diff --git a/.gitignore b/.gitignore index 554689b42..892081ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ dist/ .relayflow/ .relayflowd/ .relayflowd-*/ + +# Python bytecode: the schema-migration scripts generate this at runtime and +# an untracked .pyc breaks the review swarm patch application. +__pycache__/ +*.pyc diff --git a/ops/schema-migration/README.md b/ops/schema-migration/README.md new file mode 100644 index 000000000..6dcd61689 --- /dev/null +++ b/ops/schema-migration/README.md @@ -0,0 +1,67 @@ +# The legacy workflow schema is a fork, not a stale file + +`ops/RUNTIME-STATUS.md` records that the local SDK "refuses its old +`swarm`/`workflows` schema before execution". That is true, and it reads like a +migration chore. It is not. **Two runtimes consume these files and they speak +different schema versions.** + +## The evidence + +The local SDK refuses the 1.0 schema outright: + +Literal, captured 2026-09-08. The script emits one JSON diagnostic on stderr +and exits 2; earlier revisions of this file reformatted it into a readable +list, which made a paraphrase look like a transcript. + +``` +$ node scripts/run-local-workflow.mjs workflows/drive.yaml; echo "rc=$?" +{"severity":"refusal","kind":"invalid_spec","message":"Relayflow spec is invalid: spec: unknown key \"swarm\" (expected one of version | name | description | cli | agents | triggers | steps | budget); spec: unknown key \"workflows\" (expected one of version | name | description | cli | agents | triggers | steps | budget); spec.version: unsupported version \"1.0\" (expected \"0.1.0\"); spec.agents: expected a map of named { cli, model } declarations; spec.steps: expected a non-empty array","errors":["spec: unknown key \"swarm\" (expected one of version | name | description | cli | agents | triggers | steps | budget)","spec: unknown key \"workflows\" (expected one of version | name | description | cli | agents | triggers | steps | budget)","spec.version: unsupported version \"1.0\" (expected \"0.1.0\")","spec.agents: expected a map of named { cli, model } declarations","spec.steps: expected a non-empty array"]} +rc=2 +``` + +Cloud accepts it. On 2026-09-07 the review gate ran +`agent-relay cloud run workflows/review-swarm.yaml --sync-code --json` against +the unmodified 1.0 file; it returned run `04da7e48-87ec-4c7a-a1ee-22fd482e1cd1` +and was given sandbox `b5f3b344-64cc-434d-97f8-f5da71ba4517`. It failed later, +on Daytona CPU quota — not on schema. + +## Why this matters before anyone migrates + +Three of the seven legacy files are consumed by cloud **right now**: + +| File | Consumer | +|---|---| +| `workflows/review-swarm.yaml` | `.github/workflows/review-swarm.yml:145`, every PR | +| `workflows/watchdog.yaml` | live cloud schedule `flows-watchdog`, cron `0 8 * * *`, active | +| `workflows/drive.yaml` | registered via `agent-relay cloud schedule` (see `ops/AUTONOMY.md`) | + +Migrating those to 0.1.0 makes them parse locally and may make them +unrunnable in cloud, which would take out the review gate and the watchdog +together. **Nothing here migrates a live file.** The question that has to be +answered first is simple and I could not answer it from this machine: *does +cloud accept 0.1.0?* + +## What this directory provides + +`migrate-legacy-workflow.py` converts one legacy file and **refuses rather than +guesses**. A migration that silently drops a field is worse than one that +fails: the flow runs, looks fine, and means something else. Verified against +the real SDK — all seven files convert and compile. + +The conversion is not lossless, and the losses are recorded as comments in +every output file rather than discovered later as bugs: + +- `swarm: {pattern, channel, timeoutMs}` — 0.1.0 has no run-level slot. +- agent `preset` — persona surface (RFC-0001 decision 9), not flow spec. +- agent `timeoutMs` — 0.1.0 bounds deterministic steps only. +- a named agent reference becomes the step's `cli`. 0.1.0 requires `model` in a + named declaration and the legacy schema never carried one; rather than invent + a model, the agent's `cli` is carried onto the step. Verified: an agent step + with `cli` and no agents map compiles; an agents map with `cli` and no + `model` is refused. + +Structural changes that are lossless: `version` 1.0 → 0.1.0, step `name` → `id`, +agent step `task` → `instruction`, and `workflows[0].steps` lifted to top level. +Every legacy file in this repo declares exactly one workflow, so that lift is a +lift and not a split — the script refuses a multi-workflow file rather than +guessing how to divide it. diff --git a/ops/schema-migration/migrate-legacy-workflow.py b/ops/schema-migration/migrate-legacy-workflow.py new file mode 100644 index 000000000..af622c956 --- /dev/null +++ b/ops/schema-migration/migrate-legacy-workflow.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Migrate a legacy 1.0 relayflow YAML to the 0.1.0 authoring schema. + +The 1.0 schema wrapped a swarm definition around one or more named workflows. +0.1.0 is a single flow: one top-level `steps` array, agents as a map of named +{cli, model} declarations. Every legacy file in this repo declares exactly one +workflow, so the structural change is a lift, not a split. + +This script REFUSES rather than guesses. Anything it cannot place is reported +and the file is not written, because a migration that silently drops a field is +worse than one that fails: the flow would run, look fine, and mean something +different. What legitimately has no home in 0.1.0 is listed in +ops/schema-migration/README.md and carried into the migrated file as comments, +so the loss stays visible to the next reader. +""" +import sys, yaml + +FLOW = {'version', 'name', 'description', 'cli', 'agents', 'triggers', 'steps', 'budget'} +AGENT_DECL = {'cli', 'model'} +STEP_COMMON = {'id', 'type', 'dependsOn', 'verification', 'maxIterations', 'memory', 'requirements'} +STEP_BY_TYPE = { + 'deterministic': {'command', 'timeoutMs'}, + 'llm': {'prompt', 'model', 'cli', 'output'}, + 'agent': {'instruction', 'agent', 'cli', 'model', 'surfaces', 'recoveryMode', 'permissions', 'output'}, +} +# Legacy keys we drop on purpose, with the reason recorded in the output file. +DROPPED_TOP = {'swarm'} +DROPPED_AGENT = {'preset', 'role'} # persona surface (RFC decision 9) +DROPPED_STEP = {('agent', 'timeoutMs')} # 0.1.0 bounds deterministic steps only + + +def migrate(path): + # The refusal boundary has to be closed. Letting a parser error or an + # unexpected root type escape as a traceback means an author cannot tell an + # unsupported source from a bug in this tool, and no REFUSED result is ever + # printed -- the opposite of the fail-closed promise in the docstring. + problems, notes = [], [] + try: + with open(path) as handle: + raw = yaml.safe_load(handle) + except OSError as exc: + return None, [f"cannot read {path}: {exc}"], notes + except yaml.YAMLError as exc: + return None, [f"{path} is not valid YAML: {exc}"], notes + if raw is None: + return None, [f"{path} is empty; there is nothing to migrate"], notes + if not isinstance(raw, dict): + return None, [f"{path} must contain a mapping at the top level, " + f"found {type(raw).__name__}"], notes + + # Refuse a schema this tool was not written for. Without this, a 0.2.0 file + # would be silently restamped as 0.1.0 and "migrated" by guesswork. + src_version = str(raw.get('version')) + if src_version != '1.0': + # Return, do not accumulate. Continuing into the legacy loops with a + # schema this tool does not understand means `a.get(...)` raises on a + # 0.1.0-style agents MAP and the caller sees a traceback instead of the + # clean refusal this script promises. + return None, [f"source version is {src_version!r}, expected '1.0'; " + "this tool migrates the 1.0 schema only"], notes + + unknown_top = set(raw) - FLOW - DROPPED_TOP - {'workflows'} + if unknown_top: + problems.append(f"unhandled top-level keys: {sorted(unknown_top)}") + + workflows = raw.get('workflows') or [] + if not isinstance(workflows, list): + return None, ["'workflows' must be a list"], notes + if len(workflows) == 1 and not isinstance(workflows[0], dict): + return None, ["the workflow entry must be a mapping"], notes + if len(workflows) != 1: + problems.append(f"expected exactly one workflow, found {len(workflows)}; " + "a multi-workflow file must be split by hand, not guessed") + + if 'swarm' in raw: + s = raw['swarm'] + notes.append(f"legacy swarm dropped: {s} - 0.1.0 has no run-level " + "pattern/channel/timeout slot") + + agents, roles = {}, {} + declared_agents = raw.get('agents') or [] + if not isinstance(declared_agents, list): + return None, ["'agents' must be a list"], notes + for a in declared_agents: + if not isinstance(a, dict): + problems.append(f"agent entry must be a mapping, found " + f"{type(a).__name__}") + continue + name = a.get('name') + if not name: + problems.append(f"agent declaration without a name: {a}") + continue + decl = {k: v for k, v in a.items() if k in AGENT_DECL} + leftover = set(a) - AGENT_DECL - DROPPED_AGENT - {'name'} + if leftover: + problems.append(f"agent {name}: unhandled fields {sorted(leftover)}") + if a.get('role'): + roles[name] = a['role'] + if a.get('preset'): + notes.append(f"agent {name}: preset '{a['preset']}' dropped - " + "presets are persona surface (RFC decision 9)") + agents[name] = decl + + # The 0.1.0 target requires a non-empty `steps` array -- the SDK refusal is + # recorded in this directory's README. Emitting `steps: []` produced a + # cheerful MIGRATED line and an artifact the SDK then rejects, which is the + # same guessing this tool refuses everywhere else. + raw_steps = (workflows[0].get('steps') if workflows else None) + if workflows and not isinstance(raw_steps, list): + problems.append("workflow 'steps' must be a list; 0.1.0 requires a " + "non-empty steps array") + raw_steps = [] + elif workflows and not raw_steps: + problems.append("workflow has no steps; 0.1.0 requires a non-empty " + "steps array, and an empty migration would be refused " + "by the SDK rather than run") + raw_steps = [] + steps = [] + for s in raw_steps or []: + t = s.get('type') + out = {'id': s.get('name'), 'type': t} + if not out['id']: + problems.append(f"step without a name: {s}") + allowed = STEP_COMMON | STEP_BY_TYPE.get(t, set()) + for k, v in s.items(): + if k == 'name': + continue + if k == 'task' and t == 'agent': + # An agent's role described who it was; the 0.1.0 step carries + # the whole contract, so the role is prepended rather than lost. + role = roles.get(s.get('agent')) + out['instruction'] = f"{role}\n\n{v}" if role else v + elif k == 'agent' and t == 'agent': + # 0.1.0 requires `model` in a named agent declaration and the + # legacy schema never carried one. Rather than invent a model, + # carry the agent's `cli` onto the step, which is the same + # information the legacy file actually held. Verified against + # the SDK: an agent step with `cli` and no agents map compiles; + # an agents map with `cli` and no `model` is refused. + decl = agents.get(v) or {} + cli = decl.get('cli') + model = decl.get('model') + if not cli: + problems.append(f"step {out['id']}: agent '{v}' has no cli to carry") + elif model: + # AGENT_DECL accepts `model`, so a legacy file can declare one. + # Carrying only `cli` would drop it silently and let the run + # inherit the host's model instead -- different behaviour and + # cost than the author wrote down. That is the same + # accepted-then-dropped failure this script repairs for + # top-level `cli`, `triggers` and `budget`, so refuse instead + # of guessing which model the author would have accepted. + problems.append( + f"step {out['id']}: agent '{v}' declares model '{model}', " + "which a lowered step cannot carry; migrate this agent by " + "hand rather than let the run pick a different model") + else: + out['cli'] = cli + notes.append(f"step {out['id']}: agent reference '{v}' became cli " + f"'{cli}' - this declaration carried no model, so " + "nothing was lost") + elif k in allowed: + out[k] = v + elif (t, k) in DROPPED_STEP: + notes.append(f"step {out['id']}: {k}={v} dropped - " + "0.1.0 bounds deterministic steps only") + else: + problems.append(f"step {out['id']}: unhandled field '{k}' for type {t}") + steps.append(out) + + if problems: + return None, problems, notes + # Carry every 0.1.0 field the source actually set. `cli`, `triggers` and + # `budget` pass the unknown-key check above because they ARE valid 0.1.0 + # fields; emitting a fixed dict dropped them silently, which is the exact + # failure this script's docstring refuses to commit. Absent optionals are + # omitted rather than written as null, because an explicit null is not a + # valid value for them. + out = {'version': '0.1.0'} + for key in ('name', 'description', 'cli', 'triggers', 'budget'): + # An absent key and a key explicitly set to null are different facts. + # Treating null as absent would silently drop a field the author wrote + # down, which is the exact failure this script refuses elsewhere. + if key not in raw: + continue + if raw[key] is None: + problems.append(f"{key} is explicitly null; remove the key or give " + "it a value -- this tool will not guess which you meant") + continue + out[key] = raw[key] + if problems: + return None, problems, notes + out['steps'] = steps + return out, [], notes + + +if __name__ == '__main__': + src = sys.argv[1] + spec, problems, notes = migrate(src) + if problems: + print(f"REFUSED {src}") + for p in problems: + print(f" - {p}") + raise SystemExit(1) + header = [f"# Migrated from the legacy 1.0 schema by " + f"ops/schema-migration/migrate-legacy-workflow.py.", + "# Deliberate losses, recorded so they are not rediscovered as bugs:"] + header += [f"# - {n}" for n in notes] or ["# (none)"] + out = "\n".join(header) + "\n" + yaml.safe_dump(spec, sort_keys=False, width=100) + # No implicit in-place rewrite. Defaulting dest to src meant that running + # this with a single argument silently destroyed the input -- the exact + # "guessing on the author's behalf" this tool exists to refuse. An + # in-place migration is still available, but only when asked for by name. + if len(sys.argv) > 2: + dest = sys.argv[2] + else: + sys.stderr.write( + f"refusing to migrate {src} in place: pass an explicit destination, " + f"or '{src}' again if you really mean to overwrite it\n") + sys.exit(2) + open(dest, 'w').write(out) + print(f"MIGRATED {src} -> {dest} ({len(spec['steps'])} steps, {len(notes)} recorded losses)")