@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.14

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 (58) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/README.md +22 -5
  3. package/docs/blocks/capability-stewardship.md +1 -1
  4. package/docs/blocks/learning-harnesses.md +1 -1
  5. package/docs/capability-stewardship.md +2 -4
  6. package/docs/ingestion.md +3 -3
  7. package/docs/inquiry-harnesses.md +2 -3
  8. package/docs/install.md +3 -3
  9. package/docs/learning-harnesses.md +2 -3
  10. package/docs/local-services.md +42 -3
  11. package/docs/obsidian-contract.md +23 -0
  12. package/docs/obsidian-plugin.md +214 -3
  13. package/docs/obsidian.md +271 -11
  14. package/docs/personal-workspace.md +13 -4
  15. package/docs/release-engineering.md +2 -1
  16. package/docs/repo-boundary-guard.md +26 -3
  17. package/docs/semantic-operation.md +4 -3
  18. package/docs/upgrade.md +174 -2
  19. package/fixtures/templates/local-library.v1.json +2 -1
  20. package/package.json +2 -1
  21. package/plugins/obsidian/main.js +57 -8
  22. package/plugins/obsidian/manifest.json +1 -1
  23. package/src/boundary/content-rules.mjs +532 -67
  24. package/src/boundary/policy.mjs +135 -56
  25. package/src/cli/run.mjs +3 -2
  26. package/src/commands/obsidian.mjs +23 -7
  27. package/src/graph/graph.mjs +13 -5
  28. package/src/graph/knowledge-graph.mjs +19 -1
  29. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  30. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  31. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  32. package/src/knowledge-health/rule-identity.mjs +14 -9
  33. package/src/personal-workspace/index.mjs +1 -1
  34. package/src/personal-workspace/selection.mjs +17 -1
  35. package/src/project/path-match.mjs +2 -1
  36. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  37. package/src/projection/obsidian/personal-workspace.mjs +87 -4
  38. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  39. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  40. package/src/projection/obsidian/publication/publisher.mjs +72 -5
  41. package/src/projection/project.mjs +3 -2
  42. package/src/runtime/obsidian/engine.mjs +293 -55
  43. package/src/runtime/obsidian/lifecycle.mjs +17 -12
  44. package/src/runtime/obsidian/login-item.mjs +4 -1
  45. package/src/runtime/obsidian/observation.mjs +36 -4
  46. package/src/runtime/obsidian/opening.mjs +3 -2
  47. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  48. package/src/runtime/obsidian/pipeline.mjs +44 -3
  49. package/src/runtime/obsidian/private-lock.mjs +39 -4
  50. package/src/runtime/obsidian/service-main.mjs +58 -12
  51. package/src/runtime/obsidian/service.mjs +66 -6
  52. package/src/runtime/obsidian/view-permission.mjs +334 -0
  53. package/src/ui/control-system.mjs +201 -0
  54. package/src/ui/html-primitives.mjs +3 -0
  55. package/src/upgrade/closure-diagnostics.mjs +135 -0
  56. package/src/upgrade/closure.mjs +649 -0
  57. package/src/upgrade/upgrade.mjs +19 -7
  58. package/templates/external-project-workspace/pack-lifecycle.json +2 -1
@@ -15,6 +15,7 @@ import {
15
15
  CONSENT_COVERAGES, SERVICE_SETTINGS_SCHEMA, executableIdentity, openServiceLog, publicRecord, readLastStartup, readServiceRecord, readServiceSettings, releaseOfEntry, removeServiceRecord,
16
16
  serviceNameFor, servicePaths, writeServiceSettings,
17
17
  } from './service-record.mjs'
18
+ import { personalHomeOf } from './personal-selection.mjs'
18
19
  import { resolveServiceWorkspace } from './service.mjs'
19
20
 
20
21
  // start / status / stop of the maintenance service of one workspace, as
@@ -61,9 +62,11 @@ export const LIFECYCLE_PRIMITIVES = Object.freeze({
61
62
  provesOurProcess: (record) => processRunsRecordedExecutable(record),
62
63
  })
63
64
 
64
- function context({ loadProject, dataRoot, env, platform, create = false, randomBytes }) {
65
+ // `locateProject`, when given, answers what the workspace is resolved from without loading the project (a bound
66
+ // personal home, locatePersonalHome): its service is then found, started and stopped whatever its selection holds.
67
+ function context({ loadProject, locateProject = null, dataRoot, env, platform, create = false, randomBytes }) {
65
68
  if (typeof loadProject !== 'function') throw new TypeError('the service lifecycle needs loadProject')
66
- const project = loadProject()
69
+ const project = locateProject === null ? loadProject() : locateProject()
67
70
  if (create) ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
68
71
  const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create })
69
72
  return { project, workspace }
@@ -90,8 +93,8 @@ async function evaluate({ workspaceRoot, workspaceId }, { probeTimeoutMs = DEFAU
90
93
 
91
94
  const shown = ({ record, ...status }) => ({ ...status, record: publicRecord(record) })
92
95
 
93
- export async function serviceStatus({ loadProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
94
- const { workspace } = context({ loadProject, dataRoot, env, platform })
96
+ export async function serviceStatus({ loadProject, locateProject, dataRoot, env = process.env, platform = process.platform, probeTimeoutMs, alive } = {}, rules = LIFECYCLE_PRIMITIVES) {
97
+ const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
95
98
  if (!workspace?.workspaceRoot) return { state: 'stopped', reason: 'workspace-not-prepared', workspaceId: workspace?.workspaceId ?? null, record: null, address: null }
96
99
  return shown(await evaluate(workspace, { probeTimeoutMs, alive, rules }))
97
100
  }
@@ -177,11 +180,11 @@ async function startThroughLoginItem({ workspace, loginItem, entryPath, deadline
177
180
  // proven runtime is answered as already running.
178
181
  export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
179
182
  const {
180
- loadProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
183
+ loadProject, locateProject, dataRoot, host, port, consent, detached = false, entryPath = SERVICE_ENTRY_PATH, entryArgs = [], intervalMs,
181
184
  startTimeoutMs = DEFAULT_START_TIMEOUT_MS, probeTimeoutMs, clock = () => new Date(), env = process.env, platform = process.platform,
182
185
  spawn = childProcess.spawn, execPath = process.execPath, alive = isProcessAlive, randomBytes = cryptoRandomBytes, loginItem = null, replaceOutdated = false,
183
186
  } = options
184
- const { project, workspace } = context({ loadProject, dataRoot, env, platform, create: true })
187
+ const { project, workspace } = context({ loadProject, locateProject, dataRoot, env, platform, create: true })
185
188
  const { workspaceRoot, workspaceId } = workspace
186
189
  const deadline = Date.now() + startTimeoutMs
187
190
 
@@ -208,7 +211,7 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
208
211
  const standing = replaceOutdated ? runtimeRelease(before, entryPath) : null
209
212
  if (standing === 'later') return { ...shown(before), started: false, alreadyRunning: true, release: 'later' }
210
213
  if (standing !== 'outdated') return { ...shown(before), started: false, alreadyRunning: true }
211
- const stopped = await stopService({ loadProject, dataRoot, env, platform, probeTimeoutMs, alive }, rules)
214
+ const stopped = await stopService({ loadProject, locateProject, dataRoot, env, platform, probeTimeoutMs, alive }, rules)
212
215
  if (!stopped.stopped) return { ...shown(before), started: false, alreadyRunning: true, release: 'outdated', reason: stopped.reason ?? 'the-runtime-was-not-stopped' }
213
216
  replaced = 'outdated'
214
217
  before = await evaluate(workspace, { probeTimeoutMs, alive, rules })
@@ -241,7 +244,9 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
241
244
  const executable = executableIdentity(entryPath)
242
245
  const log = openServiceLog(workspaceRoot)
243
246
  try {
244
- const args = [executable.path, `--project=${project.configPath}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
247
+ // A bound personal home is named instead of a project: the service follows the selection confirmed there.
248
+ const home = personalHomeOf(project)
249
+ const args = [executable.path, home === null ? `--project=${project.configPath}` : `--personal-home=${home}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), `--runtime-id=${runtimeId}`, ...(intervalMs === undefined ? [] : [`--interval-ms=${intervalMs}`]), ...entryArgs]
245
250
  // No shell. Detached only when the service is meant to outlive the command that starts it. It runs in the root
246
251
  // directory, never in the one the command was started in, which may be a vault or a folder somebody removes.
247
252
  child = spawn(execPath, args, { detached, shell: false, windowsHide: true, stdio: ['ignore', log.descriptor, log.descriptor], env, cwd: path.parse(executable.path).root })
@@ -277,8 +282,8 @@ export async function startService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
277
282
  }
278
283
 
279
284
  export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
280
- const { loadProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
281
- const { workspace } = context({ loadProject, dataRoot, env, platform })
285
+ const { loadProject, locateProject, dataRoot, stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive, force = false, kill = process.kill.bind(process) } = options
286
+ const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
282
287
  if (!workspace?.workspaceRoot) return { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' }
283
288
  const { workspaceRoot, workspaceId } = workspace
284
289
  const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
@@ -308,8 +313,8 @@ export async function stopService(options = {}, rules = LIFECYCLE_PRIMITIVES) {
308
313
  // One authenticated request to the proven runtime, and to nothing else: anything but a healthy status is answered
309
314
  // with that status and no request is made.
310
315
  async function askProvenRuntime(options, rules, { method, operation, timeoutMs, body = {} }) {
311
- const { loadProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
312
- const { workspace } = context({ loadProject, dataRoot, env, platform })
316
+ const { loadProject, locateProject, dataRoot, probeTimeoutMs, env = process.env, platform = process.platform, alive = isProcessAlive } = options
317
+ const { workspace } = context({ loadProject, locateProject, dataRoot, env, platform })
313
318
  if (!workspace?.workspaceRoot) return { requested: false, state: 'stopped', reason: 'workspace-not-prepared' }
314
319
  const status = await evaluate(workspace, { probeTimeoutMs, alive, rules })
315
320
  if (status.state !== 'healthy') return { requested: false, state: status.state, reason: status.reason }
@@ -9,6 +9,7 @@ import { ensureServiceSettings } from './lifecycle.mjs'
9
9
  import { ensureWorkspaceIdentity, readLocalPointer, resolveDataRoot } from './machine-settings.mjs'
10
10
  import { SERVICE_MANAGER_KINDS } from './service-managers.mjs'
11
11
  import { readLastStartup, readServiceSettings, serviceNameFor, servicePaths, writeServiceSettings } from './service-record.mjs'
12
+ import { personalHomeOf } from './personal-selection.mjs'
12
13
  import { resolveServiceWorkspace } from './service.mjs'
13
14
  import { STARTUP_PLATFORMS, buildStartupAdapter, startupSearchPath } from './startup-adapters.mjs'
14
15
 
@@ -164,7 +165,9 @@ export const loginItemDataRoot = ({ project, dataRoot, env = process.env, platfo
164
165
  export function planLoginItem({ platform, project, workspaceRoot, workspaceId, dataRoot, label, entryPath, entryArgs = [], nodePath, searchPath = null }) {
165
166
  if (!isAbsolutePlain(dataRoot)) throw new TypeError('a login item names the absolute data root of its workspace')
166
167
  if (typeof workspaceId !== 'string' || workspaceId === '') throw new TypeError('a login item names its workspace')
167
- const args = [`--project=${project.configPath}`, `--data-root=${dataRoot}`, `--workspace-id=${workspaceId}`, ...entryArgs]
168
+ // A bound personal home is named instead of a project, as `start` names it (lifecycle.mjs).
169
+ const home = personalHomeOf(project)
170
+ const args = [home === null ? `--project=${project.configPath}` : `--personal-home=${home}`, `--data-root=${dataRoot}`, `--workspace-id=${workspaceId}`, ...entryArgs]
168
171
  const unit = buildStartupAdapter({ platform, label, nodePath, entryPath, args, logPath: servicePaths(workspaceRoot).loginItemLog, searchPath })
169
172
  return { ...unit, label, digest: sha256(unit.text), program: { node: nodePath, entry: entryPath }, searchPath }
170
173
  }
@@ -123,24 +123,52 @@ export function readFileFacts(file) {
123
123
 
124
124
  const sameHint = (left, right) => left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.ino === right.ino
125
125
 
126
+ // Whether a file still has the stat hint it was indexed with: what a reconcile that is not full decides by before it
127
+ // would hash the file again. `entry` is the file's entry in the index. A file never hashed, or gone, has none.
128
+ export function hasIndexedHint(entry, absolute, lstat = fs.lstatSync) {
129
+ if (!entry || entry.digest === null || !entry.hint) return false
130
+ const hint = statHint(absolute, lstat)
131
+ return hint !== null && sameHint(entry.hint, hint)
132
+ }
133
+
126
134
  // Reconciles `files` against `index` (a Map owned by the caller) and returns
127
135
  // the changes, decided by digest alone. `full` hashes everything; otherwise a
128
136
  // file is hashed when it is new, when its stat hint differs or when a watcher
129
137
  // named it. Entries under `prefix` that are no longer listed are removals.
130
- export function reconcile({ index, files, prefix, full, hinted = () => false, lstat = fs.lstatSync }) {
138
+ export function reconcile(input) {
139
+ const steps = reconcileSteps(input)
140
+ for (;;) { const step = steps.next(); if (step.done) return step.value }
141
+ }
142
+
143
+ // The same reconciliation with the event loop given a turn between files, under the caller's cooperative budget
144
+ // (createCooperativeBudget): for a walk long enough to hold a service's listener, such as the read-back of a large
145
+ // view. Each file is still looked at once, in the listed order, and the index holds what was seen when each was read;
146
+ // a file that changes after its turn is the next look's to see, as one that changes after an uninterrupted walk is.
147
+ // The caller must not let another reconciliation of the same index run before this one has answered.
148
+ export async function reconcileCooperatively(input, budget) {
149
+ const steps = reconcileSteps(input)
150
+ for (;;) {
151
+ const step = steps.next()
152
+ if (step.done) return step.value
153
+ await budget.checkpoint('reconcile-file')
154
+ }
155
+ }
156
+
157
+ // One step a listed file; the removals and the answer after the last.
158
+ function* reconcileSteps({ index, files, prefix, full, hinted = () => false, lstat = fs.lstatSync }) {
131
159
  const changes = []
132
160
  const present = new Set()
133
161
  let hashed = 0
134
- for (const file of files) {
162
+ const look = (file) => {
135
163
  present.add(file.key)
136
164
  const previous = index.get(file.key)
137
165
  const hint = statHint(file.absolute, lstat)
138
166
  if (hint === null) {
139
167
  if (previous && previous.digest !== null) changes.push({ key: file.key, changeClass: previous.changeClass, kind: 'removed' })
140
168
  index.set(file.key, { changeClass: file.changeClass, hint: null, digest: null, byteLength: null })
141
- continue
169
+ return
142
170
  }
143
- if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key)) continue
171
+ if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key)) return
144
172
  const facts = readFileFacts(file.absolute)
145
173
  const digest = facts?.digest ?? null
146
174
  hashed += 1
@@ -149,6 +177,10 @@ export function reconcile({ index, files, prefix, full, hinted = () => false, ls
149
177
  }
150
178
  index.set(file.key, { changeClass: file.changeClass, hint: digest === null ? null : hint, digest, byteLength: facts?.byteLength ?? null })
151
179
  }
180
+ for (const file of files) {
181
+ look(file)
182
+ yield
183
+ }
152
184
  for (const [key, previous] of index) {
153
185
  if (!key.startsWith(prefix) || present.has(key)) continue
154
186
  if (previous.digest !== null) changes.push({ key, changeClass: previous.changeClass, kind: 'removed' })
@@ -134,6 +134,7 @@ export const REASON_NEXT = Object.freeze({
134
134
  // publisher or editor is involved.
135
135
  'plugin-file-changed': 'a file of Atelier\'s plugin in this vault (under .obsidian/plugins) changed while the view was published, by another program or by you, so the publication did not finish; it is retried automatically, and if this keeps happening, stop what keeps writing there (a sync tool, say)',
136
136
  'settings-changed': 'a settings file Atelier writes in this vault (the plugin list or a plugin setting under .obsidian) changed while the view was published, by Obsidian, another program or you, and was not written over; it is retried automatically, reading the file as it is then',
137
+ 'selection-history-shortened': 'the selection history of the personal home ends at an earlier record than the running maintenance service has read: records were removed by something other than a confirmation, and the service publishes nothing while the history is shorter. Put the removed records back, and it goes on as before. Or accept the history as it now stands: a service started again (`atelier obsidian service stop`, then open again) reads the history as it is and follows the selection it then ends with, publishing it if its generation still holds (`atelier obsidian status` reports that selection, or why it cannot be used); a confirmation made after the records were removed is followed without a restart once the history is as long as it was',
137
138
  'vault-inside-another-vault': 'Obsidian lists another vault at a folder that contains this view\'s vault; open never adds a vault inside another one, and never sends a call that could reach that vault instead: remove that vault from Obsidian\'s vault list, or keep Atelier\'s data root outside that folder, then open again',
138
139
  })
139
140
 
@@ -358,7 +359,7 @@ function settingsWritten(written) {
358
359
  // again for a view the app kept from being published, and reports one outcome.
359
360
  export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIMITIVES, lifecycleRules = LIFECYCLE_PRIMITIVES) {
360
361
  const {
361
- loadProject, dataRoot, scopeId: requestedScope, appProbe, launcher, registry, quitter = null, restartApp = false, service = {}, consent, allowStale = false, extensions = null,
362
+ loadProject, locateProject, dataRoot, scopeId: requestedScope, appProbe, launcher, registry, quitter = null, restartApp = false, service = {}, consent, allowStale = false, extensions = null,
362
363
  tickTimeoutMs = 120 * 1000, appWaitMs = 30 * 1000, appPollMs = 500, sleep = defaultSleep, monotonic = () => Date.now(),
363
364
  env = process.env, platform = process.platform, probeTimeoutMs,
364
365
  } = options
@@ -382,7 +383,7 @@ export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIM
382
383
  }
383
384
  if (enablement.state === 'disabled') return finish('disabled', { reason: enablement.reason, scopeId: requestedScope ?? null })
384
385
  const scopeId = resolveScope(enablement, requestedScope)
385
- const lifecycle = { loadProject, dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
386
+ const lifecycle = { loadProject, ...(locateProject === undefined ? {} : { locateProject }), dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
386
387
 
387
388
  // 1. The owned service: reconnect, or start. Never adopt; a runtime of ours is replaced only when it runs an earlier
388
389
  // release, under the consent already recorded, when a tick is asked of it (requestServiceTick).
@@ -0,0 +1,125 @@
1
+ import path from 'node:path'
2
+ import { PersonalWorkspaceRefusal, readPersonalSelectionHead } from '@mnstry/atelier/personal-workspace'
3
+ import { ensureLocalState } from '@mnstry/atelier/project'
4
+ import { assertPersonalSelection, loadBoundProject, loadBoundProjectOffThread, notePersonalSelectionHead, personalSelectionOf, pinPersonalSelection } from '../../projection/obsidian/personal-workspace.mjs'
5
+ import { refuse } from './errors.mjs'
6
+
7
+ // A person's confirmed selection, bound to the maintenance runtime.
8
+ //
9
+ // A personal home keeps a history of the generations its person confirmed
10
+ // (`selectPersonalGeneration` of the personal-workspace module). The loaders
11
+ // here follow that history instead of naming a generation themselves:
12
+ //
13
+ // read the head -> load the generation it names -> read the head again
14
+ //
15
+ // The head is read from the history alone, in the caller's thread: one
16
+ // directory listing and one bounded read per record. Whether the generation
17
+ // may be used is composition's to decide, as for any binding: in a worker for
18
+ // a service, in the calling thread for a command run once. A head that moved
19
+ // while the generation was loaded is another selection, and refuses
20
+ // `personal-selection-changed`.
21
+ //
22
+ // Nothing is confirmed here and nothing is chosen for the person. A history
23
+ // with no record, one that does not hold, a home that cannot be read and a
24
+ // generation its composition refuses each refuse as a maintenance refusal
25
+ // with the module's own code (`nothing-selected`, `selection-history-corrupt`,
26
+ // `personal-home-unavailable`, `root-missing`, `stale-generation` and the
27
+ // rest). There is no project to fall back to: an engine whose loader refuses
28
+ // publishes nothing, and every view it knows says why.
29
+ //
30
+ // A loaded project is pinned to the record it was loaded under
31
+ // (pinPersonalSelection), so the engine observes that record and the place of
32
+ // the next one, and loads again at the tick after a confirmation or after the
33
+ // history was cut short. At every full reconciliation the history is read
34
+ // again, and a project whose record it no longer ends with is refused.
35
+ //
36
+ // A history never gets shorter through the module: selections are appended and
37
+ // nothing removes one. So a history that ends at a lower record than this
38
+ // process has read it ending at was cut short by something else, and following
39
+ // it back could publish what nobody confirmed after the records removed. Every
40
+ // read of the history's end is noted (notePersonalSelectionHead): the first
41
+ // read here, the read once the generation is loaded, the recheck of a project
42
+ // already loaded, and a read the engine asks for when its guard stops a view
43
+ // because an observed file changed or could not be read (once for views
44
+ // stopped one after another). A
45
+ // history that ends earlier than the highest record noted refuses
46
+ // `selection-history-shortened` at a load and at a recheck, and nothing is
47
+ // loaded or used from it while it is shorter; the guard's read only notes, and
48
+ // its view says `mixed-read`. An engine whose project is pinned below the
49
+ // highest record noted loads it again at its next tick
50
+ // (personalSelectionBehind). A record added and removed between two of those
51
+ // reads was never read, and does not count.
52
+ //
53
+ // Only the record number is kept, and only in this process. A record in a
54
+ // place already read, with other content, is read as a confirmation. A process
55
+ // started after the cut reads the history as it then stands and publishes what
56
+ // it ends with: starting the service again accepts the shorter history, and so
57
+ // does any start of it (a login, a restart after an upgrade or a failure).
58
+ //
59
+ // Who may see a vault is not decided here: that stays the machine's setting.
60
+ //
61
+ // Where the workspace is does not wait for a load: a bound project's folder is
62
+ // its private home, so the home alone locates it (locatePersonalHome). A
63
+ // service resolves its workspace from that at once, and starts, whether or not
64
+ // a selection is confirmed; its engine then awaits the loader at each tick.
65
+
66
+ // The decisions the oracles in test/obsidian-personal-selection.test.mjs are
67
+ // sensitive to. Production always uses these; a test hands a loader a broken
68
+ // one (`rules`) to prove that its oracle can fail.
69
+ export const SELECTION_PRIMITIVES = Object.freeze({
70
+ // A history that ends at a lower record than this process has read is refused, at every read of it.
71
+ refusesShortened: true,
72
+ // The head is read again once the generation is loaded.
73
+ readsHeadAgain: true,
74
+ // What the loaded project is pinned for (pinPersonalSelection).
75
+ pin: Object.freeze({ observe: true, recheck: true }),
76
+ })
77
+
78
+ const SOURCE = Object.freeze({ source: 'personal-workspace' })
79
+ const LOCATED = Symbol('atelier.obsidian.located-personal-home')
80
+
81
+ // The record the history ends with. A history without one refuses, and so does one shorter than this process read it.
82
+ function confirmedHead(personalHome, rules) {
83
+ let head
84
+ try { head = readPersonalSelectionHead({ personalHome }) } catch (error) {
85
+ if (!(error instanceof PersonalWorkspaceRefusal)) throw error
86
+ refuse(error.code, 'the selection history of the personal workspace could not be read; nothing is loaded from it', SOURCE)
87
+ }
88
+ notePersonalSelectionHead(personalHome, head, rules)
89
+ if (head.selected === null) refuse('nothing-selected', 'no generation of the personal workspace is confirmed; nothing is loaded until one is', SOURCE)
90
+ return head
91
+ }
92
+
93
+ function pinned(project, head, rules) {
94
+ pinPersonalSelection(project, head, { ...rules.pin, refusesShortened: rules.refusesShortened })
95
+ if (rules.readsHeadAgain) assertPersonalSelection(project)
96
+ return project
97
+ }
98
+
99
+ // What the workspace of a bound personal home is resolved from, without loading anything: a project that holds the home
100
+ // as its folder and nothing else, as a bound project's folder is. Its pointer is under the home, the data root is the
101
+ // pointer's or the caller's, and a home is outside every Git worktree. Synchronous; nothing is composed.
102
+ export function locatePersonalHome({ personalHome } = {}) {
103
+ if (typeof personalHome !== 'string' || !path.isAbsolute(personalHome) || path.resolve(personalHome) !== personalHome) refuse('path-not-absolute', 'a personal home is named by its absolute path', SOURCE)
104
+ const located = { configDir: personalHome, [LOCATED]: personalHome }
105
+ located.localState = ensureLocalState(located, { write: false, env: { PATH: process.env.PATH } })
106
+ return located
107
+ }
108
+
109
+ // The personal home a located or a loaded project follows, which a service it starts or a login item it installs is
110
+ // given instead of a project (--personal-home); null for any other project.
111
+ export const personalHomeOf = (project) => project?.[LOCATED] ?? personalSelectionOf(project)?.personalHome ?? null
112
+ export { personalSelectionOf }
113
+
114
+ // The project of the confirmed selection, for a loader that may block (a command run once). `bind` and `rules` are
115
+ // test seams.
116
+ export function loadSelectedProject({ personalHome } = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
117
+ const head = confirmedHead(personalHome, rules)
118
+ return pinned(loadBoundProject({ personalHome, generationId: head.selected }, { bind }), head, rules)
119
+ }
120
+
121
+ // The same, for a loader on a service's event loop: the composition runs in a worker, and the engine awaits it.
122
+ export async function loadSelectedProjectOffThread({ personalHome } = {}, { bind, rules = SELECTION_PRIMITIVES } = {}) {
123
+ const head = confirmedHead(personalHome, rules)
124
+ return pinned(await loadBoundProjectOffThread({ personalHome, generationId: head.selected }, { bind }), head, rules)
125
+ }
@@ -1,6 +1,7 @@
1
+ import { createCooperativeBudget, prepareViewCooperatively } from '../../projection/obsidian/materialize/prepare-view.mjs'
1
2
  import { createHash } from 'node:crypto'
2
3
  import path from 'node:path'
3
- import { buildCanonicalGraph, createGraphFileCache } from '../../graph/graph.mjs'
4
+ import { buildCanonicalGraph, buildCanonicalGraphSteps, createGraphFileCache } from '../../graph/graph.mjs'
4
5
  import { markdownMetadata } from '../../graph/knowledge-graph.mjs'
5
6
  import { EMITTER_VERSION, createPreparationCache, prepareView, readMarkdownLens, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
6
7
  import { personalWorkspaceGraph } from '../../projection/obsidian/personal-workspace.mjs'
@@ -128,8 +129,42 @@ export function profileFor({ project, workspaceId, audienceAllow }) {
128
129
  // `deferPersonalValidation` (the engine), an unconfirmed key refuses `personal-validation-pending` instead of composing
129
130
  // on this thread.
130
131
  export function buildGraph({ project, eligibility, cache = null, index = null, deferPersonalValidation = false }) {
131
- const observedDigest = cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null
132
+ const observedDigest = observedDigestOf(cache, index)
132
133
  const canonical = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation }) ?? buildCanonicalGraph(project, { fileCache: cache, observedDigest })
134
+ return eligibleGraph(canonical, eligibility)
135
+ }
136
+
137
+ // The same graph, built with the event loop given a turn between the Markdown sources of the census, under the
138
+ // caller's cooperative budget (`scheduling`: createCooperativeBudget's options, its guard checked at every wait). For
139
+ // the maintenance engine's tick: a first tick reads every source, and a large corpus would hold the service's
140
+ // listener for the whole census. A project bound to a personal workspace is built by its own route, in one stretch,
141
+ // as buildGraph builds it.
142
+ export async function buildGraphCooperatively({ project, eligibility, cache = null, index = null, deferPersonalValidation = false, scheduling = {} }) {
143
+ const observedDigest = observedDigestOf(cache, index)
144
+ // The personal route answers for a bound project, and refuses a project that carries the reserved key unbound.
145
+ const personal = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation })
146
+ if (personal !== null) return eligibleGraph(personal, eligibility)
147
+ const budget = createCooperativeBudget(scheduling)
148
+ budget.check()
149
+ const steps = buildCanonicalGraphSteps(project, { fileCache: cache, observedDigest })
150
+ let canonical
151
+ try {
152
+ for (;;) {
153
+ const step = steps.next()
154
+ if (step.done) { canonical = step.value; break }
155
+ await budget.checkpoint(step.value)
156
+ }
157
+ } catch (error) {
158
+ steps.return?.()
159
+ throw error
160
+ }
161
+ budget.finish('graph-assembled')
162
+ return eligibleGraph(canonical, eligibility)
163
+ }
164
+
165
+ const observedDigestOf = (cache, index) => (cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null)
166
+
167
+ function eligibleGraph(canonical, eligibility) {
133
168
  if (!canonical.ok) refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
134
169
  return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
135
170
  }
@@ -141,6 +176,12 @@ export function buildGraph({ project, eligibility, cache = null, index = null, d
141
176
  // its pinned digest is unchanged and the cached bytes are the ones that digest
142
177
  // describes, so bytes that drift under an unchanged pin are not consulted
143
178
  // until observation hashes the file again (the same bound as the graph stage).
179
+ // The engine keeps that bound when it prepares cooperatively. Called on its
180
+ // own, prepareViewCooperatively waits between notes and so reads every
181
+ // selected source against its pin once more before it returns. The engine
182
+ // turns that second reading off and checks the selected sources itself, after
183
+ // the preparation and again before the commit, as observation does: by stat
184
+ // hint, and by digest only where the hint moved.
144
185
  export function captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt }) {
145
186
  const roots = new Map((project.repos ?? []).filter((repo) => !repo.external).map((repo) => [repo.name, repo.path]))
146
187
  const absolute = (repoId, relative) => {
@@ -186,5 +227,5 @@ export function captureSnapshot({ project, graph, workspaceId, index, configDige
186
227
  // that note and reuses the rest. Both caches are derived, in-memory state: a
187
228
  // test may replace either seam with `() => null` to build or prepare in full.
188
229
  export function createProductionSeams() {
189
- return { buildGraph, captureSnapshot, profileFor, prepareView, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
230
+ return { buildGraph, buildGraphCooperatively, captureSnapshot, profileFor, prepareView, prepareViewCooperatively, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
190
231
  }
@@ -89,6 +89,20 @@ function listGenerations(directory) {
89
89
 
90
90
  const ticketName = (generation, kind) => `${String(generation).padStart(12, '0')}.${kind}`
91
91
 
92
+ // A ticket is removed only while it holds the given bytes: one that holds others is another holder's. Answers whether
93
+ // nothing of these bytes is left at the path (removed now, or gone already); throws what reading or removing it threw.
94
+ function removeIfStill(file, body) {
95
+ let current
96
+ try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') return true; throw error }
97
+ if (current !== body) return true
98
+ fs.unlinkSync(file)
99
+ return true
100
+ }
101
+
102
+ // Tickets this process published and could not remove after its acquisition failed, by lock directory. Each is
103
+ // removed at this process's next acquisition in that directory, before the directory is looked at.
104
+ const UNWITHDRAWN = new Map()
105
+
92
106
  // Read-only. { available, reason, generation, ticket }
93
107
  export async function inspectPrivateGenerationLock({ directory, workspaceId, proveAbandoned = createAbandonmentProof(), nowMs = Date.now() }) {
94
108
  const { names, highest, unknown } = listGenerations(directory)
@@ -116,6 +130,11 @@ export async function inspectPrivateGenerationLock({ directory, workspaceId, pro
116
130
  // Never waits and never throws for a lock that is merely held.
117
131
  export async function acquirePrivateGenerationLock({ workspaceRoot, directory, workspaceId, purpose, service = null, clock, proveAbandoned, randomBytes = cryptoRandomBytes }) {
118
132
  const contained = ensureContainedPrivateDirectory({ workspaceRoot, directory, label: 'private lock' })
133
+ const owed = UNWITHDRAWN.get(contained)
134
+ if (owed !== undefined) {
135
+ // Still failing: the ticket stays, and this acquisition finds it as held by this process, as before.
136
+ try { if (removeIfStill(owed.file, owed.body)) UNWITHDRAWN.delete(contained) } catch { /* tried again at the next acquisition */ }
137
+ }
119
138
  const acquiredAt = isoTime(clock)
120
139
  const status = await inspectPrivateGenerationLock({ directory: contained, workspaceId, proveAbandoned, nowMs: Date.parse(acquiredAt) })
121
140
  const holder = status.ticket ? { pid: status.ticket.pid, purpose: status.ticket.purpose, runtimeId: status.ticket.service?.runtimeId ?? null } : null
@@ -125,12 +144,25 @@ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, w
125
144
  const ticket = validateTicket({ schema: LOCK_TICKET_SCHEMA, workspaceId, purpose, pid: process.pid, machine: machineDigest(), nonce: randomBytes(16).toString('hex'), acquiredAt, service }, workspaceId)
126
145
  const body = JSON.stringify(ticket)
127
146
  const file = path.join(contained, ticketName(generation, 'json'))
147
+ // A ticket published and then not handed to its caller, because a later step failed (the directory sync or the
148
+ // read-back of the publication, the listing that confirms it), is removed while it still holds these bytes: nobody
149
+ // would release it, and every later acquisition by this process would find it held by this process. One that does
150
+ // not hold them is another holder's and is left alone. A removal that fails too (the same fault, say) is owed, and
151
+ // made at the next acquisition in this directory.
152
+ const withdraw = () => {
153
+ try { removeIfStill(file, body) } catch { UNWITHDRAWN.set(contained, { file, body }) }
154
+ }
128
155
  try { publishPrivateFile(file, body) } catch (error) {
129
156
  if (error.code === 'EEXIST') return { acquired: false, reason: 'lost-the-race-for-this-generation', holder: null }
157
+ withdraw()
130
158
  throw error
131
159
  }
132
160
  // Confirm: a decision made on an old listing finds the newer ticket here and withdraws.
133
- const after = listGenerations(contained)
161
+ let after
162
+ try { after = listGenerations(contained) } catch (error) {
163
+ withdraw()
164
+ throw error
165
+ }
134
166
  if (after.highest !== generation) {
135
167
  try { fs.unlinkSync(file) } catch { /* a lower generation is ignored and pruned by the next holder */ }
136
168
  return { acquired: false, reason: 'a-newer-holder-exists', holder: null }
@@ -138,18 +170,21 @@ export async function acquirePrivateGenerationLock({ workspaceRoot, directory, w
138
170
  for (const name of after.names) {
139
171
  if (Number(TICKET_NAME.exec(name)[1]) < generation) try { fs.unlinkSync(path.join(contained, name)) } catch { /* pruned by somebody else */ }
140
172
  }
173
+ // Settled once the lock is given back, or found not to be ours any more. A release that throws (a full disk, an
174
+ // I/O error while the released ticket is written) settles nothing: the lock is still held, and calling release()
175
+ // again tries again. Writing the same released ticket a second time is harmless.
141
176
  let released = false
142
177
  return {
143
178
  acquired: true,
144
179
  generation,
145
180
  release() {
146
181
  if (released) return false
147
- released = true
148
182
  let current
149
- try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') return false; throw error }
183
+ try { current = readRegularTextNoFollow(file) } catch (error) { if (error.code === 'ENOENT') { released = true; return false } throw error }
150
184
  // Taken over after a proof of abandonment (this process was thought gone): nothing here is ours to release.
151
- if (current !== body) return false
185
+ if (current !== body) { released = true; return false }
152
186
  publishPrivateFile(path.join(contained, ticketName(generation, 'released')), body)
187
+ released = true
153
188
  return true
154
189
  },
155
190
  }
@@ -4,12 +4,17 @@ import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { firstString, parseArgs, resolveProjectConfig } from '../../project/config.mjs'
6
6
  import { workspaceStateRoot } from './machine-settings.mjs'
7
+ import { loadSelectedProjectOffThread, locatePersonalHome } from './personal-selection.mjs'
7
8
  import { RELEASE_CHANGED, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
8
9
 
9
10
  // The process of the maintenance service. `start` runs it as a child; a unit
10
11
  // installed at the operating-system level runs it with `--startup`.
11
12
  //
12
- // --project=<absolute atelier.project.json> required
13
+ // --project=<absolute atelier.project.json> required, unless a personal home is named instead
14
+ // --personal-home=<absolute directory> instead of --project: the private home of a personal workspace. The
15
+ // service follows the generation its person confirmed there, composed off
16
+ // the event loop, and starts whether or not one is confirmed: it locates
17
+ // its workspace from the home alone (personal-selection.mjs)
13
18
  // --data-root=<absolute directory> optional; otherwise the pointer, the overlay or the platform default
14
19
  // --workspace-id=<identifier> named by a login item: under `--startup`, where a refusal is recorded
15
20
  // when the project no longer leads to a workspace
@@ -77,15 +82,48 @@ export const EXIT_REFUSED = 2
77
82
  // manager restarts it (a non-zero exit), on the release now installed. EX_TEMPFAIL.
78
83
  export const EXIT_RELEASE_CHANGED = 75
79
84
 
85
+ const readOr = (read, otherwise) => { try { return read() } catch { return otherwise } }
80
86
  const printed = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
81
87
 
88
+ // The first answer of the loader, or of the locator of a personal home, asked for once and handed to both who need it
89
+ // at a start under a login item: the lookup of the workspace below, and the service's own start, which takes it as the
90
+ // answer to its first call. One that answers a promise or another thenable is not waited for by either. A promise whose
91
+ // `constructor` reads as this realm's `Promise` is kept as it is; any other thenable (as a rule another realm's promise
92
+ // or a subclass's) is adopted here, once, as a native promise (so its `then` is called once in all). That promise is let go, handled
93
+ // without calling a `then` of its own: a refusal of it never goes unhandled and ends the process with an error where
94
+ // the service's typed refusal should end it cleanly. The service is handed that promise. Whatever is thrown,
95
+ // `undefined` included, and a `then` that cannot be read, is thrown again at the service's first call, so it ends the
96
+ // start as a failure of the loader would.
97
+ function firstAnswerOf(loadProject) {
98
+ let first
99
+ try {
100
+ const project = loadProject()
101
+ if (typeof project?.then === 'function') {
102
+ const promised = Promise.resolve(project)
103
+ Promise.prototype.then.call(promised, undefined, () => {})
104
+ first = { promised }
105
+ } else first = { project }
106
+ } catch (error) { first = { threw: true, error } }
107
+ let taken = false
108
+ return {
109
+ // The project, when the loader answered one at once; null when it threw or answered a promise.
110
+ project: () => (first.threw || first.promised ? null : first.project),
111
+ loadProject() {
112
+ if (taken) return loadProject()
113
+ taken = true
114
+ if (first.threw) throw first.error
115
+ return first.promised ?? first.project
116
+ },
117
+ }
118
+ }
119
+
82
120
  // The workspace of a service a login item started, when it can be found: where it keeps its log and records how its
83
- // start ended. When the project no longer leads to one (it moved, its pointer is gone), the workspace the unit names by
84
- // its data root and identity, if that exists; it is never created here. Null otherwise; a refusal is then printed
85
- // only, into the unit's own output file.
86
- function startupWorkspace({ loadProject, dataRoot, workspaceId, env = process.env, platform = process.platform }) {
121
+ // start ended. When the project no longer leads to one (it moved, its pointer is gone, its loader answered no project
122
+ // at once), the workspace the unit names by its data root and identity, if that exists; it is never created here. Null
123
+ // otherwise; a refusal is then printed only, into the unit's own output file.
124
+ function startupWorkspace({ project, dataRoot, workspaceId, env = process.env, platform = process.platform }) {
87
125
  try {
88
- const workspace = resolveServiceWorkspace({ project: loadProject(), dataRoot, env, platform })
126
+ const workspace = project === null ? null : resolveServiceWorkspace({ project, dataRoot, env, platform })
89
127
  if (workspace?.workspaceRoot) return workspace
90
128
  } catch { /* the workspace the unit names, below */ }
91
129
  if (typeof dataRoot !== 'string' || !path.isAbsolute(dataRoot) || typeof workspaceId !== 'string') return null
@@ -120,10 +158,14 @@ function recordStartup(workspace, { at, outcome, code }) {
120
158
  // and the manager restarts it after its throttle.
121
159
  export async function runServiceProcess(options) {
122
160
  const startup = options.startup === true
123
- const workspace = startup ? startupWorkspace(options) : null
161
+ // A located personal home is asked instead of the loader: the loader is then not asked before the service's engine.
162
+ const located = typeof options.locateProject === 'function'
163
+ const asked = located ? options.locateProject : options.loadProject
164
+ const first = startup && typeof asked === 'function' ? firstAnswerOf(asked) : null
165
+ const workspace = startup ? startupWorkspace({ ...options, project: first === null ? null : first.project() }) : null
124
166
  const log = workspace === null ? printed : workspaceLog(workspace.workspaceRoot)
125
167
  try {
126
- const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options })
168
+ const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options, ...(first === null ? {} : { [located ? 'locateProject' : 'loadProject']: first.loadProject }) })
127
169
  if (startup) recordStartup(workspace, { at: new Date().toISOString(), outcome: 'started', code: null })
128
170
  for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { void service.shutdown(`signal-${signal}`) })
129
171
  const { reason } = await service.done
@@ -131,8 +173,9 @@ export async function runServiceProcess(options) {
131
173
  process.exit(reason === RELEASE_CHANGED ? EXIT_RELEASE_CHANGED : 0)
132
174
  } catch (error) {
133
175
  const at = new Date().toISOString()
134
- const code = typeof error?.code === 'string' ? error.code : null
135
- log({ at, event: 'refused', code: code ?? 'untyped-error', message: String(error?.message ?? error) })
176
+ // What was thrown is read without trusting it: a value with no prototype, or a getter that throws, is still logged.
177
+ const code = readOr(() => (typeof error?.code === 'string' ? error.code : null), null)
178
+ log({ at, event: 'refused', code: code ?? 'untyped-error', message: readOr(() => String(error?.message ?? error), 'unprintable') })
136
179
  if (startup && code !== null) { recordStartup(workspace, { at, outcome: 'refused', code }); process.exitCode = 0; return }
137
180
  process.exitCode = code !== null ? EXIT_REFUSED : 1
138
181
  }
@@ -149,13 +192,16 @@ function watchRelease(invokedAs) {
149
192
  export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
150
193
  const args = parseArgs(argv)
151
194
  const configPath = firstString(args.project)
152
- if (!configPath || !path.isAbsolute(configPath)) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
195
+ const personalHome = firstString(args['personal-home'])
196
+ if (personalHome !== null && (configPath !== null || !path.isAbsolute(personalHome))) throw Object.assign(new Error('--personal-home must be the absolute path of a private home, given instead of --project'), { code: 'service-arguments-invalid' })
197
+ if (personalHome === null && (!configPath || !path.isAbsolute(configPath))) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
153
198
  const dataRoot = firstString(args['data-root']) ?? undefined
154
199
  const workspaceId = firstString(args['workspace-id']) ?? undefined
155
200
  const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
156
201
  if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
157
202
  return {
158
- loadProject: () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
203
+ loadProject: personalHome !== null ? () => loadSelectedProjectOffThread({ personalHome }) : () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
204
+ ...(personalHome === null ? {} : { locateProject: () => locatePersonalHome({ personalHome }) }),
159
205
  ...(dataRoot === undefined ? {} : { dataRoot }), ...(workspaceId === undefined ? {} : { workspaceId }), ...(intervalMs === undefined ? {} : { intervalMs }),
160
206
  ...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
161
207
  startup: args.startup === true, adapter: firstString(args.adapter),