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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11583-the-forge-owner-s-home-names-a-run-stopp → main 3 commits fork 3 threads · 3 unresolved
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 { | ||
| 361 | 361 | runCancel, |
| 362 | 362 | runPage, |
| 363 | 363 | runRedirect, |
| 364 | runRequeue, | |
| 364 | 365 | runSteer, |
| 365 | 366 | runsList, |
| 366 | 367 | } from "../web/pages/runs.ts"; |
| @@ -1406,6 +1407,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1406 | 1407 | router.add("GET", "/:owner/:name/runs", runsList); |
| 1407 | 1408 | router.add("GET", "/:owner/:name/runs/:id", runPage); |
| 1408 | 1409 | router.add("POST", "/:owner/:name/runs/:id/cancel", runCancel); |
| 1410 | router.add("POST", "/:owner/:name/runs/:id/requeue", runRequeue); | |
| 1409 | 1411 | router.add("POST", "/:owner/:name/runs/:id/steer", runSteer); |
| 1410 | 1412 | // The public social cards: a repo as owner~name, an app as its slug. |
| 1411 | 1413 | 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"; | ||
| 18 | 18 | import { fleetCommand } from "./screens/fleet.ts"; |
| 19 | 19 | import { dataCommand } from "./screens/data.ts"; |
| 20 | 20 | import { packsCommand } from "./screens/packs.ts"; |
| 21 | import { stoppedTwiceCommand } from "./screens/stopped_twice.ts"; | |
| 21 | 22 | import { getForgeCommand } from "./screens/getforge.ts"; |
| 22 | 23 | import { startForgeCommand } from "./screens/startforge.ts"; |
| 23 | 24 | import { pricingCommand } from "./screens/pricing.ts"; |
| @@ -248,6 +249,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 248 | 249 | run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"), |
| 249 | 250 | }, |
| 250 | 251 | packsCommand(session, ctx), |
| 252 | stoppedTwiceCommand(session, ctx), | |
| 251 | 253 | getForgeCommand(session, ctx), |
| 252 | 254 | ...startForgeCommand(session, ctx), |
| 253 | 255 | 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"; | ||
| 18 | 18 | import { |
| 19 | 19 | cancelRepoRun, |
| 20 | 20 | listRepoRuns, |
| 21 | requeueRepoRun, | |
| 21 | 22 | runLog, |
| 22 | 23 | steerRepoRun, |
| 23 | 24 | } from "../../actions/runs.ts"; |
| @@ -409,3 +410,30 @@ export async function runCancel(ctx: Ctx): Promise<Response> { | ||
| 409 | 410 | } |
| 410 | 411 | return flashTo(ctx, dest, { message: "Run cancelled." }); |
| 411 | 412 | } |
| 413 | ||
| 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"; | ||
| 72 | 72 | import { flowCards } from "./flow_card.ts"; |
| 73 | 73 | import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed.ts"; |
| 74 | 74 | import { heldLine } from "./held.ts"; |
| 75 | import { stoppedTwiceLine } from "./stopped_twice.ts"; | |
| 75 | 76 | import { appChips, movingRegion, tellBox } from "./living.ts"; |
| 76 | 77 | import { alarmLines, cachedAlarms } from "./alarm_lines.ts"; |
| 77 | 78 | import { kpiRow, waitingLine } from "./kpi_row.ts"; |
| @@ -355,6 +356,8 @@ export async function studioHome( | ||
| 355 | 356 | const draws = homeDraws(ctx, user); |
| 356 | 357 | const waiting = await homeRegion(ctx.app.db, ctx.request, user.id, "waiting", homeVariant(ctx.url), access, draws.waiting); |
| 357 | 358 | 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)); | |
| 358 | 361 | const alarms = user.isAdmin |
| 359 | 362 | ? alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config), ctx.app.config.dataDir)) |
| 360 | 363 | : null; |
| @@ -368,8 +371,7 @@ export async function studioHome( | ||
| 368 | 371 | // The first page after Allow says so, once. |
| 369 | 372 | const allowed = untoldAnswer(ctx.app.db, user.id) === "allowed"; |
| 370 | 373 | 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}`; | |
| 373 | 375 | const body = html`<h1 class="sr-only">Home</h1> |
| 374 | 376 | ${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} |
| 375 | 377 | ${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 <b>the</b> 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", () => { | ||
| 51 | 51 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 52 | 52 | ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"], |
| 53 | 53 | ["pack-repos", "Pack repositories", "which repositories /packs lists"], |
| 54 | ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"], | |
| 54 | 55 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 55 | 56 | ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"], |
| 56 | 57 | ["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", | ||
| 207 | 208 | ["explore", "Explore", "on the web"], |
| 208 | 209 | ["explore-feed", "Explore feed", "Atom address"], |
| 209 | 210 | ["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"], | |
| 210 | 212 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 211 | 213 | ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"], |
| 212 | 214 | ["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" | ||
| 294 | 296 | ["explore", "Explore", "on the web"], |
| 295 | 297 | ["explore-feed", "Explore feed", "Atom address"], |
| 296 | 298 | ["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"], | |
| 297 | 300 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 298 | 301 | ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"], |
| 299 | 302 | ["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", () => { | ||
| 376 | 379 | ["explore", "Explore", "on the web"], |
| 377 | 380 | ["explore-feed", "Explore feed", "Atom address"], |
| 378 | 381 | ["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"], | |
| 379 | 383 | ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"], |
| 380 | 384 | ["start-forge", "Start my forge", "a free forge of your own; you choose a username and password next"], |
| 381 | 385 | ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"], |
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.