@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -1,6 +1,8 @@
1
+ import { createCooperativeBudget, prepareViewCooperatively } from '../../projection/obsidian/materialize/prepare-view.mjs'
1
2
  import { createHash } from 'node:crypto'
3
+ import fs from 'node:fs'
2
4
  import path from 'node:path'
3
- import { buildCanonicalGraph, createGraphFileCache } from '../../graph/graph.mjs'
5
+ import { buildCanonicalGraph, buildCanonicalGraphSteps, createGraphFileCache } from '../../graph/graph.mjs'
4
6
  import { markdownMetadata } from '../../graph/knowledge-graph.mjs'
5
7
  import { EMITTER_VERSION, createPreparationCache, prepareView, readMarkdownLens, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
6
8
  import { personalWorkspaceGraph } from '../../projection/obsidian/personal-workspace.mjs'
@@ -128,9 +130,99 @@ export function profileFor({ project, workspaceId, audienceAllow }) {
128
130
  // `deferPersonalValidation` (the engine), an unconfirmed key refuses `personal-validation-pending` instead of composing
129
131
  // on this thread.
130
132
  export function buildGraph({ project, eligibility, cache = null, index = null, deferPersonalValidation = false }) {
131
- const observedDigest = cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null
132
- const canonical = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation }) ?? buildCanonicalGraph(project, { fileCache: cache, observedDigest })
133
- if (!canonical.ok) refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
133
+ const observedDigest = observedDigestOf(cache, index)
134
+ let canonical = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation })
135
+ if (canonical === null) {
136
+ try { canonical = buildCanonicalGraph(project, { fileCache: cache, observedDigest }) } catch (error) {
137
+ refuseObservedDirectory(error, { project, index })
138
+ throw error
139
+ }
140
+ }
141
+ return eligibleGraph(canonical, eligibility, { project, index })
142
+ }
143
+
144
+ // The same graph, built with the event loop given a turn between the Markdown sources of the census, under the
145
+ // caller's cooperative budget (`scheduling`: createCooperativeBudget's options, its guard checked at every wait). For
146
+ // the maintenance engine's tick: a first tick reads every source, and a large corpus would hold the service's
147
+ // listener for the whole census. A project bound to a personal workspace is built by its own route, in one stretch,
148
+ // as buildGraph builds it.
149
+ export async function buildGraphCooperatively({ project, eligibility, cache = null, index = null, deferPersonalValidation = false, scheduling = {} }) {
150
+ const observedDigest = observedDigestOf(cache, index)
151
+ // The personal route answers for a bound project, and refuses a project that carries the reserved key unbound.
152
+ const personal = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation })
153
+ if (personal !== null) return eligibleGraph(personal, eligibility, { project, index })
154
+ const budget = createCooperativeBudget(scheduling)
155
+ budget.check()
156
+ const steps = buildCanonicalGraphSteps(project, { fileCache: cache, observedDigest })
157
+ let canonical
158
+ try {
159
+ for (;;) {
160
+ const step = steps.next()
161
+ if (step.done) { canonical = step.value; break }
162
+ await budget.checkpoint(step.value)
163
+ }
164
+ } catch (error) {
165
+ steps.return?.()
166
+ refuseObservedDirectory(error, { project, index })
167
+ throw error
168
+ }
169
+ budget.finish('graph-assembled')
170
+ return eligibleGraph(canonical, eligibility, { project, index })
171
+ }
172
+
173
+ const observedDigestOf = (cache, index) => (cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null)
174
+
175
+ // Only a digest from this tick's observation establishes that the path was a readable source before the build.
176
+ // These exceptional checks never enrol a path or admit an invalid graph; without prior observation its error stays.
177
+ function* observedSources({ project, index }, changeClass = null) {
178
+ if (!(index instanceof Map)) return
179
+ for (const repo of project?.repos ?? []) {
180
+ if (repo?.external || typeof repo?.path !== 'string' || typeof repo?.name !== 'string') continue
181
+ const root = path.resolve(repo.path)
182
+ const prefix = sourceKey(repo.name, '')
183
+ for (const [key, entry] of index) {
184
+ if (typeof key !== 'string' || !key.startsWith(prefix) || typeof entry?.digest !== 'string' || (changeClass !== null && entry.changeClass !== changeClass)) continue
185
+ const relative = key.slice(prefix.length)
186
+ const file = path.resolve(root, ...relative.split('/'))
187
+ const inside = path.relative(root, file)
188
+ if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) continue
189
+ yield file
190
+ }
191
+ }
192
+ }
193
+
194
+ function directoryNow(file) {
195
+ try { return fs.lstatSync(file).isDirectory() } catch { return false }
196
+ }
197
+
198
+ function refuseObservedDirectory(error, observed) {
199
+ if (error?.code !== 'EISDIR') return
200
+ // readFileSync's EISDIR need not name a path. In that case an observed file that is now a directory still proves
201
+ // this build spans two source states; the next full observation decides what the directory now contributes.
202
+ const named = typeof error.path === 'string' ? path.resolve(error.path) : null
203
+ for (const file of observedSources(observed)) {
204
+ if ((named === null || named === file) && directoryNow(file)) refuse('mixed-read', 'an observed source became a directory while the canonical graph was being built', { cause: 'EISDIR' })
205
+ }
206
+ }
207
+
208
+ function eligibleGraph(canonical, eligibility, observed) {
209
+ if (!canonical.ok) {
210
+ // The graph records a sidecar read failure as invalid metadata. A sidecar this tick already read and that is
211
+ // gone now is instead a mixed read, as a source removed during the census is. A never-observed sidecar stays invalid.
212
+ const roots = new Map((observed.project?.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
213
+ const sidecars = new Set((canonical.nodes ?? []).flatMap((node) => typeof node.sidecar === 'string' && roots.has(node.repo)
214
+ ? [path.resolve(roots.get(node.repo), ...node.sidecar.split('/'))] : []))
215
+ for (const file of observedSources(observed, 'sidecar')) {
216
+ if (!sidecars.has(file)) continue
217
+ let stat
218
+ try { stat = fs.lstatSync(file) } catch (error) {
219
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') refuse('mixed-read', 'an observed sidecar was removed while the canonical graph was being built', { cause: error.code })
220
+ continue
221
+ }
222
+ if (stat.isDirectory()) refuse('mixed-read', 'an observed sidecar became a directory while the canonical graph was being built', { cause: 'EISDIR' })
223
+ }
224
+ refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
225
+ }
134
226
  return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
135
227
  }
136
228
 
@@ -141,6 +233,12 @@ export function buildGraph({ project, eligibility, cache = null, index = null, d
141
233
  // its pinned digest is unchanged and the cached bytes are the ones that digest
142
234
  // describes, so bytes that drift under an unchanged pin are not consulted
143
235
  // until observation hashes the file again (the same bound as the graph stage).
236
+ // The engine keeps that bound when it prepares cooperatively. Called on its
237
+ // own, prepareViewCooperatively waits between notes and so reads every
238
+ // selected source against its pin once more before it returns. The engine
239
+ // turns that second reading off and checks the selected sources itself, after
240
+ // the preparation and again before the commit, as observation does: by stat
241
+ // hint, and by digest only where the hint moved.
144
242
  export function captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt }) {
145
243
  const roots = new Map((project.repos ?? []).filter((repo) => !repo.external).map((repo) => [repo.name, repo.path]))
146
244
  const absolute = (repoId, relative) => {
@@ -149,11 +247,13 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
149
247
  return path.join(root, ...relative.split('/'))
150
248
  }
151
249
  const byRepo = new Map([...roots.keys()].map((repoId) => [repoId, new Map()]))
250
+ const observedReads = new Set()
152
251
  // Every node, and every asset a view may copy. A withheld asset is never pinned: it is in no snapshot document.
153
252
  const pinned = [...graph.nodes, ...(Array.isArray(graph.assets) ? graph.assets.filter((asset) => asset.eligible === true) : [])]
154
253
  for (const item of pinned) {
155
254
  if (!byRepo.has(item.repo)) refuse('source-not-in-snapshot', 'the graph names a repository the project does not enrol')
156
255
  const observed = index.get(sourceKey(item.repo, item.path))
256
+ if (typeof observed?.digest === 'string') observedReads.add(sourceKey(item.repo, item.path))
157
257
  const facts = observed?.digest ? observed : readFileFacts(absolute(item.repo, item.path))
158
258
  if (!facts) refuse('mixed-read', 'a file the graph read is no longer there')
159
259
  byRepo.get(item.repo).set(item.path, { path: item.path, rawDigest: facts.digest, byteLength: facts.byteLength })
@@ -176,7 +276,16 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
176
276
  repositories,
177
277
  },
178
278
  graph,
179
- readSource: (repoId, relative) => readFileBytes(absolute(repoId, relative)),
279
+ readSource: (repoId, relative) => {
280
+ const file = absolute(repoId, relative)
281
+ try { return readFileBytes(file) } catch (error) {
282
+ if (observedReads.has(sourceKey(repoId, relative))) {
283
+ if (error?.code === 'ELEAFCHANGED') refuse('mixed-read', 'a selected source was replaced while its view was being prepared or published', { cause: error.code })
284
+ if ((error?.code === 'EISDIR' || error?.message === 'state leaf is not a regular file') && directoryNow(file)) refuse('mixed-read', 'a selected source became a directory while its view was being prepared or published', { cause: 'EISDIR' })
285
+ }
286
+ throw error
287
+ }
288
+ },
180
289
  }
181
290
  }
182
291
 
@@ -186,5 +295,5 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
186
295
  // that note and reuses the rest. Both caches are derived, in-memory state: a
187
296
  // test may replace either seam with `() => null` to build or prepare in full.
188
297
  export function createProductionSeams() {
189
- return { buildGraph, captureSnapshot, profileFor, prepareView, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
298
+ return { buildGraph, buildGraphCooperatively, captureSnapshot, profileFor, prepareView, prepareViewCooperatively, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
190
299
  }
@@ -31,7 +31,7 @@ import { ObsidianMaintenanceRefusal } from './errors.mjs'
31
31
  // No answer in time proves nothing (a busy holder answers late). A ticket
32
32
  // from another machine, an unreadable ticket, an unknown file, or a live
33
33
  // process that recorded no health address all need a person: see
34
- // `inspectPrivateGenerationLock` and docs/local-services.md.
34
+ // `inspectPrivateGenerationLock` and docs/reference/local-services.md.
35
35
 
36
36
  export const LOCK_TICKET_SCHEMA = 'atelier-obsidian-lock-ticket/v1'
37
37
  export const ENGINE_LOCK_DIRECTORY = path.join('state', 'maintenance', 'engine-lock')
@@ -89,6 +89,20 @@ function listGenerations(directory) {
89
89
 
90
90
  const ticketName = (generation, kind) => `${String(generation).padStart(12, '0')}.${kind}`
91
91
 
92
+ // A ticket is removed only while it holds the given bytes: one that holds others is another holder's. Answers whether
93
+ // nothing of these bytes is left at the path (removed now, or gone already); throws what reading or removing it threw.
94
+ function removeIfStill(file, body) {
95
+ let current
96
+ try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') return true; throw error }
97
+ if (current !== body) return true
98
+ fs.unlinkSync(file)
99
+ return true
100
+ }
101
+
102
+ // Tickets this process published and could not remove after its acquisition failed, by lock directory. Each is
103
+ // removed at this process's next acquisition in that directory, before the directory is looked at.
104
+ const UNWITHDRAWN = new Map()
105
+
92
106
  // Read-only. { available, reason, generation, ticket }
93
107
  export async function inspectPrivateGenerationLock({ directory, workspaceId, proveAbandoned = createAbandonmentProof(), nowMs = Date.now() }) {
94
108
  const { names, highest, unknown } = listGenerations(directory)
@@ -114,42 +128,72 @@ export async function inspectPrivateGenerationLock({ directory, workspaceId, pro
114
128
 
115
129
  // { acquired: true, generation, release() } or { acquired: false, reason, holder }.
116
130
  // Never waits and never throws for a lock that is merely held.
117
- export async function acquirePrivateGenerationLock({ workspaceRoot, directory, workspaceId, purpose, service = null, clock, proveAbandoned, randomBytes = cryptoRandomBytes }) {
131
+ // `validateLocation`, when given, proves the private state's location before the lock directory is made, again after
132
+ // the asynchronous inspection, and once a ticket is published, before predecessors are pruned. A refusal at the first
133
+ // two writes nothing; a refusal at the last withdraws the ticket just published, and prunes nothing.
134
+ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, workspaceId, purpose, service = null, clock, proveAbandoned, randomBytes = cryptoRandomBytes, validateLocation = () => {} }) {
135
+ validateLocation()
118
136
  const contained = ensureContainedPrivateDirectory({ workspaceRoot, directory, label: 'private lock' })
137
+ const owed = UNWITHDRAWN.get(contained)
138
+ if (owed !== undefined) {
139
+ // Still failing: the ticket stays, and this acquisition finds it as held by this process, as before.
140
+ try { if (removeIfStill(owed.file, owed.body)) UNWITHDRAWN.delete(contained) } catch { /* tried again at the next acquisition */ }
141
+ }
119
142
  const acquiredAt = isoTime(clock)
120
143
  const status = await inspectPrivateGenerationLock({ directory: contained, workspaceId, proveAbandoned, nowMs: Date.parse(acquiredAt) })
121
144
  const holder = status.ticket ? { pid: status.ticket.pid, purpose: status.ticket.purpose, runtimeId: status.ticket.service?.runtimeId ?? null } : null
122
145
  if (!status.available) return { acquired: false, reason: status.reason, holder }
123
146
  if (status.generation >= MAX_GENERATION) return { acquired: false, reason: 'lock-generations-exhausted', holder }
147
+ validateLocation()
124
148
  const generation = status.generation + 1
125
149
  const ticket = validateTicket({ schema: LOCK_TICKET_SCHEMA, workspaceId, purpose, pid: process.pid, machine: machineDigest(), nonce: randomBytes(16).toString('hex'), acquiredAt, service }, workspaceId)
126
150
  const body = JSON.stringify(ticket)
127
151
  const file = path.join(contained, ticketName(generation, 'json'))
152
+ // A ticket published and then not handed to its caller, because a later step failed (the directory sync or the
153
+ // read-back of the publication, the listing that confirms it), is removed while it still holds these bytes: nobody
154
+ // would release it, and every later acquisition by this process would find it held by this process. One that does
155
+ // not hold them is another holder's and is left alone. A removal that fails too (the same fault, say) is owed, and
156
+ // made at the next acquisition in this directory.
157
+ const withdraw = () => {
158
+ try { removeIfStill(file, body) } catch { UNWITHDRAWN.set(contained, { file, body }) }
159
+ }
128
160
  try { publishPrivateFile(file, body) } catch (error) {
129
161
  if (error.code === 'EEXIST') return { acquired: false, reason: 'lost-the-race-for-this-generation', holder: null }
162
+ withdraw()
130
163
  throw error
131
164
  }
132
165
  // Confirm: a decision made on an old listing finds the newer ticket here and withdraws.
133
- const after = listGenerations(contained)
166
+ let after
167
+ try { after = listGenerations(contained) } catch (error) {
168
+ withdraw()
169
+ throw error
170
+ }
134
171
  if (after.highest !== generation) {
135
172
  try { fs.unlinkSync(file) } catch { /* a lower generation is ignored and pruned by the next holder */ }
136
173
  return { acquired: false, reason: 'a-newer-holder-exists', holder: null }
137
174
  }
175
+ try { validateLocation() } catch (error) {
176
+ withdraw()
177
+ throw error
178
+ }
138
179
  for (const name of after.names) {
139
180
  if (Number(TICKET_NAME.exec(name)[1]) < generation) try { fs.unlinkSync(path.join(contained, name)) } catch { /* pruned by somebody else */ }
140
181
  }
182
+ // Settled once the lock is given back, or found not to be ours any more. A release that throws (a full disk, an
183
+ // I/O error while the released ticket is written) settles nothing: the lock is still held, and calling release()
184
+ // again tries again. Writing the same released ticket a second time is harmless.
141
185
  let released = false
142
186
  return {
143
187
  acquired: true,
144
188
  generation,
145
189
  release() {
146
190
  if (released) return false
147
- released = true
148
191
  let current
149
- try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') return false; throw error }
192
+ try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') { released = true; return false } throw error }
150
193
  // Taken over after a proof of abandonment (this process was thought gone): nothing here is ours to release.
151
- if (current !== body) return false
194
+ if (current !== body) { released = true; return false }
152
195
  publishPrivateFile(path.join(contained, ticketName(generation, 'released')), body)
196
+ released = true
153
197
  return true
154
198
  },
155
199
  }
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { inspectPrivateLock, withPrivateLock } from '../../project/durable-state.mjs'
5
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow } from '../../project/private-state.mjs'
6
+ import { canonicalJson, closedObject } from './documents.mjs'
7
+ import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
8
+
9
+ // Machine-private continuity evidence, separate from the person's append-only selection history. The maximum never
10
+ // decreases, and previously observed record identities remain known after the person confirms a shorter history.
11
+ export const SELECTION_CONTINUITY_SCHEMA = 'atelier-obsidian-selection-continuity/v1'
12
+ export const MAX_SELECTION_IDENTITIES = 8192
13
+ export const MAX_SELECTION_CONTINUITY_BYTES = 1024 * 1024
14
+ const DIGEST = /^[0-9a-f]{64}$/
15
+ const CONFIRMATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
16
+ const SOURCE = Object.freeze({ source: 'personal-workspace' })
17
+ const invalid = () => refuse('invalid-selection-continuity', 'the private selection continuity state cannot be verified; nothing is loaded', SOURCE)
18
+ const count = (value) => Number.isSafeInteger(value) && value >= 0
19
+
20
+ function validate(document, workspaceId, homeDigest) {
21
+ closedObject(document, { required: ['schema', 'workspaceId', 'homeDigest', 'highestSequence', 'accepted', 'seen', 'confirmations'] }, 'invalid-selection-continuity', 'the selection continuity state')
22
+ closedObject(document.accepted, { required: ['sequence', 'head'] }, 'invalid-selection-continuity', 'the accepted selection')
23
+ if (document.schema !== SELECTION_CONTINUITY_SCHEMA || document.workspaceId !== workspaceId || document.homeDigest !== homeDigest
24
+ || !count(document.highestSequence) || !count(document.accepted.sequence) || document.accepted.sequence > document.highestSequence
25
+ || !Array.isArray(document.seen) || document.seen.length > MAX_SELECTION_IDENTITIES || document.seen.some((digest) => typeof digest !== 'string' || !DIGEST.test(digest))
26
+ || !Array.isArray(document.confirmations) || document.confirmations.length > MAX_SELECTION_IDENTITIES || document.confirmations.some((id) => typeof id !== 'string' || !CONFIRMATION_ID.test(id))
27
+ || new Set(document.confirmations).size !== document.confirmations.length
28
+ || new Set(document.seen).size !== document.seen.length || document.highestSequence > document.seen.length
29
+ || (document.accepted.sequence === 0 ? document.accepted.head !== 'genesis' : !document.seen.includes(document.accepted.head))) invalid()
30
+ return document
31
+ }
32
+
33
+ export function createSelectionContinuity({ workspaceRoot, workspaceId, personalHome, validateLocation = () => {} }) {
34
+ const homeDigest = createHash('sha256').update(personalHome).digest('hex')
35
+ const directory = () => ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, 'state', 'maintenance'), label: 'selection continuity state' })
36
+ const file = path.join(workspaceRoot, 'state', 'maintenance', 'selection-continuity.json')
37
+ const lockPath = path.join(workspaceRoot, 'state', 'maintenance', 'selection-continuity.lock')
38
+ let observedState = false
39
+ let pending = null // an observed head whose durable write has not yet been proved
40
+ // The durable accepted head that pending was planned over. Pending keeps its maxima and identities, but never its
41
+ // accepted head once another writer has durably accepted something else.
42
+ let pendingBase = null
43
+ function read({ initializing = false } = {}) {
44
+ directory()
45
+ let fd
46
+ try {
47
+ fd = openRegularFileNoFollow(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0))
48
+ if (fs.fstatSync(fd).size > MAX_SELECTION_CONTINUITY_BYTES) invalid()
49
+ const bytes = fs.readFileSync(fd)
50
+ if (bytes.length > MAX_SELECTION_CONTINUITY_BYTES) invalid()
51
+ const document = validate(JSON.parse(bytes), workspaceId, homeDigest)
52
+ observedState = true
53
+ return document
54
+ } catch (error) {
55
+ if (error.code === 'ENOENT' && !observedState) {
56
+ const ownership = inspectPrivateLock(lockPath)
57
+ const unused = ownership.available && ownership.reason === 'unused' && ownership.generation === 0
58
+ // Only called inside withPrivateLock's operation: generation one is this operation's own first ticket.
59
+ const ownFirstTicket = initializing && !ownership.available && ownership.reason === 'process exists' && ownership.generation === 1
60
+ if (ownership.legacyDigest === null && (initializing ? ownFirstTicket : unused)) return null
61
+ }
62
+ invalid()
63
+ } finally { if (fd !== undefined) fs.closeSync(fd) }
64
+ }
65
+ function next(before, head) {
66
+ const seen = new Set(before?.seen ?? [])
67
+ const confirmations = new Set(before?.confirmations ?? [])
68
+ const same = before?.accepted.sequence === head.sequence && before?.accepted.head === head.head
69
+ // Length alone never proves continuation: the verified chain must still hold the accepted head at its position.
70
+ const extendsAccepted = before === null || before.accepted.sequence === 0
71
+ || (head.sequence > before.accepted.sequence && head.recordDigests[before.accepted.sequence - 1] === before.accepted.head)
72
+ const shorter = before !== null && (head.sequence < before.highestSequence || head.sequence <= before.accepted.sequence || !extendsAccepted)
73
+ const fresh = head.confirmationId !== null && !confirmations.has(head.confirmationId) && !seen.has(head.head)
74
+ if (!same && shorter && !fresh) refuse('selection-history-shortened', 'the selection history no longer follows the private continuity state; restore the removed records or confirm the selection again', SOURCE)
75
+ for (const digest of head.recordDigests) seen.add(digest)
76
+ for (const id of head.confirmationIds) confirmations.add(id)
77
+ if (seen.size > MAX_SELECTION_IDENTITIES || confirmations.size > MAX_SELECTION_IDENTITIES) refuse('selection-continuity-capacity', 'the private selection continuity state has reached its identity limit; nothing is loaded', SOURCE)
78
+ return validate({ schema: SELECTION_CONTINUITY_SCHEMA, workspaceId, homeDigest,
79
+ highestSequence: Math.max(before?.highestSequence ?? 0, head.sequence), accepted: { sequence: head.sequence, head: head.head }, seen: [...seen].sort(), confirmations: [...confirmations].sort() }, workspaceId, homeDigest)
80
+ }
81
+ function withPending(current) {
82
+ if (pending === null) return current
83
+ if (current === null) return pending
84
+ const seen = [...new Set([...current.seen, ...pending.seen])].sort()
85
+ const confirmations = [...new Set([...current.confirmations, ...pending.confirmations])].sort()
86
+ if (seen.length > MAX_SELECTION_IDENTITIES || confirmations.length > MAX_SELECTION_IDENTITIES) refuse('selection-continuity-capacity', 'the private selection continuity state has reached its identity limit; nothing is loaded', SOURCE)
87
+ const durableMoved = canonicalJson(current.accepted) !== pendingBase
88
+ return { ...pending, highestSequence: Math.max(current.highestSequence, pending.highestSequence), seen, confirmations,
89
+ accepted: durableMoved || current.highestSequence > pending.highestSequence ? current.accepted : pending.accepted }
90
+ }
91
+ function note(head) {
92
+ try {
93
+ validateLocation()
94
+ const disk = read()
95
+ const before = withPending(disk)
96
+ const planned = next(before, head)
97
+ if (disk !== null && canonicalJson(disk) === canonicalJson(planned)) { pending = null; pendingBase = null; return head }
98
+ if (pending === null || (disk !== null && canonicalJson(disk.accepted) !== pendingBase)) pendingBase = disk === null ? null : canonicalJson(disk.accepted)
99
+ pending = planned
100
+ // Proven again before the lock is taken and before the checkpoint is replaced.
101
+ validateLocation()
102
+ withPrivateLock(lockPath, () => {
103
+ const current = read({ initializing: disk === null })
104
+ // Decided again over the checkpoint as it reads under the lock.
105
+ const document = next(withPending(current), head)
106
+ pendingBase = current === null ? null : canonicalJson(current.accepted)
107
+ pending = document
108
+ const text = canonicalJson(document)
109
+ if (Buffer.byteLength(text) > MAX_SELECTION_CONTINUITY_BYTES) invalid()
110
+ validateLocation()
111
+ if (current === null || canonicalJson(current) !== text) atomicReplacePrivateText(file, text)
112
+ // An uncertain write is never authority to return a project.
113
+ if (canonicalJson(read()) !== text) invalid()
114
+ })
115
+ pending = null
116
+ pendingBase = null
117
+ return head
118
+ } catch (error) {
119
+ if (error instanceof ObsidianMaintenanceRefusal) throw error
120
+ refuse('selection-continuity-unavailable', 'the private selection continuity state could not be kept; nothing is loaded', SOURCE)
121
+ }
122
+ }
123
+ return Object.freeze({ note })
124
+ }
@@ -4,12 +4,17 @@ import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { firstString, parseArgs, resolveProjectConfig } from '../../project/config.mjs'
6
6
  import { workspaceStateRoot } from './machine-settings.mjs'
7
- import { RELEASE_CHANGED, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
7
+ import { loadSelectedProjectOffThread, locatePersonalHome } from './personal-selection.mjs'
8
+ import { RELEASE_CHANGED, isLocationRefusalCode, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
8
9
 
9
10
  // The process of the maintenance service. `start` runs it as a child; a unit
10
11
  // installed at the operating-system level runs it with `--startup`.
11
12
  //
12
- // --project=<absolute atelier.project.json> required
13
+ // --project=<absolute atelier.project.json> required, unless a personal home is named instead
14
+ // --personal-home=<absolute directory> instead of --project: the private home of a personal workspace. The
15
+ // service follows the generation its person confirmed there, composed off
16
+ // the event loop, and starts whether or not one is confirmed: it locates
17
+ // its workspace from the home alone (personal-selection.mjs)
13
18
  // --data-root=<absolute directory> optional; otherwise the pointer, the overlay or the platform default
14
19
  // --workspace-id=<identifier> named by a login item: under `--startup`, where a refusal is recorded
15
20
  // when the project no longer leads to a workspace
@@ -77,17 +82,54 @@ export const EXIT_REFUSED = 2
77
82
  // manager restarts it (a non-zero exit), on the release now installed. EX_TEMPFAIL.
78
83
  export const EXIT_RELEASE_CHANGED = 75
79
84
 
85
+ const readOr = (read, otherwise) => { try { return read() } catch { return otherwise } }
80
86
  const printed = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
81
87
 
88
+ // The first answer of the loader, or of the locator of a personal home, asked for once and handed to both who need it
89
+ // at a start under a login item: the lookup of the workspace below, and the service's own start, which takes it as the
90
+ // answer to its first call. One that answers a promise or another thenable is not waited for by either. A promise whose
91
+ // `constructor` reads as this realm's `Promise` is kept as it is; any other thenable (as a rule another realm's promise
92
+ // or a subclass's) is adopted here, once, as a native promise (so its `then` is called once in all). That promise is let go, handled
93
+ // without calling a `then` of its own: a refusal of it never goes unhandled and ends the process with an error where
94
+ // the service's typed refusal should end it cleanly. The service is handed that promise. Whatever is thrown,
95
+ // `undefined` included, and a `then` that cannot be read, is thrown again at the service's first call, so it ends the
96
+ // start as a failure of the loader would.
97
+ function firstAnswerOf(loadProject) {
98
+ let first
99
+ try {
100
+ const project = loadProject()
101
+ if (typeof project?.then === 'function') {
102
+ const promised = Promise.resolve(project)
103
+ Promise.prototype.then.call(promised, undefined, () => {})
104
+ first = { promised }
105
+ } else first = { project }
106
+ } catch (error) { first = { threw: true, error } }
107
+ let taken = false
108
+ return {
109
+ // The project, when the loader answered one at once; null when it threw or answered a promise.
110
+ project: () => (first.threw || first.promised ? null : first.project),
111
+ threw: () => first.threw === true,
112
+ loadProject() {
113
+ if (taken) return loadProject()
114
+ taken = true
115
+ if (first.threw) throw first.error
116
+ return first.promised ?? first.project
117
+ },
118
+ }
119
+ }
120
+
82
121
  // The workspace of a service a login item started, when it can be found: where it keeps its log and records how its
83
- // start ended. When the project no longer leads to one (it moved, its pointer is gone), the workspace the unit names by
84
- // its data root and identity, if that exists; it is never created here. Null otherwise; a refusal is then printed
85
- // only, into the unit's own output file.
86
- function startupWorkspace({ loadProject, dataRoot, workspaceId, env = process.env, platform = process.platform }) {
122
+ // start ended. When the project no longer leads to one (it moved, its pointer is gone, its loader answered no project
123
+ // at once), the workspace the unit names by its data root and identity, if that exists; it is never created here. Null
124
+ // otherwise, and null when locating the workspace refused or a personal home's locator refused: the recorded
125
+ // coordinates are not proven outside the roots declared now. A refusal is then printed only, into the unit's own
126
+ // output file.
127
+ function startupWorkspace({ project, located = false, locatorRefused = false, dataRoot, workspaceId, env = process.env, platform = process.platform }) {
128
+ if (located && locatorRefused) return null
87
129
  try {
88
- const workspace = resolveServiceWorkspace({ project: loadProject(), dataRoot, env, platform })
130
+ const workspace = project === null ? null : resolveServiceWorkspace({ project, dataRoot, env, platform })
89
131
  if (workspace?.workspaceRoot) return workspace
90
- } catch { /* the workspace the unit names, below */ }
132
+ } catch { return null }
91
133
  if (typeof dataRoot !== 'string' || !path.isAbsolute(dataRoot) || typeof workspaceId !== 'string') return null
92
134
  try {
93
135
  const root = workspaceStateRoot(path.resolve(dataRoot), workspaceId)
@@ -120,10 +162,14 @@ function recordStartup(workspace, { at, outcome, code }) {
120
162
  // and the manager restarts it after its throttle.
121
163
  export async function runServiceProcess(options) {
122
164
  const startup = options.startup === true
123
- const workspace = startup ? startupWorkspace(options) : null
165
+ // A located personal home is asked instead of the loader: the loader is then not asked before the service's engine.
166
+ const located = typeof options.locateProject === 'function'
167
+ const asked = located ? options.locateProject : options.loadProject
168
+ const first = startup && typeof asked === 'function' ? firstAnswerOf(asked) : null
169
+ const workspace = startup ? startupWorkspace({ ...options, located, locatorRefused: first?.threw() === true, project: first === null ? null : first.project() }) : null
124
170
  const log = workspace === null ? printed : workspaceLog(workspace.workspaceRoot)
125
171
  try {
126
- const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options })
172
+ const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options, ...(first === null ? {} : { [located ? 'locateProject' : 'loadProject']: first.loadProject }) })
127
173
  if (startup) recordStartup(workspace, { at: new Date().toISOString(), outcome: 'started', code: null })
128
174
  for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { void service.shutdown(`signal-${signal}`) })
129
175
  const { reason } = await service.done
@@ -131,9 +177,12 @@ export async function runServiceProcess(options) {
131
177
  process.exit(reason === RELEASE_CHANGED ? EXIT_RELEASE_CHANGED : 0)
132
178
  } catch (error) {
133
179
  const at = new Date().toISOString()
134
- const code = typeof error?.code === 'string' ? error.code : null
135
- log({ at, event: 'refused', code: code ?? 'untyped-error', message: String(error?.message ?? error) })
136
- if (startup && code !== null) { recordStartup(workspace, { at, outcome: 'refused', code }); process.exitCode = 0; return }
180
+ // What was thrown is read without trusting it: a value with no prototype, or a getter that throws, is still logged.
181
+ const code = readOr(() => (typeof error?.code === 'string' ? error.code : null), null)
182
+ // A location refusal is printed only: nothing more is written under that private-state root.
183
+ const location = isLocationRefusalCode(code)
184
+ ;(location ? printed : log)({ at, event: 'refused', code: code ?? 'untyped-error', message: readOr(() => String(error?.message ?? error), 'unprintable') })
185
+ if (startup && code !== null) { if (!location) recordStartup(workspace, { at, outcome: 'refused', code }); process.exitCode = 0; return }
137
186
  process.exitCode = code !== null ? EXIT_REFUSED : 1
138
187
  }
139
188
  }
@@ -149,13 +198,16 @@ function watchRelease(invokedAs) {
149
198
  export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
150
199
  const args = parseArgs(argv)
151
200
  const configPath = firstString(args.project)
152
- if (!configPath || !path.isAbsolute(configPath)) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
201
+ const personalHome = firstString(args['personal-home'])
202
+ if (personalHome !== null && (configPath !== null || !path.isAbsolute(personalHome))) throw Object.assign(new Error('--personal-home must be the absolute path of a private home, given instead of --project'), { code: 'service-arguments-invalid' })
203
+ if (personalHome === null && (!configPath || !path.isAbsolute(configPath))) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
153
204
  const dataRoot = firstString(args['data-root']) ?? undefined
154
205
  const workspaceId = firstString(args['workspace-id']) ?? undefined
155
206
  const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
156
207
  if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
157
208
  return {
158
- loadProject: () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
209
+ loadProject: personalHome !== null ? () => loadSelectedProjectOffThread({ personalHome, dataRoot, env }) : () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
210
+ ...(personalHome === null ? {} : { locateProject: () => locatePersonalHome({ personalHome }) }),
159
211
  ...(dataRoot === undefined ? {} : { dataRoot }), ...(workspaceId === undefined ? {} : { workspaceId }), ...(intervalMs === undefined ? {} : { intervalMs }),
160
212
  ...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
161
213
  startup: args.startup === true, adapter: firstString(args.adapter),
@@ -167,7 +219,7 @@ const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.rea
167
219
  if (invokedDirectly) {
168
220
  const argv = process.argv.slice(2)
169
221
  // As early as this entry can: its static imports have already loaded most of the modules, so a release that changes
170
- // between this process's start and this reading is not noticed until the next change (docs/obsidian.md says so).
222
+ // between this process's start and this reading is not noticed until the next change (docs/guides/obsidian.md says so).
171
223
  const releaseWatch = argv.includes('--startup') ? watchRelease(process.argv[1]) : null
172
224
  let options = null
173
225
  try {