@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,122 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { buildCanonicalGraph, createGraphFileCache } from '../../graph/graph.mjs'
|
|
4
|
+
import { EMITTER_VERSION, createPreparationCache, prepareView, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
|
|
5
|
+
import { publishView } from '../../projection/obsidian/publication/publisher.mjs'
|
|
6
|
+
import { recheckDisplacedFiles } from '../../projection/obsidian/recovery/late-writer.mjs'
|
|
7
|
+
import { createRecoveryStore, readFileBytes } from '../../projection/obsidian/recovery/store.mjs'
|
|
8
|
+
import { compareText } from './documents.mjs'
|
|
9
|
+
import { refuse } from './errors.mjs'
|
|
10
|
+
import { readFileFacts, sha256Digest, sourceKey } from './observation.mjs'
|
|
11
|
+
|
|
12
|
+
// The production seams between the engine and the projection modules:
|
|
13
|
+
//
|
|
14
|
+
// canonical graph -> source snapshot -> prepareView -> publishView
|
|
15
|
+
//
|
|
16
|
+
// The engine receives these as one object and tests replace single members.
|
|
17
|
+
// Nothing here talks to a network, and nothing here asks git anything; the
|
|
18
|
+
// canonical graph builder reads local ignore rules through the local git
|
|
19
|
+
// executable and that is all.
|
|
20
|
+
|
|
21
|
+
// Fails closed twice. A document is eligible only when the census classified
|
|
22
|
+
// it: an unclassified one, or one that carries no classification at all, is
|
|
23
|
+
// withheld. An embedded asset has no audience of its own, so it is eligible
|
|
24
|
+
// only when at least one note that embeds it is itself eligible; a file that
|
|
25
|
+
// only withheld notes embed never reaches a view, a snapshot or a state
|
|
26
|
+
// document. `isAssetEligible(asset, { embeds, isEligibleSource })` may be
|
|
27
|
+
// supplied to replace the asset rule; only an exact `true` admits an asset, and
|
|
28
|
+
// `revision()` must change whenever either rule does.
|
|
29
|
+
export const DEFAULT_ELIGIBILITY = Object.freeze({
|
|
30
|
+
revision: () => 'classified-documents/v2+assets-embedded-by-eligible-documents/v1',
|
|
31
|
+
isEligible: (node) => node.classification === 'classified',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export function assetEligibilityFor({ graph, eligibility }) {
|
|
35
|
+
const eligibleSources = new Set(graph.nodes.filter((node) => eligibility.isEligible(node) === true).map((node) => node.id))
|
|
36
|
+
const embeds = Array.isArray(graph.embeds) ? graph.embeds : []
|
|
37
|
+
if (typeof eligibility.isAssetEligible === 'function') {
|
|
38
|
+
return (asset) => eligibility.isAssetEligible(asset, { embeds: embeds.filter((embed) => embed?.asset?.id === asset.id), isEligibleSource: (id) => eligibleSources.has(id) }) === true
|
|
39
|
+
}
|
|
40
|
+
const embedded = new Set(embeds.filter((embed) => eligibleSources.has(embed?.source)).map((embed) => embed?.asset?.id))
|
|
41
|
+
return (asset) => embedded.has(asset.id)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const posix = (value) => value.split(path.sep).join('/')
|
|
45
|
+
|
|
46
|
+
// The corpus profile of a loaded project. Repository roots are recorded
|
|
47
|
+
// relative to the workspace root, since a profile is a portable shape; a
|
|
48
|
+
// repository kept elsewhere on this machine is recorded by name only.
|
|
49
|
+
export function profileFor({ project, workspaceId, audienceAllow }) {
|
|
50
|
+
const repositories = (project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => {
|
|
51
|
+
const relative = posix(path.relative(project.workspaceRoot, repo.path))
|
|
52
|
+
const inside = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
53
|
+
return { repoId: repo.name, root: relative === '' ? '.' : inside ? relative : `elsewhere/${repo.name}`, enrollment: 'enrolled' }
|
|
54
|
+
})
|
|
55
|
+
return { schema: 'atelier-obsidian-corpus-profile/v1', workspaceId, repositories, audience: { allow: [...audienceAllow] } }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `cache` (createGraphFileCache) is the engine's per-file census cache; without it every source is parsed again.
|
|
59
|
+
// `index` is the engine's observation index. With both, a source whose observed digest is the cached one is not
|
|
60
|
+
// opened: observation already decided by digest that it did not change, under the stat-hint bound observation
|
|
61
|
+
// documents, and a full reconciliation hashes it again. Without an index every source is read and hashed here.
|
|
62
|
+
export function buildGraph({ project, eligibility, cache = null, index = null }) {
|
|
63
|
+
const observedDigest = cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null
|
|
64
|
+
const canonical = buildCanonicalGraph(project, { fileCache: cache, observedDigest })
|
|
65
|
+
if (!canonical.ok) refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
|
|
66
|
+
return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A source snapshot pinned to the digests observation decided on. prepareView
|
|
70
|
+
// reads every source it emits again and refuses with `mixed-read` when the
|
|
71
|
+
// bytes it gets are not the pinned ones, so an emitted note never carries a
|
|
72
|
+
// stale digest. A note reused from the preparation cache is not read again:
|
|
73
|
+
// its pinned digest is unchanged and the cached bytes are the ones that digest
|
|
74
|
+
// describes, so bytes that drift under an unchanged pin are not consulted
|
|
75
|
+
// until observation hashes the file again (the same bound as the graph stage).
|
|
76
|
+
export function captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt }) {
|
|
77
|
+
const roots = new Map((project.repos ?? []).filter((repo) => !repo.external).map((repo) => [repo.name, repo.path]))
|
|
78
|
+
const absolute = (repoId, relative) => {
|
|
79
|
+
const root = roots.get(repoId)
|
|
80
|
+
if (!root) refuse('source-not-in-snapshot', 'a node names a repository the project does not enrol')
|
|
81
|
+
return path.join(root, ...relative.split('/'))
|
|
82
|
+
}
|
|
83
|
+
const byRepo = new Map([...roots.keys()].map((repoId) => [repoId, new Map()]))
|
|
84
|
+
// Every node, and every asset a view may copy. A withheld asset is never pinned: it is in no snapshot document.
|
|
85
|
+
const pinned = [...graph.nodes, ...(Array.isArray(graph.assets) ? graph.assets.filter((asset) => asset.eligible === true) : [])]
|
|
86
|
+
for (const item of pinned) {
|
|
87
|
+
if (!byRepo.has(item.repo)) refuse('source-not-in-snapshot', 'the graph names a repository the project does not enrol')
|
|
88
|
+
const observed = index.get(sourceKey(item.repo, item.path))
|
|
89
|
+
const facts = observed?.digest ? observed : readFileFacts(absolute(item.repo, item.path))
|
|
90
|
+
if (!facts) refuse('mixed-read', 'a file the graph read is no longer there')
|
|
91
|
+
byRepo.get(item.repo).set(item.path, { path: item.path, rawDigest: facts.digest, byteLength: facts.byteLength })
|
|
92
|
+
}
|
|
93
|
+
const repositories = [...byRepo].sort(([left], [right]) => compareText(left, right))
|
|
94
|
+
.map(([repoId, files]) => ({ repoId, head: null, dirty: true, files: [...files.values()].sort((left, right) => compareText(left.path, right.path)) }))
|
|
95
|
+
// Embeds and assets are part of what was read: an embed that changes alone yields another snapshot.
|
|
96
|
+
const graphDigest = sha256Digest(Buffer.from(JSON.stringify([graph.nodes, graph.edges, graph.embeds ?? [], graph.assets ?? []])))
|
|
97
|
+
const identity = createHash('sha256').update(JSON.stringify([workspaceId, graphDigest, configDigest, repositories])).digest('hex').slice(0, 32)
|
|
98
|
+
return {
|
|
99
|
+
document: {
|
|
100
|
+
schema: 'atelier-obsidian-source-snapshot/v1',
|
|
101
|
+
contractVersion: '1.0.0',
|
|
102
|
+
snapshotId: `snap-${identity}`,
|
|
103
|
+
workspaceId,
|
|
104
|
+
capturedAt,
|
|
105
|
+
readConsistency: 'single-read',
|
|
106
|
+
graphPin: { graphDigest, nodeCount: graph.nodes.length, edgeCount: graph.edges.length },
|
|
107
|
+
versions: { source: '1', config: configDigest.slice('sha256:'.length, 'sha256:'.length + 16), emitter: EMITTER_VERSION },
|
|
108
|
+
repositories,
|
|
109
|
+
},
|
|
110
|
+
graph,
|
|
111
|
+
readSource: (repoId, relative) => readFileBytes(absolute(repoId, relative)),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The engine keeps one graph file cache and one preparation cache per scope
|
|
116
|
+
// for as long as it runs, and hands them to buildGraph and prepareView on
|
|
117
|
+
// every tick, so a tick after a one-note change parses that source and emits
|
|
118
|
+
// that note and reuses the rest. Both caches are derived, in-memory state: a
|
|
119
|
+
// test may replace either seam with `() => null` to build or prepare in full.
|
|
120
|
+
export function createProductionSeams() {
|
|
121
|
+
return { buildGraph, captureSnapshot, profileFor, prepareView, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
|
|
122
|
+
}
|
|
@@ -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,9 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
3
|
+
// The absolute path of the maintenance service entry, kept in its own module
|
|
4
|
+
// so that nothing which needs the path has to import the entry itself. The
|
|
5
|
+
// entry awaits at top level (it loads the command contributions before it
|
|
6
|
+
// serves), and a module that both reaches it through a static import and is
|
|
7
|
+
// itself reached by that load would deadlock the process: the contribution
|
|
8
|
+
// graph waits for the entry, the entry waits for the contribution graph.
|
|
9
|
+
export const SERVICE_ENTRY_PATH = fileURLToPath(new URL('./service-main.mjs', import.meta.url))
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
import { SERVICE_ENTRY_PATH } from './service-entry-path.mjs'
|
|
37
|
+
|
|
38
|
+
export { SERVICE_ENTRY_PATH }
|
|
39
|
+
export const EXIT_REFUSED = 2
|
|
40
|
+
|
|
41
|
+
const log = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
|
|
42
|
+
|
|
43
|
+
// The common body of a service process: run, end cleanly on a signal, exit 0
|
|
44
|
+
// after a clean stop and EXIT_REFUSED when the service refused to run.
|
|
45
|
+
export async function runServiceProcess(options) {
|
|
46
|
+
try {
|
|
47
|
+
const service = await runMaintenanceService({ log, ...options })
|
|
48
|
+
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { void service.shutdown(`signal-${signal}`) })
|
|
49
|
+
await service.done
|
|
50
|
+
// Everything durable is on disk by now; nothing left over may keep a stopped service's PID alive.
|
|
51
|
+
process.exit(0)
|
|
52
|
+
} catch (error) {
|
|
53
|
+
log({ at: new Date().toISOString(), event: 'refused', code: error?.code ?? 'untyped-error', message: String(error?.message ?? error) })
|
|
54
|
+
process.exitCode = typeof error?.code === 'string' ? EXIT_REFUSED : 1
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
|
|
59
|
+
const args = parseArgs(argv)
|
|
60
|
+
const configPath = firstString(args.project)
|
|
61
|
+
if (!configPath || !path.isAbsolute(configPath)) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
|
|
62
|
+
const dataRoot = firstString(args['data-root']) ?? undefined
|
|
63
|
+
const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
|
|
64
|
+
if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
|
|
65
|
+
return {
|
|
66
|
+
loadProject: () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
|
|
67
|
+
...(dataRoot === undefined ? {} : { dataRoot }), ...(intervalMs === undefined ? {} : { intervalMs }),
|
|
68
|
+
...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
|
|
69
|
+
startup: args.startup === true, adapter: firstString(args.adapter),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)) } catch { return false } })()
|
|
74
|
+
|
|
75
|
+
if (invokedDirectly) {
|
|
76
|
+
let options = null
|
|
77
|
+
try {
|
|
78
|
+
options = serviceOptionsFromArgv(process.argv.slice(2))
|
|
79
|
+
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' })
|
|
80
|
+
} catch (error) {
|
|
81
|
+
log({ at: new Date().toISOString(), event: 'refused', code: error.code ?? 'untyped-error', message: error.message })
|
|
82
|
+
process.exitCode = EXIT_REFUSED
|
|
83
|
+
options = null
|
|
84
|
+
}
|
|
85
|
+
if (options) {
|
|
86
|
+
const { adapter, ...rest } = options
|
|
87
|
+
const [{ loadContributions }, { createObsidianRegistry }] = await Promise.all([import('./contributions.mjs'), import('./extension-points.mjs')])
|
|
88
|
+
const registry = createObsidianRegistry({ contributions: await loadContributions() })
|
|
89
|
+
await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...(await ADAPTERS[adapter]()), engineOptions: { extensions: registry.extensions } })
|
|
90
|
+
}
|
|
91
|
+
}
|