@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,7 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { AtelierDiagnosticError } from '../../project/config.mjs'
4
- import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
4
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../../projection/obsidian/contracts.mjs'
5
5
  import { PROTOCOL_ID } from '../../projection/obsidian/publication/bridge-script.mjs'
6
6
  import { PublicationRefusal } from '../../projection/obsidian/recovery/store.mjs'
7
7
  import { canonicalJson, compareText, isoTime } from './documents.mjs'
@@ -13,7 +13,7 @@ import {
13
13
  readMachineSettings, resolveDataRoot, workspaceStateRoot, writeMachineSettings,
14
14
  } from './machine-settings.mjs'
15
15
  import { configKey, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
16
- import { dispatchAutomaticApply, heldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
16
+ import { dispatchAutomaticApply, heldPaths, layoutHeldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
17
17
  import { DEFAULT_ELIGIBILITY, createProductionSeams } from './pipeline.mjs'
18
18
  import { ENGINE_LOCK_DIRECTORY, acquirePrivateGenerationLock, createAbandonmentProof } from './private-lock.mjs'
19
19
  import { probeHealth } from './service-client.mjs'
@@ -38,6 +38,18 @@ import { createNullWatcherFactory } from './watchers.mjs'
38
38
  // prepareView -> publishView, unless that would replace a held note
39
39
  // 7. persist the freshness of every view
40
40
  //
41
+ // A view is prepared and published once more, whatever its state, on the
42
+ // next tick after `requestPreparation(scopeId)`: a one-shot request per view
43
+ // the project declared at the last tick (at most 64 wait before the first),
44
+ // which the service makes for a tick requested for that view over its
45
+ // listener (`open` requests its own). A view whose last publication did not
46
+ // settle (stale, updating, or refused as a publisher conflict) is also tried
47
+ // again without a request: at the full reconciliation; as soon as the app
48
+ // looks different from when it was last tried (`observeApp`, when given: an
49
+ // app that quit, started, or opened or closed a vault); and otherwise after a
50
+ // delay that doubles per attempt from `publicationRetryMs` up to the full
51
+ // reconciliation interval.
52
+ //
41
53
  // From the moment a workspace is resolved until the tick ends, the tick holds
42
54
  // the workspace's private engine lock, so two engines (a service and a
43
55
  // foreground command, or two services) never tick one workspace together. An
@@ -50,12 +62,13 @@ import { createNullWatcherFactory } from './watchers.mjs'
50
62
 
51
63
  export const DEFAULT_FULL_RECONCILIATION_INTERVAL_MS = 5 * 60 * 1000
52
64
  export const DEFAULT_RETRY_INTERVAL_MS = 60 * 1000
65
+ export const DEFAULT_PUBLICATION_RETRY_MS = 30 * 1000
53
66
  export const DEFAULT_LATE_WRITER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
54
67
  export const LATE_WRITER_EVERY_TICK_WINDOW_MS = 60 * 60 * 1000
55
68
 
56
69
  // A refusal that means "someone else is publishing or editing here" rather
57
70
  // than "this machine cannot publish".
58
- const CONFLICT_REFUSALS = new Set(['publication-in-progress', 'generation-mismatch', 'editor-uncoordinated', 'state-mismatch'])
71
+ const CONFLICT_REFUSALS = new Set(['publication-in-progress', 'generation-mismatch', 'editor-uncoordinated', 'vault-open-in-several-windows', 'state-mismatch'])
59
72
  const EDIT_OUTCOMES = new Set(['disk-changed', 'editor-edit'])
60
73
  const RETRIED_STATES = new Set(['stale', 'updating', 'publisher-conflict'])
61
74
  // A file changed or went away under a tick: everything is hashed again on the next one.
@@ -76,6 +89,15 @@ export const ENGINE_PRIMITIVES = Object.freeze({
76
89
  dispatchAllowed: (maintenanceMode) => maintenanceMode === 'automatic',
77
90
  // Every file is hashed, whatever its stat says, at least this often.
78
91
  isFullReconciliationDue: ({ nowMs, lastFullMs, intervalMs }) => lastFullMs === null || nowMs - lastFullMs >= intervalMs,
92
+ // A view whose last attempt did not settle, between full reconciliations: again once a delay has passed that doubles
93
+ // per attempt in a row, from `retryMs` up to `maxMs`, and at once when the app looks different from when it was last
94
+ // tried. `appState()` answers what it looks like now, or null when that is not known.
95
+ isRetryDue({ nowMs, unsettled, appState, retryMs, maxMs }) {
96
+ if (unsettled === undefined) return false
97
+ if (nowMs - unsettled.lastAttemptMs >= Math.min(maxMs, retryMs * 2 ** Math.min(unsettled.attempts - 1, 20))) return true
98
+ const now = appState()
99
+ return now !== null && now !== unsettled.appState
100
+ },
79
101
  // The embedded assets observed beside the walk: those the last built graph lets a view copy, and no withheld one.
80
102
  observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
81
103
  // One engine per workspace at a time, across processes.
@@ -106,6 +128,23 @@ export const ENGINE_PRIMITIVES = Object.freeze({
106
128
  const isTypedRefusal = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
107
129
  const digestOfJson = (value) => sha256Digest(Buffer.from(canonicalJson(value)))
108
130
 
131
+ // An editor adapter that `build` makes, synchronously or not, the first time the publisher calls it; a refusal of
132
+ // `build` is thrown from that call, and `refusal()` answers it afterwards.
133
+ function builtOnFirstUse(build) {
134
+ let adapter = null
135
+ let refused = null
136
+ const built = () => (adapter ??= Promise.resolve().then(build).catch((error) => { refused = error; throw error }))
137
+ return {
138
+ refusal: () => refused,
139
+ probe: async (input) => (await built()).probe(input),
140
+ inspect: async (payload) => (await built()).inspect(payload),
141
+ collect: async (payload) => (await built()).collect(payload),
142
+ publish: async (payload) => (await built()).publish(payload),
143
+ }
144
+ }
145
+ // Views a tick may be asked to prepare again, at most, before it runs.
146
+ const MAX_PREPARATION_REQUESTS = 64
147
+
109
148
  function journalTime(journalId) {
110
149
  const match = /^journal-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})-/.exec(journalId)
111
150
  return match ? Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +match[6], +match[7]) : null
@@ -129,6 +168,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
129
168
  loadProject, dataRoot, adapterFactory, clock,
130
169
  watcherFactory = createNullWatcherFactory(), extensions = createMaintenanceExtensions(), eligibility = DEFAULT_ELIGIBILITY,
131
170
  fullReconciliationIntervalMs = DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, lateWriterWindowMs = DEFAULT_LATE_WRITER_WINDOW_MS,
171
+ publicationRetryMs = DEFAULT_PUBLICATION_RETRY_MS, observeApp = null,
132
172
  quietPeriodMs, lstat = fs.lstatSync, randomBytes, env = process.env, platform = process.platform,
133
173
  // A service names where it answers health, so a lock it leaves behind can be proven abandoned.
134
174
  lockOwner = null, lockProbe = probeHealth,
@@ -163,6 +203,16 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
163
203
  let watching = { signature: null, handle: null }
164
204
  let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
165
205
  let heldLock = null
206
+ // Views somebody asked to have prepared and published once more, at the next tick.
207
+ const preparationRequests = new Set()
208
+ // The views the project declared at the last tick; null before the first.
209
+ let declaredScopes = null
210
+ // Views whose last attempt did not settle, by scope: { attempts in a row, lastAttemptMs, appState then }. Kept in
211
+ // memory only: a new engine attempts every view on its first tick.
212
+ const unsettled = new Map()
213
+ // Views kept current although publishing their committed generation again (a drifted plugin file) needed an app
214
+ // that did not qualify: tried again on the same schedule as an unsettled view, and forgotten once one settles.
215
+ const waitingForApp = new Map()
166
216
  const proveAbandoned = createAbandonmentProof({ probe: lockProbe })
167
217
 
168
218
  async function takeLock(workspaceRoot, workspaceId) {
@@ -246,6 +296,17 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
246
296
  async function runTick() {
247
297
  const now = isoTime(clock)
248
298
  const nowMs = Date.parse(now)
299
+ // A request that arrives while this tick runs belongs to the next one.
300
+ const requested = new Set(preparationRequests)
301
+ preparationRequests.clear()
302
+ // What the app looked like, asked at most once per tick and only when a decision needs it.
303
+ let appState
304
+ const appNow = () => {
305
+ if (appState === undefined) { try { appState = typeof observeApp === 'function' ? observeApp() ?? null : null } catch { appState = null } }
306
+ return appState
307
+ }
308
+ // An adapter built on a tick somebody asked for asks the app again instead of reusing an answer from before it changed.
309
+ if (requested.size > 0) try { adapterFactory.forget?.() } catch { /* a factory that remembers nothing */ }
249
310
  const full = firstTick || forceFull || rules.isFullReconciliationDue({ nowMs, lastFullMs, intervalMs: fullReconciliationIntervalMs })
250
311
  const changes = []
251
312
  // The hints collected so far belong to this tick; one that arrives while it runs belongs to the next.
@@ -271,6 +332,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
271
332
  const enablement = readObsidianEnablement(project)
272
333
  if (enablement.state === 'disabled') {
273
334
  stopWatching()
335
+ declaredScopes = new Set()
274
336
  await persistOutcome({ enablement: 'disabled', state: 'disabled', reason: enablement.reason, now, scopeIds: enablement.scopes.map((scope) => scope.scopeId) })
275
337
  semantic = null
276
338
  firstTick = true
@@ -305,10 +367,12 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
305
367
  const pending = stateStore.readPendingEdits()
306
368
  let edits = pending.edits
307
369
  const basesOf = new Map()
370
+ const earlierLayout = new Set()
308
371
  for (const { scope, store } of scopes) {
309
372
  const { manifest, bases } = trustedNoteBases(store)
310
373
  basesOf.set(scope.scopeId, bases)
311
374
  if (!manifest) continue
375
+ if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
312
376
  const vault = reconcile({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat })
313
377
  const digestOf = (notePath) => index.get(vaultKey(scope.scopeId, notePath))?.digest ?? null
314
378
  const observed = observeVaultEdits({ store, workspaceId, scopeId: scope.scopeId, manifest, bases, digestOf, edits, now, preserve: rules.preserve })
@@ -391,6 +455,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
391
455
  if (full) { lastFullMs = nowMs; forceFull = false }
392
456
 
393
457
  // 7. Which views are invalid.
458
+ declaredScopes = new Set(scopes.map(({ scope }) => scope.scopeId))
394
459
  const previous = readPreviousFreshness(stateStore)
395
460
  const entries = new Map()
396
461
  const unseen = new Set()
@@ -401,10 +466,17 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
401
466
  }
402
467
  const attempt = new Map()
403
468
  const invalidate = (scopeId, changeClass) => attempt.set(scopeId, new Set([...(attempt.get(scopeId) ?? []), ...(changeClass ? [changeClass] : [])]))
469
+ // The notes that keep a view in the vault layout they were published in (see layoutHeldPaths).
470
+ const layoutHeldOf = (scopeId) => layoutHeldPaths(edits, scopeId, now)
404
471
  for (const { scope } of scopes) {
405
472
  const entry = entries.get(scope.scopeId)
406
- // A view that did not settle is tried again at the full reconciliation cadence, not on every tick.
407
- if (firstTick || unseen.has(scope.scopeId) || (full && RETRIED_STATES.has(entry.state))) invalidate(scope.scopeId, null)
473
+ // A view that did not settle is tried again, not on every tick: see the head of this file.
474
+ const retried = (RETRIED_STATES.has(entry.state)
475
+ && (full || rules.isRetryDue({ nowMs, unsettled: unsettled.get(scope.scopeId), appState: appNow, retryMs: publicationRetryMs, maxMs: fullReconciliationIntervalMs })))
476
+ || (waitingForApp.has(scope.scopeId) && rules.isRetryDue({ nowMs, unsettled: waitingForApp.get(scope.scopeId), appState: appNow, retryMs: publicationRetryMs, maxMs: fullReconciliationIntervalMs }))
477
+ if (firstTick || unseen.has(scope.scopeId) || requested.has(scope.scopeId) || retried) invalidate(scope.scopeId, null)
478
+ // A settled view still in the earlier vault layout is laid out again at the first tick where no note holds it.
479
+ if (earlierLayout.has(scope.scopeId) && entry.state === 'current' && layoutHeldOf(scope.scopeId).length === 0) invalidate(scope.scopeId, null)
408
480
  }
409
481
  for (const change of changes) for (const { scope } of scopes) if (change.scopeId === undefined || change.scopeId === scope.scopeId) invalidate(scope.scopeId, change.changeClass)
410
482
 
@@ -440,16 +512,23 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
440
512
  for (const { scope, store } of built ? scopes.filter((item) => attempt.has(item.scope.scopeId)) : []) {
441
513
  const { scopeId } = scope
442
514
  const entry = entries.get(scopeId)
443
- const settle = (state, reason, extra = {}) => entries.set(scopeId, { ...entry, ...extra, state, reason, verified: extra.verified === true, checkedAt: now })
515
+ // What this attempt found worth naming: notes and rules (see "Notes that were laid out anyway" in docs/obsidian-contract.md).
516
+ let diagnostics = []
517
+ const settle = (state, reason, extra = {}) => {
518
+ const { diagnostics: _earlier, ...rest } = { ...entry, ...extra, state, reason, verified: extra.verified === true, checkedAt: now }
519
+ entries.set(scopeId, diagnostics.length > 0 ? { ...rest, diagnostics } : rest)
520
+ }
444
521
  try {
522
+ const held = heldPaths(edits, scopeId)
445
523
  const prepared = seams.prepareView({
446
524
  snapshot: built.snapshot, profile: built.profile, scope, persistentPathRegistry: stateStore.readPathRegistry(), priorManifest: store.readCurrentManifest(),
447
525
  existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), cache: preparationCacheFor(scopeId),
526
+ heldNotePaths: held, layoutHeldNotePaths: layoutHeldOf(scopeId), viewScopeIds: scopes.map((item) => item.scope.scopeId),
448
527
  })
449
528
  stateStore.writePathRegistry(prepared.persistentPathRegistry)
529
+ diagnostics = (prepared.manifest.ext?.[OBSIDIAN_EXT_KEY]?.diagnostics ?? []).slice(0, 100)
450
530
  const preparedGenerationId = prepared.manifest.generationId
451
531
  const trusted = () => store.readCurrent()
452
- const held = heldPaths(edits, scopeId)
453
532
  const observed = new Map(held.map((notePath) => [notePath, rules.heldNoteDigest({ file: path.join(store.vaultRoot, notePath), indexed: index.get(vaultKey(scopeId, notePath))?.digest ?? null })]))
454
533
  const conflicts = rules.publicationConflicts({ prepared, held, bases: basesOf.get(scopeId) ?? new Map(), observed })
455
534
  if (conflicts.length > 0) {
@@ -459,10 +538,27 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
459
538
  }
460
539
  settle('updating', 'publishing', { generationId: trusted()?.generationId ?? null, preparedGenerationId, heldNotes: held })
461
540
  persist()
462
- const result = await seams.publishView({
463
- preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter: adapterFactory({ store, scope }), clock,
464
- ...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
465
- })
541
+ // A generation that is already the committed one needs no app: the publisher settles it after its own restart
542
+ // recovery, before it would probe. Its adapter is then built only if the publisher asks for one, so an app
543
+ // that cannot be qualified never makes a current view stale. Any other publication builds the adapter first,
544
+ // and an app that does not qualify keeps the publisher from being reached at all.
545
+ const lazy = trusted()?.generationId === preparedGenerationId ? builtOnFirstUse(() => adapterFactory({ store, scope })) : null
546
+ const adapter = lazy ?? await adapterFactory({ store, scope })
547
+ let result
548
+ try {
549
+ result = await seams.publishView({
550
+ preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter, clock,
551
+ ...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
552
+ })
553
+ } catch (error) {
554
+ // The publisher asked for the app only to publish the committed generation again (a plugin file drifted),
555
+ // and the app did not qualify: nothing was written, and the committed generation is read back as it is.
556
+ // The file is written at a later attempt, once the app qualifies.
557
+ if (lazy === null || lazy.refusal() !== error || !isTypedRefusal(error) || trusted()?.generationId !== preparedGenerationId) throw error
558
+ result = { state: 'committed', alreadyCommitted: true, notes: [], waitsForApp: true }
559
+ }
560
+ if (result.waitsForApp === true) waitingForApp.set(scopeId, { attempts: (waitingForApp.get(scopeId)?.attempts ?? 0) + 1, lastAttemptMs: nowMs, appState: appNow() })
561
+ else waitingForApp.delete(scopeId)
466
562
  const pointerNow = trusted()
467
563
  const common = { generationId: pointerNow?.generationId ?? null, preparedGenerationId, retainedEdits: pointerNow?.retained?.length ?? 0 }
468
564
  if (result.state === 'refused') {
@@ -487,12 +583,22 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
487
583
  }
488
584
  } catch (error) {
489
585
  if (!isTypedRefusal(error)) { settle('stale', 'publisher-error'); persist(); throw error }
586
+ // A redaction refusal names the note and the rule it concerns, never the value.
587
+ const where = error.code === 'redaction-failure' ? error.detail ?? {} : {}
588
+ diagnostics = typeof where.rule === 'string' ? [{ code: error.code, rule: where.rule, ...(typeof where.notePath === 'string' ? { notePath: where.notePath } : {}), ...(typeof where.filePath === 'string' ? { filePath: where.filePath } : {}) }] : []
490
589
  settle('stale', error.code)
491
590
  if (REREAD_CODES.has(error.code)) forceFull = true
492
591
  }
493
592
  }
494
593
  }
495
594
  persist()
595
+ for (const scopeId of attempt.keys()) {
596
+ const settled = !RETRIED_STATES.has(entries.get(scopeId).state)
597
+ if (settled) unsettled.delete(scopeId)
598
+ else unsettled.set(scopeId, { attempts: (unsettled.get(scopeId)?.attempts ?? 0) + 1, lastAttemptMs: nowMs, appState: appNow() })
599
+ }
600
+ for (const scopeId of [...unsettled.keys()]) if (!entries.has(scopeId) || entries.get(scopeId).state === 'disabled') unsettled.delete(scopeId)
601
+ for (const scopeId of [...waitingForApp.keys()]) if (entries.get(scopeId)?.state !== 'current') waitingForApp.delete(scopeId)
496
602
  semantic = nextSemantic
497
603
  firstTick = false
498
604
  return {
@@ -505,6 +611,16 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
505
611
 
506
612
  return {
507
613
  extensions,
614
+ // The next tick that starts prepares and publishes this view once more, whatever its state. A view this
615
+ // project does not declare is ignored then.
616
+ // A view the project declared at the last tick (before the first, any view), and at most MAX_PREPARATION_REQUESTS
617
+ // of them; whether the request was taken.
618
+ requestPreparation(scopeId) {
619
+ if (typeof scopeId !== 'string' || scopeId === '' || (declaredScopes !== null && !declaredScopes.has(scopeId))) return false
620
+ if (!preparationRequests.has(scopeId) && preparationRequests.size >= MAX_PREPARATION_REQUESTS) return false
621
+ preparationRequests.add(scopeId)
622
+ return true
623
+ },
508
624
  async tick() {
509
625
  if (running) return { state: 'busy' }
510
626
  running = true
@@ -65,7 +65,7 @@ export function normalizeApplyResult(value) {
65
65
  // an apply operation replaces it.
66
66
  const OPERATION_NAME = /^[a-z][a-z0-9-]{0,31}$/
67
67
  // The operations the command ships. The service composes the same registry, so both refuse the same contributions.
68
- export const BUILT_IN_OPERATIONS = Object.freeze(['status', 'scope', 'audience', 'mode', 'policy', 'service', 'open', 'apply', 'help'])
68
+ export const BUILT_IN_OPERATIONS = Object.freeze(['status', 'scope', 'audience', 'mode', 'policy', 'service', 'open', 'plugin', 'apply', 'help'])
69
69
  export const REPLACEABLE_OPERATIONS = Object.freeze(['apply'])
70
70
 
71
71
  export function createCommandOperations({ reserved = [] } = {}) {
@@ -13,6 +13,7 @@ export { APPLY_RESULT_STATUSES, BUILT_IN_OPERATIONS, EXTENSION_KINDS, REPLACEABL
13
13
  export { CONTRIBUTIONS_DIRECTORY, loadContributions } from './contributions.mjs'
14
14
  export { APP_OUTCOMES, MINIMUM_APP_VERSION, compareAppVersions, createQualifiedAdapterFactory, meetsMinimumAppVersion, parseAppVersion, qualifyApp } from './app-capability.mjs'
15
15
  export { OPENING_OUTCOMES, describeOutcome, openScope, scopeReport } from './opening.mjs'
16
+ export { appStateSignature, registerVaultInObsidianSettings } from './app-registration.mjs'
16
17
  export {
17
18
  CHANGE_CLASSES, CLOSED_EDIT_STATES, EDIT_STATES, FRESHNESS_SCHEMA, FRESHNESS_STATES, LATE_WRITERS_SCHEMA, OPEN_EDIT_STATES, PENDING_EDITS_SCHEMA,
18
19
  createMaintenanceStateStore, validateFreshness, validateLateWriters, validatePendingEdits,
@@ -20,7 +21,7 @@ export {
20
21
  export { listConfigFiles, listSourceFiles, listVaultNotes, reconcile } from './observation.mjs'
21
22
  export { createFsWatcherFactory, createNullWatcherFactory } from './watchers.mjs'
22
23
  export { DEFAULT_ELIGIBILITY, assetEligibilityFor, createProductionSeams } from './pipeline.mjs'
23
- export { DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, DEFAULT_LATE_WRITER_WINDOW_MS, DEFAULT_RETRY_INTERVAL_MS, createMaintenanceEngine } from './engine.mjs'
24
+ export { DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, DEFAULT_LATE_WRITER_WINDOW_MS, DEFAULT_PUBLICATION_RETRY_MS, DEFAULT_RETRY_INTERVAL_MS, createMaintenanceEngine } from './engine.mjs'
24
25
  export { ENGINE_LOCK_DIRECTORY, LOCK_TICKET_SCHEMA, acquirePrivateGenerationLock, createAbandonmentProof, inspectPrivateGenerationLock } from './private-lock.mjs'
25
26
  export { DEFAULT_MAX_BACKOFF_MS, DEFAULT_TICK_INTERVAL_MS, createTickLoop } from './tick-loop.mjs'
26
27
  export { HEALTH_SCHEMA, LOOPBACK_HOSTS, probeHealth, requestLoopback } from './service-client.mjs'
@@ -29,6 +30,9 @@ export {
29
30
  removeServiceRecord, serviceNameFor, servicePaths, validateServiceRecord, writeServiceRecord, writeServiceSettings,
30
31
  } from './service-record.mjs'
31
32
  export { MAX_REQUEST_BYTES, SERVICE_OPERATIONS, createServiceServer } from './service-server.mjs'
33
+ export { PLUGIN_BEARER_SCHEMA, PLUGIN_PRESENCE_SCHEMA, createPluginChannel, createPluginSessions, ensurePluginBearer, pluginBearerDirectory, pluginPresence, readPluginBearers } from './plugin-channel.mjs'
34
+ export { PLUGIN_PRESENCE_REASONS, pluginPresenceOf, turnPluginOnNext, withPluginReportedVersion } from './plugin-presence.mjs'
35
+ export { PLUGIN_CHOICE_SCHEMA, PLUGIN_CHOICE_STATES, confirmPluginEntry, confirmPluginSeen, currentPluginChoice, decidePluginChoice, pluginChoiceDirectory, readCommunityEntry, readPluginChoice, viewVaultRoot, writePluginChoice } from './plugin-choice.mjs'
32
36
  export { DEFAULT_SHUTDOWN_GRACE_MS, SERVICE_STATUS_SCHEMA, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
33
37
  export { DEFAULT_START_TIMEOUT_MS, DEFAULT_STOP_TIMEOUT_MS, SERVICE_STATES, readServiceStatusDocument, requestServiceTick, serviceStatus, startService, stopService } from './lifecycle.mjs'
34
38
  export { STARTUP_PLATFORMS, buildStartupAdapter } from './startup-adapters.mjs'
@@ -0,0 +1,88 @@
1
+ // How `open` shows a vault in Obsidian, as steps a launcher carries out. Pure.
2
+ //
3
+ // Two facts of the app (1.13.7, read from its main process and checked on an
4
+ // isolated instance) decide the steps:
5
+ //
6
+ // - An app started with an `obsidian://` URL opens only that URL's vault and
7
+ // deletes the `open` flag of every other vault without a window, so the
8
+ // person's other vaults do not come back the next time Obsidian starts.
9
+ // Started plainly, it reopens every vault its list marks open (the view's
10
+ // vault included, when it was added to the list marked open) and keeps
11
+ // the flags.
12
+ // - `open?path=` picks the listed vault whose folder is the longest string
13
+ // prefix of the path, with no separator check; `open?vault=<id>` names one
14
+ // vault exactly.
15
+ //
16
+ // So a vault is named by its id where that id reaches it first (a vaultRoute
17
+ // of `how: 'id'`), and otherwise by its own path, which the app then matches
18
+ // exactly (a listed folder is the longest prefix of its own path); an app that is not running is
19
+ // started plainly and handed the URL only once it answers: a URL reaching a
20
+ // running app opens a window and changes no flag. On Linux a plain start is not
21
+ // qualified, and the URL starts the app, with the flag loss as a known limit.
22
+ //
23
+ // Steps:
24
+ // { step: 'plain-start' } start the app with no URL
25
+ // { step: 'wait-for-app' } wait, bounded, until the app itself answers its command-line tool
26
+ // { step: 'url', uri } hand the URL to the running app
27
+ // { step: 'url-start', uri } start the app with the URL (Linux only)
28
+ //
29
+ // { ok: true, steps } or { ok: false, reason }.
30
+
31
+ const VAULT_ID = /^[A-Za-z0-9_-]{1,64}$/
32
+
33
+ export function launchPlan({ platform = process.platform, appRunning, vaultId = null, vaultPath = null } = {}) {
34
+ let uri
35
+ if (typeof vaultId === 'string' && VAULT_ID.test(vaultId)) uri = `obsidian://open?vault=${encodeURIComponent(vaultId)}`
36
+ else if (typeof vaultPath === 'string' && vaultPath.startsWith('/') && !vaultPath.includes('\u0000')) uri = `obsidian://open?path=${encodeURIComponent(vaultPath)}`
37
+ else return { ok: false, reason: 'vault-id-unknown' }
38
+ if (platform !== 'darwin' && platform !== 'linux') return { ok: false, reason: 'launcher-platform-unqualified' }
39
+ if (appRunning === true) return { ok: true, steps: [{ step: 'url', uri }] }
40
+ if (platform === 'darwin') return { ok: true, steps: [{ step: 'plain-start' }, { step: 'wait-for-app' }, { step: 'url', uri }] }
41
+ return { ok: true, steps: [{ step: 'url-start', uri }] }
42
+ }
43
+
44
+ // Pure. Whether the command-line tool's reply to a URL says the app took it ("Processed URI …").
45
+ export const urlProcessed = (stdout) => typeof stdout === 'string' && /^processed uri\b/im.test(stdout)
46
+
47
+ // Carries out a launchPlan with injected actions, so the order and the
48
+ // fallbacks are the same for the production launcher and the test harness:
49
+ //
50
+ // start() -> Promise<boolean> start the app with no URL
51
+ // answered() -> Promise<boolean> one poll: the app itself answered (appAnswered); a tool that cannot find the
52
+ // app yet is not an answer
53
+ // handLink(uri) -> Promise<boolean> the app's tool took the URL (urlProcessed)
54
+ // osOpen(uri) -> Promise<boolean> the operating system accepted the URL
55
+ //
56
+ // A URL is never handed to the operating system for an app this launch
57
+ // started: that would start or reach it while it starts, which is what drops
58
+ // the other vaults' reopen flags. The vault was added flagged open, so a plainly
59
+ // started app opens it anyway, and `open` waits for it.
60
+ export async function runLaunchPlan(plan, { start, answered, handLink, osOpen, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), waitMs = 30_000, pollMs = 500, now = () => Date.now() }) {
61
+ if (!plan?.ok) return { launched: false, reason: plan?.reason ?? 'launcher-refused' }
62
+ let started = false
63
+ let reason = 'url-accepted'
64
+ for (const { step, uri } of plan.steps) {
65
+ if (step === 'plain-start') {
66
+ if (!(await start())) return { launched: false, reason: 'os-open-failed' }
67
+ started = true
68
+ } else if (step === 'wait-for-app') {
69
+ const deadline = now() + waitMs
70
+ let up = false
71
+ while (!up) {
72
+ up = await answered()
73
+ if (up) break
74
+ if (now() >= deadline) return { launched: true, reason: 'app-started-not-answering' }
75
+ await sleep(pollMs)
76
+ }
77
+ } else if (step === 'url') {
78
+ if (await handLink(uri)) continue
79
+ if (started) return { launched: true, reason: 'app-started-link-not-taken' }
80
+ if (!(await osOpen(uri))) return { launched: false, reason: 'os-open-failed' }
81
+ reason = 'os-open-accepted'
82
+ } else if (step === 'url-start') {
83
+ if (!(await osOpen(uri))) return { launched: false, reason: 'os-open-failed' }
84
+ reason = 'os-open-accepted'
85
+ }
86
+ }
87
+ return { launched: true, reason: started && reason === 'url-accepted' ? 'plain-start-then-url' : reason }
88
+ }
@@ -2,15 +2,17 @@ import childProcess from 'node:child_process'
2
2
  import { randomBytes as cryptoRandomBytes } from 'node:crypto'
3
3
  import fs from 'node:fs'
4
4
  import net from 'node:net'
5
+ import path from 'node:path'
6
+ import { compareAppVersions } from './app-capability.mjs'
5
7
  import { isoTime } from './documents.mjs'
6
- import { refuse } from './errors.mjs'
8
+ import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
7
9
  import { ensureWorkspaceIdentity } from './machine-settings.mjs'
8
10
  import { acquirePrivateGenerationLock, createAbandonmentProof, isProcessAlive } from './private-lock.mjs'
9
11
  import { processRunsRecordedExecutable } from './process-identity.mjs'
10
12
  import { DEFAULT_PROBE_TIMEOUT_MS, LOOPBACK_HOSTS, probeHealth, requestLoopback } from './service-client.mjs'
11
13
  import { SERVICE_ENTRY_PATH } from './service-entry-path.mjs'
12
14
  import {
13
- CONSENT_COVERAGES, SERVICE_SETTINGS_SCHEMA, executableIdentity, openServiceLog, publicRecord, readServiceRecord, readServiceSettings, removeServiceRecord,
15
+ CONSENT_COVERAGES, SERVICE_SETTINGS_SCHEMA, executableIdentity, openServiceLog, publicRecord, readServiceRecord, readServiceSettings, releaseIdentity, removeServiceRecord,
14
16
  serviceNameFor, servicePaths, writeServiceSettings,
15
17
  } from './service-record.mjs'
16
18
  import { resolveServiceWorkspace } from './service.mjs'
@@ -162,8 +164,9 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
162
164
  const log = openServiceLog(workspaceRoot)
163
165
  try {
164
166
  const args = [executable.path, `--project=${project.configPath}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
165
- // No shell. Detached only when the service is meant to outlive the command that starts it.
166
- child = spawn(execPath, args, { detached, shell: false, windowsHide: true, stdio: ['ignore', log.descriptor, log.descriptor], env })
167
+ // No shell. Detached only when the service is meant to outlive the command that starts it. It runs in the root
168
+ // directory, never in the one the command was started in, which may be a vault or a folder somebody removes.
169
+ child = spawn(execPath, args, { detached, shell: false, windowsHide: true, stdio: ['ignore', log.descriptor, log.descriptor], env, cwd: path.parse(executable.path).root })
167
170
  } finally {
168
171
  fs.closeSync(log.descriptor)
169
172
  }
@@ -226,26 +229,114 @@ export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
226
229
 
227
230
  // One authenticated request to the proven runtime, and to nothing else: anything but a healthy status is answered
228
231
  // with that status and no request is made.
229
- async function askProvenRuntime(options, rules, { method, operation, timeoutMs }) {
232
+ async function askProvenRuntime(options, rules, { method, operation, timeoutMs, body = {} }) {
230
233
  const { loadProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
231
234
  const { workspace } = context({ loadProject, dataRoot, env, platform })
232
235
  if (!workspace?.workspaceRoot) return { requested: false, state: 'stopped', reason: 'workspace-not-prepared' }
233
236
  const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
234
237
  if (status.state !== 'healthy') return { requested: false, state: status.state, reason: status.reason }
235
238
  const { record } = status
236
- const answer = await requestLoopback({ host: record.host, port: record.port, method, path: operation, bearer: record.ext.bearer, payload: method === 'POST' ? { runtimeId: record.runtimeId } : null, timeoutMs })
237
- return { requested: true, state: 'healthy', answer }
239
+ const answer = await requestLoopback({ host: record.host, port: record.port, method, path: operation, bearer: record.ext.bearer, payload: method === 'POST' ? { runtimeId: record.runtimeId, ...body } : null, timeoutMs })
240
+ return { requested: true, state: 'healthy', answer, runtimeId: record.runtimeId }
241
+ }
242
+
243
+ // Pure. How the release a runtime recorded at its start (`executable.ext.release`, see releaseIdentity: the package
244
+ // version and a digest of every runtime module) stands to the installed one, `installed` being { entry, release }, the
245
+ // digest of the entry this command would start and releaseIdentity():
246
+ // current the same version, entry module and modules;
247
+ // outdated an earlier version; the same version with another entry module or other modules; or no release named,
248
+ // as releases before the one that began recording it record none;
249
+ // later a later version, or another version that cannot be ordered. The installed release never replaces it, so two
250
+ // installations used on one workspace do not replace each other's runtime.
251
+ // Versions are ordered as semantic versions, a prerelease below its release (0.2.0-alpha.11 < 0.2.0-alpha.12 < 0.2.0).
252
+ export function releaseStanding(executable, installed) {
253
+ const recorded = executable?.ext?.release
254
+ if (recorded === undefined || recorded === null) return 'outdated'
255
+ // The same version string is compared by content, whether or not it can be ordered: a fork's "dev" on both sides
256
+ // is this release when its modules match, and outdated otherwise, never later.
257
+ const order = recorded.version === installed.release.version ? 0 : compareAppVersions(recorded.version, installed.release.version)
258
+ if (order === null || order > 0) return 'later'
259
+ if (order < 0) return 'outdated'
260
+ return executable.digest === installed.entry && recorded.digest === installed.release.digest ? 'current' : 'outdated'
261
+ }
262
+
263
+ // How the release the proven runtime runs stands to the installed one (releaseStanding), `entryPath` being the entry
264
+ // this command would start. Null when the runtime is not proven, or the installed side cannot be read.
265
+ export function runtimeRelease(status, entryPath) {
266
+ if (status?.state !== 'healthy' || typeof status.record?.executable?.digest !== 'string' || typeof entryPath !== 'string') return null
267
+ let installed
268
+ try { installed = { entry: executableIdentity(entryPath).digest, release: releaseIdentity() } } catch { return null }
269
+ return releaseStanding(status.record.executable, installed)
270
+ }
271
+
272
+ // Stops the proven runtime of this workspace through its own listener and starts `entryPath` in its place, detached,
273
+ // under the consent already recorded. Only a runtime that proves itself ours is ever stopped (stopService).
274
+ // { ok: true } or { ok: false, state, reason }.
275
+ async function restartProvenRuntime(options, rules) {
276
+ const { consent: _given, ...rest } = options
277
+ const stopped = await stopService(rest, rules)
278
+ if (!stopped.stopped) return { ok: false, state: stopped.state, reason: stopped.reason ?? 'the-runtime-was-not-stopped' }
279
+ let started
280
+ try { started = await startService({ ...rest, detached: true }, rules) } catch (error) {
281
+ if (!(error instanceof ObsidianMaintenanceRefusal)) throw error
282
+ return { ok: false, state: 'stopped', reason: error.code }
283
+ }
284
+ if (started.state === 'healthy') return { ok: true }
285
+ return { ok: false, state: started.state, reason: started.state === 'busy' ? 'restarted-service-in-its-first-tick' : started.reason ?? started.state }
238
286
  }
239
287
 
240
288
  // Asks the proven runtime for one tick now and returns what that tick reported. A tick that outlasts the wait is
241
- // `pending`, not an error.
289
+ // `pending`, not an error. With `scopeId`, that view is prepared and published once more on the tick.
290
+ //
291
+ // `service`, the start options of the entry this command would start ({ entryPath, entryArgs, ... }), lets an
292
+ // outdated runtime be replaced: one that runs an earlier release (runtimeRelease) is restarted before it is asked,
293
+ // and one that refuses a tick naming a view (a runtime of 0.2.0-alpha.11 or earlier, which knows no view in a tick)
294
+ // is restarted and asked again, each time under the consent already recorded (`restarted: 'outdated'`). A listener
295
+ // of now refuses a tick only when it names another runtime: when a concurrent command replaced the runtime between
296
+ // the check and the request, the one that took its place is asked, and nothing is restarted. A runtime of a later
297
+ // release is never replaced by this one, nor asked: `service-other-release`. Without `service`, an outdated runtime
298
+ // is only reported (`service-outdated`). `runtimeId` names the runtime that answered, or the one left running.
242
299
  export async function requestServiceTick(options = {}, rules = LIFECYCLE_PRIMITIVES) {
243
- const asked = await askProvenRuntime(options, rules, { method: 'POST', operation: '/tick', timeoutMs: options.tickTimeoutMs ?? 60 * 1000 })
244
- if (!asked.requested) return asked
245
- const { answer } = asked
246
- if (answer.kind === 'timeout') return { requested: true, state: 'healthy', pending: true, tick: null, reason: 'tick-still-running' }
247
- if (answer.kind !== 'response' || answer.statusCode !== 200 || answer.body === null) return { requested: true, state: 'healthy', pending: false, tick: null, reason: 'tick-was-not-answered' }
248
- return { requested: true, state: 'healthy', pending: false, tick: answer.body, reason: 'tick-ran' }
300
+ const { service = null, scopeId } = options
301
+ const ask = () => askProvenRuntime(options, rules, { method: 'POST', operation: '/tick', timeoutMs: options.tickTimeoutMs ?? 60 * 1000, ...(scopeId === undefined ? {} : { body: { scopeId } }) })
302
+ let restarted = null
303
+ const restart = async () => {
304
+ const renewed = await restartProvenRuntime({ ...options, ...service }, rules)
305
+ if (renewed.ok || renewed.reason === 'restarted-service-in-its-first-tick') restarted = 'outdated'
306
+ return renewed.ok ? null : { requested: false, state: renewed.state, reason: renewed.reason, ...(restarted === null ? {} : { restarted }) }
307
+ }
308
+ const later = (status) => ({ requested: false, state: 'healthy', runtimeId: status.record.runtimeId, reason: 'service-other-release' })
309
+ const replaces = typeof service?.entryPath === 'string'
310
+ if (replaces) {
311
+ const status = await serviceStatus(options, rules)
312
+ const standing = runtimeRelease(status, service.entryPath)
313
+ if (standing === 'later') return later(status)
314
+ if (standing === 'outdated') {
315
+ const failed = await restart()
316
+ if (failed) return failed
317
+ }
318
+ }
319
+ let asked = await ask()
320
+ const refusesView = () => asked.requested && scopeId !== undefined && asked.answer.kind === 'response' && asked.answer.statusCode === 409
321
+ if (refusesView() && replaces && restarted === null) {
322
+ // The record is read again first: a runtime that took this one's place meanwhile is asked, not replaced.
323
+ const now = await serviceStatus(options, rules)
324
+ if (now.state === 'healthy' && now.record?.runtimeId !== asked.runtimeId) asked = await ask()
325
+ if (refusesView()) {
326
+ if (runtimeRelease(now, service.entryPath) === 'later') return later(now)
327
+ const failed = await restart()
328
+ if (failed) return failed
329
+ asked = await ask()
330
+ }
331
+ }
332
+ const shown = restarted === null ? {} : { restarted }
333
+ if (!asked.requested) return { ...asked, ...shown }
334
+ const { answer, runtimeId } = asked
335
+ const base = { requested: true, state: 'healthy', runtimeId, ...shown }
336
+ if (refusesView()) return { ...base, pending: false, tick: null, reason: 'service-outdated' }
337
+ if (answer.kind === 'timeout') return { ...base, pending: true, tick: null, reason: 'tick-still-running' }
338
+ if (answer.kind !== 'response' || answer.statusCode !== 200 || answer.body === null) return { ...base, pending: false, tick: null, reason: 'tick-was-not-answered' }
339
+ return { ...base, pending: false, tick: answer.body, reason: 'tick-ran' }
249
340
  }
250
341
 
251
342
  // The status document of the proven runtime: loop, last tick, last error code, freshness summary and, where the