@mnstry/atelier 0.2.0-alpha.10 → 0.2.0-alpha.12

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 (81) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +10 -6
  3. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  4. package/contracts/compat-baseline.json +1 -1
  5. package/docs/assurance-controls.md +3 -2
  6. package/docs/install.md +3 -3
  7. package/docs/local-services.md +62 -11
  8. package/docs/obsidian-contract.md +681 -39
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +292 -48
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +32 -3
  13. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  14. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  15. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  16. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  17. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  18. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  19. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  20. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  21. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  22. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  23. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  24. package/fixtures/obsidian/edits/workspace.json +1 -1
  25. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  26. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  27. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  28. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  29. package/package.json +7 -3
  30. package/plugins/obsidian/main.js +444 -0
  31. package/plugins/obsidian/manifest.json +9 -0
  32. package/plugins/obsidian/styles.css +22 -0
  33. package/src/commands/obsidian.mjs +89 -10
  34. package/src/contracts/corpus.mjs +6 -0
  35. package/src/egress/forbidden-egress.mjs +2 -0
  36. package/src/project/private-state.mjs +10 -1
  37. package/src/projection/obsidian/contracts.mjs +224 -18
  38. package/src/projection/obsidian/edits/apply.mjs +12 -7
  39. package/src/projection/obsidian/edits/regions.mjs +137 -42
  40. package/src/projection/obsidian/materialize/index.mjs +6 -3
  41. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  42. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  43. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  44. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  45. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  46. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  47. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  48. package/src/projection/obsidian/proposals/observation.mjs +10 -5
  49. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  50. package/src/projection/obsidian/publication/index.mjs +2 -1
  51. package/src/projection/obsidian/publication/publisher.mjs +173 -26
  52. package/src/projection/obsidian/publication/transport.mjs +124 -17
  53. package/src/projection/obsidian/publication/vault-list.mjs +199 -0
  54. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  55. package/src/projection/obsidian/recovery/store.mjs +11 -4
  56. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  57. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  58. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  59. package/src/runtime/obsidian/app-capability.mjs +115 -14
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -37
  61. package/src/runtime/obsidian/app-registration.mjs +166 -0
  62. package/src/runtime/obsidian/engine.mjs +127 -11
  63. package/src/runtime/obsidian/extension-points.mjs +1 -1
  64. package/src/runtime/obsidian/index.mjs +5 -1
  65. package/src/runtime/obsidian/launch-plan.mjs +88 -0
  66. package/src/runtime/obsidian/lifecycle.mjs +105 -14
  67. package/src/runtime/obsidian/opening.mjs +240 -51
  68. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  69. package/src/runtime/obsidian/plugin-channel.mjs +281 -0
  70. package/src/runtime/obsidian/plugin-choice.mjs +170 -0
  71. package/src/runtime/obsidian/plugin-drift.mjs +76 -0
  72. package/src/runtime/obsidian/plugin-presence.mjs +48 -0
  73. package/src/runtime/obsidian/service-main.mjs +19 -4
  74. package/src/runtime/obsidian/service-record.mjs +36 -0
  75. package/src/runtime/obsidian/service-server.mjs +50 -12
  76. package/src/runtime/obsidian/service.mjs +100 -6
  77. package/src/runtime/obsidian/state-store.mjs +13 -1
  78. package/src/server/local-sidecar.mjs +24 -5
  79. package/src/server/server.mjs +18 -5
  80. package/src/upgrade/upgrade.mjs +1 -1
  81. package/templates/external-project-workspace/pack-lifecycle.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { enclosingVaults, findVaultEntry, vaultRoute } from '../../projection/obsidian/publication/vault-list.mjs'
3
4
  import { createRecoveryStore as createStore, readFileBytes, sha256Digest } from '../../projection/obsidian/recovery/store.mjs'
4
5
  import { inspectApp, qualifyApp } from './app-capability.mjs'
5
6
  import { readObsidianEnablement } from './enablement.mjs'
@@ -24,11 +25,18 @@ import { OPEN_EDIT_STATES, createMaintenanceStateStore } from './state-store.mjs
24
25
  // that same generation and every note in the vault has the bytes that
25
26
  // generation was published with;
26
27
  // - the installed app is present, has its command-line capability, meets
27
- // the minimum version, was asked to open the vault, answers for exactly
28
- // this vault and has finished reading it.
28
+ // the minimum version, knows the vault as one of its vaults, was asked to
29
+ // open it, answers for exactly this vault and has finished reading it.
29
30
  //
30
- // Nothing here talks to an app or an operating system itself: `appProbe` and
31
- // `launcher` are injected, and there is no default for either.
31
+ // Nothing a person does by hand is needed on the way. The app is made to know
32
+ // the vault: through the app itself while it runs and answers, or, while no
33
+ // Obsidian runs, in the app's own vault list (app-registration.mjs). A view
34
+ // that the app kept from being published (an app that runs without the vault,
35
+ // or that could not be qualified) is published again once the app holds the
36
+ // vault, through the app, before the answer is given.
37
+ //
38
+ // Nothing here talks to an app or an operating system itself: `appProbe`,
39
+ // `registry` and `launcher` are injected, and there is no default for any.
32
40
 
33
41
  export const OPENING_OUTCOMES = Object.freeze({
34
42
  current: { summary: 'the vault is the present generation, verified by read-back, and the app has it open', next: 'nothing to do' },
@@ -41,8 +49,8 @@ export const OPENING_OUTCOMES = Object.freeze({
41
49
  'app-cli-unavailable': { summary: 'the installed Obsidian has no usable command-line capability', next: 'enable the command-line interface in Obsidian, then open again' },
42
50
  indexing: { summary: 'the app answered for this vault but has not finished reading it', next: 'wait for Obsidian to finish indexing, then open again' },
43
51
  'publisher-conflict': { summary: 'another publisher or an uncoordinated editor holds this vault', next: 'close the other publisher or let it finish; it is retried automatically' },
44
- 'launch-failed': { summary: 'the operating system or the app did not open this vault', next: 'open the vault folder in Obsidian once by hand, then open again' },
45
- 'service-unavailable': { summary: 'the maintenance service of this workspace could not be started or reached as ours', next: 'see `obsidian service status`' },
52
+ 'launch-failed': { summary: 'the operating system or the app did not open this vault', next: 'run `obsidian open` again; if it fails the same way, start Obsidian with any vault open and open again' },
53
+ 'service-unavailable': { summary: 'the maintenance service of this workspace could not be started or reached as ours, or runs a later release than this command', next: 'see `obsidian service status`' },
46
54
  busy: { summary: 'the maintenance service runs but is in a long tick and did not answer in time', next: 'run `obsidian open` again in a moment; nothing was stopped or restarted' },
47
55
  disabled: { summary: 'the Obsidian integration is not enabled for this project, or this view is not configured', next: 'enable it in the project configuration' },
48
56
  })
@@ -57,19 +65,63 @@ export function describeOutcome(code) {
57
65
 
58
66
  // A reason whose next step is not its outcome's. With no vault open the app's
59
67
  // command line answers nothing, its version included, so the version floor
60
- // cannot be checked until a vault is open or the app is quit.
61
- // `editor-uncoordinated` is the publisher refusing to write while an Obsidian
62
- // runs that does not answer for this vault: nothing coordinates with the app,
63
- // so nothing is written. There is no other publisher to wait for. With the app
64
- // quit, the view is published directly, and `open` then starts the app on it.
68
+ // cannot be checked, and a vault the app does not know yet cannot be added
69
+ // through it, until a vault is open or the app is quit.
70
+ // `editor-uncoordinated` is the publisher stopping because an Obsidian that may
71
+ // hold this vault could not be coordinated with. There is no other publisher to
72
+ // wait for, and the cause is not known here: the app runs without this vault,
73
+ // or with it but its command line did not answer, or the process table was
74
+ // unknown (on Linux, any app that runs on a system Electron), or an app started
75
+ // while the view was published with the app closed. `open` makes the app hold
76
+ // the vault and publishes through it; every cause also clears once no such
77
+ // process runs.
78
+ const UNCOORDINATED = 'an Obsidian that may hold this vault could not be coordinated with, so publication stopped'
79
+ const QUIT = 'quit Obsidian (on Linux, also any app that runs on a system Electron)'
80
+ // Obsidian's settings file could not be used to add the vault while the app was quit: adding it through the app works instead.
81
+ const THROUGH_THE_APP = 'start Obsidian with any vault open, then open again: the vault is then added through the app'
65
82
  export const REASON_NEXT = Object.freeze({
66
- 'no-vault-open': 'open any vault in Obsidian, or quit Obsidian, then open again',
67
- 'editor-uncoordinated': 'Obsidian is running without this vault open, so nothing is written into it: quit Obsidian, let the view publish, then open again',
83
+ 'cli-turned-off': 'Obsidian\'s command line is turned off (the default of a new installation): turn it on in Obsidian under Settings > General > Advanced > Command line interface, then open again',
84
+ 'no-vault-open': 'open any vault in Obsidian, or quit Obsidian, then open again; open then adds this view\'s vault to Obsidian itself',
85
+ 'vault-open-cli-silent': 'Obsidian has this view\'s vault open, as Atelier\'s plugin in it shows, but its command line did not answer, so the vault can be neither found nor opened through it; make sure the command-line interface is turned on in Obsidian\'s settings, then open again',
86
+ 'editor-uncoordinated': `${UNCOORDINATED}; \`atelier obsidian open\` adds this view's vault to Obsidian and publishes through it, or ${QUIT}; it is retried automatically`,
87
+ 'obsidian-settings-missing': 'Obsidian has not run on this account yet: start it once (it creates its settings), then open again with it running or quit',
88
+ 'obsidian-settings-location-unknown': THROUGH_THE_APP,
89
+ 'obsidian-settings-unsafe': `Obsidian's settings file is a link or not a regular file, so it is not written; ${THROUGH_THE_APP}`,
90
+ 'obsidian-settings-not-owned': `Obsidian's settings file belongs to another user, so it is not written; ${THROUGH_THE_APP}`,
91
+ 'obsidian-settings-unreadable': `Obsidian's settings file cannot be read as JSON, so it is not written; ${THROUGH_THE_APP}`,
92
+ 'obsidian-settings-not-object': `Obsidian's settings file is not a JSON object, so it is not written; ${THROUGH_THE_APP}`,
93
+ 'obsidian-settings-unwritable': `Obsidian's settings directory cannot be written (space or permissions); ${THROUGH_THE_APP}`,
94
+ 'obsidian-settings-too-large': `with this view's vault, Obsidian's settings file would be larger than a settings file can be, so it is not written; ${THROUGH_THE_APP}`,
95
+ 'obsidian-sandboxed': `this Obsidian is a Flatpak or snap build, which keeps its vault list inside its sandbox, where Atelier does not write; ${THROUGH_THE_APP}`,
96
+ 'obsidian-settings-changed': 'Obsidian\'s settings file changed while the vault was being added, and nothing was written; open again',
97
+ 'app-may-be-running': 'Obsidian started while the vault was being added, and nothing was written; open again',
98
+ 'app-started-during-registration': 'Obsidian started just as this view\'s vault was added to its list and may not have read it; open again: a vault it missed is then added through it',
99
+ 'registration-not-read-back': 'this view\'s vault was added to Obsidian\'s settings file, but the file could not be read back to confirm it; open again',
100
+ 'addition-not-answered': 'Obsidian did not answer when asked to add this view\'s vault, and its vault list does not show it; open again, or quit Obsidian and open again',
101
+ 'app-did-not-list-its-vaults': 'Obsidian runs but did not answer with its vault list; open again, or quit Obsidian and open again',
102
+ 'app-refused-registration': 'Obsidian did not accept this view\'s vault folder as a vault; quit Obsidian and open again',
103
+ 'registration-not-verified': 'Obsidian answered, but its vault list does not show this view\'s vault; quit Obsidian and open again',
104
+ 'restarted-service-in-its-first-tick': 'the maintenance service ran an earlier release and was restarted on the installed one, which is still in its first tick; run `obsidian open` again in a moment',
105
+ 'service-outdated': 'the maintenance service runs an earlier release of Atelier that could not be replaced; run `atelier obsidian service stop`, then open again',
106
+ 'service-other-release': 'the maintenance service runs a later release of Atelier than this command, which never replaces a later release by itself; run `atelier obsidian service stop`, then open again, or open with the later release',
107
+ 'vault-open-in-several-windows': 'Obsidian\'s vault list marks this view\'s folder open under more than one entry (in another letter case, or through a link), so it may hold the vault in more than one window, and a publication coordinates with one window only; remove the extra entries from Obsidian\'s vault list (Obsidian keeps the last window it closed marked open, so closing windows does not clear this), then open again',
108
+ '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',
109
+ })
110
+
111
+ // What `open` answers when it could not clear the refusal itself (the app held the vault and the publication still
112
+ // stopped, or no app answered to be asked): no step is left that open could take.
113
+ const AFTER_OPEN_NEXT = Object.freeze({
114
+ 'editor-uncoordinated': `${UNCOORDINATED}, and open could not clear it; ${QUIT} so the view is published, then open again`,
68
115
  })
69
116
 
70
- // The next step for an outcome and its reason; null for a code that is not an opening outcome.
71
- export function nextStep(outcome, reason) {
72
- if (typeof reason === 'string' && Object.hasOwn(REASON_NEXT, reason)) return REASON_NEXT[reason]
117
+ // The next step for an outcome and its reason; null for a code that is not an
118
+ // opening outcome. `afterOpen` says that the answer is `open`'s own, after it
119
+ // tried what it can.
120
+ export function nextStep(outcome, reason, { afterOpen = false } = {}) {
121
+ if (typeof reason === 'string') {
122
+ if (afterOpen && Object.hasOwn(AFTER_OPEN_NEXT, reason)) return AFTER_OPEN_NEXT[reason]
123
+ if (Object.hasOwn(REASON_NEXT, reason)) return REASON_NEXT[reason]
124
+ }
73
125
  return Object.hasOwn(OPENING_OUTCOMES, outcome) ? OPENING_OUTCOMES[outcome].next : null
74
126
  }
75
127
 
@@ -81,6 +133,10 @@ export const OPENING_PRIMITIVES = Object.freeze({
81
133
  readBackAgrees: ({ entry, verification }) => verification.readable && verification.generationId === entry.preparedGenerationId && verification.intact,
82
134
  // Whether the app may be shown this vault at all.
83
135
  appQualifies: (qualification) => qualification.outcome === 'qualified',
136
+ // Whether a view that is not current was kept from publication by the app: then making the app hold the vault
137
+ // and asking for the view again can make it current. A vault open in several windows is asked for again once one
138
+ // window is left; while more are open, open names them instead.
139
+ keptByApp: (view) => ['publisher-conflict', 'stale-readable', 'not-prepared'].includes(view.outcome) && ['editor-uncoordinated', 'vault-open-in-several-windows', 'app-version-unsupported'].includes(view.reason),
84
140
  })
85
141
 
86
142
  const segment = (identifier) => identifier.replaceAll(':', '_')
@@ -88,6 +144,8 @@ const STORE_AREAS = (scopeId) => [['vaults', segment(scopeId)], ['state', 'manif
88
144
 
89
145
  // Reads the trusted pointer of a view and every note of its vault. Read-only:
90
146
  // the store is only constructed when everything it would create already exists.
147
+ // `vaultRoot` is the folder the store publishes this view into, whenever the
148
+ // store could be constructed, even before a first generation.
91
149
  export function readBackTrustedGeneration({ workspaceRoot, workspaceId, scopeId, repositoryRoots, createRecoveryStore = createStore }) {
92
150
  const unreadable = (reason) => ({ readable: false, reason, generationId: null, intact: false, noteCount: 0, differing: 0, missing: 0 })
93
151
  if (!STORE_AREAS(scopeId).every((parts) => fs.existsSync(path.join(workspaceRoot, ...parts)))) return unreadable('no-published-vault')
@@ -100,7 +158,7 @@ export function readBackTrustedGeneration({ workspaceRoot, workspaceId, scopeId,
100
158
  if (typeof error?.code === 'string') return unreadable(error.code)
101
159
  throw error
102
160
  }
103
- if (!trusted.pointer || !trusted.manifest) return unreadable('no-trusted-generation')
161
+ if (!trusted.pointer || !trusted.manifest) return { ...unreadable('no-trusted-generation'), vaultRoot: store.vaultRoot }
104
162
  let differing = 0
105
163
  let missing = 0
106
164
  for (const [notePath, base] of trusted.bases) {
@@ -147,10 +205,12 @@ export function scopeReport({ workspace, scopeId, repositoryRoots, serviceState,
147
205
  } else if (serviceState !== 'healthy') { outcome = 'stale-readable'; reason = serviceState === 'busy' ? 'maintenance-busy-not-rechecked' : 'maintenance-not-running' }
148
206
  else { outcome = 'current'; reason = entry.reason }
149
207
  return {
150
- scopeId, ...describeOutcome(outcome), reason,
208
+ scopeId, ...describeOutcome(outcome), next: nextStep(outcome, reason), reason,
151
209
  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 },
152
210
  readBack: { readable: verification.readable, reason: verification.reason, generationId: verification.generationId, intact: verification.intact, noteCount: verification.noteCount, differing: verification.differing, missing: verification.missing },
153
211
  pendingEdits: pendingSummary(stateStore, scopeId, applyAvailable),
212
+ // Notes of the view worth naming, and the rule concerned; see "Notes that were laid out anyway" in docs/obsidian-contract.md.
213
+ ...(entry?.diagnostics ? { diagnostics: entry.diagnostics } : {}),
154
214
  vaultRoot: verification.vaultRoot ?? null,
155
215
  }
156
216
  }
@@ -166,12 +226,78 @@ const defaultSleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms)
166
226
  const READABLE = new Set(['stale-readable', 'held-for-your-edit', 'updating', 'publisher-conflict'])
167
227
  // After a launch: the app is still starting, or still opening the vault it was asked for.
168
228
  const NOT_UP_YET = new Set(['version-unknown', 'no-vault-open'])
229
+ const REGISTRY_OPERATIONS = ['listThroughApp', 'registerThroughApp', 'readSettings', 'registerInSettings']
230
+
231
+ const attempt = async (operation) => { try { return await operation() } catch { return null } }
232
+
233
+ // Makes the app know this vault as one of its vaults, by the state it is in:
234
+ //
235
+ // - answering its command line (it gave its version): its own list is read;
236
+ // a vault it does not list is added and opened through the app, and read
237
+ // back from its list (findVaultEntry);
238
+ // - running with no vault open: its command line answers nothing, and its
239
+ // settings file is the running app's, so it is only read: a vault it lists
240
+ // is opened by path, and one it does not list is `no-vault-open`. So is an
241
+ // app whose version only Atelier's plugin reported: its command line gave
242
+ // none, so nothing is asked through it, and a vault its settings do not
243
+ // show is `vault-open-cli-silent` (the plugin shows the vault open);
244
+ // - not running: the vault is added to its settings file, which is written
245
+ // only while no Obsidian runs (registerVaultInObsidianSettings). An app
246
+ // that started just after that write may have read its list before it, and
247
+ // would then not find the vault by path: that is answered, never launched.
248
+ //
249
+ // A vault inside a folder the list has as a vault already is never added
250
+ // (`vault-inside-another-vault`): the app would show its notes in that vault
251
+ // too, and a call run in its folder would reach that vault.
252
+ //
253
+ // { ok: true, path, how, vaults } with the path the app knows the vault by and
254
+ // the list it was found in, or { ok: false, reason }.
255
+ //
256
+ // The window the app opens for an added vault takes the next command-line
257
+ // call while it may still be loading, and can then answer that a command
258
+ // does not exist yet: the list is asked again, a bounded number of times,
259
+ // before an addition counts as not verified.
260
+ const VERIFY_ATTEMPTS = 10
261
+ async function ensureAppKnowsVault({ registry, observation, vaultRoot, sleep, pollMs }) {
262
+ const inside = (vaults) => enclosingVaults({ vaults, vaultRoot }).length > 0
263
+ if (observation.noVaultOpen === true) {
264
+ const settings = await attempt(() => registry.readSettings())
265
+ const vaults = settings?.ok === true ? settings.vaults : null
266
+ const entry = findVaultEntry(vaults, vaultRoot)
267
+ if (entry) return { ok: true, path: entry.path, how: 'listed', vaults }
268
+ return { ok: false, reason: inside(vaults) ? 'vault-inside-another-vault' : observation.fromPlugin === true ? 'vault-open-cli-silent' : 'no-vault-open' }
269
+ }
270
+ if (observation.answering === true) {
271
+ const listed = await attempt(() => registry.listThroughApp())
272
+ if (listed?.answered !== true) return { ok: false, reason: listed?.reason === 'no-vault-open' ? 'no-vault-open' : 'app-did-not-list-its-vaults' }
273
+ const known = findVaultEntry(listed.vaults, vaultRoot)
274
+ if (known) return { ok: true, path: known.path, how: 'listed', vaults: listed.vaults }
275
+ if (inside(listed.vaults)) return { ok: false, reason: 'vault-inside-another-vault' }
276
+ const asked = await attempt(() => registry.registerThroughApp({ vaultRoot }))
277
+ if (asked?.answered !== true && asked?.reason === 'no-vault-open') return { ok: false, reason: 'no-vault-open' }
278
+ if (asked?.answered === true && asked.result !== true) return { ok: false, reason: 'app-refused-registration' }
279
+ // An addition that was not answered (a call that timed out, say) may still have been made: the list tells.
280
+ for (let attempts = 1; ; attempts += 1) {
281
+ const again = await attempt(() => registry.listThroughApp())
282
+ const added = again?.answered === true ? findVaultEntry(again.vaults, vaultRoot) : null
283
+ if (added) return { ok: true, path: added.path, how: 'added-through-app', vaults: again.vaults }
284
+ if (attempts >= VERIFY_ATTEMPTS) return { ok: false, reason: asked?.answered === true ? 'registration-not-verified' : 'addition-not-answered' }
285
+ await sleep(pollMs)
286
+ }
287
+ }
288
+ const written = await attempt(() => registry.registerInSettings({ vaultRoot }))
289
+ if (written === null) return { ok: false, reason: 'obsidian-settings-unwritable' }
290
+ if (written.ok !== true) return { ok: false, reason: typeof written.code === 'string' ? written.code : 'obsidian-settings-unwritable' }
291
+ if (written.confirmed !== true) return { ok: false, reason: written.reason === 'registration-not-read-back' ? 'registration-not-read-back' : 'app-started-during-registration' }
292
+ return { ok: true, path: written.entry.path, how: written.registered === 'already' ? 'listed' : 'added-to-settings', vaults: written.vaults }
293
+ }
169
294
 
170
295
  // Starts or reconnects the owned service, asks it for a tick, reads the view
171
- // back, qualifies the app, has the vault opened and reports one outcome.
296
+ // back, qualifies the app, makes the app know the vault, has it opened, asks
297
+ // again for a view the app kept from being published, and reports one outcome.
172
298
  export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIMITIVES, lifecycleRules = LIFECYCLE_PRIMITIVES) {
173
299
  const {
174
- loadProject, dataRoot, scopeId: requestedScope, appProbe, launcher, service = {}, consent, allowStale = false, extensions = null,
300
+ loadProject, dataRoot, scopeId: requestedScope, appProbe, launcher, registry, service = {}, consent, allowStale = false, extensions = null,
175
301
  tickTimeoutMs = 120 * 1000, appWaitMs = 30 * 1000, appPollMs = 500, sleep = defaultSleep, monotonic = () => Date.now(),
176
302
  env = process.env, platform = process.platform, probeTimeoutMs,
177
303
  } = options
@@ -179,17 +305,22 @@ export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIM
179
305
  // No defaults: reaching an app or the operating system is a decision of the entry that composes this.
180
306
  if (typeof appProbe?.inspect !== 'function' || typeof appProbe?.vaultState !== 'function') throw new TypeError('open needs an injected appProbe')
181
307
  if (typeof launcher?.open !== 'function') throw new TypeError('open needs an injected launcher')
308
+ if (!REGISTRY_OPERATIONS.every((name) => typeof registry?.[name] === 'function')) throw new TypeError('open needs an injected registry')
182
309
  if (typeof service.entryPath !== 'string') throw new TypeError('open needs the service entry it may start')
183
310
 
184
311
  const project = loadProject()
185
312
  const enablement = readObsidianEnablement(project)
186
313
  const applyAvailable = extensions !== null && extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
187
- const finish = (outcome, extra = {}) => ({ ok: outcome === 'current', ...describeOutcome(outcome), next: nextStep(outcome, extra.reason), launched: false, ...extra })
314
+ const finish = (outcome, extra = {}) => {
315
+ const { afterOpen = false, ...shown } = extra
316
+ return { ok: outcome === 'current', ...describeOutcome(outcome), next: nextStep(outcome, shown.reason, { afterOpen }), launched: false, ...shown }
317
+ }
188
318
  if (enablement.state === 'disabled') return finish('disabled', { reason: enablement.reason, scopeId: requestedScope ?? null })
189
319
  const scopeId = resolveScope(enablement, requestedScope)
190
320
  const lifecycle = { loadProject, dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
191
321
 
192
- // 1. The owned service: reconnect, or start. Never adopt, never replace.
322
+ // 1. The owned service: reconnect, or start. Never adopt; a runtime of ours is replaced only when it runs an earlier
323
+ // release, under the consent already recorded, when a tick is asked of it (requestServiceTick).
193
324
  let status = await serviceStatus(lifecycle, lifecycleRules)
194
325
  if (status.state === 'busy') return finish('busy', { scopeId, reason: status.reason, service: { state: status.state } })
195
326
  if (status.state !== 'healthy') {
@@ -204,57 +335,115 @@ export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIM
204
335
  if (started.state !== 'healthy') return finish('service-unavailable', { scopeId, reason: started.reason ?? started.state, service: { state: started.state } })
205
336
  status = started
206
337
  }
207
-
208
- // 2. A tick that starts after this request, bounded.
209
- const asked = await requestServiceTick({ ...lifecycle, tickTimeoutMs }, lifecycleRules)
210
- const serviceShown = { state: asked.state, runtimeId: status.record?.runtimeId ?? null }
211
- if (asked.state === 'busy') return finish('busy', { scopeId, reason: asked.reason, service: serviceShown })
212
- if (!asked.requested) return finish('service-unavailable', { scopeId, reason: asked.reason, service: serviceShown })
338
+ let runtimeId = status.record?.runtimeId ?? null
339
+ let restarted = null
213
340
  const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
214
341
  const report = (freshnessOverride) => scopeReport({ workspace, scopeId, repositoryRoots: protectedRoots(project), serviceState: 'healthy', applyAvailable, ...(freshnessOverride === undefined ? {} : { freshness: freshnessOverride }) }, rules)
215
- let view = report()
216
- const tick = asked.pending ? null : asked.tick
217
- if (asked.pending && view.outcome === 'current') view = { ...view, ...describeOutcome('updating'), reason: 'tick-still-running' }
218
- else if (!asked.pending && tick?.state === 'busy') return finish('busy', { scopeId, reason: 'engine-lock-held', service: serviceShown })
219
- else if (!asked.pending && (tick === null || tick.ok !== true || tick.state !== 'ticked') && view.outcome === 'current') {
220
- // The tick after this request did not complete: what was current before is not proven current now.
221
- view = { ...view, ...describeOutcome('stale-readable'), reason: `tick-${tick?.state ?? tick?.error?.code ?? 'unanswered'}` }
342
+
343
+ // A tick that starts after this request, bounded, which prepares and publishes this view once more, and the view as
344
+ // it left it. { view, serviceShown } or { answer }.
345
+ const tickAndReport = async () => {
346
+ const asked = await requestServiceTick({ ...lifecycle, tickTimeoutMs, scopeId, service }, lifecycleRules)
347
+ if (asked.restarted) restarted = asked.restarted
348
+ if (typeof asked.runtimeId === 'string') runtimeId = asked.runtimeId
349
+ const serviceShown = { state: asked.state, runtimeId, ...(restarted === null ? {} : { restarted }) }
350
+ if (asked.state === 'busy') return { answer: { outcome: 'busy', extra: { scopeId, reason: asked.reason, service: serviceShown } } }
351
+ if (!asked.requested) return { answer: { outcome: 'service-unavailable', extra: { scopeId, reason: asked.reason, service: serviceShown } } }
352
+ let view = report()
353
+ const tick = asked.pending ? null : asked.tick
354
+ if (asked.pending && view.outcome === 'current') view = { ...view, ...describeOutcome('updating'), reason: 'tick-still-running' }
355
+ else if (!asked.pending && tick?.state === 'busy') return { answer: { outcome: 'busy', extra: { scopeId, reason: 'engine-lock-held', service: serviceShown } } }
356
+ else if (!asked.pending && (tick === null || tick.ok !== true || tick.state !== 'ticked') && view.outcome === 'current') {
357
+ // The tick after this request did not complete: what was current before is not proven current now.
358
+ view = { ...view, ...describeOutcome('stale-readable'), reason: `tick-${tick?.state ?? tick?.error?.code ?? 'unanswered'}` }
359
+ }
360
+ return { view, serviceShown }
222
361
  }
223
- const common = { scopeId, reason: view.reason, service: serviceShown, freshness: view.freshness, readBack: view.readBack, pendingEdits: view.pendingEdits }
224
- if (view.outcome !== 'current' && !(allowStale && READABLE.has(view.outcome) && view.readBack.readable)) return finish(view.outcome, common)
362
+ const shownOf = (view, serviceShown) => ({ scopeId, reason: view.reason, service: serviceShown, freshness: view.freshness, readBack: view.readBack, pendingEdits: view.pendingEdits })
225
363
 
226
- // 3. The app, before anything is launched.
364
+ // 2. The view, after a tick.
365
+ const first = await tickAndReport()
366
+ if (first.answer) return finish(first.answer.outcome, first.answer.extra)
367
+ let { view } = first
368
+ let common = shownOf(view, first.serviceShown)
369
+ // A view the app kept from being published goes on: the app is made to hold the vault, and the view asked for again.
370
+ const readable = allowStale && READABLE.has(view.outcome) && view.readBack.readable
371
+ const keptByApp = view.outcome !== 'current' && rules.keptByApp(view)
372
+ if (view.outcome !== 'current' && !readable && !keptByApp) return finish(view.outcome, common)
373
+
374
+ // 3. The app, before anything is launched. With no vault open it answers nothing; a vault it already lists can still be opened by path.
227
375
  const before = qualifyApp(await inspectApp(appProbe), { requireVersion: false })
228
376
  const app = (qualification) => ({ outcome: qualification.outcome, reason: qualification.reason, version: qualification.version, floor: qualification.floor })
229
- if (!rules.appQualifies(before)) return finish(before.outcome, { ...common, reason: before.reason, app: app(before) })
377
+ const noVaultOpen = before.reason === 'no-vault-open'
378
+ if (!rules.appQualifies(before) && !noVaultOpen) {
379
+ // No app answered and none is known to run (a process table that could not be read, on Linux any app on a system
380
+ // Electron): for a view that app kept, the view's own reason says what to do.
381
+ if (keptByApp && !readable && before.running !== true && before.version === null) return finish(view.outcome, { ...common, app: app(before), afterOpen: true })
382
+ return finish(before.outcome, { ...common, reason: before.reason, app: app(before) })
383
+ }
384
+
385
+ // 4. The app knows this vault as one of its vaults: the folder the view is published into, wherever that is. Every
386
+ // call about it then reaches it and no other vault: in its folder, or by its id when a vault listed at a folder
387
+ // above it would take a call run there.
388
+ const { vaultRoot } = view
389
+ if (typeof vaultRoot !== 'string') return finish('not-prepared', { ...common, reason: 'no-vault-folder', app: app(before) })
390
+ // A version Atelier's plugin reported is not the command line answering (withPluginReportedVersion).
391
+ const fromPlugin = before.versionSource === 'plugin'
392
+ const known = await ensureAppKnowsVault({ registry, observation: { answering: typeof before.version === 'string' && !fromPlugin, noVaultOpen: noVaultOpen || fromPlugin, fromPlugin }, vaultRoot, sleep, pollMs: appPollMs })
393
+ // An app that answered its version and then closed its last vault window is one with no vault open; one that holds
394
+ // this vault open but whose command line does not answer has no usable command line.
395
+ if (!known.ok) return finish(known.reason === 'no-vault-open' ? 'app-version-unsupported' : known.reason === 'vault-open-cli-silent' ? 'app-cli-unavailable' : 'launch-failed', { ...common, reason: known.reason, app: app(before) })
396
+ const registration = { how: known.how }
397
+ const route = vaultRoute({ vaults: known.vaults, vaultRoot })
398
+ // Open in several windows, one per entry of the list that names its folder: each holds the vault, and a publication
399
+ // coordinates with one only. Nothing is launched; the entries are named.
400
+ if (route.how === 'duplicated') return finish('publisher-conflict', { ...common, reason: 'vault-open-in-several-windows', duplicates: route.entries, app: app(before), registration })
401
+ if (route.how !== 'folder' && route.how !== 'id') return finish('launch-failed', { ...common, reason: route.how === 'ambiguous' ? 'vault-inside-another-vault' : 'registration-not-verified', app: app(before), registration })
230
402
 
231
- // 4. Launch, then wait, bounded, for the app to answer for exactly this vault.
403
+ // 5. Launch, then wait, bounded, for the app to answer for exactly this vault.
232
404
  let launch
233
- try { launch = await launcher.open({ vaultRoot: view.vaultRoot }) } catch { launch = { launched: false, reason: 'launcher-threw' } }
234
- if (launch?.launched !== true) return finish('launch-failed', { ...common, reason: typeof launch?.reason === 'string' ? launch.reason : 'launcher-refused', app: app(before) })
405
+ // A quit app is started plainly and handed the vault once it answers, so the vaults its list flags open come back
406
+ // (launch-plan.mjs).
407
+ // The id only where the route found it reaches this vault first (another vault's folder name may equal an id);
408
+ // otherwise the vault's own path, which the app matches exactly since the folder is listed.
409
+ const target = route.how === 'id' ? { vaultId: route.id, vaultPath: known.path } : { vaultPath: known.path }
410
+ try { launch = await launcher.open({ vaultRoot: known.path, ...target, appRunning: before.running === true }) } catch { launch = { launched: false, reason: 'launcher-threw' } }
411
+ if (launch?.launched !== true) return finish('launch-failed', { ...common, reason: typeof launch?.reason === 'string' ? launch.reason : 'launcher-refused', app: app(before), registration })
235
412
  const deadline = monotonic() + appWaitMs
236
413
  let after = before
237
414
  let vault = { answered: false, indexReady: false }
238
415
  for (;;) {
239
416
  after = qualifyApp(await inspectApp(appProbe), { requireVersion: true })
240
417
  // A running app below the floor is final; an app that has not come up yet, or not yet opened a vault, is asked again.
241
- if (!rules.appQualifies(after) && !NOT_UP_YET.has(after.reason)) return finish(after.outcome, { ...common, launched: true, reason: after.reason, app: app(after) })
418
+ if (!rules.appQualifies(after) && !NOT_UP_YET.has(after.reason)) return finish(after.outcome, { ...common, launched: true, reason: after.reason, app: app(after), registration })
242
419
  if (rules.appQualifies(after)) {
243
- try { vault = await appProbe.vaultState({ vaultRoot: view.vaultRoot }) } catch { vault = { answered: false, indexReady: false } }
420
+ try { vault = await appProbe.vaultState({ vaultRoot, route }) } catch { vault = { answered: false, indexReady: false } }
244
421
  if (vault?.answered === true && vault.indexReady === true) break
422
+ // Only the command line answers for a vault. An app whose version still only Atelier's plugin reports gives no
423
+ // answer there, and waiting changes nothing: its command line is what is missing, not a launch.
424
+ if (vault?.answered !== true && after.versionSource === 'plugin') return finish('app-cli-unavailable', { ...common, launched: true, reason: 'vault-open-cli-silent', app: app(after), registration })
245
425
  }
246
426
  if (monotonic() >= deadline) break
247
427
  await sleep(appPollMs)
248
428
  }
249
- if (!rules.appQualifies(after)) return finish(after.outcome, { ...common, launched: true, reason: after.reason, app: app(after) })
250
- if (vault?.answered !== true) return finish('launch-failed', { ...common, launched: true, reason: 'app-did-not-answer-for-this-vault', app: app(after) })
251
- if (vault.indexReady !== true) return finish('indexing', { ...common, launched: true, reason: 'metadata-cache-not-ready', app: app(after) })
429
+ if (!rules.appQualifies(after)) return finish(after.outcome, { ...common, launched: true, reason: after.reason, app: app(after), registration })
430
+ if (vault?.answered !== true) return finish('launch-failed', { ...common, launched: true, reason: 'app-did-not-answer-for-this-vault', app: app(after), registration })
431
+ if (vault.indexReady !== true) return finish('indexing', { ...common, launched: true, reason: 'metadata-cache-not-ready', app: app(after), registration })
432
+
433
+ // 6. The app holds the vault now: a view it kept from being published is asked for again, and published through it.
434
+ if (keptByApp) {
435
+ const second = await tickAndReport()
436
+ if (second.answer) return finish(second.answer.outcome, { ...second.answer.extra, launched: true, app: app(after), registration })
437
+ view = second.view
438
+ common = shownOf(view, second.serviceShown)
439
+ if (view.outcome !== 'current') return finish(view.outcome, { ...common, launched: true, app: app(after), registration, afterOpen: true })
440
+ }
252
441
 
253
- // 5. Still what was verified? The launch took time.
254
- if (view.outcome !== 'current') return finish(view.outcome, { ...common, launched: true, app: app(after) })
442
+ // 7. Still what was verified? The launch took time.
443
+ if (view.outcome !== 'current') return finish(view.outcome, { ...common, launched: true, app: app(after), registration })
255
444
  const again = report()
256
- if (again.outcome !== 'current') return finish(again.outcome, { ...common, launched: true, reason: again.reason, freshness: again.freshness, readBack: again.readBack, app: app(after) })
257
- return finish('current', { ...common, launched: true, app: app(after) })
445
+ if (again.outcome !== 'current') return finish(again.outcome, { ...common, launched: true, reason: again.reason, freshness: again.freshness, readBack: again.readBack, app: app(after), registration })
446
+ return finish('current', { ...common, launched: true, app: app(after), registration })
258
447
  }
259
448
 
260
449
  // Production entry point: the production decisions, always.
@@ -91,6 +91,16 @@ export function heldPaths(edits, scopeId) {
91
91
  return [...new Set(edits.filter((edit) => edit.scopeId === scopeId && isOpenEdit(edit)).map((edit) => edit.path))].sort(compareText)
92
92
  }
93
93
 
94
+ // The notes a view must keep in the vault layout they were published in: those
95
+ // held for an open edit, and those whose edit closed on this tick (automatic
96
+ // mode applies and closes an edit on the tick it observes it). The view is
97
+ // prepared in that layout once more, so the file the person edited becomes the
98
+ // published note, and a later tick lays the view out again and retires it.
99
+ export function layoutHeldPaths(edits, scopeId, now) {
100
+ const closedNow = edits.filter((edit) => edit.scopeId === scopeId && edit.closedAt !== null && edit.closedAt === now)
101
+ return [...new Set([...heldPaths(edits, scopeId), ...closedNow.map((edit) => edit.path)])].sort(compareText)
102
+ }
103
+
94
104
  // Automatic dispatch. `authorize()` reads the machine settings and the policy
95
105
  // from disk. It is called before the batch and again immediately before each
96
106
  // edit, so a revocation, a pause or a switch back to manual stops the very