@mnstry/atelier 0.2.0-alpha.6 → 0.2.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (374) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +59 -15
  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-review.v1.schema.json +1601 -0
  25. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  26. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  27. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  28. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  29. package/docs/assurance-controls.md +9 -1
  30. package/docs/atelier-sync.md +5 -4
  31. package/docs/blocks/claims.md +15 -10
  32. package/docs/blocks/will-not-do.md +4 -1
  33. package/docs/coauthor-session.md +57 -0
  34. package/docs/connected-composition.md +75 -0
  35. package/docs/decisions.md +167 -0
  36. package/docs/exact-upgrades.md +159 -0
  37. package/docs/extension-pack-lifecycle.md +33 -0
  38. package/docs/guided-upgrades.md +99 -0
  39. package/docs/install.md +65 -9
  40. package/docs/intake-and-guides.md +65 -0
  41. package/docs/integration-and-review-plan.md +409 -0
  42. package/docs/integration-contract-decisions.md +22 -0
  43. package/docs/local-review.md +84 -0
  44. package/docs/local-services.md +227 -0
  45. package/docs/local-state.md +107 -0
  46. package/docs/obsidian-contract.md +687 -0
  47. package/docs/obsidian.md +387 -0
  48. package/docs/portable-presentation.md +328 -0
  49. package/docs/presentation-consumer-boundaries.md +75 -0
  50. package/docs/presentation-review-disposition.md +47 -0
  51. package/docs/project-command-inventory.md +38 -0
  52. package/docs/project-options.md +53 -0
  53. package/docs/release-engineering.md +88 -0
  54. package/docs/review-portability.md +55 -0
  55. package/docs/skill-steward.md +156 -0
  56. package/docs/upgrade.md +55 -2
  57. package/docs/vault-service.md +292 -0
  58. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  59. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  60. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  61. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  62. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  64. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  65. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  66. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  67. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  69. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  70. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  71. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  72. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  73. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  74. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  75. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  76. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  77. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  78. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  79. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  80. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  81. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  82. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  83. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  84. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  85. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  86. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  87. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  88. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  89. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  90. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  91. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  92. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  93. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  94. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  95. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  96. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  97. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  98. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  99. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  100. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  101. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  102. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  103. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  104. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  105. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  106. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  107. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  108. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  109. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  113. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  114. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  115. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  116. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  121. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  122. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  123. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  124. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  126. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  131. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  132. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  133. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  136. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  137. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  138. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  142. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  143. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  144. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  145. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  146. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  147. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  155. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  156. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  157. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  158. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  161. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  162. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  163. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  164. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  169. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  170. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  171. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  172. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  177. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  178. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  179. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  180. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  181. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  182. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  183. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  184. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  187. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  188. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  189. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  190. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  193. package/fixtures/obsidian/edits/workspace.json +65 -0
  194. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  195. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  196. package/fixtures/obsidian/materialization/workspace.json +54 -0
  197. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  198. package/fixtures/ui/presentation/pane.v1.json +7 -0
  199. package/fixtures/ui/presentation/reference.v1.json +293 -0
  200. package/package.json +57 -6
  201. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  202. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  203. package/skills/claude/atelier-open/SKILL.md +5 -0
  204. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  205. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  206. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  207. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  208. package/skills/codex/atelier-open/SKILL.md +5 -0
  209. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  210. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  211. package/src/access/preview-enforcer.mjs +84 -0
  212. package/src/boundary/policy.mjs +57 -25
  213. package/src/cli/project-options.mjs +58 -0
  214. package/src/cli/run.mjs +43 -3
  215. package/src/coauthor/session.mjs +161 -0
  216. package/src/coauthor/store.mjs +173 -0
  217. package/src/collaboration/event-ledger.mjs +16 -33
  218. package/src/collaboration/inspection-bundle.mjs +223 -0
  219. package/src/collaboration/review-contracts.mjs +25 -0
  220. package/src/collaboration/review-store.mjs +305 -0
  221. package/src/commands/coauthor.mjs +26 -0
  222. package/src/commands/config.mjs +6 -1
  223. package/src/commands/extension-pack.mjs +15 -8
  224. package/src/commands/init.mjs +44 -5
  225. package/src/commands/obsidian.mjs +368 -0
  226. package/src/commands/review.mjs +147 -0
  227. package/src/commands/setup.mjs +51 -14
  228. package/src/commands/skills.mjs +199 -0
  229. package/src/commands/sync.mjs +8 -1
  230. package/src/composition/registry.mjs +27 -0
  231. package/src/composition/wire.mjs +122 -0
  232. package/src/contracts/corpus.mjs +29 -0
  233. package/src/decisions/contracts.d.mts +96 -0
  234. package/src/decisions/contracts.mjs +321 -0
  235. package/src/disclosure/content-scan.mjs +13 -0
  236. package/src/export/atelier-export-contract.mjs +16 -1
  237. package/src/extension-packs/lifecycle.mjs +154 -0
  238. package/src/graph/graph.mjs +64 -2
  239. package/src/graph/knowledge-graph.mjs +548 -24
  240. package/src/guides/contracts.mjs +54 -0
  241. package/src/intake/store.mjs +119 -0
  242. package/src/preview/channel.mjs +89 -0
  243. package/src/project/config.mjs +26 -27
  244. package/src/project/durable-state.mjs +161 -0
  245. package/src/project/file-class.mjs +166 -0
  246. package/src/project/private-state.mjs +11 -1
  247. package/src/projection/obsidian/contracts.mjs +571 -0
  248. package/src/projection/obsidian/edits/align.mjs +169 -0
  249. package/src/projection/obsidian/edits/apply.mjs +889 -0
  250. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  251. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  252. package/src/projection/obsidian/edits/index.mjs +19 -0
  253. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  254. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  255. package/src/projection/obsidian/edits/observe.mjs +123 -0
  256. package/src/projection/obsidian/edits/policy.mjs +123 -0
  257. package/src/projection/obsidian/edits/regions.mjs +648 -0
  258. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  259. package/src/projection/obsidian/materialize/index.mjs +7 -0
  260. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  261. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  262. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  263. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  264. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  265. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  266. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  267. package/src/projection/obsidian/proposals/index.mjs +23 -0
  268. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  269. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  270. package/src/projection/obsidian/proposals/router.mjs +168 -0
  271. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  272. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  273. package/src/projection/obsidian/publication/index.mjs +5 -0
  274. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  275. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  276. package/src/projection/obsidian/publication/transport.mjs +139 -0
  277. package/src/projection/obsidian/recovery/index.mjs +6 -0
  278. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  279. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  280. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  281. package/src/projection/obsidian/recovery/store.mjs +234 -0
  282. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  283. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  284. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  285. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  286. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  287. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  288. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  289. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  290. package/src/projection/project.mjs +28 -19
  291. package/src/readiness-protocols/evidence.mjs +282 -0
  292. package/src/readiness-protocols/runtime.mjs +4 -3
  293. package/src/readiness-protocols/source-read.mjs +39 -0
  294. package/src/runtime/obsidian/app-capability.mjs +110 -0
  295. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  296. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  297. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  298. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  299. package/src/runtime/obsidian/contributions.mjs +27 -0
  300. package/src/runtime/obsidian/documents.mjs +37 -0
  301. package/src/runtime/obsidian/enablement.mjs +53 -0
  302. package/src/runtime/obsidian/engine.mjs +532 -0
  303. package/src/runtime/obsidian/errors.mjs +14 -0
  304. package/src/runtime/obsidian/extension-points.mjs +103 -0
  305. package/src/runtime/obsidian/index.mjs +34 -0
  306. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  307. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  308. package/src/runtime/obsidian/observation.mjs +158 -0
  309. package/src/runtime/obsidian/opening.mjs +243 -0
  310. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  311. package/src/runtime/obsidian/pipeline.mjs +122 -0
  312. package/src/runtime/obsidian/private-lock.mjs +156 -0
  313. package/src/runtime/obsidian/process-identity.mjs +58 -0
  314. package/src/runtime/obsidian/service-client.mjs +66 -0
  315. package/src/runtime/obsidian/service-main.mjs +89 -0
  316. package/src/runtime/obsidian/service-record.mjs +169 -0
  317. package/src/runtime/obsidian/service-server.mjs +121 -0
  318. package/src/runtime/obsidian/service.mjs +216 -0
  319. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  320. package/src/runtime/obsidian/state-store.mjs +180 -0
  321. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  322. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  323. package/src/runtime/obsidian/watchers.mjs +36 -0
  324. package/src/runtime/supervisor.mjs +16 -1
  325. package/src/server/local-sidecar.mjs +44 -0
  326. package/src/server/server.mjs +1 -0
  327. package/src/skills/steward.mjs +780 -0
  328. package/src/ui/presentation/browser.mjs +278 -0
  329. package/src/ui/presentation/contract.mjs +116 -0
  330. package/src/ui/presentation/index.mjs +7 -0
  331. package/src/ui/presentation/native.mjs +153 -0
  332. package/src/ui/presentation/proof.mjs +57 -0
  333. package/src/ui/presentation/schema-check.mjs +30 -0
  334. package/src/ui/presentation/schema.generated.mjs +1371 -0
  335. package/src/ui/presentation/state.mjs +43 -0
  336. package/src/ui/presentation/styles.mjs +67 -0
  337. package/src/ui/presentation/tokens.mjs +74 -0
  338. package/src/ui/presentation/web.mjs +85 -0
  339. package/src/ui/review-page.mjs +32 -0
  340. package/src/upgrade/explanation.mjs +33 -0
  341. package/src/upgrade/provenance.mjs +338 -0
  342. package/src/upgrade/transaction-files.mjs +103 -0
  343. package/src/upgrade/transaction.mjs +475 -0
  344. package/src/upgrade/upgrade.mjs +63 -28
  345. package/src/vault/hosts.mjs +21 -0
  346. package/src/vault/identity.mjs +19 -0
  347. package/src/vault/index.mjs +7 -0
  348. package/src/vault/interface.mjs +11 -0
  349. package/src/vault/metadata.mjs +31 -0
  350. package/src/vault/privacy.mjs +148 -0
  351. package/src/vault/probe.mjs +89 -0
  352. package/src/vault/service.mjs +131 -0
  353. package/src/vault/source.mjs +45 -0
  354. package/src/vault/storage.mjs +23 -0
  355. package/templates/distribution-workspace/.claude/launch.json +6 -2
  356. package/templates/distribution-workspace/gitignore +1 -0
  357. package/templates/distribution-workspace/project/README.md +2 -2
  358. package/templates/external-project-workspace/README.md +45 -0
  359. package/templates/external-project-workspace/answers.example.json +4 -0
  360. package/templates/external-project-workspace/atelier.project.json +40 -0
  361. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  362. package/templates/external-project-workspace/gitignore +8 -0
  363. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  364. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  365. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  366. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  367. package/templates/external-project-workspace/source/README.md +11 -0
  368. package/templates/launch.json +6 -2
  369. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  370. package/templates/private-domain-workspace/domain/README.md +2 -2
  371. package/templates/private-domain-workspace/gitignore +1 -0
  372. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  373. package/templates/shared-project-workspace/gitignore +1 -0
  374. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -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
+ }
@@ -0,0 +1,180 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
4
+ import { PATH_REGISTRY_SCHEMA } from '../../projection/obsidian/materialize/path-registry.mjs'
5
+ import { canonicalJson, closedObject, compareText } from './documents.mjs'
6
+ import { refuse } from './errors.mjs'
7
+ import { APPLY_RESULT_STATUSES } from './extension-points.mjs'
8
+
9
+ // Private, versioned documents of the maintenance engine, under
10
+ //
11
+ // <data>/obsidian/<workspace-id>/state/maintenance/
12
+ // freshness.json per-scope freshness, read by the opening commands
13
+ // pending-edits.json edits made in a vault, preserved and waiting
14
+ // late-writers.json writes that arrived in a displaced file after publication
15
+ // path-registry.json the workspace path registry prepareView grows
16
+ //
17
+ // None of the frozen contracts describes these, so each is a closed document
18
+ // of this module: an unknown key, state or schema refuses on read and on
19
+ // write. Files are owner-only and replaced atomically. A document is written
20
+ // only when its bytes change.
21
+
22
+ export const FRESHNESS_SCHEMA = 'atelier-obsidian-freshness/v1'
23
+ export const PENDING_EDITS_SCHEMA = 'atelier-obsidian-pending-edits/v1'
24
+ export const LATE_WRITERS_SCHEMA = 'atelier-obsidian-late-writers/v1'
25
+
26
+ export const FRESHNESS_STATES = Object.freeze(['current', 'updating', 'held-for-your-edit', 'stale', 'publisher-conflict', 'disabled'])
27
+ export const CHANGE_CLASSES = Object.freeze(['source-body', 'sidecar', 'asset', 'config', 'ext-settings', 'scope', 'eligibility', 'vault-note'])
28
+
29
+ // queued waiting for a person (manual) or for the next dispatch (automatic)
30
+ // apply-unavailable dispatched, and no apply operation exists yet
31
+ // apply-failed dispatched, and the operation refused, conflicted or failed
32
+ // retry-exhausted the retry budget is spent; still pending, no longer dispatched
33
+ // applied the operation reported the edit applied to source
34
+ // withdrawn the note is back at the bytes it was generated with
35
+ // superseded the note was edited again; a newer record carries the newer bytes
36
+ export const OPEN_EDIT_STATES = Object.freeze(['queued', 'apply-unavailable', 'apply-failed', 'retry-exhausted'])
37
+ export const CLOSED_EDIT_STATES = Object.freeze(['applied', 'withdrawn', 'superseded'])
38
+ export const EDIT_STATES = Object.freeze([...OPEN_EDIT_STATES, ...CLOSED_EDIT_STATES])
39
+
40
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
41
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
42
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
43
+ const EDIT_ID = /^edit-[0-9a-f]{32}$/
44
+
45
+ const isIdentifier = (value) => typeof value === 'string' && IDENTIFIER.test(value)
46
+ const isDigest = (value) => typeof value === 'string' && DIGEST.test(value)
47
+ const isTimestamp = (value) => typeof value === 'string' && TIMESTAMP.test(value)
48
+ const isRelative = (value) => typeof value === 'string' && value !== '' && !value.startsWith('/') && !value.includes('\\') && !value.split('/').some((part) => part === '' || part === '.' || part === '..')
49
+ const isCount = (value) => Number.isInteger(value) && value >= 0
50
+
51
+ function check(condition, code, message) {
52
+ if (!condition) refuse(code, message)
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Validators
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export function validateFreshness(document, workspaceId) {
60
+ const code = 'invalid-freshness-state'
61
+ closedObject(document, { required: ['schema', 'workspaceId', 'enablement', 'maintenanceMode', 'lastTickAt', 'lastFullReconciliationAt', 'scopes'] }, code, 'the freshness document')
62
+ check(document.schema === FRESHNESS_SCHEMA, code, 'the freshness document names an unknown schema')
63
+ check(document.workspaceId === workspaceId, code, 'the freshness document belongs to another workspace')
64
+ check(['enabled', 'disabled', 'refused'].includes(document.enablement), code, 'enablement is unknown')
65
+ check(['manual', 'automatic'].includes(document.maintenanceMode), code, 'maintenanceMode is unknown')
66
+ check(isTimestamp(document.lastTickAt) && (document.lastFullReconciliationAt === null || isTimestamp(document.lastFullReconciliationAt)), code, 'a tick time is not a UTC timestamp')
67
+ check(Array.isArray(document.scopes), code, 'scopes must be a list')
68
+ const seen = new Set()
69
+ for (const scope of document.scopes) {
70
+ closedObject(scope, { required: ['scopeId', 'state', 'reason', 'generationId', 'preparedGenerationId', 'verified', 'heldNotes', 'changeClasses', 'retainedEdits', 'checkedAt'] }, code, 'a scope freshness entry')
71
+ check(isIdentifier(scope.scopeId) && !seen.has(scope.scopeId), code, 'a scope identity is malformed or repeated')
72
+ seen.add(scope.scopeId)
73
+ check(FRESHNESS_STATES.includes(scope.state), code, 'a scope carries an unknown freshness state')
74
+ check(typeof scope.reason === 'string' && scope.reason !== '' && scope.reason.length <= 160, code, 'a scope reason must be a short code')
75
+ check((scope.generationId === null || isIdentifier(scope.generationId)) && (scope.preparedGenerationId === null || isIdentifier(scope.preparedGenerationId)), code, 'a generation identity is malformed')
76
+ check(typeof scope.verified === 'boolean', code, 'verified must be a boolean')
77
+ check(Array.isArray(scope.heldNotes) && scope.heldNotes.every(isRelative), code, 'heldNotes must list vault-relative paths')
78
+ // `current` is a claim about exact generations and verified bytes; a document that claims it otherwise refuses.
79
+ check(scope.state !== 'current' || (scope.verified && scope.generationId !== null && scope.generationId === scope.preparedGenerationId && scope.heldNotes.length === 0), code, 'a scope claims current without an exact verified generation')
80
+ check(Array.isArray(scope.changeClasses) && scope.changeClasses.every((item) => CHANGE_CLASSES.includes(item)), code, 'changeClasses names an unknown class')
81
+ check(isCount(scope.retainedEdits) && isTimestamp(scope.checkedAt), code, 'a scope entry is malformed')
82
+ }
83
+ return document
84
+ }
85
+
86
+ export function validatePendingEdits(document, workspaceId) {
87
+ const code = 'invalid-pending-edits'
88
+ closedObject(document, { required: ['schema', 'workspaceId', 'edits'] }, code, 'the pending edit document')
89
+ check(document.schema === PENDING_EDITS_SCHEMA, code, 'the pending edit document names an unknown schema')
90
+ check(document.workspaceId === workspaceId, code, 'the pending edit document belongs to another workspace')
91
+ check(Array.isArray(document.edits), code, 'edits must be a list')
92
+ const seen = new Set()
93
+ for (const edit of document.edits) {
94
+ closedObject(edit, { required: ['editId', 'identity', 'scopeId', 'generationId', 'path', 'baseNoteDigest', 'observedDigest', 'objectRef', 'observedAt', 'state', 'attempts', 'retryBudget', 'lastAttemptAt', 'lastResult', 'closedAt'] }, code, 'a pending edit')
95
+ check(typeof edit.editId === 'string' && EDIT_ID.test(edit.editId) && !seen.has(edit.editId), code, 'an edit identity is malformed or repeated')
96
+ seen.add(edit.editId)
97
+ closedObject(edit.identity, { required: ['workspaceId', 'repoId', 'nodeId'] }, code, 'a pending edit identity')
98
+ check(edit.identity.workspaceId === workspaceId && isIdentifier(edit.identity.repoId) && isIdentifier(edit.identity.nodeId), code, 'a pending edit identity is malformed')
99
+ check(isIdentifier(edit.scopeId) && isIdentifier(edit.generationId) && isRelative(edit.path), code, 'a pending edit origin is malformed')
100
+ check(isDigest(edit.baseNoteDigest) && isDigest(edit.observedDigest) && edit.baseNoteDigest !== edit.observedDigest, code, 'a pending edit needs two different digests')
101
+ // The edited bytes are preserved before an edit is recorded, so a record without its object refuses.
102
+ check(edit.objectRef === `recovery/objects/${edit.observedDigest.slice('sha256:'.length)}.bin`, code, 'a pending edit must name the retained object of its observed bytes')
103
+ check(isTimestamp(edit.observedAt) && EDIT_STATES.includes(edit.state) && isCount(edit.attempts), code, 'a pending edit is malformed')
104
+ check(edit.retryBudget === null || (isCount(edit.retryBudget) && edit.retryBudget <= 16), code, 'retryBudget is out of range')
105
+ check(edit.lastAttemptAt === null || isTimestamp(edit.lastAttemptAt), code, 'lastAttemptAt is malformed')
106
+ if (edit.lastResult !== null) {
107
+ closedObject(edit.lastResult, { required: ['status', 'code', 'operationId', 'policyDigest'] }, code, 'a pending edit result')
108
+ check(APPLY_RESULT_STATUSES.includes(edit.lastResult.status) && typeof edit.lastResult.code === 'string' && typeof edit.lastResult.operationId === 'string' && isDigest(edit.lastResult.policyDigest), code, 'a pending edit result is malformed')
109
+ }
110
+ check(CLOSED_EDIT_STATES.includes(edit.state) ? isTimestamp(edit.closedAt) : edit.closedAt === null, code, 'closedAt must be set exactly when an edit is closed')
111
+ check(edit.state !== 'applied' || edit.lastResult?.status === 'applied', code, 'an edit is applied only when the apply operation said so')
112
+ }
113
+ return document
114
+ }
115
+
116
+ export function validateLateWriters(document, workspaceId) {
117
+ const code = 'invalid-late-writers'
118
+ closedObject(document, { required: ['schema', 'workspaceId', 'findings'] }, code, 'the late writer document')
119
+ check(document.schema === LATE_WRITERS_SCHEMA && document.workspaceId === workspaceId && Array.isArray(document.findings), code, 'the late writer document is malformed')
120
+ for (const finding of document.findings) {
121
+ closedObject(finding, { required: ['scopeId', 'journalId', 'unit', 'notePath', 'digestAtMove', 'observedDigest', 'objectRef', 'detectedAt'] }, code, 'a late writer finding')
122
+ check(isIdentifier(finding.scopeId) && isIdentifier(finding.journalId) && isCount(finding.unit) && (finding.notePath === null || isRelative(finding.notePath)), code, 'a late writer finding is malformed')
123
+ check((finding.digestAtMove === null || isDigest(finding.digestAtMove)) && isDigest(finding.observedDigest) && isRelative(finding.objectRef) && isTimestamp(finding.detectedAt), code, 'a late writer finding is malformed')
124
+ }
125
+ return document
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Store
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export function sortEdits(edits) {
133
+ return [...edits].sort((left, right) => compareText(left.identity.repoId, right.identity.repoId) || compareText(left.identity.nodeId, right.identity.nodeId)
134
+ || compareText(left.scopeId, right.scopeId) || compareText(left.observedAt, right.observedAt) || compareText(left.editId, right.editId))
135
+ }
136
+
137
+ export function createMaintenanceStateStore({ workspaceRoot, workspaceId }) {
138
+ const directory = () => ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, 'state', 'maintenance'), label: 'Obsidian maintenance state' })
139
+ const file = (name) => path.join(workspaceRoot, 'state', 'maintenance', name)
140
+
141
+ function read(name, validate, code) {
142
+ let text
143
+ try { text = readRegularTextNoFollow(file(name)) } catch (error) {
144
+ if (error.code === 'ENOENT') return null
145
+ refuse(code, 'a maintenance state document cannot be read', { cause: error.code ?? String(error.message) })
146
+ }
147
+ let document
148
+ try { document = JSON.parse(text) } catch { refuse(code, 'a maintenance state document is not JSON') }
149
+ return validate(document, workspaceId)
150
+ }
151
+
152
+ function write(name, document, validate) {
153
+ validate(document, workspaceId)
154
+ const text = canonicalJson(document)
155
+ let existing = null
156
+ try { existing = fs.readFileSync(file(name), 'utf8') } catch (error) { if (error.code !== 'ENOENT') throw error }
157
+ if (existing === text) return false
158
+ atomicReplacePrivateText(path.join(directory(), name), text)
159
+ return true
160
+ }
161
+
162
+ const registryValidator = (document) => {
163
+ if (document?.schema !== PATH_REGISTRY_SCHEMA || document.workspaceId !== workspaceId || !Array.isArray(document.entries)) refuse('invalid-path-registry', 'the persisted path registry is malformed')
164
+ return document
165
+ }
166
+
167
+ return {
168
+ workspaceRoot,
169
+ workspaceId,
170
+ exists: () => fs.existsSync(path.join(workspaceRoot, 'state', 'maintenance')),
171
+ readFreshness: () => read('freshness.json', validateFreshness, 'invalid-freshness-state'),
172
+ writeFreshness: (document) => write('freshness.json', { ...document, scopes: [...document.scopes].sort((left, right) => compareText(left.scopeId, right.scopeId)) }, validateFreshness),
173
+ readPendingEdits: () => read('pending-edits.json', validatePendingEdits, 'invalid-pending-edits') ?? { schema: PENDING_EDITS_SCHEMA, workspaceId, edits: [] },
174
+ writePendingEdits: (document) => write('pending-edits.json', { ...document, edits: sortEdits(document.edits) }, validatePendingEdits),
175
+ readLateWriters: () => read('late-writers.json', validateLateWriters, 'invalid-late-writers') ?? { schema: LATE_WRITERS_SCHEMA, workspaceId, findings: [] },
176
+ writeLateWriters: (document) => write('late-writers.json', document, validateLateWriters),
177
+ readPathRegistry: () => read('path-registry.json', registryValidator, 'invalid-path-registry'),
178
+ writePathRegistry: (document) => write('path-registry.json', document, registryValidator),
179
+ }
180
+ }
@@ -0,0 +1,33 @@
1
+ import { readObsidianEnablement } from './enablement.mjs'
2
+ import { ObsidianMaintenanceRefusal } from './errors.mjs'
3
+ import { readServiceRecord } from './service-record.mjs'
4
+ import { resolveServiceWorkspace } from './service.mjs'
5
+ import { createMaintenanceStateStore } from './state-store.mjs'
6
+
7
+ // What Git synchronization may say about Obsidian maintenance: a report, read
8
+ // from persisted state, and a hint. Null when the project does not declare
9
+ // the integration or has it switched off, so the output of `sync` is then
10
+ // exactly what it always was. Nothing here starts or asks a service, ticks an
11
+ // engine, publishes a view or writes a file.
12
+ export function maintenanceNoticeFor(project, { dataRoot, env = process.env, platform = process.platform } = {}) {
13
+ let enablement
14
+ try { enablement = readObsidianEnablement(project) } catch (error) {
15
+ if (!(error instanceof ObsidianMaintenanceRefusal)) throw error
16
+ return { state: 'refused', code: error.code, hint: 'atelier obsidian status' }
17
+ }
18
+ if (enablement.state !== 'enabled') return null
19
+ const notice = { state: 'enabled', maintainedBy: 'the Obsidian maintenance service, not by sync', hint: 'atelier obsidian status' }
20
+ try {
21
+ const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
22
+ if (!workspace?.workspaceRoot) return { ...notice, service: 'never-started', scopes: [] }
23
+ const freshness = createMaintenanceStateStore(workspace).readFreshness()
24
+ // A record is not proof that a service runs; only `obsidian service status` proves that.
25
+ return {
26
+ ...notice, service: readServiceRecord(workspace) === null ? 'no-record' : 'recorded-not-verified', lastTickAt: freshness?.lastTickAt ?? null,
27
+ scopes: (freshness?.scopes ?? []).map(({ scopeId, state, reason, checkedAt }) => ({ scopeId, lastKnownState: state, reason, checkedAt })),
28
+ }
29
+ } catch (error) {
30
+ if (!(error instanceof ObsidianMaintenanceRefusal)) throw error
31
+ return { ...notice, service: 'unknown', code: error.code, scopes: [] }
32
+ }
33
+ }