@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +372 -0
- package/README.md +402 -205
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +49 -0
- package/docs/atelier-runtime.md +28 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/audience-visibility.md +9 -0
- package/docs/blocks/claims.md +61 -0
- package/docs/blocks/conformance-admission.md +15 -0
- package/docs/blocks/will-not-do.md +18 -0
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/continuity.md +3 -4
- package/docs/decisions.md +167 -0
- package/docs/design.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +99 -16
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/knowledge-graph.md +8 -4
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +328 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +690 -0
- package/docs/obsidian.md +423 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +174 -12
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +146 -5
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +86 -10
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-open/SKILL.md +11 -5
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-open/SKILL.md +11 -5
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/content-rules.mjs +283 -20
- package/src/boundary/policy.mjs +213 -91
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +78 -10
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +348 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +17 -9
- package/src/commands/init.mjs +46 -6
- package/src/commands/obsidian.mjs +371 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/server.mjs +1 -4
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +107 -0
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +35 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +206 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +176 -316
- package/src/graph/knowledge-graph.mjs +643 -43
- package/src/guides/contracts.mjs +54 -0
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +41 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +112 -52
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +180 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +120 -0
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +160 -0
- package/src/projection/obsidian/recovery/index.mjs +6 -0
- package/src/projection/obsidian/recovery/journal.mjs +134 -0
- package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
- package/src/projection/obsidian/recovery/restart.mjs +316 -0
- package/src/projection/obsidian/recovery/store.mjs +234 -0
- package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
- package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
- package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
- package/src/projection/obsidian/selection-ui/index.mjs +12 -0
- package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
- package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
- package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
- package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
- package/src/projection/project.mjs +28 -19
- package/src/readiness-protocols/evidence.mjs +282 -0
- package/src/readiness-protocols/runtime.mjs +4 -3
- package/src/readiness-protocols/source-read.mjs +39 -0
- package/src/runtime/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/obsidian/app-capability.mjs +129 -0
- package/src/runtime/obsidian/app-production-seams.mjs +105 -0
- package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
- package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
- package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
- package/src/runtime/obsidian/contributions.mjs +27 -0
- package/src/runtime/obsidian/documents.mjs +37 -0
- package/src/runtime/obsidian/enablement.mjs +53 -0
- package/src/runtime/obsidian/engine.mjs +532 -0
- package/src/runtime/obsidian/errors.mjs +14 -0
- package/src/runtime/obsidian/extension-points.mjs +103 -0
- package/src/runtime/obsidian/index.mjs +34 -0
- package/src/runtime/obsidian/lifecycle.mjs +258 -0
- package/src/runtime/obsidian/machine-settings.mjs +260 -0
- package/src/runtime/obsidian/observation.mjs +158 -0
- package/src/runtime/obsidian/opening.mjs +263 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-entry-path.mjs +9 -0
- package/src/runtime/obsidian/service-main.mjs +91 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- package/src/server/local-sidecar.mjs +125 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +4 -2
- package/src/skills/steward.mjs +780 -0
- package/src/support/feedback-report.mjs +4 -3
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +65 -29
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/project/README.md +2 -2
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { acquirePrivateLock, publishPrivateFile } from '../../../project/durable-state.mjs'
|
|
5
|
+
import { checkManagedRoots } from '../../../project/file-class.mjs'
|
|
6
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../../project/private-state.mjs'
|
|
7
|
+
|
|
8
|
+
// Private per-workspace state for one Obsidian view:
|
|
9
|
+
//
|
|
10
|
+
// <workspaceRoot>/vaults/<scopeId>/ the editable vault (or an explicit vaultRoot)
|
|
11
|
+
// <workspaceRoot>/state/manifests/<scopeId>/ trusted manifests and the current pointer
|
|
12
|
+
// <workspaceRoot>/state/journals/<scopeId>/<id>/ publication journals
|
|
13
|
+
// <workspaceRoot>/recovery/objects/<sha256>.bin immutable content-addressed bytes
|
|
14
|
+
// <workspaceRoot>/recovery/<journalId>/<unit>/ displaced files, receipts, and the unit's exchange candidate
|
|
15
|
+
// <workspaceRoot>/recovery/.exchange-probe/ scratch files of the exchange self-test, nothing else
|
|
16
|
+
// <workspaceRoot>/staging/<journalId>/ disposable generated bytes that no exchange ever names
|
|
17
|
+
// <vault>/.atelier-publication/ the vault lock, shared by every view of the vault
|
|
18
|
+
//
|
|
19
|
+
// Immutable records are published with publishPrivateFile: a complete file
|
|
20
|
+
// appears atomically under a name that is never overwritten. What that gives
|
|
21
|
+
// is an all-or-nothing record that survives a process crash; it is fsynced,
|
|
22
|
+
// which on some platforms is weaker than a full device flush. A displaced
|
|
23
|
+
// file is different: it is the very file that occupied a note path, it is
|
|
24
|
+
// moved and never copied over, and a program that still holds it open may
|
|
25
|
+
// write into it later. It is therefore never treated as immutable; its digest
|
|
26
|
+
// at the time of the move is recorded in a receipt and re-checked.
|
|
27
|
+
//
|
|
28
|
+
// A candidate that replaces an existing file is exchanged with it, so after
|
|
29
|
+
// the exchange the candidate's path holds the displaced bytes. That path is
|
|
30
|
+
// therefore in the unit's recovery directory, never in staging: staging is
|
|
31
|
+
// discardable, and displaced bytes must not sit in a discardable area for an
|
|
32
|
+
// instant. The candidate is written and fsynced in staging, where it is only
|
|
33
|
+
// generated bytes, named with its digest by the journal, and only then moved
|
|
34
|
+
// to its exchange path. What a file at an exchange path is, is decided from
|
|
35
|
+
// the journal: bytes with the recorded candidate digest are generated and
|
|
36
|
+
// ours; anything else is somebody's bytes and is kept. The candidate names
|
|
37
|
+
// end in `.candidate`; everything else the store puts in a unit directory
|
|
38
|
+
// ends in `.bin` or `.json`, so the names cannot collide.
|
|
39
|
+
|
|
40
|
+
export const EXCHANGE_CANDIDATE_NAME = 'exchange.candidate'
|
|
41
|
+
export const LATE_EXCHANGE_CANDIDATE_NAME = 'exchange.late.candidate'
|
|
42
|
+
|
|
43
|
+
export const VAULT_LOCK_DIRECTORY = '.atelier-publication'
|
|
44
|
+
|
|
45
|
+
// The vault lock, with the semantics of every private lock: the newest ticket
|
|
46
|
+
// owns it, a release marker frees it, and a ticket whose process is gone on
|
|
47
|
+
// this host is stale and is taken over. Nothing else is ever written here.
|
|
48
|
+
// Tickets of superseded generations are removed while the lock is held, the
|
|
49
|
+
// marker before its ticket, so the directory does not grow inside a person's
|
|
50
|
+
// vault and an interrupted removal never leaves a marker without its ticket.
|
|
51
|
+
export function acquireVaultLock(store) {
|
|
52
|
+
const directory = ensureContainedPrivateDirectory({ workspaceRoot: store.vaultRoot, directory: path.dirname(store.vaultLockPath), label: 'vault publication lock' })
|
|
53
|
+
const release = acquirePrivateLock(path.join(directory, path.basename(store.vaultLockPath)))
|
|
54
|
+
try {
|
|
55
|
+
const owners = `${store.vaultLockPath}.owners`
|
|
56
|
+
const names = fs.readdirSync(owners).filter((name) => /^\d{12}\.(json|released)$/.test(name)).sort()
|
|
57
|
+
const newest = names.filter((name) => name.endsWith('.json')).at(-1)?.slice(0, 12)
|
|
58
|
+
for (const name of names.filter((item) => item.slice(0, 12) < newest).sort((left, right) => (left.endsWith('.released') ? -1 : 1) - (right.endsWith('.released') ? -1 : 1))) {
|
|
59
|
+
fs.rmSync(path.join(owners, name), { force: true })
|
|
60
|
+
}
|
|
61
|
+
} catch { /* history that cannot be pruned is harmless */ }
|
|
62
|
+
return release
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
66
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/
|
|
67
|
+
|
|
68
|
+
export const sha256Digest = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`
|
|
69
|
+
|
|
70
|
+
export class PublicationRefusal extends Error {
|
|
71
|
+
constructor(code, message, detail = {}) {
|
|
72
|
+
super(`${code}: ${message}`)
|
|
73
|
+
this.name = 'PublicationRefusal'
|
|
74
|
+
this.code = code
|
|
75
|
+
this.detail = detail
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function refuse(code, message, detail) {
|
|
80
|
+
throw new PublicationRefusal(code, message, detail)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Identifiers become directory names. ':' is legal in an identifier and is not
|
|
84
|
+
// legal in a file name everywhere.
|
|
85
|
+
const segment = (identifier) => identifier.replaceAll(':', '_')
|
|
86
|
+
|
|
87
|
+
export function readFileDigest(file) {
|
|
88
|
+
let descriptor
|
|
89
|
+
try {
|
|
90
|
+
descriptor = openRegularFileNoFollow(file)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code === 'ENOENT') return null
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
try { return sha256Digest(fs.readFileSync(descriptor)) } finally { fs.closeSync(descriptor) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readFileBytes(file) {
|
|
99
|
+
const descriptor = openRegularFileNoFollow(file)
|
|
100
|
+
try { return fs.readFileSync(descriptor) } finally { fs.closeSync(descriptor) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function publishOnce(file, bytes) {
|
|
104
|
+
try {
|
|
105
|
+
publishPrivateFile(file, bytes)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// The name is taken by different bytes. An immutable record is never replaced.
|
|
108
|
+
if (error.code !== 'EEXIST') throw error
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `repositoryRoots` is required: every enrolled repository root of the loaded
|
|
115
|
+
// project. Neither the workspace state nor the vault may overlap one of them,
|
|
116
|
+
// in either direction, lexically or by real path; the store refuses before it
|
|
117
|
+
// creates anything. A caller with no enrolled repository (a test in a
|
|
118
|
+
// temporary directory) says so with an explicit empty list.
|
|
119
|
+
export function createRecoveryStore({ workspaceRoot, workspaceId, scopeId, vaultRoot, repositoryRoots } = {}) {
|
|
120
|
+
if (typeof workspaceRoot !== 'string' || !path.isAbsolute(workspaceRoot)) throw new TypeError('workspaceRoot must be an absolute path')
|
|
121
|
+
for (const [label, value] of [['workspaceId', workspaceId], ['scopeId', scopeId]]) {
|
|
122
|
+
if (typeof value !== 'string' || !IDENTIFIER.test(value)) throw new TypeError(`${label} must be a contract identifier`)
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(repositoryRoots) || repositoryRoots.some((item) => typeof item !== 'string' || !path.isAbsolute(item))) {
|
|
125
|
+
throw new TypeError('repositoryRoots must list the absolute root of every enrolled repository; pass an explicit empty list when there is none')
|
|
126
|
+
}
|
|
127
|
+
if (vaultRoot !== undefined && (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot))) throw new TypeError('vaultRoot must be an absolute path')
|
|
128
|
+
const guard = checkManagedRoots({ managedRoots: [workspaceRoot, ...(vaultRoot === undefined ? [] : [vaultRoot])], repositoryRoots })
|
|
129
|
+
if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals })
|
|
130
|
+
fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
|
|
131
|
+
const root = fs.realpathSync(workspaceRoot)
|
|
132
|
+
const privateDir = (...parts) => ensureContainedPrivateDirectory({ workspaceRoot: root, directory: path.join(root, ...parts), label: 'Obsidian publication state' })
|
|
133
|
+
const requestedVault = vaultRoot ?? path.join(root, 'vaults', segment(scopeId))
|
|
134
|
+
fs.mkdirSync(requestedVault, { recursive: true })
|
|
135
|
+
const vault = fs.realpathSync(requestedVault)
|
|
136
|
+
const inside = (parent, child) => { const relative = path.relative(parent, child); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) }
|
|
137
|
+
for (const area of ['state', 'recovery', 'staging']) {
|
|
138
|
+
if (inside(path.join(root, area), vault) || inside(vault, path.join(root, area))) throw new TypeError('the vault may not overlap private publication state')
|
|
139
|
+
}
|
|
140
|
+
const manifests = privateDir('state', 'manifests', segment(scopeId))
|
|
141
|
+
const journals = privateDir('state', 'journals', segment(scopeId))
|
|
142
|
+
const locks = privateDir('state', 'locks')
|
|
143
|
+
const objects = privateDir('recovery', 'objects')
|
|
144
|
+
const staging = privateDir('staging')
|
|
145
|
+
const pointerFile = path.join(manifests, 'current.json')
|
|
146
|
+
|
|
147
|
+
const store = {
|
|
148
|
+
workspaceRoot: root,
|
|
149
|
+
workspaceId,
|
|
150
|
+
scopeId,
|
|
151
|
+
vaultRoot: vault,
|
|
152
|
+
journalsRoot: journals,
|
|
153
|
+
lockPath: path.join(locks, `${segment(scopeId)}.lock`),
|
|
154
|
+
// One publisher per vault, whichever view or workspace state it belongs to.
|
|
155
|
+
// Two stores share nothing but the vault, so this lock lives under the
|
|
156
|
+
// vault's real path, in a dot-directory no note path can name.
|
|
157
|
+
vaultLockPath: path.join(vault, VAULT_LOCK_DIRECTORY, 'vault.lock'),
|
|
158
|
+
ref: (absolute) => path.relative(root, absolute).split(path.sep).join('/'),
|
|
159
|
+
resolve(ref) {
|
|
160
|
+
const absolute = path.resolve(root, ref)
|
|
161
|
+
if (!inside(root, absolute) || absolute === root) throw new TypeError('a store reference must stay inside the workspace state')
|
|
162
|
+
return absolute
|
|
163
|
+
},
|
|
164
|
+
journalDir: (journalId) => privateDir('state', 'journals', segment(scopeId), segment(journalId)),
|
|
165
|
+
stagingDir: (journalId) => privateDir('staging', segment(journalId)),
|
|
166
|
+
stagingRoot: staging,
|
|
167
|
+
// Where the exchange self-test runs: inside recovery, because that is where exchange candidates live.
|
|
168
|
+
exchangeProbeDir: () => privateDir('recovery', '.exchange-probe'),
|
|
169
|
+
// Generated bytes on their way to a path: written here, and for a created file linked from here.
|
|
170
|
+
preparedPath: (journalId, unit, { late = false } = {}) => path.join(store.stagingDir(journalId), `${String(unit).padStart(6, '0')}${late ? '.late' : ''}.candidate`),
|
|
171
|
+
// The path handed to the exchange. Computed without creating anything; unitDir creates the directory.
|
|
172
|
+
exchangeCandidatePath: (journalId, unit, { late = false } = {}) => path.join(root, 'recovery', segment(journalId), String(unit).padStart(6, '0'), late ? LATE_EXCHANGE_CANDIDATE_NAME : EXCHANGE_CANDIDATE_NAME),
|
|
173
|
+
unitsRoot: (journalId) => path.join(root, 'recovery', segment(journalId)),
|
|
174
|
+
unitDir: (journalId, unit) => privateDir('recovery', segment(journalId), String(unit).padStart(6, '0')),
|
|
175
|
+
displacedPath: (journalId, unit, name = 'displaced.bin') => path.join(store.unitDir(journalId, unit), name),
|
|
176
|
+
|
|
177
|
+
// Immutable, content-addressed bytes (a base, an observed edit, an outside writer's bytes).
|
|
178
|
+
retainObject(bytes) {
|
|
179
|
+
const digest = sha256Digest(bytes)
|
|
180
|
+
const file = path.join(objects, `${digest.slice('sha256:'.length)}.bin`)
|
|
181
|
+
publishPrivateFile(file, bytes)
|
|
182
|
+
return { digest, ref: store.ref(file) }
|
|
183
|
+
},
|
|
184
|
+
readObject(digest) {
|
|
185
|
+
if (!DIGEST.test(digest)) throw new TypeError('digest required')
|
|
186
|
+
const bytes = readFileBytes(path.join(objects, `${digest.slice('sha256:'.length)}.bin`))
|
|
187
|
+
if (sha256Digest(bytes) !== digest) refuse('recovery-object-corrupt', 'a retained object no longer matches its name')
|
|
188
|
+
return bytes
|
|
189
|
+
},
|
|
190
|
+
writeReceipt(journalId, unit, receipt) {
|
|
191
|
+
const body = Buffer.from(`${JSON.stringify({ schema: 'atelier-obsidian-recovery-receipt/v1', journalId, unit, ...receipt }, null, 2)}\n`, 'utf8')
|
|
192
|
+
const file = path.join(store.unitDir(journalId, unit), `receipt-${receipt.role}-${sha256Digest(body).slice(7, 19)}.json`)
|
|
193
|
+
publishOnce(file, body)
|
|
194
|
+
return store.ref(file)
|
|
195
|
+
},
|
|
196
|
+
listReceipts(journalId) {
|
|
197
|
+
const base = path.join(root, 'recovery', segment(journalId))
|
|
198
|
+
if (!fs.existsSync(base)) return []
|
|
199
|
+
const receipts = []
|
|
200
|
+
for (const unit of fs.readdirSync(base).sort()) {
|
|
201
|
+
for (const name of fs.readdirSync(path.join(base, unit)).filter((entry) => /^receipt-.*\.json$/.test(entry)).sort()) {
|
|
202
|
+
receipts.push(JSON.parse(readRegularTextNoFollow(path.join(base, unit, name))))
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return receipts
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// Trusted manifest. The manifest file is immutable; only the small pointer
|
|
209
|
+
// is replaced, and only as the last step of a publication.
|
|
210
|
+
readCurrent() {
|
|
211
|
+
let text
|
|
212
|
+
try { text = readRegularTextNoFollow(pointerFile) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
|
|
213
|
+
const pointer = JSON.parse(text)
|
|
214
|
+
if (pointer.scopeId !== scopeId || pointer.workspaceId !== workspaceId) refuse('state-mismatch', 'the manifest pointer belongs to another view')
|
|
215
|
+
return pointer
|
|
216
|
+
},
|
|
217
|
+
readCurrentManifest() {
|
|
218
|
+
const pointer = store.readCurrent()
|
|
219
|
+
if (!pointer) return null
|
|
220
|
+
const bytes = readFileBytes(path.join(manifests, pointer.manifestFile))
|
|
221
|
+
if (sha256Digest(bytes) !== pointer.manifestDigest) refuse('state-mismatch', 'the trusted manifest no longer matches its pointer')
|
|
222
|
+
return JSON.parse(bytes.toString('utf8'))
|
|
223
|
+
},
|
|
224
|
+
commitManifest({ manifestBytes, generationId, journalId, retained = [], committedAt }) {
|
|
225
|
+
const manifestDigest = sha256Digest(manifestBytes)
|
|
226
|
+
const manifestFile = `${segment(generationId)}--${manifestDigest.slice(7, 19)}.json`
|
|
227
|
+
publishPrivateFile(path.join(manifests, manifestFile), manifestBytes)
|
|
228
|
+
const pointer = { schema: 'atelier-obsidian-current-manifest/v1', workspaceId, scopeId, generationId, manifestFile, manifestDigest, journalId, committedAt, retained }
|
|
229
|
+
atomicReplacePrivateText(pointerFile, `${JSON.stringify(pointer, null, 2)}\n`)
|
|
230
|
+
return pointer
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
return store
|
|
234
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { compareText } from '../../../runtime/obsidian/documents.mjs'
|
|
2
|
+
import { OPEN_EDIT_STATES, createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
|
|
3
|
+
import { openObjectStore } from '../edits/object-store.mjs'
|
|
4
|
+
|
|
5
|
+
// A read-only, typed view of the shared conflict state AOP-2's arbitration
|
|
6
|
+
// keeps per object: which objects are conflicted, pending, settled or
|
|
7
|
+
// unreadable, which operations are involved and why, and which pending edit
|
|
8
|
+
// records of which views feed them. Identities, states, codes and digests
|
|
9
|
+
// only: no note text, title or path.
|
|
10
|
+
//
|
|
11
|
+
// `conflictView` is pure: it is a function of the object entries (the
|
|
12
|
+
// object store's `list()` or an `arbitrateEvents` summary) and the pending
|
|
13
|
+
// edit records. `readConflictView` reads both from the workspace's private
|
|
14
|
+
// state. Neither decides anything, writes an event or touches a source file
|
|
15
|
+
// or a vault.
|
|
16
|
+
|
|
17
|
+
export const CONFLICT_VIEW_SCHEMA = 'atelier-obsidian-conflict-view/v1'
|
|
18
|
+
export const OBJECT_VIEW_STATES = Object.freeze(['conflicted', 'pending', 'settled', 'empty', 'inconsistent', 'unreadable'])
|
|
19
|
+
|
|
20
|
+
// What a person or an agent does next. A conflict is resolved by a new
|
|
21
|
+
// operation that names the ones it resolves; nothing here chooses a winner.
|
|
22
|
+
const NEXT = Object.freeze({
|
|
23
|
+
conflicted: 'offer a resolution that names every conflicted operation; no operation is dropped and no winner is chosen',
|
|
24
|
+
pending: 'apply explicitly, or let an active automatic policy dispatch it',
|
|
25
|
+
settled: 'nothing: every recorded operation is applied, superseded or refused',
|
|
26
|
+
empty: 'nothing: no operation is recorded for this object',
|
|
27
|
+
inconsistent: 'a person looks at the object log; an event the log did not allow is recorded',
|
|
28
|
+
unreadable: 'a person looks at the object directory; nothing is decided until then',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const NEEDS_PERSON = new Set(['conflicted', 'inconsistent', 'unreadable'])
|
|
32
|
+
|
|
33
|
+
function operationView(operation) {
|
|
34
|
+
return {
|
|
35
|
+
idempotencyKey: operation.idempotencyKey,
|
|
36
|
+
kind: operation.kind ?? null,
|
|
37
|
+
state: operation.state,
|
|
38
|
+
reason: operation.reason ?? null,
|
|
39
|
+
by: operation.by ?? null,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function conflictView({ objects, edits = [], scopeId } = {}) {
|
|
44
|
+
if (!Array.isArray(objects)) throw new TypeError('conflictView needs the list of object entries')
|
|
45
|
+
const openEdits = edits.filter((edit) => OPEN_EDIT_STATES.includes(edit.state) && (scopeId === undefined || edit.scopeId === scopeId))
|
|
46
|
+
const editsOf = (repoId, nodeId) => openEdits
|
|
47
|
+
.filter((edit) => edit.identity?.repoId === repoId && edit.identity?.nodeId === nodeId)
|
|
48
|
+
.map((edit) => ({ editId: edit.editId, scopeId: edit.scopeId, state: edit.state, lastCode: edit.lastResult?.code ?? null }))
|
|
49
|
+
.sort((left, right) => compareText(left.scopeId, right.scopeId) || compareText(left.editId, right.editId))
|
|
50
|
+
|
|
51
|
+
const entries = objects.map((object) => {
|
|
52
|
+
const state = OBJECT_VIEW_STATES.includes(object.state) ? object.state : 'unreadable'
|
|
53
|
+
const identity = object.identity ?? object
|
|
54
|
+
const repoId = identity.repoId ?? null
|
|
55
|
+
const nodeId = identity.nodeId ?? null
|
|
56
|
+
const operations = (object.operations ?? []).map(operationView)
|
|
57
|
+
const intentOutcomeUnknown = object.intentOutcomeUnknown === true || (object.intent !== undefined && object.intent !== null)
|
|
58
|
+
const needsPerson = NEEDS_PERSON.has(state) || intentOutcomeUnknown
|
|
59
|
+
return {
|
|
60
|
+
repoId, nodeId, state,
|
|
61
|
+
code: object.code ?? null,
|
|
62
|
+
sourceDigest: object.sourceDigest ?? null,
|
|
63
|
+
leaseHeld: object.leaseHeld === true || (object.lease !== undefined && object.lease !== null),
|
|
64
|
+
intentOutcomeUnknown,
|
|
65
|
+
operations,
|
|
66
|
+
conflictedOperations: operations.filter((operation) => operation.state === 'conflicted').map((operation) => operation.idempotencyKey),
|
|
67
|
+
pendingEdits: repoId === null ? [] : editsOf(repoId, nodeId),
|
|
68
|
+
needsPerson,
|
|
69
|
+
next: intentOutcomeUnknown && !NEEDS_PERSON.has(state) ? 'an apply was started and its outcome is not recorded; the next lease holder reads the source and decides' : NEXT[state],
|
|
70
|
+
}
|
|
71
|
+
}).sort((left, right) => compareText(left.repoId ?? '', right.repoId ?? '') || compareText(left.nodeId ?? '', right.nodeId ?? ''))
|
|
72
|
+
|
|
73
|
+
const byState = Object.fromEntries(OBJECT_VIEW_STATES.map((state) => [state, entries.filter((entry) => entry.state === state).length]))
|
|
74
|
+
return {
|
|
75
|
+
schema: CONFLICT_VIEW_SCHEMA,
|
|
76
|
+
scopeId: scopeId ?? null,
|
|
77
|
+
objects: entries,
|
|
78
|
+
summary: { total: entries.length, byState, needsPerson: entries.filter((entry) => entry.needsPerson).length, openEdits: openEdits.length },
|
|
79
|
+
needsPerson: entries.some((entry) => entry.needsPerson),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Reads the object index (a cache the store rebuilds from the events where
|
|
84
|
+
// it is not current) and the pending edit records of the workspace.
|
|
85
|
+
export function readConflictView({ workspaceRoot, workspaceId, repositoryRoots, clock, scopeId, objectStore = openObjectStore }) {
|
|
86
|
+
const store = objectStore({ stateRoot: workspaceRoot, workspaceId, repositoryRoots, clock })
|
|
87
|
+
const { objects, foreign } = store.list(scopeId === undefined ? {} : { scopeId })
|
|
88
|
+
const edits = createMaintenanceStateStore({ workspaceRoot, workspaceId }).readPendingEdits().edits
|
|
89
|
+
const view = conflictView({ objects, edits, scopeId })
|
|
90
|
+
return { ...view, foreign }
|
|
91
|
+
}
|