@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.
Files changed (414) hide show
  1. package/CHANGELOG.md +372 -0
  2. package/README.md +402 -205
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  25. package/contracts/atelier-review.v1.schema.json +1601 -0
  26. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  27. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  28. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  29. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  30. package/contracts/public-api-baseline.json +57 -0
  31. package/docs/assurance-controls.md +49 -0
  32. package/docs/atelier-runtime.md +28 -2
  33. package/docs/atelier-sync.md +172 -0
  34. package/docs/blocks/audience-visibility.md +9 -0
  35. package/docs/blocks/claims.md +61 -0
  36. package/docs/blocks/conformance-admission.md +15 -0
  37. package/docs/blocks/will-not-do.md +18 -0
  38. package/docs/coauthor-session.md +57 -0
  39. package/docs/connected-composition.md +75 -0
  40. package/docs/continuity.md +3 -4
  41. package/docs/decisions.md +167 -0
  42. package/docs/design.md +167 -0
  43. package/docs/exact-upgrades.md +159 -0
  44. package/docs/extension-pack-lifecycle.md +33 -0
  45. package/docs/guided-upgrades.md +99 -0
  46. package/docs/install.md +99 -16
  47. package/docs/intake-and-guides.md +65 -0
  48. package/docs/integration-and-review-plan.md +409 -0
  49. package/docs/integration-contract-decisions.md +22 -0
  50. package/docs/knowledge-graph.md +8 -4
  51. package/docs/local-review.md +84 -0
  52. package/docs/local-services.md +328 -0
  53. package/docs/local-state.md +107 -0
  54. package/docs/obsidian-contract.md +690 -0
  55. package/docs/obsidian.md +423 -0
  56. package/docs/portable-presentation.md +328 -0
  57. package/docs/presentation-consumer-boundaries.md +75 -0
  58. package/docs/presentation-review-disposition.md +47 -0
  59. package/docs/project-command-inventory.md +38 -0
  60. package/docs/project-options.md +53 -0
  61. package/docs/release-engineering.md +174 -12
  62. package/docs/repo-boundary-guard.md +12 -2
  63. package/docs/review-portability.md +55 -0
  64. package/docs/skill-steward.md +156 -0
  65. package/docs/upgrade.md +146 -5
  66. package/docs/vault-service.md +292 -0
  67. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  68. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  69. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  70. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  71. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  72. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  73. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  74. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  75. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  76. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  77. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  78. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  79. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  80. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  81. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  82. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  83. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  84. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  85. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  86. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  87. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  88. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  89. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  90. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  91. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  92. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  93. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  94. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  95. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  96. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  97. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  98. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  99. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  100. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  101. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  102. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  103. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  104. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  105. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  106. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  107. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  108. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  109. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  110. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  111. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  112. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  113. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  114. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  115. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  116. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  117. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  118. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  119. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  120. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  121. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  122. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  123. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  127. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  128. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  129. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  130. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  131. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  132. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  133. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  134. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  135. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  136. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  137. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  138. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  141. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  142. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  143. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  144. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  145. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  146. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  147. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  148. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  149. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  150. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  151. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  152. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  153. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  154. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  159. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  160. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  161. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  162. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  163. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  164. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  165. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  166. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  167. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  168. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  169. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  170. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  171. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  172. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  173. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  174. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  175. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  176. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  177. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  178. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  179. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  181. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  182. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  183. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  186. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  187. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  188. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  189. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  190. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  191. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  192. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  193. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  194. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  195. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  196. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  197. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  198. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  199. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  200. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  201. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  202. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  203. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  204. package/fixtures/obsidian/edits/workspace.json +65 -0
  205. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  206. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  207. package/fixtures/obsidian/materialization/workspace.json +54 -0
  208. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  209. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  210. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  211. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  212. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  213. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  214. package/fixtures/ui/presentation/pane.v1.json +7 -0
  215. package/fixtures/ui/presentation/reference.v1.json +293 -0
  216. package/package.json +86 -10
  217. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  218. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  219. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  220. package/skills/claude/atelier-open/SKILL.md +11 -5
  221. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  222. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  223. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  224. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  225. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  226. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  227. package/skills/codex/atelier-open/SKILL.md +11 -5
  228. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  229. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  230. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  231. package/src/access/preview-enforcer.mjs +84 -0
  232. package/src/boundary/content-rules.mjs +283 -20
  233. package/src/boundary/policy.mjs +213 -91
  234. package/src/cli/execute-command.mjs +36 -0
  235. package/src/cli/project-options.mjs +58 -0
  236. package/src/cli/run.mjs +78 -10
  237. package/src/coauthor/session.mjs +161 -0
  238. package/src/coauthor/store.mjs +173 -0
  239. package/src/collaboration/event-ledger.mjs +348 -0
  240. package/src/collaboration/index.mjs +17 -0
  241. package/src/collaboration/inspection-bundle.mjs +223 -0
  242. package/src/collaboration/proposals.mjs +265 -65
  243. package/src/collaboration/review-contracts.mjs +25 -0
  244. package/src/collaboration/review-store.mjs +305 -0
  245. package/src/commands/attestation.mjs +20 -6
  246. package/src/commands/coauthor.mjs +26 -0
  247. package/src/commands/config.mjs +6 -1
  248. package/src/commands/disclosure.mjs +133 -0
  249. package/src/commands/distribution.mjs +2 -1
  250. package/src/commands/extension-pack.mjs +17 -9
  251. package/src/commands/init.mjs +46 -6
  252. package/src/commands/obsidian.mjs +371 -0
  253. package/src/commands/review.mjs +147 -0
  254. package/src/commands/server.mjs +1 -4
  255. package/src/commands/setup.mjs +51 -14
  256. package/src/commands/skills.mjs +199 -0
  257. package/src/commands/sync.mjs +107 -0
  258. package/src/composition/registry.mjs +27 -0
  259. package/src/composition/wire.mjs +122 -0
  260. package/src/contracts/corpus.mjs +35 -0
  261. package/src/decisions/contracts.d.mts +96 -0
  262. package/src/decisions/contracts.mjs +321 -0
  263. package/src/disclosure/content-scan.mjs +206 -0
  264. package/src/egress/check.mjs +7 -38
  265. package/src/egress/forbidden-egress.mjs +32 -18
  266. package/src/export/atelier-export-contract.mjs +16 -1
  267. package/src/extension-packs/lifecycle.mjs +154 -0
  268. package/src/graph/graph.mjs +176 -316
  269. package/src/graph/knowledge-graph.mjs +643 -43
  270. package/src/guides/contracts.mjs +54 -0
  271. package/src/harness/context-client.mjs +9 -1
  272. package/src/index.mjs +41 -0
  273. package/src/intake/store.mjs +119 -0
  274. package/src/preview/channel.mjs +89 -0
  275. package/src/project/config.mjs +112 -52
  276. package/src/project/durable-state.mjs +161 -0
  277. package/src/project/file-class.mjs +180 -0
  278. package/src/project/package-root.mjs +10 -0
  279. package/src/project/path-match.mjs +38 -15
  280. package/src/project/private-state.mjs +120 -0
  281. package/src/projection/obsidian/contracts.mjs +571 -0
  282. package/src/projection/obsidian/edits/align.mjs +169 -0
  283. package/src/projection/obsidian/edits/apply.mjs +889 -0
  284. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  285. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  286. package/src/projection/obsidian/edits/index.mjs +19 -0
  287. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  288. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  289. package/src/projection/obsidian/edits/observe.mjs +123 -0
  290. package/src/projection/obsidian/edits/policy.mjs +123 -0
  291. package/src/projection/obsidian/edits/regions.mjs +648 -0
  292. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  293. package/src/projection/obsidian/materialize/index.mjs +7 -0
  294. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  295. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  296. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  297. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  298. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  299. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  300. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  301. package/src/projection/obsidian/proposals/index.mjs +23 -0
  302. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  303. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  304. package/src/projection/obsidian/proposals/router.mjs +168 -0
  305. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  306. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  307. package/src/projection/obsidian/publication/index.mjs +5 -0
  308. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  309. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  310. package/src/projection/obsidian/publication/transport.mjs +160 -0
  311. package/src/projection/obsidian/recovery/index.mjs +6 -0
  312. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  313. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  314. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  315. package/src/projection/obsidian/recovery/store.mjs +234 -0
  316. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  317. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  318. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  319. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  320. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  321. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  322. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  323. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  324. package/src/projection/project.mjs +28 -19
  325. package/src/readiness-protocols/evidence.mjs +282 -0
  326. package/src/readiness-protocols/runtime.mjs +4 -3
  327. package/src/readiness-protocols/source-read.mjs +39 -0
  328. package/src/runtime/git-adapter.mjs +189 -0
  329. package/src/runtime/local-state.mjs +439 -0
  330. package/src/runtime/obsidian/app-capability.mjs +129 -0
  331. package/src/runtime/obsidian/app-production-seams.mjs +105 -0
  332. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  333. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  334. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  335. package/src/runtime/obsidian/contributions.mjs +27 -0
  336. package/src/runtime/obsidian/documents.mjs +37 -0
  337. package/src/runtime/obsidian/enablement.mjs +53 -0
  338. package/src/runtime/obsidian/engine.mjs +532 -0
  339. package/src/runtime/obsidian/errors.mjs +14 -0
  340. package/src/runtime/obsidian/extension-points.mjs +103 -0
  341. package/src/runtime/obsidian/index.mjs +34 -0
  342. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  343. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  344. package/src/runtime/obsidian/observation.mjs +158 -0
  345. package/src/runtime/obsidian/opening.mjs +263 -0
  346. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  347. package/src/runtime/obsidian/pipeline.mjs +122 -0
  348. package/src/runtime/obsidian/private-lock.mjs +156 -0
  349. package/src/runtime/obsidian/process-identity.mjs +58 -0
  350. package/src/runtime/obsidian/service-client.mjs +66 -0
  351. package/src/runtime/obsidian/service-entry-path.mjs +9 -0
  352. package/src/runtime/obsidian/service-main.mjs +91 -0
  353. package/src/runtime/obsidian/service-record.mjs +169 -0
  354. package/src/runtime/obsidian/service-server.mjs +121 -0
  355. package/src/runtime/obsidian/service.mjs +216 -0
  356. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  357. package/src/runtime/obsidian/state-store.mjs +180 -0
  358. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  359. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  360. package/src/runtime/obsidian/watchers.mjs +36 -0
  361. package/src/runtime/repository-observation.mjs +491 -0
  362. package/src/runtime/supervisor.mjs +803 -0
  363. package/src/server/local-sidecar.mjs +125 -59
  364. package/src/server/security.mjs +89 -4
  365. package/src/server/server.mjs +4 -2
  366. package/src/skills/steward.mjs +780 -0
  367. package/src/support/feedback-report.mjs +4 -3
  368. package/src/ui/presentation/browser.mjs +278 -0
  369. package/src/ui/presentation/contract.mjs +116 -0
  370. package/src/ui/presentation/index.mjs +7 -0
  371. package/src/ui/presentation/native.mjs +153 -0
  372. package/src/ui/presentation/proof.mjs +57 -0
  373. package/src/ui/presentation/schema-check.mjs +30 -0
  374. package/src/ui/presentation/schema.generated.mjs +1371 -0
  375. package/src/ui/presentation/state.mjs +43 -0
  376. package/src/ui/presentation/styles.mjs +67 -0
  377. package/src/ui/presentation/tokens.mjs +74 -0
  378. package/src/ui/presentation/web.mjs +85 -0
  379. package/src/ui/review-page.mjs +32 -0
  380. package/src/upgrade/explanation.mjs +33 -0
  381. package/src/upgrade/provenance.mjs +338 -0
  382. package/src/upgrade/transaction-files.mjs +103 -0
  383. package/src/upgrade/transaction.mjs +475 -0
  384. package/src/upgrade/upgrade.mjs +65 -29
  385. package/src/vault/hosts.mjs +21 -0
  386. package/src/vault/identity.mjs +19 -0
  387. package/src/vault/index.mjs +7 -0
  388. package/src/vault/interface.mjs +11 -0
  389. package/src/vault/metadata.mjs +31 -0
  390. package/src/vault/privacy.mjs +148 -0
  391. package/src/vault/probe.mjs +89 -0
  392. package/src/vault/service.mjs +131 -0
  393. package/src/vault/source.mjs +45 -0
  394. package/src/vault/storage.mjs +23 -0
  395. package/templates/distribution-workspace/.claude/launch.json +6 -2
  396. package/templates/distribution-workspace/gitignore +1 -0
  397. package/templates/distribution-workspace/project/README.md +2 -2
  398. package/templates/external-project-workspace/README.md +45 -0
  399. package/templates/external-project-workspace/answers.example.json +4 -0
  400. package/templates/external-project-workspace/atelier.project.json +40 -0
  401. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  402. package/templates/external-project-workspace/gitignore +8 -0
  403. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  404. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  405. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  406. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  407. package/templates/external-project-workspace/source/README.md +11 -0
  408. package/templates/launch.json +6 -2
  409. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  410. package/templates/private-domain-workspace/domain/README.md +2 -2
  411. package/templates/private-domain-workspace/gitignore +1 -0
  412. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  413. package/templates/shared-project-workspace/gitignore +1 -0
  414. 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;')
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
+ }