@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
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
AGENT_SDK_IDS,
|
|
10
|
+
AGENT_SDK_LEASES_DIR,
|
|
11
|
+
AGENT_SDK_MARKER,
|
|
12
|
+
AGENT_SDK_STORE_FORMAT,
|
|
13
|
+
agentSdkCheckedFiles,
|
|
14
|
+
agentSdkSupport,
|
|
15
|
+
getAgentSdkDescriptor,
|
|
16
|
+
hostPlatform,
|
|
17
|
+
isAgentSdkFolderName,
|
|
18
|
+
isValidAgentSdkInstall,
|
|
19
|
+
platformPackageFor,
|
|
20
|
+
readAgentSdkMarker,
|
|
21
|
+
readAgentSdkPin,
|
|
22
|
+
resolveAgentSdkRoot,
|
|
23
|
+
writeAgentSdkLease,
|
|
24
|
+
} from './agent-sdk-paths.mjs';
|
|
25
|
+
import {
|
|
26
|
+
CLI_SPAWN_DISABLED_ERROR,
|
|
27
|
+
MAX_CAPTURED_OUTPUT_CHARS,
|
|
28
|
+
isBatchLauncher,
|
|
29
|
+
killTree as killProcessTree,
|
|
30
|
+
quoteForCmd,
|
|
31
|
+
runToCompletion,
|
|
32
|
+
stripTerminalEscapes,
|
|
33
|
+
tailOf,
|
|
34
|
+
} from './cli-process-runner.mjs';
|
|
35
|
+
import { resolveNpmInvocation } from './oar-cli-helpers.mjs';
|
|
36
|
+
import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
|
|
37
|
+
import { createRelaySingletonGuard, currentBootId, lockHolderMayRun, processIdentity } from './relay-singleton-guard.mjs';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Installs the agent SDKs (and koffi on Windows) on demand, from the pins
|
|
41
|
+
* folders the package ships (server/agent-sdks/), into versioned folders in
|
|
42
|
+
* the state root (agent-sdk-paths.mjs has the layout). Modelled on
|
|
43
|
+
* cli-install-service.mjs: a frozen table (nothing a caller sends reaches a
|
|
44
|
+
* command), a single flight per SDK, snapshots with a log and logSeq.
|
|
45
|
+
*
|
|
46
|
+
* Per SDK:
|
|
47
|
+
*
|
|
48
|
+
* missing -> installing -> ready | error | unsupported
|
|
49
|
+
*
|
|
50
|
+
* Only the disk decides ready: a folder is ready when its marker and the
|
|
51
|
+
* files it lists check out. The settings row `agent_sdk_status` keeps the
|
|
52
|
+
* last failure, so a restarted relay can show it.
|
|
53
|
+
*
|
|
54
|
+
* Several processes may use one store at once (a checkout and a global
|
|
55
|
+
* install, `oar sdk install`, a test relay): each SDK has a lock file,
|
|
56
|
+
* `.store.lock`, held by whoever installs or deletes, and every process
|
|
57
|
+
* that uses a folder leaves a lease file in `.leases/<folder>/`. The clean-up
|
|
58
|
+
* deletes no folder with a live lease.
|
|
59
|
+
*
|
|
60
|
+
* Install, under the lock:
|
|
61
|
+
* 1. copy the pins (as this relay read them at start) into
|
|
62
|
+
* `<folder>.partial-<hex>` beside the final folder (one volume);
|
|
63
|
+
* 2. `npm ci` there, from the lockfile, without install scripts, with the
|
|
64
|
+
* host's npm config but none a parent process put in the environment;
|
|
65
|
+
* 3. check the files; write the marker (the files and their sizes) last;
|
|
66
|
+
* 4. rename into place; then the clean-up.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
// An install is stopped once it makes no progress for this long: no npm
|
|
70
|
+
// output (npm logs each fetch, --loglevel=http) and no bytes growing in its
|
|
71
|
+
// folder or in npm's download cache. A slow link that keeps downloading goes
|
|
72
|
+
// on, up to the hard cap.
|
|
73
|
+
export const AGENT_SDK_INSTALL_STALL_MS = 5 * 60 * 1000;
|
|
74
|
+
export const AGENT_SDK_INSTALL_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
75
|
+
const STALL_CHECK_MS = 15_000;
|
|
76
|
+
export const AGENT_SDK_LOG_FLUSH_MS = 250;
|
|
77
|
+
// Network failures are tried again after 1, 5 and 15 minutes.
|
|
78
|
+
export const AGENT_SDK_RETRY_DELAYS_MS = Object.freeze([60_000, 5 * 60_000, 15 * 60_000]);
|
|
79
|
+
export const AGENT_SDK_STATUS_SETTING_KEY = 'agent_sdk_status';
|
|
80
|
+
// A lease younger than this counts as live whatever its pid says: it covers
|
|
81
|
+
// the moment between writing it and the process it names being judged.
|
|
82
|
+
export const AGENT_SDK_LEASE_GRACE_MS = 2 * 60_000;
|
|
83
|
+
// Another process holds the lock: look again this often.
|
|
84
|
+
const LOCK_POLL_MS = 3_000;
|
|
85
|
+
// Windows Defender holds new files for a while: a rename into place is
|
|
86
|
+
// tried again for this long.
|
|
87
|
+
const RENAME_RETRY_MS = Object.freeze([100, 250, 500, 1000, 2000, 4000, 8000, 14000]);
|
|
88
|
+
// A relay a supervisor restarts in a loop does not download on every start.
|
|
89
|
+
const RESTART_QUIET_MS = 60_000;
|
|
90
|
+
// Longer roots leave too little of Windows' 260 characters for the SDKs'
|
|
91
|
+
// deepest files.
|
|
92
|
+
const MAX_ROOT_CHARS = 120;
|
|
93
|
+
const KILL_ESCALATION_MS = 2_000;
|
|
94
|
+
|
|
95
|
+
export const AGENT_SDK_STATES = Object.freeze({
|
|
96
|
+
MISSING: 'missing',
|
|
97
|
+
INSTALLING: 'installing',
|
|
98
|
+
READY: 'ready',
|
|
99
|
+
ERROR: 'error',
|
|
100
|
+
UNSUPPORTED: 'unsupported',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const AGENT_SDK_FAILURE_KINDS = Object.freeze({
|
|
104
|
+
NETWORK: 'network',
|
|
105
|
+
PLATFORM: 'platform',
|
|
106
|
+
CANCELLED: 'cancelled',
|
|
107
|
+
OTHER: 'other',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// npm's own error codes, and the HTTP status of a registry that failed.
|
|
111
|
+
const NETWORK_ERROR = /\b(ENOTFOUND|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|ENETUNREACH|EHOSTUNREACH|ERR_SOCKET_TIMEOUT|ESOCKETTIMEDOUT)\b|\bnpm (?:ERR!|error) (?:code )?E5\d\d\b|\b5\d\d (?:Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b|timed out after \d+ms/;
|
|
112
|
+
const PLATFORM_ERROR = /\bEBADPLATFORM\b/;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Network, platform or other, from npm's output; with a message for the
|
|
116
|
+
* cases whose fix is the user's.
|
|
117
|
+
*/
|
|
118
|
+
export function classifyAgentSdkFailure(text) {
|
|
119
|
+
const value = String(text || '');
|
|
120
|
+
if (PLATFORM_ERROR.test(value)) return { kind: AGENT_SDK_FAILURE_KINDS.PLATFORM, hint: null };
|
|
121
|
+
if (/\bE407\b|407 Proxy Authentication Required/.test(value)) {
|
|
122
|
+
return { kind: AGENT_SDK_FAILURE_KINDS.OTHER, hint: 'The proxy asks for a login: set it in your npm config (npm config set proxy …).' };
|
|
123
|
+
}
|
|
124
|
+
if (/\bENOSPC\b/.test(value)) return { kind: AGENT_SDK_FAILURE_KINDS.OTHER, hint: 'The disk is full.' };
|
|
125
|
+
if (/\bEINTEGRITY\b/.test(value)) return { kind: AGENT_SDK_FAILURE_KINDS.OTHER, hint: 'npm\'s cache holds a damaged copy: run npm cache verify, then try again.' };
|
|
126
|
+
if (NETWORK_ERROR.test(value)) return { kind: AGENT_SDK_FAILURE_KINDS.NETWORK, hint: 'No connection to the npm registry. OAR needs it once per SDK version.' };
|
|
127
|
+
return { kind: AGENT_SDK_FAILURE_KINDS.OTHER, hint: null };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// npm config a parent process sets in the environment (`npm start` sets
|
|
131
|
+
// dozens) would apply to these installs too; only what reaches the registry
|
|
132
|
+
// is kept (with npm's offline settings, for a host that installs from its
|
|
133
|
+
// cache), so proxies and mirrors still work.
|
|
134
|
+
const KEPT_NPM_CONFIG = /^npm_config_(registry|@[^_]+:registry|_auth.*|.*:_auth.*|.*_authtoken|proxy|https_proxy|http_proxy|noproxy|no_proxy|cafile|ca|cert|key|strict_ssl|userconfig|globalconfig|cache|fetch_retries|fetch_retry_.*|fetch_timeout|maxsockets|local_address|offline|prefer_offline|prefer_online)$/i;
|
|
135
|
+
|
|
136
|
+
export function agentSdkNpmEnv(env) {
|
|
137
|
+
const out = {};
|
|
138
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
139
|
+
if (/^npm_(package|lifecycle)_/i.test(key)) continue;
|
|
140
|
+
if (/^npm_config_/i.test(key) && !KEPT_NPM_CONFIG.test(key)) continue;
|
|
141
|
+
if (/^OAR_AGENT_SDK_DIR_/i.test(key)) continue;
|
|
142
|
+
out[key] = value;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The npm argv of an install, the same for every pins folder: scripts off.
|
|
149
|
+
* The SDK trees have none; koffi has one, which only builds from source where
|
|
150
|
+
* its prebuilt optional package (@koromix/koffi-<os>-<arch>, no script) is
|
|
151
|
+
* missing, and the store requires that package anyway. npm 11.16 and later
|
|
152
|
+
* refuse --allow-scripts on a project install, so allowing it is no option.
|
|
153
|
+
*/
|
|
154
|
+
export function agentSdkNpmArgs() {
|
|
155
|
+
return [
|
|
156
|
+
'ci',
|
|
157
|
+
// Not a workspace of a package.json above the folder, whatever is there.
|
|
158
|
+
'--no-workspaces',
|
|
159
|
+
'--include=prod', '--include=optional', '--omit=dev',
|
|
160
|
+
'--ignore-scripts',
|
|
161
|
+
'--no-dry-run', '--package-lock=true', '--install-strategy=hoisted',
|
|
162
|
+
'--no-audit', '--no-fund', '--no-update-notifier',
|
|
163
|
+
// A line per fetch: the log moves while a slow link downloads.
|
|
164
|
+
'--loglevel=http',
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** npm's cache folder as the environment or the platform default names it. */
|
|
169
|
+
export function npmCacheDirOf(env = process.env, platform = process.platform) {
|
|
170
|
+
const configured = String(env?.npm_config_cache || env?.NPM_CONFIG_CACHE || '').trim();
|
|
171
|
+
if (configured) return path.resolve(configured);
|
|
172
|
+
if (platform === 'win32') return path.join(String(env?.LOCALAPPDATA || '').trim() || path.join(os.homedir(), 'AppData', 'Local'), 'npm-cache');
|
|
173
|
+
return path.join(String(env?.HOME || '').trim() || os.homedir(), '.npm');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeText(value) {
|
|
177
|
+
return String(value == null ? '' : value).trim();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function createAgentSdkStore({
|
|
181
|
+
env = process.env,
|
|
182
|
+
platform = process.platform,
|
|
183
|
+
host = hostPlatform({ platform }),
|
|
184
|
+
root = resolveAgentSdkRoot({ env, platform }),
|
|
185
|
+
pinsDir,
|
|
186
|
+
ids = AGENT_SDK_IDS,
|
|
187
|
+
pid = process.pid,
|
|
188
|
+
spawnImpl = spawn,
|
|
189
|
+
processKillImpl = process.kill.bind(process),
|
|
190
|
+
setTimeoutImpl = setTimeout,
|
|
191
|
+
clearTimeoutImpl = clearTimeout,
|
|
192
|
+
now = () => Date.now(),
|
|
193
|
+
sleep = (ms) => new Promise((resolve) => { setTimeoutImpl(resolve, ms)?.unref?.(); }),
|
|
194
|
+
fsImpl = fs,
|
|
195
|
+
logger = console,
|
|
196
|
+
readSetting = () => null,
|
|
197
|
+
writeSetting = () => {},
|
|
198
|
+
npmInvocation = () => resolveNpmInvocation({ env: agentSdkNpmEnv(env), platform }),
|
|
199
|
+
// Whether a lease or an install's owner may still run (relay-singleton-guard).
|
|
200
|
+
holderMayRun = (record) => lockHolderMayRun({ lock: record, platform }),
|
|
201
|
+
identityOf = processIdentity,
|
|
202
|
+
bootIdOf = currentBootId,
|
|
203
|
+
lockFactory = (lockPath) => createRelaySingletonGuard({
|
|
204
|
+
lockPath, pid, token: crypto.randomBytes(16).toString('hex'), logger: { warn: () => {} }, platform,
|
|
205
|
+
}),
|
|
206
|
+
installTimeoutMs = AGENT_SDK_INSTALL_TIMEOUT_MS,
|
|
207
|
+
installStallMs = AGENT_SDK_INSTALL_STALL_MS,
|
|
208
|
+
stallCheckMs = STALL_CHECK_MS,
|
|
209
|
+
// Where npm writes a download while it runs (npm's cache, _cacache/tmp).
|
|
210
|
+
npmCacheDir = () => npmCacheDirOf(env, platform),
|
|
211
|
+
logFlushMs = AGENT_SDK_LOG_FLUSH_MS,
|
|
212
|
+
retryDelaysMs = AGENT_SDK_RETRY_DELAYS_MS,
|
|
213
|
+
lockPollMs = LOCK_POLL_MS,
|
|
214
|
+
renameRetryMs = RENAME_RETRY_MS,
|
|
215
|
+
leaseGraceMs = AGENT_SDK_LEASE_GRACE_MS,
|
|
216
|
+
// Overrides the descriptors' host-CLI platforms (tests).
|
|
217
|
+
hostCliPlatforms,
|
|
218
|
+
// A git checkout runs the SDKs it has installed as devDependencies: the
|
|
219
|
+
// status says so instead of "not installed"; per SDK (agentSdkFromCheckout).
|
|
220
|
+
fromCheckout = () => false,
|
|
221
|
+
} = {}) {
|
|
222
|
+
const listeners = new Set();
|
|
223
|
+
const readyHooks = new Set();
|
|
224
|
+
const sdks = new Map();
|
|
225
|
+
const pins = new Map();
|
|
226
|
+
let logFlushTimer = null;
|
|
227
|
+
let logEmitPending = false;
|
|
228
|
+
let disposed = false;
|
|
229
|
+
const ownLeases = new Map();
|
|
230
|
+
|
|
231
|
+
const log = (message) => {
|
|
232
|
+
try { logger?.log?.(`[agent-sdk] ${message}`); } catch {}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// The relay's own environment never passes a worker's folder on: a relay
|
|
236
|
+
// started from inside a session would take that session's SDK.
|
|
237
|
+
if (env === process.env) {
|
|
238
|
+
for (const key of Object.keys(env)) if (/^OAR_AGENT_SDK_DIR_/i.test(key)) delete env[key];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const rootProblem = (() => {
|
|
242
|
+
if (platform === 'win32' && /^[\\/]{2}/.test(root)) return `The SDK folder ${root} is a network path; set OAR_SDK_DIR to a local folder.`;
|
|
243
|
+
if (platform === 'win32' && root.length > MAX_ROOT_CHARS) return `The SDK folder ${root} is longer than ${MAX_ROOT_CHARS} characters; set OAR_SDK_DIR to a shorter one.`;
|
|
244
|
+
return null;
|
|
245
|
+
})();
|
|
246
|
+
|
|
247
|
+
function pinOf(id) {
|
|
248
|
+
if (!pins.has(id)) pins.set(id, readAgentSdkPin(id, { ...(pinsDir ? { pinsDir } : {}), host }));
|
|
249
|
+
return pins.get(id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sdkDir(id) {
|
|
253
|
+
return path.join(root, id);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function pinnedDir(id) {
|
|
257
|
+
return path.join(sdkDir(id), pinOf(id).dirName);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── The persisted row ─────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
function readPersisted() {
|
|
263
|
+
try {
|
|
264
|
+
const parsed = JSON.parse(String(readSetting(AGENT_SDK_STATUS_SETTING_KEY) || ''));
|
|
265
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
266
|
+
} catch {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function persist(id, record) {
|
|
272
|
+
try {
|
|
273
|
+
const all = readPersisted();
|
|
274
|
+
if (record) all[id] = record;
|
|
275
|
+
else delete all[id];
|
|
276
|
+
writeSetting(AGENT_SDK_STATUS_SETTING_KEY, JSON.stringify(all));
|
|
277
|
+
} catch (error) {
|
|
278
|
+
log(`could not save the ${id} status: ${error?.message || error}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── State ─────────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
function initialState(id) {
|
|
285
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
286
|
+
const pin = pinOf(id);
|
|
287
|
+
const support = agentSdkSupport(pin, { hostCliPlatforms: hostCliPlatforms?.[id] });
|
|
288
|
+
const entry = {
|
|
289
|
+
id,
|
|
290
|
+
label: descriptor.label,
|
|
291
|
+
pinned: pin.version,
|
|
292
|
+
folder: pin.dirName,
|
|
293
|
+
support,
|
|
294
|
+
state: AGENT_SDK_STATES.MISSING,
|
|
295
|
+
installed: null,
|
|
296
|
+
dir: null,
|
|
297
|
+
log: '',
|
|
298
|
+
logSeq: 0,
|
|
299
|
+
error: null,
|
|
300
|
+
kind: null,
|
|
301
|
+
retryAt: null,
|
|
302
|
+
attempts: 0,
|
|
303
|
+
heldBy: null,
|
|
304
|
+
startedAt: null,
|
|
305
|
+
finishedAt: null,
|
|
306
|
+
lastAttemptAt: null,
|
|
307
|
+
session: null,
|
|
308
|
+
retryTimer: null,
|
|
309
|
+
generation: 0,
|
|
310
|
+
waiters: [],
|
|
311
|
+
};
|
|
312
|
+
if (support === 'no') {
|
|
313
|
+
entry.state = AGENT_SDK_STATES.UNSUPPORTED;
|
|
314
|
+
entry.kind = AGENT_SDK_FAILURE_KINDS.PLATFORM;
|
|
315
|
+
entry.error = `${descriptor.label} is not available on this platform (${host.platform}-${host.arch}${host.libc === 'musl' ? '-musl' : ''}).`;
|
|
316
|
+
return entry;
|
|
317
|
+
}
|
|
318
|
+
const stored = readPersisted()[id];
|
|
319
|
+
if (stored && stored.hash === pin.hash && stored.error) {
|
|
320
|
+
// npm's EBADPLATFORM stays "not available" across a restart: never retried.
|
|
321
|
+
entry.state = stored.kind === AGENT_SDK_FAILURE_KINDS.PLATFORM ? AGENT_SDK_STATES.UNSUPPORTED : AGENT_SDK_STATES.ERROR;
|
|
322
|
+
entry.error = stored.error;
|
|
323
|
+
entry.kind = stored.kind || AGENT_SDK_FAILURE_KINDS.OTHER;
|
|
324
|
+
entry.attempts = Number(stored.attempts) || 0;
|
|
325
|
+
entry.lastAttemptAt = stored.lastAttemptAt || null;
|
|
326
|
+
}
|
|
327
|
+
return entry;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function entryOf(id) {
|
|
331
|
+
if (!sdks.has(id)) sdks.set(id, initialState(id));
|
|
332
|
+
return sdks.get(id);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The pinned folder when it checks out, else null. Never throws. */
|
|
336
|
+
function readyDir(id) {
|
|
337
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
338
|
+
if (!descriptor || !ids.includes(descriptor.id)) return null;
|
|
339
|
+
try {
|
|
340
|
+
const dir = pinnedDir(descriptor.id);
|
|
341
|
+
return isValidAgentSdkInstall(dir, { pin: pinOf(descriptor.id), fsImpl }) ? dir : null;
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The disk against the state: a folder that checks out is ready, whatever
|
|
348
|
+
// the row says; a ready folder that stopped checking out is missing.
|
|
349
|
+
function settle(entry) {
|
|
350
|
+
if (entry.state === AGENT_SDK_STATES.UNSUPPORTED || entry.session) return entry;
|
|
351
|
+
const dir = readyDir(entry.id);
|
|
352
|
+
if (dir) {
|
|
353
|
+
if (entry.state !== AGENT_SDK_STATES.READY) {
|
|
354
|
+
entry.state = AGENT_SDK_STATES.READY;
|
|
355
|
+
entry.error = null;
|
|
356
|
+
entry.kind = null;
|
|
357
|
+
entry.retryAt = null;
|
|
358
|
+
entry.attempts = 0;
|
|
359
|
+
clearRetry(entry);
|
|
360
|
+
persist(entry.id, null);
|
|
361
|
+
}
|
|
362
|
+
entry.installed = entry.pinned;
|
|
363
|
+
entry.dir = dir;
|
|
364
|
+
leaseOwn(entry.id, dir);
|
|
365
|
+
} else if (entry.state === AGENT_SDK_STATES.READY) {
|
|
366
|
+
entry.state = AGENT_SDK_STATES.MISSING;
|
|
367
|
+
entry.installed = null;
|
|
368
|
+
entry.dir = null;
|
|
369
|
+
}
|
|
370
|
+
return entry;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function publicStatus(entry) {
|
|
374
|
+
return {
|
|
375
|
+
id: entry.id,
|
|
376
|
+
label: entry.label,
|
|
377
|
+
state: entry.state,
|
|
378
|
+
pinned: entry.pinned,
|
|
379
|
+
installed: entry.installed,
|
|
380
|
+
folder: entry.folder,
|
|
381
|
+
dir: entry.dir,
|
|
382
|
+
support: entry.support,
|
|
383
|
+
log: entry.log,
|
|
384
|
+
logSeq: entry.logSeq,
|
|
385
|
+
error: entry.error,
|
|
386
|
+
kind: entry.kind,
|
|
387
|
+
retryAt: entry.retryAt ? new Date(entry.retryAt).toISOString() : null,
|
|
388
|
+
attempts: entry.attempts,
|
|
389
|
+
heldBy: entry.heldBy,
|
|
390
|
+
startedAt: entry.startedAt,
|
|
391
|
+
finishedAt: entry.finishedAt,
|
|
392
|
+
active: Boolean(entry.session),
|
|
393
|
+
canInstall: !rootProblem && entry.state !== AGENT_SDK_STATES.UNSUPPORTED && !entry.session && !spawnsDisabled(),
|
|
394
|
+
source: 'store',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function status(id) {
|
|
399
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
400
|
+
if (!descriptor || !ids.includes(descriptor.id)) return null;
|
|
401
|
+
const value = publicStatus(settle(entryOf(descriptor.id)));
|
|
402
|
+
if (!fromCheckout(descriptor.id) || value.active || value.state === AGENT_SDK_STATES.UNSUPPORTED) return value;
|
|
403
|
+
return { ...value, state: AGENT_SDK_STATES.READY, installed: value.pinned, dir: null, error: null, kind: null, retryAt: null, canInstall: false, source: 'checkout' };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function snapshot() {
|
|
407
|
+
const out = {};
|
|
408
|
+
for (const id of ids) out[id] = status(id);
|
|
409
|
+
return { root, host: { ...host }, checkout: ids.some((id) => fromCheckout(id)), sdks: out };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function emit() {
|
|
413
|
+
if (disposed) return;
|
|
414
|
+
const value = snapshot();
|
|
415
|
+
for (const listener of [...listeners]) {
|
|
416
|
+
try { listener(value); } catch (error) { log(`listener failed: ${error?.message || error}`); }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function clearLogFlush() {
|
|
421
|
+
if (logFlushTimer) clearTimeoutImpl(logFlushTimer);
|
|
422
|
+
logFlushTimer = null;
|
|
423
|
+
logEmitPending = false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function armLogFlush() {
|
|
427
|
+
logFlushTimer = setTimeoutImpl(() => {
|
|
428
|
+
logFlushTimer = null;
|
|
429
|
+
if (!logEmitPending) return;
|
|
430
|
+
logEmitPending = false;
|
|
431
|
+
emit();
|
|
432
|
+
armLogFlush();
|
|
433
|
+
}, logFlushMs);
|
|
434
|
+
logFlushTimer?.unref?.();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Log lines are coalesced to four broadcasts a second; transitions are not.
|
|
438
|
+
function emitLog() {
|
|
439
|
+
if (logFlushTimer) {
|
|
440
|
+
logEmitPending = true;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
emit();
|
|
444
|
+
armLogFlush();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function transition(entry, state, fields = {}) {
|
|
448
|
+
entry.state = state;
|
|
449
|
+
Object.assign(entry, fields);
|
|
450
|
+
clearLogFlush();
|
|
451
|
+
emit();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function appendLog(entry, session, text) {
|
|
455
|
+
if (entry.session !== session) return;
|
|
456
|
+
entry.log = `${entry.log}${stripTerminalEscapes(text)}`.slice(-MAX_CAPTURED_OUTPUT_CHARS);
|
|
457
|
+
entry.logSeq += 1;
|
|
458
|
+
emitLog();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ─── Leases ────────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
function writeLease(dir, leasePid = pid) {
|
|
464
|
+
return writeAgentSdkLease(dir, {
|
|
465
|
+
pid: leasePid,
|
|
466
|
+
holder: (() => { try { return identityOf(leasePid); } catch { return null; } })(),
|
|
467
|
+
bootId: (() => { try { return bootIdOf(); } catch { return ''; } })(),
|
|
468
|
+
fsImpl,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// This relay's own lease on the folder it uses: another relay with other
|
|
473
|
+
// pins must not clean it away.
|
|
474
|
+
function leaseOwn(id, dir) {
|
|
475
|
+
if (ownLeases.get(id) === dir) return;
|
|
476
|
+
if (writeLease(dir)) ownLeases.set(id, dir);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function leaseIsLive(file) {
|
|
480
|
+
try {
|
|
481
|
+
const stat = fsImpl.statSync(file);
|
|
482
|
+
if (now() - stat.mtimeMs < leaseGraceMs) return true;
|
|
483
|
+
const lease = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
484
|
+
return holderMayRun(lease);
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function liveLeases(id, folderName) {
|
|
491
|
+
const dir = path.join(sdkDir(id), AGENT_SDK_LEASES_DIR, folderName);
|
|
492
|
+
let names = [];
|
|
493
|
+
try { names = fsImpl.readdirSync(dir); } catch { return []; }
|
|
494
|
+
return names.filter((name) => name.endsWith('.json') && leaseIsLive(path.join(dir, name)));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function pruneLeases(id) {
|
|
498
|
+
const leasesRoot = path.join(sdkDir(id), AGENT_SDK_LEASES_DIR);
|
|
499
|
+
let folders = [];
|
|
500
|
+
try { folders = fsImpl.readdirSync(leasesRoot); } catch { return; }
|
|
501
|
+
for (const folder of folders) {
|
|
502
|
+
const dir = path.join(leasesRoot, folder);
|
|
503
|
+
let names = [];
|
|
504
|
+
try { names = fsImpl.readdirSync(dir); } catch { continue; }
|
|
505
|
+
for (const name of names) {
|
|
506
|
+
if (!leaseIsLive(path.join(dir, name))) try { fsImpl.rmSync(path.join(dir, name), { force: true }); } catch {}
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
if (!fsImpl.readdirSync(dir).length && !fsImpl.existsSync(path.join(sdkDir(id), folder))) fsImpl.rmdirSync(dir);
|
|
510
|
+
} catch {}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ─── The lock and the clean-up ─────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
function tryLock(id) {
|
|
517
|
+
fsImpl.mkdirSync(sdkDir(id), { recursive: true });
|
|
518
|
+
const guard = lockFactory(path.join(sdkDir(id), '.store.lock'));
|
|
519
|
+
try {
|
|
520
|
+
guard.acquire();
|
|
521
|
+
return { guard, heldBy: null };
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (error?.code !== 'ERELAYLOCKED') throw error;
|
|
524
|
+
let heldBy = null;
|
|
525
|
+
try { heldBy = Number(JSON.parse(fsImpl.readFileSync(path.join(sdkDir(id), '.store.lock'), 'utf8')).pid) || null; } catch {}
|
|
526
|
+
return { guard: null, heldBy };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function releaseLock(lock) {
|
|
531
|
+
try { lock?.guard?.release(); } catch (error) { log(`could not release a store lock: ${error?.message || error}`); }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// A whole SDK folder (hundreds of MB) is deleted off the event loop: the
|
|
535
|
+
// relay keeps serving meanwhile.
|
|
536
|
+
async function removeTreeAsync(dir) {
|
|
537
|
+
try {
|
|
538
|
+
await (fsImpl.promises || fs.promises).rm(dir, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
|
|
539
|
+
return true;
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// What an install of a dead process left: its partial folder (whose npm
|
|
546
|
+
// may still run on POSIX, detached) and folders moved aside to delete.
|
|
547
|
+
// Only ever under the lock, so nothing found belongs to a live install.
|
|
548
|
+
// Returns a promise only when there is something to delete, so an
|
|
549
|
+
// install with nothing left over spawns npm without a detour.
|
|
550
|
+
function cleanUpLeftovers(id) {
|
|
551
|
+
let names = [];
|
|
552
|
+
try { names = fsImpl.readdirSync(sdkDir(id)); } catch { return null; }
|
|
553
|
+
const removals = [];
|
|
554
|
+
for (const name of names) {
|
|
555
|
+
const full = path.join(sdkDir(id), name);
|
|
556
|
+
if (/\.partial-[0-9a-f]+$/.test(name)) {
|
|
557
|
+
try {
|
|
558
|
+
const owner = JSON.parse(fsImpl.readFileSync(path.join(full, '.owner.json'), 'utf8'));
|
|
559
|
+
if (owner?.pid && holderMayRun(owner)) killOrphan(owner.pid);
|
|
560
|
+
} catch {}
|
|
561
|
+
removals.push(removeTreeAsync(full));
|
|
562
|
+
} else if (/^\.trash-[0-9a-f]+$/.test(name)) {
|
|
563
|
+
removals.push(removeTreeAsync(full));
|
|
564
|
+
} else if (isAgentSdkFolderName(name) && !readAgentSdkMarker(full, { fsImpl })) {
|
|
565
|
+
// A folder under a final name without a marker is never valid.
|
|
566
|
+
moveAside(id, full);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return removals.length ? Promise.all(removals) : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Bytes under `dir`, at most `limit` entries looked at; 0 when it is not there.
|
|
573
|
+
function folderBytes(dir, limit = 20_000) {
|
|
574
|
+
let total = 0;
|
|
575
|
+
let seen = 0;
|
|
576
|
+
const walk = (current) => {
|
|
577
|
+
let entries = [];
|
|
578
|
+
try { entries = fsImpl.readdirSync(current, { withFileTypes: true }); } catch { return; }
|
|
579
|
+
for (const item of entries) {
|
|
580
|
+
if (++seen > limit) return;
|
|
581
|
+
const full = path.join(current, item.name);
|
|
582
|
+
if (item.isDirectory()) walk(full);
|
|
583
|
+
else {
|
|
584
|
+
try { total += fsImpl.statSync(full).size; } catch {}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
if (dir) walk(dir);
|
|
589
|
+
return total;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// The stall watch of a running install: progress is npm output or bytes
|
|
593
|
+
// growing in its folder or in npm's download cache; none for
|
|
594
|
+
// installStallMs stops npm, and the failure counts as a network one.
|
|
595
|
+
function watchProgress(entry, session, partial) {
|
|
596
|
+
let lastAt = now();
|
|
597
|
+
let lastBytes = -1;
|
|
598
|
+
let timer = null;
|
|
599
|
+
let cacheTmp = null;
|
|
600
|
+
try { cacheTmp = path.join(npmCacheDir(), '_cacache', 'tmp'); } catch {}
|
|
601
|
+
const tick = () => {
|
|
602
|
+
timer = null;
|
|
603
|
+
if (entry.session !== session || !session.child) {
|
|
604
|
+
if (entry.session === session) arm();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const bytes = folderBytes(path.join(partial, 'node_modules')) + folderBytes(cacheTmp, 2_000);
|
|
608
|
+
if (bytes !== lastBytes) {
|
|
609
|
+
lastBytes = bytes;
|
|
610
|
+
lastAt = now();
|
|
611
|
+
}
|
|
612
|
+
if (now() - lastAt >= installStallMs) {
|
|
613
|
+
session.stalled = true;
|
|
614
|
+
log(`${entry.id}: no progress for ${Math.round(installStallMs / 1000)} s, stopping npm`);
|
|
615
|
+
terminate(session.child);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
arm();
|
|
619
|
+
};
|
|
620
|
+
const arm = () => {
|
|
621
|
+
timer = setTimeoutImpl(tick, stallCheckMs);
|
|
622
|
+
timer?.unref?.();
|
|
623
|
+
};
|
|
624
|
+
arm();
|
|
625
|
+
return {
|
|
626
|
+
progress: () => { lastAt = now(); },
|
|
627
|
+
stop: () => {
|
|
628
|
+
if (timer) clearTimeoutImpl(timer);
|
|
629
|
+
timer = null;
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// An npm a dead install left running (POSIX runs it detached, as a group).
|
|
635
|
+
function killOrphan(orphanPid) {
|
|
636
|
+
if (platform === 'win32') return killWindowsTree(orphanPid);
|
|
637
|
+
try {
|
|
638
|
+
processKillImpl(-orphanPid, 'SIGKILL');
|
|
639
|
+
} catch {
|
|
640
|
+
try { processKillImpl(orphanPid, 'SIGKILL'); } catch {}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// A Windows process and everything it started (taskkill, no PowerShell).
|
|
645
|
+
function killWindowsTree(treePid) {
|
|
646
|
+
try {
|
|
647
|
+
// System32's own, not whatever PATH holds under that name.
|
|
648
|
+
const killer = spawnImpl(windowsSystemToolPath('taskkill', { env }), ['/PID', String(Number(treePid)), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
649
|
+
killer?.on?.('error', () => {});
|
|
650
|
+
killer?.unref?.();
|
|
651
|
+
} catch {
|
|
652
|
+
try { processKillImpl(Number(treePid), 'SIGKILL'); } catch {}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function moveAside(id, dir) {
|
|
657
|
+
const trash = path.join(sdkDir(id), `.trash-${crypto.randomBytes(3).toString('hex')}`);
|
|
658
|
+
try {
|
|
659
|
+
fsImpl.renameSync(dir, trash);
|
|
660
|
+
} catch {
|
|
661
|
+
// In use (Windows) or gone: tried again at the next start.
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
return trash;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Keeps the pinned folder, the newest other one (the version before) and
|
|
669
|
+
* every folder a live process leases; the rest go. A folder is moved aside
|
|
670
|
+
* first, the leases looked at again (a worker may have leased it in
|
|
671
|
+
* between), and only then deleted; a delete Windows refuses (a file in
|
|
672
|
+
* use) is tried again at the next start. Under the lock.
|
|
673
|
+
*/
|
|
674
|
+
function collectGarbage(id) {
|
|
675
|
+
let names = [];
|
|
676
|
+
try { names = fsImpl.readdirSync(sdkDir(id)); } catch { return null; }
|
|
677
|
+
const removals = [];
|
|
678
|
+
const pin = pinOf(id);
|
|
679
|
+
const others = [];
|
|
680
|
+
for (const name of names) {
|
|
681
|
+
if (!isAgentSdkFolderName(name) || name === pin.dirName) continue;
|
|
682
|
+
const marker = readAgentSdkMarker(path.join(sdkDir(id), name), { fsImpl });
|
|
683
|
+
// A folder made for another platform (a root Windows and WSL share)
|
|
684
|
+
// is that platform's relays' to keep or delete: their pids mean
|
|
685
|
+
// nothing here, so their leases cannot be judged.
|
|
686
|
+
if (marker && (marker.platform !== host.platform || marker.arch !== host.arch || (marker.libc || null) !== (host.libc || null))) continue;
|
|
687
|
+
others.push({ name, at: Date.parse(marker?.installedAt || '') || 0 });
|
|
688
|
+
}
|
|
689
|
+
others.sort((a, b) => b.at - a.at);
|
|
690
|
+
for (const { name } of others.slice(1)) {
|
|
691
|
+
if (liveLeases(id, name).length) continue;
|
|
692
|
+
const trash = moveAside(id, path.join(sdkDir(id), name));
|
|
693
|
+
if (!trash) continue;
|
|
694
|
+
if (liveLeases(id, name).length) {
|
|
695
|
+
try { fsImpl.renameSync(trash, path.join(sdkDir(id), name)); } catch {}
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
removals.push(removeTreeAsync(trash));
|
|
699
|
+
}
|
|
700
|
+
pruneLeases(id);
|
|
701
|
+
return removals.length ? Promise.all(removals) : null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ─── Installing ────────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
function spawnsDisabled() {
|
|
707
|
+
if (!normalizeText(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN)) return false;
|
|
708
|
+
// The e2e harness keeps spawns off and drives a stub installer: both the
|
|
709
|
+
// opt-in and the stub command are needed.
|
|
710
|
+
return !(normalizeText(env?.COPILOT_WEB_RELAY_CLI_INSTALL_ALLOW_STUB_SPAWN) && normalizeText(env?.COPILOT_WEB_RELAY_AGENT_SDK_INSTALL_COMMAND));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function spawnNpm(id, cwd) {
|
|
714
|
+
const stub = normalizeText(env?.COPILOT_WEB_RELAY_AGENT_SDK_INSTALL_COMMAND);
|
|
715
|
+
const npm = npmInvocation();
|
|
716
|
+
const [command, args] = stub && !spawnsDisabled()
|
|
717
|
+
? [stub, [id]]
|
|
718
|
+
: [npm.command, agentSdkNpmArgs()];
|
|
719
|
+
// npm.cmd needs a shell on Windows: one quoted command line, no args.
|
|
720
|
+
const batch = isBatchLauncher(command, platform);
|
|
721
|
+
return spawnImpl(batch ? [command, ...args].map(quoteForCmd).join(' ') : command, batch ? [] : args, {
|
|
722
|
+
cwd,
|
|
723
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
724
|
+
env: agentSdkNpmEnv(npm.env || env),
|
|
725
|
+
...(platform === 'win32' ? { windowsHide: true, ...(batch ? { shell: true } : {}) } : { detached: true }),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function terminate(child) {
|
|
730
|
+
// Windows: npm runs under cmd.exe (npm.cmd needs a shell), and killing
|
|
731
|
+
// the shell would leave npm's node running; the whole tree goes.
|
|
732
|
+
if (platform === 'win32') {
|
|
733
|
+
if (child?.exitCode === null && Number(child?.pid) > 0) killWindowsTree(child.pid);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
killProcessTree(child, 'SIGTERM', { platform, processKillImpl });
|
|
737
|
+
const escalation = setTimeoutImpl(() => killProcessTree(child, 'SIGKILL', { platform, processKillImpl }), KILL_ESCALATION_MS);
|
|
738
|
+
escalation?.unref?.();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Every file and folder of the tree to disk before the marker says it is
|
|
742
|
+
// complete (POSIX; a power loss otherwise can leave empty files behind a
|
|
743
|
+
// valid-looking marker). Asynchronous, a few at a time: the Claude tree
|
|
744
|
+
// takes over a minute on a slow disk, and the relay must keep running
|
|
745
|
+
// meanwhile (heartbeats, the recycle's sweeps, deliveries).
|
|
746
|
+
async function syncTree(dir) {
|
|
747
|
+
if (platform === 'win32') return;
|
|
748
|
+
const fsp = fsImpl.promises || fs.promises;
|
|
749
|
+
const targets = [];
|
|
750
|
+
const walk = async (current) => {
|
|
751
|
+
let entries = [];
|
|
752
|
+
try { entries = await fsp.readdir(current, { withFileTypes: true }); } catch { return; }
|
|
753
|
+
for (const entry of entries) {
|
|
754
|
+
const full = path.join(current, entry.name);
|
|
755
|
+
if (entry.isDirectory()) await walk(full);
|
|
756
|
+
else if (entry.isFile()) targets.push(full);
|
|
757
|
+
}
|
|
758
|
+
targets.push(current);
|
|
759
|
+
};
|
|
760
|
+
await walk(dir);
|
|
761
|
+
let next = 0;
|
|
762
|
+
const worker = async () => {
|
|
763
|
+
while (next < targets.length) {
|
|
764
|
+
const target = targets[next];
|
|
765
|
+
next += 1;
|
|
766
|
+
let handle = null;
|
|
767
|
+
try {
|
|
768
|
+
handle = await fsp.open(target, 'r');
|
|
769
|
+
await handle.sync();
|
|
770
|
+
} catch {} finally {
|
|
771
|
+
try { await handle?.close(); } catch {}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
await Promise.all(Array.from({ length: 16 }, worker));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function syncPath(target) {
|
|
779
|
+
if (platform === 'win32') return;
|
|
780
|
+
let fd = null;
|
|
781
|
+
try {
|
|
782
|
+
fd = fsImpl.openSync(target, 'r');
|
|
783
|
+
fsImpl.fsyncSync(fd);
|
|
784
|
+
} catch {} finally {
|
|
785
|
+
if (fd !== null) try { fsImpl.closeSync(fd); } catch {}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// The files the marker records with their sizes: the checked list, and the
|
|
790
|
+
// platform package's top-level and bin/ files (the binaries an antivirus
|
|
791
|
+
// might take).
|
|
792
|
+
function checkedFiles(id, dir) {
|
|
793
|
+
const pin = pinOf(id);
|
|
794
|
+
const files = {};
|
|
795
|
+
const add = (rel) => {
|
|
796
|
+
const stat = fsImpl.statSync(path.join(dir, ...rel.split('/')));
|
|
797
|
+
if (stat.isFile()) files[rel] = stat.size;
|
|
798
|
+
};
|
|
799
|
+
for (const rel of agentSdkCheckedFiles(pin)) add(rel);
|
|
800
|
+
const platformPackage = platformPackageFor(pin);
|
|
801
|
+
if (platformPackage) {
|
|
802
|
+
for (const sub of ['', 'bin']) {
|
|
803
|
+
const base = path.join(dir, 'node_modules', ...platformPackage.split('/'), sub);
|
|
804
|
+
let names = [];
|
|
805
|
+
try { names = fsImpl.readdirSync(base, { withFileTypes: true }); } catch { continue; }
|
|
806
|
+
for (const entry of names) if (entry.isFile()) add(`node_modules/${platformPackage}/${sub ? `${sub}/` : ''}${entry.name}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return files;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function writeMarker(id, dir) {
|
|
813
|
+
const pin = pinOf(id);
|
|
814
|
+
const marker = {
|
|
815
|
+
format: AGENT_SDK_STORE_FORMAT,
|
|
816
|
+
id,
|
|
817
|
+
version: pin.version,
|
|
818
|
+
hash: pin.hash,
|
|
819
|
+
platform: host.platform,
|
|
820
|
+
arch: host.arch,
|
|
821
|
+
libc: host.libc || null,
|
|
822
|
+
platformPackage: platformPackageFor(pin),
|
|
823
|
+
installedAt: new Date(now()).toISOString(),
|
|
824
|
+
files: checkedFiles(id, dir),
|
|
825
|
+
};
|
|
826
|
+
const tmp = path.join(dir, `.${AGENT_SDK_MARKER}.tmp`);
|
|
827
|
+
fsImpl.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`);
|
|
828
|
+
syncPath(tmp);
|
|
829
|
+
fsImpl.renameSync(tmp, path.join(dir, AGENT_SDK_MARKER));
|
|
830
|
+
return marker;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// The partial folder into place. A final folder that checks out by now
|
|
834
|
+
// (a lock taken over from a process that was still finishing) wins, except
|
|
835
|
+
// for a reinstall (`replace`); one that does not is moved aside first: a
|
|
836
|
+
// rename never goes over a folder. Workers that use the folder find the
|
|
837
|
+
// new copy under the same path.
|
|
838
|
+
// Returns false when `stillCurrent()` turned false between two tries (a
|
|
839
|
+
// cancel, dispose): nothing was moved, the caller lets go.
|
|
840
|
+
async function renameIntoPlace(id, partial, { replace = false, stillCurrent = () => true } = {}) {
|
|
841
|
+
const final = pinnedDir(id);
|
|
842
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
843
|
+
if (!stillCurrent()) return false;
|
|
844
|
+
if (!replace && isValidAgentSdkInstall(final, { pin: pinOf(id), fsImpl })) {
|
|
845
|
+
await removeTreeAsync(partial);
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
if (fsImpl.existsSync(final) && !moveAside(id, final)) {
|
|
849
|
+
if (attempt >= renameRetryMs.length) throw new Error(`The folder ${final} is in use; stop the sessions that use it, then try again.`);
|
|
850
|
+
await sleep(renameRetryMs[attempt]);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
try {
|
|
854
|
+
fsImpl.renameSync(partial, final);
|
|
855
|
+
if (platform !== 'win32') syncPath(sdkDir(id));
|
|
856
|
+
return true;
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (!['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY', 'EEXIST'].includes(error?.code) || attempt >= renameRetryMs.length) {
|
|
859
|
+
throw new Error(`The new ${getAgentSdkDescriptor(id).label} folder could not be moved into place (${error?.code || error?.message || error}).`);
|
|
860
|
+
}
|
|
861
|
+
await sleep(renameRetryMs[attempt]);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function clearRetry(entry) {
|
|
867
|
+
if (entry.retryTimer) clearTimeoutImpl(entry.retryTimer);
|
|
868
|
+
entry.retryTimer = null;
|
|
869
|
+
entry.retryAt = null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function scheduleRetry(entry) {
|
|
873
|
+
clearRetry(entry);
|
|
874
|
+
const delay = retryDelaysMs[entry.attempts - 1];
|
|
875
|
+
if (delay === undefined || disposed) return;
|
|
876
|
+
entry.retryAt = now() + delay;
|
|
877
|
+
entry.retryTimer = setTimeoutImpl(() => {
|
|
878
|
+
entry.retryTimer = null;
|
|
879
|
+
entry.retryAt = null;
|
|
880
|
+
void startInstall(entry.id, { reason: 'retry', manual: false });
|
|
881
|
+
}, delay);
|
|
882
|
+
entry.retryTimer?.unref?.();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function settleWaiters(entry) {
|
|
886
|
+
const waiters = entry.waiters.splice(0);
|
|
887
|
+
const value = publicStatus(entry);
|
|
888
|
+
for (const resolve of waiters) resolve(value);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function fail(entry, session, message, kind, { lock = null, partial = null } = {}) {
|
|
892
|
+
// The partial folder goes first, off the event loop: whoever sees the
|
|
893
|
+
// failure finds it gone and the lock free.
|
|
894
|
+
if (partial) return removeTreeAsync(partial).then(() => fail(entry, session, message, kind, { lock }));
|
|
895
|
+
releaseLock(lock);
|
|
896
|
+
if (entry.session !== session) return;
|
|
897
|
+
entry.session = null;
|
|
898
|
+
entry.heldBy = null;
|
|
899
|
+
entry.finishedAt = new Date(now()).toISOString();
|
|
900
|
+
entry.lastAttemptAt = entry.finishedAt;
|
|
901
|
+
if (kind === AGENT_SDK_FAILURE_KINDS.PLATFORM) {
|
|
902
|
+
transition(entry, AGENT_SDK_STATES.UNSUPPORTED, { error: message, kind });
|
|
903
|
+
} else {
|
|
904
|
+
transition(entry, AGENT_SDK_STATES.ERROR, { error: message, kind });
|
|
905
|
+
if (kind === AGENT_SDK_FAILURE_KINDS.NETWORK) scheduleRetry(entry);
|
|
906
|
+
if (entry.retryAt) emit();
|
|
907
|
+
}
|
|
908
|
+
if (kind !== AGENT_SDK_FAILURE_KINDS.CANCELLED) {
|
|
909
|
+
persist(entry.id, { hash: pinOf(entry.id).hash, error: message, kind, attempts: entry.attempts, lastAttemptAt: entry.lastAttemptAt });
|
|
910
|
+
}
|
|
911
|
+
log(`${entry.id}: ${message}`);
|
|
912
|
+
settleWaiters(entry);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// The clean-up only under the lock: a folder another process installed
|
|
916
|
+
// is that process's to tidy. Synchronous unless there is a folder to
|
|
917
|
+
// delete.
|
|
918
|
+
function succeed(entry, session, lock) {
|
|
919
|
+
const collecting = lock?.guard ? collectGarbage(entry.id) : null;
|
|
920
|
+
if (!collecting) return finishSucceed(entry, session, lock);
|
|
921
|
+
return collecting
|
|
922
|
+
.catch((error) => log(`${entry.id}: clean-up failed: ${error?.message || error}`))
|
|
923
|
+
.then(() => finishSucceed(entry, session, lock));
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function finishSucceed(entry, session, lock) {
|
|
927
|
+
releaseLock(lock);
|
|
928
|
+
if (entry.session !== session) return;
|
|
929
|
+
entry.session = null;
|
|
930
|
+
entry.heldBy = null;
|
|
931
|
+
entry.finishedAt = new Date(now()).toISOString();
|
|
932
|
+
entry.lastAttemptAt = entry.finishedAt;
|
|
933
|
+
entry.attempts = 0;
|
|
934
|
+
clearRetry(entry);
|
|
935
|
+
persist(entry.id, null);
|
|
936
|
+
const dir = readyDir(entry.id);
|
|
937
|
+
if (dir) leaseOwn(entry.id, dir);
|
|
938
|
+
transition(entry, AGENT_SDK_STATES.READY, { installed: entry.pinned, dir, error: null, kind: null });
|
|
939
|
+
settleWaiters(entry);
|
|
940
|
+
for (const hook of [...readyHooks]) {
|
|
941
|
+
try { hook(publicStatus(entry)); } catch (error) { log(`ready hook failed: ${error?.message || error}`); }
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function runInstall(entry, session) {
|
|
946
|
+
// The lock: polled while another process holds it, which then usually
|
|
947
|
+
// finishes the same install.
|
|
948
|
+
let lock = null;
|
|
949
|
+
for (;;) {
|
|
950
|
+
if (entry.session !== session) return;
|
|
951
|
+
if (!session.force && readyDir(entry.id)) return succeed(entry, session, null);
|
|
952
|
+
try {
|
|
953
|
+
lock = tryLock(entry.id);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
return fail(entry, session, `The SDK folder could not be locked (${error?.code || error?.message || error}).`, AGENT_SDK_FAILURE_KINDS.OTHER);
|
|
956
|
+
}
|
|
957
|
+
if (lock.guard) break;
|
|
958
|
+
if (entry.heldBy !== lock.heldBy) {
|
|
959
|
+
entry.heldBy = lock.heldBy;
|
|
960
|
+
emit();
|
|
961
|
+
}
|
|
962
|
+
await sleep(lockPollMs);
|
|
963
|
+
}
|
|
964
|
+
if (entry.session !== session) return releaseLock(lock);
|
|
965
|
+
entry.heldBy = null;
|
|
966
|
+
if (!session.force && readyDir(entry.id)) return succeed(entry, session, lock);
|
|
967
|
+
const cleaning = cleanUpLeftovers(entry.id);
|
|
968
|
+
if (cleaning) await cleaning;
|
|
969
|
+
// A cancel or dispose while the leftovers went: no folder, no npm.
|
|
970
|
+
if (entry.session !== session) return releaseLock(lock);
|
|
971
|
+
|
|
972
|
+
const pin = pinOf(entry.id);
|
|
973
|
+
const partial = path.join(sdkDir(entry.id), `${pin.dirName}.partial-${crypto.randomBytes(3).toString('hex')}`);
|
|
974
|
+
try {
|
|
975
|
+
fsImpl.mkdirSync(partial, { recursive: true });
|
|
976
|
+
for (const [name, bytes] of Object.entries(pin.files)) fsImpl.writeFileSync(path.join(partial, name), bytes);
|
|
977
|
+
} catch (error) {
|
|
978
|
+
return fail(entry, session, `The SDK folder could not be written (${error?.code || error?.message || error}).`, AGENT_SDK_FAILURE_KINDS.OTHER, { lock, partial });
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const watch = watchProgress(entry, session, partial);
|
|
982
|
+
const result = await runToCompletion(() => spawnNpm(entry.id, partial), {
|
|
983
|
+
timeoutMs: installTimeoutMs,
|
|
984
|
+
setTimeoutImpl,
|
|
985
|
+
clearTimeoutImpl,
|
|
986
|
+
killChild: (child) => terminate(child),
|
|
987
|
+
maxOutputChars: MAX_CAPTURED_OUTPUT_CHARS,
|
|
988
|
+
onChild: (child) => {
|
|
989
|
+
session.child = child;
|
|
990
|
+
try {
|
|
991
|
+
fsImpl.writeFileSync(path.join(partial, '.owner.json'), JSON.stringify({ format: 2, pid: child.pid, holder: identityOf(child.pid), startedAt: new Date(now()).toISOString() }));
|
|
992
|
+
} catch {}
|
|
993
|
+
},
|
|
994
|
+
onOutput: (text) => {
|
|
995
|
+
watch.progress();
|
|
996
|
+
appendLog(entry, session, text);
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
watch.stop();
|
|
1000
|
+
if (session.stalled) {
|
|
1001
|
+
result.ok = false;
|
|
1002
|
+
result.error = `npm made no progress for ${Math.round(installStallMs / 60_000)} minutes (timed out after ${installStallMs}ms)`;
|
|
1003
|
+
}
|
|
1004
|
+
if (entry.session !== session) {
|
|
1005
|
+
// Cancelled: the child is gone or going; its folder goes with it.
|
|
1006
|
+
await removeTreeAsync(partial);
|
|
1007
|
+
return releaseLock(lock);
|
|
1008
|
+
}
|
|
1009
|
+
if (!result.ok) {
|
|
1010
|
+
const output = stripTerminalEscapes(`${result.output || ''}\n${result.error || ''}`);
|
|
1011
|
+
const { kind, hint } = classifyAgentSdkFailure(output);
|
|
1012
|
+
if (/ENOENT/.test(String(result.error || '')) && result.code === null && !result.output) {
|
|
1013
|
+
return fail(entry, session, 'npm was not found next to this Node; install npm, then try again.', AGENT_SDK_FAILURE_KINDS.OTHER, { lock, partial });
|
|
1014
|
+
}
|
|
1015
|
+
const tail = session.stalled
|
|
1016
|
+
? result.error
|
|
1017
|
+
: (tailOf(stripTerminalEscapes(result.output || '')) || result.error || `npm ci exited with code ${result.code}`);
|
|
1018
|
+
return fail(entry, session, hint ? `${hint} (${tail})` : tail, kind, { lock, partial });
|
|
1019
|
+
}
|
|
1020
|
+
// npm said yes; the files decide. A platform package npm left out here
|
|
1021
|
+
// is the host's npm config (omit=optional, os, cpu), never the platform.
|
|
1022
|
+
const platformPackage = platformPackageFor(pin);
|
|
1023
|
+
if (platformPackage && !fsImpl.existsSync(path.join(partial, 'node_modules', ...platformPackage.split('/'), 'package.json'))) {
|
|
1024
|
+
return fail(entry, session, `npm installed ${getAgentSdkDescriptor(entry.id).packageName} without ${platformPackage}; check the npm config for omit, os or cpu settings.`, AGENT_SDK_FAILURE_KINDS.OTHER, { lock, partial });
|
|
1025
|
+
}
|
|
1026
|
+
try {
|
|
1027
|
+
for (const rel of agentSdkCheckedFiles(pin)) {
|
|
1028
|
+
if (!fsImpl.existsSync(path.join(partial, ...rel.split('/')))) throw new Error(`${rel} is missing after npm ci`);
|
|
1029
|
+
}
|
|
1030
|
+
try { fsImpl.rmSync(path.join(partial, '.owner.json'), { force: true }); } catch {}
|
|
1031
|
+
await syncTree(partial);
|
|
1032
|
+
// A cancel or dispose meanwhile: the folder never goes into place.
|
|
1033
|
+
const current = () => entry.session === session;
|
|
1034
|
+
if (!current()) {
|
|
1035
|
+
await removeTreeAsync(partial);
|
|
1036
|
+
return releaseLock(lock);
|
|
1037
|
+
}
|
|
1038
|
+
writeMarker(entry.id, partial);
|
|
1039
|
+
if (!(await renameIntoPlace(entry.id, partial, { replace: session.force, stillCurrent: current }))) {
|
|
1040
|
+
await removeTreeAsync(partial);
|
|
1041
|
+
return releaseLock(lock);
|
|
1042
|
+
}
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
return fail(entry, session, error?.message || String(error), AGENT_SDK_FAILURE_KINDS.OTHER, { lock, partial });
|
|
1045
|
+
}
|
|
1046
|
+
return succeed(entry, session, lock);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Starts an install, or joins the one running. `manual` (a button) resets
|
|
1051
|
+
* the automatic retries; `force` installs again over a ready folder (the
|
|
1052
|
+
* Reinstall button). Returns at once; ensure() waits.
|
|
1053
|
+
*/
|
|
1054
|
+
function startInstall(id, { reason = 'request', manual = true, force = false } = {}) {
|
|
1055
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
1056
|
+
if (!descriptor || !ids.includes(descriptor.id)) return { ok: false, statusCode: 400, error: 'Unknown agent SDK' };
|
|
1057
|
+
const entry = settle(entryOf(descriptor.id));
|
|
1058
|
+
if (entry.state === AGENT_SDK_STATES.UNSUPPORTED) return { ok: false, statusCode: 409, error: entry.error, status: publicStatus(entry) };
|
|
1059
|
+
if (entry.session) return { ok: true, reused: true, status: publicStatus(entry) };
|
|
1060
|
+
if (entry.state === AGENT_SDK_STATES.READY && !force) return { ok: true, ready: true, status: publicStatus(entry) };
|
|
1061
|
+
if (rootProblem) {
|
|
1062
|
+
log(`${entry.id}: not installed: ${rootProblem}`);
|
|
1063
|
+
entry.lastAttemptAt = new Date(now()).toISOString();
|
|
1064
|
+
transition(entry, AGENT_SDK_STATES.ERROR, { error: rootProblem, kind: AGENT_SDK_FAILURE_KINDS.OTHER });
|
|
1065
|
+
settleWaiters(entry);
|
|
1066
|
+
return { ok: false, statusCode: 409, error: rootProblem, status: publicStatus(entry) };
|
|
1067
|
+
}
|
|
1068
|
+
if (spawnsDisabled()) {
|
|
1069
|
+
log(`${entry.id}: not installed: ${CLI_SPAWN_DISABLED_ERROR} (COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN)`);
|
|
1070
|
+
transition(entry, AGENT_SDK_STATES.ERROR, { error: CLI_SPAWN_DISABLED_ERROR, kind: AGENT_SDK_FAILURE_KINDS.OTHER });
|
|
1071
|
+
settleWaiters(entry);
|
|
1072
|
+
return { ok: false, statusCode: 503, error: CLI_SPAWN_DISABLED_ERROR, status: publicStatus(entry) };
|
|
1073
|
+
}
|
|
1074
|
+
clearRetry(entry);
|
|
1075
|
+
if (manual) entry.attempts = 0;
|
|
1076
|
+
entry.attempts += 1;
|
|
1077
|
+
entry.generation += 1;
|
|
1078
|
+
const session = { generation: entry.generation, child: null, reason, force: Boolean(force) };
|
|
1079
|
+
entry.session = session;
|
|
1080
|
+
entry.log = '';
|
|
1081
|
+
entry.startedAt = new Date(now()).toISOString();
|
|
1082
|
+
entry.finishedAt = null;
|
|
1083
|
+
entry.heldBy = null;
|
|
1084
|
+
transition(entry, AGENT_SDK_STATES.INSTALLING, { error: null, kind: null });
|
|
1085
|
+
log(`${entry.id}: installing ${entry.pinned} (${reason})`);
|
|
1086
|
+
runInstall(entry, session).catch((error) => fail(entry, session, error?.message || String(error), AGENT_SDK_FAILURE_KINDS.OTHER));
|
|
1087
|
+
return { ok: true, reused: false, status: publicStatus(entry) };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Resolves with the SDK's status once it is ready, failed or not available
|
|
1092
|
+
* here, installing a missing one first (the launch gate's call); joins an
|
|
1093
|
+
* install that runs.
|
|
1094
|
+
*/
|
|
1095
|
+
function ensure(id, { reason = 'launch', retryFailed = false } = {}) {
|
|
1096
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
1097
|
+
if (!descriptor || !ids.includes(descriptor.id)) return Promise.resolve(null);
|
|
1098
|
+
// A failed install stays failed for a launch: the turn says so, and the
|
|
1099
|
+
// Retry button or the automatic retry tries again.
|
|
1100
|
+
const current = settle(entryOf(descriptor.id));
|
|
1101
|
+
if (current.state === AGENT_SDK_STATES.ERROR && !current.session && !retryFailed) return Promise.resolve(publicStatus(current));
|
|
1102
|
+
const started = startInstall(id, { reason, manual: false });
|
|
1103
|
+
if (!started.ok || !started.status) return Promise.resolve(started.status || null);
|
|
1104
|
+
if (started.ready) return Promise.resolve(started.status);
|
|
1105
|
+
const entry = entryOf(getAgentSdkDescriptor(id).id);
|
|
1106
|
+
return new Promise((resolve) => entry.waiters.push(resolve));
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** Stops a running install; dismisses a failure otherwise. */
|
|
1110
|
+
function cancel(id) {
|
|
1111
|
+
const descriptor = getAgentSdkDescriptor(id);
|
|
1112
|
+
if (!descriptor || !ids.includes(descriptor.id)) return { ok: false, statusCode: 400, error: 'Unknown agent SDK' };
|
|
1113
|
+
const entry = entryOf(descriptor.id);
|
|
1114
|
+
clearRetry(entry);
|
|
1115
|
+
const session = entry.session;
|
|
1116
|
+
if (session) {
|
|
1117
|
+
if (session.child) terminate(session.child);
|
|
1118
|
+
// A failure saved before is dismissed with it.
|
|
1119
|
+
persist(entry.id, null);
|
|
1120
|
+
fail(entry, session, `${entry.label} install cancelled`, AGENT_SDK_FAILURE_KINDS.CANCELLED);
|
|
1121
|
+
return { ok: true, status: publicStatus(entry) };
|
|
1122
|
+
}
|
|
1123
|
+
if (entry.state === AGENT_SDK_STATES.ERROR) {
|
|
1124
|
+
persist(entry.id, null);
|
|
1125
|
+
transition(entry, AGENT_SDK_STATES.MISSING, { error: null, kind: null });
|
|
1126
|
+
}
|
|
1127
|
+
return { ok: true, status: publicStatus(settle(entry)) };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* At start, in the background: the leftovers of a crash
|
|
1132
|
+
* and folders of earlier pins go, and every SDK in `inUse` whose pinned
|
|
1133
|
+
* folder is missing is installed. Never throws.
|
|
1134
|
+
*/
|
|
1135
|
+
async function reconcile({ inUse = [] } = {}) {
|
|
1136
|
+
for (const id of ids) {
|
|
1137
|
+
try {
|
|
1138
|
+
const entry = settle(entryOf(id));
|
|
1139
|
+
if (entry.state === AGENT_SDK_STATES.UNSUPPORTED || rootProblem) continue;
|
|
1140
|
+
if (fsImpl.existsSync(sdkDir(id))) {
|
|
1141
|
+
const lock = tryLock(id);
|
|
1142
|
+
if (lock.guard) {
|
|
1143
|
+
try {
|
|
1144
|
+
await cleanUpLeftovers(id);
|
|
1145
|
+
await collectGarbage(id);
|
|
1146
|
+
} finally {
|
|
1147
|
+
releaseLock(lock);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (!inUse.includes(id) || entry.state === AGENT_SDK_STATES.READY || entry.session) continue;
|
|
1152
|
+
const last = Date.parse(entry.lastAttemptAt || '') || 0;
|
|
1153
|
+
if (entry.state === AGENT_SDK_STATES.ERROR && now() - last < RESTART_QUIET_MS) continue;
|
|
1154
|
+
startInstall(id, { reason: 'start-up', manual: false });
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
log(`${id}: start-up check failed: ${error?.message || error}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** A lease on `dir` for `leasePid` (a worker the relay launches). */
|
|
1162
|
+
function lease(dir, leasePid = pid) {
|
|
1163
|
+
return writeLease(dir, leasePid);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function subscribe(listener) {
|
|
1167
|
+
if (typeof listener !== 'function') return () => {};
|
|
1168
|
+
listeners.add(listener);
|
|
1169
|
+
return () => listeners.delete(listener);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function onReady(hook) {
|
|
1173
|
+
if (typeof hook !== 'function') return () => {};
|
|
1174
|
+
readyHooks.add(hook);
|
|
1175
|
+
return () => readyHooks.delete(hook);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function dispose() {
|
|
1179
|
+
disposed = true;
|
|
1180
|
+
for (const entry of sdks.values()) {
|
|
1181
|
+
clearRetry(entry);
|
|
1182
|
+
const session = entry.session;
|
|
1183
|
+
if (session) {
|
|
1184
|
+
entry.generation += 1;
|
|
1185
|
+
entry.session = null;
|
|
1186
|
+
if (session.child) terminate(session.child);
|
|
1187
|
+
}
|
|
1188
|
+
settleWaiters(entry);
|
|
1189
|
+
}
|
|
1190
|
+
clearLogFlush();
|
|
1191
|
+
listeners.clear();
|
|
1192
|
+
readyHooks.clear();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return {
|
|
1196
|
+
root,
|
|
1197
|
+
getDescriptor: getAgentSdkDescriptor,
|
|
1198
|
+
ids: () => [...ids],
|
|
1199
|
+
readyDir,
|
|
1200
|
+
status,
|
|
1201
|
+
snapshot,
|
|
1202
|
+
install: (id, options = {}) => startInstall(id, { ...options, manual: true }),
|
|
1203
|
+
ensure,
|
|
1204
|
+
cancel,
|
|
1205
|
+
reconcile,
|
|
1206
|
+
lease,
|
|
1207
|
+
subscribe,
|
|
1208
|
+
onReady,
|
|
1209
|
+
dispose,
|
|
1210
|
+
};
|
|
1211
|
+
}
|