@relayflows/sdk 2.0.37 → 2.0.39

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 (171) hide show
  1. package/dist/agent-reaper.d.ts +1 -1
  2. package/dist/agent-reaper.d.ts.map +1 -1
  3. package/dist/agent-reaper.js +6 -4
  4. package/dist/agent-reaper.js.map +1 -1
  5. package/dist/authored-budget.d.ts +26 -1
  6. package/dist/authored-budget.d.ts.map +1 -1
  7. package/dist/authored-budget.js +72 -2
  8. package/dist/authored-budget.js.map +1 -1
  9. package/dist/authored-flow-error.d.ts +7 -1
  10. package/dist/authored-flow-error.d.ts.map +1 -1
  11. package/dist/authored-flow-error.js.map +1 -1
  12. package/dist/authored-node-runner.d.ts.map +1 -1
  13. package/dist/authored-node-runner.js +3 -2
  14. package/dist/authored-node-runner.js.map +1 -1
  15. package/dist/authored-root.d.ts.map +1 -1
  16. package/dist/authored-root.js +141 -6
  17. package/dist/authored-root.js.map +1 -1
  18. package/dist/authored-worker-step.js +1 -0
  19. package/dist/authored-worker-step.js.map +1 -1
  20. package/dist/child-stop.d.ts +3 -1
  21. package/dist/child-stop.d.ts.map +1 -1
  22. package/dist/child-stop.js +42 -2
  23. package/dist/child-stop.js.map +1 -1
  24. package/dist/cli/add-extension.js +1 -1
  25. package/dist/cli/add-extension.js.map +1 -1
  26. package/dist/cli/check.d.ts.map +1 -1
  27. package/dist/cli/check.js +25 -7
  28. package/dist/cli/check.js.map +1 -1
  29. package/dist/cli/cli-probe.d.ts +1 -0
  30. package/dist/cli/cli-probe.d.ts.map +1 -1
  31. package/dist/cli/cli-probe.js +72 -36
  32. package/dist/cli/cli-probe.js.map +1 -1
  33. package/dist/cli/cloud-deploy.d.ts +9 -1
  34. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  35. package/dist/cli/cloud-deploy.js +24 -5
  36. package/dist/cli/cloud-deploy.js.map +1 -1
  37. package/dist/cli/cloud-versions.d.ts +22 -0
  38. package/dist/cli/cloud-versions.d.ts.map +1 -0
  39. package/dist/cli/cloud-versions.js +96 -0
  40. package/dist/cli/cloud-versions.js.map +1 -0
  41. package/dist/cli/direct-run.d.ts.map +1 -1
  42. package/dist/cli/direct-run.js +4 -1
  43. package/dist/cli/direct-run.js.map +1 -1
  44. package/dist/cli/pinned-cli-alias.d.ts +13 -0
  45. package/dist/cli/pinned-cli-alias.d.ts.map +1 -0
  46. package/dist/cli/pinned-cli-alias.js +58 -0
  47. package/dist/cli/pinned-cli-alias.js.map +1 -0
  48. package/dist/cli/run.d.ts +8 -0
  49. package/dist/cli/run.d.ts.map +1 -1
  50. package/dist/cli/run.js +91 -8
  51. package/dist/cli/run.js.map +1 -1
  52. package/dist/cli/step-failure.d.ts.map +1 -1
  53. package/dist/cli/step-failure.js +18 -0
  54. package/dist/cli/step-failure.js.map +1 -1
  55. package/dist/cli-commands.d.ts +27 -0
  56. package/dist/cli-commands.d.ts.map +1 -1
  57. package/dist/cli-commands.js +18 -0
  58. package/dist/cli-commands.js.map +1 -1
  59. package/dist/cli.d.ts +9 -0
  60. package/dist/cli.d.ts.map +1 -1
  61. package/dist/cli.js +17 -0
  62. package/dist/cli.js.map +1 -1
  63. package/dist/cloud-deploy.d.ts +17 -0
  64. package/dist/cloud-deploy.d.ts.map +1 -1
  65. package/dist/cloud-deploy.js +8 -1
  66. package/dist/cloud-deploy.js.map +1 -1
  67. package/dist/cloud-run.d.ts.map +1 -1
  68. package/dist/cloud-run.js +9 -2
  69. package/dist/cloud-run.js.map +1 -1
  70. package/dist/cloud-versions-wire.d.ts +25 -0
  71. package/dist/cloud-versions-wire.d.ts.map +1 -0
  72. package/dist/cloud-versions-wire.js +39 -0
  73. package/dist/cloud-versions-wire.js.map +1 -0
  74. package/dist/cloud-versions.d.ts +47 -0
  75. package/dist/cloud-versions.d.ts.map +1 -0
  76. package/dist/cloud-versions.js +131 -0
  77. package/dist/cloud-versions.js.map +1 -0
  78. package/dist/communication/worker.d.ts.map +1 -1
  79. package/dist/communication/worker.js +24 -6
  80. package/dist/communication/worker.js.map +1 -1
  81. package/dist/compile.d.ts.map +1 -1
  82. package/dist/compile.js +16 -7
  83. package/dist/compile.js.map +1 -1
  84. package/dist/failure-kinds.d.ts +9 -1
  85. package/dist/failure-kinds.d.ts.map +1 -1
  86. package/dist/failure-kinds.js +6 -0
  87. package/dist/failure-kinds.js.map +1 -1
  88. package/dist/flow-extension-loader.d.ts +1 -1
  89. package/dist/flow-extension-loader.d.ts.map +1 -1
  90. package/dist/flow-extension-loader.js +143 -14
  91. package/dist/flow-extension-loader.js.map +1 -1
  92. package/dist/flow-extension-manifest.d.ts +1 -0
  93. package/dist/flow-extension-manifest.d.ts.map +1 -1
  94. package/dist/flow-extension-manifest.js +12 -3
  95. package/dist/flow-extension-manifest.js.map +1 -1
  96. package/dist/hosted-extension-manifest.d.ts.map +1 -1
  97. package/dist/hosted-extension-manifest.js +25 -4
  98. package/dist/hosted-extension-manifest.js.map +1 -1
  99. package/dist/hosted-extension-runtime.js +1 -1
  100. package/dist/hosted-extension-sandbox.js +2 -2
  101. package/dist/llm-worker.d.ts.map +1 -1
  102. package/dist/llm-worker.js +4 -2
  103. package/dist/llm-worker.js.map +1 -1
  104. package/dist/mcp-stdio.d.ts +2 -1
  105. package/dist/mcp-stdio.d.ts.map +1 -1
  106. package/dist/mcp-stdio.js +38 -21
  107. package/dist/mcp-stdio.js.map +1 -1
  108. package/dist/preflight.d.ts +2 -0
  109. package/dist/preflight.d.ts.map +1 -1
  110. package/dist/preflight.js.map +1 -1
  111. package/dist/protocol.d.ts +9 -0
  112. package/dist/protocol.d.ts.map +1 -1
  113. package/dist/resolved-cli-identity.d.ts +14 -0
  114. package/dist/resolved-cli-identity.d.ts.map +1 -0
  115. package/dist/resolved-cli-identity.js +19 -0
  116. package/dist/resolved-cli-identity.js.map +1 -0
  117. package/dist/worker-cli.d.ts +3 -1
  118. package/dist/worker-cli.d.ts.map +1 -1
  119. package/dist/worker-cli.js +116 -40
  120. package/dist/worker-cli.js.map +1 -1
  121. package/dist/worker-lease.d.ts +3 -1
  122. package/dist/worker-lease.d.ts.map +1 -1
  123. package/dist/worker-lease.js +47 -9
  124. package/dist/worker-lease.js.map +1 -1
  125. package/dist/worker.d.ts.map +1 -1
  126. package/dist/worker.js +4 -2
  127. package/dist/worker.js.map +1 -1
  128. package/dist/wrapper-session.d.ts +3 -1
  129. package/dist/wrapper-session.d.ts.map +1 -1
  130. package/dist/wrapper-session.js +93 -53
  131. package/dist/wrapper-session.js.map +1 -1
  132. package/package.json +2 -2
  133. package/src/agent-reaper.ts +7 -4
  134. package/src/authored-budget.ts +73 -1
  135. package/src/authored-flow-error.ts +6 -0
  136. package/src/authored-node-runner.ts +3 -2
  137. package/src/authored-root.ts +150 -7
  138. package/src/authored-worker-step.ts +1 -0
  139. package/src/child-stop.ts +40 -1
  140. package/src/cli/add-extension.ts +1 -1
  141. package/src/cli/check.ts +25 -4
  142. package/src/cli/cli-probe.ts +73 -37
  143. package/src/cli/cloud-deploy.ts +21 -7
  144. package/src/cli/cloud-versions.ts +103 -0
  145. package/src/cli/direct-run.ts +4 -1
  146. package/src/cli/pinned-cli-alias.ts +74 -0
  147. package/src/cli/run.ts +103 -7
  148. package/src/cli/step-failure.ts +18 -0
  149. package/src/cli-commands.ts +18 -0
  150. package/src/cli.ts +15 -0
  151. package/src/cloud-deploy.ts +24 -3
  152. package/src/cloud-run.ts +9 -2
  153. package/src/cloud-versions-wire.ts +58 -0
  154. package/src/cloud-versions.ts +179 -0
  155. package/src/communication/worker.ts +24 -9
  156. package/src/compile.ts +28 -13
  157. package/src/failure-kinds.ts +14 -0
  158. package/src/flow-extension-loader.ts +154 -14
  159. package/src/flow-extension-manifest.ts +6 -3
  160. package/src/hosted-extension-manifest.ts +28 -5
  161. package/src/hosted-extension-runtime.ts +1 -1
  162. package/src/hosted-extension-sandbox.ts +2 -2
  163. package/src/llm-worker.ts +6 -4
  164. package/src/mcp-stdio.ts +34 -20
  165. package/src/preflight.ts +2 -0
  166. package/src/protocol.ts +9 -0
  167. package/src/resolved-cli-identity.ts +42 -0
  168. package/src/worker-cli.ts +123 -37
  169. package/src/worker-lease.ts +50 -9
  170. package/src/worker.ts +6 -4
  171. package/src/wrapper-session.ts +89 -56
@@ -4,13 +4,17 @@ import {
4
4
  type FlowTriggerSource,
5
5
  } from '../cloud-deploy.js';
6
6
  import { describeFlowRequirements } from '../flow-requirements.js';
7
+ import { describeVersionChange } from '../cloud-versions-wire.js';
8
+ import { runCloudDeployVersionCli } from './cloud-versions.js';
7
9
  import { cliConnectPrompt, flowRequirementsForPath, harnessRemedy } from './cloud-connect-cli.js';
8
10
  import type { CliIo } from '../cli.js';
9
11
 
10
12
  export interface CloudDeployArgs {
11
13
  command: 'cloud-deploy';
12
14
  value: string;
13
- repo: string;
15
+ /** Update form: the flow (name or listener id) this source becomes the next version of. */
16
+ flow: string | undefined;
17
+ repo: string | undefined;
14
18
  on: string[];
15
19
  approver: string | undefined;
16
20
  name: string | undefined;
@@ -25,6 +29,11 @@ export interface CloudDeployArgs {
25
29
  /**
26
30
  * `flows deploy <flow.ts> --repo <owner/name> --on <provider>[:k=v,…] [--on …]
27
31
  * --approver <handle> [--name <n>] [--agents <list>] [--draft] [--no-connect] [--json]`
32
+ * `flows deploy <flow.ts> --flow <name-or-listener-id> [--plugin <ref>] [--no-connect] [--json]`
33
+ *
34
+ * The `--flow` form makes the source the next version of an existing flow and
35
+ * changes nothing else; listener settings given beside it are refused when run,
36
+ * by name, rather than silently ignored.
28
37
  *
29
38
  * Parsed here rather than in `parseDeployArgs` because the two `deploy` forms
30
39
  * share nothing but the word: the digest form copies a sealed bundle into a
@@ -32,6 +41,7 @@ export interface CloudDeployArgs {
32
41
  */
33
42
  export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs | undefined {
34
43
  let value: string | undefined;
44
+ let flow: string | undefined;
35
45
  let repo: string | undefined;
36
46
  let approver: string | undefined;
37
47
  let name: string | undefined;
@@ -72,11 +82,12 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
72
82
  i += 1;
73
83
  continue;
74
84
  }
75
- if (arg === '--repo' || arg === '--approver' || arg === '--name' || arg === '--on') {
85
+ if (arg === '--repo' || arg === '--approver' || arg === '--name' || arg === '--on' || arg === '--flow') {
76
86
  const next = args[i + 1];
77
87
  if (next === undefined || next.startsWith('-')) return undefined;
78
88
  i += 1;
79
89
  if (arg === '--on') { on.push(next); continue; }
90
+ if (arg === '--flow') { if (flow !== undefined) return undefined; flow = next; continue; }
80
91
  if (arg === '--repo') { if (repo !== undefined) return undefined; repo = next; continue; }
81
92
  if (arg === '--approver') { if (approver !== undefined) return undefined; approver = next; continue; }
82
93
  if (name !== undefined) return undefined;
@@ -86,8 +97,9 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
86
97
  if (arg.startsWith('-') || value !== undefined) return undefined;
87
98
  value = arg;
88
99
  }
89
- if (value === undefined || repo === undefined || on.length === 0) return undefined;
90
- return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, noConnect, json, plugins };
100
+ if (value === undefined) return undefined;
101
+ if (flow === undefined && (repo === undefined || on.length === 0)) return undefined;
102
+ return { command: 'cloud-deploy', value, flow, repo, on, approver, name, agents, draft, noConnect, json, plugins };
91
103
  }
92
104
 
93
105
  function describeSource(source: FlowTriggerSource): string {
@@ -96,6 +108,7 @@ function describeSource(source: FlowTriggerSource): string {
96
108
  }
97
109
 
98
110
  export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promise<0 | 1 | 2> {
111
+ if (args.flow !== undefined) return runCloudDeployVersionCli({ ...args, flow: args.flow }, io);
99
112
  const agents = args.agents === undefined ? undefined : parseAgentHarnessesOr(args.agents);
100
113
  // What the source declares, so a harness refusal names the right remedy
101
114
  // even when `--agents` was not given; the loader's own failure is reported
@@ -111,7 +124,7 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
111
124
  if (agents === undefined) harnesses = (await flowRequirementsForPath(args.value))?.harnesses ?? [];
112
125
  const deployment = await deployToCloud({
113
126
  path: args.value,
114
- repository: parseRepository(args.repo),
127
+ repository: parseRepository(args.repo!),
115
128
  sources: args.on.map(parseTriggerSource),
116
129
  approver: args.approver,
117
130
  draft: args.draft,
@@ -124,7 +137,8 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
124
137
  io.stdout(JSON.stringify({ ok: true, ...deployment }));
125
138
  return 0;
126
139
  }
127
- io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`);
140
+ io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`
141
+ + (deployment.version === undefined ? '' : ` · ${describeVersionChange(deployment.version)}`));
128
142
  io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
129
143
  io.stdout(` repository: ${deployment.repository.owner}/${deployment.repository.name}`);
130
144
  for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
@@ -181,7 +195,7 @@ export async function runCloudUndeployCli({ agentId, json }: { agentId: string;
181
195
  }
182
196
  }
183
197
 
184
- function reportCloudFailure(error: unknown, json: boolean, io: CliIo, harnesses: readonly string[] = []): 1 | 2 {
198
+ export function reportCloudFailure(error: unknown, json: boolean, io: CliIo, harnesses: readonly string[] = []): 1 | 2 {
185
199
  const code = error instanceof CloudFlowError ? error.code : 'cloud_deploy_failed';
186
200
  let message = error instanceof Error ? error.message : 'Cloud deploy failed.';
187
201
  // Cloud names the missing coding-agent credential on activation; say how it is connected.
@@ -0,0 +1,103 @@
1
+ import { CloudFlowError } from '../cloud-http.js';
2
+ import {
3
+ activateCloudFlowVersion, deployVersionToCloud, getCloudListener, resolveCloudFlow,
4
+ } from '../cloud-versions.js';
5
+ import { describeVersionChange } from '../cloud-versions-wire.js';
6
+ import { describeFlowRequirements } from '../flow-requirements.js';
7
+ import { cliConnectPrompt } from './cloud-connect-cli.js';
8
+ import { reportCloudFailure, type CloudDeployArgs } from './cloud-deploy.js';
9
+ import type { CliIo } from '../cli.js';
10
+
11
+ /**
12
+ * `flows deploy <flow.ts> --flow <name-or-id>`: the source becomes the next
13
+ * version of that flow. Listener settings are not part of a version, so a
14
+ * flag that would change one is refused by name instead of being dropped.
15
+ */
16
+ export async function runCloudDeployVersionCli(
17
+ args: CloudDeployArgs & { flow: string }, io: CliIo,
18
+ ): Promise<0 | 1 | 2> {
19
+ try {
20
+ const settings = [
21
+ args.repo === undefined ? undefined : '--repo',
22
+ args.on.length === 0 ? undefined : '--on',
23
+ args.approver === undefined ? undefined : '--approver',
24
+ args.name === undefined ? undefined : '--name',
25
+ args.agents === undefined ? undefined : '--agents',
26
+ args.draft ? '--draft' : undefined,
27
+ ].filter((flag): flag is string => flag !== undefined);
28
+ if (settings.length > 0) {
29
+ throw new CloudFlowError('invalid_input',
30
+ `--flow deploys a new version of the flow's source only; ${settings.join(', ')} stay on the listener. `
31
+ + 'Drop them, or change them in the Cloud dashboard.');
32
+ }
33
+ const connect = cliConnectPrompt(io, { noConnect: args.noConnect, json: args.json });
34
+ const deployment = await deployVersionToCloud({
35
+ path: args.value,
36
+ flow: args.flow,
37
+ ...(connect === undefined ? {} : { connect }),
38
+ ...(args.plugins.length === 0 ? {} : { plugins: args.plugins }),
39
+ });
40
+ if (args.json) {
41
+ io.stdout(JSON.stringify({ ok: true, ...deployment }));
42
+ return 0;
43
+ }
44
+ io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`
45
+ + ` · ${describeVersionChange(deployment.version)}`);
46
+ io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
47
+ const requires = describeFlowRequirements(deployment.requirements);
48
+ if (requires) io.stdout(` requires: ${requires}`);
49
+ for (const provider of deployment.connected) io.stdout(` connected: ${provider}`);
50
+ io.stdout('New runs launch this version; runs already started finish on theirs. History: flows versions '
51
+ + JSON.stringify(deployment.name));
52
+ return 0;
53
+ } catch (error) {
54
+ return reportCloudFailure(error, args.json, io);
55
+ }
56
+ }
57
+
58
+ /** `flows versions <name-or-id>`: newest first, the active one marked. */
59
+ export async function runCloudVersionsCli(
60
+ { flow, json }: { flow: string; json: boolean }, io: CliIo,
61
+ ): Promise<0 | 1 | 2> {
62
+ try {
63
+ const listener = await getCloudListener(await resolveCloudFlow(flow));
64
+ if (json) {
65
+ io.stdout(JSON.stringify({
66
+ ok: true, agentId: listener.agentId, name: listener.name,
67
+ activeVersion: listener.activeVersion?.version ?? null, versions: listener.versions,
68
+ }));
69
+ return 0;
70
+ }
71
+ if (listener.versions.length === 0) {
72
+ io.stdout(`${listener.agentId} ${JSON.stringify(listener.name)} has no recorded versions.`);
73
+ return 0;
74
+ }
75
+ io.stdout(`${listener.agentId} ${JSON.stringify(listener.name)} ${listener.status}`);
76
+ for (const version of listener.versions) {
77
+ const active = version.version === listener.activeVersion?.version ? ' (active)' : '';
78
+ io.stdout(` version ${version.version}${active} ${version.createdAt} ${version.origin} sha256 ${version.sourceSha256.slice(0, 12)}`);
79
+ }
80
+ return 0;
81
+ } catch (error) {
82
+ return reportCloudFailure(error, json, io);
83
+ }
84
+ }
85
+
86
+ /** `flows rollback <name-or-id> <version>`: move the pointer; later versions stay recorded. */
87
+ export async function runCloudRollbackCli(
88
+ { flow, version, json }: { flow: string; version: string; json: boolean }, io: CliIo,
89
+ ): Promise<0 | 1 | 2> {
90
+ try {
91
+ // Cloud numbers versions up to nine digits; anything longer cannot exist.
92
+ if (!/^[1-9][0-9]{0,8}$/u.test(version)) {
93
+ throw new CloudFlowError('invalid_input', `A version is a positive whole number, got "${version}".`);
94
+ }
95
+ const result = await activateCloudFlowVersion(flow, Number(version));
96
+ io.stdout(json
97
+ ? JSON.stringify({ ok: true, ...result })
98
+ : `ACTIVATED ${result.agentId} ${result.status} · ${describeVersionChange(result.version)}`);
99
+ return 0;
100
+ } catch (error) {
101
+ return reportCloudFailure(error, json, io);
102
+ }
103
+ }
@@ -21,7 +21,7 @@ import { authoredInput, authoredWorkerRemedy, localAgentRemedy } from './local-a
21
21
  import {
22
22
  authoredCompletion,
23
23
  authoredHumanParked,
24
- authoredStepFailure,
24
+ authoredStepFailure, rootLeaseLostReport,
25
25
  connect,
26
26
  emptyReport,
27
27
  fromCheckReport,
@@ -212,6 +212,9 @@ export async function runDirectFlow(
212
212
  if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
213
213
  return authoredStepFailure('run', base, socketPath, error);
214
214
  }
215
+ if (error instanceof AuthoredFlowExecutionError && error.code === 'root_lease_lost') {
216
+ return rootLeaseLostReport('run', base, socketPath, error);
217
+ }
215
218
  if (error instanceof AuthoredHumanParked) {
216
219
  return authoredHumanParked('run', base, socketPath, error, { dataDir, localAgent: options.localAgent === true });
217
220
  }
@@ -0,0 +1,74 @@
1
+ import {
2
+ mkdtempSync,
3
+ rmSync,
4
+ symlinkSync,
5
+ } from 'node:fs';
6
+ import {
7
+ mkdtemp,
8
+ rm,
9
+ symlink,
10
+ } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import { basename, join } from 'node:path';
13
+
14
+ export interface PinnedCliAlias {
15
+ executable: string;
16
+ release(): void;
17
+ }
18
+
19
+ export interface PinnedCliAliasSync {
20
+ executable: string;
21
+ release(): void;
22
+ }
23
+
24
+ function aliasName(identity: string): string {
25
+ const name = basename(identity);
26
+ if (name.length === 0 || name === '.' || name === '..') {
27
+ throw new Error(`Invalid CLI identity basename: ${JSON.stringify(identity)}`);
28
+ }
29
+ return name;
30
+ }
31
+
32
+ /** Execute pinned canonical bytes through the proved basename, including shebang scripts. */
33
+ export async function pinCliAlias(executable: string, identity: string): Promise<PinnedCliAlias> {
34
+ const name = aliasName(identity);
35
+ if (basename(executable) === name) {
36
+ return { executable, release() {} };
37
+ }
38
+ const directory = await mkdtemp(join(tmpdir(), 'relayflow-cli-'));
39
+ const alias = join(directory, name);
40
+ try {
41
+ await symlink(executable, alias, 'file');
42
+ } catch (error) {
43
+ await rm(directory, { recursive: true, force: true });
44
+ throw error;
45
+ }
46
+ let released = false;
47
+ return { executable: alias, release: () => {
48
+ if (released) return;
49
+ rmSync(directory, { recursive: true, force: true });
50
+ released = true;
51
+ } };
52
+ }
53
+
54
+ /** Synchronous counterpart for the synchronous preflight contract. */
55
+ export function pinCliAliasSync(executable: string, identity: string): PinnedCliAliasSync {
56
+ const name = aliasName(identity);
57
+ if (basename(executable) === name) {
58
+ return { executable, release() {} };
59
+ }
60
+ const directory = mkdtempSync(join(tmpdir(), 'relayflow-cli-'));
61
+ const alias = join(directory, name);
62
+ try {
63
+ symlinkSync(executable, alias, 'file');
64
+ } catch (error) {
65
+ rmSync(directory, { recursive: true, force: true });
66
+ throw error;
67
+ }
68
+ let released = false;
69
+ return { executable: alias, release: () => {
70
+ if (released) return;
71
+ rmSync(directory, { recursive: true, force: true });
72
+ released = true;
73
+ } };
74
+ }
package/src/cli/run.ts CHANGED
@@ -408,6 +408,9 @@ export async function resumeFlow(
408
408
  if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
409
409
  return authoredStepFailure('resume', base, socketPath, error, runId);
410
410
  }
411
+ if (error instanceof AuthoredFlowExecutionError && error.code === 'root_lease_lost') {
412
+ return rootLeaseLostReport('resume', base, socketPath, error, runId);
413
+ }
411
414
  // An authored resume that parks for want of a worker is a park, reported
412
415
  // like the run path's — and with the same remedy, because an authored root
413
416
  // admits its worker at run start: resuming again cannot attach one, so the
@@ -522,6 +525,38 @@ function authoredInputArgument(metadata: AuthoredRootMetadata | undefined): Auth
522
525
  * drove correctly, and it produces a report with no `status`, which is the
523
526
  * whole of what `RUN <id> unknown` ever meant.
524
527
  */
528
+ /**
529
+ * The authored root's lease was lost and not re-dispatched to this process
530
+ * (authored-root.ts driveRootFollowingRetries). Exit 1 because this CLI did
531
+ * not finish the run, but the report says what is true of it: still
532
+ * `running` in the journal, and resumable -- never `protocol_error` with the
533
+ * generic "worker wait canceled" and no run id, which is what it was.
534
+ */
535
+ export function rootLeaseLostReport(
536
+ command: RunCommand,
537
+ base: CheckReport | RunReport,
538
+ socketPath: string,
539
+ error: AuthoredFlowExecutionError,
540
+ fallbackRunId?: string,
541
+ ): RunExecution {
542
+ const rootRunId = error.rootRunId ?? fallbackRunId;
543
+ return {
544
+ exitCode: 1,
545
+ report: {
546
+ ...fromBase(command, base),
547
+ ok: false,
548
+ ...(rootRunId === undefined ? {} : { runId: rootRunId, rootRunId }),
549
+ socketPath,
550
+ status: 'running',
551
+ diagnostics: [...base.diagnostics, {
552
+ severity: 'failure',
553
+ kind: 'root_lease_lost',
554
+ message: error.message.replace(/^root_lease_lost: /, ''),
555
+ }],
556
+ },
557
+ };
558
+ }
559
+
525
560
  export function authoredStepFailure(
526
561
  command: RunCommand,
527
562
  base: CheckReport | RunReport,
@@ -798,13 +833,27 @@ export async function classifyOutcome(
798
833
  let parkedStep: ParkedStep | undefined;
799
834
  let needsHuman = false;
800
835
  let unclassifiedPolls = 0;
801
- while (current.status === 'parked') {
836
+ // `running` too: an authored body re-driven after a lost root lease re-admits
837
+ // its child by admission key and gets the EXISTING run back, mid-attempt.
838
+ // Only `parked` was followed, so that child -- on the attempt 2 the kernel
839
+ // retried it to -- fell straight through to "status running without a
840
+ // classifiable completion" and failed the flow (customer rw_3a0fcb71).
841
+ while (current.status === 'parked' || current.status === 'running') {
802
842
  const inspection = await inspectOutOfBandStep(client, current.run_id);
803
843
  // A runnable sibling may only be waiting for capacity held by a live
804
- // attempt. Keep driving until that lease releases before declaring a park.
805
- if (inspection?.parkedStep !== undefined && (inspection.needsHuman || inspection.runningStep === undefined)) {
844
+ // attempt or by a retry in backoff. Keep driving until that active work
845
+ // settles before declaring a park.
846
+ if (inspection?.parkedStep !== undefined && (inspection.needsHuman
847
+ || (inspection.runningStep === undefined && inspection.backoffStep === undefined))) {
806
848
  parkedStep = inspection.parkedStep;
807
849
  needsHuman = inspection.needsHuman;
850
+ // `run.get` reports the live snapshot as `running` for an ordinary
851
+ // runnable worker wait, even when `run.start`/`run.resume` already
852
+ // classified the outcome as parked. Conversely, a re-admitted run can
853
+ // arrive here with a stale `running` outcome after its worker vanished.
854
+ // The step-level inspection is authoritative that this is a park; do
855
+ // not copy its broader run status and lose that classification.
856
+ current = { ...current, status: 'parked' };
808
857
  break;
809
858
  }
810
859
  if (inspection?.runningStep !== undefined) {
@@ -812,6 +861,20 @@ export async function classifyOutcome(
812
861
  current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced);
813
862
  continue;
814
863
  }
864
+ // A step in `backoff` is between attempts: the kernel has scheduled the
865
+ // next one (a dead worker's retry is due at once, but waits for the
866
+ // worker slot the dead attempt held; a failed attempt waits out the
867
+ // retry policy's delay, bounded by its max_backoff_ms). That is progress,
868
+ // not an unclassifiable state -- treating it as one gave up after 2s on a
869
+ // child whose attempt 2 then succeeded (customer rw_3a0fcb71). Once due,
870
+ // the step is dispatched (`running`, followed above) or, with no worker,
871
+ // left `runnable` and reported as a park.
872
+ if (inspection?.backoffStep !== undefined && inspection.runningStep === undefined) {
873
+ throwIfCanceled(options.signal, inspection.backoffStep.id);
874
+ await delay(EXPIRED_LEASE_POLL_MS, options.signal);
875
+ current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced);
876
+ continue;
877
+ }
815
878
  if (inspection?.status === 'completed' || inspection?.status === 'failed') {
816
879
  current = await client.runResume(current.run_id, command === 'run' || options.allowHumanInfluenced);
817
880
  continue;
@@ -954,6 +1017,8 @@ interface OutOfBandInspection {
954
1017
  parkedStep?: ParkedStep;
955
1018
  needsHuman: boolean;
956
1019
  runningStep?: RunningStep;
1020
+ /** A step waiting out a retry backoff: its next attempt is scheduled. */
1021
+ backoffStep?: ParkedStep;
957
1022
  }
958
1023
 
959
1024
  interface RunningStep extends ParkedStep {
@@ -981,6 +1046,9 @@ async function inspectOutOfBandStep(
981
1046
  const runningEntry = entries.find(([, step]) =>
982
1047
  step.type !== 'deterministic' && step.state === 'running',
983
1048
  );
1049
+ const backoffEntry = entries.find(([, step]) =>
1050
+ step.type !== 'deterministic' && step.state === 'backoff',
1051
+ );
984
1052
  const parkedEntry = humanEntry ?? runnableEntry;
985
1053
  const parkedStep = parkedEntry === undefined ? undefined : {
986
1054
  id: parkedEntry[0],
@@ -996,11 +1064,21 @@ async function inspectOutOfBandStep(
996
1064
  needsHuman: humanEntry !== undefined,
997
1065
  ...(parkedStep !== undefined ? { parkedStep } : {}),
998
1066
  ...(runningStep !== undefined ? { runningStep } : {}),
1067
+ ...(backoffEntry !== undefined ? { backoffStep: {
1068
+ id: backoffEntry[0],
1069
+ type: backoffEntry[1].type as Extract<StepType, 'llm' | 'agent'>,
1070
+ } } : {}),
999
1071
  };
1000
1072
  }
1001
1073
 
1002
1074
  // Match kernel/relayflowd/src/server/client.rs: allow the lease sweep to dispatch a retry.
1003
1075
  export const LEASE_SWEEP_GRACE_MS = 5_000;
1076
+ // How often to re-ask the daemon about a step whose lease looks expired here.
1077
+ const EXPIRED_LEASE_POLL_MS = 250;
1078
+ // A deadline unchanged for a whole lease (relayflowd LEASE_RENEWAL_MS, 30s)
1079
+ // plus the sweep grace was renewed by nobody: the attempt is dead and the
1080
+ // daemon never swept it. Fail rather than hang.
1081
+ const STALE_LEASE_MS = 30_000 + LEASE_SWEEP_GRACE_MS;
1004
1082
 
1005
1083
  async function waitForRunningStep(
1006
1084
  client: JournalClient,
@@ -1018,20 +1096,29 @@ async function waitForRunningStep(
1018
1096
  stepType: runningStep.type,
1019
1097
  leaseDeadlineMs,
1020
1098
  });
1099
+ // When the lease deadline last changed, on this process's monotonic clock.
1100
+ // A live worker renews every ~10s, moving the deadline; a dead lease the
1101
+ // daemon has not swept keeps it. That -- not a comparison of the daemon's
1102
+ // deadline with our wall clock -- is what separates the two under skew.
1103
+ let deadlineSeenAt = performance.now();
1021
1104
  while (true) {
1022
1105
  throwIfCanceled(options.signal, runningStep.id);
1023
1106
  const remainingMs = leaseDeadlineMs + LEASE_SWEEP_GRACE_MS - Date.now();
1024
- if (remainingMs <= 0) {
1107
+ if (performance.now() - deadlineSeenAt > STALE_LEASE_MS) {
1025
1108
  throw new Error(
1026
1109
  `worker lease for step "${runningStep.id}" expired at ${leaseDeadlineMs} without completion`,
1027
1110
  );
1028
1111
  }
1029
- await delay(Math.min(50, remainingMs), options.signal);
1112
+ // Past the deadline by THIS clock alone is not an expiry: the daemon may
1113
+ // still hold the lease (skew) or be sweeping it into a retry. Keep
1114
+ // following the step, at a slower poll, until the daemon says otherwise.
1115
+ await delay(remainingMs <= 0 ? EXPIRED_LEASE_POLL_MS : Math.min(50, remainingMs), options.signal);
1030
1116
  const snapshot = await client.runGet(runId);
1031
1117
  const step = snapshot.steps[runningStep.id];
1032
1118
  if (step?.state !== 'running') return;
1033
1119
  if (step.lease_deadline_ms !== undefined && step.lease_deadline_ms !== leaseDeadlineMs) {
1034
1120
  leaseDeadlineMs = step.lease_deadline_ms;
1121
+ deadlineSeenAt = performance.now();
1035
1122
  options.onWait?.({
1036
1123
  runId,
1037
1124
  stepId: runningStep.id,
@@ -1098,7 +1185,14 @@ function errorMessage(error: unknown): string {
1098
1185
  }
1099
1186
 
1100
1187
  function throwIfCanceled(signal: AbortSignal | undefined, stepId: string): void {
1101
- if (signal?.aborted === true) throw new Error(`waiting for running step "${stepId}" was canceled`);
1188
+ if (signal?.aborted === true) {
1189
+ throw new Error(`waiting for running step "${stepId}" was canceled${cancelCause(signal)}`, { cause: signal.reason });
1190
+ }
1191
+ }
1192
+
1193
+ /** Why a wait was canceled, when whoever aborted it said (a lost lease does). */
1194
+ function cancelCause(signal: AbortSignal): string {
1195
+ return signal.reason instanceof Error && signal.reason.name !== 'AbortError' ? `: ${signal.reason.message}` : '';
1102
1196
  }
1103
1197
 
1104
1198
  export function delay(ms: number, signal?: AbortSignal): Promise<void> {
@@ -1112,7 +1206,9 @@ export function delay(ms: number, signal?: AbortSignal): Promise<void> {
1112
1206
  const cancel = (): void => {
1113
1207
  clearTimeout(timer);
1114
1208
  signal.removeEventListener('abort', cancel);
1115
- rejectDelay(new Error('worker wait canceled'));
1209
+ // Carries the abort reason: a bare "worker wait canceled" was all a
1210
+ // lost root lease ever reported (customer rw_3a0fcb71).
1211
+ rejectDelay(new Error(`worker wait canceled${cancelCause(signal)}`, { cause: signal.reason }));
1116
1212
  };
1117
1213
  if (signal.aborted) cancel();
1118
1214
  else signal.addEventListener('abort', cancel, { once: true });
@@ -63,6 +63,8 @@ export async function stepFailureDetails(
63
63
  // Keyed by step, so interleaved steps never pool their attempts and a page
64
64
  // boundary never splits one step's history.
65
65
  const histories = new Map<string, AttemptHistory>();
66
+ // Attempts the kernel itself declared dead (see `unchargedAttempts`).
67
+ const uncharged = new Map<string, number>();
66
68
  while (true) {
67
69
  const { entries } = await client.journalRead(runId, fromSeq, 100);
68
70
  if (entries.length === 0) break;
@@ -93,6 +95,7 @@ export async function stepFailureDetails(
93
95
  // holds every entry; only this diagnostic's candidate is cleared.
94
96
  if (payload === undefined || typeof completionReason !== 'string' || completionReason === 'success') {
95
97
  histories.delete(stepId);
98
+ uncharged.delete(stepId);
96
99
  continue;
97
100
  }
98
101
  const history = histories.get(stepId) ?? { records: [], causes: [] };
@@ -101,6 +104,10 @@ export async function stepFailureDetails(
101
104
  history.records.push(attemptFailure(entry['attempt'], completionReason, payload['disposition'], payload));
102
105
  history.causes.push(failureCause(completionReason, payload));
103
106
  histories.set(stepId, history);
107
+ if (payload['disposition'] !== 'step_done' && payload['completed_by'] === 'kernel'
108
+ && (completionReason === 'crashed' || completionReason === 'lease_expired')) {
109
+ uncharged.set(stepId, (uncharged.get(stepId) ?? 0) + 1);
110
+ }
104
111
  // A terminal completion that is not a success is the failure, whatever
105
112
  // its step type. The old predicate also demanded a non-zero `exit_code`,
106
113
  // which no agent completion carries and which a deterministic step that
@@ -116,6 +123,7 @@ export async function stepFailureDetails(
116
123
  ...(stepType === undefined ? {} : { stepType }),
117
124
  ...(typeof attempt === 'number' && Number.isSafeInteger(attempt) && attempt > 0 ? { attempt } : {}),
118
125
  ...(maxIterations === undefined ? {} : { maxIterations }),
126
+ ...((uncharged.get(stepId) ?? 0) > 0 ? { unchargedAttempts: uncharged.get(stepId) } : {}),
119
127
  ...terminalEvidence(payload),
120
128
  // A single failed attempt is already fully described by the scalars
121
129
  // above; repeating it as a one-element history would add a clause to
@@ -169,6 +177,16 @@ export function renderStepEvidence(details: StepFailedDetails): string {
169
177
 
170
178
  function renderAttempt(details: StepFailedDetails): string {
171
179
  if (details.attempt === undefined) return '';
180
+ const uncharged = details.unchargedAttempts ?? 0;
181
+ // `attempt=N/M` compares like with like only when every attempt was charged.
182
+ // A worker that died (crashed / lease_expired) costs no iteration, so a
183
+ // one-iteration step can legitimately run attempt 2 -- printed as
184
+ // `attempt=2/1` it read as the budget being overrun. Say which is which.
185
+ if (uncharged > 0 && uncharged < details.attempt) {
186
+ return ` attempt=${details.attempt}`
187
+ + ` (iteration ${details.attempt - uncharged}${details.maxIterations === undefined ? '' : `/${details.maxIterations}`};`
188
+ + ` ${uncharged} earlier attempt${uncharged === 1 ? '' : 's'} lost to a dead worker, not charged)`;
189
+ }
172
190
  return ` attempt=${details.attempt}`
173
191
  + (details.maxIterations === undefined ? '' : `/${details.maxIterations}`);
174
192
  }
@@ -163,11 +163,29 @@ export const CLI_VERBS = [
163
163
  { flags: '--name <name>', description: 'Name for the hosted listener' },
164
164
  { flags: '--draft', description: 'Create the listener without activating it' },
165
165
  { flags: '--plugin <ref>', description: 'Send-only GitHub flow-extension ref; repeatable. Does not write flows.json' },
166
+ { flags: '--flow <name|listener-id>', description: 'Deploy the source as the next version of this flow; its settings stay unchanged' },
166
167
  NO_CONNECT_OPTION,
167
168
  JSON_OPTION,
168
169
  ],
169
170
  variants: ['deploy', 'cloud-deploy'],
170
171
  },
172
+ {
173
+ name: 'versions',
174
+ description: 'List a hosted flow’s source versions, the active one marked',
175
+ args: [{ name: 'flow', description: 'Flow name or listener id', required: true }],
176
+ options: [JSON_OPTION],
177
+ variants: ['versions'],
178
+ },
179
+ {
180
+ name: 'rollback',
181
+ description: 'Make an earlier (or later) recorded version of a hosted flow the active one',
182
+ args: [
183
+ { name: 'flow', description: 'Flow name or listener id', required: true },
184
+ { name: 'version', description: 'Version number to activate', required: true },
185
+ ],
186
+ options: [JSON_OPTION],
187
+ variants: ['rollback'],
188
+ },
171
189
  {
172
190
  name: 'deployments',
173
191
  description: 'List this workspace’s hosted trigger listeners',
package/src/cli.ts CHANGED
@@ -44,6 +44,7 @@ import { checkTypeScriptFlow } from './cli/check-typescript.js';
44
44
  import { runCloudCli } from './cli/cloud-run.js';
45
45
  import { runCloudSyncCli } from './cli/cloud-sync.js';
46
46
  import { parseCloudDeployArgs, runCloudDeployCli, runCloudDeploymentsCli, runCloudUndeployCli, type CloudDeployArgs } from './cli/cloud-deploy.js';
47
+ import { runCloudRollbackCli, runCloudVersionsCli } from './cli/cloud-versions.js';
47
48
  import { parseCloudScheduleArgs, runCloudScheduleCli, runCloudSchedulesCli, runCloudUnscheduleCli, type CloudScheduleArgs } from './cli/cloud-schedule.js';
48
49
  import { isAuthoredFlowPath } from './direct-input.js';
49
50
  import { parseDeployArgs, runDeploy, type DeployArgs } from './cli/deploy.js';
@@ -97,6 +98,8 @@ export type ParsedArgs =
97
98
  | CloudDeployArgs
98
99
  | { command: 'deployments'; json: boolean }
99
100
  | { command: 'undeploy'; agentId: string; json: boolean }
101
+ | { command: 'versions'; flow: string; json: boolean }
102
+ | { command: 'rollback'; flow: string; version: string; json: boolean }
100
103
  | CloudScheduleArgs
101
104
  | { command: 'schedules'; json: boolean }
102
105
  | { command: 'unschedule'; scheduleId: string; json: boolean }
@@ -123,7 +126,10 @@ const USAGE = [
123
126
  'flows build [--out <dir>] <flow.yaml|flow.ts>',
124
127
  'flows build --verify <bundle-dir>',
125
128
  'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--plugin <ref>] [--no-connect] [--json]',
129
+ 'flows deploy <flow.ts> --flow <name|listener-id> [--plugin <ref>] [--no-connect] [--json]',
126
130
  'flows deployments [--json]',
131
+ 'flows versions [--json] <name|listener-id>',
132
+ 'flows rollback [--json] <name|listener-id> <version>',
127
133
  'flows undeploy [--json] <deployment-id>',
128
134
  '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]',
129
135
  'flows schedules [--json]',
@@ -244,6 +250,8 @@ export async function runCli(
244
250
  if (parsed.command === 'cloud-deploy') return runCloudDeployCli(parsed, io);
245
251
  if (parsed.command === 'deployments') return runCloudDeploymentsCli(parsed, io);
246
252
  if (parsed.command === 'undeploy') return runCloudUndeployCli(parsed, io);
253
+ if (parsed.command === 'versions') return runCloudVersionsCli(parsed, io);
254
+ if (parsed.command === 'rollback') return runCloudRollbackCli(parsed, io);
247
255
  if (parsed.command === 'schedule') return runCloudScheduleCli(parsed, io);
248
256
  if (parsed.command === 'schedules') return runCloudSchedulesCli(parsed, io);
249
257
  if (parsed.command === 'unschedule') return runCloudUnscheduleCli(parsed, io);
@@ -631,6 +639,13 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
631
639
  if (json > 1 || rest.length !== 1 || rest[0]!.startsWith('-')) return undefined;
632
640
  return { command: 'undeploy', agentId: rest[0]!, json: json === 1 };
633
641
  }
642
+ if (command === 'versions' || command === 'rollback') {
643
+ const rest = args.slice(1).filter(a => a !== '--json');
644
+ const json = args.length - 1 - rest.length;
645
+ if (json > 1 || rest.some(a => a.startsWith('-'))) return undefined;
646
+ if (command === 'versions') return rest.length === 1 ? { command, flow: rest[0]!, json: json === 1 } : undefined;
647
+ return rest.length === 2 ? { command, flow: rest[0]!, version: rest[1]!, json: json === 1 } : undefined;
648
+ }
634
649
  if (command === 'deployments') {
635
650
  const rest = args.slice(1);
636
651
  if (rest.length > 1 || (rest.length === 1 && rest[0] !== '--json')) return undefined;