Public: anyone can read this; the crew can change it.

The app page lists an adapter's last ten pull runs #10150

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10127-the-app-page-lists-an-adapter-s-last-ten → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/10127-the-app-page-lists-an-adapter-s-last-ten was merged into main as 90e5417.

Review

Sign in to review this change.
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.
1010 - **Terminal:** open the repository, press the palette key and choose
1111 "<owner>/<repo> — Adapter" (`src/tui/screens/adapter.ts`). Same gate, same
1212 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.
1317 - Both read `adapterFactsOf` (`src/apps/adapter_facts.ts`).
1418 - An app with no adapter shows nothing on the page. Its terminal screen
1519 says it declares no adapter.
@@ -18,4 +22,4 @@ when), the **next pull** and the **cursor** of the last good pull.
1822
1923 Cost: the page resolves the default branch's tip and reads the parsed spec
2024 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 @@
88 * `declaredAdapter` reads git, but only to resolve the default branch's tip;
99 * the parsed spec is cached by tip, so a page view costs one ref lookup.
1010 */
11 import type { Database } from "bun:sqlite";
1112 import type { App } from "../app.ts";
1213 import type { AppRecord } from "../core/apps.ts";
1314 import { lastPullCursor, listPullRuns, type PullRun } from "../core/runs.ts";
15 import { pullRunLines, type PullRunLine } from "./pull.ts";
1416 import type { AdapterSpec } from "./adapter.ts";
1517 import { declaredAdapter } from "./pull_schedule.ts";
1618
@@ -19,6 +21,28 @@ export interface AdapterFacts {
1921 readonly spec: AdapterSpec;
2022 readonly last: PullRun | undefined;
2123 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)}`]);
2246 }
2347
2448 /** Seconds as a phrase: `15 minutes`, `1 hour`. */
@@ -32,7 +56,7 @@ export function everyWords(seconds: number): string {
3256 export async function adapterFactsOf(app: App, record: AppRecord): Promise<AdapterFacts | undefined> {
3357 const spec = await declaredAdapter(app, record);
3458 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) };
3660 }
3761
3862 /** 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 @@
66 * nothing and, were the screen reached, sees no rows. Read-only.
77 */
88
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";
1010 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
1111 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
1212 import type { Ctx, Screen } from "../app/app.ts";
@@ -48,7 +48,14 @@ export function adapterScreen(
4848 }
4949 void facts(record).then(
5050 (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 ];
5259 loaded = true;
5360 ctx.invalidate();
5461 },
src/web/studio/adapter.ts +13 −1
@@ -7,9 +7,10 @@
77 * and goes through `html`, which escapes it. The wording is
88 * `adapterFactRows`, shared with the terminal; this module adds only markup.
99 */
10 import type { PullRunLine } from "../../apps/pull.ts";
1011 import type { App } from "../../app.ts";
1112 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";
1314 import { html, type Html } from "../html.ts";
1415 import { badge, time } from "../ui.ts";
1516
@@ -38,9 +39,20 @@ export function adapterCardView(facts: AdapterFacts, now: number = Date.now()):
3839 ${adapterFactRows(facts, now).map((row) => html`<dt>${row.label}</dt>
3940 <dd>${row.parts.map(part)}</dd>`)}
4041 </dl>
42 ${runsList(facts.runs ?? [])}
4143 </section>`;
4244 }
4345
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>`
outdated

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.

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
4456 /** The section for an app that declares an adapter on its default branch; null for any other. */
4557 export async function adapterCard(app: App, record: AppRecord): Promise<Html | null> {
4658 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", () => {
4646 expect(out).toContain("<h2>Adapter</h2>");
4747 expect(out).not.toContain("card");
4848 });
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("&lt;script&gt;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";
99 import { join } from "node:path";
1010 import { createRepo } from "../src/actions/repos.ts";
1111 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";
1313 import type { Repo } from "../src/core/repos.ts";
1414 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";
1617 import { createForgeApp } from "../src/tui/forge.ts";
1718 import { adapterCommands, adapterScreen } from "../src/tui/screens/adapter.ts";
1819 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -33,6 +34,9 @@ const facts: AdapterFacts = {
3334 spec: { schedule: { every: 900 }, source: { name: "Shop API", hosts: ["api.shop.example"] } },
3435 last: undefined,
3536 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 ],
3640 };
3741
3842 async function mk(username: string, first = false): Promise<User> {
@@ -91,6 +95,17 @@ test("the owner gets the palette entry and the rows", async () => {
9195 expect(seen.commands.map((c) => c.id)).toEqual(["adapter"]);
9296 expect(seen.text).toContain("Shop API");
9397 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");
94109 });
95110
96111 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 (
99114 expect(seen.read).toBe(0);
100115 expect(seen.text).not.toContain("Shop API");
101116 expect(seen.text).not.toContain("c-secret-9");
117 expect(seen.text).not.toContain("upstream said no");
102118 });
103119
104120 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 () => {
106122 expect(seen.commands).toEqual([]);
107123 expect(seen.read).toBe(0);
108124 expect(seen.text).not.toContain("c-secret-9");
125 expect(seen.text).not.toContain("upstream said no");
109126 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs