Public: anyone can read this; the crew can change it.
The app page lists an adapter's last ten pull runs #10150
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10127-the-app-page-lists-an-adapter-s-last-ten → main 3 commits fork 2 threads · 2 unresolved
6 files
changed +92
−7
docs/features/adapters-card.md +5 −1
| @@ -10,6 +10,10 @@ when), the **next pull** and the **cursor** of the last good pull. | ||
| 10 | 10 | - **Terminal:** open the repository, press the palette key and choose |
| 11 | 11 | "<owner>/<repo> — Adapter" (`src/tui/screens/adapter.ts`). Same gate, same |
| 12 | 12 | rows. |
| 13 | - Both list the **last ten pull runs** under the facts (`pullRunLines`, | |
| 14 | limit 10): when, ok or failed, written/skipped/failed and, for a failed | |
| 15 | run, its error sentence. With no runs, one muted line says "No pull runs | |
| 16 | yet." under the always-present "Pull runs" heading. | |
| 13 | 17 | - Both read `adapterFactsOf` (`src/apps/adapter_facts.ts`). |
| 14 | 18 | - An app with no adapter shows nothing on the page. Its terminal screen |
| 15 | 19 | says it declares no adapter. |
| @@ -18,4 +22,4 @@ when), the **next pull** and the **cursor** of the last good pull. | ||
| 18 | 22 | |
| 19 | 23 | Cost: the page resolves the default branch's tip and reads the parsed spec |
| 20 | 24 | from a cache keyed by that tip (`declaredByTip`), plus two limit-1 reads of |
| 21 | `pull_run_app` / `pull_run_app_ok`; no git parse on a repeat view. | |
| 25 | `pull_run_app` / `pull_run_app_ok` and one limit-10 read of the runs; no git parse on a repeat view. |
src/apps/adapter_facts.ts +25 −1
| @@ -8,9 +8,11 @@ | ||
| 8 | 8 | * `declaredAdapter` reads git, but only to resolve the default branch's tip; |
| 9 | 9 | * the parsed spec is cached by tip, so a page view costs one ref lookup. |
| 10 | 10 | */ |
| 11 | import type { Database } from "bun:sqlite"; | |
| 11 | 12 | import type { App } from "../app.ts"; |
| 12 | 13 | import type { AppRecord } from "../core/apps.ts"; |
| 13 | 14 | import { lastPullCursor, listPullRuns, type PullRun } from "../core/runs.ts"; |
| 15 | import { pullRunLines, type PullRunLine } from "./pull.ts"; | |
| 14 | 16 | import type { AdapterSpec } from "./adapter.ts"; |
| 15 | 17 | import { declaredAdapter } from "./pull_schedule.ts"; |
| 16 | 18 | |
| @@ -19,6 +21,28 @@ export interface AdapterFacts { | ||
| 19 | 21 | readonly spec: AdapterSpec; |
| 20 | 22 | readonly last: PullRun | undefined; |
| 21 | 23 | readonly cursor: string | undefined; |
| 24 | /** The last `PULL_RUNS_SHOWN` runs, newest first; absent reads as none. */ | |
| 25 | readonly runs?: readonly PullRunLine[]; | |
| 26 | } | |
| 27 | ||
| 28 | /** How many runs the page and the terminal list. */ | |
| 29 | export const PULL_RUNS_SHOWN = 10; | |
| 30 | export const NO_RUNS_WORDS = "No pull runs yet."; | |
| 31 | ||
| 32 | /** The runs both doors list: one indexed read, newest first, at most `PULL_RUNS_SHOWN`. */ | |
| 33 | export function shownRuns(db: Database, appId: string): PullRunLine[] { | |
| 34 | return pullRunLines(db, appId, PULL_RUNS_SHOWN); | |
| 35 | } | |
| 36 | ||
| 37 | /** One run's wording after its time and state: the counts, then the error sentence of a failed run. */ | |
| 38 | export function pullRunWords(r: PullRunLine): string { | |
| 39 | const counts = `${r.written} written, ${r.skipped} skipped, ${r.failed} failed`; | |
| 40 | return r.error === null || r.error === "" ? counts : `${counts} · ${r.error}`; | |
| 41 | } | |
| 42 | ||
| 43 | /** The runs as plain [when, words] rows for the terminal, under the facts. */ | |
| 44 | export function adapterRunRows(facts: AdapterFacts): Array<[string, string]> { | |
| 45 | return (facts.runs ?? []).map((r) => [r.at, `${r.state} ${pullRunWords(r)}`]); | |
| 22 | 46 | } |
| 23 | 47 | |
| 24 | 48 | /** Seconds as a phrase: `15 minutes`, `1 hour`. */ |
| @@ -32,7 +56,7 @@ export function everyWords(seconds: number): string { | ||
| 32 | 56 | export async function adapterFactsOf(app: App, record: AppRecord): Promise<AdapterFacts | undefined> { |
| 33 | 57 | const spec = await declaredAdapter(app, record); |
| 34 | 58 | if (spec === undefined) return undefined; |
| 35 | return { spec, last: listPullRuns(app.db, record.id, 1)[0], cursor: lastPullCursor(app.db, record.id) }; | |
| 59 | return { spec, last: listPullRuns(app.db, record.id, 1)[0], cursor: lastPullCursor(app.db, record.id), runs: shownRuns(app.db, record.id) }; | |
| 36 | 60 | } |
| 37 | 61 | |
| 38 | 62 | /** When the next pull is due, in epoch ms; undefined before the first pull or when the stamp is unreadable. */ |
src/tui/screens/adapter.ts +9 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * nothing and, were the screen reached, sees no rows. Read-only. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { adapterFactsOf, adapterRows, type AdapterFacts } from "../../apps/adapter_facts.ts"; | |
| 9 | import { NO_RUNS_WORDS, adapterFactsOf, adapterRows, adapterRunRows, type AdapterFacts } from "../../apps/adapter_facts.ts"; | |
| 10 | 10 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; |
| 11 | 11 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 12 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -48,7 +48,14 @@ export function adapterScreen( | ||
| 48 | 48 | } |
| 49 | 49 | void facts(record).then( |
| 50 | 50 | (read) => { |
| 51 | rows = read === undefined ? undefined : adapterRows(read); | |
| 51 | rows = | |
| 52 | read === undefined | |
| 53 | ? undefined | |
| 54 | : [ | |
| 55 | ...adapterRows(read), | |
| 56 | ["Pull runs", (read.runs ?? []).length === 0 ? NO_RUNS_WORDS : ""], | |
| 57 | ...adapterRunRows(read), | |
| 58 | ]; | |
| 52 | 59 | loaded = true; |
| 53 | 60 | ctx.invalidate(); |
| 54 | 61 | }, |
src/web/studio/adapter.ts +13 −1
| @@ -7,9 +7,10 @@ | ||
| 7 | 7 | * and goes through `html`, which escapes it. The wording is |
| 8 | 8 | * `adapterFactRows`, shared with the terminal; this module adds only markup. |
| 9 | 9 | */ |
| 10 | import type { PullRunLine } from "../../apps/pull.ts"; | |
| 10 | 11 | import type { App } from "../../app.ts"; |
| 11 | 12 | import type { AppRecord } from "../../core/apps.ts"; |
| 12 | import { adapterFactRows, adapterFactsOf, everyWords, type AdapterFacts, type FactPart } from "../../apps/adapter_facts.ts"; | |
| 13 | import { NO_RUNS_WORDS, adapterFactRows, pullRunWords, adapterFactsOf, everyWords, type AdapterFacts, type FactPart } from "../../apps/adapter_facts.ts"; | |
| 13 | 14 | import { html, type Html } from "../html.ts"; |
| 14 | 15 | import { badge, time } from "../ui.ts"; |
| 15 | 16 | |
| @@ -38,9 +39,20 @@ export function adapterCardView(facts: AdapterFacts, now: number = Date.now()): | ||
| 38 | 39 | ${adapterFactRows(facts, now).map((row) => html`<dt>${row.label}</dt> |
| 39 | 40 | <dd>${row.parts.map(part)}</dd>`)} |
| 40 | 41 | </dl> |
| 42 | ${runsList(facts.runs ?? [])} | |
| 41 | 43 | </section>`; |
| 42 | 44 | } |
| 43 | 45 | |
| 46 | /** Whether the sync is healthy over time: the heading is always there, the runs or one muted line under it. */ | |
| 47 | function runsList(runs: readonly PullRunLine[]): Html { | |
| 48 | return html`<h3>Pull runs</h3> | |
| 49 | ${runs.length === 0 | |
| 50 | ? html`<p class="muted">${NO_RUNS_WORDS}</p>` | |
| 51 | : html`<ul class="pull-runs"> | |
| 52 | ${runs.map((r) => html`<li class="break">${time(r.at)} ${badge(r.state, { variant: "outline" })} ${pullRunWords(r)}</li>`)} | |
| 53 | </ul>`}`; | |
| 54 | } | |
| 55 | ||
| 44 | 56 | /** The section for an app that declares an adapter on its default branch; null for any other. */ |
| 45 | 57 | export async function adapterCard(app: App, record: AppRecord): Promise<Html | null> { |
| 46 | 58 | const facts = await adapterFactsOf(app, record); |
test/adapter-card.test.ts +21
| @@ -46,3 +46,24 @@ test("the card is plain rows under an h2, not a boxed card", () => { | ||
| 46 | 46 | expect(out).toContain("<h2>Adapter</h2>"); |
| 47 | 47 | expect(out).not.toContain("card"); |
| 48 | 48 | }); |
| 49 | ||
| 50 | const line = (over: object) => ({ id: "r", at: "2026-11-09T10:00:00.000Z", durationMs: 5, state: "ok" as const, error: null, written: 3, skipped: 2, failed: 1, cursor: "c", repeat: false, ...over }); | |
| 51 | const bare = { spec, last: undefined, cursor: undefined }; | |
| 52 | ||
| 53 | test("the card lists the runs, a failed one's error escaped, under one always-present heading", () => { | |
| 54 | const out = String(adapterCardView({ ...bare, runs: [line({}), line({ state: "failed", error: "<script>x</script>" })] }, now)); | |
| 55 | expect(out).toContain("<h3>Pull runs</h3>"); | |
| 56 | expect(out.match(/<li /g)?.length).toBe(2); | |
| 57 | expect(out).toContain("3 written, 2 skipped, 1 failed"); | |
| 58 | expect(out).toContain("<script>x"); | |
| 59 | expect(out).not.toContain("<script>"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("with no runs the same heading carries one muted line", () => { | |
| 63 | for (const facts of [bare, { ...bare, runs: [] }]) { | |
| 64 | const out = String(adapterCardView(facts, now)); | |
| 65 | expect(out).toContain("<h3>Pull runs</h3>"); | |
| 66 | expect(out).toContain("No pull runs yet."); | |
| 67 | expect(out).not.toContain("<li"); | |
| 68 | } | |
| 69 | }); |
test/adapter-tui.test.ts +19 −2
| @@ -9,10 +9,11 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createRepo } from "../src/actions/repos.ts"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 12 | import { createApp as createAppRecord, getAppByRepo } from "../src/core/apps.ts"; | |
| 13 | 13 | import type { Repo } from "../src/core/repos.ts"; |
| 14 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | import type { AdapterFacts } from "../src/apps/adapter_facts.ts"; | |
| 15 | import { recordPull } from "../src/apps/pull.ts"; | |
| 16 | import { shownRuns, type AdapterFacts } from "../src/apps/adapter_facts.ts"; | |
| 16 | 17 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 17 | 18 | import { adapterCommands, adapterScreen } from "../src/tui/screens/adapter.ts"; |
| 18 | 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -33,6 +34,9 @@ const facts: AdapterFacts = { | ||
| 33 | 34 | spec: { schedule: { every: 900 }, source: { name: "Shop API", hosts: ["api.shop.example"] } }, |
| 34 | 35 | last: undefined, |
| 35 | 36 | cursor: "c-secret-9", |
| 37 | runs: [ | |
| 38 | { id: "r1", at: "2026-11-09T10:00:00.000Z", durationMs: 5, state: "failed", error: "upstream said no", written: 0, skipped: 0, failed: 0, cursor: null, repeat: false }, | |
| 39 | ], | |
| 36 | 40 | }; |
| 37 | 41 | |
| 38 | 42 | async function mk(username: string, first = false): Promise<User> { |
| @@ -91,6 +95,17 @@ test("the owner gets the palette entry and the rows", async () => { | ||
| 91 | 95 | expect(seen.commands.map((c) => c.id)).toEqual(["adapter"]); |
| 92 | 96 | expect(seen.text).toContain("Shop API"); |
| 93 | 97 | expect(seen.text).toContain("c-secret-9"); |
| 98 | expect(seen.text).toContain("Pull runs"); | |
| 99 | expect(seen.text).toContain("failed 0 written, 0 skipped, 0 failed · upstream said no"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("the runs are the last ten and no more", () => { | |
| 103 | const record = getAppByRepo(app.db, repo.id); | |
| 104 | if (record === undefined) throw new Error("no app"); | |
| 105 | for (let i = 0; i < 12; i++) recordPull(app.db, record.id, { ok: false, reason: `boom ${i}`, durationMs: 1 }); | |
| 106 | const shown = shownRuns(app.db, record.id); | |
| 107 | expect(shown.length).toBe(10); | |
| 108 | expect(shown[0]?.error).toBe("boom 11"); | |
| 94 | 109 | }); |
| 95 | 110 | |
| 96 | 111 | test("a signed-in outsider gets no entry, and the screen reads nothing", async () => { |
| @@ -99,6 +114,7 @@ test("a signed-in outsider gets no entry, and the screen reads nothing", async ( | ||
| 99 | 114 | expect(seen.read).toBe(0); |
| 100 | 115 | expect(seen.text).not.toContain("Shop API"); |
| 101 | 116 | expect(seen.text).not.toContain("c-secret-9"); |
| 117 | expect(seen.text).not.toContain("upstream said no"); | |
| 102 | 118 | }); |
| 103 | 119 | |
| 104 | 120 | test("a guest gets no entry, and the screen reads nothing", async () => { |
| @@ -106,4 +122,5 @@ test("a guest gets no entry, and the screen reads nothing", async () => { | ||
| 106 | 122 | expect(seen.commands).toEqual([]); |
| 107 | 123 | expect(seen.read).toBe(0); |
| 108 | 124 | expect(seen.text).not.toContain("c-secret-9"); |
| 125 | expect(seen.text).not.toContain("upstream said no"); | |
| 109 | 126 | }); |
Principles 1 and 8: heading appears only with data and reads 'Last 1 pull run'. Always render the h3 ('Pull runs'), muted line under it when empty.