labgate 0.5.36 → 0.5.38
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 +2 -0
- package/dist/cli.js +9 -1
- package/dist/cli.js.map +1 -1
- package/dist/lib/automation-engine.d.ts +91 -0
- package/dist/lib/automation-engine.js +401 -0
- package/dist/lib/automation-engine.js.map +1 -0
- package/dist/lib/config.d.ts +33 -0
- package/dist/lib/config.js +137 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/container.d.ts +4 -1
- package/dist/lib/container.js +26 -10
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/feedback.d.ts +13 -1
- package/dist/lib/feedback.js +9 -4
- package/dist/lib/feedback.js.map +1 -1
- package/dist/lib/init.js +5 -1
- package/dist/lib/init.js.map +1 -1
- package/dist/lib/results-mcp.js +390 -4
- package/dist/lib/results-mcp.js.map +1 -1
- package/dist/lib/results-store.d.ts +66 -0
- package/dist/lib/results-store.js +324 -6
- package/dist/lib/results-store.js.map +1 -1
- package/dist/lib/ui.d.ts +2 -0
- package/dist/lib/ui.html +16870 -8381
- package/dist/lib/ui.js +1650 -120
- package/dist/lib/ui.js.map +1 -1
- package/dist/lib/web-terminal.d.ts +4 -1
- package/dist/lib/web-terminal.js +33 -3
- package/dist/lib/web-terminal.js.map +1 -1
- package/dist/mcp-bundles/dataset-mcp.bundle.mjs +120 -3
- package/dist/mcp-bundles/display-mcp.bundle.mjs +94 -0
- package/dist/mcp-bundles/explorer-mcp.bundle.mjs +123 -4
- package/dist/mcp-bundles/results-mcp.bundle.mjs +850 -53
- package/dist/mcp-bundles/slurm-mcp.bundle.mjs +94 -0
- package/package.json +1 -1
package/dist/lib/ui.js
CHANGED
|
@@ -57,6 +57,8 @@ const web_terminal_js_1 = require("./web-terminal.js");
|
|
|
57
57
|
const explorer_js_1 = require("./explorer.js");
|
|
58
58
|
const explorer_eval_js_1 = require("./explorer-eval.js");
|
|
59
59
|
const explorer_store_js_1 = require("./explorer-store.js");
|
|
60
|
+
const feedback_js_1 = require("./feedback.js");
|
|
61
|
+
const automation_engine_js_1 = require("./automation-engine.js");
|
|
60
62
|
const log = __importStar(require("./log.js"));
|
|
61
63
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
62
64
|
const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
|
|
@@ -86,6 +88,15 @@ const UI_VERSION_CHECK_MAX_BUFFER = 256 * 1024;
|
|
|
86
88
|
const UI_VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
87
89
|
const UI_SELF_UPDATE_TIMEOUT_MS = 20 * 60 * 1000;
|
|
88
90
|
const UI_SELF_UPDATE_MAX_BUFFER = 16 * 1024 * 1024;
|
|
91
|
+
const OPEN_ONDEMAND_ENV_PREFIXES = ['OOD_', 'ONDEMAND_'];
|
|
92
|
+
const SESSION_GIT_CACHE_TTL_MS = 4_000;
|
|
93
|
+
const SESSION_GIT_COMMAND_TIMEOUT_MS = 1_500;
|
|
94
|
+
const SESSION_GIT_COMMAND_MAX_BUFFER = 512 * 1024;
|
|
95
|
+
const SESSION_GIT_MUTATION_TIMEOUT_MS = 5_000;
|
|
96
|
+
const BROWSE_DIR_GIT_STATUS_TIMEOUT_MS = 2_500;
|
|
97
|
+
const FILE_PREVIEW_DEFAULT_MAX_BYTES = 256 * 1024;
|
|
98
|
+
const FILE_PREVIEW_MAX_BYTES_LIMIT = 1 * 1024 * 1024;
|
|
99
|
+
const FILE_PREVIEW_BINARY_SCAN_BYTES = 4096;
|
|
89
100
|
const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
|
|
90
101
|
'\n' +
|
|
91
102
|
'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
|
|
@@ -105,6 +116,7 @@ let resultsStore = null;
|
|
|
105
116
|
let displayStore = null;
|
|
106
117
|
const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
|
|
107
118
|
const webTerminalBridges = new Map();
|
|
119
|
+
const automationEngines = new Map();
|
|
108
120
|
const WEB_TERMINAL_INIT_RETENTION_MS = 60 * 60 * 1000;
|
|
109
121
|
const WEB_TERMINAL_IMAGE_PULL_TIMEOUT_MS = 60 * 60 * 1000;
|
|
110
122
|
const WEB_TERMINAL_IMAGE_PULL_MAX_BUFFER = 64 * 1024 * 1024;
|
|
@@ -125,6 +137,7 @@ const CLAUDE_HEADLESS_STDERR_LIMIT = 12_000;
|
|
|
125
137
|
const webTerminalInitJobs = new Map();
|
|
126
138
|
const webTerminalImagePullLocks = new Map();
|
|
127
139
|
const webTerminalAgentPrepLocks = new Map();
|
|
140
|
+
const sessionGitStateCache = new Map();
|
|
128
141
|
const LABGATE_UI_VERSION = readPackageVersion();
|
|
129
142
|
let uiPublishedVersionCache = null;
|
|
130
143
|
let uiPublishedVersionInFlight = null;
|
|
@@ -522,11 +535,12 @@ async function prepareRuntimeForWebTerminal(preferred) {
|
|
|
522
535
|
function createWebTerminalInitId() {
|
|
523
536
|
return `wti-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
|
|
524
537
|
}
|
|
525
|
-
function createWebTerminalInitJob(agent, workdir) {
|
|
538
|
+
function createWebTerminalInitJob(agent, workdir, permissionMode) {
|
|
526
539
|
const now = new Date().toISOString();
|
|
527
540
|
return {
|
|
528
541
|
id: createWebTerminalInitId(),
|
|
529
542
|
agent,
|
|
543
|
+
permissionMode,
|
|
530
544
|
workdir,
|
|
531
545
|
status: 'running',
|
|
532
546
|
stage: 'queued',
|
|
@@ -567,6 +581,7 @@ function serializeWebTerminalInitJob(job) {
|
|
|
567
581
|
return {
|
|
568
582
|
id: job.id,
|
|
569
583
|
agent: job.agent,
|
|
584
|
+
permissionMode: job.permissionMode,
|
|
570
585
|
workdir: job.workdir,
|
|
571
586
|
status: job.status,
|
|
572
587
|
stage: job.stage,
|
|
@@ -873,7 +888,9 @@ async function startWebTerminalSession(agent, resolvedWorkdir, opts = {}) {
|
|
|
873
888
|
(0, web_terminal_js_1.writeWebTerminalRecord)(record);
|
|
874
889
|
onProgress?.('session_start', `Starting ${agent} terminal session...`);
|
|
875
890
|
try {
|
|
876
|
-
await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint
|
|
891
|
+
await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint, {
|
|
892
|
+
permissionMode: opts.permissionMode || 'default',
|
|
893
|
+
});
|
|
877
894
|
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
|
|
878
895
|
}
|
|
879
896
|
catch (err) {
|
|
@@ -1192,6 +1209,16 @@ function normalizeWebTerminalAgent(raw) {
|
|
|
1192
1209
|
return agent;
|
|
1193
1210
|
return null;
|
|
1194
1211
|
}
|
|
1212
|
+
function normalizeWebTerminalPermissionMode(raw) {
|
|
1213
|
+
if (raw === undefined || raw === null)
|
|
1214
|
+
return 'default';
|
|
1215
|
+
const mode = String(raw || '').trim().toLowerCase();
|
|
1216
|
+
if (!mode || mode === 'default')
|
|
1217
|
+
return 'default';
|
|
1218
|
+
if (mode === 'dangerous')
|
|
1219
|
+
return 'dangerous';
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1195
1222
|
function serializeWebTerminalSession(record) {
|
|
1196
1223
|
return {
|
|
1197
1224
|
id: record.id,
|
|
@@ -1477,13 +1504,32 @@ async function ensureWebTerminalBridge(record) {
|
|
|
1477
1504
|
log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
|
|
1478
1505
|
return null;
|
|
1479
1506
|
}
|
|
1480
|
-
|
|
1507
|
+
const sessionOptions = [
|
|
1481
1508
|
// Keep web terminal copy/selection behavior reliable by letting xterm own
|
|
1482
1509
|
// mouse selection instead of tmux copy-mode selection.
|
|
1483
|
-
|
|
1510
|
+
['mouse', 'off'],
|
|
1511
|
+
// Hide tmux status/footer rows in web terminal sessions.
|
|
1512
|
+
['status', 'off'],
|
|
1513
|
+
];
|
|
1514
|
+
for (const [key, value] of sessionOptions) {
|
|
1515
|
+
try {
|
|
1516
|
+
await execFileAsync(tmuxBin, ['set-option', '-t', record.tmuxSession, key, value], { timeout: 10_000 });
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
// Best effort only; attach should still proceed.
|
|
1520
|
+
}
|
|
1484
1521
|
}
|
|
1485
|
-
|
|
1486
|
-
//
|
|
1522
|
+
const windowOptions = [
|
|
1523
|
+
// Avoid tmux alternate-screen mode so TUI output remains scrollable in xterm/web UI.
|
|
1524
|
+
['alternate-screen', 'off'],
|
|
1525
|
+
];
|
|
1526
|
+
for (const [key, value] of windowOptions) {
|
|
1527
|
+
try {
|
|
1528
|
+
await execFileAsync(tmuxBin, ['set-window-option', '-t', record.tmuxSession, key, value], { timeout: 10_000 });
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
// Best effort only; attach should still proceed.
|
|
1532
|
+
}
|
|
1487
1533
|
}
|
|
1488
1534
|
if (existing && existing.pty)
|
|
1489
1535
|
return existing;
|
|
@@ -1544,6 +1590,10 @@ async function ensureWebTerminalBridge(record) {
|
|
|
1544
1590
|
seqEnd: chunk.seq,
|
|
1545
1591
|
});
|
|
1546
1592
|
}
|
|
1593
|
+
// Automation hook: feed output to engine if one exists for this terminal
|
|
1594
|
+
const engine = automationEngines.get(record.id);
|
|
1595
|
+
if (engine)
|
|
1596
|
+
engine.onTerminalOutput(data);
|
|
1547
1597
|
});
|
|
1548
1598
|
ptyProcess.onExit(async () => {
|
|
1549
1599
|
const stopRequested = bridge.stopRequested;
|
|
@@ -1551,14 +1601,20 @@ async function ensureWebTerminalBridge(record) {
|
|
|
1551
1601
|
bridge.pty = null;
|
|
1552
1602
|
const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
|
|
1553
1603
|
if (alive) {
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1604
|
+
// The attach bridge process exited while tmux is still alive (for example
|
|
1605
|
+
// after a deliberate detach during session switch). Drop transient bridge
|
|
1606
|
+
// output to avoid replaying stale lifecycle lines like "[terminated]" on
|
|
1607
|
+
// the next attach.
|
|
1558
1608
|
bridge.buffer = '';
|
|
1559
1609
|
bridge.history = [];
|
|
1560
1610
|
bridge.historyBytes = 0;
|
|
1561
|
-
|
|
1611
|
+
bridge.nextSeq = 1;
|
|
1612
|
+
if (!stopRequested) {
|
|
1613
|
+
// Another tmux client (for example `labgate continue`) may have
|
|
1614
|
+
// force-detached this bridge. Require clients to reconnect for a clean
|
|
1615
|
+
// reattach.
|
|
1616
|
+
closeWebTerminalBridgeClients(bridge);
|
|
1617
|
+
}
|
|
1562
1618
|
return;
|
|
1563
1619
|
}
|
|
1564
1620
|
const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
|
|
@@ -1582,6 +1638,12 @@ function stopWebTerminalBridge(bridge) {
|
|
|
1582
1638
|
if (!bridge.pty)
|
|
1583
1639
|
return;
|
|
1584
1640
|
bridge.stopRequested = true;
|
|
1641
|
+
// Clean up automation engine for this bridge
|
|
1642
|
+
const engine = automationEngines.get(bridge.id);
|
|
1643
|
+
if (engine) {
|
|
1644
|
+
engine.stop();
|
|
1645
|
+
automationEngines.delete(bridge.id);
|
|
1646
|
+
}
|
|
1585
1647
|
try {
|
|
1586
1648
|
bridge.pty.kill('SIGTERM');
|
|
1587
1649
|
}
|
|
@@ -1824,7 +1886,14 @@ async function startClaudeHeadlessWsRun(ws, record, prompt, resumeSessionId) {
|
|
|
1824
1886
|
}
|
|
1825
1887
|
function serveHTML(res) {
|
|
1826
1888
|
try {
|
|
1827
|
-
|
|
1889
|
+
let uname = '';
|
|
1890
|
+
try {
|
|
1891
|
+
uname = (0, os_1.userInfo)().username;
|
|
1892
|
+
}
|
|
1893
|
+
catch { }
|
|
1894
|
+
const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8')
|
|
1895
|
+
.replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN)
|
|
1896
|
+
.replaceAll('__LABGATE_USER__', uname);
|
|
1828
1897
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1829
1898
|
res.end(html);
|
|
1830
1899
|
}
|
|
@@ -1837,6 +1906,10 @@ function handleGetConfig(_req, res) {
|
|
|
1837
1906
|
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
1838
1907
|
const response = { ...effective.config };
|
|
1839
1908
|
const slurmRuntime = getSlurmRuntimeStatus();
|
|
1909
|
+
response._host = {
|
|
1910
|
+
hostname: (0, os_1.hostname)(),
|
|
1911
|
+
openondemand: isOpenOnDemandHostRuntime(),
|
|
1912
|
+
};
|
|
1840
1913
|
response._slurm = {
|
|
1841
1914
|
available: slurmRuntime.available,
|
|
1842
1915
|
missing_commands: slurmRuntime.missingCommands,
|
|
@@ -1855,6 +1928,16 @@ function handleGetConfig(_req, res) {
|
|
|
1855
1928
|
}
|
|
1856
1929
|
json(res, response);
|
|
1857
1930
|
}
|
|
1931
|
+
function isOpenOnDemandHostRuntime(env = process.env) {
|
|
1932
|
+
const keys = Object.keys(env);
|
|
1933
|
+
for (const key of keys) {
|
|
1934
|
+
for (const prefix of OPEN_ONDEMAND_ENV_PREFIXES) {
|
|
1935
|
+
if (key.startsWith(prefix))
|
|
1936
|
+
return true;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
return false;
|
|
1940
|
+
}
|
|
1858
1941
|
async function handlePostConfig(req, res) {
|
|
1859
1942
|
try {
|
|
1860
1943
|
const body = await readBody(req);
|
|
@@ -1925,6 +2008,8 @@ async function handlePostConfig(req, res) {
|
|
|
1925
2008
|
obj.slurm = incoming.slurm;
|
|
1926
2009
|
if (incoming.headless)
|
|
1927
2010
|
obj.headless = incoming.headless;
|
|
2011
|
+
if (incoming.plugins)
|
|
2012
|
+
obj.plugins = incoming.plugins;
|
|
1928
2013
|
const { writeFileSync } = await import('fs');
|
|
1929
2014
|
writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
|
|
1930
2015
|
(0, config_js_1.ensurePrivateFile)(configPath);
|
|
@@ -1937,6 +2022,266 @@ async function handlePostConfig(req, res) {
|
|
|
1937
2022
|
function handleGetConfigPath(_req, res) {
|
|
1938
2023
|
json(res, { path: (0, config_js_1.getConfigPath)() });
|
|
1939
2024
|
}
|
|
2025
|
+
// ── Plugin API ──────────────────────────────────────────────
|
|
2026
|
+
function isPluginEnabledInConfig(config, pluginId) {
|
|
2027
|
+
return config.plugins?.[pluginId] !== false;
|
|
2028
|
+
}
|
|
2029
|
+
function handleGetPlugins(_req, res) {
|
|
2030
|
+
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
2031
|
+
const plugins = effective.config.plugins ?? {};
|
|
2032
|
+
json(res, { ok: true, plugins });
|
|
2033
|
+
}
|
|
2034
|
+
async function handlePostPlugins(req, res) {
|
|
2035
|
+
try {
|
|
2036
|
+
const body = await readBody(req);
|
|
2037
|
+
const parsed = JSON.parse(body);
|
|
2038
|
+
const pluginId = typeof parsed.pluginId === 'string' ? parsed.pluginId.trim() : '';
|
|
2039
|
+
const enabled = parsed.enabled;
|
|
2040
|
+
if (!pluginId || typeof enabled !== 'boolean') {
|
|
2041
|
+
json(res, { ok: false, errors: ['pluginId (string) and enabled (boolean) are required'] }, 400);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (!(0, config_js_1.isKnownPluginId)(pluginId)) {
|
|
2045
|
+
json(res, { ok: false, errors: [`Unknown plugin id: ${pluginId}`] }, 400);
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
// Read existing config file
|
|
2049
|
+
const configPath = (0, config_js_1.getConfigPath)();
|
|
2050
|
+
let obj = {};
|
|
2051
|
+
if ((0, fs_1.existsSync)(configPath)) {
|
|
2052
|
+
const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
|
|
2053
|
+
const stripped = rawText
|
|
2054
|
+
.split('\n')
|
|
2055
|
+
.filter(line => !line.trimStart().startsWith('//'))
|
|
2056
|
+
.join('\n');
|
|
2057
|
+
try {
|
|
2058
|
+
obj = JSON.parse(stripped);
|
|
2059
|
+
}
|
|
2060
|
+
catch (err) {
|
|
2061
|
+
json(res, { ok: false, errors: [`Could not parse existing config file: ${err.message ?? String(err)}`] }, 400);
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
// Merge plugin state
|
|
2066
|
+
const rawPlugins = (obj.plugins ?? {});
|
|
2067
|
+
const plugins = {};
|
|
2068
|
+
if (rawPlugins && typeof rawPlugins === 'object' && !Array.isArray(rawPlugins)) {
|
|
2069
|
+
for (const [id, value] of Object.entries(rawPlugins)) {
|
|
2070
|
+
if (!(0, config_js_1.isKnownPluginId)(id))
|
|
2071
|
+
continue;
|
|
2072
|
+
if (typeof value !== 'boolean')
|
|
2073
|
+
continue;
|
|
2074
|
+
plugins[id] = value;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
plugins[pluginId] = enabled;
|
|
2078
|
+
obj.plugins = plugins;
|
|
2079
|
+
const { writeFileSync } = await import('fs');
|
|
2080
|
+
writeFileSync(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
|
|
2081
|
+
(0, config_js_1.ensurePrivateFile)(configPath);
|
|
2082
|
+
json(res, { ok: true, plugins });
|
|
2083
|
+
}
|
|
2084
|
+
catch (err) {
|
|
2085
|
+
json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
// ── Automation API ──────────────────────────────────────────
|
|
2089
|
+
function handleGetAutomationStatus(terminalId, res) {
|
|
2090
|
+
const engine = automationEngines.get(terminalId);
|
|
2091
|
+
if (!engine) {
|
|
2092
|
+
json(res, { ok: true, active: false });
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
const status = engine.getStatus();
|
|
2096
|
+
const config = engine.getConfig();
|
|
2097
|
+
// Strip api_key from response
|
|
2098
|
+
const { api_key: _key, ...safeConfig } = config;
|
|
2099
|
+
json(res, { ok: true, active: true, status, config: safeConfig });
|
|
2100
|
+
}
|
|
2101
|
+
async function handlePostAutomationEnable(req, res) {
|
|
2102
|
+
try {
|
|
2103
|
+
const body = await readBody(req);
|
|
2104
|
+
const parsed = JSON.parse(body);
|
|
2105
|
+
const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
|
|
2106
|
+
if (!terminalId) {
|
|
2107
|
+
json(res, { ok: false, errors: ['terminalId is required'] }, 400);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
if (automationEngines.has(terminalId)) {
|
|
2111
|
+
json(res, { ok: true, message: 'already enabled' });
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
const bridge = webTerminalBridges.get(terminalId);
|
|
2115
|
+
if (!bridge || !bridge.pty) {
|
|
2116
|
+
json(res, { ok: false, errors: ['No active terminal bridge for this id'] }, 400);
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
const effective = (0, config_js_1.loadEffectiveConfig)();
|
|
2120
|
+
const autoConfig = effective.config.automation;
|
|
2121
|
+
if (!autoConfig) {
|
|
2122
|
+
json(res, { ok: false, errors: ['Automation config not found'] }, 400);
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
const broadcastFn = (entry) => {
|
|
2126
|
+
broadcastWebTerminalMessage(bridge, {
|
|
2127
|
+
type: 'automation',
|
|
2128
|
+
subtype: entry.type,
|
|
2129
|
+
data: entry,
|
|
2130
|
+
});
|
|
2131
|
+
};
|
|
2132
|
+
const broadcastTurnFn = (action, turn) => {
|
|
2133
|
+
broadcastWebTerminalMessage(bridge, {
|
|
2134
|
+
type: 'automation_turn',
|
|
2135
|
+
action,
|
|
2136
|
+
turn,
|
|
2137
|
+
});
|
|
2138
|
+
};
|
|
2139
|
+
const writeFn = (data) => {
|
|
2140
|
+
try {
|
|
2141
|
+
bridge.pty?.write(data);
|
|
2142
|
+
}
|
|
2143
|
+
catch { /* best effort */ }
|
|
2144
|
+
};
|
|
2145
|
+
const engine = new automation_engine_js_1.AutomationEngine(terminalId, autoConfig, writeFn, broadcastFn, broadcastTurnFn);
|
|
2146
|
+
automationEngines.set(terminalId, engine);
|
|
2147
|
+
json(res, { ok: true });
|
|
2148
|
+
}
|
|
2149
|
+
catch (err) {
|
|
2150
|
+
json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
async function handlePostAutomationDisable(req, res) {
|
|
2154
|
+
try {
|
|
2155
|
+
const body = await readBody(req);
|
|
2156
|
+
const parsed = JSON.parse(body);
|
|
2157
|
+
const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
|
|
2158
|
+
if (!terminalId) {
|
|
2159
|
+
json(res, { ok: false, errors: ['terminalId is required'] }, 400);
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
const engine = automationEngines.get(terminalId);
|
|
2163
|
+
if (engine) {
|
|
2164
|
+
engine.stop();
|
|
2165
|
+
automationEngines.delete(terminalId);
|
|
2166
|
+
}
|
|
2167
|
+
json(res, { ok: true });
|
|
2168
|
+
}
|
|
2169
|
+
catch (err) {
|
|
2170
|
+
json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
async function handlePostAutomationPause(req, res) {
|
|
2174
|
+
try {
|
|
2175
|
+
const body = await readBody(req);
|
|
2176
|
+
const parsed = JSON.parse(body);
|
|
2177
|
+
const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
|
|
2178
|
+
const engine = automationEngines.get(terminalId);
|
|
2179
|
+
if (!engine) {
|
|
2180
|
+
json(res, { ok: false, errors: ['No automation engine for this terminal'] }, 404);
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
engine.pause();
|
|
2184
|
+
json(res, { ok: true, status: engine.getStatus() });
|
|
2185
|
+
}
|
|
2186
|
+
catch (err) {
|
|
2187
|
+
json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
async function handlePostAutomationResume(req, res) {
|
|
2191
|
+
try {
|
|
2192
|
+
const body = await readBody(req);
|
|
2193
|
+
const parsed = JSON.parse(body);
|
|
2194
|
+
const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
|
|
2195
|
+
const engine = automationEngines.get(terminalId);
|
|
2196
|
+
if (!engine) {
|
|
2197
|
+
json(res, { ok: false, errors: ['No automation engine for this terminal'] }, 404);
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
engine.resume();
|
|
2201
|
+
json(res, { ok: true, status: engine.getStatus() });
|
|
2202
|
+
}
|
|
2203
|
+
catch (err) {
|
|
2204
|
+
json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
async function handlePostAutomationConfig(req, res) {
|
|
2208
|
+
try {
|
|
2209
|
+
const body = await readBody(req);
|
|
2210
|
+
const parsed = JSON.parse(body);
|
|
2211
|
+
const terminalId = typeof parsed.terminalId === 'string' ? parsed.terminalId.trim() : '';
|
|
2212
|
+
const engine = automationEngines.get(terminalId);
|
|
2213
|
+
if (!engine) {
|
|
2214
|
+
json(res, { ok: false, errors: ['No automation engine for this terminal'] }, 404);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
const update = {};
|
|
2218
|
+
if (typeof parsed.system_prompt === 'string')
|
|
2219
|
+
update.system_prompt = parsed.system_prompt;
|
|
2220
|
+
if (Array.isArray(parsed.trigger_patterns))
|
|
2221
|
+
update.trigger_patterns = parsed.trigger_patterns;
|
|
2222
|
+
if (typeof parsed.model === 'string')
|
|
2223
|
+
update.model = parsed.model;
|
|
2224
|
+
if (typeof parsed.delay_ms === 'number')
|
|
2225
|
+
update.delay_ms = parsed.delay_ms;
|
|
2226
|
+
if (typeof parsed.max_turns === 'number')
|
|
2227
|
+
update.max_turns = parsed.max_turns;
|
|
2228
|
+
if (typeof parsed.context_lines === 'number')
|
|
2229
|
+
update.context_lines = parsed.context_lines;
|
|
2230
|
+
if (typeof parsed.max_tokens === 'number')
|
|
2231
|
+
update.max_tokens = parsed.max_tokens;
|
|
2232
|
+
engine.updateConfig(update);
|
|
2233
|
+
const config = engine.getConfig();
|
|
2234
|
+
const { api_key: _key, ...safeConfig } = config;
|
|
2235
|
+
json(res, { ok: true, config: safeConfig });
|
|
2236
|
+
}
|
|
2237
|
+
catch (err) {
|
|
2238
|
+
json(res, { ok: false, errors: [err.message ?? String(err)] }, 400);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
function handleGetAutomationTerminalStatus(terminalId, res) {
|
|
2242
|
+
const engine = automationEngines.get(terminalId);
|
|
2243
|
+
if (engine) {
|
|
2244
|
+
json(res, { ok: true, status: engine.getTerminalStatus() });
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
// Fallback: check bridge buffer directly
|
|
2248
|
+
const bridge = webTerminalBridges.get(terminalId);
|
|
2249
|
+
if (!bridge || !bridge.buffer) {
|
|
2250
|
+
json(res, { ok: true, status: 'idle' });
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
// Basic heuristic: check if last line looks like a prompt awaiting input
|
|
2254
|
+
const stripped = bridge.buffer.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()][AB012]|[78DEHM=>])/g, '')
|
|
2255
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
2256
|
+
const lines = stripped.split('\n');
|
|
2257
|
+
const lastLines = lines.slice(-10).join('\n');
|
|
2258
|
+
// Common patterns indicating waiting for input
|
|
2259
|
+
const awaitingPatterns = [/\?\s*$/, /Y\/n\]?\s*$/, /\[yes\/no\]\s*$/i, />\s*$/, /\$\s*$/, /:\s*$/];
|
|
2260
|
+
let isAwaiting = false;
|
|
2261
|
+
for (const pat of awaitingPatterns) {
|
|
2262
|
+
if (pat.test(lastLines)) {
|
|
2263
|
+
isAwaiting = true;
|
|
2264
|
+
break;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
json(res, { ok: true, status: isAwaiting ? 'awaiting_input' : 'working' });
|
|
2268
|
+
}
|
|
2269
|
+
function handleGetAutomationLog(terminalId, res) {
|
|
2270
|
+
const engine = automationEngines.get(terminalId);
|
|
2271
|
+
if (!engine) {
|
|
2272
|
+
json(res, { ok: true, log: [] });
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
json(res, { ok: true, log: engine.getLog() });
|
|
2276
|
+
}
|
|
2277
|
+
function handleGetAutomationTurns(terminalId, res) {
|
|
2278
|
+
const engine = automationEngines.get(terminalId);
|
|
2279
|
+
if (!engine) {
|
|
2280
|
+
json(res, { ok: true, turns: [] });
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
json(res, { ok: true, turns: engine.getTurns() });
|
|
2284
|
+
}
|
|
1940
2285
|
async function handleGetUiVersion(reqUrl, res) {
|
|
1941
2286
|
const forceRefresh = reqUrl.searchParams.get('refresh') === '1';
|
|
1942
2287
|
const published = await getPublishedUiVersionCached(forceRefresh);
|
|
@@ -2418,6 +2763,21 @@ function rememberTimestampedEntry(map, value, ts) {
|
|
|
2418
2763
|
if (ts >= existing)
|
|
2419
2764
|
map.set(value, ts);
|
|
2420
2765
|
}
|
|
2766
|
+
/** Track a file access with action type. Write/edit actions upgrade read; newer timestamps win. */
|
|
2767
|
+
function rememberFileEntry(map, path, ts, action) {
|
|
2768
|
+
if (!path)
|
|
2769
|
+
return;
|
|
2770
|
+
const existing = map.get(path);
|
|
2771
|
+
if (!existing) {
|
|
2772
|
+
map.set(path, { ts, action });
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
// Keep the most significant action (edit > write > unknown > read)
|
|
2776
|
+
const rank = { read: 0, unknown: 1, write: 2, edit: 3 };
|
|
2777
|
+
const newAction = (rank[action] ?? 0) > (rank[existing.action] ?? 0) ? action : existing.action;
|
|
2778
|
+
const newTs = ts >= existing.ts ? ts : existing.ts;
|
|
2779
|
+
map.set(path, { ts: newTs, action: newAction });
|
|
2780
|
+
}
|
|
2421
2781
|
function collectWebsiteUrls(value, accessedUrls, ts, keyHint = '') {
|
|
2422
2782
|
if (typeof value === 'string') {
|
|
2423
2783
|
for (const url of extractWebsiteUrlsFromText(value)) {
|
|
@@ -2494,14 +2854,20 @@ function tailLastJsonlEntry(filePath) {
|
|
|
2494
2854
|
continue;
|
|
2495
2855
|
const name = block.name || '';
|
|
2496
2856
|
collectWebsiteUrls(block.input, accessedUrls, ts);
|
|
2857
|
+
// Classify tool action
|
|
2858
|
+
const toolAction = (name === 'Edit' || name === 'edit') ? 'edit' :
|
|
2859
|
+
(name === 'Write' || name === 'write' || name === 'NotebookEdit') ? 'write' :
|
|
2860
|
+
(name === 'Read' || name === 'read' || name === 'Glob' || name === 'glob') ? 'read' :
|
|
2861
|
+
(name === 'Grep' || name === 'grep') ? 'read' :
|
|
2862
|
+
'unknown';
|
|
2497
2863
|
// Direct file_path from Read/Edit/Write/Glob tools
|
|
2498
2864
|
const fp = block.input.file_path || block.input.path || '';
|
|
2499
|
-
if (fp
|
|
2500
|
-
|
|
2865
|
+
if (fp) {
|
|
2866
|
+
rememberFileEntry(accessedFiles, fp, ts, toolAction);
|
|
2501
2867
|
}
|
|
2502
2868
|
// Grep pattern → search path
|
|
2503
2869
|
if ((name === 'Grep' || name === 'grep') && block.input.path) {
|
|
2504
|
-
|
|
2870
|
+
rememberFileEntry(accessedFiles, block.input.path, ts, 'read');
|
|
2505
2871
|
}
|
|
2506
2872
|
// Extract file paths from Bash commands
|
|
2507
2873
|
if (name === 'Bash' || name === 'bash') {
|
|
@@ -2511,7 +2877,7 @@ function tailLastJsonlEntry(filePath) {
|
|
|
2511
2877
|
if (absMatches) {
|
|
2512
2878
|
for (const p of absMatches) {
|
|
2513
2879
|
const clean = p.replace(/['"`;,)}\]]+$/, '');
|
|
2514
|
-
|
|
2880
|
+
rememberFileEntry(accessedFiles, clean, ts, 'unknown');
|
|
2515
2881
|
}
|
|
2516
2882
|
}
|
|
2517
2883
|
for (const url of extractWebsiteUrlsFromText(cmd)) {
|
|
@@ -2527,7 +2893,7 @@ function tailLastJsonlEntry(filePath) {
|
|
|
2527
2893
|
const fileArg = tokens[tokens.length - 1] || '';
|
|
2528
2894
|
if (fileArg && !fileArg.startsWith('/') && !fileArg.startsWith('-') && (fileArg.includes('/') || fileArg.includes('.'))) {
|
|
2529
2895
|
const full = '/work/' + fileArg;
|
|
2530
|
-
|
|
2896
|
+
rememberFileEntry(accessedFiles, full, ts, 'unknown');
|
|
2531
2897
|
}
|
|
2532
2898
|
}
|
|
2533
2899
|
}
|
|
@@ -2545,7 +2911,7 @@ function tailLastJsonlEntry(filePath) {
|
|
|
2545
2911
|
if (pathMatches) {
|
|
2546
2912
|
for (const p of pathMatches.slice(0, 20)) { // cap to avoid perf issues
|
|
2547
2913
|
const clean = p.replace(/['"`;,)}\]]+$/, '');
|
|
2548
|
-
|
|
2914
|
+
rememberFileEntry(accessedFiles, clean, ts, 'unknown');
|
|
2549
2915
|
}
|
|
2550
2916
|
}
|
|
2551
2917
|
for (const url of extractWebsiteUrlsFromText(text).slice(0, 30)) {
|
|
@@ -2581,13 +2947,25 @@ function tailLastJsonlEntry(filePath) {
|
|
|
2581
2947
|
return null;
|
|
2582
2948
|
}
|
|
2583
2949
|
}
|
|
2584
|
-
/** Cache for JSONL file scanning
|
|
2950
|
+
/** Cache for JSONL file scanning and short-lived session activity snapshots. */
|
|
2585
2951
|
let jsonlCache = null;
|
|
2952
|
+
const JSONL_CACHE_TTL_MS = 10_000;
|
|
2953
|
+
const JSONL_SCAN_MAX_ENTRIES = 6_000;
|
|
2954
|
+
const SESSION_ACTIVITY_CACHE_TTL_MS = 8_000;
|
|
2955
|
+
const sessionActivityCache = new Map();
|
|
2586
2956
|
function getTranscriptRoots(agent, sandboxHome) {
|
|
2587
2957
|
if (agent === 'claude') {
|
|
2588
2958
|
return [(0, path_1.join)(sandboxHome, '.claude', 'projects')];
|
|
2589
2959
|
}
|
|
2590
2960
|
if (agent === 'codex') {
|
|
2961
|
+
const primaryRoots = [
|
|
2962
|
+
(0, path_1.join)(sandboxHome, '.codex', 'sessions'),
|
|
2963
|
+
(0, path_1.join)(sandboxHome, '.openai', 'codex', 'sessions'),
|
|
2964
|
+
(0, path_1.join)(sandboxHome, '.config', 'codex', 'sessions'),
|
|
2965
|
+
];
|
|
2966
|
+
const existingPrimary = primaryRoots.filter((root) => (0, fs_1.existsSync)(root));
|
|
2967
|
+
if (existingPrimary.length > 0)
|
|
2968
|
+
return existingPrimary;
|
|
2591
2969
|
return [
|
|
2592
2970
|
(0, path_1.join)(sandboxHome, '.codex'),
|
|
2593
2971
|
(0, path_1.join)(sandboxHome, '.openai', 'codex'),
|
|
@@ -2596,16 +2974,22 @@ function getTranscriptRoots(agent, sandboxHome) {
|
|
|
2596
2974
|
}
|
|
2597
2975
|
return [];
|
|
2598
2976
|
}
|
|
2599
|
-
function scanJsonlFiles(root, maxDepth = 4) {
|
|
2977
|
+
function scanJsonlFiles(root, maxDepth = 4, maxEntries = JSONL_SCAN_MAX_ENTRIES) {
|
|
2600
2978
|
const files = [];
|
|
2601
2979
|
const stack = [{ dir: root, depth: 0 }];
|
|
2980
|
+
let visitedEntries = 0;
|
|
2602
2981
|
while (stack.length > 0) {
|
|
2982
|
+
if (visitedEntries >= maxEntries)
|
|
2983
|
+
break;
|
|
2603
2984
|
const current = stack.pop();
|
|
2604
2985
|
if (!current)
|
|
2605
2986
|
continue;
|
|
2606
2987
|
try {
|
|
2607
2988
|
const entries = (0, fs_1.readdirSync)(current.dir);
|
|
2608
2989
|
for (const entry of entries) {
|
|
2990
|
+
if (visitedEntries >= maxEntries)
|
|
2991
|
+
break;
|
|
2992
|
+
visitedEntries += 1;
|
|
2609
2993
|
const fullPath = (0, path_1.join)(current.dir, entry);
|
|
2610
2994
|
try {
|
|
2611
2995
|
const st = (0, fs_1.statSync)(fullPath);
|
|
@@ -2632,14 +3016,14 @@ function scanJsonlFiles(root, maxDepth = 4) {
|
|
|
2632
3016
|
}
|
|
2633
3017
|
/**
|
|
2634
3018
|
* Scan sandbox home for agent conversation JSONL files.
|
|
2635
|
-
* Results are cached
|
|
3019
|
+
* Results are cached briefly to keep `/api/sessions` responsive.
|
|
2636
3020
|
*/
|
|
2637
3021
|
function findProjectJsonlFiles(agent) {
|
|
2638
3022
|
const normalizedAgent = (agent || '').toLowerCase();
|
|
2639
3023
|
const now = Date.now();
|
|
2640
3024
|
if (jsonlCache &&
|
|
2641
3025
|
jsonlCache.agent === normalizedAgent &&
|
|
2642
|
-
now - jsonlCache.ts <
|
|
3026
|
+
now - jsonlCache.ts < JSONL_CACHE_TTL_MS) {
|
|
2643
3027
|
return jsonlCache.files;
|
|
2644
3028
|
}
|
|
2645
3029
|
const files = [];
|
|
@@ -2783,7 +3167,7 @@ function scanBlockedEvents() {
|
|
|
2783
3167
|
return events;
|
|
2784
3168
|
}
|
|
2785
3169
|
// ── Per-session accumulated file history (persists across tail reads) ──
|
|
2786
|
-
/** sessionId → (path →
|
|
3170
|
+
/** sessionId → (path → { ts, action }) */
|
|
2787
3171
|
const fileHistoryCache = new Map();
|
|
2788
3172
|
const FILE_HISTORY_TTL = 60 * 60 * 1000; // 1 hour
|
|
2789
3173
|
const websiteHistoryCache = new Map();
|
|
@@ -2798,18 +3182,16 @@ function mergeFileHistory(sessionId, newAccessed) {
|
|
|
2798
3182
|
history = new Map();
|
|
2799
3183
|
fileHistoryCache.set(sessionId, history);
|
|
2800
3184
|
}
|
|
2801
|
-
// Merge new accesses (keep newest timestamp per path)
|
|
2802
|
-
for (const [path,
|
|
2803
|
-
if (!ts || isNaN(ts))
|
|
3185
|
+
// Merge new accesses (keep newest timestamp and most significant action per path)
|
|
3186
|
+
for (const [path, entry] of newAccessed) {
|
|
3187
|
+
if (!entry.ts || isNaN(entry.ts))
|
|
2804
3188
|
continue;
|
|
2805
|
-
|
|
2806
|
-
if (ts >= existing)
|
|
2807
|
-
history.set(path, ts);
|
|
3189
|
+
rememberFileEntry(history, path, entry.ts, entry.action);
|
|
2808
3190
|
}
|
|
2809
3191
|
// Evict entries older than 1 hour
|
|
2810
3192
|
const cutoff = Date.now() - FILE_HISTORY_TTL;
|
|
2811
|
-
for (const [path,
|
|
2812
|
-
if (ts < cutoff)
|
|
3193
|
+
for (const [path, entry] of history) {
|
|
3194
|
+
if (entry.ts < cutoff)
|
|
2813
3195
|
history.delete(path);
|
|
2814
3196
|
}
|
|
2815
3197
|
return history;
|
|
@@ -2846,7 +3228,7 @@ function getAccessedFiles(accessedFiles, workdir) {
|
|
|
2846
3228
|
return [];
|
|
2847
3229
|
const files = [];
|
|
2848
3230
|
const seen = new Set();
|
|
2849
|
-
for (const [p,
|
|
3231
|
+
for (const [p, entry] of accessedFiles) {
|
|
2850
3232
|
const rel = p.startsWith('/work/') ? p.slice(6) : p;
|
|
2851
3233
|
if (!rel || seen.has(rel))
|
|
2852
3234
|
continue;
|
|
@@ -2871,8 +3253,9 @@ function getAccessedFiles(accessedFiles, workdir) {
|
|
|
2871
3253
|
files.push({
|
|
2872
3254
|
name: rel,
|
|
2873
3255
|
path: p,
|
|
2874
|
-
accessedAt: ts,
|
|
3256
|
+
accessedAt: entry.ts,
|
|
2875
3257
|
isDir,
|
|
3258
|
+
action: entry.action,
|
|
2876
3259
|
});
|
|
2877
3260
|
}
|
|
2878
3261
|
// Sort by recency (most recently accessed first)
|
|
@@ -2900,27 +3283,63 @@ function getAccessedWebsites(accessedUrls) {
|
|
|
2900
3283
|
websites.sort((a, b) => (b.accessedAt || 0) - (a.accessedAt || 0));
|
|
2901
3284
|
return websites.slice(0, 30);
|
|
2902
3285
|
}
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
* Correlates session → JSONL by matching session start time with file creation time.
|
|
2906
|
-
*/
|
|
2907
|
-
function getAgentActivity(session) {
|
|
2908
|
-
const emptyFiles = [];
|
|
2909
|
-
const emptyWebsites = [];
|
|
2910
|
-
const unknown = {
|
|
3286
|
+
function createUnknownActivity() {
|
|
3287
|
+
return {
|
|
2911
3288
|
status: 'unknown',
|
|
2912
3289
|
label: '',
|
|
2913
3290
|
detail: '',
|
|
2914
3291
|
prompt: '',
|
|
2915
3292
|
since: 0,
|
|
2916
3293
|
lastActive: 0,
|
|
2917
|
-
files:
|
|
2918
|
-
websites:
|
|
3294
|
+
files: [],
|
|
3295
|
+
websites: [],
|
|
2919
3296
|
};
|
|
3297
|
+
}
|
|
3298
|
+
function getSessionActivityCacheKey(session) {
|
|
3299
|
+
const id = String(session?.id || '').trim();
|
|
3300
|
+
if (!id)
|
|
3301
|
+
return '';
|
|
3302
|
+
const agent = String(session?.agent || '').trim().toLowerCase();
|
|
3303
|
+
const started = String(session?.started || '').trim();
|
|
3304
|
+
const workdir = String(session?.workdir || '').trim();
|
|
3305
|
+
return `${id}|${agent}|${started}|${workdir}`;
|
|
3306
|
+
}
|
|
3307
|
+
function getCachedSessionActivity(session, now = Date.now()) {
|
|
3308
|
+
const key = getSessionActivityCacheKey(session);
|
|
3309
|
+
if (!key)
|
|
3310
|
+
return null;
|
|
3311
|
+
const cached = sessionActivityCache.get(key);
|
|
3312
|
+
if (!cached)
|
|
3313
|
+
return null;
|
|
3314
|
+
if ((now - cached.ts) > SESSION_ACTIVITY_CACHE_TTL_MS) {
|
|
3315
|
+
sessionActivityCache.delete(key);
|
|
3316
|
+
return null;
|
|
3317
|
+
}
|
|
3318
|
+
return cached.activity;
|
|
3319
|
+
}
|
|
3320
|
+
function setCachedSessionActivity(session, activity, now = Date.now()) {
|
|
3321
|
+
const key = getSessionActivityCacheKey(session);
|
|
3322
|
+
if (!key)
|
|
3323
|
+
return;
|
|
3324
|
+
sessionActivityCache.set(key, { activity, ts: now });
|
|
3325
|
+
}
|
|
3326
|
+
function pruneSessionActivityCache(now = Date.now()) {
|
|
3327
|
+
for (const [key, entry] of sessionActivityCache) {
|
|
3328
|
+
if ((now - entry.ts) > SESSION_ACTIVITY_CACHE_TTL_MS) {
|
|
3329
|
+
sessionActivityCache.delete(key);
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
/**
|
|
3334
|
+
* Determine an agent session's current activity by reading transcript JSONL.
|
|
3335
|
+
* Correlates session → JSONL by matching session start time with file creation time.
|
|
3336
|
+
*/
|
|
3337
|
+
function getAgentActivity(session) {
|
|
3338
|
+
const unknown = createUnknownActivity();
|
|
2920
3339
|
const agent = String(session.agent || '').toLowerCase();
|
|
2921
3340
|
const jsonlFiles = findProjectJsonlFiles(agent);
|
|
2922
3341
|
if (jsonlFiles.length === 0) {
|
|
2923
|
-
return
|
|
3342
|
+
return unknown;
|
|
2924
3343
|
}
|
|
2925
3344
|
const sessionStartMs = session.started ? new Date(session.started).getTime() : 0;
|
|
2926
3345
|
if (!sessionStartMs)
|
|
@@ -2949,43 +3368,592 @@ function getAgentActivity(session) {
|
|
|
2949
3368
|
if (age > 60_000) {
|
|
2950
3369
|
activity = { status: 'idle', label: 'Idle', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
2951
3370
|
}
|
|
2952
|
-
else if (type === 'user') {
|
|
2953
|
-
const content = entry.message?.content;
|
|
2954
|
-
if (Array.isArray(content) && content.some((b) => b.type === 'tool_result')) {
|
|
2955
|
-
activity = { status: 'thinking', label: 'Processing...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
2956
|
-
}
|
|
2957
|
-
else {
|
|
2958
|
-
activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
2959
|
-
}
|
|
3371
|
+
else if (type === 'user') {
|
|
3372
|
+
const content = entry.message?.content;
|
|
3373
|
+
if (Array.isArray(content) && content.some((b) => b.type === 'tool_result')) {
|
|
3374
|
+
activity = { status: 'thinking', label: 'Processing...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
3375
|
+
}
|
|
3376
|
+
else {
|
|
3377
|
+
activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
else if (type === 'human') {
|
|
3381
|
+
activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
3382
|
+
}
|
|
3383
|
+
else if (type === 'assistant') {
|
|
3384
|
+
const content = entry.message?.content;
|
|
3385
|
+
if (Array.isArray(content)) {
|
|
3386
|
+
const hasToolUse = content.some((b) => b.type === 'tool_use');
|
|
3387
|
+
if (hasToolUse) {
|
|
3388
|
+
const detail = extractToolDetail(entry);
|
|
3389
|
+
const toolBlock = content.find((b) => b.type === 'tool_use');
|
|
3390
|
+
const toolName = toolBlock?.name || 'tool';
|
|
3391
|
+
activity = { status: 'tool_use', label: `Running ${toolName}...`, detail, prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
3392
|
+
}
|
|
3393
|
+
else if (content.some((b) => b.type === 'thinking')) {
|
|
3394
|
+
activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
3395
|
+
}
|
|
3396
|
+
else {
|
|
3397
|
+
activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
else {
|
|
3401
|
+
activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
else {
|
|
3405
|
+
activity = { ...unknown, files, websites };
|
|
3406
|
+
}
|
|
3407
|
+
return activity;
|
|
3408
|
+
}
|
|
3409
|
+
function compactGitErrorText(value, max = 180) {
|
|
3410
|
+
const compact = String(value || '').replace(/\s+/g, ' ').trim();
|
|
3411
|
+
if (!compact)
|
|
3412
|
+
return 'git state unavailable';
|
|
3413
|
+
if (compact.length <= max)
|
|
3414
|
+
return compact;
|
|
3415
|
+
return `${compact.slice(0, max - 3)}...`;
|
|
3416
|
+
}
|
|
3417
|
+
function createSessionGitState(patch = {}) {
|
|
3418
|
+
const merged = {
|
|
3419
|
+
available: true,
|
|
3420
|
+
initialized: false,
|
|
3421
|
+
branch: null,
|
|
3422
|
+
detached: false,
|
|
3423
|
+
head: null,
|
|
3424
|
+
staged: 0,
|
|
3425
|
+
unstaged: 0,
|
|
3426
|
+
untracked: 0,
|
|
3427
|
+
conflicted: 0,
|
|
3428
|
+
dirty: false,
|
|
3429
|
+
clean: true,
|
|
3430
|
+
ahead: null,
|
|
3431
|
+
behind: null,
|
|
3432
|
+
checked_at: new Date().toISOString(),
|
|
3433
|
+
...patch,
|
|
3434
|
+
};
|
|
3435
|
+
const dirtyCount = merged.staged + merged.unstaged + merged.untracked + merged.conflicted;
|
|
3436
|
+
merged.dirty = patch.dirty !== undefined ? patch.dirty === true : dirtyCount > 0;
|
|
3437
|
+
merged.clean = !merged.dirty;
|
|
3438
|
+
if (!merged.checked_at)
|
|
3439
|
+
merged.checked_at = new Date().toISOString();
|
|
3440
|
+
if (merged.error !== undefined && !merged.error)
|
|
3441
|
+
delete merged.error;
|
|
3442
|
+
return merged;
|
|
3443
|
+
}
|
|
3444
|
+
function parseSessionGitStatusPorcelain(stdout) {
|
|
3445
|
+
const lines = String(stdout || '').split(/\r?\n/);
|
|
3446
|
+
let branch = null;
|
|
3447
|
+
let detached = false;
|
|
3448
|
+
let head = null;
|
|
3449
|
+
let ahead = null;
|
|
3450
|
+
let behind = null;
|
|
3451
|
+
let staged = 0;
|
|
3452
|
+
let unstaged = 0;
|
|
3453
|
+
let untracked = 0;
|
|
3454
|
+
let conflicted = 0;
|
|
3455
|
+
for (const rawLine of lines) {
|
|
3456
|
+
const line = rawLine.trim();
|
|
3457
|
+
if (!line)
|
|
3458
|
+
continue;
|
|
3459
|
+
if (line.startsWith('# branch.head ')) {
|
|
3460
|
+
const value = line.slice('# branch.head '.length).trim();
|
|
3461
|
+
if (value === '(detached)') {
|
|
3462
|
+
detached = true;
|
|
3463
|
+
branch = null;
|
|
3464
|
+
}
|
|
3465
|
+
else if (value && value !== '(unknown)') {
|
|
3466
|
+
branch = value;
|
|
3467
|
+
}
|
|
3468
|
+
continue;
|
|
3469
|
+
}
|
|
3470
|
+
if (line.startsWith('# branch.oid ')) {
|
|
3471
|
+
const value = line.slice('# branch.oid '.length).trim();
|
|
3472
|
+
if (/^[0-9a-f]{7,40}$/i.test(value)) {
|
|
3473
|
+
head = value.slice(0, 12);
|
|
3474
|
+
}
|
|
3475
|
+
continue;
|
|
3476
|
+
}
|
|
3477
|
+
if (line.startsWith('# branch.ab ')) {
|
|
3478
|
+
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/);
|
|
3479
|
+
if (match) {
|
|
3480
|
+
ahead = Number.parseInt(match[1], 10);
|
|
3481
|
+
behind = Number.parseInt(match[2], 10);
|
|
3482
|
+
}
|
|
3483
|
+
continue;
|
|
3484
|
+
}
|
|
3485
|
+
const kind = line.charAt(0);
|
|
3486
|
+
if (kind === '1' || kind === '2') {
|
|
3487
|
+
const match = line.match(/^[12] ([^ ]{2}) /);
|
|
3488
|
+
const xy = match?.[1] || '..';
|
|
3489
|
+
const x = xy.charAt(0);
|
|
3490
|
+
const y = xy.charAt(1);
|
|
3491
|
+
if (x && x !== '.')
|
|
3492
|
+
staged += 1;
|
|
3493
|
+
if (y && y !== '.')
|
|
3494
|
+
unstaged += 1;
|
|
3495
|
+
continue;
|
|
3496
|
+
}
|
|
3497
|
+
if (kind === 'u') {
|
|
3498
|
+
conflicted += 1;
|
|
3499
|
+
continue;
|
|
3500
|
+
}
|
|
3501
|
+
if (kind === '?') {
|
|
3502
|
+
untracked += 1;
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
return createSessionGitState({
|
|
3506
|
+
available: true,
|
|
3507
|
+
initialized: true,
|
|
3508
|
+
branch,
|
|
3509
|
+
detached,
|
|
3510
|
+
head,
|
|
3511
|
+
staged,
|
|
3512
|
+
unstaged,
|
|
3513
|
+
untracked,
|
|
3514
|
+
conflicted,
|
|
3515
|
+
ahead,
|
|
3516
|
+
behind,
|
|
3517
|
+
});
|
|
3518
|
+
}
|
|
3519
|
+
async function collectSessionGitState(workdir) {
|
|
3520
|
+
const fullWorkdir = (0, path_1.resolve)(workdir);
|
|
3521
|
+
const now = Date.now();
|
|
3522
|
+
const cached = sessionGitStateCache.get(fullWorkdir);
|
|
3523
|
+
if (cached && (now - cached.checkedAtMs) <= SESSION_GIT_CACHE_TTL_MS) {
|
|
3524
|
+
return cached.state;
|
|
3525
|
+
}
|
|
3526
|
+
let state;
|
|
3527
|
+
try {
|
|
3528
|
+
const result = await execFileAsync('git', ['-C', fullWorkdir, 'status', '--porcelain=v2', '--branch'], {
|
|
3529
|
+
encoding: 'utf-8',
|
|
3530
|
+
timeout: SESSION_GIT_COMMAND_TIMEOUT_MS,
|
|
3531
|
+
maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
|
|
3532
|
+
});
|
|
3533
|
+
state = parseSessionGitStatusPorcelain(String(result.stdout || ''));
|
|
3534
|
+
}
|
|
3535
|
+
catch (err) {
|
|
3536
|
+
const code = typeof err?.code === 'string' ? err.code : '';
|
|
3537
|
+
const detail = `${String(err?.stderr || '')}\n${String(err?.stdout || '')}\n${String(err?.message || '')}`.trim();
|
|
3538
|
+
const lower = detail.toLowerCase();
|
|
3539
|
+
if (code === 'ENOENT' || lower.includes('spawn git enonent')) {
|
|
3540
|
+
state = createSessionGitState({
|
|
3541
|
+
available: false,
|
|
3542
|
+
initialized: false,
|
|
3543
|
+
error: 'git command not available on host',
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
else if (lower.includes('not a git repository')) {
|
|
3547
|
+
state = createSessionGitState({
|
|
3548
|
+
available: true,
|
|
3549
|
+
initialized: false,
|
|
3550
|
+
});
|
|
3551
|
+
}
|
|
3552
|
+
else if (code === 'ETIMEDOUT' || lower.includes('timed out')) {
|
|
3553
|
+
state = createSessionGitState({
|
|
3554
|
+
available: true,
|
|
3555
|
+
initialized: false,
|
|
3556
|
+
error: 'git status timed out',
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
else if (lower.includes('cannot change to') || lower.includes('no such file or directory')) {
|
|
3560
|
+
state = createSessionGitState({
|
|
3561
|
+
available: true,
|
|
3562
|
+
initialized: false,
|
|
3563
|
+
error: 'workdir is unavailable',
|
|
3564
|
+
});
|
|
3565
|
+
}
|
|
3566
|
+
else {
|
|
3567
|
+
state = createSessionGitState({
|
|
3568
|
+
available: true,
|
|
3569
|
+
initialized: false,
|
|
3570
|
+
error: compactGitErrorText(detail),
|
|
3571
|
+
});
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
sessionGitStateCache.set(fullWorkdir, {
|
|
3575
|
+
state,
|
|
3576
|
+
checkedAtMs: Date.now(),
|
|
3577
|
+
});
|
|
3578
|
+
if (sessionGitStateCache.size > 512) {
|
|
3579
|
+
const cutoff = Date.now() - (SESSION_GIT_CACHE_TTL_MS * 4);
|
|
3580
|
+
for (const [pathKey, entry] of sessionGitStateCache.entries()) {
|
|
3581
|
+
if (entry.checkedAtMs < cutoff) {
|
|
3582
|
+
sessionGitStateCache.delete(pathKey);
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
return state;
|
|
3587
|
+
}
|
|
3588
|
+
async function annotateSessionsWithGitState(sessions) {
|
|
3589
|
+
if (!Array.isArray(sessions) || sessions.length === 0)
|
|
3590
|
+
return;
|
|
3591
|
+
const inflightByWorkdir = new Map();
|
|
3592
|
+
await Promise.all(sessions.map(async (session) => {
|
|
3593
|
+
const rawWorkdir = typeof session?.workdir === 'string' ? session.workdir.trim() : '';
|
|
3594
|
+
if (!rawWorkdir) {
|
|
3595
|
+
session.git = createSessionGitState({
|
|
3596
|
+
available: true,
|
|
3597
|
+
initialized: false,
|
|
3598
|
+
error: 'missing workdir',
|
|
3599
|
+
});
|
|
3600
|
+
return;
|
|
3601
|
+
}
|
|
3602
|
+
const fullWorkdir = (0, path_1.resolve)(rawWorkdir);
|
|
3603
|
+
let inflight = inflightByWorkdir.get(fullWorkdir);
|
|
3604
|
+
if (!inflight) {
|
|
3605
|
+
inflight = collectSessionGitState(fullWorkdir);
|
|
3606
|
+
inflightByWorkdir.set(fullWorkdir, inflight);
|
|
3607
|
+
}
|
|
3608
|
+
try {
|
|
3609
|
+
session.git = await inflight;
|
|
3610
|
+
}
|
|
3611
|
+
catch {
|
|
3612
|
+
session.git = createSessionGitState({
|
|
3613
|
+
available: true,
|
|
3614
|
+
initialized: false,
|
|
3615
|
+
error: 'git state unavailable',
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
}));
|
|
3619
|
+
}
|
|
3620
|
+
function clearSessionGitStateCache(workdir) {
|
|
3621
|
+
const fullWorkdir = (0, path_1.resolve)(workdir);
|
|
3622
|
+
sessionGitStateCache.delete(fullWorkdir);
|
|
3623
|
+
}
|
|
3624
|
+
function parseSessionIdFromPath(pathname, pattern) {
|
|
3625
|
+
const match = pathname.match(pattern);
|
|
3626
|
+
if (!match)
|
|
3627
|
+
return null;
|
|
3628
|
+
const decoded = decodeURIComponent(match[1] || '').trim();
|
|
3629
|
+
const normalized = normalizeSessionId(decoded);
|
|
3630
|
+
if (normalized)
|
|
3631
|
+
return normalized;
|
|
3632
|
+
if ((0, web_terminal_js_1.isValidWebTerminalId)(decoded))
|
|
3633
|
+
return decoded;
|
|
3634
|
+
return null;
|
|
3635
|
+
}
|
|
3636
|
+
function resolveSessionGitWorkdir(sessionId) {
|
|
3637
|
+
const session = getSessionRecord(sessionId);
|
|
3638
|
+
if (session) {
|
|
3639
|
+
const localHost = (0, os_1.hostname)();
|
|
3640
|
+
if (session.node !== localHost) {
|
|
3641
|
+
return { ok: false, status: 400, error: `Session is on a different node (${session.node})` };
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
if (session && typeof session.workdir === 'string' && session.workdir.trim()) {
|
|
3645
|
+
const workdir = (0, path_1.resolve)(session.workdir);
|
|
3646
|
+
return { ok: true, workdir };
|
|
3647
|
+
}
|
|
3648
|
+
const webSession = (0, web_terminal_js_1.readWebTerminalRecord)(sessionId);
|
|
3649
|
+
if (!webSession) {
|
|
3650
|
+
return { ok: false, status: 404, error: 'Session not found' };
|
|
3651
|
+
}
|
|
3652
|
+
if (webSession.node !== (0, os_1.hostname)()) {
|
|
3653
|
+
return { ok: false, status: 400, error: `Session is on a different node (${webSession.node})` };
|
|
3654
|
+
}
|
|
3655
|
+
if (typeof webSession.workdir !== 'string' || !webSession.workdir.trim()) {
|
|
3656
|
+
return { ok: false, status: 400, error: 'Session has no valid workdir' };
|
|
3657
|
+
}
|
|
3658
|
+
return { ok: true, workdir: (0, path_1.resolve)(webSession.workdir) };
|
|
3659
|
+
}
|
|
3660
|
+
function resolveSessionGitErrorStatus(detail) {
|
|
3661
|
+
const lower = String(detail || '').toLowerCase();
|
|
3662
|
+
if (!lower)
|
|
3663
|
+
return 500;
|
|
3664
|
+
if (lower.includes('already exists'))
|
|
3665
|
+
return 409;
|
|
3666
|
+
if (lower.includes('not a git repository'))
|
|
3667
|
+
return 409;
|
|
3668
|
+
if (lower.includes('pathspec') && lower.includes('did not match'))
|
|
3669
|
+
return 404;
|
|
3670
|
+
if (lower.includes('local changes') && lower.includes('would be overwritten'))
|
|
3671
|
+
return 409;
|
|
3672
|
+
if (lower.includes('would be overwritten by checkout'))
|
|
3673
|
+
return 409;
|
|
3674
|
+
if (lower.includes('timed out'))
|
|
3675
|
+
return 504;
|
|
3676
|
+
if (lower.includes('cannot change to') || lower.includes('no such file or directory'))
|
|
3677
|
+
return 400;
|
|
3678
|
+
return 500;
|
|
3679
|
+
}
|
|
3680
|
+
function parseGitUpstreamTrack(trackRaw) {
|
|
3681
|
+
const track = String(trackRaw || '');
|
|
3682
|
+
const aheadMatch = track.match(/ahead\s+(\d+)/i);
|
|
3683
|
+
const behindMatch = track.match(/behind\s+(\d+)/i);
|
|
3684
|
+
return {
|
|
3685
|
+
ahead: aheadMatch ? Number.parseInt(aheadMatch[1], 10) : 0,
|
|
3686
|
+
behind: behindMatch ? Number.parseInt(behindMatch[1], 10) : 0,
|
|
3687
|
+
};
|
|
3688
|
+
}
|
|
3689
|
+
function parseSessionGitBranchList(stdout) {
|
|
3690
|
+
const branches = [];
|
|
3691
|
+
for (const rawLine of String(stdout || '').split(/\r?\n/)) {
|
|
3692
|
+
if (!rawLine)
|
|
3693
|
+
continue;
|
|
3694
|
+
const parts = rawLine.split('\t');
|
|
3695
|
+
const name = String(parts[0] || '').trim();
|
|
3696
|
+
if (!name)
|
|
3697
|
+
continue;
|
|
3698
|
+
const current = String(parts[1] || '').trim() === '*';
|
|
3699
|
+
const upstreamText = String(parts[2] || '').trim();
|
|
3700
|
+
const { ahead, behind } = parseGitUpstreamTrack(String(parts[3] || '').trim());
|
|
3701
|
+
branches.push({
|
|
3702
|
+
name,
|
|
3703
|
+
current,
|
|
3704
|
+
upstream: upstreamText || null,
|
|
3705
|
+
ahead,
|
|
3706
|
+
behind,
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
return branches;
|
|
3710
|
+
}
|
|
3711
|
+
async function collectSessionGitBranches(workdir) {
|
|
3712
|
+
const result = await execFileAsync('git', [
|
|
3713
|
+
'-C',
|
|
3714
|
+
workdir,
|
|
3715
|
+
'for-each-ref',
|
|
3716
|
+
'--sort=refname',
|
|
3717
|
+
'--format=%(refname:short)%09%(HEAD)%09%(upstream:short)%09%(upstream:track)',
|
|
3718
|
+
'refs/heads',
|
|
3719
|
+
], {
|
|
3720
|
+
encoding: 'utf-8',
|
|
3721
|
+
timeout: SESSION_GIT_MUTATION_TIMEOUT_MS,
|
|
3722
|
+
maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
|
|
3723
|
+
});
|
|
3724
|
+
return parseSessionGitBranchList(String(result.stdout || ''));
|
|
3725
|
+
}
|
|
3726
|
+
async function ensureSessionGitReady(workdir) {
|
|
3727
|
+
const git = await collectSessionGitState(workdir);
|
|
3728
|
+
if (git.available === false) {
|
|
3729
|
+
return {
|
|
3730
|
+
ok: false,
|
|
3731
|
+
status: 503,
|
|
3732
|
+
error: git.error || 'git command not available on host',
|
|
3733
|
+
git,
|
|
3734
|
+
};
|
|
3735
|
+
}
|
|
3736
|
+
if (!git.initialized) {
|
|
3737
|
+
return {
|
|
3738
|
+
ok: false,
|
|
3739
|
+
status: 409,
|
|
3740
|
+
error: git.error || 'No git repository initialized in this workdir',
|
|
3741
|
+
git,
|
|
3742
|
+
};
|
|
3743
|
+
}
|
|
3744
|
+
return { ok: true, git };
|
|
3745
|
+
}
|
|
3746
|
+
async function handleGetSessionGitBranches(pathname, res) {
|
|
3747
|
+
const sessionId = parseSessionIdFromPath(pathname, /^\/api\/sessions\/([^/]+)\/git\/branches$/);
|
|
3748
|
+
if (!sessionId) {
|
|
3749
|
+
json(res, { ok: false, error: 'Invalid session id' }, 400);
|
|
3750
|
+
return;
|
|
3751
|
+
}
|
|
3752
|
+
const resolved = resolveSessionGitWorkdir(sessionId);
|
|
3753
|
+
if (!resolved.ok) {
|
|
3754
|
+
json(res, { ok: false, error: resolved.error }, resolved.status);
|
|
3755
|
+
return;
|
|
3756
|
+
}
|
|
3757
|
+
const ready = await ensureSessionGitReady(resolved.workdir);
|
|
3758
|
+
if (!ready.ok) {
|
|
3759
|
+
json(res, { ok: false, error: ready.error, git: ready.git }, ready.status);
|
|
3760
|
+
return;
|
|
3761
|
+
}
|
|
3762
|
+
try {
|
|
3763
|
+
const branches = await collectSessionGitBranches(resolved.workdir);
|
|
3764
|
+
json(res, {
|
|
3765
|
+
ok: true,
|
|
3766
|
+
sessionId,
|
|
3767
|
+
workdir: resolved.workdir,
|
|
3768
|
+
git: ready.git,
|
|
3769
|
+
branches,
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
3772
|
+
catch (err) {
|
|
3773
|
+
const detail = compactGitErrorText(commandErrorDetail(err) || (err?.message ?? String(err)), 220);
|
|
3774
|
+
json(res, { ok: false, error: detail }, resolveSessionGitErrorStatus(detail));
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
async function handleGetSessionGitDag(pathname, reqUrl, res) {
|
|
3778
|
+
const sessionId = parseSessionIdFromPath(pathname, /^\/api\/sessions\/([^/]+)\/git\/dag$/);
|
|
3779
|
+
if (!sessionId) {
|
|
3780
|
+
json(res, { ok: false, error: 'Invalid session id' }, 400);
|
|
3781
|
+
return;
|
|
3782
|
+
}
|
|
3783
|
+
let limit = 120;
|
|
3784
|
+
const limitRaw = String(reqUrl.searchParams.get('limit') || '').trim();
|
|
3785
|
+
if (limitRaw) {
|
|
3786
|
+
const parsed = Number(limitRaw);
|
|
3787
|
+
if (!Number.isFinite(parsed)) {
|
|
3788
|
+
json(res, { ok: false, error: 'Invalid limit' }, 400);
|
|
3789
|
+
return;
|
|
3790
|
+
}
|
|
3791
|
+
limit = Math.max(20, Math.min(400, Math.floor(parsed)));
|
|
3792
|
+
}
|
|
3793
|
+
const resolved = resolveSessionGitWorkdir(sessionId);
|
|
3794
|
+
if (!resolved.ok) {
|
|
3795
|
+
json(res, { ok: false, error: resolved.error }, resolved.status);
|
|
3796
|
+
return;
|
|
3797
|
+
}
|
|
3798
|
+
const ready = await ensureSessionGitReady(resolved.workdir);
|
|
3799
|
+
if (!ready.ok) {
|
|
3800
|
+
json(res, { ok: false, error: ready.error, git: ready.git }, ready.status);
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3803
|
+
try {
|
|
3804
|
+
const result = await execFileAsync('git', [
|
|
3805
|
+
'-C',
|
|
3806
|
+
resolved.workdir,
|
|
3807
|
+
'log',
|
|
3808
|
+
'--graph',
|
|
3809
|
+
'--decorate',
|
|
3810
|
+
'--oneline',
|
|
3811
|
+
'--all',
|
|
3812
|
+
`--max-count=${limit}`,
|
|
3813
|
+
'--color=never',
|
|
3814
|
+
], {
|
|
3815
|
+
encoding: 'utf-8',
|
|
3816
|
+
timeout: SESSION_GIT_MUTATION_TIMEOUT_MS,
|
|
3817
|
+
maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
|
|
3818
|
+
});
|
|
3819
|
+
const graph = String(result.stdout || '').replace(/\s+$/u, '');
|
|
3820
|
+
const lines = graph ? graph.split(/\r?\n/) : [];
|
|
3821
|
+
json(res, {
|
|
3822
|
+
ok: true,
|
|
3823
|
+
sessionId,
|
|
3824
|
+
workdir: resolved.workdir,
|
|
3825
|
+
git: ready.git,
|
|
3826
|
+
graph,
|
|
3827
|
+
lineCount: lines.length,
|
|
3828
|
+
truncated: lines.length >= limit,
|
|
3829
|
+
limit,
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3832
|
+
catch (err) {
|
|
3833
|
+
const detail = compactGitErrorText(commandErrorDetail(err) || (err?.message ?? String(err)), 220);
|
|
3834
|
+
const lower = detail.toLowerCase();
|
|
3835
|
+
if (lower.includes('does not have any commits yet')) {
|
|
3836
|
+
json(res, {
|
|
3837
|
+
ok: true,
|
|
3838
|
+
sessionId,
|
|
3839
|
+
workdir: resolved.workdir,
|
|
3840
|
+
git: ready.git,
|
|
3841
|
+
graph: '',
|
|
3842
|
+
lineCount: 0,
|
|
3843
|
+
truncated: false,
|
|
3844
|
+
limit,
|
|
3845
|
+
});
|
|
3846
|
+
return;
|
|
3847
|
+
}
|
|
3848
|
+
json(res, { ok: false, error: detail }, resolveSessionGitErrorStatus(detail));
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
async function handlePostSessionGitCheckout(pathname, req, res) {
|
|
3852
|
+
const sessionId = parseSessionIdFromPath(pathname, /^\/api\/sessions\/([^/]+)\/git\/checkout$/);
|
|
3853
|
+
if (!sessionId) {
|
|
3854
|
+
json(res, { ok: false, error: 'Invalid session id' }, 400);
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3857
|
+
const resolved = resolveSessionGitWorkdir(sessionId);
|
|
3858
|
+
if (!resolved.ok) {
|
|
3859
|
+
json(res, { ok: false, error: resolved.error }, resolved.status);
|
|
3860
|
+
return;
|
|
3861
|
+
}
|
|
3862
|
+
let parsed;
|
|
3863
|
+
try {
|
|
3864
|
+
parsed = JSON.parse(await readBody(req));
|
|
3865
|
+
}
|
|
3866
|
+
catch {
|
|
3867
|
+
json(res, { ok: false, error: 'Invalid JSON' }, 400);
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3870
|
+
const branch = String(parsed.branch || '').trim();
|
|
3871
|
+
if (!branch) {
|
|
3872
|
+
json(res, { ok: false, error: 'branch is required' }, 400);
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
const ready = await ensureSessionGitReady(resolved.workdir);
|
|
3876
|
+
if (!ready.ok) {
|
|
3877
|
+
json(res, { ok: false, error: ready.error, git: ready.git }, ready.status);
|
|
3878
|
+
return;
|
|
3879
|
+
}
|
|
3880
|
+
if (!ready.git.detached && ready.git.branch === branch) {
|
|
3881
|
+
json(res, { ok: true, sessionId, unchanged: true, git: ready.git });
|
|
3882
|
+
return;
|
|
3883
|
+
}
|
|
3884
|
+
try {
|
|
3885
|
+
await execFileAsync('git', ['-C', resolved.workdir, 'checkout', branch], {
|
|
3886
|
+
encoding: 'utf-8',
|
|
3887
|
+
timeout: SESSION_GIT_MUTATION_TIMEOUT_MS,
|
|
3888
|
+
maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
|
|
3889
|
+
});
|
|
3890
|
+
clearSessionGitStateCache(resolved.workdir);
|
|
3891
|
+
const git = await collectSessionGitState(resolved.workdir);
|
|
3892
|
+
json(res, { ok: true, sessionId, git });
|
|
3893
|
+
}
|
|
3894
|
+
catch (err) {
|
|
3895
|
+
const detail = compactGitErrorText(commandErrorDetail(err) || (err?.message ?? String(err)), 220);
|
|
3896
|
+
json(res, { ok: false, error: detail }, resolveSessionGitErrorStatus(detail));
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
async function handlePostSessionGitBranchCreate(pathname, req, res) {
|
|
3900
|
+
const sessionId = parseSessionIdFromPath(pathname, /^\/api\/sessions\/([^/]+)\/git\/branches$/);
|
|
3901
|
+
if (!sessionId) {
|
|
3902
|
+
json(res, { ok: false, error: 'Invalid session id' }, 400);
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
const resolved = resolveSessionGitWorkdir(sessionId);
|
|
3906
|
+
if (!resolved.ok) {
|
|
3907
|
+
json(res, { ok: false, error: resolved.error }, resolved.status);
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
let parsed;
|
|
3911
|
+
try {
|
|
3912
|
+
parsed = JSON.parse(await readBody(req));
|
|
3913
|
+
}
|
|
3914
|
+
catch {
|
|
3915
|
+
json(res, { ok: false, error: 'Invalid JSON' }, 400);
|
|
3916
|
+
return;
|
|
2960
3917
|
}
|
|
2961
|
-
|
|
2962
|
-
|
|
3918
|
+
const name = String(parsed.name || '').trim();
|
|
3919
|
+
const from = String(parsed.from || '').trim();
|
|
3920
|
+
if (!name) {
|
|
3921
|
+
json(res, { ok: false, error: 'name is required' }, 400);
|
|
3922
|
+
return;
|
|
2963
3923
|
}
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
if (hasToolUse) {
|
|
2969
|
-
const detail = extractToolDetail(entry);
|
|
2970
|
-
const toolBlock = content.find((b) => b.type === 'tool_use');
|
|
2971
|
-
const toolName = toolBlock?.name || 'tool';
|
|
2972
|
-
activity = { status: 'tool_use', label: `Running ${toolName}...`, detail, prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
2973
|
-
}
|
|
2974
|
-
else if (content.some((b) => b.type === 'thinking')) {
|
|
2975
|
-
activity = { status: 'thinking', label: 'Thinking...', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
2976
|
-
}
|
|
2977
|
-
else {
|
|
2978
|
-
activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
2979
|
-
}
|
|
2980
|
-
}
|
|
2981
|
-
else {
|
|
2982
|
-
activity = { status: 'waiting', label: 'Waiting for input', detail: '', prompt: lastUserPrompt, since: entryTs, lastActive: mtimeMs, files, websites };
|
|
2983
|
-
}
|
|
3924
|
+
const ready = await ensureSessionGitReady(resolved.workdir);
|
|
3925
|
+
if (!ready.ok) {
|
|
3926
|
+
json(res, { ok: false, error: ready.error, git: ready.git }, ready.status);
|
|
3927
|
+
return;
|
|
2984
3928
|
}
|
|
2985
|
-
|
|
2986
|
-
|
|
3929
|
+
try {
|
|
3930
|
+
await execFileAsync('git', ['check-ref-format', '--branch', name], {
|
|
3931
|
+
encoding: 'utf-8',
|
|
3932
|
+
timeout: SESSION_GIT_COMMAND_TIMEOUT_MS,
|
|
3933
|
+
maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
|
|
3934
|
+
});
|
|
3935
|
+
}
|
|
3936
|
+
catch {
|
|
3937
|
+
json(res, { ok: false, error: 'Invalid git branch name' }, 400);
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3940
|
+
try {
|
|
3941
|
+
const args = ['-C', resolved.workdir, 'checkout', '-b', name];
|
|
3942
|
+
if (from)
|
|
3943
|
+
args.push(from);
|
|
3944
|
+
await execFileAsync('git', args, {
|
|
3945
|
+
encoding: 'utf-8',
|
|
3946
|
+
timeout: SESSION_GIT_MUTATION_TIMEOUT_MS,
|
|
3947
|
+
maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
|
|
3948
|
+
});
|
|
3949
|
+
clearSessionGitStateCache(resolved.workdir);
|
|
3950
|
+
const git = await collectSessionGitState(resolved.workdir);
|
|
3951
|
+
json(res, { ok: true, sessionId, created: name, git });
|
|
3952
|
+
}
|
|
3953
|
+
catch (err) {
|
|
3954
|
+
const detail = compactGitErrorText(commandErrorDetail(err) || (err?.message ?? String(err)), 220);
|
|
3955
|
+
json(res, { ok: false, error: detail }, resolveSessionGitErrorStatus(detail));
|
|
2987
3956
|
}
|
|
2988
|
-
return activity;
|
|
2989
3957
|
}
|
|
2990
3958
|
function getSessionStatus(workdir) {
|
|
2991
3959
|
try {
|
|
@@ -3007,6 +3975,8 @@ async function handleGetSessions(_req, res) {
|
|
|
3007
3975
|
const localHost = (0, os_1.hostname)();
|
|
3008
3976
|
const files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.json'));
|
|
3009
3977
|
const sessions = [];
|
|
3978
|
+
const now = Date.now();
|
|
3979
|
+
pruneSessionActivityCache(now);
|
|
3010
3980
|
for (const file of files) {
|
|
3011
3981
|
try {
|
|
3012
3982
|
const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
|
|
@@ -3025,11 +3995,19 @@ async function handleGetSessions(_req, res) {
|
|
|
3025
3995
|
}
|
|
3026
3996
|
}
|
|
3027
3997
|
data.status = getSessionStatus(data.workdir);
|
|
3028
|
-
|
|
3998
|
+
const cachedActivity = getCachedSessionActivity(data, now);
|
|
3999
|
+
if (cachedActivity) {
|
|
4000
|
+
data.activity = cachedActivity;
|
|
4001
|
+
}
|
|
4002
|
+
else {
|
|
4003
|
+
data.activity = getAgentActivity(data);
|
|
4004
|
+
setCachedSessionActivity(data, data.activity, now);
|
|
4005
|
+
}
|
|
3029
4006
|
sessions.push(data);
|
|
3030
4007
|
}
|
|
3031
4008
|
catch { /* skip unparseable files */ }
|
|
3032
4009
|
}
|
|
4010
|
+
await annotateSessionsWithGitState(sessions);
|
|
3033
4011
|
// Collect container resource stats
|
|
3034
4012
|
const sessionIds = sessions.map((s) => s.id).filter(Boolean);
|
|
3035
4013
|
try {
|
|
@@ -3223,11 +4201,16 @@ async function handlePostWebTerminalStart(req, res) {
|
|
|
3223
4201
|
const body = await readBody(req);
|
|
3224
4202
|
const parsed = JSON.parse(body || '{}');
|
|
3225
4203
|
const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
|
|
4204
|
+
const permissionMode = normalizeWebTerminalPermissionMode(parsed.permission_mode);
|
|
3226
4205
|
const rawWorkdir = String(parsed.workdir || '').trim();
|
|
3227
4206
|
if (!agent) {
|
|
3228
4207
|
json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
|
|
3229
4208
|
return;
|
|
3230
4209
|
}
|
|
4210
|
+
if (!permissionMode) {
|
|
4211
|
+
json(res, { ok: false, error: 'permission_mode must be "default" or "dangerous"' }, 400);
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
3231
4214
|
if (!rawWorkdir) {
|
|
3232
4215
|
json(res, { ok: false, error: 'workdir is required' }, 400);
|
|
3233
4216
|
return;
|
|
@@ -3249,7 +4232,7 @@ async function handlePostWebTerminalStart(req, res) {
|
|
|
3249
4232
|
json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
|
|
3250
4233
|
return;
|
|
3251
4234
|
}
|
|
3252
|
-
const result = await startWebTerminalSession(agent, resolvedWorkdir);
|
|
4235
|
+
const result = await startWebTerminalSession(agent, resolvedWorkdir, { permissionMode });
|
|
3253
4236
|
if (!result.ok) {
|
|
3254
4237
|
json(res, result.body, result.status);
|
|
3255
4238
|
return;
|
|
@@ -3275,6 +4258,7 @@ async function runWebTerminalInitJob(id) {
|
|
|
3275
4258
|
const result = await startWebTerminalSession(job.agent, job.workdir, {
|
|
3276
4259
|
prewarmImage: true,
|
|
3277
4260
|
prewarmAgent: true,
|
|
4261
|
+
permissionMode: job.permissionMode,
|
|
3278
4262
|
onProgress: progress,
|
|
3279
4263
|
});
|
|
3280
4264
|
if (result.ok) {
|
|
@@ -3325,11 +4309,16 @@ async function handlePostWebTerminalInit(req, res) {
|
|
|
3325
4309
|
const body = await readBody(req);
|
|
3326
4310
|
const parsed = JSON.parse(body || '{}');
|
|
3327
4311
|
const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
|
|
4312
|
+
const permissionMode = normalizeWebTerminalPermissionMode(parsed.permission_mode);
|
|
3328
4313
|
const rawWorkdir = String(parsed.workdir || '').trim();
|
|
3329
4314
|
if (!agent) {
|
|
3330
4315
|
json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
|
|
3331
4316
|
return;
|
|
3332
4317
|
}
|
|
4318
|
+
if (!permissionMode) {
|
|
4319
|
+
json(res, { ok: false, error: 'permission_mode must be "default" or "dangerous"' }, 400);
|
|
4320
|
+
return;
|
|
4321
|
+
}
|
|
3333
4322
|
if (!rawWorkdir) {
|
|
3334
4323
|
json(res, { ok: false, error: 'workdir is required' }, 400);
|
|
3335
4324
|
return;
|
|
@@ -3352,7 +4341,7 @@ async function handlePostWebTerminalInit(req, res) {
|
|
|
3352
4341
|
return;
|
|
3353
4342
|
}
|
|
3354
4343
|
pruneWebTerminalInitJobs();
|
|
3355
|
-
const job = createWebTerminalInitJob(agent, resolvedWorkdir);
|
|
4344
|
+
const job = createWebTerminalInitJob(agent, resolvedWorkdir, permissionMode);
|
|
3356
4345
|
webTerminalInitJobs.set(job.id, job);
|
|
3357
4346
|
void runWebTerminalInitJob(job.id);
|
|
3358
4347
|
json(res, { ok: true, init: serializeWebTerminalInitJob(job) }, 202);
|
|
@@ -3455,11 +4444,36 @@ async function handleGetWebTerminalInit(reqUrl, res) {
|
|
|
3455
4444
|
async function handleGetWebTerminalSessions(res) {
|
|
3456
4445
|
const records = (0, web_terminal_js_1.listWebTerminalRecords)();
|
|
3457
4446
|
const localNode = (0, os_1.hostname)();
|
|
3458
|
-
|
|
4447
|
+
const now = Date.now();
|
|
4448
|
+
const TMUX_STATUS_PROBE_MAX = 16;
|
|
4449
|
+
const TMUX_RECENT_NON_RUNNING_PROBE_MS = 45_000;
|
|
4450
|
+
function parseRecordTimeMs(record) {
|
|
4451
|
+
const raw = String(record.updatedAt || record.createdAt || '').trim();
|
|
4452
|
+
if (!raw)
|
|
4453
|
+
return 0;
|
|
4454
|
+
const ms = Date.parse(raw);
|
|
4455
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
4456
|
+
}
|
|
4457
|
+
const probeCandidates = records
|
|
4458
|
+
.filter((record) => {
|
|
4459
|
+
if (record.node !== localNode)
|
|
4460
|
+
return false;
|
|
4461
|
+
if (record.status === 'running')
|
|
4462
|
+
return true;
|
|
4463
|
+
const ageMs = now - parseRecordTimeMs(record);
|
|
4464
|
+
return ageMs >= 0 && ageMs <= TMUX_RECENT_NON_RUNNING_PROBE_MS;
|
|
4465
|
+
})
|
|
4466
|
+
.sort((a, b) => {
|
|
4467
|
+
const aRunning = a.status === 'running' ? 0 : 1;
|
|
4468
|
+
const bRunning = b.status === 'running' ? 0 : 1;
|
|
4469
|
+
if (aRunning !== bRunning)
|
|
4470
|
+
return aRunning - bRunning;
|
|
4471
|
+
return parseRecordTimeMs(b) - parseRecordTimeMs(a);
|
|
4472
|
+
})
|
|
4473
|
+
.slice(0, TMUX_STATUS_PROBE_MAX);
|
|
4474
|
+
for (const record of probeCandidates) {
|
|
3459
4475
|
// Shared home directories may contain records from other nodes.
|
|
3460
4476
|
// Only mutate runtime status for sessions that belong to this node.
|
|
3461
|
-
if (record.node !== localNode)
|
|
3462
|
-
continue;
|
|
3463
4477
|
const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
|
|
3464
4478
|
if (alive && record.status !== 'running') {
|
|
3465
4479
|
(0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, { status: 'running', exitCode: null, error: null });
|
|
@@ -3475,8 +4489,25 @@ async function handleGetWebTerminalSessions(res) {
|
|
|
3475
4489
|
});
|
|
3476
4490
|
}
|
|
3477
4491
|
}
|
|
3478
|
-
|
|
3479
|
-
|
|
4492
|
+
function parseSessionSortMs(session) {
|
|
4493
|
+
const raw = String(session.updatedAt || session.createdAt || '').trim();
|
|
4494
|
+
if (!raw)
|
|
4495
|
+
return 0;
|
|
4496
|
+
const ms = Date.parse(raw);
|
|
4497
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
4498
|
+
}
|
|
4499
|
+
const allSessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
|
|
4500
|
+
const activeSessions = allSessions.filter((session) => session.status === 'running');
|
|
4501
|
+
const recentInactive = allSessions
|
|
4502
|
+
.filter((session) => session.status !== 'running')
|
|
4503
|
+
.sort((a, b) => parseSessionSortMs(b) - parseSessionSortMs(a))
|
|
4504
|
+
.slice(0, 24);
|
|
4505
|
+
const sessions = activeSessions.concat(recentInactive);
|
|
4506
|
+
pruneWebTerminalInitJobs();
|
|
4507
|
+
const initJobs = Array.from(webTerminalInitJobs.values())
|
|
4508
|
+
.filter((job) => job.status === 'running')
|
|
4509
|
+
.map((job) => serializeWebTerminalInitJob(job));
|
|
4510
|
+
json(res, { ok: true, sessions, initJobs });
|
|
3480
4511
|
}
|
|
3481
4512
|
async function handleGetWebTerminalHistory(reqUrl, res) {
|
|
3482
4513
|
const id = String(reqUrl.searchParams.get('id') || '').trim();
|
|
@@ -3627,6 +4658,95 @@ async function handleValidatePath(req, res) {
|
|
|
3627
4658
|
}
|
|
3628
4659
|
}
|
|
3629
4660
|
// ── Directory browse helper ─────────────────────────────
|
|
4661
|
+
const BROWSE_DIR_GIT_BADGE_PRIORITY = {
|
|
4662
|
+
'!': 6,
|
|
4663
|
+
M: 5,
|
|
4664
|
+
R: 4,
|
|
4665
|
+
A: 3,
|
|
4666
|
+
D: 2,
|
|
4667
|
+
'?': 1,
|
|
4668
|
+
};
|
|
4669
|
+
function resolveBrowseDirGitBadgeFromPorcelain(kind, xy) {
|
|
4670
|
+
const x = String(xy || '..').charAt(0);
|
|
4671
|
+
const y = String(xy || '..').charAt(1);
|
|
4672
|
+
if (kind === 'u' || x === 'U' || y === 'U')
|
|
4673
|
+
return '!';
|
|
4674
|
+
if (kind === '?')
|
|
4675
|
+
return '?';
|
|
4676
|
+
if (x === 'M' || y === 'M' || x === 'T' || y === 'T' || x === 'C' || y === 'C')
|
|
4677
|
+
return 'M';
|
|
4678
|
+
if (x === 'R' || y === 'R')
|
|
4679
|
+
return 'R';
|
|
4680
|
+
if (x === 'A' || y === 'A')
|
|
4681
|
+
return 'A';
|
|
4682
|
+
if (x === 'D' || y === 'D')
|
|
4683
|
+
return 'D';
|
|
4684
|
+
return '';
|
|
4685
|
+
}
|
|
4686
|
+
function pickBrowseDirGitBadge(current, next) {
|
|
4687
|
+
if (!current)
|
|
4688
|
+
return next;
|
|
4689
|
+
const currentPriority = BROWSE_DIR_GIT_BADGE_PRIORITY[current] || 0;
|
|
4690
|
+
const nextPriority = BROWSE_DIR_GIT_BADGE_PRIORITY[next] || 0;
|
|
4691
|
+
return nextPriority > currentPriority ? next : current;
|
|
4692
|
+
}
|
|
4693
|
+
async function collectBrowseDirGitStatus(resolvedDir) {
|
|
4694
|
+
const statusByName = new Map();
|
|
4695
|
+
let stdout = '';
|
|
4696
|
+
try {
|
|
4697
|
+
const result = await execFileAsync('git', ['-C', resolvedDir, 'status', '--porcelain=v2', '--', '.'], {
|
|
4698
|
+
encoding: 'utf-8',
|
|
4699
|
+
timeout: BROWSE_DIR_GIT_STATUS_TIMEOUT_MS,
|
|
4700
|
+
maxBuffer: SESSION_GIT_COMMAND_MAX_BUFFER,
|
|
4701
|
+
});
|
|
4702
|
+
stdout = String(result.stdout || '');
|
|
4703
|
+
}
|
|
4704
|
+
catch {
|
|
4705
|
+
return statusByName;
|
|
4706
|
+
}
|
|
4707
|
+
const lines = stdout.split(/\r?\n/);
|
|
4708
|
+
for (const rawLine of lines) {
|
|
4709
|
+
const line = rawLine.trimEnd();
|
|
4710
|
+
if (!line || line.startsWith('#'))
|
|
4711
|
+
continue;
|
|
4712
|
+
const kind = line.charAt(0);
|
|
4713
|
+
let xy = '..';
|
|
4714
|
+
let relativePath = '';
|
|
4715
|
+
if (kind === '?') {
|
|
4716
|
+
relativePath = line.slice(2).trim();
|
|
4717
|
+
xy = '??';
|
|
4718
|
+
}
|
|
4719
|
+
else if (kind === '1' || kind === '2' || kind === 'u') {
|
|
4720
|
+
const tabParts = line.split('\t');
|
|
4721
|
+
if (tabParts.length < 2)
|
|
4722
|
+
continue;
|
|
4723
|
+
const metaParts = tabParts[0].split(' ');
|
|
4724
|
+
if (metaParts.length >= 2)
|
|
4725
|
+
xy = String(metaParts[1] || '..');
|
|
4726
|
+
relativePath = String(tabParts[1] || '').trim();
|
|
4727
|
+
}
|
|
4728
|
+
else {
|
|
4729
|
+
continue;
|
|
4730
|
+
}
|
|
4731
|
+
if (!relativePath)
|
|
4732
|
+
continue;
|
|
4733
|
+
const normalized = relativePath
|
|
4734
|
+
.replace(/\\/g, '/')
|
|
4735
|
+
.replace(/^\.\/+/, '')
|
|
4736
|
+
.replace(/^"+|"+$/g, '');
|
|
4737
|
+
if (!normalized)
|
|
4738
|
+
continue;
|
|
4739
|
+
const firstSegment = normalized.split('/')[0];
|
|
4740
|
+
if (!firstSegment)
|
|
4741
|
+
continue;
|
|
4742
|
+
const badge = resolveBrowseDirGitBadgeFromPorcelain(kind, xy);
|
|
4743
|
+
if (!badge)
|
|
4744
|
+
continue;
|
|
4745
|
+
const existing = statusByName.get(firstSegment);
|
|
4746
|
+
statusByName.set(firstSegment, pickBrowseDirGitBadge(existing, badge));
|
|
4747
|
+
}
|
|
4748
|
+
return statusByName;
|
|
4749
|
+
}
|
|
3630
4750
|
async function handleBrowseDir(req, res) {
|
|
3631
4751
|
try {
|
|
3632
4752
|
const body = await readBody(req);
|
|
@@ -3634,6 +4754,7 @@ async function handleBrowseDir(req, res) {
|
|
|
3634
4754
|
const rawPath = typeof parsed.path === 'string' ? parsed.path : '~';
|
|
3635
4755
|
const includeFiles = !!parsed.includeFiles;
|
|
3636
4756
|
const includeHidden = !!parsed.includeHidden;
|
|
4757
|
+
const includeGitStatus = !!parsed.includeGitStatus;
|
|
3637
4758
|
const rawLimit = Number(parsed.maxEntries);
|
|
3638
4759
|
const maxEntries = Number.isFinite(rawLimit)
|
|
3639
4760
|
? Math.max(100, Math.min(5000, Math.floor(rawLimit)))
|
|
@@ -3661,10 +4782,10 @@ async function handleBrowseDir(req, res) {
|
|
|
3661
4782
|
try {
|
|
3662
4783
|
const st = (0, fs_1.statSync)(full);
|
|
3663
4784
|
if (st.isDirectory()) {
|
|
3664
|
-
dirs.push({ name: entry, path: full });
|
|
4785
|
+
dirs.push({ name: entry, path: full, mtime_ms: Math.round(st.mtimeMs) });
|
|
3665
4786
|
}
|
|
3666
4787
|
else if (includeFiles && st.isFile()) {
|
|
3667
|
-
files.push({ name: entry, path: full });
|
|
4788
|
+
files.push({ name: entry, path: full, mtime_ms: Math.round(st.mtimeMs) });
|
|
3668
4789
|
}
|
|
3669
4790
|
}
|
|
3670
4791
|
catch { /* skip inaccessible */ }
|
|
@@ -3675,15 +4796,24 @@ async function handleBrowseDir(req, res) {
|
|
|
3675
4796
|
}
|
|
3676
4797
|
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
3677
4798
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
4799
|
+
const gitStatusByName = includeGitStatus ? await collectBrowseDirGitStatus(resolved) : new Map();
|
|
4800
|
+
const dirsOut = dirs.map((dir) => {
|
|
4801
|
+
const gitStatus = gitStatusByName.get(dir.name);
|
|
4802
|
+
return gitStatus ? { ...dir, git_status: gitStatus } : dir;
|
|
4803
|
+
});
|
|
4804
|
+
const filesOut = files.map((file) => {
|
|
4805
|
+
const gitStatus = gitStatusByName.get(file.name);
|
|
4806
|
+
return gitStatus ? { ...file, git_status: gitStatus } : file;
|
|
4807
|
+
});
|
|
3678
4808
|
const entriesOut = [
|
|
3679
|
-
...
|
|
3680
|
-
...
|
|
4809
|
+
...dirsOut.map((d) => ({ name: d.name, path: d.path, type: 'dir', mtime_ms: d.mtime_ms, git_status: d.git_status })),
|
|
4810
|
+
...filesOut.map((f) => ({ name: f.name, path: f.path, type: 'file', mtime_ms: f.mtime_ms, git_status: f.git_status })),
|
|
3681
4811
|
];
|
|
3682
4812
|
json(res, {
|
|
3683
4813
|
ok: true,
|
|
3684
4814
|
path: resolved,
|
|
3685
|
-
dirs,
|
|
3686
|
-
files: includeFiles ?
|
|
4815
|
+
dirs: dirsOut,
|
|
4816
|
+
files: includeFiles ? filesOut : undefined,
|
|
3687
4817
|
entries: includeFiles ? entriesOut : undefined,
|
|
3688
4818
|
truncated,
|
|
3689
4819
|
});
|
|
@@ -3692,6 +4822,136 @@ async function handleBrowseDir(req, res) {
|
|
|
3692
4822
|
json(res, { ok: false, error: err.message ?? String(err) }, 400);
|
|
3693
4823
|
}
|
|
3694
4824
|
}
|
|
4825
|
+
function collectFilePreviewAllowedRoots() {
|
|
4826
|
+
const roots = new Set();
|
|
4827
|
+
const addRoot = (rawPath) => {
|
|
4828
|
+
if (typeof rawPath !== 'string')
|
|
4829
|
+
return;
|
|
4830
|
+
const trimmed = rawPath.trim();
|
|
4831
|
+
if (!trimmed)
|
|
4832
|
+
return;
|
|
4833
|
+
const resolved = trimmed.replace(/^~/, (0, os_1.homedir)());
|
|
4834
|
+
try {
|
|
4835
|
+
const real = (0, fs_1.realpathSync)(resolved);
|
|
4836
|
+
if (!(0, fs_1.statSync)(real).isDirectory())
|
|
4837
|
+
return;
|
|
4838
|
+
roots.add(real);
|
|
4839
|
+
}
|
|
4840
|
+
catch {
|
|
4841
|
+
// Ignore missing/inaccessible roots.
|
|
4842
|
+
}
|
|
4843
|
+
};
|
|
4844
|
+
try {
|
|
4845
|
+
for (const record of (0, web_terminal_js_1.listWebTerminalRecords)()) {
|
|
4846
|
+
addRoot(record.workdir);
|
|
4847
|
+
}
|
|
4848
|
+
}
|
|
4849
|
+
catch {
|
|
4850
|
+
// Best-effort root discovery.
|
|
4851
|
+
}
|
|
4852
|
+
const config = (0, config_js_1.loadConfig)();
|
|
4853
|
+
const extraPaths = Array.isArray(config.filesystem?.extra_paths) ? config.filesystem.extra_paths : [];
|
|
4854
|
+
for (const entry of extraPaths) {
|
|
4855
|
+
if (entry && typeof entry === 'object') {
|
|
4856
|
+
addRoot(entry.path);
|
|
4857
|
+
}
|
|
4858
|
+
}
|
|
4859
|
+
return [...roots];
|
|
4860
|
+
}
|
|
4861
|
+
function isPathWithinAllowedRoot(filePath, rootPath) {
|
|
4862
|
+
const rel = (0, path_1.relative)(rootPath, filePath);
|
|
4863
|
+
if (!rel)
|
|
4864
|
+
return true;
|
|
4865
|
+
return !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel);
|
|
4866
|
+
}
|
|
4867
|
+
function handleReadWorkspaceFile(reqUrl, res) {
|
|
4868
|
+
const rawPath = String(reqUrl.searchParams.get('path') || '').trim();
|
|
4869
|
+
if (!rawPath) {
|
|
4870
|
+
json(res, { ok: false, error: 'Missing path parameter' }, 400);
|
|
4871
|
+
return;
|
|
4872
|
+
}
|
|
4873
|
+
if (rawPath.includes('\0')) {
|
|
4874
|
+
json(res, { ok: false, error: 'Invalid path' }, 400);
|
|
4875
|
+
return;
|
|
4876
|
+
}
|
|
4877
|
+
const requestedMaxBytes = Number(reqUrl.searchParams.get('max_bytes'));
|
|
4878
|
+
const maxBytes = Number.isFinite(requestedMaxBytes)
|
|
4879
|
+
? Math.max(4096, Math.min(FILE_PREVIEW_MAX_BYTES_LIMIT, Math.floor(requestedMaxBytes)))
|
|
4880
|
+
: FILE_PREVIEW_DEFAULT_MAX_BYTES;
|
|
4881
|
+
const resolved = rawPath.replace(/^~/, (0, os_1.homedir)());
|
|
4882
|
+
let realFilePath;
|
|
4883
|
+
try {
|
|
4884
|
+
realFilePath = (0, fs_1.realpathSync)(resolved);
|
|
4885
|
+
}
|
|
4886
|
+
catch {
|
|
4887
|
+
json(res, { ok: false, error: 'File not found' }, 404);
|
|
4888
|
+
return;
|
|
4889
|
+
}
|
|
4890
|
+
let fileStat;
|
|
4891
|
+
try {
|
|
4892
|
+
fileStat = (0, fs_1.statSync)(realFilePath);
|
|
4893
|
+
}
|
|
4894
|
+
catch {
|
|
4895
|
+
json(res, { ok: false, error: 'File not found' }, 404);
|
|
4896
|
+
return;
|
|
4897
|
+
}
|
|
4898
|
+
if (!fileStat.isFile()) {
|
|
4899
|
+
json(res, { ok: false, error: 'Path is not a file' }, 400);
|
|
4900
|
+
return;
|
|
4901
|
+
}
|
|
4902
|
+
const allowedRoots = collectFilePreviewAllowedRoots();
|
|
4903
|
+
if (allowedRoots.length === 0) {
|
|
4904
|
+
json(res, { ok: false, error: 'No allowed file roots are available' }, 403);
|
|
4905
|
+
return;
|
|
4906
|
+
}
|
|
4907
|
+
if (!allowedRoots.some((rootPath) => isPathWithinAllowedRoot(realFilePath, rootPath))) {
|
|
4908
|
+
json(res, { ok: false, error: 'Path is outside allowed file roots' }, 403);
|
|
4909
|
+
return;
|
|
4910
|
+
}
|
|
4911
|
+
const readBytes = Math.min(fileStat.size, maxBytes + 1);
|
|
4912
|
+
let fd;
|
|
4913
|
+
try {
|
|
4914
|
+
fd = (0, fs_1.openSync)(realFilePath, 'r');
|
|
4915
|
+
}
|
|
4916
|
+
catch {
|
|
4917
|
+
json(res, { ok: false, error: 'Failed to open file' }, 500);
|
|
4918
|
+
return;
|
|
4919
|
+
}
|
|
4920
|
+
const buffer = Buffer.alloc(readBytes);
|
|
4921
|
+
let bytesRead = 0;
|
|
4922
|
+
try {
|
|
4923
|
+
bytesRead = (0, fs_1.readSync)(fd, buffer, 0, readBytes, 0);
|
|
4924
|
+
}
|
|
4925
|
+
catch {
|
|
4926
|
+
json(res, { ok: false, error: 'Failed to read file' }, 500);
|
|
4927
|
+
return;
|
|
4928
|
+
}
|
|
4929
|
+
finally {
|
|
4930
|
+
try {
|
|
4931
|
+
(0, fs_1.closeSync)(fd);
|
|
4932
|
+
}
|
|
4933
|
+
catch {
|
|
4934
|
+
// noop
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
const payload = buffer.subarray(0, bytesRead);
|
|
4938
|
+
const binaryScan = payload.subarray(0, Math.min(payload.length, FILE_PREVIEW_BINARY_SCAN_BYTES));
|
|
4939
|
+
if (binaryScan.includes(0)) {
|
|
4940
|
+
json(res, { ok: false, error: 'Binary file preview is not supported', code: 'binary_file' }, 415);
|
|
4941
|
+
return;
|
|
4942
|
+
}
|
|
4943
|
+
const truncated = fileStat.size > maxBytes;
|
|
4944
|
+
const contentBuffer = truncated ? payload.subarray(0, Math.min(payload.length, maxBytes)) : payload;
|
|
4945
|
+
const content = contentBuffer.toString('utf-8');
|
|
4946
|
+
json(res, {
|
|
4947
|
+
ok: true,
|
|
4948
|
+
path: realFilePath,
|
|
4949
|
+
size: fileStat.size,
|
|
4950
|
+
truncated,
|
|
4951
|
+
max_bytes: maxBytes,
|
|
4952
|
+
content,
|
|
4953
|
+
});
|
|
4954
|
+
}
|
|
3695
4955
|
// ── Dataset stats helpers ───────────────────────────────
|
|
3696
4956
|
function formatBytesUI(bytes) {
|
|
3697
4957
|
if (bytes === 0)
|
|
@@ -3938,6 +5198,78 @@ async function handlePostDatasetExampleInstall(req, res) {
|
|
|
3938
5198
|
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
3939
5199
|
}
|
|
3940
5200
|
}
|
|
5201
|
+
function readRecentAuditLogEntries(maxEntries = 50) {
|
|
5202
|
+
const config = (0, config_js_1.loadConfig)();
|
|
5203
|
+
if (!config.audit.enabled)
|
|
5204
|
+
return [];
|
|
5205
|
+
const logDir = (0, config_js_1.getLogDir)(config);
|
|
5206
|
+
if (!(0, fs_1.existsSync)(logDir))
|
|
5207
|
+
return [];
|
|
5208
|
+
const files = (0, fs_1.readdirSync)(logDir)
|
|
5209
|
+
.filter((file) => file.endsWith('.jsonl'))
|
|
5210
|
+
.sort()
|
|
5211
|
+
.reverse();
|
|
5212
|
+
const entries = [];
|
|
5213
|
+
for (const file of files) {
|
|
5214
|
+
if (entries.length >= maxEntries)
|
|
5215
|
+
break;
|
|
5216
|
+
const filePath = (0, path_1.resolve)(logDir, file);
|
|
5217
|
+
try {
|
|
5218
|
+
const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
|
|
5219
|
+
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
|
5220
|
+
for (let idx = lines.length - 1; idx >= 0; idx -= 1) {
|
|
5221
|
+
if (entries.length >= maxEntries)
|
|
5222
|
+
break;
|
|
5223
|
+
try {
|
|
5224
|
+
const parsed = JSON.parse(lines[idx]);
|
|
5225
|
+
if (parsed && typeof parsed === 'object') {
|
|
5226
|
+
entries.push(parsed);
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
catch {
|
|
5230
|
+
// Skip malformed log lines.
|
|
5231
|
+
}
|
|
5232
|
+
}
|
|
5233
|
+
}
|
|
5234
|
+
catch {
|
|
5235
|
+
// Skip unreadable files.
|
|
5236
|
+
}
|
|
5237
|
+
}
|
|
5238
|
+
return entries;
|
|
5239
|
+
}
|
|
5240
|
+
function buildFeedbackDiagnostics() {
|
|
5241
|
+
const config = (0, config_js_1.loadConfig)();
|
|
5242
|
+
let username = null;
|
|
5243
|
+
try {
|
|
5244
|
+
username = (0, os_1.userInfo)().username;
|
|
5245
|
+
}
|
|
5246
|
+
catch {
|
|
5247
|
+
username = null;
|
|
5248
|
+
}
|
|
5249
|
+
const logDir = (0, config_js_1.getLogDir)(config);
|
|
5250
|
+
return {
|
|
5251
|
+
collected_at: new Date().toISOString(),
|
|
5252
|
+
app: {
|
|
5253
|
+
name: 'labgate-ui',
|
|
5254
|
+
version: readPackageVersion(),
|
|
5255
|
+
},
|
|
5256
|
+
host: {
|
|
5257
|
+
platform: (0, os_1.platform)(),
|
|
5258
|
+
hostname: (0, os_1.hostname)(),
|
|
5259
|
+
node_version: process.version,
|
|
5260
|
+
user: username,
|
|
5261
|
+
},
|
|
5262
|
+
config: {
|
|
5263
|
+
runtime: config.runtime,
|
|
5264
|
+
network_mode: config.network?.mode ?? null,
|
|
5265
|
+
slurm_enabled: !!config.slurm?.enabled,
|
|
5266
|
+
audit_enabled: !!config.audit?.enabled,
|
|
5267
|
+
log_dir: logDir,
|
|
5268
|
+
},
|
|
5269
|
+
slurm_runtime: getSlurmRuntimeStatus(),
|
|
5270
|
+
audit_logs: readRecentAuditLogEntries(80),
|
|
5271
|
+
};
|
|
5272
|
+
}
|
|
3941
5273
|
function handleGetLogs(_req, res) {
|
|
3942
5274
|
const config = (0, config_js_1.loadConfig)();
|
|
3943
5275
|
if (!config.audit.enabled) {
|
|
@@ -3949,36 +5281,51 @@ function handleGetLogs(_req, res) {
|
|
|
3949
5281
|
json(res, { entries: [], message: 'No audit logs found yet' });
|
|
3950
5282
|
return;
|
|
3951
5283
|
}
|
|
3952
|
-
const
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
.reverse();
|
|
3956
|
-
if (files.length === 0) {
|
|
3957
|
-
json(res, { entries: [], message: 'No audit logs found yet' });
|
|
5284
|
+
const entries = readRecentAuditLogEntries(50);
|
|
5285
|
+
if (entries.length === 0) {
|
|
5286
|
+
json(res, { entries: [], message: 'Log file is empty' });
|
|
3958
5287
|
return;
|
|
3959
5288
|
}
|
|
3960
|
-
|
|
5289
|
+
json(res, { entries });
|
|
5290
|
+
}
|
|
5291
|
+
async function handlePostFeedback(req, res) {
|
|
3961
5292
|
try {
|
|
3962
|
-
const
|
|
3963
|
-
|
|
3964
|
-
|
|
5293
|
+
const rawBody = await readBody(req);
|
|
5294
|
+
let payload = {};
|
|
5295
|
+
if (rawBody.trim().length > 0) {
|
|
5296
|
+
try {
|
|
5297
|
+
payload = JSON.parse(rawBody);
|
|
5298
|
+
}
|
|
5299
|
+
catch {
|
|
5300
|
+
json(res, { ok: false, error: 'Invalid JSON body' }, 400);
|
|
5301
|
+
return;
|
|
5302
|
+
}
|
|
5303
|
+
}
|
|
5304
|
+
const message = typeof payload.message === 'string' ? payload.message.trim() : '';
|
|
5305
|
+
if (!message) {
|
|
5306
|
+
json(res, { ok: false, error: 'Feedback message is required' }, 400);
|
|
3965
5307
|
return;
|
|
3966
5308
|
}
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
.map(line => { try {
|
|
3971
|
-
return JSON.parse(line);
|
|
5309
|
+
if (message.length > 8000) {
|
|
5310
|
+
json(res, { ok: false, error: 'Feedback message is too long (max 8000 chars)' }, 400);
|
|
5311
|
+
return;
|
|
3972
5312
|
}
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
5313
|
+
const attachLogs = payload.attach_logs === true || payload.attachLogs === true;
|
|
5314
|
+
const metadata = {
|
|
5315
|
+
attach_logs: attachLogs,
|
|
5316
|
+
};
|
|
5317
|
+
const userAgent = req.headers['user-agent'];
|
|
5318
|
+
if (typeof userAgent === 'string' && userAgent.trim().length > 0) {
|
|
5319
|
+
metadata.user_agent = userAgent.trim().slice(0, 512);
|
|
5320
|
+
}
|
|
5321
|
+
if (attachLogs) {
|
|
5322
|
+
metadata.diagnostics = buildFeedbackDiagnostics();
|
|
5323
|
+
}
|
|
5324
|
+
const result = await (0, feedback_js_1.submitFeedback)(message, { source: 'ui', metadata });
|
|
5325
|
+
json(res, { ok: true, mode: result.mode, detail: result.detail });
|
|
3979
5326
|
}
|
|
3980
5327
|
catch (err) {
|
|
3981
|
-
json(res, {
|
|
5328
|
+
json(res, { ok: false, error: err?.message ?? String(err) }, 500);
|
|
3982
5329
|
}
|
|
3983
5330
|
}
|
|
3984
5331
|
let containerStatsCache = new Map();
|
|
@@ -4083,8 +5430,9 @@ function normalizeMcpEntryForHost(entry, sandboxHome) {
|
|
|
4083
5430
|
function collectMcpState() {
|
|
4084
5431
|
const config = (0, config_js_1.loadConfig)();
|
|
4085
5432
|
const { sandboxHome, mcpConfigPath, mcpConfigExists, registeredServers } = readMcpConfigData();
|
|
4086
|
-
const
|
|
4087
|
-
const
|
|
5433
|
+
const slurmPluginEnabled = isPluginEnabledInConfig(config, 'slurm');
|
|
5434
|
+
const slurmConfigured = slurmPluginEnabled && config.slurm.enabled && config.slurm.mcp_server;
|
|
5435
|
+
const clusterConfigured = slurmPluginEnabled && config.slurm.enabled && config.slurm.mcp_server;
|
|
4088
5436
|
const datasetsConfigured = true;
|
|
4089
5437
|
const resultsConfigured = true;
|
|
4090
5438
|
const slurmEntry = registeredServers['labgate-slurm'];
|
|
@@ -4186,7 +5534,13 @@ function collectMcpState() {
|
|
|
4186
5534
|
tools: [
|
|
4187
5535
|
{ name: 'list_results', title: 'List Results', description: 'List recorded results with filtering and pagination' },
|
|
4188
5536
|
{ name: 'register_result', title: 'Register Result', description: 'Create a new structured result entry' },
|
|
5537
|
+
{
|
|
5538
|
+
name: 'register_reproducible_result',
|
|
5539
|
+
title: 'Register Reproducible Result',
|
|
5540
|
+
description: 'Register a result with script, inputs, requirements, and optional execution/submission',
|
|
5541
|
+
},
|
|
4189
5542
|
{ name: 'get_result', title: 'Get Result', description: 'Retrieve one result by id' },
|
|
5543
|
+
{ name: 'list_result_versions', title: 'List Result Versions', description: 'List all versions for a result lineage' },
|
|
4190
5544
|
{ name: 'update_result', title: 'Update Result', description: 'Update an existing result entry' },
|
|
4191
5545
|
{ name: 'delete_result', title: 'Delete Result', description: 'Delete a result entry' },
|
|
4192
5546
|
],
|
|
@@ -4380,18 +5734,40 @@ function parseResultPayload(raw, mode) {
|
|
|
4380
5734
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
4381
5735
|
return { ok: false, error: 'Body must be a JSON object.' };
|
|
4382
5736
|
}
|
|
4383
|
-
if (mode === 'create' &&
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
5737
|
+
if (mode === 'create' &&
|
|
5738
|
+
typeof raw.title !== 'string' &&
|
|
5739
|
+
typeof raw.version_of !== 'string') {
|
|
5740
|
+
return { ok: false, error: 'title is required and must be a string (or provide version_of).' };
|
|
5741
|
+
}
|
|
5742
|
+
if (mode === 'update' && raw.version_of !== undefined) {
|
|
5743
|
+
return { ok: false, error: 'version_of is only supported when creating a new result.' };
|
|
5744
|
+
}
|
|
5745
|
+
const optionalStringFields = [
|
|
5746
|
+
'id',
|
|
5747
|
+
'title',
|
|
5748
|
+
'summary',
|
|
5749
|
+
'source',
|
|
5750
|
+
'session_id',
|
|
5751
|
+
'workdir',
|
|
5752
|
+
'lineage_id',
|
|
5753
|
+
'previous_result_id',
|
|
5754
|
+
'version_of',
|
|
5755
|
+
];
|
|
4387
5756
|
for (const field of optionalStringFields) {
|
|
4388
5757
|
if (raw[field] !== undefined && raw[field] !== null && typeof raw[field] !== 'string') {
|
|
4389
5758
|
return { ok: false, error: `${field} must be a string or null.` };
|
|
4390
5759
|
}
|
|
4391
5760
|
}
|
|
5761
|
+
if (raw.starred !== undefined && typeof raw.starred !== 'boolean') {
|
|
5762
|
+
return { ok: false, error: 'starred must be a boolean.' };
|
|
5763
|
+
}
|
|
4392
5764
|
if (raw.details !== undefined && raw.details !== null && typeof raw.details !== 'string') {
|
|
4393
5765
|
return { ok: false, error: 'details must be a string or null.' };
|
|
4394
5766
|
}
|
|
5767
|
+
if (raw.version !== undefined &&
|
|
5768
|
+
(!Number.isInteger(raw.version) || raw.version < 1)) {
|
|
5769
|
+
return { ok: false, error: 'version must be an integer >= 1.' };
|
|
5770
|
+
}
|
|
4395
5771
|
if (raw.tags !== undefined &&
|
|
4396
5772
|
(!Array.isArray(raw.tags) || raw.tags.some((v) => typeof v !== 'string'))) {
|
|
4397
5773
|
return { ok: false, error: 'tags must be an array of strings.' };
|
|
@@ -4405,33 +5781,111 @@ function parseResultPayload(raw, mode) {
|
|
|
4405
5781
|
(typeof raw.metadata !== 'object' || Array.isArray(raw.metadata))) {
|
|
4406
5782
|
return { ok: false, error: 'metadata must be an object or null.' };
|
|
4407
5783
|
}
|
|
5784
|
+
if (raw.reproducibility !== undefined &&
|
|
5785
|
+
raw.reproducibility !== null &&
|
|
5786
|
+
(typeof raw.reproducibility !== 'object' || Array.isArray(raw.reproducibility))) {
|
|
5787
|
+
return { ok: false, error: 'reproducibility must be an object or null.' };
|
|
5788
|
+
}
|
|
5789
|
+
if (raw.reproducibility && typeof raw.reproducibility === 'object') {
|
|
5790
|
+
const repro = raw.reproducibility;
|
|
5791
|
+
const reproStringFields = [
|
|
5792
|
+
'script_path',
|
|
5793
|
+
'script_kind',
|
|
5794
|
+
'script_sha256',
|
|
5795
|
+
'runtime',
|
|
5796
|
+
'strategy',
|
|
5797
|
+
'precomputed_reason',
|
|
5798
|
+
];
|
|
5799
|
+
for (const field of reproStringFields) {
|
|
5800
|
+
if (repro[field] !== undefined && repro[field] !== null && typeof repro[field] !== 'string') {
|
|
5801
|
+
return { ok: false, error: `reproducibility.${field} must be a string or null.` };
|
|
5802
|
+
}
|
|
5803
|
+
}
|
|
5804
|
+
if (repro.input_files !== undefined &&
|
|
5805
|
+
(!Array.isArray(repro.input_files) || repro.input_files.some((v) => typeof v !== 'string'))) {
|
|
5806
|
+
return { ok: false, error: 'reproducibility.input_files must be an array of strings.' };
|
|
5807
|
+
}
|
|
5808
|
+
if (repro.requirements !== undefined &&
|
|
5809
|
+
(!Array.isArray(repro.requirements) || repro.requirements.some((v) => typeof v !== 'string'))) {
|
|
5810
|
+
return { ok: false, error: 'reproducibility.requirements must be an array of strings.' };
|
|
5811
|
+
}
|
|
5812
|
+
if (repro.execution !== undefined &&
|
|
5813
|
+
repro.execution !== null &&
|
|
5814
|
+
(typeof repro.execution !== 'object' || Array.isArray(repro.execution))) {
|
|
5815
|
+
return { ok: false, error: 'reproducibility.execution must be an object or null.' };
|
|
5816
|
+
}
|
|
5817
|
+
if (repro.execution && typeof repro.execution === 'object') {
|
|
5818
|
+
const execution = repro.execution;
|
|
5819
|
+
const execStringFields = ['status', 'command', 'ran_at', 'signal', 'stdout_tail', 'stderr_tail', 'slurm_job_id'];
|
|
5820
|
+
for (const field of execStringFields) {
|
|
5821
|
+
if (execution[field] !== undefined && execution[field] !== null && typeof execution[field] !== 'string') {
|
|
5822
|
+
return { ok: false, error: `reproducibility.execution.${field} must be a string or null.` };
|
|
5823
|
+
}
|
|
5824
|
+
}
|
|
5825
|
+
if (execution.duration_ms !== undefined &&
|
|
5826
|
+
execution.duration_ms !== null &&
|
|
5827
|
+
(!Number.isInteger(execution.duration_ms) || execution.duration_ms < 0)) {
|
|
5828
|
+
return { ok: false, error: 'reproducibility.execution.duration_ms must be an integer >= 0.' };
|
|
5829
|
+
}
|
|
5830
|
+
if (execution.exit_code !== undefined &&
|
|
5831
|
+
execution.exit_code !== null &&
|
|
5832
|
+
!Number.isInteger(execution.exit_code)) {
|
|
5833
|
+
return { ok: false, error: 'reproducibility.execution.exit_code must be an integer or null.' };
|
|
5834
|
+
}
|
|
5835
|
+
}
|
|
5836
|
+
}
|
|
4408
5837
|
const payload = {
|
|
5838
|
+
id: raw.id,
|
|
4409
5839
|
title: raw.title,
|
|
4410
5840
|
summary: raw.summary,
|
|
4411
5841
|
details: raw.details,
|
|
4412
5842
|
source: raw.source,
|
|
5843
|
+
starred: raw.starred,
|
|
4413
5844
|
session_id: raw.session_id,
|
|
4414
5845
|
workdir: raw.workdir,
|
|
5846
|
+
lineage_id: raw.lineage_id,
|
|
5847
|
+
version: raw.version,
|
|
5848
|
+
previous_result_id: raw.previous_result_id,
|
|
5849
|
+
version_of: raw.version_of,
|
|
4415
5850
|
tags: raw.tags,
|
|
4416
5851
|
artifacts: raw.artifacts,
|
|
4417
5852
|
metadata: raw.metadata,
|
|
5853
|
+
reproducibility: raw.reproducibility,
|
|
4418
5854
|
};
|
|
4419
5855
|
if (mode === 'update') {
|
|
4420
5856
|
const hasPatch = payload.title !== undefined ||
|
|
4421
5857
|
payload.summary !== undefined ||
|
|
4422
5858
|
payload.details !== undefined ||
|
|
4423
5859
|
payload.source !== undefined ||
|
|
5860
|
+
payload.starred !== undefined ||
|
|
4424
5861
|
payload.session_id !== undefined ||
|
|
4425
5862
|
payload.workdir !== undefined ||
|
|
5863
|
+
payload.lineage_id !== undefined ||
|
|
5864
|
+
payload.version !== undefined ||
|
|
5865
|
+
payload.previous_result_id !== undefined ||
|
|
5866
|
+
payload.version_of !== undefined ||
|
|
4426
5867
|
payload.tags !== undefined ||
|
|
4427
5868
|
payload.artifacts !== undefined ||
|
|
4428
|
-
payload.metadata !== undefined
|
|
5869
|
+
payload.metadata !== undefined ||
|
|
5870
|
+
payload.reproducibility !== undefined;
|
|
4429
5871
|
if (!hasPatch) {
|
|
4430
5872
|
return { ok: false, error: 'No update fields provided.' };
|
|
4431
5873
|
}
|
|
4432
5874
|
}
|
|
4433
5875
|
return { ok: true, payload };
|
|
4434
5876
|
}
|
|
5877
|
+
function parseOptionalBooleanQuery(value) {
|
|
5878
|
+
if (value === null)
|
|
5879
|
+
return undefined;
|
|
5880
|
+
const normalized = String(value).trim().toLowerCase();
|
|
5881
|
+
if (!normalized)
|
|
5882
|
+
return undefined;
|
|
5883
|
+
if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on')
|
|
5884
|
+
return true;
|
|
5885
|
+
if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off')
|
|
5886
|
+
return false;
|
|
5887
|
+
return undefined;
|
|
5888
|
+
}
|
|
4435
5889
|
function handleGetResults(reqUrl, res) {
|
|
4436
5890
|
const parsedLimit = parseInt(reqUrl.searchParams.get('limit') || '100', 10);
|
|
4437
5891
|
const parsedOffset = parseInt(reqUrl.searchParams.get('offset') || '0', 10);
|
|
@@ -4442,6 +5896,8 @@ function handleGetResults(reqUrl, res) {
|
|
|
4442
5896
|
search: reqUrl.searchParams.get('search') || undefined,
|
|
4443
5897
|
source: reqUrl.searchParams.get('source') || undefined,
|
|
4444
5898
|
tag: reqUrl.searchParams.get('tag') || undefined,
|
|
5899
|
+
lineage: reqUrl.searchParams.get('lineage') || undefined,
|
|
5900
|
+
starred: parseOptionalBooleanQuery(reqUrl.searchParams.get('starred')),
|
|
4445
5901
|
limit,
|
|
4446
5902
|
offset,
|
|
4447
5903
|
});
|
|
@@ -4988,6 +6444,7 @@ function startSSEBroadcast() {
|
|
|
4988
6444
|
catch { /* skip */ }
|
|
4989
6445
|
}
|
|
4990
6446
|
}
|
|
6447
|
+
await annotateSessionsWithGitState(sessions);
|
|
4991
6448
|
// Collect container resource stats
|
|
4992
6449
|
const sessionIds = sessions.map((s) => s.id).filter(Boolean);
|
|
4993
6450
|
try {
|
|
@@ -6004,6 +7461,10 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
6004
7461
|
if (!current || !current.pty)
|
|
6005
7462
|
return;
|
|
6006
7463
|
if (parsed?.type === 'input' && typeof parsed.data === 'string') {
|
|
7464
|
+
// Cancel pending automation when human types
|
|
7465
|
+
const autoEngine = automationEngines.get(id);
|
|
7466
|
+
if (autoEngine)
|
|
7467
|
+
autoEngine.cancelPending();
|
|
6007
7468
|
try {
|
|
6008
7469
|
current.pty.write(parsed.data);
|
|
6009
7470
|
}
|
|
@@ -6157,6 +7618,20 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
6157
7618
|
!requireWriteToken(req, res)) {
|
|
6158
7619
|
return;
|
|
6159
7620
|
}
|
|
7621
|
+
if (pathname.startsWith('/api/explorer/')) {
|
|
7622
|
+
const config = (0, config_js_1.loadConfig)();
|
|
7623
|
+
if (!isPluginEnabledInConfig(config, 'explorer')) {
|
|
7624
|
+
json(res, { ok: false, error: 'Explorer plugin is disabled.' }, 403);
|
|
7625
|
+
return;
|
|
7626
|
+
}
|
|
7627
|
+
}
|
|
7628
|
+
if (pathname.startsWith('/api/automation/')) {
|
|
7629
|
+
const config = (0, config_js_1.loadConfig)();
|
|
7630
|
+
if (!isPluginEnabledInConfig(config, 'automation')) {
|
|
7631
|
+
json(res, { ok: false, error: 'Automation plugin is disabled.' }, 403);
|
|
7632
|
+
return;
|
|
7633
|
+
}
|
|
7634
|
+
}
|
|
6160
7635
|
if (pathname === '/' && method === 'GET') {
|
|
6161
7636
|
serveHTML(res);
|
|
6162
7637
|
}
|
|
@@ -6169,6 +7644,43 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
6169
7644
|
else if (pathname === '/api/config/path' && method === 'GET') {
|
|
6170
7645
|
handleGetConfigPath(req, res);
|
|
6171
7646
|
}
|
|
7647
|
+
else if (pathname === '/api/plugins' && method === 'GET') {
|
|
7648
|
+
handleGetPlugins(req, res);
|
|
7649
|
+
}
|
|
7650
|
+
else if (pathname === '/api/plugins' && method === 'POST') {
|
|
7651
|
+
await handlePostPlugins(req, res);
|
|
7652
|
+
}
|
|
7653
|
+
else if (/^\/api\/automation\/status\/[^/]+$/.test(pathname) && method === 'GET') {
|
|
7654
|
+
const terminalId = pathname.split('/').pop();
|
|
7655
|
+
handleGetAutomationStatus(terminalId, res);
|
|
7656
|
+
}
|
|
7657
|
+
else if (pathname === '/api/automation/enable' && method === 'POST') {
|
|
7658
|
+
await handlePostAutomationEnable(req, res);
|
|
7659
|
+
}
|
|
7660
|
+
else if (pathname === '/api/automation/disable' && method === 'POST') {
|
|
7661
|
+
await handlePostAutomationDisable(req, res);
|
|
7662
|
+
}
|
|
7663
|
+
else if (pathname === '/api/automation/pause' && method === 'POST') {
|
|
7664
|
+
await handlePostAutomationPause(req, res);
|
|
7665
|
+
}
|
|
7666
|
+
else if (pathname === '/api/automation/resume' && method === 'POST') {
|
|
7667
|
+
await handlePostAutomationResume(req, res);
|
|
7668
|
+
}
|
|
7669
|
+
else if (pathname === '/api/automation/config' && method === 'POST') {
|
|
7670
|
+
await handlePostAutomationConfig(req, res);
|
|
7671
|
+
}
|
|
7672
|
+
else if (/^\/api\/automation\/log\/[^/]+$/.test(pathname) && method === 'GET') {
|
|
7673
|
+
const terminalId = pathname.split('/').pop();
|
|
7674
|
+
handleGetAutomationLog(terminalId, res);
|
|
7675
|
+
}
|
|
7676
|
+
else if (/^\/api\/automation\/turns\/[^/]+$/.test(pathname) && method === 'GET') {
|
|
7677
|
+
const terminalId = pathname.split('/').pop();
|
|
7678
|
+
handleGetAutomationTurns(decodeURIComponent(terminalId), res);
|
|
7679
|
+
}
|
|
7680
|
+
else if (/^\/api\/automation\/terminal-status\/[^/]+$/.test(pathname) && method === 'GET') {
|
|
7681
|
+
const terminalId = pathname.split('/').pop();
|
|
7682
|
+
handleGetAutomationTerminalStatus(terminalId, res);
|
|
7683
|
+
}
|
|
6172
7684
|
else if (pathname === '/api/ui/version' && method === 'GET') {
|
|
6173
7685
|
await handleGetUiVersion(reqUrl, res);
|
|
6174
7686
|
}
|
|
@@ -6187,12 +7699,27 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
6187
7699
|
else if (pathname === '/api/sessions/restart' && method === 'POST') {
|
|
6188
7700
|
await handleRestartSession(req, res);
|
|
6189
7701
|
}
|
|
7702
|
+
else if (/^\/api\/sessions\/[^/]+\/git\/branches$/.test(pathname) && method === 'GET') {
|
|
7703
|
+
await handleGetSessionGitBranches(pathname, res);
|
|
7704
|
+
}
|
|
7705
|
+
else if (/^\/api\/sessions\/[^/]+\/git\/dag$/.test(pathname) && method === 'GET') {
|
|
7706
|
+
await handleGetSessionGitDag(pathname, reqUrl, res);
|
|
7707
|
+
}
|
|
7708
|
+
else if (/^\/api\/sessions\/[^/]+\/git\/checkout$/.test(pathname) && method === 'POST') {
|
|
7709
|
+
await handlePostSessionGitCheckout(pathname, req, res);
|
|
7710
|
+
}
|
|
7711
|
+
else if (/^\/api\/sessions\/[^/]+\/git\/branches$/.test(pathname) && method === 'POST') {
|
|
7712
|
+
await handlePostSessionGitBranchCreate(pathname, req, res);
|
|
7713
|
+
}
|
|
6190
7714
|
else if (pathname === '/api/validate-path' && method === 'POST') {
|
|
6191
7715
|
await handleValidatePath(req, res);
|
|
6192
7716
|
}
|
|
6193
7717
|
else if (pathname === '/api/browse-dir' && method === 'POST') {
|
|
6194
7718
|
await handleBrowseDir(req, res);
|
|
6195
7719
|
}
|
|
7720
|
+
else if (pathname === '/api/files/read' && method === 'GET') {
|
|
7721
|
+
handleReadWorkspaceFile(reqUrl, res);
|
|
7722
|
+
}
|
|
6196
7723
|
else if (pathname === '/api/dataset-stats' && method === 'GET') {
|
|
6197
7724
|
handleGetDatasetStats(req, res);
|
|
6198
7725
|
}
|
|
@@ -6211,6 +7738,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
|
|
|
6211
7738
|
else if (pathname === '/api/logs' && method === 'GET') {
|
|
6212
7739
|
handleGetLogs(req, res);
|
|
6213
7740
|
}
|
|
7741
|
+
else if (pathname === '/api/feedback' && method === 'POST') {
|
|
7742
|
+
await handlePostFeedback(req, res);
|
|
7743
|
+
}
|
|
6214
7744
|
else if (pathname === '/api/security' && method === 'GET') {
|
|
6215
7745
|
handleGetSecurity(req, res);
|
|
6216
7746
|
}
|