From 219df0662031e6d181b62bad44ff57b24c5fa9ca Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:41:33 -0700 Subject: [PATCH 1/5] fix(core): add busy_timeout + WAL to workspace SQLite connections (#648) Core workspace connections used bare sqlite3.connect() with no busy_timeout and no WAL journaling, so under parallel batch execution a concurrent writer got an immediate "database is locked" OperationalError instead of waiting. This matches platform_store/database.py, which already sets WAL + 5s timeout. - Add private _open_db() helper in workspace.py (WAL + busy_timeout=5000) and route all 6 direct connects + get_db_connection through it. Pass db_path unchanged (no str() coercion) to preserve the prior connect semantics. - Route external callers through the shared path: budget.py via get_db_connection(workspace); costs_v2.py via _open_db(db_path). - Add a ThreadPoolExecutor parallel-writers test asserting no immediate database-locked error and that all writes persist. Also fix the staging/production Deploy workflow, which has been failing the health check since #643: the backend now hard-fails on the default AUTH_SECRET when auth is enabled (the default), but deploy.yml never wrote AUTH_SECRET into the generated env file. Write AUTH_SECRET (from the existing GitHub secret) into .env.staging/.env.production, fail fast with a clear message when it is unset, and document it in .env.staging.example. --- .env.staging.example | 6 +++ .github/workflows/deploy.yml | 32 ++++++++++++++ codeframe/adapters/e2b/budget.py | 6 ++- codeframe/core/workspace.py | 33 ++++++++++++--- codeframe/ui/routers/costs_v2.py | 6 +-- tests/core/test_workspace.py | 72 ++++++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 11 deletions(-) diff --git a/.env.staging.example b/.env.staging.example index 9d427756..e1eebc29 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -4,6 +4,12 @@ # Anthropic API Key (required for Lead Agent functionality) ANTHROPIC_API_KEY=your-anthropic-api-key-here +# Authentication secret (REQUIRED) +# The backend hard-fails on startup if this is unset while auth is enabled +# (auth is enabled by default) — see issue #643. Generate with: +# openssl rand -hex 32 +AUTH_SECRET=your-secure-random-secret-here + # Database Path (staging database location) DATABASE_PATH=/home/frankbria/projects/codeframe/staging/.codeframe/state.db diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 03e6e3f1..0cf23e56 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,6 +63,7 @@ jobs: REMOTE_PATH: ${{ secrets.PROJECT_PATH }} ENV_ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ENV_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }} + ENV_AUTH_SECRET: ${{ secrets.AUTH_SECRET }} ENV_DATABASE_PATH: ${{ secrets.DATABASE_PATH }} ENV_API_HOST: ${{ secrets.API_HOST }} ENV_API_PORT: ${{ secrets.API_PORT }} @@ -79,6 +80,16 @@ jobs: run: | echo "📝 Creating .env.staging file..." + # Fail fast if the AUTH_SECRET secret is missing/empty. The backend + # hard-fails on the default secret when auth is enabled (issue #643), + # which otherwise only surfaces as a health-check timeout much later. + if [ -z "${ENV_AUTH_SECRET}" ]; then + echo "❌ AUTH_SECRET GitHub Actions secret is not set (or empty)." + echo " Set it to a secure random value, e.g.:" + echo " gh secret set AUTH_SECRET --body \"\$(openssl rand -hex 32)\"" + exit 1 + fi + # Build env file content safely using printf (no shell interpretation) ENV_CONTENT=$(printf '%s\n' \ "# CodeFRAME Environment Configuration" \ @@ -88,6 +99,11 @@ jobs: "ANTHROPIC_API_KEY=${ENV_ANTHROPIC_KEY}" \ "OPENAI_API_KEY=${ENV_OPENAI_KEY}" \ "" \ + "# Authentication" \ + "# Required: the backend hard-fails on the default secret when auth" \ + "# is enabled (the default) — see issue #643." \ + "AUTH_SECRET=${ENV_AUTH_SECRET}" \ + "" \ "# Database Configuration" \ "DATABASE_PATH=${ENV_DATABASE_PATH}" \ "" \ @@ -277,6 +293,7 @@ jobs: REMOTE_PATH: ${{ secrets.PROJECT_PATH }} ENV_ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ENV_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }} + ENV_AUTH_SECRET: ${{ secrets.AUTH_SECRET }} ENV_DATABASE_PATH: ${{ secrets.DATABASE_PATH }} ENV_API_HOST: ${{ secrets.API_HOST }} ENV_API_PORT: ${{ secrets.API_PORT }} @@ -291,6 +308,16 @@ jobs: run: | echo "📝 Creating .env.production file..." + # Fail fast if the AUTH_SECRET secret is missing/empty. The backend + # hard-fails on the default secret when auth is enabled (issue #643), + # which otherwise only surfaces as a health-check timeout much later. + if [ -z "${ENV_AUTH_SECRET}" ]; then + echo "❌ AUTH_SECRET GitHub Actions secret is not set (or empty)." + echo " Set it to a secure random value, e.g.:" + echo " gh secret set AUTH_SECRET --body \"\$(openssl rand -hex 32)\"" + exit 1 + fi + # Build env file content safely using printf (no shell interpretation) ENV_CONTENT=$(printf '%s\n' \ "# CodeFRAME Environment Configuration" \ @@ -300,6 +327,11 @@ jobs: "ANTHROPIC_API_KEY=${ENV_ANTHROPIC_KEY}" \ "OPENAI_API_KEY=${ENV_OPENAI_KEY}" \ "" \ + "# Authentication" \ + "# Required: the backend hard-fails on the default secret when auth" \ + "# is enabled (the default) — see issue #643." \ + "AUTH_SECRET=${ENV_AUTH_SECRET}" \ + "" \ "# Database Configuration" \ "DATABASE_PATH=${ENV_DATABASE_PATH}" \ "" \ diff --git a/codeframe/adapters/e2b/budget.py b/codeframe/adapters/e2b/budget.py index f3f04d62..9f4285f0 100644 --- a/codeframe/adapters/e2b/budget.py +++ b/codeframe/adapters/e2b/budget.py @@ -10,6 +10,8 @@ from datetime import datetime, timezone from typing import Any +from codeframe.core.workspace import get_db_connection + def record_cloud_run( workspace: Any, @@ -32,7 +34,7 @@ def record_cloud_run( scan_blocked: Number of files blocked by credential scanner. """ created_at = datetime.now(timezone.utc).isoformat() - conn = sqlite3.connect(workspace.db_path) + conn = get_db_connection(workspace) try: conn.execute( """ @@ -60,7 +62,7 @@ def get_cloud_run(workspace: Any, run_id: str) -> dict | None: Returns: Dict with cloud run fields, or None if not found. """ - conn = sqlite3.connect(workspace.db_path) + conn = get_db_connection(workspace) conn.row_factory = sqlite3.Row try: row = conn.execute( diff --git a/codeframe/core/workspace.py b/codeframe/core/workspace.py index adf372aa..0fe57996 100644 --- a/codeframe/core/workspace.py +++ b/codeframe/core/workspace.py @@ -59,6 +59,27 @@ def _get_state_dir(repo_path: Path) -> Path: return repo_path / CODEFRAME_DIR +def _open_db(db_path: str | Path) -> sqlite3.Connection: + """Open a workspace SQLite connection with concurrency safeguards. + + Mirrors ``codeframe/platform_store/database.py``: enables WAL journaling + (readers don't block writers) and a 5s ``busy_timeout`` so a concurrent + writer waits for the lock instead of immediately raising + ``database is locked``. Used under parallel batch execution where multiple + processes and background agent threads write the same workspace DB. + + The caller is responsible for closing the connection. + """ + # NOTE: pass ``db_path`` through unchanged (sqlite3.connect accepts both str + # and PathLike). Do NOT wrap in ``str()`` — that would coerce a non-path + # (e.g. a test's MagicMock) into a literal filename and silently create a + # junk DB file instead of raising, diverging from the prior connect call. + conn = sqlite3.connect(db_path) + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA busy_timeout = 5000") + return conn + + def _init_database(db_path: Path) -> None: """Initialize the workspace SQLite database with v2 schema. @@ -70,7 +91,7 @@ def _init_database(db_path: Path) -> None: - blockers: Human-in-the-loop blockers - checkpoints: State snapshots """ - conn = sqlite3.connect(db_path) + conn = _open_db(db_path) cursor = conn.cursor() # Workspace metadata @@ -408,7 +429,7 @@ def _ensure_schema_upgrades(db_path: Path) -> None: This function is idempotent and adds any new tables/columns that were added after the initial schema creation. """ - conn = sqlite3.connect(db_path) + conn = _open_db(db_path) cursor = conn.cursor() # Check if batch_runs table exists, if not create it @@ -767,7 +788,7 @@ def create_or_load_workspace(repo_path: Path, tech_stack: Optional[str] = None) workspace_id = str(uuid.uuid4()) now = _utc_now().isoformat() - conn = sqlite3.connect(db_path) + conn = _open_db(db_path) cursor = conn.cursor() cursor.execute( "INSERT INTO workspace (id, repo_path, tech_stack, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", @@ -807,7 +828,7 @@ def get_workspace(repo_path: Path) -> Workspace: # Ensure schema is up to date for existing workspaces _ensure_schema_upgrades(db_path) - conn = sqlite3.connect(db_path) + conn = _open_db(db_path) cursor = conn.cursor() cursor.execute("SELECT id, repo_path, tech_stack, created_at FROM workspace LIMIT 1") row = cursor.fetchone() @@ -836,7 +857,7 @@ def get_db_connection(workspace: Workspace) -> sqlite3.Connection: Returns: SQLite connection """ - return sqlite3.connect(workspace.db_path) + return _open_db(workspace.db_path) def workspace_exists(repo_path: Path) -> bool: @@ -875,7 +896,7 @@ def update_workspace_tech_stack(repo_path: Path, tech_stack: Optional[str]) -> W now = _utc_now().isoformat() - conn = sqlite3.connect(db_path) + conn = _open_db(db_path) cursor = conn.cursor() cursor.execute( "UPDATE workspace SET tech_stack = ?, updated_at = ?", diff --git a/codeframe/ui/routers/costs_v2.py b/codeframe/ui/routers/costs_v2.py index 38028925..8e6adee0 100644 --- a/codeframe/ui/routers/costs_v2.py +++ b/codeframe/ui/routers/costs_v2.py @@ -23,7 +23,7 @@ from pydantic import BaseModel from codeframe.core import tasks as tasks_module -from codeframe.core.workspace import Workspace +from codeframe.core.workspace import Workspace, _open_db from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.platform_store.repositories.token_repository import TokenRepository from codeframe.ui.dependencies import get_v2_workspace @@ -79,7 +79,7 @@ def _query_costs(db_path: str, days: int) -> Dict: Remove this workaround once the two schemas converge. """ try: - conn = sqlite3.connect(db_path) + conn = _open_db(db_path) conn.row_factory = sqlite3.Row except sqlite3.Error as e: logger.warning("costs: failed to open %s: %s", db_path, e) @@ -184,7 +184,7 @@ def _open_workspace_conn(db_path: str) -> Optional[sqlite3.Connection]: fall back to an empty response rather than 500'ing the dashboard. """ try: - conn = sqlite3.connect(db_path) + conn = _open_db(db_path) conn.row_factory = sqlite3.Row return conn except sqlite3.Error as e: diff --git a/tests/core/test_workspace.py b/tests/core/test_workspace.py index 9a358af5..80b6aab4 100644 --- a/tests/core/test_workspace.py +++ b/tests/core/test_workspace.py @@ -175,3 +175,75 @@ def test_returns_correct_path(self, initialized_workspace: Workspace, temp_repo: def test_path_exists(self, initialized_workspace: Workspace): assert initialized_workspace.db_path.exists() + + +class TestConcurrentWriters: + """Concurrency guards on workspace connections (issue #648).""" + + def test_connection_sets_wal_and_busy_timeout(self, initialized_workspace: Workspace): + """get_db_connection should enable WAL journaling and a busy timeout.""" + conn = get_db_connection(initialized_workspace) + try: + journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0] + busy_timeout = conn.execute("PRAGMA busy_timeout").fetchone()[0] + finally: + conn.close() + + assert journal_mode.lower() == "wal" + assert busy_timeout >= 5000 + + def test_parallel_writers_do_not_raise_database_locked( + self, initialized_workspace: Workspace + ): + """Parallel writers should wait on the busy timeout instead of failing + immediately with ``database is locked``.""" + import time + from concurrent.futures import ThreadPoolExecutor + + ws = initialized_workspace + + # Dedicated probe table so the test is independent of the v2 schema. + setup = get_db_connection(ws) + try: + setup.execute( + "CREATE TABLE concurrency_probe " + "(id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER, n INTEGER)" + ) + setup.commit() + finally: + setup.close() + + n_threads = 8 + writes_per_thread = 15 + errors: list[Exception] = [] + + def writer(thread_id: int) -> None: + try: + conn = get_db_connection(ws) + try: + for n in range(writes_per_thread): + conn.execute( + "INSERT INTO concurrency_probe (thread_id, n) VALUES (?, ?)", + (thread_id, n), + ) + conn.commit() + time.sleep(0.001) # widen the window for contention + finally: + conn.close() + except sqlite3.OperationalError as exc: # pragma: no cover - failure path + errors.append(exc) + + with ThreadPoolExecutor(max_workers=n_threads) as executor: + futures = [executor.submit(writer, i) for i in range(n_threads)] + for future in futures: + future.result() + + assert not errors, f"concurrent writers raised OperationalError: {errors}" + + # Every write should have landed. + verify = get_db_connection(ws) + try: + count = verify.execute("SELECT COUNT(*) FROM concurrency_probe").fetchone()[0] + finally: + verify.close() + assert count == n_threads * writes_per_thread From 0b5ff85f6ac18c3e5736fbe1ee5700097ee68261 Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:19:47 -0700 Subject: [PATCH 2/5] test(core): make #648 concurrency test deterministic; clarify _open_db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original ThreadPoolExecutor stress test (8 writers) was flaky: SQLite's busy handler is not fair, so under heavy contention one writer can be starved past the 5s busy_timeout and still raise "database is locked" — even with the fix. Investigation also showed Python's sqlite3.connect already defaults to a 5s busy_timeout, so the substantive fix here is enabling WAL journaling (which removes the rollback-journal reader/writer case that fails immediately). Replace the stress test with a deterministic test: one thread holds the write lock (BEGIN IMMEDIATE) briefly while a second writer must wait for it and then succeed. Update the _open_db docstring to reflect that WAL is the real change. --- codeframe/core/workspace.py | 14 ++++--- tests/core/test_workspace.py | 78 ++++++++++++++++++++---------------- 2 files changed, 53 insertions(+), 39 deletions(-) diff --git a/codeframe/core/workspace.py b/codeframe/core/workspace.py index 0fe57996..c8addccb 100644 --- a/codeframe/core/workspace.py +++ b/codeframe/core/workspace.py @@ -62,11 +62,15 @@ def _get_state_dir(repo_path: Path) -> Path: def _open_db(db_path: str | Path) -> sqlite3.Connection: """Open a workspace SQLite connection with concurrency safeguards. - Mirrors ``codeframe/platform_store/database.py``: enables WAL journaling - (readers don't block writers) and a 5s ``busy_timeout`` so a concurrent - writer waits for the lock instead of immediately raising - ``database is locked``. Used under parallel batch execution where multiple - processes and background agent threads write the same workspace DB. + Mirrors ``codeframe/platform_store/database.py``. The substantive change is + enabling **WAL journaling**: readers no longer block writers, which removes + the rollback-journal case where a writer hits ``database is locked`` + immediately (the busy handler is skipped for that reader/writer deadlock). + WAL is a persistent, database-level setting, so applying it on every + connection is idempotent. ``busy_timeout`` is set to 5000ms to match + platform_store and make the value explicit (Python's ``sqlite3.connect`` + already defaults to a 5s timeout). Matters under parallel batch execution + where multiple processes and background agent threads write the same DB. The caller is responsible for closing the connection. """ diff --git a/tests/core/test_workspace.py b/tests/core/test_workspace.py index 80b6aab4..a4aeac99 100644 --- a/tests/core/test_workspace.py +++ b/tests/core/test_workspace.py @@ -192,13 +192,21 @@ def test_connection_sets_wal_and_busy_timeout(self, initialized_workspace: Works assert journal_mode.lower() == "wal" assert busy_timeout >= 5000 - def test_parallel_writers_do_not_raise_database_locked( + def test_writer_waits_for_held_lock_instead_of_failing( self, initialized_workspace: Workspace ): - """Parallel writers should wait on the busy timeout instead of failing - immediately with ``database is locked``.""" + """A second writer should *wait* for a briefly-held write lock and then + succeed — not fail immediately with ``database is locked``. + + This is deterministic by design: one thread holds the write lock + (``BEGIN IMMEDIATE``) for well under the busy timeout, and the second + writer must block until the holder commits. A high-concurrency stress + test was avoided on purpose — SQLite's busy handler is not fair, so + N aggressively-contending writers can starve one past any fixed timeout, + which would make the test flaky without telling us anything new. + """ + import threading import time - from concurrent.futures import ThreadPoolExecutor ws = initialized_workspace @@ -206,44 +214,46 @@ def test_parallel_writers_do_not_raise_database_locked( setup = get_db_connection(ws) try: setup.execute( - "CREATE TABLE concurrency_probe " - "(id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER, n INTEGER)" + "CREATE TABLE lock_probe (id INTEGER PRIMARY KEY, label TEXT)" ) setup.commit() finally: setup.close() - n_threads = 8 - writes_per_thread = 15 - errors: list[Exception] = [] + lock_acquired = threading.Event() + hold_seconds = 0.5 # comfortably under the 5s busy timeout - def writer(thread_id: int) -> None: + def hold_write_lock() -> None: + conn = get_db_connection(ws) + conn.isolation_level = None # take manual control of the transaction try: - conn = get_db_connection(ws) - try: - for n in range(writes_per_thread): - conn.execute( - "INSERT INTO concurrency_probe (thread_id, n) VALUES (?, ?)", - (thread_id, n), - ) - conn.commit() - time.sleep(0.001) # widen the window for contention - finally: - conn.close() - except sqlite3.OperationalError as exc: # pragma: no cover - failure path - errors.append(exc) - - with ThreadPoolExecutor(max_workers=n_threads) as executor: - futures = [executor.submit(writer, i) for i in range(n_threads)] - for future in futures: - future.result() - - assert not errors, f"concurrent writers raised OperationalError: {errors}" - - # Every write should have landed. + conn.execute("BEGIN IMMEDIATE") + conn.execute("INSERT INTO lock_probe (id, label) VALUES (1, 'holder')") + lock_acquired.set() + time.sleep(hold_seconds) + conn.execute("COMMIT") + finally: + conn.close() + + holder = threading.Thread(target=hold_write_lock) + holder.start() + assert lock_acquired.wait(timeout=5), "holder never acquired the write lock" + + # The lock is held now. Without the busy timeout this write would raise + # ``database is locked`` immediately; with it, the write blocks until the + # holder commits (~hold_seconds) and then succeeds. + conn2 = get_db_connection(ws) + try: + conn2.execute("INSERT INTO lock_probe (id, label) VALUES (2, 'waiter')") + conn2.commit() + finally: + conn2.close() + holder.join(timeout=5) + + # Both writes landed — the waiter was not dropped. verify = get_db_connection(ws) try: - count = verify.execute("SELECT COUNT(*) FROM concurrency_probe").fetchone()[0] + count = verify.execute("SELECT COUNT(*) FROM lock_probe").fetchone()[0] finally: verify.close() - assert count == n_threads * writes_per_thread + assert count == 2 From 6a4ab846a1b99a2f523f66203420e3c938516a35 Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:21:21 -0700 Subject: [PATCH 3/5] ci(deploy): reject whitespace-only AUTH_SECRET in deploy guard Addresses CodeRabbit review: _read_auth_secret() treats blank/whitespace-only AUTH_SECRET as unset and falls back to the default secret, so a whitespace-only GitHub secret would pass the `-z` guard yet still hard-fail the backend at startup. Catch it in CI for clearer feedback (both staging and production). --- .github/workflows/deploy.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0cf23e56..da4dbaaa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -83,8 +83,8 @@ jobs: # Fail fast if the AUTH_SECRET secret is missing/empty. The backend # hard-fails on the default secret when auth is enabled (issue #643), # which otherwise only surfaces as a health-check timeout much later. - if [ -z "${ENV_AUTH_SECRET}" ]; then - echo "❌ AUTH_SECRET GitHub Actions secret is not set (or empty)." + if [ -z "${ENV_AUTH_SECRET}" ] || ! printf '%s' "${ENV_AUTH_SECRET}" | grep -q '[^[:space:]]'; then + echo "❌ AUTH_SECRET GitHub Actions secret is not set (or blank/whitespace-only)." echo " Set it to a secure random value, e.g.:" echo " gh secret set AUTH_SECRET --body \"\$(openssl rand -hex 32)\"" exit 1 @@ -311,8 +311,8 @@ jobs: # Fail fast if the AUTH_SECRET secret is missing/empty. The backend # hard-fails on the default secret when auth is enabled (issue #643), # which otherwise only surfaces as a health-check timeout much later. - if [ -z "${ENV_AUTH_SECRET}" ]; then - echo "❌ AUTH_SECRET GitHub Actions secret is not set (or empty)." + if [ -z "${ENV_AUTH_SECRET}" ] || ! printf '%s' "${ENV_AUTH_SECRET}" | grep -q '[^[:space:]]'; then + echo "❌ AUTH_SECRET GitHub Actions secret is not set (or blank/whitespace-only)." echo " Set it to a secure random value, e.g.:" echo " gh secret set AUTH_SECRET --body \"\$(openssl rand -hex 32)\"" exit 1 From 1aeca53148b0cc56433475cd8bcb344efcf67b4a Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:05:50 -0700 Subject: [PATCH 4/5] refactor(core): expose get_db_connection_by_path; drop private cross-module import Addresses Claude PR review: costs_v2.py imported the private _open_db() across package boundaries (fragile if renamed/inlined). Add a public get_db_connection_by_path(db_path) accessor in workspace.py (parallels get_db_connection but takes a raw path, for the costs router's fresh/locked-DB tolerant helpers) and route costs_v2.py through it. _open_db stays the private primitive used internally. --- codeframe/core/workspace.py | 10 ++++++++++ codeframe/ui/routers/costs_v2.py | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/codeframe/core/workspace.py b/codeframe/core/workspace.py index c8addccb..dd252921 100644 --- a/codeframe/core/workspace.py +++ b/codeframe/core/workspace.py @@ -864,6 +864,16 @@ def get_db_connection(workspace: Workspace) -> sqlite3.Connection: return _open_db(workspace.db_path) +def get_db_connection_by_path(db_path: str | Path) -> sqlite3.Connection: + """Open a workspace DB connection from a raw path (WAL + busy_timeout). + + Same connection setup as :func:`get_db_connection`, for callers that hold a + path rather than a :class:`Workspace` (e.g. the costs router's helpers that + tolerate fresh/locked DBs). The caller is responsible for closing it. + """ + return _open_db(db_path) + + def workspace_exists(repo_path: Path) -> bool: """Check if a workspace exists at the given path. diff --git a/codeframe/ui/routers/costs_v2.py b/codeframe/ui/routers/costs_v2.py index 8e6adee0..cd0b0e75 100644 --- a/codeframe/ui/routers/costs_v2.py +++ b/codeframe/ui/routers/costs_v2.py @@ -23,7 +23,7 @@ from pydantic import BaseModel from codeframe.core import tasks as tasks_module -from codeframe.core.workspace import Workspace, _open_db +from codeframe.core.workspace import Workspace, get_db_connection_by_path from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.platform_store.repositories.token_repository import TokenRepository from codeframe.ui.dependencies import get_v2_workspace @@ -79,7 +79,7 @@ def _query_costs(db_path: str, days: int) -> Dict: Remove this workaround once the two schemas converge. """ try: - conn = _open_db(db_path) + conn = get_db_connection_by_path(db_path) conn.row_factory = sqlite3.Row except sqlite3.Error as e: logger.warning("costs: failed to open %s: %s", db_path, e) @@ -184,7 +184,7 @@ def _open_workspace_conn(db_path: str) -> Optional[sqlite3.Connection]: fall back to an empty response rather than 500'ing the dashboard. """ try: - conn = _open_db(db_path) + conn = get_db_connection_by_path(db_path) conn.row_factory = sqlite3.Row return conn except sqlite3.Error as e: From 3ca36c39ba9c2bd9980a922854b01fc81fa6dbd0 Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:12:05 -0700 Subject: [PATCH 5/5] test(core): add explicit v2 marker to test_workspace.py per CLAUDE.md CLAUDE.md asks new v2 tests to carry the marker explicitly (module-level pytestmark), even though tests/core/ is also auto-marked by conftest. Addresses the repeated CodeRabbit finding by following the documented project convention. --- tests/core/test_workspace.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/core/test_workspace.py b/tests/core/test_workspace.py index a4aeac99..e939dc7f 100644 --- a/tests/core/test_workspace.py +++ b/tests/core/test_workspace.py @@ -15,6 +15,10 @@ STATE_DB_NAME, ) +# Per CLAUDE.md, new v2 tests carry the marker explicitly (tests/core/ is also +# auto-marked v2 by conftest, but the convention makes the intent self-evident). +pytestmark = pytest.mark.v2 + @pytest.fixture def temp_repo(tmp_path: Path) -> Path: