@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
@@ -0,0 +1,64 @@
1
+ // What a Cloud run record has to say before this client will act on it.
2
+ //
3
+ // One validator, two callers. `waitForCloudFlowRun` (cloud-run.ts) blocks on
4
+ // it to decide when a hosted `flows run --wait` is over, and the live read
5
+ // verbs (cli/cloud-live.ts, through `getCloudRunDetailLive`) block on it to
6
+ // decide when `--watch` and `--follow` stop and with which exit code. Both
7
+ // have to mean the same thing by "this run is over, and it ended well", or a
8
+ // watch would exit 0 on a record the waiter refuses.
9
+ //
10
+ // Deliberately strict and deliberately separate from the *permissive*
11
+ // `getCloudRunDetail` mapping, which exists to show a reader whatever Cloud
12
+ // said. Attesting an outcome is a different job to rendering one: a
13
+ // `completed` header with no valid `completionReason` attests nothing, and
14
+ // must not become exit 0.
15
+
16
+ import { RUN_COMPLETION_REASONS } from '@relayflows/surface';
17
+ import { CloudFlowError, isCloudRecord } from './cloud-http.js';
18
+ import type { RunCompletionReason } from './protocol.js';
19
+
20
+ export type CloudRunState =
21
+ | { runId: string; status: 'pending' | 'launching' | 'running' }
22
+ | { runId: string; status: 'completed' | 'failed' | 'cancelled'; completionReason: RunCompletionReason };
23
+
24
+ /** The statuses that mean the hosted run has not finished. */
25
+ const ACTIVE_RUN_STATUSES: readonly string[] = ['pending', 'launching', 'running'];
26
+
27
+ /**
28
+ * Is this run still going?
29
+ *
30
+ * The one vocabulary for the question, so `run --cloud --wait`,
31
+ * `status --cloud --watch` and `logs --follow` cannot disagree about what
32
+ * `launching` means. Note this is about the *run*: a `needs_human` step is
33
+ * not a run status and never reaches here.
34
+ */
35
+ export function isCloudRunActive(status: string): boolean {
36
+ return ACTIVE_RUN_STATUSES.includes(status);
37
+ }
38
+
39
+ /**
40
+ * Validate a raw run record into an execution outcome, or refuse.
41
+ *
42
+ * Takes the body as Cloud answered it so a caller that also wants the
43
+ * renderable projection can make one request and validate the same bytes it
44
+ * rendered, rather than issuing a second GET per poll.
45
+ */
46
+ export function cloudRunState(body: unknown, runId: string): CloudRunState {
47
+ if (!isCloudRecord(body) || body.runId !== runId || body.relayflowVersion !== 'v2'
48
+ || typeof body.status !== 'string'
49
+ || !['pending', 'launching', 'running', 'completed', 'failed', 'cancelled'].includes(body.status)) {
50
+ throw new CloudFlowError('invalid_response', 'Cloud returned an invalid v2 run record.');
51
+ }
52
+ if (isCloudRunActive(body.status)) {
53
+ return { runId, status: body.status as 'pending' | 'launching' | 'running' };
54
+ }
55
+ const report = body.result;
56
+ const reason = isCloudRecord(report) ? report.completionReason : undefined;
57
+ if (typeof reason !== 'string' || !(RUN_COMPLETION_REASONS as readonly string[]).includes(reason)
58
+ || (body.status === 'completed' && (reason !== 'success' || !isCloudRecord(report) || report.ok !== true || report.status !== 'completed'))
59
+ || (body.status === 'failed' && !['step_failed', 'budget_exceeded'].includes(reason))
60
+ || (body.status === 'cancelled' && reason !== 'canceled')) {
61
+ throw new CloudFlowError('invalid_response', 'Cloud terminal record lacks a valid, consistent run completionReason; no execution outcome is attested.');
62
+ }
63
+ return { runId, status: body.status as 'completed' | 'failed' | 'cancelled', completionReason: reason as RunCompletionReason };
64
+ }
package/src/cloud-run.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { RUN_COMPLETION_REASONS, type ScheduleTriggerSource } from '@relayflows/surface';
2
- import type { RunCompletionReason } from './protocol.js';
1
+ import { type ScheduleTriggerSource } from '@relayflows/surface';
3
2
  import { readFile } from 'node:fs/promises';
4
3
  import { createHash } from 'node:crypto';
5
4
  import { setTimeout } from 'node:timers/promises';
@@ -14,6 +13,7 @@ import {
14
13
  CloudFlowError, cloudConnection, cloudFetch, cloudRequest, cloudRunId, isCloudRecord,
15
14
  type CloudConnectionOptions,
16
15
  } from './cloud-http.js';
16
+ import { cloudRunState, isCloudRunActive, type CloudRunState } from './cloud-run-record.js';
17
17
  import { packWorkingTree, prepareCloudSync, uploadCloudCode } from './cloud-sync.js';
18
18
 
19
19
  export type CloudFlowSource = FlowSpec | { path: string };
@@ -66,9 +66,7 @@ export function cloudSubmissionBody(submission: CloudSubmission): Record<string,
66
66
  };
67
67
  }
68
68
 
69
- export type CloudRunState =
70
- | { runId: string; status: 'pending' | 'launching' | 'running' }
71
- | { runId: string; status: 'completed' | 'failed' | 'cancelled'; completionReason: RunCompletionReason };
69
+ export type { CloudRunState };
72
70
 
73
71
  /**
74
72
  * Submit a declarative flow to Cloud's pinned v2 runtime. Compilation only
@@ -269,24 +267,7 @@ export async function getCloudFlowRun(
269
267
  options: CloudConnectionOptions = {},
270
268
  ): Promise<CloudRunState> {
271
269
  cloudRunId(runId);
272
- const result = await cloudRequest(`/api/v1/workflows/runs/${runId}`, options);
273
- if (!isCloudRecord(result) || result.runId !== runId || result.relayflowVersion !== 'v2'
274
- || typeof result.status !== 'string'
275
- || !['pending', 'launching', 'running', 'completed', 'failed', 'cancelled'].includes(result.status)) {
276
- throw new CloudFlowError('invalid_response', 'Cloud returned an invalid v2 run record.');
277
- }
278
- if (result.status === 'pending' || result.status === 'launching' || result.status === 'running') {
279
- return { runId, status: result.status };
280
- }
281
- const report = result.result;
282
- const reason = isCloudRecord(report) ? report.completionReason : undefined;
283
- if (typeof reason !== 'string' || !(RUN_COMPLETION_REASONS as readonly string[]).includes(reason)
284
- || (result.status === 'completed' && (reason !== 'success' || !isCloudRecord(report) || report.ok !== true || report.status !== 'completed'))
285
- || (result.status === 'failed' && !['step_failed', 'budget_exceeded'].includes(reason))
286
- || (result.status === 'cancelled' && reason !== 'canceled')) {
287
- throw new CloudFlowError('invalid_response', 'Cloud terminal record lacks a valid, consistent run completionReason; no execution outcome is attested.');
288
- }
289
- return { runId, status: result.status as 'completed' | 'failed' | 'cancelled', completionReason: reason as RunCompletionReason };
270
+ return cloudRunState(await cloudRequest(`/api/v1/workflows/runs/${runId}`, options), runId);
290
271
  }
291
272
 
292
273
  /** Wait without a fixed execution deadline. Abort stops observation, not the hosted run. */
@@ -304,7 +285,7 @@ export async function waitForCloudFlowRun(
304
285
  try {
305
286
  const run = await getCloudFlowRun(runId, options);
306
287
  failures = 0;
307
- if (run.status !== 'pending' && run.status !== 'launching' && run.status !== 'running') return run;
288
+ if (!isCloudRunActive(run.status)) return run;
308
289
  } catch (error) {
309
290
  options.signal?.throwIfAborted();
310
291
  const transient = error instanceof CloudFlowError && (error.code === 'transient_error'
@@ -1,3 +1,4 @@
1
+ import { onWorkerFailure } from '../worker-lease.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { JournalClient } from '../journal-client.js';
3
4
  import { AgentWorker } from '../worker.js';
@@ -31,7 +32,7 @@ export async function attachCommunicationWorkers(spec: KernelRunSpec, socketPath
31
32
  requiredStreams: [channelName(step.id, '$receipts')],
32
33
  pins: { workspace: [], streams: step.surfaces?.streams?.map(({ stream }) => ({ stream, read_offset: 0 })) ?? [] } });
33
34
  workers.push({ client, worker });
34
- worker.on('error', error => { failure = error; client.close(); });
35
+ worker.on('error', onWorkerFailure('communication', error => { failure = error; client.close(); }));
35
36
  await client.connect();
36
37
  await client.hello('flows-communication');
37
38
  await worker.attach();
@@ -7,6 +7,7 @@ import type { KernelAgentStep } from '../spec.js';
7
7
  import { withWorkerLease } from '../worker-lease.js';
8
8
  import { workerInstruction } from '../worker-input.js';
9
9
  import { resolveCliModel } from '../cli-adapter.js';
10
+ import { resolveAgentCwd } from '../agent-cwd.js';
10
11
  import { channelName, type CommunicationInstruction } from './spec.js';
11
12
  import { acquireRelayRuntime, type RelayHandle } from './relay.js';
12
13
  import { CommunicationSession } from './session.js';
@@ -17,13 +18,13 @@ export function requireCommunicationCli(cli: string | undefined): void {
17
18
  if (!cli?.trim()) throw new Error('Agent communication requires a declared CLI executable');
18
19
  }
19
20
  export async function completeCommunicationDispatch(client: JournalClient, dispatch: StepDispatchEvent,
20
- instruction: CommunicationInstruction, dataDir: string): Promise<void> {
21
+ instruction: CommunicationInstruction, dataDir: string, runRoot?: string): Promise<void> {
21
22
  const spec = dispatch.spec as KernelAgentStep;
22
23
  requireCommunicationCli(spec.cli);
23
24
  let output: unknown;
24
25
  let completionReason: 'success' | 'worker_error' = 'success';
25
26
  try {
26
- output = await withWorkerLease(client, dispatch, signal => run(client, dispatch, instruction, spec, dataDir, signal));
27
+ output = await withWorkerLease(client, dispatch, signal => run(client, dispatch, instruction, spec, dataDir, signal, runRoot));
27
28
  } catch (error) {
28
29
  completionReason = 'worker_error';
29
30
  output = { error: error instanceof Error ? error.message : String(error) };
@@ -33,7 +34,14 @@ export async function completeCommunicationDispatch(client: JournalClient, dispa
33
34
  started_pins: dispatch.pins, end_pins: dispatch.pins });
34
35
  }
35
36
  async function run(client: JournalClient, dispatch: StepDispatchEvent, instruction: CommunicationInstruction,
36
- spec: KernelAgentStep, dataDir: string, lease: AbortSignal): Promise<unknown> {
37
+ spec: KernelAgentStep, dataDir: string, lease: AbortSignal, runRoot?: string): Promise<unknown> {
38
+ // Same contract as the CLI worker: a declared directory is resolved and held
39
+ // inside the same run root the CLI worker measures against, before anything
40
+ // is spawned. Thrown, not reported, because `completeCommunicationDispatch`
41
+ // already turns a throw here into a `worker_error` completion carrying the
42
+ // message.
43
+ const root = runRoot ?? process.cwd();
44
+ const directory = resolveAgentCwd(root, spec.cwd) ?? root;
37
45
  const controller = new AbortController();
38
46
  const signal = AbortSignal.any([lease, controller.signal, AbortSignal.timeout(instruction.timeoutMs)]);
39
47
  const relay = await acquireRelayRuntime(dataDir, dispatch.run_id);
@@ -70,9 +78,9 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
70
78
  });
71
79
  // Relay supplies each CLI's launch flags and injection behavior.
72
80
  handle = await relay.broker.spawnPty({ name, cli: basename(spec.cli!).replace(/\.exe$/i, ''), task: prompt, channels: [], skipRelayPrompt: true,
73
- model: resolveCliModel(spec.cli!, spec.model), cwd: spec.cwd ?? process.cwd(),
81
+ model: resolveCliModel(spec.cli!, spec.model), cwd: directory,
74
82
  harnessConfig: { runtime: 'pty', command: quote(spec.cli!), args: [],
75
- cwd: spec.cwd ?? process.cwd(), env: { ...agentEnvironment(spec.cli!),
83
+ cwd: directory, env: { ...agentEnvironment(spec.cli!),
76
84
  RELAYFLOW_COMMUNICATION_SOCKET: tools.path, RELAYFLOW_COMMUNICATION_TOKEN: tools.token },
77
85
  delivery: { mode: 'pty-injection', format: 'relay-block' } } });
78
86
  const ready = await handle.waitForReady(Math.min(instruction.timeoutMs, 90_000));
package/src/compile.ts CHANGED
@@ -19,6 +19,7 @@ import { parseBudget, toKernelBudget } from './budget.js';
19
19
  import { bindingDependencies } from './input-binding.js';
20
20
  import { namedGateFailure } from './named-gates.js';
21
21
  import { lowerNamedGates } from './named-gate-lowering.js';
22
+ import { lowerStepsGreenGates } from './steps-green.js';
22
23
  import type {
23
24
  AgentStepSpec,
24
25
  DeterministicStepSpec,
@@ -208,6 +209,10 @@ function compileStep(step: StepSpec): StepSpec {
208
209
  // their dispatch timeout. It must be spread HERE and nowhere in `base`.
209
210
  ...(s.timeoutMs !== undefined ? { timeoutMs: s.timeoutMs } : {}),
210
211
  ...(s.lease_ms !== undefined ? { lease_ms: parseStepTimeout(s.lease_ms) } : {}),
212
+ // Normalized away when it names the default, so an author who spells
213
+ // out `fail` gets the same canonical spec — and the same hash — as one
214
+ // who omits the field entirely.
215
+ ...(s.onNonZero !== undefined && s.onNonZero !== 'fail' ? { onNonZero: s.onNonZero } : {}),
211
216
  };
212
217
  }
213
218
  case 'llm': {
@@ -259,6 +264,11 @@ function outputGate(gate: VerificationSpec, stepId: string): OutputVerificationS
259
264
  `step "${stepId}": exit_code is supported only on deterministic steps`,
260
265
  ]);
261
266
  }
267
+ if (gate.type === 'steps_green') {
268
+ throw new CompileError([
269
+ `step "${stepId}": gate_host_unsupported: steps_green is supported only on deterministic steps`,
270
+ ], 'gate_host_unsupported');
271
+ }
262
272
  return gate;
263
273
  }
264
274
 
@@ -331,7 +341,13 @@ export function toKernelSpec(flow: FlowSpec): KernelRunSpec {
331
341
  // reverts the other, and `validateSpec` and `flows check` would both still
332
342
  // look correct. See ops/reviews/20260903-pr139-repair-0903.md section 10.
333
343
  ...(compiled.triggers?.length ? { triggers: compiled.triggers.map(toKernelTrigger) } : {}),
334
- steps: lowerNamedGates(compiled.steps).map((step) => toKernelStep(resolveNamedAgent(step, compiled.agents))),
344
+ // `steps_green` lowers FIRST: it rewrites the steps it reads to the
345
+ // permissive envelope schema its bindings need, and the named-gate pass
346
+ // then adds each source's own gate barrier to the generated step. The
347
+ // reverse order would hand the green gate a source whose barrier it does
348
+ // not wait for.
349
+ steps: lowerNamedGates(lowerStepsGreenGates(compiled.steps))
350
+ .map((step) => toKernelStep(resolveNamedAgent(step, compiled.agents))),
335
351
  ...(compiled.budget !== undefined ? { budget: toKernelBudget(compiled.budget) } : {}),
336
352
  };
337
353
  }
@@ -426,18 +442,18 @@ function kernelTriggerToAuthoring(value: unknown, at: string): unknown {
426
442
  function kernelStepToAuthoring(value: unknown, at: string): unknown {
427
443
  const unionKeys = [
428
444
  'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input',
429
- 'command', 'timeout_ms', 'lease_ms', 'prompt', 'model', 'cli', 'instruction',
430
- 'recovery_mode', 'surfaces', 'permissions',
445
+ 'command', 'timeout_ms', 'lease_ms', 'on_non_zero', 'prompt', 'model', 'cli', 'instruction',
446
+ 'cwd', 'recovery_mode', 'surfaces', 'permissions',
431
447
  ] as const;
432
448
  const step = requireKernelObject(value, unionKeys, at);
433
449
  const type = step['type'];
434
450
  const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements', 'input'] as const;
435
451
  const typeKeys = type === 'deterministic'
436
- ? ['command', 'timeout_ms', 'lease_ms'] as const
452
+ ? ['command', 'timeout_ms', 'lease_ms', 'on_non_zero'] as const
437
453
  : type === 'llm'
438
454
  ? ['prompt', 'model', 'cli'] as const
439
455
  : type === 'agent'
440
- ? ['instruction', 'cli', 'model', 'recovery_mode', 'surfaces', 'permissions'] as const
456
+ ? ['instruction', 'cli', 'model', 'cwd', 'recovery_mode', 'surfaces', 'permissions'] as const
441
457
  : [];
442
458
  assertKernelKeys(step, [...commonKeys, ...typeKeys], at);
443
459
  if (step['retry'] !== undefined) validateAuthoringRetryDefaults(step['retry'], `${at}.retry`);
@@ -460,6 +476,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
460
476
  command: step['command'],
461
477
  ...(step['timeout_ms'] !== undefined ? { timeoutMs: step['timeout_ms'] } : {}),
462
478
  ...(step['lease_ms'] !== undefined ? { lease_ms: step['lease_ms'] } : {}),
479
+ ...(step['on_non_zero'] !== undefined ? { onNonZero: step['on_non_zero'] } : {}),
463
480
  };
464
481
  }
465
482
  if (type === 'llm') {
@@ -470,7 +487,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
470
487
  ...common,
471
488
  instruction: step['instruction'],
472
489
  ...(step['recovery_mode'] !== undefined ? { recoveryMode: step['recovery_mode'] } : {}),
473
- ...copyDefined(step, ['cli', 'model', 'surfaces']),
490
+ ...copyDefined(step, ['cli', 'model', 'cwd', 'surfaces']),
474
491
  ...(step['permissions'] !== undefined
475
492
  ? { permissions: kernelPermissionsToAuthoring(step['permissions'], `${at}.permissions`) }
476
493
  : {}),
@@ -610,6 +627,11 @@ function toKernelStep(step: StepSpec): KernelStepSpec {
610
627
  command: step.command,
611
628
  ...(step.timeoutMs !== undefined ? { timeout_ms: step.timeoutMs } : {}),
612
629
  ...(step.lease_ms !== undefined ? { lease_ms: parseStepTimeout(step.lease_ms) } : {}),
630
+ // Omitted for the default, exactly as the kernel skips serializing it:
631
+ // a spec written before this field existed keeps its canonical bytes
632
+ // and therefore its spec hash.
633
+ ...(step.onNonZero !== undefined && step.onNonZero !== 'fail'
634
+ ? { on_non_zero: step.onNonZero } : {}),
613
635
  };
614
636
  case 'llm': {
615
637
  return {
@@ -39,7 +39,7 @@ export function parseDirectInput(argument: string | undefined): unknown {
39
39
  }
40
40
  } catch (error) {
41
41
  if (error instanceof DirectInputError) throw error;
42
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
42
+ if (!namesNoFile(error)) {
43
43
  throw new DirectInputError('input_unreadable', `Input file "${argument}" could not be inspected.`);
44
44
  }
45
45
  }
@@ -56,6 +56,27 @@ export function parseDirectInput(argument: string | undefined): unknown {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Whether the `stat` failed because the argument names no file at all, rather
61
+ * than because a file exists and could not be inspected.
62
+ *
63
+ * `ENOENT` is the ordinary "nothing there". `ENAMETOOLONG` is the same answer
64
+ * for a longer argument: no path component may exceed the filesystem's limit
65
+ * (255 bytes on ext4 and on APFS), so an inline JSON object of a few hundred
66
+ * bytes cannot be a filename on any filesystem this runs on. Calling that
67
+ * `input_unreadable` refused every inline input longer than a filename — the
68
+ * recorded-input recovery commands `cli/local-agent-remedy.ts` prints for a
69
+ * parked run among them, which is how it was found.
70
+ *
71
+ * Anything else — a permission error, an I/O error — still refuses, because it
72
+ * means a path is there and this process could not look at it. Guessing that
73
+ * such an argument was inline JSON would parse a filename as a document.
74
+ */
75
+ function namesNoFile(error: unknown): boolean {
76
+ const code = (error as NodeJS.ErrnoException).code;
77
+ return code === 'ENOENT' || code === 'ENAMETOOLONG';
78
+ }
79
+
59
80
  function tooLarge(argument: string, fromFile: boolean): DirectInputError {
60
81
  const sourceKind = fromFile ? `Input file "${argument}"` : 'Inline input';
61
82
  return new DirectInputError(
@@ -74,6 +74,15 @@ export const CHECK_FAILURE_KINDS = [
74
74
  * (#442). Warning-only by design: the declaration stays legal and the flow
75
75
  * still runs, but an author who wrote one must not be left believing it
76
76
  * sandboxes the step.
77
+ *
78
+ * `gate_path_unscanned` names an `artifact_exists` path inside a prefix the
79
+ * bundled agent worker's artifact scan excludes, so the scan can never put it
80
+ * in the journaled `output.artifacts` the gate reads. It warns rather than
81
+ * refuses because the scan is not the only writer of that list — the same
82
+ * worker promotes JSON stdout and completed Relay task output verbatim, and a
83
+ * custom worker may journal anything — so such a gate is unproven, not
84
+ * unsatisfiable. Temporary: it describes #513, and retires with
85
+ * `named-gate-preflight.ts` when the scan stops excluding those paths.
77
86
  */
78
87
  export const PREFLIGHT_WARNING_KINDS = [
79
88
  'unprovable_effects',
@@ -83,6 +92,7 @@ export const PREFLIGHT_WARNING_KINDS = [
83
92
  'vacuous_gate',
84
93
  'budget_unmetered',
85
94
  'permissions_unenforced',
95
+ 'gate_path_unscanned',
86
96
  ] as const;
87
97
 
88
98
  /**
@@ -124,6 +134,14 @@ export const RUN_FAILURE_KINDS = [
124
134
  'gate_failed',
125
135
  /** `flows answer` named a wait the run is not asking: unknown, or already answered. */
126
136
  'human_wait_unknown',
137
+ /**
138
+ * `--local-agent` cannot be honoured for this invocation, so it is refused
139
+ * rather than accepted and ignored. A local agent worker is admitted at run
140
+ * start and pinned into the authored root's metadata; a resume can only
141
+ * reproduce the surface the root was started with. Exit 2, before any worker
142
+ * attaches and before the resume touches the journal.
143
+ */
144
+ 'local_agent_unavailable',
127
145
  ] as const;
128
146
 
129
147
  /**
@@ -136,8 +154,17 @@ export const RUN_WARNING_KINDS = [
136
154
  'connection_file_stale',
137
155
  ] as const;
138
156
 
139
- /** File-level editor hints emitted by flows check, outside pure preflight. */
140
- export const CHECK_WARNING_KINDS = ['editor_schema_missing'] as const;
157
+ /**
158
+ * Warnings emitted by flows check, outside pure preflight.
159
+ *
160
+ * `editor_schema_missing` is a file-level editor hint. `agent_worker_unresolved`
161
+ * is a property of the *invocation*, not of the spec: a spec with `agent`
162
+ * steps needs a worker attached for step type `agent`, and only the caller
163
+ * knows whether it attaches one. `flows check` attaches none and, being
164
+ * daemon-free, can see none either — so it opts in, while `flows run`, `flows
165
+ * build` and SDK submissions do not (cli/check-worker-surface.ts).
166
+ */
167
+ export const CHECK_WARNING_KINDS = ['editor_schema_missing', 'agent_worker_unresolved'] as const;
141
168
  export type CheckWarningKind = (typeof CHECK_WARNING_KINDS)[number];
142
169
 
143
170
  export type PreflightFailureKind = (typeof PREFLIGHT_FAILURE_KINDS)[number];
@@ -146,6 +173,69 @@ export type PreflightWarningKind = (typeof PREFLIGHT_WARNING_KINDS)[number];
146
173
  export type RunFailureKind = (typeof RUN_FAILURE_KINDS)[number];
147
174
  export type RunWarningKind = (typeof RUN_WARNING_KINDS)[number];
148
175
 
176
+ /**
177
+ * One failed attempt of a step, as the journal recorded it.
178
+ *
179
+ * The kernel appends a `step.completed` PER ATTEMPT (relayflowd-core/src/machine.rs
180
+ * `completion_actions`), so a step that was retried leaves an ordered account
181
+ * of every failure — not just the one that ran out of budget. Reporting only
182
+ * the last is how a push rejected by a pre-receive hook was reported as
183
+ * "nothing staged inside the declared scope": the retry failed for a different
184
+ * reason than the original attempt, and the original reason was the diagnosis.
185
+ *
186
+ * Excerpts here are bounded harder than the terminal attempt's (256 bytes
187
+ * rather than 1,024): this is history beside the primary account, not a
188
+ * replacement for it.
189
+ */
190
+ export interface StepAttemptFailure {
191
+ /**
192
+ * The journal entry envelope's attempt number. Absent when the journal did
193
+ * not carry one; the position in the list is not a substitute, because a
194
+ * crashed attempt that consumed no iteration is still its own record.
195
+ */
196
+ attempt?: number;
197
+ /** The kernel's label for THIS attempt, e.g. `verification_failed`. */
198
+ completionReason?: string;
199
+ /** `retry`, `step_done` or `park`: what the kernel did next, not why it failed. */
200
+ disposition?: string;
201
+ exitCode?: number;
202
+ /** Redacted, terminal-safe UTF-8 excerpt, at most 256 bytes. */
203
+ stdoutTail?: string;
204
+ /** Redacted, terminal-safe UTF-8 excerpt, at most 256 bytes. */
205
+ stderrTail?: string;
206
+ /** This attempt's own account, from the same fields the terminal one reads. */
207
+ detail?: string;
208
+ /** Set when an excerpt above was cut to fit the per-attempt bound. */
209
+ truncated?: boolean;
210
+ }
211
+
212
+ /**
213
+ * What comparing the attempts' recorded evidence established — and no more.
214
+ *
215
+ * `differs` means the journal's own failure evidence is not the same across
216
+ * attempts, which usually means an earlier attempt had a side effect the retry
217
+ * then tripped over. `unchanged` is a statement about the RECORD, not a proof
218
+ * that the underlying causes were identical. `unknown` is the honest answer
219
+ * when an attempt journaled no account of itself, or when its account was
220
+ * already truncated by its producer: equal evidence that was never complete is
221
+ * not evidence of equality.
222
+ */
223
+ export type AttemptEvidenceComparison = 'differs' | 'unchanged' | 'unknown';
224
+
225
+ /**
226
+ * Why a run parked, for the reporting side that has to name a remedy.
227
+ *
228
+ * `worker_unavailable` is a step nothing is attached to run — the one case
229
+ * `--local-agent` fixes. `needs_human` is the kernel's manual-recovery wait
230
+ * after a worker attempt already failed, and attaching a worker does not clear
231
+ * it. Both arrive as exit 3 under `run_parked`, so the distinction has to
232
+ * travel as a value: it lives here, beside the run vocabulary, because the
233
+ * classifier (cli/run.ts), the authored error boundary (authored-flow-error.ts)
234
+ * and the remedy formatter (cli/local-agent-remedy.ts) all need the same one
235
+ * and none of them may depend on the others.
236
+ */
237
+ export type ParkCause = 'worker_unavailable' | 'needs_human';
238
+
149
239
  /**
150
240
  * Optional evidence on the existing step_failed diagnostic, not a new kind.
151
241
  *
@@ -154,6 +244,9 @@ export type RunWarningKind = (typeof RUN_WARNING_KINDS)[number];
154
244
  * agent or llm step leaves `completionReason` and whatever the daemon captured
155
245
  * into `detail` (see cli/step-failure.ts). Absent means "not journaled", never
156
246
  * "zero" — an exit code is only ever reported when one was actually recorded.
247
+ *
248
+ * The scalar evidence fields always describe the TERMINAL attempt. `attempts`
249
+ * is additive history and is present only when the step failed more than once.
157
250
  */
158
251
  export interface StepFailedDetails {
159
252
  stepId?: string;
@@ -197,6 +290,15 @@ export interface StepFailedDetails {
197
290
  detail?: string;
198
291
  /** The attempt's redacted `stream-json` transcript on disk, when the worker wrote one. */
199
292
  transcriptPath?: string;
293
+ /**
294
+ * Every failed attempt of the step this failure names, oldest first, present
295
+ * only when there was more than one. The last element is the same attempt the
296
+ * scalar fields above describe; the first is the one `retries_exhausted`
297
+ * alone used to hide.
298
+ */
299
+ attempts?: StepAttemptFailure[];
300
+ /** What comparing those attempts' recorded evidence established. */
301
+ attemptEvidence?: AttemptEvidenceComparison;
200
302
  /** A runnable `flows replay` invocation for this run. */
201
303
  hint?: string;
202
304
  /** The on-disk journal for this run, when the data dir is known. */
@@ -4,6 +4,7 @@ import type { TriggerSource } from '@relayflows/surface';
4
4
  import { providerDeclaration } from './provider-trigger-contract.js';
5
5
  import type { FlowSpec } from './spec.js';
6
6
  import { helperCall } from './yaml-helpers.js';
7
+ import { helperNamespacesUsed } from './helper-reference.js';
7
8
 
8
9
  /**
9
10
  * What a flow needs from the workspace it deploys into, read from inert
@@ -155,10 +156,14 @@ export function flowRequirements(
155
156
  // payload as input, and handler bodies are not dispatched yet (flows #301).
156
157
  // A handler's *trigger* is still a requirement — it is what wakes the flow.
157
158
  const text = typeof flow.body === 'function' ? Function.prototype.toString.call(flow.body) : '';
158
- const root = contextParameter(text);
159
- if (root !== undefined) {
159
+ const rootName = contextParameter(text);
160
+ const root = rootName === undefined ? undefined : escapeRegExp(rootName);
161
+ if (root !== undefined && rootName !== undefined) {
162
+ // The AST walk compares against an Identifier name, so it needs the raw
163
+ // parameter; the regex scanners below need the escaped one.
164
+ const referenced = helperNamespacesUsed(text, rootName);
160
165
  for (const { provider, namespace } of helperProviders) {
161
- if (helperReference(root, namespace).test(text)) declare({ provider, from: 'helper', detail: `f.${namespace}` });
166
+ if (referenced.has(namespace)) declare({ provider, from: 'helper', detail: `f.${namespace}` });
162
167
  }
163
168
  for (const use of workerCalls(root, text)) need(use.cli === undefined ? fallback : harnessFromCli(use.cli), use.detail);
164
169
  // `f.human(q, { to: "slack:#eng" })` is delivered by Cloud through that
@@ -219,15 +224,25 @@ function stringList(value: unknown): string[] {
219
224
  return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
220
225
  }
221
226
 
222
- /** The body's first parameter (`f` in `async (f, input) => …`), escaped for a pattern. */
227
+ /**
228
+ * The body's first parameter (`f` in `async (f, input) => …`), RAW.
229
+ *
230
+ * Returned unescaped because the AST walk compares it to an `Identifier`
231
+ * name: escaping turned a legal parameter like `f$` into `f\$`, which matches
232
+ * no identifier and hid every helper call in that flow. Callers that build a
233
+ * pattern escape it themselves with `escapeRegExp`.
234
+ */
223
235
  function contextParameter(body: string): string | undefined {
224
- const parameter = body.match(/^(?:async\s+)?(?:function(?:\s+[\w$]+)?\s*)?(?:\(\s*([\w$]+)|([\w$]+)\s*=>)/u);
225
- return (parameter?.[1] ?? parameter?.[2])?.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
236
+ // The third alternative is an object-literal method (`async post(f) { … }`),
237
+ // a shape `Function.prototype.toString()` can return and which the first two
238
+ // do not match — leaving `root` undefined and the body unscanned entirely.
239
+ const parameter = body.match(/^(?:async\s+)?(?:function\s*\*?\s*(?:[\w$]+)?\s*)?(?:\(\s*([\w$]+)|([\w$]+)\s*=>|\*?\s*[\w$]+\s*\(\s*([\w$]+))/u);
240
+ return parameter?.[1] ?? parameter?.[2] ?? parameter?.[3];
226
241
  }
227
242
 
228
- /** Same recognition as `preflightHelpers`: `f.slack`, `f .slack`, `f["slack"]`. */
229
- function helperReference(root: string, namespace: string): RegExp {
230
- return new RegExp(`(?:^|[^\\w$.])${root}\\s*(?:\\.\\s*${namespace}\\b|\\[\\s*['"]${namespace}['"]\\s*\\])`, 'u');
243
+ /** Escape a literal for embedding in a RegExp source. */
244
+ function escapeRegExp(value: string): string {
245
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
231
246
  }
232
247
 
233
248
  /**
@@ -24,6 +24,13 @@ export interface StepGateInspection extends DataGateClassification {
24
24
  * identically to a gate that judges something.
25
25
  */
26
26
  acceptsAnyOutput?: true;
27
+ /**
28
+ * Present when the step declares `onNonZero: 'record'`. The `exit_code`
29
+ * check is still applied and still journaled, but a positive code satisfies
30
+ * it instead of failing the step. Without this a recording step reads
31
+ * exactly like a gated one — the `|| true` ambiguity, moved into the report.
32
+ */
33
+ recordsNonZeroExit?: true;
27
34
  }
28
35
 
29
36
  /** Annotation-only keywords: present, they still constrain no instance. */
@@ -55,11 +62,15 @@ export function acceptsAnyOutput(schema: unknown): boolean {
55
62
  /** Describe the exact named checks the existing kernel applies to a step. */
56
63
  export function inspectStepGate(step: StepSpec): StepGateInspection {
57
64
  const checks: JournalGateCheck[] = [];
65
+ // A policy for the step's own exit code, not a gate of its own: it never
66
+ // adds or removes a check, it changes what a positive code means to one.
67
+ const recording = step.type === 'deterministic' && step.onNonZero === 'record'
68
+ ? { recordsNonZeroExit: true as const } : {};
58
69
  if (isNamedGate(step.verification)) {
59
70
  checks.push('exit_code');
60
71
  if (step.verification.type === 'references_input') checks.push('output_contains');
61
72
  if (step.verification.type === 'word_count_bounds') checks.push('json_schema');
62
- return { stepId: step.id, kind: 'data', checks, evaluator: 'kernel', preflightable: true, replayable: true };
73
+ return { stepId: step.id, kind: 'data', checks, evaluator: 'kernel', preflightable: true, replayable: true, ...recording };
63
74
  }
64
75
  if (step.type === 'deterministic') checks.push('exit_code');
65
76
  if (step.verification?.type === 'output_contains') checks.push('output_contains');
@@ -75,5 +86,6 @@ export function inspectStepGate(step: StepSpec): StepGateInspection {
75
86
  preflightable: true,
76
87
  replayable: true,
77
88
  ...(vacuous ? { acceptsAnyOutput: true as const } : {}),
89
+ ...recording,
78
90
  };
79
91
  }
@@ -1,5 +1,6 @@
1
1
  import { helperProviders } from '@relayflows/surface/runtime';
2
2
  import type { PreflightResult, PreflightDiagnostic } from './preflight.js';
3
+ import { helperNamespacesUsed } from './helper-reference.js';
3
4
 
4
5
  /** Static discovery never executes the body; dynamic aliases are checked at call time. */
5
6
  export function preflightHelpers(
@@ -8,12 +9,18 @@ export function preflightHelpers(
8
9
  providers?: Readonly<Record<string, { mount: boolean; mock: boolean; token?: string }>> },
9
10
  ): PreflightResult {
10
11
  const body = typeof definition.body === 'function' ? Function.prototype.toString.call(definition.body) : '';
11
- const parameter = body.match(/^(?:async\s+)?(?:function(?:\s+[\w$]+)?\s*)?(?:\(\s*([\w$]+)|([\w$]+)\s*=>)/);
12
- const root = (parameter?.[1] ?? parameter?.[2])?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
+ const parameter = body.match(/^(?:async\s+)?(?:function\s*\*?\s*(?:[\w$]+)?\s*)?(?:\(\s*([\w$]+)|([\w$]+)\s*=>|\*?\s*[\w$]+\s*\(\s*([\w$]+))/u);
13
+ // NOT regex-escaped: this is compared to an AST Identifier name, so a legal
14
+ // parameter like `f$` must stay `f$`. Escaping it hid every helper call.
15
+ const root = parameter?.[1] ?? parameter?.[2] ?? parameter?.[3];
13
16
  const diagnostics: PreflightDiagnostic[] = [];
17
+ // Read from a parse, not from the text: a helper named inside a string,
18
+ // comment, template quasi or regex is not used, and refusing on one demands
19
+ // a mount the flow never touches.
20
+ const referenced = root === undefined ? new Set<string>() : helperNamespacesUsed(body, root);
14
21
  for (const { provider, namespace, supported } of helperProviders) {
15
22
  const used = definition.header?.tools?.[namespace] === true
16
- || (root !== undefined && new RegExp(`(?:^|[^\\w$.])${root}\\s*(?:\\.\\s*${namespace}\\b|\\[\\s*['"]${namespace}['"]\\s*\\])`).test(body));
23
+ || referenced.has(namespace);
17
24
  if (!used) continue;
18
25
  const fact = facts.providers?.[provider] ?? (provider === 'slack'
19
26
  ? { mount: facts.slackMount, mock: facts.slackMock, token: facts.slackToken }