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

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 (58) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/README.md +22 -5
  3. package/docs/blocks/capability-stewardship.md +1 -1
  4. package/docs/blocks/learning-harnesses.md +1 -1
  5. package/docs/capability-stewardship.md +2 -4
  6. package/docs/ingestion.md +3 -3
  7. package/docs/inquiry-harnesses.md +2 -3
  8. package/docs/install.md +3 -3
  9. package/docs/learning-harnesses.md +2 -3
  10. package/docs/local-services.md +42 -3
  11. package/docs/obsidian-contract.md +23 -0
  12. package/docs/obsidian-plugin.md +214 -3
  13. package/docs/obsidian.md +271 -11
  14. package/docs/personal-workspace.md +13 -4
  15. package/docs/release-engineering.md +2 -1
  16. package/docs/repo-boundary-guard.md +26 -3
  17. package/docs/semantic-operation.md +4 -3
  18. package/docs/upgrade.md +174 -2
  19. package/fixtures/templates/local-library.v1.json +2 -1
  20. package/package.json +2 -1
  21. package/plugins/obsidian/main.js +57 -8
  22. package/plugins/obsidian/manifest.json +1 -1
  23. package/src/boundary/content-rules.mjs +532 -67
  24. package/src/boundary/policy.mjs +135 -56
  25. package/src/cli/run.mjs +3 -2
  26. package/src/commands/obsidian.mjs +23 -7
  27. package/src/graph/graph.mjs +13 -5
  28. package/src/graph/knowledge-graph.mjs +19 -1
  29. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  30. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  31. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  32. package/src/knowledge-health/rule-identity.mjs +14 -9
  33. package/src/personal-workspace/index.mjs +1 -1
  34. package/src/personal-workspace/selection.mjs +17 -1
  35. package/src/project/path-match.mjs +2 -1
  36. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  37. package/src/projection/obsidian/personal-workspace.mjs +87 -4
  38. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  39. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  40. package/src/projection/obsidian/publication/publisher.mjs +72 -5
  41. package/src/projection/project.mjs +3 -2
  42. package/src/runtime/obsidian/engine.mjs +293 -55
  43. package/src/runtime/obsidian/lifecycle.mjs +17 -12
  44. package/src/runtime/obsidian/login-item.mjs +4 -1
  45. package/src/runtime/obsidian/observation.mjs +36 -4
  46. package/src/runtime/obsidian/opening.mjs +3 -2
  47. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  48. package/src/runtime/obsidian/pipeline.mjs +44 -3
  49. package/src/runtime/obsidian/private-lock.mjs +39 -4
  50. package/src/runtime/obsidian/service-main.mjs +58 -12
  51. package/src/runtime/obsidian/service.mjs +66 -6
  52. package/src/runtime/obsidian/view-permission.mjs +334 -0
  53. package/src/ui/control-system.mjs +201 -0
  54. package/src/ui/html-primitives.mjs +3 -0
  55. package/src/upgrade/closure-diagnostics.mjs +135 -0
  56. package/src/upgrade/closure.mjs +649 -0
  57. package/src/upgrade/upgrade.mjs +19 -7
  58. package/templates/external-project-workspace/pack-lifecycle.json +2 -1
@@ -1,8 +1,10 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { performance } from 'node:perf_hooks'
3
4
  import { AtelierDiagnosticError } from '../../project/config.mjs'
4
5
  import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../../projection/obsidian/contracts.mjs'
5
- import { PERSONAL_VALIDATION_PENDING, personalWorkspaceBindingOf, personalWorkspaceInputs, validatePersonalWorkspace } from '../../projection/obsidian/personal-workspace.mjs'
6
+ import { createCooperativeBudget } from '../../projection/obsidian/materialize/prepare-view.mjs'
7
+ import { PERSONAL_VALIDATION_PENDING, notePersonalSelectionNow, personalSelectionBehind, personalWorkspaceBindingOf, personalWorkspaceInputs, validatePersonalWorkspace } from '../../projection/obsidian/personal-workspace.mjs'
6
8
  import { PROTOCOL_ID } from '../../projection/obsidian/publication/bridge-script.mjs'
7
9
  import { PublicationRefusal, allocatedFolderState, hasCommittedGeneration, readVaultAllocation } from '../../projection/obsidian/recovery/store.mjs'
8
10
  import { canonicalJson, compareText, isoTime } from './documents.mjs'
@@ -13,7 +15,7 @@ import {
13
15
  assertOutsideRepositories, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, protectedRoots, readLocalPointer,
14
16
  readMachineSettings, resolveDataRoot, workspaceStateRoot, writeMachineSettings,
15
17
  } from './machine-settings.mjs'
16
- import { configKey, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
18
+ import { configKey, hasIndexedHint, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, reconcileCooperatively, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
17
19
  import { dispatchAutomaticApply, heldPaths, layoutHeldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
18
20
  import { createProductionSeams, eligibilityFor } from './pipeline.mjs'
19
21
  import { PLUGIN_FILES_WAIT_FOR_APP } from './plugin-presence.mjs'
@@ -103,6 +105,15 @@ export const ENGINE_PRIMITIVES = Object.freeze({
103
105
  },
104
106
  // The embedded assets observed beside the walk: those the last built graph lets a view copy, and no withheld one.
105
107
  observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
108
+ // A change the guard sees in the middle of a tick is read, where it may be a confirmed selection, while it is there.
109
+ noteSelectionAtGuard: notePersonalSelectionNow,
110
+ // A loaded project pinned to a selection record below the highest record this process has read the history ending at
111
+ // is loaded again at the next tick, whatever the files say: another reader in the process may have read a
112
+ // confirmation that is gone again before this engine looked (personalSelectionBehind).
113
+ reloadsBehindSelection: personalSelectionBehind,
114
+ // After a tick that failed without a typed reason, the next one looks at every view again, as the first tick of a
115
+ // process does: see tick().
116
+ rechecksEveryViewAfterFailure: true,
106
117
  // One engine per workspace at a time, across processes.
107
118
  acquireEngineLock: acquirePrivateGenerationLock,
108
119
  // The registered proposal adapter observes the open edits on every tick, so a structural edit is recorded as
@@ -126,6 +137,33 @@ export const ENGINE_PRIMITIVES = Object.freeze({
126
137
  heldNoteDigest({ file }) {
127
138
  try { return readFileFacts(file)?.digest ?? null } catch { return null }
128
139
  },
140
+ // Whether an error met by the check of a tick's settings, at a wait, is a failure to read them: a file-system error
141
+ // (no permission, an I/O error, no descriptor left), recognised as one is where a vault is placed. Anything else is a
142
+ // fault of the engine and stops the tick.
143
+ guardReadFailed: (error) => typeof error?.code === 'string' && /^E[A-Z]+$/.test(error.code),
144
+ // Whether an error met when the graph build, a view's preparation or the look again before its commit reads a
145
+ // source is a source, or a folder of sources, removed after this tick observed it: no such file (or a parent that is
146
+ // no longer a folder), at a path that is gone now and that this tick's walks over the sources (before the build, and
147
+ // the look again after it) recorded with a digest (a source of a repository the project observes), or a folder that
148
+ // held such a source. A source changed during the tick is refused as `mixed-read`; one removed is refused the same,
149
+ // and the next tick reads everything again. Anything else stops the tick, as before: a path still there, a path the
150
+ // walk listed and could not read (it was recorded without a digest), or one it never listed, so a path that can never
151
+ // be read still fails every tick.
152
+ sourceRemovedSinceObserved(error, { project, index }) {
153
+ if ((error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') || typeof error.path !== 'string' || !(index instanceof Map)) return false
154
+ const missing = path.resolve(error.path)
155
+ if (fs.existsSync(missing)) return false
156
+ for (const repo of project?.repos ?? []) {
157
+ if (repo?.external || typeof repo?.path !== 'string' || typeof repo?.name !== 'string') continue
158
+ const relative = path.relative(path.resolve(repo.path), missing)
159
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) continue
160
+ const posix = relative.split(path.sep).join('/')
161
+ if (typeof index.get(sourceKey(repo.name, posix))?.digest === 'string') return true
162
+ const folder = sourceKey(repo.name, `${posix}/`)
163
+ for (const [key, entry] of index) if (key.startsWith(folder) && typeof entry?.digest === 'string') return true
164
+ }
165
+ return false
166
+ },
129
167
  })
130
168
 
131
169
  const isTypedRefusal = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
@@ -137,7 +175,7 @@ const digestOfJson = (value) => sha256Digest(Buffer.from(canonicalJson(value)))
137
175
  // The configuration a tick observes. A project bound to a personal workspace adds the person's authored manifest and
138
176
  // overlay and its generation's record: a change to any of them is a configuration change, so every view is prepared
139
177
  // again, asks the composition, and is refused at once when the generation no longer holds.
140
- const configFilesOf = (project) => [...listConfigFiles(project), ...personalWorkspaceInputs(project).map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))]
178
+ export const configFilesOf = (project) => [...listConfigFiles(project), ...personalWorkspaceInputs(project).map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))]
141
179
 
142
180
  // An editor adapter that `build` makes, synchronously or not, the first time the publisher calls it; a refusal of
143
181
  // `build` is thrown from that call, and `refusal()` answers it afterwards.
@@ -160,6 +198,9 @@ function uncoordinatedAdapter(refusal) {
160
198
  }
161
199
  // Views a tick may be asked to prepare again, at most, before it runs.
162
200
  const MAX_PREPARATION_REQUESTS = 64
201
+ // At most this many views a tick refuses for a file-system error are named with that error's code (`refusalCauses`); the
202
+ // others of that tick are not named.
203
+ const MAX_REFUSAL_CAUSES = 64
163
204
 
164
205
  function journalTime(journalId) {
165
206
  const match = /^journal-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})-/.exec(journalId)
@@ -198,13 +239,32 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
198
239
  quietPeriodMs, lstat = fs.lstatSync, randomBytes, env = process.env, platform = process.platform,
199
240
  // A service names where it answers health, so a lock it leaves behind can be proven abandoned.
200
241
  lockOwner = null, lockProbe = probeHealth,
242
+ // Answers at once, without loading, what the workspace is resolved from (locatePersonalHome), for a loader that can
243
+ // refuse before any project was loaded: the views published before are then told why nothing is published now.
244
+ locateProject = null,
201
245
  } = options
202
246
  if (typeof loadProject !== 'function') throw new TypeError('the engine needs loadProject')
203
247
  if (typeof clock !== 'function') throw new TypeError('the engine needs an injected clock')
204
248
  // No default: the production adapter reaches a running app, and only the owner of the lifecycle may decide that.
205
249
  if (typeof adapterFactory !== 'function') throw new TypeError('the engine needs an adapterFactory')
206
- const seams = { ...createProductionSeams(), ...(options.seams ?? {}) }
250
+ const production = createProductionSeams()
251
+ const seams = { ...production, ...(options.seams ?? {}) }
252
+ // A caller that replaced prepareView and left the cooperative preparation as it ships prepares views its own way: its
253
+ // seam is used, synchronously, whether it handed in that one member or the production seams with that member replaced.
254
+ if (seams.prepareView !== production.prepareView && seams.prepareViewCooperatively === production.prepareViewCooperatively) seams.prepareViewCooperatively = null
255
+ // The same for the graph: a caller that replaced buildGraph and left the cooperative build as it ships builds its own way.
256
+ if (seams.buildGraph !== production.buildGraph && seams.buildGraphCooperatively === production.buildGraphCooperatively) seams.buildGraphCooperatively = null
207
257
  const rules = { ...ENGINE_PRIMITIVES, ...primitives }
258
+ // Optional local qualification telemetry. It has no authority to change a
259
+ // tick's result, and records elapsed boundaries rather than a budget claim.
260
+ const phaseTime = () => performance.timeOrigin + performance.now()
261
+ const reportPhase = (phase, startedMs) => {
262
+ try { seams.onPhase?.({ phase, startedMs, endedMs: phaseTime() }) } catch { /* telemetry never changes custody */ }
263
+ }
264
+ const measured = (phase, operation) => {
265
+ const startedMs = phaseTime()
266
+ try { return operation() } finally { reportPhase(phase, startedMs) }
267
+ }
208
268
 
209
269
  const index = new Map()
210
270
  const hintedKeys = new Set()
@@ -229,6 +289,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
229
289
  let watching = { signature: null, handle: null }
230
290
  let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
231
291
  let heldLock = null
292
+ let releaseOwed = false
232
293
  // Views somebody asked to have prepared and published once more, at the next tick.
233
294
  const preparationRequests = new Set()
234
295
  // The views the project declared at the last tick; null before the first.
@@ -250,6 +311,14 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
250
311
  }
251
312
 
252
313
  async function takeLock(workspaceRoot, workspaceId) {
314
+ // A lock an earlier tick could not give back is still this process's, and its ticket on disk unreleased: a tick
315
+ // that tried to take it anew would find its own ticket and answer `busy` until the process ends. It is given back
316
+ // first. A release that fails again fails this tick, and is owed to the next.
317
+ if (heldLock && releaseOwed) {
318
+ heldLock.release()
319
+ heldLock = null
320
+ releaseOwed = false
321
+ }
253
322
  if (heldLock) return { acquired: true }
254
323
  const lock = await rules.acquireEngineLock({
255
324
  workspaceRoot, directory: path.join(workspaceRoot, ENGINE_LOCK_DIRECTORY), workspaceId, purpose: 'maintenance-engine', service: lockOwner, clock, proveAbandoned,
@@ -258,10 +327,18 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
258
327
  return lock
259
328
  }
260
329
 
330
+ // Gives the lock back at the end of a tick. One that cannot be given back (its released ticket could not be written)
331
+ // stays held, and the failure is the tick's: the next tick gives it back before anything else. One given back owes
332
+ // nothing more, whether this tick took it or an earlier one could not give it back.
261
333
  function dropLock() {
262
334
  const lock = heldLock
335
+ if (lock === null) return
336
+ try { lock.release() } catch (error) {
337
+ releaseOwed = true
338
+ throw error
339
+ }
263
340
  heldLock = null
264
- lock?.release()
341
+ releaseOwed = false
265
342
  }
266
343
 
267
344
  function onWatcherEvent(roots, { rootId, relative }) {
@@ -318,11 +395,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
318
395
  // Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state.
319
396
  async function persistOutcome({ enablement, state, reason, now, scopeIds = [] }) {
320
397
  if (!known) {
398
+ let located = project
399
+ try { located ??= locateProject === null ? null : locateProject() } catch { located = null }
321
400
  let pointer = null
322
- try { pointer = project ? readLocalPointer(project) : null } catch { pointer = null }
401
+ try { pointer = located ? readLocalPointer(located) : null } catch { pointer = null }
323
402
  if (!pointer) return
324
403
  let root
325
- try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId) } catch { return }
404
+ try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project: located, env, platform }), pointer.workspaceId) } catch { return }
326
405
  const stateStore = createMaintenanceStateStore({ workspaceRoot: root, workspaceId: pointer.workspaceId })
327
406
  if (!stateStore.exists()) return
328
407
  known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual' }
@@ -371,7 +450,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
371
450
  if (personalWorkspaceBindingOf(project) === null) project = await loadProject()
372
451
  } else {
373
452
  const config = reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full, hinted, lstat })
374
- if (config.changes.length > 0) {
453
+ if (config.changes.length > 0 || rules.reloadsBehindSelection(project)) {
375
454
  project = await loadProject()
376
455
  reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full: true, lstat })
377
456
  }
@@ -401,6 +480,27 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
401
480
  const eligibility = fixedEligibility ?? eligibilityFor({ machine, project })
402
481
  const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
403
482
  known = { stateStore, maintenanceMode: machine.maintenanceMode }
483
+ // A view refused for a file-system error (a vault folder that could not be allocated or placed, a configuration
484
+ // file that could not be read again, a source removed while it was read, candidates that could not be staged) is
485
+ // settled under the refusal's code alone, and such a tick ends without a failure. The error's code (`EACCES`, `EIO`,
486
+ // `ENOENT`), and that of a cleanup after it that failed too, are reported beside it for the service's log. Only
487
+ // codes shaped as a file-system error's are kept: never a message, which may name a path.
488
+ const refusalCauses = []
489
+ const errnoOf = (value) => (typeof value === 'string' && /^E[A-Z]+$/.test(value) ? value : null)
490
+ const noteRefusalCause = (scopeId, refusal) => {
491
+ const cause = errnoOf(refusal?.detail?.cause)
492
+ const cleanupCause = errnoOf(refusal?.detail?.cleanupCause)
493
+ if ((cause === null && cleanupCause === null) || refusalCauses.length >= MAX_REFUSAL_CAUSES) return
494
+ refusalCauses.push({ scopeId, code: refusal.code, ...(cause === null ? {} : { cause }), ...(cleanupCause === null ? {} : { cleanupCause }) })
495
+ }
496
+ // A view whose own vault cannot be placed: a typed refusal's code and detail, or `vault-location-unusable` for a
497
+ // file-system error there, whose code is its cause.
498
+ const refuseViewAlone = (scopeId, error) => {
499
+ const code = viewRefusalCode(error)
500
+ if (code === null) throw error
501
+ allocationRefusals.set(scopeId, code)
502
+ noteRefusalCause(scopeId, isTypedRefusal(error) ? error : { code, detail: { cause: error.code } })
503
+ }
404
504
  // Each view's vault: allocated now where this workspace decided its vaults live, when it has none and was never
405
505
  // published under the data root (vault-location.mjs). A view whose folder cannot be allocated is not prepared on
406
506
  // this tick, and its freshness says why; the other views go on.
@@ -432,11 +532,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
432
532
  vaults = known.vaults
433
533
  }
434
534
  ensureVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId, location: machine.decisions.location, projectName: projectDisplayName(project), repositoryRoots, vaults, allocatedPaths, now })
435
- } catch (error) {
436
- const code = viewRefusalCode(error)
437
- if (code === null) throw error
438
- allocationRefusals.set(scope.scopeId, code)
439
- }
535
+ } catch (error) { refuseViewAlone(scope.scopeId, error) }
440
536
  }
441
537
  }
442
538
  // Each view's store, on its own: one whose vault cannot be placed (a record naming a folder inside a repository,
@@ -444,11 +540,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
444
540
  const scopes = []
445
541
  for (const scope of enablement.scopes) {
446
542
  if (allocationRefusals.has(scope.scopeId)) continue
447
- try { scopes.push({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }) } catch (error) {
448
- const code = viewRefusalCode(error)
449
- if (code === null) throw error
450
- allocationRefusals.set(scope.scopeId, code)
451
- }
543
+ try { scopes.push({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }) } catch (error) { refuseViewAlone(scope.scopeId, error) }
452
544
  }
453
545
 
454
546
  watch([
@@ -460,20 +552,37 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
460
552
 
461
553
  // 3. Vault notes: preserve, then queue. Compared with the bytes each note
462
554
  // was generated with, not with the previous look.
463
- const pending = stateStore.readPendingEdits()
464
- let edits = pending.edits
555
+ //
556
+ // Every view's notes are looked at first, and the walk waits between notes, so a large vault does not hold the
557
+ // listener for its whole length (at a full reconciliation every note is hashed, and after a publication the
558
+ // watcher has named each note it wrote). Nothing is checked at those waits and nothing is written: within
559
+ // Atelier only this engine commits a manifest or writes the pending edits, under the lock this tick holds, so the
560
+ // manifest read before the walk is the one after it. A note changed after its turn is the next tick's to see, by
561
+ // its stat hint or the watcher, as one changed just after a walk that ran through is. The pending edits are then
562
+ // read, compared and written in one stretch, as before; the bytes an edit keeps are read there, not at the walk.
465
563
  const basesOf = new Map()
466
564
  const earlierLayout = new Set()
467
- for (const { scope, store } of scopes) {
468
- const { manifest, bases } = trustedNoteBases(store)
469
- basesOf.set(scope.scopeId, bases)
470
- if (!manifest) continue
471
- if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
472
- const vault = reconcile({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat })
565
+ const looked = []
566
+ const vaultStartedMs = phaseTime()
567
+ try {
568
+ const budget = createCooperativeBudget()
569
+ for (const { scope, store } of scopes) {
570
+ const { manifest, bases } = trustedNoteBases(store)
571
+ basesOf.set(scope.scopeId, bases)
572
+ if (!manifest) continue
573
+ if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
574
+ const vault = await reconcileCooperatively({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat }, budget)
575
+ looked.push({ scope, store, manifest, bases, vault })
576
+ }
577
+ } finally { reportPhase('vault-observation', vaultStartedMs) }
578
+ const pending = stateStore.readPendingEdits()
579
+ let edits = pending.edits
580
+ for (const { scope, store, manifest, bases, vault } of looked) {
473
581
  const digestOf = (notePath) => index.get(vaultKey(scope.scopeId, notePath))?.digest ?? null
474
582
  const observed = observeVaultEdits({ store, workspaceId, scopeId: scope.scopeId, manifest, bases, digestOf, edits, now, preserve: rules.preserve })
475
583
  edits = observed.edits
476
- // A note that has gone is restored by the publisher; its disappearance is a change, once.
584
+ // A note that has gone is a change, once: the view is prepared again and its read-back says the note is missing.
585
+ // The publisher writes it back when it next publishes a generation of the view.
477
586
  if (observed.events.length > 0 || vault.changes.some((change) => change.kind === 'removed')) changes.push({ changeClass: 'vault-note', scopeId: scope.scopeId })
478
587
  }
479
588
  stateStore.writePendingEdits({ ...pending, edits })
@@ -516,24 +625,33 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
516
625
  const lateWriters = []
517
626
  const lateDocument = stateStore.readLateWriters()
518
627
  const knownFindings = new Set(lateDocument.findings.map((item) => `${item.scopeId}\u0000${item.journalId}\u0000${item.unit}\u0000${item.observedDigest}`))
519
- for (const { scope, store } of scopes) {
520
- const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
521
- if (journalIds.length === 0) continue
522
- for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
523
- if (finding.code !== 'late-writer-captured') continue
524
- const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
525
- if (knownFindings.has(key)) continue
526
- knownFindings.add(key)
527
- lateWriters.push({ scopeId: scope.scopeId, journalId: finding.journalId, unit: finding.unit, notePath: finding.notePath ?? null, digestAtMove: finding.digestAtMove ?? null, observedDigest: finding.observedDigest, objectRef: finding.objectRef, detectedAt: now })
628
+ const lateStartedMs = phaseTime()
629
+ try {
630
+ for (const { scope, store } of scopes) {
631
+ const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
632
+ if (journalIds.length === 0) continue
633
+ for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
634
+ if (finding.code !== 'late-writer-captured') continue
635
+ const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
636
+ if (knownFindings.has(key)) continue
637
+ knownFindings.add(key)
638
+ lateWriters.push({ scopeId: scope.scopeId, journalId: finding.journalId, unit: finding.unit, notePath: finding.notePath ?? null, digestAtMove: finding.digestAtMove ?? null, observedDigest: finding.observedDigest, objectRef: finding.objectRef, detectedAt: now })
639
+ }
528
640
  }
529
- }
641
+ } finally { reportPhase('late-writer-recheck', lateStartedMs) }
530
642
  if (lateWriters.length > 0) {
531
643
  const findings = [...lateDocument.findings, ...lateWriters].sort((left, right) => compareText(left.detectedAt, right.detectedAt) || compareText(left.journalId, right.journalId) || left.unit - right.unit || compareText(left.observedDigest, right.observedDigest))
532
644
  stateStore.writeLateWriters({ schema: LATE_WRITERS_SCHEMA, workspaceId, findings })
533
645
  }
534
646
 
535
- // 6. Sources, settings, scopes, eligibility.
536
- const sources = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat })
647
+ // 6. Sources, settings, scopes, eligibility. The walk over the sources waits between files, as the walk over the
648
+ // vault does; at a full reconciliation it hashes every one. A source changed after its turn is the next tick's to
649
+ // see, by its stat hint or the watcher, or at the latest by the next full reconciliation. When this tick builds the
650
+ // graph, the look again at the sources after the build (`graph-source-recheck`) refuses such a change as
651
+ // `mixed-read`.
652
+ const sourcesStartedMs = phaseTime()
653
+ let sources
654
+ try { sources = await reconcileCooperatively({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat }, createCooperativeBudget()) } finally { reportPhase('source-observation', sourcesStartedMs) }
537
655
  for (const change of sources.changes) changes.push({ changeClass: change.changeClass })
538
656
  const { scopes: _declared, ...settingsWithoutScopes } = enablement.settings
539
657
  // What the configuration says apart from this integration's own member: a change to the member alone is an
@@ -599,37 +717,126 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
599
717
 
600
718
  // 8. Rebuild and publish.
601
719
  if (attempt.size > 0) {
720
+ // Cooperative preparation/publication can outlive the settings that
721
+ // selected this tick. Reuse the existing configuration/personal-input
722
+ // observation keys, machine settings and eligibility revision as the
723
+ // fence; do not invent another selection authority.
724
+ const bindings = configFilesOf(project).map(file => ({ ...file, digest: index.get(file.key)?.digest ?? null }))
725
+ const machineDigest = digestOfJson(machine)
726
+ const eligibilityRevision = String(eligibility.revision())
727
+ // The history's end is read once for a run of stopped views, however many views one change stops; a check that
728
+ // passes ends the run, so a change that stops a later view is read again.
729
+ let selectionNoted = false
730
+ const noteSelectionOnce = () => { if (!selectionNoted) { selectionNoted = true; rules.noteSelectionAtGuard(project) } }
731
+ const assertBindings = () => {
732
+ let changed
733
+ try {
734
+ changed = bindings.some(file => (readFileFacts(file.absolute)?.digest ?? null) !== file.digest)
735
+ || digestOfJson(readMachineSettings({ workspaceRoot, workspaceId })) !== machineDigest
736
+ || String(eligibility.revision()) !== eligibilityRevision
737
+ } catch (error) {
738
+ // A file that cannot be read now is not known to hold what this tick observed. The view is refused as one
739
+ // whose settings changed, and the next tick reads everything again: what an unreadable configuration file
740
+ // means is decided there, by observation and the project loader, as before.
741
+ if (!rules.guardReadFailed(error)) throw error
742
+ forceFull = true
743
+ // The file may be a selection record that cannot be read now (no permission, an I/O error, a file replaced while
744
+ // it was read): the history's end is read for the note.
745
+ noteSelectionOnce()
746
+ refuse('mixed-read', 'a file that decides source selection could not be read again while a view was being prepared or published', { cause: error.code })
747
+ }
748
+ if (changed) {
749
+ forceFull = true
750
+ // The change may be a confirmation in a personal home. The guard compares files and reads no history, so the
751
+ // history's end is read here for the note: a record that stopped this view counts from now on, also when it
752
+ // is gone again before the next tick looks.
753
+ noteSelectionOnce()
754
+ refuse('mixed-read', 'source selection or eligibility changed while a view was being prepared or published')
755
+ }
756
+ selectionNoted = false
757
+ }
602
758
  let built = null
759
+ // Whether a selected source of a prepared view moved since this tick observed it, decided as observation
760
+ // decides (reconcile): a source that still has the stat hint it was indexed with is taken as unchanged, and one
761
+ // whose hint moved is read and must still be the pinned bytes. Bytes that drift under an unchanged hint stay
762
+ // within observation's own bound, as they do for the graph and for a reused note: the next full reconciliation
763
+ // sees them. It waits between sources, and the settings that selected the view are checked at each wait.
764
+ const assertSourcesUnmoved = async (prepared) => {
765
+ const startedMs = phaseTime()
766
+ try {
767
+ const budget = createCooperativeBudget({ guard: assertBindings })
768
+ budget.check()
769
+ const roots = new Map((project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
770
+ const selected = new Set(prepared.manifest.notes.map((note) => note.nodeId))
771
+ const attachments = new Set(prepared.manifest.attachments.map((item) => {
772
+ const source = item.ext?.[OBSIDIAN_EXT_KEY]
773
+ return source?.kind === 'embedded-asset' ? sourceKey(source.repoId, source.assetPath) : null
774
+ }))
775
+ const nodes = built.snapshot.graph.nodes.filter((node) => selected.has(node.id))
776
+ const assets = (built.snapshot.graph.assets ?? []).filter((asset) => attachments.has(sourceKey(asset.repo, asset.path)))
777
+ const pins = new Map(built.snapshot.document.repositories.flatMap((repo) => repo.files.map((file) => [sourceKey(repo.repoId, file.path), file])))
778
+ for (const source of [...nodes, ...assets]) {
779
+ await budget.checkpoint('commit-source-recheck')
780
+ const key = sourceKey(source.repo, source.path)
781
+ const root = roots.get(source.repo)
782
+ if (root !== undefined && hasIndexedHint(index.get(key), path.join(root, ...source.path.split('/')), lstat)) continue
783
+ const bytes = built.snapshot.readSource(source.repo, source.path)
784
+ const pin = pins.get(key)
785
+ if (!pin || !Buffer.isBuffer(bytes) || bytes.length !== pin.byteLength || sha256Digest(bytes) !== pin.rawDigest) refuse('mixed-read', 'a selected source changed while its view was being prepared or published; its generation is not committed')
786
+ }
787
+ budget.check()
788
+ } finally { reportPhase('commit-source-recheck', startedMs) }
789
+ }
603
790
  try {
604
791
  if (personalRefusal !== null) refuse(personalRefusal, 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
605
792
  // A personal workspace whose validity key is not confirmed yet is composed off the event loop, then built again.
606
793
  const build = () => seams.buildGraph({ project, eligibility, cache: graphCache, index, deferPersonalValidation: true })
607
794
  let graph
608
- try { graph = build() } catch (error) {
609
- if (error?.code !== PERSONAL_VALIDATION_PENDING) throw error
610
- const verdict = await validatePersonalWorkspace(project)
611
- if (verdict?.ok !== true) refuse(verdict?.code ?? 'personal-binding-unrecognized', 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
612
- try { graph = build() } catch (again) {
613
- if (again?.code !== PERSONAL_VALIDATION_PENDING) throw again
614
- refuse('personal-graph-unconfirmed', 'the graph built for the personal workspace is not the one its composition built; nothing is prepared from it', { source: 'personal-workspace' })
795
+ try {
796
+ // Any other project is built with the event loop given a turn between the Markdown sources of the census: a
797
+ // first tick reads every source. The settings that select the views are checked at each wait, as during a
798
+ // preparation, and the look again at the sources after the build sees a source that moved during it.
799
+ if (typeof seams.buildGraphCooperatively === 'function' && personalWorkspaceBindingOf(project) === null) {
800
+ graph = await seams.buildGraphCooperatively({ project, eligibility, cache: graphCache, index, scheduling: { guard: assertBindings } })
801
+ } else try { graph = build() } catch (error) {
802
+ if (error?.code !== PERSONAL_VALIDATION_PENDING) throw error
803
+ const verdict = await validatePersonalWorkspace(project)
804
+ if (verdict?.ok !== true) refuse(verdict?.code ?? 'personal-binding-unrecognized', 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
805
+ try { graph = build() } catch (again) {
806
+ if (again?.code !== PERSONAL_VALIDATION_PENDING) throw again
807
+ refuse('personal-graph-unconfirmed', 'the graph built for the personal workspace is not the one its composition built; nothing is prepared from it', { source: 'personal-workspace' })
808
+ }
615
809
  }
810
+ } catch (error) {
811
+ if (!isTypedRefusal(error) && rules.sourceRemovedSinceObserved(error, { project, index })) refuse('mixed-read', 'a source was removed while the canonical graph was being built', { cause: error.code })
812
+ throw error
616
813
  }
617
814
  // The assets this graph lets a view copy are observed from now on, and hashed now so the snapshot pins
618
815
  // what observation saw. A withheld asset is not observed: its bytes can change no view.
619
816
  const formerAssetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
620
817
  observedAssets = rules.observedAssetsOf(graph)
621
818
  const assetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
622
- const settled = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat })
819
+ const settled = measured('graph-source-recheck', () => reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat }))
623
820
  // An asset newly observed, or one no longer embedded, is the list changing. Anything else that moved since this tick looked at the sources moved while the graph was being read.
624
821
  if (settled.changes.some((change) => !(change.kind === 'added' && assetKeys.has(change.key)) && !(change.kind === 'removed' && formerAssetKeys.has(change.key) && !assetKeys.has(change.key)))) throw new ObsidianMaintenanceRefusal('mixed-read', 'a source changed while the canonical graph was being built')
625
822
  const configDigest = digestOfJson([...index].filter(([key]) => key.startsWith(CONFIG_PREFIX)).map(([key, entry]) => [path.basename(key.slice(CONFIG_PREFIX.length)), entry.digest]))
823
+ const snapshot = seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now })
824
+ const observedProject = project
626
825
  built = {
627
- snapshot: seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now }),
826
+ // A view's preparation, and the look again at its sources before the commit, read a source through the
827
+ // snapshot. A selected source removed after this tick observed it, read there, refuses the view as
828
+ // `mixed-read`, as a removal during the build does; the other views of the tick are still prepared.
829
+ snapshot: { ...snapshot, readSource: (repoId, relative) => {
830
+ try { return snapshot.readSource(repoId, relative) } catch (error) {
831
+ if (!isTypedRefusal(error) && rules.sourceRemovedSinceObserved(error, { project: observedProject, index })) refuse('mixed-read', 'a selected source was removed while its view was being prepared or published', { cause: error.code })
832
+ throw error
833
+ }
834
+ } },
628
835
  profile: seams.profileFor({ project, workspaceId, audienceAllow: machine.audienceAllow }),
629
836
  }
630
837
  } catch (error) {
631
838
  if (!isTypedRefusal(error)) throw error
632
- for (const scopeId of attempt.keys()) entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now))
839
+ for (const scopeId of attempt.keys()) { entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now)); noteRefusalCause(scopeId, error) }
633
840
  if (REREAD_CODES.has(error.code)) forceFull = true
634
841
  // The personal workspace refused the bound generation: the project is loaded again at the next tick, so a
635
842
  // loader that now binds another generation is followed.
@@ -646,11 +853,17 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
646
853
  }
647
854
  try {
648
855
  const held = heldPaths(edits, scopeId)
649
- const prepared = seams.prepareView({
856
+ const prepared = await (seams.prepareViewCooperatively ?? seams.prepareView)({
650
857
  snapshot: built.snapshot, profile: built.profile, scope, persistentPathRegistry: stateStore.readPathRegistry(), priorManifest: store.readCurrentManifest(),
651
858
  existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), cache: preparationCacheFor(scopeId),
652
859
  heldNotePaths: held, layoutHeldNotePaths: layoutHeldOf(scopeId), viewScopeIds: scopes.map((item) => item.scope.scopeId),
860
+ // The cooperative preparation would read every selected source once more before it returns. The engine
861
+ // checks them itself, below and again before the commit, without opening a source observation would not.
862
+ scheduling: { guard: assertBindings, recheckSources: false },
653
863
  })
864
+ assertBindings()
865
+ // Before anything is written: a source that moved while the view was being prepared refuses it here.
866
+ await assertSourcesUnmoved(prepared)
654
867
  stateStore.writePathRegistry(prepared.persistentPathRegistry)
655
868
  diagnostics = (prepared.manifest.ext?.[OBSIDIAN_EXT_KEY]?.diagnostics ?? []).slice(0, 100)
656
869
  const preparedGenerationId = prepared.manifest.generationId
@@ -684,10 +897,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
684
897
  }
685
898
  let result
686
899
  try {
900
+ // Once more while the publisher holds its locks, before it says the publication settled.
901
+ const beforeCommit = () => assertSourcesUnmoved(prepared)
687
902
  result = await seams.publishView({
688
903
  preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter, clock,
689
904
  ...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
690
905
  ...(typeof readUnheldEvidence === 'function' ? { unheldEvidence: readUnheldEvidence, platform } : {}),
906
+ scheduling: { guard: assertBindings }, beforeCommit,
691
907
  })
692
908
  // Stopped before its commit for want of an app that coordinates: the app's refusal is what the person has to
693
909
  // act on, and `open` answers it before it would add the vault to the app.
@@ -705,15 +921,24 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
705
921
  const common = { generationId: pointerNow?.generationId ?? null, preparedGenerationId, retainedEdits: pointerNow?.retained?.length ?? 0 }
706
922
  if (result.state === 'refused') {
707
923
  settle(CONFLICT_REFUSALS.has(result.refusal.code) ? 'publisher-conflict' : 'stale', result.refusal.code, { ...common, heldNotes: held })
924
+ noteRefusalCause(scopeId, result.refusal)
708
925
  } else if (result.state === 'updating') {
709
926
  const blocking = result.notes.filter((note) => note.blocking)
710
927
  const byEdit = blocking.length > 0 && blocking.every((note) => EDIT_OUTCOMES.has(note.outcome))
711
928
  settle(byEdit ? 'held-for-your-edit' : 'publisher-conflict', blocking[0]?.outcome ?? 'publication-incomplete', { ...common, heldNotes: [...new Set([...held, ...blocking.filter((note) => EDIT_OUTCOMES.has(note.outcome)).map((note) => note.path)])].sort(compareText) })
712
929
  } else {
713
- // Read back: every note of the committed generation, by digest.
714
- const { manifest, bases } = trustedNoteBases(store)
930
+ // Read back: every note of the committed generation, by digest. The walk waits between notes, so a large
931
+ // view does not hold the service's listener for its whole length. Nothing is checked at those waits: the
932
+ // generation is committed, the view is reported as it was read, and what changes from here on is the next
933
+ // tick's to see, as it is after a read-back that ran through.
934
+ const readBackStartedMs = phaseTime()
935
+ let bases
936
+ try {
937
+ const committed = trustedNoteBases(store)
938
+ bases = committed.bases
939
+ await reconcileCooperatively({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest: committed.manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat }, createCooperativeBudget())
940
+ } finally { reportPhase('publication-read-back', readBackStartedMs) }
715
941
  basesOf.set(scopeId, bases)
716
- reconcile({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat })
717
942
  const differing = [...bases].filter(([notePath, base]) => index.get(vaultKey(scopeId, notePath))?.digest !== base.digest).map(([notePath]) => notePath).sort(compareText)
718
943
  const editedUnderPublisher = result.notes.some((note) => note.outcome === 'edit-kept' || note.changedAfterPublication === true)
719
944
  if (held.length > 0) settle('held-for-your-edit', 'edit-pending', { ...common, heldNotes: held })
@@ -730,6 +955,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
730
955
  const where = error.code === 'redaction-failure' ? error.detail ?? {} : {}
731
956
  diagnostics = typeof where.rule === 'string' ? [{ code: error.code, rule: where.rule, ...(typeof where.notePath === 'string' ? { notePath: where.notePath } : {}), ...(typeof where.filePath === 'string' ? { filePath: where.filePath } : {}) }] : []
732
957
  settle('stale', error.code)
958
+ noteRefusalCause(scopeId, error)
733
959
  if (REREAD_CODES.has(error.code)) forceFull = true
734
960
  }
735
961
  }
@@ -748,7 +974,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
748
974
  state: 'ticked', full, workspaceId, maintenanceMode: machine.maintenanceMode,
749
975
  changes: changes.map((change) => ({ ...change })), scopes: [...entries.values()].sort((left, right) => compareText(left.scopeId, right.scopeId)),
750
976
  pendingEdits: edits.filter((edit) => edit.closedAt === null).map(({ editId, scopeId, path: notePath, state }) => ({ editId, scopeId, path: notePath, state })),
751
- dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }),
977
+ dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }), ...(refusalCauses.length === 0 ? {} : { refusalCauses }),
752
978
  }
753
979
  }
754
980
 
@@ -770,7 +996,19 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
770
996
  try {
771
997
  return await runTick()
772
998
  } catch (error) {
773
- if (!isTypedRefusal(error)) throw error
999
+ if (!isTypedRefusal(error)) {
1000
+ // A tick that failed without a typed reason may have looked at files and recorded what it saw in the index
1001
+ // without acting on it: a note gone from a vault, a source that changed, or a configuration file that changed
1002
+ // before the project could be loaded again, is a change once, and the next look would find nothing to change.
1003
+ // The next tick therefore starts as the first tick of a process does: it loads the project again and looks
1004
+ // at every view, so whatever is missing or changed makes its view stale, or is published, not left current.
1005
+ if (rules.rechecksEveryViewAfterFailure) {
1006
+ project = null
1007
+ semantic = null
1008
+ firstTick = true
1009
+ }
1010
+ throw error
1011
+ }
774
1012
  // Fail closed: nothing is published under a refusal, and every known view says why.
775
1013
  try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock) }) } catch { /* the refusal itself is still reported */ }
776
1014
  project = null