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

Builder slots serve the roadmap first: tests-only and small-addition pulls get at most a set share of builder slots #7684

Merged opened this in answer to

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

Merged

scout/forge:scout/7571-builder-slots-serve-the-roadmap-first-te → main 4 commits fork

  • Merged. scout/7571-builder-slots-serve-the-roadmap-first-te was merged into main as cc75906.

Review

Sign in to review this change.
9 files changed +413 −6
docs/CREW.md +11 −1
@@ -289,7 +289,17 @@ the planner's, found done on main and waiting for a person to close it
289289 (`done_on_main`, never re-planned), stalled, at its run cap, parked twice, covered by a red-main
290290 fix, or a setting's finding. The flow panel on `/boards` draws the same per autonomous app as one
291291 bar, and the terminal's Flow command prints it as a line per app
292 (`flow/held.ts`, kept 5 s). When nothing is startable while builder slots are free and some of the
292 (`flow/held.ts`, kept 5 s). Of the startable, what the owner raised again
293 stays on top, then the roadmap is picked
294 (#7571, `crew/small_share.ts`): an issue under an open epic or a P0/P1 pillar,
295 then other work, then small issues — a title like "A test pins …" or
296 "Tests: …", or a label `test`, `tests`, `tests-only` or `small` — and small issues hold
297 at most `slots.smallShare` percent of the builder slots (default 30, rounded up:
298 2 of 5; an admin sets it on `/settings/capacity`). At the cap a small issue is
299 skipped, not waited on, raised or not. The tick line's `small: 1/2`, the
300 Flow command's `small 1/2`, the app's row in Today on `/boards` (`small
301 issues: 1 of 2 slots`) and the studio's crew panel show small issues in
302 flight of what they may hold. When nothing is startable while builder slots are free and some of the
293303 held are stalled, capped or parked twice, the planner is owed one pass with
294304 that breakdown in its brief, to re-plan them into smaller, fresh issues; a
295305 full plate and its own unanswered decline do not hold that pass, and the
src/core/capacity.ts +14 −1
@@ -24,7 +24,8 @@ export type CapacityKey =
2424 | "children.max"
2525 | "cells.egress"
2626 | "guard_new_accounts"
27 | "signin.edge";
27 | "signin.edge"
28 | "slots.smallShare";
2829
2930 interface Definition {
3031 /** What the settings page and the API call it. */
@@ -108,6 +109,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
108109 fallback: "on",
109110 help: "on reads the platform edge's identity headers (when the forge has an edge secret) and offers its sign-in; off ignores them on every door, so people sign in with the forge's own methods — password and passkey. Accounts the edge made keep their names but have no password until they set one. Takes effect on the next request.",
110111 },
112 "slots.smallShare": {
113 label: "Builder slots small issues may hold (%)",
114 env: "FORGE_SLOTS_SMALL_SHARE",
115 kind: { type: "count", min: 0, max: 100 },
116 fallback: "30",
117 help: "The most of a repository's builder slots that tests-only and small-addition issues (\"A test pins …\", or a label test, tests, tests-only or small) hold at once, as a percent rounded up: 30 lets 2 of 5 slots go to them. At the cap a small issue is skipped and the slot goes to roadmap work; roadmap work is picked first either way. 100 lifts the cap; 0 starts none. Read on every pick.",
118 },
111119 };
112120
113121 export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[];
@@ -331,6 +339,11 @@ export function queueBatch(db: Database): number {
331339 return Number(capacityValue(db, "queue.batch"));
332340 }
333341
342 /** The percent of builder slots small issues may hold (#7571). */
343 export function smallShare(db: Database): number {
344 return Number(capacityValue(db, "slots.smallShare"));
345 }
346
334347 /** Whether the platform edge's identity is read at all; on every request. */
335348 export function edgeSigninOn(db: Database): boolean {
336349 return capacityValue(db, "signin.edge") !== "off";
src/crew/autonomy.ts +35 −2
@@ -30,7 +30,9 @@ import { queueStore } from "../checks/queue_store.ts";
3030 import { addLearned } from "../checks/learned.ts";
3131 import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts";
3232 import { checksFor } from "../checks/service.ts";
33 import { queueBatch, queueDepth } from "../core/capacity.ts";
33 import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts";
34 import { roadmapIssues, shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
35 import { raisedAt } from "../core/thought_links.ts";
3436 import { readConflictLane, readConflictRoom } from "./conflict_room.ts";
3537 import { mergeable, resolveOid, treeOf } from "../git/merge.ts";
3638 import { CLOSED_NOTE } from "../core/done_on_main.ts";
@@ -368,6 +370,34 @@ function freeSlots(app: App, repo: Repo, now: Date): number {
368370 return Math.max(0, repoPace(app, repo, now).slots - slotted.length);
369371 }
370372
373 /** The pickable issues in slot order: the owner's raised on top, then the roadmap, small last and none past their share (#7571). */
374 function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number): Ranked[] {
375 const issues = ready.map((r) => r.issue);
376 const small = smallIssues(app.db, issues);
377 const roadmap = roadmapIssues(app.db, repo, issues.map((i) => i.id));
378 const raised = raisedAt(app.db, issues.map((i) => i.id));
379 const running = smallOfIds(app.db, slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])));
380 return slotOrder(
381 ready,
382 (r) => ({ raised: raised.has(r.issue.id), roadmap: roadmap.has(r.issue.id), small: small.has(r.issue.id) }),
383 running,
384 smallCap(slots, smallShare(app.db)),
385 );
386 }
387
388 /** Slotted runs on small issues, and the slots they may hold (#7571). */
389 export function smallShareOf(app: App, repo: Repo, now = new Date()): { running: number; cap: number } {
390 const slotted = slottedRuns(activeRuns(app, repo.id), listCrewRoles(app.db, repo.id));
391 const running = smallOfIds(app.db, slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])));
392 return { running, cap: smallCap(repoPace(app, repo, now).slots, smallShare(app.db)) };
393 }
394
395 /** `1/2`: {@link smallShareOf} as the tick line, the terminal and the studio print it. */
396 export function smallShareWords(app: App, repo: Repo, now = new Date()): string {
397 const { running, cap } = smallShareOf(app, repo, now);
398 return shareWords(running, cap);
399 }
400
371401 export type Drove = "off" | "paused" | "waiting" | "full" | "picked" | "planned" | "idle";
372402
373403 /**
@@ -439,7 +469,8 @@ export async function driveRepo(
439469 ready.filter((r) => crewFor(r.issue).some((c) => c.role === "builder")),
440470 )
441471 );
442 const next = await nextOf(app, repo, pickable);
472 // The roadmap first, and small issues held to their share of the slots (#7571).
473 const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, slotted, paced.slots));
443474 if (next !== undefined) {
444475 const routed = await routeIssue(app, repo, next.issue, crewFor(next.issue), inFlight);
445476 if (!routed) return "paused";
@@ -1338,6 +1369,8 @@ export async function autonomyTick(
13381369 slots: status.slots,
13391370 // Builders with nothing in flight: what bounds active beside slots and startable (#7006).
13401371 idle: idleBuilders(activeNow, crewNow),
1372 // Small issues in flight of the slots they may hold (#7571).
1373 small: smallShareWords(app, current, now),
13411374 ...(governor === undefined ? {} : { governor }),
13421375 // Approved conflicted pulls: queued, in flight, and why each other is
13431376 // skipped (#3682, #6237), or "0 candidates of N open" (#6263); and the lane for rebases: conflicted N,
src/crew/small_share.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * Builder slots serve the roadmap first (#7571). Cheap pulls — a test that
3 * pins something, a small terminal or MCP addition — are quick to start and
4 * quick to land, so left alone they fill every slot and the open-pull limit
5 * while the roadmap waits. Two rules keep them in their place:
6 *
7 * - **Pick order:** what the owner raised again stays on top, as the map
8 * ranks it (#654); then an issue under an open epic or a P0/P1 pillar,
9 * then other roadmap-sized work, then the small ones; each keeps the
10 * map's order among its own.
11 * - **A cap:** small issues hold at most `slots.smallShare` percent of the
12 * builder slots (rounded up, so one slot may still take one). At the cap a
13 * small issue is skipped, never waited on: the slot goes to other work or
14 * stays free.
15 *
16 * Small is read from the issue alone: a title like "A test pins …" or
17 * "Tests: …", or a label `test`, `tests`, `tests-only` or `small`. Plain data in,
18 * plain data out; the reads are two grouped queries whatever the count.
19 */
20
21 import type { Database } from "bun:sqlite";
22 import { labelsForIssues } from "../core/issues.ts";
23 import { isStructural, pillarsOf, servedBy } from "../core/pillars.ts";
24 import type { Repo } from "../core/repos.ts";
25
26 /** Titles that say an issue only pins a behaviour with a test, or is a small addition. */
27 const SMALL_TITLE = /^(a test (pins|proves|shows|names|covers)\b|tests?( only)?:|small:)/i;
28 /** Labels that say the same. */
29 const SMALL_LABELS = new Set(["test", "tests", "tests-only", "small"]);
30 /** The pillars whose work comes first. */
31 const FIRST_PILLAR = /^P[01] /;
32
33 /** Whether an issue's title or labels mark it tests-only or a small addition. */
34 export function isSmall(title: string, labels: readonly string[]): boolean {
35 return SMALL_TITLE.test(title.trim()) || labels.some((l) => SMALL_LABELS.has(l.toLowerCase()));
36 }
37
38 /** How many slots small issues may hold: `share` percent of `slots`, rounded up; 5 at 30% is 2. */
39 export function smallCap(slots: number, share: number): number {
40 if (slots <= 0 || share <= 0) return 0;
41 return Math.min(slots, Math.ceil((slots * Math.min(share, 100)) / 100));
42 }
43
44 /** `1/2`: small issues in flight of what they may hold; each door adds its own word around it. */
45 export function shareWords(running: number, cap: number): string {
46 return `${running}/${cap}`;
47 }
48
49 /** `small issues: 1 of 2 slots`, for the app's row in Today on /boards. */
50 export function smallIssuesWords(share: { readonly running: number; readonly cap: number }): string {
51 return `small issues: ${share.running} of ${share.cap} ${share.cap === 1 ? "slot" : "slots"}`;
52 }
53
54 export interface Classed {
55 /** Raised again by the owner: on top whatever else it is. */
56 readonly raised: boolean;
57 /** Under an open epic or a P0/P1 pillar. */
58 readonly roadmap: boolean;
59 readonly small: boolean;
60 }
61
62 /**
63 * The ready list in pick order: what the owner raised again first, then
64 * roadmap work, then other, small work last, the map's order kept within
65 * each; small work — raised or not — is left out once `running` small
66 * issues fill `cap`.
67 */
68 export function slotOrder<T>(
69 ready: readonly T[],
70 classOf: (t: T) => Classed,
71 running: number,
72 cap: number,
73 ): T[] {
74 const rank = (t: T) => {
75 const c = classOf(t);
76 return { r: c.raised ? -1 : c.small ? 2 : c.roadmap ? 0 : 1, small: c.small };
77 };
78 const open = running < cap;
79 return ready
80 .map((t, at) => ({ t, at, ...rank(t) }))
81 .filter((x) => open || !x.small)
82 .sort((a, b) => a.r - b.r || a.at - b.at)
83 .map((x) => x.t);
84 }
85
86 /** Which of these issues are small, from their titles and labels. */
87 export function smallIssues(
88 db: Database,
89 issues: readonly { readonly id: string; readonly title: string }[],
90 ): Set<string> {
91 const labels = labelsForIssues(db, issues.map((i) => i.id));
92 return new Set(
93 issues
94 .filter((i) => isSmall(i.title, (labels.get(i.id) ?? []).map((l) => l.name)))
95 .map((i) => i.id),
96 );
97 }
98
99 /** Of these issue ids, the small ones: what the slotted runs are working on. */
100 export function smallOfIds(db: Database, ids: readonly string[]): number {
101 const unique = [...new Set(ids)];
102 if (unique.length === 0) return 0;
103 const rows = db
104 .query<{ id: string; title: string }, string[]>(
105 `SELECT id, title FROM issue WHERE id IN (${unique.map((_, n) => `?${n + 1}`).join(",")})`,
106 )
107 .all(...unique);
108 return smallIssues(db, rows).size;
109 }
110
111 /**
112 * Which of these issues are roadmap work: served by a P0 or P1 pillar, or
113 * waited for by an open epic — an open issue other than the plan's furniture.
114 */
115 export function roadmapIssues(db: Database, repo: Repo, ids: readonly string[]): Set<string> {
116 const out = new Set<string>();
117 if (ids.length === 0) return out;
118 const set = pillarsOf(db, repo);
119 const served = servedBy(db, set);
120 for (const id of ids) if (FIRST_PILLAR.test(served.get(id) ?? "")) out.add(id);
121 const rows = db
122 .query<{ id: string; parent: string }, string[]>(
123 `SELECT d.blocked_by_id AS id, d.issue_id AS parent
124 FROM issue_dependency d JOIN issue i ON i.id = d.issue_id
125 WHERE i.state = 'open' AND d.blocked_by_id IN (${ids.map((_, n) => `?${n + 1}`).join(",")})`,
126 )
127 .all(...ids);
128 for (const row of rows) if (!isStructural(set, row.parent)) out.add(row.id);
129 return out;
130 }
src/flow/held.ts +4
@@ -10,6 +10,7 @@
1010
1111 import type { App } from "../app.ts";
1212 import { listAutonomousRepos } from "../core/repos.ts";
13 import { smallShareWords } from "../crew/autonomy.ts";
1314 import { heldCounts, heldNow, type HoldName } from "../crew/held.ts";
1415
1516 export interface HeldRow {
@@ -18,6 +19,8 @@ export interface HeldRow {
1819 readonly public: boolean;
1920 readonly startable: number;
2021 readonly counts: Readonly<Record<HoldName, number>>;
22 /** `1/2`: small issues in flight of the builder slots they may hold (#7571). */
23 readonly small: string;
2124 }
2225
2326 /** How long the rows are kept. */
@@ -38,6 +41,7 @@ export function readHeld(app: App, owner: boolean, now = Date.now()): HeldRow[]
3841 public: repo.visibility === "public",
3942 startable: held.startable.length,
4043 counts: heldCounts(held),
44 small: smallShareWords(app, repo),
4145 };
4246 }),
4347 };
src/tui/screens/flow.ts +2 −1
@@ -197,7 +197,7 @@ export function openerLines(rows: readonly Opener[], inner: number): string[] {
197197 return rows.length > 5 ? [...lines, `and ${rows.length - 5} more`] : lines;
198198 }
199199
200 /** One line per autonomous repository: its name, then `startable=N` and each filter holding any. */
200 /** One line per autonomous repository: its name, then `startable=N`, each filter holding any, and `small 1/2` (#7571). */
201201 export function heldLines(rows: readonly HeldRow[], inner: number): string[] {
202202 if (rows.length === 0) return ["Ready, and why it waits: no repository runs on its own."];
203203 return [
@@ -208,6 +208,7 @@ export function heldLines(rows: readonly HeldRow[], inner: number): string[] {
208208 r.repo,
209209 `startable=${r.startable}`,
210210 ...HOLDS.filter((h) => r.counts[h] > 0).map((h) => `${h}=${r.counts[h]}`),
211 `small ${r.small}`,
211212 ].join(" "),
212213 inner,
213214 ),
src/web/pages/board.ts +4
@@ -71,8 +71,10 @@ import {
7171 autonomyStatus,
7272 holdWords,
7373 plannerHold,
74 smallShareOf,
7475 startOfTodayUtc,
7576 } from "../../crew/autonomy.ts";
77 import { smallIssuesWords } from "../../crew/small_share.ts";
7678 import { distinct, notesRegion, type Note } from "./board-notes.ts";
7779 import type { Ctx } from "../../http/context.ts";
7880 import { repoBase, signInFirst } from "../chrome.ts";
@@ -991,6 +993,8 @@ function today(ctx: Ctx, scope: BoardScope): Html {
991993 <span><b>${shipped}</b> shipped</span>`,
992994 `${perShippedWords(week.get(repo.id))} this week`,
993995 holdLine,
996 // Small issues in flight of the slots they may hold (#7571).
997 repo.autonomy === "off" ? null : smallIssuesWords(smallShareOf(ctx.app, repo, now)),
994998 review,
995999 ],
9961000 under: meter(
src/web/studio/crew.ts +3 −1
@@ -30,7 +30,7 @@ import { CREW_ROLES, listCrewRoles, type CrewRole } from "../../core/agents.ts";
3030 import { getAppByRepo } from "../../core/apps.ts";
3131 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3232 import { crewHasCredential } from "../../core/subscriptions.ts";
33 import { autonomyStatus } from "../../crew/autonomy.ts";
33 import { autonomyStatus, smallShareWords } from "../../crew/autonomy.ts";
3434 import { governorSeen } from "../../crew/governed.ts";
3535 import { crewOffInPreview } from "../../crew/preview.ts";
3636 import type { Ctx } from "../../http/context.ts";
@@ -277,6 +277,8 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html
277277 </dd>
278278 <dt>Slots</dt>
279279 <dd>${status.active} of ${status.slots} in use</dd>
280 <dt>Small issues</dt>
281 <dd>${smallShareWords(ctx.app, repo)} slots</dd>
280282 ${governor === undefined
281283 ? null
282284 : html`<dt>Governor</dt>
test/crew-small-share.test.ts added +210
@@ -0,0 +1,210 @@
1 /**
2 * Builder slots serve the roadmap first (#7571): small issues — tests-only
3 * or small additions — hold at most `slots.smallShare` of the slots, and an
4 * issue under an open epic starts before a small one.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { enableApp } from "../src/actions/apps.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { linkIssue } from "../src/actions/dependencies.ts";
14 import { openIssue } from "../src/actions/issues.ts";
15 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
16 import { smallShare } from "../src/core/capacity.ts";
17 import { getIssueById, type Issue } from "../src/core/issues.ts";
18 import { getRepoById, type Repo } from "../src/core/repos.ts";
19 import { createRun } from "../src/core/runs.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { driveRepo, smallShareWords } from "../src/crew/autonomy.ts";
22 import { isSmall, shareWords, slotOrder, smallCap, smallIssuesWords } from "../src/crew/small_share.ts";
23 import { readHeld } from "../src/flow/held.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { heldLines } from "../src/tui/screens/flow.ts";
26
27 describe("the rules", () => {
28 test("a test that pins something, or a tests/small label, is small; a feature is not", () => {
29 expect(isSmall("A test pins the merge queue's batch size", [])).toBe(true);
30 expect(isSmall("Tests: cover the people screen", [])).toBe(true);
31 expect(isSmall("Add a terminal shortcut", ["small"])).toBe(true);
32 expect(isSmall("Search notes by word", ["feature"])).toBe(false);
33 });
34
35 test("5 slots at 30% hold 2 small; 1 slot still holds 1; 0% holds none", () => {
36 expect(smallCap(5, 30)).toBe(2);
37 expect(smallCap(1, 30)).toBe(1);
38 expect(smallCap(5, 0)).toBe(0);
39 expect(smallCap(5, 100)).toBe(5);
40 });
41
42 test("roadmap first, small last, and small left out at the cap", () => {
43 const c = (t: string) => ({ raised: false, roadmap: t.startsWith("r"), small: t.startsWith("s") });
44 expect(slotOrder(["s1", "o1", "r1", "s2", "r2"], c, 0, 2)).toEqual(["r1", "r2", "o1", "s1", "s2"]);
45 expect(slotOrder(["s1", "o1", "r1"], c, 2, 2)).toEqual(["r1", "o1"]);
46 });
47
48 test("what the owner raised again stays on top, in the map's order; a raised small one still keeps to the cap", () => {
49 const c = (t: string) => ({ raised: t.endsWith("!"), roadmap: t.startsWith("r"), small: t.startsWith("s") });
50 expect(slotOrder(["r1", "o1!", "s1!", "r2"], c, 0, 2)).toEqual(["o1!", "s1!", "r1", "r2"]);
51 expect(slotOrder(["r1", "o1!", "s1!"], c, 2, 2)).toEqual(["o1!", "r1"]);
52 });
53
54 test("each label the docs name is small, and the words read as the doors print them", () => {
55 for (const label of ["test", "tests", "tests-only", "small"]) expect(isSmall("Anything", [label])).toBe(true);
56 expect(shareWords(1, 2)).toBe("1/2");
57 expect(smallIssuesWords({ running: 1, cap: 2 })).toBe("small issues: 1 of 2 slots");
58 });
59 });
60
61 let dataDir: string;
62 let app: App;
63 let alice: User;
64 let repo: Repo;
65 let servers: Servers;
66 let cookie: string;
67
68 const get = (path: string) =>
69 fetch(`${servers.url}${path}`, { headers: { cookie: `${cookie}; forge_mode=studio` }, redirect: "manual" });
70
71 /** Sets `slots.smallShare` as an admin does, on /settings/capacity. */
72 const setShare = (value: string) =>
73 fetch(`${servers.url}/settings/capacity`, {
74 method: "POST",
75 redirect: "manual",
76 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
77 body: new URLSearchParams({ key: "slots.smallShare", value }),
78 });
79
80 const fresh = () => getRepoById(app.db, repo.id)!;
81
82 /** Every issue opened here, for a drive that may pick any of them. */
83 const opened: Issue[] = [];
84
85 function open(title: string): Issue {
86 const made = openIssue(app, alice, fresh(), { title });
87 if (!made.ok) throw new Error(String(made.error));
88 opened.push(made.value);
89 return made.value;
90 }
91
92 /** Drives once; a pick gets the run its assignment would start, so it holds a slot. */
93 async function drive(issues: readonly Issue[]): Promise<Issue | undefined> {
94 const before = new Set(issues.filter((i) => getIssueById(app.db, i.id)!.assigneeId !== null).map((i) => i.id));
95 if ((await driveRepo(app, fresh())) !== "picked") return undefined;
96 const picked = issues.map((i) => getIssueById(app.db, i.id)!).find((i) => i.assigneeId !== null && !before.has(i.id));
97 if (picked === undefined) return undefined;
98 createRun(app.db, { repoId: repo.id, kind: "agent", issueId: picked.id, actorId: picked.assigneeId ?? undefined, reason: "assigned" });
99 return picked;
100 }
101
102 beforeAll(async () => {
103 dataDir = await mkdtemp(join(tmpdir(), "forge-small-share-"));
104 app = createApp({ dataDir, port: 0 });
105 const a = await createUser(app.db, {
106 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
107 password: "a-good-passphrase", adminIfFirst: true,
108 });
109 if (!a.ok) throw new Error(a.error);
110 alice = a.value;
111 const made = await createRepo(app, alice, { name: "notes", visibility: "private" });
112 if (!made.ok) throw new Error(String(made.error));
113 repo = made.value;
114 const first = await commitToBranch(app, alice, repo, {
115 message: "Start",
116 files: [
117 { path: "package.json", content: `{"name":"notes","scripts":{"start":"bun run server.ts"}}\n` },
118 { path: "server.ts", content: "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n" },
119 ],
120 });
121 if (!first.ok) throw new Error(JSON.stringify(first.error));
122 // An app, so /board's Today has a row for it.
123 const enabled = await enableApp(app, alice, repo, { slug: "notes" });
124 if (!enabled.ok) throw new Error(String(enabled.error));
125 for (const name of ["b1", "b2", "b3", "b4", "b5"]) {
126 const agent = await createAgent(app, alice, { username: name });
127 if (!agent.ok) throw new Error(String(agent.error));
128 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
129 }
130 const on = await updateRepoSettings(app, alice, repo, {
131 autonomy: "autonomous",
132 autonomyDirection: "Keep the notes tidy.",
133 autonomyCeilingUsd: 10,
134 autonomySlots: 5,
135 autonomyBy: alice.id,
136 });
137 expect(on.ok).toBe(true);
138 servers = await serve(app);
139 const signin = await fetch(`${servers.url}/signin`, {
140 method: "POST",
141 redirect: "manual",
142 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
143 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
144 });
145 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
146 });
147
148 afterAll(async () => {
149 servers.stop();
150 app.close();
151 await rm(dataDir, { recursive: true, force: true });
152 });
153
154 test("with 5 slots and only small issues ready, at most 2 start; then an epic's issue starts before a small one", async () => {
155 expect(smallShare(app.db)).toBe(30);
156 const small = ["A test pins the title", "A test pins the body", "A test pins the date", "A test pins the tags"].map(open);
157 const started: Issue[] = [];
158 for (let n = 0; n < 5; n++) {
159 const picked = await drive(small);
160 if (picked !== undefined) started.push(picked);
161 }
162 expect(started.length).toBe(2);
163 expect(smallShareWords(app, fresh())).toBe("2/2");
164
165 // Room is made for one more small one, and an epic's issue arrives beside it.
166 const epic = open("Notes search");
167 const child = open("Search notes by word");
168 expect(linkIssue(app, alice, fresh(), epic, child).ok).toBe(true);
169 app.db.query("UPDATE run SET state = 'ok' WHERE issue_id = ?1").run(started[0]!.id);
170 const next = await drive([...small, child]);
171 expect(next?.id).toBe(child.id);
172 });
173
174 test("one small issue of two slots shows on /board's Today, the studio's crew panel and the terminal's Flow", async () => {
175 expect(smallShareWords(app, fresh())).toBe("1/2");
176 const board = await (await get("/board")).text();
177 const today = board.slice(board.indexOf('id="today"'));
178 expect(today).toContain("small issues: 1 of 2 slots");
179 const studio = await (await get("/alice/notes/settings")).text();
180 expect(studio).toMatch(/<dt>Small issues<\/dt>\s*<dd>1\/2 slots<\/dd>/);
181 const flow = heldLines(readHeld(app, true), 200).join("\n");
182 expect(flow).toMatch(/^alice\/notes startable=\d+.* small 1\/2$/m);
183 });
184
185 test("what the owner raised again starts before an epic's issue", async () => {
186 const plain = open("Rename a note");
187 const epic = open("Notes export");
188 const child = open("Export notes as text");
189 expect(linkIssue(app, alice, fresh(), epic, child).ok).toBe(true);
190 app.db.query("INSERT INTO issue_raise (issue_id, raised_at) VALUES (?1, ?2)").run(plain.id, new Date().toISOString());
191 expect((await drive([plain, child]))?.id).toBe(plain.id);
192 expect((await drive([plain, child]))?.id).toBe(child.id);
193 });
194
195 test("slots.smallShare set on /settings/capacity changes the pick: 0 starts no small issue, 100 does; out of range is refused", async () => {
196 const small = open("A test pins the colour");
197 expect((await setShare("0")).status).toBe(303);
198 expect(smallShare(app.db)).toBe(0);
199 expect(await drive(opened)).toBeUndefined();
200 expect(getIssueById(app.db, small.id)!.assigneeId).toBeNull();
201 expect((await setShare("100")).status).toBe(303);
202 expect(smallShare(app.db)).toBe(100);
203 const picked = await drive(opened);
204 expect(picked).toBeDefined();
205 expect(isSmall(picked!.title, [])).toBe(true);
206 for (const value of ["101", "-1", "half"]) {
207 await setShare(value);
208 expect(smallShare(app.db)).toBe(100);
209 }
210 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs