great-cto 3.16.0 → 3.17.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/fixtures/fake-bd-fail.sh +9 -0
- package/board/packages/board/fixtures/fake-bd-slow.sh +12 -0
- package/board/packages/board/lib/alerts.mjs +13 -7
- package/board/packages/board/lib/beads.mjs +104 -14
- package/board/packages/board/public/index.html +423 -90
- package/board/packages/board/server.mjs +27 -20
- package/dist/board-daemon.js +29 -8
- package/dist/main.js +68 -1
- package/package.json +1 -1
- package/postinstall.mjs +49 -0
|
@@ -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.17.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Test fixture: a `bd` stand-in that always exits non-zero, for testing the
|
|
3
|
+
# failure path of warmTasksAsync (packages/board/beads-warm-async.test.mjs).
|
|
4
|
+
if [ "$1" = "--version" ]; then
|
|
5
|
+
echo "bd-fake 0.0.0"
|
|
6
|
+
exit 0
|
|
7
|
+
fi
|
|
8
|
+
echo "dolt: database is locked" >&2
|
|
9
|
+
exit 1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Test fixture: a `bd` stand-in that sleeps before answering, so the
|
|
3
|
+
# boot-responsiveness tests can prove the board stays answerable DURING a
|
|
4
|
+
# slow `bd list` — not just that the code calls the right function. Loaded
|
|
5
|
+
# only via GREAT_CTO_BD_BIN in packages/board/*.test.mjs; never used outside
|
|
6
|
+
# tests.
|
|
7
|
+
if [ "$1" = "--version" ]; then
|
|
8
|
+
echo "bd-fake 0.0.0"
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
11
|
+
sleep "${FAKE_BD_DELAY_SECS:-3}"
|
|
12
|
+
echo "[]"
|
|
@@ -12,14 +12,20 @@ import { listProjects, readProjectMd } from './projects.mjs';
|
|
|
12
12
|
import { addNotification } from './notifications.mjs';
|
|
13
13
|
import { getMetrics } from './metrics.mjs';
|
|
14
14
|
import { getCostHistory, getInbox } from './data-readers.mjs';
|
|
15
|
-
// Every sweep below runs over EVERY registered project
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
15
|
+
// Every sweep below runs over EVERY registered project. `sweep: true` opts a
|
|
16
|
+
// COLD project (never read this process) into bdList()'s non-blocking fill
|
|
17
|
+
// path instead of its synchronous one — the same escape hatch a WARM-but-
|
|
18
|
+
// stale entry already gets. Without it: a real board's first cron tick
|
|
19
|
+
// (t=+5:10, no request in flight) lagged the event loop 9.8s reading a
|
|
20
|
+
// single project synchronously; at 16 projects and 2-6s each that is ~60s
|
|
21
|
+
// per sweep, three of which fire every five minutes, indefinitely. Sweeps
|
|
22
|
+
// read at sweep freshness, not interactive freshness — see SWEEP_MAX_AGE_MS
|
|
23
|
+
// in beads.mjs for the arithmetic that made the board unanswerable while
|
|
24
|
+
// reporting "live · synced just now". `sweep: true` must NEVER be added to
|
|
25
|
+
// an interactive read's opts: an ordinary request is someone waiting on
|
|
26
|
+
// THIS project's answer, and the fill path answers `[]` instead.
|
|
21
27
|
import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
|
|
22
|
-
const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS };
|
|
28
|
+
const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS, sweep: true };
|
|
23
29
|
import { readVerdicts } from './verdicts.mjs';
|
|
24
30
|
import { isFailure } from './fleet.mjs';
|
|
25
31
|
import { getShareState, toggleShare } from './share.mjs';
|
|
@@ -38,6 +38,15 @@ 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
|
+
* When a cwd was last told "your cache entry is wrong" — by an invalidate or
|
|
43
|
+
* a stale-mark, whichever most recently ran. A background fetch that STARTED
|
|
44
|
+
* before this timestamp answers a question nobody is asking anymore: see
|
|
45
|
+
* `bdRefreshAsync`, which compares its own start time against this map before
|
|
46
|
+
* committing its result, so a slow fetch racing a write always loses to it.
|
|
47
|
+
*/
|
|
48
|
+
const lastInvalidatedAt = new Map();
|
|
49
|
+
|
|
41
50
|
/**
|
|
42
51
|
* Drop a directory's entry so the next read is guaranteed fresh.
|
|
43
52
|
*
|
|
@@ -47,6 +56,7 @@ const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
|
|
|
47
56
|
*/
|
|
48
57
|
function bdCacheInvalidate(cwd) {
|
|
49
58
|
clearSelfTouch(cwd);
|
|
59
|
+
lastInvalidatedAt.set(cwd, Date.now());
|
|
50
60
|
bdCache.delete(cwd);
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -63,6 +73,7 @@ function bdCacheInvalidate(cwd) {
|
|
|
63
73
|
*/
|
|
64
74
|
function bdCacheStale(cwd) {
|
|
65
75
|
clearSelfTouch(cwd);
|
|
76
|
+
lastInvalidatedAt.set(cwd, Date.now());
|
|
66
77
|
const cached = bdCache.get(cwd);
|
|
67
78
|
if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
|
|
68
79
|
}
|
|
@@ -290,13 +301,16 @@ const refreshing = new Set();
|
|
|
290
301
|
* measured at 1-10 s because it was queued behind a task read. Warming at boot
|
|
291
302
|
* moved the first stall out of sight; this removes the rest.
|
|
292
303
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
304
|
+
* `onDone`, if given, fires exactly once at every exit point (success,
|
|
305
|
+
* failure, or "already refreshing"). Every existing caller omits it and stays
|
|
306
|
+
* fire-and-forget — the ONE caller that wants to know when the fill finished
|
|
307
|
+
* is the boot warm-up (see `warmTasksAsync`), which logs how long it took.
|
|
295
308
|
*/
|
|
296
|
-
function bdRefreshAsync(cwd) {
|
|
297
|
-
if (refreshing.has(cwd)) return;
|
|
309
|
+
function bdRefreshAsync(cwd, onDone = () => {}) {
|
|
310
|
+
if (refreshing.has(cwd)) { onDone({ ok: false, skipped: true }); return; }
|
|
298
311
|
refreshing.add(cwd);
|
|
299
|
-
|
|
312
|
+
const startedAt = Date.now();
|
|
313
|
+
lastBdRunAt.set(cwd, startedAt);
|
|
300
314
|
let out = '';
|
|
301
315
|
try {
|
|
302
316
|
const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv() });
|
|
@@ -304,31 +318,87 @@ function bdRefreshAsync(cwd) {
|
|
|
304
318
|
child.on('error', (e) => {
|
|
305
319
|
refreshing.delete(cwd);
|
|
306
320
|
bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
|
|
321
|
+
onDone({ ok: false });
|
|
307
322
|
});
|
|
308
323
|
child.on('close', (code) => {
|
|
309
324
|
refreshing.delete(cwd);
|
|
310
325
|
lastBdRunAt.set(cwd, Date.now());
|
|
311
|
-
if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); return; }
|
|
326
|
+
if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); onDone({ ok: false }); return; }
|
|
312
327
|
try {
|
|
313
328
|
const parsed = JSON.parse(out || '[]');
|
|
314
329
|
// Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
|
|
315
330
|
// and a non-array rendered as no tasks is the silent zero again.
|
|
316
331
|
if (!Array.isArray(parsed)) {
|
|
317
332
|
bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
|
|
333
|
+
onDone({ ok: false });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// This fetch answers "what was true at `startedAt`?" — if anyone has
|
|
337
|
+
// since told the cache it's wrong (a write's invalidate/stale-mark)
|
|
338
|
+
// or already written something newer (a synchronous cold read that
|
|
339
|
+
// raced us and won), our answer is superseded. Committing it now
|
|
340
|
+
// would silently resurrect data from before a change a caller is
|
|
341
|
+
// already waiting to see reflected — exactly what made
|
|
342
|
+
// `tests/pipeline-e2e.test.mjs`'s gate-approval assertions flake once
|
|
343
|
+
// this function started running from a COLD cache (the boot warm-up)
|
|
344
|
+
// instead of only ever refreshing an already-populated one.
|
|
345
|
+
const supersededByInvalidation = (lastInvalidatedAt.get(cwd) || 0) >= startedAt;
|
|
346
|
+
const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
|
|
347
|
+
if (supersededByInvalidation || supersededByFresherEntry) {
|
|
348
|
+
onDone({ ok: true, count: parsed.length, discarded: true });
|
|
318
349
|
return;
|
|
319
350
|
}
|
|
320
351
|
bdFailures.delete(cwd);
|
|
321
352
|
bdCache.set(cwd, { ts: Date.now(), data: parsed });
|
|
353
|
+
onDone({ ok: true, count: parsed.length });
|
|
322
354
|
} catch (e) {
|
|
323
355
|
bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
|
|
356
|
+
onDone({ ok: false });
|
|
324
357
|
}
|
|
325
358
|
});
|
|
326
359
|
} catch (e) {
|
|
327
360
|
refreshing.delete(cwd);
|
|
328
361
|
bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
|
|
362
|
+
onDone({ ok: false });
|
|
329
363
|
}
|
|
330
364
|
}
|
|
331
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Warm a directory's task cache at boot, WITHOUT blocking the event loop.
|
|
368
|
+
*
|
|
369
|
+
* The boot warm-up used to call `getTasks(cwd)` directly — `bdList()`'s
|
|
370
|
+
* COLD-cache path, which is `bd()`'s synchronous `spawnSync`, because a cold
|
|
371
|
+
* cache has nothing to serve while a background refresh runs, so it has
|
|
372
|
+
* always been the one path in this file that blocks. That's the right call
|
|
373
|
+
* for a REQUEST (something is waiting and there's no stale data to fall back
|
|
374
|
+
* on); it was the wrong call at boot, because a boot warm-up has nobody
|
|
375
|
+
* waiting on it — its only job is to be ready before someone asks.
|
|
376
|
+
*
|
|
377
|
+
* Measured with an artificially slow `bd` (GREAT_CTO_BD_BIN pointed at a
|
|
378
|
+
* fixture that sleeps N seconds before answering): the board bound its port,
|
|
379
|
+
* then answered `000` on EVERY endpoint — including /api/version, a plain
|
|
380
|
+
* readdirSync with nothing to do with bd — for the fixture's entire delay.
|
|
381
|
+
* The port being open did not matter; the single process serving it was
|
|
382
|
+
* blocked inside `spawnSync` the whole time. `bd()`'s own 8s spawnSync
|
|
383
|
+
* timeout capped that run at ~8s; a real cold Dolt store or lock contention
|
|
384
|
+
* has no such cap, which is consistent with the up-to-2-minute stalls
|
|
385
|
+
* reported in production.
|
|
386
|
+
*
|
|
387
|
+
* This calls `bdRefreshAsync` — the SAME `spawn`-based, non-blocking path
|
|
388
|
+
* already used for warm-cache "stale-while-revalidate" reads — for the cold
|
|
389
|
+
* boot case too. The returned Promise exists only so the boot log can report
|
|
390
|
+
* how long the fill took; nothing waits on it, and the server is accepting
|
|
391
|
+
* and answering connections for the entire duration either way.
|
|
392
|
+
*
|
|
393
|
+
* @returns {Promise<{ok: boolean, count?: number, skipped?: boolean, ms: number}>}
|
|
394
|
+
*/
|
|
395
|
+
function warmTasksAsync(cwd) {
|
|
396
|
+
const t0 = Date.now();
|
|
397
|
+
return new Promise((resolve) => {
|
|
398
|
+
bdRefreshAsync(cwd, (result) => resolve({ ...result, ms: Date.now() - t0 }));
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
332
402
|
function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
|
|
333
403
|
const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
|
|
334
404
|
const cached = bdCache.get(cwd);
|
|
@@ -338,14 +408,7 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
|
|
|
338
408
|
if (cached && Date.now() - cached.ts < ttl) return cached.data;
|
|
339
409
|
|
|
340
410
|
// Stale-while-revalidate. An entry that exists is served immediately, however
|
|
341
|
-
// old, and refreshed off the event loop.
|
|
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.
|
|
411
|
+
// old, and refreshed off the event loop.
|
|
349
412
|
//
|
|
350
413
|
// The injected `runner` is how the tests drive this. When one is supplied the
|
|
351
414
|
// sync path is kept, so a test that stubs `bd` still observes the call it
|
|
@@ -354,6 +417,32 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
|
|
|
354
417
|
bdRefreshAsync(cwd);
|
|
355
418
|
return cached.data;
|
|
356
419
|
}
|
|
420
|
+
|
|
421
|
+
// A directory with NO entry at all — genuinely never read. Left
|
|
422
|
+
// synchronous for an ordinary caller: a request FOR this project is
|
|
423
|
+
// waiting on the answer, and returning `[]` while a background fill
|
|
424
|
+
// catches up would be wrong data handed to a caller who cannot tell it
|
|
425
|
+
// apart from "no tasks" — confirmed the hard way. This branch originally
|
|
426
|
+
// covered every caller, and tests/board-gate.test.mjs,
|
|
427
|
+
// tests/pipeline-e2e.test.mjs and tests/resume-e2e.test.mjs all went red:
|
|
428
|
+
// a gate approved seconds after project creation read back as "no gates",
|
|
429
|
+
// because the read that should have shown it took the async path and
|
|
430
|
+
// answered empty instead.
|
|
431
|
+
//
|
|
432
|
+
// `opts.sweep` is the one caller allowed to skip the wait: the alert cron,
|
|
433
|
+
// which already has its own looser freshness contract (SWEEP_MAX_AGE_MS)
|
|
434
|
+
// and iterates EVERY registered project on a timer nobody is blocking on.
|
|
435
|
+
// Measured live, unprompted: a real board's first cron tick (t=+5:10, no
|
|
436
|
+
// request in flight) lagged the event loop 9.8s doing exactly this
|
|
437
|
+
// synchronous cold read, once per registered project, every five minutes,
|
|
438
|
+
// indefinitely. For that caller specifically, the same non-blocking fill
|
|
439
|
+
// stale-while-revalidate already uses for a WARM entry is safe on a COLD
|
|
440
|
+
// one too — the cron does not need this tick's answer to be correct, only
|
|
441
|
+
// bounded in staleness, which the background fill still gives it.
|
|
442
|
+
if (!cached && runner === bd && opts.sweep) {
|
|
443
|
+
bdRefreshAsync(cwd);
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
357
446
|
try {
|
|
358
447
|
lastBdRunAt.set(cwd, Date.now());
|
|
359
448
|
const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
|
|
@@ -743,6 +832,7 @@ export {
|
|
|
743
832
|
checkBeadsAvailable,
|
|
744
833
|
bdWriteSerialised,
|
|
745
834
|
bdList,
|
|
835
|
+
warmTasksAsync,
|
|
746
836
|
bdFailureFor,
|
|
747
837
|
parseTasksMd,
|
|
748
838
|
getReadDegradation,
|
|
@@ -109,6 +109,16 @@
|
|
|
109
109
|
}
|
|
110
110
|
/* Light theme — same emerald system, AA-contrast on white. Toggle via data-theme. */
|
|
111
111
|
[data-theme="light"] {
|
|
112
|
+
/* Four values that had never been measured.
|
|
113
|
+
The contrast audit read `:root` and nothing else, so this theme — 39 token
|
|
114
|
+
overrides, half the board's surface area — was unchecked for months. When it
|
|
115
|
+
was finally measured against `--bg-strong`, the hardest surface here:
|
|
116
|
+
--accent 1.51:1 against a 3:1 floor for UI boundaries. Half.
|
|
117
|
+
--accent-text 4.17, --p1-fg 4.04, --p2-fg 4.09 against a 4.5 floor.
|
|
118
|
+
`--accent` was simply the dark theme's green, which is designed to sit on
|
|
119
|
+
#0a0e0c. Each value below is the same hue darkened until it clears its floor,
|
|
120
|
+
computed rather than eyeballed. */
|
|
121
|
+
|
|
112
122
|
--bg-page: #eef2f0;
|
|
113
123
|
--bg-panel: rgba(255, 255, 255, 0.9);
|
|
114
124
|
--bg-card: #ffffff;
|
|
@@ -121,11 +131,11 @@
|
|
|
121
131
|
--text2: #56655e;
|
|
122
132
|
--text3: #55625b;
|
|
123
133
|
--focus-ring: #047857;
|
|
124
|
-
--accent: #
|
|
134
|
+
--accent: #009657;
|
|
125
135
|
--accent-2: #0a7d4d;
|
|
126
136
|
--accent-soft: rgba(0, 217, 126, 0.12);
|
|
127
137
|
--accent-strong: rgba(0, 168, 98, 0.20);
|
|
128
|
-
--accent-text: #
|
|
138
|
+
--accent-text: #097648;
|
|
129
139
|
--status-backlog: #6b7280;
|
|
130
140
|
--status-todo: #6b7280;
|
|
131
141
|
--status-progress: #b45309;
|
|
@@ -134,8 +144,8 @@
|
|
|
134
144
|
--status-blocked: #c43030;
|
|
135
145
|
--status-gate: #7c3aed;
|
|
136
146
|
--p0-bg: rgba(196, 48, 48, 0.12); --p0-fg: #b91c1c;
|
|
137
|
-
--p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #
|
|
138
|
-
--p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #
|
|
147
|
+
--p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #a64c08;
|
|
148
|
+
--p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #885f09;
|
|
139
149
|
--p3-bg: rgba(0, 0, 0, 0.05); --p3-fg: #5c6b65;
|
|
140
150
|
--surface-drawer: #ffffff;
|
|
141
151
|
--surface-translucent: rgba(255, 255, 255, 0.72);
|
|
@@ -1389,7 +1399,16 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1389
1399
|
background: var(--text3); flex-shrink: 0;
|
|
1390
1400
|
}
|
|
1391
1401
|
.pl-stage.idle .pl-name .dot { background: var(--text3); }
|
|
1392
|
-
|
|
1402
|
+
/* Motion means the data is live.
|
|
1403
|
+
|
|
1404
|
+
A pulsing dot says "this is happening right now". Three stages pulsed on a
|
|
1405
|
+
rail whose newest timestamp was 260 hours old — the strongest liveness signal
|
|
1406
|
+
the design has, spent on work that stopped eleven days ago. The animation is
|
|
1407
|
+
now gated on `.fresh`, which the renderer adds only when the stage's own
|
|
1408
|
+
timestamp is inside the hour. An `active` stage that has not moved keeps its
|
|
1409
|
+
colour and loses its heartbeat. */
|
|
1410
|
+
.pl-stage.active .pl-name .dot { background: var(--status-progress); }
|
|
1411
|
+
.pl-stage.active.fresh .pl-name .dot { animation: pl-pulse 1.4s infinite; }
|
|
1393
1412
|
.pl-stage.done .pl-name .dot { background: var(--status-review); }
|
|
1394
1413
|
.pl-stage.failed .pl-name .dot { background: var(--status-blocked); }
|
|
1395
1414
|
@keyframes pl-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); } 50% { box-shadow: 0 0 0 5px rgba(245, 158, 11, 0); } }
|
|
@@ -1471,7 +1490,19 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1471
1490
|
border-radius: 8px;
|
|
1472
1491
|
display: inline-flex; align-items: center; justify-content: center;
|
|
1473
1492
|
}
|
|
1474
|
-
.pl-gate.active .pl-name .gate-glyph { animation: pl-pulse 1.4s infinite; }
|
|
1493
|
+
.pl-gate.active.fresh .pl-name .gate-glyph { animation: pl-pulse 1.4s infinite; }
|
|
1494
|
+
|
|
1495
|
+
/* Reduced motion, declared AFTER the pulse rules on purpose.
|
|
1496
|
+
|
|
1497
|
+
It was first written into the reduced-motion block near the top of this file,
|
|
1498
|
+
where `css-cascade` immediately reported it: same specificity as the two rules
|
|
1499
|
+
above, and the later rule wins, so `animation: none` never applied and a reader
|
|
1500
|
+
who had asked for less movement still got a pulsing dot. The check exists for
|
|
1501
|
+
exactly this, and it caught it within the minute. */
|
|
1502
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1503
|
+
.pl-stage.active.fresh .pl-name .dot,
|
|
1504
|
+
.pl-gate.active.fresh .pl-name .gate-glyph { animation: none; }
|
|
1505
|
+
}
|
|
1475
1506
|
/* The gate with nothing pending is a node like any other idle stage — the tinted
|
|
1476
1507
|
card is reserved for the state that actually holds the pipeline, so a clear
|
|
1477
1508
|
gate stops out-shouting the stages that ran. */
|
|
@@ -1657,6 +1688,79 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1657
1688
|
}
|
|
1658
1689
|
|
|
1659
1690
|
/* ── Resume card ──────────────────────────────────────────────────────── */
|
|
1691
|
+
/* Project status — one disclosure for everything the operator cannot act on. */
|
|
1692
|
+
/* Focus, visible against every surface it can land on.
|
|
1693
|
+
|
|
1694
|
+
Queue rows became keyboard-reachable and the buttons on them are real buttons,
|
|
1695
|
+
but nothing drew a ring — so a keyboard user could reach a row and not see
|
|
1696
|
+
where they were. `outline-offset: 2px` is not decoration: `.gate-approve` is a
|
|
1697
|
+
filled green button, and a ring drawn ON that fill disappears into it. Offset
|
|
1698
|
+
puts the ring on the surface behind, where it keeps its contrast. */
|
|
1699
|
+
.inbox-row:focus-visible,
|
|
1700
|
+
.gate-btn:focus-visible,
|
|
1701
|
+
.ac-btn:focus-visible,
|
|
1702
|
+
.ac-primary:focus-visible {
|
|
1703
|
+
outline: 2px solid var(--focus-ring);
|
|
1704
|
+
outline-offset: 2px;
|
|
1705
|
+
border-radius: 8px;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/* Reason chip — why a row is in the queue, carried by the row itself. */
|
|
1709
|
+
.why {
|
|
1710
|
+
display: inline-block;
|
|
1711
|
+
font-family: var(--mono);
|
|
1712
|
+
font-size: var(--fs-eyebrow);
|
|
1713
|
+
letter-spacing: 0.04em;
|
|
1714
|
+
padding: 1px 7px;
|
|
1715
|
+
margin-right: 8px;
|
|
1716
|
+
border-radius: 5px;
|
|
1717
|
+
border: 1px solid var(--border-strong);
|
|
1718
|
+
color: var(--text2);
|
|
1719
|
+
vertical-align: 1px;
|
|
1720
|
+
}
|
|
1721
|
+
.why-gate { color: var(--status-gate); border-color: var(--status-gate); }
|
|
1722
|
+
.why-p0 { color: var(--status-blocked); border-color: var(--status-blocked); }
|
|
1723
|
+
.why-blocked { color: var(--status-progress); border-color: var(--status-progress); }
|
|
1724
|
+
.why-stale { color: var(--text3); }
|
|
1725
|
+
|
|
1726
|
+
.proj-status {
|
|
1727
|
+
border: 1px solid var(--border);
|
|
1728
|
+
border-radius: 12px;
|
|
1729
|
+
background: var(--bg-card);
|
|
1730
|
+
margin: 10px 0 18px;
|
|
1731
|
+
}
|
|
1732
|
+
.proj-status-summary {
|
|
1733
|
+
cursor: pointer;
|
|
1734
|
+
padding: 11px 16px;
|
|
1735
|
+
font-size: var(--fs-small);
|
|
1736
|
+
color: var(--text2);
|
|
1737
|
+
list-style: none;
|
|
1738
|
+
display: flex; align-items: center; gap: 8px;
|
|
1739
|
+
}
|
|
1740
|
+
.proj-status-summary::-webkit-details-marker { display: none; }
|
|
1741
|
+
.proj-status-summary::before {
|
|
1742
|
+
content: "\25B8";
|
|
1743
|
+
color: var(--text3);
|
|
1744
|
+
transition: transform 0.12s;
|
|
1745
|
+
}
|
|
1746
|
+
.proj-status[open] .proj-status-summary::before { transform: rotate(90deg); }
|
|
1747
|
+
.proj-status-summary:hover { color: var(--text); }
|
|
1748
|
+
.proj-status-summary:focus-visible {
|
|
1749
|
+
outline: 2px solid var(--focus-ring);
|
|
1750
|
+
outline-offset: 2px;
|
|
1751
|
+
border-radius: 8px;
|
|
1752
|
+
}
|
|
1753
|
+
/* A link with no colour rule gets the browser's default, which is on nobody's
|
|
1754
|
+
palette. This one — "stale, listed below" in the empty resume column — was
|
|
1755
|
+
added today without one, and the rendered-layout check found it on both themes
|
|
1756
|
+
within the hour. That is the check working on its author. */
|
|
1757
|
+
.resume-list a, .empty a { color: var(--accent-text); text-decoration: underline; }
|
|
1758
|
+
.resume-list a:hover, .empty a:hover { color: var(--accent); }
|
|
1759
|
+
|
|
1760
|
+
.proj-status-verdict { color: var(--text3); font-family: var(--mono); font-size: var(--fs-eyebrow); }
|
|
1761
|
+
.proj-status-body { padding: 0 16px 14px; }
|
|
1762
|
+
.proj-status-body .inbox-section { margin-top: 6px; }
|
|
1763
|
+
|
|
1660
1764
|
.resume-card {
|
|
1661
1765
|
background: linear-gradient(135deg, rgba(0, 217, 126, 0.04), var(--surface-translucent));
|
|
1662
1766
|
border: 1px solid rgba(0, 217, 126, 0.18);
|
|
@@ -2022,7 +2126,11 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2022
2126
|
way, the reason is available on hover and to a screen reader. Sized down from
|
|
2023
2127
|
whatever it sits in, so a missing hero figure does not shout at 52px. */
|
|
2024
2128
|
.absent {
|
|
2025
|
-
|
|
2129
|
+
/* `0.55em` is a size relative to whatever it lands inside. Next to a 52px
|
|
2130
|
+
display numeral that resolves to 28.6px — a step the ramp does not have, and
|
|
2131
|
+
the rendered-layout check flagged it on two screens. The "n/a" beside a
|
|
2132
|
+
figure is a caption; it is sized like one. */
|
|
2133
|
+
font-family: var(--mono); font-size: var(--fs-caption);
|
|
2026
2134
|
color: var(--text3); letter-spacing: 0.04em;
|
|
2027
2135
|
cursor: help; border-bottom: 1px dotted var(--border-strong);
|
|
2028
2136
|
}
|
|
@@ -2912,14 +3020,26 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2912
3020
|
<div class="panel active" id="panel-inbox">
|
|
2913
3021
|
<div class="inbox-page">
|
|
2914
3022
|
<div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
|
|
2915
|
-
<div class="inbox-summary" id="inbox-summary"></div>
|
|
2916
3023
|
<!-- Top rung of the evidence ladder: are the bytes that were reviewed
|
|
2917
3024
|
still the bytes in the tree? Hidden while they match — and now only
|
|
2918
3025
|
used for `differs`, the one reading that needs a decision. The other
|
|
2919
3026
|
two speaking states render into #inbox-receipt-foot at the bottom of
|
|
2920
3027
|
this page (see refreshReceipt). -->
|
|
2921
3028
|
<div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:var(--fs-small)"></div>
|
|
2922
|
-
|
|
3029
|
+
<!-- Everything below is STATUS, and status is not a decision.
|
|
3030
|
+
|
|
3031
|
+
Six of the seven blocks on this screen were read-only: a pipeline rail,
|
|
3032
|
+
a resume card, two advisory notices, a strip of stat tiles. They sat
|
|
3033
|
+
ABOVE the one item the operator could act on, under a headline promising
|
|
3034
|
+
decisions. The rule the design spec states — a block earns the top of a
|
|
3035
|
+
screen only if the operator can act on it now — puts all of it here,
|
|
3036
|
+
one disclosure, closed by default.
|
|
3037
|
+
|
|
3038
|
+
Not deleted. "How is the project" is a real question; it is simply not
|
|
3039
|
+
the question this screen is named after. -->
|
|
3040
|
+
<details class="proj-status" id="proj-status">
|
|
3041
|
+
<summary class="proj-status-summary">Project status<span class="proj-status-verdict" id="proj-status-verdict"></span></summary>
|
|
3042
|
+
<div class="proj-status-body">
|
|
2923
3043
|
<!-- Resume — pick up where you left off -->
|
|
2924
3044
|
<div class="resume-card" id="resume-card" style="display:none">
|
|
2925
3045
|
<div class="resume-head">
|
|
@@ -2944,6 +3064,20 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2944
3064
|
</div>
|
|
2945
3065
|
</div>
|
|
2946
3066
|
</div>
|
|
3067
|
+
<div id="tool-failure-rate" style="display:none;font-size:var(--fs-small);color:var(--status-blocked,#f87171);background:rgba(248,113,113,0.08);padding:6px 10px;border-radius:6px;margin-bottom:10px;"></div>
|
|
3068
|
+
|
|
3069
|
+
<div class="inbox-section">
|
|
3070
|
+
<div class="inbox-section-head">
|
|
3071
|
+
<span class="dot dot-blue"></span>
|
|
3072
|
+
<h3>Active pipeline</h3>
|
|
3073
|
+
<span class="inbox-count" id="pipeline-status">idle</span>
|
|
3074
|
+
<span class="tier-badge" id="tier-badge" title="change_tier for the current working-tree diff — which gates + judge open (ADR-003/004)"></span>
|
|
3075
|
+
</div>
|
|
3076
|
+
<div class="pipeline-track" id="pipeline-track"></div>
|
|
3077
|
+
</div>
|
|
3078
|
+
</div>
|
|
3079
|
+
</details>
|
|
3080
|
+
|
|
2947
3081
|
|
|
2948
3082
|
<!-- Project archetype / compliance / phase used to be a full-width mono
|
|
2949
3083
|
row here, between the KPI tiles and the pipeline. On a screen whose
|
|
@@ -2956,24 +3090,27 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2956
3090
|
Not deleted: `PROJECT.md` is not in the Docs list, so compliance and
|
|
2957
3091
|
phase would have become unreachable. -->
|
|
2958
3092
|
<!-- Tool failure rate watchdog warning -->
|
|
2959
|
-
|
|
3093
|
+
<!-- One queue, not four sections.
|
|
2960
3094
|
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
3095
|
+
Gates, P0s, blocked and stale were four headed lists, each with its own
|
|
3096
|
+
count, on a screen that shows five to seven rows in total. The headings
|
|
3097
|
+
cost more vertical space than the rows and forced the reader to work out
|
|
3098
|
+
which bucket a thing was in before reading what it was.
|
|
3099
|
+
|
|
3100
|
+
Linear and GitHub both do the opposite: one ranked stream, and the reason
|
|
3101
|
+
an item is there rides ON the row as a chip. The reason is a property of
|
|
3102
|
+
the item, so it survives the item moving between buckets — which is what
|
|
3103
|
+
made an expired gate unrecognisable when it fell out of the gate list.
|
|
3104
|
+
|
|
3105
|
+
Order: gates first (a signature is owed), then oldest first, because the
|
|
3106
|
+
longest-ignored row is the one a triage list is read for. -->
|
|
3107
|
+
<div class="inbox-section" id="inbox-queue-section">
|
|
2971
3108
|
<div class="inbox-section-head">
|
|
2972
3109
|
<span class="dot dot-purple"></span>
|
|
2973
|
-
<h3>
|
|
2974
|
-
<span class="inbox-count" id="inbox-
|
|
3110
|
+
<h3>Needs you</h3>
|
|
3111
|
+
<span class="inbox-count" id="inbox-queue-count">0</span>
|
|
2975
3112
|
</div>
|
|
2976
|
-
<div id="inbox-
|
|
3113
|
+
<div id="inbox-queue"></div>
|
|
2977
3114
|
</div>
|
|
2978
3115
|
<!-- Gates that did NOT wait for you. Placed directly under the decisions
|
|
2979
3116
|
you still owe, because "what proceeded without me" is the question a
|
|
@@ -2986,30 +3123,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2986
3123
|
</div>
|
|
2987
3124
|
<div id="inbox-standdown"></div>
|
|
2988
3125
|
</div>
|
|
2989
|
-
<div class="inbox-section" id="inbox-p0-section">
|
|
2990
|
-
<div class="inbox-section-head">
|
|
2991
|
-
<span class="dot dot-red"></span>
|
|
2992
|
-
<h3>P0 open</h3>
|
|
2993
|
-
<span class="inbox-count" id="inbox-p0-count">0</span>
|
|
2994
|
-
</div>
|
|
2995
|
-
<div id="inbox-p0"></div>
|
|
2996
|
-
</div>
|
|
2997
|
-
<div class="inbox-section" id="inbox-blocked-section">
|
|
2998
|
-
<div class="inbox-section-head">
|
|
2999
|
-
<span class="dot dot-orange"></span>
|
|
3000
|
-
<h3>Blocked</h3>
|
|
3001
|
-
<span class="inbox-count" id="inbox-blocked-count">0</span>
|
|
3002
|
-
</div>
|
|
3003
|
-
<div id="inbox-blocked"></div>
|
|
3004
|
-
</div>
|
|
3005
|
-
<div class="inbox-section" id="inbox-stale-section">
|
|
3006
|
-
<div class="inbox-section-head">
|
|
3007
|
-
<span class="dot dot-amber"></span>
|
|
3008
|
-
<h3>Stale (in progress > 48h)</h3>
|
|
3009
|
-
<span class="inbox-count" id="inbox-stale-count">0</span>
|
|
3010
|
-
</div>
|
|
3011
|
-
<div id="inbox-stale"></div>
|
|
3012
|
-
</div>
|
|
3013
3126
|
<!-- Idle focus: when nothing needs a decision, the four sections above collapse and this shows instead. -->
|
|
3014
3127
|
<div class="inbox-allclear" id="inbox-allclear" style="display:none">
|
|
3015
3128
|
<div class="ac-mark">✓</div>
|
|
@@ -3464,36 +3577,53 @@ function renderInbox(d) {
|
|
|
3464
3577
|
Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
|
|
3465
3578
|
// Same rule as the all-clear card: "nothing urgent" is a claim about data we
|
|
3466
3579
|
// may not have. When a read failed, say that instead of reassuring the user.
|
|
3467
|
-
|
|
3468
|
-
? "Some data could not be read — treat the counts below as incomplete."
|
|
3469
|
-
: (s.gates + s.p0 ? "Here's what needs your decision." : 'Nothing urgent — back to deep work.');
|
|
3470
|
-
document.getElementById('inbox-greet').textContent = `${greetByHour()} ${greetTail}`;
|
|
3471
|
-
// Only the tiles that have something in them.
|
|
3580
|
+
// The headline states the SITUATION, not the time of day.
|
|
3472
3581
|
//
|
|
3473
|
-
//
|
|
3474
|
-
//
|
|
3475
|
-
//
|
|
3476
|
-
//
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
//
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3582
|
+
// "Good morning. Here's what needs your decision." sat above a screen whose
|
|
3583
|
+
// every timestamp was 11 to 30 days old. The most important fact available —
|
|
3584
|
+
// that nothing had moved in eleven days — was the one thing the page never
|
|
3585
|
+
// said. Borrowed from Datadog's monitor states, where "No Data" is an alert in
|
|
3586
|
+
// its own right rather than the absence of one: silence is a reading.
|
|
3587
|
+
//
|
|
3588
|
+
// Age is taken across everything the screen knows about, so a quiet queue with
|
|
3589
|
+
// a live pipeline reads differently from a project that has stopped.
|
|
3590
|
+
const newestEventAgeH = (() => {
|
|
3591
|
+
const stamps = [
|
|
3592
|
+
...(d?.pending_gates || []), ...(d?.p0_open || []),
|
|
3593
|
+
...(d?.blocked || []), ...(d?.stale_in_progress || []),
|
|
3594
|
+
].map((t) => t?.updated_at).filter(Boolean)
|
|
3595
|
+
.concat((window.__pipelineStages || []).map((st) => st?.ts).filter(Boolean))
|
|
3596
|
+
.map((t) => Date.parse(t)).filter((n) => Number.isFinite(n));
|
|
3597
|
+
if (!stamps.length) return null;
|
|
3598
|
+
return (Date.now() - Math.max(...stamps)) / 3600e3;
|
|
3599
|
+
})();
|
|
3600
|
+
|
|
3601
|
+
const needs = s.gates + s.p0;
|
|
3602
|
+
const STALL_H = 72; // the same 72h the gate-expiry hook uses
|
|
3603
|
+
const greetEl = document.getElementById('inbox-greet');
|
|
3604
|
+
greetEl.setAttribute('role', 'status');
|
|
3605
|
+
greetEl.setAttribute('aria-live', 'polite');
|
|
3606
|
+
|
|
3607
|
+
if (anyDegraded()) {
|
|
3608
|
+
// A read failed. Reassurance would be a claim about data we do not have.
|
|
3609
|
+
greetEl.textContent = 'Some data could not be read — treat the counts below as incomplete.';
|
|
3610
|
+
} else if (newestEventAgeH != null && newestEventAgeH > STALL_H) {
|
|
3611
|
+
const days = Math.floor(newestEventAgeH / 24);
|
|
3612
|
+
greetEl.textContent = needs
|
|
3613
|
+
? `Stalled — nothing has moved in ${days} days, and ${needs} item${needs > 1 ? 's' : ''} ${needs > 1 ? 'are' : 'is'} waiting on you.`
|
|
3614
|
+
: `Stalled — nothing has moved in ${days} days, and nothing is waiting on you either.`;
|
|
3615
|
+
} else if (needs) {
|
|
3616
|
+
greetEl.textContent = "Here's what needs your decision.";
|
|
3617
|
+
} else {
|
|
3618
|
+
greetEl.textContent = newestEventAgeH == null
|
|
3619
|
+
? 'Nothing recorded yet for this project.'
|
|
3620
|
+
: `All clear — last activity ${relTime(new Date(Date.now() - newestEventAgeH * 3600e3).toISOString())}.`;
|
|
3621
|
+
}
|
|
3622
|
+
// The stat-tile row is gone: two of its four boxes read 0 on an ordinary
|
|
3623
|
+
// morning, and the two that did not repeated a count the section headings
|
|
3624
|
+
// below already carry. The counts still reach the nav badge.
|
|
3625
|
+
renderQueue(d);
|
|
3626
|
+
renderStatusVerdict(d, newestEventAgeH);
|
|
3497
3627
|
refreshStandDowns();
|
|
3498
3628
|
refreshReceipt();
|
|
3499
3629
|
refreshBudgetLine();
|
|
@@ -3506,11 +3636,122 @@ function renderInbox(d) {
|
|
|
3506
3636
|
// zero and the card used to headline "Nothing needs your decision" — maximum
|
|
3507
3637
|
// confidence at exactly the moment we knew least. Absence of findings is not
|
|
3508
3638
|
// a finding of absence.
|
|
3509
|
-
|
|
3639
|
+
//
|
|
3640
|
+
// And "nothing needs you" has TWO readings that deserve different words.
|
|
3641
|
+
//
|
|
3642
|
+
// quiet because the work is done → rest, and the actions offer more work
|
|
3643
|
+
// quiet because nothing is running → that is not rest, it is a stopped
|
|
3644
|
+
// machine, and "back to deep work" is
|
|
3645
|
+
// the wrong thing to say to it
|
|
3646
|
+
//
|
|
3647
|
+
// Today's board is the second, and until now the screen had no way to say so:
|
|
3648
|
+
// an empty queue rendered the same congratulation either way.
|
|
3649
|
+
if (ac) {
|
|
3650
|
+
const show = attention === 0 && !anyDegraded();
|
|
3651
|
+
ac.style.display = show ? '' : 'none';
|
|
3652
|
+
if (show) {
|
|
3653
|
+
const stalled = newestEventAgeH != null && newestEventAgeH > STALL_H;
|
|
3654
|
+
const txt = ac.querySelector('.ac-text');
|
|
3655
|
+
const mark = ac.querySelector('.ac-mark');
|
|
3656
|
+
if (stalled) {
|
|
3657
|
+
const days = Math.floor(newestEventAgeH / 24);
|
|
3658
|
+
if (mark) mark.textContent = '·';
|
|
3659
|
+
if (txt) txt.innerHTML = `<b>Nothing is waiting on you.</b> Nothing has run in `
|
|
3660
|
+
+ `${days} days either — this project is idle, not finished.`;
|
|
3661
|
+
} else {
|
|
3662
|
+
if (mark) mark.textContent = '\u2713';
|
|
3663
|
+
if (txt) txt.innerHTML = '<b>All clear.</b> Nothing needs your decision — back to deep work.';
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3510
3667
|
renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
|
|
3511
3668
|
'Some project data could not be read — counts below are incomplete.');
|
|
3512
3669
|
}
|
|
3513
3670
|
|
|
3671
|
+
/**
|
|
3672
|
+
* The line a closed disclosure says out loud.
|
|
3673
|
+
*
|
|
3674
|
+
* "Project status" on its own is a label, not an answer: closed, it told the
|
|
3675
|
+
* reader nothing, so the only way to learn anything was to open it — which is
|
|
3676
|
+
* the cost the disclosure was supposed to remove. It now carries the verdict, and
|
|
3677
|
+
* the reader opens it only when the verdict is worth chasing.
|
|
3678
|
+
*/
|
|
3679
|
+
function renderStatusVerdict(d, ageH) {
|
|
3680
|
+
const el = document.getElementById('proj-status-verdict');
|
|
3681
|
+
if (!el) return;
|
|
3682
|
+
const parts = [];
|
|
3683
|
+
if (ageH == null) parts.push('no activity recorded');
|
|
3684
|
+
else if (ageH < 1) parts.push(`last activity ${Math.max(1, Math.round(ageH * 60))}m ago`);
|
|
3685
|
+
else if (ageH < 48) parts.push(`last activity ${Math.round(ageH)}h ago`);
|
|
3686
|
+
else parts.push(`idle ${Math.floor(ageH / 24)}d`);
|
|
3687
|
+
|
|
3688
|
+
const stages = window.__pipelineStages || [];
|
|
3689
|
+
const active = stages.filter((x) => x && x.status === 'active').length;
|
|
3690
|
+
if (stages.length) parts.push(active ? `${active} stage${active > 1 ? 's' : ''} active` : 'pipeline idle');
|
|
3691
|
+
|
|
3692
|
+
// A budget that cannot be measured is not a budget that is fine.
|
|
3693
|
+
const budget = document.getElementById('inbox-budget-line');
|
|
3694
|
+
if (budget) parts.push('budgets unmeasured');
|
|
3695
|
+
|
|
3696
|
+
el.textContent = ' · ' + parts.join(' · ');
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
/**
|
|
3700
|
+
* The one queue. Extracted from renderInbox because the receipt fetch finishes
|
|
3701
|
+
* AFTER the first render and has to put its row in — and calling renderInbox from
|
|
3702
|
+
* refreshReceipt, which renderInbox itself calls, is an infinite loop. Rendering
|
|
3703
|
+
* only the list it needs to change is both correct and cheaper.
|
|
3704
|
+
*/
|
|
3705
|
+
function renderQueue(d) {
|
|
3706
|
+
// One queue. The reason an item is here travels ON the item, so it survives the
|
|
3707
|
+
// item changing buckets — the failure that made an expired gate unreadable when
|
|
3708
|
+
// it dropped out of `pending_gates`.
|
|
3709
|
+
const ageD = (t) => {
|
|
3710
|
+
const h = ageHours(t?.updated_at);
|
|
3711
|
+
return h == null ? null : Math.floor(h / 24);
|
|
3712
|
+
};
|
|
3713
|
+
const withReason = (list, reason) => (list || []).map((t) => ({
|
|
3714
|
+
...t,
|
|
3715
|
+
__reason: typeof reason === 'function' ? reason(t) : reason,
|
|
3716
|
+
}));
|
|
3717
|
+
const queue = [
|
|
3718
|
+
// An expired gate is still a gate, and saying so is the whole point: the row
|
|
3719
|
+
// that lost its buttons for 699 hours looked like an ordinary P0.
|
|
3720
|
+
...withReason(d?.pending_gates, (t) =>
|
|
3721
|
+
(t.raw_status === 'blocked' ? 'expired gate' : 'gate')),
|
|
3722
|
+
...withReason(d?.p0_open, (t) => (t.is_gate ? 'expired gate' : 'P0')),
|
|
3723
|
+
...withReason(d?.blocked, 'blocked'),
|
|
3724
|
+
...withReason(d?.stale_in_progress, (t) => {
|
|
3725
|
+
const dd = ageD(t);
|
|
3726
|
+
return dd == null ? 'stale' : `stale ${dd}d`;
|
|
3727
|
+
}),
|
|
3728
|
+
];
|
|
3729
|
+
// The receipt-drift row, when there is one. It is not a task, so it arrives as
|
|
3730
|
+
// a synthetic item rather than from any bucket.
|
|
3731
|
+
if (window.__receiptDrift) queue.push(window.__receiptDrift);
|
|
3732
|
+
// Gates first — a signature is owed. Then oldest first, because the
|
|
3733
|
+
// longest-ignored row is what a triage list is read for.
|
|
3734
|
+
queue.sort((a, b) => {
|
|
3735
|
+
const ga = a.is_gate ? 0 : 1, gb = b.is_gate ? 0 : 1;
|
|
3736
|
+
if (ga !== gb) return ga - gb;
|
|
3737
|
+
const ah = ageHours(a.updated_at), bh = ageHours(b.updated_at);
|
|
3738
|
+
if (ah == null) return 1;
|
|
3739
|
+
if (bh == null) return -1;
|
|
3740
|
+
return bh - ah;
|
|
3741
|
+
});
|
|
3742
|
+
renderInboxList('inbox-queue', queue, 'inbox-queue-count', { reasons: true });
|
|
3743
|
+
// The heading claims what the rows can deliver, and no more. "Needs you" over a
|
|
3744
|
+
// list of things you cannot touch is the same overclaim this screen spent the
|
|
3745
|
+
// day removing — it was simply mine.
|
|
3746
|
+
// "Needs you" means a decision only you can make — a gate to sign. Everything
|
|
3747
|
+
// else is work that continues elsewhere, and saying otherwise is the overclaim
|
|
3748
|
+
// that made six rows promise an action five of them did not have.
|
|
3749
|
+
const decidable = queue.some((t) => t.is_gate);
|
|
3750
|
+
const head = document.querySelector('#inbox-queue-section h3');
|
|
3751
|
+
if (head) head.textContent = queue.length === 0 ? 'Needs you'
|
|
3752
|
+
: (decidable ? 'Needs you' : 'Waiting on work elsewhere');
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3514
3755
|
/**
|
|
3515
3756
|
* Does the last approval still describe what is in the tree?
|
|
3516
3757
|
*
|
|
@@ -3569,11 +3810,38 @@ async function refreshReceipt() {
|
|
|
3569
3810
|
//
|
|
3570
3811
|
// Moved, not removed: same wording, same detail, same page, still unmissable
|
|
3571
3812
|
// if you read to the bottom, and it still never renders as "checked and fine".
|
|
3813
|
+
// `differs` now becomes a ROW IN THE QUEUE rather than a line above it.
|
|
3814
|
+
//
|
|
3815
|
+
// The queue exists so that everything asking for the operator is in one place.
|
|
3816
|
+
// A purple line floating above it was a second place — smaller, but a reader
|
|
3817
|
+
// still had to notice it separately and decide whether it counted. It carries
|
|
3818
|
+
// the chip `review drift`, sorts with everything else, and is reachable by
|
|
3819
|
+
// keyboard like any other row.
|
|
3820
|
+
//
|
|
3821
|
+
// `unreadable` and `extended` stay in the footer. "Cannot check" is true and
|
|
3822
|
+
// careful and asks for nothing; putting it in a queue named for what needs you
|
|
3823
|
+
// would be the overclaim this whole screen has been unwinding.
|
|
3572
3824
|
const actionable = d.state === 'differs';
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3825
|
+
if (actionable) {
|
|
3826
|
+
el.style.display = 'none';
|
|
3827
|
+
window.__receiptDrift = {
|
|
3828
|
+
id: 'receipt',
|
|
3829
|
+
title: `${changedFiles.length} reviewed file(s) changed after approval`,
|
|
3830
|
+
desc: body,
|
|
3831
|
+
updated_at: d.ts || null,
|
|
3832
|
+
__reason: 'review drift',
|
|
3833
|
+
__synthetic: true,
|
|
3834
|
+
__files: changedFiles,
|
|
3835
|
+
};
|
|
3836
|
+
} else {
|
|
3837
|
+
window.__receiptDrift = null;
|
|
3838
|
+
const host = foot || el;
|
|
3839
|
+
host.style.display = '';
|
|
3840
|
+
host.innerHTML = `<span style="color:var(--text3)">${body}</span>`;
|
|
3841
|
+
}
|
|
3842
|
+
// The queue was rendered before this fetch returned, so it has to be rebuilt
|
|
3843
|
+
// with the row in it. Silent if the Inbox is not the open panel.
|
|
3844
|
+
if (window.__inboxData) renderQueue(window.__inboxData);
|
|
3577
3845
|
}
|
|
3578
3846
|
|
|
3579
3847
|
/**
|
|
@@ -3748,7 +4016,8 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
3748
4016
|
// longest-ignored row at the top, which is the order a triage list is read in.
|
|
3749
4017
|
// Gates keep their server order: their own list is already time-ordered and
|
|
3750
4018
|
// their ⏳ stamp is the loud field.
|
|
3751
|
-
|
|
4019
|
+
// A queue built with reasons arrives already ranked; re-sorting would undo it.
|
|
4020
|
+
const rows = (opts.showApprove || opts.reasons)
|
|
3752
4021
|
? items
|
|
3753
4022
|
: items.slice().sort((a, b) => {
|
|
3754
4023
|
const ah = ageHours(a.updated_at), bh = ageHours(b.updated_at);
|
|
@@ -3779,7 +4048,7 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
3779
4048
|
alsoTag,
|
|
3780
4049
|
// Gates (the showApprove list) get the exact date+time the decision has been
|
|
3781
4050
|
// waiting since; other rows carry their age in the right-hand column instead.
|
|
3782
|
-
opts.showApprove && t.updated_at
|
|
4051
|
+
(opts.showApprove || t.is_gate === true) && t.updated_at
|
|
3783
4052
|
? `<span title="${esc(fmtDate(t.updated_at))}" style="color:var(--status-gate);font-weight:600">⏳ ${fmtDT(t.updated_at)}</span>`
|
|
3784
4053
|
: '',
|
|
3785
4054
|
].filter(Boolean).join(' · ');
|
|
@@ -3793,7 +4062,43 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
3793
4062
|
// this change is removing.
|
|
3794
4063
|
const hrs = ageHours(t.updated_at);
|
|
3795
4064
|
const aged = !opts.showApprove && opts.implies !== 'age' && hrs != null && hrs >= 48;
|
|
3796
|
-
|
|
4065
|
+
// A gate can be approved because it IS a gate — not because of which list it
|
|
4066
|
+
// happened to land in.
|
|
4067
|
+
//
|
|
4068
|
+
// `gate-expiry.mjs` marks a gate `blocked` once it has waited 72 hours.
|
|
4069
|
+
// `getInbox` excludes blocked gates from `pending_gates`, so the item drops
|
|
4070
|
+
// into `p0_open`, which renders without `showApprove` — and loses its
|
|
4071
|
+
// Approve/Reject buttons. The hook built to draw attention to a neglected
|
|
4072
|
+
// gate quietly removed the only way to answer it. Observed on a gate that had
|
|
4073
|
+
// then waited 699 hours with nothing on screen able to end that wait.
|
|
4074
|
+
//
|
|
4075
|
+
// The affordance now follows `is_gate`, which is a fact about the task rather
|
|
4076
|
+
// than about the query that fetched it.
|
|
4077
|
+
const canDecide = opts.showApprove || t.is_gate === true;
|
|
4078
|
+
// A row with no action is a row that says "needs you" and means "look at me".
|
|
4079
|
+
//
|
|
4080
|
+
// Merging four sections under one "Needs you" heading made that promise for
|
|
4081
|
+
// all of them, and five of six rows could not be acted on from this board at
|
|
4082
|
+
// all: the only write action it has is approving a gate. The operator's words
|
|
4083
|
+
// were "но я не могу ничего сделать" — and they were right.
|
|
4084
|
+
//
|
|
4085
|
+
// The board does not dispatch agents; it hands you the line that does. That is
|
|
4086
|
+
// the same copy-not-dispatch the pipeline rail already uses on a failed stage,
|
|
4087
|
+
// and it is the honest affordance for a task that has sat for a month: this
|
|
4088
|
+
// cannot be finished here, here is what finishes it.
|
|
4089
|
+
// The drift row is not a task and has no agent, so it needs its own line: the
|
|
4090
|
+
// one that shows what changed under an approval. Files come from the receipt.
|
|
4091
|
+
const diffBtn = (t.__synthetic && Array.isArray(t.__files) && t.__files.length)
|
|
4092
|
+
? `<div class="actions" onclick="event.stopPropagation()">`
|
|
4093
|
+
+ `<button type="button" class="gate-btn" title="Copy a git diff limited to the reviewed files"`
|
|
4094
|
+
+ ` onclick="event.stopPropagation(); copyDiff(${esc(JSON.stringify(JSON.stringify(t.__files)))})">Copy diff</button></div>`
|
|
4095
|
+
: '';
|
|
4096
|
+
const rerun = (!canDecide && !t.__synthetic && t.agent && t.id)
|
|
4097
|
+
? `<div class="actions" onclick="event.stopPropagation()">`
|
|
4098
|
+
+ `<button type="button" class="gate-btn" title="Copy the line that re-runs this agent"`
|
|
4099
|
+
+ ` onclick="event.stopPropagation(); copyRerun('${esc(t.agent)}')">Copy re-run</button></div>`
|
|
4100
|
+
: '';
|
|
4101
|
+
const actions = canDecide
|
|
3797
4102
|
? `<div class="actions" onclick="event.stopPropagation()">
|
|
3798
4103
|
<button class="gate-btn gate-approve" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'approve')">Approve</button>
|
|
3799
4104
|
<button class="gate-btn gate-reject" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'reject')">Reject</button>
|
|
@@ -3814,12 +4119,26 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
3814
4119
|
? `<span class="ttl-desc"${ctx.wire ? ` title="${esc(ctx.wire)}"` : ''}>${esc(shown.slice(0, 140))}${shown.length > 140 ? '…' : ''}</span>`
|
|
3815
4120
|
: '';
|
|
3816
4121
|
// Only gate rows can stand down, so only they carry the note.
|
|
3817
|
-
const tierNote = opts.showApprove ? gateTierNote(t) : '';
|
|
4122
|
+
const tierNote = (opts.showApprove || t.is_gate === true) ? gateTierNote(t) : '';
|
|
4123
|
+
// Reachable by keyboard. Every row in this queue was a bare `div` with an
|
|
4124
|
+
// onclick — no tabindex, no role, no key handler — so the whole list of
|
|
4125
|
+
// things needing the operator could be seen and never opened without a
|
|
4126
|
+
// mouse. Enter and Space now do what a click does, and the row announces
|
|
4127
|
+
// itself as a button instead of as anonymous text.
|
|
4128
|
+
// Why this row is here, as a property of the row. Four headed sections used to
|
|
4129
|
+
// carry this fact in their headings, where it was lost the moment an item moved
|
|
4130
|
+
// between them — which is how an expired gate became indistinguishable from an
|
|
4131
|
+
// ordinary P0 for 699 hours.
|
|
4132
|
+
const REASON_TONE = { 'expired gate': 'gate', gate: 'gate', P0: 'p0', blocked: 'blocked' };
|
|
4133
|
+
const tone = REASON_TONE[t.__reason] || (String(t.__reason || '').startsWith('stale') ? 'stale' : '');
|
|
4134
|
+
const reasonChip = (opts.reasons && t.__reason)
|
|
4135
|
+
? `<span class="why why-${tone}">${esc(t.__reason)}</span>`
|
|
4136
|
+
: '';
|
|
3818
4137
|
return `
|
|
3819
|
-
<div class="inbox-row${aged ? ' row-aged' : ''}" onclick='openSide(${JSON.stringify(t).replace(/'/g, "'")})'>
|
|
4138
|
+
<div class="inbox-row${aged ? ' row-aged' : ''}" tabindex="0" role="button" aria-label="${esc(t.title || t.id)}" onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();this.click();}' onclick='openSide(${JSON.stringify(t).replace(/'/g, "'")})'>
|
|
3820
4139
|
<span class="id">${esc(t.id || '')}</span>
|
|
3821
|
-
<span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
|
|
3822
|
-
${actions}
|
|
4140
|
+
<span class="ttl">${reasonChip}${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
|
|
4141
|
+
${actions}${rerun}${diffBtn}
|
|
3823
4142
|
</div>`;
|
|
3824
4143
|
}).join('');
|
|
3825
4144
|
}
|
|
@@ -4677,7 +4996,7 @@ function renderPipeline(stages) {
|
|
|
4677
4996
|
? `oldest ${s.age_min < 60 ? s.age_min + 'm' : Math.round(s.age_min/60)+'h'}`
|
|
4678
4997
|
: 'waiting';
|
|
4679
4998
|
return `
|
|
4680
|
-
<div class="pl-stage pl-gate active" title="Human gate — irreversible actions need a signature here. Click to see pending gates." onclick="drillToGates()">
|
|
4999
|
+
<div class="pl-stage pl-gate active${(s.age_min != null && s.age_min < 60) ? ' fresh' : ''}" title="Human gate — irreversible actions need a signature here. Click to see pending gates." onclick="drillToGates()">
|
|
4681
5000
|
<div class="pl-name"><span class="gate-glyph">✍</span>${label}<span class="gate-badge">${s.pending}</span></div>
|
|
4682
5001
|
${s.last_message ? `<div class="pl-msg">${esc(s.last_message)}</div>` : ''}
|
|
4683
5002
|
<div class="pl-meta">${meta}</div>
|
|
@@ -4698,6 +5017,9 @@ function renderPipeline(stages) {
|
|
|
4698
5017
|
// age, which is what the reader was looking at anyway.
|
|
4699
5018
|
const msg = s.last_message ? `<div class="pl-msg">${esc(s.last_message)}</div>` : '';
|
|
4700
5019
|
const meta = s.age_min != null ? `${s.age_min < 60 ? s.age_min + 'm' : Math.round(s.age_min/60)+'h'} ago` : '';
|
|
5020
|
+
// Within the hour is live; anything older is a record of something that
|
|
5021
|
+
// finished. Only the first earns the heartbeat.
|
|
5022
|
+
const fresh = s.age_min != null && s.age_min < 60 ? ' fresh' : '';
|
|
4701
5023
|
// A failed stage offered one affordance: filter the Kanban by it. That is
|
|
4702
5024
|
// the same thing every other stage offers, and it is not what you want when
|
|
4703
5025
|
// something broke. GitHub Actions puts "Re-run failed jobs" on the failure
|
|
@@ -4712,7 +5034,7 @@ function renderPipeline(stages) {
|
|
|
4712
5034
|
title="Copy the command that re-runs this stage">re-run</button>
|
|
4713
5035
|
</div>` : '';
|
|
4714
5036
|
return `
|
|
4715
|
-
<div class="pl-stage ${s.status}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
|
|
5037
|
+
<div class="pl-stage ${s.status}${fresh}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
|
|
4716
5038
|
<div class="pl-name"><span class="dot"></span>${label}</div>
|
|
4717
5039
|
${msg}
|
|
4718
5040
|
<div class="pl-meta">${meta || s.status}</div>
|
|
@@ -4747,6 +5069,17 @@ function copyRerun(stage) {
|
|
|
4747
5069
|
);
|
|
4748
5070
|
}
|
|
4749
5071
|
|
|
5072
|
+
/** The same copy-not-dispatch: the board shows what changed, it does not open it. */
|
|
5073
|
+
function copyDiff(filesJson) {
|
|
5074
|
+
let files = [];
|
|
5075
|
+
try { files = JSON.parse(filesJson); } catch { /* nothing to scope to */ }
|
|
5076
|
+
const cmd = files.length ? `git diff -- ${files.join(' ')}` : 'git diff';
|
|
5077
|
+
navigator.clipboard.writeText(cmd).then(
|
|
5078
|
+
() => showToast(`Copied <code>${esc(cmd.slice(0, 70))}…</code>`, 'success', 3000),
|
|
5079
|
+
() => showToast(`Run <code>${esc(cmd.slice(0, 70))}</code>`, 'info', 4000),
|
|
5080
|
+
);
|
|
5081
|
+
}
|
|
5082
|
+
|
|
4750
5083
|
// Click the human-gate node → jump to the gates/inbox view where they get signed.
|
|
4751
5084
|
function drillToGates() {
|
|
4752
5085
|
const search = document.getElementById('search-input');
|
|
@@ -13,7 +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 {
|
|
16
|
+
import { warmTasksAsync } from './lib/beads.mjs';
|
|
17
17
|
import { originAllowed, isInsideDir } from './lib/util.mjs';
|
|
18
18
|
import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
|
|
19
19
|
import { startAlertCron } from './lib/alerts.mjs';
|
|
@@ -115,27 +115,34 @@ server.listen(PORT, HOST, () => {
|
|
|
115
115
|
log.info(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
|
|
116
116
|
log.info(` links; put your reverse-proxy auth in front for anything admin-grade.`);
|
|
117
117
|
}
|
|
118
|
-
// Warm the task cache for THIS project before a browser asks.
|
|
118
|
+
// Warm the task cache for THIS project before a browser asks — asynchronously.
|
|
119
119
|
//
|
|
120
|
-
// `bd list` costs seconds
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// warm.
|
|
120
|
+
// `bd list` costs seconds. This used to call `getTasks(cwd)` directly, which
|
|
121
|
+
// is `bdList()`'s COLD-cache path — `spawnSync` — because a cache with
|
|
122
|
+
// nothing in it has nothing to fall back on while a refresh runs. That
|
|
123
|
+
// reasoning is right for a REQUEST; it is wrong here, because nobody is
|
|
124
|
+
// waiting on a boot warm-up. The comment this replaced said moving the call
|
|
125
|
+
// here "moves the stall to before anyone is looking" — true of the
|
|
126
|
+
// `.listen()` callback, false of the port: `setImmediate` only defers past
|
|
127
|
+
// that callback, not past `accept()`, and the port is already open. Every
|
|
128
|
+
// connection accepted during the spawnSync — including one with nothing to
|
|
129
|
+
// do with this cwd, like /api/version's plain readdirSync — queued behind
|
|
130
|
+
// it. Measured with an artificially slow `bd` (GREAT_CTO_BD_BIN): every
|
|
131
|
+
// endpoint answered `000` for the fixture's entire delay, whatever it was
|
|
132
|
+
// asked for, because the one process serving them was blocked inside
|
|
133
|
+
// spawnSync the whole time.
|
|
125
134
|
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// the
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
log.warn(` → could not warm the task cache: ${e?.message || e}`);
|
|
138
|
-
}
|
|
135
|
+
// warmTasksAsync (lib/beads.mjs) runs the same `bd list` through `spawn`
|
|
136
|
+
// instead — the same non-blocking path already used for warm-cache
|
|
137
|
+
// "stale-while-revalidate" reads — so the event loop is free for the whole
|
|
138
|
+
// fill, cold cache or not.
|
|
139
|
+
warmTasksAsync(process.cwd()).then(({ ok, count, ms, skipped }) => {
|
|
140
|
+
if (skipped) return;
|
|
141
|
+
if (ok) log.info(` → task cache warmed in ${ms}ms (${count} task${count === 1 ? '' : 's'})`);
|
|
142
|
+
// A warm-up that failed changes nothing a request would not have hit
|
|
143
|
+
// anyway; it must never stop the board from serving. bdFailureFor(cwd)
|
|
144
|
+
// carries the reason for whoever asks next.
|
|
145
|
+
else log.warn(` → could not warm the task cache after ${ms}ms (see bdFailureFor)`);
|
|
139
146
|
});
|
|
140
147
|
|
|
141
148
|
// Discover all great_cto projects on disk asynchronously — don't block
|
package/dist/board-daemon.js
CHANGED
|
@@ -129,17 +129,38 @@ export function daemonSpec(platform, o) {
|
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
131
|
/**
|
|
132
|
-
* Decide what `board ensure` should do
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
132
|
+
* Decide what `board ensure` should do.
|
|
133
|
+
*
|
|
134
|
+
* THE PORT DECIDES, NOT THE PID FILE.
|
|
135
|
+
*
|
|
136
|
+
* This asked the pid first: `pid === null || !alive → start`. A board started any
|
|
137
|
+
* other way — `node packages/board/server.mjs`, a terminal someone left open, a
|
|
138
|
+
* pid file lost to a reboot — leaves no pid this CLI wrote, so ensure declared it
|
|
139
|
+
* absent and spawned a second server onto the occupied port. That one died on
|
|
140
|
+
* EADDRINUSE, its pid was recorded anyway, and the file then named a corpse while
|
|
141
|
+
* a perfectly healthy board answered every request. Observed exactly that: pid
|
|
142
|
+
* file 56328, dead; port 3141 served by 47326, HTTP 200.
|
|
143
|
+
*
|
|
144
|
+
* Every later run repeated it. A health gate that fails forever while reporting
|
|
145
|
+
* success is worse than no health gate.
|
|
146
|
+
*
|
|
147
|
+
* ADR-007 already said the decision is pid-alive AND port-healthy. This is that
|
|
148
|
+
* sentence, with the port asked first, because the port is what a user opens:
|
|
149
|
+
*
|
|
150
|
+
* port answering, pid ours → noop (nothing to do)
|
|
151
|
+
* port answering, pid not ours → adopt (someone else's healthy board; leave it)
|
|
152
|
+
* port hung, pid alive → restart (the case a liveness supervisor misses)
|
|
153
|
+
* port hung, no live pid → start
|
|
154
|
+
*
|
|
155
|
+
* `adopt` exists so the caller does not overwrite the pid file with a process it
|
|
156
|
+
* did not start. A pid we cannot vouch for is worse than no pid at all.
|
|
136
157
|
*/
|
|
137
158
|
export function decideEnsureAction(s) {
|
|
138
|
-
if (s.
|
|
139
|
-
return "
|
|
140
|
-
if (
|
|
159
|
+
if (s.healthy)
|
|
160
|
+
return s.pid !== null && s.alive ? "noop" : "adopt";
|
|
161
|
+
if (s.pid !== null && s.alive)
|
|
141
162
|
return "restart";
|
|
142
|
-
return "
|
|
163
|
+
return "start";
|
|
143
164
|
}
|
|
144
165
|
/**
|
|
145
166
|
* Is this HTTP response the great_cto board, or just something on that port?
|
package/dist/main.js
CHANGED
|
@@ -429,6 +429,32 @@ async function spawnDetachedBoard(port) {
|
|
|
429
429
|
catch { /* best-effort */ }
|
|
430
430
|
return child.pid;
|
|
431
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* How long a board may take to start answering before something is wrong.
|
|
434
|
+
*
|
|
435
|
+
* Measured, not chosen: this board serves /api/version at ~2.4s, falls silent for
|
|
436
|
+
* ~37s while discovering projects, and recovers. 60s leaves headroom on a slower
|
|
437
|
+
* machine with more projects. It is a ceiling on patience, not a target — a board
|
|
438
|
+
* that answers in two seconds costs two seconds.
|
|
439
|
+
*/
|
|
440
|
+
const WARM_WINDOW_MS = 60_000;
|
|
441
|
+
/**
|
|
442
|
+
* Poll until the board answers, or give up.
|
|
443
|
+
*
|
|
444
|
+
* `spawn` returning a pid means the OS created a process, not that a board is
|
|
445
|
+
* serving. A child that dies on EADDRINUSE or a bad build has a pid for a few
|
|
446
|
+
* milliseconds, and the previous code announced "board started" on the strength
|
|
447
|
+
* of it — then wrote that pid to the file the next run would trust.
|
|
448
|
+
*/
|
|
449
|
+
async function waitForBoard(port, timeoutMs) {
|
|
450
|
+
const deadline = Date.now() + timeoutMs;
|
|
451
|
+
while (Date.now() < deadline) {
|
|
452
|
+
if (await probeBoardPort(port))
|
|
453
|
+
return true;
|
|
454
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
455
|
+
}
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
432
458
|
/**
|
|
433
459
|
* `great-cto board ensure` — idempotent health gate. Starts the board only if it
|
|
434
460
|
* isn't already answering; never kills a healthy instance. Safe to call from a
|
|
@@ -438,12 +464,38 @@ async function runBoardEnsure(args) {
|
|
|
438
464
|
const port = args.boardPort;
|
|
439
465
|
const pid = readBoardPid();
|
|
440
466
|
const alive = pid !== null && isPidAlive(pid);
|
|
441
|
-
|
|
467
|
+
// Probe the port ALWAYS, not only when a pid file happens to exist. The port is
|
|
468
|
+
// what a user opens; a missing pid file means this CLI did not start the board,
|
|
469
|
+
// not that no board is running. Gating the probe on `alive` is what let ensure
|
|
470
|
+
// spawn a second server onto an occupied port, over and over.
|
|
471
|
+
// One failed probe is not a hung board.
|
|
472
|
+
//
|
|
473
|
+
// Measured on this machine: the board binds the port, answers /api/version at
|
|
474
|
+
// ~2.4s, then goes SILENT for roughly 37 seconds while it discovers projects,
|
|
475
|
+
// and only then serves normally. A single probe inside that window says "not
|
|
476
|
+
// answering" about a board that is merely starting — and the old code then
|
|
477
|
+
// restarted it, which starts the window again. That is a restart loop, and it
|
|
478
|
+
// is exactly what a supervisor calling this every minute would have produced.
|
|
479
|
+
//
|
|
480
|
+
// So a live pid gets the benefit of the doubt: several probes across the warm
|
|
481
|
+
// window before anything is declared hung. A board with no live pid needs no
|
|
482
|
+
// patience — one probe answers whether something is already serving.
|
|
483
|
+
const healthy = alive
|
|
484
|
+
? await waitForBoard(port, WARM_WINDOW_MS)
|
|
485
|
+
: await probeBoardPort(port);
|
|
442
486
|
const action = decideEnsureAction({ pid, alive, healthy });
|
|
443
487
|
if (action === "noop") {
|
|
444
488
|
log(` ${green("✓")} board already running → http://localhost:${port} (pid ${pid})`);
|
|
445
489
|
return 0;
|
|
446
490
|
}
|
|
491
|
+
if (action === "adopt") {
|
|
492
|
+
// Deliberately does NOT record a pid: this process is not ours to vouch for,
|
|
493
|
+
// and a pid file naming something we did not start is how the last one came
|
|
494
|
+
// to name a corpse.
|
|
495
|
+
log(` ${green("✓")} board already answering → http://localhost:${port}`);
|
|
496
|
+
log(` ${dim("started outside this CLI — left alone")}`);
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
447
499
|
if (action === "restart") {
|
|
448
500
|
log(` ${dim(`board pid ${pid} alive but not answering on ${port} — restarting…`)}`);
|
|
449
501
|
await killExistingBoard();
|
|
@@ -451,6 +503,21 @@ async function runBoardEnsure(args) {
|
|
|
451
503
|
const newPid = await spawnDetachedBoard(port);
|
|
452
504
|
if (!newPid)
|
|
453
505
|
return 1;
|
|
506
|
+
// Spawning is not starting. The previous version announced success the moment
|
|
507
|
+
// it had a pid, so a child that died immediately — EADDRINUSE, a syntax error,
|
|
508
|
+
// a missing build — was reported as a started board and its pid was written
|
|
509
|
+
// down. Wait for the port to actually answer before claiming anything.
|
|
510
|
+
// Same window: a fresh board is not late, it is warming.
|
|
511
|
+
const cameUp = await waitForBoard(port, WARM_WINDOW_MS);
|
|
512
|
+
if (!cameUp) {
|
|
513
|
+
try {
|
|
514
|
+
unlinkSync(boardPidFilePath());
|
|
515
|
+
}
|
|
516
|
+
catch { /* nothing to clean up */ }
|
|
517
|
+
error(`board did not come up on ${port} within 8s — pid ${newPid} exited or is not serving.`);
|
|
518
|
+
log(dim(` Something else may hold the port: lsof -nP -iTCP:${port} -sTCP:LISTEN`));
|
|
519
|
+
return 1;
|
|
520
|
+
}
|
|
454
521
|
log(` ${green("✓")} board ${action === "restart" ? "restarted" : "started"} → http://localhost:${port} (pid ${newPid})`);
|
|
455
522
|
return 0;
|
|
456
523
|
}
|
package/package.json
CHANGED
package/postinstall.mjs
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
38
|
+
import { spawn } from 'node:child_process';
|
|
38
39
|
import { join, dirname } from 'node:path';
|
|
39
40
|
import { homedir } from 'node:os';
|
|
40
41
|
import { fileURLToPath } from 'node:url';
|
|
@@ -116,6 +117,54 @@ function main() {
|
|
|
116
117
|
|
|
117
118
|
const r = compare({ cli, plugin: newestPluginVersion(cacheRoot) });
|
|
118
119
|
if (r.state === 'stale') process.stdout.write(message(r) + '\n');
|
|
120
|
+
|
|
121
|
+
ensureBoard();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Bring the board up, because an admin panel you have to remember to start is one
|
|
126
|
+
* you find down.
|
|
127
|
+
*
|
|
128
|
+
* ADR-007 accepted "board always-on" in v2.86.0 and shipped `great-cto board
|
|
129
|
+
* ensure` — an idempotent health gate that starts the board only if nothing is
|
|
130
|
+
* answering. Nothing ever called it from an install. The decision existed, the
|
|
131
|
+
* mechanism existed, and the two were never connected, so every upgrade left the
|
|
132
|
+
* panel down until somebody typed the command.
|
|
133
|
+
*
|
|
134
|
+
* Four rules this obeys, because a postinstall hook that breaks an install is
|
|
135
|
+
* worse than one that does nothing:
|
|
136
|
+
*
|
|
137
|
+
* - never fail. `npm install` must succeed even if this cannot run at all.
|
|
138
|
+
* - never block. The board takes the better part of a minute to become
|
|
139
|
+
* responsive; the installer does not wait for it. Detached, unref'd, output
|
|
140
|
+
* discarded.
|
|
141
|
+
* - never in CI, and never when asked not to. Both already guard the notice
|
|
142
|
+
* above; `GREAT_CTO_NO_BOARD=1` opts out of this specifically.
|
|
143
|
+
* - never twice. `ensure` probes the port first and adopts a board that is
|
|
144
|
+
* already answering, whoever started it.
|
|
145
|
+
*/
|
|
146
|
+
function ensureBoard() {
|
|
147
|
+
if (process.env.GREAT_CTO_NO_BOARD) return;
|
|
148
|
+
try {
|
|
149
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
150
|
+
const entry = join(here, 'index.mjs');
|
|
151
|
+
if (!existsSync(entry)) return;
|
|
152
|
+
|
|
153
|
+
const child = spawn(process.execPath, [entry, 'board', 'ensure'], {
|
|
154
|
+
detached: true,
|
|
155
|
+
stdio: 'ignore',
|
|
156
|
+
});
|
|
157
|
+
child.unref();
|
|
158
|
+
process.stdout.write(' great-cto: starting the board — http://localhost:3141\n');
|
|
159
|
+
} catch (e) {
|
|
160
|
+
// Never fail the install — but never fail SILENTLY either. The first version
|
|
161
|
+
// of this function referenced an import that was not there; the catch ate the
|
|
162
|
+
// ReferenceError and the hook printed nothing, so the board simply did not
|
|
163
|
+
// start and nothing said why. A swallowed error is the defect this project
|
|
164
|
+
// spends most of its checks on.
|
|
165
|
+
process.stdout.write(` great-cto: could not start the board — ${e?.message || e}\n`);
|
|
166
|
+
process.stdout.write(' Start it yourself with: great-cto board\n');
|
|
167
|
+
}
|
|
119
168
|
}
|
|
120
169
|
|
|
121
170
|
// Only when run as the hook, so the pure parts above stay importable by tests.
|