@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
@@ -3,6 +3,8 @@ import {
3
3
  deployToCloud, listCloudDeployments, parseAgentHarnesses, parseRepository, parseTriggerSource, undeployFromCloud,
4
4
  type FlowTriggerSource,
5
5
  } from '../cloud-deploy.js';
6
+ import { describeFlowRequirements } from '../flow-requirements.js';
7
+ import { cliConnectPrompt, flowRequirementsForPath, harnessRemedy } from './cloud-connect-cli.js';
6
8
  import type { CliIo } from '../cli.js';
7
9
 
8
10
  export interface CloudDeployArgs {
@@ -14,12 +16,14 @@ export interface CloudDeployArgs {
14
16
  name: string | undefined;
15
17
  agents: string | undefined;
16
18
  draft: boolean;
19
+ /** Refuse a missing integration instead of offering to connect it. */
20
+ noConnect: boolean;
17
21
  json: boolean;
18
22
  }
19
23
 
20
24
  /**
21
25
  * `flows deploy <flow.ts> --repo <owner/name> --on <provider>[:k=v,…] [--on …]
22
- * --approver <handle> [--name <n>] [--json]`
26
+ * --approver <handle> [--name <n>] [--agents <list>] [--draft] [--no-connect] [--json]`
23
27
  *
24
28
  * Parsed here rather than in `parseDeployArgs` because the two `deploy` forms
25
29
  * share nothing but the word: the digest form copies a sealed bundle into a
@@ -32,6 +36,7 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
32
36
  let name: string | undefined;
33
37
  let agents: string | undefined;
34
38
  let draft = false;
39
+ let noConnect = false;
35
40
  let json = false;
36
41
  const on: string[] = [];
37
42
  for (let i = 0; i < args.length; i++) {
@@ -46,6 +51,11 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
46
51
  draft = true;
47
52
  continue;
48
53
  }
54
+ if (arg === '--no-connect') {
55
+ if (noConnect) return undefined;
56
+ noConnect = true;
57
+ continue;
58
+ }
49
59
  if (arg === '--agents') {
50
60
  const next = args[i + 1];
51
61
  if (agents !== undefined || next === undefined || next.startsWith('-')) return undefined;
@@ -68,7 +78,7 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
68
78
  value = arg;
69
79
  }
70
80
  if (value === undefined || repo === undefined || on.length === 0) return undefined;
71
- return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, json };
81
+ return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, noConnect, json };
72
82
  }
73
83
 
74
84
  function describeSource(source: FlowTriggerSource): string {
@@ -77,11 +87,19 @@ function describeSource(source: FlowTriggerSource): string {
77
87
  }
78
88
 
79
89
  export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promise<0 | 1 | 2> {
90
+ const agents = args.agents === undefined ? undefined : parseAgentHarnessesOr(args.agents);
91
+ // What the source declares, so a harness refusal names the right remedy
92
+ // even when `--agents` was not given; the loader's own failure is reported
93
+ // by deployToCloud, not here.
94
+ let harnesses: readonly string[] = agents instanceof CloudFlowError ? [] : agents ?? [];
80
95
  try {
81
96
  if (args.approver === undefined) {
82
97
  throw new CloudFlowError('invalid_input',
83
98
  '--approver <handle> is required: every launched run receives it as input.approver for f.human.');
84
99
  }
100
+ if (agents instanceof CloudFlowError) throw agents;
101
+ const connect = cliConnectPrompt(io, { noConnect: args.noConnect, json: args.json });
102
+ if (agents === undefined) harnesses = (await flowRequirementsForPath(args.value))?.harnesses ?? [];
85
103
  const deployment = await deployToCloud({
86
104
  path: args.value,
87
105
  repository: parseRepository(args.repo),
@@ -89,7 +107,8 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
89
107
  approver: args.approver,
90
108
  draft: args.draft,
91
109
  ...(args.name === undefined ? {} : { name: args.name }),
92
- ...(args.agents === undefined ? {} : { agents: parseAgentHarnesses(args.agents) }),
110
+ ...(agents === undefined ? {} : { agents }),
111
+ ...(connect === undefined ? {} : { connect }),
93
112
  });
94
113
  if (args.json) {
95
114
  io.stdout(JSON.stringify({ ok: true, ...deployment }));
@@ -99,12 +118,24 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
99
118
  io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
100
119
  io.stdout(` repository: ${deployment.repository.owner}/${deployment.repository.name}`);
101
120
  for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
121
+ const requires = describeFlowRequirements(deployment.requirements);
122
+ if (requires) io.stdout(` requires: ${requires}`);
123
+ for (const provider of deployment.connected) io.stdout(` connected: ${provider}`);
102
124
  io.stdout(deployment.status === 'draft'
103
125
  ? 'Saved without activating; activate it from the Cloud dashboard, or redeploy without --draft.'
104
126
  : 'Each matching ticket launches a run of this source in a fresh branch; list with: flows deployments');
105
127
  return 0;
106
128
  } catch (error) {
107
- return reportCloudFailure(error, args.json, io);
129
+ return reportCloudFailure(error, args.json, io, harnesses);
130
+ }
131
+ }
132
+
133
+ function parseAgentHarnessesOr(value: string): ReturnType<typeof parseAgentHarnesses> | CloudFlowError {
134
+ try {
135
+ return parseAgentHarnesses(value);
136
+ } catch (error) {
137
+ if (error instanceof CloudFlowError) return error;
138
+ throw error;
108
139
  }
109
140
  }
110
141
 
@@ -140,9 +171,11 @@ export async function runCloudUndeployCli({ agentId, json }: { agentId: string;
140
171
  }
141
172
  }
142
173
 
143
- function reportCloudFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
174
+ function reportCloudFailure(error: unknown, json: boolean, io: CliIo, harnesses: readonly string[] = []): 1 | 2 {
144
175
  const code = error instanceof CloudFlowError ? error.code : 'cloud_deploy_failed';
145
176
  let message = error instanceof Error ? error.message : 'Cloud deploy failed.';
177
+ // Cloud names the missing coding-agent credential on activation; say how it is connected.
178
+ message += harnessRemedy(error, harnesses);
146
179
  // The deploy routes take a browser session or a `cli:auth` token. A
147
180
  // deployment (CI) token gets 403 `session_required`; say what fixes it.
148
181
  if (error instanceof CloudFlowError && error.status === 403) {
@@ -152,6 +185,7 @@ function reportCloudFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
152
185
  if (json) io.stdout(JSON.stringify({ ok: false, code, message }));
153
186
  else io.stderr(`${code}: ${message}`);
154
187
  return error instanceof CloudFlowError
155
- && (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code) || error.status === 403 || error.status === 401)
188
+ && (['configuration', 'unsupported_source', 'invalid_input', 'integration_not_connected'].includes(error.code)
189
+ || error.status === 403 || error.status === 401)
156
190
  ? 2 : 1;
157
191
  }
@@ -2,26 +2,30 @@ import { CloudFlowError } from '../cloud-http.js';
2
2
  import { runInCloud, waitForCloudFlowRun, type RunInCloudOptions } from '../cloud-run.js';
3
3
  import { DirectInputError, isAuthoredFlowPath, parseDirectInput } from '../direct-input.js';
4
4
  import { snapshotJsonValue } from '../json-value.js';
5
+ import { cliConnectPrompt, ensureFlowConnections, harnessRemedy } from './cloud-connect-cli.js';
5
6
  import type { CliIo } from '../cli.js';
6
7
 
7
8
  /** Presentation only: the central CLI parser owns argv; the SDK owns the lifecycle. */
8
9
  export async function runCloudCli(
9
- { value: path, json, wait, input, syncCode }: {
10
- value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean;
10
+ { value: path, json, wait, input, syncCode, noConnect = false }: {
11
+ value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect?: boolean;
11
12
  },
12
13
  io: CliIo,
14
+ /**
15
+ * Cancellation, owned by the caller. `runCli` supplies either the embedder's
16
+ * signal or one it drives from SIGINT/SIGTERM itself, so no process-wide
17
+ * handler is installed here.
18
+ */
19
+ signal: AbortSignal,
13
20
  ): Promise<0 | 1 | 2> {
14
- const controller = new AbortController();
15
- const abort = (): void => controller.abort();
16
- process.once('SIGINT', abort);
17
- process.once('SIGTERM', abort);
18
21
  let runId: string | undefined;
19
22
  // Flips at the run submission. An interruption before it — during prepare,
20
23
  // packing or upload — admitted nothing and is safe to retry; only an
21
24
  // interrupted submission has unknown admission.
22
25
  let submitting = false;
26
+ let harnesses: readonly string[] = [];
23
27
  try {
24
- const options: RunInCloudOptions = { signal: controller.signal, onSubmit: () => { submitting = true; } };
28
+ const options: RunInCloudOptions = { signal, onSubmit: () => { submitting = true; } };
25
29
  if (isAuthoredFlowPath(path)) {
26
30
  // Same parse as a local direct run, so a file-or-inline argument means
27
31
  // the same thing on both sides of `--cloud`.
@@ -32,6 +36,15 @@ export async function runCloudCli(
32
36
  throw error;
33
37
  }
34
38
  }
39
+ // After local validation, before anything is packed or uploaded: a helper
40
+ // the run would call against an unconnected integration is connected
41
+ // here, or refused here. A command that is going to be refused for its
42
+ // arguments must not first open a browser.
43
+ const connections = await ensureFlowConnections({
44
+ path, prompt: cliConnectPrompt(io, { noConnect, json }),
45
+ }, { signal });
46
+ harnesses = connections?.requirements.harnesses ?? [];
47
+ for (const provider of connections?.outcome.connected ?? []) if (!json) io.stdout(`CONNECTED ${provider}`);
35
48
  // The tree is the invoking directory, as with v1: the flow path is where
36
49
  // the body lives, not the boundary of what the run may read.
37
50
  if (syncCode) options.syncCode = { root: process.cwd() };
@@ -51,28 +64,26 @@ export async function runCloudCli(
51
64
  if (json) io.stdout(JSON.stringify({ ok: true, ...receipt }));
52
65
  return 0;
53
66
  }
54
- const run = await waitForCloudFlowRun(receipt.runId, { signal: controller.signal });
67
+ const run = await waitForCloudFlowRun(receipt.runId, { signal });
55
68
  const ok = run.status === 'completed';
56
69
  if (json) io.stdout(JSON.stringify({ ok, ...receipt, ...run }));
57
70
  else io.stdout(`${run.status.toUpperCase()} ${run.runId} completionReason: ${'completionReason' in run ? run.completionReason : 'unavailable'}`);
58
71
  return ok ? 0 : 1;
59
72
  } catch (error) {
60
- const code = controller.signal.aborted
73
+ const code = signal.aborted
61
74
  ? runId ? 'observation_aborted' : submitting ? 'admission_unknown' : 'submission_aborted'
62
75
  : error instanceof CloudFlowError ? error.code : 'cloud_run_failed';
63
- const message = controller.signal.aborted
76
+ const message = signal.aborted
64
77
  ? runId ? 'Stopped observing; the hosted run has not been cancelled.'
65
78
  : submitting
66
79
  ? 'Submission interrupted before a receipt was received. Admission is unknown; Cloud may have started the run. Do not resubmit blindly.'
67
80
  : 'Interrupted before the run was submitted; nothing was admitted. Safe to run again.'
68
- : error instanceof Error ? error.message : 'Cloud run failed.';
81
+ : (error instanceof Error ? error.message : 'Cloud run failed.') + harnessRemedy(error, harnesses);
69
82
  if (json) io.stdout(JSON.stringify({ ok: false, code, message, ...(runId ? { runId } : {}) }));
70
83
  else io.stderr(`${code}: ${message}${runId ? ` (run ${runId})` : ''}`);
71
84
  return error instanceof CloudFlowError
72
- && (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported'].includes(error.code)
85
+ && (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported',
86
+ 'integration_not_connected'].includes(error.code)
73
87
  || (runId === undefined && error.code === 'http_error' && [401, 403].includes(error.status ?? 0))) ? 2 : 1;
74
- } finally {
75
- process.off('SIGINT', abort);
76
- process.off('SIGTERM', abort);
77
88
  }
78
89
  }
@@ -1,7 +1,8 @@
1
1
  import { CloudFlowError } from '../cloud-http.js';
2
- import { listCloudSchedules, scheduleInCloud, unscheduleInCloud, type CloudSchedule } from '../cloud-schedule.js';
2
+ import { listCloudSchedules, scheduleInCloud, unscheduleInCloud, validateScheduleArgs, type CloudSchedule } from '../cloud-schedule.js';
3
3
  import { DirectInputError, isAuthoredFlowPath, parseDirectInput } from '../direct-input.js';
4
4
  import { snapshotJsonValue, type JsonValue } from '../json-value.js';
5
+ import { cliConnectPrompt, ensureFlowConnections, harnessRemedy } from './cloud-connect-cli.js';
5
6
  import type { CliIo } from '../cli.js';
6
7
 
7
8
  export interface CloudScheduleArgs {
@@ -12,12 +13,14 @@ export interface CloudScheduleArgs {
12
13
  tz: string | undefined;
13
14
  input: string | undefined;
14
15
  name: string | undefined;
16
+ /** Refuse a missing integration instead of offering to connect it. */
17
+ noConnect: boolean;
15
18
  json: boolean;
16
19
  }
17
20
 
18
21
  /**
19
22
  * `flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>]
20
- * [--tz <IANA>] [--input <json|file>] [--name <n>] [--json]`
23
+ * [--tz <IANA>] [--input <json|file>] [--name <n>] [--no-connect] [--json]`
21
24
  *
22
25
  * With neither `--cron` nor `--every`, the flow's own `schedule.*` handler
23
26
  * supplies the cron, so a declared schedule and its hosted registration
@@ -29,6 +32,7 @@ export function parseCloudScheduleArgs(args: readonly string[]): CloudScheduleAr
29
32
  cron: undefined, every: undefined, tz: undefined, input: undefined, name: undefined,
30
33
  };
31
34
  let json = false;
35
+ let noConnect = false;
32
36
  for (let i = 0; i < args.length; i++) {
33
37
  const arg = args[i]!;
34
38
  if (arg === '--json') {
@@ -36,6 +40,11 @@ export function parseCloudScheduleArgs(args: readonly string[]): CloudScheduleAr
36
40
  json = true;
37
41
  continue;
38
42
  }
43
+ if (arg === '--no-connect') {
44
+ if (noConnect) return undefined;
45
+ noConnect = true;
46
+ continue;
47
+ }
39
48
  if (arg === '--cron' || arg === '--every' || arg === '--tz' || arg === '--input' || arg === '--name') {
40
49
  const key = arg.slice(2) as keyof typeof flags;
41
50
  const next = args[i + 1];
@@ -51,7 +60,7 @@ export function parseCloudScheduleArgs(args: readonly string[]): CloudScheduleAr
51
60
  if (value === undefined) return undefined;
52
61
  if (flags.cron !== undefined && flags.every !== undefined) return undefined;
53
62
  if (flags.input !== undefined && !isAuthoredFlowPath(value)) return undefined;
54
- return { command: 'schedule', value, ...flags, json };
63
+ return { command: 'schedule', value, ...flags, noConnect, json };
55
64
  }
56
65
 
57
66
  function describe(schedule: CloudSchedule): string {
@@ -61,6 +70,7 @@ function describe(schedule: CloudSchedule): string {
61
70
  }
62
71
 
63
72
  export async function runCloudScheduleCli(args: CloudScheduleArgs, io: CliIo): Promise<0 | 1 | 2> {
73
+ let harnesses: readonly string[] = [];
64
74
  try {
65
75
  let input: JsonValue | undefined;
66
76
  let inputPresent = false;
@@ -73,6 +83,18 @@ export async function runCloudScheduleCli(args: CloudScheduleArgs, io: CliIo): P
73
83
  throw error;
74
84
  }
75
85
  }
86
+ validateScheduleArgs({
87
+ ...(args.cron === undefined ? {} : { cron: args.cron }),
88
+ ...(args.every === undefined ? {} : { every: args.every }),
89
+ ...(args.tz === undefined ? {} : { tz: args.tz }),
90
+ });
91
+ // Each fire runs the flow as this workspace, so what it needs connected
92
+ // is checked once here — after the arguments are known to be good, so a
93
+ // refused command never opens a browser first — and before the schedule exists.
94
+ const connections = await ensureFlowConnections({
95
+ path: args.value, prompt: cliConnectPrompt(io, { noConnect: args.noConnect, json: args.json }),
96
+ });
97
+ harnesses = connections?.requirements.harnesses ?? [];
76
98
  const schedule = await scheduleInCloud({
77
99
  flow: { path: args.value },
78
100
  ...(args.cron === undefined ? {} : { cron: args.cron }),
@@ -86,10 +108,11 @@ export async function runCloudScheduleCli(args: CloudScheduleArgs, io: CliIo): P
86
108
  return 0;
87
109
  }
88
110
  io.stdout(`SCHEDULED ${describe(schedule)}`);
111
+ for (const provider of connections?.outcome.connected ?? []) io.stdout(` connected: ${provider}`);
89
112
  io.stdout('Each fire submits this exact source through the same admission as flows run --cloud; list with: flows schedules');
90
113
  return 0;
91
114
  } catch (error) {
92
- return reportFailure(error, args.json, io);
115
+ return reportFailure(error, args.json, io, harnesses);
93
116
  }
94
117
  }
95
118
 
@@ -120,9 +143,10 @@ export async function runCloudUnscheduleCli(
120
143
  }
121
144
  }
122
145
 
123
- function reportFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
146
+ function reportFailure(error: unknown, json: boolean, io: CliIo, harnesses: readonly string[] = []): 1 | 2 {
124
147
  const code = error instanceof CloudFlowError ? error.code : 'cloud_schedule_failed';
125
148
  let message = error instanceof Error ? error.message : 'Cloud schedule failed.';
149
+ message += harnessRemedy(error, harnesses);
126
150
  // The schedule routes take a browser session or a `cli:auth` token.
127
151
  if (error instanceof CloudFlowError && error.status === 403) {
128
152
  message += ' Scheduling needs an interactive `cli:auth` credential: run `agent-relay cloud login`.';
@@ -130,6 +154,7 @@ function reportFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
130
154
  if (json) io.stdout(JSON.stringify({ ok: false, code, message }));
131
155
  else io.stderr(`${code}: ${message}`);
132
156
  return error instanceof CloudFlowError
133
- && (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code) || error.status === 403 || error.status === 401)
157
+ && (['configuration', 'unsupported_source', 'invalid_input', 'integration_not_connected'].includes(error.code)
158
+ || error.status === 403 || error.status === 401)
134
159
  ? 2 : 1;
135
160
  }
@@ -1,5 +1,7 @@
1
1
  import { CloudFlowError } from '../cloud-http.js';
2
- import { applyCloudPatch, downloadCloudPatch, patchedPaths } from '../cloud-sync.js';
2
+ import {
3
+ applyCloudPatch, downloadCloudPatchSet, excludedPatchPaths, patchedPaths, type CloudPathPatch,
4
+ } from '../cloud-sync.js';
3
5
  import type { CliIo } from '../cli.js';
4
6
 
5
7
  /**
@@ -7,23 +9,50 @@ import type { CliIo } from '../cli.js';
7
9
  * and apply it here. The patch is the sandbox's own `git diff` against the
8
10
  * uploaded baseline, so applying it reproduces exactly what the run's steps
9
11
  * wrote — no re-execution, no re-upload.
12
+ *
13
+ * The agent runtime's own bookkeeping inside that tree is dropped on the way
14
+ * in (`CLOUD_SYNC_PATCH_EXCLUDES`); `--dry-run` prints the patch instead of
15
+ * applying it, and a run that produced one patch per mounted path is shown but
16
+ * never applied, because no single `--dir` is the right destination for all of
17
+ * them.
10
18
  */
11
19
  export async function runCloudSyncCli(
12
- { runId, json, root }: { runId: string; json: boolean; root: string },
20
+ { runId, json, root, dryRun }: { runId: string; json: boolean; root: string; dryRun: boolean },
13
21
  io: CliIo,
14
22
  ): Promise<0 | 1 | 2> {
15
23
  try {
16
- const { patch, hasChanges } = await downloadCloudPatch(runId, {});
17
- if (!hasChanges || !patch.trim()) {
24
+ const set = await downloadCloudPatchSet(runId, {});
25
+ // A single-tree run can answer `hasChanges: true` with an empty body. The
26
+ // multi-path branch already discounts those (`patch.trim() !== ''` in
27
+ // `downloadCloudPatchSet`), and so did `agent-relay cloud sync`, which this
28
+ // command replaces. Without the same guard an empty patch reaches `git
29
+ // apply`, which fails as `patch_conflict` instead of reporting NO CHANGES.
30
+ const hasChanges = set.kind === 'single' ? set.hasChanges && set.patch.trim() !== '' : set.hasChanges;
31
+ if (!hasChanges) {
18
32
  if (json) io.stdout(JSON.stringify({ ok: true, runId, hasChanges: false, applied: false }));
19
33
  else io.stdout(`NO CHANGES ${runId}`);
20
34
  return 0;
21
35
  }
22
- applyCloudPatch(root, patch);
23
- const files = patchedPaths(patch);
24
- if (json) io.stdout(JSON.stringify({ ok: true, runId, hasChanges: true, applied: true, files }));
36
+ if (set.kind === 'multi-path') {
37
+ const changed = set.patches.filter(entry => entry.hasChanges && entry.patch.trim() !== '');
38
+ if (!dryRun) {
39
+ throw new CloudFlowError('sync_unsupported',
40
+ `Run ${runId} produced ${changed.length} path-scoped patch${changed.length === 1 ? '' : 'es'} `
41
+ + `(${changed.map(entry => entry.name).join(', ')}); flows sync applies single-tree runs only. `
42
+ + 'Inspect them with --dry-run, then apply each in its own repository.');
43
+ }
44
+ reportMultiPathDryRun(runId, changed, json, io);
45
+ return 0;
46
+ }
47
+ if (dryRun) {
48
+ reportDryRun(runId, set.patch, json, io);
49
+ return 0;
50
+ }
51
+ const { files, excluded } = applyCloudPatch(root, set.patch);
52
+ if (json) io.stdout(JSON.stringify({ ok: true, runId, hasChanges: true, applied: true, files, excluded }));
25
53
  else {
26
- io.stdout(`APPLIED ${runId}: ${files.length} file${files.length === 1 ? '' : 's'}${files.length ? `\n ${files.join('\n ')}` : ''}`);
54
+ io.stdout(`APPLIED ${runId}: ${files.length} file${files.length === 1 ? '' : 's'}${indented(files)}`);
55
+ if (excluded.length) io.stdout(`SKIPPED agent runtime paths: ${excluded.length}${indented(excluded)}`);
27
56
  io.stdout('Applied to the working tree, uncommitted: review with git diff before keeping it.');
28
57
  }
29
58
  return 0;
@@ -35,3 +64,51 @@ export async function runCloudSyncCli(
35
64
  return error instanceof CloudFlowError && ['configuration', 'sync_unsupported', 'patch_conflict'].includes(error.code) ? 2 : 1;
36
65
  }
37
66
  }
67
+
68
+ /** A single-tree patch, shown rather than applied. */
69
+ function reportDryRun(runId: string, patch: string, json: boolean, io: CliIo): void {
70
+ const excluded = excludedPatchPaths(patch);
71
+ const files = patchedPaths(patch).filter(path => !excluded.includes(path));
72
+ if (json) {
73
+ // The patch travels in the payload, not on stdout beside it: a --json
74
+ // consumer parses one object, and a diff printed alongside it is not JSON.
75
+ io.stdout(JSON.stringify({
76
+ ok: true, runId, hasChanges: true, applied: false, dryRun: true, files, excluded, patch,
77
+ }));
78
+ return;
79
+ }
80
+ io.stdout(`DRY RUN ${runId}: ${files.length} file${files.length === 1 ? '' : 's'} would be written${indented(files)}`);
81
+ if (excluded.length) io.stdout(`Would skip agent runtime paths: ${excluded.length}${indented(excluded)}`);
82
+ emitPatch(patch, io);
83
+ }
84
+
85
+ /** Every changed path-scoped patch, shown; applying them is not this command's call. */
86
+ function reportMultiPathDryRun(
87
+ runId: string, changed: readonly CloudPathPatch[], json: boolean, io: CliIo,
88
+ ): void {
89
+ if (json) {
90
+ io.stdout(JSON.stringify({
91
+ ok: true, runId, hasChanges: true, applied: false, dryRun: true, multiPath: true,
92
+ patches: changed.map(({ name, patch }) => {
93
+ const excluded = excludedPatchPaths(patch);
94
+ return { name, files: patchedPaths(patch).filter(path => !excluded.includes(path)), excluded, patch };
95
+ }),
96
+ }));
97
+ return;
98
+ }
99
+ io.stdout(`DRY RUN ${runId}: ${changed.length} path-scoped patch${changed.length === 1 ? '' : 'es'}`);
100
+ io.stdout('Each targets its own repository; apply them there, not with --dir.');
101
+ for (const { name, patch } of changed) {
102
+ io.stdout(`--- patch for path "${name}" ---`);
103
+ emitPatch(patch, io);
104
+ }
105
+ }
106
+
107
+ /** Write a diff through the line-oriented io without gaining or losing a newline. */
108
+ function emitPatch(patch: string, io: CliIo): void {
109
+ for (const line of patch.replace(/\n$/u, '').split('\n')) io.stdout(line);
110
+ }
111
+
112
+ function indented(paths: readonly string[]): string {
113
+ return paths.length ? `\n ${paths.join('\n ')}` : '';
114
+ }
package/src/cli/deploy.ts CHANGED
@@ -4,19 +4,35 @@ import { BundleFailure, bucketDirectory, copyBundle, exists, parseDigestReferenc
4
4
  verifyDigest, writableBucket } from '../bundle-transport.js';
5
5
  import { checkRunnableBundle } from './bundle-preflight.js';
6
6
 
7
- export interface DeployArgs { command: 'deploy'; value: string; to: string }
7
+ export interface DeployArgs { command: 'deploy'; value: string; to: string; json: boolean }
8
+
9
+ /**
10
+ * `--json` is declared on the `deploy` verb, which both forms share, so the
11
+ * bundle form has to accept it too -- the hosted-listener parser already did,
12
+ * and help cannot tell a reader the flag belongs to only one of them.
13
+ */
8
14
  export function parseDeployArgs(args: readonly string[]): DeployArgs | undefined {
9
15
  let value: string | undefined;
10
16
  let to: string | undefined;
17
+ let json = false;
11
18
  for (let i = 0; i < args.length; i++) {
12
19
  const arg = args[i]!;
13
20
  if (arg === '--to') {
14
21
  if (to !== undefined || !args[i + 1] || args[i + 1]!.startsWith('-')) return undefined;
15
22
  to = args[++i];
23
+ } else if (arg === '--json') {
24
+ if (json) return undefined;
25
+ json = true;
16
26
  } else if (arg.startsWith('-') || value !== undefined) return undefined;
17
27
  else value = arg;
18
28
  }
19
- return value && to && parseDigestReference(value) ? { command: 'deploy', value, to } : undefined;
29
+ return value && to && parseDigestReference(value) ? { command: 'deploy', value, to, json } : undefined;
30
+ }
31
+
32
+ /** One machine-readable object per outcome, or the text line it mirrors. */
33
+ function emitDeployOutcome(status: 'deployed' | 'already-present', args: DeployArgs, io: CliIo): void {
34
+ if (args.json) io.stdout(JSON.stringify({ ok: true, bundle: args.value, to: args.to, status }));
35
+ else io.stdout(`${status === 'deployed' ? 'DEPLOYED' : 'SKIPPED (already-present)'} ${args.value} ${args.to}`);
20
36
  }
21
37
  export async function runDeploy(args: DeployArgs, io: CliIo): Promise<0 | 1 | 2> {
22
38
  let started = false;
@@ -29,6 +45,10 @@ export async function runDeploy(args: DeployArgs, io: CliIo): Promise<0 | 1 | 2>
29
45
  const checked = await checkRunnableBundle(source, ref.name);
30
46
  if (!checked.report.ok) {
31
47
  for (const diagnostic of checked.report.diagnostics) io.stderr(`${diagnostic.severity.toUpperCase()} [${diagnostic.kind}] ${diagnostic.message}`);
48
+ if (args.json) {
49
+ io.stdout(JSON.stringify({ ok: false, code: 'bundle_unrunnable', bundle: args.value, to: args.to,
50
+ diagnostics: checked.report.diagnostics }));
51
+ }
32
52
  return 2;
33
53
  }
34
54
  // `ok` may still carry warnings (e.g. `budget_unmetered`); report them.
@@ -39,18 +59,20 @@ export async function runDeploy(args: DeployArgs, io: CliIo): Promise<0 | 1 | 2>
39
59
  if (await exists(target)) {
40
60
  await verifyDigest(target, ref.digest);
41
61
  io.stderr(`deploy_noop: ${args.value}`);
42
- io.stdout(`SKIPPED (already-present) ${args.value} ${args.to}`);
62
+ emitDeployOutcome('already-present', args, io);
43
63
  return 0;
44
64
  }
45
65
  await writableBucket(target);
46
66
  started = true;
47
67
  const copied = await copyBundle(source, target, ref.digest);
48
68
  if (!copied) io.stderr(`deploy_noop: ${args.value}`);
49
- io.stdout(`${copied ? 'DEPLOYED' : 'SKIPPED (already-present)'} ${args.value} ${args.to}`);
69
+ emitDeployOutcome(copied ? 'deployed' : 'already-present', args, io);
50
70
  return 0;
51
71
  } catch (error) {
52
72
  const kind = started ? 'deploy_partial' : error instanceof BundleFailure ? error.kind : 'bucket_unreachable';
53
- io.stderr(`${started ? 'FAILED' : 'REFUSED'} [${kind}] ${error instanceof Error ? error.message : String(error)}`);
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ io.stderr(`${started ? 'FAILED' : 'REFUSED'} [${kind}] ${message}`);
75
+ if (args.json) io.stdout(JSON.stringify({ ok: false, code: kind, message, bundle: args.value, to: args.to }));
54
76
  return started ? 1 : 2;
55
77
  }
56
78
  }
@@ -8,6 +8,7 @@ import {
8
8
  AuthoredFlowExecutionError,
9
9
  } from '../authored-flow-executor.js';
10
10
  import { executeDurableAuthoredFlow } from '../authored-root.js';
11
+ import { AuthoredHumanParked } from '../authored-flow-error.js';
11
12
  import { AuthoredFlowLoadError } from '../authored-flow-loader.js';
12
13
  import { DirectInputError, parseDirectInput } from '../direct-input.js';
13
14
  import { JournalClient } from '../journal-client.js';
@@ -15,6 +16,7 @@ import { inputFailureReport } from './check.js';
15
16
  import { checkAuthoredTriggers } from './check-triggers.js';
16
17
  import {
17
18
  authoredCompletion,
19
+ authoredHumanParked,
18
20
  authoredStepFailure,
19
21
  connect,
20
22
  emptyReport,
@@ -175,6 +177,9 @@ export async function runDirectFlow(
175
177
  if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
176
178
  return authoredStepFailure('run', base, socketPath, error);
177
179
  }
180
+ if (error instanceof AuthoredHumanParked) {
181
+ return authoredHumanParked('run', base, socketPath, error, { dataDir, localAgent: options.localAgent === true });
182
+ }
178
183
  const runId = error instanceof AuthoredFlowExecutionError ? error.runId : undefined;
179
184
  return protocolFailure('run', base, socketPath, error, runId);
180
185
  } finally {
package/src/cli/run.ts CHANGED
@@ -2,7 +2,8 @@ import { parseDigestReference } from '../bundle-transport.js';
2
2
  import { prepareDigestRun } from './run-digest.js';
3
3
  import { reuseSummary } from './reuse.js';
4
4
  import { resumeHelperEffect } from '../authored-helper-effect.js';
5
- import { AuthoredFlowExecutionError } from '../authored-flow-error.js';
5
+ import { AuthoredFlowExecutionError, AuthoredHumanParked, type AuthoredHumanWait } from '../authored-flow-error.js';
6
+ import { answerCommand, resumeCommand } from '../authored-human.js';
6
7
  import { join, resolve } from 'node:path';
7
8
  import type { ProgressEvent } from '../progress.js';
8
9
  import { toKernelSpec } from '../compile.js';
@@ -29,7 +30,7 @@ import {
29
30
  } from './check.js';
30
31
 
31
32
  export type RunExitCode = 0 | 1 | 2 | 3;
32
- export type RunCommand = 'run' | 'resume';
33
+ export type RunCommand = 'run' | 'resume' | 'answer';
33
34
 
34
35
  export interface ParkedStep {
35
36
  id: string;
@@ -53,6 +54,12 @@ export interface RunReport {
53
54
  completedSteps?: number;
54
55
  reuse?: { fromRunId: string; reusedSteps: number; executedSteps: number };
55
56
  parkedStep?: ParkedStep;
57
+ /** The open `f.human` question a parked authored run is waiting on. */
58
+ humanWait?: AuthoredHumanWait;
59
+ /** `flows answer` only: the answer it recorded. */
60
+ answer?: { waitId: string; answer: boolean; note?: string };
61
+ /** The invocation that continues this run, when one is known. */
62
+ next?: string;
56
63
  projectConfigPath?: string;
57
64
  resolutions: CheckReport['resolutions'];
58
65
  diagnostics: Array<CheckReport['diagnostics'][number] | RunDiagnostic>;
@@ -226,6 +233,9 @@ export async function resumeFlow(
226
233
  if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
227
234
  return authoredStepFailure('resume', base, socketPath, error, runId);
228
235
  }
236
+ if (error instanceof AuthoredHumanParked) {
237
+ return authoredHumanParked('resume', base, socketPath, error, { dataDir, localAgent: options.localAgent === true });
238
+ }
229
239
  if (!(error instanceof JournalProtocolError) || error.code !== 'run_not_found') {
230
240
  return protocolFailure('resume', base, socketPath, error, runId);
231
241
  }
@@ -298,6 +308,42 @@ export function authoredStepFailure(
298
308
  };
299
309
  }
300
310
 
311
+ /**
312
+ * An authored body parked on an unanswered `f.human`: exit 3, like every
313
+ * other park, but the diagnostic names the question, who it is for, and the
314
+ * exact `flows answer` invocation — the run is waiting on a person, not on a
315
+ * worker. Shared by `run` and `resume` so the two never drift.
316
+ */
317
+ export function authoredHumanParked(
318
+ command: RunCommand,
319
+ base: CheckReport | RunReport,
320
+ socketPath: string,
321
+ error: AuthoredHumanParked,
322
+ where: { dataDir?: string; localAgent: boolean },
323
+ ): RunExecution {
324
+ const runId = error.runId!;
325
+ return {
326
+ exitCode: 3,
327
+ report: {
328
+ ...fromBase(command, base),
329
+ ok: false,
330
+ runId,
331
+ socketPath,
332
+ status: 'parked',
333
+ parkedStep: { id: 'authored-root', type: 'agent' },
334
+ humanWait: error.wait,
335
+ diagnostics: [...base.diagnostics, {
336
+ severity: 'parked',
337
+ kind: 'run_parked',
338
+ message: `Run "${runId}" is waiting for ${error.wait.to} to answer ${error.wait.waitId}: `
339
+ + `${JSON.stringify(error.wait.question)}\n`
340
+ + `Answer with: ${answerCommand(runId, error.wait.waitId, where.dataDir)}\n`
341
+ + `Then continue with: ${resumeCommand(runId, where.dataDir, where.localAgent)}`,
342
+ }],
343
+ },
344
+ };
345
+ }
346
+
301
347
  /**
302
348
  * An authored body that returned its own terminal verdict, reported as that verdict.
303
349
  *