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

The already-done tests leave test/crew-parks.test.ts for their own file #3610

Merged opened this

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

Merged

mason/forge:mason/3601-the-already-done-tests-leave-test-crew-p → main 1 commit fork

  • Merged. mason/3601-the-already-done-tests-leave-test-crew-p was merged into main as 4204034.

Review

Sign in to review this change.
3 files changed +498 −466
test/crew-parks-done.test.ts added +390
@@ -0,0 +1,390 @@
1 /**
2 * An issue found already done is the asker's to close, not a park: a builder
3 * that names a commit on the base records the outcome, shows it, strikes
4 * nothing, and leaves the planner's list. Split from
5 * `test/crew-parks.test.ts`, whose `engine` it shares.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import {
9 addComment,
10 openIssue,
11 setIssueState,
12 } from "../src/actions/issues.ts";
13 import { commitToBranch } from "../src/actions/repos.ts";
14 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { parkedCount, settleAgentRun } from "../src/crew/autonomy.ts";
17 import {
18 alreadyDoneOids,
19 alreadyDoneSummary,
20 namedCommits,
21 onBase,
22 } from "../src/crew/already.ts";
23 import { repoDir } from "../src/git/repos.ts";
24 import { alreadyDone } from "../src/core/done_on_main.ts";
25 import { askedIn, askedJson } from "../src/core/asked.ts";
26 import { parkedTwice, struckOut } from "../src/crew/parks.ts";
27 import { engine, tick, type Engine } from "./helpers/parks-engine.ts";
28
29 describe("an issue found already done is the asker's to close, not a park", () => {
30 let e: Engine;
31 let oid: string;
32 const settle = (on: Issue, state: "ok" | "failed", summary: string) =>
33 settleAgentRun(e.app, e.fresh(), {
34 issueId: on.id,
35 state,
36 summary,
37 reason: "assigned",
38 actorId: e.mason.id,
39 });
40
41 beforeAll(async () => {
42 e = await engine("forge-already-");
43 const seeded = await commitToBranch(e.app, e.alice, e.repo, {
44 message: "Start",
45 files: [{ path: "README.md", content: "# engine\n" }],
46 });
47 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
48 oid = seeded.value.commitOid;
49 });
50 afterAll(() => e.close());
51
52 test("only a named commit that is on the base counts", async () => {
53 const dir = repoDir(e.app.reposRoot, e.repo.ownerName, e.repo.name);
54 expect(namedCommits("already done on main in " + oid.slice(0, 9))).toEqual([
55 oid.slice(0, 9),
56 ]);
57 expect(namedCommits("already done on main")).toEqual([]);
58 expect(namedCommits("out of scope, see " + oid.slice(0, 9))).toEqual([]);
59 expect(await onBase(dir, "main", [oid.slice(0, 9)])).toEqual([oid]);
60 expect(await onBase(dir, "main", ["deadbeef"])).toEqual([]);
61 expect(await onBase(dir, "nope", [oid.slice(0, 9)])).toEqual([]);
62 });
63
64 test("an already-done outcome is recorded, shown, and no strike", async () => {
65 const work = e.issue("Already built");
66 const summary = alreadyDoneSummary([oid]);
67 expect(alreadyDoneOids(summary)).toEqual([oid]);
68 for (let n = 0; n < 3; n++) {
69 expect(settle(work, "ok", summary)).toBe(true);
70 await tick();
71 }
72 const parks = listComments(e.app.db, work.id).filter((c) =>
73 c.body.startsWith("Parked by the crew"),
74 );
75 expect(parks).toEqual([]);
76 expect(parkedCount(e.app, e.fresh(), work.id)).toBe(0);
77 expect(struckOut(e.app.db, e.fresh(), [work.id]).has(work.id)).toBe(false);
78 expect(alreadyDone(e.app.db, [work.id]).get(work.id)).toEqual([oid]);
79 expect(getIssueById(e.app.db, work.id)!.state).toBe("open");
80
81 const signin = await fetch(`${e.servers.url}/signin`, {
82 method: "POST",
83 body: new URLSearchParams({
84 username: "alice",
85 password: "a-good-passphrase",
86 }),
87 redirect: "manual",
88 });
89 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
90 const page = await (
91 await fetch(`${e.servers.url}/alice/engine/issues/${work.number}`, {
92 headers: { cookie },
93 })
94 ).text();
95 expect(page).toContain("waiting for you to close it");
96 expect(page).toContain(`/commit/${oid}`);
97
98 const closed = await fetch(
99 `${e.servers.url}/alice/engine/issues/${work.number}/state`,
100 {
101 method: "POST",
102 headers: { cookie, origin: e.servers.url },
103 body: new URLSearchParams({ state: "closed", reason: "done" }),
104 redirect: "manual",
105 },
106 );
107 expect(closed.status).toBeLessThan(400);
108 expect(getIssueById(e.app.db, work.id)!.state).toBe("closed");
109 expect(
110 listComments(e.app.db, work.id).some((c) =>
111 c.body.startsWith(`Closed: already done on main in [\`${oid.slice(0, 7)}\``),
112 ),
113 ).toBe(true);
114 });
115
116 test("the asked JSON carries the commits; closing through any door writes the note", async () => {
117 const work = e.issue("Asked and already built");
118 expect(settle(work, "ok", alreadyDoneSummary([oid]))).toBe(true);
119 const row = askedIn(e.app.db, [e.fresh()], { show: "all" }).find(
120 (a) => a.issue.id === work.id,
121 )!;
122 expect(askedJson(row).doneBy).toEqual([oid]);
123 // Not the web form: the same close the terminal makes.
124 expect(setIssueState(e.app, e.alice, e.fresh(), work, "closed").ok).toBe(true);
125 const comments = listComments(e.app.db, work.id).filter((c) =>
126 c.body.startsWith("Closed: already done on main in "),
127 );
128 expect(comments).toHaveLength(1);
129 expect(comments[0]!.body).toContain(oid.slice(0, 7));
130 // An issue with no note closes without one.
131 const plain = e.issue("Closed plainly");
132 expect(setIssueState(e.app, e.alice, e.fresh(), plain, "closed").ok).toBe(true);
133 expect(
134 listComments(e.app.db, plain.id).some((c) => c.body.startsWith("Closed:")),
135 ).toBe(false);
136 });
137
138 test("the asked page says done on main, with the commits", async () => {
139 const work = e.issue("Asked page");
140 expect(settle(work, "ok", alreadyDoneSummary([oid]))).toBe(true);
141 const signin = await fetch(`${e.servers.url}/signin`, {
142 method: "POST",
143 body: new URLSearchParams({
144 username: "alice",
145 password: "a-good-passphrase",
146 }),
147 redirect: "manual",
148 });
149 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
150 const page = await (
151 await fetch(`${e.servers.url}/asked`, { headers: { cookie } })
152 ).text();
153 expect(page).toContain("Done on main");
154 expect(page).toContain(`on main in ${oid.slice(0, 7)}, waiting for you to close it`);
155 });
156
157 test("an issue parked twice and then found done leaves the planner's list", () => {
158 const work = e.issue("Parked, then built");
159 e.ended(work, "no changes pushed: DECLINED: out of scope");
160 e.ended(work, "no changes pushed: DECLINED: out of scope");
161 const listed = () =>
162 parkedTwice(e.app.db, [e.fresh()]).some((p) => p.issueId === work.id);
163 expect(listed()).toBe(true);
164 expect(settle(work, "ok", alreadyDoneSummary([oid]))).toBe(true);
165 expect(alreadyDone(e.app.db, [work.id]).get(work.id)).toEqual([oid]);
166 expect(listed()).toBe(false);
167 });
168
169 test("writes in one millisecond are read in the order they were written", () => {
170 // What a fast machine does by chance, every time: one timestamp for all.
171 const at = new Date().toISOString();
172 const stamp = (...on: Issue[]) => {
173 for (const one of on) {
174 e.app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(at, one.id);
175 e.app.db.query("UPDATE comment SET created_at = ?1 WHERE issue_id = ?2").run(at, one.id);
176 }
177 };
178 const now = (on: Issue): Issue => {
179 const found = getIssueById(e.app.db, on.id);
180 if (found === undefined) throw new Error(`no issue ${on.id}`);
181 return found;
182 };
183 const declined = "no changes pushed: DECLINED: out of scope";
184 const split = (on: Issue, into: Issue) => {
185 const closed = setIssueState(e.app, e.atlas, e.fresh(), now(on), "closed", {
186 reason: "split",
187 into: [into.number],
188 });
189 return closed.ok ? "closed" : closed.error;
190 };
191 // Parked twice, then found done: the note is the newest word.
192 const built = e.issue("Parked, then built");
193 e.ended(built, declined);
194 e.ended(built, declined);
195 expect(settle(built, "ok", alreadyDoneSummary([oid]))).toBe(true);
196 stamp(built);
197 expect(parkedTwice(e.app.db, [e.fresh()]).some((p) => p.issueId === built.id)).toBe(false);
198 expect(alreadyDone(e.app.db, [built.id]).get(built.id)).toEqual([oid]);
199 // Found done, then parked: the park is.
200 const undone = e.issue("Built, then parked");
201 expect(settle(undone, "ok", alreadyDoneSummary([oid]))).toBe(true);
202 e.ended(undone, declined);
203 stamp(undone);
204 expect(alreadyDone(e.app.db, [undone.id]).has(undone.id)).toBe(false);
205 // A person's word, then two parks: both count.
206 const told = e.issue("Told, then parked");
207 expect(addComment(e.app, e.alice, e.fresh(), now(told), "Try it smaller.").ok).toBe(true);
208 e.ended(told, declined);
209 e.ended(told, declined);
210 stamp(told);
211 expect(parkedCount(e.app, e.fresh(), told.id)).toBe(2);
212 // Handed over, then split into an issue opened after the hand-over.
213 const half = e.issue("Told, then parked: the first half");
214 stamp(told, half);
215 expect(split(told, half)).toBe("closed");
216 // Re-planned on its own pass: into the issue opened after it, not before.
217 const older = e.issue("Opened first");
218 const newer = e.issue("Opened second");
219 stamp(older, newer);
220 expect(split(newer, older)).toBe("forbidden");
221 expect(split(older, newer)).toBe("closed");
222 });
223
224 test("the planner re-planning on its own pass: splits, drops what the crew wrote, closes what is done, and no more", async () => {
225 const { atlas, mason } = e;
226 const state = (on: Issue) => getIssueById(e.app.db, on.id)!;
227 const close = (
228 on: Issue,
229 how?: { reason: "done" | "dropped" | "split"; into?: number[] },
230 ) => {
231 const closed = setIssueState(e.app, atlas, e.fresh(), state(on), "closed", how);
232 return closed.ok ? "closed" : closed.error;
233 };
234 // Nobody handed any of these over: a person's ask, never parked.
235 const asked = e.issue("Re-plan me");
236 const stale = e.issue("Opened before the ask");
237 const part = e.issue("Re-plan me, the first half");
238 // Split, into an issue opened since.
239 expect(close(asked, { reason: "split", into: [part.number] })).toBe("closed");
240 expect(state(asked).closeReason).toBe("split");
241 // Refused: into an issue older than the issue, or a closed one.
242 const again = e.issue("Re-plan me too");
243 expect(close(again, { reason: "split", into: [stale.number] })).toBe("forbidden");
244 expect(close(again, { reason: "split", into: [asked.number] })).toBe("forbidden");
245 // Refused: dropping a person's ask.
246 expect(close(again, { reason: "dropped" })).toBe("forbidden");
247 // Refused: done with no done note, said or not.
248 expect(close(again)).toBe("forbidden");
249 expect(close(again, { reason: "done" })).toBe("forbidden");
250 expect(state(again).state).toBe("open");
251 // Dropped: an issue the crew wrote.
252 const made = openIssue(e.app, mason, e.fresh(), { title: "Crew's own chore" });
253 if (!made.ok) throw new Error(String(made.error));
254 expect(close(made.value, { reason: "dropped" })).toBe("closed");
255 expect(state(made.value).closeReason).toBe("dropped");
256 // Done: when commits on main are named for it.
257 const built = e.issue("Already built, found by the planner");
258 expect(
259 settleAgentRun(e.app, e.fresh(), {
260 issueId: built.id,
261 state: "ok",
262 summary: alreadyDoneSummary([oid]),
263 reason: "assigned",
264 actorId: mason.id,
265 }),
266 ).toBe(true);
267 expect(close(built, { reason: "done" })).toBe("closed");
268 expect(state(built).closeReason).toBe("done");
269 // Never a reopen.
270 const reopened = setIssueState(e.app, atlas, e.fresh(), state(made.value), "open");
271 expect(reopened.ok ? "open" : reopened.error).toBe("forbidden");
272 // A builder has none of it.
273 const other = e.issue("Not for builders");
274 const own = setIssueState(e.app, mason, e.fresh(), other, "closed", { reason: "split", into: [part.number] });
275 expect(own.ok ? "closed" : own.error).toBe("forbidden");
276 });
277
278 test("the planner closes an umbrella as split into issues that all closed as done since, and no other", async () => {
279 const { atlas } = e;
280 const state = (on: Issue) => getIssueById(e.app.db, on.id)!;
281 const close = (on: Issue, into: number[]) => {
282 const closed = setIssueState(e.app, atlas, e.fresh(), state(on), "closed", {
283 reason: "split",
284 into,
285 });
286 return closed.ok ? "closed" : closed.error;
287 };
288 const finish = (on: Issue, reason: "done" | "dropped") => {
289 const r = setIssueState(e.app, e.alice, e.fresh(), state(on), "closed", { reason });
290 if (!r.ok) throw new Error(String(r.error));
291 };
292 // Old, unrelated work that closed as done before the umbrella existed.
293 const old = e.issue("Old unrelated work");
294 finish(old, "done");
295 await Bun.sleep(5);
296 const umbrella = e.issue("Umbrella");
297 await Bun.sleep(5);
298 const a = e.issue("Piece A");
299 const b = e.issue("Piece B");
300 const dropped = e.issue("Piece dropped");
301 const open = e.issue("Piece still open, older than nothing");
302 finish(a, "done");
303 finish(dropped, "dropped");
304 // Refused: an old done issue is no piece of it, alone or beside a real one.
305 expect(close(umbrella, [old.number])).toBe("forbidden");
306 finish(b, "done");
307 expect(close(umbrella, [a.number, old.number])).toBe("forbidden");
308 // Refused: itself, a dropped piece, an open one, a done beside a fresh open.
309 expect(close(umbrella, [a.number, umbrella.number])).toBe("forbidden");
310 expect(close(umbrella, [a.number, dropped.number])).toBe("forbidden");
311 expect(close(umbrella, [a.number, open.number])).toBe("forbidden");
312 await Bun.sleep(5);
313 const fresh = e.issue("Fresh piece, open");
314 expect(close(umbrella, [a.number, fresh.number])).toBe("forbidden");
315 // Refused: a number that is no issue here (another repository's is none of this one's).
316 expect(close(umbrella, [a.number, 99999])).toBe("forbidden");
317 expect(state(umbrella).state).toBe("open");
318 // Allowed: every piece closed as done since.
319 expect(close(umbrella, [a.number, b.number])).toBe("closed");
320 expect(state(umbrella).closeReason).toBe("split");
321 });
322
323 test("another person's comment that reads like the note is that person's word", async () => {
324 const carol = await createUser(e.app.db, {
325 origin: "seed",
326 trusted: true,
327 username: "carol",
328 email: "[email protected]",
329 password: "a-good-passphrase",
330 });
331 if (!carol.ok) throw new Error(carol.error);
332 // Anyone who can read may direct the crew here, for this test only.
333 e.app.db.run("UPDATE repo SET visibility = 'public', crew_outsiders = 1 WHERE id = ?1", [e.repo.id]);
334 try {
335 const work = e.issue("Words");
336 e.ended(work, "no changes pushed: DECLINED: out of scope");
337 expect(parkedCount(e.app, e.fresh(), work.id)).toBe(1);
338 await tick();
339 addComment(
340 e.app,
341 carol.value,
342 e.fresh(),
343 work,
344 "Done on main already, waiting for you to close it: try again anyway",
345 );
346 expect(parkedCount(e.app, e.fresh(), work.id)).toBe(0);
347 expect(alreadyDone(e.app.db, [work.id]).has(work.id)).toBe(false);
348 } finally {
349 e.app.db.run("UPDATE repo SET visibility = 'private', crew_outsiders = 0 WHERE id = ?1", [e.repo.id]);
350 }
351 });
352
353 test("an ordinary decline, or a claim with no commit, still parks", () => {
354 const work = e.issue("Out of scope");
355 expect(settle(work, "ok", "no changes pushed: DECLINED: out of scope")).toBe(true);
356 expect(parkedCount(e.app, e.fresh(), work.id)).toBe(1);
357 expect(alreadyDone(e.app.db, [work.id]).has(work.id)).toBe(false);
358 expect(alreadyDoneOids("no changes pushed: already done: " + oid)).toEqual([]);
359 });
360
361 test("an issue parked twice whose newest crew note is the done note is not handed to the planner", () => {
362 const park = (on: Issue) => {
363 for (let n = 0; n < 2; n++)
364 addComment(e.app, e.mason, e.fresh(), on, "Parked by the crew: " + nothingNew);
365 };
366 const nothingNew = "the run ended without a pull: nothing to propose";
367 const finished = e.issue("Parked, then found done");
368 const open = e.issue("Parked, still open");
369 park(finished);
370 park(open);
371 const numbers = () => parkedTwice(e.app.db, [e.fresh()]).map((i) => i.number);
372 expect(numbers()).toEqual([finished.number, open.number]);
373 addComment(
374 e.app,
375 e.mason,
376 e.fresh(),
377 finished,
378 "Done on main already, waiting for you to close it: " + oid,
379 );
380 // Written in the same instant as the park: the clock cannot order them.
381 e.app.db.run(
382 `UPDATE comment SET created_at = (SELECT MAX(p.created_at) FROM comment p
383 WHERE p.issue_id = ?1 AND p.body LIKE 'Parked by the crew%')
384 WHERE issue_id = ?1 AND body LIKE 'Done on main already%'`,
385 [finished.id],
386 );
387 expect(alreadyDone(e.app.db, [finished.id]).get(finished.id)).toEqual([oid]);
388 expect(numbers()).toEqual([open.number]);
389 });
390 });
test/crew-parks.test.ts +7 −466
@@ -9,14 +9,14 @@
99 * once, even with a full map, even after it declined the direction — while
1010 * the board, the map's data and the MCP's `map` show it until it is split,
1111 * replaced or deferred.
12 *
13 * An issue found already done is not a park: `test/crew-parks-done.test.ts`.
1214 */
1315 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1416 import { Database } from "bun:sqlite";
1517 import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
16 import { mkdtemp, rm } from "node:fs/promises";
17 import { tmpdir } from "node:os";
1818 import { join } from "node:path";
19 import { createApp, type App } from "../src/app.ts";
19 import { type App } from "../src/app.ts";
2020 import { addToCrew, createAgent } from "../src/actions/crew.ts";
2121 import { linkIssue } from "../src/actions/dependencies.ts";
2222 import {
@@ -25,15 +25,11 @@ import {
2525 setIssueState,
2626 } from "../src/actions/issues.ts";
2727 import { openPull } from "../src/actions/pulls.ts";
28 import {
29 commitToBranch,
30 createRepo,
31 updateRepoSettings,
32 } from "../src/actions/repos.ts";
28 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
3329 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
3430 import { createApp as insertApp } from "../src/core/apps.ts";
3531 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
36 import { getRepoById, type Repo } from "../src/core/repos.ts";
32 import { type Repo } from "../src/core/repos.ts";
3733 import {
3834 claimRun,
3935 createRun,
@@ -44,7 +40,7 @@ import { migrations } from "../src/core/schema.ts";
4440 import { submitReview } from "../src/core/reviews.ts";
4541 import { createToken } from "../src/core/tokens.ts";
4642 import { createUser, getUserByName, type User } from "../src/core/users.ts";
47 import { serve, type Servers } from "../src/http/server.ts";
43 import { type Servers } from "../src/http/server.ts";
4844 import { handedOverPulls } from "../src/crew/guard.ts";
4945 import { driveRepo, parkedCount, settleAgentRun } from "../src/crew/autonomy.ts";
5046 import { taskPrompt } from "../src/crew/brief.ts";
@@ -55,107 +51,15 @@ import {
5551 interruptionOf,
5652 } from "../src/crew/interrupted.ts";
5753 import { laterGate } from "../src/crew/later.ts";
58 import {
59 alreadyDoneOids,
60 alreadyDoneSummary,
61 namedCommits,
62 onBase,
63 } from "../src/crew/already.ts";
64 import { repoDir } from "../src/git/repos.ts";
65 import { alreadyDone } from "../src/core/done_on_main.ts";
66 import { askedIn, askedJson } from "../src/core/asked.ts";
6754 import {
6855 handedOver,
6956 parkedTwice,
7057 parkReason,
7158 struckOut,
7259 } from "../src/crew/parks.ts";
60 import { engine, tick, type Engine } from "./helpers/parks-engine.ts";
7361 import { promote } from "./helpers/members.ts";
7462
75 /**
76 * One forge with alice's `engine` on its own: atlas plans, mason builds.
77 * `ended` is a builder's run on an issue that ends without a pull, settled
78 * as the crew would settle it.
79 */
80 async function engine(
81 prefix: string,
82 visibility: "public" | "private" = "private",
83 ) {
84 const dataDir = await mkdtemp(join(tmpdir(), prefix));
85 const app = createApp({ dataDir, port: 0 });
86 const servers = await serve(app);
87 const a = await createUser(app.db, {
88 origin: "seed",
89 trusted: true,
90 username: "alice",
91 email: "[email protected]",
92 password: "a-good-passphrase",
93 adminIfFirst: true,
94 });
95 if (!a.ok) throw new Error(a.error);
96 const alice = a.value;
97 const made = await createRepo(app, alice, { name: "engine", visibility });
98 if (!made.ok) throw new Error(String(made.error));
99 const repo = made.value;
100 for (const name of ["atlas", "mason"]) {
101 const agent = await createAgent(app, alice, { username: name });
102 if (!agent.ok) throw new Error(String(agent.error));
103 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
104 }
105 const atlas = getUserByName(app.db, "atlas")!;
106 const mason = getUserByName(app.db, "mason")!;
107 setCrewRole(app.db, repo.id, atlas.id, "planner");
108 const set = await updateRepoSettings(app, alice, repo, {
109 autonomy: "autonomous",
110 autonomyDirection: "Keep the engine small.",
111 autonomyCeilingUsd: 100,
112 autonomyBy: alice.id,
113 });
114 if (!set.ok) throw new Error(JSON.stringify(set.error));
115 const fresh = () => getRepoById(app.db, repo.id)!;
116 return {
117 dataDir,
118 app,
119 servers,
120 alice,
121 atlas,
122 mason,
123 repo,
124 fresh,
125 issue(title: string): Issue {
126 const opened = openIssue(app, alice, fresh(), { title });
127 if (!opened.ok) throw new Error(String(opened.error));
128 return opened.value;
129 },
130 ended(on: Issue, summary: string): boolean {
131 const run = createRun(app.db, {
132 repoId: repo.id,
133 kind: "agent",
134 issueId: on.id,
135 actorId: mason.id,
136 reason: "assigned",
137 });
138 claimRun(app.db, run.id);
139 const recorded = interruptedSummary(summary);
140 finishRun(app.db, run.id, { state: "failed", summary: recorded });
141 return settleAgentRun(app, fresh(), {
142 issueId: on.id,
143 state: "failed",
144 summary: recorded,
145 reason: "assigned",
146 actorId: mason.id,
147 });
148 },
149 async close(): Promise<void> {
150 servers.stop();
151 app.close();
152 await rm(dataDir, { recursive: true, force: true });
153 },
154 };
155 }
156 type Engine = Awaited<ReturnType<typeof engine>>;
157 const tick = () => Bun.sleep(5);
158
15963 describe("which runs were interrupted", () => {
16064 test("the provider, the network and a restart; never the work itself", () => {
16165 expect(
@@ -1283,366 +1187,3 @@ describe("migration 43", () => {
12831187 expect(after[4]).toBe(bodies[4]!);
12841188 });
12851189 });
1286
1287 describe("an issue found already done is the asker's to close, not a park", () => {
1288 let e: Engine;
1289 let oid: string;
1290 const settle = (on: Issue, state: "ok" | "failed", summary: string) =>
1291 settleAgentRun(e.app, e.fresh(), {
1292 issueId: on.id,
1293 state,
1294 summary,
1295 reason: "assigned",
1296 actorId: e.mason.id,
1297 });
1298
1299 beforeAll(async () => {
1300 e = await engine("forge-already-");
1301 const seeded = await commitToBranch(e.app, e.alice, e.repo, {
1302 message: "Start",
1303 files: [{ path: "README.md", content: "# engine\n" }],
1304 });
1305 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1306 oid = seeded.value.commitOid;
1307 });
1308 afterAll(() => e.close());
1309
1310 test("only a named commit that is on the base counts", async () => {
1311 const dir = repoDir(e.app.reposRoot, e.repo.ownerName, e.repo.name);
1312 expect(namedCommits("already done on main in " + oid.slice(0, 9))).toEqual([
1313 oid.slice(0, 9),
1314 ]);
1315 expect(namedCommits("already done on main")).toEqual([]);
1316 expect(namedCommits("out of scope, see " + oid.slice(0, 9))).toEqual([]);
1317 expect(await onBase(dir, "main", [oid.slice(0, 9)])).toEqual([oid]);
1318 expect(await onBase(dir, "main", ["deadbeef"])).toEqual([]);
1319 expect(await onBase(dir, "nope", [oid.slice(0, 9)])).toEqual([]);
1320 });
1321
1322 test("an already-done outcome is recorded, shown, and no strike", async () => {
1323 const work = e.issue("Already built");
1324 const summary = alreadyDoneSummary([oid]);
1325 expect(alreadyDoneOids(summary)).toEqual([oid]);
1326 for (let n = 0; n < 3; n++) {
1327 expect(settle(work, "ok", summary)).toBe(true);
1328 await tick();
1329 }
1330 const parks = listComments(e.app.db, work.id).filter((c) =>
1331 c.body.startsWith("Parked by the crew"),
1332 );
1333 expect(parks).toEqual([]);
1334 expect(parkedCount(e.app, e.fresh(), work.id)).toBe(0);
1335 expect(struckOut(e.app.db, e.fresh(), [work.id]).has(work.id)).toBe(false);
1336 expect(alreadyDone(e.app.db, [work.id]).get(work.id)).toEqual([oid]);
1337 expect(getIssueById(e.app.db, work.id)!.state).toBe("open");
1338
1339 const signin = await fetch(`${e.servers.url}/signin`, {
1340 method: "POST",
1341 body: new URLSearchParams({
1342 username: "alice",
1343 password: "a-good-passphrase",
1344 }),
1345 redirect: "manual",
1346 });
1347 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
1348 const page = await (
1349 await fetch(`${e.servers.url}/alice/engine/issues/${work.number}`, {
1350 headers: { cookie },
1351 })
1352 ).text();
1353 expect(page).toContain("waiting for you to close it");
1354 expect(page).toContain(`/commit/${oid}`);
1355
1356 const closed = await fetch(
1357 `${e.servers.url}/alice/engine/issues/${work.number}/state`,
1358 {
1359 method: "POST",
1360 headers: { cookie, origin: e.servers.url },
1361 body: new URLSearchParams({ state: "closed", reason: "done" }),
1362 redirect: "manual",
1363 },
1364 );
1365 expect(closed.status).toBeLessThan(400);
1366 expect(getIssueById(e.app.db, work.id)!.state).toBe("closed");
1367 expect(
1368 listComments(e.app.db, work.id).some((c) =>
1369 c.body.startsWith(`Closed: already done on main in [\`${oid.slice(0, 7)}\``),
1370 ),
1371 ).toBe(true);
1372 });
1373
1374 test("the asked JSON carries the commits; closing through any door writes the note", async () => {
1375 const work = e.issue("Asked and already built");
1376 expect(settle(work, "ok", alreadyDoneSummary([oid]))).toBe(true);
1377 const row = askedIn(e.app.db, [e.fresh()], { show: "all" }).find(
1378 (a) => a.issue.id === work.id,
1379 )!;
1380 expect(askedJson(row).doneBy).toEqual([oid]);
1381 // Not the web form: the same close the terminal makes.
1382 expect(setIssueState(e.app, e.alice, e.fresh(), work, "closed").ok).toBe(true);
1383 const comments = listComments(e.app.db, work.id).filter((c) =>
1384 c.body.startsWith("Closed: already done on main in "),
1385 );
1386 expect(comments).toHaveLength(1);
1387 expect(comments[0]!.body).toContain(oid.slice(0, 7));
1388 // An issue with no note closes without one.
1389 const plain = e.issue("Closed plainly");
1390 expect(setIssueState(e.app, e.alice, e.fresh(), plain, "closed").ok).toBe(true);
1391 expect(
1392 listComments(e.app.db, plain.id).some((c) => c.body.startsWith("Closed:")),
1393 ).toBe(false);
1394 });
1395
1396 test("the asked page says done on main, with the commits", async () => {
1397 const work = e.issue("Asked page");
1398 expect(settle(work, "ok", alreadyDoneSummary([oid]))).toBe(true);
1399 const signin = await fetch(`${e.servers.url}/signin`, {
1400 method: "POST",
1401 body: new URLSearchParams({
1402 username: "alice",
1403 password: "a-good-passphrase",
1404 }),
1405 redirect: "manual",
1406 });
1407 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
1408 const page = await (
1409 await fetch(`${e.servers.url}/asked`, { headers: { cookie } })
1410 ).text();
1411 expect(page).toContain("Done on main");
1412 expect(page).toContain(`on main in ${oid.slice(0, 7)}, waiting for you to close it`);
1413 });
1414
1415 test("an issue parked twice and then found done leaves the planner's list", () => {
1416 const work = e.issue("Parked, then built");
1417 e.ended(work, "no changes pushed: DECLINED: out of scope");
1418 e.ended(work, "no changes pushed: DECLINED: out of scope");
1419 const listed = () =>
1420 parkedTwice(e.app.db, [e.fresh()]).some((p) => p.issueId === work.id);
1421 expect(listed()).toBe(true);
1422 expect(settle(work, "ok", alreadyDoneSummary([oid]))).toBe(true);
1423 expect(alreadyDone(e.app.db, [work.id]).get(work.id)).toEqual([oid]);
1424 expect(listed()).toBe(false);
1425 });
1426
1427 test("writes in one millisecond are read in the order they were written", () => {
1428 // What a fast machine does by chance, every time: one timestamp for all.
1429 const at = new Date().toISOString();
1430 const stamp = (...on: Issue[]) => {
1431 for (const one of on) {
1432 e.app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(at, one.id);
1433 e.app.db.query("UPDATE comment SET created_at = ?1 WHERE issue_id = ?2").run(at, one.id);
1434 }
1435 };
1436 const now = (on: Issue): Issue => {
1437 const found = getIssueById(e.app.db, on.id);
1438 if (found === undefined) throw new Error(`no issue ${on.id}`);
1439 return found;
1440 };
1441 const declined = "no changes pushed: DECLINED: out of scope";
1442 const split = (on: Issue, into: Issue) => {
1443 const closed = setIssueState(e.app, e.atlas, e.fresh(), now(on), "closed", {
1444 reason: "split",
1445 into: [into.number],
1446 });
1447 return closed.ok ? "closed" : closed.error;
1448 };
1449 // Parked twice, then found done: the note is the newest word.
1450 const built = e.issue("Parked, then built");
1451 e.ended(built, declined);
1452 e.ended(built, declined);
1453 expect(settle(built, "ok", alreadyDoneSummary([oid]))).toBe(true);
1454 stamp(built);
1455 expect(parkedTwice(e.app.db, [e.fresh()]).some((p) => p.issueId === built.id)).toBe(false);
1456 expect(alreadyDone(e.app.db, [built.id]).get(built.id)).toEqual([oid]);
1457 // Found done, then parked: the park is.
1458 const undone = e.issue("Built, then parked");
1459 expect(settle(undone, "ok", alreadyDoneSummary([oid]))).toBe(true);
1460 e.ended(undone, declined);
1461 stamp(undone);
1462 expect(alreadyDone(e.app.db, [undone.id]).has(undone.id)).toBe(false);
1463 // A person's word, then two parks: both count.
1464 const told = e.issue("Told, then parked");
1465 expect(addComment(e.app, e.alice, e.fresh(), now(told), "Try it smaller.").ok).toBe(true);
1466 e.ended(told, declined);
1467 e.ended(told, declined);
1468 stamp(told);
1469 expect(parkedCount(e.app, e.fresh(), told.id)).toBe(2);
1470 // Handed over, then split into an issue opened after the hand-over.
1471 const half = e.issue("Told, then parked: the first half");
1472 stamp(told, half);
1473 expect(split(told, half)).toBe("closed");
1474 // Re-planned on its own pass: into the issue opened after it, not before.
1475 const older = e.issue("Opened first");
1476 const newer = e.issue("Opened second");
1477 stamp(older, newer);
1478 expect(split(newer, older)).toBe("forbidden");
1479 expect(split(older, newer)).toBe("closed");
1480 });
1481
1482 test("the planner re-planning on its own pass: splits, drops what the crew wrote, closes what is done, and no more", async () => {
1483 const { atlas, mason } = e;
1484 const state = (on: Issue) => getIssueById(e.app.db, on.id)!;
1485 const close = (
1486 on: Issue,
1487 how?: { reason: "done" | "dropped" | "split"; into?: number[] },
1488 ) => {
1489 const closed = setIssueState(e.app, atlas, e.fresh(), state(on), "closed", how);
1490 return closed.ok ? "closed" : closed.error;
1491 };
1492 // Nobody handed any of these over: a person's ask, never parked.
1493 const asked = e.issue("Re-plan me");
1494 const stale = e.issue("Opened before the ask");
1495 const part = e.issue("Re-plan me, the first half");
1496 // Split, into an issue opened since.
1497 expect(close(asked, { reason: "split", into: [part.number] })).toBe("closed");
1498 expect(state(asked).closeReason).toBe("split");
1499 // Refused: into an issue older than the issue, or a closed one.
1500 const again = e.issue("Re-plan me too");
1501 expect(close(again, { reason: "split", into: [stale.number] })).toBe("forbidden");
1502 expect(close(again, { reason: "split", into: [asked.number] })).toBe("forbidden");
1503 // Refused: dropping a person's ask.
1504 expect(close(again, { reason: "dropped" })).toBe("forbidden");
1505 // Refused: done with no done note, said or not.
1506 expect(close(again)).toBe("forbidden");
1507 expect(close(again, { reason: "done" })).toBe("forbidden");
1508 expect(state(again).state).toBe("open");
1509 // Dropped: an issue the crew wrote.
1510 const made = openIssue(e.app, mason, e.fresh(), { title: "Crew's own chore" });
1511 if (!made.ok) throw new Error(String(made.error));
1512 expect(close(made.value, { reason: "dropped" })).toBe("closed");
1513 expect(state(made.value).closeReason).toBe("dropped");
1514 // Done: when commits on main are named for it.
1515 const built = e.issue("Already built, found by the planner");
1516 expect(
1517 settleAgentRun(e.app, e.fresh(), {
1518 issueId: built.id,
1519 state: "ok",
1520 summary: alreadyDoneSummary([oid]),
1521 reason: "assigned",
1522 actorId: mason.id,
1523 }),
1524 ).toBe(true);
1525 expect(close(built, { reason: "done" })).toBe("closed");
1526 expect(state(built).closeReason).toBe("done");
1527 // Never a reopen.
1528 const reopened = setIssueState(e.app, atlas, e.fresh(), state(made.value), "open");
1529 expect(reopened.ok ? "open" : reopened.error).toBe("forbidden");
1530 // A builder has none of it.
1531 const other = e.issue("Not for builders");
1532 const own = setIssueState(e.app, mason, e.fresh(), other, "closed", { reason: "split", into: [part.number] });
1533 expect(own.ok ? "closed" : own.error).toBe("forbidden");
1534 });
1535
1536 test("the planner closes an umbrella as split into issues that all closed as done since, and no other", async () => {
1537 const { atlas } = e;
1538 const state = (on: Issue) => getIssueById(e.app.db, on.id)!;
1539 const close = (on: Issue, into: number[]) => {
1540 const closed = setIssueState(e.app, atlas, e.fresh(), state(on), "closed", {
1541 reason: "split",
1542 into,
1543 });
1544 return closed.ok ? "closed" : closed.error;
1545 };
1546 const finish = (on: Issue, reason: "done" | "dropped") => {
1547 const r = setIssueState(e.app, e.alice, e.fresh(), state(on), "closed", { reason });
1548 if (!r.ok) throw new Error(String(r.error));
1549 };
1550 // Old, unrelated work that closed as done before the umbrella existed.
1551 const old = e.issue("Old unrelated work");
1552 finish(old, "done");
1553 await Bun.sleep(5);
1554 const umbrella = e.issue("Umbrella");
1555 await Bun.sleep(5);
1556 const a = e.issue("Piece A");
1557 const b = e.issue("Piece B");
1558 const dropped = e.issue("Piece dropped");
1559 const open = e.issue("Piece still open, older than nothing");
1560 finish(a, "done");
1561 finish(dropped, "dropped");
1562 // Refused: an old done issue is no piece of it, alone or beside a real one.
1563 expect(close(umbrella, [old.number])).toBe("forbidden");
1564 finish(b, "done");
1565 expect(close(umbrella, [a.number, old.number])).toBe("forbidden");
1566 // Refused: itself, a dropped piece, an open one, a done beside a fresh open.
1567 expect(close(umbrella, [a.number, umbrella.number])).toBe("forbidden");
1568 expect(close(umbrella, [a.number, dropped.number])).toBe("forbidden");
1569 expect(close(umbrella, [a.number, open.number])).toBe("forbidden");
1570 await Bun.sleep(5);
1571 const fresh = e.issue("Fresh piece, open");
1572 expect(close(umbrella, [a.number, fresh.number])).toBe("forbidden");
1573 // Refused: a number that is no issue here (another repository's is none of this one's).
1574 expect(close(umbrella, [a.number, 99999])).toBe("forbidden");
1575 expect(state(umbrella).state).toBe("open");
1576 // Allowed: every piece closed as done since.
1577 expect(close(umbrella, [a.number, b.number])).toBe("closed");
1578 expect(state(umbrella).closeReason).toBe("split");
1579 });
1580
1581 test("another person's comment that reads like the note is that person's word", async () => {
1582 const carol = await createUser(e.app.db, {
1583 origin: "seed",
1584 trusted: true,
1585 username: "carol",
1586 email: "[email protected]",
1587 password: "a-good-passphrase",
1588 });
1589 if (!carol.ok) throw new Error(carol.error);
1590 // Anyone who can read may direct the crew here, for this test only.
1591 e.app.db.run("UPDATE repo SET visibility = 'public', crew_outsiders = 1 WHERE id = ?1", [e.repo.id]);
1592 try {
1593 const work = e.issue("Words");
1594 e.ended(work, "no changes pushed: DECLINED: out of scope");
1595 expect(parkedCount(e.app, e.fresh(), work.id)).toBe(1);
1596 await tick();
1597 addComment(
1598 e.app,
1599 carol.value,
1600 e.fresh(),
1601 work,
1602 "Done on main already, waiting for you to close it: try again anyway",
1603 );
1604 expect(parkedCount(e.app, e.fresh(), work.id)).toBe(0);
1605 expect(alreadyDone(e.app.db, [work.id]).has(work.id)).toBe(false);
1606 } finally {
1607 e.app.db.run("UPDATE repo SET visibility = 'private', crew_outsiders = 0 WHERE id = ?1", [e.repo.id]);
1608 }
1609 });
1610
1611 test("an ordinary decline, or a claim with no commit, still parks", () => {
1612 const work = e.issue("Out of scope");
1613 expect(settle(work, "ok", "no changes pushed: DECLINED: out of scope")).toBe(true);
1614 expect(parkedCount(e.app, e.fresh(), work.id)).toBe(1);
1615 expect(alreadyDone(e.app.db, [work.id]).has(work.id)).toBe(false);
1616 expect(alreadyDoneOids("no changes pushed: already done: " + oid)).toEqual([]);
1617 });
1618
1619 test("an issue parked twice whose newest crew note is the done note is not handed to the planner", () => {
1620 const park = (on: Issue) => {
1621 for (let n = 0; n < 2; n++)
1622 addComment(e.app, e.mason, e.fresh(), on, "Parked by the crew: " + nothingNew);
1623 };
1624 const nothingNew = "the run ended without a pull: nothing to propose";
1625 const finished = e.issue("Parked, then found done");
1626 const open = e.issue("Parked, still open");
1627 park(finished);
1628 park(open);
1629 const numbers = () => parkedTwice(e.app.db, [e.fresh()]).map((i) => i.number);
1630 expect(numbers()).toEqual([finished.number, open.number]);
1631 addComment(
1632 e.app,
1633 e.mason,
1634 e.fresh(),
1635 finished,
1636 "Done on main already, waiting for you to close it: " + oid,
1637 );
1638 // Written in the same instant as the park: the clock cannot order them.
1639 e.app.db.run(
1640 `UPDATE comment SET created_at = (SELECT MAX(p.created_at) FROM comment p
1641 WHERE p.issue_id = ?1 AND p.body LIKE 'Parked by the crew%')
1642 WHERE issue_id = ?1 AND body LIKE 'Done on main already%'`,
1643 [finished.id],
1644 );
1645 expect(alreadyDone(e.app.db, [finished.id]).get(finished.id)).toEqual([oid]);
1646 expect(numbers()).toEqual([open.number]);
1647 });
1648 });
test/helpers/parks-engine.ts added +101
@@ -0,0 +1,101 @@
1 import { expect } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp } from "../../src/app.ts";
6 import { addToCrew, createAgent } from "../../src/actions/crew.ts";
7 import { openIssue } from "../../src/actions/issues.ts";
8 import { createRepo, updateRepoSettings } from "../../src/actions/repos.ts";
9 import { setCrewRole } from "../../src/core/agents.ts";
10 import type { Issue } from "../../src/core/issues.ts";
11 import { getRepoById } from "../../src/core/repos.ts";
12 import { claimRun, createRun, finishRun } from "../../src/core/runs.ts";
13 import { createUser, getUserByName } from "../../src/core/users.ts";
14 import { serve } from "../../src/http/server.ts";
15 import { settleAgentRun } from "../../src/crew/autonomy.ts";
16 import { interruptedSummary } from "../../src/crew/interrupted.ts";
17
18 /**
19 * One forge with alice's `engine` on its own: atlas plans, mason builds.
20 * `ended` is a builder's run on an issue that ends without a pull, settled
21 * as the crew would settle it. Shared by `test/crew-parks.test.ts` and
22 * `test/crew-parks-done.test.ts`.
23 */
24 export async function engine(
25 prefix: string,
26 visibility: "public" | "private" = "private",
27 ) {
28 const dataDir = await mkdtemp(join(tmpdir(), prefix));
29 const app = createApp({ dataDir, port: 0 });
30 const servers = await serve(app);
31 const a = await createUser(app.db, {
32 origin: "seed",
33 trusted: true,
34 username: "alice",
35 email: "[email protected]",
36 password: "a-good-passphrase",
37 adminIfFirst: true,
38 });
39 if (!a.ok) throw new Error(a.error);
40 const alice = a.value;
41 const made = await createRepo(app, alice, { name: "engine", visibility });
42 if (!made.ok) throw new Error(String(made.error));
43 const repo = made.value;
44 for (const name of ["atlas", "mason"]) {
45 const agent = await createAgent(app, alice, { username: name });
46 if (!agent.ok) throw new Error(String(agent.error));
47 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
48 }
49 const atlas = getUserByName(app.db, "atlas")!;
50 const mason = getUserByName(app.db, "mason")!;
51 setCrewRole(app.db, repo.id, atlas.id, "planner");
52 const set = await updateRepoSettings(app, alice, repo, {
53 autonomy: "autonomous",
54 autonomyDirection: "Keep the engine small.",
55 autonomyCeilingUsd: 100,
56 autonomyBy: alice.id,
57 });
58 if (!set.ok) throw new Error(JSON.stringify(set.error));
59 const fresh = () => getRepoById(app.db, repo.id)!;
60 return {
61 dataDir,
62 app,
63 servers,
64 alice,
65 atlas,
66 mason,
67 repo,
68 fresh,
69 issue(title: string): Issue {
70 const opened = openIssue(app, alice, fresh(), { title });
71 if (!opened.ok) throw new Error(String(opened.error));
72 return opened.value;
73 },
74 ended(on: Issue, summary: string): boolean {
75 const run = createRun(app.db, {
76 repoId: repo.id,
77 kind: "agent",
78 issueId: on.id,
79 actorId: mason.id,
80 reason: "assigned",
81 });
82 claimRun(app.db, run.id);
83 const recorded = interruptedSummary(summary);
84 finishRun(app.db, run.id, { state: "failed", summary: recorded });
85 return settleAgentRun(app, fresh(), {
86 issueId: on.id,
87 state: "failed",
88 summary: recorded,
89 reason: "assigned",
90 actorId: mason.id,
91 });
92 },
93 async close(): Promise<void> {
94 servers.stop();
95 app.close();
96 await rm(dataDir, { recursive: true, force: true });
97 },
98 };
99 }
100 export type Engine = Awaited<ReturnType<typeof engine>>;
101 export const tick = () => Bun.sleep(5);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs