@melaya/runner 1.0.22 → 1.0.25
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/connection.js +150 -3
- package/dist/lumaBrowserBridge.d.ts +86 -0
- package/dist/lumaBrowserBridge.js +326 -0
- package/dist/lumaLogin.d.ts +1 -0
- package/dist/lumaLogin.js +56 -6
- package/dist/modelLoader.d.ts +42 -0
- package/dist/modelLoader.js +225 -0
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -19,6 +19,7 @@ import { join } from "path";
|
|
|
19
19
|
import { tmpdir } from "os";
|
|
20
20
|
import { startLocalRelay, setActiveProject } from "./localRelay.js";
|
|
21
21
|
import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
|
|
22
|
+
import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
22
23
|
const HEARTBEAT_INTERVAL = 30_000;
|
|
23
24
|
const activeProcesses = new Map();
|
|
24
25
|
export async function connect(opts) {
|
|
@@ -36,6 +37,25 @@ export async function connect(opts) {
|
|
|
36
37
|
reconnectionAttempts: Infinity,
|
|
37
38
|
});
|
|
38
39
|
let relay = null;
|
|
40
|
+
// Luma browser bridge — only started after a successful Luma sign-in
|
|
41
|
+
// (storage state file exists). Reset to a fresh bridge after each
|
|
42
|
+
// luma:login-result so cookie rotation is picked up immediately.
|
|
43
|
+
let lumaBridge = null;
|
|
44
|
+
const _ensureLumaBridge = async () => {
|
|
45
|
+
if (lumaBridge)
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
lumaBridge = await startLumaBrowserBridge({
|
|
49
|
+
log: (m) => {
|
|
50
|
+
if (opts.verbose)
|
|
51
|
+
console.log(chalk.gray(` [luma-bridge] ${m}`));
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
console.log(chalk.yellow(` ⚠ Luma browser bridge failed to start: ${e?.message || e}`));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
39
59
|
socket.on("connect", async () => {
|
|
40
60
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
41
61
|
// Report detected models
|
|
@@ -47,6 +67,10 @@ export async function connect(opts) {
|
|
|
47
67
|
console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
|
|
48
68
|
}
|
|
49
69
|
}
|
|
70
|
+
// Start the Luma browser bridge if a saved session exists. Best-
|
|
71
|
+
// effort — failure here means Luma POSTs fall back to aiohttp
|
|
72
|
+
// (which 403s under the bot rule) but reads still work.
|
|
73
|
+
await _ensureLumaBridge();
|
|
50
74
|
console.log(chalk.gray(" Waiting for pipeline runs...\n"));
|
|
51
75
|
});
|
|
52
76
|
socket.on("connect_error", (err) => {
|
|
@@ -92,6 +116,58 @@ export async function connect(opts) {
|
|
|
92
116
|
mkdirSync(runDir, { recursive: true });
|
|
93
117
|
writeFileSync(join(runDir, "main.py"), payload.mainPyContent, "utf-8");
|
|
94
118
|
writeFileSync(join(runDir, "config.json"), payload.configJson, "utf-8");
|
|
119
|
+
// Local-model preflight: ensure LM Studio / Ollama have the
|
|
120
|
+
// pipeline's model loaded BEFORE we spawn the Python subprocess.
|
|
121
|
+
// Without this, the subprocess hangs on its first chat completion
|
|
122
|
+
// call (LM Studio JIT-loads transparently, can take 60-120s with
|
|
123
|
+
// zero progress feedback). Pipeline run d5ead8b8655b47fa hit this
|
|
124
|
+
// — only the `format openai` span landed, no `chat …` span ever
|
|
125
|
+
// arrived because LM Studio was still loading the qwen weights.
|
|
126
|
+
const { preflightModel } = await import("./modelLoader.js");
|
|
127
|
+
const onPreflightProgress = (msg) => {
|
|
128
|
+
console.log(chalk.gray(` [model] ${msg}`));
|
|
129
|
+
// Surface to the FE via a pipeline_phase event so the launching
|
|
130
|
+
// animation reflects "Loading qwen3-vl-8b…" instead of staying
|
|
131
|
+
// on a generic spinner.
|
|
132
|
+
socket.emit("runner:event", {
|
|
133
|
+
runId: payload.runId,
|
|
134
|
+
payload: {
|
|
135
|
+
event_type: "pipeline_phase",
|
|
136
|
+
run_id: payload.runId,
|
|
137
|
+
project: payload.project,
|
|
138
|
+
step: 1,
|
|
139
|
+
total: 5,
|
|
140
|
+
label: msg,
|
|
141
|
+
status: "started",
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
const preflight = await preflightModel(payload.configJson, onPreflightProgress);
|
|
146
|
+
if (!preflight.ok) {
|
|
147
|
+
const detail = `${preflight.reason ?? "unknown"}${preflight.hint ? ` — ${preflight.hint}` : ""}`;
|
|
148
|
+
console.log(chalk.red(` ✗ model preflight failed: ${detail}`));
|
|
149
|
+
socket.emit("runner:event", {
|
|
150
|
+
runId: payload.runId,
|
|
151
|
+
payload: {
|
|
152
|
+
event_type: "agent_message",
|
|
153
|
+
run_id: payload.runId,
|
|
154
|
+
project: payload.project,
|
|
155
|
+
replyId: `preflight-${payload.runId}`,
|
|
156
|
+
replyName: "Runner",
|
|
157
|
+
replyRole: "system",
|
|
158
|
+
msg: {
|
|
159
|
+
id: `preflight-${payload.runId}`,
|
|
160
|
+
name: "Runner",
|
|
161
|
+
role: "system",
|
|
162
|
+
content: [{ type: "text", text: `Model preflight failed: ${detail}` }],
|
|
163
|
+
metadata: {},
|
|
164
|
+
timestamp: new Date().toISOString(),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
95
171
|
// Build env vars for the subprocess
|
|
96
172
|
const relayUrl = `http://127.0.0.1:${relay.port}/events/relay/${relay.nonce}`;
|
|
97
173
|
const sharedDir = getSharedDir();
|
|
@@ -112,6 +188,16 @@ export async function connect(opts) {
|
|
|
112
188
|
OLLAMA_BASE_URL: "http://127.0.0.1:11434",
|
|
113
189
|
// Inject per-pipeline credentials (already scoped by the server)
|
|
114
190
|
...payload.credentials,
|
|
191
|
+
// If a Luma browser bridge is up (i.e. the user has signed in
|
|
192
|
+
// via this runner), inject its localhost URL + bearer token so
|
|
193
|
+
// shared/tools/luma.py routes /event/register through Playwright
|
|
194
|
+
// and bypasses Cloudflare's bot rule. Reads keep using aiohttp.
|
|
195
|
+
...(lumaBridge
|
|
196
|
+
? {
|
|
197
|
+
MEL_LUMA_BROWSER_URL: lumaBridge.url,
|
|
198
|
+
MEL_LUMA_BROWSER_TOKEN: lumaBridge.token,
|
|
199
|
+
}
|
|
200
|
+
: {}),
|
|
115
201
|
};
|
|
116
202
|
// Spawn Python subprocess
|
|
117
203
|
const proc = spawn(opts.pythonPath, ["-u", join(runDir, "main.py")], {
|
|
@@ -120,19 +206,60 @@ export async function connect(opts) {
|
|
|
120
206
|
stdio: ["ignore", "pipe", "pipe"],
|
|
121
207
|
});
|
|
122
208
|
activeProcesses.set(payload.runId, proc);
|
|
209
|
+
// Ring buffer of recent stderr — surfaced to the FE on non-zero
|
|
210
|
+
// exit so the operator sees the actual Python traceback in the
|
|
211
|
+
// chat panel instead of a silent "failed" with no context.
|
|
212
|
+
// Capped at 60 lines so a noisy traceback can't OOM us.
|
|
213
|
+
const stderrTail = [];
|
|
214
|
+
const STDERR_TAIL_MAX = 60;
|
|
123
215
|
proc.stdout?.on("data", (data) => {
|
|
124
216
|
const line = data.toString().trim();
|
|
125
217
|
if (line && opts.verbose)
|
|
126
218
|
console.log(chalk.gray(` [stdout] ${line}`));
|
|
127
219
|
});
|
|
128
220
|
proc.stderr?.on("data", (data) => {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
221
|
+
const text = data.toString();
|
|
222
|
+
for (const ln of text.split("\n")) {
|
|
223
|
+
const t = ln.trimEnd();
|
|
224
|
+
if (!t)
|
|
225
|
+
continue;
|
|
226
|
+
console.log(chalk.yellow(` [stderr] ${t}`));
|
|
227
|
+
stderrTail.push(t);
|
|
228
|
+
if (stderrTail.length > STDERR_TAIL_MAX)
|
|
229
|
+
stderrTail.shift();
|
|
230
|
+
}
|
|
132
231
|
});
|
|
133
232
|
proc.on("exit", (code) => {
|
|
134
233
|
activeProcesses.delete(payload.runId);
|
|
135
234
|
const status = code === 0 ? "done" : "failed";
|
|
235
|
+
// On non-zero exit, relay the captured stderr tail to the FE
|
|
236
|
+
// as a system agent_message so the operator sees the actual
|
|
237
|
+
// failure reason. Without this, a Python crash before
|
|
238
|
+
// agentscope.init() runs (bad import, missing dep, syntax
|
|
239
|
+
// error in generated main.py) shows up only as a silent
|
|
240
|
+
// run_status:failed and the user has no clue what happened.
|
|
241
|
+
if (status === "failed" && stderrTail.length > 0) {
|
|
242
|
+
const tail = stderrTail.slice(-15).join("\n");
|
|
243
|
+
socket.emit("runner:event", {
|
|
244
|
+
run_id: payload.runId,
|
|
245
|
+
event_type: "agent_message",
|
|
246
|
+
project: payload.project,
|
|
247
|
+
replyId: `runner-exit-${payload.runId}`,
|
|
248
|
+
replyName: "Runner",
|
|
249
|
+
replyRole: "system",
|
|
250
|
+
msg: {
|
|
251
|
+
id: `runner-exit-${payload.runId}`,
|
|
252
|
+
name: "Runner",
|
|
253
|
+
role: "system",
|
|
254
|
+
content: [{
|
|
255
|
+
type: "text",
|
|
256
|
+
text: `Pipeline exited with code ${code}. Last stderr:\n\`\`\`\n${tail}\n\`\`\``,
|
|
257
|
+
}],
|
|
258
|
+
metadata: { exitCode: code },
|
|
259
|
+
timestamp: new Date().toISOString(),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
136
263
|
// Emit run_status FIRST (before runComplete deletes from ownedRunIds)
|
|
137
264
|
socket.emit("runner:event", {
|
|
138
265
|
run_id: payload.runId,
|
|
@@ -207,6 +334,20 @@ export async function connect(opts) {
|
|
|
207
334
|
const outcome = await runLumaLoginFlow(emitProgress);
|
|
208
335
|
console.log(describeOutcome(outcome));
|
|
209
336
|
socket.emit("luma:login-result", { session_id: sid, ...outcome });
|
|
337
|
+
// Successful sign-in just wrote a fresh storage-state.json. If the
|
|
338
|
+
// bridge is already up, force the next forward to re-read state
|
|
339
|
+
// from disk; if it isn't up (first sign-in on this runner), boot
|
|
340
|
+
// it now so the very next pipeline run has Cloudflare bypass.
|
|
341
|
+
if (outcome.ok) {
|
|
342
|
+
if (lumaBridge) {
|
|
343
|
+
lumaBridge.invalidateState();
|
|
344
|
+
if (opts.verbose)
|
|
345
|
+
console.log(chalk.gray(" [luma-bridge] storage state invalidated; next call will re-read."));
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
await _ensureLumaBridge();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
210
351
|
}
|
|
211
352
|
catch (e) {
|
|
212
353
|
const msg = e?.message || "luma_login_unhandled_error";
|
|
@@ -240,6 +381,12 @@ export async function connect(opts) {
|
|
|
240
381
|
socket.emit("runner:runComplete", { runId: id, status: "failed" });
|
|
241
382
|
}
|
|
242
383
|
relay?.close();
|
|
384
|
+
// Best-effort Chromium teardown. The bridge's shutdown is async but
|
|
385
|
+
// we can't await inside a SIGINT handler — fire and forget; the
|
|
386
|
+
// process.exit below kills any lingering child processes anyway.
|
|
387
|
+
if (lumaBridge) {
|
|
388
|
+
lumaBridge.shutdown().catch(() => { });
|
|
389
|
+
}
|
|
243
390
|
socket.disconnect();
|
|
244
391
|
process.exit(0);
|
|
245
392
|
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Luma browser bridge — localhost HTTP server that tunnels HTTP requests
|
|
3
|
+
* through a Playwright Chromium context loaded from the user's saved
|
|
4
|
+
* Luma sign-in state.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ───────────────
|
|
8
|
+
*
|
|
9
|
+
* Cloudflare's bot rule on `POST /event/register` (and a handful of other
|
|
10
|
+
* write endpoints) is much stricter than on reads. It checks:
|
|
11
|
+
* 1. cf_clearance cookie issued by a managed challenge.
|
|
12
|
+
* 2. cf_bm cookie value freshly minted by JS executed on the page.
|
|
13
|
+
* 3. JA3/JA4 TLS fingerprint matching a real browser.
|
|
14
|
+
* 4. sec-ch-ua / sec-fetch-* header set matching same.
|
|
15
|
+
*
|
|
16
|
+
* Replaying cookies through aiohttp from Python misses #2-#4 and gets a
|
|
17
|
+
* 403 even with a valid session — see run cd0d03d05e684e0f. The reliable
|
|
18
|
+
* fix is to issue the request from inside the SAME Chromium context that
|
|
19
|
+
* captured the session: the JS runs, cookies refresh, headers and TLS
|
|
20
|
+
* fingerprint match, and the bot rule clears.
|
|
21
|
+
*
|
|
22
|
+
* How it works
|
|
23
|
+
* ────────────
|
|
24
|
+
*
|
|
25
|
+
* 1. At Luma sign-in, the Playwright `storageState` (full cookie jar +
|
|
26
|
+
* localStorage + sessionStorage) is saved to `<runner-state-dir>/
|
|
27
|
+
* luma-storage-state.json`. This is the source of truth for the
|
|
28
|
+
* browser session — separate from the cookies persisted to the
|
|
29
|
+
* server's DB (which feed the env-var path used by aiohttp reads).
|
|
30
|
+
* 2. At runner startup, if the storage-state file exists, this module
|
|
31
|
+
* starts a small HTTP server bound to 127.0.0.1 on a random free
|
|
32
|
+
* port, mints a per-session token, and exposes both via env vars
|
|
33
|
+
* `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
|
|
34
|
+
* Python pipeline subprocess.
|
|
35
|
+
* 3. Python's `luma_register_event` POSTs the registration body to
|
|
36
|
+
* `${MEL_LUMA_BROWSER_URL}/luma/forward` with
|
|
37
|
+
* `Authorization: Bearer ${MEL_LUMA_BROWSER_TOKEN}`. The bridge:
|
|
38
|
+
* a. Spawns / reuses a headless Chromium with the saved storage
|
|
39
|
+
* state, freshly reloaded from disk so a recent reconnect's
|
|
40
|
+
* cookies are picked up immediately.
|
|
41
|
+
* b. Navigates to `https://luma.com/${eid}` so Cloudflare's JS
|
|
42
|
+
* executes and refreshes cf_bm/cf_clearance.
|
|
43
|
+
* c. Calls `fetch(url, init)` from inside `page.evaluate(...)`
|
|
44
|
+
* so the request inherits the page's full browser context.
|
|
45
|
+
* d. Returns `{ status, body }` to Python.
|
|
46
|
+
*
|
|
47
|
+
* Security
|
|
48
|
+
* ────────
|
|
49
|
+
*
|
|
50
|
+
* - Server binds to 127.0.0.1 only — never accessible off the host.
|
|
51
|
+
* - Random per-session token in the Authorization header — even other
|
|
52
|
+
* processes on the same machine can't fire requests without it
|
|
53
|
+
* (the env var is only injected into the runner-spawned pipeline
|
|
54
|
+
* subprocess).
|
|
55
|
+
* - Endpoint is narrow: the URL must be a luma.com / api.luma.com /
|
|
56
|
+
* api2.luma.com / lu.ma origin and the method must be GET / POST.
|
|
57
|
+
* Anything else is rejected with 400.
|
|
58
|
+
* - Body size is capped at 64 KB.
|
|
59
|
+
*
|
|
60
|
+
* Cleanup
|
|
61
|
+
* ───────
|
|
62
|
+
*
|
|
63
|
+
* The browser is started lazily on the first request and torn down on
|
|
64
|
+
* runner exit. If the runner is killed mid-request, the next request
|
|
65
|
+
* relaunches.
|
|
66
|
+
*/
|
|
67
|
+
export interface LumaBrowserBridge {
|
|
68
|
+
url: string;
|
|
69
|
+
token: string;
|
|
70
|
+
shutdown: () => Promise<void>;
|
|
71
|
+
/** Called from connection.ts after a successful luma:login-result so
|
|
72
|
+
* the next forward picks up the freshly-saved storage state. The
|
|
73
|
+
* bridge always re-reads from disk, so this is purely a hint to
|
|
74
|
+
* drop any reusable browser context (forces re-create on next call). */
|
|
75
|
+
invalidateState: () => void;
|
|
76
|
+
}
|
|
77
|
+
export declare function lumaStorageStatePath(): string;
|
|
78
|
+
/**
|
|
79
|
+
* Starts the bridge if a saved Luma storage state exists. Returns null
|
|
80
|
+
* when no state is present so the runner stays in legacy aiohttp-only
|
|
81
|
+
* mode and Python's diagnostic path correctly reports
|
|
82
|
+
* `cf_cookies_present: []`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function startLumaBrowserBridge(opts: {
|
|
85
|
+
log: (msg: string) => void;
|
|
86
|
+
}): Promise<LumaBrowserBridge | null>;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Luma browser bridge — localhost HTTP server that tunnels HTTP requests
|
|
3
|
+
* through a Playwright Chromium context loaded from the user's saved
|
|
4
|
+
* Luma sign-in state.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ───────────────
|
|
8
|
+
*
|
|
9
|
+
* Cloudflare's bot rule on `POST /event/register` (and a handful of other
|
|
10
|
+
* write endpoints) is much stricter than on reads. It checks:
|
|
11
|
+
* 1. cf_clearance cookie issued by a managed challenge.
|
|
12
|
+
* 2. cf_bm cookie value freshly minted by JS executed on the page.
|
|
13
|
+
* 3. JA3/JA4 TLS fingerprint matching a real browser.
|
|
14
|
+
* 4. sec-ch-ua / sec-fetch-* header set matching same.
|
|
15
|
+
*
|
|
16
|
+
* Replaying cookies through aiohttp from Python misses #2-#4 and gets a
|
|
17
|
+
* 403 even with a valid session — see run cd0d03d05e684e0f. The reliable
|
|
18
|
+
* fix is to issue the request from inside the SAME Chromium context that
|
|
19
|
+
* captured the session: the JS runs, cookies refresh, headers and TLS
|
|
20
|
+
* fingerprint match, and the bot rule clears.
|
|
21
|
+
*
|
|
22
|
+
* How it works
|
|
23
|
+
* ────────────
|
|
24
|
+
*
|
|
25
|
+
* 1. At Luma sign-in, the Playwright `storageState` (full cookie jar +
|
|
26
|
+
* localStorage + sessionStorage) is saved to `<runner-state-dir>/
|
|
27
|
+
* luma-storage-state.json`. This is the source of truth for the
|
|
28
|
+
* browser session — separate from the cookies persisted to the
|
|
29
|
+
* server's DB (which feed the env-var path used by aiohttp reads).
|
|
30
|
+
* 2. At runner startup, if the storage-state file exists, this module
|
|
31
|
+
* starts a small HTTP server bound to 127.0.0.1 on a random free
|
|
32
|
+
* port, mints a per-session token, and exposes both via env vars
|
|
33
|
+
* `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
|
|
34
|
+
* Python pipeline subprocess.
|
|
35
|
+
* 3. Python's `luma_register_event` POSTs the registration body to
|
|
36
|
+
* `${MEL_LUMA_BROWSER_URL}/luma/forward` with
|
|
37
|
+
* `Authorization: Bearer ${MEL_LUMA_BROWSER_TOKEN}`. The bridge:
|
|
38
|
+
* a. Spawns / reuses a headless Chromium with the saved storage
|
|
39
|
+
* state, freshly reloaded from disk so a recent reconnect's
|
|
40
|
+
* cookies are picked up immediately.
|
|
41
|
+
* b. Navigates to `https://luma.com/${eid}` so Cloudflare's JS
|
|
42
|
+
* executes and refreshes cf_bm/cf_clearance.
|
|
43
|
+
* c. Calls `fetch(url, init)` from inside `page.evaluate(...)`
|
|
44
|
+
* so the request inherits the page's full browser context.
|
|
45
|
+
* d. Returns `{ status, body }` to Python.
|
|
46
|
+
*
|
|
47
|
+
* Security
|
|
48
|
+
* ────────
|
|
49
|
+
*
|
|
50
|
+
* - Server binds to 127.0.0.1 only — never accessible off the host.
|
|
51
|
+
* - Random per-session token in the Authorization header — even other
|
|
52
|
+
* processes on the same machine can't fire requests without it
|
|
53
|
+
* (the env var is only injected into the runner-spawned pipeline
|
|
54
|
+
* subprocess).
|
|
55
|
+
* - Endpoint is narrow: the URL must be a luma.com / api.luma.com /
|
|
56
|
+
* api2.luma.com / lu.ma origin and the method must be GET / POST.
|
|
57
|
+
* Anything else is rejected with 400.
|
|
58
|
+
* - Body size is capped at 64 KB.
|
|
59
|
+
*
|
|
60
|
+
* Cleanup
|
|
61
|
+
* ───────
|
|
62
|
+
*
|
|
63
|
+
* The browser is started lazily on the first request and torn down on
|
|
64
|
+
* runner exit. If the runner is killed mid-request, the next request
|
|
65
|
+
* relaunches.
|
|
66
|
+
*/
|
|
67
|
+
import { createServer } from "node:http";
|
|
68
|
+
import { randomBytes } from "node:crypto";
|
|
69
|
+
import { promises as fs } from "node:fs";
|
|
70
|
+
import { existsSync } from "node:fs";
|
|
71
|
+
import path from "node:path";
|
|
72
|
+
import os from "node:os";
|
|
73
|
+
const ALLOWED_HOSTNAMES = new Set([
|
|
74
|
+
"luma.com",
|
|
75
|
+
"www.luma.com",
|
|
76
|
+
"lu.ma",
|
|
77
|
+
"api.luma.com",
|
|
78
|
+
"api2.luma.com",
|
|
79
|
+
]);
|
|
80
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
81
|
+
const PAGE_GOTO_TIMEOUT_MS = 20_000;
|
|
82
|
+
const PAGE_SETTLE_MS = 1500;
|
|
83
|
+
// ── State-file location ─────────────────────────────────────────────────────
|
|
84
|
+
/** Resolves the same `melaya-runner` state dir the rest of the runner uses.
|
|
85
|
+
* We can't share a constant because connection.ts doesn't expose its dir,
|
|
86
|
+
* but the convention is well-defined: ~/.melaya/<sub> on every OS. */
|
|
87
|
+
function _stateDir() {
|
|
88
|
+
return path.join(os.homedir(), ".melaya", "luma");
|
|
89
|
+
}
|
|
90
|
+
export function lumaStorageStatePath() {
|
|
91
|
+
return path.join(_stateDir(), "storage-state.json");
|
|
92
|
+
}
|
|
93
|
+
// ── Public starter ──────────────────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Starts the bridge if a saved Luma storage state exists. Returns null
|
|
96
|
+
* when no state is present so the runner stays in legacy aiohttp-only
|
|
97
|
+
* mode and Python's diagnostic path correctly reports
|
|
98
|
+
* `cf_cookies_present: []`.
|
|
99
|
+
*/
|
|
100
|
+
export async function startLumaBrowserBridge(opts) {
|
|
101
|
+
const statePath = lumaStorageStatePath();
|
|
102
|
+
if (!existsSync(statePath)) {
|
|
103
|
+
opts.log("Luma browser bridge: no saved storage state, skipping.");
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// Lazy-load Playwright. If the user installed @melaya/runner without
|
|
107
|
+
// chromium ever being needed (e.g. legacy local-model only), we don't
|
|
108
|
+
// want to crash on startup.
|
|
109
|
+
let playwright;
|
|
110
|
+
try {
|
|
111
|
+
playwright = await import("playwright");
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
opts.log("Luma browser bridge: playwright module unavailable, skipping.");
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const token = randomBytes(24).toString("hex");
|
|
118
|
+
// Persistent browser handle. We launch on first request, not at boot,
|
|
119
|
+
// so the Chromium binary cost (~250 MB resident) is only paid by users
|
|
120
|
+
// who actually run a Luma pipeline. Subsequent requests reuse the
|
|
121
|
+
// browser; only the per-call context is fresh.
|
|
122
|
+
let browser = null;
|
|
123
|
+
let browserLaunching = null;
|
|
124
|
+
let stateMtime = 0;
|
|
125
|
+
async function _ensureBrowser() {
|
|
126
|
+
if (browser && browser.isConnected())
|
|
127
|
+
return browser;
|
|
128
|
+
if (browserLaunching)
|
|
129
|
+
return browserLaunching;
|
|
130
|
+
browserLaunching = (async () => {
|
|
131
|
+
const b = await playwright.chromium.launch({
|
|
132
|
+
headless: true,
|
|
133
|
+
args: [
|
|
134
|
+
"--disable-blink-features=AutomationControlled",
|
|
135
|
+
"--disable-dev-shm-usage",
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
browser = b;
|
|
139
|
+
browserLaunching = null;
|
|
140
|
+
return b;
|
|
141
|
+
})();
|
|
142
|
+
return browserLaunching;
|
|
143
|
+
}
|
|
144
|
+
async function _readStorageState() {
|
|
145
|
+
const stat = await fs.stat(statePath);
|
|
146
|
+
stateMtime = stat.mtimeMs;
|
|
147
|
+
const txt = await fs.readFile(statePath, "utf-8");
|
|
148
|
+
return JSON.parse(txt);
|
|
149
|
+
}
|
|
150
|
+
async function _forward(req) {
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = new URL(req.url);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return { ok: false, status: 0, body: "", error: "invalid_url" };
|
|
157
|
+
}
|
|
158
|
+
if (!ALLOWED_HOSTNAMES.has(parsed.hostname)) {
|
|
159
|
+
return { ok: false, status: 0, body: "", error: `host_not_allowed: ${parsed.hostname}` };
|
|
160
|
+
}
|
|
161
|
+
const method = (req.method || "POST").toUpperCase();
|
|
162
|
+
if (method !== "GET" && method !== "POST") {
|
|
163
|
+
return { ok: false, status: 0, body: "", error: `method_not_allowed: ${method}` };
|
|
164
|
+
}
|
|
165
|
+
const b = await _ensureBrowser();
|
|
166
|
+
let ctx = null;
|
|
167
|
+
try {
|
|
168
|
+
const storageState = await _readStorageState();
|
|
169
|
+
ctx = await b.newContext({
|
|
170
|
+
storageState: storageState,
|
|
171
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
172
|
+
"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
|
|
173
|
+
viewport: { width: 1280, height: 820 },
|
|
174
|
+
});
|
|
175
|
+
const page = await ctx.newPage();
|
|
176
|
+
const warmup = req.warmupUrl || `https://luma.com/`;
|
|
177
|
+
try {
|
|
178
|
+
await page.goto(warmup, {
|
|
179
|
+
waitUntil: "domcontentloaded",
|
|
180
|
+
timeout: req.navigationTimeoutMs || PAGE_GOTO_TIMEOUT_MS,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
// Don't fail the whole request — Cloudflare 403s during goto can
|
|
185
|
+
// happen for events that return 404 on the web shell but accept
|
|
186
|
+
// the API POST. Surface the error and keep going.
|
|
187
|
+
opts.log(`[luma-bridge] warmup goto failed (${e?.message || e}); proceeding to fetch.`);
|
|
188
|
+
}
|
|
189
|
+
// Settle: let the page's JS finish (Cloudflare token refresh, etc.)
|
|
190
|
+
await page.waitForTimeout(PAGE_SETTLE_MS);
|
|
191
|
+
// Fire the request from INSIDE the page so JA3 + headers + cookies
|
|
192
|
+
// are all browser-native. We pass init through page.evaluate so we
|
|
193
|
+
// don't have to maintain a parallel header set in this module.
|
|
194
|
+
const init = {
|
|
195
|
+
url: req.url,
|
|
196
|
+
method,
|
|
197
|
+
headers: req.headers,
|
|
198
|
+
body: typeof req.body === "string" ? req.body : (req.body ? JSON.stringify(req.body) : undefined),
|
|
199
|
+
};
|
|
200
|
+
const result = await page.evaluate(async (init) => {
|
|
201
|
+
const fetchInit = {
|
|
202
|
+
method: init.method,
|
|
203
|
+
headers: init.headers,
|
|
204
|
+
credentials: "include",
|
|
205
|
+
};
|
|
206
|
+
if (init.body !== undefined)
|
|
207
|
+
fetchInit.body = init.body;
|
|
208
|
+
const r = await fetch(init.url, fetchInit);
|
|
209
|
+
const text = await r.text();
|
|
210
|
+
return { status: r.status, body: text };
|
|
211
|
+
}, init);
|
|
212
|
+
return { ok: true, status: result.status, body: result.body };
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
return { ok: false, status: 0, body: "", error: `bridge_error: ${e?.message || e}` };
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
if (ctx) {
|
|
219
|
+
try {
|
|
220
|
+
await ctx.close();
|
|
221
|
+
}
|
|
222
|
+
catch { /* ignore */ }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ── HTTP server ──────────────────────────────────────────────────────────
|
|
227
|
+
const server = createServer(async (req, res) => {
|
|
228
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
229
|
+
res.setHeader("Cache-Control", "no-store");
|
|
230
|
+
if (req.method !== "POST") {
|
|
231
|
+
res.statusCode = 405;
|
|
232
|
+
res.end(JSON.stringify({ ok: false, error: "method_not_allowed" }));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Auth — bearer token must match what we minted. Reject any request
|
|
236
|
+
// missing or mismatched. Drop in constant time isn't critical for
|
|
237
|
+
// local-only IPC but cheap to do anyway.
|
|
238
|
+
const auth = String(req.headers["authorization"] || "");
|
|
239
|
+
const expected = `Bearer ${token}`;
|
|
240
|
+
if (auth.length !== expected.length || auth !== expected) {
|
|
241
|
+
res.statusCode = 401;
|
|
242
|
+
res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (req.url !== "/luma/forward") {
|
|
246
|
+
res.statusCode = 404;
|
|
247
|
+
res.end(JSON.stringify({ ok: false, error: "not_found" }));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Read JSON body with a hard size cap.
|
|
251
|
+
const chunks = [];
|
|
252
|
+
let total = 0;
|
|
253
|
+
let oversized = false;
|
|
254
|
+
for await (const chunk of req) {
|
|
255
|
+
total += chunk.length;
|
|
256
|
+
if (total > MAX_BODY_BYTES) {
|
|
257
|
+
oversized = true;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
chunks.push(chunk);
|
|
261
|
+
}
|
|
262
|
+
if (oversized) {
|
|
263
|
+
res.statusCode = 413;
|
|
264
|
+
res.end(JSON.stringify({ ok: false, error: "payload_too_large" }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
let payload;
|
|
268
|
+
try {
|
|
269
|
+
payload = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
res.statusCode = 400;
|
|
273
|
+
res.end(JSON.stringify({ ok: false, error: "invalid_json" }));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!payload || typeof payload !== "object" || !payload.url) {
|
|
277
|
+
res.statusCode = 400;
|
|
278
|
+
res.end(JSON.stringify({ ok: false, error: "missing_url" }));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const out = await _forward(payload);
|
|
282
|
+
res.statusCode = 200;
|
|
283
|
+
res.setHeader("Content-Type", "application/json");
|
|
284
|
+
res.end(JSON.stringify(out));
|
|
285
|
+
});
|
|
286
|
+
// Bind to 127.0.0.1 with port 0 — OS picks a free port. Promise resolves
|
|
287
|
+
// after listen() so we can return the URL.
|
|
288
|
+
await new Promise((resolve, reject) => {
|
|
289
|
+
server.once("error", reject);
|
|
290
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
291
|
+
});
|
|
292
|
+
const addr = server.address();
|
|
293
|
+
if (!addr || typeof addr === "string") {
|
|
294
|
+
server.close();
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
// Read the state mtime upfront so invalidateState() has a baseline.
|
|
298
|
+
try {
|
|
299
|
+
const stat = await fs.stat(statePath);
|
|
300
|
+
stateMtime = stat.mtimeMs;
|
|
301
|
+
}
|
|
302
|
+
catch { /* ignore */ }
|
|
303
|
+
opts.log(`Luma browser bridge ready on http://127.0.0.1:${addr.port} (storage state from ${new Date(stateMtime).toISOString()}).`);
|
|
304
|
+
return {
|
|
305
|
+
url: `http://127.0.0.1:${addr.port}`,
|
|
306
|
+
token,
|
|
307
|
+
shutdown: async () => {
|
|
308
|
+
try {
|
|
309
|
+
server.close();
|
|
310
|
+
}
|
|
311
|
+
catch { /* ignore */ }
|
|
312
|
+
if (browser) {
|
|
313
|
+
try {
|
|
314
|
+
await browser.close();
|
|
315
|
+
}
|
|
316
|
+
catch { /* ignore */ }
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
invalidateState: () => {
|
|
320
|
+
// Force a fresh context on the next call. We don't tear down the
|
|
321
|
+
// browser itself — just the cached state mtime so the next read
|
|
322
|
+
// re-loads from disk.
|
|
323
|
+
stateMtime = 0;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
package/dist/lumaLogin.d.ts
CHANGED
package/dist/lumaLogin.js
CHANGED
|
@@ -133,14 +133,39 @@ export async function runLumaLoginFlow(progress) {
|
|
|
133
133
|
if (!succeeded) {
|
|
134
134
|
return { ok: false, error: "login_timeout", detail: "5-minute sign-in window expired." };
|
|
135
135
|
}
|
|
136
|
-
// Cookie names
|
|
137
|
-
// luma.auth-session-key —
|
|
138
|
-
// luma.user-id — informational
|
|
139
|
-
//
|
|
140
|
-
//
|
|
136
|
+
// Cookie names we replay on every api2.luma.com call:
|
|
137
|
+
// luma.auth-session-key — load-bearing session token (required)
|
|
138
|
+
// luma.user-id — informational; short-circuits /me lookups
|
|
139
|
+
// cf_clearance — Cloudflare bot-challenge clearance token.
|
|
140
|
+
// Issued only after a Turnstile / managed
|
|
141
|
+
// challenge passes. Reads work without it
|
|
142
|
+
// (served from CF edge cache); WRITES like
|
|
143
|
+
// POST /event/register fail with 403 when
|
|
144
|
+
// it's absent — see run cd0d03d05e684e0f.
|
|
145
|
+
// __cf_bm — Cloudflare bot-management cookie. Rotates
|
|
146
|
+
// every ~30min; not strictly required but
|
|
147
|
+
// having it reduces the false-positive rate
|
|
148
|
+
// on the bot heuristic.
|
|
149
|
+
// _cfuvid — Cloudflare unique-visitor id; same role.
|
|
150
|
+
// Cookies are scoped to luma.com / lu.ma / api.luma.com — Playwright's
|
|
151
|
+
// context.cookies() returns them all; we filter by name, not domain.
|
|
141
152
|
const allCookies = await context.cookies();
|
|
142
153
|
const auth_c = allCookies.find((c) => c.name === "luma.auth-session-key");
|
|
143
154
|
const uid_c = allCookies.find((c) => c.name === "luma.user-id");
|
|
155
|
+
const CLOUDFLARE_COOKIE_NAMES = ["cf_clearance", "__cf_bm", "_cfuvid"];
|
|
156
|
+
const extraCookies = {};
|
|
157
|
+
for (const name of CLOUDFLARE_COOKIE_NAMES) {
|
|
158
|
+
// Pick the most-recently-set value (Playwright orders by insertion
|
|
159
|
+
// but a single name can have multiple entries across subdomains).
|
|
160
|
+
// The api2.luma.com one takes priority since that's the request
|
|
161
|
+
// target; fall back to luma.com if api isn't set yet.
|
|
162
|
+
const matches = allCookies.filter((c) => c.name === name);
|
|
163
|
+
const apiCookie = matches.find((c) => c.domain.includes("api.luma.com") || c.domain.includes("api2.luma.com"));
|
|
164
|
+
const webCookie = matches.find((c) => c.domain.endsWith("luma.com") || c.domain.endsWith("lu.ma"));
|
|
165
|
+
const picked = apiCookie?.value || webCookie?.value || matches[0]?.value;
|
|
166
|
+
if (picked)
|
|
167
|
+
extraCookies[name] = picked;
|
|
168
|
+
}
|
|
144
169
|
if (!auth_c?.value) {
|
|
145
170
|
return {
|
|
146
171
|
ok: false, error: "cookies_not_found",
|
|
@@ -161,12 +186,37 @@ export async function runLumaLoginFlow(progress) {
|
|
|
161
186
|
});
|
|
162
187
|
}
|
|
163
188
|
catch { /* swallow */ }
|
|
164
|
-
|
|
189
|
+
const cfNames = Object.keys(extraCookies);
|
|
190
|
+
const cfSummary = cfNames.length > 0 ? ` + ${cfNames.length} Cloudflare cookie${cfNames.length > 1 ? "s" : ""} (${cfNames.join(", ")})` : "";
|
|
191
|
+
// Persist the FULL Playwright storage state so the runner's browser
|
|
192
|
+
// bridge (lumaBrowserBridge.ts) can replay the same session for
|
|
193
|
+
// bot-rule-gated POSTs like /event/register. Cookies alone aren't
|
|
194
|
+
// enough — Cloudflare also fingerprints cf_bm freshness, JS
|
|
195
|
+
// execution, headers, and TLS — all of which the bridge gets for
|
|
196
|
+
// free by reusing this state inside a real Chromium context.
|
|
197
|
+
let bridgeStateNote = "";
|
|
198
|
+
try {
|
|
199
|
+
const path = await import("node:path");
|
|
200
|
+
const os = await import("node:os");
|
|
201
|
+
const fs = await import("node:fs/promises");
|
|
202
|
+
const stateDir = path.join(os.homedir(), ".melaya", "luma");
|
|
203
|
+
const statePath = path.join(stateDir, "storage-state.json");
|
|
204
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
205
|
+
// Playwright's storageState() returns cookies + localStorage +
|
|
206
|
+
// sessionStorage + indexedDB-flavored entries. We persist as-is.
|
|
207
|
+
await context.storageState({ path: statePath });
|
|
208
|
+
bridgeStateNote = " · browser session saved for bot-rule bypass";
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
bridgeStateNote = ` · WARN: could not save browser session (${e?.message || e})`;
|
|
212
|
+
}
|
|
213
|
+
progress((profileName ? `Captured session for ${profileName}` : "Captured session") + cfSummary + bridgeStateNote + ".");
|
|
165
214
|
await context.close();
|
|
166
215
|
await browser.close();
|
|
167
216
|
return {
|
|
168
217
|
ok: true,
|
|
169
218
|
cookies: { auth_session_key: auth_c.value, user_id: uid_c?.value },
|
|
219
|
+
extraCookies: Object.keys(extraCookies).length > 0 ? extraCookies : undefined,
|
|
170
220
|
profileName,
|
|
171
221
|
};
|
|
172
222
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-runner preflight: ensure the LLM model the pipeline is about to
|
|
3
|
+
* call is actually loaded in LM Studio / pulled in Ollama before we spawn
|
|
4
|
+
* the Python subprocess.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists:
|
|
7
|
+
* - LM Studio's OpenAI-compat `/v1/chat/completions` will JIT-load a
|
|
8
|
+
* downloaded model on first request, but the request blocks for the
|
|
9
|
+
* entire load (60-120s for 8B-class quantised models). The pipeline
|
|
10
|
+
* subprocess sees no progress, the FE sees a stalled "Launching
|
|
11
|
+
* pipeline" spinner, the agent's first ChatResponse never arrives.
|
|
12
|
+
* Run d5ead8b8655b47fa hit this exactly: 1 `format openai` span, no
|
|
13
|
+
* `chat ...` span, no messages.
|
|
14
|
+
* - Ollama works similarly — first call to a model that isn't pulled
|
|
15
|
+
* will fail with "model not found"; downloads have to be explicit.
|
|
16
|
+
*
|
|
17
|
+
* What we do:
|
|
18
|
+
* 1. Read the model name + provider from the pipeline's config.json.
|
|
19
|
+
* 2. For lmstudio: GET /api/v0/models to see {id, state} for every
|
|
20
|
+
* model. If state="loaded" we're good. If state="not-loaded" we
|
|
21
|
+
* POST a tiny chat completion to force the JIT load while
|
|
22
|
+
* surfacing progress to the operator's stderr + emitting a
|
|
23
|
+
* socket event so the FE can show "Loading qwen/qwen3-vl-8b…".
|
|
24
|
+
* If model isn't in the list at all → fail fast with a clear
|
|
25
|
+
* "open LM Studio and download <name>" message.
|
|
26
|
+
* 3. For ollama: GET /api/tags. If model not present → fail fast with
|
|
27
|
+
* `ollama pull <name>` instruction. If present, ollama auto-loads.
|
|
28
|
+
*/
|
|
29
|
+
export interface ModelPreflightResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
reason?: string;
|
|
32
|
+
hint?: string;
|
|
33
|
+
loaded?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function preflightLmStudio(modelName: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
36
|
+
export declare function preflightOllama(modelName: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
37
|
+
/**
|
|
38
|
+
* Top-level preflight — resolves the provider/model from the pipeline
|
|
39
|
+
* config and dispatches to the right helper. Returns immediately for
|
|
40
|
+
* non-local providers (cloud-spawn shouldn't reach the runner anyway).
|
|
41
|
+
*/
|
|
42
|
+
export declare function preflightModel(configJson: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-runner preflight: ensure the LLM model the pipeline is about to
|
|
3
|
+
* call is actually loaded in LM Studio / pulled in Ollama before we spawn
|
|
4
|
+
* the Python subprocess.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists:
|
|
7
|
+
* - LM Studio's OpenAI-compat `/v1/chat/completions` will JIT-load a
|
|
8
|
+
* downloaded model on first request, but the request blocks for the
|
|
9
|
+
* entire load (60-120s for 8B-class quantised models). The pipeline
|
|
10
|
+
* subprocess sees no progress, the FE sees a stalled "Launching
|
|
11
|
+
* pipeline" spinner, the agent's first ChatResponse never arrives.
|
|
12
|
+
* Run d5ead8b8655b47fa hit this exactly: 1 `format openai` span, no
|
|
13
|
+
* `chat ...` span, no messages.
|
|
14
|
+
* - Ollama works similarly — first call to a model that isn't pulled
|
|
15
|
+
* will fail with "model not found"; downloads have to be explicit.
|
|
16
|
+
*
|
|
17
|
+
* What we do:
|
|
18
|
+
* 1. Read the model name + provider from the pipeline's config.json.
|
|
19
|
+
* 2. For lmstudio: GET /api/v0/models to see {id, state} for every
|
|
20
|
+
* model. If state="loaded" we're good. If state="not-loaded" we
|
|
21
|
+
* POST a tiny chat completion to force the JIT load while
|
|
22
|
+
* surfacing progress to the operator's stderr + emitting a
|
|
23
|
+
* socket event so the FE can show "Loading qwen/qwen3-vl-8b…".
|
|
24
|
+
* If model isn't in the list at all → fail fast with a clear
|
|
25
|
+
* "open LM Studio and download <name>" message.
|
|
26
|
+
* 3. For ollama: GET /api/tags. If model not present → fail fast with
|
|
27
|
+
* `ollama pull <name>` instruction. If present, ollama auto-loads.
|
|
28
|
+
*/
|
|
29
|
+
import chalk from "chalk";
|
|
30
|
+
const LMSTUDIO_BASE = process.env.LMSTUDIO_BASE_URL || "http://127.0.0.1:1234";
|
|
31
|
+
const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434";
|
|
32
|
+
// JIT-load timeout. 8B-class quantised models on M-series Macs typically
|
|
33
|
+
// load in 30-60s; bigger models or slow disks can take longer. We give
|
|
34
|
+
// 180s total before declaring the load stuck. Below this the runner
|
|
35
|
+
// simply prints progress.
|
|
36
|
+
const LMSTUDIO_LOAD_TIMEOUT_MS = 180_000;
|
|
37
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
38
|
+
const { timeoutMs = 5_000, ...rest } = init;
|
|
39
|
+
return fetch(url, { ...rest, signal: AbortSignal.timeout(timeoutMs) });
|
|
40
|
+
}
|
|
41
|
+
async function listLmStudioModels() {
|
|
42
|
+
try {
|
|
43
|
+
// /api/v0/models returns an array WITH `state` (loaded/not-loaded).
|
|
44
|
+
// /v1/models returns OpenAI-shape without state. We prefer v0 for
|
|
45
|
+
// the state field; fall back to v1 if v0 returns 404 on older
|
|
46
|
+
// LM Studio versions.
|
|
47
|
+
const r = await fetchWithTimeout(`${LMSTUDIO_BASE}/api/v0/models`, { timeoutMs: 3_000 });
|
|
48
|
+
if (r.ok) {
|
|
49
|
+
const j = await r.json();
|
|
50
|
+
const arr = Array.isArray(j) ? j : (j.data ?? []);
|
|
51
|
+
return arr;
|
|
52
|
+
}
|
|
53
|
+
if (r.status === 404) {
|
|
54
|
+
const r2 = await fetchWithTimeout(`${LMSTUDIO_BASE}/v1/models`, { timeoutMs: 3_000 });
|
|
55
|
+
if (!r2.ok)
|
|
56
|
+
return null;
|
|
57
|
+
const j = await r2.json();
|
|
58
|
+
// No state field — treat as state-unknown so we fall through to
|
|
59
|
+
// a JIT-load attempt below.
|
|
60
|
+
return (j.data ?? []).map((m) => ({ id: m.id }));
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function jitLoadLmStudioModel(modelId, onProgress) {
|
|
69
|
+
// POST a 1-token chat completion; LM Studio JIT-loads the model and
|
|
70
|
+
// blocks until it's resident. We use a small max_tokens so the actual
|
|
71
|
+
// generation cost is negligible — what we care about is the load
|
|
72
|
+
// happening before the real pipeline call.
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
const tick = setInterval(() => {
|
|
75
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
76
|
+
onProgress(`still loading ${modelId} (${elapsed}s elapsed)…`);
|
|
77
|
+
}, 10_000);
|
|
78
|
+
try {
|
|
79
|
+
const r = await fetchWithTimeout(`${LMSTUDIO_BASE}/v1/chat/completions`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
model: modelId,
|
|
84
|
+
messages: [{ role: "user", content: "." }],
|
|
85
|
+
max_tokens: 1,
|
|
86
|
+
stream: false,
|
|
87
|
+
}),
|
|
88
|
+
timeoutMs: LMSTUDIO_LOAD_TIMEOUT_MS,
|
|
89
|
+
});
|
|
90
|
+
if (!r.ok) {
|
|
91
|
+
const body = await r.text().catch(() => "");
|
|
92
|
+
onProgress(`LM Studio rejected load: HTTP ${r.status} ${body.slice(0, 200)}`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
onProgress(`load timed out / failed: ${e.message}`);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearInterval(tick);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function preflightLmStudio(modelName, onProgress = () => { }) {
|
|
106
|
+
const models = await listLmStudioModels();
|
|
107
|
+
if (models === null) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: "lmstudio_unreachable",
|
|
111
|
+
hint: `Could not reach LM Studio at ${LMSTUDIO_BASE}. Start LM Studio and enable the local server (Developer tab → Start Server).`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// Match by exact id first, then by suffix (LM Studio model ids include
|
|
115
|
+
// the publisher prefix; users sometimes type just the leaf name).
|
|
116
|
+
const exact = models.find((m) => m.id === modelName);
|
|
117
|
+
const suffix = exact ? null : models.find((m) => m.id.endsWith(`/${modelName}`) || m.id.endsWith(`:${modelName}`));
|
|
118
|
+
const match = exact ?? suffix;
|
|
119
|
+
if (!match) {
|
|
120
|
+
const available = models.map((m) => m.id).slice(0, 6).join(", ");
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: "model_not_downloaded",
|
|
124
|
+
hint: `Model "${modelName}" not found in LM Studio. Open LM Studio → Search → download it. Available: ${available || "(none)"}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const state = (match.state ?? "unknown");
|
|
128
|
+
if (state === "loaded") {
|
|
129
|
+
onProgress(`✓ ${match.id} already loaded in LM Studio`);
|
|
130
|
+
return { ok: true, loaded: true };
|
|
131
|
+
}
|
|
132
|
+
onProgress(`Loading ${match.id} into LM Studio (this can take 30-120s for first load)…`);
|
|
133
|
+
const loaded = await jitLoadLmStudioModel(match.id, onProgress);
|
|
134
|
+
if (!loaded) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
reason: "load_failed",
|
|
138
|
+
hint: `LM Studio could not load "${match.id}". Check available RAM and the LM Studio server log.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
onProgress(`✓ ${match.id} loaded`);
|
|
142
|
+
return { ok: true, loaded: true };
|
|
143
|
+
}
|
|
144
|
+
export async function preflightOllama(modelName, onProgress = () => { }) {
|
|
145
|
+
try {
|
|
146
|
+
const r = await fetchWithTimeout(`${OLLAMA_BASE}/api/tags`, { timeoutMs: 3_000 });
|
|
147
|
+
if (!r.ok) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: "ollama_unreachable",
|
|
151
|
+
hint: `Could not reach Ollama at ${OLLAMA_BASE}. Start the Ollama daemon.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const j = await r.json();
|
|
155
|
+
const tags = (j.models ?? []).map((m) => m.name);
|
|
156
|
+
if (!tags.includes(modelName)) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: "model_not_pulled",
|
|
160
|
+
hint: `Model "${modelName}" not pulled in Ollama. Run: ollama pull ${modelName}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
onProgress(`✓ ${modelName} present in Ollama`);
|
|
164
|
+
return { ok: true, loaded: true };
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
return { ok: false, reason: "ollama_error", hint: e.message };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Top-level preflight — resolves the provider/model from the pipeline
|
|
172
|
+
* config and dispatches to the right helper. Returns immediately for
|
|
173
|
+
* non-local providers (cloud-spawn shouldn't reach the runner anyway).
|
|
174
|
+
*/
|
|
175
|
+
export async function preflightModel(configJson, onProgress = (m) => console.log(chalk.gray(` [model] ${m}`))) {
|
|
176
|
+
let cfg;
|
|
177
|
+
try {
|
|
178
|
+
cfg = JSON.parse(configJson);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return { ok: true }; /* let main.py error naturally */
|
|
182
|
+
}
|
|
183
|
+
// Pipeline-level + per-step / per-agent providers — match the same
|
|
184
|
+
// walk as builder_server.py:2716-2725 so we honour the agent that
|
|
185
|
+
// overrides the top-level pick.
|
|
186
|
+
const providers = new Set();
|
|
187
|
+
const models = new Set();
|
|
188
|
+
const top = String(cfg.model_provider ?? cfg.model?.provider ?? "").toLowerCase();
|
|
189
|
+
if (top)
|
|
190
|
+
providers.add(top);
|
|
191
|
+
const topModel = String(cfg.model_name ?? cfg.model?.name ?? "");
|
|
192
|
+
if (topModel)
|
|
193
|
+
models.add(topModel);
|
|
194
|
+
for (const step of (cfg.steps ?? [])) {
|
|
195
|
+
const a = step?.agent ?? {};
|
|
196
|
+
const p = String(a?.model?.provider ?? "").toLowerCase();
|
|
197
|
+
const n = String(a?.model?.name ?? "");
|
|
198
|
+
if (p)
|
|
199
|
+
providers.add(p);
|
|
200
|
+
if (n)
|
|
201
|
+
models.add(n);
|
|
202
|
+
}
|
|
203
|
+
for (const a of (cfg.agents ?? [])) {
|
|
204
|
+
const p = String(a?.model_provider ?? a?.model?.provider ?? "").toLowerCase();
|
|
205
|
+
const n = String(a?.model_name ?? a?.model?.name ?? "");
|
|
206
|
+
if (p)
|
|
207
|
+
providers.add(p);
|
|
208
|
+
if (n)
|
|
209
|
+
models.add(n);
|
|
210
|
+
}
|
|
211
|
+
// Run preflight for every distinct (provider, model) combo this
|
|
212
|
+
// pipeline will exercise. One bad agent = whole run fails fast.
|
|
213
|
+
const tasks = [];
|
|
214
|
+
for (const m of models) {
|
|
215
|
+
if (providers.has("lmstudio"))
|
|
216
|
+
tasks.push(preflightLmStudio(m, onProgress));
|
|
217
|
+
if (providers.has("ollama"))
|
|
218
|
+
tasks.push(preflightOllama(m, onProgress));
|
|
219
|
+
}
|
|
220
|
+
if (tasks.length === 0)
|
|
221
|
+
return { ok: true };
|
|
222
|
+
const results = await Promise.all(tasks);
|
|
223
|
+
const failed = results.find((r) => !r.ok);
|
|
224
|
+
return failed ?? { ok: true, loaded: true };
|
|
225
|
+
}
|