@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,169 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../project/private-state.mjs'
|
|
5
|
+
import { ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
|
|
6
|
+
import { canonicalJson, closedObject } from './documents.mjs'
|
|
7
|
+
import { refuse } from './errors.mjs'
|
|
8
|
+
import { LOOPBACK_HOSTS } from './service-client.mjs'
|
|
9
|
+
|
|
10
|
+
// Private documents of the maintenance service of one workspace, under
|
|
11
|
+
//
|
|
12
|
+
// <data>/obsidian/<workspace-id>/state/service/
|
|
13
|
+
// runtime.json the adapter record of the running service (service-state v1)
|
|
14
|
+
// settings.json what this machine chose: loopback host, port, startup consent
|
|
15
|
+
// last-error.json the last tick that failed for a reason nobody typed
|
|
16
|
+
// service.log the operational log of the service process
|
|
17
|
+
// start-lock/ serializes `start` for this workspace
|
|
18
|
+
//
|
|
19
|
+
// Owner-only, replaced atomically, outside every repository and every vault.
|
|
20
|
+
// Every document is validated on every read. One that does not validate, or
|
|
21
|
+
// that names another workspace, service or state location, refuses: it is
|
|
22
|
+
// never repaired, never adopted and never overwritten without a person.
|
|
23
|
+
|
|
24
|
+
export const SERVICE_SETTINGS_SCHEMA = 'atelier-obsidian-service-settings/v1'
|
|
25
|
+
export const SERVICE_ERROR_SCHEMA = 'atelier-obsidian-service-last-error/v1'
|
|
26
|
+
export const CONSENT_COVERAGES = Object.freeze(['service', 'service-and-startup'])
|
|
27
|
+
|
|
28
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
29
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
30
|
+
const BEARER = /^[A-Za-z0-9_-]{43}$/
|
|
31
|
+
|
|
32
|
+
export function serviceNameFor(workspaceId) {
|
|
33
|
+
const name = `atelier-obsidian-${workspaceId}`
|
|
34
|
+
if (!IDENTIFIER.test(name)) refuse('invalid-workspace-identity', 'the workspace identity cannot name a service')
|
|
35
|
+
return name
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function servicePaths(workspaceRoot) {
|
|
39
|
+
const directory = path.join(workspaceRoot, 'state', 'service')
|
|
40
|
+
return {
|
|
41
|
+
stateLocation: path.join(workspaceRoot, 'state'), directory, record: path.join(directory, 'runtime.json'), settings: path.join(directory, 'settings.json'),
|
|
42
|
+
lastError: path.join(directory, 'last-error.json'), log: path.join(directory, 'service.log'), startLock: path.join(directory, 'start-lock'),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const serviceDirectory = (workspaceRoot) => ensureContainedPrivateDirectory({ workspaceRoot, directory: servicePaths(workspaceRoot).directory, label: 'Obsidian service state' })
|
|
47
|
+
|
|
48
|
+
function readJson(file, code, label) {
|
|
49
|
+
let text
|
|
50
|
+
try { text = readRegularTextNoFollow(file) } catch (error) {
|
|
51
|
+
if (error.code === 'ENOENT') return null
|
|
52
|
+
refuse(code, `${label} cannot be read`, { cause: error.code ?? String(error.message) })
|
|
53
|
+
}
|
|
54
|
+
try { return JSON.parse(text) } catch { return refuse(code, `${label} is not JSON`) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The identity of what runs: the service entry module and the digest of its bytes.
|
|
58
|
+
export function executableIdentity(entryPath) {
|
|
59
|
+
const resolved = fs.realpathSync(entryPath)
|
|
60
|
+
return { path: resolved, digest: `sha256:${createHash('sha256').update(fs.readFileSync(resolved)).digest('hex')}` }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// The adapter record
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export function validateServiceRecord(document, { workspaceRoot, workspaceId }) {
|
|
68
|
+
const code = 'invalid-service-record'
|
|
69
|
+
try { assertObsidianContract('service-state', document) } catch (error) {
|
|
70
|
+
if (error instanceof ObsidianContractRefusal) refuse(code, 'the service record does not satisfy its contract', { errors: error.detail?.errors ?? [] })
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
if (document.workspaceId !== workspaceId || document.serviceName !== serviceNameFor(workspaceId)) refuse(code, 'the service record belongs to another workspace or service')
|
|
74
|
+
if (document.stateLocation !== servicePaths(workspaceRoot).stateLocation) refuse(code, 'the service record names another state location')
|
|
75
|
+
if (!path.isAbsolute(document.executable.path)) refuse(code, 'the service record names an executable that is not an absolute path')
|
|
76
|
+
// The bearer of the running service lives here and nowhere else.
|
|
77
|
+
closedObject(document.ext, { required: ['bearer'] }, code, 'the private part of the service record')
|
|
78
|
+
if (typeof document.ext.bearer !== 'string' || !BEARER.test(document.ext.bearer)) refuse(code, 'the service record carries no usable bearer')
|
|
79
|
+
return document
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readServiceRecord({ workspaceRoot, workspaceId }) {
|
|
83
|
+
const document = readJson(servicePaths(workspaceRoot).record, 'invalid-service-record', 'the service record')
|
|
84
|
+
return document === null ? null : validateServiceRecord(document, { workspaceRoot, workspaceId })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeServiceRecord({ workspaceRoot, workspaceId, record }) {
|
|
88
|
+
validateServiceRecord(record, { workspaceRoot, workspaceId })
|
|
89
|
+
atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'runtime.json'), canonicalJson(record))
|
|
90
|
+
return record
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Removes the record only while it still names this runtime and PID. Nothing else is ever removed here.
|
|
94
|
+
export function removeServiceRecord({ workspaceRoot, workspaceId, runtimeId, pid }) {
|
|
95
|
+
let current
|
|
96
|
+
try { current = readServiceRecord({ workspaceRoot, workspaceId }) } catch { return false }
|
|
97
|
+
if (current === null || current.runtimeId !== runtimeId || current.pid !== pid) return false
|
|
98
|
+
try { fs.unlinkSync(servicePaths(workspaceRoot).record); return true } catch (error) { if (error.code === 'ENOENT') return false; throw error }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What may be shown: everything but the bearer.
|
|
102
|
+
export function publicRecord(record) {
|
|
103
|
+
if (record === null) return null
|
|
104
|
+
const { ext: _private, ...shown } = record
|
|
105
|
+
return shown
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Machine-specific service settings
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function validateServiceSettings(document, workspaceId) {
|
|
113
|
+
const code = 'invalid-service-settings'
|
|
114
|
+
closedObject(document, { required: ['schema', 'workspaceId', 'host', 'port', 'consent', 'updatedAt'] }, code, 'service settings')
|
|
115
|
+
if (document.schema !== SERVICE_SETTINGS_SCHEMA || document.workspaceId !== workspaceId) refuse(code, 'service settings name an unknown schema or another workspace')
|
|
116
|
+
if (!LOOPBACK_HOSTS.includes(document.host)) refuse(code, 'the service host must be the literal 127.0.0.1 or ::1')
|
|
117
|
+
if (!Number.isInteger(document.port) || document.port < 1024 || document.port > 65535) refuse(code, 'the service port must be between 1024 and 65535')
|
|
118
|
+
closedObject(document.consent, { required: ['grantedAt', 'actor', 'coverage'] }, code, 'the startup consent')
|
|
119
|
+
if (!TIMESTAMP.test(document.consent.grantedAt) || typeof document.consent.actor !== 'string' || !IDENTIFIER.test(document.consent.actor) || !CONSENT_COVERAGES.includes(document.consent.coverage)) refuse(code, 'the startup consent is malformed')
|
|
120
|
+
if (typeof document.updatedAt !== 'string' || !TIMESTAMP.test(document.updatedAt)) refuse(code, 'updatedAt must be a UTC timestamp')
|
|
121
|
+
return document
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function readServiceSettings({ workspaceRoot, workspaceId }) {
|
|
125
|
+
const document = readJson(servicePaths(workspaceRoot).settings, 'invalid-service-settings', 'service settings')
|
|
126
|
+
return document === null ? null : validateServiceSettings(document, workspaceId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function writeServiceSettings({ workspaceRoot, workspaceId, settings }) {
|
|
130
|
+
validateServiceSettings(settings, workspaceId)
|
|
131
|
+
atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'settings.json'), canonicalJson(settings))
|
|
132
|
+
return settings
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// The last untyped tick error, and the operational log
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function validateLastError(document, workspaceId) {
|
|
140
|
+
const code = 'invalid-service-last-error'
|
|
141
|
+
closedObject(document, { required: ['schema', 'workspaceId', 'runtimeId', 'code', 'name', 'at', 'consecutiveFailures', 'totalFailures', 'resolvedAt'] }, code, 'the last service error')
|
|
142
|
+
const ok = document.schema === SERVICE_ERROR_SCHEMA && document.workspaceId === workspaceId && typeof document.runtimeId === 'string' && IDENTIFIER.test(document.runtimeId)
|
|
143
|
+
&& typeof document.code === 'string' && document.code.length <= 64 && typeof document.name === 'string' && document.name.length <= 64 && TIMESTAMP.test(document.at)
|
|
144
|
+
&& Number.isInteger(document.consecutiveFailures) && document.consecutiveFailures >= 0 && Number.isInteger(document.totalFailures) && document.totalFailures >= 1
|
|
145
|
+
&& (document.resolvedAt === null || TIMESTAMP.test(document.resolvedAt))
|
|
146
|
+
if (!ok) refuse(code, 'the last service error is malformed')
|
|
147
|
+
return document
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function readLastServiceError({ workspaceRoot, workspaceId }) {
|
|
151
|
+
const document = readJson(servicePaths(workspaceRoot).lastError, 'invalid-service-last-error', 'the last service error')
|
|
152
|
+
return document === null ? null : validateLastError(document, workspaceId)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// An error code and class only: a message can carry a path or a title, and goes to the private log instead.
|
|
156
|
+
export function writeLastServiceError({ workspaceRoot, workspaceId, document }) {
|
|
157
|
+
validateLastError(document, workspaceId)
|
|
158
|
+
atomicReplacePrivateText(path.join(serviceDirectory(workspaceRoot), 'last-error.json'), canonicalJson(document))
|
|
159
|
+
return document
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The log is bounded: past the ceiling it becomes the one previous log, and a new one begins.
|
|
163
|
+
export const SERVICE_LOG_MAX_BYTES = 1024 * 1024
|
|
164
|
+
|
|
165
|
+
export function openServiceLog(workspaceRoot) {
|
|
166
|
+
const file = path.join(serviceDirectory(workspaceRoot), 'service.log')
|
|
167
|
+
try { if (fs.lstatSync(file).size > SERVICE_LOG_MAX_BYTES) fs.renameSync(file, `${file}.1`) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
168
|
+
return { file, descriptor: openRegularFileNoFollow(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600) }
|
|
169
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import { requestHeader, sameOrigin } from '../../server/security.mjs'
|
|
4
|
+
import { isPlainObject } from './documents.mjs'
|
|
5
|
+
import { HEALTH_SCHEMA, LOOPBACK_HOSTS, authorityOf } from './service-client.mjs'
|
|
6
|
+
|
|
7
|
+
// The listener of the maintenance service: four fixed operations and nothing
|
|
8
|
+
// else. There is no file serving, no command, no evaluation and no route that
|
|
9
|
+
// takes a path, a name or code from a request.
|
|
10
|
+
//
|
|
11
|
+
// GET /health who answers here; open, and says nothing else
|
|
12
|
+
// GET /status service and per-view freshness summary; bearer required
|
|
13
|
+
// POST /tick run one tick now; bearer required
|
|
14
|
+
// POST /stop finish the tick in flight and exit; bearer required
|
|
15
|
+
//
|
|
16
|
+
// Every request, before its operation is even looked up:
|
|
17
|
+
//
|
|
18
|
+
// - `Host` is exactly the literal loopback authority this listener is bound
|
|
19
|
+
// to, so a name that resolves to loopback (DNS rebinding) is refused;
|
|
20
|
+
// - a `Sec-Fetch-Site` other than `none` or `same-origin`, or an `Origin`
|
|
21
|
+
// that is not this listener itself, is refused: no web page drives this;
|
|
22
|
+
// - the path is one of the four, exactly, with no query; the method is the
|
|
23
|
+
// one that path has.
|
|
24
|
+
//
|
|
25
|
+
// Everything but health needs the per-runtime random bearer that exists only
|
|
26
|
+
// in the owner-only record. A POST body is JSON of at most 1 KiB naming the
|
|
27
|
+
// runtime it is meant for, so a request aimed at an earlier runtime on the
|
|
28
|
+
// same port does nothing.
|
|
29
|
+
|
|
30
|
+
export const MAX_REQUEST_BYTES = 1024
|
|
31
|
+
export const SERVICE_OPERATIONS = Object.freeze({ '/health': 'GET', '/status': 'GET', '/tick': 'POST', '/stop': 'POST' })
|
|
32
|
+
|
|
33
|
+
const digestOf = (value) => createHash('sha256').update(String(value)).digest()
|
|
34
|
+
|
|
35
|
+
// The decisions the request oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
|
|
36
|
+
export const SERVER_PRIMITIVES = Object.freeze({
|
|
37
|
+
bearerMatches: (presented, expected) => typeof presented === 'string' && presented !== '' && timingSafeEqual(digestOf(presented), digestOf(expected)),
|
|
38
|
+
hostMatches: (header, authority) => header === authority,
|
|
39
|
+
originAllowed(headers, authority) {
|
|
40
|
+
const site = requestHeader(headers, 'sec-fetch-site').toLowerCase()
|
|
41
|
+
if (site !== '' && site !== 'none' && site !== 'same-origin') return false
|
|
42
|
+
const origin = requestHeader(headers, 'origin')
|
|
43
|
+
return origin === '' || sameOrigin(origin, `http://${authority}`)
|
|
44
|
+
},
|
|
45
|
+
maxRequestBytes: MAX_REQUEST_BYTES,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
function send(response, statusCode, body) {
|
|
49
|
+
const text = JSON.stringify(body)
|
|
50
|
+
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text), 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', Connection: 'close' })
|
|
51
|
+
response.end(text)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readBoundedJson(request, limit) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const declared = Number(request.headers['content-length'] ?? 0)
|
|
57
|
+
if (!Number.isFinite(declared) || declared > limit) return resolve({ ok: false, statusCode: 413, code: 'payload-too-large' })
|
|
58
|
+
const chunks = []
|
|
59
|
+
let size = 0
|
|
60
|
+
let done = false
|
|
61
|
+
const finish = (value) => { if (!done) { done = true; resolve(value) } }
|
|
62
|
+
request.on('data', (chunk) => {
|
|
63
|
+
size += chunk.length
|
|
64
|
+
if (size > limit) { finish({ ok: false, statusCode: 413, code: 'payload-too-large' }); request.resume() } else chunks.push(chunk)
|
|
65
|
+
})
|
|
66
|
+
request.on('end', () => {
|
|
67
|
+
let body = null
|
|
68
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { body = null }
|
|
69
|
+
finish(isPlainObject(body) ? { ok: true, body } : { ok: false, statusCode: 400, code: 'payload-not-json-object' })
|
|
70
|
+
})
|
|
71
|
+
request.on('error', () => finish({ ok: false, statusCode: 400, code: 'payload-unreadable' }))
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// identity: { serviceName, workspaceId, runtimeId, pid, host, port, executableDigest, startedAt }
|
|
76
|
+
// operations: { healthStatus(), status(), tick(), stop() }
|
|
77
|
+
export function createServiceServerForOracleTests({ identity, bearer, operations }, primitives = SERVER_PRIMITIVES) {
|
|
78
|
+
if (!LOOPBACK_HOSTS.includes(identity?.host)) throw new TypeError('the service listens on a literal loopback address only')
|
|
79
|
+
if (typeof bearer !== 'string' || bearer.length < 32) throw new TypeError('the service needs its random bearer')
|
|
80
|
+
const rules = { ...SERVER_PRIMITIVES, ...primitives }
|
|
81
|
+
|
|
82
|
+
async function handle(request, response) {
|
|
83
|
+
const authority = authorityOf(identity.host, identity.port)
|
|
84
|
+
if (!rules.hostMatches(requestHeader(request.headers, 'host'), authority)) return send(response, 403, { error: 'host-not-this-loopback-listener' })
|
|
85
|
+
if (!rules.originAllowed(request.headers, authority)) return send(response, 403, { error: 'cross-site-request' })
|
|
86
|
+
const method = Object.hasOwn(SERVICE_OPERATIONS, request.url) ? SERVICE_OPERATIONS[request.url] : null
|
|
87
|
+
if (method === null) return send(response, 404, { error: 'unknown-operation' })
|
|
88
|
+
if (request.method !== method) return send(response, 405, { error: 'method-not-allowed' })
|
|
89
|
+
if (request.url === '/health') {
|
|
90
|
+
const { serviceName, workspaceId, runtimeId, pid, host, port, executableDigest, startedAt } = identity
|
|
91
|
+
return send(response, 200, { schema: HEALTH_SCHEMA, serviceName, workspaceId, runtimeId, pid, host, port, executableDigest, startedAt, status: operations.healthStatus() })
|
|
92
|
+
}
|
|
93
|
+
const presented = /^Bearer ([A-Za-z0-9_-]+)$/.exec(requestHeader(request.headers, 'authorization'))?.[1] ?? null
|
|
94
|
+
if (!rules.bearerMatches(presented, bearer)) return send(response, 401, { error: 'bearer-required' })
|
|
95
|
+
if (request.url === '/status') return send(response, 200, await operations.status())
|
|
96
|
+
const payload = await readBoundedJson(request, rules.maxRequestBytes)
|
|
97
|
+
if (!payload.ok) return send(response, payload.statusCode, { error: payload.code })
|
|
98
|
+
if (Object.keys(payload.body).length !== 1 || payload.body.runtimeId !== identity.runtimeId) return send(response, 409, { error: 'request-names-another-runtime' })
|
|
99
|
+
if (request.url === '/tick') return send(response, 200, await operations.tick())
|
|
100
|
+
// The answer leaves first; the service then finishes its tick and exits.
|
|
101
|
+
response.once('finish', () => { void operations.stop() })
|
|
102
|
+
return send(response, 202, { stopping: true, runtimeId: identity.runtimeId, pid: identity.pid })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const server = http.createServer({ requestTimeout: 10_000, headersTimeout: 5_000, keepAliveTimeout: 1_000, maxHeaderSize: 8 * 1024 }, (request, response) => {
|
|
106
|
+
handle(request, response).catch(() => { if (!response.headersSent) send(response, 500, { error: 'operation-failed' }); else response.destroy() })
|
|
107
|
+
})
|
|
108
|
+
return {
|
|
109
|
+
server,
|
|
110
|
+
listen: () => new Promise((resolve, reject) => {
|
|
111
|
+
server.once('error', reject)
|
|
112
|
+
// A literal address and `ipv6Only`: never a wildcard, never a name.
|
|
113
|
+
server.listen({ host: identity.host, port: identity.port, ipv6Only: identity.host === '::1', exclusive: true }, () => { server.off('error', reject); resolve(server.address()) })
|
|
114
|
+
}),
|
|
115
|
+
close: () => new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections() }),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createServiceServer(options) {
|
|
120
|
+
return createServiceServerForOracleTests(options, SERVER_PRIMITIVES)
|
|
121
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { randomBytes as cryptoRandomBytes } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { AtelierDiagnosticError } from '../../project/config.mjs'
|
|
4
|
+
import { ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
|
|
5
|
+
import { isoTime } from './documents.mjs'
|
|
6
|
+
import { createMaintenanceEngine } from './engine.mjs'
|
|
7
|
+
import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
|
|
8
|
+
import { assertOutsideRepositories, protectedRoots, readLocalPointer, resolveDataRoot, workspaceStateRoot } from './machine-settings.mjs'
|
|
9
|
+
import { isProcessAlive } from './private-lock.mjs'
|
|
10
|
+
import { probeHealth } from './service-client.mjs'
|
|
11
|
+
import {
|
|
12
|
+
SERVICE_ERROR_SCHEMA, executableIdentity, readLastServiceError, readServiceRecord, readServiceSettings, removeServiceRecord, serviceNameFor, servicePaths,
|
|
13
|
+
writeLastServiceError, writeServiceRecord,
|
|
14
|
+
} from './service-record.mjs'
|
|
15
|
+
import { createServiceServer } from './service-server.mjs'
|
|
16
|
+
import { createMaintenanceStateStore } from './state-store.mjs'
|
|
17
|
+
import { DEFAULT_MAX_BACKOFF_MS, DEFAULT_TICK_INTERVAL_MS, createTickLoop } from './tick-loop.mjs'
|
|
18
|
+
import { createFsWatcherFactory } from './watchers.mjs'
|
|
19
|
+
|
|
20
|
+
// The maintenance service of one workspace, inside one process.
|
|
21
|
+
//
|
|
22
|
+
// resolve the workspace -> read this machine's service settings -> refuse if
|
|
23
|
+
// another runtime of this workspace answers -> listen on the literal
|
|
24
|
+
// loopback address -> write the owner-only record -> tick on an interval
|
|
25
|
+
//
|
|
26
|
+
// It listens before its first tick and until after its last, and it removes
|
|
27
|
+
// its record before it stops listening, so a record whose address refuses
|
|
28
|
+
// connections never belongs to a working service.
|
|
29
|
+
//
|
|
30
|
+
// The service owns a process and a listener and nothing else. Every write to
|
|
31
|
+
// a vault, to recovery or to staging is the engine's and the publisher's. On
|
|
32
|
+
// shutdown the tick in flight is allowed to finish; nothing is swept, and a
|
|
33
|
+
// publication that was cut short is settled by the publisher's own restart
|
|
34
|
+
// recovery on a later tick, never by deleting what it left behind.
|
|
35
|
+
//
|
|
36
|
+
// `adapterFactory` has no default here either: whoever starts the service
|
|
37
|
+
// decides whether it may reach a running app.
|
|
38
|
+
|
|
39
|
+
export const SERVICE_STATUS_SCHEMA = 'atelier-obsidian-service-status/v1'
|
|
40
|
+
export const DEFAULT_SHUTDOWN_GRACE_MS = 30 * 1000
|
|
41
|
+
|
|
42
|
+
// Where a workspace keeps its private state, without creating an identity.
|
|
43
|
+
export function resolveServiceWorkspace({ project, dataRoot, env = process.env, platform = process.platform, create = false }) {
|
|
44
|
+
const pointer = readLocalPointer(project)
|
|
45
|
+
if (pointer === null) return null
|
|
46
|
+
const requested = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId)
|
|
47
|
+
assertOutsideRepositories({ managedRoot: requested, repositoryRoots: protectedRoots(project) })
|
|
48
|
+
if (create) fs.mkdirSync(requested, { recursive: true, mode: 0o700 })
|
|
49
|
+
else if (!fs.existsSync(requested)) return { workspaceId: pointer.workspaceId, workspaceRoot: null }
|
|
50
|
+
return { workspaceId: pointer.workspaceId, workspaceRoot: fs.realpathSync(requested) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal
|
|
54
|
+
const errorCode = (error) => (typeof error?.code === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(error.code) ? error.code : 'untyped-error')
|
|
55
|
+
const errorName = (error) => (typeof error?.name === 'string' ? error.name.slice(0, 64) : 'Error')
|
|
56
|
+
|
|
57
|
+
// Where a rename over a file somebody is reading can fail for a moment (Windows), the one record write is tried again.
|
|
58
|
+
async function writeRecordPatiently(input) {
|
|
59
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
60
|
+
try { return writeServiceRecord(input) } catch (error) {
|
|
61
|
+
if (attempt >= 5 || !['EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error
|
|
62
|
+
await new Promise((resolve) => { setTimeout(resolve, 50) })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runMaintenanceService(options = {}) {
|
|
68
|
+
const {
|
|
69
|
+
loadProject, dataRoot, adapterFactory, entryPath, runtimeId = `rt-${cryptoRandomBytes(16).toString('hex')}`, startup = false,
|
|
70
|
+
intervalMs = DEFAULT_TICK_INTERVAL_MS, maxBackoffMs = Math.max(DEFAULT_MAX_BACKOFF_MS, intervalMs), shutdownGraceMs = DEFAULT_SHUTDOWN_GRACE_MS,
|
|
71
|
+
clock = () => new Date(), log = () => {}, pid = process.pid, randomBytes = cryptoRandomBytes, env = process.env, platform = process.platform,
|
|
72
|
+
engineOptions = {}, createEngine = createMaintenanceEngine,
|
|
73
|
+
// What the adapter factory last learned about the installed app, when it qualifies one. Codes and versions only.
|
|
74
|
+
appStatus = null,
|
|
75
|
+
} = options
|
|
76
|
+
if (typeof loadProject !== 'function') throw new TypeError('the service needs loadProject')
|
|
77
|
+
if (typeof adapterFactory !== 'function') throw new TypeError('the service needs an adapterFactory')
|
|
78
|
+
if (typeof entryPath !== 'string') throw new TypeError('the service needs the path of its entry module')
|
|
79
|
+
|
|
80
|
+
const workspace = resolveServiceWorkspace({ project: loadProject(), dataRoot, env, platform })
|
|
81
|
+
if (!workspace?.workspaceRoot) refuse('service-workspace-not-prepared', 'this workspace has no private state yet; `start` prepares it')
|
|
82
|
+
const { workspaceId, workspaceRoot } = workspace
|
|
83
|
+
const settings = readServiceSettings({ workspaceRoot, workspaceId })
|
|
84
|
+
if (settings === null) refuse('service-settings-absent', 'this machine recorded no loopback port and no consent for this workspace')
|
|
85
|
+
// A unit installed at the operating-system level runs only under a consent that covers it.
|
|
86
|
+
if (startup && settings.consent.coverage !== 'service-and-startup') refuse('startup-consent-absent', 'the recorded consent covers the service, not operating-system startup')
|
|
87
|
+
const { host, port } = settings
|
|
88
|
+
|
|
89
|
+
// Another runtime of this workspace that proves itself is never displaced. A malformed record refuses here.
|
|
90
|
+
const existing = readServiceRecord({ workspaceRoot, workspaceId })
|
|
91
|
+
if (existing && existing.runtimeId !== runtimeId && isProcessAlive(existing.pid)) {
|
|
92
|
+
const answer = await probeHealth({ host: existing.host, port: existing.port })
|
|
93
|
+
if (answer.kind === 'health' && answer.body.runtimeId === existing.runtimeId && answer.body.pid === existing.pid) refuse('service-already-running', 'another runtime of this workspace answers health', { runtimeId: existing.runtimeId, pid: existing.pid })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const executable = executableIdentity(entryPath)
|
|
97
|
+
const startedAt = isoTime(clock)
|
|
98
|
+
const identity = { serviceName: serviceNameFor(workspaceId), workspaceId, runtimeId, pid, host, port, executableDigest: executable.digest, startedAt }
|
|
99
|
+
const bearer = randomBytes(32).toString('base64url')
|
|
100
|
+
const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
|
|
101
|
+
|
|
102
|
+
let stopping = false
|
|
103
|
+
let consecutiveFailures = 0
|
|
104
|
+
let lastTick = null
|
|
105
|
+
let settleDone
|
|
106
|
+
const done = new Promise((resolve) => { settleDone = resolve })
|
|
107
|
+
|
|
108
|
+
const engine = createEngine({ watcherFactory: createFsWatcherFactory(), ...engineOptions, loadProject, dataRoot, adapterFactory, clock, env, platform, lockOwner: { host, port, runtimeId } })
|
|
109
|
+
|
|
110
|
+
const recordIsOurs = () => {
|
|
111
|
+
try { const record = readServiceRecord({ workspaceRoot, workspaceId }); return record !== null && record.runtimeId === runtimeId && record.pid === pid } catch { return false }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function recordOutcome(outcome) {
|
|
115
|
+
const at = isoTime(clock)
|
|
116
|
+
consecutiveFailures = outcome.consecutiveFailures
|
|
117
|
+
if (outcome.ok) {
|
|
118
|
+
lastTick = { at, state: outcome.report.state, reason: outcome.report.reason ?? outcome.report.refusal?.code ?? null }
|
|
119
|
+
const previous = readLastServiceError({ workspaceRoot, workspaceId })
|
|
120
|
+
if (previous && previous.resolvedAt === null) writeLastServiceError({ workspaceRoot, workspaceId, document: { ...previous, consecutiveFailures: 0, resolvedAt: at } })
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
lastTick = { at, state: 'failed', reason: errorCode(outcome.error) }
|
|
124
|
+
// The message may carry a path or a title: it goes to the private log and nowhere else.
|
|
125
|
+
log({ at, event: 'tick-failed', code: errorCode(outcome.error), name: errorName(outcome.error), message: String(outcome.error?.message ?? outcome.error), consecutiveFailures })
|
|
126
|
+
let previous = null
|
|
127
|
+
try { previous = readLastServiceError({ workspaceRoot, workspaceId }) } catch { previous = null }
|
|
128
|
+
writeLastServiceError({ workspaceRoot, workspaceId, document: { schema: SERVICE_ERROR_SCHEMA, workspaceId, runtimeId, code: errorCode(outcome.error), name: errorName(outcome.error), at, consecutiveFailures, totalFailures: (previous?.totalFailures ?? 0) + 1, resolvedAt: null } })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const loop = createTickLoop({
|
|
132
|
+
intervalMs, maxBackoffMs, onOutcome: recordOutcome,
|
|
133
|
+
async tick() {
|
|
134
|
+
// The service runs only while its record names it: a replaced or removed record ends it, cleanly.
|
|
135
|
+
if (!recordIsOurs()) { void shutdown('record-no-longer-names-this-runtime'); return { state: 'stopping', reason: 'record-no-longer-names-this-runtime' } }
|
|
136
|
+
return engine.tick()
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const summary = (report) => ({ state: report.state, reason: report.reason ?? report.refusal?.code ?? null, scopes: (report.scopes ?? []).map(({ scopeId, state, reason, verified }) => ({ scopeId, state, reason, verified })) })
|
|
141
|
+
|
|
142
|
+
function freshnessSummary() {
|
|
143
|
+
let document
|
|
144
|
+
try { document = stateStore.readFreshness() } catch (error) { if (isTyped(error)) return { readable: false, code: error.code }; throw error }
|
|
145
|
+
if (document === null) return null
|
|
146
|
+
const { enablement, maintenanceMode, lastTickAt, lastFullReconciliationAt } = document
|
|
147
|
+
// Counts, never the held paths: a note path carries a title.
|
|
148
|
+
const scopes = document.scopes.map(({ scopeId, state, reason, verified, generationId, preparedGenerationId, heldNotes, retainedEdits, checkedAt }) => ({ scopeId, state, reason, verified, generationId, preparedGenerationId, heldNoteCount: heldNotes.length, retainedEdits, checkedAt }))
|
|
149
|
+
return { readable: true, enablement, maintenanceMode, lastTickAt, lastFullReconciliationAt, scopes }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const healthStatus = () => (stopping ? 'stopped' : consecutiveFailures > 0 ? 'degraded' : 'healthy')
|
|
153
|
+
const server = createServiceServer({
|
|
154
|
+
identity, bearer,
|
|
155
|
+
operations: {
|
|
156
|
+
healthStatus,
|
|
157
|
+
status() {
|
|
158
|
+
let lastError = null
|
|
159
|
+
try { lastError = readLastServiceError({ workspaceRoot, workspaceId }) } catch (error) { if (!isTyped(error)) throw error; lastError = { unreadable: error.code } }
|
|
160
|
+
if (lastError?.schema) { const { schema: _schema, workspaceId: _workspace, ...shown } = lastError; lastError = shown }
|
|
161
|
+
return { schema: SERVICE_STATUS_SCHEMA, service: { ...identity, status: healthStatus() }, loop: loop.state(), lastTick, lastError, freshness: freshnessSummary(), ...(typeof appStatus === 'function' ? { app: appStatus() } : {}) }
|
|
162
|
+
},
|
|
163
|
+
async tick() {
|
|
164
|
+
const outcome = await loop.tickNow()
|
|
165
|
+
if (outcome.stopped) return { ok: false, stopped: true }
|
|
166
|
+
return outcome.ok ? { ok: true, ...summary(outcome.report) } : { ok: false, error: { code: errorCode(outcome.error), name: errorName(outcome.error) } }
|
|
167
|
+
},
|
|
168
|
+
stop: () => shutdown('stop-requested'),
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
let shuttingDown = null
|
|
173
|
+
function shutdown(reason) {
|
|
174
|
+
if (shuttingDown) return shuttingDown
|
|
175
|
+
stopping = true
|
|
176
|
+
shuttingDown = (async () => {
|
|
177
|
+
log({ at: isoTime(clock), event: 'stopping', reason })
|
|
178
|
+
// The tick in flight finishes, within the grace period. Past it the process ends anyway: the publisher journals every step.
|
|
179
|
+
let timer
|
|
180
|
+
await Promise.race([loop.stop(), new Promise((resolve) => { timer = setTimeout(resolve, shutdownGraceMs) })])
|
|
181
|
+
clearTimeout(timer)
|
|
182
|
+
try { engine.stop() } catch { /* watchers only */ }
|
|
183
|
+
// Drafts, pending edits, recovery and staging stay exactly as they are. Only the generated record goes, and before the listener does.
|
|
184
|
+
try { removeServiceRecord({ workspaceRoot, workspaceId, runtimeId, pid }) } catch { /* a record that cannot be removed is reported stale later */ }
|
|
185
|
+
await server.close()
|
|
186
|
+
log({ at: isoTime(clock), event: 'stopped', reason })
|
|
187
|
+
settleDone({ reason })
|
|
188
|
+
return { reason }
|
|
189
|
+
})()
|
|
190
|
+
return shuttingDown
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await server.listen()
|
|
195
|
+
} catch (error) {
|
|
196
|
+
try { engine.stop() } catch { /* nothing was watched yet */ }
|
|
197
|
+
if (error.code === 'EADDRINUSE') refuse('service-port-occupied', 'something else listens on the recorded loopback port; it is never taken over', { host, port })
|
|
198
|
+
throw error
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await writeRecordPatiently({
|
|
202
|
+
workspaceRoot, workspaceId,
|
|
203
|
+
record: {
|
|
204
|
+
schema: 'atelier-obsidian-service-state/v1', contractVersion: '1.0.0', workspaceId, serviceName: identity.serviceName, host, port, runtimeId, pid,
|
|
205
|
+
executable: { path: executable.path, digest: executable.digest, ext: { runner: process.execPath } }, stateLocation: servicePaths(workspaceRoot).stateLocation,
|
|
206
|
+
health: { status: 'healthy', checkedAt: startedAt }, consent: settings.consent, ext: { bearer },
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
} catch (error) {
|
|
210
|
+
await server.close()
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
log({ at: startedAt, event: 'started', runtimeId, pid, host, port, startup })
|
|
214
|
+
loop.start()
|
|
215
|
+
return { identity, workspaceRoot, tickNow: () => loop.tickNow(), shutdown, done }
|
|
216
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { refuse } from './errors.mjs'
|
|
3
|
+
|
|
4
|
+
// Builds the text of an operating-system startup unit for the maintenance
|
|
5
|
+
// service. Pure: it returns text and touches nothing. It writes no file,
|
|
6
|
+
// installs nothing and runs no service manager. Installing a unit is a
|
|
7
|
+
// separate system change that needs a person's explicit authorization and a
|
|
8
|
+
// recorded consent that covers startup; the service refuses `--startup`
|
|
9
|
+
// without one.
|
|
10
|
+
//
|
|
11
|
+
// Every machine-specific value is an input. Nothing is looked up here: no
|
|
12
|
+
// home directory, no environment, no current directory, no installed path.
|
|
13
|
+
//
|
|
14
|
+
// macOS a launchd user agent (property list)
|
|
15
|
+
// Linux a systemd user unit
|
|
16
|
+
// Windows refused, typed: startup there has not been qualified
|
|
17
|
+
//
|
|
18
|
+
// The unit runs the service entry in the foreground with `--startup`; the
|
|
19
|
+
// service writes its own record once it listens, exactly as under `start`.
|
|
20
|
+
|
|
21
|
+
export const STARTUP_PLATFORMS = Object.freeze(['darwin', 'linux'])
|
|
22
|
+
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
23
|
+
|
|
24
|
+
const xml = (value) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''')
|
|
25
|
+
// systemd: quoted, with its specifier and variable characters doubled.
|
|
26
|
+
const unitWord = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', '$$$$')}"`
|
|
27
|
+
|
|
28
|
+
function launchdText({ label, words, logPath, workingDirectory }) {
|
|
29
|
+
const lines = [
|
|
30
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
31
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
32
|
+
'<plist version="1.0">',
|
|
33
|
+
'<dict>',
|
|
34
|
+
' <key>Label</key>', ` <string>${xml(label)}</string>`,
|
|
35
|
+
' <key>ProgramArguments</key>', ' <array>', ...words.map((word) => ` <string>${xml(word)}</string>`), ' </array>',
|
|
36
|
+
' <key>RunAtLoad</key>', ' <true/>',
|
|
37
|
+
// Restarted after a crash, not after a clean stop; a refusal is not retried in a tight loop.
|
|
38
|
+
' <key>KeepAlive</key>', ' <dict>', ' <key>SuccessfulExit</key>', ' <false/>', ' </dict>',
|
|
39
|
+
' <key>ThrottleInterval</key>', ' <integer>60</integer>',
|
|
40
|
+
' <key>ProcessType</key>', ' <string>Background</string>',
|
|
41
|
+
...(workingDirectory === undefined ? [] : [' <key>WorkingDirectory</key>', ` <string>${xml(workingDirectory)}</string>`]),
|
|
42
|
+
' <key>StandardOutPath</key>', ` <string>${xml(logPath)}</string>`,
|
|
43
|
+
' <key>StandardErrorPath</key>', ` <string>${xml(logPath)}</string>`,
|
|
44
|
+
'</dict>',
|
|
45
|
+
'</plist>',
|
|
46
|
+
]
|
|
47
|
+
return `${lines.join('\n')}\n`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function systemdText({ label, words, logPath, workingDirectory }) {
|
|
51
|
+
const lines = [
|
|
52
|
+
'[Unit]',
|
|
53
|
+
`Description=Atelier Obsidian maintenance (${label.replaceAll(/[^A-Za-z0-9._-]/g, '')})`,
|
|
54
|
+
'',
|
|
55
|
+
'[Service]',
|
|
56
|
+
'Type=simple',
|
|
57
|
+
`ExecStart=${words.map(unitWord).join(' ')}`,
|
|
58
|
+
...(workingDirectory === undefined ? [] : [`WorkingDirectory=${workingDirectory.replaceAll('%', '%%')}`]),
|
|
59
|
+
// Restarted after a crash; not after a clean stop, and not after a refusal (exit status 2).
|
|
60
|
+
'Restart=on-failure',
|
|
61
|
+
'RestartSec=60',
|
|
62
|
+
'RestartPreventExitStatus=2',
|
|
63
|
+
`StandardOutput=append:${logPath.replaceAll('%', '%%')}`,
|
|
64
|
+
`StandardError=append:${logPath.replaceAll('%', '%%')}`,
|
|
65
|
+
'',
|
|
66
|
+
'[Install]',
|
|
67
|
+
'WantedBy=default.target',
|
|
68
|
+
]
|
|
69
|
+
return `${lines.join('\n')}\n`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// { platform, kind, fileName, text }. `args` are the arguments of the entry after `--startup`.
|
|
73
|
+
export function buildStartupAdapter({ platform, label, nodePath, entryPath, args = [], logPath, workingDirectory } = {}) {
|
|
74
|
+
if (platform === 'win32') refuse('startup-platform-unqualified', 'operating-system startup on Windows has not been qualified; no unit is generated for it', { platform })
|
|
75
|
+
if (!STARTUP_PLATFORMS.includes(platform)) refuse('startup-platform-unsupported', 'no startup unit is known for this platform', { platform: String(platform) })
|
|
76
|
+
if (typeof label !== 'string' || !LABEL.test(label)) refuse('startup-adapter-input-invalid', 'the unit label must be a plain identifier')
|
|
77
|
+
const flavor = path.posix
|
|
78
|
+
for (const [name, value] of [['nodePath', nodePath], ['entryPath', entryPath], ['logPath', logPath], ...(workingDirectory === undefined ? [] : [['workingDirectory', workingDirectory]])]) {
|
|
79
|
+
if (typeof value !== 'string' || !flavor.isAbsolute(value) || /[\u0000-\u001f]/.test(value)) refuse('startup-adapter-input-invalid', `${name} must be an absolute path given by the caller`, { name })
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(args) || args.some((word) => typeof word !== 'string' || word === '' || /[\u0000-\u001f]/.test(word))) refuse('startup-adapter-input-invalid', 'args must be a list of plain words')
|
|
82
|
+
if (args.includes('--startup')) refuse('startup-adapter-input-invalid', '--startup is added by the builder')
|
|
83
|
+
const words = [nodePath, entryPath, '--startup', ...args]
|
|
84
|
+
return platform === 'darwin'
|
|
85
|
+
? { platform, kind: 'launchd-user-agent', fileName: `${label}.plist`, text: launchdText({ label, words, logPath, workingDirectory }) }
|
|
86
|
+
: { platform, kind: 'systemd-user-unit', fileName: `${label}.service`, text: systemdText({ label, words, logPath, workingDirectory }) }
|
|
87
|
+
}
|