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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-local.mjs +306 -0
- package/docs/reference/README.md +20 -0
- package/docs/reference/cli.md +9 -1
- package/docs/reference/core-native-packaging.md +195 -11
- package/docs/reference/inventory/package-api.json +138 -12
- package/docs/reference/local-ai.md +197 -0
- package/docs/reference/protocols.md +69 -1
- package/docs/reference/runtime-modules.md +6 -5
- package/docs/reference/sdk-api.md +165 -26
- package/package.json +5 -1
- package/runtime/arcane/modules/LocalAIReadiness.js +3 -1
- package/schemas/arcane-app.schema.json +54 -0
- package/schemas/arcane-package.schema.json +44 -0
- package/src/app-descriptor.mjs +17 -1
- package/src/cli/main.mjs +8 -2
- package/src/core/host.mjs +23 -0
- package/src/core/hosts/macos/ArcaneCoreProcess.h +85 -0
- package/src/core/hosts/macos/ArcaneCoreProcess.m +357 -0
- package/src/core/hosts/windows/ArcaneCoreProcess.cs +3 -3
- package/src/core/hosts/windows/ArcaneHost.cs +695 -0
- package/src/core/hosts/windows/ArcaneHost.manifest +22 -0
- package/src/core/hosts/windows/ArcaneLauncher.cs +239 -0
- package/src/core/services/local-ai.mjs +543 -0
- package/src/dev-server.mjs +7 -0
- package/src/import-map.mjs +1 -0
- package/src/local-ai/archive.mjs +52 -0
- package/src/local-ai/config.mjs +62 -0
- package/src/local-ai/dev.mjs +390 -0
- package/src/local-ai/discover.mjs +71 -0
- package/src/local-ai/http.mjs +106 -0
- package/src/local-ai/install.mjs +344 -0
- package/src/local-ai/server.mjs +262 -0
- package/src/native/portable-layout.mjs +29 -4
- package/src/native/portable-provider.mjs +39 -8
- package/src/native/windows-provider.mjs +262 -0
- package/src/native-provider-loader.mjs +8 -2
- package/src/packager/core.mjs +6 -2
- package/src/process.mjs +66 -8
- package/src/toolchain.mjs +33 -5
|
@@ -61,7 +61,7 @@ function serviceSelection(services, release) {
|
|
|
61
61
|
if (!is.string(service?.module) || !release.files.includes(service.module)) {
|
|
62
62
|
throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The selected release does not contain service module ${String(service?.module)}.`);
|
|
63
63
|
}
|
|
64
|
-
return {
|
|
64
|
+
return {...service, options: service.options === undefined ? {} : service.options};
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -81,7 +81,7 @@ function eventOwnerPath(release) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/** Package-owned assembly; never imports an OS checkout or application service. */
|
|
84
|
-
export function createPortableNativeProvider({services
|
|
84
|
+
export function createPortableNativeProvider({services} = {}) {
|
|
85
85
|
return {
|
|
86
86
|
protocol: PROTOCOL,
|
|
87
87
|
async describe() {
|
|
@@ -120,7 +120,8 @@ export function createPortableNativeProvider({services = []} = {}) {
|
|
|
120
120
|
const {appDescriptor, release, signal, onEvent} = request;
|
|
121
121
|
throwIfAborted(signal);
|
|
122
122
|
const target = portableTarget(request.targetRequest);
|
|
123
|
-
const
|
|
123
|
+
const localAI = appDescriptor.native?.localAI;
|
|
124
|
+
const selectedServices = serviceSelection(services ?? appDescriptor.native?.services ?? [], release);
|
|
124
125
|
const eventOwnerModuleURL = eventOwnerPath(release);
|
|
125
126
|
const output = await outputDirectory(request);
|
|
126
127
|
throwIfAborted(signal);
|
|
@@ -130,7 +131,33 @@ export function createPortableNativeProvider({services = []} = {}) {
|
|
|
130
131
|
const files = [];
|
|
131
132
|
try {
|
|
132
133
|
await emit(onEvent, {type: 'native.payload.started', appId: appDescriptor.id, outputRoot: root});
|
|
133
|
-
|
|
134
|
+
async function bundleSelectedRuntimes() {
|
|
135
|
+
if (localAI === undefined) return {runtimes: [], files: []};
|
|
136
|
+
const {bundleLocalAIRuntimes} = await import('../local-ai/install.mjs');
|
|
137
|
+
return bundleLocalAIRuntimes({
|
|
138
|
+
runtimes: localAI.runtimes,
|
|
139
|
+
directory: path.join(output, 'local-ai-runtimes'),
|
|
140
|
+
outputRoot: root,
|
|
141
|
+
platform: target.platform === 'windows' ? 'win32' : target.platform,
|
|
142
|
+
architecture: target.architecture,
|
|
143
|
+
signal,
|
|
144
|
+
onEvent
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// These outputs have separate directories. Join both owners
|
|
148
|
+
// before cleanup so neither can keep writing a removed assembly.
|
|
149
|
+
const preparation = await Promise.allSettled([
|
|
150
|
+
copyRelease({...request, releaseRoot: request.appReleaseRoot}, 'app', root, files, signal),
|
|
151
|
+
bundleSelectedRuntimes()
|
|
152
|
+
]);
|
|
153
|
+
const failures = preparation.filter(function failedPreparation(result) { return result.status === 'rejected'; });
|
|
154
|
+
if (failures.length === 1) throw failures[0].reason;
|
|
155
|
+
if (failures.length > 1) {
|
|
156
|
+
throw new AggregateError(failures.map(function preparationError(result) { return result.reason; }),
|
|
157
|
+
'Portable application and local runtime preparation failed.');
|
|
158
|
+
}
|
|
159
|
+
const bundled = preparation[1].value;
|
|
160
|
+
files.push(...bundled.files);
|
|
134
161
|
const dependencies = [];
|
|
135
162
|
for (const [index, dependency] of (request.dependencies ?? []).entries()) {
|
|
136
163
|
const directory = `dependencies/${index}`;
|
|
@@ -155,8 +182,9 @@ export function createPortableNativeProvider({services = []} = {}) {
|
|
|
155
182
|
appSdk = {name: metadata.name, version: metadata.version, root: destination};
|
|
156
183
|
}
|
|
157
184
|
await writeOutput(root, 'package.json', '{"type":"module"}\n', files, signal);
|
|
158
|
-
await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices
|
|
159
|
-
|
|
185
|
+
await writeOutput(root, 'runtime/arcane-core.mjs', coreEntrySource(appDescriptor, sdk.version, selectedServices,
|
|
186
|
+
{localAI, runtimes: bundled.runtimes}), files, signal);
|
|
187
|
+
await writeOutput(root, 'runtime/arcane-api.js', await createCoreClassicSource({eventOwnerModuleURL, replayRuntimeState: true}), files, signal);
|
|
160
188
|
const manifest = {
|
|
161
189
|
schemaVersion: 1,
|
|
162
190
|
kind: 'arcane-portable-native',
|
|
@@ -166,8 +194,11 @@ export function createPortableNativeProvider({services = []} = {}) {
|
|
|
166
194
|
target,
|
|
167
195
|
webRoot: 'app',
|
|
168
196
|
start: release.manifest?.app?.start ?? './index.html',
|
|
169
|
-
core: {
|
|
170
|
-
|
|
197
|
+
core: {
|
|
198
|
+
entry: 'runtime/arcane-core.mjs', transport: 'stdio', protocol: 'arcane/1', services: selectedServices,
|
|
199
|
+
...(localAI === undefined ? {} : {localAIRuntimes: bundled.runtimes})
|
|
200
|
+
},
|
|
201
|
+
client: {source: 'runtime/arcane-api.js', injection: 'document-created', eventOwnerModuleURL, replayRuntimeState: true},
|
|
171
202
|
dependencies,
|
|
172
203
|
minimumCoreVersion: request.minimumCoreVersion ?? null,
|
|
173
204
|
files: [...files, MANIFEST]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {createWriteStream} from 'node:fs';
|
|
2
|
+
import {mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {Readable} from 'node:stream';
|
|
5
|
+
import {pipeline} from 'node:stream/promises';
|
|
6
|
+
import Is from 'strong-type';
|
|
7
|
+
import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
|
|
8
|
+
import {runProcess} from '../process.mjs';
|
|
9
|
+
import {createPortableNativeProvider} from './portable-provider.mjs';
|
|
10
|
+
import {writeOutput} from './portable-layout.mjs';
|
|
11
|
+
|
|
12
|
+
const is = new Is(false);
|
|
13
|
+
const PROTOCOL = 'arcane-native-builder/1';
|
|
14
|
+
const MANIFEST = 'arcane-native.json';
|
|
15
|
+
const HOST_ASSET = 'arcane-native-windows-x64.tar.gz';
|
|
16
|
+
const HOST_FILES = [
|
|
17
|
+
'Arcane.exe',
|
|
18
|
+
'Arcane.exe.config',
|
|
19
|
+
'Microsoft.Web.WebView2.Core.dll',
|
|
20
|
+
'Microsoft.Web.WebView2.WinForms.dll',
|
|
21
|
+
'WebView2Loader.dll',
|
|
22
|
+
'runtime/ArcaneCore.exe',
|
|
23
|
+
'runtime/arcane-core-loader.cjs',
|
|
24
|
+
'runtime/NODE-LICENSE',
|
|
25
|
+
'WEBVIEW2-LICENSE',
|
|
26
|
+
'LICENSE',
|
|
27
|
+
'COMMERCIAL-LICENSE.md',
|
|
28
|
+
'NOTICE'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function windowsTarget(value) {
|
|
32
|
+
if (value?.target !== 'windows-x64' || value.platform !== 'windows'
|
|
33
|
+
|| value.architecture !== 'x64' || value.format !== 'exe') {
|
|
34
|
+
throw new ArcaneError(ERROR_CODES.targetUnavailable, 'The Windows provider assembles windows-x64 executables.');
|
|
35
|
+
}
|
|
36
|
+
return {...value};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function portableRequest(target) {
|
|
40
|
+
return {...target, target: 'portable', format: 'portable'};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function emit(onEvent, event) {
|
|
44
|
+
if (is.function(onEvent)) await onEvent(event);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function hostFilesMissing(directory) {
|
|
48
|
+
const missing = [];
|
|
49
|
+
for (const relative of HOST_FILES) {
|
|
50
|
+
try {
|
|
51
|
+
if (!(await stat(path.join(directory, relative))).isFile()) missing.push(relative);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code !== 'ENOENT') throw error;
|
|
54
|
+
missing.push(relative);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return missing;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function requireHostDirectory(directory) {
|
|
61
|
+
const resolved = await realpath(directory);
|
|
62
|
+
const missing = await hostFilesMissing(resolved);
|
|
63
|
+
if (missing.length) {
|
|
64
|
+
throw new ArcaneError(ERROR_CODES.prerequisiteMissing, 'The selected Windows host assets are incomplete.', {
|
|
65
|
+
details: {hostDirectory: resolved, missing}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return resolved;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function removeOwnedDirectory(directory, error) {
|
|
72
|
+
try { await rm(directory, {recursive: true, force: true}); }
|
|
73
|
+
catch (cleanupError) {
|
|
74
|
+
throw new AggregateError([error, cleanupError], `Windows preparation failed; incomplete output remains at ${directory}.`);
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function releasedHostDirectory(outputRoot, version, {signal, onEvent}) {
|
|
80
|
+
const cache = path.join(outputRoot, '.arcane-native-hosts', version);
|
|
81
|
+
const destination = path.join(cache, 'windows-x64');
|
|
82
|
+
try { return await requireHostDirectory(destination); }
|
|
83
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
84
|
+
throwIfAborted(signal);
|
|
85
|
+
await mkdir(cache, {recursive: true});
|
|
86
|
+
const temporary = await mkdtemp(path.join(cache, 'preparing-windows-x64-'));
|
|
87
|
+
const archive = path.join(temporary, HOST_ASSET);
|
|
88
|
+
const extracted = path.join(temporary, 'host');
|
|
89
|
+
const url = `https://github.com/TheWizardNexus/arcane-os-sdk/releases/download/${version}/${HOST_ASSET}`;
|
|
90
|
+
try {
|
|
91
|
+
await emit(onEvent, {type: 'native.host.download.started', target: 'windows-x64', version, url});
|
|
92
|
+
throwIfAborted(signal);
|
|
93
|
+
const response = await fetch(url, {signal});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new ArcaneError(ERROR_CODES.prerequisiteMissing, `The SDK Windows host download returned HTTP ${response.status}.`, {
|
|
96
|
+
details: {url, status: response.status, response: await response.text()}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (!response.body) throw new ArcaneError(ERROR_CODES.operationFailed, 'The SDK Windows host download returned no content.', {details: {url}});
|
|
100
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(archive, {flags: 'wx'}), {signal});
|
|
101
|
+
throwIfAborted(signal);
|
|
102
|
+
await mkdir(extracted);
|
|
103
|
+
await runProcess('tar', ['-xzf', archive, '-C', extracted], {cwd: temporary, signal, onEvent});
|
|
104
|
+
await requireHostDirectory(extracted);
|
|
105
|
+
throwIfAborted(signal);
|
|
106
|
+
try { await rename(extracted, destination); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Another preparation may have finished this exact version while
|
|
109
|
+
// this download was in flight. Preserve its complete asset tree.
|
|
110
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error;
|
|
111
|
+
try { await requireHostDirectory(destination); }
|
|
112
|
+
catch { throw error; }
|
|
113
|
+
}
|
|
114
|
+
await rm(temporary, {recursive: true, force: true});
|
|
115
|
+
await emit(onEvent, {type: 'native.host.download.completed', target: 'windows-x64', version, hostDirectory: destination});
|
|
116
|
+
throwIfAborted(signal);
|
|
117
|
+
return destination;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return removeOwnedDirectory(temporary, error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function contains(root, candidate) {
|
|
124
|
+
const relative = path.relative(root, candidate);
|
|
125
|
+
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function copyHostAssets(source, root, files, signal, relative = '', ancestors = new Set()) {
|
|
129
|
+
throwIfAborted(signal);
|
|
130
|
+
const canonical = await realpath(source);
|
|
131
|
+
const output = await realpath(root);
|
|
132
|
+
if (contains(canonical, output) || contains(output, canonical) || ancestors.has(canonical)) {
|
|
133
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Windows host assets overlap their output or contain a directory cycle.', {
|
|
134
|
+
details: {source, outputRoot: root}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const parents = new Set([...ancestors, canonical]);
|
|
138
|
+
for (const entry of await readdir(source, {withFileTypes: true})) {
|
|
139
|
+
throwIfAborted(signal);
|
|
140
|
+
const selected = path.join(source, entry.name);
|
|
141
|
+
const destination = relative ? `${relative}/${entry.name}` : entry.name;
|
|
142
|
+
const information = await stat(selected);
|
|
143
|
+
if (information.isDirectory()) {
|
|
144
|
+
await mkdir(path.join(root, destination), {recursive: true});
|
|
145
|
+
await copyHostAssets(selected, root, files, signal, destination, parents);
|
|
146
|
+
} else if (information.isFile()) {
|
|
147
|
+
await writeOutput(root, destination, await readFile(selected, {signal}), files, signal);
|
|
148
|
+
} else {
|
|
149
|
+
throw new ArcaneError(ERROR_CODES.operationFailed, `Cannot copy Windows host asset ${selected}.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Compose app-owned services with the SDK's precompiled Windows host. */
|
|
155
|
+
export function createWindowsNativeProvider({services, hostDirectory} = {}) {
|
|
156
|
+
const portable = createPortableNativeProvider(services === undefined ? {} : {services});
|
|
157
|
+
return {
|
|
158
|
+
protocol: PROTOCOL,
|
|
159
|
+
async describe() {
|
|
160
|
+
return {
|
|
161
|
+
protocol: PROTOCOL,
|
|
162
|
+
id: 'arcane-sdk-windows-x64',
|
|
163
|
+
targets: ['windows-x64'],
|
|
164
|
+
executable: true,
|
|
165
|
+
serviceConfiguration: 'explicit release module factories'
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
async doctor({targetRequest, signal, onEvent} = {}) {
|
|
169
|
+
throwIfAborted(signal);
|
|
170
|
+
const target = windowsTarget(targetRequest);
|
|
171
|
+
const core = await portable.doctor({targetRequest: portableRequest(target), signal});
|
|
172
|
+
const missing = hostDirectory === undefined ? [] : await hostFilesMissing(path.resolve(hostDirectory));
|
|
173
|
+
const result = {
|
|
174
|
+
target: 'windows-x64', executable: true, ready: core.ready && missing.length === 0,
|
|
175
|
+
sdkRoot: core.sdkRoot, missing: [...core.missing, ...missing],
|
|
176
|
+
hostDirectory: hostDirectory === undefined ? null : path.resolve(hostDirectory),
|
|
177
|
+
hostSource: hostDirectory === undefined ? 'sdk-release' : 'selected-directory',
|
|
178
|
+
downloadRequired: hostDirectory === undefined,
|
|
179
|
+
runtimePrerequisite: 'Microsoft Edge WebView2 Runtime',
|
|
180
|
+
buildPrerequisite: hostDirectory === undefined ? 'tar' : null
|
|
181
|
+
};
|
|
182
|
+
await emit(onEvent, {type: 'native.doctor.completed', ...result});
|
|
183
|
+
throwIfAborted(signal);
|
|
184
|
+
return result;
|
|
185
|
+
},
|
|
186
|
+
async prepare({targetRequest, signal, onEvent} = {}) {
|
|
187
|
+
throwIfAborted(signal);
|
|
188
|
+
const target = windowsTarget(targetRequest);
|
|
189
|
+
const selection = await portable.prepare({targetRequest: portableRequest(target), signal});
|
|
190
|
+
const selected = hostDirectory === undefined ? undefined : await requireHostDirectory(hostDirectory);
|
|
191
|
+
// Downloads belong to build(), which has the caller's selected
|
|
192
|
+
// outputRoot. Preparation never invents a machine-wide cache.
|
|
193
|
+
const result = {...selection, target, executable: true, ...(selected ? {hostDirectory: selected} : {})};
|
|
194
|
+
await emit(onEvent, {type: 'native.prepared', target: 'windows-x64', sdkVersion: selection.sdkVersion});
|
|
195
|
+
throwIfAborted(signal);
|
|
196
|
+
return result;
|
|
197
|
+
},
|
|
198
|
+
async build(request = {}) {
|
|
199
|
+
const {signal, onEvent} = request;
|
|
200
|
+
throwIfAborted(signal);
|
|
201
|
+
const target = windowsTarget(request.targetRequest);
|
|
202
|
+
const selected = hostDirectory ?? request.toolchain?.hostDirectory;
|
|
203
|
+
const selectedHost = selected === undefined ? null : await requireHostDirectory(selected);
|
|
204
|
+
const artifact = await portable.build({
|
|
205
|
+
...request,
|
|
206
|
+
targetRequest: portableRequest(target),
|
|
207
|
+
async onEvent(event) {
|
|
208
|
+
if (event.type !== 'native.payload.completed') await emit(onEvent, {...event, target: 'windows-x64'});
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
const root = artifact.target.rootDir;
|
|
212
|
+
try {
|
|
213
|
+
const host = selectedHost ?? await releasedHostDirectory(path.resolve(request.outputRoot), artifact.manifest.sdk.version, {signal, onEvent});
|
|
214
|
+
const files = [...artifact.manifest.files];
|
|
215
|
+
await copyHostAssets(host, root, files, signal);
|
|
216
|
+
const manifest = {
|
|
217
|
+
...artifact.manifest,
|
|
218
|
+
kind: 'arcane-windows-native',
|
|
219
|
+
target,
|
|
220
|
+
host: {
|
|
221
|
+
executable: 'Arcane.exe',
|
|
222
|
+
platform: 'windows', architecture: 'x64', runtime: 'webview2',
|
|
223
|
+
coreExecutable: 'runtime/ArcaneCore.exe',
|
|
224
|
+
coreLoader: 'runtime/arcane-core-loader.cjs'
|
|
225
|
+
},
|
|
226
|
+
files
|
|
227
|
+
};
|
|
228
|
+
await writeFile(path.join(root, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {signal});
|
|
229
|
+
throwIfAborted(signal);
|
|
230
|
+
await emit(onEvent, {type: 'native.payload.completed', target: 'windows-x64', appId: request.appDescriptor.id, outputRoot: root});
|
|
231
|
+
throwIfAborted(signal);
|
|
232
|
+
return {...artifact, target: {...target, rootDir: root}, manifest};
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return removeOwnedDirectory(root, error);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
async verify({artifact, targetRequest, signal, onEvent} = {}) {
|
|
238
|
+
throwIfAborted(signal);
|
|
239
|
+
const target = windowsTarget(targetRequest);
|
|
240
|
+
const result = await portable.verify({artifact, targetRequest: portableRequest(target), signal});
|
|
241
|
+
await requireHostDirectory(result.outputRoot);
|
|
242
|
+
const verified = {...result, target: 'windows-x64', executable: true};
|
|
243
|
+
await emit(onEvent, {type: 'native.verified', target: 'windows-x64', outputRoot: result.outputRoot});
|
|
244
|
+
throwIfAborted(signal);
|
|
245
|
+
return verified;
|
|
246
|
+
},
|
|
247
|
+
async run({artifact, targetRequest, signal, onEvent} = {}) {
|
|
248
|
+
throwIfAborted(signal);
|
|
249
|
+
windowsTarget(targetRequest);
|
|
250
|
+
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
|
251
|
+
throw new ArcaneError(ERROR_CODES.nativeRunUnsupported, 'Running this Windows x64 artifact requires a Windows x64 host.');
|
|
252
|
+
}
|
|
253
|
+
const root = await requireHostDirectory(artifact?.target?.rootDir);
|
|
254
|
+
return runProcess(path.join(root, 'Arcane.exe'), ['--close-on-stdin-eof'], {
|
|
255
|
+
cwd: root, signal, onEvent, cancellationMode: 'close-input'
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export const arcaneNativeBuilderProvider = createWindowsNativeProvider();
|
|
262
|
+
export default arcaneNativeBuilderProvider;
|
|
@@ -22,6 +22,11 @@ const FIXED_NATIVE_PROVIDER_PATHS={
|
|
|
22
22
|
'android-arm64':providerPath('android-native-provider.mjs')
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
const SDK_NATIVE_PROVIDER_PATHS={
|
|
26
|
+
portable:'./native/portable-provider.mjs',
|
|
27
|
+
'windows-x64':'./native/windows-provider.mjs'
|
|
28
|
+
};
|
|
29
|
+
|
|
25
30
|
export const ARCANE_NATIVE_PROVIDER_PATHS={
|
|
26
31
|
portable:[...FIXED_NATIVE_PROVIDER_PATHS.portable],
|
|
27
32
|
'windows-x64':[...FIXED_NATIVE_PROVIDER_PATHS['windows-x64']],
|
|
@@ -133,10 +138,11 @@ export async function loadArcaneNativeProvider(options={}){
|
|
|
133
138
|
target,
|
|
134
139
|
message:`Loading the Arcane ${String(target)} provider.`
|
|
135
140
|
});
|
|
136
|
-
const
|
|
141
|
+
const sdkProvider=SDK_NATIVE_PROVIDER_PATHS[target];
|
|
142
|
+
const location=arcaneRoot===undefined&&sdkProvider
|
|
137
143
|
?{
|
|
138
144
|
canonicalRoot:path.resolve(fileURLToPath(new URL('../',import.meta.url))),
|
|
139
|
-
providerPath:fileURLToPath(new URL(
|
|
145
|
+
providerPath:fileURLToPath(new URL(sdkProvider,import.meta.url)),
|
|
140
146
|
providerSource:'sdk-package'
|
|
141
147
|
}
|
|
142
148
|
:await resolveProviderLocation({arcaneRoot,target,inspect,canonicalize});
|
package/src/packager/core.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
import {pathToFileURL} from 'node:url';
|
|
15
15
|
import {appRelativeRoot,resolveAppRoot,resolvePackageOutputRoot} from '../app-layout.mjs';
|
|
16
16
|
import {TARGET_IDS} from '../constants.mjs';
|
|
17
|
+
import {normalizeLocalAIConfig} from '../local-ai/config.mjs';
|
|
17
18
|
import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
|
|
18
19
|
import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
|
|
19
20
|
import {materializeNativeModules} from './native-modules.mjs';
|
|
@@ -321,7 +322,7 @@ function normalizeOptionalRecord(value,label){
|
|
|
321
322
|
export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
|
|
322
323
|
assertOnlyKeys(value,new Set([
|
|
323
324
|
'schemaVersion','id','displayName','version','entry','strategy','security',
|
|
324
|
-
'localAIModelPolicy','include','exclude','shared','adapter','pwa','outputDirectory','documents','nativeResources'
|
|
325
|
+
'localAIModelPolicy','localAI','include','exclude','shared','adapter','pwa','outputDirectory','documents','nativeResources'
|
|
325
326
|
]),`${appId}/${APP_CONFIG_NAME}`);
|
|
326
327
|
if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
|
|
327
328
|
if(!is.string(value.id)||value.id!==appId||!APP_ID_PATTERN.test(value.id)){
|
|
@@ -390,6 +391,7 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
|
|
|
390
391
|
value.localAIModelPolicy,
|
|
391
392
|
`${appId}/${APP_CONFIG_NAME}.localAIModelPolicy`
|
|
392
393
|
)}),
|
|
394
|
+
...(value.localAI===undefined?{}:{localAI:normalizeLocalAIConfig(value.localAI)}),
|
|
393
395
|
include,
|
|
394
396
|
exclude,
|
|
395
397
|
...(nativeResources===undefined?{}:{nativeResources}),
|
|
@@ -742,6 +744,7 @@ async function inspectContext(context,{signal,records}={}){
|
|
|
742
744
|
...(context.config.localAIModelPolicy===undefined?{}:{
|
|
743
745
|
localAIModelPolicy:copyJson(context.config.localAIModelPolicy)
|
|
744
746
|
}),
|
|
747
|
+
...(context.config.localAI===undefined?{}:{localAI:copyJson(context.config.localAI)}),
|
|
745
748
|
...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
|
|
746
749
|
descriptor:await optionalDescriptor(context),
|
|
747
750
|
browserDocuments:documents,
|
|
@@ -871,7 +874,8 @@ function releaseManifest(context,files,pwaArtifacts,rootDocument){
|
|
|
871
874
|
...(context.config.security===undefined?{}:{security:copyJson(context.config.security)}),
|
|
872
875
|
...(context.config.localAIModelPolicy===undefined?{}:{
|
|
873
876
|
localAIModelPolicy:copyJson(context.config.localAIModelPolicy)
|
|
874
|
-
})
|
|
877
|
+
}),
|
|
878
|
+
...(context.config.localAI===undefined?{}:{localAI:copyJson(context.config.localAI)})
|
|
875
879
|
},
|
|
876
880
|
files:[...files]
|
|
877
881
|
};
|
package/src/process.mjs
CHANGED
|
@@ -124,12 +124,16 @@ export async function runProcess(command,args=[],{
|
|
|
124
124
|
heartbeatMs=5000,
|
|
125
125
|
terminationGraceMs=DEFAULT_TERMINATION_GRACE_MS,
|
|
126
126
|
allowNonzero=false,
|
|
127
|
-
input
|
|
127
|
+
input,
|
|
128
|
+
cancellationMode='terminate-tree'
|
|
128
129
|
}={}){
|
|
129
130
|
throwIfAborted(signal);
|
|
130
131
|
if(!is.array(args)||args.some(argument=>!is.string(argument))){
|
|
131
132
|
throw new ArcaneError(ERROR_CODES.usage,'Process arguments must be a fixed array of strings.');
|
|
132
133
|
}
|
|
134
|
+
if(cancellationMode!=='terminate-tree'&&cancellationMode!=='close-input'){
|
|
135
|
+
throw new ArcaneError(ERROR_CODES.usage,'Unknown process cancellation mode.');
|
|
136
|
+
}
|
|
133
137
|
if(!is.integer(terminationGraceMs)||terminationGraceMs<100||terminationGraceMs>30_000){
|
|
134
138
|
throw new ArcaneError(
|
|
135
139
|
ERROR_CODES.usage,
|
|
@@ -153,12 +157,15 @@ export async function runProcess(command,args=[],{
|
|
|
153
157
|
return new Promise((resolve,reject)=>{
|
|
154
158
|
let stdout='';
|
|
155
159
|
let stderr='';
|
|
160
|
+
let childClosed=false;
|
|
161
|
+
let closedResult=null;
|
|
156
162
|
let settlementStarted=false;
|
|
157
163
|
let cancellationRequested=false;
|
|
158
164
|
let cancellationError=null;
|
|
159
165
|
let terminationRequested=false;
|
|
160
166
|
let escalation=null;
|
|
161
167
|
let spawnError=null;
|
|
168
|
+
let inputError=null;
|
|
162
169
|
let child;
|
|
163
170
|
let heartbeat=null;
|
|
164
171
|
const deliveries=new Set();
|
|
@@ -184,9 +191,37 @@ export async function runProcess(command,args=[],{
|
|
|
184
191
|
clearTimeout(escalation);
|
|
185
192
|
signal?.removeEventListener('abort',abort);
|
|
186
193
|
void (async()=>{
|
|
194
|
+
let callbackFailure=null;
|
|
187
195
|
try{
|
|
188
196
|
await events.drain();
|
|
189
|
-
}catch(
|
|
197
|
+
}catch(error){
|
|
198
|
+
callbackFailure=error;
|
|
199
|
+
}
|
|
200
|
+
if(cancellationMode==='close-input'&&closedResult){
|
|
201
|
+
const failures=[...new Set([
|
|
202
|
+
callback===reject?value:null,
|
|
203
|
+
callbackFailure,inputError,cancellationError,spawnError
|
|
204
|
+
].filter(error=>error!==null))];
|
|
205
|
+
if(failures.length>0){
|
|
206
|
+
const primary=failures[0];
|
|
207
|
+
// Owned cancellation/exit errors already carry this exact
|
|
208
|
+
// result. Other errors retain their identity and cause in
|
|
209
|
+
// the complete failure record instead of being rewritten.
|
|
210
|
+
if(failures.length===1&&primary?.details===closedResult){
|
|
211
|
+
reject(primary);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const failure=new ArcaneError(
|
|
215
|
+
primary?.code??ERROR_CODES.operationFailed,
|
|
216
|
+
primary?.message??`Could not complete ${command}.`,
|
|
217
|
+
{cause:primary,details:closedResult,exitCode:primary?.exitCode}
|
|
218
|
+
);
|
|
219
|
+
failure.errors=failures;
|
|
220
|
+
reject(failure);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if(callbackFailure){
|
|
190
225
|
reject(callbackFailure);
|
|
191
226
|
return;
|
|
192
227
|
}
|
|
@@ -195,10 +230,16 @@ export async function runProcess(command,args=[],{
|
|
|
195
230
|
};
|
|
196
231
|
|
|
197
232
|
const stopTree=()=>{
|
|
198
|
-
if(terminationRequested||settlementStarted){
|
|
233
|
+
if(terminationRequested||settlementStarted||childClosed){
|
|
199
234
|
return;
|
|
200
235
|
}
|
|
201
236
|
terminationRequested=true;
|
|
237
|
+
if(cancellationMode==='close-input'){
|
|
238
|
+
// The selected host owns shutdown after EOF. Keep observing its
|
|
239
|
+
// complete output and exit; accepted durable work may outlive UI.
|
|
240
|
+
child?.stdin.end();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
202
243
|
terminateProcessTree(child);
|
|
203
244
|
escalation=setTimeout(()=>{
|
|
204
245
|
if(childIsRunning(child)){
|
|
@@ -214,7 +255,7 @@ export async function runProcess(command,args=[],{
|
|
|
214
255
|
};
|
|
215
256
|
|
|
216
257
|
const abort=()=>{
|
|
217
|
-
if(cancellationRequested||settlementStarted){
|
|
258
|
+
if(cancellationRequested||settlementStarted||childClosed){
|
|
218
259
|
return;
|
|
219
260
|
}
|
|
220
261
|
cancellationRequested=true;
|
|
@@ -225,7 +266,9 @@ export async function runProcess(command,args=[],{
|
|
|
225
266
|
);
|
|
226
267
|
void events.enqueue({
|
|
227
268
|
type:'process.cancellation.requested',
|
|
228
|
-
message
|
|
269
|
+
message:cancellationMode==='close-input'
|
|
270
|
+
?`Closing ${command} input and waiting for its owned shutdown.`
|
|
271
|
+
:`Stopping ${command} and its child processes.`,
|
|
229
272
|
data:{command,pid:child?.pid??null}
|
|
230
273
|
});
|
|
231
274
|
stopTree();
|
|
@@ -286,6 +329,10 @@ export async function runProcess(command,args=[],{
|
|
|
286
329
|
}
|
|
287
330
|
});
|
|
288
331
|
child.on('close',(code,terminationSignal)=>{
|
|
332
|
+
// The child lifetime has ended even while its output/event callbacks
|
|
333
|
+
// are still draining. A later abort cannot cancel that completed work.
|
|
334
|
+
childClosed=true;
|
|
335
|
+
signal?.removeEventListener('abort',abort);
|
|
289
336
|
clearInterval(heartbeat);
|
|
290
337
|
void (async()=>{
|
|
291
338
|
await drainDeliveries();
|
|
@@ -299,10 +346,11 @@ export async function runProcess(command,args=[],{
|
|
|
299
346
|
stdout,
|
|
300
347
|
stderr
|
|
301
348
|
};
|
|
349
|
+
closedResult=result;
|
|
302
350
|
if(terminationRequested){
|
|
303
351
|
// The direct child has exited. Make one final tree-wide attempt so
|
|
304
352
|
// a descendant that ignored the graceful request cannot outlive it.
|
|
305
|
-
terminateProcessTree(child,{force:true});
|
|
353
|
+
if(cancellationMode!=='close-input')terminateProcessTree(child,{force:true});
|
|
306
354
|
if(cancellationRequested){
|
|
307
355
|
await events.enqueue({
|
|
308
356
|
type:'process.cancelled',
|
|
@@ -310,7 +358,8 @@ export async function runProcess(command,args=[],{
|
|
|
310
358
|
data:{command,code:result.code,signal:terminationSignal}
|
|
311
359
|
});
|
|
312
360
|
}
|
|
313
|
-
|
|
361
|
+
if(cancellationError)cancellationError.details=result;
|
|
362
|
+
finish(reject,events.error??inputError??cancellationError??new ArcaneError(
|
|
314
363
|
ERROR_CODES.operationFailed,
|
|
315
364
|
`Stopped ${command} after its event callback failed.`
|
|
316
365
|
));
|
|
@@ -324,6 +373,12 @@ export async function runProcess(command,args=[],{
|
|
|
324
373
|
));
|
|
325
374
|
return;
|
|
326
375
|
}
|
|
376
|
+
if(inputError){
|
|
377
|
+
finish(reject,new ArcaneError(ERROR_CODES.operationFailed,
|
|
378
|
+
`Could not write ${command} input: ${inputError.message}`,
|
|
379
|
+
{cause:inputError,details:result}));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
327
382
|
await events.enqueue({
|
|
328
383
|
type:'process.completed',
|
|
329
384
|
message:`${command} exited with code ${String(result.code)}.`,
|
|
@@ -346,7 +401,10 @@ export async function runProcess(command,args=[],{
|
|
|
346
401
|
abort();
|
|
347
402
|
}
|
|
348
403
|
|
|
349
|
-
if(input
|
|
404
|
+
if(cancellationMode==='close-input'){
|
|
405
|
+
child.stdin.on('error',error=>{inputError=error;stopTree();});
|
|
406
|
+
if(input!==undefined&&!terminationRequested)child.stdin.write(input);
|
|
407
|
+
}else if(input!==undefined){
|
|
350
408
|
child.stdin.end(input);
|
|
351
409
|
}else{
|
|
352
410
|
child.stdin.end();
|
package/src/toolchain.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Is from 'strong-type';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {resolvePackageOutputRoot} from './app-layout.mjs';
|
|
3
|
+
import {resolveAppRoot,resolvePackageOutputRoot} from './app-layout.mjs';
|
|
4
4
|
import {readdir,lstat,realpath,mkdir,readFile,writeFile} from 'node:fs/promises';
|
|
5
5
|
import {createWorkspace,initWorkspace} from './scaffold.mjs';
|
|
6
6
|
import {
|
|
@@ -17,6 +17,8 @@ import {createPwaArtifacts} from './pwa.mjs';
|
|
|
17
17
|
import {readInstalledSdkLayout} from './sdk-runtime-layout.mjs';
|
|
18
18
|
import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
|
|
19
19
|
import {refreshAppPackageProjection} from './app-descriptor.mjs';
|
|
20
|
+
import {configureDevelopmentLocalAI} from './local-ai/config.mjs';
|
|
21
|
+
import {createDevelopmentLocalAI} from './local-ai/dev.mjs';
|
|
20
22
|
import {
|
|
21
23
|
discoverApps as discoverPackagerApps,
|
|
22
24
|
inspectApp as inspectPackagedApp,
|
|
@@ -566,6 +568,11 @@ export async function developApplication(options = {}) {
|
|
|
566
568
|
onEvent: options.onEvent
|
|
567
569
|
},
|
|
568
570
|
async function refreshDevelopmentWorkspace(workspaceOperationLease) {
|
|
571
|
+
await configureDevelopmentLocalAI({
|
|
572
|
+
appRoot: resolveAppRoot(profile.workspaceRoot, profile.config, appId),
|
|
573
|
+
runtimes: options.localAI,
|
|
574
|
+
signal: options.signal
|
|
575
|
+
});
|
|
569
576
|
await refreshAppPackageProjection(
|
|
570
577
|
{
|
|
571
578
|
workspaceRoot: profile.workspaceRoot,
|
|
@@ -589,7 +596,19 @@ export async function developApplication(options = {}) {
|
|
|
589
596
|
return current;
|
|
590
597
|
}
|
|
591
598
|
);
|
|
592
|
-
const
|
|
599
|
+
const localAIConfig = prepared.descriptor?.native?.localAI;
|
|
600
|
+
const localAI = localAIConfig?.runtimes.length ? createDevelopmentLocalAI({
|
|
601
|
+
config: localAIConfig,
|
|
602
|
+
appRoot: prepared.appRoot,
|
|
603
|
+
directory: path.join(prepared.appRoot, '.arcane', 'local-ai', 'runtimes'),
|
|
604
|
+
application: prepared.descriptor,
|
|
605
|
+
version: prepared.descriptor.version,
|
|
606
|
+
signal: options.signal,
|
|
607
|
+
onEvent: options.onEvent
|
|
608
|
+
}) : undefined;
|
|
609
|
+
let server;
|
|
610
|
+
try {
|
|
611
|
+
server=await startDevServer({
|
|
593
612
|
workspaceRoot:prepared.workspaceRoot,
|
|
594
613
|
appId:prepared.appId,
|
|
595
614
|
mode:'source',
|
|
@@ -605,10 +624,19 @@ export async function developApplication(options = {}) {
|
|
|
605
624
|
tls:options.tls,
|
|
606
625
|
certPath:options.certPath,
|
|
607
626
|
keyPath:options.keyPath,
|
|
627
|
+
localAI,
|
|
608
628
|
signal:options.signal,
|
|
609
629
|
onEvent:options.onEvent
|
|
610
|
-
|
|
611
|
-
|
|
630
|
+
});
|
|
631
|
+
} catch (error) {
|
|
632
|
+
try {
|
|
633
|
+
await localAI?.close(error);
|
|
634
|
+
} catch (closeError) {
|
|
635
|
+
throw new AggregateError([error, closeError], 'Development startup and local AI shutdown failed.');
|
|
636
|
+
}
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
return {...server,appId:prepared.appId,mode:'source',...(localAI ? {localAI} : {})};
|
|
612
640
|
}
|
|
613
641
|
|
|
614
642
|
export async function packageApplication(options={}){
|
|
@@ -840,7 +868,7 @@ export function assertIntegratedNativeToolchain({
|
|
|
840
868
|
workspaceMode,workspaceRoot,toolchainRoot,target,providerSource='arcane-checkout'
|
|
841
869
|
}={}){
|
|
842
870
|
if(workspaceMode!=='integrated')return;
|
|
843
|
-
if(providerSource==='sdk-package'&&target==='portable')return;
|
|
871
|
+
if(providerSource==='sdk-package'&&(target==='portable'||target==='windows-x64'))return;
|
|
844
872
|
if(!is.string(toolchainRoot)||!sameCanonicalPath(workspaceRoot,toolchainRoot)){
|
|
845
873
|
throw new ArcaneError(
|
|
846
874
|
ERROR_CODES.policyDenied,
|