@oar-sh/oar 0.9.22 → 0.9.24
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 +258 -0
- package/README.md +9 -9
- package/bin/oar.js +82 -4
- package/package.json +16 -15
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +91 -8
- package/server/public/app/bootstrap.js +204 -69
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/settings-modal.js +167 -0
- package/server/public/app/socket-handlers.js +21 -0
- package/server/public/index.html +95 -2
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/messages-routes.mjs +102 -37
- package/server/routes/sessions-routes.mjs +236 -110
- package/server/server-runtime.mjs +416 -121
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +17 -1
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +5 -3
- package/server/services/file-serving.mjs +13 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/native-addon-build-service.mjs +224 -0
- package/server/services/oar-cli-helpers.mjs +76 -10
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-console-log.mjs +2 -3
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-loopback.mjs +3 -3
- package/server/services/sdk-session-sync-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/update-install-service.mjs +34 -3
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/worker-runtime/direct-openai-image.mjs +8 -4
- package/server/services/package-data-rescue-preinstall.mjs +0 -22
- package/server/services/package-data-rescue.mjs +0 -139
- package/server/tty-console-bootstrap.mjs +0 -75
|
@@ -24,9 +24,10 @@ export { readGitCommit, resolveBuildCommit };
|
|
|
24
24
|
export const ABOUT_TOTALS_CACHE_MS = 60_000;
|
|
25
25
|
export const ABOUT_MESSAGE_CHUNK_ROWS = 10_000;
|
|
26
26
|
|
|
27
|
-
// The
|
|
27
|
+
// The agent SDKs each provider's workers run on, by provider and store id
|
|
28
|
+
// (agent-sdk-store.mjs). Copilot has none of its own: its workers load the
|
|
29
|
+
// SDK the Copilot CLI ships, so its row names the CLI version only.
|
|
28
30
|
export const ABOUT_PROVIDER_SDK_PACKAGES = Object.freeze({
|
|
29
|
-
github: '@github/copilot-sdk',
|
|
30
31
|
claude: '@anthropic-ai/claude-agent-sdk',
|
|
31
32
|
cursor: '@cursor/sdk',
|
|
32
33
|
});
|
|
@@ -302,6 +303,7 @@ async function fileSize(fsImpl, filePath) {
|
|
|
302
303
|
* @param {() => boolean} [deps.isRelayCliOnline] the Copilot relay CLI is connected
|
|
303
304
|
* @param {() => object[]} [deps.listWorkers] the session worker registry's entries
|
|
304
305
|
* @param {() => object|null} [deps.getUpdateState] buildUpdateStatePayload()
|
|
306
|
+
* @param {() => object|null} [deps.getAgentSdkStatus] the agent SDK store's snapshot(); its `installed` versions come first
|
|
305
307
|
* @param {object} [deps.sdkVersions] package version by provider id; read from node_modules when absent
|
|
306
308
|
* @param {() => object|null} [deps.describeBackups] the database backups (db-backup-service.mjs describeBackups)
|
|
307
309
|
*/
|
|
@@ -320,6 +322,7 @@ export function createAboutService({
|
|
|
320
322
|
isRelayCliOnline = () => false,
|
|
321
323
|
listWorkers = () => [],
|
|
322
324
|
getUpdateState = () => null,
|
|
325
|
+
getAgentSdkStatus = null,
|
|
323
326
|
sdkVersions = null,
|
|
324
327
|
isPidAlive = defaultIsPidAlive,
|
|
325
328
|
fsImpl = fs,
|
|
@@ -332,7 +335,23 @@ export function createAboutService({
|
|
|
332
335
|
let resolvedSdkVersions = sdkVersions;
|
|
333
336
|
const providerForConversation = db.prepare(`SELECT provider_type FROM runtime_sessions WHERE conversation_id = ?`);
|
|
334
337
|
|
|
335
|
-
|
|
338
|
+
// The store's installed versions, read per answer (an install or a
|
|
339
|
+
// reinstall changes them under a running relay); null without a store.
|
|
340
|
+
function readAgentSdkSnapshot() {
|
|
341
|
+
if (typeof getAgentSdkStatus !== 'function') return null;
|
|
342
|
+
try {
|
|
343
|
+
return getAgentSdkStatus()?.sdks || null;
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The store's folder first, then the package's own node_modules: the order
|
|
350
|
+
// agent-sdk-loader.mjs loads them in (a git checkout runs its
|
|
351
|
+
// devDependency until the store has the pinned folder).
|
|
352
|
+
function sdkVersionFor(providerId, agentSdks) {
|
|
353
|
+
const installed = String(agentSdks?.[providerId]?.installed || '').trim();
|
|
354
|
+
if (installed) return installed;
|
|
336
355
|
if (!resolvedSdkVersions) {
|
|
337
356
|
resolvedSdkVersions = {};
|
|
338
357
|
for (const [id, packageName] of Object.entries(ABOUT_PROVIDER_SDK_PACKAGES)) {
|
|
@@ -385,6 +404,7 @@ export function createAboutService({
|
|
|
385
404
|
const settings = getProviderSettings() || {};
|
|
386
405
|
const cli = getCachedCliProviders() || {};
|
|
387
406
|
const relayCliOnline = isRelayCliOnline() === true;
|
|
407
|
+
const agentSdks = readAgentSdkSnapshot();
|
|
388
408
|
const rows = [];
|
|
389
409
|
for (const id of Object.keys(ABOUT_PROVIDER_LABELS)) {
|
|
390
410
|
const entry = settings[id];
|
|
@@ -395,15 +415,15 @@ export function createAboutService({
|
|
|
395
415
|
const viaRelayCli = id === 'openai' || (id === 'github' && engine === 'extension');
|
|
396
416
|
const runningWorkers = workerCounts.get(id) || 0;
|
|
397
417
|
const cliRow = PROVIDER_CLI_IDS[id] ? cli[PROVIDER_CLI_IDS[id]] : null;
|
|
398
|
-
const
|
|
418
|
+
const sdkPackage = ABOUT_PROVIDER_SDK_PACKAGES[id] || null;
|
|
399
419
|
rows.push({
|
|
400
420
|
id,
|
|
401
421
|
label: ABOUT_PROVIDER_LABELS[id],
|
|
402
422
|
engine,
|
|
403
423
|
cliVersion: cliRow?.installed ? (cliRow.version || null) : null,
|
|
404
424
|
cliCheckedAt: cliRow?.checkedAt || null,
|
|
405
|
-
sdkPackage
|
|
406
|
-
sdkVersion:
|
|
425
|
+
sdkPackage,
|
|
426
|
+
sdkVersion: sdkPackage ? sdkVersionFor(id, agentSdks) : null,
|
|
407
427
|
state: viaRelayCli
|
|
408
428
|
? (relayCliOnline ? 'online' : 'offline')
|
|
409
429
|
: (runningWorkers > 0 ? 'online' : 'idle'),
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The launch gate of the agent SDKs (agent-sdk-store.mjs): which SDK folder
|
|
5
|
+
* a worker is launched with, and what happens while there is none.
|
|
6
|
+
*
|
|
7
|
+
* A worker kind on an SDK (the Claude workers on Claude's, Cursor on its
|
|
8
|
+
* own) gets the store's ready folder, which it keeps for its whole life. A
|
|
9
|
+
* checkout's devDependency needs no folder. A missing SDK is never waited
|
|
10
|
+
* for: the gate starts (or joins) its install, puts an "Installing the …
|
|
11
|
+
* SDK" note in the conversation, and answers `deferred` at once. The
|
|
12
|
+
* supervisor counts that as no launch and no failure, so the request that
|
|
13
|
+
* asked for the worker (a new chat, a send, the queue's prime) answers right
|
|
14
|
+
* away and the turn stays queued. When the install ends, and whenever the
|
|
15
|
+
* SDK turns ready later (the Retry button, the automatic retry), the
|
|
16
|
+
* sessions that met the gate get their launch back (`onSessionsReleased`):
|
|
17
|
+
* their restart schedule is cleared and the queue is woken.
|
|
18
|
+
*
|
|
19
|
+
* An SDK that failed, or has no build for this platform, stops the launch
|
|
20
|
+
* with one line naming the Settings tab; the worker launch note carries it
|
|
21
|
+
* and the message stays queued for the Retry.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const AGENT_SDK_INSTALLING = 'agent-sdk-installing';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a stale worker of `kind` may lose its SDK's files to an update,
|
|
28
|
+
* so the recycle replaces it before its next message: a kind on an agent
|
|
29
|
+
* SDK the store installs. One whose SDK comes from a git checkout's
|
|
30
|
+
* node_modules keeps its files across a restart on new code, and is
|
|
31
|
+
* recycled like any worker (between turns, once its CLI child is idle).
|
|
32
|
+
*/
|
|
33
|
+
export function staleWorkerLosesAgentSdk(kind, { sdkForWorkerKind, fromCheckout }) {
|
|
34
|
+
const sdk = sdkForWorkerKind(kind);
|
|
35
|
+
return sdk !== null && !fromCheckout(sdk);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createAgentSdkLaunchGate({
|
|
39
|
+
store,
|
|
40
|
+
sdkForWorkerKind,
|
|
41
|
+
resolveSource,
|
|
42
|
+
noteInstalling = () => {},
|
|
43
|
+
onSessionsReleased = () => {},
|
|
44
|
+
logger = console,
|
|
45
|
+
} = {}) {
|
|
46
|
+
if (!store) throw new Error('createAgentSdkLaunchGate requires a store');
|
|
47
|
+
// Per SDK, the sessions whose launch met the gate and waits for it.
|
|
48
|
+
const waiting = new Map();
|
|
49
|
+
// Per SDK, an install this gate already follows to its end.
|
|
50
|
+
const following = new Set();
|
|
51
|
+
|
|
52
|
+
const log = (message) => {
|
|
53
|
+
try { logger?.log?.(`[agent-sdk] ${message}`); } catch {}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function remember(sdk, sdkSessionId) {
|
|
57
|
+
if (!waiting.has(sdk)) waiting.set(sdk, new Set());
|
|
58
|
+
waiting.get(sdk).add(sdkSessionId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Hands every session that waited on `sdk` its launch back. */
|
|
62
|
+
function release(sdk, reason) {
|
|
63
|
+
const sessions = [...(waiting.get(sdk) || [])];
|
|
64
|
+
waiting.delete(sdk);
|
|
65
|
+
if (!sessions.length) return [];
|
|
66
|
+
log(`${sdk}: ${reason}; ${sessions.length} waiting session(s) may launch`);
|
|
67
|
+
try { onSessionsReleased({ sdk, sessions, reason }); } catch (error) {
|
|
68
|
+
log(`${sdk}: releasing the waiting sessions failed: ${error?.message || error}`);
|
|
69
|
+
}
|
|
70
|
+
return sessions;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function follow(sdk) {
|
|
74
|
+
if (following.has(sdk)) return;
|
|
75
|
+
following.add(sdk);
|
|
76
|
+
Promise.resolve(store.ensure(sdk, { reason: 'first chat' }))
|
|
77
|
+
.catch((error) => log(`${sdk}: install failed: ${error?.message || error}`))
|
|
78
|
+
.finally(() => {
|
|
79
|
+
following.delete(sdk);
|
|
80
|
+
// Ready or failed: the next launch says which.
|
|
81
|
+
release(sdk, 'install ended');
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What the launch of `sdkSessionId` (worker kind `workerKind`) does:
|
|
87
|
+
* `{ sdk: null }` (no SDK, or a checkout's devDependency),
|
|
88
|
+
* `{ sdk, dir }` (launch with this folder), or `{ sdk, deferred }` (not
|
|
89
|
+
* launched now). Throws for an SDK that failed or has no build here.
|
|
90
|
+
*/
|
|
91
|
+
function check(sdkSessionId, workerKind) {
|
|
92
|
+
const sdk = sdkForWorkerKind(workerKind);
|
|
93
|
+
if (!sdk) return { sdk: null };
|
|
94
|
+
const found = resolveSource(sdk);
|
|
95
|
+
if (found?.source === 'checkout') return { sdk: null };
|
|
96
|
+
if (found?.dir) return { sdk, dir: found.dir };
|
|
97
|
+
const status = store.status(sdk);
|
|
98
|
+
const label = store.getDescriptor?.(sdk)?.label || status?.label || sdk;
|
|
99
|
+
if (status && ['error', 'unsupported'].includes(status.state) && !status.active) {
|
|
100
|
+
// Remembered too: a Retry that brings the SDK back launches it.
|
|
101
|
+
remember(sdk, sdkSessionId);
|
|
102
|
+
const tab = sdk === 'cursor' ? 'Cursor' : 'Claude';
|
|
103
|
+
const why = status.state === 'unsupported'
|
|
104
|
+
? `${label} is not available on this platform`
|
|
105
|
+
: `${label} could not be installed: ${status.error || 'unknown error'}`;
|
|
106
|
+
throw new Error(`${why} (Settings → Providers → ${tab})`);
|
|
107
|
+
}
|
|
108
|
+
remember(sdk, sdkSessionId);
|
|
109
|
+
try { noteInstalling(sdkSessionId, { sdk, label }); } catch {}
|
|
110
|
+
follow(sdk);
|
|
111
|
+
return { sdk, deferred: AGENT_SDK_INSTALLING };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The store's ready hook: an SDK that came back launches its waiters. */
|
|
115
|
+
function onReady(sdk) {
|
|
116
|
+
return release(sdk, 'ready');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
check,
|
|
121
|
+
onReady,
|
|
122
|
+
waitingSessions: (sdk) => [...(waiting.get(sdk) || [])],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { resolveOarRoot } from './oar-state-root.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where the agent SDKs the relay installs on demand live, and which folder
|
|
10
|
+
* holds a usable copy. Synchronous, without a database and without imports
|
|
11
|
+
* of the relay's own services: the relay's lock check (koffi on Windows) and
|
|
12
|
+
* a worker's loader read this before anything else runs. The installing half
|
|
13
|
+
* is agent-sdk-store.mjs.
|
|
14
|
+
*
|
|
15
|
+
* <root>/<sdk>/<version>-<hash8>/ one folder per SDK, version and platform
|
|
16
|
+
* oar-sdk.json the marker, written last
|
|
17
|
+
* <root>/<sdk>/.leases/<folder>/<pid>-*.json who uses a folder (agent-sdk-store.mjs)
|
|
18
|
+
* <root>/<sdk>/.store.lock held while one process installs or deletes
|
|
19
|
+
*
|
|
20
|
+
* <root> is <state root>/sdks, or OAR_SDK_DIR. <hash8> covers the pins
|
|
21
|
+
* folder's files (package.json, lockfile, entries) and the platform, so a
|
|
22
|
+
* changed entry file or a root shared by Windows and WSL never reuses a
|
|
23
|
+
* folder made for something else.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const AGENT_SDK_PINS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'agent-sdks');
|
|
27
|
+
export const AGENT_SDK_MARKER = 'oar-sdk.json';
|
|
28
|
+
export const AGENT_SDK_LEASES_DIR = '.leases';
|
|
29
|
+
// Bumped when the folder layout or the marker changes meaning.
|
|
30
|
+
export const AGENT_SDK_STORE_FORMAT = 1;
|
|
31
|
+
|
|
32
|
+
/** What each pins folder installs, and which file a loader imports. */
|
|
33
|
+
export const AGENT_SDK_DESCRIPTORS = Object.freeze({
|
|
34
|
+
claude: Object.freeze({
|
|
35
|
+
id: 'claude',
|
|
36
|
+
label: 'Claude Agent SDK',
|
|
37
|
+
packageName: '@anthropic-ai/claude-agent-sdk',
|
|
38
|
+
entries: Object.freeze({ main: 'entry.mjs' }),
|
|
39
|
+
platformScope: '@anthropic-ai/claude-agent-sdk-',
|
|
40
|
+
// Platforms without a vendor build where the SDK runs the host's own
|
|
41
|
+
// Claude Code CLI instead (CLAUDE_CODE_EXECUTABLE). None is verified
|
|
42
|
+
// yet; native Termux (android) would be the candidate.
|
|
43
|
+
hostCliPlatforms: Object.freeze([]),
|
|
44
|
+
platforms: null,
|
|
45
|
+
}),
|
|
46
|
+
cursor: Object.freeze({
|
|
47
|
+
id: 'cursor',
|
|
48
|
+
label: 'Cursor SDK',
|
|
49
|
+
packageName: '@cursor/sdk',
|
|
50
|
+
entries: Object.freeze({ main: 'entry.mjs', sqlite: 'entry-sqlite.mjs' }),
|
|
51
|
+
platformScope: '@cursor/sdk-',
|
|
52
|
+
platforms: null,
|
|
53
|
+
}),
|
|
54
|
+
koffi: Object.freeze({
|
|
55
|
+
id: 'koffi',
|
|
56
|
+
label: 'Windows helper (koffi)',
|
|
57
|
+
packageName: 'koffi',
|
|
58
|
+
entries: Object.freeze({ main: 'entry.cjs' }),
|
|
59
|
+
platformScope: '@koromix/koffi-',
|
|
60
|
+
// The relay needs it only for its lock check on Windows.
|
|
61
|
+
platforms: Object.freeze(['win32']),
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const AGENT_SDK_IDS = Object.freeze(Object.keys(AGENT_SDK_DESCRIPTORS));
|
|
66
|
+
|
|
67
|
+
export function getAgentSdkDescriptor(id) {
|
|
68
|
+
const key = String(id || '').trim().toLowerCase();
|
|
69
|
+
return Object.prototype.hasOwnProperty.call(AGENT_SDK_DESCRIPTORS, key) ? AGENT_SDK_DESCRIPTORS[key] : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `<state root>/sdks`, or OAR_SDK_DIR; always absolute. */
|
|
73
|
+
export function resolveAgentSdkRoot({ env = process.env, platform = process.platform } = {}) {
|
|
74
|
+
const override = String(env?.OAR_SDK_DIR || '').trim();
|
|
75
|
+
if (override) return path.resolve(override);
|
|
76
|
+
return path.join(resolveOarRoot(env, platform), 'sdks');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Linux's C library as npm tells it, 'glibc' or 'musl'; null elsewhere. */
|
|
80
|
+
export function detectLibc({ platform = process.platform, report = () => process.report?.getReport?.() } = {}) {
|
|
81
|
+
if (platform !== 'linux') return null;
|
|
82
|
+
try {
|
|
83
|
+
return report()?.header?.glibcVersionRuntime ? 'glibc' : 'musl';
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function hostPlatform({ platform = process.platform, arch = process.arch, libc } = {}) {
|
|
90
|
+
return { platform, arch, libc: libc === undefined ? detectLibc({ platform }) : libc };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pinCache = new Map();
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The pin of an SDK for this host: the pins folder's files as read once
|
|
97
|
+
* (an `oar update` replaces them under a running relay, which goes on
|
|
98
|
+
* installing what it was started with), the pinned version and the folder
|
|
99
|
+
* name, `<version>-<first 8 hex of the content hash>`.
|
|
100
|
+
*/
|
|
101
|
+
export function readAgentSdkPin(id, { pinsDir = AGENT_SDK_PINS_DIR, fsImpl = fs, host = hostPlatform() } = {}) {
|
|
102
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
103
|
+
if (!descriptor) return null;
|
|
104
|
+
const folder = path.join(pinsDir, descriptor.id);
|
|
105
|
+
const cacheKey = JSON.stringify([folder, host.platform, host.arch, host.libc]);
|
|
106
|
+
if (fsImpl === fs && pinCache.has(cacheKey)) return pinCache.get(cacheKey);
|
|
107
|
+
const names = ['package.json', 'package-lock.json', ...Object.values(descriptor.entries)].sort();
|
|
108
|
+
const files = {};
|
|
109
|
+
const hash = crypto.createHash('sha256').update(`oar-sdk-store:${AGENT_SDK_STORE_FORMAT}\0${host.platform}\0${host.arch}\0${host.libc || ''}\0`);
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
files[name] = fsImpl.readFileSync(path.join(folder, name));
|
|
112
|
+
hash.update(`${name}\0`).update(files[name]).update('\0');
|
|
113
|
+
}
|
|
114
|
+
const packageJson = JSON.parse(files['package.json'].toString('utf8'));
|
|
115
|
+
const version = String(packageJson.dependencies?.[descriptor.packageName] || '');
|
|
116
|
+
const fullHash = hash.digest('hex');
|
|
117
|
+
const pin = Object.freeze({
|
|
118
|
+
id: descriptor.id,
|
|
119
|
+
version,
|
|
120
|
+
hash: fullHash,
|
|
121
|
+
dirName: `${version}-${fullHash.slice(0, 8)}`,
|
|
122
|
+
host: Object.freeze({ ...host }),
|
|
123
|
+
files: Object.freeze(files),
|
|
124
|
+
lock: JSON.parse(files['package-lock.json'].toString('utf8')),
|
|
125
|
+
});
|
|
126
|
+
if (fsImpl === fs) pinCache.set(cacheKey, pin);
|
|
127
|
+
return pin;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function matchesList(list, value) {
|
|
131
|
+
if (!Array.isArray(list) || !list.length) return true;
|
|
132
|
+
if (list.includes(`!${value}`)) return false;
|
|
133
|
+
const allowed = list.filter((item) => !String(item).startsWith('!'));
|
|
134
|
+
return !allowed.length || allowed.includes(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The vendor's platform package this host gets from the pinned lockfile, or
|
|
139
|
+
* null when the vendor lists none for it. Matches os, cpu and libc as npm
|
|
140
|
+
* does.
|
|
141
|
+
*/
|
|
142
|
+
export function platformPackageFor(pin) {
|
|
143
|
+
const descriptor = getAgentSdkDescriptor(pin?.id);
|
|
144
|
+
if (!descriptor || !pin?.lock?.packages) return null;
|
|
145
|
+
const { platform, arch, libc } = pin.host;
|
|
146
|
+
for (const [key, entry] of Object.entries(pin.lock.packages)) {
|
|
147
|
+
const name = key.slice(key.lastIndexOf('node_modules/') + 'node_modules/'.length);
|
|
148
|
+
if (!name.startsWith(descriptor.platformScope) || !entry?.os) continue;
|
|
149
|
+
if (!matchesList(entry.os, platform) || !matchesList(entry.cpu, arch)) continue;
|
|
150
|
+
if (entry.libc && libc && !matchesList(entry.libc, libc)) continue;
|
|
151
|
+
return name;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether this host can run the SDK at all, from the descriptor and the
|
|
158
|
+
* lockfile alone: 'yes' (a vendor build for it), 'cli' (none, but the
|
|
159
|
+
* descriptor lets the host's own CLI do the work there) or 'no' ("not
|
|
160
|
+
* available on this platform", never retried).
|
|
161
|
+
*/
|
|
162
|
+
export function agentSdkSupport(pin, { hostCliPlatforms } = {}) {
|
|
163
|
+
const descriptor = getAgentSdkDescriptor(pin?.id);
|
|
164
|
+
if (!descriptor) return 'no';
|
|
165
|
+
if (descriptor.platforms && !descriptor.platforms.includes(pin.host.platform)) return 'no';
|
|
166
|
+
if (platformPackageFor(pin)) return 'yes';
|
|
167
|
+
return (hostCliPlatforms ?? descriptor.hostCliPlatforms ?? []).includes(pin.host.platform) ? 'cli' : 'no';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The files an install is checked by, relative to its folder: the entries,
|
|
172
|
+
* each pinned package's package.json and the platform package's, the SDK's
|
|
173
|
+
* own main module. A file an antivirus took, or a half-deleted tree, changes
|
|
174
|
+
* one of them.
|
|
175
|
+
*/
|
|
176
|
+
export function agentSdkCheckedFiles(pin) {
|
|
177
|
+
const descriptor = getAgentSdkDescriptor(pin?.id);
|
|
178
|
+
const files = [...Object.values(descriptor.entries)];
|
|
179
|
+
const packageJson = JSON.parse(pin.files['package.json'].toString('utf8'));
|
|
180
|
+
for (const name of Object.keys(packageJson.dependencies || {})) files.push(`node_modules/${name}/package.json`);
|
|
181
|
+
const platformPackage = platformPackageFor(pin);
|
|
182
|
+
if (platformPackage) files.push(`node_modules/${platformPackage}/package.json`);
|
|
183
|
+
return files;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The marker of an installed folder, or null. */
|
|
187
|
+
export function readAgentSdkMarker(dir, { fsImpl = fs } = {}) {
|
|
188
|
+
try {
|
|
189
|
+
const marker = JSON.parse(fsImpl.readFileSync(path.join(dir, AGENT_SDK_MARKER), 'utf8'));
|
|
190
|
+
return marker && typeof marker === 'object' && !Array.isArray(marker) ? marker : null;
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sizesMatch(dir, files, fsImpl) {
|
|
197
|
+
if (!files || typeof files !== 'object' || !Object.keys(files).length) return false;
|
|
198
|
+
try {
|
|
199
|
+
for (const [rel, size] of Object.entries(files)) {
|
|
200
|
+
const stat = fsImpl.statSync(path.join(dir, ...rel.split('/')));
|
|
201
|
+
if (!stat.isFile() || stat.size !== size) return false;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether `dir` holds a finished install made for this host: its marker
|
|
211
|
+
* names the SDK, the store format and the platform, and every file it
|
|
212
|
+
* recorded is there at its size. With `pin`, also the pinned version and
|
|
213
|
+
* content hash.
|
|
214
|
+
*/
|
|
215
|
+
export function isValidAgentSdkInstall(dir, { id, pin = null, host = pin?.host || hostPlatform(), fsImpl = fs } = {}) {
|
|
216
|
+
const marker = readAgentSdkMarker(dir, { fsImpl });
|
|
217
|
+
if (!marker || marker.format !== AGENT_SDK_STORE_FORMAT || marker.id !== (pin?.id || id)) return false;
|
|
218
|
+
if (marker.platform !== host.platform || marker.arch !== host.arch || (marker.libc || null) !== (host.libc || null)) return false;
|
|
219
|
+
if (pin && (marker.version !== pin.version || marker.hash !== pin.hash || path.basename(dir) !== pin.dirName)) return false;
|
|
220
|
+
return sizesMatch(dir, marker.files, fsImpl);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const FOLDER_NAME = /^\d[\w.+-]*-[0-9a-f]{8}$/;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The pinned folder of an SDK when it holds a finished install, else null.
|
|
227
|
+
* With `anyVersion`, otherwise the newest valid folder of an earlier pin
|
|
228
|
+
* (koffi: any version reads a process's start time).
|
|
229
|
+
*/
|
|
230
|
+
export function findReadyAgentSdkDir(id, {
|
|
231
|
+
env = process.env,
|
|
232
|
+
platform = process.platform,
|
|
233
|
+
root = resolveAgentSdkRoot({ env, platform }),
|
|
234
|
+
pinsDir = AGENT_SDK_PINS_DIR,
|
|
235
|
+
host = hostPlatform({ platform }),
|
|
236
|
+
anyVersion = false,
|
|
237
|
+
fsImpl = fs,
|
|
238
|
+
} = {}) {
|
|
239
|
+
try {
|
|
240
|
+
const pin = readAgentSdkPin(id, { pinsDir, fsImpl, host });
|
|
241
|
+
if (!pin) return null;
|
|
242
|
+
const pinned = path.join(root, pin.id, pin.dirName);
|
|
243
|
+
if (isValidAgentSdkInstall(pinned, { pin, fsImpl })) return pinned;
|
|
244
|
+
if (!anyVersion) return null;
|
|
245
|
+
let best = null;
|
|
246
|
+
for (const name of fsImpl.readdirSync(path.join(root, pin.id))) {
|
|
247
|
+
if (!FOLDER_NAME.test(name)) continue;
|
|
248
|
+
const dir = path.join(root, pin.id, name);
|
|
249
|
+
if (!isValidAgentSdkInstall(dir, { id: pin.id, host, fsImpl })) continue;
|
|
250
|
+
const at = Date.parse(readAgentSdkMarker(dir, { fsImpl })?.installedAt || '') || 0;
|
|
251
|
+
if (!best || at > best.at) best = { dir, at };
|
|
252
|
+
}
|
|
253
|
+
return best?.dir || null;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const AGENT_SDK_PACKAGE_ROOT = path.resolve(AGENT_SDK_PINS_DIR, '..', '..');
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Whether this process takes the SDKs from the package's own node_modules:
|
|
263
|
+
* a git checkout has them as devDependencies at the pinned versions.
|
|
264
|
+
* OAR_AGENT_SDK_SOURCE=store makes a checkout use the store like an install
|
|
265
|
+
* does.
|
|
266
|
+
*/
|
|
267
|
+
export function agentSdksFromCheckout({ env = process.env, packageRoot = AGENT_SDK_PACKAGE_ROOT, fsImpl = fs } = {}) {
|
|
268
|
+
if (String(env?.OAR_AGENT_SDK_SOURCE || '').trim().toLowerCase() === 'store') return false;
|
|
269
|
+
try { return fsImpl.existsSync(path.join(packageRoot, '.git')); } catch { return false; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether this process takes one SDK from the checkout: a checkout (above)
|
|
274
|
+
* whose node_modules really holds the package. One installed without its
|
|
275
|
+
* devDependencies (`npm install --omit=dev`) takes it from the store.
|
|
276
|
+
*/
|
|
277
|
+
export function agentSdkFromCheckout(id, { env = process.env, packageRoot = AGENT_SDK_PACKAGE_ROOT, fsImpl = fs } = {}) {
|
|
278
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
279
|
+
if (!descriptor || !agentSdksFromCheckout({ env, packageRoot, fsImpl })) return false;
|
|
280
|
+
try {
|
|
281
|
+
return fsImpl.existsSync(path.join(packageRoot, 'node_modules', ...descriptor.packageName.split('/'), 'package.json'));
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function isAgentSdkFolderName(name) {
|
|
288
|
+
return FOLDER_NAME.test(String(name || ''));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Records that process `pid` uses the folder `dir`, so no store's clean-up
|
|
293
|
+
* deletes it while the process runs (agent-sdk-store.mjs judges the
|
|
294
|
+
* leases). `holder` is the process's identity as the relay lock records it.
|
|
295
|
+
* Returns the lease file, or null when it could not be written.
|
|
296
|
+
*/
|
|
297
|
+
export function writeAgentSdkLease(dir, { pid = process.pid, holder = null, bootId = '', now = () => new Date(), fsImpl = fs } = {}) {
|
|
298
|
+
try {
|
|
299
|
+
const leasesDir = path.join(path.dirname(dir), AGENT_SDK_LEASES_DIR, path.basename(dir));
|
|
300
|
+
fsImpl.mkdirSync(leasesDir, { recursive: true });
|
|
301
|
+
const file = path.join(leasesDir, `${pid}-${crypto.randomBytes(3).toString('hex')}.json`);
|
|
302
|
+
fsImpl.writeFileSync(file, `${JSON.stringify({ format: 2, pid, holder, ...(bootId ? { bootId } : {}), startedAt: now().toISOString() })}\n`, { flag: 'wx' });
|
|
303
|
+
return file;
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|