@mnstry/atelier 0.2.0-alpha.6 → 0.2.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +176 -0
- package/README.md +59 -15
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/docs/assurance-controls.md +9 -1
- package/docs/atelier-sync.md +5 -4
- package/docs/blocks/claims.md +15 -10
- package/docs/blocks/will-not-do.md +4 -1
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/decisions.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +65 -9
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +227 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +687 -0
- package/docs/obsidian.md +387 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +88 -0
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +55 -2
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +57 -6
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-open/SKILL.md +5 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-open/SKILL.md +5 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/policy.mjs +57 -25
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +43 -3
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +16 -33
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/extension-pack.mjs +15 -8
- package/src/commands/init.mjs +44 -5
- package/src/commands/obsidian.mjs +368 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +8 -1
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +29 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +13 -0
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +64 -2
- package/src/graph/knowledge-graph.mjs +548 -24
- package/src/guides/contracts.mjs +54 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +26 -27
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +166 -0
- package/src/project/private-state.mjs +11 -1
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +139 -0
- package/src/projection/obsidian/recovery/index.mjs +6 -0
- package/src/projection/obsidian/recovery/journal.mjs +134 -0
- package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
- package/src/projection/obsidian/recovery/restart.mjs +316 -0
- package/src/projection/obsidian/recovery/store.mjs +234 -0
- package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
- package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
- package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
- package/src/projection/obsidian/selection-ui/index.mjs +12 -0
- package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
- package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
- package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
- package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
- package/src/projection/project.mjs +28 -19
- package/src/readiness-protocols/evidence.mjs +282 -0
- package/src/readiness-protocols/runtime.mjs +4 -3
- package/src/readiness-protocols/source-read.mjs +39 -0
- package/src/runtime/obsidian/app-capability.mjs +110 -0
- package/src/runtime/obsidian/app-production-seams.mjs +96 -0
- package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
- package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
- package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
- package/src/runtime/obsidian/contributions.mjs +27 -0
- package/src/runtime/obsidian/documents.mjs +37 -0
- package/src/runtime/obsidian/enablement.mjs +53 -0
- package/src/runtime/obsidian/engine.mjs +532 -0
- package/src/runtime/obsidian/errors.mjs +14 -0
- package/src/runtime/obsidian/extension-points.mjs +103 -0
- package/src/runtime/obsidian/index.mjs +34 -0
- package/src/runtime/obsidian/lifecycle.mjs +258 -0
- package/src/runtime/obsidian/machine-settings.mjs +260 -0
- package/src/runtime/obsidian/observation.mjs +158 -0
- package/src/runtime/obsidian/opening.mjs +243 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-main.mjs +89 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/supervisor.mjs +16 -1
- package/src/server/local-sidecar.mjs +44 -0
- package/src/server/server.mjs +1 -0
- package/src/skills/steward.mjs +780 -0
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +63 -28
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/project/README.md +2 -2
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import fsp from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
5
|
+
import { acquirePrivateLock, publishPrivateFile, syncPrivateDirectory } from '../../../project/durable-state.mjs'
|
|
6
|
+
import { ObsidianContractRefusal, assertObsidianContract } from '../contracts.mjs'
|
|
7
|
+
import { isPolicySettingsPath, isUserOwnedSettingsPath, prepareSettings } from '../materialize/settings.mjs'
|
|
8
|
+
import { createJournal, newJournalId } from '../recovery/journal.mjs'
|
|
9
|
+
import { recheckDisplacedFiles } from '../recovery/late-writer.mjs'
|
|
10
|
+
import { publishedSinceCommit, reconcileUnit, recordDisplaced, recoverPublicationsLocked, retireStagedFile } from '../recovery/restart.mjs'
|
|
11
|
+
import { PublicationRefusal, acquireVaultLock, readFileBytes, readFileDigest, refuse, sha256Digest } from '../recovery/store.mjs'
|
|
12
|
+
import { PROTOCOL_ID, isAddressableVaultPath } from './bridge-script.mjs'
|
|
13
|
+
import { probeExchange } from './exchange.mjs'
|
|
14
|
+
import { CRASH_INJECTION_TEST_SEAM } from './test-seam.mjs'
|
|
15
|
+
import { createDirectAdapter } from './transport.mjs'
|
|
16
|
+
|
|
17
|
+
// publishView: conditional publication of one prepared view into an editable
|
|
18
|
+
// vault. The order is the contract's:
|
|
19
|
+
//
|
|
20
|
+
// recover interrupted publications -> write every candidate in staging
|
|
21
|
+
// (same volume, fsync), store the manifest, open the journal, which names
|
|
22
|
+
// every candidate path and digest, and move each candidate that will be
|
|
23
|
+
// exchanged into its unit's recovery directory -> per note: retain the comparison baseline,
|
|
24
|
+
// write the capture entry, publish conditionally, verify bytes -> re-check
|
|
25
|
+
// displaced files after a quiet period -> commit the trusted manifest last.
|
|
26
|
+
//
|
|
27
|
+
// Until the manifest is committed the view is `updating`. A refusal for one
|
|
28
|
+
// note does not stop the others; a refused note stays one generation behind
|
|
29
|
+
// and the view converges on a later run. No editable path is ever replaced
|
|
30
|
+
// unconditionally: an existing file changes only through the atomic exchange
|
|
31
|
+
// inside the critical section, a new file appears only through an exclusive
|
|
32
|
+
// link, and a file leaves only by being moved to recovery.
|
|
33
|
+
//
|
|
34
|
+
// Where candidates are: an exchange leaves the displaced bytes at the
|
|
35
|
+
// candidate's path, so a candidate that replaces an existing file is exchanged
|
|
36
|
+
// from `recovery/<journalId>/<unit>/`, never from staging. Staging holds only
|
|
37
|
+
// generated bytes that no exchange names: every candidate while it is written,
|
|
38
|
+
// and the candidates of created files until they are linked into the vault.
|
|
39
|
+
|
|
40
|
+
const hex = (digest) => digest.slice('sha256:'.length)
|
|
41
|
+
const iso = (clock) => { const value = clock(); return (value instanceof Date ? value : new Date(value)).toISOString() }
|
|
42
|
+
const REFUSED_BY_EDIT = new Set(['editor-edit', 'disk-changed'])
|
|
43
|
+
const unreleased = new Map()
|
|
44
|
+
|
|
45
|
+
function readNote(file) {
|
|
46
|
+
try { return readFileBytes(file) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validatePreparedView(preparedView, store) {
|
|
50
|
+
if (!preparedView || !Array.isArray(preparedView.files) || !preparedView.manifest) throw new TypeError('publishView needs a prepared view')
|
|
51
|
+
try { assertObsidianContract('generation-manifest', preparedView.manifest) } catch (error) {
|
|
52
|
+
if (error instanceof ObsidianContractRefusal) refuse('invalid-prepared-view', 'the prepared manifest does not satisfy its contract', error.detail)
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
const { manifest } = preparedView
|
|
56
|
+
if (manifest.scopeId !== store.scopeId) refuse('invalid-prepared-view', 'the prepared view belongs to another scope')
|
|
57
|
+
if (manifest.completeness.status !== 'complete') refuse('invalid-prepared-view', 'a partial generation is not published')
|
|
58
|
+
// Closed over the manifest itself: one path names one file, as a note or as an attachment, once.
|
|
59
|
+
const expected = new Map()
|
|
60
|
+
for (const [declared, fileDigest] of [...manifest.notes.map((note) => [note.path, note.noteDigest]), ...manifest.attachments.map((item) => [item.path, item.digest])]) {
|
|
61
|
+
if (expected.has(declared)) refuse('invalid-prepared-view', 'the manifest declares one path more than once')
|
|
62
|
+
expected.set(declared, fileDigest)
|
|
63
|
+
}
|
|
64
|
+
const seen = new Set()
|
|
65
|
+
for (const file of preparedView.files) {
|
|
66
|
+
if (!isAddressableVaultPath(file.path) || isUserOwnedSettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared path is outside what the publisher may write')
|
|
67
|
+
if (seen.has(file.path)) refuse('invalid-prepared-view', 'a prepared path appears twice')
|
|
68
|
+
seen.add(file.path)
|
|
69
|
+
if (file.kind === 'settings') {
|
|
70
|
+
if (!isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'only the policy settings file may be prepared as settings')
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (!['note', 'attachment'].includes(file.kind) || isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared file has an unknown kind')
|
|
74
|
+
if (!Buffer.isBuffer(file.bytes) || sha256Digest(file.bytes) !== file.digest || expected.get(file.path) !== file.digest) {
|
|
75
|
+
refuse('invalid-prepared-view', 'prepared bytes, their digest and the manifest disagree')
|
|
76
|
+
}
|
|
77
|
+
expected.delete(file.path)
|
|
78
|
+
}
|
|
79
|
+
if (expected.size > 0) refuse('invalid-prepared-view', 'the manifest names a file the prepared view does not carry')
|
|
80
|
+
let parsedBytes = null
|
|
81
|
+
if (Buffer.isBuffer(preparedView.manifestBytes)) {
|
|
82
|
+
try { parsedBytes = JSON.parse(preparedView.manifestBytes.toString('utf8')) } catch { refuse('invalid-prepared-view', 'manifest bytes are not JSON') }
|
|
83
|
+
}
|
|
84
|
+
const manifestBytes = parsedBytes !== null && JSON.stringify(parsedBytes) === JSON.stringify(manifest)
|
|
85
|
+
? preparedView.manifestBytes
|
|
86
|
+
: Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
87
|
+
return { manifest, manifestBytes }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Every directory on the way to a vault path is a real directory. Missing
|
|
91
|
+
// ones are created; nothing existing is changed.
|
|
92
|
+
function ensureParents(vaultRoot, relativePath) {
|
|
93
|
+
let current = vaultRoot
|
|
94
|
+
for (const part of relativePath.split('/').slice(0, -1)) {
|
|
95
|
+
current = path.join(current, part)
|
|
96
|
+
let stat = fs.lstatSync(current, { throwIfNoEntry: false })
|
|
97
|
+
if (!stat) {
|
|
98
|
+
try { fs.mkdirSync(current) } catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
99
|
+
stat = fs.lstatSync(current)
|
|
100
|
+
}
|
|
101
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return false
|
|
102
|
+
}
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// `created` receives the path once this call has created the file, and not before: cleanup removes only those.
|
|
107
|
+
function stageCandidate(file, bytes, mode, created = [], crash = () => {}) {
|
|
108
|
+
const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, mode)
|
|
109
|
+
created.push(file)
|
|
110
|
+
try {
|
|
111
|
+
crash('after-late-candidate-open')
|
|
112
|
+
fs.writeFileSync(descriptor, bytes)
|
|
113
|
+
fs.fchmodSync(descriptor, mode)
|
|
114
|
+
fs.fsyncSync(descriptor)
|
|
115
|
+
} finally {
|
|
116
|
+
fs.closeSync(descriptor)
|
|
117
|
+
}
|
|
118
|
+
if (sha256Digest(readFileBytes(file)) !== sha256Digest(bytes)) throw Object.assign(new Error('staged bytes differ from the candidate'), { code: 'ESTAGE' })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Keep units, compared ahead of the per-unit loop: each vault file is read
|
|
122
|
+
// and hashed once, several at a time through the thread pool, and a unit whose
|
|
123
|
+
// bytes are exactly the candidate's is settled as unchanged. Nothing else is
|
|
124
|
+
// decided here. A keep unit that is absent, holds other bytes, sits under an
|
|
125
|
+
// unsafe parent, is not a regular file or cannot be read goes through
|
|
126
|
+
// publishUnit as before, which reads it again. The checks are the ones the
|
|
127
|
+
// synchronous path makes (every parent a real directory, the leaf a regular
|
|
128
|
+
// file opened without following a link and bound to the file that was
|
|
129
|
+
// inspected); only the ordering and the concurrency differ, and a kept note
|
|
130
|
+
// is never written, so its comparison needs no place in the unit order.
|
|
131
|
+
const KEEP_COMPARISON_WIDTH = 16
|
|
132
|
+
|
|
133
|
+
async function unchangedKeepUnits(units, store) {
|
|
134
|
+
const settled = new Set()
|
|
135
|
+
const pending = units.filter((unit) => unit.op === 'keep')
|
|
136
|
+
let next = 0
|
|
137
|
+
const worker = async () => {
|
|
138
|
+
while (next < pending.length) {
|
|
139
|
+
const unit = pending[next]
|
|
140
|
+
next += 1
|
|
141
|
+
if (await holdsCandidateBytes(store.vaultRoot, unit.path, unit.candidateDigest)) settled.add(unit.path)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await Promise.all(Array.from({ length: Math.min(KEEP_COMPARISON_WIDTH, pending.length) }, worker))
|
|
145
|
+
return settled
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function holdsCandidateBytes(vaultRoot, relativePath, candidateDigest) {
|
|
149
|
+
try {
|
|
150
|
+
let current = vaultRoot
|
|
151
|
+
for (const part of relativePath.split('/').slice(0, -1)) {
|
|
152
|
+
current = path.join(current, part)
|
|
153
|
+
const stat = await fsp.lstat(current)
|
|
154
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return false
|
|
155
|
+
}
|
|
156
|
+
const file = path.join(vaultRoot, relativePath)
|
|
157
|
+
const before = await fsp.lstat(file)
|
|
158
|
+
if (before.isSymbolicLink() || !before.isFile()) return false
|
|
159
|
+
const handle = await fsp.open(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0))
|
|
160
|
+
try {
|
|
161
|
+
const opened = await handle.stat()
|
|
162
|
+
if (!opened.isFile() || (before.ino !== 0 && (before.dev !== opened.dev || before.ino !== opened.ino))) return false
|
|
163
|
+
return sha256Digest(await handle.readFile()) === candidateDigest
|
|
164
|
+
} finally {
|
|
165
|
+
await handle.close()
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function planUnits({ files, priorManifest, pointer, ledger }) {
|
|
173
|
+
const trusted = new Map()
|
|
174
|
+
for (const note of priorManifest?.notes ?? []) trusted.set(note.path, note.noteDigest)
|
|
175
|
+
for (const attachment of priorManifest?.attachments ?? []) trusted.set(attachment.path, attachment.digest)
|
|
176
|
+
// A note kept because it was edited is still bound to the digest it was generated with.
|
|
177
|
+
for (const item of pointer?.retained ?? []) if (item.priorDigest && !item.movedToRecovery) trusted.set(item.path, item.priorDigest)
|
|
178
|
+
for (const [notePath, digest] of ledger) { if (digest === null) trusted.delete(notePath); else trusted.set(notePath, digest) }
|
|
179
|
+
|
|
180
|
+
const units = []
|
|
181
|
+
const present = new Set()
|
|
182
|
+
for (const file of files) {
|
|
183
|
+
present.add(file.path)
|
|
184
|
+
if (file.kind === 'settings') { units.push({ path: file.path, kind: 'settings', op: 'settings' }); continue }
|
|
185
|
+
const base = trusted.get(file.path)
|
|
186
|
+
const op = base === undefined ? 'create' : base === file.digest ? 'keep' : 'replace'
|
|
187
|
+
units.push({ path: file.path, kind: file.kind, op, baseDigest: base ?? null, candidateDigest: file.digest, bytes: file.bytes })
|
|
188
|
+
}
|
|
189
|
+
for (const [notePath, digest] of [...trusted].sort(([left], [right]) => (left < right ? -1 : 1))) {
|
|
190
|
+
if (!present.has(notePath)) units.push({ path: notePath, kind: notePath.startsWith('attachments/') ? 'attachment' : 'note', op: 'remove', baseDigest: digest, candidateDigest: null })
|
|
191
|
+
}
|
|
192
|
+
units.forEach((unit, index) => { unit.unit = index })
|
|
193
|
+
return units
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function publishView(options = {}) {
|
|
197
|
+
const { preparedView, protocolId, expectedGeneration, recoveryStore: store, adapter, clock = () => new Date(), quietPeriodMs = 1500, exchangeOptions = {} } = options
|
|
198
|
+
const seam = options[CRASH_INJECTION_TEST_SEAM] ?? null
|
|
199
|
+
const crash = (point) => { if (seam && seam.at === point) seam.halt(point) }
|
|
200
|
+
if (!store || typeof store.commitManifest !== 'function') throw new TypeError('publishView needs a recoveryStore')
|
|
201
|
+
if (!adapter || typeof adapter.probe !== 'function') throw new TypeError('publishView needs an editor coordination adapter')
|
|
202
|
+
if (expectedGeneration !== null && typeof expectedGeneration !== 'string') throw new TypeError('expectedGeneration must be a generation identity or null')
|
|
203
|
+
|
|
204
|
+
const releases = []
|
|
205
|
+
let journal = null
|
|
206
|
+
try {
|
|
207
|
+
if (protocolId !== PROTOCOL_ID) refuse('unknown-protocol', 'the publisher implements exactly one publication protocol')
|
|
208
|
+
const { manifest, manifestBytes } = validatePreparedView(preparedView, store)
|
|
209
|
+
// A release that could not be written (a full disk) is finished first.
|
|
210
|
+
for (const lockPath of [store.vaultLockPath, store.lockPath]) {
|
|
211
|
+
if (!unreleased.has(lockPath)) continue
|
|
212
|
+
try { unreleased.get(lockPath)() } catch (error) { refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) }) }
|
|
213
|
+
unreleased.delete(lockPath)
|
|
214
|
+
}
|
|
215
|
+
// Two locks, the view's and then the vault's: a second view or a second
|
|
216
|
+
// workspace state pointed at the same vault refuses instead of racing.
|
|
217
|
+
const acquire = (lockPath, take, held) => {
|
|
218
|
+
try { releases.push([lockPath, take()]) } catch (error) {
|
|
219
|
+
if (error.code === 'EEXIST') refuse('publication-in-progress', `another publication ${held} holds the lock`)
|
|
220
|
+
refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
acquire(store.lockPath, () => acquirePrivateLock(store.lockPath), 'of this view')
|
|
224
|
+
acquire(store.vaultLockPath, () => acquireVaultLock(store), 'into this vault')
|
|
225
|
+
const recovered = recoverPublicationsLocked({ store, clock })
|
|
226
|
+
const pointer = store.readCurrent()
|
|
227
|
+
if (pointer?.generationId === manifest.generationId) {
|
|
228
|
+
return { state: 'committed', alreadyCommitted: true, generationId: manifest.generationId, journalId: pointer.journalId, notes: [], retainedEdits: pointer.retained ?? [], lateWriters: [], recovered }
|
|
229
|
+
}
|
|
230
|
+
if ((pointer?.generationId ?? null) !== expectedGeneration) refuse('generation-mismatch', 'the committed generation is not the one this publication expects', { committed: pointer?.generationId ?? null })
|
|
231
|
+
|
|
232
|
+
// Path selection.
|
|
233
|
+
const probe = await adapter.probe({ vaultRoot: store.vaultRoot })
|
|
234
|
+
if (probe.state !== 'coordinated' && probe.state !== 'absent') refuse('editor-uncoordinated', `an Obsidian process may have this vault open and cannot be coordinated with: ${probe.reason}`)
|
|
235
|
+
const mode = probe.state === 'coordinated' ? 'in-app' : 'direct'
|
|
236
|
+
const channel = mode === 'in-app' ? adapter : createDirectAdapter({ crashSeam: seam })
|
|
237
|
+
|
|
238
|
+
// Same volume, and an exchange that works on it.
|
|
239
|
+
const vaultDevice = fs.statSync(store.vaultRoot).dev
|
|
240
|
+
// Candidates are exchanged from the recovery area, so that is where the
|
|
241
|
+
// exchange is proven; staging must share the volume because candidates are moved from it.
|
|
242
|
+
let probeDir
|
|
243
|
+
try { probeDir = store.exchangeProbeDir() } catch (error) { refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) }) }
|
|
244
|
+
if ([store.stagingRoot, store.resolve('recovery'), probeDir].some((directory) => fs.statSync(directory).dev !== vaultDevice)) {
|
|
245
|
+
refuse('staging-volume-mismatch', 'staging and recovery must be on the volume that holds the vault')
|
|
246
|
+
}
|
|
247
|
+
const exchange = probeExchange({ directory: probeDir, ...exchangeOptions })
|
|
248
|
+
if (!exchange.supported) refuse(exchange.code, exchange.message)
|
|
249
|
+
|
|
250
|
+
const units = planUnits({ files: preparedView.files, priorManifest: store.readCurrentManifest(), pointer, ledger: publishedSinceCommit({ store }) })
|
|
251
|
+
|
|
252
|
+
// Stage every known candidate before anything is touched.
|
|
253
|
+
const journalId = newJournalId(clock)
|
|
254
|
+
const created = []
|
|
255
|
+
const moved = []
|
|
256
|
+
let stagingDir = null
|
|
257
|
+
try {
|
|
258
|
+
stagingDir = store.stagingDir(journalId)
|
|
259
|
+
for (const unit of units.filter((item) => item.op === 'replace' || item.op === 'create')) {
|
|
260
|
+
const existing = fs.lstatSync(path.join(store.vaultRoot, unit.path), { throwIfNoEntry: false })
|
|
261
|
+
unit.preparedPath = store.preparedPath(journalId, unit.unit)
|
|
262
|
+
stageCandidate(unit.preparedPath, unit.bytes, existing?.isFile() ? existing.mode & 0o777 : 0o644, created)
|
|
263
|
+
// A replacement is exchanged, and the exchange leaves the displaced bytes at this path: it is in recovery.
|
|
264
|
+
unit.stagedPath = unit.op === 'replace' ? store.exchangeCandidatePath(journalId, unit.unit) : unit.preparedPath
|
|
265
|
+
}
|
|
266
|
+
syncPrivateDirectory(stagingDir)
|
|
267
|
+
const manifestFile = path.join(store.journalDir(journalId), 'manifest.json')
|
|
268
|
+
publishPrivateFile(manifestFile, manifestBytes)
|
|
269
|
+
journal = createJournal(store, { journalId, protocolId, expectedGeneration, targetGeneration: manifest.generationId, clock,
|
|
270
|
+
detail: { mode, manifestRef: store.ref(manifestFile), manifestDigest: sha256Digest(manifestBytes),
|
|
271
|
+
// The units this run may write. A kept note is compared and never written; the one case that writes
|
|
272
|
+
// it after all (a kept note gone missing) is a late candidate with its own write-ahead entry.
|
|
273
|
+
units: units.filter((item) => item.op !== 'keep').map(({ unit, path: unitPath, kind, op }) => ({ unit, path: unitPath, kind, op })),
|
|
274
|
+
staged: units.filter((item) => item.stagedPath).map((item) => ({ unit: item.unit, path: item.path, candidateDigest: item.candidateDigest, stagedRef: store.ref(item.stagedPath),
|
|
275
|
+
...(item.stagedPath === item.preparedPath ? {} : { preparedRef: store.ref(item.preparedPath) }) })) } })
|
|
276
|
+
crash('before-candidate-move')
|
|
277
|
+
// The journal now names every exchange candidate by path and digest. Only complete, fsynced files are moved
|
|
278
|
+
// in, so a file at an exchange path is never a partial candidate.
|
|
279
|
+
for (const unit of units.filter((item) => item.stagedPath && item.stagedPath !== item.preparedPath)) {
|
|
280
|
+
moveToExchangePath(store, journalId, unit, moved)
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (error instanceof PublicationRefusal) throw error
|
|
284
|
+
// No payload has named any of these paths, so nothing was exchanged and they can only hold our own
|
|
285
|
+
// candidates. In staging they are removed by name. In recovery a file is removed only when its bytes are
|
|
286
|
+
// the candidate's; anything else stays for restart recovery, which the journal header points at it. Only
|
|
287
|
+
// empty directories are removed, so a failed staging leaves nothing that looks like an unfinished unit.
|
|
288
|
+
for (const unit of moved) {
|
|
289
|
+
if (readFileDigest(unit.stagedPath) === unit.candidateDigest) fs.rmSync(unit.stagedPath, { force: true })
|
|
290
|
+
try { fs.rmdirSync(path.dirname(unit.stagedPath)) } catch { /* not empty: left for restart recovery */ }
|
|
291
|
+
}
|
|
292
|
+
for (const file of created) fs.rmSync(file, { force: true })
|
|
293
|
+
for (const directory of [stagingDir, store.unitsRoot(journalId)]) if (directory) try { fs.rmdirSync(directory) } catch { /* absent, or not empty */ }
|
|
294
|
+
refuse('staging-failed', 'the candidates could not be staged; nothing in the vault was touched', { cause: error.code ?? String(error.message) })
|
|
295
|
+
}
|
|
296
|
+
crash('after-staging')
|
|
297
|
+
|
|
298
|
+
const context = { store, journal, journalId, channel, clock, crash, mode }
|
|
299
|
+
const results = []
|
|
300
|
+
// Kept notes are compared first, several at a time; one that holds exactly its candidate bytes is unchanged.
|
|
301
|
+
const unchanged = await unchangedKeepUnits(units, store)
|
|
302
|
+
// The direct path has no editor coordination, so it is only right while no
|
|
303
|
+
// Obsidian runs. Staging takes time and an app may have started since path
|
|
304
|
+
// selection: the process table is read again immediately before the first
|
|
305
|
+
// note, whatever time has passed, and again whenever two seconds have
|
|
306
|
+
// passed since the last reading. An app that starts after a reading and
|
|
307
|
+
// before the next is not seen; that window is at most two seconds plus
|
|
308
|
+
// one note's publication.
|
|
309
|
+
let lastProbe = null
|
|
310
|
+
for (const unit of units) {
|
|
311
|
+
if (mode === 'direct' && (lastProbe === null || Date.now() - lastProbe > 2000)) {
|
|
312
|
+
lastProbe = Date.now()
|
|
313
|
+
const again = await adapter.probe({ vaultRoot: store.vaultRoot })
|
|
314
|
+
if (again.state !== 'absent') context.uncoordinated = again.reason
|
|
315
|
+
}
|
|
316
|
+
if (unit.op === 'keep' && unchanged.has(unit.path)) {
|
|
317
|
+
results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: context.uncoordinated ? 'editor-uncoordinated' : 'unchanged', blocking: false })
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
results.push(await publishUnit(unit, context))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const blocking = results.filter((result) => result.blocking)
|
|
324
|
+
const retainedEdits = results.filter((result) => result.retained).map((result) => result.retained)
|
|
325
|
+
// Notes kept earlier stay surfaced until they leave the vault or come back into a view.
|
|
326
|
+
const base = { journalId, generationId: manifest.generationId, mode, notes: results.map(({ retained, ...rest }) => rest), retainedEdits, recovered }
|
|
327
|
+
if (blocking.length > 0) {
|
|
328
|
+
const lateWriters = recheckDisplacedFiles({ store, journalIds: [journalId], clock })
|
|
329
|
+
return { ...base, state: 'updating', lateWriters }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Quiet period, then look at the displaced files again.
|
|
333
|
+
if (quietPeriodMs > 0 && results.some((result) => result.recoveryRef)) await sleep(quietPeriodMs)
|
|
334
|
+
const lateWriters = recheckDisplacedFiles({ store, journalIds: [journalId], clock })
|
|
335
|
+
for (const finding of lateWriters.filter((item) => item.code === 'late-writer-captured')) {
|
|
336
|
+
journal.append({ step: 'verify', outcome: 'conflict', state: 'verifying', notePath: finding.notePath, beforeDigest: finding.digestAtMove, afterDigest: finding.observedDigest, recoveryRef: finding.displacedRef,
|
|
337
|
+
detail: { unit: finding.unit, code: 'late-writer-captured', objectRef: finding.objectRef } })
|
|
338
|
+
}
|
|
339
|
+
journal.append({ step: 'verify', outcome: 'ok', state: 'verifying', detail: { settled: true, retained: retainedEdits } })
|
|
340
|
+
crash('before-manifest-commit')
|
|
341
|
+
store.commitManifest({ manifestBytes, generationId: manifest.generationId, journalId, retained: retainedEdits, committedAt: iso(clock) })
|
|
342
|
+
crash('after-manifest-pointer')
|
|
343
|
+
journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { manifestDigest: sha256Digest(manifestBytes) } })
|
|
344
|
+
journal.close()
|
|
345
|
+
try { fs.rmdirSync(store.stagingDir(journalId)) } catch { /* something is still staged; restart recovery looks at it */ }
|
|
346
|
+
return { ...base, state: 'committed', lateWriters }
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (!(error instanceof PublicationRefusal)) {
|
|
349
|
+
if (journal) try { journal.append({ step: 'verify', outcome: 'failed', state: 'failed', detail: { code: 'publisher-error', message: String(error.code ?? error.message).slice(0, 200) } }) } catch { /* the journal itself cannot be written */ }
|
|
350
|
+
throw error
|
|
351
|
+
}
|
|
352
|
+
return { state: 'refused', refusal: { code: error.code, message: error.message, detail: error.detail }, notes: [], retainedEdits: [], lateWriters: [] }
|
|
353
|
+
} finally {
|
|
354
|
+
for (const [lockPath, release] of releases.reverse()) try { release() } catch { unreleased.set(lockPath, release) }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// One note, attachment or settings file
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
async function publishUnit(unit, context) {
|
|
363
|
+
const { store, journal, journalId, clock } = context
|
|
364
|
+
const note = path.join(store.vaultRoot, unit.path)
|
|
365
|
+
const outcome = (code, extra = {}) => ({ path: unit.path, kind: unit.kind, op: unit.op, outcome: code, blocking: false, ...extra })
|
|
366
|
+
const observe = (bytes) => store.retainObject(bytes)
|
|
367
|
+
if (context.uncoordinated) return outcome('editor-uncoordinated', { blocking: unit.op !== 'keep' })
|
|
368
|
+
// A settings path that cannot be written safely (a symlinked `.obsidian`,
|
|
369
|
+
// say) is reported and left alone; it must not keep every note from
|
|
370
|
+
// converging while the person repairs it. Notes and attachments block.
|
|
371
|
+
const unsafeBlocks = unit.op !== 'settings'
|
|
372
|
+
if (!ensureParents(store.vaultRoot, unit.path)) return outcome('path-unsafe', { blocking: unsafeBlocks })
|
|
373
|
+
const leaf = fs.lstatSync(note, { throwIfNoEntry: false })
|
|
374
|
+
if (leaf && !leaf.isFile()) return outcome('path-unsafe', { blocking: unsafeBlocks })
|
|
375
|
+
|
|
376
|
+
let plan = unit
|
|
377
|
+
if (unit.op === 'settings') {
|
|
378
|
+
plan = planSettings(unit, context)
|
|
379
|
+
if (plan.outcome) return plan
|
|
380
|
+
}
|
|
381
|
+
const current = readNote(note)
|
|
382
|
+
const currentDigest = current === null ? null : sha256Digest(current)
|
|
383
|
+
|
|
384
|
+
if (plan.op === 'keep') {
|
|
385
|
+
if (currentDigest === plan.candidateDigest) return outcome('unchanged')
|
|
386
|
+
if (current === null) {
|
|
387
|
+
let stagedPath
|
|
388
|
+
try { stagedPath = stageLate(plan, context) } catch (error) {
|
|
389
|
+
return outcome('staging-failed', { blocking: true, errorCode: error.code ?? null })
|
|
390
|
+
}
|
|
391
|
+
plan = { ...plan, op: 'create', stagedPath }
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
// Generated bytes did not change and the person's did: nothing to write, nothing to lose.
|
|
395
|
+
const object = observe(current)
|
|
396
|
+
return outcome('edit-kept', { observedDigest: currentDigest, objectRef: object.ref })
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (plan.op === 'replace' && current === null) plan = { ...plan, op: 'create' }
|
|
400
|
+
if (plan.op === 'remove' && current === null) return outcome('already-absent')
|
|
401
|
+
if (plan.op !== 'remove' && currentDigest !== null && currentDigest === plan.candidateDigest) {
|
|
402
|
+
if (plan.stagedPath) retire(plan, context)
|
|
403
|
+
return outcome('already-current')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const operationId = `${journalId}:${unit.unit}`
|
|
407
|
+
const displaced = plan.op === 'create' ? null : store.displacedPath(journalId, unit.unit)
|
|
408
|
+
// The journal state never goes backwards: once a note has been published the transition is `updating`.
|
|
409
|
+
const capture = (result, code, extra = {}) => journal.append({ step: 'capture', outcome: result, state: context.updating ? 'updating' : 'captured', notePath: unit.path,
|
|
410
|
+
...(extra.beforeDigest ? { beforeDigest: extra.beforeDigest } : {}), ...(result === 'ok' && plan.candidateDigest ? { afterDigest: plan.candidateDigest } : {}),
|
|
411
|
+
...(extra.recoveryRef ? { recoveryRef: extra.recoveryRef } : {}), detail: { unit: unit.unit, op: plan.op, kind: unit.kind, code, ...(extra.detail ?? {}) } })
|
|
412
|
+
const settle = (result, code, extra = {}) => journal.append({ step: 'conditional-update', outcome: result, state: (context.updating ||= result === 'ok') ? 'updating' : 'captured', notePath: unit.path,
|
|
413
|
+
...(plan.baseDigest ? { beforeDigest: plan.baseDigest } : {}), ...(result === 'ok' && plan.candidateDigest ? { afterDigest: plan.candidateDigest } : {}),
|
|
414
|
+
...(extra.recoveryRef ? { recoveryRef: extra.recoveryRef } : {}), detail: { unit: unit.unit, op: plan.op, kind: unit.kind, code, ...(extra.externalCaptured ? { externalCaptured: true } : {}) } })
|
|
415
|
+
|
|
416
|
+
if (plan.op === 'create') {
|
|
417
|
+
if (current !== null) {
|
|
418
|
+
// A file appeared where a new note would go. It is not ours: keep it.
|
|
419
|
+
const object = observe(current)
|
|
420
|
+
capture('conflict', 'create-conflict', { beforeDigest: currentDigest, recoveryRef: object.ref })
|
|
421
|
+
retire(plan, context)
|
|
422
|
+
return outcome('create-conflict', { blocking: true, observedDigest: currentDigest, objectRef: object.ref })
|
|
423
|
+
}
|
|
424
|
+
capture('ok', 'intent', { detail: { intent: true, operationId, stagedRef: store.ref(plan.stagedPath) } })
|
|
425
|
+
context.crash('after-capture')
|
|
426
|
+
try {
|
|
427
|
+
// Exclusive create: a hard link either makes the complete file appear or fails because something is there.
|
|
428
|
+
fs.linkSync(plan.stagedPath, note)
|
|
429
|
+
} catch (error) {
|
|
430
|
+
retire(plan, context)
|
|
431
|
+
settle(error.code === 'EEXIST' ? 'conflict' : 'failed', error.code === 'EEXIST' ? 'create-conflict' : 'create-failed')
|
|
432
|
+
return outcome(error.code === 'EEXIST' ? 'create-conflict' : 'create-failed', { blocking: true, errorCode: error.code })
|
|
433
|
+
}
|
|
434
|
+
context.crash('after-publish')
|
|
435
|
+
fs.unlinkSync(plan.stagedPath)
|
|
436
|
+
syncPrivateDirectory(path.dirname(note))
|
|
437
|
+
settle('ok', 'created')
|
|
438
|
+
return verify(unit, plan, context, outcome('created'))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// replace and remove: the comparison baseline is retained before anything moves.
|
|
442
|
+
if (currentDigest !== plan.baseDigest) {
|
|
443
|
+
const object = observe(current)
|
|
444
|
+
capture('conflict', 'disk-changed', { beforeDigest: currentDigest, recoveryRef: object.ref })
|
|
445
|
+
if (plan.stagedPath) retire(plan, context)
|
|
446
|
+
const retained = { path: unit.path, priorDigest: plan.baseDigest, observedDigest: currentDigest, objectRef: object.ref }
|
|
447
|
+
// An edited note that left the scope is never deleted. It stays in the vault and stays surfaced.
|
|
448
|
+
if (plan.op === 'remove') return outcome('retained-edit', { retained, observedDigest: currentDigest, objectRef: object.ref })
|
|
449
|
+
return outcome('disk-changed', { blocking: true, observedDigest: currentDigest, objectRef: object.ref })
|
|
450
|
+
}
|
|
451
|
+
const baseObject = observe(current)
|
|
452
|
+
const inspected = await context.channel.inspect({ vaultRoot: store.vaultRoot, path: unit.path })
|
|
453
|
+
if (inspected.status !== 'inspected') {
|
|
454
|
+
capture('failed', inspected.status, { beforeDigest: currentDigest })
|
|
455
|
+
if (plan.stagedPath) retire(plan, context)
|
|
456
|
+
return outcome(inspected.status, { blocking: true })
|
|
457
|
+
}
|
|
458
|
+
if (inspected.views.some((view) => view.dirty || view.bufferSha256 !== hex(plan.baseDigest))) {
|
|
459
|
+
capture('conflict', 'editor-edit', { beforeDigest: currentDigest, recoveryRef: baseObject.ref })
|
|
460
|
+
if (plan.stagedPath) retire(plan, context)
|
|
461
|
+
return outcome('editor-edit', { blocking: true, openViews: inspected.views.length })
|
|
462
|
+
}
|
|
463
|
+
capture('ok', 'intent', { beforeDigest: plan.baseDigest, recoveryRef: store.ref(displaced),
|
|
464
|
+
detail: { intent: true, operationId, baseObjectRef: baseObject.ref, openViews: inspected.views.length, ...(plan.stagedPath ? { stagedRef: store.ref(plan.stagedPath) } : {}) } })
|
|
465
|
+
context.crash('after-capture')
|
|
466
|
+
|
|
467
|
+
const reply = await context.channel.publish(plan.op === 'remove'
|
|
468
|
+
? { op: 'publish', mode: 'remove', vaultRoot: store.vaultRoot, path: unit.path, operationId, baseSha256: hex(plan.baseDigest), recoveryPath: displaced }
|
|
469
|
+
: { op: 'publish', mode: 'replace', vaultRoot: store.vaultRoot, path: unit.path, operationId, baseSha256: hex(plan.baseDigest), candidateSha256: hex(plan.candidateDigest), stagedPath: plan.stagedPath, recoveryPath: displaced })
|
|
470
|
+
context.crash('after-publish')
|
|
471
|
+
|
|
472
|
+
if (reply.status === 'published' || reply.status === 'published-external-captured' || reply.status === 'removed' || reply.status === 'removed-external-captured') {
|
|
473
|
+
syncPrivateDirectory(path.dirname(displaced))
|
|
474
|
+
const recorded = recordDisplaced({ store, journalId, unit: unit.unit, notePath: unit.path, displacedPath: displaced, baseDigest: plan.baseDigest, at: iso(clock),
|
|
475
|
+
digestAtMove: /^[0-9a-f]{64}$/.test(reply.recoveredSha256 ?? '') ? `sha256:${reply.recoveredSha256}` : null })
|
|
476
|
+
const code = plan.op === 'remove' ? (recorded.externalCaptured ? 'removed-external-captured' : 'removed') : (recorded.externalCaptured ? 'published-external-captured' : 'published')
|
|
477
|
+
settle('ok', code, { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
|
|
478
|
+
const result = outcome(code, { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured, openViews: reply.openViews ?? 0, replyLost: reply.replyLost === true,
|
|
479
|
+
...(plan.op === 'remove' && recorded.externalCaptured ? { retained: { path: unit.path, priorDigest: plan.baseDigest, observedDigest: recorded.digestAtMove, recoveryRef: recorded.displacedRef, movedToRecovery: true } } : {}) })
|
|
480
|
+
return plan.op === 'remove' ? result : verify(unit, plan, context, result)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (reply.status === 'outcome-unknown') {
|
|
484
|
+
// No reply and no recorded outcome. The files say what happened; the publish is not sent again.
|
|
485
|
+
const captureEntry = journal.document().entries.findLast((entry) => entry.step === 'capture' && entry.notePath === unit.path)
|
|
486
|
+
const entry = reconcileUnit({ store, journalId, capture: captureEntry, at: iso(clock) })
|
|
487
|
+
journal.append({ ...entry, state: (context.updating ||= entry.outcome === 'ok') ? 'updating' : 'captured' })
|
|
488
|
+
if (entry.outcome === 'ok') {
|
|
489
|
+
const result = outcome(entry.detail.code, { recoveryRef: entry.recoveryRef, externalCaptured: entry.detail.externalCaptured === true, replyLost: true })
|
|
490
|
+
return plan.op === 'remove' ? result : verify(unit, plan, context, result)
|
|
491
|
+
}
|
|
492
|
+
return outcome('outcome-unknown-nothing-published', { blocking: true, replyLost: true })
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (plan.stagedPath) retire(plan, context)
|
|
496
|
+
if (reply.status === 'remove-reverted') {
|
|
497
|
+
// The put-back may have left its second name behind. It names the live note, not displaced bytes: it gets no
|
|
498
|
+
// receipt, and it is removed here when it still is that very file.
|
|
499
|
+
const [live, left] = [fs.lstatSync(note, { throwIfNoEntry: false }), fs.lstatSync(displaced, { throwIfNoEntry: false })]
|
|
500
|
+
if (live && left && live.ino === left.ino && live.dev === left.dev) try { fs.unlinkSync(displaced) } catch { /* the late-writer check skips a name that is the live note */ }
|
|
501
|
+
settle('conflict', 'remove-reverted')
|
|
502
|
+
const bytes = readNote(note)
|
|
503
|
+
const object = bytes === null ? null : observe(bytes)
|
|
504
|
+
return outcome('retained-edit', { retained: { path: unit.path, priorDigest: plan.baseDigest, observedDigest: bytes === null ? null : sha256Digest(bytes), ...(object ? { objectRef: object.ref } : {}) } })
|
|
505
|
+
}
|
|
506
|
+
if (reply.status === 'note-missing' && plan.op === 'remove') {
|
|
507
|
+
settle('ok', 'already-absent')
|
|
508
|
+
return outcome('already-absent')
|
|
509
|
+
}
|
|
510
|
+
const conflict = REFUSED_BY_EDIT.has(reply.status) || reply.status === 'note-missing'
|
|
511
|
+
// Whatever is at the path now is somebody's edit: keep a copy before reporting the refusal.
|
|
512
|
+
const seen = REFUSED_BY_EDIT.has(reply.status) ? readNote(note) : null
|
|
513
|
+
const seenObject = seen === null || sha256Digest(seen) === plan.baseDigest ? null : observe(seen)
|
|
514
|
+
settle(conflict ? 'conflict' : 'failed', reply.status, seenObject ? { recoveryRef: seenObject.ref } : {})
|
|
515
|
+
return outcome(reply.status, { blocking: true, ...(seenObject ? { observedDigest: seenObject.digest, objectRef: seenObject.ref } : {}), ...(reply.exitStatus === undefined ? {} : { exitStatus: reply.exitStatus }) })
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Moves a complete candidate from staging to the path it will be exchanged from.
|
|
519
|
+
function moveToExchangePath(store, journalId, unit, moved = []) {
|
|
520
|
+
store.unitDir(journalId, unit.unit)
|
|
521
|
+
moved.push(unit)
|
|
522
|
+
fs.renameSync(unit.preparedPath, unit.stagedPath)
|
|
523
|
+
syncPrivateDirectory(path.dirname(unit.stagedPath))
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function retire(plan, { store, journalId, crash }) {
|
|
527
|
+
const result = retireStagedFile({ store, journalId, unit: plan.unit, stagedPath: plan.stagedPath, candidateDigest: plan.candidateDigest, crash })
|
|
528
|
+
for (const capturedPath of result.capturedPaths) recordDisplaced({ store, journalId, unit: plan.unit, notePath: plan.path, displacedPath: capturedPath, baseDigest: plan.baseDigest ?? null, at: new Date().toISOString() })
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// A candidate that could not be known when the run began (settings merged from
|
|
532
|
+
// the bytes on disk now; a kept note that has gone missing). One that will be
|
|
533
|
+
// created stays in staging. One that will be exchanged goes to the unit's
|
|
534
|
+
// recovery directory like any other, and because the header could not name
|
|
535
|
+
// it, a write-ahead entry names its path and digest before it is moved there:
|
|
536
|
+
// restart recovery can then tell this generated file from displaced bytes.
|
|
537
|
+
// A late candidate that fails part-way is removed before the failure is
|
|
538
|
+
// reported: nothing names it, so nothing would ever clean it up.
|
|
539
|
+
function stageLate(plan, { store, journal, journalId, crash, updating }) {
|
|
540
|
+
const preparedPath = store.preparedPath(journalId, plan.unit, { late: true })
|
|
541
|
+
const created = []
|
|
542
|
+
try { stageCandidate(preparedPath, plan.bytes, plan.mode ?? 0o644, created, crash) } catch (error) {
|
|
543
|
+
for (const file of created) fs.rmSync(file, { force: true })
|
|
544
|
+
throw error
|
|
545
|
+
}
|
|
546
|
+
if (plan.op !== 'replace') return preparedPath
|
|
547
|
+
const unit = { unit: plan.unit, preparedPath, stagedPath: store.exchangeCandidatePath(journalId, plan.unit, { late: true }) }
|
|
548
|
+
journal.append({ step: 'capture', outcome: 'ok', state: updating ? 'updating' : 'captured', notePath: plan.path, afterDigest: plan.candidateDigest,
|
|
549
|
+
detail: { unit: plan.unit, op: plan.op, kind: plan.kind, code: 'late-candidate', intent: false, stagedRef: store.ref(unit.stagedPath), preparedRef: store.ref(preparedPath) } })
|
|
550
|
+
crash('after-late-write-ahead')
|
|
551
|
+
moveToExchangePath(store, journalId, unit)
|
|
552
|
+
return unit.stagedPath
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Policy settings are owned per key. The candidate is the file on disk now
|
|
556
|
+
// with only the owned keys set; the base is that same file. A file that is
|
|
557
|
+
// not a JSON object or array is the person's to repair and is left alone.
|
|
558
|
+
function planSettings(unit, context) {
|
|
559
|
+
const note = path.join(context.store.vaultRoot, unit.path)
|
|
560
|
+
const existing = readNote(note)
|
|
561
|
+
let prepared
|
|
562
|
+
try { prepared = prepareSettings({ existing }) } catch (error) {
|
|
563
|
+
if (error instanceof ObsidianContractRefusal) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'settings-invalid', blocking: false }
|
|
564
|
+
throw error
|
|
565
|
+
}
|
|
566
|
+
const [file] = prepared.files
|
|
567
|
+
if (existing !== null && file.bytes.equals(existing)) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'policy-satisfied', blocking: false }
|
|
568
|
+
const plan = { ...unit, op: existing === null ? 'create' : 'replace', baseDigest: existing === null ? null : sha256Digest(existing), candidateDigest: file.digest, bytes: file.bytes,
|
|
569
|
+
mode: existing === null ? 0o644 : fs.lstatSync(note).mode & 0o777 }
|
|
570
|
+
try { plan.stagedPath = stageLate(plan, context) } catch (error) {
|
|
571
|
+
return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'staging-failed', blocking: true, errorCode: error.code }
|
|
572
|
+
}
|
|
573
|
+
return plan
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function verify(unit, plan, { store, journal }, result) {
|
|
577
|
+
const bytes = readNote(path.join(store.vaultRoot, unit.path))
|
|
578
|
+
const digest = bytes === null ? null : sha256Digest(bytes)
|
|
579
|
+
if (digest === plan.candidateDigest) {
|
|
580
|
+
journal.append({ step: 'verify', outcome: 'ok', state: 'updating', notePath: unit.path, afterDigest: digest, detail: { unit: unit.unit, code: 'verified' } })
|
|
581
|
+
return result
|
|
582
|
+
}
|
|
583
|
+
// Published, then changed by someone else. That is an edit on top of this generation, kept where it is.
|
|
584
|
+
const object = bytes === null ? null : store.retainObject(bytes)
|
|
585
|
+
journal.append({ step: 'verify', outcome: 'conflict', state: 'updating', notePath: unit.path, ...(digest ? { afterDigest: digest } : {}), ...(object ? { recoveryRef: object.ref } : {}),
|
|
586
|
+
detail: { unit: unit.unit, code: 'changed-after-publication' } })
|
|
587
|
+
return { ...result, changedAfterPublication: true, observedDigest: digest, ...(object ? { objectRef: object.ref } : {}) }
|
|
588
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Crash injection for tests. The seam is a symbol-keyed option holding a
|
|
2
|
+
// function, so it cannot arrive through JSON, a bridge payload, a CLI argument
|
|
3
|
+
// or a configuration file, and the code sent to a real app never carries it.
|
|
4
|
+
//
|
|
5
|
+
// publishView({ ..., [CRASH_INJECTION_TEST_SEAM]: { at: 'after-exchange', halt: () => process.kill(process.pid, 'SIGKILL') } })
|
|
6
|
+
//
|
|
7
|
+
// Points inside the critical section: after-exchange, after-recovery-move,
|
|
8
|
+
// after-editor-update, after-removal-move. Points in the publisher:
|
|
9
|
+
// before-candidate-move (the journal header is written, the exchange
|
|
10
|
+
// candidates are still in staging), after-staging, after-late-write-ahead (a
|
|
11
|
+
// late exchange candidate is named by the journal and still in staging),
|
|
12
|
+
// after-late-candidate-open (a late candidate file exists and is still
|
|
13
|
+
// empty), after-capture, after-publish, before-manifest-commit,
|
|
14
|
+
// after-manifest-pointer. Inside the retirement of a staged file, between its
|
|
15
|
+
// move to the unit's recovery directory and the judgement: after-retire-move.
|
|
16
|
+
export const CRASH_INJECTION_TEST_SEAM = Symbol('atelier.obsidian.crash-injection-test-seam')
|