@relayflows/sdk 2.0.42 → 2.0.44

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 (374) hide show
  1. package/dist/adapters/base.d.ts +12 -1
  2. package/dist/adapters/base.d.ts.map +1 -1
  3. package/dist/adapters/base.js +11 -1
  4. package/dist/adapters/base.js.map +1 -1
  5. package/dist/adapters/claude.d.ts.map +1 -1
  6. package/dist/adapters/claude.js +8 -4
  7. package/dist/adapters/claude.js.map +1 -1
  8. package/dist/adapters/codex.d.ts.map +1 -1
  9. package/dist/adapters/codex.js +15 -6
  10. package/dist/adapters/codex.js.map +1 -1
  11. package/dist/agent-resume.d.ts +8 -0
  12. package/dist/agent-resume.d.ts.map +1 -0
  13. package/dist/agent-resume.js +37 -0
  14. package/dist/agent-resume.js.map +1 -0
  15. package/dist/agent-transcript.d.ts.map +1 -1
  16. package/dist/agent-transcript.js +5 -3
  17. package/dist/agent-transcript.js.map +1 -1
  18. package/dist/authored-flow-error.d.ts +10 -2
  19. package/dist/authored-flow-error.d.ts.map +1 -1
  20. package/dist/authored-flow-error.js.map +1 -1
  21. package/dist/authored-flow-executor.d.ts.map +1 -1
  22. package/dist/authored-flow-executor.js +16 -2
  23. package/dist/authored-flow-executor.js.map +1 -1
  24. package/dist/authored-flow-loader.d.ts +6 -0
  25. package/dist/authored-flow-loader.d.ts.map +1 -1
  26. package/dist/authored-flow-loader.js +18 -6
  27. package/dist/authored-flow-loader.js.map +1 -1
  28. package/dist/authored-helper-effect.js +3 -2
  29. package/dist/authored-helper-effect.js.map +1 -1
  30. package/dist/authored-human.d.ts +2 -0
  31. package/dist/authored-human.d.ts.map +1 -1
  32. package/dist/authored-human.js +4 -0
  33. package/dist/authored-human.js.map +1 -1
  34. package/dist/authored-memory.d.ts +47 -2
  35. package/dist/authored-memory.d.ts.map +1 -1
  36. package/dist/authored-memory.js +394 -15
  37. package/dist/authored-memory.js.map +1 -1
  38. package/dist/authored-node-entry.js +6 -1
  39. package/dist/authored-node-entry.js.map +1 -1
  40. package/dist/authored-node-runner.d.ts +1 -1
  41. package/dist/authored-node-runner.d.ts.map +1 -1
  42. package/dist/authored-node-runner.js +6 -3
  43. package/dist/authored-node-runner.js.map +1 -1
  44. package/dist/authored-root-readback.d.ts +21 -0
  45. package/dist/authored-root-readback.d.ts.map +1 -0
  46. package/dist/authored-root-readback.js +90 -0
  47. package/dist/authored-root-readback.js.map +1 -0
  48. package/dist/authored-root.d.ts.map +1 -1
  49. package/dist/authored-root.js +53 -74
  50. package/dist/authored-root.js.map +1 -1
  51. package/dist/authored-step-output.d.ts +2 -0
  52. package/dist/authored-step-output.d.ts.map +1 -1
  53. package/dist/authored-step-output.js +1 -0
  54. package/dist/authored-step-output.js.map +1 -1
  55. package/dist/authored-worker-step.d.ts.map +1 -1
  56. package/dist/authored-worker-step.js +26 -4
  57. package/dist/authored-worker-step.js.map +1 -1
  58. package/dist/cli/check-helper-body.d.ts +2 -2
  59. package/dist/cli/check-helper-body.d.ts.map +1 -1
  60. package/dist/cli/check-helper-body.js +7 -2
  61. package/dist/cli/check-helper-body.js.map +1 -1
  62. package/dist/cli/check-helper-surface.d.ts +8 -0
  63. package/dist/cli/check-helper-surface.d.ts.map +1 -0
  64. package/dist/cli/check-helper-surface.js +37 -0
  65. package/dist/cli/check-helper-surface.js.map +1 -0
  66. package/dist/cli/check-triggers.d.ts +4 -3
  67. package/dist/cli/check-triggers.d.ts.map +1 -1
  68. package/dist/cli/check-triggers.js +7 -3
  69. package/dist/cli/check-triggers.js.map +1 -1
  70. package/dist/cli/check.d.ts +7 -1
  71. package/dist/cli/check.d.ts.map +1 -1
  72. package/dist/cli/check.js +35 -8
  73. package/dist/cli/check.js.map +1 -1
  74. package/dist/cli/cloud-answer.d.ts +9 -0
  75. package/dist/cli/cloud-answer.d.ts.map +1 -0
  76. package/dist/cli/cloud-answer.js +39 -0
  77. package/dist/cli/cloud-answer.js.map +1 -0
  78. package/dist/cli/cloud-connect-cli.d.ts +2 -1
  79. package/dist/cli/cloud-connect-cli.d.ts.map +1 -1
  80. package/dist/cli/cloud-connect-cli.js +10 -2
  81. package/dist/cli/cloud-connect-cli.js.map +1 -1
  82. package/dist/cli/cloud-human.d.ts +7 -0
  83. package/dist/cli/cloud-human.d.ts.map +1 -0
  84. package/dist/cli/cloud-human.js +22 -0
  85. package/dist/cli/cloud-human.js.map +1 -0
  86. package/dist/cli/cloud-live.d.ts +2 -2
  87. package/dist/cli/cloud-live.d.ts.map +1 -1
  88. package/dist/cli/cloud-live.js +9 -2
  89. package/dist/cli/cloud-live.js.map +1 -1
  90. package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
  91. package/dist/cli/cloud-mirror-session.js +3 -2
  92. package/dist/cli/cloud-mirror-session.js.map +1 -1
  93. package/dist/cli/cloud-run.d.ts +1 -1
  94. package/dist/cli/cloud-run.d.ts.map +1 -1
  95. package/dist/cli/cloud-run.js +12 -0
  96. package/dist/cli/cloud-run.js.map +1 -1
  97. package/dist/cli/detached-record.d.ts +22 -0
  98. package/dist/cli/detached-record.d.ts.map +1 -0
  99. package/dist/cli/detached-record.js +57 -0
  100. package/dist/cli/detached-record.js.map +1 -0
  101. package/dist/cli/detached-run.d.ts +28 -0
  102. package/dist/cli/detached-run.d.ts.map +1 -0
  103. package/dist/cli/detached-run.js +122 -0
  104. package/dist/cli/detached-run.js.map +1 -0
  105. package/dist/cli/diagnostic-fold.d.ts +19 -0
  106. package/dist/cli/diagnostic-fold.d.ts.map +1 -0
  107. package/dist/cli/diagnostic-fold.js +19 -0
  108. package/dist/cli/diagnostic-fold.js.map +1 -0
  109. package/dist/cli/direct-run.d.ts.map +1 -1
  110. package/dist/cli/direct-run.js +16 -2
  111. package/dist/cli/direct-run.js.map +1 -1
  112. package/dist/cli/eval.d.ts +22 -0
  113. package/dist/cli/eval.d.ts.map +1 -0
  114. package/dist/cli/eval.js +131 -0
  115. package/dist/cli/eval.js.map +1 -0
  116. package/dist/cli/journal-timeout.d.ts +12 -0
  117. package/dist/cli/journal-timeout.d.ts.map +1 -0
  118. package/dist/cli/journal-timeout.js +42 -0
  119. package/dist/cli/journal-timeout.js.map +1 -0
  120. package/dist/cli/local-agent-remedy.d.ts.map +1 -1
  121. package/dist/cli/local-agent-remedy.js +5 -4
  122. package/dist/cli/local-agent-remedy.js.map +1 -1
  123. package/dist/cli/observer-session.d.ts +1 -0
  124. package/dist/cli/observer-session.d.ts.map +1 -1
  125. package/dist/cli/observer-session.js +8 -1
  126. package/dist/cli/observer-session.js.map +1 -1
  127. package/dist/cli/reuse.d.ts +8 -0
  128. package/dist/cli/reuse.d.ts.map +1 -1
  129. package/dist/cli/reuse.js +24 -0
  130. package/dist/cli/reuse.js.map +1 -1
  131. package/dist/cli/run.d.ts +8 -2
  132. package/dist/cli/run.d.ts.map +1 -1
  133. package/dist/cli/run.js +72 -56
  134. package/dist/cli/run.js.map +1 -1
  135. package/dist/cli/running-step.d.ts +9 -0
  136. package/dist/cli/running-step.d.ts.map +1 -0
  137. package/dist/cli/running-step.js +156 -0
  138. package/dist/cli/running-step.js.map +1 -0
  139. package/dist/cli/status.d.ts.map +1 -1
  140. package/dist/cli/status.js +12 -8
  141. package/dist/cli/status.js.map +1 -1
  142. package/dist/cli-adapter.d.ts +3 -1
  143. package/dist/cli-adapter.d.ts.map +1 -1
  144. package/dist/cli-adapter.js +6 -2
  145. package/dist/cli-adapter.js.map +1 -1
  146. package/dist/cli-commands.d.ts +77 -11
  147. package/dist/cli-commands.d.ts.map +1 -1
  148. package/dist/cli-commands.js +41 -7
  149. package/dist/cli-commands.js.map +1 -1
  150. package/dist/cli-watch.d.ts +6 -2
  151. package/dist/cli-watch.d.ts.map +1 -1
  152. package/dist/cli-watch.js +7 -6
  153. package/dist/cli-watch.js.map +1 -1
  154. package/dist/cli.d.ts +22 -1
  155. package/dist/cli.d.ts.map +1 -1
  156. package/dist/cli.js +154 -50
  157. package/dist/cli.js.map +1 -1
  158. package/dist/cloud-answer.d.ts +35 -0
  159. package/dist/cloud-answer.d.ts.map +1 -0
  160. package/dist/cloud-answer.js +194 -0
  161. package/dist/cloud-answer.js.map +1 -0
  162. package/dist/cloud-auth-store.d.ts +46 -0
  163. package/dist/cloud-auth-store.d.ts.map +1 -0
  164. package/dist/cloud-auth-store.js +171 -0
  165. package/dist/cloud-auth-store.js.map +1 -0
  166. package/dist/cloud-http.d.ts +7 -16
  167. package/dist/cloud-http.d.ts.map +1 -1
  168. package/dist/cloud-http.js +59 -40
  169. package/dist/cloud-http.js.map +1 -1
  170. package/dist/cloud-human-wait.d.ts +10 -0
  171. package/dist/cloud-human-wait.d.ts.map +1 -0
  172. package/dist/cloud-human-wait.js +17 -0
  173. package/dist/cloud-human-wait.js.map +1 -0
  174. package/dist/cloud-mirror-transport.d.ts.map +1 -1
  175. package/dist/cloud-mirror-transport.js +1 -0
  176. package/dist/cloud-mirror-transport.js.map +1 -1
  177. package/dist/cloud-run-record.d.ts +7 -2
  178. package/dist/cloud-run-record.d.ts.map +1 -1
  179. package/dist/cloud-run-record.js +13 -5
  180. package/dist/cloud-run-record.js.map +1 -1
  181. package/dist/cloud-run.d.ts +9 -0
  182. package/dist/cloud-run.d.ts.map +1 -1
  183. package/dist/cloud-run.js +36 -2
  184. package/dist/cloud-run.js.map +1 -1
  185. package/dist/communication/local.d.ts.map +1 -1
  186. package/dist/communication/local.js +6 -1
  187. package/dist/communication/local.js.map +1 -1
  188. package/dist/communication/worker.d.ts.map +1 -1
  189. package/dist/communication/worker.js +12 -2
  190. package/dist/communication/worker.js.map +1 -1
  191. package/dist/compile.d.ts.map +1 -1
  192. package/dist/compile.js +5 -3
  193. package/dist/compile.js.map +1 -1
  194. package/dist/daemon-spec-validation.d.ts +31 -0
  195. package/dist/daemon-spec-validation.d.ts.map +1 -0
  196. package/dist/daemon-spec-validation.js +115 -0
  197. package/dist/daemon-spec-validation.js.map +1 -0
  198. package/dist/failure-kinds.d.ts +6 -4
  199. package/dist/failure-kinds.d.ts.map +1 -1
  200. package/dist/failure-kinds.js +17 -1
  201. package/dist/failure-kinds.js.map +1 -1
  202. package/dist/flow-eval-cloud.d.ts +25 -0
  203. package/dist/flow-eval-cloud.d.ts.map +1 -0
  204. package/dist/flow-eval-cloud.js +108 -0
  205. package/dist/flow-eval-cloud.js.map +1 -0
  206. package/dist/flow-eval-error.d.ts +7 -0
  207. package/dist/flow-eval-error.d.ts.map +1 -0
  208. package/dist/flow-eval-error.js +10 -0
  209. package/dist/flow-eval-error.js.map +1 -0
  210. package/dist/flow-eval-gate.d.ts +15 -0
  211. package/dist/flow-eval-gate.d.ts.map +1 -0
  212. package/dist/flow-eval-gate.js +102 -0
  213. package/dist/flow-eval-gate.js.map +1 -0
  214. package/dist/flow-eval-local.d.ts +54 -0
  215. package/dist/flow-eval-local.d.ts.map +1 -0
  216. package/dist/flow-eval-local.js +220 -0
  217. package/dist/flow-eval-local.js.map +1 -0
  218. package/dist/flow-eval-outcome.d.ts +22 -0
  219. package/dist/flow-eval-outcome.d.ts.map +1 -0
  220. package/dist/flow-eval-outcome.js +37 -0
  221. package/dist/flow-eval-outcome.js.map +1 -0
  222. package/dist/flow-eval-report.d.ts +106 -0
  223. package/dist/flow-eval-report.d.ts.map +1 -0
  224. package/dist/flow-eval-report.js +4 -0
  225. package/dist/flow-eval-report.js.map +1 -0
  226. package/dist/flow-eval-suite.d.ts +46 -0
  227. package/dist/flow-eval-suite.d.ts.map +1 -0
  228. package/dist/flow-eval-suite.js +144 -0
  229. package/dist/flow-eval-suite.js.map +1 -0
  230. package/dist/flow-eval-version.d.ts +59 -0
  231. package/dist/flow-eval-version.d.ts.map +1 -0
  232. package/dist/flow-eval-version.js +303 -0
  233. package/dist/flow-eval-version.js.map +1 -0
  234. package/dist/flow-eval.d.ts +41 -0
  235. package/dist/flow-eval.d.ts.map +1 -0
  236. package/dist/flow-eval.js +201 -0
  237. package/dist/flow-eval.js.map +1 -0
  238. package/dist/helper-operation-use.d.ts +18 -0
  239. package/dist/helper-operation-use.d.ts.map +1 -0
  240. package/dist/helper-operation-use.js +336 -0
  241. package/dist/helper-operation-use.js.map +1 -0
  242. package/dist/helper-preflight.d.ts.map +1 -1
  243. package/dist/helper-preflight.js +16 -2
  244. package/dist/helper-preflight.js.map +1 -1
  245. package/dist/helper-reference.d.ts +33 -0
  246. package/dist/helper-reference.d.ts.map +1 -1
  247. package/dist/helper-reference.js +5 -5
  248. package/dist/helper-reference.js.map +1 -1
  249. package/dist/index.d.ts +6 -1
  250. package/dist/index.d.ts.map +1 -1
  251. package/dist/index.js +5 -1
  252. package/dist/index.js.map +1 -1
  253. package/dist/journal-budgeted-reads.d.ts +41 -0
  254. package/dist/journal-budgeted-reads.d.ts.map +1 -0
  255. package/dist/journal-budgeted-reads.js +142 -0
  256. package/dist/journal-budgeted-reads.js.map +1 -0
  257. package/dist/journal-client.d.ts +34 -29
  258. package/dist/journal-client.d.ts.map +1 -1
  259. package/dist/journal-client.js +45 -151
  260. package/dist/journal-client.js.map +1 -1
  261. package/dist/journal-connection.d.ts +42 -0
  262. package/dist/journal-connection.d.ts.map +1 -0
  263. package/dist/journal-connection.js +209 -0
  264. package/dist/journal-connection.js.map +1 -0
  265. package/dist/journal-read-policy.d.ts +38 -0
  266. package/dist/journal-read-policy.d.ts.map +1 -0
  267. package/dist/journal-read-policy.js +126 -0
  268. package/dist/journal-read-policy.js.map +1 -0
  269. package/dist/named-gate-lowering.d.ts.map +1 -1
  270. package/dist/named-gate-lowering.js +2 -6
  271. package/dist/named-gate-lowering.js.map +1 -1
  272. package/dist/re2js-embedded.d.ts +3 -0
  273. package/dist/re2js-embedded.d.ts.map +1 -0
  274. package/dist/re2js-embedded.js +4 -0
  275. package/dist/re2js-embedded.js.map +1 -0
  276. package/dist/spec.d.ts +10 -0
  277. package/dist/spec.d.ts.map +1 -1
  278. package/dist/spec.js.map +1 -1
  279. package/dist/step-fields.d.ts +1 -1
  280. package/dist/step-fields.js +1 -1
  281. package/dist/step-fields.js.map +1 -1
  282. package/dist/validate.d.ts.map +1 -1
  283. package/dist/validate.js +15 -0
  284. package/dist/validate.js.map +1 -1
  285. package/dist/worker-cli.d.ts +3 -1
  286. package/dist/worker-cli.d.ts.map +1 -1
  287. package/dist/worker-cli.js +15 -5
  288. package/dist/worker-cli.js.map +1 -1
  289. package/dist/worker-lease.d.ts.map +1 -1
  290. package/dist/worker-lease.js +11 -3
  291. package/dist/worker-lease.js.map +1 -1
  292. package/dist/worker.js +1 -1
  293. package/dist/worker.js.map +1 -1
  294. package/package.json +5 -3
  295. package/src/adapters/base.ts +16 -1
  296. package/src/adapters/claude.ts +8 -4
  297. package/src/adapters/codex.ts +15 -6
  298. package/src/agent-resume.ts +36 -0
  299. package/src/agent-transcript.ts +5 -3
  300. package/src/authored-flow-error.ts +9 -0
  301. package/src/authored-flow-executor.ts +18 -1
  302. package/src/authored-flow-loader.ts +27 -10
  303. package/src/authored-helper-effect.ts +3 -2
  304. package/src/authored-human.ts +5 -0
  305. package/src/authored-memory.ts +445 -15
  306. package/src/authored-node-entry.ts +6 -1
  307. package/src/authored-node-runner.ts +6 -3
  308. package/src/authored-root-readback.ts +100 -0
  309. package/src/authored-root.ts +58 -85
  310. package/src/authored-step-output.ts +3 -0
  311. package/src/authored-worker-step.ts +29 -4
  312. package/src/cli/check-helper-body.ts +9 -3
  313. package/src/cli/check-helper-surface.ts +43 -0
  314. package/src/cli/check-triggers.ts +9 -4
  315. package/src/cli/check.ts +37 -7
  316. package/src/cli/cloud-answer.ts +40 -0
  317. package/src/cli/cloud-connect-cli.ts +9 -2
  318. package/src/cli/cloud-human.ts +24 -0
  319. package/src/cli/cloud-live.ts +10 -5
  320. package/src/cli/cloud-mirror-session.ts +3 -2
  321. package/src/cli/cloud-run.ts +10 -1
  322. package/src/cli/detached-record.ts +71 -0
  323. package/src/cli/detached-run.ts +131 -0
  324. package/src/cli/diagnostic-fold.ts +28 -0
  325. package/src/cli/direct-run.ts +14 -1
  326. package/src/cli/eval.ts +141 -0
  327. package/src/cli/journal-timeout.ts +49 -0
  328. package/src/cli/local-agent-remedy.ts +6 -4
  329. package/src/cli/observer-session.ts +7 -1
  330. package/src/cli/reuse.ts +25 -0
  331. package/src/cli/run.ts +75 -67
  332. package/src/cli/running-step.ts +140 -0
  333. package/src/cli/status.ts +19 -13
  334. package/src/cli-adapter.ts +7 -1
  335. package/src/cli-commands.ts +43 -7
  336. package/src/cli-watch.ts +8 -5
  337. package/src/cli.ts +158 -51
  338. package/src/cloud-answer.ts +202 -0
  339. package/src/cloud-auth-store.ts +185 -0
  340. package/src/cloud-http.ts +61 -50
  341. package/src/cloud-human-wait.ts +23 -0
  342. package/src/cloud-mirror-transport.ts +1 -0
  343. package/src/cloud-run-record.ts +14 -5
  344. package/src/cloud-run.ts +46 -2
  345. package/src/communication/local.ts +6 -1
  346. package/src/communication/worker.ts +11 -3
  347. package/src/compile.ts +5 -3
  348. package/src/daemon-spec-validation.ts +126 -0
  349. package/src/failure-kinds.ts +17 -1
  350. package/src/flow-eval-cloud.ts +114 -0
  351. package/src/flow-eval-error.ts +10 -0
  352. package/src/flow-eval-gate.ts +114 -0
  353. package/src/flow-eval-local.ts +236 -0
  354. package/src/flow-eval-outcome.ts +43 -0
  355. package/src/flow-eval-report.ts +102 -0
  356. package/src/flow-eval-suite.ts +169 -0
  357. package/src/flow-eval-version.ts +316 -0
  358. package/src/flow-eval.ts +234 -0
  359. package/src/helper-operation-use.ts +285 -0
  360. package/src/helper-preflight.ts +17 -2
  361. package/src/helper-reference.ts +6 -6
  362. package/src/index.ts +15 -1
  363. package/src/journal-budgeted-reads.ts +134 -0
  364. package/src/journal-client.ts +59 -166
  365. package/src/journal-connection.ts +222 -0
  366. package/src/journal-read-policy.ts +123 -0
  367. package/src/named-gate-lowering.ts +2 -6
  368. package/src/re2js-embedded.ts +3 -0
  369. package/src/spec.ts +10 -0
  370. package/src/step-fields.ts +1 -1
  371. package/src/validate.ts +14 -0
  372. package/src/worker-cli.ts +14 -3
  373. package/src/worker-lease.ts +11 -3
  374. package/src/worker.ts +1 -1
@@ -145,3 +145,8 @@ export function resumeCommand(runId: string, dataDir?: string, localAgent = fals
145
145
  const dir = dataDir === undefined ? '' : ` --data-dir ${shellWord(dataDir)}`;
146
146
  return `flows resume${dir}${localAgent ? ' --local-agent' : ''} ${runId}`;
147
147
  }
148
+
149
+ /** Cloud discovers the open wait, then records the answer and resumes. */
150
+ export function cloudAnswerCommand(runId: string): string {
151
+ return `flows answer --cloud ${shellWord(runId)} yes|no`;
152
+ }
@@ -1,9 +1,10 @@
1
- import type { MemoryHelper } from '@relayflows/surface';
2
- import { createHash } from 'node:crypto';
3
- import { access, stat } from 'node:fs/promises';
1
+ import type { HistoryEntry, MemoryFinding, MemoryHelper, MemoryRecallOptions, TrajectoryEntry } from '@relayflows/surface';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { constants } from 'node:fs';
5
- import { dirname, join, resolve } from 'node:path';
5
+ import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { AuthoredFlowExecutionError } from './authored-flow-error.js';
7
+ import type { JournalClient } from './journal-client.js';
7
8
  import { preflightMemory } from './preflight.js';
8
9
 
9
10
  /** Stable across runs; disjoint from other flow files and other named flows. */
@@ -27,29 +28,458 @@ export async function assertMemoryReachable(): Promise<void> {
27
28
  if (refusal) throw new AuthoredFlowExecutionError('memory_unreachable', refusal.message);
28
29
  }
29
30
 
31
+ /**
32
+ * The promise `learn` returns. Its constructor is not `Promise`, so `await`,
33
+ * `Promise.all` and friends reach it through `then`, which records that the
34
+ * body consumed the result.
35
+ */
36
+ class LearnCall extends Promise<void> {
37
+ static override get [Symbol.species](): PromiseConstructor { return Promise; }
38
+ observed = false;
39
+ settled = false;
40
+ override then<A = void, B = never>(
41
+ onFulfilled?: ((value: void) => A | PromiseLike<A>) | null,
42
+ onRejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
43
+ ): Promise<A | B> {
44
+ this.observed = true;
45
+ return super.then(onFulfilled, onRejected);
46
+ }
47
+ }
48
+
49
+ /** Lets the executor find `learn` calls whose outcome the body never consumed. */
50
+ export class MemoryWriteTracker {
51
+ private readonly calls: LearnCall[] = [];
52
+ track(write: Promise<void>): Promise<void> {
53
+ const call = new LearnCall((resolve, reject) => { void write.then(resolve, reject); });
54
+ // Bypass the override: marking the rejection handled must not count as the body consuming it.
55
+ void Promise.prototype.then.call(call, () => { call.settled = true; }, () => { call.settled = true; });
56
+ this.calls.push(call);
57
+ return call;
58
+ }
59
+ /** Wait for every call to settle, so no write outlives the execution that started it. */
60
+ async settle(): Promise<void> {
61
+ await Promise.allSettled(this.calls.map(call => Promise.prototype.then.call(call, () => {}, () => {})));
62
+ }
63
+ /** Every call the body did not await, after letting each one settle. */
64
+ async unawaited(): Promise<number> {
65
+ const unobserved = this.calls.filter(call => !call.observed || !call.settled);
66
+ await Promise.allSettled(unobserved.map(call => Promise.prototype.then.call(call, () => {}, () => {})));
67
+ return unobserved.length;
68
+ }
69
+ }
70
+
71
+ /** Root-run stream holding every finding `learn` committed: the source of truth the trajectory files are projected from. */
72
+ export const MEMORY_LEARN_STREAM = 'memory-learn';
73
+
74
+ export interface LearnRecord {
75
+ readonly memory: 'learn';
76
+ readonly id: string;
77
+ readonly scope: string;
78
+ readonly decision: Required<MemoryFinding>;
79
+ readonly learnedAt: string;
80
+ }
81
+
82
+ export interface CommittedLearn {
83
+ readonly record: LearnRecord;
84
+ readonly runId: string;
85
+ }
86
+
87
+ /** Where `learn` journals a finding before it may become recallable. */
88
+ export interface LearnLedger {
89
+ /** The durable root run this ledger journals on. */
90
+ readonly runId: string;
91
+ /** The commit already recorded for `id`, if any. */
92
+ recorded(id: string): Promise<CommittedLearn | undefined>;
93
+ /**
94
+ * Journal `record`, then return the commit that wins for its id: the first
95
+ * one in the stream. Usually that is `record`; if another attempt on this
96
+ * root committed the same finding first, it is theirs, so every attempt
97
+ * projects the same bytes.
98
+ */
99
+ append(record: LearnRecord): Promise<CommittedLearn>;
100
+ }
101
+
102
+ function canonicalLearnRecord(raw: unknown): LearnRecord | undefined {
103
+ if (typeof raw !== 'object' || raw === null) return undefined;
104
+ const value = raw as Record<string, unknown>;
105
+ const decision = value.decision as Record<string, unknown> | undefined;
106
+ if (value.memory !== 'learn' || typeof value.id !== 'string' || typeof value.scope !== 'string'
107
+ || typeof value.learnedAt !== 'string' || typeof decision !== 'object' || decision === null) return undefined;
108
+ const { question, chosen, reasoning, alternatives } = decision;
109
+ if (typeof question !== 'string' || typeof chosen !== 'string' || typeof reasoning !== 'string'
110
+ || !Array.isArray(alternatives) || alternatives.some(item => typeof item !== 'string')) return undefined;
111
+ // Fixed field order, never the record as it came back: the journal stores
112
+ // stream messages with sorted keys, and the projected file must be
113
+ // byte-identical whether it is written now or republished on resume.
114
+ return {
115
+ memory: 'learn', id: value.id, scope: value.scope,
116
+ decision: { question, chosen, reasoning, alternatives: [...alternatives] as string[] },
117
+ learnedAt: value.learnedAt,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * A ledger on the durable root run's `memory-learn` stream. The stream is read
123
+ * once per execution and re-read after each append, so a resumed or re-driven
124
+ * body finds what any earlier attempt committed. A failed read is not cached:
125
+ * the next call reads again. Journal errors propagate unchanged, so a read
126
+ * interruption or a lost lease keeps its resumable meaning for the root.
127
+ */
128
+ export function journalLearnLedger(journal: Pick<JournalClient, 'streamAppend' | 'streamRead'>, rootRunId: string): LearnLedger {
129
+ let loaded: Promise<Map<string, CommittedLearn>> | undefined;
130
+ async function read(): Promise<Map<string, CommittedLearn>> {
131
+ const commits = new Map<string, CommittedLearn>();
132
+ let offset = 0;
133
+ for (;;) {
134
+ const page = await journal.streamRead(rootRunId, MEMORY_LEARN_STREAM, offset, 1000);
135
+ for (const message of page.messages) {
136
+ const record = canonicalLearnRecord((message as { message?: unknown }).message ?? message);
137
+ // The first commit for an id wins; it is the one its file is built from.
138
+ if (record !== undefined && !commits.has(record.id)) commits.set(record.id, { record, runId: rootRunId });
139
+ }
140
+ if (page.messages.length === 0 || page.next_offset <= offset) break;
141
+ offset = page.next_offset;
142
+ }
143
+ return commits;
144
+ }
145
+ function load(): Promise<Map<string, CommittedLearn>> {
146
+ loaded ??= read().catch(error => { loaded = undefined; throw error; });
147
+ return loaded;
148
+ }
149
+ return {
150
+ runId: rootRunId,
151
+ async recorded(id) {
152
+ return (await load()).get(id);
153
+ },
154
+ async append(record) {
155
+ await journal.streamAppend(rootRunId, MEMORY_LEARN_STREAM, record);
156
+ loaded = undefined;
157
+ return (await load()).get(record.id) ?? { record, runId: rootRunId };
158
+ },
159
+ };
160
+ }
161
+
30
162
  export function authoredMemory(
31
163
  scope: string,
32
164
  assertOpen: () => void,
33
165
  enabled: boolean,
166
+ tracker?: MemoryWriteTracker,
167
+ ledger?: LearnLedger,
34
168
  ): MemoryHelper {
35
- async function read<T>(action: (reader: import('ai-hist').AiHist) => T): Promise<T> {
36
- assertOpen();
169
+ function assertEnabled(): void {
37
170
  if (!enabled) throw new AuthoredFlowExecutionError('unsupported_header', 'f.memory requires script memory; memory.script is false');
171
+ }
172
+ async function read<T>(action: (reader: import('ai-hist').AiHist, learned: LearnedTrajectory[]) => T): Promise<T> {
173
+ assertOpen();
174
+ assertEnabled();
38
175
  await assertMemoryReachable();
39
176
  assertOpen();
40
- const { openAiHist } = await import('ai-hist');
177
+ const { defaultDbPath, openAiHist } = await import('ai-hist');
178
+ const learned = await readLearned(defaultDbPath(), scope);
41
179
  const reader = await openAiHist({ projectScope: scope, fallback: 'error' });
42
- try { return action(reader); } finally { reader.close(); }
180
+ try { return action(reader, learned); } finally { reader.close(); }
181
+ }
182
+ /**
183
+ * Journal first, then publish. The trajectory file is staged under a name
184
+ * no read lists, the finding is committed to the root run's `memory-learn`
185
+ * stream, and only then is the file renamed into place. Nothing is
186
+ * recallable that the journal does not record, and the failures a disk
187
+ * write can have (space, permissions) happen while staging, before anything
188
+ * is journaled. If the process dies between the commit and the rename, the
189
+ * resumed body re-runs `learn`, finds the commit, and publishes the same
190
+ * file from it without journaling again. With no durable root run there is
191
+ * nowhere to journal, so `learn` refuses rather than write unrecorded memory.
192
+ */
193
+ const learning = new Map<string, Promise<void>>();
194
+ async function learnOnce(decision: Required<MemoryFinding>, journaled: LearnLedger): Promise<void> {
195
+ const { dbPath, mode } = await learnTarget();
196
+ const id = learnedId(scope, decision);
197
+ const existing = await journaled.recorded(id);
198
+ if (existing !== undefined) {
199
+ await publishLearned(dbPath, existing, mode);
200
+ return;
201
+ }
202
+ const record: LearnRecord = { memory: 'learn', id, scope, decision, learnedAt: new Date().toISOString() };
203
+ let staged: StagedLearned;
204
+ try {
205
+ staged = await stageLearned(dbPath, { record, runId: journaled.runId }, mode);
206
+ } catch (error) {
207
+ throw new AuthoredFlowExecutionError('memory_unwritable', `f.memory.learn could not stage the finding: ${error instanceof Error ? error.message : String(error)}`);
208
+ }
209
+ try {
210
+ // The last point a body that already called done() can be refused: past
211
+ // here the finding is journaled and will be published.
212
+ assertOpen();
213
+ const winner = await journaled.append(record);
214
+ try {
215
+ await staged.publish(winner);
216
+ } catch (error) {
217
+ throw new AuthoredFlowExecutionError('memory_unwritable', `f.memory.learn journaled the finding (root run ${winner.runId}, stream ${MEMORY_LEARN_STREAM}, id ${id}) but could not publish it: ${error instanceof Error ? error.message : String(error)}`);
218
+ }
219
+ } finally {
220
+ await staged.discard();
221
+ }
222
+ }
223
+ async function learn(finding: MemoryFinding): Promise<void> {
224
+ assertOpen();
225
+ assertEnabled();
226
+ const decision = normalizeFinding(finding);
227
+ if (ledger === undefined) {
228
+ throw new AuthoredFlowExecutionError('memory_unjournaled', 'f.memory.learn requires a durable root run to journal the finding; run the flow through flows run');
229
+ }
230
+ const id = learnedId(scope, decision);
231
+ // Concurrent learns of one finding in this execution share one commit.
232
+ let pending = learning.get(id);
233
+ if (pending === undefined) {
234
+ pending = learnOnce(decision, ledger);
235
+ learning.set(id, pending);
236
+ void pending.then(() => learning.delete(id), () => learning.delete(id));
237
+ }
238
+ await pending;
43
239
  }
44
240
  return {
45
- recall: (query, options) => read(reader => reader.search(query, { ...options, project: scope })),
46
- why: task => read(reader => {
47
- const entry = reader.whyForTask(task);
48
- return entry?.projectId === scope ? [entry] : [];
241
+ recall: (query, options) => read((reader, learned) => recallMerged(reader, learned, scope, query, options)),
242
+ why: task => read((reader, learned) => {
243
+ const synced = reader.whyForTask(task);
244
+ const candidates = [
245
+ ...(synced?.projectId === scope ? [synced] : []),
246
+ ...learned.map(entry => entry.trajectory).filter(entry => trajectoryMatches(entry, task)),
247
+ ];
248
+ const newest = candidates.sort((a, b) => b.timestampMs - a.timestampMs)[0];
249
+ return newest === undefined ? [] : [newest];
49
250
  }),
50
- async learn() {
51
- assertOpen();
52
- throw new AuthoredFlowExecutionError('unsupported_verb', 'f.memory.learn requires the follow-up journal-backed trajectory writer');
251
+ learn(finding) {
252
+ const write = learn(finding);
253
+ return tracker === undefined ? write : tracker.track(write);
254
+ },
255
+ };
256
+ }
257
+
258
+ function normalizeFinding(finding: MemoryFinding): Required<MemoryFinding> {
259
+ const text = (value: unknown, field: string): string => {
260
+ if (typeof value !== 'string' || value.trim() === '') {
261
+ throw new AuthoredFlowExecutionError('memory_finding_invalid', `f.memory.learn requires a non-empty string ${field}`);
262
+ }
263
+ return value;
264
+ };
265
+ const alternatives = finding?.alternatives ?? [];
266
+ if (!Array.isArray(alternatives) || alternatives.some(item => typeof item !== 'string')) {
267
+ throw new AuthoredFlowExecutionError('memory_finding_invalid', 'f.memory.learn alternatives must be an array of strings');
268
+ }
269
+ return {
270
+ question: text(finding?.question, 'question'),
271
+ chosen: text(finding?.chosen, 'chosen'),
272
+ reasoning: text(finding?.reasoning, 'reasoning'),
273
+ alternatives: [...alternatives],
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Learned findings live beside the ai-hist database, not beside the flow
279
+ * source: a flow deployed from a read-only checkout, package or image can
280
+ * still learn. The directory is keyed by the scope's hash, and each file
281
+ * still names the scope as its `projectId`. Reads here do not need
282
+ * `ai-hist sync`. For sync to index them too, add `<db-dir>/flows-memory` to
283
+ * `TRAJECTORY_ROOT` together with the roots it already scans: setting it
284
+ * replaces ai-hist's default `~/Projects` scan rather than adding to it.
285
+ */
286
+ const learnedDir = (dbPath: string, scope: string): string =>
287
+ join(dirname(dbPath), 'flows-memory', basename(scope), '.trajectories', 'compacted');
288
+ const LEARNED_PREFIX = 'flows-memory-';
289
+
290
+ const learnedId = (scope: string, decision: Required<MemoryFinding>): string =>
291
+ `${LEARNED_PREFIX}${createHash('sha256').update(JSON.stringify([scope, decision])).digest('hex').slice(0, 32)}`;
292
+
293
+ /**
294
+ * `learn` never reads the ai-hist database, so it does not load it: it needs
295
+ * only the directory it lives in and its mode. A missing database is the same
296
+ * refusal reads give.
297
+ */
298
+ async function learnTarget(): Promise<{ dbPath: string; mode: number }> {
299
+ const { defaultDbPath } = await import('ai-hist');
300
+ const dbPath = defaultDbPath();
301
+ const info = await stat(dbPath).catch(() => undefined);
302
+ if (info === undefined || !info.isFile()) {
303
+ throw new AuthoredFlowExecutionError('memory_unreachable', `memory database ${dbPath} is missing or not a file`);
304
+ }
305
+ return { dbPath, mode: info.mode & 0o777 };
306
+ }
307
+
308
+ function learnedContent({ record, runId }: CommittedLearn): string {
309
+ return `${JSON.stringify({
310
+ id: record.id,
311
+ version: 1,
312
+ type: 'compacted',
313
+ projectId: record.scope,
314
+ task: { title: record.decision.question, description: null },
315
+ status: 'completed',
316
+ startedAt: record.learnedAt,
317
+ completedAt: record.learnedAt,
318
+ decisions: [record.decision],
319
+ retrospective: { summary: null, approach: null, learnings: [], confidence: null },
320
+ journal: { runId, stream: MEMORY_LEARN_STREAM, id: record.id },
321
+ }, null, 2)}\n`;
322
+ }
323
+
324
+ interface StagedLearned {
325
+ /** Rename into place, rewriting first if the winning commit is not the one staged. */
326
+ publish(winner: CommittedLearn): Promise<void>;
327
+ discard(): Promise<void>;
328
+ }
329
+
330
+ /**
331
+ * Write a committed finding's compacted trajectory file under a temporary
332
+ * name. Its content is derived only from the commit (including when it was
333
+ * learned and which root run journaled it), so every publication of one
334
+ * commit writes the same bytes. The final name is a hash of scope and
335
+ * finding, so re-learning a finding atomically replaces one file. The file
336
+ * takes the database's mode, so a private history does not leak findings
337
+ * through a world-readable file. The ai-hist database is never modified.
338
+ */
339
+ async function stageLearned(dbPath: string, staging: CommittedLearn, mode: number): Promise<StagedLearned> {
340
+ const file = join(learnedDir(dbPath, staging.record.scope), `${staging.record.id}.json`);
341
+ await mkdir(dirname(file), { recursive: true });
342
+ const staged = `${file}.${process.pid}.${randomUUID()}.tmp`;
343
+ let content = learnedContent(staging);
344
+ const write = async () => {
345
+ await writeFile(staged, content, { mode });
346
+ await chmod(staged, mode);
347
+ };
348
+ try {
349
+ await write();
350
+ } catch (error) {
351
+ await rm(staged, { force: true });
352
+ throw error;
353
+ }
354
+ return {
355
+ async publish(winner) {
356
+ const winning = learnedContent(winner);
357
+ if (winning !== content) {
358
+ content = winning;
359
+ await write();
360
+ }
361
+ await rename(staged, file);
53
362
  },
363
+ discard: () => rm(staged, { force: true }),
54
364
  };
55
365
  }
366
+
367
+ async function publishLearned(dbPath: string, committed: CommittedLearn, mode: number): Promise<void> {
368
+ let staged: StagedLearned;
369
+ try {
370
+ staged = await stageLearned(dbPath, committed, mode);
371
+ } catch (error) {
372
+ throw new AuthoredFlowExecutionError('memory_unwritable', `f.memory.learn could not publish journaled finding ${committed.record.id}: ${error instanceof Error ? error.message : String(error)}`);
373
+ }
374
+ try {
375
+ await staged.publish(committed);
376
+ } catch (error) {
377
+ throw new AuthoredFlowExecutionError('memory_unwritable', `f.memory.learn could not publish journaled finding ${committed.record.id}: ${error instanceof Error ? error.message : String(error)}`);
378
+ } finally {
379
+ await staged.discard();
380
+ }
381
+ }
382
+
383
+ interface LearnedTrajectory {
384
+ trajectory: TrajectoryEntry;
385
+ history: HistoryEntry;
386
+ }
387
+
388
+ /** This scope's learned findings, read straight from disk so they need no `ai-hist sync`. */
389
+ async function readLearned(dbPath: string, scope: string): Promise<LearnedTrajectory[]> {
390
+ const dir = learnedDir(dbPath, scope);
391
+ let names: string[];
392
+ try { names = await readdir(dir); } catch (error) {
393
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
394
+ throw error;
395
+ }
396
+ const learned = await Promise.all(names
397
+ .filter(name => name.startsWith(LEARNED_PREFIX) && name.endsWith('.json'))
398
+ .map(async name => {
399
+ const path = join(dir, name);
400
+ try {
401
+ return learnedEntry(scope, path, await readFile(path, 'utf8'), Math.trunc((await stat(path)).mtimeMs));
402
+ } catch (error) {
403
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
404
+ // Files here are only ever published whole, so an unreadable one is
405
+ // real damage; recalling around it would silently drop journaled memory.
406
+ throw new AuthoredFlowExecutionError('memory_unreachable', `learned finding ${path} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
407
+ }
408
+ }));
409
+ return learned.filter((entry): entry is LearnedTrajectory => entry !== undefined);
410
+ }
411
+
412
+ function learnedEntry(scope: string, path: string, text: string, updatedMs: number): LearnedTrajectory {
413
+ const raw: unknown = JSON.parse(text);
414
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error('not a trajectory object');
415
+ const value = raw as Record<string, unknown>;
416
+ const decisions = value.decisions;
417
+ if (typeof value.id !== 'string' || !Array.isArray(decisions)) throw new Error('missing id or decisions');
418
+ const parsed = decisions.map(decision => {
419
+ const d = decision as Record<string, unknown> | null;
420
+ if (typeof d !== 'object' || d === null || typeof d.question !== 'string' || typeof d.chosen !== 'string'
421
+ || typeof d.reasoning !== 'string' || !Array.isArray(d.alternatives) || d.alternatives.some(a => typeof a !== 'string')) {
422
+ throw new Error('malformed decision');
423
+ }
424
+ return { question: d.question, chosen: d.chosen, reasoning: d.reasoning, alternatives: d.alternatives as string[] };
425
+ });
426
+ const task = (typeof value.task === 'object' && value.task !== null ? value.task : {}) as Record<string, unknown>;
427
+ const title = typeof task.title === 'string' ? task.title : null;
428
+ const description = typeof task.description === 'string' ? task.description : null;
429
+ const id = value.id;
430
+ const completedAt = typeof value.completedAt === 'string' ? value.completedAt : null;
431
+ const timestampMs = completedAt !== null && Number.isFinite(Date.parse(completedAt)) ? Date.parse(completedAt) : updatedMs;
432
+ // Same field order ai-hist's buildSearchText uses, so matching agrees with a later sync.
433
+ const searchText = [id, scope, 'completed', title, description,
434
+ ...parsed.flatMap(d => [d.question, d.chosen, d.reasoning, ...d.alternatives])]
435
+ .filter((part): part is string => typeof part === 'string' && part.length > 0).join('\n');
436
+ return {
437
+ trajectory: {
438
+ id, version: 1, personaId: null, projectId: scope,
439
+ task: { title, description },
440
+ status: 'completed', startedAt: typeof value.startedAt === 'string' ? value.startedAt : null, completedAt,
441
+ decisions: parsed, retrospective: { summary: null, approach: null, learnings: [], confidence: null },
442
+ searchText, path, updatedMs, timestampMs,
443
+ },
444
+ // Not yet in the ai-hist database, so it has no row id; a negative id
445
+ // derived from the trajectory id marks it as read from disk.
446
+ history: {
447
+ id: -Number.parseInt(createHash('sha256').update(id).digest('hex').slice(0, 12), 16),
448
+ source: 'trajectory', sessionId: id, project: scope, prompt: searchText, timestampMs, gitBranch: null,
449
+ },
450
+ };
451
+ }
452
+
453
+ const includesFolded = (haystack: string | null | undefined, needle: string): boolean =>
454
+ (haystack ?? '').toLowerCase().includes(needle.toLowerCase());
455
+
456
+ function trajectoryMatches(entry: TrajectoryEntry, query: string): boolean {
457
+ const needle = query.trim();
458
+ if (needle === '') return false;
459
+ return [entry.searchText, entry.task.title, entry.task.description, entry.personaId, entry.projectId]
460
+ .some(field => includesFolded(field, needle));
461
+ }
462
+
463
+ /** ai-hist's `search` semantics over the database plus this scope's not-yet-synced findings. */
464
+ function recallMerged(
465
+ reader: import('ai-hist').AiHist,
466
+ learned: LearnedTrajectory[],
467
+ scope: string,
468
+ query: string,
469
+ options: MemoryRecallOptions | undefined,
470
+ ): HistoryEntry[] {
471
+ const limit = options?.limit ?? 50;
472
+ const needle = query.trim();
473
+ const fromDisk = learned.map(entry => entry.history).filter(entry =>
474
+ (needle === '' || includesFolded(entry.prompt, needle) || includesFolded(entry.project, needle))
475
+ && (options?.source === undefined || options.source === 'trajectory')
476
+ && options?.tag === undefined
477
+ && (options?.beforeMs === undefined || entry.timestampMs < options.beforeMs));
478
+ // Fetching `limit + fromDisk.length` rows guarantees a synced copy of any
479
+ // disk entry that makes the cut is among them, so it dedupes correctly.
480
+ const synced = reader.search(query, { ...options, project: scope, limit: limit + fromDisk.length });
481
+ const syncedIds = new Set(synced.filter(entry => entry.source === 'trajectory').map(entry => entry.sessionId));
482
+ return [...synced, ...fromDisk.filter(entry => !syncedIds.has(entry.sessionId))]
483
+ .sort((a, b) => b.timestampMs - a.timestampMs)
484
+ .slice(0, limit);
485
+ }
@@ -1,3 +1,4 @@
1
+ import { FLOW_READ_BUDGET_MS, isReadInterruptionError } from './journal-read-policy.js';
1
2
  import { Worker } from 'node:worker_threads';
2
3
  import { createHash, createHmac } from 'node:crypto';
3
4
  import { readFileSync, writeSync } from 'node:fs';
@@ -74,7 +75,7 @@ try {
74
75
  const loaded = await loadPinnedAuthoredSource(request.metadata, true);
75
76
  if (request.localAgentStream !== request.metadata.localAgentStream) throw new Error('authored root local agent surface mismatch');
76
77
  if (request.workerCapacity !== undefined && !isAgentCapacity(request.workerCapacity)) throw new Error('invalid authored worker capacity');
77
- client = new JournalClient(request.socketPath);
78
+ client = new JournalClient(request.socketPath, { readBudgetMs: FLOW_READ_BUDGET_MS, budgetHandshake: true });
78
79
  await client.connect(); await client.hello('flows-authored-node');
79
80
  const result = await executeAuthoredFlow(loaded.handle, client,
80
81
  request.metadata.inputPresent ? request.metadata.input : undefined, {
@@ -99,6 +100,10 @@ try {
99
100
  // `parkCause` travels for the same reason: it is the difference between
100
101
  // "attach a worker" and "a human has to recover this", and this process is
101
102
  // the only one that saw the child's classification.
103
+ // Preserve a read interruption across the Node/Bun error frame so the
104
+ // lease-owning parent can leave the root resumable.
105
+ ...(isReadInterruptionError(error)
106
+ ? { code: 'daemon_unresponsive' } : {}),
102
107
  ...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
103
108
  completionReason: error.completionReason, runId: error.runId,
104
109
  details: error.details,
@@ -1,4 +1,5 @@
1
1
  import { JournalClient } from './journal-client.js';
2
+ import { FLOW_READ_BUDGET_MS } from './journal-read-policy.js';
2
3
  import { spawn, spawnSync } from 'node:child_process';
3
4
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
4
5
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
@@ -171,7 +172,7 @@ export async function runAuthoredInNode(
171
172
  else resolve(result);
172
173
  });
173
174
  });
174
- await verifyAuthoredNodeResult(result, metadata, rootRunId, socketPath);
175
+ await verifyAuthoredNodeResult(result, metadata, rootRunId, socketPath, options.signal);
175
176
  return result;
176
177
  } finally { await rm(directory, { recursive: true, force: true }); }
177
178
  }
@@ -323,7 +324,7 @@ function frameCount(frame: Record<string, unknown>, key: string): number | undef
323
324
  /** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */
324
325
  export async function verifyAuthoredNodeResult(
325
326
  result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata,
326
- rootRunId: string, socketPath: string,
327
+ rootRunId: string, socketPath: string, signal?: AbortSignal,
327
328
  ): Promise<void> {
328
329
  const invalid = (why = ''): never => { throw new Error(`authored runtime result has no matching durable completion${process.env['FLOWS_VERIFIER_DEBUG'] && why ? ` (${why})` : ''}`); };
329
330
  if (result.rootRunId !== rootRunId || result.name !== metadata.flowName
@@ -354,7 +355,9 @@ export async function verifyAuthoredNodeResult(
354
355
  if (!authoredIds.has(gate.id.slice(0, -'.gate'.length))) invalid(`orphan ${gate.id}`);
355
356
  }
356
357
  const runs = new Set<string>();
357
- const journal = new JournalClient(socketPath);
358
+ // The verifier reads the same journal under the same CPU load as the body.
359
+ const journal = new JournalClient(socketPath, { readBudgetMs: FLOW_READ_BUDGET_MS, budgetHandshake: true,
360
+ ...(signal === undefined ? {} : { readSignal: signal }) });
358
361
  await journal.connect();
359
362
  try {
360
363
  await journal.hello('flows-authored-result-verifier');
@@ -0,0 +1,100 @@
1
+ import { isDurableCompletionDetail, isLoweredCompletion } from './authored-completion.js';
2
+ import type { AuthoredFlowExecutionResult } from './authored-flow-executor.js';
3
+ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
4
+ import { isSurfaceCompletionReason } from './authored-step-output.js';
5
+ import { JournalFrameError, JournalProtocolError, type JournalClient } from './journal-client.js';
6
+ import type { RunOutcome } from './protocol.js';
7
+
8
+ /*
9
+ * Reading an authored root back from its journal, and what a failed read
10
+ * means. Only a definite answer from the daemon may change how a run is
11
+ * treated; an unanswered, canceled or failed read decides nothing.
12
+ */
13
+
14
+ const NO_ROOT_STEP = new Error('authored root journal has no root step');
15
+
16
+ export async function rootKernelStep(journal: JournalClient, runId: string): Promise<Record<string, unknown>> {
17
+ const entries = (await journal.journalRead(runId, 1)).entries as Array<Record<string, unknown>>;
18
+ const spawned = entries.find(entry => entry.entry_type === 'run.spawned');
19
+ const payload = spawned?.payload as { spec?: { steps?: unknown[] } } | undefined;
20
+ const step = payload?.spec?.steps?.[0];
21
+ if (typeof step !== 'object' || step === null || Array.isArray(step)) throw NO_ROOT_STEP;
22
+ return step as Record<string, unknown>;
23
+ }
24
+
25
+ /** Codes relayflowd uses when it failed to read its own storage (server/protocol.rs). */
26
+ const DAEMON_READ_FAILURES = new Set(['journal_write_failed', 'internal']);
27
+
28
+ /**
29
+ * The root step of `runId`, or undefined when the daemon definitively answered
30
+ * that it has none: a refusal of the read (run_not_found, an unreadable early
31
+ * epoch, ...) or a journal without a root step. A daemon-side read failure, an
32
+ * interruption or a cancellation propagates instead, so a delayed, canceled or
33
+ * failing daemon never makes resume guess the declarative path.
34
+ */
35
+ export async function rootStepIfPresent(journal: JournalClient, runId: string): Promise<Record<string, unknown> | undefined> {
36
+ return rootKernelStep(journal, runId).catch(error => {
37
+ if ((error instanceof JournalProtocolError && !DAEMON_READ_FAILURES.has(error.code)) || error === NO_ROOT_STEP) return undefined;
38
+ throw error;
39
+ });
40
+ }
41
+
42
+ /**
43
+ * The stored result of a root the daemon already reports completed. If reading
44
+ * it back is interrupted, the outcome is still known: say so, rather than
45
+ * recasting a finished run as resumable.
46
+ */
47
+ export function completedResult(journal: JournalClient, outcome: RunOutcome, rootRunId: string): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
48
+ return completedRootResult(journal, rootRunId).catch(error => {
49
+ // A bad frame or a journal with no stored result is a definite fact; any
50
+ // other failure — interrupted, canceled, or a daemon-side read error such
51
+ // as journal_write_failed — left the verdict unread, not the run unfinished.
52
+ if (error instanceof JournalFrameError || error === NO_DURABLE_RESULT) throw error;
53
+ // The kernel completes the root step with success whatever the body
54
+ // declared; the authored verdict lives only in the unread output.
55
+ const unread = new AuthoredFlowExecutionError('result_unreadable',
56
+ `run ${rootRunId} completed, but its stored result (the flow's verdict) could not be read: ${error.message}`,
57
+ undefined, rootRunId);
58
+ unread.rootRunId = rootRunId;
59
+ throw unread;
60
+ });
61
+ }
62
+
63
+ const NO_DURABLE_RESULT = new Error('completed authored root has no durable result');
64
+
65
+ async function completedRootResult(
66
+ journal: JournalClient,
67
+ rootRunId: string,
68
+ ): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
69
+ const entries = (await journal.journalRead(rootRunId, 1)).entries as Array<Record<string, unknown>>;
70
+ const completed = entries.slice().reverse().find(entry => entry.entry_type === 'step.completed'
71
+ && entry.step_id === 'authored-root'
72
+ && (entry.payload as { completionReason?: unknown } | undefined)?.completionReason === 'success');
73
+ const output = (completed?.payload as { output?: unknown } | undefined)?.output;
74
+ if (!isCompletedRootOutput(output)) throw NO_DURABLE_RESULT;
75
+ // The stored detail, never a freshly computed one: redaction reads the
76
+ // CURRENT environment, so recomputing here would let a completed run report
77
+ // something its journal does not hold.
78
+ return Object.freeze({
79
+ name: output.name,
80
+ completionReason: output.completionReason,
81
+ ...(output.completionDetail === undefined ? {} : { completionDetail: output.completionDetail }),
82
+ journalSteps: Object.freeze(output.journalSteps.map(step => Object.freeze({ ...step }))),
83
+ rootRunId,
84
+ });
85
+ }
86
+
87
+ function isCompletedRootOutput(value: unknown): value is Omit<AuthoredFlowExecutionResult, 'rootRunId'> {
88
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
89
+ const output = value as Partial<AuthoredFlowExecutionResult>;
90
+ return typeof output.name === 'string'
91
+ && isLoweredCompletion(output.completionReason)
92
+ // A malformed detail fails the whole readback rather than being dropped:
93
+ // silently discarding it would report the verdict without the evidence
94
+ // the journal says it was recorded with.
95
+ && (output.completionDetail === undefined || isDurableCompletionDetail(output.completionDetail))
96
+ && Array.isArray(output.journalSteps)
97
+ && output.journalSteps.every(step => typeof step === 'object' && step !== null
98
+ && typeof step.id === 'string' && typeof step.runId === 'string'
99
+ && isSurfaceCompletionReason(step.completionReason));
100
+ }