@relayflows/sdk 2.0.30 → 2.0.32

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 (347) hide show
  1. package/dist/agent-artifacts.d.ts.map +1 -1
  2. package/dist/agent-artifacts.js +2 -9
  3. package/dist/agent-artifacts.js.map +1 -1
  4. package/dist/agent-cwd.d.ts +69 -0
  5. package/dist/agent-cwd.d.ts.map +1 -0
  6. package/dist/agent-cwd.js +140 -0
  7. package/dist/agent-cwd.js.map +1 -0
  8. package/dist/artifact-scan-policy.d.ts +35 -0
  9. package/dist/artifact-scan-policy.d.ts.map +1 -0
  10. package/dist/artifact-scan-policy.js +49 -0
  11. package/dist/artifact-scan-policy.js.map +1 -0
  12. package/dist/authored-completion-record.d.ts +35 -0
  13. package/dist/authored-completion-record.d.ts.map +1 -0
  14. package/dist/authored-completion-record.js +92 -0
  15. package/dist/authored-completion-record.js.map +1 -0
  16. package/dist/authored-completion.d.ts +99 -0
  17. package/dist/authored-completion.d.ts.map +1 -0
  18. package/dist/authored-completion.js +184 -0
  19. package/dist/authored-completion.js.map +1 -0
  20. package/dist/authored-flow-error.d.ts +13 -1
  21. package/dist/authored-flow-error.d.ts.map +1 -1
  22. package/dist/authored-flow-error.js +12 -0
  23. package/dist/authored-flow-error.js.map +1 -1
  24. package/dist/authored-flow-executor.d.ts +7 -30
  25. package/dist/authored-flow-executor.d.ts.map +1 -1
  26. package/dist/authored-flow-executor.js +54 -47
  27. package/dist/authored-flow-executor.js.map +1 -1
  28. package/dist/authored-human.d.ts.map +1 -1
  29. package/dist/authored-human.js +1 -3
  30. package/dist/authored-human.js.map +1 -1
  31. package/dist/authored-node-entry.js +4 -1
  32. package/dist/authored-node-entry.js.map +1 -1
  33. package/dist/authored-node-runner.d.ts +9 -1
  34. package/dist/authored-node-runner.d.ts.map +1 -1
  35. package/dist/authored-node-runner.js +92 -17
  36. package/dist/authored-node-runner.js.map +1 -1
  37. package/dist/authored-preflight.d.ts +4 -0
  38. package/dist/authored-preflight.d.ts.map +1 -0
  39. package/dist/authored-preflight.js +50 -0
  40. package/dist/authored-preflight.js.map +1 -0
  41. package/dist/authored-root.d.ts +5 -0
  42. package/dist/authored-root.d.ts.map +1 -1
  43. package/dist/authored-root.js +20 -2
  44. package/dist/authored-root.js.map +1 -1
  45. package/dist/authored-step-output.d.ts +35 -10
  46. package/dist/authored-step-output.d.ts.map +1 -1
  47. package/dist/authored-step-output.js +108 -40
  48. package/dist/authored-step-output.js.map +1 -1
  49. package/dist/authored-verdict.d.ts +28 -0
  50. package/dist/authored-verdict.d.ts.map +1 -0
  51. package/dist/authored-verdict.js +79 -0
  52. package/dist/authored-verdict.js.map +1 -0
  53. package/dist/authored-worker-step.d.ts +5 -2
  54. package/dist/authored-worker-step.d.ts.map +1 -1
  55. package/dist/authored-worker-step.js +51 -13
  56. package/dist/authored-worker-step.js.map +1 -1
  57. package/dist/cli/check-worker-surface.d.ts +27 -0
  58. package/dist/cli/check-worker-surface.d.ts.map +1 -0
  59. package/dist/cli/check-worker-surface.js +63 -0
  60. package/dist/cli/check-worker-surface.js.map +1 -0
  61. package/dist/cli/check.d.ts +28 -3
  62. package/dist/cli/check.d.ts.map +1 -1
  63. package/dist/cli/check.js +27 -140
  64. package/dist/cli/check.js.map +1 -1
  65. package/dist/cli/cli-probe.d.ts +20 -0
  66. package/dist/cli/cli-probe.d.ts.map +1 -0
  67. package/dist/cli/cli-probe.js +166 -0
  68. package/dist/cli/cli-probe.js.map +1 -0
  69. package/dist/cli/cloud-format.d.ts +9 -0
  70. package/dist/cli/cloud-format.d.ts.map +1 -0
  71. package/dist/cli/cloud-format.js +77 -0
  72. package/dist/cli/cloud-format.js.map +1 -0
  73. package/dist/cli/cloud-live.d.ts +39 -0
  74. package/dist/cli/cloud-live.d.ts.map +1 -0
  75. package/dist/cli/cloud-live.js +325 -0
  76. package/dist/cli/cloud-live.js.map +1 -0
  77. package/dist/cli/cloud-mirror-session.d.ts +97 -0
  78. package/dist/cli/cloud-mirror-session.d.ts.map +1 -0
  79. package/dist/cli/cloud-mirror-session.js +264 -0
  80. package/dist/cli/cloud-mirror-session.js.map +1 -0
  81. package/dist/cli/cloud-read.d.ts +3 -7
  82. package/dist/cli/cloud-read.d.ts.map +1 -1
  83. package/dist/cli/cloud-read.js +14 -266
  84. package/dist/cli/cloud-read.js.map +1 -1
  85. package/dist/cli/cloud-refusal.d.ts +39 -0
  86. package/dist/cli/cloud-refusal.d.ts.map +1 -0
  87. package/dist/cli/cloud-refusal.js +95 -0
  88. package/dist/cli/cloud-refusal.js.map +1 -0
  89. package/dist/cli/cloud-status-view.d.ts +7 -0
  90. package/dist/cli/cloud-status-view.d.ts.map +1 -0
  91. package/dist/cli/cloud-status-view.js +214 -0
  92. package/dist/cli/cloud-status-view.js.map +1 -0
  93. package/dist/cli/direct-run.d.ts.map +1 -1
  94. package/dist/cli/direct-run.js +16 -2
  95. package/dist/cli/direct-run.js.map +1 -1
  96. package/dist/cli/hn-monitor.d.ts +0 -13
  97. package/dist/cli/hn-monitor.d.ts.map +1 -1
  98. package/dist/cli/hn-monitor.js +2 -1
  99. package/dist/cli/hn-monitor.js.map +1 -1
  100. package/dist/cli/local-agent-remedy.d.ts +80 -0
  101. package/dist/cli/local-agent-remedy.d.ts.map +1 -0
  102. package/dist/cli/local-agent-remedy.js +116 -0
  103. package/dist/cli/local-agent-remedy.js.map +1 -0
  104. package/dist/cli/observer-session.d.ts +41 -0
  105. package/dist/cli/observer-session.d.ts.map +1 -0
  106. package/dist/cli/observer-session.js +78 -0
  107. package/dist/cli/observer-session.js.map +1 -0
  108. package/dist/cli/run.d.ts +30 -2
  109. package/dist/cli/run.d.ts.map +1 -1
  110. package/dist/cli/run.js +186 -24
  111. package/dist/cli/run.js.map +1 -1
  112. package/dist/cli/status.d.ts +9 -0
  113. package/dist/cli/status.d.ts.map +1 -1
  114. package/dist/cli/status.js +31 -2
  115. package/dist/cli/status.js.map +1 -1
  116. package/dist/cli/step-evidence.d.ts +90 -0
  117. package/dist/cli/step-evidence.d.ts.map +1 -0
  118. package/dist/cli/step-evidence.js +259 -0
  119. package/dist/cli/step-evidence.js.map +1 -0
  120. package/dist/cli/step-failure.d.ts +14 -4
  121. package/dist/cli/step-failure.d.ts.map +1 -1
  122. package/dist/cli/step-failure.js +41 -76
  123. package/dist/cli/step-failure.js.map +1 -1
  124. package/dist/cli-commands.d.ts +12 -0
  125. package/dist/cli-commands.d.ts.map +1 -1
  126. package/dist/cli-commands.js +3 -0
  127. package/dist/cli-commands.js.map +1 -1
  128. package/dist/cli.d.ts +17 -0
  129. package/dist/cli.d.ts.map +1 -1
  130. package/dist/cli.js +134 -57
  131. package/dist/cli.js.map +1 -1
  132. package/dist/cloud-deploy.d.ts +1 -1
  133. package/dist/cloud-deploy.d.ts.map +1 -1
  134. package/dist/cloud-deploy.js +24 -8
  135. package/dist/cloud-deploy.js.map +1 -1
  136. package/dist/cloud-mirror-ledger.d.ts +40 -0
  137. package/dist/cloud-mirror-ledger.d.ts.map +1 -0
  138. package/dist/cloud-mirror-ledger.js +142 -0
  139. package/dist/cloud-mirror-ledger.js.map +1 -0
  140. package/dist/cloud-mirror-step.d.ts +168 -0
  141. package/dist/cloud-mirror-step.d.ts.map +1 -0
  142. package/dist/cloud-mirror-step.js +504 -0
  143. package/dist/cloud-mirror-step.js.map +1 -0
  144. package/dist/cloud-mirror-transport.d.ts +94 -0
  145. package/dist/cloud-mirror-transport.d.ts.map +1 -0
  146. package/dist/cloud-mirror-transport.js +163 -0
  147. package/dist/cloud-mirror-transport.js.map +1 -0
  148. package/dist/cloud-mirror.d.ts +132 -0
  149. package/dist/cloud-mirror.d.ts.map +1 -0
  150. package/dist/cloud-mirror.js +556 -0
  151. package/dist/cloud-mirror.js.map +1 -0
  152. package/dist/cloud-read.d.ts +15 -0
  153. package/dist/cloud-read.d.ts.map +1 -1
  154. package/dist/cloud-read.js +20 -1
  155. package/dist/cloud-read.js.map +1 -1
  156. package/dist/cloud-run-record.d.ts +27 -0
  157. package/dist/cloud-run-record.d.ts.map +1 -0
  158. package/dist/cloud-run-record.js +56 -0
  159. package/dist/cloud-run-record.js.map +1 -0
  160. package/dist/cloud-run.d.ts +2 -9
  161. package/dist/cloud-run.d.ts.map +1 -1
  162. package/dist/cloud-run.js +3 -20
  163. package/dist/cloud-run.js.map +1 -1
  164. package/dist/communication/local.d.ts.map +1 -1
  165. package/dist/communication/local.js +2 -1
  166. package/dist/communication/local.js.map +1 -1
  167. package/dist/communication/worker.d.ts +1 -1
  168. package/dist/communication/worker.d.ts.map +1 -1
  169. package/dist/communication/worker.js +13 -5
  170. package/dist/communication/worker.js.map +1 -1
  171. package/dist/compile.d.ts.map +1 -1
  172. package/dist/compile.js +28 -6
  173. package/dist/compile.js.map +1 -1
  174. package/dist/direct-input.d.ts.map +1 -1
  175. package/dist/direct-input.js +21 -1
  176. package/dist/direct-input.js.map +1 -1
  177. package/dist/failure-kinds.d.ts +96 -6
  178. package/dist/failure-kinds.d.ts.map +1 -1
  179. package/dist/failure-kinds.js +29 -2
  180. package/dist/failure-kinds.js.map +1 -1
  181. package/dist/flow-requirements.d.ts.map +1 -1
  182. package/dist/flow-requirements.js +24 -9
  183. package/dist/flow-requirements.js.map +1 -1
  184. package/dist/gate-contract.d.ts +7 -0
  185. package/dist/gate-contract.d.ts.map +1 -1
  186. package/dist/gate-contract.js +6 -1
  187. package/dist/gate-contract.js.map +1 -1
  188. package/dist/helper-preflight.d.ts.map +1 -1
  189. package/dist/helper-preflight.js +10 -3
  190. package/dist/helper-preflight.js.map +1 -1
  191. package/dist/helper-reference.d.ts +25 -0
  192. package/dist/helper-reference.d.ts.map +1 -0
  193. package/dist/helper-reference.js +129 -0
  194. package/dist/helper-reference.js.map +1 -0
  195. package/dist/hosted-extension-runtime.js +1 -1
  196. package/dist/index.d.ts +1 -1
  197. package/dist/index.d.ts.map +1 -1
  198. package/dist/index.js.map +1 -1
  199. package/dist/journal-client.d.ts +3 -1
  200. package/dist/journal-client.d.ts.map +1 -1
  201. package/dist/journal-client.js +17 -2
  202. package/dist/journal-client.js.map +1 -1
  203. package/dist/journal-projection.d.ts +16 -0
  204. package/dist/journal-projection.d.ts.map +1 -0
  205. package/dist/journal-projection.js +108 -0
  206. package/dist/journal-projection.js.map +1 -0
  207. package/dist/llm-worker.js +1 -1
  208. package/dist/llm-worker.js.map +1 -1
  209. package/dist/local-agent.d.ts +1 -1
  210. package/dist/local-agent.d.ts.map +1 -1
  211. package/dist/local-agent.js +4 -3
  212. package/dist/local-agent.js.map +1 -1
  213. package/dist/named-gate-lowering.js +35 -5
  214. package/dist/named-gate-lowering.js.map +1 -1
  215. package/dist/named-gate-preflight.d.ts +27 -0
  216. package/dist/named-gate-preflight.d.ts.map +1 -0
  217. package/dist/named-gate-preflight.js +47 -0
  218. package/dist/named-gate-preflight.js.map +1 -0
  219. package/dist/named-gates.d.ts +1 -1
  220. package/dist/named-gates.d.ts.map +1 -1
  221. package/dist/named-gates.js +3 -0
  222. package/dist/named-gates.js.map +1 -1
  223. package/dist/observer-link.d.ts +7 -0
  224. package/dist/observer-link.d.ts.map +1 -1
  225. package/dist/observer-link.js +3 -1
  226. package/dist/observer-link.js.map +1 -1
  227. package/dist/package-version.d.ts +10 -0
  228. package/dist/package-version.d.ts.map +1 -0
  229. package/dist/package-version.js +22 -0
  230. package/dist/package-version.js.map +1 -0
  231. package/dist/preflight.d.ts +14 -0
  232. package/dist/preflight.d.ts.map +1 -1
  233. package/dist/preflight.js +25 -3
  234. package/dist/preflight.js.map +1 -1
  235. package/dist/protocol.d.ts +2 -0
  236. package/dist/protocol.d.ts.map +1 -1
  237. package/dist/redact.d.ts +42 -0
  238. package/dist/redact.d.ts.map +1 -1
  239. package/dist/redact.js +128 -0
  240. package/dist/redact.js.map +1 -1
  241. package/dist/relay-cli.d.ts.map +1 -1
  242. package/dist/relay-cli.js +1 -21
  243. package/dist/relay-cli.js.map +1 -1
  244. package/dist/run-projection.d.ts +83 -0
  245. package/dist/run-projection.d.ts.map +1 -0
  246. package/dist/run-projection.js +199 -0
  247. package/dist/run-projection.js.map +1 -0
  248. package/dist/shell-word.d.ts +19 -0
  249. package/dist/shell-word.d.ts.map +1 -0
  250. package/dist/shell-word.js +23 -0
  251. package/dist/shell-word.js.map +1 -0
  252. package/dist/spec.d.ts +52 -3
  253. package/dist/spec.d.ts.map +1 -1
  254. package/dist/spec.js.map +1 -1
  255. package/dist/step-fields.d.ts +1 -1
  256. package/dist/step-fields.js +1 -1
  257. package/dist/step-fields.js.map +1 -1
  258. package/dist/steps-green.d.ts +27 -0
  259. package/dist/steps-green.d.ts.map +1 -0
  260. package/dist/steps-green.js +164 -0
  261. package/dist/steps-green.js.map +1 -0
  262. package/dist/validate.d.ts.map +1 -1
  263. package/dist/validate.js +39 -8
  264. package/dist/validate.js.map +1 -1
  265. package/dist/worker-lease.d.ts +8 -1
  266. package/dist/worker-lease.d.ts.map +1 -1
  267. package/dist/worker-lease.js +34 -4
  268. package/dist/worker-lease.js.map +1 -1
  269. package/dist/worker.d.ts +8 -0
  270. package/dist/worker.d.ts.map +1 -1
  271. package/dist/worker.js +17 -9
  272. package/dist/worker.js.map +1 -1
  273. package/package.json +3 -7
  274. package/src/agent-artifacts.ts +2 -10
  275. package/src/agent-cwd.ts +139 -0
  276. package/src/artifact-scan-policy.ts +51 -0
  277. package/src/authored-completion-record.ts +119 -0
  278. package/src/authored-completion.ts +203 -0
  279. package/src/authored-flow-error.ts +13 -1
  280. package/src/authored-flow-executor.ts +91 -58
  281. package/src/authored-human.ts +1 -4
  282. package/src/authored-node-entry.ts +4 -1
  283. package/src/authored-node-runner.ts +96 -22
  284. package/src/authored-preflight.ts +46 -0
  285. package/src/authored-root.ts +20 -2
  286. package/src/authored-step-output.ts +139 -42
  287. package/src/authored-verdict.ts +87 -0
  288. package/src/authored-worker-step.ts +63 -14
  289. package/src/cli/check-worker-surface.ts +66 -0
  290. package/src/cli/check.ts +59 -166
  291. package/src/cli/cli-probe.ts +194 -0
  292. package/src/cli/cloud-format.ts +82 -0
  293. package/src/cli/cloud-live.ts +366 -0
  294. package/src/cli/cloud-mirror-session.ts +329 -0
  295. package/src/cli/cloud-read.ts +13 -282
  296. package/src/cli/cloud-refusal.ts +113 -0
  297. package/src/cli/cloud-status-view.ts +213 -0
  298. package/src/cli/direct-run.ts +19 -2
  299. package/src/cli/hn-monitor.ts +2 -1
  300. package/src/cli/local-agent-remedy.ts +160 -0
  301. package/src/cli/observer-session.ts +107 -0
  302. package/src/cli/run.ts +233 -28
  303. package/src/cli/status.ts +59 -3
  304. package/src/cli/step-evidence.ts +310 -0
  305. package/src/cli/step-failure.ts +57 -81
  306. package/src/cli-commands.ts +3 -0
  307. package/src/cli.ts +155 -60
  308. package/src/cloud-deploy.ts +26 -8
  309. package/src/cloud-mirror-ledger.ts +161 -0
  310. package/src/cloud-mirror-step.ts +618 -0
  311. package/src/cloud-mirror-transport.ts +261 -0
  312. package/src/cloud-mirror.ts +633 -0
  313. package/src/cloud-read.ts +25 -1
  314. package/src/cloud-run-record.ts +64 -0
  315. package/src/cloud-run.ts +5 -24
  316. package/src/communication/local.ts +2 -1
  317. package/src/communication/worker.ts +13 -5
  318. package/src/compile.ts +28 -6
  319. package/src/direct-input.ts +22 -1
  320. package/src/failure-kinds.ts +104 -2
  321. package/src/flow-requirements.ts +24 -9
  322. package/src/gate-contract.ts +13 -1
  323. package/src/helper-preflight.ts +10 -3
  324. package/src/helper-reference.ts +130 -0
  325. package/src/hosted-extension-runtime.ts +1 -1
  326. package/src/index.ts +2 -0
  327. package/src/journal-client.ts +19 -2
  328. package/src/journal-projection.ts +106 -0
  329. package/src/llm-worker.ts +1 -1
  330. package/src/local-agent.ts +4 -2
  331. package/src/named-gate-lowering.ts +35 -5
  332. package/src/named-gate-preflight.ts +47 -0
  333. package/src/named-gates.ts +3 -0
  334. package/src/observer-link.ts +10 -1
  335. package/src/package-version.ts +23 -0
  336. package/src/preflight.ts +38 -7
  337. package/src/protocol.ts +2 -0
  338. package/src/redact.ts +111 -0
  339. package/src/relay-cli.ts +1 -24
  340. package/src/run-projection.ts +247 -0
  341. package/src/shell-word.ts +24 -0
  342. package/src/spec.ts +54 -3
  343. package/src/step-fields.ts +1 -1
  344. package/src/steps-green.ts +168 -0
  345. package/src/validate.ts +35 -8
  346. package/src/worker-lease.ts +35 -5
  347. package/src/worker.ts +27 -11
package/src/preflight.ts CHANGED
@@ -12,6 +12,7 @@ import { compileSpec, CompileError } from './compile.js';
12
12
  import { helperCall } from './yaml-helpers.js';
13
13
  import { resolveCliModelSelection, type CliModelSource } from './cli-adapter.js';
14
14
  import { isNamedGate, NAMED_GATE_FAILURE_KINDS, type NamedGateFailureKind } from './named-gates.js';
15
+ import { namedGateScanCoverageDiagnostics } from './named-gate-preflight.js';
15
16
  import { compileScopes, type ScopeInput, type MountRegistry } from './scope-compiler.js';
16
17
  import { readMountRegistry } from './mount-registry.js';
17
18
  import type {
@@ -64,7 +65,7 @@ export class CliProbeError extends Error {
64
65
  }
65
66
  }
66
67
 
67
- type CliProbeOutcome =
68
+ export type CliProbeOutcome =
68
69
  | { result: CliProbeResult }
69
70
  | { failure: CliProbeFailureDetail | null };
70
71
 
@@ -88,6 +89,8 @@ export interface PreflightProbes {
88
89
  }
89
90
 
90
91
  export interface PreflightOptions {
92
+ /** Reuse only within one run and one CLI resolution environment; failures are cached too. */
93
+ cliProbeCache?: Map<string, CliProbeOutcome>;
91
94
  pluginSearchStart?: string;
92
95
  /** Validated tools.mcp header and nearest flows.json connections. */
93
96
  mcpServers?: readonly string[];
@@ -98,6 +101,7 @@ export interface PreflightOptions {
98
101
  /** Exact, project-owned model allowlist from the nearest flows.json. */
99
102
  models?: readonly string[];
100
103
  modelRegistryPath?: string;
104
+ probeCache?: Map<string, CliProbeOutcome>;
101
105
  probes: PreflightProbes;
102
106
  }
103
107
 
@@ -193,7 +197,12 @@ async function probeMcp(result: PreflightResult, options: PreflightOptions): Pro
193
197
  return { ...result, ok: !result.diagnostics.some(d => d.severity === 'refusal'), mcpTools: Object.freeze(inventory) };
194
198
  }
195
199
 
196
- function preflightSync(flow: unknown, options: PreflightOptions): PreflightResult {
200
+ type ResolutionOptions = Omit<PreflightOptions, 'probes' | 'probeCache'>;
201
+
202
+ /** Resolve static declarations before any CLI, model, command or trigger probe. */
203
+ export function resolvePreflight(flow: unknown, options: ResolutionOptions): PreflightResult & {
204
+ compiled?: import('./compile.js').CompiledFlowSpec;
205
+ } {
197
206
  // Compile before touching any environment fact. `compileSpec` snapshots raw
198
207
  // input into inert data, validates it against the closed authoring schema,
199
208
  // and lowers `output` sugar into its json_schema gate — so the gate plan
@@ -234,7 +243,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
234
243
  const cliResolutionDiagnostics: PreflightDiagnostic[] = [];
235
244
  const resolutions: CliResolution[] = [];
236
245
  const resolutionByStep = new Map<string, CliResolution>();
237
- const cliProbeResults = new Map<string, CliProbeOutcome>();
246
+ const cliProbeResults = options.cliProbeCache ?? new Map<string, CliProbeOutcome>();
238
247
 
239
248
  // A pure fact about the compiled snapshot, collected before anything that
240
249
  // can return early. An unresolved CLI, an unknown model or a bad scope all
@@ -242,6 +251,12 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
242
251
  // permissions they declared on the same file are not enforced.
243
252
  diagnostics.push(...permissionsDiagnostics(compiled));
244
253
  diagnostics.push(...scopeDiagnostics(compiled, options));
254
+ // Same rule, same reason: whether the bundled worker's artifact scan can
255
+ // reach an `artifact_exists` path is decidable from the snapshot, so it is
256
+ // collected here rather than beside `probeNamedGate` below — which CLI
257
+ // resolution, an unknown model, a bad scope or a budget refusal all return
258
+ // before.
259
+ diagnostics.push(...compiled.steps.flatMap(namedGateScanCoverageDiagnostics));
245
260
  for (const server of new Set(options.mcpServers ?? [])) {
246
261
  if (options.mcp !== undefined && Object.hasOwn(options.mcp, server)) continue;
247
262
  diagnostics.push({ severity: 'refusal', kind: 'mcp_undeclared_server', server,
@@ -282,6 +297,18 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
282
297
  return { ok: false, gates: compiled.steps.map(inspectStepGate), resolutions, diagnostics };
283
298
  }
284
299
 
300
+ return { ok: true, gates: compiled.steps.map(inspectStepGate), resolutions, diagnostics, compiled };
301
+ }
302
+
303
+ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResult {
304
+ const { compiled, ...result } = resolvePreflight(flow, options);
305
+ if (!result.ok || compiled === undefined) return result;
306
+ const { diagnostics, resolutions } = result;
307
+ const resolutionByStep = new Map(resolutions.map(resolution => [resolution.stepId, resolution]));
308
+ // `cliProbeCache` is the public run-scoped name; `probeCache` is retained as
309
+ // a compatibility alias for the async authored-preflight path.
310
+ const cliProbeResults = options.cliProbeCache ?? options.probeCache ?? new Map<string, CliProbeOutcome>();
311
+
285
312
  for (const step of compiled.steps) {
286
313
  probeNamedGate(step, options.probes, diagnostics);
287
314
  warnOnVacuousGate(step, diagnostics);
@@ -326,7 +353,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
326
353
  * fact about the filesystem. Missing manifest means no known mounts — a grant
327
354
  * still parses but refuses as `mount_unknown` in that case.
328
355
  */
329
- function scopeDiagnostics(flow: FlowSpec, options: PreflightOptions): PreflightRefusal[] {
356
+ function scopeDiagnostics(flow: FlowSpec, options: ResolutionOptions): PreflightRefusal[] {
330
357
  const workspace = flow.workspace;
331
358
  const toolsFs = flow.tools?.fs;
332
359
  if (workspace === undefined && toolsFs === undefined) return [];
@@ -350,7 +377,7 @@ function scopeDiagnostics(flow: FlowSpec, options: PreflightOptions): PreflightR
350
377
  /** Pure authoring validation: no executable, command, trigger, or daemon probe. */
351
378
  function unknownModelDiagnostics(
352
379
  flow: FlowSpec,
353
- options: PreflightOptions,
380
+ options: ResolutionOptions,
354
381
  resolutionByStep: ReadonlyMap<string, CliResolution> = new Map(),
355
382
  ): PreflightRefusal[] {
356
383
  const diagnostics: PreflightRefusal[] = [];
@@ -464,7 +491,7 @@ function unknownModelMessage(
464
491
  return `Step "${stepId}" declares model "${model}"${cliContext}, but it is not listed in ${source}; add the exact model only after verifying that project is allowed to use it.`;
465
492
  }
466
493
 
467
- function unresolvedCliMessage(stepId: string, options: PreflightOptions): string {
494
+ function unresolvedCliMessage(stepId: string, options: ResolutionOptions): string {
468
495
  const context = options.projectConfigPath !== undefined
469
496
  ? ` Nearest project config "${options.projectConfigPath}" declares no cli; outer configs are shadowed.`
470
497
  : options.projectSearchStart !== undefined
@@ -494,6 +521,10 @@ function resolveCli(
494
521
  return undefined;
495
522
  }
496
523
 
524
+ export function cliProbeKey(resolution: CliResolution, managed = false): string {
525
+ return JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null, managed]);
526
+ }
527
+
497
528
  function probeResolvedCli(
498
529
  resolution: CliResolution,
499
530
  probes: PreflightProbes,
@@ -507,7 +538,7 @@ function probeResolvedCli(
507
538
  // Model is part of the key: the same CLI probed with two different models
508
539
  // is two different questions, and caching on the CLI alone would let a
509
540
  // model that the CLI cannot resolve inherit an earlier model's pass.
510
- const cacheKey = JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null, managed]);
541
+ const cacheKey = cliProbeKey(resolution, managed);
511
542
  let outcome = cache.get(cacheKey);
512
543
  if (outcome === undefined) {
513
544
  try {
package/src/protocol.ts CHANGED
@@ -96,6 +96,8 @@ export type StepUsage =
96
96
  export interface RunStartParams {
97
97
  /** Caller-owned retry identity. Reuse with a different spec is refused. */
98
98
  admission_key?: string;
99
+ /** Push the new run's entries to this connection from `run.spawned` on, as `run.watch` does. */
100
+ watch?: boolean;
99
101
  reuse_from_run_id?: string;
100
102
  /**
101
103
  * The kernel spec dialect — the ONE boundary shape `RunSpec::parse`
package/src/redact.ts CHANGED
@@ -104,3 +104,114 @@ export function redactRelayError(message: string, env: NodeJS.ProcessEnv = proce
104
104
  }
105
105
  return redact(message, env);
106
106
  }
107
+
108
+ /**
109
+ * Credential heads whose value may continue past a line boundary.
110
+ *
111
+ * Every NAMED_VALUE pattern puts `\s` between the name and the value, and
112
+ * `\s` matches newlines: `authorization:` alone at the end of a poll is a
113
+ * complete, unredacted line, while the opaque value that lands on the next
114
+ * poll arrives with no recognizable credential context and would print
115
+ * verbatim. A trailing header — the colon plus only whitespace, or for
116
+ * `authorization` one scheme word — is therefore an open context. A header
117
+ * already followed by a non-space word has its value on that line and is not
118
+ * open. `Bearer` is the one bare scheme word the patterns treat as a name.
119
+ */
120
+ const OPEN_HEADER_PATTERNS: readonly RegExp[] = [
121
+ /\bauthorization:(?:\s+\w+)?\s*$/i,
122
+ /\bx-callback-token:\s*$/i,
123
+ /\bx-nightcto-evidence-token:\s*$/i,
124
+ /\bBearer\s*$/,
125
+ ];
126
+
127
+ /** A quoted JSON field name; the value side is inspected by the scanner. */
128
+ const JSON_FIELD_HEAD = /"([A-Za-z0-9_.\-]{1,64})"/g;
129
+
130
+ /**
131
+ * Where a named-credential context may still be half-arrived at the end of
132
+ * `text`.
133
+ *
134
+ * `openSecretStart` covers secret env *values*; this covers the credential
135
+ * *names* whose patterns can span a line break: a header released on one poll
136
+ * leaves its late-arriving value unrecognizable on the next, and a credential
137
+ * JSON field whose value string is not yet closed leaks its first fragment.
138
+ * Returns the earliest index at which such a context is open — the point past
139
+ * which nothing may be released until more of the stream arrives — or
140
+ * `text.length` when none is open. Holding back is line-granular upstream, so
141
+ * a false positive delays one line one poll rather than dropping it.
142
+ */
143
+ export function openCredentialStart(text: string): number {
144
+ let hold = text.length;
145
+ for (const pattern of OPEN_HEADER_PATTERNS) {
146
+ const match = pattern.exec(text);
147
+ if (match && match.index < hold) hold = match.index;
148
+ }
149
+ JSON_FIELD_HEAD.lastIndex = 0;
150
+ let head: RegExpExecArray | null;
151
+ while ((head = JSON_FIELD_HEAD.exec(text)) !== null) {
152
+ if (!isCredentialName(head[1]!)) continue;
153
+ let i = head.index + head[0].length;
154
+ while (i < text.length && /\s/.test(text[i]!)) i++;
155
+ if (i >= text.length) { hold = Math.min(hold, head.index); continue; }
156
+ if (text[i] !== ':') continue;
157
+ i++;
158
+ while (i < text.length && /\s/.test(text[i]!)) i++;
159
+ if (i >= text.length) { hold = Math.min(hold, head.index); continue; }
160
+ if (text.startsWith('[redacted]', i) || text[i] !== '"') continue;
161
+ i++;
162
+ let closed = false;
163
+ while (i < text.length) {
164
+ if (text[i] === '\\') { i += 2; continue; }
165
+ if (text[i] === '"') { closed = true; break; }
166
+ i++;
167
+ }
168
+ if (!closed) hold = Math.min(hold, head.index);
169
+ }
170
+ return hold;
171
+ }
172
+
173
+ /**
174
+ * Does `text` end on a bare credential header — a named credential line whose
175
+ * value, if it has one, begins on the NEXT line?
176
+ *
177
+ * The stream releaser needs this apart from `openCredentialStart`: that check
178
+ * sees only a header at the very end of the consumed text, but the danger is
179
+ * wider — a released block that *ends* on `x-callback-token:` prints the
180
+ * header now and its value, arriving on the next line in some later poll,
181
+ * without the context `redact` needs. `redact` folds the header's newline into
182
+ * its own named-pattern match, so the releaser's block-prefix check cannot
183
+ * detect the dependency on its own. Trailing whitespace-only lines after the
184
+ * header count as part of it — the value is still to come.
185
+ */
186
+ export function endsWithOpenCredentialHeader(text: string): boolean {
187
+ const stripped = text.replace(/\s+$/, '');
188
+ const lastLine = stripped.slice(stripped.lastIndexOf('\n') + 1);
189
+ return OPEN_HEADER_PATTERNS.some((pattern) => pattern.test(lastLine));
190
+ }
191
+
192
+ /**
193
+ * Where a secret value may still be half-arrived at the end of `text`.
194
+ *
195
+ * A stream is redacted in pieces, and `redact` only replaces a secret it can
196
+ * see whole: releasing text up to a point where a secret has begun but not
197
+ * ended would print the first half of it and never match the second. This
198
+ * returns the smallest index `i` for which `text.slice(i)` is a proper prefix
199
+ * of some secret env value — the point past which nothing may be released
200
+ * until more of the stream arrives — or `text.length` when no value is open.
201
+ *
202
+ * Values only, not the token and header *shapes*: those are bounded by
203
+ * whitespace, so a reader that releases whole lines cannot cut one in half.
204
+ */
205
+ export function openSecretStart(text: string, env: NodeJS.ProcessEnv = process.env): number {
206
+ let start = text.length;
207
+ for (const [, value] of secretEnvValues(env)) {
208
+ // A proper prefix is shorter than the value, so it can only begin inside
209
+ // the last `value.length - 1` characters; the first character narrows the
210
+ // scan to the few positions worth comparing.
211
+ const first = Math.max(0, text.length - value.length + 1);
212
+ for (let at = text.indexOf(value[0]!, first); at >= 0 && at < start; at = text.indexOf(value[0]!, at + 1)) {
213
+ if (value.startsWith(text.slice(at))) { start = at; break; }
214
+ }
215
+ }
216
+ return start;
217
+ }
package/src/relay-cli.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { readFileSync } from 'node:fs';
2
-
3
1
  import { runCli } from './cli.js';
4
2
  import { CLI_VERBS, CLI_VERB_NAMES, type CliCommandSpec } from './cli-commands.js';
3
+ import { packageVersion } from './package-version.js';
5
4
 
6
5
  /**
7
6
  * The `@relayflows/sdk/relay-cli` entrypoint: a mountable CLI surface.
@@ -56,28 +55,6 @@ function toCommandSpec(verb: CliCommandSpec & { variants?: unknown }): CliComman
56
55
  return spec;
57
56
  }
58
57
 
59
- /**
60
- * Read this package's version from its own manifest.
61
- *
62
- * Resolved from `import.meta.url` rather than imported, because `package.json`
63
- * sits outside `rootDir` and a hardcoded literal would silently go stale at the
64
- * next release. `src/` and `dist/` are both one level below the manifest, so
65
- * the same relative path is correct before and after a build.
66
- */
67
- function packageVersion(): string {
68
- try {
69
- const manifest: unknown = JSON.parse(
70
- readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
71
- );
72
- const version = (manifest as { version?: unknown }).version;
73
- return typeof version === 'string' && version.length > 0 ? version : '0.0.0';
74
- } catch {
75
- // A surface that cannot read its own manifest is still perfectly runnable;
76
- // refusing to mount over a cosmetic field would be the worse failure.
77
- return '0.0.0';
78
- }
79
- }
80
-
81
58
  /**
82
59
  * Build the relayflows CLI surface.
83
60
  *
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Project a run's lifecycle into its Relaycast channel, `wf-<runId>`, so the
3
+ * observer dashboard shows the flow: one message per step transition, each
4
+ * carrying the whole run snapshot under `metadata.relayflow` for a renderer
5
+ * that draws the step graph.
6
+ *
7
+ * A projection, never a source of truth (RFC-0001 settled decision 7): the
8
+ * journal is the record. Publication is serialized and fire-and-forget; a
9
+ * failure is reported once through `diagnostic` and never reaches the run.
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto';
13
+ import { renderProgress, type ProgressEvent } from './progress.js';
14
+ import type { StepType } from './spec.js';
15
+
16
+ export const RELAYFLOW_METADATA_VERSION = 1;
17
+
18
+ export type ProjectedStepState = 'pending' | 'running' | 'completed' | 'failed' | 'parked';
19
+
20
+ export interface ProjectedStep {
21
+ id: string;
22
+ type: StepType;
23
+ dependsOn: string[];
24
+ state: ProjectedStepState;
25
+ attempt?: number;
26
+ elapsedMs?: number;
27
+ completionReason?: string;
28
+ }
29
+
30
+ export interface RunSnapshot {
31
+ runId: string;
32
+ flow: string;
33
+ status: 'running' | 'completed' | 'failed' | 'parked' | 'canceled';
34
+ completionReason?: string;
35
+ steps: ProjectedStep[];
36
+ }
37
+
38
+ export interface DeclaredStep {
39
+ id: string;
40
+ type: StepType;
41
+ dependsOn?: string[];
42
+ }
43
+
44
+ export interface RunProjection {
45
+ readonly channel: string;
46
+ /** Apply a transition; `publish: false` folds replayed history into the snapshot silently. */
47
+ step(event: ProgressEvent & { attempt?: number }, publish?: boolean): void;
48
+ /** Rebuild an epoch boundary from journal facts, never from old terminal state. */
49
+ epoch(states: Array<{ id: string; state: ProjectedStepState; attempt?: number; completionReason?: string }>, publish?: boolean): void;
50
+ /** Close the run; only the first call publishes. */
51
+ finish(outcome: { status: RunSnapshot['status']; completionReason?: string }): void;
52
+ /** Resolves when every queued publication settled, or after `timeoutMs`. */
53
+ drain(timeoutMs: number): Promise<void>;
54
+ /** Stop publication and remove this session's publisher after its queue settles. */
55
+ close(timeoutMs: number): Promise<void>;
56
+ }
57
+
58
+ export type ProjectionFetch = (url: string, init: {
59
+ method: string;
60
+ headers: Record<string, string>;
61
+ body?: string;
62
+ signal?: AbortSignal;
63
+ }) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
64
+
65
+ export interface RunProjectionOptions {
66
+ workspaceKey: string;
67
+ /** Relaycast API base, default `https://cast.agentrelay.com`. */
68
+ baseUrl?: string;
69
+ fetch?: ProjectionFetch;
70
+ diagnostic: (message: string) => void;
71
+ }
72
+
73
+ const DEFAULT_BASE_URL = 'https://cast.agentrelay.com';
74
+ const REQUEST_TIMEOUT_MS = 5_000;
75
+
76
+ export function runChannelName(runId: string): string {
77
+ return `wf-${runId.toLowerCase()}`;
78
+ }
79
+
80
+ export function createRunProjection(
81
+ options: RunProjectionOptions,
82
+ run: { runId: string; flow: string; steps?: DeclaredStep[]; resumed?: boolean },
83
+ ): RunProjection {
84
+ const doFetch = options.fetch ?? (globalThis.fetch as unknown as ProjectionFetch);
85
+ const base = options.baseUrl ?? DEFAULT_BASE_URL;
86
+ const channel = runChannelName(run.runId);
87
+ const snapshot: RunSnapshot = {
88
+ runId: run.runId, flow: run.flow, status: 'running',
89
+ steps: (run.steps ?? []).map(step => ({
90
+ id: step.id, type: step.type, dependsOn: step.dependsOn ?? [], state: 'pending',
91
+ })),
92
+ };
93
+
94
+ const call = async (path: string, token: string, body?: unknown, idempotencyKey?: string, method?: string): Promise<unknown> => {
95
+ const response = await doFetch(new URL(`/v1${path}`, base).toString(), {
96
+ method: method ?? (body === undefined ? 'GET' : 'POST'),
97
+ headers: {
98
+ Authorization: `Bearer ${token}`,
99
+ 'Content-Type': 'application/json',
100
+ ...(idempotencyKey === undefined ? {} : { 'Idempotency-Key': idempotencyKey }),
101
+ },
102
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
103
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
104
+ });
105
+ const parsed = await response.json().catch(() => undefined) as
106
+ { data?: unknown; error?: { code?: string } } | undefined;
107
+ if (!response.ok) {
108
+ throw new RelaycastError(response.status, parsed?.error?.code ?? `http_${response.status}`);
109
+ }
110
+ return parsed?.data;
111
+ };
112
+
113
+ // A per-session publisher: agent names are unique per workspace, and a
114
+ // resumed run publishes from a fresh session into the same channel.
115
+ const publisherName = `flow-${randomUUID()}`;
116
+ let cleanup: Promise<void> | undefined;
117
+ const removePublisher = (reportFailure = true): Promise<void> => cleanup ??= (async () => {
118
+ // The unique name is known before POST. A lost create response is not
119
+ // proof that creation failed: retire it even after an ambiguous outcome.
120
+ try {
121
+ await call(`/agents/${encodeURIComponent(publisherName)}`, options.workspaceKey, undefined, undefined, 'DELETE');
122
+ } catch (error) {
123
+ if (error instanceof RelaycastError && error.status === 404) return;
124
+ if (reportFailure) options.diagnostic(`publisher cleanup for #${channel} failed; the run is unaffected`);
125
+ }
126
+ })();
127
+ const setup = (async (): Promise<string> => {
128
+ const agent = await call('/agents', options.workspaceKey, {
129
+ name: publisherName, type: 'agent', auto_join_general: false,
130
+ }) as { token?: unknown } | undefined;
131
+ if (typeof agent?.token !== 'string') throw new RelaycastError(0, 'agent_token_missing');
132
+ const token = agent.token;
133
+ try {
134
+ await call('/channels', token, { name: channel, topic: `relayflow ${run.flow} · run ${run.runId}` });
135
+ } catch (error) {
136
+ // Agent communication in the same run may have created it first.
137
+ if (!(error instanceof RelaycastError) || error.code !== 'channel_already_exists') throw error;
138
+ await call(`/channels/${encodeURIComponent(channel)}/join`, token, {});
139
+ }
140
+ return token;
141
+ })().catch(async (error: unknown) => { await removePublisher(false); throw error; });
142
+
143
+ let queue: Promise<unknown> = setup;
144
+ let failed = false;
145
+ let sequence = 0;
146
+ let closed = false;
147
+ const sessionId = randomUUID().slice(0, 8);
148
+ const publish = (event: string, text: string): void => {
149
+ if (closed) return;
150
+ const data = { relayflow: { version: RELAYFLOW_METADATA_VERSION, event, run: structuredClone(snapshot) } };
151
+ const key = `${sessionId}-${++sequence}`;
152
+ queue = queue.then(async () => {
153
+ if (failed) return;
154
+ await call(`/channels/${encodeURIComponent(channel)}/messages`, await setup, { text, data }, key);
155
+ }).catch((error: unknown) => {
156
+ if (failed) return;
157
+ failed = true;
158
+ options.diagnostic(`run projection to #${channel} failed (${errorMessage(error)}); `
159
+ + 'the observer will not show this run, which is unaffected');
160
+ });
161
+ };
162
+
163
+ const declared = snapshot.steps.length;
164
+ publish('run.started', `▶ ${run.flow} ${run.resumed ? 'resumed' : 'started'}`
165
+ + `${declared > 0 ? ` · ${declared} step${declared === 1 ? '' : 's'}` : ''} · run ${run.runId}`);
166
+
167
+ return {
168
+ channel,
169
+ epoch(states, shouldPublish = true) {
170
+ snapshot.status = 'running';
171
+ delete snapshot.completionReason;
172
+ for (const step of snapshot.steps) {
173
+ step.state = 'pending';
174
+ delete step.attempt;
175
+ delete step.elapsedMs;
176
+ delete step.completionReason;
177
+ const state = states.find(candidate => candidate.id === step.id);
178
+ if (state !== undefined) Object.assign(step, state);
179
+ }
180
+ if (shouldPublish) publish('run.started', `▶ ${run.flow} resumed · run ${run.runId}`);
181
+ },
182
+ step(event, shouldPublish = true) {
183
+ let step = snapshot.steps.find(candidate => candidate.id === event.stepId);
184
+ if (step === undefined) {
185
+ step = { id: event.stepId, type: event.stepType, dependsOn: [], state: 'pending' };
186
+ snapshot.steps.push(step);
187
+ }
188
+ step.state = STATE[event.type];
189
+ if (event.attempt !== undefined) step.attempt = event.attempt;
190
+ if (event.type === 'step.started') {
191
+ delete step.elapsedMs;
192
+ delete step.completionReason;
193
+ } else {
194
+ step.elapsedMs = Math.max(0, Math.round(event.elapsedMs));
195
+ }
196
+ if (event.completionReason !== undefined) step.completionReason = event.completionReason;
197
+ if (!shouldPublish || event.type === 'step.running') return;
198
+ publish(event.type, event.type === 'step.started'
199
+ ? `○ ${step.id} (${step.type}) started${step.attempt !== undefined && step.attempt > 1 ? ` · attempt ${step.attempt}` : ''}`
200
+ : renderProgress([event])[0]!);
201
+ },
202
+ finish(outcome) {
203
+ if (snapshot.status !== 'running') return;
204
+ snapshot.status = outcome.status;
205
+ if (outcome.completionReason !== undefined) snapshot.completionReason = outcome.completionReason;
206
+ const icon = outcome.status === 'completed' ? '■' : outcome.status === 'parked' ? '⏸' : '✗';
207
+ publish('run.completed', `${icon} ${run.flow} ${outcome.status}`
208
+ + `${outcome.completionReason === undefined ? '' : ` · completionReason: ${outcome.completionReason}`}`);
209
+ },
210
+ async drain(timeoutMs) {
211
+ let timer: NodeJS.Timeout | undefined;
212
+ await Promise.race([
213
+ queue,
214
+ new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMs); timer.unref?.(); }),
215
+ ]);
216
+ clearTimeout(timer);
217
+ },
218
+ async close(timeoutMs) {
219
+ if (!closed) {
220
+ closed = true;
221
+ queue = queue.then(() => removePublisher(), () => removePublisher());
222
+ }
223
+ let timer: NodeJS.Timeout | undefined;
224
+ try {
225
+ await Promise.race([queue, new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMs); timer.unref?.(); })]);
226
+ } finally { clearTimeout(timer); }
227
+ },
228
+ };
229
+ }
230
+
231
+ const STATE: Record<ProgressEvent['type'], ProjectedStepState> = {
232
+ 'step.started': 'running',
233
+ 'step.running': 'running',
234
+ 'step.completed': 'completed',
235
+ 'step.failed': 'failed',
236
+ 'step.parked': 'parked',
237
+ };
238
+
239
+ class RelaycastError extends Error {
240
+ constructor(readonly status: number, readonly code: string) {
241
+ super(status === 0 ? code : `HTTP ${status} ${code}`);
242
+ }
243
+ }
244
+
245
+ function errorMessage(error: unknown): string {
246
+ return error instanceof Error ? error.message : String(error);
247
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * POSIX shell quoting for the invocations diagnostics tell people to run.
3
+ *
4
+ * Dependency-free on purpose: every module that renders a command reaches for
5
+ * this, and two of them (`cli/run.ts` and `authored-human.ts`) already import
6
+ * each other's neighbours. A copy per call site is how one of them ends up
7
+ * emitting a path with a space in it unquoted.
8
+ */
9
+
10
+ /** `value` as a single word, quoted only when it would not survive a shell. */
11
+ export function shellWord(value: string): string {
12
+ return /^[A-Za-z0-9_./=:@%+,-]+$/.test(value) ? value : shellQuote(value);
13
+ }
14
+
15
+ /**
16
+ * `value` as a single-quoted word, always.
17
+ *
18
+ * Used where the rendered argument is arbitrary author data — a flow path or a
19
+ * JSON `--input` — and the quotes are worth keeping even when unnecessary, so
20
+ * the command reads the same for `a.flow.ts` and for `my flows/a.flow.ts`.
21
+ */
22
+ export function shellQuote(value: string): string {
23
+ return `'${value.replaceAll("'", "'\\''")}'`;
24
+ }
package/src/spec.ts CHANGED
@@ -15,6 +15,13 @@ export type { JsonOutputSchema } from './output-schema.js';
15
15
  /** The three rungs of the ladder (RFC §1; AGENTS.md rule 7). */
16
16
  export type StepType = 'deterministic' | 'llm' | 'agent';
17
17
 
18
+ /**
19
+ * A deterministic step's exit-code policy. `'fail'` is the kernel's implicit
20
+ * gate; `'record'` completes the step with its exit code journaled so a later
21
+ * step can read the outcome instead of re-running the command.
22
+ */
23
+ export type OnNonZero = 'fail' | 'record';
24
+
18
25
  /** Project-owned MCP connections. env contains names, never secret values. */
19
26
  export type McpServerConfig =
20
27
  | { command: string; args?: string[]; env?: string[] }
@@ -93,9 +100,24 @@ export interface ArtifactExistsGate {
93
100
  path: string;
94
101
  }
95
102
 
103
+ /**
104
+ * Asserts that every named step recorded a zero exit code, reading the
105
+ * journaled outcome instead of re-running the command. This is the read side
106
+ * of `onNonZero: 'record'`: red work is allowed to exist, and this gate is
107
+ * where a flow declares that it stops being allowed.
108
+ *
109
+ * Deterministic hosts only — a worker step has no recorded exit code to read —
110
+ * and the ids it names must be deterministic steps that precede it.
111
+ */
112
+ export interface StepsGreenGate {
113
+ type: 'steps_green';
114
+ /** Earlier deterministic step ids whose recorded exit codes must all be zero. */
115
+ ids: string[];
116
+ }
117
+
96
118
  export type NamedDataGate = ReferencesInputGate | SubprocessGate | WordCountBoundsGate | RegexMatchGate | ArtifactExistsGate;
97
119
  export type OutputVerificationSpec = OutputContainsGate | JsonSchemaGate | NamedDataGate;
98
- export type VerificationSpec = ExitCodeGate | OutputVerificationSpec;
120
+ export type VerificationSpec = ExitCodeGate | StepsGreenGate | OutputVerificationSpec;
99
121
 
100
122
  /**
101
123
  * Agent-step recovery modes (RFC Appendix A rule 4). Default is `reset`.
@@ -226,6 +248,15 @@ export interface DeterministicStepSpec extends BaseStepSpec {
226
248
  timeoutMs?: number;
227
249
  /** Per-invocation deterministic lease in milliseconds (maximum 15 minutes). */
228
250
  lease_ms?: number;
251
+ /**
252
+ * What a nonzero exit means. `'fail'` (the default) is the implicit
253
+ * `exit_code == 0` gate. `'record'` journals the exit code and output tails
254
+ * as the step's outcome, completes the step red, and lets dependents run —
255
+ * the repair-before-failure shape, without discarding the exit code the way
256
+ * `<command> || true` does. Declared content and schema gates, timeouts and
257
+ * worker errors keep their existing fatal semantics.
258
+ */
259
+ onNonZero?: OnNonZero;
229
260
  /** Omit to get the implicit `exit_code` gate. */
230
261
  verification?: VerificationSpec;
231
262
  }
@@ -274,7 +305,17 @@ export interface AgentStepSpec extends BaseStepSpec {
274
305
  surfaces?: AgentSurfaces;
275
306
  recoveryMode?: RecoveryMode;
276
307
  permissions?: PermissionsSpec;
277
- /** Working directory for the CLI subprocess; defaults to the flow-runner's cwd. */
308
+ /**
309
+ * Directory the declared CLI is spawned in, as a path relative to the run
310
+ * root — the flow-runner's working directory, which is also where the CLI
311
+ * runs when this is absent. Absolute paths, `.`, `..` and empty components
312
+ * are refused lexically by `flows check` and by the kernel; the worker that
313
+ * spawns the CLI additionally requires the symlink-free directory to exist
314
+ * inside the symlink-free run root. A declaration, not a sandbox: nothing
315
+ * stops a CLI from writing outside it. Not supported with
316
+ * `transport: 'relay'`, where the agent runs on a host this process cannot
317
+ * resolve. See docs/SURFACE.md.
318
+ */
278
319
  cwd?: string;
279
320
  /**
280
321
  * Dispatch transport (flows#385). `'direct'` (default) spawns the CLI as
@@ -440,6 +481,12 @@ export interface KernelDeterministicStep extends KernelStepCommon {
440
481
  command: string;
441
482
  timeout_ms?: number;
442
483
  lease_ms?: number;
484
+ /**
485
+ * Omitted for the default `'fail'`, exactly as the kernel skips serializing
486
+ * it: a step that does not declare the policy keeps the canonical bytes —
487
+ * and therefore the step hash — it had before this field existed.
488
+ */
489
+ on_non_zero?: OnNonZero;
443
490
  }
444
491
 
445
492
  export interface KernelLlmStep extends KernelStepCommon {
@@ -469,7 +516,11 @@ export interface KernelAgentStep extends KernelStepCommon {
469
516
  recovery_mode: RecoveryMode;
470
517
  surfaces?: KernelAgentSurfaces;
471
518
  permissions?: KernelPermissionsSpec;
472
- /** Working directory for the CLI subprocess; kernel passes through untouched. */
519
+ /**
520
+ * Run-root-relative directory the attached worker spawns the CLI in. The
521
+ * kernel checks the shape and does no I/O: existence and containment are
522
+ * decided by the worker, on the host that shares the agent's filesystem.
523
+ */
473
524
  cwd?: string;
474
525
  /**
475
526
  * Dispatch transport (flows#385). Kernel passes through untouched;
@@ -35,7 +35,7 @@ export const STEP_COMMON_FIELDS = [
35
35
  * per-verb boundary through a second, drifting allowlist.
36
36
  */
37
37
  export const STEP_FIELDS_BY_TYPE = {
38
- deterministic: ['command', 'timeoutMs', 'lease_ms'],
38
+ deterministic: ['command', 'timeoutMs', 'lease_ms', 'onNonZero'],
39
39
  llm: ['prompt', 'model', 'cli', 'output'],
40
40
  agent: ['instruction', 'agent', 'cli', 'model', 'cwd', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'],
41
41
  } as const satisfies Record<StepType, readonly string[]>;