Public: anyone can read this; the crew can change it.
Clog note: measure time to detect #6778
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6757-clog-note-measure-time-to-detect → main 7 commits fork 2 threads · 2 unresolved
15 files
changed +323
−42
README.md +2
| @@ -46,6 +46,8 @@ A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three | ||
| 46 | 46 | |
| 47 | 47 | Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`. |
| 48 | 48 | |
| 49 | An admin's home and the terminal's Flow dialog also say when the forge is clogged and how long the last clog took to notice and to clear: `docs/features/clog-watch.md`. | |
| 50 | ||
| 49 | 51 | A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held. |
| 50 | 52 | |
| 51 | 53 | On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`. |
docs/features/clog-watch.md added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | # Clog watch (#6757) | |
| 2 | ||
| 3 | Once a minute the forge reads its flow (open pulls that conflict, pulls opened and landed over 2 h, the oldest approved wait) and declares a clog on the thresholds in `src/flow/clog.ts`. A clog clears after two quiet readings. | |
| 4 | ||
| 5 | - **Where to see it (admin only):** home shows one muted line while a clog stands (`Clogged for 30 h (noticed 30 h in): <cause>.`) and, for a day after, the last cleared note (cause, time to detect, time to clear). The terminal's Flow dialog shows the same lines. A guest and an agent see neither. | |
| 6 | - **Time to detect** is declared-minus-started. The start is the oldest approved pull's wait when that is what clogs; for any other reason it is the reading itself, so the note leaves the line out. | |
| 7 | - **State:** `clog_state` (the standing clog) (with `started_at`, when it really began); the last cleared note is the `clog.cleared` event. Events `clog.started` and `clog.cleared` go to the outbox for admins. | |
| 8 | - **Code:** `flow/clog_tick.ts` (the tick), `flow/clog_read.ts` (the reader), `flow/clog_watch.ts` and `flow/clog_note.ts` (the pure steps). |
src/core/event_audience.ts +2
| @@ -154,6 +154,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 154 | 154 | kind.startsWith("drill.") || |
| 155 | 155 | // A snapshot moving is the forge owner's too, and has no repository. |
| 156 | 156 | kind.startsWith("backup.") || |
| 157 | // A clog is the whole forge's flow, the owner's to hear (`flow/clog_tick.ts`). | |
| 158 | kind.startsWith("clog.") || | |
| 157 | 159 | // A report from outside waits for a person who decides; its arrival is |
| 158 | 160 | // theirs to see, never the readers' (`actions/reports.ts`). |
| 159 | 161 | kind.startsWith("report.") || |
src/core/migration-pins/20261027120000-clog-started-at.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 7bdec6f0cc9f155bd48d8180db5c653c0cff4fc594885cc2008bcba76b2a9db3 |
src/core/migrations/20261027120000-clog-started-at.ts added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | // When a standing clog really began (#6757), epoch ms, beside `since` (when it | |
| 2 | // was declared). Zero means not recorded: the clog began when it was declared. | |
| 3 | export default ` | |
| 4 | ALTER TABLE clog_state ADD COLUMN started_at INTEGER NOT NULL DEFAULT 0; | |
| 5 | `; |
src/flow/clog_read.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * What the clog watch knows, for a person (#6757): the clog standing now and | |
| 3 | * the note of the last one cleared, as plain lines. The reader is the owner's | |
| 4 | * alone (`clog.*` is an admins' event); a page or screen that shows these | |
| 5 | * lines gates on that before it calls. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { duration } from "./clog_note.ts"; | |
| 10 | import { lastClearedNote, loadClog } from "./clog_tick.ts"; | |
| 11 | ||
| 12 | export interface ClogRead { | |
| 13 | readonly standing: { readonly since: number; readonly startedAt: number; readonly reasons: readonly string[] } | null; | |
| 14 | /** The last cleared note within a day, on one line. */ | |
| 15 | readonly last: string | null; | |
| 16 | } | |
| 17 | ||
| 18 | export function readClog(db: Database, now = new Date()): ClogRead { | |
| 19 | const state = loadClog(db); | |
| 20 | return { | |
| 21 | standing: state ? { since: state.since, startedAt: state.startedAt ?? state.since, reasons: state.firstReasons } : null, | |
| 22 | last: lastClearedNote(db, now) ?? null, | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | /** "Clogged for 2.5 h (noticed 12 min in): <cause>." and the last cleared note, one line each; none, no lines. */ | |
| 27 | export function clogLines(read: ClogRead, now: number): string[] { | |
| 28 | const out: string[] = []; | |
| 29 | if (read.standing) { | |
| 30 | const s = read.standing; | |
| 31 | const cause = s.reasons.length ? s.reasons.join("; ") : "cause not recorded"; | |
| 32 | const lag = s.since > s.startedAt ? ` (noticed ${duration(s.since - s.startedAt)} in)` : ""; | |
| 33 | out.push(`Clogged for ${duration(now - s.startedAt)}${lag}: ${cause}.`); | |
outdated
The comment says "declared 12 min in" but the code prints "noticed … in". Make them match. |
||
| 34 | } | |
| 35 | if (read.last) out.push(read.last); | |
| 36 | return out; | |
| 37 | } |
src/flow/clog_tick.ts +36 −10
| @@ -11,7 +11,7 @@ import { listEvents, type EventBus } from "../core/events.ts"; | ||
| 11 | 11 | import { getRepo } from "../core/repos.ts"; |
| 12 | 12 | import { createLog } from "../kernel/log.ts"; |
| 13 | 13 | import { ownRepoPath } from "../kernel/own_repo.ts"; |
| 14 | import { clogOf } from "./clog.ts"; | |
| 14 | import { CLOG_APPROVED_HOURS, clogOf } from "./clog.ts"; | |
| 15 | 15 | import { CLOG_NOTE_WINDOW_MS, clogNote, flattenNote } from "./clog_note.ts"; |
| 16 | 16 | import { clogStep, type ClogEvent, type ClogState } from "./clog_watch.ts"; |
| 17 | 17 | import { readOldestApproved } from "./oldest_approved.ts"; |
| @@ -26,14 +26,20 @@ interface StateRow { | ||
| 26 | 26 | first_reasons: string; |
| 27 | 27 | quiet: number; |
| 28 | 28 | peak_usd: number; |
| 29 | started_at: number; | |
| 29 | 30 | } |
| 30 | 31 | |
| 31 | 32 | export function loadClog(db: Database): ClogState | null { |
| 32 | const row = db.query<StateRow, []>("SELECT since, first_reasons, quiet, peak_usd FROM clog_state WHERE id = 1").get(); | |
| 33 | const row = db.query<StateRow, []>("SELECT since, first_reasons, quiet, peak_usd, started_at FROM clog_state WHERE id = 1").get(); | |
| 33 | 34 | if (!row) return null; |
| 34 | const parsed: unknown = JSON.parse(row.first_reasons); | |
| 35 | const firstReasons = Array.isArray(parsed) ? parsed.filter((r): r is string => typeof r === "string") : []; | |
| 36 | return { since: row.since, firstReasons, ...(row.quiet > 0 ? { quiet: row.quiet } : {}), peakUsd: row.peak_usd }; | |
| 35 | let firstReasons: string[] = []; | |
| 36 | try { | |
| 37 | const parsed: unknown = JSON.parse(row.first_reasons); | |
| 38 | if (Array.isArray(parsed)) firstReasons = parsed.filter((r): r is string => typeof r === "string"); | |
| 39 | } catch { | |
| 40 | // A row we cannot read is a clog with no recorded cause. | |
| 41 | } | |
| 42 | return { since: row.since, firstReasons, ...(row.started_at > 0 ? { startedAt: row.started_at } : {}), ...(row.quiet > 0 ? { quiet: row.quiet } : {}), peakUsd: row.peak_usd }; | |
| 37 | 43 | } |
| 38 | 44 | |
| 39 | 45 | function saveClog(db: Database, state: ClogState | null): void { |
| @@ -42,9 +48,9 @@ function saveClog(db: Database, state: ClogState | null): void { | ||
| 42 | 48 | return; |
| 43 | 49 | } |
| 44 | 50 | db.query( |
| 45 | `INSERT INTO clog_state (id, since, first_reasons, quiet, peak_usd) VALUES (1, ?1, ?2, ?3, ?4) | |
| 46 | ON CONFLICT (id) DO UPDATE SET since = ?1, first_reasons = ?2, quiet = ?3, peak_usd = ?4`, | |
| 47 | ).run(state.since, JSON.stringify(state.firstReasons), state.quiet ?? 0, state.peakUsd ?? 0); | |
| 51 | `INSERT INTO clog_state (id, since, first_reasons, quiet, peak_usd, started_at) VALUES (1, ?1, ?2, ?3, ?4, ?5) | |
| 52 | ON CONFLICT (id) DO UPDATE SET since = ?1, first_reasons = ?2, quiet = ?3, peak_usd = ?4, started_at = ?5`, | |
| 53 | ).run(state.since, JSON.stringify(state.firstReasons), state.quiet ?? 0, state.peakUsd ?? 0, state.startedAt ?? 0); | |
| 48 | 54 | } |
| 49 | 55 | |
| 50 | 56 | /** Pulls opened and landed over the last 2 h, forge-wide, as the clog thresholds read them. */ |
| @@ -75,6 +81,23 @@ export function lastClearedNote(db: Database, now = new Date()): string | undefi | ||
| 75 | 81 | return flattenNote(note); |
| 76 | 82 | } |
| 77 | 83 | |
| 84 | /** Ticks once a minute; a tick that throws is logged and the next one runs. Returns the stop. */ | |
| 85 | export function startClogTick( | |
| 86 | db: Database, | |
| 87 | bus: Pick<EventBus, "append">, | |
| 88 | onError: (error: unknown) => void, | |
| 89 | everyMs = 60_000, | |
| 90 | ): () => void { | |
| 91 | const timer = setInterval(() => { | |
| 92 | try { | |
| 93 | tickClog(db, bus); | |
| 94 | } catch (error) { | |
| 95 | onError(error); | |
| 96 | } | |
| 97 | }, everyMs); | |
| 98 | return () => clearInterval(timer); | |
| 99 | } | |
| 100 | ||
| 78 | 101 | /** One tick. With no own repository there is nobody to tell, and the watch still steps. */ |
| 79 | 102 | export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new Date()): ClogTick { |
| 80 | 103 | const vitals = readVitals(db, now); |
| @@ -86,12 +109,15 @@ export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new | ||
| 86 | 109 | landed, |
| 87 | 110 | oldestApprovedHours: oldest ? Math.floor(oldest.hours) : 0, |
| 88 | 111 | }).reasons; |
| 112 | // When the oldest approved pull is what clogs, the clog began when it began to wait. | |
| 113 | const waited = oldest?.hours ?? 0; | |
| 114 | const startedAt = waited > CLOG_APPROVED_HOURS ? now.getTime() - waited * HOUR_MS : now.getTime(); | |
| 89 | 115 | const [owner, name] = ownRepoPath().split("/"); |
| 90 | 116 | const repo = owner && name ? getRepo(db, owner, name) : undefined; |
| 91 | 117 | // Read, step and write in one transaction: two overlapping processes cannot both clear. |
| 92 | 118 | return db.transaction((): ClogTick => { |
| 93 | 119 | const prev = loadClog(db); |
| 94 | const step = clogStep(prev, reading, now.getTime()); | |
| 120 | const step = clogStep(prev, reading, now.getTime(), startedAt); | |
| 95 | 121 | const usd = readStranded(db).usd; |
| 96 | 122 | // The peak is the most seen while the clog stood: by the clear the pile is gone. |
| 97 | 123 | const state = step.state && { ...step.state, peakUsd: Math.max(prev?.peakUsd ?? 0, usd) }; |
| @@ -100,7 +126,7 @@ export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new | ||
| 100 | 126 | if (step.event === "cleared" && repo && prev) { |
| 101 | 127 | const note = clogNote({ |
| 102 | 128 | reasons: prev.firstReasons, |
| 103 | startedAt: prev.since, | |
| 129 | startedAt: prev.startedAt ?? prev.since, | |
| 104 | 130 | detectedAt: prev.since, |
| 105 | 131 | clearedAt: now.getTime(), |
| 106 | 132 | strandedUsd: Math.max(prev.peakUsd ?? 0, usd), |
src/flow/clog_watch.ts +18 −4
| @@ -12,6 +12,8 @@ export interface ClogState { | ||
| 12 | 12 | readonly since: number; |
| 13 | 13 | /** The reasons on the reading that started it. */ |
| 14 | 14 | readonly firstReasons: readonly string[]; |
| 15 | /** When it really began (the oldest stuck item's wait, say); never after `since`. Absent: it began when declared. */ | |
| 16 | readonly startedAt?: number; | |
| 15 | 17 | /** Quiet readings in a row so far; absent when zero. */ |
| 16 | 18 | readonly quiet?: number; |
| 17 | 19 | /** The most stranded dollars seen while it stood; kept by the tick, not by the step. */ |
| @@ -25,16 +27,28 @@ export interface ClogStep { | ||
| 25 | 27 | readonly event: ClogEvent | null; |
| 26 | 28 | /** On "cleared": when the clog had started. */ |
| 27 | 29 | readonly since?: number; |
| 30 | /** On "cleared": when it really began. */ | |
| 31 | readonly startedAt?: number; | |
| 28 | 32 | } |
| 29 | 33 | |
| 30 | export function clogStep(prev: ClogState | null, reading: readonly string[], now: number): ClogStep { | |
| 34 | /** `startedAt` is when the reading says the clog began (the reading's own time when it cannot tell); it counts only on the reading that declares it. */ | |
| 35 | export function clogStep(prev: ClogState | null, reading: readonly string[], now: number, startedAt: number = now): ClogStep { | |
| 31 | 36 | if (!prev) { |
| 32 | 37 | if (reading.length === 0) return { state: null, event: null }; |
| 33 | return { state: { since: now, firstReasons: [...reading] }, event: "started" }; | |
| 38 | const began = Number.isFinite(startedAt) ? Math.min(startedAt, now) : now; | |
| 39 | return { state: { since: now, firstReasons: [...reading], startedAt: began }, event: "started" }; | |
| 34 | 40 | } |
| 35 | 41 | if (reading.length > 0) |
| 36 | return { state: { since: prev.since, firstReasons: prev.firstReasons, ...(prev.peakUsd === undefined ? {} : { peakUsd: prev.peakUsd }) }, event: null }; | |
| 42 | return { | |
| 43 | state: { | |
| 44 | since: prev.since, | |
| 45 | firstReasons: prev.firstReasons, | |
| 46 | ...(prev.startedAt === undefined ? {} : { startedAt: prev.startedAt }), | |
| 47 | ...(prev.peakUsd === undefined ? {} : { peakUsd: prev.peakUsd }), | |
| 48 | }, | |
| 49 | event: null, | |
| 50 | }; | |
| 37 | 51 | const quiet = (prev.quiet ?? 0) + 1; |
| 38 | if (quiet >= QUIET_TO_CLEAR) return { state: null, event: "cleared", since: prev.since }; | |
| 52 | if (quiet >= QUIET_TO_CLEAR) return { state: null, event: "cleared", since: prev.since, ...(prev.startedAt === undefined ? {} : { startedAt: prev.startedAt }) }; | |
| 39 | 53 | return { state: { ...prev, quiet }, event: null }; |
| 40 | 54 | } |
src/main.ts +4 −11
| @@ -54,7 +54,7 @@ import { typeSafeDecider } from "./crew/decide.ts"; | ||
| 54 | 54 | import { graphInstagram } from "./crew/instagram.ts"; |
| 55 | 55 | import { peopleOnCrews } from "./core/agents.ts"; |
| 56 | 56 | import { startNotifier } from "./crew/notify.ts"; |
| 57 | import { tickClog } from "./flow/clog_tick.ts"; | |
| 57 | import { startClogTick } from "./flow/clog_tick.ts"; | |
| 58 | 58 | import { sweepStaleAsks } from "./crew/stale_asks.ts"; |
| 59 | 59 | import { recordAskLedger } from "./web/studio/asks.ts"; |
| 60 | 60 | import { startPromptRoutine } from "./crew/prompts.ts"; |
| @@ -551,15 +551,8 @@ const processTick = (): void => { | ||
| 551 | 551 | app.log.warn("process census failed", { error: String(error) }); |
| 552 | 552 | } |
| 553 | 553 | }; |
| 554 | // The clog watch (#6524) reads the flow every minute; its state is the database's. | |
| 555 | const clogTick = (): void => { | |
| 556 | try { | |
| 557 | tickClog(app.db, app.bus); | |
| 558 | } catch (error) { | |
| 559 | app.log.warn("clog tick failed", { error: String(error) }); | |
| 560 | } | |
| 561 | }; | |
| 562 | const clogTimer = setInterval(clogTick, 60_000); | |
| 554 | // The clog watch (#6524, #6757) reads the flow every minute; its state is the database's. | |
| 555 | const stopClogTick = startClogTick(app.db, app.bus, (error) => app.log.warn("clog tick failed", { error: String(error) })); | |
| 563 | 556 | const processTimer = setInterval(processTick, 60_000); |
| 564 | 557 | processTick(); |
| 565 | 558 | // A new version proves itself against the public URL once (release/prove.ts). |
| @@ -677,8 +670,8 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 677 | 670 | clearInterval(syncTimer); |
| 678 | 671 | clearInterval(staleTimer); |
| 679 | 672 | clearInterval(followTimer); |
| 673 | stopClogTick(); | |
| 680 | 674 | clearInterval(processTimer); |
| 681 | clearInterval(clogTimer); | |
| 682 | 675 | clearInterval(leaseTimer); |
| 683 | 676 | // The platform's SIGTERM: the listener stays open while routers learn |
| 684 | 677 | // this process is going. A person's Ctrl-C does not wait for routers. |
src/tui/screens/flow.ts +2
| @@ -29,6 +29,7 @@ import { readLeadTime, type LeadTime } from "../../flow/lead_time.ts"; | ||
| 29 | 29 | import { buildYieldSays, readBuildYield } from "../../flow/build_yield.ts"; |
| 30 | 30 | import { readReviewLatency, reviewLatencySays } from "../../flow/review_latency.ts"; |
| 31 | 31 | import { readOpeners, type Opener } from "../../flow/openers.ts"; |
| 32 | import { clogLines, readClog } from "../../flow/clog_read.ts"; | |
| 32 | 33 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| 33 | 34 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; |
| 34 | 35 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; |
| @@ -74,6 +75,7 @@ export function flowLines( | ||
| 74 | 75 | ...wrap(verdict(stages, now.getTime()), inner), |
| 75 | 76 | "", |
| 76 | 77 | ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner), |
| 78 | ...(agent === undefined ? clogLines(readClog(db, now), now.getTime()).flatMap((l) => wrap(l, inner)) : []), | |
| 77 | 79 | ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)), |
| 78 | 80 | "", |
| 79 | 81 | ...wrap(parkedSays(readParked(db, now, agent !== undefined)), inner), |
src/web/studio/home.ts +2 −1
| @@ -53,7 +53,7 @@ import { needsYouSection } from "./asks_page.ts"; | ||
| 53 | 53 | import { flowCards } from "./flow_card.ts"; |
| 54 | 54 | import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; |
| 55 | 55 | import { alarmLines, cachedAlarms } from "./alarm_lines.ts"; |
| 56 | import { kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts"; | |
| 56 | import { clogRow, kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts"; | |
| 57 | 57 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 58 | 58 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 59 | 59 | import { kpiCache, panelCache, waitingCache } from "./home_cache.ts"; |
| @@ -290,6 +290,7 @@ export async function studioHome( | ||
| 290 | 290 | ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db)) |
| 291 | 291 | : liveLine(ctx)} |
| 292 | 292 | ${figures ? timePhase(ctx.request, "vitals", () => vitalsRow(ctx.app.db)) : null} |
| 293 | ${figures ? clogRow(ctx.app.db) : null} | |
| 293 | 294 | ${figures ? alarmLines(cachedAlarms(ctx.app.db, new Date(), ceilingUsd)) : null} |
| 294 | 295 | ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user), () => waitingCache(ctx.app.db))} |
| 295 | 296 | ${figures |
src/web/studio/kpi_row.ts +8
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendCeilingWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts"; |
| 12 | 12 | import type { Kpis } from "../../flow/kpis.ts"; |
| 13 | import { clogLines, readClog } from "../../flow/clog_read.ts"; | |
| 13 | 14 | import { vitalsLine } from "../../flow/vitals.ts"; |
| 14 | 15 | import { cachedKpis, cachedVitals, cachedWaiting } from "./home_cache.ts"; |
| 15 | 16 | import { escapeHtml, html, raw, type Html } from "../html.ts"; |
| @@ -196,3 +197,10 @@ export function vitalsRow(db: Database, now: Date = new Date()): Html { | ||
| 196 | 197 | const rest = vitalsLine(v).split(" · ").slice(1).join(" · "); |
| 197 | 198 | return html`<p class="small muted"><a href="/boards">${v.open} open pulls</a> · ${rest}</p>`; |
| 198 | 199 | } |
| 200 | ||
| 201 | /** Home's clog lines for an admin (#6757): the clog standing now and the last cleared note; nothing when there is neither. */ | |
| 202 | export function clogRow(db: Database, now: Date = new Date()): Html { | |
| 203 | const lines = clogLines(readClog(db, now), now.getTime()); | |
| 204 | if (lines.length === 0) return html``; | |
| 205 | return html`${lines.map((l) => html`<p class="small muted break">${l}</p>`)}`; | |
| 206 | } |
test/event-audience-coverage.test.ts +7
| @@ -177,6 +177,13 @@ describe("event audience coverage", () => { | ||
| 177 | 177 | expect(eventAudience("brand.new_kind")).toBe("admins"); |
| 178 | 178 | }); |
| 179 | 179 | |
| 180 | test("a clog's events carry its cause and are classified for admins, never guests or agents", () => { | |
| 181 | for (const kind of ["clog.started", "clog.cleared"]) { | |
| 182 | expect(classifiedAudience(kind)).toBe("admins"); | |
| 183 | expect(eventAudience(kind)).toBe("admins"); | |
| 184 | } | |
| 185 | }); | |
| 186 | ||
| 180 | 187 | test("the scan names a kind it cannot read literally", () => { |
| 181 | 188 | const { unreadable } = emittedKinds({ |
| 182 | 189 | "src/x.ts": "app.bus.append({ kind: pick(a), payload: {} });", |
test/flow-clog-door.test.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * The clog on a door (#6757): the tick's other reasons print no "Time to | |
| 3 | * detect", a corrupt row and a restart are survived, and the standing clog and | |
| 4 | * the last cleared note show to the admin on home and in the Flow dialog, never to a guest or an agent. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { lastClearedNote, startClogTick, tickClog } from "../src/flow/clog_tick.ts"; | |
| 13 | import { clogLines, readClog } from "../src/flow/clog_read.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const T0 = new Date("2026-10-26T12:00:00.000Z"); | |
| 21 | const ago = (): string => new Date(T0.getTime() - 5 * 3_600_000).toISOString(); | |
| 22 | const after = (min: number): Date => new Date(T0.getTime() + min * 60_000); | |
| 23 | ||
| 24 | let root: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let uid: string; | |
| 28 | let n = 0; | |
| 29 | let was: string | undefined; | |
| 30 | let adminCookie = ""; | |
| 31 | let guestCookie = ""; | |
| 32 | ||
| 33 | async function signIn(username: string): Promise<string> { | |
| 34 | const res = await fetch(`${servers.url}/signin`, { | |
| 35 | method: "POST", | |
| 36 | headers: { origin: servers.url }, | |
| 37 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 38 | redirect: "manual", | |
| 39 | }); | |
| 40 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 41 | } | |
| 42 | ||
| 43 | beforeAll(async () => { | |
| 44 | was = process.env.FORGE_SENTINEL_REPO; | |
| 45 | process.env.FORGE_SENTINEL_REPO = "alice/forge"; | |
| 46 | root = mkdtempSync(join(tmpdir(), "clog-door-")); | |
| 47 | app = createApp({ dataDir: root, port: 0 }); | |
| 48 | for (const [username, first] of [["alice", true], ["guest", false]] as const) { | |
| 49 | const u = await createUser(app.db, { | |
| 50 | origin: "seed", | |
| 51 | trusted: true, | |
| 52 | username, | |
| 53 | email: `${username}@forge.local`, | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: first, | |
| 56 | }); | |
| 57 | if (!u.ok) throw new Error(u.error); | |
| 58 | if (first) uid = u.value.id; | |
| 59 | } | |
| 60 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('pub', ?1, 'forge', 'public')", [uid]); | |
| 61 | servers = await serve(app); | |
| 62 | adminCookie = await signIn("alice"); | |
| 63 | guestCookie = await signIn("guest"); | |
| 64 | }); | |
| 65 | afterAll(() => { | |
| 66 | if (was === undefined) delete process.env.FORGE_SENTINEL_REPO; | |
| 67 | else process.env.FORGE_SENTINEL_REPO = was; | |
| 68 | servers.stop(); | |
| 69 | app.close(); | |
| 70 | rmSync(root, { recursive: true, force: true }); | |
| 71 | }); | |
| 72 | ||
| 73 | const reset = (): void => { | |
| 74 | app.db.run("DELETE FROM clog_state"); | |
| 75 | app.db.run("DELETE FROM event"); | |
| 76 | app.db.run("DELETE FROM issue"); | |
| 77 | }; | |
| 78 | ||
| 79 | function pull(createdAt: string, merged: string | null = null): string { | |
| 80 | const id = `p${++n}`; | |
| 81 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES (?1, 'pub', ?2, 'pull', 't', ?3, 'open', ?4)", [id, n, uid, createdAt]); | |
| 82 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, '', 'h', ?3)", [id, `b${n}`, merged]); | |
| 83 | return id; | |
| 84 | } | |
| 85 | ||
| 86 | const cleared = (at: Date): string => lastClearedNote(app.db, at) ?? ""; | |
| 87 | ||
| 88 | test("a landing-ratio clog starts at the reading and its note prints no Time to detect", () => { | |
| 89 | reset(); | |
| 90 | for (let i = 0; i < 10; i++) pull(T0.toISOString()); | |
| 91 | expect(tickClog(app.db, app.bus, T0).event).toBe("started"); | |
| 92 | const row = app.db.query<{ since: number; started_at: number }, []>("SELECT since, started_at FROM clog_state").get()!; | |
| 93 | expect(row.started_at).toBe(row.since); | |
| 94 | app.db.run("DELETE FROM issue"); | |
| 95 | tickClog(app.db, app.bus, after(1)); | |
| 96 | expect(tickClog(app.db, app.bus, after(2)).event).toBe("cleared"); | |
| 97 | expect(cleared(after(2))).toContain("Cause: 0 landed of 10 opened"); | |
| 98 | expect(cleared(after(2))).not.toContain("Time to detect"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("a conflict clog starts at the reading too", () => { | |
| 102 | reset(); | |
| 103 | for (let i = 0; i < 11; i++) { | |
| 104 | const id = pull(ago()); | |
| 105 | app.db.run("INSERT INTO run (id, repo_id, kind, state, issue_id, reason, created_at) VALUES (?1, 'pub', 'agent', 'queued', ?2, 'conflict', ?3)", [`r${id}`, id, T0.toISOString()]); | |
| 106 | } | |
| 107 | expect(tickClog(app.db, app.bus, T0).event).toBe("started"); | |
| 108 | const row = app.db.query<{ since: number; started_at: number }, []>("SELECT since, started_at FROM clog_state").get()!; | |
| 109 | expect(row.started_at).toBe(row.since); | |
| 110 | app.db.run("DELETE FROM run"); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a corrupt reasons row is a clog with no recorded cause, and a standing row resumes after a restart", () => { | |
| 114 | reset(); | |
| 115 | app.db.run("INSERT INTO clog_state (id, since, started_at, first_reasons, quiet) VALUES (1, ?1, ?2, '{not json', 1)", [T0.getTime(), T0.getTime() - 3_600_000]); | |
| 116 | const read = readClog(app.db); | |
| 117 | expect(read.standing?.reasons).toEqual([]); | |
| 118 | expect(clogLines(read, T0.getTime())[0]).toContain("cause not recorded"); | |
| 119 | // The next quiet reading (a fresh tick, as after a restart) finishes the clear from the stored row. | |
| 120 | expect(tickClog(app.db, app.bus, after(1)).event).toBe("cleared"); | |
| 121 | expect(cleared(after(1))).toContain("Cause: not recorded"); | |
| 122 | expect(cleared(after(1))).toContain("Time to detect: 60 min"); | |
| 123 | }); | |
| 124 | ||
| 125 | test("clogLines: the standing clog, then the last cleared note", () => { | |
| 126 | const read = { standing: { since: T0.getTime(), startedAt: T0.getTime() - 30 * 3_600_000, reasons: ["a", "b"] }, last: "Clog cleared. Cause: x. · Time to detect: 5 min · Time to clear: 2 min" }; | |
| 127 | const lines = clogLines(read, T0.getTime()); | |
| 128 | expect(lines[0]).toBe("Clogged for 30 h (noticed 30 h in): a; b."); | |
| 129 | expect(lines[1]).toBe("Clog cleared. Cause: x. · Time to detect: 5 min · Time to clear: 2 min"); | |
| 130 | expect(clogLines({ standing: null, last: null }, T0.getTime())).toEqual([]); | |
| 131 | }); | |
| 132 | ||
| 133 | test("the admin sees the clog on home and in the Flow dialog; a guest and an agent see neither", async () => { | |
| 134 | reset(); | |
| 135 | app.db.run("INSERT INTO clog_state (id, since, started_at, first_reasons, quiet) VALUES (1, ?1, ?1, ?2, 0)", [Date.now(), JSON.stringify(["31 pulls conflict <b>"])]); | |
| 136 | const home = async (cookie: string): Promise<string> => (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 137 | const mine = await home(adminCookie); | |
| 138 | expect(mine).toContain("31 pulls conflict <b>"); | |
| 139 | expect(mine).not.toContain("<b>"); | |
| 140 | expect(await home(guestCookie)).not.toContain("pulls conflict"); | |
| 141 | expect(flowLines(app.db, new Date(), [], undefined, []).join("\n")).toContain("31 pulls conflict"); | |
| 142 | expect(flowLines(app.db, new Date(), [], { queues: [] }, []).join("\n")).not.toContain("31 pulls conflict"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("startClogTick runs the tick on its interval, reports a throw, and stops", async () => { | |
| 146 | reset(); | |
| 147 | for (let i = 0; i < 10; i++) pull(new Date().toISOString()); | |
| 148 | const errors: unknown[] = []; | |
| 149 | const stop = startClogTick(app.db, app.bus, (e) => errors.push(e), 5); | |
| 150 | await Bun.sleep(40); | |
| 151 | stop(); | |
| 152 | expect(app.db.query("SELECT 1 FROM clog_state").get()).not.toBeNull(); | |
| 153 | expect(errors).toEqual([]); | |
| 154 | app.db.run("DROP TABLE clog_state"); | |
| 155 | const broken = startClogTick(app.db, app.bus, (e) => errors.push(e), 5); | |
| 156 | await Bun.sleep(40); | |
| 157 | broken(); | |
| 158 | expect(errors.length).toBeGreaterThan(0); | |
| 159 | }); |
test/flow-clog-watch.test.ts +32 −16
| @@ -1,40 +1,56 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { clogNote } from "../src/flow/clog_note.ts"; | |
| 2 | 3 | import { clogStep } from "../src/flow/clog_watch.ts"; |
| 3 | 4 | |
| 4 | 5 | test("quiet with no clog stays null", () => { |
| 5 | expect(clogStep(null, [], 1)).toEqual({ state: null, event: null }); | |
| 6 | expect(clogStep(null, [], 1, 1)).toEqual({ state: null, event: null }); | |
| 6 | 7 | }); |
| 7 | 8 | |
| 8 | 9 | test("a reason starts a clog once and it stands without repeats", () => { |
| 9 | const a = clogStep(null, ["red main"], 10); | |
| 10 | const a = clogStep(null, ["red main"], 10, 10); | |
| 10 | 11 | expect(a.event).toBe("started"); |
| 11 | expect(a.state).toEqual({ since: 10, firstReasons: ["red main"] }); | |
| 12 | const b = clogStep(a.state, ["red main", "stuck"], 20); | |
| 12 | expect(a.state).toEqual({ since: 10, startedAt: 10, firstReasons: ["red main"] }); | |
| 13 | const b = clogStep(a.state, ["red main", "stuck"], 20, 20); | |
| 13 | 14 | expect(b.event).toBeNull(); |
| 14 | expect(b.state).toEqual({ since: 10, firstReasons: ["red main"] }); | |
| 15 | expect(b.state).toEqual({ since: 10, startedAt: 10, firstReasons: ["red main"] }); | |
| 15 | 16 | }); |
| 16 | 17 | |
| 17 | 18 | test("one quiet reading is still standing; a reason resets the count", () => { |
| 18 | const a = clogStep(null, ["x"], 10); | |
| 19 | const b = clogStep(a.state, [], 20); | |
| 19 | const a = clogStep(null, ["x"], 10, 10); | |
| 20 | const b = clogStep(a.state, [], 20, 20); | |
| 20 | 21 | expect(b.event).toBeNull(); |
| 21 | 22 | expect(b.state?.since).toBe(10); |
| 22 | const c = clogStep(b.state, ["x"], 30); | |
| 23 | const d = clogStep(c.state, [], 40); | |
| 23 | const c = clogStep(b.state, ["x"], 30, 30); | |
| 24 | const d = clogStep(c.state, [], 40, 40); | |
| 24 | 25 | expect(d.event).toBeNull(); |
| 25 | 26 | expect(d.state).not.toBeNull(); |
| 26 | 27 | }); |
| 27 | 28 | |
| 28 | 29 | test("two quiet readings in a row clear it with the start time", () => { |
| 29 | let s = clogStep(null, ["x"], 10).state; | |
| 30 | s = clogStep(s, [], 20).state; | |
| 31 | const c = clogStep(s, [], 30); | |
| 32 | expect(c).toEqual({ state: null, event: "cleared", since: 10 }); | |
| 30 | let s = clogStep(null, ["x"], 10, 10).state; | |
| 31 | s = clogStep(s, [], 20, 20).state; | |
| 32 | const c = clogStep(s, [], 30, 30); | |
| 33 | expect(c).toEqual({ state: null, event: "cleared", since: 10, startedAt: 10 }); | |
| 33 | 34 | }); |
| 34 | 35 | |
| 35 | 36 | test("a clog after a clear starts anew", () => { |
| 36 | const c = clogStep(clogStep(clogStep(null, ["x"], 10).state, [], 20).state, [], 30); | |
| 37 | const r = clogStep(c.state, ["y"], 50); | |
| 37 | const c = clogStep(clogStep(clogStep(null, ["x"], 10, 10).state, [], 20, 20).state, [], 30, 30); | |
| 38 | const r = clogStep(c.state, ["y"], 50, 50); | |
| 38 | 39 | expect(r.event).toBe("started"); |
| 39 | expect(r.state).toEqual({ since: 50, firstReasons: ["y"] }); | |
| 40 | expect(r.state).toEqual({ since: 50, startedAt: 50, firstReasons: ["y"] }); | |
| 41 | }); | |
| 42 | ||
| 43 | test("a real start rides the state to the clear and into the note", () => { | |
| 44 | const a = clogStep(null, ["x"], 600_000, -300_000); | |
| 45 | expect(a.state).toEqual({ since: 600_000, startedAt: -300_000, firstReasons: ["x"] }); | |
| 46 | const b = clogStep(clogStep(a.state, ["x"], 660_000, 660_000).state, [], 720_000, 720_000); | |
| 47 | const c = clogStep(b.state, [], 780_000, 780_000); | |
| 48 | expect(c.startedAt).toBe(-300_000); | |
| 49 | const note = clogNote({ reasons: ["x"], startedAt: c.startedAt!, detectedAt: c.since!, clearedAt: 780_000 }); | |
| 50 | expect(note).toContain("Time to detect: 15 min"); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a start after now, or not a number, falls back to now", () => { | |
| 54 | expect(clogStep(null, ["x"], 10, 99).state?.startedAt).toBe(10); | |
| 55 | expect(clogStep(null, ["x"], 10, NaN).state?.startedAt).toBe(10); | |
| 40 | 56 | }); |
new above
offline
Untested audience rule. Add a classifiedAudience test for clog.started and clog.cleared that fails without this line.