@relayflows/sdk 2.0.31 → 2.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/authored-preflight.d.ts +4 -0
  2. package/dist/authored-preflight.d.ts.map +1 -0
  3. package/dist/authored-preflight.js +50 -0
  4. package/dist/authored-preflight.js.map +1 -0
  5. package/dist/authored-worker-step.d.ts.map +1 -1
  6. package/dist/authored-worker-step.js +3 -2
  7. package/dist/authored-worker-step.js.map +1 -1
  8. package/dist/cli/check.d.ts +10 -2
  9. package/dist/cli/check.d.ts.map +1 -1
  10. package/dist/cli/check.js +16 -137
  11. package/dist/cli/check.js.map +1 -1
  12. package/dist/cli/cli-probe.d.ts +20 -0
  13. package/dist/cli/cli-probe.d.ts.map +1 -0
  14. package/dist/cli/cli-probe.js +166 -0
  15. package/dist/cli/cli-probe.js.map +1 -0
  16. package/dist/cli/cloud-mirror-session.d.ts +97 -0
  17. package/dist/cli/cloud-mirror-session.d.ts.map +1 -0
  18. package/dist/cli/cloud-mirror-session.js +264 -0
  19. package/dist/cli/cloud-mirror-session.js.map +1 -0
  20. package/dist/cli-commands.d.ts +6 -0
  21. package/dist/cli-commands.d.ts.map +1 -1
  22. package/dist/cli-commands.js +1 -0
  23. package/dist/cli-commands.js.map +1 -1
  24. package/dist/cli.d.ts +7 -0
  25. package/dist/cli.d.ts.map +1 -1
  26. package/dist/cli.js +84 -18
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloud-deploy.d.ts +1 -1
  29. package/dist/cloud-deploy.d.ts.map +1 -1
  30. package/dist/cloud-deploy.js +24 -8
  31. package/dist/cloud-deploy.js.map +1 -1
  32. package/dist/cloud-mirror-ledger.d.ts +40 -0
  33. package/dist/cloud-mirror-ledger.d.ts.map +1 -0
  34. package/dist/cloud-mirror-ledger.js +142 -0
  35. package/dist/cloud-mirror-ledger.js.map +1 -0
  36. package/dist/cloud-mirror-step.d.ts +180 -0
  37. package/dist/cloud-mirror-step.d.ts.map +1 -0
  38. package/dist/cloud-mirror-step.js +523 -0
  39. package/dist/cloud-mirror-step.js.map +1 -0
  40. package/dist/cloud-mirror-transport.d.ts +94 -0
  41. package/dist/cloud-mirror-transport.d.ts.map +1 -0
  42. package/dist/cloud-mirror-transport.js +163 -0
  43. package/dist/cloud-mirror-transport.js.map +1 -0
  44. package/dist/cloud-mirror.d.ts +132 -0
  45. package/dist/cloud-mirror.d.ts.map +1 -0
  46. package/dist/cloud-mirror.js +556 -0
  47. package/dist/cloud-mirror.js.map +1 -0
  48. package/dist/cloud-transcript-codex.d.ts.map +1 -1
  49. package/dist/cloud-transcript-codex.js +90 -4
  50. package/dist/cloud-transcript-codex.js.map +1 -1
  51. package/dist/cloud-transcript-types.d.ts +19 -1
  52. package/dist/cloud-transcript-types.d.ts.map +1 -1
  53. package/dist/cloud-transcript-types.js.map +1 -1
  54. package/dist/cloud-transcript.d.ts +1 -1
  55. package/dist/cloud-transcript.d.ts.map +1 -1
  56. package/dist/cloud-transcript.js +10 -0
  57. package/dist/cloud-transcript.js.map +1 -1
  58. package/dist/helper-reference.d.ts.map +1 -1
  59. package/dist/helper-reference.js +134 -6
  60. package/dist/helper-reference.js.map +1 -1
  61. package/dist/hosted-extension-runtime.js +1 -1
  62. package/dist/hosted-extension-sandbox.js +1 -1
  63. package/dist/package-version.d.ts +10 -0
  64. package/dist/package-version.d.ts.map +1 -0
  65. package/dist/package-version.js +22 -0
  66. package/dist/package-version.js.map +1 -0
  67. package/dist/preflight.d.ts +14 -0
  68. package/dist/preflight.d.ts.map +1 -1
  69. package/dist/preflight.js +18 -3
  70. package/dist/preflight.js.map +1 -1
  71. package/dist/relay-cli.d.ts.map +1 -1
  72. package/dist/relay-cli.js +1 -21
  73. package/dist/relay-cli.js.map +1 -1
  74. package/package.json +2 -2
  75. package/src/authored-preflight.ts +46 -0
  76. package/src/authored-worker-step.ts +4 -3
  77. package/src/cli/check.ts +25 -163
  78. package/src/cli/cli-probe.ts +194 -0
  79. package/src/cli/cloud-mirror-session.ts +329 -0
  80. package/src/cli-commands.ts +1 -0
  81. package/src/cli.ts +94 -19
  82. package/src/cloud-deploy.ts +26 -8
  83. package/src/cloud-mirror-ledger.ts +161 -0
  84. package/src/cloud-mirror-step.ts +650 -0
  85. package/src/cloud-mirror-transport.ts +261 -0
  86. package/src/cloud-mirror.ts +633 -0
  87. package/src/cloud-transcript-codex.ts +96 -4
  88. package/src/cloud-transcript-types.ts +18 -1
  89. package/src/cloud-transcript.ts +9 -1
  90. package/src/helper-reference.ts +131 -6
  91. package/src/hosted-extension-runtime.ts +1 -1
  92. package/src/hosted-extension-sandbox.ts +1 -1
  93. package/src/package-version.ts +23 -0
  94. package/src/preflight.ts +31 -7
  95. package/src/relay-cli.ts +1 -24
@@ -0,0 +1,329 @@
1
+ // The Cloud half of `flows run` / `flows resume`: also put this local run on
2
+ // the Cloud dashboard.
3
+ //
4
+ // Opt-in, and deliberately the only opt-in thing on this path.
5
+ //
6
+ // A local run is already watchable by default: `observer-session.ts` beside
7
+ // this one projects the run into its own channel and prints a read-only link,
8
+ // off a workspace key, for free. That is the right default — it is a step
9
+ // projection, it costs nothing, and a run that never joins a workspace is not
10
+ // a defect (RFC-0001 settled decision 7).
11
+ //
12
+ // The dashboard is the richer, hosted view of the same run: the flow source,
13
+ // every step's transcript, the run graph, the logs, and the run sitting in the
14
+ // same history as the hosted ones. It is also the one that *stores* all of
15
+ // that. So it is asked for — `--cloud-mirror`, or `FLOWS_CLOUD_MIRROR=1` for a
16
+ // shell — and never turned on by a login happening to be present. Mirroring
17
+ // someone's local runs because they once signed in is not a default anyone
18
+ // consented to.
19
+ //
20
+ // Best-effort once it is on, like the observer: a registration failure is one
21
+ // labeled stderr line, and nothing here can change a run's exit code or its
22
+ // journal. But it is a *louder* line than it used to be, because the run asked
23
+ // for this and did not get it.
24
+
25
+ import { readFile } from 'node:fs/promises';
26
+ import type { CliIo } from '../cli.js';
27
+ import { canonicalize } from '../canonical.js';
28
+ import { cloudConnection, CloudFlowError } from '../cloud-http.js';
29
+ import { recordMirroredRun, readMirroredRun } from '../cloud-mirror-ledger.js';
30
+ import { createRunMirror, readJournalEvents, type RunMirror } from '../cloud-mirror.js';
31
+ import { MirrorClient, registerLocalRun, type MirrorRunSource } from '../cloud-mirror-transport.js';
32
+ import { parseDigestReference } from '../bundle-transport.js';
33
+ import { isAuthoredFlowPath, parseDirectInput } from '../direct-input.js';
34
+ import type { ProgressEvent } from '../progress.js';
35
+ import type { RunReport } from './run.js';
36
+
37
+ /** `FLOWS_CLOUD_MIRROR=1|true|on` turns the mirror on for a whole shell. */
38
+ export const MIRROR_ENV = 'FLOWS_CLOUD_MIRROR';
39
+
40
+ /** What the mirror knows about this run on Cloud, once it is registered. */
41
+ export interface CloudMirrorReceipt {
42
+ /** Cloud's run id — the argument `flows status --cloud` and `flows logs` take. */
43
+ cloudRunId: string;
44
+ dashboardUrl: string;
45
+ }
46
+
47
+ export interface CloudMirrorSession {
48
+ /** The root run is admitted: the mirror can start reading its journal. */
49
+ onRunStarted(run: { runId: string }): void;
50
+ /**
51
+ * The registration, once it lands. Resolves to nothing when the mirror is
52
+ * off, was refused, or the run never started.
53
+ *
54
+ * Awaiting it is what lets `--json` carry the same handle the terminal line
55
+ * carries: registration is one request made when the run is admitted, so by
56
+ * the time a run has a report it has long since settled — and a consumer of
57
+ * a machine-readable report can wait for a request that has already
58
+ * happened.
59
+ */
60
+ receipt(): Promise<CloudMirrorReceipt | undefined>;
61
+ /** A journaled entry; the first one names the run for a declarative flow. */
62
+ onJournalEntry(entry: { run_id: string }): void;
63
+ /** Step transitions, pushed as lifecycle events the run page's stream shows. */
64
+ onProgress(event: ProgressEvent): void;
65
+ /** Publish the final report and the terminal status. Never rejects. */
66
+ finish(report: RunReport): Promise<void>;
67
+ }
68
+
69
+ export interface CloudMirrorDeps {
70
+ register?: typeof registerLocalRun;
71
+ createMirror?: typeof createRunMirror;
72
+ }
73
+
74
+ export interface CloudMirrorRequest {
75
+ /**
76
+ * The flow's source and input, exactly as `flows run --cloud` would send
77
+ * them. Resolved lazily, once the run id exists: a `resume` has no flow
78
+ * path at all and has to read what ran out of the journal.
79
+ */
80
+ source: (runId: string) => Promise<MirrorRunSource>;
81
+ dataDir: string;
82
+ /** Lines the CLI has printed for this run, uploaded as the run's `runner.log`. */
83
+ log: () => readonly string[];
84
+ /** How the dashboard was asked for, so a refusal can name the right switch. */
85
+ requested: 'flag' | 'env';
86
+ }
87
+
88
+ /**
89
+ * Whether this shell asked for the dashboard.
90
+ *
91
+ * Only an affirmative turns it on. Anything else — unset, empty, `0`, or a
92
+ * value nobody meant as a switch — leaves the run local, because the cost of
93
+ * reading a stray value as consent is someone's runs being uploaded.
94
+ */
95
+ export function cloudMirrorRequested(env: NodeJS.ProcessEnv): boolean {
96
+ const value = env[MIRROR_ENV]?.trim().toLowerCase();
97
+ return value === '1' || value === 'true' || value === 'on' || value === 'yes';
98
+ }
99
+
100
+ /**
101
+ * Open the session. Registration is deferred until the run id exists, so a
102
+ * refused flow never reaches Cloud at all: `flows run` on a spec that does not
103
+ * compile creates no dashboard row, the same as today.
104
+ */
105
+ export function createCloudMirrorSession(
106
+ request: CloudMirrorRequest,
107
+ io: CliIo,
108
+ env: NodeJS.ProcessEnv = process.env,
109
+ deps: CloudMirrorDeps = {},
110
+ ): CloudMirrorSession {
111
+ const register = deps.register ?? registerLocalRun;
112
+ const createMirror = deps.createMirror ?? createRunMirror;
113
+ let opening: Promise<RunMirror | undefined> | undefined;
114
+ let receipt: CloudMirrorReceipt | undefined;
115
+
116
+ const open = (runId: string): Promise<RunMirror | undefined> => opening ??= request.source(runId)
117
+ .then(register)
118
+ .then(registration => {
119
+ const mirror = createMirror({
120
+ client: new MirrorClient(registration),
121
+ dataDir: request.dataDir,
122
+ env,
123
+ diagnostic: message => io.stderr(`[cloud] ${message}`),
124
+ });
125
+ receipt = { cloudRunId: registration.runId, dashboardUrl: registration.runUrl };
126
+ // So a later `flows resume` of this same journal can name this attempt
127
+ // as its predecessor. The credential is deliberately not written; a
128
+ // resume mints its own.
129
+ void recordMirroredRun(request.dataDir, runId, {
130
+ cloudRunId: registration.runId, apiUrl: registration.apiUrl,
131
+ });
132
+ // The page, and the command that follows the same run from a terminal.
133
+ // The id is Cloud's, not the journal's, and nothing else in this
134
+ // invocation prints it — without it a reader has to pick it out of the
135
+ // URL to use any of the hosted read verbs.
136
+ io.stderr(`Dashboard: ${mirror.runUrl} · flows status --cloud --watch ${registration.runId}`);
137
+ mirror.start(runId);
138
+ return mirror;
139
+ })
140
+ .catch((error: unknown) => {
141
+ io.stderr(`[cloud] ${mirrorRefusal(error, request.requested)}`);
142
+ return undefined;
143
+ });
144
+
145
+ return {
146
+ onRunStarted(run) {
147
+ void open(run.runId);
148
+ },
149
+ onJournalEntry(entry) {
150
+ void open(entry.run_id);
151
+ },
152
+ async receipt() {
153
+ if (opening === undefined) return undefined;
154
+ await opening;
155
+ return receipt;
156
+ },
157
+ onProgress(event) {
158
+ // Fire-and-forget, and only for transitions: a `step.running` tick
159
+ // arrives every second and would say nothing the snapshot does not.
160
+ if (opening === undefined) return;
161
+ if (event.type !== 'step.started' && event.type !== 'step.completed') return;
162
+ void opening.then(mirror => mirror?.event({
163
+ eventType: `relayflow.${event.type}`,
164
+ stepName: event.stepId,
165
+ payload: { stepType: event.stepType },
166
+ }));
167
+ },
168
+ async finish(report) {
169
+ const mirror = opening === undefined ? undefined : await opening;
170
+ if (mirror === undefined) return;
171
+ await mirror.finish({
172
+ status: report.completionReason === 'canceled'
173
+ ? 'cancelled'
174
+ : report.ok ? 'completed' : 'failed',
175
+ result: completionReport(report),
176
+ ...(report.completionReason === undefined ? {} : { completionReason: report.completionReason }),
177
+ ...(terminalError(report) === undefined ? {} : { error: terminalError(report)! }),
178
+ log: request.log(),
179
+ });
180
+ },
181
+ };
182
+ }
183
+
184
+ /**
185
+ * What `flows run` mirrors: the flow file's exact bytes, and — for an authored
186
+ * flow — the input it was invoked with.
187
+ *
188
+ * The same source `flows run --cloud` would submit, so a run mirrored from a
189
+ * terminal and the same run launched hosted are recorded identically. It is
190
+ * read here rather than taken from the compiled spec because the compiled
191
+ * form is not what the author wrote, and the run page shows source.
192
+ */
193
+ export function mirrorSourceFromPath(
194
+ path: string,
195
+ inputArgument: string | undefined,
196
+ dataDir: string,
197
+ ): (runId: string) => Promise<MirrorRunSource> {
198
+ const fromJournal = mirrorSourceFromJournal(dataDir);
199
+ return async (runId) => {
200
+ // `flows run <flow>@sha256:<digest>` names a bundle, not a file on this
201
+ // disk: the runner fetches it before executing. Reading the argument as a
202
+ // path there refused the registration and left a perfectly good run off
203
+ // the dashboard, so fall back to what the journal recorded — the same
204
+ // source a resume mirrors.
205
+ if (parseDigestReference(path)) return fromJournal(runId);
206
+ const workflow = await readFile(path, 'utf8');
207
+ if (!isAuthoredFlowPath(path)) return { workflow, fileType: 'yaml' };
208
+ return { workflow, fileType: 'ts', inputs: parseDirectInput(inputArgument) };
209
+ };
210
+ }
211
+
212
+ /**
213
+ * What `flows resume` mirrors: the kernel spec the journal recorded at
214
+ * `run.spawned`, canonicalized.
215
+ *
216
+ * A resume names a run id, not a file — the flow it continues may have been
217
+ * edited or deleted since. The journal is the record of what actually ran, so
218
+ * that is what the mirror publishes, as canonical JSON (a YAML subset, the
219
+ * same representation `runInCloud` sends for a declarative flow).
220
+ *
221
+ * Each resume registers its own Cloud run, and that is deliberate rather than
222
+ * a shortcut: a mirrored run goes terminal on Cloud when the CLI exits, and
223
+ * Cloud refuses to move a terminal run back to `running`. Cloud's own v2
224
+ * resume is likewise a new attempt row, so one dashboard row per invocation is
225
+ * the shape the control plane already has — and it costs no credential stored
226
+ * on disk between invocations.
227
+ */
228
+ export function mirrorSourceFromJournal(dataDir: string): (runId: string) => Promise<MirrorRunSource> {
229
+ return async (runId) => {
230
+ // The predecessor, if this journal has been mirrored before. Read against
231
+ // the deployment this invocation will actually register with, so a run
232
+ // mirrored to staging never claims to continue an id that means something
233
+ // else in production.
234
+ const resumedFromRunId = await readMirroredRun(dataDir, runId, cloudConnection({}).baseUrl)
235
+ .catch(() => undefined);
236
+ // Through the retrying reader: a resume reads this journal while the
237
+ // daemon is writing to it, and a single `journal_busy` used to abandon
238
+ // the registration and leave the resumed run off the dashboard entirely.
239
+ for (const event of await readJournalEvents(runId, dataDir)) {
240
+ if (event.entry_type !== 'run.spawned') continue;
241
+ const payload = event.payload as { spec?: unknown } | null;
242
+ if (payload?.spec === undefined) break;
243
+ return {
244
+ workflow: canonicalize(payload.spec),
245
+ fileType: 'yaml',
246
+ ...(resumedFromRunId === undefined ? {} : { resumedFromRunId }),
247
+ };
248
+ }
249
+ throw new CloudFlowError('invalid_input',
250
+ `Run "${runId}" journals no spec to mirror; the run is unaffected.`);
251
+ };
252
+ }
253
+
254
+ /** Diagnostics one completion report carries; the rest are counted. */
255
+ const MAX_REPORT_DIAGNOSTICS = 20;
256
+ /** One diagnostic message, in code points. */
257
+ const MAX_DIAGNOSTIC_CHARS = 2_000;
258
+
259
+ /**
260
+ * The run's completion report, as the terminal callback stores it.
261
+ *
262
+ * Cloud reconciles a v2 run's reported status against this document: a
263
+ * `completed` callback whose report does not carry `ok`, `status`,
264
+ * `completionReason` and `runId` together is recorded as `failed`. That guard
265
+ * exists so a sandbox whose bootstrap exits cleanly cannot report a run that
266
+ * died at step 1 as green — and it cuts the other way too, so a mirror that
267
+ * omits the report turns a finished local run red. Those four fields are the
268
+ * contract; everything else here is what a reader of the run page gets for
269
+ * free, since the run list extracts `completionReason` from the same document.
270
+ *
271
+ * Diagnostics are bounded and their messages clipped. They are the one part of
272
+ * a report that is unbounded free text, and this document is stored whole.
273
+ */
274
+ function completionReport(report: RunReport): Record<string, unknown> {
275
+ const diagnostics = report.diagnostics.slice(0, MAX_REPORT_DIAGNOSTICS).map(entry => ({
276
+ severity: 'severity' in entry ? entry.severity : 'refusal',
277
+ kind: entry.kind,
278
+ message: [...entry.message].slice(0, MAX_DIAGNOSTIC_CHARS).join(''),
279
+ }));
280
+ return {
281
+ ok: report.ok,
282
+ command: report.command,
283
+ ...(report.runId === undefined ? {} : { runId: report.runId }),
284
+ ...(report.rootRunId === undefined ? {} : { rootRunId: report.rootRunId }),
285
+ ...(report.status === undefined ? {} : { status: report.status }),
286
+ ...(report.completionReason === undefined ? {} : { completionReason: report.completionReason }),
287
+ ...(report.completionDetail === undefined ? {} : { completionDetail: report.completionDetail }),
288
+ ...(report.completedSteps === undefined ? {} : { completedSteps: report.completedSteps }),
289
+ ...(report.parkedStep === undefined ? {} : { parkedStep: report.parkedStep }),
290
+ ...(report.parkCause === undefined ? {} : { parkCause: report.parkCause }),
291
+ ...(report.next === undefined ? {} : { next: report.next }),
292
+ ...(diagnostics.length === 0 ? {} : { diagnostics }),
293
+ ...(report.diagnostics.length > diagnostics.length
294
+ ? { diagnosticsOmitted: report.diagnostics.length - diagnostics.length }
295
+ : {}),
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Why the dashboard is not getting this run, in one line a reader can act on.
301
+ *
302
+ * Every one of these is a request that was not honoured — the run asked for
303
+ * the dashboard and is not on it — so none of them read as an aside. The
304
+ * missing-login case is the common one and names the two commands that fix
305
+ * it: sign in, or stop asking.
306
+ */
307
+ function mirrorRefusal(error: unknown, requested: 'flag' | 'env'): string {
308
+ const asked = requested === 'flag' ? '--cloud-mirror' : `${MIRROR_ENV}=1`;
309
+ if (error instanceof CloudFlowError && error.code === 'configuration') {
310
+ return error.reason === 'auth_missing'
311
+ ? `${asked} asked for the Cloud dashboard, but there is no Cloud login, so this run stays local. `
312
+ + `Sign in with \`agent-relay cloud login\`, or drop ${asked}.`
313
+ : `${asked} asked for the Cloud dashboard, but this run stays local: ${error.message}`;
314
+ }
315
+ if (error instanceof CloudFlowError && error.status === 404) {
316
+ return `${asked} asked for the Cloud dashboard, but this deployment does not accept local runs; `
317
+ + 'the run itself is unaffected.';
318
+ }
319
+ return `${asked} asked for the Cloud dashboard, but this run could not be registered (${
320
+ error instanceof Error ? error.message : String(error)}); the run itself is unaffected.`;
321
+ }
322
+
323
+ /** The run's own failure text, bounded by the mirror's transport, or nothing. */
324
+ function terminalError(report: RunReport): string | undefined {
325
+ if (report.ok) return undefined;
326
+ const diagnostic = report.diagnostics.find(entry =>
327
+ 'severity' in entry && (entry.severity === 'failure' || entry.severity === 'refusal'));
328
+ return diagnostic?.message;
329
+ }
@@ -96,6 +96,7 @@ const LOCAL_EXECUTION_OPTIONS = [
96
96
  },
97
97
  { flags: '--no-spawn', description: 'Require a running relayflowd rather than starting one' },
98
98
  { flags: '--no-observer-link', description: 'Do not mint an observer link for this run' },
99
+ { flags: '--cloud-mirror', description: 'Also put this run on the Cloud dashboard (also FLOWS_CLOUD_MIRROR=1)' },
99
100
  {
100
101
  flags: '--allow-human-influenced',
101
102
  description: 'Proceed even though the run carries human-influenced state',
package/src/cli.ts CHANGED
@@ -9,6 +9,10 @@ import type { CliModelSource } from './cli-adapter.js';
9
9
  import { renderProgress, type ProgressEvent } from './progress.js';
10
10
  import type { JournalEvent } from './journal-reader.js';
11
11
  import { createObserverSession } from './cli/observer-session.js';
12
+ import {
13
+ cloudMirrorRequested, createCloudMirrorSession, mirrorSourceFromJournal, mirrorSourceFromPath,
14
+ type CloudMirrorReceipt,
15
+ } from './cli/cloud-mirror-session.js';
12
16
  import { realpathSync } from 'node:fs';
13
17
  import { pathToFileURL } from 'node:url';
14
18
  import {
@@ -54,6 +58,7 @@ import {
54
58
  resolveObserverLinkEnv,
55
59
  type MintObserverOptions,
56
60
  } from './observer-link.js';
61
+ import { packageVersion } from './package-version.js';
57
62
 
58
63
  export type { CheckInputDiagnostic, CheckReport } from './cli/check.js';
59
64
 
@@ -95,8 +100,8 @@ export type ParsedArgs =
95
100
  | { command: 'schedules'; json: boolean }
96
101
  | { command: 'unschedule'; scheduleId: string; json: boolean }
97
102
  | { command: 'check'; json: boolean; watch: boolean; value: string }
98
- | { 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 }
99
- | { command: 'resume'; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
103
+ | { 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 }
104
+ | { command: 'resume'; localAgent: boolean; agentCapacity: number | undefined; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; cloudMirror: boolean; allowHumanInfluenced: boolean; value: string }
100
105
  | { command: 'answer'; dataDir: string; json: boolean; spawn: boolean; note: string | undefined; by: string | undefined; runId: string; waitId: string; answer: boolean }
101
106
  | RunsArgs
102
107
  | LogsArgs
@@ -126,13 +131,13 @@ const USAGE = [
126
131
  'flows run <flow>@sha256:<digest> [--bucket <file-bucket-uri>] [--data-dir <dir>] [--json]',
127
132
  'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
128
133
  'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
129
- 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] [--reuse-from <run-id>] <flow.yaml|spec.json>',
134
+ '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>',
130
135
  'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
131
136
  'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
132
137
  'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
133
- 'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
138
+ '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>',
134
139
  '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>',
135
- 'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
140
+ 'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
136
141
  'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
137
142
  'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
138
143
  'flows status [--json] [--data-dir <dir>] [--tail <n>] [<run-id>]',
@@ -161,6 +166,12 @@ const PROCESS_IO: CliIo = {
161
166
 
162
167
  /** Optional knobs for an embedded caller. `bin/flows.js` passes none. */
163
168
  export interface RunCliOptions {
169
+ /**
170
+ * Version to print for a self-contained executable that cannot read the
171
+ * installed package manifest. Normal package entrypoints leave this unset.
172
+ */
173
+ version?: string;
174
+
164
175
  /**
165
176
  * Cancellation for the long-running verbs (`run --cloud`, `check --watch`,
166
177
  * `serve-webhook`, `hn-monitor start`, `tick start`).
@@ -202,6 +213,10 @@ export async function runCli(
202
213
  io: CliIo = PROCESS_IO,
203
214
  options: RunCliOptions = {},
204
215
  ): Promise<CliExitCode> {
216
+ if (args.length === 1 && (args[0] === '--version' || args[0] === '-V')) {
217
+ io.stdout(options.version ?? packageVersion());
218
+ return 0;
219
+ }
205
220
  if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
206
221
  io.stdout(USAGE);
207
222
  return 0;
@@ -315,25 +330,59 @@ export async function runCli(
315
330
  // effect of an invocation that is about to be refused for bad input.
316
331
  const startedSteps = new Map<string, number>();
317
332
  const observer = parsed.noObserverLink ? undefined : createObserverSession(parsed.command, io);
333
+ const runnerLog: string[] = [];
334
+ // Opt-in, unlike the observer link beside it. The observer is the default
335
+ // way to watch a local run: it is free, it needs only a workspace key, and
336
+ // it carries a step projection. The dashboard is the richer, hosted view —
337
+ // it stores the flow source, every step's transcript and this invocation's
338
+ // own output — so a local run joins it because someone asked, never because
339
+ // a login happened to be lying around.
340
+ const mirror = !(parsed.cloudMirror || cloudMirrorRequested(process.env))
341
+ ? undefined
342
+ : createCloudMirrorSession({
343
+ source: parsed.command === 'run'
344
+ ? mirrorSourceFromPath(parsed.value, parsed.input, parsed.dataDir)
345
+ : mirrorSourceFromJournal(parsed.dataDir),
346
+ dataDir: parsed.dataDir,
347
+ log: () => runnerLog,
348
+ requested: parsed.cloudMirror ? 'flag' : 'env',
349
+ }, io);
350
+ // Everything this invocation prints, kept in order, so the mirror can upload
351
+ // it as the run's `runner.log` — the object the dashboard's log pane reads,
352
+ // and the sandbox's own equivalent. A declarative run emits no progress
353
+ // events at all, so building the log out of those would have left the pane
354
+ // empty for exactly the runs that are easiest to follow.
355
+ const logged: CliIo = mirror === undefined ? io : {
356
+ ...io,
357
+ stdout: line => { runnerLog.push(line); io.stdout(line); },
358
+ stderr: line => { runnerLog.push(line); io.stderr(line); },
359
+ };
318
360
  const showProgress = (event: ProgressEvent): void => {
319
361
  if (event.type === 'step.started') startedSteps.set(event.stepId, performance.now());
320
- if (!parsed.json) for (const line of renderProgress([event])) io.stderr(line);
362
+ if (!parsed.json) for (const line of renderProgress([event])) logged.stderr(line);
321
363
  observer?.onProgress(event);
364
+ mirror?.onProgress(event);
322
365
  };
323
366
  const lifecycle = {
324
367
  ...(parsed.command === 'run' ? { bucket: parsed.bucket } : {}),
325
368
  allowHumanInfluenced: parsed.allowHumanInfluenced,
326
- onPtyReady: (path: string) => io.stderr(`PTY ${path}`),
369
+ onPtyReady: (path: string) => logged.stderr(`PTY ${path}`),
327
370
  ...(parsed.command === 'run' && parsed.reuseFromRunId !== undefined ? { reuseFromRunId: parsed.reuseFromRunId } : {}),
328
371
  localAgent: parsed.localAgent,
329
372
  ...(parsed.agentCapacity === undefined ? {} : { agentCapacity: parsed.agentCapacity }),
330
373
  onProgress: showProgress,
331
- ...(observer === undefined ? {} : {
332
- onJournalEntry: (entry: JournalEvent) => observer.onJournalEntry(entry),
333
- onRunStarted: (run: { runId: string; flow: string; resumed?: boolean }) => observer.onRunStarted(run),
374
+ ...(observer === undefined && mirror === undefined ? {} : {
375
+ onJournalEntry: (entry: JournalEvent) => {
376
+ observer?.onJournalEntry(entry);
377
+ mirror?.onJournalEntry(entry);
378
+ },
379
+ onRunStarted: (run: { runId: string; flow: string; resumed?: boolean }) => {
380
+ observer?.onRunStarted(run);
381
+ mirror?.onRunStarted(run);
382
+ },
334
383
  }),
335
384
  onWait: (progress: RunProgress) => {
336
- emitWait(progress, io);
385
+ emitWait(progress, logged);
337
386
  const now = performance.now();
338
387
  if (!startedSteps.has(progress.stepId)) startedSteps.set(progress.stepId, now);
339
388
  showProgress({ type: 'step.running', stepId: progress.stepId, stepType: progress.stepType,
@@ -361,12 +410,17 @@ export async function runCli(
361
410
  // preflight) is worse than printing `Observer:` on a later line, so we
362
411
  // emit the run report immediately and finalize the observer link after.
363
412
  if (parsed.json) {
364
- const observerUrl = await observerUrlFrom(observerMint, io);
365
- emitRunReport(execution, parsed.json, io, observerUrl);
413
+ const observerUrl = await observerUrlFrom(observerMint, logged);
414
+ emitRunReport(execution, parsed.json, logged, observerUrl, await mirror?.receipt());
415
+ // Last, and after the report: the run's own output is what the mirror
416
+ // uploads, and a run's exit code has never waited on Cloud. The mirror
417
+ // bounds itself and never rejects.
418
+ await mirror?.finish(execution.report);
366
419
  return execution.exitCode;
367
420
  }
368
- emitRunReport(execution, parsed.json, io);
369
- await finalizeObserverLine(observerMint, io);
421
+ emitRunReport(execution, parsed.json, logged);
422
+ await finalizeObserverLine(observerMint, logged);
423
+ await mirror?.finish(execution.report);
370
424
  return execution.exitCode;
371
425
  }
372
426
 
@@ -593,6 +647,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
593
647
  let sawDataDir = false;
594
648
  let spawn = true;
595
649
  let noObserverLink = false;
650
+ let cloudMirror = false;
596
651
  let input: string | undefined;
597
652
  let sawInput = false;
598
653
  let reuseFromRunId: string | undefined;
@@ -651,6 +706,14 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
651
706
  noObserverLink = true;
652
707
  continue;
653
708
  }
709
+ if (argument === '--cloud-mirror') {
710
+ // Only meaningful where a local run exists to mirror. Refused on `check`
711
+ // (which starts nothing) and, below, on `--cloud` (which IS the hosted
712
+ // run), so the flag never silently no-ops.
713
+ if (command === 'check' || cloudMirror) return undefined;
714
+ cloudMirror = true;
715
+ continue;
716
+ }
654
717
  if (argument === '--data-dir') {
655
718
  const value = args[index + 1];
656
719
  if (command === 'check' || sawDataDir || value === undefined || value.startsWith('-')) return undefined;
@@ -694,7 +757,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
694
757
  // observer-link opt-out -- describes nothing there and is refused rather
695
758
  // than ignored. `--input` is the authored body's argument and travels with
696
759
  // the source, so it is accepted exactly where a local run accepts it.
697
- if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
760
+ if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || cloudMirror
761
+ || reuseFromRunId !== undefined) return undefined;
698
762
  if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
699
763
  return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect };
700
764
  }
@@ -705,8 +769,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
705
769
  return command === 'check'
706
770
  ? { command, json, watch, value: positionals[0]! }
707
771
  : command === 'run'
708
- ? { command, bucket, reuseFromRunId, localAgent, agentCapacity, dataDir, input, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! }
709
- : { command, localAgent, agentCapacity, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
772
+ ? { command, bucket, reuseFromRunId, localAgent, agentCapacity, dataDir, input, json, spawn, noObserverLink, cloudMirror, allowHumanInfluenced, value: positionals[0]! }
773
+ : { command, localAgent, agentCapacity, dataDir, json, spawn, noObserverLink, cloudMirror, allowHumanInfluenced, value: positionals[0]! };
710
774
  }
711
775
 
712
776
  /**
@@ -1029,6 +1093,7 @@ function emitRunReport(
1029
1093
  json: boolean,
1030
1094
  io: CliIo,
1031
1095
  observerUrl?: string,
1096
+ mirror?: CloudMirrorReceipt,
1032
1097
  ): void {
1033
1098
  const { report } = execution;
1034
1099
  emitDiagnostics(report.diagnostics, io);
@@ -1036,7 +1101,17 @@ function emitRunReport(
1036
1101
  // Fold `observerUrl` into the JSON report as a sibling of `runId`, so
1037
1102
  // downstream tooling that consumes `--json` gets the same signal a
1038
1103
  // human reader gets from the plain-text `Observer:` line.
1039
- const payload = observerUrl === undefined ? report : { ...report, observerUrl };
1104
+ //
1105
+ // `cloudRunId` and `dashboardUrl` ride beside it for the same reason, and
1106
+ // they close a sharper gap: the report's own `runId` is the *journal's*,
1107
+ // and every hosted read verb (`flows status --cloud`, `flows logs`,
1108
+ // `flows runs`) takes Cloud's. Without these a script that mirrored a run
1109
+ // had no handle on it at all, and a human had to read one out of a URL.
1110
+ const payload = {
1111
+ ...report,
1112
+ ...(observerUrl === undefined ? {} : { observerUrl }),
1113
+ ...(mirror === undefined ? {} : mirror),
1114
+ };
1040
1115
  io.stdout(JSON.stringify(payload));
1041
1116
  return;
1042
1117
  }
@@ -23,18 +23,22 @@ import { assertNoUseDependencies, collectExtensionSubmissions } from './flow-ext
23
23
  * inside a fresh branch of the deployment's repository.
24
24
  */
25
25
 
26
- export const FLOW_TRIGGER_PROVIDERS = ['github', 'linear', 'jira', 'shortcut', 'slack'] as const;
26
+ export const FLOW_TRIGGER_PROVIDERS = ['github', 'gitlab', 'linear', 'jira', 'shortcut', 'slack'] as const;
27
27
  export type FlowTriggerProvider = (typeof FLOW_TRIGGER_PROVIDERS)[number];
28
28
 
29
29
  /** Settings Cloud's launcher prefilter reads per provider (`flow-trigger-sources.ts`). */
30
30
  const PROVIDER_SETTINGS: Record<FlowTriggerProvider, readonly string[]> = {
31
31
  // `events`: `issues` (default) or `pull_request` — which GitHub records
32
- // wake the listener (AgentWorkforce/cloud#3772).
33
- github: ['repository', 'labels', 'contains', 'events'],
32
+ // wake the listener (AgentWorkforce/cloud#3772). `reviews`, `checks` and
33
+ // `comments` opt a pull_request source out of its extra subscriptions.
34
+ github: ['repository', 'labels', 'contains', 'events', 'reviews', 'checks', 'comments'],
35
+ gitlab: ['project', 'labels', 'contains', 'events'],
34
36
  slack: ['channel', 'contains'],
35
- linear: ['team', 'contains'],
36
- jira: ['project', 'contains'],
37
- shortcut: ['workspace', 'contains'],
37
+ // `events`: `issues` (default), `assigned` (issues assigned to the agent's
38
+ // connected app user), or `all` — which Linear records wake the listener.
39
+ linear: ['team', 'project', 'labels', 'contains', 'events'],
40
+ jira: ['project', 'labels', 'contains'],
41
+ shortcut: ['workspace', 'team', 'labels', 'contains'],
38
42
  };
39
43
  const MAX_SOURCE_BYTES = 256_000;
40
44
  const MAX_SETTING_LENGTH = 500;
@@ -135,12 +139,26 @@ export function parseTriggerSource(value: string): FlowTriggerSource {
135
139
  if (key === 'events') {
136
140
  // Cloud's enum is lowercase; send it that way whatever the shell typed.
137
141
  const events = setting.toLowerCase();
138
- if (!['issues', 'pull_request'].includes(events)) {
139
- throw new CloudFlowError('invalid_input', `github events must be "issues" or "pull_request", got "${setting}".`);
142
+ const valid =
143
+ provider === 'gitlab' ? ['issues', 'merge_request']
144
+ : provider === 'linear' ? ['issues', 'assigned', 'all']
145
+ : ['issues', 'pull_request'];
146
+ if (!valid.includes(events)) {
147
+ throw new CloudFlowError('invalid_input',
148
+ `${provider} events must be ${valid.map(v => `"${v}"`).join(' or ')}, got "${setting}".`);
140
149
  }
141
150
  settings[key] = events;
142
151
  continue;
143
152
  }
153
+ if (provider === 'github' && (key === 'reviews' || key === 'checks' || key === 'comments')) {
154
+ // Subscription opt-outs are boolean; Cloud refuses any other value.
155
+ const toggle = setting.toLowerCase();
156
+ if (!['true', 'false'].includes(toggle)) {
157
+ throw new CloudFlowError('invalid_input', `github ${key} must be "true" or "false", got "${setting}".`);
158
+ }
159
+ settings[key] = toggle;
160
+ continue;
161
+ }
144
162
  settings[key] = setting;
145
163
  }
146
164
  }