labgate 0.5.30 → 0.5.32
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 +48 -0
- package/dist/cli.js +616 -0
- package/dist/cli.js.map +1 -1
- package/dist/lib/config.d.ts +11 -0
- package/dist/lib/config.js +44 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/container.d.ts +22 -3
- package/dist/lib/container.js +373 -67
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/display-mcp.d.ts +10 -0
- package/dist/lib/display-mcp.js +160 -0
- package/dist/lib/display-mcp.js.map +1 -0
- package/dist/lib/display-store.d.ts +24 -0
- package/dist/lib/display-store.js +150 -0
- package/dist/lib/display-store.js.map +1 -0
- package/dist/lib/explorer-autopilot.d.ts +16 -0
- package/dist/lib/explorer-autopilot.js +573 -0
- package/dist/lib/explorer-autopilot.js.map +1 -0
- package/dist/lib/explorer-claude.d.ts +16 -0
- package/dist/lib/explorer-claude.js +361 -0
- package/dist/lib/explorer-claude.js.map +1 -0
- package/dist/lib/explorer-compare.d.ts +9 -0
- package/dist/lib/explorer-compare.js +190 -0
- package/dist/lib/explorer-compare.js.map +1 -0
- package/dist/lib/explorer-eval.d.ts +23 -0
- package/dist/lib/explorer-eval.js +161 -0
- package/dist/lib/explorer-eval.js.map +1 -0
- package/dist/lib/explorer-gc.d.ts +11 -0
- package/dist/lib/explorer-gc.js +304 -0
- package/dist/lib/explorer-gc.js.map +1 -0
- package/dist/lib/explorer-git.d.ts +14 -0
- package/dist/lib/explorer-git.js +136 -0
- package/dist/lib/explorer-git.js.map +1 -0
- package/dist/lib/explorer-lock.d.ts +5 -0
- package/dist/lib/explorer-lock.js +100 -0
- package/dist/lib/explorer-lock.js.map +1 -0
- package/dist/lib/explorer-mcp.d.ts +11 -0
- package/dist/lib/explorer-mcp.js +611 -0
- package/dist/lib/explorer-mcp.js.map +1 -0
- package/dist/lib/explorer-retention.d.ts +4 -0
- package/dist/lib/explorer-retention.js +58 -0
- package/dist/lib/explorer-retention.js.map +1 -0
- package/dist/lib/explorer-store.d.ts +77 -0
- package/dist/lib/explorer-store.js +950 -0
- package/dist/lib/explorer-store.js.map +1 -0
- package/dist/lib/explorer-types.d.ts +161 -0
- package/dist/lib/explorer-types.js +3 -0
- package/dist/lib/explorer-types.js.map +1 -0
- package/dist/lib/explorer.d.ts +31 -0
- package/dist/lib/explorer.js +247 -0
- package/dist/lib/explorer.js.map +1 -0
- 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 +87 -3
- 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/test/integration-harness.js +1 -1
- package/dist/lib/test/integration-harness.js.map +1 -1
- package/dist/lib/ui.d.ts +1 -0
- package/dist/lib/ui.html +11231 -4370
- package/dist/lib/ui.js +2564 -277
- package/dist/lib/ui.js.map +1 -1
- package/dist/lib/web-terminal.d.ts +13 -0
- package/dist/lib/web-terminal.js +118 -15
- package/dist/lib/web-terminal.js.map +1 -1
- package/dist/mcp-bundles/display-mcp.bundle.mjs +30209 -0
- package/dist/mcp-bundles/explorer-mcp.bundle.mjs +40044 -0
- package/dist/mcp-bundles/results-mcp.bundle.mjs +100 -7
- package/package.json +4 -3
- package/templates/tsp-lab/API_CONTRACT.md +20 -0
- package/templates/tsp-lab/EVAL.md +20 -0
- package/templates/tsp-lab/PROBLEM.md +18 -0
- package/templates/tsp-lab/data/generate_instances.py +51 -0
- package/templates/tsp-lab/data/instances.jsonl +12 -0
- package/templates/tsp-lab/eval.py +148 -0
- package/templates/tsp-lab/solver.py +88 -0
- package/templates/tsp-lab/stub-patches/enable_two_opt.patch +14 -0
package/dist/lib/ui.js
CHANGED
|
@@ -45,13 +45,18 @@ const ws_1 = require("ws");
|
|
|
45
45
|
const config_js_1 = require("./config.js");
|
|
46
46
|
const init_js_1 = require("./init.js");
|
|
47
47
|
const container_js_1 = require("./container.js");
|
|
48
|
+
const runtime_js_1 = require("./runtime.js");
|
|
48
49
|
const audit_js_1 = require("./audit.js");
|
|
49
50
|
const slurm_db_js_1 = require("./slurm-db.js");
|
|
50
51
|
const slurm_poller_js_1 = require("./slurm-poller.js");
|
|
51
52
|
const results_store_js_1 = require("./results-store.js");
|
|
53
|
+
const display_store_js_1 = require("./display-store.js");
|
|
52
54
|
const policy_js_1 = require("./policy.js");
|
|
53
55
|
const license_js_1 = require("./license.js");
|
|
54
56
|
const web_terminal_js_1 = require("./web-terminal.js");
|
|
57
|
+
const explorer_js_1 = require("./explorer.js");
|
|
58
|
+
const explorer_eval_js_1 = require("./explorer-eval.js");
|
|
59
|
+
const explorer_store_js_1 = require("./explorer-store.js");
|
|
55
60
|
const log = __importStar(require("./log.js"));
|
|
56
61
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
57
62
|
const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
|
|
@@ -69,6 +74,11 @@ const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
|
|
|
69
74
|
const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
|
|
70
75
|
const IRIS_SAMPLE_DATASET_NAME = 'flowers-iris';
|
|
71
76
|
const IRIS_SAMPLE_SOURCE_URL = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv';
|
|
77
|
+
const PODMAN_SETUP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
78
|
+
const PODMAN_SETUP_MAX_BUFFER = 16 * 1024 * 1024;
|
|
79
|
+
const EXPLORER_TSP_TEMPLATE_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'templates', 'tsp-lab');
|
|
80
|
+
const EXPLORER_TSP_TEMPLATE_SOURCE_REPO = (0, path_1.join)((0, config_js_1.getExplorerRootDir)(), 'templates', 'tsp-lab-source');
|
|
81
|
+
const EXPLORER_ARTIFACT_READ_MAX_BYTES = 2 * 1024 * 1024;
|
|
72
82
|
const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
|
|
73
83
|
'\n' +
|
|
74
84
|
'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
|
|
@@ -85,14 +95,41 @@ function resolveIrisSampleSourceUrl() {
|
|
|
85
95
|
let slurmDB = null;
|
|
86
96
|
let slurmPoller = null;
|
|
87
97
|
let resultsStore = null;
|
|
98
|
+
let displayStore = null;
|
|
88
99
|
const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
|
|
89
100
|
const webTerminalBridges = new Map();
|
|
101
|
+
const WEB_TERMINAL_INIT_RETENTION_MS = 60 * 60 * 1000;
|
|
102
|
+
const WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS = 60 * 60 * 1000;
|
|
103
|
+
const WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER = 64 * 1024 * 1024;
|
|
104
|
+
const WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
105
|
+
const WEB_TERMINAL_AGENT_PREP_MAX_BUFFER = 32 * 1024 * 1024;
|
|
106
|
+
const WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS = 90 * 1000;
|
|
107
|
+
const WEB_TERMINAL_STARTUP_READY_POLL_MS = 120;
|
|
108
|
+
const WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT = 64 * 1024;
|
|
109
|
+
const WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS = 750;
|
|
110
|
+
const WEB_TERMINAL_BUFFER_MAX_BYTES = 512_000;
|
|
111
|
+
const WEB_TERMINAL_HISTORY_MAX_BYTES = 8 * 1024 * 1024;
|
|
112
|
+
const WEB_TERMINAL_HISTORY_CHUNK_BYTES = 8 * 1024;
|
|
113
|
+
const WEB_TERMINAL_HISTORY_PAGE_DEFAULT = 120;
|
|
114
|
+
const WEB_TERMINAL_HISTORY_PAGE_MAX = 600;
|
|
115
|
+
const WEB_TERMINAL_STARTUP_HEADER_RE = /(?:^|\n)\s*LabGate\s*(?:\n|$)/i;
|
|
116
|
+
const WEB_TERMINAL_STARTUP_BLOCKED_RE = /(?:^|\n)\s*Blocked\s+\d+\s+patterns\b/i;
|
|
117
|
+
const CLAUDE_HEADLESS_STDERR_LIMIT = 12_000;
|
|
118
|
+
const webTerminalInitJobs = new Map();
|
|
119
|
+
const webTerminalImagePullLocks = new Map();
|
|
120
|
+
const webTerminalAgentPrepLocks = new Map();
|
|
90
121
|
function getResultsStore() {
|
|
91
122
|
if (!resultsStore) {
|
|
92
123
|
resultsStore = new results_store_js_1.ResultsStore((0, config_js_1.getResultsDbPath)());
|
|
93
124
|
}
|
|
94
125
|
return resultsStore;
|
|
95
126
|
}
|
|
127
|
+
function getDisplayStore() {
|
|
128
|
+
if (!displayStore) {
|
|
129
|
+
displayStore = new display_store_js_1.DisplayStore((0, config_js_1.getDisplayDbPath)());
|
|
130
|
+
}
|
|
131
|
+
return displayStore;
|
|
132
|
+
}
|
|
96
133
|
function hasCommandInPath(command) {
|
|
97
134
|
const pathValue = (process.env.PATH || '').trim();
|
|
98
135
|
if (!pathValue)
|
|
@@ -137,6 +174,475 @@ function getSlurmRuntimeStatus() {
|
|
|
137
174
|
missingCommands,
|
|
138
175
|
};
|
|
139
176
|
}
|
|
177
|
+
function commandErrorDetail(err) {
|
|
178
|
+
return [
|
|
179
|
+
err?.stderr,
|
|
180
|
+
err?.stdout,
|
|
181
|
+
err?.message,
|
|
182
|
+
err?.cause?.stderr,
|
|
183
|
+
err?.cause?.stdout,
|
|
184
|
+
err?.cause?.message,
|
|
185
|
+
]
|
|
186
|
+
.filter((part) => typeof part === 'string' && part.trim().length > 0)
|
|
187
|
+
.map((part) => String(part).trim())
|
|
188
|
+
.join('\n');
|
|
189
|
+
}
|
|
190
|
+
function isPodmanNotReadyError(error) {
|
|
191
|
+
return /podman is installed but not ready/i.test(error || '');
|
|
192
|
+
}
|
|
193
|
+
async function prepareRuntimeForWebTerminal(preferred) {
|
|
194
|
+
const initial = (0, runtime_js_1.checkRuntime)(preferred);
|
|
195
|
+
if (initial.ok) {
|
|
196
|
+
return { ok: true, initialized: false };
|
|
197
|
+
}
|
|
198
|
+
const canAutoSetupPodman = ((0, os_1.platform)() === 'darwin' &&
|
|
199
|
+
preferred !== 'apptainer' &&
|
|
200
|
+
hasCommandInPath('podman') &&
|
|
201
|
+
isPodmanNotReadyError(initial.error));
|
|
202
|
+
if (!canAutoSetupPodman) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
initialized: false,
|
|
206
|
+
error: initial.error || 'Container runtime unavailable.',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
log.step('Podman runtime not ready. Attempting automatic machine setup for UI launch...');
|
|
210
|
+
try {
|
|
211
|
+
try {
|
|
212
|
+
await execFileAsync('podman', ['machine', 'init'], {
|
|
213
|
+
timeout: PODMAN_SETUP_TIMEOUT_MS,
|
|
214
|
+
maxBuffer: PODMAN_SETUP_MAX_BUFFER,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
const detail = commandErrorDetail(err);
|
|
219
|
+
if (!/already exists/i.test(detail)) {
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await execFileAsync('podman', ['machine', 'start'], {
|
|
224
|
+
timeout: PODMAN_SETUP_TIMEOUT_MS,
|
|
225
|
+
maxBuffer: PODMAN_SETUP_MAX_BUFFER,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
const detail = commandErrorDetail(err);
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
initialized: true,
|
|
233
|
+
error: [
|
|
234
|
+
'Podman setup failed during UI session launch.',
|
|
235
|
+
detail || 'Unknown Podman error.',
|
|
236
|
+
'',
|
|
237
|
+
'Try in a terminal:',
|
|
238
|
+
' podman machine init',
|
|
239
|
+
' podman machine start',
|
|
240
|
+
].join('\n'),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const after = (0, runtime_js_1.checkRuntime)(preferred);
|
|
244
|
+
if (!after.ok) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
initialized: true,
|
|
248
|
+
error: after.error || 'Container runtime unavailable after setup.',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, initialized: true };
|
|
252
|
+
}
|
|
253
|
+
function createWebTerminalInitId() {
|
|
254
|
+
return `wti-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
|
|
255
|
+
}
|
|
256
|
+
function createWebTerminalInitJob(agent, workdir) {
|
|
257
|
+
const now = new Date().toISOString();
|
|
258
|
+
return {
|
|
259
|
+
id: createWebTerminalInitId(),
|
|
260
|
+
agent,
|
|
261
|
+
workdir,
|
|
262
|
+
status: 'running',
|
|
263
|
+
stage: 'queued',
|
|
264
|
+
message: 'Queued session initialization.',
|
|
265
|
+
startedAt: now,
|
|
266
|
+
updatedAt: now,
|
|
267
|
+
session: null,
|
|
268
|
+
error: null,
|
|
269
|
+
code: null,
|
|
270
|
+
phase: null,
|
|
271
|
+
initialized: false,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function pruneWebTerminalInitJobs() {
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
for (const [id, job] of webTerminalInitJobs.entries()) {
|
|
277
|
+
if (job.status === 'running')
|
|
278
|
+
continue;
|
|
279
|
+
const ageMs = now - Date.parse(job.updatedAt || job.startedAt || '');
|
|
280
|
+
if (Number.isFinite(ageMs) && ageMs > WEB_TERMINAL_INIT_RETENTION_MS) {
|
|
281
|
+
webTerminalInitJobs.delete(id);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function updateWebTerminalInitJob(id, patch) {
|
|
286
|
+
const existing = webTerminalInitJobs.get(id);
|
|
287
|
+
if (!existing)
|
|
288
|
+
return null;
|
|
289
|
+
const updated = {
|
|
290
|
+
...existing,
|
|
291
|
+
...patch,
|
|
292
|
+
updatedAt: new Date().toISOString(),
|
|
293
|
+
};
|
|
294
|
+
webTerminalInitJobs.set(id, updated);
|
|
295
|
+
return updated;
|
|
296
|
+
}
|
|
297
|
+
function serializeWebTerminalInitJob(job) {
|
|
298
|
+
return {
|
|
299
|
+
id: job.id,
|
|
300
|
+
agent: job.agent,
|
|
301
|
+
workdir: job.workdir,
|
|
302
|
+
status: job.status,
|
|
303
|
+
stage: job.stage,
|
|
304
|
+
message: job.message,
|
|
305
|
+
startedAt: job.startedAt,
|
|
306
|
+
updatedAt: job.updatedAt,
|
|
307
|
+
session: job.session,
|
|
308
|
+
error: job.error,
|
|
309
|
+
code: job.code,
|
|
310
|
+
phase: job.phase,
|
|
311
|
+
initialized: job.initialized,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
async function withWebTerminalImagePullLock(lockKey, work) {
|
|
315
|
+
const existing = webTerminalImagePullLocks.get(lockKey);
|
|
316
|
+
if (existing) {
|
|
317
|
+
await existing;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
let current = null;
|
|
321
|
+
current = (async () => {
|
|
322
|
+
try {
|
|
323
|
+
await work();
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
if (current && webTerminalImagePullLocks.get(lockKey) === current) {
|
|
327
|
+
webTerminalImagePullLocks.delete(lockKey);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
})();
|
|
331
|
+
webTerminalImagePullLocks.set(lockKey, current);
|
|
332
|
+
await current;
|
|
333
|
+
}
|
|
334
|
+
async function ensureWebTerminalImageReady(runtime, image, onProgress) {
|
|
335
|
+
onProgress?.('image_check', `Checking image availability for ${image}...`);
|
|
336
|
+
if (runtime === 'podman') {
|
|
337
|
+
const imageExists = async () => {
|
|
338
|
+
try {
|
|
339
|
+
await execFileAsync('podman', ['image', 'exists', image], {
|
|
340
|
+
timeout: 10_000,
|
|
341
|
+
maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
|
|
342
|
+
});
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
if (await imageExists())
|
|
350
|
+
return;
|
|
351
|
+
await withWebTerminalImagePullLock(`podman:${image}`, async () => {
|
|
352
|
+
if (await imageExists())
|
|
353
|
+
return;
|
|
354
|
+
onProgress?.('image_pull', `Pulling container image ${image}...`);
|
|
355
|
+
await execFileAsync('podman', ['pull', image], {
|
|
356
|
+
timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
|
|
357
|
+
maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const imagesDir = (0, config_js_1.getImagesDir)();
|
|
363
|
+
const sifPath = (0, path_1.join)(imagesDir, (0, container_js_1.imageToSifName)(image));
|
|
364
|
+
if ((0, fs_1.existsSync)(sifPath))
|
|
365
|
+
return;
|
|
366
|
+
await withWebTerminalImagePullLock(`apptainer:${image}`, async () => {
|
|
367
|
+
if ((0, fs_1.existsSync)(sifPath))
|
|
368
|
+
return;
|
|
369
|
+
(0, fs_1.mkdirSync)(imagesDir, { recursive: true });
|
|
370
|
+
onProgress?.('image_pull', `Pulling container image ${image}...`);
|
|
371
|
+
await execFileAsync('apptainer', ['pull', sifPath, `docker://${image}`], {
|
|
372
|
+
timeout: WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS,
|
|
373
|
+
maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
function getWebTerminalAgentBootstrapSpec(agent) {
|
|
378
|
+
if (agent === 'codex') {
|
|
379
|
+
return { bin: 'codex', pkg: '@openai/codex' };
|
|
380
|
+
}
|
|
381
|
+
return { bin: 'claude', pkg: '@anthropic-ai/claude-code' };
|
|
382
|
+
}
|
|
383
|
+
async function withWebTerminalAgentPrepareLock(lockKey, work) {
|
|
384
|
+
const existing = webTerminalAgentPrepLocks.get(lockKey);
|
|
385
|
+
if (existing) {
|
|
386
|
+
await existing;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
let current = null;
|
|
390
|
+
current = (async () => {
|
|
391
|
+
try {
|
|
392
|
+
await work();
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
if (current && webTerminalAgentPrepLocks.get(lockKey) === current) {
|
|
396
|
+
webTerminalAgentPrepLocks.delete(lockKey);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
})();
|
|
400
|
+
webTerminalAgentPrepLocks.set(lockKey, current);
|
|
401
|
+
await current;
|
|
402
|
+
}
|
|
403
|
+
function getPodmanPrewarmNetworkArgs(networkMode) {
|
|
404
|
+
const mode = String(networkMode || '').trim().toLowerCase();
|
|
405
|
+
if (mode === 'none')
|
|
406
|
+
return ['--network', 'none'];
|
|
407
|
+
if (mode === 'host')
|
|
408
|
+
return ['--network', 'host'];
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
async function runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script) {
|
|
412
|
+
if (runtime === 'podman') {
|
|
413
|
+
const result = await execFileAsync('podman', [
|
|
414
|
+
'run',
|
|
415
|
+
'--rm',
|
|
416
|
+
'--workdir', '/work',
|
|
417
|
+
'--volume', `${sandboxHome}:/home/sandbox`,
|
|
418
|
+
'--volume', `${resolvedWorkdir}:/work`,
|
|
419
|
+
'--env', 'HOME=/home/sandbox',
|
|
420
|
+
...getPodmanPrewarmNetworkArgs(networkMode),
|
|
421
|
+
image,
|
|
422
|
+
'bash',
|
|
423
|
+
'-lc',
|
|
424
|
+
script,
|
|
425
|
+
], {
|
|
426
|
+
timeout: WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS,
|
|
427
|
+
maxBuffer: WEB_TERMINAL_AGENT_PREP_MAX_BUFFER,
|
|
428
|
+
});
|
|
429
|
+
return {
|
|
430
|
+
stdout: String(result?.stdout || ''),
|
|
431
|
+
stderr: String(result?.stderr || ''),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image));
|
|
435
|
+
const result = await execFileAsync('apptainer', [
|
|
436
|
+
'exec',
|
|
437
|
+
'--containall',
|
|
438
|
+
'--cleanenv',
|
|
439
|
+
'--home', `${sandboxHome}:/home/sandbox`,
|
|
440
|
+
'--bind', `${resolvedWorkdir}:/work`,
|
|
441
|
+
'--pwd', '/work',
|
|
442
|
+
sifPath,
|
|
443
|
+
'bash',
|
|
444
|
+
'-lc',
|
|
445
|
+
script,
|
|
446
|
+
], {
|
|
447
|
+
timeout: WEB_TERMINAL_AGENT_PREP_TIMEOUT_MS,
|
|
448
|
+
maxBuffer: WEB_TERMINAL_AGENT_PREP_MAX_BUFFER,
|
|
449
|
+
});
|
|
450
|
+
return {
|
|
451
|
+
stdout: String(result?.stdout || ''),
|
|
452
|
+
stderr: String(result?.stderr || ''),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async function ensureWebTerminalAgentReady(runtime, image, agent, resolvedWorkdir, networkMode, onProgress) {
|
|
456
|
+
const spec = getWebTerminalAgentBootstrapSpec(agent);
|
|
457
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
458
|
+
const installedBin = (0, path_1.join)(sandboxHome, '.npm-global', 'bin', spec.bin);
|
|
459
|
+
if ((0, fs_1.existsSync)(installedBin)) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
await withWebTerminalAgentPrepareLock(spec.bin, async () => {
|
|
463
|
+
if ((0, fs_1.existsSync)(installedBin))
|
|
464
|
+
return;
|
|
465
|
+
onProgress?.('agent_prepare', `Preparing ${agent} CLI in sandbox home...`);
|
|
466
|
+
const script = [
|
|
467
|
+
'set -euo pipefail',
|
|
468
|
+
'export HOME=/home/sandbox',
|
|
469
|
+
'mkdir -p "$HOME/.npm-global"',
|
|
470
|
+
'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
|
|
471
|
+
'export PATH="$HOME/.npm-global/bin:$PATH"',
|
|
472
|
+
`if ! command -v ${spec.bin} >/dev/null 2>&1; then npm i -g ${spec.pkg}; fi`,
|
|
473
|
+
].join('\n');
|
|
474
|
+
await runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function extractAgentVersionFromOutput(output) {
|
|
478
|
+
const marker = String(output || '')
|
|
479
|
+
.split('\n')
|
|
480
|
+
.map((line) => line.trim())
|
|
481
|
+
.find((line) => line.startsWith('LABGATE_AGENT_VERSION:'));
|
|
482
|
+
if (!marker)
|
|
483
|
+
return null;
|
|
484
|
+
const raw = marker.slice('LABGATE_AGENT_VERSION:'.length).trim();
|
|
485
|
+
if (!raw)
|
|
486
|
+
return null;
|
|
487
|
+
return raw.replace(/^v/, '');
|
|
488
|
+
}
|
|
489
|
+
async function updateWebTerminalAgentCli(runtime, image, agent, resolvedWorkdir, networkMode) {
|
|
490
|
+
const spec = getWebTerminalAgentBootstrapSpec(agent);
|
|
491
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
492
|
+
let version = null;
|
|
493
|
+
await withWebTerminalAgentPrepareLock(spec.bin, async () => {
|
|
494
|
+
const script = [
|
|
495
|
+
'set -euo pipefail',
|
|
496
|
+
'export HOME=/home/sandbox',
|
|
497
|
+
'mkdir -p "$HOME/.npm-global"',
|
|
498
|
+
'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
|
|
499
|
+
'export PATH="$HOME/.npm-global/bin:$PATH"',
|
|
500
|
+
`npm i -g "${spec.pkg}"`,
|
|
501
|
+
`_labgate_ver="$(${spec.bin} --version 2>/dev/null || true)"`,
|
|
502
|
+
'_labgate_ver="$(printf "%s" "$_labgate_ver" | head -n 1 | tr -d \'\\r\')"',
|
|
503
|
+
'echo "LABGATE_AGENT_VERSION:${_labgate_ver}"',
|
|
504
|
+
].join('\n');
|
|
505
|
+
const result = await runWebTerminalAgentBootstrapScript(runtime, image, sandboxHome, resolvedWorkdir, networkMode, script);
|
|
506
|
+
version = extractAgentVersionFromOutput([result.stdout, result.stderr].filter(Boolean).join('\n'));
|
|
507
|
+
});
|
|
508
|
+
return { version };
|
|
509
|
+
}
|
|
510
|
+
function toRuntimeUnavailableResult(runtimeReady) {
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
status: runtimeReady.initialized ? 502 : 503,
|
|
514
|
+
body: {
|
|
515
|
+
ok: false,
|
|
516
|
+
code: 'runtime_unavailable',
|
|
517
|
+
phase: 'runtime_setup',
|
|
518
|
+
initialized: runtimeReady.initialized,
|
|
519
|
+
error: runtimeReady.error || 'Container runtime unavailable.',
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
|
|
524
|
+
const onProgress = opts.onProgress;
|
|
525
|
+
const config = (0, config_js_1.loadConfig)();
|
|
526
|
+
onProgress?.('runtime_setup', 'Checking container runtime...');
|
|
527
|
+
const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
|
|
528
|
+
if (!runtimeReady.ok) {
|
|
529
|
+
return toRuntimeUnavailableResult(runtimeReady);
|
|
530
|
+
}
|
|
531
|
+
const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
|
|
532
|
+
if (!runtimeCheck.ok || !runtimeCheck.runtime) {
|
|
533
|
+
return {
|
|
534
|
+
ok: false,
|
|
535
|
+
status: runtimeReady.initialized ? 502 : 503,
|
|
536
|
+
body: {
|
|
537
|
+
ok: false,
|
|
538
|
+
code: 'runtime_unavailable',
|
|
539
|
+
phase: 'runtime_setup',
|
|
540
|
+
initialized: runtimeReady.initialized,
|
|
541
|
+
error: runtimeCheck.error || 'Container runtime unavailable.',
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
// Preflight tmux before any slow image/agent preparation to avoid unnecessary side effects.
|
|
546
|
+
const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
|
|
547
|
+
if (!tmuxAvailable.ok) {
|
|
548
|
+
return {
|
|
549
|
+
ok: false,
|
|
550
|
+
status: 500,
|
|
551
|
+
body: { ok: false, error: tmuxAvailable.error },
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (opts.prewarmImage) {
|
|
555
|
+
try {
|
|
556
|
+
await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, onProgress);
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
const detail = commandErrorDetail(err);
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
status: 502,
|
|
563
|
+
body: {
|
|
564
|
+
ok: false,
|
|
565
|
+
code: 'image_prepare_failed',
|
|
566
|
+
phase: 'image_prepare',
|
|
567
|
+
runtime: runtimeCheck.runtime,
|
|
568
|
+
image: config.image,
|
|
569
|
+
error: detail || `Failed to pull image ${config.image}.`,
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (opts.prewarmAgent) {
|
|
575
|
+
try {
|
|
576
|
+
await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, agent, resolvedWorkdir, config.network.mode, onProgress);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
const detail = commandErrorDetail(err);
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
status: 502,
|
|
583
|
+
body: {
|
|
584
|
+
ok: false,
|
|
585
|
+
code: 'agent_prepare_failed',
|
|
586
|
+
phase: 'agent_prepare',
|
|
587
|
+
runtime: runtimeCheck.runtime,
|
|
588
|
+
agent,
|
|
589
|
+
image: config.image,
|
|
590
|
+
error: detail || `Failed to prepare ${agent} in sandbox home.`,
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
onProgress?.('tmux_check', 'Checking terminal multiplexer availability...');
|
|
596
|
+
const cliEntrypoint = resolveCliEntrypoint();
|
|
597
|
+
const id = `wt-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
|
|
598
|
+
const record = (0, web_terminal_js_1.createWebTerminalRecord)({
|
|
599
|
+
id,
|
|
600
|
+
agent,
|
|
601
|
+
runtime: runtimeCheck.runtime,
|
|
602
|
+
workdir: resolvedWorkdir,
|
|
603
|
+
});
|
|
604
|
+
(0, web_terminal_js_1.writeWebTerminalRecord)(record);
|
|
605
|
+
onProgress?.('session_start', `Starting ${agent} terminal session...`);
|
|
606
|
+
try {
|
|
607
|
+
await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint);
|
|
608
|
+
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
const message = err?.message ?? String(err);
|
|
612
|
+
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
status: 500,
|
|
616
|
+
body: { ok: false, error: `Could not start tmux session: ${message}` },
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
onProgress?.('session_start', 'Attaching terminal bridge...');
|
|
620
|
+
const bridge = await ensureWebTerminalBridge(record);
|
|
621
|
+
if (!bridge) {
|
|
622
|
+
try {
|
|
623
|
+
await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
|
|
624
|
+
}
|
|
625
|
+
catch { /* best effort */ }
|
|
626
|
+
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, {
|
|
627
|
+
status: 'failed',
|
|
628
|
+
exitCode: 1,
|
|
629
|
+
error: 'node-pty bridge unavailable',
|
|
630
|
+
});
|
|
631
|
+
return {
|
|
632
|
+
ok: false,
|
|
633
|
+
status: 500,
|
|
634
|
+
body: {
|
|
635
|
+
ok: false,
|
|
636
|
+
error: 'Started tmux session but could not create terminal bridge (node-pty unavailable).',
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
await waitForWebTerminalStartupSummary(record, bridge, onProgress);
|
|
641
|
+
return {
|
|
642
|
+
ok: true,
|
|
643
|
+
session: serializeWebTerminalSession(record),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
140
646
|
function readBody(req) {
|
|
141
647
|
return new Promise((resolve, reject) => {
|
|
142
648
|
const chunks = [];
|
|
@@ -420,7 +926,9 @@ function normalizeWebTerminalAgent(raw) {
|
|
|
420
926
|
function serializeWebTerminalSession(record) {
|
|
421
927
|
return {
|
|
422
928
|
id: record.id,
|
|
929
|
+
name: record.name || '',
|
|
423
930
|
agent: record.agent,
|
|
931
|
+
runtime: record.runtime || '',
|
|
424
932
|
workdir: record.workdir,
|
|
425
933
|
node: record.node,
|
|
426
934
|
tmuxSession: record.tmuxSession,
|
|
@@ -441,11 +949,62 @@ async function loadNodePtyModule() {
|
|
|
441
949
|
}
|
|
442
950
|
}
|
|
443
951
|
function appendWebTerminalBuffer(bridge, chunk) {
|
|
952
|
+
if (!chunk)
|
|
953
|
+
return [];
|
|
444
954
|
bridge.buffer += chunk;
|
|
445
955
|
// Keep recent output bounded to avoid unbounded memory growth.
|
|
446
|
-
if (bridge.buffer.length >
|
|
447
|
-
bridge.buffer = bridge.buffer.slice(bridge.buffer.length -
|
|
956
|
+
if (bridge.buffer.length > WEB_TERMINAL_BUFFER_MAX_BYTES) {
|
|
957
|
+
bridge.buffer = bridge.buffer.slice(bridge.buffer.length - WEB_TERMINAL_BUFFER_MAX_BYTES);
|
|
958
|
+
}
|
|
959
|
+
const appended = [];
|
|
960
|
+
for (let i = 0; i < chunk.length; i += WEB_TERMINAL_HISTORY_CHUNK_BYTES) {
|
|
961
|
+
const piece = chunk.slice(i, i + WEB_TERMINAL_HISTORY_CHUNK_BYTES);
|
|
962
|
+
if (!piece)
|
|
963
|
+
continue;
|
|
964
|
+
const seq = bridge.nextSeq++;
|
|
965
|
+
const nextChunk = { seq, data: piece };
|
|
966
|
+
bridge.history.push(nextChunk);
|
|
967
|
+
appended.push(nextChunk);
|
|
968
|
+
bridge.historyBytes += piece.length;
|
|
969
|
+
}
|
|
970
|
+
while (bridge.historyBytes > WEB_TERMINAL_HISTORY_MAX_BYTES && bridge.history.length > 0) {
|
|
971
|
+
const removed = bridge.history.shift();
|
|
972
|
+
if (!removed)
|
|
973
|
+
break;
|
|
974
|
+
bridge.historyBytes = Math.max(0, bridge.historyBytes - removed.data.length);
|
|
448
975
|
}
|
|
976
|
+
return appended;
|
|
977
|
+
}
|
|
978
|
+
function getWebTerminalHistoryPage(bridge, options) {
|
|
979
|
+
const history = bridge.history;
|
|
980
|
+
if (!history.length) {
|
|
981
|
+
return {
|
|
982
|
+
chunks: [],
|
|
983
|
+
hasMore: false,
|
|
984
|
+
nextBefore: null,
|
|
985
|
+
oldestSeq: null,
|
|
986
|
+
latestSeq: null,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
const beforeSeq = options?.beforeSeq ?? null;
|
|
990
|
+
const rawLimit = options?.limit ?? WEB_TERMINAL_HISTORY_PAGE_DEFAULT;
|
|
991
|
+
const limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(rawLimit)));
|
|
992
|
+
let endIndex = history.length;
|
|
993
|
+
if (beforeSeq !== null && Number.isFinite(beforeSeq)) {
|
|
994
|
+
const idx = history.findIndex((chunk) => chunk.seq >= beforeSeq);
|
|
995
|
+
endIndex = idx >= 0 ? idx : history.length;
|
|
996
|
+
}
|
|
997
|
+
endIndex = Math.max(0, Math.min(endIndex, history.length));
|
|
998
|
+
const startIndex = Math.max(0, endIndex - limit);
|
|
999
|
+
const chunks = history.slice(startIndex, endIndex);
|
|
1000
|
+
const hasMore = startIndex > 0;
|
|
1001
|
+
return {
|
|
1002
|
+
chunks,
|
|
1003
|
+
hasMore,
|
|
1004
|
+
nextBefore: hasMore && chunks.length > 0 ? chunks[0].seq : null,
|
|
1005
|
+
oldestSeq: history[0]?.seq ?? null,
|
|
1006
|
+
latestSeq: history[history.length - 1]?.seq ?? null,
|
|
1007
|
+
};
|
|
449
1008
|
}
|
|
450
1009
|
function sendWebTerminalMessage(ws, payload) {
|
|
451
1010
|
if (ws.readyState !== ws_1.WebSocket.OPEN)
|
|
@@ -457,6 +1016,157 @@ function sendWebTerminalMessage(ws, payload) {
|
|
|
457
1016
|
// Best effort.
|
|
458
1017
|
}
|
|
459
1018
|
}
|
|
1019
|
+
function parseJsonObjectLine(line) {
|
|
1020
|
+
try {
|
|
1021
|
+
const parsed = JSON.parse(line);
|
|
1022
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
1023
|
+
return null;
|
|
1024
|
+
return parsed;
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
function readRecordString(record, key) {
|
|
1031
|
+
const value = record[key];
|
|
1032
|
+
return typeof value === 'string' ? value : '';
|
|
1033
|
+
}
|
|
1034
|
+
function normalizeToolUseId(value) {
|
|
1035
|
+
if (typeof value === 'string')
|
|
1036
|
+
return value.trim();
|
|
1037
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1038
|
+
return String(value);
|
|
1039
|
+
return '';
|
|
1040
|
+
}
|
|
1041
|
+
function collectClaudeTextFromContent(content) {
|
|
1042
|
+
if (!Array.isArray(content))
|
|
1043
|
+
return '';
|
|
1044
|
+
let text = '';
|
|
1045
|
+
for (const part of content) {
|
|
1046
|
+
if (!part || typeof part !== 'object' || Array.isArray(part))
|
|
1047
|
+
continue;
|
|
1048
|
+
const node = part;
|
|
1049
|
+
if (node.type === 'text' && typeof node.text === 'string') {
|
|
1050
|
+
text += node.text;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return text;
|
|
1054
|
+
}
|
|
1055
|
+
function extractClaudeStreamSessionId(event) {
|
|
1056
|
+
const direct = readRecordString(event, 'session_id').trim();
|
|
1057
|
+
if (direct)
|
|
1058
|
+
return direct;
|
|
1059
|
+
const message = event.message;
|
|
1060
|
+
if (!message || typeof message !== 'object' || Array.isArray(message))
|
|
1061
|
+
return '';
|
|
1062
|
+
const nested = readRecordString(message, 'session_id').trim();
|
|
1063
|
+
return nested || '';
|
|
1064
|
+
}
|
|
1065
|
+
function extractClaudeAssistantSnapshot(event) {
|
|
1066
|
+
const type = readRecordString(event, 'type').trim().toLowerCase();
|
|
1067
|
+
if (type === 'assistant') {
|
|
1068
|
+
const message = event.message;
|
|
1069
|
+
if (message && typeof message === 'object' && !Array.isArray(message)) {
|
|
1070
|
+
const content = message.content;
|
|
1071
|
+
const contentText = collectClaudeTextFromContent(content);
|
|
1072
|
+
if (contentText)
|
|
1073
|
+
return contentText;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (type === 'result') {
|
|
1077
|
+
const resultText = readRecordString(event, 'result');
|
|
1078
|
+
if (resultText)
|
|
1079
|
+
return resultText;
|
|
1080
|
+
}
|
|
1081
|
+
return '';
|
|
1082
|
+
}
|
|
1083
|
+
function isClaudeAuthenticationFailure(event, assistantSnapshot, stderrText) {
|
|
1084
|
+
const packed = JSON.stringify(event).toLowerCase();
|
|
1085
|
+
const message = `${assistantSnapshot}\n${stderrText}`.toLowerCase();
|
|
1086
|
+
const authRe = /oauth token has expired|authentication_error|failed to authenticate|api error:\s*401/i;
|
|
1087
|
+
return authRe.test(packed) || authRe.test(message);
|
|
1088
|
+
}
|
|
1089
|
+
function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSessionId) {
|
|
1090
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
1091
|
+
const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
|
|
1092
|
+
const resume = resumeSessionId.trim();
|
|
1093
|
+
// Ensure display.json exists before bind-mounting it
|
|
1094
|
+
const displayDbPath = (0, config_js_1.getDisplayDbPath)();
|
|
1095
|
+
if (!(0, fs_1.existsSync)(displayDbPath)) {
|
|
1096
|
+
(0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(displayDbPath));
|
|
1097
|
+
(0, fs_1.writeFileSync)(displayDbPath, JSON.stringify({ version: 1, events: [] }, null, 2) + '\n', {
|
|
1098
|
+
encoding: 'utf-8',
|
|
1099
|
+
mode: config_js_1.PRIVATE_FILE_MODE,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
return [
|
|
1103
|
+
'exec',
|
|
1104
|
+
'--containall',
|
|
1105
|
+
'--cleanenv',
|
|
1106
|
+
'--home', `${sandboxHome}:/home/sandbox`,
|
|
1107
|
+
'--bind', `${workdir}:/work`,
|
|
1108
|
+
'--pwd', '/work',
|
|
1109
|
+
...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
|
|
1110
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
1111
|
+
const target = `/mnt/${(0, path_1.basename)(resolved)}`;
|
|
1112
|
+
const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
|
|
1113
|
+
return ['--bind', bindSpec];
|
|
1114
|
+
}),
|
|
1115
|
+
...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
|
|
1116
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
1117
|
+
const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
|
|
1118
|
+
return ['--bind', bindSpec];
|
|
1119
|
+
}),
|
|
1120
|
+
...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
|
|
1121
|
+
'--bind', `${(0, config_js_1.getDisplayDbPath)()}:/labgate-config/display.json`,
|
|
1122
|
+
'--env', 'HOME=/home/sandbox',
|
|
1123
|
+
'--env', 'ANTHROPIC_API_KEY=',
|
|
1124
|
+
sifPath,
|
|
1125
|
+
'/home/sandbox/.npm-global/bin/claude',
|
|
1126
|
+
'-p',
|
|
1127
|
+
'--verbose',
|
|
1128
|
+
'--output-format',
|
|
1129
|
+
'stream-json',
|
|
1130
|
+
'--include-partial-messages',
|
|
1131
|
+
...(resume ? ['--resume', resume] : []),
|
|
1132
|
+
prompt,
|
|
1133
|
+
];
|
|
1134
|
+
}
|
|
1135
|
+
function sleep(ms) {
|
|
1136
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1137
|
+
}
|
|
1138
|
+
function stripAnsiForStartupReadiness(text) {
|
|
1139
|
+
return String(text || '')
|
|
1140
|
+
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
|
|
1141
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
1142
|
+
.replace(/\r/g, '\n');
|
|
1143
|
+
}
|
|
1144
|
+
function hasWebTerminalStartupSummary(buffer) {
|
|
1145
|
+
const plain = stripAnsiForStartupReadiness(buffer);
|
|
1146
|
+
return WEB_TERMINAL_STARTUP_HEADER_RE.test(plain) && WEB_TERMINAL_STARTUP_BLOCKED_RE.test(plain);
|
|
1147
|
+
}
|
|
1148
|
+
async function waitForWebTerminalStartupSummary(record, bridge, onProgress) {
|
|
1149
|
+
const deadline = Date.now() + WEB_TERMINAL_STARTUP_READY_TIMEOUT_MS;
|
|
1150
|
+
let lastAliveCheck = 0;
|
|
1151
|
+
onProgress?.('session_start', `Finalizing ${record.agent} startup...`);
|
|
1152
|
+
while (Date.now() < deadline) {
|
|
1153
|
+
const recent = bridge.buffer.length > WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT
|
|
1154
|
+
? bridge.buffer.slice(-WEB_TERMINAL_STARTUP_READY_BUFFER_LIMIT)
|
|
1155
|
+
: bridge.buffer;
|
|
1156
|
+
if (hasWebTerminalStartupSummary(recent))
|
|
1157
|
+
return;
|
|
1158
|
+
if (!bridge.pty)
|
|
1159
|
+
return;
|
|
1160
|
+
const now = Date.now();
|
|
1161
|
+
if (now - lastAliveCheck >= WEB_TERMINAL_STARTUP_ALIVE_CHECK_MS) {
|
|
1162
|
+
lastAliveCheck = now;
|
|
1163
|
+
const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
|
|
1164
|
+
if (!alive)
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
await sleep(WEB_TERMINAL_STARTUP_READY_POLL_MS);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
460
1170
|
function broadcastWebTerminalMessage(bridge, payload) {
|
|
461
1171
|
for (const ws of bridge.clients) {
|
|
462
1172
|
if (ws.readyState !== ws_1.WebSocket.OPEN) {
|
|
@@ -466,6 +1176,23 @@ function broadcastWebTerminalMessage(bridge, payload) {
|
|
|
466
1176
|
sendWebTerminalMessage(ws, payload);
|
|
467
1177
|
}
|
|
468
1178
|
}
|
|
1179
|
+
function closeWebTerminalBridgeClients(bridge, code = 4001, reason = 'labgate-bridge-detached') {
|
|
1180
|
+
const clients = Array.from(bridge.clients);
|
|
1181
|
+
bridge.clients.clear();
|
|
1182
|
+
for (const ws of clients) {
|
|
1183
|
+
try {
|
|
1184
|
+
ws.close(code, reason);
|
|
1185
|
+
}
|
|
1186
|
+
catch {
|
|
1187
|
+
try {
|
|
1188
|
+
ws.close();
|
|
1189
|
+
}
|
|
1190
|
+
catch {
|
|
1191
|
+
// Best effort.
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
469
1196
|
async function ensureWebTerminalBridge(record) {
|
|
470
1197
|
const existing = webTerminalBridges.get(record.id);
|
|
471
1198
|
if (existing && existing.pty)
|
|
@@ -482,6 +1209,13 @@ async function ensureWebTerminalBridge(record) {
|
|
|
482
1209
|
log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
|
|
483
1210
|
return null;
|
|
484
1211
|
}
|
|
1212
|
+
try {
|
|
1213
|
+
// Keep wheel scrolling intuitive for both new and existing sessions.
|
|
1214
|
+
await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, 'mouse', 'on'], { timeout: 10_000 });
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
// Best effort only; attach should still proceed.
|
|
1218
|
+
}
|
|
485
1219
|
const env = {};
|
|
486
1220
|
for (const [k, v] of Object.entries(process.env)) {
|
|
487
1221
|
if (v !== undefined)
|
|
@@ -514,40 +1248,65 @@ async function ensureWebTerminalBridge(record) {
|
|
|
514
1248
|
const bridge = existing || {
|
|
515
1249
|
id: record.id,
|
|
516
1250
|
buffer: '',
|
|
1251
|
+
history: [],
|
|
1252
|
+
historyBytes: 0,
|
|
1253
|
+
nextSeq: 1,
|
|
1254
|
+
stopRequested: false,
|
|
517
1255
|
clients: new Set(),
|
|
518
1256
|
pty: ptyProcess,
|
|
519
1257
|
};
|
|
1258
|
+
bridge.stopRequested = false;
|
|
520
1259
|
bridge.pty = ptyProcess;
|
|
521
1260
|
webTerminalBridges.set(record.id, bridge);
|
|
522
1261
|
ptyProcess.onData((data) => {
|
|
523
|
-
appendWebTerminalBuffer(bridge, data);
|
|
524
|
-
|
|
1262
|
+
const appended = appendWebTerminalBuffer(bridge, data);
|
|
1263
|
+
for (const chunk of appended) {
|
|
1264
|
+
broadcastWebTerminalMessage(bridge, {
|
|
1265
|
+
type: 'data',
|
|
1266
|
+
id: record.id,
|
|
1267
|
+
data: chunk.data,
|
|
1268
|
+
seqStart: chunk.seq,
|
|
1269
|
+
seqEnd: chunk.seq,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
525
1272
|
});
|
|
526
1273
|
ptyProcess.onExit(async () => {
|
|
1274
|
+
const stopRequested = bridge.stopRequested;
|
|
1275
|
+
bridge.stopRequested = false;
|
|
527
1276
|
bridge.pty = null;
|
|
528
1277
|
const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
|
|
529
|
-
if (
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
type: 'status',
|
|
540
|
-
id: record.id,
|
|
541
|
-
status: updated?.status ?? finalStatus,
|
|
542
|
-
exitCode: updated?.exitCode ?? finalCode,
|
|
543
|
-
});
|
|
1278
|
+
if (alive) {
|
|
1279
|
+
if (stopRequested)
|
|
1280
|
+
return;
|
|
1281
|
+
// Another tmux client (for example `labgate continue`) may have force-detached this bridge.
|
|
1282
|
+
// Clear stale alternate-screen data and require clients to reconnect for a clean reattach.
|
|
1283
|
+
bridge.buffer = '';
|
|
1284
|
+
bridge.history = [];
|
|
1285
|
+
bridge.historyBytes = 0;
|
|
1286
|
+
closeWebTerminalBridgeClients(bridge);
|
|
1287
|
+
return;
|
|
544
1288
|
}
|
|
1289
|
+
const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
|
|
1290
|
+
const finalCode = exitInfo?.exitCode ?? (record.exitCode ?? 0);
|
|
1291
|
+
const finalStatus = finalCode === 0 ? 'exited' : 'failed';
|
|
1292
|
+
const updated = (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, {
|
|
1293
|
+
status: finalStatus,
|
|
1294
|
+
exitCode: finalCode,
|
|
1295
|
+
error: finalCode === 0 ? null : (record.error || `Exited with code ${finalCode}`),
|
|
1296
|
+
});
|
|
1297
|
+
broadcastWebTerminalMessage(bridge, {
|
|
1298
|
+
type: 'status',
|
|
1299
|
+
id: record.id,
|
|
1300
|
+
status: updated?.status ?? finalStatus,
|
|
1301
|
+
exitCode: updated?.exitCode ?? finalCode,
|
|
1302
|
+
});
|
|
545
1303
|
});
|
|
546
1304
|
return bridge;
|
|
547
1305
|
}
|
|
548
1306
|
function stopWebTerminalBridge(bridge) {
|
|
549
1307
|
if (!bridge.pty)
|
|
550
1308
|
return;
|
|
1309
|
+
bridge.stopRequested = true;
|
|
551
1310
|
try {
|
|
552
1311
|
bridge.pty.kill('SIGTERM');
|
|
553
1312
|
}
|
|
@@ -555,17 +1314,251 @@ function stopWebTerminalBridge(bridge) {
|
|
|
555
1314
|
// Best effort.
|
|
556
1315
|
}
|
|
557
1316
|
}
|
|
558
|
-
function
|
|
1317
|
+
async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
|
|
1318
|
+
const send = (payload) => {
|
|
1319
|
+
sendWebTerminalMessage(ws, payload);
|
|
1320
|
+
};
|
|
1321
|
+
const trimmedPrompt = prompt.trim();
|
|
1322
|
+
if (!trimmedPrompt) {
|
|
1323
|
+
send({ type: 'error', error: 'prompt is required' });
|
|
1324
|
+
return () => { };
|
|
1325
|
+
}
|
|
1326
|
+
send({ type: 'status', stage: 'runtime_setup', message: 'Checking container runtime...' });
|
|
1327
|
+
const config = (0, config_js_1.loadConfig)();
|
|
1328
|
+
const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
|
|
1329
|
+
if (!runtimeReady.ok) {
|
|
1330
|
+
send({
|
|
1331
|
+
type: 'error',
|
|
1332
|
+
code: 'runtime_unavailable',
|
|
1333
|
+
error: runtimeReady.error || 'Container runtime unavailable.',
|
|
1334
|
+
});
|
|
1335
|
+
return () => { };
|
|
1336
|
+
}
|
|
1337
|
+
const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
|
|
1338
|
+
if (!runtimeCheck.ok || !runtimeCheck.runtime) {
|
|
1339
|
+
send({
|
|
1340
|
+
type: 'error',
|
|
1341
|
+
code: 'runtime_unavailable',
|
|
1342
|
+
error: runtimeCheck.error || 'Container runtime unavailable.',
|
|
1343
|
+
});
|
|
1344
|
+
return () => { };
|
|
1345
|
+
}
|
|
1346
|
+
if (runtimeCheck.runtime !== 'apptainer') {
|
|
1347
|
+
send({
|
|
1348
|
+
type: 'error',
|
|
1349
|
+
code: 'runtime_unsupported',
|
|
1350
|
+
error: `Headless Claude chat currently supports Apptainer only (detected: ${runtimeCheck.runtime}).`,
|
|
1351
|
+
});
|
|
1352
|
+
return () => { };
|
|
1353
|
+
}
|
|
559
1354
|
try {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
1355
|
+
await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image, (stage, message) => {
|
|
1356
|
+
send({ type: 'status', stage, message });
|
|
1357
|
+
});
|
|
563
1358
|
}
|
|
564
|
-
catch {
|
|
565
|
-
|
|
566
|
-
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
send({
|
|
1361
|
+
type: 'error',
|
|
1362
|
+
code: 'image_prepare_failed',
|
|
1363
|
+
error: commandErrorDetail(err) || `Failed to prepare image ${config.image}.`,
|
|
1364
|
+
});
|
|
1365
|
+
return () => { };
|
|
567
1366
|
}
|
|
568
|
-
|
|
1367
|
+
try {
|
|
1368
|
+
await ensureWebTerminalAgentReady(runtimeCheck.runtime, config.image, 'claude', record.workdir, config.network.mode, (stage, message) => send({ type: 'status', stage, message }));
|
|
1369
|
+
}
|
|
1370
|
+
catch (err) {
|
|
1371
|
+
send({
|
|
1372
|
+
type: 'error',
|
|
1373
|
+
code: 'agent_prepare_failed',
|
|
1374
|
+
error: commandErrorDetail(err) || 'Failed to prepare Claude CLI in sandbox home.',
|
|
1375
|
+
});
|
|
1376
|
+
return () => { };
|
|
1377
|
+
}
|
|
1378
|
+
const args = buildClaudeHeadlessApptainerArgs(config, record.workdir, trimmedPrompt, resumeSessionId);
|
|
1379
|
+
send({ type: 'status', stage: 'run', message: 'Running Claude in headless mode...' });
|
|
1380
|
+
const child = (0, child_process_1.spawn)('apptainer', args, {
|
|
1381
|
+
cwd: record.workdir,
|
|
1382
|
+
env: process.env,
|
|
1383
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1384
|
+
});
|
|
1385
|
+
let stdoutBuffer = '';
|
|
1386
|
+
let stderrBuffer = '';
|
|
1387
|
+
let latestClaudeSessionId = resumeSessionId.trim();
|
|
1388
|
+
let emittedAssistantText = '';
|
|
1389
|
+
let doneSent = false;
|
|
1390
|
+
let syntheticToolUseSeq = 0;
|
|
1391
|
+
const sendDone = (exitCode) => {
|
|
1392
|
+
if (doneSent)
|
|
1393
|
+
return;
|
|
1394
|
+
doneSent = true;
|
|
1395
|
+
send({
|
|
1396
|
+
type: 'done',
|
|
1397
|
+
exitCode,
|
|
1398
|
+
sessionId: latestClaudeSessionId || null,
|
|
1399
|
+
isError: exitCode !== 0,
|
|
1400
|
+
});
|
|
1401
|
+
try {
|
|
1402
|
+
ws.close();
|
|
1403
|
+
}
|
|
1404
|
+
catch {
|
|
1405
|
+
// Best effort.
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
child.stdout.on('data', (chunk) => {
|
|
1409
|
+
stdoutBuffer += chunk.toString('utf-8');
|
|
1410
|
+
while (true) {
|
|
1411
|
+
const idx = stdoutBuffer.indexOf('\n');
|
|
1412
|
+
if (idx < 0)
|
|
1413
|
+
break;
|
|
1414
|
+
const line = stdoutBuffer.slice(0, idx).trim();
|
|
1415
|
+
stdoutBuffer = stdoutBuffer.slice(idx + 1);
|
|
1416
|
+
if (!line)
|
|
1417
|
+
continue;
|
|
1418
|
+
const event = parseJsonObjectLine(line);
|
|
1419
|
+
if (!event)
|
|
1420
|
+
continue;
|
|
1421
|
+
const sessionId = extractClaudeStreamSessionId(event);
|
|
1422
|
+
if (sessionId && sessionId !== latestClaudeSessionId) {
|
|
1423
|
+
latestClaudeSessionId = sessionId;
|
|
1424
|
+
send({ type: 'session', sessionId });
|
|
1425
|
+
}
|
|
1426
|
+
const snapshot = extractClaudeAssistantSnapshot(event);
|
|
1427
|
+
if (snapshot) {
|
|
1428
|
+
let delta = '';
|
|
1429
|
+
if (snapshot.startsWith(emittedAssistantText)) {
|
|
1430
|
+
delta = snapshot.slice(emittedAssistantText.length);
|
|
1431
|
+
emittedAssistantText = snapshot;
|
|
1432
|
+
}
|
|
1433
|
+
else if (!emittedAssistantText.startsWith(snapshot)) {
|
|
1434
|
+
delta = snapshot;
|
|
1435
|
+
emittedAssistantText = snapshot;
|
|
1436
|
+
}
|
|
1437
|
+
if (delta) {
|
|
1438
|
+
send({ type: 'delta', text: delta });
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
// Forward tool_use events from assistant messages
|
|
1442
|
+
const eventType = readRecordString(event, 'type').trim().toLowerCase();
|
|
1443
|
+
if (eventType === 'assistant') {
|
|
1444
|
+
const msgContent = event.message?.content;
|
|
1445
|
+
if (Array.isArray(msgContent)) {
|
|
1446
|
+
for (const block of msgContent) {
|
|
1447
|
+
if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_use') {
|
|
1448
|
+
const toolBlock = block;
|
|
1449
|
+
const toolName = String(toolBlock.name || 'tool');
|
|
1450
|
+
const detail = extractToolDetailFromToolUseBlock(toolBlock);
|
|
1451
|
+
const toolUseId = normalizeToolUseId(toolBlock.id) || `tool-${Date.now().toString(36)}-${(++syntheticToolUseSeq).toString(36)}`;
|
|
1452
|
+
// Intercept display_widget calls and forward rich content payload
|
|
1453
|
+
if (toolName === 'display_widget') {
|
|
1454
|
+
const input = toolBlock.input;
|
|
1455
|
+
if (input && typeof input.widget === 'string') {
|
|
1456
|
+
send({
|
|
1457
|
+
type: 'rich_content',
|
|
1458
|
+
widget: String(input.widget),
|
|
1459
|
+
title: input.title ? String(input.title) : undefined,
|
|
1460
|
+
data: (input.data && typeof input.data === 'object') ? input.data : {},
|
|
1461
|
+
id: toolUseId,
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
// Always also send the normal tool_use card
|
|
1466
|
+
send({ type: 'tool_use', tool_use_id: toolUseId, name: toolName, detail });
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
// Forward tool_result events (indicates tool execution completed)
|
|
1472
|
+
if (eventType === 'user') {
|
|
1473
|
+
const msgContent = event.message?.content;
|
|
1474
|
+
if (Array.isArray(msgContent)) {
|
|
1475
|
+
for (const block of msgContent) {
|
|
1476
|
+
if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_result') {
|
|
1477
|
+
const resultBlock = block;
|
|
1478
|
+
const toolUseId = normalizeToolUseId(resultBlock.tool_use_id);
|
|
1479
|
+
send({
|
|
1480
|
+
type: 'tool_result',
|
|
1481
|
+
tool_use_id: toolUseId || undefined,
|
|
1482
|
+
is_error: !!resultBlock.is_error,
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
if (isClaudeAuthenticationFailure(event, snapshot, stderrBuffer)) {
|
|
1489
|
+
send({
|
|
1490
|
+
type: 'auth_required',
|
|
1491
|
+
error: 'Claude authentication is required. Run /login in raw terminal mode to refresh session.',
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
child.stderr.on('data', (chunk) => {
|
|
1497
|
+
const text = chunk.toString('utf-8');
|
|
1498
|
+
stderrBuffer = (stderrBuffer + text).slice(-CLAUDE_HEADLESS_STDERR_LIMIT);
|
|
1499
|
+
});
|
|
1500
|
+
child.on('error', (err) => {
|
|
1501
|
+
send({
|
|
1502
|
+
type: 'error',
|
|
1503
|
+
code: 'spawn_failed',
|
|
1504
|
+
error: err.message || String(err),
|
|
1505
|
+
});
|
|
1506
|
+
sendDone(1);
|
|
1507
|
+
});
|
|
1508
|
+
child.on('close', (code) => {
|
|
1509
|
+
const remaining = stdoutBuffer.trim();
|
|
1510
|
+
if (remaining) {
|
|
1511
|
+
const event = parseJsonObjectLine(remaining);
|
|
1512
|
+
if (event) {
|
|
1513
|
+
const sessionId = extractClaudeStreamSessionId(event);
|
|
1514
|
+
if (sessionId && sessionId !== latestClaudeSessionId) {
|
|
1515
|
+
latestClaudeSessionId = sessionId;
|
|
1516
|
+
send({ type: 'session', sessionId });
|
|
1517
|
+
}
|
|
1518
|
+
const snapshot = extractClaudeAssistantSnapshot(event);
|
|
1519
|
+
if (snapshot) {
|
|
1520
|
+
if (snapshot.startsWith(emittedAssistantText)) {
|
|
1521
|
+
const delta = snapshot.slice(emittedAssistantText.length);
|
|
1522
|
+
emittedAssistantText = snapshot;
|
|
1523
|
+
if (delta)
|
|
1524
|
+
send({ type: 'delta', text: delta });
|
|
1525
|
+
}
|
|
1526
|
+
else if (!emittedAssistantText.startsWith(snapshot)) {
|
|
1527
|
+
emittedAssistantText = snapshot;
|
|
1528
|
+
send({ type: 'delta', text: snapshot });
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const exitCode = typeof code === 'number' ? code : 1;
|
|
1534
|
+
if (exitCode !== 0) {
|
|
1535
|
+
const detail = stderrBuffer.trim();
|
|
1536
|
+
if (detail) {
|
|
1537
|
+
send({ type: 'error', code: 'claude_failed', error: detail });
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
sendDone(exitCode);
|
|
1541
|
+
});
|
|
1542
|
+
return () => {
|
|
1543
|
+
try {
|
|
1544
|
+
child.kill('SIGTERM');
|
|
1545
|
+
}
|
|
1546
|
+
catch {
|
|
1547
|
+
// Best effort.
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
function serveHTML(res) {
|
|
1552
|
+
try {
|
|
1553
|
+
const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8').replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN);
|
|
1554
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1555
|
+
res.end(html);
|
|
1556
|
+
}
|
|
1557
|
+
catch {
|
|
1558
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1559
|
+
res.end('Could not load UI HTML');
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
569
1562
|
function handleGetConfig(_req, res) {
|
|
570
1563
|
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
571
1564
|
const response = { ...effective.config };
|
|
@@ -1334,6 +2327,39 @@ function findProjectJsonlFiles(agent) {
|
|
|
1334
2327
|
/**
|
|
1335
2328
|
* Extract a human-readable detail string from a JSONL entry's tool_use blocks.
|
|
1336
2329
|
*/
|
|
2330
|
+
function extractToolDetailFromToolUseBlock(block) {
|
|
2331
|
+
const name = String(block.name || '');
|
|
2332
|
+
const inputRaw = block.input;
|
|
2333
|
+
const input = inputRaw && typeof inputRaw === 'object' && !Array.isArray(inputRaw)
|
|
2334
|
+
? inputRaw
|
|
2335
|
+
: {};
|
|
2336
|
+
if (name === 'Bash' || name === 'bash') {
|
|
2337
|
+
const cmd = String(input.command || '').slice(0, 60);
|
|
2338
|
+
return cmd ? `Ran \`${cmd}\`` : 'Running Bash';
|
|
2339
|
+
}
|
|
2340
|
+
if (name === 'Edit' || name === 'edit') {
|
|
2341
|
+
const file = String(input.file_path || '').split('/').pop() || '';
|
|
2342
|
+
return file ? `Edited ${file}` : 'Editing a file';
|
|
2343
|
+
}
|
|
2344
|
+
if (name === 'Read' || name === 'read') {
|
|
2345
|
+
const file = String(input.file_path || '').split('/').pop() || '';
|
|
2346
|
+
return file ? `Read ${file}` : 'Reading a file';
|
|
2347
|
+
}
|
|
2348
|
+
if (name === 'Write' || name === 'write') {
|
|
2349
|
+
const file = String(input.file_path || '').split('/').pop() || '';
|
|
2350
|
+
return file ? `Wrote ${file}` : 'Writing a file';
|
|
2351
|
+
}
|
|
2352
|
+
if (name === 'Grep' || name === 'grep') {
|
|
2353
|
+
return `Searching for "${String(input.pattern || '').slice(0, 40)}"`;
|
|
2354
|
+
}
|
|
2355
|
+
if (name === 'Glob' || name === 'glob') {
|
|
2356
|
+
return `Finding files: ${String(input.pattern || '').slice(0, 40)}`;
|
|
2357
|
+
}
|
|
2358
|
+
if (name === 'Task' || name === 'task') {
|
|
2359
|
+
return 'Spawned subagent';
|
|
2360
|
+
}
|
|
2361
|
+
return `Using ${name}`;
|
|
2362
|
+
}
|
|
1337
2363
|
function extractToolDetail(entry) {
|
|
1338
2364
|
if (!entry.message?.content)
|
|
1339
2365
|
return '';
|
|
@@ -1341,35 +2367,8 @@ function extractToolDetail(entry) {
|
|
|
1341
2367
|
if (!Array.isArray(content))
|
|
1342
2368
|
return '';
|
|
1343
2369
|
for (const block of content) {
|
|
1344
|
-
if (block.type === 'tool_use') {
|
|
1345
|
-
|
|
1346
|
-
const input = block.input || {};
|
|
1347
|
-
if (name === 'Bash' || name === 'bash') {
|
|
1348
|
-
const cmd = (input.command || '').slice(0, 60);
|
|
1349
|
-
return cmd ? `Ran \`${cmd}\`` : `Running Bash`;
|
|
1350
|
-
}
|
|
1351
|
-
if (name === 'Edit' || name === 'edit') {
|
|
1352
|
-
const file = (input.file_path || '').split('/').pop() || '';
|
|
1353
|
-
return file ? `Edited ${file}` : 'Editing a file';
|
|
1354
|
-
}
|
|
1355
|
-
if (name === 'Read' || name === 'read') {
|
|
1356
|
-
const file = (input.file_path || '').split('/').pop() || '';
|
|
1357
|
-
return file ? `Read ${file}` : 'Reading a file';
|
|
1358
|
-
}
|
|
1359
|
-
if (name === 'Write' || name === 'write') {
|
|
1360
|
-
const file = (input.file_path || '').split('/').pop() || '';
|
|
1361
|
-
return file ? `Wrote ${file}` : 'Writing a file';
|
|
1362
|
-
}
|
|
1363
|
-
if (name === 'Grep' || name === 'grep') {
|
|
1364
|
-
return `Searching for "${(input.pattern || '').slice(0, 40)}"`;
|
|
1365
|
-
}
|
|
1366
|
-
if (name === 'Glob' || name === 'glob') {
|
|
1367
|
-
return `Finding files: ${(input.pattern || '').slice(0, 40)}`;
|
|
1368
|
-
}
|
|
1369
|
-
if (name === 'Task' || name === 'task') {
|
|
1370
|
-
return `Spawned subagent`;
|
|
1371
|
-
}
|
|
1372
|
-
return `Using ${name}`;
|
|
2370
|
+
if (block && typeof block === 'object' && !Array.isArray(block) && block.type === 'tool_use') {
|
|
2371
|
+
return extractToolDetailFromToolUseBlock(block);
|
|
1373
2372
|
}
|
|
1374
2373
|
}
|
|
1375
2374
|
return '';
|
|
@@ -1915,55 +2914,209 @@ async function handlePostWebTerminalStart(req, res) {
|
|
|
1915
2914
|
json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
|
|
1916
2915
|
return;
|
|
1917
2916
|
}
|
|
1918
|
-
const
|
|
1919
|
-
if (!
|
|
1920
|
-
json(res,
|
|
2917
|
+
const result = await startWebTerminalSession(agent, resolvedWorkdir);
|
|
2918
|
+
if (!result.ok) {
|
|
2919
|
+
json(res, result.body, result.status);
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
json(res, { ok: true, session: result.session });
|
|
2923
|
+
}
|
|
2924
|
+
catch (err) {
|
|
2925
|
+
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
async function runWebTerminalInitJob(id) {
|
|
2929
|
+
const job = webTerminalInitJobs.get(id);
|
|
2930
|
+
if (!job)
|
|
2931
|
+
return;
|
|
2932
|
+
const progress = (stage, message) => {
|
|
2933
|
+
updateWebTerminalInitJob(id, {
|
|
2934
|
+
status: 'running',
|
|
2935
|
+
stage,
|
|
2936
|
+
message,
|
|
2937
|
+
});
|
|
2938
|
+
};
|
|
2939
|
+
try {
|
|
2940
|
+
const result = await startWebTerminalSession(job.agent, job.workdir, {
|
|
2941
|
+
prewarmImage: true,
|
|
2942
|
+
prewarmAgent: true,
|
|
2943
|
+
onProgress: progress,
|
|
2944
|
+
});
|
|
2945
|
+
if (result.ok) {
|
|
2946
|
+
updateWebTerminalInitJob(id, {
|
|
2947
|
+
status: 'ready',
|
|
2948
|
+
stage: 'ready',
|
|
2949
|
+
message: 'Session ready.',
|
|
2950
|
+
session: result.session,
|
|
2951
|
+
error: null,
|
|
2952
|
+
code: null,
|
|
2953
|
+
phase: null,
|
|
2954
|
+
});
|
|
2955
|
+
pruneWebTerminalInitJobs();
|
|
1921
2956
|
return;
|
|
1922
2957
|
}
|
|
1923
|
-
const
|
|
1924
|
-
const
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2958
|
+
const body = result.body || {};
|
|
2959
|
+
const errText = String(body.error || 'Session initialization failed.');
|
|
2960
|
+
updateWebTerminalInitJob(id, {
|
|
2961
|
+
status: 'failed',
|
|
2962
|
+
stage: 'failed',
|
|
2963
|
+
message: errText.split('\n')[0] || 'Session initialization failed.',
|
|
2964
|
+
session: null,
|
|
2965
|
+
error: errText,
|
|
2966
|
+
code: typeof body.code === 'string' ? body.code : null,
|
|
2967
|
+
phase: typeof body.phase === 'string' ? body.phase : null,
|
|
2968
|
+
initialized: body.initialized === true,
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
catch (err) {
|
|
2972
|
+
const detail = commandErrorDetail(err) || (err?.message ?? String(err));
|
|
2973
|
+
updateWebTerminalInitJob(id, {
|
|
2974
|
+
status: 'failed',
|
|
2975
|
+
stage: 'failed',
|
|
2976
|
+
message: 'Session initialization failed.',
|
|
2977
|
+
session: null,
|
|
2978
|
+
error: detail,
|
|
2979
|
+
code: 'init_failed',
|
|
2980
|
+
phase: 'init',
|
|
2981
|
+
initialized: false,
|
|
1929
2982
|
});
|
|
1930
|
-
|
|
2983
|
+
}
|
|
2984
|
+
finally {
|
|
2985
|
+
pruneWebTerminalInitJobs();
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
async function handlePostWebTerminalInit(req, res) {
|
|
2989
|
+
try {
|
|
2990
|
+
const body = await readBody(req);
|
|
2991
|
+
const parsed = JSON.parse(body || '{}');
|
|
2992
|
+
const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
|
|
2993
|
+
const rawWorkdir = String(parsed.workdir || '').trim();
|
|
2994
|
+
if (!agent) {
|
|
2995
|
+
json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
if (!rawWorkdir) {
|
|
2999
|
+
json(res, { ok: false, error: 'workdir is required' }, 400);
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
const resolvedWorkdir = (0, path_1.resolve)(rawWorkdir.replace(/^~/, (0, os_1.homedir)()));
|
|
3003
|
+
if (!(0, fs_1.existsSync)(resolvedWorkdir)) {
|
|
3004
|
+
json(res, { ok: false, error: `workdir does not exist: ${resolvedWorkdir}` }, 400);
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
let st;
|
|
3008
|
+
try {
|
|
3009
|
+
st = (0, fs_1.statSync)(resolvedWorkdir);
|
|
3010
|
+
}
|
|
3011
|
+
catch {
|
|
3012
|
+
json(res, { ok: false, error: `Could not access workdir: ${resolvedWorkdir}` }, 400);
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
if (!st.isDirectory()) {
|
|
3016
|
+
json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
pruneWebTerminalInitJobs();
|
|
3020
|
+
const job = createWebTerminalInitJob(agent, resolvedWorkdir);
|
|
3021
|
+
webTerminalInitJobs.set(job.id, job);
|
|
3022
|
+
void runWebTerminalInitJob(job.id);
|
|
3023
|
+
json(res, { ok: true, init: serializeWebTerminalInitJob(job) }, 202);
|
|
3024
|
+
}
|
|
3025
|
+
catch (err) {
|
|
3026
|
+
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
async function handlePostWebTerminalAgentUpdate(req, res) {
|
|
3030
|
+
try {
|
|
3031
|
+
const body = await readBody(req);
|
|
3032
|
+
const parsed = JSON.parse(body || '{}');
|
|
3033
|
+
const agent = normalizeWebTerminalAgent(parsed.agent || '');
|
|
3034
|
+
if (!agent) {
|
|
3035
|
+
json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
const config = (0, config_js_1.loadConfig)();
|
|
3039
|
+
const runtimeReady = await prepareRuntimeForWebTerminal(config.runtime);
|
|
3040
|
+
if (!runtimeReady.ok) {
|
|
3041
|
+
json(res, {
|
|
3042
|
+
ok: false,
|
|
3043
|
+
code: 'runtime_unavailable',
|
|
3044
|
+
phase: 'runtime_setup',
|
|
3045
|
+
initialized: runtimeReady.initialized,
|
|
3046
|
+
error: runtimeReady.error || 'Container runtime unavailable.',
|
|
3047
|
+
}, runtimeReady.initialized ? 502 : 503);
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime);
|
|
3051
|
+
if (!runtimeCheck.ok || !runtimeCheck.runtime) {
|
|
3052
|
+
json(res, {
|
|
3053
|
+
ok: false,
|
|
3054
|
+
code: 'runtime_unavailable',
|
|
3055
|
+
phase: 'runtime_setup',
|
|
3056
|
+
initialized: runtimeReady.initialized,
|
|
3057
|
+
error: runtimeCheck.error || 'Container runtime unavailable.',
|
|
3058
|
+
}, runtimeReady.initialized ? 502 : 503);
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
1931
3061
|
try {
|
|
1932
|
-
await (
|
|
1933
|
-
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
|
|
3062
|
+
await ensureWebTerminalImageReady(runtimeCheck.runtime, config.image);
|
|
1934
3063
|
}
|
|
1935
3064
|
catch (err) {
|
|
1936
|
-
const
|
|
1937
|
-
(
|
|
1938
|
-
|
|
3065
|
+
const detail = commandErrorDetail(err);
|
|
3066
|
+
json(res, {
|
|
3067
|
+
ok: false,
|
|
3068
|
+
code: 'image_prepare_failed',
|
|
3069
|
+
phase: 'image_prepare',
|
|
3070
|
+
runtime: runtimeCheck.runtime,
|
|
3071
|
+
image: config.image,
|
|
3072
|
+
error: detail || `Failed to pull image ${config.image}.`,
|
|
3073
|
+
}, 502);
|
|
1939
3074
|
return;
|
|
1940
3075
|
}
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
3076
|
+
try {
|
|
3077
|
+
const updated = await updateWebTerminalAgentCli(runtimeCheck.runtime, config.image, agent, (0, os_1.homedir)(), config.network.mode);
|
|
3078
|
+
const spec = getWebTerminalAgentBootstrapSpec(agent);
|
|
3079
|
+
json(res, {
|
|
3080
|
+
ok: true,
|
|
3081
|
+
agent,
|
|
3082
|
+
pkg: spec.pkg,
|
|
3083
|
+
runtime: runtimeCheck.runtime,
|
|
3084
|
+
image: config.image,
|
|
3085
|
+
version: updated.version,
|
|
3086
|
+
restartNotice: `Restart active ${agent} sessions to use the updated CLI.`,
|
|
1951
3087
|
});
|
|
3088
|
+
}
|
|
3089
|
+
catch (err) {
|
|
3090
|
+
const detail = commandErrorDetail(err);
|
|
1952
3091
|
json(res, {
|
|
1953
3092
|
ok: false,
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
3093
|
+
code: 'agent_update_failed',
|
|
3094
|
+
phase: 'agent_prepare',
|
|
3095
|
+
agent,
|
|
3096
|
+
runtime: runtimeCheck.runtime,
|
|
3097
|
+
image: config.image,
|
|
3098
|
+
error: detail || `Failed to update ${agent} CLI.`,
|
|
3099
|
+
}, 502);
|
|
1957
3100
|
}
|
|
1958
|
-
json(res, {
|
|
1959
|
-
ok: true,
|
|
1960
|
-
session: serializeWebTerminalSession(record),
|
|
1961
|
-
});
|
|
1962
3101
|
}
|
|
1963
3102
|
catch (err) {
|
|
1964
3103
|
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
1965
3104
|
}
|
|
1966
3105
|
}
|
|
3106
|
+
async function handleGetWebTerminalInit(reqUrl, res) {
|
|
3107
|
+
pruneWebTerminalInitJobs();
|
|
3108
|
+
const id = String(reqUrl.searchParams.get('id') || '').trim();
|
|
3109
|
+
if (!id) {
|
|
3110
|
+
json(res, { ok: false, error: 'id is required' }, 400);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
const job = webTerminalInitJobs.get(id);
|
|
3114
|
+
if (!job) {
|
|
3115
|
+
json(res, { ok: false, error: 'Initialization job not found' }, 404);
|
|
3116
|
+
return;
|
|
3117
|
+
}
|
|
3118
|
+
json(res, { ok: true, init: serializeWebTerminalInitJob(job) });
|
|
3119
|
+
}
|
|
1967
3120
|
async function handleGetWebTerminalSessions(res) {
|
|
1968
3121
|
const records = (0, web_terminal_js_1.listWebTerminalRecords)();
|
|
1969
3122
|
const localNode = (0, os_1.hostname)();
|
|
@@ -1990,6 +3143,82 @@ async function handleGetWebTerminalSessions(res) {
|
|
|
1990
3143
|
const sessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
|
|
1991
3144
|
json(res, { ok: true, sessions });
|
|
1992
3145
|
}
|
|
3146
|
+
async function handleGetWebTerminalHistory(reqUrl, res) {
|
|
3147
|
+
const id = String(reqUrl.searchParams.get('id') || '').trim();
|
|
3148
|
+
if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
|
|
3149
|
+
json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
|
|
3153
|
+
if (!record) {
|
|
3154
|
+
json(res, { ok: false, error: 'Terminal session not found' }, 404);
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
if (record.node !== (0, os_1.hostname)()) {
|
|
3158
|
+
json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
const beforeRaw = String(reqUrl.searchParams.get('before') || '').trim();
|
|
3162
|
+
let beforeSeq = null;
|
|
3163
|
+
if (beforeRaw) {
|
|
3164
|
+
const parsedBefore = Number(beforeRaw);
|
|
3165
|
+
if (!Number.isFinite(parsedBefore)) {
|
|
3166
|
+
json(res, { ok: false, error: 'Invalid before sequence number' }, 400);
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
beforeSeq = Math.max(0, Math.floor(parsedBefore));
|
|
3170
|
+
}
|
|
3171
|
+
const limitRaw = String(reqUrl.searchParams.get('limit') || '').trim();
|
|
3172
|
+
let limit = WEB_TERMINAL_HISTORY_PAGE_DEFAULT;
|
|
3173
|
+
if (limitRaw) {
|
|
3174
|
+
const parsedLimit = Number(limitRaw);
|
|
3175
|
+
if (!Number.isFinite(parsedLimit)) {
|
|
3176
|
+
json(res, { ok: false, error: 'Invalid history limit' }, 400);
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
limit = Math.max(1, Math.min(WEB_TERMINAL_HISTORY_PAGE_MAX, Math.floor(parsedLimit)));
|
|
3180
|
+
}
|
|
3181
|
+
const bridge = await ensureWebTerminalBridge(record);
|
|
3182
|
+
if (!bridge) {
|
|
3183
|
+
json(res, { ok: false, error: 'Could not open terminal bridge' }, 500);
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
const page = getWebTerminalHistoryPage(bridge, { beforeSeq, limit });
|
|
3187
|
+
json(res, {
|
|
3188
|
+
ok: true,
|
|
3189
|
+
id: record.id,
|
|
3190
|
+
history: {
|
|
3191
|
+
...page,
|
|
3192
|
+
limit,
|
|
3193
|
+
},
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
3196
|
+
async function handlePostWebTerminalRename(req, res) {
|
|
3197
|
+
try {
|
|
3198
|
+
const body = await readBody(req);
|
|
3199
|
+
const parsed = JSON.parse(body || '{}');
|
|
3200
|
+
const id = String(parsed.id || '').trim();
|
|
3201
|
+
const name = typeof parsed.name === 'string' ? parsed.name.trim() : '';
|
|
3202
|
+
if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
|
|
3203
|
+
json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
if (name && !(0, web_terminal_js_1.isValidWebTerminalName)(name)) {
|
|
3207
|
+
json(res, { ok: false, error: 'Invalid session name format' }, 400);
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
const result = (0, web_terminal_js_1.renameWebTerminalRecord)(id, name);
|
|
3211
|
+
if (!result.ok) {
|
|
3212
|
+
const status = result.code === 'name_taken' ? 409 : result.code === 'not_found' ? 404 : 400;
|
|
3213
|
+
json(res, { ok: false, error: result.error, code: result.code }, status);
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
json(res, { ok: true, session: serializeWebTerminalSession(result.record) });
|
|
3217
|
+
}
|
|
3218
|
+
catch (err) {
|
|
3219
|
+
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
1993
3222
|
async function handlePostWebTerminalStop(req, res) {
|
|
1994
3223
|
try {
|
|
1995
3224
|
const body = await readBody(req);
|
|
@@ -2066,8 +3295,15 @@ async function handleValidatePath(req, res) {
|
|
|
2066
3295
|
async function handleBrowseDir(req, res) {
|
|
2067
3296
|
try {
|
|
2068
3297
|
const body = await readBody(req);
|
|
2069
|
-
const
|
|
2070
|
-
const
|
|
3298
|
+
const parsed = JSON.parse(body);
|
|
3299
|
+
const rawPath = typeof parsed.path === 'string' ? parsed.path : '~';
|
|
3300
|
+
const includeFiles = !!parsed.includeFiles;
|
|
3301
|
+
const includeHidden = !!parsed.includeHidden;
|
|
3302
|
+
const rawLimit = Number(parsed.maxEntries);
|
|
3303
|
+
const maxEntries = Number.isFinite(rawLimit)
|
|
3304
|
+
? Math.max(100, Math.min(5000, Math.floor(rawLimit)))
|
|
3305
|
+
: 2000;
|
|
3306
|
+
const resolved = rawPath.replace(/^~/, (0, os_1.homedir)());
|
|
2071
3307
|
if (!(0, fs_1.existsSync)(resolved) || !(0, fs_1.statSync)(resolved).isDirectory()) {
|
|
2072
3308
|
json(res, { ok: false, error: 'Not a directory', path: resolved });
|
|
2073
3309
|
return;
|
|
@@ -2081,19 +3317,41 @@ async function handleBrowseDir(req, res) {
|
|
|
2081
3317
|
return;
|
|
2082
3318
|
}
|
|
2083
3319
|
const dirs = [];
|
|
3320
|
+
const files = [];
|
|
3321
|
+
let truncated = false;
|
|
2084
3322
|
for (const entry of entries) {
|
|
2085
|
-
if (entry.startsWith('.'))
|
|
2086
|
-
continue; // skip dotfiles
|
|
3323
|
+
if (!includeHidden && entry.startsWith('.'))
|
|
3324
|
+
continue; // skip dotfiles by default
|
|
2087
3325
|
const full = (0, path_1.join)(resolved, entry);
|
|
2088
3326
|
try {
|
|
2089
|
-
|
|
3327
|
+
const st = (0, fs_1.statSync)(full);
|
|
3328
|
+
if (st.isDirectory()) {
|
|
2090
3329
|
dirs.push({ name: entry, path: full });
|
|
2091
3330
|
}
|
|
3331
|
+
else if (includeFiles && st.isFile()) {
|
|
3332
|
+
files.push({ name: entry, path: full });
|
|
3333
|
+
}
|
|
2092
3334
|
}
|
|
2093
3335
|
catch { /* skip inaccessible */ }
|
|
3336
|
+
if ((dirs.length + files.length) >= maxEntries) {
|
|
3337
|
+
truncated = true;
|
|
3338
|
+
break;
|
|
3339
|
+
}
|
|
2094
3340
|
}
|
|
2095
3341
|
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
2096
|
-
|
|
3342
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
3343
|
+
const entriesOut = [
|
|
3344
|
+
...dirs.map((d) => ({ name: d.name, path: d.path, type: 'dir' })),
|
|
3345
|
+
...files.map((f) => ({ name: f.name, path: f.path, type: 'file' })),
|
|
3346
|
+
];
|
|
3347
|
+
json(res, {
|
|
3348
|
+
ok: true,
|
|
3349
|
+
path: resolved,
|
|
3350
|
+
dirs,
|
|
3351
|
+
files: includeFiles ? files : undefined,
|
|
3352
|
+
entries: includeFiles ? entriesOut : undefined,
|
|
3353
|
+
truncated,
|
|
3354
|
+
});
|
|
2097
3355
|
}
|
|
2098
3356
|
catch (err) {
|
|
2099
3357
|
json(res, { ok: false, error: err.message ?? String(err) }, 400);
|
|
@@ -2413,6 +3671,10 @@ function mapContainerPathToHost(path, sandboxHome) {
|
|
|
2413
3671
|
return (0, config_js_1.getConfigPath)();
|
|
2414
3672
|
if (path === '/labgate-config/slurm.db')
|
|
2415
3673
|
return (0, config_js_1.getSlurmDbPath)();
|
|
3674
|
+
if (path === '/labgate-config/results.json')
|
|
3675
|
+
return (0, config_js_1.getResultsDbPath)();
|
|
3676
|
+
if (path === '/labgate-config/display.json')
|
|
3677
|
+
return (0, config_js_1.getDisplayDbPath)();
|
|
2416
3678
|
return path;
|
|
2417
3679
|
}
|
|
2418
3680
|
function readMcpConfigData() {
|
|
@@ -2585,7 +3847,7 @@ function collectMcpState() {
|
|
|
2585
3847
|
env: resultsEntry?.env || null,
|
|
2586
3848
|
mcpConfigPath,
|
|
2587
3849
|
serverPath: resolveServerPathFromEntry(resultsEntry, sandboxHome),
|
|
2588
|
-
dbPath:
|
|
3850
|
+
dbPath: resolveDbPathFromEntry(resultsEntry, sandboxHome),
|
|
2589
3851
|
tools: [
|
|
2590
3852
|
{ name: 'list_results', title: 'List Results', description: 'List recorded results with filtering and pagination' },
|
|
2591
3853
|
{ name: 'register_result', title: 'Register Result', description: 'Create a new structured result entry' },
|
|
@@ -3168,45 +4430,210 @@ function handleGetSlurmStats(res) {
|
|
|
3168
4430
|
}
|
|
3169
4431
|
// ── SSE: Server-Sent Events for real-time dashboard updates ──
|
|
3170
4432
|
const sseClients = new Set();
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
4433
|
+
const RESULTS_WATCH_DEBOUNCE_MS = 120;
|
|
4434
|
+
let lastResultsSignature = getResultsFileSignature();
|
|
4435
|
+
let resultsWatcher = null;
|
|
4436
|
+
let resultsWatchDebounce = null;
|
|
4437
|
+
function getResultsFileSignature() {
|
|
4438
|
+
const resultsPath = (0, config_js_1.getResultsDbPath)();
|
|
4439
|
+
try {
|
|
4440
|
+
if (!(0, fs_1.existsSync)(resultsPath))
|
|
4441
|
+
return 'missing';
|
|
4442
|
+
const st = (0, fs_1.statSync)(resultsPath);
|
|
4443
|
+
return `${st.size}:${Math.floor(st.mtimeMs)}`;
|
|
4444
|
+
}
|
|
4445
|
+
catch {
|
|
4446
|
+
return 'error';
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
function maybeBroadcastResultsChanged() {
|
|
4450
|
+
const signature = getResultsFileSignature();
|
|
4451
|
+
if (signature === lastResultsSignature)
|
|
4452
|
+
return;
|
|
4453
|
+
if (sseClients.size === 0)
|
|
4454
|
+
return;
|
|
4455
|
+
lastResultsSignature = signature;
|
|
4456
|
+
broadcastSSE('results_changed', {
|
|
4457
|
+
changed_at: new Date().toISOString(),
|
|
4458
|
+
signature,
|
|
3177
4459
|
});
|
|
3178
|
-
res.write(':\n\n'); // comment to establish connection
|
|
3179
|
-
sseClients.add(res);
|
|
3180
|
-
res.on('close', () => sseClients.delete(res));
|
|
3181
4460
|
}
|
|
3182
|
-
function
|
|
3183
|
-
|
|
3184
|
-
|
|
4461
|
+
function scheduleResultsChangeCheck(delayMs = RESULTS_WATCH_DEBOUNCE_MS) {
|
|
4462
|
+
if (resultsWatchDebounce) {
|
|
4463
|
+
clearTimeout(resultsWatchDebounce);
|
|
4464
|
+
}
|
|
4465
|
+
resultsWatchDebounce = setTimeout(() => {
|
|
4466
|
+
resultsWatchDebounce = null;
|
|
4467
|
+
maybeBroadcastResultsChanged();
|
|
4468
|
+
}, delayMs);
|
|
4469
|
+
resultsWatchDebounce.unref?.();
|
|
4470
|
+
}
|
|
4471
|
+
function stopResultsWatcher() {
|
|
4472
|
+
if (resultsWatchDebounce) {
|
|
4473
|
+
clearTimeout(resultsWatchDebounce);
|
|
4474
|
+
resultsWatchDebounce = null;
|
|
4475
|
+
}
|
|
4476
|
+
if (resultsWatcher) {
|
|
3185
4477
|
try {
|
|
3186
|
-
|
|
4478
|
+
resultsWatcher.close();
|
|
3187
4479
|
}
|
|
3188
4480
|
catch {
|
|
3189
|
-
|
|
4481
|
+
// Best effort.
|
|
3190
4482
|
}
|
|
4483
|
+
resultsWatcher = null;
|
|
3191
4484
|
}
|
|
3192
4485
|
}
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
function startSSEBroadcast() {
|
|
3196
|
-
if (sseInterval)
|
|
4486
|
+
function startResultsWatcher() {
|
|
4487
|
+
if (resultsWatcher)
|
|
3197
4488
|
return;
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
4489
|
+
lastResultsSignature = getResultsFileSignature();
|
|
4490
|
+
const resultsPath = (0, config_js_1.getResultsDbPath)();
|
|
4491
|
+
const watchDir = (0, path_1.dirname)(resultsPath);
|
|
4492
|
+
const watchFile = (0, path_1.basename)(resultsPath);
|
|
4493
|
+
try {
|
|
4494
|
+
(0, config_js_1.ensurePrivateDir)(watchDir);
|
|
4495
|
+
}
|
|
4496
|
+
catch {
|
|
4497
|
+
// Best effort.
|
|
4498
|
+
}
|
|
4499
|
+
try {
|
|
4500
|
+
resultsWatcher = (0, fs_1.watch)(watchDir, (_eventType, filename) => {
|
|
4501
|
+
const changed = filename ? String(filename) : '';
|
|
4502
|
+
if (changed && changed !== watchFile)
|
|
4503
|
+
return;
|
|
4504
|
+
scheduleResultsChangeCheck();
|
|
4505
|
+
});
|
|
4506
|
+
resultsWatcher.on('error', () => {
|
|
4507
|
+
stopResultsWatcher();
|
|
4508
|
+
});
|
|
4509
|
+
}
|
|
4510
|
+
catch {
|
|
4511
|
+
resultsWatcher = null;
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
// ── Display (widgets) file watcher ──
|
|
4515
|
+
const DISPLAY_WATCH_DEBOUNCE_MS = 120;
|
|
4516
|
+
let lastDisplaySignature = getDisplayFileSignature();
|
|
4517
|
+
let displayWatcher = null;
|
|
4518
|
+
let displayWatchDebounce = null;
|
|
4519
|
+
function getDisplayFileSignature() {
|
|
4520
|
+
const displayPath = (0, config_js_1.getDisplayDbPath)();
|
|
4521
|
+
try {
|
|
4522
|
+
if (!(0, fs_1.existsSync)(displayPath))
|
|
4523
|
+
return 'missing';
|
|
4524
|
+
const st = (0, fs_1.statSync)(displayPath);
|
|
4525
|
+
return `${st.size}:${Math.floor(st.mtimeMs)}`;
|
|
4526
|
+
}
|
|
4527
|
+
catch {
|
|
4528
|
+
return 'error';
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
function maybeBroadcastWidgetsChanged() {
|
|
4532
|
+
const signature = getDisplayFileSignature();
|
|
4533
|
+
if (signature === lastDisplaySignature)
|
|
4534
|
+
return;
|
|
4535
|
+
if (sseClients.size === 0)
|
|
4536
|
+
return;
|
|
4537
|
+
lastDisplaySignature = signature;
|
|
4538
|
+
broadcastSSE('widgets_changed', {
|
|
4539
|
+
changed_at: new Date().toISOString(),
|
|
4540
|
+
signature,
|
|
4541
|
+
});
|
|
4542
|
+
}
|
|
4543
|
+
function scheduleDisplayChangeCheck(delayMs = DISPLAY_WATCH_DEBOUNCE_MS) {
|
|
4544
|
+
if (displayWatchDebounce) {
|
|
4545
|
+
clearTimeout(displayWatchDebounce);
|
|
4546
|
+
}
|
|
4547
|
+
displayWatchDebounce = setTimeout(() => {
|
|
4548
|
+
displayWatchDebounce = null;
|
|
4549
|
+
maybeBroadcastWidgetsChanged();
|
|
4550
|
+
}, delayMs);
|
|
4551
|
+
displayWatchDebounce.unref?.();
|
|
4552
|
+
}
|
|
4553
|
+
function stopDisplayWatcher() {
|
|
4554
|
+
if (displayWatchDebounce) {
|
|
4555
|
+
clearTimeout(displayWatchDebounce);
|
|
4556
|
+
displayWatchDebounce = null;
|
|
4557
|
+
}
|
|
4558
|
+
if (displayWatcher) {
|
|
4559
|
+
try {
|
|
4560
|
+
displayWatcher.close();
|
|
4561
|
+
}
|
|
4562
|
+
catch {
|
|
4563
|
+
// Best effort.
|
|
4564
|
+
}
|
|
4565
|
+
displayWatcher = null;
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
function startDisplayWatcher() {
|
|
4569
|
+
if (displayWatcher)
|
|
4570
|
+
return;
|
|
4571
|
+
lastDisplaySignature = getDisplayFileSignature();
|
|
4572
|
+
const displayPath = (0, config_js_1.getDisplayDbPath)();
|
|
4573
|
+
const watchDir = (0, path_1.dirname)(displayPath);
|
|
4574
|
+
const watchFile = (0, path_1.basename)(displayPath);
|
|
4575
|
+
try {
|
|
4576
|
+
(0, config_js_1.ensurePrivateDir)(watchDir);
|
|
4577
|
+
}
|
|
4578
|
+
catch {
|
|
4579
|
+
// Best effort.
|
|
4580
|
+
}
|
|
4581
|
+
try {
|
|
4582
|
+
displayWatcher = (0, fs_1.watch)(watchDir, (_eventType, filename) => {
|
|
4583
|
+
const changed = filename ? String(filename) : '';
|
|
4584
|
+
if (changed && changed !== watchFile)
|
|
4585
|
+
return;
|
|
4586
|
+
scheduleDisplayChangeCheck();
|
|
4587
|
+
});
|
|
4588
|
+
displayWatcher.on('error', () => {
|
|
4589
|
+
stopDisplayWatcher();
|
|
4590
|
+
});
|
|
4591
|
+
}
|
|
4592
|
+
catch {
|
|
4593
|
+
displayWatcher = null;
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
function handleSSE(_req, res) {
|
|
4597
|
+
res.writeHead(200, {
|
|
4598
|
+
'Content-Type': 'text/event-stream',
|
|
4599
|
+
'Cache-Control': 'no-cache',
|
|
4600
|
+
'Connection': 'keep-alive',
|
|
4601
|
+
'Access-Control-Allow-Origin': '*',
|
|
4602
|
+
});
|
|
4603
|
+
res.write(':\n\n'); // comment to establish connection
|
|
4604
|
+
sseClients.add(res);
|
|
4605
|
+
res.on('close', () => sseClients.delete(res));
|
|
4606
|
+
}
|
|
4607
|
+
function broadcastSSE(event, data) {
|
|
4608
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
4609
|
+
for (const client of sseClients) {
|
|
4610
|
+
try {
|
|
4611
|
+
client.write(payload);
|
|
4612
|
+
}
|
|
4613
|
+
catch {
|
|
4614
|
+
sseClients.delete(client);
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
// Push session updates to all connected SSE clients every 2 seconds
|
|
4619
|
+
let sseInterval = null;
|
|
4620
|
+
function startSSEBroadcast() {
|
|
4621
|
+
if (sseInterval)
|
|
4622
|
+
return;
|
|
4623
|
+
startResultsWatcher();
|
|
4624
|
+
startDisplayWatcher();
|
|
4625
|
+
sseInterval = setInterval(async () => {
|
|
4626
|
+
if (sseClients.size === 0)
|
|
4627
|
+
return;
|
|
4628
|
+
// Reuse handleGetSessions logic
|
|
4629
|
+
const dir = (0, config_js_1.getSessionsDir)();
|
|
4630
|
+
const sessions = [];
|
|
4631
|
+
if ((0, fs_1.existsSync)(dir)) {
|
|
4632
|
+
const localHost = (0, os_1.hostname)();
|
|
4633
|
+
const files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'));
|
|
4634
|
+
for (const file of files) {
|
|
4635
|
+
try {
|
|
4636
|
+
const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
|
|
3210
4637
|
if (data.node === localHost) {
|
|
3211
4638
|
try {
|
|
3212
4639
|
process.kill(data.pid, 0);
|
|
@@ -3271,6 +4698,8 @@ function startSSEBroadcast() {
|
|
|
3271
4698
|
}
|
|
3272
4699
|
catch { /* slurm DB unavailable */ }
|
|
3273
4700
|
}
|
|
4701
|
+
// Results changes may come from external MCP processes; emit only on file mutation.
|
|
4702
|
+
maybeBroadcastResultsChanged();
|
|
3274
4703
|
}, 2000);
|
|
3275
4704
|
sseInterval.unref?.();
|
|
3276
4705
|
}
|
|
@@ -3337,136 +4766,778 @@ function handleGetAdminPolicy(_req, res) {
|
|
|
3337
4766
|
json(res, { ok: true, policy, bootstrap: false });
|
|
3338
4767
|
return;
|
|
3339
4768
|
}
|
|
3340
|
-
if (context.bootstrapPolicySetup) {
|
|
4769
|
+
if (context.bootstrapPolicySetup) {
|
|
4770
|
+
json(res, {
|
|
4771
|
+
ok: true,
|
|
4772
|
+
bootstrap: true,
|
|
4773
|
+
policy: buildBootstrapPolicyTemplate(context),
|
|
4774
|
+
message: `No policy is configured yet. Saving this file will make "${context.currentUser}" the initial admin.`,
|
|
4775
|
+
});
|
|
4776
|
+
return;
|
|
4777
|
+
}
|
|
4778
|
+
json(res, { ok: false, error: 'No policy file found' }, 404);
|
|
4779
|
+
}
|
|
4780
|
+
async function handlePostAdminPolicy(req, res) {
|
|
4781
|
+
const context = requireAdmin(res, { allowBootstrapPolicySetup: true });
|
|
4782
|
+
if (!context)
|
|
4783
|
+
return;
|
|
4784
|
+
try {
|
|
4785
|
+
const body = await readBody(req);
|
|
4786
|
+
const incoming = JSON.parse(body);
|
|
4787
|
+
const errors = (0, policy_js_1.validatePolicy)(incoming);
|
|
4788
|
+
if (errors.length > 0) {
|
|
4789
|
+
json(res, { ok: false, errors }, 400);
|
|
4790
|
+
return;
|
|
4791
|
+
}
|
|
4792
|
+
let policy = incoming;
|
|
4793
|
+
let addedBootstrapAdmin = false;
|
|
4794
|
+
// First-time setup guard: ensure the bootstrap user cannot lock themselves out.
|
|
4795
|
+
if (context.bootstrapPolicySetup) {
|
|
4796
|
+
const names = Array.isArray(policy.admins?.usernames) ? policy.admins.usernames : [];
|
|
4797
|
+
const alreadyIncluded = names.includes(context.currentUser);
|
|
4798
|
+
policy = {
|
|
4799
|
+
...policy,
|
|
4800
|
+
admins: {
|
|
4801
|
+
usernames: [...new Set([context.currentUser, ...names])],
|
|
4802
|
+
},
|
|
4803
|
+
};
|
|
4804
|
+
addedBootstrapAdmin = !alreadyIncluded;
|
|
4805
|
+
}
|
|
4806
|
+
(0, policy_js_1.savePolicy)(policy);
|
|
4807
|
+
json(res, {
|
|
4808
|
+
ok: true,
|
|
4809
|
+
bootstrapCompleted: context.bootstrapPolicySetup,
|
|
4810
|
+
addedBootstrapAdmin,
|
|
4811
|
+
});
|
|
4812
|
+
}
|
|
4813
|
+
catch (err) {
|
|
4814
|
+
json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
|
|
4815
|
+
}
|
|
4816
|
+
}
|
|
4817
|
+
function handleGetAdminUsers(_req, res) {
|
|
4818
|
+
if (!requireAdmin(res))
|
|
4819
|
+
return;
|
|
4820
|
+
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
4821
|
+
const sharedDir = effective.sharedSessionsDir;
|
|
4822
|
+
// Collect sessions from shared dir or fall back to local sessions
|
|
4823
|
+
const sessionsDir = sharedDir && (0, fs_1.existsSync)(sharedDir) ? sharedDir : (0, config_js_1.getSessionsDir)();
|
|
4824
|
+
const sessions = [];
|
|
4825
|
+
if ((0, fs_1.existsSync)(sessionsDir)) {
|
|
4826
|
+
const files = (0, fs_1.readdirSync)(sessionsDir).filter((f) => f.endsWith('.json'));
|
|
4827
|
+
for (const f of files) {
|
|
4828
|
+
try {
|
|
4829
|
+
const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(sessionsDir, f), 'utf-8'));
|
|
4830
|
+
sessions.push(data);
|
|
4831
|
+
}
|
|
4832
|
+
catch {
|
|
4833
|
+
// Skip malformed session files
|
|
4834
|
+
}
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
// Group by user (sessions have a 'user' field when written to shared dir)
|
|
4838
|
+
const userMap = new Map();
|
|
4839
|
+
for (const s of sessions) {
|
|
4840
|
+
const user = s.user ?? 'unknown';
|
|
4841
|
+
if (!userMap.has(user)) {
|
|
4842
|
+
userMap.set(user, { sessions: [], lastActivity: '' });
|
|
4843
|
+
}
|
|
4844
|
+
const entry = userMap.get(user);
|
|
4845
|
+
entry.sessions.push(s);
|
|
4846
|
+
const started = s.started ?? '';
|
|
4847
|
+
if (started > entry.lastActivity)
|
|
4848
|
+
entry.lastActivity = started;
|
|
4849
|
+
}
|
|
4850
|
+
const users = [...userMap.entries()].map(([username, data]) => ({
|
|
4851
|
+
username,
|
|
4852
|
+
activeSessions: data.sessions.length,
|
|
4853
|
+
lastActivity: data.lastActivity,
|
|
4854
|
+
sessions: data.sessions,
|
|
4855
|
+
}));
|
|
4856
|
+
json(res, { ok: true, users });
|
|
4857
|
+
}
|
|
4858
|
+
function handleGetAdminLogs(_req, res) {
|
|
4859
|
+
if (!requireAdmin(res))
|
|
4860
|
+
return;
|
|
4861
|
+
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
4862
|
+
const logDir = effective.sharedAuditDir && (0, fs_1.existsSync)(effective.sharedAuditDir)
|
|
4863
|
+
? effective.sharedAuditDir
|
|
4864
|
+
: (0, config_js_1.getLogDir)(effective.config);
|
|
4865
|
+
if (!(0, fs_1.existsSync)(logDir)) {
|
|
4866
|
+
json(res, { ok: true, logs: [] });
|
|
4867
|
+
return;
|
|
4868
|
+
}
|
|
4869
|
+
const files = (0, fs_1.readdirSync)(logDir)
|
|
4870
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
4871
|
+
.sort()
|
|
4872
|
+
.reverse();
|
|
4873
|
+
const logs = [];
|
|
4874
|
+
const maxEntries = 100;
|
|
4875
|
+
for (const f of files) {
|
|
4876
|
+
if (logs.length >= maxEntries)
|
|
4877
|
+
break;
|
|
4878
|
+
try {
|
|
4879
|
+
const content = (0, fs_1.readFileSync)((0, path_1.join)(logDir, f), 'utf-8');
|
|
4880
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
4881
|
+
for (const line of lines.reverse()) {
|
|
4882
|
+
if (logs.length >= maxEntries)
|
|
4883
|
+
break;
|
|
4884
|
+
try {
|
|
4885
|
+
logs.push(JSON.parse(line));
|
|
4886
|
+
}
|
|
4887
|
+
catch { /* skip malformed lines */ }
|
|
4888
|
+
}
|
|
4889
|
+
}
|
|
4890
|
+
catch { /* skip unreadable files */ }
|
|
4891
|
+
}
|
|
4892
|
+
json(res, { ok: true, logs });
|
|
4893
|
+
}
|
|
4894
|
+
function handleGetAdminLicense(_req, res) {
|
|
4895
|
+
if (!requireAdmin(res))
|
|
4896
|
+
return;
|
|
4897
|
+
const status = (0, license_js_1.validateLicense)();
|
|
4898
|
+
json(res, { ok: true, license: status });
|
|
4899
|
+
}
|
|
4900
|
+
// ── Display file endpoint ────────────────────────────────
|
|
4901
|
+
// Serves files from the container filesystem to the browser for display widgets.
|
|
4902
|
+
// Maps container paths to host paths using the same mount logic as the session.
|
|
4903
|
+
function resolveDisplayFilePath(containerPath) {
|
|
4904
|
+
const config = (0, config_js_1.loadConfig)();
|
|
4905
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
4906
|
+
// /home/sandbox/... → sandbox home
|
|
4907
|
+
if (containerPath.startsWith('/home/sandbox/')) {
|
|
4908
|
+
return (0, path_1.join)(sandboxHome, containerPath.slice('/home/sandbox/'.length));
|
|
4909
|
+
}
|
|
4910
|
+
// /datasets/<name>/... → dataset host path
|
|
4911
|
+
const datasetMatch = containerPath.match(/^\/datasets\/([^/]+)\/(.+)$/);
|
|
4912
|
+
if (datasetMatch) {
|
|
4913
|
+
const [, dsName, rest] = datasetMatch;
|
|
4914
|
+
const ds = (config.datasets || []).find((d) => d.name === dsName);
|
|
4915
|
+
if (ds) {
|
|
4916
|
+
const resolved = ds.path.replace(/^~/, (0, os_1.homedir)());
|
|
4917
|
+
return (0, path_1.join)(resolved, rest);
|
|
4918
|
+
}
|
|
4919
|
+
}
|
|
4920
|
+
// /work/... → workdir from active sessions
|
|
4921
|
+
if (containerPath.startsWith('/work/')) {
|
|
4922
|
+
const rest = containerPath.slice('/work/'.length);
|
|
4923
|
+
// Check active session workdirs
|
|
4924
|
+
try {
|
|
4925
|
+
const sessionDir = (0, config_js_1.getSessionsDir)();
|
|
4926
|
+
if ((0, fs_1.existsSync)(sessionDir)) {
|
|
4927
|
+
const files = (0, fs_1.readdirSync)(sessionDir).filter((f) => f.endsWith('.json'));
|
|
4928
|
+
for (const f of files) {
|
|
4929
|
+
try {
|
|
4930
|
+
const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(sessionDir, f), 'utf-8'));
|
|
4931
|
+
if (data.workdir) {
|
|
4932
|
+
const candidate = (0, path_1.join)(data.workdir, rest);
|
|
4933
|
+
if ((0, fs_1.existsSync)(candidate))
|
|
4934
|
+
return candidate;
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
catch { /* skip */ }
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
catch { /* skip */ }
|
|
4942
|
+
}
|
|
4943
|
+
// /mnt/<basename>/... → extra_paths
|
|
4944
|
+
const mntMatch = containerPath.match(/^\/mnt\/([^/]+)\/(.+)$/);
|
|
4945
|
+
if (mntMatch) {
|
|
4946
|
+
const [, mountBase, rest] = mntMatch;
|
|
4947
|
+
const ep = config.filesystem.extra_paths.find((p) => {
|
|
4948
|
+
const resolved = p.path.replace(/^~/, (0, os_1.homedir)());
|
|
4949
|
+
return (0, path_1.basename)(resolved) === mountBase;
|
|
4950
|
+
});
|
|
4951
|
+
if (ep) {
|
|
4952
|
+
const resolved = ep.path.replace(/^~/, (0, os_1.homedir)());
|
|
4953
|
+
return (0, path_1.join)(resolved, rest);
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4956
|
+
return null;
|
|
4957
|
+
}
|
|
4958
|
+
function getContentTypeForFile(filePath) {
|
|
4959
|
+
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
4960
|
+
const mimeMap = {
|
|
4961
|
+
png: 'image/png',
|
|
4962
|
+
jpg: 'image/jpeg',
|
|
4963
|
+
jpeg: 'image/jpeg',
|
|
4964
|
+
gif: 'image/gif',
|
|
4965
|
+
svg: 'image/svg+xml',
|
|
4966
|
+
webp: 'image/webp',
|
|
4967
|
+
pdf: 'application/pdf',
|
|
4968
|
+
csv: 'text/csv',
|
|
4969
|
+
tsv: 'text/tab-separated-values',
|
|
4970
|
+
txt: 'text/plain',
|
|
4971
|
+
json: 'application/json',
|
|
4972
|
+
pdb: 'chemical/x-pdb',
|
|
4973
|
+
cif: 'chemical/x-cif',
|
|
4974
|
+
mmcif: 'chemical/x-mmcif',
|
|
4975
|
+
fasta: 'text/plain',
|
|
4976
|
+
fa: 'text/plain',
|
|
4977
|
+
fastq: 'text/plain',
|
|
4978
|
+
fq: 'text/plain',
|
|
4979
|
+
html: 'text/html',
|
|
4980
|
+
xml: 'application/xml',
|
|
4981
|
+
};
|
|
4982
|
+
return mimeMap[ext] || 'application/octet-stream';
|
|
4983
|
+
}
|
|
4984
|
+
function handleDisplayFile(reqUrl, res) {
|
|
4985
|
+
const containerPath = reqUrl.searchParams.get('path');
|
|
4986
|
+
if (!containerPath) {
|
|
4987
|
+
json(res, { ok: false, error: 'Missing path parameter' }, 400);
|
|
4988
|
+
return;
|
|
4989
|
+
}
|
|
4990
|
+
// Prevent directory traversal
|
|
4991
|
+
if (containerPath.includes('..') || containerPath.includes('\0')) {
|
|
4992
|
+
json(res, { ok: false, error: 'Invalid path' }, 400);
|
|
4993
|
+
return;
|
|
4994
|
+
}
|
|
4995
|
+
const hostPath = resolveDisplayFilePath(containerPath);
|
|
4996
|
+
if (!hostPath) {
|
|
4997
|
+
json(res, { ok: false, error: 'Path not within any allowed mount' }, 404);
|
|
4998
|
+
return;
|
|
4999
|
+
}
|
|
5000
|
+
// Verify the resolved path doesn't escape via symlinks
|
|
5001
|
+
let realPath;
|
|
5002
|
+
try {
|
|
5003
|
+
realPath = (0, fs_1.realpathSync)(hostPath);
|
|
5004
|
+
}
|
|
5005
|
+
catch {
|
|
5006
|
+
json(res, { ok: false, error: 'File not found' }, 404);
|
|
5007
|
+
return;
|
|
5008
|
+
}
|
|
5009
|
+
try {
|
|
5010
|
+
const stat = (0, fs_1.statSync)(realPath);
|
|
5011
|
+
if (!stat.isFile()) {
|
|
5012
|
+
json(res, { ok: false, error: 'Not a file' }, 400);
|
|
5013
|
+
return;
|
|
5014
|
+
}
|
|
5015
|
+
// Limit to 50MB
|
|
5016
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
5017
|
+
json(res, { ok: false, error: 'File too large (max 50MB)' }, 413);
|
|
5018
|
+
return;
|
|
5019
|
+
}
|
|
5020
|
+
const contentType = getContentTypeForFile(realPath);
|
|
5021
|
+
const data = (0, fs_1.readFileSync)(realPath);
|
|
5022
|
+
res.writeHead(200, {
|
|
5023
|
+
'Content-Type': contentType,
|
|
5024
|
+
'Content-Length': data.length,
|
|
5025
|
+
'Cache-Control': 'no-cache',
|
|
5026
|
+
});
|
|
5027
|
+
res.end(data);
|
|
5028
|
+
}
|
|
5029
|
+
catch {
|
|
5030
|
+
json(res, { ok: false, error: 'Failed to read file' }, 500);
|
|
5031
|
+
}
|
|
5032
|
+
}
|
|
5033
|
+
function handleGetWidgets(res) {
|
|
5034
|
+
try {
|
|
5035
|
+
const store = getDisplayStore();
|
|
5036
|
+
const widgets = store.listEvents();
|
|
5037
|
+
json(res, { ok: true, widgets });
|
|
5038
|
+
}
|
|
5039
|
+
catch (err) {
|
|
5040
|
+
json(res, { ok: false, error: err.message || 'Failed to list widgets' }, 500);
|
|
5041
|
+
}
|
|
5042
|
+
}
|
|
5043
|
+
function handleClearWidgets(res) {
|
|
5044
|
+
try {
|
|
5045
|
+
const store = getDisplayStore();
|
|
5046
|
+
store.clearEvents();
|
|
5047
|
+
json(res, { ok: true });
|
|
5048
|
+
}
|
|
5049
|
+
catch (err) {
|
|
5050
|
+
json(res, { ok: false, error: err.message || 'Failed to clear widgets' }, 500);
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
function parseExplorerExperimentId(reqUrl) {
|
|
5054
|
+
return String(reqUrl.searchParams.get('experiment_id') || '').trim();
|
|
5055
|
+
}
|
|
5056
|
+
function parseExplorerRunId(reqUrl) {
|
|
5057
|
+
return String(reqUrl.searchParams.get('run_id') || '').trim();
|
|
5058
|
+
}
|
|
5059
|
+
function parseExplorerListInt(raw, fallback, min, max) {
|
|
5060
|
+
const parsed = Number(raw);
|
|
5061
|
+
if (!Number.isFinite(parsed))
|
|
5062
|
+
return fallback;
|
|
5063
|
+
return Math.max(min, Math.min(max, Math.floor(parsed)));
|
|
5064
|
+
}
|
|
5065
|
+
async function ensureExplorerQuickstartSourceRepo() {
|
|
5066
|
+
if (!(0, fs_1.existsSync)(EXPLORER_TSP_TEMPLATE_DIR)) {
|
|
5067
|
+
throw new Error(`Bundled template not found: ${EXPLORER_TSP_TEMPLATE_DIR}`);
|
|
5068
|
+
}
|
|
5069
|
+
const gitDir = (0, path_1.join)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO, '.git');
|
|
5070
|
+
if ((0, fs_1.existsSync)(gitDir))
|
|
5071
|
+
return EXPLORER_TSP_TEMPLATE_SOURCE_REPO;
|
|
5072
|
+
(0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO));
|
|
5073
|
+
(0, fs_1.mkdirSync)(EXPLORER_TSP_TEMPLATE_SOURCE_REPO, { recursive: true, mode: config_js_1.PRIVATE_DIR_MODE });
|
|
5074
|
+
(0, fs_1.cpSync)(EXPLORER_TSP_TEMPLATE_DIR, EXPLORER_TSP_TEMPLATE_SOURCE_REPO, { recursive: true, force: true });
|
|
5075
|
+
await execFileAsync('git', ['init', '.'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
|
|
5076
|
+
await execFileAsync('git', ['add', '.'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
|
|
5077
|
+
try {
|
|
5078
|
+
await execFileAsync('git', ['-c', 'user.name=labgate-ui', '-c', 'user.email=labgate@local', 'commit', '-m', 'template baseline'], { cwd: EXPLORER_TSP_TEMPLATE_SOURCE_REPO });
|
|
5079
|
+
}
|
|
5080
|
+
catch (err) {
|
|
5081
|
+
const detail = commandErrorDetail(err);
|
|
5082
|
+
if (!/nothing to commit/i.test(detail)) {
|
|
5083
|
+
throw err;
|
|
5084
|
+
}
|
|
5085
|
+
}
|
|
5086
|
+
return EXPLORER_TSP_TEMPLATE_SOURCE_REPO;
|
|
5087
|
+
}
|
|
5088
|
+
async function parseExplorerQuickstartInput(body) {
|
|
5089
|
+
const nameInput = String(body.name || '').trim();
|
|
5090
|
+
const experimentName = nameInput || `TSP Demo ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`;
|
|
5091
|
+
const modeRaw = String(body.agent_mode || 'stub').trim().toLowerCase();
|
|
5092
|
+
const agentMode = modeRaw === 'claude' ? 'claude_headless' : (modeRaw || 'stub');
|
|
5093
|
+
if (agentMode !== 'stub' && agentMode !== 'claude_headless') {
|
|
5094
|
+
throw new Error('agent_mode must be stub or claude_headless');
|
|
5095
|
+
}
|
|
5096
|
+
const claudeResumeSessionId = String(body.claude_resume_session_id || '').trim();
|
|
5097
|
+
const claudeTimeoutRaw = body.claude_timeout_sec;
|
|
5098
|
+
const claudeTimeout = Number(claudeTimeoutRaw);
|
|
5099
|
+
if (claudeTimeoutRaw !== undefined &&
|
|
5100
|
+
(!Number.isFinite(claudeTimeout) || claudeTimeout < 60 || claudeTimeout > 14_400)) {
|
|
5101
|
+
throw new Error('claude_timeout_sec must be between 60 and 14400');
|
|
5102
|
+
}
|
|
5103
|
+
const sourceRepoInput = String(body.source_repo_path || '').trim();
|
|
5104
|
+
const sourceRepoPath = sourceRepoInput
|
|
5105
|
+
? (0, path_1.resolve)(sourceRepoInput)
|
|
5106
|
+
: await ensureExplorerQuickstartSourceRepo();
|
|
5107
|
+
if (!(0, fs_1.existsSync)(sourceRepoPath)) {
|
|
5108
|
+
throw new Error(`source_repo_path not found: ${sourceRepoPath}`);
|
|
5109
|
+
}
|
|
5110
|
+
const evalCommand = String(body.eval_command || 'python3 eval.py').trim() || 'python3 eval.py';
|
|
5111
|
+
const timeoutRaw = body.eval_timeout_sec;
|
|
5112
|
+
const evalTimeoutSec = timeoutRaw === undefined ? 30 : Number(timeoutRaw);
|
|
5113
|
+
if (!Number.isFinite(evalTimeoutSec) || evalTimeoutSec < 5 || evalTimeoutSec > 86_400) {
|
|
5114
|
+
throw new Error('eval_timeout_sec must be between 5 and 86400');
|
|
5115
|
+
}
|
|
5116
|
+
const policy = {
|
|
5117
|
+
epsilon: 0.15,
|
|
5118
|
+
top_n: 5,
|
|
5119
|
+
agent_mode: agentMode,
|
|
5120
|
+
};
|
|
5121
|
+
if (agentMode === 'stub') {
|
|
5122
|
+
policy.stub_patch_file = 'stub-patches/enable_two_opt.patch';
|
|
5123
|
+
}
|
|
5124
|
+
else {
|
|
5125
|
+
if (claudeResumeSessionId)
|
|
5126
|
+
policy.claude_resume_session_id = claudeResumeSessionId;
|
|
5127
|
+
if (claudeTimeoutRaw !== undefined)
|
|
5128
|
+
policy.claude_timeout_sec = Math.floor(claudeTimeout);
|
|
5129
|
+
}
|
|
5130
|
+
return {
|
|
5131
|
+
experimentName,
|
|
5132
|
+
sourceRepoPath,
|
|
5133
|
+
evalCommand,
|
|
5134
|
+
evalTimeoutSec: Math.floor(evalTimeoutSec),
|
|
5135
|
+
policy,
|
|
5136
|
+
};
|
|
5137
|
+
}
|
|
5138
|
+
function handleGetExplorerExperiments(reqUrl, res) {
|
|
5139
|
+
const limit = parseExplorerListInt(reqUrl.searchParams.get('limit'), 20, 1, 500);
|
|
5140
|
+
const offset = parseExplorerListInt(reqUrl.searchParams.get('offset'), 0, 0, 100_000);
|
|
5141
|
+
const store = new explorer_store_js_1.ExplorerStore();
|
|
5142
|
+
try {
|
|
5143
|
+
const experiments = store.listExperiments(limit, offset);
|
|
5144
|
+
json(res, {
|
|
5145
|
+
ok: true,
|
|
5146
|
+
experiments,
|
|
5147
|
+
returned: experiments.length,
|
|
5148
|
+
limit,
|
|
5149
|
+
offset,
|
|
5150
|
+
});
|
|
5151
|
+
}
|
|
5152
|
+
catch (err) {
|
|
5153
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
5154
|
+
}
|
|
5155
|
+
finally {
|
|
5156
|
+
store.close();
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
function handleGetExplorerOverview(reqUrl, res) {
|
|
5160
|
+
const experimentId = parseExplorerExperimentId(reqUrl);
|
|
5161
|
+
if (!experimentId) {
|
|
5162
|
+
json(res, { ok: false, error: 'Missing experiment_id' }, 400);
|
|
5163
|
+
return;
|
|
5164
|
+
}
|
|
5165
|
+
try {
|
|
5166
|
+
const overview = (0, explorer_js_1.getExperimentOverview)(experimentId);
|
|
5167
|
+
if (!overview) {
|
|
5168
|
+
json(res, { ok: false, error: 'Experiment not found' }, 404);
|
|
5169
|
+
return;
|
|
5170
|
+
}
|
|
5171
|
+
json(res, { ok: true, overview });
|
|
5172
|
+
}
|
|
5173
|
+
catch (err) {
|
|
5174
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
5175
|
+
}
|
|
5176
|
+
}
|
|
5177
|
+
function handleGetExplorerTree(reqUrl, res) {
|
|
5178
|
+
const experimentId = parseExplorerExperimentId(reqUrl);
|
|
5179
|
+
if (!experimentId) {
|
|
5180
|
+
json(res, { ok: false, error: 'Missing experiment_id' }, 400);
|
|
5181
|
+
return;
|
|
5182
|
+
}
|
|
5183
|
+
const mode = String(reqUrl.searchParams.get('mode') || 'best_path').trim() === 'full' ? 'full' : 'best_path';
|
|
5184
|
+
try {
|
|
5185
|
+
const tree = (0, explorer_js_1.getExperimentTree)(experimentId, mode);
|
|
5186
|
+
json(res, { ok: true, tree });
|
|
5187
|
+
}
|
|
5188
|
+
catch (err) {
|
|
5189
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
function handleGetExplorerRuns(reqUrl, res) {
|
|
5193
|
+
const experimentId = parseExplorerExperimentId(reqUrl);
|
|
5194
|
+
if (!experimentId) {
|
|
5195
|
+
json(res, { ok: false, error: 'Missing experiment_id' }, 400);
|
|
5196
|
+
return;
|
|
5197
|
+
}
|
|
5198
|
+
const limit = parseExplorerListInt(reqUrl.searchParams.get('limit'), 20, 1, 500);
|
|
5199
|
+
const offset = parseExplorerListInt(reqUrl.searchParams.get('offset'), 0, 0, 100_000);
|
|
5200
|
+
const store = new explorer_store_js_1.ExplorerStore();
|
|
5201
|
+
try {
|
|
5202
|
+
const runs = store.listRuns(experimentId, { limit, offset });
|
|
5203
|
+
const total = store.getRunCount(experimentId);
|
|
5204
|
+
json(res, { ok: true, runs, total, returned: runs.length, limit, offset });
|
|
5205
|
+
}
|
|
5206
|
+
catch (err) {
|
|
5207
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
5208
|
+
}
|
|
5209
|
+
finally {
|
|
5210
|
+
store.close();
|
|
5211
|
+
}
|
|
5212
|
+
}
|
|
5213
|
+
function handleGetExplorerRun(reqUrl, res) {
|
|
5214
|
+
const runId = parseExplorerRunId(reqUrl);
|
|
5215
|
+
if (!runId) {
|
|
5216
|
+
json(res, { ok: false, error: 'Missing run_id' }, 400);
|
|
5217
|
+
return;
|
|
5218
|
+
}
|
|
5219
|
+
try {
|
|
5220
|
+
const details = (0, explorer_js_1.getRunDetails)(runId);
|
|
5221
|
+
if (!details) {
|
|
5222
|
+
json(res, { ok: false, error: 'Run not found' }, 404);
|
|
5223
|
+
return;
|
|
5224
|
+
}
|
|
5225
|
+
json(res, { ok: true, details });
|
|
5226
|
+
}
|
|
5227
|
+
catch (err) {
|
|
5228
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5231
|
+
function handleGetExplorerCompare(reqUrl, res) {
|
|
5232
|
+
const experimentId = parseExplorerExperimentId(reqUrl);
|
|
5233
|
+
const runId = parseExplorerRunId(reqUrl);
|
|
5234
|
+
if (!experimentId || !runId) {
|
|
5235
|
+
json(res, { ok: false, error: 'Missing experiment_id or run_id' }, 400);
|
|
5236
|
+
return;
|
|
5237
|
+
}
|
|
5238
|
+
const compareTo = String(reqUrl.searchParams.get('to') || 'best').trim() || 'best';
|
|
5239
|
+
const includePatch = reqUrl.searchParams.get('include_patch') === '1';
|
|
5240
|
+
try {
|
|
5241
|
+
const comparison = (0, explorer_js_1.compareRun)({
|
|
5242
|
+
experiment_id: experimentId,
|
|
5243
|
+
run_id: runId,
|
|
5244
|
+
compare_to: compareTo,
|
|
5245
|
+
include_patch: includePatch,
|
|
5246
|
+
});
|
|
5247
|
+
json(res, { ok: true, comparison });
|
|
5248
|
+
}
|
|
5249
|
+
catch (err) {
|
|
5250
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
5251
|
+
}
|
|
5252
|
+
}
|
|
5253
|
+
function handleGetExplorerArtifact(reqUrl, res) {
|
|
5254
|
+
const runId = parseExplorerRunId(reqUrl);
|
|
5255
|
+
if (!runId) {
|
|
5256
|
+
json(res, { ok: false, error: 'Missing run_id' }, 400);
|
|
5257
|
+
return;
|
|
5258
|
+
}
|
|
5259
|
+
const kind = String(reqUrl.searchParams.get('kind') || 'summary').trim().toLowerCase();
|
|
5260
|
+
if (!['summary', 'diff', 'stdout', 'stderr', 'eval', 'agent', 'claude_stdout', 'claude_stderr'].includes(kind)) {
|
|
5261
|
+
json(res, { ok: false, error: 'Invalid kind (expected summary|diff|stdout|stderr|eval|agent|claude_stdout|claude_stderr)' }, 400);
|
|
5262
|
+
return;
|
|
5263
|
+
}
|
|
5264
|
+
const details = (0, explorer_js_1.getRunDetails)(runId);
|
|
5265
|
+
if (!details) {
|
|
5266
|
+
json(res, { ok: false, error: 'Run not found' }, 404);
|
|
5267
|
+
return;
|
|
5268
|
+
}
|
|
5269
|
+
const artifacts = details.artifacts;
|
|
5270
|
+
const pathByKind = {
|
|
5271
|
+
summary: artifacts.summary_path,
|
|
5272
|
+
diff: artifacts.diff_path,
|
|
5273
|
+
stdout: artifacts.stdout_path,
|
|
5274
|
+
stderr: artifacts.stderr_path,
|
|
5275
|
+
eval: artifacts.eval_json_path,
|
|
5276
|
+
agent: artifacts.agent_log_path,
|
|
5277
|
+
claude_stdout: artifacts.claude_stdout_path,
|
|
5278
|
+
claude_stderr: artifacts.claude_stderr_path,
|
|
5279
|
+
};
|
|
5280
|
+
const availableByKind = {
|
|
5281
|
+
summary: artifacts.available.summary,
|
|
5282
|
+
diff: artifacts.available.diff,
|
|
5283
|
+
stdout: artifacts.available.stdout,
|
|
5284
|
+
stderr: artifacts.available.stderr,
|
|
5285
|
+
eval: artifacts.available.eval_json,
|
|
5286
|
+
agent: artifacts.available.agent_log,
|
|
5287
|
+
claude_stdout: artifacts.available.claude_stdout,
|
|
5288
|
+
claude_stderr: artifacts.available.claude_stderr,
|
|
5289
|
+
};
|
|
5290
|
+
const filePath = pathByKind[kind];
|
|
5291
|
+
if (!filePath || !availableByKind[kind] || !(0, fs_1.existsSync)(filePath)) {
|
|
5292
|
+
json(res, {
|
|
5293
|
+
ok: false,
|
|
5294
|
+
error: 'Artifact missing (possibly pruned)',
|
|
5295
|
+
artifacts_pruned: artifacts.artifacts_pruned,
|
|
5296
|
+
worktree_pruned: artifacts.worktree_pruned,
|
|
5297
|
+
}, 404);
|
|
5298
|
+
return;
|
|
5299
|
+
}
|
|
5300
|
+
try {
|
|
5301
|
+
const st = (0, fs_1.statSync)(filePath);
|
|
5302
|
+
if (!st.isFile()) {
|
|
5303
|
+
json(res, { ok: false, error: 'Artifact is not a file' }, 400);
|
|
5304
|
+
return;
|
|
5305
|
+
}
|
|
5306
|
+
const readBytes = Math.min(st.size, EXPLORER_ARTIFACT_READ_MAX_BYTES);
|
|
5307
|
+
const offset = Math.max(0, st.size - readBytes);
|
|
5308
|
+
const fd = (0, fs_1.openSync)(filePath, 'r');
|
|
5309
|
+
const buf = Buffer.alloc(readBytes);
|
|
5310
|
+
try {
|
|
5311
|
+
(0, fs_1.readSync)(fd, buf, 0, readBytes, offset);
|
|
5312
|
+
}
|
|
5313
|
+
finally {
|
|
5314
|
+
(0, fs_1.closeSync)(fd);
|
|
5315
|
+
}
|
|
5316
|
+
let text = buf.toString('utf-8');
|
|
5317
|
+
if (offset > 0) {
|
|
5318
|
+
const firstNewline = text.indexOf('\n');
|
|
5319
|
+
if (firstNewline >= 0)
|
|
5320
|
+
text = text.slice(firstNewline + 1);
|
|
5321
|
+
}
|
|
5322
|
+
if (kind === 'eval') {
|
|
5323
|
+
let parsedEval = null;
|
|
5324
|
+
try {
|
|
5325
|
+
parsedEval = JSON.parse(text);
|
|
5326
|
+
}
|
|
5327
|
+
catch {
|
|
5328
|
+
parsedEval = null;
|
|
5329
|
+
}
|
|
5330
|
+
json(res, {
|
|
5331
|
+
ok: true,
|
|
5332
|
+
kind,
|
|
5333
|
+
path: filePath,
|
|
5334
|
+
size: st.size,
|
|
5335
|
+
truncated: st.size > readBytes,
|
|
5336
|
+
eval: parsedEval,
|
|
5337
|
+
raw: text,
|
|
5338
|
+
});
|
|
5339
|
+
return;
|
|
5340
|
+
}
|
|
5341
|
+
json(res, {
|
|
5342
|
+
ok: true,
|
|
5343
|
+
kind,
|
|
5344
|
+
path: filePath,
|
|
5345
|
+
size: st.size,
|
|
5346
|
+
truncated: st.size > readBytes,
|
|
5347
|
+
text,
|
|
5348
|
+
});
|
|
5349
|
+
}
|
|
5350
|
+
catch (err) {
|
|
5351
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
5352
|
+
}
|
|
5353
|
+
}
|
|
5354
|
+
async function handlePostExplorerQuickstart(req, res) {
|
|
5355
|
+
try {
|
|
5356
|
+
let body = {};
|
|
5357
|
+
try {
|
|
5358
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
5359
|
+
}
|
|
5360
|
+
catch {
|
|
5361
|
+
body = {};
|
|
5362
|
+
}
|
|
5363
|
+
const input = await parseExplorerQuickstartInput(body);
|
|
5364
|
+
const experiment = (0, explorer_js_1.createExplorerExperiment)({
|
|
5365
|
+
name: input.experimentName,
|
|
5366
|
+
source_repo_path: input.sourceRepoPath,
|
|
5367
|
+
eval_command: input.evalCommand,
|
|
5368
|
+
eval_timeout_sec: input.evalTimeoutSec,
|
|
5369
|
+
policy: input.policy,
|
|
5370
|
+
retention: {
|
|
5371
|
+
keep_worktrees: false,
|
|
5372
|
+
artifacts: 'minimal',
|
|
5373
|
+
keep_last_n: 50,
|
|
5374
|
+
keep_best: true,
|
|
5375
|
+
keep_failed_last_n: 20,
|
|
5376
|
+
max_delete_runs: 200,
|
|
5377
|
+
},
|
|
5378
|
+
});
|
|
5379
|
+
const baselineArtifactDir = (0, config_js_1.getExplorerArtifactDir)(experiment.id, 'baseline');
|
|
5380
|
+
const baseline = (0, explorer_eval_js_1.runEvaluation)({
|
|
5381
|
+
worktree_path: experiment.repo_path,
|
|
5382
|
+
eval_command: experiment.eval_command,
|
|
5383
|
+
timeout_sec: experiment.eval_timeout_sec,
|
|
5384
|
+
artifact_dir: baselineArtifactDir,
|
|
5385
|
+
});
|
|
5386
|
+
const store = new explorer_store_js_1.ExplorerStore();
|
|
5387
|
+
try {
|
|
5388
|
+
store.createEvent(experiment.id, 'note', {
|
|
5389
|
+
message: 'baseline evaluation',
|
|
5390
|
+
status: baseline.status,
|
|
5391
|
+
score: baseline.score ?? null,
|
|
5392
|
+
artifact_dir: baselineArtifactDir,
|
|
5393
|
+
error: baseline.error || null,
|
|
5394
|
+
});
|
|
5395
|
+
}
|
|
5396
|
+
finally {
|
|
5397
|
+
store.close();
|
|
5398
|
+
}
|
|
5399
|
+
const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id);
|
|
3341
5400
|
json(res, {
|
|
3342
5401
|
ok: true,
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
5402
|
+
experiment,
|
|
5403
|
+
baseline: {
|
|
5404
|
+
status: baseline.status,
|
|
5405
|
+
score: baseline.score ?? null,
|
|
5406
|
+
error: baseline.error || null,
|
|
5407
|
+
artifact_dir: baselineArtifactDir,
|
|
5408
|
+
},
|
|
5409
|
+
overview,
|
|
3346
5410
|
});
|
|
3347
|
-
return;
|
|
3348
5411
|
}
|
|
3349
|
-
|
|
5412
|
+
catch (err) {
|
|
5413
|
+
const message = err?.message || String(err);
|
|
5414
|
+
const status = /must be|not found/i.test(message) ? 400 : 500;
|
|
5415
|
+
json(res, { ok: false, error: message }, status);
|
|
5416
|
+
}
|
|
3350
5417
|
}
|
|
3351
|
-
async function
|
|
3352
|
-
const context = requireAdmin(res, { allowBootstrapPolicySetup: true });
|
|
3353
|
-
if (!context)
|
|
3354
|
-
return;
|
|
5418
|
+
async function handlePostExplorerRegister(req, res) {
|
|
3355
5419
|
try {
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
if (errors.length > 0) {
|
|
3360
|
-
json(res, { ok: false, errors }, 400);
|
|
3361
|
-
return;
|
|
3362
|
-
}
|
|
3363
|
-
let policy = incoming;
|
|
3364
|
-
let addedBootstrapAdmin = false;
|
|
3365
|
-
// First-time setup guard: ensure the bootstrap user cannot lock themselves out.
|
|
3366
|
-
if (context.bootstrapPolicySetup) {
|
|
3367
|
-
const names = Array.isArray(policy.admins?.usernames) ? policy.admins.usernames : [];
|
|
3368
|
-
const alreadyIncluded = names.includes(context.currentUser);
|
|
3369
|
-
policy = {
|
|
3370
|
-
...policy,
|
|
3371
|
-
admins: {
|
|
3372
|
-
usernames: [...new Set([context.currentUser, ...names])],
|
|
3373
|
-
},
|
|
3374
|
-
};
|
|
3375
|
-
addedBootstrapAdmin = !alreadyIncluded;
|
|
5420
|
+
let body = {};
|
|
5421
|
+
try {
|
|
5422
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
3376
5423
|
}
|
|
3377
|
-
|
|
5424
|
+
catch {
|
|
5425
|
+
body = {};
|
|
5426
|
+
}
|
|
5427
|
+
const input = await parseExplorerQuickstartInput(body);
|
|
5428
|
+
const experiment = (0, explorer_js_1.createExplorerExperiment)({
|
|
5429
|
+
name: input.experimentName,
|
|
5430
|
+
source_repo_path: input.sourceRepoPath,
|
|
5431
|
+
eval_command: input.evalCommand,
|
|
5432
|
+
eval_timeout_sec: input.evalTimeoutSec,
|
|
5433
|
+
policy: input.policy,
|
|
5434
|
+
retention: {
|
|
5435
|
+
keep_worktrees: false,
|
|
5436
|
+
artifacts: 'minimal',
|
|
5437
|
+
keep_last_n: 50,
|
|
5438
|
+
keep_best: true,
|
|
5439
|
+
keep_failed_last_n: 20,
|
|
5440
|
+
max_delete_runs: 200,
|
|
5441
|
+
},
|
|
5442
|
+
});
|
|
5443
|
+
const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id);
|
|
3378
5444
|
json(res, {
|
|
3379
5445
|
ok: true,
|
|
3380
|
-
|
|
3381
|
-
|
|
5446
|
+
experiment,
|
|
5447
|
+
overview,
|
|
5448
|
+
flow: {
|
|
5449
|
+
tool: 'experiment_register',
|
|
5450
|
+
initialized: false,
|
|
5451
|
+
},
|
|
3382
5452
|
});
|
|
3383
5453
|
}
|
|
3384
5454
|
catch (err) {
|
|
3385
|
-
|
|
5455
|
+
const message = err?.message || String(err);
|
|
5456
|
+
const status = /must be|not found/i.test(message) ? 400 : 500;
|
|
5457
|
+
json(res, { ok: false, error: message }, status);
|
|
3386
5458
|
}
|
|
3387
5459
|
}
|
|
3388
|
-
function
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
const
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
5460
|
+
async function handlePostExplorerInit(req, res) {
|
|
5461
|
+
try {
|
|
5462
|
+
let body = {};
|
|
5463
|
+
try {
|
|
5464
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
5465
|
+
}
|
|
5466
|
+
catch {
|
|
5467
|
+
body = {};
|
|
5468
|
+
}
|
|
5469
|
+
const experimentId = String(body.experiment_id || '').trim();
|
|
5470
|
+
if (!experimentId) {
|
|
5471
|
+
json(res, { ok: false, error: 'Missing experiment_id' }, 400);
|
|
5472
|
+
return;
|
|
5473
|
+
}
|
|
5474
|
+
const store = new explorer_store_js_1.ExplorerStore();
|
|
5475
|
+
try {
|
|
5476
|
+
const experiment = store.getExperiment(experimentId);
|
|
5477
|
+
if (!experiment) {
|
|
5478
|
+
json(res, { ok: false, error: 'Experiment not found' }, 404);
|
|
5479
|
+
return;
|
|
3405
5480
|
}
|
|
5481
|
+
const baselineArtifactDir = (0, config_js_1.getExplorerArtifactDir)(experiment.id, 'baseline');
|
|
5482
|
+
const baseline = (0, explorer_eval_js_1.runEvaluation)({
|
|
5483
|
+
worktree_path: experiment.repo_path,
|
|
5484
|
+
eval_command: experiment.eval_command,
|
|
5485
|
+
timeout_sec: experiment.eval_timeout_sec,
|
|
5486
|
+
artifact_dir: baselineArtifactDir,
|
|
5487
|
+
});
|
|
5488
|
+
store.createEvent(experiment.id, 'note', {
|
|
5489
|
+
message: 'experiment initialized with baseline evaluation',
|
|
5490
|
+
status: baseline.status,
|
|
5491
|
+
score: baseline.score ?? null,
|
|
5492
|
+
artifact_dir: baselineArtifactDir,
|
|
5493
|
+
error: baseline.error || null,
|
|
5494
|
+
});
|
|
5495
|
+
const overview = (0, explorer_js_1.getExperimentOverview)(experiment.id, store);
|
|
5496
|
+
json(res, {
|
|
5497
|
+
ok: true,
|
|
5498
|
+
experiment,
|
|
5499
|
+
baseline: {
|
|
5500
|
+
status: baseline.status,
|
|
5501
|
+
score: baseline.score ?? null,
|
|
5502
|
+
error: baseline.error || null,
|
|
5503
|
+
artifact_dir: baselineArtifactDir,
|
|
5504
|
+
},
|
|
5505
|
+
overview,
|
|
5506
|
+
flow: {
|
|
5507
|
+
tool: 'experiment_init',
|
|
5508
|
+
initialized: true,
|
|
5509
|
+
},
|
|
5510
|
+
});
|
|
3406
5511
|
}
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
const userMap = new Map();
|
|
3410
|
-
for (const s of sessions) {
|
|
3411
|
-
const user = s.user ?? 'unknown';
|
|
3412
|
-
if (!userMap.has(user)) {
|
|
3413
|
-
userMap.set(user, { sessions: [], lastActivity: '' });
|
|
5512
|
+
finally {
|
|
5513
|
+
store.close();
|
|
3414
5514
|
}
|
|
3415
|
-
const entry = userMap.get(user);
|
|
3416
|
-
entry.sessions.push(s);
|
|
3417
|
-
const started = s.started ?? '';
|
|
3418
|
-
if (started > entry.lastActivity)
|
|
3419
|
-
entry.lastActivity = started;
|
|
3420
5515
|
}
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
activeSessions: data.sessions.length,
|
|
3424
|
-
lastActivity: data.lastActivity,
|
|
3425
|
-
sessions: data.sessions,
|
|
3426
|
-
}));
|
|
3427
|
-
json(res, { ok: true, users });
|
|
3428
|
-
}
|
|
3429
|
-
function handleGetAdminLogs(_req, res) {
|
|
3430
|
-
if (!requireAdmin(res))
|
|
3431
|
-
return;
|
|
3432
|
-
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
3433
|
-
const logDir = effective.sharedAuditDir && (0, fs_1.existsSync)(effective.sharedAuditDir)
|
|
3434
|
-
? effective.sharedAuditDir
|
|
3435
|
-
: (0, config_js_1.getLogDir)(effective.config);
|
|
3436
|
-
if (!(0, fs_1.existsSync)(logDir)) {
|
|
3437
|
-
json(res, { ok: true, logs: [] });
|
|
3438
|
-
return;
|
|
5516
|
+
catch (err) {
|
|
5517
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
3439
5518
|
}
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
const logs = [];
|
|
3445
|
-
const maxEntries = 100;
|
|
3446
|
-
for (const f of files) {
|
|
3447
|
-
if (logs.length >= maxEntries)
|
|
3448
|
-
break;
|
|
5519
|
+
}
|
|
5520
|
+
async function handlePostExplorerTick(req, res) {
|
|
5521
|
+
try {
|
|
5522
|
+
let body = {};
|
|
3449
5523
|
try {
|
|
3450
|
-
|
|
3451
|
-
const lines = content.split('\n').filter(l => l.trim());
|
|
3452
|
-
for (const line of lines.reverse()) {
|
|
3453
|
-
if (logs.length >= maxEntries)
|
|
3454
|
-
break;
|
|
3455
|
-
try {
|
|
3456
|
-
logs.push(JSON.parse(line));
|
|
3457
|
-
}
|
|
3458
|
-
catch { /* skip malformed lines */ }
|
|
3459
|
-
}
|
|
5524
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
3460
5525
|
}
|
|
3461
|
-
catch {
|
|
5526
|
+
catch {
|
|
5527
|
+
body = {};
|
|
5528
|
+
}
|
|
5529
|
+
const experimentId = String(body.experiment_id || '').trim();
|
|
5530
|
+
if (!experimentId) {
|
|
5531
|
+
json(res, { ok: false, error: 'Missing experiment_id' }, 400);
|
|
5532
|
+
return;
|
|
5533
|
+
}
|
|
5534
|
+
const result = (0, explorer_js_1.runAutopilotTick)(experimentId);
|
|
5535
|
+
const runDetails = result.run_id ? (0, explorer_js_1.getRunDetails)(result.run_id) : null;
|
|
5536
|
+
json(res, { ok: true, result, run_details: runDetails });
|
|
5537
|
+
}
|
|
5538
|
+
catch (err) {
|
|
5539
|
+
json(res, { ok: false, error: err?.message || String(err) }, 500);
|
|
3462
5540
|
}
|
|
3463
|
-
json(res, { ok: true, logs });
|
|
3464
|
-
}
|
|
3465
|
-
function handleGetAdminLicense(_req, res) {
|
|
3466
|
-
if (!requireAdmin(res))
|
|
3467
|
-
return;
|
|
3468
|
-
const status = (0, license_js_1.validateLicense)();
|
|
3469
|
-
json(res, { ok: true, license: status });
|
|
3470
5541
|
}
|
|
3471
5542
|
function upgradeUnauthorized(socket) {
|
|
3472
5543
|
try {
|
|
@@ -3510,6 +5581,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3510
5581
|
const socketPath = options.socketPath || (0, config_js_1.getUiSocketPath)();
|
|
3511
5582
|
const tcpPort = Number.isFinite(options.port) ? Math.floor(options.port) : null;
|
|
3512
5583
|
const useTcp = tcpPort !== null;
|
|
5584
|
+
const prewarmImageOnStartup = options.prewarmImageOnStartup === true;
|
|
3513
5585
|
const requestedPort = tcpPort ?? 0;
|
|
3514
5586
|
const maxPort = requestedPort + 3;
|
|
3515
5587
|
const uiAccessToken = useTcp ? (0, crypto_1.randomBytes)(24).toString('hex') : '';
|
|
@@ -3522,9 +5594,16 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3522
5594
|
(0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(socketPath));
|
|
3523
5595
|
}
|
|
3524
5596
|
const wsServer = new ws_1.WebSocketServer({ noServer: true });
|
|
5597
|
+
const claudeWsServer = new ws_1.WebSocketServer({ noServer: true });
|
|
3525
5598
|
wsServer.on('connection', async (ws, req) => {
|
|
3526
5599
|
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
3527
5600
|
const id = reqUrl.searchParams.get('id') || '';
|
|
5601
|
+
const replayRaw = String(reqUrl.searchParams.get('replay') || '').trim().toLowerCase();
|
|
5602
|
+
const replayDisabled = replayRaw === '0' || replayRaw === 'false' || replayRaw === 'off';
|
|
5603
|
+
const afterSeqRaw = String(reqUrl.searchParams.get('afterSeq') || '').trim();
|
|
5604
|
+
const parsedAfterSeq = afterSeqRaw ? Number(afterSeqRaw) : NaN;
|
|
5605
|
+
const hasAfterSeq = afterSeqRaw.length > 0 && Number.isFinite(parsedAfterSeq);
|
|
5606
|
+
const afterSeq = hasAfterSeq ? Math.floor(parsedAfterSeq) : null;
|
|
3528
5607
|
const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
|
|
3529
5608
|
if (!record) {
|
|
3530
5609
|
sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found' });
|
|
@@ -3545,7 +5624,19 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3545
5624
|
exitCode: record.exitCode,
|
|
3546
5625
|
error: record.error,
|
|
3547
5626
|
});
|
|
3548
|
-
if (
|
|
5627
|
+
if (afterSeq !== null) {
|
|
5628
|
+
const pending = bridge.history.filter((chunk) => chunk.seq > afterSeq);
|
|
5629
|
+
for (const chunk of pending) {
|
|
5630
|
+
sendWebTerminalMessage(ws, {
|
|
5631
|
+
type: 'data',
|
|
5632
|
+
id: record.id,
|
|
5633
|
+
data: chunk.data,
|
|
5634
|
+
seqStart: chunk.seq,
|
|
5635
|
+
seqEnd: chunk.seq,
|
|
5636
|
+
});
|
|
5637
|
+
}
|
|
5638
|
+
}
|
|
5639
|
+
else if (!replayDisabled && bridge.buffer) {
|
|
3549
5640
|
sendWebTerminalMessage(ws, { type: 'data', id: record.id, data: bridge.buffer });
|
|
3550
5641
|
}
|
|
3551
5642
|
ws.on('message', (raw) => {
|
|
@@ -3586,6 +5677,65 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3586
5677
|
}
|
|
3587
5678
|
});
|
|
3588
5679
|
});
|
|
5680
|
+
claudeWsServer.on('connection', (ws, req) => {
|
|
5681
|
+
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
5682
|
+
const id = (reqUrl.searchParams.get('id') || '').trim();
|
|
5683
|
+
const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
|
|
5684
|
+
if (!record || record.node !== (0, os_1.hostname)()) {
|
|
5685
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found on this node.' });
|
|
5686
|
+
ws.close();
|
|
5687
|
+
return;
|
|
5688
|
+
}
|
|
5689
|
+
if (record.agent !== 'claude') {
|
|
5690
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Headless chat is currently supported for Claude sessions only.' });
|
|
5691
|
+
ws.close();
|
|
5692
|
+
return;
|
|
5693
|
+
}
|
|
5694
|
+
let disposeRun = null;
|
|
5695
|
+
let runInFlight = false;
|
|
5696
|
+
ws.on('message', (raw) => {
|
|
5697
|
+
if (runInFlight) {
|
|
5698
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Headless Claude run already in progress for this socket.' });
|
|
5699
|
+
return;
|
|
5700
|
+
}
|
|
5701
|
+
let parsed = null;
|
|
5702
|
+
try {
|
|
5703
|
+
parsed = JSON.parse(raw.toString('utf-8'));
|
|
5704
|
+
}
|
|
5705
|
+
catch {
|
|
5706
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Invalid request payload.' });
|
|
5707
|
+
return;
|
|
5708
|
+
}
|
|
5709
|
+
if (!parsed || parsed.type !== 'prompt') {
|
|
5710
|
+
sendWebTerminalMessage(ws, { type: 'error', error: 'Unsupported request. Expected { type: "prompt", prompt }.' });
|
|
5711
|
+
return;
|
|
5712
|
+
}
|
|
5713
|
+
const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
|
|
5714
|
+
const resumeSessionId = typeof parsed.resumeSessionId === 'string' ? parsed.resumeSessionId : '';
|
|
5715
|
+
runInFlight = true;
|
|
5716
|
+
void startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId)
|
|
5717
|
+
.then((cleanup) => {
|
|
5718
|
+
disposeRun = cleanup;
|
|
5719
|
+
})
|
|
5720
|
+
.catch((err) => {
|
|
5721
|
+
sendWebTerminalMessage(ws, {
|
|
5722
|
+
type: 'error',
|
|
5723
|
+
code: 'headless_failed',
|
|
5724
|
+
error: err?.message ?? String(err),
|
|
5725
|
+
});
|
|
5726
|
+
sendWebTerminalMessage(ws, { type: 'done', exitCode: 1, isError: true });
|
|
5727
|
+
});
|
|
5728
|
+
});
|
|
5729
|
+
ws.on('close', () => {
|
|
5730
|
+
if (disposeRun) {
|
|
5731
|
+
try {
|
|
5732
|
+
disposeRun();
|
|
5733
|
+
}
|
|
5734
|
+
catch { /* best effort */ }
|
|
5735
|
+
}
|
|
5736
|
+
disposeRun = null;
|
|
5737
|
+
});
|
|
5738
|
+
});
|
|
3589
5739
|
const server = (0, http_1.createServer)(async (req, res) => {
|
|
3590
5740
|
const url = req.url ?? '/';
|
|
3591
5741
|
const reqUrl = new URL(url, 'http://localhost');
|
|
@@ -3711,9 +5861,24 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3711
5861
|
else if (pathname === '/api/terminal/sessions' && method === 'GET') {
|
|
3712
5862
|
await handleGetWebTerminalSessions(res);
|
|
3713
5863
|
}
|
|
5864
|
+
else if (pathname === '/api/terminal/history' && method === 'GET') {
|
|
5865
|
+
await handleGetWebTerminalHistory(reqUrl, res);
|
|
5866
|
+
}
|
|
5867
|
+
else if (pathname === '/api/terminal/init' && method === 'GET') {
|
|
5868
|
+
await handleGetWebTerminalInit(reqUrl, res);
|
|
5869
|
+
}
|
|
5870
|
+
else if (pathname === '/api/terminal/init' && method === 'POST') {
|
|
5871
|
+
await handlePostWebTerminalInit(req, res);
|
|
5872
|
+
}
|
|
3714
5873
|
else if (pathname === '/api/terminal/start' && method === 'POST') {
|
|
3715
5874
|
await handlePostWebTerminalStart(req, res);
|
|
3716
5875
|
}
|
|
5876
|
+
else if (pathname === '/api/terminal/agent/update' && method === 'POST') {
|
|
5877
|
+
await handlePostWebTerminalAgentUpdate(req, res);
|
|
5878
|
+
}
|
|
5879
|
+
else if (pathname === '/api/terminal/rename' && method === 'POST') {
|
|
5880
|
+
await handlePostWebTerminalRename(req, res);
|
|
5881
|
+
}
|
|
3717
5882
|
else if (pathname === '/api/terminal/stop' && method === 'POST') {
|
|
3718
5883
|
await handlePostWebTerminalStop(req, res);
|
|
3719
5884
|
}
|
|
@@ -3770,6 +5935,54 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3770
5935
|
else if (pathname === '/api/admin/license' && method === 'GET') {
|
|
3771
5936
|
handleGetAdminLicense(req, res);
|
|
3772
5937
|
}
|
|
5938
|
+
else if (pathname === '/api/explorer/experiments' && method === 'GET') {
|
|
5939
|
+
handleGetExplorerExperiments(reqUrl, res);
|
|
5940
|
+
}
|
|
5941
|
+
else if (pathname === '/api/explorer/overview' && method === 'GET') {
|
|
5942
|
+
handleGetExplorerOverview(reqUrl, res);
|
|
5943
|
+
}
|
|
5944
|
+
else if (pathname === '/api/explorer/tree' && method === 'GET') {
|
|
5945
|
+
handleGetExplorerTree(reqUrl, res);
|
|
5946
|
+
}
|
|
5947
|
+
else if (pathname === '/api/explorer/runs' && method === 'GET') {
|
|
5948
|
+
handleGetExplorerRuns(reqUrl, res);
|
|
5949
|
+
}
|
|
5950
|
+
else if (pathname === '/api/explorer/run' && method === 'GET') {
|
|
5951
|
+
handleGetExplorerRun(reqUrl, res);
|
|
5952
|
+
}
|
|
5953
|
+
else if (pathname === '/api/explorer/compare' && method === 'GET') {
|
|
5954
|
+
handleGetExplorerCompare(reqUrl, res);
|
|
5955
|
+
}
|
|
5956
|
+
else if (pathname === '/api/explorer/artifact' && method === 'GET') {
|
|
5957
|
+
handleGetExplorerArtifact(reqUrl, res);
|
|
5958
|
+
}
|
|
5959
|
+
else if (pathname === '/api/explorer/register' && method === 'POST') {
|
|
5960
|
+
await handlePostExplorerRegister(req, res);
|
|
5961
|
+
}
|
|
5962
|
+
else if (pathname === '/api/explorer/init' && method === 'POST') {
|
|
5963
|
+
await handlePostExplorerInit(req, res);
|
|
5964
|
+
}
|
|
5965
|
+
else if (pathname === '/api/explorer/quickstart' && method === 'POST') {
|
|
5966
|
+
await handlePostExplorerQuickstart(req, res);
|
|
5967
|
+
}
|
|
5968
|
+
else if (pathname === '/api/explorer/tick' && method === 'POST') {
|
|
5969
|
+
await handlePostExplorerTick(req, res);
|
|
5970
|
+
}
|
|
5971
|
+
else if (pathname === '/api/explorer/step' && method === 'POST') {
|
|
5972
|
+
await handlePostExplorerTick(req, res);
|
|
5973
|
+
}
|
|
5974
|
+
else if (pathname === '/api/explorer/go' && method === 'POST') {
|
|
5975
|
+
await handlePostExplorerTick(req, res);
|
|
5976
|
+
}
|
|
5977
|
+
else if (pathname === '/api/display/file' && method === 'GET') {
|
|
5978
|
+
handleDisplayFile(reqUrl, res);
|
|
5979
|
+
}
|
|
5980
|
+
else if (pathname === '/api/widgets' && method === 'GET') {
|
|
5981
|
+
handleGetWidgets(res);
|
|
5982
|
+
}
|
|
5983
|
+
else if (pathname === '/api/widgets/clear' && method === 'POST') {
|
|
5984
|
+
handleClearWidgets(res);
|
|
5985
|
+
}
|
|
3773
5986
|
else if (pathname.startsWith('/fonts/') && method === 'GET') {
|
|
3774
5987
|
serveFontFile(pathname, res);
|
|
3775
5988
|
}
|
|
@@ -3807,7 +6020,8 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3807
6020
|
});
|
|
3808
6021
|
server.on('upgrade', (req, socket, head) => {
|
|
3809
6022
|
const reqUrl = new URL(req.url || '/', 'http://localhost');
|
|
3810
|
-
|
|
6023
|
+
const pathname = reqUrl.pathname;
|
|
6024
|
+
if (pathname !== '/api/terminal/ws' && pathname !== '/api/claude/ws') {
|
|
3811
6025
|
upgradeBadRequest(socket);
|
|
3812
6026
|
return;
|
|
3813
6027
|
}
|
|
@@ -3837,8 +6051,18 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3837
6051
|
upgradeBadRequest(socket);
|
|
3838
6052
|
return;
|
|
3839
6053
|
}
|
|
3840
|
-
|
|
3841
|
-
wsServer.
|
|
6054
|
+
if (pathname === '/api/terminal/ws') {
|
|
6055
|
+
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
6056
|
+
wsServer.emit('connection', ws, req);
|
|
6057
|
+
});
|
|
6058
|
+
return;
|
|
6059
|
+
}
|
|
6060
|
+
if (record.agent !== 'claude') {
|
|
6061
|
+
upgradeBadRequest(socket);
|
|
6062
|
+
return;
|
|
6063
|
+
}
|
|
6064
|
+
claudeWsServer.handleUpgrade(req, socket, head, (ws) => {
|
|
6065
|
+
claudeWsServer.emit('connection', ws, req);
|
|
3842
6066
|
});
|
|
3843
6067
|
});
|
|
3844
6068
|
const bindTcp = (nextPort) => {
|
|
@@ -3860,51 +6084,105 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3860
6084
|
if (started)
|
|
3861
6085
|
return;
|
|
3862
6086
|
started = true;
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
6087
|
+
void (async () => {
|
|
6088
|
+
if (prewarmImageOnStartup) {
|
|
6089
|
+
try {
|
|
6090
|
+
const cfg = (0, config_js_1.loadConfig)();
|
|
6091
|
+
const runtimeReady = await prepareRuntimeForWebTerminal(cfg.runtime);
|
|
6092
|
+
if (!runtimeReady.ok) {
|
|
6093
|
+
const firstLine = String(runtimeReady.error || 'Container runtime unavailable.')
|
|
6094
|
+
.split('\n')[0]
|
|
6095
|
+
.trim();
|
|
6096
|
+
log.warn(`Skipping startup image preparation: ${firstLine}`);
|
|
6097
|
+
}
|
|
6098
|
+
else {
|
|
6099
|
+
const runtimeCheck = (0, runtime_js_1.checkRuntime)(cfg.runtime);
|
|
6100
|
+
if (!runtimeCheck.ok || !runtimeCheck.runtime) {
|
|
6101
|
+
const firstLine = String(runtimeCheck.error || 'Container runtime unavailable.')
|
|
6102
|
+
.split('\n')[0]
|
|
6103
|
+
.trim();
|
|
6104
|
+
log.warn(`Skipping startup image preparation: ${firstLine}`);
|
|
6105
|
+
}
|
|
6106
|
+
else {
|
|
6107
|
+
const runtime = runtimeCheck.runtime;
|
|
6108
|
+
const image = cfg.image;
|
|
6109
|
+
let imageExists = false;
|
|
6110
|
+
if (runtime === 'podman') {
|
|
6111
|
+
try {
|
|
6112
|
+
await execFileAsync('podman', ['image', 'exists', image], {
|
|
6113
|
+
timeout: 10_000,
|
|
6114
|
+
maxBuffer: WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER,
|
|
6115
|
+
});
|
|
6116
|
+
imageExists = true;
|
|
6117
|
+
}
|
|
6118
|
+
catch {
|
|
6119
|
+
imageExists = false;
|
|
6120
|
+
}
|
|
6121
|
+
}
|
|
6122
|
+
else {
|
|
6123
|
+
imageExists = (0, fs_1.existsSync)((0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(image)));
|
|
6124
|
+
}
|
|
6125
|
+
if (!imageExists) {
|
|
6126
|
+
log.step(`No cached image found for ${image}. Preparing it before opening UI...`);
|
|
6127
|
+
await ensureWebTerminalImageReady(runtime, image);
|
|
6128
|
+
log.success(`Prepared image ${image}.`);
|
|
6129
|
+
}
|
|
6130
|
+
}
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
6133
|
+
catch (err) {
|
|
6134
|
+
const detail = commandErrorDetail(err);
|
|
6135
|
+
const firstLine = (detail || err?.message || String(err)).split('\n')[0].trim();
|
|
6136
|
+
log.warn(`Startup image preparation failed: ${firstLine}`);
|
|
6137
|
+
}
|
|
3875
6138
|
}
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
6139
|
+
if (useTcp) {
|
|
6140
|
+
const actualPort = server.address()?.port ?? listenPort;
|
|
6141
|
+
// Use an explicit IPv4 loopback host to avoid `localhost` IPv6 collisions on macOS.
|
|
6142
|
+
dashboardQuickLink = `http://127.0.0.1:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
|
|
6143
|
+
log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
|
|
6144
|
+
try {
|
|
6145
|
+
writeDashboardLink(dashboardQuickLink);
|
|
6146
|
+
}
|
|
6147
|
+
catch {
|
|
6148
|
+
// Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
|
|
6149
|
+
}
|
|
6150
|
+
if (shouldAutoOpenUiBrowser(standalone)) {
|
|
6151
|
+
autoOpenUiBrowser(dashboardQuickLink);
|
|
6152
|
+
}
|
|
3880
6153
|
}
|
|
3881
|
-
|
|
3882
|
-
|
|
6154
|
+
else {
|
|
6155
|
+
try {
|
|
6156
|
+
(0, fs_1.chmodSync)(socketPath, config_js_1.PRIVATE_FILE_MODE);
|
|
6157
|
+
}
|
|
6158
|
+
catch {
|
|
6159
|
+
// Best effort on platforms that do not support chmod on sockets.
|
|
6160
|
+
}
|
|
6161
|
+
log.step(`Settings socket: ${socketPath}`);
|
|
6162
|
+
log.step('Use `labgate ui` for browser access on localhost (default port: 7700).');
|
|
3883
6163
|
}
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
}
|
|
3887
|
-
if (standalone) {
|
|
3888
|
-
log.step('Press Ctrl+C to stop');
|
|
3889
|
-
}
|
|
3890
|
-
// Initialise SLURM tracking if enabled
|
|
3891
|
-
const slurmConfig = (0, config_js_1.loadConfig)();
|
|
3892
|
-
if (slurmConfig.slurm.enabled && !slurmDB) {
|
|
3893
|
-
try {
|
|
3894
|
-
slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
|
|
3895
|
-
slurmPoller = new slurm_poller_js_1.SlurmPoller({
|
|
3896
|
-
db: slurmDB,
|
|
3897
|
-
pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
|
|
3898
|
-
sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
|
|
3899
|
-
});
|
|
3900
|
-
slurmPoller.start();
|
|
3901
|
-
log.step('SLURM job tracking enabled');
|
|
6164
|
+
if (standalone) {
|
|
6165
|
+
log.step('Press Ctrl+C to stop');
|
|
3902
6166
|
}
|
|
3903
|
-
|
|
3904
|
-
|
|
6167
|
+
// Initialise SLURM tracking if enabled
|
|
6168
|
+
const slurmConfig = (0, config_js_1.loadConfig)();
|
|
6169
|
+
if (slurmConfig.slurm.enabled && !slurmDB) {
|
|
6170
|
+
try {
|
|
6171
|
+
slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
|
|
6172
|
+
slurmPoller = new slurm_poller_js_1.SlurmPoller({
|
|
6173
|
+
db: slurmDB,
|
|
6174
|
+
pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
|
|
6175
|
+
sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
|
|
6176
|
+
});
|
|
6177
|
+
slurmPoller.start();
|
|
6178
|
+
log.step('SLURM job tracking enabled');
|
|
6179
|
+
}
|
|
6180
|
+
catch (err) {
|
|
6181
|
+
log.warn(`SLURM tracking unavailable: ${err.message}`);
|
|
6182
|
+
}
|
|
3905
6183
|
}
|
|
3906
|
-
|
|
3907
|
-
|
|
6184
|
+
startSSEBroadcast();
|
|
6185
|
+
})();
|
|
3908
6186
|
});
|
|
3909
6187
|
server.on('close', () => {
|
|
3910
6188
|
if (dashboardQuickLink)
|
|
@@ -3920,6 +6198,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3920
6198
|
}
|
|
3921
6199
|
webTerminalBridges.clear();
|
|
3922
6200
|
wsServer.close();
|
|
6201
|
+
claudeWsServer.close();
|
|
3923
6202
|
});
|
|
3924
6203
|
server.on('error', (err) => {
|
|
3925
6204
|
if (!useTcp && err.code === 'EADDRINUSE') {
|
|
@@ -3963,6 +6242,13 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3963
6242
|
// Best effort cleanup.
|
|
3964
6243
|
}
|
|
3965
6244
|
}
|
|
6245
|
+
if (sseInterval) {
|
|
6246
|
+
clearInterval(sseInterval);
|
|
6247
|
+
sseInterval = null;
|
|
6248
|
+
}
|
|
6249
|
+
sseClients.clear();
|
|
6250
|
+
stopResultsWatcher();
|
|
6251
|
+
stopDisplayWatcher();
|
|
3966
6252
|
// Cleanup SLURM resources
|
|
3967
6253
|
if (slurmPoller) {
|
|
3968
6254
|
slurmPoller.stop();
|
|
@@ -3976,6 +6262,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
3976
6262
|
slurmDB = null;
|
|
3977
6263
|
}
|
|
3978
6264
|
resultsStore = null;
|
|
6265
|
+
displayStore = null;
|
|
3979
6266
|
});
|
|
3980
6267
|
return server;
|
|
3981
6268
|
}
|