@relayflows/sdk 2.0.31 → 2.0.32

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 (76) 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 +168 -0
  37. package/dist/cloud-mirror-step.d.ts.map +1 -0
  38. package/dist/cloud-mirror-step.js +504 -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/hosted-extension-runtime.js +1 -1
  49. package/dist/package-version.d.ts +10 -0
  50. package/dist/package-version.d.ts.map +1 -0
  51. package/dist/package-version.js +22 -0
  52. package/dist/package-version.js.map +1 -0
  53. package/dist/preflight.d.ts +14 -0
  54. package/dist/preflight.d.ts.map +1 -1
  55. package/dist/preflight.js +18 -3
  56. package/dist/preflight.js.map +1 -1
  57. package/dist/relay-cli.d.ts.map +1 -1
  58. package/dist/relay-cli.js +1 -21
  59. package/dist/relay-cli.js.map +1 -1
  60. package/package.json +2 -2
  61. package/src/authored-preflight.ts +46 -0
  62. package/src/authored-worker-step.ts +4 -3
  63. package/src/cli/check.ts +25 -163
  64. package/src/cli/cli-probe.ts +194 -0
  65. package/src/cli/cloud-mirror-session.ts +329 -0
  66. package/src/cli-commands.ts +1 -0
  67. package/src/cli.ts +94 -19
  68. package/src/cloud-deploy.ts +26 -8
  69. package/src/cloud-mirror-ledger.ts +161 -0
  70. package/src/cloud-mirror-step.ts +618 -0
  71. package/src/cloud-mirror-transport.ts +261 -0
  72. package/src/cloud-mirror.ts +633 -0
  73. package/src/hosted-extension-runtime.ts +1 -1
  74. package/src/package-version.ts +23 -0
  75. package/src/preflight.ts +31 -7
  76. package/src/relay-cli.ts +1 -24
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
  }
@@ -0,0 +1,161 @@
1
+ // Which Cloud run mirrored which journal, remembered between invocations.
2
+ //
3
+ // A journal outlives the process that started it: `flows run` parks on an
4
+ // `f.human`, exits, and some time later a different `flows resume` picks the
5
+ // same journal up. Cloud models that second attempt as its own run row — its
6
+ // own hosted resume mints a fresh run id too — so a parked-then-resumed flow
7
+ // is two rows however it is driven. What made that read as two *unrelated*
8
+ // pieces of work was that the resuming process had no idea the first attempt
9
+ // existed: nothing on disk connected the journal to the Cloud run mirroring
10
+ // it, so the second registration could not name its predecessor.
11
+ //
12
+ // This is that connection, and deliberately nothing more.
13
+ //
14
+ // ## What is stored, and what is not
15
+ //
16
+ // The run id and the deployment that issued it. **Never the credential.** The
17
+ // run token can write this run's steps and read the workspace's runs; leaving
18
+ // one in a dotfile under a developer's home directory, for every run they
19
+ // have ever started, would be a worse trade than the feature is worth. A
20
+ // resume mints its own credential the same way the first attempt did — the
21
+ // registration request it already makes.
22
+ //
23
+ // So an entry is not a capability. Everything in it is already in the URL the
24
+ // first attempt printed to the terminal.
25
+ //
26
+ // ## Failure is not an error
27
+ //
28
+ // Every operation here is best-effort and silent. A missing entry means the
29
+ // resumed run registers without naming a predecessor, which is exactly what
30
+ // happened before this file existed; an unreadable or corrupt one means the
31
+ // same. A mirror is an observer, and bookkeeping for an observer must never
32
+ // be able to fail a run.
33
+
34
+ import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises';
35
+ import { join, resolve } from 'node:path';
36
+
37
+ /** Where the ledger lives, beside the journals it describes. */
38
+ export const LEDGER_DIRECTORY = 'cloud-runs';
39
+ /**
40
+ * How long an entry is worth keeping.
41
+ *
42
+ * A resume of a two-week-old parked run is a real thing; a resume of a run
43
+ * from last quarter is not the case this serves, and a ledger that only grows
44
+ * is a directory nobody ever looks at filling up forever.
45
+ */
46
+ export const LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
47
+ /** Entries kept. Past it, the oldest are dropped on the next write. */
48
+ export const LEDGER_MAX_ENTRIES = 500;
49
+
50
+ /** One journal's mirror, as the ledger records it. */
51
+ export interface LedgerEntry {
52
+ /** Cloud's run id for the attempt that mirrored this journal. */
53
+ cloudRunId: string;
54
+ /** The deployment that issued it; an entry from another one is not this one's. */
55
+ apiUrl: string;
56
+ }
57
+
58
+ const JOURNAL_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
59
+ const CLOUD_RUN_ID = /^[A-Za-z0-9_-]{1,128}$/u;
60
+
61
+ function entryPath(dataDir: string, journalRunId: string): string {
62
+ return join(resolve(dataDir), LEDGER_DIRECTORY, `${journalRunId}.json`);
63
+ }
64
+
65
+ /**
66
+ * Record that `journalRunId` is mirrored by `entry.cloudRunId`.
67
+ *
68
+ * Written with mode 0600 for the same reason the journals are private: it
69
+ * names runs in someone's workspace, and a shared machine should not publish
70
+ * that. It is not a secret — there is nothing here to steal — but it is
71
+ * nobody else's business either.
72
+ */
73
+ export async function recordMirroredRun(
74
+ dataDir: string,
75
+ journalRunId: string,
76
+ entry: LedgerEntry,
77
+ ): Promise<void> {
78
+ if (!JOURNAL_RUN_ID.test(journalRunId) || !CLOUD_RUN_ID.test(entry.cloudRunId)) return;
79
+ const directory = join(resolve(dataDir), LEDGER_DIRECTORY);
80
+ try {
81
+ await mkdir(directory, { recursive: true, mode: 0o700 });
82
+ await writeFile(
83
+ entryPath(dataDir, journalRunId),
84
+ JSON.stringify({ cloudRunId: entry.cloudRunId, apiUrl: entry.apiUrl }),
85
+ { mode: 0o600 },
86
+ );
87
+ } catch {
88
+ // A resume that cannot name its predecessor is the behaviour this file
89
+ // improves on, not a failure of the run.
90
+ return;
91
+ }
92
+ await prune(directory);
93
+ }
94
+
95
+ /**
96
+ * The Cloud run mirroring this journal, if one is recorded *for this
97
+ * deployment*.
98
+ *
99
+ * The deployment check is not bookkeeping. A developer who ran a flow against
100
+ * staging and resumes it against production must not have the resumed run
101
+ * claim to continue a run id that means something else there — or, worse,
102
+ * nothing at all, which Cloud would refuse and which would read as a bug in
103
+ * the resume rather than in the pairing.
104
+ */
105
+ export async function readMirroredRun(
106
+ dataDir: string,
107
+ journalRunId: string,
108
+ apiUrl: string,
109
+ ): Promise<string | undefined> {
110
+ if (!JOURNAL_RUN_ID.test(journalRunId)) return undefined;
111
+ let parsed: unknown;
112
+ try {
113
+ parsed = JSON.parse(await readFile(entryPath(dataDir, journalRunId), 'utf8'));
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
118
+ const entry = parsed as Record<string, unknown>;
119
+ if (typeof entry['cloudRunId'] !== 'string' || !CLOUD_RUN_ID.test(entry['cloudRunId'])) return undefined;
120
+ if (entry['apiUrl'] !== apiUrl) return undefined;
121
+ return entry['cloudRunId'];
122
+ }
123
+
124
+ /** Drop entries past their age, then past the count. Never throws. */
125
+ async function prune(directory: string): Promise<void> {
126
+ try {
127
+ const names = (await readdir(directory)).filter(name => name.endsWith('.json'));
128
+ if (names.length <= LEDGER_MAX_ENTRIES) {
129
+ // Still age out: a data dir that never reaches the count cap would
130
+ // otherwise keep its first entry forever.
131
+ await dropOlderThan(directory, names, Date.now() - LEDGER_MAX_AGE_MS);
132
+ return;
133
+ }
134
+ const ages = await Promise.all(names.map(async name => {
135
+ try {
136
+ return { name, at: (await stat(join(directory, name))).mtimeMs };
137
+ } catch {
138
+ return { name, at: 0 };
139
+ }
140
+ }));
141
+ ages.sort((left, right) => right.at - left.at);
142
+ await Promise.all(ages.slice(LEDGER_MAX_ENTRIES).map(entry =>
143
+ unlink(join(directory, entry.name)).catch(() => undefined)));
144
+ await dropOlderThan(directory, ages.slice(0, LEDGER_MAX_ENTRIES).map(entry => entry.name),
145
+ Date.now() - LEDGER_MAX_AGE_MS);
146
+ } catch {
147
+ return;
148
+ }
149
+ }
150
+
151
+ async function dropOlderThan(directory: string, names: string[], floor: number): Promise<void> {
152
+ await Promise.all(names.map(async name => {
153
+ try {
154
+ if ((await stat(join(directory, name))).mtimeMs < floor) {
155
+ await unlink(join(directory, name));
156
+ }
157
+ } catch {
158
+ return;
159
+ }
160
+ }));
161
+ }