@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
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { addPlugin } from './cli/add.js';
3
3
  import { watchCheck } from './cli-watch.js';
4
4
  import { checkHelperBody } from './cli/check-helper-body.js';
5
+ import { describeFlowRequirements } from './flow-requirements.js';
5
6
 
6
7
  import { renderProgress, type ProgressEvent } from './progress.js';
7
8
  import { realpathSync } from 'node:fs';
@@ -18,6 +19,7 @@ import {
18
19
  type RunProgress,
19
20
  type RunReport,
20
21
  } from './cli/run.js';
22
+ import { answerFlow } from './cli/answer.js';
21
23
  import { checkAuthoredTriggers } from './cli/check-triggers.js';
22
24
  import { parseWebhookArgs, runServeWebhook } from './cli/serve-webhook.js';
23
25
  import { runDirectFlow } from './cli/direct-run.js';
@@ -34,6 +36,7 @@ import { parseBuildArgs, runBuild, type BuildArgs } from './cli/build.js';
34
36
  import { runHnMonitor } from './cli/hn-monitor.js';
35
37
  import { runTickRunner } from './cli/tick-runner.js';
36
38
  import { DEFAULT_DATA_DIR } from './daemon-connection.js';
39
+ import { CLI_VERB_NAMES } from './cli-commands.js';
37
40
  import {
38
41
  mintObserverUrl,
39
42
  resolveObserverLinkEnv,
@@ -48,14 +51,19 @@ export interface CliIo {
48
51
  }
49
52
 
50
53
  type CliExitCode = 0 | 1 | 2 | 3;
51
- type ParsedArgs =
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 =
52
60
  | { command: 'add'; value: string }
53
61
  | ReplayArgs
54
62
  | BuildArgs
55
63
  | DeployArgs
56
64
  | { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
57
- | { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean }
58
- | { command: 'sync'; runId: string; json: boolean; root: string }
65
+ | { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean }
66
+ | { command: 'sync'; runId: string; json: boolean; root: string; dryRun: boolean }
59
67
  | CloudDeployArgs
60
68
  | { command: 'deployments'; json: boolean }
61
69
  | { command: 'undeploy'; agentId: string; json: boolean }
@@ -65,6 +73,7 @@ type ParsedArgs =
65
73
  | { command: 'check'; json: boolean; watch: boolean; value: string }
66
74
  | { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
67
75
  | { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
76
+ | { command: 'answer'; dataDir: string; json: boolean; spawn: boolean; note: string | undefined; by: string | undefined; runId: string; waitId: string; answer: boolean }
68
77
  | { command: 'observer'; dataDir: string }
69
78
  | { command: 'hn-monitor'; sub: 'start'; dataDir: string; specPath: string; pollIntervalMs: number | undefined }
70
79
  | { command: 'tick'; sub: 'start'; dataDir: string; specPath: string; scheduleId: string;
@@ -76,10 +85,10 @@ const USAGE = [
76
85
  'flows add <helper-name|@flows/helper-name>',
77
86
  'flows build [--out <dir>] <flow.yaml|flow.ts>',
78
87
  'flows build --verify <bundle-dir>',
79
- 'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--json]',
88
+ 'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--no-connect] [--json]',
80
89
  'flows deployments [--json]',
81
90
  'flows undeploy [--json] <deployment-id>',
82
- 'flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>] [--tz <IANA>] [--input <inline-json-or-file>] [--name <name>] [--json]',
91
+ 'flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>] [--tz <IANA>] [--input <inline-json-or-file>] [--name <name>] [--no-connect] [--json]',
83
92
  'flows schedules [--json]',
84
93
  'flows unschedule [--json] <schedule-id>',
85
94
  'flows deploy <flow>@sha256:<digest> --to <file-bucket-uri>',
@@ -87,12 +96,13 @@ const USAGE = [
87
96
  'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
88
97
  'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
89
98
  'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] [--reuse-from <run-id>] <flow.yaml|spec.json>',
90
- 'flows run --cloud [--json] [--wait] [--sync-code] <flow.yaml|spec.json>',
91
- 'flows run --cloud [--json] [--wait] [--sync-code] <flow.ts> --input <inline-json-or-file>',
92
- 'flows sync [--json] [--dir <path>] <run-id>',
99
+ 'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
100
+ 'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
101
+ 'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
93
102
  'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
94
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>',
95
104
  'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
105
+ 'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
96
106
  'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
97
107
  'flows observer [--data-dir <dir>]',
98
108
  'flows hn-monitor start [--data-dir <dir>] [--poll-interval-ms <n>] <spec.json>',
@@ -113,9 +123,48 @@ const PROCESS_IO: CliIo = {
113
123
  stderr: (line) => process.stderr.write(`${line}\n`),
114
124
  };
115
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
+
116
164
  export async function runCli(
117
165
  args: readonly string[],
118
166
  io: CliIo = PROCESS_IO,
167
+ options: RunCliOptions = {},
119
168
  ): Promise<CliExitCode> {
120
169
  if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
121
170
  io.stdout(USAGE);
@@ -131,9 +180,13 @@ export async function runCli(
131
180
 
132
181
  if (parsed.command === 'add') return addPlugin(parsed.value, io);
133
182
 
134
- if (parsed.command === 'serve-webhook') return runServeWebhook(parsed, io);
183
+ if (parsed.command === 'serve-webhook') {
184
+ return withInterrupt(options.signal, (signal) => runServeWebhook(parsed, io, signal));
185
+ }
135
186
 
136
- if (parsed.command === 'cloud-run') return runCloudCli(parsed, io);
187
+ if (parsed.command === 'cloud-run') {
188
+ return withInterrupt(options.signal, (signal) => runCloudCli(parsed, io, signal));
189
+ }
137
190
  if (parsed.command === 'sync') return runCloudSyncCli(parsed, io);
138
191
  if (parsed.command === 'cloud-deploy') return runCloudDeployCli(parsed, io);
139
192
  if (parsed.command === 'deployments') return runCloudDeploymentsCli(parsed, io);
@@ -142,11 +195,22 @@ export async function runCli(
142
195
  if (parsed.command === 'schedules') return runCloudSchedulesCli(parsed, io);
143
196
  if (parsed.command === 'unschedule') return runCloudUnscheduleCli(parsed, io);
144
197
  if (parsed.command === 'replay') return replayJournal(parsed, io);
198
+ if (parsed.command === 'answer') {
199
+ const execution = await answerFlow(parsed.runId, parsed.waitId, parsed.answer, parsed.dataDir, {
200
+ ...(parsed.note === undefined ? {} : { note: parsed.note }),
201
+ ...(parsed.by === undefined ? {} : { answeredBy: parsed.by }),
202
+ daemon: { spawn: parsed.spawn && spawnAllowedByEnv() },
203
+ });
204
+ emitRunReport(execution, parsed.json, io);
205
+ return execution.exitCode;
206
+ }
145
207
  if (parsed.command === 'build') return runBuild(parsed, io);
146
208
  if (parsed.command === 'deploy') return runDeploy(parsed, io);
147
209
 
148
210
  if (parsed.command === 'check') {
149
- if (parsed.watch) return watchCheck(parsed.value, parsed.json, io);
211
+ if (parsed.watch) {
212
+ return withInterrupt(options.signal, (signal) => watchCheck(parsed.value, parsed.json, io, signal));
213
+ }
150
214
  // Deliberately daemon-free (kernel/DAEMON-LIFECYCLE.md §4). `checkFlow` is
151
215
  // a compile-and-preflight that opens no daemon socket, and the parser
152
216
  // refuses `--data-dir` on `check`, so there is no data dir to attach to.
@@ -161,45 +225,27 @@ export async function runCli(
161
225
  if (parsed.command === 'observer') return runObserverCommand(io);
162
226
 
163
227
  if (parsed.command === 'hn-monitor') {
164
- const controller = new AbortController();
165
- const onSignal = (): void => controller.abort();
166
- process.once('SIGINT', onSignal);
167
- process.once('SIGTERM', onSignal);
168
- try {
169
- return await runHnMonitor({
170
- dataDir: parsed.dataDir,
171
- specPath: parsed.specPath,
172
- pollIntervalMs: parsed.pollIntervalMs,
173
- signal: controller.signal,
174
- }, io);
175
- } finally {
176
- process.off('SIGINT', onSignal);
177
- process.off('SIGTERM', onSignal);
178
- }
228
+ return withInterrupt(options.signal, (signal) => runHnMonitor({
229
+ dataDir: parsed.dataDir,
230
+ specPath: parsed.specPath,
231
+ pollIntervalMs: parsed.pollIntervalMs,
232
+ signal,
233
+ }, io));
179
234
  }
180
235
 
181
236
  if (parsed.command === 'tick') {
182
- const controller = new AbortController();
183
- const onSignal = (): void => controller.abort();
184
- process.once('SIGINT', onSignal);
185
- process.once('SIGTERM', onSignal);
186
- try {
187
- return await runTickRunner({
188
- dataDir: parsed.dataDir,
189
- specPath: parsed.specPath,
190
- schedule: {
191
- scheduleId: parsed.scheduleId,
192
- intervalMs: parsed.intervalMs,
193
- ...(parsed.epochMs === undefined ? {} : { epochMs: parsed.epochMs }),
194
- ...(parsed.maxCatchUp === undefined ? {} : { maxCatchUp: parsed.maxCatchUp }),
195
- },
196
- pollIntervalMs: parsed.pollIntervalMs,
197
- signal: controller.signal,
198
- }, io) as CliExitCode;
199
- } finally {
200
- process.off('SIGINT', onSignal);
201
- process.off('SIGTERM', onSignal);
202
- }
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);
203
249
  }
204
250
 
205
251
  // Attach-or-spawn runs inside `runFlow`/`resumeFlow`/`runDirectFlow`, at the
@@ -277,6 +323,9 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR
277
323
  report: {
278
324
  ...mcp.report,
279
325
  ...(triggers?.report.schedules === undefined ? {} : { schedules: triggers.report.schedules }),
326
+ // The authored definition sees helper flags, body use and `cli:`
327
+ // declarations; the compiled view underneath knows only its steps.
328
+ ...(triggers?.report.requirements === undefined ? {} : { requirements: triggers.report.requirements }),
280
329
  diagnostics: [...helper.report.diagnostics, ...mcp.report.diagnostics, ...triggerDiagnostics],
281
330
  ok: helper.report.ok && mcp.report.ok && triggerOk,
282
331
  },
@@ -442,6 +491,11 @@ function emitWait(
442
491
 
443
492
  function parseArgs(args: readonly string[]): ParsedArgs | undefined {
444
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;
445
499
  if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
446
500
  if (command === 'replay') return parseReplayArgs(args.slice(1));
447
501
  if (command === 'build') return parseBuildArgs(args.slice(1));
@@ -479,6 +533,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
479
533
  if (command === 'tick') return parseTickArgs(args.slice(1));
480
534
  if (command === 'observer') return parseObserverArgs(args.slice(1));
481
535
  if (command === 'sync') return parseSyncArgs(args.slice(1));
536
+ if (command === 'answer') return parseAnswerArgs(args.slice(1));
482
537
  if (command !== 'check' && command !== 'run' && command !== 'resume') return undefined;
483
538
 
484
539
  let json = false;
@@ -486,6 +541,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
486
541
  let cloud = false;
487
542
  let wait = false;
488
543
  let syncCode = false;
544
+ let noConnect = false;
489
545
  let localAgent = false;
490
546
  let allowHumanInfluenced = false;
491
547
  let dataDir = DEFAULT_DATA_DIR;
@@ -499,11 +555,13 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
499
555
  const positionals: string[] = [];
500
556
  for (let index = 1; index < args.length; index += 1) {
501
557
  const argument = args[index]!;
502
- if (argument === '--cloud' || argument === '--wait' || argument === '--sync-code') {
503
- if (command !== 'run' || (argument === '--cloud' ? cloud : argument === '--wait' ? wait : syncCode)) return undefined;
558
+ if (argument === '--cloud' || argument === '--wait' || argument === '--sync-code' || argument === '--no-connect') {
559
+ if (command !== 'run') return undefined;
560
+ if (argument === '--cloud' ? cloud : argument === '--wait' ? wait : argument === '--sync-code' ? syncCode : noConnect) return undefined;
504
561
  if (argument === '--cloud') cloud = true;
505
562
  else if (argument === '--wait') wait = true;
506
- else syncCode = true;
563
+ else if (argument === '--sync-code') syncCode = true;
564
+ else noConnect = true;
507
565
  continue;
508
566
  }
509
567
  if (argument === '--allow-human-influenced') {
@@ -583,9 +641,9 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
583
641
  // the source, so it is accepted exactly where a local run accepts it.
584
642
  if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
585
643
  if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
586
- return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode };
644
+ return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect };
587
645
  }
588
- if (wait || syncCode) return undefined;
646
+ if (wait || syncCode || noConnect) return undefined;
589
647
  if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
590
648
 
591
649
  if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
@@ -596,6 +654,66 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
596
654
  : { command, localAgent, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
597
655
  }
598
656
 
657
+ /**
658
+ * `flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>`.
659
+ * The answer is a literal `yes`/`no` (also `true`/`false`) so a shell cannot
660
+ * hand the kernel an ambiguous word as a decision. `--by` records who answered
661
+ * when the invoker is relaying a person's decision (Cloud's answer route runs
662
+ * this inside the resumed sandbox with the caller's identity); it defaults to
663
+ * the OS user.
664
+ */
665
+ function parseAnswerArgs(rest: readonly string[]): ParsedArgs | undefined {
666
+ let json = false;
667
+ let spawn = true;
668
+ let dataDir = DEFAULT_DATA_DIR;
669
+ let sawDataDir = false;
670
+ let note: string | undefined;
671
+ let by: string | undefined;
672
+ const positionals: string[] = [];
673
+ for (let index = 0; index < rest.length; index += 1) {
674
+ const argument = rest[index]!;
675
+ if (argument === '--json') {
676
+ if (json) return undefined;
677
+ json = true;
678
+ continue;
679
+ }
680
+ if (argument === '--no-spawn') {
681
+ if (!spawn) return undefined;
682
+ spawn = false;
683
+ continue;
684
+ }
685
+ if (argument === '--by') {
686
+ const value = rest[index + 1];
687
+ if (by !== undefined || value === undefined || value.startsWith('-') || value.trim() === '') return undefined;
688
+ by = value;
689
+ index += 1;
690
+ continue;
691
+ }
692
+ if (argument === '--data-dir') {
693
+ const value = rest[index + 1];
694
+ if (sawDataDir || value === undefined || value.startsWith('-')) return undefined;
695
+ dataDir = value;
696
+ sawDataDir = true;
697
+ index += 1;
698
+ continue;
699
+ }
700
+ if (argument === '--note') {
701
+ const value = rest[index + 1];
702
+ if (note !== undefined || value === undefined || value.startsWith('--')) return undefined;
703
+ note = value;
704
+ index += 1;
705
+ continue;
706
+ }
707
+ if (argument.startsWith('-')) return undefined;
708
+ positionals.push(argument);
709
+ }
710
+ if (positionals.length !== 3) return undefined;
711
+ const [runId, waitId, word] = positionals as [string, string, string];
712
+ const answer = word === 'yes' || word === 'true' ? true : word === 'no' || word === 'false' ? false : undefined;
713
+ if (answer === undefined) return undefined;
714
+ return { command: 'answer', dataDir, json, spawn, note, by, runId, waitId, answer };
715
+ }
716
+
599
717
  function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
600
718
  const sub = rest[0];
601
719
  if (sub !== 'start') return undefined;
@@ -638,9 +756,13 @@ function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
638
756
  * directory at all -- the mint is a pure Relaycast API round-trip. No
639
757
  * positional argument, no other flags.
640
758
  */
641
- /** `flows sync [--json] [--dir <path>] <run-id>`: apply a hosted run's patch to a local tree. */
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
+ */
642
763
  function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
643
764
  let json = false;
765
+ let dryRun = false;
644
766
  let root: string | undefined;
645
767
  const positionals: string[] = [];
646
768
  for (let index = 0; index < args.length; index += 1) {
@@ -650,6 +772,11 @@ function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
650
772
  json = true;
651
773
  continue;
652
774
  }
775
+ if (argument === '--dry-run') {
776
+ if (dryRun) return undefined;
777
+ dryRun = true;
778
+ continue;
779
+ }
653
780
  if (argument === '--dir') {
654
781
  const value = args[index + 1];
655
782
  if (root !== undefined || value === undefined || value.startsWith('-')) return undefined;
@@ -661,7 +788,7 @@ function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
661
788
  positionals.push(argument);
662
789
  }
663
790
  if (positionals.length !== 1) return undefined;
664
- return { command: 'sync', runId: positionals[0]!, json, root: root ?? '.' };
791
+ return { command: 'sync', runId: positionals[0]!, json, dryRun, root: root ?? '.' };
665
792
  }
666
793
 
667
794
  function parseObserverArgs(rest: readonly string[]): ParsedArgs | undefined {
@@ -790,6 +917,10 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
790
917
  const model = resolution.model === undefined ? '' : ` model "${resolution.model}"`;
791
918
  io.stdout(`RESOLVED step "${resolution.stepId}" cli "${resolution.cli}"${model} from ${resolution.source}${config}`);
792
919
  }
920
+ // What the workspace must have connected before this flow can run there;
921
+ // the hosted verbs check the same list against Cloud before submitting.
922
+ const requires = report.requirements === undefined ? '' : describeFlowRequirements(report.requirements);
923
+ if (requires) io.stdout(`REQUIRES ${requires}`);
793
924
  if (report.ok) io.stdout(`CHECK PASSED ${report.path ?? ''}`.trimEnd());
794
925
  }
795
926
 
@@ -810,6 +941,15 @@ function emitRunReport(
810
941
  return;
811
942
  }
812
943
  if (report.runId === undefined) return;
944
+ if (report.answer !== undefined) {
945
+ io.stdout(`ANSWERED ${report.runId} ${report.answer.waitId} ${report.answer.answer ? 'yes' : 'no'}`
946
+ + (report.answer.note === undefined ? '' : ` (${report.answer.note})`));
947
+ if (report.next !== undefined) io.stdout(`Continue with: ${report.next}`);
948
+ return;
949
+ }
950
+ // A refused answer changed nothing about the run, so there is no run
951
+ // outcome to summarize; the refusal above is the whole report.
952
+ if (report.command === 'answer') return;
813
953
  const completed = report.completedSteps === undefined ? '' : ` (${report.completedSteps} ${report.completedSteps === 1 ? 'step' : 'steps'})`;
814
954
  const reason = report.completionReason === undefined
815
955
  ? ''
@@ -863,3 +1003,15 @@ if (isDirectInvocation(process.argv[1])) {
863
1003
  process.exitCode = exitCode;
864
1004
  });
865
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 };
@@ -0,0 +1,187 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions } from './cloud-http.js';
3
+ import type { FlowIntegrationRequirement, FlowRequirements } from './flow-requirements.js';
4
+
5
+ /**
6
+ * Integration readiness before a hosted submission — the CLI form of the
7
+ * deploy wizard's "Connect integrations" step, and the same relayfile
8
+ * connect-session flow `agentworkforce deploy` runs for a proactive agent:
9
+ *
10
+ * GET /api/v1/workspaces/<id>/integrations/<provider>/status?scope=workspace
11
+ * POST /api/v1/workspaces/<id>/integrations/connect-session
12
+ * { allowedIntegrations: [<provider>], scope: { kind: "workspace" } }
13
+ * → { connectLink } (older: sessionUrl / url), opened in the browser,
14
+ * then the status is polled until it reports ready.
15
+ *
16
+ * Only `workspace` scope is used: a flow deployment runs as the workspace,
17
+ * which is the scope `checkFlowConnections` reads on the server. Nothing here
18
+ * stores a credential — Cloud's connect page does — and a refusal names the
19
+ * provider and the two ways to connect it (this prompt, or the dashboard).
20
+ */
21
+
22
+ export interface ConnectPrompt {
23
+ /** Asked once per missing provider before a browser opens; `false` refuses the submission. */
24
+ confirm(question: string): Promise<boolean>;
25
+ info(line: string): void;
26
+ /** Defaults to the platform opener (`open`, `xdg-open`, `start`); a failure only means the URL was printed. */
27
+ openUrl?(url: string): Promise<void> | void;
28
+ }
29
+
30
+ export interface EnsureConnectionsOptions extends CloudConnectionOptions {
31
+ workspaceId: string;
32
+ /** Omitted (`--no-connect`, or no TTY) means a missing integration is a refusal, never a prompt. */
33
+ prompt?: ConnectPrompt;
34
+ /** Status poll cadence while the browser connect is pending. Default 2 seconds. */
35
+ pollIntervalMs?: number;
36
+ /** How long one connect may stay pending. Default 5 minutes. */
37
+ connectTimeoutMs?: number;
38
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
39
+ }
40
+
41
+ export interface ConnectionsOutcome {
42
+ /** Providers the workspace already had. */
43
+ ready: string[];
44
+ /** Providers connected through this prompt, in order. */
45
+ connected: string[];
46
+ }
47
+
48
+ const WORKSPACE_ID = /^[A-Za-z0-9_-]{1,128}$/u;
49
+ const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/u;
50
+
51
+ /** Whether the workspace has a ready connection for one provider. */
52
+ export async function integrationConnected(
53
+ workspaceId: string, provider: string, options: CloudConnectionOptions,
54
+ ): Promise<boolean> {
55
+ assertIds(workspaceId, provider);
56
+ const base = `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/integrations`;
57
+ try {
58
+ const status = await cloudFetch(`${base}/${encodeURIComponent(provider)}/status?scope=workspace`, options,
59
+ { method: 'GET', detail: true });
60
+ return isReady(status);
61
+ } catch (error) {
62
+ if (!(error instanceof CloudFlowError) || error.code !== 'http_error') throw error;
63
+ if (error.refusal?.code === 'unknown_provider') {
64
+ throw new CloudFlowError('integration_not_connected',
65
+ `"${provider}" is not an integration this Cloud can connect; the flow declares it, so rename the declaration to a provider Cloud lists.`,
66
+ error.status, error.refusal);
67
+ }
68
+ // An older Cloud without the per-provider status route: the list still says which are connected.
69
+ if (error.status !== 404 && error.status !== 405) throw error;
70
+ const list = await cloudRequest(base, options);
71
+ const rows = Array.isArray(list) ? list : isCloudRecord(list) && Array.isArray(list.integrations) ? list.integrations : [];
72
+ return rows.some(row => isCloudRecord(row) && row.provider === provider && isReady(row));
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Every integration the requirements name is ready when this resolves; each
78
+ * missing one was connected through the prompt, or the whole submission is
79
+ * refused with `integration_not_connected` naming the first missing provider.
80
+ */
81
+ export async function ensureIntegrationsConnected(
82
+ requirements: Pick<FlowRequirements, 'integrations'>, options: EnsureConnectionsOptions,
83
+ ): Promise<ConnectionsOutcome> {
84
+ const outcome: ConnectionsOutcome = { ready: [], connected: [] };
85
+ for (const integration of requirements.integrations) {
86
+ options.signal?.throwIfAborted();
87
+ if (await integrationConnected(options.workspaceId, integration.provider, options)) {
88
+ outcome.ready.push(integration.provider);
89
+ continue;
90
+ }
91
+ if (options.prompt === undefined) throw notConnected(integration, options);
92
+ const label = providerLabel(integration.provider);
93
+ options.prompt.info(`This flow needs ${label} (${integration.detail}), which is not connected to this workspace.`);
94
+ if (!await options.prompt.confirm(`Connect ${label} now? (opens browser) [Y/n] `)) {
95
+ throw notConnected(integration, options);
96
+ }
97
+ await connectIntegration(integration.provider, options, options.prompt);
98
+ outcome.connected.push(integration.provider);
99
+ }
100
+ return outcome;
101
+ }
102
+
103
+ async function connectIntegration(provider: string, options: EnsureConnectionsOptions, prompt: ConnectPrompt): Promise<void> {
104
+ const base = `/api/v1/workspaces/${encodeURIComponent(options.workspaceId)}/integrations`;
105
+ const session = await cloudFetch(`${base}/connect-session`, options, {
106
+ method: 'POST', detail: true,
107
+ body: JSON.stringify({ allowedIntegrations: [provider], scope: { kind: 'workspace' } }),
108
+ });
109
+ const url = isCloudRecord(session)
110
+ ? [session.connectLink, session.sessionUrl, session.url].find((value): value is string => typeof value === 'string' && value.length > 0)
111
+ : undefined;
112
+ if (url === undefined || !/^https:\/\//u.test(url)) {
113
+ throw new CloudFlowError('invalid_response', 'Cloud did not return a connect link.');
114
+ }
115
+ prompt.info(`Opening ${url}`);
116
+ prompt.info(`If the browser did not open, visit that link, then return here; waiting for ${providerLabel(provider)} to connect…`);
117
+ try {
118
+ await (prompt.openUrl ?? openInBrowser)(url);
119
+ } catch {
120
+ // The link was printed; the poll below is what decides.
121
+ }
122
+ const interval = options.pollIntervalMs ?? 2_000;
123
+ const deadline = Date.now() + (options.connectTimeoutMs ?? 300_000);
124
+ const sleep = options.sleep ?? defaultSleep;
125
+ while (Date.now() < deadline) {
126
+ await sleep(interval, options.signal);
127
+ options.signal?.throwIfAborted();
128
+ if (await integrationConnected(options.workspaceId, provider, options)) {
129
+ prompt.info(`${providerLabel(provider)} connected.`);
130
+ return;
131
+ }
132
+ }
133
+ throw new CloudFlowError('integration_not_connected',
134
+ `${providerLabel(provider)} was not connected before the wait ran out. Finish at ${url}, then run the command again.`);
135
+ }
136
+
137
+ function notConnected(integration: FlowIntegrationRequirement, options: EnsureConnectionsOptions): CloudFlowError {
138
+ const label = providerLabel(integration.provider);
139
+ return new CloudFlowError('integration_not_connected',
140
+ `${label} is not connected to this workspace, and this flow needs it (${integration.detail}). `
141
+ + `Connect it from the dashboard, or run again without --no-connect in a terminal to connect it now `
142
+ + `(a browser opens a ${label} connect page for workspace ${options.workspaceId}).`);
143
+ }
144
+
145
+ function isReady(status: unknown): boolean {
146
+ if (!isCloudRecord(status)) return false;
147
+ return status.ready === true || status.state === 'ready' || status.status === 'ready' || status.status === 'connected';
148
+ }
149
+
150
+ function assertIds(workspaceId: string, provider: string): void {
151
+ if (!WORKSPACE_ID.test(workspaceId)) throw new CloudFlowError('invalid_response', 'Cloud reported an unusable workspace id.');
152
+ if (!PROVIDER_ID.test(provider)) throw new CloudFlowError('invalid_input', `"${provider}" is not an integration provider id.`);
153
+ }
154
+
155
+ /** `google-mail` → `Google Mail`, `github` → `GitHub`; presentation only. */
156
+ export function providerLabel(provider: string): string {
157
+ const known: Record<string, string> = { github: 'GitHub', gitlab: 'GitLab', hubspot: 'HubSpot', clickup: 'ClickUp' };
158
+ return known[provider] ?? provider.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
159
+ }
160
+
161
+ function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
162
+ return new Promise((resolve, reject) => {
163
+ if (signal?.aborted) { reject(signal.reason); return; }
164
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
165
+ function onAbort(): void { clearTimeout(timer); reject(signal!.reason); }
166
+ signal?.addEventListener('abort', onAbort, { once: true });
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Detached so a browser that outlives the CLI never holds its stdio; every
172
+ * failure is ignored. `FLOWS_NO_BROWSER=1` (an SSH session, CI) leaves the
173
+ * printed link as the only opener.
174
+ */
175
+ function openInBrowser(url: string): void {
176
+ if (process.env['FLOWS_NO_BROWSER'] === '1') return;
177
+ const [command, args] = process.platform === 'darwin' ? ['open', [url]]
178
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
179
+ : ['xdg-open', [url]];
180
+ try {
181
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
182
+ child.on('error', () => {});
183
+ child.unref();
184
+ } catch {
185
+ // Printed URL is the fallback.
186
+ }
187
+ }