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

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0-alpha.11
4
+
5
+ ### Fixed
6
+
7
+ - `status` now gives the same reason-specific next step as `open` for a
8
+ publication that stopped because an Obsidian that may hold the vault could
9
+ not be coordinated with (`publisher-conflict`, reason
10
+ `editor-uncoordinated`). The text no longer names one cause, since the
11
+ reason has several: another vault open, a command line that did not answer,
12
+ an unknown process table (on Linux, any app on a system Electron), or an app
13
+ started during an app-closed publication. The advice depends on whether the
14
+ view was ever published. Before a first publication: quit Obsidian (on
15
+ Linux, also any app that runs on a system Electron) so the view is
16
+ published, then `atelier obsidian open` starts Obsidian on it. After one:
17
+ the same, or open the view in the app as it is with `atelier obsidian open
18
+ --allow-stale`. A concurrent publisher keeps its advice.
19
+ - The service's app qualification checks no version for an app that is not
20
+ running, and that answer is remembered for up to ten seconds. An editor
21
+ adapter built on it could coordinate with an Obsidian started inside that
22
+ window, whose version nobody had checked, and publish through it in-app.
23
+ The adapter now receives the qualification and does not coordinate, or ask
24
+ the app anything, unless a version was checked. An answer that checked no
25
+ version (the app was not running, or none was found) is no longer reused,
26
+ so the next publication after the app starts reads its version and
27
+ coordinates. A publication that stops this way is tried again on the next
28
+ change or at the next full reconciliation, every five minutes.
29
+ - On macOS the app is found only at `/Applications/Obsidian.app`. An
30
+ Obsidian installed elsewhere is `app-missing`: nothing is published through
31
+ it, and publication waits while it runs. This is now documented.
32
+ - On Linux an app started through a differently named link or launcher was
33
+ read as absent, because the process name is the name it was started
34
+ through. A process whose name is not recognised is now identified by the
35
+ executable `/proc/<pid>/exe` resolves to. A read failure other than a
36
+ process that exited or whose executable this user may not read, or a table
37
+ in which nothing resolves, is `unknown`.
38
+ - The `ps` call of the process probe had no timeout, so a hung `ps` blocked
39
+ the service. It is killed after five seconds and the reading is `unknown`.
40
+ - Documented that from 0.2.0-alpha.10 the app-free publication path runs in
41
+ production, with its re-probe window of up to two seconds plus one note
42
+ while Obsidian is not running (see "Known limits" in `docs/obsidian.md`).
43
+
3
44
  ## 0.2.0-alpha.10
4
45
 
5
46
  ### Fixed
package/README.md CHANGED
@@ -54,7 +54,7 @@ governed projections
54
54
  You can see the complete loop in a disposable sample workspace:
55
55
 
56
56
  ```bash
57
- npm install --save-dev @mnstry/atelier@0.2.0-alpha.10
57
+ npm install --save-dev @mnstry/atelier@0.2.0-alpha.11
58
58
  npx mnstry-atelier init --fixture=sample-workspace --target ./sample
59
59
  npx mnstry-atelier graph --project ./sample/atelier.project.json
60
60
  npx mnstry-atelier project --project ./sample/atelier.project.json
@@ -378,7 +378,7 @@ Node.js `>=22.18.0 <23` is required. Pin the prerelease while the package
378
378
  remains in alpha:
379
379
 
380
380
  ```bash
381
- npm install --save-dev @mnstry/atelier@0.2.0-alpha.10
381
+ npm install --save-dev @mnstry/atelier@0.2.0-alpha.11
382
382
  ```
383
383
 
384
384
  Then choose the path that matches what you are building:
@@ -397,7 +397,7 @@ Then choose the path that matches what you are building:
397
397
 
398
398
  ## Status and command reference
399
399
 
400
- Current package: `@mnstry/atelier@0.2.0-alpha.10`.
400
+ Current package: `@mnstry/atelier@0.2.0-alpha.11`.
401
401
 
402
402
  The alpha package is usable and contract-tested, but its library API may still
403
403
  change before a stable release. Pin the exact version in production toolchains.
package/docs/install.md CHANGED
@@ -34,19 +34,19 @@ The registry is the distribution channel of record, as `docs/continuity.md`
34
34
  commits:
35
35
 
36
36
  ```bash
37
- npm install --save-dev @mnstry/atelier@0.2.0-alpha.10
37
+ npm install --save-dev @mnstry/atelier@0.2.0-alpha.11
38
38
  ```
39
39
 
40
40
  Installing from the matching Git tag resolves to the same reviewed commit:
41
41
 
42
42
  ```bash
43
- npm install --save-dev "git+https://github.com/MNSTRY/atelier.git#v0.2.0-alpha.10"
43
+ npm install --save-dev "git+https://github.com/MNSTRY/atelier.git#v0.2.0-alpha.11"
44
44
  ```
45
45
 
46
46
  Or over SSH:
47
47
 
48
48
  ```bash
49
- npm install --save-dev "git+ssh://git@github.com/MNSTRY/atelier.git#v0.2.0-alpha.10"
49
+ npm install --save-dev "git+ssh://git@github.com/MNSTRY/atelier.git#v0.2.0-alpha.11"
50
50
  ```
51
51
 
52
52
  Keep the `@mnstry/` scope — see the command-form note above for why the
@@ -2,8 +2,10 @@
2
2
 
3
3
  This document freezes what later work receives from the contract and
4
4
  feasibility track. It states what is proven, on what, and what is not. Nothing
5
- here enables a feature: a publisher exists under
6
- `src/projection/obsidian/publication/`, and no command or service calls it yet.
5
+ here enables a feature. The publisher under
6
+ `src/projection/obsidian/publication/` is called by the maintenance service
7
+ (`atelier obsidian service start`) and so by `atelier obsidian open`; see
8
+ [obsidian.md](obsidian.md).
7
9
 
8
10
  ## Registered shapes
9
11
 
@@ -292,9 +294,15 @@ in the newly started app is not: the app then takes its own
292
294
  external-modification merge, which can drop overlapping edits. The probe also
293
295
  cannot see an app on another machine that reaches the vault through a shared
294
296
  or synchronized folder, or an app packaged under another executable name. It
295
- recognises the app by the executable a process runs (`ps -A -o comm=`), never
297
+ recognises the app by the executable a process runs (`ps -A -o comm=`, or
298
+ `pid=,comm=` on Linux), never
296
299
  by its arguments; on Linux a system Electron process, which does not say which
297
- app it hosts, makes the reading unknown rather than absent.
300
+ app it hosts, makes the reading unknown rather than absent, and a process whose
301
+ name is not recognised is identified by the executable `/proc/<pid>/exe`
302
+ resolves to. A `ps` that does not answer within five seconds is unknown.
303
+ Before 0.2.0-alpha.10 no production reading was ever absent, because the probe
304
+ found Atelier's own service; from that release this path, and this window, are
305
+ live.
298
306
 
299
307
  ### Proven boundary
300
308
 
package/docs/obsidian.md CHANGED
@@ -303,7 +303,11 @@ usage text; `atelier obsidian --help` prints it.
303
303
  --adapter=obsidian-cli` prints a startup unit and installs nothing.
304
304
  4. Open a view: `atelier obsidian open --scope ID --adapter=obsidian-cli`.
305
305
  The command starts or reconnects maintenance, verifies the vault by reading
306
- it back, and asks the app (version 1.13.7 or later) to open it.
306
+ it back, and asks the app (version 1.13.7 or later) to open it. Quit
307
+ Obsidian before a view's first publication (on Linux, also any app that
308
+ runs on a system Electron): while a process that may be Obsidian runs, the
309
+ first publication stops, and `open` and `status` say so; see
310
+ [First publication while Obsidian is running](#first-publication-while-obsidian-is-running).
307
311
 
308
312
  `open` and `status` answer with a freshness state, not a promise. `current`
309
313
  means the vault is the present generation, verified by read-back, and the app
@@ -341,14 +345,31 @@ Publication is proven on macOS arm64 only and is refused on Windows; see
341
345
  ### First publication while Obsidian is running
342
346
 
343
347
  The publisher writes into a vault only when it can coordinate with every
344
- Obsidian that might have it open. An Obsidian that is running without this
345
- vault open cannot be coordinated with, so the first publication of a view is
346
- refused as `publisher-conflict` with reason `editor-uncoordinated`, and
347
- nothing is written. Quit Obsidian: the service then publishes the view
348
- directly, and `atelier obsidian open --scope ID --adapter=obsidian-cli` starts
349
- Obsidian on it. An Obsidian with no vault open at all answers its command line
350
- with "Vault not found." for every command, its version included; that is
351
- reported as reason `no-vault-open`, and the same remedy applies.
348
+ Obsidian that may hold it, or when the process table shows, positively, that
349
+ none runs. Otherwise it stops, and the view reports `publisher-conflict` with
350
+ reason `editor-uncoordinated`. That reason has several causes, and the report
351
+ does not say which: Obsidian runs without this vault open, or with it open but
352
+ its command line did not answer; the process table could not be read, or on
353
+ Linux shows an app that runs on a system Electron, which may be Obsidian; the
354
+ app's version was not checked when the publication began (it started just
355
+ then, or on macOS it is installed outside `/Applications`; see "Known
356
+ limits"); or an app started while the view was being published with the app
357
+ closed, which stops that publication partway. Before a first publication no
358
+ app has the vault open, so only the first way on applies: quit Obsidian (on
359
+ Linux, also any app that runs on a system Electron). The service then
360
+ publishes the view directly, and
361
+ `atelier obsidian open --scope ID --adapter=obsidian-cli` starts Obsidian on
362
+ it. `status` and `open` give that as the next step. Once a view
363
+ has been published, the last published vault stays in place and there is a
364
+ second way on: open it in the running app as it is with
365
+ `atelier obsidian open --allow-stale`, which waits, bounded, until the app
366
+ answers for it. The publication is retried automatically either way.
367
+
368
+ An Obsidian with no vault open at all answers its command line with "Vault not
369
+ found." for every command, its version included. That is reported as outcome
370
+ `app-version-unsupported` with reason `no-vault-open`, under `service.app` in
371
+ `status` and by `open`. Opening any vault in Obsidian resolves it, because the
372
+ version can then be read, and so does quitting Obsidian.
352
373
 
353
374
  ## Known limits
354
375
 
@@ -375,7 +396,20 @@ test reported as a pass.
375
396
  is admitted although the protocol depends on the view's undocumented
376
397
  `lastSavedData` field, and the protocol cases have not been re-run on any
377
398
  later release. See the "App capability floor" row of
378
- [obsidian-contract.md](obsidian-contract.md).
399
+ [obsidian-contract.md](obsidian-contract.md). An app that was not running
400
+ when an adapter was qualified had no version to check, and neither had an
401
+ app that was not found. Such an answer is never reused, and the adapter
402
+ built on it does not coordinate with any app it then finds running, and
403
+ asks it nothing. That publication stops as `publisher-conflict` with reason
404
+ `editor-uncoordinated`, and the next publication asks again and reads the
405
+ version. A view that stopped is tried again on its next change, or at the
406
+ next full reconciliation, every five minutes.
407
+ - Install location (macOS): the app is found only at
408
+ `/Applications/Obsidian.app`, and its command-line tool only inside it. An
409
+ Obsidian installed elsewhere, or a renamed bundle, is `app-missing`.
410
+ Nothing is published through it, publication waits while it runs (the
411
+ process probe still sees it), and `open` answers `app-missing` and cannot
412
+ start it. With it quit, the view is published on the path with no app.
379
413
  - No vault open: while Obsidian runs with no vault open, its command-line tool
380
414
  answers every command, `version` included, with `Vault not found.`. The
381
415
  version floor cannot be checked then, so the app does not qualify:
@@ -386,20 +420,47 @@ test reported as a pass.
386
420
  the version again on a later tick. `open` launches nothing while the app
387
421
  runs with no vault open, and after its own launch it waits, bounded, while
388
422
  the app is still opening the vault.
389
- - Detecting the app: the process table is read with `ps -A -o comm=`, and the
390
- app is recognised by the executable a process runs, never by its arguments,
391
- so a path argument that contains an `obsidian` directory (the maintenance
392
- service itself, anything under the private data root) is not the app. On
393
- macOS the executable `Obsidian` and the bundle's `Obsidian Helper`
394
- processes count, in any directory. On Linux a process named `obsidian`
395
- counts, which covers the .deb, snap, AppImage and Flatpak builds. A
396
- distribution that runs the app under a system Electron shows only
397
- `electron`: the table is then `unknown`, which is treated as a running app,
398
- so publication through the app-free path waits until no such process runs.
399
- The command-line tool (`obsidian-cli`) is a client of the app and is not
400
- counted. A process renamed by its packager, an app on another machine and an
401
- app that starts after a reading are not seen; see "The path with no app" in
402
- [obsidian-contract.md](obsidian-contract.md).
423
+ - Detecting the app: the process table is read with `ps -A -o comm=`
424
+ (`pid=,comm=` on Linux), and the app is recognised by the executable a
425
+ process runs, never by its arguments, so a path argument that contains an
426
+ `obsidian` directory (the maintenance service itself, anything under the
427
+ private data root) is not the app. A `ps` that has not answered within five
428
+ seconds is killed and the reading is `unknown`, which is treated as a
429
+ running app.
430
+ - macOS: the executable `Obsidian` and the bundle's `Obsidian Helper`
431
+ processes count, in any directory. An app started through a differently
432
+ named link shows that name for its main process; its `Obsidian Helper`
433
+ processes still count.
434
+ - Linux: a process named `obsidian` counts, which covers the .deb, snap,
435
+ AppImage and Flatpak builds. Linux names a process after the path it was
436
+ started through, so for a name that is not recognised the probe reads
437
+ the executable `/proc/<pid>/exe` resolves to: an app started through a
438
+ link or launcher named `obs`, or a child started through
439
+ `/proc/self/exe` whose executable is readable, still counts. A process
440
+ whose executable this user may not read (another user's, or one of this
441
+ user's that hides it from tracing, such as ssh-agent) or that exited
442
+ meanwhile is judged by its name alone. Any other read failure, or a
443
+ table in which no executable resolves at all, is `unknown`. A
444
+ distribution that runs the app under a system Electron shows only
445
+ `electron`: the table is then `unknown`, so publication through the
446
+ app-free path waits until no such process runs.
447
+ - The command-line tool (`obsidian-cli`) is a client of the app and is not
448
+ counted. An app packaged under another executable name, an app on
449
+ another machine and an app that starts after a reading are not seen; see
450
+ "The path with no app" in [obsidian-contract.md](obsidian-contract.md).
451
+ - The app-free path is live: before 0.2.0-alpha.10 the probe always found
452
+ Atelier's own service, so publication never took the path with no app.
453
+ From 0.2.0-alpha.10 it does, whenever the process table shows, positively,
454
+ that no Obsidian runs. It applies only then. The table is read at path
455
+ selection, again immediately before the first note, and again whenever two
456
+ seconds have passed. An app that starts between two readings is not seen
457
+ for at most two seconds plus one note's publication, and in that window a
458
+ note is exchanged with no editor check. Bytes on disk stay protected by the
459
+ on-disk comparison and the exchange. An unsaved buffer in the newly started
460
+ app does not: the app applies its own external-modification merge, which
461
+ can drop overlapping edits. Once a reading shows an app, every remaining
462
+ note refuses. To avoid the window, do not start Obsidian while a view is
463
+ being published with the app closed.
403
464
  - CRLF sources: the editor normalizes line endings when it saves, so a body
404
465
  edit made in the vault to a note whose source uses CRLF is more than a body
405
466
  replacement to the edit lens. It is preserved and becomes a proposal; it is
package/docs/upgrade.md CHANGED
@@ -9,6 +9,15 @@ workspace between Atelier package releases. The flow is local-only: it does not
9
9
  provision repositories, contact a Git host, mutate the MNSTRY runtime, or write
10
10
  through a browser view.
11
11
 
12
+ ## Upgrading to 0.2.0-alpha.11
13
+
14
+ This release tightens the Obsidian projection's app checks after independent
15
+ review of 0.2.0-alpha.10: Atelier never coordinates with an Obsidian whose version it
16
+ has not checked, the advice for a refused publication is true for every cause
17
+ and appears in `status` as well as `open`, Linux resolves an app started
18
+ through a differently named launcher, and the process check has a timeout.
19
+ No project or machine setting needs to change.
20
+
12
21
  ## Upgrading to 0.2.0-alpha.10
13
22
 
14
23
  This release fixes the first activation of the Obsidian projection. In
@@ -176,7 +185,7 @@ For registry installs, pin the exact version and record the resolved version
176
185
  in the lockfile:
177
186
 
178
187
  ```bash
179
- npm install --save-dev @mnstry/atelier@0.2.0-alpha.10
188
+ npm install --save-dev @mnstry/atelier@0.2.0-alpha.11
180
189
  npx mnstry-atelier lock write --project ./atelier.project.json
181
190
  ```
182
191
 
@@ -184,7 +193,7 @@ For Git installs, pin the release tag rather than a branch, so the lock file
184
193
  records exactly what was reviewed:
185
194
 
186
195
  ```bash
187
- npm install --save-dev "git+https://github.com/MNSTRY/atelier.git#v0.2.0-alpha.10"
196
+ npm install --save-dev "git+https://github.com/MNSTRY/atelier.git#v0.2.0-alpha.11"
188
197
  npx mnstry-atelier lock write --project ./atelier.project.json
189
198
  ```
190
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnstry/atelier",
3
- "version": "0.2.0-alpha.10",
3
+ "version": "0.2.0-alpha.11",
4
4
  "private": false,
5
5
  "description": "Local toolkit that turns a Git repository into an ontology-governed knowledge graph, enforcement layer, and runtime for people, agents, and tools. No telemetry or required hosted account; optional host-bound vault adapters.",
6
6
  "keywords": [
@@ -1,4 +1,5 @@
1
1
  import { execFile, execFileSync } from 'node:child_process'
2
+ import { readlinkSync } from 'node:fs'
2
3
  import { POLICY_SETTINGS_PATH, buildEvalCode, createInProcessHost, runInProcess, validatePayload } from './bridge-script.mjs'
3
4
 
4
5
  // Editor coordination adapter.
@@ -26,9 +27,16 @@ export class TransportTimeout extends Error {
26
27
 
27
28
  const READ_RETRIES = 2
28
29
 
29
- export function createEditorAdapter({ call, processProbe, kind = 'custom' }) {
30
+ // `qualification`, when given, is the app qualification this adapter was
31
+ // constructed under (`qualifyApp`). An adapter qualified while no app ran was
32
+ // never shown a version, so it never coordinates with an app found running
33
+ // later, whose version nobody checked: it is `uncoordinated` until an adapter
34
+ // qualified with a checked version replaces it. Without a qualification the
35
+ // adapter coordinates as before.
36
+ export function createEditorAdapter({ call, processProbe, kind = 'custom', qualification }) {
30
37
  if (typeof call !== 'function') throw new TypeError('createEditorAdapter needs a call function')
31
38
  if (typeof processProbe !== 'function') throw new TypeError('createEditorAdapter needs a processProbe function')
39
+ const versionUnchecked = qualification !== undefined && qualification?.versionChecked !== true
32
40
  const retries = []
33
41
  let tail = Promise.resolve()
34
42
  const serialized = (operation) => {
@@ -62,11 +70,13 @@ export function createEditorAdapter({ call, processProbe, kind = 'custom' }) {
62
70
  }),
63
71
  // Path selection. `absent` is returned only when the process probe says,
64
72
  // positively, that no Obsidian is running. A running or unknowable app
65
- // that does not answer for this exact vault is `uncoordinated`.
73
+ // that does not answer for this exact vault is `uncoordinated`, and so is
74
+ // any app while this adapter's qualification checked no version.
66
75
  probe: ({ vaultRoot }) => serialized(async () => {
67
76
  let processes
68
77
  try { processes = await processProbe() } catch { processes = 'unknown' }
69
78
  if (processes === 'absent') return { state: 'absent', reason: 'no Obsidian process is running' }
79
+ if (versionUnchecked) return { state: 'uncoordinated', reason: 'app-version-unchecked: an Obsidian process may be running, and this adapter was qualified while none ran, so its version was never checked' }
70
80
  try {
71
81
  const reply = await readOnly({ op: 'inspect', vaultRoot, path: POLICY_SETTINGS_PATH })
72
82
  // `path-unsafe` for the probe path still proves the app answered for this vault; the settings unit reports the path.
@@ -127,13 +137,48 @@ export function createObsidianCliCall({ cliPath = defaultCliPath(), env = proces
127
137
  // directory: a moved or renamed bundle still counts, and so does a
128
138
  // process whose path ps cannot read and names by its short name.
129
139
  // Linux `obsidian`, the executable of the .deb, snap, AppImage and Flatpak
130
- // builds. A distribution that runs the app under a system Electron
131
- // shows only `electron`, and which app it hosts is not known.
140
+ // builds, by name or, for a name that is not recognised, by the
141
+ // executable it resolves to. A distribution that runs the app under
142
+ // a system Electron shows only `electron`, and which app it hosts is
143
+ // not known.
132
144
  //
133
145
  // The command-line tool is a client of the app, not the app: counting it would
134
146
  // make every call this package makes through it look like a running app.
135
147
  const APP_EXECUTABLE = Object.freeze({ darwin: /^obsidian(?: helper\b.*)?$/i, linux: /^obsidian$/i })
136
148
  const ELECTRON_HOST = /^\.?electron(?:\d+|-wrap.*)?$/i
149
+ // A `ps` that has not answered by then is killed, and the reading is unknown.
150
+ const PS_TIMEOUT_MS = 5000
151
+ // A process that exited since the table was read, or whose executable this
152
+ // user may not read: another user's process, or one of this user's that hides
153
+ // it from tracing (ssh-agent, a sandboxed renderer). Such a process is judged
154
+ // by its name alone.
155
+ const EXECUTABLE_UNREADABLE = new Set(['ENOENT', 'ESRCH', 'EACCES', 'EPERM'])
156
+
157
+ const baseName = (text) => text.slice(text.lastIndexOf('/') + 1)
158
+ const readProcessExecutable = (pid) => readlinkSync(`/proc/${pid}/exe`)
159
+
160
+ // Linux names a process after the path it was started through, so an app
161
+ // started through a differently named link or launcher shows another name.
162
+ // The executable `/proc/<pid>/exe` resolves to does not change with the name.
163
+ // Any failure other than the expected ones is unknown, and so is a table in
164
+ // which no executable could be resolved at all: without `/proc`, nothing
165
+ // below the names can be established.
166
+ function resolveLinuxExecutables(rows, readExe) {
167
+ let resolved = 0
168
+ let unknown = false
169
+ for (const { pid } of rows) {
170
+ let target
171
+ try { target = String(readExe(pid)) } catch (error) {
172
+ if (!EXECUTABLE_UNREADABLE.has(error?.code)) unknown = true
173
+ continue
174
+ }
175
+ resolved += 1
176
+ const name = baseName(target.replace(/ \(deleted\)$/, ''))
177
+ if (APP_EXECUTABLE.linux.test(name)) return 'running'
178
+ if (ELECTRON_HOST.test(name)) unknown = true
179
+ }
180
+ return unknown || resolved === 0 ? 'unknown' : 'absent'
181
+ }
137
182
 
138
183
  // Default process probe: 'absent' only when the process table was read and
139
184
  // holds no Obsidian. Limits: it sees this machine's processes as this user
@@ -141,20 +186,27 @@ const ELECTRON_HOST = /^\.?electron(?:\d+|-wrap.*)?$/i
141
186
  // vault through a shared or synchronized folder, an app packaged under another
142
187
  // executable name, or an app that starts after the probe. Anything it cannot
143
188
  // establish is 'unknown', which the adapter treats as a running app: an empty
144
- // table, a failed read, and on Linux a system Electron process.
145
- export function defaultObsidianProcessProbe({ platform = process.platform, run = execFileSync } = {}) {
189
+ // table, a failed or slow read, and on Linux a system Electron process or an
190
+ // executable that cannot be resolved for an unexpected reason.
191
+ export function defaultObsidianProcessProbe({ platform = process.platform, run = execFileSync, readExe = readProcessExecutable } = {}) {
146
192
  if (!Object.hasOwn(APP_EXECUTABLE, platform)) return 'unknown'
193
+ const linux = platform === 'linux'
147
194
  try {
148
- const table = run('/bin/ps', ['-A', '-o', 'comm='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 })
195
+ const table = run('/bin/ps', ['-A', '-o', linux ? 'pid=,comm=' : 'comm='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024, timeout: PS_TIMEOUT_MS, killSignal: 'SIGKILL' })
149
196
  if (typeof table !== 'string' || table.trim() === '') return 'unknown'
150
- const names = table.split('\n').map((line) => line.trim()).filter((line) => line !== '').map((line) => line.slice(line.lastIndexOf('/') + 1))
151
- if (names.some((name) => APP_EXECUTABLE[platform].test(name))) return 'running'
152
- return platform === 'linux' && names.some((name) => ELECTRON_HOST.test(name)) ? 'unknown' : 'absent'
197
+ const rows = table.split('\n').map((line) => line.trim()).filter((line) => line !== '').map((line) => {
198
+ const match = linux ? /^(\d+)\s+(.*)$/.exec(line) : null
199
+ return match ? { pid: Number(match[1]), comm: match[2] } : { pid: null, comm: line }
200
+ })
201
+ if (rows.some((row) => APP_EXECUTABLE[platform].test(baseName(row.comm)))) return 'running'
202
+ if (!linux) return 'absent'
203
+ if (rows.some((row) => row.pid === null || ELECTRON_HOST.test(baseName(row.comm)))) return 'unknown'
204
+ return resolveLinuxExecutables(rows, readExe)
153
205
  } catch {
154
206
  return 'unknown'
155
207
  }
156
208
  }
157
209
 
158
- export function createObsidianCliAdapter({ cliPath, env, timeoutMs, processProbe = () => defaultObsidianProcessProbe() } = {}) {
159
- return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs }), processProbe, kind: 'obsidian-cli' })
210
+ export function createObsidianCliAdapter({ cliPath, env, timeoutMs, processProbe = () => defaultObsidianProcessProbe(), qualification } = {}) {
211
+ return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs }), processProbe, kind: 'obsidian-cli', qualification })
160
212
  }
@@ -103,13 +103,22 @@ export async function inspectApp(appProbe) {
103
103
  // The adapter factory of a service that reaches a real app. The editor adapter
104
104
  // is constructed only after the app qualified; otherwise the factory refuses,
105
105
  // typed, and the engine records that reason and publishes nothing. The answer
106
- // is remembered briefly so a tick over many views asks once.
106
+ // is remembered briefly so a tick over many views asks once, unless it let an
107
+ // adapter through without a checked version. `createAdapter` receives the
108
+ // qualification with its input: an app that was not running qualified without
109
+ // a version, and an adapter built on that answer must not coordinate with an
110
+ // app started since (see createEditorAdapter).
107
111
  export function createQualifiedAdapterFactory({ appProbe, createAdapter, floor = MINIMUM_APP_VERSION, maxAgeMs = 10_000, now = () => Date.now() } = {}) {
108
112
  if (typeof appProbe?.inspectSync !== 'function') throw new TypeError('the qualified adapter factory needs an appProbe with inspectSync()')
109
113
  if (typeof createAdapter !== 'function') throw new TypeError('the qualified adapter factory needs createAdapter')
110
114
  let last = null
115
+ // An answer that lets an adapter through without a checked version (no app
116
+ // was running, or none was found) is never reused: an app started since is
117
+ // asked for its version by the next call, not refused on the old answer. An
118
+ // answer that checked a version, or that refuses, is reused for `maxAgeMs`.
119
+ const reusable = (result) => result.versionChecked === true || (result.outcome !== 'qualified' && result.outcome !== 'app-missing')
111
120
  const qualification = () => {
112
- if (last === null || now() - last.at > maxAgeMs) {
121
+ if (last === null || !reusable(last.result) || now() - last.at > maxAgeMs) {
113
122
  let observation
114
123
  try { observation = appProbe.inspectSync() } catch { observation = null }
115
124
  last = { at: now(), result: qualifyApp(observation, { requireVersion: false, floor }) }
@@ -120,7 +129,7 @@ export function createQualifiedAdapterFactory({ appProbe, createAdapter, floor =
120
129
  const result = qualification()
121
130
  // No app at all is not an unqualified app: the publisher's own path needs none, and its adapter finds no process.
122
131
  if (result.outcome !== 'qualified' && result.outcome !== 'app-missing') refuse(result.outcome, 'the installed Obsidian does not qualify; nothing is published through it', { reason: result.reason, floor: result.floor, version: result.version })
123
- return createAdapter(input)
132
+ return createAdapter({ ...input, qualification: result })
124
133
  }
125
134
  factory.qualification = qualification
126
135
  // What was last learned, without asking again: for a status answer.
@@ -11,8 +11,10 @@ import { readVersionAnswer } from './app-capability.mjs'
11
11
  // This module is imported by exactly two places, both real command-line
12
12
  // entries and both behind an explicit `--adapter=obsidian-cli`: the service
13
13
  // entry (service-main.mjs) and the command entry (src/commands/obsidian.mjs,
14
- // `production: true`). No test imports it, and importing it leaves a trace
15
- // below that the test suite asserts is absent.
14
+ // `production: true`). No test imports it in-process, and importing it leaves
15
+ // a trace below that the test suite asserts is absent. One test imports it in
16
+ // a child process, with a stand-in for the command-line tool, to read the
17
+ // version answers it parses.
16
18
  //
17
19
  // Status: written against the documented command-line interface and NOT yet
18
20
  // exercised against a running app. The acceptance work qualifies it on an
@@ -58,18 +58,35 @@ export function describeOutcome(code) {
58
58
  // A reason whose next step is not its outcome's. With no vault open the app's
59
59
  // command line answers nothing, its version included, so the version floor
60
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.
61
+ // `editor-uncoordinated` is the publisher stopping because an Obsidian that may
62
+ // hold this vault could not be coordinated with. There is no other publisher to
63
+ // wait for, and the cause is not known here: the app runs without this vault,
64
+ // or with it but its command line did not answer, or the process table was
65
+ // unknown (on Linux, any app that runs on a system Electron), or an app started
66
+ // while the view was published with the app closed. Every cause clears once no
67
+ // such process runs, and a view that was published before can instead be
68
+ // opened in the app as it is, which then answers for it.
69
+ const UNCOORDINATED = 'an Obsidian that may hold this vault could not be coordinated with, so publication stopped'
70
+ const QUIT = 'quit Obsidian (on Linux, also any app that runs on a system Electron)'
65
71
  export const REASON_NEXT = Object.freeze({
66
72
  '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',
73
+ 'editor-uncoordinated': `${UNCOORDINATED}; ${QUIT}, or open this view in it with \`atelier obsidian open --allow-stale\`; it is retried automatically`,
68
74
  })
69
75
 
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]
76
+ // Before a first publication there is no vault to open in the app: the view is
77
+ // published only while no such process runs.
78
+ const FIRST_PUBLICATION_NEXT = Object.freeze({
79
+ 'editor-uncoordinated': `${UNCOORDINATED}; ${QUIT} so the view is published, then \`atelier obsidian open\` starts Obsidian on it`,
80
+ })
81
+
82
+ // The next step for an outcome and its reason; null for a code that is not an
83
+ // opening outcome. `published` says whether a published generation of the view
84
+ // reads back.
85
+ export function nextStep(outcome, reason, { published = true } = {}) {
86
+ if (typeof reason === 'string') {
87
+ if (!published && Object.hasOwn(FIRST_PUBLICATION_NEXT, reason)) return FIRST_PUBLICATION_NEXT[reason]
88
+ if (Object.hasOwn(REASON_NEXT, reason)) return REASON_NEXT[reason]
89
+ }
73
90
  return Object.hasOwn(OPENING_OUTCOMES, outcome) ? OPENING_OUTCOMES[outcome].next : null
74
91
  }
75
92
 
@@ -147,7 +164,7 @@ export function scopeReport({ workspace, scopeId, repositoryRoots, serviceState,
147
164
  } else if (serviceState !== 'healthy') { outcome = 'stale-readable'; reason = serviceState === 'busy' ? 'maintenance-busy-not-rechecked' : 'maintenance-not-running' }
148
165
  else { outcome = 'current'; reason = entry.reason }
149
166
  return {
150
- scopeId, ...describeOutcome(outcome), reason,
167
+ scopeId, ...describeOutcome(outcome), next: nextStep(outcome, reason, { published: verification.readable }), reason,
151
168
  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
169
  readBack: { readable: verification.readable, reason: verification.reason, generationId: verification.generationId, intact: verification.intact, noteCount: verification.noteCount, differing: verification.differing, missing: verification.missing },
153
170
  pendingEdits: pendingSummary(stateStore, scopeId, applyAvailable),
@@ -184,7 +201,7 @@ export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIM
184
201
  const project = loadProject()
185
202
  const enablement = readObsidianEnablement(project)
186
203
  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 })
204
+ const finish = (outcome, extra = {}) => ({ ok: outcome === 'current', ...describeOutcome(outcome), next: nextStep(outcome, extra.reason, { published: extra.readBack?.readable !== false }), launched: false, ...extra })
188
205
  if (enablement.state === 'disabled') return finish('disabled', { reason: enablement.reason, scopeId: requestedScope ?? null })
189
206
  const scopeId = resolveScope(enablement, requestedScope)
190
207
  const lifecycle = { loadProject, dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
@@ -23,12 +23,14 @@ import { runMaintenanceService } from './service.mjs'
23
23
  // selected them. The editor adapter is constructed only for an app that meets
24
24
  // the minimum version; below it, or when the version cannot be read, the
25
25
  // factory refuses, the engine records that reason and nothing is published.
26
+ // An adapter qualified while no app ran carries that qualification and does
27
+ // not coordinate with an app that starts before the next qualification.
26
28
  const ADAPTERS = Object.freeze({
27
29
  'obsidian-cli': async () => {
28
30
  const [{ createObsidianCliAdapter }, { createProductionAppProbe }, { createQualifiedAdapterFactory }] = await Promise.all([
29
31
  import('../../projection/obsidian/publication/transport.mjs'), import('./app-production-seams.mjs'), import('./app-capability.mjs'),
30
32
  ])
31
- const adapterFactory = createQualifiedAdapterFactory({ appProbe: createProductionAppProbe(), createAdapter: () => createObsidianCliAdapter() })
33
+ const adapterFactory = createQualifiedAdapterFactory({ appProbe: createProductionAppProbe(), createAdapter: ({ qualification }) => createObsidianCliAdapter({ qualification }) })
32
34
  return { adapterFactory, appStatus: () => { const known = adapterFactory.lastQualification(); return known === null ? null : { outcome: known.outcome, reason: known.reason, version: known.version, floor: known.floor } } }
33
35
  },
34
36
  })
@@ -6,7 +6,7 @@
6
6
  "version": "v1",
7
7
  "digest": "sha256:0eff795765e0bbd4c2510acda2e492201c23bc870df6c354eef623484643ab11",
8
8
  "compatibleRootVersions": [
9
- "0.2.0-alpha.10"
9
+ "0.2.0-alpha.11"
10
10
  ],
11
11
  "status": "active"
12
12
  }