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

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