@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
@@ -15,10 +15,11 @@ import {
15
15
  CONSENT_COVERAGES, SERVICE_SETTINGS_SCHEMA, executableIdentity, openServiceLog, publicRecord, readLastStartup, readServiceRecord, readServiceSettings, releaseOfEntry, removeServiceRecord,
16
16
  serviceNameFor, servicePaths, writeServiceSettings,
17
17
  } from './service-record.mjs'
18
- import { resolveServiceWorkspace } from './service.mjs'
18
+ import { personalHomeOf } from './personal-selection.mjs'
19
+ import { resolveServiceWorkspace, serviceLocationCheck } from './service.mjs'
19
20
 
20
21
  // start / status / stop of the maintenance service of one workspace, as
21
- // docs/local-services.md describes them.
22
+ // docs/reference/local-services.md describes them.
22
23
  //
23
24
  // Ownership is proven, never assumed. A running service is ours only when the
24
25
  // owner-only record and the health answer agree on service name, workspace,
@@ -61,10 +62,12 @@ export const LIFECYCLE_PRIMITIVES = Object.freeze({
61
62
  provesOurProcess: (record) => processRunsRecordedExecutable(record),
62
63
  })
63
64
 
64
- function context({ loadProject, dataRoot, env, platform, create = false, randomBytes }) {
65
+ // `locateProject`, when given, answers what the workspace is resolved from without loading the project (a bound
66
+ // personal home, locatePersonalHome): its service is then found, started and stopped whatever its selection holds.
67
+ function context({ loadProject, locateProject = null, dataRoot, env, platform, create = false, randomBytes }) {
65
68
  if (typeof loadProject !== 'function') throw new TypeError('the service lifecycle needs loadProject')
66
- const project = loadProject()
67
- if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
69
+ const project = locateProject === null ? loadProject() : locateProject()
70
+ if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
68
71
  const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create })
69
72
  return { project, workspace }
70
73
  }
@@ -90,8 +93,8 @@ async function evaluate({ workspaceRoot, workspaceId }, { probeTimeoutMs = DEFAU
90
93
 
91
94
  const shown = ({ record, ...status }) => ({ ...status, record: publicRecord(record) })
92
95
 
93
- export async function serviceStatus({ loadProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
94
- const { workspace } = context({ loadProject, dataRoot, env, platform })
96
+ export async function serviceStatus({ loadProject, locateProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
97
+ const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
95
98
  if (!workspace?.workspaceRoot) return { state: 'stopped', reason: 'workspace-not-prepared', workspaceId: workspace?.workspaceId ?? null, record: null, address: null }
96
99
  return shown(await evaluate(workspace, { probeTimeoutMs, alive, rules }))
97
100
  }
@@ -177,11 +180,11 @@ async function startThroughLoginItem({ workspace, loginItem, entryPath, deadline
177
180
  // proven runtime is answered as already running.
178
181
  export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
179
182
  const {
180
- loadProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
183
+ loadProject, locateProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
181
184
  startTimeoutMs = DEFAULT_START_TIMEOUT_MS, probeTimeoutMs, clock = () => new Date(), env = process.env, platform = process.platform,
182
185
  spawn = childProcess.spawn, execPath = process.execPath, alive = isProcessAlive, randomBytes = cryptoRandomBytes, loginItem = null, replaceOutdated = false,
183
186
  } = options
184
- const { project, workspace } = context({ loadProject, dataRoot, env, platform, create: true })
187
+ const { project, workspace } = context({ loadProject, locateProject, dataRoot, env, platform, create: true })
185
188
  const { workspaceRoot, workspaceId } = workspace
186
189
  const deadline = Date.now() + startTimeoutMs
187
190
 
@@ -208,7 +211,7 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
208
211
  const standing = replaceOutdated ? runtimeRelease(before, entryPath) : null
209
212
  if (standing === 'later') return { ...shown(before), started: false, alreadyRunning: true, release: 'later' }
210
213
  if (standing !== 'outdated') return { ...shown(before), started: false, alreadyRunning: true }
211
- const stopped = await stopService({ loadProject, dataRoot, env, platform, probeTimeoutMs, alive }, rules)
214
+ const stopped = await stopService({ loadProject, locateProject, dataRoot, env, platform, probeTimeoutMs, alive }, rules)
212
215
  if (!stopped.stopped) return { ...shown(before), started: false, alreadyRunning: true, release: 'outdated', reason: stopped.reason ?? 'the-runtime-was-not-stopped' }
213
216
  replaced = 'outdated'
214
217
  before = await evaluate(workspace, { probeTimeoutMs, alive, rules })
@@ -241,7 +244,9 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
241
244
  const executable = executableIdentity(entryPath)
242
245
  const log = openServiceLog(workspaceRoot)
243
246
  try {
244
- const args = [executable.path, `--project=${project.configPath}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
247
+ // A bound personal home is named instead of a project: the service follows the selection confirmed there.
248
+ const home = personalHomeOf(project)
249
+ const args = [executable.path, home === null ? `--project=${project.configPath}` : `--personal-home=${home}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
245
250
  // No shell. Detached only when the service is meant to outlive the command that starts it. It runs in the root
246
251
  // directory, never in the one the command was started in, which may be a vault or a folder somebody removes.
247
252
  child = spawn(execPath, args, { detached, shell: false, windowsHide: true, stdio: ['ignore', log.descriptor, log.descriptor], env, cwd: path.parse(executable.path).root })
@@ -277,8 +282,8 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
277
282
  }
278
283
 
279
284
  export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
280
- const { loadProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
281
- const { workspace } = context({ loadProject, dataRoot, env, platform })
285
+ const { loadProject, locateProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
286
+ const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
282
287
  if (!workspace?.workspaceRoot) return { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' }
283
288
  const { workspaceRoot, workspaceId } = workspace
284
289
  const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
@@ -308,8 +313,8 @@ export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
308
313
  // One authenticated request to the proven runtime, and to nothing else: anything but a healthy status is answered
309
314
  // with that status and no request is made.
310
315
  async function askProvenRuntime(options, rules, { method, operation, timeoutMs, body = {} }) {
311
- const { loadProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
312
- const { workspace } = context({ loadProject, dataRoot, env, platform })
316
+ const { loadProject, locateProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
317
+ const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
313
318
  if (!workspace?.workspaceRoot) return { requested: false, state: 'stopped', reason: 'workspace-not-prepared' }
314
319
  const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
315
320
  if (status.state !== 'healthy') return { requested: false, state: status.state, reason: status.reason }
@@ -9,7 +9,8 @@ import { ensureServiceSettings } from './lifecycle.mjs'
9
9
  import { ensureWorkspaceIdentity, readLocalPointer, resolveDataRoot } from './machine-settings.mjs'
10
10
  import { SERVICE_MANAGER_KINDS } from './service-managers.mjs'
11
11
  import { readLastStartup, readServiceSettings, serviceNameFor, servicePaths, writeServiceSettings } from './service-record.mjs'
12
- import { resolveServiceWorkspace } from './service.mjs'
12
+ import { personalHomeOf } from './personal-selection.mjs'
13
+ import { resolveServiceWorkspace, serviceLocationCheck } from './service.mjs'
13
14
  import { STARTUP_PLATFORMS, buildStartupAdapter, startupSearchPath } from './startup-adapters.mjs'
14
15
 
15
16
  // A login item: the operating system starts this workspace's maintenance
@@ -164,7 +165,9 @@ export const loginItemDataRoot = ({ project, dataRoot, env = process.env, platfo
164
165
  export function planLoginItem({ platform, project, workspaceRoot, workspaceId, dataRoot, label, entryPath, entryArgs = [], nodePath, searchPath = null }) {
165
166
  if (!isAbsolutePlain(dataRoot)) throw new TypeError('a login item names the absolute data root of its workspace')
166
167
  if (typeof workspaceId !== 'string' || workspaceId === '') throw new TypeError('a login item names its workspace')
167
- const args = [`--project=${project.configPath}`, `--data-root=${dataRoot}`, `--workspace-id=${workspaceId}`, ...entryArgs]
168
+ // A bound personal home is named instead of a project, as `start` names it (lifecycle.mjs).
169
+ const home = personalHomeOf(project)
170
+ const args = [home === null ? `--project=${project.configPath}` : `--personal-home=${home}`, `--data-root=${dataRoot}`, `--workspace-id=${workspaceId}`, ...entryArgs]
168
171
  const unit = buildStartupAdapter({ platform, label, nodePath, entryPath, args, logPath: servicePaths(workspaceRoot).loginItemLog, searchPath })
169
172
  return { ...unit, label, digest: sha256(unit.text), program: { node: nodePath, entry: entryPath }, searchPath }
170
173
  }
@@ -195,7 +198,7 @@ export async function installLoginItem(options = {}) {
195
198
  const { loadProject, dataRoot, env = process.env, platform = process.platform, manager, consent, ownEntry, entryArgs = [], nodePath = realNodePath(), pathValue = env.PATH, clock = () => new Date(), temporary = temporaryRoots(), beforeInstall = null } = options
196
199
  checkManager(manager, platform)
197
200
  const project = loadProject()
198
- ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }) })
201
+ ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
199
202
  const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create: true })
200
203
  const { entryPath, source } = resolveLoginItemEntry({ project, ownEntry, temporary })
201
204
  const recorded = readLoginItemRecord(workspace)
@@ -3,6 +3,7 @@ import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { LOCAL_STATE_DIR, localStateRoot } from '../../project/config.mjs'
6
+ import { withPrivateLock } from '../../project/durable-state.mjs'
6
7
  import { checkManagedRoots } from '../../project/file-class.mjs'
7
8
  import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
8
9
  import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
@@ -146,12 +147,20 @@ export function writeLocalPointer(project, pointer) {
146
147
  }
147
148
 
148
149
  // Reads the persisted workspace identity, or issues a random one and persists
149
- // it. `randomBytes` is injectable so tests are deterministic.
150
- export function ensureWorkspaceIdentity({ project, dataRoot, randomBytes = cryptoRandomBytes } = {}) {
150
+ // it. `randomBytes` is injectable so tests are deterministic. `validate`, when
151
+ // given, checks the location an identity names: a new identity is checked
152
+ // before its pointer is written, so a refused location leaves no pointer, and
153
+ // the identity actually persisted is checked again before it is returned.
154
+ export function ensureWorkspaceIdentity({ project, dataRoot, randomBytes = cryptoRandomBytes, validate = null } = {}) {
151
155
  const existing = readLocalPointer(project)
152
- if (existing) return existing
153
- const pointer = { schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) }
154
- return writeLocalPointer(project, pointer)
156
+ if (existing) { validate?.(existing); return existing }
157
+ const pointer = validatePointer({ schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) })
158
+ validate?.(pointer)
159
+ writeLocalPointer(project, pointer)
160
+ const persisted = readLocalPointer(project)
161
+ if (persisted === null) refuse('invalid-local-pointer', 'the Obsidian pointer was not kept')
162
+ validate?.(persisted)
163
+ return persisted
155
164
  }
156
165
 
157
166
  // ---------------------------------------------------------------------------
@@ -260,6 +269,18 @@ export function defaultMachineSettings({ workspaceId, updatedAt }) {
260
269
  }
261
270
  }
262
271
 
272
+ // The already adopted owner-only audience for a personal binding, recorded in the same machine setting an ordinary
273
+ // workspace uses. Existing explicit decisions keep their exact list and unclassified choice; a clear stays empty.
274
+ export function ownerOnlyPersonalSettings(settings, { decidedAt }) {
275
+ const decision = settings.decisions.audience
276
+ if (decision === null && settings.audienceAllow.length === 0) {
277
+ return withDecision({ ...settings, audienceAllow: [...ONLY_YOU_AUDIENCES], updatedAt: decidedAt }, 'audience',
278
+ { choice: 'only-you', unclassified: 'shown' }, { decidedAt, via: 'defaults' })
279
+ }
280
+ if (decision?.choice === 'only-you' || (decision?.choice === 'custom' && settings.audienceAllow.length === 0)) return settings
281
+ refuse('personal-binding-owner-only', 'a personal workspace vault is owner only; select `audience set me` or clear its audience before publishing')
282
+ }
283
+
263
284
  // The settings with one decision recorded, validated; nothing is written. `decision` holds the decision's own members
264
285
  // (`{ choice, unclassified }`, `{ parent }`, `{ choice }`); the stamp says when, by whom (an identifier, or null when not
265
286
  // known) and how.
@@ -278,15 +299,38 @@ export function readMachineSettings({ workspaceRoot, workspaceId }) {
278
299
  return document === null ? null : normalizeMachineSettings(document, workspaceId)
279
300
  }
280
301
 
281
- // Writes v2, whatever version it is handed, and returns what it wrote.
282
- export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
283
- const document = normalizeMachineSettings(settings, workspaceId)
302
+ function withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, operation) {
284
303
  assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
285
304
  fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
305
+ return withPrivateLock(path.join(settingsDirectory(workspaceRoot), 'machine-settings.lock'), operation)
306
+ }
307
+
308
+ function writeMachineSettingsUnlocked({ workspaceRoot, document }) {
286
309
  atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'machine.json'), canonicalJson(document))
287
310
  return document
288
311
  }
289
312
 
313
+ // Writes v2, whatever version it is handed, and returns what it wrote. Commands and initialization share this lock.
314
+ export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
315
+ const document = normalizeMachineSettings(settings, workspaceId)
316
+ return withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, () => writeMachineSettingsUnlocked({ workspaceRoot, document }))
317
+ }
318
+
319
+ // Read-only when already decided. If initialization is needed, reread under the write lock so a committed clear wins.
320
+ export function initializeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, updatedAt, personal = false }) {
321
+ const initialized = (current) => {
322
+ const initial = current ?? defaultMachineSettings({ workspaceId, updatedAt })
323
+ return personal ? ownerOnlyPersonalSettings(initial, { decidedAt: updatedAt }) : initial
324
+ }
325
+ const existing = readMachineSettings({ workspaceRoot, workspaceId })
326
+ if (initialized(existing) === existing) return existing
327
+ return withMachineSettingsWriteLock({ workspaceRoot, repositoryRoots }, () => {
328
+ const current = readMachineSettings({ workspaceRoot, workspaceId })
329
+ const document = normalizeMachineSettings(initialized(current), workspaceId)
330
+ return document === current ? current : writeMachineSettingsUnlocked({ workspaceRoot, document })
331
+ })
332
+ }
333
+
290
334
  // ---------------------------------------------------------------------------
291
335
  // Installed apply policy
292
336
  // ---------------------------------------------------------------------------
@@ -123,24 +123,52 @@ export function readFileFacts(file) {
123
123
 
124
124
  const sameHint = (left, right) => left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.ino === right.ino
125
125
 
126
+ // Whether a file still has the stat hint it was indexed with: what a reconcile that is not full decides by before it
127
+ // would hash the file again. `entry` is the file's entry in the index. A file never hashed, or gone, has none.
128
+ export function hasIndexedHint(entry, absolute, lstat = fs.lstatSync) {
129
+ if (!entry || entry.digest === null || !entry.hint) return false
130
+ const hint = statHint(absolute, lstat)
131
+ return hint !== null && sameHint(entry.hint, hint)
132
+ }
133
+
126
134
  // Reconciles `files` against `index` (a Map owned by the caller) and returns
127
135
  // the changes, decided by digest alone. `full` hashes everything; otherwise a
128
136
  // file is hashed when it is new, when its stat hint differs or when a watcher
129
137
  // named it. Entries under `prefix` that are no longer listed are removals.
130
- export function reconcile({ index, files, prefix, full, hinted = () => false, lstat = fs.lstatSync }) {
138
+ export function reconcile(input) {
139
+ const steps = reconcileSteps(input)
140
+ for (;;) { const step = steps.next(); if (step.done) return step.value }
141
+ }
142
+
143
+ // The same reconciliation with the event loop given a turn between files, under the caller's cooperative budget
144
+ // (createCooperativeBudget): for a walk long enough to hold a service's listener, such as the read-back of a large
145
+ // view. Each file is still looked at once, in the listed order, and the index holds what was seen when each was read;
146
+ // a file that changes after its turn is the next look's to see, as one that changes after an uninterrupted walk is.
147
+ // The caller must not let another reconciliation of the same index run before this one has answered.
148
+ export async function reconcileCooperatively(input, budget) {
149
+ const steps = reconcileSteps(input)
150
+ for (;;) {
151
+ const step = steps.next()
152
+ if (step.done) return step.value
153
+ await budget.checkpoint('reconcile-file')
154
+ }
155
+ }
156
+
157
+ // One step a listed file; the removals and the answer after the last.
158
+ function* reconcileSteps({ index, files, prefix, full, hinted = () => false, lstat = fs.lstatSync }) {
131
159
  const changes = []
132
160
  const present = new Set()
133
161
  let hashed = 0
134
- for (const file of files) {
162
+ const look = (file) => {
135
163
  present.add(file.key)
136
164
  const previous = index.get(file.key)
137
165
  const hint = statHint(file.absolute, lstat)
138
166
  if (hint === null) {
139
167
  if (previous && previous.digest !== null) changes.push({ key: file.key, changeClass: previous.changeClass, kind: 'removed' })
140
168
  index.set(file.key, { changeClass: file.changeClass, hint: null, digest: null, byteLength: null })
141
- continue
169
+ return
142
170
  }
143
- if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key)) continue
171
+ if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key)) return
144
172
  const facts = readFileFacts(file.absolute)
145
173
  const digest = facts?.digest ?? null
146
174
  hashed += 1
@@ -149,6 +177,10 @@ export function reconcile({ index, files, prefix, full, hinted = () => false, ls
149
177
  }
150
178
  index.set(file.key, { changeClass: file.changeClass, hint: digest === null ? null : hint, digest, byteLength: facts?.byteLength ?? null })
151
179
  }
180
+ for (const file of files) {
181
+ look(file)
182
+ yield
183
+ }
152
184
  for (const [key, previous] of index) {
153
185
  if (!key.startsWith(prefix) || present.has(key)) continue
154
186
  if (previous.digest !== null) changes.push({ key, changeClass: previous.changeClass, kind: 'removed' })
@@ -134,6 +134,7 @@ export const REASON_NEXT = Object.freeze({
134
134
  // publisher or editor is involved.
135
135
  'plugin-file-changed': 'a file of Atelier\'s plugin in this vault (under .obsidian/plugins) changed while the view was published, by another program or by you, so the publication did not finish; it is retried automatically, and if this keeps happening, stop what keeps writing there (a sync tool, say)',
136
136
  'settings-changed': 'a settings file Atelier writes in this vault (the plugin list or a plugin setting under .obsidian) changed while the view was published, by Obsidian, another program or you, and was not written over; it is retried automatically, reading the file as it is then',
137
+ 'selection-history-shortened': 'the selection history no longer follows the machine-private continuity evidence, so nothing is published. Put the removed records back, or explicitly confirm the generation again through the personal workspace selection operation. One fresh confirmation accepts the shorter history if the generation is still eligible. Restarting the service preserves this refusal.',
137
138
  'vault-inside-another-vault': 'Obsidian lists another vault at a folder that contains this view\'s vault; open never adds a vault inside another one, and never sends a call that could reach that vault instead: remove that vault from Obsidian\'s vault list, or keep Atelier\'s data root outside that folder, then open again',
138
139
  })
139
140
 
@@ -252,7 +253,7 @@ export function scopeReport({ workspace, scopeId, repositoryRoots, serviceState,
252
253
  freshness: entry === null ? null : { state: entry.state, reason: entry.reason, verified: entry.verified, generationId: entry.generationId, preparedGenerationId: entry.preparedGenerationId, heldNoteCount: entry.heldNotes.length, retainedEdits: entry.retainedEdits, checkedAt: entry.checkedAt },
253
254
  readBack: { readable: verification.readable, reason: verification.reason, generationId: verification.generationId, intact: verification.intact, noteCount: verification.noteCount, differing: verification.differing, missing: verification.missing },
254
255
  pendingEdits: pendingSummary(stateStore, scopeId, applyAvailable),
255
- // Notes of the view worth naming, and the rule concerned; see "Notes that were laid out anyway" in docs/obsidian-contract.md.
256
+ // Notes of the view worth naming, and the rule concerned; see "Notes that were laid out anyway" in docs/reference/obsidian-contract.md.
256
257
  ...(entry?.diagnostics ? { diagnostics: entry.diagnostics } : {}),
257
258
  vaultRoot: verification.vaultRoot ?? null,
258
259
  }
@@ -358,7 +359,7 @@ function settingsWritten(written) {
358
359
  // again for a view the app kept from being published, and reports one outcome.
359
360
  export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIMITIVES, lifecycleRules = LIFECYCLE_PRIMITIVES) {
360
361
  const {
361
- loadProject, dataRoot, scopeId: requestedScope, appProbe, launcher, registry, quitter = null, restartApp = false, service = {}, consent, allowStale = false, extensions = null,
362
+ loadProject, locateProject, dataRoot, scopeId: requestedScope, appProbe, launcher, registry, quitter = null, restartApp = false, service = {}, consent, allowStale = false, extensions = null,
362
363
  tickTimeoutMs = 120 * 1000, appWaitMs = 30 * 1000, appPollMs = 500, sleep = defaultSleep, monotonic = () => Date.now(),
363
364
  env = process.env, platform = process.platform, probeTimeoutMs,
364
365
  } = options
@@ -382,7 +383,7 @@ export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIM
382
383
  }
383
384
  if (enablement.state === 'disabled') return finish('disabled', { reason: enablement.reason, scopeId: requestedScope ?? null })
384
385
  const scopeId = resolveScope(enablement, requestedScope)
385
- const lifecycle = { loadProject, dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
386
+ const lifecycle = { loadProject, ...(locateProject === undefined ? {} : { locateProject }), dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
386
387
 
387
388
  // 1. The owned service: reconnect, or start. Never adopt; a runtime of ours is replaced only when it runs an earlier
388
389
  // release, under the consent already recorded, when a tick is asked of it (requestServiceTick).
@@ -0,0 +1,187 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { PersonalWorkspaceRefusal } from '@mnstry/atelier/personal-workspace'
4
+ import { ensureLocalState } from '@mnstry/atelier/project'
5
+ import { assertPersonalSelection, loadBoundProject, loadBoundProjectOffThread, notePersonalSelectionHead, personalSelectionOf, pinPersonalSelection } from '../../projection/obsidian/personal-workspace.mjs'
6
+ import { refuse } from './errors.mjs'
7
+ import { readPersonalSelectionSnapshot } from '../../personal-workspace/selection.mjs'
8
+ import { assertOutsideRepositories, ensureWorkspaceIdentity, resolveDataRoot, workspaceStateRoot } from './machine-settings.mjs'
9
+ import { createSelectionContinuity } from './selection-continuity.mjs'
10
+
11
+ // A person's confirmed selection, bound to the maintenance runtime.
12
+ //
13
+ // A personal home keeps a history of the generations its person confirmed
14
+ // (`selectPersonalGeneration` of the personal-workspace module). The loaders
15
+ // here follow that history instead of naming a generation themselves:
16
+ //
17
+ // read the head -> load the generation it names -> read the head again
18
+ //
19
+ // The head is read from the history alone, in the caller's thread: one
20
+ // directory listing and one bounded read per record. Whether the generation
21
+ // may be used is composition's to decide, as for any binding: in a worker for
22
+ // a service, in the calling thread for a command run once. A head that moved
23
+ // while the generation was loaded is another selection, and refuses
24
+ // `personal-selection-changed`.
25
+ //
26
+ // Nothing is confirmed here and nothing is chosen for the person. A history
27
+ // with no record, one that does not hold, a home that cannot be read and a
28
+ // generation its composition refuses each refuse as a maintenance refusal
29
+ // with the module's own code (`nothing-selected`, `selection-history-corrupt`,
30
+ // `personal-home-unavailable`, `root-missing`, `stale-generation` and the
31
+ // rest). There is no project to fall back to: an engine whose loader refuses
32
+ // publishes nothing, and every view it knows says why.
33
+ //
34
+ // A loaded project is pinned to the record it was loaded under
35
+ // (pinPersonalSelection), so the engine observes that record and the place of
36
+ // the next one, and loads again at the tick after a confirmation or after the
37
+ // history was cut short. At every full reconciliation the history is read
38
+ // again, and a project whose record it no longer ends with is refused.
39
+ //
40
+ // A history never gets shorter through the module: selections are appended and
41
+ // nothing removes one. So a history that ends at a lower record than this
42
+ // process has read it ending at was cut short by something else, and following
43
+ // it back could publish what nobody confirmed after the records removed. Every
44
+ // read of the history's end is noted (notePersonalSelectionHead): the first
45
+ // read here, the read once the generation is loaded, the recheck of a project
46
+ // already loaded, and a read the engine asks for when its guard stops a view
47
+ // because an observed file changed or could not be read (once for views
48
+ // stopped one after another). A
49
+ // history that ends earlier than the highest record noted requires a fresh confirmation or refuses
50
+ // `selection-history-shortened` at a load and at a recheck; the guard's read only notes, and
51
+ // its view says `mixed-read`. An engine whose project is pinned below the
52
+ // highest record noted loads it again at its next tick
53
+ // (personalSelectionBehind). A record added and removed between two of those
54
+ // reads was never read, and does not count.
55
+ //
56
+ // The highest number, accepted head and observed record identities are also kept in the workspace's private
57
+ // continuity state. A start of the service reads that evidence too. A shorter history remains refused until the
58
+ // removed records return or the person makes a fresh confirmation. New confirmations have a unique identity, so
59
+ // confirming the same generation against the same predecessor is distinct from restoring an old record's bytes.
60
+ //
61
+ // Who may see a vault stays the machine's setting: the engine initializes an undecided personal binding as owner only.
62
+ //
63
+ // Where the workspace is does not wait for a load: a bound project's folder is
64
+ // its private home, so the home alone locates it (locatePersonalHome). A
65
+ // service resolves its workspace from that at once, and starts, whether or not
66
+ // a selection is confirmed; its engine then awaits the loader at each tick.
67
+
68
+ // The decisions the oracles in test/obsidian-personal-selection.test.mjs are
69
+ // sensitive to. Production always uses these; a test hands a loader a broken
70
+ // one (`rules`) to prove that its oracle can fail.
71
+ export const SELECTION_PRIMITIVES = Object.freeze({
72
+ // A history that ends at a lower record than this process has read is refused, at every read of it.
73
+ refusesShortened: true,
74
+ // The head is read again once the generation is loaded.
75
+ readsHeadAgain: true,
76
+ // What the loaded project is pinned for (pinPersonalSelection).
77
+ pin: Object.freeze({ observe: true, recheck: true }),
78
+ })
79
+
80
+ const SOURCE = Object.freeze({ source: 'personal-workspace' })
81
+ const LOCATED = Symbol('atelier.obsidian.located-personal-home')
82
+
83
+ const CONTINUITY = new Map()
84
+
85
+ // Exclusion roots only, shared by location and continuity. Composition remains
86
+ // the authority for the manifest and generation. Never wait on an irregular
87
+ // file while resolving the machine's private state.
88
+ function manifestExclusionRoots(personalHome) {
89
+ let manifest
90
+ let descriptor
91
+ try {
92
+ descriptor = fs.openSync(path.join(personalHome, 'atelier.personal.json'), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK ?? 0))
93
+ const stat = fs.fstatSync(descriptor)
94
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new PersonalWorkspaceRefusal('malformed-input')
95
+ const bytes = fs.readFileSync(descriptor)
96
+ if (bytes.length > 1024 * 1024) throw new PersonalWorkspaceRefusal('malformed-input')
97
+ try { manifest = JSON.parse(bytes) } catch { throw new PersonalWorkspaceRefusal('malformed-input') }
98
+ if (!Array.isArray(manifest?.repos) || manifest.repos.some((repo) => typeof repo?.root !== 'string' || !path.isAbsolute(repo.root) || repo.root.includes('\0'))) throw new PersonalWorkspaceRefusal('malformed-input')
99
+ } catch (error) {
100
+ refuse(error instanceof PersonalWorkspaceRefusal ? error.code : 'personal-home-unavailable', 'the personal workspace manifest could not be read; no selection is loaded', SOURCE)
101
+ } finally { if (descriptor !== undefined) fs.closeSync(descriptor) }
102
+ return manifest.repos.map((repo) => repo.root)
103
+ }
104
+
105
+ function continuityFor({ personalHome, dataRoot, env = process.env, platform = process.platform }) {
106
+ const project = locatePersonalHome({ personalHome })
107
+ const repositoryRoots = [personalHome, ...project.repos.map((repo) => repo.path)]
108
+ const rootOf = (named) => workspaceStateRoot(resolveDataRoot({ dataRoot, pointer: named, project, env, platform }), named.workspaceId)
109
+ // The location is proven outside every declared root before a first use writes its pointer.
110
+ const pointer = ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }),
111
+ validate: (named) => assertOutsideRepositories({ managedRoot: rootOf(named), repositoryRoots }) })
112
+ const root = rootOf(pointer)
113
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 })
114
+ const workspaceRoot = fs.realpathSync(root)
115
+ const key = `${personalHome}\0${workspaceRoot}`
116
+ // A retained store writes later, after the manifest may have changed: each write proves the location again from
117
+ // the manifest as it reads now, and a refusal there writes nothing.
118
+ if (!CONTINUITY.has(key)) CONTINUITY.set(key, createSelectionContinuity({ workspaceRoot, workspaceId: pointer.workspaceId, personalHome,
119
+ validateLocation: () => assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots: personalExclusionRootsNow(personalHome) }) }))
120
+ return CONTINUITY.get(key)
121
+ }
122
+
123
+ // The roots a personal home's private state must stay outside, read from its manifest now. Read-only.
124
+ export function personalExclusionRootsNow(personalHome) {
125
+ return [personalHome, ...manifestExclusionRoots(personalHome)]
126
+ }
127
+
128
+ function confirmedHead(options, rules) {
129
+ const { personalHome } = options
130
+ locatePersonalHome({ personalHome })
131
+ let head
132
+ try { head = readPersonalSelectionSnapshot({ personalHome }) } catch (error) {
133
+ if (!(error instanceof PersonalWorkspaceRefusal)) throw error
134
+ refuse(error.code, 'the selection history of the personal workspace could not be read; nothing is loaded from it', SOURCE)
135
+ }
136
+ const continuity = continuityFor(options)
137
+ const note = (current) => { if (rules.refusesShortened) continuity.note(current) }
138
+ notePersonalSelectionHead(personalHome, head, { ...rules, note })
139
+ if (head.selected === null) refuse('nothing-selected', 'no generation of the personal workspace is confirmed; nothing is loaded until one is', SOURCE)
140
+ return { head, note }
141
+ }
142
+
143
+ function pinned(project, { head, note }, rules) {
144
+ pinPersonalSelection(project, head, { ...rules.pin, refusesShortened: rules.refusesShortened, note })
145
+ if (rules.readsHeadAgain) assertPersonalSelection(project)
146
+ return project
147
+ }
148
+
149
+ // What the workspace of a bound personal home is resolved from, without loading anything: a project that holds the home
150
+ // as its folder, plus the manifest's repository roots for exclusion only. A locator never admits a generation or
151
+ // notes a history head. Its pointer is under the home, the data root is the pointer's or the caller's, and a home is
152
+ // outside every Git worktree. Synchronous; nothing is composed. A missing home still locates no prepared workspace.
153
+ export function locatePersonalHome({ personalHome } = {}) {
154
+ if (typeof personalHome !== 'string' || !path.isAbsolute(personalHome) || path.resolve(personalHome) !== personalHome) refuse('path-not-absolute', 'a personal home is named by its absolute path', SOURCE)
155
+ let stat = null
156
+ try { stat = fs.lstatSync(personalHome) } catch (error) { if (error.code !== 'ENOENT') refuse('personal-home-unavailable', 'the personal home could not be inspected', SOURCE) }
157
+ if (stat !== null) {
158
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(personalHome) !== personalHome) refuse('root-symlinked', 'a personal home is its private directory, not a link', SOURCE)
159
+ if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) refuse('not-private-location', 'a personal home is private to its owner', SOURCE)
160
+ }
161
+ // Include all declared roots, even a withdrawn enrollment: locating state
162
+ // must not write into a repository while composition is unavailable.
163
+ const roots = stat === null ? [] : manifestExclusionRoots(personalHome)
164
+ const located = { configDir: personalHome, repos: roots.map((root) => ({ path: root, external: false })), [LOCATED]: personalHome }
165
+ located.localState = ensureLocalState(located, { write: false, env: { PATH: process.env.PATH } })
166
+ return located
167
+ }
168
+
169
+ // The personal home a located or a loaded project follows, which a service it starts or a login item it installs is
170
+ // given instead of a project (--personal-home); null for any other project.
171
+ export const personalHomeOf = (project) => project?.[LOCATED] ?? personalSelectionOf(project)?.personalHome ?? null
172
+ export { personalSelectionOf }
173
+
174
+ // The project of the confirmed selection, for a loader that may block (a command run once). `bind` and `rules` are
175
+ // test seams.
176
+ export function loadSelectedProject(options = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
177
+ const { personalHome } = options
178
+ const selected = confirmedHead(options, rules)
179
+ return pinned(loadBoundProject({ personalHome, generationId: selected.head.selected }, { bind }), selected, rules)
180
+ }
181
+
182
+ // The same, for a loader on a service's event loop: the composition runs in a worker, and the engine awaits it.
183
+ export async function loadSelectedProjectOffThread(options = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
184
+ const { personalHome } = options
185
+ const selected = confirmedHead(options, rules)
186
+ return pinned(await loadBoundProjectOffThread({ personalHome, generationId: selected.head.selected }, { bind }), selected, rules)
187
+ }