@melaya/runner 1.1.67 → 1.1.69
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/assistantHost.py +5 -0
- 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 +180 -66
- package/dist/computerHelperManifest.d.ts +5 -0
- package/dist/computerHelperManifest.js +9 -0
- package/dist/computerHostCli.d.ts +233 -0
- package/dist/computerHostCli.js +808 -0
- package/dist/computerRelay.d.ts +73 -0
- package/dist/computerRelay.js +280 -0
- package/dist/connection.d.ts +12 -2
- package/dist/connection.js +299 -82
- package/dist/detect.d.ts +40 -1
- package/dist/detect.js +194 -36
- package/dist/hostChannel.d.ts +122 -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/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,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);
|
package/dist/sharedVendor.js
CHANGED
|
@@ -13,6 +13,7 @@ import { homedir, setPriority } from "os";
|
|
|
13
13
|
import { spawn } from "child_process";
|
|
14
14
|
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
15
15
|
import { venvPython } from "./pythonEnv.js";
|
|
16
|
+
import { trackChild } from "./processTree.js";
|
|
16
17
|
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
17
18
|
const SHARED_DIR = join(CACHE_DIR, "shared");
|
|
18
19
|
const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
|
|
@@ -162,7 +163,8 @@ function precompileInBackground() {
|
|
|
162
163
|
const dirs = ["shared", "agentscope", "crews"].map((d) => join(CACHE_DIR, d)).filter((d) => existsSync(d));
|
|
163
164
|
if (!dirs.length)
|
|
164
165
|
return;
|
|
165
|
-
|
|
166
|
+
// Tracked: runner shutdown stops it instead of leaving it compiling.
|
|
167
|
+
const child = trackChild(spawn(py, ["-m", "compileall", "-q", "-j", "2", ...dirs], { stdio: "ignore", windowsHide: true, detached: false }));
|
|
166
168
|
child.on("error", () => { });
|
|
167
169
|
if (child.pid) {
|
|
168
170
|
try {
|
package/dist/singleInstance.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { mkdirSync, readFileSync, unlinkSync, openSync, writeSync, closeSync } from "fs";
|
|
21
21
|
import { join } from "path";
|
|
22
22
|
import { homedir } from "os";
|
|
23
|
+
import { gracefulShutdownClaimed } from "./processTree.js";
|
|
23
24
|
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
24
25
|
const LOCK_PATH = join(CACHE_DIR, "runner.lock");
|
|
25
26
|
function pidAlive(pid) {
|
|
@@ -110,7 +111,15 @@ export function acquireSingleInstanceLock() {
|
|
|
110
111
|
};
|
|
111
112
|
process.on("exit", release);
|
|
112
113
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
113
|
-
|
|
114
|
+
// Once connection.ts owns a graceful shutdown (SIGINT/SIGTERM), let it
|
|
115
|
+
// finish: it exits on its own and "exit" above releases the lock then, so
|
|
116
|
+
// a successor cannot start while this runner's children are still stopping.
|
|
117
|
+
process.on(sig, () => {
|
|
118
|
+
if (sig !== "SIGHUP" && gracefulShutdownClaimed())
|
|
119
|
+
return;
|
|
120
|
+
release();
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
|
114
123
|
}
|
|
115
124
|
return { ok: true };
|
|
116
125
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last good startup facts, so the next start can connect at once:
|
|
3
|
+
* ~/.melaya-runner/startup-cache.json holds the last published model list and
|
|
4
|
+
* the Python interpreter that worked. The runner connects with these, then
|
|
5
|
+
* publishes fresh discovery through `runner:models` when it lands.
|
|
6
|
+
*
|
|
7
|
+
* Model names only (no credentials, no tokens). A missing or malformed file
|
|
8
|
+
* reads as empty; writes are best-effort and atomic (temp file + rename).
|
|
9
|
+
*/
|
|
10
|
+
import type { DetectedModel } from "./detect.js";
|
|
11
|
+
export interface StartupCache {
|
|
12
|
+
models?: DetectedModel[];
|
|
13
|
+
modelsAt?: number;
|
|
14
|
+
python?: {
|
|
15
|
+
cmd: string;
|
|
16
|
+
minor: number;
|
|
17
|
+
provisioned?: boolean;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare function startupCachePath(dir?: string): string;
|
|
21
|
+
export declare function readStartupCache(path?: string): StartupCache;
|
|
22
|
+
export declare function writeStartupCache(patch: StartupCache, path?: string): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last good startup facts, so the next start can connect at once:
|
|
3
|
+
* ~/.melaya-runner/startup-cache.json holds the last published model list and
|
|
4
|
+
* the Python interpreter that worked. The runner connects with these, then
|
|
5
|
+
* publishes fresh discovery through `runner:models` when it lands.
|
|
6
|
+
*
|
|
7
|
+
* Model names only (no credentials, no tokens). A missing or malformed file
|
|
8
|
+
* reads as empty; writes are best-effort and atomic (temp file + rename).
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
const PROVIDERS = new Set(["lmstudio", "ollama", "claude_code", "codex", "github_copilot"]);
|
|
14
|
+
export function startupCachePath(dir = join(homedir(), ".melaya-runner")) {
|
|
15
|
+
return join(dir, "startup-cache.json");
|
|
16
|
+
}
|
|
17
|
+
export function readStartupCache(path = startupCachePath()) {
|
|
18
|
+
try {
|
|
19
|
+
const j = JSON.parse(readFileSync(path, "utf-8"));
|
|
20
|
+
const out = {};
|
|
21
|
+
if (Array.isArray(j.models)) {
|
|
22
|
+
out.models = j.models
|
|
23
|
+
.filter((m) => m && PROVIDERS.has(String(m.provider)) && typeof m.name === "string" && m.name.length > 0 && m.name.length < 200)
|
|
24
|
+
.slice(0, 500);
|
|
25
|
+
out.modelsAt = Number(j.modelsAt) || 0;
|
|
26
|
+
}
|
|
27
|
+
if (j.python && typeof j.python.cmd === "string" && Number.isInteger(j.python.minor)) {
|
|
28
|
+
out.python = { cmd: j.python.cmd, minor: j.python.minor, ...(j.python.provisioned ? { provisioned: true } : {}) };
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function writeStartupCache(patch, path = startupCachePath()) {
|
|
37
|
+
try {
|
|
38
|
+
const next = { ...readStartupCache(path), ...patch };
|
|
39
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
40
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
41
|
+
writeFileSync(tmp, JSON.stringify(next), { encoding: "utf-8", mode: 0o600 });
|
|
42
|
+
renameSync(tmp, path);
|
|
43
|
+
}
|
|
44
|
+
catch { /* best-effort: the next start just discovers again */ }
|
|
45
|
+
}
|