From 3c45bb12e3789431416b286e899d229eae2280b3 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Thu, 29 Oct 2026 16:18:30 -0500 Subject: [PATCH 1/3] fix(aggregator): age out done after 7d, collapse Done recently, filter retired Done-table class (#1146) --- pm-stack/aggregator/src/board-policy.ts | 46 ++++++++++++ pm-stack/aggregator/src/render.ts | 11 +++ pm-stack/aggregator/src/sources/live.ts | 15 ++-- .../aggregator/test/board-done-policy.test.ts | 71 +++++++++++++++++++ .../aggregator/test/board-in-flight.test.ts | 2 +- 5 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 pm-stack/aggregator/src/board-policy.ts create mode 100644 pm-stack/aggregator/test/board-done-policy.test.ts diff --git a/pm-stack/aggregator/src/board-policy.ts b/pm-stack/aggregator/src/board-policy.ts new file mode 100644 index 000000000..59b4aea8b --- /dev/null +++ b/pm-stack/aggregator/src/board-policy.ts @@ -0,0 +1,46 @@ +/** + * Board composition policy for finished work (#1146). + * + * The WIP board is a view of work in flight, not a completion log: + * - Done items stay on the board for DONE_BOARD_WINDOW_MS (7 days) + * after their last update, then drop off entirely — GitHub is the + * record of finished work. + * - Kaneo cards in the retired "ROADMAP Done table" bookkeeping class + * (the class Drew killed on 2026-10-26) are filtered out at the + * aggregator; the board provenance states this filter. + * - The renderer collapses the surviving done items behind a + * "Done recently (N)" expander (see renderBoardBody). + * + * Applied in two places so no board source can bypass it: the Kaneo + * source filters while assembling the board (sources/live.ts), and + * renderBoardBody applies it again before rendering. + */ +import type { BoardItem } from "./model.ts"; + +export const DONE_BOARD_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +/** Titles of the retired ROADMAP Done-table bookkeeping class. */ +export function isRetiredDoneTableTitle(title: string): boolean { + return /\broadmap\s+done\s+table\b/i.test(title); +} + +/** + * A done item is recent when its last update is inside the window. + * An item with no parseable updatedAt is kept: its age is unproven, + * and dropping undated work silently would be worse than showing it. + */ +export function doneItemIsRecent(item: BoardItem, now: number): boolean { + if (!item.updatedAt) return true; + const t = Date.parse(item.updatedAt); + if (Number.isNaN(t)) return true; + return now - t <= DONE_BOARD_WINDOW_MS; +} + +/** Drop aged-out done items and the retired Done-table class. */ +export function applyBoardDonePolicy(items: BoardItem[], now = Date.now()): BoardItem[] { + return items.filter((item) => { + if (item.source === "kaneo" && isRetiredDoneTableTitle(item.title)) return false; + if (item.status === "done" && !doneItemIsRecent(item, now)) return false; + return true; + }); +} diff --git a/pm-stack/aggregator/src/render.ts b/pm-stack/aggregator/src/render.ts index cfee5d640..d23b901ae 100644 --- a/pm-stack/aggregator/src/render.ts +++ b/pm-stack/aggregator/src/render.ts @@ -21,6 +21,7 @@ import { windowPercent, } from "./derive.ts"; import { latestInFamily, newClientToken, type DashAction } from "./actions.ts"; +import { applyBoardDonePolicy } from "./board-policy.ts"; import { cardState, pendingVerb, type CardState } from "./card-state.ts"; import { DELEGATION_DEFAULT_OBJECT_REF, @@ -1363,6 +1364,11 @@ export function renderBoardBody( identities?: AuthorIdentity[]; }, ): string { + // #1146: finished work is not WIP — aged-out done items and the + // retired ROADMAP Done-table class never reach the board, whatever + // source assembled it (the Kaneo source filters too; see + // board-policy.ts). Surviving done renders collapsed, below. + board = applyBoardDonePolicy(board); const cols = new Map(); for (const item of board) { const key = item.status === "done" ? "done" : item.status; @@ -1409,6 +1415,11 @@ export function renderBoardBody( .join(""); // The in-flight lane is dated by the orchestration section that // produced it (stamped on every synthetic entry as updatedAt). + if (k === "done") { + // Collapsed by default (#1146): a count + the last 7 days + // behind an expander, never a full column of finished cards. + return `
Done recently (${colItems.length}) — done in the last 7 days, collapsed by default; older done is aged off the board (GitHub is the record)${cards}
`; + } const heading = k === "in-flight" ? `in flight — orchestrator · in flight · section ${fmtDate(colItems[0]?.updatedAt)} (${colItems.length})` diff --git a/pm-stack/aggregator/src/sources/live.ts b/pm-stack/aggregator/src/sources/live.ts index ae9bbd44d..71d71378b 100644 --- a/pm-stack/aggregator/src/sources/live.ts +++ b/pm-stack/aggregator/src/sources/live.ts @@ -6,6 +6,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import type { Config } from "../config.ts"; +import { applyBoardDonePolicy, isRetiredDoneTableTitle } from "../board-policy.ts"; import { isAaSection } from "../aa.ts"; import { isDnsSection } from "../dns.ts"; import { @@ -81,6 +82,7 @@ interface KaneoTask { status: string; priority?: string; createdAt?: string; + updatedAt?: string; externalLinks?: { resourceType: string; externalId: string; @@ -137,10 +139,15 @@ async function kaneoBoard(cfg: Config, cookie: string): Promise { } // The curated in-flight set, not every open issue: in-progress, // in-review, and done (recent) columns only. To-do is the backlog. + // "Done (recent)" is enforced, not just commented (#1146): done + // cards older than 7 days drop off below via applyBoardDonePolicy, + // and the retired ROADMAP Done-table bookkeeping class is filtered + // here at the aggregator (the board provenance states the filter). const wipStatuses = new Set(["in-progress", "in-review", "done"]); for (const col of all) { if (!wipStatuses.has(col.id)) continue; for (const t of col.tasks) { + if (isRetiredDoneTableTitle(t.title)) continue; const links: Link[] = (t.externalLinks ?? []) .filter((l) => l.resourceType === "issue" || l.resourceType === "pull_request") .map((l) => { @@ -156,14 +163,14 @@ async function kaneoBoard(cfg: Config, cookie: string): Promise { source: "kaneo", status: col.id, priority: t.priority, - updatedAt: t.createdAt, + updatedAt: t.updatedAt ?? t.createdAt, kaneoUrl: cfg.kaneo.baseUrl, links, }); } } } - return out; + return applyBoardDonePolicy(out); } // --- Grist -------------------------------------------------------------- @@ -645,11 +652,11 @@ export function liveSource(cfg: Config, opts?: { standingAuto?: () => Promise 0) { - provenance.board = "live (Kaneo)"; + provenance.board = "live (Kaneo; done aged out after 7 days, retired ROADMAP Done-table cards filtered)"; let merged = board; if (cfg.projector.baseUrl) { merged = await enrichWithRegistryLinks(cfg.projector.baseUrl, board, (b) => b.id); - provenance.board = "live (Kaneo + projector links)"; + provenance.board = "live (Kaneo + projector links; done aged out after 7 days, retired ROADMAP Done-table cards filtered)"; } // In-flight lane (see inFlightBoardItems above): synthetic // entries from the fleet feed's orchestration section — the diff --git a/pm-stack/aggregator/test/board-done-policy.test.ts b/pm-stack/aggregator/test/board-done-policy.test.ts new file mode 100644 index 000000000..269a7562f --- /dev/null +++ b/pm-stack/aggregator/test/board-done-policy.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import type { BoardItem } from "../src/model.ts"; +import { renderBoardBody } from "../src/render.ts"; +import { applyBoardDonePolicy, isRetiredDoneTableTitle } from "../src/board-policy.ts"; + +// #1146: the board is a WIP view, not a completion log. Done cards +// older than 7 days drop off entirely, the surviving done renders +// collapsed behind a "Done recently (N)" expander, and Kaneo cards of +// the retired ROADMAP Done-table bookkeeping class are filtered at +// the aggregator. + +const DAY = 24 * 60 * 60 * 1000; +const now = Date.now(); +const iso = (msAgo: number) => new Date(now - msAgo).toISOString(); + +const card = (title: string, status: BoardItem["status"], updatedAt?: string): BoardItem => ({ + id: `kaneo:code/${title}`, + title, + repo: "developing-today/code", + source: "kaneo", + status, + updatedAt, + links: [], +}); + +describe("board done policy (#1146)", () => { + test("isRetiredDoneTableTitle matches the retired bookkeeping class", () => { + expect(isRetiredDoneTableTitle("ROADMAP Done table omits merged PRs")).toBe(true); + expect(isRetiredDoneTableTitle("roadmap done table refresh")).toBe(true); + expect(isRetiredDoneTableTitle("Roadmap planning for Q4")).toBe(false); + expect(isRetiredDoneTableTitle("Done table manners")).toBe(false); + }); + + test("applyBoardDonePolicy drops old done and the Done-table class, keeps the rest", () => { + const board = [ + card("Recent finish", "done", iso(1 * DAY)), + card("Ancient finish", "done", iso(30 * DAY)), + card("ROADMAP Done table omits merged PRs", "done", iso(1 * DAY)), + card("Undated finish", "done"), + card("Live work", "in-progress", iso(30 * DAY)), + card("Under review", "in-review", iso(30 * DAY)), + ]; + const out = applyBoardDonePolicy(board, now).map((i) => i.title); + expect(out).toContain("Recent finish"); + expect(out).toContain("Undated finish"); // age unproven — kept + expect(out).not.toContain("Ancient finish"); + expect(out).not.toContain("ROADMAP Done table omits merged PRs"); + expect(out).toContain("Live work"); // age-out applies to done only + expect(out).toContain("Under review"); + }); + + test("renderBoardBody renders only recent done, inside a collapsed expander", () => { + const board = [ + card("Live work", "in-progress", iso(0)), + card("Recent finish", "done", iso(2 * DAY)), + card("Ancient finish", "done", iso(30 * DAY)), + card("ROADMAP Done table omits merged PRs", "done", iso(1 * DAY)), + ]; + const html = renderBoardBody(board, new Map()); + expect(html).toContain("Recent finish"); + expect(html).not.toContain("Ancient finish"); + expect(html).not.toContain("ROADMAP Done table"); + // Collapsed expander with the surviving count, after the live lanes. + expect(html).toContain("
"); + expect(html).toContain("Done recently (1)"); + expect(html).not.toContain("done (1)"); + expect(html.indexOf("Live work")).toBeLessThan(html.indexOf("Done recently")); + // The recent card lives inside the expander, not a full column. + expect(html.indexOf("Done recently")).toBeLessThan(html.indexOf("Recent finish")); + }); +}); diff --git a/pm-stack/aggregator/test/board-in-flight.test.ts b/pm-stack/aggregator/test/board-in-flight.test.ts index 676515ed6..5da6c97d6 100644 --- a/pm-stack/aggregator/test/board-in-flight.test.ts +++ b/pm-stack/aggregator/test/board-in-flight.test.ts @@ -195,6 +195,6 @@ describe("renderBoard in-flight lane", () => { expect(html).toContain(GENERATED.slice(0, 10)); // the lane header carries the section's date expect(html).toContain('href="https://github.com/developing-today/code/issues/944"'); expect(html).toContain("completion history"); - expect(html.indexOf("in flight — orchestrator")).toBeLessThan(html.indexOf("done (")); + expect(html.indexOf("in flight — orchestrator")).toBeLessThan(html.indexOf("Done recently (")); }); }); From 5cb2fd4a1b8c2581b06a1c5534f3bb08d7ab6ccb Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Thu, 29 Oct 2026 16:53:00 -0500 Subject: [PATCH 2/3] fix(aggregator): fully filtered live board stays live, no fixture fallback (#1146) Codex review on PR #2430 (P2): when Kaneo contained only done cards older than 7 days or retired Done-table cards, kaneoBoard returned an empty array and liveSource.board() fell through its board.length guard to fixtureSource.board(), replacing the real board with illustrative fixture work and hiding the orchestrator in-flight lane. Treat a successfully fetched board as live even when the done policy filters it to empty; fixtures remain only for a failed fetch. Adds a regression test stubbing the Kaneo API with only filterable cards plus an orchestration feed: board() returns exactly the in-flight lane and provenance stays live. --- pm-stack/aggregator/src/sources/live.ts | 51 +++++----- .../aggregator/test/board-done-policy.test.ts | 92 +++++++++++++++++++ 2 files changed, 119 insertions(+), 24 deletions(-) diff --git a/pm-stack/aggregator/src/sources/live.ts b/pm-stack/aggregator/src/sources/live.ts index 71d71378b..6768d79d5 100644 --- a/pm-stack/aggregator/src/sources/live.ts +++ b/pm-stack/aggregator/src/sources/live.ts @@ -651,33 +651,36 @@ export function liveSource(cfg: Config, opts?: { standingAuto?: () => Promise 0) { - provenance.board = "live (Kaneo; done aged out after 7 days, retired ROADMAP Done-table cards filtered)"; - let merged = board; - if (cfg.projector.baseUrl) { - merged = await enrichWithRegistryLinks(cfg.projector.baseUrl, board, (b) => b.id); - provenance.board = "live (Kaneo + projector links; done aged out after 7 days, retired ROADMAP Done-table cards filtered)"; - } - // In-flight lane (see inFlightBoardItems above): synthetic - // entries from the fleet feed's orchestration section — the - // same feed read this source already uses for quota and - // orchestration — merged ahead of the Kaneo cards. The lane - // fails soft: no feed / no section = the Kaneo board alone. - try { - const feed = await getFeed(); - const orchSection = feed?.orchestration ? normalizeOrchestrationSection(feed.orchestration) : null; - if (orchSection) { - const inFlight = inFlightBoardItems(orchSection, merged); - if (inFlight.length) { - provenance.board += ` + orchestrator in-flight (${inFlight.length} lined-up, section ${orchSection.generatedAt})`; - merged = [...inFlight, ...merged]; - } + // A successfully fetched board is live data even when the + // done policy (#1146) filters it to empty: falling through to + // fixtures would replace the real (empty) board with + // illustrative work and hide the orchestrator in-flight lane + // merged below. Fixtures are only for a failed fetch (catch). + provenance.board = "live (Kaneo; done aged out after 7 days, retired ROADMAP Done-table cards filtered)"; + let merged = board; + if (cfg.projector.baseUrl) { + merged = await enrichWithRegistryLinks(cfg.projector.baseUrl, board, (b) => b.id); + provenance.board = "live (Kaneo + projector links; done aged out after 7 days, retired ROADMAP Done-table cards filtered)"; + } + // In-flight lane (see inFlightBoardItems above): synthetic + // entries from the fleet feed's orchestration section — the + // same feed read this source already uses for quota and + // orchestration — merged ahead of the Kaneo cards. The lane + // fails soft: no feed / no section = the Kaneo board alone. + try { + const feed = await getFeed(); + const orchSection = feed?.orchestration ? normalizeOrchestrationSection(feed.orchestration) : null; + if (orchSection) { + const inFlight = inFlightBoardItems(orchSection, merged); + if (inFlight.length) { + provenance.board += ` + orchestrator in-flight (${inFlight.length} lined-up, section ${orchSection.generatedAt})`; + merged = [...inFlight, ...merged]; } - } catch { - /* the Kaneo board still renders without the lane */ } - return merged; + } catch { + /* the Kaneo board still renders without the lane */ } + return merged; } catch (err) { provenance.board = `fixtures (kaneo unreachable: ${err instanceof Error ? err.message : err})`; } diff --git a/pm-stack/aggregator/test/board-done-policy.test.ts b/pm-stack/aggregator/test/board-done-policy.test.ts index 269a7562f..17efd751a 100644 --- a/pm-stack/aggregator/test/board-done-policy.test.ts +++ b/pm-stack/aggregator/test/board-done-policy.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../src/config.ts"; import type { BoardItem } from "../src/model.ts"; +import { liveSource } from "../src/sources/live.ts"; import { renderBoardBody } from "../src/render.ts"; import { applyBoardDonePolicy, isRetiredDoneTableTitle } from "../src/board-policy.ts"; @@ -69,3 +74,90 @@ describe("board done policy (#1146)", () => { expect(html.indexOf("Done recently")).toBeLessThan(html.indexOf("Recent finish")); }); }); + +describe("live board: fully filtered is still live (Codex review, PR #2430)", () => { + test("board() returns the in-flight lane, not fixtures, when every Kaneo card is filtered", async () => { + const dir = await mkdtemp(join(tmpdir(), "pm-done-filter-")); + const feedFile = join(dir, "fleet-feed.json"); + const nowIso = new Date().toISOString(); + await writeFile( + feedFile, + JSON.stringify({ + version: 1, + generatedAt: nowIso, + quota: { pools: [], burn: [], choices: { hasHeadroom: [], burnBeforeReset: [], doNotStart: [] } }, + attention: [], + orchestration: { + version: 1, + generatedAt: nowIso, + runId: "orch-test", + provenance: { sources: ["test"], runId: "orch-test" }, + tiers: { needsDrew: [], needsOrchestrator: [] }, + dependencies: [], + goals: [ + { + goalId: null, + goal: "Keep the four repos healthy", + momentum: "moving", + summary: "", + linedUp: [{ title: "Live orchestrator task", status: "in-progress", horizon: "today", horizonWhy: "test" }], + progressedSinceLastRun: [], + stalled: [], + gaps: [], + }, + ], + actions: [], + }, + }), + ); + const oldDone = new Date(Date.now() - 30 * DAY).toISOString(); + const recent = new Date(Date.now() - 1 * DAY).toISOString(); + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown) => { + const url = String(input); + const json = (body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json", ...headers } }); + if (url.includes("/api/auth/sign-in/email")) return json({}, { "set-cookie": "session=test; Path=/" }); + if (url.includes("/api/workspace")) return json([{ id: "ws1" }]); + if (url.includes("/api/project?")) return json([{ id: "p1", slug: "code", name: "Code" }]); + if (url.includes("/api/task/tasks/")) + return json({ + data: { + columns: [ + { + id: "done", + name: "Done", + tasks: [ + { id: "t-old", number: 1, title: "Ancient finish", status: "done", createdAt: oldDone, updatedAt: oldDone }, + { id: "t-ret", number: 2, title: "ROADMAP Done table omits merged PRs", status: "done", createdAt: recent, updatedAt: recent }, + ], + }, + ], + }, + pagination: { totalPages: 1 }, + }); + return new Response("not found", { status: 404 }); + }) as typeof fetch; + try { + const cfg = loadConfig({ + ...Bun.env, + PM_AGG_SOURCE: "live", + PM_AGG_FEED_FILE: feedFile, + PM_PROJECTOR_URL: "off", + KANEO_URL: "http://kaneo.test", + KANEO_ADMIN_EMAIL: "test@example.com", + KANEO_ADMIN_PASSWORD: "test-password", + }); + const src = liveSource(cfg); + const board = await src.board(); + // Every Kaneo card was filtered (aged-out + retired class), so the + // board is exactly the orchestrator in-flight lane — never the + // fixture board a failed fetch would serve. + expect(board.map((b) => b.title)).toEqual(["Live orchestrator task"]); + expect(board[0]?.source).toBe("orchestrator"); + expect(src.provenance().board).toContain("live (Kaneo"); + } finally { + globalThis.fetch = realFetch; + } + }); +}); From fe2a7153bbe7438ec2504b633f4728d79db60044 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Thu, 29 Oct 2026 16:59:53 -0500 Subject: [PATCH 3/3] fix(aggregator): propagate total Kaneo task-read failure to the fixture fallback (#1146) Codex re-review on PR #2430 (P2): kaneoBoard swallowed per-page failures with break, so discovery-ok + all-task-reads-503 returned an empty array that the previous commit now (correctly, for filtered boards) labels live. Track whether any task page was read; when projects exist but no page succeeded, throw so board() reaches its catch path and serves fixtures with the failure in provenance. Partial reads still serve, and successfully fetched fully-filtered boards stay live. Regression test: stubbed 503 task reads assert fixture provenance naming the failure. --- pm-stack/aggregator/src/sources/live.ts | 8 ++++ .../aggregator/test/board-done-policy.test.ts | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/pm-stack/aggregator/src/sources/live.ts b/pm-stack/aggregator/src/sources/live.ts index 6768d79d5..f8f7aa3cc 100644 --- a/pm-stack/aggregator/src/sources/live.ts +++ b/pm-stack/aggregator/src/sources/live.ts @@ -126,11 +126,18 @@ async function kaneoProjects( async function kaneoBoard(cfg: Config, cookie: string): Promise { const projects = await kaneoProjects(cfg, cookie); const out: BoardItem[] = []; + // A task read that fails on every project is a failed fetch, not a + // successfully empty board (Codex review, PR #2430): board() must + // reach its catch path and serve fixtures with that provenance, + // while a successful read the done policy filters to empty stays + // live. Partial data (at least one page read) is still served. + let anyTaskPageRead = false; for (const p of projects) { const all: KaneoColumn[] = []; for (let page = 1; ; page++) { const r = await getJson(`${cfg.kaneo.baseUrl}/api/task/tasks/${p.id}?page=${page}`, { Cookie: cookie }); if (!r.ok) break; + anyTaskPageRead = true; const body = r.json as { data?: { columns?: KaneoColumn[] }; pagination?: { totalPages: number } }; const cols = body.data?.columns ?? []; all.push(...cols); @@ -170,6 +177,7 @@ async function kaneoBoard(cfg: Config, cookie: string): Promise { } } } + if (projects.length > 0 && !anyTaskPageRead) throw new Error("kaneo task reads failed for every project"); return applyBoardDonePolicy(out); } diff --git a/pm-stack/aggregator/test/board-done-policy.test.ts b/pm-stack/aggregator/test/board-done-policy.test.ts index 17efd751a..08c822366 100644 --- a/pm-stack/aggregator/test/board-done-policy.test.ts +++ b/pm-stack/aggregator/test/board-done-policy.test.ts @@ -160,4 +160,47 @@ describe("live board: fully filtered is still live (Codex review, PR #2430)", () globalThis.fetch = realFetch; } }); + + test("board() serves fixtures when every Kaneo task read fails (503), not a live empty board", async () => { + const dir = await mkdtemp(join(tmpdir(), "pm-done-filter-")); + const feedFile = join(dir, "fleet-feed.json"); + await writeFile( + feedFile, + JSON.stringify({ + version: 1, + generatedAt: new Date().toISOString(), + quota: { pools: [], burn: [], choices: { hasHeadroom: [], burnBeforeReset: [], doNotStart: [] } }, + attention: [], + }), + ); + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown) => { + const url = String(input); + const json = (body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json", ...headers } }); + if (url.includes("/api/auth/sign-in/email")) return json({}, { "set-cookie": "session=test; Path=/" }); + if (url.includes("/api/workspace")) return json([{ id: "ws1" }]); + if (url.includes("/api/project?")) return json([{ id: "p1", slug: "code", name: "Code" }]); + if (url.includes("/api/task/tasks/")) return new Response("unavailable", { status: 503 }); + return new Response("not found", { status: 404 }); + }) as typeof fetch; + try { + const cfg = loadConfig({ + ...Bun.env, + PM_AGG_SOURCE: "live", + PM_AGG_FEED_FILE: feedFile, + PM_PROJECTOR_URL: "off", + KANEO_URL: "http://kaneo.test", + KANEO_ADMIN_EMAIL: "test@example.com", + KANEO_ADMIN_PASSWORD: "test-password", + }); + const src = liveSource(cfg); + const board = await src.board(); + expect(src.provenance().board).toContain("fixtures (kaneo unreachable"); + expect(src.provenance().board).toContain("kaneo task reads failed for every project"); + expect(board.length).toBeGreaterThan(0); // the fixture board, not an empty live board + } finally { + globalThis.fetch = realFetch; + } + }); });