@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.11
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 +413 -0
- package/README.md +402 -205
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +49 -0
- package/docs/atelier-runtime.md +28 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/audience-visibility.md +9 -0
- package/docs/blocks/claims.md +61 -0
- package/docs/blocks/conformance-admission.md +15 -0
- package/docs/blocks/will-not-do.md +18 -0
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/continuity.md +3 -4
- package/docs/decisions.md +167 -0
- package/docs/design.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +99 -16
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/knowledge-graph.md +8 -4
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +328 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +698 -0
- package/docs/obsidian.md +484 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +174 -12
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +155 -5
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +86 -10
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-open/SKILL.md +11 -5
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-open/SKILL.md +11 -5
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/content-rules.mjs +283 -20
- package/src/boundary/policy.mjs +213 -91
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +78 -10
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +348 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +17 -9
- package/src/commands/init.mjs +46 -6
- package/src/commands/obsidian.mjs +371 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/server.mjs +1 -4
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +107 -0
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +35 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +206 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +176 -316
- package/src/graph/knowledge-graph.mjs +643 -43
- package/src/guides/contracts.mjs +54 -0
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +41 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +112 -52
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +180 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +120 -0
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +212 -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 +138 -0
- package/src/runtime/obsidian/app-production-seams.mjs +107 -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 +280 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-entry-path.mjs +9 -0
- package/src/runtime/obsidian/service-main.mjs +93 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- package/src/server/local-sidecar.mjs +125 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +4 -2
- package/src/skills/steward.mjs +780 -0
- package/src/support/feedback-report.mjs +4 -3
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +65 -29
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/project/README.md +2 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
2
|
+
import { readlinkSync } from 'node:fs'
|
|
3
|
+
import { POLICY_SETTINGS_PATH, buildEvalCode, createInProcessHost, runInProcess, validatePayload } from './bridge-script.mjs'
|
|
4
|
+
|
|
5
|
+
// Editor coordination adapter.
|
|
6
|
+
//
|
|
7
|
+
// adapter.probe({ vaultRoot }) -> { state: 'coordinated' | 'absent' | 'uncoordinated', reason }
|
|
8
|
+
// adapter.inspect(payload) -> reply read-only, may retry
|
|
9
|
+
// adapter.collect(payload) -> reply read-only, may retry
|
|
10
|
+
// adapter.publish(payload) -> reply sent at most once
|
|
11
|
+
//
|
|
12
|
+
// A raw `call(payload)` sends one payload once and resolves with the parsed
|
|
13
|
+
// reply. It throws TransportTimeout when the reply did not arrive in time and
|
|
14
|
+
// any other error when the call could not be made. createEditorAdapter adds
|
|
15
|
+
// the rules every transport must follow: calls are serialized, read-only calls
|
|
16
|
+
// may retry, and a publish is never resent. When a publish reply is lost the
|
|
17
|
+
// recorded outcome is re-read from the app; when none is recorded the caller
|
|
18
|
+
// is told so and reconciles from the files themselves.
|
|
19
|
+
|
|
20
|
+
export class TransportTimeout extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'TransportTimeout'
|
|
24
|
+
this.code = 'transport-timeout'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const READ_RETRIES = 2
|
|
29
|
+
|
|
30
|
+
// `qualification`, when given, is the app qualification this adapter was
|
|
31
|
+
// constructed under (`qualifyApp`). An adapter qualified while no app ran was
|
|
32
|
+
// never shown a version, so it never coordinates with an app found running
|
|
33
|
+
// later, whose version nobody checked: it is `uncoordinated` until an adapter
|
|
34
|
+
// qualified with a checked version replaces it. Without a qualification the
|
|
35
|
+
// adapter coordinates as before.
|
|
36
|
+
export function createEditorAdapter({ call, processProbe, kind = 'custom', qualification }) {
|
|
37
|
+
if (typeof call !== 'function') throw new TypeError('createEditorAdapter needs a call function')
|
|
38
|
+
if (typeof processProbe !== 'function') throw new TypeError('createEditorAdapter needs a processProbe function')
|
|
39
|
+
const versionUnchecked = qualification !== undefined && qualification?.versionChecked !== true
|
|
40
|
+
const retries = []
|
|
41
|
+
let tail = Promise.resolve()
|
|
42
|
+
const serialized = (operation) => {
|
|
43
|
+
const next = tail.then(operation, operation)
|
|
44
|
+
tail = next.catch(() => {})
|
|
45
|
+
return next
|
|
46
|
+
}
|
|
47
|
+
const readOnly = async (payload) => {
|
|
48
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
49
|
+
try { return await call(validatePayload(payload)) } catch (error) {
|
|
50
|
+
if (attempt >= READ_RETRIES || !(error instanceof TransportTimeout)) throw error
|
|
51
|
+
retries.push(payload.op)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
kind,
|
|
57
|
+
transportRetries: retries,
|
|
58
|
+
inspect: (payload) => serialized(() => readOnly({ ...payload, op: 'inspect' })),
|
|
59
|
+
collect: (payload) => serialized(() => readOnly({ ...payload, op: 'collect' })),
|
|
60
|
+
publish: (payload) => serialized(async () => {
|
|
61
|
+
validatePayload(payload)
|
|
62
|
+
if (payload.op !== 'publish') throw new TypeError('publish needs a publish payload')
|
|
63
|
+
try { return await call(payload) } catch (error) {
|
|
64
|
+
retries.push(error instanceof TransportTimeout ? 'publish-reply-lost' : 'publish-call-failed')
|
|
65
|
+
let record = null
|
|
66
|
+
try { record = await readOnly({ op: 'collect', vaultRoot: payload.vaultRoot, path: payload.path }) } catch { /* the app is not answering */ }
|
|
67
|
+
if (record && record.operationId === payload.operationId && typeof record.outcome === 'string') return { ...record, status: record.outcome, replyLost: true }
|
|
68
|
+
return { status: 'outcome-unknown', operationId: payload.operationId, wrote: null, replyLost: true, transportError: String(error.message || error).slice(0, 300) }
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
// Path selection. `absent` is returned only when the process probe says,
|
|
72
|
+
// positively, that no Obsidian is running. A running or unknowable app
|
|
73
|
+
// that does not answer for this exact vault is `uncoordinated`, and so is
|
|
74
|
+
// any app while this adapter's qualification checked no version.
|
|
75
|
+
probe: ({ vaultRoot }) => serialized(async () => {
|
|
76
|
+
let processes
|
|
77
|
+
try { processes = await processProbe() } catch { processes = 'unknown' }
|
|
78
|
+
if (processes === 'absent') return { state: 'absent', reason: 'no Obsidian process is running' }
|
|
79
|
+
if (versionUnchecked) return { state: 'uncoordinated', reason: 'app-version-unchecked: an Obsidian process may be running, and this adapter was qualified while none ran, so its version was never checked' }
|
|
80
|
+
try {
|
|
81
|
+
const reply = await readOnly({ op: 'inspect', vaultRoot, path: POLICY_SETTINGS_PATH })
|
|
82
|
+
// `path-unsafe` for the probe path still proves the app answered for this vault; the settings unit reports the path.
|
|
83
|
+
if (['inspected', 'path-unsafe'].includes(reply.status) && reply.vaultBasePath === vaultRoot) return { state: 'coordinated', reason: 'the app answered for this vault' }
|
|
84
|
+
return { state: 'uncoordinated', reason: reply.status === 'vault-mismatch' ? 'the app answered for another vault' : `the app answered ${reply.status}` }
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { state: 'uncoordinated', reason: `an Obsidian process may be running and the bridge did not answer: ${String(error.message || error).slice(0, 200)}` }
|
|
87
|
+
}
|
|
88
|
+
}),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// In-process transport: the same script, run here. Used for the path with no
|
|
94
|
+
// app, and by tests that supply a host modelling an app.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export function createInProcessCall(host = createInProcessHost()) {
|
|
98
|
+
return async (payload) => runInProcess(payload, host)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createDirectAdapter({ crashSeam } = {}) {
|
|
102
|
+
return createEditorAdapter({ call: createInProcessCall(createInProcessHost({ crashSeam })), processProbe: () => 'absent', kind: 'direct' })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Obsidian CLI transport
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export function defaultCliPath(platform = process.platform) {
|
|
110
|
+
if (platform === 'darwin') return '/Applications/Obsidian.app/Contents/MacOS/obsidian-cli'
|
|
111
|
+
return 'obsidian-cli'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The CLI reaches the app through a socket under $HOME, so `env` selects the
|
|
115
|
+
// app instance. It is passed explicitly and never edited here. A call that
|
|
116
|
+
// outlives its timeout is killed with SIGKILL: the CLI ignores SIGTERM while
|
|
117
|
+
// it waits on the app.
|
|
118
|
+
export function createObsidianCliCall({ cliPath = defaultCliPath(), env = process.env, timeoutMs = 20000 } = {}) {
|
|
119
|
+
return (payload) => new Promise((resolve, reject) => {
|
|
120
|
+
execFile(cliPath, ['eval', `code=${buildEvalCode(payload)}`], { env, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
121
|
+
if (error) return reject(error.killed ? new TransportTimeout(`CLI call timed out: ${payload.op}`) : new Error(`CLI call failed: ${String(error.message).slice(0, 300)}`))
|
|
122
|
+
const out = stdout || stderr || ''
|
|
123
|
+
const start = out.indexOf('=> ')
|
|
124
|
+
if (start < 0) return reject(new Error(`bridge returned no value: ${JSON.stringify(out.slice(0, 300))}`))
|
|
125
|
+
try { return resolve(JSON.parse(out.slice(start + 3))) } catch { return reject(new Error('bridge returned a value that is not JSON')) }
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The app is recognised by the executable a process runs, never by its
|
|
131
|
+
// arguments: a path argument with an `obsidian` segment (this package's own
|
|
132
|
+
// maintenance service, its private data root) is not the app. `ps -o comm=`
|
|
133
|
+
// names the executable as it was started on macOS, usually its full path, and
|
|
134
|
+
// by its command name of at most 15 characters on Linux.
|
|
135
|
+
//
|
|
136
|
+
// macOS `Obsidian` and the bundle's `Obsidian Helper…` processes, in any
|
|
137
|
+
// directory: a moved or renamed bundle still counts, and so does a
|
|
138
|
+
// process whose path ps cannot read and names by its short name.
|
|
139
|
+
// Linux `obsidian`, the executable of the .deb, snap, AppImage and Flatpak
|
|
140
|
+
// builds, by name or, for a name that is not recognised, by the
|
|
141
|
+
// executable it resolves to. A distribution that runs the app under
|
|
142
|
+
// a system Electron shows only `electron`, and which app it hosts is
|
|
143
|
+
// not known.
|
|
144
|
+
//
|
|
145
|
+
// The command-line tool is a client of the app, not the app: counting it would
|
|
146
|
+
// make every call this package makes through it look like a running app.
|
|
147
|
+
const APP_EXECUTABLE = Object.freeze({ darwin: /^obsidian(?: helper\b.*)?$/i, linux: /^obsidian$/i })
|
|
148
|
+
const ELECTRON_HOST = /^\.?electron(?:\d+|-wrap.*)?$/i
|
|
149
|
+
// A `ps` that has not answered by then is killed, and the reading is unknown.
|
|
150
|
+
const PS_TIMEOUT_MS = 5000
|
|
151
|
+
// A process that exited since the table was read, or whose executable this
|
|
152
|
+
// user may not read: another user's process, or one of this user's that hides
|
|
153
|
+
// it from tracing (ssh-agent, a sandboxed renderer). Such a process is judged
|
|
154
|
+
// by its name alone.
|
|
155
|
+
const EXECUTABLE_UNREADABLE = new Set(['ENOENT', 'ESRCH', 'EACCES', 'EPERM'])
|
|
156
|
+
|
|
157
|
+
const baseName = (text) => text.slice(text.lastIndexOf('/') + 1)
|
|
158
|
+
const readProcessExecutable = (pid) => readlinkSync(`/proc/${pid}/exe`)
|
|
159
|
+
|
|
160
|
+
// Linux names a process after the path it was started through, so an app
|
|
161
|
+
// started through a differently named link or launcher shows another name.
|
|
162
|
+
// The executable `/proc/<pid>/exe` resolves to does not change with the name.
|
|
163
|
+
// Any failure other than the expected ones is unknown, and so is a table in
|
|
164
|
+
// which no executable could be resolved at all: without `/proc`, nothing
|
|
165
|
+
// below the names can be established.
|
|
166
|
+
function resolveLinuxExecutables(rows, readExe) {
|
|
167
|
+
let resolved = 0
|
|
168
|
+
let unknown = false
|
|
169
|
+
for (const { pid } of rows) {
|
|
170
|
+
let target
|
|
171
|
+
try { target = String(readExe(pid)) } catch (error) {
|
|
172
|
+
if (!EXECUTABLE_UNREADABLE.has(error?.code)) unknown = true
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
resolved += 1
|
|
176
|
+
const name = baseName(target.replace(/ \(deleted\)$/, ''))
|
|
177
|
+
if (APP_EXECUTABLE.linux.test(name)) return 'running'
|
|
178
|
+
if (ELECTRON_HOST.test(name)) unknown = true
|
|
179
|
+
}
|
|
180
|
+
return unknown || resolved === 0 ? 'unknown' : 'absent'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Default process probe: 'absent' only when the process table was read and
|
|
184
|
+
// holds no Obsidian. Limits: it sees this machine's processes as this user
|
|
185
|
+
// can list them; it cannot see an app on another machine that reaches the
|
|
186
|
+
// vault through a shared or synchronized folder, an app packaged under another
|
|
187
|
+
// executable name, or an app that starts after the probe. Anything it cannot
|
|
188
|
+
// establish is 'unknown', which the adapter treats as a running app: an empty
|
|
189
|
+
// table, a failed or slow read, and on Linux a system Electron process or an
|
|
190
|
+
// executable that cannot be resolved for an unexpected reason.
|
|
191
|
+
export function defaultObsidianProcessProbe({ platform = process.platform, run = execFileSync, readExe = readProcessExecutable } = {}) {
|
|
192
|
+
if (!Object.hasOwn(APP_EXECUTABLE, platform)) return 'unknown'
|
|
193
|
+
const linux = platform === 'linux'
|
|
194
|
+
try {
|
|
195
|
+
const table = run('/bin/ps', ['-A', '-o', linux ? 'pid=,comm=' : 'comm='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024, timeout: PS_TIMEOUT_MS, killSignal: 'SIGKILL' })
|
|
196
|
+
if (typeof table !== 'string' || table.trim() === '') return 'unknown'
|
|
197
|
+
const rows = table.split('\n').map((line) => line.trim()).filter((line) => line !== '').map((line) => {
|
|
198
|
+
const match = linux ? /^(\d+)\s+(.*)$/.exec(line) : null
|
|
199
|
+
return match ? { pid: Number(match[1]), comm: match[2] } : { pid: null, comm: line }
|
|
200
|
+
})
|
|
201
|
+
if (rows.some((row) => APP_EXECUTABLE[platform].test(baseName(row.comm)))) return 'running'
|
|
202
|
+
if (!linux) return 'absent'
|
|
203
|
+
if (rows.some((row) => row.pid === null || ELECTRON_HOST.test(baseName(row.comm)))) return 'unknown'
|
|
204
|
+
return resolveLinuxExecutables(rows, readExe)
|
|
205
|
+
} catch {
|
|
206
|
+
return 'unknown'
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function createObsidianCliAdapter({ cliPath, env, timeoutMs, processProbe = () => defaultObsidianProcessProbe(), qualification } = {}) {
|
|
211
|
+
return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs }), processProbe, kind: 'obsidian-cli', qualification })
|
|
212
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Recovery state for Obsidian publication: the immutable store, the durable
|
|
2
|
+
// journal, restart recovery and the late-writer re-check.
|
|
3
|
+
export { EXCHANGE_CANDIDATE_NAME, LATE_EXCHANGE_CANDIDATE_NAME, PublicationRefusal, VAULT_LOCK_DIRECTORY, acquireVaultLock, createRecoveryStore, readFileDigest, sha256Digest } from './store.mjs'
|
|
4
|
+
export { JOURNAL_SCHEMA, createJournal, journalDetail, listJournals, newJournalId } from './journal.mjs'
|
|
5
|
+
export { classifyCandidateFile, namedCandidates, publishedSinceCommit, reconcileUnit, recoverPublications, retireStagedFile } from './restart.mjs'
|
|
6
|
+
export { recheckDisplacedFiles } from './late-writer.mjs'
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { isPendingPrivateWrite, publishPrivateFile } from '../../../project/durable-state.mjs'
|
|
5
|
+
import { readRegularTextNoFollow } from '../../../project/private-state.mjs'
|
|
6
|
+
import { OBSIDIAN_EXT_KEY, assertObsidianContract } from '../contracts.mjs'
|
|
7
|
+
import { refuse } from './store.mjs'
|
|
8
|
+
|
|
9
|
+
// Durable publication journal. The registered shape is one document; on disk
|
|
10
|
+
// it is a header plus one immutable file per entry, each published atomically
|
|
11
|
+
// under a name that is never overwritten, so an entry is either wholly present
|
|
12
|
+
// or absent and history cannot be rewritten in place. The document is
|
|
13
|
+
// assembled from those files and validated against the registered schema every
|
|
14
|
+
// time it is read or extended.
|
|
15
|
+
//
|
|
16
|
+
// Entry steps follow the schema: `capture` (the write-ahead record binding a
|
|
17
|
+
// note to its base, candidate, staged file and recovery path, written before
|
|
18
|
+
// anything is exchanged), `conditional-update` (the exchange, exclusive
|
|
19
|
+
// create or conditional removal and its outcome), `verify`, `manifest-commit`
|
|
20
|
+
// and `restart`. Each entry file also carries the journal state that holds
|
|
21
|
+
// after it.
|
|
22
|
+
|
|
23
|
+
export const JOURNAL_SCHEMA = 'atelier-obsidian-publication-journal/v1'
|
|
24
|
+
const ENTRY_FILE = /^\d{8}\.json$/
|
|
25
|
+
const CLOSED_FILE = 'closed.json'
|
|
26
|
+
|
|
27
|
+
const isoTime = (clock) => {
|
|
28
|
+
const value = clock()
|
|
29
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
30
|
+
if (Number.isNaN(date.getTime())) throw new TypeError('the clock did not return a time')
|
|
31
|
+
return date.toISOString()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function newJournalId(clock = () => new Date()) {
|
|
35
|
+
return `journal-${isoTime(clock).replace(/[-:.TZ]/g, '')}-${randomBytes(4).toString('hex')}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function load(directory) {
|
|
39
|
+
const header = JSON.parse(readRegularTextNoFollow(path.join(directory, 'header.json')))
|
|
40
|
+
const entriesDir = path.join(directory, 'entries')
|
|
41
|
+
const names = fs.existsSync(entriesDir) ? fs.readdirSync(entriesDir).filter((name) => !isPendingPrivateWrite(name)).sort() : []
|
|
42
|
+
const records = names.map((name, index) => {
|
|
43
|
+
if (!ENTRY_FILE.test(name) || Number(name.slice(0, 8)) !== index) refuse('journal-corrupt', 'journal entries are not a contiguous sequence; preserve and inspect')
|
|
44
|
+
const record = JSON.parse(readRegularTextNoFollow(path.join(entriesDir, name)))
|
|
45
|
+
if (record.entry?.seq !== index) refuse('journal-corrupt', 'a journal entry does not carry its own sequence number')
|
|
46
|
+
return record
|
|
47
|
+
})
|
|
48
|
+
return { header, records }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assemble({ header, records }) {
|
|
52
|
+
const restarts = records.filter((record) => record.entry.step === 'restart' && record.entry.ext?.[OBSIDIAN_EXT_KEY]?.phase === 'begin')
|
|
53
|
+
const document = {
|
|
54
|
+
...header,
|
|
55
|
+
state: records.length > 0 ? records.at(-1).state : 'prepared',
|
|
56
|
+
entries: records.map((record) => record.entry),
|
|
57
|
+
restart: { count: restarts.length, ...(restarts.length > 0 ? { lastRecoveredAt: restarts.at(-1).entry.at } : {}) },
|
|
58
|
+
}
|
|
59
|
+
return assertObsidianContract('publication-journal', document)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handle(store, directory, clock) {
|
|
63
|
+
// One writer at a time extends a journal (the publisher holds the view's
|
|
64
|
+
// lock), so the entries read once stay the entries on disk.
|
|
65
|
+
let cached = null
|
|
66
|
+
const current = () => (cached ??= load(directory))
|
|
67
|
+
const journal = {
|
|
68
|
+
directory,
|
|
69
|
+
document: () => assemble(current()),
|
|
70
|
+
// detail lands under the entry's ext container; the rest are schema fields.
|
|
71
|
+
append({ step, outcome, state, notePath, beforeDigest, afterDigest, recoveryRef, detail }) {
|
|
72
|
+
const loaded = current()
|
|
73
|
+
const entry = {
|
|
74
|
+
seq: loaded.records.length,
|
|
75
|
+
at: isoTime(clock),
|
|
76
|
+
step,
|
|
77
|
+
outcome,
|
|
78
|
+
...(notePath === undefined ? {} : { notePath }),
|
|
79
|
+
...(beforeDigest ? { beforeDigest } : {}),
|
|
80
|
+
...(afterDigest ? { afterDigest } : {}),
|
|
81
|
+
...(recoveryRef ? { recoveryRef } : {}),
|
|
82
|
+
...(detail ? { ext: { [OBSIDIAN_EXT_KEY]: detail } } : {}),
|
|
83
|
+
}
|
|
84
|
+
const record = { entry, state: state ?? (loaded.records.at(-1)?.state ?? 'prepared') }
|
|
85
|
+
assemble({ header: loaded.header, records: [record] })
|
|
86
|
+
try {
|
|
87
|
+
publishPrivateFile(path.join(directory, 'entries', `${String(entry.seq).padStart(8, '0')}.json`), `${JSON.stringify(record, null, 2)}\n`)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
cached = null
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
loaded.records.push(record)
|
|
93
|
+
return entry
|
|
94
|
+
},
|
|
95
|
+
// A journal that can no longer matter (committed, or superseded by a later
|
|
96
|
+
// commit) is closed so that later runs do not read it again. The flag file
|
|
97
|
+
// is advisory: a journal without one is simply read and judged again.
|
|
98
|
+
close() {
|
|
99
|
+
try { publishPrivateFile(path.join(directory, CLOSED_FILE), `${JSON.stringify({ state: assemble(current()).state })}\n`) } catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
return journal
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createJournal(store, { journalId, protocolId, expectedGeneration, targetGeneration, detail, clock = () => new Date() }) {
|
|
106
|
+
const directory = store.journalDir(journalId)
|
|
107
|
+
fs.mkdirSync(path.join(directory, 'entries'), { recursive: true, mode: 0o700 })
|
|
108
|
+
const header = {
|
|
109
|
+
schema: JOURNAL_SCHEMA,
|
|
110
|
+
contractVersion: '1.0.0',
|
|
111
|
+
journalId,
|
|
112
|
+
workspaceId: store.workspaceId,
|
|
113
|
+
scopeId: store.scopeId,
|
|
114
|
+
protocolId,
|
|
115
|
+
expectedGeneration,
|
|
116
|
+
targetGeneration,
|
|
117
|
+
...(detail ? { ext: { [OBSIDIAN_EXT_KEY]: detail } } : {}),
|
|
118
|
+
}
|
|
119
|
+
assemble({ header, records: [] })
|
|
120
|
+
publishPrivateFile(path.join(directory, 'header.json'), `${JSON.stringify(header, null, 2)}\n`)
|
|
121
|
+
return handle(store, directory, clock)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Journals of this view, oldest first. A directory without a header is a
|
|
125
|
+
// journal that never began: nothing can refer to it.
|
|
126
|
+
export function listJournals(store, { clock = () => new Date(), openOnly = false } = {}) {
|
|
127
|
+
if (!fs.existsSync(store.journalsRoot)) return []
|
|
128
|
+
return fs.readdirSync(store.journalsRoot).sort()
|
|
129
|
+
.filter((name) => fs.existsSync(path.join(store.journalsRoot, name, 'header.json')))
|
|
130
|
+
.filter((name) => !openOnly || !fs.existsSync(path.join(store.journalsRoot, name, CLOSED_FILE)))
|
|
131
|
+
.map((name) => handle(store, path.join(store.journalsRoot, name), clock))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const journalDetail = (value) => value?.ext?.[OBSIDIAN_EXT_KEY] ?? {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { listJournals } from './journal.mjs'
|
|
4
|
+
import { readFileBytes, sha256Digest } from './store.mjs'
|
|
5
|
+
|
|
6
|
+
// A program that opened a note before it was exchanged still holds the
|
|
7
|
+
// displaced file, and may write into it at any later time. Those bytes land in
|
|
8
|
+
// the recovery area, not in the vault, so nothing shows them to the person
|
|
9
|
+
// unless the displaced files are looked at again. This compares every
|
|
10
|
+
// displaced file with the digest recorded when it was moved. A difference is
|
|
11
|
+
// kept as an immutable object and reported as `late-writer-captured`.
|
|
12
|
+
//
|
|
13
|
+
// The check is meaningful only after a quiet period, and one pass does not end
|
|
14
|
+
// the obligation: a holder can write later still. Callers repeat it.
|
|
15
|
+
export function recheckDisplacedFiles({ store, journalIds, clock = () => new Date() } = {}) {
|
|
16
|
+
const ids = journalIds ?? listJournals(store).map((journal) => journal.document().journalId)
|
|
17
|
+
const findings = []
|
|
18
|
+
for (const journalId of ids) {
|
|
19
|
+
const receipts = store.listReceipts(journalId)
|
|
20
|
+
const known = new Set(receipts.filter((receipt) => receipt.role === 'late-writer').map((receipt) => `${receipt.unit}\u0000${receipt.observedDigest}`))
|
|
21
|
+
for (const receipt of receipts.filter((item) => item.role === 'displaced')) {
|
|
22
|
+
// A recovery name that is a hard link to the live note (an interrupted
|
|
23
|
+
// put-back) is the note itself: edits to it are not a late writer's.
|
|
24
|
+
const live = receipt.notePath ? fs.lstatSync(path.join(store.vaultRoot, receipt.notePath), { throwIfNoEntry: false }) : null
|
|
25
|
+
const held = fs.lstatSync(store.resolve(receipt.displacedRef), { throwIfNoEntry: false })
|
|
26
|
+
if (live && held && live.ino === held.ino && live.dev === held.dev) continue
|
|
27
|
+
let bytes
|
|
28
|
+
try { bytes = readFileBytes(store.resolve(receipt.displacedRef)) } catch (error) {
|
|
29
|
+
if (error.code !== 'ENOENT') throw error
|
|
30
|
+
findings.push({ code: 'displaced-file-missing', journalId, unit: receipt.unit, notePath: receipt.notePath, displacedRef: receipt.displacedRef })
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
const observedDigest = sha256Digest(bytes)
|
|
34
|
+
if (observedDigest === receipt.digestAtMove) continue
|
|
35
|
+
const object = store.retainObject(bytes)
|
|
36
|
+
const finding = { code: 'late-writer-captured', journalId, unit: receipt.unit, notePath: receipt.notePath, displacedRef: receipt.displacedRef, digestAtMove: receipt.digestAtMove, observedDigest, objectRef: object.ref }
|
|
37
|
+
if (!known.has(`${receipt.unit}\u0000${observedDigest}`)) {
|
|
38
|
+
const value = clock()
|
|
39
|
+
store.writeReceipt(journalId, receipt.unit, { role: 'late-writer', notePath: receipt.notePath, displacedRef: receipt.displacedRef, digestAtMove: receipt.digestAtMove, observedDigest, objectRef: object.ref,
|
|
40
|
+
at: (value instanceof Date ? value : new Date(value)).toISOString() })
|
|
41
|
+
}
|
|
42
|
+
findings.push(finding)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return findings
|
|
46
|
+
}
|