arcane-os 0.56.0 → 0.58.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -2
  3. package/browser-runtime/ai/core-local.mjs +306 -0
  4. package/browser-runtime/ai/core-onnx.mjs +96 -0
  5. package/browser-runtime/ai/onnx-tensors.mjs +85 -0
  6. package/browser-runtime/core/client.mjs +151 -16
  7. package/docs/architecture.md +5 -2
  8. package/docs/platform-targets.md +30 -13
  9. package/docs/reference/README.md +38 -3
  10. package/docs/reference/availability-and-normalization.md +9 -6
  11. package/docs/reference/cli.md +29 -9
  12. package/docs/reference/core-client.md +80 -0
  13. package/docs/reference/core-native-packaging.md +304 -12
  14. package/docs/reference/core-preferences.md +49 -0
  15. package/docs/reference/diarization.md +206 -0
  16. package/docs/reference/git-text-snapshot.md +104 -0
  17. package/docs/reference/inventory/package-api.json +330 -22
  18. package/docs/reference/local-ai.md +331 -0
  19. package/docs/reference/protocols.md +97 -20
  20. package/docs/reference/pwa.md +5 -2
  21. package/docs/reference/runtime-modules.md +6 -5
  22. package/docs/reference/sdk-api.md +611 -63
  23. package/package.json +15 -3
  24. package/runtime/arcane/modules/LocalAIReadiness.js +3 -1
  25. package/schemas/arcane-app.schema.json +55 -1
  26. package/schemas/arcane-package.schema.json +44 -0
  27. package/schemas/native-build-plan.schema.json +27 -1
  28. package/schemas/target-adapter.schema.json +2 -0
  29. package/src/app-descriptor.mjs +18 -2
  30. package/src/cli/main.mjs +20 -4
  31. package/src/constants.mjs +2 -0
  32. package/src/core/host.mjs +23 -0
  33. package/src/core/hosts/macos/ArcaneCoreProcess.h +85 -0
  34. package/src/core/hosts/macos/ArcaneCoreProcess.m +357 -0
  35. package/src/core/hosts/macos/ArcaneHost.h +86 -0
  36. package/src/core/hosts/macos/ArcaneHost.m +455 -0
  37. package/src/core/hosts/macos/ArcaneLauncher.m +432 -0
  38. package/src/core/hosts/windows/ArcaneCoreProcess.cs +3 -3
  39. package/src/core/hosts/windows/ArcaneHost.cs +695 -0
  40. package/src/core/hosts/windows/ArcaneHost.manifest +22 -0
  41. package/src/core/hosts/windows/ArcaneLauncher.cs +239 -0
  42. package/src/core/services/local-ai.mjs +621 -0
  43. package/src/core/services/packaged-web.mjs +89 -0
  44. package/src/core/services/preferences.mjs +201 -0
  45. package/src/core-classic-source.mjs +2 -2
  46. package/src/dev-server.mjs +9 -5
  47. package/src/diarization/build.mjs +35 -0
  48. package/src/diarization/index.mjs +432 -0
  49. package/src/diarization/native/CMakeLists.txt +9 -0
  50. package/src/diarization/native/main.cpp +269 -0
  51. package/src/diarization/service.mjs +129 -0
  52. package/src/git-text-snapshot.mjs +265 -0
  53. package/src/import-map.mjs +2 -0
  54. package/src/index.mjs +1 -0
  55. package/src/local-ai/archive.mjs +52 -0
  56. package/src/local-ai/config.mjs +62 -0
  57. package/src/local-ai/dev.mjs +390 -0
  58. package/src/local-ai/discover.mjs +71 -0
  59. package/src/local-ai/http.mjs +106 -0
  60. package/src/local-ai/install.mjs +459 -0
  61. package/src/local-ai/onnx-worker.mjs +85 -0
  62. package/src/local-ai/onnx.mjs +368 -0
  63. package/src/local-ai/server.mjs +262 -0
  64. package/src/native/macos-provider.mjs +291 -0
  65. package/src/native/portable-layout.mjs +37 -4
  66. package/src/native/portable-provider.mjs +44 -8
  67. package/src/native/windows-provider.mjs +262 -0
  68. package/src/native-plan.mjs +9 -1
  69. package/src/native-provider-loader.mjs +10 -2
  70. package/src/packager/core.mjs +6 -2
  71. package/src/process.mjs +184 -20
  72. package/src/scaffold.mjs +1 -1
  73. package/src/targets/index.mjs +11 -1
  74. package/src/templates/workspace-template.mjs +6 -5
  75. package/src/toolchain.mjs +33 -5
@@ -0,0 +1,368 @@
1
+ import {Worker} from 'node:worker_threads';
2
+ import Is from 'strong-type';
3
+ import {createArcaneEventSource} from '../event-manager.mjs';
4
+ import {createEventQueue} from '../event-queue.mjs';
5
+
6
+ const is=new Is(false);
7
+
8
+ function failure(code,message,cause){
9
+ const error=new Error(message,{cause});
10
+ error.code=code;
11
+ return error;
12
+ }
13
+
14
+ function cancelled(reason){
15
+ const error=failure('ARCANE_CANCELLED','The ONNX operation was cancelled.',reason);
16
+ error.name='AbortError';
17
+ return error;
18
+ }
19
+
20
+ function describeError(error){
21
+ if(!(error instanceof Error))return {name:'Error',message:String(error),cause:error};
22
+ const result={...error,name:error.name,message:error.message,stack:error.stack};
23
+ if(error.cause!==undefined)result.cause=error.cause instanceof Error?describeError(error.cause):error.cause;
24
+ if(error instanceof AggregateError)result.errors=error.errors.map(describeError);
25
+ return result;
26
+ }
27
+
28
+ function restoreError(record){
29
+ return Object.assign(new Error(record.message,{cause:record.cause}),record);
30
+ }
31
+
32
+ function reportBackgroundError(error){
33
+ if(is.function(globalThis.reportError))globalThis.reportError(error);
34
+ else console.error(error);
35
+ }
36
+
37
+ function waitForRelease(task,signal){
38
+ if(!signal)return task;
39
+ if(signal.aborted)return Promise.reject(cancelled(signal.reason));
40
+ return new Promise(function joinRelease(resolve,reject){
41
+ function abort(){reject(cancelled(signal.reason));}
42
+ function complete(result){signal.removeEventListener('abort',abort);resolve(result);}
43
+ function failed(error){signal.removeEventListener('abort',abort);reject(error);}
44
+ signal.addEventListener('abort',abort,{once:true});
45
+ void task.then(complete,failed);
46
+ });
47
+ }
48
+
49
+ export function createONNXRuntime({modulePath,onEvent,signal}={}){
50
+ const owner={};
51
+ const events=createArcaneEventSource(owner,{source:'local-ai.onnx',eventTypes:['onnx.state']});
52
+ const diagnosticFailures=[];
53
+ const diagnostics=createEventQueue(deliverDiagnostic,{onFailure:reportBackgroundError});
54
+ const sessions=new Map();
55
+ let nextRequestId=0;
56
+ let closing=false;
57
+ let closed=false;
58
+ let closeTask;
59
+
60
+ async function deliverDiagnostic(event){
61
+ if(is.function(onEvent)){
62
+ try{await onEvent(event);return;}
63
+ catch(error){diagnosticFailures.push(error);reportBackgroundError(error);}
64
+ }
65
+ if(event.type==='local-ai.onnx.stdout'||event.type==='local-ai.onnx.stderr'){
66
+ process.stderr.write(event.message);
67
+ }
68
+ }
69
+
70
+ function captureOutput(entry,stream,type){
71
+ const ended=new Promise(function outputEnded(resolve){
72
+ stream.once('end',resolve);
73
+ stream.once('close',resolve);
74
+ stream.once('error',resolve);
75
+ });
76
+ stream.setEncoding('utf8');
77
+ stream.on('data',function workerOutput(content){
78
+ stream.pause();
79
+ void diagnostics.enqueue({type,message:content,data:{id:entry.state.id}}).then(
80
+ function outputDelivered(){stream.resume();},
81
+ function outputDeliveryFailed(error){
82
+ diagnosticFailures.push(error);
83
+ reportBackgroundError(error);
84
+ process.stderr.write(content);
85
+ stream.resume();
86
+ }
87
+ );
88
+ });
89
+ stream.on('error',function workerOutputError(error){
90
+ diagnosticFailures.push(error);
91
+ reportBackgroundError(error);
92
+ });
93
+ return ended;
94
+ }
95
+
96
+ function current(){
97
+ return {sessions:[...sessions.values()].map(function sessionState(entry){
98
+ return structuredClone(entry.state);
99
+ }),closed};
100
+ }
101
+
102
+ function publish(entry){
103
+ const state=current();
104
+ events.dispatch('onnx.state',state);
105
+ diagnostics.enqueue({
106
+ type:'local-ai.onnx.state',
107
+ message:entry?`ONNX session ${entry.state.id} is ${entry.state.state}.`:'The ONNX runtime is closed.',
108
+ data:entry?structuredClone(entry.state):state
109
+ });
110
+ }
111
+
112
+ function setState(entry,state,loaded,error=null){
113
+ entry.state={...entry.state,state,loaded,error:error?describeError(error):null};
114
+ publish(entry);
115
+ }
116
+
117
+ function subscribe(listener){
118
+ if(!is.function(listener))throw new TypeError('An ONNX state listener must be a function.');
119
+ if(closed){
120
+ listener(current());
121
+ return function unsubscribeClosedRuntime(){};
122
+ }
123
+ const unsubscribe=events.on('onnx.state',function deliverState(event){listener(event.detail);});
124
+ try{listener(current());}
125
+ catch(error){unsubscribe();throw error;}
126
+ return unsubscribe;
127
+ }
128
+
129
+ function assertOpen(operationSignal){
130
+ if(operationSignal?.aborted)throw cancelled(operationSignal.reason);
131
+ if(closing||closed)throw failure('LOCAL_AI_RUNTIME_CLOSED','The ONNX runtime is closing or closed.');
132
+ }
133
+
134
+ function settle(entry,operation,error,result){
135
+ if(operation.settled)return;
136
+ operation.settled=true;
137
+ operation.signal?.removeEventListener('abort',operation.cancel);
138
+ if(entry.active===operation)entry.active=null;
139
+ if(error)operation.reject(error);
140
+ else operation.resolve(result);
141
+ }
142
+
143
+ function rejectOperations(entry,error){
144
+ if(entry.active)settle(entry,entry.active,error);
145
+ for(const operation of entry.queue.splice(0))settle(entry,operation,error);
146
+ }
147
+
148
+ function observeStop(entry,reason,failed=false){
149
+ void stopSession(entry,reason,failed).catch(reportBackgroundError);
150
+ }
151
+
152
+ function stopSession(entry,reason,failed=false){
153
+ if(entry.stopTask)return entry.stopTask;
154
+ if(entry.exited)return Promise.resolve(structuredClone(entry.state));
155
+ const active=Boolean(entry.active);
156
+ const worker=entry.worker;
157
+ entry.stopping=true;
158
+ if(failed)entry.terminalError??=reason;
159
+ entry.stopTask=Promise.resolve().then(async function releaseSession(){
160
+ if(active||failed||entry.terminalError){
161
+ entry.terminationRequested=true;
162
+ await worker.terminate();
163
+ }else{
164
+ try{worker.postMessage({operation:'unload',requestId:++nextRequestId});}
165
+ catch(error){
166
+ entry.releaseError=error;
167
+ entry.terminationRequested=true;
168
+ await worker.terminate();
169
+ }
170
+ }
171
+ await entry.exit;
172
+ await Promise.all(entry.outputTasks);
173
+ if(entry.releaseError)throw entry.releaseError;
174
+ return structuredClone(entry.state);
175
+ });
176
+ // A native call has no AbortSignal. Stop delivery immediately, retain
177
+ // unloading state, and join actual worker exit before reporting release.
178
+ rejectOperations(entry,reason);
179
+ setState(entry,entry.terminalError?'error':'unloading',entry.state.loaded,entry.terminalError);
180
+ return entry.stopTask;
181
+ }
182
+
183
+ function dispatchNext(entry){
184
+ if(closing||entry.stopping||entry.exited||entry.active)return;
185
+ const operation=entry.queue.shift();
186
+ if(!operation)return;
187
+ if(operation.signal?.aborted){
188
+ settle(entry,operation,cancelled(operation.signal.reason));
189
+ dispatchNext(entry);
190
+ return;
191
+ }
192
+ entry.active=operation;
193
+ try{
194
+ entry.worker.postMessage({requestId:operation.requestId,operation:operation.kind,...operation.payload});
195
+ }catch(error){
196
+ if(operation.kind==='load')observeStop(entry,error,true);
197
+ else{
198
+ settle(entry,operation,error);
199
+ setState(entry,'error',entry.state.loaded,error);
200
+ dispatchNext(entry);
201
+ }
202
+ }
203
+ }
204
+
205
+ function enqueue(entry,kind,payload,operationSignal,deferred=false){
206
+ return new Promise(function queueOperation(resolve,reject){
207
+ const operation={kind,payload,signal:operationSignal,requestId:++nextRequestId,resolve,reject,settled:false,cancel};
208
+ function cancel(){
209
+ if(operation.settled)return;
210
+ const error=cancelled(operationSignal.reason);
211
+ if(entry.active===operation||kind==='load')observeStop(entry,error);
212
+ else{
213
+ const index=entry.queue.indexOf(operation);
214
+ if(index>=0)entry.queue.splice(index,1);
215
+ settle(entry,operation,error);
216
+ }
217
+ }
218
+ entry.queue.push(operation);
219
+ operationSignal?.addEventListener('abort',cancel,{once:true});
220
+ if(operationSignal?.aborted)cancel();
221
+ if(!deferred)dispatchNext(entry);
222
+ });
223
+ }
224
+
225
+ function receiveResult(entry,message){
226
+ if(message.operation==='unload'){
227
+ if(message.error)entry.releaseError=restoreError(message.error);
228
+ return;
229
+ }
230
+ if(closing||entry.stopping||entry.exited)return;
231
+ const operation=entry.active;
232
+ if(!operation||operation.requestId!==message.requestId)return;
233
+ if(operation.signal?.aborted){operation.cancel();return;}
234
+ if(message.error){
235
+ const error=restoreError(message.error);
236
+ if(operation.kind==='load')observeStop(entry,error,true);
237
+ else{
238
+ settle(entry,operation,error);
239
+ setState(entry,'error',true,error);
240
+ dispatchNext(entry);
241
+ }
242
+ return;
243
+ }
244
+ if(operation.kind==='load'){
245
+ entry.state={...entry.state,...message.result};
246
+ setState(entry,'ready',true);
247
+ }else if(entry.state.state==='error')setState(entry,'ready',true);
248
+ if(closing||entry.stopping||operation.settled)return;
249
+ settle(entry,operation,null,operation.kind==='load'?structuredClone(entry.state):message.result);
250
+ dispatchNext(entry);
251
+ }
252
+
253
+ function workerExited(entry,code){
254
+ entry.exited=true;
255
+ entry.worker=null;
256
+ if(!entry.stopping||(!entry.terminationRequested&&code!==0)){
257
+ entry.terminalError??=failure('LOCAL_AI_WORKER_EXITED',`The ONNX worker exited unexpectedly with code ${code}.`);
258
+ rejectOperations(entry,entry.terminalError);
259
+ }
260
+ const error=entry.releaseError??entry.terminalError;
261
+ try{setState(entry,error?'error':'unloaded',false,error);}
262
+ finally{entry.resolveExit(code);}
263
+ }
264
+
265
+ async function load({id,model,sessionOptions,signal:operationSignal}={}){
266
+ assertOpen(operationSignal);
267
+ if(!is.string(id)||!is.string(model))throw new TypeError('ONNX load requires a session id and model path.');
268
+ const previous=sessions.get(id);
269
+ if(previous?.stopTask){
270
+ try{await waitForRelease(previous.stopTask,operationSignal);}
271
+ catch(error){
272
+ // The previous operation retains its release failure. A new
273
+ // explicit load can proceed once its worker has actually exited.
274
+ if(operationSignal?.aborted||!previous.exited)throw error;
275
+ }
276
+ }
277
+ assertOpen(operationSignal);
278
+ if(sessions.get(id)!==previous){
279
+ throw failure('LOCAL_AI_SESSION_EXISTS',`ONNX session ${String(id)} was loaded by another request.`);
280
+ }
281
+ if(previous&&!previous.exited){
282
+ throw failure('LOCAL_AI_SESSION_EXISTS',`ONNX session ${String(id)} already exists. Unload it before loading a replacement.`);
283
+ }
284
+ const entry={
285
+ state:{id,model,state:'loading',loaded:false,error:null,inputNames:[],outputNames:[],inputMetadata:[],outputMetadata:[]},
286
+ worker:null,active:null,queue:[],stopping:false,exited:false,stopTask:null,terminalError:null,releaseError:null,terminationRequested:false,outputTasks:[]
287
+ };
288
+ entry.exit=new Promise(function workerExit(resolve){entry.resolveExit=resolve;});
289
+ sessions.set(id,entry);
290
+ try{
291
+ entry.worker=new Worker(new URL('./onnx-worker.mjs',import.meta.url),{
292
+ workerData:{modulePath},stdout:true,stderr:true
293
+ });
294
+ entry.outputTasks.push(
295
+ captureOutput(entry,entry.worker.stdout,'local-ai.onnx.stdout'),
296
+ captureOutput(entry,entry.worker.stderr,'local-ai.onnx.stderr')
297
+ );
298
+ entry.worker.on('message',function workerMessage(message){receiveResult(entry,message);});
299
+ entry.worker.on('messageerror',function workerMessageError(error){observeStop(entry,error,true);});
300
+ entry.worker.on('error',function workerError(error){
301
+ entry.terminalError=error;
302
+ entry.stopping=true;
303
+ rejectOperations(entry,error);
304
+ setState(entry,'error',entry.state.loaded,error);
305
+ });
306
+ entry.worker.once('exit',function workerExit(code){workerExited(entry,code);});
307
+ }catch(error){
308
+ entry.exited=true;
309
+ setState(entry,'error',false,error);
310
+ entry.resolveExit(null);
311
+ throw error;
312
+ }
313
+ // Queue load before publishing readiness so a synchronous subscriber
314
+ // can enqueue inference or request release without racing ownership.
315
+ const task=enqueue(entry,'load',{model,sessionOptions},operationSignal,true);
316
+ publish(entry);
317
+ dispatchNext(entry);
318
+ return task;
319
+ }
320
+
321
+ async function run({id,feeds,fetches,runOptions,signal:operationSignal}={}){
322
+ assertOpen(operationSignal);
323
+ const entry=sessions.get(id);
324
+ if(!entry||entry.exited||entry.stopping){
325
+ throw failure('LOCAL_AI_SESSION_UNAVAILABLE',`ONNX session ${String(id)} is not loaded or loading.`);
326
+ }
327
+ return enqueue(entry,'run',{feeds,fetches,runOptions},operationSignal);
328
+ }
329
+
330
+ async function unload({id,signal:operationSignal}={}){
331
+ if(operationSignal?.aborted)throw cancelled(operationSignal.reason);
332
+ const entry=sessions.get(id);
333
+ if(!entry)throw failure('LOCAL_AI_SESSION_UNAVAILABLE',`ONNX session ${String(id)} does not exist.`);
334
+ const result=await stopSession(entry,failure('LOCAL_AI_SESSION_UNLOADED',`ONNX session ${String(id)} is unloading.`));
335
+ if(operationSignal?.aborted)throw cancelled(operationSignal.reason);
336
+ return result;
337
+ }
338
+
339
+ function close(){
340
+ if(closeTask)return closeTask;
341
+ closing=true;
342
+ signal?.removeEventListener('abort',lifetimeAborted);
343
+ closeTask=Promise.resolve().then(async function closeRuntime(){
344
+ const results=await Promise.allSettled([...sessions.values()].map(function releaseEntry(entry){
345
+ return stopSession(entry,failure('LOCAL_AI_RUNTIME_CLOSED','The ONNX runtime is closing.'));
346
+ }));
347
+ await Promise.all([...sessions.values()].filter(function exited(entry){return entry.exited;})
348
+ .flatMap(function outputs(entry){return entry.outputTasks;}));
349
+ const failures=results.filter(function rejected(result){return result.status==='rejected';})
350
+ .map(function rejection(result){return result.reason;});
351
+ closed=[...sessions.values()].every(function exited(entry){return entry.exited;});
352
+ if(closed)publish();
353
+ try{await diagnostics.drain();}
354
+ catch(error){failures.push(error);}
355
+ failures.push(...diagnosticFailures);
356
+ if(closed)events.dispose();
357
+ if(failures.length)throw new AggregateError(failures,'The ONNX runtime closed with errors.');
358
+ return current();
359
+ });
360
+ return closeTask;
361
+ }
362
+
363
+ function lifetimeAborted(){void close().catch(reportBackgroundError);}
364
+
365
+ signal?.addEventListener('abort',lifetimeAborted,{once:true});
366
+ if(signal?.aborted)lifetimeAborted();
367
+ return {load,run,unload,current,subscribe,close};
368
+ }
@@ -0,0 +1,262 @@
1
+ import {mkdir} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import Is from 'strong-type';
4
+ import {ArcaneError,ERROR_CODES,errorRecord,normalizeError,throwIfAborted} from '../errors.mjs';
5
+ import {createEventQueue} from '../event-queue.mjs';
6
+ import {runProcess} from '../process.mjs';
7
+
8
+ const is=new Is(false);
9
+
10
+ export function localAIConnectionRefused(error){
11
+ if(error?.code==='ECONNREFUSED'||error?.cause?.code==='ECONNREFUSED')return true;
12
+ const failures=error?.errors??error?.cause?.errors;
13
+ return is.array(failures)&&failures.length>0&&failures.every(localAIConnectionRefused);
14
+ }
15
+
16
+ function localHostname(hostname){
17
+ return hostname==='localhost'||hostname==='127.0.0.1'||hostname==='[::1]'||hostname==='::1';
18
+ }
19
+
20
+ export function localAIServerURL(id,configuration={}){
21
+ return configuration.url??(id==='llama.cpp'?'http://127.0.0.1:8080':'http://127.0.0.1:11434');
22
+ }
23
+
24
+ export async function inspectLocalAIServer({id,url,signal}){
25
+ throwIfAborted(signal);
26
+ const endpoint=new URL(id==='llama.cpp'?'/health':'/api/version',url);
27
+ const response=await fetch(endpoint,{signal});
28
+ const content=await response.text();
29
+ throwIfAborted(signal);
30
+ let result=null;
31
+ try{
32
+ result=JSON.parse(content);
33
+ }catch(error){
34
+ if(!(error instanceof SyntaxError))throw error;
35
+ }
36
+ const ready=response.ok&&(id==='llama.cpp'?result?.status==='ok':is.string(result?.version)&&Boolean(result.version));
37
+ return {url,status:response.status,ready,version:id==='ollama'&&is.string(result?.version)?result.version:undefined,content,result};
38
+ }
39
+
40
+ async function probeServer(id,url,signal){
41
+ const observed=await inspectLocalAIServer({id,url,signal});
42
+ if(!observed.ready){
43
+ if(id==='llama.cpp'&&observed.status===503&&observed.result?.error?.message==='Loading model'){
44
+ throw new ArcaneError('LOCAL_AI_LOADING','llama.cpp is loading its model.',{details:observed});
45
+ }
46
+ throw new ArcaneError(ERROR_CODES.operationFailed,`${id} readiness returned HTTP ${observed.status}: ${observed.content}`,{details:observed});
47
+ }
48
+ return observed;
49
+ }
50
+
51
+ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,onEvent,onState}={}){
52
+ if(!['llama.cpp','ollama'].includes(id)){
53
+ throw new ArcaneError(ERROR_CODES.usage,`Unsupported local AI server: ${String(id)}.`);
54
+ }
55
+ const url=localAIServerURL(id,configuration);
56
+ const endpoint=new URL(url);
57
+ const controller=new AbortController();
58
+ const operationSignal=signal?AbortSignal.any([signal,controller.signal]):controller.signal;
59
+ const events=createEventQueue(onEvent,{onFailure:fail});
60
+ let current={id,url,state:'starting',available:false,owned:false};
61
+ let owned=false;
62
+ let processTask=null;
63
+ let processObservation=null;
64
+ let startupTask=null;
65
+ let closeTask=null;
66
+ let closing=false;
67
+ let settled=false;
68
+ let readinessStarted=false;
69
+ let readinessTask=null;
70
+ let terminalFailure=null;
71
+ let resolveReady;
72
+ let rejectReady;
73
+ const output={stdout:'',stderr:''};
74
+ const ready=new Promise(function createReadiness(resolve,reject){
75
+ resolveReady=resolve;
76
+ rejectReady=reject;
77
+ });
78
+ // Readiness remains returned to the caller; this observer prevents a
79
+ // background start from creating an unhandled rejection before it is used.
80
+ void ready.catch(function observeReadinessFailure(){return undefined;});
81
+
82
+ async function publishState(state,error){
83
+ current={id,url,state,available:state==='ready',owned,...(error?{error:errorRecord(error)}:{})};
84
+ await onState?.(current);
85
+ await events.send({
86
+ type:`local-ai.server.${state}`,
87
+ message:error?`${id}: ${error.message}`:`${id} is ${state}.`,
88
+ data:{...current,...(error?{error}:{})}
89
+ });
90
+ }
91
+
92
+ function settleReady(error){
93
+ if(settled)return;
94
+ settled=true;
95
+ if(error)rejectReady(error);
96
+ else resolveReady(current);
97
+ }
98
+
99
+ async function fail(error){
100
+ const failure=normalizeError(error);
101
+ terminalFailure??=failure;
102
+ controller.abort(failure);
103
+ settleReady(failure);
104
+ try{
105
+ const state=closing?'stopped':failure.code==='LOCAL_AI_LOADING'?'loading':'error';
106
+ await publishState(state,state==='error'?failure:undefined);
107
+ }catch(observerError){
108
+ // Keep the callback failure visible on the returned observation;
109
+ // the operation's original failure remains on the ready Promise.
110
+ current={...current,available:false,error:errorRecord(observerError)};
111
+ throw observerError;
112
+ }
113
+ }
114
+
115
+ async function confirmReadiness(){
116
+ await probeServer(id,url,operationSignal);
117
+ throwIfAborted(operationSignal);
118
+ await publishState('ready');
119
+ throwIfAborted(operationSignal);
120
+ settleReady();
121
+ }
122
+
123
+ async function forwardProcessEvent(event){
124
+ await events.send(event);
125
+ if(readinessStarted||closing||operationSignal.aborted)return;
126
+ if(event.type==='process.stdout')output.stdout+=event.data?.line??event.message;
127
+ else if(event.type==='process.stderr')output.stderr+=event.data?.line??event.message;
128
+ else return;
129
+ const text=event.type==='process.stdout'?output.stdout:output.stderr;
130
+ // llama.cpp b11146 and b6000 emit these after HTTP/model readiness;
131
+ // Ollama emits its listener announcement before serving queued requests.
132
+ const announced=id==='llama.cpp'?/\b(?:llama_server:\s*listening on |main:\s*server is listening on )/u.test(text):/Listening on /u.test(text);
133
+ if(!announced)return;
134
+ readinessStarted=true;
135
+ // Do not hold the process-output callback while the health response
136
+ // waits for upstream startup; stdout/stderr must continue draining.
137
+ readinessTask=confirmReadiness().catch(fail);
138
+ void readinessTask.catch(function observeReadinessCallbackFailure(){return undefined;});
139
+ }
140
+
141
+ async function start(){
142
+ await publishState('starting');
143
+ throwIfAborted(operationSignal);
144
+ let existing=false;
145
+ try{
146
+ // Discovery may precede another runtime's installation. Read the
147
+ // endpoint at this actual launch boundary rather than presenting
148
+ // that earlier installation-selection snapshot as current health.
149
+ const observed=await probeServer(id,url,operationSignal);
150
+ if(runtime?.requestedVersion&&runtime.requestedVersion!=='latest'){
151
+ const requested=runtime.requestedVersion.replace(/^v/u,'');
152
+ if(!is.string(observed.version)||observed.version.replace(/^v/u,'')!==requested){
153
+ throw new ArcaneError(ERROR_CODES.operationFailed,`The running ${id} service does not establish the requested version ${runtime.requestedVersion}. Select an available listener for that installed runtime.`,{details:observed});
154
+ }
155
+ }
156
+ existing=true;
157
+ }catch(error){
158
+ throwIfAborted(operationSignal);
159
+ // An HTTP error is an existing service's state. Only a refused
160
+ // connection allows starting the selected local executable.
161
+ if(!localAIConnectionRefused(error))throw error;
162
+ if(!localHostname(endpoint.hostname))throw error;
163
+ }
164
+ if(existing){
165
+ await publishState('ready');
166
+ throwIfAborted(operationSignal);
167
+ settleReady();
168
+ return;
169
+ }
170
+ if(!runtime?.executable){
171
+ throw new ArcaneError(ERROR_CODES.prerequisiteMissing,`No installed ${id} executable was supplied.`);
172
+ }
173
+ if(endpoint.protocol!=='http:'){
174
+ throw new ArcaneError(ERROR_CODES.usage,`${id} managed startup requires its direct HTTP listener URL.`);
175
+ }
176
+ if(!is.string(appRoot)||!appRoot){
177
+ throw new ArcaneError(ERROR_CODES.usage,`${id} managed startup needs its application root.`);
178
+ }
179
+ const args=configuration.args??[];
180
+ if(!is.array(args)||args.some(function nonStringArgument(argument){
181
+ return !is.string(argument);
182
+ })){
183
+ throw new ArcaneError(ERROR_CODES.usage,`${id} arguments must be an array of strings.`);
184
+ }
185
+ const modelsDirectory=path.resolve(appRoot,configuration.modelsDirectory??path.join('.arcane','models',id));
186
+ await mkdir(modelsDirectory,{recursive:true});
187
+ throwIfAborted(operationSignal);
188
+ const host=endpoint.hostname.replace(/^\[|\]$/gu,'');
189
+ const port=endpoint.port||'80';
190
+ const arguments_=id==='ollama'?['serve',...args]:[
191
+ '--host',host,'--port',port,'--jinja',
192
+ ...(configuration.model?['--model',path.resolve(appRoot,configuration.model)]:['--models-dir',modelsDirectory]),
193
+ ...args
194
+ ];
195
+ const env=id==='ollama'?{OLLAMA_HOST:endpoint.origin,OLLAMA_MODELS:modelsDirectory}:undefined;
196
+ owned=true;
197
+ await publishState('starting');
198
+ throwIfAborted(operationSignal);
199
+ processTask=runProcess(runtime.executable,arguments_,{
200
+ cwd:path.dirname(runtime.executable),env,signal:operationSignal,onEvent:forwardProcessEvent
201
+ });
202
+ processObservation=processTask.then(async function localServerExited(result){
203
+ if(terminalFailure)return;
204
+ if(closing||operationSignal.aborted){
205
+ settleReady(new ArcaneError(ERROR_CODES.cancelled,`${id} startup was cancelled.`,{exitCode:130}));
206
+ await publishState('stopped');
207
+ return;
208
+ }
209
+ await fail(new ArcaneError(ERROR_CODES.operationFailed,`${id} stopped with exit code ${result.code}.`,{details:result}));
210
+ },async function localServerFailed(error){
211
+ if(terminalFailure)return;
212
+ if(closing||(signal?.aborted&&!events.error)){
213
+ settleReady(normalizeError(error));
214
+ await publishState('stopped');
215
+ return;
216
+ }
217
+ await fail(error);
218
+ });
219
+ void processObservation.catch(function observeProcessCallbackFailure(error){
220
+ current={...current,state:'error',available:false,error:errorRecord(error)};
221
+ settleReady(error);
222
+ });
223
+ }
224
+
225
+ async function finishClose(){
226
+ closing=true;
227
+ operationSignal.removeEventListener('abort',observeLifetimeAbort);
228
+ controller.abort(new ArcaneError(ERROR_CODES.cancelled,`${id} was stopped by its owner.`,{exitCode:130}));
229
+ const startup=await Promise.allSettled([startupTask]);
230
+ const tasks=[processObservation,readinessTask].filter(function activeTask(task){return task!==null;});
231
+ const outcomes=await Promise.allSettled(tasks);
232
+ settleReady(new ArcaneError(ERROR_CODES.cancelled,`${id} startup was cancelled.`,{exitCode:130}));
233
+ await publishState('stopped');
234
+ await events.drain();
235
+ const failures=[...startup,...outcomes].filter(function failedTask(outcome){return outcome.status==='rejected';});
236
+ if(failures.length===1)throw failures[0].reason;
237
+ if(failures.length>1)throw new AggregateError(failures.map(function taskFailure(outcome){return outcome.reason;}),`${id} shutdown observers failed.`);
238
+ }
239
+
240
+ function close(){
241
+ closeTask??=finishClose();
242
+ return closeTask;
243
+ }
244
+
245
+ function observeLifetimeAbort(){
246
+ if(closing||terminalFailure)return;
247
+ void close().catch(function observeAbortShutdownFailure(error){
248
+ current={...current,state:'error',available:false,error:errorRecord(error)};
249
+ settleReady(error);
250
+ });
251
+ }
252
+
253
+ startupTask=start().catch(fail);
254
+ void startupTask.catch(function observeStartupCallbackFailure(error){
255
+ current={...current,state:'error',available:false,error:errorRecord(error)};
256
+ settleReady(error);
257
+ });
258
+ operationSignal.addEventListener('abort',observeLifetimeAbort,{once:true});
259
+ if(operationSignal.aborted)observeLifetimeAbort();
260
+
261
+ return {ready,url,close,get owned(){return owned;},get current(){return current;}};
262
+ }