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.
- package/README.md +49 -4
- package/dist/cli.js +322 -19
- package/dist/cli.js.map +1 -1
- package/dist/lib/audit.d.ts +5 -1
- package/dist/lib/audit.js +19 -3
- package/dist/lib/audit.js.map +1 -1
- package/dist/lib/config.d.ts +71 -2
- package/dist/lib/config.js +192 -8
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/container.d.ts +54 -0
- package/dist/lib/container.js +650 -178
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/init.js +22 -9
- package/dist/lib/init.js.map +1 -1
- package/dist/lib/license.d.ts +44 -0
- package/dist/lib/license.js +164 -0
- package/dist/lib/license.js.map +1 -0
- package/dist/lib/policy.d.ts +85 -0
- package/dist/lib/policy.js +321 -0
- package/dist/lib/policy.js.map +1 -0
- package/dist/lib/runtime.d.ts +2 -2
- package/dist/lib/runtime.js +19 -36
- package/dist/lib/runtime.js.map +1 -1
- package/dist/lib/slurm-db.d.ts +51 -0
- package/dist/lib/slurm-db.js +179 -0
- package/dist/lib/slurm-db.js.map +1 -0
- package/dist/lib/slurm-mcp.d.ts +12 -0
- package/dist/lib/slurm-mcp.js +347 -0
- package/dist/lib/slurm-mcp.js.map +1 -0
- package/dist/lib/slurm-poller.d.ts +36 -0
- package/dist/lib/slurm-poller.js +423 -0
- package/dist/lib/slurm-poller.js.map +1 -0
- package/dist/lib/test/integration-harness.d.ts +44 -0
- package/dist/lib/test/integration-harness.js +260 -0
- package/dist/lib/test/integration-harness.js.map +1 -0
- package/dist/lib/ui.d.ts +34 -1
- package/dist/lib/ui.html +3081 -356
- package/dist/lib/ui.js +2123 -106
- package/dist/lib/ui.js.map +1 -1
- 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
|
|
70
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
'
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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, {
|
|
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
|
|
151
|
-
const
|
|
152
|
-
if (!
|
|
153
|
-
json(res, {
|
|
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
|
|
157
|
-
if (!
|
|
158
|
-
json(res, {
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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, {
|
|
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
|
-
*
|
|
195
|
-
*
|
|
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
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
949
|
+
if (name === 'Grep' || name === 'grep') {
|
|
950
|
+
return `Searching for "${(input.pattern || '').slice(0, 40)}"`;
|
|
228
951
|
}
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|