@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,3067 @@
|
|
|
1
|
+
import { studioAuthorizationRequest } from "../application/operations/studio";
|
|
2
|
+
import { Deferred, Effect, Either, Exit, Fiber, Queue } from "effect";
|
|
3
|
+
import { WorkflowEffectRuntime, workflowBoundary, workflowFailure, workflowValidation, workflowResource, workflowScoped, type WorkflowFailure } from "./workflow-effects";
|
|
4
|
+
import { operationExitValue } from "../application/operation-effects";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { performance } from "node:perf_hooks";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
HOST_RESOURCE_MAX_WAIT_MILLISECONDS,
|
|
10
|
+
HostResourceError,
|
|
11
|
+
type HostResourceCoordinator,
|
|
12
|
+
type HostResourceLease,
|
|
13
|
+
} from "@hraness/slopcamera/host-resources";
|
|
14
|
+
|
|
15
|
+
import { ApplicationError, asApplicationError } from "../application/errors";
|
|
16
|
+
import type { ApplicationContext } from "../application/context";
|
|
17
|
+
import { withProjectPublicationLease } from "../application/project-publication-lease";
|
|
18
|
+
import { withSpatialProjectLease } from "../application/spatial-project-lease";
|
|
19
|
+
import { SpatialProjectMutationOutputSchema } from "../application/operations/spatial-project";
|
|
20
|
+
import {
|
|
21
|
+
RESOURCE_KINDS,
|
|
22
|
+
type OperationResourceKind,
|
|
23
|
+
type OperationResumeClass,
|
|
24
|
+
} from "../application/operation";
|
|
25
|
+
import type {
|
|
26
|
+
OperationDiscovery,
|
|
27
|
+
OperationRegistry,
|
|
28
|
+
RegisteredOperation,
|
|
29
|
+
} from "../application/registry";
|
|
30
|
+
import {
|
|
31
|
+
canonicalJson,
|
|
32
|
+
canonicalJsonFingerprint,
|
|
33
|
+
} from "../core/canonical-json";
|
|
34
|
+
import {
|
|
35
|
+
JsonValueSchema,
|
|
36
|
+
NodeKeySchema,
|
|
37
|
+
OperationPolicySchema,
|
|
38
|
+
WorkflowNodePolicySchema,
|
|
39
|
+
isComputeGraphNode,
|
|
40
|
+
isOperationGraphNode,
|
|
41
|
+
trustedComputePolicy,
|
|
42
|
+
type AuthoredComputeGraphNodeV1,
|
|
43
|
+
type AuthoredGraphNodeV1,
|
|
44
|
+
type AuthoredOperationGraphNodeV1,
|
|
45
|
+
type GraphInputValue,
|
|
46
|
+
type GraphPlanV1,
|
|
47
|
+
type JsonValue,
|
|
48
|
+
type OperationResourceTotals,
|
|
49
|
+
type SerializedRefV1,
|
|
50
|
+
type WorkflowOutputBinding,
|
|
51
|
+
type WorkflowNodePolicy,
|
|
52
|
+
} from "./contracts";
|
|
53
|
+
import {
|
|
54
|
+
NODE_EXECUTION_PLAN_VERSION,
|
|
55
|
+
NODE_PREPARATION_PLAN_VERSION,
|
|
56
|
+
RUN_NODE_VERSION,
|
|
57
|
+
RUN_STORE_VERSION,
|
|
58
|
+
NodeExecutionPlanUnsignedSchema,
|
|
59
|
+
NodeExecutionPlanSchema,
|
|
60
|
+
NodePreparationPlanUnsignedSchema,
|
|
61
|
+
NodePreparationPlanSchema,
|
|
62
|
+
RunNodeRecordSchema,
|
|
63
|
+
RunSummarySchema,
|
|
64
|
+
createNodeExecutionPlanHash,
|
|
65
|
+
createNodeInputFingerprintFromParsed,
|
|
66
|
+
createNodePreparationPlanHash,
|
|
67
|
+
createRunNodeOutputFingerprintFromParsed,
|
|
68
|
+
type NewRunGrant,
|
|
69
|
+
type CancellationRequest,
|
|
70
|
+
type NodeExecutionPlan,
|
|
71
|
+
type NodePreparationPlan,
|
|
72
|
+
type RunFence,
|
|
73
|
+
type RunGrant,
|
|
74
|
+
type RunNodeOutput,
|
|
75
|
+
type RunNodeRecord,
|
|
76
|
+
type RunStatus,
|
|
77
|
+
type RunSummary,
|
|
78
|
+
} from "./run-contracts";
|
|
79
|
+
import type { RunStore } from "./run-store";
|
|
80
|
+
import { projectReferenceValue } from "./reference-projection";
|
|
81
|
+
import { physicalHostResourceClaims } from "./host-resource-policy";
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
NODE_EXECUTION_PLAN_HASH_DOMAIN,
|
|
85
|
+
NODE_INPUT_HASH_DOMAIN,
|
|
86
|
+
NODE_OUTPUT_HASH_DOMAIN,
|
|
87
|
+
NODE_PREPARATION_PLAN_HASH_DOMAIN,
|
|
88
|
+
} from "./run-contracts";
|
|
89
|
+
|
|
90
|
+
const MAX_SUMMARY_FIELDS = 32;
|
|
91
|
+
const MAX_SUMMARY_KEY_LENGTH = 128;
|
|
92
|
+
const MAX_SUMMARY_STRING_LENGTH = 2_000;
|
|
93
|
+
const MAX_FAILURE_MESSAGE_LENGTH = 4_000;
|
|
94
|
+
const DEFAULT_CANCELLATION_POLL_MS = 100;
|
|
95
|
+
|
|
96
|
+
export const DEFAULT_SCHEDULER_RESOURCE_LIMITS = Object.freeze({
|
|
97
|
+
browser: 1,
|
|
98
|
+
"capture-device": 1,
|
|
99
|
+
cpu: 4,
|
|
100
|
+
ffmpeg: 2,
|
|
101
|
+
"local-io": 4,
|
|
102
|
+
network: 4,
|
|
103
|
+
"output-publication": 4,
|
|
104
|
+
"paid-call": 1,
|
|
105
|
+
"project-render": 1,
|
|
106
|
+
"project-publication": 1,
|
|
107
|
+
vision: 1,
|
|
108
|
+
whisper: 1,
|
|
109
|
+
}) satisfies OperationResourceTotals;
|
|
110
|
+
|
|
111
|
+
export interface SchedulerHostLimits {
|
|
112
|
+
readonly maxJobs?: number;
|
|
113
|
+
readonly maxNodes?: number;
|
|
114
|
+
readonly maxWallClockMs?: number;
|
|
115
|
+
readonly resources?: Partial<OperationResourceTotals>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SchedulerNodeContext {
|
|
119
|
+
readonly graphPlan: GraphPlanV1;
|
|
120
|
+
readonly node: AuthoredOperationGraphNodeV1;
|
|
121
|
+
readonly operation: OperationDiscovery;
|
|
122
|
+
readonly resolvedInput: JsonValue;
|
|
123
|
+
readonly runId: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface NodePreparationBinding {
|
|
127
|
+
readonly inputDescriptors: JsonValue;
|
|
128
|
+
readonly upperDurationMs?: number;
|
|
129
|
+
readonly upperInputBytes?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface NodeExecutionBinding {
|
|
133
|
+
readonly exactInput: JsonValue;
|
|
134
|
+
readonly expectedProjectGeneration?: string;
|
|
135
|
+
readonly publicationKeys: readonly string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface NodePreparationRequest extends SchedulerNodeContext {
|
|
139
|
+
readonly dependencyOutputs: Readonly<Record<string, RunNodeOutput>>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface NodeExecutionPlanningRequest extends NodePreparationRequest {
|
|
143
|
+
/** Ephemeral admission for fixed native runtime probing, never graph authority. */
|
|
144
|
+
readonly application?: ApplicationContext;
|
|
145
|
+
readonly abortSignal?: AbortSignal;
|
|
146
|
+
readonly beforePublication?: () => Promise<void>;
|
|
147
|
+
readonly preparationPlan: NodePreparationPlan;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface NodeReconciliationRequest extends SchedulerNodeContext {
|
|
151
|
+
/**
|
|
152
|
+
* The scheduler-scoped application carries the active machine lease and
|
|
153
|
+
* inherited descriptor for every reconciliation-side process.
|
|
154
|
+
*/
|
|
155
|
+
readonly application: ApplicationContext;
|
|
156
|
+
/**
|
|
157
|
+
* Aborted as soon as cancellation, the workflow deadline, or scheduler
|
|
158
|
+
* monitoring invalidates this reconciliation attempt.
|
|
159
|
+
*/
|
|
160
|
+
readonly abortSignal: AbortSignal;
|
|
161
|
+
/**
|
|
162
|
+
* Reasserts the durable run claim, cancellation marker, and wall-clock
|
|
163
|
+
* deadline. Reconcilers must call this immediately before every write.
|
|
164
|
+
*/
|
|
165
|
+
readonly beforePublication: () => Promise<void>;
|
|
166
|
+
readonly dependencyOutputs: Readonly<Record<string, RunNodeOutput>>;
|
|
167
|
+
readonly executionPlan: NodeExecutionPlan | undefined;
|
|
168
|
+
readonly previous: RunNodeRecord;
|
|
169
|
+
readonly preparationPlan: NodePreparationPlan | undefined;
|
|
170
|
+
readonly resumeClass: OperationResumeClass;
|
|
171
|
+
readonly workspaceDirectory?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type NodeReconciliation =
|
|
175
|
+
| {
|
|
176
|
+
readonly kind: "completed";
|
|
177
|
+
readonly output: JsonValue;
|
|
178
|
+
readonly receiptReference?: string;
|
|
179
|
+
readonly summary: Readonly<Record<string, boolean | null | number | string>>;
|
|
180
|
+
}
|
|
181
|
+
| {
|
|
182
|
+
readonly kind: "retry";
|
|
183
|
+
}
|
|
184
|
+
| {
|
|
185
|
+
readonly kind: "ambiguous" | "incompatible";
|
|
186
|
+
readonly message: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export interface SchedulerNodePlanner {
|
|
190
|
+
prepare(request: NodePreparationRequest): Promise<NodePreparationBinding>;
|
|
191
|
+
plan(request: NodeExecutionPlanningRequest): Promise<NodeExecutionBinding>;
|
|
192
|
+
reconcile?(request: NodeReconciliationRequest): Promise<NodeReconciliation>;
|
|
193
|
+
/** Local owner composition only; never serialized into plans or operation identity. */
|
|
194
|
+
reconcileEffect?(request: NodeReconciliationRequest): Effect.Effect<NodeReconciliation, WorkflowFailure>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface SchedulerAuthorizationRequest {
|
|
198
|
+
readonly executor: AuthoredGraphNodeV1["executor"];
|
|
199
|
+
readonly graphPlanSha256: string;
|
|
200
|
+
readonly nodeKey: string;
|
|
201
|
+
readonly operation?: OperationDiscovery;
|
|
202
|
+
readonly policy: WorkflowNodePolicy;
|
|
203
|
+
readonly runId: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface PreparationAuthorizationRequest extends SchedulerAuthorizationRequest {
|
|
207
|
+
readonly preparationPlan: NodePreparationPlan;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface EffectAuthorizationRequest extends SchedulerAuthorizationRequest {
|
|
211
|
+
readonly executionPlan: NodeExecutionPlan;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type SchedulerAuthorizationDecision =
|
|
215
|
+
| boolean
|
|
216
|
+
| {
|
|
217
|
+
readonly allowed: boolean;
|
|
218
|
+
readonly reason?: string;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export interface SchedulerAuthorization {
|
|
222
|
+
readonly grantedBy: string;
|
|
223
|
+
authorizeEffect(
|
|
224
|
+
request: EffectAuthorizationRequest,
|
|
225
|
+
): Promise<SchedulerAuthorizationDecision>;
|
|
226
|
+
authorizePreparation(
|
|
227
|
+
request: PreparationAuthorizationRequest,
|
|
228
|
+
): Promise<SchedulerAuthorizationDecision>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface SchedulerApprovalPause {
|
|
232
|
+
readonly nodeKey: string;
|
|
233
|
+
readonly phase: "effect" | "preparation";
|
|
234
|
+
readonly planSha256: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface SchedulerRunResult {
|
|
238
|
+
readonly pause?: SchedulerApprovalPause;
|
|
239
|
+
readonly summary: RunSummary;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface SchedulerComputeExecutor {
|
|
243
|
+
readonly bundleSha256: string;
|
|
244
|
+
execute(request: {
|
|
245
|
+
readonly abortSignal: AbortSignal;
|
|
246
|
+
readonly computeKey: string;
|
|
247
|
+
readonly inheritedHostResourceFileDescriptor?: number;
|
|
248
|
+
readonly input: JsonValue;
|
|
249
|
+
readonly nodeKey: string;
|
|
250
|
+
readonly replayAcknowledged: boolean;
|
|
251
|
+
readonly timeoutMs: number;
|
|
252
|
+
}): Promise<JsonValue>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface SchedulerComputeExecutorLease {
|
|
256
|
+
readonly executor: SchedulerComputeExecutor;
|
|
257
|
+
release(): Promise<void>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export type SchedulerComputeRuntime =
|
|
261
|
+
| {
|
|
262
|
+
readonly executor: SchedulerComputeExecutor;
|
|
263
|
+
readonly kind: "fresh";
|
|
264
|
+
}
|
|
265
|
+
| {
|
|
266
|
+
readonly kind: "replay";
|
|
267
|
+
load(options: {
|
|
268
|
+
readonly signal: AbortSignal;
|
|
269
|
+
readonly waitTimeoutMilliseconds: number;
|
|
270
|
+
}): Promise<SchedulerComputeExecutorLease>;
|
|
271
|
+
readonly nodeKeys: readonly string[];
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export interface DurableWorkflowSchedulerOptions {
|
|
275
|
+
readonly application: ApplicationContext;
|
|
276
|
+
readonly authorization: SchedulerAuthorization;
|
|
277
|
+
readonly cancellationPollMs?: number;
|
|
278
|
+
readonly compute?: SchedulerComputeRuntime;
|
|
279
|
+
readonly currentApplicationBuild: string;
|
|
280
|
+
/**
|
|
281
|
+
* One machine-scoped physical admission authority. Local scheduler limits
|
|
282
|
+
* remain a fast conservative prefilter; this coordinator is authoritative
|
|
283
|
+
* across independent Slopcamera processes.
|
|
284
|
+
*/
|
|
285
|
+
readonly hostResourceCoordinator: HostResourceCoordinator;
|
|
286
|
+
readonly hostLimits?: SchedulerHostLimits;
|
|
287
|
+
readonly jobs?: number;
|
|
288
|
+
readonly nodePlanner: SchedulerNodePlanner;
|
|
289
|
+
readonly now?: () => Date;
|
|
290
|
+
readonly owner: string;
|
|
291
|
+
readonly registry: OperationRegistry;
|
|
292
|
+
readonly store: Pick<
|
|
293
|
+
RunStore,
|
|
294
|
+
| "acquireClaim"
|
|
295
|
+
| "appendEvent"
|
|
296
|
+
| "appendGrant"
|
|
297
|
+
| "assertFence"
|
|
298
|
+
| "cancellation"
|
|
299
|
+
| "graphPlan"
|
|
300
|
+
| "grants"
|
|
301
|
+
| "node"
|
|
302
|
+
| "nodes"
|
|
303
|
+
| "releaseClaim"
|
|
304
|
+
| "reopenComputeNode"
|
|
305
|
+
| "runtime"
|
|
306
|
+
| "stagingDirectory"
|
|
307
|
+
| "summary"
|
|
308
|
+
| "writeNode"
|
|
309
|
+
| "writeSummary"
|
|
310
|
+
>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
interface NormalizedSchedulerLimits {
|
|
314
|
+
readonly cancellationPollMs: number;
|
|
315
|
+
readonly jobs: number;
|
|
316
|
+
readonly maxNodes: number;
|
|
317
|
+
readonly maxWallClockMs: number;
|
|
318
|
+
readonly resources: OperationResourceTotals;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface ResolvedNode {
|
|
322
|
+
readonly dependencyOutputs: Readonly<Record<string, RunNodeOutput>>;
|
|
323
|
+
readonly input: JsonValue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
interface PreparedNode {
|
|
327
|
+
readonly executionPlan: NodeExecutionPlan;
|
|
328
|
+
readonly executor: ResolvedNodeExecutor;
|
|
329
|
+
readonly record: RunNodeRecord;
|
|
330
|
+
readonly resolved: ResolvedNode;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface RunningNode {
|
|
334
|
+
readonly admissionAbortController: AbortController;
|
|
335
|
+
readonly abortController: AbortController;
|
|
336
|
+
readonly cancellable: boolean;
|
|
337
|
+
readonly executionPlan: NodeExecutionPlan;
|
|
338
|
+
readonly nodeKey: string;
|
|
339
|
+
readonly fiber: Fiber.RuntimeFiber<Exit.Exit<NodeExecutionOutcome, WorkflowFailure>, never>;
|
|
340
|
+
readonly resources: readonly {
|
|
341
|
+
readonly amount: number;
|
|
342
|
+
readonly resource: OperationResourceKind;
|
|
343
|
+
}[];
|
|
344
|
+
readonly publicationKeys: readonly string[];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
type NodeExecutionOutcome =
|
|
348
|
+
| {
|
|
349
|
+
readonly kind: "completed";
|
|
350
|
+
readonly output: RunNodeOutput;
|
|
351
|
+
}
|
|
352
|
+
| {
|
|
353
|
+
readonly failure: ReturnType<typeof schedulerFailure>;
|
|
354
|
+
readonly kind:
|
|
355
|
+
| "cancelled"
|
|
356
|
+
| "failed"
|
|
357
|
+
| "ambiguous"
|
|
358
|
+
| "ambiguous-code"
|
|
359
|
+
| "interrupted";
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
interface MutableRunState {
|
|
363
|
+
cancelled: boolean;
|
|
364
|
+
monitorError: ApplicationError | undefined;
|
|
365
|
+
pause: SchedulerApprovalPause | undefined;
|
|
366
|
+
reconciliationDeferred: boolean;
|
|
367
|
+
readonly startedAt: string;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
type ResolvedNodeExecutor =
|
|
371
|
+
| {
|
|
372
|
+
readonly kind: "operation";
|
|
373
|
+
readonly operation: RegisteredOperation;
|
|
374
|
+
readonly policy: OperationDiscovery["policy"];
|
|
375
|
+
}
|
|
376
|
+
| {
|
|
377
|
+
readonly compute: AuthoredComputeGraphNodeV1["executor"]["compute"];
|
|
378
|
+
readonly kind: "compute";
|
|
379
|
+
readonly policy: ReturnType<typeof trustedComputePolicy>;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
type NodeDeadlineKind = "node" | "workflow";
|
|
383
|
+
|
|
384
|
+
type WorkflowInterruptionKind = "cancellation" | "deadline" | "monitor";
|
|
385
|
+
|
|
386
|
+
interface WorkflowInterruption {
|
|
387
|
+
readonly error: ApplicationError;
|
|
388
|
+
readonly kind: WorkflowInterruptionKind;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function safeDeadline(startedAtMs: number, durationMs: number): number {
|
|
392
|
+
return durationMs > Number.MAX_SAFE_INTEGER - startedAtMs
|
|
393
|
+
? Number.MAX_SAFE_INTEGER
|
|
394
|
+
: startedAtMs + durationMs;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function waitUntil(deadlineMonotonicMs: number): Effect.Effect<void> {
|
|
398
|
+
return Effect.suspend(() => {
|
|
399
|
+
const remaining = deadlineMonotonicMs - performance.now();
|
|
400
|
+
return remaining <= 0 ? Effect.void : Effect.zipRight(
|
|
401
|
+
Effect.sleep(Math.min(remaining, 2_147_483_647)), waitUntil(deadlineMonotonicMs),
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
class WorkflowRunControl {
|
|
407
|
+
readonly #abortController = new AbortController();
|
|
408
|
+
readonly deadlineMonotonicMs: number;
|
|
409
|
+
readonly #interrupted: Deferred.Deferred<never, WorkflowFailure>;
|
|
410
|
+
readonly #onInterrupt: (interruption: WorkflowInterruption) => void;
|
|
411
|
+
readonly #owner: WorkflowEffectRuntime;
|
|
412
|
+
readonly #stopDeadline: () => Promise<void>;
|
|
413
|
+
#interruption: WorkflowInterruption | undefined;
|
|
414
|
+
|
|
415
|
+
constructor(
|
|
416
|
+
owner: WorkflowEffectRuntime,
|
|
417
|
+
deadlineMonotonicMs: number,
|
|
418
|
+
onInterrupt: (interruption: WorkflowInterruption) => void,
|
|
419
|
+
) {
|
|
420
|
+
this.#owner = owner;
|
|
421
|
+
this.deadlineMonotonicMs = deadlineMonotonicMs;
|
|
422
|
+
this.#onInterrupt = onInterrupt;
|
|
423
|
+
this.#interrupted = owner.deferred();
|
|
424
|
+
this.#stopDeadline = owner.watch(Effect.zipRight(waitUntil(deadlineMonotonicMs), Effect.sync(() => {
|
|
425
|
+
this.interrupt("deadline", new ApplicationError(
|
|
426
|
+
"cancelled", "Workflow exceeded the host wall-clock bound.",
|
|
427
|
+
));
|
|
428
|
+
})));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
get interruption(): WorkflowInterruption | undefined { return this.#interruption; }
|
|
432
|
+
get signal(): AbortSignal { return this.#abortController.signal; }
|
|
433
|
+
|
|
434
|
+
interrupt(kind: WorkflowInterruptionKind, error: ApplicationError): void {
|
|
435
|
+
if (this.#interruption !== undefined) return;
|
|
436
|
+
const interruption = { error, kind } satisfies WorkflowInterruption;
|
|
437
|
+
this.#interruption = interruption;
|
|
438
|
+
this.#abortController.abort(error);
|
|
439
|
+
this.#onInterrupt(interruption);
|
|
440
|
+
Deferred.unsafeDone(this.#interrupted, Effect.fail(workflowFailure(error, "monitor")));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
assertActive(): void {
|
|
444
|
+
if (this.#interruption !== undefined) throw this.#interruption.error;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async race<Value>(execute: () => Promise<Value>): Promise<Value> {
|
|
448
|
+
this.assertActive();
|
|
449
|
+
return await this.#owner.run(Effect.raceFirst(
|
|
450
|
+
workflowBoundary("authority", execute), Deferred.await(this.#interrupted),
|
|
451
|
+
));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async custody<Value>(program: Effect.Effect<Value, WorkflowFailure>): Promise<Value> {
|
|
455
|
+
return await this.#owner.custody(program);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async runNative<Value>(program: Effect.Effect<Value, WorkflowFailure>): Promise<Value> {
|
|
459
|
+
return await this.#owner.run(program);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async stop(): Promise<void> { await this.#stopDeadline(); }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function safePositiveInteger(value: number, name: string): number {
|
|
466
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
467
|
+
throw new ApplicationError("usage", `${name} must be a positive safe integer.`);
|
|
468
|
+
}
|
|
469
|
+
return value;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function safeNonnegativeInteger(value: number, name: string): number {
|
|
473
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
474
|
+
throw new ApplicationError("usage", `${name} must be a nonnegative safe integer.`);
|
|
475
|
+
}
|
|
476
|
+
return value;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function normalizeLimits(options: DurableWorkflowSchedulerOptions): NormalizedSchedulerLimits {
|
|
480
|
+
const hostJobs = safePositiveInteger(options.hostLimits?.maxJobs ?? 4, "Host job limit");
|
|
481
|
+
const requestedJobs = options.jobs === undefined
|
|
482
|
+
? hostJobs
|
|
483
|
+
: safePositiveInteger(options.jobs, "Requested job limit");
|
|
484
|
+
const resourceOverrides = options.hostLimits?.resources ?? {};
|
|
485
|
+
const resources = Object.fromEntries(RESOURCE_KINDS.map(resource => [
|
|
486
|
+
resource,
|
|
487
|
+
safeNonnegativeInteger(
|
|
488
|
+
resourceOverrides[resource] ?? DEFAULT_SCHEDULER_RESOURCE_LIMITS[resource],
|
|
489
|
+
`Host ${resource} limit`,
|
|
490
|
+
),
|
|
491
|
+
])) as Record<OperationResourceKind, number>;
|
|
492
|
+
return {
|
|
493
|
+
cancellationPollMs: safePositiveInteger(
|
|
494
|
+
options.cancellationPollMs ?? DEFAULT_CANCELLATION_POLL_MS,
|
|
495
|
+
"Cancellation poll interval",
|
|
496
|
+
),
|
|
497
|
+
jobs: Math.min(hostJobs, requestedJobs),
|
|
498
|
+
maxNodes: safePositiveInteger(options.hostLimits?.maxNodes ?? 4_096, "Host node limit"),
|
|
499
|
+
maxWallClockMs: safePositiveInteger(
|
|
500
|
+
options.hostLimits?.maxWallClockMs ?? 24 * 60 * 60 * 1_000,
|
|
501
|
+
"Host workflow wall-clock limit",
|
|
502
|
+
),
|
|
503
|
+
resources,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function nodeOutputDigest(value: JsonValue): string {
|
|
508
|
+
return createRunNodeOutputFingerprintFromParsed(value).sha256;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isSerializedRef(value: GraphInputValue | WorkflowOutputBinding): value is SerializedRefV1 {
|
|
512
|
+
return typeof value === "object"
|
|
513
|
+
&& value !== null
|
|
514
|
+
&& !Array.isArray(value)
|
|
515
|
+
&& Object.hasOwn(value, "$ref");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isGraphInputArray(value: GraphInputValue): value is readonly GraphInputValue[] {
|
|
519
|
+
return Array.isArray(value);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isWorkflowOutputArray(
|
|
523
|
+
value: WorkflowOutputBinding,
|
|
524
|
+
): value is readonly WorkflowOutputBinding[] {
|
|
525
|
+
return Array.isArray(value);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function verifiedOutput(record: RunNodeRecord): RunNodeOutput {
|
|
529
|
+
if (record.status !== "completed" || record.output === undefined) {
|
|
530
|
+
throw new ApplicationError(
|
|
531
|
+
"invalid-data",
|
|
532
|
+
`Dependency ${record.nodeKey} does not have a completed output.`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
const value = JsonValueSchema.parse(record.output.value);
|
|
536
|
+
const digest = nodeOutputDigest(value);
|
|
537
|
+
if (digest !== record.output.digestSha256) {
|
|
538
|
+
throw new ApplicationError(
|
|
539
|
+
"invalid-data",
|
|
540
|
+
`Completed output digest does not match for node ${record.nodeKey}.`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return record.output;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function resolveGraphValue(
|
|
547
|
+
value: GraphInputValue,
|
|
548
|
+
outputs: ReadonlyMap<string, RunNodeOutput>,
|
|
549
|
+
): JsonValue {
|
|
550
|
+
if (isSerializedRef(value)) {
|
|
551
|
+
const output = outputs.get(value.$ref.nodeKey);
|
|
552
|
+
if (output === undefined) {
|
|
553
|
+
throw new ApplicationError(
|
|
554
|
+
"invalid-data",
|
|
555
|
+
`Node input references unavailable output ${value.$ref.nodeKey}.`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
return projectReferenceValue(value, output.value);
|
|
559
|
+
}
|
|
560
|
+
if (isGraphInputArray(value)) {
|
|
561
|
+
return value.map(item => resolveGraphValue(item, outputs));
|
|
562
|
+
}
|
|
563
|
+
if (typeof value === "object" && value !== null) {
|
|
564
|
+
return Object.fromEntries(
|
|
565
|
+
Object.entries(value)
|
|
566
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
567
|
+
.map(([key, item]) => [key, resolveGraphValue(item, outputs)]),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
return value;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function resolveNode(
|
|
574
|
+
node: AuthoredGraphNodeV1,
|
|
575
|
+
recordsByKey: ReadonlyMap<string, RunNodeRecord>,
|
|
576
|
+
): ResolvedNode {
|
|
577
|
+
const outputs = new Map<string, RunNodeOutput>();
|
|
578
|
+
const dependencyOutputs: Record<string, RunNodeOutput> = {};
|
|
579
|
+
for (const dependency of [...node.dependencies].sort((left, right) => left.localeCompare(right))) {
|
|
580
|
+
const record = recordsByKey.get(dependency);
|
|
581
|
+
if (record === undefined) {
|
|
582
|
+
throw new ApplicationError("invalid-data", `Missing dependency node ${dependency}.`);
|
|
583
|
+
}
|
|
584
|
+
const output = verifiedOutput(record);
|
|
585
|
+
outputs.set(dependency, output);
|
|
586
|
+
dependencyOutputs[dependency] = output;
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
dependencyOutputs,
|
|
590
|
+
input: JsonValueSchema.parse(resolveGraphValue(node.input, outputs)),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function normalizePolicy(policy: WorkflowNodePolicy): WorkflowNodePolicy {
|
|
595
|
+
return WorkflowNodePolicySchema.parse({
|
|
596
|
+
...policy,
|
|
597
|
+
preparation: [...policy.preparation].sort((left, right) => left.localeCompare(right)),
|
|
598
|
+
resources: [...policy.resources].sort((left, right) => (
|
|
599
|
+
left.resource.localeCompare(right.resource)
|
|
600
|
+
)),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function normalizeDiscovery(discovery: OperationDiscovery): OperationDiscovery {
|
|
605
|
+
return {
|
|
606
|
+
...discovery,
|
|
607
|
+
inputSchemaId: discovery.inputSchemaId.replace(/^studio\./u, "slopcamera."),
|
|
608
|
+
outputSchemaId: discovery.outputSchemaId.replace(/^studio\./u, "slopcamera."),
|
|
609
|
+
policy: OperationPolicySchema.parse(normalizePolicy(discovery.policy)),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function operationForNode(
|
|
614
|
+
graphPlan: GraphPlanV1,
|
|
615
|
+
node: AuthoredOperationGraphNodeV1,
|
|
616
|
+
registry: OperationRegistry,
|
|
617
|
+
): RegisteredOperation {
|
|
618
|
+
const operation = node.executor.operation;
|
|
619
|
+
const registered = registry.get(operation.kind, operation.version);
|
|
620
|
+
const planned = graphPlan.registry.discovery.find(item => (
|
|
621
|
+
item.kind === operation.kind && item.version === operation.version
|
|
622
|
+
));
|
|
623
|
+
if (planned === undefined) {
|
|
624
|
+
throw new ApplicationError(
|
|
625
|
+
"incompatible",
|
|
626
|
+
`Graph plan omits operation ${operation.kind}@${String(operation.version)}.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (
|
|
630
|
+
canonicalJson(normalizeDiscovery(registered.discovery))
|
|
631
|
+
!== canonicalJson(normalizeDiscovery(planned))
|
|
632
|
+
) {
|
|
633
|
+
throw new ApplicationError(
|
|
634
|
+
"incompatible",
|
|
635
|
+
`Registered operation changed after planning: ${operation.kind}@${String(operation.version)}.`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
return registered;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function executorForNode(
|
|
642
|
+
graphPlan: GraphPlanV1,
|
|
643
|
+
node: AuthoredGraphNodeV1,
|
|
644
|
+
registry: OperationRegistry,
|
|
645
|
+
): ResolvedNodeExecutor {
|
|
646
|
+
if (isOperationGraphNode(node)) {
|
|
647
|
+
const operation = operationForNode(graphPlan, node, registry);
|
|
648
|
+
return {
|
|
649
|
+
kind: "operation",
|
|
650
|
+
operation,
|
|
651
|
+
policy: operation.discovery.policy,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (isComputeGraphNode(node)) {
|
|
655
|
+
return {
|
|
656
|
+
compute: node.executor.compute,
|
|
657
|
+
kind: "compute",
|
|
658
|
+
policy: trustedComputePolicy(node.executor.compute),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
throw new ApplicationError("internal", `Unknown node executor for ${node.key}.`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function normalizePublicationKeys(keys: readonly string[]): readonly string[] {
|
|
665
|
+
if (keys.length > 64) {
|
|
666
|
+
throw new ApplicationError("invalid-data", "A node may claim at most 64 publication keys.");
|
|
667
|
+
}
|
|
668
|
+
const normalized = [...keys].map((key) => {
|
|
669
|
+
if (key.length < 1 || key.length > 512) {
|
|
670
|
+
throw new ApplicationError(
|
|
671
|
+
"invalid-data",
|
|
672
|
+
"Publication keys must contain between 1 and 512 characters.",
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
return key;
|
|
676
|
+
}).sort((left, right) => left.localeCompare(right));
|
|
677
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
678
|
+
throw new ApplicationError("invalid-data", "A node cannot claim a publication key twice.");
|
|
679
|
+
}
|
|
680
|
+
return normalized;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function computePreparationBinding(resolved: ResolvedNode): NodePreparationBinding {
|
|
684
|
+
const fingerprint = canonicalJsonFingerprint(
|
|
685
|
+
resolved.input,
|
|
686
|
+
"studio.workflow.compute-input-descriptor/v1\0",
|
|
687
|
+
);
|
|
688
|
+
return {
|
|
689
|
+
inputDescriptors: JsonValueSchema.parse({
|
|
690
|
+
bytes: fingerprint.bytes,
|
|
691
|
+
inputSha256: fingerprint.sha256,
|
|
692
|
+
}),
|
|
693
|
+
upperInputBytes: fingerprint.bytes,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function createPreparationPlan(
|
|
698
|
+
graphPlan: GraphPlanV1,
|
|
699
|
+
node: AuthoredGraphNodeV1,
|
|
700
|
+
policy: WorkflowNodePolicy,
|
|
701
|
+
binding: NodePreparationBinding,
|
|
702
|
+
): NodePreparationPlan {
|
|
703
|
+
const upperDurationMs = binding.upperDurationMs ?? policy.maxDurationMs;
|
|
704
|
+
const upperInputBytes = binding.upperInputBytes ?? policy.maxInputBytes;
|
|
705
|
+
if (
|
|
706
|
+
!Number.isSafeInteger(upperDurationMs)
|
|
707
|
+
|| upperDurationMs < 0
|
|
708
|
+
|| upperDurationMs > policy.maxDurationMs
|
|
709
|
+
) {
|
|
710
|
+
throw new ApplicationError(
|
|
711
|
+
"invalid-data",
|
|
712
|
+
`Preparation duration bound exceeds policy for node ${node.key}.`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (
|
|
716
|
+
!Number.isSafeInteger(upperInputBytes)
|
|
717
|
+
|| upperInputBytes < 0
|
|
718
|
+
|| upperInputBytes > policy.maxInputBytes
|
|
719
|
+
) {
|
|
720
|
+
throw new ApplicationError(
|
|
721
|
+
"invalid-data",
|
|
722
|
+
`Preparation input bound exceeds policy for node ${node.key}.`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
const unsigned = NodePreparationPlanUnsignedSchema.parse({
|
|
726
|
+
exactInputBound: true,
|
|
727
|
+
executor: node.executor,
|
|
728
|
+
graphPlanSha256: graphPlan.graphPlanSha256,
|
|
729
|
+
inputDescriptors: JsonValueSchema.parse(binding.inputDescriptors),
|
|
730
|
+
nodeKey: node.key,
|
|
731
|
+
requestedPreparation: [...policy.preparation]
|
|
732
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
733
|
+
upperDurationMs,
|
|
734
|
+
upperInputBytes,
|
|
735
|
+
version: NODE_PREPARATION_PLAN_VERSION,
|
|
736
|
+
});
|
|
737
|
+
return NodePreparationPlanSchema.parse({
|
|
738
|
+
...unsigned,
|
|
739
|
+
preparationPlanSha256: createNodePreparationPlanHash(unsigned),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function createExecutionPlan(
|
|
744
|
+
graphPlan: GraphPlanV1,
|
|
745
|
+
node: AuthoredGraphNodeV1,
|
|
746
|
+
policy: WorkflowNodePolicy,
|
|
747
|
+
preparationPlan: NodePreparationPlan,
|
|
748
|
+
resolved: ResolvedNode,
|
|
749
|
+
binding: NodeExecutionBinding,
|
|
750
|
+
): NodeExecutionPlan {
|
|
751
|
+
const exactInput = JsonValueSchema.parse(binding.exactInput);
|
|
752
|
+
const exactInputFingerprint = createNodeInputFingerprintFromParsed(exactInput);
|
|
753
|
+
const exactInputBytes = exactInputFingerprint.bytes;
|
|
754
|
+
const exactInputLimit = preparationPlan.exactInputBound === true
|
|
755
|
+
? preparationPlan.upperInputBytes
|
|
756
|
+
: policy.maxInputBytes;
|
|
757
|
+
if (exactInputBytes > exactInputLimit) {
|
|
758
|
+
throw new ApplicationError(
|
|
759
|
+
"invalid-data",
|
|
760
|
+
`Exact input for node ${node.key} uses ${String(exactInputBytes)} canonical JSON bytes; the ${preparationPlan.exactInputBound === true ? "prepared upper bound" : "operation input limit"} is ${String(exactInputLimit)}.`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
const dependencyOutputDigests = Object.fromEntries(
|
|
764
|
+
Object.entries(resolved.dependencyOutputs)
|
|
765
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
766
|
+
.map(([key, output]) => [key, output.digestSha256]),
|
|
767
|
+
);
|
|
768
|
+
const unsigned = NodeExecutionPlanUnsignedSchema.parse({
|
|
769
|
+
dependencyOutputDigests,
|
|
770
|
+
executor: node.executor,
|
|
771
|
+
exactInput,
|
|
772
|
+
...(binding.expectedProjectGeneration === undefined
|
|
773
|
+
? {}
|
|
774
|
+
: { expectedProjectGeneration: binding.expectedProjectGeneration }),
|
|
775
|
+
graphPlanSha256: graphPlan.graphPlanSha256,
|
|
776
|
+
inputSha256: exactInputFingerprint.sha256,
|
|
777
|
+
nodeKey: node.key,
|
|
778
|
+
policy: normalizePolicy(policy),
|
|
779
|
+
preparationPlanSha256: preparationPlan.preparationPlanSha256,
|
|
780
|
+
publicationKeys: normalizePublicationKeys(binding.publicationKeys),
|
|
781
|
+
version: NODE_EXECUTION_PLAN_VERSION,
|
|
782
|
+
});
|
|
783
|
+
return NodeExecutionPlanSchema.parse({
|
|
784
|
+
...unsigned,
|
|
785
|
+
nodePlanSha256: createNodeExecutionPlanHash(unsigned),
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function verifyPreparationPlan(
|
|
790
|
+
plan: NodePreparationPlan,
|
|
791
|
+
graphPlan: GraphPlanV1,
|
|
792
|
+
node: AuthoredGraphNodeV1,
|
|
793
|
+
policy: WorkflowNodePolicy,
|
|
794
|
+
): void {
|
|
795
|
+
const parsed = NodePreparationPlanSchema.parse(plan);
|
|
796
|
+
const { preparationPlanSha256, ...unsigned } = parsed;
|
|
797
|
+
if (
|
|
798
|
+
preparationPlanSha256 !== createNodePreparationPlanHash(unsigned)
|
|
799
|
+
|| parsed.graphPlanSha256 !== graphPlan.graphPlanSha256
|
|
800
|
+
|| parsed.nodeKey !== node.key
|
|
801
|
+
|| canonicalJson(parsed.executor) !== canonicalJson(node.executor)
|
|
802
|
+
|| parsed.upperDurationMs > policy.maxDurationMs
|
|
803
|
+
|| parsed.upperInputBytes > policy.maxInputBytes
|
|
804
|
+
|| canonicalJson(parsed.requestedPreparation)
|
|
805
|
+
!== canonicalJson([...policy.preparation].sort((left, right) => left.localeCompare(right)))
|
|
806
|
+
) {
|
|
807
|
+
throw new ApplicationError(
|
|
808
|
+
"invalid-data",
|
|
809
|
+
`Persisted preparation plan no longer matches node ${node.key}.`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function verifyExecutionPlan(
|
|
815
|
+
plan: NodeExecutionPlan,
|
|
816
|
+
graphPlan: GraphPlanV1,
|
|
817
|
+
node: AuthoredGraphNodeV1,
|
|
818
|
+
policy: WorkflowNodePolicy,
|
|
819
|
+
preparationPlan: NodePreparationPlan,
|
|
820
|
+
resolved: ResolvedNode,
|
|
821
|
+
): void {
|
|
822
|
+
const parsed = NodeExecutionPlanSchema.parse(plan);
|
|
823
|
+
const { nodePlanSha256, ...unsigned } = parsed;
|
|
824
|
+
const exactInputFingerprint = createNodeInputFingerprintFromParsed(parsed.exactInput);
|
|
825
|
+
const exactInputBytes = exactInputFingerprint.bytes;
|
|
826
|
+
const exactInputLimit = preparationPlan.exactInputBound === true
|
|
827
|
+
? preparationPlan.upperInputBytes
|
|
828
|
+
: policy.maxInputBytes;
|
|
829
|
+
const expectedDigests = Object.fromEntries(
|
|
830
|
+
Object.entries(resolved.dependencyOutputs)
|
|
831
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
832
|
+
.map(([key, output]) => [key, output.digestSha256]),
|
|
833
|
+
);
|
|
834
|
+
if (
|
|
835
|
+
nodePlanSha256 !== createNodeExecutionPlanHash(unsigned)
|
|
836
|
+
|| exactInputBytes > exactInputLimit
|
|
837
|
+
|| parsed.inputSha256 !== exactInputFingerprint.sha256
|
|
838
|
+
|| parsed.graphPlanSha256 !== graphPlan.graphPlanSha256
|
|
839
|
+
|| parsed.nodeKey !== node.key
|
|
840
|
+
|| parsed.preparationPlanSha256 !== preparationPlan.preparationPlanSha256
|
|
841
|
+
|| canonicalJson(parsed.executor) !== canonicalJson(node.executor)
|
|
842
|
+
|| canonicalJson(parsed.policy) !== canonicalJson(normalizePolicy(policy))
|
|
843
|
+
|| canonicalJson(parsed.dependencyOutputDigests) !== canonicalJson(expectedDigests)
|
|
844
|
+
|| canonicalJson(parsed.publicationKeys)
|
|
845
|
+
!== canonicalJson(normalizePublicationKeys(parsed.publicationKeys))
|
|
846
|
+
) {
|
|
847
|
+
throw new ApplicationError(
|
|
848
|
+
"invalid-data",
|
|
849
|
+
`Persisted execution plan no longer matches node ${node.key}.`,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function authorizationAllowed(decision: SchedulerAuthorizationDecision): boolean {
|
|
855
|
+
return typeof decision === "boolean" ? decision : decision.allowed;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function exactPreparationGrant(
|
|
859
|
+
grants: readonly RunGrant[],
|
|
860
|
+
graphPlanSha256: string,
|
|
861
|
+
nodeKey: string,
|
|
862
|
+
preparationPlanSha256: string,
|
|
863
|
+
): boolean {
|
|
864
|
+
return grants.some(grant => (
|
|
865
|
+
grant.kind === "preparation"
|
|
866
|
+
&& grant.graphPlanSha256 === graphPlanSha256
|
|
867
|
+
&& grant.nodeKey === nodeKey
|
|
868
|
+
&& grant.preparationPlanSha256 === preparationPlanSha256
|
|
869
|
+
));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function exactEffectGrant(
|
|
873
|
+
grants: readonly RunGrant[],
|
|
874
|
+
graphPlanSha256: string,
|
|
875
|
+
nodeKey: string,
|
|
876
|
+
nodePlanSha256: string,
|
|
877
|
+
): boolean {
|
|
878
|
+
return grants.some(grant => (
|
|
879
|
+
grant.kind === "effect"
|
|
880
|
+
&& grant.graphPlanSha256 === graphPlanSha256
|
|
881
|
+
&& grant.nodeKey === nodeKey
|
|
882
|
+
&& grant.nodePlanSha256 === nodePlanSha256
|
|
883
|
+
));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function boundedMessage(message: string): string {
|
|
887
|
+
const normalized = message.length === 0 ? "Unknown scheduler failure." : message;
|
|
888
|
+
return normalized.slice(0, MAX_FAILURE_MESSAGE_LENGTH);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function schedulerFailure(error: unknown, retryable = false) {
|
|
892
|
+
const applicationError = asApplicationError(error);
|
|
893
|
+
const spatialPublication = SpatialProjectMutationOutputSchema.safeParse(applicationError.details?.spatialPublication);
|
|
894
|
+
const code = (() => {
|
|
895
|
+
switch (applicationError.code) {
|
|
896
|
+
case "authorization-required":
|
|
897
|
+
case "cancelled":
|
|
898
|
+
case "conflict":
|
|
899
|
+
case "incompatible":
|
|
900
|
+
case "invalid-data":
|
|
901
|
+
case "subprocess":
|
|
902
|
+
case "unavailable":
|
|
903
|
+
case "unsupported-plan":
|
|
904
|
+
case "ambiguous":
|
|
905
|
+
case "internal":
|
|
906
|
+
return applicationError.code;
|
|
907
|
+
case "not-found":
|
|
908
|
+
case "unsafe-path":
|
|
909
|
+
case "usage":
|
|
910
|
+
return "invalid-data";
|
|
911
|
+
}
|
|
912
|
+
})();
|
|
913
|
+
return {
|
|
914
|
+
code,
|
|
915
|
+
message: boundedMessage(applicationError.message),
|
|
916
|
+
retryable,
|
|
917
|
+
...(spatialPublication.success ? { spatialPublication: spatialPublication.data } : {}),
|
|
918
|
+
} as const;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function boundedSummary(
|
|
922
|
+
fields: Readonly<Record<string, boolean | null | number | string>>,
|
|
923
|
+
): Readonly<Record<string, boolean | null | number | string>> {
|
|
924
|
+
const entries = Object.entries(fields).sort(([left], [right]) => left.localeCompare(right));
|
|
925
|
+
if (entries.length > MAX_SUMMARY_FIELDS) {
|
|
926
|
+
throw new ApplicationError("internal", "Operation summary exceeds the scheduler field limit.");
|
|
927
|
+
}
|
|
928
|
+
return Object.fromEntries(entries.map(([key, value]) => {
|
|
929
|
+
if (key.length < 1 || key.length > MAX_SUMMARY_KEY_LENGTH) {
|
|
930
|
+
throw new ApplicationError("internal", "Operation summary contains an invalid field name.");
|
|
931
|
+
}
|
|
932
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
933
|
+
throw new ApplicationError("internal", "Operation summary contains a non-finite number.");
|
|
934
|
+
}
|
|
935
|
+
if (typeof value === "string" && value.length > MAX_SUMMARY_STRING_LENGTH) {
|
|
936
|
+
throw new ApplicationError("internal", "Operation summary contains an oversized string.");
|
|
937
|
+
}
|
|
938
|
+
return [key, value];
|
|
939
|
+
}));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function createRunNodeOutput(
|
|
943
|
+
valueInput: unknown,
|
|
944
|
+
summaryInput: Readonly<Record<string, boolean | null | number | string>>,
|
|
945
|
+
maxOutputBytes: number,
|
|
946
|
+
receiptReference?: string,
|
|
947
|
+
): RunNodeOutput {
|
|
948
|
+
const value = JsonValueSchema.parse(valueInput);
|
|
949
|
+
const fingerprint = createRunNodeOutputFingerprintFromParsed(value);
|
|
950
|
+
const bytes = fingerprint.bytes;
|
|
951
|
+
if (bytes > maxOutputBytes) {
|
|
952
|
+
throw new ApplicationError(
|
|
953
|
+
"invalid-data",
|
|
954
|
+
`Operation output uses ${String(bytes)} bytes; the limit is ${String(maxOutputBytes)}.`,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
digestSha256: fingerprint.sha256,
|
|
959
|
+
...(receiptReference === undefined ? {} : { receiptReference }),
|
|
960
|
+
summary: boundedSummary(summaryInput),
|
|
961
|
+
value,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function dependencyTerminalFailure(record: RunNodeRecord): boolean {
|
|
966
|
+
return [
|
|
967
|
+
"ambiguous",
|
|
968
|
+
"cancelled",
|
|
969
|
+
"failed",
|
|
970
|
+
"incompatible",
|
|
971
|
+
"skipped",
|
|
972
|
+
].includes(record.status);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function isRunnableStatus(record: RunNodeRecord): boolean {
|
|
976
|
+
return record.status === "pending"
|
|
977
|
+
|| record.status === "ready"
|
|
978
|
+
|| record.status === "preparing"
|
|
979
|
+
|| record.status === "approval-required";
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
class ResourceLedger {
|
|
983
|
+
readonly #jobs: number;
|
|
984
|
+
readonly #limits: OperationResourceTotals;
|
|
985
|
+
readonly #publicationKeys = new Set<string>();
|
|
986
|
+
readonly #used: Record<OperationResourceKind, number>;
|
|
987
|
+
#usedJobs = 0;
|
|
988
|
+
|
|
989
|
+
constructor(limits: NormalizedSchedulerLimits) {
|
|
990
|
+
this.#jobs = limits.jobs;
|
|
991
|
+
this.#limits = limits.resources;
|
|
992
|
+
this.#used = Object.fromEntries(RESOURCE_KINDS.map(resource => [resource, 0])) as Record<
|
|
993
|
+
OperationResourceKind,
|
|
994
|
+
number
|
|
995
|
+
>;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
impossible(
|
|
999
|
+
resources: readonly { readonly amount: number; readonly resource: OperationResourceKind }[],
|
|
1000
|
+
): boolean {
|
|
1001
|
+
return resources.some(claim => claim.amount > this.#limits[claim.resource]);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
acquire(
|
|
1005
|
+
resources: readonly { readonly amount: number; readonly resource: OperationResourceKind }[],
|
|
1006
|
+
publicationKeys: readonly string[],
|
|
1007
|
+
): boolean {
|
|
1008
|
+
if (this.#usedJobs >= this.#jobs) return false;
|
|
1009
|
+
if (publicationKeys.some(key => this.#publicationKeys.has(key))) return false;
|
|
1010
|
+
if (resources.some(claim => (
|
|
1011
|
+
this.#used[claim.resource] + claim.amount > this.#limits[claim.resource]
|
|
1012
|
+
))) return false;
|
|
1013
|
+
this.#usedJobs += 1;
|
|
1014
|
+
for (const claim of resources) this.#used[claim.resource] += claim.amount;
|
|
1015
|
+
for (const key of publicationKeys) this.#publicationKeys.add(key);
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
release(
|
|
1020
|
+
resources: readonly { readonly amount: number; readonly resource: OperationResourceKind }[],
|
|
1021
|
+
publicationKeys: readonly string[],
|
|
1022
|
+
): void {
|
|
1023
|
+
this.#usedJobs -= 1;
|
|
1024
|
+
for (const claim of resources) this.#used[claim.resource] -= claim.amount;
|
|
1025
|
+
for (const key of publicationKeys) this.#publicationKeys.delete(key);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function applicationWithHostResourceLease(
|
|
1030
|
+
application: ApplicationContext,
|
|
1031
|
+
lease: HostResourceLease,
|
|
1032
|
+
): ApplicationContext {
|
|
1033
|
+
return {
|
|
1034
|
+
...application,
|
|
1035
|
+
hostResourceLease: {
|
|
1036
|
+
assertOwned: async () => await lease.assertOwned(),
|
|
1037
|
+
claims: lease.claims,
|
|
1038
|
+
inheritedFileDescriptor: lease.inheritedFileDescriptor,
|
|
1039
|
+
inheritedFileDescriptors: [lease.inheritedFileDescriptor],
|
|
1040
|
+
profile: lease.profile,
|
|
1041
|
+
ticket: lease.ticket,
|
|
1042
|
+
},
|
|
1043
|
+
runner: {
|
|
1044
|
+
run: async (argv, options = {}) => {
|
|
1045
|
+
await lease.assertOwned();
|
|
1046
|
+
const inheritedFileDescriptors = [
|
|
1047
|
+
...(options.inheritedFileDescriptors ?? []),
|
|
1048
|
+
lease.inheritedFileDescriptor,
|
|
1049
|
+
].filter((descriptor, index, descriptors) => (
|
|
1050
|
+
descriptors.indexOf(descriptor) === index
|
|
1051
|
+
));
|
|
1052
|
+
if (inheritedFileDescriptors.length > 16) {
|
|
1053
|
+
throw new ApplicationError(
|
|
1054
|
+
"incompatible",
|
|
1055
|
+
"A workflow subprocess cannot inherit more than 16 file descriptors.",
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
return await application.runner.run(argv, {
|
|
1059
|
+
...options,
|
|
1060
|
+
inheritedFileDescriptors,
|
|
1061
|
+
});
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function asSchedulerApplicationError(error: unknown): ApplicationError {
|
|
1068
|
+
if (!(error instanceof HostResourceError)) return asApplicationError(error);
|
|
1069
|
+
switch (error.code) {
|
|
1070
|
+
case "WAIT_ABORTED":
|
|
1071
|
+
return new ApplicationError("cancelled", error.message);
|
|
1072
|
+
case "WAIT_TIMEOUT":
|
|
1073
|
+
return new ApplicationError("unavailable", error.message);
|
|
1074
|
+
case "OWNERSHIP_LOST":
|
|
1075
|
+
return new ApplicationError("conflict", error.message);
|
|
1076
|
+
case "INVALID_CLAIMS":
|
|
1077
|
+
case "INVALID_PROFILE":
|
|
1078
|
+
case "PROFILE_MISMATCH":
|
|
1079
|
+
case "UNSAFE_STATE":
|
|
1080
|
+
case "UNSUPPORTED_PLATFORM":
|
|
1081
|
+
return new ApplicationError("incompatible", error.message);
|
|
1082
|
+
default: {
|
|
1083
|
+
const exhaustive: never = error.code;
|
|
1084
|
+
return exhaustive;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function countsFor(records: readonly RunNodeRecord[]): RunSummary["counts"] {
|
|
1090
|
+
return {
|
|
1091
|
+
cancelled: records.filter(record => record.status === "cancelled").length,
|
|
1092
|
+
completed: records.filter(record => record.status === "completed").length,
|
|
1093
|
+
failed: records.filter(record => (
|
|
1094
|
+
record.status === "failed"
|
|
1095
|
+
|| record.status === "incompatible"
|
|
1096
|
+
|| record.status === "ambiguous"
|
|
1097
|
+
)).length,
|
|
1098
|
+
pending: records.filter(record => (
|
|
1099
|
+
record.status === "pending"
|
|
1100
|
+
|| record.status === "ready"
|
|
1101
|
+
|| record.status === "preparing"
|
|
1102
|
+
|| record.status === "running"
|
|
1103
|
+
|| record.status === "approval-required"
|
|
1104
|
+
|| record.status === "ambiguous-code"
|
|
1105
|
+
)).length,
|
|
1106
|
+
skipped: records.filter(record => record.status === "skipped").length,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function terminalStatus(
|
|
1111
|
+
records: readonly RunNodeRecord[],
|
|
1112
|
+
state: MutableRunState,
|
|
1113
|
+
running: number,
|
|
1114
|
+
): RunStatus {
|
|
1115
|
+
if (state.pause !== undefined) return "approval-required";
|
|
1116
|
+
if (records.some(record => record.status === "approval-required")) {
|
|
1117
|
+
return "approval-required";
|
|
1118
|
+
}
|
|
1119
|
+
if (records.some(record => record.status === "ambiguous-code")) return "ambiguous-code";
|
|
1120
|
+
if (
|
|
1121
|
+
running > 0
|
|
1122
|
+
|| records.some(record => record.status === "running")
|
|
1123
|
+
|| records.some(isRunnableStatus)
|
|
1124
|
+
) return "running";
|
|
1125
|
+
const counts = countsFor(records);
|
|
1126
|
+
if (counts.cancelled > 0) return "cancelled";
|
|
1127
|
+
if (counts.failed === 0 && counts.skipped === 0 && counts.completed === records.length) {
|
|
1128
|
+
return "completed";
|
|
1129
|
+
}
|
|
1130
|
+
if (state.cancelled) return "cancelled";
|
|
1131
|
+
if (
|
|
1132
|
+
records.some(record => record.status === "incompatible")
|
|
1133
|
+
&& counts.completed === 0
|
|
1134
|
+
) {
|
|
1135
|
+
return "incompatible";
|
|
1136
|
+
}
|
|
1137
|
+
if (counts.completed > 0) return "partial";
|
|
1138
|
+
return "failed";
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function terminalRunStatus(status: RunStatus): boolean {
|
|
1142
|
+
return status === "cancelled"
|
|
1143
|
+
|| status === "completed"
|
|
1144
|
+
|| status === "failed"
|
|
1145
|
+
|| status === "incompatible"
|
|
1146
|
+
|| status === "partial";
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function resolveOutputBinding(
|
|
1150
|
+
binding: WorkflowOutputBinding,
|
|
1151
|
+
recordsByKey: ReadonlyMap<string, RunNodeRecord>,
|
|
1152
|
+
): JsonValue | undefined {
|
|
1153
|
+
if (isSerializedRef(binding)) {
|
|
1154
|
+
const record = recordsByKey.get(binding.$ref.nodeKey);
|
|
1155
|
+
if (record === undefined || record.status !== "completed") return undefined;
|
|
1156
|
+
return projectReferenceValue(binding, verifiedOutput(record).value);
|
|
1157
|
+
}
|
|
1158
|
+
if (isWorkflowOutputArray(binding)) {
|
|
1159
|
+
const values: JsonValue[] = [];
|
|
1160
|
+
for (const item of binding) {
|
|
1161
|
+
const resolved = resolveOutputBinding(item, recordsByKey);
|
|
1162
|
+
if (resolved === undefined) return undefined;
|
|
1163
|
+
values.push(resolved);
|
|
1164
|
+
}
|
|
1165
|
+
return values;
|
|
1166
|
+
}
|
|
1167
|
+
const output: Record<string, JsonValue> = {};
|
|
1168
|
+
for (const [key, item] of Object.entries(binding).sort(([left], [right]) => (
|
|
1169
|
+
left.localeCompare(right)
|
|
1170
|
+
))) {
|
|
1171
|
+
const resolved = resolveOutputBinding(item, recordsByKey);
|
|
1172
|
+
if (resolved === undefined) return undefined;
|
|
1173
|
+
output[key] = resolved;
|
|
1174
|
+
}
|
|
1175
|
+
return output;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
export class DurableWorkflowScheduler {
|
|
1179
|
+
readonly #application: ApplicationContext;
|
|
1180
|
+
readonly #authorization: SchedulerAuthorization;
|
|
1181
|
+
readonly #computeRuntime: SchedulerComputeRuntime | undefined;
|
|
1182
|
+
#computeExecutor: SchedulerComputeExecutor | undefined;
|
|
1183
|
+
readonly #currentApplicationBuild: string;
|
|
1184
|
+
readonly #hostResourceCoordinator: HostResourceCoordinator;
|
|
1185
|
+
readonly #limits: NormalizedSchedulerLimits;
|
|
1186
|
+
readonly #nodePlanner: SchedulerNodePlanner;
|
|
1187
|
+
readonly #now: () => Date;
|
|
1188
|
+
readonly #owner: string;
|
|
1189
|
+
readonly #registry: OperationRegistry;
|
|
1190
|
+
readonly #replayComputeNodeKeys = new Set<string>();
|
|
1191
|
+
readonly #store: DurableWorkflowSchedulerOptions["store"];
|
|
1192
|
+
|
|
1193
|
+
constructor(options: DurableWorkflowSchedulerOptions) {
|
|
1194
|
+
if (options.owner.length < 1 || options.owner.length > 256) {
|
|
1195
|
+
throw new ApplicationError("usage", "Scheduler owner must contain between 1 and 256 characters.");
|
|
1196
|
+
}
|
|
1197
|
+
if (
|
|
1198
|
+
options.authorization.grantedBy.length < 1
|
|
1199
|
+
|| options.authorization.grantedBy.length > 256
|
|
1200
|
+
) {
|
|
1201
|
+
throw new ApplicationError(
|
|
1202
|
+
"usage",
|
|
1203
|
+
"Authorization owner must contain between 1 and 256 characters.",
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
this.#application = options.application;
|
|
1207
|
+
this.#authorization = options.authorization;
|
|
1208
|
+
this.#computeRuntime = options.compute;
|
|
1209
|
+
this.#computeExecutor = options.compute?.kind === "fresh"
|
|
1210
|
+
? options.compute.executor
|
|
1211
|
+
: undefined;
|
|
1212
|
+
if (
|
|
1213
|
+
options.currentApplicationBuild.length < 1
|
|
1214
|
+
|| options.currentApplicationBuild.length > 160
|
|
1215
|
+
) {
|
|
1216
|
+
throw new ApplicationError(
|
|
1217
|
+
"usage",
|
|
1218
|
+
"Current application build must contain between 1 and 160 characters.",
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
this.#currentApplicationBuild = options.currentApplicationBuild;
|
|
1222
|
+
this.#hostResourceCoordinator = options.hostResourceCoordinator;
|
|
1223
|
+
this.#limits = normalizeLimits(options);
|
|
1224
|
+
this.#nodePlanner = options.nodePlanner;
|
|
1225
|
+
this.#now = options.now ?? (() => new Date());
|
|
1226
|
+
this.#owner = options.owner;
|
|
1227
|
+
this.#registry = options.registry;
|
|
1228
|
+
this.#store = options.store;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async run(runId: string): Promise<SchedulerRunResult> {
|
|
1232
|
+
const owner = new WorkflowEffectRuntime();
|
|
1233
|
+
try { return await owner.run(this.#runOwned(runId, owner)); }
|
|
1234
|
+
finally { await owner.stop(); }
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
#runOwned(runId: string, owner: WorkflowEffectRuntime): Effect.Effect<SchedulerRunResult, WorkflowFailure> {
|
|
1238
|
+
return workflowScoped(Effect.gen(this, function*() {
|
|
1239
|
+
const graphPlan = (yield* workflowBoundary("authority", () => this.#store.graphPlan(runId)));
|
|
1240
|
+
if (graphPlan.graph.nodes.length > this.#limits.maxNodes) {
|
|
1241
|
+
return yield* Effect.fail(workflowFailure(new ApplicationError(
|
|
1242
|
+
"incompatible",
|
|
1243
|
+
`Workflow has ${String(graphPlan.graph.nodes.length)} nodes; the host limit is `
|
|
1244
|
+
+ `${String(this.#limits.maxNodes)}.`,
|
|
1245
|
+
)));
|
|
1246
|
+
}
|
|
1247
|
+
let ownsFence = true;
|
|
1248
|
+
const fence = yield* workflowResource(
|
|
1249
|
+
workflowBoundary("admission", () => this.#store.acquireClaim(runId, { owner: this.#owner })),
|
|
1250
|
+
fence => ownsFence ? this.#releaseOwnedClaim(fence) : Effect.void,
|
|
1251
|
+
);
|
|
1252
|
+
let computeExecutorLease: SchedulerComputeExecutorLease | undefined;
|
|
1253
|
+
yield* workflowResource(Effect.void, () => workflowBoundary("cleanup", () => computeExecutorLease?.release()));
|
|
1254
|
+
const workflowDeadlineMonotonicMs = safeDeadline(
|
|
1255
|
+
performance.now(),
|
|
1256
|
+
this.#limits.maxWallClockMs,
|
|
1257
|
+
);
|
|
1258
|
+
const initialSummary = (yield* workflowBoundary("authority", () => this.#summary(runId)));
|
|
1259
|
+
const state: MutableRunState = {
|
|
1260
|
+
cancelled: false,
|
|
1261
|
+
monitorError: undefined,
|
|
1262
|
+
pause: undefined,
|
|
1263
|
+
reconciliationDeferred: false,
|
|
1264
|
+
startedAt: initialSummary.startedAt ?? this.#now().toISOString(),
|
|
1265
|
+
};
|
|
1266
|
+
const running = new Map<string, RunningNode>();
|
|
1267
|
+
const completions = yield* Queue.unbounded<{ nodeKey: string; outcome: Exit.Exit<NodeExecutionOutcome, WorkflowFailure> }>();
|
|
1268
|
+
const prepared = new Map<string, PreparedNode>();
|
|
1269
|
+
const ledger = new ResourceLedger(this.#limits);
|
|
1270
|
+
const control = new WorkflowRunControl(
|
|
1271
|
+
owner,
|
|
1272
|
+
workflowDeadlineMonotonicMs,
|
|
1273
|
+
(interruption) => {
|
|
1274
|
+
if (interruption.kind === "monitor") {
|
|
1275
|
+
state.monitorError = interruption.error;
|
|
1276
|
+
} else {
|
|
1277
|
+
state.cancelled = true;
|
|
1278
|
+
}
|
|
1279
|
+
if (interruption.kind !== "deadline") {
|
|
1280
|
+
for (const item of running.values()) {
|
|
1281
|
+
item.admissionAbortController.abort();
|
|
1282
|
+
if (item.cancellable) item.abortController.abort();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
);
|
|
1287
|
+
const stopCancellation = owner.watch(Effect.forever(Effect.gen(this, function*() {
|
|
1288
|
+
yield* Effect.sleep(this.#limits.cancellationPollMs);
|
|
1289
|
+
const result = yield* Effect.either(workflowBoundary("monitor", () => this.#cancellation(runId)));
|
|
1290
|
+
if (Either.isLeft(result)) {
|
|
1291
|
+
control.interrupt("monitor", asApplicationError(result.left.cause));
|
|
1292
|
+
} else if (result.right !== undefined) {
|
|
1293
|
+
control.interrupt("cancellation", new ApplicationError("cancelled", "Workflow cancellation requested."));
|
|
1294
|
+
}
|
|
1295
|
+
})));
|
|
1296
|
+
|
|
1297
|
+
const outcome = yield* Effect.exit(Effect.catchAll(
|
|
1298
|
+
Effect.gen(this, function*() {
|
|
1299
|
+
yield* workflowBoundary("authority", () => this.#store.assertFence(fence));
|
|
1300
|
+
yield* workflowBoundary("authority", () => this.#store.appendEvent(fence, {
|
|
1301
|
+
details: { status: "running" },
|
|
1302
|
+
kind: "run-status",
|
|
1303
|
+
timestamp: this.#now().toISOString(),
|
|
1304
|
+
}));
|
|
1305
|
+
const compatibility = yield* Effect.either(Effect.gen(this, function*() {
|
|
1306
|
+
yield* workflowBoundary("authority", () => this.#verifyRuntimeCompatibility(runId, graphPlan));
|
|
1307
|
+
computeExecutorLease = (yield* workflowBoundary("authority", () => this.#prepareComputeRuntime(
|
|
1308
|
+
fence,
|
|
1309
|
+
graphPlan,
|
|
1310
|
+
state,
|
|
1311
|
+
control,
|
|
1312
|
+
)));
|
|
1313
|
+
|
|
1314
|
+
}));
|
|
1315
|
+
if (Either.isLeft(compatibility)) {
|
|
1316
|
+
const error = compatibility.left.cause;
|
|
1317
|
+
const compatibilityError = asApplicationError(error);
|
|
1318
|
+
if (
|
|
1319
|
+
compatibilityError.code !== "incompatible"
|
|
1320
|
+
&& compatibilityError.code !== "unsupported-plan"
|
|
1321
|
+
) {
|
|
1322
|
+
return yield* Effect.fail(workflowFailure(error));
|
|
1323
|
+
}
|
|
1324
|
+
yield* workflowBoundary("authority", () => this.#markRunIncompatible(fence, graphPlan, state, compatibilityError));
|
|
1325
|
+
const summary = (yield* workflowBoundary("authority", () => this.#publishSummary(fence, graphPlan, state, 0)));
|
|
1326
|
+
yield* workflowBoundary("authority", () => this.#store.appendEvent(fence, {
|
|
1327
|
+
details: { status: summary.status },
|
|
1328
|
+
kind: "run-finalized",
|
|
1329
|
+
timestamp: this.#now().toISOString(),
|
|
1330
|
+
}));
|
|
1331
|
+
yield* workflowBoundary("authority", () => this.#store.releaseClaim(fence));
|
|
1332
|
+
ownsFence = false;
|
|
1333
|
+
return { summary };
|
|
1334
|
+
|
|
1335
|
+
}
|
|
1336
|
+
yield* workflowBoundary("authority", () => this.#reconcileInterrupted(fence, graphPlan, state, control));
|
|
1337
|
+
yield* workflowBoundary("authority", () => this.#publishSummary(fence, graphPlan, state, running.size));
|
|
1338
|
+
if (state.reconciliationDeferred) {
|
|
1339
|
+
const summary = (yield* workflowBoundary("authority", () => this.#publishSummary(fence, graphPlan, state, 0)));
|
|
1340
|
+
yield* workflowBoundary("authority", () => this.#store.appendEvent(fence, {
|
|
1341
|
+
details: { status: summary.status },
|
|
1342
|
+
kind: "run-status",
|
|
1343
|
+
timestamp: this.#now().toISOString(),
|
|
1344
|
+
}));
|
|
1345
|
+
yield* workflowBoundary("authority", () => this.#store.releaseClaim(fence));
|
|
1346
|
+
ownsFence = false;
|
|
1347
|
+
return { summary };
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
while (true) {
|
|
1351
|
+
if (state.monitorError !== undefined) return yield* Effect.fail(workflowFailure(state.monitorError));
|
|
1352
|
+
yield* workflowBoundary("authority", () => this.#store.assertFence(fence));
|
|
1353
|
+
if ((yield* workflowBoundary("authority", () => this.#cancellation(runId))) !== undefined) {
|
|
1354
|
+
state.cancelled = true;
|
|
1355
|
+
for (const item of running.values()) {
|
|
1356
|
+
item.admissionAbortController.abort();
|
|
1357
|
+
if (item.cancellable) item.abortController.abort();
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (performance.now() >= workflowDeadlineMonotonicMs) {
|
|
1361
|
+
state.cancelled = true;
|
|
1362
|
+
for (const item of running.values()) {
|
|
1363
|
+
item.admissionAbortController.abort();
|
|
1364
|
+
if (item.cancellable) item.abortController.abort();
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
yield* workflowBoundary("authority", () => this.#advanceDependencies(fence, graphPlan, state));
|
|
1369
|
+
if (state.cancelled) {
|
|
1370
|
+
yield* workflowBoundary("authority", () => this.#cancelWaitingNodes(fence, graphPlan));
|
|
1371
|
+
} else if (state.pause === undefined) {
|
|
1372
|
+
yield* workflowBoundary("authority", () => this.#prepareReadyNodes(
|
|
1373
|
+
fence,
|
|
1374
|
+
graphPlan,
|
|
1375
|
+
state,
|
|
1376
|
+
control,
|
|
1377
|
+
prepared,
|
|
1378
|
+
));
|
|
1379
|
+
if (state.pause === undefined) {
|
|
1380
|
+
yield* workflowBoundary("authority", () => this.#startAdmittedNodes(
|
|
1381
|
+
fence,
|
|
1382
|
+
prepared,
|
|
1383
|
+
running,
|
|
1384
|
+
ledger,
|
|
1385
|
+
workflowDeadlineMonotonicMs,
|
|
1386
|
+
control,
|
|
1387
|
+
owner,
|
|
1388
|
+
completions,
|
|
1389
|
+
));
|
|
1390
|
+
if (state.cancelled) {
|
|
1391
|
+
yield* workflowBoundary("authority", () => this.#cancelWaitingNodes(fence, graphPlan));
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (running.size > 0) {
|
|
1397
|
+
const completed = yield* Queue.take(completions);
|
|
1398
|
+
const item = running.get(completed.nodeKey);
|
|
1399
|
+
if (item === undefined) continue;
|
|
1400
|
+
const outcome = { item, outcome: yield* completed.outcome };
|
|
1401
|
+
running.delete(outcome.item.nodeKey);
|
|
1402
|
+
ledger.release(outcome.item.resources, outcome.item.publicationKeys);
|
|
1403
|
+
const reconciliationDeferred = (yield* workflowBoundary("authority", () => this.#persistOutcome(
|
|
1404
|
+
fence,
|
|
1405
|
+
graphPlan,
|
|
1406
|
+
outcome.item,
|
|
1407
|
+
outcome.outcome,
|
|
1408
|
+
control,
|
|
1409
|
+
)));
|
|
1410
|
+
yield* workflowBoundary("authority", () => this.#publishSummary(fence, graphPlan, state, running.size));
|
|
1411
|
+
if (reconciliationDeferred) {
|
|
1412
|
+
state.reconciliationDeferred = true;
|
|
1413
|
+
const summary = (yield* workflowBoundary("authority", () => this.#publishSummary(
|
|
1414
|
+
fence,
|
|
1415
|
+
graphPlan,
|
|
1416
|
+
state,
|
|
1417
|
+
running.size,
|
|
1418
|
+
)));
|
|
1419
|
+
yield* workflowBoundary("authority", () => this.#store.appendEvent(fence, {
|
|
1420
|
+
details: { status: summary.status },
|
|
1421
|
+
kind: "run-status",
|
|
1422
|
+
timestamp: this.#now().toISOString(),
|
|
1423
|
+
}));
|
|
1424
|
+
yield* workflowBoundary("authority", () => this.#store.releaseClaim(fence));
|
|
1425
|
+
ownsFence = false;
|
|
1426
|
+
return { summary };
|
|
1427
|
+
}
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const records = (yield* workflowBoundary("authority", () => this.#store.nodes(runId)));
|
|
1432
|
+
const status = terminalStatus(records, state, 0);
|
|
1433
|
+
if (status === "running") {
|
|
1434
|
+
const impossible = (yield* workflowBoundary("authority", () => this.#failImpossibleReadyNodes(
|
|
1435
|
+
fence,
|
|
1436
|
+
graphPlan,
|
|
1437
|
+
ledger,
|
|
1438
|
+
)));
|
|
1439
|
+
if (impossible) continue;
|
|
1440
|
+
return yield* Effect.fail(workflowFailure(new ApplicationError(
|
|
1441
|
+
"internal",
|
|
1442
|
+
"Workflow scheduler reached a nonterminal state without runnable work.",
|
|
1443
|
+
)));
|
|
1444
|
+
}
|
|
1445
|
+
const summary = (yield* workflowBoundary("authority", () => this.#publishSummary(fence, graphPlan, state, 0)));
|
|
1446
|
+
yield* workflowBoundary("authority", () => this.#store.appendEvent(fence, {
|
|
1447
|
+
details: { status: summary.status },
|
|
1448
|
+
kind: terminalRunStatus(summary.status) ? "run-finalized" : "run-status",
|
|
1449
|
+
timestamp: this.#now().toISOString(),
|
|
1450
|
+
}));
|
|
1451
|
+
yield* workflowBoundary("authority", () => this.#store.releaseClaim(fence));
|
|
1452
|
+
ownsFence = false;
|
|
1453
|
+
return {
|
|
1454
|
+
...(state.pause === undefined ? {} : { pause: state.pause }),
|
|
1455
|
+
summary,
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
}),
|
|
1459
|
+
failure => Effect.gen(this, function*() {
|
|
1460
|
+
const error = failure.cause;
|
|
1461
|
+
if (
|
|
1462
|
+
ownsFence
|
|
1463
|
+
&& (
|
|
1464
|
+
control.interruption?.kind === "cancellation"
|
|
1465
|
+
|| control.interruption?.kind === "deadline"
|
|
1466
|
+
)
|
|
1467
|
+
) {
|
|
1468
|
+
const interruptedRunning = [...running.values()].sort((left, right) => (
|
|
1469
|
+
left.nodeKey.localeCompare(right.nodeKey)
|
|
1470
|
+
));
|
|
1471
|
+
if (interruptedRunning.length > 0) {
|
|
1472
|
+
const outcomes = yield* Effect.forEach(interruptedRunning, item =>
|
|
1473
|
+
Effect.flatMap(Fiber.join(item.fiber), exit => Effect.map(exit, outcome => ({ item, outcome }))),
|
|
1474
|
+
{ concurrency: "unbounded" },
|
|
1475
|
+
);
|
|
1476
|
+
running.clear();
|
|
1477
|
+
for (const outcome of outcomes) {
|
|
1478
|
+
ledger.release(outcome.item.resources, outcome.item.publicationKeys);
|
|
1479
|
+
const deferred = (yield* workflowBoundary("authority", () => this.#persistOutcome(
|
|
1480
|
+
fence,
|
|
1481
|
+
graphPlan,
|
|
1482
|
+
outcome.item,
|
|
1483
|
+
outcome.outcome,
|
|
1484
|
+
control,
|
|
1485
|
+
)));
|
|
1486
|
+
state.reconciliationDeferred ||= deferred;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
yield* workflowBoundary("authority", () => this.#cancelWaitingNodes(fence, graphPlan));
|
|
1490
|
+
const summary = (yield* workflowBoundary("authority", () => this.#publishSummary(fence, graphPlan, state, running.size)));
|
|
1491
|
+
yield* workflowBoundary("authority", () => this.#store.appendEvent(fence, {
|
|
1492
|
+
details: { status: summary.status },
|
|
1493
|
+
kind: terminalRunStatus(summary.status) ? "run-finalized" : "run-status",
|
|
1494
|
+
timestamp: this.#now().toISOString(),
|
|
1495
|
+
}));
|
|
1496
|
+
yield* workflowBoundary("authority", () => this.#store.releaseClaim(fence));
|
|
1497
|
+
ownsFence = false;
|
|
1498
|
+
return { summary };
|
|
1499
|
+
}
|
|
1500
|
+
if (ownsFence) {
|
|
1501
|
+
const applicationError = asApplicationError(error);
|
|
1502
|
+
if (applicationError.code !== "conflict") {
|
|
1503
|
+
yield* workflowBoundary("authority", () => this.#markSchedulerFailure(fence, graphPlan, state, applicationError).catch(
|
|
1504
|
+
() => undefined,
|
|
1505
|
+
));
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return yield* Effect.fail(workflowFailure(error));
|
|
1509
|
+
}),
|
|
1510
|
+
));
|
|
1511
|
+
const cleanup = yield* Effect.exit(Effect.gen(this, function*() {
|
|
1512
|
+
yield* workflowBoundary("authority", () => stopCancellation());
|
|
1513
|
+
yield* workflowBoundary("authority", () => control.stop());
|
|
1514
|
+
|
|
1515
|
+
}));
|
|
1516
|
+
return yield* Exit.zipLeft(outcome, cleanup);
|
|
1517
|
+
}));
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
#releaseOwnedClaim(fence: RunFence): Effect.Effect<void, WorkflowFailure> {
|
|
1521
|
+
return Effect.gen(this, function* () {
|
|
1522
|
+
const ownership = yield* Effect.either(workflowBoundary("cleanup", () => this.#store.assertFence(fence)));
|
|
1523
|
+
if (Either.isLeft(ownership)) {
|
|
1524
|
+
// A fresh authoritative stale-fence result proves there is no claim of
|
|
1525
|
+
// ours left to release; preserving it is not a cleanup failure.
|
|
1526
|
+
if (ownership.left.cause instanceof ApplicationError && ownership.left.cause.code === "conflict") return;
|
|
1527
|
+
return yield* Effect.fail(ownership.left);
|
|
1528
|
+
}
|
|
1529
|
+
const release = yield* Effect.either(workflowBoundary("cleanup", () => this.#store.releaseClaim(fence)));
|
|
1530
|
+
if (Either.isRight(release)) return;
|
|
1531
|
+
if (release.left.cause instanceof ApplicationError && release.left.cause.code === "conflict") {
|
|
1532
|
+
const current = yield* Effect.either(workflowBoundary("cleanup", () => this.#store.assertFence(fence)));
|
|
1533
|
+
if (Either.isLeft(current) && current.left.cause instanceof ApplicationError && current.left.cause.code === "conflict") return;
|
|
1534
|
+
}
|
|
1535
|
+
return yield* Effect.fail(release.left);
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
async #summary(runId: string): Promise<RunSummary> {
|
|
1540
|
+
return await this.#store.summary(runId);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
async #cancellation(runId: string): Promise<CancellationRequest | undefined> {
|
|
1544
|
+
let lastConflict: ApplicationError | undefined;
|
|
1545
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
1546
|
+
try {
|
|
1547
|
+
return await this.#store.cancellation(runId);
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
const applicationError = asApplicationError(error);
|
|
1550
|
+
if (applicationError.code !== "conflict") throw error;
|
|
1551
|
+
lastConflict = applicationError;
|
|
1552
|
+
await Promise.resolve();
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
throw lastConflict ?? new ApplicationError(
|
|
1556
|
+
"conflict",
|
|
1557
|
+
`Could not read a stable cancellation marker for ${runId}.`,
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async #assertWorkflowActive(
|
|
1562
|
+
fence: RunFence,
|
|
1563
|
+
control: WorkflowRunControl,
|
|
1564
|
+
): Promise<void> {
|
|
1565
|
+
control.assertActive();
|
|
1566
|
+
const cancellation = await control.race(async () =>
|
|
1567
|
+
await this.#cancellation(fence.runId)
|
|
1568
|
+
);
|
|
1569
|
+
if (cancellation !== undefined) {
|
|
1570
|
+
control.interrupt(
|
|
1571
|
+
"cancellation",
|
|
1572
|
+
new ApplicationError("cancelled", "Workflow cancellation requested."),
|
|
1573
|
+
);
|
|
1574
|
+
control.assertActive();
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
async #runControlledPort<Value>(
|
|
1579
|
+
fence: RunFence,
|
|
1580
|
+
control: WorkflowRunControl,
|
|
1581
|
+
execute: () => Promise<Value>,
|
|
1582
|
+
): Promise<Value> {
|
|
1583
|
+
await this.#assertWorkflowActive(fence, control);
|
|
1584
|
+
const value = await control.race(execute);
|
|
1585
|
+
control.assertActive();
|
|
1586
|
+
return value;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
async #verifyRuntimeCompatibility(runId: string, graphPlan: GraphPlanV1): Promise<void> {
|
|
1590
|
+
if (graphPlan.runtime.applicationBuild !== this.#currentApplicationBuild) {
|
|
1591
|
+
throw new ApplicationError(
|
|
1592
|
+
"incompatible",
|
|
1593
|
+
"Persisted workflow application build differs from the current host build.",
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
const runtime = await this.#store.runtime(runId);
|
|
1597
|
+
if (canonicalJson(runtime.runtime) !== canonicalJson(graphPlan.runtime)) {
|
|
1598
|
+
throw new ApplicationError("incompatible", "Persisted workflow runtime identity changed.");
|
|
1599
|
+
}
|
|
1600
|
+
const expected = [...new Set(graphPlan.graph.nodes
|
|
1601
|
+
.filter(isOperationGraphNode)
|
|
1602
|
+
.map(node => (
|
|
1603
|
+
`${node.executor.operation.kind}@${String(node.executor.operation.version)}`
|
|
1604
|
+
)))].sort((left, right) => left.localeCompare(right));
|
|
1605
|
+
const persisted = runtime.operations.map(operation => (
|
|
1606
|
+
`${operation.kind}@${String(operation.version)}`
|
|
1607
|
+
)).sort((left, right) => left.localeCompare(right));
|
|
1608
|
+
if (canonicalJson(expected) !== canonicalJson(persisted)) {
|
|
1609
|
+
throw new ApplicationError("incompatible", "Persisted operation runtime set changed.");
|
|
1610
|
+
}
|
|
1611
|
+
const expectedComputes = [...new Map(
|
|
1612
|
+
graphPlan.graph.nodes
|
|
1613
|
+
.filter(isComputeGraphNode)
|
|
1614
|
+
.map(node => [node.executor.compute.key, node.executor.compute]),
|
|
1615
|
+
).values()].sort((left, right) => left.key.localeCompare(right.key));
|
|
1616
|
+
if (canonicalJson(expectedComputes) !== canonicalJson(runtime.computes)) {
|
|
1617
|
+
throw new ApplicationError("incompatible", "Persisted compute runtime set changed.");
|
|
1618
|
+
}
|
|
1619
|
+
for (const node of graphPlan.graph.nodes) {
|
|
1620
|
+
if (isOperationGraphNode(node)) {
|
|
1621
|
+
operationForNode(graphPlan, node, this.#registry);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
async #prepareComputeRuntime(
|
|
1627
|
+
fence: RunFence,
|
|
1628
|
+
graphPlan: GraphPlanV1,
|
|
1629
|
+
state: MutableRunState,
|
|
1630
|
+
control: WorkflowRunControl,
|
|
1631
|
+
): Promise<SchedulerComputeExecutorLease | undefined> {
|
|
1632
|
+
const runtime = this.#computeRuntime;
|
|
1633
|
+
if (runtime === undefined) return;
|
|
1634
|
+
if (runtime.kind === "fresh") {
|
|
1635
|
+
if (runtime.executor.bundleSha256 !== graphPlan.bundle.bundleSha256) {
|
|
1636
|
+
throw new ApplicationError(
|
|
1637
|
+
"incompatible",
|
|
1638
|
+
"Fresh code worker bundle does not match the persisted graph plan.",
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
this.#computeExecutor = runtime.executor;
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const nodeKeys = [...new Set(runtime.nodeKeys.map(key => NodeKeySchema.parse(key)))]
|
|
1645
|
+
.sort((left, right) => left.localeCompare(right));
|
|
1646
|
+
if (nodeKeys.length === 0) {
|
|
1647
|
+
throw new ApplicationError(
|
|
1648
|
+
"usage",
|
|
1649
|
+
"A compute replay requires at least one exact node key.",
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
if (await this.#cancellation(fence.runId) !== undefined) {
|
|
1653
|
+
state.cancelled = true;
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
const records = await this.#store.nodes(fence.runId);
|
|
1657
|
+
const nodesByKey = new Map(graphPlan.graph.nodes.map(node => [node.key, node]));
|
|
1658
|
+
const grants: NewRunGrant[] = [];
|
|
1659
|
+
for (const nodeKey of nodeKeys) {
|
|
1660
|
+
const node = nodesByKey.get(nodeKey);
|
|
1661
|
+
const record = records.find(candidate => candidate.nodeKey === nodeKey);
|
|
1662
|
+
if (
|
|
1663
|
+
node === undefined
|
|
1664
|
+
|| !isComputeGraphNode(node)
|
|
1665
|
+
|| record?.status !== "ambiguous-code"
|
|
1666
|
+
|| record.executionPlan === undefined
|
|
1667
|
+
) {
|
|
1668
|
+
throw new ApplicationError(
|
|
1669
|
+
"conflict",
|
|
1670
|
+
`Compute node is not awaiting exact replay: ${nodeKey}`,
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
grants.push({
|
|
1674
|
+
attempt: record.attempt + 1,
|
|
1675
|
+
bundleSha256: graphPlan.bundle.bundleSha256,
|
|
1676
|
+
computeKey: node.executor.compute.key,
|
|
1677
|
+
createdAt: this.#now().toISOString(),
|
|
1678
|
+
graphPlanSha256: graphPlan.graphPlanSha256,
|
|
1679
|
+
grantedBy: this.#authorization.grantedBy,
|
|
1680
|
+
grantId: randomUUID(),
|
|
1681
|
+
kind: "compute-replay",
|
|
1682
|
+
nodeKey,
|
|
1683
|
+
nodePlanSha256: record.executionPlan.nodePlanSha256,
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
for (const grant of grants) {
|
|
1687
|
+
if (await this.#cancellation(fence.runId) !== undefined) {
|
|
1688
|
+
state.cancelled = true;
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
await this.#store.appendGrant(fence, grant);
|
|
1692
|
+
}
|
|
1693
|
+
if (await this.#cancellation(fence.runId) !== undefined) {
|
|
1694
|
+
state.cancelled = true;
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
await this.#assertWorkflowActive(fence, control);
|
|
1698
|
+
const remainingMilliseconds = control.deadlineMonotonicMs - performance.now();
|
|
1699
|
+
if (remainingMilliseconds <= 0) {
|
|
1700
|
+
control.interrupt(
|
|
1701
|
+
"deadline",
|
|
1702
|
+
new ApplicationError(
|
|
1703
|
+
"cancelled",
|
|
1704
|
+
"Workflow exceeded the host wall-clock bound.",
|
|
1705
|
+
),
|
|
1706
|
+
);
|
|
1707
|
+
control.assertActive();
|
|
1708
|
+
}
|
|
1709
|
+
const load = runtime.load({
|
|
1710
|
+
signal: control.signal,
|
|
1711
|
+
waitTimeoutMilliseconds: Math.min(
|
|
1712
|
+
HOST_RESOURCE_MAX_WAIT_MILLISECONDS,
|
|
1713
|
+
Math.max(1, Math.ceil(remainingMilliseconds)),
|
|
1714
|
+
),
|
|
1715
|
+
});
|
|
1716
|
+
let lease: SchedulerComputeExecutorLease;
|
|
1717
|
+
try {
|
|
1718
|
+
lease = await control.race(async () => await load);
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
// Cancellation may win after replay admission has already entered its
|
|
1721
|
+
// callback. Keep the scheduler return bounded, but make the eventual
|
|
1722
|
+
// executor owner close anything that settles after the run fence leaves.
|
|
1723
|
+
void load.then(
|
|
1724
|
+
async lateLease => await lateLease.release(),
|
|
1725
|
+
() => undefined,
|
|
1726
|
+
).catch(() => undefined);
|
|
1727
|
+
throw error;
|
|
1728
|
+
}
|
|
1729
|
+
try {
|
|
1730
|
+
control.assertActive();
|
|
1731
|
+
if (lease.executor.bundleSha256 !== graphPlan.bundle.bundleSha256) {
|
|
1732
|
+
throw new ApplicationError(
|
|
1733
|
+
"incompatible",
|
|
1734
|
+
"Replay code worker bundle does not match the persisted graph plan.",
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
this.#computeExecutor = lease.executor;
|
|
1738
|
+
for (const nodeKey of nodeKeys) this.#replayComputeNodeKeys.add(nodeKey);
|
|
1739
|
+
for (const nodeKey of nodeKeys) {
|
|
1740
|
+
await this.#store.reopenComputeNode(fence, nodeKey);
|
|
1741
|
+
}
|
|
1742
|
+
return lease;
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
await lease.release().catch(() => undefined);
|
|
1745
|
+
throw error;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
async #reconcileInterrupted(
|
|
1750
|
+
fence: RunFence,
|
|
1751
|
+
graphPlan: GraphPlanV1,
|
|
1752
|
+
state: MutableRunState,
|
|
1753
|
+
control: WorkflowRunControl,
|
|
1754
|
+
): Promise<void> {
|
|
1755
|
+
const records = await this.#store.nodes(fence.runId);
|
|
1756
|
+
const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
|
|
1757
|
+
for (const node of [...graphPlan.graph.nodes].sort((left, right) => (
|
|
1758
|
+
left.key.localeCompare(right.key)
|
|
1759
|
+
))) {
|
|
1760
|
+
const record = recordsByKey.get(node.key);
|
|
1761
|
+
if (record === undefined) {
|
|
1762
|
+
throw new ApplicationError("invalid-data", `Run is missing node ${node.key}.`);
|
|
1763
|
+
}
|
|
1764
|
+
if (record.status === "completed") {
|
|
1765
|
+
verifiedOutput(record);
|
|
1766
|
+
await this.#store.appendEvent(fence, {
|
|
1767
|
+
details: { digestSha256: record.output?.digestSha256 ?? "" },
|
|
1768
|
+
kind: "node-reused",
|
|
1769
|
+
nodeKey: node.key,
|
|
1770
|
+
timestamp: this.#now().toISOString(),
|
|
1771
|
+
});
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
if (record.status === "approval-required") continue;
|
|
1775
|
+
if (record.status === "preparing") {
|
|
1776
|
+
await this.#persistNode(fence, {
|
|
1777
|
+
...record,
|
|
1778
|
+
status: "ready",
|
|
1779
|
+
});
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
if (record.status !== "running") continue;
|
|
1783
|
+
|
|
1784
|
+
if (isComputeGraphNode(node)) {
|
|
1785
|
+
await this.#persistNode(fence, {
|
|
1786
|
+
...record,
|
|
1787
|
+
failure: schedulerFailure(new ApplicationError(
|
|
1788
|
+
"ambiguous",
|
|
1789
|
+
"Interrupted trusted compute requires an explicit exact-bundle replay.",
|
|
1790
|
+
)),
|
|
1791
|
+
finishedAt: this.#now().toISOString(),
|
|
1792
|
+
status: "ambiguous-code",
|
|
1793
|
+
});
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
if (!isOperationGraphNode(node)) {
|
|
1797
|
+
throw new ApplicationError(
|
|
1798
|
+
"internal",
|
|
1799
|
+
`Unknown node executor for ${node.key}.`,
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const resumeClass = operationForNode(
|
|
1804
|
+
graphPlan,
|
|
1805
|
+
node,
|
|
1806
|
+
this.#registry,
|
|
1807
|
+
).discovery.policy.resume;
|
|
1808
|
+
if (resumeClass === "deterministic") {
|
|
1809
|
+
await this.#persistNode(fence, {
|
|
1810
|
+
...record,
|
|
1811
|
+
failure: undefined,
|
|
1812
|
+
finishedAt: undefined,
|
|
1813
|
+
status: "ready",
|
|
1814
|
+
});
|
|
1815
|
+
continue;
|
|
1816
|
+
}
|
|
1817
|
+
const reconciled = await this.#reconcileOperationRecord(
|
|
1818
|
+
fence,
|
|
1819
|
+
graphPlan,
|
|
1820
|
+
node,
|
|
1821
|
+
record,
|
|
1822
|
+
recordsByKey,
|
|
1823
|
+
control,
|
|
1824
|
+
);
|
|
1825
|
+
if (reconciled.deferred) {
|
|
1826
|
+
state.reconciliationDeferred = true;
|
|
1827
|
+
if (control.interruption !== undefined) break;
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
if (
|
|
1831
|
+
reconciled.reconciliation?.kind === "ambiguous"
|
|
1832
|
+
&& resumeClass === "non-resumable-live"
|
|
1833
|
+
) {
|
|
1834
|
+
state.cancelled = false;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
async #reconcileOperationRecord(
|
|
1840
|
+
fence: RunFence,
|
|
1841
|
+
graphPlan: GraphPlanV1,
|
|
1842
|
+
node: AuthoredOperationGraphNodeV1,
|
|
1843
|
+
record: RunNodeRecord,
|
|
1844
|
+
recordsByKey: ReadonlyMap<string, RunNodeRecord>,
|
|
1845
|
+
control: WorkflowRunControl,
|
|
1846
|
+
): Promise<{
|
|
1847
|
+
readonly deferred: boolean;
|
|
1848
|
+
readonly reconciliation: NodeReconciliation | undefined;
|
|
1849
|
+
}> {
|
|
1850
|
+
const operation = operationForNode(graphPlan, node, this.#registry);
|
|
1851
|
+
const resolved = resolveNode(node, recordsByKey);
|
|
1852
|
+
const resumeClass = operation.discovery.policy.resume;
|
|
1853
|
+
await this.#store.assertFence(fence);
|
|
1854
|
+
const workspaceDirectory = record.executionPlan === undefined
|
|
1855
|
+
? undefined
|
|
1856
|
+
: await this.#store.stagingDirectory(
|
|
1857
|
+
fence,
|
|
1858
|
+
node.key,
|
|
1859
|
+
record.executionPlan.nodePlanSha256,
|
|
1860
|
+
);
|
|
1861
|
+
let reconciliation: NodeReconciliation | undefined;
|
|
1862
|
+
try {
|
|
1863
|
+
const reconcile = this.#nodePlanner.reconcile;
|
|
1864
|
+
const reconcileEffect = this.#nodePlanner.reconcileEffect;
|
|
1865
|
+
reconciliation = reconcile === undefined && reconcileEffect === undefined
|
|
1866
|
+
? undefined
|
|
1867
|
+
: await this.#runControlledPort(
|
|
1868
|
+
fence,
|
|
1869
|
+
control,
|
|
1870
|
+
async () => {
|
|
1871
|
+
const execute = async () => await this.#hostResourceCoordinator.withLease(
|
|
1872
|
+
physicalHostResourceClaims(
|
|
1873
|
+
operation.discovery.policy.resources,
|
|
1874
|
+
this.#hostResourceCoordinator,
|
|
1875
|
+
),
|
|
1876
|
+
async lease => {
|
|
1877
|
+
await lease.assertOwned();
|
|
1878
|
+
const beforePublication = async (): Promise<void> => {
|
|
1879
|
+
await lease.assertOwned();
|
|
1880
|
+
control.assertActive();
|
|
1881
|
+
if (control.signal.aborted) {
|
|
1882
|
+
throw new ApplicationError(
|
|
1883
|
+
"cancelled",
|
|
1884
|
+
"Workflow reconciliation was cancelled before publication.",
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
await this.#store.assertFence(fence);
|
|
1888
|
+
await this.#assertWorkflowActive(fence, control);
|
|
1889
|
+
await this.#store.assertFence(fence);
|
|
1890
|
+
control.assertActive();
|
|
1891
|
+
await lease.assertOwned();
|
|
1892
|
+
};
|
|
1893
|
+
const request: NodeReconciliationRequest = {
|
|
1894
|
+
abortSignal: control.signal,
|
|
1895
|
+
application: applicationWithHostResourceLease(this.#application, lease),
|
|
1896
|
+
beforePublication,
|
|
1897
|
+
dependencyOutputs: resolved.dependencyOutputs,
|
|
1898
|
+
executionPlan: record.executionPlan,
|
|
1899
|
+
graphPlan,
|
|
1900
|
+
node,
|
|
1901
|
+
operation: operation.discovery,
|
|
1902
|
+
preparationPlan: record.preparationPlan,
|
|
1903
|
+
previous: record,
|
|
1904
|
+
resolvedInput: resolved.input,
|
|
1905
|
+
resumeClass,
|
|
1906
|
+
runId: fence.runId,
|
|
1907
|
+
...(workspaceDirectory === undefined ? {} : { workspaceDirectory }),
|
|
1908
|
+
};
|
|
1909
|
+
return reconcileEffect === undefined
|
|
1910
|
+
? await reconcile!.call(this.#nodePlanner, request)
|
|
1911
|
+
: await control.runNative(reconcileEffect.call(this.#nodePlanner, request));
|
|
1912
|
+
},
|
|
1913
|
+
{
|
|
1914
|
+
signal: control.signal,
|
|
1915
|
+
waitTimeoutMilliseconds: HOST_RESOURCE_MAX_WAIT_MILLISECONDS,
|
|
1916
|
+
},
|
|
1917
|
+
);
|
|
1918
|
+
// Retain the existing owner before native lease admission starts.
|
|
1919
|
+
// A stopped observer may leave acquisition or release in flight;
|
|
1920
|
+
// neither permits disposing the runtime used by that callback.
|
|
1921
|
+
return reconcileEffect === undefined
|
|
1922
|
+
? await execute()
|
|
1923
|
+
: await control.custody(workflowBoundary("authority", execute));
|
|
1924
|
+
},
|
|
1925
|
+
);
|
|
1926
|
+
} catch {
|
|
1927
|
+
// A reconciliation adapter may fail or outlive this scheduler invocation.
|
|
1928
|
+
// Preserve the durable running record so a later fenced owner can retry
|
|
1929
|
+
// reconciliation instead of converting a possibly published effect into
|
|
1930
|
+
// an immutable failed node.
|
|
1931
|
+
await this.#store.assertFence(fence);
|
|
1932
|
+
return { deferred: true, reconciliation: undefined };
|
|
1933
|
+
}
|
|
1934
|
+
await this.#store.assertFence(fence);
|
|
1935
|
+
await this.#applyReconciliation(
|
|
1936
|
+
fence,
|
|
1937
|
+
record,
|
|
1938
|
+
operation,
|
|
1939
|
+
resumeClass,
|
|
1940
|
+
reconciliation,
|
|
1941
|
+
);
|
|
1942
|
+
return { deferred: false, reconciliation };
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async #applyReconciliation(
|
|
1946
|
+
fence: RunFence,
|
|
1947
|
+
record: RunNodeRecord,
|
|
1948
|
+
operation: RegisteredOperation,
|
|
1949
|
+
resumeClass: OperationResumeClass,
|
|
1950
|
+
reconciliation: NodeReconciliation | undefined,
|
|
1951
|
+
): Promise<void> {
|
|
1952
|
+
if (reconciliation?.kind === "completed") {
|
|
1953
|
+
const output = createRunNodeOutput(
|
|
1954
|
+
reconciliation.output,
|
|
1955
|
+
reconciliation.summary,
|
|
1956
|
+
operation.discovery.policy.maxOutputBytes,
|
|
1957
|
+
reconciliation.receiptReference,
|
|
1958
|
+
);
|
|
1959
|
+
await this.#persistNode(fence, {
|
|
1960
|
+
...record,
|
|
1961
|
+
failure: undefined,
|
|
1962
|
+
finishedAt: this.#now().toISOString(),
|
|
1963
|
+
output,
|
|
1964
|
+
status: "completed",
|
|
1965
|
+
});
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
const retryAllowed = resumeClass === "verified-receipt"
|
|
1969
|
+
|| resumeClass === "recoverable-transaction";
|
|
1970
|
+
if (reconciliation?.kind === "retry" && retryAllowed) {
|
|
1971
|
+
await this.#persistNode(fence, {
|
|
1972
|
+
...record,
|
|
1973
|
+
failure: undefined,
|
|
1974
|
+
finishedAt: undefined,
|
|
1975
|
+
status: "ready",
|
|
1976
|
+
});
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
if (reconciliation?.kind === "incompatible") {
|
|
1980
|
+
await this.#persistNode(fence, {
|
|
1981
|
+
...record,
|
|
1982
|
+
failure: schedulerFailure(
|
|
1983
|
+
new ApplicationError("incompatible", reconciliation.message),
|
|
1984
|
+
),
|
|
1985
|
+
finishedAt: this.#now().toISOString(),
|
|
1986
|
+
status: "incompatible",
|
|
1987
|
+
});
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const message = reconciliation?.kind === "ambiguous"
|
|
1991
|
+
? reconciliation.message
|
|
1992
|
+
: resumeClass === "non-resumable-live"
|
|
1993
|
+
? "Interrupted live operation requires explicit operator reconciliation."
|
|
1994
|
+
: resumeClass === "ambiguous-after-dispatch"
|
|
1995
|
+
? "Interrupted paid operation has an ambiguous dispatch outcome."
|
|
1996
|
+
: "Interrupted operation has no compatible reconciliation implementation.";
|
|
1997
|
+
const status = resumeClass === "verified-receipt"
|
|
1998
|
+
|| resumeClass === "recoverable-transaction"
|
|
1999
|
+
? "incompatible"
|
|
2000
|
+
: "ambiguous";
|
|
2001
|
+
await this.#persistNode(fence, {
|
|
2002
|
+
...record,
|
|
2003
|
+
failure: schedulerFailure(new ApplicationError(
|
|
2004
|
+
status === "incompatible" ? "incompatible" : "ambiguous",
|
|
2005
|
+
message,
|
|
2006
|
+
)),
|
|
2007
|
+
finishedAt: this.#now().toISOString(),
|
|
2008
|
+
status,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
async #advanceDependencies(
|
|
2013
|
+
fence: RunFence,
|
|
2014
|
+
graphPlan: GraphPlanV1,
|
|
2015
|
+
state: MutableRunState,
|
|
2016
|
+
): Promise<void> {
|
|
2017
|
+
let changed = true;
|
|
2018
|
+
while (changed) {
|
|
2019
|
+
changed = false;
|
|
2020
|
+
const records = await this.#store.nodes(fence.runId);
|
|
2021
|
+
const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
|
|
2022
|
+
for (const node of [...graphPlan.graph.nodes].sort((left, right) => (
|
|
2023
|
+
left.key.localeCompare(right.key)
|
|
2024
|
+
))) {
|
|
2025
|
+
const record = recordsByKey.get(node.key);
|
|
2026
|
+
if (record === undefined || record.status !== "pending") continue;
|
|
2027
|
+
const dependencies = node.dependencies.map(key => recordsByKey.get(key));
|
|
2028
|
+
if (dependencies.some(dependency => dependency === undefined)) {
|
|
2029
|
+
throw new ApplicationError("invalid-data", `Node ${node.key} has a missing dependency.`);
|
|
2030
|
+
}
|
|
2031
|
+
if (dependencies.some(dependency => (
|
|
2032
|
+
dependency !== undefined && dependencyTerminalFailure(dependency)
|
|
2033
|
+
))) {
|
|
2034
|
+
const updated = {
|
|
2035
|
+
...record,
|
|
2036
|
+
finishedAt: this.#now().toISOString(),
|
|
2037
|
+
status: state.cancelled ? "cancelled" : "skipped",
|
|
2038
|
+
} satisfies RunNodeRecord;
|
|
2039
|
+
await this.#persistNode(fence, updated);
|
|
2040
|
+
// The durable node write and matching audit event both succeeded.
|
|
2041
|
+
// Carry that authoritative transition through this pass so a long
|
|
2042
|
+
// failed-dependency chain does not require one full store scan per
|
|
2043
|
+
// skipped node. The final unchanged pass still reloads and validates
|
|
2044
|
+
// the complete persisted node set.
|
|
2045
|
+
recordsByKey.set(node.key, updated);
|
|
2046
|
+
changed = true;
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
if (dependencies.every(dependency => dependency?.status === "completed")) {
|
|
2050
|
+
const updated = {
|
|
2051
|
+
...record,
|
|
2052
|
+
status: "ready",
|
|
2053
|
+
} satisfies RunNodeRecord;
|
|
2054
|
+
await this.#persistNode(fence, updated);
|
|
2055
|
+
recordsByKey.set(node.key, updated);
|
|
2056
|
+
changed = true;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
async #prepareReadyNodes(
|
|
2063
|
+
fence: RunFence,
|
|
2064
|
+
graphPlan: GraphPlanV1,
|
|
2065
|
+
state: MutableRunState,
|
|
2066
|
+
control: WorkflowRunControl,
|
|
2067
|
+
prepared: Map<string, PreparedNode>,
|
|
2068
|
+
): Promise<void> {
|
|
2069
|
+
const records = await this.#store.nodes(fence.runId);
|
|
2070
|
+
const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
|
|
2071
|
+
const nodesByKey = new Map(graphPlan.graph.nodes.map(node => [node.key, node]));
|
|
2072
|
+
for (const record of records
|
|
2073
|
+
.filter(item => item.status === "ready" || item.status === "approval-required")
|
|
2074
|
+
.sort((left, right) => left.nodeKey.localeCompare(right.nodeKey))) {
|
|
2075
|
+
if (state.pause !== undefined || state.cancelled) break;
|
|
2076
|
+
if (prepared.has(record.nodeKey)) continue;
|
|
2077
|
+
await this.#assertWorkflowActive(fence, control);
|
|
2078
|
+
const node = nodesByKey.get(record.nodeKey);
|
|
2079
|
+
if (node === undefined) {
|
|
2080
|
+
throw new ApplicationError("invalid-data", `Run contains unknown node ${record.nodeKey}.`);
|
|
2081
|
+
}
|
|
2082
|
+
const executor = executorForNode(graphPlan, node, this.#registry);
|
|
2083
|
+
const resolved = resolveNode(node, recordsByKey);
|
|
2084
|
+
let context: NodePreparationRequest | undefined;
|
|
2085
|
+
if (executor.kind === "operation") {
|
|
2086
|
+
if (!isOperationGraphNode(node)) {
|
|
2087
|
+
throw new ApplicationError(
|
|
2088
|
+
"internal",
|
|
2089
|
+
`Node executor classification changed for ${node.key}.`,
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
context = {
|
|
2093
|
+
dependencyOutputs: resolved.dependencyOutputs,
|
|
2094
|
+
graphPlan,
|
|
2095
|
+
node,
|
|
2096
|
+
operation: executor.operation.discovery,
|
|
2097
|
+
resolvedInput: resolved.input,
|
|
2098
|
+
runId: fence.runId,
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
let preparationPlan = record.preparationPlan;
|
|
2102
|
+
if (preparationPlan === undefined) {
|
|
2103
|
+
await this.#persistNode(fence, {
|
|
2104
|
+
...record,
|
|
2105
|
+
status: "preparing",
|
|
2106
|
+
});
|
|
2107
|
+
await this.#store.assertFence(fence);
|
|
2108
|
+
const binding = context === undefined
|
|
2109
|
+
? computePreparationBinding(resolved)
|
|
2110
|
+
: await this.#runControlledPort(
|
|
2111
|
+
fence,
|
|
2112
|
+
control,
|
|
2113
|
+
async () => await this.#nodePlanner.prepare(context),
|
|
2114
|
+
);
|
|
2115
|
+
preparationPlan = createPreparationPlan(
|
|
2116
|
+
graphPlan,
|
|
2117
|
+
node,
|
|
2118
|
+
executor.policy,
|
|
2119
|
+
binding,
|
|
2120
|
+
);
|
|
2121
|
+
} else {
|
|
2122
|
+
verifyPreparationPlan(preparationPlan, graphPlan, node, executor.policy);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
const preparationAuthorized = await this.#authorizePreparation(
|
|
2126
|
+
fence,
|
|
2127
|
+
graphPlan,
|
|
2128
|
+
node,
|
|
2129
|
+
executor,
|
|
2130
|
+
preparationPlan,
|
|
2131
|
+
control,
|
|
2132
|
+
);
|
|
2133
|
+
if (!preparationAuthorized) {
|
|
2134
|
+
const paused = RunNodeRecordSchema.parse({
|
|
2135
|
+
...record,
|
|
2136
|
+
preparationPlan,
|
|
2137
|
+
status: "approval-required",
|
|
2138
|
+
version: RUN_NODE_VERSION,
|
|
2139
|
+
});
|
|
2140
|
+
await this.#persistNode(fence, paused);
|
|
2141
|
+
state.pause = {
|
|
2142
|
+
nodeKey: node.key,
|
|
2143
|
+
phase: "preparation",
|
|
2144
|
+
planSha256: preparationPlan.preparationPlanSha256,
|
|
2145
|
+
};
|
|
2146
|
+
await this.#store.appendEvent(fence, {
|
|
2147
|
+
details: {
|
|
2148
|
+
phase: "preparation",
|
|
2149
|
+
planSha256: preparationPlan.preparationPlanSha256,
|
|
2150
|
+
},
|
|
2151
|
+
kind: "approval-required",
|
|
2152
|
+
nodeKey: node.key,
|
|
2153
|
+
timestamp: this.#now().toISOString(),
|
|
2154
|
+
});
|
|
2155
|
+
break;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
let executionPlan = record.executionPlan;
|
|
2159
|
+
if (executionPlan === undefined) {
|
|
2160
|
+
await this.#store.assertFence(fence);
|
|
2161
|
+
const binding = context === undefined
|
|
2162
|
+
? {
|
|
2163
|
+
exactInput: resolved.input,
|
|
2164
|
+
publicationKeys: [],
|
|
2165
|
+
}
|
|
2166
|
+
: await this.#runControlledPort(
|
|
2167
|
+
fence,
|
|
2168
|
+
control,
|
|
2169
|
+
async () => {
|
|
2170
|
+
if (context.operation.kind !== "slopcamera.studio.run") return await this.#nodePlanner.plan({ ...context, preparationPlan });
|
|
2171
|
+
return await this.#hostResourceCoordinator.withLease(
|
|
2172
|
+
physicalHostResourceClaims(context.operation.policy.resources, this.#hostResourceCoordinator),
|
|
2173
|
+
async lease => {
|
|
2174
|
+
const beforePublication = async () => {
|
|
2175
|
+
await lease.assertOwned();
|
|
2176
|
+
await this.#store.assertFence(fence);
|
|
2177
|
+
await this.#assertWorkflowActive(fence, control);
|
|
2178
|
+
await this.#store.assertFence(fence);
|
|
2179
|
+
await lease.assertOwned();
|
|
2180
|
+
};
|
|
2181
|
+
await beforePublication();
|
|
2182
|
+
return await this.#nodePlanner.plan({ ...context, preparationPlan,
|
|
2183
|
+
application: applicationWithHostResourceLease(this.#application, lease),
|
|
2184
|
+
abortSignal: control.signal, beforePublication,
|
|
2185
|
+
});
|
|
2186
|
+
},
|
|
2187
|
+
{ signal: control.signal },
|
|
2188
|
+
);
|
|
2189
|
+
},
|
|
2190
|
+
);
|
|
2191
|
+
executionPlan = createExecutionPlan(
|
|
2192
|
+
graphPlan,
|
|
2193
|
+
node,
|
|
2194
|
+
executor.policy,
|
|
2195
|
+
preparationPlan,
|
|
2196
|
+
resolved,
|
|
2197
|
+
binding,
|
|
2198
|
+
);
|
|
2199
|
+
} else {
|
|
2200
|
+
verifyExecutionPlan(
|
|
2201
|
+
executionPlan,
|
|
2202
|
+
graphPlan,
|
|
2203
|
+
node,
|
|
2204
|
+
executor.policy,
|
|
2205
|
+
preparationPlan,
|
|
2206
|
+
resolved,
|
|
2207
|
+
);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
const effectAuthorized = await this.#authorizeEffect(
|
|
2211
|
+
fence,
|
|
2212
|
+
graphPlan,
|
|
2213
|
+
node,
|
|
2214
|
+
executor,
|
|
2215
|
+
executionPlan,
|
|
2216
|
+
control,
|
|
2217
|
+
);
|
|
2218
|
+
if (!effectAuthorized) {
|
|
2219
|
+
const paused = RunNodeRecordSchema.parse({
|
|
2220
|
+
...record,
|
|
2221
|
+
executionPlan,
|
|
2222
|
+
preparationPlan,
|
|
2223
|
+
status: "approval-required",
|
|
2224
|
+
version: RUN_NODE_VERSION,
|
|
2225
|
+
});
|
|
2226
|
+
await this.#persistNode(fence, paused);
|
|
2227
|
+
state.pause = {
|
|
2228
|
+
nodeKey: node.key,
|
|
2229
|
+
phase: "effect",
|
|
2230
|
+
planSha256: executionPlan.nodePlanSha256,
|
|
2231
|
+
};
|
|
2232
|
+
await this.#store.appendEvent(fence, {
|
|
2233
|
+
details: {
|
|
2234
|
+
phase: "effect",
|
|
2235
|
+
planSha256: executionPlan.nodePlanSha256,
|
|
2236
|
+
},
|
|
2237
|
+
kind: "approval-required",
|
|
2238
|
+
nodeKey: node.key,
|
|
2239
|
+
timestamp: this.#now().toISOString(),
|
|
2240
|
+
});
|
|
2241
|
+
break;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const ready = RunNodeRecordSchema.parse({
|
|
2245
|
+
...record,
|
|
2246
|
+
executionPlan,
|
|
2247
|
+
failure: undefined,
|
|
2248
|
+
preparationPlan,
|
|
2249
|
+
status: "ready",
|
|
2250
|
+
version: RUN_NODE_VERSION,
|
|
2251
|
+
});
|
|
2252
|
+
if (
|
|
2253
|
+
record.status !== "ready"
|
|
2254
|
+
|| record.preparationPlan === undefined
|
|
2255
|
+
|| record.executionPlan === undefined
|
|
2256
|
+
|| record.failure !== undefined
|
|
2257
|
+
) {
|
|
2258
|
+
await this.#persistNode(fence, ready);
|
|
2259
|
+
}
|
|
2260
|
+
recordsByKey.set(node.key, ready);
|
|
2261
|
+
prepared.set(node.key, {
|
|
2262
|
+
executionPlan,
|
|
2263
|
+
executor,
|
|
2264
|
+
record: ready,
|
|
2265
|
+
resolved,
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
async #authorizePreparation(
|
|
2271
|
+
fence: RunFence,
|
|
2272
|
+
graphPlan: GraphPlanV1,
|
|
2273
|
+
node: AuthoredGraphNodeV1,
|
|
2274
|
+
executor: ResolvedNodeExecutor,
|
|
2275
|
+
preparationPlan: NodePreparationPlan,
|
|
2276
|
+
control: WorkflowRunControl,
|
|
2277
|
+
): Promise<boolean> {
|
|
2278
|
+
if (preparationPlan.requestedPreparation.length === 0) return true;
|
|
2279
|
+
const grants = await this.#store.grants(fence.runId);
|
|
2280
|
+
if (exactPreparationGrant(
|
|
2281
|
+
grants,
|
|
2282
|
+
graphPlan.graphPlanSha256,
|
|
2283
|
+
preparationPlan.nodeKey,
|
|
2284
|
+
preparationPlan.preparationPlanSha256,
|
|
2285
|
+
)) return true;
|
|
2286
|
+
const allowed = authorizationAllowed(await this.#runControlledPort(
|
|
2287
|
+
fence,
|
|
2288
|
+
control,
|
|
2289
|
+
async () => await this.#authorization.authorizePreparation({
|
|
2290
|
+
executor: node.executor,
|
|
2291
|
+
graphPlanSha256: graphPlan.graphPlanSha256,
|
|
2292
|
+
nodeKey: preparationPlan.nodeKey,
|
|
2293
|
+
...(executor.kind === "operation"
|
|
2294
|
+
? { operation: executor.operation.discovery }
|
|
2295
|
+
: {}),
|
|
2296
|
+
policy: executor.policy,
|
|
2297
|
+
preparationPlan,
|
|
2298
|
+
runId: fence.runId,
|
|
2299
|
+
}),
|
|
2300
|
+
));
|
|
2301
|
+
if (!allowed) return false;
|
|
2302
|
+
const grant: NewRunGrant = {
|
|
2303
|
+
createdAt: this.#now().toISOString(),
|
|
2304
|
+
graphPlanSha256: graphPlan.graphPlanSha256,
|
|
2305
|
+
grantedBy: this.#authorization.grantedBy,
|
|
2306
|
+
grantId: randomUUID(),
|
|
2307
|
+
kind: "preparation",
|
|
2308
|
+
nodeKey: preparationPlan.nodeKey,
|
|
2309
|
+
preparationPlanSha256: preparationPlan.preparationPlanSha256,
|
|
2310
|
+
};
|
|
2311
|
+
await this.#assertWorkflowActive(fence, control);
|
|
2312
|
+
await this.#store.appendGrant(fence, grant);
|
|
2313
|
+
return true;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
async #authorizeEffect(
|
|
2317
|
+
fence: RunFence,
|
|
2318
|
+
graphPlan: GraphPlanV1,
|
|
2319
|
+
node: AuthoredGraphNodeV1,
|
|
2320
|
+
executor: ResolvedNodeExecutor,
|
|
2321
|
+
executionPlan: NodeExecutionPlan,
|
|
2322
|
+
control: WorkflowRunControl,
|
|
2323
|
+
): Promise<boolean> {
|
|
2324
|
+
// Native trust is ephemeral and exact-plan-bound; persisted generic grants never supply it.
|
|
2325
|
+
if (executor.kind === "operation" && executor.operation.discovery.kind === "slopcamera.studio.run") {
|
|
2326
|
+
const authorization = this.#application.studioAuthorization;
|
|
2327
|
+
if (authorization === undefined || !await this.#runControlledPort(fence, control, async () =>
|
|
2328
|
+
await authorization.authorize(studioAuthorizationRequest(executionPlan.exactInput)))) return false;
|
|
2329
|
+
}
|
|
2330
|
+
const grants = await this.#store.grants(fence.runId);
|
|
2331
|
+
if (exactEffectGrant(
|
|
2332
|
+
grants,
|
|
2333
|
+
graphPlan.graphPlanSha256,
|
|
2334
|
+
executionPlan.nodeKey,
|
|
2335
|
+
executionPlan.nodePlanSha256,
|
|
2336
|
+
)) return true;
|
|
2337
|
+
const allowed = authorizationAllowed(await this.#runControlledPort(
|
|
2338
|
+
fence,
|
|
2339
|
+
control,
|
|
2340
|
+
async () => await this.#authorization.authorizeEffect({
|
|
2341
|
+
executor: node.executor,
|
|
2342
|
+
executionPlan,
|
|
2343
|
+
graphPlanSha256: graphPlan.graphPlanSha256,
|
|
2344
|
+
nodeKey: executionPlan.nodeKey,
|
|
2345
|
+
...(executor.kind === "operation"
|
|
2346
|
+
? { operation: executor.operation.discovery }
|
|
2347
|
+
: {}),
|
|
2348
|
+
policy: executor.policy,
|
|
2349
|
+
runId: fence.runId,
|
|
2350
|
+
}),
|
|
2351
|
+
));
|
|
2352
|
+
if (!allowed) return false;
|
|
2353
|
+
const grant: NewRunGrant = {
|
|
2354
|
+
createdAt: this.#now().toISOString(),
|
|
2355
|
+
graphPlanSha256: graphPlan.graphPlanSha256,
|
|
2356
|
+
grantedBy: this.#authorization.grantedBy,
|
|
2357
|
+
grantId: randomUUID(),
|
|
2358
|
+
kind: "effect",
|
|
2359
|
+
nodeKey: executionPlan.nodeKey,
|
|
2360
|
+
nodePlanSha256: executionPlan.nodePlanSha256,
|
|
2361
|
+
};
|
|
2362
|
+
await this.#assertWorkflowActive(fence, control);
|
|
2363
|
+
await this.#store.appendGrant(fence, grant);
|
|
2364
|
+
return true;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
async #startAdmittedNodes(
|
|
2368
|
+
fence: RunFence,
|
|
2369
|
+
prepared: Map<string, PreparedNode>,
|
|
2370
|
+
running: Map<string, RunningNode>,
|
|
2371
|
+
ledger: ResourceLedger,
|
|
2372
|
+
workflowDeadlineMonotonicMs: number,
|
|
2373
|
+
control: WorkflowRunControl,
|
|
2374
|
+
owner: WorkflowEffectRuntime,
|
|
2375
|
+
completions: Queue.Queue<{ nodeKey: string; outcome: Exit.Exit<NodeExecutionOutcome, WorkflowFailure> }>,
|
|
2376
|
+
): Promise<void> {
|
|
2377
|
+
for (const item of [...prepared.values()].sort((left, right) => (
|
|
2378
|
+
left.record.nodeKey.localeCompare(right.record.nodeKey)
|
|
2379
|
+
))) {
|
|
2380
|
+
if (performance.now() >= workflowDeadlineMonotonicMs) {
|
|
2381
|
+
control.interrupt("deadline", new ApplicationError(
|
|
2382
|
+
"cancelled",
|
|
2383
|
+
"Workflow exceeded the host wall-clock bound.",
|
|
2384
|
+
));
|
|
2385
|
+
break;
|
|
2386
|
+
}
|
|
2387
|
+
const resources = item.executor.policy.resources;
|
|
2388
|
+
if (ledger.impossible(resources)) {
|
|
2389
|
+
await this.#persistNode(fence, {
|
|
2390
|
+
...item.record,
|
|
2391
|
+
failure: schedulerFailure(new ApplicationError(
|
|
2392
|
+
"incompatible",
|
|
2393
|
+
`Node ${item.record.nodeKey} exceeds the host resource ceiling.`,
|
|
2394
|
+
)),
|
|
2395
|
+
finishedAt: this.#now().toISOString(),
|
|
2396
|
+
status: "incompatible",
|
|
2397
|
+
});
|
|
2398
|
+
prepared.delete(item.record.nodeKey);
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
if (!ledger.acquire(resources, item.executionPlan.publicationKeys)) continue;
|
|
2402
|
+
try {
|
|
2403
|
+
await this.#store.assertFence(fence);
|
|
2404
|
+
if (await this.#cancellation(fence.runId) !== undefined) {
|
|
2405
|
+
ledger.release(resources, item.executionPlan.publicationKeys);
|
|
2406
|
+
control.interrupt("cancellation", new ApplicationError(
|
|
2407
|
+
"cancelled",
|
|
2408
|
+
"Workflow cancellation requested.",
|
|
2409
|
+
));
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
2412
|
+
const admissionAbortController = new AbortController();
|
|
2413
|
+
const abortController = new AbortController();
|
|
2414
|
+
const runningRecord = RunNodeRecordSchema.parse({
|
|
2415
|
+
...item.record,
|
|
2416
|
+
attempt: item.record.attempt + 1,
|
|
2417
|
+
startedAt: this.#now().toISOString(),
|
|
2418
|
+
status: "running",
|
|
2419
|
+
version: RUN_NODE_VERSION,
|
|
2420
|
+
});
|
|
2421
|
+
await this.#persistNode(fence, runningRecord);
|
|
2422
|
+
prepared.delete(item.record.nodeKey);
|
|
2423
|
+
const runningNode: RunningNode = {
|
|
2424
|
+
admissionAbortController,
|
|
2425
|
+
abortController,
|
|
2426
|
+
cancellable: item.executor.policy.cancellable,
|
|
2427
|
+
executionPlan: item.executionPlan,
|
|
2428
|
+
nodeKey: item.record.nodeKey,
|
|
2429
|
+
fiber: owner.fork(Effect.exit(this.#executeNode(
|
|
2430
|
+
fence,
|
|
2431
|
+
item,
|
|
2432
|
+
admissionAbortController,
|
|
2433
|
+
abortController,
|
|
2434
|
+
workflowDeadlineMonotonicMs,
|
|
2435
|
+
owner,
|
|
2436
|
+
)).pipe(Effect.tap(outcome => Queue.offer(completions, { nodeKey: item.record.nodeKey, outcome })))),
|
|
2437
|
+
publicationKeys: item.executionPlan.publicationKeys,
|
|
2438
|
+
resources,
|
|
2439
|
+
};
|
|
2440
|
+
running.set(item.record.nodeKey, runningNode);
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
ledger.release(resources, item.executionPlan.publicationKeys);
|
|
2443
|
+
throw error;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
#executeNode(
|
|
2449
|
+
fence: RunFence,
|
|
2450
|
+
item: PreparedNode,
|
|
2451
|
+
admissionAbortController: AbortController,
|
|
2452
|
+
abortController: AbortController,
|
|
2453
|
+
workflowDeadlineMonotonicMs: number,
|
|
2454
|
+
owner: WorkflowEffectRuntime,
|
|
2455
|
+
): Effect.Effect<NodeExecutionOutcome, WorkflowFailure> {
|
|
2456
|
+
return Effect.gen(this, function*() {
|
|
2457
|
+
let timedOut = false;
|
|
2458
|
+
let deadlineKind: NodeDeadlineKind | undefined;
|
|
2459
|
+
let publicationMayBeAuthoritative = false;
|
|
2460
|
+
let computeDispatched = false;
|
|
2461
|
+
const nodeDeadlineMonotonicMs = safeDeadline(
|
|
2462
|
+
performance.now(),
|
|
2463
|
+
item.executor.policy.maxDurationMs,
|
|
2464
|
+
);
|
|
2465
|
+
const effectiveDeadlineMonotonicMs = Math.min(
|
|
2466
|
+
nodeDeadlineMonotonicMs,
|
|
2467
|
+
workflowDeadlineMonotonicMs,
|
|
2468
|
+
);
|
|
2469
|
+
const effectiveDeadlineKind: NodeDeadlineKind = (
|
|
2470
|
+
workflowDeadlineMonotonicMs <= nodeDeadlineMonotonicMs
|
|
2471
|
+
)
|
|
2472
|
+
? "workflow"
|
|
2473
|
+
: "node";
|
|
2474
|
+
const expired = owner.deferred<never, WorkflowFailure>();
|
|
2475
|
+
const deadlineError = () => new ApplicationError(
|
|
2476
|
+
"unavailable",
|
|
2477
|
+
effectiveDeadlineKind === "workflow"
|
|
2478
|
+
? "Workflow exceeded the host wall-clock bound."
|
|
2479
|
+
: "Operation exceeded its registered duration bound.",
|
|
2480
|
+
);
|
|
2481
|
+
const cancelDeadline = owner.watch(Effect.zipRight(waitUntil(effectiveDeadlineMonotonicMs), Effect.sync(() => {
|
|
2482
|
+
timedOut = true;
|
|
2483
|
+
deadlineKind = effectiveDeadlineKind;
|
|
2484
|
+
Deferred.unsafeDone(expired, Effect.fail(workflowFailure(deadlineError(), "admission")));
|
|
2485
|
+
admissionAbortController.abort();
|
|
2486
|
+
if (item.executor.policy.cancellable) abortController.abort();
|
|
2487
|
+
})), true);
|
|
2488
|
+
const beforeDeadline = async <Value>(execute: () => Promise<Value>): Promise<Value> => {
|
|
2489
|
+
if (timedOut) throw deadlineError();
|
|
2490
|
+
return await owner.run(Effect.raceFirst(workflowBoundary("authority", execute), Deferred.await(expired)));
|
|
2491
|
+
};
|
|
2492
|
+
const assertPublicationAllowed = () => {
|
|
2493
|
+
if (timedOut) {
|
|
2494
|
+
throw new ApplicationError(
|
|
2495
|
+
"cancelled",
|
|
2496
|
+
"Workflow node deadline expired before publication.",
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
};
|
|
2500
|
+
|
|
2501
|
+
const outcome = yield* Effect.exit(Effect.catchAll(
|
|
2502
|
+
Effect.gen(this, function*() {
|
|
2503
|
+
yield* workflowBoundary("operation", () => beforeDeadline(async () => await this.#store.assertFence(fence)));
|
|
2504
|
+
if ((yield* workflowBoundary("operation", () => beforeDeadline(async () => await this.#cancellation(fence.runId)))) !== undefined) {
|
|
2505
|
+
return yield* Effect.fail(workflowFailure(new ApplicationError("cancelled", "Workflow cancellation requested."), "operation"));
|
|
2506
|
+
}
|
|
2507
|
+
if (
|
|
2508
|
+
item.executor.policy.effect === "paid-cloud"
|
|
2509
|
+
|| item.executionPlan.publicationKeys.length > 0
|
|
2510
|
+
) {
|
|
2511
|
+
yield* workflowBoundary("operation", () => beforeDeadline(async () => await this.#store.assertFence(fence)));
|
|
2512
|
+
if (
|
|
2513
|
+
(yield* workflowBoundary("operation", () => beforeDeadline(async () => await this.#cancellation(fence.runId))))
|
|
2514
|
+
!== undefined
|
|
2515
|
+
) {
|
|
2516
|
+
return yield* Effect.fail(workflowFailure(new ApplicationError(
|
|
2517
|
+
"cancelled",
|
|
2518
|
+
"Workflow cancellation requested before dispatch or publication.",
|
|
2519
|
+
), "operation"));
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
const baseApplication = this.#application;
|
|
2523
|
+
const executeOperation = async (lease: HostResourceLease, application: ApplicationContext = baseApplication) => {
|
|
2524
|
+
if (item.executor.kind !== "operation") {
|
|
2525
|
+
throw new ApplicationError(
|
|
2526
|
+
"internal",
|
|
2527
|
+
`Compute node ${item.record.nodeKey} entered operation execution.`,
|
|
2528
|
+
);
|
|
2529
|
+
}
|
|
2530
|
+
await beforeDeadline(async () => await this.#store.assertFence(fence));
|
|
2531
|
+
if (
|
|
2532
|
+
await beforeDeadline(async () => await this.#store.cancellation(fence.runId))
|
|
2533
|
+
!== undefined
|
|
2534
|
+
) {
|
|
2535
|
+
throw new ApplicationError(
|
|
2536
|
+
"cancelled",
|
|
2537
|
+
"Workflow cancellation requested before project publication.",
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
const workspaceDirectory = await beforeDeadline(async () =>
|
|
2541
|
+
await this.#store.stagingDirectory(
|
|
2542
|
+
fence,
|
|
2543
|
+
item.record.nodeKey,
|
|
2544
|
+
item.executionPlan.nodePlanSha256,
|
|
2545
|
+
)
|
|
2546
|
+
);
|
|
2547
|
+
await lease.assertOwned();
|
|
2548
|
+
const leasedApplication = applicationWithHostResourceLease(
|
|
2549
|
+
application,
|
|
2550
|
+
lease,
|
|
2551
|
+
);
|
|
2552
|
+
const operation = item.executor.operation;
|
|
2553
|
+
const context = {
|
|
2554
|
+
abortSignal: abortController.signal,
|
|
2555
|
+
application: leasedApplication,
|
|
2556
|
+
...(item.executionPlan.expectedProjectGeneration === undefined
|
|
2557
|
+
? {}
|
|
2558
|
+
: { expectedProjectGeneration: item.executionPlan.expectedProjectGeneration }),
|
|
2559
|
+
runFence: {
|
|
2560
|
+
generation: fence.generation,
|
|
2561
|
+
owner: fence.owner,
|
|
2562
|
+
token: fence.token,
|
|
2563
|
+
},
|
|
2564
|
+
workflow: {
|
|
2565
|
+
beforePublication: async () => {
|
|
2566
|
+
assertPublicationAllowed();
|
|
2567
|
+
await lease.assertOwned();
|
|
2568
|
+
await beforeDeadline(async () => await this.#store.assertFence(fence));
|
|
2569
|
+
assertPublicationAllowed();
|
|
2570
|
+
if (
|
|
2571
|
+
await beforeDeadline(async () => await this.#cancellation(fence.runId))
|
|
2572
|
+
!== undefined
|
|
2573
|
+
) {
|
|
2574
|
+
throw new ApplicationError(
|
|
2575
|
+
"cancelled",
|
|
2576
|
+
"Workflow cancellation requested before publication.",
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
assertPublicationAllowed();
|
|
2580
|
+
publicationMayBeAuthoritative = true;
|
|
2581
|
+
},
|
|
2582
|
+
nodeKey: item.record.nodeKey,
|
|
2583
|
+
nodePlanSha256: item.executionPlan.nodePlanSha256,
|
|
2584
|
+
runId: fence.runId,
|
|
2585
|
+
workspaceDirectory,
|
|
2586
|
+
},
|
|
2587
|
+
};
|
|
2588
|
+
const result = operation.executeEffect === undefined
|
|
2589
|
+
? await operation.execute(context, item.executionPlan.exactInput)
|
|
2590
|
+
: operationExitValue(await owner.run(Effect.exit(operation.executeEffect(context, item.executionPlan.exactInput))));
|
|
2591
|
+
if (operation.discovery.kind.startsWith("spatial.project.") && operation.discovery.kind !== "spatial.project.snapshot") {
|
|
2592
|
+
const disposition = result.output as { readonly kind?: string; readonly message?: string };
|
|
2593
|
+
if (disposition.kind !== "completed") throw new ApplicationError(
|
|
2594
|
+
disposition.kind === "ambiguous" ? "ambiguous" : "conflict",
|
|
2595
|
+
disposition.message ?? "Spatial publication did not complete.",
|
|
2596
|
+
{ spatialPublication: result.output },
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
return result;
|
|
2600
|
+
};
|
|
2601
|
+
const hostClaims = physicalHostResourceClaims(
|
|
2602
|
+
item.executor.policy.resources,
|
|
2603
|
+
this.#hostResourceCoordinator,
|
|
2604
|
+
);
|
|
2605
|
+
const hostLeaseOptions = {
|
|
2606
|
+
signal: admissionAbortController.signal,
|
|
2607
|
+
waitTimeoutMilliseconds: Math.min(
|
|
2608
|
+
HOST_RESOURCE_MAX_WAIT_MILLISECONDS,
|
|
2609
|
+
Math.max(
|
|
2610
|
+
1,
|
|
2611
|
+
Math.floor(effectiveDeadlineMonotonicMs - performance.now()),
|
|
2612
|
+
),
|
|
2613
|
+
),
|
|
2614
|
+
} as const;
|
|
2615
|
+
let output: RunNodeOutput;
|
|
2616
|
+
if (item.executor.kind === "compute") {
|
|
2617
|
+
const compute = item.executor.compute;
|
|
2618
|
+
const computeExecutor = this.#computeExecutor;
|
|
2619
|
+
const replayAuthorized = this.#computeRuntime?.kind !== "replay"
|
|
2620
|
+
|| this.#replayComputeNodeKeys.has(item.record.nodeKey);
|
|
2621
|
+
if (computeExecutor === undefined || !replayAuthorized) {
|
|
2622
|
+
return {
|
|
2623
|
+
failure: schedulerFailure(new ApplicationError(
|
|
2624
|
+
"ambiguous",
|
|
2625
|
+
"Trusted compute requires explicit authorization to reload its exact persisted bundle.",
|
|
2626
|
+
)),
|
|
2627
|
+
kind: "ambiguous-code" as const,
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
const execution = owner.custody(workflowBoundary("admission", () => this.#hostResourceCoordinator.withLease(
|
|
2631
|
+
hostClaims,
|
|
2632
|
+
async lease => {
|
|
2633
|
+
await lease.assertOwned();
|
|
2634
|
+
computeDispatched = true;
|
|
2635
|
+
return await computeExecutor.execute({
|
|
2636
|
+
abortSignal: abortController.signal,
|
|
2637
|
+
computeKey: compute.key,
|
|
2638
|
+
...(lease.inheritedFileDescriptor < 3
|
|
2639
|
+
? {}
|
|
2640
|
+
: {
|
|
2641
|
+
inheritedHostResourceFileDescriptor:
|
|
2642
|
+
lease.inheritedFileDescriptor,
|
|
2643
|
+
}),
|
|
2644
|
+
input: item.executionPlan.exactInput,
|
|
2645
|
+
nodeKey: item.record.nodeKey,
|
|
2646
|
+
replayAcknowledged: this.#computeRuntime?.kind === "replay",
|
|
2647
|
+
timeoutMs: Math.max(
|
|
2648
|
+
1,
|
|
2649
|
+
Math.floor(effectiveDeadlineMonotonicMs - performance.now()),
|
|
2650
|
+
),
|
|
2651
|
+
});
|
|
2652
|
+
},
|
|
2653
|
+
hostLeaseOptions,
|
|
2654
|
+
)));
|
|
2655
|
+
const value = (yield* workflowBoundary("operation", () => beforeDeadline(async () => await execution)));
|
|
2656
|
+
yield* workflowBoundary("operation", () => beforeDeadline(async () => await this.#store.assertFence(fence)));
|
|
2657
|
+
output = yield* workflowValidation("operation", () => createRunNodeOutput(
|
|
2658
|
+
value,
|
|
2659
|
+
{
|
|
2660
|
+
computeKey: compute.key,
|
|
2661
|
+
trustedCode: true,
|
|
2662
|
+
},
|
|
2663
|
+
item.executor.policy.maxOutputBytes,
|
|
2664
|
+
));
|
|
2665
|
+
} else {
|
|
2666
|
+
const operation = item.executor.operation;
|
|
2667
|
+
const projectPublication = item.executionPlan.publicationKeys.some(
|
|
2668
|
+
key => key.startsWith("project:"),
|
|
2669
|
+
);
|
|
2670
|
+
const execution = owner.custody(workflowBoundary("admission", () => this.#hostResourceCoordinator.withLease(
|
|
2671
|
+
hostClaims,
|
|
2672
|
+
async lease => operation.discovery.kind.startsWith("spatial.project.")
|
|
2673
|
+
? await withSpatialProjectLease(
|
|
2674
|
+
applicationWithHostResourceLease(this.#application, lease),
|
|
2675
|
+
String((item.executionPlan.exactInput as Readonly<Record<string, unknown>>).project),
|
|
2676
|
+
async application => await executeOperation(lease, application),
|
|
2677
|
+
async () => {
|
|
2678
|
+
assertPublicationAllowed();
|
|
2679
|
+
await lease.assertOwned();
|
|
2680
|
+
if (abortController.signal.aborted) throw new ApplicationError("cancelled", "Spatial project custody was cancelled before publication.");
|
|
2681
|
+
await beforeDeadline(async () => await this.#store.assertFence(fence));
|
|
2682
|
+
if (await beforeDeadline(async () => await this.#cancellation(fence.runId)) !== undefined) {
|
|
2683
|
+
throw new ApplicationError("cancelled", "Workflow cancellation requested before spatial publication.");
|
|
2684
|
+
}
|
|
2685
|
+
assertPublicationAllowed();
|
|
2686
|
+
if (abortController.signal.aborted) throw new ApplicationError("cancelled", "Spatial project custody was cancelled before publication.");
|
|
2687
|
+
await lease.assertOwned();
|
|
2688
|
+
},
|
|
2689
|
+
operation.discovery.kind === "spatial.project.migrate" ? "mutation" : "read",
|
|
2690
|
+
)
|
|
2691
|
+
: projectPublication
|
|
2692
|
+
? await withProjectPublicationLease(
|
|
2693
|
+
this.#application,
|
|
2694
|
+
operation.discovery.kind,
|
|
2695
|
+
item.executionPlan.exactInput,
|
|
2696
|
+
async () => await executeOperation(lease),
|
|
2697
|
+
)
|
|
2698
|
+
: await executeOperation(lease),
|
|
2699
|
+
hostLeaseOptions,
|
|
2700
|
+
)));
|
|
2701
|
+
const result = (yield* workflowBoundary("operation", () => beforeDeadline(async () => await execution)));
|
|
2702
|
+
yield* workflowBoundary("operation", () => beforeDeadline(async () => await this.#store.assertFence(fence)));
|
|
2703
|
+
output = yield* workflowValidation("operation", () => createRunNodeOutput(
|
|
2704
|
+
result.output,
|
|
2705
|
+
result.summary.fields,
|
|
2706
|
+
item.executor.policy.maxOutputBytes,
|
|
2707
|
+
result.receiptReference,
|
|
2708
|
+
));
|
|
2709
|
+
}
|
|
2710
|
+
return { kind: "completed" as const, output };
|
|
2711
|
+
}),
|
|
2712
|
+
failure => workflowValidation("operation", () => {
|
|
2713
|
+
const error = failure.cause;
|
|
2714
|
+
const normalized = asSchedulerApplicationError(error);
|
|
2715
|
+
if (item.executor.kind === "compute") {
|
|
2716
|
+
const cancelledBeforeDispatch = normalized.code === "cancelled"
|
|
2717
|
+
&& !computeDispatched;
|
|
2718
|
+
if (cancelledBeforeDispatch) {
|
|
2719
|
+
return {
|
|
2720
|
+
failure: schedulerFailure(
|
|
2721
|
+
new ApplicationError("cancelled", "Workflow node was cancelled."),
|
|
2722
|
+
),
|
|
2723
|
+
kind: "cancelled" as const,
|
|
2724
|
+
};
|
|
2725
|
+
}
|
|
2726
|
+
if (
|
|
2727
|
+
normalized.code === "invalid-data"
|
|
2728
|
+
|| normalized.code === "unsupported-plan"
|
|
2729
|
+
|| normalized.code === "incompatible"
|
|
2730
|
+
) {
|
|
2731
|
+
return {
|
|
2732
|
+
failure: schedulerFailure(normalized),
|
|
2733
|
+
kind: "failed" as const,
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
return {
|
|
2737
|
+
failure: schedulerFailure(new ApplicationError(
|
|
2738
|
+
"ambiguous",
|
|
2739
|
+
timedOut
|
|
2740
|
+
? deadlineKind === "workflow"
|
|
2741
|
+
? "Trusted compute exceeded the workflow wall-clock bound and requires explicit replay."
|
|
2742
|
+
: "Trusted compute exceeded its duration bound and requires explicit replay."
|
|
2743
|
+
: `Trusted compute was interrupted and requires explicit replay: ${normalized.message}`,
|
|
2744
|
+
)),
|
|
2745
|
+
kind: "ambiguous-code" as const,
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
const spatialDisposition = normalized.details?.spatialPublication;
|
|
2749
|
+
if (item.executor.operation.discovery.kind.startsWith("spatial.project.") && spatialDisposition !== undefined) {
|
|
2750
|
+
const parsed = SpatialProjectMutationOutputSchema.safeParse(spatialDisposition);
|
|
2751
|
+
if (parsed.success && parsed.data.kind !== "completed") {
|
|
2752
|
+
return {
|
|
2753
|
+
failure: schedulerFailure(normalized),
|
|
2754
|
+
kind: parsed.data.kind === "ambiguous" ? "ambiguous" as const : "failed" as const,
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
if (
|
|
2759
|
+
publicationMayBeAuthoritative
|
|
2760
|
+
&& (
|
|
2761
|
+
item.executor.policy.resume === "verified-receipt"
|
|
2762
|
+
|| item.executor.policy.resume === "recoverable-transaction"
|
|
2763
|
+
)
|
|
2764
|
+
) {
|
|
2765
|
+
return {
|
|
2766
|
+
failure: schedulerFailure(new ApplicationError(
|
|
2767
|
+
"ambiguous",
|
|
2768
|
+
timedOut
|
|
2769
|
+
? deadlineKind === "workflow"
|
|
2770
|
+
? "Recoverable publication exceeded the workflow wall-clock bound."
|
|
2771
|
+
: "Recoverable publication exceeded its duration bound."
|
|
2772
|
+
: `Recoverable publication requires reconciliation: ${normalized.message}`,
|
|
2773
|
+
normalized.details,
|
|
2774
|
+
)),
|
|
2775
|
+
kind: "interrupted" as const,
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
const cancelled = normalized.code === "cancelled"
|
|
2779
|
+
|| (abortController.signal.aborted && !timedOut);
|
|
2780
|
+
if (cancelled) {
|
|
2781
|
+
return {
|
|
2782
|
+
failure: schedulerFailure(
|
|
2783
|
+
new ApplicationError("cancelled", "Workflow node was cancelled."),
|
|
2784
|
+
),
|
|
2785
|
+
kind: "cancelled" as const,
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
if (
|
|
2789
|
+
item.executor.policy.resume === "ambiguous-after-dispatch"
|
|
2790
|
+
|| item.executor.policy.resume === "non-resumable-live"
|
|
2791
|
+
) {
|
|
2792
|
+
return {
|
|
2793
|
+
failure: schedulerFailure(new ApplicationError(
|
|
2794
|
+
"ambiguous",
|
|
2795
|
+
timedOut
|
|
2796
|
+
? deadlineKind === "workflow"
|
|
2797
|
+
? "Operation exceeded the workflow wall-clock bound after dispatch."
|
|
2798
|
+
: "Operation exceeded its duration bound after dispatch."
|
|
2799
|
+
: normalized.message,
|
|
2800
|
+
normalized.details,
|
|
2801
|
+
)),
|
|
2802
|
+
kind: "ambiguous" as const,
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
return {
|
|
2806
|
+
failure: schedulerFailure(
|
|
2807
|
+
timedOut
|
|
2808
|
+
? new ApplicationError(
|
|
2809
|
+
"unavailable",
|
|
2810
|
+
deadlineKind === "workflow"
|
|
2811
|
+
? "Operation exceeded the workflow wall-clock bound."
|
|
2812
|
+
: "Operation exceeded its registered duration bound.",
|
|
2813
|
+
)
|
|
2814
|
+
: normalized,
|
|
2815
|
+
normalized.code === "unavailable" || normalized.code === "subprocess",
|
|
2816
|
+
),
|
|
2817
|
+
kind: "failed" as const,
|
|
2818
|
+
};
|
|
2819
|
+
}),
|
|
2820
|
+
));
|
|
2821
|
+
const cleanup = yield* Effect.exit(Effect.gen(this, function*() {
|
|
2822
|
+
yield* workflowBoundary("operation", () => cancelDeadline());
|
|
2823
|
+
}));
|
|
2824
|
+
return yield* Exit.zipLeft(outcome, cleanup);
|
|
2825
|
+
});
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
async #persistOutcome(
|
|
2829
|
+
fence: RunFence,
|
|
2830
|
+
graphPlan: GraphPlanV1,
|
|
2831
|
+
running: RunningNode,
|
|
2832
|
+
outcome: NodeExecutionOutcome,
|
|
2833
|
+
control: WorkflowRunControl,
|
|
2834
|
+
): Promise<boolean> {
|
|
2835
|
+
await this.#store.assertFence(fence);
|
|
2836
|
+
const current = await this.#store.node(fence.runId, running.nodeKey);
|
|
2837
|
+
if (outcome.kind === "completed") {
|
|
2838
|
+
await this.#persistNode(fence, {
|
|
2839
|
+
...current,
|
|
2840
|
+
failure: undefined,
|
|
2841
|
+
finishedAt: this.#now().toISOString(),
|
|
2842
|
+
output: outcome.output,
|
|
2843
|
+
status: "completed",
|
|
2844
|
+
});
|
|
2845
|
+
return false;
|
|
2846
|
+
}
|
|
2847
|
+
if (outcome.kind === "interrupted") {
|
|
2848
|
+
const node = graphPlan.graph.nodes.find(item => item.key === running.nodeKey);
|
|
2849
|
+
if (node === undefined || !isOperationGraphNode(node)) {
|
|
2850
|
+
throw new ApplicationError(
|
|
2851
|
+
"invalid-data",
|
|
2852
|
+
`Missing recoverable operation node ${running.nodeKey}.`,
|
|
2853
|
+
);
|
|
2854
|
+
}
|
|
2855
|
+
const records = await this.#store.nodes(fence.runId);
|
|
2856
|
+
const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
|
|
2857
|
+
const reconciled = await this.#reconcileOperationRecord(
|
|
2858
|
+
fence,
|
|
2859
|
+
graphPlan,
|
|
2860
|
+
node,
|
|
2861
|
+
current,
|
|
2862
|
+
recordsByKey,
|
|
2863
|
+
control,
|
|
2864
|
+
);
|
|
2865
|
+
return reconciled.deferred;
|
|
2866
|
+
}
|
|
2867
|
+
await this.#persistNode(fence, {
|
|
2868
|
+
...current,
|
|
2869
|
+
failure: outcome.failure,
|
|
2870
|
+
finishedAt: this.#now().toISOString(),
|
|
2871
|
+
status: outcome.kind,
|
|
2872
|
+
});
|
|
2873
|
+
if (outcome.kind === "ambiguous") {
|
|
2874
|
+
const node = graphPlan.graph.nodes.find(item => item.key === running.nodeKey);
|
|
2875
|
+
if (node === undefined) {
|
|
2876
|
+
throw new ApplicationError("invalid-data", `Missing graph node ${running.nodeKey}.`);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
return false;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
async #cancelWaitingNodes(fence: RunFence, graphPlan: GraphPlanV1): Promise<void> {
|
|
2883
|
+
const records = await this.#store.nodes(fence.runId);
|
|
2884
|
+
const graphKeys = new Set(graphPlan.graph.nodes.map(node => node.key));
|
|
2885
|
+
for (const record of records
|
|
2886
|
+
.filter(item => (
|
|
2887
|
+
item.status === "pending"
|
|
2888
|
+
|| item.status === "ready"
|
|
2889
|
+
|| item.status === "preparing"
|
|
2890
|
+
|| item.status === "approval-required"
|
|
2891
|
+
|| item.status === "ambiguous-code"
|
|
2892
|
+
))
|
|
2893
|
+
.sort((left, right) => left.nodeKey.localeCompare(right.nodeKey))) {
|
|
2894
|
+
if (!graphKeys.has(record.nodeKey)) continue;
|
|
2895
|
+
await this.#persistNode(fence, {
|
|
2896
|
+
...record,
|
|
2897
|
+
finishedAt: this.#now().toISOString(),
|
|
2898
|
+
status: "cancelled",
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
async #failImpossibleReadyNodes(
|
|
2904
|
+
fence: RunFence,
|
|
2905
|
+
graphPlan: GraphPlanV1,
|
|
2906
|
+
ledger: ResourceLedger,
|
|
2907
|
+
): Promise<boolean> {
|
|
2908
|
+
const records = await this.#store.nodes(fence.runId);
|
|
2909
|
+
const nodesByKey = new Map(graphPlan.graph.nodes.map(node => [node.key, node]));
|
|
2910
|
+
let changed = false;
|
|
2911
|
+
for (const record of records
|
|
2912
|
+
.filter(item => item.status === "ready")
|
|
2913
|
+
.sort((left, right) => left.nodeKey.localeCompare(right.nodeKey))) {
|
|
2914
|
+
const node = nodesByKey.get(record.nodeKey);
|
|
2915
|
+
if (node === undefined) continue;
|
|
2916
|
+
const executor = executorForNode(graphPlan, node, this.#registry);
|
|
2917
|
+
if (!ledger.impossible(executor.policy.resources)) continue;
|
|
2918
|
+
await this.#persistNode(fence, {
|
|
2919
|
+
...record,
|
|
2920
|
+
failure: schedulerFailure(new ApplicationError(
|
|
2921
|
+
"incompatible",
|
|
2922
|
+
`Node ${node.key} exceeds the host resource ceiling.`,
|
|
2923
|
+
)),
|
|
2924
|
+
finishedAt: this.#now().toISOString(),
|
|
2925
|
+
status: "incompatible",
|
|
2926
|
+
});
|
|
2927
|
+
changed = true;
|
|
2928
|
+
}
|
|
2929
|
+
return changed;
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
async #persistNode(fence: RunFence, input: RunNodeRecord): Promise<void> {
|
|
2933
|
+
const parsed = RunNodeRecordSchema.parse(input);
|
|
2934
|
+
const node = RunNodeRecordSchema.parse({
|
|
2935
|
+
attempt: parsed.attempt,
|
|
2936
|
+
dependencies: parsed.dependencies,
|
|
2937
|
+
...(parsed.executionPlan === undefined ? {} : { executionPlan: parsed.executionPlan }),
|
|
2938
|
+
...(parsed.failure === undefined ? {} : { failure: parsed.failure }),
|
|
2939
|
+
...(parsed.finishedAt === undefined ? {} : { finishedAt: parsed.finishedAt }),
|
|
2940
|
+
executor: parsed.executor,
|
|
2941
|
+
nodeKey: parsed.nodeKey,
|
|
2942
|
+
...(parsed.output === undefined ? {} : { output: parsed.output }),
|
|
2943
|
+
...(parsed.preparationPlan === undefined
|
|
2944
|
+
? {}
|
|
2945
|
+
: { preparationPlan: parsed.preparationPlan }),
|
|
2946
|
+
...(parsed.startedAt === undefined ? {} : { startedAt: parsed.startedAt }),
|
|
2947
|
+
status: parsed.status,
|
|
2948
|
+
version: parsed.version,
|
|
2949
|
+
});
|
|
2950
|
+
await this.#store.assertFence(fence);
|
|
2951
|
+
await this.#store.writeNode(fence, node);
|
|
2952
|
+
await this.#store.appendEvent(fence, {
|
|
2953
|
+
details: { status: node.status },
|
|
2954
|
+
kind: "node-status",
|
|
2955
|
+
nodeKey: node.nodeKey,
|
|
2956
|
+
timestamp: this.#now().toISOString(),
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
async #publishSummary(
|
|
2961
|
+
fence: RunFence,
|
|
2962
|
+
graphPlan: GraphPlanV1,
|
|
2963
|
+
state: MutableRunState,
|
|
2964
|
+
running: number,
|
|
2965
|
+
): Promise<RunSummary> {
|
|
2966
|
+
const existing = await this.#store.summary(fence.runId);
|
|
2967
|
+
if (terminalRunStatus(existing.status)) return existing;
|
|
2968
|
+
const records = await this.#store.nodes(fence.runId);
|
|
2969
|
+
const recordsByKey = new Map(records.map(record => [record.nodeKey, record]));
|
|
2970
|
+
const status = terminalStatus(records, state, running);
|
|
2971
|
+
const now = this.#now().toISOString();
|
|
2972
|
+
const outputs = status === "completed"
|
|
2973
|
+
? resolveOutputBinding(graphPlan.graph.outputs, recordsByKey)
|
|
2974
|
+
: undefined;
|
|
2975
|
+
const summary = RunSummarySchema.parse({
|
|
2976
|
+
counts: countsFor(records),
|
|
2977
|
+
...(terminalRunStatus(status) ? { finishedAt: now } : {}),
|
|
2978
|
+
graphPlanSha256: graphPlan.graphPlanSha256,
|
|
2979
|
+
...(outputs === undefined ? {} : { outputs }),
|
|
2980
|
+
runId: fence.runId,
|
|
2981
|
+
startedAt: state.startedAt,
|
|
2982
|
+
status,
|
|
2983
|
+
updatedAt: now,
|
|
2984
|
+
version: RUN_STORE_VERSION,
|
|
2985
|
+
});
|
|
2986
|
+
await this.#store.writeSummary(fence, summary);
|
|
2987
|
+
return summary;
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
async #markSchedulerFailure(
|
|
2991
|
+
fence: RunFence,
|
|
2992
|
+
graphPlan: GraphPlanV1,
|
|
2993
|
+
state: MutableRunState,
|
|
2994
|
+
error: ApplicationError,
|
|
2995
|
+
): Promise<void> {
|
|
2996
|
+
const records = await this.#store.nodes(fence.runId);
|
|
2997
|
+
const target = records
|
|
2998
|
+
.filter(record => (
|
|
2999
|
+
record.status === "ready"
|
|
3000
|
+
|| record.status === "preparing"
|
|
3001
|
+
|| record.status === "running"
|
|
3002
|
+
))
|
|
3003
|
+
.sort((left, right) => left.nodeKey.localeCompare(right.nodeKey))[0];
|
|
3004
|
+
if (target !== undefined) {
|
|
3005
|
+
const authored = graphPlan.graph.nodes.find(node => node.key === target.nodeKey);
|
|
3006
|
+
const resumeClass = authored !== undefined && isOperationGraphNode(authored)
|
|
3007
|
+
? operationForNode(graphPlan, authored, this.#registry).discovery.policy.resume
|
|
3008
|
+
: undefined;
|
|
3009
|
+
const preserveForReconciliation = target.status === "running"
|
|
3010
|
+
&& (
|
|
3011
|
+
resumeClass === "verified-receipt"
|
|
3012
|
+
|| resumeClass === "recoverable-transaction"
|
|
3013
|
+
);
|
|
3014
|
+
if (preserveForReconciliation) {
|
|
3015
|
+
await this.#publishSummary(fence, graphPlan, state, 0);
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
await this.#persistNode(fence, {
|
|
3019
|
+
...target,
|
|
3020
|
+
failure: schedulerFailure(error),
|
|
3021
|
+
finishedAt: this.#now().toISOString(),
|
|
3022
|
+
status: error.code === "incompatible" ? "incompatible" : "failed",
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
3025
|
+
await this.#publishSummary(fence, graphPlan, state, 0);
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
async #markRunIncompatible(
|
|
3029
|
+
fence: RunFence,
|
|
3030
|
+
graphPlan: GraphPlanV1,
|
|
3031
|
+
state: MutableRunState,
|
|
3032
|
+
error: ApplicationError,
|
|
3033
|
+
): Promise<void> {
|
|
3034
|
+
const records = await this.#store.nodes(fence.runId);
|
|
3035
|
+
for (const record of [...records].sort((left, right) => (
|
|
3036
|
+
left.nodeKey.localeCompare(right.nodeKey)
|
|
3037
|
+
))) {
|
|
3038
|
+
if (record.status === "approval-required") {
|
|
3039
|
+
await this.#persistNode(fence, {
|
|
3040
|
+
...record,
|
|
3041
|
+
status: "ready",
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
const current = record.status === "approval-required"
|
|
3045
|
+
? await this.#store.node(fence.runId, record.nodeKey)
|
|
3046
|
+
: record;
|
|
3047
|
+
if (
|
|
3048
|
+
current.status !== "ready"
|
|
3049
|
+
&& current.status !== "preparing"
|
|
3050
|
+
&& current.status !== "running"
|
|
3051
|
+
&& current.status !== "ambiguous-code"
|
|
3052
|
+
) {
|
|
3053
|
+
continue;
|
|
3054
|
+
}
|
|
3055
|
+
await this.#persistNode(fence, {
|
|
3056
|
+
...current,
|
|
3057
|
+
failure: schedulerFailure(new ApplicationError(
|
|
3058
|
+
"incompatible",
|
|
3059
|
+
error.message,
|
|
3060
|
+
)),
|
|
3061
|
+
finishedAt: this.#now().toISOString(),
|
|
3062
|
+
status: "incompatible",
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
3065
|
+
await this.#advanceDependencies(fence, graphPlan, state);
|
|
3066
|
+
}
|
|
3067
|
+
}
|