@relayflows/sdk 2.0.17 → 2.0.19

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 (161) hide show
  1. package/dist/authored-flow-error.d.ts +21 -1
  2. package/dist/authored-flow-error.d.ts.map +1 -1
  3. package/dist/authored-flow-error.js +14 -0
  4. package/dist/authored-flow-error.js.map +1 -1
  5. package/dist/authored-flow-executor.d.ts.map +1 -1
  6. package/dist/authored-flow-executor.js +47 -3
  7. package/dist/authored-flow-executor.js.map +1 -1
  8. package/dist/authored-human.d.ts +49 -0
  9. package/dist/authored-human.d.ts.map +1 -0
  10. package/dist/authored-human.js +97 -0
  11. package/dist/authored-human.js.map +1 -0
  12. package/dist/authored-node-entry.js +3 -2
  13. package/dist/authored-node-entry.js.map +1 -1
  14. package/dist/authored-node-runner.d.ts.map +1 -1
  15. package/dist/authored-node-runner.js +15 -4
  16. package/dist/authored-node-runner.js.map +1 -1
  17. package/dist/authored-root.d.ts.map +1 -1
  18. package/dist/authored-root.js +29 -1
  19. package/dist/authored-root.js.map +1 -1
  20. package/dist/cli/answer.d.ts +17 -0
  21. package/dist/cli/answer.d.ts.map +1 -0
  22. package/dist/cli/answer.js +89 -0
  23. package/dist/cli/answer.js.map +1 -0
  24. package/dist/cli/build.d.ts +9 -0
  25. package/dist/cli/build.d.ts.map +1 -1
  26. package/dist/cli/build.js +26 -7
  27. package/dist/cli/build.js.map +1 -1
  28. package/dist/cli/check-triggers.d.ts.map +1 -1
  29. package/dist/cli/check-triggers.js +2 -0
  30. package/dist/cli/check-triggers.js.map +1 -1
  31. package/dist/cli/check.d.ts +11 -0
  32. package/dist/cli/check.d.ts.map +1 -1
  33. package/dist/cli/check.js +14 -2
  34. package/dist/cli/check.js.map +1 -1
  35. package/dist/cli/cloud-connect-cli.d.ts +54 -0
  36. package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
  37. package/dist/cli/cloud-connect-cli.js +109 -0
  38. package/dist/cli/cloud-connect-cli.js.map +1 -0
  39. package/dist/cli/cloud-deploy.d.ts +3 -1
  40. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  41. package/dist/cli/cloud-deploy.js +44 -6
  42. package/dist/cli/cloud-deploy.js.map +1 -1
  43. package/dist/cli/cloud-run.d.ts +9 -2
  44. package/dist/cli/cloud-run.d.ts.map +1 -1
  45. package/dist/cli/cloud-run.js +27 -15
  46. package/dist/cli/cloud-run.js.map +1 -1
  47. package/dist/cli/cloud-schedule.d.ts +3 -1
  48. package/dist/cli/cloud-schedule.d.ts.map +1 -1
  49. package/dist/cli/cloud-schedule.js +31 -6
  50. package/dist/cli/cloud-schedule.js.map +1 -1
  51. package/dist/cli/cloud-sync.d.ts +8 -1
  52. package/dist/cli/cloud-sync.d.ts.map +1 -1
  53. package/dist/cli/cloud-sync.js +79 -8
  54. package/dist/cli/cloud-sync.js.map +1 -1
  55. package/dist/cli/deploy.d.ts +6 -0
  56. package/dist/cli/deploy.d.ts.map +1 -1
  57. package/dist/cli/deploy.js +29 -4
  58. package/dist/cli/deploy.js.map +1 -1
  59. package/dist/cli/direct-run.d.ts.map +1 -1
  60. package/dist/cli/direct-run.js +5 -1
  61. package/dist/cli/direct-run.js.map +1 -1
  62. package/dist/cli/run.d.ts +22 -2
  63. package/dist/cli/run.d.ts.map +1 -1
  64. package/dist/cli/run.js +34 -1
  65. package/dist/cli/run.js.map +1 -1
  66. package/dist/cli/serve-webhook.d.ts +7 -1
  67. package/dist/cli/serve-webhook.d.ts.map +1 -1
  68. package/dist/cli/serve-webhook.js +19 -9
  69. package/dist/cli/serve-webhook.js.map +1 -1
  70. package/dist/cli-commands.d.ts +398 -0
  71. package/dist/cli-commands.d.ts.map +1 -0
  72. package/dist/cli-commands.js +254 -0
  73. package/dist/cli-commands.js.map +1 -0
  74. package/dist/cli-watch.d.ts +3 -1
  75. package/dist/cli-watch.d.ts.map +1 -1
  76. package/dist/cli-watch.js +4 -10
  77. package/dist/cli-watch.js.map +1 -1
  78. package/dist/cli.d.ts +127 -1
  79. package/dist/cli.d.ts.map +1 -1
  80. package/dist/cli.js +195 -57
  81. package/dist/cli.js.map +1 -1
  82. package/dist/cloud-connect.d.ts +52 -0
  83. package/dist/cloud-connect.d.ts.map +1 -0
  84. package/dist/cloud-connect.js +138 -0
  85. package/dist/cloud-connect.js.map +1 -0
  86. package/dist/cloud-deploy.d.ts +19 -1
  87. package/dist/cloud-deploy.d.ts.map +1 -1
  88. package/dist/cloud-deploy.js +40 -1
  89. package/dist/cloud-deploy.js.map +1 -1
  90. package/dist/cloud-http.d.ts +2 -2
  91. package/dist/cloud-http.d.ts.map +1 -1
  92. package/dist/cloud-http.js.map +1 -1
  93. package/dist/cloud-schedule.d.ts +7 -0
  94. package/dist/cloud-schedule.d.ts.map +1 -1
  95. package/dist/cloud-schedule.js +22 -1
  96. package/dist/cloud-schedule.js.map +1 -1
  97. package/dist/cloud-sync.d.ts +85 -2
  98. package/dist/cloud-sync.d.ts.map +1 -1
  99. package/dist/cloud-sync.js +123 -10
  100. package/dist/cloud-sync.js.map +1 -1
  101. package/dist/failure-kinds.d.ts +1 -1
  102. package/dist/failure-kinds.d.ts.map +1 -1
  103. package/dist/failure-kinds.js +2 -0
  104. package/dist/failure-kinds.js.map +1 -1
  105. package/dist/flow-requirements.d.ts +68 -0
  106. package/dist/flow-requirements.d.ts.map +1 -0
  107. package/dist/flow-requirements.js +152 -0
  108. package/dist/flow-requirements.js.map +1 -0
  109. package/dist/index.d.ts +3 -1
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +3 -1
  112. package/dist/index.js.map +1 -1
  113. package/dist/journal-client.d.ts +11 -0
  114. package/dist/journal-client.d.ts.map +1 -1
  115. package/dist/journal-client.js +13 -0
  116. package/dist/journal-client.js.map +1 -1
  117. package/dist/progress.d.ts +2 -1
  118. package/dist/progress.d.ts.map +1 -1
  119. package/dist/progress.js +9 -3
  120. package/dist/progress.js.map +1 -1
  121. package/dist/protocol.d.ts +25 -1
  122. package/dist/protocol.d.ts.map +1 -1
  123. package/dist/relay-cli.d.ts +50 -0
  124. package/dist/relay-cli.d.ts.map +1 -0
  125. package/dist/relay-cli.js +64 -0
  126. package/dist/relay-cli.js.map +1 -0
  127. package/package.json +7 -2
  128. package/src/authored-flow-error.ts +29 -0
  129. package/src/authored-flow-executor.ts +54 -2
  130. package/src/authored-human.ts +150 -0
  131. package/src/authored-node-entry.ts +3 -2
  132. package/src/authored-node-runner.ts +20 -5
  133. package/src/authored-root.ts +28 -1
  134. package/src/cli/answer.ts +98 -0
  135. package/src/cli/build.ts +21 -7
  136. package/src/cli/check-triggers.ts +2 -0
  137. package/src/cli/check.ts +24 -2
  138. package/src/cli/cloud-connect-cli.ts +130 -0
  139. package/src/cli/cloud-deploy.ts +40 -6
  140. package/src/cli/cloud-run.ts +26 -15
  141. package/src/cli/cloud-schedule.ts +31 -6
  142. package/src/cli/cloud-sync.ts +85 -8
  143. package/src/cli/deploy.ts +27 -5
  144. package/src/cli/direct-run.ts +5 -0
  145. package/src/cli/run.ts +48 -2
  146. package/src/cli/serve-webhook.ts +15 -8
  147. package/src/cli-commands.ts +339 -0
  148. package/src/cli-watch.ts +8 -9
  149. package/src/cli.ts +206 -54
  150. package/src/cloud-connect.ts +187 -0
  151. package/src/cloud-deploy.ts +57 -2
  152. package/src/cloud-http.ts +1 -1
  153. package/src/cloud-schedule.ts +20 -3
  154. package/src/cloud-sync.ts +164 -11
  155. package/src/failure-kinds.ts +2 -0
  156. package/src/flow-requirements.ts +201 -0
  157. package/src/index.ts +12 -2
  158. package/src/journal-client.ts +20 -0
  159. package/src/progress.ts +11 -4
  160. package/src/protocol.ts +23 -0
  161. package/src/relay-cli.ts +117 -0
@@ -1,9 +1,13 @@
1
1
  import { randomBytes, createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
3
4
  import { loadAuthoredFlow } from './authored-flow-loader.js';
5
+ import { ensureIntegrationsConnected, type ConnectPrompt } from './cloud-connect.js';
4
6
  import {
5
7
  CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions,
6
8
  } from './cloud-http.js';
9
+ import { flowRequirements, type FlowRequirements } from './flow-requirements.js';
10
+ import { readProjectConfig } from './cli/check.js';
7
11
 
8
12
  /**
9
13
  * Hosted listener deployment: the CLI form of the agentrelay.com onboarding's
@@ -47,10 +51,22 @@ export interface DeployToCloudInput {
47
51
  approver: string;
48
52
  /** Defaults to the flow's declared name. */
49
53
  name?: string;
50
- /** Coding-agent harnesses the flow uses; Cloud checks their credentials are connected. Default `["claude"]`. */
54
+ /**
55
+ * Coding-agent harnesses the flow uses; Cloud checks their credentials are
56
+ * connected. Defaults to the `cli:` declarations the source carries
57
+ * (`flowRequirements`), else `["claude"]`.
58
+ */
51
59
  agents?: FlowAgentHarness[];
52
60
  /** Save without activating: no connection checks, no listener until activated. */
53
61
  draft?: boolean;
62
+ /**
63
+ * How a missing integration is connected before activation. Omitted, a
64
+ * missing one refuses with `integration_not_connected` (the `--no-connect`
65
+ * and non-interactive behaviour); given, each is offered a browser connect.
66
+ */
67
+ connect?: ConnectPrompt;
68
+ /** Skip the pre-submission integration check entirely (Cloud still checks on activation). */
69
+ checkConnections?: boolean;
54
70
  }
55
71
 
56
72
  export const FLOW_AGENT_HARNESSES = ['claude', 'codex'] as const;
@@ -72,6 +88,10 @@ export interface CloudDeployment {
72
88
  repository: { owner: string; name: string };
73
89
  sources: FlowTriggerSource[];
74
90
  sourceSha256: string;
91
+ /** What the source declared it needs; the harnesses became `inputs.agents` unless `agents` was given. */
92
+ requirements: FlowRequirements;
93
+ /** Integrations connected through the prompt during this deploy. */
94
+ connected: string[];
75
95
  }
76
96
 
77
97
  export function parseRepository(value: string): { owner: string; name: string } {
@@ -154,6 +174,12 @@ export async function deployToCloud(
154
174
  throw new CloudFlowError('unsupported_source',
155
175
  'Cloud deploys one self-contained .flow.ts source without use dependencies.');
156
176
  }
177
+ let projectCli: string | undefined;
178
+ try {
179
+ projectCli = readProjectConfig(dirname(resolve(input.path))).cli;
180
+ } catch {
181
+ projectCli = undefined;
182
+ }
157
183
  if (input.sources.length === 0 || input.sources.length > 10) {
158
184
  throw new CloudFlowError('invalid_input', 'Give between one and ten --on trigger sources.');
159
185
  }
@@ -176,7 +202,30 @@ export async function deployToCloud(
176
202
  if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) {
177
203
  throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.');
178
204
  }
179
- const agents = input.agents ?? ['claude'];
205
+ // Every launched run lands in the deployment's repository, so GitHub is
206
+ // required even when no GitHub source wakes it.
207
+ const requirements = flowRequirements(definition, {
208
+ sources, repository: input.repository, ...(projectCli === undefined ? {} : { projectCli }),
209
+ });
210
+ // The declared harnesses become `inputs.agents`; one Cloud cannot run is
211
+ // refused here rather than silently replaced by Claude, which activation
212
+ // would then check while the deployed runs still call the declared CLI.
213
+ const unsupported = requirements.harnesses.filter(harness => !(FLOW_AGENT_HARNESSES as readonly string[]).includes(harness));
214
+ if (input.agents === undefined && unsupported.length > 0) {
215
+ const uses = requirements.harnessUses.filter(use => unsupported.includes(use.harness));
216
+ throw new CloudFlowError('unsupported_source',
217
+ `This flow declares ${uses.map(use => `${use.harness} (${use.detail})`).join(', ')}, which Cloud deployments cannot run yet; `
218
+ + `Cloud runs ${FLOW_AGENT_HARNESSES.join(' and ')}. Change the declaration, or pass --agents to deploy it anyway.`);
219
+ }
220
+ const declaredAgents = requirements.harnesses.filter((harness): harness is FlowAgentHarness => !unsupported.includes(harness));
221
+ const agents = input.agents ?? (declaredAgents.length > 0 ? declaredAgents : ['claude']);
222
+ // A draft activates nothing, so Cloud checks nothing; match it here.
223
+ const connected = input.draft || input.checkConnections === false
224
+ ? []
225
+ : (await ensureIntegrationsConnected(requirements, {
226
+ ...options, workspaceId: workspace.id, ...(input.connect === undefined ? {} : { prompt: input.connect }),
227
+ })).connected;
228
+ options.signal?.throwIfAborted();
180
229
  const result = await cloudFetch('/api/v1/flows/deploy', options, { method: 'POST', detail: true, body: JSON.stringify({
181
230
  workspaceId: workspace.id,
182
231
  mode: input.draft ? 'draft' : 'activate',
@@ -188,6 +237,11 @@ export async function deployToCloud(
188
237
  inputs: { approver, agents },
189
238
  repository: input.repository,
190
239
  sources,
240
+ requirements: {
241
+ integrations: requirements.integrations.map(i => i.provider),
242
+ harnesses: requirements.harnesses,
243
+ mcp: requirements.mcp,
244
+ },
191
245
  }) });
192
246
  if (!isCloudRecord(result) || typeof result.agentId !== 'string' || typeof result.status !== 'string') {
193
247
  throw new CloudFlowError('invalid_response', 'Cloud did not return a deployment.');
@@ -196,6 +250,7 @@ export async function deployToCloud(
196
250
  agentId: result.agentId, name, status: result.status,
197
251
  repository: input.repository, sources,
198
252
  sourceSha256: createHash('sha256').update(bytes).digest('hex'),
253
+ requirements, connected,
199
254
  };
200
255
  }
201
256
 
package/src/cloud-http.ts CHANGED
@@ -25,7 +25,7 @@ export class CloudFlowError extends Error {
25
25
  constructor(
26
26
  readonly code: 'configuration' | 'unsupported_source' | 'invalid_input' | 'invalid_response' | 'http_error'
27
27
  | 'transport_error' | 'transient_error' | 'unsupported_storage_backend' | 'sync_too_large' | 'sync_unsupported'
28
- | 'patch_conflict',
28
+ | 'patch_conflict' | 'integration_not_connected',
29
29
  message: string,
30
30
  readonly status?: number,
31
31
  readonly refusal?: CloudRefusal,
@@ -69,12 +69,29 @@ function assertTimeZone(tz: string): string {
69
69
  return tz;
70
70
  }
71
71
 
72
- export async function scheduleInCloud(
73
- input: ScheduleInCloudInput, options: CloudConnectionOptions = {},
74
- ): Promise<CloudSchedule> {
72
+ /**
73
+ * The argument checks `scheduleInCloud` makes before it reads the flow, so a
74
+ * caller can refuse a bad cron, interval or zone before any side effect (the
75
+ * CLI connects integrations in between). Declared-schedule resolution needs
76
+ * the flow and stays in `scheduleInCloud`.
77
+ */
78
+ export function validateScheduleArgs(input: Pick<ScheduleInCloudInput, 'cron' | 'every' | 'tz'>): void {
75
79
  if (input.cron !== undefined && input.every !== undefined) {
76
80
  throw new CloudFlowError('invalid_input', 'Give --cron or --every, not both.');
77
81
  }
82
+ if (input.cron !== undefined) {
83
+ try { parseCron(input.cron); } catch (error) {
84
+ throw new CloudFlowError('invalid_input', `--cron: ${error instanceof Error ? error.message : String(error)}`);
85
+ }
86
+ }
87
+ if (input.every !== undefined) everyToCron(input.every);
88
+ if (input.tz !== undefined) assertTimeZone(input.tz);
89
+ }
90
+
91
+ export async function scheduleInCloud(
92
+ input: ScheduleInCloudInput, options: CloudConnectionOptions = {},
93
+ ): Promise<CloudSchedule> {
94
+ validateScheduleArgs(input);
78
95
  const submission = await prepareCloudSubmission(input.flow, {
79
96
  ...(Object.prototype.hasOwnProperty.call(input, 'input') ? { input: input.input } : {}),
80
97
  ...(options.signal === undefined ? {} : { signal: options.signal }),
package/src/cloud-sync.ts CHANGED
@@ -272,37 +272,188 @@ export interface CloudPatch {
272
272
  hasChanges: boolean;
273
273
  }
274
274
 
275
- /** The sandbox's post-run diff. Multi-path runs carry several patches and are refused here. */
276
- export async function downloadCloudPatch(runId: string, options: CloudConnectionOptions): Promise<CloudPatch> {
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 names = Object.keys(payload.patches);
281
- throw new CloudFlowError('sync_unsupported',
282
- `Run ${runId} produced ${names.length} path-scoped patches (${names.join(', ')}); flows sync applies single-tree runs only.`);
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 match of patch.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gmu)) {
294
- for (const path of [match[1]!, match[2]!]) if (!paths.includes(path)) paths.push(path);
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 the same contract as v1.
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(root: string, patch: string): void {
305
- const args = ['-C', resolve(root), 'apply', '--whitespace=nowarn'];
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
  }
@@ -113,6 +113,8 @@ export const RUN_FAILURE_KINDS = [
113
113
  'run_unavailable',
114
114
  /** A predicate `.gate(fn)` judged false; the verdict is journaled as `<step>.gate`. */
115
115
  'gate_failed',
116
+ /** `flows answer` named a wait the run is not asking: unknown, or already answered. */
117
+ 'human_wait_unknown',
116
118
  ] as const;
117
119
 
118
120
  /**
@@ -0,0 +1,201 @@
1
+ import { helperProviders } from '@relayflows/surface/runtime';
2
+ import type { TriggerSource } from '@relayflows/surface';
3
+ import { providerDeclaration } from './provider-trigger-contract.js';
4
+ import type { FlowSpec } from './spec.js';
5
+ import { helperCall } from './yaml-helpers.js';
6
+
7
+ /**
8
+ * What a flow needs from the workspace it deploys into, read from inert
9
+ * declarations only: the header, the trigger sources, and the text of the
10
+ * body. Nothing here executes a body or opens a socket, so the same answer is
11
+ * available to `flows check`, to the hosted verbs before they submit, and to
12
+ * Cloud's deploy wizard from the source alone.
13
+ *
14
+ * Helper use is recognised exactly as `preflightHelpers` recognises it — a
15
+ * `tools.<namespace>: true` flag or a `f.<namespace>` reference in the body —
16
+ * so a flow that preflight would refuse for a missing mount is a flow whose
17
+ * integration this module names. Coding-agent harnesses come from the `cli:`
18
+ * each `f.agent`/`f.llm` call declares, else the project default, else Cloud's
19
+ * default (`claude`): the same precedence `resolveCli` applies at run time.
20
+ */
21
+
22
+ export const FLOW_HARNESSES = ['claude', 'codex', 'gemini'] as const;
23
+ export type FlowHarness = (typeof FLOW_HARNESSES)[number];
24
+
25
+ export interface FlowIntegrationRequirement {
26
+ /** Cloud integration provider id (`slack`, `github`, `linear`, …). */
27
+ provider: string;
28
+ /** `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`. */
31
+ detail: string;
32
+ }
33
+
34
+ export interface FlowHarnessRequirement {
35
+ harness: FlowHarness;
36
+ /** `agent "review"`, `llm step`, `step "draft"`, or `default`. */
37
+ detail: string;
38
+ }
39
+
40
+ export interface FlowRequirements {
41
+ integrations: FlowIntegrationRequirement[];
42
+ harnesses: FlowHarness[];
43
+ /** One entry per harness, naming the first declaration that needs it. */
44
+ harnessUses: FlowHarnessRequirement[];
45
+ /** MCP servers `tools.mcp` names; the workspace must declare each in `flows.json`. */
46
+ mcp: string[];
47
+ }
48
+
49
+ export interface FlowRequirementsContext {
50
+ /** Trigger sources the deployment listens on (`--on`, or the wizard's chosen sources). */
51
+ sources?: readonly { provider: string }[];
52
+ /** Set when the deployment targets a repository: every launched run needs GitHub. */
53
+ repository?: boolean | { owner: string; name: string };
54
+ /** The nearest `flows.json` `cli`, when one applies. */
55
+ projectCli?: string;
56
+ }
57
+
58
+ /** The inert subset of an authored definition this module reads. */
59
+ export interface RequirementsFlowDefinition {
60
+ readonly header?: { readonly tools?: Readonly<Record<string, unknown>> };
61
+ readonly body?: Function;
62
+ readonly handlers?: readonly { readonly trigger: TriggerSource }[];
63
+ }
64
+
65
+ export function flowRequirements(
66
+ flow: RequirementsFlowDefinition | FlowSpec,
67
+ context: FlowRequirementsContext = {},
68
+ ): FlowRequirements {
69
+ const integrations = new Map<string, FlowIntegrationRequirement>();
70
+ const harnessUses = new Map<FlowHarness, FlowHarnessRequirement>();
71
+ const mcp = new Set<string>();
72
+ const declare = (requirement: FlowIntegrationRequirement): void => {
73
+ if (!integrations.has(requirement.provider)) integrations.set(requirement.provider, requirement);
74
+ };
75
+ const need = (harness: FlowHarness | undefined, detail: string): void => {
76
+ if (harness !== undefined && !harnessUses.has(harness)) harnessUses.set(harness, { harness, detail });
77
+ };
78
+ const fallback = harnessFromCli(context.projectCli) ?? 'claude';
79
+
80
+ if (isCompiledSpec(flow)) {
81
+ for (const step of flow.steps) {
82
+ // Preflight owns shape refusals; a malformed step is simply not a worker step here.
83
+ if (typeof step !== 'object' || step === null || (step.type !== 'llm' && step.type !== 'agent')) continue;
84
+ // A YAML helper step (`slack: { post: … }`) compiles to an agent step
85
+ // carrying a helper envelope: it needs the provider's mount, not a harness.
86
+ const helper = step.type === 'agent' ? compiledHelper(step) : undefined;
87
+ if (helper !== undefined) {
88
+ declare({ provider: helper, from: 'helper', detail: `step "${step.id}"` });
89
+ continue;
90
+ }
91
+ const named = step.type === 'agent' && step.agent !== undefined ? flow.agents?.[step.agent]?.cli : undefined;
92
+ const cli = step.cli ?? named ?? flow.cli;
93
+ need(cli === undefined ? fallback : harnessFromCli(cli), `step "${step.id}"`);
94
+ }
95
+ } else {
96
+ const tools = flow.header?.tools ?? {};
97
+ for (const { provider, namespace } of helperProviders) {
98
+ if (tools[namespace] === true) declare({ provider, from: 'tools', detail: `tools.${namespace}` });
99
+ }
100
+ for (const entry of stringList(tools['relayfile'])) {
101
+ const provider = entry.split('/')[0]?.trim();
102
+ if (provider) declare({ provider, from: 'tools', detail: 'tools.relayfile' });
103
+ }
104
+ for (const server of stringList(tools['mcp'])) mcp.add(server);
105
+ for (const handler of flow.handlers ?? []) {
106
+ const declaration = providerDeclaration(handler.trigger);
107
+ if (declaration !== undefined) {
108
+ declare({ provider: declaration.provider, from: 'source', detail: `on ${declaration.provider} ${declaration.type}` });
109
+ }
110
+ }
111
+ // Only the default body is scanned for helper and worker calls: hosted
112
+ // deployments and schedules dispatch the default body with the trigger's
113
+ // payload as input, and handler bodies are not dispatched yet (flows #301).
114
+ // A handler's *trigger* is still a requirement — it is what wakes the flow.
115
+ const text = typeof flow.body === 'function' ? Function.prototype.toString.call(flow.body) : '';
116
+ const root = contextParameter(text);
117
+ if (root !== undefined) {
118
+ for (const { provider, namespace } of helperProviders) {
119
+ if (helperReference(root, namespace).test(text)) declare({ provider, from: 'helper', detail: `f.${namespace}` });
120
+ }
121
+ for (const use of workerCalls(root, text)) need(use.cli === undefined ? fallback : harnessFromCli(use.cli), use.detail);
122
+ }
123
+ }
124
+
125
+ for (const source of context.sources ?? []) {
126
+ declare({ provider: source.provider, from: 'source', detail: `--on ${source.provider}` });
127
+ }
128
+ if (context.repository) declare({ provider: 'github', from: 'source', detail: 'deploy target' });
129
+
130
+ const uses = [...harnessUses.values()];
131
+ return {
132
+ integrations: [...integrations.values()],
133
+ harnesses: uses.map(use => use.harness),
134
+ harnessUses: uses,
135
+ mcp: [...mcp],
136
+ };
137
+ }
138
+
139
+ /** `slack (tools.slack), github (deploy target), claude (agent "review")` — the `REQUIRES` line's body. */
140
+ export function describeFlowRequirements(requirements: FlowRequirements): string {
141
+ return [
142
+ ...requirements.integrations.map(integration => `${integration.provider} (${integration.detail})`),
143
+ ...requirements.harnessUses.map(use => `${use.harness} (${use.detail})`),
144
+ ...requirements.mcp.map(server => `mcp ${server} (tools.mcp)`),
145
+ ].join(', ');
146
+ }
147
+
148
+ /** `claude`, `/opt/bin/codex`, `gemini.exe` → the Cloud harness; anything else is not one. */
149
+ export function harnessFromCli(cli: string | undefined): FlowHarness | undefined {
150
+ if (cli === undefined) return undefined;
151
+ const base = cli.trim().split(/[\\/]/u).pop()?.replace(/\.(?:exe|cmd|bat)$/iu, '').toLowerCase();
152
+ return (FLOW_HARNESSES as readonly string[]).includes(base ?? '') ? base as FlowHarness : undefined;
153
+ }
154
+
155
+ /** The provider of a compiled YAML helper step, or undefined for an ordinary agent step or a malformed envelope. */
156
+ function compiledHelper(step: Parameters<typeof helperCall>[0]): string | undefined {
157
+ try {
158
+ return helperCall(step)?.provider;
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ function isCompiledSpec(flow: RequirementsFlowDefinition | FlowSpec): flow is FlowSpec {
165
+ return Array.isArray((flow as FlowSpec).steps) && typeof (flow as FlowSpec).version === 'string';
166
+ }
167
+
168
+ function stringList(value: unknown): string[] {
169
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
170
+ }
171
+
172
+ /** The body's first parameter (`f` in `async (f, input) => …`), escaped for a pattern. */
173
+ function contextParameter(body: string): string | undefined {
174
+ const parameter = body.match(/^(?:async\s+)?(?:function(?:\s+[\w$]+)?\s*)?(?:\(\s*([\w$]+)|([\w$]+)\s*=>)/u);
175
+ return (parameter?.[1] ?? parameter?.[2])?.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
176
+ }
177
+
178
+ /** Same recognition as `preflightHelpers`: `f.slack`, `f .slack`, `f["slack"]`. */
179
+ function helperReference(root: string, namespace: string): RegExp {
180
+ return new RegExp(`(?:^|[^\\w$.])${root}\\s*(?:\\.\\s*${namespace}\\b|\\[\\s*['"]${namespace}['"]\\s*\\])`, 'u');
181
+ }
182
+
183
+ interface WorkerCall { detail: string; cli?: string }
184
+
185
+ /**
186
+ * Each `f.agent(name, { cli })` / `f.llm(prompt, { cli })` call and the CLI it
187
+ * declares. The options object is read only up to the next worker call, so a
188
+ * `cli:` belongs to the call it follows; a call without one takes the default.
189
+ */
190
+ function workerCalls(root: string, body: string): WorkerCall[] {
191
+ const call = new RegExp(`(?:^|[^\\w$.])${root}\\s*\\.\\s*(agent|llm)\\s*\\(\\s*(?:(['"\`])([^'"\`]*)\\2)?`, 'gu');
192
+ const starts = [...body.matchAll(call)];
193
+ return starts.map((match, index) => {
194
+ const slice = body.slice(match.index! + match[0].length, starts[index + 1]?.index ?? body.length);
195
+ const cli = slice.match(/(?:^|[^\w$])cli\s*:\s*(['"`])([^'"`]*)\1/u)?.[2];
196
+ const detail = match[1] === 'agent'
197
+ ? (match[3] ? `agent ${JSON.stringify(match[3])}` : 'agent step')
198
+ : 'llm step';
199
+ return { detail, ...(cli === undefined ? {} : { cli }) };
200
+ });
201
+ }
package/src/index.ts CHANGED
@@ -64,8 +64,10 @@ export {
64
64
  type CloudFlowSource, type RunInCloudOptions, type CloudRunReceipt, type CloudRunState,
65
65
  } from './cloud-run.js';
66
66
  export {
67
- downloadCloudPatch, applyCloudPatch, packWorkingTree, patchedPaths, MAX_SYNC_BYTES,
68
- type CloudPatch, type PackedTree,
67
+ downloadCloudPatch, downloadCloudPatchSet, applyCloudPatch, packWorkingTree, patchedPaths,
68
+ excludedPatchPaths, CLOUD_SYNC_PATCH_EXCLUDES, MAX_SYNC_BYTES,
69
+ type CloudPatch, type CloudPathPatch, type CloudPatchSet, type PackedTree,
70
+ type ApplyCloudPatchOptions, type AppliedCloudPatch,
69
71
  } from './cloud-sync.js';
70
72
  export {
71
73
  scheduleInCloud, listCloudSchedules, unscheduleInCloud, everyToCron, declaredScheduleCron,
@@ -76,6 +78,14 @@ export {
76
78
  deployToCloud, listCloudDeployments, undeployFromCloud, parseRepository, parseTriggerSource, FLOW_TRIGGER_PROVIDERS,
77
79
  type DeployToCloudInput, type CloudDeployment, type CloudDeploymentSummary, type FlowTriggerSource, type FlowTriggerProvider,
78
80
  } from './cloud-deploy.js';
81
+ export {
82
+ ensureIntegrationsConnected, integrationConnected, providerLabel,
83
+ type ConnectPrompt, type EnsureConnectionsOptions, type ConnectionsOutcome,
84
+ } from './cloud-connect.js';
85
+ export {
86
+ flowRequirements, describeFlowRequirements, harnessFromCli, FLOW_HARNESSES,
87
+ type FlowRequirements, type FlowRequirementsContext, type FlowIntegrationRequirement, type FlowHarnessRequirement, type FlowHarness,
88
+ } from './flow-requirements.js';
79
89
 
80
90
  export { canonicalize, specHash } from './canonical.js';
81
91
  export {
@@ -386,6 +386,26 @@ export class JournalClient extends EventEmitter {
386
386
  }
387
387
 
388
388
  /** Satisfy `wait.event`; a human response arrives here too. */
389
+ /**
390
+ * Park the dispatched attempt this connection holds on a human question.
391
+ * Releases the lease; the caller must not heartbeat or complete afterwards.
392
+ */
393
+ stepWait(
394
+ runId: string,
395
+ stepId: string,
396
+ attempt: number,
397
+ idempotencyKey: string,
398
+ wait: { wait_id: string; prompt: string; requested_of: string; options?: string[]; timeout_at_ms?: number },
399
+ ): Promise<VerbContract['step.wait']['result']> {
400
+ return this.request('step.wait', {
401
+ run_id: runId,
402
+ step_id: stepId,
403
+ attempt,
404
+ idempotency_key: idempotencyKey,
405
+ ...wait,
406
+ });
407
+ }
408
+
389
409
  eventEmit(runId: string, eventKey: string, payload: unknown): Promise<VerbContract['event.emit']['result']> {
390
410
  return this.request('event.emit', { run_id: runId, event_key: eventKey, payload });
391
411
  }