@mnstry/atelier 0.2.0-alpha.5 → 0.2.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/README.md +86 -25
- 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/docs/assurance-controls.md +12 -2
- package/docs/atelier-runtime.md +13 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/claims.md +28 -18
- package/docs/blocks/will-not-do.md +12 -3
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/decisions.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +65 -9
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +227 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +687 -0
- package/docs/obsidian.md +387 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +99 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +70 -2
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-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/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +61 -6
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-open/SKILL.md +5 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-open/SKILL.md +5 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/content-rules.mjs +9 -4
- package/src/boundary/policy.mjs +74 -42
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +61 -3
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +16 -33
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/extension-pack.mjs +15 -8
- package/src/commands/init.mjs +44 -5
- package/src/commands/obsidian.mjs +368 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +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 +13 -0
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +64 -2
- package/src/graph/knowledge-graph.mjs +548 -24
- package/src/guides/contracts.mjs +54 -0
- package/src/index.mjs +29 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +50 -49
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +166 -0
- package/src/project/private-state.mjs +11 -1
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +139 -0
- package/src/projection/obsidian/recovery/index.mjs +6 -0
- package/src/projection/obsidian/recovery/journal.mjs +134 -0
- package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
- package/src/projection/obsidian/recovery/restart.mjs +316 -0
- package/src/projection/obsidian/recovery/store.mjs +234 -0
- package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
- package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
- package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
- package/src/projection/obsidian/selection-ui/index.mjs +12 -0
- package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
- package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
- package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
- package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
- package/src/projection/project.mjs +28 -19
- package/src/readiness-protocols/evidence.mjs +282 -0
- package/src/readiness-protocols/runtime.mjs +4 -3
- package/src/readiness-protocols/source-read.mjs +39 -0
- package/src/runtime/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/obsidian/app-capability.mjs +110 -0
- package/src/runtime/obsidian/app-production-seams.mjs +96 -0
- package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
- package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
- package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
- package/src/runtime/obsidian/contributions.mjs +27 -0
- package/src/runtime/obsidian/documents.mjs +37 -0
- package/src/runtime/obsidian/enablement.mjs +53 -0
- package/src/runtime/obsidian/engine.mjs +532 -0
- package/src/runtime/obsidian/errors.mjs +14 -0
- package/src/runtime/obsidian/extension-points.mjs +103 -0
- package/src/runtime/obsidian/index.mjs +34 -0
- package/src/runtime/obsidian/lifecycle.mjs +258 -0
- package/src/runtime/obsidian/machine-settings.mjs +260 -0
- package/src/runtime/obsidian/observation.mjs +158 -0
- package/src/runtime/obsidian/opening.mjs +243 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-main.mjs +89 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- package/src/server/local-sidecar.mjs +44 -0
- package/src/server/server.mjs +1 -0
- package/src/skills/steward.mjs +780 -0
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +63 -28
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/project/README.md +2 -2
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
|
+
import { refuse } from './errors.mjs'
|
|
5
|
+
|
|
6
|
+
// Where later work plugs in without touching a shared file: every module in
|
|
7
|
+
// `contributions/` beside this file is a contribution. Its default export is
|
|
8
|
+
// `{ id, register({ extensions, operations }) }`. The command and the service
|
|
9
|
+
// entry both load the directory, so an apply operation registered there is
|
|
10
|
+
// the one the service dispatches to and the one `status` reports.
|
|
11
|
+
//
|
|
12
|
+
// Only regular `.mjs` files directly in that directory are loaded, in name
|
|
13
|
+
// order. The directory ships with the package; nothing outside it is read.
|
|
14
|
+
|
|
15
|
+
export const CONTRIBUTIONS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'contributions')
|
|
16
|
+
|
|
17
|
+
export async function loadContributions({ directory = CONTRIBUTIONS_DIRECTORY } = {}) {
|
|
18
|
+
let entries
|
|
19
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return []; throw error }
|
|
20
|
+
const contributions = []
|
|
21
|
+
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith('.mjs')).sort((left, right) => (left.name < right.name ? -1 : 1))) {
|
|
22
|
+
const loaded = await import(pathToFileURL(path.join(directory, entry.name)).href)
|
|
23
|
+
if (loaded.default === null || typeof loaded.default !== 'object' || typeof loaded.default.register !== 'function') refuse('invalid-extension', 'a contribution module must export { id, register } by default', { file: entry.name })
|
|
24
|
+
contributions.push(loaded.default)
|
|
25
|
+
}
|
|
26
|
+
return contributions
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { refuse } from './errors.mjs'
|
|
2
|
+
|
|
3
|
+
// Helpers shared by this module's private documents. Each document is
|
|
4
|
+
// versioned by its `schema` string, closed (an unknown key refuses) and
|
|
5
|
+
// written in one canonical form, so equal state is equal bytes.
|
|
6
|
+
|
|
7
|
+
export const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
|
|
9
|
+
function sortKeys(value) {
|
|
10
|
+
if (Array.isArray(value)) return value.map(sortKeys)
|
|
11
|
+
if (!isPlainObject(value)) return value
|
|
12
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Keys sorted at every depth, two-space indentation, one final newline.
|
|
16
|
+
export function canonicalJson(value) {
|
|
17
|
+
return `${JSON.stringify(sortKeys(value), null, 2)}\n`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function closedObject(value, { required = [], optional = [] }, code, label) {
|
|
21
|
+
if (!isPlainObject(value)) refuse(code, `${label} must be an object`)
|
|
22
|
+
const allowed = new Set([...required, ...optional])
|
|
23
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key))
|
|
24
|
+
if (unknown.length > 0) refuse(code, `${label} carries an unknown key`, { keys: unknown.sort() })
|
|
25
|
+
const missing = required.filter((key) => !Object.hasOwn(value, key))
|
|
26
|
+
if (missing.length > 0) refuse(code, `${label} lacks a required key`, { keys: missing })
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
|
|
31
|
+
|
|
32
|
+
export function isoTime(clock) {
|
|
33
|
+
const value = clock()
|
|
34
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
35
|
+
if (Number.isNaN(date.getTime())) refuse('missing-clock', 'the injected clock did not return a time')
|
|
36
|
+
return date.toISOString()
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { projectExtMember } from '../../project/config.mjs'
|
|
2
|
+
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
|
|
3
|
+
import { refuse } from './errors.mjs'
|
|
4
|
+
|
|
5
|
+
// Typed enablement. The project v1 schema is unchanged: a project without the
|
|
6
|
+
// ext member is valid and means "not configured", which is disabled. A member
|
|
7
|
+
// that is present must satisfy the closed ext-settings contract exactly. An
|
|
8
|
+
// unknown key or value refuses here; it is never ignored and never read as
|
|
9
|
+
// permission, so a newer or mistyped setting cannot switch anything on.
|
|
10
|
+
|
|
11
|
+
export const ENABLEMENT_STATES = Object.freeze(['disabled', 'enabled'])
|
|
12
|
+
export const DISABLED_REASONS = Object.freeze(['not-configured', 'disabled-in-settings'])
|
|
13
|
+
|
|
14
|
+
const SCOPE_SCHEMA = 'atelier-obsidian-scope/v1'
|
|
15
|
+
|
|
16
|
+
function scopeDocument({ scopeId, mode, selector, expansion, ext }) {
|
|
17
|
+
return {
|
|
18
|
+
schema: SCOPE_SCHEMA,
|
|
19
|
+
scopeId,
|
|
20
|
+
mode,
|
|
21
|
+
selector,
|
|
22
|
+
...(expansion === undefined ? {} : { expansion }),
|
|
23
|
+
...(ext === undefined ? {} : { ext }),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertOrRefuse(shape, document, code, message) {
|
|
28
|
+
try {
|
|
29
|
+
return assertObsidianContract(shape, document)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof ObsidianContractRefusal) refuse(code, message, { errors: error.detail?.errors ?? [] })
|
|
32
|
+
throw error
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Returns a frozen { state, reason, settings, scopes, defaultScopeId }.
|
|
37
|
+
// `scopes` are complete scope contract documents, in declared order, and are
|
|
38
|
+
// listed even when the integration is disabled so a caller can name them.
|
|
39
|
+
export function readObsidianEnablement(project) {
|
|
40
|
+
const member = projectExtMember(project, OBSIDIAN_EXT_KEY)
|
|
41
|
+
if (!member.present) {
|
|
42
|
+
return Object.freeze({ state: 'disabled', reason: 'not-configured', settings: null, scopes: Object.freeze([]), defaultScopeId: null })
|
|
43
|
+
}
|
|
44
|
+
const settings = assertOrRefuse('ext-settings', member.value, 'invalid-ext-settings', 'the project Obsidian settings do not satisfy their contract; nothing is enabled')
|
|
45
|
+
const scopes = settings.scopes.map((scope) => assertOrRefuse('scope', scopeDocument(scope), 'invalid-ext-settings', 'a declared scope does not satisfy the scope contract; nothing is enabled'))
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
state: settings.enabled === true ? 'enabled' : 'disabled',
|
|
48
|
+
reason: settings.enabled === true ? 'enabled' : 'disabled-in-settings',
|
|
49
|
+
settings,
|
|
50
|
+
scopes: Object.freeze(scopes),
|
|
51
|
+
defaultScopeId: settings.defaultScopeId ?? null,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { AtelierDiagnosticError } from '../../project/config.mjs'
|
|
4
|
+
import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
|
|
5
|
+
import { PROTOCOL_ID } from '../../projection/obsidian/publication/bridge-script.mjs'
|
|
6
|
+
import { PublicationRefusal } from '../../projection/obsidian/recovery/store.mjs'
|
|
7
|
+
import { canonicalJson, compareText, isoTime } from './documents.mjs'
|
|
8
|
+
import { readObsidianEnablement } from './enablement.mjs'
|
|
9
|
+
import { ObsidianMaintenanceRefusal } from './errors.mjs'
|
|
10
|
+
import { createMaintenanceExtensions } from './extension-points.mjs'
|
|
11
|
+
import {
|
|
12
|
+
assertOutsideRepositories, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, protectedRoots, readLocalPointer,
|
|
13
|
+
readMachineSettings, resolveDataRoot, workspaceStateRoot, writeMachineSettings,
|
|
14
|
+
} from './machine-settings.mjs'
|
|
15
|
+
import { configKey, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
|
|
16
|
+
import { dispatchAutomaticApply, heldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
|
|
17
|
+
import { DEFAULT_ELIGIBILITY, createProductionSeams } from './pipeline.mjs'
|
|
18
|
+
import { ENGINE_LOCK_DIRECTORY, acquirePrivateGenerationLock, createAbandonmentProof } from './private-lock.mjs'
|
|
19
|
+
import { probeHealth } from './service-client.mjs'
|
|
20
|
+
import { FRESHNESS_SCHEMA, LATE_WRITERS_SCHEMA, createMaintenanceStateStore } from './state-store.mjs'
|
|
21
|
+
import { createNullWatcherFactory } from './watchers.mjs'
|
|
22
|
+
|
|
23
|
+
// The maintenance engine. One explicit `tick()`; no timer, no process, no
|
|
24
|
+
// listener. Whoever owns the lifecycle calls it.
|
|
25
|
+
//
|
|
26
|
+
// A tick, in order:
|
|
27
|
+
//
|
|
28
|
+
// 1. load the project when its configuration changed; read typed enablement
|
|
29
|
+
// 2. look at every vault note; preserve and queue what somebody edited
|
|
30
|
+
// 3. when a proposal adapter is registered: it observes the open edits the
|
|
31
|
+
// object store does not know yet and records what each is, in manual and
|
|
32
|
+
// in automatic mode alike, writing no source; automatic mode only:
|
|
33
|
+
// dispatch queued edits through the apply operation; then hand the
|
|
34
|
+
// pending edits to the proposal adapter
|
|
35
|
+
// 4. look at displaced files again for writes that arrived late
|
|
36
|
+
// 5. look at sources, configuration, scopes and eligibility; decide by digest
|
|
37
|
+
// 6. for each invalidated view: canonical graph -> source snapshot ->
|
|
38
|
+
// prepareView -> publishView, unless that would replace a held note
|
|
39
|
+
// 7. persist the freshness of every view
|
|
40
|
+
//
|
|
41
|
+
// From the moment a workspace is resolved until the tick ends, the tick holds
|
|
42
|
+
// the workspace's private engine lock, so two engines (a service and a
|
|
43
|
+
// foreground command, or two services) never tick one workspace together. An
|
|
44
|
+
// engine that finds the lock held writes nothing and reports `busy`.
|
|
45
|
+
//
|
|
46
|
+
// The engine writes private state and recovery objects. It writes into a
|
|
47
|
+
// vault only by calling publishView, never touches `staging/` or a journal's
|
|
48
|
+
// recovery directory, and never writes a source file: in manual mode nothing
|
|
49
|
+
// on a tick can, and in automatic mode only the registered apply operation.
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_FULL_RECONCILIATION_INTERVAL_MS = 5 * 60 * 1000
|
|
52
|
+
export const DEFAULT_RETRY_INTERVAL_MS = 60 * 1000
|
|
53
|
+
export const DEFAULT_LATE_WRITER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
|
|
54
|
+
export const LATE_WRITER_EVERY_TICK_WINDOW_MS = 60 * 60 * 1000
|
|
55
|
+
|
|
56
|
+
// A refusal that means "someone else is publishing or editing here" rather
|
|
57
|
+
// than "this machine cannot publish".
|
|
58
|
+
const CONFLICT_REFUSALS = new Set(['publication-in-progress', 'generation-mismatch', 'editor-uncoordinated', 'state-mismatch'])
|
|
59
|
+
const EDIT_OUTCOMES = new Set(['disk-changed', 'editor-edit'])
|
|
60
|
+
const RETRIED_STATES = new Set(['stale', 'updating', 'publisher-conflict'])
|
|
61
|
+
// A file changed or went away under a tick: everything is hashed again on the next one.
|
|
62
|
+
const REREAD_CODES = new Set(['mixed-read', 'source-not-in-snapshot'])
|
|
63
|
+
const SOURCE_PREFIX = 'source\u0000'
|
|
64
|
+
const CONFIG_PREFIX = configKey('')
|
|
65
|
+
|
|
66
|
+
// The decisions the oracles in test/obsidian-maintenance.test.mjs are
|
|
67
|
+
// sensitive to. Production always uses these; the tests substitute
|
|
68
|
+
// deliberately broken ones through createMaintenanceEngineForOracleTests to
|
|
69
|
+
// prove that each oracle can fail.
|
|
70
|
+
export const ENGINE_PRIMITIVES = Object.freeze({
|
|
71
|
+
// Edited bytes reach the recovery object store before a record is queued.
|
|
72
|
+
preserve: preserveInRecoveryStore,
|
|
73
|
+
// Read from disk on every call, immediately before each dispatch.
|
|
74
|
+
authorize: authorizeAutomaticApply,
|
|
75
|
+
// Only automatic mode ever reaches the apply operation.
|
|
76
|
+
dispatchAllowed: (maintenanceMode) => maintenanceMode === 'automatic',
|
|
77
|
+
// Every file is hashed, whatever its stat says, at least this often.
|
|
78
|
+
isFullReconciliationDue: ({ nowMs, lastFullMs, intervalMs }) => lastFullMs === null || nowMs - lastFullMs >= intervalMs,
|
|
79
|
+
// The embedded assets observed beside the walk: those the last built graph lets a view copy, and no withheld one.
|
|
80
|
+
observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
|
|
81
|
+
// One engine per workspace at a time, across processes.
|
|
82
|
+
acquireEngineLock: acquirePrivateGenerationLock,
|
|
83
|
+
// The registered proposal adapter observes the open edits on every tick, so a structural edit is recorded as
|
|
84
|
+
// proposed and routed without anybody asking apply to look at it.
|
|
85
|
+
observeEdits: (proposalAdapter) => typeof proposalAdapter.observe === 'function',
|
|
86
|
+
// Held notes that this prepared view would replace, create over or remove. A held note that already holds exactly
|
|
87
|
+
// the bytes this view would publish is none of those: its edit reached the source, the source was prepared again,
|
|
88
|
+
// and publishing changes nothing of the person's. The publisher reads the note again and settles it as already
|
|
89
|
+
// current only while it still holds those bytes; the next look then finds the note at its base and closes the edit.
|
|
90
|
+
// `observed` holds what `heldNoteDigest` answered for each held note immediately before this call.
|
|
91
|
+
publicationConflicts({ prepared, held, bases, observed = new Map() }) {
|
|
92
|
+
const candidates = new Map(prepared.files.map((file) => [file.path, file.digest]))
|
|
93
|
+
return held.filter((notePath) => {
|
|
94
|
+
if (candidates.has(notePath) && observed.get(notePath) === candidates.get(notePath)) return false
|
|
95
|
+
return bases.has(notePath) ? candidates.get(notePath) !== bases.get(notePath).digest : candidates.has(notePath)
|
|
96
|
+
})
|
|
97
|
+
},
|
|
98
|
+
// What a held note holds at the moment of that decision: one read of the file, now. The observation index is not
|
|
99
|
+
// asked (`indexed` is its answer, for the mutation control): its entry was taken earlier in the tick, behind a stat
|
|
100
|
+
// comparison, and the person may have typed since. A note that cannot be read answers null, and the hold stays.
|
|
101
|
+
heldNoteDigest({ file }) {
|
|
102
|
+
try { return readFileFacts(file)?.digest ?? null } catch { return null }
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const isTypedRefusal = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
|
|
107
|
+
const digestOfJson = (value) => sha256Digest(Buffer.from(canonicalJson(value)))
|
|
108
|
+
|
|
109
|
+
function journalTime(journalId) {
|
|
110
|
+
const match = /^journal-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})-/.exec(journalId)
|
|
111
|
+
return match ? Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +match[6], +match[7]) : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Unfinished journals always; finished ones while they are recent. A journal
|
|
115
|
+
// whose time cannot be read is treated as recent.
|
|
116
|
+
function journalsToRecheck(store, { nowMs, full, windowMs }) {
|
|
117
|
+
let names
|
|
118
|
+
try { names = fs.readdirSync(store.journalsRoot).sort() } catch (error) { if (error.code === 'ENOENT') return []; throw error }
|
|
119
|
+
return names.filter((name) => {
|
|
120
|
+
if (!fs.existsSync(path.join(store.journalsRoot, name, 'header.json'))) return false
|
|
121
|
+
if (!fs.existsSync(path.join(store.journalsRoot, name, 'closed.json'))) return true
|
|
122
|
+
const at = journalTime(name)
|
|
123
|
+
return at === null || nowMs - at <= (full ? windowMs : Math.min(windowMs, LATE_WRITER_EVERY_TICK_WINDOW_MS))
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createMaintenanceEngineForOracleTests(options = {}, primitives = ENGINE_PRIMITIVES) {
|
|
128
|
+
const {
|
|
129
|
+
loadProject, dataRoot, adapterFactory, clock,
|
|
130
|
+
watcherFactory = createNullWatcherFactory(), extensions = createMaintenanceExtensions(), eligibility = DEFAULT_ELIGIBILITY,
|
|
131
|
+
fullReconciliationIntervalMs = DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, lateWriterWindowMs = DEFAULT_LATE_WRITER_WINDOW_MS,
|
|
132
|
+
quietPeriodMs, lstat = fs.lstatSync, randomBytes, env = process.env, platform = process.platform,
|
|
133
|
+
// A service names where it answers health, so a lock it leaves behind can be proven abandoned.
|
|
134
|
+
lockOwner = null, lockProbe = probeHealth,
|
|
135
|
+
} = options
|
|
136
|
+
if (typeof loadProject !== 'function') throw new TypeError('the engine needs loadProject')
|
|
137
|
+
if (typeof clock !== 'function') throw new TypeError('the engine needs an injected clock')
|
|
138
|
+
// No default: the production adapter reaches a running app, and only the owner of the lifecycle may decide that.
|
|
139
|
+
if (typeof adapterFactory !== 'function') throw new TypeError('the engine needs an adapterFactory')
|
|
140
|
+
const seams = { ...createProductionSeams(), ...(options.seams ?? {}) }
|
|
141
|
+
const rules = { ...ENGINE_PRIMITIVES, ...primitives }
|
|
142
|
+
|
|
143
|
+
const index = new Map()
|
|
144
|
+
const hintedKeys = new Set()
|
|
145
|
+
const hintedPrefixes = new Set()
|
|
146
|
+
const stores = new Map()
|
|
147
|
+
// One graph file cache, and one preparation cache per scope, kept for the engine's lifetime. Derived state only:
|
|
148
|
+
// each is reused under a content digest or dependency key that proves the cached value equal to a fresh one, and a
|
|
149
|
+
// dropped cache costs a full build or preparation, never a wrong one.
|
|
150
|
+
const graphCache = seams.createGraphCache?.() ?? null
|
|
151
|
+
const preparationCaches = new Map()
|
|
152
|
+
const preparationCacheFor = (scopeId) => {
|
|
153
|
+
if (!preparationCaches.has(scopeId)) preparationCaches.set(scopeId, seams.createPreparationCache?.() ?? null)
|
|
154
|
+
return preparationCaches.get(scopeId)
|
|
155
|
+
}
|
|
156
|
+
let project = null
|
|
157
|
+
let observedAssets = []
|
|
158
|
+
let semantic = null
|
|
159
|
+
let lastFullMs = null
|
|
160
|
+
let forceFull = false
|
|
161
|
+
let firstTick = true
|
|
162
|
+
let running = false
|
|
163
|
+
let watching = { signature: null, handle: null }
|
|
164
|
+
let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
|
|
165
|
+
let heldLock = null
|
|
166
|
+
const proveAbandoned = createAbandonmentProof({ probe: lockProbe })
|
|
167
|
+
|
|
168
|
+
async function takeLock(workspaceRoot, workspaceId) {
|
|
169
|
+
if (heldLock) return { acquired: true }
|
|
170
|
+
const lock = await rules.acquireEngineLock({
|
|
171
|
+
workspaceRoot, directory: path.join(workspaceRoot, ENGINE_LOCK_DIRECTORY), workspaceId, purpose: 'maintenance-engine', service: lockOwner, clock, proveAbandoned,
|
|
172
|
+
})
|
|
173
|
+
if (lock.acquired) heldLock = lock
|
|
174
|
+
return lock
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function dropLock() {
|
|
178
|
+
const lock = heldLock
|
|
179
|
+
heldLock = null
|
|
180
|
+
lock?.release()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function onWatcherEvent(roots, { rootId, relative }) {
|
|
184
|
+
const root = roots.find((item) => item.id === rootId)
|
|
185
|
+
if (!root) return
|
|
186
|
+
if (relative === null || relative === undefined) hintedPrefixes.add(root.keyPrefix)
|
|
187
|
+
else hintedKeys.add(root.keyOf(relative))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function watch(roots) {
|
|
191
|
+
const signature = JSON.stringify(roots.map((root) => [root.id, root.path]))
|
|
192
|
+
if (watching.signature === signature) return
|
|
193
|
+
stopWatching()
|
|
194
|
+
watching = { signature, handle: watcherFactory({ roots: roots.map(({ id, path: rootPath, recursive }) => ({ id, path: rootPath, recursive })), onEvent: (event) => onWatcherEvent(roots, event) }) }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stopWatching() {
|
|
198
|
+
try { watching.handle?.close() } finally { watching = { signature: null, handle: null } }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) {
|
|
202
|
+
const signature = JSON.stringify([workspaceRoot, workspaceId, repositoryRoots])
|
|
203
|
+
const cached = stores.get(scope.scopeId)
|
|
204
|
+
if (cached?.signature === signature) return cached.store
|
|
205
|
+
const store = seams.createRecoveryStore({ workspaceRoot, workspaceId, scopeId: scope.scopeId, repositoryRoots })
|
|
206
|
+
stores.set(scope.scopeId, { signature, store })
|
|
207
|
+
return store
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function freshnessDocument({ workspaceId, enablement, maintenanceMode, now, entries }) {
|
|
211
|
+
return {
|
|
212
|
+
schema: FRESHNESS_SCHEMA, workspaceId, enablement, maintenanceMode, lastTickAt: now,
|
|
213
|
+
lastFullReconciliationAt: lastFullMs === null ? null : new Date(lastFullMs).toISOString(), scopes: [...entries.values()],
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readPreviousFreshness(stateStore) {
|
|
218
|
+
// Derived state: a document that does not validate is rebuilt, not trusted.
|
|
219
|
+
try { return stateStore.readFreshness() } catch (error) { if (isTypedRefusal(error)) return null; throw error }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const blankEntry = (scopeId, now) => ({ scopeId, state: 'stale', reason: 'not-yet-published', generationId: null, preparedGenerationId: null, verified: false, heldNotes: [], changeClasses: [], retainedEdits: 0, checkedAt: now })
|
|
223
|
+
const demote = (entry, state, reason, now) => ({ ...entry, state, reason, verified: false, checkedAt: now })
|
|
224
|
+
|
|
225
|
+
// Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state.
|
|
226
|
+
async function persistOutcome({ enablement, state, reason, now, scopeIds = [] }) {
|
|
227
|
+
if (!known) {
|
|
228
|
+
let pointer = null
|
|
229
|
+
try { pointer = project ? readLocalPointer(project) : null } catch { pointer = null }
|
|
230
|
+
if (!pointer) return
|
|
231
|
+
let root
|
|
232
|
+
try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId) } catch { return }
|
|
233
|
+
const stateStore = createMaintenanceStateStore({ workspaceRoot: root, workspaceId: pointer.workspaceId })
|
|
234
|
+
if (!stateStore.exists()) return
|
|
235
|
+
known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual' }
|
|
236
|
+
}
|
|
237
|
+
// Another engine is ticking this workspace: its state is not ours to write.
|
|
238
|
+
if (!(await takeLock(known.stateStore.workspaceRoot, known.stateStore.workspaceId)).acquired) return
|
|
239
|
+
const previous = readPreviousFreshness(known.stateStore)
|
|
240
|
+
const entries = new Map()
|
|
241
|
+
for (const entry of previous?.scopes ?? []) entries.set(entry.scopeId, demote(entry, state, reason, now))
|
|
242
|
+
for (const scopeId of scopeIds) if (!entries.has(scopeId)) entries.set(scopeId, demote(blankEntry(scopeId, now), state, reason, now))
|
|
243
|
+
known.stateStore.writeFreshness(freshnessDocument({ workspaceId: known.stateStore.workspaceId, enablement, maintenanceMode: known.maintenanceMode, now, entries }))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function runTick() {
|
|
247
|
+
const now = isoTime(clock)
|
|
248
|
+
const nowMs = Date.parse(now)
|
|
249
|
+
const full = firstTick || forceFull || rules.isFullReconciliationDue({ nowMs, lastFullMs, intervalMs: fullReconciliationIntervalMs })
|
|
250
|
+
const changes = []
|
|
251
|
+
// The hints collected so far belong to this tick; one that arrives while it runs belongs to the next.
|
|
252
|
+
const tickKeys = new Set(hintedKeys)
|
|
253
|
+
const tickPrefixes = [...hintedPrefixes]
|
|
254
|
+
hintedKeys.clear()
|
|
255
|
+
hintedPrefixes.clear()
|
|
256
|
+
const hinted = (key) => tickKeys.has(key) || tickPrefixes.some((prefix) => key.startsWith(prefix))
|
|
257
|
+
|
|
258
|
+
// 1. Project and enablement. The digests are seeded before the project is
|
|
259
|
+
// loaded for good, so the loaded project is never older than the index.
|
|
260
|
+
if (project === null) {
|
|
261
|
+
project = loadProject()
|
|
262
|
+
reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full: true, lstat })
|
|
263
|
+
project = loadProject()
|
|
264
|
+
} else {
|
|
265
|
+
const config = reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full, hinted, lstat })
|
|
266
|
+
if (config.changes.length > 0) {
|
|
267
|
+
project = loadProject()
|
|
268
|
+
reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full: true, lstat })
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const enablement = readObsidianEnablement(project)
|
|
272
|
+
if (enablement.state === 'disabled') {
|
|
273
|
+
stopWatching()
|
|
274
|
+
await persistOutcome({ enablement: 'disabled', state: 'disabled', reason: enablement.reason, now, scopeIds: enablement.scopes.map((scope) => scope.scopeId) })
|
|
275
|
+
semantic = null
|
|
276
|
+
firstTick = true
|
|
277
|
+
return { state: 'disabled', reason: enablement.reason, full, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 2. Identity and machine settings, outside every repository.
|
|
281
|
+
const pointer = ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
|
|
282
|
+
const { workspaceId } = pointer
|
|
283
|
+
const requestedRoot = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), workspaceId)
|
|
284
|
+
const repositoryRoots = protectedRoots(project)
|
|
285
|
+
assertOutsideRepositories({ managedRoot: requestedRoot, repositoryRoots })
|
|
286
|
+
fs.mkdirSync(requestedRoot, { recursive: true, mode: 0o700 })
|
|
287
|
+
const workspaceRoot = fs.realpathSync(requestedRoot)
|
|
288
|
+
const lock = await takeLock(workspaceRoot, workspaceId)
|
|
289
|
+
if (!lock.acquired) return { state: 'busy', reason: 'engine-lock-held', lock: { reason: lock.reason, holder: lock.holder ?? null }, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
|
|
290
|
+
const machine = readMachineSettings({ workspaceRoot, workspaceId })
|
|
291
|
+
?? writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: defaultMachineSettings({ workspaceId, updatedAt: now }) })
|
|
292
|
+
const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
|
|
293
|
+
known = { stateStore, maintenanceMode: machine.maintenanceMode }
|
|
294
|
+
const scopes = enablement.scopes.map((scope) => ({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }))
|
|
295
|
+
|
|
296
|
+
watch([
|
|
297
|
+
...(project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => ({ id: `repo:${repo.name}`, path: repo.path, recursive: true, keyPrefix: sourceKey(repo.name, ''), keyOf: (relative) => sourceKey(repo.name, relative) })),
|
|
298
|
+
...scopes.map(({ scope, store }) => ({ id: `vault:${scope.scopeId}`, path: store.vaultRoot, recursive: true, keyPrefix: vaultKey(scope.scopeId, ''), keyOf: (relative) => vaultKey(scope.scopeId, relative) })),
|
|
299
|
+
...[...new Set(listConfigFiles(project).map((file) => path.dirname(file.absolute)))].filter((directory) => fs.existsSync(directory))
|
|
300
|
+
.map((directory) => ({ id: `config:${directory}`, path: directory, recursive: false, keyPrefix: CONFIG_PREFIX, keyOf: (relative) => configKey(path.join(directory, relative)) })),
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
// 3. Vault notes: preserve, then queue. Compared with the bytes each note
|
|
304
|
+
// was generated with, not with the previous look.
|
|
305
|
+
const pending = stateStore.readPendingEdits()
|
|
306
|
+
let edits = pending.edits
|
|
307
|
+
const basesOf = new Map()
|
|
308
|
+
for (const { scope, store } of scopes) {
|
|
309
|
+
const { manifest, bases } = trustedNoteBases(store)
|
|
310
|
+
basesOf.set(scope.scopeId, bases)
|
|
311
|
+
if (!manifest) continue
|
|
312
|
+
const vault = reconcile({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat })
|
|
313
|
+
const digestOf = (notePath) => index.get(vaultKey(scope.scopeId, notePath))?.digest ?? null
|
|
314
|
+
const observed = observeVaultEdits({ store, workspaceId, scopeId: scope.scopeId, manifest, bases, digestOf, edits, now, preserve: rules.preserve })
|
|
315
|
+
edits = observed.edits
|
|
316
|
+
// A note that has gone is restored by the publisher; its disappearance is a change, once.
|
|
317
|
+
if (observed.events.length > 0 || vault.changes.some((change) => change.kind === 'removed')) changes.push({ changeClass: 'vault-note', scopeId: scope.scopeId })
|
|
318
|
+
}
|
|
319
|
+
stateStore.writePendingEdits({ ...pending, edits })
|
|
320
|
+
|
|
321
|
+
// Observation. The registered proposal adapter is handed a copy of the pending edits and observes the open ones
|
|
322
|
+
// the object store does not know yet, a bounded number per tick: each is classified from its preserved bytes
|
|
323
|
+
// against the source as it is now and recorded as what it is, in manual and in automatic mode alike, and no
|
|
324
|
+
// source is written. What refused before anything was recorded is offered again on a full reconciliation only.
|
|
325
|
+
const proposalAdapter = extensions.get('proposal-adapter')
|
|
326
|
+
const adapterContext = () => ({ project, workspaceRoot, workspaceId, repositoryRoots, edits: structuredClone(edits), clock, env })
|
|
327
|
+
let observed = null
|
|
328
|
+
if (proposalAdapter !== null && rules.observeEdits(proposalAdapter)) {
|
|
329
|
+
try { observed = await proposalAdapter.observe(adapterContext(), { retryRefused: full }) } catch { observed = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 4. Automatic apply, through the registered operation only.
|
|
333
|
+
let dispatched = []
|
|
334
|
+
if (rules.dispatchAllowed(machine.maintenanceMode)) {
|
|
335
|
+
const outcome = await dispatchAutomaticApply({
|
|
336
|
+
edits, applyOperation: extensions.applyOperation(), now, nowMs, retryIntervalMs,
|
|
337
|
+
authorize: () => rules.authorize({ workspaceRoot, workspaceId }),
|
|
338
|
+
})
|
|
339
|
+
edits = outcome.edits
|
|
340
|
+
dispatched = outcome.dispatched
|
|
341
|
+
stateStore.writePendingEdits({ ...pending, edits })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Structural edits. The registered proposal adapter is handed the pending edits once per tick and decides by its
|
|
345
|
+
// own record which of them it has not settled; it creates copy-only proposals and writes no source and no vault.
|
|
346
|
+
// Without one registered nothing is handed anywhere, and an adapter that throws changes nothing else of the tick.
|
|
347
|
+
let proposals = null
|
|
348
|
+
if (proposalAdapter !== null) {
|
|
349
|
+
try { proposals = await proposalAdapter.propose(adapterContext()) } catch { proposals = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 5. Late writers. The publisher looks twice per publication; a holder can write later still.
|
|
353
|
+
const lateWriters = []
|
|
354
|
+
const lateDocument = stateStore.readLateWriters()
|
|
355
|
+
const knownFindings = new Set(lateDocument.findings.map((item) => `${item.scopeId}\u0000${item.journalId}\u0000${item.unit}\u0000${item.observedDigest}`))
|
|
356
|
+
for (const { scope, store } of scopes) {
|
|
357
|
+
const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
|
|
358
|
+
if (journalIds.length === 0) continue
|
|
359
|
+
for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
|
|
360
|
+
if (finding.code !== 'late-writer-captured') continue
|
|
361
|
+
const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
|
|
362
|
+
if (knownFindings.has(key)) continue
|
|
363
|
+
knownFindings.add(key)
|
|
364
|
+
lateWriters.push({ scopeId: scope.scopeId, journalId: finding.journalId, unit: finding.unit, notePath: finding.notePath ?? null, digestAtMove: finding.digestAtMove ?? null, observedDigest: finding.observedDigest, objectRef: finding.objectRef, detectedAt: now })
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (lateWriters.length > 0) {
|
|
368
|
+
const findings = [...lateDocument.findings, ...lateWriters].sort((left, right) => compareText(left.detectedAt, right.detectedAt) || compareText(left.journalId, right.journalId) || left.unit - right.unit || compareText(left.observedDigest, right.observedDigest))
|
|
369
|
+
stateStore.writeLateWriters({ schema: LATE_WRITERS_SCHEMA, workspaceId, findings })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 6. Sources, settings, scopes, eligibility.
|
|
373
|
+
const sources = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat })
|
|
374
|
+
for (const change of sources.changes) changes.push({ changeClass: change.changeClass })
|
|
375
|
+
const { scopes: _declared, ...settingsWithoutScopes } = enablement.settings
|
|
376
|
+
// What the configuration says apart from this integration's own member: a change to the member alone is an
|
|
377
|
+
// ext-settings or a scope change, and a scope change invalidates that scope only.
|
|
378
|
+
const { [OBSIDIAN_EXT_KEY]: _member, ...otherExt } = project.config.ext ?? {}
|
|
379
|
+
const nextSemantic = {
|
|
380
|
+
config: digestOfJson({ document: { ...project.config, ext: otherExt }, files: [...index].filter(([key]) => key.startsWith(CONFIG_PREFIX) && key !== configKey(project.configPath)).map(([key, entry]) => [key.slice(CONFIG_PREFIX.length), entry.digest]) }),
|
|
381
|
+
settings: digestOfJson(settingsWithoutScopes),
|
|
382
|
+
scopes: new Map(enablement.scopes.map((scope) => [scope.scopeId, digestOfJson(scope)])),
|
|
383
|
+
eligibility: digestOfJson({ audienceAllow: [...machine.audienceAllow].sort(), revision: String(eligibility.revision()) }),
|
|
384
|
+
}
|
|
385
|
+
if (semantic) {
|
|
386
|
+
if (semantic.config !== nextSemantic.config) changes.push({ changeClass: 'config' })
|
|
387
|
+
if (semantic.settings !== nextSemantic.settings) changes.push({ changeClass: 'ext-settings' })
|
|
388
|
+
if (semantic.eligibility !== nextSemantic.eligibility) changes.push({ changeClass: 'eligibility' })
|
|
389
|
+
for (const [scopeId, digest] of nextSemantic.scopes) if (semantic.scopes.get(scopeId) !== digest) changes.push({ changeClass: 'scope', scopeId })
|
|
390
|
+
}
|
|
391
|
+
if (full) { lastFullMs = nowMs; forceFull = false }
|
|
392
|
+
|
|
393
|
+
// 7. Which views are invalid.
|
|
394
|
+
const previous = readPreviousFreshness(stateStore)
|
|
395
|
+
const entries = new Map()
|
|
396
|
+
const unseen = new Set()
|
|
397
|
+
for (const { scope } of scopes) {
|
|
398
|
+
const carried = previous?.scopes.find((entry) => entry.scopeId === scope.scopeId && entry.state !== 'disabled')
|
|
399
|
+
if (!carried) unseen.add(scope.scopeId)
|
|
400
|
+
entries.set(scope.scopeId, carried ?? blankEntry(scope.scopeId, now))
|
|
401
|
+
}
|
|
402
|
+
const attempt = new Map()
|
|
403
|
+
const invalidate = (scopeId, changeClass) => attempt.set(scopeId, new Set([...(attempt.get(scopeId) ?? []), ...(changeClass ? [changeClass] : [])]))
|
|
404
|
+
for (const { scope } of scopes) {
|
|
405
|
+
const entry = entries.get(scope.scopeId)
|
|
406
|
+
// A view that did not settle is tried again at the full reconciliation cadence, not on every tick.
|
|
407
|
+
if (firstTick || unseen.has(scope.scopeId) || (full && RETRIED_STATES.has(entry.state))) invalidate(scope.scopeId, null)
|
|
408
|
+
}
|
|
409
|
+
for (const change of changes) for (const { scope } of scopes) if (change.scopeId === undefined || change.scopeId === scope.scopeId) invalidate(scope.scopeId, change.changeClass)
|
|
410
|
+
|
|
411
|
+
// Invalidation is durable before any work: a view is not reported current while it is being rebuilt.
|
|
412
|
+
for (const [scopeId, classes] of attempt) entries.set(scopeId, { ...demote(entries.get(scopeId), 'stale', 'invalidated', now), changeClasses: [...classes].sort() })
|
|
413
|
+
for (const entry of previous?.scopes ?? []) if (!entries.has(entry.scopeId)) entries.set(entry.scopeId, demote(entry, 'disabled', 'scope-not-configured', now))
|
|
414
|
+
const persist = () => stateStore.writeFreshness(freshnessDocument({ workspaceId, enablement: 'enabled', maintenanceMode: machine.maintenanceMode, now, entries }))
|
|
415
|
+
persist()
|
|
416
|
+
|
|
417
|
+
// 8. Rebuild and publish.
|
|
418
|
+
if (attempt.size > 0) {
|
|
419
|
+
let built = null
|
|
420
|
+
try {
|
|
421
|
+
const graph = seams.buildGraph({ project, eligibility, cache: graphCache, index })
|
|
422
|
+
// The assets this graph lets a view copy are observed from now on, and hashed now so the snapshot pins
|
|
423
|
+
// what observation saw. A withheld asset is not observed: its bytes can change no view.
|
|
424
|
+
const formerAssetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
|
|
425
|
+
observedAssets = rules.observedAssetsOf(graph)
|
|
426
|
+
const assetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
|
|
427
|
+
const settled = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat })
|
|
428
|
+
// An asset newly observed, or one no longer embedded, is the list changing. Anything else that moved since this tick looked at the sources moved while the graph was being read.
|
|
429
|
+
if (settled.changes.some((change) => !(change.kind === 'added' && assetKeys.has(change.key)) && !(change.kind === 'removed' && formerAssetKeys.has(change.key) && !assetKeys.has(change.key)))) throw new ObsidianMaintenanceRefusal('mixed-read', 'a source changed while the canonical graph was being built')
|
|
430
|
+
const configDigest = digestOfJson([...index].filter(([key]) => key.startsWith(CONFIG_PREFIX)).map(([key, entry]) => [path.basename(key.slice(CONFIG_PREFIX.length)), entry.digest]))
|
|
431
|
+
built = {
|
|
432
|
+
snapshot: seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now }),
|
|
433
|
+
profile: seams.profileFor({ project, workspaceId, audienceAllow: machine.audienceAllow }),
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (!isTypedRefusal(error)) throw error
|
|
437
|
+
for (const scopeId of attempt.keys()) entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now))
|
|
438
|
+
if (REREAD_CODES.has(error.code)) forceFull = true
|
|
439
|
+
}
|
|
440
|
+
for (const { scope, store } of built ? scopes.filter((item) => attempt.has(item.scope.scopeId)) : []) {
|
|
441
|
+
const { scopeId } = scope
|
|
442
|
+
const entry = entries.get(scopeId)
|
|
443
|
+
const settle = (state, reason, extra = {}) => entries.set(scopeId, { ...entry, ...extra, state, reason, verified: extra.verified === true, checkedAt: now })
|
|
444
|
+
try {
|
|
445
|
+
const prepared = seams.prepareView({
|
|
446
|
+
snapshot: built.snapshot, profile: built.profile, scope, persistentPathRegistry: stateStore.readPathRegistry(), priorManifest: store.readCurrentManifest(),
|
|
447
|
+
existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), cache: preparationCacheFor(scopeId),
|
|
448
|
+
})
|
|
449
|
+
stateStore.writePathRegistry(prepared.persistentPathRegistry)
|
|
450
|
+
const preparedGenerationId = prepared.manifest.generationId
|
|
451
|
+
const trusted = () => store.readCurrent()
|
|
452
|
+
const held = heldPaths(edits, scopeId)
|
|
453
|
+
const observed = new Map(held.map((notePath) => [notePath, rules.heldNoteDigest({ file: path.join(store.vaultRoot, notePath), indexed: index.get(vaultKey(scopeId, notePath))?.digest ?? null })]))
|
|
454
|
+
const conflicts = rules.publicationConflicts({ prepared, held, bases: basesOf.get(scopeId) ?? new Map(), observed })
|
|
455
|
+
if (conflicts.length > 0) {
|
|
456
|
+
// Not even attempted: the prepared view would replace a note somebody edited.
|
|
457
|
+
settle('held-for-your-edit', 'publication-withheld-for-your-edit', { generationId: trusted()?.generationId ?? null, preparedGenerationId, heldNotes: held })
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
settle('updating', 'publishing', { generationId: trusted()?.generationId ?? null, preparedGenerationId, heldNotes: held })
|
|
461
|
+
persist()
|
|
462
|
+
const result = await seams.publishView({
|
|
463
|
+
preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter: adapterFactory({ store, scope }), clock,
|
|
464
|
+
...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
|
|
465
|
+
})
|
|
466
|
+
const pointerNow = trusted()
|
|
467
|
+
const common = { generationId: pointerNow?.generationId ?? null, preparedGenerationId, retainedEdits: pointerNow?.retained?.length ?? 0 }
|
|
468
|
+
if (result.state === 'refused') {
|
|
469
|
+
settle(CONFLICT_REFUSALS.has(result.refusal.code) ? 'publisher-conflict' : 'stale', result.refusal.code, { ...common, heldNotes: held })
|
|
470
|
+
} else if (result.state === 'updating') {
|
|
471
|
+
const blocking = result.notes.filter((note) => note.blocking)
|
|
472
|
+
const byEdit = blocking.length > 0 && blocking.every((note) => EDIT_OUTCOMES.has(note.outcome))
|
|
473
|
+
settle(byEdit ? 'held-for-your-edit' : 'publisher-conflict', blocking[0]?.outcome ?? 'publication-incomplete', { ...common, heldNotes: [...new Set([...held, ...blocking.filter((note) => EDIT_OUTCOMES.has(note.outcome)).map((note) => note.path)])].sort(compareText) })
|
|
474
|
+
} else {
|
|
475
|
+
// Read back: every note of the committed generation, by digest.
|
|
476
|
+
const { manifest, bases } = trustedNoteBases(store)
|
|
477
|
+
basesOf.set(scopeId, bases)
|
|
478
|
+
reconcile({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat })
|
|
479
|
+
const differing = [...bases].filter(([notePath, base]) => index.get(vaultKey(scopeId, notePath))?.digest !== base.digest).map(([notePath]) => notePath).sort(compareText)
|
|
480
|
+
const editedUnderPublisher = result.notes.some((note) => note.outcome === 'edit-kept' || note.changedAfterPublication === true)
|
|
481
|
+
if (held.length > 0) settle('held-for-your-edit', 'edit-pending', { ...common, heldNotes: held })
|
|
482
|
+
else if (differing.some((notePath) => (index.get(vaultKey(scopeId, notePath))?.digest ?? null) === null)) settle('stale', 'vault-note-missing', common)
|
|
483
|
+
// Somebody wrote during the publication. The next tick preserves and queues it.
|
|
484
|
+
else if (differing.length > 0 || editedUnderPublisher) settle('updating', 'read-back-differs', common)
|
|
485
|
+
else if (common.generationId !== preparedGenerationId) settle('stale', 'committed-generation-differs', common)
|
|
486
|
+
else settle('current', result.alreadyCommitted ? 'verified-by-read-back' : 'published-and-verified', { ...common, heldNotes: [], verified: true })
|
|
487
|
+
}
|
|
488
|
+
} catch (error) {
|
|
489
|
+
if (!isTypedRefusal(error)) { settle('stale', 'publisher-error'); persist(); throw error }
|
|
490
|
+
settle('stale', error.code)
|
|
491
|
+
if (REREAD_CODES.has(error.code)) forceFull = true
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
persist()
|
|
496
|
+
semantic = nextSemantic
|
|
497
|
+
firstTick = false
|
|
498
|
+
return {
|
|
499
|
+
state: 'ticked', full, workspaceId, maintenanceMode: machine.maintenanceMode,
|
|
500
|
+
changes: changes.map((change) => ({ ...change })), scopes: [...entries.values()].sort((left, right) => compareText(left.scopeId, right.scopeId)),
|
|
501
|
+
pendingEdits: edits.filter((edit) => edit.closedAt === null).map(({ editId, scopeId, path: notePath, state }) => ({ editId, scopeId, path: notePath, state })),
|
|
502
|
+
dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }),
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
extensions,
|
|
508
|
+
async tick() {
|
|
509
|
+
if (running) return { state: 'busy' }
|
|
510
|
+
running = true
|
|
511
|
+
try {
|
|
512
|
+
return await runTick()
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (!isTypedRefusal(error)) throw error
|
|
515
|
+
// Fail closed: nothing is published under a refusal, and every known view says why.
|
|
516
|
+
try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock) }) } catch { /* the refusal itself is still reported */ }
|
|
517
|
+
project = null
|
|
518
|
+
semantic = null
|
|
519
|
+
firstTick = true
|
|
520
|
+
return { state: 'refused', refusal: { code: error.code, message: error.message, detail: error.detail ?? {} }, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
|
|
521
|
+
} finally {
|
|
522
|
+
try { dropLock() } finally { running = false }
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
stop() { stopWatching() },
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Production entry point: the production primitives, always.
|
|
530
|
+
export function createMaintenanceEngine(options = {}) {
|
|
531
|
+
return createMaintenanceEngineForOracleTests(options, ENGINE_PRIMITIVES)
|
|
532
|
+
}
|