@shapeshift-labs/frontier-loom-ui 0.1.0
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 +28 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +105 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +3827 -0
- package/dist/client.js.map +1 -0
- package/dist/frontier.d.ts +1 -0
- package/dist/frontier.js +37 -0
- package/dist/frontier.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/public/index.html +16 -0
- package/dist/public/styles.css +3278 -0
- package/dist/server.d.ts +113 -0
- package/dist/server.js +1731 -0
- package/dist/server.js.map +1 -0
- package/features/loom-ui-dashboard.json +28 -0
- package/frontier.config.mjs +39 -0
- package/package.json +67 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1731 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { readCodexDashboardSnapshot } from '@shapeshift-labs/frontier-swarm-codex';
|
|
8
|
+
const packageDir = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const HEALTH_JSON_PARSE_MAX_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const TASK_DETAIL_PATCH_MAX_BYTES = 512 * 1024;
|
|
11
|
+
const TASK_DETAIL_FILE_DIFF_MAX_CHARS = 24_000;
|
|
12
|
+
const ARTIFACT_VIEW_MAX_BYTES = 768 * 1024;
|
|
13
|
+
const ARTIFACT_DIRECTORY_MAX_ENTRIES = 200;
|
|
14
|
+
const HUMAN_ACTION_ANSWER_MAX_BYTES = 16 * 1024;
|
|
15
|
+
const CODEX_EVENTS_USAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
const LIFETIME_DASHBOARD_MAX_SOURCES = 80;
|
|
17
|
+
const LIFETIME_DASHBOARD_MAX_JOBS = 800;
|
|
18
|
+
const LIFETIME_DASHBOARD_SCAN_MAX_FILES = 600;
|
|
19
|
+
const LIFETIME_DASHBOARD_SCAN_MAX_DEPTH = 5;
|
|
20
|
+
const LIFETIME_DASHBOARD_RESET_FILE = '.loom-ui-reset.json';
|
|
21
|
+
const REVIEW_DECISIONS_FILE = '.loom-ui-review-decisions.json';
|
|
22
|
+
const dashboardStreamListeners = new Set();
|
|
23
|
+
export function createLoomUiServer(options = {}) {
|
|
24
|
+
const normalized = normalizeServerOptions(options);
|
|
25
|
+
const server = http.createServer(async (request, response) => {
|
|
26
|
+
try {
|
|
27
|
+
await handleRequest(request, response, normalized);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
writeJson(response, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
server,
|
|
35
|
+
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export async function startLoomUiServer(options = {}) {
|
|
39
|
+
const normalized = normalizeServerOptions(options);
|
|
40
|
+
const created = createLoomUiServer(normalized);
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
const onError = (error) => {
|
|
43
|
+
created.server.off('listening', onListening);
|
|
44
|
+
reject(error);
|
|
45
|
+
};
|
|
46
|
+
const onListening = () => {
|
|
47
|
+
created.server.off('error', onError);
|
|
48
|
+
resolve();
|
|
49
|
+
};
|
|
50
|
+
created.server.once('error', onError);
|
|
51
|
+
created.server.once('listening', onListening);
|
|
52
|
+
created.server.listen(normalized.port, normalized.host);
|
|
53
|
+
});
|
|
54
|
+
const address = created.server.address();
|
|
55
|
+
const actualPort = typeof address === 'object' && address ? address.port : normalized.port;
|
|
56
|
+
return { ...created, url: `http://${formatUrlHost(normalized.host)}:${actualPort}/` };
|
|
57
|
+
}
|
|
58
|
+
async function handleRequest(request, response, options) {
|
|
59
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
60
|
+
if (request.method === 'GET' && url.pathname === '/api/health') {
|
|
61
|
+
writeJson(response, 200, await readHealth(options));
|
|
62
|
+
}
|
|
63
|
+
else if (request.method === 'GET' && url.pathname === '/api/dashboard/stream') {
|
|
64
|
+
await streamDashboard(request, response, options);
|
|
65
|
+
}
|
|
66
|
+
else if (request.method === 'GET' && url.pathname === '/api/dashboard') {
|
|
67
|
+
writeJson(response, 200, await readDashboardSnapshot(options));
|
|
68
|
+
}
|
|
69
|
+
else if (request.method === 'GET' && url.pathname === '/api/task-details') {
|
|
70
|
+
writeJson(response, 200, await readTaskDetails(options, textValue(url.searchParams.get('id'), '')));
|
|
71
|
+
}
|
|
72
|
+
else if (request.method === 'GET' && url.pathname === '/api/artifact') {
|
|
73
|
+
writeJson(response, 200, await readArtifact(options, textValue(url.searchParams.get('path'), '')));
|
|
74
|
+
}
|
|
75
|
+
else if (request.method === 'GET' && url.pathname === '/api/artifact/raw') {
|
|
76
|
+
await serveArtifactRaw(response, options, textValue(url.searchParams.get('path'), ''));
|
|
77
|
+
}
|
|
78
|
+
else if (request.method === 'POST' && url.pathname === '/api/artifact/reveal') {
|
|
79
|
+
const body = recordValue(await readJsonBody(request, 8 * 1024));
|
|
80
|
+
writeJson(response, 200, await revealArtifactInFileManager(options, textValue(body.path, ''), Boolean(body.dryRun)));
|
|
81
|
+
}
|
|
82
|
+
else if (request.method === 'POST' && url.pathname === '/api/human-actions/answer') {
|
|
83
|
+
const body = recordValue(await readJsonBody(request, HUMAN_ACTION_ANSWER_MAX_BYTES));
|
|
84
|
+
writeJson(response, 200, await writeHumanActionAnswer(options, body));
|
|
85
|
+
}
|
|
86
|
+
else if (request.method === 'GET' && url.pathname === '/client.js') {
|
|
87
|
+
await serveFile(response, path.join(packageDir, 'client.js'), 'application/javascript; charset=utf-8');
|
|
88
|
+
}
|
|
89
|
+
else if (request.method === 'GET' && url.pathname === '/vendor/frontier-dom/jsx-runtime.js') {
|
|
90
|
+
await serveFile(response, resolveFrontierDomRuntime(), 'application/javascript; charset=utf-8');
|
|
91
|
+
}
|
|
92
|
+
else if (request.method === 'GET') {
|
|
93
|
+
const file = staticFile(options.staticDir, url.pathname);
|
|
94
|
+
await serveFile(response, file, contentType(file));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
writeJson(response, 405, { ok: false, error: 'method not allowed' });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function streamDashboard(request, response, options) {
|
|
101
|
+
response.writeHead(200, {
|
|
102
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
103
|
+
'cache-control': 'no-store, max-age=0',
|
|
104
|
+
connection: 'keep-alive',
|
|
105
|
+
pragma: 'no-cache',
|
|
106
|
+
'x-accel-buffering': 'no'
|
|
107
|
+
});
|
|
108
|
+
response.write(': connected\n\n');
|
|
109
|
+
let closed = false;
|
|
110
|
+
let pending = false;
|
|
111
|
+
let lastSignature = '';
|
|
112
|
+
const send = async () => {
|
|
113
|
+
if (closed || pending)
|
|
114
|
+
return;
|
|
115
|
+
pending = true;
|
|
116
|
+
try {
|
|
117
|
+
const snapshot = await readDashboardSnapshot(options);
|
|
118
|
+
const signature = dashboardStreamSignature(snapshot);
|
|
119
|
+
const body = JSON.stringify(snapshot);
|
|
120
|
+
if (signature !== lastSignature) {
|
|
121
|
+
lastSignature = signature;
|
|
122
|
+
response.write(`data: ${body}\n\n`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
response.write(`event: error\ndata: ${JSON.stringify({ ok: false, error: errorMessage(error) })}\n\n`);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
pending = false;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const trigger = debounce(send, 120);
|
|
133
|
+
const directTrigger = () => {
|
|
134
|
+
void send();
|
|
135
|
+
};
|
|
136
|
+
const watchers = await createDashboardWatchers(options, trigger);
|
|
137
|
+
dashboardStreamListeners.add(directTrigger);
|
|
138
|
+
const tick = setInterval(trigger, 2000);
|
|
139
|
+
const heartbeat = setInterval(() => {
|
|
140
|
+
if (!closed)
|
|
141
|
+
response.write(': heartbeat\n\n');
|
|
142
|
+
}, 15000);
|
|
143
|
+
const close = () => {
|
|
144
|
+
if (closed)
|
|
145
|
+
return;
|
|
146
|
+
closed = true;
|
|
147
|
+
clearInterval(tick);
|
|
148
|
+
clearInterval(heartbeat);
|
|
149
|
+
dashboardStreamListeners.delete(directTrigger);
|
|
150
|
+
for (const watcher of watchers)
|
|
151
|
+
watcher.close();
|
|
152
|
+
response.end();
|
|
153
|
+
};
|
|
154
|
+
request.on('close', close);
|
|
155
|
+
response.on('close', close);
|
|
156
|
+
await send();
|
|
157
|
+
}
|
|
158
|
+
function dashboardStreamSignature(snapshot) {
|
|
159
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
|
|
160
|
+
return JSON.stringify(snapshot);
|
|
161
|
+
const { generatedAt: _generatedAt, ...stableSnapshot } = snapshot;
|
|
162
|
+
return JSON.stringify(stableSnapshot);
|
|
163
|
+
}
|
|
164
|
+
function debounce(fn, delayMs) {
|
|
165
|
+
let timer;
|
|
166
|
+
return () => {
|
|
167
|
+
if (timer)
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
timer = setTimeout(() => {
|
|
170
|
+
timer = undefined;
|
|
171
|
+
void fn();
|
|
172
|
+
}, delayMs);
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async function createDashboardWatchers(options, onChange) {
|
|
176
|
+
const roots = await dashboardWatchRoots(options);
|
|
177
|
+
const watchers = [];
|
|
178
|
+
for (const root of roots) {
|
|
179
|
+
const recursive = watchDirectory(root, true, onChange);
|
|
180
|
+
if (recursive) {
|
|
181
|
+
watchers.push(recursive);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const shallow = watchDirectory(root, false, onChange);
|
|
185
|
+
if (shallow)
|
|
186
|
+
watchers.push(shallow);
|
|
187
|
+
}
|
|
188
|
+
return watchers;
|
|
189
|
+
}
|
|
190
|
+
function watchDirectory(root, recursive, onChange) {
|
|
191
|
+
try {
|
|
192
|
+
return watch(root, { recursive }, onChange);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function dashboardWatchRoots(options) {
|
|
199
|
+
const inputs = [options.run, options.collection, options.continuation].filter((value) => Boolean(value));
|
|
200
|
+
const roots = [];
|
|
201
|
+
for (const input of inputs) {
|
|
202
|
+
const absolute = path.resolve(options.cwd, input);
|
|
203
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
204
|
+
if (!stat)
|
|
205
|
+
continue;
|
|
206
|
+
roots.push(stat.isDirectory() ? absolute : path.dirname(absolute));
|
|
207
|
+
}
|
|
208
|
+
if (!inputs.length) {
|
|
209
|
+
const agentRuns = path.join(options.cwd, 'agent-runs');
|
|
210
|
+
if (await fileExists(agentRuns))
|
|
211
|
+
roots.push(agentRuns);
|
|
212
|
+
}
|
|
213
|
+
return uniquePaths(roots);
|
|
214
|
+
}
|
|
215
|
+
function uniquePaths(values) {
|
|
216
|
+
return Array.from(new Set(values));
|
|
217
|
+
}
|
|
218
|
+
function normalizeServerOptions(options) {
|
|
219
|
+
return {
|
|
220
|
+
...options,
|
|
221
|
+
cwd: path.resolve(options.cwd ?? process.cwd()),
|
|
222
|
+
host: options.host ?? '127.0.0.1',
|
|
223
|
+
port: normalizePort(options.port),
|
|
224
|
+
staticDir: path.resolve(options.staticDir ?? path.join(packageDir, 'public'))
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function normalizePort(port) {
|
|
228
|
+
if (port === undefined)
|
|
229
|
+
return 0;
|
|
230
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
231
|
+
throw new Error(`invalid port: ${port}`);
|
|
232
|
+
return port;
|
|
233
|
+
}
|
|
234
|
+
function formatUrlHost(host) {
|
|
235
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
236
|
+
}
|
|
237
|
+
function dashboardInput(options) {
|
|
238
|
+
return {
|
|
239
|
+
cwd: options.cwd,
|
|
240
|
+
run: options.run,
|
|
241
|
+
collection: options.collection,
|
|
242
|
+
continuation: options.continuation
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async function readDashboardSnapshot(options) {
|
|
246
|
+
if (!options.run && !options.collection && !options.continuation)
|
|
247
|
+
return readLifetimeDashboardSnapshot(options);
|
|
248
|
+
return readScopedDashboardSnapshot(options);
|
|
249
|
+
}
|
|
250
|
+
async function readScopedDashboardSnapshot(options) {
|
|
251
|
+
const snapshot = await readCodexDashboardSnapshot(dashboardInput(options));
|
|
252
|
+
const activeRunSnapshot = await readActiveRunSnapshot(options);
|
|
253
|
+
const reviewDecisions = await readCoordinatorReviewDecisions(options.cwd);
|
|
254
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
|
|
255
|
+
return activeRunSnapshot ?? snapshot;
|
|
256
|
+
const answers = await readHumanActionAnswers(options);
|
|
257
|
+
const record = snapshot;
|
|
258
|
+
const jobs = Array.isArray(record.jobs) ? record.jobs : [];
|
|
259
|
+
const activeJobs = recordArray(activeRunSnapshot?.jobs);
|
|
260
|
+
if (shouldPreferActiveRunSnapshot(jobs, activeJobs)) {
|
|
261
|
+
return {
|
|
262
|
+
...activeRunSnapshot,
|
|
263
|
+
collectionJobs: jobs,
|
|
264
|
+
humanActions: recordArray(record.humanActions),
|
|
265
|
+
humanActionAnswers: answers,
|
|
266
|
+
sources: {
|
|
267
|
+
...recordValue(record.sources),
|
|
268
|
+
...recordValue(activeRunSnapshot?.sources),
|
|
269
|
+
...(reviewDecisions.length ? { coordinatorReviewDecisions: coordinatorReviewDecisionPath(options.cwd) } : {}),
|
|
270
|
+
...(answers.length ? { humanActionAnswers: await humanActionAnswerLogPath(options) } : {})
|
|
271
|
+
},
|
|
272
|
+
raw: {
|
|
273
|
+
...recordValue(activeRunSnapshot?.raw),
|
|
274
|
+
collectionSnapshot: {
|
|
275
|
+
jobCount: jobs.length,
|
|
276
|
+
humanActionCount: recordArray(record.humanActions).length,
|
|
277
|
+
sourceCount: Object.keys(recordValue(record.sources)).length
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const mergedJobs = applyCoordinatorReviewDecisions(mergeActiveRunJobTelemetry(jobs, activeJobs), reviewDecisions);
|
|
283
|
+
return {
|
|
284
|
+
...normalizeCoordinatorFacingSnapshot(record),
|
|
285
|
+
jobs: mergedJobs,
|
|
286
|
+
humanActionAnswers: answers,
|
|
287
|
+
sources: {
|
|
288
|
+
...recordValue(record.sources),
|
|
289
|
+
...(reviewDecisions.length ? { coordinatorReviewDecisions: coordinatorReviewDecisionPath(options.cwd) } : {}),
|
|
290
|
+
...(answers.length ? { humanActionAnswers: await humanActionAnswerLogPath(options) } : {})
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
async function readLifetimeDashboardSnapshot(options) {
|
|
295
|
+
const sources = await discoverLifetimeDashboardSources(options.cwd);
|
|
296
|
+
const snapshots = [];
|
|
297
|
+
for (const source of sources.slice(0, LIFETIME_DASHBOARD_MAX_SOURCES)) {
|
|
298
|
+
try {
|
|
299
|
+
const snapshot = recordValue(await readScopedDashboardSnapshot({
|
|
300
|
+
...options,
|
|
301
|
+
run: source.run,
|
|
302
|
+
collection: source.collection,
|
|
303
|
+
continuation: source.continuation
|
|
304
|
+
}));
|
|
305
|
+
if (Object.keys(snapshot).length)
|
|
306
|
+
snapshots.push({ source, snapshot });
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return combineLifetimeDashboardSnapshots(options, sources, snapshots, await readCoordinatorReviewDecisions(options.cwd));
|
|
313
|
+
}
|
|
314
|
+
async function discoverLifetimeDashboardSources(cwd) {
|
|
315
|
+
const root = path.join(cwd, 'agent-runs');
|
|
316
|
+
const stat = await fs.stat(root).catch(() => undefined);
|
|
317
|
+
if (!stat?.isDirectory())
|
|
318
|
+
return [];
|
|
319
|
+
const resetAt = await readLifetimeDashboardResetCutoff(root);
|
|
320
|
+
const files = await findLifetimeDashboardArtifactFiles(root, {
|
|
321
|
+
maxDepth: LIFETIME_DASHBOARD_SCAN_MAX_DEPTH,
|
|
322
|
+
maxFiles: LIFETIME_DASHBOARD_SCAN_MAX_FILES,
|
|
323
|
+
resetAt
|
|
324
|
+
});
|
|
325
|
+
const byDir = new Map();
|
|
326
|
+
for (const file of files) {
|
|
327
|
+
const dir = path.dirname(file);
|
|
328
|
+
const name = path.basename(file);
|
|
329
|
+
const fileStat = await fs.stat(file).catch(() => undefined);
|
|
330
|
+
const entry = byDir.get(dir) ?? { files: new Set(), mtimeMs: 0 };
|
|
331
|
+
entry.files.add(name);
|
|
332
|
+
entry.mtimeMs = Math.max(entry.mtimeMs, fileStat?.mtimeMs ?? 0);
|
|
333
|
+
byDir.set(dir, entry);
|
|
334
|
+
}
|
|
335
|
+
const out = [];
|
|
336
|
+
for (const [dir, entry] of byDir) {
|
|
337
|
+
if (entry.mtimeMs <= resetAt)
|
|
338
|
+
continue;
|
|
339
|
+
const relative = path.relative(cwd, dir);
|
|
340
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
341
|
+
continue;
|
|
342
|
+
const hasCollection = entry.files.has('collection.json') || entry.files.has('coordinator-query.json');
|
|
343
|
+
const hasContinuation = entry.files.has('continuation.json');
|
|
344
|
+
const hasRun = entry.files.has('swarm-results.json') || entry.files.has('pids.json') || entry.files.has('coordinator-dashboard.json');
|
|
345
|
+
if (hasCollection) {
|
|
346
|
+
out.push({
|
|
347
|
+
id: `collection:${relative}`,
|
|
348
|
+
label: lifetimeSourceLabel(relative),
|
|
349
|
+
path: relative,
|
|
350
|
+
kind: 'collection',
|
|
351
|
+
mtimeMs: entry.mtimeMs,
|
|
352
|
+
collection: relative
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (hasContinuation) {
|
|
357
|
+
out.push({
|
|
358
|
+
id: `continuation:${relative}`,
|
|
359
|
+
label: lifetimeSourceLabel(relative),
|
|
360
|
+
path: relative,
|
|
361
|
+
kind: 'continuation',
|
|
362
|
+
mtimeMs: entry.mtimeMs,
|
|
363
|
+
continuation: relative
|
|
364
|
+
});
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (hasRun) {
|
|
368
|
+
out.push({
|
|
369
|
+
id: `run:${relative}`,
|
|
370
|
+
label: lifetimeSourceLabel(relative),
|
|
371
|
+
path: relative,
|
|
372
|
+
kind: 'run',
|
|
373
|
+
mtimeMs: entry.mtimeMs,
|
|
374
|
+
run: relative
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return dedupeLifetimeDashboardSources(out).sort((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path));
|
|
379
|
+
}
|
|
380
|
+
function dedupeLifetimeDashboardSources(sources) {
|
|
381
|
+
const collections = new Set(sources.filter((source) => source.kind === 'collection').map(lifetimeRunFamilyKey));
|
|
382
|
+
const runs = new Set(sources.filter((source) => source.kind === 'run').map(lifetimeRunFamilyKey));
|
|
383
|
+
return sources.filter((source) => {
|
|
384
|
+
const family = lifetimeRunFamilyKey(source);
|
|
385
|
+
if (source.kind === 'run' && collections.has(family))
|
|
386
|
+
return false;
|
|
387
|
+
if (source.kind === 'collection' && source.path.endsWith('/collected-missing') && runs.has(family))
|
|
388
|
+
return false;
|
|
389
|
+
return true;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function lifetimeRunFamilyKey(source) {
|
|
393
|
+
const parts = source.path.split(/[\\/]/g).filter(Boolean);
|
|
394
|
+
if (!parts.length)
|
|
395
|
+
return source.path;
|
|
396
|
+
const agentRunsIndex = parts.lastIndexOf('agent-runs');
|
|
397
|
+
const start = agentRunsIndex >= 0 ? agentRunsIndex + 1 : 0;
|
|
398
|
+
return parts[start] ?? source.path;
|
|
399
|
+
}
|
|
400
|
+
async function readLifetimeDashboardResetCutoff(root) {
|
|
401
|
+
const reset = recordValue(await readJsonFile(path.join(root, LIFETIME_DASHBOARD_RESET_FILE)));
|
|
402
|
+
return numberValue(reset.resetAt ?? reset.generatedAt);
|
|
403
|
+
}
|
|
404
|
+
async function findLifetimeDashboardArtifactFiles(root, input) {
|
|
405
|
+
const names = new Set(['swarm-results.json', 'pids.json', 'coordinator-dashboard.json', 'collection.json', 'coordinator-query.json', 'continuation.json']);
|
|
406
|
+
const skipDirs = new Set(['.git', 'node_modules', 'dist', 'coverage', 'evidence', 'streams', 'patch-scores', 'apply-ledger', 'artifact-index']);
|
|
407
|
+
const out = [];
|
|
408
|
+
async function walk(current, depth) {
|
|
409
|
+
if (out.length >= input.maxFiles || depth > input.maxDepth)
|
|
410
|
+
return;
|
|
411
|
+
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
412
|
+
for (const entry of entries) {
|
|
413
|
+
if (out.length >= input.maxFiles)
|
|
414
|
+
return;
|
|
415
|
+
const absolute = path.join(current, entry.name);
|
|
416
|
+
if (entry.isDirectory()) {
|
|
417
|
+
if (skipDirs.has(entry.name) || entry.name.startsWith('.'))
|
|
418
|
+
continue;
|
|
419
|
+
if (input.resetAt && depth === 0) {
|
|
420
|
+
const dirStat = await fs.stat(absolute).catch(() => undefined);
|
|
421
|
+
if ((dirStat?.mtimeMs ?? 0) <= input.resetAt)
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
await walk(absolute, depth + 1);
|
|
425
|
+
}
|
|
426
|
+
else if (entry.isFile() && names.has(entry.name)) {
|
|
427
|
+
out.push(absolute);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
await walk(root, 0);
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
function combineLifetimeDashboardSnapshots(options, discoveredSources, snapshots, reviewDecisions) {
|
|
435
|
+
const jobs = applyCoordinatorReviewDecisions(snapshots.flatMap(({ source, snapshot }) => {
|
|
436
|
+
return recordArray(snapshot.jobs).map((job) => ({
|
|
437
|
+
...job,
|
|
438
|
+
id: lifetimeScopedId(source, textValue(job.id ?? job.jobId ?? job.taskId, 'job')),
|
|
439
|
+
originalJobId: textValue(job.id ?? job.jobId ?? job.taskId, 'job'),
|
|
440
|
+
sourceRun: source.run,
|
|
441
|
+
sourceCollection: source.collection,
|
|
442
|
+
sourceContinuation: source.continuation,
|
|
443
|
+
sourceLabel: source.label,
|
|
444
|
+
generatedAt: numberValue(job.generatedAt) || numberValue(snapshot.generatedAt) || source.mtimeMs
|
|
445
|
+
}));
|
|
446
|
+
}), reviewDecisions).slice(0, LIFETIME_DASHBOARD_MAX_JOBS);
|
|
447
|
+
const humanActionAnswers = recordArray(awaitNoop([]));
|
|
448
|
+
const summary = lifetimeDashboardSummary(jobs);
|
|
449
|
+
const latestGeneratedAt = Math.max(Date.now(), ...snapshots.map((entry) => numberValue(entry.snapshot.generatedAt)), ...discoveredSources.map((source) => source.mtimeMs));
|
|
450
|
+
const events = snapshots.flatMap(({ source, snapshot }) => recordArray(snapshot.events).map((event) => ({
|
|
451
|
+
...event,
|
|
452
|
+
sourceLabel: source.label,
|
|
453
|
+
message: textValue(event.message, textValue(event.type, 'event')),
|
|
454
|
+
at: numberValue(event.at) || source.mtimeMs
|
|
455
|
+
}))).sort((left, right) => numberValue(left.at) - numberValue(right.at)).slice(-160);
|
|
456
|
+
return {
|
|
457
|
+
kind: 'frontier.loom-ui.lifetime-dashboard',
|
|
458
|
+
version: 1,
|
|
459
|
+
ok: snapshots.some(({ snapshot }) => Boolean(snapshot.ok)),
|
|
460
|
+
generatedAt: latestGeneratedAt,
|
|
461
|
+
cwd: options.cwd,
|
|
462
|
+
sources: {
|
|
463
|
+
workspace: options.cwd,
|
|
464
|
+
lifetimeRoot: path.join(options.cwd, 'agent-runs'),
|
|
465
|
+
sourceCount: discoveredSources.length,
|
|
466
|
+
loadedSourceCount: snapshots.length,
|
|
467
|
+
...(reviewDecisions.length ? { coordinatorReviewDecisions: coordinatorReviewDecisionPath(options.cwd) } : {})
|
|
468
|
+
},
|
|
469
|
+
summary,
|
|
470
|
+
semantic: lifetimeSemanticSummary(jobs),
|
|
471
|
+
health: lifetimeHealthSummary(jobs),
|
|
472
|
+
quality: {},
|
|
473
|
+
timeSeries: lifetimeTimeSeries(jobs, events),
|
|
474
|
+
lanes: lifetimeLaneRows(jobs),
|
|
475
|
+
jobs,
|
|
476
|
+
humanActions: snapshots.flatMap(({ snapshot }) => recordArray(snapshot.humanActions)).slice(-100),
|
|
477
|
+
humanActionAnswers,
|
|
478
|
+
events,
|
|
479
|
+
routing: lifetimeRoutingSummary(snapshots.map((entry) => entry.snapshot)),
|
|
480
|
+
backlog: {
|
|
481
|
+
id: 'workspace-lifetime',
|
|
482
|
+
entryCount: snapshots.reduce((sum, entry) => sum + numberValue(recordValue(entry.snapshot.backlog).entryCount), 0),
|
|
483
|
+
readyCount: snapshots.reduce((sum, entry) => sum + numberValue(recordValue(entry.snapshot.backlog).readyCount), 0)
|
|
484
|
+
},
|
|
485
|
+
raw: {
|
|
486
|
+
lifetime: {
|
|
487
|
+
mode: 'workspace',
|
|
488
|
+
sourceCount: discoveredSources.length,
|
|
489
|
+
loadedSourceCount: snapshots.length,
|
|
490
|
+
sources: discoveredSources.slice(0, LIFETIME_DASHBOARD_MAX_SOURCES)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function lifetimeScopedId(source, id) {
|
|
496
|
+
return `${source.id}:${id}`.replaceAll(/[^\w:.-]+/g, '-');
|
|
497
|
+
}
|
|
498
|
+
async function readCoordinatorReviewDecisions(cwd) {
|
|
499
|
+
const file = coordinatorReviewDecisionPath(cwd);
|
|
500
|
+
const raw = await readJsonFile(file);
|
|
501
|
+
if (Array.isArray(raw))
|
|
502
|
+
return raw.map(recordValue).filter((entry) => Object.keys(entry).length);
|
|
503
|
+
const record = recordValue(raw);
|
|
504
|
+
return recordArray(record.decisions ?? record.entries);
|
|
505
|
+
}
|
|
506
|
+
function coordinatorReviewDecisionPath(cwd) {
|
|
507
|
+
return path.join(cwd, 'agent-runs', REVIEW_DECISIONS_FILE);
|
|
508
|
+
}
|
|
509
|
+
function applyCoordinatorReviewDecisions(jobs, decisions) {
|
|
510
|
+
const records = jobs
|
|
511
|
+
.map(recordValue)
|
|
512
|
+
.filter((job) => Object.keys(job).length)
|
|
513
|
+
.map(normalizeCoordinatorFacingJob);
|
|
514
|
+
if (!decisions.length)
|
|
515
|
+
return records;
|
|
516
|
+
return records.map((record) => {
|
|
517
|
+
const decision = decisions.find((entry) => coordinatorReviewDecisionMatches(record, entry));
|
|
518
|
+
if (!decision)
|
|
519
|
+
return record;
|
|
520
|
+
const status = textValue(decision.status ?? decision.decision, 'resolved');
|
|
521
|
+
const resolved = isResolvedCoordinatorDecision(status);
|
|
522
|
+
return {
|
|
523
|
+
...record,
|
|
524
|
+
coordinatorDecision: decision,
|
|
525
|
+
coordinatorDecisionStatus: status,
|
|
526
|
+
coordinatorDecisionAt: textValue(decision.decidedAt, ''),
|
|
527
|
+
reviewResolved: resolved,
|
|
528
|
+
...(resolved && isCoordinatorPortBucket(record.bucket) ? { bucket: 'review-resolved' } : {}),
|
|
529
|
+
...(resolved ? { disposition: status } : {})
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
function normalizeCoordinatorFacingJob(record) {
|
|
534
|
+
return {
|
|
535
|
+
...record,
|
|
536
|
+
bucket: coordinatorFacingMachineLabel(record.bucket),
|
|
537
|
+
status: coordinatorFacingMachineLabel(record.status),
|
|
538
|
+
disposition: coordinatorFacingMachineLabel(record.disposition),
|
|
539
|
+
mergeReadiness: coordinatorFacingMachineLabel(record.mergeReadiness)
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function normalizeCoordinatorFacingSnapshot(record) {
|
|
543
|
+
return {
|
|
544
|
+
...record,
|
|
545
|
+
summary: normalizeCoordinatorFacingSummary(recordValue(record.summary))
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function normalizeCoordinatorFacingSummary(summary) {
|
|
549
|
+
const out = {};
|
|
550
|
+
for (const [key, value] of Object.entries(summary)) {
|
|
551
|
+
if (key === 'bucketCounts') {
|
|
552
|
+
out.bucketCounts = normalizeCoordinatorFacingCountMap(recordValue(value));
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const nextKey = coordinatorFacingMachineKey(key);
|
|
556
|
+
if (typeof value === 'number' && typeof out[nextKey] === 'number')
|
|
557
|
+
out[nextKey] = out[nextKey] + value;
|
|
558
|
+
else
|
|
559
|
+
out[nextKey] = value;
|
|
560
|
+
}
|
|
561
|
+
const coordinatorReview = numberValue(out['needs-coordinator-review']);
|
|
562
|
+
if (coordinatorReview && out.needsCoordinatorReviewCount === undefined)
|
|
563
|
+
out.needsCoordinatorReviewCount = coordinatorReview;
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
function normalizeCoordinatorFacingCountMap(input) {
|
|
567
|
+
const out = {};
|
|
568
|
+
for (const [key, value] of Object.entries(input)) {
|
|
569
|
+
const nextKey = coordinatorFacingMachineKey(key);
|
|
570
|
+
if (typeof value === 'number' && typeof out[nextKey] === 'number')
|
|
571
|
+
out[nextKey] = out[nextKey] + value;
|
|
572
|
+
else
|
|
573
|
+
out[nextKey] = value;
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
function isCoordinatorPortBucket(value) {
|
|
578
|
+
const bucket = normalized(value);
|
|
579
|
+
return bucket === 'needs-human-port'
|
|
580
|
+
|| bucket === 'needs-human-review'
|
|
581
|
+
|| bucket === 'needs-human-decision'
|
|
582
|
+
|| bucket === 'needs-coordinator-port'
|
|
583
|
+
|| bucket === 'needs-coordinator-review'
|
|
584
|
+
|| bucket === 'needs-coordinator-decision';
|
|
585
|
+
}
|
|
586
|
+
function coordinatorFacingMachineKey(value) {
|
|
587
|
+
return textValue(coordinatorFacingMachineLabel(value), value);
|
|
588
|
+
}
|
|
589
|
+
function coordinatorFacingMachineLabel(value) {
|
|
590
|
+
const raw = textValue(value, '');
|
|
591
|
+
const current = normalized(raw);
|
|
592
|
+
if (current === 'needs-human-port')
|
|
593
|
+
return 'needs-coordinator-review';
|
|
594
|
+
if (current === 'needs-coordinator-port')
|
|
595
|
+
return 'needs-coordinator-review';
|
|
596
|
+
if (current === 'needs-human-review')
|
|
597
|
+
return 'needs-coordinator-review';
|
|
598
|
+
if (current === 'needs-human-decision')
|
|
599
|
+
return 'needs-coordinator-decision';
|
|
600
|
+
if (current === 'needshumancount')
|
|
601
|
+
return 'needsCoordinatorReviewCount';
|
|
602
|
+
if (current === 'needshumanportcount')
|
|
603
|
+
return 'needsCoordinatorReviewCount';
|
|
604
|
+
if (current === 'needscoordinatorportcount')
|
|
605
|
+
return 'needsCoordinatorReviewCount';
|
|
606
|
+
return value;
|
|
607
|
+
}
|
|
608
|
+
function coordinatorReviewDecisionMatches(job, decision) {
|
|
609
|
+
const decisionIds = coordinatorDecisionIds(decision);
|
|
610
|
+
if (!decisionIds.length)
|
|
611
|
+
return false;
|
|
612
|
+
const jobIds = coordinatorDecisionIds(job);
|
|
613
|
+
const idMatches = decisionIds.some((id) => jobIds.includes(id) || jobIds.includes(sanitizeDecisionId(id)));
|
|
614
|
+
if (!idMatches)
|
|
615
|
+
return false;
|
|
616
|
+
const decisionSource = textValue(decision.source ?? decision.sourceCollection ?? decision.sourceRun ?? decision.sourceLabel, '');
|
|
617
|
+
if (!decisionSource)
|
|
618
|
+
return true;
|
|
619
|
+
const jobSources = [
|
|
620
|
+
textValue(job.sourceLabel, ''),
|
|
621
|
+
textValue(job.sourceCollection, ''),
|
|
622
|
+
textValue(job.sourceRun, ''),
|
|
623
|
+
textValue(job.sourceContinuation, '')
|
|
624
|
+
].filter(Boolean);
|
|
625
|
+
return jobSources.some((source) => source === decisionSource || source.endsWith(decisionSource) || decisionSource.endsWith(source));
|
|
626
|
+
}
|
|
627
|
+
function coordinatorDecisionIds(record) {
|
|
628
|
+
return Array.from(new Set([
|
|
629
|
+
textValue(record.id, ''),
|
|
630
|
+
textValue(record.originalJobId, ''),
|
|
631
|
+
textValue(record.jobId, ''),
|
|
632
|
+
textValue(record.taskId, ''),
|
|
633
|
+
...stringArray(record.matchIds)
|
|
634
|
+
].filter(Boolean).flatMap((id) => [id, sanitizeDecisionId(id)])));
|
|
635
|
+
}
|
|
636
|
+
function sanitizeDecisionId(value) {
|
|
637
|
+
return value.replaceAll(/[^\w:.-]+/g, '-');
|
|
638
|
+
}
|
|
639
|
+
function isResolvedCoordinatorDecision(status) {
|
|
640
|
+
const value = normalized(status);
|
|
641
|
+
return Boolean(value) && !['open', 'pending', 'deferred', 'needs-review'].includes(value);
|
|
642
|
+
}
|
|
643
|
+
function lifetimeSourceLabel(relative) {
|
|
644
|
+
const parts = relative.split(/[\\/]/g).filter(Boolean);
|
|
645
|
+
return parts.slice(-3).join('/');
|
|
646
|
+
}
|
|
647
|
+
function lifetimeDashboardSummary(jobs) {
|
|
648
|
+
const completedCount = jobs.filter((job) => textValue(job.status, '').toLowerCase() === 'completed').length;
|
|
649
|
+
const failedCount = jobs.filter((job) => isLifetimeFailedJob(job)).length;
|
|
650
|
+
const blockedCount = jobs.filter((job) => textValue(job.status, '').toLowerCase() === 'blocked').length;
|
|
651
|
+
const runningCount = jobs.filter((job) => textValue(job.status, '').toLowerCase() === 'running').length;
|
|
652
|
+
const terminalCount = jobs.filter((job) => ['completed', 'failed', 'blocked'].includes(textValue(job.status, '').toLowerCase())).length;
|
|
653
|
+
const durationMs = jobs.reduce((sum, job) => sum + numberValue(job.durationMs), 0);
|
|
654
|
+
return {
|
|
655
|
+
jobCount: jobs.length,
|
|
656
|
+
completedCount,
|
|
657
|
+
failedCount,
|
|
658
|
+
runningCount,
|
|
659
|
+
blockedCount,
|
|
660
|
+
changedPathCount: jobs.reduce((sum, job) => sum + numberValue(job.changedPathCount), 0),
|
|
661
|
+
ownershipViolationCount: jobs.reduce((sum, job) => sum + numberValue(job.ownershipViolationCount), 0),
|
|
662
|
+
sourceOwnershipViolationCount: jobs.reduce((sum, job) => sum + numberValue(job.sourceOwnershipViolationCount), 0),
|
|
663
|
+
ignoredOwnershipViolationCount: jobs.reduce((sum, job) => sum + numberValue(job.ignoredOwnershipViolationCount), 0),
|
|
664
|
+
quarantinedChangedPathCount: jobs.reduce((sum, job) => sum + numberValue(job.quarantinedChangedPathCount), 0),
|
|
665
|
+
ignoredChangedPathCount: jobs.reduce((sum, job) => sum + numberValue(job.ignoredChangedPathCount), 0),
|
|
666
|
+
terminalCount,
|
|
667
|
+
failureCount: failedCount + blockedCount,
|
|
668
|
+
warningCount: jobs.filter((job) => textValue(job.health, '') === 'warning').length,
|
|
669
|
+
contextWarningCount: jobs.filter((job) => numberValue(job.contextBudgetWarningCount) > 0).length,
|
|
670
|
+
contextFailedCount: jobs.filter((job) => numberValue(job.contextBudgetErrorCount) > 0).length,
|
|
671
|
+
semanticCleanCount: jobs.filter((job) => textValue(job.semanticReadiness, '') === 'clean').length,
|
|
672
|
+
semanticCandidateCount: jobs.filter((job) => textValue(job.semanticReadiness, '') === 'candidate').length,
|
|
673
|
+
semanticBlockedCount: jobs.filter((job) => textValue(job.semanticReadiness, '') === 'blocked').length,
|
|
674
|
+
durationMs,
|
|
675
|
+
averageDurationMs: jobs.length ? Math.round(durationMs / jobs.length) : 0,
|
|
676
|
+
maxDurationMs: jobs.reduce((max, job) => Math.max(max, numberValue(job.durationMs)), 0),
|
|
677
|
+
actualInputTokens: jobs.reduce((sum, job) => sum + numberValue(job.actualInputTokens), 0),
|
|
678
|
+
cachedInputTokens: jobs.reduce((sum, job) => sum + numberValue(job.cachedInputTokens), 0),
|
|
679
|
+
uncachedInputTokens: jobs.reduce((sum, job) => sum + numberValue(job.uncachedInputTokens), 0)
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function lifetimeHealthSummary(jobs) {
|
|
683
|
+
const summary = lifetimeDashboardSummary(jobs);
|
|
684
|
+
const failedJobCount = numberValue(summary.failedCount);
|
|
685
|
+
const blockedJobCount = numberValue(summary.blockedCount);
|
|
686
|
+
const runningJobCount = numberValue(summary.runningCount);
|
|
687
|
+
const warningJobCount = jobs.filter((job) => textValue(job.health, '') === 'warning').length;
|
|
688
|
+
const status = failedJobCount || blockedJobCount ? 'failed' : runningJobCount ? 'running' : warningJobCount ? 'warning' : jobs.length ? 'healthy' : 'unknown';
|
|
689
|
+
return {
|
|
690
|
+
status,
|
|
691
|
+
summary: {
|
|
692
|
+
jobCount: jobs.length,
|
|
693
|
+
healthyJobCount: Math.max(0, jobs.length - failedJobCount - blockedJobCount - warningJobCount),
|
|
694
|
+
warningJobCount,
|
|
695
|
+
failedJobCount,
|
|
696
|
+
blockedJobCount,
|
|
697
|
+
runningJobCount,
|
|
698
|
+
terminalJobCount: numberValue(summary.terminalCount),
|
|
699
|
+
readyToApplyJobCount: jobs.filter((job) => textValue(job.bucket, '') === 'ready-to-apply').length,
|
|
700
|
+
contextWarningJobCount: numberValue(summary.contextWarningCount),
|
|
701
|
+
semanticCleanJobCount: numberValue(summary.semanticCleanCount),
|
|
702
|
+
semanticCandidateJobCount: numberValue(summary.semanticCandidateCount),
|
|
703
|
+
completionRatio: jobs.length ? numberValue(summary.terminalCount) / jobs.length : 0,
|
|
704
|
+
failureRatio: jobs.length ? (failedJobCount + blockedJobCount) / jobs.length : 0
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function lifetimeLaneRows(jobs) {
|
|
709
|
+
const byLane = new Map();
|
|
710
|
+
for (const job of jobs) {
|
|
711
|
+
const lane = textValue(job.lane, 'workspace');
|
|
712
|
+
byLane.set(lane, [...(byLane.get(lane) ?? []), job]);
|
|
713
|
+
}
|
|
714
|
+
return Array.from(byLane.entries()).map(([id, entries]) => ({
|
|
715
|
+
id,
|
|
716
|
+
jobCount: entries.length,
|
|
717
|
+
completedCount: entries.filter((job) => textValue(job.status, '') === 'completed').length,
|
|
718
|
+
failedCount: entries.filter(isLifetimeFailedJob).length,
|
|
719
|
+
runningCount: entries.filter((job) => textValue(job.status, '') === 'running').length
|
|
720
|
+
}));
|
|
721
|
+
}
|
|
722
|
+
function lifetimeTimeSeries(jobs, events) {
|
|
723
|
+
const bucketMs = 24 * 60 * 60 * 1000;
|
|
724
|
+
const buckets = new Map();
|
|
725
|
+
for (const job of jobs) {
|
|
726
|
+
const at = numberValue(job.finishedAt) || numberValue(job.generatedAt) || numberValue(job.startedAt);
|
|
727
|
+
if (!at)
|
|
728
|
+
continue;
|
|
729
|
+
const bucketAt = startOfLocalDay(at);
|
|
730
|
+
const bucket = buckets.get(bucketAt) ?? { at: bucketAt, terminalJobCount: 0, warningJobCount: 0, failureJobCount: 0, durationMs: 0, actualInputTokens: 0, uncachedInputTokens: 0, eventCount: 0 };
|
|
731
|
+
if (['completed', 'failed', 'blocked'].includes(textValue(job.status, '').toLowerCase()))
|
|
732
|
+
bucket.terminalJobCount += 1;
|
|
733
|
+
if (textValue(job.health, '') === 'warning')
|
|
734
|
+
bucket.warningJobCount += 1;
|
|
735
|
+
if (isLifetimeFailedJob(job))
|
|
736
|
+
bucket.failureJobCount += 1;
|
|
737
|
+
bucket.durationMs += numberValue(job.durationMs);
|
|
738
|
+
bucket.actualInputTokens += numberValue(job.actualInputTokens);
|
|
739
|
+
bucket.uncachedInputTokens += numberValue(job.uncachedInputTokens);
|
|
740
|
+
buckets.set(bucketAt, bucket);
|
|
741
|
+
}
|
|
742
|
+
for (const event of events) {
|
|
743
|
+
const at = numberValue(event.at);
|
|
744
|
+
if (!at)
|
|
745
|
+
continue;
|
|
746
|
+
const bucketAt = startOfLocalDay(at);
|
|
747
|
+
const bucket = buckets.get(bucketAt) ?? { at: bucketAt, terminalJobCount: 0, warningJobCount: 0, failureJobCount: 0, durationMs: 0, actualInputTokens: 0, uncachedInputTokens: 0, eventCount: 0 };
|
|
748
|
+
bucket.eventCount += 1;
|
|
749
|
+
buckets.set(bucketAt, bucket);
|
|
750
|
+
}
|
|
751
|
+
const points = Array.from(buckets.values()).sort((left, right) => left.at - right.at);
|
|
752
|
+
return {
|
|
753
|
+
bucketMs,
|
|
754
|
+
points,
|
|
755
|
+
summary: {
|
|
756
|
+
pointCount: points.length,
|
|
757
|
+
terminalJobCount: points.reduce((sum, point) => sum + point.terminalJobCount, 0),
|
|
758
|
+
warningJobCount: points.reduce((sum, point) => sum + point.warningJobCount, 0),
|
|
759
|
+
failureJobCount: points.reduce((sum, point) => sum + point.failureJobCount, 0),
|
|
760
|
+
durationMs: points.reduce((sum, point) => sum + point.durationMs, 0),
|
|
761
|
+
actualInputTokens: points.reduce((sum, point) => sum + point.actualInputTokens, 0),
|
|
762
|
+
uncachedInputTokens: points.reduce((sum, point) => sum + point.uncachedInputTokens, 0),
|
|
763
|
+
missingTimestampJobCount: jobs.filter((job) => !numberValue(job.finishedAt) && !numberValue(job.generatedAt) && !numberValue(job.startedAt)).length
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function startOfLocalDay(value) {
|
|
768
|
+
const date = new Date(value);
|
|
769
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
770
|
+
}
|
|
771
|
+
function lifetimeSemanticSummary(jobs) {
|
|
772
|
+
return {
|
|
773
|
+
expected: jobs.length,
|
|
774
|
+
satisfied: jobs.filter((job) => textValue(job.semanticReadiness, '') === 'clean').length,
|
|
775
|
+
autoMerge: jobs.filter((job) => Boolean(job.semanticAutoMergeCandidate)).length,
|
|
776
|
+
acceptedClean: jobs.filter((job) => textValue(job.semanticReadiness, '') === 'clean').length,
|
|
777
|
+
conflicts: jobs.filter((job) => textValue(job.semanticReadiness, '') === 'blocked').length
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function lifetimeRoutingSummary(snapshots) {
|
|
781
|
+
const routingRows = snapshots.map((snapshot) => recordValue(snapshot.routing)).filter((entry) => Object.keys(entry).length);
|
|
782
|
+
if (!routingRows.length)
|
|
783
|
+
return undefined;
|
|
784
|
+
return {
|
|
785
|
+
policyId: 'workspace-lifetime',
|
|
786
|
+
preferenceCount: routingRows.reduce((sum, row) => sum + numberValue(row.preferenceCount), 0),
|
|
787
|
+
preferCount: routingRows.reduce((sum, row) => sum + numberValue(row.preferCount), 0),
|
|
788
|
+
avoidCount: routingRows.reduce((sum, row) => sum + numberValue(row.avoidCount), 0),
|
|
789
|
+
tournamentObservationCount: routingRows.reduce((sum, row) => sum + numberValue(row.tournamentObservationCount), 0),
|
|
790
|
+
tournamentRecommendationCount: routingRows.reduce((sum, row) => sum + numberValue(row.tournamentRecommendationCount), 0)
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function isLifetimeFailedJob(job) {
|
|
794
|
+
const status = textValue(job.status, '').toLowerCase();
|
|
795
|
+
const health = textValue(job.health, '').toLowerCase();
|
|
796
|
+
const bucket = textValue(job.bucket, '').toLowerCase();
|
|
797
|
+
return status === 'failed' || health === 'failed' || bucket === 'failed-evidence';
|
|
798
|
+
}
|
|
799
|
+
function awaitNoop(value) {
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
function shouldPreferActiveRunSnapshot(jobs, activeJobs) {
|
|
803
|
+
if (!activeJobs.length)
|
|
804
|
+
return false;
|
|
805
|
+
const runningCount = activeJobs.filter((job) => textValue(job.status, '') === 'running').length;
|
|
806
|
+
if (runningCount > 0)
|
|
807
|
+
return true;
|
|
808
|
+
return activeJobs.length > jobs.length;
|
|
809
|
+
}
|
|
810
|
+
async function readActiveRunSnapshot(options) {
|
|
811
|
+
const runDir = await resolveRunDirectory(options);
|
|
812
|
+
if (!runDir)
|
|
813
|
+
return undefined;
|
|
814
|
+
const pidPath = path.join(runDir, 'pids.json');
|
|
815
|
+
const pidManifest = recordValue(await readJsonFile(pidPath));
|
|
816
|
+
const entries = recordArray(pidManifest.entries).filter((entry) => textValue(entry.role, '') === 'codex');
|
|
817
|
+
if (!entries.length)
|
|
818
|
+
return undefined;
|
|
819
|
+
const planPath = path.join(runDir, 'swarm-plan.json');
|
|
820
|
+
const plan = recordValue(await readJsonFile(planPath));
|
|
821
|
+
const planJobs = new Map(recordArray(plan.jobs).map((job) => [textValue(job.id, ''), job]));
|
|
822
|
+
const now = Date.now();
|
|
823
|
+
const jobs = await Promise.all(entries.map((entry) => activeRunJob(runDir, entry, planJobs.get(textValue(entry.jobId, '')), now)));
|
|
824
|
+
const runningCount = jobs.filter((job) => textValue(job.status, '') === 'running').length;
|
|
825
|
+
const completedCount = jobs.filter((job) => textValue(job.status, '') === 'completed').length;
|
|
826
|
+
const failedCount = jobs.filter((job) => textValue(job.status, '') === 'failed').length;
|
|
827
|
+
const actualInputTokens = jobs.reduce((sum, job) => sum + numberValue(job.actualInputTokens), 0);
|
|
828
|
+
const cachedInputTokens = jobs.reduce((sum, job) => sum + numberValue(job.cachedInputTokens), 0);
|
|
829
|
+
return {
|
|
830
|
+
ok: true,
|
|
831
|
+
generatedAt: now,
|
|
832
|
+
cwd: options.cwd,
|
|
833
|
+
summary: {
|
|
834
|
+
jobCount: jobs.length,
|
|
835
|
+
completedCount,
|
|
836
|
+
failedCount,
|
|
837
|
+
runningCount,
|
|
838
|
+
blockedCount: 0,
|
|
839
|
+
actualInputTokens,
|
|
840
|
+
cachedInputTokens,
|
|
841
|
+
uncachedInputTokens: Math.max(0, actualInputTokens - cachedInputTokens),
|
|
842
|
+
durationMs: jobs.reduce((sum, job) => Math.max(sum, numberValue(job.durationMs)), 0),
|
|
843
|
+
bucketCounts: {
|
|
844
|
+
total: jobs.length,
|
|
845
|
+
running: runningCount,
|
|
846
|
+
completed: completedCount,
|
|
847
|
+
'failed-evidence': failedCount
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
lanes: activeRunLanes(jobs),
|
|
851
|
+
jobs,
|
|
852
|
+
events: activeRunEvents(jobs),
|
|
853
|
+
sources: {
|
|
854
|
+
run: runDir,
|
|
855
|
+
activeRun: pidPath,
|
|
856
|
+
plan: planPath
|
|
857
|
+
},
|
|
858
|
+
raw: {
|
|
859
|
+
activeRun: {
|
|
860
|
+
runId: textValue(pidManifest.runId, ''),
|
|
861
|
+
launchedCount: entries.length,
|
|
862
|
+
runningCount
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
async function activeRunJob(runDir, entry, planJob, now) {
|
|
868
|
+
const jobId = textValue(entry.jobId, 'job');
|
|
869
|
+
const jobDir = path.join(runDir, jobId);
|
|
870
|
+
const lastMessagePath = path.join(jobDir, 'last-message.md');
|
|
871
|
+
const mergePath = path.join(jobDir, 'merge.json');
|
|
872
|
+
const lastMessage = await fs.stat(lastMessagePath).catch(() => undefined);
|
|
873
|
+
const merge = recordValue(await readJsonFile(mergePath));
|
|
874
|
+
const live = isProcessLive(numberValue(entry.pid), entry);
|
|
875
|
+
const status = live && !lastMessage ? 'running' : lastMessage || Object.keys(merge).length ? 'completed' : 'failed';
|
|
876
|
+
const startedAt = numberValue(entry.startedAt);
|
|
877
|
+
const finishedAt = status === 'running' ? undefined : Math.max(numberValue(lastMessage?.mtimeMs), numberValue(merge.generatedAt));
|
|
878
|
+
const evidencePaths = [
|
|
879
|
+
path.relative(optionsSafeCwd(runDir), lastMessagePath),
|
|
880
|
+
path.relative(optionsSafeCwd(runDir), path.join(jobDir, 'codex-events.jsonl')),
|
|
881
|
+
path.relative(optionsSafeCwd(runDir), path.join(jobDir, 'evidence', 'resource-allocation.json')),
|
|
882
|
+
...(Object.keys(merge).length ? [path.relative(optionsSafeCwd(runDir), mergePath)] : [])
|
|
883
|
+
];
|
|
884
|
+
const usage = await readCodexEventUsageSummary(path.join(jobDir, 'codex-events.jsonl'));
|
|
885
|
+
const task = recordValue(planJob?.task);
|
|
886
|
+
const compute = recordValue(planJob?.compute);
|
|
887
|
+
const changedPaths = stringArray(merge.changedPaths);
|
|
888
|
+
return {
|
|
889
|
+
id: jobId,
|
|
890
|
+
taskId: textValue(planJob?.taskId ?? task.id, jobId),
|
|
891
|
+
title: textValue(planJob?.title ?? task.title, jobId),
|
|
892
|
+
lane: textValue(planJob?.lane ?? task.lane, 'active-run'),
|
|
893
|
+
status,
|
|
894
|
+
bucket: status === 'running' ? 'running' : status === 'completed' ? 'completed' : 'failed-evidence',
|
|
895
|
+
disposition: status === 'running' ? 'active' : status,
|
|
896
|
+
agentId: jobId,
|
|
897
|
+
workerId: jobId,
|
|
898
|
+
model: textValue(compute.model, ''),
|
|
899
|
+
computeId: textValue(compute.id, ''),
|
|
900
|
+
reasoningEffort: textValue(compute.reasoningEffort, ''),
|
|
901
|
+
startedAt: startedAt || undefined,
|
|
902
|
+
...(finishedAt ? { finishedAt } : {}),
|
|
903
|
+
durationMs: startedAt ? Math.max(0, (finishedAt ?? now) - startedAt) : 0,
|
|
904
|
+
...(usage.inputTokens ? { actualInputTokens: usage.inputTokens, inputTokens: usage.inputTokens } : {}),
|
|
905
|
+
...(usage.cachedInputTokens ? { cachedInputTokens: usage.cachedInputTokens } : {}),
|
|
906
|
+
...(usage.uncachedInputTokens ? { uncachedInputTokens: usage.uncachedInputTokens } : {}),
|
|
907
|
+
...(usage.outputTokens ? { actualOutputTokens: usage.outputTokens, outputTokens: usage.outputTokens } : {}),
|
|
908
|
+
...(usage.reasoningOutputTokens ? { reasoningOutputTokens: usage.reasoningOutputTokens } : {}),
|
|
909
|
+
...(usage.eventCount ? {
|
|
910
|
+
usage: {
|
|
911
|
+
input_tokens: usage.inputTokens,
|
|
912
|
+
cached_input_tokens: usage.cachedInputTokens,
|
|
913
|
+
uncached_input_tokens: usage.uncachedInputTokens,
|
|
914
|
+
output_tokens: usage.outputTokens,
|
|
915
|
+
reasoning_output_tokens: usage.reasoningOutputTokens,
|
|
916
|
+
source: 'codex-events.jsonl',
|
|
917
|
+
event_count: usage.eventCount
|
|
918
|
+
}
|
|
919
|
+
} : {}),
|
|
920
|
+
changedPaths,
|
|
921
|
+
changedPathCount: changedPaths.length || numberValue(merge.changedPathCount),
|
|
922
|
+
evidencePaths,
|
|
923
|
+
evidencePathCount: evidencePaths.length,
|
|
924
|
+
commandsPassed: recordArray(merge.commandsPassed),
|
|
925
|
+
commandsFailed: recordArray(merge.commandsFailed),
|
|
926
|
+
collectReasonClasses: status === 'running' ? ['active worker'] : [],
|
|
927
|
+
mergeReadiness: textValue(merge.mergeReadiness, status)
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
function activeRunLanes(jobs) {
|
|
931
|
+
const byLane = new Map();
|
|
932
|
+
for (const job of jobs) {
|
|
933
|
+
const lane = textValue(job.lane, 'active-run');
|
|
934
|
+
byLane.set(lane, [...(byLane.get(lane) ?? []), job]);
|
|
935
|
+
}
|
|
936
|
+
return Array.from(byLane.entries()).map(([id, entries]) => ({
|
|
937
|
+
id,
|
|
938
|
+
jobCount: entries.length,
|
|
939
|
+
runningCount: entries.filter((job) => textValue(job.status, '') === 'running').length,
|
|
940
|
+
completedCount: entries.filter((job) => textValue(job.status, '') === 'completed').length,
|
|
941
|
+
failedCount: entries.filter((job) => textValue(job.status, '') === 'failed').length,
|
|
942
|
+
blockedCount: 0,
|
|
943
|
+
evidenceCount: entries.reduce((sum, job) => sum + numberValue(job.evidencePathCount), 0)
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
function activeRunEvents(jobs) {
|
|
947
|
+
return jobs.map((job) => ({
|
|
948
|
+
at: numberValue(job.startedAt) || Date.now(),
|
|
949
|
+
type: textValue(job.status, '') === 'running' ? 'worker.started' : 'worker.finished',
|
|
950
|
+
lane: textValue(job.lane, ''),
|
|
951
|
+
jobId: textValue(job.id, ''),
|
|
952
|
+
message: `${textValue(job.title, 'worker')} ${textValue(job.status, 'running')}`
|
|
953
|
+
}));
|
|
954
|
+
}
|
|
955
|
+
function mergeActiveRunJobTelemetry(jobs, activeJobs) {
|
|
956
|
+
if (!activeJobs.length)
|
|
957
|
+
return jobs;
|
|
958
|
+
const byKey = new Map();
|
|
959
|
+
for (const activeJob of activeJobs) {
|
|
960
|
+
if (!hasTokenTelemetry(activeJob))
|
|
961
|
+
continue;
|
|
962
|
+
for (const key of jobTelemetryKeys(activeJob))
|
|
963
|
+
byKey.set(key, activeJob);
|
|
964
|
+
}
|
|
965
|
+
if (!byKey.size)
|
|
966
|
+
return jobs;
|
|
967
|
+
return jobs.map((job) => {
|
|
968
|
+
const record = recordValue(job);
|
|
969
|
+
if (!Object.keys(record).length)
|
|
970
|
+
return job;
|
|
971
|
+
const activeJob = jobTelemetryKeys(record).map((key) => byKey.get(key)).find(Boolean);
|
|
972
|
+
if (!activeJob)
|
|
973
|
+
return record;
|
|
974
|
+
return {
|
|
975
|
+
...record,
|
|
976
|
+
...(numberValue(activeJob.actualInputTokens) ? { actualInputTokens: numberValue(activeJob.actualInputTokens) } : {}),
|
|
977
|
+
...(numberValue(activeJob.inputTokens) ? { inputTokens: numberValue(activeJob.inputTokens) } : {}),
|
|
978
|
+
...(numberValue(activeJob.cachedInputTokens) ? { cachedInputTokens: numberValue(activeJob.cachedInputTokens) } : {}),
|
|
979
|
+
...(numberValue(activeJob.uncachedInputTokens) ? { uncachedInputTokens: numberValue(activeJob.uncachedInputTokens) } : {}),
|
|
980
|
+
...(numberValue(activeJob.actualOutputTokens) ? { actualOutputTokens: numberValue(activeJob.actualOutputTokens) } : {}),
|
|
981
|
+
...(numberValue(activeJob.outputTokens) ? { outputTokens: numberValue(activeJob.outputTokens) } : {}),
|
|
982
|
+
...(numberValue(activeJob.reasoningOutputTokens) ? { reasoningOutputTokens: numberValue(activeJob.reasoningOutputTokens) } : {}),
|
|
983
|
+
usage: {
|
|
984
|
+
...recordValue(record.usage),
|
|
985
|
+
...recordValue(activeJob.usage)
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
function jobTelemetryKeys(job) {
|
|
991
|
+
return Array.from(new Set([
|
|
992
|
+
textValue(job.id, ''),
|
|
993
|
+
textValue(job.jobId, ''),
|
|
994
|
+
textValue(job.taskId, ''),
|
|
995
|
+
textValue(job.workerId, ''),
|
|
996
|
+
textValue(job.agentId, '')
|
|
997
|
+
].filter(Boolean)));
|
|
998
|
+
}
|
|
999
|
+
function hasTokenTelemetry(job) {
|
|
1000
|
+
return numberValue(job.actualInputTokens)
|
|
1001
|
+
+ numberValue(job.inputTokens)
|
|
1002
|
+
+ numberValue(job.cachedInputTokens)
|
|
1003
|
+
+ numberValue(job.uncachedInputTokens)
|
|
1004
|
+
+ numberValue(job.outputTokens)
|
|
1005
|
+
+ numberValue(job.actualOutputTokens) > 0;
|
|
1006
|
+
}
|
|
1007
|
+
async function readCodexEventUsageSummary(file) {
|
|
1008
|
+
const empty = emptyCodexEventUsageSummary();
|
|
1009
|
+
const stat = await fs.stat(file).catch(() => undefined);
|
|
1010
|
+
if (!stat?.isFile() || stat.size > CODEX_EVENTS_USAGE_MAX_BYTES)
|
|
1011
|
+
return empty;
|
|
1012
|
+
const text = await fs.readFile(file, 'utf8').catch(() => '');
|
|
1013
|
+
if (!text)
|
|
1014
|
+
return empty;
|
|
1015
|
+
const summary = emptyCodexEventUsageSummary();
|
|
1016
|
+
for (const line of text.split(/\r?\n/g)) {
|
|
1017
|
+
const trimmed = line.trim();
|
|
1018
|
+
if (!trimmed)
|
|
1019
|
+
continue;
|
|
1020
|
+
let event;
|
|
1021
|
+
try {
|
|
1022
|
+
event = JSON.parse(trimmed);
|
|
1023
|
+
}
|
|
1024
|
+
catch {
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
const usages = collectCodexUsageRecords(event);
|
|
1028
|
+
for (const usage of usages) {
|
|
1029
|
+
const normalized = normalizeCodexUsageRecord(usage);
|
|
1030
|
+
if (!hasCodexUsageValues(normalized))
|
|
1031
|
+
continue;
|
|
1032
|
+
summary.eventCount += 1;
|
|
1033
|
+
summary.inputTokens = Math.max(summary.inputTokens, normalized.inputTokens);
|
|
1034
|
+
summary.cachedInputTokens = Math.max(summary.cachedInputTokens, normalized.cachedInputTokens);
|
|
1035
|
+
summary.uncachedInputTokens = Math.max(summary.uncachedInputTokens, normalized.uncachedInputTokens);
|
|
1036
|
+
summary.outputTokens = Math.max(summary.outputTokens, normalized.outputTokens);
|
|
1037
|
+
summary.reasoningOutputTokens = Math.max(summary.reasoningOutputTokens, normalized.reasoningOutputTokens);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (summary.inputTokens && !summary.uncachedInputTokens) {
|
|
1041
|
+
summary.uncachedInputTokens = Math.max(0, summary.inputTokens - summary.cachedInputTokens);
|
|
1042
|
+
}
|
|
1043
|
+
return summary;
|
|
1044
|
+
}
|
|
1045
|
+
function emptyCodexEventUsageSummary() {
|
|
1046
|
+
return {
|
|
1047
|
+
inputTokens: 0,
|
|
1048
|
+
cachedInputTokens: 0,
|
|
1049
|
+
uncachedInputTokens: 0,
|
|
1050
|
+
outputTokens: 0,
|
|
1051
|
+
reasoningOutputTokens: 0,
|
|
1052
|
+
eventCount: 0
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function collectCodexUsageRecords(value, depth = 0) {
|
|
1056
|
+
if (depth > 5 || !value || typeof value !== 'object')
|
|
1057
|
+
return [];
|
|
1058
|
+
if (Array.isArray(value)) {
|
|
1059
|
+
return value.flatMap((entry) => collectCodexUsageRecords(entry, depth + 1));
|
|
1060
|
+
}
|
|
1061
|
+
const record = value;
|
|
1062
|
+
const records = [];
|
|
1063
|
+
if (looksLikeCodexUsageRecord(record))
|
|
1064
|
+
records.push(record);
|
|
1065
|
+
for (const key of ['usage', 'tokenUsage', 'token_usage', 'openaiUsage', 'openAIUsage']) {
|
|
1066
|
+
records.push(...collectCodexUsageRecords(record[key], depth + 1));
|
|
1067
|
+
}
|
|
1068
|
+
for (const [key, child] of Object.entries(record)) {
|
|
1069
|
+
if (['usage', 'tokenUsage', 'token_usage', 'openaiUsage', 'openAIUsage'].includes(key))
|
|
1070
|
+
continue;
|
|
1071
|
+
if (child && typeof child === 'object')
|
|
1072
|
+
records.push(...collectCodexUsageRecords(child, depth + 1));
|
|
1073
|
+
}
|
|
1074
|
+
return records;
|
|
1075
|
+
}
|
|
1076
|
+
function looksLikeCodexUsageRecord(record) {
|
|
1077
|
+
return firstPositiveTokenNumber(record.input_tokens, record.prompt_tokens, record.cached_input_tokens, record.cached_prompt_tokens, record.output_tokens, record.completion_tokens, record.reasoning_output_tokens) > 0;
|
|
1078
|
+
}
|
|
1079
|
+
function normalizeCodexUsageRecord(record) {
|
|
1080
|
+
const inputTokens = firstPositiveTokenNumber(record.input_tokens, record.prompt_tokens, record.inputTokens, record.promptTokens);
|
|
1081
|
+
const cachedInputTokens = Math.min(inputTokens || Number.MAX_SAFE_INTEGER, firstPositiveTokenNumber(record.cached_input_tokens, record.cached_prompt_tokens, record.cached_tokens, record.cachedInputTokens, record.cachedPromptTokens));
|
|
1082
|
+
const uncachedInputTokens = firstPositiveTokenNumber(record.uncached_input_tokens, record.uncachedInputTokens)
|
|
1083
|
+
|| (inputTokens ? Math.max(0, inputTokens - cachedInputTokens) : 0);
|
|
1084
|
+
const outputTokens = firstPositiveTokenNumber(record.output_tokens, record.completion_tokens, record.response_tokens, record.generated_tokens, record.outputTokens, record.completionTokens);
|
|
1085
|
+
const outputDetails = recordValue(record.output_tokens_details ?? record.completion_tokens_details ?? record.outputTokensDetails);
|
|
1086
|
+
const reasoningOutputTokens = firstPositiveTokenNumber(record.reasoning_output_tokens, record.reasoning_tokens, record.reasoningOutputTokens, outputDetails.reasoning_tokens, outputDetails.reasoningTokens);
|
|
1087
|
+
return {
|
|
1088
|
+
inputTokens,
|
|
1089
|
+
cachedInputTokens: cachedInputTokens === Number.MAX_SAFE_INTEGER ? 0 : cachedInputTokens,
|
|
1090
|
+
uncachedInputTokens,
|
|
1091
|
+
outputTokens,
|
|
1092
|
+
reasoningOutputTokens,
|
|
1093
|
+
eventCount: hasCodexUsageValues({ inputTokens, cachedInputTokens, uncachedInputTokens, outputTokens, reasoningOutputTokens, eventCount: 0 }) ? 1 : 0
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
function hasCodexUsageValues(usage) {
|
|
1097
|
+
return usage.inputTokens + usage.cachedInputTokens + usage.uncachedInputTokens + usage.outputTokens + usage.reasoningOutputTokens > 0;
|
|
1098
|
+
}
|
|
1099
|
+
function firstPositiveTokenNumber(...values) {
|
|
1100
|
+
for (const value of values) {
|
|
1101
|
+
const number = numberValue(value);
|
|
1102
|
+
if (number > 0)
|
|
1103
|
+
return number;
|
|
1104
|
+
}
|
|
1105
|
+
return 0;
|
|
1106
|
+
}
|
|
1107
|
+
async function resolveRunDirectory(options) {
|
|
1108
|
+
if (!options.run)
|
|
1109
|
+
return undefined;
|
|
1110
|
+
const absolute = path.resolve(options.cwd, options.run);
|
|
1111
|
+
if (!isPathInside(options.cwd, absolute))
|
|
1112
|
+
return undefined;
|
|
1113
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
1114
|
+
if (!stat)
|
|
1115
|
+
return undefined;
|
|
1116
|
+
return stat.isDirectory() ? absolute : path.dirname(absolute);
|
|
1117
|
+
}
|
|
1118
|
+
async function readJsonFile(file) {
|
|
1119
|
+
try {
|
|
1120
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
return undefined;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
function isProcessLive(pid, entry) {
|
|
1127
|
+
if (!pid)
|
|
1128
|
+
return false;
|
|
1129
|
+
try {
|
|
1130
|
+
process.kill(pid, 0);
|
|
1131
|
+
if (process.platform !== 'win32') {
|
|
1132
|
+
const status = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], {
|
|
1133
|
+
encoding: 'utf8',
|
|
1134
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1135
|
+
});
|
|
1136
|
+
const stat = status.stdout.trim();
|
|
1137
|
+
if (stat.startsWith('Z'))
|
|
1138
|
+
return false;
|
|
1139
|
+
const commandResult = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
1140
|
+
encoding: 'utf8',
|
|
1141
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1142
|
+
});
|
|
1143
|
+
const command = commandResult.stdout.trim();
|
|
1144
|
+
if (!processCommandMatchesPidManifest(command, entry))
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function processCommandMatchesPidManifest(command, entry) {
|
|
1154
|
+
if (!entry)
|
|
1155
|
+
return true;
|
|
1156
|
+
const role = textValue(entry.role, '');
|
|
1157
|
+
const jobId = textValue(entry.jobId, '');
|
|
1158
|
+
const expected = stringArray(entry.command).join(' ');
|
|
1159
|
+
if (role === 'codex' && !/\bcodex\b|Codex\.app/i.test(command))
|
|
1160
|
+
return false;
|
|
1161
|
+
if (jobId && !command.includes(jobId))
|
|
1162
|
+
return false;
|
|
1163
|
+
if (expected && expected.includes('codex') && !/\bcodex\b|Codex\.app/i.test(command))
|
|
1164
|
+
return false;
|
|
1165
|
+
return true;
|
|
1166
|
+
}
|
|
1167
|
+
function optionsSafeCwd(runDir) {
|
|
1168
|
+
return path.dirname(path.dirname(runDir));
|
|
1169
|
+
}
|
|
1170
|
+
async function readTaskDetails(options, jobId) {
|
|
1171
|
+
if (!jobId)
|
|
1172
|
+
return { ok: false, jobId, files: [], commandsPassed: [], commandsFailed: [], evidenceArtifacts: [], error: 'missing job id' };
|
|
1173
|
+
const entry = await findCollectionBundle(options, jobId);
|
|
1174
|
+
if (!entry)
|
|
1175
|
+
return { ok: false, jobId, files: [], commandsPassed: [], commandsFailed: [], evidenceArtifacts: [], error: 'task not found in collection' };
|
|
1176
|
+
const { bundle, outputDir } = entry;
|
|
1177
|
+
const patchPath = textValue(bundle.patchPath, '');
|
|
1178
|
+
const evidencePaths = stringArray(bundle.evidencePaths).slice(0, 40);
|
|
1179
|
+
return {
|
|
1180
|
+
ok: true,
|
|
1181
|
+
jobId,
|
|
1182
|
+
...(patchPath ? { patchArtifact: artifactRecord(patchPath) } : {}),
|
|
1183
|
+
files: patchPath ? await readPatchFiles(options, patchPath) : [],
|
|
1184
|
+
commandsPassed: recordArray(bundle.commandsPassed).slice(0, 20),
|
|
1185
|
+
commandsFailed: recordArray(bundle.commandsFailed).slice(0, 20),
|
|
1186
|
+
evidenceArtifacts: evidencePaths.map((evidencePath) => artifactRecord(resolveRelativeArtifactPath(outputDir, evidencePath), evidencePath))
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
async function findCollectionBundle(options, jobId) {
|
|
1190
|
+
const collectionFile = await resolveArtifactFile(options.cwd, options.collection, ['collection.json', 'coordinator-query.json']);
|
|
1191
|
+
if (!collectionFile)
|
|
1192
|
+
return undefined;
|
|
1193
|
+
const collection = JSON.parse(await fs.readFile(collectionFile, 'utf8'));
|
|
1194
|
+
const buckets = recordValue(collection.buckets);
|
|
1195
|
+
for (const entries of Object.values(buckets)) {
|
|
1196
|
+
if (!Array.isArray(entries))
|
|
1197
|
+
continue;
|
|
1198
|
+
for (const entry of entries) {
|
|
1199
|
+
const row = recordValue(entry);
|
|
1200
|
+
const bundle = recordValue(row.bundle);
|
|
1201
|
+
if (textValue(row.jobId, '') === jobId || textValue(bundle.jobId, '') === jobId) {
|
|
1202
|
+
return { bundle, outputDir: textValue(row.outputDir, '') };
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const jobs = Array.isArray(collection.jobs) ? collection.jobs : [];
|
|
1207
|
+
for (const job of jobs) {
|
|
1208
|
+
const row = recordValue(job);
|
|
1209
|
+
if (textValue(row.id ?? row.jobId, '') === jobId)
|
|
1210
|
+
return { bundle: row, outputDir: textValue(row.outputDir, '') };
|
|
1211
|
+
}
|
|
1212
|
+
return undefined;
|
|
1213
|
+
}
|
|
1214
|
+
async function resolveArtifactFile(cwd, input, names) {
|
|
1215
|
+
if (!input)
|
|
1216
|
+
return undefined;
|
|
1217
|
+
const absolute = path.resolve(cwd, input);
|
|
1218
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
1219
|
+
if (!stat)
|
|
1220
|
+
return undefined;
|
|
1221
|
+
if (!isPathInside(cwd, absolute))
|
|
1222
|
+
return undefined;
|
|
1223
|
+
if (!stat.isDirectory())
|
|
1224
|
+
return absolute;
|
|
1225
|
+
for (const name of names) {
|
|
1226
|
+
const candidate = path.join(absolute, name);
|
|
1227
|
+
const candidateStat = await fs.stat(candidate).catch(() => undefined);
|
|
1228
|
+
if (candidateStat?.isFile())
|
|
1229
|
+
return candidate;
|
|
1230
|
+
}
|
|
1231
|
+
return undefined;
|
|
1232
|
+
}
|
|
1233
|
+
async function writeHumanActionAnswer(options, body) {
|
|
1234
|
+
const code = textValue(body.code, '').trim();
|
|
1235
|
+
const answer = textValue(body.answer, '').trim();
|
|
1236
|
+
if (!code)
|
|
1237
|
+
return { ok: false, code, error: 'missing question code' };
|
|
1238
|
+
if (!answer)
|
|
1239
|
+
return { ok: false, code, error: 'missing answer' };
|
|
1240
|
+
if (answer.length > HUMAN_ACTION_ANSWER_MAX_BYTES)
|
|
1241
|
+
return { ok: false, code, error: 'answer is too large' };
|
|
1242
|
+
const answerPath = await humanActionAnswerLogPath(options);
|
|
1243
|
+
await fs.mkdir(path.dirname(answerPath), { recursive: true });
|
|
1244
|
+
const record = {
|
|
1245
|
+
type: 'human-action.answer',
|
|
1246
|
+
at: Date.now(),
|
|
1247
|
+
code,
|
|
1248
|
+
answer,
|
|
1249
|
+
source: 'frontier-loom-ui'
|
|
1250
|
+
};
|
|
1251
|
+
await fs.appendFile(answerPath, JSON.stringify(record) + '\n', 'utf8');
|
|
1252
|
+
notifyDashboardStreams();
|
|
1253
|
+
return { ok: true, code, answerPath };
|
|
1254
|
+
}
|
|
1255
|
+
function notifyDashboardStreams() {
|
|
1256
|
+
for (const listener of dashboardStreamListeners)
|
|
1257
|
+
listener();
|
|
1258
|
+
}
|
|
1259
|
+
async function readHumanActionAnswers(options) {
|
|
1260
|
+
const answerPath = await humanActionAnswerLogPath(options);
|
|
1261
|
+
const body = await fs.readFile(answerPath, 'utf8').catch(() => '');
|
|
1262
|
+
if (!body.trim())
|
|
1263
|
+
return [];
|
|
1264
|
+
const rows = [];
|
|
1265
|
+
for (const line of body.split(/\r?\n/)) {
|
|
1266
|
+
if (!line.trim())
|
|
1267
|
+
continue;
|
|
1268
|
+
try {
|
|
1269
|
+
const record = recordValue(JSON.parse(line));
|
|
1270
|
+
const code = textValue(record.code, '').trim();
|
|
1271
|
+
const answer = textValue(record.answer, '').trim();
|
|
1272
|
+
const at = Number(record.at);
|
|
1273
|
+
if (!code || !answer || !Number.isFinite(at))
|
|
1274
|
+
continue;
|
|
1275
|
+
rows.push({
|
|
1276
|
+
type: 'human-action.answer',
|
|
1277
|
+
at,
|
|
1278
|
+
code,
|
|
1279
|
+
answer,
|
|
1280
|
+
source: 'frontier-loom-ui'
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return rows
|
|
1288
|
+
.sort((left, right) => left.at - right.at)
|
|
1289
|
+
.slice(-200);
|
|
1290
|
+
}
|
|
1291
|
+
async function humanActionAnswerLogPath(options) {
|
|
1292
|
+
return path.join(await humanActionAnswerDir(options), 'human-action-answers.jsonl');
|
|
1293
|
+
}
|
|
1294
|
+
async function humanActionAnswerDir(options) {
|
|
1295
|
+
if (options.run) {
|
|
1296
|
+
const absolute = path.resolve(options.cwd, options.run);
|
|
1297
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
1298
|
+
if (stat?.isDirectory())
|
|
1299
|
+
return absolute;
|
|
1300
|
+
if (stat)
|
|
1301
|
+
return path.dirname(absolute);
|
|
1302
|
+
}
|
|
1303
|
+
if (options.collection) {
|
|
1304
|
+
const absolute = path.resolve(options.cwd, options.collection);
|
|
1305
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
1306
|
+
if (stat?.isDirectory())
|
|
1307
|
+
return absolute;
|
|
1308
|
+
if (stat)
|
|
1309
|
+
return path.dirname(absolute);
|
|
1310
|
+
}
|
|
1311
|
+
return path.join(options.cwd, 'agent-runs', 'loom-ui-human-actions');
|
|
1312
|
+
}
|
|
1313
|
+
async function readPatchFiles(options, patchPath) {
|
|
1314
|
+
const absolute = path.resolve(options.cwd, patchPath);
|
|
1315
|
+
if (!isPathInside(options.cwd, absolute))
|
|
1316
|
+
return [];
|
|
1317
|
+
const stat = await fs.stat(absolute).catch(() => undefined);
|
|
1318
|
+
if (!stat?.isFile() || stat.size > TASK_DETAIL_PATCH_MAX_BYTES)
|
|
1319
|
+
return [];
|
|
1320
|
+
const patch = await fs.readFile(absolute, 'utf8');
|
|
1321
|
+
return parseUnifiedPatchFiles(patch).slice(0, 40);
|
|
1322
|
+
}
|
|
1323
|
+
function parseUnifiedPatchFiles(patch) {
|
|
1324
|
+
const sections = patch.split(/\n(?=diff --git )/g).filter((section) => section.trim().length > 0);
|
|
1325
|
+
return sections.flatMap((section) => {
|
|
1326
|
+
const lines = section.split('\n');
|
|
1327
|
+
const pathLine = lines.find((line) => line.startsWith('+++ ')) ?? lines.find((line) => line.startsWith('diff --git '));
|
|
1328
|
+
const filePath = patchFilePath(pathLine ?? '');
|
|
1329
|
+
if (!filePath)
|
|
1330
|
+
return [];
|
|
1331
|
+
const additions = lines.filter((line) => line.startsWith('+') && !line.startsWith('+++')).length;
|
|
1332
|
+
const deletions = lines.filter((line) => line.startsWith('-') && !line.startsWith('---')).length;
|
|
1333
|
+
const truncated = section.length > TASK_DETAIL_FILE_DIFF_MAX_CHARS;
|
|
1334
|
+
return [{
|
|
1335
|
+
path: filePath,
|
|
1336
|
+
additions,
|
|
1337
|
+
deletions,
|
|
1338
|
+
diff: truncated ? `${section.slice(0, TASK_DETAIL_FILE_DIFF_MAX_CHARS)}\n... truncated ...` : section,
|
|
1339
|
+
language: languageForPath(filePath),
|
|
1340
|
+
artifactPath: filePath,
|
|
1341
|
+
hunks: parseUnifiedPatchHunks(truncated ? section.slice(0, TASK_DETAIL_FILE_DIFF_MAX_CHARS) : section),
|
|
1342
|
+
truncated
|
|
1343
|
+
}];
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
function parseUnifiedPatchHunks(section) {
|
|
1347
|
+
const hunks = [];
|
|
1348
|
+
let current = { header: 'File header', lines: [] };
|
|
1349
|
+
let oldLine = 0;
|
|
1350
|
+
let newLine = 0;
|
|
1351
|
+
for (const line of section.split('\n')) {
|
|
1352
|
+
const hunk = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/.exec(line);
|
|
1353
|
+
if (hunk) {
|
|
1354
|
+
if (current.lines.length)
|
|
1355
|
+
hunks.push(current);
|
|
1356
|
+
current = { header: line, lines: [{ kind: 'hunk', content: line }] };
|
|
1357
|
+
oldLine = Number(hunk[1]);
|
|
1358
|
+
newLine = Number(hunk[2]);
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
if (!current.lines.length && line.startsWith('diff --git '))
|
|
1362
|
+
current.header = line;
|
|
1363
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
1364
|
+
current.lines.push({ kind: 'add', newLine, content: line.slice(1) });
|
|
1365
|
+
newLine += 1;
|
|
1366
|
+
}
|
|
1367
|
+
else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
1368
|
+
current.lines.push({ kind: 'delete', oldLine, content: line.slice(1) });
|
|
1369
|
+
oldLine += 1;
|
|
1370
|
+
}
|
|
1371
|
+
else if (line.startsWith(' ')) {
|
|
1372
|
+
current.lines.push({ kind: 'context', oldLine, newLine, content: line.slice(1) });
|
|
1373
|
+
oldLine += 1;
|
|
1374
|
+
newLine += 1;
|
|
1375
|
+
}
|
|
1376
|
+
else {
|
|
1377
|
+
current.lines.push({ kind: line.startsWith('@@') ? 'hunk' : 'meta', content: line });
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (current.lines.length)
|
|
1381
|
+
hunks.push(current);
|
|
1382
|
+
return hunks;
|
|
1383
|
+
}
|
|
1384
|
+
function patchFilePath(line) {
|
|
1385
|
+
const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line);
|
|
1386
|
+
if (plus && plus[1] !== '/dev/null')
|
|
1387
|
+
return plus[1];
|
|
1388
|
+
const diff = /^diff --git\s+a\/(.+?)\s+b\/(.+)$/.exec(line);
|
|
1389
|
+
return diff?.[2] ?? '';
|
|
1390
|
+
}
|
|
1391
|
+
function artifactRecord(pathValue, label = pathValue) {
|
|
1392
|
+
return {
|
|
1393
|
+
path: pathValue,
|
|
1394
|
+
label: shortArtifactLabel(label)
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
function shortArtifactLabel(value) {
|
|
1398
|
+
const clean = value.replace(/\/+$/g, '');
|
|
1399
|
+
return clean.split(/[\\/]/g).filter(Boolean).pop() ?? clean;
|
|
1400
|
+
}
|
|
1401
|
+
function resolveRelativeArtifactPath(base, artifactPath) {
|
|
1402
|
+
if (!artifactPath || path.isAbsolute(artifactPath) || !base)
|
|
1403
|
+
return artifactPath;
|
|
1404
|
+
return path.join(base, artifactPath);
|
|
1405
|
+
}
|
|
1406
|
+
function languageForPath(filePath) {
|
|
1407
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
1408
|
+
if (['.ts', '.tsx', '.mts', '.cts'].includes(extension))
|
|
1409
|
+
return 'typescript';
|
|
1410
|
+
if (['.js', '.jsx', '.mjs', '.cjs'].includes(extension))
|
|
1411
|
+
return 'javascript';
|
|
1412
|
+
if (extension === '.json')
|
|
1413
|
+
return 'json';
|
|
1414
|
+
if (extension === '.css')
|
|
1415
|
+
return 'css';
|
|
1416
|
+
if (extension === '.html')
|
|
1417
|
+
return 'html';
|
|
1418
|
+
if (['.md', '.markdown'].includes(extension))
|
|
1419
|
+
return 'markdown';
|
|
1420
|
+
return 'text';
|
|
1421
|
+
}
|
|
1422
|
+
async function readArtifact(options, artifactPath) {
|
|
1423
|
+
const resolved = await resolveViewableArtifact(options, artifactPath);
|
|
1424
|
+
if (!resolved.ok)
|
|
1425
|
+
return { ok: false, path: artifactPath, label: shortArtifactLabel(artifactPath), error: resolved.error };
|
|
1426
|
+
const stat = await fs.stat(resolved.path).catch(() => undefined);
|
|
1427
|
+
if (!stat)
|
|
1428
|
+
return { ok: false, path: artifactPath, label: shortArtifactLabel(artifactPath), error: 'artifact not found' };
|
|
1429
|
+
if (stat.isDirectory()) {
|
|
1430
|
+
const entries = await artifactDirectoryEntries(resolved.path);
|
|
1431
|
+
return {
|
|
1432
|
+
ok: true,
|
|
1433
|
+
path: artifactPath,
|
|
1434
|
+
label: shortArtifactLabel(artifactPath),
|
|
1435
|
+
kind: 'directory',
|
|
1436
|
+
entries
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
if (!stat.isFile())
|
|
1440
|
+
return { ok: false, path: artifactPath, label: shortArtifactLabel(artifactPath), error: 'artifact is not a file' };
|
|
1441
|
+
const size = stat.size;
|
|
1442
|
+
const readBytes = Math.min(size, ARTIFACT_VIEW_MAX_BYTES);
|
|
1443
|
+
const handle = await fs.open(resolved.path, 'r');
|
|
1444
|
+
try {
|
|
1445
|
+
const buffer = Buffer.alloc(readBytes);
|
|
1446
|
+
await handle.read(buffer, 0, readBytes, 0);
|
|
1447
|
+
return {
|
|
1448
|
+
ok: true,
|
|
1449
|
+
path: artifactPath,
|
|
1450
|
+
label: shortArtifactLabel(artifactPath),
|
|
1451
|
+
kind: 'file',
|
|
1452
|
+
size,
|
|
1453
|
+
contentType: contentType(resolved.path),
|
|
1454
|
+
content: buffer.toString('utf8'),
|
|
1455
|
+
truncated: size > ARTIFACT_VIEW_MAX_BYTES
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
finally {
|
|
1459
|
+
await handle.close();
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
async function serveArtifactRaw(response, options, artifactPath) {
|
|
1463
|
+
const resolved = await resolveViewableArtifact(options, artifactPath);
|
|
1464
|
+
if (!resolved.ok) {
|
|
1465
|
+
writeJson(response, 404, { ok: false, error: resolved.error });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const stat = await fs.stat(resolved.path).catch(() => undefined);
|
|
1469
|
+
if (!stat) {
|
|
1470
|
+
writeJson(response, 404, { ok: false, error: 'artifact not found' });
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (stat.isDirectory()) {
|
|
1474
|
+
writeJson(response, 200, {
|
|
1475
|
+
ok: true,
|
|
1476
|
+
path: artifactPath,
|
|
1477
|
+
kind: 'directory',
|
|
1478
|
+
entries: await artifactDirectoryEntries(resolved.path)
|
|
1479
|
+
});
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
await serveFile(response, resolved.path, contentType(resolved.path));
|
|
1483
|
+
}
|
|
1484
|
+
async function revealArtifactInFileManager(options, artifactPath, dryRun) {
|
|
1485
|
+
const resolved = await resolveViewableArtifact(options, artifactPath);
|
|
1486
|
+
if (!resolved.ok)
|
|
1487
|
+
return { ok: false, path: artifactPath, error: resolved.error };
|
|
1488
|
+
const stat = await fs.stat(resolved.path).catch(() => undefined);
|
|
1489
|
+
if (!stat)
|
|
1490
|
+
return { ok: false, path: artifactPath, error: 'artifact not found' };
|
|
1491
|
+
const target = fileManagerTarget(resolved.path, stat.isDirectory());
|
|
1492
|
+
if (!target)
|
|
1493
|
+
return { ok: false, path: artifactPath, revealedPath: resolved.path, error: 'no supported file manager command for this platform' };
|
|
1494
|
+
const result = {
|
|
1495
|
+
ok: true,
|
|
1496
|
+
path: artifactPath,
|
|
1497
|
+
revealedPath: resolved.path,
|
|
1498
|
+
command: target.command,
|
|
1499
|
+
args: target.args,
|
|
1500
|
+
dryRun
|
|
1501
|
+
};
|
|
1502
|
+
if (dryRun)
|
|
1503
|
+
return result;
|
|
1504
|
+
await spawnDetached(target.command, target.args);
|
|
1505
|
+
return result;
|
|
1506
|
+
}
|
|
1507
|
+
function fileManagerTarget(filePath, isDirectory) {
|
|
1508
|
+
if (process.platform === 'darwin') {
|
|
1509
|
+
return isDirectory
|
|
1510
|
+
? { command: 'open', args: [filePath] }
|
|
1511
|
+
: { command: 'open', args: ['-R', filePath] };
|
|
1512
|
+
}
|
|
1513
|
+
if (process.platform === 'win32') {
|
|
1514
|
+
return isDirectory
|
|
1515
|
+
? { command: 'explorer.exe', args: [filePath] }
|
|
1516
|
+
: { command: 'explorer.exe', args: [`/select,${filePath}`] };
|
|
1517
|
+
}
|
|
1518
|
+
if (process.platform === 'linux' || process.platform === 'freebsd' || process.platform === 'openbsd') {
|
|
1519
|
+
return { command: 'xdg-open', args: [isDirectory ? filePath : path.dirname(filePath)] };
|
|
1520
|
+
}
|
|
1521
|
+
return undefined;
|
|
1522
|
+
}
|
|
1523
|
+
async function spawnDetached(command, args) {
|
|
1524
|
+
await new Promise((resolve, reject) => {
|
|
1525
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
1526
|
+
child.once('error', reject);
|
|
1527
|
+
child.once('spawn', resolve);
|
|
1528
|
+
child.unref();
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
async function artifactDirectoryEntries(root) {
|
|
1532
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
1533
|
+
const rows = [];
|
|
1534
|
+
for (const entry of entries.slice(0, ARTIFACT_DIRECTORY_MAX_ENTRIES)) {
|
|
1535
|
+
if (entry.name.startsWith('.'))
|
|
1536
|
+
continue;
|
|
1537
|
+
const entryPath = path.join(root, entry.name);
|
|
1538
|
+
const stat = await fs.stat(entryPath).catch(() => undefined);
|
|
1539
|
+
rows.push({
|
|
1540
|
+
name: entry.name,
|
|
1541
|
+
path: entryPath,
|
|
1542
|
+
kind: entry.isDirectory() ? 'directory' : 'file',
|
|
1543
|
+
...(stat?.isFile() ? { size: stat.size } : {})
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
return rows;
|
|
1547
|
+
}
|
|
1548
|
+
async function resolveViewableArtifact(options, artifactPath) {
|
|
1549
|
+
if (!artifactPath)
|
|
1550
|
+
return { ok: false, error: 'missing artifact path' };
|
|
1551
|
+
if (hasHiddenPathSegment(artifactPath))
|
|
1552
|
+
return { ok: false, error: 'hidden files are not viewable' };
|
|
1553
|
+
const roots = await dashboardArtifactRoots(options);
|
|
1554
|
+
const candidates = path.isAbsolute(artifactPath)
|
|
1555
|
+
? [path.resolve(artifactPath)]
|
|
1556
|
+
: roots.map((root) => path.resolve(root, artifactPath));
|
|
1557
|
+
for (const candidate of candidates) {
|
|
1558
|
+
if (hasHiddenPathSegment(path.relative(path.dirname(candidate), candidate)))
|
|
1559
|
+
continue;
|
|
1560
|
+
if (!roots.some((root) => isPathInside(root, candidate)))
|
|
1561
|
+
continue;
|
|
1562
|
+
const stat = await fs.stat(candidate).catch(() => undefined);
|
|
1563
|
+
if (stat)
|
|
1564
|
+
return { ok: true, path: candidate };
|
|
1565
|
+
}
|
|
1566
|
+
return { ok: false, error: 'artifact not found or outside configured roots' };
|
|
1567
|
+
}
|
|
1568
|
+
async function dashboardArtifactRoots(options) {
|
|
1569
|
+
const roots = [options.cwd];
|
|
1570
|
+
for (const input of [options.run, options.collection, options.continuation]) {
|
|
1571
|
+
if (!input)
|
|
1572
|
+
continue;
|
|
1573
|
+
const absolute = path.resolve(options.cwd, input);
|
|
1574
|
+
const stat = await fs.lstat(absolute).catch(() => undefined);
|
|
1575
|
+
if (!stat)
|
|
1576
|
+
continue;
|
|
1577
|
+
roots.push(stat.isDirectory() ? absolute : path.dirname(absolute));
|
|
1578
|
+
}
|
|
1579
|
+
return uniquePaths(roots.map((root) => path.resolve(root)));
|
|
1580
|
+
}
|
|
1581
|
+
function hasHiddenPathSegment(value) {
|
|
1582
|
+
return value.split(/[\\/]/g).some((segment) => segment.startsWith('.') && segment.length > 1);
|
|
1583
|
+
}
|
|
1584
|
+
function recordArray(value) {
|
|
1585
|
+
return Array.isArray(value) ? value.map(recordValue).filter((entry) => Object.keys(entry).length > 0) : [];
|
|
1586
|
+
}
|
|
1587
|
+
function recordValue(value) {
|
|
1588
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
1589
|
+
}
|
|
1590
|
+
function stringArray(value) {
|
|
1591
|
+
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
|
1592
|
+
}
|
|
1593
|
+
function textValue(value, fallback) {
|
|
1594
|
+
if (value === undefined || value === null || value === '')
|
|
1595
|
+
return fallback;
|
|
1596
|
+
return String(value);
|
|
1597
|
+
}
|
|
1598
|
+
function numberValue(value) {
|
|
1599
|
+
const number = Number(value ?? 0);
|
|
1600
|
+
return Number.isFinite(number) ? number : 0;
|
|
1601
|
+
}
|
|
1602
|
+
function normalized(value) {
|
|
1603
|
+
return textValue(value, '').trim().toLowerCase();
|
|
1604
|
+
}
|
|
1605
|
+
async function fileExists(file) {
|
|
1606
|
+
try {
|
|
1607
|
+
await fs.access(file);
|
|
1608
|
+
return true;
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
function isPathInside(root, value) {
|
|
1615
|
+
const relative = path.relative(path.resolve(root), path.resolve(value));
|
|
1616
|
+
return relative === '' || Boolean(relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
1617
|
+
}
|
|
1618
|
+
async function readHealth(options) {
|
|
1619
|
+
const sources = {
|
|
1620
|
+
run: await inspectHealthSource(options.cwd, options.run, 'swarm-results.json'),
|
|
1621
|
+
collection: await inspectHealthSource(options.cwd, options.collection, 'collection.json'),
|
|
1622
|
+
continuation: await inspectHealthSource(options.cwd, options.continuation, 'continuation.json')
|
|
1623
|
+
};
|
|
1624
|
+
const configuredSources = Object.values(sources).filter((source) => source.configured);
|
|
1625
|
+
return {
|
|
1626
|
+
ok: configuredSources.every((source) => source.status === 'ready'),
|
|
1627
|
+
service: 'frontier-loom-ui',
|
|
1628
|
+
generatedAt: Date.now(),
|
|
1629
|
+
cwd: options.cwd,
|
|
1630
|
+
sources
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
async function inspectHealthSource(cwd, input, defaultFile) {
|
|
1634
|
+
if (!input)
|
|
1635
|
+
return { configured: false, status: 'not-configured' };
|
|
1636
|
+
const absolute = path.resolve(cwd, input);
|
|
1637
|
+
const stat = await fs.lstat(absolute).catch((error) => ({ error }));
|
|
1638
|
+
if (!stat || 'error' in stat) {
|
|
1639
|
+
return {
|
|
1640
|
+
configured: true,
|
|
1641
|
+
status: 'missing',
|
|
1642
|
+
input,
|
|
1643
|
+
file: absolute,
|
|
1644
|
+
error: errorMessage(stat && 'error' in stat ? stat.error : undefined)
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
const file = stat.isDirectory() ? path.join(absolute, defaultFile) : absolute;
|
|
1648
|
+
try {
|
|
1649
|
+
const fileStat = await fs.stat(file);
|
|
1650
|
+
if (fileStat.size <= HEALTH_JSON_PARSE_MAX_BYTES) {
|
|
1651
|
+
const text = await fs.readFile(file, 'utf8');
|
|
1652
|
+
JSON.parse(text);
|
|
1653
|
+
}
|
|
1654
|
+
return {
|
|
1655
|
+
configured: true,
|
|
1656
|
+
status: 'ready',
|
|
1657
|
+
input,
|
|
1658
|
+
file,
|
|
1659
|
+
dir: stat.isDirectory() ? absolute : path.dirname(file)
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
catch (error) {
|
|
1663
|
+
return {
|
|
1664
|
+
configured: true,
|
|
1665
|
+
status: error.code === 'ENOENT' ? 'missing' : 'invalid',
|
|
1666
|
+
input,
|
|
1667
|
+
file,
|
|
1668
|
+
dir: stat.isDirectory() ? absolute : path.dirname(file),
|
|
1669
|
+
error: errorMessage(error)
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
function errorMessage(error) {
|
|
1674
|
+
return error instanceof Error ? error.message : error === undefined ? undefined : String(error);
|
|
1675
|
+
}
|
|
1676
|
+
function staticFile(root, pathname) {
|
|
1677
|
+
const clean = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
|
|
1678
|
+
const resolved = path.resolve(root, clean);
|
|
1679
|
+
if (!resolved.startsWith(root + path.sep) && resolved !== root)
|
|
1680
|
+
return path.join(root, 'index.html');
|
|
1681
|
+
return resolved;
|
|
1682
|
+
}
|
|
1683
|
+
async function serveFile(response, file, type) {
|
|
1684
|
+
try {
|
|
1685
|
+
const body = await fs.readFile(file);
|
|
1686
|
+
response.writeHead(200, responseHeaders(type));
|
|
1687
|
+
response.end(body);
|
|
1688
|
+
}
|
|
1689
|
+
catch {
|
|
1690
|
+
writeJson(response, 404, { ok: false, error: 'not found' });
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
function writeJson(response, status, value) {
|
|
1694
|
+
response.writeHead(status, responseHeaders('application/json; charset=utf-8'));
|
|
1695
|
+
response.end(JSON.stringify(value, null, 2));
|
|
1696
|
+
}
|
|
1697
|
+
async function readJsonBody(request, maxBytes) {
|
|
1698
|
+
let bytes = 0;
|
|
1699
|
+
const chunks = [];
|
|
1700
|
+
for await (const chunk of request) {
|
|
1701
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1702
|
+
bytes += buffer.byteLength;
|
|
1703
|
+
if (bytes > maxBytes)
|
|
1704
|
+
throw new Error('request body too large');
|
|
1705
|
+
chunks.push(buffer);
|
|
1706
|
+
}
|
|
1707
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
1708
|
+
if (!text)
|
|
1709
|
+
return {};
|
|
1710
|
+
return JSON.parse(text);
|
|
1711
|
+
}
|
|
1712
|
+
function responseHeaders(contentType) {
|
|
1713
|
+
return {
|
|
1714
|
+
'content-type': contentType,
|
|
1715
|
+
'cache-control': 'no-store, max-age=0',
|
|
1716
|
+
pragma: 'no-cache'
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
function resolveFrontierDomRuntime() {
|
|
1720
|
+
return path.join(packageDir, '..', 'node_modules', '@shapeshift-labs', 'frontier-dom', 'dist', 'jsx-runtime.js');
|
|
1721
|
+
}
|
|
1722
|
+
function contentType(file) {
|
|
1723
|
+
if (file.endsWith('.js'))
|
|
1724
|
+
return 'application/javascript; charset=utf-8';
|
|
1725
|
+
if (file.endsWith('.css'))
|
|
1726
|
+
return 'text/css; charset=utf-8';
|
|
1727
|
+
if (file.endsWith('.html'))
|
|
1728
|
+
return 'text/html; charset=utf-8';
|
|
1729
|
+
return 'application/octet-stream';
|
|
1730
|
+
}
|
|
1731
|
+
//# sourceMappingURL=server.js.map
|