188 lines
6.2 KiB
Python
188 lines
6.2 KiB
Python
"""V014 - allow user_input meta-skill steps in the audit ledger.
|
|
|
|
V013 added awaiting-user run state for clarification forms, but the
|
|
``meta_skill_run_steps.step_kind`` CHECK constraint still came from V012 and
|
|
accepted only agent / llm_classify / llm_chat / tool_call / skill_exec.
|
|
Bundled meta-skills now begin with ``user_input`` collection steps, so live
|
|
gateway runs could log CHECK failures instead of persisting the step.
|
|
|
|
SQLite cannot alter a CHECK constraint in place, so recreate the step table
|
|
with the expanded allowed-value set.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from yoyo import step
|
|
|
|
__depends__: set[str] = {"V013__meta_skill_runs_clarify"}
|
|
|
|
|
|
_NEW_STEP_KIND_VALUES = (
|
|
"'agent'",
|
|
"'llm_classify'",
|
|
"'llm_chat'",
|
|
"'tool_call'",
|
|
"'skill_exec'",
|
|
"'user_input'",
|
|
)
|
|
_OLD_STEP_KIND_VALUES = (
|
|
"'agent'",
|
|
"'llm_classify'",
|
|
"'llm_chat'",
|
|
"'tool_call'",
|
|
"'skill_exec'",
|
|
)
|
|
_STEP_COLUMNS = (
|
|
"run_id",
|
|
"step_id",
|
|
"step_kind",
|
|
"declared_skill",
|
|
"effective_skill",
|
|
"status",
|
|
"started_at_ms",
|
|
"ended_at_ms",
|
|
"rendered_inputs_json",
|
|
"output_text",
|
|
"error",
|
|
"substitute_step_id",
|
|
"truncated_fields",
|
|
)
|
|
|
|
|
|
def _create_steps_table_sql(
|
|
step_kind_values: tuple[str, ...],
|
|
table_name: str = "meta_skill_run_steps",
|
|
) -> str:
|
|
return f"""
|
|
CREATE TABLE {table_name} (
|
|
run_id TEXT NOT NULL
|
|
REFERENCES meta_skill_runs(run_id) ON DELETE CASCADE,
|
|
step_id TEXT NOT NULL,
|
|
step_kind TEXT NOT NULL
|
|
CHECK(step_kind IN ({", ".join(step_kind_values)})),
|
|
declared_skill TEXT NOT NULL,
|
|
effective_skill TEXT NOT NULL,
|
|
status TEXT NOT NULL
|
|
CHECK(status IN ('running','ok','failed','substituted')),
|
|
started_at_ms INTEGER NOT NULL,
|
|
ended_at_ms INTEGER,
|
|
rendered_inputs_json TEXT NOT NULL,
|
|
output_text TEXT,
|
|
error TEXT,
|
|
substitute_step_id TEXT,
|
|
truncated_fields TEXT NOT NULL DEFAULT '',
|
|
PRIMARY KEY (run_id, step_id)
|
|
)
|
|
"""
|
|
|
|
|
|
def _table_exists(conn, table: str) -> bool:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
(table,),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def _table_sql(conn, table: str) -> str:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
|
|
(table,),
|
|
)
|
|
row = cur.fetchone()
|
|
return row[0] if row and row[0] else ""
|
|
|
|
|
|
def _assert_fk_enforcement_off(conn) -> None:
|
|
"""Fail loudly if foreign-key enforcement is live on this connection.
|
|
|
|
yoyo wraps every Python step in an explicit transaction, and
|
|
``PRAGMA foreign_keys`` is a documented no-op inside a transaction, so
|
|
the OFF/ON bracket in the recreate below cannot take effect there. The
|
|
rebuild has only ever been validated with SQLite's default
|
|
foreign_keys=OFF; with enforcement live the drop-and-copy would run
|
|
under FK semantics this migration was not designed for. Refuse loudly.
|
|
"""
|
|
cur = conn.cursor()
|
|
cur.execute("PRAGMA foreign_keys")
|
|
row = cur.fetchone()
|
|
if row is not None and row[0]:
|
|
raise RuntimeError(
|
|
"V014: PRAGMA foreign_keys is enabled on the migration "
|
|
"connection; the meta_skill_run_steps rebuild would run with "
|
|
"foreign-key enforcement live (the OFF pragma below is a no-op "
|
|
"inside yoyo's step transaction). Refusing to proceed — run "
|
|
"migrations on a connection with foreign-key enforcement "
|
|
"disabled."
|
|
)
|
|
|
|
|
|
def _user_input_step_count(conn) -> int:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT COUNT(*) FROM meta_skill_run_steps WHERE step_kind='user_input'"
|
|
)
|
|
return int(cur.fetchone()[0] or 0)
|
|
|
|
|
|
def _recreate_steps_table(conn, step_kind_values: tuple[str, ...]) -> None:
|
|
_assert_fk_enforcement_off(conn)
|
|
cur = conn.cursor()
|
|
# Inert inside yoyo's step transaction (see _assert_fk_enforcement_off);
|
|
# kept because it is harmless and correct outside a transaction.
|
|
cur.execute("PRAGMA foreign_keys = OFF")
|
|
try:
|
|
cur.execute(_create_steps_table_sql(step_kind_values, "meta_skill_run_steps__new"))
|
|
columns = ", ".join(_STEP_COLUMNS)
|
|
cur.execute(
|
|
f"INSERT INTO meta_skill_run_steps__new ({columns}) "
|
|
f"SELECT {columns} FROM meta_skill_run_steps"
|
|
)
|
|
cur.execute("DROP TABLE meta_skill_run_steps")
|
|
cur.execute(
|
|
"ALTER TABLE meta_skill_run_steps__new RENAME TO meta_skill_run_steps"
|
|
)
|
|
cur.execute(
|
|
"CREATE INDEX idx_meta_run_steps_status"
|
|
" ON meta_skill_run_steps(status)"
|
|
)
|
|
cur.execute("PRAGMA foreign_key_check")
|
|
bad = cur.fetchall()
|
|
if bad:
|
|
raise RuntimeError(
|
|
f"V014 foreign_key_check found orphans after recreate: {bad}"
|
|
)
|
|
finally:
|
|
cur.execute("PRAGMA foreign_keys = ON")
|
|
|
|
|
|
def apply_step(conn) -> None:
|
|
if not _table_exists(conn, "meta_skill_run_steps"):
|
|
return
|
|
if "user_input" in _table_sql(conn, "meta_skill_run_steps"):
|
|
# Re-run guard (yoyo's apply-then-mark crash window, or operator
|
|
# reapply): the relaxed CHECK is already in place. Re-running the
|
|
# copy below (a fixed pre-V015 column list) against a later schema
|
|
# would silently drop the usage_json column and its data — skip
|
|
# instead.
|
|
return
|
|
_recreate_steps_table(conn, _NEW_STEP_KIND_VALUES)
|
|
|
|
|
|
def rollback_step(conn) -> None:
|
|
if not _table_exists(conn, "meta_skill_run_steps"):
|
|
return
|
|
user_input_count = _user_input_step_count(conn)
|
|
if user_input_count:
|
|
raise RuntimeError(
|
|
"V014 rollback blocked: meta_skill_run_steps contains "
|
|
f"{user_input_count} user_input row(s). Remove or archive those rows "
|
|
"before rolling back to the pre-user-input CHECK constraint."
|
|
)
|
|
_recreate_steps_table(conn, _OLD_STEP_KIND_VALUES)
|
|
|
|
|
|
steps = [step(apply_step, rollback_step)]
|