labgate 0.5.33 → 0.5.34

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/dist/lib/ui.js CHANGED
@@ -60,6 +60,7 @@ const explorer_store_js_1 = require("./explorer-store.js");
60
60
  const log = __importStar(require("./log.js"));
61
61
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
62
62
  const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
63
+ const PACKAGE_JSON_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'package.json');
63
64
  const FONTS_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', 'geist', 'dist', 'fonts');
64
65
  const XTERM_CSS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css');
65
66
  const XTERM_JS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'lib', 'xterm.js');
@@ -79,6 +80,12 @@ const PODMAN_SETUP_MAX_BUFFER = 16 * 1024 * 1024;
79
80
  const EXPLORER_TSP_TEMPLATE_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'templates', 'tsp-lab');
80
81
  const EXPLORER_TSP_TEMPLATE_SOURCE_REPO = (0, path_1.join)((0, config_js_1.getExplorerRootDir)(), 'templates', 'tsp-lab-source');
81
82
  const EXPLORER_ARTIFACT_READ_MAX_BYTES = 2 * 1024 * 1024;
83
+ const UI_VERSION_NPM_PACKAGE = 'labgate';
84
+ const UI_VERSION_CHECK_TIMEOUT_MS = 8_000;
85
+ const UI_VERSION_CHECK_MAX_BUFFER = 256 * 1024;
86
+ const UI_VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
87
+ const UI_SELF_UPDATE_TIMEOUT_MS = 20 * 60 * 1000;
88
+ const UI_SELF_UPDATE_MAX_BUFFER = 16 * 1024 * 1024;
82
89
  const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
83
90
  '\n' +
84
91
  'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
@@ -118,6 +125,18 @@ const CLAUDE_HEADLESS_STDERR_LIMIT = 12_000;
118
125
  const webTerminalInitJobs = new Map();
119
126
  const webTerminalImagePullLocks = new Map();
120
127
  const webTerminalAgentPrepLocks = new Map();
128
+ const LABGATE_UI_VERSION = readPackageVersion();
129
+ let uiPublishedVersionCache = null;
130
+ let uiPublishedVersionInFlight = null;
131
+ let uiSelfUpdateState = {
132
+ status: 'idle',
133
+ startedAt: null,
134
+ finishedAt: null,
135
+ message: '',
136
+ error: null,
137
+ latestVersion: null,
138
+ };
139
+ let uiSelfUpdatePromise = null;
121
140
  function getResultsStore() {
122
141
  if (!resultsStore) {
123
142
  resultsStore = new results_store_js_1.ResultsStore((0, config_js_1.getResultsDbPath)());
@@ -187,6 +206,256 @@ function commandErrorDetail(err) {
187
206
  .map((part) => String(part).trim())
188
207
  .join('\n');
189
208
  }
209
+ function readPackageVersion() {
210
+ try {
211
+ const parsed = JSON.parse((0, fs_1.readFileSync)(PACKAGE_JSON_PATH, 'utf-8'));
212
+ const version = typeof parsed?.version === 'string' ? parsed.version.trim() : '';
213
+ return version || '0.0.0';
214
+ }
215
+ catch {
216
+ return '0.0.0';
217
+ }
218
+ }
219
+ function stripVersionPrefix(raw) {
220
+ return String(raw || '').trim().replace(/^v/i, '');
221
+ }
222
+ function parseSemverTriplet(version) {
223
+ const normalized = stripVersionPrefix(version).split('-', 1)[0];
224
+ const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/);
225
+ if (!match)
226
+ return null;
227
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
228
+ }
229
+ function compareSemverStrings(a, b) {
230
+ const left = parseSemverTriplet(a);
231
+ const right = parseSemverTriplet(b);
232
+ if (!left || !right)
233
+ return null;
234
+ for (let i = 0; i < 3; i += 1) {
235
+ if (left[i] > right[i])
236
+ return 1;
237
+ if (left[i] < right[i])
238
+ return -1;
239
+ }
240
+ return 0;
241
+ }
242
+ function getUiBuildId() {
243
+ try {
244
+ const st = (0, fs_1.statSync)(HTML_PATH);
245
+ return `${LABGATE_UI_VERSION}:${st.size}:${Math.floor(st.mtimeMs)}`;
246
+ }
247
+ catch {
248
+ return `${LABGATE_UI_VERSION}:unknown`;
249
+ }
250
+ }
251
+ function normalizeNpmVersionValue(raw) {
252
+ if (typeof raw === 'string') {
253
+ const cleaned = stripVersionPrefix(raw).trim();
254
+ return cleaned || null;
255
+ }
256
+ if (Array.isArray(raw)) {
257
+ const versions = raw
258
+ .map((entry) => normalizeNpmVersionValue(entry))
259
+ .filter((entry) => !!entry);
260
+ if (versions.length === 0)
261
+ return null;
262
+ const sorted = [...versions].sort((a, b) => {
263
+ const cmp = compareSemverStrings(a, b);
264
+ if (cmp !== null)
265
+ return cmp;
266
+ return a.localeCompare(b);
267
+ });
268
+ return sorted[sorted.length - 1] || null;
269
+ }
270
+ return null;
271
+ }
272
+ function parseNpmVersionOutput(rawOutput) {
273
+ const text = String(rawOutput || '').trim();
274
+ if (!text)
275
+ return null;
276
+ try {
277
+ return normalizeNpmVersionValue(JSON.parse(text));
278
+ }
279
+ catch {
280
+ const cleaned = stripVersionPrefix(text.replace(/^"+|"+$/g, '').trim());
281
+ return cleaned || null;
282
+ }
283
+ }
284
+ function summarizeCommandError(err) {
285
+ const detail = commandErrorDetail(err);
286
+ const firstLine = detail
287
+ .split('\n')
288
+ .map((line) => line.trim())
289
+ .find((line) => line.length > 0);
290
+ if (!firstLine)
291
+ return 'Could not reach npm registry.';
292
+ return firstLine.length > 180 ? `${firstLine.slice(0, 177)}...` : firstLine;
293
+ }
294
+ async function fetchPublishedUiVersion() {
295
+ const checkedAt = new Date().toISOString();
296
+ try {
297
+ const result = await execFileAsync('npm', ['view', UI_VERSION_NPM_PACKAGE, 'version', '--json'], {
298
+ timeout: UI_VERSION_CHECK_TIMEOUT_MS,
299
+ maxBuffer: UI_VERSION_CHECK_MAX_BUFFER,
300
+ });
301
+ const latestVersion = parseNpmVersionOutput(String(result?.stdout || ''));
302
+ if (!latestVersion) {
303
+ return {
304
+ latestVersion: null,
305
+ checkedAt,
306
+ error: 'npm returned an unreadable version.',
307
+ };
308
+ }
309
+ return {
310
+ latestVersion,
311
+ checkedAt,
312
+ error: null,
313
+ };
314
+ }
315
+ catch (err) {
316
+ return {
317
+ latestVersion: null,
318
+ checkedAt,
319
+ error: summarizeCommandError(err),
320
+ };
321
+ }
322
+ }
323
+ async function getPublishedUiVersionCached(force = false) {
324
+ const now = Date.now();
325
+ if (!force && uiPublishedVersionCache && (now - uiPublishedVersionCache.fetchedAtMs) < UI_VERSION_CACHE_TTL_MS) {
326
+ return uiPublishedVersionCache.value;
327
+ }
328
+ if (!force && !uiPublishedVersionCache) {
329
+ return {
330
+ latestVersion: null,
331
+ checkedAt: '',
332
+ error: null,
333
+ };
334
+ }
335
+ if (uiPublishedVersionInFlight)
336
+ return uiPublishedVersionInFlight;
337
+ uiPublishedVersionInFlight = fetchPublishedUiVersion()
338
+ .then((value) => {
339
+ uiPublishedVersionCache = { value, fetchedAtMs: Date.now() };
340
+ return value;
341
+ })
342
+ .finally(() => {
343
+ uiPublishedVersionInFlight = null;
344
+ });
345
+ return uiPublishedVersionInFlight;
346
+ }
347
+ function getActiveContainerSessionCount() {
348
+ const dir = (0, config_js_1.getSessionsDir)();
349
+ if (!(0, fs_1.existsSync)(dir))
350
+ return 0;
351
+ const localHost = (0, os_1.hostname)();
352
+ const files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'));
353
+ let active = 0;
354
+ for (const file of files) {
355
+ try {
356
+ const path = (0, path_1.join)(dir, file);
357
+ const data = JSON.parse((0, fs_1.readFileSync)(path, 'utf-8'));
358
+ if (data.node === localHost) {
359
+ try {
360
+ process.kill(Number(data.pid), 0);
361
+ }
362
+ catch {
363
+ try {
364
+ (0, fs_1.unlinkSync)(path);
365
+ }
366
+ catch { /* best effort */ }
367
+ continue;
368
+ }
369
+ }
370
+ active += 1;
371
+ }
372
+ catch {
373
+ // Skip unparseable files.
374
+ }
375
+ }
376
+ return active;
377
+ }
378
+ async function getActiveWebTerminalSessionCount() {
379
+ const localHost = (0, os_1.hostname)();
380
+ const records = (0, web_terminal_js_1.listWebTerminalRecords)();
381
+ let active = 0;
382
+ for (const record of records) {
383
+ if (record.status !== 'running')
384
+ continue;
385
+ if (record.node !== localHost) {
386
+ active += 1;
387
+ continue;
388
+ }
389
+ let alive = false;
390
+ try {
391
+ alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
392
+ }
393
+ catch {
394
+ alive = false;
395
+ }
396
+ if (alive) {
397
+ active += 1;
398
+ }
399
+ else {
400
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, { status: 'exited', exitCode: record.exitCode ?? 0, error: null });
401
+ }
402
+ }
403
+ return active;
404
+ }
405
+ async function getActiveUiUsage() {
406
+ const containerSessions = getActiveContainerSessionCount();
407
+ const webTerminalSessions = await getActiveWebTerminalSessionCount();
408
+ return {
409
+ containerSessions,
410
+ webTerminalSessions,
411
+ total: containerSessions + webTerminalSessions,
412
+ };
413
+ }
414
+ function isUiUpdateInProgress() {
415
+ return !!uiSelfUpdatePromise || uiSelfUpdateState.status === 'running';
416
+ }
417
+ function runUiSelfUpdate() {
418
+ if (uiSelfUpdatePromise)
419
+ return;
420
+ const startedAt = new Date().toISOString();
421
+ uiSelfUpdateState = {
422
+ status: 'running',
423
+ startedAt,
424
+ finishedAt: null,
425
+ message: `Installing ${UI_VERSION_NPM_PACKAGE}@latest...`,
426
+ error: null,
427
+ latestVersion: null,
428
+ };
429
+ uiSelfUpdatePromise = (async () => {
430
+ try {
431
+ await execFileAsync('npm', ['install', '-g', `${UI_VERSION_NPM_PACKAGE}@latest`], {
432
+ timeout: UI_SELF_UPDATE_TIMEOUT_MS,
433
+ maxBuffer: UI_SELF_UPDATE_MAX_BUFFER,
434
+ });
435
+ const published = await getPublishedUiVersionCached(true);
436
+ uiSelfUpdateState = {
437
+ status: 'success',
438
+ startedAt,
439
+ finishedAt: new Date().toISOString(),
440
+ message: 'Update complete. Restart `labgate ui` to load the new version.',
441
+ error: null,
442
+ latestVersion: published.latestVersion,
443
+ };
444
+ }
445
+ catch (err) {
446
+ uiSelfUpdateState = {
447
+ status: 'error',
448
+ startedAt,
449
+ finishedAt: new Date().toISOString(),
450
+ message: 'Update failed.',
451
+ error: summarizeCommandError(err),
452
+ latestVersion: null,
453
+ };
454
+ }
455
+ })().finally(() => {
456
+ uiSelfUpdatePromise = null;
457
+ });
458
+ }
190
459
  function isPodmanNotReadyError(error) {
191
460
  return /podman is installed but not ready/i.test(error || '');
192
461
  }
@@ -1086,7 +1355,7 @@ function isClaudeAuthenticationFailure(event, assistantSnapshot, stderrText) {
1086
1355
  const authRe = /oauth token has expired|authentication_error|failed to authenticate|api error:\s*401/i;
1087
1356
  return authRe.test(packed) || authRe.test(message);
1088
1357
  }
1089
- function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSessionId) {
1358
+ function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSessionId, runWithAllowedPermissions) {
1090
1359
  const sandboxHome = (0, config_js_1.getSandboxHome)();
1091
1360
  const sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), (0, container_js_1.imageToSifName)(config.image));
1092
1361
  const resume = resumeSessionId.trim();
@@ -1128,6 +1397,7 @@ function buildClaudeHeadlessApptainerArgs(config, workdir, prompt, resumeSession
1128
1397
  '--output-format',
1129
1398
  'stream-json',
1130
1399
  '--include-partial-messages',
1400
+ ...(runWithAllowedPermissions ? ['--dangerously-skip-permissions'] : []),
1131
1401
  ...(resume ? ['--resume', resume] : []),
1132
1402
  prompt,
1133
1403
  ];
@@ -1380,7 +1650,7 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
1380
1650
  });
1381
1651
  return () => { };
1382
1652
  }
1383
- const args = buildClaudeHeadlessApptainerArgs(config, record.workdir, trimmedPrompt, resumeSessionId);
1653
+ const args = buildClaudeHeadlessApptainerArgs(config, record.workdir, trimmedPrompt, resumeSessionId, (0, config_js_1.shouldClaudeHeadlessRunWithAllowedPermissions)(config));
1384
1654
  const child = (0, child_process_1.spawn)('apptainer', args, {
1385
1655
  cwd: record.workdir,
1386
1656
  env: process.env,
@@ -1653,6 +1923,8 @@ async function handlePostConfig(req, res) {
1653
1923
  obj.audit = incoming.audit;
1654
1924
  if (incoming.slurm)
1655
1925
  obj.slurm = incoming.slurm;
1926
+ if (incoming.headless)
1927
+ obj.headless = incoming.headless;
1656
1928
  const { writeFileSync } = await import('fs');
1657
1929
  writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
1658
1930
  (0, config_js_1.ensurePrivateFile)(configPath);
@@ -1665,6 +1937,65 @@ async function handlePostConfig(req, res) {
1665
1937
  function handleGetConfigPath(_req, res) {
1666
1938
  json(res, { path: (0, config_js_1.getConfigPath)() });
1667
1939
  }
1940
+ async function handleGetUiVersion(reqUrl, res) {
1941
+ const forceRefresh = reqUrl.searchParams.get('refresh') === '1';
1942
+ const published = await getPublishedUiVersionCached(forceRefresh);
1943
+ const runningVersion = LABGATE_UI_VERSION;
1944
+ const latestVersion = published.latestVersion;
1945
+ let updateAvailable = false;
1946
+ if (latestVersion) {
1947
+ const cmp = compareSemverStrings(latestVersion, runningVersion);
1948
+ updateAvailable = cmp === null
1949
+ ? stripVersionPrefix(latestVersion) !== stripVersionPrefix(runningVersion)
1950
+ : cmp > 0;
1951
+ }
1952
+ json(res, {
1953
+ ok: true,
1954
+ runningVersion,
1955
+ uiBuildId: getUiBuildId(),
1956
+ latestVersion,
1957
+ latestCheckedAt: published.checkedAt,
1958
+ updateAvailable,
1959
+ updateCommand: `npm install -g ${UI_VERSION_NPM_PACKAGE}@latest`,
1960
+ restartCommand: 'labgate ui',
1961
+ checkError: published.error,
1962
+ });
1963
+ }
1964
+ function handleGetUiUpdateStatus(_req, res) {
1965
+ json(res, {
1966
+ ok: true,
1967
+ status: uiSelfUpdateState.status,
1968
+ startedAt: uiSelfUpdateState.startedAt,
1969
+ finishedAt: uiSelfUpdateState.finishedAt,
1970
+ message: uiSelfUpdateState.message,
1971
+ error: uiSelfUpdateState.error,
1972
+ latestVersion: uiSelfUpdateState.latestVersion,
1973
+ inProgress: isUiUpdateInProgress(),
1974
+ });
1975
+ }
1976
+ async function handlePostUiUpdate(_req, res) {
1977
+ if (isUiUpdateInProgress()) {
1978
+ json(res, { ok: false, code: 'in_progress', error: 'An update is already running.' }, 409);
1979
+ return;
1980
+ }
1981
+ const usage = await getActiveUiUsage();
1982
+ if (usage.total > 0) {
1983
+ json(res, {
1984
+ ok: false,
1985
+ code: 'in_use',
1986
+ error: 'Stop active sessions before updating LabGate.',
1987
+ activeUsage: usage,
1988
+ }, 409);
1989
+ return;
1990
+ }
1991
+ runUiSelfUpdate();
1992
+ json(res, {
1993
+ ok: true,
1994
+ started: true,
1995
+ status: uiSelfUpdateState.status,
1996
+ message: uiSelfUpdateState.message,
1997
+ });
1998
+ }
1668
1999
  const INSTRUCTION_FILE_MAP = {
1669
2000
  'agent.md': 'AGENTS.md',
1670
2001
  'agents.md': 'AGENTS.md',
@@ -5571,6 +5902,20 @@ function upgradeBadRequest(socket) {
5571
5902
  // Best effort.
5572
5903
  }
5573
5904
  }
5905
+ function upgradeServiceUnavailable(socket) {
5906
+ try {
5907
+ socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
5908
+ }
5909
+ catch {
5910
+ // Best effort.
5911
+ }
5912
+ try {
5913
+ socket.destroy();
5914
+ }
5915
+ catch {
5916
+ // Best effort.
5917
+ }
5918
+ }
5574
5919
  /**
5575
5920
  * Start the settings UI server.
5576
5921
  * Returns the HTTP server so callers can close it when done.
@@ -5790,6 +6135,18 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
5790
6135
  return;
5791
6136
  }
5792
6137
  try {
6138
+ if (isUiUpdateInProgress() &&
6139
+ pathname.startsWith('/api/') &&
6140
+ method !== 'GET' &&
6141
+ method !== 'HEAD' &&
6142
+ pathname !== '/api/ui/update') {
6143
+ json(res, {
6144
+ ok: false,
6145
+ code: 'update_in_progress',
6146
+ error: 'LabGate update in progress. Write actions are temporarily locked.',
6147
+ }, 503);
6148
+ return;
6149
+ }
5793
6150
  if (pathname.startsWith('/api/') &&
5794
6151
  method !== 'GET' &&
5795
6152
  method !== 'HEAD' &&
@@ -5808,6 +6165,15 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
5808
6165
  else if (pathname === '/api/config/path' && method === 'GET') {
5809
6166
  handleGetConfigPath(req, res);
5810
6167
  }
6168
+ else if (pathname === '/api/ui/version' && method === 'GET') {
6169
+ await handleGetUiVersion(reqUrl, res);
6170
+ }
6171
+ else if (pathname === '/api/ui/update/status' && method === 'GET') {
6172
+ handleGetUiUpdateStatus(req, res);
6173
+ }
6174
+ else if (pathname === '/api/ui/update' && method === 'POST') {
6175
+ await handlePostUiUpdate(req, res);
6176
+ }
5811
6177
  else if (pathname === '/api/sessions' && method === 'GET') {
5812
6178
  await handleGetSessions(req, res);
5813
6179
  }
@@ -6029,6 +6395,10 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
6029
6395
  upgradeBadRequest(socket);
6030
6396
  return;
6031
6397
  }
6398
+ if (isUiUpdateInProgress()) {
6399
+ upgradeServiceUnavailable(socket);
6400
+ return;
6401
+ }
6032
6402
  if (useTcp) {
6033
6403
  const auth = isAuthorizedRequest(req, reqUrl, uiAccessToken);
6034
6404
  if (!auth.ok) {