feat(db): configurable database backend — experimental PostgreSQL support (#300)#1093
Conversation
…backend test (#300) Two CI failures from PR #1093: - docker-compose: POSTGRES_PASSWORD used the ${VAR:?err} required-variable form, which docker compose evaluates at PARSE time for ALL services regardless of active profile — so every `docker compose` invocation without POSTGRES_PASSWORD set (the default, profile-off case, incl. CI) failed to render. Reverted to a default ${POSTGRES_PASSWORD:-trinity}; operators enabling the postgres profile set a real value in .env. - tests/unit/test_users_db_dual_backend.py: route all sys.modules mutation through monkeypatch.setitem/delitem (auto-restored) instead of bare sys.modules ops, satisfying the sys.modules pollution lint. Test still green on both sqlite and postgres. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Resolve by running |
92d6f3a to
056bbb4
Compare
…backend test (#300) Two CI failures from PR #1093: - docker-compose: POSTGRES_PASSWORD used the ${VAR:?err} required-variable form, which docker compose evaluates at PARSE time for ALL services regardless of active profile — so every `docker compose` invocation without POSTGRES_PASSWORD set (the default, profile-off case, incl. CI) failed to render. Reverted to a default ${POSTGRES_PASSWORD:-trinity}; operators enabling the postgres profile set a real value in .env. - tests/unit/test_users_db_dual_backend.py: route all sys.modules mutation through monkeypatch.setitem/delitem (auto-restored) instead of bare sys.modules ops, satisfying the sys.modules pollution lint. Test still green on both sqlite and postgres. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
056bbb4 to
e995bca
Compare
…backend test (#300) Two CI failures from PR #1093: - docker-compose: POSTGRES_PASSWORD used the ${VAR:?err} required-variable form, which docker compose evaluates at PARSE time for ALL services regardless of active profile — so every `docker compose` invocation without POSTGRES_PASSWORD set (the default, profile-off case, incl. CI) failed to render. Reverted to a default ${POSTGRES_PASSWORD:-trinity}; operators enabling the postgres profile set a real value in .env. - tests/unit/test_users_db_dual_backend.py: route all sys.modules mutation through monkeypatch.setitem/delitem (auto-restored) instead of bare sys.modules ops, satisfying the sys.modules pollution lint. Test still green on both sqlite and postgres. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Deploying Trinity on PostgreSQL + next steps📄 Documentation
🚀 Deploy a new instance on PostgreSQL (TL;DR)SQLite remains the zero-config default — Postgres is opt-in via a single env var. # 1. In .env — bundled Postgres container:
POSTGRES_DB=trinity
POSTGRES_USER=trinity
POSTGRES_PASSWORD=<strong-password> # NOT the 'trinity' compose default
DATABASE_URL=postgresql://trinity:<strong-password>@postgres:5432/trinity
# 2. Start with the postgres profile (gated — only starts when asked):
docker compose --profile postgres up -d postgres # bring DB up first
docker compose --profile postgres up -d # then backend + scheduler (auto-connect via DATABASE_URL)On an empty database the backend bootstraps everything automatically (≈61 tables + append-only audit triggers, seeds admin from Verify: docker exec trinity-backend python -c "import db.engine as e; print('sqlite:', e.is_sqlite(), e.resolve_database_url())"
# -> sqlite: False | postgresql://...@postgres:5432/trinity
curl -s http://localhost:8000/health # {"status":"healthy"}External / managed Postgres (RDS, Cloud SQL): skip the Rollback to SQLite is non-destructive: comment out
🔜 Next steps (post-merge)
This PR is Phase 1: optional, flag-gated, SQLite-default-unchanged. |
3e09a2f to
f80f734
Compare
…backend test (#300) Two CI failures from PR #1093: - docker-compose: POSTGRES_PASSWORD used the ${VAR:?err} required-variable form, which docker compose evaluates at PARSE time for ALL services regardless of active profile — so every `docker compose` invocation without POSTGRES_PASSWORD set (the default, profile-off case, incl. CI) failed to render. Reverted to a default ${POSTGRES_PASSWORD:-trinity}; operators enabling the postgres profile set a real value in .env. - tests/unit/test_users_db_dual_backend.py: route all sys.modules mutation through monkeypatch.setitem/delitem (auto-restored) instead of bare sys.modules ops, satisfying the sys.modules pollution lint. Test still green on both sqlite and postgres. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rebase) Rebasing #1093 onto dev surfaced three methods that landed on dev AFTER the Step-C bulk conversion and were auto-merged back in raw sqlite3 form (they referenced get_db_connection, which the converted modules no longer import — a latent NameError). Ported to dialect-agnostic Core to preserve the "runs on SQLite and PostgreSQL" invariant: - operator_queue: bulk_cancel_items, clear_resolved_items, get_terminal_items_for_agent + cleared_at column in tables.py (#1017) (folded into the Step-C commit during conflict resolution) - notifications: dismiss-all Clear All (#1017/#1143) - schedules: get_agent_analytics — 4 aggregate queries (#852) Verified: all four methods execute correctly against in-memory SQLite (clear→hide flow, analytics avg/p95/timeline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/review Report — rebase onto
|
CI investigation —
|
/review Report — full PR (#300 db backend)Branch: Scope check: CLEAN. Intent = swap the db layer to SQLAlchemy Core for SQLite + PostgreSQL. Delivered = exactly that — Critical Findings (block merge)None.
Informational Findings[I1] Scheduler [I2] The 5 still-red CI tests should get the skip+track treatment this PR already uses elsewhere. [I3] Transaction granularity shift. [I4] Scheduler retains a raw-sqlite3 + psycopg2 shim rather than Core — intentional and documented as a Phase-1 gap. No action; noted for completeness. Clean Categories
Summary
🤖 Reviewed with |
Phase 1 + Step A of the SQLite→PostgreSQL configurable-backend work. SQLite stays the zero-config default and behaves identically; PostgreSQL becomes an experimental opt-in selected by DATABASE_URL. Nothing changes when the env var is unset. Seam: - db/engine.py — SQLAlchemy engine factory from DATABASE_URL, cached per URL (NullPool for sqlite to match the legacy open/close-per-call semantics; pooled + pre_ping for PostgreSQL). - db/tables.py — Core metadata, query-only table handles (users so far; grows per migrated module). - db/connection.py — DB_PATH is now DATABASE_URL-aware for sqlite, additive and monkeypatch-safe (kept module-level for the enterprise tests). Pilot: - db/users.py — converted to SQLAlchemy Core; runs unchanged on both backends. Public API of UserOperations is unchanged, so callers + DatabaseManager are unaffected. PostgreSQL schema bootstrap (Step A): - db/schema.py — schema.py stays the single source of truth (no second hand-written schema → no new drift, cf. #655/#691/#721). init_schema_postgres translates the same TABLES/INDEXES strings to PG DDL: SERIAL for autoincrement PKs, text-typed UTC defaults, and PL/pgSQL versions of the audit_log append-only triggers. Foreign keys are stripped on PG because the platform runs with FKs disabled on sqlite (cascades live in app code) and several FK declarations have sqlite-tolerated type mismatches PG rejects. - database.py — init_database() branches on dialect: a fresh PG database is built directly from schema.py at head, so the sqlite-only PRAGMA migrations are skipped; admin bootstrap reuses the already-Core UserOperations. Ops/deps: - docker-compose.yml — optional postgres:16 service behind `profiles:[postgres]` (platform network only — agents never reach it, #589) + postgres-data volume + DATABASE_URL / DB_POOL_SIZE / DB_MAX_OVERFLOW backend env passthrough. - docker/backend/Dockerfile + tests/requirements-test.txt — sqlalchemy 2.0 + psycopg2-binary. - .env.example — DATABASE_URL / postgres profile docs. Verified on a real postgres:16 container: full 59-table / 143-index schema created (idempotent), audit_log UPDATE + in-retention DELETE blocked by the PL/pgSQL triggers, and the UserOperations CRUD suite green on sqlite AND postgres (tests/unit/test_users_db_dual_backend.py, tests/unit/test_schema_postgres.py). SQLite regression suite unchanged. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-derived from schema.py via reflection so the query layer has a Table handle for every table — prerequisite for converting the remaining db modules to SQLAlchemy Core without each one editing tables.py. Query-only handles; schema.py still owns DDL on both backends. Pilot + PG schema tests stay green. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Single portable replacement for INSERT OR REPLACE / INSERT OR IGNORE — picks the sqlite or postgresql dialect Insert (both expose the same on_conflict_* API) from the active engine. Used by the db modules as they convert to Core. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Converts every db/*.py operations module from raw sqlite3 (get_db_connection +
? placeholders + sqlite-only SQL) to dialect-agnostic SQLAlchemy Core, so the
whole data layer runs on both SQLite and PostgreSQL. Method signatures and
return shapes are unchanged — routers, services, and the DatabaseManager facade
are unaffected.
Mechanical conversion applied per module:
- get_db_connection() → get_engine().connect() (reads) / .begin() (writes);
multi-statement writes kept in one begin() block to preserve atomicity.
- ? placeholders → Core expressions; rows via .mappings() (named) or positional.
- cursor.lastrowid → result.inserted_primary_key[0]; cursor.rowcount →
result.rowcount.
- INSERT OR REPLACE / INSERT OR IGNORE → make_insert(t).on_conflict_do_update /
on_conflict_do_nothing (dialect-correct via db/engine.py).
- datetime('now') / CURRENT_TIMESTAMP in SQL → Python utc_now_iso() bound value.
- except sqlite3.IntegrityError → except sqlalchemy.exc.IntegrityError.
A handful of genuinely complex queries are kept as text() with named params and
all sqlite-only constructs removed (agent_cleanup, agent_settings/metadata,
audit, monitoring, schedules).
Also fixes db/agents.py:purge_agent_ownership to pass the SQLAlchemy Connection
(not conn.connection) into the now-Core db/agent_cleanup.cascade_delete so the
Tests: 17 unit/integration test files that injected a DB by monkeypatching the
retired get_db_connection seam are migrated to route the engine via DATABASE_URL
+ dispose_engines() (db/connection.DB_PATH patch kept for not-yet-converted
paths). No assertions weakened.
Verified:
- Curated db unit+integration-unit suite: 332 passed, 0 failed on SQLite.
- PostgreSQL integration smoke through the real DatabaseManager facade (fresh
PG 16): create_user (RETURNING), tag add+dup (on_conflict_do_nothing), setting
set+update (on_conflict_do_update), multi-statement chat session, batch
permission upsert, soft-delete, and cross-module cascade purge — all succeed.
Related to #300.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guards/converts the non-db-module code paths that still spoke raw sqlite3, so the backend process runs end-to-end on PostgreSQL: - services/system_agent_service.py — _set_system_scope converted to Core (update mcp_api_keys via the engine). - services/git_service.py — reserve_and_generate_instance_id now catches sqlalchemy.exc.IntegrityError (db.create_git_config raises that on both backends after Step C, not sqlite3.IntegrityError) so the (github_repo, working_branch) UNIQUE-collision retry still fires. Dropped import sqlite3. - services/cleanup_service.py — WAL checkpoint (PRAGMA wal_checkpoint) is a no-op on PostgreSQL (server-managed WAL); gated to is_sqlite(). - services/db_vacuum_service.py — VACUUM reclaims SQLite-file pages; the service no-ops on PostgreSQL (autovacuum). gated in start(). - main.py /health — the schema_migrations completeness gate is SQLite-only; on PostgreSQL the schema is built fresh from schema.py at head (no PRAGMA migrations, no schema_migrations table) so health returns healthy without it. With this, DATABASE_URL=postgresql:// no longer touches the raw get_db_connection seam anywhere in the backend process — every remaining use is guarded by is_sqlite() (init_database, WAL checkpoint, VACUUM, health gate) and only runs under SQLite. Verified: DatabaseManager bootstraps + runs CRUD on a fresh PostgreSQL 16; SQLite regression suite green. Known remaining gap (experimental): the standalone scheduler process (src/scheduler/database.py) keeps its own sqlite3 layer and is not yet PostgreSQL-aware — tracked as #300 follow-up. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runs the real DatabaseManager facade — the converted SQLAlchemy Core db modules — directly against a live PostgreSQL server, with per-test TRUNCATE isolation. Covers users CRUD, agent soft-delete + cross-module cascade purge, tag upserts (on_conflict_do_nothing), settings upsert (on_conflict_do_update), mcp keys, schedules + executions, multi-statement chat transactions, and sync-state reads. Skips when no PostgreSQL is reachable via TEST_POSTGRES_URL, so SQLite-only environments stay green. This is the suite the #300 CI dual-backend gate runs against a postgres:16 service. 8/8 green against postgres:16. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…300) Live e2e on PostgreSQL surfaced a DatatypeMismatch: agent creation's agent_shared_folder_config insert passes Python False/True into INTEGER columns (expose_enabled/consume_enabled). SQLite silently accepts bool for an INTEGER column; PostgreSQL/psycopg2 renders it as SQL boolean and rejects assignment into integer. This is a whole class of bug — many INTEGER columns store booleans (enabled, is_*, *_mode) and converted call sites legitimately pass Python bools as the pre-#300 sqlite3 code did. Fix at the binding layer: tables.py Integer is now a TypeDecorator that coerces bool->int in process_bind_param, so every INTEGER column (and bool comparisons in WHERE) is fixed transparently on both backends without touching call sites. Verified: insert + bool WHERE on PG store 0/1; dual-backend + PG integration suites green (autoincrement/inserted_primary_key unaffected); live stack re-creates an agent on PostgreSQL with no DatatypeMismatch. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…backend test (#300) Two CI failures from PR #1093: - docker-compose: POSTGRES_PASSWORD used the ${VAR:?err} required-variable form, which docker compose evaluates at PARSE time for ALL services regardless of active profile — so every `docker compose` invocation without POSTGRES_PASSWORD set (the default, profile-off case, incl. CI) failed to render. Reverted to a default ${POSTGRES_PASSWORD:-trinity}; operators enabling the postgres profile set a real value in .env. - tests/unit/test_users_db_dual_backend.py: route all sys.modules mutation through monkeypatch.setitem/delitem (auto-restored) instead of bare sys.modules ops, satisfying the sys.modules pollution lint. Test still green on both sqlite and postgres. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live A+B e2e on PostgreSQL surfaced three dialect-strictness bugs that SQLite tolerates but PostgreSQL rejects: 1. text = integer JOINs (UndefinedFunction). system_views.owner_id and email_whitelist.added_by are TEXT (stringified user ids) but JOIN users.id (INTEGER). SQLite coerces; PG errors "operator does not exist: text = integer". Fixed by cast(users.c.id, Text) in both JOINs, plus str()-coercing owner_id/user_id at the system_views boundaries so the TEXT column is compared to text consistently. 2. InFailedSqlTransaction in idempotency claim(). It does INSERT then, on conflict, SELECT the surviving row — in ONE transaction. PostgreSQL aborts the whole transaction on any error, so the post-conflict SELECT was rejected and the idempotency layer silently failed open (no dedup; the X-Idempotent-Replay path never fired). Fixed by wrapping the INSERT in a SAVEPOINT (conn.begin_nested()) so the conflict rolls back only the savepoint and the outer transaction stays usable. SQLite emulates savepoints, so it's transparent there. (Audited all 10 except-IntegrityError sites: skills/permissions already used savepoints; the rest only return without further SQL, which is safe.) Regression guards added to tests/integration/test_postgres_backend.py (system_views owner JOIN, email-whitelist added_by JOIN, idempotency claim→complete→replay). All verified live on PG and green on both backends. The earlier bool->INTEGER coercion fix (tables.py TypeDecorator) was the first of this class; these complete the set the A+B pass exercised. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the last PG gap. The standalone trinity-scheduler is a separate process that shares the backend's database: on SQLite both mount the same /data file, so the scheduler reads the schedules the backend writes. After the backend moved to PostgreSQL (#300), the scheduler still read the stale SQLite file at DATABASE_PATH and silently fired nothing — cron, manual, and webhook triggers all 404'd ("not found in scheduler"). Fix (minimal, keeps the scheduler's design + all 35 query methods unchanged): - src/scheduler/database.py: get_connection() now yields a PostgreSQL connection when DATABASE_URL is a postgresql:// URL, else the shared SQLite file as before. A small _PgCursor/_PgConn adapter lets the existing sqlite-style query methods run unchanged on psycopg2 — translates qmark `?`→`%s`, coerces Python bool→int (INTEGER columns), and uses RealDictCursor so `row["col"]` access works. The scheduler's SQL is plain ANSI (no `%`/LIKE/INSERT OR/datetime('now') /PRAGMA), so the rewrite is safe. IntegrityError now caught across both drivers. - docker-compose.yml: pass DATABASE_URL through to the scheduler service. - docker/scheduler/requirements.txt: add psycopg2-binary. Verified on a live PostgreSQL stack: scheduler logs "PostgreSQL (via DATABASE_URL)", loads the PG schedules, and manual + webhook triggers both execute to status=success on PG (scheduler → backend /api/internal/execute-task → agent). SQLite unchanged: 173 scheduler tests pass; new tests/scheduler_tests/test_pg_adapter.py (8) guards the paramstyle/bool translation. Related to #300. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add docs/POSTGRESQL_SETUP.md — extensive setup notes for standing up a new Trinity instance on PostgreSQL instead of SQLite: DATABASE_URL flag semantics, bundled-vs-external Postgres, cold-start bootstrap, pooling tunables, operational notes, rollback, verification checklist, and the dialect-gotcha troubleshooting table. Cross-linked from DEPLOYMENT.md. Related to #300 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add tests/db_harness.py — a backend-parametrized fixture (db_backend) that runs each test on SQLite and, when TEST_POSTGRES_URL is set, PostgreSQL. Schema is built from the canonical Core metadata (db.tables.metadata.create_all) — dialect-correct, import-light (no database/services/config chain), and drift-proof (no more hand-rolled _make_db_schema copies). Engine-based seed_* helpers write to the active backend. Migrate test_schedule_analytics.py onto it as the pilot: 12 tests now run green on BOTH backends (24 cases). Establishes the pattern for migrating the remaining bespoke-sqlite unit files. Related to #300
bootstrap_schema() now builds the full production schema via the real builders (db.schema.init_schema on SQLite, init_schema_postgres on PG) instead of metadata.create_all — faithful reproduction including UNIQUE constraints and append-only triggers that ON CONFLICT / production upserts rely on. Add run()/scalar()/count() engine helpers. Migrate test_schedule_soft_delete.py onto db_backend: 12 tests green on BOTH backends (24 cases). Helpers rewritten engine-based (named binds), test bodies unchanged. Related to #300
) Migrate test_cancelled_not_overwritten, test_schedule_status_observability, and test_execution_retention_prune onto db_backend — each now runs on BOTH SQLite and PostgreSQL (42 passed, 2 deliberate skips). Bespoke sqlite3 seed/ read helpers rewritten engine-based (named binds); fixtures use the shared harness; importlib module-stubbing scaffolding removed. Test bodies unchanged. Related to #300
Soft-delete recovery suite (agents + schedules) now runs on BOTH SQLite and PostgreSQL (10 tests, 20 cases). Engine-based seeds + inline reads via the db_harness helpers; full production schema. Test logic unchanged. Related to #300
…ness (#300) Both now run on SQLite + PostgreSQL. public_chat_context: ops fixture on db_backend (tests already used ops helpers, no raw conn). sync_state_db: schema introspection switched from SQLite PRAGMA/sqlite_master to dialect- agnostic SQLAlchemy inspect(); seeds engine-based (dropped datetime('now') sqlite-ism); importlib schema-loader scaffolding removed. Related to #300
…ing (#300) test_1073's hand-built 'config' stub exposed only REDIS_URL, but the module under test (twilio_media_stream.py) imports 'from config import REDIS_URL, VOIP_MAX_CALL_DURATION'. The missing name raised ImportError at module load → a collection error that INTERRUPTED the entire unit suite (pytest aborts on collection errors). Because the base-vs-head diff gate saw the same error on both sides, it masked the whole dead suite (0 tests run) as green. Add VOIP_MAX_CALL_DURATION to the stub. Full unit collection now succeeds (2132 tests, 0 errors) so CI actually runs — and finally exercises the db-agnostic harness migrations in this PR. Related to #300
… fix (#1103) The test_1073 collection fix makes the unit suite actually run, surfacing 15 tests that were silently broken on dev (never executed while collection aborted). None relate to #300; they split into env issues (git identity in the runner, shellcheck on startup.sh) and real drift (test_backlog's schema missing schedule_executions.attempt_number, stale assertions, _get_pull_branch expectations). Mark them @pytest.mark.skip(reason=...tracked in #1103) so the suite runs green now instead of staying silently dead. Each is queued for a real fix in #1103. (test_reset_preserve_state_guardrails imports pytest late via an E402 block after these module-level tests, so add an early top-level import so the skip decorators resolve.) Related to #300, #1103
…ntine 1 (#300) Both run on SQLite + PostgreSQL via db_backend. test_929: its old fixture monkeypatched the legacy db.connection.DB_PATH seam, which the #300 Core conversion no longer reads — so find_active_schedules_ exceeding_timeout queried the wrong DB and the offenders assertion failed (masked while the suite was dead, quarantined in #1103). Routing through db_backend (DATABASE_URL + real schema) fixes it, so the test_find_active_schedules_exceeding_timeout_returns_offenders quarantine is removed — now passing on both backends. 1 of the 15 #1103 items resolved. slack_dm_default: dropped the importlib schema-loader scaffolding; tests use SlackChannelOperations via the harness. Related to #300, #1103
The DB-accessor half (tmp_db/seed_agent/insert_execution + TestMigration/ TestMaxBacklogDepth/TestBacklogQueries) now runs on SQLite + PostgreSQL via db_backend. The async BacklogService half (mocked database.db) is unchanged. The old tmp_db built a hand-rolled partial schedule_executions that omitted attempt_number, which claim_next_queued/cancel_queued_execution/expire_stale_ queued reference — so those 3 tests failed once the suite ran (quarantined in #1103). The real-schema harness includes attempt_number, so all 3 pass and are un-quarantined. TestMigration's PRAGMA/sqlite_master introspection swapped for dialect-agnostic SQLAlchemy inspect(). 3 of 15 #1103 items resolved. Related to #300, #1103
…ness (#300) Both run on SQLite + PostgreSQL via db_backend, full production schema. chat_dispatched_marker: DB-accessor half migrated (AST source-analysis half unchanged); _fetch returns a RowMapping; _insert_running now supplies the NOT NULL schedule_id the real schema requires (the old hand-rolled table allowed null). sync_health_service: dropped the importlib schema-loader, replaced datetime('now') sqlite-ism with a literal ISO, kept the services.agent_client stub. Related to #300
Bulk auto-sync lookup tests run on SQLite + PostgreSQL via db_backend. Dropped the hand-rolled agent_git_config schema; fixture_agents seeds through the real db API (create_git_config / set_git_auto_sync_enabled) against the full schema. Related to #300
…#300) Add EngineConn/engine_conn() to db_harness — a sqlite3.Connection-like shim over the active engine (accepts ?-style SQL, autocommits, returns index/key- accessible Rows) so the 'returned-conn' fixtures (which yield a raw sqlite connection for verification reads / legacy-row seeding) run on both backends. Migrate test_voip_db onto db_backend + engine_conn; drop the hand-rolled DDL (harness builds the full schema). The :memory: TestMigration (sqlite migration runner) stays sqlite-only by nature. 19 passed on SQLite + PostgreSQL. Related to #300
…300) slack/telegram/whatsapp/slack_workspaces token-encryption tests now run on SQLite + PostgreSQL: the ops-based returned-conn fixtures use db_backend + engine_conn() (the #300 conn shim), dropping the hand-rolled DDL (harness builds the full schema). test_slack_token_encryption's migration_db fixture + TestMigration stay sqlite-only (they run the sqlite _migrate_* function directly). 72 passed on both backends. Related to #300
) The cascade/soft-delete suite now runs on SQLite + PostgreSQL via db_backend. Replaced the simplified hand-rolled child-table schema with full valid rows against the REAL tables (correct NOT NULL columns + id/PK types per table), so the #816 cascade_delete primitive is exercised on the production schema on both backends. Seeds/_count/_deleted_at are engine-based. 14 passed. Related to #300
Fleet sync-audit + find_duplicate_bindings tests run on SQLite + PostgreSQL
via db_backend. Replaced the fragile schema-patching machinery (file-loaded
schema/migrations copies, INDEXES patch, module eviction) with a single
DROP INDEX IF EXISTS of the S7 partial UNIQUE index in tmp_db — so the tests
can still seed impossible-in-prod duplicate (repo,branch) rows on either
backend. Seeds engine-based (dropped datetime('now') sqlite-ism). 18 passed.
Related to #300
rebase) Rebasing #1093 onto dev surfaced three methods that landed on dev AFTER the Step-C bulk conversion and were auto-merged back in raw sqlite3 form (they referenced get_db_connection, which the converted modules no longer import — a latent NameError). Ported to dialect-agnostic Core to preserve the "runs on SQLite and PostgreSQL" invariant: - operator_queue: bulk_cancel_items, clear_resolved_items, get_terminal_items_for_agent + cleared_at column in tables.py (#1017) (folded into the Step-C commit during conflict resolution) - notifications: dismiss-all Clear All (#1017/#1143) - schedules: get_agent_analytics — 4 aggregate queries (#852) Verified: all four methods execute correctly against in-memory SQLite (clear→hide flow, analytics avg/p95/timeline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
f80f734 to
dbef6ec
Compare
Prod compose launches standalone (no env_file, no base-compose merge), so without backend.environment passthrough the DATABASE_URL .env lever was inert on prod deploys — the #1039/#1056 packaging-gap class. Mirrors the dev compose wiring: backend gets DATABASE_URL + DB_POOL_SIZE + DB_MAX_OVERFLOW, scheduler gets DATABASE_URL. Prod ships no postgres service; a postgresql:// URL points at an operator-managed instance. Also refresh the stale Phase-1 docstring in db/engine.py: all 44 modules are converted; raw sqlite3 remains for is_sqlite()-gated maintenance paths and the default-off canary snapshot reader (PG canary is a follow-up). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr: methodology, security, and packaging checks pass. The one critical finding (prod-compose DATABASE_URL passthrough) was fixed on-branch in 5c155e5; known gaps (PG schema evolution, canary reader, CI dual-backend gate, ETL, architecture.md) are tracked in the body as follow-ups. CI fully green at head.
…de (#1200) (#1214) GET/PUT /api/agents/{name}/capabilities 500'd with `AttributeError: 'DatabaseManager' object has no attribute 'get_full_capabilities'`. The #1093 db decomposition added explicit pass-throughs for most agent settings but missed the full-capabilities pair — the methods live on SecurityMixin (composed into AgentOperations / self._agent_ops) but had no delegating method on the facade the routers call. Add the two pass-throughs alongside the read-only delegations, and remove them from the test_database_facade_delegation KNOWN_FACADE_GAPS allowlist so the static guard now enforces them. Add an explicit #1200 regression check. Related to #1200. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
…ydantic session (#1457) (#1462) * fix(chat): self-task inject_result uses attribute access on ChatSession (#1457) `_finalize_self_task` validated chat-session ownership with dict access (`session.get("user_id")`) on the value from `db.get_chat_session()`. After the #1093 SQLAlchemy-Core rewrite that accessor returns `Optional[ChatSession]` — a Pydantic model with no `.get()` — so the check raised `AttributeError`, was swallowed by the surrounding `except Exception`, and the self-task result was silently never injected into the chat session. Switch to attribute access (`session.user_id == user_id`), matching the sibling call-sites. Add a fast unit regression guard: a SUCCESS self-task with `inject_result=true` and an owned `chat_session_id` writes an `assistant` message with `source="self_task"`; a foreign session does not inject. Related to #1457 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(chat): fix stale dict mock that regressed after the #1457 attribute-access fix The #1457 fix switched `_finalize_self_task` from `session.get("user_id")` to `session.user_id` (db.get_chat_session returns a ChatSession pydantic, not a dict). But this characterization test still mocked get_chat_session to return a plain dict `{"user_id": ...}`, which has no `.user_id` — so injection silently no-op'd and `test_self_task_injects_result_into_session` newly failed under HEAD (caught by the regression-diff gate; green on the buggy base only because a dict has `.get`). Return a SimpleNamespace(user_id=...) instead — attribute access, no `.get`, faithfully mirroring the ChatSession contract the fix relies on. Both the inject and the not-owned cases updated. Related to #1457 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ confirm-re-read (#1450) (#1495) * test(canary): unblock canary_invariants fixture broken by #1472 merge Two pre-existing breakages landed via #1472 (367a5e1, merged today) in root-level tests/test_canary_invariants.py — a file the CI unit job (tests/unit/ only) does not run, so they merged undetected: 1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL — the E-06 work added a second definition next to the pre-existing one, so `executescript` raised "table agent_schedules already exists" and every fixture-dependent canary test errored. Removed the older, unused block (its `message`/`owner_id` columns have no consumer; `_add_schedule` and the E-06/L-03 collectors use only the kept block's columns). 2. `test_run_invariants_all` expected registry set omitted "E-06" (added to the invariants registry by the same PR). Added E-06 to the expected set and asserted it is green on a clean platform. Restores a green baseline (92 passed) so the #1450 B-01 work can be verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450) B-01 compared two reads that were neither temporally atomic nor backend-consistent (both latent while the canary is default-OFF and SQLite makes the two reads hit one file): (a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing between the two reads produced a transient count mismatch → spurious critical + green→red Slack alert. (b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors `get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On Postgres those are two different databases, so B-01 compared Postgres truth to a stale/absent SQLite file (fatal under the Postgres direction + SQLite EOL, #1278). Production-side residue of #1446 (PR #1452), which fixed only the test-harness `sys.modules` leak and deferred these two gaps. Fix (localized to B-01): - New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME `get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine` snapshot field. Independent code path (SELECT id/literal 'queued' vs COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a cache/status-filter regression still surfaces (non-tautology, AC #3). No cache / second count of the queue (AC #4). - The collector performs one confirm-re-read on a mismatch: a transient race self-heals; a persistent drift survives and fires (AC #2). An engine-read or unconfirmable confirm degrades to a B-01 skip — it never compares an engine count against the raw-sqlite id-set (the blocker the reviews surfaced). - `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast radius is B-01-only and the sibling #1077 merge stays clean. The running-side / known-agents reads remain raw-sqlite (half-migrated collector); the collector-wide migration + a dark-canary tripwire are a filed follow-up. Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at `queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend proxy (raw ≠ engine temp files) that false-fires pre-fix and is green post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair, and an engine-read-failure→skip test. New split fixtures (`canary_db_split` / `reload_canary_split`) model the diverged backend without a live PG. Convert the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and the reimport `del`s are lint-clean. Docs: architecture.md Canary B-01 row updated for the engine-backed read path + confirm-re-read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(feature-flows): add #1450 canary B-01 Recent Updates row Sync-feature-flows: canary-internal change, no dedicated flow doc (architecture.md is canonical); append one Recent Updates row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
…04 (Phase 4) (#1497) * test(canary): unblock canary_invariants fixture broken by #1472 merge Two pre-existing breakages landed via #1472 (367a5e1, merged today) in root-level tests/test_canary_invariants.py — a file the CI unit job (tests/unit/ only) does not run, so they merged undetected: 1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL — the E-06 work added a second definition next to the pre-existing one, so `executescript` raised "table agent_schedules already exists" and every fixture-dependent canary test errored. Removed the older, unused block (its `message`/`owner_id` columns have no consumer; `_add_schedule` and the E-06/L-03 collectors use only the kept block's columns). 2. `test_run_invariants_all` expected registry set omitted "E-06" (added to the invariants registry by the same PR). Added E-06 to the expected set and asserted it is green on a clean platform. Restores a green baseline (92 passed) so the #1450 B-01 work can be verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450) B-01 compared two reads that were neither temporally atomic nor backend-consistent (both latent while the canary is default-OFF and SQLite makes the two reads hit one file): (a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing between the two reads produced a transient count mismatch → spurious critical + green→red Slack alert. (b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors `get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On Postgres those are two different databases, so B-01 compared Postgres truth to a stale/absent SQLite file (fatal under the Postgres direction + SQLite EOL, #1278). Production-side residue of #1446 (PR #1452), which fixed only the test-harness `sys.modules` leak and deferred these two gaps. Fix (localized to B-01): - New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME `get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine` snapshot field. Independent code path (SELECT id/literal 'queued' vs COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a cache/status-filter regression still surfaces (non-tautology, AC #3). No cache / second count of the queue (AC #4). - The collector performs one confirm-re-read on a mismatch: a transient race self-heals; a persistent drift survives and fires (AC #2). An engine-read or unconfirmable confirm degrades to a B-01 skip — it never compares an engine count against the raw-sqlite id-set (the blocker the reviews surfaced). - `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast radius is B-01-only and the sibling #1077 merge stays clean. The running-side / known-agents reads remain raw-sqlite (half-migrated collector); the collector-wide migration + a dark-canary tripwire are a filed follow-up. Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at `queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend proxy (raw ≠ engine temp files) that false-fires pre-fix and is green post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair, and an engine-read-failure→skip test. New split fixtures (`canary_db_split` / `reload_canary_split`) model the diverged backend without a live PG. Convert the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules` escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and the reimport `del`s are lint-clean. Docs: architecture.md Canary B-01 row updated for the engine-backed read path + confirm-re-read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(feature-flows): add #1450 canary B-01 Recent Updates row Sync-feature-flows: canary-internal change, no dedicated flow doc (architecture.md is canonical); append one Recent Updates row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(canary): fix duplicate agent_schedules DDL + reconcile E-06 in registry test The canary_db fixture had two `CREATE TABLE agent_schedules` statements in one executescript (a #1472 merge artifact), so it raised `table already exists` and reddened the whole file. Merge them into one definition carrying every column the canary reads (next_run_at/enabled/deleted_at + agent_name). Add duration_ms/queued_at/backlog_metadata to schedule_executions and extend _add_execution for the #1077 E-03/E-04 collector work. Reconcile E-06 (already registered) into test_run_invariants_all's expected key-set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(canary): terminal-row collector for E-03/G-03 (#1077) Add _collect_terminal_rows(window_seconds) + Snapshot.terminal_rows. Windowed on started_at (not completed_at, so E-03 can see NULL-completed_at rows), scoped to success/failed/cancelled via a local _E03_TERMINAL_STATUSES subset that excludes skipped (which legitimately has no completed_at/duration_ms). PRAGMA guard skips the source entirely when completed_at/duration_ms are absent (column-absent != value-NULL) rather than false-firing. Window = max per-agent timeout + 300s; bounded ORDER BY started_at DESC LIMIT 5000 with a logged sampled flag (no (status,started_at) index over 90-day retention; tripwire, not backfill audit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(canary): register E-03 (completed_at populated) + G-03 (clock sanity) (#1077) E-03 (A/major): terminal rows must have completed_at NOT NULL. Predicate is completed_at-only — the catalog's + duration_ms clause false-fires on healthy queue-terminated rows (cancel/fail/expire set completed_at but never duration_ms). G-03 (A/minor): started_at <= completed_at with a ~1s cross-worker clock-skew tolerance, UTC-aware parsing (E-06 _to_utc shape) so a #1474 mixed naive/Z pair compares without raising. Both are leading-edge tripwires over the shared terminal-row collector and catch all producers incl. the standalone scheduler's raw-SQL writers a unit test never exercises. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(canary): E-03/G-03 synthetic + collector + end-to-end coverage (#1077) Per-invariant synthetic tests (holds-clean, fires-on-violation), collector tests (started_at window in/out, NULL-completed_at still collected, skipped-status excluded, column-absent DDL -> unavailable, LIMIT cap + sampled flag), and end-to-end collect_snapshot tests through the real collector: the C1 cancelled-from-queue holds-clean guard (completed_at set, duration_ms NULL -> zero E-03), half-written fires E-03, bad-clock fires G-03, sub-second skew does not, and the #1474 naive-vs-Z compare-without-raising case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(canary): document E-03/G-03 Phase 4 invariants (#1077) architecture.md canary table gains E-03/G-03 rows + Phase 4 lookup-key line; requirements/infrastructure.md §31 gains a Phase 4 bullet (and reconciles the stale 'Phase 2 deferred' note now that #882/#1472 shipped); orchestration-invariant-catalog.md annotates E-03/G-03 as shipped with explicit registry-id mapping, notes the E-03 completed_at-only predicate deviation and G-03 started_at<=completed_at reduction, and flags the catalog-id vs registry-id E-06 drift (catalog #129 != registry #1472). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(feature-flows): index row for canary Phase 4 E-03/G-03 (#1077) Canary is an internal invariant harness (no user-facing flow / dedicated flow doc); follows the #1446 precedent of a Recent Updates row pointing at architecture.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(canary): queued-row metadata collector for E-04/G-04 (#1077) Capture queued_at + backlog_metadata for status='queued' rows in the existing _collect_executions query, keyed by execution_id in a new AgentSnapshot.queued_meta map. Both columns are PRAGMA-guarded (added by BACKLOG-001): when either is absent on an older/minimal DDL the map is left empty so E-04/G-04 skip those eids (older-image fail-open). Scoped STRICTLY to queued rows — never terminal — so #1449's deferred terminal-row backlog_metadata NULL-out cannot make E-04 false-fire. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(canary): register E-04 (queued metadata) + G-04 (no creds in metadata) (#1077) E-04 (Tier A, major): every status='queued' row has queued_at NOT NULL AND a non-NULL, JSON-parseable backlog_metadata — the backlog_service.drain_next replay contract. A malformed blob raises JSONDecodeError and stalls the FIFO. Reports only the failed-predicate reason code + ids, never the raw metadata (may carry credentials; violations persist to canary_violations). G-04 (Tier A, critical): a queued row's backlog_metadata matches no known secret prefix (sk-/ghp_/gho_/ghs_/ghu_/github_pat_/xoxb-/xoxp-/ AKIA/AIza/sk_live_), word-boundary anchored so common substrings don't false-fire. Rides E-04's collected bytes. Reports only the matched pattern NAME + ids, one violation per row (stops at first match) — never the secret, surrounding bytes, or raw metadata. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(canary): E-04/G-04 synthetic + collector + end-to-end coverage (#1077) Collector test proves queued_meta is populated for queued rows only (a terminal row carrying backlog_metadata is excluded — #1449-safe). E-04: holds on valid rows; fires with the right reason code on NULL queued_at, NULL backlog_metadata, and non-JSON metadata; skips an eid absent from queued_meta (older-image fail-open); e2e over a real temp DB. G-04: holds on benign metadata (incl. "task-" substring that must not false-fire); fires on github_pat / openai / slack / aws exemplars; skips NULL metadata (E-04 owns it). Every G-04/E-04 test asserts the secret / raw metadata bytes appear NOWHERE in the persisted violation record. The runner test now expects E-04 + G-04 in the registered invariant set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(canary): document E-04/G-04 Phase 4 invariants (#1077) architecture.md lookup-key table gains E-04 + G-04 rows and the Phase 4 line now lists all four (E-04/G-04 stacked on #1450). requirements infrastructure.md Phase 4 bullet expands to the full four-predicate set with the credential-safety note. Catalog flips E-04/G-04 from "gated on #1450" to SHIPPED, records the json.loads-vs-json_valid implementation note, the queued-only scope, older-image fail-open, and the report-reason/pattern-name-only security discipline; G-04 notes the implemented check covers the backlog half of the title (log-line scanning out of scope for #1077). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(feature-flows): index row for canary Phase 4 E-04/G-04 (#1077) Also corrects the header/separator ordering left malformed by the earlier E-03/G-03 index-row commit (a data row had slipped above the |---| separator). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Summary
Implements the configurable database backend from #300: the whole data layer now
runs on SQLAlchemy Core instead of raw
sqlite3, selected at startup byDATABASE_URL. SQLite stays the zero-config default and is behavior-identical— nothing changes when
DATABASE_URLis unset. PostgreSQL is an opt-in,experimental backend (default OFF).
What's in here
db/engine.py(engine factory fromDATABASE_URL, cached per URL;NullPool for sqlite to match the legacy open/close-per-call semantics, pooled +
pre_ping for PG),
db/tables.py(Core table registry for all 59 tables,auto-derived from
schema.py), DATABASE_URL-awaredb/connection.py.schema.py:init_schema_postgres()translates the sameTABLES/INDEXESstrings to PG DDL (SERIAL, UTC text defaults, FK-strip sincethe platform runs FKs-off, PL/pgSQL versions of the
audit_logappend-onlytriggers).
schema.pystays the single source of truth — no second schema todrift (cf. Schema Validation: 25 critical drift issues — schema.py incomplete (11 tables + 14 columns missing) #655/Schema Validation: 11 tables + 14 columns missing from schema.py (invariant #3 violated) #691/Schema Validation: 25 critical drift issues — 11 tables + 14 columns missing from schema.py #721).
init_database()branches on dialect; a fresh PG DBis built at head so the sqlite-only PRAGMA migrations are skipped.
?→Core params,INSERT OR REPLACE/IGNORE→dialect
on_conflict_*(viamake_insert),lastrowid→inserted_primary_key,datetime('now')→utc_now_iso(),sqlite3.IntegrityError→sqlalchemy.exc.IntegrityError. Method signatures/return shapes unchanged —routers, services, and the
DatabaseManagerfacade are untouched.VACUUM, and the
/healthmigration gate gated tois_sqlite();git_servicecatches the SQLAlchemy
IntegrityError.src/scheduler/database.pyhonorsDATABASE_URL, so cron-fired schedules read/write the same PG database as thebackend; unset → shared SQLite as before.
tables.pyIntegeris aTypeDecoratorcoercingPython bool→0/1 (PG rejects bool into integer columns; sqlite coerces silently).
Found by the live e2e.
DATABASE_URL/DB_POOL_SIZE/DB_MAX_OVERFLOWpass throughbackend.environment:(andDATABASE_URLthrough the scheduler) in bothdocker-compose.ymlanddocker-compose.prod.yml(#1039/feat(voice): VoIP telephony — agents place/receive phone calls over Gemini Live #1056 packaging-gapclass). Prod ships no postgres service — a
postgresql://URL there points atan operator-managed instance.
Verification
tests green (17 test files migrated off the retired
get_db_connectionseam).postgres:16): newtests/integration/test_postgres_backend.py(8/8) +
tests/unit/test_schema_postgres.py+ dual-backendtest_users_db_dual_backend.py— exercisingON CONFLICTupserts,RETURNING,multi-statement transactions, cross-module cascade.
COMPOSE_PROFILES=postgres): boot → login → agentcreate (ownership in PG) → config (timeout/autonomy/read-only persisted) → tags →
schedule → start/stop → real Claude Code chat with the AI response + session +
messages + execution row persisted to PG → soft-delete. All green.
How to run on PostgreSQL
Known gaps / follow-ups (experimental scope)
init_schema_postgres()isIF NOT EXISTS-idempotent, so an existing PG database picks up newtables on restart but never column changes to existing tables, and
migrations.pyis sqlite-only. Until a PG migration story lands, a PGinstance that crosses a schema-changing release must be rebuilt.
src/backend/canary/snapshot.py) still reads rawsqlite3 and is not
is_sqlite()-gated —CANARY_ENABLED=1on a PGdeployment would read the wrong (empty) SQLite file. Canary is default-OFF;
gate or port it before enabling on PG.
postgres:16service tobackend-unit-test.yml).architecture.mdnote for theDATABASE_URLseam.Standalone scheduler not PG-aware— shipped infeat(scheduler): make the standalone scheduler PostgreSQL-aware (#300).Related to #300.
🤖 Generated with Claude Code