great-cto 3.0.0 → 3.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/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/beads.mjs +98 -1
- package/board/packages/board/lib/fleet.mjs +16 -2
- package/board/packages/board/lib/metrics.mjs +8 -3
- package/board/packages/board/lib/routes.mjs +50 -0
- package/board/packages/board/lib/watchers.mjs +5 -2
- package/board/packages/board/public/index.html +290 -14
- package/board/packages/board/server.mjs +24 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "great_cto",
|
|
3
3
|
"id": "great_cto",
|
|
4
4
|
"description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.1.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
-
import { spawnSync } from 'child_process';
|
|
4
|
+
import { spawnSync, spawn } from 'child_process';
|
|
5
5
|
import { readSafe } from './util.mjs';
|
|
6
6
|
import { bdCache } from './state.mjs';
|
|
7
7
|
import { log } from './log.mjs';
|
|
@@ -38,11 +38,35 @@ import { log } from './log.mjs';
|
|
|
38
38
|
// because each request arrived just after the entry expired.
|
|
39
39
|
const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Drop a directory's entry so the next read is guaranteed fresh.
|
|
43
|
+
*
|
|
44
|
+
* For a WRITE the board just performed. The operator clicked approve and is
|
|
45
|
+
* waiting for the result; paying for the read is correct there, and serving the
|
|
46
|
+
* pre-approval state would be a lie about what they just did.
|
|
47
|
+
*/
|
|
41
48
|
function bdCacheInvalidate(cwd) {
|
|
42
49
|
clearSelfTouch(cwd);
|
|
43
50
|
bdCache.delete(cwd);
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Mark a directory's entry stale WITHOUT dropping it.
|
|
55
|
+
*
|
|
56
|
+
* For a change that arrived from outside — a file event under a project someone
|
|
57
|
+
* is watching. Deleting made the next reader, who asked for something unrelated,
|
|
58
|
+
* pay 623 ms warm and 6.8 s cold for a write they did not make. An entry marked
|
|
59
|
+
* stale is still served, and the refresh happens off the event loop.
|
|
60
|
+
*
|
|
61
|
+
* The difference is intent, not mechanism: a write we performed must be visible
|
|
62
|
+
* to whoever asked for it; a change we merely noticed must not stop the board.
|
|
63
|
+
*/
|
|
64
|
+
function bdCacheStale(cwd) {
|
|
65
|
+
clearSelfTouch(cwd);
|
|
66
|
+
const cached = bdCache.get(cwd);
|
|
67
|
+
if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
|
|
68
|
+
}
|
|
69
|
+
|
|
46
70
|
// ── bd binary resolution (BH-32) ────────────────────────────────────────────
|
|
47
71
|
// A board launched from a GUI / launchd / a login shell that didn't source the
|
|
48
72
|
// usual profile often has a minimal PATH (`/usr/bin:/bin`) that omits where
|
|
@@ -251,6 +275,60 @@ function clearSelfTouch(cwd) { lastBdRunAt.delete(cwd); }
|
|
|
251
275
|
*/
|
|
252
276
|
const EMPTY_TTL_MS = Number(process.env.GREAT_CTO_BD_EMPTY_TTL_MS || 5000);
|
|
253
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Directories with a background refresh already in flight.
|
|
280
|
+
*
|
|
281
|
+
* Without this, ten requests arriving while one refresh runs start ten more.
|
|
282
|
+
*/
|
|
283
|
+
const refreshing = new Set();
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Refresh a directory's entry WITHOUT holding the event loop.
|
|
287
|
+
*
|
|
288
|
+
* `spawnSync` is what made this board unanswerable: `bd list` costs seconds and
|
|
289
|
+
* blocks everything for the whole of it — /api/version, one readdirSync,
|
|
290
|
+
* measured at 1-10 s because it was queued behind a task read. Warming at boot
|
|
291
|
+
* moved the first stall out of sight; this removes the rest.
|
|
292
|
+
*
|
|
293
|
+
* Nothing awaits it. It exists to make the NEXT read fast, and a caller that
|
|
294
|
+
* needed the new data would have had to block for it anyway.
|
|
295
|
+
*/
|
|
296
|
+
function bdRefreshAsync(cwd) {
|
|
297
|
+
if (refreshing.has(cwd)) return;
|
|
298
|
+
refreshing.add(cwd);
|
|
299
|
+
lastBdRunAt.set(cwd, Date.now());
|
|
300
|
+
let out = '';
|
|
301
|
+
try {
|
|
302
|
+
const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv() });
|
|
303
|
+
child.stdout?.on('data', (d) => { out += d; });
|
|
304
|
+
child.on('error', (e) => {
|
|
305
|
+
refreshing.delete(cwd);
|
|
306
|
+
bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
|
|
307
|
+
});
|
|
308
|
+
child.on('close', (code) => {
|
|
309
|
+
refreshing.delete(cwd);
|
|
310
|
+
lastBdRunAt.set(cwd, Date.now());
|
|
311
|
+
if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); return; }
|
|
312
|
+
try {
|
|
313
|
+
const parsed = JSON.parse(out || '[]');
|
|
314
|
+
// Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
|
|
315
|
+
// and a non-array rendered as no tasks is the silent zero again.
|
|
316
|
+
if (!Array.isArray(parsed)) {
|
|
317
|
+
bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
bdFailures.delete(cwd);
|
|
321
|
+
bdCache.set(cwd, { ts: Date.now(), data: parsed });
|
|
322
|
+
} catch (e) {
|
|
323
|
+
bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
} catch (e) {
|
|
327
|
+
refreshing.delete(cwd);
|
|
328
|
+
bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
254
332
|
function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
|
|
255
333
|
const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
|
|
256
334
|
const cached = bdCache.get(cwd);
|
|
@@ -258,6 +336,24 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
|
|
|
258
336
|
? Math.min(maxAge, EMPTY_TTL_MS)
|
|
259
337
|
: maxAge;
|
|
260
338
|
if (cached && Date.now() - cached.ts < ttl) return cached.data;
|
|
339
|
+
|
|
340
|
+
// Stale-while-revalidate. An entry that exists is served immediately, however
|
|
341
|
+
// old, and refreshed off the event loop. Only a directory with NO entry at all
|
|
342
|
+
// blocks — which after the boot warm-up means a project nobody has opened yet,
|
|
343
|
+
// once.
|
|
344
|
+
//
|
|
345
|
+
// The alternative was making this async and every caller with it: getTasks,
|
|
346
|
+
// getInbox, getPipeline, the metrics readers, the SSE broadcast, the alert
|
|
347
|
+
// sweeps. That refactor is the correct end state and is not what a board
|
|
348
|
+
// hanging today needs.
|
|
349
|
+
//
|
|
350
|
+
// The injected `runner` is how the tests drive this. When one is supplied the
|
|
351
|
+
// sync path is kept, so a test that stubs `bd` still observes the call it
|
|
352
|
+
// stubbed rather than a background spawn it cannot see.
|
|
353
|
+
if (cached && runner === bd) {
|
|
354
|
+
bdRefreshAsync(cwd);
|
|
355
|
+
return cached.data;
|
|
356
|
+
}
|
|
261
357
|
try {
|
|
262
358
|
lastBdRunAt.set(cwd, Date.now());
|
|
263
359
|
const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
|
|
@@ -635,6 +731,7 @@ function detectAgent(task) {
|
|
|
635
731
|
|
|
636
732
|
export {
|
|
637
733
|
bdCacheInvalidate,
|
|
734
|
+
bdCacheStale,
|
|
638
735
|
BD_CACHE_TTL_MS,
|
|
639
736
|
SWEEP_MAX_AGE_MS,
|
|
640
737
|
EMPTY_TTL_MS,
|
|
@@ -203,7 +203,19 @@ function getAgentsFleet(projectCwd) {
|
|
|
203
203
|
const active30d = agents.filter(a => a.runs_30d > 0 && !a.retired).length;
|
|
204
204
|
const retireCandidates = agents.filter(a => a.runs_30d === 0 && !a.retired).length;
|
|
205
205
|
const failing = agents.filter(a => a.health === 'failing' && !a.retired).length;
|
|
206
|
-
|
|
206
|
+
// MEASURED spend, or none. This tile reported the estimate — verdict count
|
|
207
|
+
// times a hardcoded rate — under the label "LLM SPEND 30D", while the metrics
|
|
208
|
+
// page reported its own estimate, derived from TASKS, under the same words.
|
|
209
|
+
// The two disagreed by more than twofold ($3.90 against $1.65) and neither
|
|
210
|
+
// said it was estimating, so the board contradicted itself and sounded certain
|
|
211
|
+
// doing it.
|
|
212
|
+
//
|
|
213
|
+
// The estimate stays available per agent as `llm_usd_30d_est`, named as an
|
|
214
|
+
// estimate. The fleet total claims only what verdicts actually recorded, and
|
|
215
|
+
// is null when they recorded nothing — the same rule the metrics tile, the
|
|
216
|
+
// portfolio and the budgets all follow now.
|
|
217
|
+
const totalReal30d = agents.reduce((s, a) => s + (a.llm_usd_30d_real || 0), 0);
|
|
218
|
+
const measuredFor = agents.filter((a) => a.llm_usd_30d_real != null).length;
|
|
207
219
|
|
|
208
220
|
return {
|
|
209
221
|
agents,
|
|
@@ -213,7 +225,9 @@ function getAgentsFleet(projectCwd) {
|
|
|
213
225
|
active_30d: active30d,
|
|
214
226
|
retire_candidates: retireCandidates,
|
|
215
227
|
failing_7d: failing,
|
|
216
|
-
llm_usd_30d: Math.round(
|
|
228
|
+
llm_usd_30d: measuredFor ? Math.round(totalReal30d * 100) / 100 : null,
|
|
229
|
+
llm_usd_30d_measured_for: measuredFor,
|
|
230
|
+
llm_usd_30d_agents_with_runs: agents.filter((a) => a.runs_30d > 0).length,
|
|
217
231
|
},
|
|
218
232
|
};
|
|
219
233
|
}
|
|
@@ -160,7 +160,12 @@ function getMetrics(cwd = process.cwd(), days = 30) {
|
|
|
160
160
|
// Without this, "AI spend" stayed at lifetime $93 even when period=7D
|
|
161
161
|
// showed only 12 tasks worth ~$0.30 — making savings ratios nonsensical.
|
|
162
162
|
for (const v of verdicts) {
|
|
163
|
-
|
|
163
|
+
// A RECORDED zero is not a measurement. `log-verdict.sh` wrote `cost_usd: 0`
|
|
164
|
+
// whenever no cost was passed, for this repository's whole history, so
|
|
165
|
+
// `!= null` counted every one of them as a measured run costing nothing —
|
|
166
|
+
// and the board reported "$0.00 AI spend" over projects with dozens of
|
|
167
|
+
// agent runs. Same defect as the fleet's, on a second screen.
|
|
168
|
+
if (v.cost_usd == null || v.cost_usd === 0) continue;
|
|
164
169
|
if (!agentCostMap[v.agent]) continue;
|
|
165
170
|
if (v.ts && (now - new Date(v.ts).getTime()) > costWindowMs) continue;
|
|
166
171
|
agentCostMap[v.agent].real_llm_usd += v.cost_usd;
|
|
@@ -195,7 +200,7 @@ function getMetrics(cwd = process.cwd(), days = 30) {
|
|
|
195
200
|
const taskHumanTotal = agentsCost.reduce((s, a) => s + a.human_usd, 0);
|
|
196
201
|
// Filter verdicts to the same window for consistent total
|
|
197
202
|
const verdictLlmTotal = verdicts.reduce((s, v) => {
|
|
198
|
-
if (v.cost_usd == null) return s;
|
|
203
|
+
if (v.cost_usd == null || v.cost_usd === 0) return s;
|
|
199
204
|
if (v.ts && (now - new Date(v.ts).getTime()) > costWindowMs) return s;
|
|
200
205
|
return s + v.cost_usd;
|
|
201
206
|
}, 0);
|
|
@@ -206,7 +211,7 @@ function getMetrics(cwd = process.cwd(), days = 30) {
|
|
|
206
211
|
// Bar: enough windowed done-tasks carry a real verdict cost (coverage ≥ 50%,
|
|
207
212
|
// min 3), and the measured total is a real spend (≥ 1¢, not a synthetic $0).
|
|
208
213
|
const doneInWindowCount = done.filter(t => t.closed_at && (now - new Date(t.closed_at).getTime()) <= costWindowMs).length;
|
|
209
|
-
const verdictsWithCost = verdicts.filter(v => v.cost_usd != null && (!v.ts || (now - new Date(v.ts).getTime()) <= costWindowMs)).length;
|
|
214
|
+
const verdictsWithCost = verdicts.filter(v => v.cost_usd != null && v.cost_usd !== 0 && (!v.ts || (now - new Date(v.ts).getTime()) <= costWindowMs)).length;
|
|
210
215
|
const measuredTrustworthy = verdictLlmTotal >= 0.01
|
|
211
216
|
&& verdictsWithCost >= Math.max(3, Math.ceil(0.5 * doneInWindowCount));
|
|
212
217
|
|
|
@@ -1468,6 +1468,56 @@ async function dispatch(req, res, url, cwd) {
|
|
|
1468
1468
|
return true;
|
|
1469
1469
|
}
|
|
1470
1470
|
|
|
1471
|
+
// Search INSIDE documents, not only their titles.
|
|
1472
|
+
//
|
|
1473
|
+
// The docs list has always matched on filename and heading. A hundred and
|
|
1474
|
+
// eighty-seven documents whose contents cannot be searched are a directory
|
|
1475
|
+
// listing, and the thing an operator actually wants — "where did we write down
|
|
1476
|
+
// the reason for X" — was answerable only by leaving the board.
|
|
1477
|
+
//
|
|
1478
|
+
// GET /api/docs/search?q=... -> { q, matched, scanned, results[] }
|
|
1479
|
+
if (pathname === '/api/docs/search' && req.method === 'GET') {
|
|
1480
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
1481
|
+
const q = (url.searchParams.get('q') || '').trim();
|
|
1482
|
+
if (q.length < 2) {
|
|
1483
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1484
|
+
res.end(JSON.stringify({ error: 'query must be at least 2 characters' }));
|
|
1485
|
+
return true;
|
|
1486
|
+
}
|
|
1487
|
+
try {
|
|
1488
|
+
const { listDocs } = await import('./docs.mjs');
|
|
1489
|
+
const all = listDocs(c);
|
|
1490
|
+
const needle = q.toLowerCase();
|
|
1491
|
+
const results = [];
|
|
1492
|
+
let scanned = 0, unreadable = 0;
|
|
1493
|
+
for (const g of (all.groups || [])) {
|
|
1494
|
+
for (const d of (g.docs || [])) {
|
|
1495
|
+
if (results.length >= 60) break;
|
|
1496
|
+
let text;
|
|
1497
|
+
try { text = fs.readFileSync(path.join(c, d.path), 'utf8'); scanned++; }
|
|
1498
|
+
catch { unreadable++; continue; } // counted, never silently skipped
|
|
1499
|
+
const lines = text.split('\n');
|
|
1500
|
+
const hits = [];
|
|
1501
|
+
for (let i = 0; i < lines.length && hits.length < 3; i++) {
|
|
1502
|
+
if (lines[i].toLowerCase().includes(needle)) {
|
|
1503
|
+
hits.push({ line: i + 1, text: lines[i].trim().slice(0, 160) });
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
if (hits.length) results.push({ path: d.path, title: d.title || d.name, group: g.label, hits });
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1510
|
+
// `scanned` and `unreadable` travel with the answer: "no results" over
|
|
1511
|
+
// 187 files and "no results" over 3 that could be opened are different
|
|
1512
|
+
// facts, and only one of them means the phrase is not there.
|
|
1513
|
+
res.end(JSON.stringify({ q, matched: results.length, scanned, unreadable, results }));
|
|
1514
|
+
} catch (e) {
|
|
1515
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1516
|
+
res.end(JSON.stringify({ error: String(e.message || e) }));
|
|
1517
|
+
}
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1471
1521
|
// API requests get a JSON 404 so frontends can JSON.parse() the response
|
|
1472
1522
|
// without crashing. Static-file 404s stay plain text.
|
|
1473
1523
|
if (pathname.startsWith('/api/')) {
|
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { GREAT_CTO_DIR } from './config.mjs';
|
|
4
4
|
import { sseClients } from './state.mjs';
|
|
5
5
|
import { listProjects } from './projects.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { bdCacheStale, getTasks, isSelfInflictedTouch } from './beads.mjs';
|
|
7
7
|
import { getPipeline, getInbox } from './data-readers.mjs';
|
|
8
8
|
|
|
9
9
|
// ── File watcher ───────────────────────────────────────────────────────────────
|
|
@@ -48,7 +48,10 @@ function watchBeads() {
|
|
|
48
48
|
// broadcastTasks itself; a write from outside is not a self-touch and lands
|
|
49
49
|
// in the branch below.
|
|
50
50
|
if (isSelfInflictedTouch(dir)) return;
|
|
51
|
-
|
|
51
|
+
// Stale, not deleted. A change we NOTICED must not make the next reader pay
|
|
52
|
+
// for it — they asked for something else. The broadcast below still reads
|
|
53
|
+
// fresh, because a stale entry refreshes on read.
|
|
54
|
+
bdCacheStale(dir);
|
|
52
55
|
for (const res of sseClients) {
|
|
53
56
|
if (res._gctoCwd === dir) {
|
|
54
57
|
try {
|
|
@@ -1640,6 +1640,44 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1640
1640
|
display: flex; gap: 4px; align-items: center;
|
|
1641
1641
|
}
|
|
1642
1642
|
.inbox-row .actions .gate-btn { height: 26px; padding: 0 10px; font-size: 11px; }
|
|
1643
|
+
.budgets-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
1644
|
+
.budgets-table th {
|
|
1645
|
+
text-align: left; font-family: var(--mono); font-size: 10px;
|
|
1646
|
+
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text2);
|
|
1647
|
+
padding: 0 12px 8px 0; border-bottom: 1px solid var(--border);
|
|
1648
|
+
}
|
|
1649
|
+
.budgets-table td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
1650
|
+
.budgets-table .bt-agent { font-family: var(--mono); }
|
|
1651
|
+
.budgets-table .bt-cap { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
1652
|
+
.budgets-table .bt-actions { text-align: right; white-space: nowrap; }
|
|
1653
|
+
.budgets-table .bt-actions .ab-btn + .ab-btn { margin-left: 6px; }
|
|
1654
|
+
.budgets-empty {
|
|
1655
|
+
padding: 16px; border: 1px dashed var(--border-strong); border-radius: 8px;
|
|
1656
|
+
color: var(--text2); font-size: 13px;
|
|
1657
|
+
}
|
|
1658
|
+
.budgets-uncapped { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
1659
|
+
.docs-search { display: flex; align-items: center; gap: 8px; margin: 10px 0 16px; }
|
|
1660
|
+
.docs-search input {
|
|
1661
|
+
flex: 1 1 auto; min-width: 0; max-width: 460px;
|
|
1662
|
+
height: 32px; padding: 0 10px;
|
|
1663
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
1664
|
+
background: var(--bg-card); color: var(--text); font: inherit; font-size: 13px;
|
|
1665
|
+
}
|
|
1666
|
+
.docs-search input:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
|
|
1667
|
+
.docs-search-hint { font-family: var(--mono); font-size: 10px; color: var(--text3); }
|
|
1668
|
+
.docs-search-meta { font-size: 12px; color: var(--text2); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
1669
|
+
.docs-hit { padding: 10px 0; border-top: 1px solid var(--border); }
|
|
1670
|
+
.docs-hit-line { display: flex; gap: 10px; font-family: var(--mono); font-size: 11px; color: var(--text2); margin-top: 4px; }
|
|
1671
|
+
.docs-hit-no { color: var(--text3); min-width: 34px; text-align: right; }
|
|
1672
|
+
.col-more {
|
|
1673
|
+
display: block; width: 100%;
|
|
1674
|
+
margin-top: 6px; padding: 8px;
|
|
1675
|
+
font-family: var(--mono); font-size: 11px;
|
|
1676
|
+
border: 1px dashed var(--border-strong); border-radius: 8px;
|
|
1677
|
+
background: transparent; color: var(--text2);
|
|
1678
|
+
}
|
|
1679
|
+
.col-more:hover { border-style: solid; color: var(--text); }
|
|
1680
|
+
.col-more:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
|
|
1643
1681
|
.inbox-empty {
|
|
1644
1682
|
padding: 14px 16px;
|
|
1645
1683
|
background: var(--bg-muted);
|
|
@@ -2532,6 +2570,11 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2532
2570
|
<span>Activity</span>
|
|
2533
2571
|
<span class="count" id="nav-logs-count">0</span>
|
|
2534
2572
|
</div>
|
|
2573
|
+
<div class="nav-item" data-tab="budgets" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('budgets', this)">
|
|
2574
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 1v22 M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
|
2575
|
+
<span>Budgets</span>
|
|
2576
|
+
<span class="count" id="nav-budget-count"></span>
|
|
2577
|
+
</div>
|
|
2535
2578
|
<div class="nav-item" data-tab="notifications" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('notifications', this); loadNotifHistory()">
|
|
2536
2579
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9 M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
|
2537
2580
|
<span>Notifications</span>
|
|
@@ -2712,6 +2755,14 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2712
2755
|
<div class="mp-hero" id="mp-hero"></div>
|
|
2713
2756
|
<div class="mp-secondary" id="mp-secondary"></div>
|
|
2714
2757
|
|
|
2758
|
+
<!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
|
|
2759
|
+
drill-in. A control the operator could not find is a control they do
|
|
2760
|
+
not have — and it was invisible on top of that, hidden whenever a
|
|
2761
|
+
project had declared no caps, which is exactly the project that
|
|
2762
|
+
needs the button to declare one. -->
|
|
2763
|
+
<div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
|
|
2764
|
+
<div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:12px;margin-bottom:4px;"></div>
|
|
2765
|
+
|
|
2715
2766
|
<div class="mp-section-label" id="mp-cost-label" style="margin-top:24px">Cost · last 30 days</div>
|
|
2716
2767
|
<div class="mp-card cost-panel" id="cost-panel">
|
|
2717
2768
|
<div class="cost-empty">Loading…</div>
|
|
@@ -2754,11 +2805,6 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2754
2805
|
|
|
2755
2806
|
<!-- §3.1 Fleet controls -->
|
|
2756
2807
|
<section aria-label="Fleet controls" style="margin-top:20px">
|
|
2757
|
-
<!-- Agent budgets (Paperclip cascading budget pattern) -->
|
|
2758
|
-
<div id="agent-budgets" style="display:none;background:var(--bg-muted);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px;">
|
|
2759
|
-
<!-- populated by renderAgentBudgets() from /api/heartbeat -->
|
|
2760
|
-
</div>
|
|
2761
|
-
|
|
2762
2808
|
<div class="mp-card fleet-controls">
|
|
2763
2809
|
<div class="fleet-search">
|
|
2764
2810
|
<input type="search" id="fleet-search" placeholder="Filter agents (press / to focus)" aria-label="Filter agents by slug" />
|
|
@@ -2838,8 +2884,29 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2838
2884
|
<div id="sessions-body" class="muted">Loading…</div>
|
|
2839
2885
|
</div>
|
|
2840
2886
|
|
|
2887
|
+
<div class="panel" id="panel-budgets" role="tabpanel">
|
|
2888
|
+
<div class="metrics-page">
|
|
2889
|
+
<h1>Agent budgets</h1>
|
|
2890
|
+
<p class="muted" style="max-width:70ch;margin:6px 0 20px">
|
|
2891
|
+
A cap on what one agent may spend. When measured spend passes it, the pipeline
|
|
2892
|
+
refuses to dispatch that agent and says so — it does not fail quietly.
|
|
2893
|
+
Spend is measured from verdicts that carry a cost; while none does, the cap
|
|
2894
|
+
cannot fire and says that too. An estimate never refuses.
|
|
2895
|
+
</p>
|
|
2896
|
+
<div id="budgets-page-body">loading…</div>
|
|
2897
|
+
</div>
|
|
2898
|
+
</div>
|
|
2899
|
+
|
|
2841
2900
|
<div class="panel" id="panel-docs">
|
|
2842
2901
|
<h1>Docs</h1>
|
|
2902
|
+
<div class="docs-search">
|
|
2903
|
+
<input type="search" id="docs-q" placeholder="Search inside documents…"
|
|
2904
|
+
aria-label="Search inside documents"
|
|
2905
|
+
onkeydown="if(event.key==='Enter'){event.preventDefault();searchDocs();}else if(event.key==='Escape'){this.value='';clearDocsSearch();}" />
|
|
2906
|
+
<button type="button" class="ab-btn" onclick="searchDocs()">Search</button>
|
|
2907
|
+
<span class="docs-search-hint"><kbd>Enter</kbd> search · <kbd>Esc</kbd> clear</span>
|
|
2908
|
+
</div>
|
|
2909
|
+
<div id="docs-results" style="display:none"></div>
|
|
2843
2910
|
<div id="docs-body" class="muted">loading…</div>
|
|
2844
2911
|
</div>
|
|
2845
2912
|
|
|
@@ -3410,9 +3477,18 @@ function renderAgentBudgets(budgets, meta = {}) {
|
|
|
3410
3477
|
if (!el) return;
|
|
3411
3478
|
const entries = Object.entries(budgets || {});
|
|
3412
3479
|
const malformed = meta.budgets_malformed || [];
|
|
3413
|
-
if (entries.length === 0 && !malformed.length) { el.style.display = 'none'; return; }
|
|
3414
3480
|
el.style.display = 'block';
|
|
3415
3481
|
|
|
3482
|
+
// Shown even with nothing declared. Hiding it meant the only project that
|
|
3483
|
+
// needed the "add a budget" button — one with no budgets — was the only
|
|
3484
|
+
// project that never saw it.
|
|
3485
|
+
if (entries.length === 0 && !malformed.length) {
|
|
3486
|
+
el.innerHTML = '<div class="ab-title">Agent budgets · <code>PROJECT.md</code></div>'
|
|
3487
|
+
+ '<div class="ab-spent">No caps declared — every agent dispatches regardless of what it has spent.</div>'
|
|
3488
|
+
+ '<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>';
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3416
3492
|
// Judgements the fleet already computed, keyed by slug. Absent while the
|
|
3417
3493
|
// fleet is still loading — the row then shows the cap and says the state is
|
|
3418
3494
|
// not known yet, rather than guessing one.
|
|
@@ -3450,6 +3526,95 @@ function renderAgentBudgets(budgets, meta = {}) {
|
|
|
3450
3526
|
+ `<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>`;
|
|
3451
3527
|
}
|
|
3452
3528
|
|
|
3529
|
+
/* The Budgets screen.
|
|
3530
|
+
*
|
|
3531
|
+
* The panel on the agents tab was only reachable two clicks in, and hidden
|
|
3532
|
+
* entirely on a project with no caps declared — so on the project the operator
|
|
3533
|
+
* actually opened, the feature did not exist. A limit you can only see after
|
|
3534
|
+
* hand-editing the config it reads is documentation, not a feature.
|
|
3535
|
+
*
|
|
3536
|
+
* This screen shows the same judgements the dispatcher refuses with, plus every
|
|
3537
|
+
* agent that has NO cap, because "which agents are uncapped" is the question you
|
|
3538
|
+
* arrive with when you come here at all.
|
|
3539
|
+
*/
|
|
3540
|
+
async function loadBudgetsPage() {
|
|
3541
|
+
const body = document.getElementById('budgets-page-body');
|
|
3542
|
+
if (!body) return;
|
|
3543
|
+
body.innerHTML = '<div class="muted">loading…</div>';
|
|
3544
|
+
|
|
3545
|
+
const [h] = await Promise.all([
|
|
3546
|
+
api(`/api/heartbeat${pqs()}`),
|
|
3547
|
+
(typeof loadFleet === 'function' && !(fleetState?.agents || []).length) ? loadFleet() : Promise.resolve(),
|
|
3548
|
+
]);
|
|
3549
|
+
const caps = h?.budgets || {};
|
|
3550
|
+
const agents = fleetState?.agents || [];
|
|
3551
|
+
const judged = new Map(agents.map((a) => [a.slug, a.budget]));
|
|
3552
|
+
const capped = Object.entries(caps);
|
|
3553
|
+
const uncapped = agents.filter((a) => !(a.slug in caps));
|
|
3554
|
+
|
|
3555
|
+
const stateCell = (slug) => {
|
|
3556
|
+
const b = judged.get(slug);
|
|
3557
|
+
if (!b || b.state === 'no-limit') return '<span class="muted">state not loaded</span>';
|
|
3558
|
+
const spent = (b.state === 'within' || b.state === 'exceeded')
|
|
3559
|
+
? `$${(b.measuredUsd ?? 0).toFixed(2)} measured`
|
|
3560
|
+
: (b.state === 'unmeasured' ? 'nothing measured yet' : '');
|
|
3561
|
+
return `${budgetChip(b)} <span class="ab-spent">${esc(spent)}</span>`;
|
|
3562
|
+
};
|
|
3563
|
+
|
|
3564
|
+
// Three states, as everywhere: capped, uncapped, and unreadable — never a
|
|
3565
|
+
// silent "all fine".
|
|
3566
|
+
const bad = (h?.budgets_malformed || []);
|
|
3567
|
+
const notes = [
|
|
3568
|
+
h?.budgets_deprecated_key
|
|
3569
|
+
? `<div class="ab-note">Read from the deprecated <code>${esc(h.budgets_deprecated_key)}:</code> key — rename it to <code>agent-budgets:</code>.</div>` : '',
|
|
3570
|
+
bad.length
|
|
3571
|
+
? `<div class="ab-note ab-bad">${bad.length} line(s) in PROJECT.md were not read: `
|
|
3572
|
+
+ bad.map((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>' : '',
|
|
3573
|
+
].join('');
|
|
3574
|
+
|
|
3575
|
+
const capsBlock = capped.length ? `
|
|
3576
|
+
<table class="budgets-table">
|
|
3577
|
+
<thead><tr><th scope="col">Agent</th><th scope="col">Cap</th><th scope="col">State</th><th scope="col"></th></tr></thead>
|
|
3578
|
+
<tbody>${capped.map(([slug, cap]) => `
|
|
3579
|
+
<tr>
|
|
3580
|
+
<td class="bt-agent">${esc(slug)}</td>
|
|
3581
|
+
<td class="bt-cap">$${esc(String(cap))}</td>
|
|
3582
|
+
<td>${stateCell(slug)}</td>
|
|
3583
|
+
<td class="bt-actions">
|
|
3584
|
+
<button type="button" class="ab-btn" onclick="editAgentBudget('${esc(slug)}', ${Number(cap)})">Change</button>
|
|
3585
|
+
<button type="button" class="ab-btn ab-rm" onclick="clearAgentBudget('${esc(slug)}', ${Number(cap)})">Remove</button>
|
|
3586
|
+
</td>
|
|
3587
|
+
</tr>`).join('')}</tbody>
|
|
3588
|
+
</table>`
|
|
3589
|
+
: `<div class="budgets-empty">
|
|
3590
|
+
No agent has a spending cap in this project. Nothing is being held, and nothing will be.
|
|
3591
|
+
</div>`;
|
|
3592
|
+
|
|
3593
|
+
const uncappedBlock = uncapped.length ? `
|
|
3594
|
+
<div class="mp-section-label" style="margin-top:24px">No cap (${uncapped.length})</div>
|
|
3595
|
+
<div class="budgets-uncapped">${uncapped.slice(0, 60).map((a) => `
|
|
3596
|
+
<button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')"
|
|
3597
|
+
title="Set a cap for ${esc(a.slug)}">${esc(a.slug)} +</button>`).join('')}
|
|
3598
|
+
${uncapped.length > 60 ? `<span class="muted">and ${uncapped.length - 60} more</span>` : ''}
|
|
3599
|
+
</div>` : '';
|
|
3600
|
+
|
|
3601
|
+
body.innerHTML = `${notes}${capsBlock}
|
|
3602
|
+
<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>
|
|
3603
|
+
${uncappedBlock}`;
|
|
3604
|
+
|
|
3605
|
+
const badge = document.getElementById('nav-budget-count');
|
|
3606
|
+
if (badge) badge.textContent = capped.length ? String(capped.length) : '';
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
/** Set a cap for a named agent, skipping the "which agent?" question. */
|
|
3610
|
+
function addBudgetFor(slug) {
|
|
3611
|
+
const usd = budgetPrompt(slug, null);
|
|
3612
|
+
if (usd == null) return;
|
|
3613
|
+
writeAgentBudget({ agent: slug, limit_usd: usd },
|
|
3614
|
+
`Set ${slug} to $${usd}?\n\n`
|
|
3615
|
+
+ `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${slug}.`);
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3453
3618
|
/* Writing a cap writes PROJECT.md — a file the operator owns and git tracks.
|
|
3454
3619
|
*
|
|
3455
3620
|
* The confirm states what the number DOES rather than asking whether you are
|
|
@@ -3467,6 +3632,7 @@ async function writeAgentBudget(body, consequence) {
|
|
|
3467
3632
|
showToast(`Budget for <strong>${esc(r.agent)}</strong> ${r.removed ? 'removed' : 'saved'}`, 'success', 2500);
|
|
3468
3633
|
refreshHeartbeat();
|
|
3469
3634
|
if (typeof loadFleet === 'function') loadFleet();
|
|
3635
|
+
if (currentTab === 'budgets') loadBudgetsPage();
|
|
3470
3636
|
}
|
|
3471
3637
|
|
|
3472
3638
|
function budgetPrompt(agent, current) {
|
|
@@ -4223,6 +4389,54 @@ async function loadDocs() {
|
|
|
4223
4389
|
* of them live in ~/.great_cto and are shared by every project, which is why
|
|
4224
4390
|
* they never appeared in a per-project walk at all.
|
|
4225
4391
|
*/
|
|
4392
|
+
/* Search inside documents, not only their names.
|
|
4393
|
+
*
|
|
4394
|
+
* 187 documents whose contents cannot be searched are a directory listing, and
|
|
4395
|
+
* the question actually asked of them — "where did we write down the reason for
|
|
4396
|
+
* X" — was answerable only by leaving the board.
|
|
4397
|
+
*
|
|
4398
|
+
* `scanned` and `unreadable` come back with the answer and are shown: "nothing
|
|
4399
|
+
* found across 187 files" and "nothing found across the 3 that could be opened"
|
|
4400
|
+
* are different facts, and only one of them means the phrase is not there.
|
|
4401
|
+
*/
|
|
4402
|
+
async function searchDocs() {
|
|
4403
|
+
const q = (document.getElementById('docs-q')?.value || '').trim();
|
|
4404
|
+
const out = document.getElementById('docs-results');
|
|
4405
|
+
const body = document.getElementById('docs-body');
|
|
4406
|
+
if (!out) return;
|
|
4407
|
+
if (q.length < 2) { clearDocsSearch(); return; }
|
|
4408
|
+
|
|
4409
|
+
out.style.display = 'block';
|
|
4410
|
+
out.innerHTML = '<div class="muted" style="padding:12px 0">searching…</div>';
|
|
4411
|
+
if (body) body.style.display = 'none';
|
|
4412
|
+
|
|
4413
|
+
const d = await api(`/api/docs/search?q=${encodeURIComponent(q)}${pqs().replace(/^\?/, '&')}`);
|
|
4414
|
+
if (!d || d.error) {
|
|
4415
|
+
out.innerHTML = `<div class="muted" style="padding:12px 0">search failed — ${esc(d?.error || 'no response')}</div>`;
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
const head = `<div class="docs-search-meta">${d.matched} document(s) contain “${esc(d.q)}” · `
|
|
4419
|
+
+ `${d.scanned} searched${d.unreadable ? ` · ${d.unreadable} could not be opened` : ''} · `
|
|
4420
|
+
+ `<button type="button" class="ab-btn" onclick="clearDocsSearch()">back to all docs</button></div>`;
|
|
4421
|
+
if (!d.matched) {
|
|
4422
|
+
out.innerHTML = head + '<div class="muted" style="padding:12px 0">No document contains that phrase.</div>';
|
|
4423
|
+
return;
|
|
4424
|
+
}
|
|
4425
|
+
out.innerHTML = head + d.results.map((r) => `
|
|
4426
|
+
<div class="docs-hit">
|
|
4427
|
+
<a href="#" onclick="event.preventDefault();openDoc('${esc(r.path)}')"><b>${esc(r.title)}</b></a>
|
|
4428
|
+
<span class="muted"> · ${esc(r.group)} · <code>${esc(r.path)}</code></span>
|
|
4429
|
+
${r.hits.map((h) => `<div class="docs-hit-line"><span class="docs-hit-no">${h.line}</span>${esc(h.text)}</div>`).join('')}
|
|
4430
|
+
</div>`).join('');
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
function clearDocsSearch() {
|
|
4434
|
+
const out = document.getElementById('docs-results');
|
|
4435
|
+
const body = document.getElementById('docs-body');
|
|
4436
|
+
if (out) { out.style.display = 'none'; out.innerHTML = ''; }
|
|
4437
|
+
if (body) body.style.display = '';
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4226
4440
|
async function loadContextLayers() {
|
|
4227
4441
|
let m;
|
|
4228
4442
|
try { m = await api(`/api/memory${pqs()}`); } catch { m = null; }
|
|
@@ -5094,6 +5308,15 @@ function clearFilters() {
|
|
|
5094
5308
|
}
|
|
5095
5309
|
|
|
5096
5310
|
/* ── Kanban ─────────────────────────────────────────────────────────────── */
|
|
5311
|
+
/** Columns the operator has asked to see in full, for this page load. */
|
|
5312
|
+
const expandedColumns = new Set();
|
|
5313
|
+
const COLUMN_CAP = 25;
|
|
5314
|
+
|
|
5315
|
+
function expandColumn(id) {
|
|
5316
|
+
expandedColumns.add(id);
|
|
5317
|
+
renderKanban(allTasks);
|
|
5318
|
+
}
|
|
5319
|
+
|
|
5097
5320
|
function renderKanban(tasks) {
|
|
5098
5321
|
renderFilterBar();
|
|
5099
5322
|
const board = document.getElementById('kanban-board');
|
|
@@ -5109,6 +5332,11 @@ function renderKanban(tasks) {
|
|
|
5109
5332
|
board.innerHTML = '';
|
|
5110
5333
|
for (const col of COLUMNS) {
|
|
5111
5334
|
const items = byCol[col.id];
|
|
5335
|
+
// Capped per column, not globally: a Backlog of 5 and a Done of 227 are
|
|
5336
|
+
// different problems and only one of them needs a lid.
|
|
5337
|
+
const cap = expandedColumns.has(col.id) ? Infinity : COLUMN_CAP;
|
|
5338
|
+
const shown = items.slice(0, cap);
|
|
5339
|
+
const hidden = items.length - shown.length;
|
|
5112
5340
|
const dotClass = col.dot === 'filled' ? 'filled' : col.dot === 'half' ? 'half' : '';
|
|
5113
5341
|
const el = document.createElement('div');
|
|
5114
5342
|
el.className = `column col-${col.id}`;
|
|
@@ -5118,10 +5346,20 @@ function renderKanban(tasks) {
|
|
|
5118
5346
|
<span class="col-title">${col.label}</span>
|
|
5119
5347
|
<span class="col-count">${items.length}</span>
|
|
5120
5348
|
</div>
|
|
5121
|
-
<div class="col-body">${
|
|
5349
|
+
<div class="col-body">${
|
|
5350
|
+
items.length
|
|
5351
|
+
? shown.map(cardHTML).join('') + (hidden > 0
|
|
5352
|
+
// 227 done cards in one column is not a list anybody reads; it is
|
|
5353
|
+
// a scroll. The count in the header is the fact worth having, and
|
|
5354
|
+
// the rest is available by asking rather than by default.
|
|
5355
|
+
? `<button type="button" class="col-more" onclick="expandColumn('${col.id}')">`
|
|
5356
|
+
+ `Show ${hidden} more · ${items.length} total</button>`
|
|
5357
|
+
: '')
|
|
5358
|
+
: '<div class="empty">No tasks</div>'
|
|
5359
|
+
}</div>`;
|
|
5122
5360
|
board.appendChild(el);
|
|
5123
5361
|
el.querySelectorAll('.card').forEach((cardEl, i) => {
|
|
5124
|
-
cardEl.addEventListener('click', () => openSide(
|
|
5362
|
+
cardEl.addEventListener('click', () => openSide(shown[i]));
|
|
5125
5363
|
});
|
|
5126
5364
|
}
|
|
5127
5365
|
}
|
|
@@ -5411,9 +5649,15 @@ function renderDashboard(m) {
|
|
|
5411
5649
|
delta: deltaBadge(done, m.previous?.done, m.previous?.comparable),
|
|
5412
5650
|
},
|
|
5413
5651
|
{
|
|
5414
|
-
// $0.00
|
|
5415
|
-
//
|
|
5416
|
-
|
|
5652
|
+
// Three states, and $0.00 is only one of them. A measured zero is worth
|
|
5653
|
+
// showing; a zero that is the TIME-BASED ESTIMATE with no plans or tasks
|
|
5654
|
+
// to estimate from is not a figure at all, and rendering it as $0.00 told
|
|
5655
|
+
// a project with dozens of agent runs that it had spent nothing.
|
|
5656
|
+
v: !costKnown
|
|
5657
|
+
? absent('unloaded', 'the metrics payload carried no cost section')
|
|
5658
|
+
: (realLlmUsd === 0 && llmUsd === 0
|
|
5659
|
+
? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
|
|
5660
|
+
: `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}`),
|
|
5417
5661
|
sub: '',
|
|
5418
5662
|
label: 'AI spend',
|
|
5419
5663
|
delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
|
|
@@ -6066,8 +6310,19 @@ function switchTab(id, el) {
|
|
|
6066
6310
|
// crumb — `labels` used to be referenced here but is only ever a function-local in
|
|
6067
6311
|
// this file, so this threw ReferenceError on every tab switch and skipped the per-tab
|
|
6068
6312
|
// loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
|
|
6069
|
-
const TAB_LABELS = { docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
|
|
6313
|
+
const TAB_LABELS = { budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
|
|
6070
6314
|
const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
|
|
6315
|
+
// Budgets render on Metrics, so the heartbeat that feeds them has to be
|
|
6316
|
+
// fetched when Metrics opens — not only from the agents drill-in it used to
|
|
6317
|
+
// live in.
|
|
6318
|
+
if (id === 'dashboard') refreshHeartbeat();
|
|
6319
|
+
// The Notifications panel has carried an inline history list all along, and
|
|
6320
|
+
// `renderNotifHistoryInline()` to fill it — but the only caller was
|
|
6321
|
+
// `openNotifDrawer()`. Opening the tab itself never loaded it, so the sidebar
|
|
6322
|
+
// promised 50 unread and the screen showed settings. Declared and not
|
|
6323
|
+
// consumed, in the UI this time.
|
|
6324
|
+
if (id === 'notifications') loadNotifHistory();
|
|
6325
|
+
if (id === 'budgets') loadBudgetsPage();
|
|
6071
6326
|
if (id === 'dashboard' || id === 'agents') {
|
|
6072
6327
|
// pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
|
|
6073
6328
|
// its 30-day default and overwrote whatever window the period chips had
|
|
@@ -6255,11 +6510,32 @@ function renderFleetSummary() {
|
|
|
6255
6510
|
trend: s.retire_candidates > 0 ? 'click to filter' : 'fleet is lean',
|
|
6256
6511
|
onClick: 'setFleetFilter("activity", "never")',
|
|
6257
6512
|
},
|
|
6258
|
-
{
|
|
6513
|
+
{
|
|
6514
|
+
// `?? 0` turned "nothing was measured" into "$0.00 spent", and before that
|
|
6515
|
+
// the number here was an ESTIMATE from verdict counts while the metrics
|
|
6516
|
+
// page estimated from tasks — two screens, one label, $3.90 against $1.65.
|
|
6517
|
+
v: s.llm_usd_30d != null
|
|
6518
|
+
? '$' + s.llm_usd_30d.toFixed(2)
|
|
6519
|
+
: absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`),
|
|
6520
|
+
vHtml: s.llm_usd_30d == null,
|
|
6521
|
+
label: 'LLM spend 30d',
|
|
6522
|
+
cls: '',
|
|
6523
|
+
trend: s.llm_usd_30d != null
|
|
6524
|
+
? `measured for ${s.llm_usd_30d_measured_for} agent(s)`
|
|
6525
|
+
: (s.failing_7d ? `${s.failing_7d} failing` : 'measured from verdicts'),
|
|
6526
|
+
},
|
|
6259
6527
|
];
|
|
6528
|
+
// `t.v` is HTML when it is an absence marker — a span carrying the reason on
|
|
6529
|
+
// hover and in the accessible name — and a plain string otherwise. Escaping it
|
|
6530
|
+
// unconditionally printed the markup as text on the fleet tile. `vHtml` is the
|
|
6531
|
+
// deliberate opt-in; every value without it is still escaped.
|
|
6532
|
+
//
|
|
6533
|
+
// This comment lives out here because it names `t.v` in backticks, and a
|
|
6534
|
+
// backtick inside the template literal below terminates it — which is how the
|
|
6535
|
+
// board shipped a syntax error for about ninety seconds.
|
|
6260
6536
|
el.innerHTML = tiles.map(t => `
|
|
6261
6537
|
<div class="metric-card ${t.cls}" ${t.onClick ? `role="button" tabindex="0" onclick='${t.onClick}' onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();${t.onClick}}'` : ''}>
|
|
6262
|
-
<div class="metric-num">${esc(String(t.v))}</div>
|
|
6538
|
+
<div class="metric-num">${t.vHtml ? t.v : esc(String(t.v))}</div>
|
|
6263
6539
|
<div class="metric-label">${esc(t.label)}</div>
|
|
6264
6540
|
${t.trend ? `<div class="metric-trend">${esc(t.trend)}</div>` : ''}
|
|
6265
6541
|
</div>`).join('');
|
|
@@ -13,6 +13,7 @@ import fs from 'fs';
|
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import { spawnSync } from 'child_process';
|
|
15
15
|
import { PORT, PUBLIC, HOST } from './lib/config.mjs';
|
|
16
|
+
import { getTasks } from './lib/beads.mjs';
|
|
16
17
|
import { originAllowed, isInsideDir } from './lib/util.mjs';
|
|
17
18
|
import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
|
|
18
19
|
import { startAlertCron } from './lib/alerts.mjs';
|
|
@@ -114,6 +115,29 @@ server.listen(PORT, HOST, () => {
|
|
|
114
115
|
log.info(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
|
|
115
116
|
log.info(` links; put your reverse-proxy auth in front for anything admin-grade.`);
|
|
116
117
|
}
|
|
118
|
+
// Warm the task cache for THIS project before a browser asks.
|
|
119
|
+
//
|
|
120
|
+
// `bd list` costs seconds and runs through `spawnSync`, which holds the event
|
|
121
|
+
// loop for the whole of it. On a cold board the first request pays that, and
|
|
122
|
+
// every other request queues behind it: opening the board showed "loading…"
|
|
123
|
+
// on Docs for 27 seconds, measured, while the endpoint itself takes 0.4 s
|
|
124
|
+
// warm. Nothing was broken — everything was waiting.
|
|
125
|
+
//
|
|
126
|
+
// Doing it here moves the stall to before anyone is looking, where a stall
|
|
127
|
+
// costs nothing. `setImmediate` so the listening callback returns first and
|
|
128
|
+
// the port is genuinely open while this runs.
|
|
129
|
+
setImmediate(() => {
|
|
130
|
+
const t0 = Date.now();
|
|
131
|
+
try {
|
|
132
|
+
getTasks(process.cwd());
|
|
133
|
+
log.info(` → task cache warmed in ${Date.now() - t0}ms`);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
// A warm-up that failed changes nothing a request would not have hit
|
|
136
|
+
// anyway; it must never stop the board from serving.
|
|
137
|
+
log.warn(` → could not warm the task cache: ${e?.message || e}`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
117
141
|
// Discover all great_cto projects on disk asynchronously — don't block
|
|
118
142
|
// the listening event so /api/tasks is available immediately.
|
|
119
143
|
discoverProjects().then(n => {
|
package/package.json
CHANGED