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:
@@ -0,0 +1,335 @@
|
||||
"""v0.2 baseline schema (Postgres)
|
||||
|
||||
Revision ID: 9f2a6c79667e
|
||||
Revises:
|
||||
Create Date: 2026-05-16 17:58:43.967026
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9f2a6c79667e"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"agent_personas",
|
||||
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(
|
||||
"budget_ledger",
|
||||
sa.Column("scope", sa.Text(), nullable=False),
|
||||
sa.Column("spent_usd", sa.Float(), nullable=False),
|
||||
sa.Column("cap_usd", sa.Float(), nullable=True),
|
||||
sa.Column("last_updated", sa.Text(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("scope"),
|
||||
)
|
||||
op.create_table(
|
||||
"model_pricing",
|
||||
sa.Column("model", sa.Text(), nullable=False),
|
||||
sa.Column("input_per_1k_usd", sa.Float(), nullable=False),
|
||||
sa.Column("output_per_1k_usd", sa.Float(), nullable=False),
|
||||
sa.Column("context_length", sa.Integer(), nullable=False),
|
||||
sa.Column("fetched_at", sa.Text(), nullable=False),
|
||||
sa.Column("raw_payload", sa.Text(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("model"),
|
||||
)
|
||||
op.create_table(
|
||||
"persona_consents",
|
||||
sa.Column("persona_hash", sa.Text(), nullable=False),
|
||||
sa.Column("persona_name", sa.Text(), nullable=False),
|
||||
sa.Column("persona_version", sa.Integer(), nullable=False),
|
||||
sa.Column("decision", sa.Text(), nullable=False),
|
||||
sa.Column("decided_at", sa.Text(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("persona_hash"),
|
||||
)
|
||||
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(
|
||||
"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",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_hash", sa.Text(), nullable=False),
|
||||
sa.Column("state", sa.Text(), nullable=False),
|
||||
sa.Column("repo_path", sa.Text(), nullable=False),
|
||||
sa.Column("base_branch", sa.Text(), nullable=False),
|
||||
sa.Column("worktree_root", sa.Text(), nullable=False),
|
||||
sa.Column("current_phase_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("started_at", sa.Text(), nullable=True),
|
||||
sa.Column("ended_at", sa.Text(), nullable=True),
|
||||
sa.Column("final_report_path", sa.Text(), nullable=True),
|
||||
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_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",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("run_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.Text(), nullable=False),
|
||||
sa.Column("persona_id", sa.String(length=36), nullable=False),
|
||||
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"),
|
||||
)
|
||||
op.create_table(
|
||||
"run_commands",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("run_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("command", sa.Text(), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.Text(), nullable=False),
|
||||
sa.Column("processed_at", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("idempotency_key"),
|
||||
)
|
||||
op.create_table(
|
||||
"run_inputs",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("run_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("requirements_md", sa.Text(), nullable=False),
|
||||
sa.Column("objective", sa.JSON(), nullable=False),
|
||||
sa.Column("extra", sa.JSON(), nullable=False),
|
||||
sa.Column("input_hash", sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("run_id"),
|
||||
)
|
||||
op.create_table(
|
||||
"run_phases",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("run_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("phase_key", sa.Text(), nullable=False),
|
||||
sa.Column("seq", sa.Integer(), nullable=False),
|
||||
sa.Column("state", sa.Text(), nullable=False),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False),
|
||||
sa.Column("started_at", sa.Text(), nullable=True),
|
||||
sa.Column("ended_at", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["run_id"], ["runs.id"], ondelete="CASCADE"),
|
||||
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),
|
||||
sa.Column("approval_request_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("action", sa.Text(), nullable=False),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column("decided_at", sa.Text(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["approval_request_id"], ["approval_requests.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("idempotency_key"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("approval_decisions")
|
||||
op.drop_index("tool_calls_run_id_ts_idx", table_name="tool_calls")
|
||||
op.drop_table("tool_calls")
|
||||
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_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 ###
|
||||
Reference in New Issue
Block a user