arcane-os 0.88.1 → 0.89.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 +21 -0
- package/browser-runtime/ai/browser-decisions.mjs +31 -4
- package/browser-runtime/ai/core-local.mjs +105 -25
- package/browser-runtime/ai/decision-runtime.mjs +7 -0
- package/browser-runtime/ai/decision-worker.mjs +9 -1
- package/browser-runtime/core/client.mjs +6 -0
- package/browser-runtime/core/contracts.mjs +10 -0
- package/docs/reference/ai/browser-decisions.md +89 -7
- package/docs/reference/core-client.md +59 -0
- package/docs/reference/core-native-packaging.md +57 -10
- package/docs/reference/core-repositories.md +47 -3
- package/docs/reference/inventory/package-api.json +15 -15
- package/docs/reference/inventory/runtime-components.json +1 -1
- package/docs/reference/inventory/runtime-modules.json +4 -4
- package/docs/reference/local-ai.md +86 -3
- package/docs/reference/mcp-stdio.md +76 -1
- package/docs/reference/native-app-control.md +17 -5
- package/docs/reference/protocols.md +14 -0
- package/docs/reference/runtime-modules.md +9 -3
- package/docs/reference/sdk-api.md +135 -23
- package/package.json +2 -2
- package/runtime/arcane/modules/RiskSignalAnalyzer.js +1 -5
- package/schemas/arcane-app.schema.json +13 -3
- package/schemas/arcane-package.schema.json +10 -1
- package/src/app-descriptor.mjs +6 -2
- package/src/cli/app-control.mjs +1 -1
- package/src/core/hosts/windows/ArcaneHost.cs +273 -5
- package/src/core/hosts/windows/ArcaneLauncher.cs +9 -0
- package/src/core/hosts/windows/ArcaneWindowControl.cs +7 -7
- package/src/core/repositories.mjs +11 -1
- package/src/core/services/local-ai.mjs +183 -22
- package/src/installed-sdk-runtime.mjs +2 -0
- package/src/local-ai/config.mjs +16 -0
- package/src/local-ai/dev.mjs +2 -2
- package/src/local-ai/discover.mjs +3 -0
- package/src/local-ai/install.mjs +130 -20
- package/src/local-ai/llama-execution-windows.ps1 +158 -0
- package/src/local-ai/llama-execution.mjs +169 -0
- package/src/local-ai/server.mjs +38 -8
- package/src/native/portable-layout.mjs +13 -3
- package/src/workspace-runtime.mjs +97 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Invoked through stdin by the SDK's owned process; stdout contains one JSON record.
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
4
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
# Enumerate the installed driver's devices in this helper only. An empty
|
|
8
|
+
# CUDA_VISIBLE_DEVICES value hides every device, so remove the inherited value.
|
|
9
|
+
[Environment]::SetEnvironmentVariable('CUDA_VISIBLE_DEVICES', $null, [EnvironmentVariableTarget]::Process)
|
|
10
|
+
|
|
11
|
+
Add-Type -TypeDefinition @'
|
|
12
|
+
using System;
|
|
13
|
+
using System.Globalization;
|
|
14
|
+
using System.Runtime.InteropServices;
|
|
15
|
+
using System.Text;
|
|
16
|
+
|
|
17
|
+
namespace Arcane.LocalAI {
|
|
18
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
19
|
+
public struct AdapterLuid {
|
|
20
|
+
public uint LowPart;
|
|
21
|
+
public int HighPart;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public sealed class CudaDeviceIdentity {
|
|
25
|
+
public string Uuid;
|
|
26
|
+
public uint NodeMask;
|
|
27
|
+
public AdapterLuid Luid;
|
|
28
|
+
public string UuidFunction;
|
|
29
|
+
|
|
30
|
+
// NVIDIA CU_DEVICE_ATTRIBUTE_TCC_DRIVER is 35. CUDA's Windows LUID
|
|
31
|
+
// contract is undefined in TCC mode, so only WDDM devices can be matched.
|
|
32
|
+
// https://nvidia.github.io/cuda-python/cuda-bindings/latest/module/driver.html
|
|
33
|
+
private const int TccDriverAttribute = 35;
|
|
34
|
+
|
|
35
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
36
|
+
private static extern int cuInit(uint flags);
|
|
37
|
+
|
|
38
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
39
|
+
private static extern int cuDeviceGetCount(out int count);
|
|
40
|
+
|
|
41
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
42
|
+
private static extern int cuDeviceGet(out int device, int ordinal);
|
|
43
|
+
|
|
44
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
45
|
+
private static extern int cuDeviceGetAttribute(out int value, int attribute, int device);
|
|
46
|
+
|
|
47
|
+
// CUDA writes the native Windows LUID layout: unsigned LowPart followed
|
|
48
|
+
// by signed HighPart. Marshaling the struct preserves that native layout.
|
|
49
|
+
// https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-luid
|
|
50
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
51
|
+
private static extern int cuDeviceGetLuid(out AdapterLuid luid, out uint nodeMask, int device);
|
|
52
|
+
|
|
53
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
54
|
+
private static extern int cuDeviceGetUuid_v2([Out] byte[] uuid, int device);
|
|
55
|
+
|
|
56
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
57
|
+
private static extern int cuDeviceGetUuid([Out] byte[] uuid, int device);
|
|
58
|
+
|
|
59
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
60
|
+
private static extern int cuGetErrorName(int error, out IntPtr name);
|
|
61
|
+
|
|
62
|
+
[DllImport("nvcuda.dll", ExactSpelling = true)]
|
|
63
|
+
private static extern int cuGetErrorString(int error, out IntPtr message);
|
|
64
|
+
|
|
65
|
+
private static void Check(int result, string operation) {
|
|
66
|
+
if (result == 0) return;
|
|
67
|
+
IntPtr name;
|
|
68
|
+
IntPtr message;
|
|
69
|
+
int nameResult = cuGetErrorName(result, out name);
|
|
70
|
+
int messageResult = cuGetErrorString(result, out message);
|
|
71
|
+
throw new InvalidOperationException(
|
|
72
|
+
operation + " returned CUDA error " + result + ": " +
|
|
73
|
+
(nameResult == 0 ? Marshal.PtrToStringAnsi(name) : "cuGetErrorName returned " + nameResult) + "; " +
|
|
74
|
+
(messageResult == 0 ? Marshal.PtrToStringAnsi(message) : "cuGetErrorString returned " + messageResult)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private static string ReadUuid(int device, out string function) {
|
|
79
|
+
// CUuuid's 16 native octets are unavoidable ABI storage. CUDA UUID
|
|
80
|
+
// text keeps their order; System.Guid would reverse several fields.
|
|
81
|
+
byte[] uuid = new byte[16];
|
|
82
|
+
int result;
|
|
83
|
+
try {
|
|
84
|
+
result = cuDeviceGetUuid_v2(uuid, device);
|
|
85
|
+
function = "cuDeviceGetUuid_v2";
|
|
86
|
+
} catch (EntryPointNotFoundException) {
|
|
87
|
+
// Older drivers expose the original function for this same
|
|
88
|
+
// device. No device or backend selection changes on this path.
|
|
89
|
+
result = cuDeviceGetUuid(uuid, device);
|
|
90
|
+
function = "cuDeviceGetUuid";
|
|
91
|
+
}
|
|
92
|
+
Check(result, function);
|
|
93
|
+
StringBuilder text = new StringBuilder("GPU-");
|
|
94
|
+
for (int index = 0; index < uuid.Length; index++) {
|
|
95
|
+
if (index == 4 || index == 6 || index == 8 || index == 10) text.Append('-');
|
|
96
|
+
text.Append(uuid[index].ToString("x2", CultureInfo.InvariantCulture));
|
|
97
|
+
}
|
|
98
|
+
return text.ToString();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public static CudaDeviceIdentity Resolve(uint lowPart, int highPart) {
|
|
102
|
+
Check(cuInit(0), "cuInit");
|
|
103
|
+
int count;
|
|
104
|
+
Check(cuDeviceGetCount(out count), "cuDeviceGetCount");
|
|
105
|
+
CudaDeviceIdentity matched = null;
|
|
106
|
+
int tccCount = 0;
|
|
107
|
+
for (int ordinal = 0; ordinal < count; ordinal++) {
|
|
108
|
+
int device;
|
|
109
|
+
Check(cuDeviceGet(out device, ordinal), "cuDeviceGet(" + ordinal + ")");
|
|
110
|
+
int tcc;
|
|
111
|
+
Check(cuDeviceGetAttribute(out tcc, TccDriverAttribute, device), "cuDeviceGetAttribute(TCC_DRIVER, " + ordinal + ")");
|
|
112
|
+
if (tcc != 0) {
|
|
113
|
+
tccCount++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
AdapterLuid luid;
|
|
117
|
+
uint nodeMask;
|
|
118
|
+
Check(cuDeviceGetLuid(out luid, out nodeMask, device), "cuDeviceGetLuid(" + ordinal + ")");
|
|
119
|
+
if (luid.LowPart != lowPart || luid.HighPart != highPart) continue;
|
|
120
|
+
if (matched != null) {
|
|
121
|
+
throw new InvalidOperationException("Multiple CUDA devices share the selected adapter LUID; the catalog selection cannot identify one CUDA device.");
|
|
122
|
+
}
|
|
123
|
+
string function;
|
|
124
|
+
string uuid = ReadUuid(device, out function);
|
|
125
|
+
matched = new CudaDeviceIdentity { Uuid = uuid, NodeMask = nodeMask, Luid = luid, UuidFunction = function };
|
|
126
|
+
}
|
|
127
|
+
if (matched == null) {
|
|
128
|
+
throw new InvalidOperationException(
|
|
129
|
+
"No WDDM CUDA device matches the selected adapter LUID. CUDA devices enumerated: " + count +
|
|
130
|
+
"; TCC devices without a defined LUID: " + tccCount + "."
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return matched;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
'@
|
|
138
|
+
|
|
139
|
+
$lowPart = [uint32]::Parse($env:ARCANE_LLAMA_LUID_LOW, [Globalization.CultureInfo]::InvariantCulture)
|
|
140
|
+
$highPart = [int32]::Parse($env:ARCANE_LLAMA_LUID_HIGH, [Globalization.CultureInfo]::InvariantCulture)
|
|
141
|
+
$device = [Arcane.LocalAI.CudaDeviceIdentity]::Resolve($lowPart, $highPart)
|
|
142
|
+
[ordered]@{
|
|
143
|
+
uuid = $device.Uuid
|
|
144
|
+
nodeMask = $device.NodeMask
|
|
145
|
+
adapterLuid = [ordered]@{
|
|
146
|
+
lowPart = $device.Luid.LowPart
|
|
147
|
+
highPart = $device.Luid.HighPart
|
|
148
|
+
}
|
|
149
|
+
driverMode = 'wddm'
|
|
150
|
+
uuidFunction = $device.UuidFunction
|
|
151
|
+
} | ConvertTo-Json -Depth 4 -Compress
|
|
152
|
+
} catch {
|
|
153
|
+
[Console]::Error.WriteLine($_.Exception.ToString())
|
|
154
|
+
if ($null -ne $_.ErrorDetails) { [Console]::Error.WriteLine($_.ErrorDetails.Message) }
|
|
155
|
+
if ($null -ne $_.InvocationInfo) { [Console]::Error.WriteLine($_.InvocationInfo.PositionMessage) }
|
|
156
|
+
if ($_.ScriptStackTrace) { [Console]::Error.WriteLine($_.ScriptStackTrace) }
|
|
157
|
+
exit 1
|
|
158
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Is from 'strong-type';
|
|
4
|
+
import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
|
|
5
|
+
import {runProcess} from '../process.mjs';
|
|
6
|
+
|
|
7
|
+
const is = new Is(false);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve one explicit catalog selection for a new native llama server lifetime.
|
|
11
|
+
* Default selection performs no discovery. Hardware identity and launch options
|
|
12
|
+
* are configuration evidence; the server's observed execution target stays null.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveLlamaExecution({
|
|
15
|
+
executionTarget, executionDevices, runtime, signal, onEvent
|
|
16
|
+
} = {}) {
|
|
17
|
+
throwIfAborted(signal);
|
|
18
|
+
if (executionTarget === undefined || executionTarget === null) {
|
|
19
|
+
const cpu = runtime?.requestedBackend === 'cpu';
|
|
20
|
+
return {
|
|
21
|
+
args: cpu ? ['--device', 'none', '--gpu-layers', '0'] : [],
|
|
22
|
+
env: {},
|
|
23
|
+
execution: {
|
|
24
|
+
requestedTarget: null,
|
|
25
|
+
resolvedDevice: null,
|
|
26
|
+
resolution: 'automatic',
|
|
27
|
+
reason: cpu ? 'runtime-backend-selected' : 'engine-default-required',
|
|
28
|
+
configuredTarget: cpu ? {backend: 'cpu', device: 'none', gpuLayers: 0} : null,
|
|
29
|
+
observedTarget: null,
|
|
30
|
+
fallback: false
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (!is.function(executionDevices?.resolveTarget)) {
|
|
35
|
+
throw new ArcaneError(
|
|
36
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
37
|
+
'Explicit llama.cpp device selection requires the host execution-device catalog.',
|
|
38
|
+
{details: {requestedTarget: executionTarget}}
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
// The persistent device ID is resolved again for this load because a Windows
|
|
42
|
+
// adapter LUID belongs to the current host session, not the saved selection.
|
|
43
|
+
const resolution = await executionDevices.resolveTarget(
|
|
44
|
+
{executionTarget, refresh: true, signal}
|
|
45
|
+
);
|
|
46
|
+
throwIfAborted(signal);
|
|
47
|
+
const execution = {
|
|
48
|
+
...resolution,
|
|
49
|
+
configuredTarget: null,
|
|
50
|
+
observedTarget: null,
|
|
51
|
+
fallback: false
|
|
52
|
+
};
|
|
53
|
+
if (resolution.resolution !== 'matched' || !resolution.resolvedDevice) {
|
|
54
|
+
throw new ArcaneError(
|
|
55
|
+
resolution.resolution === 'unsupported'
|
|
56
|
+
? 'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED'
|
|
57
|
+
: 'LOCAL_AI_EXECUTION_TARGET_UNAVAILABLE',
|
|
58
|
+
`The selected llama.cpp execution device could not be resolved: ${resolution.reason}.`,
|
|
59
|
+
{details: execution}
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const device = resolution.resolvedDevice;
|
|
63
|
+
if (device.kind === 'cpu') {
|
|
64
|
+
execution.configuredTarget = {
|
|
65
|
+
backend: 'cpu', deviceId: device.deviceId, device: 'none', gpuLayers: 0
|
|
66
|
+
};
|
|
67
|
+
return {args: ['--device', 'none', '--gpu-layers', '0'], env: {}, execution};
|
|
68
|
+
}
|
|
69
|
+
if (device.kind !== 'gpu' || process.platform !== 'win32') {
|
|
70
|
+
throw new ArcaneError(
|
|
71
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
72
|
+
`Exact llama.cpp ${device.kind} selection is unavailable on ${process.platform}.`,
|
|
73
|
+
{details: execution}
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (runtime?.requestedBackend === 'cpu') {
|
|
77
|
+
throw new ArcaneError(
|
|
78
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
79
|
+
'The selected GPU is incompatible with the explicit llama.cpp CPU runtime configuration.',
|
|
80
|
+
{details: execution}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (runtime?.backend !== 'cuda') {
|
|
84
|
+
throw new ArcaneError(
|
|
85
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
86
|
+
'Exact Windows GPU selection requires a llama.cpp CUDA runtime.',
|
|
87
|
+
{details: {...execution, runtimeBackend: runtime?.backend ?? null}}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const adapterLuid = device.addresses?.adapterLuid;
|
|
91
|
+
if (!is.integer(adapterLuid?.lowPart) || adapterLuid.lowPart < 0 || adapterLuid.lowPart > 0xffffffff
|
|
92
|
+
|| !is.integer(adapterLuid?.highPart) || adapterLuid.highPart < -0x80000000 || adapterLuid.highPart > 0x7fffffff) {
|
|
93
|
+
throw new ArcaneError(
|
|
94
|
+
'LOCAL_AI_EXECUTION_TARGET_UNSUPPORTED',
|
|
95
|
+
'The selected Windows GPU has no current adapter LUID for CUDA identity resolution.',
|
|
96
|
+
{details: execution}
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const identity = await resolveWindowsCudaIdentity({adapterLuid, signal, onEvent});
|
|
100
|
+
throwIfAborted(signal);
|
|
101
|
+
execution.configuredTarget = {
|
|
102
|
+
backend: 'cuda',
|
|
103
|
+
deviceId: device.deviceId,
|
|
104
|
+
device: 'CUDA0',
|
|
105
|
+
uuid: identity.uuid,
|
|
106
|
+
nodeMask: identity.nodeMask,
|
|
107
|
+
adapterLuid: identity.adapterLuid,
|
|
108
|
+
driverMode: identity.driverMode,
|
|
109
|
+
identitySource: 'nvidia-cuda-driver',
|
|
110
|
+
uuidFunction: identity.uuidFunction,
|
|
111
|
+
splitMode: 'none',
|
|
112
|
+
mainGpu: 0,
|
|
113
|
+
gpuLayers: 'all'
|
|
114
|
+
};
|
|
115
|
+
if (identity.diagnostics !== '') {
|
|
116
|
+
execution.diagnostics = [{source: 'cuda-driver', message: identity.diagnostics}];
|
|
117
|
+
}
|
|
118
|
+
// A single full UUID makes CUDA0 identify the selected physical GPU in the
|
|
119
|
+
// fresh server process, independent of DXGI or inherited CUDA ordinal order.
|
|
120
|
+
return {
|
|
121
|
+
args: ['--device', 'CUDA0', '--split-mode', 'none', '--main-gpu', '0', '--gpu-layers', 'all'],
|
|
122
|
+
env: {CUDA_VISIBLE_DEVICES: identity.uuid},
|
|
123
|
+
execution
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function resolveWindowsCudaIdentity({adapterLuid, signal, onEvent}) {
|
|
128
|
+
const script = await readFile(
|
|
129
|
+
new URL('./llama-execution-windows.ps1', import.meta.url),
|
|
130
|
+
{encoding: 'utf8', signal}
|
|
131
|
+
);
|
|
132
|
+
throwIfAborted(signal);
|
|
133
|
+
const command = process.env.SystemRoot
|
|
134
|
+
? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
135
|
+
: 'powershell.exe';
|
|
136
|
+
async function* identityInput() {
|
|
137
|
+
yield `& {\n${script}\n}\n\n`;
|
|
138
|
+
}
|
|
139
|
+
const result = await runProcess(
|
|
140
|
+
command,
|
|
141
|
+
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'],
|
|
142
|
+
{
|
|
143
|
+
signal,
|
|
144
|
+
onEvent,
|
|
145
|
+
env: {
|
|
146
|
+
ARCANE_LLAMA_LUID_LOW: String(adapterLuid.lowPart),
|
|
147
|
+
ARCANE_LLAMA_LUID_HIGH: String(adapterLuid.highPart)
|
|
148
|
+
},
|
|
149
|
+
input: identityInput(),
|
|
150
|
+
captureOutput: true,
|
|
151
|
+
emitOutputEvents: false
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
throwIfAborted(signal);
|
|
155
|
+
let identity;
|
|
156
|
+
try {
|
|
157
|
+
identity = JSON.parse(result.stdout);
|
|
158
|
+
if (!is.string(identity?.uuid) || identity.uuid === '' || !is.integer(identity.nodeMask)) {
|
|
159
|
+
throw new TypeError('The CUDA driver identity helper returned no device identity.');
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new ArcaneError(
|
|
163
|
+
ERROR_CODES.operationFailed,
|
|
164
|
+
'Could not read the selected CUDA device identity.',
|
|
165
|
+
{cause: error, details: result}
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return {...identity, diagnostics: result.stderr};
|
|
169
|
+
}
|
package/src/local-ai/server.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import Is from 'strong-type';
|
|
|
4
4
|
import {ArcaneError,ERROR_CODES,errorRecord,normalizeError,throwIfAborted} from '../errors.mjs';
|
|
5
5
|
import {createEventQueue} from '../event-queue.mjs';
|
|
6
6
|
import {runProcess} from '../process.mjs';
|
|
7
|
+
import {resolveLlamaExecution} from './llama-execution.mjs';
|
|
7
8
|
|
|
8
9
|
const is=new Is(false);
|
|
9
10
|
|
|
@@ -48,17 +49,21 @@ async function probeServer(id,url,signal){
|
|
|
48
49
|
return observed;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,onEvent,onState}={}){
|
|
52
|
+
export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,onEvent,onState,onExit,executionDevices,requireManaged=false}={}){
|
|
52
53
|
if(!['llama.cpp','ollama'].includes(id)){
|
|
53
54
|
throw new ArcaneError(ERROR_CODES.usage,`Unsupported local AI server: ${String(id)}.`);
|
|
54
55
|
}
|
|
55
56
|
const url=localAIServerURL(id,configuration);
|
|
56
57
|
const endpoint=new URL(url);
|
|
58
|
+
const managedSelection=requireManaged||(id==='llama.cpp'&&(configuration.executionTarget!==undefined
|
|
59
|
+
||(runtime?.requestedBackend&&runtime.requestedBackend!=='auto')));
|
|
57
60
|
const controller=new AbortController();
|
|
58
61
|
const operationSignal=signal?AbortSignal.any([signal,controller.signal]):controller.signal;
|
|
59
62
|
const events=createEventQueue(onEvent,{onFailure:fail});
|
|
60
63
|
let current={id,url,state:'starting',available:false,owned:false};
|
|
61
64
|
let owned=false;
|
|
65
|
+
let external=false;
|
|
66
|
+
let execution;
|
|
62
67
|
let processTask=null;
|
|
63
68
|
let processObservation=null;
|
|
64
69
|
let startupTask=null;
|
|
@@ -70,6 +75,8 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
70
75
|
let terminalFailure=null;
|
|
71
76
|
let resolveReady;
|
|
72
77
|
let rejectReady;
|
|
78
|
+
let resolveManagement;
|
|
79
|
+
const management=new Promise(function discoverManagement(resolve){resolveManagement=resolve;});
|
|
73
80
|
const output={stdout:'',stderr:''};
|
|
74
81
|
const ready=new Promise(function createReadiness(resolve,reject){
|
|
75
82
|
resolveReady=resolve;
|
|
@@ -80,7 +87,9 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
80
87
|
void ready.catch(function observeReadinessFailure(){return undefined;});
|
|
81
88
|
|
|
82
89
|
async function publishState(state,error){
|
|
83
|
-
current={id,url,state,available:state==='ready',owned
|
|
90
|
+
current={id,url,state,available:state==='ready',owned,
|
|
91
|
+
managed:!external&&Boolean(runtime?.executable),
|
|
92
|
+
...(id==='llama.cpp'?{execution:execution??null}:{}),...(error?{error:errorRecord(error)}:{})};
|
|
84
93
|
await onState?.(current);
|
|
85
94
|
await events.send({
|
|
86
95
|
type:`local-ai.server.${state}`,
|
|
@@ -147,6 +156,8 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
147
156
|
// endpoint at this actual launch boundary rather than presenting
|
|
148
157
|
// that earlier installation-selection snapshot as current health.
|
|
149
158
|
const observed=await probeServer(id,url,operationSignal);
|
|
159
|
+
external=true;
|
|
160
|
+
resolveManagement(false);
|
|
150
161
|
if(runtime?.requestedVersion&&runtime.requestedVersion!=='latest'){
|
|
151
162
|
const requested=runtime.requestedVersion.replace(/^v/u,'');
|
|
152
163
|
if(!is.string(observed.version)||observed.version.replace(/^v/u,'')!==requested){
|
|
@@ -158,10 +169,18 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
158
169
|
throwIfAborted(operationSignal);
|
|
159
170
|
// An HTTP error is an existing service's state. Only a refused
|
|
160
171
|
// connection allows starting the selected local executable.
|
|
161
|
-
if(!localAIConnectionRefused(error))
|
|
172
|
+
if(!localAIConnectionRefused(error)){
|
|
173
|
+
external=true;
|
|
174
|
+
resolveManagement(false);
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
162
177
|
if(!localHostname(endpoint.hostname))throw error;
|
|
163
178
|
}
|
|
164
179
|
if(existing){
|
|
180
|
+
external=true;
|
|
181
|
+
if(managedSelection){
|
|
182
|
+
throw new ArcaneError('LOCAL_AI_EXTERNAL_SELECTION_UNSUPPORTED','The existing llama.cpp server owns its model and device selection. Use an available listener for SDK-managed model loading.');
|
|
183
|
+
}
|
|
165
184
|
await publishState('ready');
|
|
166
185
|
throwIfAborted(operationSignal);
|
|
167
186
|
settleReady();
|
|
@@ -170,6 +189,7 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
170
189
|
if(!runtime?.executable){
|
|
171
190
|
throw new ArcaneError(ERROR_CODES.prerequisiteMissing,`No installed ${id} executable was supplied.`);
|
|
172
191
|
}
|
|
192
|
+
resolveManagement(true);
|
|
173
193
|
if(endpoint.protocol!=='http:'){
|
|
174
194
|
throw new ArcaneError(ERROR_CODES.usage,`${id} managed startup requires its direct HTTP listener URL.`);
|
|
175
195
|
}
|
|
@@ -187,12 +207,19 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
187
207
|
throwIfAborted(operationSignal);
|
|
188
208
|
const host=endpoint.hostname.replace(/^\[|\]$/gu,'');
|
|
189
209
|
const port=endpoint.port||'80';
|
|
210
|
+
const selected=id==='llama.cpp'?await resolveLlamaExecution({
|
|
211
|
+
executionTarget:configuration.executionTarget,executionDevices,runtime,signal:operationSignal,onEvent:events.send
|
|
212
|
+
}):null;
|
|
213
|
+
if(selected)execution=selected.execution;
|
|
190
214
|
const arguments_=id==='ollama'?['serve',...args]:[
|
|
191
215
|
'--host',host,'--port',port,'--jinja',
|
|
216
|
+
...(managedSelection?args:[]),
|
|
192
217
|
...(configuration.model?['--model',path.resolve(appRoot,configuration.model)]:['--models-dir',modelsDirectory]),
|
|
193
|
-
...args
|
|
218
|
+
...(!managedSelection?args:[]),
|
|
219
|
+
...(configuration.alias?['--alias',configuration.alias]:[]),
|
|
220
|
+
...selected.args
|
|
194
221
|
];
|
|
195
|
-
const env=id==='ollama'?{OLLAMA_HOST:endpoint.origin,OLLAMA_MODELS:modelsDirectory}:
|
|
222
|
+
const env=id==='ollama'?{OLLAMA_HOST:endpoint.origin,OLLAMA_MODELS:modelsDirectory}:selected.env;
|
|
196
223
|
owned=true;
|
|
197
224
|
await publishState('starting');
|
|
198
225
|
throwIfAborted(operationSignal);
|
|
@@ -215,7 +242,7 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
215
242
|
return;
|
|
216
243
|
}
|
|
217
244
|
await fail(error);
|
|
218
|
-
});
|
|
245
|
+
}).finally(async function releaseProcessResources(){await onExit?.();});
|
|
219
246
|
void processObservation.catch(function observeProcessCallbackFailure(error){
|
|
220
247
|
current={...current,state:'error',available:false,error:errorRecord(error)};
|
|
221
248
|
settleReady(error);
|
|
@@ -250,7 +277,10 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
250
277
|
});
|
|
251
278
|
}
|
|
252
279
|
|
|
253
|
-
startupTask=start().catch(fail)
|
|
280
|
+
startupTask=start().catch(fail).finally(async function releaseUnstartedResources(){
|
|
281
|
+
resolveManagement(false);
|
|
282
|
+
if(!processTask)await onExit?.();
|
|
283
|
+
});
|
|
254
284
|
void startupTask.catch(function observeStartupCallbackFailure(error){
|
|
255
285
|
current={...current,state:'error',available:false,error:errorRecord(error)};
|
|
256
286
|
settleReady(error);
|
|
@@ -258,5 +288,5 @@ export function createLocalAIServer({id,configuration={},runtime,appRoot,signal,
|
|
|
258
288
|
operationSignal.addEventListener('abort',observeLifetimeAbort,{once:true});
|
|
259
289
|
if(operationSignal.aborted)observeLifetimeAbort();
|
|
260
290
|
|
|
261
|
-
return {ready,url,close,get owned(){return owned;},get current(){return current;}};
|
|
291
|
+
return {ready,management,url,close,get owned(){return owned;},get external(){return external;},get current(){return current;}};
|
|
262
292
|
}
|
|
@@ -155,8 +155,8 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
155
155
|
const imageSelected = localAI?.runtimes?.some(function selectedImage(requirement) {
|
|
156
156
|
return (typeof requirement === 'string' ? requirement : requirement.id) === 'stable-diffusion.cpp';
|
|
157
157
|
}) ?? false;
|
|
158
|
-
const modelAssetsSelected = imageSelected || (localAI?.runtimes?.some(function
|
|
159
|
-
return (typeof requirement === 'string' ? requirement : requirement.id)
|
|
158
|
+
const modelAssetsSelected = imageSelected || (localAI?.runtimes?.some(function selectedProjectedRuntime(requirement) {
|
|
159
|
+
return ['onnx', 'llama.cpp'].includes(typeof requirement === 'string' ? requirement : requirement.id);
|
|
160
160
|
}) ?? false);
|
|
161
161
|
const whisperSelected = localAI?.runtimes?.some(function selectedWhisper(requirement) {
|
|
162
162
|
return (typeof requirement === 'string' ? requirement : requirement.id) === 'whisper.cpp';
|
|
@@ -199,13 +199,21 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
199
199
|
' register(packagedWeb);');
|
|
200
200
|
}
|
|
201
201
|
return [
|
|
202
|
-
"import {fileURLToPath} from 'node:url';",
|
|
202
|
+
"import {fileURLToPath, pathToFileURL} from 'node:url';",
|
|
203
203
|
"import path from 'node:path';",
|
|
204
204
|
"import {isSea} from 'node:sea';",
|
|
205
205
|
...(launchDefaults === undefined ? [] : ["import {mkdir} from 'node:fs/promises';"]),
|
|
206
206
|
"import {readCoreLaunchContext, startCoreHost, startCoreListener, runSharedCoreHost, startSharedCoreBridge} from 'arcane-os/core/host';",
|
|
207
207
|
"import {createModelObservationService} from 'arcane-os/core/model-observation';",
|
|
208
208
|
'',
|
|
209
|
+
"const appEntryFlag = process.argv.indexOf('--arcane-app-entry', 2);",
|
|
210
|
+
'if (appEntryFlag !== -1) {',
|
|
211
|
+
' const appEntry = process.argv[appEntryFlag + 1];',
|
|
212
|
+
" if (appEntry === undefined || appEntry === '') throw new TypeError('--arcane-app-entry requires an app-relative module path.');",
|
|
213
|
+
" const appRoot = fileURLToPath(new URL('../app/', import.meta.url));",
|
|
214
|
+
' await import(pathToFileURL(path.resolve(appRoot, appEntry)).href);',
|
|
215
|
+
'} else {',
|
|
216
|
+
...[
|
|
209
217
|
...(localAI === undefined ? [] : [
|
|
210
218
|
'// Bundled runtime paths are artifact-root-relative POSIX paths.',
|
|
211
219
|
'function runtimePath(relative) {',
|
|
@@ -366,6 +374,8 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
366
374
|
' }',
|
|
367
375
|
" process.off('SIGINT', closeCore);",
|
|
368
376
|
" process.off('SIGTERM', closeCore);",
|
|
377
|
+
'}'
|
|
378
|
+
].map(function indentOrdinaryCore(line) { return line ? ` ${line}` : line; }),
|
|
369
379
|
'}',
|
|
370
380
|
''
|
|
371
381
|
].join('\n');
|
|
@@ -85,15 +85,96 @@ async function removeTemporaryTree(location){
|
|
|
85
85
|
await rm(location,{recursive:true,force:true}).catch(()=>{});
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
async function materializeBrowserDecisions(destination, signal, onEvent) {
|
|
89
|
+
const transformers = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0/';
|
|
90
|
+
const onnx = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.31.0-dev.20260914-8d85527a0/';
|
|
91
|
+
const files = [
|
|
92
|
+
{name: 'transformers.min.js', url: `${transformers}dist/transformers.min.js`},
|
|
93
|
+
{name: 'ort-wasm-simd-threaded.asyncify.mjs', url: `${onnx}dist/ort-wasm-simd-threaded.asyncify.mjs`},
|
|
94
|
+
{name: 'ort-wasm-simd-threaded.asyncify.wasm', url: `${onnx}dist/ort-wasm-simd-threaded.asyncify.wasm`},
|
|
95
|
+
{name: 'TRANSFORMERS-LICENSE', url: `${transformers}LICENSE`},
|
|
96
|
+
{name: 'ONNX-RUNTIME-LICENSE', url: `${onnx}LICENSE`}
|
|
97
|
+
];
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
function cancelDistribution() {
|
|
100
|
+
controller.abort(signal.reason);
|
|
101
|
+
}
|
|
102
|
+
signal?.addEventListener('abort', cancelDistribution, {once: true});
|
|
103
|
+
if (signal?.aborted) cancelDistribution();
|
|
104
|
+
let completed = 0;
|
|
105
|
+
try {
|
|
106
|
+
throwIfAborted(controller.signal);
|
|
107
|
+
await emit(
|
|
108
|
+
onEvent,
|
|
109
|
+
{type: 'workspace.decisions.started', completed, total: files.length}
|
|
110
|
+
);
|
|
111
|
+
throwIfAborted(controller.signal);
|
|
112
|
+
await mkdir(destination, {recursive: true});
|
|
113
|
+
async function acquireDistributionFile(file) {
|
|
114
|
+
try {
|
|
115
|
+
throwIfAborted(controller.signal);
|
|
116
|
+
const response = await fetch(file.url, {signal: controller.signal});
|
|
117
|
+
const content = new Uint8Array(await response.arrayBuffer());
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const error = new Error(`Browser decision distribution returned HTTP ${response.status} ${response.statusText}.`);
|
|
120
|
+
error.code = 'ARCANE_DECISION_DISTRIBUTION_DOWNLOAD_FAILED';
|
|
121
|
+
error.response = {
|
|
122
|
+
url: response.url,
|
|
123
|
+
status: response.status,
|
|
124
|
+
statusText: response.statusText,
|
|
125
|
+
headers: [...response.headers],
|
|
126
|
+
content
|
|
127
|
+
};
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
throwIfAborted(controller.signal);
|
|
131
|
+
// Upstream distribution content is opaque, including its own
|
|
132
|
+
// imports and license text. No SDK source rewriting applies.
|
|
133
|
+
await writeFile(path.join(destination, file.name), content);
|
|
134
|
+
throwIfAborted(controller.signal);
|
|
135
|
+
completed += 1;
|
|
136
|
+
await emit(
|
|
137
|
+
onEvent,
|
|
138
|
+
{type: 'workspace.decisions.progress', file: file.name, url: file.url, completed, total: files.length}
|
|
139
|
+
);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
controller.abort(error);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// One fixed distribution, acquired once for the selected projection;
|
|
146
|
+
// no model/app/architecture multiplier or installation script.
|
|
147
|
+
const outcomes = await Promise.allSettled(files.map(acquireDistributionFile));
|
|
148
|
+
const failures = new Set();
|
|
149
|
+
for (const outcome of outcomes) {
|
|
150
|
+
if (outcome.status === 'rejected') failures.add(outcome.reason);
|
|
151
|
+
}
|
|
152
|
+
if (failures.size === 1) throw [...failures][0];
|
|
153
|
+
if (failures.size > 1) {
|
|
154
|
+
throw new AggregateError([...failures], 'Browser decision distribution acquisition failed.');
|
|
155
|
+
}
|
|
156
|
+
throwIfAborted(controller.signal);
|
|
157
|
+
} finally {
|
|
158
|
+
signal?.removeEventListener('abort', cancelDistribution);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
transformersVersion: '4.3.0',
|
|
162
|
+
onnxRuntimeVersion: '1.31.0-dev.20260914-8d85527a0',
|
|
163
|
+
files: files.map(function distributionFilename(file) { return file.name; })
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
88
167
|
export async function materializeWorkspaceRuntimeContent({
|
|
89
168
|
workspaceRoot,
|
|
90
169
|
runtimeRoot=path.join(getSdkRoot(),'runtime'),
|
|
91
170
|
browserRuntimeRoot=getSdkBrowserRuntimeRoot(),
|
|
92
171
|
sdkVersion=SDK_VERSION,
|
|
172
|
+
browserDecisions=false,
|
|
93
173
|
signal,
|
|
94
174
|
onEvent
|
|
95
175
|
}={}){
|
|
96
176
|
if(!workspaceRoot)fail('workspaceRoot is required to materialize a workspace runtime.');
|
|
177
|
+
if(!is.boolean(browserDecisions))throw new TypeError('browserDecisions must be a boolean.');
|
|
97
178
|
throwIfAborted(signal);
|
|
98
179
|
const workspace=await realDirectory(workspaceRoot,'Workspace root');
|
|
99
180
|
const runtime=await realDirectory(runtimeRoot,'SDK runtime root');
|
|
@@ -109,6 +190,7 @@ export async function materializeWorkspaceRuntimeContent({
|
|
|
109
190
|
const backupRoot=path.join(workspace,`${BACKUP_PREFIX}${token}`);
|
|
110
191
|
let backedUp=false;
|
|
111
192
|
let promoted=false;
|
|
193
|
+
let decisionDistribution;
|
|
112
194
|
|
|
113
195
|
await mkdir(stagingRoot);
|
|
114
196
|
try{
|
|
@@ -134,6 +216,14 @@ export async function materializeWorkspaceRuntimeContent({
|
|
|
134
216
|
);
|
|
135
217
|
}
|
|
136
218
|
|
|
219
|
+
if (browserDecisions) {
|
|
220
|
+
decisionDistribution = await materializeBrowserDecisions(
|
|
221
|
+
path.join(sdkDestination, 'ai', 'decisions-runtime'),
|
|
222
|
+
signal,
|
|
223
|
+
onEvent
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
137
227
|
throwIfAborted(signal);
|
|
138
228
|
try{
|
|
139
229
|
const existing=await lstat(destinationRoot);
|
|
@@ -160,7 +250,13 @@ export async function materializeWorkspaceRuntimeContent({
|
|
|
160
250
|
return {
|
|
161
251
|
kind:'arcane-workspace-runtime-content',
|
|
162
252
|
workspaceRoot:workspace,
|
|
163
|
-
runtimeRoot:destinationRoot
|
|
253
|
+
runtimeRoot:destinationRoot,
|
|
254
|
+
...(decisionDistribution ? {
|
|
255
|
+
browserDecisions: {
|
|
256
|
+
...decisionDistribution,
|
|
257
|
+
directory: path.join(destinationRoot, 'sdk', 'ai', 'decisions-runtime')
|
|
258
|
+
}
|
|
259
|
+
} : {})
|
|
164
260
|
};
|
|
165
261
|
}catch(error){
|
|
166
262
|
if(promoted)await removeTemporaryTree(destinationRoot);
|