@melaya/runner 1.1.68 → 1.1.70
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/dist/browserBridge.js +178 -0
- package/dist/browserFast.d.ts +144 -0
- package/dist/browserFast.js +363 -0
- package/dist/browserProvisioner.d.ts +39 -0
- package/dist/browserProvisioner.js +96 -0
- package/dist/cli.js +190 -65
- package/dist/codeWorker.js +33 -17
- package/dist/computerHelperManifest.d.ts +5 -0
- package/dist/computerHelperManifest.js +18 -0
- package/dist/computerHostCli.d.ts +233 -0
- package/dist/computerHostCli.js +815 -0
- package/dist/computerRelay.d.ts +73 -0
- package/dist/computerRelay.js +280 -0
- package/dist/connection.d.ts +18 -2
- package/dist/connection.js +322 -82
- package/dist/detect.d.ts +40 -1
- package/dist/detect.js +194 -36
- package/dist/hostChannel.d.ts +126 -0
- package/dist/hostChannel.js +117 -0
- package/dist/hostFiles.d.ts +25 -0
- package/dist/hostFiles.js +90 -0
- package/dist/linkedinLogin.js +13 -17
- package/dist/lumaLogin.js +11 -17
- package/dist/modelRescan.d.ts +36 -0
- package/dist/modelRescan.js +80 -0
- package/dist/processTree.d.ts +65 -0
- package/dist/processTree.js +237 -0
- package/dist/publishGate.js +14 -4
- package/dist/pythonEnv.js +6 -7
- package/dist/sharedVendor.js +3 -1
- package/dist/singleInstance.js +10 -1
- package/dist/startupCache.d.ts +22 -0
- package/dist/startupCache.js +45 -0
- package/dist/startupTimings.d.ts +34 -0
- package/dist/startupTimings.js +81 -0
- package/dist/uvBootstrap.js +32 -24
- package/dist/vaults.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files this runner produced on the computer, reported to the Melaya desktop
|
|
3
|
+
* app (contract ComputerRunnerFileMessage, { type: "computer.file" }) so
|
|
4
|
+
* computer control can open them or pick them in a file dialog by handle.
|
|
5
|
+
*
|
|
6
|
+
* Only inside the desktop app (hostChannel.isHosted()); under `npx` every
|
|
7
|
+
* function here is a no-op. The path goes to the app's main process over the
|
|
8
|
+
* parentPort and never to the server: the app turns it into an opaque handle.
|
|
9
|
+
*
|
|
10
|
+
* Sources: Assistant turn output files (after assistantFiles.ts accepted them)
|
|
11
|
+
* and pipeline outputs written to MELAYA_OUTPUT_DIR during a run. Browser
|
|
12
|
+
* downloads are denied by the browser bridge (acceptDownloads: false), so there
|
|
13
|
+
* is nothing to report from there.
|
|
14
|
+
*/
|
|
15
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
16
|
+
import { basename, extname, join } from "node:path";
|
|
17
|
+
import { isHosted, sendToHost } from "./hostChannel.js";
|
|
18
|
+
/** Never offered to computer actions as a file handle. */
|
|
19
|
+
const BLOCKED_EXT = new Set([
|
|
20
|
+
"exe", "msi", "bat", "cmd", "com", "scr", "ps1", "vbs", "js", "jse", "wsf", "hta", "lnk", "reg", "jar",
|
|
21
|
+
"dll", "app", "pkg", "dmg", "sh", "command", "py", "pyc",
|
|
22
|
+
]);
|
|
23
|
+
/** At most this many files per run, so a tool that writes thousands of files cannot flood the host. */
|
|
24
|
+
export const MAX_REPORTED_PER_RUN = 25;
|
|
25
|
+
function reportable(path) {
|
|
26
|
+
try {
|
|
27
|
+
const st = lstatSync(path);
|
|
28
|
+
if (!st.isFile() || st.isSymbolicLink() || st.size <= 0)
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const ext = extname(path).slice(1).toLowerCase();
|
|
35
|
+
return !!ext && !BLOCKED_EXT.has(ext);
|
|
36
|
+
}
|
|
37
|
+
/** Report one file. Returns true when a message was sent. */
|
|
38
|
+
export function reportHostFile(path, origin) {
|
|
39
|
+
if (!isHosted() || typeof path !== "string" || !path)
|
|
40
|
+
return false;
|
|
41
|
+
if (!reportable(path))
|
|
42
|
+
return false;
|
|
43
|
+
sendToHost({ type: "computer.file", path, name: basename(path), origin });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Files in `dir` (and one level of sub-folders) modified at or after `sinceMs`,
|
|
48
|
+
* newest first, capped. Pure apart from reading the folder.
|
|
49
|
+
*/
|
|
50
|
+
export function filesWrittenSince(dir, sinceMs, max = MAX_REPORTED_PER_RUN) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const walk = (d, depth) => {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
const p = join(d, e.name);
|
|
62
|
+
if (e.isDirectory()) {
|
|
63
|
+
if (depth < 1)
|
|
64
|
+
walk(p, depth + 1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!e.isFile())
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
const m = lstatSync(p).mtimeMs;
|
|
71
|
+
// 2 s of slack for filesystems with coarse timestamps.
|
|
72
|
+
if (m >= sinceMs - 2000)
|
|
73
|
+
out.push({ p, m });
|
|
74
|
+
}
|
|
75
|
+
catch { /* gone */ }
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
walk(dir, 0);
|
|
79
|
+
return out.sort((a, b) => b.m - a.m).slice(0, max).map((x) => x.p);
|
|
80
|
+
}
|
|
81
|
+
/** After a run: report the outputs it wrote. No-op outside the desktop app. */
|
|
82
|
+
export function reportOutputsSince(dir, sinceMs) {
|
|
83
|
+
if (!isHosted() || !dir)
|
|
84
|
+
return 0;
|
|
85
|
+
let n = 0;
|
|
86
|
+
for (const p of filesWrittenSince(dir, sinceMs))
|
|
87
|
+
if (reportHostFile(p, "output"))
|
|
88
|
+
n++;
|
|
89
|
+
return n;
|
|
90
|
+
}
|
package/dist/linkedinLogin.js
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { spawn } from "child_process";
|
|
42
42
|
import chalk from "chalk";
|
|
43
|
+
import { launchInstalledBrowser, ProvisionError } from "./browserProvisioner.js";
|
|
43
44
|
const LOGIN_TIMEOUT_MS = 5 * 60_000; // 5 min for the user to finish typing
|
|
44
45
|
const POLL_INTERVAL_MS = 750;
|
|
45
46
|
// URL fragments that indicate "the user is logged in" — LinkedIn redirects
|
|
@@ -163,26 +164,21 @@ export async function runLinkedInLoginFlow(progress) {
|
|
|
163
164
|
let browser = null;
|
|
164
165
|
try {
|
|
165
166
|
progress("Opening LinkedIn login window…");
|
|
167
|
+
// One provisioner for every headed flow (browserProvisioner.ts): installed
|
|
168
|
+
// Chrome, then Edge, then Brave. Downloading Chromium through npx is a
|
|
169
|
+
// CLI-only last resort; the desktop app gets a clear "install Chrome or
|
|
170
|
+
// Edge" instead (audit P1.4).
|
|
166
171
|
try {
|
|
167
|
-
browser = await playwright.chromium.launch({
|
|
168
|
-
headless: false,
|
|
169
|
-
channel: "chrome",
|
|
172
|
+
({ browser } = await launchInstalledBrowser((o) => playwright.chromium.launch(o), {
|
|
170
173
|
args: ["--disable-blink-features=AutomationControlled"],
|
|
171
|
-
|
|
174
|
+
progress,
|
|
175
|
+
downloadChromium: () => _installChromium(progress),
|
|
176
|
+
}));
|
|
172
177
|
}
|
|
173
|
-
catch (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
await _installChromium(progress);
|
|
178
|
-
}
|
|
179
|
-
catch (e) {
|
|
180
|
-
return { ok: false, error: "chromium_install_failed", detail: e?.message };
|
|
181
|
-
}
|
|
182
|
-
browser = await playwright.chromium.launch({
|
|
183
|
-
headless: false,
|
|
184
|
-
args: ["--disable-blink-features=AutomationControlled"],
|
|
185
|
-
});
|
|
178
|
+
catch (e) {
|
|
179
|
+
if (e instanceof ProvisionError)
|
|
180
|
+
return { ok: false, error: "browser_not_installed", detail: e.instructions || e.message };
|
|
181
|
+
return { ok: false, error: "chromium_install_failed", detail: e?.message };
|
|
186
182
|
}
|
|
187
183
|
_activeLoginBrowser = browser;
|
|
188
184
|
const context = await browser.newContext({
|
package/dist/lumaLogin.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { spawn } from "child_process";
|
|
25
25
|
import chalk from "chalk";
|
|
26
|
+
import { launchInstalledBrowser, ProvisionError } from "./browserProvisioner.js";
|
|
26
27
|
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
27
28
|
const POLL_INTERVAL_MS = 750;
|
|
28
29
|
// Pages that mean "logged in to Luma". Luma's web app routes the user to
|
|
@@ -128,26 +129,19 @@ export async function runLumaLoginFlow(progress) {
|
|
|
128
129
|
let browser = null;
|
|
129
130
|
try {
|
|
130
131
|
progress("Opening Luma login window…");
|
|
132
|
+
// Same provisioner as LinkedIn (browserProvisioner.ts): installed Chrome,
|
|
133
|
+
// Edge, Brave; npx Chromium download only from the CLI (audit P1.4).
|
|
131
134
|
try {
|
|
132
|
-
browser = await playwright.chromium.launch({
|
|
133
|
-
headless: false,
|
|
134
|
-
channel: "chrome",
|
|
135
|
+
({ browser } = await launchInstalledBrowser((o) => playwright.chromium.launch(o), {
|
|
135
136
|
args: ["--disable-blink-features=AutomationControlled"],
|
|
136
|
-
|
|
137
|
+
progress,
|
|
138
|
+
downloadChromium: () => _installChromium(progress),
|
|
139
|
+
}));
|
|
137
140
|
}
|
|
138
|
-
catch (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
await _installChromium(progress);
|
|
143
|
-
}
|
|
144
|
-
catch (e) {
|
|
145
|
-
return { ok: false, error: "chromium_install_failed", detail: e?.message };
|
|
146
|
-
}
|
|
147
|
-
browser = await playwright.chromium.launch({
|
|
148
|
-
headless: false,
|
|
149
|
-
args: ["--disable-blink-features=AutomationControlled"],
|
|
150
|
-
});
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (e instanceof ProvisionError)
|
|
143
|
+
return { ok: false, error: "browser_not_installed", detail: e.instructions || e.message };
|
|
144
|
+
return { ok: false, error: "chromium_install_failed", detail: e?.message };
|
|
151
145
|
}
|
|
152
146
|
_activeLoginBrowser = browser;
|
|
153
147
|
const context = await browser.newContext({
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forced model rescans on request.
|
|
3
|
+
*
|
|
4
|
+
* The runner rescans its model providers every 15 min (connection.ts). When
|
|
5
|
+
* the desktop app launches an engine (Ollama / LM Studio started, a Claude
|
|
6
|
+
* Code / Codex / Copilot sign-in finished) it sends { type: "rescan_models" }
|
|
7
|
+
* (hostChannel.ts), so the new models reach Melaya in seconds instead.
|
|
8
|
+
*
|
|
9
|
+
* Requests are cheap to send and may arrive in bursts (the app after a launch,
|
|
10
|
+
* the web page every 10 s while it waits), so:
|
|
11
|
+
* - a request while a forced scan runs shares that scan;
|
|
12
|
+
* - at most one forced scan starts per `minIntervalMs` (5 s);
|
|
13
|
+
* - a request inside that window is not dropped: it schedules one trailing
|
|
14
|
+
* scan at the end of the window, shared by every request until then.
|
|
15
|
+
*/
|
|
16
|
+
export interface ForcedRescanOptions<T> {
|
|
17
|
+
/** One forced provider scan (scanProviders({ force: true })). */
|
|
18
|
+
scan: () => Promise<T>;
|
|
19
|
+
/** Publish the result the way the periodic rescan does (runner:models). */
|
|
20
|
+
publish: (result: T) => void;
|
|
21
|
+
/** Minimum gap between two forced scan starts. Default 5 s. */
|
|
22
|
+
minIntervalMs?: number;
|
|
23
|
+
/** Called when a scan or publish throws; the last good list stays. */
|
|
24
|
+
onError?: (e: unknown) => void;
|
|
25
|
+
/** Test seams. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
setTimer?: (fn: () => void, ms: number) => () => void;
|
|
28
|
+
}
|
|
29
|
+
export interface ForcedRescan {
|
|
30
|
+
/** Ask for a forced rescan. Resolves once the scan that serves it has published (or failed). */
|
|
31
|
+
request(): Promise<void>;
|
|
32
|
+
/** Cancel a pending trailing scan (shutdown). */
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
export declare const FORCED_RESCAN_MIN_INTERVAL_MS = 5000;
|
|
36
|
+
export declare function createForcedRescan<T>(opts: ForcedRescanOptions<T>): ForcedRescan;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forced model rescans on request.
|
|
3
|
+
*
|
|
4
|
+
* The runner rescans its model providers every 15 min (connection.ts). When
|
|
5
|
+
* the desktop app launches an engine (Ollama / LM Studio started, a Claude
|
|
6
|
+
* Code / Codex / Copilot sign-in finished) it sends { type: "rescan_models" }
|
|
7
|
+
* (hostChannel.ts), so the new models reach Melaya in seconds instead.
|
|
8
|
+
*
|
|
9
|
+
* Requests are cheap to send and may arrive in bursts (the app after a launch,
|
|
10
|
+
* the web page every 10 s while it waits), so:
|
|
11
|
+
* - a request while a forced scan runs shares that scan;
|
|
12
|
+
* - at most one forced scan starts per `minIntervalMs` (5 s);
|
|
13
|
+
* - a request inside that window is not dropped: it schedules one trailing
|
|
14
|
+
* scan at the end of the window, shared by every request until then.
|
|
15
|
+
*/
|
|
16
|
+
export const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000;
|
|
17
|
+
function defaultTimer(fn, ms) {
|
|
18
|
+
const t = setTimeout(fn, ms);
|
|
19
|
+
t.unref?.();
|
|
20
|
+
return () => clearTimeout(t);
|
|
21
|
+
}
|
|
22
|
+
export function createForcedRescan(opts) {
|
|
23
|
+
const minGap = opts.minIntervalMs ?? FORCED_RESCAN_MIN_INTERVAL_MS;
|
|
24
|
+
const now = opts.now ?? Date.now;
|
|
25
|
+
const setTimer = opts.setTimer ?? defaultTimer;
|
|
26
|
+
let inFlight = null;
|
|
27
|
+
let lastStart = Number.NEGATIVE_INFINITY;
|
|
28
|
+
let trailing = null;
|
|
29
|
+
let disposed = false;
|
|
30
|
+
const run = () => {
|
|
31
|
+
lastStart = now();
|
|
32
|
+
const p = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
opts.publish(await opts.scan());
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
try {
|
|
38
|
+
opts.onError?.(e);
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
})().finally(() => { if (inFlight === p)
|
|
43
|
+
inFlight = null; });
|
|
44
|
+
inFlight = p;
|
|
45
|
+
return p;
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
request() {
|
|
49
|
+
if (disposed)
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
if (inFlight)
|
|
52
|
+
return inFlight;
|
|
53
|
+
if (trailing)
|
|
54
|
+
return trailing.promise;
|
|
55
|
+
const wait = lastStart + minGap - now();
|
|
56
|
+
if (wait <= 0)
|
|
57
|
+
return run();
|
|
58
|
+
let resolve;
|
|
59
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
60
|
+
const cancel = setTimer(() => {
|
|
61
|
+
trailing = null;
|
|
62
|
+
if (disposed) {
|
|
63
|
+
resolve();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
(inFlight ?? run()).then(resolve, resolve);
|
|
67
|
+
}, wait);
|
|
68
|
+
trailing = { promise, cancel, resolve };
|
|
69
|
+
return promise;
|
|
70
|
+
},
|
|
71
|
+
dispose() {
|
|
72
|
+
disposed = true;
|
|
73
|
+
if (trailing) {
|
|
74
|
+
trailing.cancel();
|
|
75
|
+
trailing.resolve();
|
|
76
|
+
trailing = null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child process trees: spawn, track, and stop them for sure.
|
|
3
|
+
*
|
|
4
|
+
* A runner child (pipeline Python, Assistant host, RAG ingest, venv build) can
|
|
5
|
+
* start its own children (a CLI model provider, a browser, pip). Killing only
|
|
6
|
+
* the direct child leaves those grandchildren running after the runner exits.
|
|
7
|
+
*
|
|
8
|
+
* - POSIX: every tracked child is spawned as the leader of its own process
|
|
9
|
+
* group (`detached: true`), so one signal to `-pid` reaches the whole tree.
|
|
10
|
+
* - Windows: `taskkill /PID <pid> /T` first (asks windowed processes such as
|
|
11
|
+
* browsers to close), then `taskkill /PID <pid> /T /F` after the grace period.
|
|
12
|
+
*
|
|
13
|
+
* Every tracked child is also killed synchronously on process exit, so a crash
|
|
14
|
+
* or a `process.exit()` elsewhere does not leave orphans.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn as rawSpawn, type ChildProcess } from "child_process";
|
|
17
|
+
/** Track a child so shutdown (and process exit) stops its whole tree. Returns the child. */
|
|
18
|
+
export declare function trackChild<T extends ChildProcess>(child: T): T;
|
|
19
|
+
/** Live tracked children (for diagnostics and tests). */
|
|
20
|
+
export declare function trackedChildren(): ChildProcess[];
|
|
21
|
+
/**
|
|
22
|
+
* Drop-in replacement for child_process.spawn: the child leads its own process
|
|
23
|
+
* group on POSIX (unless the caller set `detached` itself) and is tracked.
|
|
24
|
+
* Windows keeps the default (detached there means a new console window).
|
|
25
|
+
*/
|
|
26
|
+
export declare const spawnTracked: typeof rawSpawn;
|
|
27
|
+
/**
|
|
28
|
+
* Stop a child and every descendant: a graceful request first, then a forced
|
|
29
|
+
* kill once `graceMs` has passed. Resolves when the tree is gone (or the forced
|
|
30
|
+
* kill was issued). Never throws.
|
|
31
|
+
*/
|
|
32
|
+
export declare function terminateTree(child: ChildProcess, opts?: {
|
|
33
|
+
graceMs?: number;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
/** Stop every tracked child tree, in parallel, within the grace period. */
|
|
36
|
+
export declare function terminateAllTracked(opts?: {
|
|
37
|
+
graceMs?: number;
|
|
38
|
+
}): Promise<number>;
|
|
39
|
+
/** Last resort on process exit: synchronous forced kill of every tracked tree. */
|
|
40
|
+
export declare function killAllTrackedSync(): void;
|
|
41
|
+
export interface ShutdownStep {
|
|
42
|
+
name: string;
|
|
43
|
+
/** Upper bound for this step; the shutdown moves on when it passes. */
|
|
44
|
+
deadlineMs: number;
|
|
45
|
+
run: () => unknown | Promise<unknown>;
|
|
46
|
+
}
|
|
47
|
+
export interface ShutdownStepResult {
|
|
48
|
+
name: string;
|
|
49
|
+
outcome: "ok" | "timeout" | "error";
|
|
50
|
+
ms: number;
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Run independent shutdown steps in parallel, each bounded by its own deadline. */
|
|
54
|
+
export declare function runShutdownSteps(steps: ShutdownStep[]): Promise<ShutdownStepResult[]>;
|
|
55
|
+
/** One line for logs: "runs ok 12ms, browsers timeout 3000ms". */
|
|
56
|
+
export declare function formatShutdownResults(results: ShutdownStepResult[]): string;
|
|
57
|
+
export declare function claimGracefulShutdown(): void;
|
|
58
|
+
export declare function gracefulShutdownClaimed(): boolean;
|
|
59
|
+
/** Run this child below normal priority for its whole life (fire-and-forget warm-ups). */
|
|
60
|
+
export declare function lowerPriority(child: ChildProcess): ChildProcess;
|
|
61
|
+
/** Mark a setup child; it runs below normal priority while setup is in background mode. */
|
|
62
|
+
export declare function registerSetupChild(child: ChildProcess): ChildProcess;
|
|
63
|
+
/** Background mode on: new setup children start below normal priority. */
|
|
64
|
+
export declare function setSetupBackground(on: boolean): void;
|
|
65
|
+
export declare function setupIsBackground(): boolean;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child process trees: spawn, track, and stop them for sure.
|
|
3
|
+
*
|
|
4
|
+
* A runner child (pipeline Python, Assistant host, RAG ingest, venv build) can
|
|
5
|
+
* start its own children (a CLI model provider, a browser, pip). Killing only
|
|
6
|
+
* the direct child leaves those grandchildren running after the runner exits.
|
|
7
|
+
*
|
|
8
|
+
* - POSIX: every tracked child is spawned as the leader of its own process
|
|
9
|
+
* group (`detached: true`), so one signal to `-pid` reaches the whole tree.
|
|
10
|
+
* - Windows: `taskkill /PID <pid> /T` first (asks windowed processes such as
|
|
11
|
+
* browsers to close), then `taskkill /PID <pid> /T /F` after the grace period.
|
|
12
|
+
*
|
|
13
|
+
* Every tracked child is also killed synchronously on process exit, so a crash
|
|
14
|
+
* or a `process.exit()` elsewhere does not leave orphans.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn as rawSpawn, spawnSync, execFile } from "child_process";
|
|
17
|
+
import os from "os";
|
|
18
|
+
const isWin = process.platform === "win32";
|
|
19
|
+
const tracked = new Set();
|
|
20
|
+
let exitHookInstalled = false;
|
|
21
|
+
function alive(child) {
|
|
22
|
+
return child.exitCode === null && child.signalCode === null && typeof child.pid === "number";
|
|
23
|
+
}
|
|
24
|
+
/** Track a child so shutdown (and process exit) stops its whole tree. Returns the child. */
|
|
25
|
+
export function trackChild(child) {
|
|
26
|
+
if (!child || typeof child.pid !== "number")
|
|
27
|
+
return child;
|
|
28
|
+
tracked.add(child);
|
|
29
|
+
const forget = () => { tracked.delete(child); };
|
|
30
|
+
child.once("exit", forget);
|
|
31
|
+
child.once("error", forget);
|
|
32
|
+
if (!exitHookInstalled) {
|
|
33
|
+
exitHookInstalled = true;
|
|
34
|
+
process.on("exit", killAllTrackedSync);
|
|
35
|
+
}
|
|
36
|
+
return child;
|
|
37
|
+
}
|
|
38
|
+
/** Live tracked children (for diagnostics and tests). */
|
|
39
|
+
export function trackedChildren() {
|
|
40
|
+
return [...tracked].filter(alive);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Drop-in replacement for child_process.spawn: the child leads its own process
|
|
44
|
+
* group on POSIX (unless the caller set `detached` itself) and is tracked.
|
|
45
|
+
* Windows keeps the default (detached there means a new console window).
|
|
46
|
+
*/
|
|
47
|
+
export const spawnTracked = ((command, argsOrOptions, maybeOptions) => {
|
|
48
|
+
const args = Array.isArray(argsOrOptions) ? argsOrOptions : [];
|
|
49
|
+
const given = ((Array.isArray(argsOrOptions) ? maybeOptions : argsOrOptions) ?? {});
|
|
50
|
+
const opts = { ...given };
|
|
51
|
+
if (!isWin && opts.detached === undefined)
|
|
52
|
+
opts.detached = true;
|
|
53
|
+
if (isWin && opts.windowsHide === undefined)
|
|
54
|
+
opts.windowsHide = true;
|
|
55
|
+
return trackChild(rawSpawn(command, args, opts));
|
|
56
|
+
});
|
|
57
|
+
function taskkill(pid, force) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const args = ["/PID", String(pid), "/T"];
|
|
60
|
+
if (force)
|
|
61
|
+
args.push("/F");
|
|
62
|
+
try {
|
|
63
|
+
execFile("taskkill", args, { windowsHide: true, timeout: 5000 }, () => resolve());
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
resolve();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function signalTree(child, signal) {
|
|
71
|
+
const pid = child.pid;
|
|
72
|
+
if (typeof pid !== "number")
|
|
73
|
+
return;
|
|
74
|
+
// Group first (covers grandchildren); the plain pid when it does not lead a group.
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-pid, signal);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch { /* not a group leader, or gone */ }
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, signal);
|
|
82
|
+
}
|
|
83
|
+
catch { /* gone */ }
|
|
84
|
+
}
|
|
85
|
+
function waitExit(child, ms) {
|
|
86
|
+
if (!alive(child))
|
|
87
|
+
return Promise.resolve(true);
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
let done = false;
|
|
90
|
+
const finish = (v) => { if (done)
|
|
91
|
+
return; done = true; clearTimeout(t); resolve(v); };
|
|
92
|
+
const t = setTimeout(() => finish(!alive(child)), ms);
|
|
93
|
+
child.once("exit", () => finish(true));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Stop a child and every descendant: a graceful request first, then a forced
|
|
98
|
+
* kill once `graceMs` has passed. Resolves when the tree is gone (or the forced
|
|
99
|
+
* kill was issued). Never throws.
|
|
100
|
+
*/
|
|
101
|
+
export async function terminateTree(child, opts = {}) {
|
|
102
|
+
const graceMs = Math.max(0, opts.graceMs ?? 2000);
|
|
103
|
+
const pid = child?.pid;
|
|
104
|
+
if (typeof pid !== "number")
|
|
105
|
+
return;
|
|
106
|
+
if (isWin) {
|
|
107
|
+
if (alive(child)) {
|
|
108
|
+
if (graceMs > 0) {
|
|
109
|
+
await taskkill(pid, false);
|
|
110
|
+
if (await waitExit(child, graceMs))
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await taskkill(pid, true);
|
|
114
|
+
await waitExit(child, 1000);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (graceMs > 0 && alive(child)) {
|
|
119
|
+
signalTree(child, "SIGTERM");
|
|
120
|
+
await waitExit(child, graceMs);
|
|
121
|
+
}
|
|
122
|
+
// Always sweep the group: the leader may be gone while a grandchild ignored SIGTERM.
|
|
123
|
+
try {
|
|
124
|
+
process.kill(-pid, "SIGKILL");
|
|
125
|
+
}
|
|
126
|
+
catch { /* group already empty */ }
|
|
127
|
+
if (alive(child)) {
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, "SIGKILL");
|
|
130
|
+
}
|
|
131
|
+
catch { /* gone */ }
|
|
132
|
+
await waitExit(child, 1000);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Stop every tracked child tree, in parallel, within the grace period. */
|
|
136
|
+
export async function terminateAllTracked(opts = {}) {
|
|
137
|
+
const live = [...tracked];
|
|
138
|
+
await Promise.all(live.map((c) => terminateTree(c, opts)));
|
|
139
|
+
return live.length;
|
|
140
|
+
}
|
|
141
|
+
/** Last resort on process exit: synchronous forced kill of every tracked tree. */
|
|
142
|
+
export function killAllTrackedSync() {
|
|
143
|
+
for (const child of tracked) {
|
|
144
|
+
const pid = child.pid;
|
|
145
|
+
if (typeof pid !== "number")
|
|
146
|
+
continue;
|
|
147
|
+
if (isWin) {
|
|
148
|
+
if (!alive(child))
|
|
149
|
+
continue;
|
|
150
|
+
try {
|
|
151
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, timeout: 3000 });
|
|
152
|
+
}
|
|
153
|
+
catch { /* ignore */ }
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
try {
|
|
157
|
+
process.kill(-pid, "SIGKILL");
|
|
158
|
+
}
|
|
159
|
+
catch { /* not a leader / gone */ }
|
|
160
|
+
if (alive(child)) {
|
|
161
|
+
try {
|
|
162
|
+
process.kill(pid, "SIGKILL");
|
|
163
|
+
}
|
|
164
|
+
catch { /* gone */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
tracked.clear();
|
|
169
|
+
}
|
|
170
|
+
/** Run independent shutdown steps in parallel, each bounded by its own deadline. */
|
|
171
|
+
export async function runShutdownSteps(steps) {
|
|
172
|
+
return Promise.all(steps.map(async (s) => {
|
|
173
|
+
const t0 = Date.now();
|
|
174
|
+
let timer;
|
|
175
|
+
const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), Math.max(0, s.deadlineMs)); });
|
|
176
|
+
try {
|
|
177
|
+
const r = await Promise.race([Promise.resolve().then(s.run).then(() => "ok"), deadline]);
|
|
178
|
+
return { name: s.name, outcome: r, ms: Date.now() - t0 };
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
return { name: s.name, outcome: "error", ms: Date.now() - t0, error: String(e?.message || e).slice(0, 200) };
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (timer)
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
}
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
/** One line for logs: "runs ok 12ms, browsers timeout 3000ms". */
|
|
190
|
+
export function formatShutdownResults(results) {
|
|
191
|
+
return results.map((r) => `${r.name} ${r.outcome} ${r.ms}ms`).join(", ");
|
|
192
|
+
}
|
|
193
|
+
// ── Graceful shutdown ownership ───────────────────────────────────────────
|
|
194
|
+
// connection.ts owns the graceful shutdown once the socket exists; other
|
|
195
|
+
// signal handlers (singleInstance.ts) must then not exit the process under it.
|
|
196
|
+
let gracefulClaimed = false;
|
|
197
|
+
export function claimGracefulShutdown() { gracefulClaimed = true; }
|
|
198
|
+
export function gracefulShutdownClaimed() { return gracefulClaimed; }
|
|
199
|
+
// ── Background priority ───────────────────────────────────────────────────
|
|
200
|
+
// Setup work (dependency installs, warm-ups) runs below normal priority while
|
|
201
|
+
// nobody waits for it; a request that must wait promotes it back to normal.
|
|
202
|
+
let backgroundMode = false;
|
|
203
|
+
const setupChildren = new Set();
|
|
204
|
+
function applyPriority(pid, low) {
|
|
205
|
+
try {
|
|
206
|
+
os.setPriority(pid, low ? os.constants.priority.PRIORITY_BELOW_NORMAL : os.constants.priority.PRIORITY_NORMAL);
|
|
207
|
+
}
|
|
208
|
+
catch { /* not permitted (raising priority back needs privileges on POSIX) */ }
|
|
209
|
+
}
|
|
210
|
+
/** Run this child below normal priority for its whole life (fire-and-forget warm-ups). */
|
|
211
|
+
export function lowerPriority(child) {
|
|
212
|
+
if (typeof child?.pid === "number")
|
|
213
|
+
applyPriority(child.pid, true);
|
|
214
|
+
return child;
|
|
215
|
+
}
|
|
216
|
+
/** Mark a setup child; it runs below normal priority while setup is in background mode. */
|
|
217
|
+
export function registerSetupChild(child) {
|
|
218
|
+
const pid = child?.pid;
|
|
219
|
+
if (typeof pid !== "number")
|
|
220
|
+
return child;
|
|
221
|
+
setupChildren.add(child);
|
|
222
|
+
child.once("exit", () => setupChildren.delete(child));
|
|
223
|
+
child.once("error", () => setupChildren.delete(child));
|
|
224
|
+
if (backgroundMode)
|
|
225
|
+
applyPriority(pid, true);
|
|
226
|
+
return child;
|
|
227
|
+
}
|
|
228
|
+
/** Background mode on: new setup children start below normal priority. */
|
|
229
|
+
export function setSetupBackground(on) {
|
|
230
|
+
if (backgroundMode === on)
|
|
231
|
+
return;
|
|
232
|
+
backgroundMode = on;
|
|
233
|
+
for (const c of setupChildren)
|
|
234
|
+
if (typeof c.pid === "number" && alive(c))
|
|
235
|
+
applyPriority(c.pid, on);
|
|
236
|
+
}
|
|
237
|
+
export function setupIsBackground() { return backgroundMode; }
|
package/dist/publishGate.js
CHANGED
|
@@ -125,20 +125,30 @@ export function classifyControlEffect(name) {
|
|
|
125
125
|
const n = String(name || "").toLowerCase().trim();
|
|
126
126
|
if (!n)
|
|
127
127
|
return null;
|
|
128
|
+
// The verb lists are accent-free ("reinitialiser", "creer"), so a real
|
|
129
|
+
// label ("Réinitialiser", "Créer", full-width "DELETE") is also tried
|
|
130
|
+
// folded. Escalation only: the raw name is still tried as before.
|
|
131
|
+
const f = foldName(n);
|
|
132
|
+
const has = (p) => containsPhrase(n, p) || (f !== n && containsPhrase(f, p));
|
|
128
133
|
for (const p of PURCHASE_VERBS)
|
|
129
|
-
if (
|
|
134
|
+
if (has(p))
|
|
130
135
|
return "purchase";
|
|
131
136
|
for (const p of DESTRUCTIVE_VERBS)
|
|
132
|
-
if (
|
|
137
|
+
if (has(p))
|
|
133
138
|
return "destructive";
|
|
134
139
|
for (const p of ACCOUNT_VERBS)
|
|
135
|
-
if (
|
|
140
|
+
if (has(p))
|
|
136
141
|
return "account_change";
|
|
137
142
|
for (const p of PUBLISH_VERBS)
|
|
138
|
-
if (
|
|
143
|
+
if (has(p))
|
|
139
144
|
return "publish";
|
|
140
145
|
return null;
|
|
141
146
|
}
|
|
147
|
+
/** Case, diacritics, full/half-width forms and curly apostrophes folded. */
|
|
148
|
+
function foldName(s) {
|
|
149
|
+
return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").normalize("NFKC").toLowerCase()
|
|
150
|
+
.replace(/[‘’ʼ]/g, "'").replace(/[\s ]+/g, " ").trim();
|
|
151
|
+
}
|
|
142
152
|
/** Does this effect need approval in the given autonomy mode?
|
|
143
153
|
* safe -> gate every consequential write
|
|
144
154
|
* payments_only -> gate purchases AND the two irreversible families
|
package/dist/pythonEnv.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* Idempotent: re-uses the venv if a marker file matches the current
|
|
10
10
|
* shared-bundle version. Re-bootstraps when the bundle version changes.
|
|
11
11
|
*/
|
|
12
|
-
import { spawn } from "child_process";
|
|
13
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
13
|
+
import { spawnTracked, registerSetupChild, terminateTree } from "./processTree.js";
|
|
14
14
|
import { createHash } from "crypto";
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { homedir, platform } from "os";
|
|
@@ -387,10 +387,12 @@ function runProc(cmd, args, onLine, envExtra = {},
|
|
|
387
387
|
// (which every run, RAG ingest and assistant boot sits behind).
|
|
388
388
|
inactivityMs = 10 * 60_000) {
|
|
389
389
|
return new Promise((resolve) => {
|
|
390
|
-
|
|
390
|
+
// Tracked (shutdown stops pip/uv and their children) and, while the setup
|
|
391
|
+
// runs in the background, below normal priority (processTree.ts).
|
|
392
|
+
const child = registerSetupChild(spawnTracked(cmd, args, {
|
|
391
393
|
stdio: ["ignore", "pipe", "pipe"],
|
|
392
394
|
env: { ...process.env, ...envExtra },
|
|
393
|
-
});
|
|
395
|
+
}));
|
|
394
396
|
let done = false;
|
|
395
397
|
let timer;
|
|
396
398
|
const finish = (code) => {
|
|
@@ -405,10 +407,7 @@ inactivityMs = 10 * 60_000) {
|
|
|
405
407
|
if (timer)
|
|
406
408
|
clearTimeout(timer);
|
|
407
409
|
timer = setTimeout(() => {
|
|
408
|
-
|
|
409
|
-
child.kill("SIGKILL");
|
|
410
|
-
}
|
|
411
|
-
catch { /* already gone */ }
|
|
410
|
+
void terminateTree(child, { graceMs: 0 });
|
|
412
411
|
onLine(`[runProc] no output for ${Math.round(inactivityMs / 60000)} min — killing '${cmd}'`);
|
|
413
412
|
finish(1);
|
|
414
413
|
}, inactivityMs);
|