Public: anyone can read this; the crew can change it.
Today's card says what the decider did: closed N, deferred N, asked you about N #1983
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1606-today-s-card-says-what-the-decider-did-c → main 6 commits fork
10 files
changed +590
−3
README.md +9
| @@ -306,6 +306,15 @@ muted"). On the On you card: `space` marks a row, `x` closes the marked rows | ||
| 306 | 306 | again to every muted repository. Each goes through `applyAsksBulk`, so the |
| 307 | 307 | per-row access checks and refusals are the web's, shown in the toast. |
| 308 | 308 | |
| 309 | **What the decider did today.** Today's card on the board carries one line, | |
| 310 | "The decider closed N, deferred N, asked you about N": each count links to | |
| 311 | its ledger rows (`/board?ledger=closed|deferred|asked`; any other value is | |
| 312 | ignored, `?apps=` is kept). A deferral is the decider's own `stop`, never the | |
| 313 | rule's. The ledger view lists up to 200 rows and says when it left some out. | |
| 314 | In the terminal the same line sits above the dashboard, `C` (or the palette's | |
| 315 | Decider) opens the rows, and `1`, `2`, `3` switch pile. Both are for the | |
| 316 | repositories you own only: a reader sees nothing. | |
| 317 | ||
| 309 | 318 | **Let the decider** answers a hand-over card (an issue the crew parked) when |
| 310 | 319 | you would rather not: the third tap on the card, `POST /api/v1/asks/decide` |
| 311 | 320 | (`{"item": "parked:<id>"}`), the MCP tool `asks_decide`, or `D` on the On you |
scripts/gate.ts +2
| @@ -64,6 +64,7 @@ export const GATE: readonly string[] = [ | ||
| 64 | 64 | "test/tui-onyou.test.ts", |
| 65 | 65 | "test/tui-deploys.test.ts", |
| 66 | 66 | "test/tui-access.test.ts", |
| 67 | "test/tui-decider.test.ts", | |
| 67 | 68 | "test/claim-link.test.ts", |
| 68 | 69 | "test/og-card.test.ts", |
| 69 | 70 | "test/og-route.test.ts", |
| @@ -231,6 +232,7 @@ export const GATE: readonly string[] = [ | ||
| 231 | 232 | "test/credentials.test.ts", |
| 232 | 233 | "test/standing.test.ts", |
| 233 | 234 | "test/board.test.ts", |
| 235 | "test/decider-day.test.ts", | |
| 234 | 236 | "test/spawn-plan.test.ts", |
| 235 | 237 | "test/spawn-anon.test.ts", |
| 236 | 238 | "test/spawn-host.test.ts", |
src/core/decisions.ts +87
| @@ -357,3 +357,90 @@ export function decisionsSince( | ||
| 357 | 357 | .all(kind, since, Math.min(500, Math.max(1, limit))) |
| 358 | 358 | .map(toDecision); |
| 359 | 359 | } |
| 360 | ||
| 361 | /** | |
| 362 | * What the decider did with a person's cards, in three piles: `closed` | |
| 363 | * (a hand-over set aside), `deferred` (a pull the decider held back with a | |
| 364 | * `stop`, until its check passes) and `asked` (a hand-over it left with the | |
| 365 | * owner). A rule's `stop` is the crew's own, not the decider's. | |
| 366 | */ | |
| 367 | export const DECIDER_PILES = ["closed", "deferred", "asked"] as const; | |
| 368 | export type DeciderPile = (typeof DECIDER_PILES)[number]; | |
| 369 | ||
| 370 | const PILE_WHERE: Record<DeciderPile, string> = { | |
| 371 | closed: "kind = 'hand' AND answer = 'set_aside' AND outcome = 'set_aside'", | |
| 372 | deferred: "kind = 'retry' AND answer = 'stop' AND by != 'rule'", | |
| 373 | asked: "kind = 'hand' AND answer = 'ask_owner'", | |
| 374 | }; | |
| 375 | ||
| 376 | export function isDeciderPile(s: unknown): s is DeciderPile { | |
| 377 | return DECIDER_PILES.includes(s as DeciderPile); | |
| 378 | } | |
| 379 | ||
| 380 | const inRepos = (n: number, from: number) => | |
| 381 | Array.from({ length: n }, (_, i) => `?${i + from}`).join(","); | |
| 382 | ||
| 383 | /** Repository ids, once each, in batches small enough for one query — no owner's count is cut short. */ | |
| 384 | function repoBatches(repoIds: readonly string[], size = 400): string[][] { | |
| 385 | const ids = [...new Set(repoIds)]; | |
| 386 | const out: string[][] = []; | |
| 387 | for (let i = 0; i < ids.length; i += size) out.push(ids.slice(i, i + size)); | |
| 388 | return out; | |
| 389 | } | |
| 390 | ||
| 391 | /** How many decisions fall in each pile since a moment, across these repositories. */ | |
| 392 | export function deciderCounts( | |
| 393 | db: Database, | |
| 394 | repoIds: readonly string[], | |
| 395 | since: string, | |
| 396 | ): Record<DeciderPile, number> { | |
| 397 | const out = { closed: 0, deferred: 0, asked: 0 }; | |
| 398 | for (const ids of repoBatches(repoIds)) | |
| 399 | for (const pile of DECIDER_PILES) | |
| 400 | out[pile] += | |
| 401 | db | |
| 402 | .query<{ n: number }, string[]>( | |
| 403 | `SELECT COUNT(*) AS n FROM decision | |
| 404 | WHERE created_at >= ?1 AND (${PILE_WHERE[pile]}) AND repo_id IN (${inRepos(ids.length, 2)})`, | |
| 405 | ) | |
| 406 | .get(since, ...ids)?.n ?? 0; | |
| 407 | return out; | |
| 408 | } | |
| 409 | ||
| 410 | /** The sentence for the counts, in plain words: "closed 4, deferred 2, asked you about 1". */ | |
| 411 | export function deciderWords(n: Record<DeciderPile, number>): string { | |
| 412 | return `closed ${n.closed}, deferred ${n.deferred}, asked you about ${n.asked}`; | |
| 413 | } | |
| 414 | ||
| 415 | /** What an empty pile says, for a person. */ | |
| 416 | export const PILE_EMPTY: Record<DeciderPile, string> = { | |
| 417 | closed: "The decider has closed nothing today.", | |
| 418 | deferred: "The decider has held nothing back today.", | |
| 419 | asked: "The decider has not asked you about anything today.", | |
| 420 | }; | |
| 421 | ||
| 422 | /** The ledger rows behind one pile's count, newest first. */ | |
| 423 | export function listDeciderPile( | |
| 424 | db: Database, | |
| 425 | repoIds: readonly string[], | |
| 426 | pile: DeciderPile, | |
| 427 | since: string, | |
| 428 | limit = 200, | |
| 429 | ): Decision[] { | |
| 430 | const cap = Math.min(500, Math.max(1, limit)); | |
| 431 | const rows: Decision[] = []; | |
| 432 | for (const ids of repoBatches(repoIds)) | |
| 433 | rows.push( | |
| 434 | ...db | |
| 435 | .query<Row, (string | number)[]>( | |
| 436 | `SELECT * FROM decision | |
| 437 | WHERE created_at >= ?1 AND (${PILE_WHERE[pile]}) AND repo_id IN (${inRepos(ids.length, 3)}) | |
| 438 | ORDER BY created_at DESC, id DESC LIMIT ?2`, | |
| 439 | ) | |
| 440 | .all(since, cap, ...ids) | |
| 441 | .map(toDecision), | |
| 442 | ); | |
| 443 | return rows | |
| 444 | .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : a.id < b.id ? 1 : -1)) | |
| 445 | .slice(0, cap); | |
| 446 | } |
src/tui/forge.ts +8
| @@ -30,6 +30,7 @@ import { | ||
| 30 | 30 | } from "./screens/common.ts"; |
| 31 | 31 | import { boardsScreen } from "./screens/boards.ts"; |
| 32 | 32 | import { askedScreen } from "./screens/asked.ts"; |
| 33 | import { deciderScreen } from "./screens/decider.ts"; | |
| 33 | 34 | import { familyScreen } from "./screens/family.ts"; |
| 34 | 35 | import { familyLines, familyView } from "../spawn/family.ts"; |
| 35 | 36 | import { issueScreen } from "./screens/issue.ts"; |
| @@ -179,6 +180,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 179 | 180 | hint: "your thoughts and what became of them", |
| 180 | 181 | run: () => ctx.push(askedScreen(session, ctx)), |
| 181 | 182 | }); |
| 183 | if (session.signedIn) | |
| 184 | commands.push({ | |
| 185 | id: "decider", | |
| 186 | title: "Decider", | |
| 187 | hint: "what it closed, deferred and asked you about today", | |
| 188 | run: () => ctx.push(deciderScreen(session, ctx)), | |
| 189 | }); | |
| 182 | 190 | if (session.signedIn) |
| 183 | 191 | commands.push( |
| 184 | 192 | { |
src/tui/screens/dashboard.ts +17
| @@ -73,6 +73,7 @@ import { | ||
| 73 | 73 | promptScreen, |
| 74 | 74 | plural, |
| 75 | 75 | } from "./common.ts"; |
| 76 | import { deciderLine, deciderScreen } from "./decider.ts"; | |
| 76 | 77 | import { issueScreen } from "./issue.ts"; |
| 77 | 78 | import { newRepoScreen } from "./newrepo.ts"; |
| 78 | 79 | import { pullScreen } from "./pull.ts"; |
| @@ -126,6 +127,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 126 | 127 | let clientLines: string[] = []; |
| 127 | 128 | /** The check lane, as the board's strip says it; null when nothing is queued. */ |
| 128 | 129 | let lane: string | null = null; |
| 130 | /** What the decider did today, the board's Today line; null on a quiet day or for a reader. */ | |
| 131 | let decider: string | null = null; | |
| 129 | 132 | const filter = new Filter(); |
| 130 | 133 | /** One cursor per pane: moving in one must never move another. */ |
| 131 | 134 | const cursors: Record<PaneId, ListState> = { |
| @@ -168,6 +171,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 168 | 171 | ); |
| 169 | 172 | forge = forgeNeeds(f.app, f.user); |
| 170 | 173 | lane = forgeLane(f.app, f.user); |
| 174 | decider = deciderLine(f.app, f.user); | |
| 171 | 175 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
| 172 | 176 | // filter reaches every repository you can see. |
| 173 | 177 | const orgs = f.user |
| @@ -541,6 +545,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 541 | 545 | binding(["d"], "unpin", { |
| 542 | 546 | disabled: focus !== "machine" || pins.length === 0, |
| 543 | 547 | }), |
| 548 | binding(["C"], "decider", { disabled: !f.user }), | |
| 544 | 549 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| 545 | 550 | binding(["A"], "account", { disabled: !f.signedIn }), |
| 546 | 551 | binding(["s"], "sign in", { disabled: f.signedIn }), |
| @@ -574,6 +579,17 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 574 | 579 | ); |
| 575 | 580 | body = rest!; |
| 576 | 581 | } |
| 582 | if (decider !== null) { | |
| 583 | const [strip, rest] = split(body, "v", [len(1), fill(1)]); | |
| 584 | s.text( | |
| 585 | strip!.x, | |
| 586 | strip!.y, | |
| 587 | truncate(`${decider} C lists`, strip!.w), | |
| 588 | style(c.mutedFg, c.bg), | |
| 589 | strip!.x + strip!.w, | |
| 590 | ); | |
| 591 | body = rest!; | |
| 592 | } | |
| 577 | 593 | if (forge !== undefined) { |
| 578 | 594 | const lines = forge.length === 0 ? ["All fine"] : forge.slice(0, 3); |
| 579 | 595 | if (lane !== null) lines.push(`Lane: ${lane}`); |
| @@ -666,6 +682,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 666 | 682 | if (e.key === "u" && f.user) return undoSet(), true; |
| 667 | 683 | if (e.key === "U" && f.user) return undoHand(), true; |
| 668 | 684 | if (e.key === "L" && f.user) return listenAgain(), true; |
| 685 | if (e.key === "C" && f.user) return ctx.push(deciderScreen(f, ctx)), true; | |
| 669 | 686 | if (e.key === "P" && f.user) return showPeople(), true; |
| 670 | 687 | if (f.user && e.key >= "1" && e.key <= String(KIND_CHIPS.length)) |
| 671 | 688 | return toggleChip(Number(e.key) - 1), true; |
src/tui/screens/decider.ts added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | /** | |
| 2 | * What the decider did today, in the terminal: the board's Today line and the | |
| 3 | * rows behind each count, from the same `deciderCounts` and `listDeciderPile` | |
| 4 | * reads. Only repositories this person holds owner facts for are counted — | |
| 5 | * the board's own gate — so a guest or a reader sees nothing. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { returnsRepos } from "../../actions/returns.ts"; | |
| 10 | import { startOfTodayUtc } from "../../crew/autonomy.ts"; | |
| 11 | import { | |
| 12 | DECIDER_PILES, | |
| 13 | PILE_EMPTY, | |
| 14 | deciderCounts, | |
| 15 | deciderWords, | |
| 16 | listDeciderPile, | |
| 17 | type DeciderPile, | |
| 18 | } from "../../core/decisions.ts"; | |
| 19 | import { getIssuesByIds } from "../../core/issues.ts"; | |
| 20 | import { getReposByIds } from "../../core/repos.ts"; | |
| 21 | import type { User } from "../../core/users.ts"; | |
| 22 | import type { App } from "../../app.ts"; | |
| 23 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 24 | import { binding } from "../app/keys.ts"; | |
| 25 | import type { Session } from "../session.ts"; | |
| 26 | import { fill, len, split } from "../ui/layout.ts"; | |
| 27 | import { emptyState } from "../ui/widgets.ts"; | |
| 28 | import { style } from "../term/style.ts"; | |
| 29 | import { truncate } from "../term/width.ts"; | |
| 30 | import { ListState, drawList, hitList, metaOf, openOnWeb, relTime, type ListRow } from "./common.ts"; | |
| 31 | ||
| 32 | const ROWS = 200; | |
| 33 | ||
| 34 | export interface DeciderRow extends ListRow { | |
| 35 | /** The web path `enter` opens. */ | |
| 36 | readonly path: string; | |
| 37 | } | |
| 38 | ||
| 39 | /** The repositories whose decisions this viewer may count: the board's ownerFacts. */ | |
| 40 | const owned = (app: App, viewer: User | undefined): string[] => | |
| 41 | viewer === undefined ? [] : returnsRepos(app, viewer).map((r) => r.id); | |
| 42 | ||
| 43 | /** "The decider closed 4, deferred 2, asked you about 1"; null on a quiet day. */ | |
| 44 | export function deciderLine(app: App, viewer: User | undefined, now = new Date()): string | null { | |
| 45 | const n = deciderCounts(app.db, owned(app, viewer), startOfTodayUtc(now).toISOString()); | |
| 46 | return n.closed + n.deferred + n.asked === 0 ? null : `The decider ${deciderWords(n)}.`; | |
| 47 | } | |
| 48 | ||
| 49 | /** The ledger rows behind one pile's count today, newest first. */ | |
| 50 | export function deciderRows(app: App, viewer: User | undefined, pile: DeciderPile, now = new Date()): DeciderRow[] { | |
| 51 | const db: Database = app.db; | |
| 52 | const ids = owned(app, viewer); | |
| 53 | const decisions = listDeciderPile(db, ids, pile, startOfTodayUtc(now).toISOString(), ROWS); | |
| 54 | const about = getIssuesByIds(db, decisions.flatMap((d) => (d.issueId === null ? [] : [d.issueId]))); | |
| 55 | const repos = getReposByIds(db, [...new Set(decisions.map((d) => d.repoId))]); | |
| 56 | return decisions.flatMap((d) => { | |
| 57 | const repo = repos.get(d.repoId); | |
| 58 | if (repo === undefined) return []; | |
| 59 | const issue = d.issueId === null ? undefined : about.get(d.issueId); | |
| 60 | const base = `/${repo.ownerName}/${repo.name}`; | |
| 61 | return [ | |
| 62 | { | |
| 63 | title: (issue?.title ?? repo.name).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " "), | |
| 64 | secondary: `${repo.ownerName}/${repo.name}${issue ? ` #${issue.number}` : ""} · ${d.by === "rule" ? "by the rule" : `by ${d.by}`}`, | |
| 65 | meta: metaOf(d.outcome ?? "", relTime(d.createdAt)), | |
| 66 | path: issue === undefined ? base : `${base}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`, | |
| 67 | }, | |
| 68 | ]; | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | export function deciderScreen(f: Session, ctx: Ctx, first: DeciderPile = "closed"): Screen { | |
| 73 | const state = new ListState(); | |
| 74 | let pile: DeciderPile = first; | |
| 75 | let rows: DeciderRow[] = []; | |
| 76 | let line: string | null = null; | |
| 77 | const read = (): void => { | |
| 78 | line = deciderLine(f.app, f.user); | |
| 79 | rows = deciderRows(f.app, f.user, pile); | |
| 80 | state.clamp(rows.length); | |
| 81 | }; | |
| 82 | const pick = (next: DeciderPile): void => { | |
| 83 | pile = next; | |
| 84 | state.index = 0; | |
| 85 | read(); | |
| 86 | ctx.invalidate(); | |
| 87 | }; | |
| 88 | const open = (index: number): void => { | |
| 89 | const row = rows[index]; | |
| 90 | if (row !== undefined) openOnWeb(f, ctx, row.path, "That card"); | |
| 91 | }; | |
| 92 | return { | |
| 93 | title: "Decider", | |
| 94 | keys: [ | |
| 95 | binding(["1", "2", "3"], "closed · deferred · asked"), | |
| 96 | binding(["j", "k"], "move"), | |
| 97 | binding(["enter"], "open"), | |
| 98 | binding(["r"], "refresh"), | |
| 99 | ], | |
| 100 | load: async () => read(), | |
| 101 | render(area, s) { | |
| 102 | const c = ctx.theme.c; | |
| 103 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 104 | const sentence = `${line ?? "The decider has done nothing today."} [${pile}]`; | |
| 105 | s.text(head!.x, head!.y, truncate(sentence, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 106 | if (rows.length === 0) { | |
| 107 | emptyState(s, body!, c, { title: PILE_EMPTY[pile], body: "Press 1, 2 or 3 for another pile." }); | |
| 108 | return; | |
| 109 | } | |
| 110 | drawList(s, body!, c, state, rows); | |
| 111 | hitList(ctx, body!, state, rows.length, { open }); | |
| 112 | }, | |
| 113 | key(e) { | |
| 114 | const i = Number(e.key) - 1; | |
| 115 | if (Number.isInteger(i) && i >= 0 && i < DECIDER_PILES.length) return pick(DECIDER_PILES[i]!), true; | |
| 116 | if (state.key(e, rows.length)) return true; | |
| 117 | if (e.key === "enter") return open(state.index), true; | |
| 118 | if (e.key === "r") return read(), ctx.invalidate(), true; | |
| 119 | return false; | |
| 120 | }, | |
| 121 | }; | |
| 122 | } |
src/web/pages/board.ts +55 −2
| @@ -22,7 +22,12 @@ import { tokenReturns } from "../../core/returns.ts"; | ||
| 22 | 22 | import { needLine } from "../../actions/health.ts"; |
| 23 | 23 | import { returnsRepos } from "../../actions/returns.ts"; |
| 24 | 24 | import { |
| 25 | deciderCounts, | |
| 26 | PILE_EMPTY, | |
| 27 | isDeciderPile, | |
| 25 | 28 | listDecisionsForRepos, |
| 29 | listDeciderPile, | |
| 30 | type DeciderPile, | |
| 26 | 31 | type Decision, |
| 27 | 32 | type DecisionKind, |
| 28 | 33 | } from "../../core/decisions.ts"; |
| @@ -848,6 +853,15 @@ function handContextLine(d: Decision): Html { | ||
| 848 | 853 | </p>`; |
| 849 | 854 | } |
| 850 | 855 | |
| 856 | /** The most rows a ledger view lists; past it the view says how many it left out. */ | |
| 857 | const LEDGER_ROWS = 200; | |
| 858 | ||
| 859 | const PILE_LEAD: Record<DeciderPile, string> = { | |
| 860 | closed: "What the decider closed today.", | |
| 861 | deferred: "What the decider held back today.", | |
| 862 | asked: "What the decider asked you about today.", | |
| 863 | }; | |
| 864 | ||
| 851 | 865 | function decided(ctx: Ctx, scope: BoardScope): Html { |
| 852 | 866 | const db = ctx.app.db; |
| 853 | 867 | // The decision ledger is the owner's own record of what the crew chose — |
| @@ -855,7 +869,15 @@ function decided(ctx: Ctx, scope: BoardScope): Html { | ||
| 855 | 869 | const ids = scope.repos |
| 856 | 870 | .filter((r) => scope.ownerFacts.has(r.id)) |
| 857 | 871 | .map((r) => r.id); |
| 858 | const decisions = listDecisionsForRepos(db, ids, 12); | |
| 872 | // `?ledger=closed|deferred|asked` is the rows behind a count on Today's line. | |
| 873 | const asked = ctx.url.searchParams.get("ledger"); | |
| 874 | const pile = isDeciderPile(asked) ? asked : null; | |
| 875 | const since = startOfTodayUtc(new Date()).toISOString(); | |
| 876 | const decisions = | |
| 877 | pile === null | |
| 878 | ? listDecisionsForRepos(db, ids, 12) | |
| 879 | : listDeciderPile(db, ids, pile, since, LEDGER_ROWS); | |
| 880 | const total = pile === null ? 0 : deciderCounts(db, ids, since)[pile]; | |
| 859 | 881 | const about = getIssuesByIds( |
| 860 | 882 | db, |
| 861 | 883 | decisions.map((d) => d.issueId).filter((id): id is string => id !== null), |
| @@ -910,12 +932,42 @@ function decided(ctx: Ctx, scope: BoardScope): Html { | ||
| 910 | 932 | "Decided", |
| 911 | 933 | "run.*,pull.*", |
| 912 | 934 | rows, |
| 913 | "Nothing decided yet.", | |
| 935 | pile === null ? "Nothing decided yet." : PILE_EMPTY[pile], | |
| 936 | pile === null | |
| 937 | ? null | |
| 938 | : html`<p class="muted small board-context"> | |
| 939 | ${PILE_LEAD[pile]}${total > decisions.length | |
| 940 | ? ` Showing the newest ${decisions.length} of ${total}.` | |
| 941 | : ""} | |
| 942 | <a href="${boardHref(ctx, null)}#decided">All decisions</a> | |
| 943 | </p>`, | |
| 914 | 944 | ); |
| 915 | 945 | } |
| 916 | 946 | |
| 917 | 947 | // ---- today ---- |
| 918 | 948 | |
| 949 | /** The board's own address, keeping `?apps=`, with a ledger pile (or none) in `?ledger=`. */ | |
| 950 | function boardHref(ctx: Ctx, pile: DeciderPile | null): string { | |
| 951 | const q = new URLSearchParams(); | |
| 952 | const apps = ctx.url.searchParams.get("apps"); | |
| 953 | if (apps) q.set("apps", apps); | |
| 954 | if (pile !== null) q.set("ledger", pile); | |
| 955 | const s = q.toString(); | |
| 956 | return s === "" ? "/board" : `/board?${s}`; | |
| 957 | } | |
| 958 | ||
| 959 | /** "The decider closed 4, deferred 2, asked you about 1", each count a link to its ledger rows; null on a quiet day. */ | |
| 960 | function deciderLine(ctx: Ctx, scope: BoardScope, since: string): Html | null { | |
| 961 | const n = deciderCounts(ctx.app.db, [...scope.ownerFacts], since); | |
| 962 | if (n.closed + n.deferred + n.asked === 0) return null; | |
| 963 | const link = (pile: DeciderPile) => | |
| 964 | html`<a href="${boardHref(ctx, pile)}#decided">${n[pile]}</a>`; | |
| 965 | return html`<p class="board-context muted small" id="decider-day"> | |
| 966 | The decider closed ${link("closed")}, deferred ${link("deferred")}, asked | |
| 967 | you about ${link("asked")}. | |
| 968 | </p>`; | |
| 969 | } | |
| 970 | ||
| 919 | 971 | function today(ctx: Ctx, scope: BoardScope): Html { |
| 920 | 972 | const db = ctx.app.db; |
| 921 | 973 | const now = new Date(); |
| @@ -1003,6 +1055,7 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 1003 | 1055 | scope.repos.length === 0 |
| 1004 | 1056 | ? "No apps to watch." |
| 1005 | 1057 | : "No app runs on its own yet.", |
| 1058 | deciderLine(ctx, scope, since), | |
| 1006 | 1059 | ); |
| 1007 | 1060 | } |
| 1008 | 1061 |
test/decider-day.test.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * Today's card on the board carries one line — "The decider closed N, | |
| 3 | * deferred N, asked you about N" — each count linking to the ledger rows | |
| 4 | * behind it. A fixture day: a yesterday's row never counts, a rule's stop is | |
| 5 | * not the decider's, and the link shows exactly the counted rows. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { enableApp } from "../src/actions/apps.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { | |
| 16 | deciderCounts, | |
| 17 | recordDecision, | |
| 18 | setOutcome, | |
| 19 | type DecisionKind, | |
| 20 | } from "../src/core/decisions.ts"; | |
| 21 | import type { Repo } from "../src/core/repos.ts"; | |
| 22 | import { createUser, type User } from "../src/core/users.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | ||
| 25 | describe("the decider's day on Today's card", () => { | |
| 26 | let dataDir: string; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let alice: User; | |
| 30 | let repo: Repo; | |
| 31 | let cookie: string; | |
| 32 | let cards = 0; | |
| 33 | ||
| 34 | const get = (path: string) => | |
| 35 | fetch(`${servers.url}${path}`, { headers: { cookie } }).then((r) => r.text()); | |
| 36 | ||
| 37 | function row( | |
| 38 | kind: DecisionKind, | |
| 39 | answer: string, | |
| 40 | by: string, | |
| 41 | outcome?: string, | |
| 42 | ): string { | |
| 43 | const issue = openIssue(app, alice, repo, { title: `Card ${++cards}` }); | |
| 44 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 45 | const d = recordDecision(app.db, { | |
| 46 | kind, | |
| 47 | repoId: repo.id, | |
| 48 | issueId: issue.value.id, | |
| 49 | state: {}, | |
| 50 | answer, | |
| 51 | p: 0.9, | |
| 52 | by, | |
| 53 | ms: 1, | |
| 54 | }); | |
| 55 | if (outcome !== undefined) setOutcome(app.db, d.id, outcome); | |
| 56 | return d.id; | |
| 57 | } | |
| 58 | ||
| 59 | beforeAll(async () => { | |
| 60 | dataDir = await mkdtemp(join(tmpdir(), "forge-decider-day-")); | |
| 61 | app = createApp({ dataDir, port: 0 }); | |
| 62 | servers = await serve(app); | |
| 63 | const a = await createUser(app.db, { | |
| 64 | trusted: true, | |
| 65 | username: "alice", | |
| 66 | email: "[email protected]", | |
| 67 | password: "a-good-passphrase", | |
| 68 | adminIfFirst: true, | |
| 69 | }); | |
| 70 | if (!a.ok) throw new Error(a.error); | |
| 71 | alice = a.value; | |
| 72 | cookie = ( | |
| 73 | ( | |
| 74 | await fetch(`${servers.url}/signin`, { | |
| 75 | method: "POST", | |
| 76 | body: new URLSearchParams({ | |
| 77 | username: "alice", | |
| 78 | password: "a-good-passphrase", | |
| 79 | }), | |
| 80 | redirect: "manual", | |
| 81 | }) | |
| 82 | ).headers.get("set-cookie") ?? "" | |
| 83 | ).split(";")[0]!; | |
| 84 | const made = await createRepo(app, alice, { name: "notes", visibility: "private" }); | |
| 85 | if (!made.ok) throw new Error(String(made.error)); | |
| 86 | repo = made.value; | |
| 87 | const seeded = await commitToBranch(app, alice, repo, { | |
| 88 | message: "Start", | |
| 89 | files: [ | |
| 90 | { | |
| 91 | path: "package.json", | |
| 92 | content: '{"name":"notes","scripts":{"start":"bun run server.ts"}}\n', | |
| 93 | }, | |
| 94 | { | |
| 95 | path: "server.ts", | |
| 96 | content: | |
| 97 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 98 | }, | |
| 99 | ], | |
| 100 | }); | |
| 101 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 102 | const enabled = await enableApp(app, alice, repo, { slug: "notes" }); | |
| 103 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 104 | }, 30_000); | |
| 105 | ||
| 106 | afterAll(async () => { | |
| 107 | servers.stop(); | |
| 108 | app.close(); | |
| 109 | await rm(dataDir, { recursive: true, force: true }); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a quiet day says nothing", async () => { | |
| 113 | expect(await get("/board")).not.toContain('id="decider-day"'); | |
| 114 | }); | |
| 115 | ||
| 116 | test("counts the day's closed, deferred and asked, and links each to its rows", async () => { | |
| 117 | const closed = [1, 2, 3].map(() => row("hand", "set_aside", "typesafe", "set_aside")); | |
| 118 | row("hand", "set_aside", "typesafe", "undone"); // taken back: not closed | |
| 119 | row("retry", "stop", "typesafe"); | |
| 120 | row("retry", "stop", "typesafe"); | |
| 121 | row("retry", "stop", "rule"); // the rule's own stop is not the decider's | |
| 122 | row("retry", "retry", "typesafe"); | |
| 123 | row("hand", "ask_owner", "typesafe", "asked"); | |
| 124 | // Yesterday does not count. | |
| 125 | app.db | |
| 126 | .query("UPDATE decision SET created_at = '2020-01-01T00:00:00.000Z' WHERE id = ?1") | |
| 127 | .run(closed[0]!); | |
| 128 | ||
| 129 | expect(deciderCounts(app.db, [repo.id], "2021-01-01T00:00:00.000Z")).toEqual({ | |
| 130 | closed: 2, | |
| 131 | deferred: 2, | |
| 132 | asked: 1, | |
| 133 | }); | |
| 134 | ||
| 135 | const page = await get("/board"); | |
| 136 | const line = page.slice(page.indexOf('id="decider-day"')); | |
| 137 | const text = line | |
| 138 | .slice(0, line.indexOf("</p>")) | |
| 139 | .replace(/<[^>]+>/g, "") | |
| 140 | .replace(/\s+/g, " "); | |
| 141 | expect(text).toContain("The decider closed 2, deferred 2, asked you about 1."); | |
| 142 | expect(line).toContain('href="/board?ledger=closed#decided"'); | |
| 143 | expect(line).toContain('href="/board?ledger=deferred#decided"'); | |
| 144 | expect(line).toContain('href="/board?ledger=asked#decided"'); | |
| 145 | ||
| 146 | const rowsOf = async (pile: string) => { | |
| 147 | const html = await get(`/board?ledger=${pile}`); | |
| 148 | const decided = html.slice( | |
| 149 | html.indexOf('id="decided"'), | |
| 150 | html.indexOf('id="today"'), | |
| 151 | ); | |
| 152 | return (decided.match(/class="board-card board-decision"/g) ?? []).length; | |
| 153 | }; | |
| 154 | expect(await rowsOf("closed")).toBe(2); | |
| 155 | expect(await rowsOf("deferred")).toBe(2); | |
| 156 | expect(await rowsOf("asked")).toBe(1); | |
| 157 | }); | |
| 158 | ||
| 159 | test("an unknown pile is ignored, and ?apps= is kept in the links", async () => { | |
| 160 | const plain = await get("/board?ledger=nonsense"); | |
| 161 | expect(plain).not.toContain("What the decider nonsense"); | |
| 162 | const scoped = await get("/board?apps=notes"); | |
| 163 | expect(scoped).toContain('href="/board?apps=notes&ledger=closed#decided"'); | |
| 164 | }); | |
| 165 | }); |
test/tui-decider.test.ts added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | /** | |
| 2 | * The decider's day in the terminal: the board's Today line and the rows | |
| 3 | * behind each pile, gated by owner facts — a reader of the repository, or a | |
| 4 | * guest, sees nothing of it. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { openIssue } from "../src/actions/issues.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { deciderCounts, recordDecision, setOutcome, type DecisionKind } from "../src/core/decisions.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { deciderLine, deciderRows, deciderScreen } from "../src/tui/screens/decider.ts"; | |
| 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { settleApp } from "./helpers/tui.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | let dir: string; | |
| 25 | let app: App; | |
| 26 | let alice: User; | |
| 27 | let bob: User; | |
| 28 | let repo: Repo; | |
| 29 | let cards = 0; | |
| 30 | ||
| 31 | const identity = (user?: User) => ({ | |
| 32 | sshUsername: user?.username ?? "git", | |
| 33 | remoteAddress: "127.0.0.1", | |
| 34 | fingerprint: "SHA256:test", | |
| 35 | user, | |
| 36 | }); | |
| 37 | ||
| 38 | function row(kind: DecisionKind, answer: string, by: string, outcome?: string): void { | |
| 39 | const issue = openIssue(app, alice, repo, { title: `Card ${++cards}` }); | |
| 40 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 41 | const d = recordDecision(app.db, { | |
| 42 | kind, repoId: repo.id, issueId: issue.value.id, state: {}, answer, p: 0.9, by, ms: 1, | |
| 43 | }); | |
| 44 | if (outcome !== undefined) setOutcome(app.db, d.id, outcome); | |
| 45 | } | |
| 46 | ||
| 47 | async function screenText(user: User | undefined, keys = ""): Promise<string> { | |
| 48 | const terminal = headlessTerminal(120, 30); | |
| 49 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); | |
| 50 | tui.start(); | |
| 51 | tui.ctx.push(deciderScreen(session, tui.ctx)); | |
| 52 | await settleApp(tui); | |
| 53 | if (keys !== "") { | |
| 54 | terminal.feed(keys); | |
| 55 | await settleApp(tui); | |
| 56 | } | |
| 57 | const text = tui.screen.toText(); | |
| 58 | tui.stop?.(); | |
| 59 | return text; | |
| 60 | } | |
| 61 | ||
| 62 | beforeAll(async () => { | |
| 63 | dir = await mkdtemp(join(tmpdir(), "forge-tui-decider-")); | |
| 64 | app = createApp({ dataDir: dir, port: 0 }); | |
| 65 | const mk = async (username: string, first = false) => { | |
| 66 | const r = await createUser(app.db, { | |
| 67 | trusted: true, username, email: `${username}@forge.local`, | |
| 68 | password: "a-good-passphrase", adminIfFirst: first, | |
| 69 | }); | |
| 70 | if (!r.ok) throw new Error(r.error); | |
| 71 | return r.value; | |
| 72 | }; | |
| 73 | alice = await mk("alice", true); | |
| 74 | bob = await mk("bob"); | |
| 75 | const made = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 76 | if (!made.ok) throw new Error(String(made.error)); | |
| 77 | repo = made.value; | |
| 78 | row("hand", "set_aside", "typesafe", "set_aside"); | |
| 79 | row("hand", "set_aside", "typesafe", "set_aside"); | |
| 80 | row("retry", "stop", "typesafe"); | |
| 81 | row("retry", "stop", "rule"); // the rule's stop is the crew's, not the decider's | |
| 82 | row("hand", "ask_owner", "typesafe", "asked"); | |
| 83 | }); | |
| 84 | afterAll(async () => { | |
| 85 | app.close(); | |
| 86 | await rm(dir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | describe("the decider in the terminal", () => { | |
| 90 | test("the owner gets the board's sentence", () => { | |
| 91 | expect(deciderLine(app, alice)).toBe("The decider closed 2, deferred 1, asked you about 1."); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a reader of the repository and a guest get nothing", () => { | |
| 95 | expect(deciderLine(app, bob)).toBeNull(); | |
| 96 | expect(deciderLine(app, undefined)).toBeNull(); | |
| 97 | expect(deciderRows(app, bob, "closed")).toEqual([]); | |
| 98 | }); | |
| 99 | ||
| 100 | test("each pile lists the rows behind its count, with the card's address", () => { | |
| 101 | expect(deciderRows(app, alice, "closed")).toHaveLength(2); | |
| 102 | expect(deciderRows(app, alice, "deferred")).toHaveLength(1); | |
| 103 | const asked = deciderRows(app, alice, "asked"); | |
| 104 | expect(asked).toHaveLength(1); | |
| 105 | expect(asked[0]!.path).toMatch(/^\/alice\/notes\/issues\/\d+$/); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the screen shows the sentence and switches pile with 1, 2, 3", async () => { | |
| 109 | const closed = await screenText(alice); | |
| 110 | expect(closed).toContain("The decider closed 2, deferred 1, asked you about 1."); | |
| 111 | expect(closed).toContain("Card 1"); | |
| 112 | expect(closed).not.toContain("Card 5"); | |
| 113 | expect(await screenText(alice, "3")).toContain("Card 5"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a reader's screen says the pile is empty in words", async () => { | |
| 117 | expect(await screenText(bob)).toContain("The decider has closed nothing today."); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a large owner is counted whole, past the batch", () => { | |
| 121 | const ids = [...Array.from({ length: 450 }, (_, i) => `x${i}`), repo.id]; | |
| 122 | expect(deciderCounts(app.db, ids, "2000-01-01T00:00:00.000Z").closed).toBe(2); | |
| 123 | }); | |
| 124 | }); |
test/tui-tell.test.ts +1 −1
| @@ -31,7 +31,7 @@ let bob: User; | ||
| 31 | 31 | let repo: Repo; |
| 32 | 32 | |
| 33 | 33 | async function palette(user: User, keys: string[]): Promise<string> { |
| 34 | const terminal = headlessTerminal(120, 30); | |
| 34 | const terminal = headlessTerminal(120, 40); | |
| 35 | 35 | const { app: tui, session } = createForgeApp( |
| 36 | 36 | app, |
| 37 | 37 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, |
new above
offline