diff --git a/Makefile b/Makefile index 9bc6cb2..e2f3d5f 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/backend/openbot/cli.py b/backend/openbot/cli.py index 31c45cf..ff7b916 100644 --- a/backend/openbot/cli.py +++ b/backend/openbot/cli.py @@ -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)) @@ -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) diff --git a/backend/openbot/config.py b/backend/openbot/config.py index 3e992fb..293e53d 100644 --- a/backend/openbot/config.py +++ b/backend/openbot/config.py @@ -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: :). @@ -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 @@ -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): diff --git a/backend/openbot/runtime/actors.py b/backend/openbot/runtime/actors.py index 5b7e60e..e144fce 100644 --- a/backend/openbot/runtime/actors.py +++ b/backend/openbot/runtime/actors.py @@ -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) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index fbe7576..e79ac33 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -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 diff --git a/frontend/electron/main.cjs b/frontend/electron/main.cjs index bbe4902..eb4fbca 100644 --- a/frontend/electron/main.cjs +++ b/frontend/electron/main.cjs @@ -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)); } diff --git a/frontend/electron/main.test.ts b/frontend/electron/main.test.ts index ce839d6..ad98795 100644 --- a/frontend/electron/main.test.ts +++ b/frontend/electron/main.test.ts @@ -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", () => { diff --git a/scripts/electron-backend.sh b/scripts/electron-backend.sh index 1bb1f6b..7f4d4ac 100755 --- a/scripts/electron-backend.sh +++ b/scripts/electron-backend.sh @@ -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 @@ -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" diff --git a/scripts/electron-dev.sh b/scripts/electron-dev.sh index ac45b9f..fdf07b3 100755 --- a/scripts/electron-dev.sh +++ b/scripts/electron-dev.sh @@ -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}" @@ -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}"