@kontourai/flow-agents 3.8.0 → 3.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/CHANGELOG.md +7 -0
- package/build/src/builder-flow-runtime.js +16 -10
- package/build/src/cli/continuation-adapter.d.ts +24 -0
- package/build/src/cli/continuation-adapter.js +225 -0
- package/build/src/cli/workflow.js +47 -1
- package/build/src/continuation-driver.d.ts +92 -0
- package/build/src/continuation-driver.js +444 -0
- package/build/src/index.d.ts +2 -0
- package/build/src/index.js +1 -0
- package/docs/public-workflow-cli.md +54 -0
- package/docs/workflow-usage-guide.md +7 -0
- package/package.json +4 -4
- package/src/builder-flow-runtime.ts +9 -3
- package/src/cli/builder-flow-runtime.test.mjs +128 -2
- package/src/cli/continuation-adapter.ts +239 -0
- package/src/cli/continuation-driver.test.mjs +564 -0
- package/src/cli/workflow.ts +48 -1
- package/src/continuation-driver.ts +532 -0
- package/src/index.ts +20 -0
|
@@ -18,9 +18,11 @@ import {
|
|
|
18
18
|
syncBuilderFlowSession,
|
|
19
19
|
} from "../../build/src/builder-flow-runtime.js";
|
|
20
20
|
import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
|
|
21
|
+
import { driveBuilderFlowSession } from "../../build/src/continuation-driver.js";
|
|
21
22
|
import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
|
|
22
23
|
import { performLocalClaim, performLocalRelease, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
|
|
23
24
|
import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
|
|
25
|
+
import { main as workflowMain } from "../../build/src/cli/workflow.js";
|
|
24
26
|
import { buildTrustBundle, inferExecutedTestCount, main as workflowSidecarMain } from "../../build/src/cli/workflow-sidecar.js";
|
|
25
27
|
|
|
26
28
|
const SUBJECT = "local:work-item/runtime-projection";
|
|
@@ -88,7 +90,7 @@ function claimSessionAssignment(session) {
|
|
|
88
90
|
ttlSeconds: 1800,
|
|
89
91
|
actorKey: ACTOR_KEY,
|
|
90
92
|
branch: `agent/${session.slug}`,
|
|
91
|
-
artifactDir: session.
|
|
93
|
+
artifactDir: session.slug,
|
|
92
94
|
workItemRef: SUBJECT,
|
|
93
95
|
reason: "test",
|
|
94
96
|
});
|
|
@@ -100,7 +102,7 @@ function claimAmbientSessionAssignment(session) {
|
|
|
100
102
|
ttlSeconds: 1800,
|
|
101
103
|
actorKey: ambient.actorKey,
|
|
102
104
|
branch: `agent/${session.slug}`,
|
|
103
|
-
artifactDir: session.
|
|
105
|
+
artifactDir: session.slug,
|
|
104
106
|
workItemRef: SUBJECT,
|
|
105
107
|
reason: "test",
|
|
106
108
|
});
|
|
@@ -365,6 +367,130 @@ test("small-model client can start and advance from projected actions without ch
|
|
|
365
367
|
assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
|
|
366
368
|
});
|
|
367
369
|
|
|
370
|
+
test("Builder projection preserves Flow continuation semantics for exceptional statuses", async () => {
|
|
371
|
+
const session = makeSession("continuation-status-projection");
|
|
372
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
373
|
+
const stateFile = path.join(runDir(session.slug, session.projectRoot), "state.json");
|
|
374
|
+
const original = readJson(stateFile);
|
|
375
|
+
for (const [status, expected] of [
|
|
376
|
+
["blocked", "continue"],
|
|
377
|
+
["accepted_by_exception", "continue"],
|
|
378
|
+
["needs_decision", "blocked"],
|
|
379
|
+
["failed", "failed"],
|
|
380
|
+
]) {
|
|
381
|
+
writeJson(stateFile, { ...original, status, next_action: `fixture ${status}` });
|
|
382
|
+
const recovered = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
383
|
+
assert.equal(recovered.projection.next_action.status, expected, status);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("continuation driver advances two real FlowDefinition steps unattended", async () => {
|
|
388
|
+
const session = makeSession("continuation-driver-two-steps");
|
|
389
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
390
|
+
const visited = [];
|
|
391
|
+
|
|
392
|
+
const result = await driveBuilderFlowSession({
|
|
393
|
+
sessionDir: session.sessionDir,
|
|
394
|
+
maxTurns: 2,
|
|
395
|
+
execute: async (request) => {
|
|
396
|
+
visited.push(request.current_step);
|
|
397
|
+
if (request.current_step === "pull-work") {
|
|
398
|
+
writeBundle(session.sessionDir, [bundleClaim({
|
|
399
|
+
expectation: "selected-work",
|
|
400
|
+
claimType: "builder.pull-work.selected",
|
|
401
|
+
subjectType: "work-item",
|
|
402
|
+
})]);
|
|
403
|
+
} else if (request.current_step === "design-probe") {
|
|
404
|
+
writeBundle(session.sessionDir, [
|
|
405
|
+
bundleClaim({
|
|
406
|
+
expectation: "pickup-probe-readiness",
|
|
407
|
+
claimType: "builder.design-probe.pickup-readiness",
|
|
408
|
+
subjectType: "work-item",
|
|
409
|
+
}),
|
|
410
|
+
bundleClaim({
|
|
411
|
+
expectation: "probe-decisions-or-accepted-gaps",
|
|
412
|
+
claimType: "builder.design-probe.decisions",
|
|
413
|
+
subjectType: "decision",
|
|
414
|
+
}),
|
|
415
|
+
]);
|
|
416
|
+
} else {
|
|
417
|
+
assert.fail(`unexpected continuation step ${request.current_step}`);
|
|
418
|
+
}
|
|
419
|
+
return { status: "completed", summary: `completed ${request.current_step}` };
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
assert.equal(result.outcome, "budget_exhausted");
|
|
424
|
+
assert.equal(result.turns_started, 2);
|
|
425
|
+
assert.deepEqual(visited, ["pull-work", "design-probe"]);
|
|
426
|
+
assert.equal(result.snapshot.current_step, "plan");
|
|
427
|
+
assert.equal(result.snapshot.next_action.skills[0], "plan-work");
|
|
428
|
+
const driverState = readJson(path.join(session.sessionDir, "continuation-driver", "state.json"));
|
|
429
|
+
assert.equal(driverState.status, "budget_exhausted");
|
|
430
|
+
assert.equal(driverState.turns_started, 2);
|
|
431
|
+
const events = fs.readFileSync(path.join(session.sessionDir, "continuation-driver", "events.jsonl"), "utf8").trim().split("\n").map(JSON.parse);
|
|
432
|
+
assert.deepEqual(events.filter((event) => event.type === "turn_started").map((event) => event.current_step), ["pull-work", "design-probe"]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("public workflow drive invokes an explicit adapter but preserves canonical completion authority", async () => {
|
|
436
|
+
const session = makeSession("continuation-driver-cli");
|
|
437
|
+
claimAmbientSessionAssignment(session);
|
|
438
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
439
|
+
const adapter = path.join(session.projectRoot, "adapter.mjs");
|
|
440
|
+
const commandFile = path.join(session.projectRoot, "adapter-command.json");
|
|
441
|
+
fs.writeFileSync(adapter, `
|
|
442
|
+
import fs from "node:fs";
|
|
443
|
+
let input = "";
|
|
444
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
445
|
+
fs.writeFileSync("adapter-request.json", input);
|
|
446
|
+
process.stdout.write(JSON.stringify({ status: "completed", summary: "adapter says done" }));
|
|
447
|
+
`);
|
|
448
|
+
writeJson(commandFile, { argv: [process.execPath, adapter] });
|
|
449
|
+
|
|
450
|
+
const rc = await workflowMain([
|
|
451
|
+
"drive",
|
|
452
|
+
"--session-dir", session.sessionDir,
|
|
453
|
+
"--adapter-command-file", commandFile,
|
|
454
|
+
"--max-turns", "1",
|
|
455
|
+
"--turn-timeout-ms", "5000",
|
|
456
|
+
"--barrier-wait-ms", "0",
|
|
457
|
+
"--json",
|
|
458
|
+
]);
|
|
459
|
+
|
|
460
|
+
assert.equal(rc, 0);
|
|
461
|
+
const request = readJson(path.join(session.projectRoot, "adapter-request.json"));
|
|
462
|
+
assert.equal(request.current_step, "pull-work");
|
|
463
|
+
assert.deepEqual(request.next_action.skills, ["pull-work"]);
|
|
464
|
+
assert.equal(Object.hasOwn(request, "system_prompt"), false);
|
|
465
|
+
const driverState = readJson(path.join(session.sessionDir, "continuation-driver", "state.json"));
|
|
466
|
+
assert.equal(driverState.status, "budget_exhausted");
|
|
467
|
+
const canonical = await recoverBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
468
|
+
assert.equal(canonical.run.state.status, "active");
|
|
469
|
+
assert.equal(canonical.run.state.current_step, "pull-work");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("public workflow drive rejects a non-owner before adapter execution", async () => {
|
|
473
|
+
const session = makeSession("continuation-driver-owner");
|
|
474
|
+
claimSessionAssignment(session);
|
|
475
|
+
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
476
|
+
const marker = path.join(session.projectRoot, "adapter-ran");
|
|
477
|
+
const adapter = path.join(session.projectRoot, "adapter.mjs");
|
|
478
|
+
const commandFile = path.join(session.projectRoot, "adapter-command.json");
|
|
479
|
+
fs.writeFileSync(adapter, `
|
|
480
|
+
import fs from "node:fs";
|
|
481
|
+
fs.writeFileSync(${JSON.stringify(marker)}, "ran");
|
|
482
|
+
process.stdout.write(JSON.stringify({ status: "completed" }));
|
|
483
|
+
`);
|
|
484
|
+
writeJson(commandFile, { argv: [process.execPath, adapter] });
|
|
485
|
+
|
|
486
|
+
await assert.rejects(
|
|
487
|
+
workflowMain(["drive", "--session-dir", session.sessionDir, "--adapter-command-file", commandFile]),
|
|
488
|
+
/active, matching assignment actor/,
|
|
489
|
+
);
|
|
490
|
+
assert.equal(fs.existsSync(marker), false);
|
|
491
|
+
assert.equal(fs.existsSync(path.join(session.sessionDir, "continuation-driver")), false);
|
|
492
|
+
});
|
|
493
|
+
|
|
368
494
|
test("sync attaches the staged trust.bundle bytes when the session bundle is replaced", async () => {
|
|
369
495
|
const session = makeSession("snapshot-attachment");
|
|
370
496
|
await startBuilderFlowSession({ sessionDir: session.sessionDir });
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import type { ContinuationBarrier, ContinuationTurnRequest, ContinuationTurnResult } from "../continuation-driver.js";
|
|
6
|
+
|
|
7
|
+
export type ContinuationAdapterCommand = {
|
|
8
|
+
argv: string[];
|
|
9
|
+
identity: string;
|
|
10
|
+
integrity: Array<{ file: string; sha256: string }>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function loadContinuationAdapterCommand(commandFileInput: string): ContinuationAdapterCommand {
|
|
14
|
+
const commandFile = path.resolve(commandFileInput);
|
|
15
|
+
const command = validateAdapterCommand(JSON.parse(readRegularFileNoFollow(commandFile, "continuation adapter command file")) as unknown);
|
|
16
|
+
if (!path.isAbsolute(command.argv[0]!)) throw new Error("continuation adapter executable must be an absolute path");
|
|
17
|
+
const integrity = [...new Set(command.argv.filter((entry) => path.isAbsolute(entry) && regularFileExists(entry)))]
|
|
18
|
+
.map((file) => ({ file, sha256: sha256File(file, "continuation adapter integrity file") }));
|
|
19
|
+
if (!integrity.some((entry) => entry.file === command.argv[0])) throw new Error("continuation adapter executable must be a regular file");
|
|
20
|
+
const identity = createHash("sha256").update(JSON.stringify({ ...command, integrity })).digest("hex");
|
|
21
|
+
return { ...command, identity, integrity };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function executeContinuationAdapter(
|
|
25
|
+
commandFileInput: string,
|
|
26
|
+
request: ContinuationTurnRequest,
|
|
27
|
+
options: { cwd: string; timeoutMs: number },
|
|
28
|
+
): Promise<ContinuationTurnResult> {
|
|
29
|
+
const command = loadContinuationAdapterCommand(commandFileInput);
|
|
30
|
+
return executeLoadedContinuationAdapter(command, request, options);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function executeLoadedContinuationAdapter(
|
|
34
|
+
command: ContinuationAdapterCommand,
|
|
35
|
+
request: ContinuationTurnRequest,
|
|
36
|
+
options: { cwd: string; timeoutMs: number },
|
|
37
|
+
): Promise<ContinuationTurnResult> {
|
|
38
|
+
for (const entry of command.integrity) {
|
|
39
|
+
if (sha256File(entry.file, "continuation adapter integrity file") !== entry.sha256) {
|
|
40
|
+
throw new Error(`continuation adapter integrity changed after mission binding: ${entry.file}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
assertPositiveInteger(options.timeoutMs, "continuation adapter timeoutMs", 1, 86_400_000);
|
|
44
|
+
return await spawnAdapter(command, request, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function waitForContinuationBarrier(
|
|
48
|
+
barrier: ContinuationBarrier,
|
|
49
|
+
options: { maxWaitMs: number; pollMs: number; now?: () => number; sleep?: (ms: number) => Promise<void> },
|
|
50
|
+
): Promise<"ready" | "pending"> {
|
|
51
|
+
assertPositiveInteger(options.maxWaitMs, "continuation barrier maxWaitMs", 0, 86_400_000);
|
|
52
|
+
assertPositiveInteger(options.pollMs, "continuation barrier pollMs", 1, 60_000);
|
|
53
|
+
const now = options.now ?? Date.now;
|
|
54
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
55
|
+
const stopAt = now() + options.maxWaitMs;
|
|
56
|
+
|
|
57
|
+
if (barrier.kind === "deadline") {
|
|
58
|
+
const deadline = Date.parse(barrier.at);
|
|
59
|
+
if (deadline <= now()) return "ready";
|
|
60
|
+
const waitMs = Math.min(deadline - now(), Math.max(0, stopAt - now()));
|
|
61
|
+
if (waitMs > 0) await sleep(waitMs);
|
|
62
|
+
return Date.parse(barrier.at) <= now() ? "ready" : "pending";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
while (pidAlive(barrier.pid)) {
|
|
66
|
+
const remaining = stopAt - now();
|
|
67
|
+
if (remaining <= 0) return "pending";
|
|
68
|
+
await sleep(Math.min(options.pollMs, remaining));
|
|
69
|
+
}
|
|
70
|
+
return "ready";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function spawnAdapter(
|
|
74
|
+
command: ContinuationAdapterCommand,
|
|
75
|
+
request: ContinuationTurnRequest,
|
|
76
|
+
options: { cwd: string; timeoutMs: number },
|
|
77
|
+
): Promise<ContinuationTurnResult> {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const child = spawn(command.argv[0]!, command.argv.slice(1), {
|
|
80
|
+
cwd: options.cwd,
|
|
81
|
+
env: process.env,
|
|
82
|
+
detached: process.platform !== "win32",
|
|
83
|
+
shell: false,
|
|
84
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
85
|
+
});
|
|
86
|
+
const stdout: Buffer[] = [];
|
|
87
|
+
const stderr: Buffer[] = [];
|
|
88
|
+
let stdoutBytes = 0;
|
|
89
|
+
let stderrBytes = 0;
|
|
90
|
+
let settled = false;
|
|
91
|
+
let forcedKill: NodeJS.Timeout | undefined;
|
|
92
|
+
let terminationError: Error | undefined;
|
|
93
|
+
const maxBytes = 4 * 1024 * 1024;
|
|
94
|
+
const timeout = setTimeout(() => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
terminationError = new Error(`continuation adapter timed out after ${options.timeoutMs}ms`);
|
|
97
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
98
|
+
forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
|
|
99
|
+
}, options.timeoutMs);
|
|
100
|
+
|
|
101
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
102
|
+
stdoutBytes += chunk.length;
|
|
103
|
+
if (stdoutBytes > maxBytes) {
|
|
104
|
+
if (!terminationError) {
|
|
105
|
+
terminationError = new Error("continuation adapter stdout exceeded 4 MiB");
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
108
|
+
forcedKill = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 250);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
stdout.push(chunk);
|
|
113
|
+
});
|
|
114
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
115
|
+
stderrBytes += chunk.length;
|
|
116
|
+
if (stderrBytes <= maxBytes) stderr.push(chunk);
|
|
117
|
+
});
|
|
118
|
+
child.on("error", (error) => {
|
|
119
|
+
if (settled) return;
|
|
120
|
+
settled = true;
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
if (forcedKill) clearTimeout(forcedKill);
|
|
123
|
+
reject(error);
|
|
124
|
+
});
|
|
125
|
+
child.on("close", (code, signal) => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timeout);
|
|
129
|
+
if (forcedKill) clearTimeout(forcedKill);
|
|
130
|
+
if (terminationError) {
|
|
131
|
+
reject(terminationError);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const stderrText = Buffer.concat(stderr).toString("utf8").trim();
|
|
135
|
+
if (code !== 0) {
|
|
136
|
+
scheduleProcessGroupTermination(child.pid);
|
|
137
|
+
reject(new Error(`continuation adapter exited ${code ?? signal ?? "unknown"}${stderrText ? `: ${stderrText}` : ""}`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const output = Buffer.concat(stdout).toString("utf8").trim();
|
|
141
|
+
try {
|
|
142
|
+
const result = JSON.parse(output) as ContinuationTurnResult;
|
|
143
|
+
if (!isValidWaitResult(result)) scheduleProcessGroupTermination(child.pid);
|
|
144
|
+
resolve(result);
|
|
145
|
+
} catch {
|
|
146
|
+
scheduleProcessGroupTermination(child.pid);
|
|
147
|
+
reject(new Error("continuation adapter must emit exactly one JSON result on stdout"));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
child.stdin.on("error", (error) => {
|
|
151
|
+
if ((error as NodeJS.ErrnoException).code !== "EPIPE" && !settled) {
|
|
152
|
+
settled = true;
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
if (forcedKill) clearTimeout(forcedKill);
|
|
155
|
+
scheduleProcessGroupTermination(child.pid);
|
|
156
|
+
reject(error);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
child.stdin.end(`${JSON.stringify(request)}\n`);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateAdapterCommand(value: unknown): Pick<ContinuationAdapterCommand, "argv"> {
|
|
164
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("continuation adapter command file must contain an object");
|
|
165
|
+
const argv = (value as { argv?: unknown }).argv;
|
|
166
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.length > 128 || argv.some((entry) => typeof entry !== "string" || entry.length === 0 || entry.includes("\0"))) {
|
|
167
|
+
throw new Error("continuation adapter command argv must contain 1 through 128 non-empty strings");
|
|
168
|
+
}
|
|
169
|
+
return { argv: [...argv] as string[] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function assertRegularFile(file: string, label: string): void {
|
|
173
|
+
const stat = fs.lstatSync(file);
|
|
174
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must be a regular file`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readRegularFileNoFollow(file: string, label: string): string {
|
|
178
|
+
return readRegularFileBufferNoFollow(file, label).toString("utf8");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readRegularFileBufferNoFollow(file: string, label: string): Buffer {
|
|
182
|
+
assertRegularFile(file, label);
|
|
183
|
+
const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
184
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
|
185
|
+
try {
|
|
186
|
+
if (!fs.fstatSync(fd).isFile()) throw new Error(`${label} must be a regular file`);
|
|
187
|
+
return fs.readFileSync(fd);
|
|
188
|
+
} finally {
|
|
189
|
+
fs.closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function assertPositiveInteger(value: number, label: string, min: number, max: number): void {
|
|
194
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} through ${max}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pidAlive(pid: number): boolean {
|
|
198
|
+
try {
|
|
199
|
+
process.kill(pid, 0);
|
|
200
|
+
return true;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function terminateProcessGroup(pid: number | undefined, signal: NodeJS.Signals): void {
|
|
207
|
+
if (!pid) return;
|
|
208
|
+
try {
|
|
209
|
+
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isValidWaitResult(result: ContinuationTurnResult): boolean {
|
|
216
|
+
if (result?.status !== "wait" || !result.barrier || typeof result.barrier !== "object") return false;
|
|
217
|
+
if (result.barrier.kind === "pid") return Number.isSafeInteger(result.barrier.pid) && result.barrier.pid > 0;
|
|
218
|
+
return result.barrier.kind === "deadline" && typeof result.barrier.at === "string" && Number.isFinite(Date.parse(result.barrier.at));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function scheduleProcessGroupTermination(pid: number | undefined): void {
|
|
222
|
+
terminateProcessGroup(pid, "SIGTERM");
|
|
223
|
+
const forcedKill = setTimeout(() => terminateProcessGroup(pid, "SIGKILL"), 250);
|
|
224
|
+
forcedKill.unref();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function regularFileExists(file: string): boolean {
|
|
228
|
+
try {
|
|
229
|
+
const stat = fs.lstatSync(file);
|
|
230
|
+
return !stat.isSymbolicLink() && stat.isFile();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sha256File(file: string, label: string): string {
|
|
238
|
+
return createHash("sha256").update(readRegularFileBufferNoFollow(file, label)).digest("hex");
|
|
239
|
+
}
|