@relayflows/sdk 2.0.30 → 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 (295) 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-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/cli/check-worker-surface.d.ts +27 -0
  54. package/dist/cli/check-worker-surface.d.ts.map +1 -0
  55. package/dist/cli/check-worker-surface.js +63 -0
  56. package/dist/cli/check-worker-surface.js.map +1 -0
  57. package/dist/cli/check.d.ts +19 -2
  58. package/dist/cli/check.d.ts.map +1 -1
  59. package/dist/cli/check.js +12 -4
  60. package/dist/cli/check.js.map +1 -1
  61. package/dist/cli/cloud-format.d.ts +9 -0
  62. package/dist/cli/cloud-format.d.ts.map +1 -0
  63. package/dist/cli/cloud-format.js +77 -0
  64. package/dist/cli/cloud-format.js.map +1 -0
  65. package/dist/cli/cloud-live.d.ts +39 -0
  66. package/dist/cli/cloud-live.d.ts.map +1 -0
  67. package/dist/cli/cloud-live.js +325 -0
  68. package/dist/cli/cloud-live.js.map +1 -0
  69. package/dist/cli/cloud-read.d.ts +3 -7
  70. package/dist/cli/cloud-read.d.ts.map +1 -1
  71. package/dist/cli/cloud-read.js +14 -266
  72. package/dist/cli/cloud-read.js.map +1 -1
  73. package/dist/cli/cloud-refusal.d.ts +39 -0
  74. package/dist/cli/cloud-refusal.d.ts.map +1 -0
  75. package/dist/cli/cloud-refusal.js +95 -0
  76. package/dist/cli/cloud-refusal.js.map +1 -0
  77. package/dist/cli/cloud-status-view.d.ts +7 -0
  78. package/dist/cli/cloud-status-view.d.ts.map +1 -0
  79. package/dist/cli/cloud-status-view.js +214 -0
  80. package/dist/cli/cloud-status-view.js.map +1 -0
  81. package/dist/cli/direct-run.d.ts.map +1 -1
  82. package/dist/cli/direct-run.js +16 -2
  83. package/dist/cli/direct-run.js.map +1 -1
  84. package/dist/cli/hn-monitor.d.ts +0 -13
  85. package/dist/cli/hn-monitor.d.ts.map +1 -1
  86. package/dist/cli/hn-monitor.js +2 -1
  87. package/dist/cli/hn-monitor.js.map +1 -1
  88. package/dist/cli/local-agent-remedy.d.ts +80 -0
  89. package/dist/cli/local-agent-remedy.d.ts.map +1 -0
  90. package/dist/cli/local-agent-remedy.js +116 -0
  91. package/dist/cli/local-agent-remedy.js.map +1 -0
  92. package/dist/cli/observer-session.d.ts +41 -0
  93. package/dist/cli/observer-session.d.ts.map +1 -0
  94. package/dist/cli/observer-session.js +78 -0
  95. package/dist/cli/observer-session.js.map +1 -0
  96. package/dist/cli/run.d.ts +30 -2
  97. package/dist/cli/run.d.ts.map +1 -1
  98. package/dist/cli/run.js +186 -24
  99. package/dist/cli/run.js.map +1 -1
  100. package/dist/cli/status.d.ts +9 -0
  101. package/dist/cli/status.d.ts.map +1 -1
  102. package/dist/cli/status.js +31 -2
  103. package/dist/cli/status.js.map +1 -1
  104. package/dist/cli/step-evidence.d.ts +90 -0
  105. package/dist/cli/step-evidence.d.ts.map +1 -0
  106. package/dist/cli/step-evidence.js +259 -0
  107. package/dist/cli/step-evidence.js.map +1 -0
  108. package/dist/cli/step-failure.d.ts +14 -4
  109. package/dist/cli/step-failure.d.ts.map +1 -1
  110. package/dist/cli/step-failure.js +41 -76
  111. package/dist/cli/step-failure.js.map +1 -1
  112. package/dist/cli-commands.d.ts +6 -0
  113. package/dist/cli-commands.d.ts.map +1 -1
  114. package/dist/cli-commands.js +2 -0
  115. package/dist/cli-commands.js.map +1 -1
  116. package/dist/cli.d.ts +10 -0
  117. package/dist/cli.d.ts.map +1 -1
  118. package/dist/cli.js +53 -42
  119. package/dist/cli.js.map +1 -1
  120. package/dist/cloud-read.d.ts +15 -0
  121. package/dist/cloud-read.d.ts.map +1 -1
  122. package/dist/cloud-read.js +20 -1
  123. package/dist/cloud-read.js.map +1 -1
  124. package/dist/cloud-run-record.d.ts +27 -0
  125. package/dist/cloud-run-record.d.ts.map +1 -0
  126. package/dist/cloud-run-record.js +56 -0
  127. package/dist/cloud-run-record.js.map +1 -0
  128. package/dist/cloud-run.d.ts +2 -9
  129. package/dist/cloud-run.d.ts.map +1 -1
  130. package/dist/cloud-run.js +3 -20
  131. package/dist/cloud-run.js.map +1 -1
  132. package/dist/communication/local.d.ts.map +1 -1
  133. package/dist/communication/local.js +2 -1
  134. package/dist/communication/local.js.map +1 -1
  135. package/dist/communication/worker.d.ts +1 -1
  136. package/dist/communication/worker.d.ts.map +1 -1
  137. package/dist/communication/worker.js +13 -5
  138. package/dist/communication/worker.js.map +1 -1
  139. package/dist/compile.d.ts.map +1 -1
  140. package/dist/compile.js +28 -6
  141. package/dist/compile.js.map +1 -1
  142. package/dist/direct-input.d.ts.map +1 -1
  143. package/dist/direct-input.js +21 -1
  144. package/dist/direct-input.js.map +1 -1
  145. package/dist/failure-kinds.d.ts +96 -6
  146. package/dist/failure-kinds.d.ts.map +1 -1
  147. package/dist/failure-kinds.js +29 -2
  148. package/dist/failure-kinds.js.map +1 -1
  149. package/dist/flow-requirements.d.ts.map +1 -1
  150. package/dist/flow-requirements.js +24 -9
  151. package/dist/flow-requirements.js.map +1 -1
  152. package/dist/gate-contract.d.ts +7 -0
  153. package/dist/gate-contract.d.ts.map +1 -1
  154. package/dist/gate-contract.js +6 -1
  155. package/dist/gate-contract.js.map +1 -1
  156. package/dist/helper-preflight.d.ts.map +1 -1
  157. package/dist/helper-preflight.js +10 -3
  158. package/dist/helper-preflight.js.map +1 -1
  159. package/dist/helper-reference.d.ts +25 -0
  160. package/dist/helper-reference.d.ts.map +1 -0
  161. package/dist/helper-reference.js +129 -0
  162. package/dist/helper-reference.js.map +1 -0
  163. package/dist/index.d.ts +1 -1
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js.map +1 -1
  166. package/dist/journal-client.d.ts +3 -1
  167. package/dist/journal-client.d.ts.map +1 -1
  168. package/dist/journal-client.js +17 -2
  169. package/dist/journal-client.js.map +1 -1
  170. package/dist/journal-projection.d.ts +16 -0
  171. package/dist/journal-projection.d.ts.map +1 -0
  172. package/dist/journal-projection.js +108 -0
  173. package/dist/journal-projection.js.map +1 -0
  174. package/dist/llm-worker.js +1 -1
  175. package/dist/llm-worker.js.map +1 -1
  176. package/dist/local-agent.d.ts +1 -1
  177. package/dist/local-agent.d.ts.map +1 -1
  178. package/dist/local-agent.js +4 -3
  179. package/dist/local-agent.js.map +1 -1
  180. package/dist/named-gate-lowering.js +35 -5
  181. package/dist/named-gate-lowering.js.map +1 -1
  182. package/dist/named-gate-preflight.d.ts +27 -0
  183. package/dist/named-gate-preflight.d.ts.map +1 -0
  184. package/dist/named-gate-preflight.js +47 -0
  185. package/dist/named-gate-preflight.js.map +1 -0
  186. package/dist/named-gates.d.ts +1 -1
  187. package/dist/named-gates.d.ts.map +1 -1
  188. package/dist/named-gates.js +3 -0
  189. package/dist/named-gates.js.map +1 -1
  190. package/dist/observer-link.d.ts +7 -0
  191. package/dist/observer-link.d.ts.map +1 -1
  192. package/dist/observer-link.js +3 -1
  193. package/dist/observer-link.js.map +1 -1
  194. package/dist/preflight.d.ts.map +1 -1
  195. package/dist/preflight.js +7 -0
  196. package/dist/preflight.js.map +1 -1
  197. package/dist/protocol.d.ts +2 -0
  198. package/dist/protocol.d.ts.map +1 -1
  199. package/dist/redact.d.ts +42 -0
  200. package/dist/redact.d.ts.map +1 -1
  201. package/dist/redact.js +128 -0
  202. package/dist/redact.js.map +1 -1
  203. package/dist/run-projection.d.ts +83 -0
  204. package/dist/run-projection.d.ts.map +1 -0
  205. package/dist/run-projection.js +199 -0
  206. package/dist/run-projection.js.map +1 -0
  207. package/dist/shell-word.d.ts +19 -0
  208. package/dist/shell-word.d.ts.map +1 -0
  209. package/dist/shell-word.js +23 -0
  210. package/dist/shell-word.js.map +1 -0
  211. package/dist/spec.d.ts +52 -3
  212. package/dist/spec.d.ts.map +1 -1
  213. package/dist/spec.js.map +1 -1
  214. package/dist/step-fields.d.ts +1 -1
  215. package/dist/step-fields.js +1 -1
  216. package/dist/step-fields.js.map +1 -1
  217. package/dist/steps-green.d.ts +27 -0
  218. package/dist/steps-green.d.ts.map +1 -0
  219. package/dist/steps-green.js +164 -0
  220. package/dist/steps-green.js.map +1 -0
  221. package/dist/validate.d.ts.map +1 -1
  222. package/dist/validate.js +39 -8
  223. package/dist/validate.js.map +1 -1
  224. package/dist/worker-lease.d.ts +8 -1
  225. package/dist/worker-lease.d.ts.map +1 -1
  226. package/dist/worker-lease.js +34 -4
  227. package/dist/worker-lease.js.map +1 -1
  228. package/dist/worker.d.ts +8 -0
  229. package/dist/worker.d.ts.map +1 -1
  230. package/dist/worker.js +17 -9
  231. package/dist/worker.js.map +1 -1
  232. package/package.json +3 -7
  233. package/src/agent-artifacts.ts +2 -10
  234. package/src/agent-cwd.ts +139 -0
  235. package/src/artifact-scan-policy.ts +51 -0
  236. package/src/authored-completion-record.ts +119 -0
  237. package/src/authored-completion.ts +203 -0
  238. package/src/authored-flow-error.ts +13 -1
  239. package/src/authored-flow-executor.ts +91 -58
  240. package/src/authored-human.ts +1 -4
  241. package/src/authored-node-entry.ts +4 -1
  242. package/src/authored-node-runner.ts +96 -22
  243. package/src/authored-root.ts +20 -2
  244. package/src/authored-step-output.ts +139 -42
  245. package/src/authored-verdict.ts +87 -0
  246. package/src/authored-worker-step.ts +60 -12
  247. package/src/cli/check-worker-surface.ts +66 -0
  248. package/src/cli/check.ts +35 -4
  249. package/src/cli/cloud-format.ts +82 -0
  250. package/src/cli/cloud-live.ts +366 -0
  251. package/src/cli/cloud-read.ts +13 -282
  252. package/src/cli/cloud-refusal.ts +113 -0
  253. package/src/cli/cloud-status-view.ts +213 -0
  254. package/src/cli/direct-run.ts +19 -2
  255. package/src/cli/hn-monitor.ts +2 -1
  256. package/src/cli/local-agent-remedy.ts +160 -0
  257. package/src/cli/observer-session.ts +107 -0
  258. package/src/cli/run.ts +233 -28
  259. package/src/cli/status.ts +59 -3
  260. package/src/cli/step-evidence.ts +310 -0
  261. package/src/cli/step-failure.ts +57 -81
  262. package/src/cli-commands.ts +2 -0
  263. package/src/cli.ts +64 -44
  264. package/src/cloud-read.ts +25 -1
  265. package/src/cloud-run-record.ts +64 -0
  266. package/src/cloud-run.ts +5 -24
  267. package/src/communication/local.ts +2 -1
  268. package/src/communication/worker.ts +13 -5
  269. package/src/compile.ts +28 -6
  270. package/src/direct-input.ts +22 -1
  271. package/src/failure-kinds.ts +104 -2
  272. package/src/flow-requirements.ts +24 -9
  273. package/src/gate-contract.ts +13 -1
  274. package/src/helper-preflight.ts +10 -3
  275. package/src/helper-reference.ts +130 -0
  276. package/src/index.ts +2 -0
  277. package/src/journal-client.ts +19 -2
  278. package/src/journal-projection.ts +106 -0
  279. package/src/llm-worker.ts +1 -1
  280. package/src/local-agent.ts +4 -2
  281. package/src/named-gate-lowering.ts +35 -5
  282. package/src/named-gate-preflight.ts +47 -0
  283. package/src/named-gates.ts +3 -0
  284. package/src/observer-link.ts +10 -1
  285. package/src/preflight.ts +7 -0
  286. package/src/protocol.ts +2 -0
  287. package/src/redact.ts +111 -0
  288. package/src/run-projection.ts +247 -0
  289. package/src/shell-word.ts +24 -0
  290. package/src/spec.ts +54 -3
  291. package/src/step-fields.ts +1 -1
  292. package/src/steps-green.ts +168 -0
  293. package/src/validate.ts +35 -8
  294. package/src/worker-lease.ts +35 -5
  295. package/src/worker.ts +27 -11
@@ -0,0 +1,203 @@
1
+ // The authored verdict: which completions this runtime lowers, the durable
2
+ // marker that carries one, and the optional detail that says why.
3
+ //
4
+ // Split out of `authored-flow-executor.ts` so the CLI report and the status
5
+ // projection can read this vocabulary without importing the executor — and
6
+ // with it every provider adapter, MCP client and helper the executor pulls
7
+ // in. Nothing here does I/O or reads a clock.
8
+
9
+ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
10
+ import { redact } from './redact.js';
11
+
12
+ /**
13
+ * The completion reasons an authored body may declare and this executor lowers.
14
+ *
15
+ * `FlowCompletionReason` is wider than this on purpose — it is the journal's
16
+ * run vocabulary plus authored verdicts — but the two sets drifting silently is
17
+ * exactly what made a type-valid `done("step_failed")` die at runtime as
18
+ * `unsupported_completion`. Every gate that asks "is this a completion this
19
+ * runtime can lower?" now asks this one function, so a reason cannot be
20
+ * accepted in one place and rejected in another.
21
+ *
22
+ * These are internal cross-module helpers for the authored seam (the executor,
23
+ * the durable root, the IPC verifier, the CLI report and `flows status`), NOT
24
+ * public SDK surface. `src/index.ts` deliberately re-exports nothing from this
25
+ * module — keep it that way, or the whole authored seam leaks with them.
26
+ */
27
+ export const LOWERED_COMPLETIONS = ['success', 'needs_human', 'step_failed', 'declined'] as const;
28
+ export type LoweredCompletionReason = (typeof LOWERED_COMPLETIONS)[number];
29
+
30
+ export function isLoweredCompletion(value: unknown): value is LoweredCompletionReason {
31
+ return typeof value === 'string' && (LOWERED_COMPLETIONS as readonly string[]).includes(value);
32
+ }
33
+
34
+ /**
35
+ * The bound on a normalized completion detail: 2,000 Unicode code points in
36
+ * the FINAL string, {@link COMPLETION_DETAIL_TRUNCATED_SUFFIX} included.
37
+ *
38
+ * Code points, not `String.length`: `.length` counts UTF-16 code units, so an
39
+ * emoji-heavy detail measured that way is half the length a reader would call
40
+ * it. The number matches `@relayflows/surface`'s
41
+ * `COMPLETION_DETAIL_MAX_CODE_POINTS`, which is what an author sees; it is the
42
+ * same order as the kernel's gate-detail cap
43
+ * (`kernel/relayflowd/src/engine/remote.rs`) but deliberately NOT the same
44
+ * bound — that one takes 2,000 characters and then appends its suffix, so its
45
+ * final string is longer than the number it advertises.
46
+ */
47
+ export const COMPLETION_DETAIL_MAX_CODE_POINTS = 2000;
48
+
49
+ /** Fixed width, so the bound above can reserve exactly its room. */
50
+ export const COMPLETION_DETAIL_TRUNCATED_SUFFIX = '… (truncated)';
51
+
52
+ /**
53
+ * Read `done()`'s optional second argument into the detail the marker carries.
54
+ *
55
+ * Absence has four spellings — no argument, `undefined`, `{}`, and
56
+ * `{ detail: undefined }` — and all four mean the same thing: no detail, and a
57
+ * marker command byte-identical to the one the one-argument call has always
58
+ * produced. Whitespace-only joins them: say nothing, or say something.
59
+ *
60
+ * A `detail` that is present and not a string is a programming error the type
61
+ * system already catches for TypeScript callers; the runtime check is here for
62
+ * the same reason `isSurfaceFlowCompletionReason` is, and it refuses with the
63
+ * existing closed `unsupported_completion` code rather than widening the
64
+ * taxonomy. Refusals name the type they got, never the value — a malformed
65
+ * detail is exactly the kind of text that may carry a credential.
66
+ *
67
+ * Redaction runs BEFORE truncation, and the order is load-bearing rather than
68
+ * cosmetic: truncating first can split a token so no pattern matches it any
69
+ * more, which is a truncation that *causes* a leak. What redaction recognises
70
+ * is the SDK's existing policy — known token shapes, named credential fields,
71
+ * and the values of secret-looking environment variables (`redact.ts`) — not a
72
+ * guarantee to recognise every possible secret.
73
+ *
74
+ * Lone surrogates are substituted here too, so what leaves this function is a
75
+ * well-formed string the journal protocol can carry — see {@link wellFormed}.
76
+ */
77
+ export function normalizeCompletionDetail(
78
+ options: unknown,
79
+ env: NodeJS.ProcessEnv = process.env,
80
+ ): string | undefined {
81
+ if (options === undefined) return undefined;
82
+ if (typeof options !== 'object' || options === null || Array.isArray(options)) {
83
+ throw new AuthoredFlowExecutionError(
84
+ 'unsupported_completion',
85
+ `done() options must be an object with an optional string "detail"; received ${typeName(options)}`,
86
+ );
87
+ }
88
+ const detail = (options as { detail?: unknown }).detail;
89
+ if (detail === undefined) return undefined;
90
+ if (typeof detail !== 'string') {
91
+ throw new AuthoredFlowExecutionError(
92
+ 'unsupported_completion',
93
+ `done() detail must be a string; received ${typeName(detail)}`,
94
+ );
95
+ }
96
+ const redacted = wellFormed(redact(detail, env)).trim();
97
+ return redacted.length === 0 ? undefined : bound(redacted);
98
+ }
99
+
100
+ /**
101
+ * Lone UTF-16 surrogates, as code points.
102
+ *
103
+ * Under `u` a subject string is iterated by CODE POINT, so a well-formed pair
104
+ * is one code point above U+FFFF and never enters this class. What the class
105
+ * matches is exactly a surrogate with no partner.
106
+ */
107
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
108
+ const LONE_SURROGATES = /[\uD800-\uDFFF]/gu;
109
+
110
+ /**
111
+ * Replace every lone surrogate with U+FFFD, the replacement character.
112
+ *
113
+ * A JavaScript string is UTF-16 code units, not text. Slicing a character off
114
+ * the end of an agent's output — `(prose + '\u{1F642}').slice(0, -1)` — leaves
115
+ * a high surrogate with no partner, and the type system calls the result a
116
+ * `string`. Nothing downstream does.
117
+ * `JSON.stringify` escapes it, so the marker COMMAND is admitted; the raw
118
+ * detail then travels in the root's `step.complete` output, where the kernel's
119
+ * JSON decoder refuses it ("unexpected end of hex escape") and answers with a
120
+ * `bad_request` carrying a null request id — which resolves no pending
121
+ * request, so the call hangs and the authored explanation never lands.
122
+ *
123
+ * Substituting is right here where refusing is not: the flow has already done
124
+ * its work and reached a verdict, and losing the whole explanation over one
125
+ * broken code unit destroys more than it protects. One code unit in, one code
126
+ * unit out, so the bound below still counts what a reader would count.
127
+ */
128
+ function wellFormed(text: string): string {
129
+ return LONE_SURROGATE.test(text) ? text.replace(LONE_SURROGATES, '\uFFFD') : text;
130
+ }
131
+
132
+ /**
133
+ * Is this a detail a durable record may carry?
134
+ *
135
+ * The gate every read boundary uses — the completed root's output, and the
136
+ * IPC frame the Bun runner returns — so an over-long or wrong-typed value
137
+ * fails closed there instead of flowing into a report as if it had been
138
+ * journaled by this process.
139
+ */
140
+ export function isDurableCompletionDetail(value: unknown): value is string {
141
+ return typeof value === 'string'
142
+ && value.length > 0
143
+ && !LONE_SURROGATE.test(value)
144
+ && [...value].length <= COMPLETION_DETAIL_MAX_CODE_POINTS;
145
+ }
146
+
147
+ /**
148
+ * The detail as ONE line, for a diagnostic message.
149
+ *
150
+ * Cloud renders a run's `error` through a line-oriented view that elides the
151
+ * middle of a long one (`cli/cloud-read.ts` `errorLines`), so a forty-line
152
+ * detail can lose the very finding it exists to carry. Escaping the breaks
153
+ * keeps the whole detail on the single line that renderer cannot elide, and
154
+ * keeps control characters out of a terminal. The unescaped normalized detail
155
+ * still travels in the structured fields beside it.
156
+ */
157
+ export function singleLineCompletionDetail(detail: string): string {
158
+ return detail.replace(/[\u0000-\u001F\u007F-\u009F]/gu, (char) =>
159
+ NAMED_ESCAPE[char] ?? `\\u${char.codePointAt(0)!.toString(16).padStart(4, '0')}`);
160
+ }
161
+
162
+ const NAMED_ESCAPE: Record<string, string> = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
163
+
164
+ /**
165
+ * The deterministic command that carries an authored verdict into the journal.
166
+ *
167
+ * `success` with no detail lowers to `:` because success needs no marker: the
168
+ * marker run's own kernel `success` already IS that record. Every other
169
+ * lowered verdict is something the kernel's completion vocabulary cannot
170
+ * express on a step that *succeeded*, so it travels as data on stdout and is
171
+ * read back from `step.completed`. It is deliberately not lowered as a failing
172
+ * command: no step failed here, and a fabricated failure would put bogus
173
+ * evidence in the journal for a flow whose steps all ran correctly.
174
+ *
175
+ * A detail rides in the same JSON object, so the marker stays the one durable
176
+ * record of the verdict and the IPC verifier's existing command comparison
177
+ * checks the detail against the journal for free. With no detail the command
178
+ * is byte-identical to the one this function has always returned, which is
179
+ * what keeps `spec_hash` stable for every flow that does not opt in.
180
+ */
181
+ export function completionMarker(reason: LoweredCompletionReason, detail?: string): string {
182
+ if (detail === undefined) {
183
+ return reason === 'success' ? ':' : `printf '%s' '{"completionReason":"${reason}"}'`;
184
+ }
185
+ // `JSON.stringify` escapes quotes, backslashes and every control character,
186
+ // so the command is one line and the only shell-significant character left
187
+ // in it is `'` — which the single-quote escape below closes.
188
+ const json = JSON.stringify({ completionReason: reason, detail });
189
+ return `printf '%s' '${json.replaceAll("'", "'\\''")}'`;
190
+ }
191
+
192
+ function typeName(value: unknown): string {
193
+ if (value === null) return 'null';
194
+ if (Array.isArray(value)) return 'an array';
195
+ return `a ${typeof value}`;
196
+ }
197
+
198
+ function bound(text: string): string {
199
+ const points = [...text];
200
+ if (points.length <= COMPLETION_DETAIL_MAX_CODE_POINTS) return text;
201
+ const keep = COMPLETION_DETAIL_MAX_CODE_POINTS - [...COMPLETION_DETAIL_TRUNCATED_SUFFIX].length;
202
+ return `${points.slice(0, keep).join('')}${COMPLETION_DETAIL_TRUNCATED_SUFFIX}`;
203
+ }
@@ -1,5 +1,5 @@
1
1
  import type { HumanRecipient } from './human-to.js';
2
- import type { StepFailedDetails } from './failure-kinds.js';
2
+ import type { ParkCause, StepFailedDetails } from './failure-kinds.js';
3
3
  import type {
4
4
  CompletionReason as ProtocolCompletionReason,
5
5
  RunCompletionReason as ProtocolRunCompletionReason,
@@ -42,6 +42,18 @@ export type AuthoredFlowExecutionErrorCode =
42
42
  export class AuthoredFlowExecutionError extends Error {
43
43
  /** Set by the durable root driver after the child error crosses any IPC boundary. */
44
44
  rootRunId?: string;
45
+ /**
46
+ * Why an `agent_parked`/`llm_parked` child run parked, as the child's own
47
+ * classification established it (`RunReport.parkCause`).
48
+ *
49
+ * Set by the worker runner beside the code, because the code alone does not
50
+ * say: `agent_parked` covers both "nothing is attached to run this step" and
51
+ * the kernel's `needs_human` recovery wait, and only the first is fixed by
52
+ * attaching a worker. The CLI boundary reads this field rather than matching
53
+ * the message, so the remedy it prints cannot drift from the cause. Absent
54
+ * means unestablished — the boundary then says nothing about workers.
55
+ */
56
+ parkCause?: ParkCause;
45
57
  constructor(
46
58
  readonly code: AuthoredFlowExecutionErrorCode,
47
59
  message: string,
@@ -13,6 +13,13 @@ import { AuthoredBudget } from './authored-budget.js';
13
13
  import { assertMemoryReachable, authoredMemory, scriptMemoryScope } from './authored-memory.js';
14
14
  import { authoredDeterministicRunner, authoredWorkerRunner } from './authored-worker-step.js';
15
15
  import { isSurfaceFlowCompletionReason, isSurfaceRunCompletionReason } from './authored-step-output.js';
16
+ import {
17
+ completionMarker,
18
+ isLoweredCompletion,
19
+ normalizeCompletionDetail,
20
+ type LoweredCompletionReason,
21
+ } from './authored-completion.js';
22
+ import { commitAuthoredVerdict } from './authored-completion-record.js';
16
23
  import {
17
24
  type AgentResult,
18
25
  type LlmOptions,
@@ -21,6 +28,8 @@ import {
21
28
  type Ctx,
22
29
  type FlowCompletionReason,
23
30
  type RunCompletionReason as SurfaceRunCompletionReason,
31
+ type RunOptions,
32
+ type RunResult,
24
33
  type Step,
25
34
  } from '@relayflows/surface';
26
35
  import { createHelpers, helperProviders, type HelperCall, type FlowHandle } from '@relayflows/surface/runtime';
@@ -96,6 +105,12 @@ export interface AuthoredFlowExecutionResult {
96
105
  readonly rootRunId?: string;
97
106
  readonly name: string;
98
107
  readonly completionReason: LoweredCompletionReason;
108
+ /**
109
+ * Why, when the body said. Normalized at `done()` — redacted, trimmed and
110
+ * bounded — and absent for every one-argument call, so the durable output,
111
+ * the IPC frame and the report keep the shapes they had.
112
+ */
113
+ readonly completionDetail?: string;
99
114
  readonly journalSteps: readonly AuthoredFlowJournalStep[];
100
115
  }
101
116
 
@@ -243,6 +258,7 @@ export async function executeAuthoredFlow<Input = undefined>(
243
258
  const stepEdges = (step: string): AuthoredStepEdges | undefined => lifecycle.stepEdges(step);
244
259
  let nextStep = 1;
245
260
  let requestedCompletion: LoweredCompletionReason | undefined;
261
+ let requestedDetail: string | undefined;
246
262
 
247
263
  const lowerDeterministic = authoredDeterministicRunner(
248
264
  definition.name, journal, journalSteps, budget, {
@@ -382,6 +398,56 @@ export async function executeAuthoredFlow<Input = undefined>(
382
398
  return trackStep(authoredSteps, llmOp);
383
399
  }
384
400
 
401
+ /**
402
+ * `f.run` (docs/SURFACE.md §1). Declared as overloads like `llmOperation`,
403
+ * because the exit-code policy decides the RESULT TYPE: the default keeps
404
+ * the stdout string every body is written against, and `'record'` widens it
405
+ * to the `RunResult` whose `ok` is the journaled exit code.
406
+ */
407
+ function runOperation(command: string, options?: RunOptions & { onNonZero?: 'fail' }): Step<string>;
408
+ function runOperation(command: string, options: RunOptions & { onNonZero: 'record' }): Step<RunResult>;
409
+ function runOperation(command: string, options: RunOptions): Step<string | RunResult>;
410
+ function runOperation(command: string, runOptions?: RunOptions): Step<string> | Step<RunResult> {
411
+ assertOperationAllowed('run', definition.name, requestedCompletion);
412
+ const leaseMs = runOptions?.timeout === undefined ? undefined : parseStepTimeout(runOptions.timeout);
413
+ // Refused before an ordinal is consumed or anything is journaled. A
414
+ // misspelled policy must not quietly fall back to the fatal default and
415
+ // take away the very branch the author wrote below this line.
416
+ const policy = runOptions?.onNonZero;
417
+ if (policy !== undefined && policy !== 'fail' && policy !== 'record') {
418
+ throw new AuthoredFlowExecutionError(
419
+ 'unsupported_verb',
420
+ `f.run options.onNonZero must be 'fail' or 'record' (got ${JSON.stringify(policy)}).`,
421
+ );
422
+ }
423
+ const id = `run-${nextStep++}`;
424
+ if (policy === 'record') {
425
+ let recordOp!: AuthoredFlowOperation<RunResult>;
426
+ recordOp = new AuthoredFlowOperation<RunResult>(
427
+ id, 'run',
428
+ () => assertOperationAllowed('run', definition.name, requestedCompletion),
429
+ () => observeStep(id, 'deterministic',
430
+ () => lowerDeterministic.recording(id, command, leaseMs, recordOp.namedGate), options.onProgress),
431
+ lifecycle,
432
+ // No label: a command is not a display name. It carries literal
433
+ // tokens and URLs, and no prefix of it is safe to show (displayLabel).
434
+ {},
435
+ );
436
+ return trackStep(authoredSteps, recordOp);
437
+ }
438
+ let runOp!: AuthoredFlowOperation<string>;
439
+ runOp = new AuthoredFlowOperation<string>(
440
+ id, 'run',
441
+ () => assertOperationAllowed('run', definition.name, requestedCompletion),
442
+ () => observeStep(id, 'deterministic', () => lowerDeterministic(id, command, false, leaseMs, runOp.namedGate), options.onProgress),
443
+ lifecycle,
444
+ // No label: a command is not a display name. It carries literal
445
+ // tokens and URLs, and no prefix of it is safe to show (displayLabel).
446
+ {},
447
+ );
448
+ return trackStep(authoredSteps, runOp);
449
+ }
450
+
385
451
  const slackRun = options.rootRunId ?? randomUUID();
386
452
  function slackOperation<T>(call: SlackCall): Step<T> {
387
453
  assertOperationAllowed(`slack.${call.verb}`, definition.name, requestedCompletion);
@@ -444,23 +510,7 @@ export async function executeAuthoredFlow<Input = undefined>(
444
510
  () => assertOperationAllowed('memory', definition.name, requestedCompletion),
445
511
  definition.header.memory?.script !== false,
446
512
  ),
447
- run(command, runOptions) {
448
- assertOperationAllowed('run', definition.name, requestedCompletion);
449
- const leaseMs = runOptions?.timeout === undefined ? undefined : parseStepTimeout(runOptions.timeout);
450
- const id = `run-${nextStep++}`;
451
- let runOp!: AuthoredFlowOperation<string>;
452
- runOp = new AuthoredFlowOperation<string>(
453
- id,
454
- 'run',
455
- () => assertOperationAllowed('run', definition.name, requestedCompletion),
456
- () => observeStep(id, 'deterministic', () => lowerDeterministic(id, command, false, leaseMs, runOp.namedGate), options.onProgress),
457
- lifecycle,
458
- // No label: a command is not a display name. It carries literal
459
- // tokens and URLs, and no prefix of it is safe to show (displayLabel).
460
- {},
461
- );
462
- return trackStep(authoredSteps, runOp);
463
- },
513
+ run: runOperation,
464
514
  llm: llmOperation,
465
515
  agent(name, options) {
466
516
  assertOperationAllowed('agent', definition.name, requestedCompletion);
@@ -562,7 +612,7 @@ export async function executeAuthoredFlow<Input = undefined>(
562
612
  displayLabel(typeof name === 'string' ? name : undefined),
563
613
  ));
564
614
  },
565
- done(reason) {
615
+ done(reason, doneOptions) {
566
616
  if (!isSurfaceFlowCompletionReason(reason)) {
567
617
  throw new AuthoredFlowExecutionError(
568
618
  'unsupported_completion',
@@ -595,8 +645,13 @@ export async function executeAuthoredFlow<Input = undefined>(
595
645
  reason,
596
646
  );
597
647
  }
648
+ // Validated before the completion is marked, so a malformed `detail`
649
+ // leaves the flow exactly as any other refused `done()` does: not
650
+ // completed, and still able to report the real refusal.
651
+ const detail = normalizeCompletionDetail(doneOptions);
598
652
  lifecycle.markCompletion();
599
653
  requestedCompletion = reason;
654
+ requestedDetail = detail;
600
655
  },
601
656
  cloud: unsupportedCloud(
602
657
  () => assertOperationAllowed('cloud', definition.name, requestedCompletion),
@@ -670,12 +725,27 @@ export async function executeAuthoredFlow<Input = undefined>(
670
725
  // not as a fabricated kernel run.completed reason. The marker step reports
671
726
  // what the body decided; it is not itself a step that failed. The CLI turns
672
727
  // the verdict into the exit code (success/declined 0, needs_human 3, step_failed 1).
673
- await lowerDeterministic(`complete-${nextStep}`,
674
- completionMarker(requestedCompletion), true);
728
+ //
729
+ // The verdict is committed to the root BEFORE the marker run is opened, and
730
+ // a resumed body reuses what was committed rather than recomputing it
731
+ // (authored-completion-record.ts). A detail is redacted against
732
+ // `process.env`, so a credential rotated while this process was down would
733
+ // otherwise re-normalize the same authored sentence differently, retry the
734
+ // marker's stable admission key with a drifted spec, and lose the
735
+ // explanation the root had already journaled to `run_admission_conflict`.
736
+ // With no detail there is no environment in the command and nothing to
737
+ // commit, so that path is untouched.
738
+ const terminalId = `complete-${nextStep}`;
739
+ const verdict = await commitAuthoredVerdict(journal, options.rootRunId, terminalId, {
740
+ reason: requestedCompletion,
741
+ ...(requestedDetail === undefined ? {} : { detail: requestedDetail }),
742
+ });
743
+ await lowerDeterministic(terminalId, completionMarker(verdict.reason, verdict.detail), true);
675
744
  return Object.freeze({
676
745
  ...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
677
746
  name: definition.name,
678
- completionReason: requestedCompletion,
747
+ completionReason: verdict.reason,
748
+ ...(verdict.detail === undefined ? {} : { completionDetail: verdict.detail }),
679
749
  journalSteps: Object.freeze([...journalSteps]),
680
750
  });
681
751
  }
@@ -695,43 +765,6 @@ function unsupportedVerb(verb: string): AuthoredFlowExecutionError {
695
765
  );
696
766
  }
697
767
 
698
- /**
699
- * The completion reasons an authored body may declare and this executor lowers.
700
- *
701
- * `FlowCompletionReason` is wider than this on purpose — it is the journal's
702
- * run vocabulary plus authored verdicts — but the two sets drifting silently is
703
- * exactly what made a type-valid `done("step_failed")` die at runtime as
704
- * `unsupported_completion`. Every gate that asks "is this a completion this
705
- * runtime can lower?" now asks this one function, so a reason cannot be
706
- * accepted in one place and rejected in another.
707
- *
708
- * These three are internal cross-module helpers for the authored seam (the
709
- * executor, the durable root, the IPC verifier and the CLI report), NOT public
710
- * SDK surface. `src/index.ts` deliberately re-exports nothing from this module
711
- * — keep it that way, or the whole authored seam leaks with them.
712
- */
713
- export const LOWERED_COMPLETIONS = ['success', 'needs_human', 'step_failed', 'declined'] as const;
714
- export type LoweredCompletionReason = (typeof LOWERED_COMPLETIONS)[number];
715
-
716
- export function isLoweredCompletion(value: unknown): value is LoweredCompletionReason {
717
- return typeof value === 'string' && (LOWERED_COMPLETIONS as readonly string[]).includes(value);
718
- }
719
-
720
- /**
721
- * The deterministic command that carries an authored verdict into the journal.
722
- *
723
- * `success` lowers to `:` because success needs no marker: the marker run's own
724
- * kernel `success` already IS that record. Every other lowered verdict is
725
- * something the kernel's completion vocabulary cannot express on a step that
726
- * *succeeded*, so it travels as data on stdout and is read back from
727
- * `step.completed`. It is deliberately not lowered as a failing command: no
728
- * step failed here, and a fabricated failure would put bogus evidence in the
729
- * journal for a flow whose steps all ran correctly.
730
- */
731
- export function completionMarker(reason: LoweredCompletionReason): string {
732
- return reason === 'success' ? ':' : `printf '%s' '{"completionReason":"${reason}"}'`;
733
- }
734
-
735
768
  function assertOperationAllowed(
736
769
  verb: string,
737
770
  flowName: string,
@@ -1,5 +1,6 @@
1
1
  import { AuthoredFlowExecutionError, type AuthoredHumanWait } from './authored-flow-error.js';
2
2
  import type { JournalClient } from './journal-client.js';
3
+ import { shellWord } from './shell-word.js';
3
4
 
4
5
  /**
5
6
  * The answer contract for a parked `f.human`.
@@ -144,7 +145,3 @@ export function resumeCommand(runId: string, dataDir?: string, localAgent = fals
144
145
  const dir = dataDir === undefined ? '' : ` --data-dir ${shellWord(dataDir)}`;
145
146
  return `flows resume${dir}${localAgent ? ' --local-agent' : ''} ${runId}`;
146
147
  }
147
-
148
- function shellWord(value: string): string {
149
- return /^[A-Za-z0-9_./=:@%+,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
150
- }
@@ -88,9 +88,12 @@ try {
88
88
  // `details` is the failing child step's journal evidence. Without it the
89
89
  // parent can only re-render the message, and the machine-readable
90
90
  // diagnostic loses the command's exit code and output tails.
91
+ // `parkCause` travels for the same reason: it is the difference between
92
+ // "attach a worker" and "a human has to recover this", and this process is
93
+ // the only one that saw the child's classification.
91
94
  ...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
92
95
  completionReason: error.completionReason, runId: error.runId,
93
- details: error.details } : {}),
96
+ details: error.details, parkCause: error.parkCause } : {}),
94
97
  ...(error instanceof AuthoredHumanParked ? { wait: error.wait } : {}) });
95
98
  process.exitCode = 1;
96
99
  } finally {
@@ -8,12 +8,14 @@ import { isAbsolute, join } from 'node:path';
8
8
  import type { Readable } from 'node:stream';
9
9
  import type { AuthoredRootMetadata } from './authored-root.js';
10
10
  import type { AuthoredExecutionRuntime, AuthoredFlowExecutionResult, ExecuteAuthoredFlowOptions } from './authored-flow-executor.js';
11
- import { completionMarker, isLoweredCompletion } from './authored-flow-executor.js';
11
+ import { completionMarker, isDurableCompletionDetail, isLoweredCompletion } from './authored-completion.js';
12
12
  import {
13
13
  AuthoredFlowExecutionError, AuthoredHumanParked,
14
14
  type AuthoredFlowExecutionErrorCode, type AuthoredHumanWait,
15
15
  } from './authored-flow-error.js';
16
- import type { StepFailedDetails } from './failure-kinds.js';
16
+ import type {
17
+ AttemptEvidenceComparison, ParkCause, StepAttemptFailure, StepFailedDetails,
18
+ } from './failure-kinds.js';
17
19
  import { HUMAN_WAIT_ID } from './authored-human.js';
18
20
  import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
19
21
 
@@ -137,13 +139,18 @@ export async function runAuthoredInNode(
137
139
  else if (message.type === 'wait') options.onWait?.(message.event);
138
140
  else if (message.type === 'result') result = { ...message.result, executionRuntime: runtime };
139
141
  else if (message.type === 'error') {
140
- failure = message.code === 'human_parked' && isHumanWaitFrame(message.wait) && message.runId === rootRunId
141
- ? new AuthoredHumanParked(message.wait, rootRunId)
142
- : typeof message.code === 'string'
143
- ? new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
144
- message.message, message.completionReason, message.runId,
145
- stepFailedFrame(message.details))
146
- : new Error(message.message);
142
+ if (message.code === 'human_parked' && isHumanWaitFrame(message.wait) && message.runId === rootRunId) {
143
+ failure = new AuthoredHumanParked(message.wait, rootRunId);
144
+ } else if (typeof message.code === 'string') {
145
+ const authored = new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
146
+ message.message, message.completionReason, message.runId,
147
+ stepFailedFrame(message.details));
148
+ // Validated, not trusted, like `details`: an unrecognised cause
149
+ // is dropped so the boundary says nothing about workers rather
150
+ // than acting on a value this frame could have invented.
151
+ authored.parkCause = parkCauseFrame(message.parkCause);
152
+ failure = authored;
153
+ } else failure = new Error(message.message);
147
154
  } else throw new Error('unknown authored runtime message');
148
155
  } catch (error) { stop(error instanceof Error ? error : new Error('invalid authored runtime message')); }
149
156
  }
@@ -174,6 +181,17 @@ function isHumanWaitFrame(value: unknown): value is AuthoredHumanWait {
174
181
  && typeof wait.to === 'string' && wait.to !== '';
175
182
  }
176
183
 
184
+ /**
185
+ * A park cause arriving over IPC, accepted only as one of the two values the
186
+ * type admits. Anything else — absent, misspelled, a different type — becomes
187
+ * `undefined`, which the remedy formatter reads as "unestablished" and answers
188
+ * with silence. Guessing `worker_unavailable` here would let a malformed frame
189
+ * put "attach a worker" on a park a worker cannot clear.
190
+ */
191
+ export function parkCauseFrame(value: unknown): ParkCause | undefined {
192
+ return value === 'worker_unavailable' || value === 'needs_human' ? value : undefined;
193
+ }
194
+
177
195
  /**
178
196
  * Step evidence arriving over IPC, reduced to the fields `StepFailedDetails`
179
197
  * declares and the types it declares them as. The child is the same pinned
@@ -184,26 +202,76 @@ function isHumanWaitFrame(value: unknown): value is AuthoredHumanWait {
184
202
  * evidence frame would replace the answer with a worse one.
185
203
  */
186
204
  export function stepFailedFrame(value: unknown): StepFailedDetails | undefined {
187
- if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
188
- const frame = value as Record<string, unknown>;
189
- const text = (key: string): string | undefined =>
190
- typeof frame[key] === 'string' ? (frame[key] as string).slice(0, 8192) : undefined;
191
- const count = (key: string): number | undefined =>
192
- typeof frame[key] === 'number' && Number.isSafeInteger(frame[key]) ? frame[key] as number : undefined;
205
+ const frame = frameRecord(value);
206
+ if (frame === undefined) return undefined;
193
207
  const details: StepFailedDetails = {};
194
208
  for (const [key, parsed] of [
195
- ['stepId', text('stepId')], ['stepType', text('stepType')],
196
- ['completionReason', text('completionReason')], ['attempt', count('attempt')],
197
- ['maxIterations', count('maxIterations')], ['exitCode', count('exitCode')],
198
- ['stdoutTail', text('stdoutTail')], ['stderrTail', text('stderrTail')],
199
- ['detail', text('detail')], ['transcriptPath', text('transcriptPath')],
200
- ['hint', text('hint')], ['journalPath', text('journalPath')],
209
+ ['stepId', frameText(frame, 'stepId')], ['stepType', frameText(frame, 'stepType')],
210
+ ['completionReason', frameText(frame, 'completionReason')], ['attempt', frameCount(frame, 'attempt')],
211
+ ['maxIterations', frameCount(frame, 'maxIterations')], ['exitCode', frameCount(frame, 'exitCode')],
212
+ ['stdoutTail', frameText(frame, 'stdoutTail')], ['stderrTail', frameText(frame, 'stderrTail')],
213
+ ['detail', frameText(frame, 'detail')], ['transcriptPath', frameText(frame, 'transcriptPath')],
214
+ ['attempts', attemptFrames(frame['attempts'])],
215
+ ['attemptEvidence', comparisonFrame(frame['attemptEvidence'])],
216
+ ['hint', frameText(frame, 'hint')], ['journalPath', frameText(frame, 'journalPath')],
201
217
  ] as const) {
202
218
  if (parsed !== undefined) (details as Record<string, unknown>)[key] = parsed;
203
219
  }
204
220
  return Object.keys(details).length === 0 ? undefined : details;
205
221
  }
206
222
 
223
+ /**
224
+ * The attempt history, reduced element by element on the same terms as the
225
+ * frame that carries it. An element that is not an object contributes nothing
226
+ * rather than voiding the whole history: a report naming three of four
227
+ * attempts is still the first attempt's error, which is the fact the terminal
228
+ * scalars cannot supply.
229
+ */
230
+ function attemptFrames(value: unknown): StepAttemptFailure[] | undefined {
231
+ if (!Array.isArray(value)) return undefined;
232
+ const attempts: StepAttemptFailure[] = [];
233
+ for (const element of value) {
234
+ const source = frameRecord(element);
235
+ if (source === undefined) continue;
236
+ const attempt: StepAttemptFailure = {};
237
+ for (const [key, parsed] of [
238
+ ['attempt', frameCount(source, 'attempt')],
239
+ ['completionReason', frameText(source, 'completionReason')],
240
+ ['disposition', frameText(source, 'disposition')],
241
+ ['exitCode', frameCount(source, 'exitCode')],
242
+ ['stdoutTail', frameText(source, 'stdoutTail')],
243
+ ['stderrTail', frameText(source, 'stderrTail')],
244
+ ['detail', frameText(source, 'detail')],
245
+ // Only a literal `true` claims truncation; anything else leaves the
246
+ // excerpt unlabelled rather than labelling a complete one as cut.
247
+ ['truncated', source['truncated'] === true ? true : undefined],
248
+ ] as const) {
249
+ if (parsed !== undefined) (attempt as Record<string, unknown>)[key] = parsed;
250
+ }
251
+ if (Object.keys(attempt).length > 0) attempts.push(attempt);
252
+ }
253
+ return attempts.length === 0 ? undefined : attempts;
254
+ }
255
+
256
+ /** An unrecognised verdict is dropped, and rendering then says `unknown`. */
257
+ function comparisonFrame(value: unknown): AttemptEvidenceComparison | undefined {
258
+ return value === 'differs' || value === 'unchanged' || value === 'unknown' ? value : undefined;
259
+ }
260
+
261
+ function frameRecord(value: unknown): Record<string, unknown> | undefined {
262
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
263
+ ? value as Record<string, unknown> : undefined;
264
+ }
265
+
266
+ function frameText(frame: Record<string, unknown>, key: string): string | undefined {
267
+ return typeof frame[key] === 'string' ? (frame[key] as string).slice(0, 8192) : undefined;
268
+ }
269
+
270
+ function frameCount(frame: Record<string, unknown>, key: string): number | undefined {
271
+ return typeof frame[key] === 'number' && Number.isSafeInteger(frame[key])
272
+ ? frame[key] as number : undefined;
273
+ }
274
+
207
275
  /** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */
208
276
  export async function verifyAuthoredNodeResult(
209
277
  result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata,
@@ -212,6 +280,9 @@ export async function verifyAuthoredNodeResult(
212
280
  const invalid = (why = ''): never => { throw new Error(`authored runtime result has no matching durable completion${process.env['FLOWS_VERIFIER_DEBUG'] && why ? ` (${why})` : ''}`); };
213
281
  if (result.rootRunId !== rootRunId || result.name !== metadata.flowName
214
282
  || !isLoweredCompletion(result.completionReason)
283
+ // Type- and bound-check the claimed detail here, so the marker comparison
284
+ // below compares two values this process would itself have produced.
285
+ || (result.completionDetail !== undefined && !isDurableCompletionDetail(result.completionDetail))
215
286
  || !Array.isArray(result.journalSteps) || result.journalSteps.length === 0) invalid('frame');
216
287
  const terminal = result.journalSteps.at(-1)!;
217
288
  if (!terminal || !/^complete-[1-9][0-9]*$/.test(terminal.id)) invalid('terminal');
@@ -288,8 +359,11 @@ export async function verifyAuthoredNodeResult(
288
359
  // rejected a frame whose reason is not lowerable at all — that one is
289
360
  // the runtime validation of untrusted IPC, and it is why nothing has
290
361
  // to be re-asserted here just to satisfy the type.
362
+ // The detail is inside the marker, so this one comparison also
363
+ // attests it: a frame cannot claim a detail the journal does not
364
+ // hold, drop one it does, or alter a character of it.
291
365
  if (step?.type !== 'deterministic'
292
- || step.command !== completionMarker(result.completionReason)) invalid('marker');
366
+ || step.command !== completionMarker(result.completionReason, result.completionDetail)) invalid('marker');
293
367
  }
294
368
  }
295
369
  } finally { journal.close(); }