@hraness/slopcamera 3.2.4

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 (441) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +60 -0
  3. package/PRIVACY.md +46 -0
  4. package/README.md +311 -0
  5. package/SECURITY.md +21 -0
  6. package/apps/desktop/README.md +966 -0
  7. package/apps/desktop/analysis/protocol.ts +223 -0
  8. package/apps/desktop/application/capability-binding.ts +594 -0
  9. package/apps/desktop/application/context.ts +97 -0
  10. package/apps/desktop/application/creative-iteration.ts +1385 -0
  11. package/apps/desktop/application/default-registry.ts +146 -0
  12. package/apps/desktop/application/directing-contract.ts +218 -0
  13. package/apps/desktop/application/directing-plan.ts +178 -0
  14. package/apps/desktop/application/errors.ts +18 -0
  15. package/apps/desktop/application/gateway-port.ts +867 -0
  16. package/apps/desktop/application/html-overlay-browser-runtime.ts +687 -0
  17. package/apps/desktop/application/html-overlay-integrity.ts +337 -0
  18. package/apps/desktop/application/html-overlay-renderer.ts +45 -0
  19. package/apps/desktop/application/index.ts +13 -0
  20. package/apps/desktop/application/operation-completion-checkpoint.ts +333 -0
  21. package/apps/desktop/application/operation-effects.ts +94 -0
  22. package/apps/desktop/application/operation.ts +224 -0
  23. package/apps/desktop/application/operations/analysis/faces.ts +244 -0
  24. package/apps/desktop/application/operations/analysis/music.ts +248 -0
  25. package/apps/desktop/application/operations/analysis/project-auto-zooms.ts +302 -0
  26. package/apps/desktop/application/operations/analysis/project-inactivity.ts +381 -0
  27. package/apps/desktop/application/operations/analysis/scenes.ts +272 -0
  28. package/apps/desktop/application/operations/analysis/shared.ts +202 -0
  29. package/apps/desktop/application/operations/derive/edit-batch.ts +967 -0
  30. package/apps/desktop/application/operations/derive/follow-faces.ts +454 -0
  31. package/apps/desktop/application/operations/edit/bind-candidate-revision.ts +91 -0
  32. package/apps/desktop/application/operations/edit/create-candidate-revision.ts +377 -0
  33. package/apps/desktop/application/operations/edit/create-revision.ts +255 -0
  34. package/apps/desktop/application/operations/edit/freeze-revision.ts +182 -0
  35. package/apps/desktop/application/operations/gateway/image.ts +41 -0
  36. package/apps/desktop/application/operations/gateway/index.ts +17 -0
  37. package/apps/desktop/application/operations/gateway/shared.ts +122 -0
  38. package/apps/desktop/application/operations/gateway/speech.ts +41 -0
  39. package/apps/desktop/application/operations/gateway/transcription.ts +42 -0
  40. package/apps/desktop/application/operations/gateway/video.ts +41 -0
  41. package/apps/desktop/application/operations/index.ts +31 -0
  42. package/apps/desktop/application/operations/iteration/create-candidate.ts +240 -0
  43. package/apps/desktop/application/operations/iteration/create-matrix.ts +117 -0
  44. package/apps/desktop/application/operations/iteration/select.ts +155 -0
  45. package/apps/desktop/application/operations/iteration/shared.ts +250 -0
  46. package/apps/desktop/application/operations/media/audio-effects.ts +376 -0
  47. package/apps/desktop/application/operations/media/binding.ts +79 -0
  48. package/apps/desktop/application/operations/media/capabilities.ts +112 -0
  49. package/apps/desktop/application/operations/media/color-grade.ts +343 -0
  50. package/apps/desktop/application/operations/media/html-overlay.ts +802 -0
  51. package/apps/desktop/application/operations/media/index.ts +8 -0
  52. package/apps/desktop/application/operations/media/ingest.ts +303 -0
  53. package/apps/desktop/application/operations/media/overlay.ts +729 -0
  54. package/apps/desktop/application/operations/media/shared.ts +845 -0
  55. package/apps/desktop/application/operations/media/transform-platform.ts +120 -0
  56. package/apps/desktop/application/operations/project/commit-edits.ts +2054 -0
  57. package/apps/desktop/application/operations/project/promote-selection.ts +495 -0
  58. package/apps/desktop/application/operations/project/snapshot.ts +95 -0
  59. package/apps/desktop/application/operations/recording/index.ts +5 -0
  60. package/apps/desktop/application/operations/recording/pause.ts +34 -0
  61. package/apps/desktop/application/operations/recording/resume.ts +34 -0
  62. package/apps/desktop/application/operations/recording/shared.ts +199 -0
  63. package/apps/desktop/application/operations/recording/start.ts +169 -0
  64. package/apps/desktop/application/operations/recording/stop.ts +34 -0
  65. package/apps/desktop/application/operations/render/bind-candidate-output.ts +324 -0
  66. package/apps/desktop/application/operations/render/materialize-selection.ts +322 -0
  67. package/apps/desktop/application/operations/render/project-caption-plan.ts +287 -0
  68. package/apps/desktop/application/operations/render/project-plan.ts +939 -0
  69. package/apps/desktop/application/operations/render/project-spatial-receipt.ts +70 -0
  70. package/apps/desktop/application/operations/render/project.ts +1922 -0
  71. package/apps/desktop/application/operations/shared.ts +67 -0
  72. package/apps/desktop/application/operations/slopcamera-portable.ts +1188 -0
  73. package/apps/desktop/application/operations/slopcamera-visuals.ts +784 -0
  74. package/apps/desktop/application/operations/spatial-project.ts +209 -0
  75. package/apps/desktop/application/operations/spatial-render.ts +415 -0
  76. package/apps/desktop/application/operations/spatial-scene.ts +97 -0
  77. package/apps/desktop/application/operations/studio.ts +143 -0
  78. package/apps/desktop/application/output-publication-lease.ts +163 -0
  79. package/apps/desktop/application/project-publication-lease.ts +233 -0
  80. package/apps/desktop/application/project-store.ts +196 -0
  81. package/apps/desktop/application/receipts.ts +533 -0
  82. package/apps/desktop/application/registry.ts +182 -0
  83. package/apps/desktop/application/spatial-assets.ts +675 -0
  84. package/apps/desktop/application/spatial-compositor-cadence.ts +23 -0
  85. package/apps/desktop/application/spatial-project-authority.ts +35 -0
  86. package/apps/desktop/application/spatial-project-lease.ts +126 -0
  87. package/apps/desktop/application/spatial-project-render-preparation.ts +225 -0
  88. package/apps/desktop/application/spatial-project-store.ts +286 -0
  89. package/apps/desktop/application/spatial-render-projection.ts +201 -0
  90. package/apps/desktop/application/spatial-render.ts +677 -0
  91. package/apps/desktop/application/spatial-spz.ts +44 -0
  92. package/apps/desktop/application/spatial-world-import.ts +135 -0
  93. package/apps/desktop/application/spatial-world-provenance.ts +46 -0
  94. package/apps/desktop/application/studio-port.ts +58 -0
  95. package/apps/desktop/application/verified-receipt-reconciliation.ts +1574 -0
  96. package/apps/desktop/capture/protocol.ts +689 -0
  97. package/apps/desktop/cli/analyzer.ts +406 -0
  98. package/apps/desktop/cli/args.ts +3181 -0
  99. package/apps/desktop/cli/asset-ingest.ts +747 -0
  100. package/apps/desktop/cli/atomic-render-effects.ts +210 -0
  101. package/apps/desktop/cli/atomic-render-platform.ts +205 -0
  102. package/apps/desktop/cli/atomic-render.ts +50 -0
  103. package/apps/desktop/cli/audio-analysis.ts +252 -0
  104. package/apps/desktop/cli/bounded-map.ts +39 -0
  105. package/apps/desktop/cli/bundle-service.ts +256 -0
  106. package/apps/desktop/cli/capabilities.ts +212 -0
  107. package/apps/desktop/cli/capture-bundle.ts +1117 -0
  108. package/apps/desktop/cli/command-host-resources.ts +251 -0
  109. package/apps/desktop/cli/commands.ts +6836 -0
  110. package/apps/desktop/cli/directing-blob.ts +291 -0
  111. package/apps/desktop/cli/directing-media.ts +517 -0
  112. package/apps/desktop/cli/directing-quote.ts +89 -0
  113. package/apps/desktop/cli/directing-service.ts +288 -0
  114. package/apps/desktop/cli/emoji-assets.ts +538 -0
  115. package/apps/desktop/cli/errors.ts +29 -0
  116. package/apps/desktop/cli/face-analysis-service.ts +940 -0
  117. package/apps/desktop/cli/filter-script.ts +76 -0
  118. package/apps/desktop/cli/gateway-application-port.ts +1142 -0
  119. package/apps/desktop/cli/gateway-credential.ts +81 -0
  120. package/apps/desktop/cli/gateway-media-artifacts.ts +723 -0
  121. package/apps/desktop/cli/gateway-media-catalog.ts +922 -0
  122. package/apps/desktop/cli/gateway-media-service.ts +3106 -0
  123. package/apps/desktop/cli/gateway-media-signature.ts +122 -0
  124. package/apps/desktop/cli/gateway-provider-options.ts +562 -0
  125. package/apps/desktop/cli/gateway-scene-provider.ts +391 -0
  126. package/apps/desktop/cli/help.ts +501 -0
  127. package/apps/desktop/cli/html-overlay-gpu.ts +97 -0
  128. package/apps/desktop/cli/html-overlay-renderer.ts +2478 -0
  129. package/apps/desktop/cli/io.ts +290 -0
  130. package/apps/desktop/cli/main.ts +123 -0
  131. package/apps/desktop/cli/media-effects-service.ts +885 -0
  132. package/apps/desktop/cli/media-ingest-model.ts +364 -0
  133. package/apps/desktop/cli/media-ingest-platform.ts +227 -0
  134. package/apps/desktop/cli/media-ingest-program.ts +200 -0
  135. package/apps/desktop/cli/media-ingest.ts +30 -0
  136. package/apps/desktop/cli/music-analysis-service.ts +434 -0
  137. package/apps/desktop/cli/mutation-lock.ts +530 -0
  138. package/apps/desktop/cli/native-assets.d.ts +12 -0
  139. package/apps/desktop/cli/native-media-runtime.macos.ts +7 -0
  140. package/apps/desktop/cli/overlay-playback.ts +177 -0
  141. package/apps/desktop/cli/paths.ts +265 -0
  142. package/apps/desktop/cli/portable-surface.ts +242 -0
  143. package/apps/desktop/cli/project-camera-receipt.ts +112 -0
  144. package/apps/desktop/cli/project-face-camera.ts +394 -0
  145. package/apps/desktop/cli/project-inactivity-service.ts +552 -0
  146. package/apps/desktop/cli/project-media-integrity.ts +121 -0
  147. package/apps/desktop/cli/project-renderer.ts +1526 -0
  148. package/apps/desktop/cli/project-service.ts +519 -0
  149. package/apps/desktop/cli/project-state-transaction.ts +520 -0
  150. package/apps/desktop/cli/query-limits.ts +1 -0
  151. package/apps/desktop/cli/recording-controller.ts +1567 -0
  152. package/apps/desktop/cli/recording-daemon.ts +931 -0
  153. package/apps/desktop/cli/recording-ref.ts +56 -0
  154. package/apps/desktop/cli/renamed-environment.ts +6 -0
  155. package/apps/desktop/cli/renderer.ts +1439 -0
  156. package/apps/desktop/cli/run-cli-test-helper.ts +60 -0
  157. package/apps/desktop/cli/scene-analysis-service.ts +879 -0
  158. package/apps/desktop/cli/spatial-compositor-verifier.ts +213 -0
  159. package/apps/desktop/cli/spatial-project-service.ts +90 -0
  160. package/apps/desktop/cli/spatial-scene-service.ts +105 -0
  161. package/apps/desktop/cli/spatial-world-service.ts +39 -0
  162. package/apps/desktop/cli/speech-analysis-service.ts +682 -0
  163. package/apps/desktop/cli/studio-assemble.ts +58 -0
  164. package/apps/desktop/cli/studio-command.ts +60 -0
  165. package/apps/desktop/cli/studio-custody.ts +60 -0
  166. package/apps/desktop/cli/studio-encode.ts +216 -0
  167. package/apps/desktop/cli/studio-exr.ts +52 -0
  168. package/apps/desktop/cli/studio-files.ts +179 -0
  169. package/apps/desktop/cli/studio-output-validation.ts +162 -0
  170. package/apps/desktop/cli/studio-process.ts +145 -0
  171. package/apps/desktop/cli/studio-runtime.ts +77 -0
  172. package/apps/desktop/cli/studio-scaffold.ts +70 -0
  173. package/apps/desktop/cli/studio-service.ts +301 -0
  174. package/apps/desktop/cli/studio-spatial-asset.ts +248 -0
  175. package/apps/desktop/cli/studio-template-names.ts +3 -0
  176. package/apps/desktop/cli/studio-workflow.ts +19 -0
  177. package/apps/desktop/cli/time.ts +25 -0
  178. package/apps/desktop/cli/workflow-code.ts +584 -0
  179. package/apps/desktop/cli/workflow-runs.ts +576 -0
  180. package/apps/desktop/code/advanced.ts +139 -0
  181. package/apps/desktop/code/application-node-planner.ts +1288 -0
  182. package/apps/desktop/code/compiler.ts +329 -0
  183. package/apps/desktop/code/contracts.ts +13 -0
  184. package/apps/desktop/code/creative-recipes.ts +94 -0
  185. package/apps/desktop/code/define-workflow.ts +115 -0
  186. package/apps/desktop/code/editing.ts +284 -0
  187. package/apps/desktop/code/file-candidate-provenance.ts +420 -0
  188. package/apps/desktop/code/file-candidate.ts +69 -0
  189. package/apps/desktop/code/graph-builder.ts +10 -0
  190. package/apps/desktop/code/host-resource-policy.ts +55 -0
  191. package/apps/desktop/code/plan-contracts.ts +159 -0
  192. package/apps/desktop/code/planning.ts +361 -0
  193. package/apps/desktop/code/public.ts +134 -0
  194. package/apps/desktop/code/reference-projection.ts +62 -0
  195. package/apps/desktop/code/run-contracts.ts +466 -0
  196. package/apps/desktop/code/run-store.ts +2478 -0
  197. package/apps/desktop/code/runtime-identity.ts +328 -0
  198. package/apps/desktop/code/scheduler.ts +3067 -0
  199. package/apps/desktop/code/semantic-builder.ts +1436 -0
  200. package/apps/desktop/code/source-bundle.ts +1641 -0
  201. package/apps/desktop/code/source-typecheck.ts +211 -0
  202. package/apps/desktop/code/testing.ts +79 -0
  203. package/apps/desktop/code/worker-client.ts +1675 -0
  204. package/apps/desktop/code/worker-effects.ts +141 -0
  205. package/apps/desktop/code/worker-entry.ts +435 -0
  206. package/apps/desktop/code/worker-lease-guardian.ts +179 -0
  207. package/apps/desktop/code/worker-process-identity.ts +484 -0
  208. package/apps/desktop/code/worker-protocol.ts +211 -0
  209. package/apps/desktop/code/workflow-effects.ts +132 -0
  210. package/apps/desktop/contracts/analysis.ts +949 -0
  211. package/apps/desktop/contracts/edit.ts +576 -0
  212. package/apps/desktop/contracts/index.ts +9 -0
  213. package/apps/desktop/contracts/media-effects.ts +139 -0
  214. package/apps/desktop/contracts/project-render.ts +644 -0
  215. package/apps/desktop/contracts/project.ts +740 -0
  216. package/apps/desktop/contracts/recording.ts +1434 -0
  217. package/apps/desktop/contracts/render.ts +334 -0
  218. package/apps/desktop/contracts/runtime.ts +155 -0
  219. package/apps/desktop/contracts/spatial-compositor.ts +58 -0
  220. package/apps/desktop/contracts/spatial-project.ts +159 -0
  221. package/apps/desktop/contracts/spatial-world.ts +48 -0
  222. package/apps/desktop/contracts/video-effects.ts +120 -0
  223. package/apps/desktop/core/alignment-apply.ts +400 -0
  224. package/apps/desktop/core/audio-alignment.ts +466 -0
  225. package/apps/desktop/core/auto-zoom.ts +209 -0
  226. package/apps/desktop/core/canonical-json.ts +14 -0
  227. package/apps/desktop/core/emoji.ts +210 -0
  228. package/apps/desktop/core/events.ts +409 -0
  229. package/apps/desktop/core/face-framing.ts +533 -0
  230. package/apps/desktop/core/face-tracks.ts +326 -0
  231. package/apps/desktop/core/inactivity.ts +137 -0
  232. package/apps/desktop/core/index.ts +30 -0
  233. package/apps/desktop/core/intervals.ts +123 -0
  234. package/apps/desktop/core/montage.ts +236 -0
  235. package/apps/desktop/core/music-analysis.ts +518 -0
  236. package/apps/desktop/core/plan.ts +244 -0
  237. package/apps/desktop/core/project-auto-zoom.ts +75 -0
  238. package/apps/desktop/core/project-camera-spatial.ts +676 -0
  239. package/apps/desktop/core/project-camera.ts +403 -0
  240. package/apps/desktop/core/project-captions.ts +888 -0
  241. package/apps/desktop/core/project-inactivity.ts +125 -0
  242. package/apps/desktop/core/project-metadata.ts +927 -0
  243. package/apps/desktop/core/project-plan.ts +375 -0
  244. package/apps/desktop/core/project-render-plan.ts +303 -0
  245. package/apps/desktop/core/project-time.ts +469 -0
  246. package/apps/desktop/core/render-plan.ts +442 -0
  247. package/apps/desktop/core/scene-cache.ts +174 -0
  248. package/apps/desktop/core/scene-sampling.ts +448 -0
  249. package/apps/desktop/core/spatial-compositor.ts +22 -0
  250. package/apps/desktop/core/spatial-durability.ts +99 -0
  251. package/apps/desktop/core/spatial-project.ts +169 -0
  252. package/apps/desktop/core/spatial-shot-clock.ts +32 -0
  253. package/apps/desktop/core/speech-analysis.ts +333 -0
  254. package/apps/desktop/core/storage.ts +970 -0
  255. package/apps/desktop/core/time-map.ts +91 -0
  256. package/apps/desktop/core/time.ts +61 -0
  257. package/apps/desktop/core/video-effects.ts +660 -0
  258. package/apps/desktop/dist/cli/NebulaSans-Bold-26se8aek.otf +0 -0
  259. package/apps/desktop/dist/cli/NebulaSans-Bold-bcz7y08t.woff2 +0 -0
  260. package/apps/desktop/dist/cli/NebulaSans-Book-5ax05zvn.woff2 +0 -0
  261. package/apps/desktop/dist/cli/NebulaSans-Book-8cenzchw.otf +0 -0
  262. package/apps/desktop/dist/cli/main.js +5086 -0
  263. package/apps/desktop/html-overlay/catalog.ts +171 -0
  264. package/apps/desktop/html-overlay/contracts.ts +372 -0
  265. package/apps/desktop/html-overlay/execution-profile.ts +77 -0
  266. package/apps/desktop/html-overlay/index.ts +98 -0
  267. package/apps/desktop/html-overlay/libraries.ts +263 -0
  268. package/apps/desktop/html-overlay/random.ts +49 -0
  269. package/apps/desktop/html-overlay/runtime.ts +813 -0
  270. package/apps/desktop/html-overlay/scaffolds.ts +792 -0
  271. package/apps/desktop/html-overlay/spark-runtime.ts +88 -0
  272. package/apps/desktop/html-overlay/spatial-splat-runtime.ts +60 -0
  273. package/apps/desktop/html-overlay/spatial.ts +720 -0
  274. package/apps/desktop/studio/assets/README.md +39 -0
  275. package/apps/desktop/studio/assets/contracts.ts +129 -0
  276. package/apps/desktop/studio/assets/network.ts +64 -0
  277. package/apps/desktop/studio/assets/poly-haven.ts +148 -0
  278. package/apps/desktop/studio/drivers/blender_driver.py +466 -0
  279. package/apps/desktop/studio/drivers/cadquery_driver.py +176 -0
  280. package/apps/desktop/studio/education/driver.py +308 -0
  281. package/apps/desktop/studio/education/lesson.py +180 -0
  282. package/apps/desktop/studio/education/toolkit.py +249 -0
  283. package/apps/desktop/workflows/chaptered-demo.ts +48 -0
  284. package/apps/desktop/workflows/creative-iteration.ts +107 -0
  285. package/apps/desktop/workflows/creative-selection.ts +92 -0
  286. package/apps/desktop/workflows/fragments.ts +486 -0
  287. package/apps/desktop/workflows/index.ts +93 -0
  288. package/apps/desktop/workflows/polished-screen-demo.ts +128 -0
  289. package/apps/desktop/workflows/scene-direction.ts +12 -0
  290. package/apps/desktop/workflows/social-variants.ts +75 -0
  291. package/apps/desktop/workflows/talking-head-cleanup.ts +53 -0
  292. package/dist/NebulaSans-Bold-26se8aek.otf +0 -0
  293. package/dist/NebulaSans-Bold-bcz7y08t.woff2 +0 -0
  294. package/dist/NebulaSans-Book-5ax05zvn.woff2 +0 -0
  295. package/dist/NebulaSans-Book-8cenzchw.otf +0 -0
  296. package/dist/cli.js +479 -0
  297. package/dist/code/advanced.js +270 -0
  298. package/dist/code/index.js +2240 -0
  299. package/dist/generate.js +24 -0
  300. package/dist/host-resources.js +26 -0
  301. package/dist/index-42zsesc1.js +2257 -0
  302. package/dist/index-7308egqr.js +48 -0
  303. package/dist/index-8txs6fkn.js +712 -0
  304. package/dist/index-mcy8z0br.js +192 -0
  305. package/dist/index-p63wavx0.js +2029 -0
  306. package/dist/index-r7gdhmsp.js +424 -0
  307. package/dist/index-sh6xbav6.js +1219 -0
  308. package/dist/index-z1w83f81.js +4 -0
  309. package/dist/index-z7239b4h.js +2363 -0
  310. package/dist/index-zfeg9hmw.js +2013 -0
  311. package/dist/index.js +241 -0
  312. package/dist/operations.js +29 -0
  313. package/dist/skill-install-dh0ntqba.js +10 -0
  314. package/dist/vectorize/worker.js +142 -0
  315. package/dist/workflow.js +16 -0
  316. package/docs/README.md +38 -0
  317. package/docs/studio.md +215 -0
  318. package/examples/studio/blender/README.md +26 -0
  319. package/examples/studio/blender/character.py +152 -0
  320. package/examples/studio/blender/cloth.py +101 -0
  321. package/examples/studio/blender/color_chart.py +29 -0
  322. package/examples/studio/blender/fluid.py +78 -0
  323. package/examples/studio/blender/import_model.py +74 -0
  324. package/examples/studio/blender/product.py +53 -0
  325. package/examples/studio/blender/shaded_street.py +481 -0
  326. package/examples/studio/blender/studio_scene.py +147 -0
  327. package/examples/studio/cadquery/README.md +13 -0
  328. package/examples/studio/cadquery/bracket.py +36 -0
  329. package/examples/studio/cadquery/import_step.py +12 -0
  330. package/examples/studio/education/README.md +46 -0
  331. package/examples/studio/education/lesson.json +99 -0
  332. package/examples/studio/education/scene.py +7 -0
  333. package/examples/studio/hybrid-scene.ts +96 -0
  334. package/examples/studio/native-workflow.ts +14 -0
  335. package/examples/studio/vgpu/README.md +64 -0
  336. package/examples/studio/vgpu/heat-field.mjs +290 -0
  337. package/package.json +304 -0
  338. package/packages/scene/src/index.ts +1 -0
  339. package/packages/scene/src/scene.ts +215 -0
  340. package/schema/diagram.schema.json +334 -0
  341. package/skills/slopcamera/SKILL.md +45 -0
  342. package/skills/slopcamera/agents/openai.yaml +4 -0
  343. package/skills/slopcamera/references/customization.md +77 -0
  344. package/skills/slopcamera/references/diagrams.md +153 -0
  345. package/skills/slopcamera/references/directed-scenes.md +42 -0
  346. package/skills/slopcamera/references/directing-video.md +18 -0
  347. package/skills/slopcamera/references/educational-video.md +26 -0
  348. package/skills/slopcamera/references/gateway-media.md +134 -0
  349. package/skills/slopcamera/references/install.md +62 -0
  350. package/skills/slopcamera/references/native-studio.md +31 -0
  351. package/skills/slopcamera/references/reference-led-3d.md +140 -0
  352. package/skills/slopcamera/references/rubber-stamp-examples/poster-example-1.jpg +0 -0
  353. package/skills/slopcamera/references/rubber-stamp-examples/poster-example-2.jpg +0 -0
  354. package/skills/slopcamera/references/rubber-stamp-examples/stamp-style-1.png +0 -0
  355. package/skills/slopcamera/references/rubber-stamp-examples/stamp-style-2.png +0 -0
  356. package/skills/slopcamera/references/rubber-stamp-field-notes.md +144 -0
  357. package/skills/slopcamera/references/vectorization.md +29 -0
  358. package/skills/slopcamera/references/video-projects.md +187 -0
  359. package/skills/slopcamera/references/visual-communication.md +120 -0
  360. package/skills/slopcamera/references/workflows-sdk.md +45 -0
  361. package/skills/slopcamera/scripts/compose-rubber-stamp-field-note.ts +229 -0
  362. package/src/artifacts.ts +106 -0
  363. package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
  364. package/src/assets/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
  365. package/src/assets/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
  366. package/src/assets/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
  367. package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  368. package/src/assets/fonts/nebula-sans/PROVENANCE.md +21 -0
  369. package/src/assets.d.ts +9 -0
  370. package/src/cli.ts +601 -0
  371. package/src/cloud-errors.ts +26 -0
  372. package/src/code/advanced.ts +10 -0
  373. package/src/code/boundary.ts +21 -0
  374. package/src/code/canonical-json.ts +129 -0
  375. package/src/code/compiler.ts +953 -0
  376. package/src/code/contracts.ts +576 -0
  377. package/src/code/define-workflow.ts +334 -0
  378. package/src/code/errors.ts +41 -0
  379. package/src/code/graph-builder.ts +945 -0
  380. package/src/code/index.ts +121 -0
  381. package/src/code/json-snapshot.ts +899 -0
  382. package/src/code/json-utf8.ts +88 -0
  383. package/src/code/portable-builder.ts +136 -0
  384. package/src/code/projection.ts +378 -0
  385. package/src/code/public-operations.ts +450 -0
  386. package/src/code/runtime.ts +580 -0
  387. package/src/code/sha256.ts +36 -0
  388. package/src/config.ts +180 -0
  389. package/src/desktop.ts +253 -0
  390. package/src/fs.ts +10 -0
  391. package/src/generate.ts +704 -0
  392. package/src/host-resource-posix.ts +161 -0
  393. package/src/host-resources.ts +1749 -0
  394. package/src/icons.ts +58 -0
  395. package/src/index.ts +200 -0
  396. package/src/label-layout.ts +130 -0
  397. package/src/layout.ts +413 -0
  398. package/src/lint.ts +127 -0
  399. package/src/mcp/boundary.ts +354 -0
  400. package/src/mcp/index.ts +33 -0
  401. package/src/mcp/server.ts +272 -0
  402. package/src/mcp/tools.ts +963 -0
  403. package/src/mcp/types.ts +71 -0
  404. package/src/operations.ts +775 -0
  405. package/src/parse.ts +861 -0
  406. package/src/process-environment.ts +17 -0
  407. package/src/render.ts +437 -0
  408. package/src/skill-install.ts +49 -0
  409. package/src/spatial-scene/authoring.ts +27 -0
  410. package/src/spatial-scene/camera-track.ts +83 -0
  411. package/src/spatial-scene/contracts.ts +277 -0
  412. package/src/spatial-scene/evaluate.ts +145 -0
  413. package/src/spatial-scene/gltf.ts +558 -0
  414. package/src/spatial-scene/identity.ts +260 -0
  415. package/src/spatial-scene/index.ts +10 -0
  416. package/src/spatial-scene/inspect.ts +68 -0
  417. package/src/spatial-scene/math.ts +280 -0
  418. package/src/spatial-scene/patch.ts +131 -0
  419. package/src/spatial-scene/time.ts +53 -0
  420. package/src/studio/contracts.ts +129 -0
  421. package/src/studio/index.ts +2 -0
  422. package/src/studio/plan.ts +156 -0
  423. package/src/studio/shared.ts +41 -0
  424. package/src/theme.ts +68 -0
  425. package/src/tldr.ts +474 -0
  426. package/src/types.ts +212 -0
  427. package/src/vectorize/archive.ts +101 -0
  428. package/src/vectorize/command.ts +797 -0
  429. package/src/vectorize/index.ts +27 -0
  430. package/src/vectorize/limits.ts +61 -0
  431. package/src/vectorize/metrics.ts +351 -0
  432. package/src/vectorize/pixels.ts +323 -0
  433. package/src/vectorize/supervisor.ts +339 -0
  434. package/src/vectorize/svg.ts +188 -0
  435. package/src/vectorize/tool.ts +542 -0
  436. package/src/vectorize/types.ts +113 -0
  437. package/src/vectorize/vectorize.ts +634 -0
  438. package/src/vectorize/worker-protocol.ts +40 -0
  439. package/src/vectorize/worker.ts +209 -0
  440. package/src/version.ts +1 -0
  441. package/src/workflow.ts +409 -0
@@ -0,0 +1,2478 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants, type Stats } from "node:fs";
3
+ import {
4
+ link,
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ readdir,
9
+ realpath,
10
+ rename,
11
+ unlink,
12
+ } from "node:fs/promises";
13
+ import { hostname } from "node:os";
14
+ import { dirname, join, parse, resolve } from "node:path";
15
+ import { setTimeout as delay } from "node:timers/promises";
16
+
17
+ import type { z } from "zod";
18
+ import { z as schema } from "zod";
19
+
20
+ import { ApplicationError } from "../application/errors";
21
+ import type { OperationDiscovery } from "../application/registry";
22
+ import {
23
+ canonicalJson,
24
+ } from "../core/canonical-json";
25
+ import {
26
+ CancellationRequestSchema,
27
+ CreateRunRecordSchema,
28
+ NODE_EXECUTION_PLAN_VERSION,
29
+ NODE_PREPARATION_PLAN_VERSION,
30
+ RUN_EVENT_VERSION,
31
+ RUN_FENCE_VERSION,
32
+ RUN_GRANT_VERSION,
33
+ RUN_NODE_VERSION,
34
+ RUN_OUTPUTS_VERSION,
35
+ RUN_STORE_VERSION,
36
+ RunEventSchema,
37
+ RunFenceSchema,
38
+ RunGrantSchema,
39
+ RunNodeRecordSchema,
40
+ RunOutputsSchema,
41
+ RunRuntimeRecordSchema,
42
+ RunSummarySchema,
43
+ RunWorkflowRecordSchema,
44
+ createNodeExecutionPlanHash,
45
+ createNodeInputHash,
46
+ createNodePreparationPlanHash,
47
+ createRunNodeOutputDigest,
48
+ createRunOutputsDigest,
49
+ nodeRecordFilename,
50
+ type CancellationRequest,
51
+ type CreateRunRecord,
52
+ type NewRunEvent,
53
+ type NewRunGrant,
54
+ type RunEvent,
55
+ type RunFence,
56
+ type RunGrant,
57
+ type RunNodeRecord,
58
+ type RunOutputs,
59
+ type RunRuntimeRecord,
60
+ type RunSummary,
61
+ type RunWorkflowRecord,
62
+ } from "./run-contracts";
63
+ import {
64
+ AuthoredWorkflowGraphV1Schema,
65
+ NodeKeySchema,
66
+ Sha256Schema,
67
+ WorkflowNodePolicySchema,
68
+ isComputeGraphNode,
69
+ isOperationGraphNode,
70
+ trustedComputePolicy,
71
+ type AuthoredGraphNodeV1,
72
+ type GraphPlanV1,
73
+ type JsonValue,
74
+ type WorkflowNodePolicy,
75
+ type WorkflowOutputBinding,
76
+ } from "./contracts";
77
+ import { parseGraphPlan } from "./compiler";
78
+ import { projectReferenceValue } from "./reference-projection";
79
+
80
+ const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
81
+ const MAX_STRUCTURED_FILE_BYTES = 32 * 1024 * 1024;
82
+ const MAX_JOURNAL_BYTES = 32 * 1024 * 1024;
83
+ const MAX_JOURNAL_ENTRY_BYTES = 256 * 1024;
84
+ const MAX_JOURNAL_ENTRIES = 100_000;
85
+ const PHYSICAL_LOCK_WAIT_MS = 30_000;
86
+ const PHYSICAL_LOCK_RETRY_MS = 5;
87
+ const MAX_CACHED_CLAIM_SESSIONS = 16;
88
+ const MAX_CACHED_CLAIM_SESSION_BYTES = 64 * 1024 * 1024;
89
+ const MAX_CACHED_CLAIM_SESSION_TOTAL_BYTES = 128 * 1024 * 1024;
90
+ const CACHED_CLAIM_SESSION_FIXED_BYTES = 512;
91
+ const CACHED_IDENTITY_FIXED_BYTES = 256;
92
+ const CACHED_IDENTITY_SNAPSHOT_MAP_BYTES = 256;
93
+ const CACHED_IDENTITY_SNAPSHOT_BYTES = 256;
94
+ const CACHED_JOURNAL_FIXED_BYTES = 256;
95
+ const CACHED_FILE_SNAPSHOT_BYTES = 256;
96
+ const RETAINED_JSON_VALUE_BYTES = 64;
97
+ const RETAINED_GRANT_ID_BYTES = 128;
98
+
99
+ const CLAIM_FILE = ".claim.json";
100
+ const CLAIM_GENERATION_FILE = ".claim-generation.json";
101
+ const INITIALIZED_FILE = ".initialized.json";
102
+ const PHYSICAL_LOCK_FILE = ".run-store-lock.json";
103
+
104
+ interface ClaimSnapshot {
105
+ readonly dev: number;
106
+ readonly ino: number;
107
+ readonly mode: number;
108
+ readonly size: number;
109
+ readonly uid: number;
110
+ }
111
+
112
+ interface FileSnapshot extends ClaimSnapshot {
113
+ readonly birthtimeMs: number;
114
+ readonly blksize: number;
115
+ readonly blocks: number;
116
+ readonly ctimeMs: number;
117
+ readonly gid: number;
118
+ readonly mtimeMs: number;
119
+ readonly nlink: number;
120
+ readonly rdev: number;
121
+ }
122
+
123
+ interface ExistingClaim {
124
+ readonly fence: RunFence;
125
+ readonly snapshot: ClaimSnapshot;
126
+ }
127
+
128
+ interface ValidatedRunIdentity {
129
+ readonly bundleBytes: Uint8Array;
130
+ readonly directory: string;
131
+ readonly graphPlan: GraphPlanV1;
132
+ readonly runtime: RunRuntimeRecord;
133
+ readonly workflow: RunWorkflowRecord;
134
+ }
135
+
136
+ type ValidatedRunMetadata = Omit<ValidatedRunIdentity, "bundleBytes">;
137
+
138
+ interface CachedRunIdentity {
139
+ readonly identity: ValidatedRunMetadata;
140
+ readonly retainedBytes: number;
141
+ readonly sha256ByPath: ReadonlyMap<string, string>;
142
+ readonly snapshots: ReadonlyMap<string, FileSnapshot>;
143
+ }
144
+
145
+ interface JournalAppendState<Cursor> {
146
+ readonly cursor: Cursor;
147
+ readonly entryCount: number;
148
+ snapshot: FileSnapshot;
149
+ }
150
+
151
+ interface EventJournalCursor {
152
+ readonly lastFenceGeneration: number;
153
+ readonly lastSequence: number;
154
+ }
155
+
156
+ interface GrantJournalCursor {
157
+ readonly grantIds: Set<string>;
158
+ }
159
+
160
+ interface CachedClaimSession {
161
+ readonly eventJournal?: JournalAppendState<EventJournalCursor>;
162
+ readonly fenceGeneration: number;
163
+ readonly fenceToken: string;
164
+ readonly grantJournal?: JournalAppendState<GrantJournalCursor>;
165
+ readonly identity?: CachedRunIdentity;
166
+ readonly retainedBytes: number;
167
+ }
168
+
169
+ export interface AcquireRunClaimOptions {
170
+ readonly now?: () => Date;
171
+ readonly owner: string;
172
+ readonly processAlive?: (pid: number) => boolean;
173
+ readonly staleAfterMs?: number;
174
+ }
175
+
176
+ export interface RunStoreOptions {
177
+ /** Allows a smaller deterministic test budget, but cannot raise the production cap. */
178
+ readonly claimSessionCacheBudgetBytes?: number;
179
+ readonly root: string;
180
+ }
181
+
182
+ const InitializedRecordSchema = RunSummarySchema.pick({
183
+ graphPlanSha256: true,
184
+ runId: true,
185
+ version: true,
186
+ });
187
+
188
+ const ClaimGenerationRecordSchema = RunRuntimeRecordSchema.pick({ version: true }).extend({
189
+ generation: RunFenceSchema.shape.generation,
190
+ });
191
+
192
+ const PhysicalLockRecordSchema = schema.strictObject({
193
+ acquiredAt: schema.string().datetime({ offset: true }),
194
+ hostname: schema.string().min(1).max(255),
195
+ pid: schema.number().int().positive().safe(),
196
+ token: schema.string().uuid(),
197
+ version: schema.literal(RUN_STORE_VERSION),
198
+ });
199
+
200
+ const TERMINAL_NODE_STATUSES = new Set<RunNodeRecord["status"]>([
201
+ "ambiguous",
202
+ "cancelled",
203
+ "completed",
204
+ "failed",
205
+ "incompatible",
206
+ "skipped",
207
+ ]);
208
+
209
+ const TERMINAL_RUN_STATUSES = new Set<RunSummary["status"]>([
210
+ "cancelled",
211
+ "completed",
212
+ "failed",
213
+ "incompatible",
214
+ "partial",
215
+ ]);
216
+
217
+ const FORWARD_NODE_TRANSITIONS: Readonly<
218
+ Record<RunNodeRecord["status"], ReadonlySet<RunNodeRecord["status"]>>
219
+ > = {
220
+ "ambiguous": new Set(),
221
+ "ambiguous-code": new Set(["cancelled", "incompatible"]),
222
+ "approval-required": new Set(["approval-required", "cancelled", "preparing", "ready"]),
223
+ "cancelled": new Set(),
224
+ "completed": new Set(),
225
+ "failed": new Set(),
226
+ "incompatible": new Set(),
227
+ "pending": new Set(["cancelled", "ready", "skipped"]),
228
+ "preparing": new Set([
229
+ "approval-required",
230
+ "cancelled",
231
+ "failed",
232
+ "incompatible",
233
+ "ready",
234
+ ]),
235
+ "ready": new Set([
236
+ "approval-required",
237
+ "cancelled",
238
+ "failed",
239
+ "incompatible",
240
+ "preparing",
241
+ "ready",
242
+ "running",
243
+ ]),
244
+ "running": new Set([
245
+ "ambiguous",
246
+ "ambiguous-code",
247
+ "cancelled",
248
+ "completed",
249
+ "failed",
250
+ "incompatible",
251
+ "ready",
252
+ ]),
253
+ "skipped": new Set(),
254
+ };
255
+
256
+ function errno(error: unknown, code: string): boolean {
257
+ return error instanceof Error && "code" in error && error.code === code;
258
+ }
259
+
260
+ function currentUid(): number | undefined {
261
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
262
+ }
263
+
264
+ function privateFile(details: Stats): boolean {
265
+ const expectedUid = currentUid();
266
+ return details.isFile()
267
+ && !details.isSymbolicLink()
268
+ && (details.mode & 0o777) === 0o600
269
+ && (expectedUid === undefined || details.uid === expectedUid);
270
+ }
271
+
272
+ function privateDirectory(details: Stats): boolean {
273
+ const expectedUid = currentUid();
274
+ return details.isDirectory()
275
+ && !details.isSymbolicLink()
276
+ && (details.mode & 0o777) === 0o700
277
+ && (expectedUid === undefined || details.uid === expectedUid);
278
+ }
279
+
280
+ function snapshot(details: Stats): ClaimSnapshot {
281
+ return {
282
+ dev: details.dev,
283
+ ino: details.ino,
284
+ mode: details.mode,
285
+ size: details.size,
286
+ uid: details.uid,
287
+ };
288
+ }
289
+
290
+ function fileSnapshot(details: Stats): FileSnapshot {
291
+ return {
292
+ ...snapshot(details),
293
+ birthtimeMs: details.birthtimeMs,
294
+ blksize: details.blksize,
295
+ blocks: details.blocks,
296
+ ctimeMs: details.ctimeMs,
297
+ gid: details.gid,
298
+ mtimeMs: details.mtimeMs,
299
+ nlink: details.nlink,
300
+ rdev: details.rdev,
301
+ };
302
+ }
303
+
304
+ function sameSnapshot(left: ClaimSnapshot, right: ClaimSnapshot): boolean {
305
+ return left.dev === right.dev
306
+ && left.ino === right.ino
307
+ && left.mode === right.mode
308
+ && left.size === right.size
309
+ && left.uid === right.uid;
310
+ }
311
+
312
+ function sameFileSnapshot(left: FileSnapshot, right: FileSnapshot): boolean {
313
+ return sameFileSnapshotExceptCtime(left, right) && left.ctimeMs === right.ctimeMs;
314
+ }
315
+
316
+ function sameFileSnapshotExceptCtime(left: FileSnapshot, right: FileSnapshot): boolean {
317
+ return sameSnapshot(left, right)
318
+ && left.birthtimeMs === right.birthtimeMs
319
+ && left.blksize === right.blksize
320
+ && left.blocks === right.blocks
321
+ && left.gid === right.gid
322
+ && left.mtimeMs === right.mtimeMs
323
+ && left.nlink === right.nlink
324
+ && left.rdev === right.rdev;
325
+ }
326
+
327
+ function samePhysicalFile(left: ClaimSnapshot, right: ClaimSnapshot): boolean {
328
+ return left.dev === right.dev
329
+ && left.ino === right.ino
330
+ && left.mode === right.mode
331
+ && left.uid === right.uid;
332
+ }
333
+
334
+ function localProcessAlive(pid: number): boolean {
335
+ try {
336
+ process.kill(pid, 0);
337
+ return true;
338
+ } catch (error) {
339
+ return !errno(error, "ESRCH");
340
+ }
341
+ }
342
+
343
+ function equalCanonical(left: unknown, right: unknown): boolean {
344
+ return canonicalJson(left) === canonicalJson(right);
345
+ }
346
+
347
+ function addRetainedBytes(...values: readonly number[]): number {
348
+ let total = 0;
349
+ for (const value of values) {
350
+ if (total > Number.MAX_SAFE_INTEGER - value) return Number.MAX_SAFE_INTEGER;
351
+ total += value;
352
+ }
353
+ return total;
354
+ }
355
+
356
+ function multiplyRetainedBytes(value: number, multiplier: number): number {
357
+ if (value > Number.MAX_SAFE_INTEGER / multiplier) return Number.MAX_SAFE_INTEGER;
358
+ return value * multiplier;
359
+ }
360
+
361
+ function retainedUtf8Bytes(value: string): number {
362
+ return multiplyRetainedBytes(new TextEncoder().encode(value).byteLength, 2);
363
+ }
364
+
365
+ function retainedJsonValueCount(root: unknown): number {
366
+ const pending: unknown[] = [root];
367
+ const seen = new WeakSet<object>();
368
+ let count = 0;
369
+ while (pending.length > 0) {
370
+ const value = pending.pop();
371
+ if (typeof value === "object" && value !== null) {
372
+ if (seen.has(value)) continue;
373
+ seen.add(value);
374
+ }
375
+ count = addRetainedBytes(count, 1);
376
+ if (Array.isArray(value)) {
377
+ for (const item of value) pending.push(item);
378
+ } else if (typeof value === "object" && value !== null) {
379
+ for (const item of Object.values(value)) pending.push(item);
380
+ }
381
+ }
382
+ return count;
383
+ }
384
+
385
+ function cachedIdentityRetainedBytes(
386
+ identity: ValidatedRunMetadata,
387
+ snapshots: ReadonlyMap<string, FileSnapshot>,
388
+ ): number {
389
+ let retainedBytes = addRetainedBytes(
390
+ CACHED_IDENTITY_FIXED_BYTES,
391
+ CACHED_IDENTITY_SNAPSHOT_MAP_BYTES,
392
+ CACHED_IDENTITY_SNAPSHOT_MAP_BYTES,
393
+ retainedUtf8Bytes(identity.directory),
394
+ );
395
+ for (const path of snapshots.keys()) {
396
+ retainedBytes = addRetainedBytes(
397
+ retainedBytes,
398
+ CACHED_IDENTITY_SNAPSHOT_BYTES,
399
+ CACHED_IDENTITY_SNAPSHOT_BYTES,
400
+ retainedUtf8Bytes(path),
401
+ retainedUtf8Bytes(path),
402
+ 128, // The second map retains one 64-character physical SHA-256 per file.
403
+ );
404
+ }
405
+ for (const [filename, value] of [
406
+ ["graph-plan.json", identity.graphPlan],
407
+ ["runtime.json", identity.runtime],
408
+ ["workflow.json", identity.workflow],
409
+ ] as const) {
410
+ const path = join(identity.directory, filename);
411
+ const retainedSnapshot = snapshots.get(path);
412
+ if (retainedSnapshot === undefined) {
413
+ throw new ApplicationError("internal", `Run identity snapshot is missing: ${path}`);
414
+ }
415
+ retainedBytes = addRetainedBytes(
416
+ retainedBytes,
417
+ multiplyRetainedBytes(retainedSnapshot.size, 2),
418
+ multiplyRetainedBytes(retainedJsonValueCount(value), RETAINED_JSON_VALUE_BYTES),
419
+ );
420
+ }
421
+ return retainedBytes;
422
+ }
423
+
424
+ function cachedClaimSessionRetainedBytes(
425
+ runId: string,
426
+ session: Omit<CachedClaimSession, "retainedBytes">,
427
+ ): number {
428
+ let retainedBytes = addRetainedBytes(
429
+ CACHED_CLAIM_SESSION_FIXED_BYTES,
430
+ retainedUtf8Bytes(runId),
431
+ retainedUtf8Bytes(session.fenceToken),
432
+ session.identity?.retainedBytes ?? 0,
433
+ );
434
+ if (session.eventJournal !== undefined) {
435
+ retainedBytes = addRetainedBytes(
436
+ retainedBytes,
437
+ CACHED_JOURNAL_FIXED_BYTES,
438
+ CACHED_FILE_SNAPSHOT_BYTES,
439
+ );
440
+ }
441
+ if (session.grantJournal !== undefined) {
442
+ retainedBytes = addRetainedBytes(
443
+ retainedBytes,
444
+ CACHED_JOURNAL_FIXED_BYTES,
445
+ CACHED_FILE_SNAPSHOT_BYTES,
446
+ multiplyRetainedBytes(
447
+ session.grantJournal.cursor.grantIds.size,
448
+ RETAINED_GRANT_ID_BYTES,
449
+ ),
450
+ );
451
+ }
452
+ return retainedBytes;
453
+ }
454
+
455
+ function sortedOperationIdentities(
456
+ graphPlan: GraphPlanV1,
457
+ ): RunRuntimeRecord["operations"] {
458
+ const identities = new Map<string, RunRuntimeRecord["operations"][number]>();
459
+ for (const node of graphPlan.graph.nodes) {
460
+ if (!isOperationGraphNode(node)) continue;
461
+ const operation = node.executor.operation;
462
+ const key = `${operation.kind}@${String(operation.version)}`;
463
+ identities.set(key, operation);
464
+ }
465
+ return [...identities.entries()]
466
+ .sort(([left], [right]) => left.localeCompare(right))
467
+ .map(([, identity]) => identity);
468
+ }
469
+
470
+ function sortedComputeIdentities(
471
+ graphPlan: GraphPlanV1,
472
+ ): RunRuntimeRecord["computes"] {
473
+ const identities = new Map<string, RunRuntimeRecord["computes"][number]>();
474
+ for (const node of graphPlan.graph.nodes) {
475
+ if (!isComputeGraphNode(node)) continue;
476
+ identities.set(node.executor.compute.key, node.executor.compute);
477
+ }
478
+ return [...identities.entries()]
479
+ .sort(([left], [right]) => left.localeCompare(right))
480
+ .map(([, identity]) => identity);
481
+ }
482
+
483
+ function normalizedPolicy(policy: WorkflowNodePolicy): WorkflowNodePolicy {
484
+ return WorkflowNodePolicySchema.parse({
485
+ ...policy,
486
+ preparation: [...policy.preparation].sort((left, right) => left.localeCompare(right)),
487
+ resources: [...policy.resources].sort((left, right) => (
488
+ left.resource.localeCompare(right.resource)
489
+ )),
490
+ });
491
+ }
492
+
493
+ function operationDiscovery(
494
+ graphPlan: GraphPlanV1,
495
+ node: AuthoredGraphNodeV1,
496
+ ): OperationDiscovery {
497
+ if (!isOperationGraphNode(node)) {
498
+ throw new ApplicationError(
499
+ "invalid-data",
500
+ `Compute node ${node.key} has no application operation discovery.`,
501
+ );
502
+ }
503
+ const discovery = graphPlan.registry.discovery.find(item => (
504
+ item.kind === node.executor.operation.kind
505
+ && item.version === node.executor.operation.version
506
+ ));
507
+ if (discovery === undefined) {
508
+ throw new ApplicationError(
509
+ "invalid-data",
510
+ `Graph plan has no registered identity for node ${node.key}.`,
511
+ );
512
+ }
513
+ return discovery;
514
+ }
515
+
516
+ function nodePolicy(
517
+ graphPlan: GraphPlanV1,
518
+ node: AuthoredGraphNodeV1,
519
+ ): WorkflowNodePolicy {
520
+ if (isOperationGraphNode(node)) {
521
+ return operationDiscovery(graphPlan, node).policy;
522
+ }
523
+ if (isComputeGraphNode(node)) {
524
+ return trustedComputePolicy(node.executor.compute);
525
+ }
526
+ throw new ApplicationError("internal", `Unknown node executor for ${node.key}.`);
527
+ }
528
+
529
+ async function syncDirectory(path: string): Promise<void> {
530
+ const handle = await open(path, constants.O_NOFOLLOW | constants.O_RDONLY);
531
+ try {
532
+ await handle.sync();
533
+ } finally {
534
+ await handle.close();
535
+ }
536
+ }
537
+
538
+ async function writeNoReplace(path: string, value: string | Uint8Array): Promise<void> {
539
+ const handle = await open(
540
+ path,
541
+ constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_WRONLY,
542
+ 0o600,
543
+ );
544
+ try {
545
+ await handle.writeFile(value);
546
+ await handle.sync();
547
+ } finally {
548
+ await handle.close();
549
+ }
550
+ }
551
+
552
+ async function writeJsonNoReplace(path: string, value: unknown): Promise<void> {
553
+ await writeNoReplace(path, `${canonicalJson(value)}\n`);
554
+ }
555
+
556
+ async function publishNoReplace(path: string, value: string | Uint8Array): Promise<void> {
557
+ const directory = dirname(path);
558
+ const temporary = join(directory, `.publish-${randomUUID()}`);
559
+ await writeNoReplace(temporary, value);
560
+ try {
561
+ await link(temporary, path);
562
+ await syncDirectory(directory);
563
+ } finally {
564
+ await unlink(temporary).catch(() => undefined);
565
+ }
566
+ }
567
+
568
+ async function publishJsonNoReplace(path: string, value: unknown): Promise<void> {
569
+ await publishNoReplace(path, `${canonicalJson(value)}\n`);
570
+ }
571
+
572
+ async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
573
+ const temporary = join(dirname(path), `.replace-${randomUUID()}`);
574
+ await writeJsonNoReplace(temporary, value);
575
+ try {
576
+ await rename(temporary, path);
577
+ await syncDirectory(dirname(path));
578
+ } catch (error) {
579
+ await unlink(temporary).catch(() => undefined);
580
+ throw error;
581
+ }
582
+ }
583
+
584
+ async function readBoundedPhysicalBytes(
585
+ path: string,
586
+ maximumBytes = MAX_STRUCTURED_FILE_BYTES,
587
+ ): Promise<Uint8Array> {
588
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0 || maximumBytes > MAX_STRUCTURED_FILE_BYTES) {
589
+ throw new ApplicationError("invalid-data", "Run artifact byte bound is invalid.");
590
+ }
591
+ let pathDetails: Stats;
592
+ try {
593
+ pathDetails = await lstat(path);
594
+ } catch (error) {
595
+ if (errno(error, "ENOENT")) {
596
+ throw new ApplicationError("not-found", `Run artifact does not exist: ${path}`);
597
+ }
598
+ throw error;
599
+ }
600
+ if (
601
+ !privateFile(pathDetails)
602
+ || !Number.isSafeInteger(pathDetails.size)
603
+ || pathDetails.size < 0
604
+ || pathDetails.size > maximumBytes
605
+ ) {
606
+ throw new ApplicationError(
607
+ "unsafe-path",
608
+ `Run artifact must be a bounded private physical file: ${path}`,
609
+ );
610
+ }
611
+ const handle = await open(path, constants.O_NOFOLLOW | constants.O_RDONLY | constants.O_NONBLOCK);
612
+ try {
613
+ const lexical = fileSnapshot(pathDetails);
614
+ let priorSnapshot: FileSnapshot | undefined;
615
+ let priorDigest: string | undefined;
616
+ // Consume at most one extra byte so concurrent growth cannot turn a
617
+ // bounded read into an unbounded one. Both passes reuse this one buffer.
618
+ const buffer = Buffer.alloc(pathDetails.size + 1);
619
+ for (const attempt of [1, 2] as const) {
620
+ const before = fileSnapshot(await handle.stat());
621
+ if (!sameFileSnapshotExceptCtime(lexical, before)
622
+ || (priorSnapshot !== undefined && !sameFileSnapshot(priorSnapshot, before))) {
623
+ throw new ApplicationError("conflict", `Run artifact changed before read: ${path}`);
624
+ }
625
+ let byteLength = 0;
626
+ while (byteLength < buffer.byteLength) {
627
+ const { bytesRead } = await handle.read(
628
+ buffer, byteLength, buffer.byteLength - byteLength, byteLength,
629
+ );
630
+ if (bytesRead === 0) break;
631
+ byteLength += bytesRead;
632
+ }
633
+ const after = fileSnapshot(await handle.stat());
634
+ const finalPath = fileSnapshot(await lstat(path));
635
+ const digest = createHash("sha256").update(buffer.subarray(0, byteLength)).digest("hex");
636
+ if (!sameFileSnapshotExceptCtime(before, after)
637
+ || !sameFileSnapshotExceptCtime(after, finalPath)
638
+ || byteLength !== before.size
639
+ || (priorDigest !== undefined && priorDigest !== digest)) {
640
+ throw new ApplicationError("conflict", `Run artifact changed during read: ${path}`);
641
+ }
642
+ const stable = sameFileSnapshot(before, after) && sameFileSnapshot(after, finalPath);
643
+ if (stable && (attempt === 2 || sameFileSnapshot(lexical, before))) {
644
+ return buffer.subarray(0, byteLength);
645
+ }
646
+ if (attempt === 2) {
647
+ throw new ApplicationError("conflict", `Run artifact changed during read: ${path}`);
648
+ }
649
+ // A ctime transition alone does not establish coherent bytes. One second
650
+ // pinned read must reproduce this digest with an unchanged snapshot,
651
+ // including the bridge from this final path observation to its FD stat.
652
+ priorSnapshot = finalPath;
653
+ priorDigest = digest;
654
+ }
655
+ throw new ApplicationError("conflict", `Run artifact changed during read: ${path}`);
656
+ } finally {
657
+ await handle.close();
658
+ }
659
+ }
660
+
661
+ function decodeUtf8(bytes: Uint8Array, path: string): string {
662
+ try {
663
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
664
+ } catch (error) {
665
+ throw new ApplicationError("invalid-data", `Run artifact is not UTF-8 (${path}): ${String(error)}`);
666
+ }
667
+ }
668
+
669
+ async function readBoundedPhysical(path: string, digests?: Map<string, string>): Promise<string> {
670
+ const bytes = await readBoundedPhysicalBytes(path);
671
+ digests?.set(path, createHash("sha256").update(bytes).digest("hex"));
672
+ return decodeUtf8(bytes, path);
673
+ }
674
+
675
+ async function readJsonUnknown(path: string, digests?: Map<string, string>): Promise<unknown> {
676
+ try {
677
+ return JSON.parse(await readBoundedPhysical(path, digests)) as unknown;
678
+ } catch (error) {
679
+ if (error instanceof ApplicationError) throw error;
680
+ throw new ApplicationError("invalid-data", `Run artifact is not JSON: ${path}`);
681
+ }
682
+ }
683
+
684
+ async function readJson<Schema extends z.ZodType>(
685
+ path: string,
686
+ validator: Schema,
687
+ digests?: Map<string, string>,
688
+ ): Promise<z.infer<Schema>> {
689
+ try {
690
+ return validator.parse(await readJsonUnknown(path, digests));
691
+ } catch (error) {
692
+ if (error instanceof ApplicationError) throw error;
693
+ throw new ApplicationError("invalid-data", `Run artifact failed validation (${path}): ${String(error)}`);
694
+ }
695
+ }
696
+
697
+ function completeJournalPrefix(bytes: Uint8Array): Uint8Array {
698
+ const lastNewline = bytes.lastIndexOf(0x0a);
699
+ return lastNewline < 0 ? bytes.subarray(0, 0) : bytes.subarray(0, lastNewline + 1);
700
+ }
701
+
702
+ function parseJournalBytes<Schema extends z.ZodType>(
703
+ bytes: Uint8Array,
704
+ path: string,
705
+ validator: Schema,
706
+ ): readonly z.infer<Schema>[] {
707
+ const complete = completeJournalPrefix(bytes);
708
+ if (complete.byteLength === 0) return Object.freeze([]);
709
+ const lines = decodeUtf8(complete, path).split("\n");
710
+ lines.pop();
711
+ if (lines.length > MAX_JOURNAL_ENTRIES) {
712
+ throw new ApplicationError(
713
+ "invalid-data",
714
+ `Run journal exceeds ${String(MAX_JOURNAL_ENTRIES)} entries: ${path}`,
715
+ );
716
+ }
717
+ return Object.freeze(lines.map((line, index) => {
718
+ try {
719
+ return validator.parse(JSON.parse(line) as unknown);
720
+ } catch (error) {
721
+ throw new ApplicationError(
722
+ "invalid-data",
723
+ `Invalid run journal entry ${String(index + 1)} (${path}): ${String(error)}`,
724
+ );
725
+ }
726
+ }));
727
+ }
728
+
729
+ async function readJsonLines<Schema extends z.ZodType>(
730
+ path: string,
731
+ validator: Schema,
732
+ ): Promise<readonly z.infer<Schema>[]> {
733
+ return parseJournalBytes(
734
+ await readBoundedPhysicalBytes(path, MAX_JOURNAL_BYTES),
735
+ path,
736
+ validator,
737
+ );
738
+ }
739
+
740
+ async function appendJournalEntry<Schema extends z.ZodType, Result, Cursor>(
741
+ path: string,
742
+ validator: Schema,
743
+ validate: (entries: readonly z.infer<Schema>[]) => Cursor,
744
+ create: (context: {
745
+ readonly cursor: Cursor;
746
+ readonly entryCount: number;
747
+ }) => {
748
+ readonly entry: z.infer<Schema>;
749
+ readonly result: Result;
750
+ },
751
+ advance: (cursor: Cursor, entry: z.infer<Schema>) => Cursor,
752
+ cached?: JournalAppendState<Cursor>,
753
+ ): Promise<{
754
+ readonly result: Result;
755
+ readonly state: JournalAppendState<Cursor>;
756
+ }> {
757
+ const details = await lstat(path);
758
+ if (!privateFile(details) || details.size > MAX_JOURNAL_BYTES) {
759
+ throw new ApplicationError(
760
+ "unsafe-path",
761
+ `Run journal must be a bounded private physical file: ${path}`,
762
+ );
763
+ }
764
+ const handle = await open(path, constants.O_NOFOLLOW | constants.O_RDWR);
765
+ try {
766
+ const opened = await handle.stat();
767
+ if (!sameSnapshot(snapshot(details), snapshot(opened))) {
768
+ throw new ApplicationError("conflict", `Run journal changed before append: ${path}`);
769
+ }
770
+ const mayReuse = cached !== undefined
771
+ && sameFileSnapshot(cached.snapshot, fileSnapshot(details))
772
+ && sameFileSnapshot(cached.snapshot, fileSnapshot(opened));
773
+ let completeBytes: number;
774
+ let cursor: Cursor;
775
+ let entryCount: number;
776
+ if (mayReuse) {
777
+ completeBytes = opened.size;
778
+ cursor = cached.cursor;
779
+ entryCount = cached.entryCount;
780
+ } else {
781
+ const bytes = new Uint8Array(await handle.readFile());
782
+ if (bytes.byteLength > MAX_JOURNAL_BYTES) {
783
+ throw new ApplicationError("invalid-data", `Run journal exceeds its byte limit: ${path}`);
784
+ }
785
+ const complete = completeJournalPrefix(bytes);
786
+ const entries = parseJournalBytes(bytes, path, validator);
787
+ cursor = validate(entries);
788
+ entryCount = entries.length;
789
+ completeBytes = complete.byteLength;
790
+ if (bytes.byteLength !== completeBytes) {
791
+ await handle.truncate(completeBytes);
792
+ await handle.sync();
793
+ }
794
+ }
795
+ if (entryCount >= MAX_JOURNAL_ENTRIES) {
796
+ throw new ApplicationError(
797
+ "conflict",
798
+ `Run journal already contains ${String(MAX_JOURNAL_ENTRIES)} entries.`,
799
+ );
800
+ }
801
+ const created = create({ cursor, entryCount });
802
+ const entry = validator.parse(created.entry);
803
+ const encoded = new TextEncoder().encode(`${canonicalJson(entry)}\n`);
804
+ if (encoded.byteLength > MAX_JOURNAL_ENTRY_BYTES) {
805
+ throw new ApplicationError("invalid-data", "Run journal entry exceeds its byte limit.");
806
+ }
807
+ if (completeBytes + encoded.byteLength > MAX_JOURNAL_BYTES) {
808
+ throw new ApplicationError("conflict", "Run journal has reached its byte limit.");
809
+ }
810
+ let written = 0;
811
+ while (written < encoded.byteLength) {
812
+ const result = await handle.write(
813
+ encoded,
814
+ written,
815
+ encoded.byteLength - written,
816
+ completeBytes + written,
817
+ );
818
+ if (result.bytesWritten < 1) {
819
+ throw new ApplicationError("unavailable", `Run journal append made no progress: ${path}`);
820
+ }
821
+ written += result.bytesWritten;
822
+ }
823
+ await handle.sync();
824
+ const persisted = await handle.stat();
825
+ if (
826
+ !samePhysicalFile(snapshot(opened), snapshot(persisted))
827
+ || persisted.size !== completeBytes + encoded.byteLength
828
+ ) {
829
+ throw new ApplicationError("conflict", `Run journal changed during append: ${path}`);
830
+ }
831
+ return {
832
+ result: created.result,
833
+ state: {
834
+ cursor: advance(cursor, entry),
835
+ entryCount: entryCount + 1,
836
+ snapshot: fileSnapshot(persisted),
837
+ },
838
+ };
839
+ } finally {
840
+ await handle.close();
841
+ }
842
+ }
843
+
844
+ function validateEventSequence(
845
+ events: readonly RunEvent[],
846
+ runId: string,
847
+ ): EventJournalCursor {
848
+ let previousGeneration = 0;
849
+ for (const [index, event] of events.entries()) {
850
+ if (event.runId !== runId || event.sequence !== index + 1) {
851
+ throw new ApplicationError("invalid-data", `Run event sequence is inconsistent for ${runId}.`);
852
+ }
853
+ if (event.fenceGeneration < previousGeneration) {
854
+ throw new ApplicationError("invalid-data", `Run event fence generation regressed for ${runId}.`);
855
+ }
856
+ previousGeneration = event.fenceGeneration;
857
+ }
858
+ return {
859
+ lastFenceGeneration: previousGeneration,
860
+ lastSequence: events.at(-1)?.sequence ?? 0,
861
+ };
862
+ }
863
+
864
+ function validateGrantLedger(
865
+ grants: readonly RunGrant[],
866
+ runId: string,
867
+ graphPlanSha256: string,
868
+ ): GrantJournalCursor {
869
+ const ids = new Set<string>();
870
+ for (const grant of grants) {
871
+ if (grant.runId !== runId || grant.graphPlanSha256 !== graphPlanSha256) {
872
+ throw new ApplicationError("invalid-data", `Run grant identity is inconsistent for ${runId}.`);
873
+ }
874
+ if (ids.has(grant.grantId)) {
875
+ throw new ApplicationError("invalid-data", `Run grant ID is duplicated: ${grant.grantId}`);
876
+ }
877
+ ids.add(grant.grantId);
878
+ }
879
+ return { grantIds: ids };
880
+ }
881
+
882
+ function countsFor(records: readonly RunNodeRecord[]): RunSummary["counts"] {
883
+ return {
884
+ cancelled: records.filter(record => record.status === "cancelled").length,
885
+ completed: records.filter(record => record.status === "completed").length,
886
+ failed: records.filter(record => (
887
+ record.status === "failed"
888
+ || record.status === "incompatible"
889
+ || record.status === "ambiguous"
890
+ )).length,
891
+ pending: records.filter(record => (
892
+ record.status === "pending"
893
+ || record.status === "ready"
894
+ || record.status === "preparing"
895
+ || record.status === "running"
896
+ || record.status === "approval-required"
897
+ || record.status === "ambiguous-code"
898
+ )).length,
899
+ skipped: records.filter(record => record.status === "skipped").length,
900
+ };
901
+ }
902
+
903
+ function isSerializedOutputReference(
904
+ value: WorkflowOutputBinding,
905
+ ): value is Extract<WorkflowOutputBinding, { readonly $ref: unknown }> {
906
+ return typeof value === "object"
907
+ && value !== null
908
+ && !Array.isArray(value)
909
+ && Object.hasOwn(value, "$ref");
910
+ }
911
+
912
+ function isWorkflowOutputArray(
913
+ value: WorkflowOutputBinding,
914
+ ): value is readonly WorkflowOutputBinding[] {
915
+ return Array.isArray(value);
916
+ }
917
+
918
+ function resolveOutputBinding(
919
+ binding: WorkflowOutputBinding,
920
+ recordsByKey: ReadonlyMap<string, RunNodeRecord>,
921
+ digests: Record<string, string>,
922
+ ): JsonValue | undefined {
923
+ if (isSerializedOutputReference(binding)) {
924
+ const record = recordsByKey.get(binding.$ref.nodeKey);
925
+ if (record?.status !== "completed" || record.output === undefined) return undefined;
926
+ digests[record.nodeKey] = record.output.digestSha256;
927
+ return projectReferenceValue(binding, record.output.value);
928
+ }
929
+ if (isWorkflowOutputArray(binding)) {
930
+ const output: JsonValue[] = [];
931
+ for (const item of binding) {
932
+ const resolved = resolveOutputBinding(item, recordsByKey, digests);
933
+ if (resolved === undefined) return undefined;
934
+ output.push(resolved);
935
+ }
936
+ return output;
937
+ }
938
+ const output: Record<string, JsonValue> = {};
939
+ for (const [key, item] of Object.entries(binding).sort(([left], [right]) => (
940
+ left.localeCompare(right)
941
+ ))) {
942
+ const resolved = resolveOutputBinding(item, recordsByKey, digests);
943
+ if (resolved === undefined) return undefined;
944
+ output[key] = resolved;
945
+ }
946
+ return output;
947
+ }
948
+
949
+ function derivedRunStatus(records: readonly RunNodeRecord[]): RunSummary["status"] {
950
+ if (records.some(record => record.status === "approval-required")) return "approval-required";
951
+ if (records.some(record => record.status === "ambiguous-code")) return "ambiguous-code";
952
+ if (records.some(record => (
953
+ record.status === "pending"
954
+ || record.status === "ready"
955
+ || record.status === "preparing"
956
+ || record.status === "running"
957
+ ))) return "running";
958
+ const counts = countsFor(records);
959
+ if (counts.cancelled > 0) return "cancelled";
960
+ if (counts.failed === 0 && counts.skipped === 0 && counts.completed === records.length) {
961
+ return "completed";
962
+ }
963
+ if (records.some(record => record.status === "incompatible") && counts.completed === 0) {
964
+ return "incompatible";
965
+ }
966
+ if (counts.completed > 0) return "partial";
967
+ return "failed";
968
+ }
969
+
970
+ export class RunStore {
971
+ // Another RunStore instance may release a claim without notifying this one,
972
+ // so claim-local acceleration is bounded independently of release.
973
+ readonly #cachedClaimSessions = new Map<string, CachedClaimSession>();
974
+ #cachedClaimSessionBytes = 0;
975
+ readonly #claimSessionCacheBudgetBytes: number;
976
+ readonly #root: string;
977
+
978
+ constructor(options: RunStoreOptions) {
979
+ const cacheBudgetBytes = options.claimSessionCacheBudgetBytes
980
+ ?? MAX_CACHED_CLAIM_SESSION_TOTAL_BYTES;
981
+ if (
982
+ !Number.isSafeInteger(cacheBudgetBytes)
983
+ || cacheBudgetBytes < 0
984
+ || cacheBudgetBytes > MAX_CACHED_CLAIM_SESSION_TOTAL_BYTES
985
+ ) {
986
+ throw new ApplicationError(
987
+ "usage",
988
+ `Run claim-session cache budget must be a safe integer from 0 through ${String(MAX_CACHED_CLAIM_SESSION_TOTAL_BYTES)}.`,
989
+ );
990
+ }
991
+ this.#claimSessionCacheBudgetBytes = cacheBudgetBytes;
992
+ this.#root = resolve(options.root);
993
+ }
994
+
995
+ async #physicalRoot(): Promise<string> {
996
+ const absolute = this.#root;
997
+ const parsed = parse(absolute);
998
+ const segments = absolute.slice(parsed.root.length).split("/").filter(Boolean);
999
+ let current = parsed.root;
1000
+ for (const segment of segments) {
1001
+ current = join(current, segment);
1002
+ let details: Stats;
1003
+ try {
1004
+ details = await lstat(current);
1005
+ } catch (error) {
1006
+ if (!errno(error, "ENOENT")) throw error;
1007
+ try {
1008
+ await mkdir(current, { mode: 0o700 });
1009
+ } catch (mkdirError) {
1010
+ if (!errno(mkdirError, "EEXIST")) throw mkdirError;
1011
+ }
1012
+ details = await lstat(current);
1013
+ }
1014
+ if (details.isSymbolicLink() || !details.isDirectory()) {
1015
+ throw new ApplicationError(
1016
+ "unsafe-path",
1017
+ `Run root crosses a non-physical directory component: ${current}`,
1018
+ );
1019
+ }
1020
+ }
1021
+ const rootDetails = await lstat(absolute);
1022
+ if (!privateDirectory(rootDetails)) {
1023
+ throw new ApplicationError(
1024
+ "unsafe-path",
1025
+ `Run root must be an owner-only physical directory (0700): ${absolute}`,
1026
+ );
1027
+ }
1028
+ if (await realpath(absolute) !== absolute) {
1029
+ throw new ApplicationError("unsafe-path", `Run root crosses a symbolic link: ${absolute}`);
1030
+ }
1031
+ return absolute;
1032
+ }
1033
+
1034
+ async #runDirectory(runId: string, mustExist = true): Promise<string> {
1035
+ const parsedRunId = RunFenceSchema.shape.runId.parse(runId);
1036
+ const root = await this.#physicalRoot();
1037
+ const directory = join(root, parsedRunId);
1038
+ if (mustExist) {
1039
+ const details = await lstat(directory).catch(error => {
1040
+ if (errno(error, "ENOENT")) {
1041
+ throw new ApplicationError("not-found", `Workflow run does not exist: ${parsedRunId}`);
1042
+ }
1043
+ throw error;
1044
+ });
1045
+ if (!privateDirectory(details) || await realpath(directory) !== directory) {
1046
+ throw new ApplicationError(
1047
+ "unsafe-path",
1048
+ `Workflow run must be an owner-only physical directory (0700): ${parsedRunId}`,
1049
+ );
1050
+ }
1051
+ }
1052
+ return directory;
1053
+ }
1054
+
1055
+ async #nodeDirectory(directory: string): Promise<string> {
1056
+ return await this.#privateChildDirectory(directory, "nodes");
1057
+ }
1058
+
1059
+ async #privateChildDirectory(directory: string, name: string): Promise<string> {
1060
+ const child = join(directory, name);
1061
+ const details = await lstat(child);
1062
+ if (!privateDirectory(details) || await realpath(child) !== child) {
1063
+ throw new ApplicationError(
1064
+ "unsafe-path",
1065
+ `Run ${name} storage must be a physical 0700 directory.`,
1066
+ );
1067
+ }
1068
+ return child;
1069
+ }
1070
+
1071
+ async #ensurePrivateChildDirectory(
1072
+ directory: string,
1073
+ name: string,
1074
+ ): Promise<string> {
1075
+ const child = join(directory, name);
1076
+ try {
1077
+ await mkdir(child, { mode: 0o700 });
1078
+ } catch (error) {
1079
+ if (!errno(error, "EEXIST")) throw error;
1080
+ }
1081
+ return await this.#privateChildDirectory(directory, name);
1082
+ }
1083
+
1084
+ async #withPhysicalLock<Value>(
1085
+ runId: string,
1086
+ work: (directory: string) => Promise<Value>,
1087
+ ): Promise<Value> {
1088
+ const directory = await this.#runDirectory(runId);
1089
+ const lockPath = join(directory, PHYSICAL_LOCK_FILE);
1090
+ const lock = PhysicalLockRecordSchema.parse({
1091
+ acquiredAt: new Date().toISOString(),
1092
+ hostname: hostname(),
1093
+ pid: process.pid,
1094
+ token: randomUUID(),
1095
+ version: RUN_STORE_VERSION,
1096
+ });
1097
+ const deadline = Date.now() + PHYSICAL_LOCK_WAIT_MS;
1098
+ let acquiredSnapshot: ClaimSnapshot | undefined;
1099
+ while (acquiredSnapshot === undefined) {
1100
+ try {
1101
+ await publishJsonNoReplace(lockPath, lock);
1102
+ acquiredSnapshot = snapshot(await lstat(lockPath));
1103
+ } catch (error) {
1104
+ if (!errno(error, "EEXIST")) throw error;
1105
+ let existing: z.infer<typeof PhysicalLockRecordSchema>;
1106
+ let details: Stats | undefined;
1107
+ try {
1108
+ details = await lstat(lockPath);
1109
+ existing = await readJson(lockPath, PhysicalLockRecordSchema);
1110
+ } catch (readError) {
1111
+ const current = await lstat(lockPath).catch(() => undefined);
1112
+ if (
1113
+ details === undefined
1114
+ ||
1115
+ current === undefined
1116
+ || !sameSnapshot(snapshot(details), snapshot(current))
1117
+ ) {
1118
+ if (Date.now() >= deadline) {
1119
+ throw new ApplicationError(
1120
+ "conflict",
1121
+ `Timed out waiting for run serialization: ${runId}`,
1122
+ );
1123
+ }
1124
+ await delay(PHYSICAL_LOCK_RETRY_MS);
1125
+ continue;
1126
+ }
1127
+ if (
1128
+ errno(readError, "ENOENT")
1129
+ || (
1130
+ readError instanceof ApplicationError
1131
+ && (readError.code === "not-found" || readError.code === "conflict")
1132
+ )
1133
+ ) {
1134
+ if (Date.now() >= deadline) {
1135
+ throw new ApplicationError(
1136
+ "conflict",
1137
+ `Timed out waiting for run serialization: ${runId}`,
1138
+ );
1139
+ }
1140
+ await delay(PHYSICAL_LOCK_RETRY_MS);
1141
+ continue;
1142
+ }
1143
+ throw new ApplicationError(
1144
+ "unsafe-path",
1145
+ `Run serialization lock is malformed: ${String(readError)}`,
1146
+ );
1147
+ }
1148
+ if (existing.hostname === hostname() && !localProcessAlive(existing.pid)) {
1149
+ const currentLock = await lstat(lockPath).catch(() => undefined);
1150
+ if (
1151
+ currentLock !== undefined
1152
+ && sameSnapshot(snapshot(details), snapshot(currentLock))
1153
+ ) {
1154
+ await unlink(lockPath);
1155
+ await syncDirectory(directory);
1156
+ continue;
1157
+ }
1158
+ }
1159
+ if (Date.now() >= deadline) {
1160
+ throw new ApplicationError("conflict", `Timed out waiting for run serialization: ${runId}`);
1161
+ }
1162
+ await delay(PHYSICAL_LOCK_RETRY_MS);
1163
+ }
1164
+ }
1165
+ try {
1166
+ return await work(directory);
1167
+ } finally {
1168
+ const current = await lstat(lockPath).catch(() => undefined);
1169
+ if (current !== undefined && sameSnapshot(acquiredSnapshot, snapshot(current))) {
1170
+ const persisted = await readJson(lockPath, PhysicalLockRecordSchema).catch(() => undefined);
1171
+ if (persisted?.token === lock.token) {
1172
+ await unlink(lockPath).catch(() => undefined);
1173
+ await syncDirectory(directory).catch(() => undefined);
1174
+ }
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ async #readIdentity(directory: string, runId: string, digests?: Map<string, string>): Promise<ValidatedRunIdentity> {
1180
+ await this.#privateChildDirectory(directory, "staging");
1181
+ let graphPlan: GraphPlanV1;
1182
+ try {
1183
+ graphPlan = parseGraphPlan(await readJsonUnknown(join(directory, "graph-plan.json"), digests));
1184
+ } catch (error) {
1185
+ if (error instanceof ApplicationError) throw error;
1186
+ throw new ApplicationError("invalid-data", `Persisted graph plan is invalid: ${String(error)}`);
1187
+ }
1188
+ const initialized = await readJson(join(directory, INITIALIZED_FILE), InitializedRecordSchema, digests);
1189
+ if (
1190
+ initialized.runId !== runId
1191
+ || initialized.graphPlanSha256 !== graphPlan.graphPlanSha256
1192
+ ) {
1193
+ throw new ApplicationError("invalid-data", "Run initialization identity is inconsistent.");
1194
+ }
1195
+ const storedGraph = await readJson(
1196
+ join(directory, "graph.json"),
1197
+ AuthoredWorkflowGraphV1Schema,
1198
+ digests,
1199
+ );
1200
+ if (!equalCanonical(storedGraph, graphPlan.graph)) {
1201
+ throw new ApplicationError("invalid-data", "Persisted authored graph differs from its graph plan.");
1202
+ }
1203
+ const workflow = await readJson(
1204
+ join(directory, "workflow.json"),
1205
+ RunWorkflowRecordSchema,
1206
+ digests,
1207
+ );
1208
+ if (
1209
+ !equalCanonical(workflow.bundle, graphPlan.bundle)
1210
+ || !equalCanonical(workflow.workflow, graphPlan.graph.workflow)
1211
+ ) {
1212
+ throw new ApplicationError("invalid-data", "Persisted workflow identity differs from its graph plan.");
1213
+ }
1214
+ const runtime = await readJson(join(directory, "runtime.json"), RunRuntimeRecordSchema, digests);
1215
+ if (
1216
+ !equalCanonical(runtime.runtime, graphPlan.runtime)
1217
+ || !equalCanonical(runtime.computes, sortedComputeIdentities(graphPlan))
1218
+ || !equalCanonical(runtime.operations, sortedOperationIdentities(graphPlan))
1219
+ ) {
1220
+ throw new ApplicationError("invalid-data", "Persisted runtime identity differs from its graph plan.");
1221
+ }
1222
+ const bundleBytes = await readBoundedPhysicalBytes(
1223
+ join(directory, "workflow.bundle.js"),
1224
+ MAX_BUNDLE_BYTES,
1225
+ );
1226
+ const bundleSha256 = createHash("sha256").update(bundleBytes).digest("hex");
1227
+ digests?.set(join(directory, "workflow.bundle.js"), bundleSha256);
1228
+ if (
1229
+ bundleBytes.byteLength !== graphPlan.bundle.bytes
1230
+ || bundleSha256 !== graphPlan.bundle.bundleSha256
1231
+ ) {
1232
+ throw new ApplicationError("invalid-data", "Persisted workflow bundle bytes changed.");
1233
+ }
1234
+ return { bundleBytes, directory, graphPlan, runtime, workflow };
1235
+ }
1236
+
1237
+ #identityArtifactPaths(directory: string): readonly string[] {
1238
+ return [
1239
+ join(directory, "graph-plan.json"),
1240
+ join(directory, INITIALIZED_FILE),
1241
+ join(directory, "graph.json"),
1242
+ join(directory, "workflow.json"),
1243
+ join(directory, "runtime.json"),
1244
+ join(directory, "workflow.bundle.js"),
1245
+ ];
1246
+ }
1247
+
1248
+ async #identitySnapshots(directory: string): Promise<ReadonlyMap<string, FileSnapshot>> {
1249
+ const entries = await Promise.all(this.#identityArtifactPaths(directory).map(async path => {
1250
+ const details = await lstat(path);
1251
+ if (!privateFile(details)) {
1252
+ throw new ApplicationError(
1253
+ "unsafe-path",
1254
+ `Run identity must be stored in a private physical file: ${path}`,
1255
+ );
1256
+ }
1257
+ return [path, fileSnapshot(details)] as const;
1258
+ }));
1259
+ return new Map(entries);
1260
+ }
1261
+
1262
+ #sameIdentitySnapshots(
1263
+ left: ReadonlyMap<string, FileSnapshot>,
1264
+ right: ReadonlyMap<string, FileSnapshot>,
1265
+ ): boolean {
1266
+ if (left.size !== right.size) return false;
1267
+ for (const [path, expected] of left) {
1268
+ const actual = right.get(path);
1269
+ if (actual === undefined || !sameFileSnapshot(expected, actual)) return false;
1270
+ }
1271
+ return true;
1272
+ }
1273
+
1274
+ async #identityDigests(
1275
+ snapshots: ReadonlyMap<string, FileSnapshot>,
1276
+ ): Promise<ReadonlyMap<string, string>> {
1277
+ const digests = new Map<string, string>();
1278
+ for (const [path, expected] of snapshots) {
1279
+ // Identity snapshots have already been admitted by the schema reader.
1280
+ // The exact original size further bounds every revalidation read.
1281
+ const bytes = await readBoundedPhysicalBytes(path, expected.size);
1282
+ digests.set(path, createHash("sha256").update(bytes).digest("hex"));
1283
+ }
1284
+ return digests;
1285
+ }
1286
+
1287
+ async #readStableIdentity(
1288
+ directory: string,
1289
+ runId: string,
1290
+ ): Promise<CachedRunIdentity> {
1291
+ const before = await this.#identitySnapshots(directory);
1292
+ const sha256ByPath = new Map<string, string>();
1293
+ // Bind the exact bytes that were parsed, instead of hashing a later
1294
+ // independently read version of the same logical document.
1295
+ const validated = await this.#readIdentity(directory, runId, sha256ByPath);
1296
+ let after = await this.#identitySnapshots(directory);
1297
+ if (!this.#sameIdentitySnapshots(before, after)) {
1298
+ for (const [path, previous] of before) {
1299
+ const current = after.get(path);
1300
+ if (current === undefined || !sameFileSnapshotExceptCtime(previous, current)) {
1301
+ throw new ApplicationError("conflict", `Run identity changed while it was validated: ${runId}`);
1302
+ }
1303
+ }
1304
+ const repeatedDigests = await this.#identityDigests(after);
1305
+ const repeatedSnapshots = await this.#identitySnapshots(directory);
1306
+ if (!this.#sameIdentitySnapshots(after, repeatedSnapshots)
1307
+ || [...sha256ByPath].some(([path, expected]) => repeatedDigests.get(path) !== expected)) {
1308
+ throw new ApplicationError("conflict", `Run identity changed while it was validated: ${runId}`);
1309
+ }
1310
+ after = repeatedSnapshots;
1311
+ }
1312
+ // The snapshots still cover the bundle file on every fenced operation. Keep
1313
+ // only parsed metadata here so one cache entry cannot retain 16 MiB of bytes.
1314
+ const identity: ValidatedRunMetadata = {
1315
+ directory: validated.directory,
1316
+ graphPlan: validated.graphPlan,
1317
+ runtime: validated.runtime,
1318
+ workflow: validated.workflow,
1319
+ };
1320
+ return {
1321
+ identity,
1322
+ retainedBytes: cachedIdentityRetainedBytes(identity, after),
1323
+ sha256ByPath,
1324
+ snapshots: after,
1325
+ };
1326
+ }
1327
+
1328
+ #claimSession(fence: RunFence): CachedClaimSession | undefined {
1329
+ const cached = this.#cachedClaimSessions.get(fence.runId);
1330
+ if (
1331
+ cached === undefined
1332
+ || cached.fenceGeneration !== fence.generation
1333
+ || cached.fenceToken !== fence.token
1334
+ ) return undefined;
1335
+ this.#cachedClaimSessions.delete(fence.runId);
1336
+ this.#cachedClaimSessions.set(fence.runId, cached);
1337
+ return cached;
1338
+ }
1339
+
1340
+ #deleteCachedClaimSession(runId: string): void {
1341
+ const cached = this.#cachedClaimSessions.get(runId);
1342
+ if (cached === undefined) return;
1343
+ this.#cachedClaimSessions.delete(runId);
1344
+ this.#cachedClaimSessionBytes = Math.max(
1345
+ 0,
1346
+ this.#cachedClaimSessionBytes - cached.retainedBytes,
1347
+ );
1348
+ }
1349
+
1350
+ #cacheClaimSession(
1351
+ fence: RunFence,
1352
+ update: Partial<Omit<
1353
+ CachedClaimSession,
1354
+ "fenceGeneration" | "fenceToken" | "retainedBytes"
1355
+ >>,
1356
+ ): void {
1357
+ const current = this.#claimSession(fence);
1358
+ const candidate = {
1359
+ ...(current?.eventJournal === undefined
1360
+ ? {}
1361
+ : { eventJournal: current.eventJournal }),
1362
+ ...(current?.grantJournal === undefined
1363
+ ? {}
1364
+ : { grantJournal: current.grantJournal }),
1365
+ ...(current?.identity === undefined ? {} : { identity: current.identity }),
1366
+ ...update,
1367
+ fenceGeneration: fence.generation,
1368
+ fenceToken: fence.token,
1369
+ } satisfies Omit<CachedClaimSession, "retainedBytes">;
1370
+ const retainedBytes = cachedClaimSessionRetainedBytes(fence.runId, candidate);
1371
+ this.#deleteCachedClaimSession(fence.runId);
1372
+ if (
1373
+ retainedBytes > MAX_CACHED_CLAIM_SESSION_BYTES
1374
+ || retainedBytes > this.#claimSessionCacheBudgetBytes
1375
+ ) return;
1376
+ this.#cachedClaimSessions.set(fence.runId, { ...candidate, retainedBytes });
1377
+ this.#cachedClaimSessionBytes = addRetainedBytes(
1378
+ this.#cachedClaimSessionBytes,
1379
+ retainedBytes,
1380
+ );
1381
+ while (
1382
+ this.#cachedClaimSessions.size > MAX_CACHED_CLAIM_SESSIONS
1383
+ || this.#cachedClaimSessionBytes > this.#claimSessionCacheBudgetBytes
1384
+ ) {
1385
+ const oldest = this.#cachedClaimSessions.keys().next().value;
1386
+ if (oldest === undefined) break;
1387
+ this.#deleteCachedClaimSession(oldest);
1388
+ }
1389
+ }
1390
+
1391
+ #clearClaimSessionForFence(fence: RunFence): void {
1392
+ const cached = this.#cachedClaimSessions.get(fence.runId);
1393
+ if (
1394
+ cached?.fenceGeneration === fence.generation
1395
+ && cached.fenceToken === fence.token
1396
+ ) this.#deleteCachedClaimSession(fence.runId);
1397
+ }
1398
+
1399
+ async #identityForFence(
1400
+ directory: string,
1401
+ fence: RunFence,
1402
+ ): Promise<ValidatedRunMetadata> {
1403
+ const cached = this.#claimSession(fence)?.identity;
1404
+ if (cached !== undefined) {
1405
+ try {
1406
+ const current = await this.#identitySnapshots(directory);
1407
+ if (this.#sameIdentitySnapshots(cached.snapshots, current)) return cached.identity;
1408
+ for (const [path, previous] of cached.snapshots) {
1409
+ const actual = current.get(path);
1410
+ if (actual === undefined || !sameFileSnapshotExceptCtime(previous, actual)) {
1411
+ throw new ApplicationError("conflict", `Run identity metadata changed: ${path}`);
1412
+ }
1413
+ }
1414
+ // A ctime-only observation does not establish either mutation or safety.
1415
+ // Reverify every immutable file against its originally bound physical
1416
+ // bytes exactly once, inside a stable whole-identity snapshot window.
1417
+ const sha256ByPath = await this.#identityDigests(current);
1418
+ const after = await this.#identitySnapshots(directory);
1419
+ if (!this.#sameIdentitySnapshots(current, after)) {
1420
+ throw new ApplicationError("conflict", "Run identity changed during revalidation.");
1421
+ }
1422
+ for (const [path, expected] of cached.sha256ByPath) {
1423
+ if (sha256ByPath.get(path) !== expected) {
1424
+ throw new ApplicationError("conflict", `Run identity bytes changed: ${path}`);
1425
+ }
1426
+ }
1427
+ this.#cacheClaimSession(fence, { identity: { ...cached, snapshots: after } });
1428
+ return cached.identity;
1429
+ } catch (error) {
1430
+ this.#clearClaimSessionForFence(fence);
1431
+ throw new ApplicationError(
1432
+ "conflict",
1433
+ `Run identity changed while its claim was active: ${fence.runId} (${String(error)})`,
1434
+ );
1435
+ }
1436
+ }
1437
+ const stable = await this.#readStableIdentity(directory, fence.runId);
1438
+ this.#cacheClaimSession(fence, { identity: stable });
1439
+ return stable.identity;
1440
+ }
1441
+
1442
+ async #identity(runId: string): Promise<ValidatedRunIdentity> {
1443
+ const directory = await this.#runDirectory(runId);
1444
+ return await this.#readIdentity(directory, runId);
1445
+ }
1446
+
1447
+ async create(input: CreateRunRecord): Promise<RunSummary> {
1448
+ const submitted = CreateRunRecordSchema.parse(input);
1449
+ const graphPlan = parseGraphPlan(submitted.graphPlan);
1450
+ if (submitted.bundleBytes.byteLength > MAX_BUNDLE_BYTES) {
1451
+ throw new ApplicationError(
1452
+ "invalid-data",
1453
+ `Workflow bundle exceeds ${String(MAX_BUNDLE_BYTES)} bytes.`,
1454
+ );
1455
+ }
1456
+ const bundleSha256 = createHash("sha256").update(submitted.bundleBytes).digest("hex");
1457
+ if (
1458
+ submitted.bundleBytes.byteLength !== graphPlan.bundle.bytes
1459
+ || bundleSha256 !== graphPlan.bundle.bundleSha256
1460
+ || !equalCanonical(submitted.workflow.bundle, graphPlan.bundle)
1461
+ ) {
1462
+ throw new ApplicationError("invalid-data", "Workflow bundle identity or bytes do not match.");
1463
+ }
1464
+ if (
1465
+ !equalCanonical(submitted.workflow.workflow, graphPlan.graph.workflow)
1466
+ || submitted.workflow.sourceLocator !== submitted.sourceLocator
1467
+ ) {
1468
+ throw new ApplicationError("invalid-data", "Workflow source identity does not match the graph plan.");
1469
+ }
1470
+ if (
1471
+ !equalCanonical(submitted.runtime.runtime, graphPlan.runtime)
1472
+ || !equalCanonical(
1473
+ submitted.runtime.computes,
1474
+ sortedComputeIdentities(graphPlan),
1475
+ )
1476
+ || !equalCanonical(submitted.runtime.operations, sortedOperationIdentities(graphPlan))
1477
+ ) {
1478
+ throw new ApplicationError("invalid-data", "Workflow runtime or operation identities do not match.");
1479
+ }
1480
+
1481
+ const directory = await this.#runDirectory(submitted.runId, false);
1482
+ const root = dirname(directory);
1483
+ try {
1484
+ await mkdir(directory, { mode: 0o700 });
1485
+ } catch (error) {
1486
+ if (errno(error, "EEXIST")) {
1487
+ throw new ApplicationError("conflict", `Workflow run already exists: ${submitted.runId}`);
1488
+ }
1489
+ throw error;
1490
+ }
1491
+ await syncDirectory(root);
1492
+ if (!privateDirectory(await lstat(directory))) {
1493
+ throw new ApplicationError("unsafe-path", "New workflow run directory is not private.");
1494
+ }
1495
+ await mkdir(join(directory, "nodes"), { mode: 0o700 });
1496
+ await mkdir(join(directory, "staging"), { mode: 0o700 });
1497
+ await writeJsonNoReplace(join(directory, "runtime.json"), submitted.runtime);
1498
+ await writeJsonNoReplace(join(directory, "workflow.json"), submitted.workflow);
1499
+ await writeNoReplace(join(directory, "workflow.bundle.js"), submitted.bundleBytes);
1500
+ await writeJsonNoReplace(join(directory, "graph.json"), graphPlan.graph);
1501
+ await writeJsonNoReplace(join(directory, "graph-plan.json"), graphPlan);
1502
+ await writeNoReplace(join(directory, "events.jsonl"), "");
1503
+ await writeNoReplace(join(directory, "grants.jsonl"), "");
1504
+
1505
+ const nodeDirectory = await this.#nodeDirectory(directory);
1506
+ for (const node of graphPlan.graph.nodes) {
1507
+ const nodeRecord = RunNodeRecordSchema.parse({
1508
+ attempt: 0,
1509
+ dependencies: node.dependencies,
1510
+ executor: node.executor,
1511
+ nodeKey: node.key,
1512
+ status: node.dependencies.length === 0 ? "ready" : "pending",
1513
+ version: RUN_NODE_VERSION,
1514
+ });
1515
+ await writeJsonNoReplace(join(nodeDirectory, nodeRecordFilename(node.key)), nodeRecord);
1516
+ }
1517
+ const now = new Date().toISOString();
1518
+ const summary = RunSummarySchema.parse({
1519
+ counts: {
1520
+ cancelled: 0,
1521
+ completed: 0,
1522
+ failed: 0,
1523
+ pending: graphPlan.graph.nodes.length,
1524
+ skipped: 0,
1525
+ },
1526
+ graphPlanSha256: graphPlan.graphPlanSha256,
1527
+ runId: submitted.runId,
1528
+ status: "planned",
1529
+ updatedAt: now,
1530
+ version: RUN_STORE_VERSION,
1531
+ });
1532
+ await writeJsonNoReplace(join(directory, "summary.json"), summary);
1533
+ await writeJsonNoReplace(join(directory, INITIALIZED_FILE), {
1534
+ graphPlanSha256: graphPlan.graphPlanSha256,
1535
+ runId: submitted.runId,
1536
+ version: RUN_STORE_VERSION,
1537
+ });
1538
+ await syncDirectory(nodeDirectory);
1539
+ await syncDirectory(join(directory, "staging"));
1540
+ await syncDirectory(directory);
1541
+ return summary;
1542
+ }
1543
+
1544
+ async list(): Promise<readonly RunSummary[]> {
1545
+ const root = await this.#physicalRoot();
1546
+ const entries = await readdir(root, { withFileTypes: true });
1547
+ const summaries: RunSummary[] = [];
1548
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
1549
+ if (!entry.isDirectory() || !entry.name.startsWith("run_")) continue;
1550
+ try {
1551
+ const directory = await this.#runDirectory(entry.name);
1552
+ const initialized = await readJson(join(directory, INITIALIZED_FILE), InitializedRecordSchema);
1553
+ const summary = await readJson(join(directory, "summary.json"), RunSummarySchema);
1554
+ if (
1555
+ initialized.runId !== summary.runId
1556
+ || initialized.graphPlanSha256 !== summary.graphPlanSha256
1557
+ ) continue;
1558
+ summaries.push(summary);
1559
+ } catch {
1560
+ // Incomplete or corrupt creations are recovery evidence, not discoverable runs.
1561
+ }
1562
+ }
1563
+ return Object.freeze(summaries.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
1564
+ }
1565
+
1566
+ async graphPlan(runId: string): Promise<GraphPlanV1> {
1567
+ return (await this.#identity(runId)).graphPlan;
1568
+ }
1569
+
1570
+ async graph(runId: string) {
1571
+ return (await this.#identity(runId)).graphPlan.graph;
1572
+ }
1573
+
1574
+ async workflow(runId: string): Promise<RunWorkflowRecord> {
1575
+ return (await this.#identity(runId)).workflow;
1576
+ }
1577
+
1578
+ async runtime(runId: string): Promise<RunRuntimeRecord> {
1579
+ return (await this.#identity(runId)).runtime;
1580
+ }
1581
+
1582
+ async bundle(runId: string): Promise<Uint8Array> {
1583
+ return new Uint8Array((await this.#identity(runId)).bundleBytes);
1584
+ }
1585
+
1586
+ /**
1587
+ * Resolves one durable private workspace bound to the current fenced
1588
+ * node-execution plan. The path is host-owned and never serialized into
1589
+ * authored graph input.
1590
+ */
1591
+ async stagingDirectory(
1592
+ fence: RunFence,
1593
+ nodeKey: string,
1594
+ nodePlanSha256: string,
1595
+ ): Promise<string> {
1596
+ const parsedFence = RunFenceSchema.parse(fence);
1597
+ const parsedNodeKey = NodeKeySchema.parse(nodeKey);
1598
+ const parsedNodePlanSha256 = Sha256Schema.parse(nodePlanSha256);
1599
+ return await this.#withPhysicalLock(parsedFence.runId, async directory => {
1600
+ await this.#assertFenceIn(directory, parsedFence);
1601
+ const identity = await this.#identityForFence(directory, parsedFence);
1602
+ const record = await readJson(
1603
+ join(
1604
+ await this.#nodeDirectory(directory),
1605
+ nodeRecordFilename(parsedNodeKey),
1606
+ ),
1607
+ RunNodeRecordSchema,
1608
+ );
1609
+ if (
1610
+ record.nodeKey !== parsedNodeKey
1611
+ || record.executionPlan?.nodePlanSha256 !== parsedNodePlanSha256
1612
+ ) {
1613
+ throw new ApplicationError(
1614
+ "conflict",
1615
+ `Node staging identity does not match its exact execution plan: ${parsedNodeKey}`,
1616
+ );
1617
+ }
1618
+ const staging = await this.#privateChildDirectory(
1619
+ identity.directory,
1620
+ "staging",
1621
+ );
1622
+ const nodeDigest = nodeRecordFilename(parsedNodeKey).slice(0, -".json".length);
1623
+ const nodeDirectory = await this.#ensurePrivateChildDirectory(
1624
+ staging,
1625
+ nodeDigest,
1626
+ );
1627
+ const planDirectory = await this.#ensurePrivateChildDirectory(
1628
+ nodeDirectory,
1629
+ parsedNodePlanSha256,
1630
+ );
1631
+ await syncDirectory(nodeDirectory);
1632
+ await syncDirectory(staging);
1633
+ return planDirectory;
1634
+ });
1635
+ }
1636
+
1637
+ async summary(runId: string): Promise<RunSummary> {
1638
+ const identity = await this.#identity(runId);
1639
+ const summary = await readJson(join(identity.directory, "summary.json"), RunSummarySchema);
1640
+ if (
1641
+ summary.runId !== runId
1642
+ || summary.graphPlanSha256 !== identity.graphPlan.graphPlanSha256
1643
+ ) {
1644
+ throw new ApplicationError("invalid-data", "Run summary identity is inconsistent.");
1645
+ }
1646
+ if (summary.outputs !== undefined) {
1647
+ const outputs = await this.outputs(runId);
1648
+ if (outputs === undefined || !equalCanonical(outputs.outputs, summary.outputs)) {
1649
+ throw new ApplicationError("invalid-data", "Run summary output identity is inconsistent.");
1650
+ }
1651
+ }
1652
+ if (TERMINAL_RUN_STATUSES.has(summary.status)) {
1653
+ const records = await this.#loadNodes(identity);
1654
+ if (
1655
+ !equalCanonical(summary.counts, countsFor(records))
1656
+ || summary.status !== derivedRunStatus(records)
1657
+ || summary.finishedAt === undefined
1658
+ ) {
1659
+ throw new ApplicationError("invalid-data", "Terminal run summary is inconsistent with node state.");
1660
+ }
1661
+ }
1662
+ return summary;
1663
+ }
1664
+
1665
+ async #loadNodes(identity: ValidatedRunMetadata): Promise<readonly RunNodeRecord[]> {
1666
+ const nodeDirectory = await this.#nodeDirectory(identity.directory);
1667
+ const records = await Promise.all(identity.graphPlan.graph.nodes.map(async node => (
1668
+ await readJson(
1669
+ join(nodeDirectory, nodeRecordFilename(node.key)),
1670
+ RunNodeRecordSchema,
1671
+ )
1672
+ )));
1673
+ const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
1674
+ if (recordsByKey.size !== identity.graphPlan.graph.nodes.length) {
1675
+ throw new ApplicationError("invalid-data", "Run node identities are duplicated or missing.");
1676
+ }
1677
+ for (const node of identity.graphPlan.graph.nodes) {
1678
+ const record = recordsByKey.get(node.key);
1679
+ if (record === undefined) {
1680
+ throw new ApplicationError("invalid-data", `Run is missing authored node ${node.key}.`);
1681
+ }
1682
+ this.#validateNodeRecord(identity.graphPlan, node, record, recordsByKey);
1683
+ }
1684
+ return Object.freeze(records);
1685
+ }
1686
+
1687
+ async #loadNodeContext(
1688
+ identity: ValidatedRunMetadata,
1689
+ nodeKey: string,
1690
+ ): Promise<{
1691
+ readonly authored: AuthoredGraphNodeV1;
1692
+ readonly record: RunNodeRecord;
1693
+ readonly recordsByKey: ReadonlyMap<string, RunNodeRecord>;
1694
+ }> {
1695
+ const authored = identity.graphPlan.graph.nodes.find(node => node.key === nodeKey);
1696
+ if (authored === undefined) {
1697
+ throw new ApplicationError("not-found", `Workflow run node does not exist: ${nodeKey}`);
1698
+ }
1699
+ const nodeDirectory = await this.#nodeDirectory(identity.directory);
1700
+ const authoredByKey = new Map(
1701
+ identity.graphPlan.graph.nodes.map(node => [node.key, node]),
1702
+ );
1703
+ const relevant = new Set<string>();
1704
+ const pending = [authored.key];
1705
+ while (pending.length > 0) {
1706
+ const key = pending.pop();
1707
+ if (key === undefined || relevant.has(key)) continue;
1708
+ const node = authoredByKey.get(key);
1709
+ if (node === undefined) {
1710
+ throw new ApplicationError("invalid-data", `Run graph is missing dependency ${key}.`);
1711
+ }
1712
+ relevant.add(key);
1713
+ pending.push(...node.dependencies);
1714
+ }
1715
+ const relevantKeys = [...relevant];
1716
+ const records = await Promise.all(relevantKeys.map(async key => (
1717
+ await readJson(
1718
+ join(nodeDirectory, nodeRecordFilename(key)),
1719
+ RunNodeRecordSchema,
1720
+ )
1721
+ )));
1722
+ const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
1723
+ if (recordsByKey.size !== relevantKeys.length) {
1724
+ throw new ApplicationError(
1725
+ "invalid-data",
1726
+ `Run node context contains duplicated or missing identities: ${nodeKey}`,
1727
+ );
1728
+ }
1729
+ for (const key of relevantKeys) {
1730
+ const record = recordsByKey.get(key);
1731
+ const expected = authoredByKey.get(key);
1732
+ if (record === undefined || expected === undefined) {
1733
+ throw new ApplicationError("invalid-data", `Run is missing authored node ${key}.`);
1734
+ }
1735
+ this.#validateNodeRecord(identity.graphPlan, expected, record, recordsByKey);
1736
+ }
1737
+ const record = recordsByKey.get(authored.key);
1738
+ if (record === undefined) {
1739
+ throw new ApplicationError("invalid-data", `Run is missing authored node ${authored.key}.`);
1740
+ }
1741
+ return {
1742
+ authored,
1743
+ record,
1744
+ recordsByKey,
1745
+ };
1746
+ }
1747
+
1748
+ #validateNodeRecord(
1749
+ graphPlan: GraphPlanV1,
1750
+ authored: AuthoredGraphNodeV1,
1751
+ record: RunNodeRecord,
1752
+ recordsByKey: ReadonlyMap<string, RunNodeRecord>,
1753
+ ): void {
1754
+ if (
1755
+ record.nodeKey !== authored.key
1756
+ || !equalCanonical(record.dependencies, authored.dependencies)
1757
+ || !equalCanonical(record.executor, authored.executor)
1758
+ ) {
1759
+ throw new ApplicationError("invalid-data", `Persisted node identity changed: ${authored.key}`);
1760
+ }
1761
+ const policy = nodePolicy(graphPlan, authored);
1762
+ if (record.executionPlan !== undefined && record.preparationPlan === undefined) {
1763
+ throw new ApplicationError(
1764
+ "invalid-data",
1765
+ `Execution plan has no preparation plan for node ${authored.key}.`,
1766
+ );
1767
+ }
1768
+ if (record.preparationPlan !== undefined) {
1769
+ const { preparationPlanSha256, ...unsigned } = record.preparationPlan;
1770
+ if (
1771
+ record.preparationPlan.version !== NODE_PREPARATION_PLAN_VERSION
1772
+ || preparationPlanSha256 !== createNodePreparationPlanHash(unsigned)
1773
+ || record.preparationPlan.graphPlanSha256 !== graphPlan.graphPlanSha256
1774
+ || record.preparationPlan.nodeKey !== authored.key
1775
+ || !equalCanonical(record.preparationPlan.executor, authored.executor)
1776
+ || !equalCanonical(
1777
+ record.preparationPlan.requestedPreparation,
1778
+ [...policy.preparation].sort((left, right) => left.localeCompare(right)),
1779
+ )
1780
+ || record.preparationPlan.upperDurationMs > policy.maxDurationMs
1781
+ || record.preparationPlan.upperInputBytes > policy.maxInputBytes
1782
+ ) {
1783
+ throw new ApplicationError(
1784
+ "invalid-data",
1785
+ `Preparation plan digest or identity is invalid for node ${authored.key}.`,
1786
+ );
1787
+ }
1788
+ }
1789
+ if (record.executionPlan !== undefined) {
1790
+ const { nodePlanSha256, ...unsigned } = record.executionPlan;
1791
+ const expectedDependencyDigests = Object.fromEntries(
1792
+ [...authored.dependencies].sort((left, right) => left.localeCompare(right)).map(key => {
1793
+ const dependency = recordsByKey.get(key);
1794
+ if (dependency?.status !== "completed" || dependency.output === undefined) {
1795
+ throw new ApplicationError(
1796
+ "invalid-data",
1797
+ `Execution plan for ${authored.key} references incomplete dependency ${key}.`,
1798
+ );
1799
+ }
1800
+ return [key, dependency.output.digestSha256];
1801
+ }),
1802
+ );
1803
+ const publicationKeys = [...record.executionPlan.publicationKeys]
1804
+ .sort((left, right) => left.localeCompare(right));
1805
+ if (
1806
+ record.executionPlan.version !== NODE_EXECUTION_PLAN_VERSION
1807
+ || nodePlanSha256 !== createNodeExecutionPlanHash(unsigned)
1808
+ || record.executionPlan.inputSha256 !== createNodeInputHash(record.executionPlan.exactInput)
1809
+ || record.executionPlan.graphPlanSha256 !== graphPlan.graphPlanSha256
1810
+ || record.executionPlan.nodeKey !== authored.key
1811
+ || record.executionPlan.preparationPlanSha256
1812
+ !== record.preparationPlan?.preparationPlanSha256
1813
+ || !equalCanonical(record.executionPlan.executor, authored.executor)
1814
+ || !equalCanonical(record.executionPlan.policy, normalizedPolicy(policy))
1815
+ || !equalCanonical(record.executionPlan.dependencyOutputDigests, expectedDependencyDigests)
1816
+ || !equalCanonical(record.executionPlan.publicationKeys, publicationKeys)
1817
+ || new Set(publicationKeys).size !== publicationKeys.length
1818
+ ) {
1819
+ throw new ApplicationError(
1820
+ "invalid-data",
1821
+ `Execution plan digest or identity is invalid for node ${authored.key}.`,
1822
+ );
1823
+ }
1824
+ }
1825
+ if (
1826
+ record.output !== undefined
1827
+ && record.output.digestSha256 !== createRunNodeOutputDigest(record.output.value)
1828
+ ) {
1829
+ throw new ApplicationError("invalid-data", `Output digest is invalid for node ${authored.key}.`);
1830
+ }
1831
+ }
1832
+
1833
+ async node(runId: string, nodeKey: string): Promise<RunNodeRecord> {
1834
+ const parsedKey = NodeKeySchema.parse(nodeKey);
1835
+ const identity = await this.#identity(runId);
1836
+ return (await this.#loadNodeContext(identity, parsedKey)).record;
1837
+ }
1838
+
1839
+ async nodes(runId: string): Promise<readonly RunNodeRecord[]> {
1840
+ const identity = await this.#identity(runId);
1841
+ return await this.#loadNodes(identity);
1842
+ }
1843
+
1844
+ async events(runId: string): Promise<readonly RunEvent[]> {
1845
+ const directory = await this.#runDirectory(runId);
1846
+ const events = await readJsonLines(join(directory, "events.jsonl"), RunEventSchema);
1847
+ validateEventSequence(events, runId);
1848
+ return events;
1849
+ }
1850
+
1851
+ async grants(runId: string): Promise<readonly RunGrant[]> {
1852
+ const identity = await this.#identity(runId);
1853
+ const grants = await readJsonLines(join(identity.directory, "grants.jsonl"), RunGrantSchema);
1854
+ validateGrantLedger(grants, runId, identity.graphPlan.graphPlanSha256);
1855
+ return grants;
1856
+ }
1857
+
1858
+ async outputs(runId: string): Promise<RunOutputs | undefined> {
1859
+ const identity = await this.#identity(runId);
1860
+ const outputPath = join(identity.directory, "outputs.json");
1861
+ const outputs = await readJson(outputPath, RunOutputsSchema).catch(error => {
1862
+ if (error instanceof ApplicationError && error.code === "not-found") return undefined;
1863
+ throw error;
1864
+ });
1865
+ if (outputs === undefined) return undefined;
1866
+ if (
1867
+ outputs.runId !== runId
1868
+ || outputs.graphPlanSha256 !== identity.graphPlan.graphPlanSha256
1869
+ || outputs.outputsSha256 !== createRunOutputsDigest(outputs.outputs)
1870
+ ) {
1871
+ throw new ApplicationError("invalid-data", "Persisted run outputs have an invalid identity.");
1872
+ }
1873
+ const records = await this.#loadNodes(identity);
1874
+ const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
1875
+ const digests: Record<string, string> = {};
1876
+ const resolved = resolveOutputBinding(identity.graphPlan.graph.outputs, recordsByKey, digests);
1877
+ if (
1878
+ resolved === undefined
1879
+ || !equalCanonical(outputs.outputs, resolved)
1880
+ || !equalCanonical(outputs.nodeOutputDigests, digests)
1881
+ ) {
1882
+ throw new ApplicationError("invalid-data", "Persisted run outputs differ from completed nodes.");
1883
+ }
1884
+ return outputs;
1885
+ }
1886
+
1887
+ async #existingClaimIn(directory: string): Promise<ExistingClaim | undefined> {
1888
+ const path = join(directory, CLAIM_FILE);
1889
+ let details: Stats;
1890
+ try {
1891
+ details = await lstat(path);
1892
+ } catch (error) {
1893
+ if (errno(error, "ENOENT")) return undefined;
1894
+ throw error;
1895
+ }
1896
+ const fence = await readJson(path, RunFenceSchema);
1897
+ return { fence, snapshot: snapshot(details) };
1898
+ }
1899
+
1900
+ async #assertFenceIn(directory: string, fence: RunFence): Promise<RunFence> {
1901
+ const parsed = RunFenceSchema.parse(fence);
1902
+ const existing = await this.#existingClaimIn(directory);
1903
+ if (
1904
+ existing === undefined
1905
+ || existing.fence.generation !== parsed.generation
1906
+ || existing.fence.token !== parsed.token
1907
+ || existing.fence.owner !== parsed.owner
1908
+ || existing.fence.runId !== parsed.runId
1909
+ ) {
1910
+ this.#clearClaimSessionForFence(parsed);
1911
+ throw new ApplicationError("conflict", `Workflow run fence is stale: ${parsed.runId}`);
1912
+ }
1913
+ return parsed;
1914
+ }
1915
+
1916
+ async #appendEventIn(
1917
+ directory: string,
1918
+ fence: RunFence,
1919
+ input: NewRunEvent,
1920
+ ): Promise<RunEvent> {
1921
+ const cached = this.#claimSession(fence)?.eventJournal;
1922
+ const appended = await appendJournalEntry(
1923
+ join(directory, "events.jsonl"),
1924
+ RunEventSchema,
1925
+ entries => validateEventSequence(entries, fence.runId),
1926
+ ({ cursor }) => {
1927
+ if (fence.generation < cursor.lastFenceGeneration) {
1928
+ throw new ApplicationError(
1929
+ "invalid-data",
1930
+ `Run event fence generation regressed for ${fence.runId}.`,
1931
+ );
1932
+ }
1933
+ const event = RunEventSchema.parse({
1934
+ ...input,
1935
+ fenceGeneration: fence.generation,
1936
+ runId: fence.runId,
1937
+ sequence: cursor.lastSequence + 1,
1938
+ version: RUN_EVENT_VERSION,
1939
+ });
1940
+ return { entry: event, result: event };
1941
+ },
1942
+ (_cursor, event) => ({
1943
+ lastFenceGeneration: event.fenceGeneration,
1944
+ lastSequence: event.sequence,
1945
+ }),
1946
+ cached,
1947
+ );
1948
+ this.#cacheClaimSession(fence, { eventJournal: appended.state });
1949
+ return appended.result;
1950
+ }
1951
+
1952
+ async acquireClaim(runId: string, options: AcquireRunClaimOptions): Promise<RunFence> {
1953
+ return await this.#withPhysicalLock(runId, async directory => {
1954
+ const stableIdentity = await this.#readStableIdentity(directory, runId);
1955
+ const now = (options.now ?? (() => new Date()))();
1956
+ const staleAfterMs = options.staleAfterMs ?? 30_000;
1957
+ if (!Number.isSafeInteger(staleAfterMs) || staleAfterMs < 0) {
1958
+ throw new ApplicationError("usage", "Run claim stale interval must be a nonnegative safe integer.");
1959
+ }
1960
+ const existing = await this.#existingClaimIn(directory);
1961
+ let previousGeneration = existing?.fence.generation ?? 0;
1962
+ if (existing !== undefined) {
1963
+ const age = now.getTime() - Date.parse(existing.fence.acquiredAt);
1964
+ const ownerAlive = existing.fence.hostname !== hostname()
1965
+ || (options.processAlive ?? localProcessAlive)(existing.fence.pid);
1966
+ if (age < staleAfterMs || ownerAlive) {
1967
+ throw new ApplicationError("conflict", `Workflow run is already claimed: ${runId}`, {
1968
+ acquiredAt: existing.fence.acquiredAt,
1969
+ owner: existing.fence.owner,
1970
+ pid: existing.fence.pid,
1971
+ });
1972
+ }
1973
+ const current = await lstat(join(directory, CLAIM_FILE)).catch(() => undefined);
1974
+ if (current === undefined || !sameSnapshot(existing.snapshot, snapshot(current))) {
1975
+ throw new ApplicationError("conflict", `Workflow run claim changed during reclaim: ${runId}`);
1976
+ }
1977
+ await unlink(join(directory, CLAIM_FILE));
1978
+ await syncDirectory(directory);
1979
+ }
1980
+ // No live claim remains under the physical lock, so an entry left behind
1981
+ // by a release through another RunStore instance cannot describe this session.
1982
+ this.#deleteCachedClaimSession(runId);
1983
+
1984
+ const generationRecord = await readJson(
1985
+ join(directory, CLAIM_GENERATION_FILE),
1986
+ ClaimGenerationRecordSchema,
1987
+ ).catch(error => {
1988
+ if (error instanceof ApplicationError && error.code === "not-found") return undefined;
1989
+ throw error;
1990
+ });
1991
+ previousGeneration = Math.max(previousGeneration, generationRecord?.generation ?? 0);
1992
+ const generation = previousGeneration + 1;
1993
+ const fence = RunFenceSchema.parse({
1994
+ acquiredAt: now.toISOString(),
1995
+ generation,
1996
+ hostname: hostname(),
1997
+ owner: options.owner,
1998
+ pid: process.pid,
1999
+ runId,
2000
+ token: randomUUID(),
2001
+ version: RUN_FENCE_VERSION,
2002
+ });
2003
+
2004
+ // Reserve the generation before the claim can become visible. A crash may skip a
2005
+ // generation, but can never reuse one.
2006
+ await writeJsonAtomic(join(directory, CLAIM_GENERATION_FILE), {
2007
+ generation,
2008
+ version: RUN_STORE_VERSION,
2009
+ });
2010
+ try {
2011
+ await publishJsonNoReplace(join(directory, CLAIM_FILE), fence);
2012
+ await this.#appendEventIn(directory, fence, {
2013
+ details: { owner: fence.owner },
2014
+ kind: "run-claimed",
2015
+ timestamp: fence.acquiredAt,
2016
+ });
2017
+ this.#cacheClaimSession(fence, { identity: stableIdentity });
2018
+ return fence;
2019
+ } catch (error) {
2020
+ this.#clearClaimSessionForFence(fence);
2021
+ const published = await this.#existingClaimIn(directory).catch(() => undefined);
2022
+ if (
2023
+ published?.fence.token === fence.token
2024
+ && published.fence.generation === fence.generation
2025
+ ) {
2026
+ const current = await lstat(join(directory, CLAIM_FILE)).catch(() => undefined);
2027
+ if (current !== undefined && sameSnapshot(published.snapshot, snapshot(current))) {
2028
+ await unlink(join(directory, CLAIM_FILE)).catch(() => undefined);
2029
+ await syncDirectory(directory).catch(() => undefined);
2030
+ }
2031
+ }
2032
+ throw error;
2033
+ }
2034
+ });
2035
+ }
2036
+
2037
+ async assertFence(fence: RunFence): Promise<void> {
2038
+ const parsed = RunFenceSchema.parse(fence);
2039
+ await this.#assertFenceIn(await this.#runDirectory(parsed.runId), parsed);
2040
+ }
2041
+
2042
+ async releaseClaim(fence: RunFence): Promise<void> {
2043
+ const parsed = RunFenceSchema.parse(fence);
2044
+ await this.#withPhysicalLock(parsed.runId, async directory => {
2045
+ await this.#assertFenceIn(directory, parsed);
2046
+ const existing = await this.#existingClaimIn(directory);
2047
+ if (existing === undefined) {
2048
+ throw new ApplicationError("conflict", `Workflow run fence is stale: ${parsed.runId}`);
2049
+ }
2050
+ const current = await lstat(join(directory, CLAIM_FILE));
2051
+ if (!sameSnapshot(existing.snapshot, snapshot(current))) {
2052
+ throw new ApplicationError(
2053
+ "conflict",
2054
+ `Workflow run claim changed during release: ${parsed.runId}`,
2055
+ );
2056
+ }
2057
+ await unlink(join(directory, CLAIM_FILE));
2058
+ await syncDirectory(directory);
2059
+ this.#clearClaimSessionForFence(parsed);
2060
+ });
2061
+ }
2062
+
2063
+ async appendEvent(fence: RunFence, input: NewRunEvent): Promise<RunEvent> {
2064
+ const parsed = RunFenceSchema.parse(fence);
2065
+ return await this.#withPhysicalLock(parsed.runId, async directory => {
2066
+ await this.#assertFenceIn(directory, parsed);
2067
+ if (input.nodeKey !== undefined) {
2068
+ const identity = await this.#identityForFence(directory, parsed);
2069
+ if (!identity.graphPlan.graph.nodes.some(node => node.key === input.nodeKey)) {
2070
+ throw new ApplicationError("invalid-data", `Run event names an unknown node: ${input.nodeKey}`);
2071
+ }
2072
+ }
2073
+ return await this.#appendEventIn(directory, parsed, input);
2074
+ });
2075
+ }
2076
+
2077
+ #validateTransition(
2078
+ graphPlan: GraphPlanV1,
2079
+ authored: AuthoredGraphNodeV1,
2080
+ current: RunNodeRecord,
2081
+ next: RunNodeRecord,
2082
+ recordsByKey: ReadonlyMap<string, RunNodeRecord>,
2083
+ ): void {
2084
+ if (TERMINAL_NODE_STATUSES.has(current.status)) {
2085
+ if (!equalCanonical(current, next)) {
2086
+ throw new ApplicationError(
2087
+ "conflict",
2088
+ `Terminal run node ${current.nodeKey} is immutable.`,
2089
+ );
2090
+ }
2091
+ return;
2092
+ }
2093
+ if (!FORWARD_NODE_TRANSITIONS[current.status].has(next.status)) {
2094
+ throw new ApplicationError(
2095
+ "invalid-data",
2096
+ `Illegal run node transition ${current.status} -> ${next.status} for ${current.nodeKey}.`,
2097
+ );
2098
+ }
2099
+ const expectedAttempt = current.status === "ready" && next.status === "running"
2100
+ ? current.attempt + 1
2101
+ : current.attempt;
2102
+ if (next.attempt !== expectedAttempt) {
2103
+ throw new ApplicationError(
2104
+ "invalid-data",
2105
+ `Run node attempt changed outside ready -> running for ${current.nodeKey}.`,
2106
+ );
2107
+ }
2108
+ if (
2109
+ current.preparationPlan !== undefined
2110
+ && !equalCanonical(current.preparationPlan, next.preparationPlan)
2111
+ ) {
2112
+ throw new ApplicationError("conflict", `Preparation plan changed for ${current.nodeKey}.`);
2113
+ }
2114
+ if (
2115
+ current.executionPlan !== undefined
2116
+ && !equalCanonical(current.executionPlan, next.executionPlan)
2117
+ ) {
2118
+ throw new ApplicationError("conflict", `Execution plan changed for ${current.nodeKey}.`);
2119
+ }
2120
+ if (
2121
+ !(current.status === "ready" && next.status === "running")
2122
+ && current.startedAt !== next.startedAt
2123
+ ) {
2124
+ throw new ApplicationError("invalid-data", `Run node startedAt changed illegally: ${current.nodeKey}`);
2125
+ }
2126
+ if (
2127
+ current.status === "ready"
2128
+ && next.status === "running"
2129
+ && current.startedAt !== undefined
2130
+ && Date.parse(next.startedAt!) < Date.parse(current.startedAt)
2131
+ ) {
2132
+ throw new ApplicationError("invalid-data", `Run node startedAt regressed: ${current.nodeKey}`);
2133
+ }
2134
+ if (
2135
+ current.status === "pending"
2136
+ && next.status === "ready"
2137
+ && authored.dependencies.some(key => recordsByKey.get(key)?.status !== "completed")
2138
+ ) {
2139
+ throw new ApplicationError(
2140
+ "invalid-data",
2141
+ `Run node ${current.nodeKey} became ready before all dependencies completed.`,
2142
+ );
2143
+ }
2144
+ if (current.status === "running" && next.status === "ready") {
2145
+ const resume = nodePolicy(graphPlan, authored).resume;
2146
+ if (
2147
+ resume !== "deterministic"
2148
+ && resume !== "verified-receipt"
2149
+ && resume !== "recoverable-transaction"
2150
+ ) {
2151
+ throw new ApplicationError(
2152
+ "conflict",
2153
+ `Interrupted node ${current.nodeKey} cannot be retried under ${resume}.`,
2154
+ );
2155
+ }
2156
+ }
2157
+ }
2158
+
2159
+ async writeNode(fence: RunFence, node: RunNodeRecord): Promise<void> {
2160
+ const parsedFence = RunFenceSchema.parse(fence);
2161
+ await this.#withPhysicalLock(parsedFence.runId, async directory => {
2162
+ await this.#assertFenceIn(directory, parsedFence);
2163
+ const parsedNode = RunNodeRecordSchema.parse(node);
2164
+ const identity = await this.#identityForFence(directory, parsedFence);
2165
+ const context = await this.#loadNodeContext(identity, parsedNode.nodeKey);
2166
+ const nextByKey = new Map(context.recordsByKey);
2167
+ nextByKey.set(parsedNode.nodeKey, parsedNode);
2168
+ this.#validateTransition(
2169
+ identity.graphPlan,
2170
+ context.authored,
2171
+ context.record,
2172
+ parsedNode,
2173
+ nextByKey,
2174
+ );
2175
+ this.#validateNodeRecord(
2176
+ identity.graphPlan,
2177
+ context.authored,
2178
+ parsedNode,
2179
+ nextByKey,
2180
+ );
2181
+ const nodeDirectory = await this.#nodeDirectory(directory);
2182
+ await writeJsonAtomic(
2183
+ join(nodeDirectory, nodeRecordFilename(parsedNode.nodeKey)),
2184
+ parsedNode,
2185
+ );
2186
+ });
2187
+ }
2188
+
2189
+ async writeSummary(fence: RunFence, summary: RunSummary): Promise<void> {
2190
+ const parsedFence = RunFenceSchema.parse(fence);
2191
+ await this.#withPhysicalLock(parsedFence.runId, async directory => {
2192
+ await this.#assertFenceIn(directory, parsedFence);
2193
+ const parsedSummary = RunSummarySchema.parse(summary);
2194
+ const identity = await this.#identityForFence(directory, parsedFence);
2195
+ if (
2196
+ parsedSummary.runId !== parsedFence.runId
2197
+ || parsedSummary.graphPlanSha256 !== identity.graphPlan.graphPlanSha256
2198
+ ) {
2199
+ throw new ApplicationError("invalid-data", "Run summary identity is inconsistent.");
2200
+ }
2201
+ const current = await readJson(join(directory, "summary.json"), RunSummarySchema);
2202
+ if (TERMINAL_RUN_STATUSES.has(current.status)) {
2203
+ if (!equalCanonical(current, parsedSummary)) {
2204
+ throw new ApplicationError("conflict", "Terminal run summary is immutable.");
2205
+ }
2206
+ return;
2207
+ }
2208
+ if (Date.parse(parsedSummary.updatedAt) < Date.parse(current.updatedAt)) {
2209
+ throw new ApplicationError("invalid-data", "Run summary updatedAt regressed.");
2210
+ }
2211
+ if (
2212
+ current.startedAt !== undefined
2213
+ && parsedSummary.startedAt !== current.startedAt
2214
+ ) {
2215
+ throw new ApplicationError("invalid-data", "Run summary startedAt changed.");
2216
+ }
2217
+ if (parsedSummary.status !== "planned" && parsedSummary.startedAt === undefined) {
2218
+ throw new ApplicationError("invalid-data", "Started run summaries require startedAt.");
2219
+ }
2220
+ const terminal = TERMINAL_RUN_STATUSES.has(parsedSummary.status);
2221
+ if (terminal !== (parsedSummary.finishedAt !== undefined)) {
2222
+ throw new ApplicationError(
2223
+ "invalid-data",
2224
+ "Only terminal run summaries require finishedAt.",
2225
+ );
2226
+ }
2227
+ const records = await this.#loadNodes(identity);
2228
+ if (!equalCanonical(parsedSummary.counts, countsFor(records))) {
2229
+ throw new ApplicationError("invalid-data", "Run summary counts do not match node state.");
2230
+ }
2231
+ const expectedStatus = derivedRunStatus(records);
2232
+ if (parsedSummary.status !== expectedStatus) {
2233
+ throw new ApplicationError(
2234
+ "invalid-data",
2235
+ `Run summary status ${parsedSummary.status} does not match ${expectedStatus}.`,
2236
+ );
2237
+ }
2238
+
2239
+ if (parsedSummary.status === "completed") {
2240
+ const digests: Record<string, string> = {};
2241
+ const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
2242
+ const expectedOutputs = resolveOutputBinding(
2243
+ identity.graphPlan.graph.outputs,
2244
+ recordsByKey,
2245
+ digests,
2246
+ );
2247
+ if (
2248
+ expectedOutputs === undefined
2249
+ || parsedSummary.outputs === undefined
2250
+ || !equalCanonical(parsedSummary.outputs, expectedOutputs)
2251
+ ) {
2252
+ throw new ApplicationError(
2253
+ "invalid-data",
2254
+ "Completed run outputs do not match the authored output bindings.",
2255
+ );
2256
+ }
2257
+ const outputRecord = RunOutputsSchema.parse({
2258
+ graphPlanSha256: identity.graphPlan.graphPlanSha256,
2259
+ nodeOutputDigests: digests,
2260
+ outputs: expectedOutputs,
2261
+ outputsSha256: createRunOutputsDigest(expectedOutputs),
2262
+ runId: parsedFence.runId,
2263
+ version: RUN_OUTPUTS_VERSION,
2264
+ });
2265
+ try {
2266
+ await publishJsonNoReplace(join(directory, "outputs.json"), outputRecord);
2267
+ } catch (error) {
2268
+ if (!errno(error, "EEXIST")) throw error;
2269
+ const existing = await readJson(join(directory, "outputs.json"), RunOutputsSchema);
2270
+ if (!equalCanonical(existing, outputRecord)) {
2271
+ throw new ApplicationError("conflict", "Run outputs were already published differently.");
2272
+ }
2273
+ }
2274
+ } else if (parsedSummary.outputs !== undefined) {
2275
+ throw new ApplicationError("invalid-data", "Only completed runs may publish outputs.");
2276
+ }
2277
+ await writeJsonAtomic(join(directory, "summary.json"), parsedSummary);
2278
+ });
2279
+ }
2280
+
2281
+ async appendGrant(fence: RunFence, grant: NewRunGrant): Promise<RunGrant> {
2282
+ const parsedFence = RunFenceSchema.parse(fence);
2283
+ return await this.#withPhysicalLock(parsedFence.runId, async directory => {
2284
+ await this.#assertFenceIn(directory, parsedFence);
2285
+ const identity = await this.#identityForFence(directory, parsedFence);
2286
+ const parsedGrant = RunGrantSchema.parse({
2287
+ ...grant,
2288
+ runId: parsedFence.runId,
2289
+ version: RUN_GRANT_VERSION,
2290
+ });
2291
+ if (parsedGrant.graphPlanSha256 !== identity.graphPlan.graphPlanSha256) {
2292
+ throw new ApplicationError("invalid-data", "Run grant targets a different graph plan.");
2293
+ }
2294
+ if (
2295
+ "nodeKey" in parsedGrant
2296
+ && !identity.graphPlan.graph.nodes.some(node => node.key === parsedGrant.nodeKey)
2297
+ ) {
2298
+ throw new ApplicationError("invalid-data", `Run grant targets unknown node ${parsedGrant.nodeKey}.`);
2299
+ }
2300
+ if (
2301
+ parsedGrant.kind === "compute-replay"
2302
+ && parsedGrant.bundleSha256 !== identity.graphPlan.bundle.bundleSha256
2303
+ ) {
2304
+ throw new ApplicationError("invalid-data", "Compute replay grant targets a different bundle.");
2305
+ }
2306
+ if (parsedGrant.kind === "compute-replay") {
2307
+ const authored = identity.graphPlan.graph.nodes.find(
2308
+ node => node.key === parsedGrant.nodeKey,
2309
+ );
2310
+ if (authored === undefined || !isComputeGraphNode(authored)) {
2311
+ throw new ApplicationError(
2312
+ "invalid-data",
2313
+ `Compute replay grant targets a non-compute node: ${parsedGrant.nodeKey}`,
2314
+ );
2315
+ }
2316
+ const records = await this.#loadNodes(identity);
2317
+ const record = records.find(node => node.nodeKey === parsedGrant.nodeKey);
2318
+ if (
2319
+ record?.status !== "ambiguous-code"
2320
+ || record.executionPlan === undefined
2321
+ || parsedGrant.computeKey !== authored.executor.compute.key
2322
+ || parsedGrant.nodePlanSha256 !== record.executionPlan.nodePlanSha256
2323
+ || parsedGrant.attempt !== record.attempt + 1
2324
+ ) {
2325
+ throw new ApplicationError(
2326
+ "conflict",
2327
+ `Compute replay grant does not match the exact next attempt for ${parsedGrant.nodeKey}.`,
2328
+ );
2329
+ }
2330
+ }
2331
+ const cached = this.#claimSession(parsedFence)?.grantJournal;
2332
+ const appended = await appendJournalEntry(
2333
+ join(directory, "grants.jsonl"),
2334
+ RunGrantSchema,
2335
+ entries => validateGrantLedger(
2336
+ entries,
2337
+ parsedFence.runId,
2338
+ identity.graphPlan.graphPlanSha256,
2339
+ ),
2340
+ ({ cursor }) => {
2341
+ if (cursor.grantIds.has(parsedGrant.grantId)) {
2342
+ throw new ApplicationError("conflict", `Run grant already exists: ${parsedGrant.grantId}`);
2343
+ }
2344
+ return { entry: parsedGrant, result: parsedGrant };
2345
+ },
2346
+ (cursor, persisted) => {
2347
+ cursor.grantIds.add(persisted.grantId);
2348
+ return cursor;
2349
+ },
2350
+ cached,
2351
+ );
2352
+ this.#cacheClaimSession(parsedFence, { grantJournal: appended.state });
2353
+ return appended.result;
2354
+ });
2355
+ }
2356
+
2357
+ async reopenComputeNode(fence: RunFence, nodeKey: string): Promise<RunNodeRecord> {
2358
+ const parsedFence = RunFenceSchema.parse(fence);
2359
+ const parsedKey = NodeKeySchema.parse(nodeKey);
2360
+ return await this.#withPhysicalLock(parsedFence.runId, async directory => {
2361
+ await this.#assertFenceIn(directory, parsedFence);
2362
+ const identity = await this.#identityForFence(directory, parsedFence);
2363
+ const authored = identity.graphPlan.graph.nodes.find(
2364
+ node => node.key === parsedKey,
2365
+ );
2366
+ if (authored === undefined || !isComputeGraphNode(authored)) {
2367
+ throw new ApplicationError(
2368
+ "invalid-data",
2369
+ `Only a trusted compute node may be reopened: ${parsedKey}`,
2370
+ );
2371
+ }
2372
+ const records = await this.#loadNodes(identity);
2373
+ const current = records.find(node => node.nodeKey === parsedKey);
2374
+ if (
2375
+ current?.status !== "ambiguous-code"
2376
+ || current.executionPlan === undefined
2377
+ ) {
2378
+ throw new ApplicationError(
2379
+ "conflict",
2380
+ `Compute node is not awaiting an explicit replay: ${parsedKey}`,
2381
+ );
2382
+ }
2383
+ const grants = await readJsonLines(
2384
+ join(directory, "grants.jsonl"),
2385
+ RunGrantSchema,
2386
+ );
2387
+ validateGrantLedger(
2388
+ grants,
2389
+ parsedFence.runId,
2390
+ identity.graphPlan.graphPlanSha256,
2391
+ );
2392
+ const authorized = grants.some(grant => (
2393
+ grant.kind === "compute-replay"
2394
+ && grant.nodeKey === parsedKey
2395
+ && grant.attempt === current.attempt + 1
2396
+ && grant.bundleSha256 === identity.graphPlan.bundle.bundleSha256
2397
+ && grant.computeKey === authored.executor.compute.key
2398
+ && grant.nodePlanSha256 === current.executionPlan?.nodePlanSha256
2399
+ ));
2400
+ if (!authorized) {
2401
+ throw new ApplicationError(
2402
+ "authorization-required",
2403
+ `Compute node ${parsedKey} requires an exact replay grant for attempt ${String(current.attempt + 1)}.`,
2404
+ );
2405
+ }
2406
+ const retained: Record<string, unknown> = { ...current };
2407
+ delete retained.failure;
2408
+ delete retained.finishedAt;
2409
+ const reopened = RunNodeRecordSchema.parse({
2410
+ ...retained,
2411
+ status: "ready",
2412
+ });
2413
+ const recordsByKey = new Map(records.map(record => [
2414
+ record.nodeKey,
2415
+ record.nodeKey === parsedKey ? reopened : record,
2416
+ ]));
2417
+ this.#validateNodeRecord(
2418
+ identity.graphPlan,
2419
+ authored,
2420
+ reopened,
2421
+ recordsByKey,
2422
+ );
2423
+ await writeJsonAtomic(
2424
+ join(
2425
+ await this.#nodeDirectory(directory),
2426
+ nodeRecordFilename(parsedKey),
2427
+ ),
2428
+ reopened,
2429
+ );
2430
+ return reopened;
2431
+ });
2432
+ }
2433
+
2434
+ async requestCancellation(
2435
+ runId: string,
2436
+ requestedBy: string,
2437
+ now = new Date(),
2438
+ ): Promise<CancellationRequest> {
2439
+ return await this.#withPhysicalLock(runId, async directory => {
2440
+ await this.#readIdentity(directory, runId);
2441
+ const path = join(directory, "cancel-request.json");
2442
+ const existing = await readJson(path, CancellationRequestSchema).catch(error => {
2443
+ if (error instanceof ApplicationError && error.code === "not-found") return undefined;
2444
+ throw error;
2445
+ });
2446
+ if (existing !== undefined) return existing;
2447
+ const request = CancellationRequestSchema.parse({
2448
+ requestedAt: now.toISOString(),
2449
+ requestedBy,
2450
+ runId,
2451
+ version: RUN_STORE_VERSION,
2452
+ });
2453
+ try {
2454
+ await publishJsonNoReplace(path, request);
2455
+ return request;
2456
+ } catch (error) {
2457
+ const published = await readJson(path, CancellationRequestSchema).catch(() => undefined);
2458
+ if (published !== undefined) return published;
2459
+ throw error;
2460
+ }
2461
+ });
2462
+ }
2463
+
2464
+ async cancellation(runId: string): Promise<CancellationRequest | undefined> {
2465
+ const directory = await this.#runDirectory(runId);
2466
+ return await readJson(join(directory, "cancel-request.json"), CancellationRequestSchema)
2467
+ .then(request => {
2468
+ if (request.runId !== runId) {
2469
+ throw new ApplicationError("invalid-data", "Cancellation request targets a different run.");
2470
+ }
2471
+ return request;
2472
+ })
2473
+ .catch(error => {
2474
+ if (error instanceof ApplicationError && error.code === "not-found") return undefined;
2475
+ throw error;
2476
+ });
2477
+ }
2478
+ }