@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.10
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 +372 -0
- package/README.md +402 -205
- 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-repository-observation.v1.schema.json +163 -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/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +49 -0
- package/docs/atelier-runtime.md +28 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/audience-visibility.md +9 -0
- package/docs/blocks/claims.md +61 -0
- package/docs/blocks/conformance-admission.md +15 -0
- package/docs/blocks/will-not-do.md +18 -0
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/continuity.md +3 -4
- package/docs/decisions.md +167 -0
- package/docs/design.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 +99 -16
- 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/knowledge-graph.md +8 -4
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +328 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +690 -0
- package/docs/obsidian.md +423 -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 +174 -12
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +146 -5
- 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-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -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/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +86 -10
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-open/SKILL.md +11 -5
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -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-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-open/SKILL.md +11 -5
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -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/content-rules.mjs +283 -20
- package/src/boundary/policy.mjs +213 -91
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +78 -10
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +348 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +17 -9
- package/src/commands/init.mjs +46 -6
- package/src/commands/obsidian.mjs +371 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/server.mjs +1 -4
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +107 -0
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +35 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +206 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +176 -316
- package/src/graph/knowledge-graph.mjs +643 -43
- package/src/guides/contracts.mjs +54 -0
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +41 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +112 -52
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +180 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +120 -0
- 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 +160 -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/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/obsidian/app-capability.mjs +129 -0
- package/src/runtime/obsidian/app-production-seams.mjs +105 -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 +263 -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-entry-path.mjs +9 -0
- package/src/runtime/obsidian/service-main.mjs +91 -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/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- package/src/server/local-sidecar.mjs +125 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +4 -2
- package/src/skills/steward.mjs +780 -0
- package/src/support/feedback-report.mjs +4 -3
- 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 +65 -29
- 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,34 @@
|
|
|
1
|
+
// Continuous maintenance of Obsidian views: typed enablement, private machine
|
|
2
|
+
// settings, observation by digest, pending edits and per-view freshness.
|
|
3
|
+
// The engine starts no process, timer or listener; `tick()` is explicit. The
|
|
4
|
+
// owned lifecycle (start / status / stop of one loopback service per
|
|
5
|
+
// workspace) is separate, below, and is the only thing here that does.
|
|
6
|
+
export { ObsidianMaintenanceRefusal } from './errors.mjs'
|
|
7
|
+
export { DISABLED_REASONS, ENABLEMENT_STATES, readObsidianEnablement } from './enablement.mjs'
|
|
8
|
+
export {
|
|
9
|
+
LOCAL_POINTER_SCHEMA, MACHINE_SETTINGS_SCHEMA, MAINTENANCE_MODES, authorizeAutomaticApply, defaultDataRoot, defaultMachineSettings, ensureWorkspaceIdentity,
|
|
10
|
+
installApplyPolicy, localPointerPath, protectedRoots, readInstalledApplyPolicy, readLocalPointer, readMachineSettings, resolveDataRoot, revokeApplyPolicy, workspaceStateRoot, writeLocalPointer, writeMachineSettings,
|
|
11
|
+
} from './machine-settings.mjs'
|
|
12
|
+
export { APPLY_RESULT_STATUSES, BUILT_IN_OPERATIONS, EXTENSION_KINDS, REPLACEABLE_OPERATIONS, UNAVAILABLE_APPLY_OPERATION, createCommandOperations, createMaintenanceExtensions, createObsidianRegistry } from './extension-points.mjs'
|
|
13
|
+
export { CONTRIBUTIONS_DIRECTORY, loadContributions } from './contributions.mjs'
|
|
14
|
+
export { APP_OUTCOMES, MINIMUM_APP_VERSION, compareAppVersions, createQualifiedAdapterFactory, meetsMinimumAppVersion, parseAppVersion, qualifyApp } from './app-capability.mjs'
|
|
15
|
+
export { OPENING_OUTCOMES, describeOutcome, openScope, scopeReport } from './opening.mjs'
|
|
16
|
+
export {
|
|
17
|
+
CHANGE_CLASSES, CLOSED_EDIT_STATES, EDIT_STATES, FRESHNESS_SCHEMA, FRESHNESS_STATES, LATE_WRITERS_SCHEMA, OPEN_EDIT_STATES, PENDING_EDITS_SCHEMA,
|
|
18
|
+
createMaintenanceStateStore, validateFreshness, validateLateWriters, validatePendingEdits,
|
|
19
|
+
} from './state-store.mjs'
|
|
20
|
+
export { listConfigFiles, listSourceFiles, listVaultNotes, reconcile } from './observation.mjs'
|
|
21
|
+
export { createFsWatcherFactory, createNullWatcherFactory } from './watchers.mjs'
|
|
22
|
+
export { DEFAULT_ELIGIBILITY, assetEligibilityFor, createProductionSeams } from './pipeline.mjs'
|
|
23
|
+
export { DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, DEFAULT_LATE_WRITER_WINDOW_MS, DEFAULT_RETRY_INTERVAL_MS, createMaintenanceEngine } from './engine.mjs'
|
|
24
|
+
export { ENGINE_LOCK_DIRECTORY, LOCK_TICKET_SCHEMA, acquirePrivateGenerationLock, createAbandonmentProof, inspectPrivateGenerationLock } from './private-lock.mjs'
|
|
25
|
+
export { DEFAULT_MAX_BACKOFF_MS, DEFAULT_TICK_INTERVAL_MS, createTickLoop } from './tick-loop.mjs'
|
|
26
|
+
export { HEALTH_SCHEMA, LOOPBACK_HOSTS, probeHealth, requestLoopback } from './service-client.mjs'
|
|
27
|
+
export {
|
|
28
|
+
CONSENT_COVERAGES, SERVICE_ERROR_SCHEMA, SERVICE_SETTINGS_SCHEMA, executableIdentity, publicRecord, readLastServiceError, readServiceRecord, readServiceSettings,
|
|
29
|
+
removeServiceRecord, serviceNameFor, servicePaths, validateServiceRecord, writeServiceRecord, writeServiceSettings,
|
|
30
|
+
} from './service-record.mjs'
|
|
31
|
+
export { MAX_REQUEST_BYTES, SERVICE_OPERATIONS, createServiceServer } from './service-server.mjs'
|
|
32
|
+
export { DEFAULT_SHUTDOWN_GRACE_MS, SERVICE_STATUS_SCHEMA, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
|
|
33
|
+
export { DEFAULT_START_TIMEOUT_MS, DEFAULT_STOP_TIMEOUT_MS, SERVICE_STATES, readServiceStatusDocument, requestServiceTick, serviceStatus, startService, stopService } from './lifecycle.mjs'
|
|
34
|
+
export { STARTUP_PLATFORMS, buildStartupAdapter } from './startup-adapters.mjs'
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import childProcess from 'node:child_process'
|
|
2
|
+
import { randomBytes as cryptoRandomBytes } from 'node:crypto'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import net from 'node:net'
|
|
5
|
+
import { isoTime } from './documents.mjs'
|
|
6
|
+
import { refuse } from './errors.mjs'
|
|
7
|
+
import { ensureWorkspaceIdentity } from './machine-settings.mjs'
|
|
8
|
+
import { acquirePrivateGenerationLock, createAbandonmentProof, isProcessAlive } from './private-lock.mjs'
|
|
9
|
+
import { processRunsRecordedExecutable } from './process-identity.mjs'
|
|
10
|
+
import { DEFAULT_PROBE_TIMEOUT_MS, LOOPBACK_HOSTS, probeHealth, requestLoopback } from './service-client.mjs'
|
|
11
|
+
import { SERVICE_ENTRY_PATH } from './service-entry-path.mjs'
|
|
12
|
+
import {
|
|
13
|
+
CONSENT_COVERAGES, SERVICE_SETTINGS_SCHEMA, executableIdentity, openServiceLog, publicRecord, readServiceRecord, readServiceSettings, removeServiceRecord,
|
|
14
|
+
serviceNameFor, servicePaths, writeServiceSettings,
|
|
15
|
+
} from './service-record.mjs'
|
|
16
|
+
import { resolveServiceWorkspace } from './service.mjs'
|
|
17
|
+
|
|
18
|
+
// start / status / stop of the maintenance service of one workspace, as
|
|
19
|
+
// docs/local-services.md describes them.
|
|
20
|
+
//
|
|
21
|
+
// Ownership is proven, never assumed. A running service is ours only when the
|
|
22
|
+
// owner-only record and the health answer agree on service name, workspace,
|
|
23
|
+
// runtime identifier, PID, executable digest and literal loopback address,
|
|
24
|
+
// and that PID is alive. Anything else that answers on the port is
|
|
25
|
+
// `occupied`: it is not adopted, not stopped and not replaced. One case is
|
|
26
|
+
// told apart from it: a listener that accepts and does not answer in time
|
|
27
|
+
// while the recorded PID is alive and provably runs the recorded executable is
|
|
28
|
+
// our own service in a long tick. That is `busy`: never adopted, never
|
|
29
|
+
// stopped and never started over; it is simply asked again later. A record whose
|
|
30
|
+
// address refuses connections is `stale-record` when its PID is gone and
|
|
31
|
+
// `pid-not-ours` when that PID is alive (the number was reused, or a process
|
|
32
|
+
// outlived its listener): such a PID is never signalled.
|
|
33
|
+
//
|
|
34
|
+
// Nothing here looks a process up by port, name or pattern, and nothing here
|
|
35
|
+
// removes anything but the one generated record.
|
|
36
|
+
|
|
37
|
+
export const SERVICE_STATES = Object.freeze(['stopped', 'healthy', 'busy', 'occupied', 'stale-record', 'pid-not-ours'])
|
|
38
|
+
export const DEFAULT_START_TIMEOUT_MS = 20 * 1000
|
|
39
|
+
export const DEFAULT_STOP_TIMEOUT_MS = 45 * 1000
|
|
40
|
+
|
|
41
|
+
const AGREEMENT = ['serviceName', 'workspaceId', 'runtimeId', 'pid', 'host', 'port']
|
|
42
|
+
const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms) })
|
|
43
|
+
|
|
44
|
+
// The decisions the lifecycle oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
|
|
45
|
+
export const LIFECYCLE_PRIMITIVES = Object.freeze({
|
|
46
|
+
// Every field the record and the health answer must agree on; the names of those that differ.
|
|
47
|
+
disagreements(record, health) {
|
|
48
|
+
const differing = AGREEMENT.filter((field) => record[field] !== health[field])
|
|
49
|
+
if (record.executable.digest !== health.executableDigest) differing.push('executableDigest')
|
|
50
|
+
return differing
|
|
51
|
+
},
|
|
52
|
+
// Whether start may report a running service as this workspace's own instead of creating one.
|
|
53
|
+
isOurs: (status) => status.state === 'healthy',
|
|
54
|
+
// Whether start gives up because something unproven answers on the port.
|
|
55
|
+
refusesOccupied: (status) => status.state === 'occupied',
|
|
56
|
+
// Whether a stop may be sent at all.
|
|
57
|
+
mayStop: (status) => status.state === 'healthy',
|
|
58
|
+
// Whether the live recorded PID provably runs the recorded executable, when health did not answer in time.
|
|
59
|
+
provesOurProcess: (record) => processRunsRecordedExecutable(record),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
function context({ loadProject, dataRoot, env, platform, create = false, randomBytes }) {
|
|
63
|
+
if (typeof loadProject !== 'function') throw new TypeError('the service lifecycle needs loadProject')
|
|
64
|
+
const project = loadProject()
|
|
65
|
+
if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
|
|
66
|
+
const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create })
|
|
67
|
+
return { project, workspace }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function evaluate({ workspaceRoot, workspaceId }, { probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, alive = isProcessAlive, rules = LIFECYCLE_PRIMITIVES } = {}) {
|
|
71
|
+
// A malformed or foreign record refuses right here, typed; it is never repaired and never adopted.
|
|
72
|
+
const record = readServiceRecord({ workspaceRoot, workspaceId })
|
|
73
|
+
const settings = readServiceSettings({ workspaceRoot, workspaceId })
|
|
74
|
+
const address = record ? { host: record.host, port: record.port } : settings ? { host: settings.host, port: settings.port } : null
|
|
75
|
+
const base = { workspaceId, serviceName: serviceNameFor(workspaceId), address, record, logPath: servicePaths(workspaceRoot).log }
|
|
76
|
+
if (address === null) return { ...base, state: 'stopped', reason: 'never-started' }
|
|
77
|
+
const answer = await probeHealth({ ...address, timeoutMs: probeTimeoutMs })
|
|
78
|
+
if (record === null) return answer.kind === 'refused' ? { ...base, state: 'stopped', reason: 'no-record-and-nothing-listens' } : { ...base, state: 'occupied', reason: 'a-listener-without-a-record', answer: answer.kind }
|
|
79
|
+
if (answer.kind === 'refused') return alive(record.pid) ? { ...base, state: 'pid-not-ours', reason: 'recorded-pid-is-alive-but-nothing-listens' } : { ...base, state: 'stale-record', reason: 'recorded-pid-is-gone' }
|
|
80
|
+
// Only a timeout can be a busy service: a refusal, a foreign answer or a failed connection never is.
|
|
81
|
+
if (answer.kind === 'timeout' && alive(record.pid) && rules.provesOurProcess(record) === true) return { ...base, state: 'busy', reason: 'our-service-did-not-answer-health-in-time', answer: answer.kind }
|
|
82
|
+
if (answer.kind !== 'health') return { ...base, state: 'occupied', reason: answer.kind === 'timeout' ? 'listener-did-not-answer-in-time' : 'listener-is-not-this-service', answer: answer.kind }
|
|
83
|
+
const differing = rules.disagreements(record, answer.body)
|
|
84
|
+
if (differing.length > 0) return { ...base, state: 'occupied', reason: 'health-identity-differs', disagreements: differing }
|
|
85
|
+
if (!alive(record.pid)) return { ...base, state: 'occupied', reason: 'recorded-pid-is-gone-but-something-answers' }
|
|
86
|
+
return { ...base, state: 'healthy', reason: 'record-and-health-agree', health: answer.body }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const shown = ({ record, ...status }) => ({ ...status, record: publicRecord(record) })
|
|
90
|
+
|
|
91
|
+
export async function serviceStatus({ loadProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
92
|
+
const { workspace } = context({ loadProject, dataRoot, env, platform })
|
|
93
|
+
if (!workspace?.workspaceRoot) return { state: 'stopped', reason: 'workspace-not-prepared', workspaceId: workspace?.workspaceId ?? null, record: null, address: null }
|
|
94
|
+
return shown(await evaluate(workspace, { probeTimeoutMs, alive, rules }))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A port nobody listens on right now, chosen once and then recorded, so later starts, status and an installed unit all name the same one.
|
|
98
|
+
function freeLoopbackPort(host) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const server = net.createServer()
|
|
101
|
+
server.once('error', reject)
|
|
102
|
+
server.listen({ host, port: 0, exclusive: true }, () => { const { port } = server.address(); server.close(() => resolve(port)) })
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function resolveSettings({ workspaceRoot, workspaceId }, { host, port, consent, now }) {
|
|
107
|
+
const current = readServiceSettings({ workspaceRoot, workspaceId })
|
|
108
|
+
if (host !== undefined && !LOOPBACK_HOSTS.includes(host)) refuse('service-address-not-loopback', 'the service host must be the literal 127.0.0.1 or ::1')
|
|
109
|
+
if (port !== undefined && (!Number.isInteger(port) || port < 1024 || port > 65535)) refuse('service-address-not-loopback', 'the service port must be between 1024 and 65535')
|
|
110
|
+
if (consent !== undefined && (typeof consent?.actor !== 'string' || !CONSENT_COVERAGES.includes(consent.coverage ?? 'service'))) refuse('startup-consent-required', 'a consent names its actor and what it covers')
|
|
111
|
+
if (current === null && consent === undefined) refuse('startup-consent-required', 'the first start of this workspace needs an explicit consent naming its actor')
|
|
112
|
+
const nextHost = host ?? current?.host ?? '127.0.0.1'
|
|
113
|
+
const next = {
|
|
114
|
+
schema: SERVICE_SETTINGS_SCHEMA, workspaceId, host: nextHost, port: port ?? (current && current.host === nextHost ? current.port : await freeLoopbackPort(nextHost)),
|
|
115
|
+
consent: consent === undefined || (current && current.consent.actor === consent.actor && current.consent.coverage === (consent.coverage ?? 'service')) ? current.consent : { grantedAt: now, actor: consent.actor, coverage: consent.coverage ?? 'service' },
|
|
116
|
+
updatedAt: now,
|
|
117
|
+
}
|
|
118
|
+
const changed = current === null || ['host', 'port'].some((key) => current[key] !== next[key]) || current.consent !== next.consent
|
|
119
|
+
return changed ? writeServiceSettings({ workspaceRoot, workspaceId, settings: next }) : current
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
123
|
+
const {
|
|
124
|
+
loadProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
|
|
125
|
+
startTimeoutMs = DEFAULT_START_TIMEOUT_MS, probeTimeoutMs, clock = () => new Date(), env = process.env, platform = process.platform,
|
|
126
|
+
spawn = childProcess.spawn, execPath = process.execPath, alive = isProcessAlive, randomBytes = cryptoRandomBytes,
|
|
127
|
+
} = options
|
|
128
|
+
const { project, workspace } = context({ loadProject, dataRoot, env, platform, create: true })
|
|
129
|
+
const { workspaceRoot, workspaceId } = workspace
|
|
130
|
+
const deadline = Date.now() + startTimeoutMs
|
|
131
|
+
|
|
132
|
+
// One start at a time per workspace. A start is bounded by its own deadline, so a holder far older than that is gone.
|
|
133
|
+
const proveAbandoned = createAbandonmentProof({ alive, maxAgeMs: startTimeoutMs * 4 })
|
|
134
|
+
let lock
|
|
135
|
+
for (;;) {
|
|
136
|
+
lock = await acquirePrivateGenerationLock({ workspaceRoot, directory: servicePaths(workspaceRoot).startLock, workspaceId, purpose: 'service-start', clock, proveAbandoned })
|
|
137
|
+
if (lock.acquired) break
|
|
138
|
+
const meanwhile = await evaluate(workspace, { probeTimeoutMs, alive, rules })
|
|
139
|
+
if (rules.isOurs(meanwhile)) return { ...shown(meanwhile), started: false, alreadyRunning: true }
|
|
140
|
+
if (meanwhile.state === 'busy') return { ...shown(meanwhile), started: false, alreadyRunning: true, busy: true }
|
|
141
|
+
if (Date.now() >= deadline) refuse('service-start-in-progress', 'another start of this workspace holds the start lock', { reason: lock.reason })
|
|
142
|
+
await sleep(100)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let child = null
|
|
146
|
+
try {
|
|
147
|
+
const before = await evaluate(workspace, { probeTimeoutMs, alive, rules })
|
|
148
|
+
if (rules.isOurs(before)) return { ...shown(before), started: false, alreadyRunning: true }
|
|
149
|
+
// Running, and in a long tick: nothing is started beside it and nothing replaces it.
|
|
150
|
+
if (before.state === 'busy') return { ...shown(before), started: false, alreadyRunning: true, busy: true }
|
|
151
|
+
if (rules.refusesOccupied(before)) refuse('service-port-occupied', 'something that is not this service answers on the loopback port; it is never taken over', { reason: before.reason, address: before.address })
|
|
152
|
+
// `stale-record` and `pid-not-ours`: nothing listens, so the recorded runtime is not serving. Its PID is never signalled;
|
|
153
|
+
// the new service replaces the record once it listens. Nothing under recovery or staging is touched on the way.
|
|
154
|
+
const settings = await resolveSettings(workspace, { host, port, consent, now: isoTime(clock) })
|
|
155
|
+
if (before.address && (before.address.host !== settings.host || before.address.port !== settings.port)) {
|
|
156
|
+
const moved = await probeHealth({ host: settings.host, port: settings.port, timeoutMs: probeTimeoutMs })
|
|
157
|
+
if (moved.kind !== 'refused') refuse('service-port-occupied', 'something already answers on the selected loopback port; it is never taken over', { address: { host: settings.host, port: settings.port } })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const runtimeId = `rt-${randomBytes(16).toString('hex')}`
|
|
161
|
+
const executable = executableIdentity(entryPath)
|
|
162
|
+
const log = openServiceLog(workspaceRoot)
|
|
163
|
+
try {
|
|
164
|
+
const args = [executable.path, `--project=${project.configPath}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
|
|
165
|
+
// No shell. Detached only when the service is meant to outlive the command that starts it.
|
|
166
|
+
child = spawn(execPath, args, { detached, shell: false, windowsHide: true, stdio: ['ignore', log.descriptor, log.descriptor], env })
|
|
167
|
+
} finally {
|
|
168
|
+
fs.closeSync(log.descriptor)
|
|
169
|
+
}
|
|
170
|
+
let exited = null
|
|
171
|
+
child.once('exit', (code, signal) => { exited = { code, signal } })
|
|
172
|
+
child.once('error', (error) => { exited = { code: null, signal: null, error: error.code ?? 'spawn-failed' } })
|
|
173
|
+
|
|
174
|
+
// Health must echo the runtime identifier generated here and the PID of the child created here.
|
|
175
|
+
let last = null
|
|
176
|
+
while (Date.now() < deadline && exited === null) {
|
|
177
|
+
last = await evaluate(workspace, { probeTimeoutMs, alive, rules }).catch((error) => ({ state: 'refused', reason: error.code ?? 'untyped-error' }))
|
|
178
|
+
if (last.state === 'healthy' && last.record.runtimeId === runtimeId && last.record.pid === child.pid) {
|
|
179
|
+
if (detached) child.unref()
|
|
180
|
+
return { ...shown(last), started: true, alreadyRunning: false, ...(detached ? {} : { child }) }
|
|
181
|
+
}
|
|
182
|
+
await sleep(50)
|
|
183
|
+
}
|
|
184
|
+
// The child created here wrote the record and went straight into a long first tick: it runs, and is not stopped.
|
|
185
|
+
if (exited === null && last?.state === 'busy' && last.record?.runtimeId === runtimeId && last.record?.pid === child.pid) {
|
|
186
|
+
if (detached) child.unref()
|
|
187
|
+
return { ...shown(last), started: true, alreadyRunning: false, busy: true, ...(detached ? {} : { child }) }
|
|
188
|
+
}
|
|
189
|
+
// Ownership was never proven: only the child created here is stopped, by its handle.
|
|
190
|
+
if (exited === null) { child.kill(); const until = Date.now() + 5000; while (exited === null && Date.now() < until) await sleep(25) }
|
|
191
|
+
removeServiceRecord({ workspaceRoot, workspaceId, runtimeId, pid: child.pid })
|
|
192
|
+
return { state: 'start-failed', started: false, alreadyRunning: false, workspaceId, reason: exited?.error ?? (exited?.code === null || exited === null ? 'health-never-proved-ownership' : `service-exited-${exited.code}`), observed: last?.state ?? null, logPath: log.file }
|
|
193
|
+
} finally {
|
|
194
|
+
lock.release()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
199
|
+
const { loadProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
|
|
200
|
+
const { workspace } = context({ loadProject, dataRoot, env, platform })
|
|
201
|
+
if (!workspace?.workspaceRoot) return { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' }
|
|
202
|
+
const { workspaceRoot, workspaceId } = workspace
|
|
203
|
+
const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
|
|
204
|
+
if (status.state === 'stopped') return { ...shown(status), stopped: false, refused: false }
|
|
205
|
+
// Any disagreement refuses. Nothing is signalled, and the record stays for a person to look at.
|
|
206
|
+
if (status.state === 'busy') return { ...shown(status), stopped: false, refused: true, retry: true, reason: 'service-is-busy-ask-again-later' }
|
|
207
|
+
if (!rules.mayStop(status)) return { ...shown(status), stopped: false, refused: true }
|
|
208
|
+
|
|
209
|
+
const { record } = status
|
|
210
|
+
const answer = await requestLoopback({ host: record.host, port: record.port, method: 'POST', path: '/stop', bearer: record.ext.bearer, payload: { runtimeId: record.runtimeId }, timeoutMs: Math.max(probeTimeoutMs ?? 0, 2 * DEFAULT_PROBE_TIMEOUT_MS) })
|
|
211
|
+
const accepted = answer.kind === 'response' && answer.statusCode === 202 && answer.body?.runtimeId === record.runtimeId && answer.body?.pid === record.pid
|
|
212
|
+
if (!accepted) return { ...shown(status), stopped: false, refused: true, reason: 'stop-was-not-accepted-by-the-proven-runtime' }
|
|
213
|
+
const until = Date.now() + stopTimeoutMs
|
|
214
|
+
while (alive(record.pid) && Date.now() < until) await sleep(50)
|
|
215
|
+
if (alive(record.pid)) {
|
|
216
|
+
// Escalation is opt-in and proven again first: the same runtime and PID must still answer.
|
|
217
|
+
const again = force ? await evaluate(workspace, { probeTimeoutMs, alive, rules }).catch(() => null) : null
|
|
218
|
+
if (!(again?.state === 'healthy' && again.record.runtimeId === record.runtimeId && again.record.pid === record.pid)) return { ...shown(status), stopped: false, refused: true, reason: 'stop-timed-out' }
|
|
219
|
+
kill(record.pid, 'SIGKILL')
|
|
220
|
+
while (alive(record.pid) && Date.now() < until + 5000) await sleep(50)
|
|
221
|
+
}
|
|
222
|
+
// The service removes its own record on the way out; after a hard end it is removed here, and only if it still names that runtime.
|
|
223
|
+
removeServiceRecord({ workspaceRoot, workspaceId, runtimeId: record.runtimeId, pid: record.pid })
|
|
224
|
+
return { state: 'stopped', stopped: true, refused: false, reason: 'stopped-the-proven-runtime', workspaceId, runtimeId: record.runtimeId, pid: record.pid }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// One authenticated request to the proven runtime, and to nothing else: anything but a healthy status is answered
|
|
228
|
+
// with that status and no request is made.
|
|
229
|
+
async function askProvenRuntime(options, rules, { method, operation, timeoutMs }) {
|
|
230
|
+
const { loadProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
|
|
231
|
+
const { workspace } = context({ loadProject, dataRoot, env, platform })
|
|
232
|
+
if (!workspace?.workspaceRoot) return { requested: false, state: 'stopped', reason: 'workspace-not-prepared' }
|
|
233
|
+
const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
|
|
234
|
+
if (status.state !== 'healthy') return { requested: false, state: status.state, reason: status.reason }
|
|
235
|
+
const { record } = status
|
|
236
|
+
const answer = await requestLoopback({ host: record.host, port: record.port, method, path: operation, bearer: record.ext.bearer, payload: method === 'POST' ? { runtimeId: record.runtimeId } : null, timeoutMs })
|
|
237
|
+
return { requested: true, state: 'healthy', answer }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Asks the proven runtime for one tick now and returns what that tick reported. A tick that outlasts the wait is
|
|
241
|
+
// `pending`, not an error.
|
|
242
|
+
export async function requestServiceTick(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
243
|
+
const asked = await askProvenRuntime(options, rules, { method: 'POST', operation: '/tick', timeoutMs: options.tickTimeoutMs ?? 60 * 1000 })
|
|
244
|
+
if (!asked.requested) return asked
|
|
245
|
+
const { answer } = asked
|
|
246
|
+
if (answer.kind === 'timeout') return { requested: true, state: 'healthy', pending: true, tick: null, reason: 'tick-still-running' }
|
|
247
|
+
if (answer.kind !== 'response' || answer.statusCode !== 200 || answer.body === null) return { requested: true, state: 'healthy', pending: false, tick: null, reason: 'tick-was-not-answered' }
|
|
248
|
+
return { requested: true, state: 'healthy', pending: false, tick: answer.body, reason: 'tick-ran' }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The status document of the proven runtime: loop, last tick, last error code, freshness summary and, where the
|
|
252
|
+
// service qualifies an app, what it last learned about it. Null when it did not answer.
|
|
253
|
+
export async function readServiceStatusDocument(options = {}, rules = LIFECYCLE_PRIMITIVES) {
|
|
254
|
+
const asked = await askProvenRuntime(options, rules, { method: 'GET', operation: '/status', timeoutMs: options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS })
|
|
255
|
+
if (!asked.requested) return { ...asked, document: null }
|
|
256
|
+
const { answer } = asked
|
|
257
|
+
return { requested: true, state: 'healthy', document: answer.kind === 'response' && answer.statusCode === 200 ? answer.body : null }
|
|
258
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { randomBytes as cryptoRandomBytes } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { LOCAL_STATE_DIR, localStateRoot } from '../../project/config.mjs'
|
|
6
|
+
import { checkManagedRoots } from '../../project/file-class.mjs'
|
|
7
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
8
|
+
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
|
|
9
|
+
import { refuse } from './errors.mjs'
|
|
10
|
+
import { canonicalJson, closedObject, isPlainObject } from './documents.mjs'
|
|
11
|
+
|
|
12
|
+
// Machine-private settings of the Obsidian maintenance runtime.
|
|
13
|
+
//
|
|
14
|
+
// <data>/obsidian/<workspace-id>/state/settings/machine.json mode, audiences, policy reference
|
|
15
|
+
// <data>/obsidian/<workspace-id>/state/settings/apply-policy.json the installed apply policy
|
|
16
|
+
// <project>/.atelier-local/obsidian.json pointer: workspace id, optional data root
|
|
17
|
+
//
|
|
18
|
+
// Nothing here is project configuration and nothing here is ever serialized
|
|
19
|
+
// into a note. The data directory sits outside every enrolled repository. The
|
|
20
|
+
// pointer is the one ignored file a project gains: it holds the persisted
|
|
21
|
+
// random workspace identity (never a hash of a checkout path, so moving a
|
|
22
|
+
// checkout keeps its vaults) and, optionally, where this machine keeps the
|
|
23
|
+
// data directory.
|
|
24
|
+
|
|
25
|
+
export const MACHINE_SETTINGS_SCHEMA = 'atelier-obsidian-machine-settings/v1'
|
|
26
|
+
export const LOCAL_POINTER_SCHEMA = 'atelier-obsidian-local-pointer/v1'
|
|
27
|
+
export const LOCAL_POINTER_FILE = 'obsidian.json'
|
|
28
|
+
export const MAINTENANCE_MODES = Object.freeze(['manual', 'automatic'])
|
|
29
|
+
|
|
30
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
31
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/
|
|
32
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/
|
|
33
|
+
|
|
34
|
+
const segment = (identifier) => identifier.replaceAll(':', '_')
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Where the data directory is
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
// Pure: computes a path from its arguments and touches nothing.
|
|
41
|
+
export function defaultDataRoot({ platform = process.platform, env = process.env, homedir = os.homedir() } = {}) {
|
|
42
|
+
const flavor = platform === 'win32' ? path.win32 : path.posix
|
|
43
|
+
const absolute = (value) => typeof value === 'string' && value !== '' && flavor.isAbsolute(value)
|
|
44
|
+
if (platform === 'win32') {
|
|
45
|
+
if (!absolute(env.LOCALAPPDATA)) refuse('data-root-unresolvable', 'LOCALAPPDATA does not name an absolute directory')
|
|
46
|
+
return flavor.join(env.LOCALAPPDATA, 'Atelier')
|
|
47
|
+
}
|
|
48
|
+
if (!absolute(homedir)) refuse('data-root-unresolvable', 'the home directory is not an absolute path')
|
|
49
|
+
if (platform === 'darwin') return flavor.join(homedir, 'Library', 'Application Support', 'Atelier')
|
|
50
|
+
// A relative XDG_DATA_HOME is invalid by its specification and is ignored.
|
|
51
|
+
return flavor.join(absolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : flavor.join(homedir, '.local', 'share'), 'atelier')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The data root for this run: an injected root, else the project's pointer,
|
|
55
|
+
// else the local overlay preference, else the platform default. Pure. Under
|
|
56
|
+
// the Node test runner the platform default refuses, so a test that forgot to
|
|
57
|
+
// inject a root can never reach a person's real data directory.
|
|
58
|
+
export function resolveDataRoot({ dataRoot, pointer = null, project = null, platform = process.platform, env = process.env, homedir } = {}) {
|
|
59
|
+
const preference = project?.localOverlay?.overlay?.preferences?.[OBSIDIAN_EXT_KEY]
|
|
60
|
+
const configured = dataRoot ?? pointer?.dataRoot ?? (isPlainObject(preference) ? preference.dataRoot : undefined)
|
|
61
|
+
if (configured !== undefined) {
|
|
62
|
+
if (typeof configured !== 'string' || !path.isAbsolute(configured)) refuse('data-root-not-absolute', 'a configured data root must be an absolute path')
|
|
63
|
+
return path.resolve(configured)
|
|
64
|
+
}
|
|
65
|
+
if (env.NODE_TEST_CONTEXT !== undefined) refuse('real-data-root-under-test', 'the platform data directory is never used under the test runner; inject a data root')
|
|
66
|
+
return defaultDataRoot({ platform, env, ...(homedir === undefined ? {} : { homedir }) })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function workspaceStateRoot(dataRoot, workspaceId) {
|
|
70
|
+
if (typeof workspaceId !== 'string' || !IDENTIFIER.test(workspaceId)) refuse('invalid-workspace-identity', 'a workspace identity must be a contract identifier')
|
|
71
|
+
return path.join(dataRoot, 'obsidian', segment(workspaceId))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Every enrolled repository root plus the directories the project itself
|
|
75
|
+
// occupies. Private state may overlap none of them, in either direction.
|
|
76
|
+
export function protectedRoots(project) {
|
|
77
|
+
const roots = new Set()
|
|
78
|
+
for (const repo of project?.repos ?? []) if (!repo.external && typeof repo.path === 'string') roots.add(path.resolve(repo.path))
|
|
79
|
+
if (typeof project?.configDir === 'string') roots.add(path.resolve(project.configDir))
|
|
80
|
+
return [...roots].sort()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function assertOutsideRepositories({ managedRoot, repositoryRoots }) {
|
|
84
|
+
const guard = checkManagedRoots({ managedRoots: [managedRoot], repositoryRoots })
|
|
85
|
+
if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals.map(({ code }) => code) })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// The pointer a project keeps in its ignored local state
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function validatePointer(document) {
|
|
93
|
+
closedObject(document, { required: ['schema', 'workspaceId'], optional: ['dataRoot'] }, 'invalid-local-pointer', 'the Obsidian pointer')
|
|
94
|
+
if (document.schema !== LOCAL_POINTER_SCHEMA) refuse('invalid-local-pointer', 'the Obsidian pointer names an unknown schema')
|
|
95
|
+
if (typeof document.workspaceId !== 'string' || !IDENTIFIER.test(document.workspaceId)) refuse('invalid-local-pointer', 'the Obsidian pointer carries no usable workspace identity')
|
|
96
|
+
if (document.dataRoot !== undefined && (typeof document.dataRoot !== 'string' || !path.isAbsolute(document.dataRoot))) {
|
|
97
|
+
refuse('invalid-local-pointer', 'the Obsidian pointer names a data root that is not an absolute path')
|
|
98
|
+
}
|
|
99
|
+
return document
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const localPointerPath = (project) => path.join(localStateRoot(project), LOCAL_POINTER_FILE)
|
|
103
|
+
|
|
104
|
+
function readJsonFile(file, code, label) {
|
|
105
|
+
let text
|
|
106
|
+
try { text = readRegularTextNoFollow(file) } catch (error) {
|
|
107
|
+
if (error.code === 'ENOENT') return null
|
|
108
|
+
refuse(code, `${label} cannot be read`, { cause: error.code ?? String(error.message) })
|
|
109
|
+
}
|
|
110
|
+
try { return JSON.parse(text) } catch { return refuse(code, `${label} is not JSON`) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readLocalPointer(project) {
|
|
114
|
+
const document = readJsonFile(localPointerPath(project), 'invalid-local-pointer', 'the Obsidian pointer')
|
|
115
|
+
return document === null ? null : validatePointer(document)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The pointer is written only where the project's local state is proven
|
|
119
|
+
// ignored, so a machine-local value can never be committed by accident.
|
|
120
|
+
export function writeLocalPointer(project, pointer) {
|
|
121
|
+
validatePointer(pointer)
|
|
122
|
+
if (project?.localState?.ignored !== true) refuse('local-state-not-ignored', `${LOCAL_STATE_DIR}/ is not ignored in this project; the Obsidian pointer is not written`)
|
|
123
|
+
const directory = ensureContainedPrivateDirectory({ workspaceRoot: project.configDir, directory: localStateRoot(project), label: 'project local state' })
|
|
124
|
+
atomicReplacePrivateText(path.join(directory, LOCAL_POINTER_FILE), canonicalJson(pointer))
|
|
125
|
+
return pointer
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Reads the persisted workspace identity, or issues a random one and persists
|
|
129
|
+
// it. `randomBytes` is injectable so tests are deterministic.
|
|
130
|
+
export function ensureWorkspaceIdentity({ project, dataRoot, randomBytes = cryptoRandomBytes } = {}) {
|
|
131
|
+
const existing = readLocalPointer(project)
|
|
132
|
+
if (existing) return existing
|
|
133
|
+
const pointer = { schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) }
|
|
134
|
+
return writeLocalPointer(project, pointer)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Machine settings
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function validateMachineSettings(document, workspaceId) {
|
|
142
|
+
closedObject(document, { required: ['schema', 'workspaceId', 'maintenanceMode', 'audienceAllow', 'applyPolicy', 'updatedAt'] }, 'invalid-machine-settings', 'machine settings')
|
|
143
|
+
if (document.schema !== MACHINE_SETTINGS_SCHEMA) refuse('invalid-machine-settings', 'machine settings name an unknown schema')
|
|
144
|
+
if (document.workspaceId !== workspaceId) refuse('invalid-machine-settings', 'machine settings belong to another workspace')
|
|
145
|
+
if (!MAINTENANCE_MODES.includes(document.maintenanceMode)) refuse('invalid-machine-settings', 'maintenanceMode must be manual or automatic')
|
|
146
|
+
if (!Array.isArray(document.audienceAllow) || document.audienceAllow.length > 64 || document.audienceAllow.some((item) => typeof item !== 'string' || !IDENTIFIER.test(item))
|
|
147
|
+
|| new Set(document.audienceAllow).size !== document.audienceAllow.length) {
|
|
148
|
+
refuse('invalid-machine-settings', 'audienceAllow must be a list of distinct audience identifiers')
|
|
149
|
+
}
|
|
150
|
+
if (document.applyPolicy !== null) {
|
|
151
|
+
closedObject(document.applyPolicy, { required: ['policyId', 'version', 'digest'] }, 'invalid-machine-settings', 'the apply policy reference')
|
|
152
|
+
const { policyId, version, digest } = document.applyPolicy
|
|
153
|
+
if (typeof policyId !== 'string' || !IDENTIFIER.test(policyId) || !Number.isInteger(version) || version < 1 || typeof digest !== 'string' || !DIGEST.test(digest)) {
|
|
154
|
+
refuse('invalid-machine-settings', 'the apply policy reference is malformed')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (typeof document.updatedAt !== 'string' || !TIMESTAMP.test(document.updatedAt)) refuse('invalid-machine-settings', 'updatedAt must be a UTC timestamp')
|
|
158
|
+
return document
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The defaults fail closed: edits are only queued, no audience is visible and
|
|
162
|
+
// no apply policy is installed until the person says otherwise.
|
|
163
|
+
export function defaultMachineSettings({ workspaceId, updatedAt }) {
|
|
164
|
+
return { schema: MACHINE_SETTINGS_SCHEMA, workspaceId, maintenanceMode: 'manual', audienceAllow: [], applyPolicy: null, updatedAt }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const settingsDirectory = (workspaceRoot) => ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, 'state', 'settings'), label: 'Obsidian machine settings' })
|
|
168
|
+
const settingsFile = (workspaceRoot, name) => path.join(workspaceRoot, 'state', 'settings', name)
|
|
169
|
+
|
|
170
|
+
export function readMachineSettings({ workspaceRoot, workspaceId }) {
|
|
171
|
+
const document = readJsonFile(settingsFile(workspaceRoot, 'machine.json'), 'invalid-machine-settings', 'machine settings')
|
|
172
|
+
return document === null ? null : validateMachineSettings(document, workspaceId)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
|
|
176
|
+
validateMachineSettings(settings, workspaceId)
|
|
177
|
+
assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
|
|
178
|
+
fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
|
|
179
|
+
atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'machine.json'), canonicalJson(settings))
|
|
180
|
+
return settings
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Installed apply policy
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
// `digestOf` is the canonical digest of a policy, handed in by the caller that installs for a person (the command):
|
|
188
|
+
// the decision that reads the policy recomputes that digest on every apply, so a policy that carries another one
|
|
189
|
+
// could be installed and would then refuse every automatic apply. With it, such a policy is refused here, naming the
|
|
190
|
+
// digest it has to carry. A digest is not a secret. The file of the person is never rewritten.
|
|
191
|
+
export function installApplyPolicy({ workspaceRoot, workspaceId, policy, repositoryRoots, updatedAt, digestOf = null }) {
|
|
192
|
+
try { assertObsidianContract('apply-policy', policy) } catch (error) {
|
|
193
|
+
if (error instanceof ObsidianContractRefusal) refuse('invalid-apply-policy', 'the apply policy does not satisfy its contract', { errors: error.detail?.errors ?? [] })
|
|
194
|
+
throw error
|
|
195
|
+
}
|
|
196
|
+
if (policy.workspaceId !== workspaceId) refuse('invalid-apply-policy', 'the apply policy belongs to another workspace')
|
|
197
|
+
if (digestOf !== null) {
|
|
198
|
+
const expected = digestOf(policy)
|
|
199
|
+
if (policy.digest !== expected) refuse('policy-digest-mismatch', `the policy does not carry the digest of its content; it has to carry ${expected}`, { expected, carried: policy.digest })
|
|
200
|
+
}
|
|
201
|
+
const current = readMachineSettings({ workspaceRoot, workspaceId }) ?? defaultMachineSettings({ workspaceId, updatedAt })
|
|
202
|
+
assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
|
|
203
|
+
fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
|
|
204
|
+
atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'apply-policy.json'), canonicalJson(policy))
|
|
205
|
+
return writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: { ...current, applyPolicy: { policyId: policy.policyId, version: policy.version, digest: policy.digest }, updatedAt } })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Whether an automatic dispatch is authorized right now. Reads both files
|
|
209
|
+
// from disk on every call; the engine calls this immediately before each
|
|
210
|
+
// dispatch, so a revocation or a pause takes effect for the very next edit.
|
|
211
|
+
// Anything missing, malformed or mismatched is "not authorized", never an
|
|
212
|
+
// error that could be mistaken for permission.
|
|
213
|
+
// `assumeAutomatic` answers "would automatic mode be authorized": the check `mode set automatic` makes before it writes.
|
|
214
|
+
export function authorizeAutomaticApply({ workspaceRoot, workspaceId, assumeAutomatic = false }) {
|
|
215
|
+
const denied = (reason) => ({ authorized: false, reason, policy: null })
|
|
216
|
+
let settings
|
|
217
|
+
try { settings = readMachineSettings({ workspaceRoot, workspaceId }) } catch { return denied('machine-settings-invalid') }
|
|
218
|
+
if (!settings) return denied('machine-settings-absent')
|
|
219
|
+
if (settings.maintenanceMode !== 'automatic' && assumeAutomatic !== true) return denied('maintenance-mode-manual')
|
|
220
|
+
if (settings.applyPolicy === null) return denied('no-apply-policy-installed')
|
|
221
|
+
let policy
|
|
222
|
+
try {
|
|
223
|
+
policy = readJsonFile(settingsFile(workspaceRoot, 'apply-policy.json'), 'invalid-apply-policy', 'the apply policy')
|
|
224
|
+
if (policy !== null) assertObsidianContract('apply-policy', policy)
|
|
225
|
+
} catch { return denied('apply-policy-invalid') }
|
|
226
|
+
if (policy === null) return denied('apply-policy-absent')
|
|
227
|
+
const reference = settings.applyPolicy
|
|
228
|
+
if (policy.workspaceId !== workspaceId || policy.policyId !== reference.policyId || policy.version !== reference.version || policy.digest !== reference.digest) {
|
|
229
|
+
return denied('apply-policy-reference-mismatch')
|
|
230
|
+
}
|
|
231
|
+
if (policy.mode !== 'automatic') return denied('apply-policy-manual')
|
|
232
|
+
if (policy.status !== 'active') return denied(`apply-policy-${policy.status}`)
|
|
233
|
+
return { authorized: true, reason: 'apply-policy-active', policy }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The installed policy as stored, validated, or null. A stored policy that does not validate refuses.
|
|
237
|
+
export function readInstalledApplyPolicy({ workspaceRoot, workspaceId }) {
|
|
238
|
+
const policy = readJsonFile(settingsFile(workspaceRoot, 'apply-policy.json'), 'invalid-apply-policy', 'the apply policy')
|
|
239
|
+
if (policy === null) return null
|
|
240
|
+
try { assertObsidianContract('apply-policy', policy) } catch (error) {
|
|
241
|
+
if (error instanceof ObsidianContractRefusal) refuse('invalid-apply-policy', 'the stored apply policy does not satisfy its contract', { errors: error.detail?.errors ?? [] })
|
|
242
|
+
throw error
|
|
243
|
+
}
|
|
244
|
+
if (policy.workspaceId !== workspaceId) refuse('invalid-apply-policy', 'the stored apply policy belongs to another workspace')
|
|
245
|
+
return policy
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Revocation. The stored policy is marked revoked first: from that write on, every authorization read denies, so an
|
|
249
|
+
// apply that is queued behind it is never dispatched. Then maintenance goes back to manual, so installing a policy
|
|
250
|
+
// later does not resume automatic apply by itself.
|
|
251
|
+
export function revokeApplyPolicy({ workspaceRoot, workspaceId, repositoryRoots, updatedAt }) {
|
|
252
|
+
const policy = readInstalledApplyPolicy({ workspaceRoot, workspaceId })
|
|
253
|
+
const current = readMachineSettings({ workspaceRoot, workspaceId })
|
|
254
|
+
if (policy === null && (current === null || current.applyPolicy === null)) return { revoked: false, reason: 'no-apply-policy-installed', policy: null }
|
|
255
|
+
assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
|
|
256
|
+
const revoked = policy === null ? null : { ...policy, status: 'revoked' }
|
|
257
|
+
if (revoked !== null) atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'apply-policy.json'), canonicalJson(revoked))
|
|
258
|
+
if (current !== null) writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: { ...current, maintenanceMode: 'manual', updatedAt } })
|
|
259
|
+
return { revoked: true, reason: policy?.status === 'revoked' ? 'already-revoked' : 'revoked', policy: revoked }
|
|
260
|
+
}
|