pi-pignon 0.1.1
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/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/examples/pignon.json +35 -0
- package/package.json +68 -0
- package/schema/config.schema.json +446 -0
- package/src/compare.ts +109 -0
- package/src/config/defaults.ts +95 -0
- package/src/config/describe.ts +30 -0
- package/src/config/load.ts +445 -0
- package/src/config/migrate.ts +86 -0
- package/src/config/presets.ts +54 -0
- package/src/config/schema.ts +224 -0
- package/src/deciders/create.ts +102 -0
- package/src/deciders/jev.ts +226 -0
- package/src/deciders/laya-local.ts +718 -0
- package/src/deciders/laya-serve.ts +53 -0
- package/src/deciders/parse.ts +48 -0
- package/src/deciders/questions.ts +34 -0
- package/src/deciders/strategy.ts +225 -0
- package/src/deciders/types.ts +70 -0
- package/src/extension.ts +439 -0
- package/src/onboarding.ts +203 -0
- package/src/policy.ts +215 -0
- package/src/report.ts +171 -0
- package/src/router.ts +215 -0
- package/src/stats.ts +55 -0
- package/src/types.ts +341 -0
- package/src/ui.ts +153 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `laya-local` decider (experimental): long-lived stdio client for pignon's
|
|
3
|
+
* own Laya worker (`worker/`, not published). The supported way to run Laya
|
|
4
|
+
* locally is the official `laya-serve` (see `laya-serve.ts`).
|
|
5
|
+
*
|
|
6
|
+
* Spawns `worker/laya_worker.py`, speaks newline-delimited JSON over
|
|
7
|
+
* stdin/stdout, and keeps the process (and its resident MLX model) warm across
|
|
8
|
+
* prompts. No port and no server to keep alive: the extension owns the
|
|
9
|
+
* lifecycle.
|
|
10
|
+
*
|
|
11
|
+
* Zero Pi dependencies — fully testable in Node.js/Vitest.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
15
|
+
import { accessSync, constants, existsSync, realpathSync, statSync } from "node:fs";
|
|
16
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { createInterface } from "node:readline";
|
|
19
|
+
|
|
20
|
+
import { DEFAULT_THRESHOLDS } from "../config/defaults.js";
|
|
21
|
+
import type { LayaDecisionResponse, LayaHealthResponse } from "../types.js";
|
|
22
|
+
import {
|
|
23
|
+
type Decider,
|
|
24
|
+
type DeciderResult,
|
|
25
|
+
type DecisionRequest,
|
|
26
|
+
DeciderError,
|
|
27
|
+
} from "./types.js";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Default worker resolution
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Injectable spawn function (defaults to node:child_process `spawn`). */
|
|
34
|
+
export type SpawnFn = typeof spawn;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Locate the directory containing `laya_worker.py`.
|
|
38
|
+
*
|
|
39
|
+
* The extension is commonly installed as a symlink (e.g.
|
|
40
|
+
* `~/.pi/agent/extensions/laya-ll-router -> <repo>`). Depending on the loader,
|
|
41
|
+
* `import.meta.url` may keep the symlinked path, so fall back to the realpath
|
|
42
|
+
* of this module before giving up.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveWorkerDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
45
|
+
if (env.LAYA_WORKER_DIR) return env.LAYA_WORKER_DIR;
|
|
46
|
+
|
|
47
|
+
// This module lives in <root>/src/deciders/.
|
|
48
|
+
const rootOf = (modulePath: string) => resolve(dirname(modulePath), "..", "..");
|
|
49
|
+
const candidates = [join(rootOf(fileURLToPath(import.meta.url)), "worker")];
|
|
50
|
+
try {
|
|
51
|
+
const realRoot = rootOf(realpathSync(fileURLToPath(import.meta.url)));
|
|
52
|
+
candidates.push(join(realRoot, "worker"));
|
|
53
|
+
} catch {
|
|
54
|
+
// realpath unavailable — keep the direct candidate only
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
if (existsSync(join(candidate, "laya_worker.py"))) return resolve(candidate);
|
|
59
|
+
}
|
|
60
|
+
return resolve(candidates[0]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The worker's command name, once installed from the repo's `worker/` directory. */
|
|
64
|
+
export const WORKER_PACKAGE = "pignon-laya";
|
|
65
|
+
|
|
66
|
+
/** Worker protocol (`PROTOCOL_VERSION` in laya_worker.py) this extension speaks: 0.3.x. */
|
|
67
|
+
export const SUPPORTED_PROTOCOL = { major: 0, minor: 3 } as const;
|
|
68
|
+
|
|
69
|
+
/** How to start the worker, and where that came from. */
|
|
70
|
+
export interface WorkerLaunch {
|
|
71
|
+
command: string;
|
|
72
|
+
args: string[];
|
|
73
|
+
cwd?: string;
|
|
74
|
+
source: "config" | "env" | "checkout" | "path";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Find the worker, in this order:
|
|
79
|
+
* 1. `command` from the config (e.g. a development checkout);
|
|
80
|
+
* 2. LAYA_PYTHON, running `laya_worker.py` from the worker directory;
|
|
81
|
+
* 3. a source checkout with its `uv sync` environment (`worker/.venv`);
|
|
82
|
+
* 4. `pignon-laya` on PATH (`uv tool install ./worker` from a checkout).
|
|
83
|
+
*/
|
|
84
|
+
export function resolveLaunch(
|
|
85
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
86
|
+
command?: readonly string[],
|
|
87
|
+
): WorkerLaunch | { reason: string } {
|
|
88
|
+
if (command && command.length > 0) return { command: command[0]!, args: command.slice(1), source: "config" };
|
|
89
|
+
|
|
90
|
+
const workerDir = resolveWorkerDir(env);
|
|
91
|
+
const script = env.LAYA_WORKER_SCRIPT ?? join(workerDir, "laya_worker.py");
|
|
92
|
+
if (env.LAYA_PYTHON) return { command: env.LAYA_PYTHON, args: [script], cwd: workerDir, source: "env" };
|
|
93
|
+
|
|
94
|
+
const venvPython = join(workerDir, ".venv", "bin", "python");
|
|
95
|
+
if (existsSync(venvPython) && existsSync(script)) {
|
|
96
|
+
return { command: venvPython, args: [script], cwd: workerDir, source: "checkout" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const installed = which(WORKER_PACKAGE, env);
|
|
100
|
+
if (installed) return { command: installed, args: [], source: "path" };
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
reason: "the experimental Laya worker is not installed (see worker/README.md in the pignon repository)",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** First executable named `name` on PATH. */
|
|
108
|
+
export function which(name: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
109
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
110
|
+
if (!dir) continue;
|
|
111
|
+
const candidate = join(dir, name);
|
|
112
|
+
try {
|
|
113
|
+
accessSync(candidate, constants.X_OK);
|
|
114
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
115
|
+
} catch {
|
|
116
|
+
// not here
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Why a worker's protocol version cannot be used, or undefined when it can. */
|
|
123
|
+
export function protocolProblem(version: unknown): string | undefined {
|
|
124
|
+
const needed = `${SUPPORTED_PROTOCOL.major}.${SUPPORTED_PROTOCOL.minor}.x`;
|
|
125
|
+
const upgradeWorker = "update it (`uv sync` in worker/, or `uv tool install --force ./worker`)";
|
|
126
|
+
if (typeof version !== "string") return `the Laya worker is too old (no protocol version, pignon needs ${needed}); ${upgradeWorker}`;
|
|
127
|
+
const [major, minor] = version.split(".").map(Number);
|
|
128
|
+
if (major === undefined || minor === undefined || Number.isNaN(major) || Number.isNaN(minor)) {
|
|
129
|
+
return `the Laya worker reports an invalid protocol version "${version}"`;
|
|
130
|
+
}
|
|
131
|
+
// Before 1.0, a minor version change is a breaking change.
|
|
132
|
+
const compatible =
|
|
133
|
+
major === SUPPORTED_PROTOCOL.major && (major > 0 || minor === SUPPORTED_PROTOCOL.minor);
|
|
134
|
+
if (compatible) return undefined;
|
|
135
|
+
const older = major < SUPPORTED_PROTOCOL.major || (major === SUPPORTED_PROTOCOL.major && minor < SUPPORTED_PROTOCOL.minor);
|
|
136
|
+
return `the Laya worker speaks protocol ${version}, pignon needs ${needed}; ${older ? upgradeWorker : "upgrade pignon"}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether the local worker can run here: laya-mlx needs an Apple Silicon Mac,
|
|
141
|
+
* and the worker needs a Python environment (`uv sync` in the worker dir, or
|
|
142
|
+
* LAYA_PYTHON).
|
|
143
|
+
*/
|
|
144
|
+
export type LayaRuntimeStatus = { ok: true; launch?: WorkerLaunch } | { ok: false; reason: string };
|
|
145
|
+
|
|
146
|
+
export function layaRuntimeStatus(
|
|
147
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
148
|
+
platform: NodeJS.Platform = process.platform,
|
|
149
|
+
arch: string = process.arch,
|
|
150
|
+
command?: readonly string[],
|
|
151
|
+
): LayaRuntimeStatus {
|
|
152
|
+
if (platform !== "darwin" || arch !== "arm64") {
|
|
153
|
+
return { ok: false, reason: "the local Laya model needs an Apple Silicon Mac" };
|
|
154
|
+
}
|
|
155
|
+
const launch = resolveLaunch(env, command);
|
|
156
|
+
return "reason" in launch ? { ok: false, reason: launch.reason } : { ok: true, launch };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Worker environment
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** Variables the worker needs: process basics, locale, proxies and CA bundles. */
|
|
164
|
+
const ENV_NAMES = new Set([
|
|
165
|
+
"PATH",
|
|
166
|
+
"HOME",
|
|
167
|
+
"USER",
|
|
168
|
+
"LOGNAME",
|
|
169
|
+
"TMPDIR",
|
|
170
|
+
"TMP",
|
|
171
|
+
"TEMP",
|
|
172
|
+
"LANG",
|
|
173
|
+
"XDG_CACHE_HOME",
|
|
174
|
+
"XDG_CONFIG_HOME",
|
|
175
|
+
"HTTP_PROXY",
|
|
176
|
+
"HTTPS_PROXY",
|
|
177
|
+
"NO_PROXY",
|
|
178
|
+
"ALL_PROXY",
|
|
179
|
+
"http_proxy",
|
|
180
|
+
"https_proxy",
|
|
181
|
+
"no_proxy",
|
|
182
|
+
"all_proxy",
|
|
183
|
+
"SSL_CERT_FILE",
|
|
184
|
+
"SSL_CERT_DIR",
|
|
185
|
+
"REQUESTS_CA_BUNDLE",
|
|
186
|
+
"CURL_CA_BUNDLE",
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
/** Prefixes for the worker's own settings, Hugging Face Hub, MLX and locale. */
|
|
190
|
+
const ENV_PREFIXES = ["LAYA_", "HF_", "HUGGINGFACE_", "MLX_", "LC_"];
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Environment passed to the worker.
|
|
194
|
+
*
|
|
195
|
+
* The worker runs third-party Python packages, so it gets an allowlist rather
|
|
196
|
+
* than the host's whole environment (which holds provider API keys).
|
|
197
|
+
*/
|
|
198
|
+
export function workerEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
199
|
+
const env: NodeJS.ProcessEnv = {};
|
|
200
|
+
for (const [name, value] of Object.entries(source)) {
|
|
201
|
+
if (value === undefined) continue;
|
|
202
|
+
if (ENV_NAMES.has(name) || ENV_PREFIXES.some((prefix) => name.startsWith(prefix))) {
|
|
203
|
+
env[name] = value;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return env;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Errors
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/** Error raised when the worker cannot be started or a request fails. */
|
|
214
|
+
export class LayaWorkerError extends DeciderError {
|
|
215
|
+
constructor(message: string, cause?: unknown) {
|
|
216
|
+
super(message, cause);
|
|
217
|
+
this.name = "LayaWorkerError";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Wire protocol
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
interface WorkerReady {
|
|
226
|
+
type: "ready";
|
|
227
|
+
/** `PROTOCOL_VERSION` of the worker; absent before pignon. */
|
|
228
|
+
protocol?: string;
|
|
229
|
+
model?: string;
|
|
230
|
+
backend?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
interface WorkerFatal {
|
|
234
|
+
type: "fatal";
|
|
235
|
+
error: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface WorkerSuccess {
|
|
239
|
+
id: number | null;
|
|
240
|
+
ok: true;
|
|
241
|
+
result: unknown;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface WorkerFailure {
|
|
245
|
+
id: number | null;
|
|
246
|
+
ok: false;
|
|
247
|
+
error: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
type WorkerMessage = WorkerReady | WorkerFatal | WorkerSuccess | WorkerFailure;
|
|
251
|
+
|
|
252
|
+
interface Pending {
|
|
253
|
+
resolve: (value: unknown) => void;
|
|
254
|
+
reject: (error: Error) => void;
|
|
255
|
+
timer: ReturnType<typeof setTimeout>;
|
|
256
|
+
signal?: AbortSignal;
|
|
257
|
+
onAbort?: () => void;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Options
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export interface LayaWorkerOptions {
|
|
265
|
+
/** Executable to run. Defaults to what `resolveLaunch()` finds. */
|
|
266
|
+
command?: string;
|
|
267
|
+
/** Arguments. Defaults to the worker script (override with LAYA_WORKER_SCRIPT). */
|
|
268
|
+
args?: string[];
|
|
269
|
+
/** Working directory for the worker. Defaults to `resolveWorkerDir()`. */
|
|
270
|
+
cwd?: string;
|
|
271
|
+
/** Extra environment variables merged over the `workerEnv()` allowlist. */
|
|
272
|
+
env?: NodeJS.ProcessEnv;
|
|
273
|
+
/** Per-request timeout in milliseconds. */
|
|
274
|
+
timeoutMs?: number;
|
|
275
|
+
/** How long to wait for the model to load and report `ready`. */
|
|
276
|
+
startupTimeoutMs?: number;
|
|
277
|
+
/** Command from the config (`deciders[].command`), tried before anything else. */
|
|
278
|
+
launchCommand?: readonly string[];
|
|
279
|
+
/** Spawn implementation injection (used by tests). */
|
|
280
|
+
spawnFn?: SpawnFn;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Generous because the first start may download the checkpoint from Hugging
|
|
285
|
+
* Face; prompts never wait on startup, so this only bounds a hung worker.
|
|
286
|
+
*/
|
|
287
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 300_000;
|
|
288
|
+
|
|
289
|
+
/** How long `stop()` waits after SIGTERM before sending SIGKILL. */
|
|
290
|
+
const STOP_GRACE_MS = 500;
|
|
291
|
+
|
|
292
|
+
/** Worker diagnostics kept in memory for `/laya log`. */
|
|
293
|
+
const LOG_CAPACITY = 200;
|
|
294
|
+
const LOG_LINE_MAX = 500;
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Client
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Client for the local Laya stdio worker.
|
|
302
|
+
*
|
|
303
|
+
* The process starts lazily on the first request and is reused afterwards.
|
|
304
|
+
* All methods accept an optional `AbortSignal` so callers can bound latency
|
|
305
|
+
* and respect Pi session cancellation.
|
|
306
|
+
*/
|
|
307
|
+
export class LayaWorker implements Decider {
|
|
308
|
+
readonly id = "laya-local";
|
|
309
|
+
readonly remote = false;
|
|
310
|
+
|
|
311
|
+
private readonly command: string;
|
|
312
|
+
private readonly args: string[];
|
|
313
|
+
private readonly cwd?: string;
|
|
314
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
315
|
+
private readonly timeoutMs: number;
|
|
316
|
+
private readonly startupTimeoutMs: number;
|
|
317
|
+
private readonly spawnFn: SpawnFn;
|
|
318
|
+
|
|
319
|
+
/** Why the worker cannot be started here, when no launcher was found. */
|
|
320
|
+
private readonly unavailable?: string;
|
|
321
|
+
private child?: ChildProcessWithoutNullStreams;
|
|
322
|
+
private ready = false;
|
|
323
|
+
private starting?: Promise<void>;
|
|
324
|
+
private stopped = false;
|
|
325
|
+
private nextId = 1;
|
|
326
|
+
private lastModel?: string;
|
|
327
|
+
private readonly pending = new Map<number, Pending>();
|
|
328
|
+
private readonly logLines: string[] = [];
|
|
329
|
+
|
|
330
|
+
constructor(options: LayaWorkerOptions = {}) {
|
|
331
|
+
// Resolved here rather than at import time, so env overrides set before
|
|
332
|
+
// construction apply and importing the module has no side effects.
|
|
333
|
+
let launch: WorkerLaunch | { reason: string };
|
|
334
|
+
if (options.command !== undefined) {
|
|
335
|
+
launch = { command: options.command, args: options.args ?? [], source: "config" };
|
|
336
|
+
} else {
|
|
337
|
+
launch = resolveLaunch(process.env, options.launchCommand);
|
|
338
|
+
}
|
|
339
|
+
if ("reason" in launch) {
|
|
340
|
+
this.unavailable = launch.reason;
|
|
341
|
+
launch = { command: "", args: [], source: "config" };
|
|
342
|
+
}
|
|
343
|
+
this.command = launch.command;
|
|
344
|
+
this.args = launch.args;
|
|
345
|
+
this.cwd = options.cwd ?? launch.cwd;
|
|
346
|
+
this.env = { ...workerEnv(), ...options.env };
|
|
347
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_THRESHOLDS.layaTimeoutMs;
|
|
348
|
+
this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
349
|
+
this.spawnFn = options.spawnFn ?? spawn;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Model repo reported by the worker once ready. */
|
|
353
|
+
get model(): string | undefined {
|
|
354
|
+
return this.lastModel;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Whether the worker process is currently running and warmed up. */
|
|
358
|
+
get isReady(): boolean {
|
|
359
|
+
return this.ready && this.child !== undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Most recent worker diagnostics (stderr and protocol errors), oldest first.
|
|
364
|
+
*
|
|
365
|
+
* Kept in memory instead of written to the host's stderr, which would draw
|
|
366
|
+
* over Pi's TUI.
|
|
367
|
+
*/
|
|
368
|
+
get recentLogs(): readonly string[] {
|
|
369
|
+
return this.logLines;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Worker health and loaded model. */
|
|
373
|
+
async health(signal?: AbortSignal): Promise<LayaHealthResponse> {
|
|
374
|
+
const result = await this.request("health", {}, signal);
|
|
375
|
+
if (!isHealthResponse(result)) {
|
|
376
|
+
throw new LayaWorkerError("Laya worker returned a malformed health response");
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Ask the worker the request's questions; answers are parsed by the caller. */
|
|
382
|
+
async decide(request: DecisionRequest, signal?: AbortSignal): Promise<DeciderResult> {
|
|
383
|
+
const started = Date.now();
|
|
384
|
+
|
|
385
|
+
const response = await this.request(
|
|
386
|
+
"decide",
|
|
387
|
+
{ text: request.text, questions: request.questions },
|
|
388
|
+
signal,
|
|
389
|
+
);
|
|
390
|
+
if (!isDecisionResponse(response)) {
|
|
391
|
+
throw new LayaWorkerError("Laya worker returned a malformed decision");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
deciderId: this.id,
|
|
396
|
+
model: typeof response.model === "string" ? response.model : (this.lastModel ?? "unknown"),
|
|
397
|
+
answers: response.answers,
|
|
398
|
+
latencyMs: Date.now() - started,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Start the worker and load its model without sending a real decision. */
|
|
403
|
+
async warmup(signal?: AbortSignal): Promise<void> {
|
|
404
|
+
await this.health(signal);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Stop the worker and reject any in-flight requests.
|
|
409
|
+
*
|
|
410
|
+
* Safe to call multiple times; the client cannot be restarted afterwards.
|
|
411
|
+
*/
|
|
412
|
+
stop(): void {
|
|
413
|
+
if (this.stopped) return;
|
|
414
|
+
this.stopped = true;
|
|
415
|
+
|
|
416
|
+
const child = this.child;
|
|
417
|
+
this.failAll(new LayaWorkerError("Laya worker stopped"));
|
|
418
|
+
this.child = undefined;
|
|
419
|
+
this.ready = false;
|
|
420
|
+
|
|
421
|
+
if (child) {
|
|
422
|
+
try {
|
|
423
|
+
child.stdin.end();
|
|
424
|
+
} catch {
|
|
425
|
+
// stdin may already be closed
|
|
426
|
+
}
|
|
427
|
+
child.kill("SIGTERM");
|
|
428
|
+
// `child.killed` only means a signal was delivered; check whether the
|
|
429
|
+
// process has actually exited before escalating.
|
|
430
|
+
const timer = setTimeout(() => {
|
|
431
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
432
|
+
try {
|
|
433
|
+
child.kill("SIGKILL");
|
|
434
|
+
} catch {
|
|
435
|
+
// already gone
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}, STOP_GRACE_MS);
|
|
439
|
+
timer.unref?.();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
// Internals
|
|
445
|
+
// -------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
private async request(
|
|
448
|
+
method: string,
|
|
449
|
+
params: Record<string, unknown>,
|
|
450
|
+
signal?: AbortSignal,
|
|
451
|
+
): Promise<unknown> {
|
|
452
|
+
if (this.stopped) {
|
|
453
|
+
throw new LayaWorkerError("Laya worker is stopped");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await this.ensureStarted();
|
|
457
|
+
|
|
458
|
+
if (signal?.aborted) {
|
|
459
|
+
throw new LayaWorkerError("Laya worker request aborted");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const child = this.child;
|
|
463
|
+
if (!child) {
|
|
464
|
+
throw new LayaWorkerError("Laya worker is not running");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const id = this.nextId++;
|
|
468
|
+
|
|
469
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
470
|
+
const cleanup = () => {
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const timer = setTimeout(() => {
|
|
476
|
+
this.pending.delete(id);
|
|
477
|
+
cleanup();
|
|
478
|
+
reject(new LayaWorkerError(`Laya worker request timed out after ${this.timeoutMs}ms`));
|
|
479
|
+
}, this.timeoutMs);
|
|
480
|
+
|
|
481
|
+
const onAbort = signal
|
|
482
|
+
? () => {
|
|
483
|
+
this.pending.delete(id);
|
|
484
|
+
cleanup();
|
|
485
|
+
reject(new LayaWorkerError("Laya worker request aborted"));
|
|
486
|
+
}
|
|
487
|
+
: undefined;
|
|
488
|
+
|
|
489
|
+
if (onAbort) signal!.addEventListener("abort", onAbort, { once: true });
|
|
490
|
+
|
|
491
|
+
this.pending.set(id, { resolve, reject, timer, signal, onAbort });
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
// The worker drops requests whose deadline passed while they were
|
|
495
|
+
// queued, so a backlog of timed-out requests cannot build up.
|
|
496
|
+
const deadline_ms = Date.now() + this.timeoutMs;
|
|
497
|
+
child.stdin.write(JSON.stringify({ id, method, deadline_ms, ...params }) + "\n");
|
|
498
|
+
} catch (err) {
|
|
499
|
+
this.pending.delete(id);
|
|
500
|
+
cleanup();
|
|
501
|
+
reject(new LayaWorkerError("Failed to write to Laya worker", err));
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private ensureStarted(): Promise<void> {
|
|
507
|
+
if (this.unavailable) return Promise.reject(new LayaWorkerError(this.unavailable));
|
|
508
|
+
if (this.ready && this.child) return Promise.resolve();
|
|
509
|
+
if (!this.starting) {
|
|
510
|
+
this.starting = this.startProcess().finally(() => {
|
|
511
|
+
this.starting = undefined;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return this.starting;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private startProcess(): Promise<void> {
|
|
518
|
+
return new Promise<void>((resolve, reject) => {
|
|
519
|
+
let settled = false;
|
|
520
|
+
let startTimer: ReturnType<typeof setTimeout> | undefined;
|
|
521
|
+
const settleReady = () => {
|
|
522
|
+
if (settled) return;
|
|
523
|
+
settled = true;
|
|
524
|
+
if (startTimer) clearTimeout(startTimer);
|
|
525
|
+
this.ready = true;
|
|
526
|
+
resolve();
|
|
527
|
+
};
|
|
528
|
+
const settleError = (err: LayaWorkerError) => {
|
|
529
|
+
if (settled) return;
|
|
530
|
+
settled = true;
|
|
531
|
+
if (startTimer) clearTimeout(startTimer);
|
|
532
|
+
reject(err);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
let child: ChildProcessWithoutNullStreams;
|
|
536
|
+
try {
|
|
537
|
+
child = this.spawnFn(this.command, this.args, {
|
|
538
|
+
cwd: this.cwd,
|
|
539
|
+
env: this.env,
|
|
540
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
541
|
+
}) as ChildProcessWithoutNullStreams;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
settleError(new LayaWorkerError(`Failed to spawn Laya worker: ${String(err)}`, err));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
this.child = child;
|
|
548
|
+
this.ready = false;
|
|
549
|
+
|
|
550
|
+
startTimer = setTimeout(() => {
|
|
551
|
+
settleError(
|
|
552
|
+
new LayaWorkerError(`Laya worker not ready after ${this.startupTimeoutMs}ms`),
|
|
553
|
+
);
|
|
554
|
+
// Do not leave a half-started process (and its model) running: the
|
|
555
|
+
// next request would spawn another one next to it.
|
|
556
|
+
if (this.child === child) {
|
|
557
|
+
this.child = undefined;
|
|
558
|
+
this.ready = false;
|
|
559
|
+
}
|
|
560
|
+
child.kill("SIGKILL");
|
|
561
|
+
}, this.startupTimeoutMs);
|
|
562
|
+
|
|
563
|
+
const rl = createInterface({ input: child.stdout });
|
|
564
|
+
// A worker this extension cannot talk to must not stay up holding its model.
|
|
565
|
+
const rejectWorker = (err: LayaWorkerError) => {
|
|
566
|
+
settleError(err);
|
|
567
|
+
this.log(err.message);
|
|
568
|
+
if (this.child === child) {
|
|
569
|
+
this.child = undefined;
|
|
570
|
+
this.ready = false;
|
|
571
|
+
}
|
|
572
|
+
child.kill("SIGKILL");
|
|
573
|
+
};
|
|
574
|
+
rl.on("line", (line) => this.handleLine(line, settleReady, settleError, rejectWorker));
|
|
575
|
+
|
|
576
|
+
// Split on "\n" only: readline would also split on the "\r" progress
|
|
577
|
+
// bars use to redraw, turning one bar into hundreds of log lines.
|
|
578
|
+
let stderrBuffer = "";
|
|
579
|
+
child.stderr.setEncoding("utf8");
|
|
580
|
+
child.stderr.on("data", (chunk: string) => {
|
|
581
|
+
stderrBuffer += chunk;
|
|
582
|
+
const lines = stderrBuffer.split("\n");
|
|
583
|
+
stderrBuffer = lines.pop() ?? "";
|
|
584
|
+
for (const line of lines) this.log(line);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// A replaced child (e.g. killed after a startup timeout) must not tear
|
|
588
|
+
// down the current one or fail its pending requests.
|
|
589
|
+
child.on("error", (err) => {
|
|
590
|
+
if (this.child === child) {
|
|
591
|
+
this.handleExit(new LayaWorkerError(`Laya worker process error: ${err.message}`, err));
|
|
592
|
+
}
|
|
593
|
+
settleError(new LayaWorkerError(`Laya worker process error: ${err.message}`, err));
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
child.on("exit", (code, signal) => {
|
|
597
|
+
rl.close();
|
|
598
|
+
this.log(stderrBuffer);
|
|
599
|
+
this.log(`exited (code=${code ?? "null"}, signal=${signal ?? "null"})`);
|
|
600
|
+
if (this.child === child) {
|
|
601
|
+
this.handleExit(
|
|
602
|
+
new LayaWorkerError(`Laya worker exited (code=${code ?? "null"}, signal=${signal ?? "null"})`),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
settleError(
|
|
606
|
+
new LayaWorkerError(`Laya worker exited before ready (code=${code ?? "null"})`),
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private handleLine(
|
|
613
|
+
line: string,
|
|
614
|
+
settleReady: () => void,
|
|
615
|
+
settleError: (err: LayaWorkerError) => void,
|
|
616
|
+
rejectWorker: (err: LayaWorkerError) => void,
|
|
617
|
+
): void {
|
|
618
|
+
let parsed: unknown;
|
|
619
|
+
try {
|
|
620
|
+
parsed = JSON.parse(line);
|
|
621
|
+
} catch {
|
|
622
|
+
return; // ignore non-protocol noise
|
|
623
|
+
}
|
|
624
|
+
// Stray stdout output can still be valid JSON (`42`, `null`, `[]`).
|
|
625
|
+
if (!isWorkerMessage(parsed)) return;
|
|
626
|
+
const message = parsed;
|
|
627
|
+
|
|
628
|
+
if ("type" in message) {
|
|
629
|
+
if (message.type === "ready") {
|
|
630
|
+
const problem = protocolProblem(message.protocol);
|
|
631
|
+
if (problem) {
|
|
632
|
+
rejectWorker(new LayaWorkerError(problem));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
this.lastModel = message.model;
|
|
636
|
+
settleReady();
|
|
637
|
+
} else if (message.type === "fatal") {
|
|
638
|
+
settleError(new LayaWorkerError(`Laya worker failed to load model: ${message.error}`));
|
|
639
|
+
}
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (message.id === null) {
|
|
644
|
+
this.log(`protocol error: ${message.ok ? "" : message.error}`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const pending = this.pending.get(message.id);
|
|
649
|
+
if (!pending) return;
|
|
650
|
+
|
|
651
|
+
this.pending.delete(message.id);
|
|
652
|
+
clearTimeout(pending.timer);
|
|
653
|
+
if (pending.onAbort && pending.signal) {
|
|
654
|
+
pending.signal.removeEventListener("abort", pending.onAbort);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (message.ok) {
|
|
658
|
+
pending.resolve(message.result);
|
|
659
|
+
} else {
|
|
660
|
+
pending.reject(new LayaWorkerError(message.error));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private log(line: string): void {
|
|
665
|
+
const text = line.trimEnd();
|
|
666
|
+
if (!text) return;
|
|
667
|
+
// Progress bars redraw with carriage returns; keep only the final state.
|
|
668
|
+
const last = text.slice(text.lastIndexOf("\r") + 1);
|
|
669
|
+
this.logLines.push(last.length > LOG_LINE_MAX ? `${last.slice(0, LOG_LINE_MAX)}…` : last);
|
|
670
|
+
if (this.logLines.length > LOG_CAPACITY) this.logLines.shift();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private handleExit(err: Error): void {
|
|
674
|
+
this.child = undefined;
|
|
675
|
+
this.ready = false;
|
|
676
|
+
this.failAll(err);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private failAll(err: Error): void {
|
|
680
|
+
for (const pending of this.pending.values()) {
|
|
681
|
+
clearTimeout(pending.timer);
|
|
682
|
+
if (pending.onAbort && pending.signal) {
|
|
683
|
+
pending.signal.removeEventListener("abort", pending.onAbort);
|
|
684
|
+
}
|
|
685
|
+
pending.reject(err);
|
|
686
|
+
}
|
|
687
|
+
this.pending.clear();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
// Helpers
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
696
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function isHealthResponse(value: unknown): value is LayaHealthResponse {
|
|
700
|
+
return isRecord(value) && typeof value.ready === "boolean";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Only `answers` is required; `parseDecision` checks each answer it reads. */
|
|
704
|
+
function isDecisionResponse(value: unknown): value is LayaDecisionResponse {
|
|
705
|
+
return isRecord(value) && isRecord(value.answers);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Shape check for a line read from the worker's stdout. */
|
|
709
|
+
function isWorkerMessage(value: unknown): value is WorkerMessage {
|
|
710
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
711
|
+
const record = value as Record<string, unknown>;
|
|
712
|
+
if ("type" in record) return record.type === "ready" || record.type === "fatal";
|
|
713
|
+
return (
|
|
714
|
+
(typeof record.id === "number" || record.id === null) &&
|
|
715
|
+
typeof record.ok === "boolean"
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|