arcane-os 0.56.0 → 0.57.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 (41) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/core-local.mjs +306 -0
  4. package/docs/reference/README.md +20 -0
  5. package/docs/reference/cli.md +9 -1
  6. package/docs/reference/core-native-packaging.md +195 -11
  7. package/docs/reference/inventory/package-api.json +138 -12
  8. package/docs/reference/local-ai.md +197 -0
  9. package/docs/reference/protocols.md +69 -1
  10. package/docs/reference/runtime-modules.md +6 -5
  11. package/docs/reference/sdk-api.md +165 -26
  12. package/package.json +5 -1
  13. package/runtime/arcane/modules/LocalAIReadiness.js +3 -1
  14. package/schemas/arcane-app.schema.json +54 -0
  15. package/schemas/arcane-package.schema.json +44 -0
  16. package/src/app-descriptor.mjs +17 -1
  17. package/src/cli/main.mjs +8 -2
  18. package/src/core/host.mjs +23 -0
  19. package/src/core/hosts/macos/ArcaneCoreProcess.h +85 -0
  20. package/src/core/hosts/macos/ArcaneCoreProcess.m +357 -0
  21. package/src/core/hosts/windows/ArcaneCoreProcess.cs +3 -3
  22. package/src/core/hosts/windows/ArcaneHost.cs +695 -0
  23. package/src/core/hosts/windows/ArcaneHost.manifest +22 -0
  24. package/src/core/hosts/windows/ArcaneLauncher.cs +239 -0
  25. package/src/core/services/local-ai.mjs +543 -0
  26. package/src/dev-server.mjs +7 -0
  27. package/src/import-map.mjs +1 -0
  28. package/src/local-ai/archive.mjs +52 -0
  29. package/src/local-ai/config.mjs +62 -0
  30. package/src/local-ai/dev.mjs +390 -0
  31. package/src/local-ai/discover.mjs +71 -0
  32. package/src/local-ai/http.mjs +106 -0
  33. package/src/local-ai/install.mjs +344 -0
  34. package/src/local-ai/server.mjs +262 -0
  35. package/src/native/portable-layout.mjs +29 -4
  36. package/src/native/portable-provider.mjs +39 -8
  37. package/src/native/windows-provider.mjs +262 -0
  38. package/src/native-provider-loader.mjs +8 -2
  39. package/src/packager/core.mjs +6 -2
  40. package/src/process.mjs +66 -8
  41. package/src/toolchain.mjs +33 -5
@@ -0,0 +1,344 @@
1
+ import Is from 'strong-type';
2
+ import {createWriteStream} from 'node:fs';
3
+ import {chmod,copyFile,lstat,mkdir,mkdtemp,readFile,readdir,readlink,rm,stat,symlink,unlink,writeFile} from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {Readable} from 'node:stream';
6
+ import {pipeline} from 'node:stream/promises';
7
+ import {ArcaneError,ERROR_CODES,normalizeError,throwIfAborted} from '../errors.mjs';
8
+ import {createEventQueue} from '../event-queue.mjs';
9
+ import {extractLocalAIArchive} from './archive.mjs';
10
+
11
+ const is=new Is(false);
12
+ const installations=new Map();
13
+
14
+ function selectRuntimes(runtimes){
15
+ if(!is.array(runtimes)){
16
+ throw new ArcaneError(ERROR_CODES.usage,'Local AI runtimes must be an array.');
17
+ }
18
+ const selected=new Map();
19
+ for(const runtime of runtimes){
20
+ if(!runtime||!['llama.cpp','ollama'].includes(runtime.id)){
21
+ throw new ArcaneError(ERROR_CODES.targetUnavailable,`Local AI runtime installation is unavailable for ${String(runtime?.id)}.`);
22
+ }
23
+ const prior=selected.get(runtime.id);
24
+ if(prior&&(prior.version!==runtime.version||prior.url!==runtime.url)){
25
+ throw new ArcaneError(ERROR_CODES.usage,`Select one ${runtime.id} runtime version and URL for this operation.`);
26
+ }
27
+ if(runtime.version!==undefined&&(!is.string(runtime.version)||!runtime.version)){
28
+ throw new ArcaneError(ERROR_CODES.usage,`${runtime.id} version must be a nonempty string.`);
29
+ }
30
+ if(runtime.url!==undefined&&(!is.string(runtime.url)||!runtime.url)){
31
+ throw new ArcaneError(ERROR_CODES.usage,`${runtime.id} URL must be a nonempty string.`);
32
+ }
33
+ selected.set(runtime.id,runtime);
34
+ }
35
+ return [...selected.values()];
36
+ }
37
+
38
+ function selectedDirectory(directory){
39
+ if(!is.string(directory)||!directory){
40
+ throw new ArcaneError(ERROR_CODES.usage,'Local AI installation needs an explicit directory.');
41
+ }
42
+ return path.resolve(directory);
43
+ }
44
+
45
+ function releaseAssetNames(id,version,platform,architecture){
46
+ if(id==='ollama'){
47
+ if(platform==='darwin'&&['x64','arm64'].includes(architecture))return ['ollama-darwin.tgz'];
48
+ const arch=architecture==='x64'?'amd64':architecture;
49
+ if(!['amd64','arm64'].includes(arch))return [];
50
+ if(platform==='win32')return [`ollama-windows-${arch}.zip`];
51
+ if(platform==='linux')return [`ollama-linux-${arch}.tar.zst`,`ollama-linux-${arch}.tgz`];
52
+ return [];
53
+ }
54
+ if(!['x64','arm64'].includes(architecture))return [];
55
+ if(platform==='win32')return [`llama-${version}-bin-win-cpu-${architecture}.zip`];
56
+ if(platform==='linux')return [`llama-${version}-bin-ubuntu-${architecture}.tar.gz`,`llama-${version}-bin-ubuntu-${architecture}.zip`];
57
+ if(platform==='darwin')return [`llama-${version}-bin-macos-${architecture}.tar.gz`,`llama-${version}-bin-macos-${architecture}.zip`];
58
+ if(platform==='android'&&architecture==='arm64')return [`llama-${version}-bin-android-arm64.tar.gz`];
59
+ return [];
60
+ }
61
+
62
+ async function upstreamResponse(url,signal){
63
+ throwIfAborted(signal);
64
+ const response=await fetch(url,{signal,headers:{'User-Agent':'arcane-os-local-ai'}});
65
+ if(!response.ok){
66
+ const body=await response.text();
67
+ throw new ArcaneError(ERROR_CODES.operationFailed,`Local AI download returned HTTP ${response.status}${body?`: ${body}`:''}.`);
68
+ }
69
+ return response;
70
+ }
71
+
72
+ async function selectRelease(runtime,platform,architecture,signal){
73
+ if(runtime.url){
74
+ const name=path.posix.basename(new URL(runtime.url).pathname);
75
+ return {version:runtime.version??'custom',url:runtime.url,name};
76
+ }
77
+ const repository=runtime.id==='llama.cpp'?'ggml-org/llama.cpp':'ollama/ollama';
78
+ const selected=runtime.version&&runtime.version!=='latest'
79
+ ?`tags/${encodeURIComponent(runtime.id==='ollama'&&!runtime.version.startsWith('v')?`v${runtime.version}`:runtime.version)}`
80
+ :'latest';
81
+ let release=await (await upstreamResponse(`https://api.github.com/repos/${repository}/releases/${selected}`,signal)).json();
82
+ if(runtime.id==='llama.cpp'){
83
+ // Stable llama.cpp releases identify their binary build explicitly.
84
+ const nightly=release.assets?.find(function isNightlySelection(asset){return asset.name==='nightly-tag.txt';});
85
+ if(nightly){
86
+ const tag=(await (await upstreamResponse(nightly.browser_download_url,signal)).text()).trim();
87
+ release=await (await upstreamResponse(`https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`,signal)).json();
88
+ }
89
+ }
90
+ // Select only published format variants for this exact target distribution.
91
+ const names=releaseAssetNames(runtime.id,release.tag_name,platform,architecture);
92
+ const asset=release.assets?.find(function isSelectedAsset(candidate){return names.includes(candidate.name);});
93
+ if(!asset){
94
+ throw new ArcaneError(ERROR_CODES.targetUnavailable,`${runtime.id} ${release.tag_name} has no published ${platform}/${architecture} runtime archive${names.length?` named ${names.join(' or ')}`:''}.`);
95
+ }
96
+ return {version:release.tag_name,url:asset.browser_download_url,name:asset.name};
97
+ }
98
+
99
+ async function installedRuntime(base,runtime,platform,architecture,signal){
100
+ let entries;
101
+ try{
102
+ entries=await readdir(base,{withFileTypes:true});
103
+ }catch(error){
104
+ if(error.code==='ENOENT')return null;
105
+ throw error;
106
+ }
107
+ for(const entry of entries){
108
+ throwIfAborted(signal);
109
+ if(!entry.isDirectory())continue;
110
+ let installation;
111
+ try{
112
+ installation=JSON.parse(await readFile(path.join(base,entry.name,'installation.json'),{encoding:'utf8',signal}));
113
+ }catch(error){
114
+ if(error.code==='ENOENT'||error instanceof SyntaxError)continue;
115
+ throw error;
116
+ }
117
+ if(installation.requestVersion!==(runtime.version??null)||installation.requestUrl!==(runtime.url??null))continue;
118
+ const record=installation.runtime;
119
+ if(record?.id!==runtime.id||record.platform!==platform||record.architecture!==architecture)continue;
120
+ try{
121
+ if((await stat(record.executable)).isFile())return {...record,...(runtime.version?{requestedVersion:runtime.version}:{})};
122
+ }catch(error){
123
+ if(error.code!=='ENOENT')throw error;
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ async function runtimeExecutable(root,id,platform,signal){
130
+ const filename=`${id==='llama.cpp'?'llama-server':'ollama'}${platform==='win32'?'.exe':''}`;
131
+ const directories=[root];
132
+ for(const directory of directories){
133
+ throwIfAborted(signal);
134
+ const entries=await readdir(directory,{withFileTypes:true});
135
+ for(const entry of entries){
136
+ const selected=path.join(directory,entry.name);
137
+ if(entry.name===filename&&(entry.isFile()||entry.isSymbolicLink()))return selected;
138
+ if(entry.isDirectory())directories.push(selected);
139
+ }
140
+ }
141
+ throw new ArcaneError(ERROR_CODES.operationFailed,`The ${id} archive does not contain ${filename}.`);
142
+ }
143
+
144
+ async function installRuntime(runtime,{directory,platform,architecture,signal,onEvent}){
145
+ const base=path.join(directory,runtime.id,`${platform}-${architecture}`,encodeURIComponent(runtime.version??'default'));
146
+ await onEvent({type:'local-ai.install.starting',message:`Preparing ${runtime.id} for ${platform}/${architecture}.`,data:{id:runtime.id,platform,architecture}});
147
+ const installed=await installedRuntime(base,runtime,platform,architecture,signal);
148
+ if(installed){
149
+ await onEvent({type:'local-ai.install.available',message:`${runtime.id} ${installed.version} is already installed.`,data:installed});
150
+ return installed;
151
+ }
152
+ const release=await selectRelease(runtime,platform,architecture,signal);
153
+ throwIfAborted(signal);
154
+ await mkdir(base,{recursive:true});
155
+ const attempt=await mkdtemp(path.join(base,'install-'));
156
+ const root=path.join(attempt,'runtime');
157
+ const archive=path.join(attempt,release.name);
158
+ let completed=false;
159
+ try{
160
+ await onEvent({type:'local-ai.install.downloading',message:`Downloading ${runtime.id} ${release.version}.`,data:{id:runtime.id,version:release.version,platform,architecture}});
161
+ const response=await upstreamResponse(release.url,signal);
162
+ await pipeline(Readable.fromWeb(response.body),createWriteStream(archive,{flags:'wx'}),{signal});
163
+ await onEvent({type:'local-ai.install.extracting',message:`Extracting ${runtime.id} ${release.version}.`,data:{id:runtime.id,version:release.version}});
164
+ await extractLocalAIArchive({archive,directory:root,signal,onEvent});
165
+ const executable=await runtimeExecutable(root,runtime.id,platform,signal);
166
+ const record={id:runtime.id,version:release.version,platform,architecture,root,executable,...(runtime.version?{requestedVersion:runtime.version}:{})};
167
+ throwIfAborted(signal);
168
+ await unlink(archive);
169
+ await writeFile(path.join(attempt,'installation.json'),`${JSON.stringify({requestVersion:runtime.version??null,requestUrl:runtime.url??null,runtime:record},null,2)}\n`,{flag:'wx',signal});
170
+ completed=true;
171
+ await onEvent({type:'local-ai.install.completed',message:`${runtime.id} ${release.version} is available.`,data:record});
172
+ return record;
173
+ }catch(error){
174
+ // Only this invocation's newly created incomplete attempt is removed.
175
+ // Previously installed runtimes are never overwritten or retired here.
176
+ if(!completed){
177
+ try{
178
+ await rm(attempt,{recursive:true,force:true});
179
+ }catch(cleanupError){
180
+ throw new AggregateError([error,cleanupError],`Installation of ${runtime.id} failed and its incomplete directory could not be removed.`);
181
+ }
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ function shareInstallation(runtime,options){
188
+ throwIfAborted(options.signal);
189
+ const key=JSON.stringify([options.directory,options.platform,options.architecture,runtime.id,runtime.version??null,runtime.url??null]);
190
+ let entry=installations.get(key);
191
+ if(entry?.controller.signal.aborted){
192
+ // The prior cancelled attempt owns its cleanup. A later request waits
193
+ // for that narrow shared destination operation before starting afresh.
194
+ return entry.task.catch(function observeCancelledAttempt(){return undefined;}).then(function restartAfterCancellation(){
195
+ return shareInstallation(runtime,options);
196
+ });
197
+ }
198
+ if(!entry){
199
+ entry={controller:new AbortController(),consumers:new Set(),task:null};
200
+ installations.set(key,entry);
201
+ }
202
+ const current=entry;
203
+ return new Promise(function subscribeInstallation(resolve,reject){
204
+ let settled=false;
205
+ const consumer={onEvent:options.onEvent,finish,cancel,cancelError:null};
206
+ function finish(error,value){
207
+ if(settled)return;
208
+ settled=true;
209
+ options.signal?.removeEventListener('abort',abort);
210
+ current.consumers.delete(consumer);
211
+ if(current.consumers.size===0&&!current.controller.signal.aborted){
212
+ current.controller.abort(error);
213
+ }
214
+ if(consumer.cancelError)reject(consumer.cancelError);
215
+ else if(error)reject(error);
216
+ else resolve(value);
217
+ }
218
+ function abort(){
219
+ cancel(new ArcaneError(ERROR_CODES.cancelled,`Cancelled preparation of ${runtime.id}.`,{cause:options.signal?.reason,exitCode:130}));
220
+ }
221
+ function cancel(error){
222
+ if(settled)return;
223
+ consumer.cancelError??=error;
224
+ const hasOtherConsumer=[...current.consumers].some(function stillNeedsInstallation(subscriber){
225
+ return subscriber!==consumer&&!subscriber.cancelError;
226
+ });
227
+ if(hasOtherConsumer){
228
+ finish(consumer.cancelError);
229
+ }else{
230
+ // The last consumer stays joined until the download, extractor
231
+ // and incomplete-output cleanup have actually stopped.
232
+ current.controller.abort(consumer.cancelError);
233
+ }
234
+ }
235
+ async function publish(event){
236
+ await Promise.all([...current.consumers].map(async function sendConsumerEvent(subscriber){
237
+ if(subscriber.cancelError)return;
238
+ try{
239
+ await subscriber.onEvent(event);
240
+ }catch(error){
241
+ subscriber.cancel(error);
242
+ }
243
+ }));
244
+ throwIfAborted(current.controller.signal);
245
+ }
246
+ current.consumers.add(consumer);
247
+ options.signal?.addEventListener('abort',abort,{once:true});
248
+ if(options.signal?.aborted)abort();
249
+ if(!current.task){
250
+ current.task=installRuntime(runtime,{...options,signal:current.controller.signal,onEvent:publish});
251
+ void current.task.then(function installationCompleted(record){
252
+ installations.delete(key);
253
+ for(const subscriber of [...current.consumers])subscriber.finish(null,record);
254
+ },function installationFailed(error){
255
+ installations.delete(key);
256
+ for(const subscriber of [...current.consumers])subscriber.finish(normalizeError(error));
257
+ });
258
+ }
259
+ });
260
+ }
261
+
262
+ export async function ensureLocalAIRuntimes({runtimes=[],directory,platform=process.platform,architecture=process.arch,signal,onEvent}={}){
263
+ const selected=selectRuntimes(runtimes);
264
+ if(selected.length===0)return [];
265
+ const root=selectedDirectory(directory);
266
+ const controller=new AbortController();
267
+ const operationSignal=signal?AbortSignal.any([signal,controller.signal]):controller.signal;
268
+ const events=createEventQueue(onEvent,{onFailure:function cancelFailedObserver(error){controller.abort(error);}});
269
+ const startedAt=Date.now();
270
+ const heartbeat=setInterval(function reportInstallHeartbeat(){
271
+ void events.enqueue({type:'local-ai.install.heartbeat',message:'Local AI runtime preparation is active.',data:{elapsedMs:Date.now()-startedAt}},{coalesce:'local-ai.install.heartbeat'});
272
+ },5000);
273
+ heartbeat.unref?.();
274
+ try{
275
+ // There is one job per selected runtime, at most one llama.cpp and one
276
+ // Ollama job. Independent runtimes download and extract concurrently.
277
+ const outcomes=await Promise.allSettled(selected.map(function prepareRuntime(runtime){
278
+ return shareInstallation(runtime,{directory:root,platform,architecture,signal:operationSignal,onEvent:events.send});
279
+ }));
280
+ await events.drain();
281
+ const failures=outcomes.filter(function failedRuntime(outcome){return outcome.status==='rejected';});
282
+ if(failures.length===1)throw failures[0].reason;
283
+ if(failures.length>1)throw new AggregateError(failures.map(function runtimeFailure(outcome){return outcome.reason;}),'Local AI runtime preparation failed.');
284
+ throwIfAborted(operationSignal);
285
+ return outcomes.map(function completedRuntime(outcome){return outcome.value;});
286
+ }finally{
287
+ clearInterval(heartbeat);
288
+ await events.drain();
289
+ }
290
+ }
291
+
292
+ async function copyRuntimeTree(source,destination,outputRoot,files,signal){
293
+ throwIfAborted(signal);
294
+ const info=await lstat(source);
295
+ if(info.isDirectory()){
296
+ await mkdir(destination);
297
+ for(const entry of await readdir(source)){
298
+ await copyRuntimeTree(path.join(source,entry),path.join(destination,entry),outputRoot,files,signal);
299
+ }
300
+ await chmod(destination,info.mode);
301
+ return;
302
+ }
303
+ if(info.isSymbolicLink()){
304
+ await symlink(await readlink(source),destination);
305
+ }else{
306
+ await copyFile(source,destination);
307
+ await chmod(destination,info.mode);
308
+ }
309
+ files.push(path.relative(outputRoot,destination).split(path.sep).join('/'));
310
+ }
311
+
312
+ export async function bundleLocalAIRuntimes({runtimes=[],directory,outputRoot,platform=process.platform,architecture=process.arch,signal,onEvent}={}){
313
+ const selected=selectRuntimes(runtimes);
314
+ const root=selectedDirectory(outputRoot);
315
+ for(const runtime of selected){
316
+ const destination=path.join(root,'runtime','local-ai',runtime.id);
317
+ try{
318
+ await lstat(destination);
319
+ }catch(error){
320
+ if(error.code==='ENOENT')continue;
321
+ throw error;
322
+ }
323
+ throw new ArcaneError(ERROR_CODES.operationFailed,`Local AI bundle destination already exists: ${destination}. Select a fresh native staging directory.`);
324
+ }
325
+ const installed=await ensureLocalAIRuntimes({runtimes:selected,directory,platform,architecture,signal,onEvent});
326
+ if(installed.length===0)return {runtimes:[],files:[]};
327
+ await onEvent?.({type:'local-ai.bundle.starting',message:'Copying selected local AI runtimes into the native bundle.',data:{platform,architecture,runtimes:selected.map(function runtimeId(runtime){return runtime.id;})}});
328
+ await mkdir(path.join(root,'runtime','local-ai'),{recursive:true});
329
+ const outcomes=await Promise.allSettled(installed.map(async function bundleRuntime(runtime){
330
+ throwIfAborted(signal);
331
+ const files=[];
332
+ const relative=`runtime/local-ai/${runtime.id}`;
333
+ const destination=path.join(root,relative);
334
+ await copyRuntimeTree(runtime.root,destination,root,files,signal);
335
+ return {runtime:{...runtime,root:relative,executable:`${relative}/${path.relative(runtime.root,runtime.executable).split(path.sep).join('/')}`},files};
336
+ }));
337
+ const failures=outcomes.filter(function failedBundle(outcome){return outcome.status==='rejected';});
338
+ if(failures.length===1)throw failures[0].reason;
339
+ if(failures.length>1)throw new AggregateError(failures.map(function bundleFailure(outcome){return outcome.reason;}),'Local AI runtime bundling failed.');
340
+ const records=outcomes.map(function bundledRecord(outcome){return outcome.value.runtime;});
341
+ const files=outcomes.flatMap(function bundledFiles(outcome){return outcome.value.files;});
342
+ await onEvent?.({type:'local-ai.bundle.completed',message:'Selected local AI runtimes are included in the native bundle.',data:{platform,architecture,files}});
343
+ return {runtimes:records,files};
344
+ }
@@ -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
+ }
@@ -149,20 +149,45 @@ export async function copyCoreRuntime(root, files, signal, {
149
149
  return metadata;
150
150
  }
151
151
 
152
- export function coreEntrySource(application, version, services) {
152
+ export function coreEntrySource(application, version, services, {localAI, runtimes = []} = {}) {
153
153
  const imports = services.map(function serviceImport(service, index) {
154
154
  const specifier = '../app/' + service.module.split('/').map(encodeURIComponent).join('/');
155
155
  return `import createService${index} from ${JSON.stringify(specifier)};`;
156
156
  });
157
+ // JSON parsing retains authored field names that have object-literal syntax.
157
158
  const definitions = services.map(function serviceDefinition(service, index) {
158
- return ` createService${index}(${JSON.stringify(service.options === undefined ? {} : service.options)})`;
159
+ return ` createService${index}(JSON.parse(${JSON.stringify(JSON.stringify(service.options === undefined ? {} : service.options))}), context)`;
159
160
  });
161
+ if (localAI !== undefined) {
162
+ imports.push("import {createLocalAIService} from 'arcane-os/core/local-ai';");
163
+ definitions.push(` createLocalAIService(JSON.parse(${JSON.stringify(JSON.stringify(localAI))}), context)`);
164
+ }
160
165
  return [
161
- "import {startCoreHost} from 'arcane-os/core/host';",
166
+ "import {fileURLToPath} from 'node:url';",
167
+ "import {readCoreLaunchContext, startCoreHost} from 'arcane-os/core/host';",
162
168
  ...imports,
163
169
  '',
170
+ ...(localAI === undefined ? [] : [
171
+ '// Bundled runtime paths are artifact-root-relative POSIX paths.',
172
+ 'function runtimePath(relative) {',
173
+ " return fileURLToPath(new URL('../' + relative.split('/').map(encodeURIComponent).join('/'), import.meta.url));",
174
+ '}',
175
+ ''
176
+ ]),
177
+ '// Factories share launch context as their second argument; authored options stay first.',
178
+ '// Explicit launch fields override artifact-relative defaults unchanged.',
179
+ 'const context = {',
180
+ " appRoot: fileURLToPath(new URL('../app/', import.meta.url)),",
181
+ ...(localAI === undefined ? [] : [
182
+ ` runtimes: JSON.parse(${JSON.stringify(JSON.stringify(runtimes))}).map(function runtimeLocation(runtime) {`,
183
+ ' return {...runtime, root: runtimePath(runtime.root), executable: runtimePath(runtime.executable)};',
184
+ ' }),'
185
+ ]),
186
+ ' ...await readCoreLaunchContext()',
187
+ '};',
188
+ '',
164
189
  'const host = startCoreHost({',
165
- ` application: ${JSON.stringify(application)},`,
190
+ ` application: JSON.parse(${JSON.stringify(JSON.stringify(application))}),`,
166
191
  ` version: ${JSON.stringify(version)},`,
167
192
  ` services: [\n${definitions.join(',\n')}\n ]`,
168
193
  '});',