@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,1749 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
constants,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
ftruncateSync,
|
|
9
|
+
fsyncSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
openSync,
|
|
13
|
+
readSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
statSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
writeSync,
|
|
20
|
+
type Stats,
|
|
21
|
+
} from "node:fs"
|
|
22
|
+
import { availableParallelism, homedir } from "node:os"
|
|
23
|
+
import { isAbsolute, join } from "node:path"
|
|
24
|
+
import { performance } from "node:perf_hooks"
|
|
25
|
+
import {
|
|
26
|
+
isHostResourcePlatformSupported,
|
|
27
|
+
tryLockHostResourceDescriptor,
|
|
28
|
+
unlockHostResourceDescriptor,
|
|
29
|
+
} from "./host-resource-posix.js"
|
|
30
|
+
|
|
31
|
+
const profileIdPattern = /^[a-z0-9][a-z0-9._/-]{0,119}$/u
|
|
32
|
+
const resourceNamePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u
|
|
33
|
+
const ownerIdPattern = /^[0-9a-f]{32}$/u
|
|
34
|
+
const ticketPattern = /^[1-9][0-9]{0,19}$/u
|
|
35
|
+
const markerNamePattern = /^lease-([0-9]{20})-([0-9a-f]{32})\.lock$/u
|
|
36
|
+
const temporaryNamePattern = /^\.host-resources-[0-9a-f]{32}\.tmp$/u
|
|
37
|
+
const stateFileName = "state.json"
|
|
38
|
+
const controlFileName = "control.lock"
|
|
39
|
+
const initializedFileName = "initialized.lock"
|
|
40
|
+
const maximumResources = 64
|
|
41
|
+
const maximumProcessLocalProfiles = 64
|
|
42
|
+
const maximumDirectoryEntries = 1_024
|
|
43
|
+
const maximumFileBytes = 64 * 1_024
|
|
44
|
+
const maximumPathBytes = 4_096
|
|
45
|
+
const maximumResourceAmount = 1_000_000
|
|
46
|
+
const maximumTicket = 0xffff_ffff_ffff_ffffn
|
|
47
|
+
const defaultPollIntervalMilliseconds = 25
|
|
48
|
+
const defaultWaitTimeoutMilliseconds = 35_000
|
|
49
|
+
const maximumAdmissionBackoffMilliseconds = 250
|
|
50
|
+
const smallPollIntervalMilliseconds = 4
|
|
51
|
+
const maximumAdmissionBackoffMultiplier = 8
|
|
52
|
+
|
|
53
|
+
/** Largest supported explicit admission wait for any host-resource lease. */
|
|
54
|
+
export const HOST_RESOURCE_MAX_WAIT_MILLISECONDS = 24 * 60 * 60_000
|
|
55
|
+
|
|
56
|
+
export const slopcameraHostResourceNames = Object.freeze([
|
|
57
|
+
"cpu",
|
|
58
|
+
"local-io",
|
|
59
|
+
"ffmpeg",
|
|
60
|
+
"video-encode",
|
|
61
|
+
"vision",
|
|
62
|
+
"whisper",
|
|
63
|
+
"network",
|
|
64
|
+
"paid-call",
|
|
65
|
+
"browser",
|
|
66
|
+
"capture-device",
|
|
67
|
+
] as const)
|
|
68
|
+
|
|
69
|
+
export type SlopcameraHostResourceName =
|
|
70
|
+
(typeof slopcameraHostResourceNames)[number]
|
|
71
|
+
|
|
72
|
+
export interface HostResourceCapacity {
|
|
73
|
+
readonly resource: string
|
|
74
|
+
readonly limit: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface HostResourceProfile {
|
|
78
|
+
readonly id: string
|
|
79
|
+
readonly capacities: readonly HostResourceCapacity[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface HostResourceClaim {
|
|
83
|
+
readonly resource: string
|
|
84
|
+
readonly amount: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface HostResourceLease {
|
|
88
|
+
readonly claims: readonly HostResourceClaim[]
|
|
89
|
+
readonly inheritedFileDescriptor: number
|
|
90
|
+
readonly profile: HostResourceProfile
|
|
91
|
+
readonly ticket: string
|
|
92
|
+
assertOwned(): Promise<void>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface HostResourceLeaseOptions {
|
|
96
|
+
readonly signal?: AbortSignal
|
|
97
|
+
readonly waitTimeoutMilliseconds?: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface HostResourceCoordinator {
|
|
101
|
+
readonly profile: HostResourceProfile
|
|
102
|
+
readonly scope: "machine" | "process"
|
|
103
|
+
withLease<T>(
|
|
104
|
+
claims: readonly HostResourceClaim[],
|
|
105
|
+
callback: (lease: HostResourceLease) => T | Promise<T>,
|
|
106
|
+
options?: HostResourceLeaseOptions,
|
|
107
|
+
): Promise<T>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface HostResourceCoordinatorOptions {
|
|
111
|
+
readonly profile?: HostResourceProfile
|
|
112
|
+
readonly stateRoot?: string
|
|
113
|
+
readonly pollIntervalMilliseconds?: number
|
|
114
|
+
readonly waitTimeoutMilliseconds?: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type HostResourceErrorCode =
|
|
118
|
+
| "INVALID_PROFILE"
|
|
119
|
+
| "INVALID_CLAIMS"
|
|
120
|
+
| "UNSUPPORTED_PLATFORM"
|
|
121
|
+
| "UNSAFE_STATE"
|
|
122
|
+
| "PROFILE_MISMATCH"
|
|
123
|
+
| "WAIT_ABORTED"
|
|
124
|
+
| "WAIT_TIMEOUT"
|
|
125
|
+
| "OWNERSHIP_LOST"
|
|
126
|
+
|
|
127
|
+
export class HostResourceError extends Error {
|
|
128
|
+
readonly code: HostResourceErrorCode
|
|
129
|
+
|
|
130
|
+
constructor(code: HostResourceErrorCode, message: string, cause?: unknown) {
|
|
131
|
+
super(`[${code}] ${message}`, cause === undefined ? undefined : { cause })
|
|
132
|
+
this.name = "HostResourceError"
|
|
133
|
+
this.code = code
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface MarkerIdentity {
|
|
138
|
+
readonly device: number
|
|
139
|
+
readonly inode: number
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface MarkerDocument {
|
|
143
|
+
readonly version: 1
|
|
144
|
+
readonly owner: string
|
|
145
|
+
readonly profileSha256: string
|
|
146
|
+
readonly ticket: string
|
|
147
|
+
readonly phase: "A" | "W"
|
|
148
|
+
readonly claims: readonly HostResourceClaim[]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface LiveMarker {
|
|
152
|
+
document: MarkerDocument
|
|
153
|
+
readonly identity: MarkerIdentity
|
|
154
|
+
readonly name: string
|
|
155
|
+
readonly path: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface OwnedMarker extends LiveMarker {
|
|
159
|
+
readonly descriptor: number
|
|
160
|
+
released: boolean
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface ResolvedStateRoot {
|
|
164
|
+
readonly path: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface ResolvedCoordinatorOptions {
|
|
168
|
+
readonly pollIntervalMilliseconds: number
|
|
169
|
+
readonly profile: HostResourceProfile
|
|
170
|
+
readonly profileSha256: string
|
|
171
|
+
readonly stateRoot: string
|
|
172
|
+
readonly waitTimeoutMilliseconds: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
176
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function hasExactKeys(
|
|
180
|
+
value: Readonly<Record<string, unknown>>,
|
|
181
|
+
expected: readonly string[],
|
|
182
|
+
): boolean {
|
|
183
|
+
const actual = Object.keys(value).sort()
|
|
184
|
+
const normalizedExpected = [...expected].sort()
|
|
185
|
+
return actual.length === normalizedExpected.length
|
|
186
|
+
&& actual.every((key, index) => key === normalizedExpected[index])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function boundedPositiveInteger(
|
|
190
|
+
value: unknown,
|
|
191
|
+
maximum: number,
|
|
192
|
+
): value is number {
|
|
193
|
+
return Number.isSafeInteger(value) && (value as number) >= 1
|
|
194
|
+
&& (value as number) <= maximum
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function deepFreeze<T>(value: T): T {
|
|
198
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
|
|
199
|
+
return value
|
|
200
|
+
}
|
|
201
|
+
for (const nested of Object.values(value)) deepFreeze(nested)
|
|
202
|
+
return Object.freeze(value)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function resourceEntries(
|
|
206
|
+
value: unknown,
|
|
207
|
+
amountKey: "amount" | "limit",
|
|
208
|
+
errorCode: "INVALID_CLAIMS" | "INVALID_PROFILE",
|
|
209
|
+
): readonly Readonly<{ resource: string; value: number }>[] {
|
|
210
|
+
const minimumEntries = errorCode === "INVALID_CLAIMS" ? 0 : 1
|
|
211
|
+
if (
|
|
212
|
+
!Array.isArray(value)
|
|
213
|
+
|| value.length < minimumEntries
|
|
214
|
+
|| value.length > maximumResources
|
|
215
|
+
) {
|
|
216
|
+
throw new HostResourceError(
|
|
217
|
+
errorCode,
|
|
218
|
+
`Host-resource ${amountKey === "limit" ? "capacities" : "claims"} must contain ${minimumEntries} through ${maximumResources} entries.`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
const seen = new Set<string>()
|
|
222
|
+
const result = value.map((candidate) => {
|
|
223
|
+
if (
|
|
224
|
+
!isRecord(candidate)
|
|
225
|
+
|| !hasExactKeys(candidate, ["resource", amountKey])
|
|
226
|
+
|| typeof candidate.resource !== "string"
|
|
227
|
+
|| !resourceNamePattern.test(candidate.resource)
|
|
228
|
+
|| !boundedPositiveInteger(candidate[amountKey], maximumResourceAmount)
|
|
229
|
+
|| seen.has(candidate.resource)
|
|
230
|
+
) {
|
|
231
|
+
throw new HostResourceError(
|
|
232
|
+
errorCode,
|
|
233
|
+
`Host-resource ${amountKey === "limit" ? "capacities" : "claims"} are malformed or duplicated.`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
seen.add(candidate.resource)
|
|
237
|
+
return { resource: candidate.resource, value: candidate[amountKey] as number }
|
|
238
|
+
})
|
|
239
|
+
return result.sort((left, right) => (
|
|
240
|
+
left.resource < right.resource ? -1 : left.resource > right.resource ? 1 : 0
|
|
241
|
+
))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function normalizeHostResourceProfile(value: unknown): HostResourceProfile {
|
|
245
|
+
if (
|
|
246
|
+
!isRecord(value)
|
|
247
|
+
|| !hasExactKeys(value, ["id", "capacities"])
|
|
248
|
+
|| typeof value.id !== "string"
|
|
249
|
+
|| !profileIdPattern.test(value.id)
|
|
250
|
+
) {
|
|
251
|
+
throw new HostResourceError(
|
|
252
|
+
"INVALID_PROFILE",
|
|
253
|
+
"Host-resource profile identity is invalid.",
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
const capacities = resourceEntries(
|
|
257
|
+
value.capacities,
|
|
258
|
+
"limit",
|
|
259
|
+
"INVALID_PROFILE",
|
|
260
|
+
).map(({ resource, value: limit }) => ({ resource, limit }))
|
|
261
|
+
return deepFreeze({ id: value.id, capacities })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function normalizeHostResourceClaims(
|
|
265
|
+
value: unknown,
|
|
266
|
+
profile: HostResourceProfile,
|
|
267
|
+
): readonly HostResourceClaim[] {
|
|
268
|
+
const normalizedProfile = normalizeHostResourceProfile(profile)
|
|
269
|
+
const limits = new Map(
|
|
270
|
+
normalizedProfile.capacities.map(({ resource, limit }) => [resource, limit]),
|
|
271
|
+
)
|
|
272
|
+
const claims = resourceEntries(value, "amount", "INVALID_CLAIMS")
|
|
273
|
+
.map(({ resource, value: amount }) => {
|
|
274
|
+
const limit = limits.get(resource)
|
|
275
|
+
if (limit === undefined || amount > limit) {
|
|
276
|
+
throw new HostResourceError(
|
|
277
|
+
"INVALID_CLAIMS",
|
|
278
|
+
`Host-resource claim ${resource} is unavailable or exceeds its profile limit.`,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
return { resource, amount }
|
|
282
|
+
})
|
|
283
|
+
return deepFreeze(claims)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function defaultSlopcameraHostResourceProfile(
|
|
287
|
+
hostParallelism = availableParallelism(),
|
|
288
|
+
): HostResourceProfile {
|
|
289
|
+
if (!boundedPositiveInteger(hostParallelism, maximumResourceAmount)) {
|
|
290
|
+
throw new HostResourceError(
|
|
291
|
+
"INVALID_PROFILE",
|
|
292
|
+
"Host parallelism must be a positive safe integer.",
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
const reserve = hostParallelism >= 6 ? 2 : hostParallelism >= 2 ? 1 : 0
|
|
296
|
+
return normalizeHostResourceProfile({
|
|
297
|
+
id: "slopcamera.host-resources/v1",
|
|
298
|
+
capacities: [
|
|
299
|
+
{ resource: "cpu", limit: Math.max(1, hostParallelism - reserve) },
|
|
300
|
+
{ resource: "local-io", limit: 2 },
|
|
301
|
+
{ resource: "ffmpeg", limit: 2 },
|
|
302
|
+
{ resource: "video-encode", limit: 1 },
|
|
303
|
+
{ resource: "vision", limit: 1 },
|
|
304
|
+
{ resource: "whisper", limit: 1 },
|
|
305
|
+
{ resource: "network", limit: 4 },
|
|
306
|
+
{ resource: "paid-call", limit: 1 },
|
|
307
|
+
{ resource: "browser", limit: 1 },
|
|
308
|
+
{ resource: "capture-device", limit: 1 },
|
|
309
|
+
],
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function defaultSlopcameraHostResourceStateRoot(
|
|
314
|
+
platform: NodeJS.Platform = process.platform,
|
|
315
|
+
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
|
316
|
+
userHome = homedir(),
|
|
317
|
+
): string {
|
|
318
|
+
if (!isHostResourcePlatformSupported(platform)) {
|
|
319
|
+
throw new HostResourceError(
|
|
320
|
+
"UNSUPPORTED_PLATFORM",
|
|
321
|
+
"Slopcamera host-resource coordination requires Darwin or Linux.",
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
if (platform === "darwin") {
|
|
325
|
+
return join(
|
|
326
|
+
userHome,
|
|
327
|
+
"Library",
|
|
328
|
+
"Application Support",
|
|
329
|
+
"Slopcamera",
|
|
330
|
+
"cli",
|
|
331
|
+
"host-resources-v1",
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
const configuredStateHome = environment.XDG_STATE_HOME
|
|
335
|
+
const stateHome = configuredStateHome !== undefined
|
|
336
|
+
&& isAbsolute(configuredStateHome)
|
|
337
|
+
? configuredStateHome
|
|
338
|
+
: join(userHome, ".local", "state")
|
|
339
|
+
return join(stateHome, "slopcamera", "host-resources-v1")
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
function canonicalProfile(profile: HostResourceProfile): string {
|
|
344
|
+
return JSON.stringify(profile)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function sha256(value: string): string {
|
|
348
|
+
return createHash("sha256").update(value).digest("hex")
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function stateFailure(message: string, cause?: unknown): HostResourceError {
|
|
352
|
+
return new HostResourceError("UNSAFE_STATE", message, cause)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function isErrorCode(value: unknown, code: string): boolean {
|
|
356
|
+
return isRecord(value) && value.code === code
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function ownerUid(): number {
|
|
360
|
+
const uid = process.geteuid?.() ?? process.getuid?.()
|
|
361
|
+
if (uid === undefined) throw stateFailure("Host-resource owner identity is unavailable.")
|
|
362
|
+
return uid
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function identity(value: Pick<Stats, "dev" | "ino">): MarkerIdentity {
|
|
366
|
+
return { device: value.dev, inode: value.ino }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function sameIdentity(
|
|
370
|
+
left: MarkerIdentity,
|
|
371
|
+
right: MarkerIdentity | Pick<Stats, "dev" | "ino">,
|
|
372
|
+
): boolean {
|
|
373
|
+
const device = "device" in right ? right.device : right.dev
|
|
374
|
+
const inode = "inode" in right ? right.inode : right.ino
|
|
375
|
+
return left.device === device && left.inode === inode
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function privateDirectory(metadata: Stats): boolean {
|
|
379
|
+
return metadata.isDirectory()
|
|
380
|
+
&& !metadata.isSymbolicLink()
|
|
381
|
+
&& metadata.uid === ownerUid()
|
|
382
|
+
&& (metadata.mode & 0o777) === 0o700
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function privateRegularFile(metadata: Stats): boolean {
|
|
386
|
+
return metadata.isFile()
|
|
387
|
+
&& !metadata.isSymbolicLink()
|
|
388
|
+
&& metadata.nlink === 1
|
|
389
|
+
&& metadata.uid === ownerUid()
|
|
390
|
+
&& (metadata.mode & 0o777) === 0o600
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function metadataOrNull(path: string): Stats | null {
|
|
394
|
+
try {
|
|
395
|
+
return lstatSync(path)
|
|
396
|
+
} catch (cause) {
|
|
397
|
+
if (isErrorCode(cause, "ENOENT")) return null
|
|
398
|
+
throw cause
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function ensureStateRoot(path: string): ResolvedStateRoot {
|
|
403
|
+
if (
|
|
404
|
+
!isAbsolute(path)
|
|
405
|
+
|| path.includes("\0")
|
|
406
|
+
|| Buffer.byteLength(path, "utf8") > maximumPathBytes
|
|
407
|
+
) {
|
|
408
|
+
throw stateFailure("Host-resource state root must be a bounded absolute path.")
|
|
409
|
+
}
|
|
410
|
+
let created = false
|
|
411
|
+
let metadata = metadataOrNull(path)
|
|
412
|
+
if (metadata === null) {
|
|
413
|
+
try {
|
|
414
|
+
mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
415
|
+
created = true
|
|
416
|
+
} catch (cause) {
|
|
417
|
+
if (!isErrorCode(cause, "EEXIST")) {
|
|
418
|
+
throw stateFailure("Host-resource state root could not be created.", cause)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
metadata = lstatSync(path)
|
|
422
|
+
if (created) chmodSync(path, 0o700)
|
|
423
|
+
}
|
|
424
|
+
if (!privateDirectory(metadata)) {
|
|
425
|
+
throw stateFailure("Host-resource state root is not one private owned directory.")
|
|
426
|
+
}
|
|
427
|
+
const canonical = realpathSync(path)
|
|
428
|
+
if (!privateDirectory(statSync(canonical))) {
|
|
429
|
+
throw stateFailure("Host-resource state root canonicalization is unsafe.")
|
|
430
|
+
}
|
|
431
|
+
return { path: canonical }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function openPrivateFile(path: string, exclusive = false): number {
|
|
435
|
+
const existing = metadataOrNull(path)
|
|
436
|
+
if (existing !== null && !privateRegularFile(existing)) {
|
|
437
|
+
throw stateFailure("Host-resource state contains an unsafe file.")
|
|
438
|
+
}
|
|
439
|
+
const flags = constants.O_RDWR
|
|
440
|
+
| constants.O_CREAT
|
|
441
|
+
| constants.O_NOFOLLOW
|
|
442
|
+
| (exclusive ? constants.O_EXCL : 0)
|
|
443
|
+
const descriptor = openSync(path, flags, 0o600)
|
|
444
|
+
try {
|
|
445
|
+
fchmodSync(descriptor, 0o600)
|
|
446
|
+
const opened = fstatSync(descriptor)
|
|
447
|
+
const published = lstatSync(path)
|
|
448
|
+
if (
|
|
449
|
+
!privateRegularFile(opened)
|
|
450
|
+
|| !privateRegularFile(published)
|
|
451
|
+
|| opened.dev !== published.dev
|
|
452
|
+
|| opened.ino !== published.ino
|
|
453
|
+
) {
|
|
454
|
+
throw stateFailure("Host-resource file identity changed while opening.")
|
|
455
|
+
}
|
|
456
|
+
return descriptor
|
|
457
|
+
} catch (cause) {
|
|
458
|
+
closeSync(descriptor)
|
|
459
|
+
throw cause
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function readDescriptor(descriptor: number): string {
|
|
464
|
+
const metadata = fstatSync(descriptor)
|
|
465
|
+
if (!privateRegularFile(metadata) || metadata.size > maximumFileBytes) {
|
|
466
|
+
throw stateFailure("Host-resource state file is malformed.")
|
|
467
|
+
}
|
|
468
|
+
const bytes = Buffer.alloc(metadata.size)
|
|
469
|
+
let offset = 0
|
|
470
|
+
while (offset < bytes.length) {
|
|
471
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset)
|
|
472
|
+
if (count === 0) break
|
|
473
|
+
offset += count
|
|
474
|
+
}
|
|
475
|
+
if (offset !== bytes.length) throw stateFailure("Host-resource state read was incomplete.")
|
|
476
|
+
return bytes.toString("utf8")
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function writeDescriptor(descriptor: number, value: string): void {
|
|
480
|
+
const bytes = Buffer.from(value, "utf8")
|
|
481
|
+
if (bytes.length > maximumFileBytes) {
|
|
482
|
+
throw stateFailure("Host-resource state write exceeds its bound.")
|
|
483
|
+
}
|
|
484
|
+
ftruncateSync(descriptor, 0)
|
|
485
|
+
let offset = 0
|
|
486
|
+
while (offset < bytes.length) {
|
|
487
|
+
const written = writeSync(
|
|
488
|
+
descriptor,
|
|
489
|
+
bytes,
|
|
490
|
+
offset,
|
|
491
|
+
bytes.length - offset,
|
|
492
|
+
offset,
|
|
493
|
+
)
|
|
494
|
+
if (written < 1) throw stateFailure("Host-resource state write was incomplete.")
|
|
495
|
+
offset += written
|
|
496
|
+
}
|
|
497
|
+
fsyncSync(descriptor)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
interface StateDocument {
|
|
501
|
+
readonly version: 1
|
|
502
|
+
readonly profile: HostResourceProfile
|
|
503
|
+
readonly nextTicket: string
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function stateJson(state: StateDocument): string {
|
|
507
|
+
return JSON.stringify({
|
|
508
|
+
version: state.version,
|
|
509
|
+
profile: state.profile,
|
|
510
|
+
nextTicket: state.nextTicket,
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function markerJson(marker: MarkerDocument): string {
|
|
515
|
+
return JSON.stringify({
|
|
516
|
+
version: marker.version,
|
|
517
|
+
owner: marker.owner,
|
|
518
|
+
profileSha256: marker.profileSha256,
|
|
519
|
+
ticket: marker.ticket,
|
|
520
|
+
phase: marker.phase,
|
|
521
|
+
claims: marker.claims,
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function syncDirectory(directory: string): void {
|
|
526
|
+
const descriptor = openSync(
|
|
527
|
+
directory,
|
|
528
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
529
|
+
)
|
|
530
|
+
try {
|
|
531
|
+
if (!privateDirectory(fstatSync(descriptor))) {
|
|
532
|
+
throw stateFailure("Host-resource state root changed identity.")
|
|
533
|
+
}
|
|
534
|
+
fsyncSync(descriptor)
|
|
535
|
+
} finally {
|
|
536
|
+
closeSync(descriptor)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function atomicWriteState(directory: string, state: StateDocument): void {
|
|
541
|
+
const temporaryName = `.host-resources-${randomBytes(16).toString("hex")}.tmp`
|
|
542
|
+
const temporaryPath = join(directory, temporaryName)
|
|
543
|
+
const statePath = join(directory, stateFileName)
|
|
544
|
+
let descriptor: number | undefined
|
|
545
|
+
try {
|
|
546
|
+
descriptor = openPrivateFile(temporaryPath, true)
|
|
547
|
+
writeDescriptor(descriptor, stateJson(state))
|
|
548
|
+
closeSync(descriptor)
|
|
549
|
+
descriptor = undefined
|
|
550
|
+
const existing = metadataOrNull(statePath)
|
|
551
|
+
if (existing !== null && !privateRegularFile(existing)) {
|
|
552
|
+
throw stateFailure("Host-resource profile state is unsafe.")
|
|
553
|
+
}
|
|
554
|
+
renameSync(temporaryPath, statePath)
|
|
555
|
+
const published = lstatSync(statePath)
|
|
556
|
+
if (!privateRegularFile(published)) {
|
|
557
|
+
throw stateFailure("Host-resource profile state was not published safely.")
|
|
558
|
+
}
|
|
559
|
+
syncDirectory(directory)
|
|
560
|
+
} finally {
|
|
561
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
562
|
+
try {
|
|
563
|
+
unlinkSync(temporaryPath)
|
|
564
|
+
} catch (cause) {
|
|
565
|
+
if (!isErrorCode(cause, "ENOENT")) {
|
|
566
|
+
// The unique private temporary file is inert. A later control owner
|
|
567
|
+
// removes it before admitting more work.
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function parseState(value: string): StateDocument {
|
|
574
|
+
let parsed: unknown
|
|
575
|
+
try {
|
|
576
|
+
parsed = JSON.parse(value)
|
|
577
|
+
} catch (cause) {
|
|
578
|
+
throw stateFailure("Host-resource profile state is not valid JSON.", cause)
|
|
579
|
+
}
|
|
580
|
+
if (
|
|
581
|
+
!isRecord(parsed)
|
|
582
|
+
|| !hasExactKeys(parsed, ["version", "profile", "nextTicket"])
|
|
583
|
+
|| parsed.version !== 1
|
|
584
|
+
|| typeof parsed.nextTicket !== "string"
|
|
585
|
+
|| !ticketPattern.test(parsed.nextTicket)
|
|
586
|
+
) {
|
|
587
|
+
throw stateFailure("Host-resource profile state is malformed.")
|
|
588
|
+
}
|
|
589
|
+
let profile: HostResourceProfile
|
|
590
|
+
try {
|
|
591
|
+
profile = normalizeHostResourceProfile(parsed.profile)
|
|
592
|
+
} catch (cause) {
|
|
593
|
+
throw stateFailure("Host-resource profile state is malformed.", cause)
|
|
594
|
+
}
|
|
595
|
+
const nextTicket = BigInt(parsed.nextTicket)
|
|
596
|
+
if (nextTicket > maximumTicket + 1n) {
|
|
597
|
+
throw stateFailure("Host-resource ticket state exceeds its bound.")
|
|
598
|
+
}
|
|
599
|
+
const state: StateDocument = {
|
|
600
|
+
version: 1,
|
|
601
|
+
profile,
|
|
602
|
+
nextTicket: parsed.nextTicket,
|
|
603
|
+
}
|
|
604
|
+
if (stateJson(state) !== value) {
|
|
605
|
+
throw stateFailure("Host-resource profile state is not normalized.")
|
|
606
|
+
}
|
|
607
|
+
return state
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function readState(
|
|
611
|
+
directory: string,
|
|
612
|
+
profile: HostResourceProfile,
|
|
613
|
+
): StateDocument {
|
|
614
|
+
const path = join(directory, stateFileName)
|
|
615
|
+
const initializedPath = join(directory, initializedFileName)
|
|
616
|
+
if (metadataOrNull(path) === null) {
|
|
617
|
+
if (
|
|
618
|
+
metadataOrNull(initializedPath) !== null
|
|
619
|
+
|| readdirSync(directory).some((name) => markerNamePattern.test(name))
|
|
620
|
+
) {
|
|
621
|
+
throw stateFailure("Host-resource profile state is missing.")
|
|
622
|
+
}
|
|
623
|
+
const initial: StateDocument = {
|
|
624
|
+
version: 1,
|
|
625
|
+
profile,
|
|
626
|
+
nextTicket: "1",
|
|
627
|
+
}
|
|
628
|
+
atomicWriteState(directory, initial)
|
|
629
|
+
const initialized = openPrivateFile(initializedPath, true)
|
|
630
|
+
closeSync(initialized)
|
|
631
|
+
syncDirectory(directory)
|
|
632
|
+
return initial
|
|
633
|
+
}
|
|
634
|
+
const descriptor = openPrivateFile(path)
|
|
635
|
+
try {
|
|
636
|
+
const state = parseState(readDescriptor(descriptor))
|
|
637
|
+
const initializedWasMissing = metadataOrNull(initializedPath) === null
|
|
638
|
+
const initialized = initializedWasMissing
|
|
639
|
+
? openPrivateFile(initializedPath, true)
|
|
640
|
+
: openPrivateFile(initializedPath)
|
|
641
|
+
try {
|
|
642
|
+
if (fstatSync(initialized).size !== 0) {
|
|
643
|
+
throw stateFailure("Host-resource initialization state is malformed.")
|
|
644
|
+
}
|
|
645
|
+
} finally {
|
|
646
|
+
closeSync(initialized)
|
|
647
|
+
}
|
|
648
|
+
if (initializedWasMissing) syncDirectory(directory)
|
|
649
|
+
return state
|
|
650
|
+
} finally {
|
|
651
|
+
closeSync(descriptor)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function assertMatchingProfile(
|
|
656
|
+
state: StateDocument,
|
|
657
|
+
profile: HostResourceProfile,
|
|
658
|
+
): void {
|
|
659
|
+
if (canonicalProfile(state.profile) === canonicalProfile(profile)) return
|
|
660
|
+
throw new HostResourceError(
|
|
661
|
+
"PROFILE_MISMATCH",
|
|
662
|
+
"The machine-global Slopcamera host-resource profile does not match this process.",
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function reserveTicket(
|
|
667
|
+
directory: string,
|
|
668
|
+
state: StateDocument,
|
|
669
|
+
): string {
|
|
670
|
+
const ticket = BigInt(state.nextTicket)
|
|
671
|
+
if (ticket > maximumTicket) {
|
|
672
|
+
throw stateFailure("Host-resource tickets are exhausted.")
|
|
673
|
+
}
|
|
674
|
+
atomicWriteState(directory, {
|
|
675
|
+
...state,
|
|
676
|
+
nextTicket: String(ticket + 1n),
|
|
677
|
+
})
|
|
678
|
+
return String(ticket)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function duration(
|
|
682
|
+
value: number | undefined,
|
|
683
|
+
fallback: number,
|
|
684
|
+
maximum: number,
|
|
685
|
+
code: "INVALID_CLAIMS" | "INVALID_PROFILE",
|
|
686
|
+
label: string,
|
|
687
|
+
): number {
|
|
688
|
+
const resolved = value ?? fallback
|
|
689
|
+
if (!boundedPositiveInteger(resolved, maximum)) {
|
|
690
|
+
throw new HostResourceError(code, `${label} is outside its supported bound.`)
|
|
691
|
+
}
|
|
692
|
+
return resolved
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function throwIfWaitEnded(signal: AbortSignal | undefined, deadline: number): void {
|
|
696
|
+
if (signal?.aborted === true) {
|
|
697
|
+
throw new HostResourceError(
|
|
698
|
+
"WAIT_ABORTED",
|
|
699
|
+
"Host-resource admission was cancelled before execution.",
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
if (performance.now() >= deadline) {
|
|
703
|
+
throw new HostResourceError(
|
|
704
|
+
"WAIT_TIMEOUT",
|
|
705
|
+
"Host-resource admission exceeded its bounded wait.",
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function waitForRetry(
|
|
711
|
+
pollIntervalMilliseconds: number,
|
|
712
|
+
signal: AbortSignal | undefined,
|
|
713
|
+
deadline: number,
|
|
714
|
+
): Promise<void> {
|
|
715
|
+
throwIfWaitEnded(signal, deadline)
|
|
716
|
+
const remaining = Math.max(1, Math.ceil(deadline - performance.now()))
|
|
717
|
+
await new Promise<void>((resolve, reject) => {
|
|
718
|
+
let settled = false
|
|
719
|
+
const finish = (): void => {
|
|
720
|
+
if (settled) return
|
|
721
|
+
settled = true
|
|
722
|
+
signal?.removeEventListener("abort", cancel)
|
|
723
|
+
resolve()
|
|
724
|
+
}
|
|
725
|
+
const cancel = (): void => {
|
|
726
|
+
if (settled) return
|
|
727
|
+
settled = true
|
|
728
|
+
clearTimeout(timer)
|
|
729
|
+
signal?.removeEventListener("abort", cancel)
|
|
730
|
+
reject(new HostResourceError(
|
|
731
|
+
"WAIT_ABORTED",
|
|
732
|
+
"Host-resource admission was cancelled before execution.",
|
|
733
|
+
))
|
|
734
|
+
}
|
|
735
|
+
const timer = setTimeout(
|
|
736
|
+
finish,
|
|
737
|
+
Math.min(pollIntervalMilliseconds, remaining),
|
|
738
|
+
)
|
|
739
|
+
signal?.addEventListener("abort", cancel, { once: true })
|
|
740
|
+
if (signal?.aborted === true) cancel()
|
|
741
|
+
})
|
|
742
|
+
throwIfWaitEnded(signal, deadline)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function mixRetrySeed(ticket: string, retry: number): bigint {
|
|
746
|
+
const mask = 0xffff_ffff_ffff_ffffn
|
|
747
|
+
let value = (
|
|
748
|
+
BigInt(ticket)
|
|
749
|
+
+ BigInt(retry + 1) * 0x9e37_79b9_7f4a_7c15n
|
|
750
|
+
) & mask
|
|
751
|
+
value = ((value ^ (value >> 30n)) * 0xbf58_476d_1ce4_e5b9n) & mask
|
|
752
|
+
value = ((value ^ (value >> 27n)) * 0x94d0_49bb_1331_11ebn) & mask
|
|
753
|
+
return (value ^ (value >> 31n)) & mask
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function admissionRetryDelayMilliseconds(
|
|
757
|
+
pollIntervalMilliseconds: number,
|
|
758
|
+
ticket: string,
|
|
759
|
+
unchangedRetryCount: number,
|
|
760
|
+
): number {
|
|
761
|
+
// Explicit tiny intervals are commonly used by deterministic local callers.
|
|
762
|
+
// Keep them as the upper bound instead of silently stretching their loop.
|
|
763
|
+
const adaptiveLimit = pollIntervalMilliseconds <= smallPollIntervalMilliseconds
|
|
764
|
+
? pollIntervalMilliseconds
|
|
765
|
+
: Math.max(
|
|
766
|
+
pollIntervalMilliseconds,
|
|
767
|
+
Math.min(
|
|
768
|
+
maximumAdmissionBackoffMilliseconds,
|
|
769
|
+
pollIntervalMilliseconds * maximumAdmissionBackoffMultiplier,
|
|
770
|
+
),
|
|
771
|
+
)
|
|
772
|
+
const exponent = Math.min(
|
|
773
|
+
unchangedRetryCount,
|
|
774
|
+
Math.ceil(Math.log2(maximumAdmissionBackoffMultiplier)),
|
|
775
|
+
)
|
|
776
|
+
const ceiling = Math.min(
|
|
777
|
+
adaptiveLimit,
|
|
778
|
+
pollIntervalMilliseconds * (2 ** exponent),
|
|
779
|
+
)
|
|
780
|
+
const floor = Math.max(1, Math.ceil(ceiling / 2))
|
|
781
|
+
const width = ceiling - floor + 1
|
|
782
|
+
const offset = Number(
|
|
783
|
+
mixRetrySeed(ticket, unchangedRetryCount) % BigInt(width),
|
|
784
|
+
)
|
|
785
|
+
return floor + offset
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function withControlLock<T>(
|
|
789
|
+
directory: string,
|
|
790
|
+
pollIntervalMilliseconds: number,
|
|
791
|
+
signal: AbortSignal | undefined,
|
|
792
|
+
deadline: number,
|
|
793
|
+
callback: () => T,
|
|
794
|
+
): Promise<T> {
|
|
795
|
+
const descriptor = openPrivateFile(join(directory, controlFileName))
|
|
796
|
+
let locked = false
|
|
797
|
+
try {
|
|
798
|
+
while (!(locked = tryLockHostResourceDescriptor(descriptor))) {
|
|
799
|
+
await waitForRetry(pollIntervalMilliseconds, signal, deadline)
|
|
800
|
+
}
|
|
801
|
+
throwIfWaitEnded(signal, deadline)
|
|
802
|
+
return callback()
|
|
803
|
+
} catch (cause) {
|
|
804
|
+
if (cause instanceof HostResourceError) throw cause
|
|
805
|
+
throw stateFailure("Host-resource control locking failed.", cause)
|
|
806
|
+
} finally {
|
|
807
|
+
if (locked) {
|
|
808
|
+
try {
|
|
809
|
+
unlockHostResourceDescriptor(descriptor)
|
|
810
|
+
} catch {
|
|
811
|
+
// Closing the descriptor still relinquishes the kernel lock.
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
closeSync(descriptor)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function normalizedTicketName(ticket: string): string {
|
|
819
|
+
return ticket.padStart(20, "0")
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function parseMarker(
|
|
823
|
+
name: string,
|
|
824
|
+
source: string,
|
|
825
|
+
profile: HostResourceProfile,
|
|
826
|
+
profileSha256: string,
|
|
827
|
+
): MarkerDocument {
|
|
828
|
+
const nameMatch = markerNamePattern.exec(name)
|
|
829
|
+
let parsed: unknown
|
|
830
|
+
try {
|
|
831
|
+
parsed = JSON.parse(source)
|
|
832
|
+
} catch (cause) {
|
|
833
|
+
throw stateFailure("A live host-resource marker is not valid JSON.", cause)
|
|
834
|
+
}
|
|
835
|
+
if (
|
|
836
|
+
nameMatch === null
|
|
837
|
+
|| !isRecord(parsed)
|
|
838
|
+
|| !hasExactKeys(parsed, [
|
|
839
|
+
"version",
|
|
840
|
+
"owner",
|
|
841
|
+
"profileSha256",
|
|
842
|
+
"ticket",
|
|
843
|
+
"phase",
|
|
844
|
+
"claims",
|
|
845
|
+
])
|
|
846
|
+
|| parsed.version !== 1
|
|
847
|
+
|| typeof parsed.owner !== "string"
|
|
848
|
+
|| !ownerIdPattern.test(parsed.owner)
|
|
849
|
+
|| typeof parsed.profileSha256 !== "string"
|
|
850
|
+
|| !/^[0-9a-f]{64}$/u.test(parsed.profileSha256)
|
|
851
|
+
|| typeof parsed.ticket !== "string"
|
|
852
|
+
|| !ticketPattern.test(parsed.ticket)
|
|
853
|
+
|| (parsed.phase !== "A" && parsed.phase !== "W")
|
|
854
|
+
|| normalizedTicketName(parsed.ticket) !== nameMatch[1]
|
|
855
|
+
|| parsed.owner !== nameMatch[2]
|
|
856
|
+
) {
|
|
857
|
+
throw stateFailure("A live host-resource marker is malformed.")
|
|
858
|
+
}
|
|
859
|
+
if (parsed.profileSha256 !== profileSha256) {
|
|
860
|
+
throw new HostResourceError(
|
|
861
|
+
"PROFILE_MISMATCH",
|
|
862
|
+
"A live lease belongs to a different host-resource profile.",
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
let claims: readonly HostResourceClaim[]
|
|
866
|
+
try {
|
|
867
|
+
claims = normalizeHostResourceClaims(parsed.claims, profile)
|
|
868
|
+
} catch (cause) {
|
|
869
|
+
throw stateFailure("A live host-resource marker has invalid claims.", cause)
|
|
870
|
+
}
|
|
871
|
+
const marker: MarkerDocument = {
|
|
872
|
+
version: 1,
|
|
873
|
+
owner: parsed.owner,
|
|
874
|
+
profileSha256: parsed.profileSha256,
|
|
875
|
+
ticket: parsed.ticket,
|
|
876
|
+
phase: parsed.phase,
|
|
877
|
+
claims,
|
|
878
|
+
}
|
|
879
|
+
if (markerJson(marker) !== source) {
|
|
880
|
+
throw stateFailure("A live host-resource marker is not normalized.")
|
|
881
|
+
}
|
|
882
|
+
return marker
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function unlinkExactMarker(path: string, markerIdentity: MarkerIdentity): void {
|
|
886
|
+
const published = lstatSync(path)
|
|
887
|
+
if (!privateRegularFile(published) || !sameIdentity(markerIdentity, published)) {
|
|
888
|
+
throw new HostResourceError(
|
|
889
|
+
"OWNERSHIP_LOST",
|
|
890
|
+
"Host-resource marker identity changed before cleanup.",
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
unlinkSync(path)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function removeTemporaryFile(path: string): void {
|
|
897
|
+
const descriptor = openPrivateFile(path)
|
|
898
|
+
try {
|
|
899
|
+
const opened = fstatSync(descriptor)
|
|
900
|
+
const published = lstatSync(path)
|
|
901
|
+
if (opened.dev !== published.dev || opened.ino !== published.ino) {
|
|
902
|
+
throw stateFailure("Host-resource temporary state changed identity.")
|
|
903
|
+
}
|
|
904
|
+
unlinkSync(path)
|
|
905
|
+
} finally {
|
|
906
|
+
closeSync(descriptor)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function scanLiveMarkers(
|
|
911
|
+
directory: string,
|
|
912
|
+
profile: HostResourceProfile,
|
|
913
|
+
profileSha256: string,
|
|
914
|
+
): readonly LiveMarker[] {
|
|
915
|
+
const entries = readdirSync(directory, { withFileTypes: true })
|
|
916
|
+
if (entries.length > maximumDirectoryEntries) {
|
|
917
|
+
throw stateFailure("Host-resource state contains too many entries.")
|
|
918
|
+
}
|
|
919
|
+
const live: LiveMarker[] = []
|
|
920
|
+
const tickets = new Set<string>()
|
|
921
|
+
const owners = new Set<string>()
|
|
922
|
+
for (const entry of entries) {
|
|
923
|
+
if (
|
|
924
|
+
entry.name === controlFileName
|
|
925
|
+
|| entry.name === stateFileName
|
|
926
|
+
|| entry.name === initializedFileName
|
|
927
|
+
) continue
|
|
928
|
+
const path = join(directory, entry.name)
|
|
929
|
+
if (temporaryNamePattern.test(entry.name)) {
|
|
930
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
931
|
+
throw stateFailure("Host-resource temporary state is unsafe.")
|
|
932
|
+
}
|
|
933
|
+
removeTemporaryFile(path)
|
|
934
|
+
continue
|
|
935
|
+
}
|
|
936
|
+
if (!markerNamePattern.test(entry.name)) {
|
|
937
|
+
throw stateFailure("Host-resource state contains an unknown entry.")
|
|
938
|
+
}
|
|
939
|
+
const descriptor = openPrivateFile(path)
|
|
940
|
+
let locked = false
|
|
941
|
+
try {
|
|
942
|
+
const opened = fstatSync(descriptor)
|
|
943
|
+
const markerIdentity = identity(opened)
|
|
944
|
+
locked = tryLockHostResourceDescriptor(descriptor)
|
|
945
|
+
if (locked) {
|
|
946
|
+
// An unlocked marker has no live descriptor owner. The kernel lock is
|
|
947
|
+
// the sole liveness fact; PIDs and timestamps are deliberately absent.
|
|
948
|
+
unlinkExactMarker(path, markerIdentity)
|
|
949
|
+
continue
|
|
950
|
+
}
|
|
951
|
+
const document = parseMarker(
|
|
952
|
+
entry.name,
|
|
953
|
+
readDescriptor(descriptor),
|
|
954
|
+
profile,
|
|
955
|
+
profileSha256,
|
|
956
|
+
)
|
|
957
|
+
if (tickets.has(document.ticket) || owners.has(document.owner)) {
|
|
958
|
+
throw stateFailure("Live host-resource marker identity is duplicated.")
|
|
959
|
+
}
|
|
960
|
+
tickets.add(document.ticket)
|
|
961
|
+
owners.add(document.owner)
|
|
962
|
+
live.push({
|
|
963
|
+
document,
|
|
964
|
+
identity: markerIdentity,
|
|
965
|
+
name: entry.name,
|
|
966
|
+
path,
|
|
967
|
+
})
|
|
968
|
+
} finally {
|
|
969
|
+
if (locked) {
|
|
970
|
+
try {
|
|
971
|
+
unlockHostResourceDescriptor(descriptor)
|
|
972
|
+
} catch {
|
|
973
|
+
// Close remains the authoritative release.
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
closeSync(descriptor)
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return live.sort((left, right) => {
|
|
980
|
+
const leftTicket = BigInt(left.document.ticket)
|
|
981
|
+
const rightTicket = BigInt(right.document.ticket)
|
|
982
|
+
return leftTicket < rightTicket ? -1 : leftTicket > rightTicket ? 1 : 0
|
|
983
|
+
})
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function assertOwnedMarkerIdentity(marker: OwnedMarker): void {
|
|
987
|
+
if (marker.released) {
|
|
988
|
+
throw new HostResourceError(
|
|
989
|
+
"OWNERSHIP_LOST",
|
|
990
|
+
"Host-resource lease is no longer active.",
|
|
991
|
+
)
|
|
992
|
+
}
|
|
993
|
+
let held: Stats
|
|
994
|
+
let published: Stats
|
|
995
|
+
try {
|
|
996
|
+
held = fstatSync(marker.descriptor)
|
|
997
|
+
published = lstatSync(marker.path)
|
|
998
|
+
} catch (cause) {
|
|
999
|
+
throw new HostResourceError(
|
|
1000
|
+
"OWNERSHIP_LOST",
|
|
1001
|
+
"Host-resource marker is no longer reachable.",
|
|
1002
|
+
cause,
|
|
1003
|
+
)
|
|
1004
|
+
}
|
|
1005
|
+
if (
|
|
1006
|
+
!privateRegularFile(held)
|
|
1007
|
+
|| !privateRegularFile(published)
|
|
1008
|
+
|| !sameIdentity(marker.identity, held)
|
|
1009
|
+
|| !sameIdentity(marker.identity, published)
|
|
1010
|
+
) {
|
|
1011
|
+
throw new HostResourceError(
|
|
1012
|
+
"OWNERSHIP_LOST",
|
|
1013
|
+
"Host-resource descriptor no longer owns its published inode.",
|
|
1014
|
+
)
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function publishWaitingMarker(
|
|
1019
|
+
directory: string,
|
|
1020
|
+
state: StateDocument,
|
|
1021
|
+
claims: readonly HostResourceClaim[],
|
|
1022
|
+
profileSha256: string,
|
|
1023
|
+
): OwnedMarker {
|
|
1024
|
+
const ticket = reserveTicket(directory, state)
|
|
1025
|
+
const owner = randomBytes(16).toString("hex")
|
|
1026
|
+
const name = `lease-${normalizedTicketName(ticket)}-${owner}.lock`
|
|
1027
|
+
const path = join(directory, name)
|
|
1028
|
+
let descriptor: number | undefined
|
|
1029
|
+
try {
|
|
1030
|
+
descriptor = openPrivateFile(path, true)
|
|
1031
|
+
if (!tryLockHostResourceDescriptor(descriptor)) {
|
|
1032
|
+
throw stateFailure("A new host-resource marker could not be locked.")
|
|
1033
|
+
}
|
|
1034
|
+
const document: MarkerDocument = {
|
|
1035
|
+
version: 1,
|
|
1036
|
+
owner,
|
|
1037
|
+
profileSha256,
|
|
1038
|
+
ticket,
|
|
1039
|
+
phase: "W",
|
|
1040
|
+
claims,
|
|
1041
|
+
}
|
|
1042
|
+
writeDescriptor(descriptor, markerJson(document))
|
|
1043
|
+
return {
|
|
1044
|
+
descriptor,
|
|
1045
|
+
document,
|
|
1046
|
+
identity: identity(fstatSync(descriptor)),
|
|
1047
|
+
name,
|
|
1048
|
+
path,
|
|
1049
|
+
released: false,
|
|
1050
|
+
}
|
|
1051
|
+
} catch (cause) {
|
|
1052
|
+
if (descriptor !== undefined) {
|
|
1053
|
+
try {
|
|
1054
|
+
unlockHostResourceDescriptor(descriptor)
|
|
1055
|
+
} catch {
|
|
1056
|
+
// Closing remains the authoritative release.
|
|
1057
|
+
}
|
|
1058
|
+
closeSync(descriptor)
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
unlinkSync(path)
|
|
1062
|
+
} catch (cleanupCause) {
|
|
1063
|
+
if (!isErrorCode(cleanupCause, "ENOENT")) {
|
|
1064
|
+
// A later scanner removes an unlocked unique marker.
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
throw cause
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function sameMarker(left: LiveMarker, right: OwnedMarker): boolean {
|
|
1072
|
+
return left.name === right.name
|
|
1073
|
+
&& left.document.owner === right.document.owner
|
|
1074
|
+
&& left.document.ticket === right.document.ticket
|
|
1075
|
+
&& sameIdentity(left.identity, right.identity)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function claimsOverlap(
|
|
1079
|
+
left: readonly HostResourceClaim[],
|
|
1080
|
+
right: readonly HostResourceClaim[],
|
|
1081
|
+
): boolean {
|
|
1082
|
+
const resources = new Set(left.map(({ resource }) => resource))
|
|
1083
|
+
return right.some(({ resource }) => resources.has(resource))
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function canFitClaims(
|
|
1087
|
+
profile: HostResourceProfile,
|
|
1088
|
+
active: readonly LiveMarker[],
|
|
1089
|
+
requested: readonly HostResourceClaim[],
|
|
1090
|
+
): boolean {
|
|
1091
|
+
const used = new Map<string, number>()
|
|
1092
|
+
for (const marker of active) {
|
|
1093
|
+
for (const claim of marker.document.claims) {
|
|
1094
|
+
used.set(claim.resource, (used.get(claim.resource) ?? 0) + claim.amount)
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
for (const { resource, limit } of profile.capacities) {
|
|
1098
|
+
const current = used.get(resource) ?? 0
|
|
1099
|
+
if (current > limit) {
|
|
1100
|
+
throw stateFailure("Live host-resource leases exceed the declared profile.")
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return requested.every(({ resource, amount }) => {
|
|
1104
|
+
const limit = profile.capacities.find(
|
|
1105
|
+
(capacity) => capacity.resource === resource,
|
|
1106
|
+
)?.limit
|
|
1107
|
+
return limit !== undefined && (used.get(resource) ?? 0) + amount <= limit
|
|
1108
|
+
})
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
interface AdmissionAttempt {
|
|
1112
|
+
readonly admitted: boolean
|
|
1113
|
+
readonly queueState: string
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function admissionQueueState(
|
|
1117
|
+
marker: OwnedMarker,
|
|
1118
|
+
live: readonly LiveMarker[],
|
|
1119
|
+
): string {
|
|
1120
|
+
const ticket = BigInt(marker.document.ticket)
|
|
1121
|
+
const blockers = live.filter((candidate) => {
|
|
1122
|
+
if (sameMarker(candidate, marker)) return false
|
|
1123
|
+
if (!claimsOverlap(candidate.document.claims, marker.document.claims)) {
|
|
1124
|
+
return false
|
|
1125
|
+
}
|
|
1126
|
+
return candidate.document.phase === "A"
|
|
1127
|
+
|| BigInt(candidate.document.ticket) < ticket
|
|
1128
|
+
})
|
|
1129
|
+
return sha256(blockers.map((candidate) => (
|
|
1130
|
+
`${candidate.name}\0${markerJson(candidate.document)}`
|
|
1131
|
+
)).join("\n"))
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function attemptAdmission(
|
|
1135
|
+
marker: OwnedMarker,
|
|
1136
|
+
live: readonly LiveMarker[],
|
|
1137
|
+
profile: HostResourceProfile,
|
|
1138
|
+
): AdmissionAttempt {
|
|
1139
|
+
assertOwnedMarkerIdentity(marker)
|
|
1140
|
+
const current = live.find((candidate) => sameMarker(candidate, marker))
|
|
1141
|
+
if (
|
|
1142
|
+
current === undefined
|
|
1143
|
+
|| current.document.phase !== marker.document.phase
|
|
1144
|
+
|| markerJson(current.document) !== markerJson(marker.document)
|
|
1145
|
+
) {
|
|
1146
|
+
throw new HostResourceError(
|
|
1147
|
+
"OWNERSHIP_LOST",
|
|
1148
|
+
"Host-resource lease marker no longer matches its descriptor.",
|
|
1149
|
+
)
|
|
1150
|
+
}
|
|
1151
|
+
const ticket = BigInt(marker.document.ticket)
|
|
1152
|
+
const earlierOverlap = live.some((candidate) => (
|
|
1153
|
+
candidate.document.phase === "W"
|
|
1154
|
+
&& BigInt(candidate.document.ticket) < ticket
|
|
1155
|
+
&& claimsOverlap(candidate.document.claims, marker.document.claims)
|
|
1156
|
+
))
|
|
1157
|
+
if (earlierOverlap) {
|
|
1158
|
+
return {
|
|
1159
|
+
admitted: false,
|
|
1160
|
+
queueState: admissionQueueState(marker, live),
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
const active = live.filter((candidate) => candidate.document.phase === "A")
|
|
1164
|
+
if (!canFitClaims(profile, active, marker.document.claims)) {
|
|
1165
|
+
return {
|
|
1166
|
+
admitted: false,
|
|
1167
|
+
queueState: admissionQueueState(marker, live),
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
const admitted: MarkerDocument = { ...marker.document, phase: "A" }
|
|
1171
|
+
writeDescriptor(marker.descriptor, markerJson(admitted))
|
|
1172
|
+
marker.document = admitted
|
|
1173
|
+
return { admitted: true, queueState: "" }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function markerMatchesOwnedActive(
|
|
1177
|
+
candidate: LiveMarker,
|
|
1178
|
+
marker: OwnedMarker,
|
|
1179
|
+
): boolean {
|
|
1180
|
+
return sameMarker(candidate, marker)
|
|
1181
|
+
&& candidate.document.phase === "A"
|
|
1182
|
+
&& marker.document.phase === "A"
|
|
1183
|
+
&& markerJson(candidate.document) === markerJson(marker.document)
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function admitMarker(
|
|
1187
|
+
marker: OwnedMarker,
|
|
1188
|
+
root: string,
|
|
1189
|
+
options: ResolvedCoordinatorOptions,
|
|
1190
|
+
signal: AbortSignal | undefined,
|
|
1191
|
+
deadline: number,
|
|
1192
|
+
state: () => StateDocument,
|
|
1193
|
+
): Promise<void> {
|
|
1194
|
+
let previousQueueState: string | undefined
|
|
1195
|
+
let unchangedRetryCount = 0
|
|
1196
|
+
for (;;) {
|
|
1197
|
+
const attempt = await withControlLock(
|
|
1198
|
+
root,
|
|
1199
|
+
options.pollIntervalMilliseconds,
|
|
1200
|
+
signal,
|
|
1201
|
+
deadline,
|
|
1202
|
+
() => {
|
|
1203
|
+
const currentState = state()
|
|
1204
|
+
assertMatchingProfile(currentState, options.profile)
|
|
1205
|
+
const live = scanLiveMarkers(
|
|
1206
|
+
root,
|
|
1207
|
+
options.profile,
|
|
1208
|
+
options.profileSha256,
|
|
1209
|
+
)
|
|
1210
|
+
return attemptAdmission(marker, live, options.profile)
|
|
1211
|
+
},
|
|
1212
|
+
)
|
|
1213
|
+
if (attempt.admitted) return
|
|
1214
|
+
// The durable earlier-ticket marker remains the FIFO authority. Backoff
|
|
1215
|
+
// only changes when this candidate rechecks it, and relevant marker
|
|
1216
|
+
// progress restores the initial responsive interval without a watcher.
|
|
1217
|
+
if (attempt.queueState === previousQueueState) {
|
|
1218
|
+
unchangedRetryCount += 1
|
|
1219
|
+
} else {
|
|
1220
|
+
previousQueueState = attempt.queueState
|
|
1221
|
+
unchangedRetryCount = 0
|
|
1222
|
+
}
|
|
1223
|
+
await waitForRetry(
|
|
1224
|
+
admissionRetryDelayMilliseconds(
|
|
1225
|
+
options.pollIntervalMilliseconds,
|
|
1226
|
+
marker.document.ticket,
|
|
1227
|
+
unchangedRetryCount,
|
|
1228
|
+
),
|
|
1229
|
+
signal,
|
|
1230
|
+
deadline,
|
|
1231
|
+
)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function assertMarkerOwned(
|
|
1236
|
+
marker: OwnedMarker,
|
|
1237
|
+
root: string,
|
|
1238
|
+
options: ResolvedCoordinatorOptions,
|
|
1239
|
+
state: () => StateDocument,
|
|
1240
|
+
): Promise<void> {
|
|
1241
|
+
assertOwnedMarkerIdentity(marker)
|
|
1242
|
+
const deadline = performance.now() + options.waitTimeoutMilliseconds
|
|
1243
|
+
await withControlLock(
|
|
1244
|
+
root,
|
|
1245
|
+
options.pollIntervalMilliseconds,
|
|
1246
|
+
undefined,
|
|
1247
|
+
deadline,
|
|
1248
|
+
() => {
|
|
1249
|
+
const currentState = state()
|
|
1250
|
+
assertMatchingProfile(currentState, options.profile)
|
|
1251
|
+
const live = scanLiveMarkers(
|
|
1252
|
+
root,
|
|
1253
|
+
options.profile,
|
|
1254
|
+
options.profileSha256,
|
|
1255
|
+
)
|
|
1256
|
+
if (!live.some((candidate) => markerMatchesOwnedActive(candidate, marker))) {
|
|
1257
|
+
throw new HostResourceError(
|
|
1258
|
+
"OWNERSHIP_LOST",
|
|
1259
|
+
"Host-resource lease is not backed by its exact active kernel lock.",
|
|
1260
|
+
)
|
|
1261
|
+
}
|
|
1262
|
+
},
|
|
1263
|
+
)
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
async function releaseMarker(
|
|
1267
|
+
marker: OwnedMarker,
|
|
1268
|
+
root: string,
|
|
1269
|
+
options: ResolvedCoordinatorOptions,
|
|
1270
|
+
state: () => StateDocument,
|
|
1271
|
+
): Promise<void> {
|
|
1272
|
+
if (marker.released) return
|
|
1273
|
+
marker.released = true
|
|
1274
|
+
try {
|
|
1275
|
+
closeSync(marker.descriptor)
|
|
1276
|
+
} catch (cause) {
|
|
1277
|
+
if (!isErrorCode(cause, "EBADF")) throw cause
|
|
1278
|
+
}
|
|
1279
|
+
const deadline = performance.now() + options.waitTimeoutMilliseconds
|
|
1280
|
+
await withControlLock(
|
|
1281
|
+
root,
|
|
1282
|
+
options.pollIntervalMilliseconds,
|
|
1283
|
+
undefined,
|
|
1284
|
+
deadline,
|
|
1285
|
+
() => {
|
|
1286
|
+
const currentState = state()
|
|
1287
|
+
assertMatchingProfile(currentState, options.profile)
|
|
1288
|
+
if (metadataOrNull(marker.path) === null) return
|
|
1289
|
+
const descriptor = openPrivateFile(marker.path)
|
|
1290
|
+
let locked = false
|
|
1291
|
+
try {
|
|
1292
|
+
const opened = fstatSync(descriptor)
|
|
1293
|
+
if (!sameIdentity(marker.identity, opened)) {
|
|
1294
|
+
throw new HostResourceError(
|
|
1295
|
+
"OWNERSHIP_LOST",
|
|
1296
|
+
"Host-resource marker inode changed before release.",
|
|
1297
|
+
)
|
|
1298
|
+
}
|
|
1299
|
+
locked = tryLockHostResourceDescriptor(descriptor)
|
|
1300
|
+
if (!locked) {
|
|
1301
|
+
// An explicitly inherited descriptor still owns the lease. Keep its
|
|
1302
|
+
// active marker visible until the descendant exits; a future scan
|
|
1303
|
+
// removes it using the released kernel lock alone.
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
unlinkExactMarker(marker.path, marker.identity)
|
|
1307
|
+
} finally {
|
|
1308
|
+
if (locked) {
|
|
1309
|
+
try {
|
|
1310
|
+
unlockHostResourceDescriptor(descriptor)
|
|
1311
|
+
} catch {
|
|
1312
|
+
// Close remains the authoritative release.
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
closeSync(descriptor)
|
|
1316
|
+
}
|
|
1317
|
+
},
|
|
1318
|
+
)
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function resolveCoordinatorOptions(
|
|
1322
|
+
options: HostResourceCoordinatorOptions,
|
|
1323
|
+
): ResolvedCoordinatorOptions {
|
|
1324
|
+
if (!isHostResourcePlatformSupported(process.platform)) {
|
|
1325
|
+
throw new HostResourceError(
|
|
1326
|
+
"UNSUPPORTED_PLATFORM",
|
|
1327
|
+
"Slopcamera host-resource coordination requires Darwin or Linux.",
|
|
1328
|
+
)
|
|
1329
|
+
}
|
|
1330
|
+
const profile = normalizeHostResourceProfile(
|
|
1331
|
+
options.profile ?? defaultSlopcameraHostResourceProfile(),
|
|
1332
|
+
)
|
|
1333
|
+
const stateRoot = options.stateRoot
|
|
1334
|
+
?? defaultSlopcameraHostResourceStateRoot()
|
|
1335
|
+
if (
|
|
1336
|
+
typeof stateRoot !== "string"
|
|
1337
|
+
|| !isAbsolute(stateRoot)
|
|
1338
|
+
|| stateRoot.includes("\0")
|
|
1339
|
+
|| Buffer.byteLength(stateRoot, "utf8") > maximumPathBytes
|
|
1340
|
+
) {
|
|
1341
|
+
throw stateFailure("Host-resource state root must be a bounded absolute path.")
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
pollIntervalMilliseconds: duration(
|
|
1345
|
+
options.pollIntervalMilliseconds,
|
|
1346
|
+
defaultPollIntervalMilliseconds,
|
|
1347
|
+
1_000,
|
|
1348
|
+
"INVALID_PROFILE",
|
|
1349
|
+
"Host-resource poll interval",
|
|
1350
|
+
),
|
|
1351
|
+
profile,
|
|
1352
|
+
profileSha256: sha256(canonicalProfile(profile)),
|
|
1353
|
+
stateRoot,
|
|
1354
|
+
waitTimeoutMilliseconds: duration(
|
|
1355
|
+
options.waitTimeoutMilliseconds,
|
|
1356
|
+
defaultWaitTimeoutMilliseconds,
|
|
1357
|
+
HOST_RESOURCE_MAX_WAIT_MILLISECONDS,
|
|
1358
|
+
"INVALID_PROFILE",
|
|
1359
|
+
"Host-resource wait timeout",
|
|
1360
|
+
),
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
export function createHostResourceCoordinator(
|
|
1365
|
+
coordinatorOptions: HostResourceCoordinatorOptions = {},
|
|
1366
|
+
): HostResourceCoordinator {
|
|
1367
|
+
const options = resolveCoordinatorOptions(coordinatorOptions)
|
|
1368
|
+
let resolvedRoot: ResolvedStateRoot | undefined
|
|
1369
|
+
|
|
1370
|
+
const root = (): string => {
|
|
1371
|
+
if (resolvedRoot === undefined) {
|
|
1372
|
+
resolvedRoot = ensureStateRoot(options.stateRoot)
|
|
1373
|
+
}
|
|
1374
|
+
return resolvedRoot.path
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const state = (): StateDocument => {
|
|
1378
|
+
return readState(root(), options.profile)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const coordinator: HostResourceCoordinator = {
|
|
1382
|
+
profile: options.profile,
|
|
1383
|
+
scope: "machine",
|
|
1384
|
+
async withLease<T>(
|
|
1385
|
+
claimValue: readonly HostResourceClaim[],
|
|
1386
|
+
callback: (lease: HostResourceLease) => T | Promise<T>,
|
|
1387
|
+
leaseOptions: HostResourceLeaseOptions = {},
|
|
1388
|
+
): Promise<T> {
|
|
1389
|
+
const claims = normalizeHostResourceClaims(claimValue, options.profile)
|
|
1390
|
+
if (typeof callback !== "function") {
|
|
1391
|
+
throw new HostResourceError(
|
|
1392
|
+
"INVALID_CLAIMS",
|
|
1393
|
+
"Host-resource lease callback must be a function.",
|
|
1394
|
+
)
|
|
1395
|
+
}
|
|
1396
|
+
const waitTimeoutMilliseconds = duration(
|
|
1397
|
+
leaseOptions.waitTimeoutMilliseconds,
|
|
1398
|
+
options.waitTimeoutMilliseconds,
|
|
1399
|
+
HOST_RESOURCE_MAX_WAIT_MILLISECONDS,
|
|
1400
|
+
"INVALID_CLAIMS",
|
|
1401
|
+
"Host-resource lease wait timeout",
|
|
1402
|
+
)
|
|
1403
|
+
const signal = leaseOptions.signal
|
|
1404
|
+
const deadline = performance.now() + waitTimeoutMilliseconds
|
|
1405
|
+
throwIfWaitEnded(signal, deadline)
|
|
1406
|
+
const directory = root()
|
|
1407
|
+
let marker: OwnedMarker | undefined
|
|
1408
|
+
let callbackSettledSuccessfully = false
|
|
1409
|
+
let callbackResult!: T
|
|
1410
|
+
let callbackFailure: unknown
|
|
1411
|
+
try {
|
|
1412
|
+
marker = await withControlLock(
|
|
1413
|
+
directory,
|
|
1414
|
+
options.pollIntervalMilliseconds,
|
|
1415
|
+
signal,
|
|
1416
|
+
deadline,
|
|
1417
|
+
() => {
|
|
1418
|
+
const currentState = state()
|
|
1419
|
+
assertMatchingProfile(currentState, options.profile)
|
|
1420
|
+
scanLiveMarkers(directory, options.profile, options.profileSha256)
|
|
1421
|
+
return publishWaitingMarker(
|
|
1422
|
+
directory,
|
|
1423
|
+
currentState,
|
|
1424
|
+
claims,
|
|
1425
|
+
options.profileSha256,
|
|
1426
|
+
)
|
|
1427
|
+
},
|
|
1428
|
+
)
|
|
1429
|
+
await admitMarker(
|
|
1430
|
+
marker,
|
|
1431
|
+
directory,
|
|
1432
|
+
options,
|
|
1433
|
+
signal,
|
|
1434
|
+
deadline,
|
|
1435
|
+
state,
|
|
1436
|
+
)
|
|
1437
|
+
const ownedMarker = marker
|
|
1438
|
+
const lease: HostResourceLease = Object.freeze({
|
|
1439
|
+
claims,
|
|
1440
|
+
inheritedFileDescriptor: ownedMarker.descriptor,
|
|
1441
|
+
profile: options.profile,
|
|
1442
|
+
ticket: ownedMarker.document.ticket,
|
|
1443
|
+
assertOwned: async (): Promise<void> => {
|
|
1444
|
+
await assertMarkerOwned(ownedMarker, directory, options, state)
|
|
1445
|
+
},
|
|
1446
|
+
})
|
|
1447
|
+
await lease.assertOwned()
|
|
1448
|
+
try {
|
|
1449
|
+
callbackResult = await callback(lease)
|
|
1450
|
+
callbackSettledSuccessfully = true
|
|
1451
|
+
// Detect callback-local descriptor/path misuse while the exact owner
|
|
1452
|
+
// is still available. External cancellation never releases capacity
|
|
1453
|
+
// until this callback has settled.
|
|
1454
|
+
await lease.assertOwned()
|
|
1455
|
+
} catch (cause) {
|
|
1456
|
+
callbackFailure = cause
|
|
1457
|
+
}
|
|
1458
|
+
} finally {
|
|
1459
|
+
if (marker !== undefined) {
|
|
1460
|
+
try {
|
|
1461
|
+
await releaseMarker(marker, directory, options, state)
|
|
1462
|
+
} catch (releaseFailure) {
|
|
1463
|
+
if (callbackFailure === undefined) callbackFailure = releaseFailure
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (!callbackSettledSuccessfully || callbackFailure !== undefined) {
|
|
1468
|
+
throw callbackFailure
|
|
1469
|
+
}
|
|
1470
|
+
return callbackResult
|
|
1471
|
+
},
|
|
1472
|
+
}
|
|
1473
|
+
return Object.freeze(coordinator)
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
interface ProcessLocalRequest<T> {
|
|
1477
|
+
readonly callback: (lease: HostResourceLease) => T | Promise<T>
|
|
1478
|
+
readonly claims: readonly HostResourceClaim[]
|
|
1479
|
+
readonly reject: (cause: unknown) => void
|
|
1480
|
+
readonly resolve: (value: T) => void
|
|
1481
|
+
readonly signal: AbortSignal | undefined
|
|
1482
|
+
readonly ticket: string
|
|
1483
|
+
abortListener?: () => void
|
|
1484
|
+
active: boolean
|
|
1485
|
+
descriptor?: number
|
|
1486
|
+
settled: boolean
|
|
1487
|
+
timeout?: ReturnType<typeof setTimeout>
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
interface ProcessLocalCoordinatorState {
|
|
1491
|
+
readonly active: Map<string, ProcessLocalRequest<unknown>>
|
|
1492
|
+
nextTicket: bigint
|
|
1493
|
+
readonly waiting: ProcessLocalRequest<unknown>[]
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const processLocalCoordinatorStates = new Map<
|
|
1497
|
+
string,
|
|
1498
|
+
ProcessLocalCoordinatorState
|
|
1499
|
+
>()
|
|
1500
|
+
|
|
1501
|
+
export function createProcessLocalHostResourceCoordinator(
|
|
1502
|
+
coordinatorOptions: Omit<HostResourceCoordinatorOptions, "stateRoot"> = {},
|
|
1503
|
+
): HostResourceCoordinator {
|
|
1504
|
+
const profile = normalizeHostResourceProfile(
|
|
1505
|
+
coordinatorOptions.profile ?? defaultSlopcameraHostResourceProfile(),
|
|
1506
|
+
)
|
|
1507
|
+
const defaultWait = duration(
|
|
1508
|
+
coordinatorOptions.waitTimeoutMilliseconds,
|
|
1509
|
+
defaultWaitTimeoutMilliseconds,
|
|
1510
|
+
HOST_RESOURCE_MAX_WAIT_MILLISECONDS,
|
|
1511
|
+
"INVALID_PROFILE",
|
|
1512
|
+
"Host-resource wait timeout",
|
|
1513
|
+
)
|
|
1514
|
+
if (coordinatorOptions.pollIntervalMilliseconds !== undefined) {
|
|
1515
|
+
duration(
|
|
1516
|
+
coordinatorOptions.pollIntervalMilliseconds,
|
|
1517
|
+
defaultPollIntervalMilliseconds,
|
|
1518
|
+
1_000,
|
|
1519
|
+
"INVALID_PROFILE",
|
|
1520
|
+
"Host-resource poll interval",
|
|
1521
|
+
)
|
|
1522
|
+
}
|
|
1523
|
+
const stateKey = canonicalProfile(profile)
|
|
1524
|
+
let localState = processLocalCoordinatorStates.get(stateKey)
|
|
1525
|
+
if (localState === undefined) {
|
|
1526
|
+
if (processLocalCoordinatorStates.size >= maximumProcessLocalProfiles) {
|
|
1527
|
+
throw new HostResourceError(
|
|
1528
|
+
"INVALID_PROFILE",
|
|
1529
|
+
"This process already coordinates the maximum number of host-resource profiles.",
|
|
1530
|
+
)
|
|
1531
|
+
}
|
|
1532
|
+
localState = {
|
|
1533
|
+
active: new Map<string, ProcessLocalRequest<unknown>>(),
|
|
1534
|
+
nextTicket: 1n,
|
|
1535
|
+
waiting: [],
|
|
1536
|
+
}
|
|
1537
|
+
processLocalCoordinatorStates.set(stateKey, localState)
|
|
1538
|
+
}
|
|
1539
|
+
const { active, waiting } = localState
|
|
1540
|
+
|
|
1541
|
+
const removeWaitHooks = (request: ProcessLocalRequest<unknown>): void => {
|
|
1542
|
+
if (request.timeout !== undefined) clearTimeout(request.timeout)
|
|
1543
|
+
if (request.abortListener !== undefined) {
|
|
1544
|
+
request.signal?.removeEventListener("abort", request.abortListener)
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const activeMarkers = (): readonly LiveMarker[] => [...active.values()].map(
|
|
1549
|
+
(request) => ({
|
|
1550
|
+
document: {
|
|
1551
|
+
version: 1,
|
|
1552
|
+
owner: request.ticket.padStart(32, "0").slice(-32),
|
|
1553
|
+
profileSha256: sha256(canonicalProfile(profile)),
|
|
1554
|
+
ticket: request.ticket,
|
|
1555
|
+
phase: "A",
|
|
1556
|
+
claims: request.claims,
|
|
1557
|
+
},
|
|
1558
|
+
identity: { device: 0, inode: 0 },
|
|
1559
|
+
name: request.ticket,
|
|
1560
|
+
path: "",
|
|
1561
|
+
}),
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
const pump = (): void => {
|
|
1565
|
+
for (let index = 0; index < waiting.length;) {
|
|
1566
|
+
const request = waiting[index]
|
|
1567
|
+
if (request === undefined) break
|
|
1568
|
+
const earlierOverlap = waiting.slice(0, index).some((earlier) =>
|
|
1569
|
+
claimsOverlap(earlier.claims, request.claims))
|
|
1570
|
+
if (
|
|
1571
|
+
earlierOverlap
|
|
1572
|
+
|| !canFitClaims(profile, activeMarkers(), request.claims)
|
|
1573
|
+
) {
|
|
1574
|
+
index += 1
|
|
1575
|
+
continue
|
|
1576
|
+
}
|
|
1577
|
+
waiting.splice(index, 1)
|
|
1578
|
+
removeWaitHooks(request)
|
|
1579
|
+
let descriptor: number
|
|
1580
|
+
try {
|
|
1581
|
+
descriptor = openSync(
|
|
1582
|
+
process.platform === "win32" ? "NUL" : "/dev/null",
|
|
1583
|
+
constants.O_RDONLY,
|
|
1584
|
+
)
|
|
1585
|
+
} catch (cause) {
|
|
1586
|
+
request.settled = true
|
|
1587
|
+
request.reject(new HostResourceError(
|
|
1588
|
+
"UNSAFE_STATE",
|
|
1589
|
+
"Process-local host-resource admission could not open its lease descriptor.",
|
|
1590
|
+
cause,
|
|
1591
|
+
))
|
|
1592
|
+
continue
|
|
1593
|
+
}
|
|
1594
|
+
request.active = true
|
|
1595
|
+
active.set(request.ticket, request)
|
|
1596
|
+
request.descriptor = descriptor
|
|
1597
|
+
const assertOwned = async (): Promise<void> => {
|
|
1598
|
+
if (
|
|
1599
|
+
request.settled
|
|
1600
|
+
|| !request.active
|
|
1601
|
+
|| active.get(request.ticket) !== request
|
|
1602
|
+
|| request.descriptor !== descriptor
|
|
1603
|
+
) {
|
|
1604
|
+
throw new HostResourceError(
|
|
1605
|
+
"OWNERSHIP_LOST",
|
|
1606
|
+
"Process-local host-resource lease is no longer active.",
|
|
1607
|
+
)
|
|
1608
|
+
}
|
|
1609
|
+
try {
|
|
1610
|
+
fstatSync(descriptor)
|
|
1611
|
+
} catch (cause) {
|
|
1612
|
+
throw new HostResourceError(
|
|
1613
|
+
"OWNERSHIP_LOST",
|
|
1614
|
+
"Process-local host-resource descriptor is no longer open.",
|
|
1615
|
+
cause,
|
|
1616
|
+
)
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
const lease: HostResourceLease = Object.freeze({
|
|
1620
|
+
assertOwned,
|
|
1621
|
+
claims: request.claims,
|
|
1622
|
+
inheritedFileDescriptor: descriptor,
|
|
1623
|
+
profile,
|
|
1624
|
+
ticket: request.ticket,
|
|
1625
|
+
})
|
|
1626
|
+
void (async () => {
|
|
1627
|
+
let succeeded = false
|
|
1628
|
+
let result: unknown
|
|
1629
|
+
let failure: unknown
|
|
1630
|
+
try {
|
|
1631
|
+
await lease.assertOwned()
|
|
1632
|
+
result = await request.callback(lease)
|
|
1633
|
+
await lease.assertOwned()
|
|
1634
|
+
succeeded = true
|
|
1635
|
+
} catch (cause) {
|
|
1636
|
+
failure = cause
|
|
1637
|
+
} finally {
|
|
1638
|
+
request.active = false
|
|
1639
|
+
request.settled = true
|
|
1640
|
+
active.delete(request.ticket)
|
|
1641
|
+
try {
|
|
1642
|
+
closeSync(descriptor)
|
|
1643
|
+
} catch (cause) {
|
|
1644
|
+
if (failure === undefined) failure = cause
|
|
1645
|
+
}
|
|
1646
|
+
pump()
|
|
1647
|
+
}
|
|
1648
|
+
if (succeeded && failure === undefined) request.resolve(result)
|
|
1649
|
+
else request.reject(failure)
|
|
1650
|
+
})()
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
const coordinator: HostResourceCoordinator = {
|
|
1655
|
+
profile,
|
|
1656
|
+
scope: "process",
|
|
1657
|
+
async withLease<T>(
|
|
1658
|
+
claimValue: readonly HostResourceClaim[],
|
|
1659
|
+
callback: (lease: HostResourceLease) => T | Promise<T>,
|
|
1660
|
+
leaseOptions: HostResourceLeaseOptions = {},
|
|
1661
|
+
): Promise<T> {
|
|
1662
|
+
const claims = normalizeHostResourceClaims(claimValue, profile)
|
|
1663
|
+
if (typeof callback !== "function") {
|
|
1664
|
+
return Promise.reject(new HostResourceError(
|
|
1665
|
+
"INVALID_CLAIMS",
|
|
1666
|
+
"Host-resource lease callback must be a function.",
|
|
1667
|
+
))
|
|
1668
|
+
}
|
|
1669
|
+
if (localState.nextTicket > maximumTicket) {
|
|
1670
|
+
return Promise.reject(stateFailure("Host-resource tickets are exhausted."))
|
|
1671
|
+
}
|
|
1672
|
+
const waitTimeoutMilliseconds = duration(
|
|
1673
|
+
leaseOptions.waitTimeoutMilliseconds,
|
|
1674
|
+
defaultWait,
|
|
1675
|
+
HOST_RESOURCE_MAX_WAIT_MILLISECONDS,
|
|
1676
|
+
"INVALID_CLAIMS",
|
|
1677
|
+
"Host-resource lease wait timeout",
|
|
1678
|
+
)
|
|
1679
|
+
const ticket = String(localState.nextTicket)
|
|
1680
|
+
localState.nextTicket += 1n
|
|
1681
|
+
return await new Promise<T>((resolve, reject) => {
|
|
1682
|
+
const request: ProcessLocalRequest<T> = {
|
|
1683
|
+
active: false,
|
|
1684
|
+
callback,
|
|
1685
|
+
claims,
|
|
1686
|
+
reject,
|
|
1687
|
+
resolve,
|
|
1688
|
+
settled: false,
|
|
1689
|
+
signal: leaseOptions.signal,
|
|
1690
|
+
ticket,
|
|
1691
|
+
}
|
|
1692
|
+
const removeWaiting = (cause: HostResourceError): void => {
|
|
1693
|
+
if (request.active || request.settled) return
|
|
1694
|
+
request.settled = true
|
|
1695
|
+
const index = waiting.indexOf(request as ProcessLocalRequest<unknown>)
|
|
1696
|
+
if (index >= 0) waiting.splice(index, 1)
|
|
1697
|
+
removeWaitHooks(request as ProcessLocalRequest<unknown>)
|
|
1698
|
+
reject(cause)
|
|
1699
|
+
pump()
|
|
1700
|
+
}
|
|
1701
|
+
request.abortListener = () => removeWaiting(new HostResourceError(
|
|
1702
|
+
"WAIT_ABORTED",
|
|
1703
|
+
"Host-resource admission was cancelled before execution.",
|
|
1704
|
+
))
|
|
1705
|
+
request.timeout = setTimeout(
|
|
1706
|
+
() => removeWaiting(new HostResourceError(
|
|
1707
|
+
"WAIT_TIMEOUT",
|
|
1708
|
+
"Host-resource admission exceeded its bounded wait.",
|
|
1709
|
+
)),
|
|
1710
|
+
waitTimeoutMilliseconds,
|
|
1711
|
+
)
|
|
1712
|
+
request.timeout.unref?.()
|
|
1713
|
+
leaseOptions.signal?.addEventListener("abort", request.abortListener, {
|
|
1714
|
+
once: true,
|
|
1715
|
+
})
|
|
1716
|
+
if (leaseOptions.signal?.aborted === true) {
|
|
1717
|
+
request.abortListener()
|
|
1718
|
+
return
|
|
1719
|
+
}
|
|
1720
|
+
waiting.push(request as ProcessLocalRequest<unknown>)
|
|
1721
|
+
pump()
|
|
1722
|
+
})
|
|
1723
|
+
},
|
|
1724
|
+
}
|
|
1725
|
+
return Object.freeze(coordinator)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
export function createDefaultHostResourceCoordinator(
|
|
1729
|
+
options: HostResourceCoordinatorOptions = {},
|
|
1730
|
+
platform: NodeJS.Platform = process.platform,
|
|
1731
|
+
): HostResourceCoordinator {
|
|
1732
|
+
if (isHostResourcePlatformSupported(platform)) {
|
|
1733
|
+
return createHostResourceCoordinator(options)
|
|
1734
|
+
}
|
|
1735
|
+
const {
|
|
1736
|
+
profile,
|
|
1737
|
+
pollIntervalMilliseconds,
|
|
1738
|
+
waitTimeoutMilliseconds,
|
|
1739
|
+
} = options
|
|
1740
|
+
return createProcessLocalHostResourceCoordinator({
|
|
1741
|
+
...(profile === undefined ? {} : { profile }),
|
|
1742
|
+
...(pollIntervalMilliseconds === undefined
|
|
1743
|
+
? {}
|
|
1744
|
+
: { pollIntervalMilliseconds }),
|
|
1745
|
+
...(waitTimeoutMilliseconds === undefined
|
|
1746
|
+
? {}
|
|
1747
|
+
: { waitTimeoutMilliseconds }),
|
|
1748
|
+
})
|
|
1749
|
+
}
|