@mnstry/atelier 0.2.0-alpha.6 → 0.2.0-alpha.8
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/CHANGELOG.md +176 -0
- package/README.md +59 -15
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/docs/assurance-controls.md +9 -1
- package/docs/atelier-sync.md +5 -4
- package/docs/blocks/claims.md +15 -10
- package/docs/blocks/will-not-do.md +4 -1
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/decisions.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +65 -9
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +227 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +687 -0
- package/docs/obsidian.md +387 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +88 -0
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +55 -2
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +57 -6
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-open/SKILL.md +5 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-open/SKILL.md +5 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/policy.mjs +57 -25
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +43 -3
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +16 -33
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/extension-pack.mjs +15 -8
- package/src/commands/init.mjs +44 -5
- package/src/commands/obsidian.mjs +368 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +8 -1
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +29 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +13 -0
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +64 -2
- package/src/graph/knowledge-graph.mjs +548 -24
- package/src/guides/contracts.mjs +54 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +26 -27
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +166 -0
- package/src/project/private-state.mjs +11 -1
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +139 -0
- package/src/projection/obsidian/recovery/index.mjs +6 -0
- package/src/projection/obsidian/recovery/journal.mjs +134 -0
- package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
- package/src/projection/obsidian/recovery/restart.mjs +316 -0
- package/src/projection/obsidian/recovery/store.mjs +234 -0
- package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
- package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
- package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
- package/src/projection/obsidian/selection-ui/index.mjs +12 -0
- package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
- package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
- package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
- package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
- package/src/projection/project.mjs +28 -19
- package/src/readiness-protocols/evidence.mjs +282 -0
- package/src/readiness-protocols/runtime.mjs +4 -3
- package/src/readiness-protocols/source-read.mjs +39 -0
- package/src/runtime/obsidian/app-capability.mjs +110 -0
- package/src/runtime/obsidian/app-production-seams.mjs +96 -0
- package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
- package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
- package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
- package/src/runtime/obsidian/contributions.mjs +27 -0
- package/src/runtime/obsidian/documents.mjs +37 -0
- package/src/runtime/obsidian/enablement.mjs +53 -0
- package/src/runtime/obsidian/engine.mjs +532 -0
- package/src/runtime/obsidian/errors.mjs +14 -0
- package/src/runtime/obsidian/extension-points.mjs +103 -0
- package/src/runtime/obsidian/index.mjs +34 -0
- package/src/runtime/obsidian/lifecycle.mjs +258 -0
- package/src/runtime/obsidian/machine-settings.mjs +260 -0
- package/src/runtime/obsidian/observation.mjs +158 -0
- package/src/runtime/obsidian/opening.mjs +243 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-main.mjs +89 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/supervisor.mjs +16 -1
- package/src/server/local-sidecar.mjs +44 -0
- package/src/server/server.mjs +1 -0
- package/src/skills/steward.mjs +780 -0
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +63 -28
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/project/README.md +2 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createHash, randomBytes as cryptoRandomBytes } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { hostname } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { isPendingPrivateWrite, publishPrivateFile } from '../../project/durable-state.mjs'
|
|
6
|
+
import { ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
7
|
+
import { closedObject, isoTime } from './documents.mjs'
|
|
8
|
+
import { ObsidianMaintenanceRefusal } from './errors.mjs'
|
|
9
|
+
|
|
10
|
+
// A private lock between processes of one machine, as a directory of owner
|
|
11
|
+
// tickets:
|
|
12
|
+
//
|
|
13
|
+
// <lock>/000000000007.json the ticket of generation 7: who holds it
|
|
14
|
+
// <lock>/000000000007.released the same bytes: generation 7 was given back
|
|
15
|
+
//
|
|
16
|
+
// Only the highest generation means anything. A ticket is published with a
|
|
17
|
+
// non-overwriting hard link, so of two contenders for generation N+1 exactly
|
|
18
|
+
// one wins; the winner then confirms that nothing higher exists (a contender
|
|
19
|
+
// that decided on an old listing finds the newer ticket and withdraws) and
|
|
20
|
+
// removes every lower generation, so a lock taken on every tick does not grow.
|
|
21
|
+
//
|
|
22
|
+
// Nothing is ever taken from a holder that may still be working. A ticket
|
|
23
|
+
// whose holder did not release it is passed over only with proof:
|
|
24
|
+
//
|
|
25
|
+
// - its process is gone; or
|
|
26
|
+
// - it is a maintenance service that recorded where it answers health, and
|
|
27
|
+
// that address refuses connections or answers as another runtime or PID.
|
|
28
|
+
// A service listens from before its first tick until after its last, so
|
|
29
|
+
// either answer proves the recorded runtime is not ticking.
|
|
30
|
+
//
|
|
31
|
+
// No answer in time proves nothing (a busy holder answers late). A ticket
|
|
32
|
+
// from another machine, an unreadable ticket, an unknown file, or a live
|
|
33
|
+
// process that recorded no health address all need a person: see
|
|
34
|
+
// `inspectPrivateGenerationLock` and docs/local-services.md.
|
|
35
|
+
|
|
36
|
+
export const LOCK_TICKET_SCHEMA = 'atelier-obsidian-lock-ticket/v1'
|
|
37
|
+
export const ENGINE_LOCK_DIRECTORY = path.join('state', 'maintenance', 'engine-lock')
|
|
38
|
+
|
|
39
|
+
const TICKET_NAME = /^(\d{12})\.(json|released)$/
|
|
40
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
41
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1'])
|
|
42
|
+
const MAX_GENERATION = 999999999999
|
|
43
|
+
|
|
44
|
+
export const machineDigest = () => createHash('sha256').update(hostname()).digest('hex')
|
|
45
|
+
|
|
46
|
+
export function isProcessAlive(pid) {
|
|
47
|
+
try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateTicket(document, workspaceId) {
|
|
51
|
+
closedObject(document, { required: ['schema', 'workspaceId', 'purpose', 'pid', 'machine', 'nonce', 'acquiredAt', 'service'] }, 'invalid-lock-ticket', 'a lock ticket')
|
|
52
|
+
const ok = document.schema === LOCK_TICKET_SCHEMA && document.workspaceId === workspaceId && typeof document.purpose === 'string' && IDENTIFIER.test(document.purpose)
|
|
53
|
+
&& Number.isSafeInteger(document.pid) && document.pid > 0 && /^[0-9a-f]{64}$/.test(document.machine) && /^[0-9a-f]{32}$/.test(document.nonce) && typeof document.acquiredAt === 'string'
|
|
54
|
+
if (!ok) throw new ObsidianMaintenanceRefusal('invalid-lock-ticket', 'a lock ticket is malformed or belongs to another workspace')
|
|
55
|
+
if (document.service !== null) {
|
|
56
|
+
closedObject(document.service, { required: ['host', 'port', 'runtimeId'] }, 'invalid-lock-ticket', 'the service of a lock ticket')
|
|
57
|
+
if (!LOOPBACK.has(document.service.host) || !Number.isInteger(document.service.port) || document.service.port < 1 || document.service.port > 65535 || typeof document.service.runtimeId !== 'string' || !IDENTIFIER.test(document.service.runtimeId)) {
|
|
58
|
+
throw new ObsidianMaintenanceRefusal('invalid-lock-ticket', 'the service of a lock ticket is malformed')
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return document
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Whether the holder of an unreleased ticket is provably not working any more.
|
|
65
|
+
// `probe` answers { kind: 'refused' | 'health' | ... , body }.
|
|
66
|
+
export function createAbandonmentProof({ probe = null, alive = isProcessAlive, machine = machineDigest(), maxAgeMs = null } = {}) {
|
|
67
|
+
return async function proveAbandoned(ticket, { nowMs } = {}) {
|
|
68
|
+
if (ticket.machine !== machine) return { abandoned: false, reason: 'held-on-another-machine' }
|
|
69
|
+
if (!alive(ticket.pid)) return { abandoned: true, reason: 'holder-process-gone' }
|
|
70
|
+
if (ticket.pid === process.pid) return { abandoned: false, reason: 'held-by-this-process' }
|
|
71
|
+
// A purpose whose holder is bounded in time by construction (a start) may name a maximum age.
|
|
72
|
+
if (maxAgeMs !== null && Number.isFinite(nowMs) && nowMs - Date.parse(ticket.acquiredAt) > maxAgeMs) return { abandoned: true, reason: 'holder-older-than-its-bound' }
|
|
73
|
+
if (ticket.service === null || typeof probe !== 'function') return { abandoned: false, reason: 'live-process-unproven' }
|
|
74
|
+
const answer = await probe({ host: ticket.service.host, port: ticket.service.port })
|
|
75
|
+
if (answer.kind === 'refused') return { abandoned: true, reason: 'service-address-closed' }
|
|
76
|
+
if (answer.kind === 'health' && (answer.body.runtimeId !== ticket.service.runtimeId || answer.body.pid !== ticket.pid)) return { abandoned: true, reason: 'service-address-answers-as-another-runtime' }
|
|
77
|
+
return { abandoned: false, reason: answer.kind === 'health' ? 'holder-answers-health' : 'live-process-unproven' }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function listGenerations(directory) {
|
|
82
|
+
let names
|
|
83
|
+
try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') return { names: [], highest: 0, unknown: null }; throw error }
|
|
84
|
+
names = names.filter((name) => !isPendingPrivateWrite(name))
|
|
85
|
+
const unknown = names.find((name) => !TICKET_NAME.test(name)) ?? null
|
|
86
|
+
const highest = names.reduce((most, name) => Math.max(most, Number(TICKET_NAME.exec(name)?.[1] ?? 0)), 0)
|
|
87
|
+
return { names, highest, unknown }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ticketName = (generation, kind) => `${String(generation).padStart(12, '0')}.${kind}`
|
|
91
|
+
|
|
92
|
+
// Read-only. { available, reason, generation, ticket }
|
|
93
|
+
export async function inspectPrivateGenerationLock({ directory, workspaceId, proveAbandoned = createAbandonmentProof(), nowMs = Date.now() }) {
|
|
94
|
+
const { names, highest, unknown } = listGenerations(directory)
|
|
95
|
+
if (unknown) return { available: false, reason: 'unknown-lock-file', generation: highest, ticket: null }
|
|
96
|
+
if (highest === 0) return { available: true, reason: 'unused', generation: 0, ticket: null }
|
|
97
|
+
let body
|
|
98
|
+
let ticket
|
|
99
|
+
try {
|
|
100
|
+
body = readRegularTextNoFollow(path.join(directory, ticketName(highest, 'json')))
|
|
101
|
+
ticket = validateTicket(JSON.parse(body), workspaceId)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// Pruned or replaced between the listing and the read: somebody is working here right now.
|
|
104
|
+
return { available: false, reason: error.code === 'ENOENT' ? 'lock-changed-while-reading' : 'unreadable-lock-ticket', generation: highest, ticket: null }
|
|
105
|
+
}
|
|
106
|
+
if (names.includes(ticketName(highest, 'released'))) {
|
|
107
|
+
let released
|
|
108
|
+
try { released = readRegularTextNoFollow(path.join(directory, ticketName(highest, 'released'))) } catch { released = null }
|
|
109
|
+
return released === body ? { available: true, reason: 'released', generation: highest, ticket } : { available: false, reason: 'release-identity-differs', generation: highest, ticket }
|
|
110
|
+
}
|
|
111
|
+
const proof = await proveAbandoned(ticket, { nowMs })
|
|
112
|
+
return { available: proof.abandoned, reason: proof.reason, generation: highest, ticket }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// { acquired: true, generation, release() } or { acquired: false, reason, holder }.
|
|
116
|
+
// Never waits and never throws for a lock that is merely held.
|
|
117
|
+
export async function acquirePrivateGenerationLock({ workspaceRoot, directory, workspaceId, purpose, service = null, clock, proveAbandoned, randomBytes = cryptoRandomBytes }) {
|
|
118
|
+
const contained = ensureContainedPrivateDirectory({ workspaceRoot, directory, label: 'private lock' })
|
|
119
|
+
const acquiredAt = isoTime(clock)
|
|
120
|
+
const status = await inspectPrivateGenerationLock({ directory: contained, workspaceId, proveAbandoned, nowMs: Date.parse(acquiredAt) })
|
|
121
|
+
const holder = status.ticket ? { pid: status.ticket.pid, purpose: status.ticket.purpose, runtimeId: status.ticket.service?.runtimeId ?? null } : null
|
|
122
|
+
if (!status.available) return { acquired: false, reason: status.reason, holder }
|
|
123
|
+
if (status.generation >= MAX_GENERATION) return { acquired: false, reason: 'lock-generations-exhausted', holder }
|
|
124
|
+
const generation = status.generation + 1
|
|
125
|
+
const ticket = validateTicket({ schema: LOCK_TICKET_SCHEMA, workspaceId, purpose, pid: process.pid, machine: machineDigest(), nonce: randomBytes(16).toString('hex'), acquiredAt, service }, workspaceId)
|
|
126
|
+
const body = JSON.stringify(ticket)
|
|
127
|
+
const file = path.join(contained, ticketName(generation, 'json'))
|
|
128
|
+
try { publishPrivateFile(file, body) } catch (error) {
|
|
129
|
+
if (error.code === 'EEXIST') return { acquired: false, reason: 'lost-the-race-for-this-generation', holder: null }
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
// Confirm: a decision made on an old listing finds the newer ticket here and withdraws.
|
|
133
|
+
const after = listGenerations(contained)
|
|
134
|
+
if (after.highest !== generation) {
|
|
135
|
+
try { fs.unlinkSync(file) } catch { /* a lower generation is ignored and pruned by the next holder */ }
|
|
136
|
+
return { acquired: false, reason: 'a-newer-holder-exists', holder: null }
|
|
137
|
+
}
|
|
138
|
+
for (const name of after.names) {
|
|
139
|
+
if (Number(TICKET_NAME.exec(name)[1]) < generation) try { fs.unlinkSync(path.join(contained, name)) } catch { /* pruned by somebody else */ }
|
|
140
|
+
}
|
|
141
|
+
let released = false
|
|
142
|
+
return {
|
|
143
|
+
acquired: true,
|
|
144
|
+
generation,
|
|
145
|
+
release() {
|
|
146
|
+
if (released) return false
|
|
147
|
+
released = true
|
|
148
|
+
let current
|
|
149
|
+
try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') return false; throw error }
|
|
150
|
+
// Taken over after a proof of abandonment (this process was thought gone): nothing here is ours to release.
|
|
151
|
+
if (current !== body) return false
|
|
152
|
+
publishPrivateFile(path.join(contained, ticketName(generation, 'released')), body)
|
|
153
|
+
return true
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { executableIdentity } from './service-record.mjs'
|
|
4
|
+
|
|
5
|
+
// Whether a live PID provably runs the executable a service record names,
|
|
6
|
+
// without that process answering anything. Used for one decision only: telling
|
|
7
|
+
// a service of ours that is too busy to answer health (`busy`) from a listener
|
|
8
|
+
// nobody proved (`occupied`). Whatever cannot be established is "not proven".
|
|
9
|
+
//
|
|
10
|
+
// Linux /proc/<pid>/cmdline, the exact argument list
|
|
11
|
+
// macOS /bin/ps -ww -o args= -p <pid>, one line of text
|
|
12
|
+
// Windows not established: a busy service reads as `occupied` there
|
|
13
|
+
//
|
|
14
|
+
// Nothing is looked up by name, port or pattern: the one PID asked about is
|
|
15
|
+
// the PID the owner-only record names.
|
|
16
|
+
|
|
17
|
+
const RUNTIME_ARGUMENT = '--runtime-id='
|
|
18
|
+
|
|
19
|
+
// string[] (exact arguments), string (one joined line) or null.
|
|
20
|
+
export function readProcessCommandLine(pid, { platform = process.platform, readFile = fs.readFileSync, run = execFileSync } = {}) {
|
|
21
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null
|
|
22
|
+
try {
|
|
23
|
+
if (platform === 'linux') {
|
|
24
|
+
const words = readFile(`/proc/${pid}/cmdline`, 'utf8').split('\u0000').filter((word) => word !== '')
|
|
25
|
+
return words.length > 0 ? words : null
|
|
26
|
+
}
|
|
27
|
+
if (platform === 'darwin' || platform === 'freebsd' || platform === 'openbsd') {
|
|
28
|
+
const line = run('/bin/ps', ['-ww', '-o', 'args=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, maxBuffer: 1024 * 1024 }).trim()
|
|
29
|
+
return line === '' ? null : line
|
|
30
|
+
}
|
|
31
|
+
} catch { /* not established */ }
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Pure. The command line must name the recorded entry module, and a runtime
|
|
36
|
+
// identifier it carries must be the recorded one.
|
|
37
|
+
export function commandLineNamesRecord(commandLine, record) {
|
|
38
|
+
const entry = record?.executable?.path
|
|
39
|
+
if (typeof entry !== 'string' || entry === '' || typeof record.runtimeId !== 'string') return false
|
|
40
|
+
if (Array.isArray(commandLine)) {
|
|
41
|
+
if (!commandLine.slice(1).includes(entry)) return false
|
|
42
|
+
const named = commandLine.filter((word) => word.startsWith(RUNTIME_ARGUMENT)).map((word) => word.slice(RUNTIME_ARGUMENT.length))
|
|
43
|
+
return named.every((runtimeId) => runtimeId === record.runtimeId)
|
|
44
|
+
}
|
|
45
|
+
if (typeof commandLine !== 'string') return false
|
|
46
|
+
if (!` ${commandLine} `.includes(` ${entry} `)) return false
|
|
47
|
+
const named = [...commandLine.matchAll(/(?:^|\s)--runtime-id=(\S+)/g)].map((match) => match[1])
|
|
48
|
+
return named.every((runtimeId) => runtimeId === record.runtimeId)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The recorded executable is still the bytes that were recorded, and the live
|
|
52
|
+
// process was started on it.
|
|
53
|
+
export function processRunsRecordedExecutable(record, { commandLineOf = readProcessCommandLine, identityOf = executableIdentity } = {}) {
|
|
54
|
+
try {
|
|
55
|
+
if (identityOf(record.executable.path).digest !== record.executable.digest) return false
|
|
56
|
+
} catch { return false }
|
|
57
|
+
return commandLineNamesRecord(commandLineOf(record.pid), record)
|
|
58
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { isPlainObject } from './documents.mjs'
|
|
3
|
+
import { refuse } from './errors.mjs'
|
|
4
|
+
|
|
5
|
+
// The only client of the maintenance service, and the only place this runtime
|
|
6
|
+
// opens a connection. It connects to a literal loopback address and nothing
|
|
7
|
+
// else: a hostname, a wildcard address or any other value refuses before a
|
|
8
|
+
// socket exists, so no name is ever resolved and nothing leaves the machine.
|
|
9
|
+
|
|
10
|
+
export const LOOPBACK_HOSTS = Object.freeze(['127.0.0.1', '::1'])
|
|
11
|
+
export const HEALTH_SCHEMA = 'atelier-obsidian-service-health/v1'
|
|
12
|
+
// Long enough for a closed port to refuse on every platform: some answer a refused loopback connection only after retrying for about two seconds.
|
|
13
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 5000
|
|
14
|
+
const MAX_RESPONSE_BYTES = 64 * 1024
|
|
15
|
+
|
|
16
|
+
export const authorityOf = (host, port) => (host === '::1' ? `[::1]:${port}` : `${host}:${port}`)
|
|
17
|
+
|
|
18
|
+
function assertLoopback(host, port) {
|
|
19
|
+
if (!LOOPBACK_HOSTS.includes(host)) refuse('service-address-not-loopback', 'the maintenance service is reached at a literal loopback address only')
|
|
20
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) refuse('service-address-not-loopback', 'the maintenance service port is not a port')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// { kind: 'refused' } nothing listens there
|
|
24
|
+
// { kind: 'timeout' } something accepted the connection and did not answer in time
|
|
25
|
+
// { kind: 'error', code } the connection failed some other way
|
|
26
|
+
// { kind: 'response', statusCode, body } body is parsed JSON, or null
|
|
27
|
+
export function requestLoopback({ host, port, method = 'GET', path = '/health', bearer = null, payload = null, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS }) {
|
|
28
|
+
assertLoopback(host, port)
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
let settled = false
|
|
31
|
+
const settle = (value) => { if (!settled) { settled = true; resolve(value) } }
|
|
32
|
+
const text = payload === null ? null : JSON.stringify(payload)
|
|
33
|
+
const headers = { Host: authorityOf(host, port), Connection: 'close', ...(bearer === null ? {} : { Authorization: `Bearer ${bearer}` }), ...(text === null ? {} : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) }) }
|
|
34
|
+
// The target is one of two literals, whatever was passed in.
|
|
35
|
+
const request = http.request({ host: host === '::1' ? '::1' : '127.0.0.1', port, family: host === '::1' ? 6 : 4, method, path, headers, agent: false, timeout: timeoutMs }, (response) => {
|
|
36
|
+
const chunks = []
|
|
37
|
+
let size = 0
|
|
38
|
+
response.on('data', (chunk) => {
|
|
39
|
+
size += chunk.length
|
|
40
|
+
if (size > MAX_RESPONSE_BYTES) { request.destroy(); settle({ kind: 'response', statusCode: response.statusCode, body: null }) } else chunks.push(chunk)
|
|
41
|
+
})
|
|
42
|
+
response.on('end', () => {
|
|
43
|
+
let body = null
|
|
44
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { body = null }
|
|
45
|
+
settle({ kind: 'response', statusCode: response.statusCode, body: isPlainObject(body) ? body : null })
|
|
46
|
+
})
|
|
47
|
+
response.on('error', () => settle({ kind: 'error', code: 'response-failed' }))
|
|
48
|
+
})
|
|
49
|
+
const timer = setTimeout(() => { request.destroy(); settle({ kind: 'timeout' }) }, timeoutMs)
|
|
50
|
+
request.on('timeout', () => { request.destroy(); settle({ kind: 'timeout' }) })
|
|
51
|
+
request.on('error', (error) => settle(error.code === 'ECONNREFUSED' ? { kind: 'refused' } : { kind: 'error', code: error.code ?? 'request-failed' }))
|
|
52
|
+
request.on('close', () => clearTimeout(timer))
|
|
53
|
+
request.end(text ?? undefined)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// { kind: 'health', body } only for a well-formed health document; any other
|
|
58
|
+
// answer on the port is { kind: 'foreign' }: occupied, by something else.
|
|
59
|
+
export async function probeHealth({ host, port, timeoutMs }) {
|
|
60
|
+
const answer = await requestLoopback({ host, port, method: 'GET', path: '/health', ...(timeoutMs === undefined ? {} : { timeoutMs }) })
|
|
61
|
+
if (answer.kind !== 'response') return answer
|
|
62
|
+
const body = answer.body
|
|
63
|
+
const wellFormed = answer.statusCode === 200 && body?.schema === HEALTH_SCHEMA && typeof body.serviceName === 'string' && typeof body.workspaceId === 'string' && typeof body.runtimeId === 'string'
|
|
64
|
+
&& Number.isInteger(body.pid) && typeof body.host === 'string' && Number.isInteger(body.port) && typeof body.executableDigest === 'string'
|
|
65
|
+
return wellFormed ? { kind: 'health', body } : { kind: 'foreign', statusCode: answer.statusCode }
|
|
66
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { firstString, parseArgs, resolveProjectConfig } from '../../project/config.mjs'
|
|
6
|
+
import { runMaintenanceService } from './service.mjs'
|
|
7
|
+
|
|
8
|
+
// The process of the maintenance service. `start` runs it as a child; a unit
|
|
9
|
+
// installed at the operating-system level runs it with `--startup`.
|
|
10
|
+
//
|
|
11
|
+
// --project=<absolute atelier.project.json> required
|
|
12
|
+
// --data-root=<absolute directory> optional; otherwise the pointer, the overlay or the platform default
|
|
13
|
+
// --runtime-id=<identifier> given by `start`, so it can recognise the child it created
|
|
14
|
+
// --interval-ms=<milliseconds> optional
|
|
15
|
+
// --startup run by an operating-system unit; needs a consent that covers startup
|
|
16
|
+
// --adapter=obsidian-cli the editor adapter; there is no default
|
|
17
|
+
//
|
|
18
|
+
// Without `--adapter` the process refuses before it listens or ticks: reaching
|
|
19
|
+
// a running app is a decision of whoever starts the service, never a default.
|
|
20
|
+
|
|
21
|
+
// Reaching a real app happens here and nowhere else in the service: the
|
|
22
|
+
// production probe and the CLI transport are imported only once `--adapter`
|
|
23
|
+
// selected them. The editor adapter is constructed only for an app that meets
|
|
24
|
+
// the minimum version; below it, or when the version cannot be read, the
|
|
25
|
+
// factory refuses, the engine records that reason and nothing is published.
|
|
26
|
+
const ADAPTERS = Object.freeze({
|
|
27
|
+
'obsidian-cli': async () => {
|
|
28
|
+
const [{ createObsidianCliAdapter }, { createProductionAppProbe }, { createQualifiedAdapterFactory }] = await Promise.all([
|
|
29
|
+
import('../../projection/obsidian/publication/transport.mjs'), import('./app-production-seams.mjs'), import('./app-capability.mjs'),
|
|
30
|
+
])
|
|
31
|
+
const adapterFactory = createQualifiedAdapterFactory({ appProbe: createProductionAppProbe(), createAdapter: () => createObsidianCliAdapter() })
|
|
32
|
+
return { adapterFactory, appStatus: () => { const known = adapterFactory.lastQualification(); return known === null ? null : { outcome: known.outcome, reason: known.reason, version: known.version, floor: known.floor } } }
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const SERVICE_ENTRY_PATH = fileURLToPath(import.meta.url)
|
|
37
|
+
export const EXIT_REFUSED = 2
|
|
38
|
+
|
|
39
|
+
const log = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
|
|
40
|
+
|
|
41
|
+
// The common body of a service process: run, end cleanly on a signal, exit 0
|
|
42
|
+
// after a clean stop and EXIT_REFUSED when the service refused to run.
|
|
43
|
+
export async function runServiceProcess(options) {
|
|
44
|
+
try {
|
|
45
|
+
const service = await runMaintenanceService({ log, ...options })
|
|
46
|
+
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { void service.shutdown(`signal-${signal}`) })
|
|
47
|
+
await service.done
|
|
48
|
+
// Everything durable is on disk by now; nothing left over may keep a stopped service's PID alive.
|
|
49
|
+
process.exit(0)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
log({ at: new Date().toISOString(), event: 'refused', code: error?.code ?? 'untyped-error', message: String(error?.message ?? error) })
|
|
52
|
+
process.exitCode = typeof error?.code === 'string' ? EXIT_REFUSED : 1
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
|
|
57
|
+
const args = parseArgs(argv)
|
|
58
|
+
const configPath = firstString(args.project)
|
|
59
|
+
if (!configPath || !path.isAbsolute(configPath)) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
|
|
60
|
+
const dataRoot = firstString(args['data-root']) ?? undefined
|
|
61
|
+
const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
|
|
62
|
+
if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
|
|
63
|
+
return {
|
|
64
|
+
loadProject: () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
|
|
65
|
+
...(dataRoot === undefined ? {} : { dataRoot }), ...(intervalMs === undefined ? {} : { intervalMs }),
|
|
66
|
+
...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
|
|
67
|
+
startup: args.startup === true, adapter: firstString(args.adapter),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(SERVICE_ENTRY_PATH) } catch { return false } })()
|
|
72
|
+
|
|
73
|
+
if (invokedDirectly) {
|
|
74
|
+
let options = null
|
|
75
|
+
try {
|
|
76
|
+
options = serviceOptionsFromArgv(process.argv.slice(2))
|
|
77
|
+
if (!options.adapter || !Object.hasOwn(ADAPTERS, options.adapter)) throw Object.assign(new Error('no editor adapter was selected; pass --adapter explicitly'), { code: 'service-adapter-not-selected' })
|
|
78
|
+
} catch (error) {
|
|
79
|
+
log({ at: new Date().toISOString(), event: 'refused', code: error.code ?? 'untyped-error', message: error.message })
|
|
80
|
+
process.exitCode = EXIT_REFUSED
|
|
81
|
+
options = null
|
|
82
|
+
}
|
|
83
|
+
if (options) {
|
|
84
|
+
const { adapter, ...rest } = options
|
|
85
|
+
const [{ loadContributions }, { createObsidianRegistry }] = await Promise.all([import('./contributions.mjs'), import('./extension-points.mjs')])
|
|
86
|
+
const registry = createObsidianRegistry({ contributions: await loadContributions() })
|
|
87
|
+
await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...(await ADAPTERS[adapter]()), engineOptions: { extensions: registry.extensions } })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
5
|
+
import { ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
|
|
6
|
+
import { canonicalJson, closedObject } from './documents.mjs'
|
|
7
|
+
import { refuse } from './errors.mjs'
|
|
8
|
+
import { LOOPBACK_HOSTS } from './service-client.mjs'
|
|
9
|
+
|
|
10
|
+
// Private documents of the maintenance service of one workspace, under
|
|
11
|
+
//
|
|
12
|
+
// <data>/obsidian/<workspace-id>/state/service/
|
|
13
|
+
// runtime.json the adapter record of the running service (service-state v1)
|
|
14
|
+
// settings.json what this machine chose: loopback host, port, startup consent
|
|
15
|
+
// last-error.json the last tick that failed for a reason nobody typed
|
|
16
|
+
// service.log the operational log of the service process
|
|
17
|
+
// start-lock/ serializes `start` for this workspace
|
|
18
|
+
//
|
|
19
|
+
// Owner-only, replaced atomically, outside every repository and every vault.
|
|
20
|
+
// Every document is validated on every read. One that does not validate, or
|
|
21
|
+
// that names another workspace, service or state location, refuses: it is
|
|
22
|
+
// never repaired, never adopted and never overwritten without a person.
|
|
23
|
+
|
|
24
|
+
export const SERVICE_SETTINGS_SCHEMA = 'atelier-obsidian-service-settings/v1'
|
|
25
|
+
export const SERVICE_ERROR_SCHEMA = 'atelier-obsidian-service-last-error/v1'
|
|
26
|
+
export const CONSENT_COVERAGES = Object.freeze(['service', 'service-and-startup'])
|
|
27
|
+
|
|
28
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
29
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
30
|
+
const BEARER = /^[A-Za-z0-9_-]{43}$/
|
|
31
|
+
|
|
32
|
+
export function serviceNameFor(workspaceId) {
|
|
33
|
+
const name = `atelier-obsidian-${workspaceId}`
|
|
34
|
+
if (!IDENTIFIER.test(name)) refuse('invalid-workspace-identity', 'the workspace identity cannot name a service')
|
|
35
|
+
return name
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function servicePaths(workspaceRoot) {
|
|
39
|
+
const directory = path.join(workspaceRoot, 'state', 'service')
|
|
40
|
+
return {
|
|
41
|
+
stateLocation: path.join(workspaceRoot, 'state'), directory, record: path.join(directory, 'runtime.json'), settings: path.join(directory, 'settings.json'),
|
|
42
|
+
lastError: path.join(directory, 'last-error.json'), log: path.join(directory, 'service.log'), startLock: path.join(directory, 'start-lock'),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const serviceDirectory = (workspaceRoot) => ensureContainedPrivateDirectory({ workspaceRoot, directory: servicePaths(workspaceRoot).directory, label: 'Obsidian service state' })
|
|
47
|
+
|
|
48
|
+
function readJson(file, code, label) {
|
|
49
|
+
let text
|
|
50
|
+
try { text = readRegularTextNoFollow(file) } catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT') return null
|
|
52
|
+
refuse(code, `${label} cannot be read`, { cause: error.code ?? String(error.message) })
|
|
53
|
+
}
|
|
54
|
+
try { return JSON.parse(text) } catch { return refuse(code, `${label} is not JSON`) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The identity of what runs: the service entry module and the digest of its bytes.
|
|
58
|
+
export function executableIdentity(entryPath) {
|
|
59
|
+
const resolved = fs.realpathSync(entryPath)
|
|
60
|
+
return { path: resolved, digest: `sha256:${createHash('sha256').update(fs.readFileSync(resolved)).digest('hex')}` }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// The adapter record
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export function validateServiceRecord(document, { workspaceRoot, workspaceId }) {
|
|
68
|
+
const code = 'invalid-service-record'
|
|
69
|
+
try { assertObsidianContract('service-state', document) } catch (error) {
|
|
70
|
+
if (error instanceof ObsidianContractRefusal) refuse(code, 'the service record does not satisfy its contract', { errors: error.detail?.errors ?? [] })
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
if (document.workspaceId !== workspaceId || document.serviceName !== serviceNameFor(workspaceId)) refuse(code, 'the service record belongs to another workspace or service')
|
|
74
|
+
if (document.stateLocation !== servicePaths(workspaceRoot).stateLocation) refuse(code, 'the service record names another state location')
|
|
75
|
+
if (!path.isAbsolute(document.executable.path)) refuse(code, 'the service record names an executable that is not an absolute path')
|
|
76
|
+
// The bearer of the running service lives here and nowhere else.
|
|
77
|
+
closedObject(document.ext, { required: ['bearer'] }, code, 'the private part of the service record')
|
|
78
|
+
if (typeof document.ext.bearer !== 'string' || !BEARER.test(document.ext.bearer)) refuse(code, 'the service record carries no usable bearer')
|
|
79
|
+
return document
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readServiceRecord({ workspaceRoot, workspaceId }) {
|
|
83
|
+
const document = readJson(servicePaths(workspaceRoot).record, 'invalid-service-record', 'the service record')
|
|
84
|
+
return document === null ? null : validateServiceRecord(document, { workspaceRoot, workspaceId })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeServiceRecord({ workspaceRoot, workspaceId, record }) {
|
|
88
|
+
validateServiceRecord(record, { workspaceRoot, workspaceId })
|
|
89
|
+
atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'runtime.json'), canonicalJson(record))
|
|
90
|
+
return record
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Removes the record only while it still names this runtime and PID. Nothing else is ever removed here.
|
|
94
|
+
export function removeServiceRecord({ workspaceRoot, workspaceId, runtimeId, pid }) {
|
|
95
|
+
let current
|
|
96
|
+
try { current = readServiceRecord({ workspaceRoot, workspaceId }) } catch { return false }
|
|
97
|
+
if (current === null || current.runtimeId !== runtimeId || current.pid !== pid) return false
|
|
98
|
+
try { fs.unlinkSync(servicePaths(workspaceRoot).record); return true } catch (error) { if (error.code === 'ENOENT') return false; throw error }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What may be shown: everything but the bearer.
|
|
102
|
+
export function publicRecord(record) {
|
|
103
|
+
if (record === null) return null
|
|
104
|
+
const { ext: _private, ...shown } = record
|
|
105
|
+
return shown
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Machine-specific service settings
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function validateServiceSettings(document, workspaceId) {
|
|
113
|
+
const code = 'invalid-service-settings'
|
|
114
|
+
closedObject(document, { required: ['schema', 'workspaceId', 'host', 'port', 'consent', 'updatedAt'] }, code, 'service settings')
|
|
115
|
+
if (document.schema !== SERVICE_SETTINGS_SCHEMA || document.workspaceId !== workspaceId) refuse(code, 'service settings name an unknown schema or another workspace')
|
|
116
|
+
if (!LOOPBACK_HOSTS.includes(document.host)) refuse(code, 'the service host must be the literal 127.0.0.1 or ::1')
|
|
117
|
+
if (!Number.isInteger(document.port) || document.port < 1024 || document.port > 65535) refuse(code, 'the service port must be between 1024 and 65535')
|
|
118
|
+
closedObject(document.consent, { required: ['grantedAt', 'actor', 'coverage'] }, code, 'the startup consent')
|
|
119
|
+
if (!TIMESTAMP.test(document.consent.grantedAt) || typeof document.consent.actor !== 'string' || !IDENTIFIER.test(document.consent.actor) || !CONSENT_COVERAGES.includes(document.consent.coverage)) refuse(code, 'the startup consent is malformed')
|
|
120
|
+
if (typeof document.updatedAt !== 'string' || !TIMESTAMP.test(document.updatedAt)) refuse(code, 'updatedAt must be a UTC timestamp')
|
|
121
|
+
return document
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function readServiceSettings({ workspaceRoot, workspaceId }) {
|
|
125
|
+
const document = readJson(servicePaths(workspaceRoot).settings, 'invalid-service-settings', 'service settings')
|
|
126
|
+
return document === null ? null : validateServiceSettings(document, workspaceId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function writeServiceSettings({ workspaceRoot, workspaceId, settings }) {
|
|
130
|
+
validateServiceSettings(settings, workspaceId)
|
|
131
|
+
atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'settings.json'), canonicalJson(settings))
|
|
132
|
+
return settings
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// The last untyped tick error, and the operational log
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function validateLastError(document, workspaceId) {
|
|
140
|
+
const code = 'invalid-service-last-error'
|
|
141
|
+
closedObject(document, { required: ['schema', 'workspaceId', 'runtimeId', 'code', 'name', 'at', 'consecutiveFailures', 'totalFailures', 'resolvedAt'] }, code, 'the last service error')
|
|
142
|
+
const ok = document.schema === SERVICE_ERROR_SCHEMA && document.workspaceId === workspaceId && typeof document.runtimeId === 'string' && IDENTIFIER.test(document.runtimeId)
|
|
143
|
+
&& typeof document.code === 'string' && document.code.length <= 64 && typeof document.name === 'string' && document.name.length <= 64 && TIMESTAMP.test(document.at)
|
|
144
|
+
&& Number.isInteger(document.consecutiveFailures) && document.consecutiveFailures >= 0 && Number.isInteger(document.totalFailures) && document.totalFailures >= 1
|
|
145
|
+
&& (document.resolvedAt === null || TIMESTAMP.test(document.resolvedAt))
|
|
146
|
+
if (!ok) refuse(code, 'the last service error is malformed')
|
|
147
|
+
return document
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function readLastServiceError({ workspaceRoot, workspaceId }) {
|
|
151
|
+
const document = readJson(servicePaths(workspaceRoot).lastError, 'invalid-service-last-error', 'the last service error')
|
|
152
|
+
return document === null ? null : validateLastError(document, workspaceId)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// An error code and class only: a message can carry a path or a title, and goes to the private log instead.
|
|
156
|
+
export function writeLastServiceError({ workspaceRoot, workspaceId, document }) {
|
|
157
|
+
validateLastError(document, workspaceId)
|
|
158
|
+
atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'last-error.json'), canonicalJson(document))
|
|
159
|
+
return document
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The log is bounded: past the ceiling it becomes the one previous log, and a new one begins.
|
|
163
|
+
export const SERVICE_LOG_MAX_BYTES = 1024 * 1024
|
|
164
|
+
|
|
165
|
+
export function openServiceLog(workspaceRoot) {
|
|
166
|
+
const file = path.join(serviceDirectory(workspaceRoot), 'service.log')
|
|
167
|
+
try { if (fs.lstatSync(file).size > SERVICE_LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
168
|
+
return { file, descriptor: openRegularFileNoFollow(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600) }
|
|
169
|
+
}
|