Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.PHONY: dev backend frontend app electron electron-package test smoke build run lint setup reset_db sync_bots --include-llm-call-details

ELECTRON_DETAIL_ARGS := $(if $(filter --include-llm-call-details,$(MAKECMDGOALS)),--include-llm-call-details,)
ELECTRON_ROOT_ARGS := $(if $(ROOT_DIRECTORY),--root-directory "$(ROOT_DIRECTORY)",)

setup: ## install backend and frontend dependencies, create .env from the template
cd backend && uv sync
Expand All @@ -17,7 +18,7 @@ frontend: ## Vite dev server, proxies /api to the backend
cd frontend && pnpm dev

electron: ## launch Electron with a Vite frontend and dedicated backend on :8001 (override ELECTRON_BACKEND_PORT)
./scripts/electron-dev.sh $(ELECTRON_DETAIL_ARGS)
./scripts/electron-dev.sh $(ELECTRON_DETAIL_ARGS) $(ELECTRON_ROOT_ARGS)

app: ## alias for `make electron`; starts the Electron frontend and backend together
$(MAKE) electron
Expand Down
3 changes: 3 additions & 0 deletions backend/openbot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
def main() -> None:
args, uvicorn_args = _parse_args()
_apply_detail_setting(args)
if args.root_directory:
os.environ["OPENBOT_ROOT_DIRECTORY"] = args.root_directory
uvicorn.run("openbot.main:app", **_uvicorn_options(uvicorn_args))


Expand All @@ -28,6 +30,7 @@ def _parse_args(argv: list[str] | None = None) -> tuple[argparse.Namespace, list
help="include per-model-call token details in activity events")
details.add_argument("--exclude-llm-call-details", action="store_true",
help="omit per-model-call token details from activity events")
parser.add_argument("--root-directory", help="root directory for default workspace and database paths")
return parser.parse_known_args(argv)


Expand Down
15 changes: 14 additions & 1 deletion backend/openbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
from typing import Annotated

from pydantic import Field, field_validator
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict

# Shared regex for validating Telegram Bot API tokens (format: <bot_id>:<token>).
Expand All @@ -14,6 +14,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=(".env", "../.env"), env_file_encoding="utf-8", extra="ignore")

database_url: str = "sqlite+aiosqlite:///./.openbot/openbot.db"
root_directory: Path | None = Field(default=None, validation_alias="OPENBOT_ROOT_DIRECTORY")
openbot_api_key: str | None = None

openai_api_key: str | None = None
Expand Down Expand Up @@ -88,6 +89,18 @@ class Settings(BaseSettings):
telegram_webhook_secret: str | None = None
telegram_transport: str = "long_polling" # "long_polling" or "webhook"

@model_validator(mode="after")
def _apply_root_directory_defaults(self):
"""Use a caller-supplied root for defaults without overriding explicit settings."""
if self.root_directory is None:
return self
root = Path(self.root_directory)
if "workspace_root" not in self.model_fields_set:
self.workspace_root = root
if "database_url" not in self.model_fields_set:
self.database_url = f"sqlite+aiosqlite:///{root / '.openbot' / 'openbot.db'}"
return self

@field_validator("cors_origins", "webhook_retry_delays", "openrouter_provider_order", mode="before")
@classmethod
def _split_csv(cls, v):
Expand Down
7 changes: 5 additions & 2 deletions backend/openbot/runtime/actors.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,10 +473,13 @@ async def cancel_run(self, run_id: str) -> bool:
if run is None:
return False
for w in self._workers.values():
if isinstance(w, BotActor) and w.current_run_id == run_id and w.current_task:
if not isinstance(w, BotActor) or w.current_run_id != run_id:
continue
task = w.current_task
if task is not None:
await activity.record(self.s, "run.cancel_requested", level="warning", thread_id=run.thread_id,
actor_id=run.actor_id, run_id=run_id, summary="cancelling the live run", live=True)
w.current_task.cancel()
task.cancel()
return True
async with self.s.session_factory() as session:
run = await session.get(Run, run_id)
Expand Down
48 changes: 48 additions & 0 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,54 @@ def test_database_url_override_is_preserved(monkeypatch):
assert settings.database_url == override


def test_root_directory_sets_default_workspace_and_database(monkeypatch, tmp_path):
monkeypatch.setenv("OPENBOT_ROOT_DIRECTORY", str(tmp_path))
monkeypatch.delenv("WORKSPACE_ROOT", raising=False)
monkeypatch.delenv("DATABASE_URL", raising=False)
settings = Settings(_env_file=None)
assert settings.workspace_root == tmp_path
assert settings.database_url == f"sqlite+aiosqlite:///{tmp_path / '.openbot' / 'openbot.db'}"


def test_root_directory_preserves_explicit_workspace_and_database(monkeypatch, tmp_path):
workspace = tmp_path / "workspace"
database = tmp_path / "custom.db"
monkeypatch.setenv("OPENBOT_ROOT_DIRECTORY", str(tmp_path))
monkeypatch.setenv("WORKSPACE_ROOT", str(workspace))
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{database}")
settings = Settings(_env_file=None)
assert settings.workspace_root == workspace
assert settings.database_url == f"sqlite+aiosqlite:///{database}"


def test_root_directory_preserves_explicit_workspace_equal_to_default(monkeypatch, tmp_path):
monkeypatch.setenv("OPENBOT_ROOT_DIRECTORY", str(tmp_path))
monkeypatch.setenv("WORKSPACE_ROOT", "./workspace")
monkeypatch.delenv("DATABASE_URL", raising=False)
settings = Settings(_env_file=None)
assert settings.workspace_root == Path("workspace")
assert settings.database_url == f"sqlite+aiosqlite:///{tmp_path / '.openbot' / 'openbot.db'}"


def test_root_directory_preserves_explicit_database_equal_to_default(monkeypatch, tmp_path):
default_database = "sqlite+aiosqlite:///./.openbot/openbot.db"
monkeypatch.setenv("OPENBOT_ROOT_DIRECTORY", str(tmp_path))
monkeypatch.delenv("WORKSPACE_ROOT", raising=False)
monkeypatch.setenv("DATABASE_URL", default_database)
settings = Settings(_env_file=None)
assert settings.workspace_root == tmp_path
assert settings.database_url == default_database


def test_without_root_directory_defaults_are_unchanged(monkeypatch):
monkeypatch.delenv("OPENBOT_ROOT_DIRECTORY", raising=False)
monkeypatch.delenv("WORKSPACE_ROOT", raising=False)
monkeypatch.delenv("DATABASE_URL", raising=False)
settings = Settings(_env_file=None)
assert settings.workspace_root == Path("./workspace")
assert settings.database_url == "sqlite+aiosqlite:///./.openbot/openbot.db"



def test_sqlite_parent_is_created_for_default_and_override(tmp_path):
from openbot.db.session import ensure_sqlite_parent
Expand Down
2 changes: 1 addition & 1 deletion frontend/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function openApprovedExternal(rawUrl) { if (isApprovedExternalUrl(rawUrl)) { voi
function startBackend() {
if (isDevelopment || usesExternalBackend) return;
const script = path.join(process.resourcesPath, "backend", "electron-backend.sh");
backendProcess = spawn("/bin/sh", [script], { detached: true, env: { ...process.env, OPENBOT_RESOURCES: process.resourcesPath, OPENBOT_USER_DATA: app.getPath("userData"), OPENBOT_BACKEND_PORT: backendPort }, stdio: "ignore" });
backendProcess = spawn("/bin/sh", [script], { detached: true, env: { ...process.env, OPENBOT_RESOURCES: process.resourcesPath, OPENBOT_USER_DATA: app.getPath("userData"), OPENBOT_BACKEND_PORT: backendPort, OPENBOT_ROOT_DIRECTORY: process.env.OPENBOT_ROOT_DIRECTORY }, stdio: "ignore" });
backendProcess.unref();
backendProcess.on("error", (error) => console.error("OpenBot backend failed to start", error));
}
Expand Down
19 changes: 19 additions & 0 deletions frontend/electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ describe("Electron launcher detail controls", () => {
});
expect(output).toContain("./scripts/electron-dev.sh --include-llm-call-details");
});

it("forwards a caller-supplied root directory", () => {
expect(devScriptSource).toContain('ROOT_ARGS+=(--root-directory "$1")');
expect(backendScriptSource).toContain('ROOT_ARGS+=(--root-directory "$OPENBOT_ROOT_DIRECTORY")');
expect(mainSource).toContain("OPENBOT_ROOT_DIRECTORY: process.env.OPENBOT_ROOT_DIRECTORY");
const output = execFileSync("make", ["-n", "electron", "ROOT_DIRECTORY=/tmp/openbot-root"], {
cwd: path.resolve(__dirname, "../.."),
encoding: "utf8",
});
expect(output).toContain('./scripts/electron-dev.sh --root-directory "/tmp/openbot-root"');
});

it("preserves root-directory argument boundaries when the path contains spaces", () => {
const output = execFileSync("make", ["-n", "electron", "ROOT_DIRECTORY=/tmp/openbot root"], {
cwd: path.resolve(__dirname, "../.."),
encoding: "utf8",
});
expect(output).toContain('./scripts/electron-dev.sh --root-directory "/tmp/openbot root"');
});
});

describe("Electron file watching", () => {
Expand Down
16 changes: 12 additions & 4 deletions scripts/electron-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ set -Eeuo pipefail
PROJECT_ROOT="${OPENBOT_RESOURCES:?OPENBOT_RESOURCES is required}"
PORT="${OPENBOT_BACKEND_PORT:-8000}"
cd "$PROJECT_ROOT"
mkdir -p "${OPENBOT_USER_DATA:?OPENBOT_USER_DATA is required}/workspace" "${OPENBOT_USER_DATA}/logs"
export DATABASE_URL="${DATABASE_URL:-sqlite+aiosqlite:///${OPENBOT_USER_DATA}/openbot.db}"
export WORKSPACE_ROOT="${WORKSPACE_ROOT:-${OPENBOT_USER_DATA}/workspace}"
if [[ -n "${OPENBOT_ROOT_DIRECTORY:-}" ]]; then
mkdir -p "$OPENBOT_ROOT_DIRECTORY/.openbot" "$OPENBOT_ROOT_DIRECTORY"
else
mkdir -p "${OPENBOT_USER_DATA:?OPENBOT_USER_DATA is required}/workspace" "${OPENBOT_USER_DATA}/logs"
export DATABASE_URL="${DATABASE_URL:-sqlite+aiosqlite:///${OPENBOT_USER_DATA}/openbot.db}"
export WORKSPACE_ROOT="${WORKSPACE_ROOT:-${OPENBOT_USER_DATA}/workspace}"
fi
export TOOLS_DIR="${TOOLS_DIR:-$PROJECT_ROOT/tools}"
export LOG_FILE="${LOG_FILE:-${OPENBOT_USER_DATA}/logs/openbot.log}"
# `uv run --project` otherwise builds .venv inside PROJECT_ROOT, i.e. Contents/Resources of the
Expand Down Expand Up @@ -44,4 +48,8 @@ DETAILS_FLAG="--exclude-llm-call-details"
if [ "${OPENBOT_INCLUDE_LLM_CALL_DETAILS:-false}" = "true" ]; then
DETAILS_FLAG="--include-llm-call-details"
fi
exec "$UV" run --project "$PROJECT_ROOT/backend" python -m openbot.cli "$DETAILS_FLAG" --host 127.0.0.1 --port "$PORT"
ROOT_ARGS=()
if [[ -n "${OPENBOT_ROOT_DIRECTORY:-}" ]]; then
ROOT_ARGS+=(--root-directory "$OPENBOT_ROOT_DIRECTORY")
fi
exec "$UV" run --project "$PROJECT_ROOT/backend" python -m openbot.cli "$DETAILS_FLAG" "${ROOT_ARGS[@]}" --host 127.0.0.1 --port "$PORT"
7 changes: 6 additions & 1 deletion scripts/electron-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ if [[ "${1:-}" == "--include-llm-call-details" ]]; then
DETAILS_FLAG="--include-llm-call-details"
shift
fi
ROOT_ARGS=()
if [[ -n "${1:-}" ]]; then
ROOT_ARGS+=(--root-directory "$1")
shift
fi
BACKEND_URL="http://localhost:${ELECTRON_BACKEND_PORT}"
FRONTEND_URL="http://localhost:${FRONTEND_PORT}"

Expand Down Expand Up @@ -78,7 +83,7 @@ PY
# without backend reload or Vite file watching: changes take effect after an app restart,
# avoiding watcher activity that can freeze the UI while messages are being sent.
echo "Starting Electron backend on ${BACKEND_URL}"
run_in_process_group uv run --project backend python -m openbot.cli "$DETAILS_FLAG" --port "$ELECTRON_BACKEND_PORT" &
run_in_process_group uv run --project backend python -m openbot.cli "$DETAILS_FLAG" "${ROOT_ARGS[@]}" --port "$ELECTRON_BACKEND_PORT" &
backend_pid=$!

echo "Starting Vite frontend on ${FRONTEND_URL}"
Expand Down
Loading