@mono-agent/agent-app 0.6.2 → 0.9.0
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/README.md +259 -19
- package/dist/account-home.d.ts +6 -0
- package/dist/account-home.d.ts.map +1 -0
- package/dist/account-home.js +9 -0
- package/dist/account-home.js.map +1 -0
- package/dist/adapter-send-proxy.d.ts +18 -0
- package/dist/adapter-send-proxy.d.ts.map +1 -0
- package/dist/adapter-send-proxy.js +229 -0
- package/dist/adapter-send-proxy.js.map +1 -0
- package/dist/adapter-send-tools-main.js +62 -4
- package/dist/adapter-send-tools-main.js.map +1 -1
- package/dist/adapter-send-tools.d.ts +33 -6
- package/dist/adapter-send-tools.d.ts.map +1 -1
- package/dist/adapter-send-tools.js +485 -87
- package/dist/adapter-send-tools.js.map +1 -1
- package/dist/app-config.d.ts +3 -1
- package/dist/app-config.d.ts.map +1 -1
- package/dist/app-config.js +16 -5
- package/dist/app-config.js.map +1 -1
- package/dist/app.d.ts +27 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +761 -147
- package/dist/app.js.map +1 -1
- package/dist/background-environment.d.ts +16 -0
- package/dist/background-environment.d.ts.map +1 -0
- package/dist/background-environment.js +68 -0
- package/dist/background-environment.js.map +1 -0
- package/dist/background-runtime.d.ts +62 -0
- package/dist/background-runtime.d.ts.map +1 -0
- package/dist/background-runtime.js +1115 -0
- package/dist/background-runtime.js.map +1 -0
- package/dist/background-snapshot-key.d.ts +13 -0
- package/dist/background-snapshot-key.d.ts.map +1 -0
- package/dist/background-snapshot-key.js +194 -0
- package/dist/background-snapshot-key.js.map +1 -0
- package/dist/background-snapshot.d.ts +96 -0
- package/dist/background-snapshot.d.ts.map +1 -0
- package/dist/background-snapshot.js +450 -0
- package/dist/background-snapshot.js.map +1 -0
- package/dist/background-worker-lease.d.ts +39 -0
- package/dist/background-worker-lease.d.ts.map +1 -0
- package/dist/background-worker-lease.js +356 -0
- package/dist/background-worker-lease.js.map +1 -0
- package/dist/background.d.ts +74 -2
- package/dist/background.d.ts.map +1 -1
- package/dist/background.js +815 -73
- package/dist/background.js.map +1 -1
- package/dist/broadcast-recorder.d.ts.map +1 -1
- package/dist/broadcast-recorder.js +24 -2
- package/dist/broadcast-recorder.js.map +1 -1
- package/dist/channel-plugins.d.ts +2 -0
- package/dist/channel-plugins.d.ts.map +1 -1
- package/dist/channel-plugins.js +8 -0
- package/dist/channel-plugins.js.map +1 -1
- package/dist/channels.d.ts +10 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +87 -9
- package/dist/channels.js.map +1 -1
- package/dist/cli.d.ts +86 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2421 -143
- package/dist/cli.js.map +1 -1
- package/dist/config-reference.d.ts.map +1 -1
- package/dist/config-reference.js +448 -6
- package/dist/config-reference.js.map +1 -1
- package/dist/configuration-proposal-main.d.ts +3 -0
- package/dist/configuration-proposal-main.d.ts.map +1 -0
- package/dist/configuration-proposal-main.js +13 -0
- package/dist/configuration-proposal-main.js.map +1 -0
- package/dist/configuration-proposal-tool.d.ts +33 -0
- package/dist/configuration-proposal-tool.d.ts.map +1 -0
- package/dist/configuration-proposal-tool.js +188 -0
- package/dist/configuration-proposal-tool.js.map +1 -0
- package/dist/configured-agent.d.ts +16 -0
- package/dist/configured-agent.d.ts.map +1 -1
- package/dist/configured-agent.js +165 -18
- package/dist/configured-agent.js.map +1 -1
- package/dist/consumer-contract.d.ts +3 -0
- package/dist/consumer-contract.d.ts.map +1 -1
- package/dist/consumer-contract.js +44 -1
- package/dist/consumer-contract.js.map +1 -1
- package/dist/continuation-command.d.ts +15 -0
- package/dist/continuation-command.d.ts.map +1 -0
- package/dist/continuation-command.js +170 -0
- package/dist/continuation-command.js.map +1 -0
- package/dist/continuation-config.d.ts +25 -0
- package/dist/continuation-config.d.ts.map +1 -0
- package/dist/continuation-config.js +236 -0
- package/dist/continuation-config.js.map +1 -0
- package/dist/continuation-service.d.ts +99 -0
- package/dist/continuation-service.d.ts.map +1 -0
- package/dist/continuation-service.js +1313 -0
- package/dist/continuation-service.js.map +1 -0
- package/dist/continuation-store.d.ts +104 -0
- package/dist/continuation-store.d.ts.map +1 -0
- package/dist/continuation-store.js +688 -0
- package/dist/continuation-store.js.map +1 -0
- package/dist/continuations.d.ts +167 -0
- package/dist/continuations.d.ts.map +1 -0
- package/dist/continuations.js +71 -0
- package/dist/continuations.js.map +1 -0
- package/dist/doctor.d.ts +29 -5
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +1611 -116
- package/dist/doctor.js.map +1 -1
- package/dist/first-run-managed-memory.d.ts +39 -0
- package/dist/first-run-managed-memory.d.ts.map +1 -0
- package/dist/first-run-managed-memory.js +584 -0
- package/dist/first-run-managed-memory.js.map +1 -0
- package/dist/first-run-readiness.d.ts +111 -0
- package/dist/first-run-readiness.d.ts.map +1 -0
- package/dist/first-run-readiness.js +728 -0
- package/dist/first-run-readiness.js.map +1 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +95 -3
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1218 -24
- package/dist/init.js.map +1 -1
- package/dist/interaction-bridge.d.ts +25 -1
- package/dist/interaction-bridge.d.ts.map +1 -1
- package/dist/interaction-bridge.js +281 -19
- package/dist/interaction-bridge.js.map +1 -1
- package/dist/launchd.d.ts +16 -6
- package/dist/launchd.d.ts.map +1 -1
- package/dist/launchd.js +30 -15
- package/dist/launchd.js.map +1 -1
- package/dist/local-configuration.d.ts +99 -0
- package/dist/local-configuration.d.ts.map +1 -0
- package/dist/local-configuration.js +1299 -0
- package/dist/local-configuration.js.map +1 -0
- package/dist/managed-runtime-packages.d.ts +9 -0
- package/dist/managed-runtime-packages.d.ts.map +1 -0
- package/dist/managed-runtime-packages.js +56 -0
- package/dist/managed-runtime-packages.js.map +1 -0
- package/dist/memory-command.d.ts +4 -0
- package/dist/memory-command.d.ts.map +1 -1
- package/dist/memory-command.js +751 -27
- package/dist/memory-command.js.map +1 -1
- package/dist/memory-embedding-service.d.ts +33 -0
- package/dist/memory-embedding-service.d.ts.map +1 -0
- package/dist/memory-embedding-service.js +207 -0
- package/dist/memory-embedding-service.js.map +1 -0
- package/dist/memory-recall.d.ts +43 -14
- package/dist/memory-recall.d.ts.map +1 -1
- package/dist/memory-recall.js +103 -33
- package/dist/memory-recall.js.map +1 -1
- package/dist/memory-retrieval.d.ts +66 -0
- package/dist/memory-retrieval.d.ts.map +1 -0
- package/dist/memory-retrieval.js +332 -0
- package/dist/memory-retrieval.js.map +1 -0
- package/dist/modules/base.d.ts +6 -5
- package/dist/modules/base.d.ts.map +1 -1
- package/dist/modules/base.js +11 -7
- package/dist/modules/base.js.map +1 -1
- package/dist/modules/catalog.d.ts.map +1 -1
- package/dist/modules/catalog.js +141 -25
- package/dist/modules/catalog.js.map +1 -1
- package/dist/modules/index.d.ts +2 -2
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/known-tools.d.ts +3 -0
- package/dist/modules/known-tools.d.ts.map +1 -1
- package/dist/modules/known-tools.js +11 -1
- package/dist/modules/known-tools.js.map +1 -1
- package/dist/modules/types.d.ts +20 -6
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/modules/types.js.map +1 -1
- package/dist/notify-runtime.d.ts +26 -0
- package/dist/notify-runtime.d.ts.map +1 -0
- package/dist/notify-runtime.js +27 -0
- package/dist/notify-runtime.js.map +1 -0
- package/dist/notify-tool.d.ts +51 -0
- package/dist/notify-tool.d.ts.map +1 -0
- package/dist/notify-tool.js +182 -0
- package/dist/notify-tool.js.map +1 -0
- package/dist/package-version.d.ts +3 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +14 -0
- package/dist/package-version.js.map +1 -0
- package/dist/pi-auth-store-inspection.d.ts +21 -0
- package/dist/pi-auth-store-inspection.d.ts.map +1 -0
- package/dist/pi-auth-store-inspection.js +120 -0
- package/dist/pi-auth-store-inspection.js.map +1 -0
- package/dist/pi-oauth-login-main.d.ts +3 -0
- package/dist/pi-oauth-login-main.d.ts.map +1 -0
- package/dist/pi-oauth-login-main.js +14 -0
- package/dist/pi-oauth-login-main.js.map +1 -0
- package/dist/pi-oauth-login.d.ts +22 -0
- package/dist/pi-oauth-login.d.ts.map +1 -0
- package/dist/pi-oauth-login.js +79 -0
- package/dist/pi-oauth-login.js.map +1 -0
- package/dist/proactive-notify.d.ts +2 -0
- package/dist/proactive-notify.d.ts.map +1 -1
- package/dist/proactive-notify.js +1 -0
- package/dist/proactive-notify.js.map +1 -1
- package/dist/process-incarnation.d.ts +23 -0
- package/dist/process-incarnation.d.ts.map +1 -0
- package/dist/process-incarnation.js +208 -0
- package/dist/process-incarnation.js.map +1 -0
- package/dist/project-skills.d.ts +40 -0
- package/dist/project-skills.d.ts.map +1 -0
- package/dist/project-skills.js +573 -0
- package/dist/project-skills.js.map +1 -0
- package/dist/provider-setup.d.ts +117 -2
- package/dist/provider-setup.d.ts.map +1 -1
- package/dist/provider-setup.js +1505 -49
- package/dist/provider-setup.js.map +1 -1
- package/dist/readiness-probe-worker.d.ts +12 -0
- package/dist/readiness-probe-worker.d.ts.map +1 -0
- package/dist/readiness-probe-worker.js +276 -0
- package/dist/readiness-probe-worker.js.map +1 -0
- package/dist/readiness-probe.d.ts +100 -0
- package/dist/readiness-probe.d.ts.map +1 -0
- package/dist/readiness-probe.js +780 -0
- package/dist/readiness-probe.js.map +1 -0
- package/dist/recipes/base.d.ts +15 -0
- package/dist/recipes/base.d.ts.map +1 -0
- package/dist/recipes/base.js +51 -0
- package/dist/recipes/base.js.map +1 -0
- package/dist/recipes/catalog.d.ts +4 -0
- package/dist/recipes/catalog.d.ts.map +1 -0
- package/dist/recipes/catalog.js +525 -0
- package/dist/recipes/catalog.js.map +1 -0
- package/dist/recipes/index.d.ts +11 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +14 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/recipes/types.d.ts +70 -0
- package/dist/recipes/types.d.ts.map +1 -0
- package/dist/recipes/types.js +15 -0
- package/dist/recipes/types.js.map +1 -0
- package/dist/request-model-override.d.ts +43 -1
- package/dist/request-model-override.d.ts.map +1 -1
- package/dist/request-model-override.js +178 -19
- package/dist/request-model-override.js.map +1 -1
- package/dist/run-history.d.ts +32 -0
- package/dist/run-history.d.ts.map +1 -0
- package/dist/run-history.js +859 -0
- package/dist/run-history.js.map +1 -0
- package/dist/runs-health.d.ts.map +1 -1
- package/dist/runs-health.js +12 -4
- package/dist/runs-health.js.map +1 -1
- package/dist/runtime-option-extensions.d.ts +14 -0
- package/dist/runtime-option-extensions.d.ts.map +1 -0
- package/dist/runtime-option-extensions.js +98 -0
- package/dist/runtime-option-extensions.js.map +1 -0
- package/dist/runtime-routes.d.ts +9 -0
- package/dist/runtime-routes.d.ts.map +1 -0
- package/dist/runtime-routes.js +13 -0
- package/dist/runtime-routes.js.map +1 -0
- package/dist/sandbox-manager.d.ts +86 -0
- package/dist/sandbox-manager.d.ts.map +1 -0
- package/dist/sandbox-manager.js +1402 -0
- package/dist/sandbox-manager.js.map +1 -0
- package/dist/setup.d.ts +29 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +97 -0
- package/dist/setup.js.map +1 -0
- package/dist/supermemory-plugin.d.ts +56 -0
- package/dist/supermemory-plugin.d.ts.map +1 -0
- package/dist/supermemory-plugin.js +142 -0
- package/dist/supermemory-plugin.js.map +1 -0
- package/dist/tui-command.d.ts +56 -0
- package/dist/tui-command.d.ts.map +1 -1
- package/dist/tui-command.js +305 -31
- package/dist/tui-command.js.map +1 -1
- package/dist/web-command.d.ts +8 -0
- package/dist/web-command.d.ts.map +1 -1
- package/dist/web-command.js +174 -17
- package/dist/web-command.js.map +1 -1
- package/dist/wizard/answers.d.ts +31 -4
- package/dist/wizard/answers.d.ts.map +1 -1
- package/dist/wizard/answers.js +141 -29
- package/dist/wizard/answers.js.map +1 -1
- package/dist/wizard/from-flags.d.ts +7 -0
- package/dist/wizard/from-flags.d.ts.map +1 -1
- package/dist/wizard/from-flags.js +36 -2
- package/dist/wizard/from-flags.js.map +1 -1
- package/dist/wizard/index.d.ts +4 -4
- package/dist/wizard/index.d.ts.map +1 -1
- package/dist/wizard/index.js +2 -2
- package/dist/wizard/index.js.map +1 -1
- package/dist/wizard/model-discovery.d.ts +33 -2
- package/dist/wizard/model-discovery.d.ts.map +1 -1
- package/dist/wizard/model-discovery.js +826 -108
- package/dist/wizard/model-discovery.js.map +1 -1
- package/dist/wizard/presets.d.ts +1 -1
- package/dist/wizard/presets.d.ts.map +1 -1
- package/dist/wizard/presets.js +7 -12
- package/dist/wizard/presets.js.map +1 -1
- package/dist/wizard/prompts.d.ts +56 -9
- package/dist/wizard/prompts.d.ts.map +1 -1
- package/dist/wizard/prompts.js +172 -20
- package/dist/wizard/prompts.js.map +1 -1
- package/dist/wizard/run.d.ts +59 -3
- package/dist/wizard/run.d.ts.map +1 -1
- package/dist/wizard/run.js +1290 -191
- package/dist/wizard/run.js.map +1 -1
- package/package.json +21 -16
- package/resources/srt/package-lock.json +66 -0
- package/resources/srt/package.json +9 -0
- package/schema/mono-agent.config.schema.json +587 -20
- package/skills/mono-agent-composer/SKILL.md +6 -6
- package/skills/mono-agent-composer/references/config-blueprint.md +11 -10
- package/skills/mono-agent-composer/references/discovery-questions.md +33 -18
- package/skills/mono-agent-composer/references/feature-coverage.md +14 -12
- package/skills/mono-agent-composer/references/package-map.md +2 -2
- package/skills/mono-agent-composer/references/playbooks.md +8 -7
- package/skills/mono-agent-composer/references/validation.md +3 -3
package/dist/background.js
CHANGED
|
@@ -1,21 +1,42 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
6
|
import process from "node:process";
|
|
5
7
|
import { listRecordedRuns, listTraceSources } from "@mono-agent/observability";
|
|
6
8
|
import { describeSensitiveDataExportWarning, phoenixAppBaseUrl, resolveAppTraceRegistryDir, resolveAppTraceStaleAfterMs, } from "./app-config.js";
|
|
7
9
|
import { formatChannelFactValue } from "./channel-fact-format.js";
|
|
8
|
-
import { bootout, bootstrap, buildPlistXml, defaultPathEnv, deriveLaunchdLabel, isLoaded,
|
|
10
|
+
import { bootout, bootstrap, buildPlistXml, defaultPathEnv, deriveLaunchdLabel, isLoaded, launchdServiceInfo, launchdPathsFor, makeLaunchctlRunner, } from "./launchd.js";
|
|
11
|
+
import { selectBackgroundOperationalEnvironment } from "./background-environment.js";
|
|
12
|
+
import { ensureManagedBackgroundRuntime, MANAGED_BACKGROUND_WORKER_ENV, } from "./background-runtime.js";
|
|
13
|
+
import { backgroundSnapshotFromMetadata, captureBackgroundSnapshot, encodeBackgroundSnapshot, sameBackgroundSnapshot, } from "./background-snapshot.js";
|
|
14
|
+
import { resolveConfiguredManagedRuntimePackages } from "./managed-runtime-packages.js";
|
|
15
|
+
import { currentProcessIncarnation, isSameProcessIncarnation as matchesProcessIncarnation, processIncarnationFromJson, } from "./process-incarnation.js";
|
|
9
16
|
import { buildRunsHealthDisplay, RUNS_HEALTH_MAX_RUNS } from "./runs-health.js";
|
|
10
17
|
import * as ui from "./ui.js";
|
|
18
|
+
export { acquireBackgroundWorkerLease, backgroundWorkerLeasePath, } from "./background-worker-lease.js";
|
|
19
|
+
/** Exact non-secret environment materialised into a managed LaunchAgent. */
|
|
20
|
+
export function managedBackgroundEnvironment(env) {
|
|
21
|
+
return {
|
|
22
|
+
...selectBackgroundOperationalEnvironment(env),
|
|
23
|
+
PATH: defaultPathEnv({ ...env }),
|
|
24
|
+
// This is a lifecycle marker, not a config override. It tells cli.ts to
|
|
25
|
+
// discard launchd's ambient environment before loading the chosen dotenv.
|
|
26
|
+
[MANAGED_BACKGROUND_WORKER_ENV]: "1",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
11
29
|
/**
|
|
12
30
|
* Resolve everything the control commands need from CLI args. The registry dir
|
|
13
31
|
* and config path are resolved exactly as the worker will resolve them, so the
|
|
14
32
|
* detached launcher can find the worker's manifest without any IPC.
|
|
15
33
|
*/
|
|
16
34
|
export async function resolveInstanceTarget(input) {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
35
|
+
const lexicalCwd = resolve(input.cwd);
|
|
36
|
+
const [cwd, configPath] = await Promise.all([
|
|
37
|
+
realpath(lexicalCwd),
|
|
38
|
+
canonicalBackgroundConfigPath(lexicalCwd, input.args.configPath),
|
|
39
|
+
]);
|
|
19
40
|
const configInput = { env: input.env, cwd, configPath };
|
|
20
41
|
const [registryDir, staleAfterMs] = await Promise.all([
|
|
21
42
|
resolveAppTraceRegistryDir(configInput),
|
|
@@ -34,9 +55,41 @@ export async function resolveInstanceTarget(input) {
|
|
|
34
55
|
// Bake an explicit --env-file (resolved absolute) into the plist so the
|
|
35
56
|
// launchd worker loads the same env file the launcher did.
|
|
36
57
|
...(input.args.envFile === undefined ? {} : { envFile: resolve(cwd, input.args.envFile) }),
|
|
37
|
-
|
|
58
|
+
configurationEnvironment: { ...input.env },
|
|
59
|
+
environment: managedBackgroundEnvironment(input.env),
|
|
60
|
+
...(input.requireTui === true ? { requireTui: true } : {}),
|
|
38
61
|
};
|
|
39
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Collapse symlinked parent aliases without following the config's final path
|
|
65
|
+
* component. The final component is separately required to be a regular,
|
|
66
|
+
* non-symlink file before start; keeping it unresolved preserves that check.
|
|
67
|
+
* Missing parents remain addressable so stop/status/logs can still operate on
|
|
68
|
+
* a previously installed label after an agent folder is damaged or removed.
|
|
69
|
+
*/
|
|
70
|
+
export async function canonicalBackgroundConfigPath(cwd, configuredPath) {
|
|
71
|
+
const lexical = resolve(cwd, configuredPath ?? "mono-agent.config.json");
|
|
72
|
+
try {
|
|
73
|
+
const candidate = join(await realpath(dirname(lexical)), basename(lexical));
|
|
74
|
+
try {
|
|
75
|
+
const details = await lstat(candidate);
|
|
76
|
+
// Preserve the final-component symlink so the start-time regular-file
|
|
77
|
+
// check can reject it. For a real file, realpath also canonicalises the
|
|
78
|
+
// stored filename casing on the default case-insensitive macOS volume.
|
|
79
|
+
return details.isSymbolicLink() ? candidate : await realpath(candidate);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
|
|
83
|
+
return candidate;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
|
|
89
|
+
return lexical;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
40
93
|
export function defaultBackgroundDeps() {
|
|
41
94
|
return {
|
|
42
95
|
runner: makeLaunchctlRunner(),
|
|
@@ -45,10 +98,10 @@ export function defaultBackgroundDeps() {
|
|
|
45
98
|
sleep: (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)),
|
|
46
99
|
listRecordedRuns,
|
|
47
100
|
listTraceSources,
|
|
48
|
-
writeFile:
|
|
49
|
-
mkdir:
|
|
101
|
+
writeFile: writeOwnerPrivateLaunchdFile,
|
|
102
|
+
mkdir: ensureOwnerPrivateLaunchdDirectory,
|
|
50
103
|
rm: (path) => rm(path, { force: true }),
|
|
51
|
-
stat,
|
|
104
|
+
stat: inspectOwnerPrivateLaunchdLog,
|
|
52
105
|
rename,
|
|
53
106
|
isAlive: (pid) => {
|
|
54
107
|
try {
|
|
@@ -60,6 +113,14 @@ export function defaultBackgroundDeps() {
|
|
|
60
113
|
return isErrno(error, "EPERM");
|
|
61
114
|
}
|
|
62
115
|
},
|
|
116
|
+
ensureManagedRuntime: (input) => ensureManagedBackgroundRuntime(input),
|
|
117
|
+
resolveManagedRuntimePackages: (target) => resolveConfiguredManagedRuntimePackages({
|
|
118
|
+
cwd: target.cwd,
|
|
119
|
+
configPath: target.configPath,
|
|
120
|
+
env: target.configurationEnvironment,
|
|
121
|
+
}),
|
|
122
|
+
acquireLifecycleLock: acquireFilesystemLifecycleLock,
|
|
123
|
+
probeTui: probeTuiEndpoint,
|
|
63
124
|
stdout: (text) => void process.stdout.write(text),
|
|
64
125
|
stderr: (text) => void process.stderr.write(text),
|
|
65
126
|
spawnTail: (args) => new Promise((resolvePromise) => {
|
|
@@ -70,38 +131,176 @@ export function defaultBackgroundDeps() {
|
|
|
70
131
|
};
|
|
71
132
|
}
|
|
72
133
|
const DEFAULT_POLL = { timeoutMs: 18_000, intervalMs: 400 };
|
|
73
|
-
const SINCE_SKEW_MS = 2_000;
|
|
74
134
|
export const LAUNCHD_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
75
135
|
export const LAUNCHD_LOG_ROTATION_COUNT = 3;
|
|
76
136
|
export async function startBackground(target, deps, poll = DEFAULT_POLL) {
|
|
77
|
-
return await
|
|
137
|
+
return (await ensureBackgroundReady(target, deps, poll)).ok ? 0 : 1;
|
|
78
138
|
}
|
|
79
139
|
/** Restart is behaviourally identical: ensure a single fresh running instance. */
|
|
80
140
|
export async function restartBackground(target, deps, poll = DEFAULT_POLL) {
|
|
81
|
-
return await
|
|
141
|
+
return (await ensureBackgroundReady(target, deps, poll)).ok ? 0 : 1;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Stop, perform the caller's stopped-worker mutation, and start again while
|
|
145
|
+
* retaining one lifecycle lock. This closes the force-restart gap in which a
|
|
146
|
+
* concurrent start could previously enter while the session store was purged.
|
|
147
|
+
*/
|
|
148
|
+
export async function forceRestartBackground(target, deps, whileStopped, poll = DEFAULT_POLL) {
|
|
149
|
+
const release = await deps.acquireLifecycleLock(target);
|
|
150
|
+
if (release === undefined) {
|
|
151
|
+
deps.stderr(ui.errorLine(`Another lifecycle command is already active for ${target.label}.`));
|
|
152
|
+
deps.stderr(ui.hint("No LaunchAgent or session changes were made. Wait for that command to finish, then retry."));
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const stopCode = await stopBackgroundUnlocked(target, deps, poll);
|
|
157
|
+
if (stopCode !== 0)
|
|
158
|
+
return stopCode;
|
|
159
|
+
await whileStopped();
|
|
160
|
+
return (await ensureBackgroundReadyUnlocked(target, deps, poll)).ok ? 0 : 1;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await release().catch((error) => {
|
|
164
|
+
deps.stderr(ui.errorLine(`Could not release lifecycle lock for ${target.label}: ${error instanceof Error ? error.message : String(error)}`));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Ensure the canonical per-config LaunchAgent is running and return the fresh
|
|
170
|
+
* trace source that proved it reached `startup-complete`. This is the shared
|
|
171
|
+
* lifecycle boundary for CLI start/restart and remote configuration handoffs;
|
|
172
|
+
* callers must not open a console when the result is not ok.
|
|
173
|
+
*/
|
|
174
|
+
export async function ensureBackgroundReady(target, deps, poll = DEFAULT_POLL) {
|
|
175
|
+
const release = await deps.acquireLifecycleLock(target);
|
|
176
|
+
if (release === undefined) {
|
|
177
|
+
deps.stderr(ui.errorLine(`Another lifecycle command is already active for ${target.label}.`));
|
|
178
|
+
deps.stderr(ui.hint("No LaunchAgent changes were made. Wait for that command to finish, then retry."));
|
|
179
|
+
return { ok: false, action: "start", reason: "ownership" };
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return await ensureBackgroundReadyUnlocked(target, deps, poll);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await release().catch((error) => {
|
|
186
|
+
deps.stderr(ui.errorLine(`Could not release lifecycle lock for ${target.label}: ${error instanceof Error ? error.message : String(error)}`));
|
|
187
|
+
});
|
|
188
|
+
}
|
|
82
189
|
}
|
|
83
|
-
async function
|
|
190
|
+
async function ensureBackgroundReadyUnlocked(target, deps, poll) {
|
|
191
|
+
if (target.expectedSnapshot === undefined) {
|
|
192
|
+
deps.stderr(ui.errorLine("Refusing to launch a managed worker without an approved background snapshot."));
|
|
193
|
+
deps.stderr(ui.hint("No LaunchAgent changes were made. Retry from the wizard or `mono-agent start`."));
|
|
194
|
+
return { ok: false, action: "start", reason: "snapshot" };
|
|
195
|
+
}
|
|
84
196
|
const uid = deps.getuid();
|
|
85
|
-
await
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
197
|
+
if (!(await snapshotStillMatches(target, deps))) {
|
|
198
|
+
reportSnapshotDrift(target, deps);
|
|
199
|
+
return { ok: false, action: "start", reason: "snapshot" };
|
|
200
|
+
}
|
|
201
|
+
let launchTarget;
|
|
202
|
+
try {
|
|
203
|
+
const additionalPackages = await deps.resolveManagedRuntimePackages?.(target) ?? [];
|
|
204
|
+
const runtime = await deps.ensureManagedRuntime({
|
|
205
|
+
currentCliPath: target.cliPath,
|
|
206
|
+
nodePath: target.nodePath,
|
|
207
|
+
additionalPackages,
|
|
208
|
+
});
|
|
209
|
+
launchTarget = {
|
|
210
|
+
...target,
|
|
211
|
+
cliPath: runtime.cliPath,
|
|
212
|
+
nodePath: runtime.nodePath,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
reportLifecycleException(target, deps, "install and verify the durable managed runtime", error);
|
|
217
|
+
return { ok: false, action: "start", reason: "runtime" };
|
|
218
|
+
}
|
|
219
|
+
// Runtime materialisation can involve npm/native installation. Recheck the
|
|
220
|
+
// approved files after that unbounded external work and before writing any
|
|
221
|
+
// LaunchAgent state.
|
|
222
|
+
if (!(await snapshotStillMatches(launchTarget, deps))) {
|
|
223
|
+
reportSnapshotDrift(launchTarget, deps);
|
|
224
|
+
return { ok: false, action: "start", reason: "snapshot" };
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const conflict = await findOwnershipConflict(launchTarget, deps, uid);
|
|
228
|
+
if (conflict !== undefined) {
|
|
229
|
+
reportOwnershipConflict(launchTarget, deps, conflict);
|
|
230
|
+
return { ok: false, action: "start", reason: "ownership" };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
reportLifecycleException(launchTarget, deps, "verify the existing background worker ownership", error);
|
|
235
|
+
return { ok: false, action: "start", reason: "ownership" };
|
|
236
|
+
}
|
|
237
|
+
let sinceMs;
|
|
238
|
+
try {
|
|
239
|
+
await prepareLaunchdDirectories(launchTarget, deps);
|
|
240
|
+
await rotateLaunchdLogs(launchTarget.paths, deps);
|
|
241
|
+
await writePlist(launchTarget, deps);
|
|
242
|
+
sinceMs = deps.now();
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
reportLifecycleException(launchTarget, deps, "prepare the LaunchAgent", error);
|
|
246
|
+
return { ok: false, action: "start", reason: "preparation" };
|
|
247
|
+
}
|
|
248
|
+
let outcome;
|
|
249
|
+
try {
|
|
250
|
+
outcome = await bootstrapOrRestart(launchTarget, deps, uid, poll);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
reportLifecycleException(launchTarget, deps, "start the LaunchAgent", error);
|
|
254
|
+
return { ok: false, action: "start", reason: "launchctl" };
|
|
255
|
+
}
|
|
256
|
+
const action = outcome.restarted ? "restart" : "start";
|
|
89
257
|
if (!outcome.ok) {
|
|
90
|
-
|
|
258
|
+
reportLaunchFailure(launchTarget, deps, action, outcome.failure);
|
|
259
|
+
return { ok: false, action, reason: "launchctl" };
|
|
91
260
|
}
|
|
92
261
|
deps.stdout(ui.hint("Waiting for the worker to report ready…"));
|
|
93
|
-
|
|
262
|
+
let ready;
|
|
263
|
+
try {
|
|
264
|
+
ready = await pollInstanceReady(launchTarget, deps, {
|
|
265
|
+
...poll,
|
|
266
|
+
sinceMs,
|
|
267
|
+
...(launchTarget.requireTui === true ? { requireTui: true } : {}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
reportLifecycleException(launchTarget, deps, "read the worker readiness trace", error);
|
|
272
|
+
return { ok: false, action, reason: "readiness" };
|
|
273
|
+
}
|
|
94
274
|
if (ready === undefined) {
|
|
95
|
-
|
|
275
|
+
if (!(await snapshotStillMatches(launchTarget, deps))) {
|
|
276
|
+
const stopped = await stopBackgroundUnlocked(launchTarget, deps, poll);
|
|
277
|
+
reportSnapshotDrift(launchTarget, deps);
|
|
278
|
+
if (stopped === 0) {
|
|
279
|
+
deps.stderr(ui.style.dim("The drifted LaunchAgent was stopped before returning control.") + "\n");
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
deps.stderr(ui.style.yellow("The drifted LaunchAgent could not be proven stopped; follow the recovery commands above.") + "\n");
|
|
283
|
+
}
|
|
284
|
+
return { ok: false, action, reason: "snapshot" };
|
|
285
|
+
}
|
|
286
|
+
reportTimeout(launchTarget, deps);
|
|
287
|
+
return { ok: false, action, reason: "timeout" };
|
|
96
288
|
}
|
|
97
|
-
|
|
98
|
-
|
|
289
|
+
const completedAction = outcome.restarted ? "restarted" : "started";
|
|
290
|
+
printInstanceInfo(ready, launchTarget, deps, completedAction);
|
|
291
|
+
return { ok: true, action: completedAction, source: ready };
|
|
99
292
|
}
|
|
100
|
-
async function
|
|
293
|
+
async function prepareLaunchdDirectories(target, deps) {
|
|
101
294
|
// launchd will not create the log file's parent directory, so make both dirs
|
|
102
295
|
// before writing the plist that references them.
|
|
296
|
+
await deps.mkdir(dirname(target.paths.logDir));
|
|
103
297
|
await deps.mkdir(target.paths.logDir);
|
|
104
298
|
await deps.mkdir(target.paths.launchAgentsDir);
|
|
299
|
+
}
|
|
300
|
+
async function writePlist(target, deps) {
|
|
301
|
+
if (target.expectedSnapshot === undefined) {
|
|
302
|
+
throw new Error("A managed LaunchAgent requires an approved background snapshot.");
|
|
303
|
+
}
|
|
105
304
|
const xml = buildPlistXml({
|
|
106
305
|
label: target.label,
|
|
107
306
|
nodePath: target.nodePath,
|
|
@@ -109,9 +308,10 @@ async function writePlist(target, deps) {
|
|
|
109
308
|
configPath: target.configPath,
|
|
110
309
|
cwd: target.cwd,
|
|
111
310
|
...(target.envFile === undefined ? {} : { envFile: target.envFile }),
|
|
311
|
+
expectedBackgroundSnapshot: encodeBackgroundSnapshot(target.expectedSnapshot),
|
|
112
312
|
stdoutPath: target.paths.stdoutPath,
|
|
113
313
|
stderrPath: target.paths.stderrPath,
|
|
114
|
-
|
|
314
|
+
environment: target.environment,
|
|
115
315
|
});
|
|
116
316
|
await deps.writeFile(target.paths.plistPath, xml);
|
|
117
317
|
}
|
|
@@ -126,8 +326,10 @@ async function rotateLaunchdLog(path, deps) {
|
|
|
126
326
|
try {
|
|
127
327
|
size = (await deps.stat(path)).size;
|
|
128
328
|
}
|
|
129
|
-
catch {
|
|
130
|
-
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (isErrno(error, "ENOENT"))
|
|
331
|
+
return;
|
|
332
|
+
throw error;
|
|
131
333
|
}
|
|
132
334
|
if (size <= LAUNCHD_LOG_MAX_BYTES) {
|
|
133
335
|
return;
|
|
@@ -144,52 +346,270 @@ async function rotateLaunchdLog(path, deps) {
|
|
|
144
346
|
}
|
|
145
347
|
}
|
|
146
348
|
}
|
|
349
|
+
async function ensureOwnerPrivateLaunchdDirectory(path) {
|
|
350
|
+
const parent = dirname(path);
|
|
351
|
+
const parentDetails = await lstat(parent);
|
|
352
|
+
assertOwnerDirectory(parentDetails, parent, "LaunchAgent parent");
|
|
353
|
+
try {
|
|
354
|
+
await mkdir(path, { mode: 0o700 });
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (!isErrno(error, "EEXIST"))
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
const parentAfter = await lstat(parent);
|
|
361
|
+
assertOwnerDirectory(parentAfter, parent, "LaunchAgent parent");
|
|
362
|
+
if (!sameFilesystemIdentity(parentDetails, parentAfter)) {
|
|
363
|
+
throw new Error(`LaunchAgent parent ${parent} changed while ${path} was created.`);
|
|
364
|
+
}
|
|
365
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
|
|
366
|
+
try {
|
|
367
|
+
const before = await handle.stat();
|
|
368
|
+
assertOwnerDirectory(before, path, "LaunchAgent directory");
|
|
369
|
+
await handle.chmod(0o700);
|
|
370
|
+
const secured = await handle.stat();
|
|
371
|
+
if (!sameFilesystemIdentity(before, secured)) {
|
|
372
|
+
throw new Error(`LaunchAgent directory ${path} changed while it was secured.`);
|
|
373
|
+
}
|
|
374
|
+
assertOwnerDirectory(secured, path, "LaunchAgent directory");
|
|
375
|
+
if ((secured.mode & 0o077) !== 0) {
|
|
376
|
+
throw new Error(`LaunchAgent directory ${path} must be owner-only.`);
|
|
377
|
+
}
|
|
378
|
+
const current = await lstat(path);
|
|
379
|
+
if (!sameFilesystemIdentity(secured, current)) {
|
|
380
|
+
throw new Error(`LaunchAgent directory ${path} changed while it was secured.`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await handle.close();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function writeOwnerPrivateLaunchdFile(path, data) {
|
|
388
|
+
let existing;
|
|
389
|
+
try {
|
|
390
|
+
existing = await lstat(path);
|
|
391
|
+
assertOwnerRegularFile(existing, path, "LaunchAgent plist");
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (!isErrno(error, "ENOENT"))
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
398
|
+
let handle = await open(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
399
|
+
try {
|
|
400
|
+
await handle.writeFile(data, "utf8");
|
|
401
|
+
await handle.chmod(0o600);
|
|
402
|
+
await handle.sync();
|
|
403
|
+
await handle.close();
|
|
404
|
+
handle = undefined;
|
|
405
|
+
const temporary = await lstat(temporaryPath);
|
|
406
|
+
assertOwnerRegularFile(temporary, temporaryPath, "temporary LaunchAgent plist");
|
|
407
|
+
if ((temporary.mode & 0o777) !== 0o600) {
|
|
408
|
+
throw new Error(`Temporary LaunchAgent plist ${temporaryPath} must be owner-readable and owner-writable only.`);
|
|
409
|
+
}
|
|
410
|
+
let current;
|
|
411
|
+
try {
|
|
412
|
+
current = await lstat(path);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (!isErrno(error, "ENOENT"))
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
if ((existing === undefined) !== (current === undefined)
|
|
419
|
+
|| (existing !== undefined && current !== undefined && !sameFilesystemIdentity(existing, current))) {
|
|
420
|
+
throw new Error(`LaunchAgent plist ${path} changed before the new definition was committed.`);
|
|
421
|
+
}
|
|
422
|
+
await rename(temporaryPath, path);
|
|
423
|
+
const committed = await lstat(path);
|
|
424
|
+
assertOwnerRegularFile(committed, path, "LaunchAgent plist");
|
|
425
|
+
if (!sameFilesystemIdentity(temporary, committed)) {
|
|
426
|
+
throw new Error(`LaunchAgent plist ${path} changed while the new definition was committed.`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
await handle?.close().catch(() => undefined);
|
|
431
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function inspectOwnerPrivateLaunchdLog(path) {
|
|
435
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
436
|
+
try {
|
|
437
|
+
const before = await handle.stat();
|
|
438
|
+
assertOwnerRegularFile(before, path, "LaunchAgent log");
|
|
439
|
+
await handle.chmod(0o600);
|
|
440
|
+
const secured = await handle.stat();
|
|
441
|
+
if (!sameFilesystemIdentity(before, secured)) {
|
|
442
|
+
throw new Error(`LaunchAgent log ${path} changed while it was secured.`);
|
|
443
|
+
}
|
|
444
|
+
assertOwnerRegularFile(secured, path, "LaunchAgent log");
|
|
445
|
+
const current = await lstat(path);
|
|
446
|
+
if (!sameFilesystemIdentity(secured, current)) {
|
|
447
|
+
throw new Error(`LaunchAgent log ${path} changed while it was secured.`);
|
|
448
|
+
}
|
|
449
|
+
return secured;
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
await handle.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function assertOwnerDirectory(details, path, description) {
|
|
456
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
457
|
+
throw new Error(`${description} ${path} must be a real directory.`);
|
|
458
|
+
}
|
|
459
|
+
assertCurrentUserOwns(details, path, description);
|
|
460
|
+
}
|
|
461
|
+
function assertOwnerRegularFile(details, path, description) {
|
|
462
|
+
if (!details.isFile() || details.isSymbolicLink()) {
|
|
463
|
+
throw new Error(`${description} ${path} must be a regular non-symbolic-link file.`);
|
|
464
|
+
}
|
|
465
|
+
if (details.nlink !== 1) {
|
|
466
|
+
throw new Error(`${description} ${path} must have exactly one filesystem link.`);
|
|
467
|
+
}
|
|
468
|
+
assertCurrentUserOwns(details, path, description);
|
|
469
|
+
}
|
|
470
|
+
function assertCurrentUserOwns(details, path, description) {
|
|
471
|
+
if (typeof process.getuid === "function" && details.uid !== process.getuid()) {
|
|
472
|
+
throw new Error(`${description} ${path} is not owned by the current user.`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function sameFilesystemIdentity(left, right) {
|
|
476
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
477
|
+
}
|
|
147
478
|
/**
|
|
148
|
-
* Idempotent: bootstrap when not loaded
|
|
149
|
-
*
|
|
150
|
-
*
|
|
479
|
+
* Idempotent: bootstrap when not loaded. A loaded job is always fully removed
|
|
480
|
+
* before bootstrap so launchd cannot retain stale ProgramArguments or env from
|
|
481
|
+
* the previous plist.
|
|
151
482
|
*/
|
|
152
|
-
async function bootstrapOrRestart(target, deps, uid) {
|
|
153
|
-
|
|
483
|
+
async function bootstrapOrRestart(target, deps, uid, poll) {
|
|
484
|
+
const service = await launchdServiceInfo(deps.runner, target.label, uid);
|
|
485
|
+
if (!service.loaded) {
|
|
154
486
|
const booted = await bootstrap(deps.runner, target.paths.plistPath, uid);
|
|
155
487
|
if (booted.code === 0 || (await isLoaded(deps.runner, target.label, uid))) {
|
|
156
488
|
return { ok: true, restarted: false };
|
|
157
489
|
}
|
|
158
490
|
return { ok: false, restarted: false, failure: booted };
|
|
159
491
|
}
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
492
|
+
const oldSources = await findInstances(target, deps);
|
|
493
|
+
const oldPids = uniquePids([
|
|
494
|
+
service.pid,
|
|
495
|
+
// A cleanly stopped manifest can outlive its process long enough for the OS
|
|
496
|
+
// to reuse that pid for unrelated work. It is historical evidence, not an
|
|
497
|
+
// ownership claim, so never wait for its recycled pid during restart.
|
|
498
|
+
...oldSources
|
|
499
|
+
.filter((source) => source.health !== "stopped")
|
|
500
|
+
.map((source) => source.pid),
|
|
501
|
+
]);
|
|
502
|
+
const removed = await bootout(deps.runner, target.label, uid);
|
|
503
|
+
const unloaded = await pollUntil(deps, poll, async () => !(await launchdServiceInfo(deps.runner, target.label, uid)).loaded);
|
|
504
|
+
if (!unloaded) {
|
|
505
|
+
return {
|
|
506
|
+
ok: false,
|
|
507
|
+
restarted: true,
|
|
508
|
+
failure: lifecycleFailure(removed, "launchd still reports the previous service as loaded after bootout"),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const stopped = await pollUntil(deps, poll, async () => oldPids.every((pid) => !deps.isAlive(pid)));
|
|
512
|
+
if (!stopped) {
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
restarted: true,
|
|
516
|
+
failure: lifecycleFailure(removed, `previous worker pid(s) ${oldPids.join(", ")} remained alive after bootout`),
|
|
517
|
+
};
|
|
163
518
|
}
|
|
164
|
-
// Fallback: a full unload + reload cycle.
|
|
165
|
-
await bootout(deps.runner, target.label, uid);
|
|
166
519
|
const booted = await bootstrap(deps.runner, target.paths.plistPath, uid);
|
|
167
520
|
if (booted.code === 0 || (await isLoaded(deps.runner, target.label, uid))) {
|
|
168
521
|
return { ok: true, restarted: true };
|
|
169
522
|
}
|
|
170
523
|
return { ok: false, restarted: true, failure: booted };
|
|
171
524
|
}
|
|
172
|
-
|
|
525
|
+
async function findOwnershipConflict(target, deps, uid) {
|
|
526
|
+
const [service, sources] = await Promise.all([
|
|
527
|
+
launchdServiceInfo(deps.runner, target.label, uid),
|
|
528
|
+
findInstances(target, deps),
|
|
529
|
+
]);
|
|
530
|
+
const live = sources.filter((source) => source.health !== "stopped" && source.pid !== undefined && deps.isAlive(source.pid));
|
|
531
|
+
if (live.length === 0)
|
|
532
|
+
return undefined;
|
|
533
|
+
if (!service.loaded || service.pid === undefined || !deps.isAlive(service.pid)) {
|
|
534
|
+
return `live matching trace pid(s) ${live.map((source) => source.pid).join(", ")} are not owned by a live ${target.label} launchd job`;
|
|
535
|
+
}
|
|
536
|
+
const foreign = live.filter((source) => source.pid !== service.pid);
|
|
537
|
+
if (foreign.length > 0) {
|
|
538
|
+
return `live matching trace pid(s) ${foreign.map((source) => source.pid).join(", ")} differ from launchd pid ${service.pid}`;
|
|
539
|
+
}
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
async function pollUntil(deps, options, condition) {
|
|
543
|
+
const deadline = deps.now() + options.timeoutMs;
|
|
544
|
+
for (;;) {
|
|
545
|
+
if (await condition())
|
|
546
|
+
return true;
|
|
547
|
+
if (deps.now() >= deadline)
|
|
548
|
+
return false;
|
|
549
|
+
await deps.sleep(options.intervalMs);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function lifecycleFailure(result, detail) {
|
|
553
|
+
return {
|
|
554
|
+
code: result.code === 0 ? 1 : result.code,
|
|
555
|
+
stdout: result.stdout,
|
|
556
|
+
stderr: [result.stderr.trim(), detail].filter((value) => value.length > 0).join("\n"),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function uniquePids(values) {
|
|
560
|
+
return [...new Set(values.filter((value) => value !== undefined && value > 0))];
|
|
561
|
+
}
|
|
562
|
+
export async function stopBackground(target, deps, poll = DEFAULT_POLL) {
|
|
563
|
+
const release = await deps.acquireLifecycleLock(target);
|
|
564
|
+
if (release === undefined) {
|
|
565
|
+
deps.stderr(ui.errorLine(`Another lifecycle command is already active for ${target.label}.`));
|
|
566
|
+
deps.stderr(ui.hint("The LaunchAgent plist was preserved. Wait for that command to finish, then retry."));
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
return await stopBackgroundUnlocked(target, deps, poll);
|
|
571
|
+
}
|
|
572
|
+
finally {
|
|
573
|
+
await release().catch((error) => {
|
|
574
|
+
deps.stderr(ui.errorLine(`Could not release lifecycle lock for ${target.label}: ${error instanceof Error ? error.message : String(error)}`));
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async function stopBackgroundUnlocked(target, deps, poll) {
|
|
173
579
|
const uid = deps.getuid();
|
|
174
|
-
const existing = await
|
|
175
|
-
const
|
|
580
|
+
const existing = await findInstances(target, deps);
|
|
581
|
+
const service = await launchdServiceInfo(deps.runner, target.label, uid);
|
|
582
|
+
const liveTracePids = uniquePids(existing
|
|
583
|
+
.filter((source) => source.health !== "stopped" && source.pid !== undefined && deps.isAlive(source.pid))
|
|
584
|
+
.map((source) => source.pid));
|
|
585
|
+
if (liveTracePids.some((pid) => pid !== service.pid)) {
|
|
586
|
+
deps.stderr(ui.errorLine(`Refusing to stop ${target.label}: matching live trace pid(s) ${liveTracePids.join(", ")} are not owned by that launchd job.`));
|
|
587
|
+
deps.stderr(ui.hint("The LaunchAgent plist was preserved. Stop the unmanaged process explicitly, then retry."));
|
|
588
|
+
return 1;
|
|
589
|
+
}
|
|
176
590
|
const result = await bootout(deps.runner, target.label, uid);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
await
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// afterwards — otherwise we'd silently swallow genuine stop failures.
|
|
183
|
-
if (result.code !== 0 && (await isLoaded(deps.runner, target.label, uid))) {
|
|
184
|
-
deps.stderr(ui.errorLine(`Failed to stop ${target.label}: launchctl bootout exited ${result.code}.`));
|
|
591
|
+
const unloaded = await pollUntil(deps, poll, async () => !(await launchdServiceInfo(deps.runner, target.label, uid)).loaded);
|
|
592
|
+
const ownedPids = uniquePids([service.pid, ...liveTracePids]);
|
|
593
|
+
const stopped = await pollUntil(deps, poll, async () => ownedPids.every((pid) => !deps.isAlive(pid)));
|
|
594
|
+
if (!unloaded || !stopped) {
|
|
595
|
+
deps.stderr(ui.errorLine(`Failed to prove ${target.label} stopped${result.code === 0 ? "" : ` (launchctl bootout exited ${result.code})`}.`));
|
|
185
596
|
const detail = (result.stderr || result.stdout).trim();
|
|
186
597
|
if (detail.length > 0) {
|
|
187
598
|
deps.stderr(ui.style.dim(detail) + "\n");
|
|
188
599
|
}
|
|
189
|
-
|
|
600
|
+
if (!unloaded)
|
|
601
|
+
deps.stderr(ui.style.dim("launchd still reports the service as loaded.\n"));
|
|
602
|
+
if (!stopped)
|
|
603
|
+
deps.stderr(ui.style.dim(`Worker pid(s) ${ownedPids.join(", ")} are still alive.\n`));
|
|
604
|
+
deps.stderr(ui.hint("The LaunchAgent plist was preserved so the service remains recoverable. Inspect status/logs and retry."));
|
|
190
605
|
return 1;
|
|
191
606
|
}
|
|
192
|
-
|
|
607
|
+
// Remove the plist only after both launchd ownership and process death are
|
|
608
|
+
// proven. This prevents a failed stop from destroying its recovery handle.
|
|
609
|
+
await deps.rm(target.paths.plistPath);
|
|
610
|
+
for (const source of existing)
|
|
611
|
+
await maybeUnlinkDeadManifest(target, deps, source);
|
|
612
|
+
deps.stdout(service.loaded
|
|
193
613
|
? `${ui.badge("ok")}${ui.style.bold(`Stopped ${target.label}`)} and removed its LaunchAgent.\n`
|
|
194
614
|
: `${ui.style.dim(`${target.label} was not running; removed its LaunchAgent if present.`)}\n`);
|
|
195
615
|
deps.stdout(ui.keyValue([["config", target.configPath]]));
|
|
@@ -197,10 +617,14 @@ export async function stopBackground(target, deps) {
|
|
|
197
617
|
}
|
|
198
618
|
export async function statusBackground(target, deps) {
|
|
199
619
|
const result = await deps.listTraceSources({ registryDir: target.registryDir, staleAfterMs: target.staleAfterMs });
|
|
200
|
-
const
|
|
620
|
+
const classified = await Promise.all(result.sources.map(async (source) => ({
|
|
621
|
+
source,
|
|
622
|
+
matches: await matchesConfig(source, target.configPath),
|
|
623
|
+
})));
|
|
624
|
+
const current = classified.find((entry) => entry.matches)?.source;
|
|
201
625
|
if (current === undefined) {
|
|
202
626
|
deps.stdout(ui.style.dim(`No running mono-agent instance for ${target.configPath}.`) + "\n");
|
|
203
|
-
deps.stdout(ui.hint(`Start it with: mono-agent start${
|
|
627
|
+
deps.stdout(ui.hint(`Start it with: mono-agent start${commandFlags(target)}`));
|
|
204
628
|
}
|
|
205
629
|
else {
|
|
206
630
|
writeInstanceDetail(current, target, deps);
|
|
@@ -208,7 +632,9 @@ export async function statusBackground(target, deps) {
|
|
|
208
632
|
}
|
|
209
633
|
// Only surface other instances that are live or crashed — cleanly stopped
|
|
210
634
|
// manifests linger in the registry and would just be noise.
|
|
211
|
-
const others =
|
|
635
|
+
const others = classified
|
|
636
|
+
.filter((entry) => !entry.matches && entry.source.health !== "stopped")
|
|
637
|
+
.map((entry) => entry.source);
|
|
212
638
|
if (others.length > 0) {
|
|
213
639
|
deps.stdout("\n" + ui.rule("Other mono-agent instances"));
|
|
214
640
|
for (const source of others) {
|
|
@@ -229,11 +655,19 @@ export async function tailLogs(target, deps, options) {
|
|
|
229
655
|
}
|
|
230
656
|
export async function pollInstanceReady(target, deps, options) {
|
|
231
657
|
const deadline = deps.now() + options.timeoutMs;
|
|
232
|
-
const sinceFloor = options.sinceMs - SINCE_SKEW_MS;
|
|
233
658
|
for (;;) {
|
|
234
|
-
const
|
|
235
|
-
if (
|
|
236
|
-
|
|
659
|
+
const service = await launchdServiceInfo(deps.runner, target.label, deps.getuid());
|
|
660
|
+
if (service.loaded && service.pid !== undefined && deps.isAlive(service.pid)) {
|
|
661
|
+
const matches = await findInstances(target, deps);
|
|
662
|
+
const match = matches.find((source) => source.pid === service.pid);
|
|
663
|
+
if (match !== undefined
|
|
664
|
+
&& isReady(match, options.requireTui === true)
|
|
665
|
+
&& startedAtMs(match) >= options.sinceMs
|
|
666
|
+
&& snapshotMetadataMatches(match, target.expectedSnapshot)
|
|
667
|
+
&& await snapshotStillMatches(target, deps)
|
|
668
|
+
&& (options.requireTui !== true || await deps.probeTui(match))) {
|
|
669
|
+
return match;
|
|
670
|
+
}
|
|
237
671
|
}
|
|
238
672
|
if (deps.now() >= deadline) {
|
|
239
673
|
return undefined;
|
|
@@ -241,15 +675,52 @@ export async function pollInstanceReady(target, deps, options) {
|
|
|
241
675
|
await deps.sleep(options.intervalMs);
|
|
242
676
|
}
|
|
243
677
|
}
|
|
678
|
+
async function snapshotStillMatches(target, deps) {
|
|
679
|
+
if (target.expectedSnapshot === undefined)
|
|
680
|
+
return true;
|
|
681
|
+
const capture = deps.captureSnapshot ?? captureTargetSnapshot;
|
|
682
|
+
try {
|
|
683
|
+
return sameBackgroundSnapshot(await capture(target), target.expectedSnapshot);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async function captureTargetSnapshot(target) {
|
|
690
|
+
return await captureBackgroundSnapshot({
|
|
691
|
+
cwd: target.cwd,
|
|
692
|
+
configPath: target.configPath,
|
|
693
|
+
...(target.envFile === undefined ? {} : { envFile: target.envFile }),
|
|
694
|
+
env: target.configurationEnvironment,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
function snapshotMetadataMatches(source, expected) {
|
|
698
|
+
if (expected === undefined)
|
|
699
|
+
return true;
|
|
700
|
+
const actual = backgroundSnapshotFromMetadata(source.metadata);
|
|
701
|
+
return actual !== undefined && sameBackgroundSnapshot(actual, expected);
|
|
702
|
+
}
|
|
703
|
+
function reportSnapshotDrift(target, deps) {
|
|
704
|
+
deps.stderr(ui.errorLine("The committed config, dotenv, Identity, Soul, MCP config, or durable environment changed before background readiness."));
|
|
705
|
+
deps.stderr(ui.style.dim("No readiness claim was made for a different snapshot.") + "\n");
|
|
706
|
+
reportLaunchRecovery(target, deps);
|
|
707
|
+
}
|
|
244
708
|
export function printInstanceInfo(source, target, deps, verb) {
|
|
245
|
-
const flag =
|
|
709
|
+
const flag = commandFlags(target);
|
|
246
710
|
deps.stdout(`${ui.badge("ok")}${ui.style.bold(`mono-agent ${verb} in the background.`)}\n\n`);
|
|
247
711
|
writeInstanceDetail(source, target, deps);
|
|
248
712
|
deps.stdout("\n" + ui.hint(`Stop with: mono-agent stop${flag} · Logs: mono-agent logs${flag} --follow`));
|
|
249
713
|
}
|
|
250
|
-
async function
|
|
714
|
+
async function findInstances(target, deps) {
|
|
251
715
|
const result = await deps.listTraceSources({ registryDir: target.registryDir, staleAfterMs: target.staleAfterMs });
|
|
252
|
-
|
|
716
|
+
const matches = await Promise.all(result.sources.map(async (source) => ({
|
|
717
|
+
source,
|
|
718
|
+
matches: await matchesConfig(source, target.configPath),
|
|
719
|
+
})));
|
|
720
|
+
return matches.filter((entry) => entry.matches).map((entry) => entry.source);
|
|
721
|
+
}
|
|
722
|
+
async function findInstance(target, deps) {
|
|
723
|
+
return (await findInstances(target, deps))[0];
|
|
253
724
|
}
|
|
254
725
|
async function maybeUnlinkDeadManifest(target, deps, existing) {
|
|
255
726
|
// Only clean up a manifest whose process is already gone; a worker that is
|
|
@@ -259,25 +730,38 @@ async function maybeUnlinkDeadManifest(target, deps, existing) {
|
|
|
259
730
|
}
|
|
260
731
|
await deps.rm(resolve(target.registryDir, `${existing.sourceId}.json`));
|
|
261
732
|
}
|
|
262
|
-
function
|
|
733
|
+
function reportLaunchFailure(target, deps, verb, result) {
|
|
263
734
|
deps.stderr(ui.errorLine(`Failed to ${verb} ${target.label} via launchctl${result === undefined ? "" : ` (exit ${result.code})`}.`));
|
|
264
735
|
const detail = (result?.stderr || result?.stdout || "").trim();
|
|
265
736
|
if (detail.length > 0) {
|
|
266
737
|
deps.stderr(ui.style.dim(detail) + "\n");
|
|
267
738
|
}
|
|
268
|
-
|
|
269
|
-
|
|
739
|
+
reportLaunchRecovery(target, deps);
|
|
740
|
+
}
|
|
741
|
+
function reportOwnershipConflict(target, deps, detail) {
|
|
742
|
+
deps.stderr(ui.errorLine(`Refusing to launch a second worker for ${target.configPath}.`));
|
|
743
|
+
deps.stderr(ui.style.dim(`${detail}.\n`));
|
|
744
|
+
deps.stderr(ui.hint("No LaunchAgent changes were made. Stop the unmanaged process or reconcile launchd ownership, then retry."));
|
|
745
|
+
reportLaunchRecovery(target, deps);
|
|
746
|
+
}
|
|
747
|
+
function reportLifecycleException(target, deps, operation, error) {
|
|
748
|
+
deps.stderr(ui.errorLine(`Failed to ${operation}: ${error instanceof Error ? error.message : String(error)}`));
|
|
749
|
+
deps.stderr(ui.style.dim("The committed agent files were preserved.") + "\n");
|
|
750
|
+
reportLaunchRecovery(target, deps);
|
|
270
751
|
}
|
|
271
752
|
function reportTimeout(target, deps) {
|
|
272
|
-
const flag = configFlag(target);
|
|
273
753
|
deps.stderr(ui.errorLine("mono-agent did not report ready within the timeout."));
|
|
274
|
-
deps.stderr(ui.style.dim("It may still be starting, or it may have failed.
|
|
754
|
+
deps.stderr(ui.style.dim("The committed agent files were preserved. It may still be starting, or it may have failed.") + "\n");
|
|
755
|
+
reportLaunchRecovery(target, deps);
|
|
756
|
+
}
|
|
757
|
+
function reportLaunchRecovery(target, deps) {
|
|
758
|
+
const flags = commandFlags(target);
|
|
759
|
+
deps.stderr(ui.style.dim("Retry or inspect with:") + "\n");
|
|
275
760
|
deps.stderr(` ${ui.style.gray("logs: ")} ${target.paths.stderrPath}\n` +
|
|
276
761
|
` ${target.paths.stdoutPath}\n` +
|
|
277
|
-
` ${ui.style.gray("
|
|
278
|
-
` ${ui.style.gray("status:")} mono-agent status${
|
|
279
|
-
` ${ui.style.gray("
|
|
280
|
-
return 1;
|
|
762
|
+
` ${ui.style.gray("retry: ")} mono-agent start${flags}\n` +
|
|
763
|
+
` ${ui.style.gray("status:")} mono-agent status${flags}\n` +
|
|
764
|
+
` ${ui.style.gray("follow:")} mono-agent logs${flags} --follow\n`);
|
|
281
765
|
}
|
|
282
766
|
function writeInstanceDetail(source, target, deps) {
|
|
283
767
|
deps.stdout(ui.rule("instance"));
|
|
@@ -360,11 +844,32 @@ function formatOtherInstance(source) {
|
|
|
360
844
|
const config = source.configPath ?? "(unknown config)";
|
|
361
845
|
return ` ${ui.healthBadge(source.health)}${source.health.padEnd(8)} pid ${pid.padEnd(7)} ${source.sourceId} ${config}`;
|
|
362
846
|
}
|
|
363
|
-
function matchesConfig(source, configPath) {
|
|
364
|
-
|
|
847
|
+
async function matchesConfig(source, configPath) {
|
|
848
|
+
if (source.configPath === undefined)
|
|
849
|
+
return false;
|
|
850
|
+
return await canonicalBackgroundConfigPath(process.cwd(), source.configPath) === configPath;
|
|
851
|
+
}
|
|
852
|
+
function isReady(source, requireTui) {
|
|
853
|
+
if (source.health !== "running" || metadataReason(source) !== "startup-complete")
|
|
854
|
+
return false;
|
|
855
|
+
if (source.memoryHealth?.status === "unhealthy")
|
|
856
|
+
return false;
|
|
857
|
+
const channels = channelRecords(source);
|
|
858
|
+
if (channels.some((channel) => channel.kind === "failed"))
|
|
859
|
+
return false;
|
|
860
|
+
if (!requireTui)
|
|
861
|
+
return true;
|
|
862
|
+
if (channels.some((channel) => typeof channel.kind === "string"
|
|
863
|
+
&& ["failed", "degraded", "waiting_for_config"].includes(channel.kind)))
|
|
864
|
+
return false;
|
|
865
|
+
return tuiEndpoint(source) !== undefined;
|
|
365
866
|
}
|
|
366
|
-
function
|
|
367
|
-
|
|
867
|
+
function channelRecords(source) {
|
|
868
|
+
const channels = source.metadata?.channels;
|
|
869
|
+
if (channels === null || typeof channels !== "object")
|
|
870
|
+
return [];
|
|
871
|
+
return Object.values(channels)
|
|
872
|
+
.filter((value) => value !== null && typeof value === "object");
|
|
368
873
|
}
|
|
369
874
|
function metadataReason(source) {
|
|
370
875
|
const reason = source.metadata?.reason;
|
|
@@ -519,7 +1024,244 @@ function stringFieldAsList(record, key) {
|
|
|
519
1024
|
/** ` --config <path>` when a non-default config is in play, else empty. */
|
|
520
1025
|
function configFlag(target) {
|
|
521
1026
|
const defaultPath = resolve(target.cwd, "mono-agent.config.json");
|
|
522
|
-
return target.configPath === defaultPath ? "" : ` --config ${target.configPath}`;
|
|
1027
|
+
return target.configPath === defaultPath ? "" : ` --config ${shellCommandArgument(target.configPath)}`;
|
|
1028
|
+
}
|
|
1029
|
+
function commandFlags(target) {
|
|
1030
|
+
return `${configFlag(target)}${target.envFile === undefined ? "" : ` --env-file ${shellCommandArgument(target.envFile)}`}`;
|
|
1031
|
+
}
|
|
1032
|
+
function shellCommandArgument(value) {
|
|
1033
|
+
return /^[a-zA-Z0-9_./:@%+=,-]+$/u.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
1034
|
+
}
|
|
1035
|
+
export async function acquireFilesystemLifecycleLock(target, options = {}) {
|
|
1036
|
+
const pid = options.pid ?? process.pid;
|
|
1037
|
+
const now = options.now ?? (() => Date.now());
|
|
1038
|
+
const isProcessAlive = options.isProcessAlive ?? processIsAlive;
|
|
1039
|
+
const incarnation = options.processIncarnation ?? await currentProcessIncarnation();
|
|
1040
|
+
const isSameProcess = options.isSameProcessIncarnation ?? matchesProcessIncarnation;
|
|
1041
|
+
const ownerlessGraceMs = options.ownerlessGraceMs ?? 5 * 60_000;
|
|
1042
|
+
const randomToken = options.randomToken ?? randomUUID;
|
|
1043
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
1044
|
+
throw new Error(`Lifecycle lock pid must be a positive safe integer; received ${String(pid)}.`);
|
|
1045
|
+
}
|
|
1046
|
+
if (!Number.isFinite(ownerlessGraceMs) || ownerlessGraceMs < 0) {
|
|
1047
|
+
throw new Error("Lifecycle lock ownerless grace must be a non-negative finite duration.");
|
|
1048
|
+
}
|
|
1049
|
+
const managedRoot = dirname(target.paths.logDir);
|
|
1050
|
+
const locksDir = resolve(managedRoot, "locks");
|
|
1051
|
+
for (const path of [managedRoot, locksDir]) {
|
|
1052
|
+
await ensureOwnerPrivateLaunchdDirectory(path);
|
|
1053
|
+
}
|
|
1054
|
+
const lockDir = resolve(locksDir, `${target.label}.lock`);
|
|
1055
|
+
const ownerPath = resolve(lockDir, "owner.json");
|
|
1056
|
+
const token = `${pid}-${now()}-${randomToken()}`;
|
|
1057
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
1058
|
+
let createdIdentity;
|
|
1059
|
+
try {
|
|
1060
|
+
await mkdir(lockDir, { mode: 0o700 });
|
|
1061
|
+
const created = await assertLifecycleLockDirectory(lockDir);
|
|
1062
|
+
createdIdentity = lifecycleLockIdentity(created);
|
|
1063
|
+
await writeFile(ownerPath, `${JSON.stringify({
|
|
1064
|
+
pid,
|
|
1065
|
+
token,
|
|
1066
|
+
createdAt: new Date(now()).toISOString(),
|
|
1067
|
+
incarnation,
|
|
1068
|
+
})}\n`, {
|
|
1069
|
+
encoding: "utf8",
|
|
1070
|
+
flag: "wx",
|
|
1071
|
+
mode: 0o600,
|
|
1072
|
+
});
|
|
1073
|
+
if (!(await sameLifecycleLockDirectory(lockDir, createdIdentity))) {
|
|
1074
|
+
throw new Error(`Lifecycle lock ${lockDir} changed while its owner record was written.`);
|
|
1075
|
+
}
|
|
1076
|
+
const ownedIdentity = createdIdentity;
|
|
1077
|
+
return async () => {
|
|
1078
|
+
try {
|
|
1079
|
+
if (!(await sameLifecycleLockDirectory(lockDir, ownedIdentity))) {
|
|
1080
|
+
throw new Error(`Lifecycle lock ${lockDir} is no longer owned by this command.`);
|
|
1081
|
+
}
|
|
1082
|
+
const current = JSON.parse(await readFile(ownerPath, "utf8"));
|
|
1083
|
+
if (current.token !== token) {
|
|
1084
|
+
throw new Error(`Lifecycle lock ${lockDir} is no longer owned by this command.`);
|
|
1085
|
+
}
|
|
1086
|
+
const releasedPath = resolve(locksDir, `${target.label}.released-${now()}-${pid}-${randomToken()}`);
|
|
1087
|
+
if (!(await sameLifecycleLockDirectory(lockDir, ownedIdentity))) {
|
|
1088
|
+
throw new Error(`Lifecycle lock ${lockDir} changed during release.`);
|
|
1089
|
+
}
|
|
1090
|
+
await rename(lockDir, releasedPath);
|
|
1091
|
+
const moved = await lstat(releasedPath);
|
|
1092
|
+
if (!sameLifecycleLockIdentity(moved, ownedIdentity)) {
|
|
1093
|
+
await rename(releasedPath, lockDir).catch(() => undefined);
|
|
1094
|
+
throw new Error(`Lifecycle lock ${lockDir} changed during release.`);
|
|
1095
|
+
}
|
|
1096
|
+
await rm(releasedPath, { recursive: true, force: true });
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
if (!isErrno(error, "ENOENT"))
|
|
1100
|
+
throw error;
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
if (createdIdentity !== undefined) {
|
|
1106
|
+
await removeSameLifecycleLockDirectory(lockDir, createdIdentity).catch(() => undefined);
|
|
1107
|
+
}
|
|
1108
|
+
if (!isErrno(error, "EEXIST"))
|
|
1109
|
+
throw error;
|
|
1110
|
+
}
|
|
1111
|
+
let observed;
|
|
1112
|
+
try {
|
|
1113
|
+
observed = await assertLifecycleLockDirectory(lockDir);
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
if (isErrno(error, "ENOENT"))
|
|
1117
|
+
continue;
|
|
1118
|
+
return undefined;
|
|
1119
|
+
}
|
|
1120
|
+
const observedIdentity = lifecycleLockIdentity(observed);
|
|
1121
|
+
const owner = await readLifecycleLockOwner(ownerPath);
|
|
1122
|
+
if (!(await sameLifecycleLockDirectory(lockDir, observedIdentity)))
|
|
1123
|
+
continue;
|
|
1124
|
+
if (owner !== undefined) {
|
|
1125
|
+
let sameOwner = true;
|
|
1126
|
+
try {
|
|
1127
|
+
sameOwner = owner.incarnation === undefined
|
|
1128
|
+
? isProcessAlive(owner.pid)
|
|
1129
|
+
: await isSameProcess(owner.pid, owner.incarnation);
|
|
1130
|
+
}
|
|
1131
|
+
catch {
|
|
1132
|
+
// A failed identity probe cannot authorize stealing another command's lock.
|
|
1133
|
+
sameOwner = true;
|
|
1134
|
+
}
|
|
1135
|
+
if (sameOwner)
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1138
|
+
if (owner === undefined) {
|
|
1139
|
+
const lockAgeMs = now() - observed.mtimeMs;
|
|
1140
|
+
// Another process may be between its atomic mkdir and owner.json write.
|
|
1141
|
+
// Never steal that fresh ownerless lock; only recover abandoned debris.
|
|
1142
|
+
if (lockAgeMs < ownerlessGraceMs)
|
|
1143
|
+
return undefined;
|
|
1144
|
+
}
|
|
1145
|
+
if (!(await sameLifecycleLockDirectory(lockDir, observedIdentity)))
|
|
1146
|
+
continue;
|
|
1147
|
+
await options.beforeStaleLockRename?.();
|
|
1148
|
+
const stalePath = resolve(locksDir, `${target.label}.stale-${now()}-${pid}-${randomToken()}`);
|
|
1149
|
+
try {
|
|
1150
|
+
await rename(lockDir, stalePath);
|
|
1151
|
+
}
|
|
1152
|
+
catch (error) {
|
|
1153
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "EEXIST"))
|
|
1154
|
+
continue;
|
|
1155
|
+
return undefined;
|
|
1156
|
+
}
|
|
1157
|
+
const moved = await lstat(stalePath);
|
|
1158
|
+
if (!sameLifecycleLockIdentity(moved, observedIdentity)) {
|
|
1159
|
+
// A contender replaced the path after our final identity check. Restore
|
|
1160
|
+
// the directory we moved and fail closed; never delete its live lock.
|
|
1161
|
+
await rename(stalePath, lockDir).catch(() => undefined);
|
|
1162
|
+
return undefined;
|
|
1163
|
+
}
|
|
1164
|
+
await rm(stalePath, { recursive: true, force: true });
|
|
1165
|
+
}
|
|
1166
|
+
return undefined;
|
|
1167
|
+
}
|
|
1168
|
+
async function assertLifecycleLockDirectory(path) {
|
|
1169
|
+
const details = await lstat(path);
|
|
1170
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
1171
|
+
throw new Error(`Lifecycle lock ${path} must be a real directory.`);
|
|
1172
|
+
}
|
|
1173
|
+
if (typeof process.getuid === "function" && details.uid !== process.getuid()) {
|
|
1174
|
+
throw new Error(`Lifecycle lock ${path} is not owned by the current user.`);
|
|
1175
|
+
}
|
|
1176
|
+
return details;
|
|
1177
|
+
}
|
|
1178
|
+
function lifecycleLockIdentity(details) {
|
|
1179
|
+
return { dev: details.dev, ino: details.ino };
|
|
1180
|
+
}
|
|
1181
|
+
function sameLifecycleLockIdentity(details, identity) {
|
|
1182
|
+
return details.dev === identity.dev && details.ino === identity.ino;
|
|
1183
|
+
}
|
|
1184
|
+
async function sameLifecycleLockDirectory(path, identity) {
|
|
1185
|
+
try {
|
|
1186
|
+
const details = await assertLifecycleLockDirectory(path);
|
|
1187
|
+
return sameLifecycleLockIdentity(details, identity);
|
|
1188
|
+
}
|
|
1189
|
+
catch {
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
async function removeSameLifecycleLockDirectory(path, identity) {
|
|
1194
|
+
if (await sameLifecycleLockDirectory(path, identity)) {
|
|
1195
|
+
await rm(path, { recursive: true, force: true });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
async function readLifecycleLockOwner(path) {
|
|
1199
|
+
try {
|
|
1200
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
1201
|
+
const incarnation = processIncarnationFromJson(parsed.incarnation);
|
|
1202
|
+
return typeof parsed.pid === "number" && Number.isSafeInteger(parsed.pid) && parsed.pid > 0
|
|
1203
|
+
? {
|
|
1204
|
+
pid: parsed.pid,
|
|
1205
|
+
...(incarnation === undefined ? {} : { incarnation }),
|
|
1206
|
+
}
|
|
1207
|
+
: undefined;
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
function processIsAlive(pid) {
|
|
1214
|
+
try {
|
|
1215
|
+
process.kill(pid, 0);
|
|
1216
|
+
return true;
|
|
1217
|
+
}
|
|
1218
|
+
catch (error) {
|
|
1219
|
+
return isErrno(error, "EPERM");
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
async function probeTuiEndpoint(source) {
|
|
1223
|
+
const baseUrl = tuiEndpoint(source);
|
|
1224
|
+
if (baseUrl === undefined)
|
|
1225
|
+
return false;
|
|
1226
|
+
let url;
|
|
1227
|
+
try {
|
|
1228
|
+
url = new URL(`${baseUrl.replace(/\/+$/u, "")}/v1/info`);
|
|
1229
|
+
}
|
|
1230
|
+
catch {
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1233
|
+
if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname))
|
|
1234
|
+
return false;
|
|
1235
|
+
try {
|
|
1236
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
|
|
1237
|
+
// Authenticated adapters return 401/403 without the secret. That still
|
|
1238
|
+
// proves the advertised loopback listener is reachable; an open endpoint
|
|
1239
|
+
// additionally proves it belongs to the expected worker pid.
|
|
1240
|
+
if (response.status === 401 || response.status === 403)
|
|
1241
|
+
return true;
|
|
1242
|
+
if (!response.ok)
|
|
1243
|
+
return false;
|
|
1244
|
+
const body = await response.json();
|
|
1245
|
+
return typeof source.pid === "number" && body.pid === source.pid;
|
|
1246
|
+
}
|
|
1247
|
+
catch {
|
|
1248
|
+
return false;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
function isLoopbackHostname(hostname) {
|
|
1252
|
+
return hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost";
|
|
1253
|
+
}
|
|
1254
|
+
function tuiEndpoint(source) {
|
|
1255
|
+
const channels = source.metadata?.channels;
|
|
1256
|
+
if (channels === null || typeof channels !== "object")
|
|
1257
|
+
return undefined;
|
|
1258
|
+
const tui = channels.tui;
|
|
1259
|
+
if (tui === null || typeof tui !== "object")
|
|
1260
|
+
return undefined;
|
|
1261
|
+
const record = tui;
|
|
1262
|
+
return record.kind === "running" && typeof record.baseUrl === "string" && record.baseUrl.trim().length > 0
|
|
1263
|
+
? record.baseUrl
|
|
1264
|
+
: undefined;
|
|
523
1265
|
}
|
|
524
1266
|
function formatChannels(source) {
|
|
525
1267
|
const channels = source.metadata?.channels;
|