@relayflows/sdk 2.0.29 → 2.0.31

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 (393) 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 +64 -48
  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-root.d.ts +5 -0
  38. package/dist/authored-root.d.ts.map +1 -1
  39. package/dist/authored-root.js +20 -2
  40. package/dist/authored-root.js.map +1 -1
  41. package/dist/authored-step-output.d.ts +35 -10
  42. package/dist/authored-step-output.d.ts.map +1 -1
  43. package/dist/authored-step-output.js +108 -40
  44. package/dist/authored-step-output.js.map +1 -1
  45. package/dist/authored-verdict.d.ts +28 -0
  46. package/dist/authored-verdict.d.ts.map +1 -0
  47. package/dist/authored-verdict.js +79 -0
  48. package/dist/authored-verdict.js.map +1 -0
  49. package/dist/authored-worker-step.d.ts +5 -2
  50. package/dist/authored-worker-step.d.ts.map +1 -1
  51. package/dist/authored-worker-step.js +48 -11
  52. package/dist/authored-worker-step.js.map +1 -1
  53. package/dist/bundle-extensions.d.ts.map +1 -1
  54. package/dist/bundle-extensions.js +4 -7
  55. package/dist/bundle-extensions.js.map +1 -1
  56. package/dist/bundle.d.ts.map +1 -1
  57. package/dist/bundle.js +41 -7
  58. package/dist/bundle.js.map +1 -1
  59. package/dist/canonical.d.ts.map +1 -1
  60. package/dist/canonical.js +18 -6
  61. package/dist/canonical.js.map +1 -1
  62. package/dist/cli/check-worker-surface.d.ts +27 -0
  63. package/dist/cli/check-worker-surface.d.ts.map +1 -0
  64. package/dist/cli/check-worker-surface.js +63 -0
  65. package/dist/cli/check-worker-surface.js.map +1 -0
  66. package/dist/cli/check.d.ts +19 -2
  67. package/dist/cli/check.d.ts.map +1 -1
  68. package/dist/cli/check.js +12 -4
  69. package/dist/cli/check.js.map +1 -1
  70. package/dist/cli/cloud-format.d.ts +9 -0
  71. package/dist/cli/cloud-format.d.ts.map +1 -0
  72. package/dist/cli/cloud-format.js +77 -0
  73. package/dist/cli/cloud-format.js.map +1 -0
  74. package/dist/cli/cloud-live.d.ts +39 -0
  75. package/dist/cli/cloud-live.d.ts.map +1 -0
  76. package/dist/cli/cloud-live.js +325 -0
  77. package/dist/cli/cloud-live.js.map +1 -0
  78. package/dist/cli/cloud-read.d.ts +3 -7
  79. package/dist/cli/cloud-read.d.ts.map +1 -1
  80. package/dist/cli/cloud-read.js +14 -266
  81. package/dist/cli/cloud-read.js.map +1 -1
  82. package/dist/cli/cloud-refusal.d.ts +39 -0
  83. package/dist/cli/cloud-refusal.d.ts.map +1 -0
  84. package/dist/cli/cloud-refusal.js +95 -0
  85. package/dist/cli/cloud-refusal.js.map +1 -0
  86. package/dist/cli/cloud-status-view.d.ts +7 -0
  87. package/dist/cli/cloud-status-view.d.ts.map +1 -0
  88. package/dist/cli/cloud-status-view.js +214 -0
  89. package/dist/cli/cloud-status-view.js.map +1 -0
  90. package/dist/cli/direct-run.d.ts.map +1 -1
  91. package/dist/cli/direct-run.js +16 -2
  92. package/dist/cli/direct-run.js.map +1 -1
  93. package/dist/cli/hn-monitor.d.ts +0 -13
  94. package/dist/cli/hn-monitor.d.ts.map +1 -1
  95. package/dist/cli/hn-monitor.js +2 -1
  96. package/dist/cli/hn-monitor.js.map +1 -1
  97. package/dist/cli/local-agent-remedy.d.ts +80 -0
  98. package/dist/cli/local-agent-remedy.d.ts.map +1 -0
  99. package/dist/cli/local-agent-remedy.js +116 -0
  100. package/dist/cli/local-agent-remedy.js.map +1 -0
  101. package/dist/cli/observer-session.d.ts +41 -0
  102. package/dist/cli/observer-session.d.ts.map +1 -0
  103. package/dist/cli/observer-session.js +78 -0
  104. package/dist/cli/observer-session.js.map +1 -0
  105. package/dist/cli/run.d.ts +30 -2
  106. package/dist/cli/run.d.ts.map +1 -1
  107. package/dist/cli/run.js +186 -24
  108. package/dist/cli/run.js.map +1 -1
  109. package/dist/cli/status.d.ts +9 -0
  110. package/dist/cli/status.d.ts.map +1 -1
  111. package/dist/cli/status.js +31 -2
  112. package/dist/cli/status.js.map +1 -1
  113. package/dist/cli/step-evidence.d.ts +90 -0
  114. package/dist/cli/step-evidence.d.ts.map +1 -0
  115. package/dist/cli/step-evidence.js +259 -0
  116. package/dist/cli/step-evidence.js.map +1 -0
  117. package/dist/cli/step-failure.d.ts +14 -4
  118. package/dist/cli/step-failure.d.ts.map +1 -1
  119. package/dist/cli/step-failure.js +41 -76
  120. package/dist/cli/step-failure.js.map +1 -1
  121. package/dist/cli-commands.d.ts +6 -0
  122. package/dist/cli-commands.d.ts.map +1 -1
  123. package/dist/cli-commands.js +2 -0
  124. package/dist/cli-commands.js.map +1 -1
  125. package/dist/cli.d.ts +10 -0
  126. package/dist/cli.d.ts.map +1 -1
  127. package/dist/cli.js +53 -42
  128. package/dist/cli.js.map +1 -1
  129. package/dist/cloud-read.d.ts +15 -0
  130. package/dist/cloud-read.d.ts.map +1 -1
  131. package/dist/cloud-read.js +20 -1
  132. package/dist/cloud-read.js.map +1 -1
  133. package/dist/cloud-run-record.d.ts +27 -0
  134. package/dist/cloud-run-record.d.ts.map +1 -0
  135. package/dist/cloud-run-record.js +56 -0
  136. package/dist/cloud-run-record.js.map +1 -0
  137. package/dist/cloud-run.d.ts +2 -9
  138. package/dist/cloud-run.d.ts.map +1 -1
  139. package/dist/cloud-run.js +3 -20
  140. package/dist/cloud-run.js.map +1 -1
  141. package/dist/communication/local.d.ts.map +1 -1
  142. package/dist/communication/local.js +2 -1
  143. package/dist/communication/local.js.map +1 -1
  144. package/dist/communication/worker.d.ts +1 -1
  145. package/dist/communication/worker.d.ts.map +1 -1
  146. package/dist/communication/worker.js +13 -5
  147. package/dist/communication/worker.js.map +1 -1
  148. package/dist/compile.d.ts.map +1 -1
  149. package/dist/compile.js +28 -6
  150. package/dist/compile.js.map +1 -1
  151. package/dist/direct-input.d.ts.map +1 -1
  152. package/dist/direct-input.js +21 -1
  153. package/dist/direct-input.js.map +1 -1
  154. package/dist/failure-kinds.d.ts +96 -6
  155. package/dist/failure-kinds.d.ts.map +1 -1
  156. package/dist/failure-kinds.js +29 -2
  157. package/dist/failure-kinds.js.map +1 -1
  158. package/dist/flow-extension-compat.d.ts.map +1 -1
  159. package/dist/flow-extension-compat.js +26 -8
  160. package/dist/flow-extension-compat.js.map +1 -1
  161. package/dist/flow-extension-loader.d.ts +7 -0
  162. package/dist/flow-extension-loader.d.ts.map +1 -1
  163. package/dist/flow-extension-loader.js +48 -22
  164. package/dist/flow-extension-loader.js.map +1 -1
  165. package/dist/flow-requirements.d.ts.map +1 -1
  166. package/dist/flow-requirements.js +24 -9
  167. package/dist/flow-requirements.js.map +1 -1
  168. package/dist/fs-descriptor.d.ts +17 -0
  169. package/dist/fs-descriptor.d.ts.map +1 -0
  170. package/dist/fs-descriptor.js +96 -0
  171. package/dist/fs-descriptor.js.map +1 -0
  172. package/dist/gate-contract.d.ts +7 -0
  173. package/dist/gate-contract.d.ts.map +1 -1
  174. package/dist/gate-contract.js +6 -1
  175. package/dist/gate-contract.js.map +1 -1
  176. package/dist/helper-preflight.d.ts.map +1 -1
  177. package/dist/helper-preflight.js +10 -3
  178. package/dist/helper-preflight.js.map +1 -1
  179. package/dist/helper-reference.d.ts +25 -0
  180. package/dist/helper-reference.d.ts.map +1 -0
  181. package/dist/helper-reference.js +129 -0
  182. package/dist/helper-reference.js.map +1 -0
  183. package/dist/hosted-base-snapshot.d.ts +25 -0
  184. package/dist/hosted-base-snapshot.d.ts.map +1 -0
  185. package/dist/hosted-base-snapshot.js +278 -0
  186. package/dist/hosted-base-snapshot.js.map +1 -0
  187. package/dist/hosted-extension-declarations.d.ts +10 -0
  188. package/dist/hosted-extension-declarations.d.ts.map +1 -0
  189. package/dist/hosted-extension-declarations.js +272 -0
  190. package/dist/hosted-extension-declarations.js.map +1 -0
  191. package/dist/hosted-extension-isolation.d.ts +82 -0
  192. package/dist/hosted-extension-isolation.d.ts.map +1 -0
  193. package/dist/hosted-extension-isolation.js +322 -0
  194. package/dist/hosted-extension-isolation.js.map +1 -0
  195. package/dist/hosted-extension-manifest.d.ts +9 -0
  196. package/dist/hosted-extension-manifest.d.ts.map +1 -0
  197. package/dist/hosted-extension-manifest.js +235 -0
  198. package/dist/hosted-extension-manifest.js.map +1 -0
  199. package/dist/hosted-extension-protocol.d.ts +16 -0
  200. package/dist/hosted-extension-protocol.d.ts.map +1 -0
  201. package/dist/hosted-extension-protocol.js +224 -0
  202. package/dist/hosted-extension-protocol.js.map +1 -0
  203. package/dist/hosted-extension-runtime.d.ts +30 -0
  204. package/dist/hosted-extension-runtime.d.ts.map +1 -0
  205. package/dist/hosted-extension-runtime.js +167 -0
  206. package/dist/hosted-extension-runtime.js.map +1 -0
  207. package/dist/hosted-extension-sandbox-source.d.ts +9 -0
  208. package/dist/hosted-extension-sandbox-source.d.ts.map +1 -0
  209. package/dist/hosted-extension-sandbox-source.js +95 -0
  210. package/dist/hosted-extension-sandbox-source.js.map +1 -0
  211. package/dist/hosted-extension-sandbox.d.ts +39 -0
  212. package/dist/hosted-extension-sandbox.d.ts.map +1 -0
  213. package/dist/hosted-extension-sandbox.js +418 -0
  214. package/dist/hosted-extension-sandbox.js.map +1 -0
  215. package/dist/hosted-project.d.ts +3 -0
  216. package/dist/hosted-project.d.ts.map +1 -0
  217. package/dist/hosted-project.js +19 -0
  218. package/dist/hosted-project.js.map +1 -0
  219. package/dist/hosted-promise-safety.d.ts +12 -0
  220. package/dist/hosted-promise-safety.d.ts.map +1 -0
  221. package/dist/hosted-promise-safety.js +63 -0
  222. package/dist/hosted-promise-safety.js.map +1 -0
  223. package/dist/index.d.ts +3 -1
  224. package/dist/index.d.ts.map +1 -1
  225. package/dist/index.js +2 -0
  226. package/dist/index.js.map +1 -1
  227. package/dist/intrinsic-array.d.ts +3 -0
  228. package/dist/intrinsic-array.d.ts.map +1 -0
  229. package/dist/intrinsic-array.js +14 -0
  230. package/dist/intrinsic-array.js.map +1 -0
  231. package/dist/journal-client.d.ts +3 -1
  232. package/dist/journal-client.d.ts.map +1 -1
  233. package/dist/journal-client.js +17 -2
  234. package/dist/journal-client.js.map +1 -1
  235. package/dist/journal-projection.d.ts +16 -0
  236. package/dist/journal-projection.d.ts.map +1 -0
  237. package/dist/journal-projection.js +108 -0
  238. package/dist/journal-projection.js.map +1 -0
  239. package/dist/json-value.d.ts +6 -1
  240. package/dist/json-value.d.ts.map +1 -1
  241. package/dist/json-value.js +155 -36
  242. package/dist/json-value.js.map +1 -1
  243. package/dist/llm-worker.d.ts +9 -0
  244. package/dist/llm-worker.d.ts.map +1 -1
  245. package/dist/llm-worker.js +16 -2
  246. package/dist/llm-worker.js.map +1 -1
  247. package/dist/local-agent.d.ts +1 -1
  248. package/dist/local-agent.d.ts.map +1 -1
  249. package/dist/local-agent.js +4 -3
  250. package/dist/local-agent.js.map +1 -1
  251. package/dist/named-gate-lowering.js +35 -5
  252. package/dist/named-gate-lowering.js.map +1 -1
  253. package/dist/named-gate-preflight.d.ts +27 -0
  254. package/dist/named-gate-preflight.d.ts.map +1 -0
  255. package/dist/named-gate-preflight.js +47 -0
  256. package/dist/named-gate-preflight.js.map +1 -0
  257. package/dist/named-gates.d.ts +1 -1
  258. package/dist/named-gates.d.ts.map +1 -1
  259. package/dist/named-gates.js +3 -0
  260. package/dist/named-gates.js.map +1 -1
  261. package/dist/observer-link.d.ts +7 -0
  262. package/dist/observer-link.d.ts.map +1 -1
  263. package/dist/observer-link.js +3 -1
  264. package/dist/observer-link.js.map +1 -1
  265. package/dist/plugin-store.d.ts +8 -2
  266. package/dist/plugin-store.d.ts.map +1 -1
  267. package/dist/plugin-store.js +233 -56
  268. package/dist/plugin-store.js.map +1 -1
  269. package/dist/preflight.d.ts.map +1 -1
  270. package/dist/preflight.js +7 -0
  271. package/dist/preflight.js.map +1 -1
  272. package/dist/protocol.d.ts +2 -0
  273. package/dist/protocol.d.ts.map +1 -1
  274. package/dist/redact.d.ts +42 -0
  275. package/dist/redact.d.ts.map +1 -1
  276. package/dist/redact.js +128 -0
  277. package/dist/redact.js.map +1 -1
  278. package/dist/run-projection.d.ts +83 -0
  279. package/dist/run-projection.d.ts.map +1 -0
  280. package/dist/run-projection.js +199 -0
  281. package/dist/run-projection.js.map +1 -0
  282. package/dist/semver-range.d.ts.map +1 -1
  283. package/dist/semver-range.js +27 -15
  284. package/dist/semver-range.js.map +1 -1
  285. package/dist/shell-word.d.ts +19 -0
  286. package/dist/shell-word.d.ts.map +1 -0
  287. package/dist/shell-word.js +23 -0
  288. package/dist/shell-word.js.map +1 -0
  289. package/dist/spec.d.ts +52 -3
  290. package/dist/spec.d.ts.map +1 -1
  291. package/dist/spec.js.map +1 -1
  292. package/dist/step-fields.d.ts +1 -1
  293. package/dist/step-fields.js +1 -1
  294. package/dist/step-fields.js.map +1 -1
  295. package/dist/steps-green.d.ts +27 -0
  296. package/dist/steps-green.d.ts.map +1 -0
  297. package/dist/steps-green.js +164 -0
  298. package/dist/steps-green.js.map +1 -0
  299. package/dist/validate.d.ts.map +1 -1
  300. package/dist/validate.js +39 -8
  301. package/dist/validate.js.map +1 -1
  302. package/dist/worker-lease.d.ts +8 -1
  303. package/dist/worker-lease.d.ts.map +1 -1
  304. package/dist/worker-lease.js +34 -4
  305. package/dist/worker-lease.js.map +1 -1
  306. package/dist/worker.d.ts +8 -0
  307. package/dist/worker.d.ts.map +1 -1
  308. package/dist/worker.js +17 -9
  309. package/dist/worker.js.map +1 -1
  310. package/package.json +3 -7
  311. package/src/agent-artifacts.ts +2 -10
  312. package/src/agent-cwd.ts +139 -0
  313. package/src/artifact-scan-policy.ts +51 -0
  314. package/src/authored-completion-record.ts +119 -0
  315. package/src/authored-completion.ts +203 -0
  316. package/src/authored-flow-error.ts +13 -1
  317. package/src/authored-flow-executor.ts +101 -59
  318. package/src/authored-human.ts +1 -4
  319. package/src/authored-node-entry.ts +4 -1
  320. package/src/authored-node-runner.ts +96 -22
  321. package/src/authored-root.ts +20 -2
  322. package/src/authored-step-output.ts +139 -42
  323. package/src/authored-verdict.ts +87 -0
  324. package/src/authored-worker-step.ts +60 -12
  325. package/src/bundle-extensions.ts +6 -5
  326. package/src/bundle.ts +52 -7
  327. package/src/canonical.ts +20 -6
  328. package/src/cli/check-worker-surface.ts +66 -0
  329. package/src/cli/check.ts +35 -4
  330. package/src/cli/cloud-format.ts +82 -0
  331. package/src/cli/cloud-live.ts +366 -0
  332. package/src/cli/cloud-read.ts +13 -282
  333. package/src/cli/cloud-refusal.ts +113 -0
  334. package/src/cli/cloud-status-view.ts +213 -0
  335. package/src/cli/direct-run.ts +19 -2
  336. package/src/cli/hn-monitor.ts +2 -1
  337. package/src/cli/local-agent-remedy.ts +160 -0
  338. package/src/cli/observer-session.ts +107 -0
  339. package/src/cli/run.ts +233 -28
  340. package/src/cli/status.ts +59 -3
  341. package/src/cli/step-evidence.ts +310 -0
  342. package/src/cli/step-failure.ts +57 -81
  343. package/src/cli-commands.ts +2 -0
  344. package/src/cli.ts +64 -44
  345. package/src/cloud-read.ts +25 -1
  346. package/src/cloud-run-record.ts +64 -0
  347. package/src/cloud-run.ts +5 -24
  348. package/src/communication/local.ts +2 -1
  349. package/src/communication/worker.ts +13 -5
  350. package/src/compile.ts +28 -6
  351. package/src/direct-input.ts +22 -1
  352. package/src/failure-kinds.ts +104 -2
  353. package/src/flow-extension-compat.ts +32 -7
  354. package/src/flow-extension-loader.ts +56 -21
  355. package/src/flow-requirements.ts +24 -9
  356. package/src/fs-descriptor.ts +127 -0
  357. package/src/gate-contract.ts +13 -1
  358. package/src/helper-preflight.ts +10 -3
  359. package/src/helper-reference.ts +130 -0
  360. package/src/hosted-base-snapshot.ts +351 -0
  361. package/src/hosted-extension-declarations.ts +387 -0
  362. package/src/hosted-extension-isolation.ts +457 -0
  363. package/src/hosted-extension-manifest.ts +249 -0
  364. package/src/hosted-extension-protocol.ts +262 -0
  365. package/src/hosted-extension-runtime.ts +266 -0
  366. package/src/hosted-extension-sandbox-source.ts +94 -0
  367. package/src/hosted-extension-sandbox.ts +489 -0
  368. package/src/hosted-project.ts +18 -0
  369. package/src/hosted-promise-safety.ts +68 -0
  370. package/src/index.ts +20 -0
  371. package/src/intrinsic-array.ts +14 -0
  372. package/src/journal-client.ts +19 -2
  373. package/src/journal-projection.ts +106 -0
  374. package/src/json-value.ts +184 -32
  375. package/src/llm-worker.ts +16 -2
  376. package/src/local-agent.ts +4 -2
  377. package/src/named-gate-lowering.ts +35 -5
  378. package/src/named-gate-preflight.ts +47 -0
  379. package/src/named-gates.ts +3 -0
  380. package/src/observer-link.ts +10 -1
  381. package/src/plugin-store.ts +323 -56
  382. package/src/preflight.ts +7 -0
  383. package/src/protocol.ts +2 -0
  384. package/src/redact.ts +111 -0
  385. package/src/run-projection.ts +247 -0
  386. package/src/semver-range.ts +39 -14
  387. package/src/shell-word.ts +24 -0
  388. package/src/spec.ts +54 -3
  389. package/src/step-fields.ts +1 -1
  390. package/src/steps-green.ts +168 -0
  391. package/src/validate.ts +35 -8
  392. package/src/worker-lease.ts +35 -5
  393. package/src/worker.ts +27 -11
@@ -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
+ }
@@ -9,19 +9,41 @@
9
9
  */
10
10
  const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
11
11
  const RANGE = /^(\*|(?:[\^~]|>=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: <\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)?)$/;
12
+ const MATH_MAX = Math.max;
13
+ const NUMBER = Number;
14
+ const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger;
15
+ const REGEXP_EXEC = Function.prototype.call.bind(RegExp.prototype.exec) as (
16
+ regexp: RegExp,
17
+ value: string,
18
+ ) => RegExpExecArray | null;
19
+ const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (
20
+ regexp: RegExp,
21
+ value: string,
22
+ ) => boolean;
23
+ const STRING_SLICE = Function.prototype.call.bind(String.prototype.slice) as (
24
+ value: string,
25
+ start: number,
26
+ end?: number,
27
+ ) => string;
28
+ const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as (
29
+ value: string,
30
+ separator: string,
31
+ ) => string[];
12
32
 
13
33
  interface Parsed { readonly triple: readonly [number, number, number]; readonly pre?: string }
14
34
 
15
35
  export function parseVersion(value: string): Parsed | undefined {
16
- const m = VERSION.exec(value);
36
+ const m = REGEXP_EXEC(VERSION, value);
17
37
  if (!m) return undefined;
18
- const triple = [Number(m[1]), Number(m[2]), Number(m[3])] as const;
19
- if (triple.some(n => !Number.isSafeInteger(n))) return undefined;
38
+ const triple = [NUMBER(m[1]), NUMBER(m[2]), NUMBER(m[3])] as const;
39
+ for (let index = 0; index < triple.length; index += 1) {
40
+ if (!NUMBER_IS_SAFE_INTEGER(triple[index])) return undefined;
41
+ }
20
42
  return m[4] === undefined ? { triple } : { triple, pre: m[4] };
21
43
  }
22
44
 
23
45
  export function isVersionRange(value: string): boolean {
24
- return RANGE.test(value);
46
+ return REGEXP_TEST(RANGE, value);
25
47
  }
26
48
 
27
49
  function compare(a: Parsed, b: Parsed): number {
@@ -33,16 +55,16 @@ function compare(a: Parsed, b: Parsed): number {
33
55
  }
34
56
 
35
57
  function comparePrerelease(a: string, b: string): number {
36
- const as = a.split('.');
37
- const bs = b.split('.');
38
- const n = Math.max(as.length, bs.length);
58
+ const as = STRING_SPLIT(a, '.');
59
+ const bs = STRING_SPLIT(b, '.');
60
+ const n = MATH_MAX(as.length, bs.length);
39
61
  for (let i = 0; i < n; i++) {
40
62
  const left = as[i];
41
63
  const right = bs[i];
42
64
  if (left === undefined) return -1;
43
65
  if (right === undefined) return 1;
44
- const leftNum = /^\d+$/.test(left) ? Number(left) : undefined;
45
- const rightNum = /^\d+$/.test(right) ? Number(right) : undefined;
66
+ const leftNum = REGEXP_TEST(/^\d+$/, left) ? NUMBER(left) : undefined;
67
+ const rightNum = REGEXP_TEST(/^\d+$/, right) ? NUMBER(right) : undefined;
46
68
  if (leftNum !== undefined && rightNum !== undefined) {
47
69
  if (leftNum !== rightNum) return leftNum - rightNum;
48
70
  continue;
@@ -59,15 +81,18 @@ export function satisfiesRange(version: string, range: string): boolean {
59
81
  const v = parseVersion(version);
60
82
  if (v === undefined || !isVersionRange(range)) return false;
61
83
  if (range === '*') return true;
62
- const [lowerText, upperText] = range.split(' ') as [string, string | undefined];
63
- const operator = /^[\^~]|^>=/.exec(lowerText)?.[0] ?? '';
64
- const lower = parseVersion(lowerText.slice(operator.length))!;
84
+ const parts = STRING_SPLIT(range, ' ');
85
+ const lowerText = parts[0]!;
86
+ const upperText = parts[1];
87
+ const operator = REGEXP_EXEC(/^[\^~]|^>=/, lowerText)?.[0] ?? '';
88
+ const lower = parseVersion(STRING_SLICE(lowerText, operator.length))!;
65
89
  if (compare(v, lower) < 0) return false;
66
90
  if (operator === '') return compare(v, lower) === 0;
67
91
  let upper: Parsed | undefined;
68
- if (upperText !== undefined) upper = parseVersion(upperText.slice(1));
92
+ if (upperText !== undefined) upper = parseVersion(STRING_SLICE(upperText, 1));
69
93
  else if (operator === '^') {
70
- const [major, minor] = lower.triple;
94
+ const major = lower.triple[0];
95
+ const minor = lower.triple[1];
71
96
  upper = major > 0 ? { triple: [major + 1, 0, 0] } : minor > 0 ? { triple: [0, minor + 1, 0] } : { triple: [0, 0, lower.triple[2] + 1] };
72
97
  } else if (operator === '~') upper = { triple: [lower.triple[0], lower.triple[1] + 1, 0] };
73
98
  return upper === undefined || compare(v, upper) < 0;
@@ -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[]>;
@@ -0,0 +1,168 @@
1
+ // The read side of `onNonZero: 'record'`. A `steps_green` gate names earlier
2
+ // deterministic steps and asserts every one of them recorded exit code zero.
3
+ // The verdict comes from the journal — the recorded outcome each step already
4
+ // produced — so asserting it costs nothing and cannot disagree with what ran.
5
+
6
+ import { bindingDependencies } from './input-binding.js';
7
+ import type { StepSpec, StepsGreenGate, VerificationSpec } from './spec.js';
8
+
9
+ export const STEPS_GREEN_KEYS = ['type', 'ids'] as const;
10
+
11
+ export function isStepsGreenGate(gate: VerificationSpec | undefined): gate is StepsGreenGate {
12
+ return gate?.type === 'steps_green';
13
+ }
14
+
15
+ /** Shape errors for one gate, without reference to the rest of the flow. */
16
+ export function stepsGreenErrors(gate: Record<string, unknown>, at: string, stepType: string): string[] {
17
+ const errors: string[] = [];
18
+ if (stepType !== 'deterministic') {
19
+ errors.push(`${at}: gate_host_unsupported: steps_green is supported only on deterministic steps`);
20
+ }
21
+ const ids = gate['ids'];
22
+ if (!Array.isArray(ids) || ids.length === 0
23
+ || !ids.every(id => typeof id === 'string' && id.trim().length > 0)) {
24
+ errors.push(`${at}.ids: gate_ids_invalid: expected a non-empty array of step ids`);
25
+ return errors;
26
+ }
27
+ if (new Set(ids).size !== ids.length) {
28
+ errors.push(`${at}.ids: gate_ids_invalid: step ids must be unique`);
29
+ }
30
+ return errors;
31
+ }
32
+
33
+ /**
34
+ * Flow-level errors: the ids must resolve to earlier deterministic steps whose
35
+ * recorded outcome this gate can actually read. Refusing here is the whole
36
+ * point — a gate that silently reads nothing is the `|| true` failure mode
37
+ * this feature replaces.
38
+ */
39
+ export function stepsGreenReferenceErrors(steps: readonly unknown[]): string[] {
40
+ const errors: string[] = [];
41
+ const sources = new Map(steps.flatMap((step, index) =>
42
+ isObject(step) && typeof step['id'] === 'string' ? [[step['id'], { step, index }] as const] : []));
43
+ for (const [index, step] of steps.entries()) {
44
+ if (!isObject(step)) continue;
45
+ const gate = step['verification'];
46
+ if (!isObject(gate) || gate['type'] !== 'steps_green') continue;
47
+ const ids = gate['ids'];
48
+ if (!Array.isArray(ids)) continue;
49
+ const at = `spec.steps[${index}].verification.ids`;
50
+ for (const id of ids) {
51
+ if (typeof id !== 'string') continue;
52
+ const source = sources.get(id);
53
+ if (source === undefined) {
54
+ errors.push(`${at}: gate_source_unsupported: unknown step "${id}"`);
55
+ continue;
56
+ }
57
+ if (source.index >= index) {
58
+ errors.push(`${at}: gate_source_unsupported: source "${id}" must precede this step; forward and self references are not supported`);
59
+ continue;
60
+ }
61
+ if (source.step['type'] !== 'deterministic') {
62
+ errors.push(`${at}: gate_source_unsupported: source "${id}" is a ${String(source.step['type'])} step and records no exit code`);
63
+ continue;
64
+ }
65
+ // The gate binds the source's whole output envelope, which requires a
66
+ // declared output schema. Every source shape is reachable except this
67
+ // one: `output_contains` is the single gate the compiler cannot widen
68
+ // without dropping the author's own check.
69
+ const sourceGate = source.step['verification'];
70
+ if (isObject(sourceGate) && sourceGate['type'] === 'output_contains') {
71
+ errors.push(`${at}: gate_source_unsupported: source "${id}" declares an output_contains gate, which cannot also carry the output schema this gate reads`);
72
+ }
73
+ }
74
+ }
75
+ return errors;
76
+ }
77
+
78
+ /**
79
+ * Lower every `steps_green` gate into a generated deterministic gate step.
80
+ *
81
+ * The host keeps its own command and its own exit policy; the gate is a
82
+ * separate step, always fatal, that binds each named source's journaled output
83
+ * envelope and refuses any nonzero or malformed exit code. Dependents of the
84
+ * host gain a barrier on the gate, so "these steps were green" is established
85
+ * before anything downstream of the assertion runs.
86
+ *
87
+ * Runs BEFORE `lowerNamedGates`, which then adds each source's own named-gate
88
+ * barrier to the generated step and rewrites named-gate hosts to the permissive
89
+ * envelope schema this gate's bindings need.
90
+ */
91
+ export function lowerStepsGreenGates(steps: readonly StepSpec[]): StepSpec[] {
92
+ const used = new Set(steps.map(step => step.id));
93
+ const barriers = new Map<string, string>();
94
+ for (const step of steps) {
95
+ // Deterministic hosts only, which `validateSpec` already enforces; the
96
+ // narrowing here is what lets the rewrite below stay type-exact.
97
+ if (step.type !== 'deterministic' || !isStepsGreenGate(step.verification)) continue;
98
+ let id = `${step.id}.green`;
99
+ while (used.has(id)) id += '.green';
100
+ used.add(id);
101
+ barriers.set(step.id, id);
102
+ }
103
+ if (barriers.size === 0) return [...steps];
104
+
105
+ // Every source's envelope has to be bindable. A source that declares no
106
+ // gate, or the implicit exit_code gate, gets the permissive whole-output
107
+ // schema; its exit policy is untouched, so a recording source stays red.
108
+ const bound = new Set(steps.flatMap(step =>
109
+ isStepsGreenGate(step.verification) ? step.verification.ids : []));
110
+ const output: StepSpec[] = [];
111
+ for (const step of steps) {
112
+ const dependencies = [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])];
113
+ const dependsOn = [...new Set([...dependencies, ...dependencies.flatMap(id => barriers.get(id) ?? [])])];
114
+ let producer: StepSpec = dependsOn.length ? { ...step, dependsOn } : step;
115
+ if (bound.has(step.id) && producer.type === 'deterministic'
116
+ && (producer.verification === undefined || producer.verification.type === 'exit_code')) {
117
+ producer = { ...producer, verification: { type: 'json_schema', schema: true } };
118
+ }
119
+ const gate = step.verification;
120
+ if (producer.type !== 'deterministic' || !isStepsGreenGate(gate)) {
121
+ output.push(producer);
122
+ continue;
123
+ }
124
+ // The host's own gate becomes the implicit exit_code check. Its command
125
+ // still runs, and its own `onNonZero` still applies to its own exit. A
126
+ // host that is itself read by a later gate keeps the bindable envelope.
127
+ output.push({
128
+ ...producer,
129
+ verification: bound.has(step.id) ? { type: 'json_schema', schema: true } : { type: 'exit_code' },
130
+ });
131
+ const input = Object.fromEntries(gate.ids.map((id, index) => [String(index), { step: id }]));
132
+ output.push({
133
+ id: barriers.get(step.id)!, type: 'deterministic',
134
+ // A named source that is itself a gate host must have passed its own
135
+ // assertion too — depending on the source alone would read its command
136
+ // exit and let this barrier pass while the inner gate is still red.
137
+ dependsOn: [...new Set([step.id, ...gate.ids, ...gate.ids.flatMap(id => barriers.get(id) ?? [])])], input,
138
+ command: greenCommand(gate.ids),
139
+ verification: { type: 'exit_code' }, maxIterations: step.maxIterations ?? 1,
140
+ ...(step.requirements === undefined ? {} : { requirements: step.requirements }),
141
+ });
142
+ }
143
+ return output;
144
+ }
145
+
146
+ /**
147
+ * Compiler-owned Node command. Author-supplied ids are JSON literals and the
148
+ * recorded envelopes travel only through FLOWS_INPUT, never through shell text.
149
+ */
150
+ function greenCommand(ids: readonly string[]): string {
151
+ const script = `const input=JSON.parse(process.env.FLOWS_INPUT);
152
+ const ids=${JSON.stringify(ids)};
153
+ const red=[];
154
+ for(let i=0;i<ids.length;i++){
155
+ const envelope=input[String(i)];
156
+ if(envelope===null||typeof envelope!=='object'||Array.isArray(envelope)){red.push(ids[i]+': no recorded outcome');continue;}
157
+ const code=envelope.exit_code;
158
+ if(typeof code!=='number'||!Number.isInteger(code)){red.push(ids[i]+': recorded no integer exit_code');continue;}
159
+ if(code!==0)red.push(ids[i]+': exit code '+code);
160
+ }
161
+ if(red.length>0){process.stderr.write('steps_green: '+red.join('; ')+'\\n');process.exit(1);}
162
+ process.stdout.write('steps_green:pass');`;
163
+ return `node -e '${script.replaceAll("'", "'\\''")}'`;
164
+ }
165
+
166
+ function isObject(value: unknown): value is Record<string, unknown> {
167
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
168
+ }