arcane-os 0.88.0 → 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 +32 -0
- package/README.md +1 -1
- 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/cli.md +10 -0
- 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 +146 -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-operation-lock.mjs +23 -10
- 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');
|
|
@@ -98,17 +98,20 @@ async function ensureLockDirectory(workspace){
|
|
|
98
98
|
return physicalDirectory(directory,'Workspace operation-lock directory',{create:true});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function
|
|
101
|
+
function ownerIsAbsent(pid){
|
|
102
102
|
if(!is.safeInteger(pid)||pid<1)return false;
|
|
103
103
|
try{
|
|
104
104
|
process.kill(pid,0);
|
|
105
|
-
return
|
|
105
|
+
return false;
|
|
106
106
|
}catch(error){
|
|
107
|
-
|
|
107
|
+
if(error?.code==='ESRCH')return true;
|
|
108
|
+
const busyError=workspaceBusy('The recorded Arcane workspace operation owner could not be determined.');
|
|
109
|
+
busyError.cause=error;
|
|
110
|
+
throw busyError;
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
function lockDocument(value,{workspaceRoot
|
|
114
|
+
function lockDocument(value,{workspaceRoot}={}){
|
|
112
115
|
if(!value||!is.object(value)||is.array(value)
|
|
113
116
|
||value.schemaVersion!==1
|
|
114
117
|
||value.kind!=='arcane-workspace-operation-lock'
|
|
@@ -124,7 +127,7 @@ function lockDocument(value,{workspaceRoot,now=Date.now()}={}){
|
|
|
124
127
|
}
|
|
125
128
|
const expiresAt=Date.parse(value.expiresAt);
|
|
126
129
|
if(!is.finite(expiresAt))return null;
|
|
127
|
-
return
|
|
130
|
+
return value;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
async function readLock(lockPath,workspaceRoot){
|
|
@@ -145,16 +148,24 @@ async function readLock(lockPath,workspaceRoot){
|
|
|
145
148
|
return lockDocument(value,{workspaceRoot});
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
async function
|
|
151
|
+
async function recoverAbsentOwnerLock(lockPath,workspaceRoot,onEvent,signal){
|
|
152
|
+
throwIfAborted(signal);
|
|
149
153
|
const document=await readLock(lockPath,workspaceRoot);
|
|
150
|
-
|
|
154
|
+
throwIfAborted(signal);
|
|
155
|
+
if(!document||!ownerIsAbsent(document.owner.pid))return false;
|
|
151
156
|
await onEvent?.({
|
|
152
157
|
type:'workspace.operation.stale-recovered',
|
|
153
158
|
workspaceRoot,
|
|
154
159
|
lockPath,
|
|
155
|
-
previousOwner:document.owner,
|
|
160
|
+
previousOwner:{...document.owner},
|
|
156
161
|
previousOperation:document.operation
|
|
157
162
|
});
|
|
163
|
+
throwIfAborted(signal);
|
|
164
|
+
const current=await readLock(lockPath,workspaceRoot);
|
|
165
|
+
throwIfAborted(signal);
|
|
166
|
+
// Observers may replace the record; their completion does not retain ownership.
|
|
167
|
+
if(current?.nonce!==document.nonce||current?.owner.pid!==document.owner.pid
|
|
168
|
+
||!ownerIsAbsent(current.owner.pid))return false;
|
|
158
169
|
await rm(lockPath);
|
|
159
170
|
return true;
|
|
160
171
|
}
|
|
@@ -254,15 +265,17 @@ async function acquire({
|
|
|
254
265
|
lockPath=path.join(directory.canonical,LOCK_NAME);
|
|
255
266
|
let recovered=false;
|
|
256
267
|
for(;;){
|
|
268
|
+
throwIfAborted(signal);
|
|
257
269
|
try{
|
|
258
270
|
handle=await open(lockPath,OPEN_EXCLUSIVE,0o600);
|
|
259
271
|
break;
|
|
260
272
|
}catch(error){
|
|
261
273
|
if(error?.code!=='EEXIST')throw error;
|
|
262
|
-
if(recovered||!(await
|
|
274
|
+
if(recovered||!(await recoverAbsentOwnerLock(
|
|
263
275
|
lockPath,
|
|
264
276
|
workspace.canonical,
|
|
265
|
-
onEvent
|
|
277
|
+
onEvent,
|
|
278
|
+
signal
|
|
266
279
|
))){
|
|
267
280
|
fail(
|
|
268
281
|
`Workspace is locked by another Arcane operation: ${lockPath}. `
|