great-cto 3.16.0 → 3.18.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/alert-recurrence.mjs +73 -0
- package/board/packages/board/lib/alerts.mjs +42 -9
- package/board/packages/board/lib/beads.mjs +120 -15
- package/board/packages/board/lib/data-readers.mjs +8 -0
- package/board/packages/board/lib/docs.mjs +21 -0
- package/board/packages/board/public/index.html +485 -110
- package/board/packages/board/server.mjs +27 -20
- package/board/scripts/lib/doc-links.mjs +103 -0
- 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.18.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 "[]"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Has this alert fired here before?
|
|
3
|
+
*
|
|
4
|
+
* `alerts-fired.json` has recorded every alert this machine sent, keyed
|
|
5
|
+
* `<event>:<project>:<id>` with the time it fired. It was read only as a dedupe
|
|
6
|
+
* set — "have I already sent THIS instance" — so a gate going stale for the
|
|
7
|
+
* first time in a project and the ninth in a month produced the same sentence,
|
|
8
|
+
* and the operator had no way to tell a one-off from a pattern.
|
|
9
|
+
*
|
|
10
|
+
* A threshold cannot make that distinction; only history can, and the history
|
|
11
|
+
* was already on disk.
|
|
12
|
+
*
|
|
13
|
+
* Three states, because the file is lossy and its absence means something:
|
|
14
|
+
*
|
|
15
|
+
* unknown — no history to read. NOT "first": a count nobody took is not a
|
|
16
|
+
* count of zero, and this is the substitution the project exists
|
|
17
|
+
* to refuse.
|
|
18
|
+
* first — history exists and holds nothing for this rule in this project.
|
|
19
|
+
* recurring — it holds N earlier fires inside the window.
|
|
20
|
+
*
|
|
21
|
+
* `atLeast` marks a count taken from a FULL history: the writer keeps only the
|
|
22
|
+
* last 500 keys, so anything older is gone and the number is a floor.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The writer's cap — see writeAlertsFired in alerts.mjs. */
|
|
26
|
+
const HISTORY_CAP = 500;
|
|
27
|
+
const DEFAULT_WINDOW_DAYS = 30;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Record<string,string>|null} fired parsed alerts-fired.json, or null
|
|
31
|
+
* when there is no such file
|
|
32
|
+
* @param {{event: string, project: string, now?: number, windowDays?: number}} q
|
|
33
|
+
* @returns {{state:'unknown'|'first'|'recurring', count:number|null,
|
|
34
|
+
* windowDays:number, atLeast:boolean, sentence:string}}
|
|
35
|
+
*/
|
|
36
|
+
export function recurrence(fired, { event, project, now = Date.now(), windowDays = DEFAULT_WINDOW_DAYS }) {
|
|
37
|
+
if (!fired || typeof fired !== 'object') {
|
|
38
|
+
return {
|
|
39
|
+
state: 'unknown', count: null, windowDays, atLeast: false,
|
|
40
|
+
sentence: 'No alert history on this machine, so whether this has happened before is unknown.',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Anchored on the full `event:project:` prefix. A bare startsWith(event) would
|
|
45
|
+
// count `gate.stalest` as `gate.stale`, and a project slug may itself contain
|
|
46
|
+
// a colon — so both parts are matched as one literal prefix.
|
|
47
|
+
const prefix = `${event}:${project}:`;
|
|
48
|
+
const cutoff = now - windowDays * 86_400_000;
|
|
49
|
+
const keys = Object.keys(fired);
|
|
50
|
+
|
|
51
|
+
let count = 0;
|
|
52
|
+
for (const k of keys) {
|
|
53
|
+
if (!k.startsWith(prefix)) continue;
|
|
54
|
+
const at = Date.parse(fired[k]);
|
|
55
|
+
// An unreadable timestamp is not evidence of a recent fire. Skipped, not
|
|
56
|
+
// counted as now — which is what a NaN comparison would have done silently.
|
|
57
|
+
if (!Number.isFinite(at) || at < cutoff) continue;
|
|
58
|
+
count++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const atLeast = keys.length >= HISTORY_CAP;
|
|
62
|
+
if (count === 0) {
|
|
63
|
+
return {
|
|
64
|
+
state: 'first', count: 0, windowDays, atLeast,
|
|
65
|
+
sentence: `First time this has fired for this project in ${windowDays} days.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
state: 'recurring', count, windowDays, atLeast,
|
|
70
|
+
sentence: `${atLeast ? 'At least ' : ''}${count} other time${count === 1 ? '' : 's'} `
|
|
71
|
+
+ `in the last ${windowDays} days for this project — a pattern, not a one-off.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -8,18 +8,25 @@ import {
|
|
|
8
8
|
} from '../push-adapter.mjs';
|
|
9
9
|
import { GREAT_CTO_DIR, PUSH_SUBS_FILE, VAPID_KEYS_FILE, VAPID_SUBJECT, BUILD_VERSION } from './config.mjs';
|
|
10
10
|
import { _reportRepublishDedupeSet } from './state.mjs';
|
|
11
|
+
import { recurrence } from './alert-recurrence.mjs';
|
|
11
12
|
import { listProjects, readProjectMd } from './projects.mjs';
|
|
12
13
|
import { addNotification } from './notifications.mjs';
|
|
13
14
|
import { getMetrics } from './metrics.mjs';
|
|
14
15
|
import { getCostHistory, getInbox } from './data-readers.mjs';
|
|
15
|
-
// Every sweep below runs over EVERY registered project
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
16
|
+
// Every sweep below runs over EVERY registered project. `sweep: true` opts a
|
|
17
|
+
// COLD project (never read this process) into bdList()'s non-blocking fill
|
|
18
|
+
// path instead of its synchronous one — the same escape hatch a WARM-but-
|
|
19
|
+
// stale entry already gets. Without it: a real board's first cron tick
|
|
20
|
+
// (t=+5:10, no request in flight) lagged the event loop 9.8s reading a
|
|
21
|
+
// single project synchronously; at 16 projects and 2-6s each that is ~60s
|
|
22
|
+
// per sweep, three of which fire every five minutes, indefinitely. Sweeps
|
|
23
|
+
// read at sweep freshness, not interactive freshness — see SWEEP_MAX_AGE_MS
|
|
24
|
+
// in beads.mjs for the arithmetic that made the board unanswerable while
|
|
25
|
+
// reporting "live · synced just now". `sweep: true` must NEVER be added to
|
|
26
|
+
// an interactive read's opts: an ordinary request is someone waiting on
|
|
27
|
+
// THIS project's answer, and the fill path answers `[]` instead.
|
|
21
28
|
import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
|
|
22
|
-
const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS };
|
|
29
|
+
const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS, sweep: true };
|
|
23
30
|
import { readVerdicts } from './verdicts.mjs';
|
|
24
31
|
import { isFailure } from './fleet.mjs';
|
|
25
32
|
import { getShareState, toggleShare } from './share.mjs';
|
|
@@ -37,6 +44,22 @@ function readAlertsFired() {
|
|
|
37
44
|
try { return JSON.parse(fs.readFileSync(ALERTS_FIRED_PATH, 'utf8')); } catch { return {}; }
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The same file, read as HISTORY rather than as a dedupe set.
|
|
49
|
+
*
|
|
50
|
+
* readAlertsFired answers "have I already sent this instance", and `{}` on
|
|
51
|
+
* failure is right for that: not knowing must never block a send. Read as
|
|
52
|
+
* history, that same `{}` says "this has never happened here" — a claim nobody
|
|
53
|
+
* checked. `null` keeps the two apart.
|
|
54
|
+
*/
|
|
55
|
+
function readAlertsHistory() {
|
|
56
|
+
try {
|
|
57
|
+
if (!fs.existsSync(ALERTS_FIRED_PATH)) return null;
|
|
58
|
+
const parsed = JSON.parse(fs.readFileSync(ALERTS_FIRED_PATH, 'utf8'));
|
|
59
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
60
|
+
} catch { return null; }
|
|
61
|
+
}
|
|
62
|
+
|
|
40
63
|
function writeAlertsFired(map) {
|
|
41
64
|
try {
|
|
42
65
|
if (!fs.existsSync(GREAT_CTO_DIR)) fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
|
|
@@ -234,14 +257,24 @@ function startAlertCron() {
|
|
|
234
257
|
const ageHr = (Date.now() - created) / 3600_000;
|
|
235
258
|
if (ageHr < 2 || ageHr > 24 * 7) continue;
|
|
236
259
|
const dedupeKey = `gate.stale:${proj.slug}:${g.id}`;
|
|
260
|
+
// How often this has happened here. A threshold says the gate is old;
|
|
261
|
+
// only the history says whether old gates are this project's normal
|
|
262
|
+
// state. Both readings come from the same file — one as a dedupe set,
|
|
263
|
+
// this one as history.
|
|
264
|
+
const seen = recurrence(readAlertsHistory(), { event: 'gate.stale', project: proj.slug });
|
|
237
265
|
const stalePayload = {
|
|
238
266
|
title: `${proj.slug} — ${g.title.slice(0, 60)} pending ${ageHr.toFixed(1)}h`,
|
|
239
|
-
body: `A gate has been waiting for your approval for ${ageHr.toFixed(1)} hours.\n\nGate: ${g.id}\nProject: ${proj.slug}`,
|
|
267
|
+
body: `A gate has been waiting for your approval for ${ageHr.toFixed(1)} hours.\n\n${seen.sentence}\n\nGate: ${g.id}\nProject: ${proj.slug}`,
|
|
240
268
|
level: 'warning',
|
|
241
269
|
project: proj.slug,
|
|
242
270
|
link: `http://localhost:3141/?project=${encodeURIComponent(proj.slug)}&task=${encodeURIComponent(g.id)}#inbox`,
|
|
243
271
|
action: 'Approve in board',
|
|
244
|
-
kv: {
|
|
272
|
+
kv: {
|
|
273
|
+
gate: g.id, agent: g.agent || 'unknown', age: `${ageHr.toFixed(1)}h`,
|
|
274
|
+
// `unknown` is carried through rather than rendered as 0 — a count
|
|
275
|
+
// nobody took is not a count of none.
|
|
276
|
+
seen_before: seen.count === null ? 'unknown' : `${seen.atLeast ? '\u2265' : ''}${seen.count}`,
|
|
277
|
+
},
|
|
245
278
|
};
|
|
246
279
|
fireEmailAlert('gate.stale', dedupeKey, stalePayload);
|
|
247
280
|
addNotification('gate.stale', stalePayload, dedupeKey);
|
|
@@ -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 });
|
|
@@ -593,7 +682,22 @@ function getReadDegradation(cwd = process.cwd()) {
|
|
|
593
682
|
// tasks.md first: if that file exists and is broken, that is the specific
|
|
594
683
|
// problem. Otherwise report bd's failure, which until now was swallowed — the
|
|
595
684
|
// board answered "no tasks" for a project whose database bd refused to open.
|
|
596
|
-
|
|
685
|
+
const fromTasksMd = readDegradation.get(cwd);
|
|
686
|
+
if (fromTasksMd) return fromTasksMd;
|
|
687
|
+
|
|
688
|
+
// A project that never ran `bd init` has no beads store to fail. bd still
|
|
689
|
+
// reports its absence as an error, and reporting THAT as a degradation put a
|
|
690
|
+
// permanent "counts are incomplete" banner over complete counts on every
|
|
691
|
+
// project that tracks tasks in tasks.md — a supported source, not a fallback
|
|
692
|
+
// of last resort. Absent and broken are different states; this file already
|
|
693
|
+
// draws that line for tasks.md ("Missing is a normal state") and now draws it
|
|
694
|
+
// for beads too.
|
|
695
|
+
//
|
|
696
|
+
// Deliberately narrow: an existing .beads/ that bd cannot open is still a
|
|
697
|
+
// defect and is still reported. Only absence is forgiven.
|
|
698
|
+
if (checkBeadsAvailable(cwd)) return null;
|
|
699
|
+
|
|
700
|
+
return bdFailureFor(cwd) || null;
|
|
597
701
|
}
|
|
598
702
|
|
|
599
703
|
function parseTasksMd(cwd) {
|
|
@@ -743,6 +847,7 @@ export {
|
|
|
743
847
|
checkBeadsAvailable,
|
|
744
848
|
bdWriteSerialised,
|
|
745
849
|
bdList,
|
|
850
|
+
warmTasksAsync,
|
|
746
851
|
bdFailureFor,
|
|
747
852
|
parseTasksMd,
|
|
748
853
|
getReadDegradation,
|
|
@@ -27,6 +27,14 @@ function getMemory(cwd = process.cwd()) {
|
|
|
27
27
|
];
|
|
28
28
|
const result = layers.map(l => ({
|
|
29
29
|
...l,
|
|
30
|
+
// `path` opens the file; `displayPath` is what a person — or a README
|
|
31
|
+
// screenshot — sees. An absolute path here names the operator's home
|
|
32
|
+
// directory and username: fine in a local tool, wrong the moment the screen
|
|
33
|
+
// is photographed. The layer already knows its scope, so the display form is
|
|
34
|
+
// derived rather than guessed.
|
|
35
|
+
displayPath: l.scope === 'global'
|
|
36
|
+
? path.join('~', path.relative(home, l.path)).split(path.sep).join('/')
|
|
37
|
+
: path.relative(cwd, l.path).split(path.sep).join('/'),
|
|
30
38
|
content: readFileSafe(l.path),
|
|
31
39
|
exists: fs.existsSync(l.path),
|
|
32
40
|
size: fs.existsSync(l.path) ? fs.statSync(l.path).size : 0,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import { judgeFreshness } from '../../../scripts/lib/freshness.mjs';
|
|
17
|
+
import { linkGraph } from '../../../scripts/lib/doc-links.mjs';
|
|
17
18
|
import path from 'node:path';
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -148,6 +149,10 @@ export function titleFromText(text) {
|
|
|
148
149
|
return m ? m[1].replace(/\.md$/i, '').trim() : null;
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
/** Generated summaries and translated copies — see listDocs. */
|
|
153
|
+
const IS_COPY = /\.summary\.md$/i;
|
|
154
|
+
const IS_TRANSLATED = /^docs[/\\][a-z]{2}(-[A-Z]{2})?[/\\]/;
|
|
155
|
+
|
|
151
156
|
function walk(dir, root, out, depth = 0) {
|
|
152
157
|
if (depth > 3 || out.length >= MAX_DOCS) return;
|
|
153
158
|
let entries;
|
|
@@ -158,6 +163,10 @@ function walk(dir, root, out, depth = 0) {
|
|
|
158
163
|
const abs = path.join(dir, e.name);
|
|
159
164
|
if (e.isDirectory()) { walk(abs, root, out, depth + 1); continue; }
|
|
160
165
|
if (!e.name.toLowerCase().endsWith('.md')) continue;
|
|
166
|
+
// A generated summary and a translation are copies of a document, not more
|
|
167
|
+
// documents. Counting them made this screen report 188 where there are 156,
|
|
168
|
+
// and stood a machine-written summary in the index beside its own source.
|
|
169
|
+
if (IS_COPY.test(e.name) || IS_TRANSLATED.test(path.relative(root, abs))) continue;
|
|
161
170
|
let st;
|
|
162
171
|
try { st = fs.statSync(abs); } catch { continue; }
|
|
163
172
|
out.push({ abs, rel: path.relative(root, abs), size: st.size, modified: st.mtime.toISOString() });
|
|
@@ -336,6 +345,17 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
|
|
|
336
345
|
}
|
|
337
346
|
}
|
|
338
347
|
|
|
348
|
+
// How many documents cite this one. Measured over docs/ only, which is where
|
|
349
|
+
// the link graph is defined; anything outside it gets `null` — "not measured"
|
|
350
|
+
// and "measured, and the answer is none" are different facts, and rendering
|
|
351
|
+
// the first as the second is the substitution this whole board refuses.
|
|
352
|
+
let inboundBy = null;
|
|
353
|
+
try {
|
|
354
|
+
const g = linkGraph(path.join(root, 'docs'));
|
|
355
|
+
inboundBy = new Map();
|
|
356
|
+
for (const [k, v] of g.inbound) inboundBy.set(path.relative(root, k), v.length);
|
|
357
|
+
} catch { inboundBy = null; }
|
|
358
|
+
|
|
339
359
|
const seen = new Set();
|
|
340
360
|
const docs = [];
|
|
341
361
|
for (const d of found) {
|
|
@@ -353,6 +373,7 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
|
|
|
353
373
|
title: (text !== null && titleFromText(text)) || path.basename(d.rel, '.md'),
|
|
354
374
|
group: groupFor(d.rel, { text: text ?? '' }),
|
|
355
375
|
size: d.size,
|
|
376
|
+
inbound: inboundBy ? (inboundBy.has(d.rel) ? inboundBy.get(d.rel) : null) : null,
|
|
356
377
|
modified: d.modified,
|
|
357
378
|
// A modification time answers "when was this file last touched", which is
|
|
358
379
|
// a different question from "is this still true". A typo fix rejuvenates a
|