@relayflows/sdk 2.0.14 → 2.0.16

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 (98) hide show
  1. package/dist/agent-artifacts.d.ts +20 -0
  2. package/dist/agent-artifacts.d.ts.map +1 -0
  3. package/dist/agent-artifacts.js +81 -0
  4. package/dist/agent-artifacts.js.map +1 -0
  5. package/dist/authored-flow-executor.d.ts +33 -3
  6. package/dist/authored-flow-executor.d.ts.map +1 -1
  7. package/dist/authored-flow-executor.js +56 -10
  8. package/dist/authored-flow-executor.js.map +1 -1
  9. package/dist/authored-node-runner.d.ts.map +1 -1
  10. package/dist/authored-node-runner.js +11 -4
  11. package/dist/authored-node-runner.js.map +1 -1
  12. package/dist/authored-root.d.ts.map +1 -1
  13. package/dist/authored-root.js +2 -2
  14. package/dist/authored-root.js.map +1 -1
  15. package/dist/authored-step-output.d.ts +2 -0
  16. package/dist/authored-step-output.d.ts.map +1 -1
  17. package/dist/authored-step-output.js +5 -1
  18. package/dist/authored-step-output.js.map +1 -1
  19. package/dist/authored-worker-step.d.ts.map +1 -1
  20. package/dist/authored-worker-step.js +19 -1
  21. package/dist/authored-worker-step.js.map +1 -1
  22. package/dist/cli/check-triggers.d.ts.map +1 -1
  23. package/dist/cli/check-triggers.js +14 -3
  24. package/dist/cli/check-triggers.js.map +1 -1
  25. package/dist/cli/check.js +6 -1
  26. package/dist/cli/check.js.map +1 -1
  27. package/dist/cli/cloud-deploy.d.ts +30 -0
  28. package/dist/cli/cloud-deploy.d.ts.map +1 -0
  29. package/dist/cli/cloud-deploy.js +162 -0
  30. package/dist/cli/cloud-deploy.js.map +1 -0
  31. package/dist/cli/cloud-run.d.ts +3 -1
  32. package/dist/cli/cloud-run.d.ts.map +1 -1
  33. package/dist/cli/cloud-run.js +37 -5
  34. package/dist/cli/cloud-run.js.map +1 -1
  35. package/dist/cli/cloud-sync.d.ts +13 -0
  36. package/dist/cli/cloud-sync.d.ts.map +1 -0
  37. package/dist/cli/cloud-sync.js +39 -0
  38. package/dist/cli/cloud-sync.js.map +1 -0
  39. package/dist/cli/direct-run.d.ts.map +1 -1
  40. package/dist/cli/direct-run.js +2 -26
  41. package/dist/cli/direct-run.js.map +1 -1
  42. package/dist/cli/run.d.ts +20 -1
  43. package/dist/cli/run.d.ts.map +1 -1
  44. package/dist/cli/run.js +77 -15
  45. package/dist/cli/run.js.map +1 -1
  46. package/dist/cli.d.ts.map +1 -1
  47. package/dist/cli.js +82 -12
  48. package/dist/cli.js.map +1 -1
  49. package/dist/cloud-deploy.d.ts +70 -0
  50. package/dist/cloud-deploy.d.ts.map +1 -0
  51. package/dist/cloud-deploy.js +181 -0
  52. package/dist/cloud-deploy.js.map +1 -0
  53. package/dist/cloud-http.d.ts +37 -2
  54. package/dist/cloud-http.d.ts.map +1 -1
  55. package/dist/cloud-http.js +98 -8
  56. package/dist/cloud-http.js.map +1 -1
  57. package/dist/cloud-run.d.ts +23 -0
  58. package/dist/cloud-run.d.ts.map +1 -1
  59. package/dist/cloud-run.js +30 -1
  60. package/dist/cloud-run.js.map +1 -1
  61. package/dist/cloud-sync.d.ts +66 -0
  62. package/dist/cloud-sync.d.ts.map +1 -0
  63. package/dist/cloud-sync.js +287 -0
  64. package/dist/cloud-sync.js.map +1 -0
  65. package/dist/failure-kinds.d.ts +8 -1
  66. package/dist/failure-kinds.d.ts.map +1 -1
  67. package/dist/failure-kinds.js +8 -0
  68. package/dist/failure-kinds.js.map +1 -1
  69. package/dist/index.d.ts +2 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +2 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/provider-trigger-contract.d.ts +27 -0
  74. package/dist/provider-trigger-contract.d.ts.map +1 -0
  75. package/dist/provider-trigger-contract.js +0 -0
  76. package/dist/provider-trigger-contract.js.map +1 -0
  77. package/package.json +2 -2
  78. package/src/agent-artifacts.ts +76 -0
  79. package/src/authored-flow-executor.ts +77 -15
  80. package/src/authored-node-runner.ts +11 -4
  81. package/src/authored-root.ts +2 -2
  82. package/src/authored-step-output.ts +6 -1
  83. package/src/authored-worker-step.ts +19 -1
  84. package/src/cli/check-triggers.ts +14 -5
  85. package/src/cli/check.ts +6 -1
  86. package/src/cli/cloud-deploy.ts +157 -0
  87. package/src/cli/cloud-run.ts +37 -6
  88. package/src/cli/cloud-sync.ts +37 -0
  89. package/src/cli/direct-run.ts +2 -25
  90. package/src/cli/run.ts +86 -16
  91. package/src/cli.ts +75 -12
  92. package/src/cloud-deploy.ts +236 -0
  93. package/src/cloud-http.ts +129 -9
  94. package/src/cloud-run.ts +47 -1
  95. package/src/cloud-sync.ts +315 -0
  96. package/src/failure-kinds.ts +8 -0
  97. package/src/index.ts +8 -0
  98. package/src/provider-trigger-contract.ts +0 -0
@@ -0,0 +1,157 @@
1
+ import { CloudFlowError } from '../cloud-http.js';
2
+ import {
3
+ deployToCloud, listCloudDeployments, parseAgentHarnesses, parseRepository, parseTriggerSource, undeployFromCloud,
4
+ type FlowTriggerSource,
5
+ } from '../cloud-deploy.js';
6
+ import type { CliIo } from '../cli.js';
7
+
8
+ export interface CloudDeployArgs {
9
+ command: 'cloud-deploy';
10
+ value: string;
11
+ repo: string;
12
+ on: string[];
13
+ approver: string | undefined;
14
+ name: string | undefined;
15
+ agents: string | undefined;
16
+ draft: boolean;
17
+ json: boolean;
18
+ }
19
+
20
+ /**
21
+ * `flows deploy <flow.ts> --repo <owner/name> --on <provider>[:k=v,…] [--on …]
22
+ * --approver <handle> [--name <n>] [--json]`
23
+ *
24
+ * Parsed here rather than in `parseDeployArgs` because the two `deploy` forms
25
+ * share nothing but the word: the digest form copies a sealed bundle into a
26
+ * file bucket, this one creates a hosted listener. The positional decides.
27
+ */
28
+ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs | undefined {
29
+ let value: string | undefined;
30
+ let repo: string | undefined;
31
+ let approver: string | undefined;
32
+ let name: string | undefined;
33
+ let agents: string | undefined;
34
+ let draft = false;
35
+ let json = false;
36
+ const on: string[] = [];
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = args[i]!;
39
+ if (arg === '--json') {
40
+ if (json) return undefined;
41
+ json = true;
42
+ continue;
43
+ }
44
+ if (arg === '--draft') {
45
+ if (draft) return undefined;
46
+ draft = true;
47
+ continue;
48
+ }
49
+ if (arg === '--agents') {
50
+ const next = args[i + 1];
51
+ if (agents !== undefined || next === undefined || next.startsWith('-')) return undefined;
52
+ agents = next;
53
+ i += 1;
54
+ continue;
55
+ }
56
+ if (arg === '--repo' || arg === '--approver' || arg === '--name' || arg === '--on') {
57
+ const next = args[i + 1];
58
+ if (next === undefined || next.startsWith('-')) return undefined;
59
+ i += 1;
60
+ if (arg === '--on') { on.push(next); continue; }
61
+ if (arg === '--repo') { if (repo !== undefined) return undefined; repo = next; continue; }
62
+ if (arg === '--approver') { if (approver !== undefined) return undefined; approver = next; continue; }
63
+ if (name !== undefined) return undefined;
64
+ name = next;
65
+ continue;
66
+ }
67
+ if (arg.startsWith('-') || value !== undefined) return undefined;
68
+ value = arg;
69
+ }
70
+ if (value === undefined || repo === undefined || on.length === 0) return undefined;
71
+ return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, json };
72
+ }
73
+
74
+ function describeSource(source: FlowTriggerSource): string {
75
+ const settings = Object.entries(source.settings).map(([k, v]) => `${k}=${v}`).join(' ');
76
+ return settings ? `${source.provider} ${settings}` : source.provider;
77
+ }
78
+
79
+ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promise<0 | 1 | 2> {
80
+ try {
81
+ if (args.approver === undefined) {
82
+ throw new CloudFlowError('invalid_input',
83
+ '--approver <handle> is required: every launched run receives it as input.approver for f.human.');
84
+ }
85
+ const deployment = await deployToCloud({
86
+ path: args.value,
87
+ repository: parseRepository(args.repo),
88
+ sources: args.on.map(parseTriggerSource),
89
+ approver: args.approver,
90
+ draft: args.draft,
91
+ ...(args.name === undefined ? {} : { name: args.name }),
92
+ ...(args.agents === undefined ? {} : { agents: parseAgentHarnesses(args.agents) }),
93
+ });
94
+ if (args.json) {
95
+ io.stdout(JSON.stringify({ ok: true, ...deployment }));
96
+ return 0;
97
+ }
98
+ io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`);
99
+ io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
100
+ io.stdout(` repository: ${deployment.repository.owner}/${deployment.repository.name}`);
101
+ for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
102
+ io.stdout(deployment.status === 'draft'
103
+ ? 'Saved without activating; activate it from the Cloud dashboard, or redeploy without --draft.'
104
+ : 'Each matching ticket launches a run of this source in a fresh branch; list with: flows deployments');
105
+ return 0;
106
+ } catch (error) {
107
+ return reportCloudFailure(error, args.json, io);
108
+ }
109
+ }
110
+
111
+ export async function runCloudDeploymentsCli({ json }: { json: boolean }, io: CliIo): Promise<0 | 1 | 2> {
112
+ try {
113
+ const deployments = await listCloudDeployments();
114
+ if (json) {
115
+ io.stdout(JSON.stringify({ ok: true, deployments }));
116
+ return 0;
117
+ }
118
+ if (deployments.length === 0) {
119
+ io.stdout('No flow deployments in this workspace.');
120
+ return 0;
121
+ }
122
+ for (const d of deployments) {
123
+ const repo = d.repository ? ` ${d.repository.owner}/${d.repository.name}` : '';
124
+ io.stdout(`${d.agentId} ${d.status} ${JSON.stringify(d.name)}${repo}`);
125
+ for (const source of d.sources) io.stdout(` on: ${describeSource(source)}`);
126
+ }
127
+ return 0;
128
+ } catch (error) {
129
+ return reportCloudFailure(error, json, io);
130
+ }
131
+ }
132
+
133
+ export async function runCloudUndeployCli({ agentId, json }: { agentId: string; json: boolean }, io: CliIo): Promise<0 | 1 | 2> {
134
+ try {
135
+ await undeployFromCloud(agentId);
136
+ io.stdout(json ? JSON.stringify({ ok: true, agentId, status: 'deleted' }) : `UNDEPLOYED ${agentId}`);
137
+ return 0;
138
+ } catch (error) {
139
+ return reportCloudFailure(error, json, io);
140
+ }
141
+ }
142
+
143
+ function reportCloudFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
144
+ const code = error instanceof CloudFlowError ? error.code : 'cloud_deploy_failed';
145
+ let message = error instanceof Error ? error.message : 'Cloud deploy failed.';
146
+ // The deploy routes take a browser session or a `cli:auth` token. A
147
+ // deployment (CI) token gets 403 `session_required`; say what fixes it.
148
+ if (error instanceof CloudFlowError && error.status === 403) {
149
+ message += ' Deploying needs an interactive `cli:auth` credential: run `agent-relay cloud login` '
150
+ + '(a deployment token can run flows but not deploy them).';
151
+ }
152
+ if (json) io.stdout(JSON.stringify({ ok: false, code, message }));
153
+ else io.stderr(`${code}: ${message}`);
154
+ return error instanceof CloudFlowError
155
+ && (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code) || error.status === 403 || error.status === 401)
156
+ ? 2 : 1;
157
+ }
@@ -1,10 +1,14 @@
1
1
  import { CloudFlowError } from '../cloud-http.js';
2
- import { runInCloud, waitForCloudFlowRun } from '../cloud-run.js';
2
+ import { runInCloud, waitForCloudFlowRun, type RunInCloudOptions } from '../cloud-run.js';
3
+ import { DirectInputError, isAuthoredFlowPath, parseDirectInput } from '../direct-input.js';
4
+ import { snapshotJsonValue } from '../json-value.js';
3
5
  import type { CliIo } from '../cli.js';
4
6
 
5
7
  /** Presentation only: the central CLI parser owns argv; the SDK owns the lifecycle. */
6
8
  export async function runCloudCli(
7
- { value: path, json, wait }: { value: string; json: boolean; wait: boolean },
9
+ { value: path, json, wait, input, syncCode }: {
10
+ value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean;
11
+ },
8
12
  io: CliIo,
9
13
  ): Promise<0 | 1 | 2> {
10
14
  const controller = new AbortController();
@@ -12,12 +16,36 @@ export async function runCloudCli(
12
16
  process.once('SIGINT', abort);
13
17
  process.once('SIGTERM', abort);
14
18
  let runId: string | undefined;
19
+ // Flips at the run submission. An interruption before it — during prepare,
20
+ // packing or upload — admitted nothing and is safe to retry; only an
21
+ // interrupted submission has unknown admission.
22
+ let submitting = false;
15
23
  try {
16
- const receipt = await runInCloud({ path }, { signal: controller.signal });
24
+ const options: RunInCloudOptions = { signal: controller.signal, onSubmit: () => { submitting = true; } };
25
+ if (isAuthoredFlowPath(path)) {
26
+ // Same parse as a local direct run, so a file-or-inline argument means
27
+ // the same thing on both sides of `--cloud`.
28
+ try {
29
+ options.input = snapshotJsonValue(parseDirectInput(input), 'Cloud authored input');
30
+ } catch (error) {
31
+ if (error instanceof DirectInputError) throw new CloudFlowError('invalid_input', error.message);
32
+ throw error;
33
+ }
34
+ }
35
+ // The tree is the invoking directory, as with v1: the flow path is where
36
+ // the body lives, not the boundary of what the run may read.
37
+ if (syncCode) options.syncCode = { root: process.cwd() };
38
+ const receipt = await runInCloud({ path }, options);
17
39
  runId = receipt.runId;
18
40
  if (!json) {
19
41
  io.stdout(`ACCEPTED ${receipt.runId} (${receipt.status})`);
20
42
  io.stdout(receipt.apiUrl);
43
+ if (receipt.synced) {
44
+ io.stdout(`SYNCED ${receipt.synced.files} files (${receipt.synced.bytes} bytes); pull changes with: flows sync ${receipt.runId}`);
45
+ for (const link of receipt.synced.skippedLinks) {
46
+ io.stderr(`WARNING [sync_link_skipped] ${link} points outside the synced tree and was not uploaded`);
47
+ }
48
+ }
21
49
  }
22
50
  if (!wait) {
23
51
  if (json) io.stdout(JSON.stringify({ ok: true, ...receipt }));
@@ -29,16 +57,19 @@ export async function runCloudCli(
29
57
  else io.stdout(`${run.status.toUpperCase()} ${run.runId} completionReason: ${'completionReason' in run ? run.completionReason : 'unavailable'}`);
30
58
  return ok ? 0 : 1;
31
59
  } catch (error) {
32
- const code = controller.signal.aborted ? (runId ? 'observation_aborted' : 'admission_unknown')
60
+ const code = controller.signal.aborted
61
+ ? runId ? 'observation_aborted' : submitting ? 'admission_unknown' : 'submission_aborted'
33
62
  : error instanceof CloudFlowError ? error.code : 'cloud_run_failed';
34
63
  const message = controller.signal.aborted
35
64
  ? runId ? 'Stopped observing; the hosted run has not been cancelled.'
36
- : 'Submission interrupted before a receipt was received. Admission is unknown; Cloud may have started the run. Do not resubmit blindly.'
65
+ : submitting
66
+ ? 'Submission interrupted before a receipt was received. Admission is unknown; Cloud may have started the run. Do not resubmit blindly.'
67
+ : 'Interrupted before the run was submitted; nothing was admitted. Safe to run again.'
37
68
  : error instanceof Error ? error.message : 'Cloud run failed.';
38
69
  if (json) io.stdout(JSON.stringify({ ok: false, code, message, ...(runId ? { runId } : {}) }));
39
70
  else io.stderr(`${code}: ${message}${runId ? ` (run ${runId})` : ''}`);
40
71
  return error instanceof CloudFlowError
41
- && (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code)
72
+ && (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported'].includes(error.code)
42
73
  || (runId === undefined && error.code === 'http_error' && [401, 403].includes(error.status ?? 0))) ? 2 : 1;
43
74
  } finally {
44
75
  process.off('SIGINT', abort);
@@ -0,0 +1,37 @@
1
+ import { CloudFlowError } from '../cloud-http.js';
2
+ import { applyCloudPatch, downloadCloudPatch, patchedPaths } from '../cloud-sync.js';
3
+ import type { CliIo } from '../cli.js';
4
+
5
+ /**
6
+ * `flows sync <run-id>`: fetch the diff a hosted run left in its synced tree
7
+ * and apply it here. The patch is the sandbox's own `git diff` against the
8
+ * uploaded baseline, so applying it reproduces exactly what the run's steps
9
+ * wrote — no re-execution, no re-upload.
10
+ */
11
+ export async function runCloudSyncCli(
12
+ { runId, json, root }: { runId: string; json: boolean; root: string },
13
+ io: CliIo,
14
+ ): Promise<0 | 1 | 2> {
15
+ try {
16
+ const { patch, hasChanges } = await downloadCloudPatch(runId, {});
17
+ if (!hasChanges || !patch.trim()) {
18
+ if (json) io.stdout(JSON.stringify({ ok: true, runId, hasChanges: false, applied: false }));
19
+ else io.stdout(`NO CHANGES ${runId}`);
20
+ return 0;
21
+ }
22
+ applyCloudPatch(root, patch);
23
+ const files = patchedPaths(patch);
24
+ if (json) io.stdout(JSON.stringify({ ok: true, runId, hasChanges: true, applied: true, files }));
25
+ else {
26
+ io.stdout(`APPLIED ${runId}: ${files.length} file${files.length === 1 ? '' : 's'}${files.length ? `\n ${files.join('\n ')}` : ''}`);
27
+ io.stdout('Applied to the working tree, uncommitted: review with git diff before keeping it.');
28
+ }
29
+ return 0;
30
+ } catch (error) {
31
+ const code = error instanceof CloudFlowError ? error.code : 'cloud_sync_failed';
32
+ const message = error instanceof Error ? error.message : 'Cloud sync failed.';
33
+ if (json) io.stdout(JSON.stringify({ ok: false, code, message, runId }));
34
+ else io.stderr(`${code}: ${message} (run ${runId})`);
35
+ return error instanceof CloudFlowError && ['configuration', 'sync_unsupported', 'patch_conflict'].includes(error.code) ? 2 : 1;
36
+ }
37
+ }
@@ -14,6 +14,7 @@ import { JournalClient } from '../journal-client.js';
14
14
  import { inputFailureReport } from './check.js';
15
15
  import { checkAuthoredTriggers } from './check-triggers.js';
16
16
  import {
17
+ authoredCompletion,
17
18
  authoredStepFailure,
18
19
  connect,
19
20
  emptyReport,
@@ -99,31 +100,7 @@ export async function runDirectFlow(
99
100
  `authored flow "${result.name}" completed without a journal step`,
100
101
  ));
101
102
  }
102
- if (result.completionReason === 'needs_human') {
103
- return {
104
- exitCode: 3,
105
- report: {
106
- ...base, ok: false, runId: result.rootRunId, socketPath, status: 'parked',
107
- completedSteps: result.journalSteps.length,
108
- diagnostics: [...base.diagnostics, {
109
- severity: 'parked', kind: 'run_parked',
110
- message: `Flow "${result.name}" needs_human; see the journal for accumulated blockers.`,
111
- }],
112
- },
113
- };
114
- }
115
- return {
116
- exitCode: 0,
117
- report: {
118
- ...base,
119
- ok: true,
120
- runId: result.rootRunId,
121
- socketPath,
122
- status: 'completed',
123
- completionReason: result.completionReason,
124
- completedSteps: result.journalSteps.length,
125
- },
126
- };
103
+ return authoredCompletion('run', base, socketPath, result, result.rootRunId);
127
104
  } catch (caught) {
128
105
  if (caught instanceof McpPreflightError) return {
129
106
  exitCode: 2, report: fromCheckReport('run', caught.report),
package/src/cli/run.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  RunStatus,
23
23
  } from '../protocol.js';
24
24
  import type { StepType } from '../spec.js';
25
+ import type { LoweredCompletionReason } from '../authored-flow-executor.js';
25
26
  import {
26
27
  checkFlow,
27
28
  type CheckReport,
@@ -36,7 +37,7 @@ export interface ParkedStep {
36
37
  }
37
38
 
38
39
  export interface RunDiagnostic extends StepFailedDetails {
39
- severity: 'refusal' | 'failure' | 'parked' | 'warning';
40
+ severity: 'refusal' | 'failure' | 'parked' | 'warning' | 'declined';
40
41
  kind: RunFailureKind | RunWarningKind | RunCompletionReason;
41
42
  message: string;
42
43
  }
@@ -191,21 +192,7 @@ export async function resumeFlow(
191
192
  lifecycle: options,
192
193
  });
193
194
  if (result === undefined) throw new Error('authored root disappeared during resume');
194
- return {
195
- exitCode: result.completionReason === 'needs_human' ? 3 : 0,
196
- report: {
197
- ...base,
198
- ok: result.completionReason === 'success',
199
- runId,
200
- socketPath,
201
- status: result.completionReason === 'needs_human' ? 'parked' : 'completed',
202
- ...(result.completionReason === 'success'
203
- ? { completionReason: 'success' as const }
204
- : { diagnostics: [{ severity: 'parked' as const, kind: 'run_parked' as const,
205
- message: `Flow "${result.name}" needs_human; see the journal for accumulated blockers.` }] }),
206
- completedSteps: result.journalSteps.length,
207
- },
208
- };
195
+ return authoredCompletion('resume', base, socketPath, result, runId);
209
196
  }
210
197
  // resumeHelperEffect subsumes the old resumeSlackEffect: it handles the
211
198
  // slack effect resume plus every other provider from N's codegen. The
@@ -309,6 +296,89 @@ export function authoredStepFailure(
309
296
  };
310
297
  }
311
298
 
299
+ /**
300
+ * An authored body that returned its own terminal verdict, reported as that verdict.
301
+ *
302
+ * Shared by `runDirectFlow` and `resumeFlow` for the reason `authoredStepFailure`
303
+ * is shared: the run and resume paths had already drifted once, and the
304
+ * `needs_human` report was duplicated verbatim in both files.
305
+ *
306
+ * This is NOT `authoredStepFailure`, even though a `step_failed` verdict lands
307
+ * on the same exit code and report shape. That function describes a step that
308
+ * ran and failed, and carries the failing step's evidence. Here every step
309
+ * succeeded and the BODY declared the outcome, so there is no failing step to
310
+ * name — routing this through the other helper would invent one.
311
+ */
312
+ export function authoredCompletion(
313
+ command: RunCommand,
314
+ base: RunReport,
315
+ socketPath: string,
316
+ result: { name: string; completionReason: LoweredCompletionReason; journalSteps: readonly unknown[] },
317
+ runId: string | undefined,
318
+ ): RunExecution {
319
+ const common: RunReport = {
320
+ ...fromBase(command, base),
321
+ ...(runId === undefined ? {} : { runId }),
322
+ socketPath,
323
+ completedSteps: result.journalSteps.length,
324
+ };
325
+ switch (result.completionReason) {
326
+ case 'success':
327
+ return {
328
+ exitCode: 0,
329
+ report: { ...common, ok: true, status: 'completed', completionReason: 'success' },
330
+ };
331
+ case 'declined':
332
+ return {
333
+ exitCode: 0,
334
+ report: {
335
+ ...common, ok: true, status: 'completed', completionReason: 'success',
336
+ diagnostics: [...base.diagnostics, {
337
+ severity: 'declined', kind: 'run_declined',
338
+ message: 'Flow deliberately chose not to act on this input.',
339
+ }],
340
+ },
341
+ };
342
+ case 'needs_human':
343
+ return {
344
+ exitCode: 3,
345
+ report: {
346
+ ...common, ok: false, status: 'parked',
347
+ diagnostics: [...base.diagnostics, {
348
+ severity: 'parked', kind: 'run_parked',
349
+ message: `Flow "${result.name}" needs_human; see the journal for accumulated blockers.`,
350
+ }],
351
+ },
352
+ };
353
+ case 'step_failed':
354
+ // A declared run failure. Exit 1, not the parked 3: nothing here is
355
+ // waiting for a human to recover it, and exit 3 is the local kit's
356
+ // manual-approval stop. `status: failed` with this `completionReason` is
357
+ // also the only terminal shape Cloud accepts for a non-success run
358
+ // (see cloud-run.ts).
359
+ return {
360
+ exitCode: 1,
361
+ report: {
362
+ ...common, ok: false, status: 'failed', completionReason: 'step_failed',
363
+ diagnostics: [...base.diagnostics, {
364
+ severity: 'failure', kind: 'step_failed',
365
+ message: `Flow "${result.name}" declared done("step_failed"): its own checks did not pass. `
366
+ + 'No step failed, so there is no step-level evidence to inspect; the journal holds '
367
+ + 'every step the flow ran before it decided.',
368
+ }],
369
+ },
370
+ };
371
+ }
372
+ // Exhaustive by construction. A new lowered completion has to choose its
373
+ // own exit code and wording here; it must not inherit "its own checks did not
374
+ // pass", which would state something the body never declared. Letting an
375
+ // unlisted reason fall through to the failure branch is how a reporting-side
376
+ // copy of the same vocabulary drifts from the executor's — the defect this
377
+ // change exists to close — so it is a compile error, not a wrong report.
378
+ const unreachable: never = result.completionReason;
379
+ throw new Error(`unreachable authored completion: ${String(unreachable)}`);
380
+ }
381
+
312
382
  /**
313
383
  * Get a live daemon, then open the socket to it.
314
384
  *
package/src/cli.ts CHANGED
@@ -24,6 +24,8 @@ import { runDirectFlow } from './cli/direct-run.js';
24
24
  import { parseReplayArgs, replayJournal, type ReplayArgs } from './cli/replay.js';
25
25
  import { checkTypeScriptFlow } from './cli/check-typescript.js';
26
26
  import { runCloudCli } from './cli/cloud-run.js';
27
+ import { runCloudSyncCli } from './cli/cloud-sync.js';
28
+ import { parseCloudDeployArgs, runCloudDeployCli, runCloudDeploymentsCli, runCloudUndeployCli, type CloudDeployArgs } from './cli/cloud-deploy.js';
27
29
  import { isAuthoredFlowPath } from './direct-input.js';
28
30
  import { parseDeployArgs, runDeploy, type DeployArgs } from './cli/deploy.js';
29
31
  import { parseDigestReference } from './bundle-transport.js';
@@ -51,7 +53,11 @@ type ParsedArgs =
51
53
  | BuildArgs
52
54
  | DeployArgs
53
55
  | { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
54
- | { command: 'cloud-run'; value: string; json: boolean; wait: boolean }
56
+ | { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean }
57
+ | { command: 'sync'; runId: string; json: boolean; root: string }
58
+ | CloudDeployArgs
59
+ | { command: 'deployments'; json: boolean }
60
+ | { command: 'undeploy'; agentId: string; json: boolean }
55
61
  | { command: 'check'; json: boolean; watch: boolean; value: string }
56
62
  | { 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 }
57
63
  | { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
@@ -66,12 +72,17 @@ const USAGE = [
66
72
  'flows add <helper-name|@flows/helper-name>',
67
73
  'flows build [--out <dir>] <flow.yaml|flow.ts>',
68
74
  'flows build --verify <bundle-dir>',
75
+ 'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--json]',
76
+ 'flows deployments [--json]',
77
+ 'flows undeploy [--json] <deployment-id>',
69
78
  'flows deploy <flow>@sha256:<digest> --to <file-bucket-uri>',
70
79
  'flows run <flow>@sha256:<digest> [--bucket <file-bucket-uri>] [--data-dir <dir>] [--json]',
71
80
  'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
72
81
  'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
73
82
  'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] [--reuse-from <run-id>] <flow.yaml|spec.json>',
74
- 'flows run --cloud [--json] [--wait] <flow.yaml|spec.json>',
83
+ 'flows run --cloud [--json] [--wait] [--sync-code] <flow.yaml|spec.json>',
84
+ 'flows run --cloud [--json] [--wait] [--sync-code] <flow.ts> --input <inline-json-or-file>',
85
+ 'flows sync [--json] [--dir <path>] <run-id>',
75
86
  'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
76
87
  '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>',
77
88
  'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
@@ -116,6 +127,10 @@ export async function runCli(
116
127
  if (parsed.command === 'serve-webhook') return runServeWebhook(parsed, io);
117
128
 
118
129
  if (parsed.command === 'cloud-run') return runCloudCli(parsed, io);
130
+ if (parsed.command === 'sync') return runCloudSyncCli(parsed, io);
131
+ if (parsed.command === 'cloud-deploy') return runCloudDeployCli(parsed, io);
132
+ if (parsed.command === 'deployments') return runCloudDeploymentsCli(parsed, io);
133
+ if (parsed.command === 'undeploy') return runCloudUndeployCli(parsed, io);
119
134
  if (parsed.command === 'replay') return replayJournal(parsed, io);
120
135
  if (parsed.command === 'build') return runBuild(parsed, io);
121
136
  if (parsed.command === 'deploy') return runDeploy(parsed, io);
@@ -419,17 +434,35 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
419
434
  if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
420
435
  if (command === 'replay') return parseReplayArgs(args.slice(1));
421
436
  if (command === 'build') return parseBuildArgs(args.slice(1));
422
- if (command === 'deploy') return parseDeployArgs(args.slice(1));
437
+ if (command === 'deploy') {
438
+ // The positional decides the form: an authored source deploys a hosted
439
+ // listener; a digest reference copies a sealed bundle into a file bucket.
440
+ const source = args.slice(1).find(a => !a.startsWith('-') && isAuthoredFlowPath(a));
441
+ return source !== undefined ? parseCloudDeployArgs(args.slice(1)) : parseDeployArgs(args.slice(1));
442
+ }
443
+ if (command === 'undeploy') {
444
+ const rest = args.slice(1).filter(a => a !== '--json');
445
+ const json = args.length - 1 - rest.length;
446
+ if (json > 1 || rest.length !== 1 || rest[0]!.startsWith('-')) return undefined;
447
+ return { command: 'undeploy', agentId: rest[0]!, json: json === 1 };
448
+ }
449
+ if (command === 'deployments') {
450
+ const rest = args.slice(1);
451
+ if (rest.length > 1 || (rest.length === 1 && rest[0] !== '--json')) return undefined;
452
+ return { command: 'deployments', json: rest.length === 1 };
453
+ }
423
454
  if (command === 'serve-webhook') return parseWebhookArgs(args.slice(1));
424
455
  if (command === 'hn-monitor') return parseHnMonitorArgs(args.slice(1));
425
456
  if (command === 'tick') return parseTickArgs(args.slice(1));
426
457
  if (command === 'observer') return parseObserverArgs(args.slice(1));
458
+ if (command === 'sync') return parseSyncArgs(args.slice(1));
427
459
  if (command !== 'check' && command !== 'run' && command !== 'resume') return undefined;
428
460
 
429
461
  let json = false;
430
462
  let watch = false;
431
463
  let cloud = false;
432
464
  let wait = false;
465
+ let syncCode = false;
433
466
  let localAgent = false;
434
467
  let allowHumanInfluenced = false;
435
468
  let dataDir = DEFAULT_DATA_DIR;
@@ -443,10 +476,11 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
443
476
  const positionals: string[] = [];
444
477
  for (let index = 1; index < args.length; index += 1) {
445
478
  const argument = args[index]!;
446
- if (argument === '--cloud' || argument === '--wait') {
447
- if (command !== 'run' || (argument === '--cloud' ? cloud : wait)) return undefined;
479
+ if (argument === '--cloud' || argument === '--wait' || argument === '--sync-code') {
480
+ if (command !== 'run' || (argument === '--cloud' ? cloud : argument === '--wait' ? wait : syncCode)) return undefined;
448
481
  if (argument === '--cloud') cloud = true;
449
- else wait = true;
482
+ else if (argument === '--wait') wait = true;
483
+ else syncCode = true;
450
484
  continue;
451
485
  }
452
486
  if (argument === '--allow-human-influenced') {
@@ -520,13 +554,15 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
520
554
  if (bucket !== undefined && (cloud || !parseDigestReference(positionals[0]!))) return undefined;
521
555
  if (cloud) {
522
556
  // `--cloud` submits the spec to Cloud, so every flag that only describes a
523
- // local run -- an inline input, a data dir, a suppressed daemon, a local
524
- // agent, a local observer-link opt-out -- describes nothing there and is
525
- // refused rather than ignored.
526
- if (allowHumanInfluenced || sawInput || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
527
- return { command: 'cloud-run', value: positionals[0]!, json, wait };
557
+ // local run -- a data dir, a suppressed daemon, a local agent, a local
558
+ // observer-link opt-out -- describes nothing there and is refused rather
559
+ // than ignored. `--input` is the authored body's argument and travels with
560
+ // the source, so it is accepted exactly where a local run accepts it.
561
+ if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
562
+ if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
563
+ return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode };
528
564
  }
529
- if (wait) return undefined;
565
+ if (wait || syncCode) return undefined;
530
566
  if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
531
567
 
532
568
  if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
@@ -579,6 +615,32 @@ function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
579
615
  * directory at all -- the mint is a pure Relaycast API round-trip. No
580
616
  * positional argument, no other flags.
581
617
  */
618
+ /** `flows sync [--json] [--dir <path>] <run-id>`: apply a hosted run's patch to a local tree. */
619
+ function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
620
+ let json = false;
621
+ let root: string | undefined;
622
+ const positionals: string[] = [];
623
+ for (let index = 0; index < args.length; index += 1) {
624
+ const argument = args[index]!;
625
+ if (argument === '--json') {
626
+ if (json) return undefined;
627
+ json = true;
628
+ continue;
629
+ }
630
+ if (argument === '--dir') {
631
+ const value = args[index + 1];
632
+ if (root !== undefined || value === undefined || value.startsWith('-')) return undefined;
633
+ root = value;
634
+ index += 1;
635
+ continue;
636
+ }
637
+ if (argument.startsWith('-')) return undefined;
638
+ positionals.push(argument);
639
+ }
640
+ if (positionals.length !== 1) return undefined;
641
+ return { command: 'sync', runId: positionals[0]!, json, root: root ?? '.' };
642
+ }
643
+
582
644
  function parseObserverArgs(rest: readonly string[]): ParsedArgs | undefined {
583
645
  let dataDir = DEFAULT_DATA_DIR;
584
646
  let sawDataDir = false;
@@ -750,6 +812,7 @@ function diagnosticLabel(severity: string): string {
750
812
  case 'warning': return 'WARNING';
751
813
  case 'failure': return 'FAILED';
752
814
  case 'parked': return 'PARKED';
815
+ case 'declined': return 'DECLINED';
753
816
  default: return 'REFUSED';
754
817
  }
755
818
  }