@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.
- package/dist/authored-preflight.d.ts +4 -0
- package/dist/authored-preflight.d.ts.map +1 -0
- package/dist/authored-preflight.js +50 -0
- package/dist/authored-preflight.js.map +1 -0
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +3 -2
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check.d.ts +10 -2
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +16 -137
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cli-probe.d.ts +20 -0
- package/dist/cli/cli-probe.d.ts.map +1 -0
- package/dist/cli/cli-probe.js +166 -0
- package/dist/cli/cli-probe.js.map +1 -0
- package/dist/cli/cloud-mirror-session.d.ts +97 -0
- package/dist/cli/cloud-mirror-session.d.ts.map +1 -0
- package/dist/cli/cloud-mirror-session.js +264 -0
- package/dist/cli/cloud-mirror-session.js.map +1 -0
- package/dist/cli-commands.d.ts +6 -0
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +1 -0
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +84 -18
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +1 -1
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +24 -8
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-mirror-ledger.d.ts +40 -0
- package/dist/cloud-mirror-ledger.d.ts.map +1 -0
- package/dist/cloud-mirror-ledger.js +142 -0
- package/dist/cloud-mirror-ledger.js.map +1 -0
- package/dist/cloud-mirror-step.d.ts +168 -0
- package/dist/cloud-mirror-step.d.ts.map +1 -0
- package/dist/cloud-mirror-step.js +504 -0
- package/dist/cloud-mirror-step.js.map +1 -0
- package/dist/cloud-mirror-transport.d.ts +94 -0
- package/dist/cloud-mirror-transport.d.ts.map +1 -0
- package/dist/cloud-mirror-transport.js +163 -0
- package/dist/cloud-mirror-transport.js.map +1 -0
- package/dist/cloud-mirror.d.ts +132 -0
- package/dist/cloud-mirror.d.ts.map +1 -0
- package/dist/cloud-mirror.js +556 -0
- package/dist/cloud-mirror.js.map +1 -0
- package/dist/hosted-extension-runtime.js +1 -1
- package/dist/package-version.d.ts +10 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +22 -0
- package/dist/package-version.js.map +1 -0
- package/dist/preflight.d.ts +14 -0
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +18 -3
- package/dist/preflight.js.map +1 -1
- package/dist/relay-cli.d.ts.map +1 -1
- package/dist/relay-cli.js +1 -21
- package/dist/relay-cli.js.map +1 -1
- package/package.json +2 -2
- package/src/authored-preflight.ts +46 -0
- package/src/authored-worker-step.ts +4 -3
- package/src/cli/check.ts +25 -163
- package/src/cli/cli-probe.ts +194 -0
- package/src/cli/cloud-mirror-session.ts +329 -0
- package/src/cli-commands.ts +1 -0
- package/src/cli.ts +94 -19
- package/src/cloud-deploy.ts +26 -8
- package/src/cloud-mirror-ledger.ts +161 -0
- package/src/cloud-mirror-step.ts +618 -0
- package/src/cloud-mirror-transport.ts +261 -0
- package/src/cloud-mirror.ts +633 -0
- package/src/hosted-extension-runtime.ts +1 -1
- package/src/package-version.ts +23 -0
- package/src/preflight.ts +31 -7
- 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]))
|
|
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) =>
|
|
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) =>
|
|
333
|
-
|
|
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,
|
|
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,
|
|
365
|
-
emitRunReport(execution, parsed.json,
|
|
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,
|
|
369
|
-
await finalizeObserverLine(observerMint,
|
|
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 ||
|
|
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
|
-
|
|
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
|
}
|
package/src/cloud-deploy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
+
}
|