206 lines
8.5 KiB
Python
206 lines
8.5 KiB
Python
"""Phase 2 migration for the MAF roundtable experiment.
|
|
|
|
Idempotent migration that brings a Phase-1 deployment up to Phase-2 schema:
|
|
|
|
1. Create ``maf_roundtable_seat_runs`` (MAF↔DeerFlow run mapping, survives restart).
|
|
2. Add ``source_event_id`` column to ``maf_roundtable_events`` (durable event dedup).
|
|
3. Create the ``(run_id, source_event_id)`` unique index on ``maf_roundtable_events``.
|
|
4. Create ``maf_roundtable_action_secrets`` (encrypted actual execution params, server-only).
|
|
5. Add ``turn_index`` column to ``maf_roundtable_actions`` (Phase-1 DBs may lack it).
|
|
|
|
Supports SQLite, PostgreSQL, and MySQL. Each step checks whether the object
|
|
already exists before creating it, so running the script multiple times has no
|
|
side effect. Old event rows keep ``source_event_id = NULL`` (SQL treats NULLs as
|
|
distinct in a UNIQUE constraint, so they are never deduped).
|
|
|
|
**Deployment order**: run this migration BEFORE enabling the
|
|
``maf_roundtable.enabled`` feature flag.
|
|
|
|
Usage (from ``offline-backend-20260512/backend/``)::
|
|
|
|
# Preview (no writes)
|
|
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase2.py --dry-run
|
|
|
|
# Execute
|
|
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_phase2.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from typing import Any
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
logger = logging.getLogger("migrate_maf_roundtable_phase2")
|
|
|
|
|
|
async def _migrate(dry_run: bool) -> int:
|
|
from deerflow.config import get_app_config
|
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
|
|
|
config = get_app_config()
|
|
await init_engine_from_config(config.database)
|
|
sf = get_session_factory()
|
|
if sf is None:
|
|
logger.error("Database backend is 'memory' — nothing to migrate.")
|
|
return 0
|
|
|
|
engine = sf.kw["bind"] # the async engine
|
|
|
|
# Run introspection + DDL on the sync connection (works across all dialects).
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(_do_migrate_sync, dry_run=dry_run, logger=logger, changes_ref=[0])
|
|
|
|
await close_engine()
|
|
logger.info("Migration complete (dry_run=%s).", dry_run)
|
|
return 0
|
|
|
|
|
|
def _do_migrate_sync(
|
|
sync_conn: Any,
|
|
*,
|
|
dry_run: bool,
|
|
logger: logging.Logger,
|
|
changes_ref: list[int],
|
|
) -> None:
|
|
"""Run all migration steps on a sync connection (called via run_sync)."""
|
|
from sqlalchemy import inspect, text
|
|
|
|
insp = inspect(sync_conn)
|
|
dialect_name = sync_conn.dialect.name
|
|
|
|
def _exec(sql: str) -> None:
|
|
if dry_run:
|
|
logger.info(" [dry-run] would execute: %s", sql.strip()[:300])
|
|
return
|
|
sync_conn.execute(text(sql))
|
|
changes_ref[0] += 1
|
|
|
|
# ── 1. maf_roundtable_seat_runs ──────────────────────────────────────
|
|
if "maf_roundtable_seat_runs" not in insp.get_table_names():
|
|
logger.info("Creating table: maf_roundtable_seat_runs")
|
|
for stmt in _DDL_SEAT_RUNS:
|
|
_exec(stmt)
|
|
else:
|
|
logger.info("Table maf_roundtable_seat_runs already exists — skip.")
|
|
|
|
# ── 2. source_event_id column on maf_roundtable_events ──────────────
|
|
if "maf_roundtable_events" in insp.get_table_names():
|
|
event_cols = {c["name"] for c in insp.get_columns("maf_roundtable_events")}
|
|
if "source_event_id" not in event_cols:
|
|
logger.info("Adding column source_event_id to maf_roundtable_events")
|
|
_exec(_ddl_add_source_event_id(dialect_name))
|
|
else:
|
|
logger.info("Column source_event_id already exists on maf_roundtable_events — skip.")
|
|
else:
|
|
logger.info("Table maf_roundtable_events not present — skip source_event_id migration.")
|
|
|
|
# ── 3. (run_id, source_event_id) unique index ───────────────────────
|
|
index_found = False
|
|
if "maf_roundtable_events" in insp.get_table_names():
|
|
for idx in insp.get_indexes("maf_roundtable_events"):
|
|
if idx.get("name") == "uq_maf_events_run_source":
|
|
index_found = True
|
|
break
|
|
if not index_found and "maf_roundtable_events" in insp.get_table_names():
|
|
logger.info("Creating unique index uq_maf_events_run_source on maf_roundtable_events")
|
|
_exec(_DDL_EVENTS_SOURCE_INDEX)
|
|
else:
|
|
logger.info("Index uq_maf_events_run_source already exists or events table absent — skip.")
|
|
|
|
# ── 4. maf_roundtable_action_secrets ─────────────────────────────────
|
|
if "maf_roundtable_action_secrets" not in insp.get_table_names():
|
|
logger.info("Creating table: maf_roundtable_action_secrets")
|
|
for stmt in _DDL_ACTION_SECRETS:
|
|
_exec(stmt)
|
|
else:
|
|
logger.info("Table maf_roundtable_action_secrets already exists — skip.")
|
|
|
|
# ── 5. turn_index on maf_roundtable_actions (Phase-1 deployments) ───
|
|
action_cols = (
|
|
{c["name"] for c in insp.get_columns("maf_roundtable_actions")}
|
|
if "maf_roundtable_actions" in insp.get_table_names()
|
|
else set()
|
|
)
|
|
if "maf_roundtable_actions" in insp.get_table_names() and "turn_index" not in action_cols:
|
|
logger.info("Adding column turn_index to maf_roundtable_actions")
|
|
_exec(_ddl_add_action_turn_index(dialect_name))
|
|
elif "turn_index" in action_cols:
|
|
logger.info("Column turn_index already exists on maf_roundtable_actions — skip.")
|
|
|
|
logger.info("Migration steps: %d applied.", changes_ref[0])
|
|
|
|
|
|
# ── DDL templates (portable across sqlite / postgres / mysql) ──────────────
|
|
# Each DDL is a LIST of single SQL statements so ``text()`` receives one
|
|
# statement at a time — multi-statement strings fail on MySQL and several
|
|
# SQLite builds because ``text()`` does not split on ``;``.
|
|
|
|
_DDL_SEAT_RUNS: list[str] = [
|
|
"""
|
|
CREATE TABLE maf_roundtable_seat_runs (
|
|
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
|
maf_run_id VARCHAR(64) NOT NULL,
|
|
agent_id VARCHAR(64) NOT NULL,
|
|
turn_index INTEGER NOT NULL,
|
|
deerflow_run_id VARCHAR(64),
|
|
deerflow_thread_id VARCHAR(64),
|
|
sandbox_id VARCHAR(64),
|
|
workflow_checkpoint_id VARCHAR(128),
|
|
status VARCHAR(32) DEFAULT 'pending' NOT NULL,
|
|
started_at DATETIME,
|
|
finished_at DATETIME,
|
|
error TEXT,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL,
|
|
FOREIGN KEY (maf_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE CASCADE
|
|
)
|
|
""",
|
|
"CREATE INDEX ix_maf_seat_runs_maf_run_id ON maf_roundtable_seat_runs (maf_run_id)",
|
|
"CREATE INDEX ix_maf_seat_runs_agent_id ON maf_roundtable_seat_runs (agent_id)",
|
|
"CREATE INDEX ix_maf_seat_runs_status ON maf_roundtable_seat_runs (status)",
|
|
"CREATE UNIQUE INDEX uq_maf_seat_runs_run_agent_turn ON maf_roundtable_seat_runs (maf_run_id, agent_id, turn_index)",
|
|
]
|
|
|
|
_DDL_EVENTS_SOURCE_INDEX = "CREATE UNIQUE INDEX uq_maf_events_run_source ON maf_roundtable_events (run_id, source_event_id)"
|
|
|
|
_DDL_ACTION_SECRETS: list[str] = [
|
|
"""
|
|
CREATE TABLE maf_roundtable_action_secrets (
|
|
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
|
action_id VARCHAR(64) NOT NULL,
|
|
arguments_encrypted TEXT NOT NULL,
|
|
created_at DATETIME NOT NULL,
|
|
FOREIGN KEY (action_id) REFERENCES maf_roundtable_actions (id) ON DELETE CASCADE
|
|
)
|
|
""",
|
|
"CREATE INDEX ix_maf_action_secrets_action_id ON maf_roundtable_action_secrets (action_id)",
|
|
"CREATE UNIQUE INDEX uq_maf_action_secrets_action ON maf_roundtable_action_secrets (action_id)",
|
|
]
|
|
|
|
|
|
def _ddl_add_source_event_id(dialect: str) -> str:
|
|
"""ALTER TABLE to add source_event_id column (dialect-specific NULL handling)."""
|
|
col_type = "VARCHAR(191)"
|
|
return f"ALTER TABLE maf_roundtable_events ADD COLUMN source_event_id {col_type} NULL"
|
|
|
|
|
|
def _ddl_add_action_turn_index(dialect: str) -> str:
|
|
if dialect == "mysql":
|
|
return "ALTER TABLE maf_roundtable_actions ADD COLUMN turn_index INT NULL"
|
|
return "ALTER TABLE maf_roundtable_actions ADD COLUMN turn_index INTEGER NULL"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Phase 2 migration for MAF roundtable tables")
|
|
parser.add_argument("--dry-run", action="store_true", help="Preview only; do not write to the database")
|
|
args = parser.parse_args()
|
|
return asyncio.run(_migrate(args.dry_run))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|