great-cto 3.15.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/lib/data-readers.mjs +20 -3
- package/board/packages/board/lib/docs.mjs +1 -1
- package/board/packages/board/public/index.html +517 -108
- 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,
|
|
@@ -114,8 +114,21 @@ function getPipeline(cwd = process.cwd()) {
|
|
|
114
114
|
// signature. Surface that checkpoint AS A STAGE in the pipeline, sitting just
|
|
115
115
|
// before the irreversible steps (devops/ship). It lights up when a gate is
|
|
116
116
|
// awaiting a human — so the operator always sees where the rails are.
|
|
117
|
+
// Awaiting a signature and blocked are not the same thing, and this said they
|
|
118
|
+
// were. `getInbox`'s pendingGates excludes blocked gates — deliberately, and
|
|
119
|
+
// under test: in a tasks.md project a gate marked `blocked` carried raw_status
|
|
120
|
+
// 'open' and never left the inbox. This filter excluded only done/closed, so
|
|
121
|
+
// the same gate the tile reported as 0 PENDING DECISIONS was announced here as
|
|
122
|
+
// 1 GATE AWAITING SIGNATURE. Two counts of one concept, on one screen,
|
|
123
|
+
// disagreeing over a single word.
|
|
124
|
+
//
|
|
125
|
+
// Dropping it from the rail would have been the other wrong answer: a gate
|
|
126
|
+
// stuck for 29 days is exactly what the rail exists to show. It is still
|
|
127
|
+
// shown, and now it is named for what it is.
|
|
117
128
|
const openGates = tasks.filter(t => t.is_gate && t.status !== 'done' && t.status !== 'closed' && t.raw_status !== 'closed');
|
|
118
|
-
const
|
|
129
|
+
const blockedGates = openGates.filter(t => t.raw_status === 'blocked' || t.status === 'blocked');
|
|
130
|
+
const awaitingGates = openGates.filter(t => !blockedGates.includes(t));
|
|
131
|
+
const pending = awaitingGates.length;
|
|
119
132
|
const newestGate = openGates.reduce((acc, t) => {
|
|
120
133
|
const ts = t.updated_at || t.created_at; return (!acc || (ts && ts > acc)) ? ts : acc;
|
|
121
134
|
}, null);
|
|
@@ -123,11 +136,15 @@ function getPipeline(cwd = process.cwd()) {
|
|
|
123
136
|
const gateNode = {
|
|
124
137
|
stage: 'human-gate',
|
|
125
138
|
is_human_gate: true,
|
|
126
|
-
|
|
139
|
+
// Blocked is not active: an active stage is one somebody can act on now.
|
|
140
|
+
status: pending > 0 ? 'active' : (blockedGates.length ? 'blocked' : 'idle'),
|
|
127
141
|
pending,
|
|
142
|
+
blocked: blockedGates.length,
|
|
128
143
|
last_message: pending > 0
|
|
129
144
|
? `${pending} gate${pending > 1 ? 's' : ''} awaiting signature`
|
|
130
|
-
:
|
|
145
|
+
: (blockedGates.length
|
|
146
|
+
? `${blockedGates.length} gate${blockedGates.length > 1 ? 's' : ''} blocked`
|
|
147
|
+
: 'no gate pending'),
|
|
131
148
|
verdict: null,
|
|
132
149
|
ts: newestGate,
|
|
133
150
|
age_min: gateAgeMs != null ? Math.round(gateAgeMs / 60000) : null,
|
|
@@ -182,7 +182,7 @@ function walk(dir, root, out, depth = 0) {
|
|
|
182
182
|
* 8. a bare README a name that describes nothing else
|
|
183
183
|
*
|
|
184
184
|
* The leading filename token outranks the directory, and that ordering was
|
|
185
|
-
* bought: 14 `docs/
|
|
185
|
+
* bought: 14 `docs/adr/ADR-0NN-*.md` files were being filed as
|
|
186
186
|
* Architecture, and `docs/design/PLAN-*.md` as Design. A directory is where a
|
|
187
187
|
* project dumps a category; the front of a filename is what the author called
|
|
188
188
|
* THIS document. Mid-name tokens rank below the directory instead, because
|