@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,517 @@
|
|
|
1
|
+
import { randomBytes as cryptoRandomBytes, randomUUID } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { isPendingPrivateWrite } from '../../../project/durable-state.mjs'
|
|
5
|
+
import { checkManagedRoots } from '../../../project/file-class.mjs'
|
|
6
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, syncPrivateDirectory } from '../../../project/private-state.mjs'
|
|
7
|
+
import { canonicalJson, isoTime } from '../../../runtime/obsidian/documents.mjs'
|
|
8
|
+
import { createAbandonmentProof, machineDigest } from '../../../runtime/obsidian/private-lock.mjs'
|
|
9
|
+
import { OBSIDIAN_EXT_KEY, validateObsidianContract } from '../contracts.mjs'
|
|
10
|
+
import { sha256Digest } from '../materialize/byte-lens.mjs'
|
|
11
|
+
import { ARBITRATION_RULES, EditArbitrationRefusal, OBJECT_EVENT_SCHEMA, REFUSED_DISPOSITIONS, arbitrateEvents, isObjectEvent, recordableOperation, refuseArbitration, sameOrigin } from './arbitrate.mjs'
|
|
12
|
+
import { decodeIdentitySegment, encodeIdentitySegment, isIdentifier, isIdentitySegment } from './object-identity.mjs'
|
|
13
|
+
import { editIdempotencyKey } from './observe.mjs'
|
|
14
|
+
|
|
15
|
+
// Durable arbitration of edits, shared by every view of one workspace.
|
|
16
|
+
//
|
|
17
|
+
// <stateRoot>/state/objects/<repo>/<node>/000000000001.json the events of one object
|
|
18
|
+
// <stateRoot>/state/object-index/<repo>/<node>.json a derived summary, a cache
|
|
19
|
+
//
|
|
20
|
+
// <repo> and <node> are encoded identifiers (object-identity.mjs); the
|
|
21
|
+
// repository is part of the key, so equal node ids of two repositories never
|
|
22
|
+
// meet. The state root is private workspace state, outside every repository
|
|
23
|
+
// and vault.
|
|
24
|
+
//
|
|
25
|
+
// The events of an object are its only truth. An event is one complete,
|
|
26
|
+
// canonical JSON file named by its sequence number, published with a
|
|
27
|
+
// non-overwriting hard link and never rewritten or removed. Every event names
|
|
28
|
+
// the digest of the one before it. An appender reads the log through N,
|
|
29
|
+
// decides, and publishes N+1; of two appenders exactly one gets the name, and
|
|
30
|
+
// the other reads again and decides again against what it lost to. So every
|
|
31
|
+
// event was decided against exactly the events before it, the log is a
|
|
32
|
+
// serialization of everything any process did, and there is no last writer to
|
|
33
|
+
// win: what an operation becomes is a function of the whole log
|
|
34
|
+
// (arbitrate.mjs), not of who wrote last.
|
|
35
|
+
//
|
|
36
|
+
// The compare/apply lease of an object is two of those events. Holding it
|
|
37
|
+
// means the last `lease-acquired` is not followed by its `lease-released`.
|
|
38
|
+
// It is taken from a holder only with proof that the holder is gone: its
|
|
39
|
+
// process does not exist on this machine (the proof rules of the engine lock,
|
|
40
|
+
// runtime/obsidian/private-lock.mjs). No amount of elapsed time is proof. A
|
|
41
|
+
// holder on another machine, or a live process that no longer knows it holds
|
|
42
|
+
// the lease, needs a person; so does an object whose log cannot be read,
|
|
43
|
+
// which refuses every operation and is never repaired by guessing.
|
|
44
|
+
//
|
|
45
|
+
// Only what the log cannot derive is asked of the caller; what it can derive
|
|
46
|
+
// is also written down, as `conflict-declared`, `superseded` and
|
|
47
|
+
// `siblings-stale` events that follow the event that caused them. A crash
|
|
48
|
+
// between the two loses nothing: the state is derived from the cause, and the
|
|
49
|
+
// next writer or `recoverObject` appends the declaration that is missing.
|
|
50
|
+
//
|
|
51
|
+
// Bounds. An event is at most 16 KiB (an observation is about 1.5 KiB). An
|
|
52
|
+
// object holds at most 4096 events, about 500 edits taken through a full
|
|
53
|
+
// apply; the last 64 are kept for settling what is in flight (intent,
|
|
54
|
+
// outcome, declarations, release), so an observation or a new lease refuses
|
|
55
|
+
// `object-log-full` first. Nothing is rotated, compacted or dropped: a full
|
|
56
|
+
// object refuses, and what becomes of it is a person's decision.
|
|
57
|
+
//
|
|
58
|
+
// Nothing here writes to a source file or to a vault, and no event, index or
|
|
59
|
+
// error holds note or source text: identifiers, digests, store references,
|
|
60
|
+
// codes, numbers and timestamps only.
|
|
61
|
+
|
|
62
|
+
export const OBJECTS_DIRECTORY = path.join('state', 'objects')
|
|
63
|
+
export const OBJECT_INDEX_DIRECTORY = path.join('state', 'object-index')
|
|
64
|
+
export const OBJECT_INDEX_SCHEMA = 'atelier-obsidian-object-index/v1'
|
|
65
|
+
export const EDIT_ACKNOWLEDGEMENT_SCHEMA = 'atelier-obsidian-edit-acknowledgement/v1'
|
|
66
|
+
export const OBJECT_STORE_LIMITS = Object.freeze({ maxEventBytes: 16 * 1024, maxEventsPerObject: 4096, reservedForSettlement: 64, maxAppendAttempts: 256 })
|
|
67
|
+
export const OBJECT_STORE_CRASH_STEPS = Object.freeze(['event-linked', 'event-published', 'index-updated'])
|
|
68
|
+
|
|
69
|
+
const EVENT_NAME = /^(\d{12})\.json$/
|
|
70
|
+
const eventName = (sequence) => `${String(sequence).padStart(12, '0')}.json`
|
|
71
|
+
const NEEDS_PERSON = new Set(['held-on-another-machine'])
|
|
72
|
+
|
|
73
|
+
export const OBJECT_STORE_PRIMITIVES = Object.freeze({
|
|
74
|
+
rules: ARBITRATION_RULES,
|
|
75
|
+
limits: OBJECT_STORE_LIMITS,
|
|
76
|
+
// Proof that the holder of a lease is gone: its process does not exist on
|
|
77
|
+
// this machine. There is no maximum age.
|
|
78
|
+
proveAbandoned: createAbandonmentProof(),
|
|
79
|
+
// An index is used only when it was derived from exactly the events present.
|
|
80
|
+
indexIsCurrent: ({ index, eventCount, headDigest }) => index.sequence === eventCount && index.headDigest === headDigest,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// The members of `values` that were given: an absent optional member is absent from the event, never null.
|
|
84
|
+
const optional = (values) => Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null))
|
|
85
|
+
|
|
86
|
+
function readBytesNoFollow(file) {
|
|
87
|
+
const descriptor = openRegularFileNoFollow(file)
|
|
88
|
+
try { return fs.readFileSync(descriptor) } finally { fs.closeSync(descriptor) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createObjectStoreForOracleTests(primitives = OBJECT_STORE_PRIMITIVES) {
|
|
92
|
+
const { rules, limits, proveAbandoned, indexIsCurrent } = primitives
|
|
93
|
+
|
|
94
|
+
return function openObjectStore({ stateRoot, workspaceId, repositoryRoots, clock, crash = () => {}, randomBytes = cryptoRandomBytes } = {}) {
|
|
95
|
+
if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot)) throw new TypeError('stateRoot must be an absolute path')
|
|
96
|
+
if (!isIdentifier(workspaceId)) throw new TypeError('workspaceId must be a contract identifier')
|
|
97
|
+
if (typeof clock !== 'function') throw new TypeError('a clock must be injected')
|
|
98
|
+
if (!Array.isArray(repositoryRoots) || repositoryRoots.some((item) => typeof item !== 'string' || !path.isAbsolute(item))) {
|
|
99
|
+
throw new TypeError('repositoryRoots must list the absolute root of every enrolled repository; pass an explicit empty list when there is none')
|
|
100
|
+
}
|
|
101
|
+
const guard = checkManagedRoots({ managedRoots: [stateRoot], repositoryRoots })
|
|
102
|
+
if (!guard.ok) refuseArbitration('state-root-overlaps-repository', 'the arbitration state may not overlap an enrolled repository', { codes: guard.refusals.map((item) => item.code) })
|
|
103
|
+
fs.mkdirSync(stateRoot, { recursive: true, mode: 0o700 })
|
|
104
|
+
const root = fs.realpathSync.native(stateRoot)
|
|
105
|
+
// Two processes may create the same directory at once; the one that loses
|
|
106
|
+
// the mkdir finds it made, and checks it like any other.
|
|
107
|
+
const privateDir = (...parts) => {
|
|
108
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
109
|
+
try { return ensureContainedPrivateDirectory({ workspaceRoot: root, directory: path.join(root, ...parts), label: 'edit arbitration state' }) } catch (error) {
|
|
110
|
+
if (error.code !== 'EEXIST' || attempt >= 8) throw error
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const objectsRoot = privateDir(OBJECTS_DIRECTORY)
|
|
115
|
+
const indexRoot = privateDir(OBJECT_INDEX_DIRECTORY)
|
|
116
|
+
// Verified prefixes of logs this store has read. Events are immutable and
|
|
117
|
+
// chained, so a verified prefix stays verified; recoverObject reads afresh.
|
|
118
|
+
const verified = new Map()
|
|
119
|
+
|
|
120
|
+
function identityOf(value) {
|
|
121
|
+
const identity = { workspaceId, repoId: value?.repoId, nodeId: value?.nodeId }
|
|
122
|
+
if (!isIdentifier(identity.repoId) || !isIdentifier(identity.nodeId)) refuseArbitration('invalid-identity', 'an object is named by a repository identifier and a node identifier')
|
|
123
|
+
if (value.workspaceId !== undefined && value.workspaceId !== workspaceId) refuseArbitration('foreign-workspace', 'the object belongs to another workspace')
|
|
124
|
+
return identity
|
|
125
|
+
}
|
|
126
|
+
const segmentsOf = (identity) => [encodeIdentitySegment(identity.repoId), encodeIdentitySegment(identity.nodeId)]
|
|
127
|
+
const pointerOf = (identity) => ({ repoId: identity.repoId, nodeId: identity.nodeId })
|
|
128
|
+
|
|
129
|
+
// ---- reading ---------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function listEventNames(directory, identity) {
|
|
132
|
+
let names
|
|
133
|
+
try { names = fs.readdirSync(directory) } catch (error) {
|
|
134
|
+
if (error.code === 'ENOENT') return 0
|
|
135
|
+
if (error.code === 'ENOTDIR') refuseArbitration('foreign-object-file', 'something that is not a directory stands where the object is kept', pointerOf(identity))
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
138
|
+
let highest = 0
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
if (isPendingPrivateWrite(name)) continue
|
|
141
|
+
const match = EVENT_NAME.exec(name)
|
|
142
|
+
if (!match || Number(match[1]) < 1) refuseArbitration('foreign-object-file', 'the object directory holds a file that is not an event; nothing is decided until a person has looked', pointerOf(identity))
|
|
143
|
+
highest = Math.max(highest, Number(match[1]))
|
|
144
|
+
}
|
|
145
|
+
return highest
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readEvent(directory, identity, sequence, previous) {
|
|
149
|
+
const corrupt = (code, message) => refuseArbitration(code, message, { ...pointerOf(identity), sequence })
|
|
150
|
+
let bytes
|
|
151
|
+
try {
|
|
152
|
+
const file = path.join(directory, eventName(sequence))
|
|
153
|
+
if (fs.lstatSync(file).size > limits.maxEventBytes) corrupt('object-event-too-large', 'an event is larger than an event can be')
|
|
154
|
+
bytes = readBytesNoFollow(file)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error instanceof EditArbitrationRefusal) throw error
|
|
157
|
+
// Events are published in order and never removed, so a name that is
|
|
158
|
+
// listed has every lower name beside it.
|
|
159
|
+
return corrupt(error.code === 'ENOENT' ? 'object-log-gap' : 'object-event-unreadable', 'an event of the object cannot be read')
|
|
160
|
+
}
|
|
161
|
+
let document
|
|
162
|
+
try { document = JSON.parse(bytes.toString('utf8')) } catch { return corrupt('object-event-malformed', 'an event of the object is not a document') }
|
|
163
|
+
if (!isObjectEvent(document) || canonicalJson(document) !== bytes.toString('utf8')) corrupt('object-event-malformed', 'an event of the object is not a valid canonical event')
|
|
164
|
+
if (document.workspaceId !== identity.workspaceId || document.repoId !== identity.repoId || document.nodeId !== identity.nodeId) corrupt('object-event-foreign', 'an event names another object')
|
|
165
|
+
if (document.sequence !== sequence || document.previous !== previous) corrupt('object-log-chain-broken', 'an event does not follow the one before it')
|
|
166
|
+
return { document, digest: sha256Digest(bytes) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// { directory, events, digests, derived }. Refuses a log that cannot be trusted.
|
|
170
|
+
function readLog(identity, { fresh = false } = {}) {
|
|
171
|
+
const directory = path.join(objectsRoot, ...segmentsOf(identity))
|
|
172
|
+
const highest = listEventNames(directory, identity)
|
|
173
|
+
const cached = fresh ? undefined : verified.get(directory)
|
|
174
|
+
const events = cached ? [...cached.events] : []
|
|
175
|
+
const digests = cached ? [...cached.digests] : []
|
|
176
|
+
if (highest < events.length) refuseArbitration('object-log-shortened', 'events of the object have disappeared', pointerOf(identity))
|
|
177
|
+
for (let sequence = events.length + 1; sequence <= highest; sequence += 1) {
|
|
178
|
+
const { document, digest } = readEvent(directory, identity, sequence, digests.at(-1) ?? null)
|
|
179
|
+
events.push(document)
|
|
180
|
+
digests.push(digest)
|
|
181
|
+
}
|
|
182
|
+
verified.set(directory, { events, digests })
|
|
183
|
+
const derived = arbitrateEvents(events, rules)
|
|
184
|
+
if (derived.violations.length > 0) {
|
|
185
|
+
refuseArbitration('object-log-inconsistent', 'the events of the object are not a history this store could have written', { ...pointerOf(identity), violations: derived.violations })
|
|
186
|
+
}
|
|
187
|
+
return { directory, events, digests, derived: { ...derived, identity } }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- writing ---------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
// 'published' or 'taken'. A crash leaves a complete event or none, and at
|
|
193
|
+
// most a private staging file that no reader takes for an event.
|
|
194
|
+
function publishEvent(directory, document) {
|
|
195
|
+
const text = canonicalJson(document)
|
|
196
|
+
const bytes = Buffer.from(text, 'utf8')
|
|
197
|
+
if (bytes.length > limits.maxEventBytes) refuseArbitration('object-event-too-large', 'the event is larger than an event can be', { sequence: document.sequence, bytes: bytes.length })
|
|
198
|
+
const file = path.join(directory, eventName(document.sequence))
|
|
199
|
+
const pending = path.join(directory, `.atelier-write-${randomUUID()}.tmp`)
|
|
200
|
+
const context = { type: document.type, sequence: document.sequence, repoId: document.repoId, nodeId: document.nodeId }
|
|
201
|
+
let descriptor
|
|
202
|
+
let crashed = false
|
|
203
|
+
const seam = (step) => { try { crash(step, context) } catch (error) { crashed = true; throw error } }
|
|
204
|
+
try {
|
|
205
|
+
descriptor = openRegularFileNoFollow(pending, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600)
|
|
206
|
+
fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined
|
|
207
|
+
try { fs.linkSync(pending, file) } catch (error) {
|
|
208
|
+
if (error.code !== 'EEXIST') throw error
|
|
209
|
+
// The same bytes under the name: the same decision, made twice.
|
|
210
|
+
if (!readBytesNoFollow(file).equals(bytes)) return 'taken'
|
|
211
|
+
}
|
|
212
|
+
seam('event-linked')
|
|
213
|
+
syncPrivateDirectory(directory)
|
|
214
|
+
if (!readBytesNoFollow(file).equals(bytes)) throw new Error('published event readback mismatch')
|
|
215
|
+
seam('event-published')
|
|
216
|
+
return 'published'
|
|
217
|
+
} finally {
|
|
218
|
+
if (descriptor !== undefined) fs.closeSync(descriptor)
|
|
219
|
+
// A crashed process removes nothing.
|
|
220
|
+
if (!crashed) try { fs.unlinkSync(pending) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function objectDirectory(identity) {
|
|
225
|
+
const [repo, node] = segmentsOf(identity)
|
|
226
|
+
const existed = fs.existsSync(path.join(objectsRoot, repo, node))
|
|
227
|
+
const directory = privateDir(OBJECTS_DIRECTORY, repo, node)
|
|
228
|
+
if (!existed) { syncPrivateDirectory(path.join(objectsRoot, repo)); syncPrivateDirectory(objectsRoot) }
|
|
229
|
+
return directory
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Appends the event `build(derived)` describes, decided against exactly
|
|
233
|
+
// the events before it. `build` returns { type, body }, or { done } when
|
|
234
|
+
// nothing is to be appended. The reducer is the judge: an event it would
|
|
235
|
+
// list as a violation is refused with that code and never written.
|
|
236
|
+
function append(identity, build, { settling = false } = {}) {
|
|
237
|
+
for (let attempt = 0; attempt < limits.maxAppendAttempts; attempt += 1) {
|
|
238
|
+
const log = readLog(identity)
|
|
239
|
+
const decided = build(log.derived, log)
|
|
240
|
+
if (Object.hasOwn(decided, 'done')) return { appended: false, log, value: decided.done }
|
|
241
|
+
const ceiling = limits.maxEventsPerObject - (settling ? 0 : limits.reservedForSettlement)
|
|
242
|
+
if (log.events.length >= ceiling) refuseArbitration('object-log-full', 'the object holds as many events as an object may; nothing is rotated or dropped', { ...pointerOf(identity), events: log.events.length, limit: limits.maxEventsPerObject })
|
|
243
|
+
const event = { schema: OBJECT_EVENT_SCHEMA, ...identity, sequence: log.events.length + 1, previous: log.digests.at(-1) ?? null, type: decided.type, recordedAt: isoTime(clock), body: decided.body }
|
|
244
|
+
if (!isObjectEvent(event)) refuseArbitration('invalid-event', 'the event is not one this store records', { type: decided.type })
|
|
245
|
+
const after = arbitrateEvents([...log.events, event], rules)
|
|
246
|
+
if (after.violations.length > 0) refuseArbitration(after.violations[0].code, 'the events of the object do not allow this', { ...pointerOf(identity), type: decided.type })
|
|
247
|
+
if (publishEvent(objectDirectory(identity), event) === 'taken') continue
|
|
248
|
+
return { appended: true, event, log: readLog(identity) }
|
|
249
|
+
}
|
|
250
|
+
return refuseArbitration('append-contention', 'the object was appended to by others on every attempt', pointerOf(identity))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// What the log can derive and has not yet written down, oldest cause first.
|
|
254
|
+
function declare(identity) {
|
|
255
|
+
for (;;) {
|
|
256
|
+
const { appended } = append(identity, ({ undeclared }) => {
|
|
257
|
+
if (undeclared.siblingsStale !== null) return { type: 'siblings-stale', body: undeclared.siblingsStale }
|
|
258
|
+
if (undeclared.superseded.length > 0) return { type: 'superseded', body: { operations: undeclared.superseded } }
|
|
259
|
+
if (undeclared.conflicted.length > 0) return { type: 'conflict-declared', body: { operations: undeclared.conflicted } }
|
|
260
|
+
return { done: true }
|
|
261
|
+
}, { settling: true })
|
|
262
|
+
if (!appended) return
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- the index, a cache ---------------------------------------------
|
|
267
|
+
|
|
268
|
+
const indexFile = (identity) => { const [repo, node] = segmentsOf(identity); return path.join(indexRoot, repo, `${node}.json`) }
|
|
269
|
+
|
|
270
|
+
function indexDocument({ derived, digests }) {
|
|
271
|
+
const scopes = new Set()
|
|
272
|
+
const operations = derived.operations.map((entry) => {
|
|
273
|
+
const own = [...new Set(entry.origins.map((origin) => origin.scopeId))].sort()
|
|
274
|
+
for (const scopeId of own) scopes.add(scopeId)
|
|
275
|
+
return { idempotencyKey: entry.idempotencyKey, kind: entry.kind, state: entry.state, reason: entry.reason, scopes: own }
|
|
276
|
+
})
|
|
277
|
+
return {
|
|
278
|
+
schema: OBJECT_INDEX_SCHEMA, ...derived.identity, sequence: derived.sequence, headDigest: digests.at(-1) ?? null,
|
|
279
|
+
state: derived.state, sourceDigest: derived.sourceDigest, operations, scopes: [...scopes].sort(),
|
|
280
|
+
leaseHeld: derived.lease !== null, intentOutcomeUnknown: derived.intent !== null,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function writeIndex(log) {
|
|
285
|
+
if (log.events.length === 0) return
|
|
286
|
+
const [repo] = segmentsOf(log.derived.identity)
|
|
287
|
+
privateDir(OBJECT_INDEX_DIRECTORY, repo)
|
|
288
|
+
const text = canonicalJson(indexDocument(log))
|
|
289
|
+
const file = indexFile(log.derived.identity)
|
|
290
|
+
try { if (readBytesNoFollow(file).toString('utf8') === text) return } catch { /* absent or unreadable: written below */ }
|
|
291
|
+
atomicReplacePrivateText(file, text)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function settle(identity) {
|
|
295
|
+
declare(identity)
|
|
296
|
+
const log = readLog(identity)
|
|
297
|
+
// The index is a cache: when it cannot be written, every reader derives
|
|
298
|
+
// what it needs from the events, and says so here.
|
|
299
|
+
let indexed = true
|
|
300
|
+
try { writeIndex(log) } catch { indexed = false }
|
|
301
|
+
crash('index-updated', { type: null, sequence: log.events.length, ...pointerOf(identity) })
|
|
302
|
+
return { log, indexed }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readIndex(identity) {
|
|
306
|
+
try {
|
|
307
|
+
const document = JSON.parse(readBytesNoFollow(indexFile(identity)).toString('utf8'))
|
|
308
|
+
return document?.schema === OBJECT_INDEX_SCHEMA && document.workspaceId === workspaceId && document.repoId === identity.repoId && document.nodeId === identity.nodeId && Array.isArray(document.operations) && Array.isArray(document.scopes) ? document : null
|
|
309
|
+
} catch { return null }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---- objects on disk -------------------------------------------------
|
|
313
|
+
|
|
314
|
+
const directoriesOf = (directory) => {
|
|
315
|
+
let entries
|
|
316
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return { names: [], foreign: 0 }; throw error }
|
|
317
|
+
const names = entries.filter((entry) => entry.isDirectory() && isIdentitySegment(entry.name)).map((entry) => entry.name).sort()
|
|
318
|
+
return { names, foreign: entries.length - names.length }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The identity of the object in <repo>/<node>: decoded from the names, or
|
|
322
|
+
// read from the first event when a name is hashed.
|
|
323
|
+
function identityAt(repo, node) {
|
|
324
|
+
let repoId = decodeIdentitySegment(repo)
|
|
325
|
+
let nodeId = decodeIdentitySegment(node)
|
|
326
|
+
if (repoId === null || nodeId === null) {
|
|
327
|
+
try {
|
|
328
|
+
const first = JSON.parse(readBytesNoFollow(path.join(objectsRoot, repo, node, eventName(1))).toString('utf8'))
|
|
329
|
+
if (isObjectEvent(first)) { repoId = first.repoId; nodeId = first.nodeId }
|
|
330
|
+
} catch { /* reported below */ }
|
|
331
|
+
}
|
|
332
|
+
if (!isIdentifier(repoId) || !isIdentifier(nodeId) || encodeIdentitySegment(repoId) !== repo || encodeIdentitySegment(nodeId) !== node) return null
|
|
333
|
+
return { workspaceId, repoId, nodeId }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function walkObjects(visit) {
|
|
337
|
+
let foreign = 0
|
|
338
|
+
const repos = directoriesOf(objectsRoot)
|
|
339
|
+
foreign += repos.foreign
|
|
340
|
+
for (const repo of repos.names) {
|
|
341
|
+
const nodes = directoriesOf(path.join(objectsRoot, repo))
|
|
342
|
+
foreign += nodes.foreign
|
|
343
|
+
for (const node of nodes.names) visit(identityAt(repo, node), path.join(objectsRoot, repo, node))
|
|
344
|
+
}
|
|
345
|
+
return foreign
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---- the lease --------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
function leaseOf(lease) {
|
|
351
|
+
if (lease === null || typeof lease !== 'object' || typeof lease.nonce !== 'string') refuseArbitration('lease-required', 'this is recorded only under the lease of the object')
|
|
352
|
+
return { identity: identityOf(lease), nonce: lease.nonce }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function underLease(lease, type, body) {
|
|
356
|
+
const { identity, nonce } = leaseOf(lease)
|
|
357
|
+
const { event } = append(identity, () => ({ type, body: { nonce, ...body } }), { settling: true })
|
|
358
|
+
const { log, indexed } = settle(identity)
|
|
359
|
+
return { sequence: event.sequence, state: log.derived, indexed }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
workspaceId,
|
|
364
|
+
stateRoot: root,
|
|
365
|
+
|
|
366
|
+
// Records one edit operation document. Returns { acknowledgement,
|
|
367
|
+
// appended, operation, object }. The acknowledgement depends only on the
|
|
368
|
+
// event that recorded this origin, so offering the same document again
|
|
369
|
+
// (a lost acknowledgement, a second tick, another process) appends
|
|
370
|
+
// nothing and answers the same.
|
|
371
|
+
observe(operation, { presentSourceDigest, resolves = [] } = {}) {
|
|
372
|
+
if (validateObsidianContract('edit-operation', operation).length > 0) refuseArbitration('invalid-operation', 'the document is not an edit operation')
|
|
373
|
+
const identity = identityOf(operation)
|
|
374
|
+
const recorded = recordableOperation(operation)
|
|
375
|
+
const expectedKey = editIdempotencyKey({ ...identity, baseSourceDigest: operation.baseSourceDigest, observedDigest: operation.observed.digest })
|
|
376
|
+
if (recorded === null || operation.idempotencyKey !== expectedKey) refuseArbitration('invalid-operation', 'the edit operation cannot be recorded as it is', { editId: operation.editId })
|
|
377
|
+
if (recorded.state === 'pending' && (recorded.kind !== 'body-replacement' || recorded.ext?.[OBSIDIAN_EXT_KEY]?.newSourceDigest === undefined)) {
|
|
378
|
+
refuseArbitration('invalid-operation', 'a pending operation is a body replacement that names its result', { editId: operation.editId })
|
|
379
|
+
}
|
|
380
|
+
const present = presentSourceDigest === undefined ? (recorded.ext?.[OBSIDIAN_EXT_KEY]?.currentSourceDigest ?? null) : presentSourceDigest
|
|
381
|
+
const body = { operation: recorded, presentSourceDigest: present, resolves: [...new Set(resolves)].sort() }
|
|
382
|
+
const origin = { scopeId: recorded.origin.scopeId, generationId: recorded.origin.generationId, editId: recorded.editId, resolves: body.resolves }
|
|
383
|
+
const recordedAt = (derived) => derived.operations.find((entry) => entry.idempotencyKey === recorded.idempotencyKey)?.origins.find((known) => sameOrigin(known, origin))?.sequence
|
|
384
|
+
const result = append(identity, (derived) => {
|
|
385
|
+
const sequence = recordedAt(derived)
|
|
386
|
+
if (sequence !== undefined) return { done: sequence }
|
|
387
|
+
return { type: derived.operations.some((entry) => entry.idempotencyKey === recorded.idempotencyKey) ? 'coalesced' : 'observed', body }
|
|
388
|
+
})
|
|
389
|
+
const { log, indexed } = settle(identity)
|
|
390
|
+
const sequence = result.appended ? result.event.sequence : result.value
|
|
391
|
+
return {
|
|
392
|
+
acknowledgement: { schema: EDIT_ACKNOWLEDGEMENT_SCHEMA, ...identity, idempotencyKey: recorded.idempotencyKey, editId: recorded.editId, sequence, eventDigest: log.digests[sequence - 1] },
|
|
393
|
+
appended: result.appended,
|
|
394
|
+
operation: log.derived.operations.find((entry) => entry.idempotencyKey === recorded.idempotencyKey) ?? null,
|
|
395
|
+
object: log.derived,
|
|
396
|
+
indexed,
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
stateOf(identity) {
|
|
401
|
+
return readLog(identityOf(identity)).derived
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
// Every object on disk, from the index where it is current and from the
|
|
405
|
+
// events where it is not. An object that cannot be read is listed as
|
|
406
|
+
// such and hides nothing else. { objects, foreign }
|
|
407
|
+
list({ state, scopeId } = {}) {
|
|
408
|
+
const objects = []
|
|
409
|
+
const foreign = walkObjects((identity, directory) => {
|
|
410
|
+
if (identity === null) { objects.push({ repoId: null, nodeId: null, state: 'unreadable', code: 'object-identity-unknown' }); return }
|
|
411
|
+
try {
|
|
412
|
+
const eventCount = listEventNames(directory, identity)
|
|
413
|
+
if (eventCount === 0) return
|
|
414
|
+
let index = readIndex(identity)
|
|
415
|
+
const headDigest = index === null ? null : sha256Digest(readBytesNoFollow(path.join(directory, eventName(eventCount))))
|
|
416
|
+
if (index === null || !indexIsCurrent({ index, eventCount, headDigest })) {
|
|
417
|
+
const log = readLog(identity)
|
|
418
|
+
index = indexDocument(log)
|
|
419
|
+
try { writeIndex(log) } catch { /* still a cache */ }
|
|
420
|
+
}
|
|
421
|
+
objects.push({ repoId: index.repoId, nodeId: index.nodeId, state: index.state, sequence: index.sequence, sourceDigest: index.sourceDigest, scopes: index.scopes, operations: index.operations, leaseHeld: index.leaseHeld, intentOutcomeUnknown: index.intentOutcomeUnknown })
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (!(error instanceof EditArbitrationRefusal)) throw error
|
|
424
|
+
objects.push({ ...pointerOf(identity), state: 'unreadable', code: error.code })
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
return {
|
|
428
|
+
objects: objects.filter((item) => (state === undefined || item.state === state) && (scopeId === undefined || item.scopes?.includes(scopeId))),
|
|
429
|
+
foreign,
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
// { acquired: true, lease } or { acquired: false, code: 'lease-held',
|
|
434
|
+
// reason, needsPerson, holder }. Never waits.
|
|
435
|
+
async acquireLease(identityInput, { pid = process.pid, runtimeId = null } = {}) {
|
|
436
|
+
const identity = identityOf(identityInput)
|
|
437
|
+
for (let attempt = 0; attempt < limits.maxAppendAttempts; attempt += 1) {
|
|
438
|
+
const before = readLog(identity).derived
|
|
439
|
+
let takeover = null
|
|
440
|
+
if (before.lease !== null) {
|
|
441
|
+
const held = before.lease
|
|
442
|
+
const proof = await proveAbandoned({ machine: held.machine, pid: held.pid, service: null, acquiredAt: held.acquiredAt }, { nowMs: Date.parse(isoTime(clock)) })
|
|
443
|
+
if (!proof.abandoned) {
|
|
444
|
+
return { acquired: false, code: 'lease-held', reason: proof.reason, needsPerson: NEEDS_PERSON.has(proof.reason), holder: { pid: held.pid, runtimeId: held.runtimeId, acquiredAt: held.acquiredAt, sequence: held.sequence } }
|
|
445
|
+
}
|
|
446
|
+
takeover = { nonce: held.nonce, reason: proof.reason }
|
|
447
|
+
}
|
|
448
|
+
const nonce = randomBytes(16).toString('hex')
|
|
449
|
+
let lost = false
|
|
450
|
+
const { event } = append(identity, (derived) => {
|
|
451
|
+
// The holder changed while it was being examined: examine again.
|
|
452
|
+
if ((derived.lease?.nonce ?? null) !== (takeover?.nonce ?? null)) { lost = true; return { done: null } }
|
|
453
|
+
return { type: 'lease-acquired', body: { lease: { nonce, pid, machine: machineDigest(), runtimeId, acquiredAt: isoTime(clock) }, takeover } }
|
|
454
|
+
})
|
|
455
|
+
if (lost) continue
|
|
456
|
+
const { log } = settle(identity)
|
|
457
|
+
return { acquired: true, lease: { ...identity, nonce, sequence: event.sequence }, takeover, object: log.derived }
|
|
458
|
+
}
|
|
459
|
+
return refuseArbitration('append-contention', 'the lease of the object changed hands on every attempt', pointerOf(identity))
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
// false when the lease is no longer this one (it was taken over after a
|
|
463
|
+
// proof that this holder was gone).
|
|
464
|
+
releaseLease(lease) {
|
|
465
|
+
const { identity, nonce } = leaseOf(lease)
|
|
466
|
+
const { appended } = append(identity, (derived) => (derived.lease?.nonce === nonce ? { type: 'lease-released', body: { nonce } } : { done: false }), { settling: true })
|
|
467
|
+
settle(identity)
|
|
468
|
+
return appended
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
// Write-ahead: recorded before the source is touched.
|
|
472
|
+
recordIntent(lease, { idempotencyKey, expectedSourceDigest, newSourceDigest, actor, policy, applyId }) {
|
|
473
|
+
return underLease(lease, 'apply-intent', { idempotencyKey, expectedSourceDigest, newSourceDigest, actor, policy, ...optional({ applyId }) })
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
// The source was written: old and new digests, who, and under what.
|
|
477
|
+
recordApplied(lease, { idempotencyKey, oldSourceDigest, newSourceDigest, actor, policy, applyId, backupRef }) {
|
|
478
|
+
return underLease(lease, 'applied', { idempotencyKey, oldSourceDigest, newSourceDigest, actor, policy, ...optional({ applyId, backupRef }) })
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
// The source was not written. `presentSourceDigest` is what the source
|
|
482
|
+
// was read to be, when it was read. `disposition` is what becomes of a
|
|
483
|
+
// pending operation: 'retained' (try again later), 'conflicted', 'refused'.
|
|
484
|
+
recordRefused(lease, { idempotencyKey, code, presentSourceDigest = null, disposition = 'retained', applyId, policy, recoveryRefs }) {
|
|
485
|
+
if (!REFUSED_DISPOSITIONS.includes(disposition)) refuseArbitration('invalid-event', 'the disposition is not one this store records', { type: 'apply-refused' })
|
|
486
|
+
return underLease(lease, 'apply-refused', { idempotencyKey, code, presentSourceDigest, disposition, ...optional({ applyId, policy, recoveryRefs }) })
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
// Reads every event of the object again, from the files, writes down
|
|
490
|
+
// whatever declaration a crash left unwritten, and rebuilds its index.
|
|
491
|
+
recoverObject(identityInput) {
|
|
492
|
+
const identity = identityOf(identityInput)
|
|
493
|
+
readLog(identity, { fresh: true })
|
|
494
|
+
const { log, indexed } = settle(identity)
|
|
495
|
+
return { ...log.derived, indexed }
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
// Deletes every index and derives it again from the events. Equal events
|
|
499
|
+
// give equal bytes.
|
|
500
|
+
rebuildIndexes() {
|
|
501
|
+
fs.rmSync(indexRoot, { recursive: true, force: true })
|
|
502
|
+
privateDir(OBJECT_INDEX_DIRECTORY)
|
|
503
|
+
const result = { rebuilt: 0, unreadable: [], foreign: 0 }
|
|
504
|
+
result.foreign = walkObjects((identity) => {
|
|
505
|
+
if (identity === null) { result.unreadable.push({ repoId: null, nodeId: null, code: 'object-identity-unknown' }); return }
|
|
506
|
+
try { writeIndex(readLog(identity, { fresh: true })); result.rebuilt += 1 } catch (error) {
|
|
507
|
+
if (!(error instanceof EditArbitrationRefusal)) throw error
|
|
508
|
+
result.unreadable.push({ ...pointerOf(identity), code: error.code })
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
return result
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export const openObjectStore = createObjectStoreForOracleTests()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { OBSIDIAN_EXT_KEY, assertObsidianContract } from '../contracts.mjs'
|
|
3
|
+
import { sha256Digest } from '../materialize/byte-lens.mjs'
|
|
4
|
+
import { applyEditLens } from './regions.mjs'
|
|
5
|
+
|
|
6
|
+
// Observation: one pending edit record of the maintenance engine becomes one
|
|
7
|
+
// edit operation document.
|
|
8
|
+
//
|
|
9
|
+
// The order is fixed. First the immutable bytes: the edited note must already
|
|
10
|
+
// be retained in the recovery object store under its digest (the engine put it
|
|
11
|
+
// there before it queued the record), and the published note and the base
|
|
12
|
+
// source the edit is judged against are retained beside it. Only then is the
|
|
13
|
+
// edit classified. A crash between the two leaves every byte needed to
|
|
14
|
+
// classify again and no half-made operation. The live note in the vault is
|
|
15
|
+
// never read here: bytes that were not preserved cannot be replaced by
|
|
16
|
+
// whatever the note holds now.
|
|
17
|
+
//
|
|
18
|
+
// Nothing here writes to a source file or to a vault, and the document holds
|
|
19
|
+
// identities, digests, byte offsets, codes and store references only.
|
|
20
|
+
|
|
21
|
+
const EXT = OBSIDIAN_EXT_KEY
|
|
22
|
+
export const EDIT_LENS_VERSION = '1.0.0'
|
|
23
|
+
|
|
24
|
+
// The same edited bytes over the same base source of the same object are the
|
|
25
|
+
// same operation, from whichever view or generation they were observed.
|
|
26
|
+
export function editIdempotencyKey({ workspaceId, repoId, nodeId, baseSourceDigest, observedDigest }) {
|
|
27
|
+
return `op-${createHash('sha256').update([workspaceId, repoId, nodeId, baseSourceDigest, observedDigest].join('\u0000')).digest('hex')}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// How a lens outcome is carried by the closed operation contract. A change to
|
|
31
|
+
// front matter or to the link structure is content for a proposal, which a
|
|
32
|
+
// later step builds from the preserved bytes; a base that moved is a conflict;
|
|
33
|
+
// everything else the lens refuses is refused.
|
|
34
|
+
function classify(outcome) {
|
|
35
|
+
if (outcome.kind === 'body-replacement') return { kind: 'body-replacement', state: 'pending' }
|
|
36
|
+
if (outcome.code === 'unsupported-structural-edit' || outcome.code === 'unsupported-frontmatter-edit') return { kind: 'semantic-proposal', state: 'proposed' }
|
|
37
|
+
if (outcome.code === 'stale-base') return { kind: 'body-replacement', state: 'conflicted' }
|
|
38
|
+
return { kind: 'body-replacement', state: 'refused' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function refusal(code, detail = {}) {
|
|
42
|
+
return { kind: 'refusal', code, detail }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readRetained(store, digest) {
|
|
46
|
+
try {
|
|
47
|
+
const bytes = store.readObject(digest)
|
|
48
|
+
return sha256Digest(bytes) === digest ? bytes : null
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code === 'ENOENT' || error?.code === 'recovery-object-corrupt') return null
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const EDIT_OBSERVATION_STEPS = Object.freeze({
|
|
56
|
+
// Returns { edited, publishedRef, baseRef }; throws nothing for missing bytes.
|
|
57
|
+
preserve({ store, edit, publishedNoteBytes, baseSourceBytes }) {
|
|
58
|
+
const edited = readRetained(store, edit.observedDigest)
|
|
59
|
+
if (edited === null) return { edited: null }
|
|
60
|
+
return { edited, publishedRef: store.retainObject(publishedNoteBytes).ref, baseRef: store.retainObject(baseSourceBytes).ref }
|
|
61
|
+
},
|
|
62
|
+
classify: (input) => applyEditLens(input),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// `edit` is a pending edit record; `store` is the recovery store of its view;
|
|
66
|
+
// `manifest` is the generation manifest the record names. `publishedNoteBytes`
|
|
67
|
+
// is the publisher's retained baseline of the note and `baseSourceBytes` the
|
|
68
|
+
// source as it is now. `afterPreserved` is a seam between the two halves.
|
|
69
|
+
//
|
|
70
|
+
// Returns { operation, outcome }: the validated document and the lens result
|
|
71
|
+
// (the new source bytes stay in memory; this phase applies nothing).
|
|
72
|
+
export function createEditObserverForOracleTests(steps = EDIT_OBSERVATION_STEPS) {
|
|
73
|
+
return function observeEdit({ edit, store, manifest, publishedNoteBytes, baseSourceBytes, afterPreserved = () => {} }) {
|
|
74
|
+
for (const bytes of [publishedNoteBytes, baseSourceBytes]) if (!Buffer.isBuffer(bytes)) throw new TypeError('observation reads Buffers only')
|
|
75
|
+
const { workspaceId, repoId, nodeId } = edit.identity
|
|
76
|
+
const noteEntry = manifest.generationId === edit.generationId && manifest.scopeId === edit.scopeId
|
|
77
|
+
? manifest.notes.find((note) => note.repoId === repoId && note.nodeId === nodeId && note.path === edit.path)
|
|
78
|
+
: undefined
|
|
79
|
+
|
|
80
|
+
const preserved = steps.preserve({ store, edit, publishedNoteBytes, baseSourceBytes })
|
|
81
|
+
afterPreserved()
|
|
82
|
+
let outcome
|
|
83
|
+
if (!preserved.edited) outcome = refusal('edit-bytes-missing', { observedDigest: edit.observedDigest })
|
|
84
|
+
else if (!noteEntry) outcome = refusal('unknown-note')
|
|
85
|
+
else outcome = steps.classify({ manifest, repoId, nodeId, publishedNoteBytes, editedNoteBytes: preserved.edited, baseSourceBytes })
|
|
86
|
+
|
|
87
|
+
const baseSourceDigest = noteEntry?.ext?.[EXT]?.source?.rawDigest ?? sha256Digest(baseSourceBytes)
|
|
88
|
+
const { kind, state } = classify(outcome)
|
|
89
|
+
const operation = {
|
|
90
|
+
schema: 'atelier-obsidian-edit-operation/v1',
|
|
91
|
+
contractVersion: '1.0.0',
|
|
92
|
+
editId: edit.editId,
|
|
93
|
+
workspaceId,
|
|
94
|
+
repoId,
|
|
95
|
+
nodeId,
|
|
96
|
+
origin: {
|
|
97
|
+
scopeId: edit.scopeId,
|
|
98
|
+
generationId: edit.generationId,
|
|
99
|
+
ext: { [EXT]: { notePath: edit.path, baseNoteDigest: edit.baseNoteDigest, publishedNoteDigest: noteEntry?.noteDigest ?? null } },
|
|
100
|
+
},
|
|
101
|
+
kind,
|
|
102
|
+
baseSourceDigest,
|
|
103
|
+
observed: { digest: edit.observedDigest, byteLength: preserved.edited ? preserved.edited.length : 0, recoveryRef: edit.objectRef },
|
|
104
|
+
idempotencyKey: editIdempotencyKey({ workspaceId, repoId, nodeId, baseSourceDigest, observedDigest: edit.observedDigest }),
|
|
105
|
+
state,
|
|
106
|
+
observedAt: edit.observedAt,
|
|
107
|
+
ext: {
|
|
108
|
+
[EXT]: {
|
|
109
|
+
lensVersion: EDIT_LENS_VERSION,
|
|
110
|
+
...(preserved.edited ? { publishedNoteRef: preserved.publishedRef, baseSourceRef: preserved.baseRef } : {}),
|
|
111
|
+
currentSourceDigest: sha256Digest(baseSourceBytes),
|
|
112
|
+
...(outcome.kind === 'body-replacement'
|
|
113
|
+
? { result: { newSourceDigest: outcome.newSourceDigest, newByteLength: outcome.newSourceBytes.length, changedRanges: outcome.changedRanges, unchanged: outcome.unchanged, generated: outcome.generated } }
|
|
114
|
+
: { refusal: { code: outcome.code, detail: outcome.detail } }),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
assertObsidianContract('edit-operation', operation)
|
|
119
|
+
return { operation, outcome }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const observeEdit = createEditObserverForOracleTests()
|