labgate 0.5.10 → 0.5.11
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 +50 -2
- package/dist/cli.js +356 -27
- package/dist/cli.js.map +1 -1
- package/dist/lib/cluster-mcp.d.ts +33 -0
- package/dist/lib/cluster-mcp.js +313 -0
- package/dist/lib/cluster-mcp.js.map +1 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +7 -3
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/container.d.ts +10 -0
- package/dist/lib/container.js +164 -32
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/dataset-mcp.d.ts +20 -0
- package/dist/lib/dataset-mcp.js +809 -0
- package/dist/lib/dataset-mcp.js.map +1 -0
- package/dist/lib/init.js +2 -2
- package/dist/lib/results-mcp.d.ts +9 -0
- package/dist/lib/results-mcp.js +205 -0
- package/dist/lib/results-mcp.js.map +1 -0
- package/dist/lib/results-store.d.ts +61 -0
- package/dist/lib/results-store.js +319 -0
- package/dist/lib/results-store.js.map +1 -0
- package/dist/lib/slurm-mcp.js +1 -1
- package/dist/lib/slurm-mcp.js.map +1 -1
- package/dist/lib/test/integration-harness.d.ts +4 -0
- package/dist/lib/test/integration-harness.js +13 -1
- package/dist/lib/test/integration-harness.js.map +1 -1
- package/dist/lib/ui.html +2068 -351
- package/dist/lib/ui.js +701 -0
- package/dist/lib/ui.js.map +1 -1
- package/dist/mcp-bundles/cluster-mcp.bundle.mjs +30235 -0
- package/dist/mcp-bundles/dataset-mcp.bundle.mjs +30968 -0
- package/dist/mcp-bundles/results-mcp.bundle.mjs +30449 -0
- package/dist/mcp-bundles/slurm-mcp.bundle.mjs +30501 -0
- package/package.json +4 -2
package/dist/lib/ui.js
CHANGED
|
@@ -46,6 +46,7 @@ const container_js_1 = require("./container.js");
|
|
|
46
46
|
const audit_js_1 = require("./audit.js");
|
|
47
47
|
const slurm_db_js_1 = require("./slurm-db.js");
|
|
48
48
|
const slurm_poller_js_1 = require("./slurm-poller.js");
|
|
49
|
+
const results_store_js_1 = require("./results-store.js");
|
|
49
50
|
const policy_js_1 = require("./policy.js");
|
|
50
51
|
const license_js_1 = require("./license.js");
|
|
51
52
|
const log = __importStar(require("./log.js"));
|
|
@@ -60,6 +61,13 @@ const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
|
|
|
60
61
|
// ── SLURM module state (initialised in startUI when slurm.enabled) ──
|
|
61
62
|
let slurmDB = null;
|
|
62
63
|
let slurmPoller = null;
|
|
64
|
+
let resultsStore = null;
|
|
65
|
+
function getResultsStore() {
|
|
66
|
+
if (!resultsStore) {
|
|
67
|
+
resultsStore = new results_store_js_1.ResultsStore((0, config_js_1.getResultsDbPath)());
|
|
68
|
+
}
|
|
69
|
+
return resultsStore;
|
|
70
|
+
}
|
|
63
71
|
function readBody(req) {
|
|
64
72
|
return new Promise((resolve, reject) => {
|
|
65
73
|
const chunks = [];
|
|
@@ -1372,6 +1380,93 @@ async function handleStopSession(req, res) {
|
|
|
1372
1380
|
json(res, { ok: false, error: err.message ?? String(err) }, 500);
|
|
1373
1381
|
}
|
|
1374
1382
|
}
|
|
1383
|
+
function resolveCliEntrypoint() {
|
|
1384
|
+
const distCli = (0, path_1.resolve)(__dirname, '..', 'cli.js');
|
|
1385
|
+
if ((0, fs_1.existsSync)(distCli))
|
|
1386
|
+
return distCli;
|
|
1387
|
+
const binCli = (0, path_1.resolve)(__dirname, '..', '..', 'bin', 'labgate.js');
|
|
1388
|
+
if ((0, fs_1.existsSync)(binCli))
|
|
1389
|
+
return binCli;
|
|
1390
|
+
throw new Error('Could not find LabGate CLI entrypoint');
|
|
1391
|
+
}
|
|
1392
|
+
function relaunchSessionDetached(agent, workdir) {
|
|
1393
|
+
const cliEntrypoint = resolveCliEntrypoint();
|
|
1394
|
+
const child = (0, child_process_1.spawn)(process.execPath, [cliEntrypoint, agent, workdir, '--no-footer'], {
|
|
1395
|
+
detached: true,
|
|
1396
|
+
stdio: 'ignore',
|
|
1397
|
+
env: process.env,
|
|
1398
|
+
});
|
|
1399
|
+
child.unref();
|
|
1400
|
+
}
|
|
1401
|
+
async function waitForSessionFileRemoval(sessionFile, timeoutMs = 10_000) {
|
|
1402
|
+
const deadline = Date.now() + timeoutMs;
|
|
1403
|
+
while ((0, fs_1.existsSync)(sessionFile) && Date.now() < deadline) {
|
|
1404
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
async function handleRestartSession(req, res) {
|
|
1408
|
+
try {
|
|
1409
|
+
const body = await readBody(req);
|
|
1410
|
+
const parsed = JSON.parse(body || '{}');
|
|
1411
|
+
const id = normalizeSessionId(parsed.id || '');
|
|
1412
|
+
if (!id) {
|
|
1413
|
+
json(res, { ok: false, error: 'Missing or invalid session id' }, 400);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const dir = (0, config_js_1.getSessionsDir)();
|
|
1417
|
+
const localHost = (0, os_1.hostname)();
|
|
1418
|
+
const sessionFile = (0, path_1.join)(dir, id + '.json');
|
|
1419
|
+
if (!(0, fs_1.existsSync)(sessionFile)) {
|
|
1420
|
+
json(res, { ok: false, error: 'Session not found' }, 404);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
const data = JSON.parse((0, fs_1.readFileSync)(sessionFile, 'utf-8'));
|
|
1424
|
+
if (data.node !== localHost) {
|
|
1425
|
+
json(res, { ok: false, error: 'Session is on a different node (' + data.node + ')' }, 400);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
const agent = String(data.agent || '').toLowerCase();
|
|
1429
|
+
const workdir = String(data.workdir || '');
|
|
1430
|
+
if (!agent || !workdir) {
|
|
1431
|
+
json(res, { ok: false, error: 'Session file is missing agent or workdir' }, 400);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
if (agent !== 'claude' && agent !== 'codex') {
|
|
1435
|
+
json(res, { ok: false, error: `Unsupported agent: ${agent}` }, 400);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
process.kill(data.pid, 'SIGTERM');
|
|
1440
|
+
}
|
|
1441
|
+
catch {
|
|
1442
|
+
// Process is already gone. Continue with cleanup + relaunch.
|
|
1443
|
+
}
|
|
1444
|
+
await waitForSessionFileRemoval(sessionFile);
|
|
1445
|
+
if ((0, fs_1.existsSync)(sessionFile)) {
|
|
1446
|
+
let stillRunning = false;
|
|
1447
|
+
try {
|
|
1448
|
+
process.kill(data.pid, 0);
|
|
1449
|
+
stillRunning = true;
|
|
1450
|
+
}
|
|
1451
|
+
catch {
|
|
1452
|
+
// Process is gone.
|
|
1453
|
+
}
|
|
1454
|
+
if (stillRunning) {
|
|
1455
|
+
json(res, { ok: false, error: 'Session did not stop in time. Please stop it first and retry.' }, 409);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
try {
|
|
1459
|
+
(0, fs_1.unlinkSync)(sessionFile);
|
|
1460
|
+
}
|
|
1461
|
+
catch { /* best effort */ }
|
|
1462
|
+
}
|
|
1463
|
+
relaunchSessionDetached(agent, workdir);
|
|
1464
|
+
json(res, { ok: true, restarted: { id, agent, workdir } });
|
|
1465
|
+
}
|
|
1466
|
+
catch (err) {
|
|
1467
|
+
json(res, { ok: false, error: err.message ?? String(err) }, 500);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1375
1470
|
/**
|
|
1376
1471
|
* Validate a host path: exists, is a directory, and is readable.
|
|
1377
1472
|
* Returns { valid: true/false, error?, path? } — advisory only, does not block.
|
|
@@ -1467,6 +1562,581 @@ async function collectContainerStats(sessionIds) {
|
|
|
1467
1562
|
containerStatsCache = new Map();
|
|
1468
1563
|
return containerStatsCache;
|
|
1469
1564
|
}
|
|
1565
|
+
function mapContainerPathToHost(path, sandboxHome) {
|
|
1566
|
+
if (path.startsWith('/home/sandbox/')) {
|
|
1567
|
+
return (0, path_1.join)(sandboxHome, path.slice('/home/sandbox/'.length));
|
|
1568
|
+
}
|
|
1569
|
+
if (path === '/labgate-config/config.json')
|
|
1570
|
+
return (0, config_js_1.getConfigPath)();
|
|
1571
|
+
if (path === '/labgate-config/slurm.db')
|
|
1572
|
+
return (0, config_js_1.getSlurmDbPath)();
|
|
1573
|
+
return path;
|
|
1574
|
+
}
|
|
1575
|
+
function readMcpConfigData() {
|
|
1576
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
1577
|
+
const mcpConfigPath = (0, path_1.join)(sandboxHome, '.claude.json');
|
|
1578
|
+
let mcpJson = {};
|
|
1579
|
+
try {
|
|
1580
|
+
if ((0, fs_1.existsSync)(mcpConfigPath)) {
|
|
1581
|
+
mcpJson = JSON.parse((0, fs_1.readFileSync)(mcpConfigPath, 'utf-8'));
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
mcpJson = {};
|
|
1586
|
+
}
|
|
1587
|
+
return {
|
|
1588
|
+
sandboxHome,
|
|
1589
|
+
mcpConfigPath,
|
|
1590
|
+
mcpConfigExists: (0, fs_1.existsSync)(mcpConfigPath),
|
|
1591
|
+
mcpJson,
|
|
1592
|
+
registeredServers: (mcpJson.mcpServers || {}),
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
function resolveServerPathFromEntry(entry, sandboxHome) {
|
|
1596
|
+
if (!entry || !Array.isArray(entry.args) || typeof entry.args[0] !== 'string')
|
|
1597
|
+
return null;
|
|
1598
|
+
return mapContainerPathToHost(entry.args[0], sandboxHome);
|
|
1599
|
+
}
|
|
1600
|
+
function resolveDbPathFromEntry(entry, sandboxHome) {
|
|
1601
|
+
if (!entry || !Array.isArray(entry.args))
|
|
1602
|
+
return null;
|
|
1603
|
+
const idx = entry.args.findIndex((arg) => arg === '--db');
|
|
1604
|
+
if (idx < 0 || typeof entry.args[idx + 1] !== 'string')
|
|
1605
|
+
return null;
|
|
1606
|
+
return mapContainerPathToHost(entry.args[idx + 1], sandboxHome);
|
|
1607
|
+
}
|
|
1608
|
+
function inferServerState(id, configured, entry, sandboxHome) {
|
|
1609
|
+
const registered = !!entry;
|
|
1610
|
+
if (!configured)
|
|
1611
|
+
return { configured, registered, ready: false, reason: 'disabled_in_config' };
|
|
1612
|
+
if (!registered)
|
|
1613
|
+
return { configured, registered, ready: false, reason: 'not_registered_yet' };
|
|
1614
|
+
const command = entry?.command;
|
|
1615
|
+
if (!command || typeof command !== 'string') {
|
|
1616
|
+
return { configured, registered, ready: false, reason: 'missing_command' };
|
|
1617
|
+
}
|
|
1618
|
+
const serverPath = resolveServerPathFromEntry(entry, sandboxHome);
|
|
1619
|
+
if (command === 'node' && (!serverPath || !(0, fs_1.existsSync)(serverPath))) {
|
|
1620
|
+
return { configured, registered, ready: false, reason: 'missing_bundle_or_script' };
|
|
1621
|
+
}
|
|
1622
|
+
if (id === 'labgate-slurm') {
|
|
1623
|
+
const usesSandboxModules = !!serverPath && serverPath.startsWith((0, path_1.join)(sandboxHome, '.mcp-servers'));
|
|
1624
|
+
const sqlitePkg = usesSandboxModules
|
|
1625
|
+
? (0, path_1.join)(sandboxHome, '.mcp-servers', 'node_modules', 'better-sqlite3')
|
|
1626
|
+
: (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', 'better-sqlite3');
|
|
1627
|
+
if (!(0, fs_1.existsSync)(sqlitePkg)) {
|
|
1628
|
+
return { configured, registered, ready: false, reason: 'missing_dependency_better_sqlite3' };
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
return { configured, registered, ready: true, reason: 'ready' };
|
|
1632
|
+
}
|
|
1633
|
+
function normalizeMcpEntryForHost(entry, sandboxHome) {
|
|
1634
|
+
const command = mapContainerPathToHost(String(entry.command || ''), sandboxHome);
|
|
1635
|
+
const args = Array.isArray(entry.args) ? entry.args.map((arg) => mapContainerPathToHost(String(arg), sandboxHome)) : [];
|
|
1636
|
+
const env = typeof entry.env === 'object' && entry.env ? { ...entry.env } : {};
|
|
1637
|
+
if (env.LABGATE_CONFIG_PATH)
|
|
1638
|
+
env.LABGATE_CONFIG_PATH = mapContainerPathToHost(env.LABGATE_CONFIG_PATH, sandboxHome);
|
|
1639
|
+
if (env.LABGATE_CONTAINER_MODE === '1')
|
|
1640
|
+
delete env.LABGATE_CONTAINER_MODE;
|
|
1641
|
+
return { command, args, env };
|
|
1642
|
+
}
|
|
1643
|
+
function collectMcpState() {
|
|
1644
|
+
const config = (0, config_js_1.loadConfig)();
|
|
1645
|
+
const { sandboxHome, mcpConfigPath, mcpConfigExists, registeredServers } = readMcpConfigData();
|
|
1646
|
+
const slurmConfigured = config.slurm.enabled && config.slurm.mcp_server;
|
|
1647
|
+
const clusterConfigured = config.slurm.enabled && config.slurm.mcp_server;
|
|
1648
|
+
const datasetsConfigured = true;
|
|
1649
|
+
const resultsConfigured = true;
|
|
1650
|
+
const slurmEntry = registeredServers['labgate-slurm'];
|
|
1651
|
+
const clusterEntry = registeredServers['labgate-cluster'];
|
|
1652
|
+
const datasetsEntry = registeredServers['labgate-datasets'];
|
|
1653
|
+
const resultsEntry = registeredServers['labgate-results'];
|
|
1654
|
+
const slurmState = inferServerState('labgate-slurm', slurmConfigured, slurmEntry, sandboxHome);
|
|
1655
|
+
const clusterState = inferServerState('labgate-cluster', clusterConfigured, clusterEntry, sandboxHome);
|
|
1656
|
+
const datasetsState = inferServerState('labgate-datasets', datasetsConfigured, datasetsEntry, sandboxHome);
|
|
1657
|
+
const resultsState = inferServerState('labgate-results', resultsConfigured, resultsEntry, sandboxHome);
|
|
1658
|
+
const servers = [
|
|
1659
|
+
{
|
|
1660
|
+
id: 'labgate-slurm',
|
|
1661
|
+
name: 'labgate-slurm',
|
|
1662
|
+
description: 'SLURM job tracker. Query job status, read output, and manage SLURM jobs.',
|
|
1663
|
+
active: slurmState.ready,
|
|
1664
|
+
configured: slurmState.configured,
|
|
1665
|
+
registered: slurmState.registered,
|
|
1666
|
+
ready: slurmState.ready,
|
|
1667
|
+
reason: slurmState.reason,
|
|
1668
|
+
command: slurmEntry?.command || null,
|
|
1669
|
+
args: Array.isArray(slurmEntry?.args) ? slurmEntry.args : null,
|
|
1670
|
+
env: slurmEntry?.env || null,
|
|
1671
|
+
mcpConfigPath,
|
|
1672
|
+
serverPath: resolveServerPathFromEntry(slurmEntry, sandboxHome),
|
|
1673
|
+
dbPath: resolveDbPathFromEntry(slurmEntry, sandboxHome),
|
|
1674
|
+
tools: [
|
|
1675
|
+
{ name: 'list_slurm_jobs', title: 'List SLURM Jobs', description: 'List tracked SLURM jobs with filtering by state or search' },
|
|
1676
|
+
{ name: 'get_slurm_job', title: 'Get SLURM Job Details', description: 'Get detailed info about a specific SLURM job' },
|
|
1677
|
+
{ name: 'get_slurm_output', title: 'Read SLURM Job Output', description: 'Read stdout/stderr output of a SLURM job' },
|
|
1678
|
+
{ name: 'cancel_slurm_job', title: 'Cancel SLURM Job', description: 'Cancel a pending or running SLURM job' },
|
|
1679
|
+
{ name: 'set_slurm_job_notes', title: 'Set SLURM Job Notes', description: 'Add or update notes on a SLURM job' },
|
|
1680
|
+
],
|
|
1681
|
+
},
|
|
1682
|
+
{
|
|
1683
|
+
id: 'labgate-cluster',
|
|
1684
|
+
name: 'labgate-cluster',
|
|
1685
|
+
description: 'Cluster helper. Inspect partitions, limits, queue pressure, and available environment modules.',
|
|
1686
|
+
active: clusterState.ready,
|
|
1687
|
+
configured: clusterState.configured,
|
|
1688
|
+
registered: clusterState.registered,
|
|
1689
|
+
ready: clusterState.ready,
|
|
1690
|
+
reason: clusterState.reason,
|
|
1691
|
+
command: clusterEntry?.command || null,
|
|
1692
|
+
args: Array.isArray(clusterEntry?.args) ? clusterEntry.args : null,
|
|
1693
|
+
env: clusterEntry?.env || null,
|
|
1694
|
+
mcpConfigPath,
|
|
1695
|
+
serverPath: resolveServerPathFromEntry(clusterEntry, sandboxHome),
|
|
1696
|
+
dbPath: resolveDbPathFromEntry(clusterEntry, sandboxHome),
|
|
1697
|
+
tools: [
|
|
1698
|
+
{ name: 'get_cluster_partitions', title: 'Get Cluster Partitions', description: 'List SLURM partitions with availability, node counts, and limits' },
|
|
1699
|
+
{ name: 'get_partition_limits', title: 'Get Partition Limits', description: 'Show partition policy limits from SLURM configuration' },
|
|
1700
|
+
{ name: 'get_queue_pressure', title: 'Get Queue Pressure', description: 'Summarize pending/running load by partition' },
|
|
1701
|
+
{ name: 'find_module', title: 'Find Environment Modules', description: 'Search available HPC environment modules before job submission' },
|
|
1702
|
+
],
|
|
1703
|
+
},
|
|
1704
|
+
{
|
|
1705
|
+
id: 'labgate-datasets',
|
|
1706
|
+
name: 'labgate-datasets',
|
|
1707
|
+
description: 'Dataset manager. Register, browse, search, and inspect datasets mounted in the sandbox.',
|
|
1708
|
+
active: datasetsState.ready,
|
|
1709
|
+
configured: datasetsState.configured,
|
|
1710
|
+
registered: datasetsState.registered,
|
|
1711
|
+
ready: datasetsState.ready,
|
|
1712
|
+
reason: datasetsState.reason,
|
|
1713
|
+
command: datasetsEntry?.command || null,
|
|
1714
|
+
args: Array.isArray(datasetsEntry?.args) ? datasetsEntry.args : null,
|
|
1715
|
+
env: datasetsEntry?.env || null,
|
|
1716
|
+
mcpConfigPath,
|
|
1717
|
+
serverPath: resolveServerPathFromEntry(datasetsEntry, sandboxHome),
|
|
1718
|
+
dbPath: resolveDbPathFromEntry(datasetsEntry, sandboxHome),
|
|
1719
|
+
tools: [
|
|
1720
|
+
{ name: 'list_datasets', title: 'List Datasets', description: 'List all configured datasets with stats' },
|
|
1721
|
+
{ name: 'inspect_dataset', title: 'Inspect Dataset', description: 'Browse dataset directory contents' },
|
|
1722
|
+
{ name: 'search_dataset', title: 'Search Dataset Files', description: 'Search for files by pattern in a dataset' },
|
|
1723
|
+
{ name: 'get_dataset_summary', title: 'Dataset Summary', description: 'Get size, file count, and extension breakdown' },
|
|
1724
|
+
{ name: 'read_dataset_file', title: 'Read Dataset File', description: 'Read text file contents from a dataset' },
|
|
1725
|
+
{ name: 'validate_dataset', title: 'Validate Dataset', description: 'Validate dataset path/name/mode before registration' },
|
|
1726
|
+
{ name: 'register_dataset', title: 'Register Dataset', description: 'Add a host directory as a named dataset' },
|
|
1727
|
+
{ name: 'update_dataset', title: 'Update Dataset', description: 'Update an existing dataset registration' },
|
|
1728
|
+
{ name: 'unregister_dataset', title: 'Unregister Dataset', description: 'Remove a dataset from the config' },
|
|
1729
|
+
],
|
|
1730
|
+
},
|
|
1731
|
+
{
|
|
1732
|
+
id: 'labgate-results',
|
|
1733
|
+
name: 'labgate-results',
|
|
1734
|
+
description: 'Results registry. Record and retrieve structured findings across sessions.',
|
|
1735
|
+
active: resultsState.ready,
|
|
1736
|
+
configured: resultsState.configured,
|
|
1737
|
+
registered: resultsState.registered,
|
|
1738
|
+
ready: resultsState.ready,
|
|
1739
|
+
reason: resultsState.reason,
|
|
1740
|
+
command: resultsEntry?.command || null,
|
|
1741
|
+
args: Array.isArray(resultsEntry?.args) ? resultsEntry.args : null,
|
|
1742
|
+
env: resultsEntry?.env || null,
|
|
1743
|
+
mcpConfigPath,
|
|
1744
|
+
serverPath: resolveServerPathFromEntry(resultsEntry, sandboxHome),
|
|
1745
|
+
dbPath: null,
|
|
1746
|
+
tools: [
|
|
1747
|
+
{ name: 'list_results', title: 'List Results', description: 'List recorded results with filtering and pagination' },
|
|
1748
|
+
{ name: 'register_result', title: 'Register Result', description: 'Create a new structured result entry' },
|
|
1749
|
+
{ name: 'get_result', title: 'Get Result', description: 'Retrieve one result by id' },
|
|
1750
|
+
{ name: 'update_result', title: 'Update Result', description: 'Update an existing result entry' },
|
|
1751
|
+
{ name: 'delete_result', title: 'Delete Result', description: 'Delete a result entry' },
|
|
1752
|
+
],
|
|
1753
|
+
},
|
|
1754
|
+
];
|
|
1755
|
+
return {
|
|
1756
|
+
mcpConfigPath,
|
|
1757
|
+
mcpConfigExists,
|
|
1758
|
+
servers,
|
|
1759
|
+
activeCount: servers.filter((s) => s.ready).length,
|
|
1760
|
+
checkedAt: new Date().toISOString(),
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
function handleGetMcp(_req, res) {
|
|
1764
|
+
const state = collectMcpState();
|
|
1765
|
+
json(res, { ok: true, ...state });
|
|
1766
|
+
}
|
|
1767
|
+
function handleGetMcpConfigRaw(_req, res) {
|
|
1768
|
+
const { mcpConfigPath, mcpConfigExists } = collectMcpState();
|
|
1769
|
+
if (!mcpConfigExists || !(0, fs_1.existsSync)(mcpConfigPath)) {
|
|
1770
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1771
|
+
res.end('MCP config file does not exist yet.');
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
const content = (0, fs_1.readFileSync)(mcpConfigPath, 'utf-8');
|
|
1775
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1776
|
+
res.end(content);
|
|
1777
|
+
}
|
|
1778
|
+
async function runMcpHealthCheck(serverId, entry) {
|
|
1779
|
+
const started = Date.now();
|
|
1780
|
+
const { sandboxHome } = readMcpConfigData();
|
|
1781
|
+
const normalized = normalizeMcpEntryForHost(entry, sandboxHome);
|
|
1782
|
+
if (!normalized.command) {
|
|
1783
|
+
return { ok: false, message: 'Missing command in MCP entry.', durationMs: Date.now() - started, command: '', args: [] };
|
|
1784
|
+
}
|
|
1785
|
+
return await new Promise((resolveDone) => {
|
|
1786
|
+
const child = (0, child_process_1.spawn)(normalized.command, normalized.args, {
|
|
1787
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1788
|
+
env: { ...process.env, ...normalized.env },
|
|
1789
|
+
});
|
|
1790
|
+
let done = false;
|
|
1791
|
+
let stdoutBuf = '';
|
|
1792
|
+
let stderrBuf = '';
|
|
1793
|
+
let initDone = false;
|
|
1794
|
+
const timeout = setTimeout(() => finish(false, 'Timed out waiting for MCP handshake.'), 8_000);
|
|
1795
|
+
function finish(ok, message, toolCount) {
|
|
1796
|
+
if (done)
|
|
1797
|
+
return;
|
|
1798
|
+
done = true;
|
|
1799
|
+
clearTimeout(timeout);
|
|
1800
|
+
try {
|
|
1801
|
+
child.kill('SIGTERM');
|
|
1802
|
+
}
|
|
1803
|
+
catch { /* best effort */ }
|
|
1804
|
+
resolveDone({
|
|
1805
|
+
ok,
|
|
1806
|
+
message,
|
|
1807
|
+
toolCount,
|
|
1808
|
+
durationMs: Date.now() - started,
|
|
1809
|
+
stderr: stderrBuf.trim() || undefined,
|
|
1810
|
+
command: normalized.command,
|
|
1811
|
+
args: normalized.args,
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
function send(message) {
|
|
1815
|
+
try {
|
|
1816
|
+
child.stdin.write(JSON.stringify(message) + '\n');
|
|
1817
|
+
}
|
|
1818
|
+
catch {
|
|
1819
|
+
finish(false, 'Failed to write to MCP process stdin.');
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
function handleRpcMessage(msg) {
|
|
1823
|
+
if (done)
|
|
1824
|
+
return;
|
|
1825
|
+
if (msg && msg.id === 1) {
|
|
1826
|
+
if (msg.error) {
|
|
1827
|
+
finish(false, `initialize failed: ${msg.error.message || JSON.stringify(msg.error)}`);
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
initDone = true;
|
|
1831
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
|
|
1832
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
if (msg && msg.id === 2) {
|
|
1836
|
+
if (msg.error) {
|
|
1837
|
+
finish(false, `tools/list failed: ${msg.error.message || JSON.stringify(msg.error)}`);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
const tools = Array.isArray(msg?.result?.tools) ? msg.result.tools : [];
|
|
1841
|
+
finish(true, `MCP handshake successful (${tools.length} tools).`, tools.length);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
child.on('error', (err) => {
|
|
1845
|
+
finish(false, `Failed to spawn MCP server: ${err.message}`);
|
|
1846
|
+
});
|
|
1847
|
+
child.stdout.on('data', (chunk) => {
|
|
1848
|
+
stdoutBuf += chunk.toString('utf-8');
|
|
1849
|
+
let idx = stdoutBuf.indexOf('\n');
|
|
1850
|
+
while (idx >= 0) {
|
|
1851
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
1852
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
1853
|
+
if (line) {
|
|
1854
|
+
try {
|
|
1855
|
+
const msg = JSON.parse(line);
|
|
1856
|
+
handleRpcMessage(msg);
|
|
1857
|
+
}
|
|
1858
|
+
catch {
|
|
1859
|
+
// Ignore non-JSON output lines.
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
idx = stdoutBuf.indexOf('\n');
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
child.stderr.on('data', (chunk) => {
|
|
1866
|
+
stderrBuf += chunk.toString('utf-8');
|
|
1867
|
+
if (stderrBuf.length > 4000)
|
|
1868
|
+
stderrBuf = stderrBuf.slice(-4000);
|
|
1869
|
+
});
|
|
1870
|
+
child.on('exit', (code, signal) => {
|
|
1871
|
+
if (done)
|
|
1872
|
+
return;
|
|
1873
|
+
const detail = initDone
|
|
1874
|
+
? `MCP process exited before tools/list response (code ${code ?? 'null'}, signal ${signal ?? 'none'}).`
|
|
1875
|
+
: `MCP process exited before initialize response (code ${code ?? 'null'}, signal ${signal ?? 'none'}).`;
|
|
1876
|
+
finish(false, detail);
|
|
1877
|
+
});
|
|
1878
|
+
send({
|
|
1879
|
+
jsonrpc: '2.0',
|
|
1880
|
+
id: 1,
|
|
1881
|
+
method: 'initialize',
|
|
1882
|
+
params: {
|
|
1883
|
+
protocolVersion: '2024-11-05',
|
|
1884
|
+
capabilities: {},
|
|
1885
|
+
clientInfo: { name: 'labgate-ui', version: '1.0.0' },
|
|
1886
|
+
},
|
|
1887
|
+
});
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
async function handlePostMcpReregister(_req, res) {
|
|
1891
|
+
try {
|
|
1892
|
+
const config = (0, config_js_1.loadConfig)();
|
|
1893
|
+
(0, container_js_1.prepareMcpServers)({
|
|
1894
|
+
agent: 'claude',
|
|
1895
|
+
workdir: process.cwd(),
|
|
1896
|
+
config,
|
|
1897
|
+
dryRun: false,
|
|
1898
|
+
});
|
|
1899
|
+
const state = collectMcpState();
|
|
1900
|
+
json(res, { ok: true, message: 'MCP servers re-registered.', ...state });
|
|
1901
|
+
}
|
|
1902
|
+
catch (err) {
|
|
1903
|
+
json(res, { ok: false, error: err.message ?? String(err) }, 500);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
async function handlePostMcpTest(req, res) {
|
|
1907
|
+
try {
|
|
1908
|
+
const body = await readBody(req);
|
|
1909
|
+
const parsed = JSON.parse(body || '{}');
|
|
1910
|
+
const id = String(parsed.id || '').trim();
|
|
1911
|
+
if (!id) {
|
|
1912
|
+
json(res, { ok: false, error: 'Missing server id.' }, 400);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const state = collectMcpState();
|
|
1916
|
+
const server = state.servers.find((s) => s.id === id);
|
|
1917
|
+
if (!server) {
|
|
1918
|
+
json(res, { ok: false, error: `Unknown server id: ${id}` }, 404);
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (!server.registered) {
|
|
1922
|
+
json(res, { ok: false, error: `Server "${id}" is not registered.`, result: null }, 400);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
const { registeredServers } = readMcpConfigData();
|
|
1926
|
+
const entry = registeredServers[id];
|
|
1927
|
+
if (!entry || !entry.command) {
|
|
1928
|
+
json(res, { ok: false, error: `Server "${id}" has invalid command config.`, result: null }, 400);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
const result = await runMcpHealthCheck(id, entry);
|
|
1932
|
+
json(res, { ok: result.ok, server: id, result });
|
|
1933
|
+
}
|
|
1934
|
+
catch (err) {
|
|
1935
|
+
json(res, { ok: false, error: err.message ?? String(err) }, 500);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
// ── Results API handlers ────────────────────────────────────
|
|
1939
|
+
function parseResultPayload(raw, mode) {
|
|
1940
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
1941
|
+
return { ok: false, error: 'Body must be a JSON object.' };
|
|
1942
|
+
}
|
|
1943
|
+
if (mode === 'create' && typeof raw.title !== 'string') {
|
|
1944
|
+
return { ok: false, error: 'title is required and must be a string.' };
|
|
1945
|
+
}
|
|
1946
|
+
const optionalStringFields = ['title', 'summary', 'source', 'session_id', 'workdir'];
|
|
1947
|
+
for (const field of optionalStringFields) {
|
|
1948
|
+
if (raw[field] !== undefined && raw[field] !== null && typeof raw[field] !== 'string') {
|
|
1949
|
+
return { ok: false, error: `${field} must be a string or null.` };
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
if (raw.details !== undefined && raw.details !== null && typeof raw.details !== 'string') {
|
|
1953
|
+
return { ok: false, error: 'details must be a string or null.' };
|
|
1954
|
+
}
|
|
1955
|
+
if (raw.tags !== undefined &&
|
|
1956
|
+
(!Array.isArray(raw.tags) || raw.tags.some((v) => typeof v !== 'string'))) {
|
|
1957
|
+
return { ok: false, error: 'tags must be an array of strings.' };
|
|
1958
|
+
}
|
|
1959
|
+
if (raw.artifacts !== undefined &&
|
|
1960
|
+
(!Array.isArray(raw.artifacts) || raw.artifacts.some((v) => typeof v !== 'string'))) {
|
|
1961
|
+
return { ok: false, error: 'artifacts must be an array of strings.' };
|
|
1962
|
+
}
|
|
1963
|
+
if (raw.metadata !== undefined &&
|
|
1964
|
+
raw.metadata !== null &&
|
|
1965
|
+
(typeof raw.metadata !== 'object' || Array.isArray(raw.metadata))) {
|
|
1966
|
+
return { ok: false, error: 'metadata must be an object or null.' };
|
|
1967
|
+
}
|
|
1968
|
+
const payload = {
|
|
1969
|
+
title: raw.title,
|
|
1970
|
+
summary: raw.summary,
|
|
1971
|
+
details: raw.details,
|
|
1972
|
+
source: raw.source,
|
|
1973
|
+
session_id: raw.session_id,
|
|
1974
|
+
workdir: raw.workdir,
|
|
1975
|
+
tags: raw.tags,
|
|
1976
|
+
artifacts: raw.artifacts,
|
|
1977
|
+
metadata: raw.metadata,
|
|
1978
|
+
};
|
|
1979
|
+
if (mode === 'update') {
|
|
1980
|
+
const hasPatch = payload.title !== undefined ||
|
|
1981
|
+
payload.summary !== undefined ||
|
|
1982
|
+
payload.details !== undefined ||
|
|
1983
|
+
payload.source !== undefined ||
|
|
1984
|
+
payload.session_id !== undefined ||
|
|
1985
|
+
payload.workdir !== undefined ||
|
|
1986
|
+
payload.tags !== undefined ||
|
|
1987
|
+
payload.artifacts !== undefined ||
|
|
1988
|
+
payload.metadata !== undefined;
|
|
1989
|
+
if (!hasPatch) {
|
|
1990
|
+
return { ok: false, error: 'No update fields provided.' };
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
return { ok: true, payload };
|
|
1994
|
+
}
|
|
1995
|
+
function handleGetResults(reqUrl, res) {
|
|
1996
|
+
const parsedLimit = parseInt(reqUrl.searchParams.get('limit') || '100', 10);
|
|
1997
|
+
const parsedOffset = parseInt(reqUrl.searchParams.get('offset') || '0', 10);
|
|
1998
|
+
const limit = Number.isFinite(parsedLimit) ? Math.min(500, Math.max(1, parsedLimit)) : 100;
|
|
1999
|
+
const offset = Number.isFinite(parsedOffset) ? Math.max(0, parsedOffset) : 0;
|
|
2000
|
+
const store = getResultsStore();
|
|
2001
|
+
const listed = store.listResults({
|
|
2002
|
+
search: reqUrl.searchParams.get('search') || undefined,
|
|
2003
|
+
source: reqUrl.searchParams.get('source') || undefined,
|
|
2004
|
+
tag: reqUrl.searchParams.get('tag') || undefined,
|
|
2005
|
+
limit,
|
|
2006
|
+
offset,
|
|
2007
|
+
});
|
|
2008
|
+
json(res, {
|
|
2009
|
+
ok: true,
|
|
2010
|
+
total: listed.total,
|
|
2011
|
+
returned: listed.results.length,
|
|
2012
|
+
limit,
|
|
2013
|
+
offset,
|
|
2014
|
+
results: listed.results,
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
function handleGetResult(pathname, res) {
|
|
2018
|
+
const id = pathname.match(/\/api\/results\/([^/]+)$/)?.[1];
|
|
2019
|
+
if (!id) {
|
|
2020
|
+
json(res, { ok: false, error: 'Invalid result id' }, 400);
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
const store = getResultsStore();
|
|
2024
|
+
const result = store.getResult(decodeURIComponent(id));
|
|
2025
|
+
if (!result) {
|
|
2026
|
+
json(res, { ok: false, error: 'Result not found' }, 404);
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
json(res, { ok: true, result });
|
|
2030
|
+
}
|
|
2031
|
+
async function handlePostResults(req, res) {
|
|
2032
|
+
let body;
|
|
2033
|
+
try {
|
|
2034
|
+
body = JSON.parse(await readBody(req));
|
|
2035
|
+
}
|
|
2036
|
+
catch {
|
|
2037
|
+
json(res, { ok: false, error: 'Invalid JSON' }, 400);
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
const parsed = parseResultPayload(body, 'create');
|
|
2041
|
+
if (!parsed.ok) {
|
|
2042
|
+
json(res, { ok: false, error: parsed.error }, 400);
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
try {
|
|
2046
|
+
const store = getResultsStore();
|
|
2047
|
+
const result = store.createResult(parsed.payload);
|
|
2048
|
+
try {
|
|
2049
|
+
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
2050
|
+
(0, audit_js_1.writeAuditEvent)(effective.config, {
|
|
2051
|
+
timestamp: new Date().toISOString(),
|
|
2052
|
+
session: result.session_id || 'results-ui',
|
|
2053
|
+
event: 'result_registered',
|
|
2054
|
+
result_id: result.id,
|
|
2055
|
+
title: result.title,
|
|
2056
|
+
source: result.source,
|
|
2057
|
+
}, { sharedAuditDir: effective.sharedAuditDir });
|
|
2058
|
+
}
|
|
2059
|
+
catch { /* best effort */ }
|
|
2060
|
+
json(res, { ok: true, result }, 201);
|
|
2061
|
+
}
|
|
2062
|
+
catch (err) {
|
|
2063
|
+
json(res, { ok: false, error: err.message ?? String(err) }, 400);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
async function handlePutResult(pathname, req, res) {
|
|
2067
|
+
const id = pathname.match(/\/api\/results\/([^/]+)$/)?.[1];
|
|
2068
|
+
if (!id) {
|
|
2069
|
+
json(res, { ok: false, error: 'Invalid result id' }, 400);
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
let body;
|
|
2073
|
+
try {
|
|
2074
|
+
body = JSON.parse(await readBody(req));
|
|
2075
|
+
}
|
|
2076
|
+
catch {
|
|
2077
|
+
json(res, { ok: false, error: 'Invalid JSON' }, 400);
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
const parsed = parseResultPayload(body, 'update');
|
|
2081
|
+
if (!parsed.ok) {
|
|
2082
|
+
json(res, { ok: false, error: parsed.error }, 400);
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
try {
|
|
2086
|
+
const store = getResultsStore();
|
|
2087
|
+
const result = store.updateResult(decodeURIComponent(id), parsed.payload);
|
|
2088
|
+
if (!result) {
|
|
2089
|
+
json(res, { ok: false, error: 'Result not found' }, 404);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
try {
|
|
2093
|
+
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
2094
|
+
(0, audit_js_1.writeAuditEvent)(effective.config, {
|
|
2095
|
+
timestamp: new Date().toISOString(),
|
|
2096
|
+
session: result.session_id || 'results-ui',
|
|
2097
|
+
event: 'result_updated',
|
|
2098
|
+
result_id: result.id,
|
|
2099
|
+
title: result.title,
|
|
2100
|
+
}, { sharedAuditDir: effective.sharedAuditDir });
|
|
2101
|
+
}
|
|
2102
|
+
catch { /* best effort */ }
|
|
2103
|
+
json(res, { ok: true, result });
|
|
2104
|
+
}
|
|
2105
|
+
catch (err) {
|
|
2106
|
+
json(res, { ok: false, error: err.message ?? String(err) }, 400);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
async function handleDeleteResult(pathname, res) {
|
|
2110
|
+
const id = pathname.match(/\/api\/results\/([^/]+)$/)?.[1];
|
|
2111
|
+
if (!id) {
|
|
2112
|
+
json(res, { ok: false, error: 'Invalid result id' }, 400);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
const decodedId = decodeURIComponent(id);
|
|
2116
|
+
const store = getResultsStore();
|
|
2117
|
+
const existing = store.getResult(decodedId);
|
|
2118
|
+
if (!existing) {
|
|
2119
|
+
json(res, { ok: false, error: 'Result not found' }, 404);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
const ok = store.deleteResult(decodedId);
|
|
2123
|
+
if (!ok) {
|
|
2124
|
+
json(res, { ok: false, error: 'Result not found' }, 404);
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
try {
|
|
2128
|
+
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
2129
|
+
(0, audit_js_1.writeAuditEvent)(effective.config, {
|
|
2130
|
+
timestamp: new Date().toISOString(),
|
|
2131
|
+
session: existing.session_id || 'results-ui',
|
|
2132
|
+
event: 'result_deleted',
|
|
2133
|
+
result_id: existing.id,
|
|
2134
|
+
title: existing.title,
|
|
2135
|
+
}, { sharedAuditDir: effective.sharedAuditDir });
|
|
2136
|
+
}
|
|
2137
|
+
catch { /* best effort */ }
|
|
2138
|
+
json(res, { ok: true });
|
|
2139
|
+
}
|
|
1470
2140
|
// ── SLURM API handlers ──────────────────────────────────────
|
|
1471
2141
|
function handleGetSlurmJobs(reqUrl, res) {
|
|
1472
2142
|
if (!slurmDB) {
|
|
@@ -2024,6 +2694,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
2024
2694
|
else if (pathname === '/api/sessions/stop' && method === 'POST') {
|
|
2025
2695
|
await handleStopSession(req, res);
|
|
2026
2696
|
}
|
|
2697
|
+
else if (pathname === '/api/sessions/restart' && method === 'POST') {
|
|
2698
|
+
await handleRestartSession(req, res);
|
|
2699
|
+
}
|
|
2027
2700
|
else if (pathname === '/api/validate-path' && method === 'POST') {
|
|
2028
2701
|
await handleValidatePath(req, res);
|
|
2029
2702
|
}
|
|
@@ -2039,6 +2712,33 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
2039
2712
|
else if (pathname === '/api/security' && method === 'GET') {
|
|
2040
2713
|
handleGetSecurity(req, res);
|
|
2041
2714
|
}
|
|
2715
|
+
else if (pathname === '/api/results' && method === 'GET') {
|
|
2716
|
+
handleGetResults(reqUrl, res);
|
|
2717
|
+
}
|
|
2718
|
+
else if (pathname === '/api/results' && method === 'POST') {
|
|
2719
|
+
await handlePostResults(req, res);
|
|
2720
|
+
}
|
|
2721
|
+
else if (/^\/api\/results\/([^/]+)$/.test(pathname) && method === 'GET') {
|
|
2722
|
+
handleGetResult(pathname, res);
|
|
2723
|
+
}
|
|
2724
|
+
else if (/^\/api\/results\/([^/]+)$/.test(pathname) && method === 'PUT') {
|
|
2725
|
+
await handlePutResult(pathname, req, res);
|
|
2726
|
+
}
|
|
2727
|
+
else if (/^\/api\/results\/([^/]+)$/.test(pathname) && method === 'DELETE') {
|
|
2728
|
+
await handleDeleteResult(pathname, res);
|
|
2729
|
+
}
|
|
2730
|
+
else if (pathname === '/api/mcp' && method === 'GET') {
|
|
2731
|
+
handleGetMcp(req, res);
|
|
2732
|
+
}
|
|
2733
|
+
else if (pathname === '/api/mcp/config/raw' && method === 'GET') {
|
|
2734
|
+
handleGetMcpConfigRaw(req, res);
|
|
2735
|
+
}
|
|
2736
|
+
else if (pathname === '/api/mcp/reregister' && method === 'POST') {
|
|
2737
|
+
await handlePostMcpReregister(req, res);
|
|
2738
|
+
}
|
|
2739
|
+
else if (pathname === '/api/mcp/test' && method === 'POST') {
|
|
2740
|
+
await handlePostMcpTest(req, res);
|
|
2741
|
+
}
|
|
2042
2742
|
else if (pathname === '/api/events' && method === 'GET') {
|
|
2043
2743
|
handleSSE(req, res);
|
|
2044
2744
|
// ── SLURM API ──
|
|
@@ -2211,6 +2911,7 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
2211
2911
|
catch { }
|
|
2212
2912
|
slurmDB = null;
|
|
2213
2913
|
}
|
|
2914
|
+
resultsStore = null;
|
|
2214
2915
|
});
|
|
2215
2916
|
return server;
|
|
2216
2917
|
}
|