@relayflows/sdk 2.0.17 → 2.0.18

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 (123) 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.js +1 -1
  25. package/dist/cli/build.js.map +1 -1
  26. package/dist/cli/check-triggers.d.ts.map +1 -1
  27. package/dist/cli/check-triggers.js +2 -0
  28. package/dist/cli/check-triggers.js.map +1 -1
  29. package/dist/cli/check.d.ts +11 -0
  30. package/dist/cli/check.d.ts.map +1 -1
  31. package/dist/cli/check.js +14 -2
  32. package/dist/cli/check.js.map +1 -1
  33. package/dist/cli/cloud-connect-cli.d.ts +54 -0
  34. package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
  35. package/dist/cli/cloud-connect-cli.js +109 -0
  36. package/dist/cli/cloud-connect-cli.js.map +1 -0
  37. package/dist/cli/cloud-deploy.d.ts +3 -1
  38. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  39. package/dist/cli/cloud-deploy.js +44 -6
  40. package/dist/cli/cloud-deploy.js.map +1 -1
  41. package/dist/cli/cloud-run.d.ts +2 -1
  42. package/dist/cli/cloud-run.d.ts.map +1 -1
  43. package/dist/cli/cloud-run.js +17 -3
  44. package/dist/cli/cloud-run.js.map +1 -1
  45. package/dist/cli/cloud-schedule.d.ts +3 -1
  46. package/dist/cli/cloud-schedule.d.ts.map +1 -1
  47. package/dist/cli/cloud-schedule.js +31 -6
  48. package/dist/cli/cloud-schedule.js.map +1 -1
  49. package/dist/cli/direct-run.d.ts.map +1 -1
  50. package/dist/cli/direct-run.js +5 -1
  51. package/dist/cli/direct-run.js.map +1 -1
  52. package/dist/cli/run.d.ts +22 -2
  53. package/dist/cli/run.d.ts.map +1 -1
  54. package/dist/cli/run.js +34 -1
  55. package/dist/cli/run.js.map +1 -1
  56. package/dist/cli.d.ts.map +1 -1
  57. package/dist/cli.js +114 -9
  58. package/dist/cli.js.map +1 -1
  59. package/dist/cloud-connect.d.ts +52 -0
  60. package/dist/cloud-connect.d.ts.map +1 -0
  61. package/dist/cloud-connect.js +138 -0
  62. package/dist/cloud-connect.js.map +1 -0
  63. package/dist/cloud-deploy.d.ts +19 -1
  64. package/dist/cloud-deploy.d.ts.map +1 -1
  65. package/dist/cloud-deploy.js +40 -1
  66. package/dist/cloud-deploy.js.map +1 -1
  67. package/dist/cloud-http.d.ts +2 -2
  68. package/dist/cloud-http.d.ts.map +1 -1
  69. package/dist/cloud-http.js.map +1 -1
  70. package/dist/cloud-schedule.d.ts +7 -0
  71. package/dist/cloud-schedule.d.ts.map +1 -1
  72. package/dist/cloud-schedule.js +22 -1
  73. package/dist/cloud-schedule.js.map +1 -1
  74. package/dist/failure-kinds.d.ts +1 -1
  75. package/dist/failure-kinds.d.ts.map +1 -1
  76. package/dist/failure-kinds.js +2 -0
  77. package/dist/failure-kinds.js.map +1 -1
  78. package/dist/flow-requirements.d.ts +68 -0
  79. package/dist/flow-requirements.d.ts.map +1 -0
  80. package/dist/flow-requirements.js +152 -0
  81. package/dist/flow-requirements.js.map +1 -0
  82. package/dist/index.d.ts +2 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -0
  85. package/dist/index.js.map +1 -1
  86. package/dist/journal-client.d.ts +11 -0
  87. package/dist/journal-client.d.ts.map +1 -1
  88. package/dist/journal-client.js +13 -0
  89. package/dist/journal-client.js.map +1 -1
  90. package/dist/progress.d.ts +2 -1
  91. package/dist/progress.d.ts.map +1 -1
  92. package/dist/progress.js +9 -3
  93. package/dist/progress.js.map +1 -1
  94. package/dist/protocol.d.ts +25 -1
  95. package/dist/protocol.d.ts.map +1 -1
  96. package/package.json +2 -2
  97. package/src/authored-flow-error.ts +29 -0
  98. package/src/authored-flow-executor.ts +54 -2
  99. package/src/authored-human.ts +150 -0
  100. package/src/authored-node-entry.ts +3 -2
  101. package/src/authored-node-runner.ts +20 -5
  102. package/src/authored-root.ts +28 -1
  103. package/src/cli/answer.ts +98 -0
  104. package/src/cli/build.ts +1 -1
  105. package/src/cli/check-triggers.ts +2 -0
  106. package/src/cli/check.ts +24 -2
  107. package/src/cli/cloud-connect-cli.ts +130 -0
  108. package/src/cli/cloud-deploy.ts +40 -6
  109. package/src/cli/cloud-run.ts +16 -4
  110. package/src/cli/cloud-schedule.ts +31 -6
  111. package/src/cli/direct-run.ts +5 -0
  112. package/src/cli/run.ts +48 -2
  113. package/src/cli.ts +103 -10
  114. package/src/cloud-connect.ts +187 -0
  115. package/src/cloud-deploy.ts +57 -2
  116. package/src/cloud-http.ts +1 -1
  117. package/src/cloud-schedule.ts +20 -3
  118. package/src/failure-kinds.ts +2 -0
  119. package/src/flow-requirements.ts +201 -0
  120. package/src/index.ts +8 -0
  121. package/src/journal-client.ts +20 -0
  122. package/src/progress.ts +11 -4
  123. package/src/protocol.ts +23 -0
@@ -0,0 +1,130 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { loadAuthoredFlow } from '../authored-flow-loader.js';
6
+ import { ensureIntegrationsConnected, type ConnectPrompt, type ConnectionsOutcome } from '../cloud-connect.js';
7
+ import { CloudFlowError, cloudRequest, isCloudRecord, type CloudConnectionOptions } from '../cloud-http.js';
8
+ import { CompileError, compileSpec, kernelToAuthoring } from '../compile.js';
9
+ import { FLOW_HARNESSES, flowRequirements, type FlowRequirements, type FlowRequirementsContext } from '../flow-requirements.js';
10
+ import { readProjectConfig } from './check.js';
11
+ import type { CliIo } from '../cli.js';
12
+
13
+ /**
14
+ * The hosted verbs' shared "are the integrations connected?" step. It runs
15
+ * before `flows deploy`, `flows schedule` and `flows run --cloud` submit
16
+ * anything, so a flow that would fail at its first `f.slack.post` with
17
+ * `helper_slack.credential_missing` is connected — or refused — first.
18
+ *
19
+ * The flow is read the same way the submission reads it (authored loader or
20
+ * YAML compile); a source that does not load is left for the submission to
21
+ * refuse with its own, better diagnostic, so this step never masks one.
22
+ */
23
+
24
+ export interface FlowConnectionsInput extends FlowRequirementsContext {
25
+ path: string;
26
+ /** `undefined` refuses a missing integration instead of prompting (`--no-connect`, `--json`, no TTY). */
27
+ prompt: ConnectPrompt | undefined;
28
+ /** Known already (deploy resolved it); otherwise `whoami` supplies it. */
29
+ workspaceId?: string;
30
+ }
31
+
32
+ export async function flowRequirementsForPath(
33
+ path: string, context: FlowRequirementsContext = {},
34
+ ): Promise<FlowRequirements | undefined> {
35
+ const projectCli = context.projectCli ?? projectCliFor(path);
36
+ const withCli = { ...context, ...(projectCli === undefined ? {} : { projectCli }) };
37
+ try {
38
+ if (/\.flow\.ts$/iu.test(path)) {
39
+ const loaded = await loadAuthoredFlow(path);
40
+ return flowRequirements(loaded.getDefinition(loaded.handle), withCli);
41
+ }
42
+ if (!/\.(?:ya?ml|json)$/iu.test(path)) return undefined;
43
+ const parsed: unknown = parseYaml(await readFile(path, 'utf8'));
44
+ let spec;
45
+ try {
46
+ spec = compileSpec(parsed);
47
+ } catch (error) {
48
+ if (!(error instanceof CompileError)) throw error;
49
+ spec = compileSpec(kernelToAuthoring(parsed));
50
+ }
51
+ return flowRequirements(spec, withCli);
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ function projectCliFor(path: string): string | undefined {
58
+ try {
59
+ return readProjectConfig(dirname(resolve(path))).cli;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /** `whoami`'s current workspace: the one every hosted verb submits into. */
66
+ export async function currentWorkspaceId(options: CloudConnectionOptions): Promise<string> {
67
+ const whoami = await cloudRequest('/api/v1/auth/whoami', options);
68
+ const workspace = isCloudRecord(whoami) && isCloudRecord(whoami.currentWorkspace) ? whoami.currentWorkspace : undefined;
69
+ if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) {
70
+ throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.');
71
+ }
72
+ return workspace.id;
73
+ }
74
+
75
+ /**
76
+ * Connects (or refuses on) every integration `path` requires. Nothing is
77
+ * contacted when the flow requires no integration, so a flow with no
78
+ * helpers, sources or repository submits exactly as before; the derived
79
+ * requirements still come back so a later harness refusal can name its remedy.
80
+ * `undefined` only when the source does not load — the submission says why.
81
+ */
82
+ export async function ensureFlowConnections(
83
+ input: FlowConnectionsInput, options: CloudConnectionOptions = {},
84
+ ): Promise<{ requirements: FlowRequirements; outcome: ConnectionsOutcome } | undefined> {
85
+ const requirements = await flowRequirementsForPath(input.path, input);
86
+ if (requirements === undefined) return undefined;
87
+ if (requirements.integrations.length === 0) return { requirements, outcome: { ready: [], connected: [] } };
88
+ const workspaceId = input.workspaceId ?? await currentWorkspaceId(options);
89
+ const outcome = await ensureIntegrationsConnected(requirements, {
90
+ ...options, workspaceId, ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
91
+ });
92
+ return { requirements, outcome };
93
+ }
94
+
95
+ /**
96
+ * A terminal prompt, or nothing. `--json` output is for a machine, and a
97
+ * pipe cannot answer a question, so both refuse instead of asking; `--no-connect`
98
+ * is the explicit form of the same choice.
99
+ */
100
+ export function cliConnectPrompt(
101
+ io: CliIo, { noConnect, json }: { noConnect: boolean; json: boolean },
102
+ streams: { stdin: NodeJS.ReadStream; stdout: NodeJS.WriteStream } = { stdin: process.stdin, stdout: process.stdout },
103
+ ): ConnectPrompt | undefined {
104
+ if (noConnect || json || !streams.stdin.isTTY || !streams.stdout.isTTY) return undefined;
105
+ return {
106
+ info: line => io.stderr(line),
107
+ confirm: question => new Promise(resolveAnswer => {
108
+ const rl = createInterface({ input: streams.stdin, output: streams.stdout });
109
+ rl.question(question, answer => {
110
+ rl.close();
111
+ resolveAnswer(/^\s*(?:y(?:es)?)?\s*$/iu.test(answer));
112
+ });
113
+ }),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Cloud refuses a run or activation whose coding-agent credential is missing
119
+ * (`flow_model_not_connected`, `cli_credentials_missing`); there is no status
120
+ * route to ask first, so the refusal is where the remedy is named.
121
+ */
122
+ export function harnessRemedy(error: unknown, harnesses: readonly string[]): string {
123
+ if (!(error instanceof CloudFlowError) || error.refusal === undefined) return '';
124
+ if (!['flow_model_not_connected', 'cli_credentials_missing'].includes(error.refusal.code)) return '';
125
+ if (/agent-relay cloud connect/u.test(error.refusal.error)) return '';
126
+ const message = error.refusal.error.toLowerCase();
127
+ const named = FLOW_HARNESSES.filter(harness => message.includes(harness));
128
+ const targets = named.length > 0 ? named : harnesses.length > 0 ? harnesses : ['claude'];
129
+ return ` Connect it with: ${targets.map(harness => `agent-relay cloud connect ${harness}`).join('; ')}.`;
130
+ }
@@ -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,12 +2,13 @@ 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,
13
14
  ): Promise<0 | 1 | 2> {
@@ -20,6 +21,7 @@ export async function runCloudCli(
20
21
  // packing or upload — admitted nothing and is safe to retry; only an
21
22
  // interrupted submission has unknown admission.
22
23
  let submitting = false;
24
+ let harnesses: readonly string[] = [];
23
25
  try {
24
26
  const options: RunInCloudOptions = { signal: controller.signal, onSubmit: () => { submitting = true; } };
25
27
  if (isAuthoredFlowPath(path)) {
@@ -32,6 +34,15 @@ export async function runCloudCli(
32
34
  throw error;
33
35
  }
34
36
  }
37
+ // After local validation, before anything is packed or uploaded: a helper
38
+ // the run would call against an unconnected integration is connected
39
+ // here, or refused here. A command that is going to be refused for its
40
+ // arguments must not first open a browser.
41
+ const connections = await ensureFlowConnections({
42
+ path, prompt: cliConnectPrompt(io, { noConnect, json }),
43
+ }, { signal: controller.signal });
44
+ harnesses = connections?.requirements.harnesses ?? [];
45
+ for (const provider of connections?.outcome.connected ?? []) if (!json) io.stdout(`CONNECTED ${provider}`);
35
46
  // The tree is the invoking directory, as with v1: the flow path is where
36
47
  // the body lives, not the boundary of what the run may read.
37
48
  if (syncCode) options.syncCode = { root: process.cwd() };
@@ -65,11 +76,12 @@ export async function runCloudCli(
65
76
  : submitting
66
77
  ? 'Submission interrupted before a receipt was received. Admission is unknown; Cloud may have started the run. Do not resubmit blindly.'
67
78
  : 'Interrupted before the run was submitted; nothing was admitted. Safe to run again.'
68
- : error instanceof Error ? error.message : 'Cloud run failed.';
79
+ : (error instanceof Error ? error.message : 'Cloud run failed.') + harnessRemedy(error, harnesses);
69
80
  if (json) io.stdout(JSON.stringify({ ok: false, code, message, ...(runId ? { runId } : {}) }));
70
81
  else io.stderr(`${code}: ${message}${runId ? ` (run ${runId})` : ''}`);
71
82
  return error instanceof CloudFlowError
72
- && (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported'].includes(error.code)
83
+ && (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported',
84
+ 'integration_not_connected'].includes(error.code)
73
85
  || (runId === undefined && error.code === 'http_error' && [401, 403].includes(error.status ?? 0))) ? 2 : 1;
74
86
  } finally {
75
87
  process.off('SIGINT', abort);
@@ -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
  }
@@ -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
  *