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

Merged opened this

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

Merged

scout/forge:scout/7086-a-test-pins-requeueing-a-crew-run-only-a → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/7086-a-test-pins-requeueing-a-crew-run-only-a was merged into main as 575b196.

Review

Sign in to review this change.
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 => {

Nit: "his own repository" assumes a pronoun for a test user. Write "their own".

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs