@relayflows/sdk 2.0.18 → 2.0.20
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-flow-error.d.ts +6 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +8 -0
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/cli/answer.d.ts.map +1 -1
- package/dist/cli/answer.js +2 -1
- package/dist/cli/answer.js.map +1 -1
- package/dist/cli/build.d.ts +9 -0
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +25 -6
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/cloud-run.d.ts +7 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +12 -14
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/cloud-sync.d.ts +8 -1
- package/dist/cli/cloud-sync.d.ts.map +1 -1
- package/dist/cli/cloud-sync.js +79 -8
- package/dist/cli/cloud-sync.js.map +1 -1
- package/dist/cli/deploy.d.ts +6 -0
- package/dist/cli/deploy.d.ts.map +1 -1
- package/dist/cli/deploy.js +29 -4
- package/dist/cli/deploy.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +2 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/serve-webhook.d.ts +7 -1
- package/dist/cli/serve-webhook.d.ts.map +1 -1
- package/dist/cli/serve-webhook.js +19 -9
- package/dist/cli/serve-webhook.js.map +1 -1
- package/dist/cli-commands.d.ts +398 -0
- package/dist/cli-commands.d.ts.map +1 -0
- package/dist/cli-commands.js +254 -0
- package/dist/cli-commands.js.map +1 -0
- package/dist/cli-watch.d.ts +3 -1
- package/dist/cli-watch.d.ts.map +1 -1
- package/dist/cli-watch.js +4 -10
- package/dist/cli-watch.js.map +1 -1
- package/dist/cli.d.ts +127 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +81 -48
- package/dist/cli.js.map +1 -1
- package/dist/cloud-sync.d.ts +85 -2
- package/dist/cloud-sync.d.ts.map +1 -1
- package/dist/cloud-sync.js +123 -10
- package/dist/cloud-sync.js.map +1 -1
- package/dist/flow-requirements.d.ts +2 -2
- package/dist/flow-requirements.d.ts.map +1 -1
- package/dist/flow-requirements.js +182 -0
- package/dist/flow-requirements.js.map +1 -1
- package/dist/human-to.d.ts +45 -0
- package/dist/human-to.d.ts.map +1 -0
- package/dist/human-to.js +49 -0
- package/dist/human-to.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/relay-cli.d.ts +50 -0
- package/dist/relay-cli.d.ts.map +1 -0
- package/dist/relay-cli.js +64 -0
- package/dist/relay-cli.js.map +1 -0
- package/package.json +7 -2
- package/src/authored-flow-error.ts +5 -0
- package/src/authored-flow-executor.ts +8 -0
- package/src/cli/answer.ts +2 -1
- package/src/cli/build.ts +20 -6
- package/src/cli/cloud-run.ts +11 -12
- package/src/cli/cloud-sync.ts +85 -8
- package/src/cli/deploy.ts +27 -5
- package/src/cli/run.ts +2 -1
- package/src/cli/serve-webhook.ts +15 -8
- package/src/cli-commands.ts +339 -0
- package/src/cli-watch.ts +8 -9
- package/src/cli.ts +103 -44
- package/src/cloud-sync.ts +164 -11
- package/src/flow-requirements.ts +143 -2
- package/src/human-to.ts +71 -0
- package/src/index.ts +5 -2
- package/src/relay-cli.ts +117 -0
package/src/cli.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { parseBuildArgs, runBuild, type BuildArgs } from './cli/build.js';
|
|
|
36
36
|
import { runHnMonitor } from './cli/hn-monitor.js';
|
|
37
37
|
import { runTickRunner } from './cli/tick-runner.js';
|
|
38
38
|
import { DEFAULT_DATA_DIR } from './daemon-connection.js';
|
|
39
|
+
import { CLI_VERB_NAMES } from './cli-commands.js';
|
|
39
40
|
import {
|
|
40
41
|
mintObserverUrl,
|
|
41
42
|
resolveObserverLinkEnv,
|
|
@@ -50,14 +51,19 @@ export interface CliIo {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
type CliExitCode = 0 | 1 | 2 | 3;
|
|
53
|
-
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every shape `parseArgs` can produce. Exported for `cli-commands.ts`, whose
|
|
57
|
+
* table must claim each variant or fail to compile.
|
|
58
|
+
*/
|
|
59
|
+
export type ParsedArgs =
|
|
54
60
|
| { command: 'add'; value: string }
|
|
55
61
|
| ReplayArgs
|
|
56
62
|
| BuildArgs
|
|
57
63
|
| DeployArgs
|
|
58
64
|
| { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
|
|
59
65
|
| { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean }
|
|
60
|
-
| { command: 'sync'; runId: string; json: boolean; root: string }
|
|
66
|
+
| { command: 'sync'; runId: string; json: boolean; root: string; dryRun: boolean }
|
|
61
67
|
| CloudDeployArgs
|
|
62
68
|
| { command: 'deployments'; json: boolean }
|
|
63
69
|
| { command: 'undeploy'; agentId: string; json: boolean }
|
|
@@ -92,7 +98,7 @@ const USAGE = [
|
|
|
92
98
|
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] [--reuse-from <run-id>] <flow.yaml|spec.json>',
|
|
93
99
|
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
|
|
94
100
|
'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
|
|
95
|
-
'flows sync [--json] [--dir <path>] <run-id>',
|
|
101
|
+
'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
|
|
96
102
|
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
|
|
97
103
|
'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>',
|
|
98
104
|
'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
|
|
@@ -117,9 +123,48 @@ const PROCESS_IO: CliIo = {
|
|
|
117
123
|
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
118
124
|
};
|
|
119
125
|
|
|
126
|
+
/** Optional knobs for an embedded caller. `bin/flows.js` passes none. */
|
|
127
|
+
export interface RunCliOptions {
|
|
128
|
+
/**
|
|
129
|
+
* Cancellation for the long-running verbs (`run --cloud`, `check --watch`,
|
|
130
|
+
* `serve-webhook`, `hn-monitor start`, `tick start`).
|
|
131
|
+
*
|
|
132
|
+
* Supply one and `runCli` installs **no** process signal handlers -- required
|
|
133
|
+
* of a CLI surface mounted into another host, which owns SIGINT itself.
|
|
134
|
+
* Omit it and the standalone `flows` binary keeps today's behaviour exactly:
|
|
135
|
+
* SIGINT/SIGTERM are handled here, for the duration of that verb only.
|
|
136
|
+
*/
|
|
137
|
+
signal?: AbortSignal;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run one long-running verb under a cancellation signal.
|
|
142
|
+
*
|
|
143
|
+
* With a caller-supplied signal this installs nothing. Without one it owns
|
|
144
|
+
* SIGINT/SIGTERM for the duration of `body` and removes the handlers after --
|
|
145
|
+
* the pre-existing standalone behaviour, unchanged.
|
|
146
|
+
*/
|
|
147
|
+
async function withInterrupt<T>(
|
|
148
|
+
provided: AbortSignal | undefined,
|
|
149
|
+
body: (signal: AbortSignal) => Promise<T>,
|
|
150
|
+
): Promise<T> {
|
|
151
|
+
if (provided !== undefined) return body(provided);
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const onSignal = (): void => controller.abort();
|
|
154
|
+
process.once('SIGINT', onSignal);
|
|
155
|
+
process.once('SIGTERM', onSignal);
|
|
156
|
+
try {
|
|
157
|
+
return await body(controller.signal);
|
|
158
|
+
} finally {
|
|
159
|
+
process.off('SIGINT', onSignal);
|
|
160
|
+
process.off('SIGTERM', onSignal);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
120
164
|
export async function runCli(
|
|
121
165
|
args: readonly string[],
|
|
122
166
|
io: CliIo = PROCESS_IO,
|
|
167
|
+
options: RunCliOptions = {},
|
|
123
168
|
): Promise<CliExitCode> {
|
|
124
169
|
if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
|
|
125
170
|
io.stdout(USAGE);
|
|
@@ -135,9 +180,13 @@ export async function runCli(
|
|
|
135
180
|
|
|
136
181
|
if (parsed.command === 'add') return addPlugin(parsed.value, io);
|
|
137
182
|
|
|
138
|
-
if (parsed.command === 'serve-webhook')
|
|
183
|
+
if (parsed.command === 'serve-webhook') {
|
|
184
|
+
return withInterrupt(options.signal, (signal) => runServeWebhook(parsed, io, signal));
|
|
185
|
+
}
|
|
139
186
|
|
|
140
|
-
if (parsed.command === 'cloud-run')
|
|
187
|
+
if (parsed.command === 'cloud-run') {
|
|
188
|
+
return withInterrupt(options.signal, (signal) => runCloudCli(parsed, io, signal));
|
|
189
|
+
}
|
|
141
190
|
if (parsed.command === 'sync') return runCloudSyncCli(parsed, io);
|
|
142
191
|
if (parsed.command === 'cloud-deploy') return runCloudDeployCli(parsed, io);
|
|
143
192
|
if (parsed.command === 'deployments') return runCloudDeploymentsCli(parsed, io);
|
|
@@ -159,7 +208,9 @@ export async function runCli(
|
|
|
159
208
|
if (parsed.command === 'deploy') return runDeploy(parsed, io);
|
|
160
209
|
|
|
161
210
|
if (parsed.command === 'check') {
|
|
162
|
-
if (parsed.watch)
|
|
211
|
+
if (parsed.watch) {
|
|
212
|
+
return withInterrupt(options.signal, (signal) => watchCheck(parsed.value, parsed.json, io, signal));
|
|
213
|
+
}
|
|
163
214
|
// Deliberately daemon-free (kernel/DAEMON-LIFECYCLE.md §4). `checkFlow` is
|
|
164
215
|
// a compile-and-preflight that opens no daemon socket, and the parser
|
|
165
216
|
// refuses `--data-dir` on `check`, so there is no data dir to attach to.
|
|
@@ -174,45 +225,27 @@ export async function runCli(
|
|
|
174
225
|
if (parsed.command === 'observer') return runObserverCommand(io);
|
|
175
226
|
|
|
176
227
|
if (parsed.command === 'hn-monitor') {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
dataDir: parsed.dataDir,
|
|
184
|
-
specPath: parsed.specPath,
|
|
185
|
-
pollIntervalMs: parsed.pollIntervalMs,
|
|
186
|
-
signal: controller.signal,
|
|
187
|
-
}, io);
|
|
188
|
-
} finally {
|
|
189
|
-
process.off('SIGINT', onSignal);
|
|
190
|
-
process.off('SIGTERM', onSignal);
|
|
191
|
-
}
|
|
228
|
+
return withInterrupt(options.signal, (signal) => runHnMonitor({
|
|
229
|
+
dataDir: parsed.dataDir,
|
|
230
|
+
specPath: parsed.specPath,
|
|
231
|
+
pollIntervalMs: parsed.pollIntervalMs,
|
|
232
|
+
signal,
|
|
233
|
+
}, io));
|
|
192
234
|
}
|
|
193
235
|
|
|
194
236
|
if (parsed.command === 'tick') {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
...(parsed.maxCatchUp === undefined ? {} : { maxCatchUp: parsed.maxCatchUp }),
|
|
208
|
-
},
|
|
209
|
-
pollIntervalMs: parsed.pollIntervalMs,
|
|
210
|
-
signal: controller.signal,
|
|
211
|
-
}, io) as CliExitCode;
|
|
212
|
-
} finally {
|
|
213
|
-
process.off('SIGINT', onSignal);
|
|
214
|
-
process.off('SIGTERM', onSignal);
|
|
215
|
-
}
|
|
237
|
+
return withInterrupt(options.signal, async (signal) => await runTickRunner({
|
|
238
|
+
dataDir: parsed.dataDir,
|
|
239
|
+
specPath: parsed.specPath,
|
|
240
|
+
schedule: {
|
|
241
|
+
scheduleId: parsed.scheduleId,
|
|
242
|
+
intervalMs: parsed.intervalMs,
|
|
243
|
+
...(parsed.epochMs === undefined ? {} : { epochMs: parsed.epochMs }),
|
|
244
|
+
...(parsed.maxCatchUp === undefined ? {} : { maxCatchUp: parsed.maxCatchUp }),
|
|
245
|
+
},
|
|
246
|
+
pollIntervalMs: parsed.pollIntervalMs,
|
|
247
|
+
signal,
|
|
248
|
+
}, io) as CliExitCode);
|
|
216
249
|
}
|
|
217
250
|
|
|
218
251
|
// Attach-or-spawn runs inside `runFlow`/`resumeFlow`/`runDirectFlow`, at the
|
|
@@ -458,6 +491,11 @@ function emitWait(
|
|
|
458
491
|
|
|
459
492
|
function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
460
493
|
const command = args[0];
|
|
494
|
+
// The verb set lives in exactly one place -- `CLI_VERBS` in cli-commands.ts --
|
|
495
|
+
// which is also what `createRelayCliSurface` projects into `commands`. Gating
|
|
496
|
+
// dispatch on it means a token the surface does not declare can never reach a
|
|
497
|
+
// parser, so the declared tree and the dispatched tree cannot drift apart.
|
|
498
|
+
if (command === undefined || !CLI_VERB_NAMES.has(command)) return undefined;
|
|
461
499
|
if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
|
|
462
500
|
if (command === 'replay') return parseReplayArgs(args.slice(1));
|
|
463
501
|
if (command === 'build') return parseBuildArgs(args.slice(1));
|
|
@@ -718,9 +756,13 @@ function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
|
|
|
718
756
|
* directory at all -- the mint is a pure Relaycast API round-trip. No
|
|
719
757
|
* positional argument, no other flags.
|
|
720
758
|
*/
|
|
721
|
-
/**
|
|
759
|
+
/**
|
|
760
|
+
* `flows sync [--json] [--dry-run] [--dir <path>] <run-id>`: apply a hosted
|
|
761
|
+
* run's patch to a local tree, or with `--dry-run` print it and apply nothing.
|
|
762
|
+
*/
|
|
722
763
|
function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
723
764
|
let json = false;
|
|
765
|
+
let dryRun = false;
|
|
724
766
|
let root: string | undefined;
|
|
725
767
|
const positionals: string[] = [];
|
|
726
768
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -730,6 +772,11 @@ function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
730
772
|
json = true;
|
|
731
773
|
continue;
|
|
732
774
|
}
|
|
775
|
+
if (argument === '--dry-run') {
|
|
776
|
+
if (dryRun) return undefined;
|
|
777
|
+
dryRun = true;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
733
780
|
if (argument === '--dir') {
|
|
734
781
|
const value = args[index + 1];
|
|
735
782
|
if (root !== undefined || value === undefined || value.startsWith('-')) return undefined;
|
|
@@ -741,7 +788,7 @@ function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
741
788
|
positionals.push(argument);
|
|
742
789
|
}
|
|
743
790
|
if (positionals.length !== 1) return undefined;
|
|
744
|
-
return { command: 'sync', runId: positionals[0]!, json, root: root ?? '.' };
|
|
791
|
+
return { command: 'sync', runId: positionals[0]!, json, dryRun, root: root ?? '.' };
|
|
745
792
|
}
|
|
746
793
|
|
|
747
794
|
function parseObserverArgs(rest: readonly string[]): ParsedArgs | undefined {
|
|
@@ -956,3 +1003,15 @@ if (isDirectInvocation(process.argv[1])) {
|
|
|
956
1003
|
process.exitCode = exitCode;
|
|
957
1004
|
});
|
|
958
1005
|
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* The argv parser, exported for the CLI-surface drift test.
|
|
1009
|
+
*
|
|
1010
|
+
* The drift test must prove that every command `cli-commands.ts` declares
|
|
1011
|
+
* actually routes to a `ParsedArgs` variant, and that every variant is
|
|
1012
|
+
* reachable from some declared command. Observing that through `runCli` would
|
|
1013
|
+
* mean executing the commands. Not part of the package's public API --
|
|
1014
|
+
* `@relayflows/sdk/cli` exports `runCli`, and `@relayflows/sdk/relay-cli`
|
|
1015
|
+
* exports the surface.
|
|
1016
|
+
*/
|
|
1017
|
+
export { parseArgs as parseCliArgs };
|
package/src/cloud-sync.ts
CHANGED
|
@@ -272,37 +272,188 @@ export interface CloudPatch {
|
|
|
272
272
|
hasChanges: boolean;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
/**
|
|
276
|
-
export
|
|
275
|
+
/** One entry of a multi-path run's patch map: the mounted path's name and its diff. */
|
|
276
|
+
export interface CloudPathPatch extends CloudPatch {
|
|
277
|
+
name: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* What `/patch` answered, in both shapes the endpoint can produce.
|
|
282
|
+
*
|
|
283
|
+
* A run that declared mounted `paths` gets one `changes-<name>.patch` per path
|
|
284
|
+
* and the route answers `{ patches: { <name>: { patch, hasChanges } } }`; every
|
|
285
|
+
* other run gets `changes.patch` and the flat `{ patch, hasChanges }`. The
|
|
286
|
+
* multi-path shape is not a v1 relic -- `paths` is orthogonal to
|
|
287
|
+
* `relayflowVersion`, so a v2 run that submits several paths returns it too.
|
|
288
|
+
*/
|
|
289
|
+
export type CloudPatchSet =
|
|
290
|
+
| { kind: 'single'; patch: string; hasChanges: boolean }
|
|
291
|
+
| { kind: 'multi-path'; patches: readonly CloudPathPatch[]; hasChanges: boolean };
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The sandbox's post-run diff, in whichever shape the run produced.
|
|
295
|
+
*
|
|
296
|
+
* Both shapes are modelled rather than one refused at the transport, so a
|
|
297
|
+
* caller can show a multi-path run's patches (`flows sync --dry-run`) before
|
|
298
|
+
* deciding what to do with them. Applying them is still the caller's refusal
|
|
299
|
+
* to make: they target different repositories and no single tree is the right
|
|
300
|
+
* destination.
|
|
301
|
+
*/
|
|
302
|
+
export async function downloadCloudPatchSet(
|
|
303
|
+
runId: string, options: CloudConnectionOptions,
|
|
304
|
+
): Promise<CloudPatchSet> {
|
|
277
305
|
const payload = await cloudRequest(`/api/v1/workflows/runs/${encodeURIComponent(cloudRunId(runId))}/patch`, options);
|
|
278
306
|
if (!isCloudRecord(payload)) throw new CloudFlowError('invalid_response', 'Cloud patch response was not an object.');
|
|
279
307
|
if (isCloudRecord(payload.patches)) {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
308
|
+
const patches: CloudPathPatch[] = [];
|
|
309
|
+
for (const [name, entry] of Object.entries(payload.patches)) {
|
|
310
|
+
if (!isCloudRecord(entry) || typeof entry.patch !== 'string' || typeof entry.hasChanges !== 'boolean') {
|
|
311
|
+
throw new CloudFlowError('invalid_response', `Cloud patch response has an unusable entry for path "${name}".`);
|
|
312
|
+
}
|
|
313
|
+
patches.push({ name, patch: entry.patch, hasChanges: entry.hasChanges });
|
|
314
|
+
}
|
|
315
|
+
return { kind: 'multi-path', patches, hasChanges: patches.some(entry => entry.hasChanges && entry.patch.trim() !== '') };
|
|
283
316
|
}
|
|
284
317
|
if (typeof payload.patch !== 'string' || typeof payload.hasChanges !== 'boolean') {
|
|
285
318
|
throw new CloudFlowError('invalid_response', 'Cloud patch response is missing patch or hasChanges.');
|
|
286
319
|
}
|
|
287
|
-
return { patch: payload.patch, hasChanges: payload.hasChanges };
|
|
320
|
+
return { kind: 'single', patch: payload.patch, hasChanges: payload.hasChanges };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The sandbox's post-run diff. Multi-path runs carry several patches and are refused here. */
|
|
324
|
+
export async function downloadCloudPatch(runId: string, options: CloudConnectionOptions): Promise<CloudPatch> {
|
|
325
|
+
const set = await downloadCloudPatchSet(runId, options);
|
|
326
|
+
if (set.kind === 'multi-path') {
|
|
327
|
+
const names = set.patches.map(entry => entry.name);
|
|
328
|
+
throw new CloudFlowError('sync_unsupported',
|
|
329
|
+
`Run ${runId} produced ${names.length} path-scoped patches (${names.join(', ')}); flows sync applies single-tree runs only.`);
|
|
330
|
+
}
|
|
331
|
+
return { patch: set.patch, hasChanges: set.hasChanges };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Paths a synced patch must never write, the single home for the list.
|
|
336
|
+
*
|
|
337
|
+
* These are the agent runtime's own bookkeeping inside a synced tree: helper
|
|
338
|
+
* binaries staged for the sandbox, the relayfile mount's ACL and state files
|
|
339
|
+
* (including the temporaries a mid-write state leaves behind), trajectory
|
|
340
|
+
* records and workflow context. The sandbox commits its baseline before the
|
|
341
|
+
* run, so every one of them shows up in the post-run diff as a creation or a
|
|
342
|
+
* modification -- applying that diff verbatim drags the run's own plumbing into
|
|
343
|
+
* the user's checkout, where at best it is noise in `git diff` and at worst it
|
|
344
|
+
* overwrites the mount state of the tree being synced into.
|
|
345
|
+
*
|
|
346
|
+
* `git apply --exclude` matches these with wildmatch, anchored at the patch
|
|
347
|
+
* root and with `*` stopping at a `/`: `.agent-bin/**` drops
|
|
348
|
+
* `.agent-bin/nested/tool` but deliberately not `packages/x/.agent-bin/tool`,
|
|
349
|
+
* which belongs to a different tree than the one being synced.
|
|
350
|
+
*/
|
|
351
|
+
export const CLOUD_SYNC_PATCH_EXCLUDES = [
|
|
352
|
+
'.agent-bin/**',
|
|
353
|
+
'.relayfile.acl',
|
|
354
|
+
'.relayfile-mount-state.json',
|
|
355
|
+
'.relayfile-mount-state.json.tmp-*',
|
|
356
|
+
'.trajectories/**',
|
|
357
|
+
'.workflow-context/**',
|
|
358
|
+
] as const;
|
|
359
|
+
|
|
360
|
+
/** The `a/` and `b/` sides of every `diff --git` header, in file order. */
|
|
361
|
+
function patchHeaders(patch: string): { old: string; new: string }[] {
|
|
362
|
+
const headers: { old: string; new: string }[] = [];
|
|
363
|
+
for (const match of patch.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gmu)) {
|
|
364
|
+
headers.push({ old: match[1]!, new: match[2]! });
|
|
365
|
+
}
|
|
366
|
+
return headers;
|
|
288
367
|
}
|
|
289
368
|
|
|
290
369
|
/** Every path a unified diff touches, deletions included, in order of first appearance. */
|
|
291
370
|
export function patchedPaths(patch: string): string[] {
|
|
292
371
|
const paths: string[] = [];
|
|
293
|
-
for (const
|
|
294
|
-
for (const path of [
|
|
372
|
+
for (const header of patchHeaders(patch)) {
|
|
373
|
+
for (const path of [header.old, header.new]) if (!paths.includes(path)) paths.push(path);
|
|
295
374
|
}
|
|
296
375
|
return paths;
|
|
297
376
|
}
|
|
298
377
|
|
|
378
|
+
/**
|
|
379
|
+
* `git apply --exclude`'s wildmatch, as a matcher over a patch's own paths.
|
|
380
|
+
*
|
|
381
|
+
* Anchored at the patch root, `**` crosses `/` and `*`/`?` do not -- the subset
|
|
382
|
+
* of wildmatch {@link CLOUD_SYNC_PATCH_EXCLUDES} uses. Kept honest by a test
|
|
383
|
+
* that runs the same patterns through `git apply --numstat` and requires the
|
|
384
|
+
* two answers to agree, so a divergence fails here rather than silently
|
|
385
|
+
* reporting a path as dropped that git actually wrote.
|
|
386
|
+
*/
|
|
387
|
+
function matchesExclude(path: string, pattern: string): boolean {
|
|
388
|
+
let expression = '^';
|
|
389
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
390
|
+
const character = pattern[index]!;
|
|
391
|
+
if (character === '*') {
|
|
392
|
+
if (pattern[index + 1] === '*') { expression += '.*'; index += 1; continue; }
|
|
393
|
+
expression += '[^/]*';
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
expression += character === '?' ? '[^/]' : character.replace(/[.+^${}()|[\]\\]/gu, '\\$&');
|
|
397
|
+
}
|
|
398
|
+
return new RegExp(`${expression}$`, 'u').test(path);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The subset of a patch's paths `exclude` drops, in order of first appearance.
|
|
403
|
+
*
|
|
404
|
+
* Decided per `diff --git` header on its `b/` side, which is the name `git
|
|
405
|
+
* apply` itself tests -- so a rename is dropped or kept whole, never half. A
|
|
406
|
+
* deletion names the same path on both sides, so it is covered by the same rule.
|
|
407
|
+
*/
|
|
408
|
+
export function excludedPatchPaths(
|
|
409
|
+
patch: string, exclude: readonly string[] = CLOUD_SYNC_PATCH_EXCLUDES,
|
|
410
|
+
): string[] {
|
|
411
|
+
const paths: string[] = [];
|
|
412
|
+
for (const header of patchHeaders(patch)) {
|
|
413
|
+
if (!exclude.some(pattern => matchesExclude(header.new, pattern))) continue;
|
|
414
|
+
for (const path of [header.old, header.new]) if (!paths.includes(path)) paths.push(path);
|
|
415
|
+
}
|
|
416
|
+
return paths;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Options for {@link applyCloudPatch}. */
|
|
420
|
+
export interface ApplyCloudPatchOptions {
|
|
421
|
+
/**
|
|
422
|
+
* Path patterns to drop, defaulting to {@link CLOUD_SYNC_PATCH_EXCLUDES}.
|
|
423
|
+
* Pass `[]` to apply a patch whole -- including the runtime artifacts the
|
|
424
|
+
* default list exists to keep out of a working tree.
|
|
425
|
+
*/
|
|
426
|
+
exclude?: readonly string[];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** What {@link applyCloudPatch} wrote, and what it dropped on the way. */
|
|
430
|
+
export interface AppliedCloudPatch {
|
|
431
|
+
/** Paths the apply wrote, in order of first appearance in the patch. */
|
|
432
|
+
files: string[];
|
|
433
|
+
/** Paths `exclude` dropped, in order of first appearance in the patch. */
|
|
434
|
+
excluded: string[];
|
|
435
|
+
}
|
|
436
|
+
|
|
299
437
|
/**
|
|
300
438
|
* `git apply --check` then `git apply`; a conflict leaves the tree untouched.
|
|
301
439
|
* The patch lands in the working tree uncommitted, so what the run changed is
|
|
302
|
-
* reviewed with `git diff` before anything is kept
|
|
440
|
+
* reviewed with `git diff` before anything is kept -- the same contract as v1.
|
|
441
|
+
*
|
|
442
|
+
* Both invocations carry the identical `--exclude` arguments. A check run
|
|
443
|
+
* without them is a different question than the apply answers: it can pass on
|
|
444
|
+
* an excluded hunk that the apply then never writes, or fail on one and refuse
|
|
445
|
+
* a patch whose applied part was clean. The exclusions are a property of the
|
|
446
|
+
* patch that lands, so they belong to both halves or neither.
|
|
447
|
+
*
|
|
448
|
+
* A patch whose every path is excluded is a no-op, not a failure: `git apply`
|
|
449
|
+
* exits 0 having written nothing, and the returned `files` is empty.
|
|
303
450
|
*/
|
|
304
|
-
export function applyCloudPatch(
|
|
305
|
-
|
|
451
|
+
export function applyCloudPatch(
|
|
452
|
+
root: string, patch: string, options: ApplyCloudPatchOptions = {},
|
|
453
|
+
): AppliedCloudPatch {
|
|
454
|
+
const exclude = options.exclude ?? CLOUD_SYNC_PATCH_EXCLUDES;
|
|
455
|
+
const args = ['-C', resolve(root), 'apply', '--whitespace=nowarn',
|
|
456
|
+
...exclude.map(pattern => `--exclude=${pattern}`)];
|
|
306
457
|
const check = spawnSync('git', [...args, '--check'], { input: patch, encoding: 'utf8' });
|
|
307
458
|
if (check.status !== 0) {
|
|
308
459
|
throw new CloudFlowError('patch_conflict',
|
|
@@ -312,4 +463,6 @@ export function applyCloudPatch(root: string, patch: string): void {
|
|
|
312
463
|
if (apply.status !== 0) {
|
|
313
464
|
throw new CloudFlowError('patch_conflict', `git apply failed:\n${apply.stderr.trim()}`);
|
|
314
465
|
}
|
|
466
|
+
const excluded = excludedPatchPaths(patch, exclude);
|
|
467
|
+
return { files: patchedPaths(patch).filter(path => !excluded.includes(path)), excluded };
|
|
315
468
|
}
|
package/src/flow-requirements.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { humanRecipientProvider } from './human-to.js';
|
|
1
2
|
import { helperProviders } from '@relayflows/surface/runtime';
|
|
2
3
|
import type { TriggerSource } from '@relayflows/surface';
|
|
3
4
|
import { providerDeclaration } from './provider-trigger-contract.js';
|
|
@@ -26,8 +27,8 @@ export interface FlowIntegrationRequirement {
|
|
|
26
27
|
/** Cloud integration provider id (`slack`, `github`, `linear`, …). */
|
|
27
28
|
provider: string;
|
|
28
29
|
/** `tools`: a header declaration; `source`: a trigger or deploy target; `helper`: body use without a flag, or a YAML helper step. */
|
|
29
|
-
from: 'tools' | 'source' | 'helper';
|
|
30
|
-
/** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`. */
|
|
30
|
+
from: 'tools' | 'source' | 'helper' | 'human';
|
|
31
|
+
/** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`, `f.human to`. */
|
|
31
32
|
detail: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -119,6 +120,14 @@ export function flowRequirements(
|
|
|
119
120
|
if (helperReference(root, namespace).test(text)) declare({ provider, from: 'helper', detail: `f.${namespace}` });
|
|
120
121
|
}
|
|
121
122
|
for (const use of workerCalls(root, text)) need(use.cli === undefined ? fallback : harnessFromCli(use.cli), use.detail);
|
|
123
|
+
// `f.human(q, { to: "slack:#eng" })` is delivered by Cloud through that
|
|
124
|
+
// provider, so the deploy must have it connected. Only a literal `to`
|
|
125
|
+
// can be read here; a computed one (`input.approver`) is resolved by
|
|
126
|
+
// Cloud at park time against the run's own trigger channel.
|
|
127
|
+
for (const to of humanRecipients(root, text)) {
|
|
128
|
+
const provider = humanRecipientProvider(to);
|
|
129
|
+
if (provider !== undefined) declare({ provider, from: 'human', detail: 'f.human to' });
|
|
130
|
+
}
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
|
|
@@ -180,6 +189,138 @@ function helperReference(root: string, namespace: string): RegExp {
|
|
|
180
189
|
return new RegExp(`(?:^|[^\\w$.])${root}\\s*(?:\\.\\s*${namespace}\\b|\\[\\s*['"]${namespace}['"]\\s*\\])`, 'u');
|
|
181
190
|
}
|
|
182
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The literal `to` of each `f.human(question, { to: "…" })` call in the body.
|
|
194
|
+
*
|
|
195
|
+
* Bounded to the call's OWN argument list — the text between its `(` and the
|
|
196
|
+
* matching `)`, string- and nesting-aware — and within that to the top level
|
|
197
|
+
* of its options object, so a `to:` in a later call, in a nested object, in
|
|
198
|
+
* the question string, or in an unrelated `{ to }` of the surrounding code is
|
|
199
|
+
* never read as this call's recipient. Only a plain string literal counts; a
|
|
200
|
+
* template with interpolation or an identifier is a computed `to`, resolved
|
|
201
|
+
* by Cloud at park time.
|
|
202
|
+
*/
|
|
203
|
+
function humanRecipients(root: string, body: string): string[] {
|
|
204
|
+
const call = new RegExp(`(?:^|[^\\w$.])${root}\\s*\\.\\s*human\\s*\\(`, 'gu');
|
|
205
|
+
const found: string[] = [];
|
|
206
|
+
for (const match of body.matchAll(call)) {
|
|
207
|
+
const open = match.index! + match[0].length - 1;
|
|
208
|
+
const close = matchingClose(body, open);
|
|
209
|
+
if (close === -1) continue;
|
|
210
|
+
const args = body.slice(open + 1, close);
|
|
211
|
+
const options = secondArgumentObject(args);
|
|
212
|
+
if (options === undefined) continue;
|
|
213
|
+
const to = topLevelStringProperty(options, 'to');
|
|
214
|
+
if (to !== undefined) found.push(to);
|
|
215
|
+
}
|
|
216
|
+
return found;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Skip the comment or string starting at `i`, returning the index just past
|
|
221
|
+
* it; `i` itself when nothing skippable starts there; -1 when unterminated.
|
|
222
|
+
* Every walker below steps through this, so a `,`, `to:` or bracket inside a
|
|
223
|
+
* comment or string is never read as syntax.
|
|
224
|
+
*/
|
|
225
|
+
function skipCommentOrString(text: string, i: number): number {
|
|
226
|
+
const ch = text[i]!;
|
|
227
|
+
const next = text[i + 1];
|
|
228
|
+
if (ch === '/' && next === '/') { const end = text.indexOf('\n', i); return end === -1 ? text.length : end + 1; }
|
|
229
|
+
if (ch === '/' && next === '*') { const end = text.indexOf('*/', i + 2); return end === -1 ? -1 : end + 2; }
|
|
230
|
+
if (ch === '"' || ch === "'" || ch === '`') { const end = stringEnd(text, i); return end === -1 ? -1 : end + 1; }
|
|
231
|
+
return i;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Index of the `)`/`}`/`]` closing the bracket at `open`, skipping strings, templates and comments; -1 if unbalanced. */
|
|
235
|
+
function matchingClose(text: string, open: number): number {
|
|
236
|
+
const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' };
|
|
237
|
+
const stack: string[] = [pairs[text[open]!]!];
|
|
238
|
+
let i = open + 1;
|
|
239
|
+
while (i < text.length && stack.length > 0) {
|
|
240
|
+
const skipped = skipCommentOrString(text, i);
|
|
241
|
+
if (skipped === -1) return -1;
|
|
242
|
+
if (skipped !== i) { i = skipped; continue; }
|
|
243
|
+
const ch = text[i]!;
|
|
244
|
+
if (ch in pairs) stack.push(pairs[ch]!);
|
|
245
|
+
else if (ch === ')' || ch === '}' || ch === ']') { if (stack.pop() !== ch) return -1; }
|
|
246
|
+
i += 1;
|
|
247
|
+
}
|
|
248
|
+
return stack.length === 0 ? i - 1 : -1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Index of the quote closing the string opening at `start` (template `${…}` skipped); -1 if unterminated. */
|
|
252
|
+
function stringEnd(text: string, start: number): number {
|
|
253
|
+
const quote = text[start]!;
|
|
254
|
+
let i = start + 1;
|
|
255
|
+
while (i < text.length) {
|
|
256
|
+
const ch = text[i]!;
|
|
257
|
+
if (ch === '\\') { i += 2; continue; }
|
|
258
|
+
if (ch === quote) return i;
|
|
259
|
+
if (quote === '`' && ch === '$' && text[i + 1] === '{') {
|
|
260
|
+
const end = matchingClose(text, i + 1);
|
|
261
|
+
if (end === -1) return -1;
|
|
262
|
+
i = end + 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
i += 1;
|
|
266
|
+
}
|
|
267
|
+
return -1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The `{ … }` that is the call's second top-level argument, or undefined. */
|
|
271
|
+
function secondArgumentObject(args: string): string | undefined {
|
|
272
|
+
let depth = 0;
|
|
273
|
+
let i = 0;
|
|
274
|
+
let commas = 0;
|
|
275
|
+
while (i < args.length) {
|
|
276
|
+
const skipped = skipCommentOrString(args, i);
|
|
277
|
+
if (skipped === -1) return undefined;
|
|
278
|
+
if (skipped !== i) { i = skipped; continue; }
|
|
279
|
+
const ch = args[i]!;
|
|
280
|
+
if (ch === '(' || ch === '{' || ch === '[') {
|
|
281
|
+
if (depth === 0 && commas === 1 && ch === '{') {
|
|
282
|
+
const close = matchingClose(args, i);
|
|
283
|
+
return close === -1 ? undefined : args.slice(i, close + 1);
|
|
284
|
+
}
|
|
285
|
+
depth += 1;
|
|
286
|
+
} else if (ch === ')' || ch === '}' || ch === ']') depth -= 1;
|
|
287
|
+
else if (ch === ',' && depth === 0) commas += 1;
|
|
288
|
+
i += 1;
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The plain string literal value of `name:` at the top level of an object literal, else undefined. */
|
|
294
|
+
function topLevelStringProperty(object: string, name: string): string | undefined {
|
|
295
|
+
const key = new RegExp(`^(?:${name}|'${name}'|"${name}")\\s*:\\s*`, 'u');
|
|
296
|
+
let depth = 0;
|
|
297
|
+
let i = 1; // past the opening brace
|
|
298
|
+
const end = object.length - 1;
|
|
299
|
+
while (i < end) {
|
|
300
|
+
const skipped = skipCommentOrString(object, i);
|
|
301
|
+
if (skipped === -1) return undefined;
|
|
302
|
+
if (skipped !== i) { i = skipped; continue; }
|
|
303
|
+
const ch = object[i]!;
|
|
304
|
+
if (ch === '(' || ch === '{' || ch === '[') { depth += 1; i += 1; continue; }
|
|
305
|
+
if (ch === ')' || ch === '}' || ch === ']') { depth -= 1; i += 1; continue; }
|
|
306
|
+
if (depth === 0 && (i === 1 || /[\s,{/]/u.test(object[i - 1]!))) {
|
|
307
|
+
const match = key.exec(object.slice(i));
|
|
308
|
+
if (match !== null) {
|
|
309
|
+
const valueStart = i + match[0].length;
|
|
310
|
+
const quote = object[valueStart];
|
|
311
|
+
if (quote !== '"' && quote !== "'" && quote !== '`') return undefined;
|
|
312
|
+
const valueEnd = stringEnd(object, valueStart);
|
|
313
|
+
if (valueEnd === -1) return undefined;
|
|
314
|
+
const raw = object.slice(valueStart + 1, valueEnd);
|
|
315
|
+
// An interpolated template is computed; a plain one is a literal.
|
|
316
|
+
return quote === '`' && /\$\{/u.test(raw) ? undefined : raw.replace(/\\(.)/gu, '$1');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
i += 1;
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
183
324
|
interface WorkerCall { detail: string; cli?: string }
|
|
184
325
|
|
|
185
326
|
/**
|