@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
package/dist/connection.js
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
import { io } from "socket.io-client";
|
|
14
14
|
import chalk from "chalk";
|
|
15
15
|
import ora from "ora";
|
|
16
|
-
|
|
16
|
+
// Every child spawned here leads its own process group (POSIX) and is tracked,
|
|
17
|
+
// so shutdown and runner:kill stop the whole tree (audit P0.5).
|
|
18
|
+
import { spawnTracked as spawn, terminateTree, terminateAllTracked, runShutdownSteps, formatShutdownResults, claimGracefulShutdown, killAllTrackedSync, setSetupBackground, lowerPriority, } from "./processTree.js";
|
|
19
|
+
import { markStage } from "./startupTimings.js";
|
|
17
20
|
import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
|
|
18
|
-
import { dirname, join } from "path";
|
|
21
|
+
import { dirname, isAbsolute, join } from "path";
|
|
19
22
|
import { fileURLToPath } from "url";
|
|
20
23
|
import { tmpdir, homedir, hostname } from "os";
|
|
21
24
|
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
@@ -25,7 +28,8 @@ import { startLocalRelay, setActiveProject } from "./localRelay.js";
|
|
|
25
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
29
|
const __dirname = dirname(__filename);
|
|
27
30
|
import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
|
|
28
|
-
import {
|
|
31
|
+
import { scanProviders, providerFingerprint } from "./detect.js";
|
|
32
|
+
import { createForcedRescan } from "./modelRescan.js";
|
|
29
33
|
import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
30
34
|
// Melaya Browser (plan Sections 3/4/7): the governed browser control
|
|
31
35
|
// bridge + its runner-side grant verifier and engine discovery.
|
|
@@ -43,6 +47,33 @@ import { VAULT_HOST_CAPABILITY, VaultHostProcess, VaultRegistry, VaultService, a
|
|
|
43
47
|
// Obsidian app bridge: App ops through the user's running Obsidian (CLI /
|
|
44
48
|
// Local REST API); the REST key stays in ~/.melaya-runner/vaults.
|
|
45
49
|
import { ObsidianAppBridge } from "./obsidianApp.js";
|
|
50
|
+
// Desktop app host channel: state changes for the app's tray and web UI (no-op under npx).
|
|
51
|
+
import { hostStatus, isHosted, hostProgress, sendToHost } from "./hostChannel.js";
|
|
52
|
+
// Desktop app: files this runner wrote are offered to computer control as handles (no-op under npx).
|
|
53
|
+
import { reportHostFile, reportOutputsSince } from "./hostFiles.js";
|
|
54
|
+
// Computer control: computer:command / computer:stop relayed to the desktop app
|
|
55
|
+
// executor (or the CLI computer host); capabilities reported on connect.
|
|
56
|
+
import { attachComputerRelay } from "./computerRelay.js";
|
|
57
|
+
// Model rediscovery while connected (audit P2.3): every 15 min, and skipped
|
|
58
|
+
// when the provider fingerprint (credential/cache file mtimes + local server
|
|
59
|
+
// ports) is unchanged since the last scan, unless that scan is older than
|
|
60
|
+
// MODEL_REFRESH_MAX_SKIP_MS (LM Studio / Ollama model lists can change with
|
|
61
|
+
// the port still open).
|
|
62
|
+
const MODEL_REFRESH_MS = 15 * 60_000;
|
|
63
|
+
const MODEL_REFRESH_MAX_SKIP_MS = 60 * 60_000;
|
|
64
|
+
// Background setup (audit P1.5): module download, Python dependencies, Claude
|
|
65
|
+
// version probe, Python import warm-up. Starts only once the runner has been
|
|
66
|
+
// online and idle (no runs, no Assistant boot) for BG_SETUP_IDLE_MS, at below-
|
|
67
|
+
// normal priority. A request that needs it first does the work itself (same
|
|
68
|
+
// singleflight), and is told when it waits on setup already in flight.
|
|
69
|
+
const BG_SETUP_IDLE_MS = 15_000;
|
|
70
|
+
const BG_SETUP_CHECK_MS = 5_000;
|
|
71
|
+
// Shutdown budget (audit P0.5): every subsystem gets its own deadline and they
|
|
72
|
+
// run in parallel; the slowest bound (SHUTDOWN_STEP_MS) plus the final sweep
|
|
73
|
+
// stays inside the desktop app's 5 s hard exit (cli.ts stopHosted).
|
|
74
|
+
const SHUTDOWN_STEP_MS = 3_000;
|
|
75
|
+
const SHUTDOWN_CHILD_GRACE_MS = 2_000;
|
|
76
|
+
const SHUTDOWN_SWEEP_MS = 800;
|
|
46
77
|
// Same ceiling the builder enforces on RunRequest.trigger (48 KB UTF-8 of the
|
|
47
78
|
// compact JSON) and the server dispatch route re-checks (Buffer.byteLength).
|
|
48
79
|
const MAX_TRIGGER_JSON_BYTES = 48 * 1024;
|
|
@@ -117,6 +148,58 @@ const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
|
|
|
117
148
|
const ASSISTANT_STDOUT_MAX_CARRY = 64 * 1024 * 1024;
|
|
118
149
|
export async function connect(opts) {
|
|
119
150
|
const spinner = ora("Connecting to Melaya...").start();
|
|
151
|
+
const print = (line) => {
|
|
152
|
+
if (spinner.isSpinning) {
|
|
153
|
+
spinner.clear();
|
|
154
|
+
console.log(line);
|
|
155
|
+
spinner.render();
|
|
156
|
+
}
|
|
157
|
+
else
|
|
158
|
+
console.log(line);
|
|
159
|
+
};
|
|
160
|
+
// ── Python, resolved after the socket connects (audit P1.2) ─────────────
|
|
161
|
+
// Requests that need Python await it here; most of the time it is already
|
|
162
|
+
// resolved by the time the first request arrives.
|
|
163
|
+
let resolvedPython = typeof opts.pythonPath === "string" ? opts.pythonPath : null;
|
|
164
|
+
const pythonPromise = Promise.resolve(opts.pythonPath).then((p) => { resolvedPython = p; return p; });
|
|
165
|
+
const getPython = async () => {
|
|
166
|
+
if (resolvedPython)
|
|
167
|
+
return resolvedPython;
|
|
168
|
+
if (opts.verbose)
|
|
169
|
+
print(chalk.gray(" [python] waiting for the interpreter to be resolved"));
|
|
170
|
+
const p = await pythonPromise;
|
|
171
|
+
if (!p)
|
|
172
|
+
throw new Error("No supported Python (3.10-3.12) is available on this computer yet. Install Python 3.12, then restart the runner.");
|
|
173
|
+
return p;
|
|
174
|
+
};
|
|
175
|
+
// ── Background setup state (audit P1.5) ──────────────────────────────────
|
|
176
|
+
let bgSetup = null;
|
|
177
|
+
let bgSetupStep = "";
|
|
178
|
+
let foregroundDemand = false;
|
|
179
|
+
let lastActivityAt = Date.now();
|
|
180
|
+
let bgReadyResolve = () => { };
|
|
181
|
+
const bgReady = new Promise((r) => { bgReadyResolve = r; });
|
|
182
|
+
/** A request needs Python / the runtime now: setup work stops being background. */
|
|
183
|
+
const demandRuntime = (what) => {
|
|
184
|
+
lastActivityAt = Date.now();
|
|
185
|
+
if (!foregroundDemand) {
|
|
186
|
+
foregroundDemand = true;
|
|
187
|
+
bgReadyResolve();
|
|
188
|
+
// Assistant hosts read MEL_CLAUDE_CODE_VERSION; resolve it now if the
|
|
189
|
+
// background setup has not (defined below; only called from handlers).
|
|
190
|
+
void _detectClaudeCodeVersion();
|
|
191
|
+
}
|
|
192
|
+
setSetupBackground(false);
|
|
193
|
+
if (bgSetup && bgSetupStep) {
|
|
194
|
+
print(chalk.hex("#7C6FF0")(` ⏳ ${what} waits for the one-time setup to finish (${bgSetupStep})…`));
|
|
195
|
+
hostProgress("running", bgSetupStep, true);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
/** The interpreter for a request the user is waiting on. */
|
|
199
|
+
const foregroundPython = async (what) => {
|
|
200
|
+
demandRuntime(what);
|
|
201
|
+
return getPython();
|
|
202
|
+
};
|
|
120
203
|
// Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
|
|
121
204
|
// The `path` must match the server's Socket.IO HTTP path (/api/v1/events).
|
|
122
205
|
// The namespace is specified in the URL suffix, not the path option.
|
|
@@ -129,6 +212,10 @@ export async function connect(opts) {
|
|
|
129
212
|
reconnectionDelay: 2000,
|
|
130
213
|
reconnectionAttempts: Infinity,
|
|
131
214
|
});
|
|
215
|
+
const computerRelay = attachComputerRelay(socket, { verbose: opts.verbose, deps: { log: (l) => console.log(chalk.gray(l)) } });
|
|
216
|
+
// Browser engine discovery (cached promise, loads Playwright) overlaps the
|
|
217
|
+
// handshake instead of delaying runner:hello after it.
|
|
218
|
+
void discoverEngines().catch(() => { });
|
|
132
219
|
let relay = null;
|
|
133
220
|
// ── Disconnect diagnostics (transport-close root-cause) ──────────────
|
|
134
221
|
// A "transport close" disconnect can be a NETWORK blip OR the Node event loop
|
|
@@ -185,7 +272,7 @@ export async function connect(opts) {
|
|
|
185
272
|
}
|
|
186
273
|
if (!localVersion)
|
|
187
274
|
throw new Error("runner python runtime still setting up");
|
|
188
|
-
const env = await ensurePythonEnv(
|
|
275
|
+
const env = await ensurePythonEnv(await getPython(), localVersion, (m) => vaultLog(`venv: ${m}`));
|
|
189
276
|
if (!env.ok)
|
|
190
277
|
throw new Error(`venv bootstrap failed: ${env.reason}`);
|
|
191
278
|
const script = findVaultHostScript(__dirname);
|
|
@@ -264,13 +351,17 @@ export async function connect(opts) {
|
|
|
264
351
|
// it as MEL_CLAUDE_CODE_VERSION, which every assistant host inherits. Without
|
|
265
352
|
// it each host ran a blocking `claude --version` (slow on Windows, through a
|
|
266
353
|
// .cmd shim) on its first model call. Fixed argv, no user input.
|
|
354
|
+
let _claudeVersionStarted = false;
|
|
267
355
|
const _detectClaudeCodeVersion = async () => {
|
|
268
|
-
if (process.env.MEL_CLAUDE_CODE_VERSION)
|
|
356
|
+
if (process.env.MEL_CLAUDE_CODE_VERSION || _claudeVersionStarted)
|
|
269
357
|
return;
|
|
358
|
+
_claudeVersionStarted = true;
|
|
270
359
|
const out = await new Promise((resolve) => {
|
|
271
360
|
let buf = "";
|
|
272
361
|
try {
|
|
273
362
|
const p = spawn("claude", ["--version"], { shell: process.platform === "win32", stdio: ["ignore", "pipe", "ignore"] });
|
|
363
|
+
if (!foregroundDemand)
|
|
364
|
+
lowerPriority(p);
|
|
274
365
|
const timer = setTimeout(() => { try {
|
|
275
366
|
p.kill();
|
|
276
367
|
}
|
|
@@ -291,51 +382,59 @@ export async function connect(opts) {
|
|
|
291
382
|
if (_assistantPrewarmed)
|
|
292
383
|
return;
|
|
293
384
|
_assistantPrewarmed = true;
|
|
385
|
+
const step = (s) => { bgSetupStep = s; hostProgress("running", s); };
|
|
386
|
+
let outcome = "done";
|
|
294
387
|
void _detectClaudeCodeVersion();
|
|
295
388
|
try {
|
|
296
389
|
const { getLocalSharedVersion } = await import("./sharedVendor.js");
|
|
297
390
|
let localVersion = getLocalSharedVersion();
|
|
298
391
|
const freshSetup = !localVersion;
|
|
299
392
|
if (!localVersion) {
|
|
300
|
-
// FIRST LAUNCH: no shared bundle on disk yet. Download it now
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// user (not verbose-gated)
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
393
|
+
// FIRST LAUNCH: no shared bundle on disk yet. Download it now (the
|
|
394
|
+
// runner has been idle since it came online) so the venv is built
|
|
395
|
+
// before the first assistant/pipeline run instead of making the user
|
|
396
|
+
// wait 1-2 min mid-chat. Shown to the user (not verbose-gated): a
|
|
397
|
+
// silent multi-minute pause reads as "stuck" and users leave.
|
|
398
|
+
print(chalk.hex("#7C6FF0")("\n ⚙ First-time setup: preparing your local AI runtime (one-time, in the background)…"));
|
|
399
|
+
print(chalk.gray(" downloading runtime modules…"));
|
|
400
|
+
step("downloading runtime modules");
|
|
307
401
|
try {
|
|
308
402
|
await ensureSharedModules(opts.serverUrl, "latest", opts.token);
|
|
309
403
|
localVersion = getLocalSharedVersion();
|
|
310
404
|
}
|
|
311
405
|
catch (e) {
|
|
312
|
-
|
|
406
|
+
print(chalk.gray(` setup deferred (${e?.message || e}) — it will finish on your first run`));
|
|
407
|
+
outcome = "failed";
|
|
313
408
|
return;
|
|
314
409
|
}
|
|
315
|
-
if (!localVersion)
|
|
410
|
+
if (!localVersion) {
|
|
411
|
+
outcome = "failed";
|
|
316
412
|
return;
|
|
413
|
+
}
|
|
317
414
|
}
|
|
318
415
|
const { ensurePythonEnv, getCertBundlePath } = await import("./pythonEnv.js");
|
|
319
416
|
if (freshSetup)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
417
|
+
print(chalk.gray(" installing Python dependencies (~1-2 min)…"));
|
|
418
|
+
step(freshSetup ? "installing Python dependencies" : "checking Python dependencies");
|
|
419
|
+
const envResult = await ensurePythonEnv(await getPython(), localVersion, (m) => { if (opts.verbose)
|
|
420
|
+
print(chalk.gray(` [assistant prewarm] ${m}`)); });
|
|
421
|
+
if (!envResult.ok)
|
|
422
|
+
outcome = "failed";
|
|
323
423
|
if (freshSetup) {
|
|
324
|
-
|
|
424
|
+
print(envResult.ok
|
|
325
425
|
? chalk.green(" ✓ Local AI runtime ready — your first chat will start instantly\n")
|
|
326
426
|
: chalk.yellow(` ⚠ runtime setup did not finish (${envResult.reason || "unknown"}); it will retry on your first run\n`));
|
|
327
427
|
}
|
|
328
428
|
else if (opts.verbose) {
|
|
329
|
-
|
|
429
|
+
print(chalk.gray(" [assistant prewarm] ready"));
|
|
330
430
|
}
|
|
331
431
|
// ALSO pre-import the heavy Python stack so the first real run/chat
|
|
332
|
-
// doesn't pay the ~6s cold `import shared.runtime.registry`.
|
|
333
|
-
//
|
|
334
|
-
// pipeline spawn uses (see the runner:run handler below) so
|
|
335
|
-
// resolves; this heats the OS file cache + bytecode.
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
if (envResult.ok) {
|
|
432
|
+
// doesn't pay the ~6s cold `import shared.runtime.registry`. Spawned
|
|
433
|
+
// fire-and-forget at below-normal priority with the SAME PYTHONPATH/env a
|
|
434
|
+
// real pipeline spawn uses (see the runner:run handler below) so
|
|
435
|
+
// `shared.*` resolves; this heats the OS file cache + bytecode. Skipped
|
|
436
|
+
// once a request has already started using the runtime.
|
|
437
|
+
if (envResult.ok && !foregroundDemand) {
|
|
339
438
|
try {
|
|
340
439
|
const sharedDir = getSharedDir();
|
|
341
440
|
if (existsSync(sharedDir)) {
|
|
@@ -348,10 +447,11 @@ export async function connect(opts) {
|
|
|
348
447
|
...(certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {}),
|
|
349
448
|
};
|
|
350
449
|
const warmChild = spawn(envResult.pythonPath, ["-c", "import shared.runtime.registry"], { env: warmEnv, stdio: "ignore" });
|
|
450
|
+
lowerPriority(warmChild);
|
|
351
451
|
warmChild.on("error", () => { });
|
|
352
452
|
warmChild.on("exit", (code) => {
|
|
353
453
|
if (code === 0 && opts.verbose) {
|
|
354
|
-
|
|
454
|
+
print(chalk.gray(" [assistant prewarm] import-warmed"));
|
|
355
455
|
}
|
|
356
456
|
});
|
|
357
457
|
warmChild.unref();
|
|
@@ -361,36 +461,113 @@ export async function connect(opts) {
|
|
|
361
461
|
}
|
|
362
462
|
}
|
|
363
463
|
catch (e) {
|
|
464
|
+
outcome = "failed";
|
|
364
465
|
if (opts.verbose)
|
|
365
|
-
|
|
466
|
+
print(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
bgSetupStep = "";
|
|
470
|
+
hostProgress(outcome);
|
|
366
471
|
}
|
|
367
472
|
};
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
473
|
+
// Start the background setup once the runner is online and has been idle
|
|
474
|
+
// (no runs, no Assistant host booting or live) for BG_SETUP_IDLE_MS. When a
|
|
475
|
+
// request needs the runtime first, it does the work itself and this never
|
|
476
|
+
// runs (the request path already ensured the same things).
|
|
477
|
+
const _runtimeIdle = () => activeProcesses.size === 0 && activeAssistants.size === 0 && bootingAssistants.size === 0;
|
|
478
|
+
let bgTimer = null;
|
|
479
|
+
const scheduleBackgroundSetup = () => {
|
|
480
|
+
if (bgSetup || bgTimer || foregroundDemand)
|
|
481
|
+
return;
|
|
482
|
+
lastActivityAt = Date.now();
|
|
483
|
+
bgTimer = setInterval(() => {
|
|
484
|
+
if (bgSetup || foregroundDemand) {
|
|
485
|
+
if (bgTimer)
|
|
486
|
+
clearInterval(bgTimer);
|
|
487
|
+
bgTimer = null;
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (!_runtimeIdle()) {
|
|
491
|
+
lastActivityAt = Date.now();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (Date.now() - lastActivityAt < BG_SETUP_IDLE_MS)
|
|
495
|
+
return;
|
|
496
|
+
if (bgTimer)
|
|
497
|
+
clearInterval(bgTimer);
|
|
498
|
+
bgTimer = null;
|
|
499
|
+
setSetupBackground(true);
|
|
500
|
+
bgSetup = _prewarmAssistantEnv().finally(() => {
|
|
501
|
+
setSetupBackground(false);
|
|
502
|
+
bgReadyResolve();
|
|
503
|
+
});
|
|
504
|
+
}, BG_SETUP_CHECK_MS);
|
|
505
|
+
bgTimer.unref?.();
|
|
506
|
+
};
|
|
507
|
+
// ── Model list: initial (cached) list on connect, fresh discovery later ──
|
|
508
|
+
// cli.ts connects with the last good list from disk and publishes the fresh
|
|
509
|
+
// scan through publishModels(). While connected, rescan every 15 min, and
|
|
510
|
+
// only when the provider fingerprint changed (credential/cache file mtimes,
|
|
511
|
+
// local server ports) or the last scan is over an hour old. Authenticated
|
|
512
|
+
// network calls inside a scan are cached per credential (detect.ts).
|
|
513
|
+
const publishModels = (models) => {
|
|
514
|
+
if (JSON.stringify(models) === JSON.stringify(opts.models))
|
|
515
|
+
return;
|
|
516
|
+
opts.models = models;
|
|
517
|
+
if (socket.connected)
|
|
518
|
+
socket.emit("runner:models", models);
|
|
519
|
+
if (opts.verbose)
|
|
520
|
+
print(chalk.gray(` [models] published: ${models.length} model(s)`));
|
|
521
|
+
};
|
|
373
522
|
let modelRefresh = null;
|
|
523
|
+
let lastFingerprint = "";
|
|
524
|
+
let lastScanAt = Date.now();
|
|
525
|
+
void providerFingerprint().then((f) => { lastFingerprint = f; }).catch(() => { });
|
|
374
526
|
const startModelRefresh = () => {
|
|
375
527
|
if (modelRefresh)
|
|
376
528
|
return;
|
|
377
529
|
modelRefresh = setInterval(async () => {
|
|
378
530
|
try {
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
531
|
+
const fp = await providerFingerprint();
|
|
532
|
+
if (fp === lastFingerprint && Date.now() - lastScanAt < MODEL_REFRESH_MAX_SKIP_MS) {
|
|
533
|
+
if (opts.verbose)
|
|
534
|
+
print(chalk.gray(" [models] unchanged, rescan skipped"));
|
|
381
535
|
return;
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
536
|
+
}
|
|
537
|
+
const scan = await scanProviders();
|
|
538
|
+
lastFingerprint = fp;
|
|
539
|
+
lastScanAt = Date.now();
|
|
540
|
+
publishModels(scan.models);
|
|
387
541
|
}
|
|
388
542
|
catch { /* keep the last good list */ }
|
|
389
|
-
},
|
|
543
|
+
}, MODEL_REFRESH_MS);
|
|
390
544
|
modelRefresh.unref?.();
|
|
391
545
|
};
|
|
546
|
+
// Forced rescan on request (desktop app after an engine launch / sign-in).
|
|
547
|
+
// Same publish path as the periodic rescan; also resets its change detection.
|
|
548
|
+
const forcedRescan = createForcedRescan({
|
|
549
|
+
scan: async () => {
|
|
550
|
+
const fp = await providerFingerprint().catch(() => "");
|
|
551
|
+
const scan = await scanProviders({ force: true });
|
|
552
|
+
if (fp)
|
|
553
|
+
lastFingerprint = fp;
|
|
554
|
+
lastScanAt = Date.now();
|
|
555
|
+
return scan.models;
|
|
556
|
+
},
|
|
557
|
+
publish: (models) => {
|
|
558
|
+
if (opts.verbose)
|
|
559
|
+
print(chalk.gray(` [models] forced rescan: ${models.length} model(s)`));
|
|
560
|
+
publishModels(models);
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
const rescanModels = async () => {
|
|
564
|
+
await forcedRescan.request();
|
|
565
|
+
return opts.models;
|
|
566
|
+
};
|
|
392
567
|
socket.on("connect", async () => {
|
|
393
568
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
569
|
+
markStage("socket_connected");
|
|
570
|
+
hostStatus("online");
|
|
394
571
|
connectedAt = Date.now();
|
|
395
572
|
peakLagMs = 0; // fresh window each connection so the disconnect line reflects THIS session
|
|
396
573
|
// New socket lifetime → new generation. Grants minted for an earlier
|
|
@@ -451,16 +628,17 @@ export async function connect(opts) {
|
|
|
451
628
|
console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
|
|
452
629
|
}
|
|
453
630
|
}
|
|
631
|
+
markStage("online");
|
|
454
632
|
// Start the Luma browser bridge if a saved session exists. Best-
|
|
455
633
|
// effort — failure here means Luma POSTs fall back to aiohttp
|
|
456
634
|
// (which 403s under the bot rule) but reads still work.
|
|
457
635
|
await _ensureLumaBridge();
|
|
458
|
-
// Pre-warm the
|
|
459
|
-
// message doesn't pay
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
|
|
636
|
+
// Pre-warm the runtime (modules, Python deps, Claude version, Python
|
|
637
|
+
// imports) so the first chat message doesn't pay it inline, but only once
|
|
638
|
+
// the runner has been idle for a while and at below-normal priority, so it
|
|
639
|
+
// does not compete with the first screen (audit P1.5). A request that
|
|
640
|
+
// arrives first does the work itself.
|
|
641
|
+
scheduleBackgroundSetup();
|
|
464
642
|
console.log(chalk.gray(" Waiting for pipeline runs...\n"));
|
|
465
643
|
});
|
|
466
644
|
// A runner that never connects is invisible to the funnel: last_seen stays
|
|
@@ -475,9 +653,18 @@ export async function connect(opts) {
|
|
|
475
653
|
void import("./installReport.js").then((m) => m.reportInstallFailure(authRejected ? "socket_auth_rejected" : "socket_unreachable", { token: opts.token, server: opts.serverUrl })).catch(() => { });
|
|
476
654
|
}
|
|
477
655
|
if (authRejected) {
|
|
656
|
+
hostStatus("error", "This computer's runner token was revoked or is invalid. Connect this computer again.", "token_rejected");
|
|
478
657
|
console.log(chalk.yellow(" Token may be expired or revoked. Generate a new one in Settings → Connectors."));
|
|
479
658
|
setTimeout(() => process.exit(1), 300); // let the beacon leave the box
|
|
480
659
|
}
|
|
660
|
+
else if (isHosted() && /invalid|expired|revoked|token required/i.test(err.message)) {
|
|
661
|
+
// Desktop app: the handshake's token errors are final too; the app asks for a new token.
|
|
662
|
+
hostStatus("error", "This computer's runner token was revoked or is invalid. Connect this computer again.", "token_rejected");
|
|
663
|
+
setTimeout(() => process.exit(1), 300);
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
hostStatus(connectedAt ? "reconnecting" : "starting", "Can't reach Melaya right now. Retrying.");
|
|
667
|
+
}
|
|
481
668
|
});
|
|
482
669
|
socket.on("disconnect", (reason) => {
|
|
483
670
|
const upS = connectedAt ? Math.round((Date.now() - connectedAt) / 1000) : 0;
|
|
@@ -498,9 +685,11 @@ export async function connect(opts) {
|
|
|
498
685
|
browserBridge.teardownAll(`socket_disconnect:${reason}`).catch(() => { });
|
|
499
686
|
}
|
|
500
687
|
if (reason === "io server disconnect") {
|
|
688
|
+
hostStatus("error", "Melaya closed the connection: this computer was disconnected from your account, or stopped answering.", "server_disconnect");
|
|
501
689
|
console.log(chalk.red(" Server closed the connection. Check your token."));
|
|
502
690
|
process.exit(1);
|
|
503
691
|
}
|
|
692
|
+
hostStatus("reconnecting");
|
|
504
693
|
});
|
|
505
694
|
// ── Melaya Browser: grant public key delivery (plan Section 0.6) ──────
|
|
506
695
|
// The server pushes its current EdDSA grant-verification public keys
|
|
@@ -744,7 +933,7 @@ export async function connect(opts) {
|
|
|
744
933
|
// ModuleNotFoundError. The venv is created lazily on first run
|
|
745
934
|
// and reused until sharedVersion changes.
|
|
746
935
|
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
747
|
-
const envResult = await ensurePythonEnv(
|
|
936
|
+
const envResult = await ensurePythonEnv(await foregroundPython("This run"), payload.sharedVersion, (msg) => {
|
|
748
937
|
console.log(chalk.gray(` [venv] ${msg}`));
|
|
749
938
|
// Surface bootstrap progress to the FE so the launching
|
|
750
939
|
// animation reflects the real state instead of staying on a
|
|
@@ -1121,6 +1310,7 @@ export async function connect(opts) {
|
|
|
1121
1310
|
});
|
|
1122
1311
|
}
|
|
1123
1312
|
// Spawn Python subprocess (uses the bootstrapped venv python).
|
|
1313
|
+
const runStartedAt = Date.now();
|
|
1124
1314
|
const proc = spawn(pipelinePython, ["-u", join(runDir, "main.py")], {
|
|
1125
1315
|
cwd: runDir,
|
|
1126
1316
|
env,
|
|
@@ -1162,6 +1352,7 @@ export async function connect(opts) {
|
|
|
1162
1352
|
});
|
|
1163
1353
|
proc.on("exit", (code) => {
|
|
1164
1354
|
activeProcesses.delete(payload.runId);
|
|
1355
|
+
reportOutputsSince(env.MELAYA_OUTPUT_DIR, runStartedAt);
|
|
1165
1356
|
// Melaya Browser terminal path (plan Section 7): the pipeline
|
|
1166
1357
|
// process exited, so revoke the grant, cancel in-flight browser
|
|
1167
1358
|
// ops, release leases, and close ONLY owned browsers. Idempotent.
|
|
@@ -1305,7 +1496,7 @@ export async function connect(opts) {
|
|
|
1305
1496
|
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
1306
1497
|
// Dedicated venv with agentscope + anthropic + openai installed.
|
|
1307
1498
|
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
1308
|
-
const envResult = await ensurePythonEnv(
|
|
1499
|
+
const envResult = await ensurePythonEnv(await foregroundPython("This strategy"), sharedVersion, (msg) => {
|
|
1309
1500
|
console.log(chalk.gray(` [venv] ${msg}`));
|
|
1310
1501
|
socket.emit("runner:event", {
|
|
1311
1502
|
run_id: runId,
|
|
@@ -1491,6 +1682,7 @@ export async function connect(opts) {
|
|
|
1491
1682
|
// and ships it inline on the crew dispatch payload.
|
|
1492
1683
|
...(cfg.credentials ?? {}),
|
|
1493
1684
|
};
|
|
1685
|
+
const runStartedAt = Date.now();
|
|
1494
1686
|
const proc = spawn(envResult.pythonPath, ["-u", join(runDir, "main.py")], {
|
|
1495
1687
|
cwd: runDir,
|
|
1496
1688
|
env,
|
|
@@ -1518,6 +1710,7 @@ export async function connect(opts) {
|
|
|
1518
1710
|
});
|
|
1519
1711
|
proc.on("exit", (code) => {
|
|
1520
1712
|
activeProcesses.delete(runId);
|
|
1713
|
+
reportOutputsSince(env.MELAYA_OUTPUT_DIR, runStartedAt);
|
|
1521
1714
|
const status = code === 0 ? "done" : "failed";
|
|
1522
1715
|
if (status === "failed" && stderrTail.length > 0) {
|
|
1523
1716
|
const tail = stderrTail.slice(-15).join("\n");
|
|
@@ -1621,6 +1814,9 @@ export async function connect(opts) {
|
|
|
1621
1814
|
const r = readTurnFile(st, parsed.path);
|
|
1622
1815
|
if (r.ok)
|
|
1623
1816
|
socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: String(parsed.turnId), kind: "file", ...r.file });
|
|
1817
|
+
// Accepted (inside this turn's dir): also offer it to computer control in the desktop app.
|
|
1818
|
+
if (r.ok)
|
|
1819
|
+
reportHostFile(isAbsolute(String(parsed.path)) ? String(parsed.path) : join(st.dir, String(parsed.path)), "output");
|
|
1624
1820
|
else if (opts.verbose)
|
|
1625
1821
|
console.log(chalk.gray(` [assistant] output file not relayed (${r.reason})`));
|
|
1626
1822
|
}
|
|
@@ -1924,12 +2120,17 @@ export async function connect(opts) {
|
|
|
1924
2120
|
return;
|
|
1925
2121
|
if (!payload?.userId || !payload?.configHash)
|
|
1926
2122
|
return;
|
|
2123
|
+
// Booting a spare host is heavy: wait until the background setup ran (idle)
|
|
2124
|
+
// or a request already brought the runtime up (audit P1.5).
|
|
2125
|
+
await bgReady;
|
|
2126
|
+
if (warmSpare || _prewarmInFlight)
|
|
2127
|
+
return;
|
|
1927
2128
|
try {
|
|
1928
2129
|
const sharedVersion = String(payload.sharedVersion ?? "latest");
|
|
1929
2130
|
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
1930
2131
|
_dropSpareOnOlderBundle();
|
|
1931
2132
|
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
1932
|
-
const envResult = await ensurePythonEnv(
|
|
2133
|
+
const envResult = await ensurePythonEnv(await getPython(), sharedVersion, (m) => { if (opts.verbose)
|
|
1933
2134
|
console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
|
|
1934
2135
|
if (!envResult.ok)
|
|
1935
2136
|
return;
|
|
@@ -2033,7 +2234,7 @@ export async function connect(opts) {
|
|
|
2033
2234
|
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
2034
2235
|
_dropSpareOnOlderBundle();
|
|
2035
2236
|
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
2036
|
-
const envResult = await ensurePythonEnv(
|
|
2237
|
+
const envResult = await ensurePythonEnv(await foregroundPython("This chat"), sharedVersion, (m) => { if (opts.verbose)
|
|
2037
2238
|
console.log(chalk.gray(` [assistant venv] ${m}`)); });
|
|
2038
2239
|
if (!envResult.ok) {
|
|
2039
2240
|
emitEv({ kind: "error", message: `python env failed: ${envResult.reason}` });
|
|
@@ -2479,7 +2680,7 @@ export async function connect(opts) {
|
|
|
2479
2680
|
// Reuse the same venv as runner:run — agentscope.rag + ollama +
|
|
2480
2681
|
// qdrant-client land here.
|
|
2481
2682
|
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
2482
|
-
const env = await ensurePythonEnv(
|
|
2683
|
+
const env = await ensurePythonEnv(await foregroundPython("This request"), payload.sharedVersion, (m) => emitProgress("venv", { message: m }));
|
|
2483
2684
|
if (!env.ok) {
|
|
2484
2685
|
emitProgress("error", { error: `venv bootstrap failed: ${env.reason}` });
|
|
2485
2686
|
socket.emit("rag:ingest-result", {
|
|
@@ -2685,7 +2886,7 @@ export async function connect(opts) {
|
|
|
2685
2886
|
return;
|
|
2686
2887
|
}
|
|
2687
2888
|
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
2688
|
-
const env = await ensurePythonEnv(
|
|
2889
|
+
const env = await ensurePythonEnv(await foregroundPython("This request"), payload.sharedVersion);
|
|
2689
2890
|
if (!env.ok) {
|
|
2690
2891
|
socket.emit("rag:retrieve-result", {
|
|
2691
2892
|
session_id: sid,
|
|
@@ -2802,7 +3003,7 @@ export async function connect(opts) {
|
|
|
2802
3003
|
reply(false, undefined, "runner is still setting up its Python runtime — try again in a moment");
|
|
2803
3004
|
return;
|
|
2804
3005
|
}
|
|
2805
|
-
const env = await ensurePythonEnv(
|
|
3006
|
+
const env = await ensurePythonEnv(await foregroundPython("This request"), localVersion, (m) => { if (opts.verbose)
|
|
2806
3007
|
console.log(chalk.gray(` [db-test venv] ${m}`)); });
|
|
2807
3008
|
if (!env.ok) {
|
|
2808
3009
|
reply(false, undefined, `venv bootstrap failed: ${env.reason}`);
|
|
@@ -2923,7 +3124,7 @@ export async function connect(opts) {
|
|
|
2923
3124
|
reply({ error: "runner python runtime still setting up" });
|
|
2924
3125
|
return;
|
|
2925
3126
|
}
|
|
2926
|
-
const env = await ensurePythonEnv(
|
|
3127
|
+
const env = await ensurePythonEnv(await foregroundPython("This request"), localVersion, (m) => { if (opts.verbose)
|
|
2927
3128
|
console.log(chalk.gray(` [backlink-fetch venv] ${m}`)); });
|
|
2928
3129
|
if (!env.ok) {
|
|
2929
3130
|
reply({ error: `venv bootstrap failed: ${env.reason}` });
|
|
@@ -3014,6 +3215,9 @@ export async function connect(opts) {
|
|
|
3014
3215
|
const outcome = await runLinkedInLoginFlow(emitProgress);
|
|
3015
3216
|
console.log(describeOutcome(outcome));
|
|
3016
3217
|
socket.emit("linkedin:login-result", { session_id: sid, ...outcome });
|
|
3218
|
+
// Desktop app: no Chrome / Edge to sign in with is something only the user can fix.
|
|
3219
|
+
if (outcome.error === "browser_not_installed")
|
|
3220
|
+
sendToHost({ type: "notice", code: "browser_not_installed" });
|
|
3017
3221
|
}
|
|
3018
3222
|
catch (e) {
|
|
3019
3223
|
const msg = e?.message || "linkedin_login_unhandled_error";
|
|
@@ -3042,6 +3246,8 @@ export async function connect(opts) {
|
|
|
3042
3246
|
const outcome = await runLumaLoginFlow(emitProgress);
|
|
3043
3247
|
console.log(describeOutcome(outcome));
|
|
3044
3248
|
socket.emit("luma:login-result", { session_id: sid, ...outcome });
|
|
3249
|
+
if (outcome.error === "browser_not_installed")
|
|
3250
|
+
sendToHost({ type: "notice", code: "browser_not_installed" });
|
|
3045
3251
|
// Successful sign-in just wrote a fresh storage-state.json. If the
|
|
3046
3252
|
// bridge is already up, force the next forward to re-read state
|
|
3047
3253
|
// from disk; if it isn't up (first sign-in on this runner), boot
|
|
@@ -3092,7 +3298,8 @@ export async function connect(opts) {
|
|
|
3092
3298
|
socket.on("runner:kill", (data) => {
|
|
3093
3299
|
const proc = activeProcesses.get(data.runId);
|
|
3094
3300
|
if (proc) {
|
|
3095
|
-
|
|
3301
|
+
// The whole tree: a run's own children (CLI providers, browsers) too.
|
|
3302
|
+
void terminateTree(proc, { graceMs: SHUTDOWN_CHILD_GRACE_MS });
|
|
3096
3303
|
activeProcesses.delete(data.runId);
|
|
3097
3304
|
console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
|
|
3098
3305
|
}
|
|
@@ -3141,38 +3348,71 @@ export async function connect(opts) {
|
|
|
3141
3348
|
// script that fetches `localhost:1234`/`11434` and POSTs each SSE
|
|
3142
3349
|
// line back as an HTTP callback to the cloud server. Same pattern
|
|
3143
3350
|
// as the BacktestOptimizer dispatch.
|
|
3144
|
-
// ── Graceful shutdown
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3351
|
+
// ── Graceful shutdown (audit P0.5) ─────────────────────────────────
|
|
3352
|
+
// Every subsystem is awaited under its own deadline, in parallel: runs and
|
|
3353
|
+
// Assistant hosts (whole process trees), browsers, the Luma bridge, the
|
|
3354
|
+
// vault host, the computer relay, the local relay. A final sweep stops any
|
|
3355
|
+
// tracked child still alive (venv builds, warm-ups), then the socket closes.
|
|
3356
|
+
// Bounded well inside the desktop app's 5 s hard exit. A second Ctrl+C (or
|
|
3357
|
+
// SIGTERM) during shutdown forces the exit at once; the process "exit" hook
|
|
3358
|
+
// in processTree.ts still kills every tracked tree on the way out.
|
|
3359
|
+
let shuttingDown = false;
|
|
3360
|
+
const shutdown = async () => {
|
|
3361
|
+
const t0 = Date.now();
|
|
3362
|
+
if (modelRefresh) {
|
|
3363
|
+
clearInterval(modelRefresh);
|
|
3364
|
+
modelRefresh = null;
|
|
3365
|
+
}
|
|
3366
|
+
forcedRescan.dispose();
|
|
3367
|
+
if (bgTimer) {
|
|
3368
|
+
clearInterval(bgTimer);
|
|
3369
|
+
bgTimer = null;
|
|
3370
|
+
}
|
|
3371
|
+
const runs = [...activeProcesses.entries()];
|
|
3372
|
+
for (const [id] of runs)
|
|
3149
3373
|
socket.emit("runner:runComplete", { runId: id, status: "failed" });
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
}
|
|
3155
|
-
catch { /* noop */ }
|
|
3156
|
-
}
|
|
3374
|
+
activeProcesses.clear();
|
|
3375
|
+
const hosts = [...activeAssistants.values()].map((s) => s.proc);
|
|
3376
|
+
if (warmSpare)
|
|
3377
|
+
hosts.push(warmSpare.proc);
|
|
3157
3378
|
activeAssistants.clear();
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3379
|
+
warmSpare = null;
|
|
3380
|
+
const results = await runShutdownSteps([
|
|
3381
|
+
{ name: "runs", deadlineMs: SHUTDOWN_STEP_MS, run: () => Promise.all(runs.map(([, p]) => terminateTree(p, { graceMs: SHUTDOWN_CHILD_GRACE_MS }))) },
|
|
3382
|
+
{ name: "assistant_hosts", deadlineMs: SHUTDOWN_STEP_MS, run: () => Promise.all(hosts.map((p) => terminateTree(p, { graceMs: SHUTDOWN_CHILD_GRACE_MS }))) },
|
|
3383
|
+
// Melaya Browser terminal path (plan Section 7): cancels in-flight ops,
|
|
3384
|
+
// releases every lease, closes ONLY owned browsers (attached user
|
|
3385
|
+
// browsers are merely disconnected).
|
|
3386
|
+
{ name: "browsers", deadlineMs: SHUTDOWN_STEP_MS, run: () => browserBridge?.shutdown() },
|
|
3387
|
+
{ name: "luma_browser", deadlineMs: SHUTDOWN_STEP_MS, run: () => lumaBridge?.shutdown() },
|
|
3388
|
+
{ name: "vaults", deadlineMs: SHUTDOWN_STEP_MS, run: () => vaultService.stop() },
|
|
3389
|
+
{ name: "computer", deadlineMs: SHUTDOWN_STEP_MS, run: () => computerRelay.dispose() },
|
|
3390
|
+
{ name: "relay", deadlineMs: SHUTDOWN_STEP_MS, run: () => relay?.close() },
|
|
3391
|
+
]);
|
|
3392
|
+
// Anything still tracked (venv build, warm-up, vault host, helper): stop the trees now.
|
|
3393
|
+
const swept = await runShutdownSteps([
|
|
3394
|
+
{ name: "children", deadlineMs: SHUTDOWN_SWEEP_MS + 500, run: () => terminateAllTracked({ graceMs: SHUTDOWN_SWEEP_MS }) },
|
|
3395
|
+
]);
|
|
3396
|
+
const line = `shutdown ${Date.now() - t0}ms: ${formatShutdownResults([...results, ...swept])}`;
|
|
3397
|
+
if (opts.verbose)
|
|
3398
|
+
console.log(chalk.gray(` ${line}`));
|
|
3399
|
+
sendToHost({ type: "log", level: "info", line });
|
|
3171
3400
|
socket.disconnect();
|
|
3172
|
-
process.exit(0);
|
|
3173
3401
|
};
|
|
3402
|
+
const cleanup = () => {
|
|
3403
|
+
if (shuttingDown) {
|
|
3404
|
+
// Second signal: stop waiting.
|
|
3405
|
+
killAllTrackedSync();
|
|
3406
|
+
process.exit(0);
|
|
3407
|
+
}
|
|
3408
|
+
shuttingDown = true;
|
|
3409
|
+
console.log(chalk.gray("\n Shutting down..."));
|
|
3410
|
+
void shutdown().catch(() => { }).finally(() => process.exit(0));
|
|
3411
|
+
};
|
|
3412
|
+
claimGracefulShutdown();
|
|
3174
3413
|
process.on("SIGINT", cleanup);
|
|
3175
3414
|
process.on("SIGTERM", cleanup);
|
|
3415
|
+
return { publishModels, rescanModels, print };
|
|
3176
3416
|
}
|
|
3177
3417
|
export function verifySignature(content, signature, runnerToken) {
|
|
3178
3418
|
if (!content || !signature || !runnerToken)
|