@mnstry/atelier 0.2.0-alpha.5 → 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 +191 -0
- package/README.md +86 -25
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/docs/assurance-controls.md +12 -2
- package/docs/atelier-runtime.md +13 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/claims.md +28 -18
- package/docs/blocks/will-not-do.md +12 -3
- 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 +99 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +70 -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-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +61 -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/content-rules.mjs +9 -4
- package/src/boundary/policy.mjs +74 -42
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +61 -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 +107 -0
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +35 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +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/index.mjs +29 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +50 -49
- 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/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -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/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- 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,277 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { isPendingPrivateWrite, publishPrivateFile } from '../../../project/durable-state.mjs'
|
|
4
|
+
import { checkManagedRoots } from '../../../project/file-class.mjs'
|
|
5
|
+
import { ensureContainedPrivateDirectory, openRegularFileNoFollow } from '../../../project/private-state.mjs'
|
|
6
|
+
import { canonicalJson, closedObject, compareText, isoTime } from '../../../runtime/obsidian/documents.mjs'
|
|
7
|
+
import { validateObsidianContract } from '../contracts.mjs'
|
|
8
|
+
import { encodeIdentitySegment, isIdentifier } from '../edits/object-identity.mjs'
|
|
9
|
+
import { sha256Digest } from '../materialize/byte-lens.mjs'
|
|
10
|
+
import { adapterOperationId, isAdapterOperationId, isRoutableSourcePath, proposalStoreId } from './router.mjs'
|
|
11
|
+
|
|
12
|
+
// The memory of the proposal adapter: one record per adapter operation, in
|
|
13
|
+
// private workspace state, written BEFORE any proposal is created.
|
|
14
|
+
//
|
|
15
|
+
// <stateRoot>/state/proposals/<repo>/operations/<operation>--000001.json
|
|
16
|
+
// <stateRoot>/state/proposals/<repo>/lock/ (adapter.mjs)
|
|
17
|
+
//
|
|
18
|
+
// <repo> is an encoded repository identifier (edits/object-identity.mjs), so
|
|
19
|
+
// two repositories never share a directory. A record is one complete,
|
|
20
|
+
// canonical JSON file, owner-only, published with a non-overwriting hard link
|
|
21
|
+
// and never rewritten or removed. Each step of an operation is the next
|
|
22
|
+
// numbered file; it repeats the whole record and names the digest of the file
|
|
23
|
+
// before it. The state of an operation is its highest file.
|
|
24
|
+
//
|
|
25
|
+
// queued recorded; no proposal has been asked for yet, or the last attempt is to be made again
|
|
26
|
+
// submitted a proposal that carries this operation identity exists in the store of the repository
|
|
27
|
+
// acknowledged that proposal was read back and a receipt binds it to the edit; final
|
|
28
|
+
// backpressure the store could not take it now; tried again after `nextAttemptAt`, a bounded number of times
|
|
29
|
+
// refused it cannot be routed or stored as it is; final until a person asks for it again
|
|
30
|
+
//
|
|
31
|
+
// Bounds. A repository holds at most 4096 operations, 256 of them open, and an
|
|
32
|
+
// operation at most 64 files of at most 16 KiB. Nothing is rotated, compacted
|
|
33
|
+
// or dropped: a full queue refuses `queue-full` and records nothing, and the
|
|
34
|
+
// edit stays where it was, preserved and recorded, to be offered again.
|
|
35
|
+
//
|
|
36
|
+
// No record holds note or source text: identifiers, digests, a
|
|
37
|
+
// repository-relative path, codes, numbers and timestamps only.
|
|
38
|
+
|
|
39
|
+
export const PROPOSAL_QUEUE_DIRECTORY = path.join('state', 'proposals')
|
|
40
|
+
export const PROPOSAL_OPERATION_SCHEMA = 'atelier-obsidian-proposal-operation/v1'
|
|
41
|
+
export const PROPOSAL_QUEUE_STATES = Object.freeze(['queued', 'submitted', 'acknowledged', 'backpressure', 'refused'])
|
|
42
|
+
export const OPEN_QUEUE_STATES = Object.freeze(['queued', 'submitted', 'backpressure'])
|
|
43
|
+
export const PROPOSAL_QUEUE_LIMITS = Object.freeze({ maxOperationsPerRepository: 4096, maxOpenPerRepository: 256, maxRecordsPerOperation: 64, maxRecordBytes: 16 * 1024 })
|
|
44
|
+
export const PROPOSAL_QUEUE_CRASH_STEPS = Object.freeze(['record-published'])
|
|
45
|
+
|
|
46
|
+
const TRANSITIONS = Object.freeze({
|
|
47
|
+
queued: ['queued', 'submitted', 'backpressure', 'refused'],
|
|
48
|
+
backpressure: ['backpressure', 'submitted', 'refused', 'queued'],
|
|
49
|
+
// Back to `queued` only when the proposal it named is no longer in the store: it is then asked for again.
|
|
50
|
+
submitted: ['acknowledged', 'queued', 'submitted'],
|
|
51
|
+
refused: ['queued'],
|
|
52
|
+
acknowledged: [],
|
|
53
|
+
})
|
|
54
|
+
const RECORD_NAME = /^(pa-[0-9a-f]{64})--(\d{6})\.json$/
|
|
55
|
+
const recordName = (operationId, sequence) => `${operationId}--${String(sequence).padStart(6, '0')}.json`
|
|
56
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/
|
|
57
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
58
|
+
const CODE = /^[a-z][a-z0-9-]{0,63}$/
|
|
59
|
+
const EDIT_ID = /^edit-[0-9a-f]{32}$/
|
|
60
|
+
const PROPOSAL_ID = /^proposal-[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
61
|
+
const FIELDS = Object.freeze(['schema', 'adapterOperationId', 'sequence', 'previous', 'workspaceId', 'repoId', 'nodeId', 'editId', 'idempotencyKey', 'scopeId', 'generationId', 'sourcePath', 'storeId',
|
|
62
|
+
'state', 'code', 'attempts', 'lastAttemptAt', 'nextAttemptAt', 'proposalId', 'dedupe', 'receipt', 'recordedAt'])
|
|
63
|
+
// What never changes from the first file of an operation to its last.
|
|
64
|
+
const FIXED = Object.freeze(['adapterOperationId', 'workspaceId', 'repoId', 'nodeId', 'editId', 'idempotencyKey', 'scopeId', 'generationId', 'storeId'])
|
|
65
|
+
|
|
66
|
+
export class ProposalQueueRefusal extends Error {
|
|
67
|
+
constructor(code, message, detail = {}) {
|
|
68
|
+
super(`${code}: ${message}`)
|
|
69
|
+
this.name = 'ProposalQueueRefusal'
|
|
70
|
+
this.code = code
|
|
71
|
+
this.detail = detail
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const refuse = (code, message, detail) => { throw new ProposalQueueRefusal(code, message, detail) }
|
|
75
|
+
|
|
76
|
+
const isCount = (value) => Number.isInteger(value) && value >= 0
|
|
77
|
+
const isTimestamp = (value) => typeof value === 'string' && TIMESTAMP.test(value)
|
|
78
|
+
|
|
79
|
+
export function isProposalOperationRecord(record) {
|
|
80
|
+
try { closedObject(record, { required: FIELDS }, 'invalid', 'record') } catch { return false }
|
|
81
|
+
const identityOk = record.schema === PROPOSAL_OPERATION_SCHEMA && [record.workspaceId, record.repoId, record.nodeId, record.scopeId, record.generationId].every(isIdentifier)
|
|
82
|
+
&& isAdapterOperationId(record.adapterOperationId) && typeof record.editId === 'string' && EDIT_ID.test(record.editId) && typeof record.idempotencyKey === 'string'
|
|
83
|
+
if (!identityOk) return false
|
|
84
|
+
let expected
|
|
85
|
+
try { expected = adapterOperationId(record) } catch { return false }
|
|
86
|
+
if (expected !== record.adapterOperationId || record.storeId !== proposalStoreId(record)) return false
|
|
87
|
+
if (!Number.isInteger(record.sequence) || record.sequence < 1 || !(record.previous === null ? record.sequence === 1 : DIGEST.test(record.previous) && record.sequence > 1)) return false
|
|
88
|
+
// The path a route refused for is unknown or unusable, and is then not recorded at all.
|
|
89
|
+
if (!(record.sourcePath === null || isRoutableSourcePath(record.sourcePath))) return false
|
|
90
|
+
if (!PROPOSAL_QUEUE_STATES.includes(record.state) || !(record.code === null || (typeof record.code === 'string' && CODE.test(record.code)))) return false
|
|
91
|
+
if (!isCount(record.attempts) || !(record.lastAttemptAt === null || isTimestamp(record.lastAttemptAt)) || !(record.nextAttemptAt === null || isTimestamp(record.nextAttemptAt)) || !isTimestamp(record.recordedAt)) return false
|
|
92
|
+
if (!(record.proposalId === null || (typeof record.proposalId === 'string' && record.proposalId.length <= 128 && PROPOSAL_ID.test(record.proposalId)))) return false
|
|
93
|
+
if (!(record.dedupe === null || ['new', 'recovered'].includes(record.dedupe))) return false
|
|
94
|
+
const settled = record.state === 'submitted' || record.state === 'acknowledged'
|
|
95
|
+
if (settled !== (record.proposalId !== null) || settled !== (record.dedupe !== null)) return false
|
|
96
|
+
if ((record.state === 'refused' || record.state === 'backpressure') && record.code === null) return false
|
|
97
|
+
if (record.state === 'acknowledged' || record.state === 'refused') {
|
|
98
|
+
const receipt = record.receipt
|
|
99
|
+
if (validateObsidianContract('proposal-receipt', receipt).length > 0) return false
|
|
100
|
+
if (receipt.adapterOperationId !== record.adapterOperationId || receipt.editId !== record.editId || receipt.repoId !== record.repoId || receipt.storeId !== record.storeId || receipt.proposalId !== record.proposalId) return false
|
|
101
|
+
if (receipt.backpressure !== (record.state === 'acknowledged' ? 'accepted' : 'refused')) return false
|
|
102
|
+
} else if (record.receipt !== null) return false
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readBytesNoFollow(file) {
|
|
107
|
+
const descriptor = openRegularFileNoFollow(file)
|
|
108
|
+
try { return fs.readFileSync(descriptor) } finally { fs.closeSync(descriptor) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function openProposalQueue({ stateRoot, workspaceId, repositoryRoots, clock, crash = () => {}, limits = PROPOSAL_QUEUE_LIMITS } = {}) {
|
|
112
|
+
if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot)) throw new TypeError('stateRoot must be an absolute path')
|
|
113
|
+
if (!isIdentifier(workspaceId)) throw new TypeError('workspaceId must be a contract identifier')
|
|
114
|
+
if (typeof clock !== 'function') throw new TypeError('a clock must be injected')
|
|
115
|
+
if (!Array.isArray(repositoryRoots) || repositoryRoots.some((item) => typeof item !== 'string' || !path.isAbsolute(item))) throw new TypeError('repositoryRoots must list the absolute root of every enrolled repository')
|
|
116
|
+
const guard = checkManagedRoots({ managedRoots: [stateRoot], repositoryRoots })
|
|
117
|
+
if (!guard.ok) refuse('state-root-overlaps-repository', 'the adapter queue may not overlap an enrolled repository', { codes: guard.refusals.map((item) => item.code) })
|
|
118
|
+
const root = fs.realpathSync.native(stateRoot)
|
|
119
|
+
const queueRoot = path.join(root, PROPOSAL_QUEUE_DIRECTORY)
|
|
120
|
+
// Two processes may create the same directory at once; the one that loses the mkdir finds it made.
|
|
121
|
+
const privateDir = (...parts) => {
|
|
122
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
123
|
+
try { return ensureContainedPrivateDirectory({ workspaceRoot: root, directory: path.join(queueRoot, ...parts), label: 'proposal adapter state' }) } catch (error) {
|
|
124
|
+
if (error.code !== 'EEXIST' || attempt >= 8) throw error
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const repositoryDirectory = (repoId) => path.join(queueRoot, encodeIdentitySegment(repoId))
|
|
129
|
+
const operationsDirectory = (repoId) => path.join(repositoryDirectory(repoId), 'operations')
|
|
130
|
+
// Records are immutable, so one that was read and verified stays verified under its name.
|
|
131
|
+
const verified = new Map()
|
|
132
|
+
|
|
133
|
+
function readRecord(directory, name) {
|
|
134
|
+
const file = path.join(directory, name)
|
|
135
|
+
const cached = verified.get(file)
|
|
136
|
+
if (cached) return cached
|
|
137
|
+
const [, operationId, digits] = RECORD_NAME.exec(name)
|
|
138
|
+
const corrupt = (code, message) => refuse(code, message, { adapterOperationId: operationId, sequence: Number(digits) })
|
|
139
|
+
let bytes
|
|
140
|
+
try {
|
|
141
|
+
if (fs.lstatSync(file).size > limits.maxRecordBytes) corrupt('queue-record-too-large', 'a record of the adapter queue is larger than a record can be')
|
|
142
|
+
bytes = readBytesNoFollow(file)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof ProposalQueueRefusal) throw error
|
|
145
|
+
return corrupt('queue-record-unreadable', 'a record of the adapter queue cannot be read')
|
|
146
|
+
}
|
|
147
|
+
let record
|
|
148
|
+
try { record = JSON.parse(bytes.toString('utf8')) } catch { return corrupt('queue-record-malformed', 'a record of the adapter queue is not a document') }
|
|
149
|
+
if (!isProposalOperationRecord(record) || canonicalJson(record) !== bytes.toString('utf8')) corrupt('queue-record-malformed', 'a record of the adapter queue is not a valid canonical record')
|
|
150
|
+
if (record.adapterOperationId !== operationId || record.sequence !== Number(digits) || record.workspaceId !== workspaceId) corrupt('queue-record-foreign', 'a record of the adapter queue is not where it belongs')
|
|
151
|
+
const entry = { record, digest: sha256Digest(bytes) }
|
|
152
|
+
verified.set(file, entry)
|
|
153
|
+
return entry
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Every operation of one repository by its files: Map(operationId -> highest sequence). Refuses a directory that
|
|
157
|
+
// holds anything this module did not write, since nothing may be decided over files nobody can account for.
|
|
158
|
+
function listNames(repoId) {
|
|
159
|
+
const directory = operationsDirectory(repoId)
|
|
160
|
+
let names
|
|
161
|
+
try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') return { directory, highest: new Map() }; throw error }
|
|
162
|
+
const highest = new Map()
|
|
163
|
+
for (const name of names) {
|
|
164
|
+
if (isPendingPrivateWrite(name)) continue
|
|
165
|
+
const match = RECORD_NAME.exec(name)
|
|
166
|
+
if (!match || Number(match[2]) < 1) refuse('queue-foreign-file', 'the adapter queue holds a file that is not a record; nothing is decided until a person has looked', { repoId })
|
|
167
|
+
highest.set(match[1], Math.max(highest.get(match[1]) ?? 0, Number(match[2])))
|
|
168
|
+
}
|
|
169
|
+
return { directory, highest }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The whole, verified history of one operation, or null.
|
|
173
|
+
function readOperation(repoId, operationId) {
|
|
174
|
+
const { directory, highest } = listNames(repoId)
|
|
175
|
+
const last = highest.get(operationId) ?? 0
|
|
176
|
+
if (last === 0) return null
|
|
177
|
+
const records = []
|
|
178
|
+
let previous = null
|
|
179
|
+
for (let sequence = 1; sequence <= last; sequence += 1) {
|
|
180
|
+
let entry
|
|
181
|
+
try { entry = readRecord(directory, recordName(operationId, sequence)) } catch (error) {
|
|
182
|
+
if (error instanceof ProposalQueueRefusal && error.code === 'queue-record-unreadable') refuse('queue-record-gap', 'a record of the operation is missing', { adapterOperationId: operationId, sequence })
|
|
183
|
+
throw error
|
|
184
|
+
}
|
|
185
|
+
const before = records.at(-1)
|
|
186
|
+
if (entry.record.previous !== previous || entry.record.repoId !== repoId) refuse('queue-chain-broken', 'a record of the operation does not follow the one before it', { adapterOperationId: operationId, sequence })
|
|
187
|
+
if (before && (FIXED.some((field) => before[field] !== entry.record[field]) || !TRANSITIONS[before.state].includes(entry.record.state))) {
|
|
188
|
+
refuse('queue-history-invalid', 'the records of the operation are not a history this adapter could have written', { adapterOperationId: operationId, sequence })
|
|
189
|
+
}
|
|
190
|
+
records.push(entry.record)
|
|
191
|
+
previous = entry.digest
|
|
192
|
+
}
|
|
193
|
+
return { records, head: records.at(-1), headDigest: previous }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function publish(directory, record) {
|
|
197
|
+
const bytes = Buffer.from(canonicalJson(record), 'utf8')
|
|
198
|
+
if (bytes.length > limits.maxRecordBytes) refuse('queue-record-too-large', 'the record is larger than a record can be', { bytes: bytes.length })
|
|
199
|
+
const file = path.join(directory, recordName(record.adapterOperationId, record.sequence))
|
|
200
|
+
const context = { adapterOperationId: record.adapterOperationId, sequence: record.sequence, state: record.state }
|
|
201
|
+
// Non-overwriting: of two writers of one name exactly one succeeds, and the other is told.
|
|
202
|
+
try { publishPrivateFile(file, bytes) } catch (error) {
|
|
203
|
+
if (error.code === 'EEXIST') refuse('queue-record-taken', 'another writer recorded this step of the operation first', context)
|
|
204
|
+
throw error
|
|
205
|
+
}
|
|
206
|
+
crash('record-published', context)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
workspaceId,
|
|
211
|
+
stateRoot: root,
|
|
212
|
+
queueRoot,
|
|
213
|
+
lockDirectory: (repoId) => path.join(repositoryDirectory(repoId), 'lock'),
|
|
214
|
+
// Made on demand: a workspace that never sees a structural edit holds no adapter state at all.
|
|
215
|
+
ensureRepository(repoId) { privateDir(encodeIdentitySegment(repoId)); return privateDir(encodeIdentitySegment(repoId), 'operations') },
|
|
216
|
+
|
|
217
|
+
read: (repoId, operationId) => readOperation(repoId, operationId),
|
|
218
|
+
|
|
219
|
+
// The head of every operation of every repository. An operation (or a repository) that cannot be read is listed
|
|
220
|
+
// as such, with its code, and hides nothing else. { operations, unreadable }
|
|
221
|
+
heads({ repoId: only } = {}) {
|
|
222
|
+
const operations = []
|
|
223
|
+
const unreadable = []
|
|
224
|
+
let segments
|
|
225
|
+
try { segments = fs.readdirSync(queueRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort() } catch (error) { if (error.code === 'ENOENT') return { operations, unreadable }; throw error }
|
|
226
|
+
for (const segment of segments) {
|
|
227
|
+
if (only !== undefined && segment !== encodeIdentitySegment(only)) continue
|
|
228
|
+
const directory = path.join(queueRoot, segment, 'operations')
|
|
229
|
+
let names
|
|
230
|
+
try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') continue; throw error }
|
|
231
|
+
const highest = new Map()
|
|
232
|
+
let foreign = false
|
|
233
|
+
for (const name of names) {
|
|
234
|
+
if (isPendingPrivateWrite(name)) continue
|
|
235
|
+
const match = RECORD_NAME.exec(name)
|
|
236
|
+
if (!match) { foreign = true; continue }
|
|
237
|
+
highest.set(match[1], Math.max(highest.get(match[1]) ?? 0, Number(match[2])))
|
|
238
|
+
}
|
|
239
|
+
if (foreign) unreadable.push({ segment, adapterOperationId: null, code: 'queue-foreign-file' })
|
|
240
|
+
for (const [operationId, sequence] of [...highest].sort(([left], [right]) => compareText(left, right))) {
|
|
241
|
+
try {
|
|
242
|
+
const { record } = readRecord(directory, recordName(operationId, sequence))
|
|
243
|
+
if (encodeIdentitySegment(record.repoId) !== segment) refuse('queue-record-foreign', 'a record is kept under another repository', { adapterOperationId: operationId })
|
|
244
|
+
operations.push(record)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!(error instanceof ProposalQueueRefusal)) throw error
|
|
247
|
+
unreadable.push({ segment, adapterOperationId: operationId, code: error.code })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { operations, unreadable }
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// Records the next step of an operation. `fields` are the members that change; the first step names the whole
|
|
255
|
+
// operation. Returns the record written. Refuses `queue-full` before the first step of an operation the
|
|
256
|
+
// repository has no room for, and `queue-operation-full` when an operation holds as many files as it may.
|
|
257
|
+
append(repoId, operationId, fields) {
|
|
258
|
+
const existing = readOperation(repoId, operationId)
|
|
259
|
+
if (existing === null) {
|
|
260
|
+
const { highest } = listNames(repoId)
|
|
261
|
+
if (highest.size >= limits.maxOperationsPerRepository) refuse('queue-full', 'the adapter queue of the repository holds as many operations as it may; nothing is rotated or dropped', { repoId, operations: highest.size })
|
|
262
|
+
const open = this.heads({ repoId }).operations.filter((record) => OPEN_QUEUE_STATES.includes(record.state)).length
|
|
263
|
+
if (open >= limits.maxOpenPerRepository) refuse('queue-full', 'the adapter queue of the repository holds as many open operations as it may', { repoId, open })
|
|
264
|
+
} else if (existing.records.length >= limits.maxRecordsPerOperation) {
|
|
265
|
+
refuse('queue-operation-full', 'the operation holds as many records as an operation may', { adapterOperationId: operationId })
|
|
266
|
+
}
|
|
267
|
+
const base = existing?.head ?? { schema: PROPOSAL_OPERATION_SCHEMA, adapterOperationId: operationId, workspaceId, repoId, code: null, attempts: 0, lastAttemptAt: null, nextAttemptAt: null, proposalId: null, dedupe: null, receipt: null }
|
|
268
|
+
const record = { ...base, ...fields, sequence: (existing?.records.length ?? 0) + 1, previous: existing?.headDigest ?? null, recordedAt: isoTime(clock) }
|
|
269
|
+
if (!isProposalOperationRecord(record)) refuse('invalid-queue-record', 'the record is not one this adapter writes', { adapterOperationId: operationId, state: String(fields?.state) })
|
|
270
|
+
if (existing && (FIXED.some((field) => existing.head[field] !== record[field]) || !TRANSITIONS[existing.head.state].includes(record.state))) {
|
|
271
|
+
refuse('invalid-queue-transition', 'the operation cannot go from where it is to there', { adapterOperationId: operationId, from: existing.head.state, to: record.state })
|
|
272
|
+
}
|
|
273
|
+
publish(this.ensureRepository(repoId), record)
|
|
274
|
+
return record
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { isIdentifier } from '../edits/object-identity.mjs'
|
|
5
|
+
|
|
6
|
+
// Routing: which existing proposal store a structural edit belongs to, and the
|
|
7
|
+
// name the adapter knows that edit by.
|
|
8
|
+
//
|
|
9
|
+
// A structural edit is an edit operation of kind `semantic-proposal`. It is a
|
|
10
|
+
// request about ONE source document of ONE enrolled repository, so it goes to
|
|
11
|
+
// the proposal store of that repository and to no other. The store of a
|
|
12
|
+
// repository is the one every other writer of proposals already uses for a
|
|
13
|
+
// root: `<repository root>/.atelier-proposals`, the default of
|
|
14
|
+
// createProposalStore for that root. Nothing here invents another place, and
|
|
15
|
+
// a store is never resolved inside a vault, inside private Obsidian state, or
|
|
16
|
+
// for a repository the project does not enrol.
|
|
17
|
+
//
|
|
18
|
+
// The routing key is the whole identity (workspaceId, repoId, nodeId). Two
|
|
19
|
+
// repositories that hold the same relative path and the same local node id
|
|
20
|
+
// have different repository identifiers, so they have different roots,
|
|
21
|
+
// different stores and different adapter operation identities.
|
|
22
|
+
//
|
|
23
|
+
// A route that cannot be resolved refuses with a code. A refusal reads and
|
|
24
|
+
// writes nothing of the edit: the preserved bytes and the record of the object
|
|
25
|
+
// stay exactly as they are.
|
|
26
|
+
|
|
27
|
+
export const PROPOSAL_STORE_DIRECTORY = '.atelier-proposals'
|
|
28
|
+
export const PROPOSAL_STORE_LEDGER = 'events.ndjson'
|
|
29
|
+
export const MAX_ROUTED_SOURCE_PATH = 500
|
|
30
|
+
|
|
31
|
+
export const PROPOSAL_ROUTE_REFUSALS = Object.freeze([
|
|
32
|
+
'invalid-operation',
|
|
33
|
+
'foreign-workspace',
|
|
34
|
+
'repository-not-enrolled',
|
|
35
|
+
'repository-external',
|
|
36
|
+
'repository-root-unreadable',
|
|
37
|
+
'route-withheld',
|
|
38
|
+
'route-visibility-unknown',
|
|
39
|
+
'source-path-invalid',
|
|
40
|
+
'source-path-not-preservable',
|
|
41
|
+
'proposal-store-unsafe',
|
|
42
|
+
'proposal-store-inside-managed-root',
|
|
43
|
+
'proposal-store-not-ignored',
|
|
44
|
+
'proposal-store-ignore-unknown',
|
|
45
|
+
])
|
|
46
|
+
// Conditions of the machine at this moment rather than of the route: tried again later, within the retry bounds.
|
|
47
|
+
export const TRANSIENT_ROUTE_REFUSALS = Object.freeze(['route-visibility-unknown'])
|
|
48
|
+
|
|
49
|
+
const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{16,128}$/
|
|
50
|
+
const OPERATION_IDENTITY = /^pa-[0-9a-f]{64}$/
|
|
51
|
+
const DOMAIN = 'atelier-obsidian-proposal-adapter/v1'
|
|
52
|
+
|
|
53
|
+
const hashOf = (parts) => createHash('sha256').update(parts.join('\u0000')).digest('hex')
|
|
54
|
+
|
|
55
|
+
// The decisions the oracles of test/obsidian-proposals.test.mjs are sensitive
|
|
56
|
+
// to. Production always uses these; the tests substitute deliberately broken
|
|
57
|
+
// ones through createProposalRouterForOracleTests to prove each oracle can fail.
|
|
58
|
+
export const PROPOSAL_ROUTER_PRIMITIVES = Object.freeze({
|
|
59
|
+
// What an adapter operation identity is made from: the whole identity and the idempotency key, in this order.
|
|
60
|
+
identityParts: ({ workspaceId, repoId, nodeId, idempotencyKey }) => [workspaceId, repoId, nodeId, idempotencyKey],
|
|
61
|
+
// The enrolled repository a route goes to: the one the identity names, and no other.
|
|
62
|
+
repositoryOf: ({ project, repoId }) => (project?.repos ?? []).filter((item) => item?.name === repoId),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function validIdentityInput({ workspaceId, repoId, nodeId, idempotencyKey }) {
|
|
66
|
+
return [workspaceId, repoId, nodeId].every(isIdentifier) && typeof idempotencyKey === 'string' && IDEMPOTENCY_KEY.test(idempotencyKey)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The adapter operation identity: a fixed-length, lower-case name derived from
|
|
70
|
+
// the whole identity and the idempotency key of the edit operation. The parts
|
|
71
|
+
// are joined by a character no identifier can hold, so moving a character from
|
|
72
|
+
// one part to the next gives another name. It holds only [a-z0-9-], is 67
|
|
73
|
+
// characters long, and is carried inside the payload object of a proposal,
|
|
74
|
+
// which the store writes and reads back as JSON without normalising it.
|
|
75
|
+
function operationIdWith(rules, input) {
|
|
76
|
+
if (!validIdentityInput(input)) throw new TypeError('an adapter operation identity is made from a whole identity and an idempotency key')
|
|
77
|
+
return `pa-${hashOf([DOMAIN, 'operation', ...rules.identityParts(input)])}`
|
|
78
|
+
}
|
|
79
|
+
export const adapterOperationId = (input) => operationIdWith(PROPOSAL_ROUTER_PRIMITIVES, input)
|
|
80
|
+
|
|
81
|
+
export const isAdapterOperationId = (value) => typeof value === 'string' && OPERATION_IDENTITY.test(value)
|
|
82
|
+
|
|
83
|
+
// The store of one repository as this workspace names it. No path is part of it.
|
|
84
|
+
export function proposalStoreId({ workspaceId, repoId }) {
|
|
85
|
+
if (![workspaceId, repoId].every(isIdentifier)) throw new TypeError('a proposal store identity is made from a workspace and a repository')
|
|
86
|
+
return `ps-${hashOf([DOMAIN, 'store', workspaceId, repoId, PROPOSAL_STORE_DIRECTORY])}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const inside = (parent, candidate) => {
|
|
90
|
+
const relative = path.relative(parent, candidate)
|
|
91
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
92
|
+
}
|
|
93
|
+
const lstatOrNull = (file) => { try { return fs.lstatSync(file) } catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; throw error } }
|
|
94
|
+
const realOrSelf = (directory) => { try { return fs.realpathSync.native(directory) } catch { return directory } }
|
|
95
|
+
|
|
96
|
+
export function isRoutableSourcePath(value) {
|
|
97
|
+
if (typeof value !== 'string' || value === '' || path.isAbsolute(value) || value.includes('\\') || value.includes('\u0000') || /^[A-Za-z]:/.test(value)) return false
|
|
98
|
+
return !value.split('/').some((part) => part === '' || part === '.' || part === '..')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What the existing store does to the `path` of a proposal: trimmed, and cut
|
|
102
|
+
// at 500 characters. A path that would not come back as it went in refuses
|
|
103
|
+
// before anything is created; it is never recorded cut short.
|
|
104
|
+
export const survivesStoreNormalisation = (sourcePath) => sourcePath.trim().slice(0, MAX_ROUTED_SOURCE_PATH) === sourcePath
|
|
105
|
+
|
|
106
|
+
const refusal = (code, detail = {}) => ({ ok: false, code, transient: TRANSIENT_ROUTE_REFUSALS.includes(code), detail })
|
|
107
|
+
|
|
108
|
+
// `identity` is { workspaceId, repoId, nodeId }; `sourcePath` is the path of
|
|
109
|
+
// the source inside its repository, as the manifest of the view records it.
|
|
110
|
+
// `managedRoots` are the private state root and every vault. `isVisible` says
|
|
111
|
+
// whether this machine may see the object now: true, false, or null when that
|
|
112
|
+
// cannot be decided. `isGitIgnored` answers true, false or null.
|
|
113
|
+
//
|
|
114
|
+
// { ok: true, route } or { ok: false, code, transient, detail }. Never throws
|
|
115
|
+
// for a route that is merely wrong, and writes nothing.
|
|
116
|
+
function resolveWith(rules, { project, workspaceId, identity, sourcePath, managedRoots = [], isVisible, isGitIgnored, env = process.env }) {
|
|
117
|
+
if (identity === null || typeof identity !== 'object' || ![identity.workspaceId, identity.repoId, identity.nodeId].every(isIdentifier)) return refusal('invalid-operation', { member: 'identity' })
|
|
118
|
+
if (identity.workspaceId !== workspaceId) return refusal('foreign-workspace')
|
|
119
|
+
const { repoId, nodeId } = identity
|
|
120
|
+
const named = rules.repositoryOf({ project, repoId, nodeId })
|
|
121
|
+
if (named.length !== 1) return refusal('repository-not-enrolled')
|
|
122
|
+
const [repo] = named
|
|
123
|
+
if (repo.external || typeof repo.path !== 'string' || !path.isAbsolute(repo.path)) return refusal('repository-external')
|
|
124
|
+
let repositoryRoot
|
|
125
|
+
try {
|
|
126
|
+
repositoryRoot = fs.realpathSync.native(repo.path)
|
|
127
|
+
if (!fs.statSync(repositoryRoot).isDirectory()) return refusal('repository-root-unreadable')
|
|
128
|
+
} catch { return refusal('repository-root-unreadable') }
|
|
129
|
+
|
|
130
|
+
if (!isRoutableSourcePath(sourcePath)) return refusal('source-path-invalid')
|
|
131
|
+
if (!survivesStoreNormalisation(sourcePath)) return refusal('source-path-not-preservable', { length: sourcePath.length, limit: MAX_ROUTED_SOURCE_PATH })
|
|
132
|
+
|
|
133
|
+
const storeDirectory = path.join(repositoryRoot, PROPOSAL_STORE_DIRECTORY)
|
|
134
|
+
const present = lstatOrNull(storeDirectory)
|
|
135
|
+
if (present !== null && (present.isSymbolicLink() || !present.isDirectory())) return refusal('proposal-store-unsafe')
|
|
136
|
+
for (const managedRoot of managedRoots) {
|
|
137
|
+
const managed = realOrSelf(managedRoot)
|
|
138
|
+
if (inside(managed, storeDirectory) || inside(storeDirectory, managed)) return refusal('proposal-store-inside-managed-root')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const visible = isVisible({ workspaceId, repoId, nodeId })
|
|
142
|
+
if (visible === null || visible === undefined) return refusal('route-visibility-unknown')
|
|
143
|
+
if (visible !== true) return refusal('route-withheld')
|
|
144
|
+
|
|
145
|
+
// A store that is already there is whatever git already says it is. One that would be made here must not become
|
|
146
|
+
// something git reports: a repository that does not ignore the directory refuses, and nothing is made.
|
|
147
|
+
if (present === null) {
|
|
148
|
+
const ignored = isGitIgnored({ repositoryRoot, relative: `${PROPOSAL_STORE_DIRECTORY}/${PROPOSAL_STORE_LEDGER}`, env })
|
|
149
|
+
if (ignored === false) return refusal('proposal-store-not-ignored')
|
|
150
|
+
if (ignored !== true && lstatOrNull(path.join(repositoryRoot, '.git')) !== null) return refusal('proposal-store-ignore-unknown')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
route: Object.freeze({
|
|
156
|
+
workspaceId, repoId, nodeId, sourcePath, repositoryRoot, storeDirectory,
|
|
157
|
+
storeExists: present !== null,
|
|
158
|
+
storeId: proposalStoreId({ workspaceId, repoId }),
|
|
159
|
+
}),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createProposalRouterForOracleTests(primitives = PROPOSAL_ROUTER_PRIMITIVES) {
|
|
164
|
+
const rules = { ...PROPOSAL_ROUTER_PRIMITIVES, ...primitives }
|
|
165
|
+
return { adapterOperationId: (input) => operationIdWith(rules, input), resolveProposalRoute: (input) => resolveWith(rules, input) }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const resolveProposalRoute = (input) => resolveWith(PROPOSAL_ROUTER_PRIMITIVES, input)
|