flows: time-travel replay verb (flows replay) — RFC-0001 covenant 1
Adds a first-class flows replay CLI verb that walks a run's journal non-destructively. The kernel already treats the journal as truth per RFC-0001 covenant 1 ("journal is truth"); resume proves it can traverse a journal to continue. This verb exposes that same traversal as a read-only observation surface — a debugging and audit primitive that never re-executes effects, CLI adapters, or verifications.
Motivation
RFC-0001 covenant 1: the journal is truth. Every step of a run is journaled with its inputs, verification, and completion event. Resume uses that today; there is no user-facing verb to inspect the journal outside of running one. Time-travel replay is missing.
Consequences of the gap:
- Debugging a completed run means reading raw journal bytes or attaching a live daemon to it. Neither is friendly, and both couple the debugger to daemon lifecycle.
- The claim "journals replay identically" is not exercised by any surface. If a change to canonicalization silently broke journal readback, we would not notice until a resume in production.
- Audit tooling that inspects a specific step's verification verdict has nowhere to hook in without duplicating the kernel's journal reader.
Scope
Ships flows replay as a read-only verb:
flows replay [--json] [--data-dir <dir>] <run-id> [--at <step-id>]
Behavior:
- Reads
<data-dir>/journal/<run-id>.jsonl (or wherever the kernel places journals in the shipped daemon; discover from packages/sdk/src/relayflowd-path.ts).
- Emits one event per journal record, in journal order.
- Does not contact
relayflowd. No socket, no run.start, no run.resume. Pure filesystem read against the journal file.
- Does not re-execute steps, CLI adapters, verification predicates, effect writes, or worker leases. This verb is an observer.
- Default
--data-dir is .relayflowd (matches existing verbs).
Output modes
Default (human-readable, one line per event):
Each line is a compact state transition, e.g.
run.spawned step-1 (run git status) [pending]
step.attempt.started step-1 [running]
step.completed step-1 exit_code=0 verification=ok [succeeded]
run.spawned step-2 (llm Summarize the diff...) [pending]
step.attempt.started step-2 [running]
step.completed step-2 verification=json_schema:ok [succeeded]
run.completed success [terminal]
Format is stable, terse, and column-aligned enough that awk/cut can pull one field. Format specifics are up to the implementation; the constraint is one line per journal event, in order, without truncation of the essential fields.
--json mode:
One JSON object per line. Schema:
{
"step_id": "step-1",
"kind": "step.completed",
"event": { /* raw journal event body */ },
"verification": { "type": "exit_code", "verdict": "ok" },
"spend": null
}
step_id is null for run-level events (run.spawned, run.completed, run.parked).
kind is the raw event kind from the journal.
event is the full event body — treat this as a passthrough; no reshaping.
verification is populated only on events that carry a verification verdict.
spend is populated only if the run journaled a spend record for this step; otherwise null.
The --json shape is stable across runs of the same journal. Two invocations against the same file produce byte-identical stdout (acceptance criterion iv).
--at <step-id> filter
Truncates output at the named step (inclusive of that step's terminal event). Journal events after the target step are not emitted.
- If the step is
run.spawned but never terminated in the journal, output stops at the last event referencing that step.
- If the step id is not found, refuses with
step_not_found (see refusals below).
Rationale: this is the observation primitive equivalent of git log <commit> — walking history up to a target point. Full time-travel fork-from-here semantics belong in a later slice; this verb only shows the history.
Refusals (exit 2)
| Code |
Trigger |
run_not_found |
Journal file for <run-id> does not exist under <data-dir>/journal/. |
journal_read_failed |
Journal file exists but cannot be parsed (partial line, unknown record type in a strict schema build, IO error). Includes the byte offset of the failing record in stderr. |
step_not_found |
--at <step-id> names a step that never appears in the journal. |
invalid_run_id |
<run-id> fails the run-id syntactic check (matches the same regex as flows resume). |
Exit code contract matches the existing CLI table in docs/SURFACE.md §5:
0 → replay completed and emitted every event.
2 → replay was refused before any output.
1 → replay started emitting output and then hit a mid-journal read error. journal_read_failed on stderr with byte offset; stdout may be partial.
Acceptance criteria
Concrete evidence a reviewer can re-run:
- Full-run walk.
flows replay <id> against a completed journal (produced by an existing test fixture in testdata/ or by flows run testdata/hello-deterministic.flow.yaml) emits exactly one line per journal event, in order, terminating at the run's terminal event (run.completed or run.parked).
--at truncation. flows replay <id> --at <step-id> emits every event up to and including the terminal event for <step-id>, and no events after it.
--json stability. flows replay <id> --json is byte-identical across two invocations of the same journal file — verified by a diff in the SDK test. This is the RFC-0001 covenant 1 assertion: journals replay identically.
- Refusals.
flows replay does-not-exist → exit 2, stderr contains REFUSED [run_not_found].
flows replay <valid-id> --at nonexistent-step → exit 2, REFUSED [step_not_found].
flows replay <malformed-id> → exit 2, REFUSED [invalid_run_id].
- Daemon-independence. Test asserts
relayflowd is not spawned or contacted during replay (no socket open, no hello). This mirrors how flows check and flows observer operate: no daemon, no data-dir side effects.
Files to touch
packages/sdk/src/cli/replay.ts (new) — the verb implementation. Mirrors the shape of packages/sdk/src/cli/run.ts for CLI ergonomics, minus daemon spawn.
packages/sdk/src/cli.ts — wire the new verb into the dispatch table. Follow the existing pattern for check, run, resume, observer.
packages/sdk/src/journal-client.ts — add a readonly journal walker if one is not already exported. If a walker exists internally, promote/export it. Do not modify write-side behavior.
packages/sdk/tests/cli-replay.test.ts (new) — acceptance-criteria tests: full walk, --at truncation, --json stability, all refusals, daemon-independence.
testdata/ — reuse existing fixture journals if any exist; otherwise generate one via a run in the test's beforeAll and freeze the journal file for the stability assertion.
Not in scope
- Mutating replay / playback with different inputs. That's fork-from-here time travel, a separate slice.
- TUI or interactive step-through. This verb is one-shot, streamed to stdout.
- Effect re-emission. Under no circumstances does replay contact Slack, GitHub, MCP servers, or any external system. It is a pure journal read.
- Cross-run diffing. A
flows diff <run-a> <run-b> verb is a plausible follow-up; not this slice.
- Time-travel replay UI in the observer. The observer link surface can add a "replay" view later using this verb's
--json output as its input; the UI itself is out of scope.
- Kernel changes. The kernel already owns the journal write path; this verb reads its output. No
relayflowd changes required unless the walker in journal-client.ts needs a new protocol frame — and it should not, because the journal is a flat file on disk.
Verification plan
- Kernel + SDK CI passes:
kernel/target/release/relayflowd unchanged; packages/sdk typechecks and tests green.
- New SDK test file covers all four acceptance criteria + the four refusal cases.
- Manual smoke:
flows run testdata/hello-deterministic.flow.yaml --data-dir /tmp/replay-smoke, then flows replay <the-run-id> --data-dir /tmp/replay-smoke. Output should show run.spawned → all steps → run.completed, one line each.
- Manual smoke
--json: flows replay <id> --json --data-dir /tmp/replay-smoke > /tmp/a.jsonl twice, diff /tmp/a.jsonl /tmp/b.jsonl is empty.
References
docs/RFC-0001-everything-is-a-relayflow.md — covenant 1 (journal is truth), covenant 2 (preflight is honest, not relevant here).
docs/SURFACE.md §5 — existing gate-1 CLI verbs (check, run, resume, observer); exit-code table; refusal spelling conventions.
packages/sdk/src/journal-client.ts — existing journal client (write + resume; extend with readonly walker).
packages/sdk/src/cli/run.ts — CLI shape reference (arg parsing, --json mode, refusal formatting).
packages/sdk/src/relayflowd-path.ts — data-dir + journal path derivation.
kernel/relayflowd/src/lib.rs — journal write shape reference (do not modify).
PR expectations
- One PR titled:
feat(sdk): flows replay verb — journal time-travel (#N) (where #N is this issue).
- Base:
main.
- Branch:
feat/spec-I-replay (already created as a worktree pinned to the latest main).
- After open, address bot review feedback (cursor, coderabbit, cubic) of Medium+ severity with fix commits before task-exit. Reply to false positives.
- Do not skip hooks, do not amend after hook failure, do not force-push. New commits only.
- Task-exit condition: PR CI green (linux-x64-artifact + packed-consumer + review checks) and all Medium+ bot findings addressed.
flows: time-travel replay verb (
flows replay) — RFC-0001 covenant 1Adds a first-class
flows replayCLI verb that walks a run's journal non-destructively. The kernel already treats the journal as truth per RFC-0001 covenant 1 ("journal is truth"); resume proves it can traverse a journal to continue. This verb exposes that same traversal as a read-only observation surface — a debugging and audit primitive that never re-executes effects, CLI adapters, or verifications.Motivation
RFC-0001 covenant 1: the journal is truth. Every step of a run is journaled with its inputs, verification, and completion event. Resume uses that today; there is no user-facing verb to inspect the journal outside of running one. Time-travel replay is missing.
Consequences of the gap:
Scope
Ships
flows replayas a read-only verb:Behavior:
<data-dir>/journal/<run-id>.jsonl(or wherever the kernel places journals in the shipped daemon; discover frompackages/sdk/src/relayflowd-path.ts).relayflowd. No socket, norun.start, norun.resume. Pure filesystem read against the journal file.--data-diris.relayflowd(matches existing verbs).Output modes
Default (human-readable, one line per event):
Each line is a compact state transition, e.g.
Format is stable, terse, and column-aligned enough that
awk/cutcan pull one field. Format specifics are up to the implementation; the constraint is one line per journal event, in order, without truncation of the essential fields.--jsonmode:One JSON object per line. Schema:
{ "step_id": "step-1", "kind": "step.completed", "event": { /* raw journal event body */ }, "verification": { "type": "exit_code", "verdict": "ok" }, "spend": null }step_idisnullfor run-level events (run.spawned,run.completed,run.parked).kindis the raw event kind from the journal.eventis the full event body — treat this as a passthrough; no reshaping.verificationis populated only on events that carry a verification verdict.spendis populated only if the run journaled a spend record for this step; otherwisenull.The
--jsonshape is stable across runs of the same journal. Two invocations against the same file produce byte-identical stdout (acceptance criterion iv).--at <step-id>filterTruncates output at the named step (inclusive of that step's terminal event). Journal events after the target step are not emitted.
run.spawnedbut never terminated in the journal, output stops at the last event referencing that step.step_not_found(see refusals below).Rationale: this is the observation primitive equivalent of
git log <commit>— walking history up to a target point. Full time-travel fork-from-here semantics belong in a later slice; this verb only shows the history.Refusals (exit 2)
run_not_found<run-id>does not exist under<data-dir>/journal/.journal_read_failedstep_not_found--at <step-id>names a step that never appears in the journal.invalid_run_id<run-id>fails the run-id syntactic check (matches the same regex asflows resume).Exit code contract matches the existing CLI table in
docs/SURFACE.md§5:0→ replay completed and emitted every event.2→ replay was refused before any output.1→ replay started emitting output and then hit a mid-journal read error.journal_read_failedon stderr with byte offset; stdout may be partial.Acceptance criteria
Concrete evidence a reviewer can re-run:
flows replay <id>against a completed journal (produced by an existing test fixture intestdata/or byflows run testdata/hello-deterministic.flow.yaml) emits exactly one line per journal event, in order, terminating at the run's terminal event (run.completedorrun.parked).--attruncation.flows replay <id> --at <step-id>emits every event up to and including the terminal event for<step-id>, and no events after it.--jsonstability.flows replay <id> --jsonis byte-identical across two invocations of the same journal file — verified by adiffin the SDK test. This is the RFC-0001 covenant 1 assertion: journals replay identically.flows replay does-not-exist→ exit 2, stderr containsREFUSED [run_not_found].flows replay <valid-id> --at nonexistent-step→ exit 2,REFUSED [step_not_found].flows replay <malformed-id>→ exit 2,REFUSED [invalid_run_id].relayflowdis not spawned or contacted during replay (no socket open, no hello). This mirrors howflows checkandflows observeroperate: no daemon, no data-dir side effects.Files to touch
packages/sdk/src/cli/replay.ts(new) — the verb implementation. Mirrors the shape ofpackages/sdk/src/cli/run.tsfor CLI ergonomics, minus daemon spawn.packages/sdk/src/cli.ts— wire the new verb into the dispatch table. Follow the existing pattern forcheck,run,resume,observer.packages/sdk/src/journal-client.ts— add a readonly journal walker if one is not already exported. If a walker exists internally, promote/export it. Do not modify write-side behavior.packages/sdk/tests/cli-replay.test.ts(new) — acceptance-criteria tests: full walk,--attruncation,--jsonstability, all refusals, daemon-independence.testdata/— reuse existing fixture journals if any exist; otherwise generate one via arunin the test'sbeforeAlland freeze the journal file for the stability assertion.Not in scope
flows diff <run-a> <run-b>verb is a plausible follow-up; not this slice.--jsonoutput as its input; the UI itself is out of scope.relayflowdchanges required unless the walker injournal-client.tsneeds a new protocol frame — and it should not, because the journal is a flat file on disk.Verification plan
kernel/target/release/relayflowdunchanged;packages/sdktypechecks and tests green.flows run testdata/hello-deterministic.flow.yaml --data-dir /tmp/replay-smoke, thenflows replay <the-run-id> --data-dir /tmp/replay-smoke. Output should showrun.spawned→ all steps →run.completed, one line each.--json:flows replay <id> --json --data-dir /tmp/replay-smoke > /tmp/a.jsonltwice,diff /tmp/a.jsonl /tmp/b.jsonlis empty.References
docs/RFC-0001-everything-is-a-relayflow.md— covenant 1 (journal is truth), covenant 2 (preflight is honest, not relevant here).docs/SURFACE.md§5 — existing gate-1 CLI verbs (check,run,resume,observer); exit-code table; refusal spelling conventions.packages/sdk/src/journal-client.ts— existing journal client (write + resume; extend with readonly walker).packages/sdk/src/cli/run.ts— CLI shape reference (arg parsing,--jsonmode, refusal formatting).packages/sdk/src/relayflowd-path.ts— data-dir + journal path derivation.kernel/relayflowd/src/lib.rs— journal write shape reference (do not modify).PR expectations
feat(sdk): flows replay verb — journal time-travel (#N)(where#Nis this issue).main.feat/spec-I-replay(already created as a worktree pinned to the latest main).