@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 +41 -0
- package/README.md +3 -3
- package/docs/install.md +3 -3
- package/docs/obsidian-contract.md +12 -4
- package/docs/obsidian.md +85 -24
- package/docs/upgrade.md +11 -2
- package/package.json +1 -1
- package/src/projection/obsidian/publication/transport.mjs +64 -12
- package/src/runtime/obsidian/app-capability.mjs +12 -3
- package/src/runtime/obsidian/app-production-seams.mjs +4 -2
- package/src/runtime/obsidian/opening.mjs +27 -10
- package/src/runtime/obsidian/service-main.mjs +3 -1
- package/templates/external-project-workspace/pack-lifecycle.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
6
|
-
`src/projection/obsidian/publication
|
|
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
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
390
|
-
app is recognised by the executable a
|
|
391
|
-
so a path argument that contains an
|
|
392
|
-
service itself, anything under the
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
131
|
-
//
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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':
|
|
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
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
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
|
})
|