Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 67 additions & 0 deletions ops/schema-migration/README.md
Original file line number Diff line number Diff line change
@@ -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.
222 changes: 222 additions & 0 deletions ops/schema-migration/migrate-legacy-workflow.py
Original file line number Diff line number Diff line change
@@ -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)")
Loading