machine-bridge-mcp 0.11.1 → 0.12.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/CHANGELOG.md +26 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +5 -5
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +14 -8
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +3 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +12 -4
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +11 -8
- package/package.json +7 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/app-automation.mjs +3 -1
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +165 -81
- package/src/local/daemon-process.mjs +3 -67
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +53 -25
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/worker/index.ts +1 -1
package/src/local/job-runner.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { createHash
|
|
3
|
-
import { chmodSync,
|
|
4
|
-
import { basename,
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
5
|
import { executionEnv } from "./shell.mjs";
|
|
6
6
|
import { terminateProcessTree } from "./process-sessions.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { createExclusiveFileSync, removeOwnedJsonFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
8
|
+
import { currentProcessStartTimeMs } from "./process-identity.mjs";
|
|
8
9
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
9
10
|
|
|
10
11
|
const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
|
|
@@ -22,6 +23,8 @@ if (!jobDirInput) throw new Error("--job-dir is required");
|
|
|
22
23
|
const jobDir = resolve(jobDirInput);
|
|
23
24
|
if (!JOB_ID.test(basename(jobDir))) throw new Error("--job-dir must name a managed job directory");
|
|
24
25
|
const recover = options.recover === true;
|
|
26
|
+
const recoveryLockToken = typeof process.env.MBM_RECOVERY_LOCK_TOKEN === "string" ? process.env.MBM_RECOVERY_LOCK_TOKEN : "";
|
|
27
|
+
delete process.env.MBM_RECOVERY_LOCK_TOKEN;
|
|
25
28
|
const planFile = join(jobDir, "plan.json");
|
|
26
29
|
const statusFile = join(jobDir, "status.json");
|
|
27
30
|
const resultFile = join(jobDir, "result.json");
|
|
@@ -30,6 +33,7 @@ const runtimeDir = join(jobDir, "runtime");
|
|
|
30
33
|
const resourcesDir = join(runtimeDir, "resources");
|
|
31
34
|
const temporaryFilesDir = join(runtimeDir, "files");
|
|
32
35
|
const runnerPidFile = join(jobDir, "runner.pid");
|
|
36
|
+
const RUNNER_PROCESS_STARTED_AT = new Date(currentProcessStartTimeMs()).toISOString();
|
|
33
37
|
|
|
34
38
|
class JobCancelledError extends Error {
|
|
35
39
|
constructor() {
|
|
@@ -49,7 +53,7 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
|
49
53
|
|
|
50
54
|
const initial = readJson(statusFile, MAX_STATUS_BYTES);
|
|
51
55
|
assertLaunchState(initial);
|
|
52
|
-
|
|
56
|
+
createExclusiveFileSync(runnerPidFile, `${JSON.stringify({ pid: process.pid, processStartedAt: RUNNER_PROCESS_STARTED_AT })}\n`, { mode: 0o600 });
|
|
53
57
|
if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
|
|
54
58
|
try {
|
|
55
59
|
const plan = readJson(planFile, 1024 * 1024);
|
|
@@ -59,10 +63,22 @@ try {
|
|
|
59
63
|
recordFatalRunnerError(error);
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
async function releaseRecoveryClaim() {
|
|
67
|
+
if (!/^[a-f0-9]{32}$/.test(recoveryLockToken)) throw new Error("recovery runner is missing its ownership token");
|
|
68
|
+
const file = join(jobDir, "recovery.lock");
|
|
69
|
+
const deadline = Date.now() + 5000;
|
|
70
|
+
while (Date.now() < deadline) {
|
|
71
|
+
if (removeOwnedJsonFileSync(file, { pid: process.pid, token: recoveryLockToken })) return;
|
|
72
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
|
|
73
|
+
}
|
|
74
|
+
throw new Error("recovery runner could not verify ownership of the recovery lock");
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
async function main(plan, initial) {
|
|
63
78
|
const status = {
|
|
64
79
|
...initial,
|
|
65
80
|
runner_pid: process.pid,
|
|
81
|
+
runner_process_started_at: RUNNER_PROCESS_STARTED_AT,
|
|
66
82
|
started_at: initial.started_at || new Date().toISOString(),
|
|
67
83
|
updated_at: new Date().toISOString(),
|
|
68
84
|
status: recover ? "cleaning" : "running",
|
|
@@ -182,6 +198,7 @@ function recordFatalRunnerError(error) {
|
|
|
182
198
|
current_phase: null,
|
|
183
199
|
current_step: null,
|
|
184
200
|
runner_pid: process.pid,
|
|
201
|
+
runner_process_started_at: RUNNER_PROCESS_STARTED_AT,
|
|
185
202
|
updated_at: now,
|
|
186
203
|
finished_at: now,
|
|
187
204
|
error_class: result.error_class,
|
|
@@ -259,7 +276,6 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
|
|
|
259
276
|
timedOut = true;
|
|
260
277
|
terminateProcessTree(child, "SIGTERM");
|
|
261
278
|
killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
|
|
262
|
-
killTimer.unref?.();
|
|
263
279
|
}, timeoutMs);
|
|
264
280
|
timer.unref?.();
|
|
265
281
|
const cancellationPoll = setInterval(() => {
|
|
@@ -300,12 +316,14 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
|
|
|
300
316
|
if (closed) return;
|
|
301
317
|
closed = true;
|
|
302
318
|
clearTimeout(timer);
|
|
303
|
-
if (killTimer) clearTimeout(killTimer);
|
|
319
|
+
if (killTimer && !timedOut) clearTimeout(killTimer);
|
|
304
320
|
clearInterval(cancellationPoll);
|
|
305
321
|
activeChild = null;
|
|
306
322
|
activeChildCancellationAware = false;
|
|
307
|
-
if (cancellationEscalation
|
|
308
|
-
|
|
323
|
+
if (cancellationEscalation && !isCancellationRequested()) {
|
|
324
|
+
clearTimeout(cancellationEscalation);
|
|
325
|
+
cancellationEscalation = null;
|
|
326
|
+
}
|
|
309
327
|
callback();
|
|
310
328
|
}
|
|
311
329
|
});
|
|
@@ -440,7 +458,7 @@ function redactOutput(buffer, context) {
|
|
|
440
458
|
}
|
|
441
459
|
|
|
442
460
|
function updateStatus(status, changes) {
|
|
443
|
-
Object.assign(status, changes, { runner_pid: process.pid, updated_at: new Date().toISOString() });
|
|
461
|
+
Object.assign(status, changes, { runner_pid: process.pid, runner_process_started_at: RUNNER_PROCESS_STARTED_AT, updated_at: new Date().toISOString() });
|
|
444
462
|
writeJson(statusFile, status, MAX_STATUS_BYTES);
|
|
445
463
|
}
|
|
446
464
|
|
|
@@ -451,9 +469,9 @@ function requestCancellation() {
|
|
|
451
469
|
terminateProcessTree(child, "SIGTERM");
|
|
452
470
|
if (cancellationEscalation) return;
|
|
453
471
|
cancellationEscalation = setTimeout(() => {
|
|
454
|
-
|
|
472
|
+
terminateProcessTree(child, "SIGKILL");
|
|
473
|
+
cancellationEscalation = null;
|
|
455
474
|
}, 2000);
|
|
456
|
-
cancellationEscalation.unref?.();
|
|
457
475
|
}
|
|
458
476
|
|
|
459
477
|
function isCancellationRequested() {
|
|
@@ -474,12 +492,10 @@ function appendLimited(current, chunk, limit, budget) {
|
|
|
474
492
|
}
|
|
475
493
|
|
|
476
494
|
function writeJson(file, value, maxBytes) {
|
|
477
|
-
const text = `${JSON.stringify(value, null, 2)}
|
|
495
|
+
const text = `${JSON.stringify(value, null, 2)}
|
|
496
|
+
`;
|
|
478
497
|
if (Buffer.byteLength(text) > maxBytes) throw new Error(`job JSON exceeds ${maxBytes} bytes`);
|
|
479
|
-
|
|
480
|
-
writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
|
|
481
|
-
replaceFileSync(temp, file);
|
|
482
|
-
chmodSync(file, 0o600);
|
|
498
|
+
replaceFileAtomicallySync(file, text, { mode: 0o600 });
|
|
483
499
|
}
|
|
484
500
|
|
|
485
501
|
function readJson(file, maxBytes) {
|
package/src/local/log.mjs
CHANGED
|
@@ -22,8 +22,15 @@ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
|
|
|
22
22
|
const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
23
23
|
const AWS_ACCESS_KEY = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
|
|
24
24
|
const GITHUB_TOKEN = /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g;
|
|
25
|
+
const GITLAB_TOKEN = /\bglpat-[A-Za-z0-9_-]{20,}\b/g;
|
|
26
|
+
const NPM_TOKEN = /\bnpm_[A-Za-z0-9]{30,}\b/g;
|
|
27
|
+
const SLACK_TOKEN = /\bxox[aboprs]-[A-Za-z0-9-]{10,}\b/g;
|
|
28
|
+
const GOOGLE_API_KEY = /\bAIza[A-Za-z0-9_-]{30,}\b/g;
|
|
29
|
+
const PAYMENT_API_KEY = /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{16,}\b/g;
|
|
30
|
+
const JWT_VALUE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
31
|
+
const URL_CREDENTIALS = /https?:\/\/[^\s/@:"'<>]+:[^\s/@"'<>]+@[^\s/"'<>]+/gi;
|
|
25
32
|
const API_SECRET = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g;
|
|
26
|
-
const PRIVATE_KEY_HEADER = /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g;
|
|
33
|
+
const PRIVATE_KEY_HEADER = /-----BEGIN\s+(?:(?:OPENSSH|RSA|EC|DSA)\s+|ENCRYPTED\s+)?PRIVATE\s+KEY-----/g;
|
|
27
34
|
const HOME_PATHS = [...new Set([process.env.HOME, process.env.USERPROFILE, safeHomeDirectory()].filter(value => typeof value === "string" && value.length > 1))]
|
|
28
35
|
.sort((left, right) => right.length - left.length);
|
|
29
36
|
|
|
@@ -54,6 +61,7 @@ export function createLogger(options = {}) {
|
|
|
54
61
|
error(message, fields) { write(process.stderr, "error", "[error]", COLORS.red, message, fields); },
|
|
55
62
|
debug(message, fields) { write(process.stderr, "debug", "[debug]", COLORS.gray, message, fields); },
|
|
56
63
|
plain(message = "") { if (!quiet) process.stdout.write(`${String(message)}\n`); },
|
|
64
|
+
safePlain(message = "") { if (!quiet) process.stdout.write(`${sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS)}\n`); },
|
|
57
65
|
json(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); },
|
|
58
66
|
};
|
|
59
67
|
}
|
|
@@ -123,6 +131,13 @@ export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
|
|
|
123
131
|
.replace(BEARER_VALUE, "Bearer <redacted>")
|
|
124
132
|
.replace(AWS_ACCESS_KEY, "<redacted-cloud-key>")
|
|
125
133
|
.replace(GITHUB_TOKEN, "<redacted-access-token>")
|
|
134
|
+
.replace(GITLAB_TOKEN, "<redacted-access-token>")
|
|
135
|
+
.replace(NPM_TOKEN, "<redacted-access-token>")
|
|
136
|
+
.replace(SLACK_TOKEN, "<redacted-access-token>")
|
|
137
|
+
.replace(GOOGLE_API_KEY, "<redacted-cloud-key>")
|
|
138
|
+
.replace(PAYMENT_API_KEY, "<redacted-api-secret>")
|
|
139
|
+
.replace(JWT_VALUE, "<redacted-bearer-token>")
|
|
140
|
+
.replace(URL_CREDENTIALS, "<redacted-credential-url>")
|
|
126
141
|
.replace(API_SECRET, "<redacted-api-secret>")
|
|
127
142
|
.replace(PRIVATE_KEY_HEADER, "<redacted-private-key-header>")
|
|
128
143
|
.replace(EMAIL_VALUE, "<redacted-email>");
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync,
|
|
3
|
+
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
|
-
import {
|
|
6
|
+
import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
|
+
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
8
|
+
import { currentProcessStartTimeMs, inspectProcessInstance, processStartTimeMs } from "./process-identity.mjs";
|
|
8
9
|
import { readBoundedRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
|
|
9
10
|
|
|
10
11
|
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
@@ -23,7 +24,7 @@ const ACTIVE_JOB_STATES = new Set(["queued", "running", "cleaning", "interrupted
|
|
|
23
24
|
const PLAN_RETAINING_STATES = new Set(["staged", ...ACTIVE_JOB_STATES]);
|
|
24
25
|
|
|
25
26
|
export class ManagedJobManager {
|
|
26
|
-
constructor({ jobRoot, workspace, policy, resources = {}, resourceStatePath = "", logger = console, recover = true }) {
|
|
27
|
+
constructor({ jobRoot, workspace, policy, resources = {}, resourceStatePath = "", stateRoot = "", logger = console, recover = true }) {
|
|
27
28
|
const jobRootInput = resolve(jobRoot);
|
|
28
29
|
ensureOwnerOnlyDir(jobRootInput);
|
|
29
30
|
this.jobRoot = realpathSync.native ? realpathSync.native(jobRootInput) : realpathSync(jobRootInput);
|
|
@@ -32,12 +33,15 @@ export class ManagedJobManager {
|
|
|
32
33
|
this.policy = policy;
|
|
33
34
|
this.resources = normalizeResourceRegistry(resources);
|
|
34
35
|
this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
|
|
36
|
+
this.stateRoot = stateRoot ? resolve(stateRoot) : "";
|
|
35
37
|
this.logger = logger;
|
|
38
|
+
this.assertMaintenanceAvailable();
|
|
36
39
|
this.prune();
|
|
37
40
|
if (recover) this.recoverInterruptedJobs();
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
status() {
|
|
44
|
+
this.assertMaintenanceAvailable();
|
|
41
45
|
const jobs = this.list({ limit: MAX_JOBS }).jobs;
|
|
42
46
|
return {
|
|
43
47
|
active: jobs.filter((job) => ACTIVE_JOB_STATES.has(job.status)).length,
|
|
@@ -48,6 +52,7 @@ export class ManagedJobManager {
|
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
resourceInfo() {
|
|
55
|
+
this.assertMaintenanceAvailable();
|
|
51
56
|
const resources = this.currentResources();
|
|
52
57
|
return {
|
|
53
58
|
count: Object.keys(resources).length,
|
|
@@ -57,6 +62,7 @@ export class ManagedJobManager {
|
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
listResources() {
|
|
65
|
+
this.assertMaintenanceAvailable();
|
|
60
66
|
const resources = [];
|
|
61
67
|
for (const [name, resource] of Object.entries(this.currentResources()).sort(([a], [b]) => a.localeCompare(b))) {
|
|
62
68
|
try {
|
|
@@ -70,6 +76,7 @@ export class ManagedJobManager {
|
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
diagnoseStorage() {
|
|
79
|
+
this.assertMaintenanceAvailable();
|
|
73
80
|
const probe = join(this.jobRoot, `.probe-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
74
81
|
try {
|
|
75
82
|
writeFileSync(probe, "ok\n", { mode: 0o600, flag: "wx" });
|
|
@@ -84,16 +91,19 @@ export class ManagedJobManager {
|
|
|
84
91
|
|
|
85
92
|
|
|
86
93
|
stage(args = {}) {
|
|
94
|
+
this.assertMaintenanceAvailable();
|
|
87
95
|
if (this.policy.allowWrite !== true) throw new Error("stage_job is disabled by daemon policy");
|
|
88
96
|
return this.createJob(args, { launch: false });
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
start(args = {}) {
|
|
100
|
+
this.assertMaintenanceAvailable();
|
|
92
101
|
this.assertEnabled("start_job");
|
|
93
102
|
return this.createJob(args, { launch: true });
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
approve(args = {}, { localOperator = false } = {}) {
|
|
106
|
+
this.assertMaintenanceAvailable();
|
|
97
107
|
if (!localOperator) this.assertEnabled("approve_job");
|
|
98
108
|
const dir = this.jobDir(args.job_id);
|
|
99
109
|
const transition = acquireJobTransitionLock(dir);
|
|
@@ -203,22 +213,29 @@ export class ManagedJobManager {
|
|
|
203
213
|
|
|
204
214
|
|
|
205
215
|
list(args = {}) {
|
|
216
|
+
this.assertMaintenanceAvailable();
|
|
206
217
|
this.prune();
|
|
207
218
|
const limit = clampInt(args.limit, 20, 1, MAX_JOBS);
|
|
208
219
|
const jobs = [];
|
|
209
220
|
for (const entry of safeReadDir(this.jobRoot)) {
|
|
210
221
|
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
211
222
|
const dir = join(this.jobRoot, entry.name);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
try {
|
|
224
|
+
this.reconcileStatus(dir);
|
|
225
|
+
const status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
226
|
+
if (!status) continue;
|
|
227
|
+
jobs.push(publicStatus(status));
|
|
228
|
+
} catch (error) {
|
|
229
|
+
this.logger.warn?.("managed job status is unreadable; retaining it for inspection", { error_class: resourceErrorClass(error) });
|
|
230
|
+
jobs.push({ job_id: entry.name, name: "unavailable", status: "unreadable", error_class: resourceErrorClass(error) });
|
|
231
|
+
}
|
|
216
232
|
}
|
|
217
233
|
jobs.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
|
|
218
234
|
return { jobs: jobs.slice(0, limit), retained: jobs.length, maximum: MAX_JOBS };
|
|
219
235
|
}
|
|
220
236
|
|
|
221
237
|
read(args = {}) {
|
|
238
|
+
this.assertMaintenanceAvailable();
|
|
222
239
|
const dir = this.jobDir(args.job_id);
|
|
223
240
|
this.reconcileStatus(dir);
|
|
224
241
|
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
@@ -230,6 +247,7 @@ export class ManagedJobManager {
|
|
|
230
247
|
}
|
|
231
248
|
|
|
232
249
|
inspectLocal(args = {}) {
|
|
250
|
+
this.assertMaintenanceAvailable();
|
|
233
251
|
const dir = this.jobDir(args.job_id);
|
|
234
252
|
this.reconcileStatus(dir);
|
|
235
253
|
const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
@@ -243,6 +261,7 @@ export class ManagedJobManager {
|
|
|
243
261
|
}
|
|
244
262
|
|
|
245
263
|
cancel(args = {}) {
|
|
264
|
+
this.assertMaintenanceAvailable();
|
|
246
265
|
const dir = this.jobDir(args.job_id);
|
|
247
266
|
const transition = acquireJobTransitionLock(dir);
|
|
248
267
|
if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
|
|
@@ -286,12 +305,18 @@ export class ManagedJobManager {
|
|
|
286
305
|
}
|
|
287
306
|
|
|
288
307
|
currentResources() {
|
|
308
|
+
this.assertMaintenanceAvailable();
|
|
289
309
|
if (!this.resourceStatePath) return this.resources;
|
|
290
|
-
const state = readJson(this.resourceStatePath, 2 * 1024 * 1024);
|
|
291
|
-
if (!state
|
|
310
|
+
const state = readJson(this.resourceStatePath, 2 * 1024 * 1024, "resource state");
|
|
311
|
+
if (!state) return this.resources;
|
|
312
|
+
if (typeof state !== "object" || Array.isArray(state)) throw new Error("resource state is not a JSON object");
|
|
292
313
|
return normalizeResourceRegistry(state.resources);
|
|
293
314
|
}
|
|
294
315
|
|
|
316
|
+
assertMaintenanceAvailable() {
|
|
317
|
+
if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
|
|
318
|
+
}
|
|
319
|
+
|
|
295
320
|
assertEnabled(tool) {
|
|
296
321
|
if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") {
|
|
297
322
|
throw new Error(`${tool} is disabled by daemon policy`);
|
|
@@ -307,14 +332,14 @@ export class ManagedJobManager {
|
|
|
307
332
|
}
|
|
308
333
|
|
|
309
334
|
reconcileStatus(dir) {
|
|
335
|
+
this.assertMaintenanceAvailable();
|
|
310
336
|
const file = join(dir, "status.json");
|
|
311
337
|
const initial = readJson(file, 256 * 1024);
|
|
312
338
|
if (!initial || !ACTIVE_JOB_STATES.has(initial.status)) {
|
|
313
339
|
if (initial) scrubFinishedPlan(dir, initial);
|
|
314
340
|
return;
|
|
315
341
|
}
|
|
316
|
-
|
|
317
|
-
if (Number.isInteger(initialPid) && initialPid > 0 && isPidAlive(initialPid)) return;
|
|
342
|
+
if (runnerProcessIsCurrent(initial, dir)) return;
|
|
318
343
|
const updated = Date.parse(initial.updated_at || initial.created_at || "");
|
|
319
344
|
if (Number.isFinite(updated) && Date.now() - updated < 10_000) return;
|
|
320
345
|
|
|
@@ -324,14 +349,13 @@ export class ManagedJobManager {
|
|
|
324
349
|
try {
|
|
325
350
|
const status = readJson(file, 256 * 1024);
|
|
326
351
|
if (!status || !ACTIVE_JOB_STATES.has(status.status)) return;
|
|
327
|
-
|
|
328
|
-
if (Number.isInteger(pid) && pid > 0 && isPidAlive(pid)) return;
|
|
352
|
+
if (runnerProcessIsCurrent(status, dir)) return;
|
|
329
353
|
const recoveryAttempts = Number(status.recovery_attempts || 0);
|
|
330
354
|
if (recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
|
|
331
355
|
markRecoveryExhausted(dir, file, status, recoveryAttempts);
|
|
332
356
|
return;
|
|
333
357
|
}
|
|
334
|
-
const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts);
|
|
358
|
+
const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts, recoveryLock.token);
|
|
335
359
|
recoveryLock.handoff(runnerPid);
|
|
336
360
|
handedOff = true;
|
|
337
361
|
} finally {
|
|
@@ -340,22 +364,35 @@ export class ManagedJobManager {
|
|
|
340
364
|
}
|
|
341
365
|
|
|
342
366
|
recoverInterruptedJobs() {
|
|
367
|
+
this.assertMaintenanceAvailable();
|
|
343
368
|
for (const entry of safeReadDir(this.jobRoot)) {
|
|
344
369
|
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
345
|
-
|
|
370
|
+
try {
|
|
371
|
+
this.reconcileStatus(join(this.jobRoot, entry.name));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
this.logger.warn?.("managed job recovery skipped unreadable state; retaining it for inspection", { error_class: resourceErrorClass(error) });
|
|
374
|
+
}
|
|
346
375
|
}
|
|
347
376
|
}
|
|
348
377
|
|
|
349
378
|
prune() {
|
|
379
|
+
this.assertMaintenanceAvailable();
|
|
350
380
|
const entries = [];
|
|
351
381
|
for (const entry of safeReadDir(this.jobRoot)) {
|
|
352
382
|
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
353
383
|
const dir = join(this.jobRoot, entry.name);
|
|
354
|
-
|
|
355
|
-
|
|
384
|
+
let status;
|
|
385
|
+
let mtime;
|
|
386
|
+
try {
|
|
387
|
+
status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
388
|
+
mtime = statSync(dir).mtimeMs;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
this.logger.warn?.("managed job pruning skipped unreadable state; retaining it for inspection", { error_class: resourceErrorClass(error) });
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
356
393
|
if (!status) {
|
|
357
|
-
const
|
|
358
|
-
if ((
|
|
394
|
+
const runner = readRunnerOwner(dir, { startedAt: new Date(mtime).toISOString() });
|
|
395
|
+
if (!runnerProcessIsCurrent(runner, dir, { ownerOnly: true }) && Date.now() - mtime > 60_000) {
|
|
359
396
|
rmSync(dir, { recursive: true, force: true });
|
|
360
397
|
continue;
|
|
361
398
|
}
|
|
@@ -384,13 +421,19 @@ export class ManagedJobManager {
|
|
|
384
421
|
|
|
385
422
|
export function activeManagedJobs(jobRoot) {
|
|
386
423
|
const root = resolve(jobRoot);
|
|
424
|
+
if (!existsSync(root)) return [];
|
|
387
425
|
const jobs = [];
|
|
388
426
|
for (const entry of safeReadDir(root)) {
|
|
389
427
|
if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
|
|
390
428
|
const dir = join(root, entry.name);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
429
|
+
let status;
|
|
430
|
+
try {
|
|
431
|
+
status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
|
|
432
|
+
} catch (error) {
|
|
433
|
+
jobs.push({ job_id: entry.name, status: "unreadable", runner_alive: true, error_class: resourceErrorClass(error) });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const runnerAlive = runnerProcessIsCurrent(status, dir);
|
|
394
437
|
const lifecycleActive = status && ACTIVE_JOB_STATES.has(status.status);
|
|
395
438
|
if (runnerAlive || lifecycleActive) {
|
|
396
439
|
jobs.push({
|
|
@@ -673,7 +716,7 @@ function markRecoveryExhausted(dir, statusFile, status, recoveryAttempts) {
|
|
|
673
716
|
scrubFinishedPlan(dir, status);
|
|
674
717
|
}
|
|
675
718
|
|
|
676
|
-
function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts) {
|
|
719
|
+
function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recoveryToken) {
|
|
677
720
|
status.status = "interrupted";
|
|
678
721
|
status.updated_at = new Date().toISOString();
|
|
679
722
|
status.finished_at = status.updated_at;
|
|
@@ -682,7 +725,7 @@ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts) {
|
|
|
682
725
|
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
683
726
|
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
684
727
|
rmSync(join(dir, "runner.pid"), { force: true });
|
|
685
|
-
return launchRunner(dir, true);
|
|
728
|
+
return launchRunner(dir, true, recoveryToken);
|
|
686
729
|
}
|
|
687
730
|
|
|
688
731
|
function acquireRecoveryLock(dir) {
|
|
@@ -707,32 +750,102 @@ function acquireJobTransitionLock(dir) {
|
|
|
707
750
|
}
|
|
708
751
|
|
|
709
752
|
function acquirePidLock(file, { allowHandoff = false } = {}) {
|
|
710
|
-
for (let attempt = 0; attempt <
|
|
753
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
754
|
+
const owner = pidLockOwner(process.pid, currentProcessStartTimeMs());
|
|
711
755
|
try {
|
|
712
|
-
|
|
756
|
+
createExclusiveFileSync(file, `${JSON.stringify(owner)}
|
|
757
|
+
`, { mode: 0o600 });
|
|
713
758
|
return {
|
|
714
759
|
...(allowHandoff ? {
|
|
715
760
|
handoff(pid) {
|
|
716
|
-
if (Number.isInteger(pid)
|
|
761
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
762
|
+
const nextOwner = { ...pidLockOwner(pid, processStartTimeMs(pid)), token: owner.token };
|
|
763
|
+
replacePrivateTextFile(file, `${JSON.stringify(nextOwner)}
|
|
764
|
+
`);
|
|
717
765
|
},
|
|
718
766
|
} : {}),
|
|
719
|
-
|
|
767
|
+
token: owner.token,
|
|
768
|
+
release() { removePidLockOwnedBy(file, owner.token); },
|
|
720
769
|
};
|
|
721
770
|
} catch (error) {
|
|
722
771
|
if (error?.code !== "EEXIST") throw error;
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const age = Date.now() -
|
|
726
|
-
const
|
|
727
|
-
const definitelyStale =
|
|
772
|
+
const snapshot = readPidLockSnapshot(file);
|
|
773
|
+
if (!snapshot) continue;
|
|
774
|
+
const age = Date.now() - snapshot.info.mtimeMs;
|
|
775
|
+
const identity = snapshot.owner ? inspectProcessInstance(snapshot.owner, { maxAgeMs: 5 * 60_000 }) : null;
|
|
776
|
+
const definitelyStale = !snapshot.owner
|
|
777
|
+
? age >= 60_000
|
|
778
|
+
: identity.reclaimable === true;
|
|
728
779
|
if (!definitelyStale) return null;
|
|
729
|
-
|
|
780
|
+
removePidLockSnapshot(file, snapshot);
|
|
730
781
|
}
|
|
731
782
|
}
|
|
732
783
|
return null;
|
|
733
784
|
}
|
|
734
785
|
|
|
735
|
-
function
|
|
786
|
+
function pidLockOwner(pid, startedAtMs) {
|
|
787
|
+
return {
|
|
788
|
+
pid,
|
|
789
|
+
token: randomBytes(16).toString("hex"),
|
|
790
|
+
startedAt: new Date().toISOString(),
|
|
791
|
+
processStartedAt: Number.isFinite(startedAtMs) && startedAtMs > 0 ? new Date(startedAtMs).toISOString() : null,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function readPidLockOwner(file) {
|
|
796
|
+
return readPidLockSnapshot(file)?.owner || null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function readPidLockSnapshot(file) {
|
|
800
|
+
let info;
|
|
801
|
+
try { info = lstatSync(file); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
802
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("job lock must be a regular non-symbolic-link file");
|
|
803
|
+
let owner = null;
|
|
804
|
+
try {
|
|
805
|
+
const text = readBoundedFile(file, 1024).toString("utf8").trim();
|
|
806
|
+
if (/^\d+$/.test(text)) owner = { pid: Number(text), startedAt: new Date(info.mtimeMs).toISOString() };
|
|
807
|
+
else {
|
|
808
|
+
const parsed = JSON.parse(text);
|
|
809
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
|
|
810
|
+
}
|
|
811
|
+
} catch (error) {
|
|
812
|
+
if (error?.code === "ENOENT") return null;
|
|
813
|
+
}
|
|
814
|
+
return { owner, info: pidLockIdentity(info) };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function removePidLockOwnedBy(file, token) {
|
|
818
|
+
const snapshot = readPidLockSnapshot(file);
|
|
819
|
+
if (!snapshot || snapshot.owner?.token !== token) return false;
|
|
820
|
+
return removePidLockSnapshot(file, snapshot);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function removePidLockSnapshot(file, snapshot) {
|
|
824
|
+
let current;
|
|
825
|
+
try { current = lstatSync(file); } catch (error) { return error?.code === "ENOENT"; }
|
|
826
|
+
if (current.isSymbolicLink() || !current.isFile()) return false;
|
|
827
|
+
if (!samePidLockIdentity(snapshot.info, pidLockIdentity(current))) return false;
|
|
828
|
+
if (snapshot.owner?.token) {
|
|
829
|
+
const currentOwner = readPidLockSnapshot(file)?.owner;
|
|
830
|
+
if (currentOwner?.token !== snapshot.owner.token) return false;
|
|
831
|
+
}
|
|
832
|
+
try { rmSync(file); return true; } catch (error) { return error?.code === "ENOENT"; }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function pidLockIdentity(info) {
|
|
836
|
+
return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs) };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function samePidLockIdentity(left, right) {
|
|
840
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function replacePrivateTextFile(file, content) {
|
|
844
|
+
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
845
|
+
ownerOnlyFile(file);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function launchRunner(dir, recover = false, recoveryToken = "") {
|
|
736
849
|
const args = [RUNNER_PATH, "--job-dir", dir];
|
|
737
850
|
if (recover) args.push("--recover");
|
|
738
851
|
const stdoutFile = join(dir, "runner.out.log");
|
|
@@ -749,6 +862,7 @@ function launchRunner(dir, recover = false) {
|
|
|
749
862
|
detached: true,
|
|
750
863
|
stdio: ["ignore", stdoutFd, stderrFd],
|
|
751
864
|
windowsHide: true,
|
|
865
|
+
env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
|
|
752
866
|
});
|
|
753
867
|
} finally {
|
|
754
868
|
if (stdoutFd !== undefined) closeSync(stdoutFd);
|
|
@@ -761,15 +875,30 @@ function launchRunner(dir, recover = false) {
|
|
|
761
875
|
}
|
|
762
876
|
|
|
763
877
|
|
|
764
|
-
function
|
|
878
|
+
function readRunnerOwner(dir, fallback = {}) {
|
|
765
879
|
try {
|
|
766
|
-
const
|
|
767
|
-
|
|
880
|
+
const text = readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8").trim();
|
|
881
|
+
if (/^\d+$/.test(text)) return { pid: Number(text), ...fallback };
|
|
882
|
+
const parsed = JSON.parse(text);
|
|
883
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...fallback };
|
|
884
|
+
return { ...fallback, ...parsed };
|
|
768
885
|
} catch {
|
|
769
|
-
return
|
|
886
|
+
return { ...fallback };
|
|
770
887
|
}
|
|
771
888
|
}
|
|
772
889
|
|
|
890
|
+
function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
|
|
891
|
+
const fallback = ownerOnly ? status : {
|
|
892
|
+
pid: Number(status?.runner_pid) || undefined,
|
|
893
|
+
processStartedAt: status?.runner_process_started_at,
|
|
894
|
+
startedAt: status?.started_at || status?.updated_at || status?.created_at,
|
|
895
|
+
};
|
|
896
|
+
const owner = readRunnerOwner(dir, fallback);
|
|
897
|
+
if (!owner.pid) return false;
|
|
898
|
+
const identity = inspectProcessInstance(owner, { maxAgeMs: Number.POSITIVE_INFINITY });
|
|
899
|
+
return identity.current || (identity.alive && !identity.reclaimable);
|
|
900
|
+
}
|
|
901
|
+
|
|
773
902
|
function scrubFinishedPlan(dir, status) {
|
|
774
903
|
if (PLAN_RETAINING_STATES.has(status.status)) return;
|
|
775
904
|
rmSync(join(dir, "plan.json"), { force: true });
|
|
@@ -816,20 +945,30 @@ function publicStatus(status) {
|
|
|
816
945
|
|
|
817
946
|
function atomicWriteJson(file, value, maxBytes) {
|
|
818
947
|
ensureOwnerOnlyDir(dirname(file));
|
|
819
|
-
const text = `${JSON.stringify(value, null, 2)}
|
|
948
|
+
const text = `${JSON.stringify(value, null, 2)}
|
|
949
|
+
`;
|
|
820
950
|
if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
|
|
821
|
-
|
|
822
|
-
writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
|
|
823
|
-
replaceFileSync(temp, file);
|
|
951
|
+
replaceFileAtomicallySync(file, text, { mode: 0o600 });
|
|
824
952
|
ownerOnlyFile(file);
|
|
825
953
|
}
|
|
826
954
|
|
|
827
|
-
function readJson(file, maxBytes) {
|
|
828
|
-
|
|
955
|
+
function readJson(file, maxBytes, label = "JSON") {
|
|
956
|
+
let buffer;
|
|
957
|
+
try { buffer = readBoundedFile(file, maxBytes); } catch (error) {
|
|
958
|
+
if (error?.code === "ENOENT") return null;
|
|
959
|
+
throw new Error(`${label} is unavailable (${resourceErrorClass(error)})`);
|
|
960
|
+
}
|
|
961
|
+
let text;
|
|
962
|
+
try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
|
|
963
|
+
throw new Error(`${label} is not valid UTF-8`);
|
|
964
|
+
}
|
|
965
|
+
try { return JSON.parse(text); } catch {
|
|
966
|
+
throw new Error(`${label} is not valid JSON`);
|
|
967
|
+
}
|
|
829
968
|
}
|
|
830
969
|
|
|
831
970
|
function readRequiredJson(file, maxBytes, label) {
|
|
832
|
-
const value = readJson(file, maxBytes);
|
|
971
|
+
const value = readJson(file, maxBytes, label);
|
|
833
972
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is unavailable or invalid`);
|
|
834
973
|
return value;
|
|
835
974
|
}
|
|
@@ -900,15 +1039,7 @@ function resourceErrorClass(error) {
|
|
|
900
1039
|
}
|
|
901
1040
|
|
|
902
1041
|
function safeReadDir(dir) {
|
|
903
|
-
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
function safeMtime(path) {
|
|
907
|
-
try { return statSync(path).mtimeMs; } catch { return 0; }
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
function isPidAlive(pid) {
|
|
911
|
-
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
|
|
1042
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
912
1043
|
}
|
|
913
1044
|
|
|
914
1045
|
function boundedString(value, maxBytes, label) {
|