@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.
- package/LICENSE +21 -0
- package/NOTICE.md +60 -0
- package/PRIVACY.md +46 -0
- package/README.md +311 -0
- package/SECURITY.md +21 -0
- package/apps/desktop/README.md +966 -0
- package/apps/desktop/analysis/protocol.ts +223 -0
- package/apps/desktop/application/capability-binding.ts +594 -0
- package/apps/desktop/application/context.ts +97 -0
- package/apps/desktop/application/creative-iteration.ts +1385 -0
- package/apps/desktop/application/default-registry.ts +146 -0
- package/apps/desktop/application/directing-contract.ts +218 -0
- package/apps/desktop/application/directing-plan.ts +178 -0
- package/apps/desktop/application/errors.ts +18 -0
- package/apps/desktop/application/gateway-port.ts +867 -0
- package/apps/desktop/application/html-overlay-browser-runtime.ts +687 -0
- package/apps/desktop/application/html-overlay-integrity.ts +337 -0
- package/apps/desktop/application/html-overlay-renderer.ts +45 -0
- package/apps/desktop/application/index.ts +13 -0
- package/apps/desktop/application/operation-completion-checkpoint.ts +333 -0
- package/apps/desktop/application/operation-effects.ts +94 -0
- package/apps/desktop/application/operation.ts +224 -0
- package/apps/desktop/application/operations/analysis/faces.ts +244 -0
- package/apps/desktop/application/operations/analysis/music.ts +248 -0
- package/apps/desktop/application/operations/analysis/project-auto-zooms.ts +302 -0
- package/apps/desktop/application/operations/analysis/project-inactivity.ts +381 -0
- package/apps/desktop/application/operations/analysis/scenes.ts +272 -0
- package/apps/desktop/application/operations/analysis/shared.ts +202 -0
- package/apps/desktop/application/operations/derive/edit-batch.ts +967 -0
- package/apps/desktop/application/operations/derive/follow-faces.ts +454 -0
- package/apps/desktop/application/operations/edit/bind-candidate-revision.ts +91 -0
- package/apps/desktop/application/operations/edit/create-candidate-revision.ts +377 -0
- package/apps/desktop/application/operations/edit/create-revision.ts +255 -0
- package/apps/desktop/application/operations/edit/freeze-revision.ts +182 -0
- package/apps/desktop/application/operations/gateway/image.ts +41 -0
- package/apps/desktop/application/operations/gateway/index.ts +17 -0
- package/apps/desktop/application/operations/gateway/shared.ts +122 -0
- package/apps/desktop/application/operations/gateway/speech.ts +41 -0
- package/apps/desktop/application/operations/gateway/transcription.ts +42 -0
- package/apps/desktop/application/operations/gateway/video.ts +41 -0
- package/apps/desktop/application/operations/index.ts +31 -0
- package/apps/desktop/application/operations/iteration/create-candidate.ts +240 -0
- package/apps/desktop/application/operations/iteration/create-matrix.ts +117 -0
- package/apps/desktop/application/operations/iteration/select.ts +155 -0
- package/apps/desktop/application/operations/iteration/shared.ts +250 -0
- package/apps/desktop/application/operations/media/audio-effects.ts +376 -0
- package/apps/desktop/application/operations/media/binding.ts +79 -0
- package/apps/desktop/application/operations/media/capabilities.ts +112 -0
- package/apps/desktop/application/operations/media/color-grade.ts +343 -0
- package/apps/desktop/application/operations/media/html-overlay.ts +802 -0
- package/apps/desktop/application/operations/media/index.ts +8 -0
- package/apps/desktop/application/operations/media/ingest.ts +303 -0
- package/apps/desktop/application/operations/media/overlay.ts +729 -0
- package/apps/desktop/application/operations/media/shared.ts +845 -0
- package/apps/desktop/application/operations/media/transform-platform.ts +120 -0
- package/apps/desktop/application/operations/project/commit-edits.ts +2054 -0
- package/apps/desktop/application/operations/project/promote-selection.ts +495 -0
- package/apps/desktop/application/operations/project/snapshot.ts +95 -0
- package/apps/desktop/application/operations/recording/index.ts +5 -0
- package/apps/desktop/application/operations/recording/pause.ts +34 -0
- package/apps/desktop/application/operations/recording/resume.ts +34 -0
- package/apps/desktop/application/operations/recording/shared.ts +199 -0
- package/apps/desktop/application/operations/recording/start.ts +169 -0
- package/apps/desktop/application/operations/recording/stop.ts +34 -0
- package/apps/desktop/application/operations/render/bind-candidate-output.ts +324 -0
- package/apps/desktop/application/operations/render/materialize-selection.ts +322 -0
- package/apps/desktop/application/operations/render/project-caption-plan.ts +287 -0
- package/apps/desktop/application/operations/render/project-plan.ts +939 -0
- package/apps/desktop/application/operations/render/project-spatial-receipt.ts +70 -0
- package/apps/desktop/application/operations/render/project.ts +1922 -0
- package/apps/desktop/application/operations/shared.ts +67 -0
- package/apps/desktop/application/operations/slopcamera-portable.ts +1188 -0
- package/apps/desktop/application/operations/slopcamera-visuals.ts +784 -0
- package/apps/desktop/application/operations/spatial-project.ts +209 -0
- package/apps/desktop/application/operations/spatial-render.ts +415 -0
- package/apps/desktop/application/operations/spatial-scene.ts +97 -0
- package/apps/desktop/application/operations/studio.ts +143 -0
- package/apps/desktop/application/output-publication-lease.ts +163 -0
- package/apps/desktop/application/project-publication-lease.ts +233 -0
- package/apps/desktop/application/project-store.ts +196 -0
- package/apps/desktop/application/receipts.ts +533 -0
- package/apps/desktop/application/registry.ts +182 -0
- package/apps/desktop/application/spatial-assets.ts +675 -0
- package/apps/desktop/application/spatial-compositor-cadence.ts +23 -0
- package/apps/desktop/application/spatial-project-authority.ts +35 -0
- package/apps/desktop/application/spatial-project-lease.ts +126 -0
- package/apps/desktop/application/spatial-project-render-preparation.ts +225 -0
- package/apps/desktop/application/spatial-project-store.ts +286 -0
- package/apps/desktop/application/spatial-render-projection.ts +201 -0
- package/apps/desktop/application/spatial-render.ts +677 -0
- package/apps/desktop/application/spatial-spz.ts +44 -0
- package/apps/desktop/application/spatial-world-import.ts +135 -0
- package/apps/desktop/application/spatial-world-provenance.ts +46 -0
- package/apps/desktop/application/studio-port.ts +58 -0
- package/apps/desktop/application/verified-receipt-reconciliation.ts +1574 -0
- package/apps/desktop/capture/protocol.ts +689 -0
- package/apps/desktop/cli/analyzer.ts +406 -0
- package/apps/desktop/cli/args.ts +3181 -0
- package/apps/desktop/cli/asset-ingest.ts +747 -0
- package/apps/desktop/cli/atomic-render-effects.ts +210 -0
- package/apps/desktop/cli/atomic-render-platform.ts +205 -0
- package/apps/desktop/cli/atomic-render.ts +50 -0
- package/apps/desktop/cli/audio-analysis.ts +252 -0
- package/apps/desktop/cli/bounded-map.ts +39 -0
- package/apps/desktop/cli/bundle-service.ts +256 -0
- package/apps/desktop/cli/capabilities.ts +212 -0
- package/apps/desktop/cli/capture-bundle.ts +1117 -0
- package/apps/desktop/cli/command-host-resources.ts +251 -0
- package/apps/desktop/cli/commands.ts +6836 -0
- package/apps/desktop/cli/directing-blob.ts +291 -0
- package/apps/desktop/cli/directing-media.ts +517 -0
- package/apps/desktop/cli/directing-quote.ts +89 -0
- package/apps/desktop/cli/directing-service.ts +288 -0
- package/apps/desktop/cli/emoji-assets.ts +538 -0
- package/apps/desktop/cli/errors.ts +29 -0
- package/apps/desktop/cli/face-analysis-service.ts +940 -0
- package/apps/desktop/cli/filter-script.ts +76 -0
- package/apps/desktop/cli/gateway-application-port.ts +1142 -0
- package/apps/desktop/cli/gateway-credential.ts +81 -0
- package/apps/desktop/cli/gateway-media-artifacts.ts +723 -0
- package/apps/desktop/cli/gateway-media-catalog.ts +922 -0
- package/apps/desktop/cli/gateway-media-service.ts +3106 -0
- package/apps/desktop/cli/gateway-media-signature.ts +122 -0
- package/apps/desktop/cli/gateway-provider-options.ts +562 -0
- package/apps/desktop/cli/gateway-scene-provider.ts +391 -0
- package/apps/desktop/cli/help.ts +501 -0
- package/apps/desktop/cli/html-overlay-gpu.ts +97 -0
- package/apps/desktop/cli/html-overlay-renderer.ts +2478 -0
- package/apps/desktop/cli/io.ts +290 -0
- package/apps/desktop/cli/main.ts +123 -0
- package/apps/desktop/cli/media-effects-service.ts +885 -0
- package/apps/desktop/cli/media-ingest-model.ts +364 -0
- package/apps/desktop/cli/media-ingest-platform.ts +227 -0
- package/apps/desktop/cli/media-ingest-program.ts +200 -0
- package/apps/desktop/cli/media-ingest.ts +30 -0
- package/apps/desktop/cli/music-analysis-service.ts +434 -0
- package/apps/desktop/cli/mutation-lock.ts +530 -0
- package/apps/desktop/cli/native-assets.d.ts +12 -0
- package/apps/desktop/cli/native-media-runtime.macos.ts +7 -0
- package/apps/desktop/cli/overlay-playback.ts +177 -0
- package/apps/desktop/cli/paths.ts +265 -0
- package/apps/desktop/cli/portable-surface.ts +242 -0
- package/apps/desktop/cli/project-camera-receipt.ts +112 -0
- package/apps/desktop/cli/project-face-camera.ts +394 -0
- package/apps/desktop/cli/project-inactivity-service.ts +552 -0
- package/apps/desktop/cli/project-media-integrity.ts +121 -0
- package/apps/desktop/cli/project-renderer.ts +1526 -0
- package/apps/desktop/cli/project-service.ts +519 -0
- package/apps/desktop/cli/project-state-transaction.ts +520 -0
- package/apps/desktop/cli/query-limits.ts +1 -0
- package/apps/desktop/cli/recording-controller.ts +1567 -0
- package/apps/desktop/cli/recording-daemon.ts +931 -0
- package/apps/desktop/cli/recording-ref.ts +56 -0
- package/apps/desktop/cli/renamed-environment.ts +6 -0
- package/apps/desktop/cli/renderer.ts +1439 -0
- package/apps/desktop/cli/run-cli-test-helper.ts +60 -0
- package/apps/desktop/cli/scene-analysis-service.ts +879 -0
- package/apps/desktop/cli/spatial-compositor-verifier.ts +213 -0
- package/apps/desktop/cli/spatial-project-service.ts +90 -0
- package/apps/desktop/cli/spatial-scene-service.ts +105 -0
- package/apps/desktop/cli/spatial-world-service.ts +39 -0
- package/apps/desktop/cli/speech-analysis-service.ts +682 -0
- package/apps/desktop/cli/studio-assemble.ts +58 -0
- package/apps/desktop/cli/studio-command.ts +60 -0
- package/apps/desktop/cli/studio-custody.ts +60 -0
- package/apps/desktop/cli/studio-encode.ts +216 -0
- package/apps/desktop/cli/studio-exr.ts +52 -0
- package/apps/desktop/cli/studio-files.ts +179 -0
- package/apps/desktop/cli/studio-output-validation.ts +162 -0
- package/apps/desktop/cli/studio-process.ts +145 -0
- package/apps/desktop/cli/studio-runtime.ts +77 -0
- package/apps/desktop/cli/studio-scaffold.ts +70 -0
- package/apps/desktop/cli/studio-service.ts +301 -0
- package/apps/desktop/cli/studio-spatial-asset.ts +248 -0
- package/apps/desktop/cli/studio-template-names.ts +3 -0
- package/apps/desktop/cli/studio-workflow.ts +19 -0
- package/apps/desktop/cli/time.ts +25 -0
- package/apps/desktop/cli/workflow-code.ts +584 -0
- package/apps/desktop/cli/workflow-runs.ts +576 -0
- package/apps/desktop/code/advanced.ts +139 -0
- package/apps/desktop/code/application-node-planner.ts +1288 -0
- package/apps/desktop/code/compiler.ts +329 -0
- package/apps/desktop/code/contracts.ts +13 -0
- package/apps/desktop/code/creative-recipes.ts +94 -0
- package/apps/desktop/code/define-workflow.ts +115 -0
- package/apps/desktop/code/editing.ts +284 -0
- package/apps/desktop/code/file-candidate-provenance.ts +420 -0
- package/apps/desktop/code/file-candidate.ts +69 -0
- package/apps/desktop/code/graph-builder.ts +10 -0
- package/apps/desktop/code/host-resource-policy.ts +55 -0
- package/apps/desktop/code/plan-contracts.ts +159 -0
- package/apps/desktop/code/planning.ts +361 -0
- package/apps/desktop/code/public.ts +134 -0
- package/apps/desktop/code/reference-projection.ts +62 -0
- package/apps/desktop/code/run-contracts.ts +466 -0
- package/apps/desktop/code/run-store.ts +2478 -0
- package/apps/desktop/code/runtime-identity.ts +328 -0
- package/apps/desktop/code/scheduler.ts +3067 -0
- package/apps/desktop/code/semantic-builder.ts +1436 -0
- package/apps/desktop/code/source-bundle.ts +1641 -0
- package/apps/desktop/code/source-typecheck.ts +211 -0
- package/apps/desktop/code/testing.ts +79 -0
- package/apps/desktop/code/worker-client.ts +1675 -0
- package/apps/desktop/code/worker-effects.ts +141 -0
- package/apps/desktop/code/worker-entry.ts +435 -0
- package/apps/desktop/code/worker-lease-guardian.ts +179 -0
- package/apps/desktop/code/worker-process-identity.ts +484 -0
- package/apps/desktop/code/worker-protocol.ts +211 -0
- package/apps/desktop/code/workflow-effects.ts +132 -0
- package/apps/desktop/contracts/analysis.ts +949 -0
- package/apps/desktop/contracts/edit.ts +576 -0
- package/apps/desktop/contracts/index.ts +9 -0
- package/apps/desktop/contracts/media-effects.ts +139 -0
- package/apps/desktop/contracts/project-render.ts +644 -0
- package/apps/desktop/contracts/project.ts +740 -0
- package/apps/desktop/contracts/recording.ts +1434 -0
- package/apps/desktop/contracts/render.ts +334 -0
- package/apps/desktop/contracts/runtime.ts +155 -0
- package/apps/desktop/contracts/spatial-compositor.ts +58 -0
- package/apps/desktop/contracts/spatial-project.ts +159 -0
- package/apps/desktop/contracts/spatial-world.ts +48 -0
- package/apps/desktop/contracts/video-effects.ts +120 -0
- package/apps/desktop/core/alignment-apply.ts +400 -0
- package/apps/desktop/core/audio-alignment.ts +466 -0
- package/apps/desktop/core/auto-zoom.ts +209 -0
- package/apps/desktop/core/canonical-json.ts +14 -0
- package/apps/desktop/core/emoji.ts +210 -0
- package/apps/desktop/core/events.ts +409 -0
- package/apps/desktop/core/face-framing.ts +533 -0
- package/apps/desktop/core/face-tracks.ts +326 -0
- package/apps/desktop/core/inactivity.ts +137 -0
- package/apps/desktop/core/index.ts +30 -0
- package/apps/desktop/core/intervals.ts +123 -0
- package/apps/desktop/core/montage.ts +236 -0
- package/apps/desktop/core/music-analysis.ts +518 -0
- package/apps/desktop/core/plan.ts +244 -0
- package/apps/desktop/core/project-auto-zoom.ts +75 -0
- package/apps/desktop/core/project-camera-spatial.ts +676 -0
- package/apps/desktop/core/project-camera.ts +403 -0
- package/apps/desktop/core/project-captions.ts +888 -0
- package/apps/desktop/core/project-inactivity.ts +125 -0
- package/apps/desktop/core/project-metadata.ts +927 -0
- package/apps/desktop/core/project-plan.ts +375 -0
- package/apps/desktop/core/project-render-plan.ts +303 -0
- package/apps/desktop/core/project-time.ts +469 -0
- package/apps/desktop/core/render-plan.ts +442 -0
- package/apps/desktop/core/scene-cache.ts +174 -0
- package/apps/desktop/core/scene-sampling.ts +448 -0
- package/apps/desktop/core/spatial-compositor.ts +22 -0
- package/apps/desktop/core/spatial-durability.ts +99 -0
- package/apps/desktop/core/spatial-project.ts +169 -0
- package/apps/desktop/core/spatial-shot-clock.ts +32 -0
- package/apps/desktop/core/speech-analysis.ts +333 -0
- package/apps/desktop/core/storage.ts +970 -0
- package/apps/desktop/core/time-map.ts +91 -0
- package/apps/desktop/core/time.ts +61 -0
- package/apps/desktop/core/video-effects.ts +660 -0
- package/apps/desktop/dist/cli/NebulaSans-Bold-26se8aek.otf +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Bold-bcz7y08t.woff2 +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Book-5ax05zvn.woff2 +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Book-8cenzchw.otf +0 -0
- package/apps/desktop/dist/cli/main.js +5086 -0
- package/apps/desktop/html-overlay/catalog.ts +171 -0
- package/apps/desktop/html-overlay/contracts.ts +372 -0
- package/apps/desktop/html-overlay/execution-profile.ts +77 -0
- package/apps/desktop/html-overlay/index.ts +98 -0
- package/apps/desktop/html-overlay/libraries.ts +263 -0
- package/apps/desktop/html-overlay/random.ts +49 -0
- package/apps/desktop/html-overlay/runtime.ts +813 -0
- package/apps/desktop/html-overlay/scaffolds.ts +792 -0
- package/apps/desktop/html-overlay/spark-runtime.ts +88 -0
- package/apps/desktop/html-overlay/spatial-splat-runtime.ts +60 -0
- package/apps/desktop/html-overlay/spatial.ts +720 -0
- package/apps/desktop/studio/assets/README.md +39 -0
- package/apps/desktop/studio/assets/contracts.ts +129 -0
- package/apps/desktop/studio/assets/network.ts +64 -0
- package/apps/desktop/studio/assets/poly-haven.ts +148 -0
- package/apps/desktop/studio/drivers/blender_driver.py +466 -0
- package/apps/desktop/studio/drivers/cadquery_driver.py +176 -0
- package/apps/desktop/studio/education/driver.py +308 -0
- package/apps/desktop/studio/education/lesson.py +180 -0
- package/apps/desktop/studio/education/toolkit.py +249 -0
- package/apps/desktop/workflows/chaptered-demo.ts +48 -0
- package/apps/desktop/workflows/creative-iteration.ts +107 -0
- package/apps/desktop/workflows/creative-selection.ts +92 -0
- package/apps/desktop/workflows/fragments.ts +486 -0
- package/apps/desktop/workflows/index.ts +93 -0
- package/apps/desktop/workflows/polished-screen-demo.ts +128 -0
- package/apps/desktop/workflows/scene-direction.ts +12 -0
- package/apps/desktop/workflows/social-variants.ts +75 -0
- package/apps/desktop/workflows/talking-head-cleanup.ts +53 -0
- package/dist/NebulaSans-Bold-26se8aek.otf +0 -0
- package/dist/NebulaSans-Bold-bcz7y08t.woff2 +0 -0
- package/dist/NebulaSans-Book-5ax05zvn.woff2 +0 -0
- package/dist/NebulaSans-Book-8cenzchw.otf +0 -0
- package/dist/cli.js +479 -0
- package/dist/code/advanced.js +270 -0
- package/dist/code/index.js +2240 -0
- package/dist/generate.js +24 -0
- package/dist/host-resources.js +26 -0
- package/dist/index-42zsesc1.js +2257 -0
- package/dist/index-7308egqr.js +48 -0
- package/dist/index-8txs6fkn.js +712 -0
- package/dist/index-mcy8z0br.js +192 -0
- package/dist/index-p63wavx0.js +2029 -0
- package/dist/index-r7gdhmsp.js +424 -0
- package/dist/index-sh6xbav6.js +1219 -0
- package/dist/index-z1w83f81.js +4 -0
- package/dist/index-z7239b4h.js +2363 -0
- package/dist/index-zfeg9hmw.js +2013 -0
- package/dist/index.js +241 -0
- package/dist/operations.js +29 -0
- package/dist/skill-install-dh0ntqba.js +10 -0
- package/dist/vectorize/worker.js +142 -0
- package/dist/workflow.js +16 -0
- package/docs/README.md +38 -0
- package/docs/studio.md +215 -0
- package/examples/studio/blender/README.md +26 -0
- package/examples/studio/blender/character.py +152 -0
- package/examples/studio/blender/cloth.py +101 -0
- package/examples/studio/blender/color_chart.py +29 -0
- package/examples/studio/blender/fluid.py +78 -0
- package/examples/studio/blender/import_model.py +74 -0
- package/examples/studio/blender/product.py +53 -0
- package/examples/studio/blender/shaded_street.py +481 -0
- package/examples/studio/blender/studio_scene.py +147 -0
- package/examples/studio/cadquery/README.md +13 -0
- package/examples/studio/cadquery/bracket.py +36 -0
- package/examples/studio/cadquery/import_step.py +12 -0
- package/examples/studio/education/README.md +46 -0
- package/examples/studio/education/lesson.json +99 -0
- package/examples/studio/education/scene.py +7 -0
- package/examples/studio/hybrid-scene.ts +96 -0
- package/examples/studio/native-workflow.ts +14 -0
- package/examples/studio/vgpu/README.md +64 -0
- package/examples/studio/vgpu/heat-field.mjs +290 -0
- package/package.json +304 -0
- package/packages/scene/src/index.ts +1 -0
- package/packages/scene/src/scene.ts +215 -0
- package/schema/diagram.schema.json +334 -0
- package/skills/slopcamera/SKILL.md +45 -0
- package/skills/slopcamera/agents/openai.yaml +4 -0
- package/skills/slopcamera/references/customization.md +77 -0
- package/skills/slopcamera/references/diagrams.md +153 -0
- package/skills/slopcamera/references/directed-scenes.md +42 -0
- package/skills/slopcamera/references/directing-video.md +18 -0
- package/skills/slopcamera/references/educational-video.md +26 -0
- package/skills/slopcamera/references/gateway-media.md +134 -0
- package/skills/slopcamera/references/install.md +62 -0
- package/skills/slopcamera/references/native-studio.md +31 -0
- package/skills/slopcamera/references/reference-led-3d.md +140 -0
- package/skills/slopcamera/references/rubber-stamp-examples/poster-example-1.jpg +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/poster-example-2.jpg +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/stamp-style-1.png +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/stamp-style-2.png +0 -0
- package/skills/slopcamera/references/rubber-stamp-field-notes.md +144 -0
- package/skills/slopcamera/references/vectorization.md +29 -0
- package/skills/slopcamera/references/video-projects.md +187 -0
- package/skills/slopcamera/references/visual-communication.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +45 -0
- package/skills/slopcamera/scripts/compose-rubber-stamp-field-note.ts +229 -0
- package/src/artifacts.ts +106 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +21 -0
- package/src/assets.d.ts +9 -0
- package/src/cli.ts +601 -0
- package/src/cloud-errors.ts +26 -0
- package/src/code/advanced.ts +10 -0
- package/src/code/boundary.ts +21 -0
- package/src/code/canonical-json.ts +129 -0
- package/src/code/compiler.ts +953 -0
- package/src/code/contracts.ts +576 -0
- package/src/code/define-workflow.ts +334 -0
- package/src/code/errors.ts +41 -0
- package/src/code/graph-builder.ts +945 -0
- package/src/code/index.ts +121 -0
- package/src/code/json-snapshot.ts +899 -0
- package/src/code/json-utf8.ts +88 -0
- package/src/code/portable-builder.ts +136 -0
- package/src/code/projection.ts +378 -0
- package/src/code/public-operations.ts +450 -0
- package/src/code/runtime.ts +580 -0
- package/src/code/sha256.ts +36 -0
- package/src/config.ts +180 -0
- package/src/desktop.ts +253 -0
- package/src/fs.ts +10 -0
- package/src/generate.ts +704 -0
- package/src/host-resource-posix.ts +161 -0
- package/src/host-resources.ts +1749 -0
- package/src/icons.ts +58 -0
- package/src/index.ts +200 -0
- package/src/label-layout.ts +130 -0
- package/src/layout.ts +413 -0
- package/src/lint.ts +127 -0
- package/src/mcp/boundary.ts +354 -0
- package/src/mcp/index.ts +33 -0
- package/src/mcp/server.ts +272 -0
- package/src/mcp/tools.ts +963 -0
- package/src/mcp/types.ts +71 -0
- package/src/operations.ts +775 -0
- package/src/parse.ts +861 -0
- package/src/process-environment.ts +17 -0
- package/src/render.ts +437 -0
- package/src/skill-install.ts +49 -0
- package/src/spatial-scene/authoring.ts +27 -0
- package/src/spatial-scene/camera-track.ts +83 -0
- package/src/spatial-scene/contracts.ts +277 -0
- package/src/spatial-scene/evaluate.ts +145 -0
- package/src/spatial-scene/gltf.ts +558 -0
- package/src/spatial-scene/identity.ts +260 -0
- package/src/spatial-scene/index.ts +10 -0
- package/src/spatial-scene/inspect.ts +68 -0
- package/src/spatial-scene/math.ts +280 -0
- package/src/spatial-scene/patch.ts +131 -0
- package/src/spatial-scene/time.ts +53 -0
- package/src/studio/contracts.ts +129 -0
- package/src/studio/index.ts +2 -0
- package/src/studio/plan.ts +156 -0
- package/src/studio/shared.ts +41 -0
- package/src/theme.ts +68 -0
- package/src/tldr.ts +474 -0
- package/src/types.ts +212 -0
- package/src/vectorize/archive.ts +101 -0
- package/src/vectorize/command.ts +797 -0
- package/src/vectorize/index.ts +27 -0
- package/src/vectorize/limits.ts +61 -0
- package/src/vectorize/metrics.ts +351 -0
- package/src/vectorize/pixels.ts +323 -0
- package/src/vectorize/supervisor.ts +339 -0
- package/src/vectorize/svg.ts +188 -0
- package/src/vectorize/tool.ts +542 -0
- package/src/vectorize/types.ts +113 -0
- package/src/vectorize/vectorize.ts +634 -0
- package/src/vectorize/worker-protocol.ts +40 -0
- package/src/vectorize/worker.ts +209 -0
- package/src/version.ts +1 -0
- 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
|
+
}
|