@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,1641 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn as spawnNode, type ChildProcess } from "node:child_process";
|
|
3
|
+
import { constants, type BigIntStats } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
chmod,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
mkdtemp,
|
|
9
|
+
open,
|
|
10
|
+
realpath,
|
|
11
|
+
rm,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import {
|
|
16
|
+
dirname,
|
|
17
|
+
extname,
|
|
18
|
+
isAbsolute,
|
|
19
|
+
join,
|
|
20
|
+
relative,
|
|
21
|
+
resolve,
|
|
22
|
+
} from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
import ts from "typescript";
|
|
25
|
+
|
|
26
|
+
import { ApplicationError } from "../application/errors";
|
|
27
|
+
import {
|
|
28
|
+
canonicalJson,
|
|
29
|
+
sha256Hex,
|
|
30
|
+
} from "../core/canonical-json";
|
|
31
|
+
import type { WorkflowBundleIdentity as GraphWorkflowBundleIdentity } from "./contracts";
|
|
32
|
+
import { typecheckWorkflowSnapshot } from "./source-typecheck";
|
|
33
|
+
import { captureWorkerProcessStartIdentity } from "./worker-process-identity";
|
|
34
|
+
|
|
35
|
+
export const WORKFLOW_ALLOWED_BARE_IMPORTS = Object.freeze([
|
|
36
|
+
"@hraness/slopcamera/local/code",
|
|
37
|
+
"@hraness/slopcamera/local/code/advanced",
|
|
38
|
+
"@hraness/slopcamera/local/code/workflows",
|
|
39
|
+
"@hraness/slopcamera/local/html-overlay",
|
|
40
|
+
"zod",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
|
44
|
+
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
45
|
+
const MAX_SOURCE_MODULES = 512;
|
|
46
|
+
const MAX_TOTAL_SOURCE_BYTES = 32 * 1024 * 1024;
|
|
47
|
+
const MAX_SOURCE_IMPORT_EDGES = 4_096;
|
|
48
|
+
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
|
|
49
|
+
const MAX_BUNDLER_DIAGNOSTIC_BYTES = 64 * 1024;
|
|
50
|
+
const MAX_BUNDLER_DURATION_MS = 60_000;
|
|
51
|
+
const MAX_TIMER_DURATION_MS = 2_147_483_647;
|
|
52
|
+
const BUNDLER_GUARDIAN_HANDSHAKE_MS = 2_000;
|
|
53
|
+
const BUNDLER_GUARDIAN_DIAGNOSTIC_BYTES = 2_000;
|
|
54
|
+
|
|
55
|
+
const BUNDLE_SUBPROCESS_SOURCE = String.raw`
|
|
56
|
+
import { fstatSync } from "node:fs";
|
|
57
|
+
const processIdentity = await import(process.argv[5]);
|
|
58
|
+
processIdentity.establishCurrentWorkerProcessGroup();
|
|
59
|
+
const preparationLeaseFd = Number(process.argv[3]);
|
|
60
|
+
if (
|
|
61
|
+
!Number.isSafeInteger(preparationLeaseFd)
|
|
62
|
+
|| preparationLeaseFd < 0
|
|
63
|
+
|| (preparationLeaseFd > 0 && preparationLeaseFd < 3)
|
|
64
|
+
) {
|
|
65
|
+
throw new Error("Workflow bundler preparation lease descriptor is invalid.");
|
|
66
|
+
}
|
|
67
|
+
if (preparationLeaseFd !== 0) fstatSync(preparationLeaseFd);
|
|
68
|
+
process.stdout.write("ready\n");
|
|
69
|
+
const startupGate = await Bun.stdin.text();
|
|
70
|
+
if (startupGate !== "start\n") {
|
|
71
|
+
throw new Error("Workflow bundler did not receive its exact startup gate.");
|
|
72
|
+
}
|
|
73
|
+
const request = JSON.parse(await Bun.file(process.argv[1]).text());
|
|
74
|
+
const maximumBundleBytes = Number(process.argv[4]);
|
|
75
|
+
if (!Number.isSafeInteger(maximumBundleBytes) || maximumBundleBytes < 1) {
|
|
76
|
+
throw new Error("Workflow bundler output byte limit is invalid.");
|
|
77
|
+
}
|
|
78
|
+
const plugin = {
|
|
79
|
+
name: "slopcamera-workflow-allowlisted-imports",
|
|
80
|
+
setup(build) {
|
|
81
|
+
for (const [specifier, path] of Object.entries(request.aliases)) {
|
|
82
|
+
const escaped = specifier.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");
|
|
83
|
+
build.onResolve({ filter: new RegExp("^" + escaped + "$") }, () => ({ path }));
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const built = await Bun.build({
|
|
88
|
+
entrypoints: [request.entryPath],
|
|
89
|
+
format: "esm",
|
|
90
|
+
minify: false,
|
|
91
|
+
packages: "bundle",
|
|
92
|
+
plugins: [plugin],
|
|
93
|
+
sourcemap: "none",
|
|
94
|
+
splitting: false,
|
|
95
|
+
target: "bun",
|
|
96
|
+
treeShaking: false,
|
|
97
|
+
});
|
|
98
|
+
if (!built.success || built.outputs.length !== 1 || built.outputs[0]?.kind !== "entry-point") {
|
|
99
|
+
console.error(JSON.stringify({
|
|
100
|
+
logs: built.logs.map(log => log.message),
|
|
101
|
+
outputs: built.outputs.map(output => ({ kind: output.kind, path: output.path })),
|
|
102
|
+
}));
|
|
103
|
+
process.exit(65);
|
|
104
|
+
}
|
|
105
|
+
const output = built.outputs[0];
|
|
106
|
+
if (
|
|
107
|
+
!Number.isSafeInteger(output.size)
|
|
108
|
+
|| output.size < 0
|
|
109
|
+
|| output.size > maximumBundleBytes
|
|
110
|
+
) {
|
|
111
|
+
console.error("Workflow bundle exceeds " + String(maximumBundleBytes) + " bytes.");
|
|
112
|
+
process.exit(66);
|
|
113
|
+
}
|
|
114
|
+
await Bun.write(process.argv[2], output);
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
const BUNDLER_RETIREMENT_GUARDIAN_SOURCE = String.raw`
|
|
118
|
+
import { closeSync, fstatSync, lstatSync } from "node:fs";
|
|
119
|
+
import { rm } from "node:fs/promises";
|
|
120
|
+
|
|
121
|
+
const LEASE_FD = 3;
|
|
122
|
+
const DIRECTORY_FD = 4;
|
|
123
|
+
const POLL_MS = 20;
|
|
124
|
+
const CLEANUP_DEADLINE_MS = 5_000;
|
|
125
|
+
|
|
126
|
+
function argument(name) {
|
|
127
|
+
const index = process.argv.indexOf(name);
|
|
128
|
+
const value = index === -1 ? undefined : process.argv[index + 1];
|
|
129
|
+
if (value === undefined || value === "") {
|
|
130
|
+
throw new Error("Missing workflow-bundler guardian argument: " + name);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const processId = Number(argument("--bundler-pid"));
|
|
136
|
+
if (!Number.isSafeInteger(processId) || processId < 2) {
|
|
137
|
+
throw new Error("Workflow-bundler guardian requires a positive process ID.");
|
|
138
|
+
}
|
|
139
|
+
const expectedIdentity = argument("--bundler-start-identity");
|
|
140
|
+
const directoryPath = argument("--directory");
|
|
141
|
+
const hasLease = argument("--has-lease") === "true";
|
|
142
|
+
const identity = await import(argument("--identity-module"));
|
|
143
|
+
const parsedIdentity = identity.parseWorkerProcessStartIdentity(expectedIdentity);
|
|
144
|
+
if (hasLease) {
|
|
145
|
+
fstatSync(LEASE_FD);
|
|
146
|
+
const locking = await import(argument("--host-resource-lock-module"));
|
|
147
|
+
if (!locking.tryLockHostResourceDescriptor(LEASE_FD)) {
|
|
148
|
+
throw new Error("Workflow-bundler guardian could not prove inherited lease ownership.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const pinnedDirectory = fstatSync(DIRECTORY_FD, { bigint: true });
|
|
152
|
+
if (!pinnedDirectory.isDirectory() || (pinnedDirectory.mode & 0o77n) !== 0n) {
|
|
153
|
+
throw new Error("Workflow-bundler guardian directory descriptor is unsafe.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sameDirectoryIdentity(details) {
|
|
157
|
+
return (
|
|
158
|
+
details.isDirectory()
|
|
159
|
+
&& !details.isSymbolicLink()
|
|
160
|
+
&& details.dev === pinnedDirectory.dev
|
|
161
|
+
&& details.ino === pinnedDirectory.ino
|
|
162
|
+
&& (details.mode & 0o77n) === 0n
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function waitForExactDeath() {
|
|
167
|
+
while (
|
|
168
|
+
identity.workerProcessStartIdentityStatus(processId, parsedIdentity)
|
|
169
|
+
!== "different-or-dead"
|
|
170
|
+
) {
|
|
171
|
+
await Bun.sleep(POLL_MS);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function terminateExactBundler() {
|
|
176
|
+
while (true) {
|
|
177
|
+
const status = identity.workerProcessStartIdentityStatus(
|
|
178
|
+
processId,
|
|
179
|
+
parsedIdentity,
|
|
180
|
+
);
|
|
181
|
+
if (status === "different-or-dead") {
|
|
182
|
+
await waitForExactDeath();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (status === "unknown" || !identity.guardianPinsWorkerProcessId(processId)) {
|
|
186
|
+
await Bun.sleep(POLL_MS);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
process.kill(processId, "SIGKILL");
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (
|
|
193
|
+
typeof error === "object"
|
|
194
|
+
&& error !== null
|
|
195
|
+
&& "code" in error
|
|
196
|
+
&& Reflect.get(error, "code") === "ESRCH"
|
|
197
|
+
) return;
|
|
198
|
+
await Bun.sleep(POLL_MS);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
await waitForExactDeath();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function removePinnedDirectory() {
|
|
207
|
+
const deadline = Date.now() + CLEANUP_DEADLINE_MS;
|
|
208
|
+
while (true) {
|
|
209
|
+
let lexical;
|
|
210
|
+
try {
|
|
211
|
+
lexical = lstatSync(directoryPath, { bigint: true });
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (
|
|
214
|
+
typeof error === "object"
|
|
215
|
+
&& error !== null
|
|
216
|
+
&& "code" in error
|
|
217
|
+
&& Reflect.get(error, "code") === "ENOENT"
|
|
218
|
+
) return;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
if (!sameDirectoryIdentity(lexical)) {
|
|
222
|
+
throw new Error("Workflow-bundler guardian directory identity changed.");
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await rm(directoryPath, { force: true, recursive: true });
|
|
226
|
+
return;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (Date.now() >= deadline) throw error;
|
|
229
|
+
// Filesystems can expose a short ENOTEMPTY/EBUSY tail while recursive
|
|
230
|
+
// deletion drains. Reattest the pinned identity before each retry.
|
|
231
|
+
await Bun.sleep(POLL_MS);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await identity.joinAndPinWorkerProcessGroup(processId, parsedIdentity);
|
|
237
|
+
let leaseOpen = hasLease;
|
|
238
|
+
const releaseLease = () => {
|
|
239
|
+
if (!leaseOpen) return;
|
|
240
|
+
leaseOpen = false;
|
|
241
|
+
closeSync(LEASE_FD);
|
|
242
|
+
};
|
|
243
|
+
let settling = false;
|
|
244
|
+
let input = "";
|
|
245
|
+
const settle = async (killBundler, protocolError) => {
|
|
246
|
+
if (settling) return;
|
|
247
|
+
settling = true;
|
|
248
|
+
let failure = protocolError;
|
|
249
|
+
let exactDeathProven = false;
|
|
250
|
+
try {
|
|
251
|
+
if (killBundler) {
|
|
252
|
+
await terminateExactBundler();
|
|
253
|
+
exactDeathProven = true;
|
|
254
|
+
} else if (
|
|
255
|
+
identity.workerProcessStartIdentityStatus(processId, parsedIdentity)
|
|
256
|
+
!== "different-or-dead"
|
|
257
|
+
) {
|
|
258
|
+
await terminateExactBundler();
|
|
259
|
+
exactDeathProven = true;
|
|
260
|
+
failure = new Error(
|
|
261
|
+
"Workflow-bundler guardian received completion before exact child death.",
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
exactDeathProven = true;
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
failure ??= error;
|
|
268
|
+
}
|
|
269
|
+
if (!exactDeathProven) {
|
|
270
|
+
// Identity uncertainty is never permission to drop machine capacity while
|
|
271
|
+
// the exact child may still be live. Remain fail-closed for supervision.
|
|
272
|
+
process.stderr.write(
|
|
273
|
+
String(failure instanceof Error ? failure.message : failure) + "\n",
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// The exact bundler is gone. Release scarce machine capacity before scratch
|
|
278
|
+
// deletion, whose failures are bounded and cannot retain a lease.
|
|
279
|
+
releaseLease();
|
|
280
|
+
try {
|
|
281
|
+
await removePinnedDirectory();
|
|
282
|
+
} catch (error) {
|
|
283
|
+
failure ??= error;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
closeSync(DIRECTORY_FD);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
failure ??= error;
|
|
289
|
+
}
|
|
290
|
+
if (failure !== undefined) {
|
|
291
|
+
process.stderr.write(
|
|
292
|
+
String(failure instanceof Error ? failure.message : failure) + "\n",
|
|
293
|
+
);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
process.exit(0);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
process.stdin.setEncoding("utf8");
|
|
300
|
+
process.stdin.on("data", chunk => {
|
|
301
|
+
input += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
302
|
+
if (input.length > 64 || !"complete\n".startsWith(input)) {
|
|
303
|
+
void settle(true, new Error("Workflow-bundler guardian received invalid control input."));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (input === "complete\n") void settle(false);
|
|
307
|
+
});
|
|
308
|
+
process.stdin.once("end", () => {
|
|
309
|
+
void settle(input !== "complete\n");
|
|
310
|
+
});
|
|
311
|
+
process.stdin.once("error", error => {
|
|
312
|
+
void settle(true, error);
|
|
313
|
+
});
|
|
314
|
+
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
|
315
|
+
process.on(signal, () => {
|
|
316
|
+
void settle(true);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// The caller may crash after handoff. Closed diagnostic pipes are not a
|
|
320
|
+
// retirement failure and must not preempt exact kill-and-cleanup ownership.
|
|
321
|
+
process.stdout.on("error", () => undefined);
|
|
322
|
+
process.stderr.on("error", () => undefined);
|
|
323
|
+
process.stdout.write("ready\n");
|
|
324
|
+
`;
|
|
325
|
+
|
|
326
|
+
export interface WorkflowBundleIdentity {
|
|
327
|
+
readonly bunRevision: string;
|
|
328
|
+
readonly bunVersion: string;
|
|
329
|
+
readonly format: "esm";
|
|
330
|
+
readonly minify: false;
|
|
331
|
+
readonly packages: "bundle";
|
|
332
|
+
readonly schemaVersion: 1;
|
|
333
|
+
readonly sourcemap: "none";
|
|
334
|
+
readonly target: "bun";
|
|
335
|
+
readonly treeShaking: false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export interface WorkflowSourceBundle {
|
|
339
|
+
readonly bytes: Uint8Array;
|
|
340
|
+
readonly dependencyGraphSha256: string;
|
|
341
|
+
readonly entryPath: string;
|
|
342
|
+
readonly entryRelativePath: string;
|
|
343
|
+
readonly externalImports: readonly {
|
|
344
|
+
readonly resolvedPath: string;
|
|
345
|
+
readonly specifier: string;
|
|
346
|
+
}[];
|
|
347
|
+
readonly identity: WorkflowBundleIdentity;
|
|
348
|
+
readonly importedPaths: readonly string[];
|
|
349
|
+
readonly sha256: string;
|
|
350
|
+
readonly sourceSha256: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface BundleWorkflowSourceOptions {
|
|
354
|
+
/**
|
|
355
|
+
* Runs immediately before a previously validated source path is opened.
|
|
356
|
+
* Primarily useful for deterministic file-kind replacement-race audits.
|
|
357
|
+
*/
|
|
358
|
+
readonly beforeSourceFileOpened?: (sourcePath: string) => Promise<void> | void;
|
|
359
|
+
/**
|
|
360
|
+
* Runs after a source descriptor is pinned and validated but before its
|
|
361
|
+
* bytes are read. Primarily useful for deterministic replacement-race
|
|
362
|
+
* audits.
|
|
363
|
+
*/
|
|
364
|
+
readonly afterSourceFileOpened?: (sourcePath: string) => Promise<void> | void;
|
|
365
|
+
/**
|
|
366
|
+
* Runs only after every local module has been copied into the private,
|
|
367
|
+
* immutable build snapshot. Primarily useful for deterministic audit tests
|
|
368
|
+
* and progress instrumentation.
|
|
369
|
+
*/
|
|
370
|
+
readonly afterSourceSnapshot?: () => Promise<void> | void;
|
|
371
|
+
/** Runs after the exact bundler PID is captured. Intended for lifecycle audits. */
|
|
372
|
+
readonly afterBundlerStarted?: (details: {
|
|
373
|
+
readonly processId: number;
|
|
374
|
+
readonly processStartIdentity: string;
|
|
375
|
+
readonly temporaryDirectory: string;
|
|
376
|
+
}) => Promise<void> | void;
|
|
377
|
+
/**
|
|
378
|
+
* Runs after the guardian has pinned the exact gated bundler, inherited the
|
|
379
|
+
* machine lease and cleanup authority, and before the startup gate opens.
|
|
380
|
+
* Intended for deterministic caller-crash audits.
|
|
381
|
+
*/
|
|
382
|
+
readonly afterBundlerRetirementGuardianReady?: (details: {
|
|
383
|
+
readonly processId: number;
|
|
384
|
+
readonly processStartIdentity: string;
|
|
385
|
+
readonly temporaryDirectory: string;
|
|
386
|
+
}) => Promise<void> | void;
|
|
387
|
+
readonly allowedBareImports?: readonly string[];
|
|
388
|
+
readonly allowedRoot: string;
|
|
389
|
+
readonly bareImportResolutionRoot?: string;
|
|
390
|
+
readonly entryPath: string;
|
|
391
|
+
/** Combined CPU/local-I/O kernel lease copied into the short-lived bundler. */
|
|
392
|
+
readonly inheritedHostResourceFileDescriptor?: number;
|
|
393
|
+
readonly maximumBundleBytes?: number;
|
|
394
|
+
readonly maximumBundlerDurationMs?: number;
|
|
395
|
+
readonly maximumImportEdges?: number;
|
|
396
|
+
readonly maximumModules?: number;
|
|
397
|
+
readonly maximumSourceBytes?: number;
|
|
398
|
+
readonly maximumTotalSourceBytes?: number;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export interface RestoreWorkflowSourceBundleOptions {
|
|
402
|
+
readonly bundle: GraphWorkflowBundleIdentity;
|
|
403
|
+
readonly bytes: Uint8Array;
|
|
404
|
+
readonly externalModules: readonly string[];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const WORKFLOW_BUNDLE_IDENTITY = Object.freeze({
|
|
408
|
+
bunRevision: Bun.revision,
|
|
409
|
+
bunVersion: Bun.version,
|
|
410
|
+
format: "esm" as const,
|
|
411
|
+
minify: false as const,
|
|
412
|
+
packages: "bundle" as const,
|
|
413
|
+
schemaVersion: 1 as const,
|
|
414
|
+
sourcemap: "none" as const,
|
|
415
|
+
target: "bun" as const,
|
|
416
|
+
treeShaking: false as const,
|
|
417
|
+
}) satisfies WorkflowBundleIdentity;
|
|
418
|
+
|
|
419
|
+
interface PhysicalFileIdentity {
|
|
420
|
+
readonly ctimeNs: bigint;
|
|
421
|
+
readonly dev: bigint;
|
|
422
|
+
readonly ino: bigint;
|
|
423
|
+
readonly mtimeNs: bigint;
|
|
424
|
+
readonly size: bigint;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function physicalFileIdentity(details: BigIntStats): PhysicalFileIdentity {
|
|
428
|
+
return {
|
|
429
|
+
ctimeNs: details.ctimeNs,
|
|
430
|
+
dev: details.dev,
|
|
431
|
+
ino: details.ino,
|
|
432
|
+
mtimeNs: details.mtimeNs,
|
|
433
|
+
size: details.size,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function samePhysicalFileIdentity(
|
|
438
|
+
left: PhysicalFileIdentity,
|
|
439
|
+
right: PhysicalFileIdentity,
|
|
440
|
+
): boolean {
|
|
441
|
+
return (
|
|
442
|
+
left.ctimeNs === right.ctimeNs
|
|
443
|
+
&& left.dev === right.dev
|
|
444
|
+
&& left.ino === right.ino
|
|
445
|
+
&& left.mtimeNs === right.mtimeNs
|
|
446
|
+
&& left.size === right.size
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function changedPhysicalFile(path: string): ApplicationError {
|
|
451
|
+
return new ApplicationError(
|
|
452
|
+
"invalid-data",
|
|
453
|
+
`Workflow file changed while its exact bytes were being captured: ${path}`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function assertPathReferencesIdentity(
|
|
458
|
+
path: string,
|
|
459
|
+
expected: PhysicalFileIdentity,
|
|
460
|
+
): Promise<string> {
|
|
461
|
+
try {
|
|
462
|
+
const [physicalPath, details] = await Promise.all([
|
|
463
|
+
realpath(path),
|
|
464
|
+
lstat(path, { bigint: true }),
|
|
465
|
+
]);
|
|
466
|
+
if (
|
|
467
|
+
!details.isFile()
|
|
468
|
+
|| details.isSymbolicLink()
|
|
469
|
+
|| details.dev !== expected.dev
|
|
470
|
+
|| details.ino !== expected.ino
|
|
471
|
+
) {
|
|
472
|
+
throw changedPhysicalFile(path);
|
|
473
|
+
}
|
|
474
|
+
return physicalPath;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error instanceof ApplicationError) throw error;
|
|
477
|
+
throw changedPhysicalFile(path);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function readBoundedPhysicalFile(options: {
|
|
482
|
+
readonly admitBytes?: (byteLength: number) => void;
|
|
483
|
+
readonly afterOpen?: () => Promise<void> | void;
|
|
484
|
+
readonly expectedPhysicalPath?: string;
|
|
485
|
+
readonly maximumBytes: number;
|
|
486
|
+
readonly path: string;
|
|
487
|
+
readonly tooLargeMessage: string;
|
|
488
|
+
}): Promise<Uint8Array> {
|
|
489
|
+
const handle = await open(
|
|
490
|
+
options.path,
|
|
491
|
+
constants.O_RDONLY
|
|
492
|
+
| (constants.O_NOFOLLOW ?? 0)
|
|
493
|
+
| (constants.O_NONBLOCK ?? 0),
|
|
494
|
+
);
|
|
495
|
+
try {
|
|
496
|
+
const beforeDetails = await handle.stat({ bigint: true });
|
|
497
|
+
if (!beforeDetails.isFile()) throw changedPhysicalFile(options.path);
|
|
498
|
+
if (beforeDetails.size > BigInt(options.maximumBytes)) {
|
|
499
|
+
throw new ApplicationError("invalid-data", options.tooLargeMessage);
|
|
500
|
+
}
|
|
501
|
+
const before = physicalFileIdentity(beforeDetails);
|
|
502
|
+
const beforePhysicalPath = await assertPathReferencesIdentity(options.path, before);
|
|
503
|
+
if (
|
|
504
|
+
options.expectedPhysicalPath !== undefined
|
|
505
|
+
&& beforePhysicalPath !== options.expectedPhysicalPath
|
|
506
|
+
) {
|
|
507
|
+
throw changedPhysicalFile(options.path);
|
|
508
|
+
}
|
|
509
|
+
options.admitBytes?.(Number(before.size));
|
|
510
|
+
await options.afterOpen?.();
|
|
511
|
+
|
|
512
|
+
// Allocation follows the descriptor size proof. Positional reads keep the
|
|
513
|
+
// snapshot pinned to this exact inode even if the path is replaced.
|
|
514
|
+
const bytes = new Uint8Array(Number(before.size));
|
|
515
|
+
let offset = 0;
|
|
516
|
+
while (offset < bytes.byteLength) {
|
|
517
|
+
const result = await handle.read(
|
|
518
|
+
bytes,
|
|
519
|
+
offset,
|
|
520
|
+
bytes.byteLength - offset,
|
|
521
|
+
offset,
|
|
522
|
+
);
|
|
523
|
+
if (result.bytesRead === 0) throw changedPhysicalFile(options.path);
|
|
524
|
+
offset += result.bytesRead;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const afterDetails = await handle.stat({ bigint: true });
|
|
528
|
+
const after = physicalFileIdentity(afterDetails);
|
|
529
|
+
if (!afterDetails.isFile() || !samePhysicalFileIdentity(before, after)) {
|
|
530
|
+
throw changedPhysicalFile(options.path);
|
|
531
|
+
}
|
|
532
|
+
const afterPhysicalPath = await assertPathReferencesIdentity(options.path, after);
|
|
533
|
+
if (afterPhysicalPath !== beforePhysicalPath) {
|
|
534
|
+
throw changedPhysicalFile(options.path);
|
|
535
|
+
}
|
|
536
|
+
return bytes;
|
|
537
|
+
} finally {
|
|
538
|
+
await handle.close();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function waitForBundlerStartup(options: {
|
|
543
|
+
readonly exit: Promise<number>;
|
|
544
|
+
readonly stream: ReadableStream<Uint8Array>;
|
|
545
|
+
}): Promise<void> {
|
|
546
|
+
const reader = options.stream.getReader();
|
|
547
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
548
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
549
|
+
timer = setTimeout(() => reject(new ApplicationError(
|
|
550
|
+
"subprocess",
|
|
551
|
+
"Workflow bundler exceeded its startup handshake deadline.",
|
|
552
|
+
)), BUNDLER_GUARDIAN_HANDSHAKE_MS);
|
|
553
|
+
});
|
|
554
|
+
const exited = options.exit.then(exitCode => {
|
|
555
|
+
throw new ApplicationError(
|
|
556
|
+
"subprocess",
|
|
557
|
+
`Workflow bundler exited during startup (exit ${String(exitCode)}).`,
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
void exited.catch(() => undefined);
|
|
561
|
+
let bytes = new Uint8Array(0);
|
|
562
|
+
try {
|
|
563
|
+
while (true) {
|
|
564
|
+
const next = await Promise.race([reader.read(), deadline, exited]);
|
|
565
|
+
if (next.done) {
|
|
566
|
+
throw new ApplicationError(
|
|
567
|
+
"subprocess",
|
|
568
|
+
"Workflow bundler closed its startup handshake stream.",
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
const combined = new Uint8Array(bytes.byteLength + next.value.byteLength);
|
|
572
|
+
combined.set(bytes);
|
|
573
|
+
combined.set(next.value, bytes.byteLength);
|
|
574
|
+
bytes = combined;
|
|
575
|
+
if (bytes.byteLength > 64) {
|
|
576
|
+
throw new ApplicationError(
|
|
577
|
+
"subprocess",
|
|
578
|
+
"Workflow bundler exceeded its startup handshake byte limit.",
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const text = new TextDecoder().decode(bytes);
|
|
582
|
+
if (text === "ready\n") return;
|
|
583
|
+
if (!"ready\n".startsWith(text)) {
|
|
584
|
+
throw new ApplicationError(
|
|
585
|
+
"subprocess",
|
|
586
|
+
"Workflow bundler returned an invalid startup handshake.",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} finally {
|
|
591
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
592
|
+
await reader.cancel().catch(() => undefined);
|
|
593
|
+
reader.releaseLock();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function readBoundedBundlerDiagnostic(
|
|
598
|
+
stream: ReadableStream<Uint8Array>,
|
|
599
|
+
signal: AbortSignal,
|
|
600
|
+
): Promise<{ readonly overflowed: boolean; readonly text: string }> {
|
|
601
|
+
const reader = stream.getReader();
|
|
602
|
+
const diagnostic = new Uint8Array(MAX_BUNDLER_DIAGNOSTIC_BYTES);
|
|
603
|
+
let byteLength = 0;
|
|
604
|
+
const cancel = (): void => {
|
|
605
|
+
void reader.cancel(signal.reason).catch(() => undefined);
|
|
606
|
+
};
|
|
607
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
608
|
+
if (signal.aborted) cancel();
|
|
609
|
+
try {
|
|
610
|
+
while (true) {
|
|
611
|
+
const next = await reader.read();
|
|
612
|
+
if (next.done) break;
|
|
613
|
+
const remaining = diagnostic.byteLength - byteLength;
|
|
614
|
+
if (next.value.byteLength > remaining) {
|
|
615
|
+
await reader.cancel("Workflow bundler diagnostic byte limit exceeded.")
|
|
616
|
+
.catch(() => undefined);
|
|
617
|
+
return {
|
|
618
|
+
overflowed: true,
|
|
619
|
+
text: new TextDecoder().decode(diagnostic.subarray(0, byteLength)),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
diagnostic.set(next.value, byteLength);
|
|
623
|
+
byteLength += next.value.byteLength;
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
overflowed: false,
|
|
627
|
+
text: new TextDecoder().decode(diagnostic.subarray(0, byteLength)),
|
|
628
|
+
};
|
|
629
|
+
} catch (error) {
|
|
630
|
+
await reader.cancel(error).catch(() => undefined);
|
|
631
|
+
throw error;
|
|
632
|
+
} finally {
|
|
633
|
+
signal.removeEventListener("abort", cancel);
|
|
634
|
+
reader.releaseLock();
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
interface BundlerRetirementGuardian {
|
|
639
|
+
/** Confirms exact child death, removes scratch, and releases the guardian. */
|
|
640
|
+
complete(): Promise<void>;
|
|
641
|
+
/** Rejects if the preinstalled guardian disappears before settlement. */
|
|
642
|
+
readonly failure: Promise<never>;
|
|
643
|
+
/** Parent EOF transfers exact retirement and cleanup after a boundary error. */
|
|
644
|
+
retireDetached(): boolean;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function childExit(child: ChildProcess): Promise<{
|
|
648
|
+
readonly code: number | null;
|
|
649
|
+
readonly signal: NodeJS.Signals | null;
|
|
650
|
+
}> {
|
|
651
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
652
|
+
return Promise.resolve({ code: child.exitCode, signal: child.signalCode });
|
|
653
|
+
}
|
|
654
|
+
return new Promise(resolveExit => {
|
|
655
|
+
child.once("exit", (code, signal) => resolveExit({ code, signal }));
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function boundedGuardianDiagnostic(child: ChildProcess): () => string {
|
|
660
|
+
let diagnostic = Buffer.alloc(0);
|
|
661
|
+
child.stderr?.on("data", (chunk: Uint8Array) => {
|
|
662
|
+
diagnostic = Buffer.concat([diagnostic, chunk])
|
|
663
|
+
.subarray(-BUNDLER_GUARDIAN_DIAGNOSTIC_BYTES);
|
|
664
|
+
});
|
|
665
|
+
return () => diagnostic.toString("utf8").trim();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function waitForGuardianLine(options: {
|
|
669
|
+
readonly child: ChildProcess;
|
|
670
|
+
readonly diagnostic: () => string;
|
|
671
|
+
readonly exit: Promise<{
|
|
672
|
+
readonly code: number | null;
|
|
673
|
+
readonly signal: NodeJS.Signals | null;
|
|
674
|
+
}>;
|
|
675
|
+
readonly expected: string;
|
|
676
|
+
readonly phase: string;
|
|
677
|
+
}): Promise<void> {
|
|
678
|
+
const output = options.child.stdout;
|
|
679
|
+
if (output === null) {
|
|
680
|
+
throw new ApplicationError(
|
|
681
|
+
"subprocess",
|
|
682
|
+
"Workflow-bundler retirement guardian has no output stream.",
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
await new Promise<void>((resolveLine, rejectLine) => {
|
|
686
|
+
let bytes = Buffer.alloc(0);
|
|
687
|
+
let settled = false;
|
|
688
|
+
const settle = (error?: Error): void => {
|
|
689
|
+
if (settled) return;
|
|
690
|
+
settled = true;
|
|
691
|
+
clearTimeout(timer);
|
|
692
|
+
output.removeListener("data", onData);
|
|
693
|
+
if (error === undefined) resolveLine();
|
|
694
|
+
else rejectLine(error);
|
|
695
|
+
};
|
|
696
|
+
const onData = (chunk: Uint8Array): void => {
|
|
697
|
+
bytes = Buffer.concat([bytes, chunk]);
|
|
698
|
+
if (bytes.byteLength > 64) {
|
|
699
|
+
settle(new ApplicationError(
|
|
700
|
+
"subprocess",
|
|
701
|
+
`Workflow-bundler retirement guardian exceeded its ${options.phase} response bound.`,
|
|
702
|
+
));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const text = bytes.toString("utf8");
|
|
706
|
+
if (text === options.expected) settle();
|
|
707
|
+
else if (!options.expected.startsWith(text)) {
|
|
708
|
+
settle(new ApplicationError(
|
|
709
|
+
"subprocess",
|
|
710
|
+
`Workflow-bundler retirement guardian returned an invalid ${options.phase} response.`,
|
|
711
|
+
));
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
const timer = setTimeout(() => settle(new ApplicationError(
|
|
715
|
+
"subprocess",
|
|
716
|
+
`Workflow-bundler retirement guardian exceeded its ${options.phase} deadline.`,
|
|
717
|
+
)), BUNDLER_GUARDIAN_HANDSHAKE_MS);
|
|
718
|
+
output.on("data", onData);
|
|
719
|
+
void options.exit.then(result => {
|
|
720
|
+
const detail = options.diagnostic();
|
|
721
|
+
settle(new ApplicationError(
|
|
722
|
+
"subprocess",
|
|
723
|
+
`Workflow-bundler retirement guardian exited during ${options.phase} (code ${String(result.code)}, signal ${String(result.signal)})${detail === "" ? "." : `: ${detail}`}`,
|
|
724
|
+
));
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function startBundlerRetirementGuardian(options: {
|
|
730
|
+
readonly bunExecutable: string;
|
|
731
|
+
readonly directory: string;
|
|
732
|
+
readonly inheritedHostResourceFileDescriptor: number;
|
|
733
|
+
readonly processId: number;
|
|
734
|
+
readonly processStartIdentity: string;
|
|
735
|
+
}): Promise<BundlerRetirementGuardian> {
|
|
736
|
+
const directory = await open(
|
|
737
|
+
options.directory,
|
|
738
|
+
constants.O_RDONLY
|
|
739
|
+
| (constants.O_DIRECTORY ?? 0)
|
|
740
|
+
| (constants.O_NOFOLLOW ?? 0),
|
|
741
|
+
);
|
|
742
|
+
let guardian: ChildProcess;
|
|
743
|
+
try {
|
|
744
|
+
guardian = spawnNode(
|
|
745
|
+
options.bunExecutable,
|
|
746
|
+
[
|
|
747
|
+
"-e",
|
|
748
|
+
BUNDLER_RETIREMENT_GUARDIAN_SOURCE,
|
|
749
|
+
"guardian",
|
|
750
|
+
"--bundler-pid",
|
|
751
|
+
String(options.processId),
|
|
752
|
+
"--bundler-start-identity",
|
|
753
|
+
options.processStartIdentity,
|
|
754
|
+
"--directory",
|
|
755
|
+
options.directory,
|
|
756
|
+
"--has-lease",
|
|
757
|
+
options.inheritedHostResourceFileDescriptor === 0 ? "false" : "true",
|
|
758
|
+
"--identity-module",
|
|
759
|
+
pathToFileURL(join(import.meta.dir, "worker-process-identity.ts")).href,
|
|
760
|
+
"--host-resource-lock-module",
|
|
761
|
+
pathToFileURL(join(
|
|
762
|
+
import.meta.dir,
|
|
763
|
+
"../../../src/host-resource-posix.ts",
|
|
764
|
+
)).href,
|
|
765
|
+
],
|
|
766
|
+
{
|
|
767
|
+
env: {
|
|
768
|
+
LANG: "C.UTF-8",
|
|
769
|
+
LC_ALL: "C.UTF-8",
|
|
770
|
+
PATH: dirname(options.bunExecutable),
|
|
771
|
+
},
|
|
772
|
+
stdio: [
|
|
773
|
+
"pipe",
|
|
774
|
+
"pipe",
|
|
775
|
+
"pipe",
|
|
776
|
+
options.inheritedHostResourceFileDescriptor === 0
|
|
777
|
+
? "ignore"
|
|
778
|
+
: options.inheritedHostResourceFileDescriptor,
|
|
779
|
+
directory.fd,
|
|
780
|
+
],
|
|
781
|
+
},
|
|
782
|
+
);
|
|
783
|
+
} finally {
|
|
784
|
+
await directory.close();
|
|
785
|
+
}
|
|
786
|
+
if (
|
|
787
|
+
guardian.stdin === null
|
|
788
|
+
|| guardian.stdout === null
|
|
789
|
+
|| guardian.stderr === null
|
|
790
|
+
) {
|
|
791
|
+
guardian.kill("SIGKILL");
|
|
792
|
+
throw new ApplicationError(
|
|
793
|
+
"subprocess",
|
|
794
|
+
"Workflow-bundler retirement guardian did not expose its control streams.",
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
const exit = childExit(guardian);
|
|
798
|
+
const diagnostic = boundedGuardianDiagnostic(guardian);
|
|
799
|
+
try {
|
|
800
|
+
await waitForGuardianLine({
|
|
801
|
+
child: guardian,
|
|
802
|
+
diagnostic,
|
|
803
|
+
exit,
|
|
804
|
+
expected: "ready\n",
|
|
805
|
+
phase: "startup",
|
|
806
|
+
});
|
|
807
|
+
} catch (error) {
|
|
808
|
+
guardian.kill("SIGKILL");
|
|
809
|
+
await exit;
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
guardian.stdout.resume();
|
|
813
|
+
let settlementRequested: "complete" | "retire" | undefined;
|
|
814
|
+
const failure = new Promise<never>((_resolve, reject) => {
|
|
815
|
+
void exit.then(result => {
|
|
816
|
+
if (settlementRequested !== undefined) return;
|
|
817
|
+
const detail = diagnostic();
|
|
818
|
+
reject(new ApplicationError(
|
|
819
|
+
"subprocess",
|
|
820
|
+
`Workflow-bundler retirement guardian exited before bundling settled (code ${String(result.code)}, signal ${String(result.signal)})${detail === "" ? "." : `: ${detail}`}`,
|
|
821
|
+
));
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
void failure.catch(() => undefined);
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
complete: async () => {
|
|
828
|
+
if (settlementRequested === "retire") {
|
|
829
|
+
throw new ApplicationError(
|
|
830
|
+
"conflict",
|
|
831
|
+
"Workflow-bundler guardian already owns detached retirement.",
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
if (settlementRequested === undefined) {
|
|
835
|
+
settlementRequested = "complete";
|
|
836
|
+
if (guardian.exitCode === null && guardian.signalCode === null) {
|
|
837
|
+
await new Promise<void>((resolveWrite, rejectWrite) => {
|
|
838
|
+
const control = guardian.stdin;
|
|
839
|
+
if (control === null) {
|
|
840
|
+
rejectWrite(new Error("Workflow-bundler guardian control pipe is missing."));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const onError = (error: Error): void => rejectWrite(error);
|
|
844
|
+
control.once("error", onError);
|
|
845
|
+
control.end("complete\n", () => {
|
|
846
|
+
control.removeListener("error", onError);
|
|
847
|
+
resolveWrite();
|
|
848
|
+
});
|
|
849
|
+
}).catch(error => {
|
|
850
|
+
if (guardian.exitCode === null && guardian.signalCode === null) throw error;
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const result = await exit;
|
|
855
|
+
if (result.code !== 0) {
|
|
856
|
+
const detail = diagnostic();
|
|
857
|
+
throw new ApplicationError(
|
|
858
|
+
"subprocess",
|
|
859
|
+
`Workflow-bundler retirement guardian failed (code ${String(result.code)}, signal ${String(result.signal)})${detail === "" ? "." : `: ${detail}`}`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
failure,
|
|
864
|
+
retireDetached: () => {
|
|
865
|
+
if (settlementRequested !== undefined) return settlementRequested === "retire";
|
|
866
|
+
if (guardian.exitCode !== null || guardian.signalCode !== null) return false;
|
|
867
|
+
settlementRequested = "retire";
|
|
868
|
+
// The guardian was installed before the bundler's start gate. Closing
|
|
869
|
+
// this liveness pipe transfers exact kill, post-death lease release, and
|
|
870
|
+
// bounded scratch cleanup even if the caller itself is terminating.
|
|
871
|
+
guardian.stdin?.destroy();
|
|
872
|
+
guardian.stdout?.destroy();
|
|
873
|
+
guardian.stderr?.destroy();
|
|
874
|
+
guardian.unref();
|
|
875
|
+
return true;
|
|
876
|
+
},
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function bareImportAliases(
|
|
881
|
+
imports: readonly string[],
|
|
882
|
+
resolutionRoot: string,
|
|
883
|
+
): Promise<Readonly<Record<string, string>>> {
|
|
884
|
+
const aliases: Record<string, string> = {};
|
|
885
|
+
for (const specifier of imports) {
|
|
886
|
+
if (specifier === "bun" || specifier.startsWith("node:")) continue;
|
|
887
|
+
try {
|
|
888
|
+
const resolvedPath = await Bun.resolve(specifier, resolutionRoot);
|
|
889
|
+
aliases[specifier] = resolvedPath;
|
|
890
|
+
} catch (error) {
|
|
891
|
+
throw new ApplicationError(
|
|
892
|
+
"not-found",
|
|
893
|
+
`Could not resolve allowlisted workflow import ${specifier}: ${String(error)}`,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return aliases;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function restoreWorkflowSourceBundle(
|
|
901
|
+
options: RestoreWorkflowSourceBundleOptions,
|
|
902
|
+
): WorkflowSourceBundle {
|
|
903
|
+
const bytes = new Uint8Array(options.bytes);
|
|
904
|
+
const actualSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
905
|
+
if (
|
|
906
|
+
bytes.byteLength !== options.bundle.bytes
|
|
907
|
+
|| actualSha256 !== options.bundle.bundleSha256
|
|
908
|
+
) {
|
|
909
|
+
throw new ApplicationError(
|
|
910
|
+
"invalid-data",
|
|
911
|
+
"Persisted workflow bundle bytes do not match their graph-plan identity.",
|
|
912
|
+
{
|
|
913
|
+
actualBytes: bytes.byteLength,
|
|
914
|
+
actualSha256,
|
|
915
|
+
expectedBytes: options.bundle.bytes,
|
|
916
|
+
expectedSha256: options.bundle.bundleSha256,
|
|
917
|
+
},
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
if (options.externalModules.length !== 0) {
|
|
921
|
+
throw new ApplicationError(
|
|
922
|
+
"incompatible",
|
|
923
|
+
"This code-worker ABI accepts only self-contained persisted workflow bundles.",
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
return Object.freeze({
|
|
927
|
+
bytes,
|
|
928
|
+
dependencyGraphSha256: options.bundle.dependencyGraphSha256,
|
|
929
|
+
entryPath: options.bundle.entrypoint,
|
|
930
|
+
entryRelativePath: options.bundle.entrypoint,
|
|
931
|
+
externalImports: [],
|
|
932
|
+
identity: WORKFLOW_BUNDLE_IDENTITY,
|
|
933
|
+
importedPaths: [],
|
|
934
|
+
sha256: options.bundle.bundleSha256,
|
|
935
|
+
sourceSha256: options.bundle.sourceSha256,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function buildWorkflowBundle(
|
|
940
|
+
entryRelativePath: string,
|
|
941
|
+
modules: readonly CapturedSourceModule[],
|
|
942
|
+
aliases: Readonly<Record<string, string>>,
|
|
943
|
+
configSearchPath: string,
|
|
944
|
+
includeRuntimeTypes: boolean,
|
|
945
|
+
semanticCheck: boolean,
|
|
946
|
+
inheritedHostResourceFileDescriptor: number,
|
|
947
|
+
maximumBundleBytes: number,
|
|
948
|
+
maximumBundlerDurationMs: number,
|
|
949
|
+
afterSourceSnapshot?: () => Promise<void> | void,
|
|
950
|
+
afterBundlerStarted?: BundleWorkflowSourceOptions["afterBundlerStarted"],
|
|
951
|
+
afterBundlerRetirementGuardianReady?: BundleWorkflowSourceOptions["afterBundlerRetirementGuardianReady"],
|
|
952
|
+
): Promise<Uint8Array> {
|
|
953
|
+
// The runtime identity records this Bun, so bundling must not silently
|
|
954
|
+
// switch to a different PATH-resolved executable.
|
|
955
|
+
const bunExecutable = process.execPath;
|
|
956
|
+
const directory = await mkdtemp(join(tmpdir(), "slopcamera-workflow-bundle-"));
|
|
957
|
+
await chmod(directory, 0o700);
|
|
958
|
+
const requestPath = join(directory, "request.json");
|
|
959
|
+
const outputPath = join(directory, "workflow.bundle.js");
|
|
960
|
+
const sourceRoot = join(directory, "source");
|
|
961
|
+
let guardianOwnsCleanup = false;
|
|
962
|
+
try {
|
|
963
|
+
await mkdir(sourceRoot, { mode: 0o700 });
|
|
964
|
+
for (const module of modules) {
|
|
965
|
+
const target = join(sourceRoot, module.relativePath);
|
|
966
|
+
if (!isWithin(sourceRoot, target)) {
|
|
967
|
+
throw new ApplicationError(
|
|
968
|
+
"internal",
|
|
969
|
+
`Captured workflow source escaped its private snapshot: ${module.relativePath}`,
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
await mkdir(dirname(target), { mode: 0o700, recursive: true });
|
|
973
|
+
await writeFile(target, module.source, { flag: "wx", mode: 0o400 });
|
|
974
|
+
}
|
|
975
|
+
const entryPath = join(sourceRoot, entryRelativePath);
|
|
976
|
+
await afterSourceSnapshot?.();
|
|
977
|
+
if (semanticCheck) {
|
|
978
|
+
typecheckWorkflowSnapshot({
|
|
979
|
+
aliases,
|
|
980
|
+
configSearchPath,
|
|
981
|
+
entryPath,
|
|
982
|
+
includeRuntimeTypes,
|
|
983
|
+
sourceRoot,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
await writeFile(
|
|
987
|
+
requestPath,
|
|
988
|
+
`${canonicalJson({ aliases, entryPath })}\n`,
|
|
989
|
+
{ flag: "wx", mode: 0o600 },
|
|
990
|
+
);
|
|
991
|
+
const child = Bun.spawn(
|
|
992
|
+
[
|
|
993
|
+
bunExecutable,
|
|
994
|
+
"-e",
|
|
995
|
+
BUNDLE_SUBPROCESS_SOURCE,
|
|
996
|
+
requestPath,
|
|
997
|
+
outputPath,
|
|
998
|
+
inheritedHostResourceFileDescriptor === 0 ? "0" : "3",
|
|
999
|
+
String(maximumBundleBytes),
|
|
1000
|
+
pathToFileURL(join(import.meta.dir, "worker-process-identity.ts")).href,
|
|
1001
|
+
],
|
|
1002
|
+
{
|
|
1003
|
+
cwd: sourceRoot,
|
|
1004
|
+
env: {
|
|
1005
|
+
LANG: "C.UTF-8",
|
|
1006
|
+
LC_ALL: "C.UTF-8",
|
|
1007
|
+
PATH: dirname(bunExecutable),
|
|
1008
|
+
TMPDIR: directory,
|
|
1009
|
+
},
|
|
1010
|
+
stdio: [
|
|
1011
|
+
"pipe",
|
|
1012
|
+
"pipe",
|
|
1013
|
+
"pipe",
|
|
1014
|
+
...(inheritedHostResourceFileDescriptor === 0
|
|
1015
|
+
? []
|
|
1016
|
+
: [inheritedHostResourceFileDescriptor]),
|
|
1017
|
+
],
|
|
1018
|
+
},
|
|
1019
|
+
);
|
|
1020
|
+
const exitPromise = child.exited;
|
|
1021
|
+
const terminate = (): void => {
|
|
1022
|
+
try {
|
|
1023
|
+
child.kill("SIGKILL");
|
|
1024
|
+
} catch {
|
|
1025
|
+
// A process that exited between the boundary failure and this signal
|
|
1026
|
+
// already released its resources.
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
const processId = child.pid;
|
|
1030
|
+
if (!Number.isSafeInteger(processId) || processId < 2) {
|
|
1031
|
+
terminate();
|
|
1032
|
+
await exitPromise;
|
|
1033
|
+
throw new ApplicationError(
|
|
1034
|
+
"subprocess",
|
|
1035
|
+
"Workflow bundler did not expose a valid process ID.",
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
let processStartIdentity: string;
|
|
1039
|
+
try {
|
|
1040
|
+
processStartIdentity = process.platform === "win32"
|
|
1041
|
+
? "0".repeat(64)
|
|
1042
|
+
: captureWorkerProcessStartIdentity(processId);
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
terminate();
|
|
1045
|
+
await exitPromise;
|
|
1046
|
+
throw error;
|
|
1047
|
+
}
|
|
1048
|
+
const diagnosticAbort = new AbortController();
|
|
1049
|
+
const diagnosticPromise = readBoundedBundlerDiagnostic(
|
|
1050
|
+
child.stderr,
|
|
1051
|
+
diagnosticAbort.signal,
|
|
1052
|
+
);
|
|
1053
|
+
void diagnosticPromise.catch(() => undefined);
|
|
1054
|
+
let guardian: BundlerRetirementGuardian | undefined;
|
|
1055
|
+
try {
|
|
1056
|
+
if (process.platform !== "win32") {
|
|
1057
|
+
guardian = await startBundlerRetirementGuardian({
|
|
1058
|
+
bunExecutable,
|
|
1059
|
+
directory,
|
|
1060
|
+
inheritedHostResourceFileDescriptor,
|
|
1061
|
+
processId,
|
|
1062
|
+
processStartIdentity,
|
|
1063
|
+
});
|
|
1064
|
+
// From this acknowledgement onward, caller EOF is a durable transfer.
|
|
1065
|
+
// The child is still behind its exact startup gate.
|
|
1066
|
+
guardianOwnsCleanup = true;
|
|
1067
|
+
}
|
|
1068
|
+
const startup = waitForBundlerStartup({
|
|
1069
|
+
exit: exitPromise,
|
|
1070
|
+
stream: child.stdout,
|
|
1071
|
+
});
|
|
1072
|
+
void startup.catch(() => undefined);
|
|
1073
|
+
await (guardian === undefined
|
|
1074
|
+
? startup
|
|
1075
|
+
: Promise.race([startup, guardian.failure]));
|
|
1076
|
+
await afterBundlerStarted?.({
|
|
1077
|
+
processId,
|
|
1078
|
+
processStartIdentity,
|
|
1079
|
+
temporaryDirectory: directory,
|
|
1080
|
+
});
|
|
1081
|
+
if (guardian !== undefined) {
|
|
1082
|
+
await afterBundlerRetirementGuardianReady?.({
|
|
1083
|
+
processId,
|
|
1084
|
+
processStartIdentity,
|
|
1085
|
+
temporaryDirectory: directory,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
await child.stdin.write("start\n");
|
|
1089
|
+
await child.stdin.flush();
|
|
1090
|
+
await child.stdin.end();
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
diagnosticAbort.abort(error);
|
|
1093
|
+
const transferred = guardian?.retireDetached() ?? false;
|
|
1094
|
+
if (transferred) {
|
|
1095
|
+
child.unref();
|
|
1096
|
+
} else {
|
|
1097
|
+
guardianOwnsCleanup = false;
|
|
1098
|
+
terminate();
|
|
1099
|
+
await Promise.allSettled([exitPromise, diagnosticPromise]);
|
|
1100
|
+
}
|
|
1101
|
+
throw new ApplicationError(
|
|
1102
|
+
"subprocess",
|
|
1103
|
+
`Workflow bundler startup gate failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
const diagnosticBoundary = diagnosticPromise.then(diagnostic => {
|
|
1107
|
+
if (diagnostic.overflowed) {
|
|
1108
|
+
throw new ApplicationError(
|
|
1109
|
+
"invalid-data",
|
|
1110
|
+
`Workflow bundler diagnostic exceeds ${String(MAX_BUNDLER_DIAGNOSTIC_BYTES)} bytes.`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
return diagnostic;
|
|
1114
|
+
});
|
|
1115
|
+
void diagnosticBoundary.catch(() => undefined);
|
|
1116
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
1117
|
+
const deadlinePromise = new Promise<never>((_resolve, reject) => {
|
|
1118
|
+
deadline = setTimeout(() => {
|
|
1119
|
+
reject(new ApplicationError(
|
|
1120
|
+
"unavailable",
|
|
1121
|
+
`Workflow bundler exceeded ${String(maximumBundlerDurationMs)} milliseconds.`,
|
|
1122
|
+
));
|
|
1123
|
+
}, maximumBundlerDurationMs);
|
|
1124
|
+
});
|
|
1125
|
+
let exitCode: number;
|
|
1126
|
+
let diagnostic: Awaited<typeof diagnosticPromise>;
|
|
1127
|
+
try {
|
|
1128
|
+
[exitCode, diagnostic] = await Promise.race([
|
|
1129
|
+
Promise.all([exitPromise, diagnosticBoundary]),
|
|
1130
|
+
deadlinePromise,
|
|
1131
|
+
...(guardian === undefined ? [] : [guardian.failure]),
|
|
1132
|
+
]);
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
diagnosticAbort.abort(error);
|
|
1135
|
+
const transferred = guardian?.retireDetached() ?? false;
|
|
1136
|
+
if (transferred) {
|
|
1137
|
+
child.unref();
|
|
1138
|
+
} else {
|
|
1139
|
+
guardianOwnsCleanup = false;
|
|
1140
|
+
terminate();
|
|
1141
|
+
await Promise.allSettled([exitPromise, diagnosticPromise]);
|
|
1142
|
+
}
|
|
1143
|
+
throw error;
|
|
1144
|
+
} finally {
|
|
1145
|
+
if (deadline !== undefined) clearTimeout(deadline);
|
|
1146
|
+
}
|
|
1147
|
+
let terminalError: Error | undefined;
|
|
1148
|
+
let output: Uint8Array | undefined;
|
|
1149
|
+
if (exitCode !== 0) {
|
|
1150
|
+
terminalError = new ApplicationError(
|
|
1151
|
+
"invalid-data",
|
|
1152
|
+
`Workflow bundle failed: ${
|
|
1153
|
+
diagnostic.text.trim() || `exit ${String(exitCode)}`
|
|
1154
|
+
}`,
|
|
1155
|
+
);
|
|
1156
|
+
} else {
|
|
1157
|
+
try {
|
|
1158
|
+
output = await readBoundedPhysicalFile({
|
|
1159
|
+
maximumBytes: maximumBundleBytes,
|
|
1160
|
+
path: outputPath,
|
|
1161
|
+
tooLargeMessage: `Workflow bundle exceeds ${String(maximumBundleBytes)} bytes.`,
|
|
1162
|
+
});
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
terminalError = error instanceof Error ? error : new Error(String(error));
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (guardian !== undefined) {
|
|
1168
|
+
try {
|
|
1169
|
+
await guardian.complete();
|
|
1170
|
+
} catch (guardianError) {
|
|
1171
|
+
if (terminalError === undefined) throw guardianError;
|
|
1172
|
+
throw new AggregateError(
|
|
1173
|
+
[terminalError, guardianError],
|
|
1174
|
+
"Workflow bundling and its retirement guardian both failed.",
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (terminalError !== undefined) throw terminalError;
|
|
1179
|
+
if (output === undefined) {
|
|
1180
|
+
throw new ApplicationError("internal", "Workflow bundler omitted its output bytes.");
|
|
1181
|
+
}
|
|
1182
|
+
return output;
|
|
1183
|
+
} finally {
|
|
1184
|
+
if (!guardianOwnsCleanup) {
|
|
1185
|
+
await rm(directory, { force: true, recursive: true });
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
1191
|
+
const fromRoot = relative(root, candidate);
|
|
1192
|
+
return fromRoot === "" || (!fromRoot.startsWith("..") && !isAbsolute(fromRoot));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async function physicalFileWithin(root: string, requested: string): Promise<string> {
|
|
1196
|
+
const lexical = resolve(requested);
|
|
1197
|
+
if (!isWithin(root, lexical)) {
|
|
1198
|
+
throw new ApplicationError("unsafe-path", `Workflow source escapes its allowed root: ${requested}`);
|
|
1199
|
+
}
|
|
1200
|
+
const details = await lstat(lexical);
|
|
1201
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
1202
|
+
throw new ApplicationError("unsafe-path", `Workflow source must be a physical regular file: ${requested}`);
|
|
1203
|
+
}
|
|
1204
|
+
const physical = await realpath(lexical);
|
|
1205
|
+
if (!isWithin(root, physical) || physical !== lexical) {
|
|
1206
|
+
throw new ApplicationError("unsafe-path", `Workflow source crosses a symbolic link: ${requested}`);
|
|
1207
|
+
}
|
|
1208
|
+
if (!SOURCE_EXTENSIONS.has(extname(physical))) {
|
|
1209
|
+
throw new ApplicationError("usage", `Workflow source has an unsupported extension: ${extname(physical)}`);
|
|
1210
|
+
}
|
|
1211
|
+
return physical;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function allowedBareImport(specifier: string, allowlist: ReadonlySet<string>): boolean {
|
|
1215
|
+
if (specifier.startsWith("node:") || specifier === "bun") return true;
|
|
1216
|
+
return allowlist.has(specifier);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function containsNonliteralDynamicImport(source: Uint8Array): boolean {
|
|
1220
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(source);
|
|
1221
|
+
type Mode =
|
|
1222
|
+
| { readonly kind: "block-comment" }
|
|
1223
|
+
| { readonly kind: "code"; braceDepth: number | undefined }
|
|
1224
|
+
| { readonly kind: "line-comment" }
|
|
1225
|
+
| { readonly kind: "quoted"; readonly quote: "'" | "\"" }
|
|
1226
|
+
| { readonly kind: "template" };
|
|
1227
|
+
const modes: Mode[] = [{ kind: "code", braceDepth: undefined }];
|
|
1228
|
+
let index = 0;
|
|
1229
|
+
|
|
1230
|
+
while (index < text.length) {
|
|
1231
|
+
const mode = modes.at(-1)!;
|
|
1232
|
+
const character = text[index]!;
|
|
1233
|
+
const next = text[index + 1];
|
|
1234
|
+
if (mode.kind === "line-comment") {
|
|
1235
|
+
index += 1;
|
|
1236
|
+
if (character === "\n" || character === "\r") modes.pop();
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
if (mode.kind === "block-comment") {
|
|
1240
|
+
index += 1;
|
|
1241
|
+
if (character === "*" && next === "/") {
|
|
1242
|
+
index += 1;
|
|
1243
|
+
modes.pop();
|
|
1244
|
+
}
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
if (mode.kind === "quoted") {
|
|
1248
|
+
index += 1;
|
|
1249
|
+
if (character === "\\") index += 1;
|
|
1250
|
+
else if (character === mode.quote) modes.pop();
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
if (mode.kind === "template") {
|
|
1254
|
+
index += 1;
|
|
1255
|
+
if (character === "\\") {
|
|
1256
|
+
index += 1;
|
|
1257
|
+
} else if (character === "`") {
|
|
1258
|
+
modes.pop();
|
|
1259
|
+
} else if (character === "$" && next === "{") {
|
|
1260
|
+
index += 1;
|
|
1261
|
+
modes.push({ kind: "code", braceDepth: 1 });
|
|
1262
|
+
}
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (character === "/" && next === "/") {
|
|
1267
|
+
modes.push({ kind: "line-comment" });
|
|
1268
|
+
index += 2;
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (character === "/" && next === "*") {
|
|
1272
|
+
modes.push({ kind: "block-comment" });
|
|
1273
|
+
index += 2;
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
if (character === "'" || character === "\"") {
|
|
1277
|
+
modes.push({ kind: "quoted", quote: character });
|
|
1278
|
+
index += 1;
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
if (character === "`") {
|
|
1282
|
+
modes.push({ kind: "template" });
|
|
1283
|
+
index += 1;
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (mode.braceDepth !== undefined && character === "{") {
|
|
1287
|
+
mode.braceDepth += 1;
|
|
1288
|
+
index += 1;
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
if (mode.braceDepth !== undefined && character === "}") {
|
|
1292
|
+
mode.braceDepth -= 1;
|
|
1293
|
+
index += 1;
|
|
1294
|
+
if (mode.braceDepth === 0) modes.pop();
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
if (
|
|
1298
|
+
text.startsWith("import", index)
|
|
1299
|
+
&& !/[A-Za-z0-9_$]/u.test(text[index - 1] ?? "")
|
|
1300
|
+
&& !/[A-Za-z0-9_$]/u.test(text[index + "import".length] ?? "")
|
|
1301
|
+
) {
|
|
1302
|
+
let lookahead = index + "import".length;
|
|
1303
|
+
while (lookahead < text.length) {
|
|
1304
|
+
if (/\s/u.test(text[lookahead]!)) {
|
|
1305
|
+
lookahead += 1;
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
if (text.startsWith("//", lookahead)) {
|
|
1309
|
+
const newline = text.indexOf("\n", lookahead + 2);
|
|
1310
|
+
lookahead = newline === -1 ? text.length : newline + 1;
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
if (text.startsWith("/*", lookahead)) {
|
|
1314
|
+
const end = text.indexOf("*/", lookahead + 2);
|
|
1315
|
+
lookahead = end === -1 ? text.length : end + 2;
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
if (text[lookahead] === "(") return true;
|
|
1321
|
+
index = lookahead;
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
index += 1;
|
|
1325
|
+
}
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
interface ScannedSourceGraph {
|
|
1330
|
+
readonly bareImports: readonly string[];
|
|
1331
|
+
readonly dependencyGraphSha256: string;
|
|
1332
|
+
readonly importedPaths: readonly string[];
|
|
1333
|
+
readonly modules: readonly CapturedSourceModule[];
|
|
1334
|
+
readonly sourceSha256: string;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
interface CapturedSourceModule {
|
|
1338
|
+
readonly relativePath: string;
|
|
1339
|
+
readonly source: Uint8Array;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function scanSourceGraph(options: {
|
|
1343
|
+
readonly afterSourceFileOpened?: (sourcePath: string) => Promise<void> | void;
|
|
1344
|
+
readonly beforeSourceFileOpened?: (sourcePath: string) => Promise<void> | void;
|
|
1345
|
+
readonly allowedBareImports: ReadonlySet<string>;
|
|
1346
|
+
readonly allowedRoot: string;
|
|
1347
|
+
readonly entryPath: string;
|
|
1348
|
+
readonly maximumImportEdges: number;
|
|
1349
|
+
readonly maximumModules: number;
|
|
1350
|
+
readonly maximumSourceBytes: number;
|
|
1351
|
+
readonly maximumTotalSourceBytes: number;
|
|
1352
|
+
}): Promise<ScannedSourceGraph> {
|
|
1353
|
+
const transpiler = new Bun.Transpiler({ loader: "tsx" });
|
|
1354
|
+
const pending = [options.entryPath];
|
|
1355
|
+
const visited = new Set<string>();
|
|
1356
|
+
const bareImports = new Set<string>();
|
|
1357
|
+
let importEdgeCount = 0;
|
|
1358
|
+
let totalSourceBytes = 0;
|
|
1359
|
+
const modules = new Map<string, {
|
|
1360
|
+
readonly bytes: number;
|
|
1361
|
+
readonly sha256: string;
|
|
1362
|
+
readonly source: Uint8Array;
|
|
1363
|
+
}>();
|
|
1364
|
+
while (pending.length > 0) {
|
|
1365
|
+
const sourcePath = pending.pop()!;
|
|
1366
|
+
if (visited.has(sourcePath)) continue;
|
|
1367
|
+
if (visited.size >= options.maximumModules) {
|
|
1368
|
+
throw new ApplicationError("invalid-data", `Workflow source exceeds ${String(options.maximumModules)} modules.`);
|
|
1369
|
+
}
|
|
1370
|
+
visited.add(sourcePath);
|
|
1371
|
+
const sourceRelativePath = relative(options.allowedRoot, sourcePath);
|
|
1372
|
+
const afterSourceFileOpened = options.afterSourceFileOpened;
|
|
1373
|
+
await options.beforeSourceFileOpened?.(sourcePath);
|
|
1374
|
+
const source = await readBoundedPhysicalFile({
|
|
1375
|
+
admitBytes: byteLength => {
|
|
1376
|
+
if (byteLength > options.maximumTotalSourceBytes - totalSourceBytes) {
|
|
1377
|
+
throw new ApplicationError(
|
|
1378
|
+
"invalid-data",
|
|
1379
|
+
`Workflow source exceeds ${String(options.maximumTotalSourceBytes)} total bytes.`,
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
totalSourceBytes += byteLength;
|
|
1383
|
+
},
|
|
1384
|
+
...(afterSourceFileOpened === undefined
|
|
1385
|
+
? {}
|
|
1386
|
+
: { afterOpen: async () => await afterSourceFileOpened(sourcePath) }),
|
|
1387
|
+
expectedPhysicalPath: sourcePath,
|
|
1388
|
+
maximumBytes: options.maximumSourceBytes,
|
|
1389
|
+
path: sourcePath,
|
|
1390
|
+
tooLargeMessage:
|
|
1391
|
+
`Workflow module exceeds ${String(options.maximumSourceBytes)} bytes: ${sourceRelativePath}`,
|
|
1392
|
+
});
|
|
1393
|
+
modules.set(sourceRelativePath, {
|
|
1394
|
+
bytes: source.byteLength,
|
|
1395
|
+
sha256: createHash("sha256").update(source).digest("hex"),
|
|
1396
|
+
source,
|
|
1397
|
+
});
|
|
1398
|
+
if (containsNonliteralDynamicImport(source)) {
|
|
1399
|
+
throw new ApplicationError(
|
|
1400
|
+
"unsupported-plan",
|
|
1401
|
+
"Workflow modules cannot use dynamic-import: <nonliteral>",
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
let imports: ReturnType<Bun.Transpiler["scanImports"]>;
|
|
1405
|
+
try {
|
|
1406
|
+
imports = transpiler.scanImports(source);
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
throw new ApplicationError("invalid-data", `Could not parse workflow module ${sourcePath}: ${String(error)}`);
|
|
1409
|
+
}
|
|
1410
|
+
const semanticImports = ts.preProcessFile(
|
|
1411
|
+
new TextDecoder().decode(source),
|
|
1412
|
+
true,
|
|
1413
|
+
true,
|
|
1414
|
+
).importedFiles.map(imported => imported.fileName);
|
|
1415
|
+
const moduleImportEdgeCount = Math.max(imports.length, semanticImports.length);
|
|
1416
|
+
if (moduleImportEdgeCount > options.maximumImportEdges - importEdgeCount) {
|
|
1417
|
+
throw new ApplicationError(
|
|
1418
|
+
"invalid-data",
|
|
1419
|
+
`Workflow source exceeds ${String(options.maximumImportEdges)} import edges.`,
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
importEdgeCount += moduleImportEdgeCount;
|
|
1423
|
+
const staticSpecifiers = new Set<string>();
|
|
1424
|
+
for (const imported of imports) {
|
|
1425
|
+
if (
|
|
1426
|
+
imported.kind === "dynamic-import"
|
|
1427
|
+
|| imported.kind === "require-call"
|
|
1428
|
+
|| imported.kind === "require-resolve"
|
|
1429
|
+
) {
|
|
1430
|
+
throw new ApplicationError(
|
|
1431
|
+
"unsupported-plan",
|
|
1432
|
+
`Workflow modules cannot use ${imported.kind}: ${imported.path || "<nonliteral>"}`,
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
staticSpecifiers.add(imported.path);
|
|
1436
|
+
}
|
|
1437
|
+
for (const specifier of semanticImports) staticSpecifiers.add(specifier);
|
|
1438
|
+
for (const specifier of staticSpecifiers) {
|
|
1439
|
+
if (specifier.includes("@hraness/slopcamera/local/code/testing")) {
|
|
1440
|
+
throw new ApplicationError("unsupported-plan", "Production workflows cannot import @hraness/slopcamera/local/code/testing.");
|
|
1441
|
+
}
|
|
1442
|
+
if (!specifier.startsWith(".") && !isAbsolute(specifier)) {
|
|
1443
|
+
if (!allowedBareImport(specifier, options.allowedBareImports)) {
|
|
1444
|
+
throw new ApplicationError("unsupported-plan", `Workflow bare import is not allowlisted: ${specifier}`);
|
|
1445
|
+
}
|
|
1446
|
+
bareImports.add(specifier);
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
let resolvedImport: string;
|
|
1450
|
+
try {
|
|
1451
|
+
resolvedImport = await Bun.resolve(specifier, dirname(sourcePath));
|
|
1452
|
+
} catch (error) {
|
|
1453
|
+
throw new ApplicationError(
|
|
1454
|
+
"not-found",
|
|
1455
|
+
`Could not resolve workflow import ${specifier} from ${sourcePath}: ${String(error)}`,
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
pending.push(await physicalFileWithin(options.allowedRoot, resolvedImport));
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
const importedPaths = Object.freeze([...visited].sort());
|
|
1462
|
+
const moduleDescriptors = [...modules]
|
|
1463
|
+
.map(([path, value]) => ({
|
|
1464
|
+
bytes: value.bytes,
|
|
1465
|
+
path,
|
|
1466
|
+
sha256: value.sha256,
|
|
1467
|
+
}))
|
|
1468
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
1469
|
+
const entryDescriptor = modules.get(relative(options.allowedRoot, options.entryPath));
|
|
1470
|
+
if (entryDescriptor === undefined) {
|
|
1471
|
+
throw new ApplicationError("internal", "Workflow source scan omitted its entry module.");
|
|
1472
|
+
}
|
|
1473
|
+
return Object.freeze({
|
|
1474
|
+
bareImports: Object.freeze([...bareImports].sort()),
|
|
1475
|
+
dependencyGraphSha256: sha256Hex(
|
|
1476
|
+
`studio.workflow.source-graph/v1\0${canonicalJson(moduleDescriptors)}`,
|
|
1477
|
+
),
|
|
1478
|
+
importedPaths,
|
|
1479
|
+
modules: Object.freeze(
|
|
1480
|
+
[...modules]
|
|
1481
|
+
.map(([relativePath, value]) => Object.freeze({
|
|
1482
|
+
relativePath,
|
|
1483
|
+
source: value.source,
|
|
1484
|
+
}))
|
|
1485
|
+
.sort((left, right) => left.relativePath.localeCompare(right.relativePath)),
|
|
1486
|
+
),
|
|
1487
|
+
sourceSha256: entryDescriptor.sha256,
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
async function bundleWorkflowSourceInternal(
|
|
1492
|
+
options: BundleWorkflowSourceOptions,
|
|
1493
|
+
semanticCheck: boolean,
|
|
1494
|
+
): Promise<WorkflowSourceBundle> {
|
|
1495
|
+
const lexicalRoot = resolve(options.allowedRoot);
|
|
1496
|
+
const rootDetails = await lstat(lexicalRoot);
|
|
1497
|
+
if (rootDetails.isSymbolicLink() || !rootDetails.isDirectory()) {
|
|
1498
|
+
throw new ApplicationError("unsafe-path", "Workflow source root must be a physical directory.");
|
|
1499
|
+
}
|
|
1500
|
+
const allowedRoot = await realpath(lexicalRoot);
|
|
1501
|
+
const requestedEntry = resolve(lexicalRoot, options.entryPath);
|
|
1502
|
+
if (!isWithin(lexicalRoot, requestedEntry)) {
|
|
1503
|
+
throw new ApplicationError(
|
|
1504
|
+
"unsafe-path",
|
|
1505
|
+
`Workflow source escapes its allowed root: ${options.entryPath}`,
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
const entryPath = await physicalFileWithin(
|
|
1509
|
+
allowedRoot,
|
|
1510
|
+
resolve(allowedRoot, relative(lexicalRoot, requestedEntry)),
|
|
1511
|
+
);
|
|
1512
|
+
const maximumModules = options.maximumModules ?? MAX_SOURCE_MODULES;
|
|
1513
|
+
const maximumSourceBytes = options.maximumSourceBytes ?? MAX_SOURCE_BYTES;
|
|
1514
|
+
const maximumTotalSourceBytes =
|
|
1515
|
+
options.maximumTotalSourceBytes ?? MAX_TOTAL_SOURCE_BYTES;
|
|
1516
|
+
const maximumImportEdges =
|
|
1517
|
+
options.maximumImportEdges ?? MAX_SOURCE_IMPORT_EDGES;
|
|
1518
|
+
const maximumBundleBytes = options.maximumBundleBytes ?? MAX_BUNDLE_BYTES;
|
|
1519
|
+
const maximumBundlerDurationMs =
|
|
1520
|
+
options.maximumBundlerDurationMs ?? MAX_BUNDLER_DURATION_MS;
|
|
1521
|
+
const inheritedHostResourceFileDescriptor =
|
|
1522
|
+
options.inheritedHostResourceFileDescriptor ?? 0;
|
|
1523
|
+
if (
|
|
1524
|
+
!Number.isSafeInteger(inheritedHostResourceFileDescriptor)
|
|
1525
|
+
|| inheritedHostResourceFileDescriptor < 0
|
|
1526
|
+
|| (
|
|
1527
|
+
inheritedHostResourceFileDescriptor > 0
|
|
1528
|
+
&& inheritedHostResourceFileDescriptor < 3
|
|
1529
|
+
)
|
|
1530
|
+
) {
|
|
1531
|
+
throw new ApplicationError(
|
|
1532
|
+
"usage",
|
|
1533
|
+
"Workflow bundling requires one safe inherited host-resource descriptor.",
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
for (const [label, value] of [
|
|
1537
|
+
["module", maximumModules],
|
|
1538
|
+
["import edge", maximumImportEdges],
|
|
1539
|
+
["source byte", maximumSourceBytes],
|
|
1540
|
+
["total source byte", maximumTotalSourceBytes],
|
|
1541
|
+
["bundle byte", maximumBundleBytes],
|
|
1542
|
+
["bundler duration millisecond", maximumBundlerDurationMs],
|
|
1543
|
+
] as const) {
|
|
1544
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
1545
|
+
throw new ApplicationError("usage", `Workflow ${label} limit must be a positive safe integer.`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (maximumBundlerDurationMs > MAX_TIMER_DURATION_MS) {
|
|
1549
|
+
throw new ApplicationError(
|
|
1550
|
+
"usage",
|
|
1551
|
+
`Workflow bundler duration cannot exceed ${String(MAX_TIMER_DURATION_MS)} milliseconds.`,
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
const allowedBareImports = options.allowedBareImports ?? WORKFLOW_ALLOWED_BARE_IMPORTS;
|
|
1555
|
+
const allowedBareImportSet = new Set(allowedBareImports);
|
|
1556
|
+
const sourceGraph = await scanSourceGraph({
|
|
1557
|
+
...(options.afterSourceFileOpened === undefined
|
|
1558
|
+
? {}
|
|
1559
|
+
: { afterSourceFileOpened: options.afterSourceFileOpened }),
|
|
1560
|
+
...(options.beforeSourceFileOpened === undefined
|
|
1561
|
+
? {}
|
|
1562
|
+
: { beforeSourceFileOpened: options.beforeSourceFileOpened }),
|
|
1563
|
+
allowedBareImports: allowedBareImportSet,
|
|
1564
|
+
allowedRoot,
|
|
1565
|
+
entryPath,
|
|
1566
|
+
maximumImportEdges,
|
|
1567
|
+
maximumModules,
|
|
1568
|
+
maximumSourceBytes,
|
|
1569
|
+
maximumTotalSourceBytes,
|
|
1570
|
+
});
|
|
1571
|
+
const aliasSpecifiers = new Set(sourceGraph.bareImports);
|
|
1572
|
+
const aliases = await bareImportAliases(
|
|
1573
|
+
[...aliasSpecifiers].sort(),
|
|
1574
|
+
resolve(options.bareImportResolutionRoot ?? import.meta.dir),
|
|
1575
|
+
);
|
|
1576
|
+
const entryRelativePath = relative(allowedRoot, entryPath);
|
|
1577
|
+
const bytes = await buildWorkflowBundle(
|
|
1578
|
+
entryRelativePath,
|
|
1579
|
+
sourceGraph.modules,
|
|
1580
|
+
aliases,
|
|
1581
|
+
resolve(options.bareImportResolutionRoot ?? import.meta.dir),
|
|
1582
|
+
(
|
|
1583
|
+
sourceGraph.bareImports.some(specifier => (
|
|
1584
|
+
specifier === "bun" || specifier.startsWith("node:")
|
|
1585
|
+
))
|
|
1586
|
+
|| sourceGraph.modules.some(module => (
|
|
1587
|
+
/\b(?:Bun|process)\b/u.test(new TextDecoder().decode(module.source))
|
|
1588
|
+
))
|
|
1589
|
+
),
|
|
1590
|
+
semanticCheck,
|
|
1591
|
+
inheritedHostResourceFileDescriptor,
|
|
1592
|
+
maximumBundleBytes,
|
|
1593
|
+
maximumBundlerDurationMs,
|
|
1594
|
+
options.afterSourceSnapshot,
|
|
1595
|
+
options.afterBundlerStarted,
|
|
1596
|
+
options.afterBundlerRetirementGuardianReady,
|
|
1597
|
+
);
|
|
1598
|
+
if (bytes.byteLength > maximumBundleBytes) {
|
|
1599
|
+
throw new ApplicationError("invalid-data", `Workflow bundle exceeds ${String(maximumBundleBytes)} bytes.`);
|
|
1600
|
+
}
|
|
1601
|
+
return Object.freeze({
|
|
1602
|
+
bytes,
|
|
1603
|
+
dependencyGraphSha256: sourceGraph.dependencyGraphSha256,
|
|
1604
|
+
entryPath,
|
|
1605
|
+
entryRelativePath,
|
|
1606
|
+
externalImports: Object.freeze([]),
|
|
1607
|
+
identity: WORKFLOW_BUNDLE_IDENTITY,
|
|
1608
|
+
importedPaths: sourceGraph.importedPaths,
|
|
1609
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1610
|
+
sourceSha256: sourceGraph.sourceSha256,
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Low-level exact bundling primitive. Production custom-code entry points use
|
|
1616
|
+
* checkAndBundleWorkflowSource so syntax, import policy, and semantic types are
|
|
1617
|
+
* all proved over the same captured bytes.
|
|
1618
|
+
*/
|
|
1619
|
+
export async function bundleWorkflowSource(
|
|
1620
|
+
options: BundleWorkflowSourceOptions,
|
|
1621
|
+
): Promise<WorkflowSourceBundle> {
|
|
1622
|
+
return await bundleWorkflowSourceInternal(options, false);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
export async function checkAndBundleWorkflowSource(
|
|
1626
|
+
options: BundleWorkflowSourceOptions,
|
|
1627
|
+
): Promise<WorkflowSourceBundle> {
|
|
1628
|
+
return await bundleWorkflowSourceInternal(options, true);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
export function workflowBundleContract(
|
|
1632
|
+
bundle: WorkflowSourceBundle,
|
|
1633
|
+
): GraphWorkflowBundleIdentity {
|
|
1634
|
+
return {
|
|
1635
|
+
bundleSha256: bundle.sha256,
|
|
1636
|
+
bytes: bundle.bytes.byteLength,
|
|
1637
|
+
dependencyGraphSha256: bundle.dependencyGraphSha256,
|
|
1638
|
+
entrypoint: bundle.entryRelativePath,
|
|
1639
|
+
sourceSha256: bundle.sourceSha256,
|
|
1640
|
+
};
|
|
1641
|
+
}
|