Skip to content
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ truth*: the journal is the record, and the workspace is one view onto it. A run
that never joins a workspace is harder to watch; it is not less durable, less
resumable, or less correct.

The observer link is the default way to watch a local run. `--cloud-mirror`
(or `FLOWS_CLOUD_MIRROR=1`) additionally puts the run on the Cloud dashboard,
which is the richer hosted view and therefore opt-in: it stores the flow
source, every step's transcript and the run's own output, so it happens because
someone asked and never because a login was present (`docs/CLOUD.md`, "Local
runs on the dashboard"). Both are projections — watchability, not authority —
and neither can fail a run.

This paragraph previously said every run MUST join the canonical workspace and
that anything else was a defect. That predates decision 7 and outlived it — it
caused a review to flag a local demo as a P1 defect when the demo was fine.
Expand Down
95 changes: 95 additions & 0 deletions docs/CLOUD.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,101 @@ A synced run and a Cloud repository grant are mutually exclusive on the
server: `--sync-code` is the local-driven development loop, and
webhook-triggered deployments keep cloning through the grant.

## Local runs on the dashboard

A local run is watchable by default through its **observer link** — free, off a
workspace key, a step projection in its own channel, printed by every `flows
run` unless `--no-observer-link`. That is the default way to follow a run you
started in a terminal, and it is unchanged.

`--cloud-mirror` additionally puts the run on the **Cloud dashboard**: the
richer, hosted view of the same run.

```sh
flows run --cloud-mirror review.flow.ts --input '{"pr":7}'
# Dashboard: https://…/dashboard/workflow/<run>/runner · flows status --cloud --watch <run>

FLOWS_CLOUD_MIRROR=1 flows run review.flow.ts # for a whole shell
```

Nothing about execution changes. The run executes locally, against the local
daemon, under your own credentials; the journal is still the record. What is
added is a reader beside it that polls this run's journals every ten seconds
and pushes what it finds — the same live step view, the same final step rows,
the same per-step transcripts and the same terminal status a sandboxed run
reports. The run row is marked `dispatchType: "local"`, so the run page says it
ran on your machine rather than promising a sandbox that is never coming.

What the dashboard adds over the observer link: the flow source, every step's
agent transcript, the run graph, the run's own log, and the run sitting in the
same history as your hosted ones — readable afterwards through `flows runs`,
`flows status --cloud` and `flows logs`, which until now only answered for runs
Cloud had launched.

### Why it is opt-in

Because it is the richer view, it is also the one that *stores* all of that.
The mirror sends the flow source, step metadata, agent transcripts and this
invocation's own stderr. Transcripts are the sharp edge: they are whatever the
agent printed, which includes file contents, command output, and anything it
read out of its environment. Every string goes through the same redactor
`flows status` uses — but redaction is pattern matching, and pattern matching
has a false-negative rate.

So the trigger is an explicit request, never the presence of a login. A
developer who signed in once to run something hosted has not thereby agreed to
publish every unrelated experiment in every checkout on that machine into their
workspace, where anyone who can read the workspace can read it. `--cloud-mirror`
is that agreement, per run; `FLOWS_CLOUD_MIRROR=1` is it for a shell.

Only an affirmative counts for the environment variable (`1`, `true`, `on`,
`yes`). Anything else — unset, empty, `0`, or a value nobody meant as a switch
— leaves the run local, because the cost of reading a stray value as consent is
someone's runs being uploaded.

The terminal line names Cloud's run id as well as the page, because the
report's own `runId` is the *journal's* and every hosted read verb (`flows
status --cloud`, `flows logs`, `flows runs`) takes Cloud's. Under `--json` the
same pair rides in the report as `cloudRunId` and `dashboardUrl`, beside
`observerUrl`.

A run that asked for the dashboard and did not get it says so, once, on stderr
— a missing login, a deployment that does not serve the route, a refused
registration. It is a request that was not honoured, not an aside, and it never
changes the run's outcome.

What it does and does not do:

- **Registers before it reports.** `POST /api/v1/workflows/local-run` creates
the run row and returns a credential scoped to that one run. Registration
happens once the run id exists, so a flow `flows run` refuses at check time
never reaches Cloud at all.
- **Reads only this run's journals.** The root, plus the child journals the
root's own authored-step index names. A shared `~/.relayflowd` holding other
people's runs contributes nothing.
- **Cannot fail a run.** Every push collapses to a boolean; each poll is
bounded by its own deadline; the whole finish is bounded. A Cloud outage
costs a mirrored run its dashboard page and nothing else.
- **Does not make Cloud the authority.** Cancel is refused for a local run:
Cloud mirrors it and does not control it, and a cancel button that stopped
the *reporting* while the flow kept running would be a cancellation that did
not happen. Stop it where it is running.
- **One dashboard row per invocation, and the rows are linked.** A mirrored run
goes terminal on Cloud when the CLI exits, and Cloud refuses to move a
terminal run back to `running`, so `flows resume --cloud-mirror` registers its
own row — the same shape Cloud's own v2 resume already has. It carries
`resumedFromRunId`, so the run page says which attempt it continues and a
reader of the earlier "Needs review" row can find out how it ended. A resume
mirrors the kernel spec its journal recorded, since the flow file may have
been edited or deleted since.

No credential is written to disk between invocations: the run token lives only
for the process that holds it. What *is* written, under
`<data-dir>/cloud-runs/`, is which Cloud run mirrored which journal — the id
and the deployment, nothing else, mode 0600 — so a later `flows resume` of the
same journal can name its predecessor. Everything in that file was already in
the URL the first attempt printed. Entries age out at 30 days and 500 rows.

## Reading a hosted run

Three read-only verbs answer "what did that run do" from the Cloud API, so an
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const LOCAL_EXECUTION_OPTIONS = [
},
{ flags: '--no-spawn', description: 'Require a running relayflowd rather than starting one' },
{ flags: '--no-observer-link', description: 'Do not mint an observer link for this run' },
{ flags: '--cloud-mirror', description: 'Also put this run on the Cloud dashboard (also FLOWS_CLOUD_MIRROR=1)' },
{
flags: '--allow-human-influenced',
description: 'Proceed even though the run carries human-influenced state',
Expand Down
102 changes: 83 additions & 19 deletions packages/sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import type { CliModelSource } from './cli-adapter.js';
import { renderProgress, type ProgressEvent } from './progress.js';
import type { JournalEvent } from './journal-reader.js';
import { createObserverSession } from './cli/observer-session.js';
import {
cloudMirrorRequested, createCloudMirrorSession, mirrorSourceFromJournal, mirrorSourceFromPath,
type CloudMirrorReceipt,
} from './cli/cloud-mirror-session.js';
import { realpathSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import {
Expand Down Expand Up @@ -95,8 +99,8 @@ export type ParsedArgs =
| { command: 'schedules'; json: boolean }
| { command: 'unschedule'; scheduleId: string; json: boolean }
| { command: 'check'; json: boolean; watch: boolean; value: string }
| { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
| { command: 'resume'; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
| { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; cloudMirror: boolean; allowHumanInfluenced: boolean; value: string }
| { command: 'resume'; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; cloudMirror: boolean; allowHumanInfluenced: boolean; value: string }
| { command: 'answer'; dataDir: string; json: boolean; spawn: boolean; note: string | undefined; by: string | undefined; runId: string; waitId: string; answer: boolean }
| RunsArgs
| LogsArgs
Expand Down Expand Up @@ -126,13 +130,13 @@ const USAGE = [
'flows run <flow>@sha256:<digest> [--bucket <file-bucket-uri>] [--data-dir <dir>] [--json]',
'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] [--reuse-from <run-id>] <flow.yaml|spec.json>',
'flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] [--reuse-from <run-id>] <flow.yaml|spec.json>',
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
'flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
'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>]',
Expand Down Expand Up @@ -315,25 +319,59 @@ export async function runCli(
// effect of an invocation that is about to be refused for bad input.
const startedSteps = new Map<string, number>();
const observer = parsed.noObserverLink ? undefined : createObserverSession(parsed.command, io);
const runnerLog: string[] = [];
// Opt-in, unlike the observer link beside it. The observer is the default
// way to watch a local run: it is free, it needs only a workspace key, and
// it carries a step projection. The dashboard is the richer, hosted view —
// it stores the flow source, every step's transcript and this invocation's
// own output — so a local run joins it because someone asked, never because
// a login happened to be lying around.
const mirror = !(parsed.cloudMirror || cloudMirrorRequested(process.env))
? undefined
: createCloudMirrorSession({
source: parsed.command === 'run'
? mirrorSourceFromPath(parsed.value, parsed.input, parsed.dataDir)
: mirrorSourceFromJournal(parsed.dataDir),
Comment on lines +332 to +334

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve digest-backed sources before mirroring

For the supported flows run --cloud-mirror --bucket ... <flow>@sha256:<digest> form, parsed.value is a digest reference rather than a filesystem path, so mirrorSourceFromPath attempts readFile on the literal digest and registration always fails even though runFlow successfully fetches and executes the bundle. Select the journal-derived or prepared bundle source for digest references so the requested dashboard mirror is not inert.

AGENTS.md reference: AGENTS.md:L51-L57

Useful? React with 👍 / 👎.

dataDir: parsed.dataDir,
log: () => runnerLog,
requested: parsed.cloudMirror ? 'flag' : 'env',
}, io);
// Everything this invocation prints, kept in order, so the mirror can upload
// it as the run's `runner.log` — the object the dashboard's log pane reads,
// and the sandbox's own equivalent. A declarative run emits no progress
// events at all, so building the log out of those would have left the pane
// empty for exactly the runs that are easiest to follow.
const logged: CliIo = mirror === undefined ? io : {
...io,
stdout: line => { runnerLog.push(line); io.stdout(line); },
stderr: line => { runnerLog.push(line); io.stderr(line); },
};
const showProgress = (event: ProgressEvent): void => {
if (event.type === 'step.started') startedSteps.set(event.stepId, performance.now());
if (!parsed.json) for (const line of renderProgress([event])) io.stderr(line);
if (!parsed.json) for (const line of renderProgress([event])) logged.stderr(line);
observer?.onProgress(event);
mirror?.onProgress(event);
};
const lifecycle = {
...(parsed.command === 'run' ? { bucket: parsed.bucket } : {}),
allowHumanInfluenced: parsed.allowHumanInfluenced,
onPtyReady: (path: string) => io.stderr(`PTY ${path}`),
onPtyReady: (path: string) => logged.stderr(`PTY ${path}`),
...(parsed.command === 'run' && parsed.reuseFromRunId !== undefined ? { reuseFromRunId: parsed.reuseFromRunId } : {}),
localAgent: parsed.localAgent,
...(parsed.agentCapacity === undefined ? {} : { agentCapacity: parsed.agentCapacity }),
onProgress: showProgress,
...(observer === undefined ? {} : {
onJournalEntry: (entry: JournalEvent) => observer.onJournalEntry(entry),
onRunStarted: (run: { runId: string; flow: string; resumed?: boolean }) => observer.onRunStarted(run),
...(observer === undefined && mirror === undefined ? {} : {
onJournalEntry: (entry: JournalEvent) => {
observer?.onJournalEntry(entry);
mirror?.onJournalEntry(entry);
},
onRunStarted: (run: { runId: string; flow: string; resumed?: boolean }) => {
observer?.onRunStarted(run);
mirror?.onRunStarted(run);
},
}),
onWait: (progress: RunProgress) => {
emitWait(progress, io);
emitWait(progress, logged);
const now = performance.now();
if (!startedSteps.has(progress.stepId)) startedSteps.set(progress.stepId, now);
showProgress({ type: 'step.running', stepId: progress.stepId, stepType: progress.stepType,
Expand Down Expand Up @@ -361,12 +399,17 @@ export async function runCli(
// preflight) is worse than printing `Observer:` on a later line, so we
// emit the run report immediately and finalize the observer link after.
if (parsed.json) {
const observerUrl = await observerUrlFrom(observerMint, io);
emitRunReport(execution, parsed.json, io, observerUrl);
const observerUrl = await observerUrlFrom(observerMint, logged);
emitRunReport(execution, parsed.json, logged, observerUrl, await mirror?.receipt());
// Last, and after the report: the run's own output is what the mirror
// uploads, and a run's exit code has never waited on Cloud. The mirror
// bounds itself and never rejects.
await mirror?.finish(execution.report);
return execution.exitCode;
}
emitRunReport(execution, parsed.json, io);
await finalizeObserverLine(observerMint, io);
emitRunReport(execution, parsed.json, logged);
await finalizeObserverLine(observerMint, logged);
await mirror?.finish(execution.report);
return execution.exitCode;
}

Expand Down Expand Up @@ -593,6 +636,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
let sawDataDir = false;
let spawn = true;
let noObserverLink = false;
let cloudMirror = false;
let input: string | undefined;
let sawInput = false;
let reuseFromRunId: string | undefined;
Expand Down Expand Up @@ -651,6 +695,14 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
noObserverLink = true;
continue;
}
if (argument === '--cloud-mirror') {
// Only meaningful where a local run exists to mirror. Refused on `check`
// (which starts nothing) and, below, on `--cloud` (which IS the hosted
// run), so the flag never silently no-ops.
if (command === 'check' || cloudMirror) return undefined;
cloudMirror = true;
continue;
}
if (argument === '--data-dir') {
const value = args[index + 1];
if (command === 'check' || sawDataDir || value === undefined || value.startsWith('-')) return undefined;
Expand Down Expand Up @@ -694,7 +746,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
// observer-link opt-out -- describes nothing there and is refused rather
// than ignored. `--input` is the authored body's argument and travels with
// the source, so it is accepted exactly where a local run accepts it.
if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || cloudMirror
|| reuseFromRunId !== undefined) return undefined;
if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect };
}
Expand All @@ -705,8 +758,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
return command === 'check'
? { command, json, watch, value: positionals[0]! }
: command === 'run'
? { command, bucket, reuseFromRunId, localAgent, agentCapacity, dataDir, input, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! }
: { command, localAgent, agentCapacity, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
? { command, bucket, reuseFromRunId, localAgent, agentCapacity, dataDir, input, json, spawn, noObserverLink, cloudMirror, allowHumanInfluenced, value: positionals[0]! }
: { command, localAgent, agentCapacity, dataDir, json, spawn, noObserverLink, cloudMirror, allowHumanInfluenced, value: positionals[0]! };
}

/**
Expand Down Expand Up @@ -1029,14 +1082,25 @@ function emitRunReport(
json: boolean,
io: CliIo,
observerUrl?: string,
mirror?: CloudMirrorReceipt,
): void {
const { report } = execution;
emitDiagnostics(report.diagnostics, io);
if (json) {
// Fold `observerUrl` into the JSON report as a sibling of `runId`, so
// downstream tooling that consumes `--json` gets the same signal a
// human reader gets from the plain-text `Observer:` line.
const payload = observerUrl === undefined ? report : { ...report, observerUrl };
//
// `cloudRunId` and `dashboardUrl` ride beside it for the same reason, and
// they close a sharper gap: the report's own `runId` is the *journal's*,
// and every hosted read verb (`flows status --cloud`, `flows logs`,
// `flows runs`) takes Cloud's. Without these a script that mirrored a run
// had no handle on it at all, and a human had to read one out of a URL.
const payload = {
...report,
...(observerUrl === undefined ? {} : { observerUrl }),
...(mirror === undefined ? {} : mirror),
};
io.stdout(JSON.stringify(payload));
return;
}
Expand Down
Loading
Loading