@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.
- package/CHANGELOG.md +233 -0
- package/README.md +22 -5
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/capability-stewardship.md +2 -4
- package/docs/ingestion.md +3 -3
- package/docs/inquiry-harnesses.md +2 -3
- package/docs/install.md +3 -3
- package/docs/learning-harnesses.md +2 -3
- package/docs/local-services.md +42 -3
- package/docs/obsidian-contract.md +23 -0
- package/docs/obsidian-plugin.md +214 -3
- package/docs/obsidian.md +271 -11
- package/docs/personal-workspace.md +13 -4
- package/docs/release-engineering.md +2 -1
- package/docs/repo-boundary-guard.md +26 -3
- package/docs/semantic-operation.md +4 -3
- package/docs/upgrade.md +174 -2
- package/fixtures/templates/local-library.v1.json +2 -1
- package/package.json +2 -1
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/src/boundary/content-rules.mjs +532 -67
- package/src/boundary/policy.mjs +135 -56
- package/src/cli/run.mjs +3 -2
- package/src/commands/obsidian.mjs +23 -7
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +19 -1
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +17 -1
- package/src/project/path-match.mjs +2 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
- package/src/projection/obsidian/personal-workspace.mjs +87 -4
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +72 -5
- package/src/projection/project.mjs +3 -2
- package/src/runtime/obsidian/engine.mjs +293 -55
- package/src/runtime/obsidian/lifecycle.mjs +17 -12
- package/src/runtime/obsidian/login-item.mjs +4 -1
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +3 -2
- package/src/runtime/obsidian/personal-selection.mjs +125 -0
- package/src/runtime/obsidian/pipeline.mjs +44 -3
- package/src/runtime/obsidian/private-lock.mjs +39 -4
- package/src/runtime/obsidian/service-main.mjs +58 -12
- package/src/runtime/obsidian/service.mjs +66 -6
- package/src/runtime/obsidian/view-permission.mjs +334 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +19 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
169
|
+
return
|
|
142
170
|
}
|
|
143
|
-
if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key))
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
84
|
-
// its data root and identity, if that exists; it is never created here. Null
|
|
85
|
-
// only, into the unit's own output file.
|
|
86
|
-
function startupWorkspace({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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),
|