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

The forge owner's home names a run stopped twice by restarts in one plain line, with Try again #11640

Merged opened this

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

Merged

scout/forge:scout/11583-the-forge-owner-s-home-names-a-run-stopp → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/11583-the-forge-owner-s-home-names-a-run-stopp was merged into main as 7a6cc45.

Review

Sign in to review this change.
10 files changed +330 −2
docs/features/stopped-twice.md added +8
@@ -0,0 +1,8 @@
1 # Stopped twice
2
3 When the forge restarts under a crew run twice, the run is marked failed with "stopped twice by its forge restarting; not tried again". Home names each such run (last 24 hours, up to five, repositories you may write) in one line with **Try again**; so does the terminal.
4
5 - **Web**: Home, under the held line. Try again posts (same origin, signed in) to `/:owner/:name/runs/:id/requeue`, which queues the run's task again and returns to Home. Writers with the crew ability only; others are refused.
6 - **Terminal**: the palette's "Stopped runs". `j`/`k` move, `Enter` or `r` tries again. The screen reads the database on every draw, so a retry on either door shows on the other.
7 - **Gone when**: a newer agent run of the same issue exists, the run is over a day old, or you cannot write the repository.
8 - Both doors read `stoppedTwiceRuns` (`src/runs/stopped_twice.ts`) and write through `requeueRepoRun`. The forge may refuse a retry (one already waiting, the day's ceiling); it says so.
src/http/server.ts +2
@@ -361,6 +361,7 @@ import {
361361 runCancel,
362362 runPage,
363363 runRedirect,
364 runRequeue,
364365 runSteer,
365366 runsList,
366367 } from "../web/pages/runs.ts";
@@ -1406,6 +1407,7 @@ export function buildRouter(): Router<Handler> {
14061407 router.add("GET", "/:owner/:name/runs", runsList);
14071408 router.add("GET", "/:owner/:name/runs/:id", runPage);
14081409 router.add("POST", "/:owner/:name/runs/:id/cancel", runCancel);
1410 router.add("POST", "/:owner/:name/runs/:id/requeue", runRequeue);
14091411 router.add("POST", "/:owner/:name/runs/:id/steer", runSteer);
14101412 // The public social cards: a repo as owner~name, an app as its slug.
14111413 router.add("GET", "/og/repo/:ref.svg", ogCard("repo"));
src/runs/stopped_twice.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * Runs the forge's restarts stopped twice (#11583), as plain data: the last
3 * 24 hours, newest first, one per issue, none once a newer run of that issue
4 * exists (Try again made one). Home's line and the terminal screen share it.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { Repo } from "../core/repos.ts";
9 import { STOPPED_TWICE } from "../crew/dispatch.ts";
10
11 const WINDOW_MS = 24 * 60 * 60 * 1000;
12 export const MAX_STOPPED = 5;
13
14 export interface StoppedTwice {
15 runId: string;
16 repo: Repo;
17 number: number;
18 title: string;
19 /** The reason, in the words the run carries. */
20 reason: string;
21 }
22
23 interface Row {
24 id: string;
25 repo_id: string;
26 number: number;
27 title: string;
28 }
29
30 /** One bounded query over the repositories given (the ones this person may write). */
31 export function stoppedTwiceRuns(db: Database, writable: readonly Repo[], now = Date.now()): StoppedTwice[] {
32 const byId = new Map<string, Repo>(writable.map((r) => [r.id, r]));
33 if (byId.size === 0) return [];
34 const since = new Date(now - WINDOW_MS).toISOString();
35 const marks = [...byId.keys()].map(() => "?").join(",");
36 return db
37 .query<Row, (string | number)[]>(
38 `SELECT r.id, r.repo_id, i.number, i.title
39 FROM run r JOIN issue i ON i.id = r.issue_id
40 WHERE r.kind = 'agent' AND r.state = 'failed' AND r.summary = ?
41 AND r.finished_at >= ? AND r.repo_id IN (${marks})
42 AND NOT EXISTS (SELECT 1 FROM run n WHERE n.issue_id = r.issue_id AND n.kind = 'agent' AND n.id > r.id)
43 ORDER BY r.finished_at DESC LIMIT ${MAX_STOPPED}`,
44 )
45 .all(STOPPED_TWICE, since, ...byId.keys())
46 .map((row) => ({ runId: row.id, repo: byId.get(row.repo_id)!, number: row.number, title: row.title, reason: STOPPED_TWICE }));
47 }
src/tui/forge.ts +2
@@ -18,6 +18,7 @@ import { sourceFor } from "../view/source.ts";
1818 import { fleetCommand } from "./screens/fleet.ts";
1919 import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
21 import { stoppedTwiceCommand } from "./screens/stopped_twice.ts";
2122 import { getForgeCommand } from "./screens/getforge.ts";
2223 import { startForgeCommand } from "./screens/startforge.ts";
2324 import { pricingCommand } from "./screens/pricing.ts";
@@ -248,6 +249,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
248249 run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"),
249250 },
250251 packsCommand(session, ctx),
252 stoppedTwiceCommand(session, ctx),
251253 getForgeCommand(session, ctx),
252254 ...startForgeCommand(session, ctx),
253255 pricingCommand(session, ctx),
src/tui/screens/stopped_twice.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * Runs the forge's restarts stopped twice, in the terminal (#11583): the same
3 * lines Home prints on the web, from `stoppedTwiceRuns`, read on every draw so
4 * a Try again made on the page shows here at once. Enter (or r) queues the run
5 * again through `requeueRepoRun`, the web route's own door, which announces the
6 * new run so web viewers see it live. Gated like the page: only repositories
7 * this person may write are listed.
8 */
9
10 import { requeueRepoRun } from "../../actions/runs.ts";
11 import { listReposWritableTo } from "../../core/repos.ts";
12 import { words } from "../../fleet/verbs.ts";
13 import { stoppedTwiceRuns } from "../../runs/stopped_twice.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 { truncate } from "../term/width.ts";
19
20 export function stoppedTwiceScreen(session: Session, ctx: Ctx): Screen {
21 let at = 0;
22 const rows = () => stoppedTwiceRuns(session.app.db, listReposWritableTo(session.app.db, session.user, { withoutArchived: true, limit: 200 }));
23 return {
24 title: "Stopped runs",
25 keys: [binding(["j", "k"], "move"), binding(["enter", "r"], "try again"), binding(["esc"], "back")],
26 render(area, s) {
27 const c = ctx.theme.c;
28 const list = rows();
29 at = Math.min(at, Math.max(0, list.length - 1));
30 if (list.length === 0) s.text(area.x + 1, area.y, "No run was stopped twice by restarts in the last day.", style(c.mutedFg, c.bg), area.x + area.w);
31 list.forEach((r, i) => {
32 if (i >= area.h) return;
33 const text = `${i === at ? "> " : " "}${r.repo.name}#${r.number} ${r.title}: ${r.reason}`;
34 s.text(area.x + 1, area.y + i, truncate(text, area.w - 2), i === at ? style(c.accent, c.bg) : style(c.fg, c.bg), area.x + area.w);
35 });
36 },
37 key(e) {
38 if (e.ctrl || e.alt) return false;
39 const list = rows();
40 if (e.key === "esc" || e.key === "q") ctx.pop();
41 else if (e.key === "j" || e.key === "down") at = Math.min(Math.max(0, list.length - 1), at + 1);
42 else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1);
43 else if (e.key === "enter" || e.key === "r") {
44 const row = list[at];
45 if (row === undefined) return true;
46 const done = requeueRepoRun(session.app, session.user, row.repo, row.runId);
47 if (done.ok) ctx.toast(`Queued ${row.repo.name}#${row.number} again.`);
48 else ctx.toast(`Could not try again: ${words(done.error)}.`, "danger");
49 ctx.invalidate();
50 } else return false;
51 return true;
52 },
53 };
54 }
55
56 export const stoppedTwiceCommand = (session: Session, ctx: Ctx) => ({
57 id: "stopped-twice",
58 title: "Stopped runs",
59 hint: "runs restarts stopped twice, with Try again",
60 run: () => ctx.push(stoppedTwiceScreen(session, ctx)),
61 });
src/web/pages/runs.ts +28
@@ -18,6 +18,7 @@ import type { Database } from "bun:sqlite";
1818 import {
1919 cancelRepoRun,
2020 listRepoRuns,
21 requeueRepoRun,
2122 runLog,
2223 steerRepoRun,
2324 } from "../../actions/runs.ts";
@@ -409,3 +410,30 @@ export async function runCancel(ctx: Ctx): Promise<Response> {
409410 }
410411 return flashTo(ctx, dest, { message: "Run cancelled." });
411412 }
413

Minor: runRequeue is imported out of order in server.ts and runs.ts; keep the existing ordering. Also the success flash redirects to / even when Try again is posted from elsewhere; fine for now but keep it deliberate.

414 const REQUEUE_REFUSED: Record<string, string> = {
415 not_finished: "That run has not finished.",
416 not_agent: "Only a crew run can be tried again.",
417 no_task: "That run's task is gone.",
418 not_queued: "The forge would not queue it now: one is already waiting, or today's ceiling is reached.",
419 archived: "That app is archived.",
420 };
421
422 // POST /:owner/:name/runs/:id/requeue — "Try again": the run's task goes back on the queue.
423 // Only Home posts it, so it answers on Home (/) on purpose.
424 export async function runRequeue(ctx: Ctx): Promise<Response> {
425 const repo = loadRepo(ctx);
426 if (!repo) return notFoundPage(ctx);
427 if (!ctx.auth.user) return signInFirst(ctx);
428 const form = await readForm(ctx, "rw");
429 if (form instanceof Response) return form;
430 const queued = requeueRepoRun(ctx.app, ctx.auth.user, repo, ctx.params.id ?? "");
431 if (!queued.ok) {
432 if (queued.error === "not_found") return notFoundPage(ctx);
433 return flashTo(ctx, "/", {
434 tone: "danger",
435 message: REQUEUE_REFUSED[queued.error] ?? "You need write access to try a run again.",
436 });
437 }
438 return flashTo(ctx, "/", { message: "Queued again." });
439 }
src/web/studio/home.ts +4 −2
@@ -72,6 +72,7 @@ import { needsYouSection } from "./asks_page.ts";
7272 import { flowCards } from "./flow_card.ts";
7373 import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed.ts";
7474 import { heldLine } from "./held.ts";
75 import { stoppedTwiceLine } from "./stopped_twice.ts";
7576 import { appChips, movingRegion, tellBox } from "./living.ts";
7677 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
7778 import { kpiRow, waitingLine } from "./kpi_row.ts";
@@ -355,6 +356,8 @@ export async function studioHome(
355356 const draws = homeDraws(ctx, user);
356357 const waiting = await homeRegion(ctx.app.db, ctx.request, user.id, "waiting", homeVariant(ctx.url), access, draws.waiting);
357358 const held = timePhase(ctx.request, "held", () => heldLine(ctx, user));
359 const mine = writableAppsOf(ctx, user).repos;
360 const stopped = timePhase(ctx.request, "stopped", () => stoppedTwiceLine(ctx, mine));
358361 const alarms = user.isAdmin
359362 ? alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config), ctx.app.config.dataDir))
360363 : null;
@@ -368,8 +371,7 @@ export async function studioHome(
368371 // The first page after Allow says so, once.
369372 const allowed = untoldAnswer(ctx.app.db, user.id) === "allowed";
370373 if (allowed) markTold(ctx.app.db, user.id, new Date().toISOString());
371 const needsYou = html`${requests}${waiting}${held}${alarms}`;
372 const mine = writableAppsOf(ctx, user).repos;
374 const needsYou = html`${requests}${waiting}${held}${stopped}${alarms}`;
373375 const body = html`<h1 class="sr-only">Home</h1>
374376 ${allowed ? html`<div class="toast" data-tone="success" role="status" id="youre-in"><strong>${YOURE_IN_MESSAGE}</strong> Your account is open: tell the crew what you want to build.</div>` : null}
375377 ${tellBox(ctx, user)}
src/web/studio/stopped_twice.ts added +32
@@ -0,0 +1,32 @@
1 /**
2 * Home names a run the forge's restarts stopped twice (#11583): one plain
3 * line per such run in the last 24 hours — its issue, the reason in the
4 * words the run carries, and Try again, which posts to the run's re-queue
5 * route. One bounded query; no line when there is none, and none once a
6 * newer run of the same issue exists (Try again made one).
7 */
8
9 import type { Repo } from "../../core/repos.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { stoppedTwiceRuns } from "../../runs/stopped_twice.ts";
12 import { repoBase } from "../chrome.ts";
13 import { html, type Html } from "../html.ts";
14 import { btn } from "./../ui.ts";
15
16 /** The lines for runs stopped twice that this person may try again; nothing where none is. */
17 export function stoppedTwiceLine(ctx: Ctx, writable: readonly Repo[], now = Date.now()): Html | null {
18 const rows = stoppedTwiceRuns(ctx.app.db, writable, now);
19 if (rows.length === 0) return null;
20 return html`<section class="stack stack-tight" id="stopped-twice" aria-label="Stopped runs">
21 ${rows.map((row) => {
22 const repo = row.repo;
23 return html`<form class="bar small" method="post" action="${repoBase(repo)}/runs/${row.runId}/requeue">
24 <span
25 ><a href="${repoBase(repo)}/issues/${row.number}">${repo.name}#${row.number} ${row.title}</a>:
26 ${row.reason}</span
27 >
28 ${btn("Try again", { size: "sm", variant: "outline" })}
29 </form>`;
outdated

Both doors: this line exists only on the page. Add a src/tui/ screen (entry per AGENTS.md) that lists the same runs and offers Try again, gated like the page, and a docs/features/<name>.md how-to. Reuse this query as a plain-data function so both doors share it.

30 })}
31 </section>`;
32 }
test/home-stopped-twice.test.ts added +142
@@ -0,0 +1,142 @@
1 /** Home names a run stopped twice by restarts, with Try again (#11583). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { newApp } from "../src/actions/apps.ts";
8 import { addToCrew, createAgent } from "../src/actions/crew.ts";
9 import { stoppedTwiceRuns } from "../src/runs/stopped_twice.ts";
10 import { stoppedTwiceScreen } from "../src/tui/screens/stopped_twice.ts";
11 import { getRepo } from "../src/core/repos.ts";
12 import { STOPPED_TWICE } from "../src/crew/dispatch.ts";
13 import { createIssue } from "../src/core/issues.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { promote } from "./helpers/members.ts";
18
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let alice: User;
23 let cookie: string;
24 let repoId: string;
25 let botId: string;
26
27 const home = async () => (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
28
29 function failedRun(title: string, summary: string, finishedAt: string) {
30 const made = createIssue(app.db, { repoId, authorId: alice.id, title, kind: "issue" });
31 if (!made.ok) throw new Error(made.error);
32 const id = `0000-${crypto.randomUUID()}`;
33 app.db
34 .query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state, summary, actor_id, finished_at) VALUES (?, ?, 'agent', ?, 'c', 'failed', ?, ?, ?)")
35 .run(id, repoId, made.value.id, summary, botId, finishedAt);
36 return { id, issue: made.value };
37 }
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-stopped-twice-"));
41 app = createApp({ dataDir, port: 0 });
42 servers = await serve(app);
43 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
44 if (!r.ok) throw new Error(r.error);
45 alice = r.value;
46 promote(app.db, alice);
47 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
48 const made = await newApp(app, alice, { name: "shop", description: "Words", visibility: "public" });
49 if (!made.ok) throw new Error(String(made.error));
50 repoId = getRepo(app.db, "alice", "shop")!.id;
51 const bot = await createAgent(app, alice, { username: "bot", brief: "Be brief." });
52 if (!bot.ok) throw new Error(String(bot.error));
53 botId = bot.value.user.id;
54 if (!addToCrew(app, alice, getRepo(app.db, "alice", "shop")!, "bot").ok) throw new Error("crew");
55 });
56
57 afterAll(async () => {
58 servers.stop();
59 app.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 test("no line when no run was stopped twice", async () => {
64 expect(await home()).not.toContain("stopped-twice");
65 });
66
67 test("a run stopped twice lately shows one line with its reason and Try again; old, other, or retried runs show none", async () => {
68 failedRun("Old one", STOPPED_TWICE, new Date(Date.now() - 25 * 3600_000).toISOString());
69 failedRun("Other failure", "tests failed", new Date().toISOString());
70 const again = failedRun("Already retried", STOPPED_TWICE, new Date(Date.now() - 1000).toISOString());
71 app.db.query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES ('zzzzz-newer', ?, 'agent', ?, 'c', 'queued')").run(repoId, again.issue.id);
72 const hit = failedRun("Fix <b>the</b> thing", STOPPED_TWICE, new Date().toISOString());
73 const page = await home();
74 expect(page).toContain('id="stopped-twice"');
75 expect(page).toContain(STOPPED_TWICE);
76 expect(page).toContain(`action="/alice/shop/runs/${hit.id}/requeue"`);
77 expect(page).toContain("Try again");
78 expect(page).toContain("Fix &lt;b&gt;the&lt;/b&gt; thing");
79 expect(page).not.toContain("Fix <b>the</b>");
80 expect(page.match(/\/requeue"/g)?.length).toBe(1);
81 });
82
83 test("a stranger sees no line", async () => {
84 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
outdated

Only the refusal is tested. Add the positive path: same-origin post by a writer queues a new run and the home line goes away; and a signed-in non-writer post is refused. The route is new behaviour with no test that would fail if it re-queued nothing.

85 if (!r.ok) throw new Error(r.error);
86 const bob = `forge_session=${createSession(app.db, r.value.id).token}`;
87 expect(await (await fetch(`${servers.url}/`, { headers: { cookie: bob } })).text()).not.toContain("stopped-twice");
88 });
89
90 test("the Try again route refuses a cross-origin post", async () => {
91 const run = app.db.query<{ id: string }, []>("SELECT id FROM run WHERE summary LIKE 'stopped twice%' ORDER BY id DESC LIMIT 1").get()!;
92 const res = await fetch(`${servers.url}/alice/shop/runs/${run.id}/requeue`, {
93 method: "POST",
94 headers: { cookie, origin: "https://evil.example", "content-type": "application/x-www-form-urlencoded" },
95 body: "",
96 redirect: "manual",
97 });
98 expect(res.status).toBeGreaterThanOrEqual(400);
99 });
100
101 const post = (path: string, who: string) =>
102 fetch(`${servers.url}${path}`, {
103 method: "POST",
104 headers: { cookie: who, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
105 body: "",
106 redirect: "manual",
107 });
108 const runsOf = (issueId: string) => app.db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM run WHERE issue_id = ? AND kind = 'agent'").get(issueId)!.n;
109
110 test("a signed-in non-writer's same-origin post queues nothing", async () => {
111 const hit = failedRun("Not for bob", STOPPED_TWICE, new Date().toISOString());
112 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" });
113 if (!r.ok) throw new Error(r.error);
114 const carol = `forge_session=${createSession(app.db, r.value.id).token}`;
115 const res = await post(`/alice/shop/runs/${hit.id}/requeue`, carol);
116 expect(res.status).toBeLessThan(500);
117 expect(runsOf(hit.issue.id)).toBe(1);
118 expect(await home()).toContain(`/runs/${hit.id}/requeue`);
119 });
120
121 test("a writer's same-origin post queues a new run and the line goes away", async () => {
122 const hit = failedRun("Retry me", STOPPED_TWICE, new Date().toISOString());
123 expect(await home()).toContain(`/runs/${hit.id}/requeue`);
124 const res = await post(`/alice/shop/runs/${hit.id}/requeue`, cookie);
125 expect(res.status).toBe(303);
126 expect(runsOf(hit.issue.id)).toBe(2);
127 expect(await home()).not.toContain(`/runs/${hit.id}/requeue`);
128 });
129
130 test("the terminal screen lists the same runs and Enter tries one again", async () => {
131 const hit = failedRun("Retry from terminal", STOPPED_TWICE, new Date().toISOString());
132 const repo = getRepo(app.db, "alice", "shop")!;
133 expect(stoppedTwiceRuns(app.db, [repo]).some((r) => r.runId === hit.id)).toBe(true);
134 const toasts: string[] = [];
135 const ctx = { theme: { c: {} }, pop() {}, push() {}, invalidate() {}, toast: (t: string) => toasts.push(t) };
136 const screen = stoppedTwiceScreen({ app, user: alice } as never, ctx as never);
137 // Newest first: the run just made is the first row.
138 expect(screen.key!({ key: "enter", ctrl: false, alt: false } as never)).toBe(true);
139 expect(toasts[0]).toContain("again");
140 expect(runsOf(hit.issue.id)).toBe(2);
141 expect(await home()).not.toContain(`/runs/${hit.id}/requeue`);
142 });
test/palette-roster.test.ts +4
@@ -51,6 +51,7 @@ test("a site-admin person is offered the whole roster", () => {
5151 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5252 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
5353 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
54 ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"],
5455 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
5556 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
5657 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
@@ -207,6 +208,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
207208 ["explore", "Explore", "on the web"],
208209 ["explore-feed", "Explore feed", "Atom address"],
209210 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
211 ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"],
210212 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
211213 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
212214 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
@@ -294,6 +296,7 @@ test("a member person is offered the signed-in roster without the admin entries"
294296 ["explore", "Explore", "on the web"],
295297 ["explore-feed", "Explore feed", "Atom address"],
296298 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
299 ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"],
297300 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
298301 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
299302 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
@@ -376,6 +379,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
376379 ["explore", "Explore", "on the web"],
377380 ["explore-feed", "Explore feed", "Atom address"],
378381 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
382 ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"],
379383 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
380384 ["start-forge", "Start my forge", "a free forge of your own; you choose a username and password next"],
381385 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs