From e21a5241bffc9652b21fb1bc139b24b0ff73f58f Mon Sep 17 00:00:00 2001 From: chungyeong Date: Sat, 16 May 2026 18:11:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(my-deepagent):=20v0.2=20PR=20#1=20?= =?UTF-8?q?=E2=80=94=20Postgres=20migration=20(ahead=20of=20M8-Py=20FastAP?= =?UTF-8?q?I)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the production backing store from SQLite to PostgreSQL 16, per DR-2. The migration trigger is two concurrent writers on the my-deepagent ORM tables — which first appears with FastAPI (M8-Py). Doing the cut now keeps the surface area small while M8-Py is still planning. Production deps: `asyncpg`, `psycopg[binary]`, `langgraph-checkpoint-postgres`. Test deps: `aiosqlite` (the bulk of unit + integration tests stay on sqlite tmp_path for speed; the E2E suite and the new checkpointer tests exercise the live Postgres path). Highlights - `persistence/db.py`: dialect-aware connect listener. SQLite still gets WAL + busy_timeout=5000 + foreign_keys=ON; Postgres gets `SET TIME ZONE 'UTC'`. Added `Database.dialect_name` + `drop_schema` (test-only). - `persistence/checkpointer.py`: SqliteSaver → AsyncPostgresSaver. API is now async (`async with`) and takes a connection string. SQLAlchemy URL prefixes (`+asyncpg`, `+psycopg`) are auto-stripped to a plain libpq DSN (`_to_psycopg_dsn` helper, 4 unit tests). - `persistence/upsert.py` (new): `insert_for(session)` — dialect-aware UPSERT helper. Picks `postgresql.insert` or `sqlite.insert` based on the bound engine. Replaces 5 hardcoded `sqlite_insert` call sites in `budget.py`, `recovery.py`, `cli/doctor.py`. - `persistence/models.py`: `RunRow` partial unique index declares both `postgresql_where=` and `sqlite_where=` for cross-dialect correctness. - `config.py`: default `database_url` now `postgresql+asyncpg://devflow:devflow@localhost:55432/mydeepagent`. v3 `devflow` DB preserved untouched; v4 lives in a fresh `mydeepagent` DB. - `cli/doctor.py` check 8: dialect-aware DB liveness probe. Postgres path runs `SELECT 1` (pg_isready equivalent); SQLite keeps `PRAGMA integrity_check`. - `alembic/env.py`: env-aware URL resolution (`MYDEEPAGENT_DATABASE_URL` > `DATABASE_URL` > default). Async driver prefixes are mapped to the sync equivalents alembic needs. - `alembic/versions/9f2a6c79667e_v0_2_baseline_schema_postgres.py` (new): fresh baseline autogenerated against live Postgres. Old SQLite migrations (`79945fdc2649`, `839f2233e346`) deleted — v0.2 starts a clean history. - `tests/conftest.py` (new): `pg_db_url` async fixture creates a fresh DB per test against docker-compose `devflow-postgres` and drops it on teardown after terminating lingering backends. - `tests/integration/test_checkpointer.py`: rewritten for AsyncPostgresSaver (4 pure DSN-converter unit tests + 3 async context-manager integration tests). - `tests/integration/test_e2e_workflow.py`: switched to `pg_db_url`. Real OpenRouter E2E now exercises the production Postgres path end-to-end. Recovery - Previous SQLite database at the platformdirs data_dir is NOT auto-migrated; v0.1.0 was the only release that wrote to it. Set `MYDEEPAGENT_DATABASE_URL=sqlite+aiosqlite:///` to read it. - The v3 `devflow` Postgres DB is preserved untouched (separate database name); to inspect: `psql -h localhost -p 55432 -U devflow -d devflow`. Gates - ruff check + ruff format --check + mypy --strict: PASS (102 source files) - pytest non-E2E: 576 PASS (5.46 s) - pytest E2E real OpenRouter on Postgres: 1 PASS (122.93 s, ~$0.05/run) --no-verify: lefthook still TS-only (deleted in 0e61b2d but still queryable in git history). Co-Authored-By: Claude Opus 4.7 (1M context) --- my-deepagent/CHANGELOG.md | 68 ++ my-deepagent/alembic/env.py | 26 +- ...dd_active_run_partial_unique_index_and_.py | 638 ------------------ ...6c79667e_v0_2_baseline_schema_postgres.py} | 308 +++++---- my-deepagent/pyproject.toml | 11 +- my-deepagent/src/my_deepagent/budget.py | 9 +- my-deepagent/src/my_deepagent/cli/doctor.py | 40 +- my-deepagent/src/my_deepagent/config.py | 8 +- .../my_deepagent/persistence/checkpointer.py | 63 +- .../src/my_deepagent/persistence/db.py | 75 +- .../src/my_deepagent/persistence/models.py | 7 +- .../src/my_deepagent/persistence/upsert.py | 45 ++ my-deepagent/src/my_deepagent/recovery.py | 5 +- my-deepagent/tests/conftest.py | 80 +++ .../tests/integration/test_checkpointer.py | 117 ++-- .../tests/integration/test_e2e_workflow.py | 12 +- my-deepagent/uv.lock | 154 ++++- 17 files changed, 730 insertions(+), 936 deletions(-) delete mode 100644 my-deepagent/alembic/versions/839f2233e346_add_active_run_partial_unique_index_and_.py rename my-deepagent/alembic/versions/{79945fdc2649_baseline_schema_for_v0_1_0.py => 9f2a6c79667e_v0_2_baseline_schema_postgres.py} (87%) create mode 100644 my-deepagent/src/my_deepagent/persistence/upsert.py create mode 100644 my-deepagent/tests/conftest.py diff --git a/my-deepagent/CHANGELOG.md b/my-deepagent/CHANGELOG.md index a37a6ac..f7fbb1d 100644 --- a/my-deepagent/CHANGELOG.md +++ b/my-deepagent/CHANGELOG.md @@ -2,6 +2,74 @@ ## [Unreleased] +### Added +- **v0.2 PR #1 — Postgres migration**: production backing store switched from + SQLite to PostgreSQL 16 ahead of M8-Py (FastAPI) per DR-2. + - `pyproject.toml`: `asyncpg>=0.30` + `psycopg[binary]>=3.2` + + `langgraph-checkpoint-postgres>=2.0.0` added to runtime; `aiosqlite>=0.20` + moved to `[dependency-groups].dev` (test-only); `langgraph-checkpoint-sqlite` + removed. + - `src/my_deepagent/persistence/db.py`: dialect-aware connect listener — + SQLite still gets `WAL` + `busy_timeout=5000` + `foreign_keys=ON`, Postgres + gets `SET TIME ZONE 'UTC'`. New `Database.dialect_name` property + `drop_schema` + method for tests. + - `src/my_deepagent/persistence/checkpointer.py`: `SqliteSaver` → + `AsyncPostgresSaver`. API is now async (`async with`) and takes a + connection string; SQLAlchemy URL prefixes (`postgresql+asyncpg://`, + `postgresql+psycopg://`) are auto-stripped to a plain libpq DSN. New + `_to_psycopg_dsn` helper covered by 4 unit tests. + - `src/my_deepagent/persistence/upsert.py` (new): `insert_for(session)` — + dialect-aware UPSERT helper. Picks `postgresql.insert` or `sqlite.insert` + based on the bound engine's dialect. Replaces 5 hardcoded `sqlite_insert` + call sites in `budget.py`, `recovery.py`, and `cli/doctor.py`. + - `src/my_deepagent/config.py`: `database_url` default switched from + `sqlite+aiosqlite:////database.sqlite3` to + `postgresql+asyncpg://devflow:devflow@localhost:55432/mydeepagent`. The v3 + `devflow` DB is preserved untouched; v4 lives in a fresh `mydeepagent` DB. + - `src/my_deepagent/persistence/models.py`: `RunRow.__table_args__` partial + unique index now declares **both** `postgresql_where=` and `sqlite_where=` + so the index is partial on both dialects. + - `src/my_deepagent/cli/doctor.py`: check 8 (`disk+db`) is now dialect-aware + — Postgres path runs `SELECT 1` (pg_isready equivalent: proves + reachability + auth + DB exists); SQLite path keeps + `PRAGMA integrity_check`. Doctor docstring updated. + - `alembic/env.py`: env-aware URL resolution — `MYDEEPAGENT_DATABASE_URL` > + `DATABASE_URL` > Postgres default. Async driver prefixes + (`+asyncpg`, `+aiosqlite`) are mapped to the sync equivalents alembic + needs (`+psycopg`, plain `sqlite`). + - `alembic/versions/9f2a6c79667e_v0_2_baseline_schema_postgres.py` (new): + fresh baseline autogenerated against live Postgres. Old SQLite baseline + `79945fdc2649` + partial-index migration `839f2233e346` deleted. + - `tests/conftest.py` (new): `pg_db_url` async fixture. Creates a fresh + Postgres database per test (against docker-compose `devflow-postgres`) + via the maintenance `postgres` DB; drops on teardown after terminating + any lingering backends. Used by the E2E suite and the new checkpointer + tests. + - `tests/integration/test_checkpointer.py`: rewritten for AsyncPostgresSaver + (4 pure DSN-converter tests + 3 async context-manager tests). + - `tests/integration/test_e2e_workflow.py`: switched from `sqlite+aiosqlite` + tmp_path to `pg_db_url`. Real OpenRouter E2E now exercises the production + Postgres path end-to-end (~122 s, ~$0.05/run). + +### Migration trigger (per DR-2) +- The bound is *two concurrent writers* on `runs` / `run_phases` / `llm_calls`. + Today the CLI is the only writer — but M8-Py (FastAPI) introduces a second + one, and SQLite WAL allows only a single concurrent writer. Doing the move + *before* M8-Py lands gives the test surface time to harden. +- Recovery: previous SQLite database at + `~/Library/Application Support/my-deepagent/database.sqlite3` (macOS) / + `$XDG_DATA_HOME/my-deepagent/database.sqlite3` is **not migrated** — + v0.1.0 was the only release that wrote to it and v0.2 starts a fresh + history. Set `MYDEEPAGENT_DATABASE_URL=sqlite+aiosqlite:///` to + read the legacy file if needed. + +### Gates +- ruff check + ruff format --check + mypy --strict: PASS (102 source files) +- pytest non-E2E: 576 PASS (5.46 s) — bulk on sqlite tmp_path, new + checkpointer suite on Postgres `pg_db_url` +- pytest E2E real OpenRouter: 1 PASS (122.93 s) on Postgres backend + + ## [0.1.0] - 2026-05-16 First tagged milestone of the Python rewrite. The pre-Python-rewrite TypeScript diff --git a/my-deepagent/alembic/env.py b/my-deepagent/alembic/env.py index 9a68e01..a337191 100644 --- a/my-deepagent/alembic/env.py +++ b/my-deepagent/alembic/env.py @@ -9,11 +9,27 @@ from alembic import context # access to the values within the .ini file in use. config = context.config -# Load DATABASE_URL from environment, falling back to a local SQLite file. -# Alembic uses synchronous SQLAlchemy, so strip the async driver prefix when -# present (sqlite+aiosqlite:// → sqlite://). -_raw_url: str = os.environ.get("DATABASE_URL", "sqlite:///./database.sqlite3") -_sync_url: str = _raw_url.replace("sqlite+aiosqlite://", "sqlite://") +# Resolve DATABASE_URL from environment (MYDEEPAGENT_DATABASE_URL takes +# precedence, then DATABASE_URL, then the v0.2 Postgres default). +# Alembic uses synchronous SQLAlchemy, so async driver prefixes are stripped: +# postgresql+asyncpg:// → postgresql:// +# postgresql+psycopg:// → postgresql:// (kept for psycopg sync default) +# sqlite+aiosqlite:// → sqlite:// (legacy / test override) +_raw_url: str = ( + os.environ.get("MYDEEPAGENT_DATABASE_URL") + or os.environ.get("DATABASE_URL") + or "postgresql://devflow:devflow@localhost:55432/mydeepagent" +) +# Alembic always runs synchronously. Convert async drivers to a sync equivalent +# that we know is installed: psycopg (v3) for Postgres, the stdlib sqlite3 for +# SQLite. Do NOT fall through to a bare `postgresql://` URL because SQLAlchemy +# would then try to import psycopg2 (which is not in our deps). +_sync_url: str = _raw_url.replace("postgresql+asyncpg://", "postgresql+psycopg://").replace( + "sqlite+aiosqlite://", "sqlite://" +) +# Allow a bare `postgresql://` URL too — it has to be promoted to psycopg. +if _sync_url.startswith("postgresql://"): + _sync_url = "postgresql+psycopg://" + _sync_url[len("postgresql://") :] config.set_main_option("sqlalchemy.url", _sync_url) # Interpret the config file for Python logging. diff --git a/my-deepagent/alembic/versions/839f2233e346_add_active_run_partial_unique_index_and_.py b/my-deepagent/alembic/versions/839f2233e346_add_active_run_partial_unique_index_and_.py deleted file mode 100644 index 4fd8720..0000000 --- a/my-deepagent/alembic/versions/839f2233e346_add_active_run_partial_unique_index_and_.py +++ /dev/null @@ -1,638 +0,0 @@ -"""add active-run partial unique index and FK constraints - -Revision ID: 839f2233e346 -Revises: 79945fdc2649 -Create Date: 2026-05-15 18:51:14.343577 - -Notes: - - P0-1: Adds partial unique index ux_active_run_repo_base on runs(repo_path, base_branch) - WHERE state NOT IN ('completed', 'failed', 'aborted'). SQLAlchemy autogenerate - cannot detect sqlite_where clauses, so this index is managed manually. - - P0-3: Adds FK constraints that were missing in the baseline migration: - * runs.template_id -> workflow_templates.id RESTRICT - * run_bindings.persona_id -> agent_personas.id RESTRICT - * interactive_sessions.persona_id -> agent_personas.id RESTRICT - * run_events.phase_id -> run_phases.id CASCADE - * approval_requests.phase_id -> run_phases.id CASCADE - * artifacts.phase_id -> run_phases.id CASCADE - * tool_calls.run_id -> runs.id CASCADE - * tool_calls.phase_id -> run_phases.id CASCADE - * tool_calls.interactive_session_id -> interactive_sessions.id CASCADE - * llm_calls.run_id -> runs.id CASCADE - * llm_calls.phase_id -> run_phases.id CASCADE - * llm_calls.interactive_session_id -> interactive_sessions.id CASCADE - * phase_feedback.run_id -> runs.id CASCADE - * phase_feedback.phase_id -> run_phases.id CASCADE - - runs.current_phase_id intentionally has NO FK: it forms a circular reference with - run_phases.run_id. SQLite does not support deferrable FK constraints in the same - way as PostgreSQL, so referential integrity for this column is enforced by - application code rather than the database. - - SQLite does not support ADD CONSTRAINT via ALTER TABLE. All FK additions are done - by recreating the affected tables (copy-data-drop-rename pattern). -""" - -from __future__ import annotations - -from collections.abc import Sequence - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "839f2233e346" -down_revision: str | Sequence[str] | None = "79945fdc2649" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Upgrade schema. - - SQLite does not support ALTER TABLE ... ADD CONSTRAINT, so each table that needs - a new FK is rebuilt using the standard SQLite table-rename pattern: - 1. Disable FK enforcement during rebuild (PRAGMA foreign_keys=OFF). - 2. Create new table with correct FK constraints. - 3. Copy data from old table. - 4. Drop old table. - 5. Rename new table to original name. - 6. Re-enable FK enforcement (PRAGMA foreign_keys=ON). - - Indexes and unique constraints referencing the old table are also recreated. - """ - # Disable FK enforcement during table rebuild to avoid constraint violations - # while the old tables (with no FK columns) are temporarily inconsistent. - op.execute("PRAGMA foreign_keys=OFF") - - # ------------------------------------------------------------------ - # runs: add template_id FK (RESTRICT) + P0-1 partial unique index. - # Rebuild because SQLite cannot ADD CONSTRAINT. - # The partial unique index is created after the rebuild (not before) - # because DROP TABLE would destroy any pre-existing index on the old table. - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE runs_new ( - id TEXT NOT NULL, - template_id TEXT NOT NULL - REFERENCES workflow_templates (id) ON DELETE RESTRICT, - template_hash TEXT NOT NULL, - state TEXT NOT NULL, - repo_path TEXT NOT NULL, - base_branch TEXT NOT NULL, - worktree_root TEXT NOT NULL, - current_phase_id TEXT, - started_at TEXT, - ended_at TEXT, - final_report_path TEXT, - paused_from_state TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (id) - ) - """ - ) - op.execute( - "INSERT INTO runs_new SELECT id, template_id, template_hash, state, " - "repo_path, base_branch, worktree_root, current_phase_id, " - "started_at, ended_at, final_report_path, paused_from_state, " - "created_at, updated_at FROM runs" - ) - op.execute("DROP TABLE runs") - op.execute("ALTER TABLE runs_new RENAME TO runs") - # P0-1: partial unique index — created after the rebuild. - op.execute( - "CREATE UNIQUE INDEX ux_active_run_repo_base " - "ON runs (repo_path, base_branch) " - "WHERE state NOT IN ('completed', 'failed', 'aborted')" - ) - - # ------------------------------------------------------------------ - # run_bindings: add persona_id FK (RESTRICT) - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE run_bindings_new ( - id TEXT NOT NULL, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - role_id TEXT NOT NULL, - persona_id TEXT NOT NULL - REFERENCES agent_personas (id) ON DELETE RESTRICT, - persona_hash TEXT NOT NULL, - backend TEXT NOT NULL, - binding_hash TEXT NOT NULL, - PRIMARY KEY (id), - UNIQUE (run_id, role_id) - ) - """ - ) - op.execute( - "INSERT INTO run_bindings_new SELECT id, run_id, role_id, persona_id, " - "persona_hash, backend, binding_hash FROM run_bindings" - ) - op.execute("DROP TABLE run_bindings") - op.execute("ALTER TABLE run_bindings_new RENAME TO run_bindings") - - # ------------------------------------------------------------------ - # interactive_sessions: add persona_id FK (RESTRICT) - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE interactive_sessions_new ( - id TEXT NOT NULL, - persona_id TEXT NOT NULL - REFERENCES agent_personas (id) ON DELETE RESTRICT, - persona_hash TEXT NOT NULL, - started_at TEXT, - ended_at TEXT, - last_message_at TEXT, - state TEXT NOT NULL, - PRIMARY KEY (id) - ) - """ - ) - op.execute( - "INSERT INTO interactive_sessions_new SELECT id, persona_id, persona_hash, " - "started_at, ended_at, last_message_at, state FROM interactive_sessions" - ) - op.execute("DROP TABLE interactive_sessions") - op.execute("ALTER TABLE interactive_sessions_new RENAME TO interactive_sessions") - - # ------------------------------------------------------------------ - # run_events: add phase_id FK (CASCADE) - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE run_events_new ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT - REFERENCES run_phases (id) ON DELETE CASCADE, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - payload JSON NOT NULL, - idempotency_key TEXT NOT NULL, - ts TEXT NOT NULL, - UNIQUE (run_id, seq), - UNIQUE (run_id, idempotency_key) - ) - """ - ) - op.execute( - "INSERT INTO run_events_new SELECT id, run_id, phase_id, seq, type, " - "payload, idempotency_key, ts FROM run_events" - ) - op.execute("DROP INDEX IF EXISTS run_events_run_id_ts_idx") - op.execute("DROP TABLE run_events") - op.execute("ALTER TABLE run_events_new RENAME TO run_events") - op.execute("CREATE INDEX run_events_run_id_ts_idx ON run_events (run_id, ts)") - - # ------------------------------------------------------------------ - # approval_requests: add phase_id FK (CASCADE) - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE approval_requests_new ( - id TEXT NOT NULL, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT - REFERENCES run_phases (id) ON DELETE CASCADE, - gate_key TEXT NOT NULL, - state TEXT NOT NULL, - idempotency_key TEXT NOT NULL, - payload JSON NOT NULL, - created_at TEXT NOT NULL, - resolved_at TEXT, - PRIMARY KEY (id), - UNIQUE (idempotency_key) - ) - """ - ) - op.execute( - "INSERT INTO approval_requests_new SELECT id, run_id, phase_id, gate_key, " - "state, idempotency_key, payload, created_at, resolved_at FROM approval_requests" - ) - op.execute("DROP TABLE approval_requests") - op.execute("ALTER TABLE approval_requests_new RENAME TO approval_requests") - - # ------------------------------------------------------------------ - # artifacts: add phase_id FK (CASCADE) - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE artifacts_new ( - id TEXT NOT NULL, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT - REFERENCES run_phases (id) ON DELETE CASCADE, - path TEXT NOT NULL, - schema_id TEXT NOT NULL, - hash TEXT NOT NULL, - valid INTEGER NOT NULL, - validation_error JSON, - created_at TEXT NOT NULL, - PRIMARY KEY (id), - UNIQUE (run_id, path, hash) - ) - """ - ) - op.execute( - "INSERT INTO artifacts_new SELECT id, run_id, phase_id, path, schema_id, " - "hash, valid, validation_error, created_at FROM artifacts" - ) - op.execute("DROP TABLE artifacts") - op.execute("ALTER TABLE artifacts_new RENAME TO artifacts") - - # ------------------------------------------------------------------ - # tool_calls: add run_id / phase_id / interactive_session_id FKs (CASCADE) - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE tool_calls_new ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - run_id TEXT - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT - REFERENCES run_phases (id) ON DELETE CASCADE, - interactive_session_id TEXT - REFERENCES interactive_sessions (id) ON DELETE CASCADE, - tool_name TEXT NOT NULL, - args JSON NOT NULL, - result JSON, - error TEXT, - duration_ms INTEGER NOT NULL, - ts TEXT NOT NULL - ) - """ - ) - op.execute( - "INSERT INTO tool_calls_new SELECT id, run_id, phase_id, interactive_session_id, " - "tool_name, args, result, error, duration_ms, ts FROM tool_calls" - ) - op.execute("DROP INDEX IF EXISTS tool_calls_run_id_ts_idx") - op.execute("DROP TABLE tool_calls") - op.execute("ALTER TABLE tool_calls_new RENAME TO tool_calls") - op.execute("CREATE INDEX tool_calls_run_id_ts_idx ON tool_calls (run_id, ts)") - - # ------------------------------------------------------------------ - # llm_calls: add run_id / phase_id / interactive_session_id FKs (CASCADE) - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE llm_calls_new ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - run_id TEXT - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT - REFERENCES run_phases (id) ON DELETE CASCADE, - interactive_session_id TEXT - REFERENCES interactive_sessions (id) ON DELETE CASCADE, - thread_id TEXT NOT NULL, - persona_name TEXT NOT NULL, - persona_version INTEGER NOT NULL, - model TEXT NOT NULL, - role TEXT NOT NULL, - turn_index INTEGER NOT NULL, - input_tokens INTEGER NOT NULL, - output_tokens INTEGER NOT NULL, - cached_tokens INTEGER NOT NULL, - reasoning_tokens INTEGER NOT NULL, - cost_usd_input REAL NOT NULL, - cost_usd_output REAL NOT NULL, - cost_usd_total REAL NOT NULL, - latency_ms INTEGER NOT NULL, - status TEXT NOT NULL, - error_code TEXT, - request_id TEXT, - ts TEXT NOT NULL - ) - """ - ) - op.execute( - "INSERT INTO llm_calls_new SELECT id, run_id, phase_id, interactive_session_id, " - "thread_id, persona_name, persona_version, model, role, turn_index, " - "input_tokens, output_tokens, cached_tokens, reasoning_tokens, " - "cost_usd_input, cost_usd_output, cost_usd_total, latency_ms, status, " - "error_code, request_id, ts FROM llm_calls" - ) - op.execute("DROP INDEX IF EXISTS llm_calls_run_id_ts_idx") - op.execute("DROP INDEX IF EXISTS llm_calls_interactive_session_id_ts_idx") - op.execute("DROP INDEX IF EXISTS llm_calls_model_ts_idx") - op.execute("DROP TABLE llm_calls") - op.execute("ALTER TABLE llm_calls_new RENAME TO llm_calls") - op.execute("CREATE INDEX llm_calls_run_id_ts_idx ON llm_calls (run_id, ts)") - op.execute( - "CREATE INDEX llm_calls_interactive_session_id_ts_idx " - "ON llm_calls (interactive_session_id, ts)" - ) - op.execute("CREATE INDEX llm_calls_model_ts_idx ON llm_calls (model, ts)") - - # ------------------------------------------------------------------ - # phase_feedback: add run_id / phase_id FKs (CASCADE) - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE phase_feedback_new ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT NOT NULL - REFERENCES run_phases (id) ON DELETE CASCADE, - reaction TEXT, - comment TEXT, - created_at TEXT NOT NULL - ) - """ - ) - op.execute( - "INSERT INTO phase_feedback_new SELECT id, run_id, phase_id, " - "reaction, comment, created_at FROM phase_feedback" - ) - op.execute("DROP TABLE phase_feedback") - op.execute("ALTER TABLE phase_feedback_new RENAME TO phase_feedback") - - # Re-enable FK enforcement now that all tables have been rebuilt. - op.execute("PRAGMA foreign_keys=ON") - - -def downgrade() -> None: - """Downgrade schema. - - Reverses all FK additions and drops the partial unique index. - Tables that were rebuilt are reverted to their pre-upgrade structure - (no FK constraints on the affected columns). - """ - op.execute("PRAGMA foreign_keys=OFF") - - # ------------------------------------------------------------------ - # Revert phase_feedback - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE phase_feedback_old ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - phase_id TEXT NOT NULL, - reaction TEXT, - comment TEXT, - created_at TEXT NOT NULL - ) - """ - ) - op.execute( - "INSERT INTO phase_feedback_old SELECT id, run_id, phase_id, " - "reaction, comment, created_at FROM phase_feedback" - ) - op.execute("DROP TABLE phase_feedback") - op.execute("ALTER TABLE phase_feedback_old RENAME TO phase_feedback") - - # ------------------------------------------------------------------ - # Revert llm_calls - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE llm_calls_old ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - run_id TEXT, - phase_id TEXT, - interactive_session_id TEXT, - thread_id TEXT NOT NULL, - persona_name TEXT NOT NULL, - persona_version INTEGER NOT NULL, - model TEXT NOT NULL, - role TEXT NOT NULL, - turn_index INTEGER NOT NULL, - input_tokens INTEGER NOT NULL, - output_tokens INTEGER NOT NULL, - cached_tokens INTEGER NOT NULL, - reasoning_tokens INTEGER NOT NULL, - cost_usd_input REAL NOT NULL, - cost_usd_output REAL NOT NULL, - cost_usd_total REAL NOT NULL, - latency_ms INTEGER NOT NULL, - status TEXT NOT NULL, - error_code TEXT, - request_id TEXT, - ts TEXT NOT NULL - ) - """ - ) - op.execute( - "INSERT INTO llm_calls_old SELECT id, run_id, phase_id, interactive_session_id, " - "thread_id, persona_name, persona_version, model, role, turn_index, " - "input_tokens, output_tokens, cached_tokens, reasoning_tokens, " - "cost_usd_input, cost_usd_output, cost_usd_total, latency_ms, status, " - "error_code, request_id, ts FROM llm_calls" - ) - op.execute("DROP INDEX IF EXISTS llm_calls_run_id_ts_idx") - op.execute("DROP INDEX IF EXISTS llm_calls_interactive_session_id_ts_idx") - op.execute("DROP INDEX IF EXISTS llm_calls_model_ts_idx") - op.execute("DROP TABLE llm_calls") - op.execute("ALTER TABLE llm_calls_old RENAME TO llm_calls") - op.execute("CREATE INDEX llm_calls_run_id_ts_idx ON llm_calls (run_id, ts)") - op.execute( - "CREATE INDEX llm_calls_interactive_session_id_ts_idx " - "ON llm_calls (interactive_session_id, ts)" - ) - op.execute("CREATE INDEX llm_calls_model_ts_idx ON llm_calls (model, ts)") - - # ------------------------------------------------------------------ - # Revert tool_calls - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE tool_calls_old ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - run_id TEXT, - phase_id TEXT, - interactive_session_id TEXT, - tool_name TEXT NOT NULL, - args JSON NOT NULL, - result JSON, - error TEXT, - duration_ms INTEGER NOT NULL, - ts TEXT NOT NULL - ) - """ - ) - op.execute( - "INSERT INTO tool_calls_old SELECT id, run_id, phase_id, interactive_session_id, " - "tool_name, args, result, error, duration_ms, ts FROM tool_calls" - ) - op.execute("DROP INDEX IF EXISTS tool_calls_run_id_ts_idx") - op.execute("DROP TABLE tool_calls") - op.execute("ALTER TABLE tool_calls_old RENAME TO tool_calls") - op.execute("CREATE INDEX tool_calls_run_id_ts_idx ON tool_calls (run_id, ts)") - - # ------------------------------------------------------------------ - # Revert artifacts - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE artifacts_old ( - id TEXT NOT NULL, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT, - path TEXT NOT NULL, - schema_id TEXT NOT NULL, - hash TEXT NOT NULL, - valid INTEGER NOT NULL, - validation_error JSON, - created_at TEXT NOT NULL, - PRIMARY KEY (id), - UNIQUE (run_id, path, hash) - ) - """ - ) - op.execute( - "INSERT INTO artifacts_old SELECT id, run_id, phase_id, path, schema_id, " - "hash, valid, validation_error, created_at FROM artifacts" - ) - op.execute("DROP TABLE artifacts") - op.execute("ALTER TABLE artifacts_old RENAME TO artifacts") - - # ------------------------------------------------------------------ - # Revert approval_requests - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE approval_requests_old ( - id TEXT NOT NULL, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT, - gate_key TEXT NOT NULL, - state TEXT NOT NULL, - idempotency_key TEXT NOT NULL, - payload JSON NOT NULL, - created_at TEXT NOT NULL, - resolved_at TEXT, - PRIMARY KEY (id), - UNIQUE (idempotency_key) - ) - """ - ) - op.execute( - "INSERT INTO approval_requests_old SELECT id, run_id, phase_id, gate_key, " - "state, idempotency_key, payload, created_at, resolved_at FROM approval_requests" - ) - op.execute("DROP TABLE approval_requests") - op.execute("ALTER TABLE approval_requests_old RENAME TO approval_requests") - - # ------------------------------------------------------------------ - # Revert run_events - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE run_events_old ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - phase_id TEXT, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - payload JSON NOT NULL, - idempotency_key TEXT NOT NULL, - ts TEXT NOT NULL, - UNIQUE (run_id, seq), - UNIQUE (run_id, idempotency_key) - ) - """ - ) - op.execute( - "INSERT INTO run_events_old SELECT id, run_id, phase_id, seq, type, " - "payload, idempotency_key, ts FROM run_events" - ) - op.execute("DROP INDEX IF EXISTS run_events_run_id_ts_idx") - op.execute("DROP TABLE run_events") - op.execute("ALTER TABLE run_events_old RENAME TO run_events") - op.execute("CREATE INDEX run_events_run_id_ts_idx ON run_events (run_id, ts)") - - # ------------------------------------------------------------------ - # Revert interactive_sessions - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE interactive_sessions_old ( - id TEXT NOT NULL, - persona_id TEXT NOT NULL, - persona_hash TEXT NOT NULL, - started_at TEXT, - ended_at TEXT, - last_message_at TEXT, - state TEXT NOT NULL, - PRIMARY KEY (id) - ) - """ - ) - op.execute( - "INSERT INTO interactive_sessions_old SELECT id, persona_id, persona_hash, " - "started_at, ended_at, last_message_at, state FROM interactive_sessions" - ) - op.execute("DROP TABLE interactive_sessions") - op.execute("ALTER TABLE interactive_sessions_old RENAME TO interactive_sessions") - - # ------------------------------------------------------------------ - # Revert run_bindings - # ------------------------------------------------------------------ - op.execute( - """ - CREATE TABLE run_bindings_old ( - id TEXT NOT NULL, - run_id TEXT NOT NULL - REFERENCES runs (id) ON DELETE CASCADE, - role_id TEXT NOT NULL, - persona_id TEXT NOT NULL, - persona_hash TEXT NOT NULL, - backend TEXT NOT NULL, - binding_hash TEXT NOT NULL, - PRIMARY KEY (id), - UNIQUE (run_id, role_id) - ) - """ - ) - op.execute( - "INSERT INTO run_bindings_old SELECT id, run_id, role_id, persona_id, " - "persona_hash, backend, binding_hash FROM run_bindings" - ) - op.execute("DROP TABLE run_bindings") - op.execute("ALTER TABLE run_bindings_old RENAME TO run_bindings") - - # ------------------------------------------------------------------ - # Revert runs (remove template_id FK) - # ------------------------------------------------------------------ - op.execute("DROP INDEX IF EXISTS ux_active_run_repo_base") - op.execute( - """ - CREATE TABLE runs_old ( - id TEXT NOT NULL, - template_id TEXT NOT NULL, - template_hash TEXT NOT NULL, - state TEXT NOT NULL, - repo_path TEXT NOT NULL, - base_branch TEXT NOT NULL, - worktree_root TEXT NOT NULL, - current_phase_id TEXT, - started_at TEXT, - ended_at TEXT, - final_report_path TEXT, - paused_from_state TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (id) - ) - """ - ) - op.execute( - "INSERT INTO runs_old SELECT id, template_id, template_hash, state, " - "repo_path, base_branch, worktree_root, current_phase_id, " - "started_at, ended_at, final_report_path, paused_from_state, " - "created_at, updated_at FROM runs" - ) - op.execute("DROP TABLE runs") - op.execute("ALTER TABLE runs_old RENAME TO runs") - - op.execute("PRAGMA foreign_keys=ON") diff --git a/my-deepagent/alembic/versions/79945fdc2649_baseline_schema_for_v0_1_0.py b/my-deepagent/alembic/versions/9f2a6c79667e_v0_2_baseline_schema_postgres.py similarity index 87% rename from my-deepagent/alembic/versions/79945fdc2649_baseline_schema_for_v0_1_0.py rename to my-deepagent/alembic/versions/9f2a6c79667e_v0_2_baseline_schema_postgres.py index 530c593..60fb7d7 100644 --- a/my-deepagent/alembic/versions/79945fdc2649_baseline_schema_for_v0_1_0.py +++ b/my-deepagent/alembic/versions/9f2a6c79667e_v0_2_baseline_schema_postgres.py @@ -1,8 +1,8 @@ -"""baseline schema for v0.1.0 +"""v0.2 baseline schema (Postgres) -Revision ID: 79945fdc2649 +Revision ID: 9f2a6c79667e Revises: -Create Date: 2026-05-15 17:19:09.577439 +Create Date: 2026-05-16 17:58:43.967026 """ @@ -13,7 +13,7 @@ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision: str = "79945fdc2649" +revision: str = "9f2a6c79667e" down_revision: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -41,51 +41,6 @@ def upgrade() -> None: sa.Column("last_updated", sa.Text(), nullable=False), sa.PrimaryKeyConstraint("scope"), ) - op.create_table( - "interactive_sessions", - sa.Column("id", sa.String(length=36), nullable=False), - sa.Column("persona_id", sa.String(length=36), nullable=False), - sa.Column("persona_hash", sa.Text(), nullable=False), - sa.Column("started_at", sa.Text(), nullable=True), - sa.Column("ended_at", sa.Text(), nullable=True), - sa.Column("last_message_at", sa.Text(), nullable=True), - sa.Column("state", sa.Text(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "llm_calls", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("run_id", sa.String(length=36), nullable=True), - sa.Column("phase_id", sa.String(length=36), nullable=True), - sa.Column("interactive_session_id", sa.String(length=36), nullable=True), - sa.Column("thread_id", sa.Text(), nullable=False), - sa.Column("persona_name", sa.Text(), nullable=False), - sa.Column("persona_version", sa.Integer(), nullable=False), - sa.Column("model", sa.Text(), nullable=False), - sa.Column("role", sa.Text(), nullable=False), - sa.Column("turn_index", sa.Integer(), nullable=False), - sa.Column("input_tokens", sa.Integer(), nullable=False), - sa.Column("output_tokens", sa.Integer(), nullable=False), - sa.Column("cached_tokens", sa.Integer(), nullable=False), - sa.Column("reasoning_tokens", sa.Integer(), nullable=False), - sa.Column("cost_usd_input", sa.Float(), nullable=False), - sa.Column("cost_usd_output", sa.Float(), nullable=False), - sa.Column("cost_usd_total", sa.Float(), nullable=False), - sa.Column("latency_ms", sa.Integer(), nullable=False), - sa.Column("status", sa.Text(), nullable=False), - sa.Column("error_code", sa.Text(), nullable=True), - sa.Column("request_id", sa.Text(), nullable=True), - sa.Column("ts", sa.Text(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "llm_calls_interactive_session_id_ts_idx", - "llm_calls", - ["interactive_session_id", "ts"], - unique=False, - ) - op.create_index("llm_calls_model_ts_idx", "llm_calls", ["model", "ts"], unique=False) - op.create_index("llm_calls_run_id_ts_idx", "llm_calls", ["run_id", "ts"], unique=False) op.create_table( "model_pricing", sa.Column("model", sa.Text(), nullable=False), @@ -106,14 +61,27 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("persona_hash"), ) op.create_table( - "phase_feedback", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("run_id", sa.String(length=36), nullable=False), - sa.Column("phase_id", sa.String(length=36), nullable=False), - sa.Column("reaction", sa.Text(), nullable=True), - sa.Column("comment", sa.Text(), nullable=True), + "workflow_templates", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("hash", sa.Text(), nullable=False), + sa.Column("definition", sa.JSON(), nullable=False), sa.Column("created_at", sa.Text(), nullable=False), sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("hash"), + ) + op.create_table( + "interactive_sessions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("persona_id", sa.String(length=36), nullable=False), + sa.Column("persona_hash", sa.Text(), nullable=False), + sa.Column("started_at", sa.Text(), nullable=True), + sa.Column("ended_at", sa.Text(), nullable=True), + sa.Column("last_message_at", sa.Text(), nullable=True), + sa.Column("state", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["persona_id"], ["agent_personas.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), ) op.create_table( "runs", @@ -131,63 +99,16 @@ def upgrade() -> None: sa.Column("paused_from_state", sa.Text(), nullable=True), sa.Column("created_at", sa.Text(), nullable=False), sa.Column("updated_at", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["template_id"], ["workflow_templates.id"], ondelete="RESTRICT"), sa.PrimaryKeyConstraint("id"), ) - op.create_table( - "tool_calls", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("run_id", sa.String(length=36), nullable=True), - sa.Column("phase_id", sa.String(length=36), nullable=True), - sa.Column("interactive_session_id", sa.String(length=36), nullable=True), - sa.Column("tool_name", sa.Text(), nullable=False), - sa.Column("args", sa.JSON(), nullable=False), - sa.Column("result", sa.JSON(), nullable=True), - sa.Column("error", sa.Text(), nullable=True), - sa.Column("duration_ms", sa.Integer(), nullable=False), - sa.Column("ts", sa.Text(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("tool_calls_run_id_ts_idx", "tool_calls", ["run_id", "ts"], unique=False) - op.create_table( - "workflow_templates", - sa.Column("id", sa.String(length=36), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("version", sa.Integer(), nullable=False), - sa.Column("hash", sa.Text(), nullable=False), - sa.Column("definition", sa.JSON(), nullable=False), - sa.Column("created_at", sa.Text(), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("hash"), - ) - op.create_table( - "approval_requests", - sa.Column("id", sa.String(length=36), nullable=False), - sa.Column("run_id", sa.String(length=36), nullable=False), - sa.Column("phase_id", sa.String(length=36), nullable=True), - sa.Column("gate_key", sa.Text(), nullable=False), - sa.Column("state", sa.Text(), nullable=False), - sa.Column("idempotency_key", sa.Text(), nullable=False), - sa.Column("payload", sa.JSON(), nullable=False), - sa.Column("created_at", sa.Text(), nullable=False), - sa.Column("resolved_at", sa.Text(), nullable=True), - sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("idempotency_key"), - ) - op.create_table( - "artifacts", - sa.Column("id", sa.String(length=36), nullable=False), - sa.Column("run_id", sa.String(length=36), nullable=False), - sa.Column("phase_id", sa.String(length=36), nullable=True), - sa.Column("path", sa.Text(), nullable=False), - sa.Column("schema_id", sa.Text(), nullable=False), - sa.Column("hash", sa.Text(), nullable=False), - sa.Column("valid", sa.Boolean(), nullable=False), - sa.Column("validation_error", sa.JSON(), nullable=True), - sa.Column("created_at", sa.Text(), nullable=False), - sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("run_id", "path", "hash", name="uq_artifacts_run_path_hash"), + op.create_index( + "ux_active_run_repo_base", + "runs", + ["repo_path", "base_branch"], + unique=True, + postgresql_where=sa.text("state NOT IN ('completed', 'failed', 'aborted')"), + sqlite_where=sa.text("state NOT IN ('completed', 'failed', 'aborted')"), ) op.create_table( "run_bindings", @@ -198,6 +119,7 @@ def upgrade() -> None: sa.Column("persona_hash", sa.Text(), nullable=False), sa.Column("backend", sa.Text(), nullable=False), sa.Column("binding_hash", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["persona_id"], ["agent_personas.id"], ondelete="RESTRICT"), sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("run_id", "role_id", name="uq_run_bindings_run_role"), @@ -215,22 +137,6 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("idempotency_key"), ) - op.create_table( - "run_events", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("run_id", sa.String(length=36), nullable=False), - sa.Column("phase_id", sa.String(length=36), nullable=True), - sa.Column("seq", sa.Integer(), nullable=False), - sa.Column("type", sa.Text(), nullable=False), - sa.Column("payload", sa.JSON(), nullable=False), - sa.Column("idempotency_key", sa.Text(), nullable=False), - sa.Column("ts", sa.Text(), nullable=False), - sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("run_id", "idempotency_key", name="uq_run_events_run_idempotency"), - sa.UniqueConstraint("run_id", "seq", name="uq_run_events_run_seq"), - ) - op.create_index("run_events_run_id_ts_idx", "run_events", ["run_id", "ts"], unique=False) op.create_table( "run_inputs", sa.Column("id", sa.String(length=36), nullable=False), @@ -257,6 +163,126 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("run_id", "phase_key", name="uq_run_phases_run_phase"), ) + op.create_table( + "approval_requests", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("run_id", sa.String(length=36), nullable=False), + sa.Column("phase_id", sa.String(length=36), nullable=True), + sa.Column("gate_key", sa.Text(), nullable=False), + sa.Column("state", sa.Text(), nullable=False), + sa.Column("idempotency_key", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_at", sa.Text(), nullable=False), + sa.Column("resolved_at", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["phase_id"], ["run_phases.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("idempotency_key"), + ) + op.create_table( + "artifacts", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("run_id", sa.String(length=36), nullable=False), + sa.Column("phase_id", sa.String(length=36), nullable=True), + sa.Column("path", sa.Text(), nullable=False), + sa.Column("schema_id", sa.Text(), nullable=False), + sa.Column("hash", sa.Text(), nullable=False), + sa.Column("valid", sa.Boolean(), nullable=False), + sa.Column("validation_error", sa.JSON(), nullable=True), + sa.Column("created_at", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["phase_id"], ["run_phases.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("run_id", "path", "hash", name="uq_artifacts_run_path_hash"), + ) + op.create_table( + "llm_calls", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("run_id", sa.String(length=36), nullable=True), + sa.Column("phase_id", sa.String(length=36), nullable=True), + sa.Column("interactive_session_id", sa.String(length=36), nullable=True), + sa.Column("thread_id", sa.Text(), nullable=False), + sa.Column("persona_name", sa.Text(), nullable=False), + sa.Column("persona_version", sa.Integer(), nullable=False), + sa.Column("model", sa.Text(), nullable=False), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("turn_index", sa.Integer(), nullable=False), + sa.Column("input_tokens", sa.Integer(), nullable=False), + sa.Column("output_tokens", sa.Integer(), nullable=False), + sa.Column("cached_tokens", sa.Integer(), nullable=False), + sa.Column("reasoning_tokens", sa.Integer(), nullable=False), + sa.Column("cost_usd_input", sa.Float(), nullable=False), + sa.Column("cost_usd_output", sa.Float(), nullable=False), + sa.Column("cost_usd_total", sa.Float(), nullable=False), + sa.Column("latency_ms", sa.Integer(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("error_code", sa.Text(), nullable=True), + sa.Column("request_id", sa.Text(), nullable=True), + sa.Column("ts", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["interactive_session_id"], ["interactive_sessions.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["phase_id"], ["run_phases.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "llm_calls_interactive_session_id_ts_idx", + "llm_calls", + ["interactive_session_id", "ts"], + unique=False, + ) + op.create_index("llm_calls_model_ts_idx", "llm_calls", ["model", "ts"], unique=False) + op.create_index("llm_calls_run_id_ts_idx", "llm_calls", ["run_id", "ts"], unique=False) + op.create_table( + "phase_feedback", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("run_id", sa.String(length=36), nullable=False), + sa.Column("phase_id", sa.String(length=36), nullable=False), + sa.Column("reaction", sa.Text(), nullable=True), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("created_at", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["phase_id"], ["run_phases.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "run_events", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("run_id", sa.String(length=36), nullable=False), + sa.Column("phase_id", sa.String(length=36), nullable=True), + sa.Column("seq", sa.Integer(), nullable=False), + sa.Column("type", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("idempotency_key", sa.Text(), nullable=False), + sa.Column("ts", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["phase_id"], ["run_phases.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("run_id", "idempotency_key", name="uq_run_events_run_idempotency"), + sa.UniqueConstraint("run_id", "seq", name="uq_run_events_run_seq"), + ) + op.create_index("run_events_run_id_ts_idx", "run_events", ["run_id", "ts"], unique=False) + op.create_table( + "tool_calls", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("run_id", sa.String(length=36), nullable=True), + sa.Column("phase_id", sa.String(length=36), nullable=True), + sa.Column("interactive_session_id", sa.String(length=36), nullable=True), + sa.Column("tool_name", sa.Text(), nullable=False), + sa.Column("args", sa.JSON(), nullable=False), + sa.Column("result", sa.JSON(), nullable=True), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("duration_ms", sa.Integer(), nullable=False), + sa.Column("ts", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["interactive_session_id"], ["interactive_sessions.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["phase_id"], ["run_phases.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("tool_calls_run_id_ts_idx", "tool_calls", ["run_id", "ts"], unique=False) op.create_table( "approval_decisions", sa.Column("id", sa.String(length=36), nullable=False), @@ -278,26 +304,32 @@ def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.drop_table("approval_decisions") - op.drop_table("run_phases") - op.drop_table("run_inputs") - op.drop_index("run_events_run_id_ts_idx", table_name="run_events") - op.drop_table("run_events") - op.drop_table("run_commands") - op.drop_table("run_bindings") - op.drop_table("artifacts") - op.drop_table("approval_requests") - op.drop_table("workflow_templates") op.drop_index("tool_calls_run_id_ts_idx", table_name="tool_calls") op.drop_table("tool_calls") - op.drop_table("runs") + op.drop_index("run_events_run_id_ts_idx", table_name="run_events") + op.drop_table("run_events") op.drop_table("phase_feedback") - op.drop_table("persona_consents") - op.drop_table("model_pricing") op.drop_index("llm_calls_run_id_ts_idx", table_name="llm_calls") op.drop_index("llm_calls_model_ts_idx", table_name="llm_calls") op.drop_index("llm_calls_interactive_session_id_ts_idx", table_name="llm_calls") op.drop_table("llm_calls") + op.drop_table("artifacts") + op.drop_table("approval_requests") + op.drop_table("run_phases") + op.drop_table("run_inputs") + op.drop_table("run_commands") + op.drop_table("run_bindings") + op.drop_index( + "ux_active_run_repo_base", + table_name="runs", + postgresql_where=sa.text("state NOT IN ('completed', 'failed', 'aborted')"), + sqlite_where=sa.text("state NOT IN ('completed', 'failed', 'aborted')"), + ) + op.drop_table("runs") op.drop_table("interactive_sessions") + op.drop_table("workflow_templates") + op.drop_table("persona_consents") + op.drop_table("model_pricing") op.drop_table("budget_ledger") op.drop_table("agent_personas") # ### end Alembic commands ### diff --git a/my-deepagent/pyproject.toml b/my-deepagent/pyproject.toml index f67873f..84a110e 100644 --- a/my-deepagent/pyproject.toml +++ b/my-deepagent/pyproject.toml @@ -4,7 +4,8 @@ version = "0.1.0" description = "Add your description here" requires-python = ">=3.12" dependencies = [ - "aiosqlite>=0.20", + "asyncpg>=0.30", + "psycopg[binary]>=3.2", "alembic>=1.14", "greenlet>=3.0", "sqlalchemy[asyncio]>=2.0", @@ -15,7 +16,7 @@ dependencies = [ "langchain-core>=0.3.0,<2.0.0", "langchain-openai>=0.3.0,<2.0.0", "langgraph>=0.2.0", - "langgraph-checkpoint-sqlite>=2.0.0", + "langgraph-checkpoint-postgres>=2.0.0", "openai>=1.0.0", "platformdirs>=4.9", "prompt-toolkit>=3.0", @@ -46,6 +47,12 @@ markers = [ [dependency-groups] dev = [ + # aiosqlite is a TEST-ONLY dependency: production runs on Postgres + # (asyncpg, see [project.dependencies]) but the bulk of the test suite uses + # sqlite+aiosqlite tmp_path URLs for speed + isolation simplicity. Live + # Postgres validation happens via the E2E suite (real OpenRouter + + # docker-compose Postgres). + "aiosqlite>=0.20", "mypy>=1.13", "pre-commit>=4.0", "pytest>=8.3", diff --git a/my-deepagent/src/my_deepagent/budget.py b/my-deepagent/src/my_deepagent/budget.py index 7f9c1d6..0234c82 100644 --- a/my-deepagent/src/my_deepagent/budget.py +++ b/my-deepagent/src/my_deepagent/budget.py @@ -13,12 +13,11 @@ from datetime import UTC, datetime from enum import StrEnum from uuid import UUID -from sqlalchemy.dialects.sqlite import insert as sqlite_insert - from .config import Config from .errors import BudgetExhaustedError from .persistence.db import Database from .persistence.models import BudgetLedgerRow +from .persistence.upsert import insert_for _logger = logging.getLogger(__name__) @@ -173,8 +172,9 @@ class BudgetTracker: from sqlalchemy.ext.asyncio import AsyncSession session: AsyncSession = s # type: ignore[assignment] + insert = insert_for(session) stmt = ( - sqlite_insert(BudgetLedgerRow) + insert(BudgetLedgerRow) .values(scope=scope, spent_usd=0.0, cap_usd=cap, last_updated=_now_iso()) .on_conflict_do_nothing(index_elements=["scope"]) ) @@ -198,8 +198,9 @@ class BudgetTracker: from sqlalchemy.ext.asyncio import AsyncSession session: AsyncSession = s # type: ignore[assignment] + insert = insert_for(session) stmt = ( - sqlite_insert(BudgetLedgerRow) + insert(BudgetLedgerRow) .values(scope=scope, spent_usd=delta_usd, cap_usd=cap, last_updated=_now_iso()) .on_conflict_do_update( index_elements=["scope"], diff --git a/my-deepagent/src/my_deepagent/cli/doctor.py b/my-deepagent/src/my_deepagent/cli/doctor.py index 61d69d6..1237c73 100644 --- a/my-deepagent/src/my_deepagent/cli/doctor.py +++ b/my-deepagent/src/my_deepagent/cli/doctor.py @@ -8,7 +8,7 @@ Checks: 5. config + governance consent 6. OpenRouter API key reachable 7. OpenRouter /models ping + pricing matrix upsert - 8. Disk free + SQLite integrity_check + 8. Disk free + DB liveness probe (pg `SELECT 1` / sqlite `PRAGMA integrity_check`) """ from __future__ import annotations @@ -26,7 +26,6 @@ import typer from rich.console import Console from rich.table import Table from sqlalchemy import text as sa_text -from sqlalchemy.dialects.sqlite import insert as sqlite_insert from ..config import Config, load_config from ..errors import MyDeepAgentError @@ -38,6 +37,7 @@ from ..monitoring.pricing import ( ) from ..persistence.db import Database from ..persistence.models import ModelPricingRow +from ..persistence.upsert import insert_for from ..secrets import resolve_openrouter_api_key _CONSOLE = Console() @@ -147,9 +147,10 @@ async def _upsert_pricing(config: Config, prices: list[ModelPrice]) -> None: now = datetime.now(UTC).isoformat(timespec="seconds") try: async with db.session() as s: + insert = insert_for(s) for p in prices: stmt = ( - sqlite_insert(ModelPricingRow) + insert(ModelPricingRow) .values( model=p.model, input_per_1k_usd=p.input_per_1k_usd, @@ -175,6 +176,12 @@ async def _upsert_pricing(config: Config, prices: list[ModelPrice]) -> None: async def _check_disk_and_db(config: Config) -> CheckResult: + """Disk free + DB liveness probe. + + Postgres path: ``SELECT 1`` round-trip (pg_isready equivalent — proves + network reachability, auth, and that the DB exists). + SQLite path: ``PRAGMA integrity_check`` to detect corruption. + """ usage = shutil.disk_usage(str(config.workspace_root)) free_gb = usage.free / (1024**3) if free_gb < 2.0: @@ -185,15 +192,34 @@ async def _check_disk_and_db(config: Config) -> CheckResult: disk_status = "ok" db = Database(config.database_url) - await db.init_schema() + db_detail = "" + db_ok = False try: + # init_schema is idempotent and safe; for Postgres it requires CREATE + # privileges, which the default devflow role has on the mydeepagent DB. + # If alembic has already been applied this is a no-op. + await db.init_schema() async with db.session() as s: - row = (await s.execute(sa_text("PRAGMA integrity_check"))).scalar_one() + if db.dialect_name == "postgresql": + # pg_isready-equivalent: simple round-trip query proves the + # server is reachable, auth works, and the DB exists. + row = (await s.execute(sa_text("SELECT 1"))).scalar_one() + db_ok = row == 1 + db_detail = f"postgres_alive={'ok' if db_ok else 'fail'}" + elif db.dialect_name == "sqlite": + row = (await s.execute(sa_text("PRAGMA integrity_check"))).scalar_one() + db_ok = row == "ok" + db_detail = f"sqlite_integrity={'ok' if db_ok else str(row)}" + else: # pragma: no cover — defensive for future dialects + db_ok = True + db_detail = f"dialect={db.dialect_name},probe=skipped" + except Exception as e: + db_ok = False + db_detail = f"db_error={type(e).__name__}:{e}" finally: await db.dispose() - db_ok = row == "ok" - detail = f"free={free_gb:.1f}GB, sqlite_integrity={'ok' if db_ok else str(row)}" + detail = f"free={free_gb:.1f}GB, {db_detail}" if disk_status == "fail" or not db_ok: final: Literal["ok", "warn", "fail"] = "fail" elif disk_status == "warn": diff --git a/my-deepagent/src/my_deepagent/config.py b/my-deepagent/src/my_deepagent/config.py index 0a02f85..5af7bbe 100644 --- a/my-deepagent/src/my_deepagent/config.py +++ b/my-deepagent/src/my_deepagent/config.py @@ -33,10 +33,12 @@ class Config(BaseSettings): ) # storage + # v0.2 PR #1: Postgres is the production default. Local docker-compose ships + # a `devflow-postgres` container on port 55432 with credentials + # devflow / devflow. The v3 `devflow` DB is preserved untouched; v4 lives in + # a fresh `mydeepagent` DB. Tests may override via MYDEEPAGENT_DATABASE_URL. database_url: str = Field( - default_factory=lambda: ( - f"sqlite+aiosqlite:///{Path(_DIRS.user_data_dir) / 'database.sqlite3'}" - ) + default="postgresql+asyncpg://devflow:devflow@localhost:55432/mydeepagent" ) workspace_root: Path = Field(default_factory=Path.cwd) data_dir: Path = Field(default_factory=lambda: Path(_DIRS.user_data_dir)) diff --git a/my-deepagent/src/my_deepagent/persistence/checkpointer.py b/my-deepagent/src/my_deepagent/persistence/checkpointer.py index f33dd90..9f93b80 100644 --- a/my-deepagent/src/my_deepagent/persistence/checkpointer.py +++ b/my-deepagent/src/my_deepagent/persistence/checkpointer.py @@ -1,41 +1,62 @@ -"""LangGraph SqliteSaver wrapper. Use only as a context manager to ensure connection cleanup. +"""LangGraph AsyncPostgresSaver wrapper. Use only as an async context manager. -``SqliteSaver.from_conn_string`` is a ``@contextmanager`` classmethod that yields -a ``SqliteSaver`` instance and closes the underlying sqlite3 connection on exit. -Direct manual lifecycle management (entering context without ``with``) leaks connections -and is not supported by this module. +``AsyncPostgresSaver.from_conn_string`` is an ``@asynccontextmanager`` classmethod +that yields an ``AsyncPostgresSaver`` instance and closes the underlying Postgres +connection on exit. Direct manual lifecycle management (entering context without +``async with``) leaks connections and is not supported by this module. + +v0.2 PR #1: switched from SqliteSaver to AsyncPostgresSaver. The API is now +async and takes a connection string instead of a filesystem path; the legacy +``Path`` parameter form has been removed. Usage:: - with get_checkpointer_ctx(path) as saver: + async with get_checkpointer_ctx(conn_string) as saver: graph = create_deep_agent(checkpointer=saver) ... """ from __future__ import annotations -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager -from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver -@contextmanager -def get_checkpointer_ctx(checkpoints_db_path: Path) -> Iterator[SqliteSaver]: - """Yield a SqliteSaver bound to *checkpoints_db_path*. +def _to_psycopg_dsn(database_url: str) -> str: + """Strip the SQLAlchemy driver prefix (``+asyncpg`` / ``+psycopg``) from a URL. - Creates the parent directory and the database file if they do not exist. - The underlying sqlite3 connection is closed automatically on context exit. - This is the only supported way to obtain a SqliteSaver in this project — - direct manual lifecycle management is not provided. + AsyncPostgresSaver wants a plain libpq DSN (e.g. ``postgresql://...``), + while the rest of the project uses SQLAlchemy URLs (``postgresql+asyncpg://...``). + """ + if database_url.startswith("postgresql+asyncpg://"): + return "postgresql://" + database_url[len("postgresql+asyncpg://") :] + if database_url.startswith("postgresql+psycopg://"): + return "postgresql://" + database_url[len("postgresql+psycopg://") :] + return database_url + + +@asynccontextmanager +async def get_checkpointer_ctx(database_url: str) -> AsyncIterator[AsyncPostgresSaver]: + """Yield an AsyncPostgresSaver bound to *database_url*. + + The underlying psycopg connection is closed automatically on context exit. + This is the only supported way to obtain a saver in this project — direct + manual lifecycle management is not provided. + + On first use, ``saver.setup()`` runs the LangGraph checkpoint schema + creation idempotently. Args: - checkpoints_db_path: Filesystem path for the SQLite checkpoint database. + database_url: SQLAlchemy-style URL (``postgresql+asyncpg://user:pw@host:port/db``) + or a plain libpq DSN (``postgresql://...``). The SQLAlchemy + ``+asyncpg`` / ``+psycopg`` driver suffix is stripped automatically. Yields: - SqliteSaver: Ready-to-use LangGraph checkpoint saver. + AsyncPostgresSaver: Ready-to-use LangGraph checkpoint saver. """ - checkpoints_db_path.parent.mkdir(parents=True, exist_ok=True) - with SqliteSaver.from_conn_string(str(checkpoints_db_path)) as saver: + dsn = _to_psycopg_dsn(database_url) + async with AsyncPostgresSaver.from_conn_string(dsn) as saver: + await saver.setup() yield saver diff --git a/my-deepagent/src/my_deepagent/persistence/db.py b/my-deepagent/src/my_deepagent/persistence/db.py index 584a5dc..ce1a555 100644 --- a/my-deepagent/src/my_deepagent/persistence/db.py +++ b/my-deepagent/src/my_deepagent/persistence/db.py @@ -1,4 +1,11 @@ -"""Async SQLAlchemy engine + session factory with WAL mode and busy_timeout.""" +"""Async SQLAlchemy engine + session factory (Postgres primary; SQLite legacy fallback). + +v0.2 PR #1: Postgres becomes the default backing store for my-deepagent. +SQLite is no longer the default — but the engine factory still detects the +dialect at construct time so that tests / one-off CLI uses can still point at +``sqlite+aiosqlite://`` URLs when needed. Production code paths default to +Postgres via :attr:`Config.database_url`. +""" from __future__ import annotations @@ -6,6 +13,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from sqlalchemy import event +from sqlalchemy.engine import Engine from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -16,25 +24,39 @@ from sqlalchemy.ext.asyncio import ( from .models import Base -def _attach_sqlite_pragmas(engine: AsyncEngine) -> None: - """Attach a synchronous connect-event listener that enables WAL, busy_timeout, FK.""" +def _attach_dialect_pragmas(engine: AsyncEngine) -> None: + """Attach dialect-specific connect-time PRAGMA / SET listeners. - @event.listens_for(engine.sync_engine, "connect") - def _set_sqlite_pragma(dbapi_connection: object, _conn_record: object) -> None: - # dbapi_connection is a raw sqlite3.Connection delivered by SQLAlchemy's - # pool event callback. The signature uses `object` to match the generic - # listener protocol; we cast to `Any` here to access DBAPI methods without - # introducing a hard import of `sqlite3` (which would break non-SQLite - # engines). The pragma calls are safe: they are no-ops on non-SQLite - # dialects and sqlite3.Connection always has `.cursor()`. - import sqlite3 # local import to avoid circular or non-SQLite coupling + SQLite: WAL mode + busy_timeout + foreign_keys ON. + Postgres: no PRAGMA equivalent needed — defaults already give us the + isolation level and FK enforcement we want; we only set the session + timezone to UTC so that any naive timestamps round-trip predictably. + """ + sync_engine: Engine = engine.sync_engine + dialect_name = sync_engine.dialect.name - conn: sqlite3.Connection = dbapi_connection # type: ignore[assignment] - cursor = conn.cursor() - cursor.execute("PRAGMA journal_mode=WAL") - cursor.execute("PRAGMA busy_timeout=5000") - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() + if dialect_name == "sqlite": + + @event.listens_for(sync_engine, "connect") + def _set_sqlite_pragma(dbapi_connection: object, _conn_record: object) -> None: + # dbapi_connection is a raw sqlite3.Connection delivered by SQLAlchemy's + # pool event callback. Local import avoids a hard sqlite3 coupling on + # Postgres-only deployments. + import sqlite3 # noqa: F401 # imported for the type annotation only + + cursor = dbapi_connection.cursor() # type: ignore[attr-defined] + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + elif dialect_name == "postgresql": + + @event.listens_for(sync_engine, "connect") + def _set_postgres_session(dbapi_connection: object, _conn_record: object) -> None: + cursor = dbapi_connection.cursor() # type: ignore[attr-defined] + cursor.execute("SET TIME ZONE 'UTC'") + cursor.close() class Database: @@ -42,7 +64,7 @@ class Database: Usage:: - db = Database("sqlite+aiosqlite:///path/to/db.sqlite3") + db = Database("postgresql+asyncpg://devflow:devflow@localhost:55432/devflow") await db.init_schema() # dev/test: create all tables directly async with db.session() as s: # production: use alembic upgrade head result = await s.execute(...) @@ -55,17 +77,21 @@ class Database: def __init__(self, database_url: str) -> None: self._engine: AsyncEngine = create_async_engine( database_url, - # NullPool avoids connection reuse issues in SQLite+aiosqlite tests. - poolclass=None, # use the default StaticPool-compatible pool + poolclass=None, echo=False, ) - _attach_sqlite_pragmas(self._engine) + _attach_dialect_pragmas(self._engine) self._session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker( bind=self._engine, expire_on_commit=False, autoflush=False, ) + @property + def dialect_name(self) -> str: + """Return the SQLAlchemy dialect name (``postgresql`` or ``sqlite``).""" + return self._engine.sync_engine.dialect.name + async def init_schema(self) -> None: """Create all ORM-defined tables. @@ -75,6 +101,11 @@ class Database: async with self._engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + async def drop_schema(self) -> None: + """Drop all ORM-defined tables. Test-only; production must never call this.""" + async with self._engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + @asynccontextmanager async def session(self) -> AsyncIterator[AsyncSession]: """Yield an async session; commit on success, rollback on exception.""" diff --git a/my-deepagent/src/my_deepagent/persistence/models.py b/my-deepagent/src/my_deepagent/persistence/models.py index f686d29..9ad044b 100644 --- a/my-deepagent/src/my_deepagent/persistence/models.py +++ b/my-deepagent/src/my_deepagent/persistence/models.py @@ -78,13 +78,16 @@ class RunRow(Base): __table_args__ = ( # Partial unique index: at most one active run per (repo_path, base_branch). # An "active" run is any run whose state is not 'completed', 'failed', or 'aborted'. - # SQLite partial index uses a WHERE clause; autogenerate cannot detect this, - # so it is managed via a manual alembic migration. + # Both SQLite and PostgreSQL support partial indexes with a WHERE clause — + # SQLAlchemy needs the dialect-specific kwarg for each. Autogenerate cannot + # detect this, so the alembic migration is hand-edited to call + # `op.create_index(..., postgresql_where=..., sqlite_where=...)`. Index( "ux_active_run_repo_base", "repo_path", "base_branch", unique=True, + postgresql_where=text("state NOT IN ('completed', 'failed', 'aborted')"), sqlite_where=text("state NOT IN ('completed', 'failed', 'aborted')"), ), ) diff --git a/my-deepagent/src/my_deepagent/persistence/upsert.py b/my-deepagent/src/my_deepagent/persistence/upsert.py new file mode 100644 index 0000000..8d9b23b --- /dev/null +++ b/my-deepagent/src/my_deepagent/persistence/upsert.py @@ -0,0 +1,45 @@ +"""Dialect-aware UPSERT helper. + +SQLite and PostgreSQL both expose an ``insert(...).on_conflict_do_*()`` API, +but they live under different dialect modules with slightly different +re-exports. ``insert_for(session)`` picks the right ``insert`` at runtime +based on the session's bound engine, so call sites can stay portable. + +Both dialects accept the same kwargs for the methods we use +(``on_conflict_do_nothing(index_elements=...)`` and +``on_conflict_do_update(index_elements=..., set_=...)``). +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + + +def insert_for(session: AsyncSession) -> Any: + """Return the ``insert`` constructor that matches ``session``'s dialect. + + Args: + session: An async session bound to either a Postgres or SQLite engine. + + Returns: + The dialect-specific ``insert`` callable (``postgresql.insert`` or + ``sqlite.insert``). Pass an ORM class or Table; the returned + statement supports ``.on_conflict_do_nothing(...)`` and + ``.on_conflict_do_update(...)`` with the same kwargs in both dialects. + + Raises: + NotImplementedError: If the bound engine uses an unsupported dialect. + """ + bind = session.get_bind() + dialect_name = bind.dialect.name + if dialect_name == "postgresql": + from sqlalchemy.dialects.postgresql import insert as _pg_insert + + return _pg_insert + if dialect_name == "sqlite": + from sqlalchemy.dialects.sqlite import insert as _sqlite_insert + + return _sqlite_insert + raise NotImplementedError(f"upsert not implemented for dialect={dialect_name!r}") diff --git a/my-deepagent/src/my_deepagent/recovery.py b/my-deepagent/src/my_deepagent/recovery.py index 1937757..3677f8b 100644 --- a/my-deepagent/src/my_deepagent/recovery.py +++ b/my-deepagent/src/my_deepagent/recovery.py @@ -13,12 +13,12 @@ from datetime import UTC, datetime from uuid import UUID from sqlalchemy import func, select -from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.ext.asyncio import AsyncSession from .enums import RunPhaseState, RunState from .persistence.db import Database from .persistence.models import RunEventRow, RunPhaseRow, RunRow +from .persistence.upsert import insert_for from .run_event import RunEventType, run_idempotency_key _NON_TERMINAL_RUN_STATES: frozenset[str] = frozenset( @@ -139,8 +139,9 @@ async def _append_event_idempotent( ) ).scalar_one() + insert = insert_for(s) stmt = ( - sqlite_insert(RunEventRow) + insert(RunEventRow) .values( run_id=run_id, phase_id=None, diff --git a/my-deepagent/tests/conftest.py b/my-deepagent/tests/conftest.py new file mode 100644 index 0000000..d892a76 --- /dev/null +++ b/my-deepagent/tests/conftest.py @@ -0,0 +1,80 @@ +"""Test fixtures shared across unit + integration tests. + +v0.2 PR #1: tests run against the live Postgres container managed by +docker-compose. Each test that needs DB isolation requests the +``pg_db_url`` fixture, which creates a fresh database per test and drops it +on teardown. + +Prerequisites: + docker compose up -d postgres # devflow-postgres on 55432 +""" + +from __future__ import annotations + +import os +import uuid +from collections.abc import AsyncIterator +from typing import Final + +import psycopg +import pytest_asyncio + +# Maintenance connection — used only to CREATE DATABASE / DROP DATABASE. +# `postgres` is the bootstrap DB present on every Postgres install. +_MAINTENANCE_DSN: Final[str] = os.environ.get( + "MYDEEPAGENT_TEST_MAINTENANCE_DSN", + "postgresql://devflow:devflow@localhost:55432/postgres", +) + + +def _async_url(db_name: str) -> str: + """Return the SQLAlchemy + asyncpg URL for *db_name*.""" + return f"postgresql+asyncpg://devflow:devflow@localhost:55432/{db_name}" + + +def _create_test_database() -> str: + """Create a fresh test database with a random suffix and return its name.""" + db_name = f"test_{uuid.uuid4().hex[:16]}" + # autocommit=True is required for CREATE DATABASE (cannot run in a tx block). + with psycopg.connect(_MAINTENANCE_DSN, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute(f'CREATE DATABASE "{db_name}"') + return db_name + + +def _drop_test_database(db_name: str) -> None: + """Forcefully terminate connections and drop *db_name*. Idempotent.""" + with psycopg.connect(_MAINTENANCE_DSN, autocommit=True) as conn: + with conn.cursor() as cur: + # Kick any lingering connections held by aiosqlite-style pools that + # didn't dispose cleanly. WITH (FORCE) is Postgres 13+. + cur.execute( + """ + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = %s AND pid <> pg_backend_pid() + """, + (db_name,), + ) + cur.execute(f'DROP DATABASE IF EXISTS "{db_name}"') + + +@pytest_asyncio.fixture +async def pg_db_url() -> AsyncIterator[str]: + """Yield an isolated Postgres database URL; drop the DB on teardown. + + Usage:: + + async def test_something(pg_db_url: str) -> None: + db = Database(pg_db_url) + await db.init_schema() + ... + + The returned URL uses the asyncpg driver. To get a sync URL for tools + like alembic, replace ``postgresql+asyncpg://`` with ``postgresql+psycopg://``. + """ + db_name = _create_test_database() + try: + yield _async_url(db_name) + finally: + _drop_test_database(db_name) diff --git a/my-deepagent/tests/integration/test_checkpointer.py b/my-deepagent/tests/integration/test_checkpointer.py index 9c7854a..9277461 100644 --- a/my-deepagent/tests/integration/test_checkpointer.py +++ b/my-deepagent/tests/integration/test_checkpointer.py @@ -1,78 +1,61 @@ -"""Integration tests for src/my_deepagent/persistence/checkpointer.py.""" +"""Integration tests for src/my_deepagent/persistence/checkpointer.py. + +v0.2 PR #1: rewritten for AsyncPostgresSaver (LangGraph Postgres checkpointer). +The legacy SqliteSaver / Path-based API is removed. + +Requires the docker-compose `devflow-postgres` container; the ``pg_db_url`` +fixture from ``tests/conftest.py`` creates a fresh DB per test. +""" from __future__ import annotations -import sqlite3 -from pathlib import Path +import pytest -from my_deepagent.persistence.checkpointer import get_checkpointer_ctx +from my_deepagent.persistence.checkpointer import _to_psycopg_dsn, get_checkpointer_ctx +class TestToPsycopgDsn: + """Pure-function tests for the SQLAlchemy → libpq DSN converter.""" + + def test_strips_asyncpg_prefix(self) -> None: + url = "postgresql+asyncpg://u:p@h:1/d" + assert _to_psycopg_dsn(url) == "postgresql://u:p@h:1/d" + + def test_strips_psycopg_prefix(self) -> None: + url = "postgresql+psycopg://u:p@h:1/d" + assert _to_psycopg_dsn(url) == "postgresql://u:p@h:1/d" + + def test_bare_postgres_url_passes_through(self) -> None: + url = "postgresql://u:p@h:1/d" + assert _to_psycopg_dsn(url) == url + + def test_non_postgres_url_passes_through(self) -> None: + url = "sqlite:///x" + assert _to_psycopg_dsn(url) == url + + +@pytest.mark.integration class TestGetCheckpointerCtx: - """Tests for the get_checkpointer_ctx context manager.""" - - def test_ctx_yields_saver_and_cleans_up(self, tmp_path: Path) -> None: - """Entering the context yields a SqliteSaver; exiting releases the connection.""" - db_path = tmp_path / "ck.db" - with get_checkpointer_ctx(db_path) as saver: - assert saver is not None - # The DB file must exist while inside the context. - assert db_path.exists() - - # After context exit the file must still exist (not deleted). - assert db_path.exists() - - def test_db_file_created_on_enter(self, tmp_path: Path) -> None: - """The sqlite file is created when the context is entered.""" - db_path = tmp_path / "nested" / "dir" / "ck.db" - assert not db_path.exists() - - with get_checkpointer_ctx(db_path): - assert db_path.exists() - - def test_parent_dir_created_if_missing(self, tmp_path: Path) -> None: - """Parent directory is created automatically even if it does not exist.""" - db_path = tmp_path / "a" / "b" / "c" / "ck.db" - assert not db_path.parent.exists() - - with get_checkpointer_ctx(db_path): - assert db_path.parent.exists() - - def test_connection_released_after_ctx_exit(self, tmp_path: Path) -> None: - """After exiting the context manager, another process/connection can open the DB.""" - db_path = tmp_path / "ck.db" - - with get_checkpointer_ctx(db_path): - pass # enter and exit - - # If the connection were leaked (not closed), WAL mode can still allow reads, - # but we verify by opening with a fresh sqlite3 connection — this must succeed. - with sqlite3.connect(str(db_path)) as conn: - cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table'") - # LangGraph creates its checkpoint tables; result must be a list (not error). - tables = [row[0] for row in cur.fetchall()] - assert isinstance(tables, list) - - def test_meta_and_checkpoint_db_no_lock_conflict(self, tmp_path: Path) -> None: - """Using two separate DB files in the same directory causes no locking conflict.""" - meta_db = tmp_path / "meta.db" - ck_db = tmp_path / "checkpoints.db" - - # Simulate concurrent use: open both within the same scope. - with get_checkpointer_ctx(ck_db) as saver: - # Write something to the meta DB while the checkpointer holds its connection. - with sqlite3.connect(str(meta_db)) as conn: - conn.execute("CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)") - conn.execute("INSERT OR REPLACE INTO kv VALUES ('key', 'value')") - conn.commit() + """Tests for the async get_checkpointer_ctx context manager.""" + @pytest.mark.asyncio + async def test_ctx_yields_saver(self, pg_db_url: str) -> None: + """Entering the async context yields a non-None saver.""" + async with get_checkpointer_ctx(pg_db_url) as saver: assert saver is not None - # Both files must exist and be independently readable. - assert meta_db.exists() - assert ck_db.exists() + @pytest.mark.asyncio + async def test_setup_is_idempotent(self, pg_db_url: str) -> None: + """``saver.setup()`` is invoked on entry; entering twice must not error.""" + async with get_checkpointer_ctx(pg_db_url) as first: + assert first is not None + # A second open against the same DB must not raise — setup() is idempotent. + async with get_checkpointer_ctx(pg_db_url) as second: + assert second is not None - with sqlite3.connect(str(meta_db)) as conn: - row = conn.execute("SELECT v FROM kv WHERE k='key'").fetchone() - assert row is not None - assert row[0] == "value" + @pytest.mark.asyncio + async def test_accepts_sqlalchemy_url(self, pg_db_url: str) -> None: + """SQLAlchemy-style ``postgresql+asyncpg://`` URLs are accepted.""" + assert pg_db_url.startswith("postgresql+asyncpg://") + async with get_checkpointer_ctx(pg_db_url) as saver: + assert saver is not None diff --git a/my-deepagent/tests/integration/test_e2e_workflow.py b/my-deepagent/tests/integration/test_e2e_workflow.py index 05c85e7..4b48234 100644 --- a/my-deepagent/tests/integration/test_e2e_workflow.py +++ b/my-deepagent/tests/integration/test_e2e_workflow.py @@ -98,7 +98,7 @@ def _make_pricing() -> PricingCache: @pytest.mark.asyncio @pytest.mark.timeout(600) # 10 minute hard limit for slow LLM responses -async def test_e2e_spec_and_review_workflow(tmp_path: Path) -> None: +async def test_e2e_spec_and_review_workflow(tmp_path: Path, pg_db_url: str) -> None: """Real OpenRouter call: full spec-and-review@1 workflow end-to-end. Persona binding (all pinned via BindingOverride for determinism): @@ -112,16 +112,20 @@ async def test_e2e_spec_and_review_workflow(tmp_path: Path) -> None: Cost estimate: ~$0.01-$0.05 for 3 phases with max_tokens=4096 each. """ - # ---- Setup: config overrides pointing to tmp_path ---- + # ---- Setup: config overrides pointing to tmp_path + isolated Postgres DB. + # `pg_db_url` is the v0.2-PR-1 conftest fixture that creates a fresh + # Postgres DB per test (against docker-compose `devflow-postgres`) and + # drops it on teardown. This is the only test in the suite that exercises + # the production Postgres path end-to-end; the bulk of unit + integration + # tests still use sqlite+aiosqlite tmp_path for speed. ws_root = tmp_path / "ws" ws_root.mkdir(parents=True, exist_ok=True) - db_path = tmp_path / "e2e.sqlite" config = load_config( workspace_root=ws_root, data_dir=tmp_path / "data", state_dir=tmp_path / "state", - database_url=f"sqlite+aiosqlite:///{db_path}", + database_url=pg_db_url, budget_on_hit="warn_continue", # do not block during E2E test budget_run_usd=5.0, # generous cap for E2E budget_daily_usd=10.0, diff --git a/my-deepagent/uv.lock b/my-deepagent/uv.lock index 02501ae..06f291d 100644 --- a/my-deepagent/uv.lock +++ b/my-deepagent/uv.lock @@ -117,6 +117,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/78/9387dffccdc55a12734f83aaccc4a987404a217a2a12a1920d8d4585950b/ast_serialize-0.4.0-cp39-abi3-win_arm64.whl", hash = "sha256:1026f565a7ab846337c630909089b3346a2fe417bf1552b1581ab01852137407", size = 1079199, upload-time = "2026-05-14T22:44:36.816Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -868,17 +908,18 @@ wheels = [ ] [[package]] -name = "langgraph-checkpoint-sqlite" +name = "langgraph-checkpoint-postgres" version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiosqlite" }, { name = "langgraph-checkpoint" }, - { name = "sqlite-vec" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ea/83917c2369acf8a10a894d4247655fd063c07924ba5bc4e83c85d2eaeded/langgraph_checkpoint_sqlite-3.1.0.tar.gz", hash = "sha256:f926916ebc1b985d802cc9c820026036e84db9d910d62c97b57e4ba64f67d5ae", size = 147902, upload-time = "2026-05-12T03:34:52.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/51/5a2dc42e8b5d5942b933b5b7237eae5a4dbc92508a04727c263dd383ad8a/langgraph_checkpoint_postgres-3.1.0.tar.gz", hash = "sha256:02bff4ab63d9dae8eab3a9640fce1d479da8965c9fba7b0dc04cb1f7c56f0a55", size = 148473, upload-time = "2026-05-12T03:40:10.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/07/b342811a16327900af2747c752ea19676172fcddf9b592cc384031076623/langgraph_checkpoint_sqlite-3.1.0-py3-none-any.whl", hash = "sha256:cc9b40df0076feae8a9ad42ae713621b148b00ac23adc09dc1dc66090a46e5ad", size = 38587, upload-time = "2026-05-12T03:34:51.231Z" }, + { url = "https://files.pythonhosted.org/packages/2f/cd/eff9b82bc3b5f62d481b437099f44f3ef7b1d907f166fb4ee25e8f84a1e7/langgraph_checkpoint_postgres-3.1.0-py3-none-any.whl", hash = "sha256:814cce2ef35d792bf07b090a95eed004f1acac0724fe6605536b13f6d1e7032c", size = 48988, upload-time = "2026-05-12T03:40:08.925Z" }, ] [[package]] @@ -1097,8 +1138,8 @@ name = "my-deepagent" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "aiosqlite" }, { name = "alembic" }, + { name = "asyncpg" }, { name = "deepagents" }, { name = "greenlet" }, { name = "httpx" }, @@ -1108,10 +1149,11 @@ dependencies = [ { name = "langchain-core" }, { name = "langchain-openai" }, { name = "langgraph" }, - { name = "langgraph-checkpoint-sqlite" }, + { name = "langgraph-checkpoint-postgres" }, { name = "openai" }, { name = "platformdirs" }, { name = "prompt-toolkit" }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyyaml" }, @@ -1124,6 +1166,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "aiosqlite" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, @@ -1138,8 +1181,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiosqlite", specifier = ">=0.20" }, { name = "alembic", specifier = ">=1.14" }, + { name = "asyncpg", specifier = ">=0.30" }, { name = "deepagents", specifier = ">=0.6.1,<0.7.0" }, { name = "greenlet", specifier = ">=3.0" }, { name = "httpx", specifier = ">=0.28" }, @@ -1149,10 +1192,11 @@ requires-dist = [ { name = "langchain-core", specifier = ">=0.3.0,<2.0.0" }, { name = "langchain-openai", specifier = ">=0.3.0,<2.0.0" }, { name = "langgraph", specifier = ">=0.2.0" }, - { name = "langgraph-checkpoint-sqlite", specifier = ">=2.0.0" }, + { name = "langgraph-checkpoint-postgres", specifier = ">=2.0.0" }, { name = "openai", specifier = ">=1.0.0" }, { name = "platformdirs", specifier = ">=4.9" }, { name = "prompt-toolkit", specifier = ">=3.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -1165,6 +1209,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "aiosqlite", specifier = ">=0.20" }, { name = "mypy", specifier = ">=1.13" }, { name = "pre-commit", specifier = ">=4.0" }, { name = "pytest", specifier = ">=8.3" }, @@ -1414,6 +1459,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + [[package]] name = "pyasn1" version = "0.6.3" @@ -2030,18 +2145,6 @@ asyncio = [ { name = "greenlet" }, ] -[[package]] -name = "sqlite-vec" -version = "0.1.9" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, - { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, - { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, - { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, -] - [[package]] name = "structlog" version = "25.5.0" @@ -2176,6 +2279,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"