@melaya/runner 1.0.24 → 1.0.27

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.
@@ -104,6 +104,58 @@ export async function connect(opts) {
104
104
  }
105
105
  // Ensure shared modules are cached
106
106
  await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
107
+ // Ensure the dedicated venv at ~/.melaya-runner/venv/ has
108
+ // agentscope + its transitive deps installed. Without this, the
109
+ // operator's system python3 typically doesn't have shortuuid /
110
+ // anthropic / openai / opentelemetry / etc. pre-installed and
111
+ // every pipeline run crashes during `import agentscope` with a
112
+ // ModuleNotFoundError. The venv is created lazily on first run
113
+ // and reused until sharedVersion changes.
114
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
115
+ const envResult = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion, (msg) => {
116
+ console.log(chalk.gray(` [venv] ${msg}`));
117
+ // Surface bootstrap progress to the FE so the launching
118
+ // animation reflects the real state instead of staying on a
119
+ // generic spinner during the 1-2 min first-time install.
120
+ socket.emit("runner:event", {
121
+ runId: payload.runId,
122
+ payload: {
123
+ event_type: "pipeline_phase",
124
+ run_id: payload.runId,
125
+ project: payload.project,
126
+ step: 1,
127
+ total: 5,
128
+ label: `venv: ${msg}`,
129
+ status: "started",
130
+ },
131
+ });
132
+ });
133
+ if (!envResult.ok) {
134
+ console.log(chalk.red(` ✗ python venv bootstrap failed: ${envResult.reason}`));
135
+ socket.emit("runner:event", {
136
+ runId: payload.runId,
137
+ payload: {
138
+ event_type: "agent_message",
139
+ run_id: payload.runId,
140
+ project: payload.project,
141
+ replyId: `venv-${payload.runId}`,
142
+ replyName: "Runner",
143
+ replyRole: "system",
144
+ msg: {
145
+ id: `venv-${payload.runId}`,
146
+ name: "Runner",
147
+ role: "system",
148
+ content: [{ type: "text", text: `Python env bootstrap failed: ${envResult.reason}` }],
149
+ metadata: {},
150
+ timestamp: new Date().toISOString(),
151
+ },
152
+ },
153
+ });
154
+ socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
155
+ return;
156
+ }
157
+ // Replace the system python with the venv python for THIS spawn.
158
+ const pipelinePython = envResult.pythonPath;
107
159
  // Register run in DB + set active project for event relay
108
160
  setActiveProject(payload.project);
109
161
  socket.emit("runner:registerRun", {
@@ -116,6 +168,58 @@ export async function connect(opts) {
116
168
  mkdirSync(runDir, { recursive: true });
117
169
  writeFileSync(join(runDir, "main.py"), payload.mainPyContent, "utf-8");
118
170
  writeFileSync(join(runDir, "config.json"), payload.configJson, "utf-8");
171
+ // Local-model preflight: ensure LM Studio / Ollama have the
172
+ // pipeline's model loaded BEFORE we spawn the Python subprocess.
173
+ // Without this, the subprocess hangs on its first chat completion
174
+ // call (LM Studio JIT-loads transparently, can take 60-120s with
175
+ // zero progress feedback). Pipeline run d5ead8b8655b47fa hit this
176
+ // — only the `format openai` span landed, no `chat …` span ever
177
+ // arrived because LM Studio was still loading the qwen weights.
178
+ const { preflightModel } = await import("./modelLoader.js");
179
+ const onPreflightProgress = (msg) => {
180
+ console.log(chalk.gray(` [model] ${msg}`));
181
+ // Surface to the FE via a pipeline_phase event so the launching
182
+ // animation reflects "Loading qwen3-vl-8b…" instead of staying
183
+ // on a generic spinner.
184
+ socket.emit("runner:event", {
185
+ runId: payload.runId,
186
+ payload: {
187
+ event_type: "pipeline_phase",
188
+ run_id: payload.runId,
189
+ project: payload.project,
190
+ step: 1,
191
+ total: 5,
192
+ label: msg,
193
+ status: "started",
194
+ },
195
+ });
196
+ };
197
+ const preflight = await preflightModel(payload.configJson, onPreflightProgress);
198
+ if (!preflight.ok) {
199
+ const detail = `${preflight.reason ?? "unknown"}${preflight.hint ? ` — ${preflight.hint}` : ""}`;
200
+ console.log(chalk.red(` ✗ model preflight failed: ${detail}`));
201
+ socket.emit("runner:event", {
202
+ runId: payload.runId,
203
+ payload: {
204
+ event_type: "agent_message",
205
+ run_id: payload.runId,
206
+ project: payload.project,
207
+ replyId: `preflight-${payload.runId}`,
208
+ replyName: "Runner",
209
+ replyRole: "system",
210
+ msg: {
211
+ id: `preflight-${payload.runId}`,
212
+ name: "Runner",
213
+ role: "system",
214
+ content: [{ type: "text", text: `Model preflight failed: ${detail}` }],
215
+ metadata: {},
216
+ timestamp: new Date().toISOString(),
217
+ },
218
+ },
219
+ });
220
+ socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
221
+ return;
222
+ }
119
223
  // Build env vars for the subprocess
120
224
  const relayUrl = `http://127.0.0.1:${relay.port}/events/relay/${relay.nonce}`;
121
225
  const sharedDir = getSharedDir();
@@ -147,26 +251,67 @@ export async function connect(opts) {
147
251
  }
148
252
  : {}),
149
253
  };
150
- // Spawn Python subprocess
151
- const proc = spawn(opts.pythonPath, ["-u", join(runDir, "main.py")], {
254
+ // Spawn Python subprocess (uses the bootstrapped venv python).
255
+ const proc = spawn(pipelinePython, ["-u", join(runDir, "main.py")], {
152
256
  cwd: runDir,
153
257
  env,
154
258
  stdio: ["ignore", "pipe", "pipe"],
155
259
  });
156
260
  activeProcesses.set(payload.runId, proc);
261
+ // Ring buffer of recent stderr — surfaced to the FE on non-zero
262
+ // exit so the operator sees the actual Python traceback in the
263
+ // chat panel instead of a silent "failed" with no context.
264
+ // Capped at 60 lines so a noisy traceback can't OOM us.
265
+ const stderrTail = [];
266
+ const STDERR_TAIL_MAX = 60;
157
267
  proc.stdout?.on("data", (data) => {
158
268
  const line = data.toString().trim();
159
269
  if (line && opts.verbose)
160
270
  console.log(chalk.gray(` [stdout] ${line}`));
161
271
  });
162
272
  proc.stderr?.on("data", (data) => {
163
- const line = data.toString().trim();
164
- if (line)
165
- console.log(chalk.yellow(` [stderr] ${line}`));
273
+ const text = data.toString();
274
+ for (const ln of text.split("\n")) {
275
+ const t = ln.trimEnd();
276
+ if (!t)
277
+ continue;
278
+ console.log(chalk.yellow(` [stderr] ${t}`));
279
+ stderrTail.push(t);
280
+ if (stderrTail.length > STDERR_TAIL_MAX)
281
+ stderrTail.shift();
282
+ }
166
283
  });
167
284
  proc.on("exit", (code) => {
168
285
  activeProcesses.delete(payload.runId);
169
286
  const status = code === 0 ? "done" : "failed";
287
+ // On non-zero exit, relay the captured stderr tail to the FE
288
+ // as a system agent_message so the operator sees the actual
289
+ // failure reason. Without this, a Python crash before
290
+ // agentscope.init() runs (bad import, missing dep, syntax
291
+ // error in generated main.py) shows up only as a silent
292
+ // run_status:failed and the user has no clue what happened.
293
+ if (status === "failed" && stderrTail.length > 0) {
294
+ const tail = stderrTail.slice(-15).join("\n");
295
+ socket.emit("runner:event", {
296
+ run_id: payload.runId,
297
+ event_type: "agent_message",
298
+ project: payload.project,
299
+ replyId: `runner-exit-${payload.runId}`,
300
+ replyName: "Runner",
301
+ replyRole: "system",
302
+ msg: {
303
+ id: `runner-exit-${payload.runId}`,
304
+ name: "Runner",
305
+ role: "system",
306
+ content: [{
307
+ type: "text",
308
+ text: `Pipeline exited with code ${code}. Last stderr:\n\`\`\`\n${tail}\n\`\`\``,
309
+ }],
310
+ metadata: { exitCode: code },
311
+ timestamp: new Date().toISOString(),
312
+ },
313
+ });
314
+ }
170
315
  // Emit run_status FIRST (before runComplete deletes from ownedRunIds)
171
316
  socket.emit("runner:event", {
172
317
  run_id: payload.runId,
@@ -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
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Manage a dedicated Python venv at ~/.melaya-runner/venv/ so pipelines
3
+ * don't depend on whatever's globally installed in the operator's
4
+ * `python3`. Without this, every fresh runner install hits import errors
5
+ * (`ModuleNotFoundError: No module named 'shortuuid'`, etc.) on the
6
+ * first pipeline run because agentscope and its 23 transitive deps are
7
+ * not present in the operator's system Python.
8
+ *
9
+ * Idempotent: re-uses the venv if a marker file matches the current
10
+ * shared-bundle version. Re-bootstraps when the bundle version changes.
11
+ */
12
+ export declare function venvPython(): string;
13
+ export declare function ensurePythonEnv(systemPython: string, expectedVersion: string, onProgress?: (msg: string) => void): Promise<{
14
+ ok: boolean;
15
+ pythonPath: string;
16
+ reason?: string;
17
+ }>;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Manage a dedicated Python venv at ~/.melaya-runner/venv/ so pipelines
3
+ * don't depend on whatever's globally installed in the operator's
4
+ * `python3`. Without this, every fresh runner install hits import errors
5
+ * (`ModuleNotFoundError: No module named 'shortuuid'`, etc.) on the
6
+ * first pipeline run because agentscope and its 23 transitive deps are
7
+ * not present in the operator's system Python.
8
+ *
9
+ * Idempotent: re-uses the venv if a marker file matches the current
10
+ * shared-bundle version. Re-bootstraps when the bundle version changes.
11
+ */
12
+ import { spawn } from "child_process";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
14
+ import { join } from "path";
15
+ import { homedir, platform } from "os";
16
+ import chalk from "chalk";
17
+ const CACHE_DIR = join(homedir(), ".melaya-runner");
18
+ const VENV_DIR = join(CACHE_DIR, "venv");
19
+ const VENV_MARK = join(CACHE_DIR, "venv-version.txt");
20
+ const AGENTSCOPE = join(CACHE_DIR, "agentscope");
21
+ // Extra deps the cached `shared/tools/*.py` import that aren't in
22
+ // agentscope's pyproject. Keep this list narrow — full tool-specific
23
+ // SDKs (linkedin_api, twilio, stripe, etc.) install lazily on first
24
+ // use via tool-specific scripts. The minimum to GET A PIPELINE RUNNING
25
+ // is agentscope + aiohttp + requests + redis (used by some tools).
26
+ const EXTRA_DEPS = ["aiohttp", "requests", "python-dotenv"];
27
+ export function venvPython() {
28
+ return platform() === "win32"
29
+ ? join(VENV_DIR, "Scripts", "python.exe")
30
+ : join(VENV_DIR, "bin", "python");
31
+ }
32
+ function venvIsValid(expectedVersion) {
33
+ if (!existsSync(venvPython()))
34
+ return false;
35
+ if (!existsSync(VENV_MARK))
36
+ return false;
37
+ try {
38
+ const cached = readFileSync(VENV_MARK, "utf-8").trim();
39
+ return cached === expectedVersion;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ function runProc(cmd, args, onLine) {
46
+ return new Promise((resolve) => {
47
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
48
+ const chew = (b) => {
49
+ for (const ln of b.toString().split("\n")) {
50
+ const t = ln.trimEnd();
51
+ if (t)
52
+ onLine(t);
53
+ }
54
+ };
55
+ child.stdout?.on("data", chew);
56
+ child.stderr?.on("data", chew);
57
+ child.on("exit", (code) => resolve(code ?? 1));
58
+ child.on("error", () => resolve(1));
59
+ });
60
+ }
61
+ export async function ensurePythonEnv(systemPython, expectedVersion, onProgress = (m) => console.log(chalk.gray(` [venv] ${m}`))) {
62
+ if (venvIsValid(expectedVersion)) {
63
+ return { ok: true, pythonPath: venvPython() };
64
+ }
65
+ if (!existsSync(AGENTSCOPE)) {
66
+ return {
67
+ ok: false,
68
+ pythonPath: systemPython,
69
+ reason: `agentscope cache missing at ${AGENTSCOPE} — restart the runner so the shared bundle downloads first.`,
70
+ };
71
+ }
72
+ mkdirSync(CACHE_DIR, { recursive: true });
73
+ // Step 1: create the venv if missing.
74
+ if (!existsSync(venvPython())) {
75
+ onProgress(`creating venv at ${VENV_DIR} (one-time, ~5s)`);
76
+ const code = await runProc(systemPython, ["-m", "venv", VENV_DIR], onProgress);
77
+ if (code !== 0 || !existsSync(venvPython())) {
78
+ return {
79
+ ok: false,
80
+ pythonPath: systemPython,
81
+ reason: `python -m venv failed (exit ${code}). Confirm the venv module is available: ${systemPython} -m venv --help`,
82
+ };
83
+ }
84
+ }
85
+ // Step 2: upgrade pip + wheel so wheel-based deps install cleanly.
86
+ onProgress("upgrading pip / wheel inside the venv");
87
+ await runProc(venvPython(), ["-m", "pip", "install", "--quiet", "--upgrade", "pip", "wheel"], onProgress);
88
+ // Step 3: install agentscope from the local cache (its pyproject pulls
89
+ // anthropic, openai, shortuuid, opentelemetry-*, etc.).
90
+ onProgress("installing agentscope + 23 transitive deps (anthropic, openai, opentelemetry, …) — first run can take 1-2 min");
91
+ const acode = await runProc(venvPython(), ["-m", "pip", "install", "--quiet", "-e", AGENTSCOPE, ...EXTRA_DEPS], onProgress);
92
+ if (acode !== 0) {
93
+ return {
94
+ ok: false,
95
+ pythonPath: systemPython,
96
+ reason: `pip install agentscope failed (exit ${acode}). Check the lines above for the actual pip error.`,
97
+ };
98
+ }
99
+ writeFileSync(VENV_MARK, expectedVersion, "utf-8");
100
+ // sanity: confirm shortuuid resolves now (it's the historic canary
101
+ // import that fails on a stale env).
102
+ const probe = await runProc(venvPython(), ["-c", "import shortuuid, agentscope, anthropic, openai"], onProgress);
103
+ if (probe !== 0) {
104
+ return {
105
+ ok: false,
106
+ pythonPath: systemPython,
107
+ reason: "venv created but agentscope import probe failed. Delete ~/.melaya-runner/venv and restart the runner.",
108
+ };
109
+ }
110
+ onProgress(`✓ venv ready (python=${venvPython()})`);
111
+ return { ok: true, pythonPath: venvPython() };
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.24",
3
+ "version": "1.0.27",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,