Public: anyone can read this; the crew can change it.
When a clog clears, the forge posts its note on the owner's feed once #6681
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6524-when-a-clog-clears-the-forge-posts-its-n → main 11 commits fork 10 threads · 10 unresolved
docs/STATUS.md +1
| @@ -37,6 +37,7 @@ | ||
| 37 | 37 | allowed). |
| 38 | 38 | - **System memory** — `/system`, for a forge admin who is a person (anyone else gets 403): the forge's RSS beside its JS heap (`heapStats()` from bun:jsc), hourly mean and max for the last 24 h, drawn on the server. Sampled once a minute into `memory_hour` in `app.db` (a week kept). RSS rising while the heap stays flat is native memory growth. In the terminal the same admin has a **System memory** command in the palette: the last 24 hours as text rows (hour, RSS mean/max, heap mean/max in MiB), read-only. The account menu links `/system` for that admin. |
| 39 | 39 | - **Flow alarms** — a firing alarm (red main, a stuck approved pull, an idle crew, no release for 90 min) shows as one linked line above home's feed and on `/boards` (admin), under the vitals line of the terminal's Flow dialog and home, as one phone notification when it starts (not repeated while it stands), and in the `flow` tool's `alarms` (MCP, `/api/v1/telemetry/flow`; an agent's cut to public repositories). Nothing firing draws nothing. The dialog also lists the idle crew pulls (number, repository, hours since the last push), oldest first, at most 20 read and five drawn with `+N more`; none draws nothing, and an agent's list is cut to public repositories. |
| 40 | - **Clog watch** — the minute tick (`src/flow/clog_tick.ts`) reads the flow; when a clog starts the admin gets one phone notification, and when it clears one `clog.cleared` note (cause, time to clear, peak stranded $) lands once on the admin's feed and as a line on the terminal's home. The watch state is in the database, so a restart repeats neither. | |
| 40 | 41 | - **Boards** — `/boards` also shows the pipeline (a row per stage, the bottleneck named) and the held counts (why each autonomous repository's ready issues wait; private repositories included), for the same admin who is a person; they sit as two cards under the board list, not as panels of a board, because they are not saved queries. The terminal's Boards screen does not draw them: its door to the same rows is the **Flow** dialog. `/boards/data`, for a forge admin who is a person (anyone else |
| 41 | 42 | gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`), |
| 42 | 43 | drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`) |
src/core/event_audience.ts +2
| @@ -170,6 +170,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 170 | 170 | kind === "repo.upstream_follow" || |
| 171 | 171 | kind === "repo.upstream_checked" || |
| 172 | 172 | kind === "repo.crew_outsiders" || |
| 173 | // A clog starting names the forge's own pile-up: the owner's to hear (#6524). | |
| 174 | kind.startsWith("clog.") || | |
| 173 | 175 | // A file held against new work is its admins' book-keeping (#6522). |
| 174 | 176 | kind === "repo.file_held" || |
| 175 | 177 | kind === "repo.file_released" |
src/core/migrations/20261027010000-clog-watch.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | // The clog watch survives a restart (#6524): the one standing clog (since | |
| 2 | // when, the reasons that started it, quiet readings so far). The cleared | |
| 3 | // note rides the event outbox as `clog.cleared`. | |
| 4 | export default ` | |
| 5 | CREATE TABLE IF NOT EXISTS clog_state ( | |
| 6 | id INTEGER PRIMARY KEY CHECK (id = 1), | |
| 7 | since INTEGER NOT NULL, | |
| 8 | first_reasons TEXT NOT NULL, | |
| 9 | quiet INTEGER NOT NULL DEFAULT 0 | |
| 10 | ); | |
| 11 | `; |
src/core/migrations/20261027100000-clog-peak.ts added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | // The most the stranded spend reached while a clog stood (#6524), so the | |
| 2 | // cleared note can say "at peak" truthfully. Additive; zero before this column. | |
| 3 | export default ` | |
| 4 | ALTER TABLE clog_state ADD COLUMN peak_usd REAL NOT NULL DEFAULT 0; | |
| 5 | `; |
src/crew/notify.ts +19
| @@ -41,6 +41,7 @@ import { health } from "../http/health.ts"; | ||
| 41 | 41 | import { forgeVitals } from "../actions/health.ts"; |
| 42 | 42 | import { RECOVERED, drainIncident, nextPage, observe, recoveredLine, type Watch } from "./incidents.ts"; |
| 43 | 43 | import { firedAlarms, type FiredAlarm } from "../flow/alarms.ts"; |
| 44 | import { loadClog } from "../flow/clog_tick.ts"; | |
| 44 | 45 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 45 | 46 | import { askLinesFor } from "../web/studio/asks.ts"; |
| 46 | 47 | |
| @@ -56,6 +57,7 @@ const BACKOFF_MAX_MS = HOUR_MS; | ||
| 56 | 57 | |
| 57 | 58 | /** Events after which the set of asks may have changed. */ |
| 58 | 59 | const WAKES = new Set([ |
| 60 | "clog.started", | |
| 59 | 61 | "run.finished", |
| 60 | 62 | "pull.opened", |
| 61 | 63 | "pull.pushed", |
| @@ -262,6 +264,22 @@ export function alarmLines( | ||
| 262 | 264 | }); |
| 263 | 265 | } |
| 264 | 266 | |
| 267 | /** | |
| 268 | * The owner hears once when a clog (#6524) starts: the id is the clog's own | |
| 269 | * start, so the one standing clog is the same ask on every tick and a later | |
| 270 | * one is new. The state is the database's, so a restart never says it twice. | |
| 271 | */ | |
| 272 | export function clogLines( | |
| 273 | app: App, | |
| 274 | user: User, | |
| 275 | ): { id: string; line: string; items: readonly string[]; link: string }[] { | |
| 276 | if (!user.isAdmin || user.kind !== "user") return []; | |
| 277 | const clog = loadClog(app.db); | |
| 278 | if (clog === null) return []; | |
| 279 | const id = `clog:${clog.since}`; | |
| 280 | return [{ id, line: `Clogged: ${clog.firstReasons.join("; ")}.`, items: [id], link: "/" }]; | |
| 281 | } | |
| 282 | ||
| 265 | 283 | export interface Notifier { |
| 266 | 284 | /** Resolves when the work queued so far is done. For tests and shutdown. */ |
| 267 | 285 | settled(): Promise<void>; |
| @@ -448,6 +466,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 448 | 466 | ...mirrorLines(app, user, now()), |
| 449 | 467 | ...redLines(app, user, now()), |
| 450 | 468 | ...alarmLines(app, user, now(), options.alarms?.()), |
| 469 | ...clogLines(app, user), | |
| 451 | 470 | ]; |
| 452 | 471 | const before = seen.get(userId); |
| 453 | 472 | // What is gone may ask again later, and then it is new again. Seen by |
src/feed/items.ts +8
| @@ -30,6 +30,7 @@ import { | ||
| 30 | 30 | import { getRunErrorLine } from "../core/runs.ts"; |
| 31 | 31 | import { getUserById, type User } from "../core/users.ts"; |
| 32 | 32 | import { watchedBy } from "../core/watches.ts"; |
| 33 | import { CLOG_NOTE_WINDOW_MS, flattenNote } from "../flow/clog_note.ts"; | |
| 33 | 34 | import { chooseCandidates } from "./choose.ts"; |
| 34 | 35 | import { writeStamp } from "../core/write_clock.ts"; |
| 35 | 36 | import { Stamped } from "../kernel/stamped.ts"; |
| @@ -379,6 +380,7 @@ export function candidatesFor( | ||
| 379 | 380 | if (!repo) continue; |
| 380 | 381 | const fold = folded.get(e.id); |
| 381 | 382 | if (fold === "skip") continue; |
| 383 | if (e.kind === "clog.cleared" && now - Date.parse(e.createdAt) > CLOG_NOTE_WINDOW_MS) continue; | |
| 382 | 384 | const c = fromEvent(db, e, repo, owns(repo), readsLogs(repo), subjects); |
| 383 | 385 | if (c) |
| 384 | 386 | out.push({ |
| @@ -686,6 +688,12 @@ function fromEvent( | ||
| 686 | 688 | what: said === "" ? `replied on “${on}”` : `on “${on}”: ${clip(said)}`, |
| 687 | 689 | }; |
| 688 | 690 | } |
| 691 | case "clog.cleared": { | |
| 692 | const note = typeof p["note"] === "string" ? p["note"] : ""; | |
| 693 | if (note === "") return undefined; | |
| 694 | const what = clip(flattenNote(note), 300); | |
| 695 | return { ...common, kind: "map", link: base(repo), what, said: what }; | |
| 696 | } | |
| 689 | 697 | case "issue.blocked": |
| 690 | 698 | case "issue.unblocked": |
| 691 | 699 | return { |
src/flow/clog_note.ts +12 −3
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The note posted when a clog clears (#5930): the cause on one line, how long |
| 3 | * the clog took to be noticed and to clear, and the dollars stranded at the | |
| 3 | * the clog took to be noticed (only when that was measured) and to clear, and the dollars stranded at the | |
| 4 | 4 | * peak. Pure: plain data in, markdown out. Posting it is another module's job. |
| 5 | 5 | */ |
| 6 | 6 | |
| @@ -25,14 +25,23 @@ export function duration(ms: number): string { | ||
| 25 | 25 | return `${(Math.round(min / 6) / 10).toString()} h`; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | /** How long a cleared clog's note stays on the feed and the terminal's home line. */ | |
| 29 | export const CLOG_NOTE_WINDOW_MS = 24 * 3_600_000; | |
| 30 | ||
| 31 | /** The note on one line: its blank line and bullets become " · ". */ | |
| 32 | export function flattenNote(note: string): string { | |
| 33 | return note.replace(/\s*\n+\s*-?\s*/g, " · ").trim(); | |
Skip this line when detectedAt === startedAt rather than print a figure that is always 0. |
||
| 34 | } | |
| 35 | ||
| 28 | 36 | export function clogNote(input: ClogNoteInput): string { |
| 29 | 37 | const why = input.reasons.map((r) => r.trim()).filter(Boolean); |
| 30 | 38 | const lines = [ |
| 31 | 39 | `Clog cleared. Cause: ${why.length ? why.join("; ") : "not recorded"}.`, |
| 32 | 40 | "", |
| 33 | `- Time to detect: ${duration(input.detectedAt - input.startedAt)}`, | |
| 34 | `- Time to clear: ${duration(input.clearedAt - input.detectedAt)}`, | |
| 35 | 41 | ]; |
| 42 | // Detected the moment it started means detection was not measured: print no figure rather than a false 0. | |
| 43 | if (input.detectedAt !== input.startedAt) lines.push(`- Time to detect: ${duration(input.detectedAt - input.startedAt)}`); | |
| 44 | lines.push(`- Time to clear: ${duration(input.clearedAt - input.detectedAt)}`); | |
| 36 | 45 | const usd = input.strandedUsd; |
| 37 | 46 | if (typeof usd === "number" && Number.isFinite(usd) && usd > 0) { |
| 38 | 47 | lines.push(`- Stranded at peak: $${usd.toLocaleString("en-US", { maximumFractionDigits: 2 })}`); |
src/flow/clog_tick.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * The minute's clog tick (#6524): reads the flow, asks `clogOf` whether it is | |
| 3 | * clogged, steps the watch (`clogStep`) and acts on the one event: "started" | |
| 4 | * is told on the bus once, "cleared" puts `clogNote` on the bus once (the feed | |
| 5 | * and the terminal read it from there). The watch state lives in `clog_state`, so a restart reads where it | |
| 6 | * left off and never repeats either. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { listEvents, type EventBus } from "../core/events.ts"; | |
| 11 | import { getRepo } from "../core/repos.ts"; | |
| 12 | import { createLog } from "../kernel/log.ts"; | |
| 13 | import { ownRepoPath } from "../kernel/own_repo.ts"; | |
| 14 | import { clogOf } from "./clog.ts"; | |
| 15 | import { CLOG_NOTE_WINDOW_MS, clogNote, flattenNote } from "./clog_note.ts"; | |
| 16 | import { clogStep, type ClogEvent, type ClogState } from "./clog_watch.ts"; | |
| 17 | import { readOldestApproved } from "./oldest_approved.ts"; | |
| 18 | import { readStranded } from "./stranded.ts"; | |
| 19 | import { readVitals } from "./vitals.ts"; | |
| 20 | ||
| 21 | const HOUR_MS = 3_600_000; | |
| 22 | const log = createLog("clog"); | |
| 23 | ||
| 24 | interface StateRow { | |
| 25 | since: number; | |
| 26 | first_reasons: string; | |
| 27 | quiet: number; | |
| 28 | peak_usd: number; | |
| 29 | } | |
| 30 | ||
| 31 | 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 | 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 }; | |
| 37 | } | |
| 38 | ||
| 39 | function saveClog(db: Database, state: ClogState | null): void { | |
| 40 | if (!state) { | |
| 41 | db.query("DELETE FROM clog_state WHERE id = 1").run(); | |
| 42 | return; | |
| 43 | } | |
| 44 | 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); | |
| 48 | } | |
| 49 | ||
| 50 | /** Pulls opened and landed over the last 2 h, forge-wide, as the clog thresholds read them. */ | |
| 51 | function twoHours(db: Database, now: Date): { opened: number; landed: number } { | |
| 52 | const from = new Date(now.getTime() - 2 * HOUR_MS).toISOString(); | |
| 53 | const to = now.toISOString(); | |
| 54 | const row = db | |
| 55 | .query<{ opened: number; landed: number }, [string, string]>( | |
| 56 | `SELECT | |
| 57 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 58 | WHERE i.created_at >= ?1 AND i.created_at <= ?2) AS opened, | |
| 59 | (SELECT COUNT(*) FROM pull p JOIN repo r ON r.id = (SELECT repo_id FROM issue WHERE id = p.issue_id) | |
| 60 | WHERE p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?2) AS landed`, | |
| 61 | ) | |
| 62 | .get(from, to); | |
| 63 | return { opened: row?.opened ?? 0, landed: row?.landed ?? 0 }; | |
| 64 | } | |
| 65 | ||
outdated
The |
||
| 66 | export interface ClogTick { | |
| 67 | readonly event: ClogEvent | null; | |
outdated
The comment says first line only, but flattenNote returns the whole note on one line. Fix the comment. |
||
| 68 | } | |
| 69 | ||
| 70 | /** The newest cleared clog's note within a day, on one line: the terminal's door to what the feed shows. */ | |
| 71 | export function lastClearedNote(db: Database, now = new Date()): string | undefined { | |
| 72 | const [e] = listEvents(db, { kind: "clog.cleared", limit: 1 }); | |
| 73 | const note = e?.payload["note"]; | |
| 74 | if (typeof note !== "string" || now.getTime() - Date.parse(e!.createdAt) > CLOG_NOTE_WINDOW_MS) return undefined; | |
| 75 | return flattenNote(note); | |
| 76 | } | |
| 77 | ||
| 78 | /** One tick. With no own repository there is nobody to tell, and the watch still steps. */ | |
| 79 | export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new Date()): ClogTick { | |
outdated
|
||
| 80 | const vitals = readVitals(db, now); | |
| 81 | const { opened, landed } = twoHours(db, now); | |
| 82 | const oldest = readOldestApproved(db, now); | |
| 83 | const reading = clogOf({ | |
outdated
While a clog stands, |
||
| 84 | conflicted: vitals.conflicted, | |
| 85 | opened, | |
| 86 | landed, | |
| 87 | oldestApprovedHours: oldest ? Math.floor(oldest.hours) : 0, | |
| 88 | }).reasons; | |
| 89 | const [owner, name] = ownRepoPath().split("/"); | |
| 90 | const repo = owner && name ? getRepo(db, owner, name) : undefined; | |
| 91 | // Read, step and write in one transaction: two overlapping processes cannot both clear. | |
| 92 | return db.transaction((): ClogTick => { | |
| 93 | const prev = loadClog(db); | |
| 94 | const step = clogStep(prev, reading, now.getTime()); | |
| 95 | const usd = readStranded(db).usd; | |
| 96 | // The peak is the most seen while the clog stood: by the clear the pile is gone. | |
| 97 | const state = step.state && { ...step.state, peakUsd: Math.max(prev?.peakUsd ?? 0, usd) }; | |
| 98 | if (JSON.stringify(state) !== JSON.stringify(prev)) saveClog(db, state); | |
outdated
detectedAt equals startedAt here, so the note always reports "Time to detect: 0 min". Omit that line until detection is measured. |
||
| 99 | if (step.event === "cleared" && !repo) log.warn("clog cleared but the own repository is missing; no note posted"); | |
| 100 | if (step.event === "cleared" && repo && prev) { | |
| 101 | const note = clogNote({ | |
| 102 | reasons: prev.firstReasons, | |
| 103 | startedAt: prev.since, | |
| 104 | detectedAt: prev.since, | |
| 105 | clearedAt: now.getTime(), | |
| 106 | strandedUsd: Math.max(prev.peakUsd ?? 0, usd), | |
| 107 | }); | |
| 108 | bus.append({ kind: "clog.cleared", repoId: repo.id, payload: { note, since: prev.since } }); | |
| 109 | } | |
| 110 | if (step.event === "started" && repo) { | |
| 111 | bus.append({ kind: "clog.started", repoId: repo.id, payload: { since: now.getTime() } }); | |
| 112 | } | |
| 113 | return { event: step.event }; | |
| 114 | }).immediate(); | |
| 115 | } |
src/flow/clog_watch.ts +4 −1
| @@ -14,6 +14,8 @@ export interface ClogState { | ||
| 14 | 14 | readonly firstReasons: readonly string[]; |
| 15 | 15 | /** Quiet readings in a row so far; absent when zero. */ |
| 16 | 16 | readonly quiet?: number; |
| 17 | /** The most stranded dollars seen while it stood; kept by the tick, not by the step. */ | |
| 18 | readonly peakUsd?: number; | |
| 17 | 19 | } |
| 18 | 20 | |
| 19 | 21 | export type ClogEvent = "started" | "cleared"; |
| @@ -30,7 +32,8 @@ export function clogStep(prev: ClogState | null, reading: readonly string[], now | ||
| 30 | 32 | if (reading.length === 0) return { state: null, event: null }; |
| 31 | 33 | return { state: { since: now, firstReasons: [...reading] }, event: "started" }; |
| 32 | 34 | } |
| 33 | if (reading.length > 0) return { state: { since: prev.since, firstReasons: prev.firstReasons }, event: null }; | |
| 35 | if (reading.length > 0) | |
| 36 | return { state: { since: prev.since, firstReasons: prev.firstReasons, ...(prev.peakUsd === undefined ? {} : { peakUsd: prev.peakUsd }) }, event: null }; | |
| 34 | 37 | const quiet = (prev.quiet ?? 0) + 1; |
| 35 | 38 | if (quiet >= QUIET_TO_CLEAR) return { state: null, event: "cleared", since: prev.since }; |
| 36 | 39 | return { state: { ...prev, quiet }, event: null }; |
src/main.ts +11
| @@ -54,6 +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 | 58 | import { sweepStaleAsks } from "./crew/stale_asks.ts"; |
| 58 | 59 | import { recordAskLedger } from "./web/studio/asks.ts"; |
| 59 | 60 | import { startPromptRoutine } from "./crew/prompts.ts"; |
| @@ -550,6 +551,15 @@ const processTick = (): void => { | ||
| 550 | 551 | app.log.warn("process census failed", { error: String(error) }); |
| 551 | 552 | } |
| 552 | 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); | |
| 553 | 563 | const processTimer = setInterval(processTick, 60_000); |
| 554 | 564 | processTick(); |
| 555 | 565 | // A new version proves itself against the public URL once (release/prove.ts). |
| @@ -668,6 +678,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 668 | 678 | clearInterval(staleTimer); |
| 669 | 679 | clearInterval(followTimer); |
| 670 | 680 | clearInterval(processTimer); |
| 681 | clearInterval(clogTimer); | |
| 671 | 682 | clearInterval(leaseTimer); |
| 672 | 683 | // The platform's SIGTERM: the listener stays open while routers learn |
| 673 | 684 | // this process is going. A person's Ctrl-C does not wait for routers. |
src/tui/screens/dashboard.ts +3
| @@ -107,6 +107,7 @@ import { listPinned } from "../../actions/board-pins.ts"; | ||
| 107 | 107 | import { migrateScreen } from "./migrate.ts"; |
| 108 | 108 | import { newRepoScreen } from "./newrepo.ts"; |
| 109 | 109 | import { firedAlarms } from "../../flow/alarms.ts"; |
| 110 | import { lastClearedNote } from "../../flow/clog_tick.ts"; | |
| 110 | 111 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 111 | 112 | import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts"; |
| 112 | 113 | import { noReleaseAlarm } from "../../flow/alarm_no_release.ts"; |
| @@ -302,6 +303,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 302 | 303 | lane = forgeLane(f.app, f.user); |
| 303 | 304 | decider = deciderLine(f.app, f.user); |
| 304 | 305 | const commit = running().commit; |
| 306 | const cleared = lastClearedNote(f.db, now); | |
Nit: |
||
| 305 | 307 | figures = |
| 306 | 308 | f.user?.isAdmin !== true |
| 307 | 309 | ? [] |
| @@ -309,6 +311,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 309 | 311 | ...kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }), |
| 310 | 312 | vitalsLine(readVitals(f.db, now)), |
| 311 | 313 | ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says), |
outdated
This line is the terminal door to the clear note and nothing renders it in a test. Add a dashboard test: admin sees the note, non-admin sees nothing. |
||
| 314 | ...(cleared === undefined ? [] : [cleared]), | |
| 312 | 315 | ]; |
| 313 | 316 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
| 314 | 317 | // filter reaches every repository you can see. |
test/flow-clog-note.test.ts +6
| @@ -31,3 +31,9 @@ test("a negative span reads as zero", () => { | ||
| 31 | 31 | test("no reasons still reads", () => { |
| 32 | 32 | expect(clogNote({ ...base, reasons: [] })).toContain("Cause: not recorded."); |
| 33 | 33 | }); |
| 34 | ||
| 35 | test("a detection that was not measured prints no time to detect", () => { | |
| 36 | const note = clogNote({ ...base, detectedAt: base.startedAt }); | |
| 37 | expect(note).not.toContain("Time to detect"); | |
| 38 | expect(note).toContain("- Time to clear: 70 min"); | |
| 39 | }); |
test/flow-clog-tick.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** The minute's clog tick (#6524): start notifies once, clear posts once, a restart repeats neither. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 9 | import { openPull } from "../src/actions/pulls.ts"; | |
| 10 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 12 | import { setNotifyUrl, startNotifier, type Notifier } from "../src/crew/notify.ts"; | |
| 13 | import { candidatesFor } from "../src/feed/items.ts"; | |
| 14 | import { lastClearedNote, loadClog, tickClog } from "../src/flow/clog_tick.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | const MIN = 60_000; | |
| 20 | let dataDir = ""; | |
| 21 | let app: App; | |
| 22 | let alice: User; | |
| 23 | let was: string | undefined; | |
| 24 | const posts: string[] = []; | |
| 25 | const notifiers: Notifier[] = []; | |
| 26 | ||
| 27 | const boot = async (): Promise<Notifier> => { | |
| 28 | const n = startNotifier(app, { | |
| 29 | forgeUrl: "https://forge.example.test", | |
| 30 | drainingSince: () => null, | |
| 31 | send: async (_u, body) => { | |
| 32 | posts.push(body); | |
| 33 | return true; | |
| 34 | }, | |
| 35 | }); | |
| 36 | notifiers.push(n); | |
| 37 | await n.settled(); | |
| 38 | return n; | |
| 39 | }; | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | was = process.env.FORGE_SENTINEL_REPO; | |
| 43 | process.env.FORGE_SENTINEL_REPO = "alice/forge"; | |
| 44 | dataDir = await mkdtemp(join(tmpdir(), "forge-clog-tick-")); | |
| 45 | app = createApp({ dataDir, port: 0 }); | |
| 46 | const a = await createUser(app.db, { | |
| 47 | origin: "seed", | |
| 48 | trusted: true, | |
| 49 | username: "alice", | |
| 50 | email: "[email protected]", | |
| 51 | password: "a-long-clog-tick-passphrase", | |
| 52 | adminIfFirst: true, | |
| 53 | }); | |
| 54 | if (!a.ok) throw new Error(a.error); | |
| 55 | alice = a.value; | |
| 56 | setNotifyUrl(app.db, alice.id, "https://ntfy.example.test/root-topic"); | |
| 57 | await createAgent(app, alice, { username: "bot" }); | |
| 58 | const made = await createRepo(app, alice, { name: "forge", visibility: "public" }); | |
| 59 | if (!made.ok) throw new Error(String(made.error)); | |
| 60 | const repo = made.value; | |
| 61 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# f\n" }] }); | |
| 62 | addToCrew(app, alice, repo, "bot"); | |
| 63 | // Ten pulls opened and none landed over 2 h: a clog by the landing ratio. | |
| 64 | for (let i = 0; i < 10; i++) { | |
| 65 | await commitToBranch(app, alice, repo, { | |
| 66 | branch: `bot/${i}`, | |
| 67 | base: "main", | |
| 68 | message: `p${i}`, | |
| 69 | files: [{ path: `F${i}.md`, content: `${i}\n` }], | |
| 70 | }); | |
| 71 | const opened = await openPull(app, getUserByName(app.db, "bot")!, repo, { | |
| 72 | title: `p${i}`, | |
| 73 | body: "", | |
| 74 | baseBranch: "main", | |
| 75 | headBranch: `bot/${i}`, | |
| 76 | }); | |
| 77 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 78 | } | |
| 79 | }, 60_000); | |
| 80 | ||
| 81 | afterAll(async () => { | |
| 82 | for (const n of notifiers) await n.close(); | |
| 83 | if (was === undefined) delete process.env.FORGE_SENTINEL_REPO; | |
| 84 | else process.env.FORGE_SENTINEL_REPO = was; | |
| 85 | await rm(dataDir, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | test("across ticks, start notifies once and clear posts once, through a restart", async () => { | |
| 89 | const t0 = new Date(); | |
| 90 | const first = await boot(); | |
| 91 | expect(tickClog(app.db, app.bus, t0).event).toBe("started"); | |
| 92 | await first.settled(); | |
| 93 | expect(posts).toHaveLength(1); | |
| 94 | expect(posts[0]).toContain("Clogged:"); | |
| 95 | ||
| 96 | // Still clogged: no second event, no second notification. | |
| 97 | expect(tickClog(app.db, app.bus, new Date(t0.getTime() + MIN)).event).toBeNull(); | |
| 98 | await first.settled(); | |
| 99 | expect(posts).toHaveLength(1); | |
| 100 | ||
| 101 | // A restart: the watch state is the database's, and the new notifier says nothing. | |
| 102 | await first.close(); | |
| 103 | expect(loadClog(app.db)?.since).toBe(t0.getTime()); | |
| 104 | const second = await boot(); | |
| 105 | expect(tickClog(app.db, app.bus, new Date(t0.getTime() + 2 * MIN)).event).toBeNull(); | |
| 106 | await second.settled(); | |
| 107 | expect(posts).toHaveLength(1); | |
| 108 | ||
| 109 | // The pile leaves the 2 h window: two quiet ticks clear it, once. | |
| 110 | const later = t0.getTime() + 3 * 60 * MIN; | |
| 111 | expect(tickClog(app.db, app.bus, new Date(later)).event).toBeNull(); | |
| 112 | expect(tickClog(app.db, app.bus, new Date(later + MIN)).event).toBe("cleared"); | |
| 113 | expect(tickClog(app.db, app.bus, new Date(later + 2 * MIN)).event).toBeNull(); | |
| 114 | expect(app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'clog.cleared'").get()).toEqual({ n: 1 }); | |
| 115 | expect(loadClog(app.db)).toBeNull(); | |
| 116 | await second.settled(); | |
| 117 | expect(posts).toHaveLength(1); | |
| 118 | ||
| 119 | // The note is on the admin's feed, once. | |
| 120 | const mine = candidatesFor(app.db, alice, later).filter((c) => c.what.startsWith("Clog cleared")); | |
| 121 | expect(mine).toHaveLength(1); | |
| 122 | expect(mine[0]!.what).toContain("Clog cleared. Cause:"); | |
| 123 | expect(mine[0]!.what).not.toContain("Time to detect"); | |
| 124 | // The terminal's home reads the same note from the same event. | |
| 125 | expect(lastClearedNote(app.db, new Date(later))).toContain("Clog cleared. Cause:"); | |
| 126 | expect(lastClearedNote(app.db, new Date(later + 25 * 60 * MIN))).toBeUndefined(); | |
| 127 | // Both doors drop the note after the same window. | |
| 128 | const dayOn = Date.now() + 25 * 60 * MIN; | |
| 129 | expect(candidatesFor(app.db, alice, dayOn).filter((c) => c.what.startsWith("Clog cleared"))).toHaveLength(0); | |
| 130 | const bob = await createUser(app.db, { | |
| 131 | origin: "seed", | |
| 132 | trusted: true, | |
| 133 | username: "bob", | |
| 134 | email: "[email protected]", | |
| 135 | password: "a-long-clog-tick-passphrase", | |
| 136 | }); | |
| 137 | if (!bob.ok) throw new Error(bob.error); | |
| 138 | expect(candidatesFor(app.db, bob.value, later).filter((c) => c.what.startsWith("Clog cleared"))).toHaveLength(0); | |
| 139 | }, 30_000); | |
| 140 | ||
| 141 | test("the note reports the peak stranded dollars, not the figure at the clear", () => { | |
| 142 | const far = Date.now() + 20 * 60 * MIN; | |
| 143 | // A clog that stood with 50 dollars stranded; by now nothing is stranded. | |
| 144 | app.db.query("INSERT INTO clog_state (id, since, first_reasons, quiet, peak_usd) VALUES (1, ?1, ?2, 0, 50)").run(far - 30 * MIN, "[\"x\"]"); | |
| 145 | expect(tickClog(app.db, app.bus, new Date(far)).event).toBeNull(); | |
| 146 | expect(loadClog(app.db)?.peakUsd).toBe(50); | |
| 147 | expect(tickClog(app.db, app.bus, new Date(far + MIN)).event).toBe("cleared"); | |
| 148 | expect(lastClearedNote(app.db, new Date(far + MIN))).toContain("Stranded at peak: $50"); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a clear with no own repository still clears the watch and posts no note", () => { | |
| 152 | const was = process.env.FORGE_SENTINEL_REPO; | |
| 153 | process.env.FORGE_SENTINEL_REPO = "alice/nowhere"; | |
| 154 | try { | |
| 155 | const far = Date.now() + 10 * 60 * 60 * MIN; | |
| 156 | app.db.query("INSERT INTO clog_state (id, since, first_reasons, quiet) VALUES (1, ?1, ?2, 1)").run(far - MIN, "[\"x\"]"); | |
| 157 | expect(tickClog(app.db, app.bus, new Date(far)).event).toBe("cleared"); | |
| 158 | expect(loadClog(app.db)).toBeNull(); | |
| 159 | expect(app.db.query("SELECT COUNT(*) AS n FROM event WHERE kind = 'clog.cleared'").get()).toEqual({ n: 2 }); | |
| 160 | } finally { | |
| 161 | process.env.FORGE_SENTINEL_REPO = was; | |
| 162 | } | |
| 163 | }); |
test/tui-dashboard-alarms.test.ts +9
| @@ -77,6 +77,15 @@ test("a stuck approved pull draws the stuck line for an admin; a non-admin reads | ||
| 77 | 77 | expect(await firstScreen(bob)).toContain("alice/open#1 has sat approved"); |
| 78 | 78 | }); |
| 79 | 79 | |
| 80 | test("a cleared clog's note is on the admin's home line and nobody else's", async () => { | |
| 81 | forge.db.run( | |
| 82 | "INSERT INTO event (id, kind, repo_id, actor_id, payload, created_at) VALUES ('clog1', 'clog.cleared', NULL, NULL, ?1, ?2)", | |
| 83 | [JSON.stringify({ note: "Clog cleared. Cause: the pile.\n\n- Time to clear: 3 min\n" }), new Date().toISOString()], | |
| 84 | ); | |
| 85 | expect(await firstScreen(alice)).toContain("Clog cleared. Cause: the pile."); | |
| 86 | expect(await firstScreen(bob)).not.toContain("Clog cleared"); | |
| 87 | }); | |
| 88 | ||
| 80 | 89 | test("the dashboard's since-you-were-here shows a release and a failed gate", async () => { |
| 81 | 90 | const db = forge.db; |
| 82 | 91 | const now = new Date().toISOString(); |
Nit: this prefix also covers
clog.cleared, so the comment should say "a clog starting or clearing".