@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,139 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
2
|
+
import { POLICY_SETTINGS_PATH, buildEvalCode, createInProcessHost, runInProcess, validatePayload } from './bridge-script.mjs'
|
|
3
|
+
|
|
4
|
+
// Editor coordination adapter.
|
|
5
|
+
//
|
|
6
|
+
// adapter.probe({ vaultRoot }) -> { state: 'coordinated' | 'absent' | 'uncoordinated', reason }
|
|
7
|
+
// adapter.inspect(payload) -> reply read-only, may retry
|
|
8
|
+
// adapter.collect(payload) -> reply read-only, may retry
|
|
9
|
+
// adapter.publish(payload) -> reply sent at most once
|
|
10
|
+
//
|
|
11
|
+
// A raw `call(payload)` sends one payload once and resolves with the parsed
|
|
12
|
+
// reply. It throws TransportTimeout when the reply did not arrive in time and
|
|
13
|
+
// any other error when the call could not be made. createEditorAdapter adds
|
|
14
|
+
// the rules every transport must follow: calls are serialized, read-only calls
|
|
15
|
+
// may retry, and a publish is never resent. When a publish reply is lost the
|
|
16
|
+
// recorded outcome is re-read from the app; when none is recorded the caller
|
|
17
|
+
// is told so and reconciles from the files themselves.
|
|
18
|
+
|
|
19
|
+
export class TransportTimeout extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = 'TransportTimeout'
|
|
23
|
+
this.code = 'transport-timeout'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const READ_RETRIES = 2
|
|
28
|
+
|
|
29
|
+
export function createEditorAdapter({ call, processProbe, kind = 'custom' }) {
|
|
30
|
+
if (typeof call !== 'function') throw new TypeError('createEditorAdapter needs a call function')
|
|
31
|
+
if (typeof processProbe !== 'function') throw new TypeError('createEditorAdapter needs a processProbe function')
|
|
32
|
+
const retries = []
|
|
33
|
+
let tail = Promise.resolve()
|
|
34
|
+
const serialized = (operation) => {
|
|
35
|
+
const next = tail.then(operation, operation)
|
|
36
|
+
tail = next.catch(() => {})
|
|
37
|
+
return next
|
|
38
|
+
}
|
|
39
|
+
const readOnly = async (payload) => {
|
|
40
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
41
|
+
try { return await call(validatePayload(payload)) } catch (error) {
|
|
42
|
+
if (attempt >= READ_RETRIES || !(error instanceof TransportTimeout)) throw error
|
|
43
|
+
retries.push(payload.op)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
kind,
|
|
49
|
+
transportRetries: retries,
|
|
50
|
+
inspect: (payload) => serialized(() => readOnly({ ...payload, op: 'inspect' })),
|
|
51
|
+
collect: (payload) => serialized(() => readOnly({ ...payload, op: 'collect' })),
|
|
52
|
+
publish: (payload) => serialized(async () => {
|
|
53
|
+
validatePayload(payload)
|
|
54
|
+
if (payload.op !== 'publish') throw new TypeError('publish needs a publish payload')
|
|
55
|
+
try { return await call(payload) } catch (error) {
|
|
56
|
+
retries.push(error instanceof TransportTimeout ? 'publish-reply-lost' : 'publish-call-failed')
|
|
57
|
+
let record = null
|
|
58
|
+
try { record = await readOnly({ op: 'collect', vaultRoot: payload.vaultRoot, path: payload.path }) } catch { /* the app is not answering */ }
|
|
59
|
+
if (record && record.operationId === payload.operationId && typeof record.outcome === 'string') return { ...record, status: record.outcome, replyLost: true }
|
|
60
|
+
return { status: 'outcome-unknown', operationId: payload.operationId, wrote: null, replyLost: true, transportError: String(error.message || error).slice(0, 300) }
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
// Path selection. `absent` is returned only when the process probe says,
|
|
64
|
+
// positively, that no Obsidian is running. A running or unknowable app
|
|
65
|
+
// that does not answer for this exact vault is `uncoordinated`.
|
|
66
|
+
probe: ({ vaultRoot }) => serialized(async () => {
|
|
67
|
+
let processes
|
|
68
|
+
try { processes = await processProbe() } catch { processes = 'unknown' }
|
|
69
|
+
if (processes === 'absent') return { state: 'absent', reason: 'no Obsidian process is running' }
|
|
70
|
+
try {
|
|
71
|
+
const reply = await readOnly({ op: 'inspect', vaultRoot, path: POLICY_SETTINGS_PATH })
|
|
72
|
+
// `path-unsafe` for the probe path still proves the app answered for this vault; the settings unit reports the path.
|
|
73
|
+
if (['inspected', 'path-unsafe'].includes(reply.status) && reply.vaultBasePath === vaultRoot) return { state: 'coordinated', reason: 'the app answered for this vault' }
|
|
74
|
+
return { state: 'uncoordinated', reason: reply.status === 'vault-mismatch' ? 'the app answered for another vault' : `the app answered ${reply.status}` }
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { state: 'uncoordinated', reason: `an Obsidian process may be running and the bridge did not answer: ${String(error.message || error).slice(0, 200)}` }
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// In-process transport: the same script, run here. Used for the path with no
|
|
84
|
+
// app, and by tests that supply a host modelling an app.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export function createInProcessCall(host = createInProcessHost()) {
|
|
88
|
+
return async (payload) => runInProcess(payload, host)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createDirectAdapter({ crashSeam } = {}) {
|
|
92
|
+
return createEditorAdapter({ call: createInProcessCall(createInProcessHost({ crashSeam })), processProbe: () => 'absent', kind: 'direct' })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Obsidian CLI transport
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export function defaultCliPath(platform = process.platform) {
|
|
100
|
+
if (platform === 'darwin') return '/Applications/Obsidian.app/Contents/MacOS/obsidian-cli'
|
|
101
|
+
return 'obsidian-cli'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The CLI reaches the app through a socket under $HOME, so `env` selects the
|
|
105
|
+
// app instance. It is passed explicitly and never edited here. A call that
|
|
106
|
+
// outlives its timeout is killed with SIGKILL: the CLI ignores SIGTERM while
|
|
107
|
+
// it waits on the app.
|
|
108
|
+
export function createObsidianCliCall({ cliPath = defaultCliPath(), env = process.env, timeoutMs = 20000 } = {}) {
|
|
109
|
+
return (payload) => new Promise((resolve, reject) => {
|
|
110
|
+
execFile(cliPath, ['eval', `code=${buildEvalCode(payload)}`], { env, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
111
|
+
if (error) return reject(error.killed ? new TransportTimeout(`CLI call timed out: ${payload.op}`) : new Error(`CLI call failed: ${String(error.message).slice(0, 300)}`))
|
|
112
|
+
const out = stdout || stderr || ''
|
|
113
|
+
const start = out.indexOf('=> ')
|
|
114
|
+
if (start < 0) return reject(new Error(`bridge returned no value: ${JSON.stringify(out.slice(0, 300))}`))
|
|
115
|
+
try { return resolve(JSON.parse(out.slice(start + 3))) } catch { return reject(new Error('bridge returned a value that is not JSON')) }
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Default process probe: 'absent' only when the process table was read and
|
|
121
|
+
// holds no Obsidian. Limits: it sees this machine's processes as this user
|
|
122
|
+
// can list them; it cannot see an app on another machine that reaches the
|
|
123
|
+
// vault through a shared or synchronized folder, an app packaged under another
|
|
124
|
+
// executable name, or an app that starts after the probe. Anything it cannot
|
|
125
|
+
// establish is 'unknown', which the adapter treats as a running app.
|
|
126
|
+
export function defaultObsidianProcessProbe({ platform = process.platform, run = execFileSync } = {}) {
|
|
127
|
+
if (platform !== 'darwin' && platform !== 'linux') return 'unknown'
|
|
128
|
+
try {
|
|
129
|
+
const table = run('/bin/ps', ['-A', '-o', 'args='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 })
|
|
130
|
+
if (typeof table !== 'string' || table.trim() === '') return 'unknown'
|
|
131
|
+
return /(^|[\\/ ])obsidian(\.app|\.exe|-cli)?([\\/ ]|$)/im.test(table.split('\n').filter((line) => !line.includes('/bin/ps')).join('\n')) ? 'running' : 'absent'
|
|
132
|
+
} catch {
|
|
133
|
+
return 'unknown'
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createObsidianCliAdapter({ cliPath, env, timeoutMs, processProbe = () => defaultObsidianProcessProbe() } = {}) {
|
|
138
|
+
return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs }), processProbe, kind: 'obsidian-cli' })
|
|
139
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Recovery state for Obsidian publication: the immutable store, the durable
|
|
2
|
+
// journal, restart recovery and the late-writer re-check.
|
|
3
|
+
export { EXCHANGE_CANDIDATE_NAME, LATE_EXCHANGE_CANDIDATE_NAME, PublicationRefusal, VAULT_LOCK_DIRECTORY, acquireVaultLock, createRecoveryStore, readFileDigest, sha256Digest } from './store.mjs'
|
|
4
|
+
export { JOURNAL_SCHEMA, createJournal, journalDetail, listJournals, newJournalId } from './journal.mjs'
|
|
5
|
+
export { classifyCandidateFile, namedCandidates, publishedSinceCommit, reconcileUnit, recoverPublications, retireStagedFile } from './restart.mjs'
|
|
6
|
+
export { recheckDisplacedFiles } from './late-writer.mjs'
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { isPendingPrivateWrite, publishPrivateFile } from '../../../project/durable-state.mjs'
|
|
5
|
+
import { readRegularTextNoFollow } from '../../../project/private-state.mjs'
|
|
6
|
+
import { OBSIDIAN_EXT_KEY, assertObsidianContract } from '../contracts.mjs'
|
|
7
|
+
import { refuse } from './store.mjs'
|
|
8
|
+
|
|
9
|
+
// Durable publication journal. The registered shape is one document; on disk
|
|
10
|
+
// it is a header plus one immutable file per entry, each published atomically
|
|
11
|
+
// under a name that is never overwritten, so an entry is either wholly present
|
|
12
|
+
// or absent and history cannot be rewritten in place. The document is
|
|
13
|
+
// assembled from those files and validated against the registered schema every
|
|
14
|
+
// time it is read or extended.
|
|
15
|
+
//
|
|
16
|
+
// Entry steps follow the schema: `capture` (the write-ahead record binding a
|
|
17
|
+
// note to its base, candidate, staged file and recovery path, written before
|
|
18
|
+
// anything is exchanged), `conditional-update` (the exchange, exclusive
|
|
19
|
+
// create or conditional removal and its outcome), `verify`, `manifest-commit`
|
|
20
|
+
// and `restart`. Each entry file also carries the journal state that holds
|
|
21
|
+
// after it.
|
|
22
|
+
|
|
23
|
+
export const JOURNAL_SCHEMA = 'atelier-obsidian-publication-journal/v1'
|
|
24
|
+
const ENTRY_FILE = /^\d{8}\.json$/
|
|
25
|
+
const CLOSED_FILE = 'closed.json'
|
|
26
|
+
|
|
27
|
+
const isoTime = (clock) => {
|
|
28
|
+
const value = clock()
|
|
29
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
30
|
+
if (Number.isNaN(date.getTime())) throw new TypeError('the clock did not return a time')
|
|
31
|
+
return date.toISOString()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function newJournalId(clock = () => new Date()) {
|
|
35
|
+
return `journal-${isoTime(clock).replace(/[-:.TZ]/g, '')}-${randomBytes(4).toString('hex')}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function load(directory) {
|
|
39
|
+
const header = JSON.parse(readRegularTextNoFollow(path.join(directory, 'header.json')))
|
|
40
|
+
const entriesDir = path.join(directory, 'entries')
|
|
41
|
+
const names = fs.existsSync(entriesDir) ? fs.readdirSync(entriesDir).filter((name) => !isPendingPrivateWrite(name)).sort() : []
|
|
42
|
+
const records = names.map((name, index) => {
|
|
43
|
+
if (!ENTRY_FILE.test(name) || Number(name.slice(0, 8)) !== index) refuse('journal-corrupt', 'journal entries are not a contiguous sequence; preserve and inspect')
|
|
44
|
+
const record = JSON.parse(readRegularTextNoFollow(path.join(entriesDir, name)))
|
|
45
|
+
if (record.entry?.seq !== index) refuse('journal-corrupt', 'a journal entry does not carry its own sequence number')
|
|
46
|
+
return record
|
|
47
|
+
})
|
|
48
|
+
return { header, records }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assemble({ header, records }) {
|
|
52
|
+
const restarts = records.filter((record) => record.entry.step === 'restart' && record.entry.ext?.[OBSIDIAN_EXT_KEY]?.phase === 'begin')
|
|
53
|
+
const document = {
|
|
54
|
+
...header,
|
|
55
|
+
state: records.length > 0 ? records.at(-1).state : 'prepared',
|
|
56
|
+
entries: records.map((record) => record.entry),
|
|
57
|
+
restart: { count: restarts.length, ...(restarts.length > 0 ? { lastRecoveredAt: restarts.at(-1).entry.at } : {}) },
|
|
58
|
+
}
|
|
59
|
+
return assertObsidianContract('publication-journal', document)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handle(store, directory, clock) {
|
|
63
|
+
// One writer at a time extends a journal (the publisher holds the view's
|
|
64
|
+
// lock), so the entries read once stay the entries on disk.
|
|
65
|
+
let cached = null
|
|
66
|
+
const current = () => (cached ??= load(directory))
|
|
67
|
+
const journal = {
|
|
68
|
+
directory,
|
|
69
|
+
document: () => assemble(current()),
|
|
70
|
+
// detail lands under the entry's ext container; the rest are schema fields.
|
|
71
|
+
append({ step, outcome, state, notePath, beforeDigest, afterDigest, recoveryRef, detail }) {
|
|
72
|
+
const loaded = current()
|
|
73
|
+
const entry = {
|
|
74
|
+
seq: loaded.records.length,
|
|
75
|
+
at: isoTime(clock),
|
|
76
|
+
step,
|
|
77
|
+
outcome,
|
|
78
|
+
...(notePath === undefined ? {} : { notePath }),
|
|
79
|
+
...(beforeDigest ? { beforeDigest } : {}),
|
|
80
|
+
...(afterDigest ? { afterDigest } : {}),
|
|
81
|
+
...(recoveryRef ? { recoveryRef } : {}),
|
|
82
|
+
...(detail ? { ext: { [OBSIDIAN_EXT_KEY]: detail } } : {}),
|
|
83
|
+
}
|
|
84
|
+
const record = { entry, state: state ?? (loaded.records.at(-1)?.state ?? 'prepared') }
|
|
85
|
+
assemble({ header: loaded.header, records: [record] })
|
|
86
|
+
try {
|
|
87
|
+
publishPrivateFile(path.join(directory, 'entries', `${String(entry.seq).padStart(8, '0')}.json`), `${JSON.stringify(record, null, 2)}\n`)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
cached = null
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
loaded.records.push(record)
|
|
93
|
+
return entry
|
|
94
|
+
},
|
|
95
|
+
// A journal that can no longer matter (committed, or superseded by a later
|
|
96
|
+
// commit) is closed so that later runs do not read it again. The flag file
|
|
97
|
+
// is advisory: a journal without one is simply read and judged again.
|
|
98
|
+
close() {
|
|
99
|
+
try { publishPrivateFile(path.join(directory, CLOSED_FILE), `${JSON.stringify({ state: assemble(current()).state })}\n`) } catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
return journal
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createJournal(store, { journalId, protocolId, expectedGeneration, targetGeneration, detail, clock = () => new Date() }) {
|
|
106
|
+
const directory = store.journalDir(journalId)
|
|
107
|
+
fs.mkdirSync(path.join(directory, 'entries'), { recursive: true, mode: 0o700 })
|
|
108
|
+
const header = {
|
|
109
|
+
schema: JOURNAL_SCHEMA,
|
|
110
|
+
contractVersion: '1.0.0',
|
|
111
|
+
journalId,
|
|
112
|
+
workspaceId: store.workspaceId,
|
|
113
|
+
scopeId: store.scopeId,
|
|
114
|
+
protocolId,
|
|
115
|
+
expectedGeneration,
|
|
116
|
+
targetGeneration,
|
|
117
|
+
...(detail ? { ext: { [OBSIDIAN_EXT_KEY]: detail } } : {}),
|
|
118
|
+
}
|
|
119
|
+
assemble({ header, records: [] })
|
|
120
|
+
publishPrivateFile(path.join(directory, 'header.json'), `${JSON.stringify(header, null, 2)}\n`)
|
|
121
|
+
return handle(store, directory, clock)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Journals of this view, oldest first. A directory without a header is a
|
|
125
|
+
// journal that never began: nothing can refer to it.
|
|
126
|
+
export function listJournals(store, { clock = () => new Date(), openOnly = false } = {}) {
|
|
127
|
+
if (!fs.existsSync(store.journalsRoot)) return []
|
|
128
|
+
return fs.readdirSync(store.journalsRoot).sort()
|
|
129
|
+
.filter((name) => fs.existsSync(path.join(store.journalsRoot, name, 'header.json')))
|
|
130
|
+
.filter((name) => !openOnly || !fs.existsSync(path.join(store.journalsRoot, name, CLOSED_FILE)))
|
|
131
|
+
.map((name) => handle(store, path.join(store.journalsRoot, name), clock))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const journalDetail = (value) => value?.ext?.[OBSIDIAN_EXT_KEY] ?? {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { listJournals } from './journal.mjs'
|
|
4
|
+
import { readFileBytes, sha256Digest } from './store.mjs'
|
|
5
|
+
|
|
6
|
+
// A program that opened a note before it was exchanged still holds the
|
|
7
|
+
// displaced file, and may write into it at any later time. Those bytes land in
|
|
8
|
+
// the recovery area, not in the vault, so nothing shows them to the person
|
|
9
|
+
// unless the displaced files are looked at again. This compares every
|
|
10
|
+
// displaced file with the digest recorded when it was moved. A difference is
|
|
11
|
+
// kept as an immutable object and reported as `late-writer-captured`.
|
|
12
|
+
//
|
|
13
|
+
// The check is meaningful only after a quiet period, and one pass does not end
|
|
14
|
+
// the obligation: a holder can write later still. Callers repeat it.
|
|
15
|
+
export function recheckDisplacedFiles({ store, journalIds, clock = () => new Date() } = {}) {
|
|
16
|
+
const ids = journalIds ?? listJournals(store).map((journal) => journal.document().journalId)
|
|
17
|
+
const findings = []
|
|
18
|
+
for (const journalId of ids) {
|
|
19
|
+
const receipts = store.listReceipts(journalId)
|
|
20
|
+
const known = new Set(receipts.filter((receipt) => receipt.role === 'late-writer').map((receipt) => `${receipt.unit}\u0000${receipt.observedDigest}`))
|
|
21
|
+
for (const receipt of receipts.filter((item) => item.role === 'displaced')) {
|
|
22
|
+
// A recovery name that is a hard link to the live note (an interrupted
|
|
23
|
+
// put-back) is the note itself: edits to it are not a late writer's.
|
|
24
|
+
const live = receipt.notePath ? fs.lstatSync(path.join(store.vaultRoot, receipt.notePath), { throwIfNoEntry: false }) : null
|
|
25
|
+
const held = fs.lstatSync(store.resolve(receipt.displacedRef), { throwIfNoEntry: false })
|
|
26
|
+
if (live && held && live.ino === held.ino && live.dev === held.dev) continue
|
|
27
|
+
let bytes
|
|
28
|
+
try { bytes = readFileBytes(store.resolve(receipt.displacedRef)) } catch (error) {
|
|
29
|
+
if (error.code !== 'ENOENT') throw error
|
|
30
|
+
findings.push({ code: 'displaced-file-missing', journalId, unit: receipt.unit, notePath: receipt.notePath, displacedRef: receipt.displacedRef })
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
const observedDigest = sha256Digest(bytes)
|
|
34
|
+
if (observedDigest === receipt.digestAtMove) continue
|
|
35
|
+
const object = store.retainObject(bytes)
|
|
36
|
+
const finding = { code: 'late-writer-captured', journalId, unit: receipt.unit, notePath: receipt.notePath, displacedRef: receipt.displacedRef, digestAtMove: receipt.digestAtMove, observedDigest, objectRef: object.ref }
|
|
37
|
+
if (!known.has(`${receipt.unit}\u0000${observedDigest}`)) {
|
|
38
|
+
const value = clock()
|
|
39
|
+
store.writeReceipt(journalId, receipt.unit, { role: 'late-writer', notePath: receipt.notePath, displacedRef: receipt.displacedRef, digestAtMove: receipt.digestAtMove, observedDigest, objectRef: object.ref,
|
|
40
|
+
at: (value instanceof Date ? value : new Date(value)).toISOString() })
|
|
41
|
+
}
|
|
42
|
+
findings.push(finding)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return findings
|
|
46
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { acquirePrivateLock, syncPrivateDirectory } from '../../../project/durable-state.mjs'
|
|
4
|
+
import { journalDetail, listJournals } from './journal.mjs'
|
|
5
|
+
import { readFileBytes, readFileDigest, refuse, sha256Digest } from './store.mjs'
|
|
6
|
+
|
|
7
|
+
// Restart recovery. Every decision here is made from what is on disk, never
|
|
8
|
+
// from what a lost reply might have said, and every action is a move, a
|
|
9
|
+
// non-overwriting link, the removal of a file proven to be our own candidate,
|
|
10
|
+
// or an appended journal entry. Running it again changes nothing.
|
|
11
|
+
//
|
|
12
|
+
// Interrupted states of one note, identified by its write-ahead `capture`
|
|
13
|
+
// entry (base, candidate, candidate path, recovery path) with no outcome
|
|
14
|
+
// entry. The candidate path of a replacement is in the unit's recovery
|
|
15
|
+
// directory, so whatever it holds is never in a discardable area:
|
|
16
|
+
//
|
|
17
|
+
// candidate path == candidate digest nothing was exchanged. The file is a
|
|
18
|
+
// generated candidate and is retired;
|
|
19
|
+
// the note is as it was.
|
|
20
|
+
// candidate path, any other bytes the exchange happened and the move to
|
|
21
|
+
// the recovery name did not: the path
|
|
22
|
+
// holds the displaced bytes. They are
|
|
23
|
+
// moved to the recovery name and
|
|
24
|
+
// compared with base.
|
|
25
|
+
// path absent, recovery name present exchange and move both happened.
|
|
26
|
+
// neither nothing happened.
|
|
27
|
+
//
|
|
28
|
+
// The journal names every exchange candidate, path and digest, before the file
|
|
29
|
+
// is at that path (the header for candidates known when the run began, a
|
|
30
|
+
// write-ahead entry for a late one), and only complete files are moved there.
|
|
31
|
+
// classifyCandidateFile is the only place that decides between the two kinds.
|
|
32
|
+
//
|
|
33
|
+
// For the whole journal: a pointer that already names this journal gets its
|
|
34
|
+
// missing `manifest-commit` entry; a journal that reached `verifying` with
|
|
35
|
+
// every note settled commits its stored manifest; anything else is left as
|
|
36
|
+
// `updating` for the next publication to converge, with the digests it did
|
|
37
|
+
// publish still trusted as bases.
|
|
38
|
+
|
|
39
|
+
const exists = (file) => { try { fs.lstatSync(file); return true } catch (error) { if (error.code === 'ENOENT') return false; throw error } }
|
|
40
|
+
|
|
41
|
+
const RETIRING_NAME = 'retiring.bin'
|
|
42
|
+
|
|
43
|
+
// What a file at a candidate path is. `generated-candidate` is claimed only
|
|
44
|
+
// for bytes whose digest the journal recorded for that candidate; those are
|
|
45
|
+
// ours and may be deleted. Everything else, including a file whose candidate
|
|
46
|
+
// digest the journal does not give, is `displaced-bytes`: somebody's content,
|
|
47
|
+
// which is moved or kept and never deleted.
|
|
48
|
+
export function classifyCandidateFile({ file, candidateDigest }) {
|
|
49
|
+
const digest = file ? readFileDigest(file) : null
|
|
50
|
+
if (digest === null) return 'absent'
|
|
51
|
+
return candidateDigest && digest === candidateDigest ? 'generated-candidate' : 'displaced-bytes'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Every candidate a journal names: from its header, and from the write-ahead
|
|
55
|
+
// entries of late exchange candidates. `preparedRef` is where the bytes were
|
|
56
|
+
// written, in staging, before they were moved to `stagedRef`.
|
|
57
|
+
export function namedCandidates(document) {
|
|
58
|
+
const named = (journalDetail(document).staged ?? []).map(({ unit, path: notePath, candidateDigest, stagedRef, preparedRef }) => ({ unit, path: notePath, candidateDigest, stagedRef, preparedRef: preparedRef ?? null, late: false }))
|
|
59
|
+
for (const entry of document.entries) {
|
|
60
|
+
const detail = journalDetail(entry)
|
|
61
|
+
if (entry.step === 'capture' && detail.code === 'late-candidate' && detail.stagedRef) {
|
|
62
|
+
named.push({ unit: detail.unit, path: entry.notePath, candidateDigest: entry.afterDigest ?? null, stagedRef: detail.stagedRef, preparedRef: detail.preparedRef ?? null, late: true })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return named
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function freeName(store, journalId, unit, stem = 'displaced') {
|
|
69
|
+
for (let index = 0; ; index += 1) {
|
|
70
|
+
const candidate = store.displacedPath(journalId, unit, index === 0 ? `${stem}.bin` : `${stem}-${index}.bin`)
|
|
71
|
+
if (!exists(candidate)) return candidate
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Judges a file already moved to the unit's retiring name: our own candidate
|
|
76
|
+
// is deleted; anything else is somebody's bytes and stays in recovery under a
|
|
77
|
+
// name of its own. This is both the second half of retireStagedFile and what
|
|
78
|
+
// restart recovery runs for a retirement that was interrupted.
|
|
79
|
+
function judgeRetiring({ store, journalId, unit, candidateDigest }) {
|
|
80
|
+
const retiring = store.displacedPath(journalId, unit, RETIRING_NAME)
|
|
81
|
+
if (!exists(retiring)) return { retired: false, capturedPaths: [] }
|
|
82
|
+
if (classifyCandidateFile({ file: retiring, candidateDigest }) === 'generated-candidate') {
|
|
83
|
+
fs.unlinkSync(retiring)
|
|
84
|
+
return { retired: true, capturedPaths: [] }
|
|
85
|
+
}
|
|
86
|
+
const capturedPath = freeName(store, journalId, unit, 'unexpected-at-staged-path')
|
|
87
|
+
fs.renameSync(retiring, capturedPath)
|
|
88
|
+
syncPrivateDirectory(path.dirname(capturedPath))
|
|
89
|
+
return { retired: false, capturedPaths: [capturedPath] }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A candidate path is never unlinked in place: between a digest check and an
|
|
93
|
+
// unlink, a late exchange could put displaced bytes there. It is first moved
|
|
94
|
+
// to a private name, which no payload refers to, and only then judged. That
|
|
95
|
+
// name lives in the unit's recovery directory, beside the exchange candidate,
|
|
96
|
+
// never in staging: between the two steps the file may be the only copy of
|
|
97
|
+
// bytes an exchange displaced. A process that dies between the steps leaves
|
|
98
|
+
// `retiring.bin` there, and restart recovery finishes the judgement.
|
|
99
|
+
// Returns { retired, capturedPaths }: every path in `capturedPaths` holds
|
|
100
|
+
// bytes that were not the candidate and still needs a receipt.
|
|
101
|
+
export function retireStagedFile({ store, journalId, unit, stagedPath, candidateDigest, crash = () => {} }) {
|
|
102
|
+
// An earlier interrupted retirement of this unit is finished first, so the move below never lands on it.
|
|
103
|
+
const earlier = judgeRetiring({ store, journalId, unit, candidateDigest })
|
|
104
|
+
if (!stagedPath || !exists(stagedPath)) return earlier
|
|
105
|
+
fs.renameSync(stagedPath, store.displacedPath(journalId, unit, RETIRING_NAME))
|
|
106
|
+
crash('after-retire-move')
|
|
107
|
+
const result = judgeRetiring({ store, journalId, unit, candidateDigest })
|
|
108
|
+
return { retired: result.retired, capturedPaths: [...earlier.capturedPaths, ...result.capturedPaths] }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Units of one journal that still hold a `retiring.bin`.
|
|
112
|
+
function interruptedRetirements(store, journalId) {
|
|
113
|
+
const base = path.join(store.workspaceRoot, 'recovery', journalId.replaceAll(':', '_'))
|
|
114
|
+
if (!exists(base)) return []
|
|
115
|
+
return fs.readdirSync(base).filter((name) => /^\d{6}$/.test(name) && exists(path.join(base, name, RETIRING_NAME))).map((name) => Number(name)).sort((a, b) => a - b)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// `digestAtMove` is the digest taken inside the critical section, right after
|
|
119
|
+
// the move, when the caller has it. A holder of the old file may already have
|
|
120
|
+
// written into it by the time this process reads it; that difference is left
|
|
121
|
+
// for the late-writer check to report.
|
|
122
|
+
export function recordDisplaced({ store, journalId, unit, notePath, displacedPath, baseDigest, at, digestAtMove: observedAtMove = null }) {
|
|
123
|
+
const bytes = readFileBytes(displacedPath)
|
|
124
|
+
const digestAtMove = observedAtMove ?? sha256Digest(bytes)
|
|
125
|
+
const externalCaptured = digestAtMove !== baseDigest
|
|
126
|
+
// The displaced file stays where it is. Bytes that are not the known base get an immutable copy as well.
|
|
127
|
+
const object = sha256Digest(bytes) !== baseDigest ? store.retainObject(bytes) : null
|
|
128
|
+
store.writeReceipt(journalId, unit, { role: 'displaced', notePath, displacedRef: store.ref(displacedPath), digestAtMove, baseDigest, externalCaptured, ...(object ? { objectRef: object.ref } : {}), at })
|
|
129
|
+
return { digestAtMove, externalCaptured, displacedRef: store.ref(displacedPath) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Settles one note whose outcome is unknown. Returns the entry to append.
|
|
133
|
+
export function reconcileUnit({ store, journalId, capture, at }) {
|
|
134
|
+
const detail = journalDetail(capture)
|
|
135
|
+
const { unit, op } = detail
|
|
136
|
+
const notePath = capture.notePath
|
|
137
|
+
const note = path.join(store.vaultRoot, notePath)
|
|
138
|
+
const base = capture.beforeDigest ?? null
|
|
139
|
+
const candidate = capture.afterDigest ?? null
|
|
140
|
+
const staged = detail.stagedRef ? store.resolve(detail.stagedRef) : null
|
|
141
|
+
const displaced = capture.recoveryRef ? store.resolve(capture.recoveryRef) : null
|
|
142
|
+
const settle = (outcome, code, extra = {}) => ({ step: 'conditional-update', outcome, notePath, ...(base ? { beforeDigest: base } : {}), ...(outcome === 'ok' && candidate ? { afterDigest: candidate } : {}),
|
|
143
|
+
...(extra.recoveryRef ? { recoveryRef: extra.recoveryRef } : {}), detail: { unit, op, kind: detail.kind, code, reconciled: true, ...(extra.externalCaptured ? { externalCaptured: true } : {}) } })
|
|
144
|
+
|
|
145
|
+
const keepUnexpected = (paths) => { for (const capturedPath of paths) recordDisplaced({ store, journalId, unit, notePath, displacedPath: capturedPath, baseDigest: base, at }) }
|
|
146
|
+
const completeAfterExchange = (holder) => {
|
|
147
|
+
const target = !displaced || exists(displaced) ? freeName(store, journalId, unit) : displaced
|
|
148
|
+
fs.renameSync(holder, target)
|
|
149
|
+
syncPrivateDirectory(path.dirname(target))
|
|
150
|
+
const recorded = recordDisplaced({ store, journalId, unit, notePath, displacedPath: target, baseDigest: base, at })
|
|
151
|
+
return settle('ok', 'completed-after-exchange', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (op === 'replace') {
|
|
155
|
+
// A retirement interrupted between its two moves: the file that was at the
|
|
156
|
+
// staged path is at the retiring name. Bytes that are not the candidate
|
|
157
|
+
// can only have come from the exchange, exactly as if they were still staged.
|
|
158
|
+
const retiring = store.displacedPath(journalId, unit, RETIRING_NAME)
|
|
159
|
+
if (classifyCandidateFile({ file: retiring, candidateDigest: candidate }) === 'displaced-bytes') {
|
|
160
|
+
const entry = completeAfterExchange(retiring)
|
|
161
|
+
if (staged) keepUnexpected(retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths)
|
|
162
|
+
return entry
|
|
163
|
+
}
|
|
164
|
+
const atCandidatePath = classifyCandidateFile({ file: staged, candidateDigest: candidate })
|
|
165
|
+
if (atCandidatePath === 'generated-candidate') {
|
|
166
|
+
keepUnexpected(retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths)
|
|
167
|
+
return settle('skipped', 'interrupted-before-exchange')
|
|
168
|
+
}
|
|
169
|
+
if (atCandidatePath === 'displaced-bytes') return completeAfterExchange(staged)
|
|
170
|
+
keepUnexpected(judgeRetiring({ store, journalId, unit, candidateDigest: candidate }).capturedPaths)
|
|
171
|
+
if (displaced && exists(displaced)) {
|
|
172
|
+
const known = store.listReceipts(journalId).find((receipt) => receipt.role === 'displaced' && receipt.unit === unit && receipt.displacedRef === store.ref(displaced))
|
|
173
|
+
const recorded = known ?? recordDisplaced({ store, journalId, unit, notePath, displacedPath: displaced, baseDigest: base, at })
|
|
174
|
+
return settle('ok', 'completed-after-recovery-move', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
|
|
175
|
+
}
|
|
176
|
+
return settle('skipped', 'nothing-happened')
|
|
177
|
+
}
|
|
178
|
+
if (op === 'create') {
|
|
179
|
+
const created = readFileDigest(note) === candidate
|
|
180
|
+
keepUnexpected(staged ? retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths : [])
|
|
181
|
+
return created ? settle('ok', 'completed-after-create') : settle('skipped', 'interrupted-before-create')
|
|
182
|
+
}
|
|
183
|
+
if (op === 'remove') {
|
|
184
|
+
if (!displaced || !exists(displaced)) return settle('skipped', 'interrupted-before-removal')
|
|
185
|
+
const noteStat = fs.lstatSync(note, { throwIfNoEntry: false })
|
|
186
|
+
const movedStat = fs.lstatSync(displaced)
|
|
187
|
+
if (noteStat && noteStat.ino === movedStat.ino && noteStat.dev === movedStat.dev) {
|
|
188
|
+
// An interrupted put-back: the note path already names these bytes again.
|
|
189
|
+
fs.unlinkSync(displaced)
|
|
190
|
+
return settle('skipped', 'remove-reverted')
|
|
191
|
+
}
|
|
192
|
+
const recorded = recordDisplaced({ store, journalId, unit, notePath, displacedPath: displaced, baseDigest: base, at })
|
|
193
|
+
return settle('ok', recorded.externalCaptured ? 'removed-external-captured' : 'completed-after-removal', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
|
|
194
|
+
}
|
|
195
|
+
return refuse('journal-corrupt', 'a capture entry names an unknown operation')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Late candidates in staging that no entry names. Every one counts: a journal
|
|
199
|
+
// may hold a named late candidate and an orphaned one side by side. This looks
|
|
200
|
+
// in staging only. Nothing in a unit's recovery directory is ever an orphan:
|
|
201
|
+
// a late exchange candidate gets there after its write-ahead entry, and a
|
|
202
|
+
// file there that the journal does not account for is kept.
|
|
203
|
+
function strays(store, document) {
|
|
204
|
+
const directory = path.join(store.stagingRoot, document.journalId.replaceAll(':', '_'))
|
|
205
|
+
if (!exists(directory)) return []
|
|
206
|
+
const named = new Set(document.entries.flatMap((entry) => [journalDetail(entry).stagedRef, journalDetail(entry).preparedRef]).filter(Boolean))
|
|
207
|
+
return fs.readdirSync(directory).filter((name) => name.endsWith('.late.candidate')).map((name) => path.join(directory, name)).filter((file) => !named.has(store.ref(file)))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const iso = (clock) => { const value = clock(); return (value instanceof Date ? value : new Date(value)).toISOString() }
|
|
211
|
+
|
|
212
|
+
// Caller holds the view's lock.
|
|
213
|
+
export function recoverPublicationsLocked({ store, clock = () => new Date() }) {
|
|
214
|
+
const report = []
|
|
215
|
+
for (const journal of listJournals(store, { clock, openOnly: true })) {
|
|
216
|
+
const document = journal.document()
|
|
217
|
+
if (document.state === 'committed') { journal.close(); continue }
|
|
218
|
+
const header = journalDetail(document)
|
|
219
|
+
const settled = new Set(document.entries.filter((entry) => entry.step === 'conditional-update').map((entry) => journalDetail(entry).unit))
|
|
220
|
+
const pending = document.entries.filter((entry) => entry.step === 'capture' && journalDetail(entry).intent === true && !settled.has(journalDetail(entry).unit))
|
|
221
|
+
const pendingUnits = new Set(pending.map((entry) => journalDetail(entry).unit))
|
|
222
|
+
// Candidates the journal names that are still on disk. At the candidate path of a pending unit the file is
|
|
223
|
+
// reconcileUnit's to judge. A file still in staging was never moved to an exchange path, whatever its unit.
|
|
224
|
+
const leftovers = namedCandidates(document).flatMap((item) => [...(pendingUnits.has(item.unit) ? [] : [item.stagedRef]), item.preparedRef].filter(Boolean).map((ref) => ({ ...item, file: store.resolve(ref) })))
|
|
225
|
+
.filter((item) => exists(item.file))
|
|
226
|
+
// Retirements interrupted between their two moves, for notes that are otherwise settled.
|
|
227
|
+
const candidateOf = new Map((header.staged ?? []).map((item) => [item.unit, item.candidateDigest]))
|
|
228
|
+
for (const entry of document.entries) if (entry.step === 'capture' && journalDetail(entry).stagedRef && entry.afterDigest) candidateOf.set(journalDetail(entry).unit, entry.afterDigest)
|
|
229
|
+
const pathOf = new Map((header.units ?? []).map((item) => [item.unit, item.path]))
|
|
230
|
+
const retiring = interruptedRetirements(store, document.journalId).filter((unit) => !pendingUnits.has(unit))
|
|
231
|
+
const orphans = strays(store, document)
|
|
232
|
+
const pointer = store.readCurrent()
|
|
233
|
+
const hasCommitEntry = document.entries.some((entry) => entry.step === 'manifest-commit' && entry.outcome === 'ok')
|
|
234
|
+
const pointerNamesJournal = pointer?.journalId === document.journalId
|
|
235
|
+
const verifying = document.entries.findLast((entry) => entry.step === 'verify' && journalDetail(entry).settled === true)
|
|
236
|
+
const current = (pointer?.generationId ?? null) === document.expectedGeneration
|
|
237
|
+
const canCommit = Boolean(verifying) && !hasCommitEntry && !pointerNamesJournal && current
|
|
238
|
+
const stale = !current && !pointerNamesJournal
|
|
239
|
+
const atRest = document.state === 'failed' || document.state === 'updating' || document.state === 'captured' || document.state === 'prepared'
|
|
240
|
+
const work = pending.length > 0 || leftovers.length > 0 || retiring.length > 0 || orphans.length > 0 || (pointerNamesJournal && !hasCommitEntry) || canCommit || document.state === 'recovering' || (stale && document.state !== 'failed')
|
|
241
|
+
if (!work && atRest) { if (stale) journal.close(); continue }
|
|
242
|
+
|
|
243
|
+
const actions = []
|
|
244
|
+
journal.append({ step: 'restart', outcome: 'ok', state: 'recovering', detail: { phase: 'begin', pendingUnits: pending.length } })
|
|
245
|
+
for (const capture of pending) {
|
|
246
|
+
const entry = reconcileUnit({ store, journalId: document.journalId, capture, at: iso(clock) })
|
|
247
|
+
journal.append({ ...entry, state: 'recovering' })
|
|
248
|
+
actions.push({ path: capture.notePath, outcome: entry.outcome, code: entry.detail.code })
|
|
249
|
+
if (entry.outcome === 'ok' && entry.detail.op !== 'remove') {
|
|
250
|
+
const verified = readFileDigest(path.join(store.vaultRoot, capture.notePath)) === capture.afterDigest
|
|
251
|
+
journal.append({ step: 'verify', outcome: verified ? 'ok' : 'conflict', state: 'recovering', notePath: capture.notePath, afterDigest: capture.afterDigest,
|
|
252
|
+
detail: { unit: entry.detail.unit, code: verified ? 'verified' : 'changed-after-publication' } })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const keepUnexpected = (unit, notePath, capturedPaths) => {
|
|
256
|
+
for (const capturedPath of capturedPaths) {
|
|
257
|
+
// Not the candidate, and nothing says what it is: it is kept and surfaced as an outside writer's bytes.
|
|
258
|
+
recordDisplaced({ store, journalId: document.journalId, unit, notePath, displacedPath: capturedPath, baseDigest: null, at: iso(clock) })
|
|
259
|
+
actions.push({ path: notePath, outcome: 'ok', code: 'unexpected-bytes-at-staged-path-kept' })
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const unit of retiring) {
|
|
263
|
+
const result = retireStagedFile({ store, journalId: document.journalId, unit, stagedPath: null, candidateDigest: candidateOf.get(unit) ?? null })
|
|
264
|
+
keepUnexpected(unit, pathOf.get(unit) ?? null, result.capturedPaths)
|
|
265
|
+
if (result.retired) actions.push({ path: pathOf.get(unit) ?? null, outcome: 'ok', code: 'interrupted-retirement-finished' })
|
|
266
|
+
}
|
|
267
|
+
for (const item of leftovers) {
|
|
268
|
+
const result = retireStagedFile({ store, journalId: document.journalId, unit: item.unit, stagedPath: item.file, candidateDigest: item.candidateDigest })
|
|
269
|
+
keepUnexpected(item.unit, item.path, result.capturedPaths)
|
|
270
|
+
}
|
|
271
|
+
// A file in staging that no entry names was never put in a payload, so nothing can have exchanged it.
|
|
272
|
+
for (const orphan of orphans) fs.rmSync(orphan, { force: true })
|
|
273
|
+
const stagingDir = store.stagingDir(document.journalId)
|
|
274
|
+
try { fs.rmdirSync(stagingDir) } catch { /* not empty, or already gone */ }
|
|
275
|
+
|
|
276
|
+
let committed = false
|
|
277
|
+
if (pointerNamesJournal && !hasCommitEntry) {
|
|
278
|
+
journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { code: 'pointer-already-committed', reconciled: true } })
|
|
279
|
+
committed = true
|
|
280
|
+
} else if (canCommit) {
|
|
281
|
+
const manifestBytes = readFileBytes(store.resolve(header.manifestRef))
|
|
282
|
+
if (sha256Digest(manifestBytes) !== header.manifestDigest) refuse('journal-corrupt', 'the manifest stored with a journal no longer matches its digest')
|
|
283
|
+
store.commitManifest({ manifestBytes, generationId: document.targetGeneration, journalId: document.journalId, retained: journalDetail(verifying).retained ?? [], committedAt: iso(clock) })
|
|
284
|
+
journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { code: 'committed-on-restart', reconciled: true } })
|
|
285
|
+
committed = true
|
|
286
|
+
} else {
|
|
287
|
+
journal.append({ step: 'restart', outcome: 'ok', state: stale ? 'failed' : 'updating', detail: { phase: 'complete', ...(stale ? { code: 'superseded' } : {}) } })
|
|
288
|
+
}
|
|
289
|
+
if (committed || stale) journal.close()
|
|
290
|
+
report.push({ journalId: document.journalId, actions, committed, state: journal.document().state })
|
|
291
|
+
}
|
|
292
|
+
return { journals: report }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function recoverPublications({ store, clock = () => new Date() }) {
|
|
296
|
+
const release = acquirePrivateLock(store.lockPath)
|
|
297
|
+
try { return recoverPublicationsLocked({ store, clock }) } finally { release() }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Digests this view has published since its trusted manifest was committed,
|
|
301
|
+
// by journals that did not reach a commit. They are valid bases: without them
|
|
302
|
+
// a note published by an interrupted run would look like an outside edit.
|
|
303
|
+
export function publishedSinceCommit({ store }) {
|
|
304
|
+
const pointer = store.readCurrent()
|
|
305
|
+
const ledger = new Map()
|
|
306
|
+
for (const journal of listJournals(store, { openOnly: true })) {
|
|
307
|
+
const document = journal.document()
|
|
308
|
+
if (document.state === 'committed' || document.expectedGeneration !== (pointer?.generationId ?? null)) continue
|
|
309
|
+
for (const entry of document.entries) {
|
|
310
|
+
const detail = journalDetail(entry)
|
|
311
|
+
if (entry.step !== 'conditional-update' || entry.outcome !== 'ok' || detail.kind === 'settings') continue
|
|
312
|
+
ledger.set(entry.notePath, detail.op === 'remove' ? null : entry.afterDigest)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return ledger
|
|
316
|
+
}
|