labgate 0.5.29 → 0.5.31
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/README.md +6 -2
- package/dist/cli.js +101 -0
- package/dist/cli.js.map +1 -1
- package/dist/lib/container.d.ts +19 -0
- package/dist/lib/container.js +256 -59
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/init.d.ts +1 -0
- package/dist/lib/init.js +68 -10
- package/dist/lib/init.js.map +1 -1
- package/dist/lib/results-mcp.d.ts +2 -2
- package/dist/lib/results-mcp.js +26 -4
- package/dist/lib/results-mcp.js.map +1 -1
- package/dist/lib/results-store.d.ts +1 -0
- package/dist/lib/results-store.js +50 -0
- package/dist/lib/results-store.js.map +1 -1
- package/dist/lib/runtime.d.ts +6 -0
- package/dist/lib/runtime.js +46 -19
- package/dist/lib/runtime.js.map +1 -1
- package/dist/lib/ui.d.ts +2 -0
- package/dist/lib/ui.html +7498 -3438
- package/dist/lib/ui.js +1555 -128
- package/dist/lib/ui.js.map +1 -1
- package/dist/lib/web-terminal.d.ts +13 -0
- package/dist/lib/web-terminal.js +126 -15
- package/dist/lib/web-terminal.js.map +1 -1
- package/dist/mcp-bundles/results-mcp.bundle.mjs +70 -3
- package/package.json +2 -2
package/dist/lib/ui.js
CHANGED
|
@@ -43,7 +43,9 @@ const util_1 = require("util");
|
|
|
43
43
|
const crypto_1 = require("crypto");
|
|
44
44
|
const ws_1 = require("ws");
|
|
45
45
|
const config_js_1 = require("./config.js");
|
|
46
|
+
const init_js_1 = require("./init.js");
|
|
46
47
|
const container_js_1 = require("./container.js");
|
|
48
|
+
const runtime_js_1 = require("./runtime.js");
|
|
47
49
|
const audit_js_1 = require("./audit.js");
|
|
48
50
|
const slurm_db_js_1 = require("./slurm-db.js");
|
|
49
51
|
const slurm_poller_js_1 = require("./slurm-poller.js");
|
|
@@ -68,6 +70,8 @@ const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
|
|
|
68
70
|
const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
|
|
69
71
|
const IRIS_SAMPLE_DATASET_NAME = 'flowers-iris';
|
|
70
72
|
const IRIS_SAMPLE_SOURCE_URL = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv';
|
|
73
|
+
const PODMAN_SETUP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
74
|
+
const PODMAN_SETUP_MAX_BUFFER = 16 * 1024 * 1024;
|
|
71
75
|
const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
|
|
72
76
|
'\n' +
|
|
73
77
|
'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
|
|
@@ -86,6 +90,26 @@ let slurmPoller = null;
|
|
|
86
90
|
let resultsStore = null;
|
|
87
91
|
const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
|
|
88
92
|
const webTerminalBridges = new Map();
|
|
93
|
+
const WEB_TERMINAL_INIT_RETENTION_MS = 60 * 60 * 1000;
|
|
94
|
+
const WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS = 60 * 60 * 1000;
|
|
95
|
+
const WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER = 64 * 1024 * 1024;
|
|
96
|
+
const WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
97
|
+
const WEB_TERMINAL_AGENT_PREP_MAX_BUFFER = 32 * 1024 * 1024;
|
|
98
|
+
const WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS = 90 * 1000;
|
|
99
|
+
const WEB_TERMINAL_STARTUP_READY_POLL_MS = 120;
|
|
100
|
+
const WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT = 64 * 1024;
|
|
101
|
+
const WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS = 750;
|
|
102
|
+
const WEB_TERMINAL_BUFFER_MAX_BYTES = 512_000;
|
|
103
|
+
const WEB_TERMINAL_HISTORY_MAX_BYTES = 8 * 1024 * 1024;
|
|
104
|
+
const WEB_TERMINAL_HISTORY_CHUNK_BYTES = 8 * 1024;
|
|
105
|
+
const WEB_TERMINAL_HISTORY_PAGE_DEFAULT = 120;
|
|
106
|
+
const WEB_TERMINAL_HISTORY_PAGE_MAX = 600;
|
|
107
|
+
const WEB_TERMINAL_STARTUP_HEADER_RE = /(?:^|\n)\s*LabGate\s*(?:\n|$)/i;
|
|
108
|
+
const WEB_TERMINAL_STARTUP_BLOCKED_RE = /(?:^|\n)\s*Blocked\s+\d+\s+patterns\b/i;
|
|
109
|
+
const CLAUDE_HEADLESS_STDERR_LIMIT = 12_000;
|
|
110
|
+
const webTerminalInitJobs = new Map();
|
|
111
|
+
const webTerminalImagePullLocks = new Map();
|
|
112
|
+
const webTerminalAgentPrepLocks = new Map();
|
|
89
113
|
function getResultsStore() {
|
|
90
114
|
if (!resultsStore) {
|
|
91
115
|
resultsStore = new results_store_js_1.ResultsStore((0, config_js_1.getResultsDbPath)());
|
|
@@ -136,6 +160,475 @@ function getSlurmRuntimeStatus() {
|
|
|
136
160
|
missingCommands,
|
|
137
161
|
};
|
|
138
162
|
}
|
|
163
|
+
function commandErrorDetail(err) {
|
|
164
|
+
return [
|
|
165
|
+
err?.stderr,
|
|
166
|
+
err?.stdout,
|
|
167
|
+
err?.message,
|
|
168
|
+
err?.cause?.stderr,
|
|
169
|
+
err?.cause?.stdout,
|
|
170
|
+
err?.cause?.message,
|
|
171
|
+
]
|
|
172
|
+
.filter((part) => typeof part === 'string' && part.trim().length > 0)
|
|
173
|
+
.map((part) => String(part).trim())
|
|
174
|
+
.join('\n');
|
|
175
|
+
}
|
|
176
|
+
function isPodmanNotReadyError(error) {
|
|
177
|
+
return /podman is installed but not ready/i.test(error || '');
|
|
178
|
+
}
|
|
179
|
+
async function prepareRuntimeForWebTerminal(preferred) {
|
|
180
|
+
const initial = (0, runtime_js_1.checkRuntime)(preferred);
|
|
181
|
+
if (initial.ok) {
|
|
182
|
+
return { ok: true, initialized: false };
|
|
183
|
+
}
|
|
184
|
+
const canAutoSetupPodman = ((0, os_1.platform)() === 'darwin' &&
|
|
185
|
+
preferred !== 'apptainer' &&
|
|
186
|
+
hasCommandInPath('podman') &&
|
|
187
|
+
isPodmanNotReadyError(initial.error));
|
|
188
|
+
if (!canAutoSetupPodman) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
initialized: false,
|
|
192
|
+
error: initial.error || 'Container runtime unavailable.',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
log.step('Podman runtime not ready. Attempting automatic machine setup for UI launch...');
|
|
196
|
+
try {
|
|
197
|
+
try {
|
|
198
|
+
await execFileAsync('podman', ['machine', 'init'], {
|
|
199
|
+
timeout: PODMAN_SETUP_TIMEOUT_MS,
|
|
200
|
+
maxBuffer: PODMAN_SETUP_MAX_BUFFER,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
const detail = commandErrorDetail(err);
|
|
205
|
+
if (!/already exists/i.test(detail)) {
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
await execFileAsync('podman', ['machine', 'start'], {
|
|
210
|
+
timeout: PODMAN_SETUP_TIMEOUT_MS,
|
|
211
|
+
maxBuffer: PODMAN_SETUP_MAX_BUFFER,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
const detail = commandErrorDetail(err);
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
initialized: true,
|
|
219
|
+
error: [
|
|
220
|
+
'Podman setup failed during UI session launch.',
|
|
221
|
+
detail || 'Unknown Podman error.',
|
|
222
|
+
'',
|
|
223
|
+
'Try in a terminal:',
|
|
224
|
+
' podman machine init',
|
|
225
|
+
' podman machine start',
|
|
226
|
+
].join('\n'),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const after = (0, runtime_js_1.checkRuntime)(preferred);
|
|
230
|
+
if (!after.ok) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
initialized: true,
|
|
234
|
+
error: after.error || 'Container runtime unavailable after setup.',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, initialized: true };
|
|
238
|
+
}
|
|
239
|
+
function createWebTerminalInitId() {
|
|
240
|
+
return `wti-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
|
|
241
|
+
}
|
|
242
|
+
function createWebTerminalInitJob(agent, workdir) {
|
|
243
|
+
const now = new Date().toISOString();
|
|
244
|
+
return {
|
|
245
|
+
id: createWebTerminalInitId(),
|
|
246
|
+
agent,
|
|
247
|
+
workdir,
|
|
248
|
+
status: 'running',
|
|
249
|
+
stage: 'queued',
|
|
250
|
+
message: 'Queued session initialization.',
|
|
251
|
+
startedAt: now,
|
|
252
|
+
updatedAt: now,
|
|
253
|
+
session: null,
|
|
254
|
+
error: null,
|
|
255
|
+
code: null,
|
|
256
|
+
phase: null,
|
|
257
|
+
initialized: false,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function pruneWebTerminalInitJobs() {
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
for (const [id, job] of webTerminalInitJobs.entries()) {
|
|
263
|
+
if (job.status === 'running')
|
|
264
|
+
continue;
|
|
265
|
+
const ageMs = now - Date.parse(job.updatedAt || job.startedAt || '');
|
|
266
|
+
if (Number.isFinite(ageMs) && ageMs > WEB_TERMINAL_INIT_RETENTION_MS) {
|
|
267
|
+
webTerminalInitJobs.delete(id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function updateWebTerminalInitJob(id, patch) {
|
|
272
|
+
const existing = webTerminalInitJobs.get(id);
|
|
273
|
+
if (!existing)
|
|
274
|
+
return null;
|
|
275
|
+
const updated = {
|
|
276
|
+
...existing,
|
|
277
|
+
...patch,
|
|
278
|
+
updatedAt: new Date().toISOString(),
|
|
279
|
+
};
|
|
280
|
+
webTerminalInitJobs.set(id, updated);
|
|
281
|
+
return updated;
|
|
282
|
+
}
|
|
283
|
+
function serializeWebTerminalInitJob(job) {
|
|
284
|
+
return {
|
|
285
|
+
id: job.id,
|
|
286
|
+
agent: job.agent,
|
|
287
|
+
workdir: job.workdir,
|
|
288
|
+
status: job.status,
|
|
289
|
+
stage: job.stage,
|
|
290
|
+
message: job.message,
|
|
291
|
+
startedAt: job.startedAt,
|
|
292
|
+
updatedAt: job.updatedAt,
|
|
293
|
+
session: job.session,
|
|
294
|
+
error: job.error,
|
|
295
|
+
code: job.code,
|
|
296
|
+
phase: job.phase,
|
|
297
|
+
initialized: job.initialized,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function withWebTerminalImagePullLock(lockKey, work) {
|
|
301
|
+
const existing = webTerminalImagePullLocks.get(lockKey);
|
|
302
|
+
if (existing) {
|
|
303
|
+
await existing;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
let current = null;
|
|
307
|
+
current = (async () => {
|
|
308
|
+
try {
|
|
309
|
+
await work();
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
if (current && webTerminalImagePullLocks.get(lockKey) === current) {
|
|
313
|
+
webTerminalImagePullLocks.delete(lockKey);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
})();
|
|
317
|
+
webTerminalImagePullLocks.set(lockKey, current);
|
|
318
|
+
await current;
|
|
319
|
+
}
|
|
320
|
+
async function ensureWebTerminalImageReady(runtime, image, onProgress) {
|
|
321
|
+
onProgress?.('image_check', `Checking image availability for ${image}...`);
|
|
322
|
+
if (runtime === 'podman') {
|
|
323
|
+
const imageExists = async () => {
|
|
324
|
+
try {
|
|
325
|
+
await execFileAsync('podman', ['image', 'exists', image], {
|
|
326
|
+
timeout: 10_000,
|
|
327
|
+
maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
|
|
328
|
+
});
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
if (await imageExists())
|
|
336
|
+
return;
|
|
337
|
+
await withWebTerminalImagePullLock(`podman:${image}`, async () => {
|
|
338
|
+
if (await imageExists())
|
|
339
|
+
return;
|
|
340
|
+
onProgress?.('image_pull', `Pulling container image ${image}...`);
|
|
341
|
+
await execFileAsync('podman', ['pull', image], {
|
|
342
|
+
timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
|
|
343
|
+
maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const imagesDir = (0, config_js_1.getImagesDir)();
|
|
349
|
+
const sifPath = (0, path_1.join)(imagesDir, (0, container_js_1.imageToSifName)(image));
|
|
350
|
+
if ((0, fs_1.existsSync)(sifPath))
|
|
351
|
+
return;
|
|
352
|
+
await withWebTerminalImagePullLock(`apptainer:${image}`, async () => {
|
|
353
|
+
if ((0, fs_1.existsSync)(sifPath))
|
|
354
|
+
return;
|
|
355
|
+
(0, fs_1.mkdirSync)(imagesDir, { recursive: true });
|
|
356
|
+
onProgress?.('image_pull', `Pulling container image ${image}...`);
|
|
357
|
+
await execFileAsync('apptainer', ['pull', sifPath, `docker://${image}`], {
|
|
358
|
+
timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
|
|
359
|
+
maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function getWebTerminalAgentBootstrapSpec(agent) {
|
|
364
|
+
if (agent === 'codex') {
|
|
365
|
+
return { bin: 'codex', pkg: '@openai/codex' };
|
|
366
|
+
}
|
|
367
|
+
return { bin: 'claude', pkg: '@anthropic-ai/claude-code' };
|
|
368
|
+
}
|
|
369
|
+
async function withWebTerminalAgentPrepareLock(lockKey, work) {
|
|
370
|
+
const existing = webTerminalAgentPrepLocks.get(lockKey);
|
|
371
|
+
if (existing) {
|
|
372
|
+
await existing;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
let current = null;
|
|
376
|
+
current = (async () => {
|
|
377
|
+
try {
|
|
378
|
+
await work();
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
if (current && webTerminalAgentPrepLocks.get(lockKey) === current) {
|
|
382
|
+
webTerminalAgentPrepLocks.delete(lockKey);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
})();
|
|
386
|
+
webTerminalAgentPrepLocks.set(lockKey, current);
|
|
387
|
+
await current;
|
|
388
|
+
}
|
|
389
|
+
function getPodmanPrewarmNetworkArgs(networkMode) {
|
|
390
|
+
const mode = String(networkMode || '').trim().toLowerCase();
|
|
391
|
+
if (mode === 'none')
|
|
392
|
+
return ['--network', 'none'];
|
|
393
|
+
if (mode === 'host')
|
|
394
|
+
return ['--network', 'host'];
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
async function runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script) {
|
|
398
|
+
if (runtime === 'podman') {
|
|
399
|
+
const result = await execFileAsync('podman', [
|
|
400
|
+
'run',
|
|
401
|
+
'--rm',
|
|
402
|
+
'--workdir', '/work',
|
|
403
|
+
'--volume', `${sandboxHome}:/home/sandbox`,
|
|
404
|
+
'--volume', `${resolvedWorkdir}:/work`,
|
|
405
|
+
'--env', 'HOME=/home/sandbox',
|
|
406
|
+
...getPodmanPrewarmNetworkArgs(networkMode),
|
|
407
|
+
image,
|
|
408
|
+
'bash',
|
|
409
|
+
'-lc',
|
|
410
|
+
script,
|
|
411
|
+
], {
|
|
412
|
+
timeout: WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS,
|
|
413
|
+
maxBuffer: WEB_TERMINAL_AGENT_PREP_MAX_BUFFER,
|
|
414
|
+
});
|
|
415
|
+
return {
|
|
416
|
+
stdout: String(result?.stdout || ''),
|
|
417
|
+
stderr: String(result?.stderr || ''),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image));
|
|
421
|
+
const result = await execFileAsync('apptainer', [
|
|
422
|
+
'exec',
|
|
423
|
+
'--containall',
|
|
424
|
+
'--cleanenv',
|
|
425
|
+
'--home', `${sandboxHome}:/home/sandbox`,
|
|
426
|
+
'--bind', `${resolvedWorkdir}:/work`,
|
|
427
|
+
'--pwd', '/work',
|
|
428
|
+
sifPath,
|
|
429
|
+
'bash',
|
|
430
|
+
'-lc',
|
|
431
|
+
script,
|
|
432
|
+
], {
|
|
433
|
+
timeout: WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS,
|
|
434
|
+
maxBuffer: WEB_TERMINAL_AGENT_PREP_MAX_BUFFER,
|
|
435
|
+
});
|
|
436
|
+
return {
|
|
437
|
+
stdout: String(result?.stdout || ''),
|
|
438
|
+
stderr: String(result?.stderr || ''),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
async function ensureWebTerminalAgentReady(runtime, image, agent, resolvedWorkdir, networkMode, onProgress) {
|
|
442
|
+
const spec = getWebTerminalAgentBootstrapSpec(agent);
|
|
443
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
444
|
+
const installedBin = (0, path_1.join)(sandboxHome, '.npm-global', 'bin', spec.bin);
|
|
445
|
+
if ((0, fs_1.existsSync)(installedBin)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
await withWebTerminalAgentPrepareLock(spec.bin, async () => {
|
|
449
|
+
if ((0, fs_1.existsSync)(installedBin))
|
|
450
|
+
return;
|
|
451
|
+
onProgress?.('agent_prepare', `Preparing ${agent} CLI in sandbox home...`);
|
|
452
|
+
const script = [
|
|
453
|
+
'set -euo pipefail',
|
|
454
|
+
'export HOME=/home/sandbox',
|
|
455
|
+
'mkdir -p "$HOME/.npm-global"',
|
|
456
|
+
'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
|
|
457
|
+
'export PATH="$HOME/.npm-global/bin:$PATH"',
|
|
458
|
+
`if ! command -v ${spec.bin} >/dev/null 2>&1; then npm i -g ${spec.pkg}; fi`,
|
|
459
|
+
].join('\n');
|
|
460
|
+
await runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function extractAgentVersionFromOutput(output) {
|
|
464
|
+
const marker = String(output || '')
|
|
465
|
+
.split('\n')
|
|
466
|
+
.map((line) => line.trim())
|
|
467
|
+
.find((line) => line.startsWith('LABGATE_AGENT_VERSION:'));
|
|
468
|
+
if (!marker)
|
|
469
|
+
return null;
|
|
470
|
+
const raw = marker.slice('LABGATE_AGENT_VERSION:'.length).trim();
|
|
471
|
+
if (!raw)
|
|
472
|
+
return null;
|
|
473
|
+
return raw.replace(/^v/, '');
|
|
474
|
+
}
|
|
475
|
+
async function updateWebTerminalAgentCli(runtime, image, agent, resolvedWorkdir, networkMode) {
|
|
476
|
+
const spec = getWebTerminalAgentBootstrapSpec(agent);
|
|
477
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
478
|
+
let version = null;
|
|
479
|
+
await withWebTerminalAgentPrepareLock(spec.bin, async () => {
|
|
480
|
+
const script = [
|
|
481
|
+
'set -euo pipefail',
|
|
482
|
+
'export HOME=/home/sandbox',
|
|
483
|
+
'mkdir -p "$HOME/.npm-global"',
|
|
484
|
+
'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
|
|
485
|
+
'export PATH="$HOME/.npm-global/bin:$PATH"',
|
|
486
|
+
`npm i -g "${spec.pkg}"`,
|
|
487
|
+
`_labgate_ver="$(${spec.bin} --version 2>/dev/null || true)"`,
|
|
488
|
+
'_labgate_ver="$(printf "%s" "$_labgate_ver" | head -n 1 | tr -d \'\\r\')"',
|
|
489
|
+
'echo "LABGATE_AGENT_VERSION:${_labgate_ver}"',
|
|
490
|
+
].join('\n');
|
|
491
|
+
const result = await runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script);
|
|
492
|
+
version = extractAgentVersionFromOutput([result.stdout, result.stderr].filter(Boolean).join('\n'));
|
|
493
|
+
});
|
|
494
|
+
return { version };
|
|
495
|
+
}
|
|
496
|
+
function toRuntimeUnavailableResult(runtimeReady) {
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
status: runtimeReady.initialized ? 502 : 503,
|
|
500
|
+
body: {
|
|
501
|
+
ok: false,
|
|
502
|
+
code: 'runtime_unavailable',
|
|
503
|
+
phase: 'runtime_setup',
|
|
504
|
+
initialized: runtimeReady.initialized,
|
|
505
|
+
error: runtimeReady.error || 'Container runtime unavailable.',
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
|
|
510
|
+
const onProgress = opts.onProgress;
|
|
511
|
+
const config = (0, config_js_1.loadConfig)();
|
|
512
|
+
onProgress?.('runtime_setup', 'Checking container runtime...');
|
|
513
|
+
const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
|
|
514
|
+
if (!runtimeReady.ok) {
|
|
515
|
+
return toRuntimeUnavailableResult(runtimeReady);
|
|
516
|
+
}
|
|
517
|
+
const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
|
|
518
|
+
if (!runtimeCheck.ok || !runtimeCheck.runtime) {
|
|
519
|
+
return {
|
|
520
|
+
ok: false,
|
|
521
|
+
status: runtimeReady.initialized ? 502 : 503,
|
|
522
|
+
body: {
|
|
523
|
+
ok: false,
|
|
524
|
+
code: 'runtime_unavailable',
|
|
525
|
+
phase: 'runtime_setup',
|
|
526
|
+
initialized: runtimeReady.initialized,
|
|
527
|
+
error: runtimeCheck.error || 'Container runtime unavailable.',
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
// Preflight tmux before any slow image/agent preparation to avoid unnecessary side effects.
|
|
532
|
+
const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
|
|
533
|
+
if (!tmuxAvailable.ok) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
status: 500,
|
|
537
|
+
body: { ok: false, error: tmuxAvailable.error },
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (opts.prewarmImage) {
|
|
541
|
+
try {
|
|
542
|
+
await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, onProgress);
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
const detail = commandErrorDetail(err);
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
status: 502,
|
|
549
|
+
body: {
|
|
550
|
+
ok: false,
|
|
551
|
+
code: 'image_prepare_failed',
|
|
552
|
+
phase: 'image_prepare',
|
|
553
|
+
runtime: runtimeCheck.runtime,
|
|
554
|
+
image: config.image,
|
|
555
|
+
error: detail || `Failed to pull image ${config.image}.`,
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (opts.prewarmAgent) {
|
|
561
|
+
try {
|
|
562
|
+
await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, agent, resolvedWorkdir, config.network.mode, onProgress);
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
const detail = commandErrorDetail(err);
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
status: 502,
|
|
569
|
+
body: {
|
|
570
|
+
ok: false,
|
|
571
|
+
code: 'agent_prepare_failed',
|
|
572
|
+
phase: 'agent_prepare',
|
|
573
|
+
runtime: runtimeCheck.runtime,
|
|
574
|
+
agent,
|
|
575
|
+
image: config.image,
|
|
576
|
+
error: detail || `Failed to prepare ${agent} in sandbox home.`,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
onProgress?.('tmux_check', 'Checking terminal multiplexer availability...');
|
|
582
|
+
const cliEntrypoint = resolveCliEntrypoint();
|
|
583
|
+
const id = `wt-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
|
|
584
|
+
const record = (0, web_terminal_js_1.createWebTerminalRecord)({
|
|
585
|
+
id,
|
|
586
|
+
agent,
|
|
587
|
+
runtime: runtimeCheck.runtime,
|
|
588
|
+
workdir: resolvedWorkdir,
|
|
589
|
+
});
|
|
590
|
+
(0, web_terminal_js_1.writeWebTerminalRecord)(record);
|
|
591
|
+
onProgress?.('session_start', `Starting ${agent} terminal session...`);
|
|
592
|
+
try {
|
|
593
|
+
await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint);
|
|
594
|
+
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
const message = err?.message ?? String(err);
|
|
598
|
+
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
|
|
599
|
+
return {
|
|
600
|
+
ok: false,
|
|
601
|
+
status: 500,
|
|
602
|
+
body: { ok: false, error: `Could not start tmux session: ${message}` },
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
onProgress?.('session_start', 'Attaching terminal bridge...');
|
|
606
|
+
const bridge = await ensureWebTerminalBridge(record);
|
|
607
|
+
if (!bridge) {
|
|
608
|
+
try {
|
|
609
|
+
await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
|
|
610
|
+
}
|
|
611
|
+
catch { /* best effort */ }
|
|
612
|
+
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, {
|
|
613
|
+
status: 'failed',
|
|
614
|
+
exitCode: 1,
|
|
615
|
+
error: 'node-pty bridge unavailable',
|
|
616
|
+
});
|
|
617
|
+
return {
|
|
618
|
+
ok: false,
|
|
619
|
+
status: 500,
|
|
620
|
+
body: {
|
|
621
|
+
ok: false,
|
|
622
|
+
error: 'Started tmux session but could not create terminal bridge (node-pty unavailable).',
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
await waitForWebTerminalStartupSummary(record, bridge, onProgress);
|
|
627
|
+
return {
|
|
628
|
+
ok: true,
|
|
629
|
+
session: serializeWebTerminalSession(record),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
139
632
|
function readBody(req) {
|
|
140
633
|
return new Promise((resolve, reject) => {
|
|
141
634
|
const chunks = [];
|
|
@@ -419,7 +912,9 @@ function normalizeWebTerminalAgent(raw) {
|
|
|
419
912
|
function serializeWebTerminalSession(record) {
|
|
420
913
|
return {
|
|
421
914
|
id: record.id,
|
|
915
|
+
name: record.name || '',
|
|
422
916
|
agent: record.agent,
|
|
917
|
+
runtime: record.runtime || '',
|
|
423
918
|
workdir: record.workdir,
|
|
424
919
|
node: record.node,
|
|
425
920
|
tmuxSession: record.tmuxSession,
|
|
@@ -440,11 +935,62 @@ async function loadNodePtyModule() {
|
|
|
440
935
|
}
|
|
441
936
|
}
|
|
442
937
|
function appendWebTerminalBuffer(bridge, chunk) {
|
|
938
|
+
if (!chunk)
|
|
939
|
+
return [];
|
|
443
940
|
bridge.buffer += chunk;
|
|
444
941
|
// Keep recent output bounded to avoid unbounded memory growth.
|
|
445
|
-
if (bridge.buffer.length >
|
|
446
|
-
bridge.buffer = bridge.buffer.slice(bridge.buffer.length -
|
|
942
|
+
if (bridge.buffer.length > WEB_TERMINAL_BUFFER_MAX_BYTES) {
|
|
943
|
+
bridge.buffer = bridge.buffer.slice(bridge.buffer.length - WEB_TERMINAL_BUFFER_MAX_BYTES);
|
|
944
|
+
}
|
|
945
|
+
const appended = [];
|
|
946
|
+
for (let i = 0; i < chunk.length; i += WEB_TERMINAL_HISTORY_CHUNK_BYTES) {
|
|
947
|
+
const piece = chunk.slice(i, i + WEB_TERMINAL_HISTORY_CHUNK_BYTES);
|
|
948
|
+
if (!piece)
|
|
949
|
+
continue;
|
|
950
|
+
const seq = bridge.nextSeq++;
|
|
951
|
+
const nextChunk = { seq, data: piece };
|
|
952
|
+
bridge.history.push(nextChunk);
|
|
953
|
+
appended.push(nextChunk);
|
|
954
|
+
bridge.historyBytes += piece.length;
|
|
955
|
+
}
|
|
956
|
+
while (bridge.historyBytes > WEB_TERMINAL_HISTORY_MAX_BYTES && bridge.history.length > 0) {
|
|
957
|
+
const removed = bridge.history.shift();
|
|
958
|
+
if (!removed)
|
|
959
|
+
break;
|
|
960
|
+
bridge.historyBytes = Math.max(0, bridge.historyBytes - removed.data.length);
|
|
447
961
|
}
|
|
962
|
+
return appended;
|
|
963
|
+
}
|
|
964
|
+
function getWebTerminalHistoryPage(bridge, options) {
|
|
965
|
+
const history = bridge.history;
|
|
966
|
+
if (!history.length) {
|
|
967
|
+
return {
|
|
968
|
+
chunks: [],
|
|
969
|
+
hasMore: false,
|
|
970
|
+
nextBefore: null,
|
|
971
|
+
oldestSeq: null,
|
|
972
|
+
latestSeq: null,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
const beforeSeq = options?.beforeSeq ?? null;
|
|
976
|
+
const rawLimit = options?.limit ?? WEB_TERMINAL_HISTORY_PAGE_DEFAULT;
|
|
977
|
+
const limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(rawLimit)));
|
|
978
|
+
let endIndex = history.length;
|
|
979
|
+
if (beforeSeq !== null && Number.isFinite(beforeSeq)) {
|
|
980
|
+
const idx = history.findIndex((chunk) => chunk.seq >= beforeSeq);
|
|
981
|
+
endIndex = idx >= 0 ? idx : history.length;
|
|
982
|
+
}
|
|
983
|
+
endIndex = Math.max(0, Math.min(endIndex, history.length));
|
|
984
|
+
const startIndex = Math.max(0, endIndex - limit);
|
|
985
|
+
const chunks = history.slice(startIndex, endIndex);
|
|
986
|
+
const hasMore = startIndex > 0;
|
|
987
|
+
return {
|
|
988
|
+
chunks,
|
|
989
|
+
hasMore,
|
|
990
|
+
nextBefore: hasMore && chunks.length > 0 ? chunks[0].seq : null,
|
|
991
|
+
oldestSeq: history[0]?.seq ?? null,
|
|
992
|
+
latestSeq: history[history.length - 1]?.seq ?? null,
|
|
993
|
+
};
|
|
448
994
|
}
|
|
449
995
|
function sendWebTerminalMessage(ws, payload) {
|
|
450
996
|
if (ws.readyState !== ws_1.WebSocket.OPEN)
|
|
@@ -456,6 +1002,140 @@ function sendWebTerminalMessage(ws, payload) {
|
|
|
456
1002
|
// Best effort.
|
|
457
1003
|
}
|
|
458
1004
|
}
|
|
1005
|
+
function parseJsonObjectLine(line) {
|
|
1006
|
+
try {
|
|
1007
|
+
const parsed = JSON.parse(line);
|
|
1008
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
1009
|
+
return null;
|
|
1010
|
+
return parsed;
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function readRecordString(record, key) {
|
|
1017
|
+
const value = record[key];
|
|
1018
|
+
return typeof value === 'string' ? value : '';
|
|
1019
|
+
}
|
|
1020
|
+
function collectClaudeTextFromContent(content) {
|
|
1021
|
+
if (!Array.isArray(content))
|
|
1022
|
+
return '';
|
|
1023
|
+
let text = '';
|
|
1024
|
+
for (const part of content) {
|
|
1025
|
+
if (!part || typeof part !== 'object' || Array.isArray(part))
|
|
1026
|
+
continue;
|
|
1027
|
+
const node = part;
|
|
1028
|
+
if (node.type === 'text' && typeof node.text === 'string') {
|
|
1029
|
+
text += node.text;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return text;
|
|
1033
|
+
}
|
|
1034
|
+
function extractClaudeStreamSessionId(event) {
|
|
1035
|
+
const direct = readRecordString(event, 'session_id').trim();
|
|
1036
|
+
if (direct)
|
|
1037
|
+
return direct;
|
|
1038
|
+
const message = event.message;
|
|
1039
|
+
if (!message || typeof message !== 'object' || Array.isArray(message))
|
|
1040
|
+
return '';
|
|
1041
|
+
const nested = readRecordString(message, 'session_id').trim();
|
|
1042
|
+
return nested || '';
|
|
1043
|
+
}
|
|
1044
|
+
function extractClaudeAssistantSnapshot(event) {
|
|
1045
|
+
const type = readRecordString(event, 'type').trim().toLowerCase();
|
|
1046
|
+
if (type === 'assistant') {
|
|
1047
|
+
const message = event.message;
|
|
1048
|
+
if (message && typeof message === 'object' && !Array.isArray(message)) {
|
|
1049
|
+
const content = message.content;
|
|
1050
|
+
const contentText = collectClaudeTextFromContent(content);
|
|
1051
|
+
if (contentText)
|
|
1052
|
+
return contentText;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (type === 'result') {
|
|
1056
|
+
const resultText = readRecordString(event, 'result');
|
|
1057
|
+
if (resultText)
|
|
1058
|
+
return resultText;
|
|
1059
|
+
}
|
|
1060
|
+
return '';
|
|
1061
|
+
}
|
|
1062
|
+
function isClaudeAuthenticationFailure(event, assistantSnapshot, stderrText) {
|
|
1063
|
+
const packed = JSON.stringify(event).toLowerCase();
|
|
1064
|
+
const message = `${assistantSnapshot}\n${stderrText}`.toLowerCase();
|
|
1065
|
+
const authRe = /oauth token has expired|authentication_error|failed to authenticate|api error:\s*401/i;
|
|
1066
|
+
return authRe.test(packed) || authRe.test(message);
|
|
1067
|
+
}
|
|
1068
|
+
function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSessionId) {
|
|
1069
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
1070
|
+
const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
|
|
1071
|
+
const resume = resumeSessionId.trim();
|
|
1072
|
+
return [
|
|
1073
|
+
'exec',
|
|
1074
|
+
'--containall',
|
|
1075
|
+
'--cleanenv',
|
|
1076
|
+
'--home', `${sandboxHome}:/home/sandbox`,
|
|
1077
|
+
'--bind', `${workdir}:/work`,
|
|
1078
|
+
'--pwd', '/work',
|
|
1079
|
+
...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
|
|
1080
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
1081
|
+
const target = `/mnt/${(0, path_1.basename)(resolved)}`;
|
|
1082
|
+
const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
|
|
1083
|
+
return ['--bind', bindSpec];
|
|
1084
|
+
}),
|
|
1085
|
+
...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
|
|
1086
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
1087
|
+
const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
|
|
1088
|
+
return ['--bind', bindSpec];
|
|
1089
|
+
}),
|
|
1090
|
+
...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
|
|
1091
|
+
'--env', 'HOME=/home/sandbox',
|
|
1092
|
+
'--env', 'ANTHROPIC_API_KEY=',
|
|
1093
|
+
sifPath,
|
|
1094
|
+
'/home/sandbox/.npm-global/bin/claude',
|
|
1095
|
+
'-p',
|
|
1096
|
+
'--verbose',
|
|
1097
|
+
'--output-format',
|
|
1098
|
+
'stream-json',
|
|
1099
|
+
'--include-partial-messages',
|
|
1100
|
+
...(resume ? ['--resume', resume] : []),
|
|
1101
|
+
prompt,
|
|
1102
|
+
];
|
|
1103
|
+
}
|
|
1104
|
+
function sleep(ms) {
|
|
1105
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1106
|
+
}
|
|
1107
|
+
function stripAnsiForStartupReadiness(text) {
|
|
1108
|
+
return String(text || '')
|
|
1109
|
+
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
|
1110
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
1111
|
+
.replace(/\r/g, '\n');
|
|
1112
|
+
}
|
|
1113
|
+
function hasWebTerminalStartupSummary(buffer) {
|
|
1114
|
+
const plain = stripAnsiForStartupReadiness(buffer);
|
|
1115
|
+
return WEB_TERMINAL_STARTUP_HEADER_RE.test(plain) && WEB_TERMINAL_STARTUP_BLOCKED_RE.test(plain);
|
|
1116
|
+
}
|
|
1117
|
+
async function waitForWebTerminalStartupSummary(record, bridge, onProgress) {
|
|
1118
|
+
const deadline = Date.now() + WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS;
|
|
1119
|
+
let lastAliveCheck = 0;
|
|
1120
|
+
onProgress?.('session_start', `Finalizing ${record.agent} startup...`);
|
|
1121
|
+
while (Date.now() < deadline) {
|
|
1122
|
+
const recent = bridge.buffer.length > WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT
|
|
1123
|
+
? bridge.buffer.slice(-WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT)
|
|
1124
|
+
: bridge.buffer;
|
|
1125
|
+
if (hasWebTerminalStartupSummary(recent))
|
|
1126
|
+
return;
|
|
1127
|
+
if (!bridge.pty)
|
|
1128
|
+
return;
|
|
1129
|
+
const now = Date.now();
|
|
1130
|
+
if (now - lastAliveCheck >= WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS) {
|
|
1131
|
+
lastAliveCheck = now;
|
|
1132
|
+
const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
|
|
1133
|
+
if (!alive)
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
await sleep(WEB_TERMINAL_STARTUP_READY_POLL_MS);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
459
1139
|
function broadcastWebTerminalMessage(bridge, payload) {
|
|
460
1140
|
for (const ws of bridge.clients) {
|
|
461
1141
|
if (ws.readyState !== ws_1.WebSocket.OPEN) {
|
|
@@ -465,6 +1145,23 @@ function broadcastWebTerminalMessage(bridge, payload) {
|
|
|
465
1145
|
sendWebTerminalMessage(ws, payload);
|
|
466
1146
|
}
|
|
467
1147
|
}
|
|
1148
|
+
function closeWebTerminalBridgeClients(bridge, code = 4001, reason = 'labgate-bridge-detached') {
|
|
1149
|
+
const clients = Array.from(bridge.clients);
|
|
1150
|
+
bridge.clients.clear();
|
|
1151
|
+
for (const ws of clients) {
|
|
1152
|
+
try {
|
|
1153
|
+
ws.close(code, reason);
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
try {
|
|
1157
|
+
ws.close();
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
// Best effort.
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
468
1165
|
async function ensureWebTerminalBridge(record) {
|
|
469
1166
|
const existing = webTerminalBridges.get(record.id);
|
|
470
1167
|
if (existing && existing.pty)
|
|
@@ -481,6 +1178,13 @@ async function ensureWebTerminalBridge(record) {
|
|
|
481
1178
|
log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
|
|
482
1179
|
return null;
|
|
483
1180
|
}
|
|
1181
|
+
try {
|
|
1182
|
+
// Keep wheel scrolling intuitive for both new and existing sessions.
|
|
1183
|
+
await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, 'mouse', 'on'], { timeout: 10_000 });
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
// Best effort only; attach should still proceed.
|
|
1187
|
+
}
|
|
484
1188
|
const env = {};
|
|
485
1189
|
for (const [k, v] of Object.entries(process.env)) {
|
|
486
1190
|
if (v !== undefined)
|
|
@@ -513,40 +1217,65 @@ async function ensureWebTerminalBridge(record) {
|
|
|
513
1217
|
const bridge = existing || {
|
|
514
1218
|
id: record.id,
|
|
515
1219
|
buffer: '',
|
|
1220
|
+
history: [],
|
|
1221
|
+
historyBytes: 0,
|
|
1222
|
+
nextSeq: 1,
|
|
1223
|
+
stopRequested: false,
|
|
516
1224
|
clients: new Set(),
|
|
517
1225
|
pty: ptyProcess,
|
|
518
1226
|
};
|
|
1227
|
+
bridge.stopRequested = false;
|
|
519
1228
|
bridge.pty = ptyProcess;
|
|
520
1229
|
webTerminalBridges.set(record.id, bridge);
|
|
521
1230
|
ptyProcess.onData((data) => {
|
|
522
|
-
appendWebTerminalBuffer(bridge, data);
|
|
523
|
-
|
|
1231
|
+
const appended = appendWebTerminalBuffer(bridge, data);
|
|
1232
|
+
for (const chunk of appended) {
|
|
1233
|
+
broadcastWebTerminalMessage(bridge, {
|
|
1234
|
+
type: 'data',
|
|
1235
|
+
id: record.id,
|
|
1236
|
+
data: chunk.data,
|
|
1237
|
+
seqStart: chunk.seq,
|
|
1238
|
+
seqEnd: chunk.seq,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
524
1241
|
});
|
|
525
1242
|
ptyProcess.onExit(async () => {
|
|
1243
|
+
const stopRequested = bridge.stopRequested;
|
|
1244
|
+
bridge.stopRequested = false;
|
|
526
1245
|
bridge.pty = null;
|
|
527
1246
|
const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
type: 'status',
|
|
539
|
-
id: record.id,
|
|
540
|
-
status: updated?.status ?? finalStatus,
|
|
541
|
-
exitCode: updated?.exitCode ?? finalCode,
|
|
542
|
-
});
|
|
1247
|
+
if (alive) {
|
|
1248
|
+
if (stopRequested)
|
|
1249
|
+
return;
|
|
1250
|
+
// Another tmux client (for example `labgate continue`) may have force-detached this bridge.
|
|
1251
|
+
// Clear stale alternate-screen data and require clients to reconnect for a clean reattach.
|
|
1252
|
+
bridge.buffer = '';
|
|
1253
|
+
bridge.history = [];
|
|
1254
|
+
bridge.historyBytes = 0;
|
|
1255
|
+
closeWebTerminalBridgeClients(bridge);
|
|
1256
|
+
return;
|
|
543
1257
|
}
|
|
1258
|
+
const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
|
|
1259
|
+
const finalCode = exitInfo?.exitCode ?? (record.exitCode ?? 0);
|
|
1260
|
+
const finalStatus = finalCode === 0 ? 'exited' : 'failed';
|
|
1261
|
+
const updated = (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, {
|
|
1262
|
+
status: finalStatus,
|
|
1263
|
+
exitCode: finalCode,
|
|
1264
|
+
error: finalCode === 0 ? null : (record.error || `Exited with code ${finalCode}`),
|
|
1265
|
+
});
|
|
1266
|
+
broadcastWebTerminalMessage(bridge, {
|
|
1267
|
+
type: 'status',
|
|
1268
|
+
id: record.id,
|
|
1269
|
+
status: updated?.status ?? finalStatus,
|
|
1270
|
+
exitCode: updated?.exitCode ?? finalCode,
|
|
1271
|
+
});
|
|
544
1272
|
});
|
|
545
1273
|
return bridge;
|
|
546
1274
|
}
|
|
547
1275
|
function stopWebTerminalBridge(bridge) {
|
|
548
1276
|
if (!bridge.pty)
|
|
549
1277
|
return;
|
|
1278
|
+
bridge.stopRequested = true;
|
|
550
1279
|
try {
|
|
551
1280
|
bridge.pty.kill('SIGTERM');
|
|
552
1281
|
}
|
|
@@ -554,6 +1283,192 @@ function stopWebTerminalBridge(bridge) {
|
|
|
554
1283
|
// Best effort.
|
|
555
1284
|
}
|
|
556
1285
|
}
|
|
1286
|
+
async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
|
|
1287
|
+
const send = (payload) => {
|
|
1288
|
+
sendWebTerminalMessage(ws, payload);
|
|
1289
|
+
};
|
|
1290
|
+
const trimmedPrompt = prompt.trim();
|
|
1291
|
+
if (!trimmedPrompt) {
|
|
1292
|
+
send({ type: 'error', error: 'prompt is required' });
|
|
1293
|
+
return () => { };
|
|
1294
|
+
}
|
|
1295
|
+
send({ type: 'status', stage: 'runtime_setup', message: 'Checking container runtime...' });
|
|
1296
|
+
const config = (0, config_js_1.loadConfig)();
|
|
1297
|
+
const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
|
|
1298
|
+
if (!runtimeReady.ok) {
|
|
1299
|
+
send({
|
|
1300
|
+
type: 'error',
|
|
1301
|
+
code: 'runtime_unavailable',
|
|
1302
|
+
error: runtimeReady.error || 'Container runtime unavailable.',
|
|
1303
|
+
});
|
|
1304
|
+
return () => { };
|
|
1305
|
+
}
|
|
1306
|
+
const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
|
|
1307
|
+
if (!runtimeCheck.ok || !runtimeCheck.runtime) {
|
|
1308
|
+
send({
|
|
1309
|
+
type: 'error',
|
|
1310
|
+
code: 'runtime_unavailable',
|
|
1311
|
+
error: runtimeCheck.error || 'Container runtime unavailable.',
|
|
1312
|
+
});
|
|
1313
|
+
return () => { };
|
|
1314
|
+
}
|
|
1315
|
+
if (runtimeCheck.runtime !== 'apptainer') {
|
|
1316
|
+
send({
|
|
1317
|
+
type: 'error',
|
|
1318
|
+
code: 'runtime_unsupported',
|
|
1319
|
+
error: `Headless Claude chat currently supports Apptainer only (detected: ${runtimeCheck.runtime}).`,
|
|
1320
|
+
});
|
|
1321
|
+
return () => { };
|
|
1322
|
+
}
|
|
1323
|
+
try {
|
|
1324
|
+
await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, (stage, message) => {
|
|
1325
|
+
send({ type: 'status', stage, message });
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
catch (err) {
|
|
1329
|
+
send({
|
|
1330
|
+
type: 'error',
|
|
1331
|
+
code: 'image_prepare_failed',
|
|
1332
|
+
error: commandErrorDetail(err) || `Failed to prepare image ${config.image}.`,
|
|
1333
|
+
});
|
|
1334
|
+
return () => { };
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, 'claude', record.workdir, config.network.mode, (stage, message) => send({ type: 'status', stage, message }));
|
|
1338
|
+
}
|
|
1339
|
+
catch (err) {
|
|
1340
|
+
send({
|
|
1341
|
+
type: 'error',
|
|
1342
|
+
code: 'agent_prepare_failed',
|
|
1343
|
+
error: commandErrorDetail(err) || 'Failed to prepare Claude CLI in sandbox home.',
|
|
1344
|
+
});
|
|
1345
|
+
return () => { };
|
|
1346
|
+
}
|
|
1347
|
+
const args = buildClaudeHeadlessApptainerArgs(config, record.workdir, trimmedPrompt, resumeSessionId);
|
|
1348
|
+
send({ type: 'status', stage: 'run', message: 'Running Claude in headless mode...' });
|
|
1349
|
+
const child = (0, child_process_1.spawn)('apptainer', args, {
|
|
1350
|
+
cwd: record.workdir,
|
|
1351
|
+
env: process.env,
|
|
1352
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1353
|
+
});
|
|
1354
|
+
let stdoutBuffer = '';
|
|
1355
|
+
let stderrBuffer = '';
|
|
1356
|
+
let latestClaudeSessionId = resumeSessionId.trim();
|
|
1357
|
+
let emittedAssistantText = '';
|
|
1358
|
+
let doneSent = false;
|
|
1359
|
+
const sendDone = (exitCode) => {
|
|
1360
|
+
if (doneSent)
|
|
1361
|
+
return;
|
|
1362
|
+
doneSent = true;
|
|
1363
|
+
send({
|
|
1364
|
+
type: 'done',
|
|
1365
|
+
exitCode,
|
|
1366
|
+
sessionId: latestClaudeSessionId || null,
|
|
1367
|
+
isError: exitCode !== 0,
|
|
1368
|
+
});
|
|
1369
|
+
try {
|
|
1370
|
+
ws.close();
|
|
1371
|
+
}
|
|
1372
|
+
catch {
|
|
1373
|
+
// Best effort.
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
child.stdout.on('data', (chunk) => {
|
|
1377
|
+
stdoutBuffer += chunk.toString('utf-8');
|
|
1378
|
+
while (true) {
|
|
1379
|
+
const idx = stdoutBuffer.indexOf('\n');
|
|
1380
|
+
if (idx < 0)
|
|
1381
|
+
break;
|
|
1382
|
+
const line = stdoutBuffer.slice(0, idx).trim();
|
|
1383
|
+
stdoutBuffer = stdoutBuffer.slice(idx + 1);
|
|
1384
|
+
if (!line)
|
|
1385
|
+
continue;
|
|
1386
|
+
const event = parseJsonObjectLine(line);
|
|
1387
|
+
if (!event)
|
|
1388
|
+
continue;
|
|
1389
|
+
const sessionId = extractClaudeStreamSessionId(event);
|
|
1390
|
+
if (sessionId && sessionId !== latestClaudeSessionId) {
|
|
1391
|
+
latestClaudeSessionId = sessionId;
|
|
1392
|
+
send({ type: 'session', sessionId });
|
|
1393
|
+
}
|
|
1394
|
+
const snapshot = extractClaudeAssistantSnapshot(event);
|
|
1395
|
+
if (snapshot) {
|
|
1396
|
+
let delta = '';
|
|
1397
|
+
if (snapshot.startsWith(emittedAssistantText)) {
|
|
1398
|
+
delta = snapshot.slice(emittedAssistantText.length);
|
|
1399
|
+
emittedAssistantText = snapshot;
|
|
1400
|
+
}
|
|
1401
|
+
else if (!emittedAssistantText.startsWith(snapshot)) {
|
|
1402
|
+
delta = snapshot;
|
|
1403
|
+
emittedAssistantText = snapshot;
|
|
1404
|
+
}
|
|
1405
|
+
if (delta) {
|
|
1406
|
+
send({ type: 'delta', text: delta });
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
|
|
1410
|
+
send({
|
|
1411
|
+
type: 'auth_required',
|
|
1412
|
+
error: 'Claude authentication is required. Run /login in raw terminal mode to refresh session.',
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
child.stderr.on('data', (chunk) => {
|
|
1418
|
+
const text = chunk.toString('utf-8');
|
|
1419
|
+
stderrBuffer = (stderrBuffer + text).slice(-CLAUDE_HEADLESS_STDERR_LIMIT);
|
|
1420
|
+
});
|
|
1421
|
+
child.on('error', (err) => {
|
|
1422
|
+
send({
|
|
1423
|
+
type: 'error',
|
|
1424
|
+
code: 'spawn_failed',
|
|
1425
|
+
error: err.message || String(err),
|
|
1426
|
+
});
|
|
1427
|
+
sendDone(1);
|
|
1428
|
+
});
|
|
1429
|
+
child.on('close', (code) => {
|
|
1430
|
+
const remaining = stdoutBuffer.trim();
|
|
1431
|
+
if (remaining) {
|
|
1432
|
+
const event = parseJsonObjectLine(remaining);
|
|
1433
|
+
if (event) {
|
|
1434
|
+
const sessionId = extractClaudeStreamSessionId(event);
|
|
1435
|
+
if (sessionId && sessionId !== latestClaudeSessionId) {
|
|
1436
|
+
latestClaudeSessionId = sessionId;
|
|
1437
|
+
send({ type: 'session', sessionId });
|
|
1438
|
+
}
|
|
1439
|
+
const snapshot = extractClaudeAssistantSnapshot(event);
|
|
1440
|
+
if (snapshot) {
|
|
1441
|
+
if (snapshot.startsWith(emittedAssistantText)) {
|
|
1442
|
+
const delta = snapshot.slice(emittedAssistantText.length);
|
|
1443
|
+
emittedAssistantText = snapshot;
|
|
1444
|
+
if (delta)
|
|
1445
|
+
send({ type: 'delta', text: delta });
|
|
1446
|
+
}
|
|
1447
|
+
else if (!emittedAssistantText.startsWith(snapshot)) {
|
|
1448
|
+
emittedAssistantText = snapshot;
|
|
1449
|
+
send({ type: 'delta', text: snapshot });
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
const exitCode = typeof code === 'number' ? code : 1;
|
|
1455
|
+
if (exitCode !== 0) {
|
|
1456
|
+
const detail = stderrBuffer.trim();
|
|
1457
|
+
if (detail) {
|
|
1458
|
+
send({ type: 'error', code: 'claude_failed', error: detail });
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
sendDone(exitCode);
|
|
1462
|
+
});
|
|
1463
|
+
return () => {
|
|
1464
|
+
try {
|
|
1465
|
+
child.kill('SIGTERM');
|
|
1466
|
+
}
|
|
1467
|
+
catch {
|
|
1468
|
+
// Best effort.
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
557
1472
|
function serveHTML(res) {
|
|
558
1473
|
try {
|
|
559
1474
|
const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8').replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN);
|
|
@@ -1852,38 +2767,140 @@ async function handleRestartSession(req, res) {
|
|
|
1852
2767
|
return;
|
|
1853
2768
|
}
|
|
1854
2769
|
try {
|
|
1855
|
-
process.kill(data.pid, 'SIGTERM');
|
|
2770
|
+
process.kill(data.pid, 'SIGTERM');
|
|
2771
|
+
}
|
|
2772
|
+
catch {
|
|
2773
|
+
// Process is already gone. Continue with cleanup + relaunch.
|
|
2774
|
+
}
|
|
2775
|
+
await waitForSessionFileRemoval(sessionFile);
|
|
2776
|
+
if ((0, fs_1.existsSync)(sessionFile)) {
|
|
2777
|
+
let stillRunning = false;
|
|
2778
|
+
try {
|
|
2779
|
+
process.kill(data.pid, 0);
|
|
2780
|
+
stillRunning = true;
|
|
2781
|
+
}
|
|
2782
|
+
catch {
|
|
2783
|
+
// Process is gone.
|
|
2784
|
+
}
|
|
2785
|
+
if (stillRunning) {
|
|
2786
|
+
json(res, { ok: false, error: 'Session did not stop in time. Please stop it first and retry.' }, 409);
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
try {
|
|
2790
|
+
(0, fs_1.unlinkSync)(sessionFile);
|
|
2791
|
+
}
|
|
2792
|
+
catch { /* best effort */ }
|
|
2793
|
+
}
|
|
2794
|
+
relaunchSessionDetached(agent, workdir);
|
|
2795
|
+
json(res, { ok: true, restarted: { id, agent, workdir } });
|
|
2796
|
+
}
|
|
2797
|
+
catch (err) {
|
|
2798
|
+
json(res, { ok: false, error: err.message ?? String(err) }, 500);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
async function handlePostWebTerminalStart(req, res) {
|
|
2802
|
+
try {
|
|
2803
|
+
const body = await readBody(req);
|
|
2804
|
+
const parsed = JSON.parse(body || '{}');
|
|
2805
|
+
const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
|
|
2806
|
+
const rawWorkdir = String(parsed.workdir || '').trim();
|
|
2807
|
+
if (!agent) {
|
|
2808
|
+
json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
if (!rawWorkdir) {
|
|
2812
|
+
json(res, { ok: false, error: 'workdir is required' }, 400);
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
const resolvedWorkdir = (0, path_1.resolve)(rawWorkdir.replace(/^~/, (0, os_1.homedir)()));
|
|
2816
|
+
if (!(0, fs_1.existsSync)(resolvedWorkdir)) {
|
|
2817
|
+
json(res, { ok: false, error: `workdir does not exist: ${resolvedWorkdir}` }, 400);
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
let st;
|
|
2821
|
+
try {
|
|
2822
|
+
st = (0, fs_1.statSync)(resolvedWorkdir);
|
|
1856
2823
|
}
|
|
1857
2824
|
catch {
|
|
1858
|
-
|
|
2825
|
+
json(res, { ok: false, error: `Could not access workdir: ${resolvedWorkdir}` }, 400);
|
|
2826
|
+
return;
|
|
1859
2827
|
}
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
try {
|
|
1864
|
-
process.kill(data.pid, 0);
|
|
1865
|
-
stillRunning = true;
|
|
1866
|
-
}
|
|
1867
|
-
catch {
|
|
1868
|
-
// Process is gone.
|
|
1869
|
-
}
|
|
1870
|
-
if (stillRunning) {
|
|
1871
|
-
json(res, { ok: false, error: 'Session did not stop in time. Please stop it first and retry.' }, 409);
|
|
1872
|
-
return;
|
|
1873
|
-
}
|
|
1874
|
-
try {
|
|
1875
|
-
(0, fs_1.unlinkSync)(sessionFile);
|
|
1876
|
-
}
|
|
1877
|
-
catch { /* best effort */ }
|
|
2828
|
+
if (!st.isDirectory()) {
|
|
2829
|
+
json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
|
|
2830
|
+
return;
|
|
1878
2831
|
}
|
|
1879
|
-
|
|
1880
|
-
|
|
2832
|
+
const result = await startWebTerminalSession(agent, resolvedWorkdir);
|
|
2833
|
+
if (!result.ok) {
|
|
2834
|
+
json(res, result.body, result.status);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
json(res, { ok: true, session: result.session });
|
|
1881
2838
|
}
|
|
1882
2839
|
catch (err) {
|
|
1883
|
-
json(res, { ok: false, error: err
|
|
2840
|
+
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
1884
2841
|
}
|
|
1885
2842
|
}
|
|
1886
|
-
async function
|
|
2843
|
+
async function runWebTerminalInitJob(id) {
|
|
2844
|
+
const job = webTerminalInitJobs.get(id);
|
|
2845
|
+
if (!job)
|
|
2846
|
+
return;
|
|
2847
|
+
const progress = (stage, message) => {
|
|
2848
|
+
updateWebTerminalInitJob(id, {
|
|
2849
|
+
status: 'running',
|
|
2850
|
+
stage,
|
|
2851
|
+
message,
|
|
2852
|
+
});
|
|
2853
|
+
};
|
|
2854
|
+
try {
|
|
2855
|
+
const result = await startWebTerminalSession(job.agent, job.workdir, {
|
|
2856
|
+
prewarmImage: true,
|
|
2857
|
+
prewarmAgent: true,
|
|
2858
|
+
onProgress: progress,
|
|
2859
|
+
});
|
|
2860
|
+
if (result.ok) {
|
|
2861
|
+
updateWebTerminalInitJob(id, {
|
|
2862
|
+
status: 'ready',
|
|
2863
|
+
stage: 'ready',
|
|
2864
|
+
message: 'Session ready.',
|
|
2865
|
+
session: result.session,
|
|
2866
|
+
error: null,
|
|
2867
|
+
code: null,
|
|
2868
|
+
phase: null,
|
|
2869
|
+
});
|
|
2870
|
+
pruneWebTerminalInitJobs();
|
|
2871
|
+
return;
|
|
2872
|
+
}
|
|
2873
|
+
const body = result.body || {};
|
|
2874
|
+
const errText = String(body.error || 'Session initialization failed.');
|
|
2875
|
+
updateWebTerminalInitJob(id, {
|
|
2876
|
+
status: 'failed',
|
|
2877
|
+
stage: 'failed',
|
|
2878
|
+
message: errText.split('\n')[0] || 'Session initialization failed.',
|
|
2879
|
+
session: null,
|
|
2880
|
+
error: errText,
|
|
2881
|
+
code: typeof body.code === 'string' ? body.code : null,
|
|
2882
|
+
phase: typeof body.phase === 'string' ? body.phase : null,
|
|
2883
|
+
initialized: body.initialized === true,
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
catch (err) {
|
|
2887
|
+
const detail = commandErrorDetail(err) || (err?.message ?? String(err));
|
|
2888
|
+
updateWebTerminalInitJob(id, {
|
|
2889
|
+
status: 'failed',
|
|
2890
|
+
stage: 'failed',
|
|
2891
|
+
message: 'Session initialization failed.',
|
|
2892
|
+
session: null,
|
|
2893
|
+
error: detail,
|
|
2894
|
+
code: 'init_failed',
|
|
2895
|
+
phase: 'init',
|
|
2896
|
+
initialized: false,
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
finally {
|
|
2900
|
+
pruneWebTerminalInitJobs();
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
async function handlePostWebTerminalInit(req, res) {
|
|
1887
2904
|
try {
|
|
1888
2905
|
const body = await readBody(req);
|
|
1889
2906
|
const parsed = JSON.parse(body || '{}');
|
|
@@ -1914,55 +2931,107 @@ async function handlePostWebTerminalStart(req, res) {
|
|
|
1914
2931
|
json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
|
|
1915
2932
|
return;
|
|
1916
2933
|
}
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2934
|
+
pruneWebTerminalInitJobs();
|
|
2935
|
+
const job = createWebTerminalInitJob(agent, resolvedWorkdir);
|
|
2936
|
+
webTerminalInitJobs.set(job.id, job);
|
|
2937
|
+
void runWebTerminalInitJob(job.id);
|
|
2938
|
+
json(res, { ok: true, init: serializeWebTerminalInitJob(job) }, 202);
|
|
2939
|
+
}
|
|
2940
|
+
catch (err) {
|
|
2941
|
+
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
async function handlePostWebTerminalAgentUpdate(req, res) {
|
|
2945
|
+
try {
|
|
2946
|
+
const body = await readBody(req);
|
|
2947
|
+
const parsed = JSON.parse(body || '{}');
|
|
2948
|
+
const agent = normalizeWebTerminalAgent(parsed.agent || '');
|
|
2949
|
+
if (!agent) {
|
|
2950
|
+
json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
const config = (0, config_js_1.loadConfig)();
|
|
2954
|
+
const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
|
|
2955
|
+
if (!runtimeReady.ok) {
|
|
2956
|
+
json(res, {
|
|
2957
|
+
ok: false,
|
|
2958
|
+
code: 'runtime_unavailable',
|
|
2959
|
+
phase: 'runtime_setup',
|
|
2960
|
+
initialized: runtimeReady.initialized,
|
|
2961
|
+
error: runtimeReady.error || 'Container runtime unavailable.',
|
|
2962
|
+
}, runtimeReady.initialized ? 502 : 503);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
|
|
2966
|
+
if (!runtimeCheck.ok || !runtimeCheck.runtime) {
|
|
2967
|
+
json(res, {
|
|
2968
|
+
ok: false,
|
|
2969
|
+
code: 'runtime_unavailable',
|
|
2970
|
+
phase: 'runtime_setup',
|
|
2971
|
+
initialized: runtimeReady.initialized,
|
|
2972
|
+
error: runtimeCheck.error || 'Container runtime unavailable.',
|
|
2973
|
+
}, runtimeReady.initialized ? 502 : 503);
|
|
1920
2974
|
return;
|
|
1921
2975
|
}
|
|
1922
|
-
const cliEntrypoint = resolveCliEntrypoint();
|
|
1923
|
-
const id = `wt-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
|
|
1924
|
-
const record = (0, web_terminal_js_1.createWebTerminalRecord)({
|
|
1925
|
-
id,
|
|
1926
|
-
agent,
|
|
1927
|
-
workdir: resolvedWorkdir,
|
|
1928
|
-
});
|
|
1929
|
-
(0, web_terminal_js_1.writeWebTerminalRecord)(record);
|
|
1930
2976
|
try {
|
|
1931
|
-
await (
|
|
1932
|
-
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
|
|
2977
|
+
await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image);
|
|
1933
2978
|
}
|
|
1934
2979
|
catch (err) {
|
|
1935
|
-
const
|
|
1936
|
-
(
|
|
1937
|
-
|
|
2980
|
+
const detail = commandErrorDetail(err);
|
|
2981
|
+
json(res, {
|
|
2982
|
+
ok: false,
|
|
2983
|
+
code: 'image_prepare_failed',
|
|
2984
|
+
phase: 'image_prepare',
|
|
2985
|
+
runtime: runtimeCheck.runtime,
|
|
2986
|
+
image: config.image,
|
|
2987
|
+
error: detail || `Failed to pull image ${config.image}.`,
|
|
2988
|
+
}, 502);
|
|
1938
2989
|
return;
|
|
1939
2990
|
}
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2991
|
+
try {
|
|
2992
|
+
const updated = await updateWebTerminalAgentCli(runtimeCheck.runtime, config.image, agent, (0, os_1.homedir)(), config.network.mode);
|
|
2993
|
+
const spec = getWebTerminalAgentBootstrapSpec(agent);
|
|
2994
|
+
json(res, {
|
|
2995
|
+
ok: true,
|
|
2996
|
+
agent,
|
|
2997
|
+
pkg: spec.pkg,
|
|
2998
|
+
runtime: runtimeCheck.runtime,
|
|
2999
|
+
image: config.image,
|
|
3000
|
+
version: updated.version,
|
|
3001
|
+
restartNotice: `Restart active ${agent} sessions to use the updated CLI.`,
|
|
1950
3002
|
});
|
|
3003
|
+
}
|
|
3004
|
+
catch (err) {
|
|
3005
|
+
const detail = commandErrorDetail(err);
|
|
1951
3006
|
json(res, {
|
|
1952
3007
|
ok: false,
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
3008
|
+
code: 'agent_update_failed',
|
|
3009
|
+
phase: 'agent_prepare',
|
|
3010
|
+
agent,
|
|
3011
|
+
runtime: runtimeCheck.runtime,
|
|
3012
|
+
image: config.image,
|
|
3013
|
+
error: detail || `Failed to update ${agent} CLI.`,
|
|
3014
|
+
}, 502);
|
|
1956
3015
|
}
|
|
1957
|
-
json(res, {
|
|
1958
|
-
ok: true,
|
|
1959
|
-
session: serializeWebTerminalSession(record),
|
|
1960
|
-
});
|
|
1961
3016
|
}
|
|
1962
3017
|
catch (err) {
|
|
1963
3018
|
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
1964
3019
|
}
|
|
1965
3020
|
}
|
|
3021
|
+
async function handleGetWebTerminalInit(reqUrl, res) {
|
|
3022
|
+
pruneWebTerminalInitJobs();
|
|
3023
|
+
const id = String(reqUrl.searchParams.get('id') || '').trim();
|
|
3024
|
+
if (!id) {
|
|
3025
|
+
json(res, { ok: false, error: 'id is required' }, 400);
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
const job = webTerminalInitJobs.get(id);
|
|
3029
|
+
if (!job) {
|
|
3030
|
+
json(res, { ok: false, error: 'Initialization job not found' }, 404);
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
json(res, { ok: true, init: serializeWebTerminalInitJob(job) });
|
|
3034
|
+
}
|
|
1966
3035
|
async function handleGetWebTerminalSessions(res) {
|
|
1967
3036
|
const records = (0, web_terminal_js_1.listWebTerminalRecords)();
|
|
1968
3037
|
const localNode = (0, os_1.hostname)();
|
|
@@ -1989,6 +3058,82 @@ async function handleGetWebTerminalSessions(res) {
|
|
|
1989
3058
|
const sessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
|
|
1990
3059
|
json(res, { ok: true, sessions });
|
|
1991
3060
|
}
|
|
3061
|
+
async function handleGetWebTerminalHistory(reqUrl, res) {
|
|
3062
|
+
const id = String(reqUrl.searchParams.get('id') || '').trim();
|
|
3063
|
+
if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
|
|
3064
|
+
json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
|
|
3068
|
+
if (!record) {
|
|
3069
|
+
json(res, { ok: false, error: 'Terminal session not found' }, 404);
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
if (record.node !== (0, os_1.hostname)()) {
|
|
3073
|
+
json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
const beforeRaw = String(reqUrl.searchParams.get('before') || '').trim();
|
|
3077
|
+
let beforeSeq = null;
|
|
3078
|
+
if (beforeRaw) {
|
|
3079
|
+
const parsedBefore = Number(beforeRaw);
|
|
3080
|
+
if (!Number.isFinite(parsedBefore)) {
|
|
3081
|
+
json(res, { ok: false, error: 'Invalid before sequence number' }, 400);
|
|
3082
|
+
return;
|
|
3083
|
+
}
|
|
3084
|
+
beforeSeq = Math.max(0, Math.floor(parsedBefore));
|
|
3085
|
+
}
|
|
3086
|
+
const limitRaw = String(reqUrl.searchParams.get('limit') || '').trim();
|
|
3087
|
+
let limit = WEB_TERMINAL_HISTORY_PAGE_DEFAULT;
|
|
3088
|
+
if (limitRaw) {
|
|
3089
|
+
const parsedLimit = Number(limitRaw);
|
|
3090
|
+
if (!Number.isFinite(parsedLimit)) {
|
|
3091
|
+
json(res, { ok: false, error: 'Invalid history limit' }, 400);
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(parsedLimit)));
|
|
3095
|
+
}
|
|
3096
|
+
const bridge = await ensureWebTerminalBridge(record);
|
|
3097
|
+
if (!bridge) {
|
|
3098
|
+
json(res, { ok: false, error: 'Could not open terminal bridge' }, 500);
|
|
3099
|
+
return;
|
|
3100
|
+
}
|
|
3101
|
+
const page = getWebTerminalHistoryPage(bridge, { beforeSeq, limit });
|
|
3102
|
+
json(res, {
|
|
3103
|
+
ok: true,
|
|
3104
|
+
id: record.id,
|
|
3105
|
+
history: {
|
|
3106
|
+
...page,
|
|
3107
|
+
limit,
|
|
3108
|
+
},
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
async function handlePostWebTerminalRename(req, res) {
|
|
3112
|
+
try {
|
|
3113
|
+
const body = await readBody(req);
|
|
3114
|
+
const parsed = JSON.parse(body || '{}');
|
|
3115
|
+
const id = String(parsed.id || '').trim();
|
|
3116
|
+
const name = typeof parsed.name === 'string' ? parsed.name.trim() : '';
|
|
3117
|
+
if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
|
|
3118
|
+
json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
|
|
3119
|
+
return;
|
|
3120
|
+
}
|
|
3121
|
+
if (name && !(0, web_terminal_js_1.isValidWebTerminalName)(name)) {
|
|
3122
|
+
json(res, { ok: false, error: 'Invalid session name format' }, 400);
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
const result = (0, web_terminal_js_1.renameWebTerminalRecord)(id, name);
|
|
3126
|
+
if (!result.ok) {
|
|
3127
|
+
const status = result.code === 'name_taken' ? 409 : result.code === 'not_found' ? 404 : 400;
|
|
3128
|
+
json(res, { ok: false, error: result.error, code: result.code }, status);
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
json(res, { ok: true, session: serializeWebTerminalSession(result.record) });
|
|
3132
|
+
}
|
|
3133
|
+
catch (err) {
|
|
3134
|
+
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
1992
3137
|
async function handlePostWebTerminalStop(req, res) {
|
|
1993
3138
|
try {
|
|
1994
3139
|
const body = await readBody(req);
|
|
@@ -2065,8 +3210,15 @@ async function handleValidatePath(req, res) {
|
|
|
2065
3210
|
async function handleBrowseDir(req, res) {
|
|
2066
3211
|
try {
|
|
2067
3212
|
const body = await readBody(req);
|
|
2068
|
-
const
|
|
2069
|
-
const
|
|
3213
|
+
const parsed = JSON.parse(body);
|
|
3214
|
+
const rawPath = typeof parsed.path === 'string' ? parsed.path : '~';
|
|
3215
|
+
const includeFiles = !!parsed.includeFiles;
|
|
3216
|
+
const includeHidden = !!parsed.includeHidden;
|
|
3217
|
+
const rawLimit = Number(parsed.maxEntries);
|
|
3218
|
+
const maxEntries = Number.isFinite(rawLimit)
|
|
3219
|
+
? Math.max(100, Math.min(5000, Math.floor(rawLimit)))
|
|
3220
|
+
: 2000;
|
|
3221
|
+
const resolved = rawPath.replace(/^~/, (0, os_1.homedir)());
|
|
2070
3222
|
if (!(0, fs_1.existsSync)(resolved) || !(0, fs_1.statSync)(resolved).isDirectory()) {
|
|
2071
3223
|
json(res, { ok: false, error: 'Not a directory', path: resolved });
|
|
2072
3224
|
return;
|
|
@@ -2080,19 +3232,41 @@ async function handleBrowseDir(req, res) {
|
|
|
2080
3232
|
return;
|
|
2081
3233
|
}
|
|
2082
3234
|
const dirs = [];
|
|
3235
|
+
const files = [];
|
|
3236
|
+
let truncated = false;
|
|
2083
3237
|
for (const entry of entries) {
|
|
2084
|
-
if (entry.startsWith('.'))
|
|
2085
|
-
continue; // skip dotfiles
|
|
3238
|
+
if (!includeHidden && entry.startsWith('.'))
|
|
3239
|
+
continue; // skip dotfiles by default
|
|
2086
3240
|
const full = (0, path_1.join)(resolved, entry);
|
|
2087
3241
|
try {
|
|
2088
|
-
|
|
3242
|
+
const st = (0, fs_1.statSync)(full);
|
|
3243
|
+
if (st.isDirectory()) {
|
|
2089
3244
|
dirs.push({ name: entry, path: full });
|
|
2090
3245
|
}
|
|
3246
|
+
else if (includeFiles && st.isFile()) {
|
|
3247
|
+
files.push({ name: entry, path: full });
|
|
3248
|
+
}
|
|
2091
3249
|
}
|
|
2092
3250
|
catch { /* skip inaccessible */ }
|
|
3251
|
+
if ((dirs.length + files.length) >= maxEntries) {
|
|
3252
|
+
truncated = true;
|
|
3253
|
+
break;
|
|
3254
|
+
}
|
|
2093
3255
|
}
|
|
2094
3256
|
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
2095
|
-
|
|
3257
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
3258
|
+
const entriesOut = [
|
|
3259
|
+
...dirs.map((d) => ({ name: d.name, path: d.path, type: 'dir' })),
|
|
3260
|
+
...files.map((f) => ({ name: f.name, path: f.path, type: 'file' })),
|
|
3261
|
+
];
|
|
3262
|
+
json(res, {
|
|
3263
|
+
ok: true,
|
|
3264
|
+
path: resolved,
|
|
3265
|
+
dirs,
|
|
3266
|
+
files: includeFiles ? files : undefined,
|
|
3267
|
+
entries: includeFiles ? entriesOut : undefined,
|
|
3268
|
+
truncated,
|
|
3269
|
+
});
|
|
2096
3270
|
}
|
|
2097
3271
|
catch (err) {
|
|
2098
3272
|
json(res, { ok: false, error: err.message ?? String(err) }, 400);
|
|
@@ -2412,6 +3586,8 @@ function mapContainerPathToHost(path, sandboxHome) {
|
|
|
2412
3586
|
return (0, config_js_1.getConfigPath)();
|
|
2413
3587
|
if (path === '/labgate-config/slurm.db')
|
|
2414
3588
|
return (0, config_js_1.getSlurmDbPath)();
|
|
3589
|
+
if (path === '/labgate-config/results.json')
|
|
3590
|
+
return (0, config_js_1.getResultsDbPath)();
|
|
2415
3591
|
return path;
|
|
2416
3592
|
}
|
|
2417
3593
|
function readMcpConfigData() {
|
|
@@ -2584,7 +3760,7 @@ function collectMcpState() {
|
|
|
2584
3760
|
env: resultsEntry?.env || null,
|
|
2585
3761
|
mcpConfigPath,
|
|
2586
3762
|
serverPath: resolveServerPathFromEntry(resultsEntry, sandboxHome),
|
|
2587
|
-
dbPath:
|
|
3763
|
+
dbPath: resolveDbPathFromEntry(resultsEntry, sandboxHome),
|
|
2588
3764
|
tools: [
|
|
2589
3765
|
{ name: 'list_results', title: 'List Results', description: 'List recorded results with filtering and pagination' },
|
|
2590
3766
|
{ name: 'register_result', title: 'Register Result', description: 'Create a new structured result entry' },
|
|
@@ -3167,6 +4343,87 @@ function handleGetSlurmStats(res) {
|
|
|
3167
4343
|
}
|
|
3168
4344
|
// ── SSE: Server-Sent Events for real-time dashboard updates ──
|
|
3169
4345
|
const sseClients = new Set();
|
|
4346
|
+
const RESULTS_WATCH_DEBOUNCE_MS = 120;
|
|
4347
|
+
let lastResultsSignature = getResultsFileSignature();
|
|
4348
|
+
let resultsWatcher = null;
|
|
4349
|
+
let resultsWatchDebounce = null;
|
|
4350
|
+
function getResultsFileSignature() {
|
|
4351
|
+
const resultsPath = (0, config_js_1.getResultsDbPath)();
|
|
4352
|
+
try {
|
|
4353
|
+
if (!(0, fs_1.existsSync)(resultsPath))
|
|
4354
|
+
return 'missing';
|
|
4355
|
+
const st = (0, fs_1.statSync)(resultsPath);
|
|
4356
|
+
return `${st.size}:${Math.floor(st.mtimeMs)}`;
|
|
4357
|
+
}
|
|
4358
|
+
catch {
|
|
4359
|
+
return 'error';
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
function maybeBroadcastResultsChanged() {
|
|
4363
|
+
const signature = getResultsFileSignature();
|
|
4364
|
+
if (signature === lastResultsSignature)
|
|
4365
|
+
return;
|
|
4366
|
+
if (sseClients.size === 0)
|
|
4367
|
+
return;
|
|
4368
|
+
lastResultsSignature = signature;
|
|
4369
|
+
broadcastSSE('results_changed', {
|
|
4370
|
+
changed_at: new Date().toISOString(),
|
|
4371
|
+
signature,
|
|
4372
|
+
});
|
|
4373
|
+
}
|
|
4374
|
+
function scheduleResultsChangeCheck(delayMs = RESULTS_WATCH_DEBOUNCE_MS) {
|
|
4375
|
+
if (resultsWatchDebounce) {
|
|
4376
|
+
clearTimeout(resultsWatchDebounce);
|
|
4377
|
+
}
|
|
4378
|
+
resultsWatchDebounce = setTimeout(() => {
|
|
4379
|
+
resultsWatchDebounce = null;
|
|
4380
|
+
maybeBroadcastResultsChanged();
|
|
4381
|
+
}, delayMs);
|
|
4382
|
+
resultsWatchDebounce.unref?.();
|
|
4383
|
+
}
|
|
4384
|
+
function stopResultsWatcher() {
|
|
4385
|
+
if (resultsWatchDebounce) {
|
|
4386
|
+
clearTimeout(resultsWatchDebounce);
|
|
4387
|
+
resultsWatchDebounce = null;
|
|
4388
|
+
}
|
|
4389
|
+
if (resultsWatcher) {
|
|
4390
|
+
try {
|
|
4391
|
+
resultsWatcher.close();
|
|
4392
|
+
}
|
|
4393
|
+
catch {
|
|
4394
|
+
// Best effort.
|
|
4395
|
+
}
|
|
4396
|
+
resultsWatcher = null;
|
|
4397
|
+
}
|
|
4398
|
+
}
|
|
4399
|
+
function startResultsWatcher() {
|
|
4400
|
+
if (resultsWatcher)
|
|
4401
|
+
return;
|
|
4402
|
+
lastResultsSignature = getResultsFileSignature();
|
|
4403
|
+
const resultsPath = (0, config_js_1.getResultsDbPath)();
|
|
4404
|
+
const watchDir = (0, path_1.dirname)(resultsPath);
|
|
4405
|
+
const watchFile = (0, path_1.basename)(resultsPath);
|
|
4406
|
+
try {
|
|
4407
|
+
(0, config_js_1.ensurePrivateDir)(watchDir);
|
|
4408
|
+
}
|
|
4409
|
+
catch {
|
|
4410
|
+
// Best effort.
|
|
4411
|
+
}
|
|
4412
|
+
try {
|
|
4413
|
+
resultsWatcher = (0, fs_1.watch)(watchDir, (_eventType, filename) => {
|
|
4414
|
+
const changed = filename ? String(filename) : '';
|
|
4415
|
+
if (changed && changed !== watchFile)
|
|
4416
|
+
return;
|
|
4417
|
+
scheduleResultsChangeCheck();
|
|
4418
|
+
});
|
|
4419
|
+
resultsWatcher.on('error', () => {
|
|
4420
|
+
stopResultsWatcher();
|
|
4421
|
+
});
|
|
4422
|
+
}
|
|
4423
|
+
catch {
|
|
4424
|
+
resultsWatcher = null;
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
3170
4427
|
function handleSSE(_req, res) {
|
|
3171
4428
|
res.writeHead(200, {
|
|
3172
4429
|
'Content-Type': 'text/event-stream',
|
|
@@ -3194,6 +4451,7 @@ let sseInterval = null;
|
|
|
3194
4451
|
function startSSEBroadcast() {
|
|
3195
4452
|
if (sseInterval)
|
|
3196
4453
|
return;
|
|
4454
|
+
startResultsWatcher();
|
|
3197
4455
|
sseInterval = setInterval(async () => {
|
|
3198
4456
|
if (sseClients.size === 0)
|
|
3199
4457
|
return;
|
|
@@ -3270,6 +4528,8 @@ function startSSEBroadcast() {
|
|
|
3270
4528
|
}
|
|
3271
4529
|
catch { /* slurm DB unavailable */ }
|
|
3272
4530
|
}
|
|
4531
|
+
// Results changes may come from external MCP processes; emit only on file mutation.
|
|
4532
|
+
maybeBroadcastResultsChanged();
|
|
3273
4533
|
}, 2000);
|
|
3274
4534
|
sseInterval.unref?.();
|
|
3275
4535
|
}
|
|
@@ -3504,10 +4764,12 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3504
4764
|
const options = typeof optsOrPort === 'number'
|
|
3505
4765
|
? { port: optsOrPort, standalone: standaloneArg }
|
|
3506
4766
|
: optsOrPort;
|
|
4767
|
+
(0, init_js_1.ensureBundledIrisDatasetRegistration)();
|
|
3507
4768
|
const standalone = options.standalone ?? true;
|
|
3508
4769
|
const socketPath = options.socketPath || (0, config_js_1.getUiSocketPath)();
|
|
3509
4770
|
const tcpPort = Number.isFinite(options.port) ? Math.floor(options.port) : null;
|
|
3510
4771
|
const useTcp = tcpPort !== null;
|
|
4772
|
+
const prewarmImageOnStartup = options.prewarmImageOnStartup === true;
|
|
3511
4773
|
const requestedPort = tcpPort ?? 0;
|
|
3512
4774
|
const maxPort = requestedPort + 3;
|
|
3513
4775
|
const uiAccessToken = useTcp ? (0, crypto_1.randomBytes)(24).toString('hex') : '';
|
|
@@ -3520,9 +4782,16 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3520
4782
|
(0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(socketPath));
|
|
3521
4783
|
}
|
|
3522
4784
|
const wsServer = new ws_1.WebSocketServer({ noServer: true });
|
|
4785
|
+
const claudeWsServer = new ws_1.WebSocketServer({ noServer: true });
|
|
3523
4786
|
wsServer.on('connection', async (ws, req) => {
|
|
3524
4787
|
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
3525
4788
|
const id = reqUrl.searchParams.get('id') || '';
|
|
4789
|
+
const replayRaw = String(reqUrl.searchParams.get('replay') || '').trim().toLowerCase();
|
|
4790
|
+
const replayDisabled = replayRaw === '0' || replayRaw === 'false' || replayRaw === 'off';
|
|
4791
|
+
const afterSeqRaw = String(reqUrl.searchParams.get('afterSeq') || '').trim();
|
|
4792
|
+
const parsedAfterSeq = afterSeqRaw ? Number(afterSeqRaw) : NaN;
|
|
4793
|
+
const hasAfterSeq = afterSeqRaw.length > 0 && Number.isFinite(parsedAfterSeq);
|
|
4794
|
+
const afterSeq = hasAfterSeq ? Math.floor(parsedAfterSeq) : null;
|
|
3526
4795
|
const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
|
|
3527
4796
|
if (!record) {
|
|
3528
4797
|
sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found' });
|
|
@@ -3543,7 +4812,19 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3543
4812
|
exitCode: record.exitCode,
|
|
3544
4813
|
error: record.error,
|
|
3545
4814
|
});
|
|
3546
|
-
if (
|
|
4815
|
+
if (afterSeq !== null) {
|
|
4816
|
+
const pending = bridge.history.filter((chunk) => chunk.seq > afterSeq);
|
|
4817
|
+
for (const chunk of pending) {
|
|
4818
|
+
sendWebTerminalMessage(ws, {
|
|
4819
|
+
type: 'data',
|
|
4820
|
+
id: record.id,
|
|
4821
|
+
data: chunk.data,
|
|
4822
|
+
seqStart: chunk.seq,
|
|
4823
|
+
seqEnd: chunk.seq,
|
|
4824
|
+
});
|
|
4825
|
+
}
|
|
4826
|
+
}
|
|
4827
|
+
else if (!replayDisabled && bridge.buffer) {
|
|
3547
4828
|
sendWebTerminalMessage(ws, { type: 'data', id: record.id, data: bridge.buffer });
|
|
3548
4829
|
}
|
|
3549
4830
|
ws.on('message', (raw) => {
|
|
@@ -3584,6 +4865,65 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3584
4865
|
}
|
|
3585
4866
|
});
|
|
3586
4867
|
});
|
|
4868
|
+
claudeWsServer.on('connection', (ws, req) => {
|
|
4869
|
+
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
4870
|
+
const id = (reqUrl.searchParams.get('id') || '').trim();
|
|
4871
|
+
const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
|
|
4872
|
+
if (!record || record.node !== (0, os_1.hostname)()) {
|
|
4873
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found on this node.' });
|
|
4874
|
+
ws.close();
|
|
4875
|
+
return;
|
|
4876
|
+
}
|
|
4877
|
+
if (record.agent !== 'claude') {
|
|
4878
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Headless chat is currently supported for Claude sessions only.' });
|
|
4879
|
+
ws.close();
|
|
4880
|
+
return;
|
|
4881
|
+
}
|
|
4882
|
+
let disposeRun = null;
|
|
4883
|
+
let runInFlight = false;
|
|
4884
|
+
ws.on('message', (raw) => {
|
|
4885
|
+
if (runInFlight) {
|
|
4886
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Headless Claude run already in progress for this socket.' });
|
|
4887
|
+
return;
|
|
4888
|
+
}
|
|
4889
|
+
let parsed = null;
|
|
4890
|
+
try {
|
|
4891
|
+
parsed = JSON.parse(raw.toString('utf-8'));
|
|
4892
|
+
}
|
|
4893
|
+
catch {
|
|
4894
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Invalid request payload.' });
|
|
4895
|
+
return;
|
|
4896
|
+
}
|
|
4897
|
+
if (!parsed || parsed.type !== 'prompt') {
|
|
4898
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Unsupported request. Expected { type: "prompt", prompt }.' });
|
|
4899
|
+
return;
|
|
4900
|
+
}
|
|
4901
|
+
const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
|
|
4902
|
+
const resumeSessionId = typeof parsed.resumeSessionId === 'string' ? parsed.resumeSessionId : '';
|
|
4903
|
+
runInFlight = true;
|
|
4904
|
+
void startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId)
|
|
4905
|
+
.then((cleanup) => {
|
|
4906
|
+
disposeRun = cleanup;
|
|
4907
|
+
})
|
|
4908
|
+
.catch((err) => {
|
|
4909
|
+
sendWebTerminalMessage(ws, {
|
|
4910
|
+
type: 'error',
|
|
4911
|
+
code: 'headless_failed',
|
|
4912
|
+
error: err?.message ?? String(err),
|
|
4913
|
+
});
|
|
4914
|
+
sendWebTerminalMessage(ws, { type: 'done', exitCode: 1, isError: true });
|
|
4915
|
+
});
|
|
4916
|
+
});
|
|
4917
|
+
ws.on('close', () => {
|
|
4918
|
+
if (disposeRun) {
|
|
4919
|
+
try {
|
|
4920
|
+
disposeRun();
|
|
4921
|
+
}
|
|
4922
|
+
catch { /* best effort */ }
|
|
4923
|
+
}
|
|
4924
|
+
disposeRun = null;
|
|
4925
|
+
});
|
|
4926
|
+
});
|
|
3587
4927
|
const server = (0, http_1.createServer)(async (req, res) => {
|
|
3588
4928
|
const url = req.url ?? '/';
|
|
3589
4929
|
const reqUrl = new URL(url, 'http://localhost');
|
|
@@ -3709,9 +5049,24 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3709
5049
|
else if (pathname === '/api/terminal/sessions' && method === 'GET') {
|
|
3710
5050
|
await handleGetWebTerminalSessions(res);
|
|
3711
5051
|
}
|
|
5052
|
+
else if (pathname === '/api/terminal/history' && method === 'GET') {
|
|
5053
|
+
await handleGetWebTerminalHistory(reqUrl, res);
|
|
5054
|
+
}
|
|
5055
|
+
else if (pathname === '/api/terminal/init' && method === 'GET') {
|
|
5056
|
+
await handleGetWebTerminalInit(reqUrl, res);
|
|
5057
|
+
}
|
|
5058
|
+
else if (pathname === '/api/terminal/init' && method === 'POST') {
|
|
5059
|
+
await handlePostWebTerminalInit(req, res);
|
|
5060
|
+
}
|
|
3712
5061
|
else if (pathname === '/api/terminal/start' && method === 'POST') {
|
|
3713
5062
|
await handlePostWebTerminalStart(req, res);
|
|
3714
5063
|
}
|
|
5064
|
+
else if (pathname === '/api/terminal/agent/update' && method === 'POST') {
|
|
5065
|
+
await handlePostWebTerminalAgentUpdate(req, res);
|
|
5066
|
+
}
|
|
5067
|
+
else if (pathname === '/api/terminal/rename' && method === 'POST') {
|
|
5068
|
+
await handlePostWebTerminalRename(req, res);
|
|
5069
|
+
}
|
|
3715
5070
|
else if (pathname === '/api/terminal/stop' && method === 'POST') {
|
|
3716
5071
|
await handlePostWebTerminalStop(req, res);
|
|
3717
5072
|
}
|
|
@@ -3805,7 +5160,8 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3805
5160
|
});
|
|
3806
5161
|
server.on('upgrade', (req, socket, head) => {
|
|
3807
5162
|
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
3808
|
-
|
|
5163
|
+
const pathname = reqUrl.pathname;
|
|
5164
|
+
if (pathname !== '/api/terminal/ws' && pathname !== '/api/claude/ws') {
|
|
3809
5165
|
upgradeBadRequest(socket);
|
|
3810
5166
|
return;
|
|
3811
5167
|
}
|
|
@@ -3835,13 +5191,24 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3835
5191
|
upgradeBadRequest(socket);
|
|
3836
5192
|
return;
|
|
3837
5193
|
}
|
|
3838
|
-
|
|
3839
|
-
wsServer.
|
|
5194
|
+
if (pathname === '/api/terminal/ws') {
|
|
5195
|
+
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
5196
|
+
wsServer.emit('connection', ws, req);
|
|
5197
|
+
});
|
|
5198
|
+
return;
|
|
5199
|
+
}
|
|
5200
|
+
if (record.agent !== 'claude') {
|
|
5201
|
+
upgradeBadRequest(socket);
|
|
5202
|
+
return;
|
|
5203
|
+
}
|
|
5204
|
+
claudeWsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
5205
|
+
claudeWsServer.emit('connection', ws, req);
|
|
3840
5206
|
});
|
|
3841
5207
|
});
|
|
3842
5208
|
const bindTcp = (nextPort) => {
|
|
3843
5209
|
listenPort = nextPort;
|
|
3844
|
-
|
|
5210
|
+
const address = options.listenAddress || '127.0.0.1';
|
|
5211
|
+
server.listen(listenPort, address);
|
|
3845
5212
|
};
|
|
3846
5213
|
const bindUnixSocket = () => {
|
|
3847
5214
|
try {
|
|
@@ -3857,51 +5224,104 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3857
5224
|
if (started)
|
|
3858
5225
|
return;
|
|
3859
5226
|
started = true;
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
5227
|
+
void (async () => {
|
|
5228
|
+
if (prewarmImageOnStartup) {
|
|
5229
|
+
try {
|
|
5230
|
+
const cfg = (0, config_js_1.loadConfig)();
|
|
5231
|
+
const runtimeReady = await prepareRuntimeForWebTerminal(cfg.runtime);
|
|
5232
|
+
if (!runtimeReady.ok) {
|
|
5233
|
+
const firstLine = String(runtimeReady.error || 'Container runtime unavailable.')
|
|
5234
|
+
.split('\n')[0]
|
|
5235
|
+
.trim();
|
|
5236
|
+
log.warn(`Skipping startup image preparation: ${firstLine}`);
|
|
5237
|
+
}
|
|
5238
|
+
else {
|
|
5239
|
+
const runtimeCheck = (0, runtime_js_1.checkRuntime)(cfg.runtime);
|
|
5240
|
+
if (!runtimeCheck.ok || !runtimeCheck.runtime) {
|
|
5241
|
+
const firstLine = String(runtimeCheck.error || 'Container runtime unavailable.')
|
|
5242
|
+
.split('\n')[0]
|
|
5243
|
+
.trim();
|
|
5244
|
+
log.warn(`Skipping startup image preparation: ${firstLine}`);
|
|
5245
|
+
}
|
|
5246
|
+
else {
|
|
5247
|
+
const runtime = runtimeCheck.runtime;
|
|
5248
|
+
const image = cfg.image;
|
|
5249
|
+
let imageExists = false;
|
|
5250
|
+
if (runtime === 'podman') {
|
|
5251
|
+
try {
|
|
5252
|
+
await execFileAsync('podman', ['image', 'exists', image], {
|
|
5253
|
+
timeout: 10_000,
|
|
5254
|
+
maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
|
|
5255
|
+
});
|
|
5256
|
+
imageExists = true;
|
|
5257
|
+
}
|
|
5258
|
+
catch {
|
|
5259
|
+
imageExists = false;
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
else {
|
|
5263
|
+
imageExists = (0, fs_1.existsSync)((0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image)));
|
|
5264
|
+
}
|
|
5265
|
+
if (!imageExists) {
|
|
5266
|
+
log.step(`No cached image found for ${image}. Preparing it before opening UI...`);
|
|
5267
|
+
await ensureWebTerminalImageReady(runtime, image);
|
|
5268
|
+
log.success(`Prepared image ${image}.`);
|
|
5269
|
+
}
|
|
5270
|
+
}
|
|
5271
|
+
}
|
|
5272
|
+
}
|
|
5273
|
+
catch (err) {
|
|
5274
|
+
const detail = commandErrorDetail(err);
|
|
5275
|
+
const firstLine = (detail || err?.message || String(err)).split('\n')[0].trim();
|
|
5276
|
+
log.warn(`Startup image preparation failed: ${firstLine}`);
|
|
5277
|
+
}
|
|
3872
5278
|
}
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
(
|
|
5279
|
+
if (useTcp) {
|
|
5280
|
+
const actualPort = server.address()?.port ?? listenPort;
|
|
5281
|
+
dashboardQuickLink = `http://localhost:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
|
|
5282
|
+
log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
|
|
5283
|
+
try {
|
|
5284
|
+
writeDashboardLink(dashboardQuickLink);
|
|
5285
|
+
}
|
|
5286
|
+
catch {
|
|
5287
|
+
// Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
|
|
5288
|
+
}
|
|
5289
|
+
if (shouldAutoOpenUiBrowser(standalone)) {
|
|
5290
|
+
autoOpenUiBrowser(dashboardQuickLink);
|
|
5291
|
+
}
|
|
3877
5292
|
}
|
|
3878
|
-
|
|
3879
|
-
|
|
5293
|
+
else {
|
|
5294
|
+
try {
|
|
5295
|
+
(0, fs_1.chmodSync)(socketPath, config_js_1.PRIVATE_FILE_MODE);
|
|
5296
|
+
}
|
|
5297
|
+
catch {
|
|
5298
|
+
// Best effort on platforms that do not support chmod on sockets.
|
|
5299
|
+
}
|
|
5300
|
+
log.step(`Settings socket: ${socketPath}`);
|
|
5301
|
+
log.step('Use `labgate ui` for browser access on localhost (default port: 7700).');
|
|
3880
5302
|
}
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
}
|
|
3884
|
-
if (standalone) {
|
|
3885
|
-
log.step('Press Ctrl+C to stop');
|
|
3886
|
-
}
|
|
3887
|
-
// Initialise SLURM tracking if enabled
|
|
3888
|
-
const slurmConfig = (0, config_js_1.loadConfig)();
|
|
3889
|
-
if (slurmConfig.slurm.enabled && !slurmDB) {
|
|
3890
|
-
try {
|
|
3891
|
-
slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
|
|
3892
|
-
slurmPoller = new slurm_poller_js_1.SlurmPoller({
|
|
3893
|
-
db: slurmDB,
|
|
3894
|
-
pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
|
|
3895
|
-
sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
|
|
3896
|
-
});
|
|
3897
|
-
slurmPoller.start();
|
|
3898
|
-
log.step('SLURM job tracking enabled');
|
|
5303
|
+
if (standalone) {
|
|
5304
|
+
log.step('Press Ctrl+C to stop');
|
|
3899
5305
|
}
|
|
3900
|
-
|
|
3901
|
-
|
|
5306
|
+
// Initialise SLURM tracking if enabled
|
|
5307
|
+
const slurmConfig = (0, config_js_1.loadConfig)();
|
|
5308
|
+
if (slurmConfig.slurm.enabled && !slurmDB) {
|
|
5309
|
+
try {
|
|
5310
|
+
slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
|
|
5311
|
+
slurmPoller = new slurm_poller_js_1.SlurmPoller({
|
|
5312
|
+
db: slurmDB,
|
|
5313
|
+
pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
|
|
5314
|
+
sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
|
|
5315
|
+
});
|
|
5316
|
+
slurmPoller.start();
|
|
5317
|
+
log.step('SLURM job tracking enabled');
|
|
5318
|
+
}
|
|
5319
|
+
catch (err) {
|
|
5320
|
+
log.warn(`SLURM tracking unavailable: ${err.message}`);
|
|
5321
|
+
}
|
|
3902
5322
|
}
|
|
3903
|
-
|
|
3904
|
-
|
|
5323
|
+
startSSEBroadcast();
|
|
5324
|
+
})();
|
|
3905
5325
|
});
|
|
3906
5326
|
server.on('close', () => {
|
|
3907
5327
|
if (dashboardQuickLink)
|
|
@@ -3917,6 +5337,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3917
5337
|
}
|
|
3918
5338
|
webTerminalBridges.clear();
|
|
3919
5339
|
wsServer.close();
|
|
5340
|
+
claudeWsServer.close();
|
|
3920
5341
|
});
|
|
3921
5342
|
server.on('error', (err) => {
|
|
3922
5343
|
if (!useTcp && err.code === 'EADDRINUSE') {
|
|
@@ -3960,6 +5381,12 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3960
5381
|
// Best effort cleanup.
|
|
3961
5382
|
}
|
|
3962
5383
|
}
|
|
5384
|
+
if (sseInterval) {
|
|
5385
|
+
clearInterval(sseInterval);
|
|
5386
|
+
sseInterval = null;
|
|
5387
|
+
}
|
|
5388
|
+
sseClients.clear();
|
|
5389
|
+
stopResultsWatcher();
|
|
3963
5390
|
// Cleanup SLURM resources
|
|
3964
5391
|
if (slurmPoller) {
|
|
3965
5392
|
slurmPoller.stop();
|