@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
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';
@@ -54,7 +56,7 @@ type ParsedArgs =
54
56
  | BuildArgs
55
57
  | DeployArgs
56
58
  | { 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 }
59
+ | { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean }
58
60
  | { command: 'sync'; runId: string; json: boolean; root: string }
59
61
  | CloudDeployArgs
60
62
  | { command: 'deployments'; json: boolean }
@@ -65,6 +67,7 @@ type ParsedArgs =
65
67
  | { command: 'check'; json: boolean; watch: boolean; value: string }
66
68
  | { 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
69
  | { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
70
+ | { command: 'answer'; dataDir: string; json: boolean; spawn: boolean; note: string | undefined; by: string | undefined; runId: string; waitId: string; answer: boolean }
68
71
  | { command: 'observer'; dataDir: string }
69
72
  | { command: 'hn-monitor'; sub: 'start'; dataDir: string; specPath: string; pollIntervalMs: number | undefined }
70
73
  | { command: 'tick'; sub: 'start'; dataDir: string; specPath: string; scheduleId: string;
@@ -76,10 +79,10 @@ const USAGE = [
76
79
  'flows add <helper-name|@flows/helper-name>',
77
80
  'flows build [--out <dir>] <flow.yaml|flow.ts>',
78
81
  '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]',
82
+ 'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--no-connect] [--json]',
80
83
  'flows deployments [--json]',
81
84
  '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]',
85
+ '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
86
  'flows schedules [--json]',
84
87
  'flows unschedule [--json] <schedule-id>',
85
88
  'flows deploy <flow>@sha256:<digest> --to <file-bucket-uri>',
@@ -87,12 +90,13 @@ const USAGE = [
87
90
  'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
88
91
  'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
89
92
  '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>',
93
+ 'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
94
+ 'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
92
95
  'flows sync [--json] [--dir <path>] <run-id>',
93
96
  'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
94
97
  '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
98
  'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
99
+ 'flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>',
96
100
  'flows replay [--allow-human-influenced] [--json] [--data-dir <dir>] <run-id> [--at <step-id>]',
97
101
  'flows observer [--data-dir <dir>]',
98
102
  'flows hn-monitor start [--data-dir <dir>] [--poll-interval-ms <n>] <spec.json>',
@@ -142,6 +146,15 @@ export async function runCli(
142
146
  if (parsed.command === 'schedules') return runCloudSchedulesCli(parsed, io);
143
147
  if (parsed.command === 'unschedule') return runCloudUnscheduleCli(parsed, io);
144
148
  if (parsed.command === 'replay') return replayJournal(parsed, io);
149
+ if (parsed.command === 'answer') {
150
+ const execution = await answerFlow(parsed.runId, parsed.waitId, parsed.answer, parsed.dataDir, {
151
+ ...(parsed.note === undefined ? {} : { note: parsed.note }),
152
+ ...(parsed.by === undefined ? {} : { answeredBy: parsed.by }),
153
+ daemon: { spawn: parsed.spawn && spawnAllowedByEnv() },
154
+ });
155
+ emitRunReport(execution, parsed.json, io);
156
+ return execution.exitCode;
157
+ }
145
158
  if (parsed.command === 'build') return runBuild(parsed, io);
146
159
  if (parsed.command === 'deploy') return runDeploy(parsed, io);
147
160
 
@@ -277,6 +290,9 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR
277
290
  report: {
278
291
  ...mcp.report,
279
292
  ...(triggers?.report.schedules === undefined ? {} : { schedules: triggers.report.schedules }),
293
+ // The authored definition sees helper flags, body use and `cli:`
294
+ // declarations; the compiled view underneath knows only its steps.
295
+ ...(triggers?.report.requirements === undefined ? {} : { requirements: triggers.report.requirements }),
280
296
  diagnostics: [...helper.report.diagnostics, ...mcp.report.diagnostics, ...triggerDiagnostics],
281
297
  ok: helper.report.ok && mcp.report.ok && triggerOk,
282
298
  },
@@ -479,6 +495,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
479
495
  if (command === 'tick') return parseTickArgs(args.slice(1));
480
496
  if (command === 'observer') return parseObserverArgs(args.slice(1));
481
497
  if (command === 'sync') return parseSyncArgs(args.slice(1));
498
+ if (command === 'answer') return parseAnswerArgs(args.slice(1));
482
499
  if (command !== 'check' && command !== 'run' && command !== 'resume') return undefined;
483
500
 
484
501
  let json = false;
@@ -486,6 +503,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
486
503
  let cloud = false;
487
504
  let wait = false;
488
505
  let syncCode = false;
506
+ let noConnect = false;
489
507
  let localAgent = false;
490
508
  let allowHumanInfluenced = false;
491
509
  let dataDir = DEFAULT_DATA_DIR;
@@ -499,11 +517,13 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
499
517
  const positionals: string[] = [];
500
518
  for (let index = 1; index < args.length; index += 1) {
501
519
  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;
520
+ if (argument === '--cloud' || argument === '--wait' || argument === '--sync-code' || argument === '--no-connect') {
521
+ if (command !== 'run') return undefined;
522
+ if (argument === '--cloud' ? cloud : argument === '--wait' ? wait : argument === '--sync-code' ? syncCode : noConnect) return undefined;
504
523
  if (argument === '--cloud') cloud = true;
505
524
  else if (argument === '--wait') wait = true;
506
- else syncCode = true;
525
+ else if (argument === '--sync-code') syncCode = true;
526
+ else noConnect = true;
507
527
  continue;
508
528
  }
509
529
  if (argument === '--allow-human-influenced') {
@@ -583,9 +603,9 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
583
603
  // the source, so it is accepted exactly where a local run accepts it.
584
604
  if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
585
605
  if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
586
- return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode };
606
+ return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect };
587
607
  }
588
- if (wait || syncCode) return undefined;
608
+ if (wait || syncCode || noConnect) return undefined;
589
609
  if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
590
610
 
591
611
  if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
@@ -596,6 +616,66 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
596
616
  : { command, localAgent, dataDir, json, spawn, noObserverLink, allowHumanInfluenced, value: positionals[0]! };
597
617
  }
598
618
 
619
+ /**
620
+ * `flows answer [--json] [--no-spawn] [--data-dir <dir>] [--note <text>] [--by <identity>] <run-id> <wait-id> <yes|no>`.
621
+ * The answer is a literal `yes`/`no` (also `true`/`false`) so a shell cannot
622
+ * hand the kernel an ambiguous word as a decision. `--by` records who answered
623
+ * when the invoker is relaying a person's decision (Cloud's answer route runs
624
+ * this inside the resumed sandbox with the caller's identity); it defaults to
625
+ * the OS user.
626
+ */
627
+ function parseAnswerArgs(rest: readonly string[]): ParsedArgs | undefined {
628
+ let json = false;
629
+ let spawn = true;
630
+ let dataDir = DEFAULT_DATA_DIR;
631
+ let sawDataDir = false;
632
+ let note: string | undefined;
633
+ let by: string | undefined;
634
+ const positionals: string[] = [];
635
+ for (let index = 0; index < rest.length; index += 1) {
636
+ const argument = rest[index]!;
637
+ if (argument === '--json') {
638
+ if (json) return undefined;
639
+ json = true;
640
+ continue;
641
+ }
642
+ if (argument === '--no-spawn') {
643
+ if (!spawn) return undefined;
644
+ spawn = false;
645
+ continue;
646
+ }
647
+ if (argument === '--by') {
648
+ const value = rest[index + 1];
649
+ if (by !== undefined || value === undefined || value.startsWith('-') || value.trim() === '') return undefined;
650
+ by = value;
651
+ index += 1;
652
+ continue;
653
+ }
654
+ if (argument === '--data-dir') {
655
+ const value = rest[index + 1];
656
+ if (sawDataDir || value === undefined || value.startsWith('-')) return undefined;
657
+ dataDir = value;
658
+ sawDataDir = true;
659
+ index += 1;
660
+ continue;
661
+ }
662
+ if (argument === '--note') {
663
+ const value = rest[index + 1];
664
+ if (note !== undefined || value === undefined || value.startsWith('--')) return undefined;
665
+ note = value;
666
+ index += 1;
667
+ continue;
668
+ }
669
+ if (argument.startsWith('-')) return undefined;
670
+ positionals.push(argument);
671
+ }
672
+ if (positionals.length !== 3) return undefined;
673
+ const [runId, waitId, word] = positionals as [string, string, string];
674
+ const answer = word === 'yes' || word === 'true' ? true : word === 'no' || word === 'false' ? false : undefined;
675
+ if (answer === undefined) return undefined;
676
+ return { command: 'answer', dataDir, json, spawn, note, by, runId, waitId, answer };
677
+ }
678
+
599
679
  function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
600
680
  const sub = rest[0];
601
681
  if (sub !== 'start') return undefined;
@@ -790,6 +870,10 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
790
870
  const model = resolution.model === undefined ? '' : ` model "${resolution.model}"`;
791
871
  io.stdout(`RESOLVED step "${resolution.stepId}" cli "${resolution.cli}"${model} from ${resolution.source}${config}`);
792
872
  }
873
+ // What the workspace must have connected before this flow can run there;
874
+ // the hosted verbs check the same list against Cloud before submitting.
875
+ const requires = report.requirements === undefined ? '' : describeFlowRequirements(report.requirements);
876
+ if (requires) io.stdout(`REQUIRES ${requires}`);
793
877
  if (report.ok) io.stdout(`CHECK PASSED ${report.path ?? ''}`.trimEnd());
794
878
  }
795
879
 
@@ -810,6 +894,15 @@ function emitRunReport(
810
894
  return;
811
895
  }
812
896
  if (report.runId === undefined) return;
897
+ if (report.answer !== undefined) {
898
+ io.stdout(`ANSWERED ${report.runId} ${report.answer.waitId} ${report.answer.answer ? 'yes' : 'no'}`
899
+ + (report.answer.note === undefined ? '' : ` (${report.answer.note})`));
900
+ if (report.next !== undefined) io.stdout(`Continue with: ${report.next}`);
901
+ return;
902
+ }
903
+ // A refused answer changed nothing about the run, so there is no run
904
+ // outcome to summarize; the refusal above is the whole report.
905
+ if (report.command === 'answer') return;
813
906
  const completed = report.completedSteps === undefined ? '' : ` (${report.completedSteps} ${report.completedSteps === 1 ? 'step' : 'steps'})`;
814
907
  const reason = report.completionReason === undefined
815
908
  ? ''
@@ -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
+ }
@@ -1,9 +1,13 @@
1
1
  import { randomBytes, createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
3
4
  import { loadAuthoredFlow } from './authored-flow-loader.js';
5
+ import { ensureIntegrationsConnected, type ConnectPrompt } from './cloud-connect.js';
4
6
  import {
5
7
  CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions,
6
8
  } from './cloud-http.js';
9
+ import { flowRequirements, type FlowRequirements } from './flow-requirements.js';
10
+ import { readProjectConfig } from './cli/check.js';
7
11
 
8
12
  /**
9
13
  * Hosted listener deployment: the CLI form of the agentrelay.com onboarding's
@@ -47,10 +51,22 @@ export interface DeployToCloudInput {
47
51
  approver: string;
48
52
  /** Defaults to the flow's declared name. */
49
53
  name?: string;
50
- /** Coding-agent harnesses the flow uses; Cloud checks their credentials are connected. Default `["claude"]`. */
54
+ /**
55
+ * Coding-agent harnesses the flow uses; Cloud checks their credentials are
56
+ * connected. Defaults to the `cli:` declarations the source carries
57
+ * (`flowRequirements`), else `["claude"]`.
58
+ */
51
59
  agents?: FlowAgentHarness[];
52
60
  /** Save without activating: no connection checks, no listener until activated. */
53
61
  draft?: boolean;
62
+ /**
63
+ * How a missing integration is connected before activation. Omitted, a
64
+ * missing one refuses with `integration_not_connected` (the `--no-connect`
65
+ * and non-interactive behaviour); given, each is offered a browser connect.
66
+ */
67
+ connect?: ConnectPrompt;
68
+ /** Skip the pre-submission integration check entirely (Cloud still checks on activation). */
69
+ checkConnections?: boolean;
54
70
  }
55
71
 
56
72
  export const FLOW_AGENT_HARNESSES = ['claude', 'codex'] as const;
@@ -72,6 +88,10 @@ export interface CloudDeployment {
72
88
  repository: { owner: string; name: string };
73
89
  sources: FlowTriggerSource[];
74
90
  sourceSha256: string;
91
+ /** What the source declared it needs; the harnesses became `inputs.agents` unless `agents` was given. */
92
+ requirements: FlowRequirements;
93
+ /** Integrations connected through the prompt during this deploy. */
94
+ connected: string[];
75
95
  }
76
96
 
77
97
  export function parseRepository(value: string): { owner: string; name: string } {
@@ -154,6 +174,12 @@ export async function deployToCloud(
154
174
  throw new CloudFlowError('unsupported_source',
155
175
  'Cloud deploys one self-contained .flow.ts source without use dependencies.');
156
176
  }
177
+ let projectCli: string | undefined;
178
+ try {
179
+ projectCli = readProjectConfig(dirname(resolve(input.path))).cli;
180
+ } catch {
181
+ projectCli = undefined;
182
+ }
157
183
  if (input.sources.length === 0 || input.sources.length > 10) {
158
184
  throw new CloudFlowError('invalid_input', 'Give between one and ten --on trigger sources.');
159
185
  }
@@ -176,7 +202,30 @@ export async function deployToCloud(
176
202
  if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) {
177
203
  throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.');
178
204
  }
179
- const agents = input.agents ?? ['claude'];
205
+ // Every launched run lands in the deployment's repository, so GitHub is
206
+ // required even when no GitHub source wakes it.
207
+ const requirements = flowRequirements(definition, {
208
+ sources, repository: input.repository, ...(projectCli === undefined ? {} : { projectCli }),
209
+ });
210
+ // The declared harnesses become `inputs.agents`; one Cloud cannot run is
211
+ // refused here rather than silently replaced by Claude, which activation
212
+ // would then check while the deployed runs still call the declared CLI.
213
+ const unsupported = requirements.harnesses.filter(harness => !(FLOW_AGENT_HARNESSES as readonly string[]).includes(harness));
214
+ if (input.agents === undefined && unsupported.length > 0) {
215
+ const uses = requirements.harnessUses.filter(use => unsupported.includes(use.harness));
216
+ throw new CloudFlowError('unsupported_source',
217
+ `This flow declares ${uses.map(use => `${use.harness} (${use.detail})`).join(', ')}, which Cloud deployments cannot run yet; `
218
+ + `Cloud runs ${FLOW_AGENT_HARNESSES.join(' and ')}. Change the declaration, or pass --agents to deploy it anyway.`);
219
+ }
220
+ const declaredAgents = requirements.harnesses.filter((harness): harness is FlowAgentHarness => !unsupported.includes(harness));
221
+ const agents = input.agents ?? (declaredAgents.length > 0 ? declaredAgents : ['claude']);
222
+ // A draft activates nothing, so Cloud checks nothing; match it here.
223
+ const connected = input.draft || input.checkConnections === false
224
+ ? []
225
+ : (await ensureIntegrationsConnected(requirements, {
226
+ ...options, workspaceId: workspace.id, ...(input.connect === undefined ? {} : { prompt: input.connect }),
227
+ })).connected;
228
+ options.signal?.throwIfAborted();
180
229
  const result = await cloudFetch('/api/v1/flows/deploy', options, { method: 'POST', detail: true, body: JSON.stringify({
181
230
  workspaceId: workspace.id,
182
231
  mode: input.draft ? 'draft' : 'activate',
@@ -188,6 +237,11 @@ export async function deployToCloud(
188
237
  inputs: { approver, agents },
189
238
  repository: input.repository,
190
239
  sources,
240
+ requirements: {
241
+ integrations: requirements.integrations.map(i => i.provider),
242
+ harnesses: requirements.harnesses,
243
+ mcp: requirements.mcp,
244
+ },
191
245
  }) });
192
246
  if (!isCloudRecord(result) || typeof result.agentId !== 'string' || typeof result.status !== 'string') {
193
247
  throw new CloudFlowError('invalid_response', 'Cloud did not return a deployment.');
@@ -196,6 +250,7 @@ export async function deployToCloud(
196
250
  agentId: result.agentId, name, status: result.status,
197
251
  repository: input.repository, sources,
198
252
  sourceSha256: createHash('sha256').update(bytes).digest('hex'),
253
+ requirements, connected,
199
254
  };
200
255
  }
201
256
 
package/src/cloud-http.ts CHANGED
@@ -25,7 +25,7 @@ export class CloudFlowError extends Error {
25
25
  constructor(
26
26
  readonly code: 'configuration' | 'unsupported_source' | 'invalid_input' | 'invalid_response' | 'http_error'
27
27
  | 'transport_error' | 'transient_error' | 'unsupported_storage_backend' | 'sync_too_large' | 'sync_unsupported'
28
- | 'patch_conflict',
28
+ | 'patch_conflict' | 'integration_not_connected',
29
29
  message: string,
30
30
  readonly status?: number,
31
31
  readonly refusal?: CloudRefusal,
@@ -69,12 +69,29 @@ function assertTimeZone(tz: string): string {
69
69
  return tz;
70
70
  }
71
71
 
72
- export async function scheduleInCloud(
73
- input: ScheduleInCloudInput, options: CloudConnectionOptions = {},
74
- ): Promise<CloudSchedule> {
72
+ /**
73
+ * The argument checks `scheduleInCloud` makes before it reads the flow, so a
74
+ * caller can refuse a bad cron, interval or zone before any side effect (the
75
+ * CLI connects integrations in between). Declared-schedule resolution needs
76
+ * the flow and stays in `scheduleInCloud`.
77
+ */
78
+ export function validateScheduleArgs(input: Pick<ScheduleInCloudInput, 'cron' | 'every' | 'tz'>): void {
75
79
  if (input.cron !== undefined && input.every !== undefined) {
76
80
  throw new CloudFlowError('invalid_input', 'Give --cron or --every, not both.');
77
81
  }
82
+ if (input.cron !== undefined) {
83
+ try { parseCron(input.cron); } catch (error) {
84
+ throw new CloudFlowError('invalid_input', `--cron: ${error instanceof Error ? error.message : String(error)}`);
85
+ }
86
+ }
87
+ if (input.every !== undefined) everyToCron(input.every);
88
+ if (input.tz !== undefined) assertTimeZone(input.tz);
89
+ }
90
+
91
+ export async function scheduleInCloud(
92
+ input: ScheduleInCloudInput, options: CloudConnectionOptions = {},
93
+ ): Promise<CloudSchedule> {
94
+ validateScheduleArgs(input);
78
95
  const submission = await prepareCloudSubmission(input.flow, {
79
96
  ...(Object.prototype.hasOwnProperty.call(input, 'input') ? { input: input.input } : {}),
80
97
  ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -113,6 +113,8 @@ export const RUN_FAILURE_KINDS = [
113
113
  'run_unavailable',
114
114
  /** A predicate `.gate(fn)` judged false; the verdict is journaled as `<step>.gate`. */
115
115
  'gate_failed',
116
+ /** `flows answer` named a wait the run is not asking: unknown, or already answered. */
117
+ 'human_wait_unknown',
116
118
  ] as const;
117
119
 
118
120
  /**