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

App page shows an adapter's last pull, next pull and cursor #10068

Merged opened this

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

Merged

mason/forge:mason/10037-app-page-shows-an-adapter-s-last-pull-ne → main 5 commits fork 8 threads · 8 unresolved

  • Merged. mason/10037-app-page-shows-an-adapter-s-last-pull-ne was merged into main as 46f518e.

Review

Sign in to review this change.
10 files changed +425 −3
docs/features/adapters-card.md added +21
@@ -0,0 +1,21 @@
1 # Adapter facts on the app page and in the terminal (#10037)
2
3 An app whose `forge.json` declares an `adapter` shows its **source**, how
4 often it pulls (**every**), its **last pull** (state, written/skipped/failed,
5 when), the **next pull** and the **cursor** of the last good pull.
6
7 - **Web:** the app page (`/<owner>/<repo>/app`) shows an "Adapter" section of
8 plain rows in the side column. Only people who see owner facts
9 (`canSeeOwnerFacts`) get it.
10 - **Terminal:** open the repository, press the palette key and choose
11 "<owner>/<repo> — Adapter" (`src/tui/screens/adapter.ts`). Same gate, same
12 rows.
13 - Both read `adapterFactsOf` (`src/apps/adapter_facts.ts`).
14 - An app with no adapter shows nothing on the page. Its terminal screen
15 says it declares no adapter.
16 - Both doors render the same wording, `adapterFactRows`; the web adds only
outdated

Stray '...' in the sentence; make it two plain sentences.

17 markup.
18
19 Cost: the page resolves the default branch's tip and reads the parsed spec
20 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.
src/apps/adapter_facts.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * What an app's adapter looks like from outside (#10037): its declared spec,
3 * its last pull and the cursor of the last good one. The web page
4 * (`web/studio/adapter.ts`) and the terminal screen (`tui/screens/adapter.ts`)
5 * both read these facts, so the two doors cannot disagree. Owner facts: the
6 * callers gate with `canSeeOwnerFacts`.
7 *
8 * `declaredAdapter` reads git, but only to resolve the default branch's tip;
9 * the parsed spec is cached by tip, so a page view costs one ref lookup.
10 */
11 import type { App } from "../app.ts";
12 import type { AppRecord } from "../core/apps.ts";
13 import { lastPullCursor, listPullRuns, type PullRun } from "../core/runs.ts";
14 import type { AdapterSpec } from "./adapter.ts";
15 import { declaredAdapter } from "./pull_schedule.ts";
16
17 /** The facts: pure data, so a test renders them without a database. */
18 export interface AdapterFacts {
19 readonly spec: AdapterSpec;
20 readonly last: PullRun | undefined;
21 readonly cursor: string | undefined;
22 }
23
24 /** Seconds as a phrase: `15 minutes`, `1 hour`. */
25 export function everyWords(seconds: number): string {
26 const unit = seconds % 3600 === 0 ? ["hour", 3600] : seconds % 60 === 0 ? ["minute", 60] : ["second", 1];
27 const n = seconds / (unit[1] as number);
28 return `${n} ${unit[0]}${n === 1 ? "" : "s"}`;
29 }
30
31 /** The facts for an app that declares an adapter on its default branch; undefined for any other. */
32 export async function adapterFactsOf(app: App, record: AppRecord): Promise<AdapterFacts | undefined> {
33 const spec = await declaredAdapter(app, record);
34 if (spec === undefined) return undefined;
35 return { spec, last: listPullRuns(app.db, record.id, 1)[0], cursor: lastPullCursor(app.db, record.id) };
36 }
37
38 /** When the next pull is due, in epoch ms; undefined before the first pull or when the stamp is unreadable. */
39 export function nextDue(facts: AdapterFacts): number | undefined {
40 if (facts.last === undefined) return undefined;
41 const due = Date.parse(facts.last.createdAt) + facts.spec.schedule.every * 1000;
42 return Number.isFinite(due) ? due : undefined;
43 }
44
45 /**
46 * One piece of a fact's wording. The words live here, once; a door only
47 * chooses how each kind looks: the web turns `badge` into a badge, `time` into
48 * a `<time>`, `muted` into muted text and `code` into `<code>`; the terminal
49 * prints them all as plain text.
50 */
outdated

These last/next pull phrases duplicate src/web/studio/adapter.ts (lastPull, nextPull). Share one source of the wording so the page and terminal cannot disagree.

51 export type FactPart =
52 | { readonly kind: "text" | "muted" | "badge" | "code"; readonly text: string }
53 | { readonly kind: "time"; readonly iso: string };
54
55 export interface FactRow {
56 readonly label: string;
57 readonly parts: readonly FactPart[];
58 }
59
60 const text = (t: string): FactPart => ({ kind: "text", text: t });
61 const muted = (t: string): FactPart => ({ kind: "muted", text: t });
62
63 function lastPullParts(last: AdapterFacts["last"]): FactPart[] {
64 if (last === undefined) return [muted("Not yet")];
65 if (last.state !== "ok")
66 return [{ kind: "badge", text: "failed" }, text(` ${last.reason} · `), { kind: "time", iso: last.createdAt }];
67 return [
68 { kind: "badge", text: "ok" },
69 text(` ${last.written} written, ${last.skipped} skipped, ${last.failed} failed · `),
70 { kind: "time", iso: last.createdAt },
71 ...(last.repeat ? [text(" · nothing new")] : []),
72 ];
73 }
74
75 function nextPullParts(facts: AdapterFacts, now: number): FactPart[] {
76 if (facts.last === undefined) return [muted("On the next sweep, once it is running")];
77 const due = nextDue(facts);
78 if (due === undefined) return [];
79 if (due <= now) return [muted("Due now, once it is running")];
80 return [
81 { kind: "time", iso: new Date(due).toISOString() },
82 text(" "),
83 muted(`(in ${everyWords(Math.ceil((due - now) / 1000))})`),
84 ];
85 }
86
87 /** The facts as labelled rows of wording: the one source both doors render. */
88 export function adapterFactRows(facts: AdapterFacts, now: number = Date.now()): FactRow[] {
89 const { spec, cursor } = facts;
90 return [
91 { label: "Source", parts: [text(spec.source?.name ?? "Not named")] },
92 { label: "Every", parts: [text(everyWords(spec.schedule.every))] },
93 { label: "Last pull", parts: lastPullParts(facts.last) },
94 { label: "Next pull", parts: nextPullParts(facts, now) },
95 { label: "Cursor", parts: [cursor === undefined || cursor === "" ? muted("None yet") : { kind: "code", text: cursor }] },
96 ];
97 }
98
99 /** The facts as plain label/value rows, for the terminal. */
100 export function adapterRows(facts: AdapterFacts, now: number = Date.now()): Array<[string, string]> {
101 return adapterFactRows(facts, now).map((row) => [
102 row.label,
103 row.parts.map((p) => (p.kind === "time" ? p.iso : p.text)).join(""),
104 ]);
105 }
src/apps/pull_schedule.ts +1 −1
@@ -106,7 +106,7 @@ export async function postPull(port: number, waitMs: number = PULL_WAIT_MS): Pro
106106 const declaredByTip = new Map<string, AdapterSpec | undefined>();
107107 const DECLARED_MAX = 256;
108108
109 async function declaredAdapter(app: App, record: AppRecord): Promise<AdapterSpec | undefined> {
109 export async function declaredAdapter(app: App, record: AppRecord): Promise<AdapterSpec | undefined> {
110110 const repo = getRepoById(app.db, record.repoId);
111111 if (repo === undefined) return undefined;
112112 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
src/tui/screens/adapter.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * An app's adapter in the terminal (#10037): the facts the app page shows
3 * (`web/studio/adapter.ts`), read from the same `adapterFactsOf` and
4 * gated the same way — owner facts (`canSeeOwnerFacts`) only. The palette
5 * offers it while a repository with an app is open; anyone else is offered
6 * nothing and, were the screen reached, sees no rows. Read-only.
7 */
8
9 import { adapterFactsOf, adapterRows, type AdapterFacts } from "../../apps/adapter_facts.ts";
10 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
11 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import type { Command } from "../app/palette.ts";
14 import type { Session } from "../session.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import { drawFacts } from "./common.ts";
18
19 const mayRead = (f: Session, repo: Repo): boolean => canSeeOwnerFacts(f.db, repo, f.user);
20
21 /** The palette's "Adapter", while a repository is open, to those who see its owner facts. */
22 export function adapterCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {

The palette gate (guest/non-owner gets no entry, owner does) is untested. Add a test that fails if canSeeOwnerFacts is dropped here.

23 if (repo === undefined || !mayRead(f, repo) || getAppByRepo(f.db, repo.id) === undefined) return [];
24 return [
25 {
26 id: "adapter",
27 title: `${repo.ownerName}/${repo.name} — Adapter`,
28 hint: "source, last pull, next pull and cursor",
29 run: () => ctx.push(adapterScreen(f, ctx, repo)),
30 },
31 ];
32 }
33
34 /** `facts` is the read; a test passes its own so the gate is checked without a git repository. */
35 export function adapterScreen(
36 f: Session,
37 ctx: Ctx,
38 repo: Repo,
39 facts: (record: AppRecord) => Promise<AdapterFacts | undefined> = (record) => adapterFactsOf(f.app, record),
40 ): Screen {
41 let rows: Array<[string, string]> | undefined;
42 let loaded = false;
43 const load = (): void => {
44 const record = mayRead(f, repo) ? getAppByRepo(f.db, repo.id) : undefined;
45 if (record === undefined) {
46 loaded = true;
47 return;
48 }
49 void facts(record).then(
50 (read) => {
51 rows = read === undefined ? undefined : adapterRows(read);
52 loaded = true;
53 ctx.invalidate();
54 },
55 () => {
56 loaded = true;
57 ctx.invalidate();
58 },
59 );
60 };
61 load();
outdated

[area][0]! is a roundabout alias; use area directly.

62 return {
63 title: `${repo.ownerName}/${repo.name} — Adapter`,
64 keys: [],
65 render(area, s) {
66 const c = ctx.theme.c;
67 if (rows === undefined) {
68 const said = loaded ? "This app declares no adapter." : "Reading…";
69 s.text(area.x, area.y, truncate(said, area.w), style(c.mutedFg, c.bg), area.x + area.w);
70 return;
71 }
72 drawFacts(s, area, c, rows);
73 },
74 key: () => false,
75 };
76 }
src/tui/screens/workspace.ts +2
@@ -140,6 +140,7 @@ import { labelsCommands } from "./labels.ts";
140140 import { appEnvCommands } from "./appenv.ts";
141141 import { autonomyCommands } from "./autonomy.ts";
142142 import { trafficCommands } from "./trafficview.ts";
143 import { adapterCommands } from "./adapter.ts";
143144 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
144145 import { finderScreen } from "./finder.ts";
145146 import { issuesScreen } from "./issues.ts";
@@ -1460,6 +1461,7 @@ export function workspaceScreen(
14601461 ...appEnvCommands(f, ctx, repo),
14611462 ...autonomyCommands(f, ctx, repo),
14621463 ...trafficCommands(f, ctx, repo),
1464 ...adapterCommands(f, ctx, repo),
14631465 ],
14641466 get title() {
14651467 return sourceName(src);
src/web/studio/adapter.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * The adapter card on an app's studio page (#10037, docs/ADAPTERS.md): for an
3 * app whose `forge.json` declares an `adapter`, its source, its last pull
4 * (state, counts, when), when the next is due, and the cursor of the last good
5 * pull. The read is two limit-1 walks of `pull_run_app` and `pull_run_app_ok`
6 * (`test/hot-reads-indexed.test.ts`). Every value is the adapter's own text
7 * and goes through `html`, which escapes it. The wording is
8 * `adapterFactRows`, shared with the terminal; this module adds only markup.
9 */
10 import type { App } from "../../app.ts";
11 import type { AppRecord } from "../../core/apps.ts";
12 import { adapterFactRows, adapterFactsOf, everyWords, type AdapterFacts, type FactPart } from "../../apps/adapter_facts.ts";
13 import { html, type Html } from "../html.ts";
14 import { badge, time } from "../ui.ts";
15
16 export { everyWords, type AdapterFacts };
outdated

Both doors: no src/tui screen shows these facts. Add one, with its palette entry, and gate it like the page.

17
18 function part(p: FactPart): Html {
19 switch (p.kind) {
20 case "time":
21 return time(p.iso);
22 case "badge":
23 return badge(p.text, { variant: "outline" });
24 case "muted":
25 return html`<span class="muted">${p.text}</span>`;
26 case "code":
27 return html`<code>${p.text}</code>`;
28 case "text":
29 return html`${p.text}`;
30 }
31 }
32
33 /** The adapter's facts as plain dl rows under an h2 (principles 6 and 7: a fact is a row, not a card). */
34 export function adapterCardView(facts: AdapterFacts, now: number = Date.now()): Html {
35 return html`<section class="adapter">
36 <h2>Adapter</h2>
37 <dl>
38 ${adapterFactRows(facts, now).map((row) => html`<dt>${row.label}</dt>
39 <dd>${row.parts.map(part)}</dd>`)}
40 </dl>
41 </section>`;
42 }
43
44 /** The section for an app that declares an adapter on its default branch; null for any other. */
45 export async function adapterCard(app: App, record: AppRecord): Promise<Html | null> {
46 const facts = await adapterFactsOf(app, record);
47 return facts === undefined ? null : adapterCardView(facts);
48 }
src/web/studio/app.ts +5 −1
@@ -58,6 +58,7 @@ import { answersRegion } from "./answers.ts";
5858 import { nowCard, nowRegion } from "./now.ts";
5959 import { repoAsks } from "./asks.ts";
6060 import { notesCard } from "./notes.ts";
61 import { adapterCard } from "./adapter.ts";
6162 import { appPanels } from "./previews.ts";
6263 import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts";
6364 import { crewKeyNote } from "./crew.ts";
@@ -503,6 +504,9 @@ export async function studioApp(
503504 const policy = await timePhaseAsync(req, "policy", () =>
504505 policyLine(ctx, repo, record),
505506 );
507 const adapter = ownerFacts
508 ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx.app, record))
509 : null;
506510 const rendering = performance.now();
507511 const body = html`${repoHeader(ctx, repo, "app", {
508512 compact: true,
@@ -533,7 +537,7 @@ export async function studioApp(
533537 ${notesCard(ctx, repo, record)}
534538 </div>
535539 <aside class="stack">
536 ${crewCard} ${weekCard} ${ownCard} ${more}
540 ${adapter} ${crewCard} ${weekCard} ${ownCard} ${more}
537541 </aside>
538542 </div>
539543 ${liveMount({ repo: base, app: true })}`;
test/adapter-card.test.ts added +48
@@ -0,0 +1,48 @@
1 import { expect, test } from "bun:test";
2 import { adapterCardView, everyWords } from "../src/web/studio/adapter.ts";
3 import { adapterRows } from "../src/apps/adapter_facts.ts";
4 import type { PullRun } from "../src/core/runs.ts";
5
6 const spec = { schedule: { every: 900 }, source: { name: "Shop <b>API</b>", hosts: ["api.shop.example"] } };
7 const run = (over: Partial<PullRun>): PullRun => ({
8 id: "r", appId: "a", kind: "pull", state: "ok", written: 3, skipped: 2, failed: 1,
9 cursor: "c9", durationMs: 5, reason: "", repeat: false, createdAt: "2026-11-09T10:00:00.000Z", ...over,
10 });
11 const now = Date.parse("2026-11-09T10:05:00.000Z");
12
13 test("the card shows source, last pull, next pull and cursor", () => {
14 const out = String(adapterCardView({ spec, last: run({}), cursor: "c9" }, now));
15 expect(out).toContain("Shop &lt;b&gt;API&lt;/b&gt;");
16 expect(out).toContain("3 written, 2 skipped, 1 failed");
17 expect(out).toContain("in 10 minutes");
18 expect(out).toContain("<code>c9</code>");
19 });
20
21 test("a failed pull shows its reason, escaped, and a hostile cursor stays text", () => {
22 const out = String(adapterCardView({ spec, last: run({ state: "failed", reason: "<script>x</script>" }), cursor: "<img>" }, now));
23 expect(out).not.toContain("<script>");
24 expect(out).not.toContain("<img>");
25 expect(out).toContain("&lt;script&gt;");
26 });
27
28 test("before the first pull the card says so", () => {
29 const out = String(adapterCardView({ spec, last: undefined, cursor: undefined }, now));
30 expect(out).toContain("Not yet");
31 expect(out).toContain("None yet");
32 expect(everyWords(3600)).toBe("1 hour");
33 });
outdated

Move this import to the top of the file.

34
35 test("the terminal rows read the same facts as the card", () => {
36 const rows = Object.fromEntries(adapterRows({ spec, last: run({}), cursor: "c9" }, now));
37 expect(rows["Source"]).toBe("Shop <b>API</b>");
38 expect(rows["Last pull"]).toContain("3 written, 2 skipped, 1 failed");
39 expect(rows["Next pull"]).toContain("in 10 minutes");
40 expect(rows["Cursor"]).toBe("c9");
41 expect(Object.fromEntries(adapterRows({ spec, last: undefined, cursor: undefined }, now))["Last pull"]).toBe("Not yet");
42 });
43
44 test("the card is plain rows under an h2, not a boxed card", () => {
45 const out = String(adapterCardView({ spec, last: undefined, cursor: undefined }, now));
46 expect(out).toContain("<h2>Adapter</h2>");
47 expect(out).not.toContain("card");
48 });
test/adapter-tui.test.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * The adapter's terminal door (#10037) is gated like the page: owner facts
3 * (`canSeeOwnerFacts`) only. A guest or a signed-in outsider gets no palette
4 * entry and, were the screen reached, no rows; the owner gets both.
5 */
6 import { afterAll, beforeAll, 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 { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createApp as createAppRecord } from "../src/core/apps.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import type { AdapterFacts } from "../src/apps/adapter_facts.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { adapterCommands, adapterScreen } from "../src/tui/screens/adapter.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 import { proveAll } from "./helpers/verified.ts";
22 import { promote } from "./helpers/members.ts";
23
24 quietLogs();
25
26 let dir: string;
27 let app: App;
28 let olive: User;
29 let otto: User;
30 let repo: Repo;
31
32 const facts: AdapterFacts = {
33 spec: { schedule: { every: 900 }, source: { name: "Shop API", hosts: ["api.shop.example"] } },
34 last: undefined,
35 cursor: "c-secret-9",
36 };
37
38 async function mk(username: string, first = false): Promise<User> {
39 const r = await createUser(app.db, {
40 origin: "seed",
41 trusted: true,
42 username,
43 email: `${username}@forge.local`,
44 password: "a-good-passphrase",
45 adminIfFirst: first,
46 });
47 if (!r.ok) throw new Error(r.error);
48 return r.value;
49 }
50
51 beforeAll(async () => {
52 dir = await mkdtemp(join(tmpdir(), "forge-adapter-tui-"));
53 app = createApp({ dataDir: dir, port: 0 });
54 await mk("siteadmin", true); // the site admin, so neither below is one
55 olive = await mk("olive");
56 otto = await mk("otto");
57 proveAll(app);
58 promote(app.db, olive, otto);
59 const made = await createRepo(app, olive, { name: "shop", visibility: "public" });
60 if (!made.ok) throw new Error(String(made.error));
61 repo = made.value;
62 const record = createAppRecord(app.db, { repoId: repo.id, slug: "shop", access: "public" });
63 if (!record.ok) throw new Error(String(record.error));
64 });
65
66 afterAll(async () => {
67 app.db.close();
68 await rm(dir, { recursive: true, force: true });
69 });
70
71 async function look(user: User | undefined) {
72 const terminal = headlessTerminal(100, 20);
73 const { app: tui, session } = createForgeApp(
74 app,
75 { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", ...(user ? { user } : {}) },
76 { terminal, exitHooks: false },
77 );
78 tui.start();
79 let read = 0;
80 const commands = adapterCommands(session, tui.ctx, repo);
81 tui.ctx.push(adapterScreen(session, tui.ctx, repo, async () => (read++, facts)));
82 await settleApp(tui);
83 await settleApp(tui);
84 const text = tui.screen.toText();
85 tui.stop?.();
86 return { commands, text, read };
87 }
88
89 test("the owner gets the palette entry and the rows", async () => {
90 const seen = await look(olive);
91 expect(seen.commands.map((c) => c.id)).toEqual(["adapter"]);
92 expect(seen.text).toContain("Shop API");
93 expect(seen.text).toContain("c-secret-9");
94 });
95
96 test("a signed-in outsider gets no entry, and the screen reads nothing", async () => {
97 const seen = await look(otto);
98 expect(seen.commands).toEqual([]);
99 expect(seen.read).toBe(0);
100 expect(seen.text).not.toContain("Shop API");
101 expect(seen.text).not.toContain("c-secret-9");
102 });
103
104 test("a guest gets no entry, and the screen reads nothing", async () => {
105 const seen = await look(undefined);
106 expect(seen.commands).toEqual([]);
107 expect(seen.read).toBe(0);
108 expect(seen.text).not.toContain("c-secret-9");
109 });
test/hot-reads-indexed.test.ts +10 −1
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { listEvents } from "../src/core/events.ts";
8 import { queuedApart } from "../src/core/runs.ts";
8 import { lastPullCursor, listPullRuns, queuedApart } from "../src/core/runs.ts";
99 import { closedByReview, reviewedClosed } from "../src/core/gate_hold.ts";
1010 import { askToLiveByPillar } from "../src/core/pillars.ts";
1111
@@ -82,3 +82,12 @@ test("the queued agent runs weighed against the dispatcher's holds walk the queu
8282 expect(lines.filter((l) => l.includes("USING INDEX run_queue")).length).toBe(2);
8383 noScan(lines.filter((l) => !l.startsWith("SCAN json_each")));
8484 });
85
86 test("an app's last pull and last good cursor walk the pull_run indexes", () => {
87 const last = plans(() => void listPullRuns(app.db, "a", 1));
88 expect(last.join("\n")).toContain("pull_run_app");
89 noScan(last);
90 const cursor = plans(() => void lastPullCursor(app.db, "a"));
91 expect(cursor.join("\n")).toContain("pull_run_app_ok");
92 noScan(cursor);
93 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs