@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
@@ -47,7 +47,8 @@ export function matchesPathPattern(pattern, rel) {
47
47
  const normalizedRel = normalizeRelPath(rel)
48
48
  if (!normalizedPattern || !normalizedRel) return false
49
49
  const candidate = normalizedPattern.includes('/') ? normalizedRel : path.posix.basename(normalizedRel)
50
- return new RegExp(`^${globSource(normalizedPattern)}$`, 'i').test(candidate)
50
+ // dotAll: `**` crosses a newline in a name as well as a slash.
51
+ return new RegExp(`^${globSource(normalizedPattern)}$`, 'is').test(candidate)
51
52
  }
52
53
 
53
54
  export const basenameOf = (pattern) => path.posix.basename(normalizeRelPath(pattern))
@@ -1,3 +1,59 @@
1
+ import { performance } from 'node:perf_hooks'
2
+
3
+ // What the caller of cooperative work stopped it with: its `signal`, or its
4
+ // `guard` (the engine's check that the settings which selected a view still
5
+ // hold). Such an error is marked, so whatever catches it on the way out can
6
+ // tell it from a failure of the work itself and hand it on under its own code.
7
+ const SCHEDULING_STOP = Symbol('atelier.cooperative-stop')
8
+ const markedStop = (error) => {
9
+ if (error !== null && typeof error === 'object') { try { Object.defineProperty(error, SCHEDULING_STOP, { value: true }) } catch { /* a frozen error stays unmarked */ } }
10
+ return error
11
+ }
12
+ export const isSchedulingStop = (error) => error !== null && typeof error === 'object' && error[SCHEDULING_STOP] === true
13
+
14
+ // Internal scheduling shared by preparation and publication. The budget is a
15
+ // yield target, not a deadline: one checked unit and native fsync stay atomic.
16
+ export function createCooperativeBudget({ budgetMs = 8, maxUnits = 32, signal, guard = null, onBurst = null } = {}) {
17
+ if (!(Number.isFinite(budgetMs) && budgetMs > 0 && Number.isInteger(maxUnits) && maxUnits > 0)) throw new TypeError('finite positive scheduling budget required')
18
+ let started = performance.now(), units = 0, maxUnitMs = 0, last = started
19
+ const checkSignal = () => {
20
+ if (signal?.aborted) { const error = new Error('cooperative work cancelled at a completed boundary'); error.code = 'ABORT_ERR'; throw markedStop(error) }
21
+ }
22
+ const check = () => {
23
+ checkSignal()
24
+ if (guard) { try { guard() } catch (error) { throw markedStop(error) } }
25
+ }
26
+ const checkpoint = async (phase, force = false) => {
27
+ const now = performance.now()
28
+ maxUnitMs = Math.max(maxUnitMs, now - last); units += 1
29
+ checkSignal()
30
+ if (force || units >= maxUnits || now - started >= budgetMs) {
31
+ check()
32
+ onBurst?.({ phase, elapsedMs: now - started, units, maxUnitMs })
33
+ await new Promise(resolve => setImmediate(resolve))
34
+ check()
35
+ started = performance.now(); units = 0; maxUnitMs = 0
36
+ }
37
+ last = performance.now()
38
+ }
39
+ const finish = phase => { const now = performance.now(); maxUnitMs = Math.max(maxUnitMs, now - last); onBurst?.({ phase, elapsedMs: now - started, units: units + 1, maxUnitMs }) }
40
+ return { checkpoint, check, finish }
41
+ }
42
+
43
+ async function runCooperatively(iterator, options) {
44
+ const budget = createCooperativeBudget(options)
45
+ budget.check()
46
+ try {
47
+ for (;;) {
48
+ const step = iterator.next()
49
+ if (step.done) { budget.finish('prepare-finalize'); return step.value }
50
+ if (step.value === 'final-cache-commit') budget.check()
51
+ else await budget.checkpoint(step.value)
52
+ }
53
+ } catch (error) { iterator.return?.(); throw error }
54
+ }
55
+
56
+
1
57
  import { createHash } from 'node:crypto'
2
58
  import { unclosedFenceAtEnd } from '../../../graph/knowledge-graph.mjs'
3
59
  import {
@@ -638,14 +694,14 @@ function layoutOf({ layout, priorManifest, layoutHeld }) {
638
694
  }
639
695
 
640
696
  export function prepareView(options = {}) {
641
- return prepareWithRules(REDACTION_RULES, options)
697
+ return drainPreparation(prepareSteps(REDACTION_RULES, options))
642
698
  }
643
699
 
644
700
  // Test seam only: the mutation controls of the redaction guard substitute a
645
701
  // rule through this. Runtime code uses prepareView.
646
702
  export function createViewPreparationForOracleTests(rules = REDACTION_RULES) {
647
703
  const guard = { ...REDACTION_RULES, ...rules }
648
- return (options = {}) => prepareWithRules(guard, options)
704
+ return (options = {}) => drainPreparation(prepareSteps(guard, options))
649
705
  }
650
706
 
651
707
  // `heldNotePaths` are the vault files held for an open edit: they stay in the
@@ -653,7 +709,7 @@ export function createViewPreparationForOracleTests(rules = REDACTION_RULES) {
653
709
  // notes that hold a layout 1 view in layout 1 (open edits and edits closed on
654
710
  // this tick); without it, `heldNotePaths` do. `viewScopeIds`, when given, are
655
711
  // the views still maintained: the registry drops the sections of any other.
656
- function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegistry = null, priorManifest = null, existingSettings = null, plugin = null, clock, generationId, vaultRootBytes, maxFullPathBytes, cache = null, heldNotePaths = null, layoutHeldNotePaths = null, viewScopeIds = null, layout: requestedLayout } = {}) {
712
+ function* prepareSteps(guard, { snapshot, profile, scope, persistentPathRegistry = null, priorManifest = null, existingSettings = null, plugin = null, clock, generationId, vaultRootBytes, maxFullPathBytes, cache = null, heldNotePaths = null, layoutHeldNotePaths = null, viewScopeIds = null, layout: requestedLayout } = {}, recheckSources = false) {
657
713
  if (cache !== null && !isPreparationCache(cache)) refuse('invalid-preparation-cache', 'the preparation cache must come from createPreparationCache')
658
714
  assertObsidianContract('corpus-profile', profile)
659
715
  assertObsidianContract('scope', scope)
@@ -670,6 +726,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
670
726
  if (viewScopeIds !== null && !(Array.isArray(viewScopeIds) && viewScopeIds.every((id) => typeof id === 'string'))) refuse('invalid-view-scopes', 'viewScopeIds must be an array of scope identities')
671
727
  const layout = layoutOf({ layout: requestedLayout, priorManifest, layoutHeld: layoutHeldNotePaths ?? heldNotePaths ?? [] })
672
728
  const checkedAt = timestampFrom(clock)
729
+ yield 'prepare-validation'
673
730
  const canonical = canonicalSnapshotOf(snapshot.graph)
674
731
 
675
732
  // Selection and visibility come from the contract, never from this module.
@@ -686,6 +743,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
686
743
  // Every view's allocation of the registry, this one's included; a registry of another shape or workspace refuses.
687
744
  const views = viewsOfRegistry(persistentPathRegistry, profile.workspaceId)
688
745
 
746
+ yield 'selection-and-universe'
689
747
  const edgeById = new Map(canonical.edges.map((edge) => [edge.id, edge]))
690
748
  const vaultEdges = selection.vaultEdges.map((id) => edgeById.get(id)).filter((edge) => vault.has(edge.source) && vault.has(edge.target))
691
749
  const outsideEdges = selection.outsideSelectionEdges.map((id) => edgeById.get(id)).filter((edge) => vault.has(edge.source) !== vault.has(edge.target))
@@ -714,6 +772,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
714
772
  // taken. A view prepared in layout 1 holds layout 1 files, none of which is a
715
773
  // layout 2 path. The registry keeps the result as this view's section,
716
774
  // whichever layout the view is prepared in.
775
+ yield 'link-and-asset-index'
717
776
  const readable = allocateViewPaths({
718
777
  published: publishedOf(priorManifest),
719
778
  nodes: [...vault].map((id) => nodeById.get(id)),
@@ -747,6 +806,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
747
806
  return { key: { edgeKey: occurrence.target }, markdown: target.markdown, wikilink: target.wikilink, alias: true }
748
807
  }
749
808
 
809
+ yield 'path-allocation'
750
810
  const read = sourceReader(snapshot)
751
811
  const orderedNodes = [...vault].map((id) => nodeById.get(id)).sort((left, right) => compare(left.repo, right.repo) || compare(left.id, right.id))
752
812
  // Edges indexed by endpoint once, so a note's rows cost its degree and not
@@ -778,6 +838,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
778
838
  const titles = titleRenderings()
779
839
 
780
840
  for (const node of orderedNodes) {
841
+ yield 'emit-note'
781
842
  const notePathValue = pathOf(node)
782
843
  const attachmentPathValue = node.extension === 'md' ? null : attachmentOf(node)
783
844
  const pinned = read.pinned(node.repo, node.path)
@@ -814,12 +875,14 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
814
875
  // One copy per asset, however many notes embed it, read through the pinned
815
876
  // snapshot like every other source.
816
877
  for (const asset of embeddedAssets.values()) {
878
+ yield 'emit-asset'
817
879
  const attachmentPath = assetPaths.get(asset.id)
818
880
  const { bytes, rawDigest } = read(asset.repo, asset.path)
819
881
  attachments.push({ path: attachmentPath, digest: rawDigest, byteLength: bytes.length, ext: { [EXT_KEY]: { kind: 'embedded-asset', repoId: asset.repo, assetPath: asset.path } } })
820
882
  files.push({ path: attachmentPath, kind: 'attachment', bytes, digest: rawDigest })
821
883
  }
822
884
 
885
+ yield 'emit-complete'
823
886
  const links = vaultEdges
824
887
  .map((edge) => {
825
888
  const inversions = edge.type === DERIVED_RELATION_TYPE ? inversionsByEdge.get(`${edge.source}\u0000${edge.target}`) ?? [] : []
@@ -835,6 +898,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
835
898
  })
836
899
  .sort((left, right) => compare(left.edgeId, right.edgeId))
837
900
 
901
+ yield 'manifest-links'
838
902
  const settings = prepareSettings({ existing: existingSettings, plugin })
839
903
  files.push(...settings.files)
840
904
  const occupied = new Set()
@@ -847,6 +911,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
847
911
  files.sort((left, right) => compare(left.path, right.path))
848
912
  attachments.sort((left, right) => compare(left.path, right.path))
849
913
 
914
+ yield 'settings-and-path-guard'
850
915
  const contentDigest = createHash('sha256')
851
916
  for (const file of files) contentDigest.update(`${file.path}\u0000${file.digest}\n`)
852
917
  const manifest = {
@@ -877,6 +942,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
877
942
  // the audience may see but this view does not select, every identifier is
878
943
  // reported. An identifier this view's own notes share names nothing outside
879
944
  // it.
945
+ yield 'manifest-validation'
880
946
  const viewAttachments = new Set(attachments.map((item) => item.path))
881
947
  const visible = new Set(universe.nodes)
882
948
  const censusRepositories = new Set([...canonical.nodes, ...censusAssets].filter(usable).map((item) => item.repo))
@@ -927,6 +993,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
927
993
  const allocatedInView = new Map(orderedNodes.map((node) => [node.id, pathOf(node)]))
928
994
  const wrapperAttachments = new Set(orderedNodes.filter((node) => node.extension !== 'md').map((node) => (legacy ? `attachments/${legacyBasename(pathOf(node))}.${/^[a-z0-9]{1,16}$/.test(String(node.extension).toLowerCase()) ? String(node.extension).toLowerCase() : 'bin'}` : attachmentOf(node))))
929
995
  const allowedAttachments = new Set([...wrapperAttachments, ...assetPaths.values()])
996
+ yield 'redaction-index'
930
997
  const guardReported = assertViewRedaction({
931
998
  manifest,
932
999
  files,
@@ -954,6 +1021,7 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
954
1021
  })
955
1022
  }
956
1023
 
1024
+ yield 'redaction-validation'
957
1025
  const prior = new Map((priorManifest?.notes ?? []).map((note) => [note.path, note.noteDigest]))
958
1026
  const changes = { added: [], changed: [], unchanged: [], removed: [] }
959
1027
  for (const note of notes) changes[!prior.has(note.path) ? 'added' : prior.get(note.path) === note.noteDigest ? 'unchanged' : 'changed'].push(note.path)
@@ -962,8 +1030,16 @@ function prepareWithRules(guard, { snapshot, profile, scope, persistentPathRegis
962
1030
  // under the publication protocol.
963
1031
  changes.removed = [...prior.keys()].filter((notePathValue) => !present.has(notePathValue)).sort(compare)
964
1032
 
965
- // The cache is replaced only by a preparation that passed every check, and
966
- // holds exactly the notes of this view.
1033
+ // A yield lets a source change after it was emitted (or reused). Check the
1034
+ // selected sources against the same pin again, before exposing the result
1035
+ // or replacing the derived cache. This does not create a new snapshot.
1036
+ if (recheckSources) {
1037
+ for (const node of orderedNodes) { yield 'recheck-source'; read(node.repo, node.path) }
1038
+ for (const asset of embeddedAssets.values()) { yield 'recheck-asset'; read(asset.repo, asset.path) }
1039
+ }
1040
+ // The last cancellation boundary precedes the cache commit. There is no
1041
+ // yield between that commit and returning its complete prepared result.
1042
+ yield 'final-cache-commit'
967
1043
  if (nextCache) cache.notes = nextCache
968
1044
  if (noteDiagnostics.length > 0) manifest.ext[EXT_KEY].diagnostics = noteDiagnostics
969
1045
 
@@ -995,3 +1071,9 @@ export function withEligibility(graph, isEligible, isAssetEligible = null) {
995
1071
  : {}),
996
1072
  }
997
1073
  }
1074
+
1075
+ function drainPreparation(iterator) { for (;;) { const step = iterator.next(); if (step.done) return step.value } }
1076
+ // A wait lets a source change after it was emitted or reused, so the selected sources are read against their pins once
1077
+ // more before the result is returned. A caller that checks them itself, as the engine does by stat hint, turns that
1078
+ // second reading off with `scheduling.recheckSources: false`.
1079
+ export async function prepareViewCooperatively(options = {}) { return runCooperatively(prepareSteps(REDACTION_RULES, options, options.scheduling?.recheckSources !== false), options.scheduling) }
@@ -2,10 +2,10 @@ import { createHash } from 'node:crypto'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'
5
- import { PersonalWorkspaceRefusal, composePersonalWorkspace } from '@mnstry/atelier/personal-workspace'
5
+ import { PersonalWorkspaceRefusal, composePersonalWorkspace, readPersonalSelectionHead } from '@mnstry/atelier/personal-workspace'
6
6
  import { ensureLocalState, resolveProjectConfig, validateProjectConfigDoc } from '@mnstry/atelier/project'
7
7
  import { buildCanonicalGraph } from '../../graph/graph.mjs'
8
- import { refuse } from '../../runtime/obsidian/errors.mjs'
8
+ import { ObsidianMaintenanceRefusal, refuse } from '../../runtime/obsidian/errors.mjs'
9
9
  import { OBSIDIAN_EXT_KEY } from './contracts.mjs'
10
10
 
11
11
  // The one personal-workspace route of the Obsidian projection.
@@ -60,6 +60,9 @@ const BINDING = Symbol('atelier.obsidian.personal-workspace-binding')
60
60
  const ROUTES = new WeakMap()
61
61
  // The last validity key the composition confirmed for each binding: { inputs, facts }.
62
62
  const CONFIRMED = new WeakMap()
63
+ // The selection record a binding was loaded under, when its loader followed the person's confirmed selection instead of
64
+ // naming a generation itself (runtime/obsidian/personal-selection.mjs): { sequence, head, observe, recheck }.
65
+ const SELECTED = new WeakMap()
63
66
  export const EVERYTHING_SCOPE_ID = 'everything'
64
67
  const EXT_SCHEMA = 'atelier-obsidian-ext-settings/v1'
65
68
  // The settings member of a bound project names its generation here. It survives any copy of the project (a structured
@@ -389,6 +392,12 @@ export async function validatePersonalWorkspace(project) {
389
392
  if (binding === null) return null
390
393
  const route = ROUTES.get(binding)
391
394
  if (route === undefined) return { ok: false, code: 'personal-binding-unrecognized' }
395
+ // A project pinned to a confirmed selection holds only while the history still ends, intact, with that selection's
396
+ // record: what observation of the record cannot see (a record before it broken, a confirmation that landed before
397
+ // the engine first looked) is seen here.
398
+ if (SELECTED.get(binding)?.recheck) {
399
+ try { assertPersonalSelection(project) } catch (error) { if (!(error instanceof ObsidianMaintenanceRefusal)) throw error; CONFIRMED.delete(binding); return { ok: false, code: error.code } }
400
+ }
392
401
  let composed
393
402
  if (route.offThread) composed = await composeInWorker(binding, route.worker)
394
403
  else {
@@ -400,12 +409,86 @@ export async function validatePersonalWorkspace(project) {
400
409
  return { ok: true, changed: previous?.inputs !== composed.key.inputs || previous?.facts !== composed.key.facts }
401
410
  }
402
411
 
412
+ const selectionRecord = ({ personalHome }, sequence) => path.join(personalHome, 'selections', `${String(sequence).padStart(6, '0')}.json`)
413
+
414
+ // The highest record number this process has read each home's selection history ending at, at any read of it: a
415
+ // loader's first read, its read-again, the recheck of a pinned project, and the read the engine's guard asks for when
416
+ // it stops a view (notePersonalSelectionNow). Kept in the main thread: no composition worker reads the history's end,
417
+ // and one that did would have a map of its own and escape the rule.
418
+ const READ_UP_TO = new Map()
419
+
420
+ // Notes where a home's selection history ends, as just read, and answers that head. Selections are only ever appended,
421
+ // so a history that ends at an earlier record than this process has read it ending at was cut short by something other
422
+ // than a confirmation. It refuses `selection-history-shortened` at every read, the read-again and the recheck of a
423
+ // project already loaded included, while it is shorter. Only the number is kept, and only in this process: a record in
424
+ // a place already read, with other content, is read as a confirmation, and a process started after the cut reads the
425
+ // history as it then stands. `refusesShortened: false` is a test seam only, the rule's mutation control.
426
+ export function notePersonalSelectionHead(personalHome, head, { refusesShortened = true } = {}) {
427
+ const before = READ_UP_TO.get(personalHome) ?? 0
428
+ if (head.sequence > before) READ_UP_TO.set(personalHome, head.sequence)
429
+ else if (refusesShortened && head.sequence < before) refuse('selection-history-shortened', 'the selection history of the personal workspace ends at an earlier record than this process has read it ending at; nothing is used from it while it is shorter', { source: 'personal-workspace' })
430
+ return head
431
+ }
432
+
433
+ // Pins a bound project to the selection record it was loaded under, and answers the project. From then on the engine
434
+ // observes that record and the place of the next one as configuration (personalWorkspaceInputs): a confirmation
435
+ // appended to the history, or a history cut short, is a change. And every time the composition is asked again
436
+ // (validatePersonalWorkspace) the history is read again and must still end with that record. `observe: false`,
437
+ // `recheck: false` and `refusesShortened: false` are test seams only, the mutation controls of each.
438
+ export function pinPersonalSelection(project, { sequence, head }, { observe = true, recheck = true, refusesShortened = true } = {}) {
439
+ SELECTED.set(personalWorkspaceBindingOf(project), Object.freeze({ sequence, head, observe, recheck, refusesShortened }))
440
+ return project
441
+ }
442
+
443
+ // The selection a project is pinned to, as { personalHome, generationId, sequence, head }; null for any other project.
444
+ export function personalSelectionOf(project) {
445
+ const binding = personalWorkspaceBindingOf(project)
446
+ const pinned = binding === null ? undefined : SELECTED.get(binding)
447
+ return pinned === undefined ? null : { personalHome: binding.personalHome, generationId: binding.generationId, sequence: pinned.sequence, head: pinned.head }
448
+ }
449
+
450
+ // Refuses `personal-selection-changed` unless the history still ends with the record this project is pinned to; a
451
+ // history that cannot be read refuses with the module's own code. What is read is noted first
452
+ // (notePersonalSelectionHead): a later record seen here counts from then on, and a history shorter than this process
453
+ // has read refuses `selection-history-shortened`, also when it ends with the pinned record. Nothing for a project that
454
+ // is not pinned.
455
+ export function assertPersonalSelection(project) {
456
+ const pinned = personalSelectionOf(project)
457
+ if (pinned === null) return
458
+ let now
459
+ try { now = readPersonalSelectionHead({ personalHome: pinned.personalHome }) } catch (error) { asMaintenanceRefusal(error, 'the selection history of the personal workspace could not be read; nothing is used from it') }
460
+ notePersonalSelectionHead(pinned.personalHome, now, SELECTED.get(personalWorkspaceBindingOf(project)))
461
+ if (now.sequence !== pinned.sequence || now.head !== pinned.head) refuse('personal-selection-changed', 'the confirmed selection of the personal workspace is no longer the one this project was loaded under; nothing is used from it', { source: 'personal-workspace' })
462
+ }
463
+
464
+ // Whether this process has read a pinned project's selection history ending at a later record than the one the
465
+ // project is pinned to: a confirmation since, or a history that was longer and is cut short now. Either way the project
466
+ // is not the one to publish under any more, and is loaded again: the load follows the confirmation, or refuses the
467
+ // shortened history. In memory, with no read. False for a project that is not pinned.
468
+ export function personalSelectionBehind(project) {
469
+ const pinned = personalSelectionOf(project)
470
+ return pinned !== null && (READ_UP_TO.get(pinned.personalHome) ?? 0) > pinned.sequence
471
+ }
472
+
473
+ // Reads where the history of a pinned project's home ends now, for the note alone: what the read refuses is not this
474
+ // caller's to raise. For the engine's guard, which sees a selection record's file change in the middle of a tick and
475
+ // stops the view as `mixed-read` without reading the history: the record that stopped it is read while it is there, so
476
+ // it counts when it is removed before the next tick. Nothing for a project that is not pinned.
477
+ export function notePersonalSelectionNow(project) {
478
+ try { assertPersonalSelection(project) } catch (error) { if (!(error instanceof ObsidianMaintenanceRefusal)) throw error }
479
+ }
480
+
403
481
  // The authored and generation files whose change means the binding must be asked again: the manifest, the overlay and
404
- // the bound generation's record. Observed as configuration by the engine. Empty for any other project.
482
+ // the bound generation's record; and, for a project pinned to a selection, that selection's record and the place of the
483
+ // next one. Observed as configuration by the engine. Empty for any other project.
405
484
  export function personalWorkspaceInputs(project) {
406
485
  const binding = personalWorkspaceBindingOf(project)
407
486
  if (binding === null) return []
408
- return [path.join(binding.personalHome, 'atelier.personal.json'), path.join(binding.personalHome, 'atelier.overlay.json'), path.join(generationRoot(binding), 'generation.json')]
487
+ const pinned = SELECTED.get(binding)
488
+ return [
489
+ path.join(binding.personalHome, 'atelier.personal.json'), path.join(binding.personalHome, 'atelier.overlay.json'), path.join(generationRoot(binding), 'generation.json'),
490
+ ...(pinned?.observe ? [selectionRecord(binding, pinned.sequence), selectionRecord(binding, pinned.sequence + 1)] : []),
491
+ ]
409
492
  }
410
493
 
411
494
  // The repository of a bound project's private notes, or null. A vault edit to one of them is never applied into a
@@ -15,6 +15,7 @@ import { isIdentifier } from '../edits/object-identity.mjs'
15
15
  import { openObjectStore } from '../edits/object-store.mjs'
16
16
  import { decideApply } from '../edits/policy.mjs'
17
17
  import { applyEditLens } from '../edits/regions.mjs'
18
+ import { createCooperativeBudget } from '../materialize/prepare-view.mjs'
18
19
  import { PERSONAL_VALIDATION_PENDING, personalOverlayRepoOf } from '../personal-workspace.mjs'
19
20
  import { PublicationRefusal } from '../recovery/store.mjs'
20
21
  import {
@@ -188,6 +189,9 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
188
189
  // Set when an observation found the personal workspace's graph pending: the edits it could not look at are offered
189
190
  // again on the next observation, not parked until a full reconciliation.
190
191
  let observePendingAgain = false
192
+ // The observation under way, if any. An observation waits between two edits, and what it reads of this adapter
193
+ // (the flag above, the workspace it opens) is read when it begins: so the next one begins when this one has ended.
194
+ let observing = Promise.resolve()
191
195
 
192
196
  // -- the workspace of one call -------------------------------------------
193
197
 
@@ -431,10 +435,13 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
431
435
 
432
436
  // One repository: its lock, then each of its operations in turn. What goes wrong with one repository, or with one
433
437
  // operation, is reported by its code and stops nothing else.
434
- async function settleRepository(workspace, repoId, works, report) {
438
+ // `budget`, when a tick hands one over, gives the event loop a turn between two operations: each operation is
439
+ // settled without interruption, and the lock of the repository is held from the first to the last.
440
+ async function settleRepository(workspace, repoId, works, report, budget = null) {
435
441
  try {
436
- const held = await withRepositoryLock(workspace, repoId, () => {
437
- for (const work of works) {
442
+ const held = await withRepositoryLock(workspace, repoId, async () => {
443
+ for (const [index, work] of works.entries()) {
444
+ if (budget !== null && index > 0) await budget.checkpoint('settle-operation', true)
438
445
  try { report.outcomes.push(settleOne(workspace, work.item, work)) } catch (error) {
439
446
  if (error?.crashSeam === true || !isTyped(error)) throw error
440
447
  report.outcomes.push({ status: 'failed', code: error.code, editId: work.item.editId, repoId, nodeId: work.item.nodeId, adapterOperationId: work.item.adapterOperationId, state: null, proposalId: null })
@@ -448,6 +455,26 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
448
455
  }
449
456
  }
450
457
 
458
+ // One observation, from opening the workspace to the flag it leaves for the next.
459
+ async function observeNow(context, { retryRefused = false } = {}) {
460
+ const workspace = open(context)
461
+ const edits = Array.isArray(context.edits) ? context.edits : []
462
+ // A private note of a personal workspace lives in a generation the personal-workspace module owns: its edit is
463
+ // not observed, recorded or proposed, and stays held in the vault. Answered by name, with nothing written.
464
+ const overlayRepoId = personalOverlayRepoOf(workspace.project)
465
+ const isPrivate = (edit) => overlayRepoId !== null && edit?.identity?.repoId === overlayRepoId
466
+ const privateNotes = edits.filter((edit) => isPrivate(edit) && edit.closedAt === null)
467
+ .map((edit) => ({ editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, status: 'refused', code: 'personal-overlay-not-proposed' }))
468
+ const shared = edits.filter((edit) => !isPrivate(edit))
469
+ // The graph is built only when an edit is looked at, as for any project. When the personal workspace's graph is
470
+ // pending, the edits it reached are not reported or recorded, and are offered again on the next observation.
471
+ const retry = retryRefused || observePendingAgain
472
+ const looked = await observation.run.cooperatively(workspace, { edits: shared, retryRefused: retry })
473
+ const pending = looked.filter((item) => item.code === PERSONAL_VALIDATION_PENDING)
474
+ observePendingAgain = pending.length > 0
475
+ return { adapterId: PROPOSAL_ADAPTER_ID, observed: [...privateNotes, ...looked.filter((item) => item.code !== PERSONAL_VALIDATION_PENDING)], ...(pending.length > 0 ? { deferred: PERSONAL_VALIDATION_PENDING } : {}) }
476
+ }
477
+
451
478
  return {
452
479
  id: PROPOSAL_ADAPTER_ID,
453
480
 
@@ -455,23 +482,10 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
455
482
  // observed from its preserved bytes against the source as it is now and recorded as what it is, a bounded number
456
483
  // per tick and never twice. `retryRefused` offers once more the edits whose observation refused before anything
457
484
  // could be recorded. Writes no source and no vault. Returns codes and identifiers only.
458
- async observe(context, { retryRefused = false } = {}) {
459
- const workspace = open(context)
460
- const edits = Array.isArray(context.edits) ? context.edits : []
461
- // A private note of a personal workspace lives in a generation the personal-workspace module owns: its edit is
462
- // not observed, recorded or proposed, and stays held in the vault. Answered by name, with nothing written.
463
- const overlayRepoId = personalOverlayRepoOf(workspace.project)
464
- const isPrivate = (edit) => overlayRepoId !== null && edit?.identity?.repoId === overlayRepoId
465
- const privateNotes = edits.filter((edit) => isPrivate(edit) && edit.closedAt === null)
466
- .map((edit) => ({ editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, status: 'refused', code: 'personal-overlay-not-proposed' }))
467
- const shared = edits.filter((edit) => !isPrivate(edit))
468
- // The graph is built only when an edit is looked at, as for any project. When the personal workspace's graph is
469
- // pending, the edits it reached are not reported or recorded, and are offered again on the next observation.
470
- const retry = retryRefused || observePendingAgain
471
- const looked = observation.run(workspace, { edits: shared, retryRefused: retry })
472
- const pending = looked.filter((item) => item.code === PERSONAL_VALIDATION_PENDING)
473
- observePendingAgain = pending.length > 0
474
- return { adapterId: PROPOSAL_ADAPTER_ID, observed: [...privateNotes, ...looked.filter((item) => item.code !== PERSONAL_VALIDATION_PENDING)], ...(pending.length > 0 ? { deferred: PERSONAL_VALIDATION_PENDING } : {}) }
485
+ observe(context, options = {}) {
486
+ const run = observing.then(() => observeNow(context, options))
487
+ observing = run.catch(() => {})
488
+ return run
475
489
  },
476
490
 
477
491
  // The tick. `context` is { project, workspaceRoot, workspaceId, repositoryRoots, edits }: the pending edit
@@ -481,6 +495,9 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
481
495
  const nowMs = Date.parse(isoTime(clock))
482
496
  const report = { adapterId: PROPOSAL_ADAPTER_ID, examined: 0, outcomes: [], repositories: [] }
483
497
  const edits = Array.isArray(context.edits) ? context.edits : []
498
+ // The tick goes on answering while it routes: a turn of the event loop when describing has taken its share
499
+ // of time, and one before each repository and between two of its operations.
500
+ const budget = createCooperativeBudget()
484
501
  const { operations: heads, unreadable } = workspace.queue().heads()
485
502
  for (const item of unreadable) report.repositories.push({ repoId: null, code: item.code })
486
503
  const byEdit = new Map(edits.map((edit) => [edit.editId, edit]))
@@ -497,7 +514,8 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
497
514
  const editState = edit === null || edit.closedAt === null ? 'open' : ['withdrawn', 'superseded'].includes(edit.state) ? edit.state : 'closed'
498
515
  let item = itemOfHead(head, edit)
499
516
  if (edit !== null && head.state !== 'submitted') {
500
- try { item = { ...item, ...(describe(workspace, edit) ?? {}) } } catch (error) { if (!isTyped(error)) throw error; report.outcomes.push({ status: 'failed', code: error.code, editId: head.editId, repoId: head.repoId, nodeId: head.nodeId, adapterOperationId: head.adapterOperationId, state: head.state, proposalId: null }); continue }
517
+ try { item = { ...item, ...(describe(workspace, edit) ?? {}) } } catch (error) { if (!isTyped(error)) throw error; report.outcomes.push({ status: 'failed', code: error.code, editId: head.editId, repoId: head.repoId, nodeId: head.nodeId, adapterOperationId: head.adapterOperationId, state: head.state, proposalId: null }); await budget.checkpoint('describe-operation'); continue }
518
+ await budget.checkpoint('describe-operation')
501
519
  }
502
520
  add(head.repoId, { item, editState })
503
521
  }
@@ -510,11 +528,16 @@ export function createProposalAdapterForOracleTests(primitives = PROPOSAL_ADAPTE
510
528
  for (const edit of looked) {
511
529
  report.examined += 1
512
530
  let item
513
- try { item = describe(workspace, edit) } catch (error) { if (!isTyped(error)) throw error; report.outcomes.push({ status: 'failed', code: error.code, editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, adapterOperationId: null, state: null, proposalId: null }); continue }
531
+ try { item = describe(workspace, edit) } catch (error) { if (!isTyped(error)) throw error; report.outcomes.push({ status: 'failed', code: error.code, editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId, adapterOperationId: null, state: null, proposalId: null }); await budget.checkpoint('describe-edit'); continue }
514
532
  if (item !== null) add(item.repoId, { item, editState: 'open' })
533
+ await budget.checkpoint('describe-edit')
515
534
  }
516
535
 
517
- for (const [repoId, list] of [...works].sort(([left], [right]) => compareText(left, right))) await settleRepository(workspace, repoId, list, report)
536
+ for (const [repoId, list] of [...works].sort(([left], [right]) => compareText(left, right))) {
537
+ await budget.checkpoint('settle-repository', true)
538
+ await settleRepository(workspace, repoId, list, report, budget)
539
+ }
540
+ budget.finish('propose-finalize')
518
541
  return report
519
542
  },
520
543
 
@@ -10,6 +10,7 @@ import { SOURCE_APPLY_PRIMITIVES, SourceApplyRefusal, locateSource } from '../ed
10
10
  import { EditArbitrationRefusal } from '../edits/arbitrate.mjs'
11
11
  import { createEditObserverForOracleTests, editIdempotencyKey, observeEdit } from '../edits/observe.mjs'
12
12
  import { decideApply } from '../edits/policy.mjs'
13
+ import { createCooperativeBudget } from '../materialize/prepare-view.mjs'
13
14
  import { PublicationRefusal, readFileBytes, sha256Digest } from '../recovery/store.mjs'
14
15
  import { PROPOSAL_BACKPRESSURE } from './backpressure.mjs'
15
16
 
@@ -38,6 +39,14 @@ import { PROPOSAL_BACKPRESSURE } from './backpressure.mjs'
38
39
  // reconciliation cadence, never on every tick. A bounded number of edits is
39
40
  // looked at per tick, in the order they were observed, starting where the last
40
41
  // tick stopped.
42
+ //
43
+ // One edit is one unit: it is read, judged and recorded without interruption.
44
+ // Between two edits the cooperative form (`cooperatively`, which the adapter
45
+ // uses on a tick) gives the event loop a turn, so the process that runs the
46
+ // tick goes on answering while a burst of edits is observed. A second
47
+ // cooperative observation waits for the first to finish; what is looked at,
48
+ // and in what order, is the same in both forms. The uninterrupted form
49
+ // cannot wait, so it refuses to run beside a cooperative one.
41
50
 
42
51
  const EXT = OBSIDIAN_EXT_KEY
43
52
  const GONE = new Set(['ENOENT', 'ENOTDIR', 'ELOOP'])
@@ -203,7 +212,8 @@ export function createTickObservation(options = {}) {
203
212
  // `workspace` is the opened workspace of the adapter; `edits` the pending edit records, open and closed;
204
213
  // `retryRefused` offers the edits whose observation refused earlier once more. Returns one entry per edit looked
205
214
  // at: { editId, repoId, nodeId, status: 'observed' | 'refused', code, kind?, operationState?, idempotencyKey? }.
206
- return function observePendingEdits(workspace, { edits, retryRefused = false }) {
215
+ // Yields between two edits, never within one, and returns the entries when it is done.
216
+ function* observeSteps(workspace, { edits, retryRefused = false }) {
207
217
  if (retryRefused) unobservable.clear()
208
218
  const now = isoTime(workspace.clock)
209
219
  const candidates = edits.filter((edit) => edit.closedAt === null && OPEN_EDIT_STATES.includes(edit.state) && !recorded.has(edit.editId) && !unobservable.has(edit.editId))
@@ -212,7 +222,8 @@ export function createTickObservation(options = {}) {
212
222
  const looked = [...candidates.slice(start), ...candidates.slice(0, start)].slice(0, bounds.maxObservedPerTick)
213
223
  cursor = candidates.length > bounds.maxObservedPerTick ? start + looked.length : 0
214
224
  const observed = []
215
- for (const edit of looked) {
225
+ for (const [index, edit] of looked.entries()) {
226
+ if (index > 0) yield 'observe-edit'
216
227
  const entry = { editId: edit.editId, repoId: edit.identity.repoId, nodeId: edit.identity.nodeId }
217
228
  try {
218
229
  const outcome = observeOne(workspace, edit, now)
@@ -226,4 +237,31 @@ export function createTickObservation(options = {}) {
226
237
  }
227
238
  return observed
228
239
  }
240
+
241
+ // Cooperative observations asked for and not yet finished, and the last of them.
242
+ let underWay = 0
243
+ let last = Promise.resolve()
244
+ function observePendingEdits(workspace, input) {
245
+ if (underWay > 0) throw Object.assign(new Error('a cooperative observation of this instance is under way; the uninterrupted form cannot run beside it'), { code: 'observation-under-way' })
246
+ const steps = observeSteps(workspace, input)
247
+ for (;;) { const step = steps.next(); if (step.done) return step.value }
248
+ }
249
+ // The same observation with a turn of the event loop between two edits. One at a time: an observation asked for
250
+ // while another is under way starts when that one has finished, so no edit is looked at by both. One that failed
251
+ // holds back none that follow.
252
+ observePendingEdits.cooperatively = (workspace, input) => {
253
+ underWay += 1
254
+ const run = last.then(async () => {
255
+ const budget = createCooperativeBudget()
256
+ const steps = observeSteps(workspace, input)
257
+ for (;;) {
258
+ const step = steps.next()
259
+ if (step.done) { budget.finish('observe-finalize'); return step.value }
260
+ await budget.checkpoint(step.value, true)
261
+ }
262
+ }).finally(() => { underWay -= 1 })
263
+ last = run.catch(() => {})
264
+ return run
265
+ }
266
+ return observePendingEdits
229
267
  }