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

/fleet: a server-rendered table of what today's selection grammar selects, with the query line in the URL #10071

Merged opened this

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

Merged

scout/forge:scout/565-fleet-a-server-rendered-table-of-what-to → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/565-fleet-a-server-rendered-table-of-what-to was merged into main as cb9439a.

Review

Sign in to review this change.
16 files changed +785 −4
docs/APPS.md +8
@@ -1395,6 +1395,14 @@ The same numbers are `GET /fleet/graph.csv` and `GET /api/v1/fleet/graph`;
13951395 spend is the owner's fact, so a repository you cannot write is dropped
13961396 and counted with the names that do not exist, never told apart.
13971397
1398 Before a fleet operation acts, `GET /fleet?q=<query line>` shows the rows
1399 its query line selects, as a table: `repo autonomy:autonomous`,
1400 `run state:queued`, `issue assignee:none`. The grammar and the rule that
1401 a repository you cannot read is absent are `src/fleet/selection.ts`'s; a
1402 line that does not parse shows why beside it. Headers sort by one column
1403 (`&sort=<column>`, `-<column>` descending), without JavaScript. Past 500
1404 rows the page asks for a narrower query rather than cutting the list.
1405
13981406 The agent runs Claude Code headless in its clone, with a prompt the forge
13991407 composes: who it is, what the task is, how to finish, and what will be
14001408 refused. Permissions are the forge's, passed as settings: file tools
docs/design/audit.md +1
@@ -94,6 +94,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
9494 - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1.
9595 - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen.
9696 - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen.
97 - `/fleet` — `pages/fleet.ts` · the rows a fleet query line selects, as a table · `/fleet/graph` · none seen.
9798
9899 ### Machine and operation (admin)
99100
docs/features/fleet.md added +36
@@ -0,0 +1,36 @@
1 # Fleet rows
2
3 Before a fleet operation acts, see the rows its query line selects. There are two doors, and both show the same rows.
4
5 **On the web:** `GET /fleet?q=<query line>&sort=<column>` (`src/web/pages/fleet.ts`), linked from the fleet graph as "Rows".
6
7 - The page shows a row count and a table.
8 - Click a column header to sort by it, and click again to reverse. This works through `?sort=<column>` and `?sort=-<column>` links, so no JavaScript is needed.
9 - A line that does not parse shows its reason beside the field, with status 200, and selects nothing.
10
11 **In the terminal:** "Fleet rows" in the palette (`src/tui/screens/fleet.ts`).
12
13 - It asks for the query line and shows the same table.
14 - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back.
15 - A line that does not parse keeps the prompt open with its reason.
16
17 **The query line:** a kind, then `field:value` terms that must all hold. A value starting with `!` negates its term.
18
19 | Kind | Fields | Columns |
20 | --- | --- | --- |
21 | `repo` | `owner`, `name`, `repo`, `autonomy`, `visibility` | repository, autonomy, visibility, app |
22 | `run` | `state`, `kind`, `agent`, `issue`, `repo` | started, repository, kind, agent, state, cost |
23 | `issue` | `number`, `state`, `assignee` (`none` for unassigned), `label`, `limb`, `repo` | repository, #, title, state, assignee |
24
25 Every kind also has `id`, and `repo` and `issue` share `owner` and `name`. Some examples:
26
27 - `repo autonomy:autonomous`
28 - `run state:queued repo:!trevato/forge`
29 - `issue repo:engine label:bug assignee:none`
30
31 **Shared code and rules:**
32
33 - The grammar, and the rule that only rows the viewer may read appear, are `src/fleet/selection.ts`'s. A signed-out visitor sees public repositories only.
34 - Both doors read through `viewFleet` in `src/fleet/rows.ts`.
35 - Both use the columns and sort in `src/fleet/columns.ts`.
36 - A selection of more than 500 rows is refused, with a request to narrow the query, rather than cut short. Nothing is paged.
src/fleet/columns.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * The fleet table's columns, one set per kind, and its one-column sort:
3 * shared by the web page (`web/fleet-table.ts`) and the terminal screen
4 * (`tui/screens/fleet.ts`), so both doors show the same rows the same way.
5 */
6
7 import type { FleetRow } from "./rows.ts";
8 import type { FleetKind } from "./selection.ts";
9
10 export interface Column {
11 readonly key: string;
12 readonly label: string;
13 readonly num?: boolean;
14 }
15
16 export const COLUMNS: Record<FleetKind, readonly Column[]> = {
17 repo: [
18 { key: "repo", label: "repository" },
19 { key: "autonomy", label: "autonomy" },
20 { key: "visibility", label: "visibility" },
21 { key: "app", label: "app" },
22 ],
23 run: [
24 { key: "created", label: "started" },
25 { key: "repo", label: "repository" },
26 { key: "kind", label: "kind" },
27 { key: "agent", label: "agent" },
28 { key: "state", label: "state" },
29 { key: "cost", label: "cost", num: true },
30 ],
31 issue: [
32 { key: "repo", label: "repository" },
33 { key: "number", label: "#", num: true },
34 { key: "title", label: "title" },
35 { key: "state", label: "state" },
36 { key: "assignee", label: "assignee" },
37 ],
38 };
39
40 export interface Sort {
41 readonly key: string;
42 readonly descending: boolean;
43 }
44
45 /** `<column>` or `-<column>`; anything else keeps the selection's order. */
46 export function readSort(kind: FleetKind, text: string | null): Sort | undefined {
47 if (text === null) return undefined;
48 const descending = text.startsWith("-");
49 const key = descending ? text.slice(1) : text;
50 return COLUMNS[kind].some((c) => c.key === key) ? { key, descending } : undefined;
51 }
52
53 /** The sort as `?sort=` writes it. */
54 export const sortText = (sort: Sort): string => `${sort.descending ? "-" : ""}${sort.key}`;
55
56 /** The rows in the sort's order; a copy, the input is left alone. Ties keep their order. */
57 export function sortRows(rows: readonly FleetRow[], sort: Sort | undefined): FleetRow[] {
58 const out = [...rows];
59 if (sort === undefined) return out;
60 const sign = sort.descending ? -1 : 1;
61 out.sort((a, b) => {
62 const x = a[sort.key] ?? "";
63 const y = b[sort.key] ?? "";
64 const by =
65 typeof x === "number" && typeof y === "number"
66 ? x - y
67 : String(x).localeCompare(String(y));
68 return sign * by;
69 });
70 return out;
71 }
72
73 /** A cell as text: cost in dollars, everything else as it is. */
74 export function cellText(column: Column, row: FleetRow): string {
75 const value = row[column.key];
76 return column.key === "cost" && typeof value === "number"
77 ? `$${value.toFixed(2)}`
78 : String(value ?? "");
79 }
src/fleet/rows.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * The rows behind ids `selection.ts` selected: ids in, plain records out,
3 * in the order the ids came. Reads nothing the selection did not already
4 * clear for the viewer — it never widens, it only fills in columns.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { User } from "../core/users.ts";
9 import { parseQuery, selectRows, type FleetKind } from "./selection.ts";
10
11 export type Cell = string | number;
12 export type FleetRow = Readonly<Record<string, Cell>>;
13
14 const SQL: Record<FleetKind, string> = {
15 repo: `SELECT repo.id AS id, user.username || '/' || repo.name AS repo,
16 repo.visibility AS visibility, repo.autonomy AS autonomy,
17 COALESCE(app.access, 'none') AS app
18 FROM repo JOIN user ON user.id = repo.owner_id
19 LEFT JOIN app ON app.repo_id = repo.id
20 WHERE repo.id IN (SELECT value FROM json_each(?1))`,
21 run: `SELECT run.id AS id, user.username || '/' || repo.name AS repo,
22 run.kind AS kind, COALESCE(actor.username, '') AS agent,
23 run.state AS state, run.cost_usd AS cost, run.created_at AS created
24 FROM run JOIN repo ON repo.id = run.repo_id
25 JOIN user ON user.id = repo.owner_id
26 LEFT JOIN user AS actor ON actor.id = run.actor_id
27 WHERE run.id IN (SELECT value FROM json_each(?1))`,
28 issue: `SELECT issue.id AS id, user.username || '/' || repo.name AS repo,
29 issue.number AS number, issue.title AS title, issue.state AS state,
30 COALESCE(who.username, '') AS assignee
31 FROM issue JOIN repo ON repo.id = issue.repo_id
32 JOIN user ON user.id = repo.owner_id
33 LEFT JOIN user AS who ON who.id = issue.assignee_id
34 WHERE issue.id IN (SELECT value FROM json_each(?1))`,
35 };
36
37 /** Each id's row, in the ids' order; an id gone since selection is dropped. */
38 export function fleetRows(
39 db: Database,
40 kind: FleetKind,
41 ids: readonly string[],
42 ): FleetRow[] {
43 if (ids.length === 0) return [];
44 const found = new Map(
45 db
46 .query<FleetRow & { id: string }, [string]>(SQL[kind])
47 .all(JSON.stringify(ids))
48 .map((r) => [r.id, r]),
49 );
50 return ids.flatMap((id) => {
51 const row = found.get(id);
52 return row === undefined ? [] : [row];
53 });
54 }
55
56 /** What a query line shows: its kind and rows, or why it shows nothing. */
57 export type FleetView =
58 | { readonly ok: true; readonly kind: FleetKind; readonly rows: FleetRow[] }
59 | { readonly ok: false; readonly error: string };
60
61 /**
62 * A query line read, selected for `viewer` (undefined: signed out) and
63 * filled in — what the web page and the terminal screen both show.
64 */
65 export function viewFleet(
66 db: Database,
67 viewer: User | undefined,
68 line: string,
69 ): FleetView {
70 const parsed = parseQuery(line);
71 if (!parsed.ok) return { ok: false, error: parsed.error.message };
72 const ids = selectRows(db, viewer, parsed.value);
73 if (!ids.ok) return { ok: false, error: ids.error.message };
74 const kind = parsed.value.kind;
75 return { ok: true, kind, rows: fleetRows(db, kind, ids.value) };
76 }
src/fleet/selection.ts +6 −4
@@ -109,7 +109,7 @@ const FIELDS: Record<FleetKind, Record<string, Predicate>> = {
109109 */
110110 function limbIds(
111111 db: Database,
112 viewer: User,
112 viewer: User | undefined,
113113 query: FleetQuery,
114114 term: Term,
115115 ): Result<string[], QueryError> {
@@ -180,16 +180,18 @@ export function parseQuery(text: string): Result<FleetQuery, QueryError> {
180180 }
181181
182182 /**
183 * The ids a query selects for `viewer`, in a stable order. More than
183 * The ids a query selects for `viewer` (undefined: a signed-out visitor),
184 * in a stable order. More than
184185 * `MAX_ROWS` is refused rather than cut: an operation over the first 500
185186 * of 900 rows is not what anyone meant.
186187 */
187188 export function selectRows(
188189 db: Database,
189 viewer: User,
190 viewer: User | undefined,
190191 query: FleetQuery,
191192 ): Result<string[], QueryError> {
192 const params: (string | number)[] = [viewer.id];
193 // Signed out, `?1` is an id no account has: only public repositories hold.
194 const params: (string | number)[] = [viewer?.id ?? ""];
193195 const bind = (value: string): string => {
194196 params.push(value);
195197 return `?${params.length}`;
src/http/server.ts +2
@@ -337,6 +337,7 @@ import {
337337 fleetGraphLive,
338338 fleetGraphPage,
339339 } from "../web/pages/fleet-graph.ts";
340 import { fleetPage } from "../web/pages/fleet.ts";
340341 import { fileHoldUpdate } from "../web/pages/file_holds.ts";
341342 import { newPullPage, newPullSubmit } from "../web/pages/pulls.ts";
342343 import {
@@ -912,6 +913,7 @@ export function buildRouter(): Router<Handler> {
912913 router.add("POST", "/maintenance/resume", maintenanceResume);
913914 router.add("GET", "/resilience/nightly/:job", nightlyOutputPage);
914915 // A selection's spend and runs over time; literal, above /:owner.
916 router.add("GET", "/fleet", fleetPage);
915917 router.add("GET", "/fleet/graph", fleetGraphPage);
916918 router.add("GET", "/fleet/graph/live", fleetGraphLive);
917919 router.add("GET", "/fleet/graph.csv", fleetGraphCsv);
src/tui/forge.ts +2
@@ -14,6 +14,7 @@ import { cleanQuery } from "../core/search.ts";
1414 import { guestCodeSearch } from "../http/strangers.ts";
1515 import { canRead, getRepo, type Repo } from "../core/repos.ts";
1616 import { sourceFor } from "../view/source.ts";
17 import { fleetCommand } from "./screens/fleet.ts";
1718 import { packsCommand } from "./screens/packs.ts";
1819 import { App, type Ctx, type Screen } from "./app/app.ts";
1920 import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts";
@@ -207,6 +208,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
207208 run: () => openOnWeb(session, ctx, "/explore", "The explorer"),
208209 },
209210 packsCommand(session, ctx),
211 fleetCommand(session, ctx),
210212 {
211213 // A person's or an org's page is the web's (#636): the terminal
212214 // hands over its address.
src/tui/screens/fleet.ts added +137
@@ -0,0 +1,137 @@
1 /**
2 * `/fleet` in the terminal: a query line in, the rows it selects as a
3 * table, sorted by one column. The same read as the web page
4 * (`fleet/rows.ts` `viewFleet`), so the grammar and who sees which row are
5 * `fleet/selection.ts`'s, never this screen's; the same columns and sort
6 * (`fleet/columns.ts`). Keys: `/` a new query line, `s` sort by the next
7 * column, `r` reverse it, `j`/`k` scroll, `esc` back.
8 */
9
10 import { cellText, COLUMNS, sortRows, type Sort } from "../../fleet/columns.ts";
11 import { viewFleet, type FleetView } from "../../fleet/rows.ts";
12 import type { FleetKind } from "../../fleet/selection.ts";
13 import type { FleetRow } from "../../fleet/rows.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Session } from "../session.ts";
17 import { style } from "../term/style.ts";
18 import { pad, truncate } from "../term/width.ts";
19 import { promptScreen, Scroll } from "./common.ts";
20
21 /** No column is padded past this, so a long title does not push the rest away. */
22 const CELL_MAX = 40;
23
24 /** The table as text lines: a header naming the sorted column, then one line a row. */
25 export function fleetLines(
26 kind: FleetKind,
27 rows: readonly FleetRow[],
28 sort: Sort | undefined,
29 ): string[] {
30 const columns = COLUMNS[kind];
31 const sorted = sortRows(rows, sort);
32 const label = (key: string, text: string) =>
33 sort?.key === key ? `${text} ${sort.descending ? "v" : "^"}` : text;
34 const widths = columns.map((c) =>
35 Math.min(
36 CELL_MAX,
37 Math.max(label(c.key, c.label).length, ...sorted.map((r) => cellText(c, r).length)),
38 ),
39 );
40 const line = (cells: string[]) =>
41 cells.map((t, i) => pad(truncate(t, widths[i]!), widths[i]!)).join(" ").trimEnd();
42 return [
43 line(columns.map((c) => label(c.key, c.label))),
44 ...sorted.map((r) => line(columns.map((c) => cellText(c, r) || "-"))),
45 ];
46 }
47
48 /** The next column to sort by: the one after the current, ascending; none sorted, the first. */
49 export function nextSort(kind: FleetKind, sort: Sort | undefined): Sort {
50 const keys = COLUMNS[kind].map((c) => c.key);
51 const at = sort === undefined ? -1 : keys.indexOf(sort.key);
52 return { key: keys[(at + 1) % keys.length]!, descending: false };
53 }
54
55 /** The screen, and the prompt that writes its query line. */
56 export function fleetScreen(
57 session: Session,
58 ctx: Ctx,
59 line = "",
60 ): { screen: Screen; ask: () => void } {
61 const scroll = new Scroll();
62 let query = line;
63 let view: FleetView | undefined = line === "" ? undefined : viewFleet(session.db, session.user, line);
64 let sort: Sort | undefined;
65 let lines: string[] = [];
66 const screen: Screen = {
67 title: "Fleet",
68 keys: [
69 binding(["/"], "query"),
70 binding(["s"], "sort"),
71 binding(["r"], "reverse"),
72 binding(["j", "k"], "scroll"),
73 binding(["esc"], "back"),
74 ],
75 render(area, s) {
76 const c = ctx.theme.c;
77 const head =
78 view === undefined
79 ? "/ to write a query line: repo autonomy:autonomous"
80 : view.ok
81 ? `${query} ${view.rows.length} ${view.rows.length === 1 ? "row" : "rows"}`
82 : `${query} ${view.error}`;
83 s.text(area.x + 1, area.y, truncate(head, area.w - 2), style(view?.ok === false ? c.danger : c.mutedFg, c.bg), area.x + area.w);
84 lines = view?.ok ? fleetLines(view.kind, view.rows, sort) : [];
85 const [header, ...body] = lines;
86 if (header !== undefined && area.h > 2)
87 s.text(area.x + 1, area.y + 2, truncate(header, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w);
88 const room = Math.max(0, area.h - 3);
89 const from = scroll.window(room, body.length);
90 for (let i = 0; i < room; i++) {
91 const text = body[from + i];
92 if (text !== undefined)
93 s.text(area.x + 1, area.y + 3 + i, truncate(text, area.w - 2), style(c.fg, c.bg), area.x + area.w);
94 }
95 },
96 key(e) {
97 if (e.ctrl || e.alt) return scroll.key(e, Math.max(0, lines.length - 1));
98 if (e.key === "esc" || e.key === "q") ctx.pop();
99 else if (e.key === "/") ask();
100 else if (e.key === "s" && view?.ok) sort = nextSort(view.kind, sort);
101 else if (e.key === "r" && view?.ok && sort !== undefined) sort = { ...sort, descending: !sort.descending };
102 else return scroll.key(e, Math.max(0, lines.length - 1));
103 return true;
104 },
105 };
106 const ask = () =>
107 ctx.push(
108 promptScreen(ctx, screen, {
109 title: "Fleet query",
110 label: "kind, then field:value terms",
111 placeholder: "repo autonomy:autonomous",
112 value: query,
113 onSubmit: (text) => {
114 if (text === "") return "a query starts with what it selects: repo, run or issue";
115 const next = viewFleet(session.db, session.user, text);
116 if (!next.ok) return next.error;
117 query = text;
118 view = next;
119 sort = undefined;
120 scroll.to(0, 0);
121 return undefined;
122 },
123 }),
124 );
125 return { screen, ask };
126 }
127
128 export const fleetCommand = (session: Session, ctx: Ctx) => ({
129 id: "fleet",
130 title: "Fleet rows",
131 hint: "the rows a fleet query line selects",
132 run: () => {
133 const { screen, ask } = fleetScreen(session, ctx);
134 ctx.push(screen);
135 ask();
136 },
137 });
src/web/fleet-table.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * The fleet table on the web: the rows a query line selected, in the
3 * columns `fleet/columns.ts` names, sortable by one column through `?sort=`
4 * links. Pure: rows in, HTML out — every cell goes through `html`, which
5 * escapes it.
6 */
7
8 import {
9 cellText,
10 COLUMNS,
11 sortRows,
12 sortText,
13 type Column,
14 type Sort,
15 } from "../fleet/columns.ts";
16 import type { FleetRow } from "../fleet/rows.ts";
17 import type { FleetKind } from "../fleet/selection.ts";
18 import { html, type Html } from "./html.ts";
19
20 /** Where a cell leads, if anywhere. */
21 function href(column: Column, row: FleetRow): string | undefined {
22 if (column.key === "repo") return `/${row.repo}`;
23 if (column.key === "number") return `/${row.repo}/issues/${row.number}`;
24 return undefined;
25 }
26
27 function cell(column: Column, row: FleetRow): Html | string {
28 const text = cellText(column, row);
29 if (text === "") return html`<span class="muted">—</span>`;
30 const to = href(column, row);
31 return to === undefined ? text : html`<a href="${to}">${text}</a>`;
32 }
33
34 /**
35 * The table for `rows` of `kind`, its headers linking to `?q=…&sort=…`.
36 * The sorted column's link flips its direction.
37 */
38 export function fleetTable(
39 kind: FleetKind,
40 query: string,
41 rows: readonly FleetRow[],
42 sort: Sort | undefined,
43 ): Html {
44 const columns = COLUMNS[kind];
45 const sorted = sortRows(rows, sort);
46 const head = (c: Column): Html => {
47 const on = sort?.key === c.key;
48 const next = sortText({ key: c.key, descending: on && !sort.descending });
49 const to = `/fleet?${new URLSearchParams({ q: query, sort: next }).toString()}`;
50 return html`<th scope="col"${c.num ? html` class="num"` : ""}${
51 on ? html` aria-sort="${sort.descending ? "descending" : "ascending"}"` : ""
52 }><a href="${to}">${c.label}</a></th>`;
53 };
54 return html`<div class="table-scroll">
55 <table class="table" data-dense>
56 <caption class="sr-only">The ${kind} rows the query selects</caption>
57 <thead><tr>${columns.map(head)}</tr></thead>
58 <tbody>
59 ${sorted.map(
60 (row) =>
61 html`<tr>${columns.map(
62 (c) => html`<td${c.num ? html` class="num"` : ""}>${cell(c, row)}</td>`,
63 )}</tr>`,
64 )}
65 </tbody>
66 </table>
67 </div>`;
68 }
src/web/pages/fleet-graph.ts +5
@@ -65,6 +65,11 @@ export function fleetGraphPage(ctx: Ctx): Response {
6565 size: "sm",
6666 variant: "secondary",
6767 })}
68 ${btn("Rows", {
69 href: "/fleet",
70 size: "sm",
71 variant: "secondary",
72 })}
6873 <p class="muted">
6974 What a selection of repositories spent and how many runs ended,
7075 hour by hour and day by day.
src/web/pages/fleet.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * `GET /fleet?q=<query line>&sort=<column>`: the rows a fleet operation's
3 * query line selects, as a table, so an operator sees them before acting.
4 * The grammar and the visibility rule are `fleet/selection.ts`'s; a line
5 * that does not parse shows its error beside the query line. The terminal
6 * door is `tui/screens/fleet.ts`.
7 */
8
9 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
10 import { readSort } from "../../fleet/columns.ts";
11 import { viewFleet, type FleetView } from "../../fleet/rows.ts";
12 import { MAX_ROWS } from "../../fleet/selection.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { fleetTable } from "../fleet-table.ts";
15 import { html } from "../html.ts";
16 import { page } from "../layout.ts";
17 import { btn, field } from "../ui.ts";
18
19 /** GET /fleet */
20 export function fleetPage(ctx: Ctx): Response {
21 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
22 const q = (ctx.url.searchParams.get("q") ?? "").slice(0, 2000);
23 const view: FleetView | undefined =
24 q.trim() === "" ? undefined : viewFleet(ctx.app.db, ctx.auth.user, q);
25 const rendering = performance.now();
26 const shown = view?.ok ? view : undefined;
27 const sort = shown && readSort(shown.kind, ctx.url.searchParams.get("sort"));
outdated

Casting a fake object through unknown as User bypasses the type check. Make the viewer type in selection.ts explicitly allow a visitor, or pass null.

28 const response = page({
29 ctx,
30 title: "Fleet",
31 description: "The rows a fleet query line selects, before anything acts on them.",
32 wide: true,
33 body: html`<div class="page-head">
34 <h1>Fleet</h1>
35 <p class="muted">
36 The rows a query line selects, at most ${MAX_ROWS}, before anything acts on them.
37 </p>
38 </div>
39 <form method="get" action="/fleet" class="field-row mb">
40 ${field({
41 label: "Query",
42 name: "q",
43 id: "fleet-q",
44 value: q,
45 placeholder: "repo autonomy:autonomous",
46 autocomplete: "off",
47 mono: true,
48 spellcheck: false,
49 ...(view === undefined || view.ok ? {} : { error: view.error }),
50 })}
51 ${btn("Select", { type: "submit", variant: "secondary" })}
52 </form>
53 ${shown === undefined
54 ? null
55 : html`<p class="muted" data-count="${shown.rows.length}">
56 ${shown.rows.length} ${shown.rows.length === 1 ? "row" : "rows"}
57 </p>
58 ${shown.rows.length === 0 ? null : fleetTable(shown.kind, q, shown.rows, sort)}`}`,
59 });
outdated

Principle 1: this paragraph explains the grammar, and the Try paragraph below and the placeholder explain it again. Say what the page is for in one sentence and drop the rest.

60 notePhase(ctx.request, "render", performance.now() - rendering);
61 return response;
62 }
test/fleet-table.test.ts added +168
@@ -0,0 +1,168 @@
1 /**
2 * `GET /fleet`: the rows `fleet/selection.ts` selects, as a table. Render,
3 * a parse error beside the query line, sorting through header links, a
4 * private repository absent for a visitor, escaping, a 1,000-row table
5 * in full, and a page whose SQL does not grow with its rows (counted work,
6 * not a clock: #7824).
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import type { FleetRow } from "../src/fleet/rows.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { readSort, sortRows } from "../src/fleet/columns.ts";
19 import { fleetTable } from "../src/web/fleet-table.ts";
20 import { promote } from "./helpers/members.ts";
21 import { expectWork, requestStatements } from "./helpers/work.ts";
22
23 describe("the fleet table", () => {
24 let dataDir: string;
25 let app: App;
26 let servers: Servers;
27 let alice: User;
28 let aliceCookie: string;
29
30 const person = async (username: string, adminIfFirst = false) => {
31 const made = await createUser(app.db, {
32 origin: "seed",
33 trusted: true,
34 username,
35 allowReserved: true,
36 email: `${username}@forge.local`,
37 password: "a-good-passphrase",
38 adminIfFirst,
39 });
40 if (!made.ok) throw new Error(made.error);
41 promote(app.db, made.value);
42 return made.value;
43 };
44 const repoOf = async (owner: User, name: string, visibility: "public" | "private") => {
45 const made = await createRepo(app, owner, { name, visibility });
46 if (!made.ok) throw new Error(String(made.error));
47 await updateRepoSettings(app, owner, made.value, {
48 autonomy: "assisted",
49 autonomyBy: owner.id,
50 });
51 return made.value;
52 };
53 const get = (path: string, cookie?: string) =>
54 fetch(`${servers.url}${path}`, cookie ? { headers: { cookie } } : {});
55 const rowsOf = (body: string) => Number(body.match(/data-count="(\d+)"/)?.[1] ?? -1);
56
57 beforeAll(async () => {
58 dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-table-"));
59 app = createApp({ dataDir, port: 0 });
60 servers = await serve(app);
61 await person("root", true);
62 alice = await person("alice");
63 await repoOf(alice, "engine", "private");
64 await repoOf(alice, "tools", "public");
65 await repoOf(alice, "atlas", "public");
66 aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`;
67 }, 30_000);
68
69 afterAll(async () => {
70 servers.stop();
71 app.close();
72 await rm(dataDir, { recursive: true, force: true });
73 });
74
75 test("renders the selected rows with a count, and the security headers", async () => {
76 const res = await get(`/fleet?${new URLSearchParams({ q: "repo autonomy:assisted" })}`, aliceCookie);
77 expect(res.status).toBe(200);
78 expect(res.headers.get("content-security-policy")).toBeTruthy();
79 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
80 expect(res.headers.get("referrer-policy")).toBeTruthy();
81 const body = await res.text();
82 expect(rowsOf(body)).toBe(3);
83 for (const name of ["alice/engine", "alice/tools", "alice/atlas"])
84 expect(body).toContain(`href="/${name}"`);
85 expect(body).toContain('<th scope="col"><a href="/fleet?q=repo+autonomy%3Aassisted&amp;sort=autonomy">');
86 });
87
88 test("an empty query line selects nothing and offers an example", async () => {
89 const body = await (await get("/fleet")).text();
90 expect(body).toContain('name="q"');
91 expect(body).not.toContain("data-count=");
92 });
93
94 test("a query that does not parse shows its error beside the line, status 200, nothing selected", async () => {
95 const res = await get(`/fleet?${new URLSearchParams({ q: "repo spent:4" })}`, aliceCookie);
96 expect(res.status).toBe(200);
97 const body = await res.text();
98 expect(body).toContain('aria-invalid="true"');
99 expect(body).toContain("repo has no field &quot;spent&quot;");
100 expect(body).not.toContain("data-count=");
101 expect(body).not.toContain("<table");
102 });
103
104 test("sorts by one column through the header links, and flips on the second click", async () => {
105 const order = async (sort: string) => {
106 const body = await (
107 await get(`/fleet?${new URLSearchParams({ q: "repo owner:alice", sort })}`, aliceCookie)
108 ).text();
109 return [...body.matchAll(/<td><a href="\/(alice\/\w+)">/g)].map((m) => m[1]);
110 };
111 expect(await order("repo")).toEqual(["alice/atlas", "alice/engine", "alice/tools"]);
112 expect(await order("-repo")).toEqual(["alice/tools", "alice/engine", "alice/atlas"]);
113 // private before public; ties keep the selection's own order.
114 expect(await order("visibility")).toEqual(["alice/engine", "alice/atlas", "alice/tools"]);
115 const body = await (
116 await get(`/fleet?${new URLSearchParams({ q: "repo owner:alice", sort: "repo" })}`, aliceCookie)
117 ).text();
118 expect(body).toContain('aria-sort="ascending"><a href="/fleet?q=repo+owner%3Aalice&amp;sort=-repo"');
119 expect(readSort("repo", "nonsense")).toBeUndefined();
120 expect(readSort("repo", "-app")).toEqual({ key: "app", descending: true });
121 });
122
123 test("a private repository is absent for a visitor, present for its owner", async () => {
124 const q = `/fleet?${new URLSearchParams({ q: "repo owner:alice" })}`;
125 const visitor = await (await get(q)).text();
126 expect(rowsOf(visitor)).toBe(2);
127 expect(visitor).not.toContain("alice/engine");
128 const owner = await (await get(q, aliceCookie)).text();
129 expect(rowsOf(owner)).toBe(3);
130 expect(owner).toContain("alice/engine");
131 });
132
133 test("every cell is escaped", () => {
134 const rows: FleetRow[] = [
135 { id: "1", repo: "a/b", number: 1, title: "<script>alert(1)</script>", state: "open", assignee: '"x"' },
136 ];
137 const out = String(fleetTable("issue", "issue <b>", rows, undefined));
138 expect(out).not.toContain("<script>");
139 expect(out).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
140 expect(out).toContain("&quot;x&quot;");
141 expect(out).not.toContain("<b>");
142 });
143
144 test("a 1,000-row selection renders every row, sorted", () => {
145 const rows: FleetRow[] = Array.from({ length: 1000 }, (_, i) => ({
146 id: String(i),
147 created: `2026-11-09T00:00:${String(i % 60).padStart(2, "0")}Z`,
148 repo: `owner${i % 37}/repo${i}`,
149 kind: "agent",
150 agent: `agent${i % 5}`,
151 state: i % 3 === 0 ? "failed" : "ok",
152 cost: (i * 7) % 13,
153 }));
154 const out = String(fleetTable("run", "run", rows, { key: "cost", descending: true }));
155 expect((out.match(/<tr>/g) ?? []).length).toBe(1001);
156 expect(out.indexOf("$12.00")).toBeLessThan(out.indexOf("$0.00"));
157 expect(sortRows(rows, { key: "cost", descending: false })[0]!.cost).toBe(0);
158 });
159
160 test("the page's SQL does not grow with its rows", async () => {
161 const url = (q: string) => `${servers.url}/fleet?${new URLSearchParams({ q })}`;
162 const init = { headers: { cookie: aliceCookie } };
163 await requestStatements(app.db, url("repo name:atlas"), init); // warm
164 const one = await requestStatements(app.db, url("repo name:atlas"), init);
165 const three = await requestStatements(app.db, url("repo owner:alice"), init);
166 expectWork(three, { max: one });
167 });
168 });
test/forge-looks.test.ts +1
@@ -27,6 +27,7 @@ const NOT_A_PAGE: Record<string, string> = {
2727 "/llms.txt": "plain markdown for machines, not a page to picture",
2828 "/agents": "/llms.txt drawn as a page for an assistant; no picture: the cap of 24 looks is spent, and test/agents-page.test.ts covers it",
2929 "/packs": "no picture: the cap of 24 looks is spent; the page is covered by test/packs.test.ts",
30 "/fleet": "no picture: the cap of 24 looks is spent; the page is covered by test/fleet-table.test.ts",
3031 "/robots.txt": "plain text for crawlers",
3132 "/openapi.json": "a redirect to the contract document",
3233 "/favicon.ico": "the tab icon, an image",
test/palette-roster.test.ts +4
@@ -48,6 +48,7 @@ test("a site-admin person is offered the whole roster", () => {
4848 ["home", "Home", "your repositories"],
4949 ["explore", "Explore", "on the web"],
5050 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
51 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
5152 ["profile", "Open a profile", "username or org"],
5253 ["repos", "Repositories", "the repositories you may write"],
5354 ["displays", "Displays", "the displays you may read"],
@@ -161,6 +162,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
161162 ["home", "Home", "your repositories"],
162163 ["explore", "Explore", "on the web"],
163164 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
165 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
164166 ["profile", "Open a profile", "username or org"],
165167 ["repos", "Repositories", "the repositories you may write"],
166168 ["displays", "Displays", "the displays you may read"],
@@ -232,6 +234,7 @@ test("a member person is offered the signed-in roster without the admin entries"
232234 ["home", "Home", "your repositories"],
233235 ["explore", "Explore", "on the web"],
234236 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
237 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
235238 ["profile", "Open a profile", "username or org"],
236239 ["repos", "Repositories", "the repositories you may write"],
237240 ["displays", "Displays", "the displays you may read"],
@@ -299,6 +302,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
299302 ["home", "Home", "your repositories"],
300303 ["explore", "Explore", "on the web"],
301304 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
305 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
302306 ["profile", "Open a profile", "username or org"],
303307 ["repo", "Open a repository", "owner/name"],
304308 ["issues", "Issues in a repository", "owner/name"],
test/tui-fleet.test.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The terminal's fleet rows (#565): the palette's "Fleet rows" asks for a
3 * query line and shows the rows `viewFleet` selects — the web page's read,
4 * so a private repository is absent for a guest and present for its owner;
5 * a line that does not parse keeps the prompt open with its error; `s`
6 * sorts by the next column and `r` reverses it.
7 */
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App as Forge } from "../src/app.ts";
14 import { createUser, getUserByName, type User } from "../src/core/users.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import { fleetLines, nextSort } from "../src/tui/screens/fleet.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { promote } from "./helpers/members.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 quietLogs();
23
24 let dataDir: string;
25 let forge: Forge;
26
27 function boot(user: User | undefined) {
28 const { app } = createForgeApp(
29 forge,
30 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
31 { terminal: headlessTerminal(120, 32), exitHooks: false },
32 );
33 app.start();
34 const key = async (k: string, ctrl = false) => {
35 app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
36 await settleApp(app);
37 };
38 const type = async (text: string) => {
39 for (const ch of text) await key(ch);
40 };
41 return {
42 text: () => app.screen.toText(),
43 key,
44 /** Open "Fleet rows" from the palette and submit `line`. */
45 select: async (line: string) => {
46 await key("k", true);
47 await type("Fleet rows");
48 await key("enter");
49 await type(line);
50 await key("enter");
51 },
52 };
53 }
54
55 beforeAll(async () => {
56 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-fleet-"));
57 forge = createApp({ dataDir, port: 0 });
58 for (const [name, admin] of [["root", true], ["alice", false]] as const) {
59 const made = await createUser(forge.db, {
60 origin: "seed",
61 trusted: true,
62 username: name,
63 allowReserved: true,
64 email: `${name}@forge.local`,
65 password: "a-good-passphrase",
66 adminIfFirst: admin,
67 });
68 if (!made.ok) throw new Error(made.error);
69 promote(forge.db, made.value);
70 }
71 const alice = getUserByName(forge.db, "alice")!;
72 for (const [name, visibility] of [["engine", "private"], ["tools", "public"], ["atlas", "public"]] as const) {
73 const made = await createRepo(forge, alice, { name, visibility });
74 if (!made.ok) throw new Error(String(made.error));
75 }
76 }, 30_000);
77
78 afterAll(async () => {
79 forge.close();
80 await rm(dataDir, { recursive: true, force: true });
81 });
82
83 test("the owner sees all three rows; a guest does not see the private one", async () => {
84 const owner = boot(getUserByName(forge.db, "alice"));
85 await owner.select("repo owner:alice");
86 expect(owner.text()).toContain("3 rows");
87 for (const name of ["alice/engine", "alice/tools", "alice/atlas"]) expect(owner.text()).toContain(name);
88
89 const guest = boot(undefined);
90 await guest.select("repo owner:alice");
91 expect(guest.text()).toContain("2 rows");
92 expect(guest.text()).not.toContain("alice/engine");
93 expect(guest.text()).toContain("alice/tools");
94 });
95
96 test("a line that does not parse keeps the prompt open with its error", async () => {
97 const t = boot(getUserByName(forge.db, "alice"));
98 await t.select("repo spent:4");
99 expect(t.text()).toContain('repo has no field "spent"');
100 expect(t.text()).toContain("Fleet query");
101 });
102
103 test("s sorts by the next column, r reverses it", async () => {
104 const t = boot(getUserByName(forge.db, "alice"));
105 await t.select("repo owner:alice");
106 const order = () =>
107 ["alice/atlas", "alice/engine", "alice/tools"].sort((a, b) => t.text().indexOf(a) - t.text().indexOf(b));
108 await t.key("s");
109 expect(t.text()).toContain("repository ^");
110 expect(order()).toEqual(["alice/atlas", "alice/engine", "alice/tools"]);
111 await t.key("r");
112 expect(t.text()).toContain("repository v");
113 expect(order()).toEqual(["alice/tools", "alice/engine", "alice/atlas"]);
114 });
115
116 test("fleetLines draws a header and one line a row, in the shared columns", () => {
117 const lines = fleetLines(
118 "issue",
119 [
120 { id: "a", repo: "o/r", number: 2, title: "second", state: "open", assignee: "" },
121 { id: "b", repo: "o/r", number: 1, title: "first", state: "open", assignee: "bo" },
122 ],
123 { key: "number", descending: false },
124 );
125 expect(lines[0]).toMatch(/^repository\s+# \^\s+title\s+state\s+assignee$/);
126 expect(lines[1]).toContain("first");
127 expect(lines[2]).toMatch(/second\s+open\s+-$/);
128 expect(nextSort("issue", undefined)).toEqual({ key: "repo", descending: false });
129 expect(nextSort("issue", { key: "assignee", descending: true })).toEqual({ key: "repo", descending: false });
130 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs