machine-bridge-mcp 3.0.0-beta.21 → 3.0.0-beta.26
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 +134 -0
- package/CONTRIBUTING.md +3 -3
- package/GOVERNANCE.md +2 -2
- package/README.md +24 -6
- package/browser-extension/manifest.json +1 -1
- package/docs/AGENT_CONTEXT.md +10 -7
- package/docs/ARCHITECTURE.md +35 -22
- package/docs/AUDIT.md +85 -1
- package/docs/CLIENTS.md +6 -2
- package/docs/ENGINEERING.md +31 -9
- package/docs/LOCAL_AUTOMATION.md +4 -2
- package/docs/LOGGING.md +8 -8
- package/docs/OPERATIONS.md +43 -17
- package/docs/PRIVACY.md +18 -4
- package/docs/PROJECT_STANDARDS.md +2 -2
- package/docs/RELEASING.md +35 -11
- package/docs/TESTING.md +36 -16
- package/docs/THREAT_MODEL.md +20 -5
- package/docs/TOOL_REFERENCE.md +18 -12
- package/docs/UPGRADING.md +32 -0
- package/package.json +15 -6
- package/scripts/check-plan.mjs +8 -0
- package/scripts/coverage-check.mjs +30 -1
- package/scripts/foreground-daemon-recovery.mjs +88 -0
- package/scripts/github-release.mjs +22 -16
- package/scripts/install-published-prerelease.mjs +7 -7
- package/scripts/official-mcp-conformance.mjs +243 -0
- package/scripts/persistent-activation-process.mjs +36 -0
- package/scripts/release-candidate-manifest.mjs +12 -0
- package/scripts/release-publication-guard.mjs +65 -0
- package/scripts/release-state.mjs +1 -1
- package/scripts/sbom-check.mjs +99 -0
- package/scripts/start-release-candidate.mjs +39 -13
- package/src/local/agent-context-projection.mjs +26 -7
- package/src/local/agent-context.mjs +25 -4
- package/src/local/autostart-log-maintenance.mjs +36 -0
- package/src/local/capability-observer.mjs +5 -0
- package/src/local/child-process-settlement.mjs +103 -0
- package/src/local/cli-activate.mjs +42 -5
- package/src/local/cli-service.mjs +55 -5
- package/src/local/cli.mjs +59 -10
- package/src/local/daemon-process.mjs +24 -3
- package/src/local/delegated-process-sandbox.mjs +1 -0
- package/src/local/execution-routing.mjs +231 -0
- package/src/local/git-service.mjs +3 -1
- package/src/local/job-runner.mjs +55 -19
- package/src/local/macos-trust-broker.mjs +7 -0
- package/src/local/managed-job-runner-claim.mjs +54 -0
- package/src/local/managed-job-runner.mjs +13 -2
- package/src/local/process-execution.mjs +2 -2
- package/src/local/process-identity.mjs +11 -0
- package/src/local/process-tree-ownership-types.d.ts +37 -0
- package/src/local/process-tree-ownership.mjs +49 -41
- package/src/local/process-tree.mjs +1 -1
- package/src/local/relay-call-recovery.mjs +40 -21
- package/src/local/runtime-activation.mjs +357 -38
- package/src/local/runtime-capabilities.mjs +22 -6
- package/src/local/runtime-diagnostics.mjs +9 -2
- package/src/local/runtime.mjs +18 -4
- package/src/local/service-convergence.mjs +33 -0
- package/src/local/service-owner.mjs +147 -0
- package/src/local/service-restart-handoff.mjs +22 -8
- package/src/local/service-runtime.mjs +145 -0
- package/src/local/service.mjs +143 -25
- package/src/local/state.mjs +104 -7
- package/src/local/stdio.mjs +139 -45
- package/src/local/system-network-route.mjs +76 -0
- package/src/local/tool-executor.mjs +24 -6
- package/src/local/tools.mjs +6 -5
- package/src/local/windows-service-convergence.mjs +49 -0
- package/src/local/windows-service.mjs +30 -53
- package/src/shared/mcp-protocol.d.mts +27 -0
- package/src/shared/mcp-protocol.mjs +256 -0
- package/src/shared/mcp-subscriptions.d.mts +4 -0
- package/src/shared/mcp-subscriptions.mjs +59 -0
- package/src/shared/relay-contract.json +1 -0
- package/src/shared/result-projection.d.mts +2 -1
- package/src/shared/result-projection.mjs +13 -2
- package/src/shared/server-metadata.json +11 -4
- package/src/shared/tool-argument-validation.d.mts +17 -0
- package/src/shared/tool-argument-validation.mjs +325 -0
- package/src/shared/tool-catalog.json +18 -12
- package/src/worker/durable-stream-calls.ts +12 -24
- package/src/worker/http.ts +36 -2
- package/src/worker/index.ts +181 -165
- package/src/worker/mcp-http-contract.ts +276 -0
- package/src/worker/mcp-jsonrpc.ts +12 -6
- package/src/worker/mcp-legacy-dispatch.ts +104 -0
- package/src/worker/mcp-modern-controller.ts +199 -0
- package/src/worker/mcp-modern-proxy.ts +126 -0
- package/src/worker/mcp-modern-stream.ts +71 -0
- package/src/worker/mcp-session.ts +12 -3
- package/src/worker/mcp-stream-proxy-contract.ts +67 -0
- package/src/worker/mcp-stream-proxy.ts +17 -60
- package/src/worker/mcp-tool-call-input.ts +23 -0
- package/src/worker/tool-catalog.ts +29 -1
- package/src/worker/tool-timeout.ts +53 -12
- package/src/worker/worker-mcp-config.ts +23 -0
- package/src/worker/worker-metadata.ts +10 -1
- package/src/worker/worker-runtime-config.ts +19 -0
- package/src/worker/worker-static-routes.ts +7 -2
- package/tsconfig.local.json +7 -1
|
@@ -8,6 +8,39 @@ export async function waitForActiveStatus(
|
|
|
8
8
|
return waitForStatus(readStatus, (status) => status?.active === true, { attempts, delayMs, sleep });
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
|
|
12
|
+
export async function waitForStableActiveStatus(
|
|
13
|
+
readStatus,
|
|
14
|
+
{ attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, stableSamples = 5, sleep = delay, identity = stableIdentity } = {},
|
|
15
|
+
) {
|
|
16
|
+
if (typeof readStatus !== "function") throw new TypeError("readStatus must be a function");
|
|
17
|
+
if (typeof identity !== "function") throw new TypeError("identity must be a function");
|
|
18
|
+
const maximum = Number.isInteger(attempts) && attempts > 0 ? attempts : DEFAULT_ATTEMPTS;
|
|
19
|
+
const required = Number.isInteger(stableSamples) && stableSamples > 0 ? stableSamples : 5;
|
|
20
|
+
let status = null;
|
|
21
|
+
let consecutive = 0;
|
|
22
|
+
let priorIdentity = null;
|
|
23
|
+
for (let attempt = 1; attempt <= maximum; attempt += 1) {
|
|
24
|
+
status = await readStatus();
|
|
25
|
+
const currentIdentity = status?.active === true ? identity(status) : null;
|
|
26
|
+
const identityChanged = consecutive > 0
|
|
27
|
+
&& (priorIdentity !== null || currentIdentity !== null)
|
|
28
|
+
&& currentIdentity !== priorIdentity;
|
|
29
|
+
if (status?.active === true && !identityChanged) consecutive += 1;
|
|
30
|
+
else consecutive = status?.active === true ? 1 : 0;
|
|
31
|
+
priorIdentity = currentIdentity;
|
|
32
|
+
if (consecutive >= required) return { stable: true, status, samples: consecutive, attempts: attempt };
|
|
33
|
+
if (attempt < maximum) await sleep(delayMs);
|
|
34
|
+
}
|
|
35
|
+
return { stable: false, status, samples: consecutive, attempts: maximum };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stableIdentity(status) {
|
|
39
|
+
if (Number.isInteger(status?.pid) && status.pid > 0) return `pid:${status.pid}`;
|
|
40
|
+
if (typeof status?.last_run_time === "string" && status.last_run_time) return `run:${status.last_run_time}`;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
11
44
|
export async function waitForInactiveStatus(
|
|
12
45
|
readStatus,
|
|
13
46
|
{ attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { existsSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
7
|
+
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
8
|
+
import { ensureOwnerOnlyDir, machineServiceControlRoot } from "./state.mjs";
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
const MAX_BYTES = 64 * 1024;
|
|
12
|
+
const FILE_NAME = "service-owner.json";
|
|
13
|
+
const VERSION = /^[0-9A-Za-z.+_-]{1,64}$/;
|
|
14
|
+
const TRANSACTION = /^[A-Za-z0-9_-]{20,128}$/;
|
|
15
|
+
|
|
16
|
+
/** @typedef {{ schemaVersion: 1, status: "pending" | "committed", transactionId: string, workspace: string, stateRoot: string, entryScript: string, version: string, createdAt: string, committedAt: string | null }} ServiceOwner */
|
|
17
|
+
/** @typedef {{ workspace?: unknown, stateRoot?: unknown, entryScript?: unknown, version?: unknown }} ServiceOwnerSpec */
|
|
18
|
+
/** @typedef {{ controlRoot?: string }} ServiceOwnerOptions */
|
|
19
|
+
|
|
20
|
+
/** @param {ServiceOwnerOptions} [options] */ export function serviceOwnerPath(options = {}) {
|
|
21
|
+
return path.join(machineServiceControlRoot(options), FILE_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {ServiceOwnerSpec} spec @param {ServiceOwnerOptions} [options] */ export function beginServiceOwnerUpdate(spec, options = {}) {
|
|
25
|
+
const file = serviceOwnerPath(options);
|
|
26
|
+
ensureOwnerOnlyDir(path.dirname(file));
|
|
27
|
+
const previous = existsSync(file) ? readOwnerSnapshot(file) : null;
|
|
28
|
+
const transactionId = randomBytes(24).toString("base64url");
|
|
29
|
+
const createdAt = new Date().toISOString();
|
|
30
|
+
const pending = normalizeOwner({
|
|
31
|
+
schemaVersion: SCHEMA_VERSION,
|
|
32
|
+
status: "pending",
|
|
33
|
+
transactionId,
|
|
34
|
+
workspace: spec?.workspace,
|
|
35
|
+
stateRoot: spec?.stateRoot,
|
|
36
|
+
entryScript: spec?.entryScript,
|
|
37
|
+
version: spec?.version,
|
|
38
|
+
createdAt,
|
|
39
|
+
committedAt: null,
|
|
40
|
+
});
|
|
41
|
+
writeOwner(file, pending);
|
|
42
|
+
let closed = false;
|
|
43
|
+
return {
|
|
44
|
+
path: file,
|
|
45
|
+
owner: pending,
|
|
46
|
+
commit() {
|
|
47
|
+
if (closed) throw new Error("service owner transaction is already closed");
|
|
48
|
+
assertCurrentTransaction(file, transactionId, "pending");
|
|
49
|
+
const committed = normalizeOwner({ ...pending, status: "committed", committedAt: new Date().toISOString() });
|
|
50
|
+
writeOwner(file, committed);
|
|
51
|
+
closed = true;
|
|
52
|
+
return committed;
|
|
53
|
+
},
|
|
54
|
+
rollback() {
|
|
55
|
+
if (closed) return false;
|
|
56
|
+
assertCurrentTransaction(file, transactionId, "pending");
|
|
57
|
+
if (previous) replaceFileAtomicallySync(file, previous.raw, { mode: 0o600 });
|
|
58
|
+
else rmSync(file, { force: true });
|
|
59
|
+
closed = true;
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {ServiceOwnerOptions} [options] @returns {ServiceOwner | null} */ export function loadServiceOwner(options = {}) {
|
|
66
|
+
const file = serviceOwnerPath(options);
|
|
67
|
+
return existsSync(file) ? readOwnerSnapshot(file).owner : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {ServiceOwnerOptions} [options] @returns {ServiceOwner | null} */ export function loadCommittedServiceOwner(options = {}) {
|
|
71
|
+
const owner = loadServiceOwner(options);
|
|
72
|
+
if (!owner) return null;
|
|
73
|
+
if (owner.status !== "committed") {
|
|
74
|
+
throw new Error("machine service owner transition is incomplete; reinstall the service before starting it");
|
|
75
|
+
}
|
|
76
|
+
return owner;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {ServiceOwnerOptions} [options] */ export function removeServiceOwner(options = {}) {
|
|
80
|
+
const file = serviceOwnerPath(options);
|
|
81
|
+
if (!existsSync(file)) return false;
|
|
82
|
+
readBoundedRegularFileSync(file, MAX_BYTES, "machine service owner file", {
|
|
83
|
+
verifyPathIdentity: true, rejectMultipleLinks: true,
|
|
84
|
+
});
|
|
85
|
+
rmSync(file, { force: true });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {string} file @returns {{ owner: ServiceOwner, raw: Buffer }} */ function readOwnerSnapshot(file) {
|
|
90
|
+
const raw = readBoundedRegularFileSync(file, MAX_BYTES, "machine service owner file");
|
|
91
|
+
let parsed;
|
|
92
|
+
try { parsed = JSON.parse(raw.toString("utf8")); }
|
|
93
|
+
catch (error) { throw new Error("machine service owner file is not valid JSON", { cause: error }); }
|
|
94
|
+
return { owner: normalizeOwner(parsed), raw };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {string} file @param {ServiceOwner} owner */ function writeOwner(file, owner) {
|
|
98
|
+
const content = `${JSON.stringify(owner, null, 2)}\n`;
|
|
99
|
+
if (Buffer.byteLength(content) > MAX_BYTES) throw new Error("machine service owner record exceeds the size limit");
|
|
100
|
+
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {string} file @param {string} transactionId @param {"pending" | "committed"} status */ function assertCurrentTransaction(file, transactionId, status) {
|
|
104
|
+
const current = readOwnerSnapshot(file).owner;
|
|
105
|
+
if (current.transactionId !== transactionId || current.status !== status) {
|
|
106
|
+
throw new Error("machine service owner transaction changed before completion");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {unknown} value @returns {ServiceOwner} */ function normalizeOwner(value) {
|
|
111
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("machine service owner must be an object");
|
|
112
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
113
|
+
if (record.schemaVersion !== SCHEMA_VERSION) throw new Error("machine service owner schema is unsupported");
|
|
114
|
+
if (record.status !== "pending" && record.status !== "committed") throw new Error("machine service owner status is invalid");
|
|
115
|
+
if (typeof record.transactionId !== "string" || !TRANSACTION.test(record.transactionId)) throw new Error("machine service owner transaction id is invalid");
|
|
116
|
+
if (typeof record.version !== "string" || !VERSION.test(record.version)) throw new Error("machine service owner version is invalid");
|
|
117
|
+
const workspace = canonicalDirectory(record.workspace, "workspace");
|
|
118
|
+
const stateRoot = canonicalDirectory(record.stateRoot, "state root");
|
|
119
|
+
const entryScript = canonicalFile(record.entryScript, "entry script");
|
|
120
|
+
const createdAt = isoTimestamp(record.createdAt, "createdAt");
|
|
121
|
+
const committedAt = record.status === "committed" ? isoTimestamp(record.committedAt, "committedAt") : null;
|
|
122
|
+
return { schemaVersion: SCHEMA_VERSION, status: record.status, transactionId: record.transactionId,
|
|
123
|
+
workspace, stateRoot, entryScript, version: record.version, createdAt, committedAt };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {unknown} value @param {string} label */ function canonicalDirectory(value, label) {
|
|
127
|
+
const canonical = canonicalPath(value, label);
|
|
128
|
+
if (!statSync(canonical).isDirectory()) throw new Error(`machine service owner ${label} is not a directory`);
|
|
129
|
+
return canonical;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {unknown} value @param {string} label */ function canonicalFile(value, label) {
|
|
133
|
+
const canonical = canonicalPath(value, label);
|
|
134
|
+
if (!statSync(canonical).isFile()) throw new Error(`machine service owner ${label} is not a file`);
|
|
135
|
+
return canonical;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {unknown} value @param {string} label */ function canonicalPath(value, label) {
|
|
139
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`machine service owner ${label} must be absolute`);
|
|
140
|
+
try { return realpathSync.native ? realpathSync.native(value) : realpathSync(value); }
|
|
141
|
+
catch (error) { throw new Error(`machine service owner ${label} is unavailable`, { cause: error }); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {unknown} value @param {string} label */ function isoTimestamp(value, label) {
|
|
145
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error(`machine service owner ${label} is invalid`);
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { restartOwnedServiceRuntime } from "./service-runtime.mjs";
|
|
4
4
|
import { createLogger } from "./log.mjs";
|
|
5
|
+
import { acquireMachineServiceLockWithWait } from "./state.mjs";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_DELAY_MS = 300;
|
|
7
8
|
const MAX_DELAY_MS = 5_000;
|
|
8
9
|
|
|
9
10
|
export async function runServiceRestartHandoff(options = {}) {
|
|
10
11
|
const delayMs = boundedDelay(options.delayMs ?? process.argv[2]);
|
|
11
|
-
const restart = typeof options.
|
|
12
|
+
const restart = typeof options.restartServiceRuntime === "function"
|
|
13
|
+
? options.restartServiceRuntime
|
|
14
|
+
: typeof options.restartAutostart === "function" ? options.restartAutostart : restartOwnedServiceRuntime;
|
|
12
15
|
const sleep = typeof options.sleep === "function" ? options.sleep : delay;
|
|
13
16
|
const logger = options.logger || createLogger({ component: "service-restart", level: "warn", format: "json", stderrOnly: true });
|
|
17
|
+
const acquireLock = typeof options.acquireServiceLock === "function"
|
|
18
|
+
? options.acquireServiceLock
|
|
19
|
+
: () => acquireMachineServiceLockWithWait({ operation: "service-restart", logger, ...(options.serviceLockOptions || {}) });
|
|
14
20
|
await sleep(delayMs);
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
const lock = await acquireLock();
|
|
22
|
+
if (!lock?.acquired || typeof lock.release !== "function") {
|
|
23
|
+
throw new Error("machine-service operation lock could not be acquired for restart");
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const result = await restart({ logger });
|
|
27
|
+
if (result?.ok !== true) {
|
|
28
|
+
const error = new Error(`service restart handoff failed (${result?.reason || result?.provider || "unknown"})`);
|
|
29
|
+
error.result = result;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
} finally {
|
|
34
|
+
lock.release();
|
|
20
35
|
}
|
|
21
|
-
return result;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
export async function serviceRestartHandoffMain(options = {}) {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { inspectWorkspaceDaemon } from "./daemon-process.mjs";
|
|
4
|
+
import { loadCommittedServiceOwner } from "./service-owner.mjs";
|
|
5
|
+
import { autostartStatus, restartAutostart, startAutostart, stopAutostart } from "./service.mjs";
|
|
6
|
+
import { loadState } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {{ status: string, workspace: string, stateRoot: string, entryScript: string, version: string }} ServiceOwner */
|
|
9
|
+
/** @typedef {Record<string, unknown> & { ok?: boolean, active?: boolean | null, provider?: string, reason?: string }} ProviderResult */
|
|
10
|
+
/** @typedef {Record<string, unknown> & { alive?: boolean, verified_service_daemon?: boolean, mode?: string, startup_readiness_verified?: boolean, identity_reason?: string }} DaemonStatus */
|
|
11
|
+
/** @typedef {{ expectedVersion: string, expectedEntryScript: string }} DaemonExpectation */
|
|
12
|
+
/** @typedef {{ owner?: ServiceOwner, ownerOptions?: Record<string, unknown>, loadOwner?: (options: Record<string, unknown>) => ServiceOwner | null, loadState?: (workspace: string, options: { stateDir: string }) => object, inspectDaemon?: (state: object, expectation: DaemonExpectation) => DaemonStatus, readProvider?: () => ProviderResult | Promise<ProviderResult>, mutateProvider?: (options: { logger?: Console }) => ProviderResult | Promise<ProviderResult>, stopProvider?: (options: { logger?: Console }) => ProviderResult | Promise<ProviderResult>, logger?: Console, attempts?: number, delayMs?: number, sleep?: (milliseconds: number) => Promise<void> }} OwnedRuntimeOptions */
|
|
13
|
+
/** @typedef {{ inspectDaemon?: () => DaemonStatus, attempts?: number, delayMs?: number, sleep?: (milliseconds: number) => Promise<void> }} OwnedWaitOptions */
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ATTEMPTS = 900;
|
|
16
|
+
const DEFAULT_DELAY_MS = 100;
|
|
17
|
+
|
|
18
|
+
/** @param {OwnedRuntimeOptions} [options] */
|
|
19
|
+
export async function startOwnedServiceRuntime(options = {}) {
|
|
20
|
+
return convergeOwnedServiceRuntime({
|
|
21
|
+
...options,
|
|
22
|
+
mutateProvider: options.mutateProvider || startAutostart,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {OwnedRuntimeOptions} [options] */
|
|
27
|
+
export async function restartOwnedServiceRuntime(options = {}) {
|
|
28
|
+
return convergeOwnedServiceRuntime({
|
|
29
|
+
...options,
|
|
30
|
+
mutateProvider: options.mutateProvider || restartAutostart,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {OwnedRuntimeOptions} [options] */
|
|
35
|
+
export async function convergeOwnedServiceRuntime(options = {}) {
|
|
36
|
+
const loadOwner = options.loadOwner === undefined ? loadCommittedServiceOwner : options.loadOwner;
|
|
37
|
+
const loadOwnerState = options.loadState === undefined ? loadState : options.loadState;
|
|
38
|
+
const inspectDaemon = options.inspectDaemon === undefined ? inspectWorkspaceDaemon : options.inspectDaemon;
|
|
39
|
+
const readProvider = options.readProvider === undefined ? autostartStatus : options.readProvider;
|
|
40
|
+
const mutateProvider = options.mutateProvider;
|
|
41
|
+
const stopProvider = options.stopProvider === undefined ? stopAutostart : options.stopProvider;
|
|
42
|
+
for (const [name, value] of Object.entries({ loadOwner, loadOwnerState, inspectDaemon, readProvider, stopProvider })) {
|
|
43
|
+
if (typeof value !== "function") throw new TypeError(`owned service runtime requires ${name}`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof mutateProvider !== "function") throw new TypeError("owned service runtime requires mutateProvider");
|
|
46
|
+
const owner = options.owner || loadOwner(options.ownerOptions || {});
|
|
47
|
+
if (!owner) throw new Error("machine service owner is unavailable; reinstall the service before starting it");
|
|
48
|
+
const state = loadOwnerState(owner.workspace, { stateDir: owner.stateRoot });
|
|
49
|
+
const inspect = () => inspectDaemon(state, {
|
|
50
|
+
expectedVersion: owner.version,
|
|
51
|
+
expectedEntryScript: owner.entryScript,
|
|
52
|
+
});
|
|
53
|
+
const providerBefore = await readProvider();
|
|
54
|
+
if (typeof providerBefore?.active !== "boolean") {
|
|
55
|
+
throw new Error("machine service provider activity could not be verified before mutation");
|
|
56
|
+
}
|
|
57
|
+
const daemonBefore = inspect();
|
|
58
|
+
assertStartOwnership(providerBefore, daemonBefore);
|
|
59
|
+
if (providerBefore.active === true && readyOwnedDaemon(daemonBefore)) {
|
|
60
|
+
return { ok: true, active: true, already_running: true, reason: "already_running",
|
|
61
|
+
provider: providerBefore.provider, daemon: daemonBefore, service_owner: ownerSummary(owner) };
|
|
62
|
+
}
|
|
63
|
+
const providerResult = await mutateProvider({ logger: options.logger });
|
|
64
|
+
if (providerResult?.ok !== true || providerResult.active !== true) return providerResult;
|
|
65
|
+
const convergence = await waitForOwnedServiceDaemon({
|
|
66
|
+
inspectDaemon: inspect,
|
|
67
|
+
attempts: options.attempts,
|
|
68
|
+
delayMs: options.delayMs,
|
|
69
|
+
sleep: options.sleep,
|
|
70
|
+
});
|
|
71
|
+
if (convergence.ok) {
|
|
72
|
+
return { ...providerResult, ok: true, active: true, daemon: convergence.daemon,
|
|
73
|
+
service_owner: ownerSummary(owner), readiness_attempts: convergence.attempts };
|
|
74
|
+
}
|
|
75
|
+
const providerStop = await stopProvider({ logger: options.logger });
|
|
76
|
+
const providerInactive = providerStop?.ok === true && providerStop.active === false;
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
active: providerStop?.active === true ? true : providerStop?.active === false ? false : null,
|
|
80
|
+
provider: providerResult.provider || providerStop?.provider || "unknown",
|
|
81
|
+
reason: providerInactive ? convergence.reason : `${convergence.reason}_provider_stop_unverified`,
|
|
82
|
+
daemon: convergence.daemon,
|
|
83
|
+
provider_start: providerResult,
|
|
84
|
+
provider_stop: providerStop,
|
|
85
|
+
service_owner: ownerSummary(owner),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {OwnedWaitOptions} [options] */
|
|
90
|
+
export async function waitForOwnedServiceDaemon(options = {}) {
|
|
91
|
+
const { inspectDaemon, attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = options;
|
|
92
|
+
if (typeof inspectDaemon !== "function") throw new TypeError("owned service convergence requires inspectDaemon");
|
|
93
|
+
const maximum = boundedAttempts(attempts);
|
|
94
|
+
/** @type {DaemonStatus | null} */
|
|
95
|
+
let daemon = null;
|
|
96
|
+
for (let attempt = 1; attempt <= maximum; attempt += 1) {
|
|
97
|
+
daemon = inspectDaemon();
|
|
98
|
+
if (readyOwnedDaemon(daemon)) return { ok: true, attempts: attempt, daemon, reason: null };
|
|
99
|
+
if (daemon?.alive === true && daemon.verified_service_daemon !== true) {
|
|
100
|
+
return { ok: false, attempts: attempt, daemon,
|
|
101
|
+
reason: `daemon_identity_${daemon.identity_reason || "unverified"}` };
|
|
102
|
+
}
|
|
103
|
+
if (attempt < maximum) await sleep(delayMs);
|
|
104
|
+
}
|
|
105
|
+
return { ok: false, attempts: maximum, daemon,
|
|
106
|
+
reason: daemon?.alive ? "daemon_readiness_not_verified" : "daemon_not_running" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {ProviderResult} provider @param {DaemonStatus} daemon */
|
|
110
|
+
function assertStartOwnership(provider, daemon) {
|
|
111
|
+
if (!daemon?.alive) return;
|
|
112
|
+
if (daemon.verified_service_daemon !== true || daemon.mode !== "service") {
|
|
113
|
+
throw new Error(`refusing to mutate the machine service while its owner has a ${daemon.identity_reason || daemon.mode || "unverified"} daemon`);
|
|
114
|
+
}
|
|
115
|
+
if (provider.active !== true) {
|
|
116
|
+
throw new Error("the machine service owner has an orphaned service daemon while the provider is inactive; stop it explicitly before retrying");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @param {DaemonStatus | null | undefined} daemon */
|
|
121
|
+
function readyOwnedDaemon(daemon) {
|
|
122
|
+
return daemon?.alive === true
|
|
123
|
+
&& daemon.verified_service_daemon === true
|
|
124
|
+
&& daemon.mode === "service"
|
|
125
|
+
&& daemon.startup_readiness_verified === true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {ServiceOwner} owner */
|
|
129
|
+
function ownerSummary(owner) {
|
|
130
|
+
return { status: owner.status, version: owner.version };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @param {unknown} value */
|
|
134
|
+
function boundedAttempts(value) {
|
|
135
|
+
const parsed = value === undefined ? DEFAULT_ATTEMPTS : Number(value);
|
|
136
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 1800) {
|
|
137
|
+
throw new Error("owned service convergence attempts must be between 1 and 1800");
|
|
138
|
+
}
|
|
139
|
+
return parsed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {number} milliseconds @returns {Promise<void>} */
|
|
143
|
+
function delay(milliseconds) {
|
|
144
|
+
return new Promise(resolve => { setTimeout(resolve, milliseconds); });
|
|
145
|
+
}
|
package/src/local/service.mjs
CHANGED
|
@@ -5,9 +5,10 @@ import { runExecutable } from "./shell.mjs";
|
|
|
5
5
|
import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
|
|
6
6
|
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
7
7
|
import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { waitForInactiveStatus, waitForStableActiveStatus, waitForStatus } from "./service-convergence.mjs";
|
|
9
9
|
import { launchdStatusSummary, systemdStatusSummary } from "./service-status.mjs";
|
|
10
10
|
import { writeServiceEnvironment } from "./service-environment.mjs";
|
|
11
|
+
import { beginServiceOwnerUpdate, removeServiceOwner } from "./service-owner.mjs";
|
|
11
12
|
import {
|
|
12
13
|
installWindowsTask,
|
|
13
14
|
restartWindowsTask,
|
|
@@ -20,6 +21,7 @@ export { windowsCommandLineArgument } from "./windows-service.mjs";
|
|
|
20
21
|
|
|
21
22
|
const LABEL = "dev.machine-bridge-mcp.daemon";
|
|
22
23
|
const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
|
24
|
+
const SERVICE_COMMAND_TIMEOUT_MS = 30_000;
|
|
23
25
|
const AUTOSTART_LOG_SCHEMA_VERSION = 4;
|
|
24
26
|
|
|
25
27
|
function serviceRun(command, args) {
|
|
@@ -30,24 +32,67 @@ export function runServiceCommand(command, args, execute = runExecutable) {
|
|
|
30
32
|
return execute(command, args, {
|
|
31
33
|
capture: true,
|
|
32
34
|
allowFailure: true,
|
|
35
|
+
timeoutMs: SERVICE_COMMAND_TIMEOUT_MS,
|
|
33
36
|
maxOutputBytes: SERVICE_COMMAND_OUTPUT_BYTES,
|
|
34
37
|
});
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
export async function installAutostart({ workspace, stateRoot, entryScript, logger = console
|
|
40
|
+
export async function installAutostart({ workspace, stateRoot, entryScript, version, logger = console,
|
|
41
|
+
installProvider = defaultInstallProvider, beginOwnerUpdate = beginServiceOwnerUpdate,
|
|
42
|
+
writeEnvironment = writeServiceEnvironment } = {}) {
|
|
38
43
|
const spec = serviceSpec({ workspace, stateRoot, entryScript });
|
|
39
|
-
const
|
|
44
|
+
const ownerUpdate = beginOwnerUpdate({ ...spec, version });
|
|
45
|
+
let serviceEnvironment;
|
|
46
|
+
try { serviceEnvironment = writeEnvironment(spec.stateRoot); }
|
|
47
|
+
catch (error) {
|
|
48
|
+
try { ownerUpdate.rollback(); }
|
|
49
|
+
catch (rollbackError) {
|
|
50
|
+
throw new AggregateError([error, rollbackError],
|
|
51
|
+
"service environment preparation failed and the previous machine service owner could not be restored");
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
40
55
|
let result;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
try { result = await installProvider(spec, logger); }
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
"autostart installation threw after its machine service owner became pending; service start is blocked until reinstall",
|
|
60
|
+
{ cause: error },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (result?.ok !== true) {
|
|
64
|
+
return {
|
|
65
|
+
...result,
|
|
66
|
+
service_environment: serviceEnvironment,
|
|
67
|
+
service_owner: { status: "pending", version },
|
|
68
|
+
reason: result?.reason || "installation_failed_owner_pending",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
let owner;
|
|
72
|
+
try { owner = ownerUpdate.commit(); }
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"autostart definition was installed but its machine service owner could not be committed; the pending owner was retained and service start is blocked until reinstall",
|
|
76
|
+
{ cause: error },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { ...result, service_environment: serviceEnvironment, service_owner: { status: owner.status, version: owner.version } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function defaultInstallProvider(spec, logger) {
|
|
83
|
+
if (process.platform === "darwin") return installLaunchd(spec, logger);
|
|
84
|
+
if (process.platform === "win32") return installWindowsTask(spec, logger, { run: serviceRun });
|
|
85
|
+
return installSystemd(spec, logger);
|
|
45
86
|
}
|
|
46
87
|
|
|
88
|
+
|
|
47
89
|
export async function uninstallAutostart({ stateRoot, logger = console } = {}) {
|
|
48
|
-
|
|
49
|
-
if (process.platform === "
|
|
50
|
-
|
|
90
|
+
let result;
|
|
91
|
+
if (process.platform === "darwin") result = await uninstallLaunchd(logger);
|
|
92
|
+
else if (process.platform === "win32") result = await uninstallWindowsTask(logger, { run: serviceRun, stateRoot });
|
|
93
|
+
else result = await uninstallSystemd(logger);
|
|
94
|
+
if (result?.ok === true) removeServiceOwner();
|
|
95
|
+
return result;
|
|
51
96
|
}
|
|
52
97
|
|
|
53
98
|
export async function autostartStatus() {
|
|
@@ -71,7 +116,7 @@ export async function restartAutostart({ logger = console } = {}) {
|
|
|
71
116
|
export async function stopAutostart({ logger = console } = {}) {
|
|
72
117
|
if (process.platform === "darwin") return stopLaunchd(logger);
|
|
73
118
|
if (process.platform === "win32") return stopWindowsTask(logger, { run: serviceRun });
|
|
74
|
-
return
|
|
119
|
+
return stopSystemdService(logger);
|
|
75
120
|
}
|
|
76
121
|
|
|
77
122
|
export function normalizeServiceCommandResult(provider, result, { allowAlreadyStopped = false } = {}) {
|
|
@@ -304,15 +349,19 @@ async function startLaunchd(logger) {
|
|
|
304
349
|
if (!existsSync(plistPath)) return { ok: false, provider: "launchd", installed: false, loaded: false, active: false, reason: "not_installed" };
|
|
305
350
|
const before = await statusLaunchd();
|
|
306
351
|
if (before.active) {
|
|
307
|
-
|
|
308
|
-
|
|
352
|
+
const existing = await waitForStableActiveStatus(statusLaunchd);
|
|
353
|
+
if (existing.stable) {
|
|
354
|
+
logger.info?.("launchd service is already running");
|
|
355
|
+
return { ...existing.status, ok: true, active_before: true, already_running: true, reason: "already_running" };
|
|
356
|
+
}
|
|
309
357
|
}
|
|
310
358
|
const boot = before.loaded
|
|
311
359
|
? { code: 0, stdout: "", stderr: "", already_loaded: true }
|
|
312
360
|
: await serviceRun("launchctl", ["bootstrap", domainTarget, plistPath]);
|
|
313
361
|
const kick = await serviceRun("launchctl", ["kickstart", serviceTarget]);
|
|
314
|
-
const
|
|
315
|
-
const
|
|
362
|
+
const stability = await waitForStableActiveStatus(statusLaunchd);
|
|
363
|
+
const after = stability.status;
|
|
364
|
+
const ok = stability.stable;
|
|
316
365
|
if (ok) logger.info?.("launchd service started");
|
|
317
366
|
else logger.warn?.("launchd service failed to start");
|
|
318
367
|
return {
|
|
@@ -338,14 +387,18 @@ async function restartLaunchd(logger) {
|
|
|
338
387
|
const target = launchdServiceTarget();
|
|
339
388
|
const previousPid = before.pid;
|
|
340
389
|
const kick = await serviceRun("launchctl", ["kickstart", "-k", target]);
|
|
341
|
-
const
|
|
390
|
+
const changed = await waitForStatus(
|
|
342
391
|
statusLaunchd,
|
|
343
392
|
(status) => status?.active === true && (!previousPid || status.pid !== previousPid),
|
|
344
393
|
);
|
|
345
|
-
const
|
|
394
|
+
const stability = changed?.active === true && (!previousPid || changed.pid !== previousPid)
|
|
395
|
+
? await waitForStableActiveStatus(statusLaunchd, { identity: (status) => status?.pid || null })
|
|
396
|
+
: { stable: false, status: changed };
|
|
397
|
+
const after = stability.status;
|
|
398
|
+
const ok = kick.code === 0 && stability.stable && (!previousPid || after?.pid !== previousPid);
|
|
346
399
|
if (ok) logger.info?.("launchd service restarted");
|
|
347
400
|
else logger.warn?.("launchd service restart could not be verified");
|
|
348
|
-
return { ok, provider: "launchd", installed: true, active_before: true, active: after?.active === true, restarted: ok, kickstart: kick };
|
|
401
|
+
return { ok, provider: "launchd", installed: true, active_before: true, active: after?.active === true && stability.stable, restarted: ok, kickstart: kick };
|
|
349
402
|
}
|
|
350
403
|
|
|
351
404
|
async function stopLaunchd(logger) {
|
|
@@ -362,6 +415,7 @@ async function stopLaunchd(logger) {
|
|
|
362
415
|
loaded: false,
|
|
363
416
|
active_before: false,
|
|
364
417
|
active: false,
|
|
418
|
+
restore_required: false,
|
|
365
419
|
already_stopped: true,
|
|
366
420
|
code: 0,
|
|
367
421
|
stdout: "",
|
|
@@ -384,8 +438,9 @@ async function stopLaunchd(logger) {
|
|
|
384
438
|
ok,
|
|
385
439
|
provider: "launchd",
|
|
386
440
|
installed: existsSync(plistPath),
|
|
387
|
-
active_before: true,
|
|
441
|
+
active_before: before.active === true,
|
|
388
442
|
active,
|
|
443
|
+
restore_required: ok,
|
|
389
444
|
already_stopped: false,
|
|
390
445
|
code: ok ? 0 : rawResult.code,
|
|
391
446
|
bootout_service_target: byServiceTarget,
|
|
@@ -469,15 +524,77 @@ ${disable.stderr}`));
|
|
|
469
524
|
return { ok, provider: "systemd", path: servicePath, disable, active_check: activeCheck, reload, active: false };
|
|
470
525
|
}
|
|
471
526
|
|
|
527
|
+
export async function stopSystemdService(logger = console, options = {}) {
|
|
528
|
+
const run = typeof options.run === "function" ? options.run : serviceRun;
|
|
529
|
+
const readStatus = typeof options.readStatus === "function" ? options.readStatus : statusSystemd;
|
|
530
|
+
const waitForInactive = typeof options.waitForInactive === "function"
|
|
531
|
+
? options.waitForInactive
|
|
532
|
+
: callback => waitForInactiveStatus(callback);
|
|
533
|
+
const before = await readStatus();
|
|
534
|
+
if (systemdStatusIsInactive(before)) {
|
|
535
|
+
logger.info?.("systemd service is not active");
|
|
536
|
+
return {
|
|
537
|
+
ok: true,
|
|
538
|
+
provider: "systemd",
|
|
539
|
+
installed: before?.installed === true,
|
|
540
|
+
active_before: false,
|
|
541
|
+
active: false,
|
|
542
|
+
restore_required: false,
|
|
543
|
+
already_stopped: true,
|
|
544
|
+
state: before?.state || "inactive",
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
if (before?.active !== true && ["unknown", "maintenance"].includes(before?.state)) {
|
|
548
|
+
return {
|
|
549
|
+
ok: false,
|
|
550
|
+
provider: "systemd",
|
|
551
|
+
installed: before?.installed === true,
|
|
552
|
+
active_before: null,
|
|
553
|
+
active: null,
|
|
554
|
+
restore_required: null,
|
|
555
|
+
reason: "status_unavailable",
|
|
556
|
+
status: before,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const command = await run("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]);
|
|
560
|
+
const after = await waitForInactive(readStatus);
|
|
561
|
+
const inactive = systemdStatusIsInactive(after);
|
|
562
|
+
if (inactive) logger.info?.("systemd service stopped");
|
|
563
|
+
else logger.warn?.("systemd service stop could not be verified");
|
|
564
|
+
return {
|
|
565
|
+
ok: inactive,
|
|
566
|
+
provider: "systemd",
|
|
567
|
+
installed: after?.installed === true,
|
|
568
|
+
active_before: before?.active === true,
|
|
569
|
+
active: inactive ? false : after?.active ?? null,
|
|
570
|
+
restore_required: inactive && systemdStatusRequiresRestore(before),
|
|
571
|
+
already_stopped: false,
|
|
572
|
+
command,
|
|
573
|
+
status: after,
|
|
574
|
+
reason: inactive ? "stopped" : "stop_not_observed",
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function systemdStatusIsInactive(status) {
|
|
579
|
+
return status?.installed === false || status?.active === false && ["inactive", "failed"].includes(status?.state);
|
|
580
|
+
}
|
|
581
|
+
function systemdStatusRequiresRestore(status) {
|
|
582
|
+
return status?.active === true || ["activating", "reloading"].includes(status?.state);
|
|
583
|
+
}
|
|
584
|
+
|
|
472
585
|
async function startSystemd(logger) {
|
|
473
586
|
const before = await statusSystemd();
|
|
474
587
|
if (before.active) {
|
|
475
|
-
|
|
476
|
-
|
|
588
|
+
const existing = await waitForStableActiveStatus(statusSystemd);
|
|
589
|
+
if (existing.stable) {
|
|
590
|
+
logger.info?.("systemd service is already running");
|
|
591
|
+
return { ...existing.status, ok: true, active_before: true, already_running: true, reason: "already_running" };
|
|
592
|
+
}
|
|
477
593
|
}
|
|
478
594
|
const command = await serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]);
|
|
479
|
-
const
|
|
480
|
-
const
|
|
595
|
+
const stability = await waitForStableActiveStatus(statusSystemd);
|
|
596
|
+
const after = stability.status;
|
|
597
|
+
const ok = command.code === 0 && stability.stable;
|
|
481
598
|
if (ok) logger.info?.("systemd service started");
|
|
482
599
|
else logger.warn?.("systemd service failed to start");
|
|
483
600
|
return { ok, provider: "systemd", installed: after.installed, active_before: false, active: after.active, command };
|
|
@@ -485,8 +602,9 @@ async function startSystemd(logger) {
|
|
|
485
602
|
|
|
486
603
|
async function restartSystemd(logger) {
|
|
487
604
|
const command = await serviceRun("systemctl", ["--user", "restart", "machine-bridge-mcp.service"]);
|
|
488
|
-
const
|
|
489
|
-
const
|
|
605
|
+
const stability = await waitForStableActiveStatus(statusSystemd);
|
|
606
|
+
const after = stability.status;
|
|
607
|
+
const ok = command.code === 0 && stability.stable;
|
|
490
608
|
if (ok) logger.info?.("systemd service restarted");
|
|
491
609
|
else logger.warn?.("systemd service restart could not be verified");
|
|
492
610
|
return { ok, provider: "systemd", installed: after.installed, active: after.active, restarted: ok, command };
|