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

Raise or lower a limb against its siblings, and the crew takes ready work in that order #1212

Merged opened this

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

Merged

scout/forge:scout/1064-raise-or-lower-a-limb-against-its-siblin → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/1064-raise-or-lower-a-limb-against-its-siblin was merged into main as 516d975.

Review

Sign in to review this change.
15 files changed +1056 −82
docs/PLANNING.md +15 −3
@@ -307,14 +307,26 @@ one undo.
307307 - **Close as not doing** closes every open issue that is the limb's own,
308308 the limb included, as `dropped`, with the reason said on each. What other
309309 work outside the limb also waits for is not the limb's to close.
310 - **Raise** and **Lower** (#1064) move the limb one place among its
311 siblings — the pillars and Later at the top, a milestone's limbs under
312 it. The place is stored per group of siblings (`limb_place`), kept across
313 a restart, and is the order the limbs are listed in; a limb never placed
314 follows the placed ones, by number. The preview shows the new order and
315 how the top of the ready list changes. Once a person has set an order in
316 a repository, the crew's ready order follows it: an issue beneath a
317 higher limb comes before one beneath a lower limb (beneath two limbs, the
318 higher), after them come issues beneath no limb, and within a limb the
319 order is the old one — what it frees, then age. Until then nothing about
320 the ready order changes. It is one fleet row, the verb `place`; undo puts
321 the place back. Through the API and the MCP `limb` tool (`action` raise or
322 lower, `under` when the limb sits in several places), dry-run by default.
310323
311324 An umbrella or the gate is the plan's own and is not filed. A leaf beneath
312325 a limb that is not deferred cannot be deferred alone: defer the limb. A
313 candidate to trim offers Defer first. Raising and lowering a limb against
314 its siblings is #760; handing one to the planner is #761.
326 candidate to trim offers Defer first. Handing one to the planner is #761.
315327
316328 The same is `POST /api/v1/repos/:owner/:name/milestones/:number/limb`
317 `{ "action": "defer" | "resume" | "close", "reason"?, "pillar"?, "dryRun"? }`
329 `{ "action": "defer" | "resume" | "close" | "raise" | "lower", "reason"?, "pillar"?, "under"?, "dryRun"? }`
318330 and the MCP's `limb`, both a dry run unless `dryRun` is false. Applying and
319331 undoing take an admin token of a person who administers the repository; an
320332 agent may look and never apply.
scripts/gate.ts +1
@@ -178,6 +178,7 @@ export const GATE: readonly string[] = [
178178 "test/issues.test.ts",
179179 "test/kernel.test.ts",
180180 "test/keys.test.ts",
181 "test/limb_order.test.ts",
181182 "test/limbs.test.ts",
182183 "test/live-pages.test.ts",
183184 "test/live.test.ts",
src/actions/fleet.ts +12
@@ -34,6 +34,7 @@ import {
3434 } from "../core/issues.ts";
3535 import { listCrewRoles } from "../core/agents.ts";
3636 import { joinMany } from "../core/pulls.ts";
37 import { seating, type Seating } from "../core/limb_seats.ts";
3738 import { pillarsOf, type Pillars } from "../core/pillars.ts";
3839 import {
3940 canAdmin,
@@ -178,6 +179,16 @@ function loadTargets(
178179 const pillars = new Map<string, Pillars>();
179180 for (const [repoId, repo] of repos) pillars.set(repoId, pillarsOf(app.db, repo));
180181 const under = umbrellasOver(app, ids, [...pillars.values()]);
182 // Read on the first ask, once for a repository: only `place` wants it.
183 const seatings = new Map<string, Seating>();
184 const seatingOf = (repo: Repo) => () => {
185 let seats = seatings.get(repo.id);
186 if (seats === undefined) {
187 seats = seating(app.db, repo);
188 seatings.set(repo.id, seats);
189 }
190 return seats;
191 };
181192 const peers: ReadonlySet<string> = new Set(ids);
182193 const pull = (id: string) => {
183194 const p = pulls.get(id);
@@ -207,6 +218,7 @@ function loadTargets(
207218 planner: planners.get(repo.id) ?? null,
208219 pillars: pillars.get(repo.id)!,
209220 under: under.get(id) ?? [],
221 seating: seatingOf(repo),
210222 peers,
211223 ...(viewer === undefined
212224 ? {}
src/actions/limbs.ts +82 −1
@@ -16,6 +16,11 @@
1616 * one reason said on each — and never what something outside the limb
1717 * also waits for.
1818 *
19 * - `raise` and `lower` move the limb one place among its siblings (#1064),
20 * and the crew takes ready work in that order. One row: the limb, holding
21 * its seat (`core/limb_seats.ts`). The preview shows the new order and how
22 * the top of the ready list changes.
23 *
1924 * Who may is the fleet's rule, unchanged: a person who administers the
2025 * repository; an agent may preview and never apply.
2126 */
@@ -23,6 +28,9 @@
2328 import type { App } from "../app.ts";
2429 import type { FleetValue, Operation } from "../core/fleet_ops.ts";
2530 import type { Issue } from "../core/issues.ts";
31 import { readyNow } from "../core/dependencies.ts";
32 import { limbRanks, withGroup } from "../core/limb_order.ts";
33 import { parseSeat, seating } from "../core/limb_seats.ts";
2634 import { readPlan } from "../core/milestones.ts";
2735 import { isStructural, pillarsOf } from "../core/pillars.ts";
2836 import { canRead, type Repo } from "../core/repos.ts";
@@ -35,7 +43,7 @@ import {
3543 type Plan,
3644 } from "./fleet.ts";
3745
38 export const LIMB_ACTIONS = ["defer", "resume", "close"] as const;
46 export const LIMB_ACTIONS = ["defer", "resume", "close", "raise", "lower"] as const;
3947 export type LimbAction = (typeof LIMB_ACTIONS)[number];
4048
4149 export const isLimbAction = (value: unknown): value is LimbAction =>
@@ -47,6 +55,8 @@ export interface LimbRequest {
4755 readonly reason?: string;
4856 /** For `resume`: the pillar to put it back under; absent leaves it under none. */
4957 readonly pillar?: string;
58 /** For `raise` and `lower`: among whose limbs, `top` or a milestone's number; absent when the limb sits in one place. */
59 readonly under?: string;
5060 }
5161
5262 /** An issue an action touches or takes along, as a person reads it. */
@@ -62,8 +72,17 @@ export interface LimbReach {
6272 readonly taken: readonly LimbIssue[];
6373 /** For `defer` and `close`: what beneath it stays, because something outside the limb still waits for it. */
6474 readonly kept: readonly LimbIssue[];
75 /** For `raise` and `lower`: the limbs of that group as they would stand, and the top of the ready list before and after. */
76 readonly order?: readonly LimbIssue[];
77 readonly ready?: {
78 readonly before: readonly LimbIssue[];
79 readonly after: readonly LimbIssue[];
80 };
6581 }
6682
83 /** How many ready issues the preview shows: the top of the list. */
84 const TOP_READY = 5;
85
6786 const brief = (issue: Issue): LimbIssue => ({
6887 number: issue.number,
6988 title: issue.title,
@@ -72,6 +91,66 @@ const brief = (issue: Issue): LimbIssue => ({
7291 const fail = (code: FleetError["code"], message: string) =>
7392 err<FleetError>({ code, message });
7493
94 /** One step up or down among the limb's siblings, with the order and the ready list it makes. */
95 function move(
96 app: App,
97 repo: Repo,
98 limb: Issue,
99 request: LimbRequest,
100 where: string,
101 ): Result<
102 { query: string; action: { verb: string; value: FleetValue }; reach: LimbReach },
103 FleetError
104 > {
105 const seats = seating(app.db, repo);
106 const held = seats.of(limb.id).flatMap((s) => parseSeat(s) ?? []);
107 const under = request.under?.trim().toLowerCase();
108 if (under !== undefined && under !== "top" && !/^[1-9][0-9]{0,8}$/.test(under))
109 return fail("bad_action", "under is top, or the number of a milestone");
110 const seat =
111 under === undefined
112 ? held.length === 1
113 ? held[0]
114 : undefined
115 : held.find((s) => s.group === under);
116 if (seat === undefined)
117 return fail(
118 "bad_action",
119 held.length === 0 || under !== undefined
120 ? "it is not one of the limbs of a milestone, so it has no place to move"
121 : "it is a limb of several milestones: say which with under",
122 );
123 const to = seat.place + (request.action === "raise" ? -1 : 1);
124 const size = seats.size(seat.group);
125 if (to < 1) return fail("bad_action", "it is first already");
126 if (to > size) return fail("bad_action", "it is last already");
127 const ids = [...seats.order(seat.group)];
128 ids.splice(seat.place - 1, 1);
129 ids.splice(to - 1, 0, limb.id);
130 const group = seats.parentId(seat.group)!;
131 const after = readyNow({
132 ...seats.graph,
133 limbRank: limbRanks(seats.tree, withGroup(seats.places, group, ids)),
134 });
135 const byId = new Map(seats.graph.issues.map((i) => [i.id, i]));
136 return ok({
137 query: `${where} number:${limb.number}`,
138 action: { verb: "place", value: `${seat.group}:${to}` },
139 reach: {
140 limb: brief(limb),
141 taken: [],
142 kept: [],
143 order: ids.flatMap((id) => byId.get(id) ?? []).map(brief),
144 ready: {
145 before: readyNow(seats.graph)
146 .slice(0, TOP_READY)
147 .map((r) => brief(r.issue)),
148 after: after.slice(0, TOP_READY).map((r) => brief(r.issue)),
149 },
150 },
151 });
152 }
153
75154 /** The fleet operation a limb action is, and what it reaches. */
76155 function shape(
77156 app: App,
@@ -93,6 +172,8 @@ function shape(
93172 return fail("not_found", "no such milestone here");
94173 const limb = brief(rows.own[0]);
95174 const where = `issue repo:${repo.ownerName}/${repo.name}`;
175 if (request.action === "raise" || request.action === "lower")
176 return move(app, repo, rows.own[0], request, where);
96177 if (request.action === "close") {
97178 const reason = (request.reason ?? "").trim();
98179 if (reason === "")
src/core/dependencies.ts +25 −1
@@ -14,6 +14,7 @@ import { getIssuesByIds, type CloseReason, type Issue } from "./issues.ts";
1414 import { pillarsOf, structuralIds } from "./pillars.ts";
1515 import { getRepoById } from "./repos.ts";
1616 import { unadoptedAmong } from "../crew/direct.ts";
17 import { limbRanks, limbTree, readPlaces } from "./limb_order.ts";
1718
1819 export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue";
1920
@@ -215,6 +216,12 @@ export interface Graph {
215216 * words, as every surface says it (`deferredWhy`).
216217 */
217218 readonly deferred: ReadonlyMap<string, string>;
219 /**
220 * Where each issue stands in the order of the limbs (#1064), the first
221 * limb that holds it, lower first. Only when a person has set an order
222 * here; absent, the ready order is the one that never knew limbs.
223 */
224 readonly limbRank?: ReadonlyMap<string, number>;
218225 }
219226
220227 /**
@@ -318,7 +325,16 @@ export function repoGraph(
318325 );
319326 const repo = getRepoById(db, repoId);
320327 const set = repo === undefined ? undefined : pillarsOf(db, repo);
328 const places = readPlaces(db, repoId);
321329 return {
330 ...(set === undefined || places.size === 0

This gate is what undo has to bring back. Today it only flips one way: once a group is stored, limbRank is on for the repository even after every move is undone.

331 ? {}
332 : {
333 limbRank: limbRanks(
334 limbTree({ issues, edges: kept }, set),
335 places,
336 ),
337 }),
322338 issues,
323339 unadopted:
324340 repo === undefined ? new Set() : unadoptedAmong(db, repo, issues),
@@ -509,14 +525,22 @@ function clearNow(graph: Graph): Ranked[] {
509525 * reader's suggestion only once someone who writes here adopted it, and
510526 * never the plan's own furniture: an umbrella or the Later gate is not
511527 * work. What a free crew slot takes next.
528 *
529 * Where a person has ordered the limbs (`limb_order.ts`), an issue beneath
530 * a higher limb comes before one beneath a lower limb, and one beneath no
531 * limb after them all; within a limb the order is the one above.
512532 */
513533 export function readyNow(graph: Graph): Ranked[] {
514 return clearNow(graph).filter(
534 const ready = clearNow(graph).filter(
515535 (r) =>
516536 !graph.unadopted.has(r.issue.id) &&
517537 !graph.structural.has(r.issue.id) &&
518538 !graph.deferred.has(r.issue.id),
519539 );
540 const rank = graph.limbRank;
541 if (rank === undefined) return ready;
542 const of = (r: Ranked) => rank.get(r.issue.id) ?? Number.MAX_SAFE_INTEGER;
543 return ready.sort((a, b) => of(a) - of(b));
520544 }
521545
522546 /**
src/core/limb_order.ts added +213
@@ -0,0 +1,213 @@
1 /**
2 * The order of the limbs (#1064): which of a milestone's limbs comes first,
3 * set by a person, and the crew's ready order that follows it.
4 *
5 * `limbTree` reads the shape once from a graph: what each issue waits for
6 * with the plan's furniture left out, the top of the plan, and which
7 * children are limbs (they bring a tree of their own) rather than leaves.
8 * A person's places are stored per group of siblings — `TOP` for the top of
9 * the plan, else the issue whose limbs they are — and a limb with no place
10 * of its own follows the placed ones, by number. `limbRanks` walks the
11 * ordered tree once: an issue's rank is the first limb that holds it, so one
12 * beneath two limbs takes the higher. Nothing here queries per issue.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import type { Graph } from "./dependencies.ts";
17 import type { Issue } from "./issues.ts";
18 import { isStructural, type Pillars } from "./pillars.ts";
19
20 /** The group of siblings at the top of the plan. */
21 export const TOP = "";
22
23 /** Stored places: group → limb → place (1 is first). */
24 export type Places = ReadonlyMap<string, ReadonlyMap<string, number>>;
25
26 export interface LimbTree {
27 /** Every issue that waits for something beyond the furniture, and what for. */
28 readonly waitsFor: ReadonlyMap<string, readonly string[]>;
29 readonly waitedBy: ReadonlyMap<string, readonly string[]>;
30 /** Issues that wait for anything at all, the furniture included. */
31 readonly waits: ReadonlySet<string>;
32 /** The top-level limbs in their natural order: the pillars and Later, or the plan's roots. */
33 readonly top: readonly Issue[];
34 /** What an issue waits for directly, as its milestone shows it. */
35 childrenOf(id: string): readonly string[];
36 /** The children that bring a tree of their own, by number. */
37 limbsOf(id: string): readonly string[];
38 /** Every issue that has children. */
39 parents(): readonly string[];
40 }
41
42 export function limbTree(
43 graph: Pick<Graph, "issues" | "edges">,
44 set: Pillars,
45 ): LimbTree {
46 const byId = new Map(graph.issues.map((i) => [i.id, i]));
47 const waitsFor = new Map<string, string[]>();
48 const waitedBy = new Map<string, string[]>();
49 const waits = new Set<string>();
50 for (const edge of graph.edges) {
51 waits.add(edge.issueId);
52 if (isStructural(set, edge.blockedById)) continue;
53 waitsFor.set(edge.issueId, [
54 ...(waitsFor.get(edge.issueId) ?? []),
55 edge.blockedById,
56 ]);
57 waitedBy.set(edge.blockedById, [
58 ...(waitedBy.get(edge.blockedById) ?? []),
59 edge.issueId,
60 ]);
61 }
62 // Work behind the gate is Later's whether or not the Later umbrella waits
63 // for it: its milestone counts what the map's Later row counts.
64 const behindGate =
65 set.gate === undefined
66 ? []
67 : graph.edges
68 .filter(
69 (e) =>
70 e.blockedById === set.gate!.id && !isStructural(set, e.issueId),
71 )
72 .map((e) => e.issueId);
73 if (set.later !== undefined && behindGate.length > 0) waits.add(set.later.id);
74 const childrenOf = (id: string): readonly string[] =>
75 id === set.later?.id
76 ? [...new Set([...(waitsFor.get(id) ?? []), ...behindGate])]
77 : (waitsFor.get(id) ?? []);
78 const top: Issue[] =
79 set.pillars.length > 0
80 ? [
81 ...set.pillars.map((p) => p.umbrella),
82 ...(set.later === undefined ? [] : [set.later]),
83 ]
84 : graph.issues.filter(
85 (i) =>
86 i.state === "open" &&
87 childrenOf(i.id).length > 0 &&
88 !(waitedBy.get(i.id) ?? []).some(
89 (id) => byId.get(id)?.state === "open",
90 ),
91 );
92 const parents = [
93 ...new Set([
94 ...waitsFor.keys(),
95 ...(set.later !== undefined && behindGate.length > 0 ? [set.later.id] : []),
96 ]),
97 ];
98 return {
99 waitsFor,
100 waitedBy,
101 waits,
102 top,
103 childrenOf,
104 parents: () => parents,
105 limbsOf(id) {
106 const children = childrenOf(id)
107 .map((c) => byId.get(c))
108 .filter((i): i is Issue => i !== undefined)
109 .sort((a, b) => a.number - b.number);
110 // A child is a limb when it brings a tree of its own; one that only
111 // waits for its siblings is a leaf in an order.
112 const siblings = new Set(children.map((c) => c.id));
113 return children
114 .filter((c) => (waitsFor.get(c.id) ?? []).some((w) => !siblings.has(w)))
115 .map((c) => c.id);
116 },
117 };
118 }
119
120 /** Every stored place of one repository, in one read. */
121 export function readPlaces(db: Database, repoId: string): Places {
122 const out = new Map<string, Map<string, number>>();
123 for (const row of db
124 .query<{ parent_id: string; issue_id: string; place: number }, [string]>(
125 `SELECT parent_id, issue_id, place FROM limb_place WHERE repo_id = ?1`,
126 )
127 .all(repoId)) {
128 const group = out.get(row.parent_id) ?? new Map<string, number>();
129 group.set(row.issue_id, row.place);
130 out.set(row.parent_id, group);
131 }
132 return out;
133 }
134
135 /** One group of siblings in order: the placed ones by place, then the rest as they came. */
136 export function seated(
137 places: Places,
138 group: string,
139 ids: readonly string[],
140 ): string[] {
141 const mine = places.get(group);
142 if (mine === undefined) return [...ids];
143 const at = (id: string) => mine.get(id);
144 const placed = ids
145 .filter((id) => at(id) !== undefined)
146 .sort((a, b) => at(a)! - at(b)!);
147 return [...placed, ...ids.filter((id) => at(id) === undefined)];
148 }
149
150 /** Every group that has limbs, each in order. */
151 export function groupsOf(tree: LimbTree, places: Places): Map<string, string[]> {
152 const out = new Map<string, string[]>();
153 const top = tree.top.map((i) => i.id);
154 if (top.length > 0) out.set(TOP, seated(places, TOP, top));
155 for (const id of tree.parents()) {
156 const limbs = tree.limbsOf(id);
157 if (limbs.length > 0) out.set(id, seated(places, id, limbs));
158 }
159 return out;
160 }
161
162 /**
163 * Each issue's rank: the first limb, in the order of the limbs, that holds
164 * it. A lower rank is higher. Issues beneath no limb have none.
165 */
166 export function limbRanks(tree: LimbTree, places: Places): Map<string, number> {
167 const rank = new Map<string, number>();
168 const seen = new Set<string>();
169 let next = 0;
170 const visit = (id: string): void => {
171 if (seen.has(id)) return;
172 seen.add(id);
173 const n = next++;
174 if (!rank.has(id)) rank.set(id, n);
175 for (const child of tree.childrenOf(id)) if (!rank.has(child)) rank.set(child, n);
176 for (const limb of seated(places, id, tree.limbsOf(id))) visit(limb);
177 };
178 for (const id of seated(places, TOP, tree.top.map((i) => i.id))) visit(id);
179 return rank;
180 }
181
182 /** The places with one group set to this order, 1 for the first. */
183 export function withGroup(
184 places: Places,
185 group: string,
186 ids: readonly string[],
187 ): Places {
188 const next = new Map(places);
189 next.set(group, new Map(ids.map((id, n) => [id, n + 1])));
190 return next;
191 }
192
193 /** Keep one group's order. */
194 export function savePlaces(
195 db: Database,

High: this always writes rows, so moving a limb back to where it started (undo, or a raise followed by a lower) leaves limb_place non-empty, and the ready order stays limb-ranked for good. When ids equals the group's natural order (seated(new Map(), group, natural)), delete the group's rows and insert nothing.

196 repoId: string,
197 group: string,
198 ids: readonly string[],
199 ): void {
200 db.transaction(() => {
201 db.query(`DELETE FROM limb_place WHERE repo_id = ?1 AND parent_id = ?2`).run(
202 repoId,
203 group,
204 );
205 ids.forEach((id, n) =>
206 db
207 .query(
208 `INSERT INTO limb_place (repo_id, parent_id, issue_id, place) VALUES (?1, ?2, ?3, ?4)`,
209 )
210 .run(repoId, group, id, n + 1),
211 );
212 })();
213 }
src/core/limb_seats.ts added +104
@@ -0,0 +1,104 @@
1 /**
2 * A limb's seat (#1064): its place among its siblings, as a fleet row holds
3 * it. A seat is `<group>:<place>`, the group being `top` or the number of
4 * the issue whose limbs these are, the place counted from 1. A limb that is
5 * a limb of several issues holds one seat in each. Moving a limb rewrites
6 * its group's order whole, so the fleet's undo — the seat from before — puts
7 * the others back with it.
8 */
9
10 import { repoGraph, type Graph } from "./dependencies.ts";
11 import type { Database } from "bun:sqlite";
12 import {
13 groupsOf,
14 limbTree,
15 type LimbTree,
16 readPlaces,
17 savePlaces,
18 TOP,
19 type Places,
20 } from "./limb_order.ts";
21 import { pillarsOf } from "./pillars.ts";
22 import type { Repo } from "./repos.ts";
23
24 export interface Seating {
25 /** The seats an issue holds, `top` first, then by the number of the group. */
26 of(issueId: string): readonly string[];
27 /** How many limbs sit in a group (`top` or an issue number). */
28 size(group: string): number;
29 /** A group's limbs, in order, as issue ids. */
30 order(group: string): readonly string[];
31 /** The id of the issue a group names. */
32 parentId(group: string): string | undefined;
33 readonly places: Places;
34 /** What it was read from, for a caller that asks more of the same graph. */
35 readonly graph: Graph;
36 readonly tree: LimbTree;
37 }
38
39 const SEAT = /^(top|[1-9][0-9]{0,8}):([1-9][0-9]{0,5})$/;
40
41 /** A seat's group and place, or nothing when it is not one. */
42 export function parseSeat(
43 value: unknown,
44 ): { group: string; place: number } | undefined {
45 const m = typeof value === "string" ? SEAT.exec(value) : null;
46 return m === null ? undefined : { group: m[1]!, place: Number(m[2]) };
47 }
48
49 export function seating(
50 db: Database,
51 repo: Repo,
52 read?: { graph: Graph; tree: LimbTree; places: Places },
53 ): Seating {
54 const graph = read?.graph ?? repoGraph(db, repo.id);
55 const tree = read?.tree ?? limbTree(graph, pillarsOf(db, repo));
56 const places = read?.places ?? readPlaces(db, repo.id);
57 const groups = groupsOf(tree, places);
58 const numberOf = new Map(graph.issues.map((i) => [i.id, i.number]));
59 const idOf = new Map(graph.issues.map((i) => [String(i.number), i.id]));
60 const name = (id: string): string =>
61 id === TOP ? "top" : String(numberOf.get(id) ?? 0);
62 const keys = [...groups.keys()].sort((a, b) =>
63 a === TOP ? -1 : b === TOP ? 1 : (numberOf.get(a) ?? 0) - (numberOf.get(b) ?? 0),
64 );
65 const held = new Map<string, string[]>();
66 for (const key of keys)
67 groups.get(key)!.forEach((id, n) =>
68 held.set(id, [...(held.get(id) ?? []), `${name(key)}:${n + 1}`]),
69 );
70 const keyOf = (group: string): string =>
71 group === "top" ? TOP : (idOf.get(group) ?? "\0");
72 return {
73 of: (id) => held.get(id) ?? [],
74 size: (group) => groups.get(keyOf(group))?.length ?? 0,
75 order: (group) => groups.get(keyOf(group)) ?? [],
76 parentId: (group) => (group === "top" ? TOP : idOf.get(group)),
77 places,
78 graph,
79 tree,
80 };
81 }
82
83 /** Seat a limb at a place in its group; undefined when it sits there, else why not. */
84 export function moveLimb(
85 db: Database,
86 repo: Repo,
87 issueId: string,
88 seat: { group: string; place: number },
89 ): string | undefined {
90 const seats = seating(db, repo);
91 const ids = [...seats.order(seat.group)];
92 const at = ids.indexOf(issueId);
93 if (at < 0) return "it is not one of those limbs";
94 if (seat.place > ids.length) return `there are only ${ids.length} limbs there`;
95 ids.splice(at, 1);
96 ids.splice(seat.place - 1, 0, issueId);
97 const group = seats.parentId(seat.group)!;
98 // Back where it began is no order at all: the rows go, and the crew's
99 // ready order is the natural one again.
100 const natural = group === TOP ? seats.tree.top.map((i) => i.id) : seats.tree.limbsOf(group);
101 const same = ids.length === natural.length && ids.every((id, n) => id === natural[n]);
102 savePlaces(db, repo.id, group, same ? [] : ids);
103 return undefined;
104 }
src/core/milestones.ts +27 −56
@@ -15,6 +15,14 @@
1515
1616 import type { Database } from "bun:sqlite";
1717 import { deferredIn, repoGraph, type Graph } from "./dependencies.ts";
18 import {
19 limbTree,
20 readPlaces,
21 seated,
22 TOP,
23 type LimbTree,
24 type Places,
25 } from "./limb_order.ts";
1826 import type { Issue } from "./issues.ts";
1927 import {
2028 isStructural,
@@ -106,6 +114,8 @@ export interface Milestone extends Limb {
106114 }
107115
108116 export interface Plan {
117 /** What the plan was read from, for a caller that asks more of the same graph. */
118 readonly read: { readonly graph: Graph; readonly tree: LimbTree; readonly places: Places };
109119 /** The top-level limbs, in order: the pillars and Later, or the plan's roots. */
110120 readonly limbs: readonly Limb[];
111121 /**
@@ -165,39 +175,10 @@ export function readPlan(
165175 const byId = new Map(graph.issues.map((i) => [i.id, i]));
166176 const byNumber = new Map(graph.issues.map((i) => [i.number, i]));
167177
168 // What each issue waits for directly, furniture left out.
169 const waitsFor = new Map<string, string[]>();
170 const waitedBy = new Map<string, string[]>();
171 const waits = new Set<string>();
172 for (const edge of graph.edges) {
173 waits.add(edge.issueId);
174 if (isStructural(set, edge.blockedById)) continue;
175 waitsFor.set(edge.issueId, [
176 ...(waitsFor.get(edge.issueId) ?? []),
177 edge.blockedById,
178 ]);
179 waitedBy.set(edge.blockedById, [
180 ...(waitedBy.get(edge.blockedById) ?? []),
181 edge.issueId,
182 ]);
183 }
184 // Work behind the gate is Later's whether or not the Later umbrella waits
185 // for it: its milestone counts what the map's Later row counts.
186 const behindGate =
187 set.gate === undefined
188 ? []
189 : graph.edges
190 .filter(
191 (e) =>
192 e.blockedById === set.gate!.id && !isStructural(set, e.issueId),
193 )
194 .map((e) => e.issueId);
195 if (set.later !== undefined && behindGate.length > 0) waits.add(set.later.id);
196 /** What an issue waits for directly, as its milestone shows it. */
197 const childrenOf = (id: string): string[] =>
198 id === set.later?.id
199 ? [...new Set([...(waitsFor.get(id) ?? []), ...behindGate])]
200 : (waitsFor.get(id) ?? []);
178 // What each issue waits for directly, furniture left out (`limb_order.ts`).
179 const tree = limbTree(graph, set);
180 const places = readPlaces(db, repo.id);
181 const { waitsFor, waitedBy, waits, childrenOf } = tree;
201182 const memo = new Map<string, Set<string>>();
202183 const walking = new Set<string>();
203184 const beneath = (id: string): Set<string> => {
@@ -214,6 +195,11 @@ export function readPlan(
214195 memo.set(id, out);
215196 return out;
216197 };
198 /** Sibling limbs as issues, in the order a person set. */
199 const arranged = (group: string, ids: readonly string[]): Issue[] =>
200 seated(places, group, ids)
201 .map((id) => byId.get(id))
202 .filter((i): i is Issue => i !== undefined);
217203
218204 // Where the crew is: the issue a run is on, or the issue its pull closes.
219205 const proposed = openPullsClosing(db, repo.id);
@@ -354,24 +340,13 @@ export function readPlan(
354340 };
355341 };
356342
357 const isMilestone = (id: string): boolean => childrenOf(id).length > 0;
358
359 const top: Issue[] =
360 set.pillars.length > 0
361 ? [
362 ...set.pillars.map((p) => p.umbrella),
363 ...(set.later === undefined ? [] : [set.later]),
364 ]
365 : graph.issues.filter(
366 (i) =>
367 i.state === "open" &&
368 isMilestone(i.id) &&
369 !(waitedBy.get(i.id) ?? []).some(
370 (id) => byId.get(id)?.state === "open",
371 ),
372 );
343 const top = arranged(
344 TOP,
345 tree.top.map((i) => i.id),
346 );
373347
374348 return {
349 read: { graph, tree, places },
375350 limbs: top.map(limbOf),
376351 limb(number: number): { own: Issue[]; shared: Issue[] } | undefined {
377352 const issue = byNumber.get(number);
@@ -484,11 +459,7 @@ export function readPlan(
484459 .map((id) => byId.get(id))
485460 .filter((i): i is Issue => i !== undefined)
486461 .sort((a, b) => a.number - b.number);
487 // A child is a limb when it brings a tree of its own; one that only
488 // waits for its siblings is a leaf in an order.
489 const siblings = new Set(children.map((c) => c.id));
490 const isLimb = (child: Issue): boolean =>
491 (waitsFor.get(child.id) ?? []).some((id) => !siblings.has(id));
462 const limbIds = new Set(tree.limbsOf(issue.id));
492463 const closedThisWeek = under.filter(
493464 (i) =>
494465 i.state === "closed" && counted(i) && (i.closedAt ?? "") >= since,
@@ -534,9 +505,9 @@ export function readPlan(
534505 .filter((pull) => pull.closes.some((n) => numbers.has(n)))
535506 .map(({ number: n, title, at }) => ({ number: n, title, at })),
536507 estimate,
537 limbs: children.filter(isLimb).map(limbOf),
508 limbs: arranged(issue.id, [...limbIds]).map(limbOf),
538509 leaves: children
539 .filter((c) => !isLimb(c))
510 .filter((c) => !limbIds.has(c.id))
540511 .map((c) => {
541512 const state = stateOf(c);
542513 const why =
src/core/schema.ts +11
@@ -1584,6 +1584,17 @@ export const migrations: readonly string[] = [
15841584 ALTER TABLE look ADD COLUMN attached_by TEXT;
15851585 ALTER TABLE look ADD COLUMN note TEXT;
15861586 `,
1587 // 75 — a limb's place among its siblings (#1064), set by a person. `parent_id`
1588 // is the issue whose limbs these are, or '' for the top of the plan.
1589 `
1590 CREATE TABLE limb_place (
1591 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
1592 parent_id TEXT NOT NULL,
1593 issue_id TEXT NOT NULL,

Minor, not blocking. issue_id and parent_id have no foreign key to issue, so deleting or transferring an issue leaves its limb_place rows behind. readPlaces then returns a non-empty map, and repoGraph keeps setting limbRank (and re-sorting readyNow) even when no live group has a set order. seated drops ids that are not in the tree, so the order shown stays right. Suggested fix: add REFERENCES issue(id) ON DELETE CASCADE to issue_id in a later migration, or have moveLimb prune the rows whose ids are gone.

1594 place INTEGER NOT NULL,
1595 PRIMARY KEY (repo_id, parent_id, issue_id)
1596 );
1597 `,
15871598 ];
15881599
15891600 /**
src/fleet/verbs.ts +49
@@ -23,6 +23,7 @@ import { cancelRepoRun, requeueRepoRun } from "../actions/runs.ts";
2323 import { isCrew } from "../core/agents.ts";
2424 import { mergePull } from "../actions/pulls.ts";
2525 import { bringUpToDate } from "../crew/autonomy.ts";
26 import { moveLimb, parseSeat, type Seating } from "../core/limb_seats.ts";
2627 import { getHold } from "../core/holds.ts";
2728 import { recordInteraction, removeDismissals } from "../core/interactions.ts";
2829 import { getPull } from "../core/pulls.ts";
@@ -69,6 +70,8 @@ export interface Target {
6970 readonly pillars?: Pillars;
7071 /** For an issue: the numbers of the umbrellas that wait for it directly, ascending. */
7172 readonly under?: readonly number[];
73 /** For an issue: its repository's limbs in order, read on the first ask. */
74 readonly seating?: () => Seating;
7275 /** For an issue: the ids of every row this operation is over, itself included. */
7376 readonly peers?: ReadonlySet<string>;
7477 }
@@ -676,4 +679,50 @@ export const VERBS: Record<string, Verb> = {
676679 );
677680 },
678681 },
682
683 /**
684 * Seat a limb among its siblings: the value is a seat, `<group>:<place>`
685 * (`top` or an issue number, then the place from 1), and the limb takes
686 * that place with the others moving over. The row holds every seat the
687 * limb has, so undo is the seats from before. The order is kept here and
688 * is not the origin's, so a mirror is no reason to refuse.
689 */
690 place: {
691 kind: "issue",
692 reversible: true,
693 parse: (_app, value) =>
694 parseSeat(value) === undefined
695 ? err("place takes a seat: top:2, or the number of the milestone and the place, 12:1")
696 : ok(String(value)),
697 read: (t) => [...(t.seating?.().of(t.id) ?? [])],
698 next: replace,
699 nextFor(_t, before, value) {
700 const want = parseSeat(value);
701 const held = Array.isArray(before) ? before.map(String) : [];
702 return want === undefined
703 ? held
704 : held.map((seat) => (parseSeat(seat)?.group === want.group ? String(value) : seat));
705 },
706 refuse(_app, t, after) {
707 const seats = t.seating?.();
708 for (const seat of Array.isArray(after) ? after.map(String) : []) {
709 const want = parseSeat(seat);
710 if (want === undefined || seats === undefined) return "not a seat";
711 if (want.place > seats.size(want.group))
712 return `there are only ${seats.size(want.group)} limbs there`;
713 }
714 return undefined;
715 },
716 async write(app, _actor, t, after) {
717 const have = t.seating?.().of(t.id) ?? [];
718 for (const seat of Array.isArray(after) ? after.map(String) : []) {
719 const want = parseSeat(seat);
720 if (want === undefined) return "not a seat";
721 if (have.includes(seat)) continue;
722 const why = moveLimb(app.db, t.repo, t.id, want);
723 if (why !== undefined) return why;
724 }
725 return undefined;
726 },
727 },
679728 };
src/http/api.ts +5 −2
@@ -2510,7 +2510,7 @@ export function apiRoutes(router: Router<Handler>): void {
25102510 },
25112511 );
25122512 // One action on a whole limb (#733): defer, resume, or close as not
2513 // doing. A fleet operation in every respect — a dry run by default with a
2513 // doing, or raise or lower it among its siblings (#1064). A fleet operation in every respect — a dry run by default with a
25142514 // read credential, an admin token to apply, the receipt in
25152515 // /api/v1/fleet/ops/:id and its undo at …/undo.
25162516 router.add(
@@ -2525,11 +2525,14 @@ export function apiRoutes(router: Router<Handler>): void {
25252525 const number = positiveInt(ctx.params.number);
25262526 if (!repo || number === undefined) return notFound();
25272527 if (!isLimbAction(input.action))
2528 return apiError("bad_action", 422, "action is defer, resume or close");
2528 return apiError("bad_action", 422, "action is defer, resume, close, raise or lower");
25292529 const request = {
25302530 action: input.action,
25312531 ...(typeof input.reason === "string" ? { reason: input.reason } : {}),
25322532 ...(typeof input.pillar === "string" ? { pillar: input.pillar } : {}),
2533 ...(typeof input.under === "string" || typeof input.under === "number"
2534 ? { under: String(input.under) }
2535 : {}),
25332536 };
25342537 if (dryRun) {
25352538 const plan = previewLimb(ctx.app, ctx.auth.user, repo, number, request);
src/http/mcp.ts +11 −3
@@ -1249,7 +1249,7 @@ const TOOLS: readonly Tool[] = [
12491249 {
12501250 name: "limb",
12511251 description:
1252 "One action on a whole limb of the plan — a milestone and everything beneath it that only it needs. `defer` moves the limb behind the Later gate, so the crew picks none of it; `resume` brings it back, under `pillar` when you name one; `close` closes every open issue of the limb as not doing, with `reason` said on each; `undo` reverses an `operation` by its id. A dry run unless `dryRun` is false: it answers with every row before and after, what the action takes along (`taken`) and what stays because something outside the limb still waits for it (`kept`), and changes nothing. Applying and undoing take an admin token of a person who administers the app, and answer with the receipt.",
1252 "One action on a whole limb of the plan — a milestone and everything beneath it that only it needs. `defer` moves the limb behind the Later gate, so the crew picks none of it; `resume` brings it back, under `pillar` when you name one; `close` closes every open issue of the limb as not doing, with `reason` said on each; `raise` and `lower` move it one place among its siblings — `under` says whose limbs when it is a limb of several, or `top` — and the crew takes ready work from the higher limb first; the dry run answers with the new `order` and the top of the ready list `before` and `after`. `undo` reverses an `operation` by its id. A dry run unless `dryRun` is false: it answers with every row before and after, what the action takes along (`taken`) and what stays because something outside the limb still waits for it (`kept`), and changes nothing. Applying and undoing take an admin token of a person who administers the app, and answer with the receipt.",
12531253 inputSchema: {
12541254 type: "object",
12551255 properties: {
@@ -1259,7 +1259,7 @@ const TOOLS: readonly Tool[] = [
12591259 minimum: 1,
12601260 description: "The limb: the number of its issue.",
12611261 },
1262 action: { type: "string", enum: ["defer", "resume", "close", "undo"] },
1262 action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "undo"] },
12631263 reason: {
12641264 type: "string",
12651265 description: "For close: why it is not being done, in one sentence.",
@@ -1269,6 +1269,11 @@ const TOOLS: readonly Tool[] = [
12691269 description:
12701270 "For resume: the pillar to put it back under, by name or short form; under none when absent.",
12711271 },
1272 under: {
1273 type: "string",
1274 description:
1275 "For raise and lower: among whose limbs — `top`, or the number of the milestone; needed only when the limb sits in several places.",
1276 },
12721277 operation: {
12731278 type: "string",
12741279 description: "For undo: the id of the operation to reverse.",
@@ -1303,11 +1308,14 @@ const TOOLS: readonly Tool[] = [
13031308 !Number.isInteger(number) ||
13041309 number < 1
13051310 )
1306 return bad("invalid", "give the limb's issue number and an action: defer, resume, close or undo");
1311 return bad("invalid", "give the limb's issue number and an action: defer, resume, close, raise, lower or undo");
13071312 const request = {
13081313 action: args["action"],
13091314 ...(typeof args["reason"] === "string" ? { reason: args["reason"] } : {}),
13101315 ...(typeof args["pillar"] === "string" ? { pillar: args["pillar"] } : {}),
1316 ...(typeof args["under"] === "string" || typeof args["under"] === "number"
1317 ? { under: String(args["under"]) }
1318 : {}),
13111319 };
13121320 if (dry) {
13131321 const plan = previewLimb(ctx.app, user, repo, number, request);
src/web/pages/limb.ts +71 −8
@@ -50,6 +50,22 @@ const WORDS: Record<LimbAction, { ask: string; does: string; go: string }> = {
5050 does: "Every open issue that only this limb needs closes as not doing, with your reason said on each.",
5151 go: "Close as not doing",
5252 },
53 raise: {
54 ask: "Raise",
55 does: "It moves up one place among its siblings, and the crew takes ready work from the higher limb first.",
56 go: "Raise this limb",
57 },
58 lower: {
59 ask: "Lower",
60 does: "It moves down one place among its siblings, and the crew takes ready work from the higher limb first.",
61 go: "Lower this limb",
62 },
63 };
64
65 /** A seat (`top:2`) as a person reads it; anything else as it is. */
66 const seatWords = (token: string | number): string => {
67 const place = /^(?:top|[0-9]+):([0-9]+)$/.exec(String(token))?.[1];
68 return place === undefined ? String(token) : `place ${place}`;
5369 };
5470
5571 const shown = (value: FleetValue): string =>
@@ -58,7 +74,7 @@ const shown = (value: FleetValue): string =>
5874 : Array.isArray(value)
5975 ? value.length === 0
6076 ? "filed nowhere"
61 : value.join(", ")
77 : value.map(seatWords).join(", ")
6278 : String(value);
6379
6480 /** How many issues a list shows before the rest folds away. */
@@ -134,10 +150,12 @@ function requestOf(
134150 ): LimbRequest {
135151 const reason = get("reason").trim();
136152 const pillar = get("pillar").trim();
153 const under = get("under").trim();
137154 return {
138155 action,
139156 ...(reason === "" ? {} : { reason }),
140157 ...(pillar === "" ? {} : { pillar }),
158 ...(under === "" ? {} : { under }),
141159 };
142160 }
143161
@@ -179,6 +197,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
179197 }
180198 const p = plan.value;
181199 const willWrite = p.counts.change > 0;
200 const moving = action === "raise" || action === "lower";
182201 const pillars =
183202 action === "resume" ? pillarsOf(ctx.app.db, repo).pillars : [];
184203 const body = html`${repoHeader(ctx, repo, "map")}
@@ -189,6 +208,41 @@ export function limbPreviewPage(ctx: Ctx): Response {
189208 <h1>${words.ask} “${p.limb.title}”?</h1>
190209 <p>${words.does}</p>
191210 </div>
211 ${when(
212 p.order !== undefined,
213 () =>
214 html`<section class="ms-section" id="limb-order">
215 <h2>The order of the limbs</h2>
216 <ol class="ms-rows">
217 ${(p.order ?? []).map(
218 (i) =>
219 html`<li ${i.number === p.limb.number ? html`aria-current="true"` : null}>
220 ${i.number === p.limb.number
221 ? html`<b>${i.title}</b>`
222 : html`<a href="${base}/issues/${i.number}">${i.title}</a>`}
223 <span class="muted small">#${i.number}</span>
224 </li>`,
225 )}
226 </ol>
227 </section>`,
228 )}
229 ${when(
230 p.ready !== undefined,
231 () => {
232 const before = (p.ready?.before ?? []).map((i) => i.number);
233 const after = p.ready?.after ?? [];
234 return html`<section class="ms-section" id="limb-ready">
235 <h2>The top of the ready list</h2>
236 ${after.length === 0
237 ? html`<p class="muted small">Nothing is ready.</p>`
238 : before.join() === after.map((i) => i.number).join()
239 ? html`<p class="muted small">Unchanged: the same ${after.length} come first.</p>
240 ${issueList(base, after)}`
241 : html`<p class="muted small">After the move, the crew takes these first:</p>
242 ${issueList(base, after)}`}
243 </section>`;
244 },
245 )}
192246 ${when(
193247 p.taken.length > 0,
194248 () =>
@@ -213,12 +267,14 @@ export function limbPreviewPage(ctx: Ctx): Response {
213267 ${issueList(base, p.kept)}
214268 </section>`,
215269 )}
216 <section class="ms-section" id="limb-rows">
217 <h2>What is written</h2>
218 ${p.rows.length === 0
219 ? html`<p class="muted small">Nothing: no open issue is the limb's alone.</p>`
220 : rowList(ctx, base, p.rows)}
221 </section>
270 ${moving
271 ? null
272 : html`<section class="ms-section" id="limb-rows">
273 <h2>What is written</h2>
274 ${p.rows.length === 0
275 ? html`<p class="muted small">Nothing: no open issue is the limb's alone.</p>`
276 : rowList(ctx, base, p.rows)}
277 </section>`}
222278 ${willWrite
223279 ? html`<form
224280 method="post"
@@ -226,6 +282,10 @@ export function limbPreviewPage(ctx: Ctx): Response {
226282 class="limb-confirm mt"
227283 >
228284 <input type="hidden" name="action" value="${action}" />
285 ${when(
286 request.under,
287 (under) => html`<input type="hidden" name="under" value="${under}" />`,
288 )}
229289 ${when(
230290 action === "close",
231291 () =>
@@ -305,7 +365,9 @@ export async function limbSubmit(ctx: Ctx): Promise<Response> {
305365 if (!op.ok)
306366 return op.error.code === "not_found"
307367 ? notFoundPage(ctx)
308 : redirect(`${milestoneHref(base, number)}/limb?action=${action}`);
368 : redirect(
369 `${milestoneHref(base, number)}/limb?action=${action}${(field(form, "under") ?? "") === "" ? "" : `&under=${encodeURIComponent(field(form, "under"))}`}`,
370 );
309371 return redirect(`${milestoneHref(base, number)}/limb/${op.value.id}`);
310372 }
311373
@@ -313,6 +375,7 @@ export async function limbSubmit(ctx: Ctx): Promise<Response> {
313375 function done(op: Operation): string {
314376 if (op.undoOf !== null) return "Undone";
315377 if (op.verb === "drop") return "Closed as not doing";
378 if (op.verb === "place") return "Moved";
316379 return op.value === "later" ? "Deferred" : "Resumed";
317380 }
318381
src/web/pages/milestones.ts +72 −8
@@ -17,7 +17,12 @@ import {
1717 type LeafState,
1818 type Limb,
1919 type Milestone,
20 type Plan,
2021 } from "../../core/milestones.ts";
22 import type { Database } from "bun:sqlite";
23 import { parseSeat, seating } from "../../core/limb_seats.ts";
24 import type { Issue } from "../../core/issues.ts";
25 import type { User } from "../../core/users.ts";
2126 import type { LimbAction } from "../../actions/limbs.ts";
2227 import { isStructural, PILLAR_DAYS, pillarsOf } from "../../core/pillars.ts";
2328 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
@@ -47,7 +52,53 @@ export const limbHref = (
4752 base: string,
4853 number: number,
4954 action: LimbAction,
50 ): string => `${milestoneHref(base, number)}/limb?action=${action}`;
55 under?: string,
56 ): string =>
57 `${milestoneHref(base, number)}/limb?action=${action}${under === undefined ? "" : `&under=${under}`}`;
58
59 /**
60 * Raise and Lower for a limb among its siblings in one group (`top`, or a
61 * milestone's number): only the moves that change something, and nothing
62 * at all where it has no sibling. For whoever administers the repository.
63 */
64 type Mover = ((limb: Issue, group: string) => Html | null) & {
65 /** The first group a limb sits in, for its own page. */
66 homeOf(limb: Issue): string | undefined;
67 };
68
69 function moverFor(
70 db: Database,
outdated

Medium: this is a second full repoGraph plus limb tree per admin page view, next to the one readPlan already built. Reuse the plan's tree and places.

71 repo: Repo,
72 viewer: User | undefined,
73 read: Plan["read"],
74 ): Mover | undefined {
75 if (!canAdmin(db, repo, viewer)) return undefined;
76 const seats = seating(db, repo, read);
77 const base = repoBase(repo);
78 const at = (limb: Issue, group: string) => {
79 const seat = seats
80 .of(limb.id)
81 .flatMap((s) => parseSeat(s) ?? [])
82 .find((s) => s.group === group);
83 if (seat === undefined || seats.size(group) < 2) return null;
84 const up = seat.place > 1;
85 const down = seat.place < seats.size(group);
86 return html`<span class="ms-move small"
87 >${up
88 ? html`<a href="${limbHref(base, limb.number, "raise", group)}"
89 >Raise</a
90 >`
91 : null}${down
92 ? html`<a href="${limbHref(base, limb.number, "lower", group)}"
93 >Lower</a
94 >`
95 : null}</span
96 >`;
97 };
98 return Object.assign(at, {
99 homeOf: (limb: Issue) => parseSeat(seats.of(limb.id)[0])?.group,
100 });
101 }
51102
52103 /**
53104 * The actions on a whole limb, for whoever administers the repository:
@@ -60,10 +111,12 @@ function limbActions(
60111 m: Milestone,
61112 furniture: boolean,
62113 lead: Html | null,
114 move: Html | null,
63115 ): Html {
64116 const number = m.issue.number;
65117 return html`<p class="bar ms-actions" id="limb-actions">
66118 ${lead}
119 ${move}
67120 ${furniture
68121 ? null
69122 : m.deferred
@@ -101,7 +154,7 @@ function weightWords(limb: Limb): Html {
101154 }
102155
103156 /** One limb as one line: a link to it, how far it is, and its weight. */
104 function limbLine(base: string, limb: Limb): Html {
157 function limbLine(base: string, limb: Limb, move: Html | null = null): Html {
105158 const w = limb.weight;
106159 return html`<li class="ms-limb" data-trim="${limb.trim === undefined ? "no" : "yes"}">
107160 <a class="ms-name" href="${milestoneHref(base, limb.issue.number)}"
@@ -120,6 +173,7 @@ function limbLine(base: string, limb: Limb): Html {
120173 (why) =>
121174 html`<span class="ms-trim small">A candidate to trim: ${why}.</span>`,
122175 )}
176 ${move}
123177 </li>`;
124178 }
125179
@@ -131,6 +185,7 @@ export function milestonesPage(ctx: Ctx): Response {
131185 spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user),
132186 });
133187 const base = repoBase(repo);
188 const move = moverFor(ctx.app.db, repo, ctx.auth.user, plan.read);
134189 const body = html`${repoHeader(ctx, repo, "map")}
135190 <div class="page-head mt">
136191 <h1>Milestones</h1>
@@ -145,7 +200,7 @@ export function milestonesPage(ctx: Ctx): Response {
145200 show. <a href="${base}/map">See the map</a>.
146201 </p>`
147202 : html`<ul class="ms-limbs" id="ms-limbs">
148 ${plan.limbs.map((limb) => limbLine(base, limb))}
203 ${plan.limbs.map((limb) => limbLine(base, limb, move?.(limb.issue, "top") ?? null))}
149204 </ul>`}`;
150205 return page({
151206 ctx,
@@ -208,6 +263,7 @@ function milestoneBody(
208263 m: Milestone,
209264 names: boolean,
210265 actions: Html | null,
266 move?: Mover,
211267 ): Html {
212268 const base = repoBase(repo);
213269 const issue = (number: number, title: string): Html =>
@@ -257,7 +313,9 @@ function milestoneBody(
257313 html`<section class="ms-section" id="ms-limbs">
258314 <h2>Limbs</h2>
259315 <ul class="ms-limbs">
260 ${m.limbs.map((limb) => limbLine(base, limb))}
316 ${m.limbs.map((limb) =>
317 limbLine(base, limb, move?.(limb.issue, String(m.issue.number)) ?? null),
318 )}
261319 </ul>
262320 </section>`,
263321 )}
@@ -378,10 +436,11 @@ export function milestonePage(ctx: Ctx): Response {
378436 if (!repo) return notFoundPage(ctx);
379437 const number = Number(ctx.params.number);
380438 const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
381 const milestone = Number.isInteger(number)
382 ? readPlan(ctx.app.db, repo, { spend: owner }).milestone(number)
439 const plan = Number.isInteger(number)
440 ? readPlan(ctx.app.db, repo, { spend: owner })
383441 : undefined;
384 if (milestone === undefined) return notFoundPage(ctx);
442 const milestone = plan?.milestone(number);
443 if (plan === undefined || milestone === undefined) return notFoundPage(ctx);
385444 const viewer = ctx.auth.user;
386445 // Watch is each person's own; acting on the whole limb is for whoever
387446 // administers the repository. One row, Watch first.
@@ -393,12 +452,16 @@ export function milestonePage(ctx: Ctx): Response {
393452 isWatching(ctx.app.db, viewer.id, milestone.issue.id),
394453 )
395454 : null;
455 const move = moverFor(ctx.app.db, repo, viewer, plan.read);
456 // Its own place: among the limbs of whichever milestone holds it first.
457 const seat = move?.homeOf(milestone.issue);
396458 const actions = canAdmin(ctx.app.db, repo, ctx.auth.user)
397459 ? limbActions(
398460 repoBase(repo),
399461 milestone,
400462 isStructural(pillarsOf(ctx.app.db, repo), milestone.issue.id),
401463 watch,
464 seat === undefined ? null : (move?.(milestone.issue, seat) ?? null),
402465 )
403466 : watch === null
404467 ? null
@@ -409,7 +472,7 @@ export function milestonePage(ctx: Ctx): Response {
409472 crumbs: crumbsFor(repo),
410473 palette: repoPalette(ctx, repo),
411474 body: html`${repoHeader(ctx, repo, "map")}
412 ${milestoneBody(repo, milestone, owner, actions)}`,
475 ${milestoneBody(repo, milestone, owner, actions, move)}`,
413476 styleCss: milestonesCss,
414477 });
415478 }
@@ -492,6 +555,7 @@ export const milestonesCss = /* css */ `
492555 .ms-top h1, .ms-top p { margin: 0; }
493556 .ms-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
494557 .ms-actions { margin-top: 4px; }
558 .ms-move { grid-column: 1 / -1; display: flex; gap: 14px; }
495559 .ms-section { margin-top: 22px; }
496560 .ms-section h2 { font-size: 13px; margin: 0 0 6px; }
497561 .ms-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; }
test/limb_order.test.ts added +358
@@ -0,0 +1,358 @@
1 /**
2 * The order of the limbs (#1064): raise or lower a limb against its
3 * siblings, and the crew takes ready work in that order.
4 *
5 * The refusals come first — a reader, a crew agent and a read token move
6 * nothing. Then the stored order (kept across a restart), the ready order
7 * that follows it, the issue beneath two limbs, the preview, the undo, and
8 * every door: the page, the API and the MCP tool.
9 */
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { linkIssue } from "../src/actions/dependencies.ts";
17 import { undoFleetOp } from "../src/actions/fleet.ts";
18 import { openIssue } from "../src/actions/issues.ts";
19 import { applyLimb, previewLimb } from "../src/actions/limbs.ts";
20 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
21 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
22 import { getIssueById, type Issue } from "../src/core/issues.ts";
23 import { readPlan } from "../src/core/milestones.ts";
24 import { getRepoById, type Repo } from "../src/core/repos.ts";
25 import { createToken } from "../src/core/tokens.ts";
26 import { createUser, getUserByName, type User } from "../src/core/users.ts";
27 import { serve, type Servers } from "../src/http/server.ts";
28
29 describe("the order of the limbs", () => {
30 let dataDir: string;
31 let app: App;
32 let servers: Servers;
33 let base: string;
34 let alice: User;
35 let bob: User;
36 let mason: User;
37 let repo: Repo;
38 let trust: Issue;
39 let feed: Issue;
40 let s1: Issue;
41 let s2: Issue;
42 let a1: Issue;
43 let b1: Issue;
44 let both: Issue;
45 let x1: Issue;
46 let x2: Issue;
47
48 const fresh = () => getRepoById(app.db, repo.id)!;
49 const current = (issue: Issue) => getIssueById(app.db, issue.id)!;
50 const open = (title: string): Issue => {
51 const opened = openIssue(app, alice, fresh(), { title });
52 if (!opened.ok) throw new Error(String(opened.error));
53 return opened.value;
54 };
55 const link = (waiter: Issue, on: Issue): void => {
56 const linked = linkIssue(app, alice, fresh(), current(waiter), current(on));
57 if (!linked.ok) throw new Error(String(linked.error));
58 };
59 const ready = (): number[] =>
60 readyNow(repoGraph(app.db, repo.id)).map((r) => r.issue.number);
61 const names = (...issues: Issue[]) => issues.map((i) => i.number);
62 const places = (): string =>
63 JSON.stringify(
64 app.db
65 .query("SELECT parent_id, issue_id, place FROM limb_place ORDER BY 1, 2")
66 .all(),
67 );
68 const top = (): number[] =>
69 readPlan(app.db, fresh()).limbs.map((l) => l.issue.number);
70 const under = (m: Issue): number[] =>
71 readPlan(app.db, fresh())
72 .milestone(m.number)!
73 .limbs.map((l) => l.issue.number);
74 const tokenOf = (user: User, scope: "ro" | "rw" | "admin"): string =>
75 createToken(app.db, user.id, scope, scope).token;
76 const post = (token: string, path: string, body: unknown) =>
77 fetch(`${base}${path}`, {
78 method: "POST",
79 headers: {
80 authorization: `Bearer ${token}`,
81 "content-type": "application/json",
82 },
83 body: JSON.stringify(body),
84 });
85 const cookies: Record<string, string> = {};
86 const get = (who: string, path: string) =>
87 fetch(`${base}${path}`, { headers: { cookie: cookies[who]! }, redirect: "manual" });
88 const submit = (who: string, path: string, form: Record<string, string>) =>
89 fetch(`${base}${path}`, {
90 method: "POST",
91 headers: { cookie: cookies[who]!, origin: base },
92 body: new URLSearchParams(form),
93 redirect: "manual",
94 });
95 let nextId = 1;
96 const tool = async (token: string, args: unknown) => {
97 const res = await post(token, "/mcp", {
98 jsonrpc: "2.0",
99 id: nextId++,
100 method: "tools/call",
101 params: { name: "limb", arguments: args },
102 });
103 const body = (await res.json()) as any;
104 const text: string = body.result.content[0].text;
105 return body.result.isError === true
106 ? { error: text }
107 : { value: JSON.parse(text) };
108 };
109 /** The ready order with no order set, and with feed's limbs first. */
110 let NATURAL: number[] = [];
111 let FEED_FIRST: number[] = [];
112 const path = () => `/api/v1/repos/alice/order/milestones`;
113
114 beforeAll(async () => {
115 dataDir = await mkdtemp(join(tmpdir(), "forge-limb-order-"));
116 app = createApp({ dataDir, port: 0 });
117 servers = await serve(app);
118 base = servers.url;
119 for (const [username, adminIfFirst] of [
120 ["alice", true],
121 ["bob", false],
122 ] as const) {
123 const made = await createUser(app.db, {
124 trusted: true,
125 username,
126 email: `${username}@forge.local`,
127 password: "a-good-passphrase",
128 adminIfFirst,
129 });
130 if (!made.ok) throw new Error(made.error);
131 }
132 alice = getUserByName(app.db, "alice")!;
133 bob = getUserByName(app.db, "bob")!;
134 for (const name of ["alice", "bob"])
135 cookies[name] = (
136 (
137 await fetch(`${base}/signin`, {
138 method: "POST",
139 body: new URLSearchParams({ username: name, password: "a-good-passphrase" }),
140 redirect: "manual",
141 })
142 ).headers.get("set-cookie") ?? ""
143 ).split(";")[0]!;
144 const made = await createRepo(app, alice, { name: "order", visibility: "public" });
145 if (!made.ok) throw new Error(String(made.error));
146 const seeded = await commitToBranch(app, alice, made.value, {
147 message: "Start",
148 files: [{ path: "README.md", content: "# order\n" }],
149 });
150 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
151 repo = made.value;
152 const agent = await createAgent(app, alice, { username: "mason" });
153 if (!agent.ok) throw new Error(String(agent.error));
154 expect(addToCrew(app, alice, repo, "mason").ok).toBe(true);
155 mason = getUserByName(app.db, "mason")!;
156
157 // Natural ready order: what frees most, then the oldest.
158 b1 = open("A feed leaf");
159 x1 = open("First sub-limb leaf");
160 x2 = open("Second sub-limb leaf");
161 both = open("Under both pillars");
162 a1 = open("A trust leaf");
163 trust = open("P0 Trust: agents contained");
164 feed = open("P1 Feed: one front door");
165 open("Later: work that serves no pillar now");
166 const gate = open("Later gate: nothing behind this starts");
167 s1 = open("First sub-limb");
168 s2 = open("Second sub-limb");
169 link(gate, trust);
170 link(gate, feed);
171 link(trust, a1);
172 link(trust, both);
173 link(feed, both);
174 link(feed, b1);
175 link(feed, s1);
176 link(feed, s2);
177 link(s1, x1);
178 link(s2, x2);
179 NATURAL = names(x1, x2, both, b1, a1);
180 // An issue beneath both pillars takes the higher one.
181 FEED_FIRST = names(both, b1, x1, x2, a1);
182 }, 60_000);
183
184 afterAll(async () => {
185 servers.stop();
186 app.close();
187 await rm(dataDir, { recursive: true, force: true });
188 });
189
190 test("with no order set, the ready order is the one that never knew limbs", () => {
191 expect(places()).toBe("[]");
192 expect(ready()).toEqual(NATURAL);
193 expect(top()).toEqual(names(trust, feed).concat(top().slice(2)));
194 });
195
196 test("refused: a reader, a crew agent and a read token move nothing, by any door", async () => {
197 const request = { action: "raise" as const, under: "top" };
198 // A reader may look: the one row comes back refused, and says why.
199 const looked = previewLimb(app, bob, fresh(), feed.number, request);
200 expect(looked.ok && looked.value.rows.map((r) => r.outcome)).toEqual(["refused"]);
201 if (looked.ok) expect(looked.value.rows[0]!.reason).toBe("you are not an admin of alice/order");
202 const tried = await applyLimb(app, bob, fresh(), feed.number, request);
203 expect(tried.ok && tried.value.rows.every((r) => r.outcome === "refused")).toBe(true);
204 // An agent never applies; nobody signed in does not either.
205 const agent = await applyLimb(app, mason, fresh(), feed.number, request);
206 expect(agent.ok ? "applied" : agent.error.code).toBe("forbidden");
207 const nobody = await applyLimb(app, undefined, fresh(), feed.number, request);
208 expect(nobody.ok ? "applied" : nobody.error.code).toBe("unauthorized");
209 // The API: a read token, a write token and an agent's admin token.
210 const url = `${path()}/${feed.number}/limb`;
211 const apply = { action: "raise", under: "top", dryRun: false };
212 expect((await post(tokenOf(alice, "ro"), url, apply)).status).toBe(403);
213 expect((await post(tokenOf(alice, "rw"), url, apply)).status).toBe(403);
214 expect((await post(tokenOf(mason, "admin"), url, apply)).status).toBe(403);
215 // A reader's admin token writes nothing.
216 const theirs = await post(tokenOf(bob, "admin"), url, apply);
217 const rows = ((await theirs.json()) as { rows?: { outcome: string }[] }).rows;
218 expect(rows?.every((r) => r.outcome === "refused") ?? true).toBe(true);
219 // MCP: applying takes an admin token, whoever asks.
220 const args = { app: "alice/order", issue: feed.number, action: "raise", under: "top", dryRun: false };
221 for (const token of [tokenOf(alice, "ro"), tokenOf(alice, "rw"), tokenOf(mason, "rw")])
222 expect((await tool(token, args)).error).toContain("forbidden");
223 expect((await tool(tokenOf(mason, "admin"), args)).error).toContain("forbidden");
224 // The page: a reader sees no Raise, and a posted form moves nothing.
225 expect(await (await get("bob", `/alice/order/milestones`)).text()).not.toContain(">Raise<");
226 const form = { action: "raise", under: "top" };
227 const posted = await submit("bob", `/alice/order/milestones/${feed.number}/limb`, form);
228 expect(posted.status).not.toBe(201);
229 expect(places()).toBe("[]");
230 expect(top().slice(0, 2)).toEqual(names(trust, feed));
231 expect(ready()).toEqual(NATURAL);
232 });
233
234 test("a move that cannot be made is said, not guessed", async () => {
235 const say = async (limb: Issue, request: { action: "raise" | "lower"; under?: string }) => {
236 const r = await applyLimb(app, alice, fresh(), limb.number, request);
237 return r.ok ? "applied" : r.error.message;
238 };
239 expect(await say(trust, { action: "raise", under: "top" })).toBe("it is first already");
240 expect(await say(a1, { action: "raise" })).toContain("no place to move");
241 expect(await say(s1, { action: "raise", under: "top" })).toContain("no place to move");
242 expect(await say(feed, { action: "raise", under: "nonsense" })).toContain("under is top");
243 expect(places()).toBe("[]");
244 });
245
246 test("the preview shows the new order and the top of the ready list, and writes nothing", () => {
247 const plan = previewLimb(app, alice, fresh(), feed.number, { action: "raise", under: "top" });
248 expect(plan.ok).toBe(true);
249 if (!plan.ok) return;
250 expect(plan.value.order!.map((i) => i.number).slice(0, 2)).toEqual(names(feed, trust));
251 expect(plan.value.ready!.before.map((i) => i.number)).toEqual(NATURAL);
252 // The issue beneath both pillars takes the higher, feed, so it comes
253 // before the leaves of feed's sub-limbs.
254 expect(plan.value.ready!.after.map((i) => i.number)).toEqual(FEED_FIRST);
255 expect(plan.value.rows.map((r) => [r.before, r.after, r.outcome])).toEqual([
256 [["top:2"], ["top:1"], "change"],
257 ]);
258 expect(places()).toBe("[]");
259 });
260
261 let receipt: string;
262
263 test("raise: the place is stored, shown as the order, and the ready order follows it", async () => {
264 const op = await applyLimb(app, alice, fresh(), feed.number, { action: "raise", under: "top" });
265 expect(op.ok && op.value.rows.map((r) => r.outcome)).toEqual(["ok"]);
266 if (!op.ok) return;
267 receipt = op.value.id;
268 expect(top().slice(0, 2)).toEqual(names(feed, trust));
269 expect(ready()).toEqual(FEED_FIRST);
270 expect(JSON.parse(places()).map((r: { place: number }) => r.place).sort()).toEqual([1, 2, 3]);
271 // The first cannot be raised again.
272 const again = await applyLimb(app, alice, fresh(), feed.number, { action: "raise", under: "top" });
273 expect(again.ok ? "applied" : again.error.message).toBe("it is first already");
274 });
275
276 test("undo puts the order back, once, and the ready list follows the order shown", async () => {
277 const undone = await undoFleetOp(app, alice, receipt);
278 expect(undone.ok && undone.value.rows.map((r) => r.outcome)).toEqual(["ok"]);
279 expect(top().slice(0, 2)).toEqual(names(trust, feed));
280 // Back where it began is no order at all: the rows go and the crew's
281 // ready order is the natural one again.
282 expect(places()).toBe("[]");
283 expect(ready()).toEqual(NATURAL);
284 const twice = await undoFleetOp(app, alice, receipt);
285 expect(twice.ok ? "undone" : twice.error.code).toBe("already_undone");
286 });
287
288 test("lower is the same move the other way, and among a milestone's own limbs", async () => {
289 expect(under(feed)).toEqual(names(s1, s2));
290 const lowered = await applyLimb(app, alice, fresh(), s1.number, { action: "lower", under: String(feed.number) });
291 expect(lowered.ok).toBe(true);
292 expect(under(feed)).toEqual(names(s2, s1));
293 // Second sub-limb's leaf now comes before the first's.
294 expect(ready().indexOf(x2.number)).toBeLessThan(ready().indexOf(x1.number));
295 const last = await applyLimb(app, alice, fresh(), s1.number, { action: "lower" });
296 expect(last.ok ? "applied" : last.error.message).toBe("it is last already");
297 // Undoing it, though others moved since, only where it still holds.
298 if (lowered.ok) await undoFleetOp(app, alice, lowered.value.id);
299 expect(under(feed)).toEqual(names(s1, s2));
300 });
301
302 test("the order survives a restart", async () => {
303 const raised = await applyLimb(app, alice, fresh(), feed.number, { action: "raise", under: "top" });
304 expect(raised.ok).toBe(true);
305 servers.stop();
306 app.close();
307 app = createApp({ dataDir, port: 0 });
308 servers = await serve(app);
309 base = servers.url;
310 expect(top().slice(0, 2)).toEqual(names(feed, trust));
311 expect(ready()).toEqual(FEED_FIRST);
312 });
313
314 test("through the API and MCP: a dry run by default, an admin applies", async () => {
315 const url = `${path()}/${trust.number}/limb`;
316 const dry = await post(tokenOf(alice, "ro"), url, { action: "raise", under: "top" });
317 expect(dry.status).toBe(200);
318 const planned = (await dry.json()) as { dryRun: boolean; order: { number: number }[] };
319 expect(planned.dryRun).toBe(true);
320 expect(planned.order.slice(0, 2).map((i) => i.number)).toEqual(names(trust, feed));
321 expect(top().slice(0, 2)).toEqual(names(feed, trust));
322 const applied = await post(tokenOf(alice, "admin"), url, { action: "raise", under: "top", dryRun: false });
323 expect(applied.status).toBe(201);
324 expect(top().slice(0, 2)).toEqual(names(trust, feed));
325 const viaTool = await tool(tokenOf(alice, "ro"), { app: "alice/order", issue: trust.number, action: "lower", under: "top" });
326 expect(viaTool.value.dryRun).toBe(true);
327 expect(viaTool.value.order.slice(0, 2).map((i: { number: number }) => i.number)).toEqual(names(feed, trust));
328 const done = await tool(tokenOf(alice, "admin"), { app: "alice/order", issue: trust.number, action: "lower", under: "top", dryRun: false });
329 expect(done.value.rows[0].outcome).toBe("ok");
330 expect(top().slice(0, 2)).toEqual(names(feed, trust));
331 const back = await tool(tokenOf(alice, "admin"), { app: "alice/order", action: "undo", operation: done.value.id, dryRun: false });
332 expect(back.value.rows[0].outcome).toBe("ok");
333 expect(top().slice(0, 2)).toEqual(names(trust, feed));
334 });
335
336 test("the page: Raise and Lower for an admin, the preview, the receipt, the undo", async () => {
337 const list = await (await get("alice", "/alice/order/milestones")).text();
338 expect(list).toContain("/milestones/" + feed.number + "/limb?action=raise&amp;under=top");
339 expect(list).not.toContain("/milestones/" + trust.number + "/limb?action=raise");
340 const page = await (await get("alice", `/alice/order/milestones/${feed.number}`)).text();
341 expect(page).toContain(`/milestones/${s2.number}/limb?action=raise&amp;under=${feed.number}`);
342 const preview = await (
343 await get("alice", `/alice/order/milestones/${feed.number}/limb?action=raise&under=top`)
344 ).text();
345 expect(preview).toContain('id="limb-order"');
346 expect(preview).toContain('id="limb-ready"');
347 const posted = await submit("alice", `/alice/order/milestones/${feed.number}/limb`, { action: "raise", under: "top" });
348 expect(posted.status).toBe(303);
349 expect(top().slice(0, 2)).toEqual(names(feed, trust));
350 const receiptPage = await (await get("alice", posted.headers.get("location")!)).text();
351 expect(receiptPage).toContain("Moved");
352 expect(receiptPage).toContain("place 2");
353 const op = posted.headers.get("location")!.split("/").pop()!;
354 const undo = await submit("alice", `/alice/order/milestones/${feed.number}/limb/${op}/undo`, {});
355 expect(undo.status).toBe(303);
356 expect(top().slice(0, 2)).toEqual(names(trust, feed));
357 });
358 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs