feat(sdk): flows status — a run's own state from its journal, offline; discovery env; transcript tails

Merged
KH
1mo ago
+2846-61

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), terminal completion_reason, spawn time, spend (tokens and decimal dollars, dollars_unmetered sticky), step counts — the denominator line is always printed.
  • Each step: StepState name (state.rs), attempt / max_iterations, start and elapsed, lease deadline and LEASE 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's completionReason / disposition / gate verdict / redacted detail (≤ 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)

FactWhy it is Cloud'sWhat this PR does instead
Step state of a hosted run from outside the sandboxThe Cloud API exposes only status and a terminal completionReason (packages/sdk/src/cloud-run.ts:265-272); the journal lives on the sandbox's diskInside 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 sandboxThe 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 runsOnly Cloud's tables know; the sandbox holds no token that could askNot 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 internalsThey 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 brokennightcto, from GCP, per nightcto/docs/alerting-contract.md — plan Part 3Nothing here. This PR adds no alerting, no heartbeat, no callback; the agent-facing surface is disk-only by construction.

Commits

  1. F-A flows status — run-state.ts (pure fold mirroring RunState::fold, kernel/relayflowd-core/src/state.rs:143-226, 428-452), redact.ts (worker-cli.ts's redactRelayError at :194-200 @ 8df3a23b now delegates), cli/status.ts, journal_busy as its own JournalReadFailure code so status retries exactly that (5 × 50 ms) and refuses honestly after. Exit 0 rendered; 1 rendered with partial non-empty; 2 refused.
  2. F-B discovery env — step-env.ts; worker-cli.ts sets/removes the four names; attempt on SidechannelContext threaded from dispatch.attempt (worker.ts:112-115).
  3. 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 ambient RELAYFLOW_RUN_ID is a parent step's identity.
  4. F-C transcript tails — transcript-tail.ts: 64 KiB ring per stream at runs/<run>/steps/<step>/attempt-<n>.{stdout,stderr}.tail, mode 0600 in the existing 0700 step 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.
  5. docs — SURFACE.md §5 subsection; CLOUD.md limits bullet.

Why these were the gaps (existing behaviour, cited @ 8df3a23b)

  • No status/inspect verb: USAGE packages/sdk/src/cli.ts:83-109. flows replay is offline but prints raw payloads including run.spawned.spec (cli/replay.ts:58-67) and refuses human-influenced runs (replay.ts:54-57).
  • The agent got the worker's whole process.env minus 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 output nulled (kernel/relayflowd-core/src/machine.rs:373, 389, 398); what survives is verification.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 to pty.sock (pty-sidechannel.ts:13-18).
  • walkJournal copies db+wal to scratch and verifies header / meta.run_id / quick_check before reading (journal-reader.ts:61-107) — status reuses it and opens nothing else.

Non-negotiables — where enforced

RuleEnforcement
No network, no credential; disk onlycli-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 secretsredact.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 engineNothing in this PR alerts, heartbeats or calls back. Plan Part 3 is nightcto's and is not started here.
Cite file:lineAbove, spot-checked against this tree at 8df3a23b and obs-cloud at 2ccc4f66d.
Small diffs, tests beside code4 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 with partial is reserved for a genuine mid-walk read failure (tested: corrupt seq 7 → partial: ["journal_read_failed"], steps rendered up to there).
  • runnable counts under pending in counts (schema kept exactly as §1.3); the per-step state still says runnable.
  • Text view shows tails only with --tail; --json always carries them (20 lines default). A plain flows status opens the journal and nothing else.
  • journal_busy also changes flows replay's diagnostic kind for that one case (message unchanged; the existing test asserted the message only).
  • U1 in the plan (hide sibling steps' detail in 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, and RELAYFLOW_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 .tmp staging 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 status works unchanged, but Cloud must put the pinned flows binary on the agent's PATH; the boundary is documented in docs/CLOUD.md.