@gobing-ai/ts-laya-mlx 0.5.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/LICENSE +176 -0
- package/NOTICE +31 -0
- package/README.md +145 -0
- package/dist/driver.d.ts +25 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +150 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/worker-client.d.ts +106 -0
- package/dist/worker-client.d.ts.map +1 -0
- package/dist/worker-client.js +446 -0
- package/package.json +64 -0
- package/src/driver.ts +184 -0
- package/src/index.ts +14 -0
- package/src/worker-client.ts +592 -0
- package/worker/README.md +45 -0
- package/worker/laya_worker.py +208 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker-client.d.ts","sourceRoot":"","sources":["../src/worker-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-lifecycle client for the laya JSON-lines worker (task 0076).
|
|
3
|
+
*
|
|
4
|
+
* Spawns `worker/laya_worker.py` through ts-runtime's `ProcessExecutor.runStreaming`
|
|
5
|
+
* (ADR-011/ADR-014 — the only sanctioned spawn seam), awaits the startup handshake
|
|
6
|
+
* before the first request, correlates responses by id, and enforces two independent
|
|
7
|
+
* budgets: `startupTimeoutMs` for the one-time weight load, `requestTimeoutMs` for a
|
|
8
|
+
* single forward pass. The worker is started lazily on the first ask and held for the
|
|
9
|
+
* client's lifetime; a worker that dies rejects every in-flight call and the next ask
|
|
10
|
+
* starts a fresh process — never a silent mid-ask restart. Only documented env keys
|
|
11
|
+
* reach the child, so the injected-env contract (ADR-011) holds across the process
|
|
12
|
+
* boundary. Protocol reference: docs/design/laya-local-decision-backend.md.
|
|
13
|
+
*/
|
|
14
|
+
import { DecisionAuthError, DecisionBackendError, DecisionConfigError, DecisionConnectionError, DecisionRequestError, DecisionTimeoutError, } from '@gobing-ai/ts-ai-runner';
|
|
15
|
+
import { joinPath, nodeBunFactory } from '@gobing-ai/ts-runtime';
|
|
16
|
+
/** Environment keys this package may pass to the child process — the full allowlist. */
|
|
17
|
+
const FORWARDED_ENV_KEYS = ['LAYA_MODEL_ID', 'LAYA_MODEL_PATH', 'LAYA_CACHE_DIR', 'LAYA_PYTHON', 'HF_TOKEN'];
|
|
18
|
+
/** How many trailing stderr bytes to keep for exit-failure messages. */
|
|
19
|
+
const STDERR_TAIL_LIMIT = 2000;
|
|
20
|
+
/** Grace window for the exit event once stdout closes, so pending calls reject with the exit-informed error. */
|
|
21
|
+
const STREAM_CLOSE_GRACE_MS = 250;
|
|
22
|
+
/**
|
|
23
|
+
* Project the injected environment onto the child allowlist. Everything absent from
|
|
24
|
+
* `FORWARDED_ENV_KEYS` is dropped, so the parent environment is never inherited
|
|
25
|
+
* wholesale and the child gets exactly the documented keys that are set.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveForwardedEnv(env) {
|
|
28
|
+
const forwarded = {};
|
|
29
|
+
if (!env)
|
|
30
|
+
return forwarded;
|
|
31
|
+
for (const key of FORWARDED_ENV_KEYS) {
|
|
32
|
+
const value = env[key];
|
|
33
|
+
if (value !== undefined)
|
|
34
|
+
forwarded[key] = value;
|
|
35
|
+
}
|
|
36
|
+
return forwarded;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validate host platform prerequisites for `@gobing-ai/ts-laya-mlx`.
|
|
40
|
+
* Throws {@link DecisionConfigError} if the platform is not Apple Silicon macOS.
|
|
41
|
+
*/
|
|
42
|
+
export function validateHostPrerequisites(platform = process.platform, arch = process.arch) {
|
|
43
|
+
if (platform !== 'darwin' || arch !== 'arm64') {
|
|
44
|
+
throw new DecisionConfigError(`laya-mlx requires macOS on Apple Silicon (darwin arm64); current platform is '${platform} ${arch}'`, 'PLATFORM');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function hasNonFiniteValues(obj) {
|
|
48
|
+
if (typeof obj === 'number') {
|
|
49
|
+
return !Number.isFinite(obj);
|
|
50
|
+
}
|
|
51
|
+
if (obj !== null && typeof obj === 'object') {
|
|
52
|
+
for (const val of Object.values(obj)) {
|
|
53
|
+
if (hasNonFiniteValues(val))
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Map worker-reported error kinds and messages onto the decision taxonomy (design: Errors table, task 0077).
|
|
61
|
+
*/
|
|
62
|
+
export function translateWorkerError(kind, message) {
|
|
63
|
+
const lower = message.toLowerCase();
|
|
64
|
+
// Credential/auth failures during artifact resolution (0077 R6)
|
|
65
|
+
if (lower.includes('401') ||
|
|
66
|
+
lower.includes('unauthorized') ||
|
|
67
|
+
lower.includes('403') ||
|
|
68
|
+
lower.includes('forbidden') ||
|
|
69
|
+
lower.includes('gated repo') ||
|
|
70
|
+
lower.includes('hf_token') ||
|
|
71
|
+
lower.includes('invalid credentials')) {
|
|
72
|
+
const status = lower.includes('403') || lower.includes('forbidden') ? 403 : 401;
|
|
73
|
+
return new DecisionAuthError(`laya artifact resolution failed: ${message}`, status);
|
|
74
|
+
}
|
|
75
|
+
// Transport/connection failures during artifact resolution (0077 R6)
|
|
76
|
+
if (lower.includes('connectionerror') ||
|
|
77
|
+
lower.includes('connection refused') ||
|
|
78
|
+
lower.includes('name resolution') ||
|
|
79
|
+
lower.includes('nameresolutionerror') ||
|
|
80
|
+
lower.includes('network is unreachable') ||
|
|
81
|
+
lower.includes('could not connect') ||
|
|
82
|
+
lower.includes('transport')) {
|
|
83
|
+
return new DecisionConnectionError(`laya artifact resolution failed: ${message}`);
|
|
84
|
+
}
|
|
85
|
+
// Missing runtime (0077 R2)
|
|
86
|
+
if (lower.includes('no module named') || lower.includes('laya_mlx') || lower.includes('laya-mlx')) {
|
|
87
|
+
return new DecisionConfigError(`laya-mlx runtime is not importable: ${message}; install with 'pip install laya-mlx'`, 'LAYA_PYTHON');
|
|
88
|
+
}
|
|
89
|
+
// Non-finite output (0077 R5)
|
|
90
|
+
if (lower.includes('non-finite') ||
|
|
91
|
+
lower.includes('floatingpointerror') ||
|
|
92
|
+
lower.includes('nan') ||
|
|
93
|
+
lower.includes('infinity')) {
|
|
94
|
+
return new DecisionBackendError(`laya worker non-finite output: ${message}`, undefined);
|
|
95
|
+
}
|
|
96
|
+
switch (kind) {
|
|
97
|
+
case 'config':
|
|
98
|
+
return new DecisionConfigError(`laya worker rejected its configuration: ${message}`, 'laya-worker');
|
|
99
|
+
case 'request':
|
|
100
|
+
return new DecisionRequestError(`laya worker rejected the request: ${message}`, undefined, message);
|
|
101
|
+
default:
|
|
102
|
+
return new DecisionBackendError(`laya worker backend failure: ${message}`, undefined);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Map a worker-reported error kind onto the decision taxonomy (design: Errors table). */
|
|
106
|
+
function toWorkerError(kind, message) {
|
|
107
|
+
return translateWorkerError(kind, message);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A started worker process plus its line pump, pending-request map, handshake
|
|
111
|
+
* race, and exit watcher. The pump owns the stdout stream exclusively: the first
|
|
112
|
+
* line settles the handshake, every later line dispatches by id.
|
|
113
|
+
*/
|
|
114
|
+
class WorkerHandle {
|
|
115
|
+
proc;
|
|
116
|
+
handshake;
|
|
117
|
+
requestTimeoutMs;
|
|
118
|
+
pending = new Map();
|
|
119
|
+
nextRequestId = 1;
|
|
120
|
+
alive = true;
|
|
121
|
+
stderrTail = '';
|
|
122
|
+
handshakeSettled = false;
|
|
123
|
+
handshakeTimer = null;
|
|
124
|
+
handshakeResolve = null;
|
|
125
|
+
handshakeReject = null;
|
|
126
|
+
streamCloseTimer = null;
|
|
127
|
+
constructor(proc, requestTimeoutMs, startupTimeoutMs) {
|
|
128
|
+
this.proc = proc;
|
|
129
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
130
|
+
this.handshake = new Promise((resolve, reject) => {
|
|
131
|
+
this.handshakeResolve = resolve;
|
|
132
|
+
this.handshakeReject = reject;
|
|
133
|
+
});
|
|
134
|
+
this.handshakeTimer = setTimeout(() => {
|
|
135
|
+
this.failHandshake(new DecisionTimeoutError(`laya worker did not emit its handshake within startupTimeoutMs (${startupTimeoutMs})`, startupTimeoutMs));
|
|
136
|
+
}, startupTimeoutMs);
|
|
137
|
+
if (proc.stdout !== null)
|
|
138
|
+
this.pump(proc.stdout);
|
|
139
|
+
if (proc.stderr !== null)
|
|
140
|
+
this.collectStderr(proc.stderr);
|
|
141
|
+
void proc.exited.then((code) => {
|
|
142
|
+
this.alive = false;
|
|
143
|
+
this.failHandshake(this.exitError(code));
|
|
144
|
+
this.failAll(this.exitError(code));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Reject everything in flight; the map clear makes repeated calls harmless. */
|
|
148
|
+
failAll(error) {
|
|
149
|
+
this.alive = false;
|
|
150
|
+
for (const entry of this.pending.values()) {
|
|
151
|
+
clearTimeout(entry.timer);
|
|
152
|
+
entry.reject(error);
|
|
153
|
+
}
|
|
154
|
+
this.pending.clear();
|
|
155
|
+
}
|
|
156
|
+
/** Close stdin for a graceful EOF exit. */
|
|
157
|
+
end() {
|
|
158
|
+
try {
|
|
159
|
+
this.proc.endStdin();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Already dead — the exit watcher owns failure propagation from here.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Terminate a worker that must not keep running (startup failure). */
|
|
166
|
+
kill() {
|
|
167
|
+
try {
|
|
168
|
+
this.proc.kill();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Already dead — the exit watcher owns failure propagation from here.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
get isAlive() {
|
|
175
|
+
return this.alive;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Register a pending entry under the request timeout and return its correlation
|
|
179
|
+
* id. Registered before the write so the exit watcher covers the send window.
|
|
180
|
+
*/
|
|
181
|
+
armRequest(resolve, reject) {
|
|
182
|
+
const id = String(this.nextRequestId++);
|
|
183
|
+
const requestTimeoutMs = this.requestTimeoutMs;
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
// Reject this request only; the stream keeps running and the late
|
|
186
|
+
// response is dropped by id when it arrives (no queue corruption).
|
|
187
|
+
this.pending.delete(id);
|
|
188
|
+
reject(new DecisionTimeoutError(`laya worker request ${id} exceeded requestTimeoutMs (${requestTimeoutMs})`, requestTimeoutMs));
|
|
189
|
+
}, requestTimeoutMs);
|
|
190
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
191
|
+
return id;
|
|
192
|
+
}
|
|
193
|
+
exitError(code) {
|
|
194
|
+
const tail = this.stderrTail.length > 0 ? `: ${this.stderrTail}` : '';
|
|
195
|
+
const raw = `${this.stderrTail} (code ${code ?? 'unknown'})`;
|
|
196
|
+
if (raw.includes('No module named') || raw.includes('laya_mlx')) {
|
|
197
|
+
return new DecisionConfigError(`laya-mlx runtime is not importable: ${raw}; install with 'pip install laya-mlx'`, 'LAYA_PYTHON');
|
|
198
|
+
}
|
|
199
|
+
return new DecisionBackendError(`laya worker exited unexpectedly (code ${code ?? 'unknown'})${tail}`, undefined);
|
|
200
|
+
}
|
|
201
|
+
failHandshake(error) {
|
|
202
|
+
if (this.handshakeSettled)
|
|
203
|
+
return;
|
|
204
|
+
this.handshakeSettled = true;
|
|
205
|
+
if (this.handshakeTimer !== null)
|
|
206
|
+
clearTimeout(this.handshakeTimer);
|
|
207
|
+
this.handshakeReject?.(error);
|
|
208
|
+
}
|
|
209
|
+
/** Read stdout as UTF-8 JSON Lines; first line settles the handshake, the rest dispatch by id. */
|
|
210
|
+
pump(stream) {
|
|
211
|
+
const decoder = new TextDecoder();
|
|
212
|
+
let buffer = '';
|
|
213
|
+
void (async () => {
|
|
214
|
+
for await (const chunk of stream) {
|
|
215
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
216
|
+
let newlineAt = buffer.indexOf('\n');
|
|
217
|
+
while (newlineAt !== -1) {
|
|
218
|
+
this.onLine(buffer.slice(0, newlineAt));
|
|
219
|
+
buffer = buffer.slice(newlineAt + 1);
|
|
220
|
+
newlineAt = buffer.indexOf('\n');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
buffer += decoder.decode();
|
|
224
|
+
if (buffer.trim().length > 0)
|
|
225
|
+
this.onLine(buffer);
|
|
226
|
+
this.onStreamClosed();
|
|
227
|
+
})().catch(() => {
|
|
228
|
+
this.failAll(new DecisionBackendError('laya worker output stream failed', undefined));
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Stdout closed: a dead worker's pipes drain before its exit event resolves, so
|
|
233
|
+
* defer to the exit watcher — pending calls must reject with the exit-informed
|
|
234
|
+
* error (exit code + stderr tail), not a generic stream close. The grace timer
|
|
235
|
+
* bounds the wait for a worker that closed its stream but kept running; those
|
|
236
|
+
* calls fall back to the close rejection instead of hanging to their timeouts.
|
|
237
|
+
*/
|
|
238
|
+
onStreamClosed() {
|
|
239
|
+
this.streamCloseTimer = setTimeout(() => {
|
|
240
|
+
this.streamCloseTimer = null;
|
|
241
|
+
this.failAll(new DecisionBackendError('laya worker closed its output stream', undefined));
|
|
242
|
+
}, STREAM_CLOSE_GRACE_MS);
|
|
243
|
+
void this.proc.exited.then((code) => {
|
|
244
|
+
if (this.streamCloseTimer !== null) {
|
|
245
|
+
clearTimeout(this.streamCloseTimer);
|
|
246
|
+
this.streamCloseTimer = null;
|
|
247
|
+
}
|
|
248
|
+
this.failAll(this.exitError(code));
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
onLine(line) {
|
|
252
|
+
if (!this.handshakeSettled) {
|
|
253
|
+
this.settleHandshake(line);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
let parsed;
|
|
257
|
+
try {
|
|
258
|
+
parsed = JSON.parse(line);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Stream framing is broken — no pending request can trust what follows.
|
|
262
|
+
this.failAll(new DecisionBackendError(`laya worker emitted an unparseable line: ${line.slice(0, 200)}`, undefined));
|
|
263
|
+
this.kill();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (parsed.id === undefined)
|
|
267
|
+
return; // stray line without correlation — dropped by design
|
|
268
|
+
const entry = this.pending.get(String(parsed.id));
|
|
269
|
+
if (entry === undefined)
|
|
270
|
+
return; // late response for an already-rejected request
|
|
271
|
+
this.pending.delete(String(parsed.id));
|
|
272
|
+
clearTimeout(entry.timer);
|
|
273
|
+
if (parsed.ok === true && parsed.result !== undefined) {
|
|
274
|
+
if (hasNonFiniteValues(parsed.result.answers)) {
|
|
275
|
+
entry.reject(new DecisionBackendError("laya worker returned non-finite model outputs; retry with dtype='float32'", undefined));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
entry.resolve(parsed.result);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (parsed.ok === false) {
|
|
282
|
+
entry.reject(toWorkerError(parsed.error?.kind, parsed.error?.message ?? 'unknown worker error'));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
entry.reject(new DecisionBackendError(`laya worker sent a malformed response for request ${String(parsed.id)}`, undefined));
|
|
286
|
+
}
|
|
287
|
+
/** First stdout line: validate the handshake envelope and settle the startup race. */
|
|
288
|
+
settleHandshake(line) {
|
|
289
|
+
if (this.handshakeSettled)
|
|
290
|
+
return; // the timeout or the exit watcher won the race
|
|
291
|
+
this.handshakeSettled = true;
|
|
292
|
+
if (this.handshakeTimer !== null)
|
|
293
|
+
clearTimeout(this.handshakeTimer);
|
|
294
|
+
let handshake;
|
|
295
|
+
try {
|
|
296
|
+
handshake = JSON.parse(line);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
this.handshakeReject?.(new DecisionBackendError(`laya worker handshake was not JSON: ${line.slice(0, 200)}`, undefined));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (handshake.ready === true) {
|
|
303
|
+
this.handshakeResolve?.(handshake);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
this.handshakeReject?.(toWorkerError(handshake.error?.kind ?? 'config', handshake.error?.message ?? 'worker reported it was not ready'));
|
|
307
|
+
}
|
|
308
|
+
/** Drain stderr so a chatty worker cannot block, keeping a tail for exit diagnostics. */
|
|
309
|
+
collectStderr(stream) {
|
|
310
|
+
const decoder = new TextDecoder();
|
|
311
|
+
void (async () => {
|
|
312
|
+
for await (const chunk of stream) {
|
|
313
|
+
this.stderrTail = `${this.stderrTail}${decoder.decode(chunk, { stream: true })}`.slice(-STDERR_TAIL_LIMIT);
|
|
314
|
+
}
|
|
315
|
+
})().catch(() => {
|
|
316
|
+
// Stderr diagnostics are best-effort only.
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* TypeScript half of the laya process bridge: start the Python worker lazily,
|
|
322
|
+
* hold it warm for the client's lifetime, and serve serialized asks with
|
|
323
|
+
* id-correlated, timeout-bounded responses.
|
|
324
|
+
*/
|
|
325
|
+
export class LayaWorkerClient {
|
|
326
|
+
pythonPath;
|
|
327
|
+
args;
|
|
328
|
+
cwd;
|
|
329
|
+
startupTimeoutMs;
|
|
330
|
+
requestTimeoutMs;
|
|
331
|
+
forwardedEnv;
|
|
332
|
+
executor;
|
|
333
|
+
worker = null;
|
|
334
|
+
chain = Promise.resolve();
|
|
335
|
+
disposed = false;
|
|
336
|
+
constructor(options = {}, executor = nodeBunFactory.createProcessExecutor()) {
|
|
337
|
+
validateHostPrerequisites(options.platform, options.arch);
|
|
338
|
+
const packageRoot = joinPath(import.meta.dir, '..');
|
|
339
|
+
this.pythonPath = options.pythonPath ?? options.env?.LAYA_PYTHON ?? 'python3';
|
|
340
|
+
this.cwd = options.cwd ?? packageRoot;
|
|
341
|
+
this.startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
|
|
342
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
343
|
+
this.forwardedEnv = resolveForwardedEnv(options.env);
|
|
344
|
+
this.executor = executor;
|
|
345
|
+
this.args = [options.workerScript ?? joinPath(packageRoot, 'worker', 'laya_worker.py')];
|
|
346
|
+
this.args.push('--module', options.module ?? 'laya_mlx');
|
|
347
|
+
const resolvedModelPath = options.modelPath ?? options.env?.LAYA_MODEL_PATH;
|
|
348
|
+
const resolvedCacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
|
|
349
|
+
const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ?? 'convaiinnovations/laya-multilingual';
|
|
350
|
+
if (resolvedModelPath !== undefined) {
|
|
351
|
+
this.args.push('--model-path', resolvedModelPath);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
this.args.push('--model', resolvedModelId);
|
|
355
|
+
}
|
|
356
|
+
if (options.revision !== undefined)
|
|
357
|
+
this.args.push('--revision', options.revision);
|
|
358
|
+
this.args.push('--dtype', options.dtype ?? 'float16');
|
|
359
|
+
this.args.push('--batch-size', String(options.batchSize ?? 16));
|
|
360
|
+
if (resolvedCacheDir !== undefined)
|
|
361
|
+
this.args.push('--cache-dir', resolvedCacheDir);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Ask one batch of worker-format questions. Requests are serialized — the
|
|
365
|
+
* worker serves one request at a time and batching happens inside a single
|
|
366
|
+
* request — and the worker spawns lazily on the first call, so the weight
|
|
367
|
+
* load is paid once per client.
|
|
368
|
+
*/
|
|
369
|
+
ask(state, questions) {
|
|
370
|
+
const run = this.chain.then(() => this.dispatch(state, questions));
|
|
371
|
+
this.chain = run.then(() => undefined, () => undefined);
|
|
372
|
+
return run;
|
|
373
|
+
}
|
|
374
|
+
/** Close the worker; safe to call repeatedly. In-flight calls reject through the exit watcher. */
|
|
375
|
+
dispose() {
|
|
376
|
+
this.disposed = true;
|
|
377
|
+
const worker = this.worker;
|
|
378
|
+
this.worker = null;
|
|
379
|
+
worker?.end();
|
|
380
|
+
}
|
|
381
|
+
async dispatch(state, questions) {
|
|
382
|
+
if (this.disposed)
|
|
383
|
+
throw new DecisionBackendError('laya worker client has been disposed', undefined);
|
|
384
|
+
const worker = await this.ensureWorker();
|
|
385
|
+
return new Promise((resolve, reject) => {
|
|
386
|
+
const id = worker.armRequest(resolve, reject);
|
|
387
|
+
try {
|
|
388
|
+
worker.proc.writeStdin(`${JSON.stringify({ id, state, questions })}\n`);
|
|
389
|
+
}
|
|
390
|
+
catch (cause) {
|
|
391
|
+
reject(new DecisionBackendError('laya worker rejected the request line', undefined, { cause }));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!worker.isAlive) {
|
|
395
|
+
// The exit watcher raced the write; fail now instead of at the timeout.
|
|
396
|
+
worker.failAll(new DecisionBackendError('laya worker exited before the request could be sent', undefined));
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async ensureWorker() {
|
|
401
|
+
if (this.worker?.isAlive)
|
|
402
|
+
return this.worker;
|
|
403
|
+
this.worker = null;
|
|
404
|
+
const worker = this.spawn();
|
|
405
|
+
try {
|
|
406
|
+
await worker.handshake;
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
// Startup failed: nothing is in flight, so terminate and surface the
|
|
410
|
+
// error. The next ask starts fresh rather than reusing a dead handle.
|
|
411
|
+
worker.kill();
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
if (this.disposed) {
|
|
415
|
+
// dispose() raced the startup handshake — the worker must not outlive
|
|
416
|
+
// a disposed client.
|
|
417
|
+
worker.kill();
|
|
418
|
+
throw new DecisionBackendError('laya worker client has been disposed', undefined);
|
|
419
|
+
}
|
|
420
|
+
this.worker = worker;
|
|
421
|
+
return worker;
|
|
422
|
+
}
|
|
423
|
+
spawn() {
|
|
424
|
+
let proc;
|
|
425
|
+
try {
|
|
426
|
+
proc = this.executor.runStreaming({
|
|
427
|
+
command: this.pythonPath,
|
|
428
|
+
args: this.args,
|
|
429
|
+
env: this.forwardedEnv,
|
|
430
|
+
envMode: 'replace',
|
|
431
|
+
label: 'laya-worker',
|
|
432
|
+
cwd: this.cwd,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch (cause) {
|
|
436
|
+
throw new DecisionConfigError(`Python interpreter '${this.pythonPath}' is missing or not executable; install Python 3.10+ (e.g. via 'brew install python@3.11') or set LAYA_PYTHON`, 'LAYA_PYTHON', { cause });
|
|
437
|
+
}
|
|
438
|
+
const handle = new WorkerHandle(proc, this.requestTimeoutMs, this.startupTimeoutMs);
|
|
439
|
+
void proc.exited.then(() => {
|
|
440
|
+
if (this.worker === handle) {
|
|
441
|
+
this.worker = null;
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
return handle;
|
|
445
|
+
}
|
|
446
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gobing-ai/ts-laya-mlx",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "@gobing-ai/ts-laya-mlx — Local Laya decision backend driving the vendored MLX runtime over a process bridge.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"typescript",
|
|
7
|
+
"ai",
|
|
8
|
+
"decision",
|
|
9
|
+
"mlx",
|
|
10
|
+
"laya",
|
|
11
|
+
"local"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/gobing-ai/ts-libs.git",
|
|
16
|
+
"directory": "packages/laya-mlx"
|
|
17
|
+
},
|
|
18
|
+
"author": "Robin Min <minlongbing@gmail.com>",
|
|
19
|
+
"contributors": [
|
|
20
|
+
"Robin Min <minlongbing@gmail.com>"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"private": false,
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"worker",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"NOTICE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json && bun ../../scripts/builder.ts fix-dist-esm-extensions dist",
|
|
44
|
+
"test": "NODE_ENV=test bun test --coverage --coverage-dir=.coverage --reporter=dots",
|
|
45
|
+
"test:full": "NODE_ENV=test bun test --update-snapshots --coverage --coverage-dir=.coverage",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint": "biome check . && bun run typecheck",
|
|
48
|
+
"format": "biome check . --write",
|
|
49
|
+
"parity": "bun scripts/full-parity.ts",
|
|
50
|
+
"check": "bun run lint && bun run test",
|
|
51
|
+
"prepublishOnly": "bun run build",
|
|
52
|
+
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-laya-mlx-v<version> && git push --tags' && exit 1"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@gobing-ai/ts-ai-runner": "workspace:*",
|
|
56
|
+
"@gobing-ai/ts-runtime": "workspace:*"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/bun": "1.3.14"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|