labgate 0.5.3 → 0.5.5

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.
Files changed (40) hide show
  1. package/README.md +49 -4
  2. package/dist/cli.js +322 -19
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/audit.d.ts +5 -1
  5. package/dist/lib/audit.js +19 -3
  6. package/dist/lib/audit.js.map +1 -1
  7. package/dist/lib/config.d.ts +71 -2
  8. package/dist/lib/config.js +192 -8
  9. package/dist/lib/config.js.map +1 -1
  10. package/dist/lib/container.d.ts +54 -0
  11. package/dist/lib/container.js +650 -178
  12. package/dist/lib/container.js.map +1 -1
  13. package/dist/lib/init.js +22 -9
  14. package/dist/lib/init.js.map +1 -1
  15. package/dist/lib/license.d.ts +44 -0
  16. package/dist/lib/license.js +164 -0
  17. package/dist/lib/license.js.map +1 -0
  18. package/dist/lib/policy.d.ts +85 -0
  19. package/dist/lib/policy.js +321 -0
  20. package/dist/lib/policy.js.map +1 -0
  21. package/dist/lib/runtime.d.ts +2 -2
  22. package/dist/lib/runtime.js +19 -36
  23. package/dist/lib/runtime.js.map +1 -1
  24. package/dist/lib/slurm-db.d.ts +51 -0
  25. package/dist/lib/slurm-db.js +179 -0
  26. package/dist/lib/slurm-db.js.map +1 -0
  27. package/dist/lib/slurm-mcp.d.ts +12 -0
  28. package/dist/lib/slurm-mcp.js +347 -0
  29. package/dist/lib/slurm-mcp.js.map +1 -0
  30. package/dist/lib/slurm-poller.d.ts +36 -0
  31. package/dist/lib/slurm-poller.js +423 -0
  32. package/dist/lib/slurm-poller.js.map +1 -0
  33. package/dist/lib/test/integration-harness.d.ts +44 -0
  34. package/dist/lib/test/integration-harness.js +260 -0
  35. package/dist/lib/test/integration-harness.js.map +1 -0
  36. package/dist/lib/ui.d.ts +34 -1
  37. package/dist/lib/ui.html +3081 -356
  38. package/dist/lib/ui.js +2123 -106
  39. package/dist/lib/ui.js.map +1 -1
  40. package/package.json +13 -4
package/dist/lib/ui.js CHANGED
@@ -37,11 +37,30 @@ exports.startUI = startUI;
37
37
  const http_1 = require("http");
38
38
  const fs_1 = require("fs");
39
39
  const path_1 = require("path");
40
+ const os_1 = require("os");
40
41
  const child_process_1 = require("child_process");
42
+ const util_1 = require("util");
43
+ const crypto_1 = require("crypto");
41
44
  const config_js_1 = require("./config.js");
45
+ const container_js_1 = require("./container.js");
42
46
  const runtime_js_1 = require("./runtime.js");
47
+ const audit_js_1 = require("./audit.js");
48
+ const slurm_db_js_1 = require("./slurm-db.js");
49
+ const slurm_poller_js_1 = require("./slurm-poller.js");
50
+ const policy_js_1 = require("./policy.js");
51
+ const license_js_1 = require("./license.js");
43
52
  const log = __importStar(require("./log.js"));
53
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
44
54
  const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
55
+ const FONTS_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', 'geist', 'dist', 'fonts');
56
+ const WRITE_TOKEN_PLACEHOLDER = '__LABGATE_WRITE_TOKEN__';
57
+ const UI_WRITE_TOKEN = (0, crypto_1.randomBytes)(24).toString('hex');
58
+ const UI_AUTH_COOKIE = 'labgate_ui_token';
59
+ const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
60
+ const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
61
+ // ── SLURM module state (initialised in startUI when slurm.enabled) ──
62
+ let slurmDB = null;
63
+ let slurmPoller = null;
45
64
  function readBody(req) {
46
65
  return new Promise((resolve, reject) => {
47
66
  const chunks = [];
@@ -54,9 +73,84 @@ function json(res, data, status = 200) {
54
73
  res.writeHead(status, { 'Content-Type': 'application/json' });
55
74
  res.end(JSON.stringify(data));
56
75
  }
76
+ function getHeaderValue(req, name) {
77
+ const value = req.headers[name.toLowerCase()];
78
+ if (Array.isArray(value))
79
+ return value[0] || '';
80
+ return value || '';
81
+ }
82
+ function requireWriteToken(req, res) {
83
+ const token = getHeaderValue(req, 'x-labgate-token');
84
+ if (!token || token !== UI_WRITE_TOKEN) {
85
+ json(res, { ok: false, error: 'Forbidden' }, 403);
86
+ return false;
87
+ }
88
+ return true;
89
+ }
90
+ function getCookieValue(req, name) {
91
+ const rawCookie = getHeaderValue(req, 'cookie');
92
+ if (!rawCookie)
93
+ return '';
94
+ const parts = rawCookie.split(';');
95
+ for (const part of parts) {
96
+ const idx = part.indexOf('=');
97
+ if (idx <= 0)
98
+ continue;
99
+ const key = part.slice(0, idx).trim();
100
+ if (key !== name)
101
+ continue;
102
+ const value = part.slice(idx + 1).trim();
103
+ try {
104
+ return decodeURIComponent(value);
105
+ }
106
+ catch {
107
+ return value;
108
+ }
109
+ }
110
+ return '';
111
+ }
112
+ function isAuthorizedRequest(req, reqUrl, accessToken) {
113
+ if (!accessToken)
114
+ return { ok: true, tokenFromQuery: false };
115
+ const headerToken = getHeaderValue(req, 'x-labgate-auth').trim();
116
+ if (headerToken && headerToken === accessToken)
117
+ return { ok: true, tokenFromQuery: false };
118
+ const cookieToken = getCookieValue(req, UI_AUTH_COOKIE);
119
+ if (cookieToken && cookieToken === accessToken)
120
+ return { ok: true, tokenFromQuery: false };
121
+ const queryToken = (reqUrl.searchParams.get('token') || '').trim();
122
+ if (queryToken && queryToken === accessToken)
123
+ return { ok: true, tokenFromQuery: true };
124
+ return { ok: false, tokenFromQuery: false };
125
+ }
126
+ function serveFontFile(url, res) {
127
+ // Only allow specific font files from the geist package
128
+ const match = url.match(/^\/fonts\/([\w-]+\.woff2)$/);
129
+ if (!match) {
130
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
131
+ res.end('Not found');
132
+ return;
133
+ }
134
+ const filename = match[1];
135
+ // Search across font subdirectories
136
+ for (const subdir of ['geist-pixel', 'geist-sans', 'geist-mono']) {
137
+ const fontPath = (0, path_1.join)(FONTS_DIR, subdir, filename);
138
+ if ((0, fs_1.existsSync)(fontPath)) {
139
+ const data = (0, fs_1.readFileSync)(fontPath);
140
+ res.writeHead(200, {
141
+ 'Content-Type': 'font/woff2',
142
+ 'Cache-Control': 'public, max-age=31536000, immutable',
143
+ });
144
+ res.end(data);
145
+ return;
146
+ }
147
+ }
148
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
149
+ res.end('Font not found');
150
+ }
57
151
  function serveHTML(res) {
58
152
  try {
59
- const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8');
153
+ const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8').replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN);
60
154
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
61
155
  res.end(html);
62
156
  }
@@ -66,8 +160,20 @@ function serveHTML(res) {
66
160
  }
67
161
  }
68
162
  function handleGetConfig(_req, res) {
69
- const config = (0, config_js_1.loadConfig)();
70
- json(res, config);
163
+ const effective = (0, config_js_1.loadEffectiveConfig)();
164
+ const response = { ...effective.config };
165
+ // Attach enterprise metadata so the frontend knows which fields to lock
166
+ if (effective.enterprise) {
167
+ response._enterprise = {
168
+ active: true,
169
+ institution: effective.institution,
170
+ isAdmin: effective.isAdmin,
171
+ lockedFields: [...effective.lockedFields],
172
+ lockedListItems: effective.lockedListItems,
173
+ constraints: effective.constraints,
174
+ };
175
+ }
176
+ json(res, response);
71
177
  }
72
178
  async function handlePostConfig(req, res) {
73
179
  try {
@@ -78,6 +184,37 @@ async function handlePostConfig(req, res) {
78
184
  json(res, { ok: false, errors }, 400);
79
185
  return;
80
186
  }
187
+ // Enforce enterprise policy: reject changes to locked fields
188
+ const effective = (0, config_js_1.loadEffectiveConfig)();
189
+ if (effective.enterprise && effective.lockedFields.size > 0) {
190
+ const violations = [];
191
+ const orig = effective.config;
192
+ if (effective.lockedFields.has('runtime') && incoming.runtime !== orig.runtime) {
193
+ violations.push('runtime');
194
+ }
195
+ if (effective.lockedFields.has('image') && incoming.image !== orig.image) {
196
+ violations.push('image');
197
+ }
198
+ if (effective.lockedFields.has('network.mode') && incoming.network?.mode !== orig.network.mode) {
199
+ violations.push('network.mode');
200
+ }
201
+ if (effective.lockedFields.has('audit.enabled') && incoming.audit?.enabled !== orig.audit.enabled) {
202
+ violations.push('audit.enabled');
203
+ }
204
+ if (effective.lockedFields.has('audit.log_dir') && incoming.audit?.log_dir !== orig.audit.log_dir) {
205
+ violations.push('audit.log_dir');
206
+ }
207
+ if (effective.lockedFields.has('slurm.enabled') && incoming.slurm?.enabled !== orig.slurm.enabled) {
208
+ violations.push('slurm.enabled');
209
+ }
210
+ if (violations.length > 0) {
211
+ json(res, {
212
+ ok: false,
213
+ errors: [`Cannot change admin-locked fields: ${violations.join(', ')}`],
214
+ }, 403);
215
+ return;
216
+ }
217
+ }
81
218
  const configPath = (0, config_js_1.getConfigPath)();
82
219
  // Read existing raw file and parse (strip comments)
83
220
  let obj = {};
@@ -101,11 +238,13 @@ async function handlePostConfig(req, res) {
101
238
  obj.filesystem = incoming.filesystem;
102
239
  obj.commands = incoming.commands;
103
240
  obj.network = incoming.network;
241
+ obj.datasets = incoming.datasets;
104
242
  obj.audit = incoming.audit;
105
243
  if (incoming.slurm)
106
244
  obj.slurm = incoming.slurm;
107
245
  const { writeFileSync } = await import('fs');
108
- writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
246
+ writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
247
+ (0, config_js_1.ensurePrivateFile)(configPath);
109
248
  json(res, { ok: true });
110
249
  }
111
250
  catch (err) {
@@ -115,150 +254,2028 @@ async function handlePostConfig(req, res) {
115
254
  function handleGetConfigPath(_req, res) {
116
255
  json(res, { path: (0, config_js_1.getConfigPath)() });
117
256
  }
118
- function handleGetSessions(_req, res) {
119
- const config = (0, config_js_1.loadConfig)();
120
- const check = (0, runtime_js_1.checkRuntime)(config.runtime);
121
- if (!check.ok || !check.runtime) {
122
- json(res, { sessions: [], message: 'No container runtime found' });
257
+ const INSTRUCTION_FILE_MAP = {
258
+ 'agent.md': 'AGENTS.md',
259
+ 'agents.md': 'AGENTS.md',
260
+ 'claude.md': 'CLAUDE.md',
261
+ };
262
+ function normalizeSessionId(raw) {
263
+ const sessionId = (raw || '').trim();
264
+ return /^[a-zA-Z0-9_-]{4,64}$/.test(sessionId) ? sessionId : null;
265
+ }
266
+ function normalizeInstructionName(raw) {
267
+ const key = (raw || '').trim().toLowerCase();
268
+ return INSTRUCTION_FILE_MAP[key] || null;
269
+ }
270
+ function getInstructionFileForAgent(agent) {
271
+ return (agent || '').toLowerCase() === 'claude' ? 'CLAUDE.md' : 'AGENTS.md';
272
+ }
273
+ function buildLabgateInstructionBlockForWorkdir(workdir) {
274
+ const lines = [
275
+ LABGATE_INSTRUCTION_START,
276
+ '## LabGate Sandbox Context (Auto-Managed)',
277
+ '- You are running inside a LabGate sandbox container.',
278
+ '- Path mapping:',
279
+ ` - Container \`/work\` maps to host \`${workdir}\``,
280
+ ` - Container \`/home/sandbox\` maps to host \`${(0, config_js_1.getSandboxHome)()}\``,
281
+ ];
282
+ let datasets = [];
283
+ try {
284
+ const config = (0, config_js_1.loadConfig)();
285
+ const extraPaths = Array.isArray(config.filesystem?.extra_paths) ? config.filesystem.extra_paths : [];
286
+ for (const mount of extraPaths) {
287
+ if (!mount || typeof mount !== 'object')
288
+ continue;
289
+ const rawPath = mount.path;
290
+ const mode = mount.mode;
291
+ if (typeof rawPath !== 'string')
292
+ continue;
293
+ const hostPath = rawPath.replace(/^~/, (0, os_1.homedir)());
294
+ const mountMode = typeof mode === 'string' ? mode : 'rw';
295
+ lines.push(` - Container \`/mnt/${(0, path_1.basename)(hostPath)}\` maps to host \`${hostPath}\` (${mountMode})`);
296
+ }
297
+ datasets = Array.isArray(config.datasets) ? config.datasets : [];
298
+ for (const ds of datasets) {
299
+ if (!ds || typeof ds !== 'object')
300
+ continue;
301
+ const rawPath = ds.path;
302
+ const name = ds.name;
303
+ const mode = ds.mode;
304
+ if (typeof rawPath !== 'string' || typeof name !== 'string')
305
+ continue;
306
+ const hostPath = rawPath.replace(/^~/, (0, os_1.homedir)());
307
+ const mountMode = typeof mode === 'string' ? mode : 'ro';
308
+ lines.push(` - Container \`/datasets/${name}\` maps to host \`${hostPath}\` (${mountMode})`);
309
+ }
310
+ }
311
+ catch {
312
+ // Keep the core mapping block even if config loading fails.
313
+ }
314
+ lines.push('- Treat other host paths as unavailable unless explicitly mounted.');
315
+ lines.push('- When reporting file paths to the user, prefer showing both container and host paths when helpful.');
316
+ if (datasets.length > 0) {
317
+ lines.push('');
318
+ lines.push('### Available Datasets');
319
+ lines.push('The following named datasets are mounted and available for analysis:');
320
+ for (const ds of datasets) {
321
+ if (!ds || typeof ds !== 'object')
322
+ continue;
323
+ const name = ds.name;
324
+ const mode = ds.mode || 'ro';
325
+ const desc = ds.description;
326
+ const descStr = typeof desc === 'string' && desc ? ` — ${desc}` : '';
327
+ lines.push(`- **${name}** at \`/datasets/${name}\` (${mode})${descStr}`);
328
+ }
329
+ lines.push('');
330
+ lines.push('Use these dataset paths directly when the user references data by name.');
331
+ }
332
+ lines.push(LABGATE_INSTRUCTION_END);
333
+ return lines.join('\n');
334
+ }
335
+ function hashText(content) {
336
+ return (0, crypto_1.createHash)('sha256').update(content, 'utf-8').digest('hex');
337
+ }
338
+ function splitInstructionContent(content) {
339
+ const startIdx = content.indexOf(LABGATE_INSTRUCTION_START);
340
+ if (startIdx < 0) {
341
+ return { managedPresent: false, managedBlock: '', userContent: content };
342
+ }
343
+ const endMarkerIdx = content.indexOf(LABGATE_INSTRUCTION_END, startIdx);
344
+ if (endMarkerIdx < 0) {
345
+ return { managedPresent: false, managedBlock: '', userContent: content };
346
+ }
347
+ const endIdx = endMarkerIdx + LABGATE_INSTRUCTION_END.length;
348
+ const managedBlock = content.slice(startIdx, endIdx).trimEnd();
349
+ const withoutManaged = `${content.slice(0, startIdx)}${content.slice(endIdx)}`;
350
+ const userContent = withoutManaged.replace(/^\n+/, '');
351
+ return {
352
+ managedPresent: true,
353
+ managedBlock,
354
+ userContent,
355
+ };
356
+ }
357
+ function buildInstructionContent(managedBlock, userContent) {
358
+ const normalizedUser = (userContent || '').replace(/^\n+/, '');
359
+ if (!managedBlock)
360
+ return normalizedUser;
361
+ if (!normalizedUser)
362
+ return `${managedBlock}\n`;
363
+ return `${managedBlock}\n\n${normalizedUser}`;
364
+ }
365
+ function getInstructionTemplate(name, agent) {
366
+ if (name === 'CLAUDE.md') {
367
+ return [
368
+ '# CLAUDE.md',
369
+ '',
370
+ '## Project Context',
371
+ '- Summarize the project purpose and constraints here.',
372
+ '',
373
+ '## Coding Guidelines',
374
+ '- Prefer minimal, focused changes.',
375
+ '- Keep tests updated for behavior changes.',
376
+ '',
377
+ '## Verification',
378
+ '- Run relevant unit and integration tests before finishing.',
379
+ '',
380
+ ].join('\n');
381
+ }
382
+ const agentNote = (agent || '').toLowerCase() === 'codex'
383
+ ? '- This project is frequently edited via Codex sessions.'
384
+ : '- This project may be edited by multiple agents.';
385
+ return [
386
+ '# AGENTS.md',
387
+ '',
388
+ '## Agent Instructions',
389
+ agentNote,
390
+ '- Keep edits scoped to the requested task.',
391
+ '- Do not introduce unrelated refactors.',
392
+ '',
393
+ '## Repository Conventions',
394
+ '- Preserve existing style and architecture.',
395
+ '- Add tests for new or changed behavior.',
396
+ '',
397
+ ].join('\n');
398
+ }
399
+ function getSessionRecord(sessionId) {
400
+ const sessionFile = (0, path_1.join)((0, config_js_1.getSessionsDir)(), `${sessionId}.json`);
401
+ if (!(0, fs_1.existsSync)(sessionFile))
402
+ return null;
403
+ try {
404
+ const data = JSON.parse((0, fs_1.readFileSync)(sessionFile, 'utf-8'));
405
+ const localHost = (0, os_1.hostname)();
406
+ if (data.node === localHost) {
407
+ try {
408
+ process.kill(data.pid, 0);
409
+ }
410
+ catch {
411
+ // Local session died — clean stale record
412
+ try {
413
+ (0, fs_1.unlinkSync)(sessionFile);
414
+ }
415
+ catch { /* best effort */ }
416
+ return null;
417
+ }
418
+ }
419
+ return data;
420
+ }
421
+ catch {
422
+ return null;
423
+ }
424
+ }
425
+ function handleGetSessionInstructions(pathname, reqUrl, res) {
426
+ const match = pathname.match(/^\/api\/sessions\/([^/]+)\/instructions$/);
427
+ if (!match) {
428
+ json(res, { ok: false, error: 'Invalid path' }, 400);
429
+ return;
430
+ }
431
+ const sessionId = normalizeSessionId(decodeURIComponent(match[1]));
432
+ if (!sessionId) {
433
+ json(res, { ok: false, error: 'Invalid session id' }, 400);
434
+ return;
435
+ }
436
+ const name = normalizeInstructionName(reqUrl.searchParams.get('name') || '');
437
+ if (!name) {
438
+ json(res, { ok: false, error: 'Instruction name must be AGENTS.md or CLAUDE.md' }, 400);
439
+ return;
440
+ }
441
+ const session = getSessionRecord(sessionId);
442
+ if (!session) {
443
+ json(res, { ok: false, error: 'Session not found' }, 404);
123
444
  return;
124
445
  }
125
- const runtime = check.runtime;
126
- if ((0, runtime_js_1.isApptainerFamily)(runtime)) {
127
- json(res, { sessions: [], message: 'Apptainer sessions run as processes. Use: ps aux | grep apptainer' });
446
+ if (!session.workdir || typeof session.workdir !== 'string') {
447
+ json(res, { ok: false, error: 'Session has no valid workdir' }, 400);
128
448
  return;
129
449
  }
450
+ const filePath = (0, path_1.join)(session.workdir, name);
451
+ let content = '';
452
+ let exists = false;
453
+ let mtimeMs = 0;
454
+ let autoCreated = false;
455
+ let managedInjected = false;
130
456
  try {
131
- const output = (0, child_process_1.execFileSync)(runtime, [
132
- 'ps',
133
- '--filter', 'name=labgate-',
134
- '--format', '{{.Names}}\t{{.Status}}\t{{.RunningFor}}',
135
- ], { encoding: 'utf-8', timeout: 5000 }).trim();
136
- if (!output) {
137
- json(res, { sessions: [] });
138
- return;
457
+ if ((0, fs_1.existsSync)(filePath)) {
458
+ content = (0, fs_1.readFileSync)(filePath, 'utf-8');
459
+ exists = true;
460
+ mtimeMs = (0, fs_1.statSync)(filePath).mtimeMs;
461
+ }
462
+ const existedBefore = exists;
463
+ const defaultInstruction = getInstructionFileForAgent(String(session.agent || ''));
464
+ if (name === defaultInstruction) {
465
+ const parts = splitInstructionContent(content);
466
+ const managedBlock = parts.managedPresent ? parts.managedBlock : buildLabgateInstructionBlockForWorkdir(session.workdir);
467
+ const ensured = buildInstructionContent(managedBlock, parts.userContent);
468
+ if (!parts.managedPresent || !exists) {
469
+ if (ensured !== content || !exists) {
470
+ (0, fs_1.writeFileSync)(filePath, ensured, 'utf-8');
471
+ content = ensured;
472
+ exists = true;
473
+ mtimeMs = (0, fs_1.statSync)(filePath).mtimeMs;
474
+ }
475
+ autoCreated = !existedBefore;
476
+ managedInjected = !parts.managedPresent;
477
+ }
139
478
  }
140
- const sessions = output.split('\n').map(line => {
141
- const parts = line.split('\t');
142
- return { name: parts[0] || '', status: parts[1] || '', running: parts[2] || '' };
143
- });
144
- json(res, { sessions });
145
479
  }
146
- catch {
147
- json(res, { sessions: [] });
480
+ catch (err) {
481
+ json(res, { ok: false, error: err.message ?? String(err) }, 500);
482
+ return;
148
483
  }
484
+ const parts = splitInstructionContent(content);
485
+ json(res, {
486
+ ok: true,
487
+ sessionId,
488
+ name,
489
+ path: filePath,
490
+ exists,
491
+ mtimeMs,
492
+ hash: hashText(content),
493
+ content,
494
+ userContent: parts.userContent,
495
+ managedPresent: parts.managedPresent,
496
+ managedText: parts.managedBlock,
497
+ autoCreated,
498
+ managedInjected,
499
+ template: getInstructionTemplate(name, String(session.agent || '')),
500
+ });
149
501
  }
150
- function handleGetLogs(_req, res) {
151
- const config = (0, config_js_1.loadConfig)();
152
- if (!config.audit.enabled) {
153
- json(res, { entries: [], message: 'Audit logging is disabled' });
502
+ async function handlePutSessionInstructions(pathname, req, res) {
503
+ const match = pathname.match(/^\/api\/sessions\/([^/]+)\/instructions$/);
504
+ if (!match) {
505
+ json(res, { ok: false, error: 'Invalid path' }, 400);
154
506
  return;
155
507
  }
156
- const logDir = (0, config_js_1.getLogDir)(config);
157
- if (!(0, fs_1.existsSync)(logDir)) {
158
- json(res, { entries: [], message: 'No audit logs found yet' });
508
+ const sessionId = normalizeSessionId(decodeURIComponent(match[1]));
509
+ if (!sessionId) {
510
+ json(res, { ok: false, error: 'Invalid session id' }, 400);
159
511
  return;
160
512
  }
161
- const files = (0, fs_1.readdirSync)(logDir)
162
- .filter((f) => f.endsWith('.jsonl'))
163
- .sort()
164
- .reverse();
165
- if (files.length === 0) {
166
- json(res, { entries: [], message: 'No audit logs found yet' });
513
+ const session = getSessionRecord(sessionId);
514
+ if (!session) {
515
+ json(res, { ok: false, error: 'Session not found' }, 404);
516
+ return;
517
+ }
518
+ if (!session.workdir || typeof session.workdir !== 'string') {
519
+ json(res, { ok: false, error: 'Session has no valid workdir' }, 400);
167
520
  return;
168
521
  }
169
- const logFile = (0, path_1.resolve)(logDir, files[0]);
170
522
  try {
171
- const content = (0, fs_1.readFileSync)(logFile, 'utf-8').trim();
172
- if (!content) {
173
- json(res, { entries: [], message: 'Log file is empty' });
523
+ const body = JSON.parse(await readBody(req));
524
+ const name = normalizeInstructionName(body.name || '');
525
+ if (!name) {
526
+ json(res, { ok: false, error: 'Instruction name must be AGENTS.md or CLAUDE.md' }, 400);
174
527
  return;
175
528
  }
176
- const allLines = content.split('\n');
177
- const lines = allLines.slice(-50);
178
- const entries = lines
179
- .map(line => { try {
180
- return JSON.parse(line);
529
+ const nextUserContent = typeof body.userContent === 'string'
530
+ ? body.userContent
531
+ : (typeof body.content === 'string' ? body.content : null);
532
+ if (nextUserContent === null) {
533
+ json(res, { ok: false, error: 'Instruction content must be a string' }, 400);
534
+ return;
181
535
  }
182
- catch {
183
- return null;
184
- } })
185
- .filter(Boolean)
186
- .reverse();
187
- json(res, { entries });
536
+ if (typeof body.expectedHash !== 'string') {
537
+ json(res, { ok: false, error: 'Missing expectedHash for conflict detection' }, 400);
538
+ return;
539
+ }
540
+ const filePath = (0, path_1.join)(session.workdir, name);
541
+ let currentContent = '';
542
+ try {
543
+ if ((0, fs_1.existsSync)(filePath))
544
+ currentContent = (0, fs_1.readFileSync)(filePath, 'utf-8');
545
+ }
546
+ catch (err) {
547
+ json(res, { ok: false, error: err.message ?? String(err) }, 500);
548
+ return;
549
+ }
550
+ const currentHash = hashText(currentContent);
551
+ const currentParts = splitInstructionContent(currentContent);
552
+ if (body.expectedHash !== currentHash) {
553
+ json(res, {
554
+ ok: false,
555
+ error: 'Instruction file changed on disk',
556
+ conflict: true,
557
+ currentHash,
558
+ currentContent,
559
+ currentUserContent: currentParts.userContent,
560
+ managedPresent: currentParts.managedPresent,
561
+ currentManagedText: currentParts.managedBlock,
562
+ }, 409);
563
+ return;
564
+ }
565
+ const defaultInstruction = getInstructionFileForAgent(String(session.agent || ''));
566
+ const managedBlock = currentParts.managedBlock || (name === defaultInstruction
567
+ ? buildLabgateInstructionBlockForWorkdir(session.workdir)
568
+ : '');
569
+ const nextContent = buildInstructionContent(managedBlock, nextUserContent);
570
+ const tmpPath = (0, path_1.join)(session.workdir, `.${name}.${process.pid}.${Date.now()}.tmp`);
571
+ try {
572
+ (0, fs_1.writeFileSync)(tmpPath, nextContent, 'utf-8');
573
+ (0, fs_1.renameSync)(tmpPath, filePath);
574
+ }
575
+ finally {
576
+ try {
577
+ if ((0, fs_1.existsSync)(tmpPath))
578
+ (0, fs_1.unlinkSync)(tmpPath);
579
+ }
580
+ catch { /* best effort */ }
581
+ }
582
+ const writtenContent = (0, fs_1.readFileSync)(filePath, 'utf-8');
583
+ const writtenParts = splitInstructionContent(writtenContent);
584
+ const nextHash = hashText(writtenContent);
585
+ const mtimeMs = (0, fs_1.statSync)(filePath).mtimeMs;
586
+ try {
587
+ const effective = (0, config_js_1.loadEffectiveConfig)();
588
+ (0, audit_js_1.writeAuditEvent)(effective.config, {
589
+ timestamp: new Date().toISOString(),
590
+ session: sessionId,
591
+ event: 'instruction_updated',
592
+ filename: name,
593
+ bytes: Buffer.byteLength(writtenContent, 'utf-8'),
594
+ hash: nextHash,
595
+ }, { sharedAuditDir: effective.sharedAuditDir });
596
+ }
597
+ catch { /* do not fail save on audit errors */ }
598
+ json(res, {
599
+ ok: true,
600
+ sessionId,
601
+ name,
602
+ path: filePath,
603
+ exists: true,
604
+ mtimeMs,
605
+ hash: nextHash,
606
+ userContent: writtenParts.userContent,
607
+ managedPresent: writtenParts.managedPresent,
608
+ managedText: writtenParts.managedBlock,
609
+ });
188
610
  }
189
611
  catch (err) {
190
- json(res, { entries: [], message: 'Could not read log file: ' + (err.message ?? String(err)) });
612
+ json(res, { ok: false, error: err.message ?? String(err) }, 400);
613
+ }
614
+ }
615
+ function extractTextContent(value) {
616
+ if (typeof value === 'string')
617
+ return value;
618
+ if (!value)
619
+ return '';
620
+ if (Array.isArray(value)) {
621
+ return value.map(v => extractTextContent(v)).join('\n');
622
+ }
623
+ if (typeof value === 'object') {
624
+ const obj = value;
625
+ const parts = [];
626
+ if (typeof obj.text === 'string')
627
+ parts.push(obj.text);
628
+ if (obj.content !== undefined)
629
+ parts.push(extractTextContent(obj.content));
630
+ return parts.join('\n');
631
+ }
632
+ return '';
633
+ }
634
+ function sanitizeTokenEdge(value) {
635
+ return value
636
+ .trim()
637
+ .replace(/^[("'`[{<]+/, '')
638
+ .replace(/[)"'`}\]>.,;!?]+$/, '');
639
+ }
640
+ function normalizeWebsiteUrl(rawUrl) {
641
+ const cleaned = sanitizeTokenEdge(rawUrl);
642
+ if (!cleaned)
643
+ return null;
644
+ const maybePrefixed = cleaned.startsWith('www.') ? `https://${cleaned}` : cleaned;
645
+ try {
646
+ const parsed = new URL(maybePrefixed);
647
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
648
+ return null;
649
+ parsed.hash = '';
650
+ parsed.search = '';
651
+ parsed.username = '';
652
+ parsed.password = '';
653
+ return parsed.toString();
654
+ }
655
+ catch {
656
+ return null;
657
+ }
658
+ }
659
+ function extractWebsiteUrlsFromText(text) {
660
+ if (!text)
661
+ return [];
662
+ const scan = text.length > 12_000 ? text.slice(0, 12_000) : text;
663
+ const matches = scan.match(/https?:\/\/[^\s"'`<>()\[\]{}]+/g) || [];
664
+ const normalized = new Set();
665
+ for (const raw of matches) {
666
+ const url = normalizeWebsiteUrl(raw);
667
+ if (url)
668
+ normalized.add(url);
669
+ }
670
+ return [...normalized];
671
+ }
672
+ function rememberTimestampedEntry(map, value, ts) {
673
+ if (!value)
674
+ return;
675
+ const existing = map.get(value) || 0;
676
+ if (ts >= existing)
677
+ map.set(value, ts);
678
+ }
679
+ function collectWebsiteUrls(value, accessedUrls, ts, keyHint = '') {
680
+ if (typeof value === 'string') {
681
+ for (const url of extractWebsiteUrlsFromText(value)) {
682
+ rememberTimestampedEntry(accessedUrls, url, ts);
683
+ }
684
+ if (/(^|_)(url|uri|href|website|link|domain|host)(_|$)/i.test(keyHint)) {
685
+ const direct = normalizeWebsiteUrl(value);
686
+ if (direct) {
687
+ rememberTimestampedEntry(accessedUrls, direct, ts);
688
+ }
689
+ else {
690
+ const bareDomain = sanitizeTokenEdge(value);
691
+ if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9-]{1,63})+(?:\/\S*)?$/i.test(bareDomain)) {
692
+ const normalized = normalizeWebsiteUrl(`https://${bareDomain}`);
693
+ if (normalized)
694
+ rememberTimestampedEntry(accessedUrls, normalized, ts);
695
+ }
696
+ }
697
+ }
698
+ return;
699
+ }
700
+ if (Array.isArray(value)) {
701
+ for (const item of value) {
702
+ collectWebsiteUrls(item, accessedUrls, ts, keyHint);
703
+ }
704
+ return;
705
+ }
706
+ if (value && typeof value === 'object') {
707
+ const obj = value;
708
+ for (const [key, nested] of Object.entries(obj)) {
709
+ collectWebsiteUrls(nested, accessedUrls, ts, key);
710
+ }
191
711
  }
192
712
  }
193
713
  /**
194
- * Start the settings UI server.
195
- * Returns the HTTP server so callers can close it when done.
196
- * In standalone mode (`labgate ui`), pass standalone=true for extra logging.
714
+ * Tail-read the last ~16KB of a file and return the last parseable
715
+ * non-snapshot JSONL entry, last user prompt, and recently accessed files.
197
716
  */
198
- function startUI(port, standalone = true) {
199
- const server = (0, http_1.createServer)(async (req, res) => {
200
- const url = req.url ?? '/';
201
- const method = req.method ?? 'GET';
202
- res.setHeader('Access-Control-Allow-Origin', '*');
203
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
204
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
205
- if (method === 'OPTIONS') {
206
- res.writeHead(204);
207
- res.end();
208
- return;
717
+ function tailLastJsonlEntry(filePath) {
718
+ try {
719
+ const st = (0, fs_1.statSync)(filePath);
720
+ if (st.size === 0)
721
+ return null;
722
+ const TAIL_SIZE = 16384;
723
+ const readSize = Math.min(TAIL_SIZE, st.size);
724
+ const buf = Buffer.alloc(readSize);
725
+ const fd = (0, fs_1.openSync)(filePath, 'r');
726
+ try {
727
+ (0, fs_1.readSync)(fd, buf, 0, readSize, st.size - readSize);
728
+ }
729
+ finally {
730
+ (0, fs_1.closeSync)(fd);
731
+ }
732
+ const text = buf.toString('utf-8');
733
+ const lines = text.split('\n').filter(l => l.trim().length > 0);
734
+ let lastEntry = null;
735
+ let lastUserPrompt = '';
736
+ const accessedFiles = new Map();
737
+ const accessedUrls = new Map();
738
+ // Walk backwards through all parseable lines
739
+ for (let i = lines.length - 1; i >= 0; i--) {
740
+ try {
741
+ const obj = JSON.parse(lines[i]);
742
+ if (obj.type === 'summary' || obj.type === 'snapshot' || obj.type === 'file-history-snapshot')
743
+ continue;
744
+ if (!lastEntry)
745
+ lastEntry = obj;
746
+ const parsed = obj.timestamp ? new Date(obj.timestamp).getTime() : 0;
747
+ const ts = (parsed && !isNaN(parsed)) ? parsed : Date.now();
748
+ // Collect file paths from tool_use entries
749
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
750
+ for (const block of obj.message.content) {
751
+ if (block.type !== 'tool_use' || !block.input)
752
+ continue;
753
+ const name = block.name || '';
754
+ collectWebsiteUrls(block.input, accessedUrls, ts);
755
+ // Direct file_path from Read/Edit/Write/Glob tools
756
+ const fp = block.input.file_path || block.input.path || '';
757
+ if (fp && !accessedFiles.has(fp)) {
758
+ rememberTimestampedEntry(accessedFiles, fp, ts);
759
+ }
760
+ // Grep pattern → search path
761
+ if ((name === 'Grep' || name === 'grep') && block.input.path) {
762
+ rememberTimestampedEntry(accessedFiles, block.input.path, ts);
763
+ }
764
+ // Extract file paths from Bash commands
765
+ if (name === 'Bash' || name === 'bash') {
766
+ const cmd = block.input.command || '';
767
+ // Absolute /work/ paths
768
+ const absMatches = cmd.match(/\/work\/\S+/g);
769
+ if (absMatches) {
770
+ for (const p of absMatches) {
771
+ const clean = p.replace(/['"`;,)}\]]+$/, '');
772
+ rememberTimestampedEntry(accessedFiles, clean, ts);
773
+ }
774
+ }
775
+ for (const url of extractWebsiteUrlsFromText(cmd)) {
776
+ rememberTimestampedEntry(accessedUrls, url, ts);
777
+ }
778
+ // Relative paths from file-accessing commands (cat, head, tail, less, vi, etc.)
779
+ // Also handles: python script.py, node file.js, chmod, cp, mv, rm, etc.
780
+ const argMatches = cmd.match(/(?:cat|head|tail|less|more|vi|vim|nano|code|python3?|node|chmod|cp|mv|rm|touch|wc|diff|grep|rg|sed|awk|source|bash|sh)\s+(?:-\S+\s+)*([^\s|>&;]+)/g);
781
+ if (argMatches) {
782
+ for (const m of argMatches) {
783
+ // Extract the file argument (last non-flag token)
784
+ const tokens = m.split(/\s+/).filter((t) => !t.startsWith('-'));
785
+ const fileArg = tokens[tokens.length - 1] || '';
786
+ if (fileArg && !fileArg.startsWith('/') && !fileArg.startsWith('-') && (fileArg.includes('/') || fileArg.includes('.'))) {
787
+ const full = '/work/' + fileArg;
788
+ rememberTimestampedEntry(accessedFiles, full, ts);
789
+ }
790
+ }
791
+ }
792
+ }
793
+ }
794
+ }
795
+ // Also extract paths from tool_result content (file listings, etc.)
796
+ if (obj.type === 'user' && Array.isArray(obj.message?.content)) {
797
+ for (const block of obj.message.content) {
798
+ if (block.type !== 'tool_result')
799
+ continue;
800
+ const text = extractTextContent(block.content);
801
+ // Look for /work/ paths in tool output
802
+ const pathMatches = text.match(/\/work\/[^\s:]+/g);
803
+ if (pathMatches) {
804
+ for (const p of pathMatches.slice(0, 20)) { // cap to avoid perf issues
805
+ const clean = p.replace(/['"`;,)}\]]+$/, '');
806
+ rememberTimestampedEntry(accessedFiles, clean, ts);
807
+ }
808
+ }
809
+ for (const url of extractWebsiteUrlsFromText(text).slice(0, 30)) {
810
+ rememberTimestampedEntry(accessedUrls, url, ts);
811
+ }
812
+ }
813
+ }
814
+ // Find the last real user prompt
815
+ if (!lastUserPrompt && obj.type === 'user' && !obj.isMeta) {
816
+ const content = obj.message?.content;
817
+ if (typeof content === 'string' && content.length > 0) {
818
+ if (!content.includes('<local-command-caveat>')) {
819
+ lastUserPrompt = content.slice(0, 500);
820
+ }
821
+ }
822
+ else if (Array.isArray(content)) {
823
+ const textBlock = content.find((b) => b.type === 'text' && b.text);
824
+ if (textBlock && !textBlock.text.includes('<local-command-caveat>')) {
825
+ lastUserPrompt = textBlock.text.slice(0, 500);
826
+ }
827
+ }
828
+ }
829
+ }
830
+ catch {
831
+ // Line may be truncated at the start of our read window — skip it
832
+ }
209
833
  }
834
+ if (!lastEntry)
835
+ return null;
836
+ return { entry: lastEntry, lastUserPrompt, accessedFiles, accessedUrls, mtimeMs: st.mtimeMs };
837
+ }
838
+ catch {
839
+ return null;
840
+ }
841
+ }
842
+ /** Cache for JSONL file scanning (refreshed every 3s) */
843
+ let jsonlCache = null;
844
+ function getTranscriptRoots(agent, sandboxHome) {
845
+ if (agent === 'claude') {
846
+ return [(0, path_1.join)(sandboxHome, '.claude', 'projects')];
847
+ }
848
+ if (agent === 'codex') {
849
+ return [
850
+ (0, path_1.join)(sandboxHome, '.codex'),
851
+ (0, path_1.join)(sandboxHome, '.openai', 'codex'),
852
+ (0, path_1.join)(sandboxHome, '.config', 'codex'),
853
+ ];
854
+ }
855
+ return [];
856
+ }
857
+ function scanJsonlFiles(root, maxDepth = 4) {
858
+ const files = [];
859
+ const stack = [{ dir: root, depth: 0 }];
860
+ while (stack.length > 0) {
861
+ const current = stack.pop();
862
+ if (!current)
863
+ continue;
210
864
  try {
211
- if (url === '/' && method === 'GET') {
212
- serveHTML(res);
865
+ const entries = (0, fs_1.readdirSync)(current.dir);
866
+ for (const entry of entries) {
867
+ const fullPath = (0, path_1.join)(current.dir, entry);
868
+ try {
869
+ const st = (0, fs_1.statSync)(fullPath);
870
+ if (st.isDirectory()) {
871
+ if (current.depth < maxDepth) {
872
+ stack.push({ dir: fullPath, depth: current.depth + 1 });
873
+ }
874
+ continue;
875
+ }
876
+ if (entry.endsWith('.jsonl')) {
877
+ files.push({ path: fullPath, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs });
878
+ }
879
+ }
880
+ catch {
881
+ // Skip unreadable entries
882
+ }
213
883
  }
214
- else if (url === '/api/config' && method === 'GET') {
215
- handleGetConfig(req, res);
884
+ }
885
+ catch {
886
+ // Skip unreadable directories
887
+ }
888
+ }
889
+ return files;
890
+ }
891
+ /**
892
+ * Scan sandbox home for agent conversation JSONL files.
893
+ * Results are cached for 3 seconds.
894
+ */
895
+ function findProjectJsonlFiles(agent) {
896
+ const normalizedAgent = (agent || '').toLowerCase();
897
+ const now = Date.now();
898
+ if (jsonlCache &&
899
+ jsonlCache.agent === normalizedAgent &&
900
+ now - jsonlCache.ts < 2000) {
901
+ return jsonlCache.files;
902
+ }
903
+ const files = [];
904
+ const seen = new Set();
905
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
906
+ const roots = getTranscriptRoots(normalizedAgent, sandboxHome);
907
+ for (const root of roots) {
908
+ if (!(0, fs_1.existsSync)(root))
909
+ continue;
910
+ for (const file of scanJsonlFiles(root)) {
911
+ if (seen.has(file.path))
912
+ continue;
913
+ seen.add(file.path);
914
+ files.push(file);
915
+ }
916
+ }
917
+ jsonlCache = { agent: normalizedAgent, files, ts: now };
918
+ return files;
919
+ }
920
+ /**
921
+ * Extract a human-readable detail string from a JSONL entry's tool_use blocks.
922
+ */
923
+ function extractToolDetail(entry) {
924
+ if (!entry.message?.content)
925
+ return '';
926
+ const content = entry.message.content;
927
+ if (!Array.isArray(content))
928
+ return '';
929
+ for (const block of content) {
930
+ if (block.type === 'tool_use') {
931
+ const name = block.name || '';
932
+ const input = block.input || {};
933
+ if (name === 'Bash' || name === 'bash') {
934
+ const cmd = (input.command || '').slice(0, 60);
935
+ return cmd ? `Ran \`${cmd}\`` : `Running Bash`;
216
936
  }
217
- else if (url === '/api/config' && method === 'POST') {
218
- await handlePostConfig(req, res);
937
+ if (name === 'Edit' || name === 'edit') {
938
+ const file = (input.file_path || '').split('/').pop() || '';
939
+ return file ? `Edited ${file}` : 'Editing a file';
219
940
  }
220
- else if (url === '/api/config/path' && method === 'GET') {
221
- handleGetConfigPath(req, res);
941
+ if (name === 'Read' || name === 'read') {
942
+ const file = (input.file_path || '').split('/').pop() || '';
943
+ return file ? `Read ${file}` : 'Reading a file';
222
944
  }
223
- else if (url === '/api/sessions' && method === 'GET') {
224
- handleGetSessions(req, res);
945
+ if (name === 'Write' || name === 'write') {
946
+ const file = (input.file_path || '').split('/').pop() || '';
947
+ return file ? `Wrote ${file}` : 'Writing a file';
225
948
  }
226
- else if (url === '/api/logs' && method === 'GET') {
227
- handleGetLogs(req, res);
949
+ if (name === 'Grep' || name === 'grep') {
950
+ return `Searching for "${(input.pattern || '').slice(0, 40)}"`;
228
951
  }
229
- else {
230
- res.writeHead(404, { 'Content-Type': 'text/plain' });
231
- res.end('Not found');
952
+ if (name === 'Glob' || name === 'glob') {
953
+ return `Finding files: ${(input.pattern || '').slice(0, 40)}`;
232
954
  }
955
+ if (name === 'Task' || name === 'task') {
956
+ return `Spawned subagent`;
957
+ }
958
+ return `Using ${name}`;
233
959
  }
234
- catch (err) {
235
- res.writeHead(500, { 'Content-Type': 'text/plain' });
236
- res.end('Internal server error');
237
- }
960
+ }
961
+ return '';
962
+ }
963
+ /** Cache for blocked events scan */
964
+ let blockedCache = null;
965
+ /**
966
+ * Scan JSONL files for blocked command events.
967
+ * Looks for tool_result entries containing "[labgate] Command blocked:".
968
+ * Reads up to 64KB from the tail of each file. Cached for 5 seconds.
969
+ */
970
+ function scanBlockedEvents() {
971
+ const now = Date.now();
972
+ if (blockedCache && now - blockedCache.ts < 5000)
973
+ return blockedCache.events;
974
+ const events = [];
975
+ const seen = new Set();
976
+ const jsonlFiles = [...findProjectJsonlFiles('claude'), ...findProjectJsonlFiles('codex')]
977
+ .filter((f) => {
978
+ if (seen.has(f.path))
979
+ return false;
980
+ seen.add(f.path);
981
+ return true;
238
982
  });
239
- server.on('error', (err) => {
240
- if (err.code === 'EADDRINUSE') {
241
- // Port busy — try next port (up to 3 attempts)
242
- if (port < 7703) {
243
- server.listen(port + 1, '127.0.0.1');
983
+ for (const file of jsonlFiles) {
984
+ try {
985
+ const st = (0, fs_1.statSync)(file.path);
986
+ if (st.size === 0)
987
+ continue;
988
+ const TAIL_SIZE = 65536;
989
+ const readSize = Math.min(TAIL_SIZE, st.size);
990
+ const buf = Buffer.alloc(readSize);
991
+ const fd = (0, fs_1.openSync)(file.path, 'r');
992
+ try {
993
+ (0, fs_1.readSync)(fd, buf, 0, readSize, st.size - readSize);
244
994
  }
245
- else {
246
- log.warn(`Settings UI: ports 7700-7703 all in use, skipping`);
995
+ finally {
996
+ (0, fs_1.closeSync)(fd);
997
+ }
998
+ const text = buf.toString('utf-8');
999
+ // Quick check before expensive line-by-line parsing
1000
+ if (!text.includes('[labgate] Command blocked'))
1001
+ continue;
1002
+ const lines = text.split('\n');
1003
+ for (const line of lines) {
1004
+ if (!line.includes('[labgate] Command blocked'))
1005
+ continue;
1006
+ try {
1007
+ const obj = JSON.parse(line);
1008
+ if (obj.type !== 'user')
1009
+ continue;
1010
+ const content = obj.message?.content;
1011
+ if (!Array.isArray(content))
1012
+ continue;
1013
+ for (const block of content) {
1014
+ if (block.type !== 'tool_result')
1015
+ continue;
1016
+ const resultText = extractTextContent(block.content);
1017
+ const match = resultText.match(/\[labgate\] Command blocked: (\S+)/);
1018
+ if (match) {
1019
+ events.push({
1020
+ command: match[1],
1021
+ timestamp: obj.timestamp || '',
1022
+ sessionId: obj.sessionId || '',
1023
+ });
1024
+ }
1025
+ }
1026
+ }
1027
+ catch { /* skip unparseable lines */ }
1028
+ }
1029
+ }
1030
+ catch { /* skip inaccessible files */ }
1031
+ }
1032
+ // Sort newest first
1033
+ events.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
1034
+ blockedCache = { events, ts: now };
1035
+ return events;
1036
+ }
1037
+ // ── Per-session accumulated file history (persists across tail reads) ──
1038
+ /** sessionId → (path → most recent access timestamp) */
1039
+ const fileHistoryCache = new Map();
1040
+ const FILE_HISTORY_TTL = 60 * 60 * 1000; // 1 hour
1041
+ const websiteHistoryCache = new Map();
1042
+ const WEBSITE_HISTORY_TTL = 60 * 60 * 1000; // 1 hour
1043
+ /**
1044
+ * Merge newly discovered file accesses into the persistent per-session cache.
1045
+ * Entries older than 1 hour are evicted.
1046
+ */
1047
+ function mergeFileHistory(sessionId, newAccessed) {
1048
+ let history = fileHistoryCache.get(sessionId);
1049
+ if (!history) {
1050
+ history = new Map();
1051
+ fileHistoryCache.set(sessionId, history);
1052
+ }
1053
+ // Merge new accesses (keep newest timestamp per path)
1054
+ for (const [path, ts] of newAccessed) {
1055
+ if (!ts || isNaN(ts))
1056
+ continue;
1057
+ const existing = history.get(path) || 0;
1058
+ if (ts >= existing)
1059
+ history.set(path, ts);
1060
+ }
1061
+ // Evict entries older than 1 hour
1062
+ const cutoff = Date.now() - FILE_HISTORY_TTL;
1063
+ for (const [path, ts] of history) {
1064
+ if (ts < cutoff)
1065
+ history.delete(path);
1066
+ }
1067
+ return history;
1068
+ }
1069
+ /**
1070
+ * Merge newly discovered website accesses into the persistent per-session cache.
1071
+ * Entries older than 1 hour are evicted.
1072
+ */
1073
+ function mergeWebsiteHistory(sessionId, newAccessed) {
1074
+ let history = websiteHistoryCache.get(sessionId);
1075
+ if (!history) {
1076
+ history = new Map();
1077
+ websiteHistoryCache.set(sessionId, history);
1078
+ }
1079
+ for (const [url, ts] of newAccessed) {
1080
+ if (!ts || isNaN(ts))
1081
+ continue;
1082
+ rememberTimestampedEntry(history, url, ts);
1083
+ }
1084
+ const cutoff = Date.now() - WEBSITE_HISTORY_TTL;
1085
+ for (const [url, ts] of history) {
1086
+ if (ts < cutoff)
1087
+ history.delete(url);
1088
+ }
1089
+ return history;
1090
+ }
1091
+ /**
1092
+ * Build file access list from accumulated access history.
1093
+ * Only shows files/dirs that the agent actually touched — no directory listing.
1094
+ * JSONL paths are container paths (/work/...) mapped to host relative paths.
1095
+ */
1096
+ function getAccessedFiles(accessedFiles, workdir) {
1097
+ if (accessedFiles.size === 0)
1098
+ return [];
1099
+ const files = [];
1100
+ const seen = new Set();
1101
+ for (const [p, ts] of accessedFiles) {
1102
+ const rel = p.startsWith('/work/') ? p.slice(6) : p;
1103
+ if (!rel || seen.has(rel))
1104
+ continue;
1105
+ seen.add(rel);
1106
+ let isDir = false;
1107
+ if (p.startsWith('/work/')) {
1108
+ const hostPath = (0, path_1.join)(workdir, rel);
1109
+ try {
1110
+ isDir = (0, fs_1.statSync)(hostPath).isDirectory();
1111
+ }
1112
+ catch {
1113
+ // Fall back to a conservative heuristic when the file no longer exists
1114
+ const base = rel.split('/').pop() || '';
1115
+ isDir = !base.includes('.');
247
1116
  }
248
1117
  }
249
1118
  else {
250
- log.warn(`Settings UI failed: ${err.message}`);
1119
+ const normalized = rel.endsWith('/') ? rel.slice(0, -1) : rel;
1120
+ const base = normalized.split('/').pop() || normalized;
1121
+ isDir = !base.includes('.');
251
1122
  }
252
- });
253
- server.listen(port, '127.0.0.1', () => {
254
- const actualPort = server.address()?.port ?? port;
255
- log.step(`Settings: http://localhost:${actualPort}`);
256
- if (standalone) {
257
- log.step('Press Ctrl+C to stop');
1123
+ files.push({
1124
+ name: rel,
1125
+ path: p,
1126
+ accessedAt: ts,
1127
+ isDir,
1128
+ });
1129
+ }
1130
+ // Sort by recency (most recently accessed first)
1131
+ files.sort((a, b) => (b.accessedAt || 0) - (a.accessedAt || 0));
1132
+ return files.slice(0, 60);
1133
+ }
1134
+ function getAccessedWebsites(accessedUrls) {
1135
+ if (accessedUrls.size === 0)
1136
+ return [];
1137
+ const websites = [];
1138
+ for (const [url, ts] of accessedUrls) {
1139
+ let host = '';
1140
+ try {
1141
+ host = new URL(url).hostname;
258
1142
  }
259
- });
260
- // Don't keep the process alive just for the UI server
261
- server.unref();
1143
+ catch {
1144
+ host = url;
1145
+ }
1146
+ websites.push({
1147
+ url,
1148
+ host,
1149
+ accessedAt: ts,
1150
+ });
1151
+ }
1152
+ websites.sort((a, b) => (b.accessedAt || 0) - (a.accessedAt || 0));
1153
+ return websites.slice(0, 30);
1154
+ }
1155
+ /**
1156
+ * Determine an agent session's current activity by reading transcript JSONL.
1157
+ * Correlates session → JSONL by matching session start time with file creation time.
1158
+ */
1159
+ function getAgentActivity(session) {
1160
+ const emptyFiles = [];
1161
+ const emptyWebsites = [];
1162
+ const unknown = {
1163
+ status: 'unknown',
1164
+ label: '',
1165
+ detail: '',
1166
+ prompt: '',
1167
+ since: 0,
1168
+ lastActive: 0,
1169
+ files: emptyFiles,
1170
+ websites: emptyWebsites,
1171
+ };
1172
+ const agent = String(session.agent || '').toLowerCase();
1173
+ const jsonlFiles = findProjectJsonlFiles(agent);
1174
+ if (jsonlFiles.length === 0) {
1175
+ return { ...unknown, files: [], websites: [] };
1176
+ }
1177
+ const sessionStartMs = session.started ? new Date(session.started).getTime() : 0;
1178
+ if (!sessionStartMs)
1179
+ return unknown;
1180
+ const sorted = [...jsonlFiles].sort((a, b) => b.mtimeMs - a.mtimeMs);
1181
+ let bestFile = sorted.find(f => Math.abs(f.ctimeMs - sessionStartMs) < 5 * 60 * 1000);
1182
+ if (!bestFile) {
1183
+ bestFile = sorted.find(f => f.mtimeMs >= sessionStartMs);
1184
+ }
1185
+ if (!bestFile)
1186
+ return unknown;
1187
+ const result = tailLastJsonlEntry(bestFile.path);
1188
+ if (!result)
1189
+ return unknown;
1190
+ const { entry, lastUserPrompt, accessedFiles, accessedUrls, mtimeMs } = result;
1191
+ const age = Date.now() - mtimeMs;
1192
+ const type = entry.type || '';
1193
+ const entryTs = entry.timestamp ? new Date(entry.timestamp).getTime() : mtimeMs;
1194
+ // Merge tail-read file accesses into persistent per-session history
1195
+ const mergedHistory = mergeFileHistory(session.id || '', accessedFiles);
1196
+ const files = getAccessedFiles(mergedHistory, session.workdir || '');
1197
+ const mergedWebHistory = mergeWebsiteHistory(session.id || '', accessedUrls);
1198
+ const websites = getAccessedWebsites(mergedWebHistory);
1199
+ // Build the activity result, then attach files
1200
+ let activity;
1201
+ if (age > 60_000) {
1202
+ activity = { status: 'idle', label: 'Idle', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
1203
+ }
1204
+ else if (type === 'user') {
1205
+ const content = entry.message?.content;
1206
+ if (Array.isArray(content) && content.some((b) => b.type === 'tool_result')) {
1207
+ activity = { status: 'thinking', label: 'Processing...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
1208
+ }
1209
+ else {
1210
+ activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
1211
+ }
1212
+ }
1213
+ else if (type === 'human') {
1214
+ activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
1215
+ }
1216
+ else if (type === 'assistant') {
1217
+ const content = entry.message?.content;
1218
+ if (Array.isArray(content)) {
1219
+ const hasToolUse = content.some((b) => b.type === 'tool_use');
1220
+ if (hasToolUse) {
1221
+ const detail = extractToolDetail(entry);
1222
+ const toolBlock = content.find((b) => b.type === 'tool_use');
1223
+ const toolName = toolBlock?.name || 'tool';
1224
+ activity = { status: 'tool_use', label: `Running ${toolName}...`, detail, prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
1225
+ }
1226
+ else if (content.some((b) => b.type === 'thinking')) {
1227
+ activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
1228
+ }
1229
+ else {
1230
+ activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
1231
+ }
1232
+ }
1233
+ else {
1234
+ activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
1235
+ }
1236
+ }
1237
+ else {
1238
+ activity = { ...unknown, files, websites };
1239
+ }
1240
+ return activity;
1241
+ }
1242
+ function getSessionStatus(workdir) {
1243
+ try {
1244
+ const st = (0, fs_1.statSync)(workdir);
1245
+ const age = Date.now() - st.mtimeMs;
1246
+ return age < 60_000 ? 'active' : 'idle';
1247
+ }
1248
+ catch {
1249
+ // workdir inaccessible (e.g. remote NFS not mounted)
1250
+ return 'running';
1251
+ }
1252
+ }
1253
+ async function handleGetSessions(_req, res) {
1254
+ const dir = (0, config_js_1.getSessionsDir)();
1255
+ if (!(0, fs_1.existsSync)(dir)) {
1256
+ json(res, { sessions: [] });
1257
+ return;
1258
+ }
1259
+ const localHost = (0, os_1.hostname)();
1260
+ const files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'));
1261
+ const sessions = [];
1262
+ for (const file of files) {
1263
+ try {
1264
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
1265
+ // Check if the process is still alive (local node only)
1266
+ if (data.node === localHost) {
1267
+ try {
1268
+ process.kill(data.pid, 0);
1269
+ }
1270
+ catch {
1271
+ // Process is dead — clean up stale file
1272
+ try {
1273
+ (0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
1274
+ }
1275
+ catch { /* best effort */ }
1276
+ continue;
1277
+ }
1278
+ }
1279
+ data.status = getSessionStatus(data.workdir);
1280
+ data.activity = getAgentActivity(data);
1281
+ sessions.push(data);
1282
+ }
1283
+ catch { /* skip unparseable files */ }
1284
+ }
1285
+ // Collect container resource stats
1286
+ const sessionIds = sessions.map((s) => s.id).filter(Boolean);
1287
+ try {
1288
+ const stats = await collectContainerStats(sessionIds);
1289
+ for (const session of sessions) {
1290
+ const s = stats.get(session.id);
1291
+ if (s)
1292
+ session.stats = s;
1293
+ }
1294
+ }
1295
+ catch { /* stats unavailable */ }
1296
+ // Annotate sessions with restart-required status
1297
+ annotateRestartRequired(sessions);
1298
+ json(res, { sessions });
1299
+ }
1300
+ /**
1301
+ * Compare each session's config fingerprint against the current config.
1302
+ * Annotates sessions with `restartRequired` and `restartReasons` when they
1303
+ * are running with a stale mount configuration.
1304
+ */
1305
+ function annotateRestartRequired(sessions) {
1306
+ const config = (0, config_js_1.loadConfig)();
1307
+ const currentFingerprint = (0, container_js_1.computeMountFingerprint)(config);
1308
+ for (const session of sessions) {
1309
+ if (session.configFingerprint && session.configFingerprint !== currentFingerprint) {
1310
+ session.restartRequired = true;
1311
+ const reasons = [];
1312
+ if (session.image !== config.image)
1313
+ reasons.push('Container image changed');
1314
+ if (session.network !== config.network.mode)
1315
+ reasons.push('Network mode changed');
1316
+ if (reasons.length === 0)
1317
+ reasons.push('Mount config changed (datasets or paths)');
1318
+ session.restartReasons = reasons;
1319
+ }
1320
+ else {
1321
+ session.restartRequired = false;
1322
+ }
1323
+ }
1324
+ }
1325
+ function handleGetSecurity(_req, res) {
1326
+ const config = (0, config_js_1.loadConfig)();
1327
+ const blockedEvents = scanBlockedEvents();
1328
+ json(res, {
1329
+ blockedCommands: blockedEvents.slice(0, 20),
1330
+ blockedCount: blockedEvents.length,
1331
+ protection: {
1332
+ blacklistedCommands: config.commands.blacklist.length,
1333
+ blockedPatterns: config.filesystem.blocked_patterns.length,
1334
+ networkMode: config.network.mode,
1335
+ },
1336
+ });
1337
+ }
1338
+ async function handleStopSession(req, res) {
1339
+ try {
1340
+ const body = await readBody(req);
1341
+ const { id } = JSON.parse(body);
1342
+ if (!id) {
1343
+ json(res, { ok: false, error: 'Missing session id' }, 400);
1344
+ return;
1345
+ }
1346
+ const dir = (0, config_js_1.getSessionsDir)();
1347
+ const localHost = (0, os_1.hostname)();
1348
+ const sessionFile = (0, path_1.join)(dir, id + '.json');
1349
+ if (!(0, fs_1.existsSync)(sessionFile)) {
1350
+ json(res, { ok: false, error: 'Session not found' }, 404);
1351
+ return;
1352
+ }
1353
+ const data = JSON.parse((0, fs_1.readFileSync)(sessionFile, 'utf-8'));
1354
+ if (data.node !== localHost) {
1355
+ json(res, { ok: false, error: 'Session is on a different node (' + data.node + ')' }, 400);
1356
+ return;
1357
+ }
1358
+ try {
1359
+ process.kill(data.pid, 'SIGTERM');
1360
+ }
1361
+ catch {
1362
+ // Process already dead — clean up
1363
+ try {
1364
+ (0, fs_1.unlinkSync)(sessionFile);
1365
+ }
1366
+ catch { /* best effort */ }
1367
+ json(res, { ok: true });
1368
+ return;
1369
+ }
1370
+ json(res, { ok: true });
1371
+ }
1372
+ catch (err) {
1373
+ json(res, { ok: false, error: err.message ?? String(err) }, 500);
1374
+ }
1375
+ }
1376
+ /**
1377
+ * Validate a host path: exists, is a directory, and is readable.
1378
+ * Returns { valid: true/false, error?, path? } — advisory only, does not block.
1379
+ */
1380
+ async function handleValidatePath(req, res) {
1381
+ try {
1382
+ const body = await readBody(req);
1383
+ const { path: rawPath } = JSON.parse(body);
1384
+ if (!rawPath || typeof rawPath !== 'string') {
1385
+ json(res, { valid: false, error: 'Missing path' }, 400);
1386
+ return;
1387
+ }
1388
+ const resolved = rawPath.replace(/^~/, (0, os_1.homedir)());
1389
+ if (!(0, fs_1.existsSync)(resolved)) {
1390
+ json(res, { valid: false, error: 'Path does not exist', path: resolved });
1391
+ return;
1392
+ }
1393
+ const st = (0, fs_1.statSync)(resolved);
1394
+ if (!st.isDirectory()) {
1395
+ json(res, { valid: false, error: 'Path is not a directory', path: resolved });
1396
+ return;
1397
+ }
1398
+ try {
1399
+ (0, fs_1.readdirSync)(resolved);
1400
+ }
1401
+ catch {
1402
+ json(res, { valid: false, error: 'Path is not readable', path: resolved });
1403
+ return;
1404
+ }
1405
+ json(res, { valid: true, path: resolved });
1406
+ }
1407
+ catch (err) {
1408
+ json(res, { valid: false, error: err.message ?? String(err) }, 400);
1409
+ }
1410
+ }
1411
+ function handleGetLogs(_req, res) {
1412
+ const config = (0, config_js_1.loadConfig)();
1413
+ if (!config.audit.enabled) {
1414
+ json(res, { entries: [], message: 'Audit logging is disabled' });
1415
+ return;
1416
+ }
1417
+ const logDir = (0, config_js_1.getLogDir)(config);
1418
+ if (!(0, fs_1.existsSync)(logDir)) {
1419
+ json(res, { entries: [], message: 'No audit logs found yet' });
1420
+ return;
1421
+ }
1422
+ const files = (0, fs_1.readdirSync)(logDir)
1423
+ .filter((f) => f.endsWith('.jsonl'))
1424
+ .sort()
1425
+ .reverse();
1426
+ if (files.length === 0) {
1427
+ json(res, { entries: [], message: 'No audit logs found yet' });
1428
+ return;
1429
+ }
1430
+ const logFile = (0, path_1.resolve)(logDir, files[0]);
1431
+ try {
1432
+ const content = (0, fs_1.readFileSync)(logFile, 'utf-8').trim();
1433
+ if (!content) {
1434
+ json(res, { entries: [], message: 'Log file is empty' });
1435
+ return;
1436
+ }
1437
+ const allLines = content.split('\n');
1438
+ const lines = allLines.slice(-50);
1439
+ const entries = lines
1440
+ .map(line => { try {
1441
+ return JSON.parse(line);
1442
+ }
1443
+ catch {
1444
+ return null;
1445
+ } })
1446
+ .filter(Boolean)
1447
+ .reverse();
1448
+ json(res, { entries });
1449
+ }
1450
+ catch (err) {
1451
+ json(res, { entries: [], message: 'Could not read log file: ' + (err.message ?? String(err)) });
1452
+ }
1453
+ }
1454
+ let containerStatsCache = new Map();
1455
+ let containerStatsCacheTs = 0;
1456
+ const STATS_CACHE_TTL = 3000;
1457
+ /** Parse Docker's human-readable memory (e.g., "123.4MiB", "1.2GiB") to MB */
1458
+ function parseMemString(str) {
1459
+ if (!str)
1460
+ return 0;
1461
+ const match = str.match(/([\d.]+)\s*(KiB|MiB|GiB|B)/i);
1462
+ if (!match)
1463
+ return 0;
1464
+ const num = parseFloat(match[1]);
1465
+ switch (match[2].toLowerCase()) {
1466
+ case 'gib': return Math.round(num * 1024);
1467
+ case 'mib': return Math.round(num);
1468
+ case 'kib': return Math.round(num / 1024);
1469
+ default: return Math.round(num / (1024 * 1024));
1470
+ }
1471
+ }
1472
+ async function collectContainerStats(sessionIds) {
1473
+ const now = Date.now();
1474
+ if (now - containerStatsCacheTs < STATS_CACHE_TTL)
1475
+ return containerStatsCache;
1476
+ if (sessionIds.length === 0) {
1477
+ containerStatsCacheTs = now;
1478
+ containerStatsCache = new Map();
1479
+ return containerStatsCache;
1480
+ }
1481
+ const config = (0, config_js_1.loadConfig)();
1482
+ let runtimeType;
1483
+ try {
1484
+ const runtimeCheck = (0, runtime_js_1.checkRuntime)(config.runtime === 'auto' ? undefined : config.runtime);
1485
+ if (!runtimeCheck.ok || !runtimeCheck.runtime) {
1486
+ containerStatsCacheTs = now;
1487
+ containerStatsCache = new Map();
1488
+ return containerStatsCache;
1489
+ }
1490
+ runtimeType = runtimeCheck.runtime;
1491
+ }
1492
+ catch {
1493
+ containerStatsCacheTs = now;
1494
+ containerStatsCache = new Map();
1495
+ return containerStatsCache;
1496
+ }
1497
+ if ((0, runtime_js_1.isApptainerFamily)(runtimeType)) {
1498
+ containerStatsCacheTs = now;
1499
+ containerStatsCache = new Map();
1500
+ return containerStatsCache;
1501
+ }
1502
+ const containerNames = sessionIds.map(id => `labgate-${id}`);
1503
+ const stats = new Map();
1504
+ try {
1505
+ // Docker: use tab-separated format
1506
+ const result = await execFileAsync(runtimeType, [
1507
+ 'stats', '--no-stream',
1508
+ '--format', '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}',
1509
+ ...containerNames
1510
+ ], { timeout: 5000 });
1511
+ const lines = result.stdout.trim().split('\n');
1512
+ for (const line of lines) {
1513
+ const parts = line.split('\t');
1514
+ if (parts.length < 4)
1515
+ continue;
1516
+ const name = parts[0].trim();
1517
+ if (!name.startsWith('labgate-'))
1518
+ continue;
1519
+ const sessionId = name.slice('labgate-'.length);
1520
+ const memParts = parts[2].split('/');
1521
+ stats.set(sessionId, {
1522
+ cpuPercent: parseFloat(parts[1].replace('%', '').trim()) || 0,
1523
+ memUsageMB: parseMemString(memParts[0]?.trim() || ''),
1524
+ memLimitMB: parseMemString(memParts[1]?.trim() || ''),
1525
+ memPercent: parseFloat(parts[3].replace('%', '').trim()) || 0,
1526
+ });
1527
+ }
1528
+ }
1529
+ catch { /* stats unavailable — return empty map */ }
1530
+ containerStatsCacheTs = now;
1531
+ containerStatsCache = stats;
1532
+ return stats;
1533
+ }
1534
+ // ── SLURM API handlers ──────────────────────────────────────
1535
+ function handleGetSlurmJobs(reqUrl, res) {
1536
+ if (!slurmDB) {
1537
+ json(res, { ok: false, error: 'SLURM tracking not enabled' }, 400);
1538
+ return;
1539
+ }
1540
+ const state = reqUrl.searchParams.get('state') || undefined;
1541
+ const session_id = reqUrl.searchParams.get('session_id') || undefined;
1542
+ const search = reqUrl.searchParams.get('search') || undefined;
1543
+ const parsedLimit = parseInt(reqUrl.searchParams.get('limit') || '50', 10);
1544
+ const parsedOffset = parseInt(reqUrl.searchParams.get('offset') || '0', 10);
1545
+ const limit = Number.isFinite(parsedLimit) ? Math.min(500, Math.max(1, parsedLimit)) : 50;
1546
+ const offset = Number.isFinite(parsedOffset) ? Math.max(0, parsedOffset) : 0;
1547
+ const jobs = slurmDB.listJobs({ state, session_id, search, limit, offset });
1548
+ json(res, { ok: true, jobs });
1549
+ }
1550
+ function handleGetSlurmJob(pathname, res) {
1551
+ if (!slurmDB) {
1552
+ json(res, { ok: false, error: 'SLURM tracking not enabled' }, 400);
1553
+ return;
1554
+ }
1555
+ const jobId = pathname.match(/\/api\/slurm\/jobs\/([^/]+)$/)?.[1];
1556
+ if (!jobId) {
1557
+ json(res, { ok: false, error: 'Invalid job ID' }, 400);
1558
+ return;
1559
+ }
1560
+ const job = slurmDB.getJob(decodeURIComponent(jobId));
1561
+ if (!job) {
1562
+ json(res, { ok: false, error: 'Job not found' }, 404);
1563
+ return;
1564
+ }
1565
+ json(res, { ok: true, job });
1566
+ }
1567
+ function handleGetSlurmJobOutput(pathname, reqUrl, res) {
1568
+ if (!slurmDB) {
1569
+ json(res, { ok: false, error: 'SLURM tracking not enabled' }, 400);
1570
+ return;
1571
+ }
1572
+ const jobId = pathname.match(/\/api\/slurm\/jobs\/([^/]+)\/output$/)?.[1];
1573
+ if (!jobId) {
1574
+ json(res, { ok: false, error: 'Invalid job ID' }, 400);
1575
+ return;
1576
+ }
1577
+ const job = slurmDB.getJob(decodeURIComponent(jobId));
1578
+ if (!job) {
1579
+ json(res, { ok: false, error: 'Job not found' }, 404);
1580
+ return;
1581
+ }
1582
+ const streamParam = reqUrl.searchParams.get('stream') || 'stdout';
1583
+ if (streamParam !== 'stdout' && streamParam !== 'stderr') {
1584
+ json(res, { ok: false, error: 'Invalid stream. Use "stdout" or "stderr".' }, 400);
1585
+ return;
1586
+ }
1587
+ const stream = streamParam;
1588
+ const parsedTail = parseInt(reqUrl.searchParams.get('tail') || '100', 10);
1589
+ const tailLines = Number.isFinite(parsedTail) ? Math.min(5000, Math.max(1, parsedTail)) : 100;
1590
+ const filePath = stream === 'stderr' ? job.stderr_path : job.stdout_path;
1591
+ if (!filePath) {
1592
+ json(res, { ok: true, content: '', exists: false, size: 0, lines: 0 });
1593
+ return;
1594
+ }
1595
+ if (!(0, fs_1.existsSync)(filePath)) {
1596
+ json(res, { ok: true, content: '', exists: false, size: 0, lines: 0 });
1597
+ return;
1598
+ }
1599
+ try {
1600
+ const st = (0, fs_1.statSync)(filePath);
1601
+ // Tail-read: read last chunk and extract last N lines
1602
+ const maxBytes = 1_048_576; // 1MB cap
1603
+ const readSize = Math.min(st.size, maxBytes);
1604
+ let fd = null;
1605
+ const buf = Buffer.alloc(readSize);
1606
+ try {
1607
+ fd = (0, fs_1.openSync)(filePath, 'r');
1608
+ (0, fs_1.readSync)(fd, buf, 0, readSize, Math.max(0, st.size - readSize));
1609
+ }
1610
+ finally {
1611
+ if (fd !== null) {
1612
+ try {
1613
+ (0, fs_1.closeSync)(fd);
1614
+ }
1615
+ catch { /* best effort */ }
1616
+ }
1617
+ }
1618
+ let text = buf.toString('utf-8');
1619
+ if (st.size > readSize) {
1620
+ const firstNewline = text.indexOf('\n');
1621
+ if (firstNewline >= 0)
1622
+ text = text.slice(firstNewline + 1);
1623
+ }
1624
+ const allLines = text.split('\n');
1625
+ const sliced = allLines.slice(-tailLines);
1626
+ const lines = sliced.join('\n');
1627
+ json(res, { ok: true, content: lines, exists: true, size: st.size, lines: sliced.length });
1628
+ }
1629
+ catch (err) {
1630
+ json(res, { ok: false, error: `Failed to read output: ${err.message}` }, 500);
1631
+ }
1632
+ }
1633
+ async function handleCancelSlurmJob(pathname, res) {
1634
+ if (!slurmDB) {
1635
+ json(res, { ok: false, error: 'SLURM tracking not enabled' }, 400);
1636
+ return;
1637
+ }
1638
+ const jobId = pathname.match(/\/api\/slurm\/jobs\/([^/]+)\/cancel$/)?.[1];
1639
+ if (!jobId) {
1640
+ json(res, { ok: false, error: 'Invalid job ID' }, 400);
1641
+ return;
1642
+ }
1643
+ const decodedId = decodeURIComponent(jobId);
1644
+ const job = slurmDB.getJob(decodedId);
1645
+ if (!job) {
1646
+ json(res, { ok: false, error: 'Job not found' }, 404);
1647
+ return;
1648
+ }
1649
+ if (slurm_db_js_1.TERMINAL_STATES.has(job.state)) {
1650
+ json(res, { ok: false, error: `Job is already ${job.state}` }, 400);
1651
+ return;
1652
+ }
1653
+ try {
1654
+ await execFileAsync('scancel', [decodedId], { timeout: 10_000 });
1655
+ slurmDB.upsertJob({ job_id: decodedId, state: 'CANCELLED' });
1656
+ json(res, { ok: true, message: `Job ${decodedId} cancelled` });
1657
+ }
1658
+ catch (err) {
1659
+ json(res, { ok: false, error: `scancel failed: ${err.message}` }, 500);
1660
+ }
1661
+ }
1662
+ async function handleUpdateSlurmJobNotes(pathname, req, res) {
1663
+ if (!slurmDB) {
1664
+ json(res, { ok: false, error: 'SLURM tracking not enabled' }, 400);
1665
+ return;
1666
+ }
1667
+ const jobId = pathname.match(/\/api\/slurm\/jobs\/([^/]+)\/notes$/)?.[1];
1668
+ if (!jobId) {
1669
+ json(res, { ok: false, error: 'Invalid job ID' }, 400);
1670
+ return;
1671
+ }
1672
+ const decodedId = decodeURIComponent(jobId);
1673
+ const job = slurmDB.getJob(decodedId);
1674
+ if (!job) {
1675
+ json(res, { ok: false, error: 'Job not found' }, 404);
1676
+ return;
1677
+ }
1678
+ let parsed;
1679
+ try {
1680
+ parsed = JSON.parse(await readBody(req));
1681
+ }
1682
+ catch {
1683
+ json(res, { ok: false, error: 'Invalid JSON' }, 400);
1684
+ return;
1685
+ }
1686
+ if (parsed.notes !== null && typeof parsed.notes !== 'string') {
1687
+ json(res, { ok: false, error: 'notes must be a string or null' }, 400);
1688
+ return;
1689
+ }
1690
+ slurmDB.updateJobNotes(decodedId, parsed.notes);
1691
+ json(res, { ok: true });
1692
+ }
1693
+ function handleGetSlurmStats(res) {
1694
+ if (!slurmDB) {
1695
+ json(res, { ok: false, error: 'SLURM tracking not enabled' }, 400);
1696
+ return;
1697
+ }
1698
+ json(res, {
1699
+ ok: true,
1700
+ stats: {
1701
+ pending: slurmDB.getJobCount('PENDING'),
1702
+ running: slurmDB.getJobCount('RUNNING'),
1703
+ completed: slurmDB.getJobCount('COMPLETED'),
1704
+ failed: slurmDB.getJobCount('FAILED'),
1705
+ cancelled: slurmDB.getJobCount('CANCELLED'),
1706
+ timeout: slurmDB.getJobCount('TIMEOUT'),
1707
+ total: slurmDB.getJobCount(),
1708
+ },
1709
+ });
1710
+ }
1711
+ // ── SSE: Server-Sent Events for real-time dashboard updates ──
1712
+ const sseClients = new Set();
1713
+ function handleSSE(_req, res) {
1714
+ res.writeHead(200, {
1715
+ 'Content-Type': 'text/event-stream',
1716
+ 'Cache-Control': 'no-cache',
1717
+ 'Connection': 'keep-alive',
1718
+ 'Access-Control-Allow-Origin': '*',
1719
+ });
1720
+ res.write(':\n\n'); // comment to establish connection
1721
+ sseClients.add(res);
1722
+ res.on('close', () => sseClients.delete(res));
1723
+ }
1724
+ function broadcastSSE(event, data) {
1725
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1726
+ for (const client of sseClients) {
1727
+ try {
1728
+ client.write(payload);
1729
+ }
1730
+ catch {
1731
+ sseClients.delete(client);
1732
+ }
1733
+ }
1734
+ }
1735
+ // Push session updates to all connected SSE clients every 2 seconds
1736
+ let sseInterval = null;
1737
+ function startSSEBroadcast() {
1738
+ if (sseInterval)
1739
+ return;
1740
+ sseInterval = setInterval(async () => {
1741
+ if (sseClients.size === 0)
1742
+ return;
1743
+ // Reuse handleGetSessions logic
1744
+ const dir = (0, config_js_1.getSessionsDir)();
1745
+ const sessions = [];
1746
+ if ((0, fs_1.existsSync)(dir)) {
1747
+ const localHost = (0, os_1.hostname)();
1748
+ const files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'));
1749
+ for (const file of files) {
1750
+ try {
1751
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
1752
+ if (data.node === localHost) {
1753
+ try {
1754
+ process.kill(data.pid, 0);
1755
+ }
1756
+ catch {
1757
+ try {
1758
+ (0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
1759
+ }
1760
+ catch { /* best effort */ }
1761
+ continue;
1762
+ }
1763
+ }
1764
+ data.status = getSessionStatus(data.workdir);
1765
+ data.activity = getAgentActivity(data);
1766
+ sessions.push(data);
1767
+ }
1768
+ catch { /* skip */ }
1769
+ }
1770
+ }
1771
+ // Collect container resource stats
1772
+ const sessionIds = sessions.map((s) => s.id).filter(Boolean);
1773
+ try {
1774
+ const stats = await collectContainerStats(sessionIds);
1775
+ for (const session of sessions) {
1776
+ const s = stats.get(session.id);
1777
+ if (s)
1778
+ session.stats = s;
1779
+ }
1780
+ }
1781
+ catch { /* stats unavailable */ }
1782
+ // Annotate sessions with restart-required status
1783
+ annotateRestartRequired(sessions);
1784
+ broadcastSSE('sessions', { sessions });
1785
+ // Also broadcast security data
1786
+ const config = (0, config_js_1.loadConfig)();
1787
+ const blockedEvents = scanBlockedEvents();
1788
+ broadcastSSE('security', {
1789
+ blockedCommands: blockedEvents.slice(0, 20),
1790
+ blockedCount: blockedEvents.length,
1791
+ protection: {
1792
+ blacklistedCommands: config.commands.blacklist.length,
1793
+ blockedPatterns: config.filesystem.blocked_patterns.length,
1794
+ networkMode: config.network.mode,
1795
+ },
1796
+ });
1797
+ // Broadcast SLURM job data when enabled
1798
+ if (slurmDB) {
1799
+ try {
1800
+ const jobs = slurmDB.listJobs({ limit: 100 });
1801
+ broadcastSSE('slurm', {
1802
+ jobs,
1803
+ stats: {
1804
+ pending: slurmDB.getJobCount('PENDING'),
1805
+ running: slurmDB.getJobCount('RUNNING'),
1806
+ completed: slurmDB.getJobCount('COMPLETED'),
1807
+ failed: slurmDB.getJobCount('FAILED'),
1808
+ cancelled: slurmDB.getJobCount('CANCELLED'),
1809
+ timeout: slurmDB.getJobCount('TIMEOUT'),
1810
+ total: slurmDB.getJobCount(),
1811
+ },
1812
+ });
1813
+ }
1814
+ catch { /* slurm DB unavailable */ }
1815
+ }
1816
+ }, 2000);
1817
+ sseInterval.unref?.();
1818
+ }
1819
+ function getAdminAccessContext() {
1820
+ const effective = (0, config_js_1.loadEffectiveConfig)();
1821
+ return {
1822
+ effective,
1823
+ currentUser: (0, os_1.userInfo)().username,
1824
+ // Enterprise license is valid, but no valid policy is currently loaded.
1825
+ bootstrapPolicySetup: effective.enterprise && !effective.policy,
1826
+ };
1827
+ }
1828
+ function requireAdmin(res, opts) {
1829
+ const context = getAdminAccessContext();
1830
+ const { effective } = context;
1831
+ if (!effective.enterprise) {
1832
+ json(res, { ok: false, error: 'Forbidden: enterprise license required' }, 403);
1833
+ return null;
1834
+ }
1835
+ if (effective.isAdmin)
1836
+ return context;
1837
+ if (opts?.allowBootstrapPolicySetup && context.bootstrapPolicySetup) {
1838
+ return context;
1839
+ }
1840
+ json(res, { ok: false, error: 'Forbidden: admin access required' }, 403);
1841
+ return null;
1842
+ }
1843
+ function buildBootstrapPolicyTemplate(context) {
1844
+ return {
1845
+ version: 1,
1846
+ institution: context.effective.institution ?? 'Your Institution',
1847
+ admins: { usernames: [context.currentUser] },
1848
+ force: {},
1849
+ constraints: {},
1850
+ };
1851
+ }
1852
+ function handleGetEnterprise(_req, res) {
1853
+ const context = getAdminAccessContext();
1854
+ const { effective } = context;
1855
+ json(res, {
1856
+ enterprise: effective.enterprise,
1857
+ institution: effective.institution ?? null,
1858
+ isAdmin: effective.isAdmin,
1859
+ bootstrapPolicySetup: context.bootstrapPolicySetup,
1860
+ bootstrapUser: context.bootstrapPolicySetup ? context.currentUser : null,
1861
+ lockedFields: [...effective.lockedFields],
1862
+ lockedListItems: effective.lockedListItems,
1863
+ constraints: effective.constraints,
1864
+ licenseStatus: effective.licenseStatus
1865
+ ? {
1866
+ valid: effective.licenseStatus.valid,
1867
+ daysRemaining: effective.licenseStatus.daysRemaining,
1868
+ error: effective.licenseStatus.error,
1869
+ }
1870
+ : null,
1871
+ });
1872
+ }
1873
+ function handleGetAdminPolicy(_req, res) {
1874
+ const context = requireAdmin(res, { allowBootstrapPolicySetup: true });
1875
+ if (!context)
1876
+ return;
1877
+ const policy = (0, policy_js_1.loadPolicy)();
1878
+ if (policy) {
1879
+ json(res, { ok: true, policy, bootstrap: false });
1880
+ return;
1881
+ }
1882
+ if (context.bootstrapPolicySetup) {
1883
+ json(res, {
1884
+ ok: true,
1885
+ bootstrap: true,
1886
+ policy: buildBootstrapPolicyTemplate(context),
1887
+ message: `No policy is configured yet. Saving this file will make "${context.currentUser}" the initial admin.`,
1888
+ });
1889
+ return;
1890
+ }
1891
+ json(res, { ok: false, error: 'No policy file found' }, 404);
1892
+ }
1893
+ async function handlePostAdminPolicy(req, res) {
1894
+ const context = requireAdmin(res, { allowBootstrapPolicySetup: true });
1895
+ if (!context)
1896
+ return;
1897
+ try {
1898
+ const body = await readBody(req);
1899
+ const incoming = JSON.parse(body);
1900
+ const errors = (0, policy_js_1.validatePolicy)(incoming);
1901
+ if (errors.length > 0) {
1902
+ json(res, { ok: false, errors }, 400);
1903
+ return;
1904
+ }
1905
+ let policy = incoming;
1906
+ let addedBootstrapAdmin = false;
1907
+ // First-time setup guard: ensure the bootstrap user cannot lock themselves out.
1908
+ if (context.bootstrapPolicySetup) {
1909
+ const names = Array.isArray(policy.admins?.usernames) ? policy.admins.usernames : [];
1910
+ const alreadyIncluded = names.includes(context.currentUser);
1911
+ policy = {
1912
+ ...policy,
1913
+ admins: {
1914
+ usernames: [...new Set([context.currentUser, ...names])],
1915
+ },
1916
+ };
1917
+ addedBootstrapAdmin = !alreadyIncluded;
1918
+ }
1919
+ (0, policy_js_1.savePolicy)(policy);
1920
+ json(res, {
1921
+ ok: true,
1922
+ bootstrapCompleted: context.bootstrapPolicySetup,
1923
+ addedBootstrapAdmin,
1924
+ });
1925
+ }
1926
+ catch (err) {
1927
+ json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
1928
+ }
1929
+ }
1930
+ function handleGetAdminUsers(_req, res) {
1931
+ if (!requireAdmin(res))
1932
+ return;
1933
+ const effective = (0, config_js_1.loadEffectiveConfig)();
1934
+ const sharedDir = effective.sharedSessionsDir;
1935
+ // Collect sessions from shared dir or fall back to local sessions
1936
+ const sessionsDir = sharedDir && (0, fs_1.existsSync)(sharedDir) ? sharedDir : (0, config_js_1.getSessionsDir)();
1937
+ const sessions = [];
1938
+ if ((0, fs_1.existsSync)(sessionsDir)) {
1939
+ const files = (0, fs_1.readdirSync)(sessionsDir).filter((f) => f.endsWith('.json'));
1940
+ for (const f of files) {
1941
+ try {
1942
+ const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(sessionsDir, f), 'utf-8'));
1943
+ sessions.push(data);
1944
+ }
1945
+ catch {
1946
+ // Skip malformed session files
1947
+ }
1948
+ }
1949
+ }
1950
+ // Group by user (sessions have a 'user' field when written to shared dir)
1951
+ const userMap = new Map();
1952
+ for (const s of sessions) {
1953
+ const user = s.user ?? 'unknown';
1954
+ if (!userMap.has(user)) {
1955
+ userMap.set(user, { sessions: [], lastActivity: '' });
1956
+ }
1957
+ const entry = userMap.get(user);
1958
+ entry.sessions.push(s);
1959
+ const started = s.started ?? '';
1960
+ if (started > entry.lastActivity)
1961
+ entry.lastActivity = started;
1962
+ }
1963
+ const users = [...userMap.entries()].map(([username, data]) => ({
1964
+ username,
1965
+ activeSessions: data.sessions.length,
1966
+ lastActivity: data.lastActivity,
1967
+ sessions: data.sessions,
1968
+ }));
1969
+ json(res, { ok: true, users });
1970
+ }
1971
+ function handleGetAdminLogs(_req, res) {
1972
+ if (!requireAdmin(res))
1973
+ return;
1974
+ const effective = (0, config_js_1.loadEffectiveConfig)();
1975
+ const logDir = effective.sharedAuditDir && (0, fs_1.existsSync)(effective.sharedAuditDir)
1976
+ ? effective.sharedAuditDir
1977
+ : (0, config_js_1.getLogDir)(effective.config);
1978
+ if (!(0, fs_1.existsSync)(logDir)) {
1979
+ json(res, { ok: true, logs: [] });
1980
+ return;
1981
+ }
1982
+ const files = (0, fs_1.readdirSync)(logDir)
1983
+ .filter((f) => f.endsWith('.jsonl'))
1984
+ .sort()
1985
+ .reverse();
1986
+ const logs = [];
1987
+ const maxEntries = 100;
1988
+ for (const f of files) {
1989
+ if (logs.length >= maxEntries)
1990
+ break;
1991
+ try {
1992
+ const content = (0, fs_1.readFileSync)((0, path_1.join)(logDir, f), 'utf-8');
1993
+ const lines = content.split('\n').filter(l => l.trim());
1994
+ for (const line of lines.reverse()) {
1995
+ if (logs.length >= maxEntries)
1996
+ break;
1997
+ try {
1998
+ logs.push(JSON.parse(line));
1999
+ }
2000
+ catch { /* skip malformed lines */ }
2001
+ }
2002
+ }
2003
+ catch { /* skip unreadable files */ }
2004
+ }
2005
+ json(res, { ok: true, logs });
2006
+ }
2007
+ function handleGetAdminLicense(_req, res) {
2008
+ if (!requireAdmin(res))
2009
+ return;
2010
+ const status = (0, license_js_1.validateLicense)();
2011
+ json(res, { ok: true, license: status });
2012
+ }
2013
+ /**
2014
+ * Start the settings UI server.
2015
+ * Returns the HTTP server so callers can close it when done.
2016
+ * In standalone mode (`labgate ui`), pass standalone=true for extra logging.
2017
+ */
2018
+ function startUI(optsOrPort = {}, standaloneArg = true) {
2019
+ const options = typeof optsOrPort === 'number'
2020
+ ? { port: optsOrPort, standalone: standaloneArg }
2021
+ : optsOrPort;
2022
+ const standalone = options.standalone ?? true;
2023
+ const socketPath = options.socketPath || (0, config_js_1.getUiSocketPath)();
2024
+ const tcpPort = Number.isFinite(options.port) ? Math.floor(options.port) : null;
2025
+ const useTcp = tcpPort !== null;
2026
+ const requestedPort = tcpPort ?? 0;
2027
+ const maxPort = requestedPort + 3;
2028
+ const uiAccessToken = useTcp ? (0, crypto_1.randomBytes)(24).toString('hex') : '';
2029
+ let listenPort = requestedPort;
2030
+ let started = false;
2031
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)((0, config_js_1.getConfigPath)()));
2032
+ if (!useTcp) {
2033
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(socketPath));
2034
+ }
2035
+ const server = (0, http_1.createServer)(async (req, res) => {
2036
+ const url = req.url ?? '/';
2037
+ const reqUrl = new URL(url, 'http://localhost');
2038
+ const pathname = reqUrl.pathname;
2039
+ const method = req.method ?? 'GET';
2040
+ if (useTcp) {
2041
+ const auth = isAuthorizedRequest(req, reqUrl, uiAccessToken);
2042
+ if (!auth.ok) {
2043
+ if (pathname.startsWith('/api/')) {
2044
+ json(res, { ok: false, error: 'Unauthorized' }, 401);
2045
+ }
2046
+ else {
2047
+ res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
2048
+ res.end('Unauthorized. Open the UI with the full URL (including ?token=...).');
2049
+ }
2050
+ return;
2051
+ }
2052
+ if (auth.tokenFromQuery) {
2053
+ res.setHeader('Set-Cookie', `${UI_AUTH_COOKIE}=${uiAccessToken}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800`);
2054
+ }
2055
+ }
2056
+ if (method === 'OPTIONS') {
2057
+ if (pathname.startsWith('/api/')) {
2058
+ json(res, { ok: false, error: 'Not found' }, 404);
2059
+ }
2060
+ else {
2061
+ res.writeHead(204);
2062
+ res.end();
2063
+ }
2064
+ return;
2065
+ }
2066
+ try {
2067
+ if (pathname.startsWith('/api/') &&
2068
+ method !== 'GET' &&
2069
+ method !== 'HEAD' &&
2070
+ !requireWriteToken(req, res)) {
2071
+ return;
2072
+ }
2073
+ if (pathname === '/' && method === 'GET') {
2074
+ serveHTML(res);
2075
+ }
2076
+ else if (pathname === '/api/config' && method === 'GET') {
2077
+ handleGetConfig(req, res);
2078
+ }
2079
+ else if (pathname === '/api/config' && method === 'POST') {
2080
+ await handlePostConfig(req, res);
2081
+ }
2082
+ else if (pathname === '/api/config/path' && method === 'GET') {
2083
+ handleGetConfigPath(req, res);
2084
+ }
2085
+ else if (pathname === '/api/sessions' && method === 'GET') {
2086
+ await handleGetSessions(req, res);
2087
+ }
2088
+ else if (pathname === '/api/sessions/stop' && method === 'POST') {
2089
+ await handleStopSession(req, res);
2090
+ }
2091
+ else if (pathname === '/api/validate-path' && method === 'POST') {
2092
+ await handleValidatePath(req, res);
2093
+ }
2094
+ else if (/^\/api\/sessions\/[^/]+\/instructions$/.test(pathname) && method === 'GET') {
2095
+ handleGetSessionInstructions(pathname, reqUrl, res);
2096
+ }
2097
+ else if (/^\/api\/sessions\/[^/]+\/instructions$/.test(pathname) && method === 'PUT') {
2098
+ await handlePutSessionInstructions(pathname, req, res);
2099
+ }
2100
+ else if (pathname === '/api/logs' && method === 'GET') {
2101
+ handleGetLogs(req, res);
2102
+ }
2103
+ else if (pathname === '/api/security' && method === 'GET') {
2104
+ handleGetSecurity(req, res);
2105
+ }
2106
+ else if (pathname === '/api/events' && method === 'GET') {
2107
+ handleSSE(req, res);
2108
+ // ── SLURM API ──
2109
+ }
2110
+ else if (pathname === '/api/slurm/jobs' && method === 'GET') {
2111
+ handleGetSlurmJobs(reqUrl, res);
2112
+ }
2113
+ else if (/^\/api\/slurm\/jobs\/([^/]+)$/.test(pathname) && method === 'GET') {
2114
+ handleGetSlurmJob(pathname, res);
2115
+ }
2116
+ else if (/^\/api\/slurm\/jobs\/([^/]+)\/output$/.test(pathname) && method === 'GET') {
2117
+ handleGetSlurmJobOutput(pathname, reqUrl, res);
2118
+ }
2119
+ else if (/^\/api\/slurm\/jobs\/([^/]+)\/cancel$/.test(pathname) && method === 'POST') {
2120
+ await handleCancelSlurmJob(pathname, res);
2121
+ }
2122
+ else if (/^\/api\/slurm\/jobs\/([^/]+)\/notes$/.test(pathname) && method === 'PUT') {
2123
+ await handleUpdateSlurmJobNotes(pathname, req, res);
2124
+ }
2125
+ else if (pathname === '/api/slurm/stats' && method === 'GET') {
2126
+ handleGetSlurmStats(res);
2127
+ // ── Enterprise / Admin API ──
2128
+ }
2129
+ else if (pathname === '/api/enterprise' && method === 'GET') {
2130
+ handleGetEnterprise(req, res);
2131
+ }
2132
+ else if (pathname === '/api/admin/policy' && method === 'GET') {
2133
+ handleGetAdminPolicy(req, res);
2134
+ }
2135
+ else if (pathname === '/api/admin/policy' && method === 'POST') {
2136
+ await handlePostAdminPolicy(req, res);
2137
+ }
2138
+ else if (pathname === '/api/admin/users' && method === 'GET') {
2139
+ handleGetAdminUsers(req, res);
2140
+ }
2141
+ else if (pathname === '/api/admin/logs' && method === 'GET') {
2142
+ handleGetAdminLogs(req, res);
2143
+ }
2144
+ else if (pathname === '/api/admin/license' && method === 'GET') {
2145
+ handleGetAdminLicense(req, res);
2146
+ }
2147
+ else if (pathname.startsWith('/fonts/') && method === 'GET') {
2148
+ serveFontFile(pathname, res);
2149
+ }
2150
+ else {
2151
+ if (pathname.startsWith('/api/')) {
2152
+ json(res, { ok: false, error: 'Not found' }, 404);
2153
+ }
2154
+ else {
2155
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2156
+ res.end('Not found');
2157
+ }
2158
+ }
2159
+ }
2160
+ catch (err) {
2161
+ if (pathname.startsWith('/api/')) {
2162
+ json(res, { ok: false, error: 'Internal server error' }, 500);
2163
+ }
2164
+ else {
2165
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2166
+ res.end('Internal server error');
2167
+ }
2168
+ }
2169
+ });
2170
+ const bindTcp = (nextPort) => {
2171
+ listenPort = nextPort;
2172
+ server.listen(listenPort, '127.0.0.1');
2173
+ };
2174
+ const bindUnixSocket = () => {
2175
+ try {
2176
+ if ((0, fs_1.existsSync)(socketPath))
2177
+ (0, fs_1.unlinkSync)(socketPath);
2178
+ }
2179
+ catch {
2180
+ // Best effort cleanup of stale socket file.
2181
+ }
2182
+ server.listen(socketPath);
2183
+ };
2184
+ server.on('listening', () => {
2185
+ if (started)
2186
+ return;
2187
+ started = true;
2188
+ if (useTcp) {
2189
+ const actualPort = server.address()?.port ?? listenPort;
2190
+ log.step(`Settings: http://localhost:${actualPort}/?token=${uiAccessToken}`);
2191
+ }
2192
+ else {
2193
+ try {
2194
+ (0, fs_1.chmodSync)(socketPath, config_js_1.PRIVATE_FILE_MODE);
2195
+ }
2196
+ catch {
2197
+ // Best effort on platforms that do not support chmod on sockets.
2198
+ }
2199
+ log.step(`Settings socket: ${socketPath}`);
2200
+ log.step('Use `labgate ui --port 7700` for browser access on localhost.');
2201
+ }
2202
+ if (standalone) {
2203
+ log.step('Press Ctrl+C to stop');
2204
+ }
2205
+ // Initialise SLURM tracking if enabled
2206
+ const slurmConfig = (0, config_js_1.loadConfig)();
2207
+ if (slurmConfig.slurm.enabled && !slurmDB) {
2208
+ try {
2209
+ slurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
2210
+ slurmPoller = new slurm_poller_js_1.SlurmPoller({
2211
+ db: slurmDB,
2212
+ pollIntervalMs: slurmConfig.slurm.poll_interval_seconds * 1000,
2213
+ sacctLookbackHours: slurmConfig.slurm.sacct_lookback_hours,
2214
+ });
2215
+ slurmPoller.start();
2216
+ log.step('SLURM job tracking enabled');
2217
+ }
2218
+ catch (err) {
2219
+ log.warn(`SLURM tracking unavailable: ${err.message}`);
2220
+ }
2221
+ }
2222
+ startSSEBroadcast();
2223
+ });
2224
+ server.on('error', (err) => {
2225
+ if (!useTcp && err.code === 'EADDRINUSE') {
2226
+ try {
2227
+ (0, fs_1.unlinkSync)(socketPath);
2228
+ bindUnixSocket();
2229
+ return;
2230
+ }
2231
+ catch {
2232
+ // fall through to warning path
2233
+ }
2234
+ }
2235
+ if (useTcp && err.code === 'EADDRINUSE') {
2236
+ // Port busy — try next port (up to 3 attempts)
2237
+ if (listenPort < maxPort) {
2238
+ bindTcp(listenPort + 1);
2239
+ }
2240
+ else {
2241
+ log.warn(`Settings UI: ports ${requestedPort}-${maxPort} all in use, skipping`);
2242
+ }
2243
+ }
2244
+ else {
2245
+ log.warn(`Settings UI failed: ${err.message}`);
2246
+ }
2247
+ });
2248
+ if (useTcp) {
2249
+ bindTcp(requestedPort);
2250
+ }
2251
+ else {
2252
+ bindUnixSocket();
2253
+ }
2254
+ // Don't keep the process alive just for the UI server
2255
+ server.unref();
2256
+ server.on('close', () => {
2257
+ if (!useTcp) {
2258
+ try {
2259
+ if ((0, fs_1.existsSync)(socketPath))
2260
+ (0, fs_1.unlinkSync)(socketPath);
2261
+ }
2262
+ catch {
2263
+ // Best effort cleanup.
2264
+ }
2265
+ }
2266
+ // Cleanup SLURM resources
2267
+ if (slurmPoller) {
2268
+ slurmPoller.stop();
2269
+ slurmPoller = null;
2270
+ }
2271
+ if (slurmDB) {
2272
+ try {
2273
+ slurmDB.close();
2274
+ }
2275
+ catch { }
2276
+ slurmDB = null;
2277
+ }
2278
+ });
262
2279
  return server;
263
2280
  }
264
2281
  //# sourceMappingURL=ui.js.map