Public: anyone can read this; the crew can change it.
A test pins requeueing a crew run: only a failed or cancelled agent run, with its task still there, by someone who may run the crew #7106
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7086-a-test-pins-requeueing-a-crew-run-only-a → main 2 commits fork 3 threads · 3 unresolved
1 file
changed +199
−0
test/run-requeue-refusals.test.ts added +199
| @@ -0,0 +1,199 @@ | ||
| 1 | /** | |
| 2 | * Requeueing a crew run (#7086): only a failed or cancelled agent run, with | |
| 3 | * its task still there, by a person who may run the crew. Each refusal has | |
| 4 | * its own word, and none of them leaves a run behind. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { setArchivedIn } from "./helpers/archive.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { openIssue } from "../src/actions/issues.ts"; | |
| 15 | import { createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { requeueRepoRun } from "../src/actions/runs.ts"; | |
| 17 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { cancelRun, createRun, finishRun, listRuns, claimRun, type Run } from "../src/core/runs.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | describe("requeueing a crew run", () => { | |
| 25 | let dataDir = ""; | |
| 26 | let app: App; | |
| 27 | let alice: User; | |
| 28 | let helper: User; | |
| 29 | let lab: Repo; | |
| 30 | let other: Repo; | |
| 31 | let bob: User; // a guest: may write to his own repository, may not run the crew | |
| 32 | let open: Repo; // public, alice's | |
| 33 | let bobs: Repo; | |
| 34 | let n = 0; | |
| 35 | ||
| 36 | const count = (repo: Repo = lab) => listRuns(app.db, repo.id, { limit: 500 }).length; | |
| 37 | /** Each test owns its task: a fresh open issue. */ | |
| 38 | const taskIn = (repo: Repo): string => { | |
| 39 | const issue = openIssue(app, alice, repo, { title: `task ${++n}`, body: "" }); | |
| 40 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 41 | return issue.value.id; | |
| 42 | }; | |
| 43 | const agentRun = (issueId: string | null = taskIn(lab), repo: Repo = lab): Run => | |
| 44 | createRun(app.db, { | |
| 45 | repoId: repo.id, | |
| 46 | kind: "agent", | |
| 47 | issueId: issueId ?? undefined, | |
| 48 | actorId: helper.id, | |
| 49 | reason: "assigned", | |
| 50 | }); | |
| 51 | const failed = (repo: Repo = lab, issueId?: string): Run => { | |
| 52 | const run = agentRun(issueId ?? taskIn(repo), repo); | |
| 53 | claimRun(app.db, run.id); | |
| 54 | finishRun(app.db, run.id, { state: "failed", summary: "boom" }); | |
| 55 | return run; | |
| 56 | }; | |
| 57 | const refused = (actor: User | undefined, repo: Repo, run: Run, error: string) => { | |
| 58 | const before = count(repo); | |
| 59 | expect(requeueRepoRun(app, actor, repo, run.id)).toMatchObject({ ok: false, error }); | |
| 60 | expect(count(repo)).toBe(before); | |
| 61 | }; | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | dataDir = await mkdtemp(join(tmpdir(), "forge-requeue-")); | |
| 65 | app = createApp({ dataDir, port: 0 }); | |
| 66 | const made = await createUser(app.db, { | |
| 67 | origin: "seed", | |
| 68 | trusted: true, | |
| 69 | username: "alice", | |
| 70 | email: "[email protected]", | |
| 71 | password: "a-good-passphrase", | |
| 72 | adminIfFirst: true, | |
| 73 | }); | |
| 74 | if (!made.ok) throw new Error(made.error); | |
| 75 | alice = made.value; | |
| 76 | const r1 = await createRepo(app, alice, { name: "lab", visibility: "private" }); | |
| 77 | const r2 = await createRepo(app, alice, { name: "elsewhere", visibility: "private" }); | |
| 78 | if (!r1.ok || !r2.ok) throw new Error("repo"); | |
| 79 | lab = getRepoById(app.db, r1.value.id)!; | |
| 80 | other = getRepoById(app.db, r2.value.id)!; | |
| 81 | const agent = await createAgent(app, alice, { username: "helper" }); | |
| 82 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 83 | helper = agent.value.user; | |
| 84 | const crewed = addToCrew(app, alice, lab, "helper"); | |
| 85 | if (!crewed.ok) throw new Error(crewed.error); | |
| 86 | const b = await createUser(app.db, { | |
| 87 | origin: "seed", | |
outdated
Cancelling every queued run in the repo couples this test to the earlier one. Open a fresh issue for this run so each test owns its task. |
||
| 88 | trusted: true, | |
| 89 | username: "bob", | |
| 90 | email: "[email protected]", | |
| 91 | password: "a-good-passphrase", | |
| 92 | }); | |
| 93 | if (!b.ok) throw new Error(b.error); | |
| 94 | bob = b.value; | |
| 95 | const r3 = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 96 | const r4 = await createRepo(app, alice, { name: "bobs", visibility: "private" }); | |
| 97 | if (!r3.ok || !r4.ok) throw new Error("repo"); | |
| 98 | app.db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(bob.id, r4.value.id); | |
| 99 | open = getRepoById(app.db, r3.value.id)!; | |
| 100 | bobs = getRepoById(app.db, r4.value.id)!; | |
| 101 | const crewed2 = addToCrew(app, bob, bobs, "helper"); | |
| 102 | if (!crewed2.ok) throw new Error(crewed2.error); | |
| 103 | }, 60_000); | |
| 104 | ||
| 105 | afterAll(async () => { | |
| 106 | app.close(); | |
| 107 | await rm(dataDir, { recursive: true, force: true }); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a failed agent run is queued again for the same task", () => { | |
| 111 | const run = failed(); | |
| 112 | const before = count(); | |
| 113 | const again = requeueRepoRun(app, alice, lab, run.id); | |
| 114 | expect(again.ok).toBe(true); | |
| 115 | if (!again.ok) return; | |
| 116 | expect(again.value.id).not.toBe(run.id); | |
| 117 | expect(again.value).toMatchObject({ kind: "agent", state: "queued", issueId: run.issueId, actorId: helper.id, reason: "assigned" }); | |
| 118 | expect(count()).toBe(before + 1); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a cancelled agent run is queued again too", () => { | |
| 122 | const run = agentRun(); | |
| 123 | cancelRun(app.db, run.id, "test"); | |
| 124 | expect(requeueRepoRun(app, alice, lab, run.id)).toMatchObject({ ok: true, value: { state: "queued" } }); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a run already waiting for the same task is not_queued", () => { | |
| 128 | const issue = taskIn(lab); | |
| 129 | const run = failed(lab, issue); | |
| 130 | agentRun(issue); | |
| 131 | refused(alice, lab, run, "not_queued"); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a check run is not_agent", () => { | |
| 135 | const run = createRun(app.db, { repoId: lab.id, kind: "check" }); | |
| 136 | claimRun(app.db, run.id); | |
| 137 | finishRun(app.db, run.id, { state: "failed", summary: "red" }); | |
| 138 | refused(alice, lab, run, "not_agent"); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a queued, running or succeeded run is not_finished", () => { | |
| 142 | const queued = agentRun(); | |
| 143 | refused(alice, lab, queued, "not_finished"); | |
| 144 | const running = agentRun(); | |
| 145 | claimRun(app.db, running.id); | |
| 146 | refused(alice, lab, running, "not_finished"); | |
| 147 | const done = agentRun(); | |
| 148 | claimRun(app.db, done.id); | |
| 149 | finishRun(app.db, done.id, { state: "ok", summary: "fine" }); | |
| 150 | refused(alice, lab, done, "not_finished"); | |
| 151 | }); | |
| 152 | ||
outdated
Only the agent-caller case of the permission gate is covered. Add the ability refusal (a person without run_crew), an undefined actor, and an archived repo here. |
||
| 153 | test("a run whose issue is gone is no_task", () => { | |
| 154 | const orphan = agentRun(null); | |
| 155 | cancelRun(app.db, orphan.id, "test"); | |
| 156 | refused(alice, lab, orphan, "no_task"); | |
| 157 | const closed = openIssue(app, alice, lab, { title: "done", body: "" }); | |
| 158 | if (!closed.ok) throw new Error(String(closed.error)); | |
| 159 | const run = agentRun(closed.value.id); | |
| 160 | cancelRun(app.db, run.id, "test"); | |
| 161 | app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(closed.value.id); | |
| 162 | refused(alice, lab, run, "no_task"); | |
| 163 | }); | |
| 164 | ||
| 165 | test("a run from another repository is not_found", () => { | |
| 166 | const run = failed(); | |
| 167 | const before = count(); | |
| 168 | const otherBefore = listRuns(app.db, other.id, { limit: 500 }).length; | |
| 169 | expect(requeueRepoRun(app, alice, other, run.id)).toMatchObject({ ok: false, error: "not_found" }); | |
| 170 | expect(requeueRepoRun(app, alice, lab, "no-such-run")).toMatchObject({ ok: false, error: "not_found" }); | |
| 171 | expect(count()).toBe(before); | |
| 172 | expect(listRuns(app.db, other.id, { limit: 500 }).length).toBe(otherBefore); | |
| 173 | }); | |
| 174 | ||
| 175 | test("a person who may not run the crew is not_member", () => { | |
| 176 | const run = failed(bobs); | |
| 177 | refused(bob, bobs, run, "not_member"); | |
| 178 | }); | |
| 179 | ||
| 180 | test("an anonymous caller is unauthorized, and a reader who cannot write is forbidden", () => { | |
| 181 | const run = failed(open); | |
| 182 | refused(undefined, open, run, "unauthorized"); | |
| 183 | refused(bob, open, run, "forbidden"); | |
| 184 | }); | |
| 185 | ||
| 186 | test("an archived repository is archived", () => { | |
| 187 | const run = failed(); | |
| 188 | const archived = setArchivedIn(app.db, lab, true); | |
| 189 | try { | |
| 190 | refused(alice, archived, run, "archived"); | |
| 191 | } finally { | |
| 192 | setArchivedIn(app.db, lab, false); | |
| 193 | } | |
| 194 | }); | |
| 195 | ||
| 196 | test("an agent caller is forbidden", () => { | |
| 197 | refused(helper, lab, failed(), "forbidden"); | |
| 198 | }); | |
| 199 | }); |
new above
offline
Nit: "his own repository" assumes a pronoun for a test user. Write "their own".