@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,889 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { setTimeout as sleep } from 'node:timers/promises'
5
+ import { AtelierDiagnosticError } from '../../../project/config.mjs'
6
+ import { publishPrivateFile } from '../../../project/durable-state.mjs'
7
+ import { ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow, syncPrivateDirectory } from '../../../project/private-state.mjs'
8
+ import { canonicalJson, compareText, isoTime } from '../../../runtime/obsidian/documents.mjs'
9
+ import { readObsidianEnablement } from '../../../runtime/obsidian/enablement.mjs'
10
+ import { ObsidianMaintenanceRefusal } from '../../../runtime/obsidian/errors.mjs'
11
+ import { protectedRoots, readInstalledApplyPolicy, readLocalPointer, readMachineSettings, resolveDataRoot, workspaceStateRoot } from '../../../runtime/obsidian/machine-settings.mjs'
12
+ import { DEFAULT_ELIGIBILITY, createProductionSeams } from '../../../runtime/obsidian/pipeline.mjs'
13
+ import { OPEN_EDIT_STATES, createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
14
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../contracts.mjs'
15
+ import { readMarkdownLens } from '../materialize/byte-lens.mjs'
16
+ import { exchangeFiles, probeExchange } from '../publication/exchange.mjs'
17
+ import { createJournal, newJournalId } from '../recovery/journal.mjs'
18
+ import { recheckDisplacedFiles } from '../recovery/late-writer.mjs'
19
+ import { classifyCandidateFile, recordDisplaced, retireStagedFile } from '../recovery/restart.mjs'
20
+ import { PublicationRefusal, readFileBytes, sha256Digest } from '../recovery/store.mjs'
21
+ import { EditArbitrationRefusal } from './arbitrate.mjs'
22
+ import { isIdentifier } from './object-identity.mjs'
23
+ import { openObjectStore } from './object-store.mjs'
24
+ import { createEditObserverForOracleTests, editIdempotencyKey, observeEdit } from './observe.mjs'
25
+ import { decideApply } from './policy.mjs'
26
+
27
+ // Source apply: the one operation that writes an edit made in a vault back to
28
+ // its SOURCE file. A person's explicit Apply and an automatic policy call this
29
+ // same function; they differ only in who authorises (policy.mjs). Nothing else
30
+ // in this integration writes to a source file.
31
+ //
32
+ // The write is never a check followed by a rename: a program that saves the
33
+ // source between the two would lose its bytes. It is the publisher's atomic
34
+ // exchange, so whatever occupied the source path at the instant of the write
35
+ // is kept under the candidate's name and then looked at:
36
+ //
37
+ // 1 resolve the workspace, the pending edit, its view and its manifest
38
+ // 2 build the canonical graph now; the identity must still name the same
39
+ // path of an enrolled repository, and that path must be a regular file
40
+ // with one name, reached through no symbolic link, inside the repository,
41
+ // outside every managed root and the git directory, and not git-ignored
42
+ // 3 read the source now; run the lens from the PRESERVED edit bytes (never
43
+ // the live note) against it; record the observation of this edit and of
44
+ // every other open edit of the same object
45
+ // 4 take the object lease; settle an earlier apply whose outcome is unknown
46
+ // 5 refuse, writing nothing: replay of an applied operation (answered from
47
+ // the record), conflicted object, stale source, a lens refusal, a change
48
+ // outside the authored body, the policy, a missing exchange, a volume
49
+ // that differs from the source's
50
+ // 6 write-ahead apply record; candidate written and fsynced in a private
51
+ // per-apply directory of the workspace state, on the source's volume
52
+ // 7 `apply-intent` recorded in the object store
53
+ // 8 the policy is read again from disk; then the exchange
54
+ // 9 displaced bytes == base: the old source stays retained as the backup,
55
+ // with a receipt; the source is verified; `applied` is recorded
56
+ // displaced bytes != base: a concurrent writer. The files are exchanged
57
+ // BACK so its bytes return to the source path; what that displaces must
58
+ // be our candidate, and anything else is kept too. `apply-refused`,
59
+ // `concurrent-source-writer`, with every recovery reference
60
+ // 10 after a quiet period the backup is looked at again: a holder of the
61
+ // old file may still write into it (`source-changed-after-apply`)
62
+ //
63
+ // The candidate lives in `<workspace state>/recovery/<applyId>/000000/`, the
64
+ // directory layout of the recovery store, and never in the repository working
65
+ // tree: a stray file there could be committed by somebody. That requires the
66
+ // workspace state and the source to share a volume, since an exchange cannot
67
+ // cross one; when they do not, the apply refuses `apply-volume-mismatch` and
68
+ // writes nothing.
69
+ //
70
+ // Nothing here runs a git command that writes (the one git call asks whether a
71
+ // path is ignored), creates a source file, follows a symbolic link, or touches
72
+ // any path of a repository other than the one source file. Results, events and
73
+ // errors hold identifiers, digests, store references, codes and numbers only.
74
+
75
+ const EXT = OBSIDIAN_EXT_KEY
76
+ export const SOURCE_APPLY_PROTOCOL_ID = 'source-apply-exchange/v1'
77
+ export const SOURCE_APPLY_RECORD_SCHEMA = 'atelier-obsidian-source-apply/v1'
78
+ export const SOURCE_APPLY_DIRECTORY = path.join('state', 'source-apply')
79
+ export const SOURCE_APPLY_OPERATION_ID = 'atelier.source-apply/v1'
80
+ export const DEFAULT_APPLY_QUIET_PERIOD_MS = 1500
81
+ export const DEFAULT_APPLY_RECHECK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
82
+ // A batch a person names attempts at most this many edits: the largest bound a policy can carry.
83
+ export const MAX_MANUAL_BATCH_SIZE = 1000
84
+ const UNIT = 0
85
+
86
+ export const SOURCE_APPLY_STEPS = Object.freeze(['apply-record-written', 'candidate-written', 'intent-recorded', 'exchanged', 'exchanged-back', 'backup-recorded', 'applied-recorded', 'settled'])
87
+
88
+ export const SOURCE_APPLY_REFUSALS = Object.freeze([
89
+ 'integration-disabled', 'workspace-not-prepared', 'unknown-edit', 'foreign-workspace', 'edit-not-open', 'unknown-scope', 'manifest-unavailable', 'published-note-unavailable',
90
+ 'corpus-unreadable', 'repository-not-enrolled', 'source-not-in-graph', 'source-moved', 'source-missing', 'source-symlink', 'source-not-regular-file', 'source-unreadable', 'source-hard-linked', 'source-outside-repository',
91
+ 'source-inside-managed-root', 'source-inside-git-directory', 'source-git-ignored', 'source-ignore-state-unknown', 'sibling-edit-unobservable', 'lease-held', 'object-conflicted',
92
+ 'stale-source', 'edit-not-applicable', 'change-outside-authored-body', 'no-source-change', 'exchange-unavailable', 'apply-volume-mismatch', 'batch-bound-reached',
93
+ 'concurrent-source-writer', 'source-changed-during-apply', 'interrupted-before-exchange', 'apply-interrupted-needs-person', 'recovery-state-unreadable', 'apply-outcome-unknown', 'source-changed-after-apply',
94
+ ])
95
+
96
+ // Refusals that say the source or the object is contested, not that this machine or this request cannot apply.
97
+ const CONFLICT_CODES = new Set(['object-conflicted', 'stale-source', 'concurrent-source-writer', 'source-changed-during-apply', 'apply-interrupted-needs-person'])
98
+
99
+ export class SourceApplyRefusal extends Error {
100
+ constructor(code, detail = {}) {
101
+ super(code)
102
+ this.name = 'SourceApplyRefusal'
103
+ this.code = code
104
+ this.detail = detail
105
+ }
106
+ }
107
+ const ApplyRefusal = SourceApplyRefusal
108
+ const refuse = (code, detail) => { throw new ApplyRefusal(code, detail) }
109
+ const isTyped = (error) => error instanceof ApplyRefusal || error instanceof EditArbitrationRefusal || error instanceof PublicationRefusal || error instanceof ObsidianMaintenanceRefusal
110
+ || error instanceof ObsidianContractRefusal || error instanceof AtelierDiagnosticError
111
+ const segment = (identifier) => identifier.replaceAll(':', '_')
112
+ const inside = (parent, child) => { const relative = path.relative(parent, child); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) }
113
+ // What a path answers when another program removed or replaced it, or a directory on the way to it, since it was last
114
+ // looked at. Between the check of a path and the intent every such answer is a typed refusal, never an exception.
115
+ const GONE = new Set(['ENOENT', 'ENOTDIR', 'ELOOP'])
116
+ // A path this process may not look at is not "nothing there": null would let a caller rename over it. It is a typed
117
+ // refusal, so one unsearchable directory is reported for its own record and delays no other.
118
+ const lstatOrNull = (file, unreadable = 'recovery-state-unreadable') => {
119
+ try { return fs.lstatSync(file, { throwIfNoEntry: false }) ?? null } catch (error) {
120
+ if (GONE.has(error.code)) return null
121
+ if (UNREADABLE.has(error.code)) refuse(unreadable, { cause: error.code })
122
+ throw error
123
+ }
124
+ }
125
+
126
+ // One open, never through a symbolic link, and only of a regular file. A source can be replaced by another program
127
+ // at any moment, so nothing is assumed about the file between two calls: the mode comes from the same descriptor as
128
+ // the bytes.
129
+ function readNoFollow(file, { withMode = false } = {}) {
130
+ const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0))
131
+ try {
132
+ const stat = fs.fstatSync(descriptor)
133
+ if (!stat.isFile()) throw Object.assign(new Error('not a regular file'), { code: 'EISDIR' })
134
+ const bytes = fs.readFileSync(descriptor)
135
+ return withMode ? { bytes, mode: stat.mode & 0o777 } : bytes
136
+ } finally { fs.closeSync(descriptor) }
137
+ }
138
+
139
+ const NOT_A_FILE = new Set(['EISDIR', 'EINVAL', 'ENOTDIR', 'EMLINK'])
140
+ const UNREADABLE = new Set(['EACCES', 'EPERM', 'EIO'])
141
+
142
+ // The digest of the file, null when nothing is there, 'unreadable' when what is there is not a regular file: whatever
143
+ // that is, it is not bytes this module knows.
144
+ function digestOrNull(file) {
145
+ try { return sha256Digest(readNoFollow(file)) } catch (error) {
146
+ if (error.code === 'ENOENT') return null
147
+ // A file this process may not read is not bytes this module knows either: it is kept and never judged ours.
148
+ if (NOT_A_FILE.has(error.code) || error.code === 'ELOOP') return 'unreadable'
149
+ // Permission or I/O: this process could not look. That proves nothing about the file, unlike the values above.
150
+ if (UNREADABLE.has(error.code)) return 'denied'
151
+ throw error
152
+ }
153
+ }
154
+ const presentOf = (digest) => (digest === 'unreadable' || digest === 'denied' ? null : digest)
155
+
156
+ // No GIT_* variable of the caller reaches a git that is asked about a repository.
157
+ const gitEnvironment = (env) => ({ ...Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith('GIT_'))), GIT_OPTIONAL_LOCKS: '0' })
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // The decisions the oracles of test/obsidian-edits.test.mjs are sensitive to.
161
+ // Production always uses these; the tests substitute deliberately broken ones
162
+ // through createSourceApplyForOracleTests to prove that each oracle can fail.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export const SOURCE_APPLY_PRIMITIVES = Object.freeze({
166
+ // The edited bytes are the ones preserved when the edit was observed, never the note as it is now.
167
+ editedBytes: ({ store, edit }) => store.readObject(edit.observedDigest),
168
+ // One decision function for both modes; automatic mode reads the installed policy from disk inside it.
169
+ decide: decideApply,
170
+ // Asked a second time, immediately before the exchange.
171
+ decideAgain: true,
172
+ // Puts the candidate at the source path and keeps whatever was there, under the candidate's name.
173
+ commit: ({ candidatePath, sourcePath, exchange }) => exchange(candidatePath, sourcePath),
174
+ // A concurrent writer's bytes go back to the source path.
175
+ exchangeBack: ({ candidatePath, sourcePath, exchange }) => exchange(candidatePath, sourcePath),
176
+ // The late-writer check after a durable apply; a test may make it fail to prove the answer stays applied.
177
+ lateWriterCheck: (run) => run(),
178
+ // The displaced source is moved to its recovery name, never removed.
179
+ keepDisplaced: ({ from, to }) => { fs.renameSync(from, to); syncPrivateDirectory(path.dirname(to)) },
180
+ // Whether git ignores the path: true, false, or null when git cannot say.
181
+ isGitIgnored({ repositoryRoot, relative, env = process.env }) {
182
+ const result = spawnSync('git', ['-C', repositoryRoot, 'check-ignore', '-q', '--', relative], { env: gitEnvironment(env), stdio: 'ignore', timeout: 8000 })
183
+ return result.status === 0 ? true : result.status === 1 ? false : null
184
+ },
185
+ // Where git keeps this repository, which need not be `<root>/.git`: a worktree, a submodule and a repository made
186
+ // with a separate git directory name it in a `gitdir:` file. The absolute path, or null when git cannot say.
187
+ gitDirectory({ repositoryRoot, env = process.env }) {
188
+ const result = spawnSync('git', ['-C', repositoryRoot, 'rev-parse', '--absolute-git-dir'], { env: gitEnvironment(env), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 8000 })
189
+ const answer = result.status === 0 && typeof result.stdout === 'string' ? result.stdout.replace(/\r?\n$/, '') : ''
190
+ return answer !== '' && path.isAbsolute(answer) ? answer : null
191
+ },
192
+ })
193
+
194
+ // Observation of an edit whose base source is no longer the source: a conflict, decided without the lens.
195
+ const STALE_OBSERVER = createEditObserverForOracleTests({
196
+ preserve({ store, edit, baseSourceBytes }) {
197
+ let edited = null
198
+ try { edited = store.readObject(edit.observedDigest) } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'recovery-object-corrupt') throw error }
199
+ return edited === null ? { edited: null } : { edited, publishedRef: undefined, baseRef: store.retainObject(baseSourceBytes).ref }
200
+ },
201
+ classify: () => ({ kind: 'refusal', code: 'stale-base', detail: {} }),
202
+ })
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Where the source is
206
+ // ---------------------------------------------------------------------------
207
+
208
+ // The absolute path of the one file an apply may write, or a typed refusal. Every directory on the way is a real
209
+ // directory and the leaf is a regular file with a single name; nothing is created and nothing is followed.
210
+ //
211
+ // A git directory is refused three ways, because no one of them sees every case: any segment of the path spelled
212
+ // `.git` in any case (a nested repository or a submodule; a file system that folds case), the real path lying inside
213
+ // `<root>/.git`, and the real path lying inside the directory git itself names for the repository. A git that cannot
214
+ // name it refuses: nothing is written on a guess.
215
+ export function locateSource({ project, repoId, relative, managedRoots, isGitIgnored, gitDirectory = SOURCE_APPLY_PRIMITIVES.gitDirectory, env }) {
216
+ const repo = (project.repos ?? []).find((item) => item.name === repoId && !item.external && typeof item.path === 'string')
217
+ if (!repo) refuse('repository-not-enrolled')
218
+ const parts = typeof relative === 'string' ? relative.split('/') : []
219
+ if (parts.length === 0 || parts.some((part) => part === '' || part === '.' || part === '..') || path.isAbsolute(relative) || relative.includes('\\')) refuse('source-outside-repository')
220
+ if (parts.some((part) => part.toLowerCase() === '.git')) refuse('source-inside-git-directory')
221
+ let repositoryRoot
222
+ try { repositoryRoot = fs.realpathSync.native(repo.path) } catch { refuse('repository-not-enrolled', { cause: 'root-unreadable' }) }
223
+ let current = repositoryRoot
224
+ parts.forEach((part, index) => {
225
+ current = path.join(current, part)
226
+ // A component of the source path that may not be looked at is the source being unreadable, not recovery state.
227
+ const stat = lstatOrNull(current, 'source-unreadable')
228
+ if (stat === null) refuse('source-missing')
229
+ if (stat.isSymbolicLink()) refuse('source-symlink')
230
+ if (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile()) refuse(index < parts.length - 1 ? 'source-missing' : 'source-not-regular-file')
231
+ // Another name for the same file would keep the old bytes after an exchange, silently.
232
+ if (index === parts.length - 1 && stat.nlink > 1) refuse('source-hard-linked')
233
+ })
234
+ let real
235
+ try { real = fs.realpathSync.native(current) } catch { refuse('source-missing') }
236
+ if (!inside(repositoryRoot, real) || real === repositoryRoot) refuse('source-outside-repository')
237
+ for (const managedRoot of managedRoots) {
238
+ let managed
239
+ try { managed = fs.realpathSync.native(managedRoot) } catch { continue }
240
+ if (inside(managed, real)) refuse('source-inside-managed-root')
241
+ }
242
+ const realOrSelf = (directory) => { try { return fs.realpathSync.native(directory) } catch { return directory } }
243
+ if (inside(realOrSelf(path.join(repositoryRoot, '.git')), real)) refuse('source-inside-git-directory')
244
+ const gitDir = gitDirectory({ repositoryRoot, env })
245
+ if (typeof gitDir !== 'string') refuse('source-ignore-state-unknown', { cause: 'git-directory-unknown' })
246
+ if (inside(realOrSelf(gitDir), real)) refuse('source-inside-git-directory')
247
+ const ignored = isGitIgnored({ repositoryRoot, relative, env })
248
+ if (ignored === true) refuse('source-git-ignored')
249
+ if (ignored !== false) refuse('source-ignore-state-unknown')
250
+ return { absolute: real, repositoryRoot }
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // The operation
255
+ // ---------------------------------------------------------------------------
256
+
257
+ export function createSourceApplyForOracleTests(primitives = SOURCE_APPLY_PRIMITIVES) {
258
+ const rules = { ...SOURCE_APPLY_PRIMITIVES, ...primitives }
259
+
260
+ // `loadProject`, `dataRoot`, `env`, `platform` and `clock` are what the engine and the command are given.
261
+ // `crash(step)` is the crash seam; `beforeExchange()` runs after the intent is durable and before the policy is
262
+ // read again, so a test can land a writer or a revocation exactly there; `leasePid` lets a test hold a lease as a
263
+ // process that is gone, and `objectStore` lets it open the object store with the proof of that injected, since a
264
+ // real PID that exited can belong to another process a moment later; `extraManagedRoots` adds managed roots to the
265
+ // ones the workspace has.
266
+ return function createSourceApply(context = {}) {
267
+ const {
268
+ loadProject, dataRoot, env = process.env, platform = process.platform, clock = () => new Date(), eligibility = DEFAULT_ELIGIBILITY,
269
+ quietPeriodMs = DEFAULT_APPLY_QUIET_PERIOD_MS, recheckWindowMs = DEFAULT_APPLY_RECHECK_WINDOW_MS, exchangeOptions = {},
270
+ crash = () => {}, beforeExchange = async () => {}, leasePid, extraManagedRoots = [], manualBatchBound = MAX_MANUAL_BATCH_SIZE, objectStore = openObjectStore,
271
+ } = context
272
+ if (!Number.isInteger(manualBatchBound) || manualBatchBound < 1 || manualBatchBound > MAX_MANUAL_BATCH_SIZE) throw new TypeError('manualBatchBound is an integer from 1 to MAX_MANUAL_BATCH_SIZE')
273
+ if (typeof loadProject !== 'function') throw new TypeError('source apply needs loadProject')
274
+ const seams = { ...createProductionSeams(), ...(context.seams ?? {}) }
275
+ const exchange = (from, to) => exchangeFiles(from, to, exchangeOptions)
276
+
277
+ // -- the workspace ------------------------------------------------------
278
+
279
+ function openWorkspace() {
280
+ const project = loadProject()
281
+ const enablement = readObsidianEnablement(project)
282
+ if (enablement.state !== 'enabled') refuse('integration-disabled', { reason: enablement.reason })
283
+ const pointer = readLocalPointer(project)
284
+ if (pointer === null) refuse('workspace-not-prepared')
285
+ const requested = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId)
286
+ let workspaceRoot
287
+ try { workspaceRoot = fs.realpathSync(requested) } catch (error) { if (GONE.has(error.code)) refuse('workspace-not-prepared'); throw error }
288
+ const { workspaceId } = pointer
289
+ const repositoryRoots = protectedRoots(project)
290
+ const machine = readMachineSettings({ workspaceRoot, workspaceId })
291
+ if (machine === null) refuse('workspace-not-prepared')
292
+ const stores = new Map()
293
+ const workspace = {
294
+ project, enablement, workspaceRoot, workspaceId, repositoryRoots, machine,
295
+ stateStore: createMaintenanceStateStore({ workspaceRoot, workspaceId }),
296
+ objects: objectStore({ stateRoot: workspaceRoot, workspaceId, repositoryRoots, clock }),
297
+ storeOf(scopeId) {
298
+ if (!stores.has(scopeId)) stores.set(scopeId, seams.createRecoveryStore({ workspaceRoot, workspaceId, scopeId, repositoryRoots }))
299
+ return stores.get(scopeId)
300
+ },
301
+ recordsDir: () => ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, SOURCE_APPLY_DIRECTORY), label: 'source apply records' }),
302
+ }
303
+ return workspace
304
+ }
305
+
306
+ // The canonical graph as it is now, and the corpus profile of this machine. Built once per call.
307
+ function currentCorpus(workspace) {
308
+ // The graph reads every enrolled source. A file this process may not read is a refusal that names no file.
309
+ try {
310
+ workspace.corpus ??= {
311
+ graph: seams.buildGraph({ project: workspace.project, eligibility }),
312
+ profile: seams.profileFor({ project: workspace.project, workspaceId: workspace.workspaceId, audienceAllow: workspace.machine.audienceAllow }),
313
+ }
314
+ } catch (error) {
315
+ if (isTyped(error) || !UNREADABLE.has(error?.code)) throw error
316
+ refuse('corpus-unreadable', { cause: error.code })
317
+ }
318
+ return workspace.corpus
319
+ }
320
+
321
+ // The immutable manifest of one generation of one view. The current one when it is that generation; otherwise
322
+ // the stored file, whose name carries the generation and the head of its digest.
323
+ function manifestOf(workspace, scopeId, generationId) {
324
+ const store = workspace.storeOf(scopeId)
325
+ const current = store.readCurrentManifest()
326
+ if (current?.generationId === generationId) return current
327
+ const directory = path.join(workspace.workspaceRoot, 'state', 'manifests', segment(scopeId))
328
+ let names = []
329
+ try { names = fs.readdirSync(directory) } catch (error) { if (error.code !== 'ENOENT') throw error }
330
+ for (const name of names.filter((item) => item.startsWith(`${segment(generationId)}--`) && item.endsWith('.json')).sort()) {
331
+ let bytes
332
+ try { bytes = readFileBytes(path.join(directory, name)) } catch (error) { if (GONE.has(error.code)) continue; throw error }
333
+ if (name !== `${segment(generationId)}--${sha256Digest(bytes).slice(7, 19)}.json`) continue
334
+ const manifest = JSON.parse(bytes.toString('utf8'))
335
+ if (manifest.generationId === generationId && manifest.scopeId === scopeId) return manifest
336
+ }
337
+ return null
338
+ }
339
+
340
+ // The note exactly as it was published, which the lens needs to tell generated bytes from authored ones. The
341
+ // publisher keeps no copy of a note it created, so the note is prepared again from the sources as they are now
342
+ // and used only when it has the digest the manifest recorded; a retained object of that digest serves as well.
343
+ function publishedNoteOf(workspace, { scope, manifest, noteEntry }) {
344
+ const store = workspace.storeOf(scope.scopeId)
345
+ try { return store.readObject(noteEntry.noteDigest) } catch (error) { if (error.code !== 'ENOENT' && error.code !== 'recovery-object-corrupt') throw error }
346
+ workspace.prepared ??= new Map()
347
+ if (!workspace.prepared.has(scope.scopeId)) {
348
+ let files = []
349
+ try {
350
+ const { graph, profile } = currentCorpus(workspace)
351
+ const snapshot = seams.captureSnapshot({ project: workspace.project, graph, workspaceId: workspace.workspaceId, index: new Map(), configDigest: manifest.ext?.[EXT]?.configDigest ?? `sha256:${'0'.repeat(64)}`, capturedAt: isoTime(clock) })
352
+ files = seams.prepareView({ snapshot, profile, scope, persistentPathRegistry: workspace.stateStore.readPathRegistry(), priorManifest: manifest, existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8') }).files
353
+ } catch (error) { if (!isTyped(error) && !GONE.has(error?.code) && !UNREADABLE.has(error?.code)) throw error }
354
+ workspace.prepared.set(scope.scopeId, files)
355
+ }
356
+ const file = workspace.prepared.get(scope.scopeId).find((item) => item.path === noteEntry.path && item.digest === noteEntry.noteDigest)
357
+ return file ? file.bytes : null
358
+ }
359
+
360
+ // Everything about one pending edit that does not need the lease.
361
+ function resolveEdit(workspace, edit) {
362
+ const { repoId, nodeId } = edit.identity
363
+ const scope = workspace.enablement.scopes.find((item) => item.scopeId === edit.scopeId)
364
+ if (!scope) refuse('unknown-scope')
365
+ const manifest = manifestOf(workspace, edit.scopeId, edit.generationId)
366
+ const noteEntry = manifest?.notes.find((note) => note.repoId === repoId && note.nodeId === nodeId && note.path === edit.path)
367
+ if (!noteEntry) refuse('manifest-unavailable')
368
+ const recorded = noteEntry.ext?.[EXT]?.source
369
+ const baseSourceDigest = recorded?.rawDigest
370
+ if (typeof baseSourceDigest !== 'string') refuse('manifest-unavailable')
371
+ return { scope, manifest, noteEntry, recorded, baseSourceDigest, idempotencyKey: editIdempotencyKey({ workspaceId: workspace.workspaceId, repoId, nodeId, baseSourceDigest, observedDigest: edit.observedDigest }) }
372
+ }
373
+
374
+ // Runs the lens for one pending edit against `sourceBytes` and records the observation. The live note is never read.
375
+ function observe(workspace, edit, resolved, sourceBytes, sourceFile = null) {
376
+ const store = workspace.storeOf(edit.scopeId)
377
+ // The object store of the view, with the edited bytes supplied by the rule under test.
378
+ const reading = { ...store, readObject: (digest) => (digest === edit.observedDigest ? rules.editedBytes({ store, edit, workspace }) : store.readObject(digest)) }
379
+ let observed
380
+ if (sha256Digest(sourceBytes) !== resolved.baseSourceDigest) {
381
+ // The source moved on. The lens has nothing to say about it, and the note as it was published cannot be
382
+ // prepared again from a source that changed, so the conflict is recorded without either. The source as it
383
+ // is now is retained beside the edit.
384
+ observed = STALE_OBSERVER({ edit, manifest: resolved.manifest, publishedNoteBytes: Buffer.alloc(0), baseSourceBytes: sourceBytes, store: reading })
385
+ delete observed.operation.ext[EXT].publishedNoteRef
386
+ } else {
387
+ const publishedNoteBytes = publishedNoteOf(workspace, resolved)
388
+ // Preparing the note reads the source again: a source that changed since it was read here is a stale source.
389
+ if (publishedNoteBytes === null && sourceFile !== null) {
390
+ // A read this process is denied is not another program writing: it is the source being unreadable.
391
+ const present = digestOrNull(sourceFile)
392
+ if (present === 'denied') refuse('source-unreadable', { cause: 'denied-while-reading' })
393
+ if (present !== sha256Digest(sourceBytes)) refuse('stale-source', { cause: 'changed-while-reading' })
394
+ }
395
+ if (publishedNoteBytes === null) refuse('published-note-unavailable')
396
+ observed = observeEdit({ edit, manifest: resolved.manifest, publishedNoteBytes, baseSourceBytes: sourceBytes, store: reading })
397
+ }
398
+ workspace.objects.observe(observed.operation, { presentSourceDigest: sha256Digest(sourceBytes) })
399
+ return observed
400
+ }
401
+
402
+ // -- results ------------------------------------------------------------
403
+
404
+ const resultOf = (edit, status, code, extra = {}) => ({
405
+ status, code, editId: edit?.editId ?? null, repoId: edit?.identity.repoId ?? null, nodeId: edit?.identity.nodeId ?? null, ...extra,
406
+ })
407
+ const refusalResult = (edit, error, extra = {}) => resultOf(edit, CONFLICT_CODES.has(error.code) ? 'conflict' : 'refused', error.code, { ...extra, ...(error.detail && Object.keys(error.detail).length > 0 ? { detail: error.detail } : {}) })
408
+ const replayOf = (edit, outcome, code) => resultOf(edit, 'applied', code, {
409
+ replayed: true, idempotencyKey: outcome.idempotencyKey, oldSourceDigest: outcome.oldSourceDigest, newSourceDigest: outcome.newSourceDigest, actor: outcome.actor, policy: outcome.policy,
410
+ applyId: outcome.applyId, backupRef: outcome.backupRef,
411
+ })
412
+
413
+ // -- apply records: what was begun, written before anything else ---------
414
+
415
+ const recordFile = (workspace, applyId, suffix = '') => path.join(workspace.recordsDir(), `${segment(applyId)}${suffix}.json`)
416
+ function readJson(file) { try { return JSON.parse(readRegularTextNoFollow(file)) } catch (error) { if (error.code === 'ENOENT') return null; throw error } }
417
+ function settleRecord(workspace, applyId, outcome) {
418
+ try { publishPrivateFile(recordFile(workspace, applyId, '.settled'), canonicalJson({ schema: SOURCE_APPLY_RECORD_SCHEMA, applyId, ...outcome, settledAt: isoTime(clock) })) } catch (error) { if (error.code !== 'EEXIST') throw error }
419
+ }
420
+ function listRecords(workspace) {
421
+ let names = []
422
+ try { names = fs.readdirSync(path.join(workspace.workspaceRoot, SOURCE_APPLY_DIRECTORY)).sort() } catch (error) { if (error.code !== 'ENOENT') throw error }
423
+ const settled = new Set(names.filter((name) => name.endsWith('.settled.json')).map((name) => name.slice(0, -'.settled.json'.length)))
424
+ return names.filter((name) => name.endsWith('.json') && !name.endsWith('.settled.json') && !name.startsWith('.')).map((name) => {
425
+ const record = readJson(path.join(workspace.workspaceRoot, SOURCE_APPLY_DIRECTORY, name))
426
+ return record?.schema === SOURCE_APPLY_RECORD_SCHEMA && isIdentifier(record.applyId) ? { record, settled: settled.has(name.slice(0, -'.json'.length)) } : null
427
+ }).filter(Boolean)
428
+ }
429
+
430
+ // The displaced source stays where the exchange left it, under its recovery name, with a receipt that binds the
431
+ // digest it had. A closed journal of this protocol makes the maintenance engine look at it again on later ticks.
432
+ function keepBackup(workspace, record, { from, digestAtMove }) {
433
+ const store = workspace.storeOf(record.scopeId)
434
+ let target = store.displacedPath(record.applyId, UNIT)
435
+ for (let index = 1; lstatOrNull(target) !== null; index += 1) target = store.displacedPath(record.applyId, UNIT, `displaced-${index}.bin`)
436
+ rules.keepDisplaced({ from, to: target })
437
+ const recorded = recordDisplaced({ store, journalId: record.applyId, unit: UNIT, notePath: null, displacedPath: target, baseDigest: record.baseSourceDigest, at: isoTime(clock), digestAtMove })
438
+ try {
439
+ createJournal(store, { journalId: record.applyId, protocolId: SOURCE_APPLY_PROTOCOL_ID, expectedGeneration: record.generationId, targetGeneration: record.generationId, clock, detail: { kind: 'source-apply' } }).close()
440
+ } catch (error) { if (error.code !== 'EEXIST') throw error }
441
+ return recorded.displacedRef
442
+ }
443
+
444
+ // Removes the file at the candidate path only when it is our candidate; anything else is kept, with a receipt.
445
+ function retireCandidate(workspace, record) {
446
+ const store = workspace.storeOf(record.scopeId)
447
+ const result = retireStagedFile({ store, journalId: record.applyId, unit: UNIT, stagedPath: store.exchangeCandidatePath(record.applyId, UNIT), candidateDigest: record.newSourceDigest })
448
+ return result.capturedPaths.map((capturedPath) => recordDisplaced({ store, journalId: record.applyId, unit: UNIT, notePath: null, displacedPath: capturedPath, baseDigest: record.baseSourceDigest, at: isoTime(clock) }).displacedRef)
449
+ }
450
+
451
+ // -- settling an apply whose outcome is unknown, from digests only -------
452
+
453
+ // Caller holds the lease of the object. Returns the result that was decided, or null when nothing is known yet.
454
+ function settleInterrupted(workspace, lease, record, state) {
455
+ const store = workspace.storeOf(record.scopeId)
456
+ const known = state.applyOutcomes.findLast((outcome) => outcome.applyId === record.applyId)
457
+ const intent = state.intent?.applyId === record.applyId ? state.intent : null
458
+ const candidatePath = store.exchangeCandidatePath(record.applyId, UNIT)
459
+ const finish = (outcome) => { settleRecord(workspace, record.applyId, outcome); return outcome }
460
+ if (known) {
461
+ const refs = retireCandidate(workspace, record)
462
+ return finish({ status: known.status, code: known.code, recoveryRefs: refs })
463
+ }
464
+ if (intent === null) {
465
+ // No intent is durable, so the exchange was never called: whatever is at the candidate path was never a source.
466
+ const refs = retireCandidate(workspace, record)
467
+ return finish({ status: 'refused', code: 'interrupted-before-exchange', recoveryRefs: refs })
468
+ }
469
+ let sourceDigest = null
470
+ try {
471
+ const located = locateSource({ project: workspace.project, repoId: record.repoId, relative: record.sourcePath, managedRoots: [workspace.workspaceRoot, ...extraManagedRoots], isGitIgnored: rules.isGitIgnored, gitDirectory: rules.gitDirectory, env })
472
+ sourceDigest = digestOrNull(located.absolute)
473
+ } catch (error) {
474
+ if (!(error instanceof ApplyRefusal)) throw error
475
+ // Not being allowed to look at the source is not a state of the source: nothing is settled from it.
476
+ if (error.code === 'source-unreadable' || error.code === 'recovery-state-unreadable') refuse('recovery-state-unreadable', { cause: error.detail?.cause ?? 'source' })
477
+ }
478
+ if (sourceDigest === 'denied') refuse('recovery-state-unreadable', { cause: 'source' })
479
+ const refused = (code, disposition, recoveryRefs = []) => {
480
+ workspace.objects.recordRefused(lease, { idempotencyKey: record.idempotencyKey, code, presentSourceDigest: presentOf(sourceDigest), disposition, applyId: record.applyId, policy: intent.policy, ...(recoveryRefs.length > 0 ? { recoveryRefs } : {}) })
481
+ return finish({ status: CONFLICT_CODES.has(code) ? 'conflict' : 'refused', code, recoveryRefs })
482
+ }
483
+ const applied = (backupRef) => {
484
+ workspace.objects.recordApplied(lease, { idempotencyKey: record.idempotencyKey, oldSourceDigest: record.baseSourceDigest, newSourceDigest: record.newSourceDigest, actor: intent.actor, policy: intent.policy, applyId: record.applyId, backupRef })
485
+ return finish({ status: 'applied', code: 'applied-after-restart', backupRef })
486
+ }
487
+ const atCandidate = classifyCandidateFile({ file: lstatOrNull(candidatePath)?.isFile() ? candidatePath : null, candidateDigest: record.newSourceDigest })
488
+ if (atCandidate === 'generated-candidate') return refused('interrupted-before-exchange', 'retained', retireCandidate(workspace, record))
489
+ if (atCandidate === 'displaced-bytes') {
490
+ // The exchange happened and the move to the recovery name did not.
491
+ const digestAtMove = digestOrNull(candidatePath)
492
+ if (digestAtMove === 'denied') refuse('recovery-state-unreadable', { cause: 'displaced' })
493
+ const backupRef = keepBackup(workspace, record, { from: candidatePath, digestAtMove })
494
+ return digestAtMove === record.baseSourceDigest && sourceDigest === record.newSourceDigest ? applied(backupRef) : refused('apply-interrupted-needs-person', 'conflicted', [backupRef])
495
+ }
496
+ const receipt = store.listReceipts(record.applyId).find((item) => item.role === 'displaced' && item.unit === UNIT)
497
+ const displaced = store.displacedPath(record.applyId, UNIT)
498
+ if (receipt || lstatOrNull(displaced) !== null) {
499
+ const backupRef = receipt?.displacedRef ?? recordDisplaced({ store, journalId: record.applyId, unit: UNIT, notePath: null, displacedPath: displaced, baseDigest: record.baseSourceDigest, at: isoTime(clock) }).displacedRef
500
+ const digestAtMove = receipt?.digestAtMove ?? digestOrNull(displaced)
501
+ if (digestAtMove === 'denied') refuse('recovery-state-unreadable', { cause: 'backup' })
502
+ return digestAtMove === record.baseSourceDigest && sourceDigest === record.newSourceDigest ? applied(backupRef) : refused('apply-interrupted-needs-person', 'conflicted', [backupRef])
503
+ }
504
+ const refs = retireCandidate(workspace, record)
505
+ return sourceDigest === record.baseSourceDigest ? refused('interrupted-before-exchange', 'retained', refs) : refused('apply-interrupted-needs-person', 'conflicted', refs)
506
+ }
507
+
508
+ // -- one apply ------------------------------------------------------------
509
+
510
+ async function applyOne(workspace, request) {
511
+ const pending = workspace.stateStore.readPendingEdits()
512
+ const edit = pending.edits.find((item) => item.editId === request.editId)
513
+ if (!edit) return resultOf(null, 'refused', 'unknown-edit', { editId: typeof request.editId === 'string' ? request.editId.slice(0, 64) : null })
514
+ let lease = null
515
+ let crashed = false
516
+ // Set once the first exchange has taken place: from then on a typed failure is settled, never returned open.
517
+ let settleAfterExchange = null
518
+ const seam = (step, detail) => { try { crash(step, detail) } catch (error) { crashed = true; throw error } }
519
+ try {
520
+ if (edit.identity.workspaceId !== workspace.workspaceId) refuse('foreign-workspace')
521
+ const identity = { repoId: edit.identity.repoId, nodeId: edit.identity.nodeId }
522
+
523
+ // Whether this machine may see the object is asked first, of the canonical graph as it is now, before the
524
+ // manifest, the record of the object or the source path is looked at. An object that is withheld and one that
525
+ // is absent get this one answer, with nothing read and nothing recorded, so no later answer (a moved or
526
+ // deleted source, an earlier apply, a missing manifest) can tell a caller which of the two it is.
527
+ const { graph, profile } = currentCorpus(workspace)
528
+ const decideWith = (editClass, attempts) => rules.decide({ request, workspace: { workspaceRoot: workspace.workspaceRoot, workspaceId: workspace.workspaceId }, graph, profile, object: identity, editClass, attempts })
529
+ const visibility = decideWith('body-replacement', [])
530
+ if (!visibility.allowed && visibility.code === 'object-not-visible') refuse('object-not-visible')
531
+
532
+ const resolved = resolveEdit(workspace, edit)
533
+ const key = resolved.idempotencyKey
534
+
535
+ // A repeated request, or a retry after a lost reply, is answered from the record and writes nothing.
536
+ const before = workspace.objects.stateOf(identity)
537
+ const replay = (state) => {
538
+ const entry = state.operations.find((item) => item.idempotencyKey === key)
539
+ if (entry?.state === 'applied') return replayOf(edit, state.applyOutcomes.findLast((outcome) => outcome.status === 'applied' && outcome.idempotencyKey === key), 'already-applied')
540
+ if (entry?.state === 'superseded' && entry.reason === 'already-applied') {
541
+ const by = state.applyOutcomes.findLast((outcome) => outcome.status === 'applied' && outcome.idempotencyKey === entry.by)
542
+ if (by) return replayOf(edit, by, 'already-applied-by-equal-edit')
543
+ }
544
+ return null
545
+ }
546
+ if (before.intent === null && replay(before)) return replay(before)
547
+ if (!OPEN_EDIT_STATES.includes(edit.state) && before.intent === null) refuse('edit-not-open', { state: edit.state })
548
+ const attemptsIn = (state) => state.applyOutcomes.filter((outcome) => outcome.status === 'refused' && outcome.idempotencyKey === key && outcome.policy?.mode === 'automatic').map((outcome) => ({ policyDigest: outcome.policy.policyDigest }))
549
+
550
+ // The source, now. A visible object is in the graph; the first check stands for a decision that says otherwise.
551
+ const node = graph.nodes.find((item) => item.repo === identity.repoId && item.id === identity.nodeId)
552
+ if (!node) refuse('source-not-in-graph')
553
+ if (node.path !== resolved.recorded.path) refuse('source-moved')
554
+ // A retry budget that the record says is spent is known before anything is read: it appends nothing at all.
555
+ const early = decideWith('body-replacement', attemptsIn(before))
556
+ if (!early.allowed && early.code === 'retry-budget-exhausted') refuse(early.code, early.detail)
557
+ const vaultRoots = workspace.enablement.scopes.map((scope) => workspace.storeOf(scope.scopeId).vaultRoot)
558
+ const located = locateSource({ project: workspace.project, repoId: identity.repoId, relative: node.path, managedRoots: [workspace.workspaceRoot, ...vaultRoots, ...extraManagedRoots], isGitIgnored: rules.isGitIgnored, gitDirectory: rules.gitDirectory, env })
559
+ let source
560
+ try { source = readNoFollow(located.absolute, { withMode: true }) } catch (error) { refuse(error.code === 'ENOENT' ? 'source-missing' : error.code === 'ELOOP' ? 'source-symlink' : NOT_A_FILE.has(error.code) ? 'source-not-regular-file' : 'source-unreadable', { cause: error.code ?? 'unknown' }) }
561
+ const { bytes: sourceBytes, mode: sourceMode } = source
562
+ const sourceDigest = sha256Digest(sourceBytes)
563
+
564
+ // This edit and every other open edit of the object are observed before anything is decided, so a divergent
565
+ // edit made in another view makes the object conflicted before any source is written.
566
+ const observed = observe(workspace, edit, resolved, sourceBytes, located.absolute)
567
+ for (const sibling of pending.edits.filter((item) => item.editId !== edit.editId && OPEN_EDIT_STATES.includes(item.state) && item.identity.repoId === identity.repoId && item.identity.nodeId === identity.nodeId)) {
568
+ try { observe(workspace, sibling, resolveEdit(workspace, sibling), sourceBytes) } catch (error) {
569
+ if (!(error instanceof ApplyRefusal)) throw error
570
+ refuse('sibling-edit-unobservable', { cause: error.code })
571
+ }
572
+ }
573
+
574
+ const acquired = await workspace.objects.acquireLease(identity, leasePid === undefined ? {} : { pid: leasePid })
575
+ if (!acquired.acquired) refuse('lease-held', { reason: acquired.reason, needsPerson: acquired.needsPerson })
576
+ lease = acquired.lease
577
+ let state = acquired.object
578
+
579
+ // An earlier apply of this object whose outcome is unknown is settled first, from what is on disk.
580
+ if (state.intent !== null) {
581
+ const interrupted = listRecords(workspace).find((item) => item.record.applyId === state.intent.applyId)?.record
582
+ if (!interrupted) refuse('apply-interrupted-needs-person', { cause: 'apply-record-missing' })
583
+ settleInterrupted(workspace, lease, interrupted, state)
584
+ state = workspace.objects.stateOf(identity)
585
+ if (replay(state)) return replay(state)
586
+ }
587
+
588
+ const attempts = attemptsIn(state)
589
+ const decideNow = () => decideWith(observed.operation.kind, attempts)
590
+ let decision = null
591
+ // Recorded under the lease. A manual request that would only repeat the last recorded refusal records nothing
592
+ // more; an automatic one is counted every time, and its count is bounded by the retry budget.
593
+ const recordRefusal = (code, { disposition = 'retained', presentSourceDigest = sourceDigest, applyId, recoveryRefs = [] } = {}) => {
594
+ const last = state.applyOutcomes.findLast((outcome) => outcome.idempotencyKey === key)
595
+ const repeated = request.mode === 'manual' && last?.status === 'refused' && last.code === code && last.presentSourceDigest === presentSourceDigest && applyId === undefined
596
+ const entry = state.operations.find((item) => item.idempotencyKey === key)
597
+ if (repeated || !entry || !['pending', 'conflicted'].includes(entry.state)) return
598
+ workspace.objects.recordRefused(lease, { idempotencyKey: key, code, presentSourceDigest, disposition, applyId, policy: decision?.allowed ? decision.policy : undefined, ...(recoveryRefs.length > 0 ? { recoveryRefs } : {}) })
599
+ }
600
+ const refuseRecorded = (code, options = {}, detail = {}) => { recordRefusal(code, options); refuse(code, detail) }
601
+
602
+ decision = decideNow()
603
+ if (!decision.allowed && decision.code === 'retry-budget-exhausted') refuse(decision.code, decision.detail)
604
+
605
+ const entry = state.operations.find((item) => item.idempotencyKey === key)
606
+ if (sourceDigest !== resolved.baseSourceDigest) refuseRecorded('stale-source', { disposition: 'conflicted' })
607
+ if (state.state === 'conflicted' || entry?.state === 'conflicted') refuseRecorded('object-conflicted', { disposition: 'conflicted' }, { reason: entry?.reason ?? null })
608
+ if (observed.outcome.kind !== 'body-replacement' || entry?.state !== 'pending') refuseRecorded('edit-not-applicable', {}, { cause: observed.outcome.code ?? entry?.state ?? null })
609
+ const { newSourceBytes, newSourceDigest, changedRanges } = observed.outcome
610
+ const bodyStart = readMarkdownLens(sourceBytes).body.start
611
+ if (changedRanges.some((range) => range.start < bodyStart || range.newStart < bodyStart) || !newSourceBytes.subarray(0, bodyStart).equals(sourceBytes.subarray(0, bodyStart)) || newSourceDigest !== entry.newSourceDigest) {
612
+ refuseRecorded('change-outside-authored-body', { disposition: 'refused' })
613
+ }
614
+ if (changedRanges.length === 0 || newSourceDigest === sourceDigest) refuseRecorded('no-source-change', { disposition: 'refused' })
615
+ if (!decision.allowed) refuseRecorded(decision.code, {}, decision.detail)
616
+
617
+ // The candidate's directory, on the volume of the source, with an exchange that works there.
618
+ const store = workspace.storeOf(edit.scopeId)
619
+ const recoveryRoot = store.resolve('recovery')
620
+ const volumeOf = (directory, code) => { try { return fs.statSync(directory).dev } catch (error) { if (GONE.has(error.code)) refuse(code, { cause: 'changed-while-applying' }); throw error } }
621
+ if (volumeOf(recoveryRoot, 'workspace-not-prepared') !== volumeOf(path.dirname(located.absolute), 'source-missing')) refuseRecorded('apply-volume-mismatch')
622
+ const probe = probeExchange({ directory: store.exchangeProbeDir(), ...exchangeOptions })
623
+ if (!probe.supported) refuseRecorded('exchange-unavailable', {}, { cause: probe.code })
624
+
625
+ // Write-ahead: the apply record, then the candidate, then the intent.
626
+ const applyId = newJournalId(clock)
627
+ const record = {
628
+ schema: SOURCE_APPLY_RECORD_SCHEMA, applyId, workspaceId: workspace.workspaceId, repoId: identity.repoId, nodeId: identity.nodeId, editId: edit.editId, scopeId: edit.scopeId, generationId: edit.generationId,
629
+ idempotencyKey: key, sourcePath: node.path, baseSourceDigest: sourceDigest, newSourceDigest, mode: request.mode, actor: decision.actor, policy: decision.policy, at: isoTime(clock),
630
+ }
631
+ publishPrivateFile(recordFile(workspace, applyId), canonicalJson(record))
632
+ seam('apply-record-written', { applyId })
633
+ const unitDir = store.unitDir(applyId, UNIT)
634
+ const candidatePath = store.exchangeCandidatePath(applyId, UNIT)
635
+ const descriptor = openRegularFileNoFollow(candidatePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, sourceMode)
636
+ try { fs.writeFileSync(descriptor, newSourceBytes); fs.fchmodSync(descriptor, sourceMode); fs.fsyncSync(descriptor) } finally { fs.closeSync(descriptor) }
637
+ syncPrivateDirectory(unitDir)
638
+ const readBack = digestOrNull(candidatePath)
639
+ // Before the intent exists the candidate is only ours: a denied read back is reported as that, and the file is retired.
640
+ if (readBack === 'denied') { retireCandidate(workspace, record); refuse('recovery-state-unreadable', { cause: 'candidate-read-back' }) }
641
+ if (readBack !== newSourceDigest) throw new Error('the candidate read back differs from the bytes written')
642
+ seam('candidate-written', { applyId })
643
+
644
+ workspace.objects.recordIntent(lease, { idempotencyKey: key, expectedSourceDigest: sourceDigest, newSourceDigest, actor: decision.actor, policy: decision.policy, applyId })
645
+ seam('intent-recorded', { applyId })
646
+ const abandon = (code, options = {}, detail = {}) => {
647
+ const refs = [...retireCandidate(workspace, record), ...(options.recoveryRefs ?? [])]
648
+ workspace.objects.recordRefused(lease, { idempotencyKey: key, code, presentSourceDigest: options.presentSourceDigest ?? null, disposition: options.disposition ?? 'retained', applyId, policy: decision.policy, ...(refs.length > 0 ? { recoveryRefs: refs } : {}) })
649
+ settleRecord(workspace, applyId, { status: CONFLICT_CODES.has(code) ? 'conflict' : 'refused', code, recoveryRefs: refs })
650
+ settleAfterExchange = null
651
+ return refuse(code, { ...detail, applyId, ...(refs.length > 0 ? { recoveryRefs: refs } : {}) })
652
+ }
653
+
654
+ await beforeExchange({ applyId, editId: edit.editId, mode: request.mode })
655
+ // Revoked, paused or replaced since it was decided: nothing is written.
656
+ if (rules.decideAgain) { const again = decideNow(); if (!again.allowed) abandon(again.code, {}, again.detail) }
657
+
658
+ const appliedAnswer = (settled) => resultOf(edit, 'applied', settled.code, { replayed: false, idempotencyKey: key, oldSourceDigest: sourceDigest, newSourceDigest, actor: decision.actor, policy: decision.policy, applyId, backupRef: settled.backupRef })
659
+ // Once the source may have been exchanged, a refusal (an answer that nothing was written) is no longer honest:
660
+ // a typed failure that leaves the intent open is settled from the digests on disk exactly as restart recovery
661
+ // would, an apply that did happen is answered as applied, and if even that cannot be done the answer says so.
662
+ // Armed from the first exchange until the outcome is durable, so it never speaks of a settled record.
663
+ const settleFromDisk = (error) => {
664
+ let state
665
+ try { state = workspace.objects.stateOf(identity) } catch (again) { if (!isTyped(again)) throw again; state = null }
666
+ if (state !== null && state.intent === null) return refusalResult(edit, error)
667
+ try {
668
+ if (state === null) throw error
669
+ const settled = settleInterrupted(workspace, lease, record, state)
670
+ if (settled.status === 'applied') return appliedAnswer(settled)
671
+ return refusalResult(edit, new ApplyRefusal(settled.code, { applyId, cause: error.code, ...((settled.recoveryRefs ?? []).length > 0 ? { recoveryRefs: settled.recoveryRefs } : {}) }))
672
+ } catch (again) {
673
+ if (!isTyped(again)) throw again
674
+ return resultOf(edit, 'conflict', 'apply-outcome-unknown', { applyId, detail: { cause: again.code ?? error.code } })
675
+ }
676
+ }
677
+ try { rules.commit({ candidatePath, sourcePath: located.absolute, exchange }) } catch (error) {
678
+ if (!isTyped(error) && error?.name !== 'ExchangeRefusal') throw error
679
+ // An exchange that says it failed is not believed either way: the files are read. Only the candidate still
680
+ // at its path beside the source as it was read says that nothing was exchanged. Anything else is decided
681
+ // exactly as restart recovery decides it, from digests on disk, so an exchange that did take place is
682
+ // recorded as applied, with its backup, and never as a refusal.
683
+ const presentSourceDigest = digestOrNull(located.absolute)
684
+ if (digestOrNull(candidatePath) === newSourceDigest && presentSourceDigest === sourceDigest) abandon('exchange-unavailable', { presentSourceDigest }, { cause: error.code })
685
+ // The digests did not show the clean state: from here the exchange may have taken place.
686
+ settleAfterExchange = settleFromDisk
687
+ return settleAfterExchange(error)
688
+ }
689
+ settleAfterExchange = settleFromDisk
690
+ seam('exchanged', { applyId })
691
+
692
+ // What the exchange displaced is the truth about what the source was at that instant.
693
+ const displacedDigest = digestOrNull(candidatePath)
694
+ // Not being able to look is not evidence of a change: nothing is decided from it. The settlement reads again.
695
+ if (displacedDigest === 'denied') return settleAfterExchange(new ApplyRefusal('recovery-state-unreadable', { cause: 'displaced-unreadable' }))
696
+ if (displacedDigest === sourceDigest) {
697
+ syncPrivateDirectory(path.dirname(located.absolute))
698
+ const backupRef = keepBackup(workspace, record, { from: candidatePath, digestAtMove: displacedDigest })
699
+ seam('backup-recorded', { applyId })
700
+ const written = digestOrNull(located.absolute)
701
+ if (written === 'denied') return settleAfterExchange(new ApplyRefusal('source-unreadable', { cause: 'source-unreadable-after-exchange' }))
702
+ if (written !== newSourceDigest) {
703
+ // Somebody wrote the source again already. The old source and the candidate are both retained.
704
+ const kept = store.retainObject(newSourceBytes).ref
705
+ workspace.objects.recordRefused(lease, { idempotencyKey: key, code: 'source-changed-during-apply', presentSourceDigest: presentOf(written), disposition: 'conflicted', applyId, policy: decision.policy, recoveryRefs: [backupRef, kept] })
706
+ settleRecord(workspace, applyId, { status: 'conflict', code: 'source-changed-during-apply', recoveryRefs: [backupRef, kept] })
707
+ settleAfterExchange = null
708
+ refuse('source-changed-during-apply', { applyId, recoveryRefs: [backupRef, kept] })
709
+ }
710
+ workspace.objects.recordApplied(lease, { idempotencyKey: key, oldSourceDigest: sourceDigest, newSourceDigest, actor: decision.actor, policy: decision.policy, applyId, backupRef })
711
+ seam('applied-recorded', { applyId })
712
+ settleRecord(workspace, applyId, { status: 'applied', code: 'applied', backupRef })
713
+ settleAfterExchange = null
714
+ seam('settled', { applyId })
715
+ workspace.objects.releaseLease(lease)
716
+ lease = null
717
+ if (quietPeriodMs > 0) await sleep(quietPeriodMs)
718
+ // The apply is durable. A typed failure of the late-writer check does not turn it into a refusal: the answer
719
+ // is applied, and the check is repeated by the engine on later ticks.
720
+ let lateWriters
721
+ try { lateWriters = rules.lateWriterCheck(() => recheckBackups(workspace, [record])) } catch (error) {
722
+ if (!isTyped(error)) throw error
723
+ return resultOf(edit, 'applied', 'applied', { replayed: false, idempotencyKey: key, oldSourceDigest: sourceDigest, newSourceDigest, actor: decision.actor, policy: decision.policy, applyId, backupRef, lateWriterCheck: { status: 'unavailable', code: error.code } })
724
+ }
725
+ return resultOf(edit, 'applied', lateWriters.length > 0 ? 'source-changed-after-apply' : 'applied', {
726
+ replayed: false, idempotencyKey: key, oldSourceDigest: sourceDigest, newSourceDigest, actor: decision.actor, policy: decision.policy, applyId, backupRef,
727
+ ...(lateWriters.length > 0 ? { lateWriters } : {}),
728
+ })
729
+ }
730
+
731
+ // A writer got in between the read and the exchange. Its bytes are at the candidate path: they go back.
732
+ // An immutable copy is kept first, so the bytes have a receipt whatever happens to the file next.
733
+ // Nothing between the two exchanges may return while the intent is open: retaining their bytes and the exchange
734
+ // back share one rule with the first exchange. A typed failure, or a file that is gone or cannot be read, is
735
+ // settled from what is on disk; anything else (a crash, a programming error) propagates.
736
+ let theirs = null
737
+ try {
738
+ theirs = displacedDigest !== null && displacedDigest !== 'unreadable' && displacedDigest !== 'denied' ? store.retainObject(readNoFollow(candidatePath)).ref : null
739
+ rules.exchangeBack({ candidatePath, sourcePath: located.absolute, exchange })
740
+ } catch (error) {
741
+ if (!isTyped(error) && error?.name !== 'ExchangeRefusal' && !GONE.has(error?.code) && !UNREADABLE.has(error?.code)) throw error
742
+ // The files could not be exchanged back. Nothing is guessed: what is on disk is kept and settled from digests.
743
+ const settled = settleInterrupted(workspace, lease, record, workspace.objects.stateOf(identity))
744
+ // Read from disk, the first exchange may turn out to have displaced the base after all: that is an apply.
745
+ if (settled.status === 'applied') return appliedAnswer(settled)
746
+ refuse(settled.code, { applyId, recoveryRefs: [...(settled.recoveryRefs ?? []), ...(theirs ? [theirs] : [])] })
747
+ }
748
+ seam('exchanged-back', { applyId })
749
+ syncPrivateDirectory(path.dirname(located.absolute))
750
+ return abandon('concurrent-source-writer', { disposition: 'conflicted', presentSourceDigest: presentOf(digestOrNull(located.absolute)), recoveryRefs: theirs ? [theirs] : [] })
751
+ } catch (error) {
752
+ if (crashed || !isTyped(error)) throw error
753
+ return settleAfterExchange ? settleAfterExchange(error) : refusalResult(edit, error)
754
+ } finally {
755
+ // A crashed process releases nothing.
756
+ if (lease !== null && !crashed) try { workspace.objects.releaseLease(lease) } catch { /* the next holder proves this one gone */ }
757
+ }
758
+ }
759
+
760
+ // -- late writers ---------------------------------------------------------
761
+
762
+ function recheckBackups(workspace, records) {
763
+ const findings = []
764
+ for (const scopeId of [...new Set(records.map((record) => record.scopeId))]) {
765
+ const journalIds = records.filter((record) => record.scopeId === scopeId).map((record) => record.applyId)
766
+ for (const finding of recheckDisplacedFiles({ store: workspace.storeOf(scopeId), journalIds, clock })) {
767
+ if (finding.code !== 'late-writer-captured') continue
768
+ const record = records.find((item) => item.applyId === finding.journalId)
769
+ findings.push({ code: 'source-changed-after-apply', applyId: finding.journalId, editId: record.editId, repoId: record.repoId, nodeId: record.nodeId, backupRef: finding.displacedRef, digestAtMove: finding.digestAtMove, observedDigest: finding.observedDigest, objectRef: finding.objectRef })
770
+ }
771
+ }
772
+ return findings
773
+ }
774
+
775
+ const guarded = async (run) => {
776
+ let workspace
777
+ try { workspace = openWorkspace() } catch (error) { if (!isTyped(error)) throw error; return { workspace: null, refusal: error } }
778
+ return { workspace, value: await run(workspace) }
779
+ }
780
+
781
+ return {
782
+ id: SOURCE_APPLY_OPERATION_ID,
783
+
784
+ // request: { editId, mode: 'manual' | 'automatic', actor?, policyDigest? }
785
+ async apply(request) {
786
+ const { workspace, refusal, value } = await guarded((opened) => applyOne(opened, { ...request }))
787
+ return workspace === null ? refusalResult(null, refusal, { editId: typeof request?.editId === 'string' ? request.editId.slice(0, 64) : null }) : value
788
+ },
789
+
790
+ // Several edits under one request. At most a bound of them are attempted: `maxBatchSize` of the installed
791
+ // policy in automatic mode, `manualBatchBound` for a batch a person names. The rest are refused
792
+ // `batch-bound-reached` and nothing is written for them.
793
+ async applyBatch({ editIds, ...request }) {
794
+ const results = []
795
+ let bound = manualBatchBound
796
+ for (const editId of editIds) {
797
+ if (results.filter((result) => result.code !== 'batch-bound-reached').length >= bound) { results.push(resultOf(null, 'refused', 'batch-bound-reached', { editId })); continue }
798
+ const { workspace, refusal, value } = await guarded((opened) => {
799
+ // Read again for every edit: a policy replaced in the middle of a batch bounds the rest of it.
800
+ if (request.mode === 'automatic') bound = installedBatchBound(opened)
801
+ return applyOne(opened, { ...request, editId })
802
+ })
803
+ results.push(workspace === null ? refusalResult(null, refusal, { editId }) : value)
804
+ }
805
+ return results
806
+ },
807
+
808
+ // Settles every apply that was begun and never finished. Decided from digests on disk; safe to run again.
809
+ async recover() {
810
+ const { workspace, refusal, value } = await guarded(async (opened) => {
811
+ const report = []
812
+ for (const { record, settled } of listRecords(opened)) {
813
+ if (settled) continue
814
+ const identity = { repoId: record.repoId, nodeId: record.nodeId }
815
+ // Every record is settled on its own: an object whose log or store cannot be read is reported by its
816
+ // code, left exactly as it is, and keeps no other interrupted apply waiting.
817
+ let lease = null
818
+ try {
819
+ const acquired = await opened.objects.acquireLease(identity, leasePid === undefined ? {} : { pid: leasePid })
820
+ if (!acquired.acquired) { report.push({ applyId: record.applyId, editId: record.editId, status: 'refused', code: 'lease-held' }); continue }
821
+ lease = acquired.lease
822
+ const outcome = settleInterrupted(opened, lease, record, acquired.object)
823
+ report.push({ applyId: record.applyId, editId: record.editId, repoId: record.repoId, nodeId: record.nodeId, ...outcome })
824
+ } catch (error) {
825
+ if (!isTyped(error)) throw error
826
+ report.push({ applyId: record.applyId, editId: record.editId, repoId: record.repoId, nodeId: record.nodeId, status: 'refused', code: error.code })
827
+ } finally {
828
+ if (lease !== null) try { opened.objects.releaseLease(lease) } catch (error) { if (!isTyped(error)) throw error }
829
+ }
830
+ }
831
+ return report
832
+ })
833
+ return workspace === null ? { recovered: [], refusal: { code: refusal.code } } : { recovered: value }
834
+ },
835
+
836
+ // Looks again at every backup of a recent apply. A holder of the old file can write later still: repeat it.
837
+ async recheck() {
838
+ const { workspace, value } = await guarded((opened) => {
839
+ const nowMs = Date.parse(isoTime(clock))
840
+ return recheckBackups(opened, listRecords(opened).map((item) => item.record).filter((record) => nowMs - Date.parse(record.at) <= recheckWindowMs))
841
+ })
842
+ return workspace === null ? [] : value
843
+ },
844
+
845
+ // Pending and conflicted operations: identities, states and codes. No note text, title or path.
846
+ async list() {
847
+ const { workspace, refusal, value } = await guarded((opened) => {
848
+ const objects = new Map(opened.objects.list().objects.filter((item) => item.repoId !== null).map((item) => [`${item.repoId}\u0000${item.nodeId}`, item]))
849
+ return opened.stateStore.readPendingEdits().edits.filter((edit) => OPEN_EDIT_STATES.includes(edit.state)).sort((left, right) => compareText(left.observedAt, right.observedAt) || compareText(left.editId, right.editId)).map((edit) => {
850
+ const object = objects.get(`${edit.identity.repoId}\u0000${edit.identity.nodeId}`) ?? null
851
+ return { editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, scopeId: edit.scopeId, state: edit.state, observedAt: edit.observedAt, attempts: edit.attempts, lastCode: edit.lastResult?.code ?? null,
852
+ object: object === null ? null : { state: object.state, code: object.code ?? null, operations: (object.operations ?? []).map(({ idempotencyKey, kind, state, reason }) => ({ idempotencyKey, kind, state, reason })) } }
853
+ })
854
+ })
855
+ if (workspace === null) throw refusal
856
+ return value
857
+ },
858
+
859
+ async show(editId) {
860
+ const { workspace, refusal, value } = await guarded((opened) => {
861
+ const edit = opened.stateStore.readPendingEdits().edits.find((item) => item.editId === editId)
862
+ if (!edit) refuse('unknown-edit')
863
+ const identity = { repoId: edit.identity.repoId, nodeId: edit.identity.nodeId }
864
+ const state = opened.objects.stateOf(identity)
865
+ let key = null
866
+ try { key = resolveEdit(opened, edit).idempotencyKey } catch (error) { if (!(error instanceof ApplyRefusal)) throw error }
867
+ const entry = state.operations.find((item) => item.idempotencyKey === key) ?? null
868
+ const records = listRecords(opened).map((item) => item.record).filter((record) => record.editId === editId)
869
+ return {
870
+ editId, ...identity, scopeId: edit.scopeId, generationId: edit.generationId, state: edit.state, observedAt: edit.observedAt, observedDigest: edit.observedDigest, objectRef: edit.objectRef,
871
+ object: { state: state.state, sourceDigest: state.sourceDigest, outcomeUnknown: state.intent !== null },
872
+ operation: entry === null ? null : { idempotencyKey: entry.idempotencyKey, kind: entry.kind, state: entry.state, reason: entry.reason, baseSourceDigest: entry.baseSourceDigest, newSourceDigest: entry.newSourceDigest },
873
+ outcomes: state.applyOutcomes.filter((outcome) => outcome.idempotencyKey === key),
874
+ lateWriters: recheckBackups(opened, records),
875
+ }
876
+ })
877
+ if (workspace === null) throw refusal
878
+ return value
879
+ },
880
+ }
881
+
882
+ // Without a readable policy every edit is refused by the decision itself; the bound is then the largest a policy can carry.
883
+ function installedBatchBound(workspace) {
884
+ try { return readInstalledApplyPolicy({ workspaceRoot: workspace.workspaceRoot, workspaceId: workspace.workspaceId })?.maxBatchSize ?? MAX_MANUAL_BATCH_SIZE } catch (error) { if (!isTyped(error)) throw error; return MAX_MANUAL_BATCH_SIZE }
885
+ }
886
+ }
887
+ }
888
+
889
+ export const createSourceApply = createSourceApplyForOracleTests()