Public: anyone can read this; the crew can change it.
App page shows an adapter's last pull, next pull and cursor #10068
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10037-app-page-shows-an-adapter-s-last-pull-ne → main 5 commits fork 8 threads · 8 unresolved
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 | |
| 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 | ||
| 106 | 106 | const declaredByTip = new Map<string, AdapterSpec | undefined>(); |
| 107 | 107 | const DECLARED_MAX = 256; |
| 108 | 108 | |
| 109 | async function declaredAdapter(app: App, record: AppRecord): Promise<AdapterSpec | undefined> { | |
| 109 | export async function declaredAdapter(app: App, record: AppRecord): Promise<AdapterSpec | undefined> { | |
| 110 | 110 | const repo = getRepoById(app.db, record.repoId); |
| 111 | 111 | if (repo === undefined) return undefined; |
| 112 | 112 | 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
|
||
| 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"; | ||
| 140 | 140 | import { appEnvCommands } from "./appenv.ts"; |
| 141 | 141 | import { autonomyCommands } from "./autonomy.ts"; |
| 142 | 142 | import { trafficCommands } from "./trafficview.ts"; |
| 143 | import { adapterCommands } from "./adapter.ts"; | |
| 143 | 144 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 144 | 145 | import { finderScreen } from "./finder.ts"; |
| 145 | 146 | import { issuesScreen } from "./issues.ts"; |
| @@ -1460,6 +1461,7 @@ export function workspaceScreen( | ||
| 1460 | 1461 | ...appEnvCommands(f, ctx, repo), |
| 1461 | 1462 | ...autonomyCommands(f, ctx, repo), |
| 1462 | 1463 | ...trafficCommands(f, ctx, repo), |
| 1464 | ...adapterCommands(f, ctx, repo), | |
| 1463 | 1465 | ], |
| 1464 | 1466 | get title() { |
| 1465 | 1467 | 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"; | ||
| 58 | 58 | import { nowCard, nowRegion } from "./now.ts"; |
| 59 | 59 | import { repoAsks } from "./asks.ts"; |
| 60 | 60 | import { notesCard } from "./notes.ts"; |
| 61 | import { adapterCard } from "./adapter.ts"; | |
| 61 | 62 | import { appPanels } from "./previews.ts"; |
| 62 | 63 | import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts"; |
| 63 | 64 | import { crewKeyNote } from "./crew.ts"; |
| @@ -503,6 +504,9 @@ export async function studioApp( | ||
| 503 | 504 | const policy = await timePhaseAsync(req, "policy", () => |
| 504 | 505 | policyLine(ctx, repo, record), |
| 505 | 506 | ); |
| 507 | const adapter = ownerFacts | |
| 508 | ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx.app, record)) | |
| 509 | : null; | |
| 506 | 510 | const rendering = performance.now(); |
| 507 | 511 | const body = html`${repoHeader(ctx, repo, "app", { |
| 508 | 512 | compact: true, |
| @@ -533,7 +537,7 @@ export async function studioApp( | ||
| 533 | 537 | ${notesCard(ctx, repo, record)} |
| 534 | 538 | </div> |
| 535 | 539 | <aside class="stack"> |
| 536 | ${crewCard} ${weekCard} ${ownCard} ${more} | |
| 540 | ${adapter} ${crewCard} ${weekCard} ${ownCard} ${more} | |
| 537 | 541 | </aside> |
| 538 | 542 | </div> |
| 539 | 543 | ${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 <b>API</b>"); | |
| 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("<script>"); | |
| 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"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | 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"; | |
| 9 | 9 | import { closedByReview, reviewedClosed } from "../src/core/gate_hold.ts"; |
| 10 | 10 | import { askToLiveByPillar } from "../src/core/pillars.ts"; |
| 11 | 11 | |
| @@ -82,3 +82,12 @@ test("the queued agent runs weighed against the dispatcher's holds walk the queu | ||
| 82 | 82 | expect(lines.filter((l) => l.includes("USING INDEX run_queue")).length).toBe(2); |
| 83 | 83 | noScan(lines.filter((l) => !l.startsWith("SCAN json_each"))); |
| 84 | 84 | }); |
| 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 | }); |
Stray '...' in the sentence; make it two plain sentences.