@mnstry/atelier 0.2.0-alpha.11 → 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.
- package/CHANGELOG.md +344 -0
- package/README.md +10 -6
- package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
- package/contracts/compat-baseline.json +1 -1
- package/docs/assurance-controls.md +3 -2
- package/docs/install.md +3 -3
- package/docs/local-services.md +62 -11
- package/docs/obsidian-contract.md +669 -35
- package/docs/obsidian-plugin.md +585 -0
- package/docs/obsidian.md +233 -50
- package/docs/release-engineering.md +7 -1
- package/docs/upgrade.md +23 -3
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
- package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
- package/fixtures/obsidian/edits/workspace.json +1 -1
- package/fixtures/obsidian/materialization/expected-full.json +21 -21
- package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
- package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
- package/package.json +7 -3
- package/plugins/obsidian/main.js +444 -0
- package/plugins/obsidian/manifest.json +9 -0
- package/plugins/obsidian/styles.css +22 -0
- package/src/commands/obsidian.mjs +89 -10
- package/src/contracts/corpus.mjs +6 -0
- package/src/egress/forbidden-egress.mjs +2 -0
- package/src/project/private-state.mjs +10 -1
- package/src/projection/obsidian/contracts.mjs +224 -18
- package/src/projection/obsidian/edits/apply.mjs +12 -7
- package/src/projection/obsidian/edits/regions.mjs +137 -42
- package/src/projection/obsidian/materialize/index.mjs +6 -3
- package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
- package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
- package/src/projection/obsidian/materialize/redaction.mjs +327 -0
- package/src/projection/obsidian/materialize/settings.mjs +113 -34
- package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
- package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
- package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
- package/src/projection/obsidian/proposals/observation.mjs +10 -5
- package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
- package/src/projection/obsidian/publication/index.mjs +2 -1
- package/src/projection/obsidian/publication/publisher.mjs +173 -26
- package/src/projection/obsidian/publication/transport.mjs +63 -8
- package/src/projection/obsidian/publication/vault-list.mjs +199 -0
- package/src/projection/obsidian/recovery/restart.mjs +3 -1
- package/src/projection/obsidian/recovery/store.mjs +11 -4
- package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
- package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
- package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
- package/src/runtime/obsidian/app-capability.mjs +104 -12
- package/src/runtime/obsidian/app-production-seams.mjs +126 -39
- package/src/runtime/obsidian/app-registration.mjs +166 -0
- package/src/runtime/obsidian/engine.mjs +127 -11
- package/src/runtime/obsidian/extension-points.mjs +1 -1
- package/src/runtime/obsidian/index.mjs +5 -1
- package/src/runtime/obsidian/launch-plan.mjs +88 -0
- package/src/runtime/obsidian/lifecycle.mjs +105 -14
- package/src/runtime/obsidian/opening.mjs +227 -55
- package/src/runtime/obsidian/pending-edits.mjs +10 -0
- package/src/runtime/obsidian/plugin-channel.mjs +281 -0
- package/src/runtime/obsidian/plugin-choice.mjs +170 -0
- package/src/runtime/obsidian/plugin-drift.mjs +76 -0
- package/src/runtime/obsidian/plugin-presence.mjs +48 -0
- package/src/runtime/obsidian/service-main.mjs +18 -5
- package/src/runtime/obsidian/service-record.mjs +36 -0
- package/src/runtime/obsidian/service-server.mjs +50 -12
- package/src/runtime/obsidian/service.mjs +100 -6
- package/src/runtime/obsidian/state-store.mjs +13 -1
- package/src/server/local-sidecar.mjs +24 -5
- package/src/server/server.mjs +18 -5
- package/src/upgrade/upgrade.mjs +1 -1
- 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
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|