arcane-os 0.66.0 → 0.67.0
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 +23 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-image.mjs +84 -34
- package/browser-runtime/ai/core-local.mjs +195 -43
- package/browser-runtime/ai/core-onnx.mjs +71 -12
- package/docs/reference/README.md +1 -0
- package/docs/reference/core-native-packaging.md +11 -4
- package/docs/reference/core-repositories.md +93 -9
- package/docs/reference/core-runtime.md +32 -5
- package/docs/reference/inventory/package-api.json +9 -9
- package/docs/reference/local-ai.md +112 -2
- package/docs/reference/local-image-generation.md +15 -4
- package/docs/reference/model-assets.md +50 -3
- package/docs/reference/native-decisions.md +181 -0
- package/docs/reference/sdk-api.md +55 -12
- package/package.json +5 -2
- package/src/core/repositories.mjs +27 -8
- package/src/core/runtime.mjs +11 -0
- package/src/core/services/decisions.mjs +144 -0
- package/src/core/services/local-ai.mjs +31 -9
- package/src/core/services/model-assets.mjs +98 -13
- package/src/git-identity.mjs +22 -58
- package/src/local-ai/decision-tokenizer-worker.mjs +31 -0
- package/src/local-ai/decision-tokenizer.mjs +109 -0
- package/src/local-ai/decisions.mjs +273 -0
- package/src/local-ai/dev.mjs +8 -2
- package/src/local-ai/onnx-worker.mjs +67 -7
- package/src/local-ai/onnx.mjs +23 -12
- package/src/native/portable-layout.mjs +8 -3
- package/src/repository.mjs +144 -4
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {createNativeDecisionModel} from '../../local-ai/decisions.mjs';
|
|
3
|
+
import {createArcaneEventSource} from '../../event-manager.mjs';
|
|
4
|
+
import {CoreError} from '../../../browser-runtime/core/contracts.mjs';
|
|
5
|
+
import {encodeTensorMap} from '../../../browser-runtime/ai/onnx-tensors.mjs';
|
|
6
|
+
|
|
7
|
+
/** App-owned selection; shared typed-decision execution, without a renderer. */
|
|
8
|
+
export function createNativeDecisionService(configuration = {}, {appRoot = process.cwd()} = {}) {
|
|
9
|
+
const events = createArcaneEventSource({}, {source: 'core-decisions', eventTypes: ['decisions.state']});
|
|
10
|
+
const lifetime = new AbortController();
|
|
11
|
+
let context;
|
|
12
|
+
let model;
|
|
13
|
+
let loading;
|
|
14
|
+
let loadController;
|
|
15
|
+
let releasing;
|
|
16
|
+
let closing;
|
|
17
|
+
let unsubscribe;
|
|
18
|
+
|
|
19
|
+
function current() {
|
|
20
|
+
return model?.current() ?? {
|
|
21
|
+
family: 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
|
|
22
|
+
revision: configuration.revision ?? 'main', dtype: configuration.dtype ?? 'fp32',
|
|
23
|
+
state: closing ? 'disposed' : 'unloaded', loaded: false, busy: Boolean(loading),
|
|
24
|
+
activeRequests: 0, progress: null, error: null, execution: null
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function publish(value = current()) {
|
|
29
|
+
events.dispatch('decisions.state', value);
|
|
30
|
+
context?.emit('decisions.state', value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function subscribe(listener, {emitCurrent = true, signal} = {}) {
|
|
34
|
+
const stop = events.on('decisions.state', function changed(event) { listener(event.detail); }, {signal});
|
|
35
|
+
try { if (emitCurrent && !signal?.aborted) listener(current()); }
|
|
36
|
+
catch (error) { stop(); throw error; }
|
|
37
|
+
return stop;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function operationSignal(signal) { return signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; }
|
|
41
|
+
|
|
42
|
+
function load({signal} = {}) {
|
|
43
|
+
operationSignal(signal).throwIfAborted();
|
|
44
|
+
if (!context) throw new CoreError({code: 'CORE_NOT_READY', message: 'Register and start the decision service with Core before loading.'});
|
|
45
|
+
if (loading) return observeLoad(loading, loadController, signal);
|
|
46
|
+
if (model?.current().loaded) return Promise.resolve(model.current());
|
|
47
|
+
loadController = new AbortController();
|
|
48
|
+
const controller = loadController;
|
|
49
|
+
const selectedSignal = AbortSignal.any([lifetime.signal, controller.signal]);
|
|
50
|
+
const precedingRelease = releasing;
|
|
51
|
+
loading = Promise.resolve().then(async function loadSelectedModel() {
|
|
52
|
+
selectedSignal.throwIfAborted();
|
|
53
|
+
if (precedingRelease) await precedingRelease;
|
|
54
|
+
selectedSignal.throwIfAborted();
|
|
55
|
+
if (model) await model.dispose();
|
|
56
|
+
unsubscribe?.();
|
|
57
|
+
// Only the explicit load waits for its native dependencies. Merely
|
|
58
|
+
// registering this service does not download a model or delay UI.
|
|
59
|
+
const [localAI, modelAssets] = await Promise.all([
|
|
60
|
+
context.getService('local-ai'),
|
|
61
|
+
configuration.paths ? undefined : context.getService('model-assets')
|
|
62
|
+
]);
|
|
63
|
+
selectedSignal.throwIfAborted();
|
|
64
|
+
const paths = configuration.paths && Object.fromEntries(
|
|
65
|
+
Object.entries(configuration.paths).map(function nativePath([name, value]) { return [name, path.resolve(appRoot, value)]; })
|
|
66
|
+
);
|
|
67
|
+
model = createNativeDecisionModel({
|
|
68
|
+
...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets,
|
|
69
|
+
signal: lifetime.signal
|
|
70
|
+
});
|
|
71
|
+
unsubscribe = model.subscribe(publish);
|
|
72
|
+
return model.load({signal: selectedSignal});
|
|
73
|
+
});
|
|
74
|
+
function settled() { loading = undefined; loadController = undefined; publish(); }
|
|
75
|
+
loading.then(settled, settled);
|
|
76
|
+
publish();
|
|
77
|
+
return observeLoad(loading, controller, signal);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function observeLoad(task, controller, signal) {
|
|
81
|
+
function abort() { controller.abort(signal.reason); }
|
|
82
|
+
signal?.addEventListener('abort', abort, {once: true});
|
|
83
|
+
if (signal?.aborted) abort();
|
|
84
|
+
return task.finally(function detachLoadCaller() { signal?.removeEventListener('abort', abort); });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function evaluate(rows, {signal, runOptions} = {}) {
|
|
88
|
+
const selectedSignal = operationSignal(signal);
|
|
89
|
+
selectedSignal.throwIfAborted();
|
|
90
|
+
if (!model) throw new CoreError({code: 'ARCANE_DECISION_NOT_LOADED', message: 'Load the selected native decision model before evaluating.'});
|
|
91
|
+
return model.evaluate(rows, {signal: selectedSignal, runOptions});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function unload() {
|
|
95
|
+
if (releasing) return releasing;
|
|
96
|
+
// An unload during model preparation must cancel that accepted load,
|
|
97
|
+
// rather than wait for it to download and activate first.
|
|
98
|
+
loadController?.abort(new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision load was cancelled.'}));
|
|
99
|
+
const task = model?.unload() ?? Promise.resolve();
|
|
100
|
+
const acceptedLoad = loading;
|
|
101
|
+
releasing = Promise.resolve().then(async function releaseSelectedModel() {
|
|
102
|
+
const results = await Promise.allSettled([task, acceptedLoad]);
|
|
103
|
+
if (results[0].status === 'rejected') throw results[0].reason;
|
|
104
|
+
return current();
|
|
105
|
+
});
|
|
106
|
+
function released() { releasing = undefined; }
|
|
107
|
+
releasing.then(released, released);
|
|
108
|
+
return releasing;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function dispose() {
|
|
112
|
+
if (closing) return closing;
|
|
113
|
+
const reason = new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision service is closing.'});
|
|
114
|
+
closing = Promise.resolve().then(async function closeDecisionService() {
|
|
115
|
+
const failures = [];
|
|
116
|
+
try { await loading; } catch (error) { if (!lifetime.signal.aborted) failures.push(error); }
|
|
117
|
+
try { await model?.dispose(); } catch (error) { failures.push(error); }
|
|
118
|
+
unsubscribe?.();
|
|
119
|
+
publish();
|
|
120
|
+
events.dispose();
|
|
121
|
+
if (failures.length) throw new AggregateError(failures, 'Closing the decision service failed.');
|
|
122
|
+
});
|
|
123
|
+
lifetime.abort(reason);
|
|
124
|
+
return closing;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
name: configuration.name ?? 'decisions', current, subscribe, load, evaluate, classify: evaluate, unload, dispose,
|
|
129
|
+
start(serviceContext) { context = serviceContext; },
|
|
130
|
+
methods: {
|
|
131
|
+
'decisions.status': current,
|
|
132
|
+
'decisions.load': function loadRequest(parameters, request) { return load({signal: request.signal}); },
|
|
133
|
+
'decisions.evaluate': async function evaluateRequest({rows, runOptions}, request) {
|
|
134
|
+
const result = await evaluate(rows, {runOptions, signal: request.signal});
|
|
135
|
+
// Typed arrays retain their complete values through Core's
|
|
136
|
+
// existing JSON tensor encoding, only at this transport edge.
|
|
137
|
+
return {...result, outputs: encodeTensorMap(result.outputs)};
|
|
138
|
+
},
|
|
139
|
+
'decisions.unload': {lifetime: 'service', handle: unload}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default createNativeDecisionService;
|
|
@@ -149,8 +149,13 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
function runtimeRecord(engine) {
|
|
153
|
+
return {...engine.state, models: [...engine.models],
|
|
154
|
+
...(engine.id === 'llama.cpp' ? {released: engine.released === true} : {})};
|
|
155
|
+
}
|
|
156
|
+
|
|
152
157
|
function snapshot() {
|
|
153
|
-
const records = [...engines.values()].map(
|
|
158
|
+
const records = [...engines.values()].map(runtimeRecord);
|
|
154
159
|
if (onnxSelected) records.push(onnxStatus());
|
|
155
160
|
const ollama = records.find(function ollamaRecord(record) { return record.id === 'ollama'; });
|
|
156
161
|
return {
|
|
@@ -283,7 +288,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
283
288
|
engine.models = [...merged.values()];
|
|
284
289
|
}
|
|
285
290
|
publish();
|
|
286
|
-
return
|
|
291
|
+
return runtimeRecord(engine);
|
|
287
292
|
}
|
|
288
293
|
|
|
289
294
|
function observeModels(engine) {
|
|
@@ -347,7 +352,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
347
352
|
return recovering;
|
|
348
353
|
}
|
|
349
354
|
|
|
350
|
-
async function engineReady(id, signal, {retryLoading = false} = {}) {
|
|
355
|
+
async function engineReady(id, signal, {retryLoading = false, resumeReleased = true} = {}) {
|
|
351
356
|
signal?.throwIfAborted();
|
|
352
357
|
const engine = engines.get(id);
|
|
353
358
|
if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', `${id} is not selected in this application's localAI configuration.`);
|
|
@@ -358,13 +363,28 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
358
363
|
if (retryLoading && engine.state.state === 'loading') {
|
|
359
364
|
await awaitReadiness(restartEngine(engine), signal);
|
|
360
365
|
}
|
|
361
|
-
if (engine.released) {
|
|
366
|
+
if (engine.released) {
|
|
367
|
+
if (!resumeReleased) return engine;
|
|
368
|
+
engine.released = false;
|
|
369
|
+
startEngine(engine);
|
|
370
|
+
}
|
|
362
371
|
await awaitReadiness(engine.ready, signal);
|
|
363
372
|
signal?.throwIfAborted();
|
|
364
373
|
if (!engine.state.available) throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', `${id} is unavailable.`);
|
|
365
374
|
return engine;
|
|
366
375
|
}
|
|
367
376
|
|
|
377
|
+
function observeLlama(signal) {
|
|
378
|
+
signal?.throwIfAborted();
|
|
379
|
+
const engine = engines.get('llama.cpp');
|
|
380
|
+
if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', "llama.cpp is not selected in this application's localAI configuration.");
|
|
381
|
+
// Observation neither waits for startup nor starts a released engine.
|
|
382
|
+
if (!engine.state.available || engine.released || engine.stopping || engine.recovering || engine.signal.aborted) {
|
|
383
|
+
return runtimeRecord(engine);
|
|
384
|
+
}
|
|
385
|
+
return refreshModels(engine, signal);
|
|
386
|
+
}
|
|
387
|
+
|
|
368
388
|
function waitForModel(engine, model, signal) {
|
|
369
389
|
return new Promise(function awaitModel(resolve, reject) {
|
|
370
390
|
let unsubscribe;
|
|
@@ -563,6 +583,9 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
563
583
|
const service = {
|
|
564
584
|
name: 'local-ai',
|
|
565
585
|
current: snapshot,
|
|
586
|
+
getONNXRuntime() {
|
|
587
|
+
return requireONNX();
|
|
588
|
+
},
|
|
566
589
|
async start(currentContext) {
|
|
567
590
|
context = currentContext;
|
|
568
591
|
if (prepare) {
|
|
@@ -633,17 +656,16 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
|
|
|
633
656
|
return snapshot();
|
|
634
657
|
},
|
|
635
658
|
'llama.status': async function llamaStatus(_parameters, request) {
|
|
636
|
-
|
|
637
|
-
return refreshModels(engine, request.signal);
|
|
659
|
+
return observeLlama(request.signal);
|
|
638
660
|
},
|
|
639
661
|
'llama.models': async function llamaModels(_parameters, request) {
|
|
640
|
-
const
|
|
641
|
-
const status = await refreshModels(engine, request.signal);
|
|
662
|
+
const status = await observeLlama(request.signal);
|
|
642
663
|
return {models: status.models};
|
|
643
664
|
},
|
|
644
665
|
'llama.load': loadLlama,
|
|
645
666
|
'llama.unload': async function unloadLlama({model}, request) {
|
|
646
|
-
const engine = await engineReady('llama.cpp', request.signal);
|
|
667
|
+
const engine = await engineReady('llama.cpp', request.signal, {resumeReleased: false});
|
|
668
|
+
if (engine.released) return {...engine.state, models: [...engine.models], released: true, unloaded: true};
|
|
647
669
|
engine.loadControllers.get(model)?.abort(failure('LOCAL_AI_MODEL_UNLOADED', `The application unloaded ${model}.`));
|
|
648
670
|
if (engine.loads.has(model)) await Promise.allSettled([engine.loads.get(model)]);
|
|
649
671
|
if (!engine.router) {
|
|
@@ -10,7 +10,7 @@ function failure(code, message) {
|
|
|
10
10
|
return new CoreError({code, message});
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/** Working files for native engines.
|
|
13
|
+
/** Working files for native engines. Caller-selected source assets stay authoritative. */
|
|
14
14
|
export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
15
15
|
const projections = new Map();
|
|
16
16
|
let context;
|
|
@@ -63,6 +63,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
63
63
|
const errors = [];
|
|
64
64
|
let filesClosed = false;
|
|
65
65
|
try { await record.opening; } catch (error) { record.error ??= error; }
|
|
66
|
+
try { await record.downloading; } catch (error) { record.error ??= error; }
|
|
66
67
|
try { await record.finishing; } catch (error) { record.error ??= error; }
|
|
67
68
|
try { await closeMembers(record); filesClosed = true; } catch (error) { errors.push(error); }
|
|
68
69
|
if (record.directory && filesClosed) {
|
|
@@ -87,10 +88,16 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
87
88
|
|
|
88
89
|
function releasePreparation(record) {
|
|
89
90
|
record.preparationOwned = false;
|
|
91
|
+
record.downloadController?.abort(failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.'));
|
|
90
92
|
publish(record);
|
|
91
93
|
return cleanup(record);
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
function release(id) {
|
|
97
|
+
const record = projections.get(id);
|
|
98
|
+
return record ? releasePreparation(record) : Promise.resolve({id, state: 'released'});
|
|
99
|
+
}
|
|
100
|
+
|
|
94
101
|
async function failedOperation(record, error) {
|
|
95
102
|
record.error = error;
|
|
96
103
|
record.state = 'error';
|
|
@@ -165,19 +172,98 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
165
172
|
const member = record.members[memberIndex];
|
|
166
173
|
if (!member) throw new TypeError('Select an existing model asset member.');
|
|
167
174
|
try {
|
|
168
|
-
|
|
169
|
-
request.signal.throwIfAborted();
|
|
170
|
-
if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
|
|
171
|
-
await member.handle.appendFile(Buffer.from(contentBase64, 'base64'));
|
|
172
|
-
request.signal.throwIfAborted();
|
|
173
|
-
});
|
|
174
|
-
// File writes remain ordered; unrelated member files write independently.
|
|
175
|
-
member.tail = task.catch(function observeWriteFailure() {});
|
|
176
|
-
await task;
|
|
175
|
+
await appendMemberContent(record, member, Buffer.from(contentBase64, 'base64'), request.signal);
|
|
177
176
|
return {id, memberIndex, written: true};
|
|
178
177
|
} catch (error) { return failedOperation(record, error); }
|
|
179
178
|
}
|
|
180
179
|
|
|
180
|
+
function appendMemberContent(record, member, content, signal) {
|
|
181
|
+
const task = member.tail.then(async function appendOriginalContent() {
|
|
182
|
+
signal.throwIfAborted();
|
|
183
|
+
if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
|
|
184
|
+
await member.handle.appendFile(content);
|
|
185
|
+
signal.throwIfAborted();
|
|
186
|
+
});
|
|
187
|
+
// File writes remain ordered; unrelated member files write independently.
|
|
188
|
+
member.tail = task.catch(function observeWriteFailure() {});
|
|
189
|
+
return task;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function prepare({id, workingDirectory, members, signal, onProgress} = {}) {
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
195
|
+
const request = {signal: operationSignal};
|
|
196
|
+
let record;
|
|
197
|
+
let completed = 0;
|
|
198
|
+
let firstFailure;
|
|
199
|
+
|
|
200
|
+
async function progress(phase, memberIndex) {
|
|
201
|
+
operationSignal.throwIfAborted();
|
|
202
|
+
await onProgress?.({
|
|
203
|
+
phase, completed, total: members.length, unit: 'files',
|
|
204
|
+
...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})
|
|
205
|
+
});
|
|
206
|
+
operationSignal.throwIfAborted();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function downloadMember(member, memberIndex) {
|
|
210
|
+
try {
|
|
211
|
+
await progress('download', memberIndex);
|
|
212
|
+
const response = await fetch(member.url, {signal: operationSignal});
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
|
|
215
|
+
error.url = member.url;
|
|
216
|
+
error.status = response.status;
|
|
217
|
+
error.response = await response.text();
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
|
|
221
|
+
for await (const content of response.body) {
|
|
222
|
+
operationSignal.throwIfAborted();
|
|
223
|
+
await appendMemberContent(record, record.members[memberIndex], content, operationSignal);
|
|
224
|
+
await progress('download', memberIndex);
|
|
225
|
+
}
|
|
226
|
+
completed += 1;
|
|
227
|
+
await progress('download', memberIndex);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
firstFailure ??= error;
|
|
230
|
+
controller.abort(error);
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await openProjection({id, workingDirectory, members}, request);
|
|
237
|
+
record = requireProjection(id);
|
|
238
|
+
operationSignal.throwIfAborted();
|
|
239
|
+
if (!record.preparationOwned || closing) {
|
|
240
|
+
throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
|
|
241
|
+
}
|
|
242
|
+
record.downloadController = controller;
|
|
243
|
+
// Cleanup joins this transfer-only task. Its rejection handler below
|
|
244
|
+
// may release preparation without waiting on itself.
|
|
245
|
+
record.downloading = Promise.resolve().then(async function downloadMembers() {
|
|
246
|
+
await progress('open');
|
|
247
|
+
const results = await Promise.allSettled(members.map(downloadMember));
|
|
248
|
+
const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
|
|
249
|
+
.map(function reason(result) { return result.reason; });
|
|
250
|
+
if (errors.length > 1) {
|
|
251
|
+
throw new AggregateError(errors, 'Downloading model asset members failed.', {cause: firstFailure});
|
|
252
|
+
}
|
|
253
|
+
if (errors.length) throw errors[0];
|
|
254
|
+
});
|
|
255
|
+
await record.downloading;
|
|
256
|
+
await progress('complete');
|
|
257
|
+
const projection = await completeProjection({id}, request);
|
|
258
|
+
await progress('ready');
|
|
259
|
+
return projection;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
controller.abort(error);
|
|
262
|
+
if (record) return failedOperation(record, error);
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
181
267
|
async function completeProjection({id}, request) {
|
|
182
268
|
const record = requireProjection(id);
|
|
183
269
|
await record.opening;
|
|
@@ -245,7 +331,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
245
331
|
}
|
|
246
332
|
|
|
247
333
|
return {
|
|
248
|
-
name: 'model-assets', current, retain,
|
|
334
|
+
name: 'model-assets', current, prepare, retain, release,
|
|
249
335
|
start(serviceContext) { context = serviceContext; },
|
|
250
336
|
methods: {
|
|
251
337
|
'modelAssets.open': openProjection,
|
|
@@ -255,8 +341,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
|
|
|
255
341
|
'modelAssets.release': {
|
|
256
342
|
lifetime: 'service',
|
|
257
343
|
handle({id}) {
|
|
258
|
-
|
|
259
|
-
return record ? releasePreparation(record) : {id, state: 'released'};
|
|
344
|
+
return release(id);
|
|
260
345
|
}
|
|
261
346
|
}
|
|
262
347
|
},
|
package/src/git-identity.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import Is from 'strong-type';
|
|
|
3
3
|
import {ArcaneError, throwIfAborted} from './errors.mjs';
|
|
4
4
|
import {runProcess} from './process.mjs';
|
|
5
5
|
import {createEventQueue} from './event-queue.mjs';
|
|
6
|
+
import {readGitText} from './repository.mjs';
|
|
6
7
|
|
|
7
8
|
const is = new Is(false);
|
|
8
9
|
|
|
@@ -16,67 +17,30 @@ export async function readGitIdentity({directory, signal, onEvent, run = runProc
|
|
|
16
17
|
const args = ['config'];
|
|
17
18
|
if (scope !== 'effective') args.push(`--${scope}`);
|
|
18
19
|
args.push('--includes', '--null', '--get-regexp', '^(user[.](name|email)|github[.]user)$');
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
onOutput: function observeOutput({stream, chunk}) {
|
|
29
|
-
if (stream === 'stdout') chunks.push(chunk);
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
try { throwIfAborted(signal); }
|
|
33
|
-
catch (error) { error.details = result; throw error; }
|
|
34
|
-
const identity = {name: null, email: null, githubUser: null};
|
|
35
|
-
if (result.code === 1) return identity;
|
|
36
|
-
if (result.code !== 0) {
|
|
37
|
-
throw new ArcaneError('ARCANE_OPERATION_FAILED',
|
|
38
|
-
`Git ${scope} identity lookup exited with code ${String(result.code)}.`, {details: result});
|
|
39
|
-
}
|
|
40
|
-
let output;
|
|
41
|
-
try { output = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(Buffer.concat(chunks)); }
|
|
42
|
-
catch (cause) {
|
|
43
|
-
throw new ArcaneError('ARCANE_GIT_IDENTITY_NOT_TEXT',
|
|
44
|
-
`Git ${scope} identity configuration cannot be represented as UTF-8 text.`, {cause, details: result});
|
|
45
|
-
}
|
|
46
|
-
const fields = {'user.name': 'name', 'user.email': 'email', 'github.user': 'githubUser'};
|
|
47
|
-
// Git's NUL record framing preserves complete values, including newlines
|
|
48
|
-
// and empty strings. The last occurrence is the effective scalar value.
|
|
49
|
-
for (const record of output.split('\0')) {
|
|
50
|
-
if (record === '') continue;
|
|
51
|
-
const separator = record.indexOf('\n');
|
|
52
|
-
const key = separator === -1 ? record : record.slice(0, separator);
|
|
53
|
-
if (Object.hasOwn(fields, key)) {
|
|
54
|
-
identity[fields[key]] = separator === -1 ? '' : record.slice(separator + 1);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return identity;
|
|
58
|
-
} catch (error) {
|
|
59
|
-
// Keep the process error and its native details intact. Raw stdout
|
|
60
|
-
// belongs to this reader because the process owner did not decode it.
|
|
61
|
-
const rawStdout = Buffer.concat(chunks);
|
|
62
|
-
let attached = false;
|
|
63
|
-
let attachmentError;
|
|
64
|
-
try {
|
|
65
|
-
if (error !== null && (is.object(error) || is.function(error)) && !('rawStdout' in error)) {
|
|
66
|
-
Object.defineProperty(error, 'rawStdout', {
|
|
67
|
-
value: rawStdout, writable: true, enumerable: true, configurable: true
|
|
68
|
-
});
|
|
69
|
-
attached = true;
|
|
70
|
-
}
|
|
71
|
-
} catch (cause) { attachmentError = cause; }
|
|
72
|
-
if (attached) throw error;
|
|
73
|
-
const failure = new ArcaneError(error?.code ?? 'ARCANE_OPERATION_FAILED',
|
|
74
|
-
error instanceof Error ? error.message : String(error),
|
|
75
|
-
{cause: error, details: error?.details, exitCode: error?.exitCode});
|
|
20
|
+
const {result, output, rawStdout} = await readGitText(args, {
|
|
21
|
+
cwd, signal, onEvent: events.send, run, allowNonzero: true,
|
|
22
|
+
notTextCode: 'ARCANE_GIT_IDENTITY_NOT_TEXT', label: `Git ${scope} identity configuration`
|
|
23
|
+
});
|
|
24
|
+
const identity = {name: null, email: null, githubUser: null};
|
|
25
|
+
if (result.code === 1) return identity;
|
|
26
|
+
if (result.code !== 0) {
|
|
27
|
+
const failure = new ArcaneError('ARCANE_OPERATION_FAILED',
|
|
28
|
+
`Git ${scope} identity lookup exited with code ${String(result.code)}.`, {details: result});
|
|
76
29
|
failure.rawStdout = rawStdout;
|
|
77
|
-
if (attachmentError) failure.attachmentError = attachmentError;
|
|
78
30
|
throw failure;
|
|
79
31
|
}
|
|
32
|
+
const fields = {'user.name': 'name', 'user.email': 'email', 'github.user': 'githubUser'};
|
|
33
|
+
// Git's NUL record framing preserves complete values, including newlines
|
|
34
|
+
// and empty strings. The last occurrence is the effective scalar value.
|
|
35
|
+
for (const record of output.split('\0')) {
|
|
36
|
+
if (record === '') continue;
|
|
37
|
+
const separator = record.indexOf('\n');
|
|
38
|
+
const key = separator === -1 ? record : record.slice(0, separator);
|
|
39
|
+
if (Object.hasOwn(fields, key)) {
|
|
40
|
+
identity[fields[key]] = separator === -1 ? '' : record.slice(separator + 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return identity;
|
|
80
44
|
}
|
|
81
45
|
|
|
82
46
|
const scopes = cwd === undefined ? ['global'] : ['global', 'local', 'effective'];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {parentPort, workerData} from 'node:worker_threads';
|
|
3
|
+
import {Tokenizer} from '@huggingface/tokenizers';
|
|
4
|
+
import {createDecisionInputs, encodeDecisionRows} from '../../browser-runtime/ai/decision-runtime.mjs';
|
|
5
|
+
import {serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
6
|
+
|
|
7
|
+
// Parsing and tokenization run here, not on Core's request/rendering thread.
|
|
8
|
+
try {
|
|
9
|
+
const [tokenizerText, configurationText] = await Promise.all([
|
|
10
|
+
readFile(workerData.tokenizerPath, 'utf8'),
|
|
11
|
+
readFile(workerData.tokenizerConfigPath, 'utf8')
|
|
12
|
+
]);
|
|
13
|
+
const tokenizer = new Tokenizer(JSON.parse(tokenizerText), JSON.parse(configurationText));
|
|
14
|
+
const interfaceAdapter = {
|
|
15
|
+
encode(text, options) { return tokenizer.encode(text, options).ids; },
|
|
16
|
+
convert_tokens_to_ids(token) { return tokenizer.token_to_id(token); }
|
|
17
|
+
};
|
|
18
|
+
parentPort.on('message', function encodeRequest({id, rows}) {
|
|
19
|
+
try {
|
|
20
|
+
const {encoded, pad} = encodeDecisionRows(interfaceAdapter, rows, 'laya');
|
|
21
|
+
const inputs = createDecisionInputs(encoded, pad);
|
|
22
|
+
parentPort.postMessage({id, result: inputs});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
parentPort.postMessage({id, error: serializeCoreError(error)});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
parentPort.postMessage({id: 0, result: {ready: true}});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
parentPort.postMessage({id: 0, error: serializeCoreError(error)});
|
|
30
|
+
parentPort.close();
|
|
31
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {Worker} from 'node:worker_threads';
|
|
2
|
+
import {CoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
3
|
+
|
|
4
|
+
function cancelled() {
|
|
5
|
+
return new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'Decision tokenization was cancelled.'});
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** One tokenizer worker owned by one native model activation. */
|
|
9
|
+
export function createDecisionTokenizer({tokenizerPath, tokenizerConfigPath, signal, onError}) {
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
const pending = new Map();
|
|
12
|
+
let nextId = 0;
|
|
13
|
+
let closing;
|
|
14
|
+
let terminalError;
|
|
15
|
+
let exited = false;
|
|
16
|
+
const worker = new Worker(new URL('./decision-tokenizer-worker.mjs', import.meta.url), {
|
|
17
|
+
workerData: {tokenizerPath, tokenizerConfigPath}
|
|
18
|
+
});
|
|
19
|
+
let resolveExit;
|
|
20
|
+
const exit = new Promise(function tokenizerExit(resolve) { resolveExit = resolve; });
|
|
21
|
+
|
|
22
|
+
function rejectPending(error) {
|
|
23
|
+
terminalError ??= error;
|
|
24
|
+
for (const operation of pending.values()) operation.reject(error);
|
|
25
|
+
pending.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function operation(id) {
|
|
29
|
+
return new Promise(function retainOperation(resolve, reject) {
|
|
30
|
+
pending.set(id, {resolve, reject});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ready = operation(0);
|
|
35
|
+
// The native owner observes ready together with ONNX load. Retain an error
|
|
36
|
+
// handler immediately when the other owner is still starting.
|
|
37
|
+
ready.catch(function observeTokenizerLoadFailure() {});
|
|
38
|
+
worker.on('message', function tokenizerMessage(message) {
|
|
39
|
+
const request = pending.get(message.id);
|
|
40
|
+
if (!request) return;
|
|
41
|
+
pending.delete(message.id);
|
|
42
|
+
if (Object.hasOwn(message, 'error')) {
|
|
43
|
+
const error = new CoreError(message.error);
|
|
44
|
+
request.reject(error);
|
|
45
|
+
if (message.id === 0) failed(error);
|
|
46
|
+
}
|
|
47
|
+
else request.resolve(message.result);
|
|
48
|
+
});
|
|
49
|
+
worker.on('error', failed);
|
|
50
|
+
worker.on('messageerror', failed);
|
|
51
|
+
worker.once('exit', function tokenizerExited(code) {
|
|
52
|
+
exited = true;
|
|
53
|
+
signal?.removeEventListener('abort', abort);
|
|
54
|
+
const error = terminalError ?? new CoreError({
|
|
55
|
+
code: 'ARCANE_DECISION_TOKENIZER_CLOSED',
|
|
56
|
+
message: `The decision tokenizer worker exited with code ${code}.`
|
|
57
|
+
});
|
|
58
|
+
if (!closing) failed(error);
|
|
59
|
+
else rejectPending(error);
|
|
60
|
+
resolveExit(code);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function failed(error) {
|
|
64
|
+
const firstFailure = !terminalError && !closing;
|
|
65
|
+
rejectPending(error);
|
|
66
|
+
close(error).catch(function reportTokenizerFailure(value) { console.error(value); });
|
|
67
|
+
if (firstFailure) onError?.(error);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function close(reason = cancelled()) {
|
|
71
|
+
if (closing) return closing;
|
|
72
|
+
rejectPending(reason);
|
|
73
|
+
signal?.removeEventListener('abort', abort);
|
|
74
|
+
closing = Promise.resolve().then(async function releaseTokenizer() {
|
|
75
|
+
let terminationError;
|
|
76
|
+
try { if (!exited) await worker.terminate(); }
|
|
77
|
+
catch (error) {
|
|
78
|
+
terminationError = error;
|
|
79
|
+
console.error('Terminating the decision tokenizer failed.', error);
|
|
80
|
+
}
|
|
81
|
+
// A rejected termination request is not evidence that the worker
|
|
82
|
+
// has stopped reading its files. Retain ownership until real exit.
|
|
83
|
+
await exit;
|
|
84
|
+
if (terminationError) throw terminationError;
|
|
85
|
+
});
|
|
86
|
+
return closing;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function abort() {
|
|
90
|
+
close(signal.reason).catch(function reportTokenizerCloseFailure(error) { console.error(error); });
|
|
91
|
+
}
|
|
92
|
+
signal?.addEventListener('abort', abort, {once: true});
|
|
93
|
+
if (signal?.aborted) abort();
|
|
94
|
+
|
|
95
|
+
async function encode(rows) {
|
|
96
|
+
await ready;
|
|
97
|
+
if (terminalError || closing || exited) throw terminalError ?? cancelled();
|
|
98
|
+
const id = ++nextId;
|
|
99
|
+
const result = operation(id);
|
|
100
|
+
try { worker.postMessage({id, rows}); }
|
|
101
|
+
catch (error) {
|
|
102
|
+
pending.get(id).reject(error);
|
|
103
|
+
pending.delete(id);
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {ready, encode, close, get exited() { return exited; }};
|
|
109
|
+
}
|