feat(my-deepagent): v0.2 PR #1 — Postgres migration (ahead of M8-Py FastAPI)

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:///<path>` 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) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-16 18:11:19 +09:00
parent 55be4f3aa0
commit e21a5241bf
17 changed files with 730 additions and 936 deletions

View File

@@ -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:///<data_dir>/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:///<path>` 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

View File

@@ -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.

View File

@@ -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")

View File

@@ -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 ###

View File

@@ -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",

View File

@@ -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"],

View File

@@ -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:
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":

View File

@@ -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))

View File

@@ -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

View File

@@ -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,33 +24,47 @@ 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")
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
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. 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
# pool event callback. Local import avoids a hard sqlite3 coupling on
# Postgres-only deployments.
import sqlite3 # noqa: F401 # imported for the type annotation only
conn: sqlite3.Connection = dbapi_connection # type: ignore[assignment]
cursor = conn.cursor()
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:
"""Façade over async engine + session maker.
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."""

View File

@@ -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')"),
),
)

View File

@@ -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}")

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

154
my-deepagent/uv.lock generated
View File

@@ -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"