feat(my-deepagent): v0.1.0 Step 0~5 — scaffolding through deepagent + OpenRouter

Python rewrite of the agent harness on top of deepagents 0.6.1 + langchain 1.x,
replacing the abandoned TS attempt in packages/. 388 unit/integration tests pass.

Steps
-----
0. Scaffolding — uv workspace, ruff/mypy/pre-commit/alembic, src/tests/docs
   trees with docs/schemas/ seeded from my-deepagent-seed/.
1. Core — config (pydantic-settings with MYDEEPAGENT_ env prefix and TOML
   source), enums (Backend, Capability, RiskLevel, ApprovalDecisionAction,
   ApprovalState, RunState, RunPhaseState, SessionState, ErrorClass),
   errors (MyDeepAgentError + BudgetExhaustedError with PEP-3134 cause +
   context suppression), hash (canonical JSON + sha256).
2. Persona/Workflow/Binding — pydantic v2 schemas with tuple-based deep
   immutability (post-construction hash drift prevented), YAML loaders,
   deterministic auto-select (preferred_backends → version → name → hash),
   override resolution with ineligibility diagnostics, PersonaConsentStore
   with fcntl.flock + tmp+fsync+rename atomic write.
3. Artifact schema registry — Draft202012Validator, multi-root resolution,
   structured ValidationFinding output.
4. Persistence — 18 SQLAlchemy 2.0 async ORM models with FK CASCADE/RESTRICT,
   WAL + busy_timeout + foreign_keys PRAGMA, alembic baseline +
   ux_active_run_repo_base partial unique index, LangGraph SqliteSaver as
   context manager only (lifecycle safety).
5. DeepAgent session — build_agent wires Persona → create_deep_agent with
   LocalShellBackend / FilesystemBackend / StateBackend / CompositeBackend,
   ChatOpenAI(base_url=openrouter) for openrouter: model strings, and 4
   middleware classes (cost / audit-tool / safety-shell / fallback-model).

Critical workarounds
--------------------
- deepagents 0.6.1 rejects FilesystemPermission together with backends that
  implement SandboxBackendProtocol (LocalShellBackend). SafetyShellMiddleware
  enforces destructive-command and secret-path policy at the tool layer
  instead, and build_agent strips the permissions kwarg when the persona's
  deepagents_backend is local_shell.
- FilesystemOperation in deepagents is Literal['read', 'write'] only;
  _map_operations collapses our richer schema (read/write/edit/ls) safely.

Real OpenRouter smoke
---------------------
test_openrouter_deepagents_local_shell_smoke calls DeepSeek via deepagents +
LocalShellBackend + SafetyShellMiddleware end-to-end. PASS, ~$0.000001 cost,
input=9 / output=1 tokens with content "OK".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-15 19:40:02 +09:00
parent 1fe59d16ca
commit 17ba5d723b
100 changed files with 12408 additions and 0 deletions

View File

@@ -0,0 +1,303 @@
"""baseline schema for v0.1.0
Revision ID: 79945fdc2649
Revises:
Create Date: 2026-05-15 17:19:09.577439
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "79945fdc2649"
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(
"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),
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(
"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.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.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_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(["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_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),
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_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_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_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("interactive_sessions")
op.drop_table("budget_ledger")
op.drop_table("agent_personas")
# ### end Alembic commands ###

View File

@@ -0,0 +1,638 @@
"""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")