@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,414 @@
|
|
|
1
|
+
import { canonicalJson } from '../../../runtime/obsidian/documents.mjs'
|
|
2
|
+
import { OBSIDIAN_EXT_KEY } from '../contracts.mjs'
|
|
3
|
+
import { isIdentifier } from './object-identity.mjs'
|
|
4
|
+
|
|
5
|
+
// Arbitration of the edits of one object, as a pure function of its event
|
|
6
|
+
// list. Nothing here reads a clock, a file or a process table: equal events
|
|
7
|
+
// give an equal result, which is what makes every index a cache.
|
|
8
|
+
//
|
|
9
|
+
// The rules, in the order they are applied to a new body replacement:
|
|
10
|
+
//
|
|
11
|
+
// 1. Equal idempotency key (equal base and equal edited bytes): one logical
|
|
12
|
+
// operation; every origin is recorded.
|
|
13
|
+
// 2. A base that is not the source digest the object is known to have now
|
|
14
|
+
// is stale. Its result already being the present source makes it
|
|
15
|
+
// `superseded` (nothing is left to apply); otherwise it is `conflicted`.
|
|
16
|
+
// The present digest is the last fact recorded under the object lease
|
|
17
|
+
// (`applied`, or a refusal that read the source); before any such fact it
|
|
18
|
+
// is what the observer said it read.
|
|
19
|
+
// 3. An operation that names the operations it resolves, whose base is the
|
|
20
|
+
// present digest and which names every conflicted operation, supersedes
|
|
21
|
+
// the ones it names and is pending. A resolution that cannot be checked
|
|
22
|
+
// against a present digest, or that leaves a conflict unnamed, is itself
|
|
23
|
+
// conflicted. Rule 2 is applied first and is never overridden.
|
|
24
|
+
// 4. While any operation is conflicted, a new one joins the conflict.
|
|
25
|
+
// 5. Open operations that would not produce the same source from the same
|
|
26
|
+
// base are all conflicted, whatever order, view or process they came
|
|
27
|
+
// from. Nothing is dropped and no winner is chosen.
|
|
28
|
+
//
|
|
29
|
+
// Operations that arrive as `proposed` or `refused` are recorded and take no
|
|
30
|
+
// part; one that arrives `conflicted` (the lens found its base stale) is
|
|
31
|
+
// retained as a conflict.
|
|
32
|
+
|
|
33
|
+
const EXT = OBSIDIAN_EXT_KEY
|
|
34
|
+
export const OBJECT_EVENT_SCHEMA = 'atelier-obsidian-object-event/v1'
|
|
35
|
+
export const OBJECT_EVENT_TYPES = Object.freeze(['observed', 'coalesced', 'conflict-declared', 'superseded', 'lease-acquired', 'lease-released', 'apply-intent', 'applied', 'apply-refused', 'siblings-stale'])
|
|
36
|
+
export const ARBITRATION_REASONS = Object.freeze(['stale-base', 'divergent-edits', 'divergent-bases', 'object-conflicted', 'resolution-incomplete', 'resolution-unverifiable', 'already-applied', 'resolved'])
|
|
37
|
+
export const REFUSED_DISPOSITIONS = Object.freeze(['retained', 'conflicted', 'refused'])
|
|
38
|
+
|
|
39
|
+
export class EditArbitrationRefusal extends Error {
|
|
40
|
+
constructor(code, message, detail = {}) {
|
|
41
|
+
super(`${code}: ${message}`)
|
|
42
|
+
this.name = 'EditArbitrationRefusal'
|
|
43
|
+
this.code = code
|
|
44
|
+
this.detail = detail
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function refuseArbitration(code, message, detail) {
|
|
49
|
+
throw new EditArbitrationRefusal(code, message, detail)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Documents. Every shape is closed and every string is an identifier, a
|
|
54
|
+
// digest, a store reference, a code or a timestamp.
|
|
55
|
+
|
|
56
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/
|
|
57
|
+
const KEY = /^op-[0-9a-f]{64}$/
|
|
58
|
+
const NONCE = /^[0-9a-f]{32}$/
|
|
59
|
+
const MACHINE = /^[0-9a-f]{64}$/
|
|
60
|
+
const CODE = /^[a-z][a-z0-9-]{0,63}$/
|
|
61
|
+
const REFERENCE = /^recovery\/objects\/[0-9a-f]{64}\.bin$/
|
|
62
|
+
// A file of one source apply: the displaced source, or bytes found where they were not expected.
|
|
63
|
+
const APPLY_REFERENCE = /^recovery\/[A-Za-z0-9._-]{1,128}\/\d{6}\/[A-Za-z0-9._-]{1,64}\.bin$/
|
|
64
|
+
const TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/
|
|
65
|
+
const VERSION = /^\d+\.\d+\.\d+$/
|
|
66
|
+
|
|
67
|
+
const is = {
|
|
68
|
+
digest: (value) => typeof value === 'string' && DIGEST.test(value),
|
|
69
|
+
key: (value) => typeof value === 'string' && KEY.test(value),
|
|
70
|
+
nonce: (value) => typeof value === 'string' && NONCE.test(value),
|
|
71
|
+
machine: (value) => typeof value === 'string' && MACHINE.test(value),
|
|
72
|
+
code: (value) => typeof value === 'string' && CODE.test(value),
|
|
73
|
+
reference: (value) => typeof value === 'string' && REFERENCE.test(value),
|
|
74
|
+
retained: (value) => typeof value === 'string' && (REFERENCE.test(value) || APPLY_REFERENCE.test(value)),
|
|
75
|
+
time: (value) => typeof value === 'string' && TIME.test(value) && !Number.isNaN(Date.parse(value)),
|
|
76
|
+
version: (value) => typeof value === 'string' && VERSION.test(value),
|
|
77
|
+
identifier: isIdentifier,
|
|
78
|
+
count: (value) => Number.isSafeInteger(value) && value >= 0,
|
|
79
|
+
positive: (value) => Number.isSafeInteger(value) && value > 0,
|
|
80
|
+
}
|
|
81
|
+
const nullable = (check) => (value) => value === null || check(value)
|
|
82
|
+
const oneOf = (values) => (value) => values.includes(value)
|
|
83
|
+
const listOf = (check, { max = 4096, sortedBy = null } = {}) => (value) => Array.isArray(value) && value.length <= max && value.every(check)
|
|
84
|
+
&& (sortedBy === null || value.every((item, index) => index === 0 || sortedBy(value[index - 1]) < sortedBy(item)))
|
|
85
|
+
const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
86
|
+
// A closed object: exactly the required keys plus any of the optional ones.
|
|
87
|
+
const shape = (required, optional = {}) => (value) => plain(value)
|
|
88
|
+
&& Object.keys(value).every((key) => Object.hasOwn(required, key) || Object.hasOwn(optional, key))
|
|
89
|
+
&& Object.entries(required).every(([key, check]) => Object.hasOwn(value, key) && check(value[key]))
|
|
90
|
+
&& Object.entries(optional).every(([key, check]) => !Object.hasOwn(value, key) || check(value[key]))
|
|
91
|
+
const extOf = (check) => shape({ [EXT]: check })
|
|
92
|
+
|
|
93
|
+
const policy = (value) => shape({ mode: oneOf(['manual']) })(value) || shape({ mode: oneOf(['automatic']), policyDigest: is.digest }, { policyId: is.identifier })(value)
|
|
94
|
+
|
|
95
|
+
// The recorded form of an edit operation: the frozen contract's required
|
|
96
|
+
// members and, under the extension key, digests and references only. The
|
|
97
|
+
// vault-relative note path of the original document is readable text and is
|
|
98
|
+
// not recorded; the note is found again from the origin and the identity.
|
|
99
|
+
const recordedOperation = shape({
|
|
100
|
+
schema: oneOf(['atelier-obsidian-edit-operation/v1']),
|
|
101
|
+
editId: is.identifier,
|
|
102
|
+
workspaceId: is.identifier,
|
|
103
|
+
repoId: is.identifier,
|
|
104
|
+
nodeId: is.identifier,
|
|
105
|
+
origin: shape({ scopeId: is.identifier, generationId: is.identifier }, { ext: extOf(shape({}, { baseNoteDigest: is.digest, publishedNoteDigest: nullable(is.digest) })) }),
|
|
106
|
+
kind: oneOf(['body-replacement', 'semantic-proposal']),
|
|
107
|
+
baseSourceDigest: is.digest,
|
|
108
|
+
observed: shape({ digest: is.digest, byteLength: is.count, recoveryRef: is.reference }),
|
|
109
|
+
idempotencyKey: is.key,
|
|
110
|
+
state: oneOf(['pending', 'proposed', 'conflicted', 'refused']),
|
|
111
|
+
}, {
|
|
112
|
+
contractVersion: is.version,
|
|
113
|
+
observedAt: is.time,
|
|
114
|
+
ext: extOf(shape({}, { lensVersion: is.version, publishedNoteRef: is.reference, baseSourceRef: is.reference, currentSourceDigest: is.digest, newSourceDigest: is.digest, newByteLength: is.count, refusalCode: is.code })),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const observation = shape({ operation: recordedOperation, presentSourceDigest: nullable(is.digest), resolves: listOf(is.key, { max: 256, sortedBy: (key) => key }) })
|
|
118
|
+
// A reason is one of ARBITRATION_REASONS or the code of an apply refusal.
|
|
119
|
+
const declared = () => listOf(shape({ idempotencyKey: is.key, reason: is.code }, { by: nullable(is.key) }), { sortedBy: (item) => item.idempotencyKey })
|
|
120
|
+
const viewKey = (view) => `${view.scopeId}\u0000${view.generationId}`
|
|
121
|
+
|
|
122
|
+
const BODIES = {
|
|
123
|
+
observed: observation,
|
|
124
|
+
coalesced: observation,
|
|
125
|
+
'conflict-declared': shape({ operations: declared() }),
|
|
126
|
+
superseded: shape({ operations: declared() }),
|
|
127
|
+
'lease-acquired': shape({
|
|
128
|
+
lease: shape({ nonce: is.nonce, pid: is.positive, machine: is.machine, runtimeId: nullable(is.identifier), acquiredAt: is.time }),
|
|
129
|
+
takeover: nullable(shape({ nonce: is.nonce, reason: is.code })),
|
|
130
|
+
}),
|
|
131
|
+
'lease-released': shape({ nonce: is.nonce }),
|
|
132
|
+
// `applyId` names the write-ahead record and the recovery directory of one source apply; `backupRef` the displaced
|
|
133
|
+
// source it retained; `recoveryRefs` every file or object a refused apply retained.
|
|
134
|
+
'apply-intent': shape({ nonce: is.nonce, idempotencyKey: is.key, expectedSourceDigest: is.digest, newSourceDigest: is.digest, actor: is.identifier, policy }, { applyId: is.identifier }),
|
|
135
|
+
applied: shape({ nonce: is.nonce, idempotencyKey: is.key, oldSourceDigest: is.digest, newSourceDigest: is.digest, actor: is.identifier, policy }, { applyId: is.identifier, backupRef: is.retained }),
|
|
136
|
+
'apply-refused': shape({ nonce: is.nonce, idempotencyKey: is.key, code: is.code, presentSourceDigest: nullable(is.digest), disposition: oneOf(REFUSED_DISPOSITIONS) },
|
|
137
|
+
{ applyId: is.identifier, policy, recoveryRefs: listOf(is.retained, { max: 16 }) }),
|
|
138
|
+
'siblings-stale': shape({
|
|
139
|
+
appliedSequence: is.positive,
|
|
140
|
+
newSourceDigest: is.digest,
|
|
141
|
+
views: listOf(shape({ scopeId: is.identifier, generationId: is.identifier, role: oneOf(['origin', 'sibling']) }), { sortedBy: viewKey }),
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const envelope = shape({
|
|
146
|
+
schema: oneOf([OBJECT_EVENT_SCHEMA]),
|
|
147
|
+
workspaceId: is.identifier,
|
|
148
|
+
repoId: is.identifier,
|
|
149
|
+
nodeId: is.identifier,
|
|
150
|
+
sequence: is.positive,
|
|
151
|
+
previous: nullable(is.digest),
|
|
152
|
+
type: oneOf(OBJECT_EVENT_TYPES),
|
|
153
|
+
recordedAt: is.time,
|
|
154
|
+
body: plain,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
export function isObjectEvent(document) {
|
|
158
|
+
if (!envelope(document) || !BODIES[document.type](document.body)) return false
|
|
159
|
+
if (document.type !== 'observed' && document.type !== 'coalesced') return true
|
|
160
|
+
const { operation } = document.body
|
|
161
|
+
return operation.workspaceId === document.workspaceId && operation.repoId === document.repoId && operation.nodeId === document.nodeId
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// What of an edit operation document is recorded. The caller has validated
|
|
165
|
+
// the document against the frozen contract.
|
|
166
|
+
export function recordableOperation(operation) {
|
|
167
|
+
const ext = operation.ext?.[EXT] ?? {}
|
|
168
|
+
const originExt = operation.origin.ext?.[EXT] ?? {}
|
|
169
|
+
const kept = {}
|
|
170
|
+
for (const name of ['lensVersion', 'publishedNoteRef', 'baseSourceRef', 'currentSourceDigest']) if (ext[name] !== undefined) kept[name] = ext[name]
|
|
171
|
+
if (ext.result?.newSourceDigest !== undefined) { kept.newSourceDigest = ext.result.newSourceDigest; kept.newByteLength = ext.result.newByteLength }
|
|
172
|
+
if (ext.refusal?.code !== undefined) kept.refusalCode = ext.refusal.code
|
|
173
|
+
const keptOrigin = {}
|
|
174
|
+
for (const name of ['baseNoteDigest', 'publishedNoteDigest']) if (originExt[name] !== undefined) keptOrigin[name] = originExt[name]
|
|
175
|
+
const recorded = {
|
|
176
|
+
schema: operation.schema,
|
|
177
|
+
...(operation.contractVersion === undefined ? {} : { contractVersion: operation.contractVersion }),
|
|
178
|
+
editId: operation.editId,
|
|
179
|
+
workspaceId: operation.workspaceId,
|
|
180
|
+
repoId: operation.repoId,
|
|
181
|
+
nodeId: operation.nodeId,
|
|
182
|
+
origin: { scopeId: operation.origin.scopeId, generationId: operation.origin.generationId, ...(Object.keys(keptOrigin).length > 0 ? { ext: { [EXT]: keptOrigin } } : {}) },
|
|
183
|
+
kind: operation.kind,
|
|
184
|
+
baseSourceDigest: operation.baseSourceDigest,
|
|
185
|
+
observed: { digest: operation.observed.digest, byteLength: operation.observed.byteLength, recoveryRef: operation.observed.recoveryRef },
|
|
186
|
+
idempotencyKey: operation.idempotencyKey,
|
|
187
|
+
state: operation.state,
|
|
188
|
+
...(operation.observedAt === undefined ? {} : { observedAt: operation.observedAt }),
|
|
189
|
+
...(Object.keys(kept).length > 0 ? { ext: { [EXT]: kept } } : {}),
|
|
190
|
+
}
|
|
191
|
+
return recordedOperation(recorded) ? recorded : null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// The rules that differ between a correct arbiter and the broken ones the
|
|
196
|
+
// tests substitute. Each returns what becomes of the operations involved.
|
|
197
|
+
|
|
198
|
+
export const ARBITRATION_RULES = Object.freeze({
|
|
199
|
+
// `incoming` would not produce the same source from the same base as `open`.
|
|
200
|
+
divergent: ({ incoming, open }) => {
|
|
201
|
+
const reason = open.every((other) => other.baseSourceDigest === incoming.baseSourceDigest) ? 'divergent-edits' : 'divergent-bases'
|
|
202
|
+
return [incoming, ...open].map((entry) => ({ entry, state: 'conflicted', reason }))
|
|
203
|
+
},
|
|
204
|
+
// The base of `incoming` is not the present source digest.
|
|
205
|
+
stale: ({ incoming }) => [{ entry: incoming, state: 'conflicted', reason: 'stale-base' }],
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const OPEN = new Set(['pending', 'conflicted'])
|
|
209
|
+
// Equal whatever order the keys were written in.
|
|
210
|
+
const sameDocument = (left, right) => canonicalJson(left) === canonicalJson(right)
|
|
211
|
+
const sameList = (left, right) => left.length === right.length && left.every((item, index) => item === right[index])
|
|
212
|
+
const equivalent = (left, right) => left.baseSourceDigest === right.baseSourceDigest && left.newSourceDigest !== null && left.newSourceDigest === right.newSourceDigest
|
|
213
|
+
|
|
214
|
+
function createState() {
|
|
215
|
+
return { identity: null, sequence: 0, operations: new Map(), sourceDigest: null, sourceFrom: null, hint: null, lastAppliedKey: null, lease: null, intent: null, applies: [], outcomes: [], declared: new Map(), violations: [] }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function assign(state, { entry, state: next, reason = null, by = null, drop = false }) {
|
|
219
|
+
// Only a broken arbiter of the tests drops anything.
|
|
220
|
+
if (drop) { state.operations.delete(entry.idempotencyKey); return }
|
|
221
|
+
if (entry.state !== next) state.declared.delete(entry.idempotencyKey)
|
|
222
|
+
Object.assign(entry, { state: next, reason, by })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function place(state, entry, resolves, rules) {
|
|
226
|
+
const present = state.sourceDigest ?? state.hint
|
|
227
|
+
const others = [...state.operations.values()].filter((other) => other !== entry)
|
|
228
|
+
if (present !== null && entry.baseSourceDigest !== present) {
|
|
229
|
+
if (entry.newSourceDigest === present) return assign(state, { entry, state: 'superseded', reason: 'already-applied', by: state.sourceFrom })
|
|
230
|
+
for (const outcome of rules.stale({ incoming: entry, present })) assign(state, outcome)
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
const conflicted = others.filter((other) => other.state === 'conflicted')
|
|
234
|
+
if (resolves.length > 0) {
|
|
235
|
+
if (present === null) return assign(state, { entry, state: 'conflicted', reason: 'resolution-unverifiable' })
|
|
236
|
+
const named = new Set(resolves)
|
|
237
|
+
if (named.has(entry.idempotencyKey) || resolves.some((key) => !state.operations.has(key)) || conflicted.some((other) => !named.has(other.idempotencyKey))) {
|
|
238
|
+
return assign(state, { entry, state: 'conflicted', reason: 'resolution-incomplete' })
|
|
239
|
+
}
|
|
240
|
+
for (const other of others) if (named.has(other.idempotencyKey) && OPEN.has(other.state)) assign(state, { entry: other, state: 'superseded', reason: 'resolved', by: entry.idempotencyKey })
|
|
241
|
+
} else if (conflicted.length > 0) {
|
|
242
|
+
return assign(state, { entry, state: 'conflicted', reason: 'object-conflicted' })
|
|
243
|
+
}
|
|
244
|
+
const open = others.filter((other) => other.state === 'pending' && !equivalent(other, entry))
|
|
245
|
+
if (open.length > 0) { for (const outcome of rules.divergent({ incoming: entry, open })) assign(state, outcome); return undefined }
|
|
246
|
+
return assign(state, { entry, state: 'pending' })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The source is now `digest`: what was open against another base is settled.
|
|
250
|
+
function sourceMoved(state, digest, byKey, rules) {
|
|
251
|
+
state.sourceDigest = digest
|
|
252
|
+
state.sourceFrom = byKey
|
|
253
|
+
for (const entry of state.operations.values()) {
|
|
254
|
+
if (!OPEN.has(entry.state) || entry.baseSourceDigest === digest) continue
|
|
255
|
+
if (entry.newSourceDigest === digest) assign(state, { entry, state: 'superseded', reason: 'already-applied', by: byKey })
|
|
256
|
+
else if (entry.state === 'pending') for (const outcome of rules.stale({ incoming: entry, present: digest })) assign(state, outcome)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const undeclared = (state, which) => [...state.operations.values()]
|
|
261
|
+
.filter((entry) => entry.state === which && state.declared.get(entry.idempotencyKey) !== which)
|
|
262
|
+
.map((entry) => ({ idempotencyKey: entry.idempotencyKey, reason: entry.reason, ...(which === 'superseded' ? { by: entry.by } : {}) }))
|
|
263
|
+
.sort((left, right) => (left.idempotencyKey < right.idempotencyKey ? -1 : 1))
|
|
264
|
+
|
|
265
|
+
function viewsOf(state, appliedKey) {
|
|
266
|
+
const views = new Map()
|
|
267
|
+
for (const entry of state.operations.values()) {
|
|
268
|
+
for (const origin of entry.origins) {
|
|
269
|
+
const view = { scopeId: origin.scopeId, generationId: origin.generationId, role: entry.idempotencyKey === appliedKey ? 'origin' : 'sibling' }
|
|
270
|
+
if (views.get(viewKey(view))?.role !== 'origin') views.set(viewKey(view), view)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return [...views.values()].sort((left, right) => (viewKey(left) < viewKey(right) ? -1 : 1))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const HANDLERS = {
|
|
277
|
+
observed: observe,
|
|
278
|
+
coalesced: observe,
|
|
279
|
+
'conflict-declared': (state, event) => declare(state, event, 'conflicted'),
|
|
280
|
+
superseded: (state, event) => declare(state, event, 'superseded'),
|
|
281
|
+
'lease-acquired'(state, { body, sequence }) {
|
|
282
|
+
if (state.lease === null ? body.takeover !== null : body.takeover?.nonce !== state.lease.nonce) return state.lease === null ? 'takeover-without-holder' : 'lease-held'
|
|
283
|
+
state.lease = { ...body.lease, sequence, takeover: body.takeover }
|
|
284
|
+
return null
|
|
285
|
+
},
|
|
286
|
+
'lease-released'(state, { body }) {
|
|
287
|
+
if (state.lease?.nonce !== body.nonce) return 'lease-not-held'
|
|
288
|
+
state.lease = null
|
|
289
|
+
return null
|
|
290
|
+
},
|
|
291
|
+
'apply-intent'(state, { body, sequence }) {
|
|
292
|
+
if (state.lease?.nonce !== body.nonce) return 'lease-not-held'
|
|
293
|
+
if (state.intent !== null) return 'intent-unresolved'
|
|
294
|
+
const entry = state.operations.get(body.idempotencyKey)
|
|
295
|
+
if (entry === undefined) return 'unknown-operation'
|
|
296
|
+
if ([...state.operations.values()].some((other) => other.state === 'conflicted')) return 'object-conflicted'
|
|
297
|
+
if (entry.state !== 'pending' || entry.kind !== 'body-replacement') return 'operation-not-applicable'
|
|
298
|
+
if (body.expectedSourceDigest !== entry.baseSourceDigest || body.newSourceDigest !== entry.newSourceDigest) return 'digest-mismatch'
|
|
299
|
+
state.intent = { ...body, sequence }
|
|
300
|
+
return null
|
|
301
|
+
},
|
|
302
|
+
applied(state, { body, sequence }, rules) {
|
|
303
|
+
if (state.lease?.nonce !== body.nonce) return 'lease-not-held'
|
|
304
|
+
if (state.intent === null || state.intent.idempotencyKey !== body.idempotencyKey) return 'intent-missing'
|
|
305
|
+
if (body.oldSourceDigest !== state.intent.expectedSourceDigest || body.newSourceDigest !== state.intent.newSourceDigest) return 'digest-mismatch'
|
|
306
|
+
const entry = state.operations.get(body.idempotencyKey)
|
|
307
|
+
state.intent = null
|
|
308
|
+
assign(state, { entry, state: 'applied' })
|
|
309
|
+
state.lastAppliedKey = body.idempotencyKey
|
|
310
|
+
sourceMoved(state, body.newSourceDigest, body.idempotencyKey, rules)
|
|
311
|
+
state.applies.push({ appliedSequence: sequence, newSourceDigest: body.newSourceDigest, views: viewsOf(state, body.idempotencyKey), declared: false })
|
|
312
|
+
state.outcomes.push({ sequence, idempotencyKey: body.idempotencyKey, status: 'applied', code: 'applied', oldSourceDigest: body.oldSourceDigest, newSourceDigest: body.newSourceDigest, actor: body.actor, policy: body.policy, applyId: body.applyId ?? null, backupRef: body.backupRef ?? null })
|
|
313
|
+
return null
|
|
314
|
+
},
|
|
315
|
+
'apply-refused'(state, { body, sequence }, rules) {
|
|
316
|
+
if (state.lease?.nonce !== body.nonce) return 'lease-not-held'
|
|
317
|
+
const entry = state.operations.get(body.idempotencyKey)
|
|
318
|
+
if (entry === undefined) return 'unknown-operation'
|
|
319
|
+
if (state.intent !== null && state.intent.idempotencyKey !== body.idempotencyKey) return 'intent-unresolved'
|
|
320
|
+
if (state.intent === null && !OPEN.has(entry.state)) return 'operation-not-applicable'
|
|
321
|
+
state.intent = null
|
|
322
|
+
state.outcomes.push({ sequence, idempotencyKey: body.idempotencyKey, status: 'refused', code: body.code, presentSourceDigest: body.presentSourceDigest, disposition: body.disposition, policy: body.policy ?? null, applyId: body.applyId ?? null, recoveryRefs: body.recoveryRefs ?? [] })
|
|
323
|
+
if (body.presentSourceDigest !== null) sourceMoved(state, body.presentSourceDigest, null, rules)
|
|
324
|
+
if (entry.state === 'pending' && body.disposition !== 'retained') assign(state, { entry, state: body.disposition, reason: body.code })
|
|
325
|
+
return null
|
|
326
|
+
},
|
|
327
|
+
'siblings-stale'(state, { body }) {
|
|
328
|
+
const next = state.applies.find((apply) => !apply.declared)
|
|
329
|
+
if (next === undefined || next.appliedSequence !== body.appliedSequence || next.newSourceDigest !== body.newSourceDigest || !sameDocument(next.views, body.views)) return 'declaration-mismatch'
|
|
330
|
+
next.declared = true
|
|
331
|
+
return null
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function observe(state, { type, body, sequence }, rules) {
|
|
336
|
+
const { operation, resolves } = body
|
|
337
|
+
const existing = state.operations.get(operation.idempotencyKey)
|
|
338
|
+
if ((type === 'observed') !== (existing === undefined)) return 'observation-kind-mismatch'
|
|
339
|
+
const origin = { scopeId: operation.origin.scopeId, generationId: operation.origin.generationId, editId: operation.editId, recoveryRef: operation.observed.recoveryRef, observedAt: operation.observedAt ?? null, sequence, resolves }
|
|
340
|
+
if (existing?.origins.some((known) => sameOrigin(known, origin))) return 'duplicate-origin'
|
|
341
|
+
const ext = operation.ext?.[EXT] ?? {}
|
|
342
|
+
if (operation.state === 'pending' && (operation.kind !== 'body-replacement' || ext.newSourceDigest === undefined)) return 'operation-unusable'
|
|
343
|
+
if (existing !== undefined && (existing.baseSourceDigest !== operation.baseSourceDigest || existing.observedDigest !== operation.observed.digest || existing.kind !== operation.kind)) return 'operation-identity-mismatch'
|
|
344
|
+
if (body.presentSourceDigest !== null) state.hint = body.presentSourceDigest
|
|
345
|
+
if (existing !== undefined) {
|
|
346
|
+
existing.origins.push(origin)
|
|
347
|
+
// A known operation offered again as the resolution of a conflict.
|
|
348
|
+
if (resolves.length > 0 && operation.state === 'pending' && OPEN.has(existing.state)) place(state, existing, resolves, rules)
|
|
349
|
+
return null
|
|
350
|
+
}
|
|
351
|
+
const entry = {
|
|
352
|
+
idempotencyKey: operation.idempotencyKey, kind: operation.kind, baseSourceDigest: operation.baseSourceDigest,
|
|
353
|
+
observedDigest: operation.observed.digest, observedByteLength: operation.observed.byteLength, newSourceDigest: ext.newSourceDigest ?? null,
|
|
354
|
+
state: operation.state, reason: null, by: null, firstSequence: sequence, origins: [origin],
|
|
355
|
+
}
|
|
356
|
+
state.operations.set(entry.idempotencyKey, entry)
|
|
357
|
+
if (operation.state === 'pending') place(state, entry, resolves, rules)
|
|
358
|
+
else if (operation.state === 'conflicted') entry.reason = 'stale-base'
|
|
359
|
+
else if (operation.state === 'refused') entry.reason = ext.refusalCode ?? null
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export const sameOrigin = (left, right) => left.scopeId === right.scopeId && left.generationId === right.generationId && left.editId === right.editId && sameList(left.resolves, right.resolves)
|
|
364
|
+
|
|
365
|
+
function declare(state, { body }, which) {
|
|
366
|
+
if (body.operations.length === 0 || !sameDocument(undeclared(state, which), body.operations)) return 'declaration-mismatch'
|
|
367
|
+
for (const item of body.operations) state.declared.set(item.idempotencyKey, which)
|
|
368
|
+
return null
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function summarize(state) {
|
|
372
|
+
const operations = [...state.operations.values()].sort((left, right) => left.firstSequence - right.firstSequence)
|
|
373
|
+
const objectState = state.violations.length > 0 ? 'inconsistent'
|
|
374
|
+
: operations.some((entry) => entry.state === 'conflicted') ? 'conflicted'
|
|
375
|
+
: operations.some((entry) => entry.state === 'pending') ? 'pending'
|
|
376
|
+
: operations.length === 0 ? 'empty' : 'settled'
|
|
377
|
+
const pendingApply = state.applies.find((apply) => !apply.declared)
|
|
378
|
+
return {
|
|
379
|
+
identity: state.identity,
|
|
380
|
+
sequence: state.sequence,
|
|
381
|
+
state: objectState,
|
|
382
|
+
sourceDigest: state.sourceDigest ?? state.hint,
|
|
383
|
+
sourceDigestProven: state.sourceDigest !== null,
|
|
384
|
+
operations,
|
|
385
|
+
lease: state.lease,
|
|
386
|
+
// An intent with no outcome recorded: whether the source was written is
|
|
387
|
+
// not known here. Whoever holds the lease next reads the source and
|
|
388
|
+
// decides from its digest.
|
|
389
|
+
intent: state.intent === null ? null : { ...state.intent, status: 'outcome-unknown' },
|
|
390
|
+
siblingsStale: state.applies.map((apply) => ({ ...apply })),
|
|
391
|
+
// Every recorded outcome of an apply, oldest first: what a repeated request is answered from, and what a retry
|
|
392
|
+
// budget is counted over.
|
|
393
|
+
applyOutcomes: state.outcomes.map((outcome) => ({ ...outcome })),
|
|
394
|
+
undeclared: {
|
|
395
|
+
siblingsStale: pendingApply === undefined ? null : { appliedSequence: pendingApply.appliedSequence, newSourceDigest: pendingApply.newSourceDigest, views: pendingApply.views },
|
|
396
|
+
superseded: undeclared(state, 'superseded'),
|
|
397
|
+
conflicted: undeclared(state, 'conflicted'),
|
|
398
|
+
},
|
|
399
|
+
violations: state.violations,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// `events` are validated object events in sequence order. An event that the
|
|
404
|
+
// events before it do not allow changes nothing and is listed in `violations`.
|
|
405
|
+
export function arbitrateEvents(events, rules = ARBITRATION_RULES) {
|
|
406
|
+
const state = createState()
|
|
407
|
+
for (const event of events) {
|
|
408
|
+
state.identity ??= { workspaceId: event.workspaceId, repoId: event.repoId, nodeId: event.nodeId }
|
|
409
|
+
state.sequence = event.sequence
|
|
410
|
+
const violation = HANDLERS[event.type](state, event, rules)
|
|
411
|
+
if (violation) state.violations.push({ sequence: event.sequence, code: violation })
|
|
412
|
+
}
|
|
413
|
+
return summarize(state)
|
|
414
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { firstString, parseArgs, resolveProjectConfig } from '../../../project/config.mjs'
|
|
3
|
+
import { refuse } from '../../../runtime/obsidian/errors.mjs'
|
|
4
|
+
import { PublicationRefusal } from '../recovery/store.mjs'
|
|
5
|
+
import { SOURCE_APPLY_OPERATION_ID, SourceApplyRefusal, createSourceApply } from './apply.mjs'
|
|
6
|
+
import { EditArbitrationRefusal } from './arbitrate.mjs'
|
|
7
|
+
|
|
8
|
+
// Binds source apply to the two places that call it: the maintenance engine,
|
|
9
|
+
// which dispatches queued edits in automatic mode, and `atelier obsidian
|
|
10
|
+
// apply`, which is how a person or an agent acting for them asks for one edit
|
|
11
|
+
// by name. Both reach the same function; the engine says `automatic`, the
|
|
12
|
+
// command says `manual`.
|
|
13
|
+
//
|
|
14
|
+
// The engine hands an apply operation the edit and the policy and nothing
|
|
15
|
+
// about where it runs, so the operation finds its project the way the process
|
|
16
|
+
// that hosts the engine did: from `--project` and `--data-root` of that
|
|
17
|
+
// process, else from the working directory. A workspace other than the edit's
|
|
18
|
+
// own refuses.
|
|
19
|
+
|
|
20
|
+
const EXIT = Object.freeze({ ok: 0, notSuccess: 3 })
|
|
21
|
+
const EDIT_ID = /^edit-[0-9a-f]{32}$/
|
|
22
|
+
const ACTOR = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
23
|
+
const ENGINE_STATUSES = new Set(['applied', 'refused', 'conflict', 'failed'])
|
|
24
|
+
|
|
25
|
+
export function processApplyContext({ argv = process.argv.slice(2), env = process.env, cwd = process.cwd(), platform = process.platform } = {}) {
|
|
26
|
+
const args = parseArgs(argv)
|
|
27
|
+
const configPath = firstString(args.project)
|
|
28
|
+
const dataRoot = firstString(args['data-root']) ?? undefined
|
|
29
|
+
return {
|
|
30
|
+
loadProject: () => resolveProjectConfig({ argv: configPath ? [`--project=${path.resolve(cwd, configPath)}`] : [], cwd: configPath ? path.dirname(path.resolve(cwd, configPath)) : cwd, env, writeLocalState: false }),
|
|
31
|
+
...(dataRoot === undefined ? {} : { dataRoot: path.resolve(cwd, dataRoot) }), env, platform,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The engine looks at displaced files again on the tick of a dispatch and on later ones, and an applied source leaves
|
|
36
|
+
// a closed journal that names its backup, so the operation does not wait out a quiet period inside a tick.
|
|
37
|
+
export function createEngineApplyOperation({ context = processApplyContext, create = createSourceApply } = {}) {
|
|
38
|
+
return {
|
|
39
|
+
id: SOURCE_APPLY_OPERATION_ID,
|
|
40
|
+
async apply({ edit, policyDigest }) {
|
|
41
|
+
const sourceApply = create({ ...(typeof context === 'function' ? context() : context), quietPeriodMs: 0 })
|
|
42
|
+
const result = await sourceApply.apply({ editId: edit?.editId, mode: 'automatic', policyDigest })
|
|
43
|
+
return { status: ENGINE_STATUSES.has(result.status) ? result.status : 'failed', code: result.code }
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SUMMARY = 'list | show EDIT | run EDIT [--actor ID] | recover Pending edits and the explicit apply of one of them to its source file.'
|
|
49
|
+
|
|
50
|
+
export function createApplyCommandOperation({ create = createSourceApply } = {}) {
|
|
51
|
+
return {
|
|
52
|
+
name: 'apply',
|
|
53
|
+
summary: SUMMARY,
|
|
54
|
+
// The command's option table is shared; this declares that `apply` is the operation that takes `--actor`.
|
|
55
|
+
options: ['actor'],
|
|
56
|
+
async run({ args, flags, loadProject, dataRoot, env, platform, clock }) {
|
|
57
|
+
const [sub = 'list', editId, ...extra] = args
|
|
58
|
+
const sourceApply = create({ loadProject, ...(dataRoot === undefined ? {} : { dataRoot }), env, platform, clock })
|
|
59
|
+
// The refusals of the apply operation, of the object store and of the store of a view are typed refusals of
|
|
60
|
+
// this command too: an object log that cannot be read is an answer with a code, never an internal error.
|
|
61
|
+
const typed = async (operation) => {
|
|
62
|
+
try { return await operation() } catch (error) {
|
|
63
|
+
if (error instanceof SourceApplyRefusal) refuse(error.code, 'the apply operation refused', error.detail)
|
|
64
|
+
if (error instanceof EditArbitrationRefusal) refuse(error.code, 'the record of this object cannot be read; nothing was changed')
|
|
65
|
+
if (error instanceof PublicationRefusal) refuse(error.code, 'the store of this view refused; nothing was changed')
|
|
66
|
+
throw error
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// The option table of the command is one table for every operation, so only `run` can say that it has no use for an actor.
|
|
70
|
+
if (sub !== 'run' && flags.actor !== undefined) refuse('usage', '--actor belongs to `apply run`')
|
|
71
|
+
const needsEdit = () => { if (typeof editId !== 'string' || !EDIT_ID.test(editId)) refuse('usage', 'name the edit: an identifier that `obsidian apply list` printed') }
|
|
72
|
+
if (sub === 'list') {
|
|
73
|
+
const edits = await typed(() => sourceApply.list())
|
|
74
|
+
return { exit: EXIT.ok, document: { edits }, human: edits.length === 0 ? ['no pending edit'] : edits.map((edit) => `${edit.editId}\t${edit.repoId}\t${edit.nodeId}\t${edit.state}\t${edit.object?.state ?? 'not-observed'}${edit.lastCode ? `\t${edit.lastCode}` : ''}`) }
|
|
75
|
+
}
|
|
76
|
+
if (sub === 'show') {
|
|
77
|
+
needsEdit()
|
|
78
|
+
const edit = await typed(() => sourceApply.show(editId))
|
|
79
|
+
return { exit: EXIT.ok, document: { edit }, human: [JSON.stringify(edit, null, 2)] }
|
|
80
|
+
}
|
|
81
|
+
if (sub === 'run') {
|
|
82
|
+
needsEdit()
|
|
83
|
+
if (extra.length > 0) refuse('usage', 'run takes one edit; name the actor with --actor ID')
|
|
84
|
+
const { actor } = flags
|
|
85
|
+
if (actor !== undefined && !ACTOR.test(actor)) refuse('usage', 'the actor must be an identifier')
|
|
86
|
+
const result = await typed(() => sourceApply.apply({ editId, mode: 'manual', ...(actor === undefined ? {} : { actor }) }))
|
|
87
|
+
const applied = result.status === 'applied'
|
|
88
|
+
return { exit: applied ? EXIT.ok : EXIT.notSuccess, document: { result }, human: [`${result.status}: ${result.code}`, ...(applied ? ['The source file changed; nothing was staged or committed.'] : ['Nothing was written to the source file.'])] }
|
|
89
|
+
}
|
|
90
|
+
if (sub === 'recover') {
|
|
91
|
+
const report = await typed(() => sourceApply.recover())
|
|
92
|
+
if (report.refusal) refuse(report.refusal.code, 'interrupted applies could not be looked at')
|
|
93
|
+
return { exit: EXIT.ok, document: report, human: report.recovered.length === 0 ? ['no interrupted apply'] : report.recovered.map((item) => `${item.applyId}\t${item.status}\t${item.code}`) }
|
|
94
|
+
}
|
|
95
|
+
return refuse('usage', 'apply takes list, show EDIT, run EDIT [--actor ID] or recover')
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createSourceApplyContribution(options = {}) {
|
|
101
|
+
return {
|
|
102
|
+
id: 'atelier.source-apply',
|
|
103
|
+
register({ extensions, operations }) {
|
|
104
|
+
extensions.register('apply-operation', createEngineApplyOperation(options))
|
|
105
|
+
operations.register(createApplyCommandOperation(options))
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Edits made in a vault: the raw-byte lens that turns an edited note into
|
|
2
|
+
// exact source bytes or a typed refusal, and the observation that turns a
|
|
3
|
+
// pending edit record into an edit operation document, and the durable,
|
|
4
|
+
// object-keyed arbitration of those operations across views and processes.
|
|
5
|
+
// The lens, the observation and the arbitration write to no source file and to
|
|
6
|
+
// no vault. Source apply (apply.mjs) is the one operation that writes a source
|
|
7
|
+
// file, under the policy decision of policy.mjs.
|
|
8
|
+
export { ALIGN_LIMITS, alignBodies } from './align.mjs'
|
|
9
|
+
export {
|
|
10
|
+
DEFAULT_APPLY_QUIET_PERIOD_MS, MAX_MANUAL_BATCH_SIZE, SOURCE_APPLY_DIRECTORY, SOURCE_APPLY_OPERATION_ID, SOURCE_APPLY_PRIMITIVES, SOURCE_APPLY_PROTOCOL_ID, SOURCE_APPLY_RECORD_SCHEMA, SOURCE_APPLY_REFUSALS,
|
|
11
|
+
SOURCE_APPLY_STEPS, SourceApplyRefusal, createSourceApply, createSourceApplyForOracleTests,
|
|
12
|
+
} from './apply.mjs'
|
|
13
|
+
export { createApplyCommandOperation, createEngineApplyOperation, createSourceApplyContribution, processApplyContext } from './contribution.mjs'
|
|
14
|
+
export { APPLY_MODES, APPLY_POLICY_PRIMITIVES, APPLY_POLICY_REFUSALS, DEFAULT_MANUAL_ACTOR, applyPolicyDigest, canonicalApplyPolicy, createApplyPolicyForOracleTests, decideApply, withApplyPolicyDigest } from './policy.mjs'
|
|
15
|
+
export { ARBITRATION_REASONS, ARBITRATION_RULES, EditArbitrationRefusal, OBJECT_EVENT_SCHEMA, OBJECT_EVENT_TYPES, REFUSED_DISPOSITIONS, arbitrateEvents, isObjectEvent } from './arbitrate.mjs'
|
|
16
|
+
export { MAX_PLAIN_SEGMENT, decodeIdentitySegment, encodeIdentitySegment } from './object-identity.mjs'
|
|
17
|
+
export { EDIT_ACKNOWLEDGEMENT_SCHEMA, OBJECTS_DIRECTORY, OBJECT_INDEX_DIRECTORY, OBJECT_INDEX_SCHEMA, OBJECT_STORE_CRASH_STEPS, OBJECT_STORE_LIMITS, OBJECT_STORE_PRIMITIVES, createObjectStoreForOracleTests, openObjectStore } from './object-store.mjs'
|
|
18
|
+
export { EDIT_LENS_VERSION, EDIT_OBSERVATION_STEPS, createEditObserverForOracleTests, editIdempotencyKey, observeEdit } from './observe.mjs'
|
|
19
|
+
export { EDIT_LENS_PRIMITIVES, EDIT_LENS_REFUSALS, applyEditLens, assertRewritesAccounted, createEditLensForOracleTests, findStructuralLinks, placeUnits, splitGeneratedTail, splitPrefix } from './regions.mjs'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// An object of the arbitration store is one source document of one repository
|
|
4
|
+
// of one workspace: (workspaceId, repoId, nodeId). The workspace is the state
|
|
5
|
+
// root; the repository and the node each become one directory name.
|
|
6
|
+
//
|
|
7
|
+
// An identifier may hold upper-case letters, '.', ':' and may spell a name a
|
|
8
|
+
// platform reserves, so it is never used as a name directly:
|
|
9
|
+
//
|
|
10
|
+
// k-<escaped> the whole identifier, reversible
|
|
11
|
+
// h-<escaped head>-<32 hex of sha256(identifier)> when the escaped form is long
|
|
12
|
+
//
|
|
13
|
+
// Escaping keeps [a-z0-9_-]; an upper-case letter becomes '~' and its lower
|
|
14
|
+
// case; any other character becomes '~' and two hex digits (every such code
|
|
15
|
+
// starts with a digit, so the two escapes cannot be confused). The result is
|
|
16
|
+
// lower case only, so two identifiers that differ by case never meet on a
|
|
17
|
+
// case-insensitive filesystem; the fixed prefix means no name is ever a
|
|
18
|
+
// reserved device name, and no name ends in a dot or a space. A hashed name is
|
|
19
|
+
// not reversible from the name alone: every event in the directory carries the
|
|
20
|
+
// identity, and the store checks that it encodes to the directory it is in.
|
|
21
|
+
|
|
22
|
+
export const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
23
|
+
export const MAX_PLAIN_SEGMENT = 64
|
|
24
|
+
const HEAD_LENGTH = 24
|
|
25
|
+
|
|
26
|
+
const escapeCharacter = (character) => {
|
|
27
|
+
if (/[a-z0-9_-]/.test(character)) return character
|
|
28
|
+
if (/[A-Z]/.test(character)) return `~${character.toLowerCase()}`
|
|
29
|
+
return `~${character.charCodeAt(0).toString(16).padStart(2, '0')}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const isIdentifier = (value) => typeof value === 'string' && IDENTIFIER_PATTERN.test(value)
|
|
33
|
+
|
|
34
|
+
export function encodeIdentitySegment(identifier) {
|
|
35
|
+
if (!isIdentifier(identifier)) throw new TypeError('an identity segment is made from an identifier')
|
|
36
|
+
const escaped = [...identifier].map(escapeCharacter).join('')
|
|
37
|
+
if (escaped.length <= MAX_PLAIN_SEGMENT) return `k-${escaped}`
|
|
38
|
+
const head = escaped.slice(0, HEAD_LENGTH).replace(/~[0-9a-f]?$/, '')
|
|
39
|
+
return `h-${head}-${createHash('sha256').update(identifier).digest('hex').slice(0, 32)}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The identifier of a `k-` name; null for a hashed name or for a name this
|
|
43
|
+
// module would not have produced.
|
|
44
|
+
export function decodeIdentitySegment(segment) {
|
|
45
|
+
if (typeof segment !== 'string' || !segment.startsWith('k-')) return null
|
|
46
|
+
const escaped = segment.slice(2)
|
|
47
|
+
let identifier = ''
|
|
48
|
+
for (let at = 0; at < escaped.length; at += 1) {
|
|
49
|
+
const character = escaped[at]
|
|
50
|
+
if (character !== '~') { identifier += character; continue }
|
|
51
|
+
const next = escaped[at + 1] ?? ''
|
|
52
|
+
if (/[a-z]/.test(next)) { identifier += next.toUpperCase(); at += 1; continue }
|
|
53
|
+
const code = escaped.slice(at + 1, at + 3)
|
|
54
|
+
if (!/^[0-9][0-9a-f]$/.test(code)) return null
|
|
55
|
+
identifier += String.fromCharCode(Number.parseInt(code, 16))
|
|
56
|
+
at += 2
|
|
57
|
+
}
|
|
58
|
+
return isIdentifier(identifier) && encodeIdentitySegment(identifier) === segment ? identifier : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const isIdentitySegment = (segment) => typeof segment === 'string' && (decodeIdentitySegment(segment) !== null || /^h-[a-z0-9_~-]{0,24}-[0-9a-f]{32}$/.test(segment))
|