feat(sdk): flows status — a run's own state from its journal, offline; discovery env; transcript tails
Flows part of the run self-watch plan (~/.relayflow-run-self-watch/plan.md Part 1: F-A → F-B → F-E → F-C). Four small commits plus docs. Kernel untouched; no journal entry types added.
What an agent can now see about its own run
Inside any direct or wrapper agent step that has a data dir, with no arguments, no daemon, no network and no credential:
$ flows status
RUN 01M23… hn-monitor/analyze running started 12m04s ago spend 18,204 in / 2,110 out / $0.0431
steps 3: 1 done · 1 running · 1 pending
✓ fetch deterministic done 1 attempt 2.1s success gate: schema pass
↻ analyze agent running attempt 2/3 4m12s lease ok (expires in 23.0s) ← this step
last attempt 1: worker_error → retry
gate: execution FAIL — "CLI invocation timed out after 600000ms."
artifacts (attempt 1): none journaled (output was a JSON object)
○ post agent pending
- Run: status (
running|parked|completed|failed|cancelling|cancelled), terminalcompletion_reason, spawn time, spend (tokens and decimal dollars,dollars_unmeteredsticky), step counts — the denominator line is always printed. - Each step:
StepStatename (state.rs), attempt /max_iterations, start and elapsed, lease deadline andLEASE OVERDUE by <t>computed from the journaled deadline and the wall clock alone (a local dead-man with no daemon dependency), backoff wake time, pending wait (human|event|timer), the last completion'scompletionReason/disposition/ gate verdict / redacteddetail(≤ 1 KiB),human_intervention, effect count, journaled artifact paths. --tail <n>: the last n lines of this attempt's stdout/stderr from the new bounded tail files, redacted.--json: one canonicalised object (v: 1,this_step,partial,tails), byte-identical across invocations.- Never present, in either rendering (the schema has no field for them, so omission is structural): step instructions, input bindings, output bodies, wake contexts, memory packs, pins, effect refs, human prompts.
How it finds itself: the worker now exports RELAYFLOW_DATA_DIR (absolute), RELAYFLOW_RUN_ID, RELAYFLOW_STEP_ID, RELAYFLOW_ATTEMPT to every direct and wrapper attempt that has a data dir, and removes them otherwise — an ambient value from an enclosing step never passes through. With these alone an agent can open its journal and nothing else.
What still needs Cloud (stated, not invented)
| Fact | Why it is Cloud's | What this PR does instead |
|---|---|---|
| Step state of a hosted run from outside the sandbox | The Cloud API exposes only status and a terminal completionReason (packages/sdk/src/cloud-run.ts:265-272); the journal lives on the sandbox's disk | Inside the sandbox flows status works unchanged: the worker is the --local-agent of the CLI Cloud spawns with --data-dir (cloud relayflow-v2-executor.ts:510-531 @ obs-cloud 2ccc4f66d). Nothing outside sees steps, and no journal-export or "watch my run" endpoint is proposed. |
The agent being able to type flows status in a hosted sandbox | The executor invokes the pinned binary by absolute path (artifact.flowsPath, relayflow-v2-executor.ts:531; <root>/bin/flows, relayflow-v2-artifact.ts:113) and only passes PATH through unchanged (relayflow-v2-step-env.ts:35) | Cloud PR C1 in the plan: prepend dirname(artifact.flowsPath) to the CLI child's PATH. Not a flows change; documented in docs/CLOUD.md. |
| The run's Cloud run id, its sandbox id, which listener launched it, sibling runs | Only Cloud's tables know; the sandbox holds no token that could ask | Not rendered and not guessed. If Cloud exports the UUID as env (plan C1), echo $RELAYFLOW_V2_CLOUD_RUN_ID — flows does not learn Cloud's env names. |
Relay-transport (transport: 'relay') step internals | They run on the relay host; locally there is only the claim file and, on completion, the receipt (worker-cli.ts:160-192 @ 8df3a23b) | The step's journal state is shown; tails say no transcript on disk. |
| Why a stuck hosted run was not reaped, and who notices when Cloud is broken | nightcto, from GCP, per nightcto/docs/alerting-contract.md — plan Part 3 | Nothing here. This PR adds no alerting, no heartbeat, no callback; the agent-facing surface is disk-only by construction. |
Commits
- F-A
flows status—run-state.ts(pure fold mirroringRunState::fold,kernel/relayflowd-core/src/state.rs:143-226, 428-452),redact.ts(worker-cli.ts'sredactRelayErrorat:194-200@ 8df3a23b now delegates),cli/status.ts,journal_busyas its ownJournalReadFailurecode so status retries exactly that (5 × 50 ms) and refuses honestly after. Exit 0 rendered; 1 rendered withpartialnon-empty; 2 refused. - F-B discovery env —
step-env.ts;worker-cli.tssets/removes the four names;attemptonSidechannelContextthreaded fromdispatch.attempt(worker.ts:112-115). - F-E wrapper — same four names set on the wrapper's closed environment from the dispatch. Deliberately not added to
WRAPPER_ENV_ALLOWLIST(wrapper-runtime.ts:4-21@ 8df3a23b): an ambientRELAYFLOW_RUN_IDis a parent step's identity. - F-C transcript tails —
transcript-tail.ts: 64 KiB ring per stream atruns/<run>/steps/<step>/attempt-<n>.{stdout,stderr}.tail, mode0600in the existing0700step dir (pty-sidechannel.ts:65-66), write-beside/rename-over at ≤ 4 flushes/s, header line{v,run_id,step_id,attempt,started_at_ms}so a file from an earlier life of the data dir is rejected. Best-effort: a write failure is one process warning and the step completes as before. Not written for wrapper or relay transport. - docs —
SURFACE.md§5 subsection;CLOUD.mdlimits bullet.
Why these were the gaps (existing behaviour, cited @ 8df3a23b)
- No status/inspect verb: USAGE
packages/sdk/src/cli.ts:83-109.flows replayis offline but prints raw payloads includingrun.spawned.spec(cli/replay.ts:58-67) and refuses human-influenced runs (replay.ts:54-57). - The agent got the worker's whole
process.envminus two names and no identity (worker-cli.ts:121-123); the wrapper got a closed allowlist, also no identity (wrapper-runtime.ts:4-21). - Failed agent/llm attempts have
outputnulled (kernel/relayflowd-core/src/machine.rs:373, 389, 398); what survives isverification.detail≤ 2,000 chars (kernel/relayflowd/src/engine/remote.rs:544-547). The transcript was RAM-only (worker-cli.ts:253-254, 306, 317) and published live only topty.sock(pty-sidechannel.ts:13-18). walkJournalcopies db+wal to scratch and verifies header /meta.run_id/quick_checkbefore reading (journal-reader.ts:61-107) — status reuses it and opens nothing else.
Non-negotiables — where enforced
| Rule | Enforcement |
|---|---|
| No network, no credential; disk only | cli-status.test.ts: Socket.prototype.connect spy never called; data dir byte-identical before/after; no connection.json / relayflowd.sock created; a worker-spawned fake agent runs the built flows status --json with no args and gets its own run. The four names are identifiers and a path. |
| Never print secrets | redact.test.ts pins every shape (env value ≥ 8 chars → [redacted:<NAME>]; relay/bearer/header/sk-/ghp_/xox/NAME=value; identifiers and env names untouched; redactRelayError golden unchanged). cli-status.test.ts asserts a GITHUB_TOKEN value, a bearer and an rk_live_ token never reach text or JSON, and that no instruction / stdout_tail / fixture output / wake_context / input / pins / idempotency_key / surface_path substring appears in the JSON. Tail lines are redacted on read. |
| nightcto watches from outside cloud; no new engine | Nothing in this PR alerts, heartbeats or calls back. Plan Part 3 is nightcto's and is not started here. |
| Cite file:line | Above, spot-checked against this tree at 8df3a23b and obs-cloud at 2ccc4f66d. |
| Small diffs, tests beside code | 4 code commits (≈ 350 / 25 / 10 / 180 LOC), each with its named test file. |
Deviations from the plan, called out
- Busy journal with nothing rendered → exit 2
REFUSED [journal_busy], not exit 1. The plan says "render the last consistent snapshot if any"; the snapshot check happens before any event is yielded, so there is never a partial snapshot to render on a busy journal. Exit 1 withpartialis reserved for a genuine mid-walk read failure (tested: corrupt seq 7 →partial: ["journal_read_failed"], steps rendered up to there). runnablecounts underpendingincounts(schema kept exactly as §1.3); the per-stepstatestill saysrunnable.- Text view shows tails only with
--tail;--jsonalways carries them (20 lines default). A plainflows statusopens the journal and nothing else. journal_busyalso changesflows replay's diagnostic kind for that one case (message unchanged; the existing test asserted the message only).- U1 in the plan (hide sibling steps'
detailin step scope) is left at the plan's default A: redacted detail for all steps.
Tests
Ran with the kernel built (npm run test:prep) and RELAYFLOWD_BIN exported: npm run typecheck, typecheck:tests, build, then vitest run over the whole packages/sdk/tests suite:
Test Files 1 failed | 135 passed | 1 skipped (137)
Tests 2149 passed | 16 skipped (2165)
The one failing file is tests/authored-node-runtime.test.ts, whose beforeAll asserts bun --version is 1.4.0 and this machine has 1.3.14 — environmental; the file is untouched by this branch. Suites touched: run-state, redact, cli-status, transcript-tail (new); cli-replay (fixture builder extracted to tests/journal-fixture.ts), relay-cli-surface (arity check taught about an optional positional), worker-cli, pty-sidechannel.
🤖 Generated with Claude Code
[!NOTE]
Medium Risk
New agent-facing output path must not leak secrets; worker spawn and artifact-diff behavior changed for every direct agent attempt with a data dir.
Overview
Adds flows status, a daemon-free CLI that snapshots a run’s journal (same copy-then-verify path as flows replay), folds it into a run/step view, and prints text or canonical --json. Run discovery uses an optional <run-id>, --data-dir, or the worker-set RELAYFLOW_* env vars; inside a step it marks ← this step. --tail shows redacted last-n lines from new on-disk tail files.
Supporting pieces: run-state.ts (journal fold, spend/epoch/transcript digest handling), redact.ts (shared scrubbing for status and relay errors), journal_busy with retries for concurrent writers, and transcript-tail.ts plus worker-cli changes to tee stdout/stderr, export step identity to direct and wrapper spawns, exclude tail/transcript paths from artifact diffs, and bound tail close so steps don’t hang.
docs/SURFACE.md and docs/CLOUD.md document the verb, tails, redaction, and hosted-sandbox expectations (pinned flows on PATH).
Reviewed by Cursor Bugbot for commit 2dae6ecd8d0136ecbf612c2020702919b2bdcd9c. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Adds flows status, an offline command that shows an agent its own run's state from its journal, plus the worker-side discovery environment and transcript tails that make it work inside a step. It also surfaces the per-attempt transcript digest, so an agent can see why its last attempt failed without a daemon socket or credential — flows replay still prints raw payloads and refuses human-influenced runs.
New Features
flows status [--json] [--tail <n>] [<run-id>]renders run status, per-step state, attempts, leases, backoff, waits, last verdicts, spend, and step and effect counts, plus the transcript digest's model, turn/tool counts, provider cost, and failure excerpt.- Step instructions, inputs, outputs, wake contexts, memory packs, and pins are structurally absent from both renderings.
- Direct and wrapper agent steps get
RELAYFLOW_DATA_DIR,RELAYFLOW_RUN_ID,RELAYFLOW_STEP_ID, andRELAYFLOW_ATTEMPT; without a data dir the names are removed so an ambient value from an enclosing step never passes through. - Direct agent attempts leave a bounded 64 KiB transcript tail per stdout/stderr stream on disk;
--tail <n>shows the last n lines, redacted. - The tail files and their
.tmpstaging files are excluded from the agent's artifact list so a data dir under the working directory cannot make the kernel reject the step's completion. - A shared redaction module scrubs secret env values, token shapes, and JSON credential fields — including escaped values — from rendered free text and artifact paths while leaving identifiers readable.
- Completion facts, elapsed, and spend stay correct across journal compaction and decimal scales; a done step carried by a compaction summary omits its attempt count rather than printing 0.
- Tail closes are bounded so a stalled flush cannot hang a step or drop the transcript digest's journal pointer.
Migration
- A busy journal is retried 5 × 50 ms and then refused with exit 2; this also changes
flows replay's diagnostic kind for that one case. - Inside a hosted sandbox
flows statusworks unchanged, but Cloud must put the pinnedflowsbinary on the agent's PATH; the boundary is documented indocs/CLOUD.md.