Skip to content
Draft
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
88 changes: 87 additions & 1 deletion docs/CLOUD.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ agent holding its user's own Cloud credential does not have to hand-roll HTTP:
```sh
flows runs [--limit <n>] [--json] # recent runs
flows logs <run-id> [--step <name>] [--raw] [--json] # runner log, or a step's transcript
flows logs <run-id> --follow [--json] # ...and keep reading it until the run ends
flows status --cloud [--json] <run-id> # the run's steps, as `flows status` renders a local one
flows status --cloud --watch [--json] <run-id> # ...and redraw it until the run ends
```

They resolve their credential exactly the way every other hosted verb does
Expand Down Expand Up @@ -222,9 +224,91 @@ authority surface 2.0.22 · artifact 9c361a2cbb0a · commit b4dd665eb433
✓ complete-3 deterministic completed 1 attempt 0.0s success gate: exit_code pass
```

A step that is still going renders in the same grammar as a finished one, in
the local view's vocabulary: `↻` for `running` and `backoff`, `⏸` for
`waiting` and `needs_human`, the number of the attempt now running, and the
time since its `startTime`.

```text
↻ agent-5 agent running attempt 1 6m50s
```

Only what the snapshot establishes is printed. Cloud's step rows carry no
maximum-attempt budget, no wait id and no backoff deadline, so — unlike the
local view — no `attempt 1/3`, no `awaiting human: ...` and no
`backoff until ...` appears; those cells arrive if and when the step route
carries the fields. A row with no `startTime` has not been dispatched, so it
shows no attempt number rather than `attempt 1`, and a step that has ended
keeps the duration Cloud reported instead of being advanced to now. A running
run whose snapshot has no rows prints `steps 0` followed by
`No step snapshot available yet.` — a fact about the snapshot, where a bare
`steps 0` would be a claim about the run.

### Following a run that is still going

`flows status --cloud --watch` redraws the page every two seconds until the
run reaches a terminal status, then leaves the final page up. `flows logs
<run-id> --follow` appends new runner output on the same cadence until the run
ends and Cloud marks the log complete, then prints the run's outcome:

```text
LOG 20d04c99-3fa8-48c9-9286-92d364a5bc2e runner following 1,204 bytes so far
[relayflow] ▶ agent-5 (agent) started
[relayflow] ✓ agent-1 … done in 5m16s · claude-opus-5 · 37 turns · $2.26 · wrote plan.md
COMPLETED 20d04c99-3fa8-48c9-9286-92d364a5bc2e completionReason: success
```

Both differ from their one-shot forms in one visible way: **the exit code is
the run's, not the read's.** They exit 0 only on a run Cloud attests as
`completed` with `completionReason: success`, 1 on an attested failure or
cancellation, and 1 with `cloud_invalid_response` on a terminal record that
attests neither — the same validation `flows run --cloud --wait` blocks on, so
the two cannot disagree. A plain `flows logs` on a failed run still exits 0,
because there the exit code describes the read. Ctrl-C exits 1 with
`observation_aborted`; the hosted run is **not** cancelled by it.

Under `--json` both poll silently and print exactly one document at the end —
the one their one-shot form would have printed, with `--follow` carrying the
whole redacted log. That is deliberately unlike `flows check --watch`, which
emits one JSON report per check: these two have a single result, and a script
that wants it wants to block and then parse once. In that document `ok: true`
means the read succeeded; the run's outcome is the exit code.

A watched page is drawn in one write after the cancellation check, so an
interrupt never leaves half a frame, and a failed poll leaves the previous page
alone rather than clearing the screen to report it. Transient failures and
HTTP 408/429/500/502/503/504 are retried with the same doubling delay, capped
at 30 seconds, that the hosted waiter uses; every other failure refuses with
the codes below. The page is two reads against two projections (the run record
then the step rows), so a step row can lag the header above it by a poll; it is
not an atomic snapshot and does not claim to be.

`--follow` does not take `--step`. A step transcript is not an append-only
stream: a retry replaces it, and the rendered form is built from the whole
JSONL. The combination is refused with `invalid_invocation` before any request,
naming both alternatives. Following the runner log re-reads it whole on every
poll and prints only the part that is new — the route's `offset` is a byte
count and the content is a string, and mixing the two silently loses text the
moment a log contains a non-ASCII character, which the runner's own transition
lines do. The cost is a full read per poll and the log held in memory; the
benefit is that no line can be duplicated or skipped. If what was already
printed is no longer a prefix of what Cloud serves, `--follow` refuses with
`cloud_log_rewritten` rather than guessing which bytes are new.

What has not been printed yet is redacted as one block, never a line at a
time, and a line is held back while a secret env value has begun in it and not
ended — whether the rest of that value is further down the same response or
has not been served yet. A multi-line value, a PEM private key being the usual
one, is therefore replaced whole by `[redacted:<NAME>]`: no line of it can
reach stdout on its own, which is exactly what a line-at-a-time redactor can
never prevent. The cost is that a line can appear one poll later than the byte
that completed it.

`--cloud` takes neither `--data-dir` nor `--tail`: both name things on this
filesystem, which a hosted run has none of, so pairing them is refused as an
invocation rather than quietly ignored. The spend total is summed from the
invocation rather than quietly ignored. `--watch` is refused without `--cloud`
for the same reason: the local `flows status` reads one journal file and
returns, so there is no loop for it to hang in. The spend total is summed from the
step rows because the run record carries no total, and Cloud stores each
step's cost as a float — unlike the local view, which adds the journal's
decimal strings exactly (`run-state.ts`).
Expand All @@ -239,6 +323,8 @@ Every refusal is one `REFUSED [code] message` line naming what to do next:
| `cloud_forbidden` | 403: authenticated, but not allowed to read that run or log |
| `cloud_run_not_found` | 404: no such run for this credential; points at `flows runs` |
| `cloud_step_no_transcript` | `--step` named a step with no transcript, or no such step; names the ones that have one |
| `cloud_log_rewritten` | `--follow` found the log no longer starts with what it printed; following it would skip or repeat output |
| `observation_aborted` | Ctrl-C (or a caller's abort) during `--watch`/`--follow`; the hosted run continues |
| `invalid_invocation` | the run id is not a run id (wrong characters, too long); refused before any request |
| `cloud_invalid_response` | Cloud answered something this client cannot trust — a record for a different run, a list that is not a list, a row with no id, a pagination cursor that does not advance |
| `cloud_unreachable` / `cloud_transport_failed` | the request never completed |
Expand Down
9 changes: 7 additions & 2 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -734,10 +734,15 @@ journal. They are documented in [CLOUD.md](CLOUD.md#reading-a-hosted-run):

```text
flows runs [--limit <n>] [--json]
flows logs [--step <name>] [--raw] [--json] <run-id>
flows status --cloud [--json] <run-id>
flows logs [--step <name>] [--raw] [--json] [--follow] <run-id>
flows status --cloud [--json] [--watch] <run-id>
```

`--watch` and `--follow` keep reading until the hosted run is terminal and
exit with *its* outcome rather than the read's, using `flows run --cloud
--wait`'s mapping; Ctrl-C ends the observation, not the run. `--watch` needs
`--cloud`, and `--follow` does not take `--step`.

### Agent sidechannel (initial byte-stream slice)

Local agent workers (`flows run --local-agent`) open
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export const CLI_VERBS = [
options: [
{ flags: '--step <name>', description: 'Show that agent step’s transcript instead of the runner log' },
{ flags: '--raw', description: 'Print the transcript JSONL unrendered (still redacted)' },
{ flags: '--follow', description: 'Append new runner output until the run ends; exits with the run’s outcome' },
JSON_OPTION,
],
variants: ['logs'],
Expand Down Expand Up @@ -325,6 +326,7 @@ export const CLI_VERBS = [
DATA_DIR_OPTION,
{ flags: '--tail <n>', description: 'Lines of each agent attempt’s transcript tail to show' },
{ flags: '--cloud', description: 'Read the run from Cloud instead of a local journal; needs the run id' },
{ flags: '--watch', description: 'With --cloud: redraw until the run ends, then exit with its outcome' },
],
variants: ['status'],
},
Expand Down
34 changes: 27 additions & 7 deletions packages/sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
parseLogsArgs, parseRunsArgs, runCloudLogsCli, runCloudRunsCli, runCloudStatusCli,
type LogsArgs, type RunsArgs,
} from './cli/cloud-read.js';
import { runCloudLogsFollow, runCloudStatusWatch } from './cli/cloud-live.js';
import { transcriptTailSource } from './transcript-tail.js';
import { checkTypeScriptFlow } from './cli/check-typescript.js';
import { runCloudCli } from './cli/cloud-run.js';
Expand All @@ -55,6 +56,16 @@ export type { CheckInputDiagnostic, CheckReport } from './cli/check.js';
export interface CliIo {
stdout(line: string): void;
stderr(line: string): void;
/**
* True when stdout is an interactive terminal.
*
* Only the live views read it, and only to decide whether a redraw may use
* ANSI control sequences: `flows status --cloud --watch` clears the screen
* for a terminal and appends whole pages when its output is redirected or
* mounted in a host that renders text. Absent means "not a terminal", so an
* embedder that says nothing gets the safe form.
*/
tty?: boolean;
}

type CliExitCode = 0 | 1 | 2 | 3;
Expand Down Expand Up @@ -121,9 +132,9 @@ const USAGE = [
'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
'flows status [--json] [--data-dir <dir>] [--tail <n>] [<run-id>]',
'flows status --cloud [--json] <run-id>',
'flows status --cloud [--json] [--watch] <run-id>',
'flows runs [--limit <n>] [--json]',
'flows logs [--step <name>] [--raw] [--json] <run-id>',
'flows logs [--step <name>] [--raw] [--json] [--follow] <run-id>',
'flows observer [--data-dir <dir>]',
'flows hn-monitor start [--data-dir <dir>] [--poll-interval-ms <n>] <spec.json>',
].join('\n');
Expand All @@ -141,6 +152,7 @@ function spawnAllowedByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
const PROCESS_IO: CliIo = {
stdout: (line) => process.stdout.write(`${line}\n`),
stderr: (line) => process.stderr.write(`${line}\n`),
tty: process.stdout.isTTY === true,
};

/** Optional knobs for an embedded caller. `bin/flows.js` passes none. */
Expand Down Expand Up @@ -220,13 +232,21 @@ export async function runCli(
// works inside a step of a run whose daemon is gone (kernel/DAEMON-LIFECYCLE.md §4).
if (parsed.command === 'status') {
// One verb, two sources. `--cloud` never reaches `runStatus`, so the
// offline reader stays offline (cli/status.ts).
return parsed.cloud === true
? runCloudStatusCli(parsed, io)
: runStatus(parsed, io, { tails: transcriptTailSource() });
// offline reader stays offline (cli/status.ts). Only `--watch` blocks, so
// only `--watch` takes a signal: a one-shot read keeps installing none.
if (parsed.cloud !== true) return runStatus(parsed, io, { tails: transcriptTailSource() });
const cloudStatus = parsed;
return cloudStatus.watch === true
? withInterrupt(options.signal, (signal) => runCloudStatusWatch(cloudStatus, io, { signal }))
: runCloudStatusCli(cloudStatus, io);
}
if (parsed.command === 'runs') return runCloudRunsCli(parsed, io);
if (parsed.command === 'logs') return runCloudLogsCli(parsed, io);
if (parsed.command === 'logs') {
const logs = parsed;
return logs.follow
? withInterrupt(options.signal, (signal) => runCloudLogsFollow(logs, io, { signal }))
: runCloudLogsCli(logs, io);
}
if (parsed.command === 'answer') {
const execution = await answerFlow(parsed.runId, parsed.waitId, parsed.answer, parsed.dataDir, {
...(parsed.note === undefined ? {} : { note: parsed.note }),
Expand Down
82 changes: 82 additions & 0 deletions packages/sdk/src/cli/cloud-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// The cells every hosted-run page is built from.
//
// Moved out of `cli/cloud-read.ts` verbatim when the live views (`--watch`,
// `--follow`) needed the same formatting: the run list, the status page and
// the watched page must render a count, a cost and an instant identically, or
// a reader would have to learn which verb they were looking at first.
//
// Depends on nothing in `cli/` but `formatDuration`, so every other Cloud CLI
// module can import it without an import cycle.

import { formatDuration } from './status.js';

export function thousands(value: number): string {
return String(value).replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
}

export function dollars(value: number): string {
return `$${value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '')}`;
}

/** Agent- and flow-authored names cannot inject terminal control sequences. */
export function safe(text: string): string {
return text.replace(/[\u0000-\u001F\u007F-\u009F]/gu, '?');
}

/**
* Render a run's `error` as readable lines rather than one control-char smear.
*
* Cloud stores the runner's terminal output in this field, newlines and all, so
* passing it through `safe()` alone turns a 200-line tail into a single line of
* `?` separators. Long runs are dominated by lease renewals — one line every
* 10s for the life of every agent step, differing only in the deadline — which
* are worth counting, not reading.
*
* Collapsing is deliberately narrow: two adjacent lines merge only when they
* are character-for-character identical once a trailing integer is masked. A
* shared prefix is NOT line identity — runner lines put the step name, reason
* or message after a long fixed prefix, so collapsing on a prefix would hide
* distinct diagnostics behind a similarity count. The final line never
* collapses into an earlier one, because that is where the failure is.
*/
const TRAILING_NUMBER = /\d+(?=\D{0,2}$)/u;

export function errorLines(text: string, indent: string): string[] {
const raw = text.split(/\r\n|\r|\n/u).map((line) => line.trimEnd()).filter((line) => line !== '');
if (raw.length === 0) return [];

const key = (line: string): string => line.replace(TRAILING_NUMBER, '#');
const collapsed: { line: string; count: number }[] = [];
raw.forEach((line, index) => {
const previous = collapsed.at(-1);
const isLast = index === raw.length - 1;
if (previous !== undefined && !isLast && key(previous.line) === key(line)) previous.count += 1;
else collapsed.push({ line, count: 1 });
});

const rendered = collapsed.map(({ line, count }) =>
count === 1 ? safe(line) : `${safe(line)} (${count} times, differing only in a number)`);

const HEAD = 2;
const TAIL = 12;
if (rendered.length <= HEAD + TAIL + 1) return rendered.map((line) => `${indent}${line}`);
const elided = rendered.length - HEAD - TAIL;
return [
...rendered.slice(0, HEAD),
`… ${elided} more line${elided === 1 ? '' : 's'} (full text: --json)`,
...rendered.slice(-TAIL),
].map((line) => `${indent}${line}`);
}

/** ISO-8601 to the second: a list column, not a timestamp to do arithmetic on. */
export function instant(value: string | null): string {
if (value === null) return 'unknown';
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString().replace(/\.\d{3}Z$/u, 'Z') : 'unknown';
}

export function ago(value: string | null, now: number): string {
if (value === null) return 'unknown';
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? `${formatDuration(now - parsed)} ago` : 'unknown';
}
Loading
Loading