@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +372 -0
- package/README.md +402 -205
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/contracts/public-api-baseline.json +57 -0
- package/docs/assurance-controls.md +49 -0
- package/docs/atelier-runtime.md +28 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/audience-visibility.md +9 -0
- package/docs/blocks/claims.md +61 -0
- package/docs/blocks/conformance-admission.md +15 -0
- package/docs/blocks/will-not-do.md +18 -0
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/continuity.md +3 -4
- package/docs/decisions.md +167 -0
- package/docs/design.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +99 -16
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/knowledge-graph.md +8 -4
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +328 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +690 -0
- package/docs/obsidian.md +423 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +174 -12
- package/docs/repo-boundary-guard.md +12 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +146 -5
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
- package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +86 -10
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-local-service/SKILL.md +47 -0
- package/skills/claude/atelier-open/SKILL.md +11 -5
- package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-local-service/SKILL.md +47 -0
- package/skills/codex/atelier-open/SKILL.md +11 -5
- package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/content-rules.mjs +283 -20
- package/src/boundary/policy.mjs +213 -91
- package/src/cli/execute-command.mjs +36 -0
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +78 -10
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +348 -0
- package/src/collaboration/index.mjs +17 -0
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/proposals.mjs +265 -65
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/attestation.mjs +20 -6
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/disclosure.mjs +133 -0
- package/src/commands/distribution.mjs +2 -1
- package/src/commands/extension-pack.mjs +17 -9
- package/src/commands/init.mjs +46 -6
- package/src/commands/obsidian.mjs +371 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/server.mjs +1 -4
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +107 -0
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +35 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +206 -0
- package/src/egress/check.mjs +7 -38
- package/src/egress/forbidden-egress.mjs +32 -18
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +176 -316
- package/src/graph/knowledge-graph.mjs +643 -43
- package/src/guides/contracts.mjs +54 -0
- package/src/harness/context-client.mjs +9 -1
- package/src/index.mjs +41 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +112 -52
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +180 -0
- package/src/project/package-root.mjs +10 -0
- package/src/project/path-match.mjs +38 -15
- package/src/project/private-state.mjs +120 -0
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +160 -0
- package/src/projection/obsidian/recovery/index.mjs +6 -0
- package/src/projection/obsidian/recovery/journal.mjs +134 -0
- package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
- package/src/projection/obsidian/recovery/restart.mjs +316 -0
- package/src/projection/obsidian/recovery/store.mjs +234 -0
- package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
- package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
- package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
- package/src/projection/obsidian/selection-ui/index.mjs +12 -0
- package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
- package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
- package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
- package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
- package/src/projection/project.mjs +28 -19
- package/src/readiness-protocols/evidence.mjs +282 -0
- package/src/readiness-protocols/runtime.mjs +4 -3
- package/src/readiness-protocols/source-read.mjs +39 -0
- package/src/runtime/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/obsidian/app-capability.mjs +129 -0
- package/src/runtime/obsidian/app-production-seams.mjs +105 -0
- package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
- package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
- package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
- package/src/runtime/obsidian/contributions.mjs +27 -0
- package/src/runtime/obsidian/documents.mjs +37 -0
- package/src/runtime/obsidian/enablement.mjs +53 -0
- package/src/runtime/obsidian/engine.mjs +532 -0
- package/src/runtime/obsidian/errors.mjs +14 -0
- package/src/runtime/obsidian/extension-points.mjs +103 -0
- package/src/runtime/obsidian/index.mjs +34 -0
- package/src/runtime/obsidian/lifecycle.mjs +258 -0
- package/src/runtime/obsidian/machine-settings.mjs +260 -0
- package/src/runtime/obsidian/observation.mjs +158 -0
- package/src/runtime/obsidian/opening.mjs +263 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-entry-path.mjs +9 -0
- package/src/runtime/obsidian/service-main.mjs +91 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- package/src/server/local-sidecar.mjs +125 -59
- package/src/server/security.mjs +89 -4
- package/src/server/server.mjs +4 -2
- package/src/skills/steward.mjs +780 -0
- package/src/support/feedback-report.mjs +4 -3
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +65 -29
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/project/README.md +2 -2
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { DOC_EXTENSIONS } from '../../graph/knowledge-graph.mjs'
|
|
5
|
+
import { localOverlayCandidatePaths } from '../../project/config.mjs'
|
|
6
|
+
import { openRegularFileNoFollow } from '../../project/private-state.mjs'
|
|
7
|
+
import { compareText } from './documents.mjs'
|
|
8
|
+
|
|
9
|
+
// Observation by digest. The git-based observation of the synchronization
|
|
10
|
+
// loop sees only what git tracks inside a repository; a vault is outside every
|
|
11
|
+
// repository and ignored by design, so this module looks at files directly and
|
|
12
|
+
// never asks git anything.
|
|
13
|
+
//
|
|
14
|
+
// Three layers, each only ever an accelerator for the next:
|
|
15
|
+
//
|
|
16
|
+
// watcher event names a path to look at now. Events may be lost, repeated
|
|
17
|
+
// or late on every platform; nothing is decided from one.
|
|
18
|
+
// stat hint size, modification time, change time and inode. A
|
|
19
|
+
// difference means "hash this file"; equality means "skip it
|
|
20
|
+
// on this tick". A hint never reports a change by itself.
|
|
21
|
+
// digest the only thing that decides. A full reconciliation hashes
|
|
22
|
+
// every observed file whatever its stat says, so a change
|
|
23
|
+
// the first two layers missed converges there.
|
|
24
|
+
|
|
25
|
+
export const sha256Digest = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`
|
|
26
|
+
|
|
27
|
+
const SKIPPED_DIRECTORIES = new Set(['node_modules'])
|
|
28
|
+
const SIDECAR_SUFFIX = '.kg.json'
|
|
29
|
+
|
|
30
|
+
export const sourceKey = (repoId, relative) => `source\u0000${repoId}\u0000${relative}`
|
|
31
|
+
export const configKey = (absolute) => `config\u0000${absolute}`
|
|
32
|
+
export const vaultKey = (scopeId, relative) => `vault\u0000${scopeId}\u0000${relative}`
|
|
33
|
+
|
|
34
|
+
function walk(root, relative, found) {
|
|
35
|
+
let entries
|
|
36
|
+
try { entries = fs.readdirSync(path.join(root, relative), { withFileTypes: true }) } catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return found
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
for (const entry of entries.sort((left, right) => compareText(left.name, right.name))) {
|
|
41
|
+
if (entry.name.startsWith('.') || SKIPPED_DIRECTORIES.has(entry.name)) continue
|
|
42
|
+
const child = relative === '' ? entry.name : `${relative}/${entry.name}`
|
|
43
|
+
// Links are not followed: a link could lead outside the repository.
|
|
44
|
+
if (entry.isDirectory()) walk(root, child, found)
|
|
45
|
+
else if (entry.isFile()) found.push(child)
|
|
46
|
+
}
|
|
47
|
+
return found
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sourceClass(relative, names) {
|
|
51
|
+
if (relative.endsWith(SIDECAR_SUFFIX)) return 'sidecar'
|
|
52
|
+
const extension = path.posix.extname(relative).toLowerCase()
|
|
53
|
+
if (extension === '.md') return 'source-body'
|
|
54
|
+
// Any other file is a source only when the census could enrol it: a document
|
|
55
|
+
// extension, or a file with a sidecar beside it.
|
|
56
|
+
return DOC_EXTENSIONS.has(extension) || names.has(`${relative}${SIDECAR_SUFFIX}`) ? 'asset' : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Every file whose bytes can change a view: source bodies, sidecars and the
|
|
60
|
+
// assets they enrol, in every enrolled repository. Deliberately a superset of
|
|
61
|
+
// the census (it does not apply ignore rules): observing one file too many
|
|
62
|
+
// costs a rebuild that produces the same generation, observing one too few
|
|
63
|
+
// would leave a view stale.
|
|
64
|
+
export function listSourceFiles(project, { assets = [] } = {}) {
|
|
65
|
+
const files = []
|
|
66
|
+
const roots = new Map()
|
|
67
|
+
for (const repo of project.repos ?? []) {
|
|
68
|
+
if (repo.external || typeof repo.path !== 'string' || typeof repo.name !== 'string') continue
|
|
69
|
+
roots.set(repo.name, repo.path)
|
|
70
|
+
const relatives = walk(repo.path, '', [])
|
|
71
|
+
const names = new Set(relatives)
|
|
72
|
+
for (const relative of relatives) {
|
|
73
|
+
const changeClass = sourceClass(relative, names)
|
|
74
|
+
if (changeClass) files.push({ key: sourceKey(repo.name, relative), changeClass, absolute: path.join(repo.path, relative), repoId: repo.name, relative })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Embedded assets of the last built graph. The resolver accepts a file in a
|
|
78
|
+
// dot-directory, which the walk skips, and an image has no sidecar, so these
|
|
79
|
+
// are listed by name. Only assets a view may copy are passed in.
|
|
80
|
+
const listed = new Set(files.map((file) => file.key))
|
|
81
|
+
for (const asset of assets) {
|
|
82
|
+
const key = sourceKey(asset.repo, asset.path)
|
|
83
|
+
if (!roots.has(asset.repo) || listed.has(key)) continue
|
|
84
|
+
listed.add(key)
|
|
85
|
+
files.push({ key, changeClass: 'asset', absolute: path.join(roots.get(asset.repo), ...asset.path.split('/')), repoId: asset.repo, relative: asset.path })
|
|
86
|
+
}
|
|
87
|
+
return files
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The files that decide what a project is: its configuration, its repository
|
|
91
|
+
// access file and every place a local overlay may appear. A path that does
|
|
92
|
+
// not exist yet is observed too, so its appearance is a change.
|
|
93
|
+
export function listConfigFiles(project) {
|
|
94
|
+
const paths = new Set([project.configPath, project.repoAccessPath, ...localOverlayCandidatePaths({ configDir: project.configDir, env: {} }), ...(project.localOverlay?.paths ?? [])].filter((item) => typeof item === 'string'))
|
|
95
|
+
return [...paths].sort().map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function listVaultNotes({ scopeId, vaultRoot, manifest }) {
|
|
99
|
+
return (manifest?.notes ?? []).map((note) => ({ key: vaultKey(scopeId, note.path), changeClass: 'vault-note', absolute: path.join(vaultRoot, ...note.path.split('/')), scopeId, relative: note.path }))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function statHint(file, lstat) {
|
|
103
|
+
let stat
|
|
104
|
+
try { stat = lstat(file) } catch (error) {
|
|
105
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
return stat.isFile() ? { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ino: stat.ino } : null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// One read: the digest and the length are of the same bytes.
|
|
112
|
+
export function readFileFacts(file) {
|
|
113
|
+
let descriptor
|
|
114
|
+
try { descriptor = openRegularFileNoFollow(file) } catch (error) {
|
|
115
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'ELOOP' || error.message === 'state leaf is not a regular file') return null
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const bytes = fs.readFileSync(descriptor)
|
|
120
|
+
return { digest: sha256Digest(bytes), byteLength: bytes.length }
|
|
121
|
+
} finally { fs.closeSync(descriptor) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sameHint = (left, right) => left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.ino === right.ino
|
|
125
|
+
|
|
126
|
+
// Reconciles `files` against `index` (a Map owned by the caller) and returns
|
|
127
|
+
// the changes, decided by digest alone. `full` hashes everything; otherwise a
|
|
128
|
+
// file is hashed when it is new, when its stat hint differs or when a watcher
|
|
129
|
+
// named it. Entries under `prefix` that are no longer listed are removals.
|
|
130
|
+
export function reconcile({ index, files, prefix, full, hinted = () => false, lstat = fs.lstatSync }) {
|
|
131
|
+
const changes = []
|
|
132
|
+
const present = new Set()
|
|
133
|
+
let hashed = 0
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
present.add(file.key)
|
|
136
|
+
const previous = index.get(file.key)
|
|
137
|
+
const hint = statHint(file.absolute, lstat)
|
|
138
|
+
if (hint === null) {
|
|
139
|
+
if (previous && previous.digest !== null) changes.push({ key: file.key, changeClass: previous.changeClass, kind: 'removed' })
|
|
140
|
+
index.set(file.key, { changeClass: file.changeClass, hint: null, digest: null, byteLength: null })
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key)) continue
|
|
144
|
+
const facts = readFileFacts(file.absolute)
|
|
145
|
+
const digest = facts?.digest ?? null
|
|
146
|
+
hashed += 1
|
|
147
|
+
if (!previous || previous.digest !== digest || previous.changeClass !== file.changeClass) {
|
|
148
|
+
if (digest !== null || (previous && previous.digest !== null)) changes.push({ key: file.key, changeClass: file.changeClass, kind: !previous || previous.digest === null ? 'added' : digest === null ? 'removed' : 'changed' })
|
|
149
|
+
}
|
|
150
|
+
index.set(file.key, { changeClass: file.changeClass, hint: digest === null ? null : hint, digest, byteLength: facts?.byteLength ?? null })
|
|
151
|
+
}
|
|
152
|
+
for (const [key, previous] of index) {
|
|
153
|
+
if (!key.startsWith(prefix) || present.has(key)) continue
|
|
154
|
+
if (previous.digest !== null) changes.push({ key, changeClass: previous.changeClass, kind: 'removed' })
|
|
155
|
+
index.delete(key)
|
|
156
|
+
}
|
|
157
|
+
return { changes, hashed }
|
|
158
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { createRecoveryStore as createStore, readFileBytes, sha256Digest } from '../../projection/obsidian/recovery/store.mjs'
|
|
4
|
+
import { inspectApp, qualifyApp } from './app-capability.mjs'
|
|
5
|
+
import { readObsidianEnablement } from './enablement.mjs'
|
|
6
|
+
import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
|
|
7
|
+
import { UNAVAILABLE_APPLY_OPERATION } from './extension-points.mjs'
|
|
8
|
+
import { LIFECYCLE_PRIMITIVES, requestServiceTick, serviceStatus, startService } from './lifecycle.mjs'
|
|
9
|
+
import { protectedRoots } from './machine-settings.mjs'
|
|
10
|
+
import { trustedNoteBases } from './pending-edits.mjs'
|
|
11
|
+
import { resolveServiceWorkspace } from './service.mjs'
|
|
12
|
+
import { OPEN_EDIT_STATES, createMaintenanceStateStore } from './state-store.mjs'
|
|
13
|
+
|
|
14
|
+
// Opening a view, and saying honestly how fresh it is.
|
|
15
|
+
//
|
|
16
|
+
// Every answer is one of the typed outcomes below. Only `current` is success,
|
|
17
|
+
// and it means all of:
|
|
18
|
+
//
|
|
19
|
+
// - the owned maintenance service is healthy and ran a tick after this
|
|
20
|
+
// request, so the generation it prepared comes from the present sources;
|
|
21
|
+
// - the persisted freshness of the view says `current`, which the state
|
|
22
|
+
// store only accepts for a verified generation equal to the prepared one;
|
|
23
|
+
// - read back here, independently: the trusted pointer of the view names
|
|
24
|
+
// that same generation and every note in the vault has the bytes that
|
|
25
|
+
// generation was published with;
|
|
26
|
+
// - the installed app is present, has its command-line capability, meets
|
|
27
|
+
// the minimum version, was asked to open the vault, answers for exactly
|
|
28
|
+
// this vault and has finished reading it.
|
|
29
|
+
//
|
|
30
|
+
// Nothing here talks to an app or an operating system itself: `appProbe` and
|
|
31
|
+
// `launcher` are injected, and there is no default for either.
|
|
32
|
+
|
|
33
|
+
export const OPENING_OUTCOMES = Object.freeze({
|
|
34
|
+
current: { summary: 'the vault is the present generation, verified by read-back, and the app has it open', next: 'nothing to do' },
|
|
35
|
+
updating: { summary: 'maintenance is publishing or has not finished; the vault is not confirmed current', next: 'run `obsidian open` again in a moment' },
|
|
36
|
+
'held-for-your-edit': { summary: 'a note you edited is preserved and held; the view is not republished over it', next: 'apply or withdraw the pending edit, then open again' },
|
|
37
|
+
'stale-readable': { summary: 'a last good vault exists and can be read, but it is not proven to be the present generation', next: 'see `obsidian status` for the reason; `obsidian open --allow-stale` opens it as it is' },
|
|
38
|
+
'not-prepared': { summary: 'no generation of this view has been published yet', next: 'see `obsidian status` for the reason' },
|
|
39
|
+
'app-missing': { summary: 'no Obsidian installation was found', next: 'install Obsidian, then open again' },
|
|
40
|
+
'app-version-unsupported': { summary: 'the installed Obsidian is below the minimum supported version, or its version cannot be read', next: 'update Obsidian, then open again' },
|
|
41
|
+
'app-cli-unavailable': { summary: 'the installed Obsidian has no usable command-line capability', next: 'enable the command-line interface in Obsidian, then open again' },
|
|
42
|
+
indexing: { summary: 'the app answered for this vault but has not finished reading it', next: 'wait for Obsidian to finish indexing, then open again' },
|
|
43
|
+
'publisher-conflict': { summary: 'another publisher or an uncoordinated editor holds this vault', next: 'close the other publisher or let it finish; it is retried automatically' },
|
|
44
|
+
'launch-failed': { summary: 'the operating system or the app did not open this vault', next: 'open the vault folder in Obsidian once by hand, then open again' },
|
|
45
|
+
'service-unavailable': { summary: 'the maintenance service of this workspace could not be started or reached as ours', next: 'see `obsidian service status`' },
|
|
46
|
+
busy: { summary: 'the maintenance service runs but is in a long tick and did not answer in time', next: 'run `obsidian open` again in a moment; nothing was stopped or restarted' },
|
|
47
|
+
disabled: { summary: 'the Obsidian integration is not enabled for this project, or this view is not configured', next: 'enable it in the project configuration' },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// A sub-state of pending edits, never an opening failure.
|
|
51
|
+
export const APPLY_UNAVAILABLE = 'apply-unavailable'
|
|
52
|
+
|
|
53
|
+
export function describeOutcome(code) {
|
|
54
|
+
if (!Object.hasOwn(OPENING_OUTCOMES, code)) throw new TypeError(`unknown opening outcome: ${String(code)}`)
|
|
55
|
+
return { outcome: code, ...OPENING_OUTCOMES[code] }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A reason whose next step is not its outcome's. With no vault open the app's
|
|
59
|
+
// command line answers nothing, its version included, so the version floor
|
|
60
|
+
// cannot be checked until a vault is open or the app is quit.
|
|
61
|
+
// `editor-uncoordinated` is the publisher refusing to write while an Obsidian
|
|
62
|
+
// runs that does not answer for this vault: nothing coordinates with the app,
|
|
63
|
+
// so nothing is written. There is no other publisher to wait for. With the app
|
|
64
|
+
// quit, the view is published directly, and `open` then starts the app on it.
|
|
65
|
+
export const REASON_NEXT = Object.freeze({
|
|
66
|
+
'no-vault-open': 'open any vault in Obsidian, or quit Obsidian, then open again',
|
|
67
|
+
'editor-uncoordinated': 'Obsidian is running without this vault open, so nothing is written into it: quit Obsidian, let the view publish, then open again',
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// The next step for an outcome and its reason; null for a code that is not an opening outcome.
|
|
71
|
+
export function nextStep(outcome, reason) {
|
|
72
|
+
if (typeof reason === 'string' && Object.hasOwn(REASON_NEXT, reason)) return REASON_NEXT[reason]
|
|
73
|
+
return Object.hasOwn(OPENING_OUTCOMES, outcome) ? OPENING_OUTCOMES[outcome].next : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The decisions the opening oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
|
|
77
|
+
export const OPENING_PRIMITIVES = Object.freeze({
|
|
78
|
+
// The freshness states that are their own outcome.
|
|
79
|
+
outcomeOfState: (state) => (['updating', 'held-for-your-edit', 'publisher-conflict', 'disabled'].includes(state) ? state : null),
|
|
80
|
+
// Whether a persisted `current` may be believed after reading the vault back.
|
|
81
|
+
readBackAgrees: ({ entry, verification }) => verification.readable && verification.generationId === entry.preparedGenerationId && verification.intact,
|
|
82
|
+
// Whether the app may be shown this vault at all.
|
|
83
|
+
appQualifies: (qualification) => qualification.outcome === 'qualified',
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const segment = (identifier) => identifier.replaceAll(':', '_')
|
|
87
|
+
const STORE_AREAS = (scopeId) => [['vaults', segment(scopeId)], ['state', 'manifests', segment(scopeId)], ['state', 'journals', segment(scopeId)], ['state', 'locks'], ['recovery', 'objects'], ['staging']]
|
|
88
|
+
|
|
89
|
+
// Reads the trusted pointer of a view and every note of its vault. Read-only:
|
|
90
|
+
// the store is only constructed when everything it would create already exists.
|
|
91
|
+
export function readBackTrustedGeneration({ workspaceRoot, workspaceId, scopeId, repositoryRoots, createRecoveryStore = createStore }) {
|
|
92
|
+
const unreadable = (reason) => ({ readable: false, reason, generationId: null, intact: false, noteCount: 0, differing: 0, missing: 0 })
|
|
93
|
+
if (!STORE_AREAS(scopeId).every((parts) => fs.existsSync(path.join(workspaceRoot, ...parts)))) return unreadable('no-published-vault')
|
|
94
|
+
let store
|
|
95
|
+
let trusted
|
|
96
|
+
try {
|
|
97
|
+
store = createRecoveryStore({ workspaceRoot, workspaceId, scopeId, repositoryRoots })
|
|
98
|
+
trusted = { pointer: store.readCurrent(), ...trustedNoteBases(store) }
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (typeof error?.code === 'string') return unreadable(error.code)
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
if (!trusted.pointer || !trusted.manifest) return unreadable('no-trusted-generation')
|
|
104
|
+
let differing = 0
|
|
105
|
+
let missing = 0
|
|
106
|
+
for (const [notePath, base] of trusted.bases) {
|
|
107
|
+
let bytes
|
|
108
|
+
try { bytes = readFileBytes(path.join(store.vaultRoot, ...notePath.split('/'))) } catch (error) {
|
|
109
|
+
if (error.code === 'ENOENT') { missing += 1; continue }
|
|
110
|
+
return unreadable(error.code ?? 'vault-unreadable')
|
|
111
|
+
}
|
|
112
|
+
if (sha256Digest(bytes) !== base.digest) differing += 1
|
|
113
|
+
}
|
|
114
|
+
return { readable: true, reason: 'read-back', generationId: trusted.pointer.generationId, intact: differing === 0 && missing === 0, noteCount: trusted.bases.size, differing, missing, vaultRoot: store.vaultRoot }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pendingSummary(stateStore, scopeId, applyAvailable) {
|
|
118
|
+
const open = stateStore.readPendingEdits().edits.filter((edit) => edit.scopeId === scopeId && OPEN_EDIT_STATES.includes(edit.state))
|
|
119
|
+
const byState = Object.fromEntries(OPEN_EDIT_STATES.map((state) => [state, open.filter((edit) => edit.state === state).length]))
|
|
120
|
+
// Said whenever no apply operation exists, not only after a dispatch found that out.
|
|
121
|
+
return { open: open.length, byState, apply: applyAvailable ? 'available' : APPLY_UNAVAILABLE }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The honest freshness of one view, from persisted state plus a read-back.
|
|
125
|
+
// `serviceState` is the proven lifecycle state; a persisted `current` is not
|
|
126
|
+
// repeated as `current` while nothing proves maintenance is looking.
|
|
127
|
+
export function scopeReport({ workspace, scopeId, repositoryRoots, serviceState, applyAvailable = false, freshness = undefined, createRecoveryStore }, rules = OPENING_PRIMITIVES) {
|
|
128
|
+
const stateStore = createMaintenanceStateStore(workspace)
|
|
129
|
+
let document = freshness
|
|
130
|
+
let unreadableCode = null
|
|
131
|
+
if (document === undefined) {
|
|
132
|
+
try { document = stateStore.readFreshness() } catch (error) { if (!(error instanceof ObsidianMaintenanceRefusal)) throw error; document = null; unreadableCode = error.code }
|
|
133
|
+
}
|
|
134
|
+
const entry = document?.scopes.find((item) => item.scopeId === scopeId) ?? null
|
|
135
|
+
const verification = readBackTrustedGeneration({ ...workspace, scopeId, repositoryRoots, ...(createRecoveryStore ? { createRecoveryStore } : {}) })
|
|
136
|
+
const lastGood = verification.readable ? 'stale-readable' : 'not-prepared'
|
|
137
|
+
let outcome
|
|
138
|
+
let reason
|
|
139
|
+
if (entry === null) { outcome = lastGood; reason = unreadableCode ?? 'no-freshness-recorded' }
|
|
140
|
+
else if (rules.outcomeOfState(entry.state) !== null) { outcome = rules.outcomeOfState(entry.state); reason = entry.reason }
|
|
141
|
+
else if (entry.state !== 'current') { outcome = lastGood; reason = entry.reason }
|
|
142
|
+
else if (!rules.readBackAgrees({ entry, verification })) {
|
|
143
|
+
// Notes that differ are an edit the engine has not looked at yet; a pointer that differs is another generation.
|
|
144
|
+
const edited = verification.readable && verification.generationId === entry.preparedGenerationId
|
|
145
|
+
outcome = edited ? 'updating' : lastGood
|
|
146
|
+
reason = edited ? 'vault-differs-from-trusted-generation' : verification.readable ? 'trusted-generation-differs' : verification.reason
|
|
147
|
+
} else if (serviceState !== 'healthy') { outcome = 'stale-readable'; reason = serviceState === 'busy' ? 'maintenance-busy-not-rechecked' : 'maintenance-not-running' }
|
|
148
|
+
else { outcome = 'current'; reason = entry.reason }
|
|
149
|
+
return {
|
|
150
|
+
scopeId, ...describeOutcome(outcome), reason,
|
|
151
|
+
freshness: entry === null ? null : { state: entry.state, reason: entry.reason, verified: entry.verified, generationId: entry.generationId, preparedGenerationId: entry.preparedGenerationId, heldNoteCount: entry.heldNotes.length, retainedEdits: entry.retainedEdits, checkedAt: entry.checkedAt },
|
|
152
|
+
readBack: { readable: verification.readable, reason: verification.reason, generationId: verification.generationId, intact: verification.intact, noteCount: verification.noteCount, differing: verification.differing, missing: verification.missing },
|
|
153
|
+
pendingEdits: pendingSummary(stateStore, scopeId, applyAvailable),
|
|
154
|
+
vaultRoot: verification.vaultRoot ?? null,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function resolveScope(enablement, requested) {
|
|
159
|
+
const scopeId = requested ?? enablement.defaultScopeId ?? enablement.scopes[0]?.scopeId ?? null
|
|
160
|
+
if (scopeId === null) refuse('unknown-scope', 'this project declares no view to open')
|
|
161
|
+
if (!enablement.scopes.some((scope) => scope.scopeId === scopeId)) refuse('unknown-scope', 'no declared view has this identity', { scopeId: String(scopeId).slice(0, 128) })
|
|
162
|
+
return scopeId
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const defaultSleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms) })
|
|
166
|
+
const READABLE = new Set(['stale-readable', 'held-for-your-edit', 'updating', 'publisher-conflict'])
|
|
167
|
+
// After a launch: the app is still starting, or still opening the vault it was asked for.
|
|
168
|
+
const NOT_UP_YET = new Set(['version-unknown', 'no-vault-open'])
|
|
169
|
+
|
|
170
|
+
// Starts or reconnects the owned service, asks it for a tick, reads the view
|
|
171
|
+
// back, qualifies the app, has the vault opened and reports one outcome.
|
|
172
|
+
export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIMITIVES, lifecycleRules = LIFECYCLE_PRIMITIVES) {
|
|
173
|
+
const {
|
|
174
|
+
loadProject, dataRoot, scopeId: requestedScope, appProbe, launcher, service = {}, consent, allowStale = false, extensions = null,
|
|
175
|
+
tickTimeoutMs = 120 * 1000, appWaitMs = 30 * 1000, appPollMs = 500, sleep = defaultSleep, monotonic = () => Date.now(),
|
|
176
|
+
env = process.env, platform = process.platform, probeTimeoutMs,
|
|
177
|
+
} = options
|
|
178
|
+
if (typeof loadProject !== 'function') throw new TypeError('open needs loadProject')
|
|
179
|
+
// No defaults: reaching an app or the operating system is a decision of the entry that composes this.
|
|
180
|
+
if (typeof appProbe?.inspect !== 'function' || typeof appProbe?.vaultState !== 'function') throw new TypeError('open needs an injected appProbe')
|
|
181
|
+
if (typeof launcher?.open !== 'function') throw new TypeError('open needs an injected launcher')
|
|
182
|
+
if (typeof service.entryPath !== 'string') throw new TypeError('open needs the service entry it may start')
|
|
183
|
+
|
|
184
|
+
const project = loadProject()
|
|
185
|
+
const enablement = readObsidianEnablement(project)
|
|
186
|
+
const applyAvailable = extensions !== null && extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
|
|
187
|
+
const finish = (outcome, extra = {}) => ({ ok: outcome === 'current', ...describeOutcome(outcome), next: nextStep(outcome, extra.reason), launched: false, ...extra })
|
|
188
|
+
if (enablement.state === 'disabled') return finish('disabled', { reason: enablement.reason, scopeId: requestedScope ?? null })
|
|
189
|
+
const scopeId = resolveScope(enablement, requestedScope)
|
|
190
|
+
const lifecycle = { loadProject, dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
|
|
191
|
+
|
|
192
|
+
// 1. The owned service: reconnect, or start. Never adopt, never replace.
|
|
193
|
+
let status = await serviceStatus(lifecycle, lifecycleRules)
|
|
194
|
+
if (status.state === 'busy') return finish('busy', { scopeId, reason: status.reason, service: { state: status.state } })
|
|
195
|
+
if (status.state !== 'healthy') {
|
|
196
|
+
let started
|
|
197
|
+
try {
|
|
198
|
+
started = await startService({ ...lifecycle, detached: true, ...service, ...(consent === undefined ? {} : { consent }) }, lifecycleRules)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (!(error instanceof ObsidianMaintenanceRefusal)) throw error
|
|
201
|
+
return finish('service-unavailable', { scopeId, reason: error.code, service: { state: status.state } })
|
|
202
|
+
}
|
|
203
|
+
if (started.state === 'busy') return finish('busy', { scopeId, reason: started.reason, service: { state: 'busy', started: started.started } })
|
|
204
|
+
if (started.state !== 'healthy') return finish('service-unavailable', { scopeId, reason: started.reason ?? started.state, service: { state: started.state } })
|
|
205
|
+
status = started
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 2. A tick that starts after this request, bounded.
|
|
209
|
+
const asked = await requestServiceTick({ ...lifecycle, tickTimeoutMs }, lifecycleRules)
|
|
210
|
+
const serviceShown = { state: asked.state, runtimeId: status.record?.runtimeId ?? null }
|
|
211
|
+
if (asked.state === 'busy') return finish('busy', { scopeId, reason: asked.reason, service: serviceShown })
|
|
212
|
+
if (!asked.requested) return finish('service-unavailable', { scopeId, reason: asked.reason, service: serviceShown })
|
|
213
|
+
const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
|
|
214
|
+
const report = (freshnessOverride) => scopeReport({ workspace, scopeId, repositoryRoots: protectedRoots(project), serviceState: 'healthy', applyAvailable, ...(freshnessOverride === undefined ? {} : { freshness: freshnessOverride }) }, rules)
|
|
215
|
+
let view = report()
|
|
216
|
+
const tick = asked.pending ? null : asked.tick
|
|
217
|
+
if (asked.pending && view.outcome === 'current') view = { ...view, ...describeOutcome('updating'), reason: 'tick-still-running' }
|
|
218
|
+
else if (!asked.pending && tick?.state === 'busy') return finish('busy', { scopeId, reason: 'engine-lock-held', service: serviceShown })
|
|
219
|
+
else if (!asked.pending && (tick === null || tick.ok !== true || tick.state !== 'ticked') && view.outcome === 'current') {
|
|
220
|
+
// The tick after this request did not complete: what was current before is not proven current now.
|
|
221
|
+
view = { ...view, ...describeOutcome('stale-readable'), reason: `tick-${tick?.state ?? tick?.error?.code ?? 'unanswered'}` }
|
|
222
|
+
}
|
|
223
|
+
const common = { scopeId, reason: view.reason, service: serviceShown, freshness: view.freshness, readBack: view.readBack, pendingEdits: view.pendingEdits }
|
|
224
|
+
if (view.outcome !== 'current' && !(allowStale && READABLE.has(view.outcome) && view.readBack.readable)) return finish(view.outcome, common)
|
|
225
|
+
|
|
226
|
+
// 3. The app, before anything is launched.
|
|
227
|
+
const before = qualifyApp(await inspectApp(appProbe), { requireVersion: false })
|
|
228
|
+
const app = (qualification) => ({ outcome: qualification.outcome, reason: qualification.reason, version: qualification.version, floor: qualification.floor })
|
|
229
|
+
if (!rules.appQualifies(before)) return finish(before.outcome, { ...common, reason: before.reason, app: app(before) })
|
|
230
|
+
|
|
231
|
+
// 4. Launch, then wait, bounded, for the app to answer for exactly this vault.
|
|
232
|
+
let launch
|
|
233
|
+
try { launch = await launcher.open({ vaultRoot: view.vaultRoot }) } catch { launch = { launched: false, reason: 'launcher-threw' } }
|
|
234
|
+
if (launch?.launched !== true) return finish('launch-failed', { ...common, reason: typeof launch?.reason === 'string' ? launch.reason : 'launcher-refused', app: app(before) })
|
|
235
|
+
const deadline = monotonic() + appWaitMs
|
|
236
|
+
let after = before
|
|
237
|
+
let vault = { answered: false, indexReady: false }
|
|
238
|
+
for (;;) {
|
|
239
|
+
after = qualifyApp(await inspectApp(appProbe), { requireVersion: true })
|
|
240
|
+
// A running app below the floor is final; an app that has not come up yet, or not yet opened a vault, is asked again.
|
|
241
|
+
if (!rules.appQualifies(after) && !NOT_UP_YET.has(after.reason)) return finish(after.outcome, { ...common, launched: true, reason: after.reason, app: app(after) })
|
|
242
|
+
if (rules.appQualifies(after)) {
|
|
243
|
+
try { vault = await appProbe.vaultState({ vaultRoot: view.vaultRoot }) } catch { vault = { answered: false, indexReady: false } }
|
|
244
|
+
if (vault?.answered === true && vault.indexReady === true) break
|
|
245
|
+
}
|
|
246
|
+
if (monotonic() >= deadline) break
|
|
247
|
+
await sleep(appPollMs)
|
|
248
|
+
}
|
|
249
|
+
if (!rules.appQualifies(after)) return finish(after.outcome, { ...common, launched: true, reason: after.reason, app: app(after) })
|
|
250
|
+
if (vault?.answered !== true) return finish('launch-failed', { ...common, launched: true, reason: 'app-did-not-answer-for-this-vault', app: app(after) })
|
|
251
|
+
if (vault.indexReady !== true) return finish('indexing', { ...common, launched: true, reason: 'metadata-cache-not-ready', app: app(after) })
|
|
252
|
+
|
|
253
|
+
// 5. Still what was verified? The launch took time.
|
|
254
|
+
if (view.outcome !== 'current') return finish(view.outcome, { ...common, launched: true, app: app(after) })
|
|
255
|
+
const again = report()
|
|
256
|
+
if (again.outcome !== 'current') return finish(again.outcome, { ...common, launched: true, reason: again.reason, freshness: again.freshness, readBack: again.readBack, app: app(after) })
|
|
257
|
+
return finish('current', { ...common, launched: true, app: app(after) })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Production entry point: the production decisions, always.
|
|
261
|
+
export function openScope(options = {}) {
|
|
262
|
+
return openScopeForOracleTests(options, OPENING_PRIMITIVES, LIFECYCLE_PRIMITIVES)
|
|
263
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { publishedSinceCommit } from '../../projection/obsidian/recovery/restart.mjs'
|
|
4
|
+
import { readFileBytes, sha256Digest } from '../../projection/obsidian/recovery/store.mjs'
|
|
5
|
+
import { compareText } from './documents.mjs'
|
|
6
|
+
import { normalizeApplyResult } from './extension-points.mjs'
|
|
7
|
+
import { OPEN_EDIT_STATES } from './state-store.mjs'
|
|
8
|
+
|
|
9
|
+
// Edits a person (or another program) made in a vault.
|
|
10
|
+
//
|
|
11
|
+
// A note whose bytes differ from the digest its view was generated with has
|
|
12
|
+
// been edited. The order is fixed: the edited bytes are copied into the
|
|
13
|
+
// recovery object store first, and only then is a record queued. The engine
|
|
14
|
+
// never writes such a note. Whether an edit reaches a source file is a
|
|
15
|
+
// separate, explicit operation: never on a tick in manual mode, and in
|
|
16
|
+
// automatic mode only through the registered apply operation under an
|
|
17
|
+
// installed policy that is read again immediately before every dispatch.
|
|
18
|
+
|
|
19
|
+
const DISPATCHABLE = new Set(['queued', 'apply-unavailable', 'apply-failed'])
|
|
20
|
+
export const isOpenEdit = (edit) => OPEN_EDIT_STATES.includes(edit.state)
|
|
21
|
+
|
|
22
|
+
export function editIdentifier({ workspaceId, repoId, nodeId, scopeId, generationId, observedDigest }) {
|
|
23
|
+
return `edit-${createHash('sha256').update([workspaceId, repoId, nodeId, scopeId, generationId, observedDigest].join('\u0000')).digest('hex').slice(0, 32)}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The digest each note of a view is bound to, exactly as the publisher binds
|
|
27
|
+
// it: the trusted manifest, then whatever an unfinished publication of this
|
|
28
|
+
// view has verifiably published since. Without the second part a note written
|
|
29
|
+
// by an interrupted run would look like somebody's edit.
|
|
30
|
+
export function trustedNoteBases(store) {
|
|
31
|
+
const manifest = store.readCurrentManifest()
|
|
32
|
+
const bases = new Map()
|
|
33
|
+
if (!manifest) return { manifest: null, bases }
|
|
34
|
+
const published = publishedSinceCommit({ store })
|
|
35
|
+
for (const note of manifest.notes) {
|
|
36
|
+
const digest = published.has(note.path) ? published.get(note.path) : note.noteDigest
|
|
37
|
+
if (digest !== null) bases.set(note.path, { digest, repoId: note.repoId, nodeId: note.nodeId })
|
|
38
|
+
}
|
|
39
|
+
return { manifest, bases }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Production preservation: an immutable, content-addressed copy in the
|
|
43
|
+
// recovery object store, through the store's own primitive.
|
|
44
|
+
export const preserveInRecoveryStore = (store, bytes) => store.retainObject(bytes)
|
|
45
|
+
|
|
46
|
+
// Compares each note with its base and returns the next list of edit records.
|
|
47
|
+
// `digestOf(path)` is the digest observation found; the bytes are read again
|
|
48
|
+
// here and hashed, and that second reading is what gets preserved and recorded.
|
|
49
|
+
export function observeVaultEdits({ store, workspaceId, scopeId, manifest, bases, digestOf, edits, now, preserve = preserveInRecoveryStore }) {
|
|
50
|
+
const next = edits.map((edit) => ({ ...edit }))
|
|
51
|
+
const events = []
|
|
52
|
+
const openFor = (notePath) => next.filter((edit) => edit.scopeId === scopeId && edit.path === notePath && isOpenEdit(edit))
|
|
53
|
+
const close = (edit, state) => { edit.state = state; edit.closedAt = now }
|
|
54
|
+
|
|
55
|
+
for (const [notePath, base] of bases) {
|
|
56
|
+
const observed = digestOf(notePath)
|
|
57
|
+
// A missing note holds no bytes to keep; the publisher restores it.
|
|
58
|
+
if (observed === null || observed === undefined) continue
|
|
59
|
+
if (observed === base.digest) {
|
|
60
|
+
for (const edit of openFor(notePath)) { close(edit, 'withdrawn'); events.push({ kind: 'withdrawn', scopeId, path: notePath, editId: edit.editId }) }
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (openFor(notePath).some((edit) => edit.observedDigest === observed)) continue
|
|
64
|
+
let bytes
|
|
65
|
+
try { bytes = readFileBytes(path.join(store.vaultRoot, ...notePath.split('/'))) } catch (error) {
|
|
66
|
+
if (error.code === 'ENOENT') continue
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
const observedDigest = sha256Digest(bytes)
|
|
70
|
+
if (observedDigest === base.digest) continue
|
|
71
|
+
// Preserved first. If this throws, nothing has been recorded.
|
|
72
|
+
const object = preserve(store, bytes)
|
|
73
|
+
if (openFor(notePath).some((edit) => edit.observedDigest === observedDigest)) continue
|
|
74
|
+
for (const edit of openFor(notePath)) { close(edit, 'superseded'); events.push({ kind: 'superseded', scopeId, path: notePath, editId: edit.editId }) }
|
|
75
|
+
const identity = { workspaceId, repoId: base.repoId, nodeId: base.nodeId }
|
|
76
|
+
const editId = editIdentifier({ ...identity, scopeId, generationId: manifest.generationId, observedDigest })
|
|
77
|
+
// The same bytes over the same generation, seen again after being closed: reopened, not duplicated.
|
|
78
|
+
const index = next.findIndex((edit) => edit.editId === editId)
|
|
79
|
+
const record = {
|
|
80
|
+
editId, identity, scopeId, generationId: manifest.generationId, path: notePath, baseNoteDigest: base.digest, observedDigest, objectRef: object.ref,
|
|
81
|
+
observedAt: now, state: 'queued', attempts: 0, retryBudget: null, lastAttemptAt: null, lastResult: null, closedAt: null,
|
|
82
|
+
}
|
|
83
|
+
if (index === -1) next.push(record); else next[index] = record
|
|
84
|
+
events.push({ kind: 'queued', scopeId, path: notePath, editId })
|
|
85
|
+
}
|
|
86
|
+
// A note that left the view is no longer compared; its record stays open and its bytes stay kept.
|
|
87
|
+
return { edits: next, events }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function heldPaths(edits, scopeId) {
|
|
91
|
+
return [...new Set(edits.filter((edit) => edit.scopeId === scopeId && isOpenEdit(edit)).map((edit) => edit.path))].sort(compareText)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Automatic dispatch. `authorize()` reads the machine settings and the policy
|
|
95
|
+
// from disk. It is called before the batch and again immediately before each
|
|
96
|
+
// edit, so a revocation, a pause or a switch back to manual stops the very
|
|
97
|
+
// next dispatch. Attempts are bounded by the policy's retry budget and spaced
|
|
98
|
+
// by `retryIntervalMs`; an exhausted edit stays pending and is not dispatched
|
|
99
|
+
// again until the operation or the policy changes.
|
|
100
|
+
export async function dispatchAutomaticApply({ edits, authorize, applyOperation, now, nowMs, retryIntervalMs }) {
|
|
101
|
+
const next = edits.map((edit) => ({ ...edit }))
|
|
102
|
+
const dispatched = []
|
|
103
|
+
const first = authorize()
|
|
104
|
+
if (!first.authorized) return { edits: next, dispatched, authorization: first.reason }
|
|
105
|
+
let authorization = first.reason
|
|
106
|
+
let remaining = first.policy.maxBatchSize
|
|
107
|
+
const ordered = next.filter((edit) => isOpenEdit(edit)).sort((left, right) => compareText(left.observedAt, right.observedAt) || compareText(left.editId, right.editId))
|
|
108
|
+
for (const edit of ordered) {
|
|
109
|
+
if (remaining <= 0) break
|
|
110
|
+
const current = authorize()
|
|
111
|
+
if (!current.authorized) { authorization = current.reason; break }
|
|
112
|
+
const { policy } = current
|
|
113
|
+
if (!policy.allowedEditClasses.includes('body-replacement')) { authorization = 'edit-class-not-allowed'; break }
|
|
114
|
+
// A new operation or a new policy revision is a new situation: the budget starts again.
|
|
115
|
+
if (edit.lastResult && (edit.lastResult.operationId !== applyOperation.id || edit.lastResult.policyDigest !== policy.digest)) {
|
|
116
|
+
edit.attempts = 0
|
|
117
|
+
if (edit.state === 'retry-exhausted') edit.state = 'queued'
|
|
118
|
+
}
|
|
119
|
+
edit.retryBudget = policy.retryBudget
|
|
120
|
+
if (edit.attempts >= policy.retryBudget + 1) { edit.state = 'retry-exhausted'; continue }
|
|
121
|
+
if (!DISPATCHABLE.has(edit.state)) continue
|
|
122
|
+
if (edit.lastAttemptAt !== null && nowMs - Date.parse(edit.lastAttemptAt) < retryIntervalMs) continue
|
|
123
|
+
remaining -= 1
|
|
124
|
+
let result
|
|
125
|
+
try {
|
|
126
|
+
result = normalizeApplyResult(await applyOperation.apply({
|
|
127
|
+
edit: structuredClone(edit), policy: structuredClone(policy), policyDigest: policy.digest, idempotencyKey: edit.editId,
|
|
128
|
+
}))
|
|
129
|
+
} catch {
|
|
130
|
+
result = { status: 'failed', code: 'apply-operation-threw' }
|
|
131
|
+
}
|
|
132
|
+
edit.attempts += 1
|
|
133
|
+
edit.lastAttemptAt = now
|
|
134
|
+
edit.lastResult = { ...result, operationId: applyOperation.id, policyDigest: policy.digest }
|
|
135
|
+
if (result.status === 'applied') { edit.state = 'applied'; edit.closedAt = now }
|
|
136
|
+
else if (edit.attempts >= policy.retryBudget + 1) edit.state = 'retry-exhausted'
|
|
137
|
+
else edit.state = result.status === 'apply-unavailable' ? 'apply-unavailable' : 'apply-failed'
|
|
138
|
+
dispatched.push({ editId: edit.editId, status: result.status, code: result.code, state: edit.state })
|
|
139
|
+
}
|
|
140
|
+
return { edits: next, dispatched, authorization }
|
|
141
|
+
}
|