feat(maf): add secure roundtable workflow runtime
This commit is contained in:
1 parent
e690475e6e
commit
bd4131b4e2
52 files changed
+10315
-96
No files matched your search
@@ -0,0 +1,205 @@
|
||||
"""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())
|
||||
@@ -191,6 +191,13 @@ CALL _add_column_if_missing('task_buttons', 'append_auth', 'TINYINT(1) NOT NULL
|
||||
CALL _add_column_if_missing('task_buttons', 'auth_token_param', "VARCHAR(64) NULL DEFAULT 'token'");
|
||||
CALL _add_column_if_missing('task_buttons', 'auth_name_param', "VARCHAR(64) NULL DEFAULT 'username'");
|
||||
|
||||
-- ---- maf_roundtable_actions.turn_index --------------------------------------
|
||||
-- MAF 会商外部写动作所属的席位轮次。持久化该列(而非仅藏在 idempotency_key 里),
|
||||
-- 使待审批动作可精确按 run_id + agent_id + turn_index + logical_action_id 匹配,
|
||||
-- 不会把同一席位历史轮次的 waiting action 混进当前审批。
|
||||
-- ORM: MafRoundtableActionRow.turn_index = Integer, nullable。
|
||||
CALL _add_column_if_missing('maf_roundtable_actions', 'turn_index', 'INT NULL');
|
||||
|
||||
-- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
@@ -199,5 +206,6 @@ SELECT table_name, column_name
|
||||
OR (table_name = 'agents' AND column_name IN ('square_id'))
|
||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
|
||||
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
|
||||
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')))
|
||||
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))
|
||||
OR (table_name = 'maf_roundtable_actions' AND column_name = 'turn_index'))
|
||||
ORDER BY table_name, column_name;
|
||||
Reference in new issue
Block a user