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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1064-raise-or-lower-a-limb-against-its-siblin → main 5 commits fork 4 threads · 4 unresolved
docs/PLANNING.md +15 −3
| @@ -307,14 +307,26 @@ one undo. | ||
| 307 | 307 | - **Close as not doing** closes every open issue that is the limb's own, |
| 308 | 308 | the limb included, as `dropped`, with the reason said on each. What other |
| 309 | 309 | 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. | |
| 310 | 323 | |
| 311 | 324 | An umbrella or the gate is the plan's own and is not filed. A leaf beneath |
| 312 | 325 | 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. | |
| 315 | 327 | |
| 316 | 328 | 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"? }` | |
| 318 | 330 | and the MCP's `limb`, both a dry run unless `dryRun` is false. Applying and |
| 319 | 331 | undoing take an admin token of a person who administers the repository; an |
| 320 | 332 | agent may look and never apply. |
scripts/gate.ts +1
| @@ -178,6 +178,7 @@ export const GATE: readonly string[] = [ | ||
| 178 | 178 | "test/issues.test.ts", |
| 179 | 179 | "test/kernel.test.ts", |
| 180 | 180 | "test/keys.test.ts", |
| 181 | "test/limb_order.test.ts", | |
| 181 | 182 | "test/limbs.test.ts", |
| 182 | 183 | "test/live-pages.test.ts", |
| 183 | 184 | "test/live.test.ts", |
src/actions/fleet.ts +12
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | } from "../core/issues.ts"; |
| 35 | 35 | import { listCrewRoles } from "../core/agents.ts"; |
| 36 | 36 | import { joinMany } from "../core/pulls.ts"; |
| 37 | import { seating, type Seating } from "../core/limb_seats.ts"; | |
| 37 | 38 | import { pillarsOf, type Pillars } from "../core/pillars.ts"; |
| 38 | 39 | import { |
| 39 | 40 | canAdmin, |
| @@ -178,6 +179,16 @@ function loadTargets( | ||
| 178 | 179 | const pillars = new Map<string, Pillars>(); |
| 179 | 180 | for (const [repoId, repo] of repos) pillars.set(repoId, pillarsOf(app.db, repo)); |
| 180 | 181 | 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 | }; | |
| 181 | 192 | const peers: ReadonlySet<string> = new Set(ids); |
| 182 | 193 | const pull = (id: string) => { |
| 183 | 194 | const p = pulls.get(id); |
| @@ -207,6 +218,7 @@ function loadTargets( | ||
| 207 | 218 | planner: planners.get(repo.id) ?? null, |
| 208 | 219 | pillars: pillars.get(repo.id)!, |
| 209 | 220 | under: under.get(id) ?? [], |
| 221 | seating: seatingOf(repo), | |
| 210 | 222 | peers, |
| 211 | 223 | ...(viewer === undefined |
| 212 | 224 | ? {} |
src/actions/limbs.ts +82 −1
| @@ -16,6 +16,11 @@ | ||
| 16 | 16 | * one reason said on each — and never what something outside the limb |
| 17 | 17 | * also waits for. |
| 18 | 18 | * |
| 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 | * | |
| 19 | 24 | * Who may is the fleet's rule, unchanged: a person who administers the |
| 20 | 25 | * repository; an agent may preview and never apply. |
| 21 | 26 | */ |
| @@ -23,6 +28,9 @@ | ||
| 23 | 28 | import type { App } from "../app.ts"; |
| 24 | 29 | import type { FleetValue, Operation } from "../core/fleet_ops.ts"; |
| 25 | 30 | 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"; | |
| 26 | 34 | import { readPlan } from "../core/milestones.ts"; |
| 27 | 35 | import { isStructural, pillarsOf } from "../core/pillars.ts"; |
| 28 | 36 | import { canRead, type Repo } from "../core/repos.ts"; |
| @@ -35,7 +43,7 @@ import { | ||
| 35 | 43 | type Plan, |
| 36 | 44 | } from "./fleet.ts"; |
| 37 | 45 | |
| 38 | export const LIMB_ACTIONS = ["defer", "resume", "close"] as const; | |
| 46 | export const LIMB_ACTIONS = ["defer", "resume", "close", "raise", "lower"] as const; | |
| 39 | 47 | export type LimbAction = (typeof LIMB_ACTIONS)[number]; |
| 40 | 48 | |
| 41 | 49 | export const isLimbAction = (value: unknown): value is LimbAction => |
| @@ -47,6 +55,8 @@ export interface LimbRequest { | ||
| 47 | 55 | readonly reason?: string; |
| 48 | 56 | /** For `resume`: the pillar to put it back under; absent leaves it under none. */ |
| 49 | 57 | 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; | |
| 50 | 60 | } |
| 51 | 61 | |
| 52 | 62 | /** An issue an action touches or takes along, as a person reads it. */ |
| @@ -62,8 +72,17 @@ export interface LimbReach { | ||
| 62 | 72 | readonly taken: readonly LimbIssue[]; |
| 63 | 73 | /** For `defer` and `close`: what beneath it stays, because something outside the limb still waits for it. */ |
| 64 | 74 | 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 | }; | |
| 65 | 81 | } |
| 66 | 82 | |
| 83 | /** How many ready issues the preview shows: the top of the list. */ | |
| 84 | const TOP_READY = 5; | |
| 85 | ||
| 67 | 86 | const brief = (issue: Issue): LimbIssue => ({ |
| 68 | 87 | number: issue.number, |
| 69 | 88 | title: issue.title, |
| @@ -72,6 +91,66 @@ const brief = (issue: Issue): LimbIssue => ({ | ||
| 72 | 91 | const fail = (code: FleetError["code"], message: string) => |
| 73 | 92 | err<FleetError>({ code, message }); |
| 74 | 93 | |
| 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 | ||
| 75 | 154 | /** The fleet operation a limb action is, and what it reaches. */ |
| 76 | 155 | function shape( |
| 77 | 156 | app: App, |
| @@ -93,6 +172,8 @@ function shape( | ||
| 93 | 172 | return fail("not_found", "no such milestone here"); |
| 94 | 173 | const limb = brief(rows.own[0]); |
| 95 | 174 | 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); | |
| 96 | 177 | if (request.action === "close") { |
| 97 | 178 | const reason = (request.reason ?? "").trim(); |
| 98 | 179 | if (reason === "") |
src/core/dependencies.ts +25 −1
| @@ -14,6 +14,7 @@ import { getIssuesByIds, type CloseReason, type Issue } from "./issues.ts"; | ||
| 14 | 14 | import { pillarsOf, structuralIds } from "./pillars.ts"; |
| 15 | 15 | import { getRepoById } from "./repos.ts"; |
| 16 | 16 | import { unadoptedAmong } from "../crew/direct.ts"; |
| 17 | import { limbRanks, limbTree, readPlaces } from "./limb_order.ts"; | |
| 17 | 18 | |
| 18 | 19 | export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue"; |
| 19 | 20 | |
| @@ -215,6 +216,12 @@ export interface Graph { | ||
| 215 | 216 | * words, as every surface says it (`deferredWhy`). |
| 216 | 217 | */ |
| 217 | 218 | 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>; | |
| 218 | 225 | } |
| 219 | 226 | |
| 220 | 227 | /** |
| @@ -318,7 +325,16 @@ export function repoGraph( | ||
| 318 | 325 | ); |
| 319 | 326 | const repo = getRepoById(db, repoId); |
| 320 | 327 | const set = repo === undefined ? undefined : pillarsOf(db, repo); |
| 328 | const places = readPlaces(db, repoId); | |
| 321 | 329 | return { |
| 330 | ...(set === undefined || places.size === 0 | |
| 331 | ? {} | |
| 332 | : { | |
| 333 | limbRank: limbRanks( | |
| 334 | limbTree({ issues, edges: kept }, set), | |
| 335 | places, | |
| 336 | ), | |
| 337 | }), | |
| 322 | 338 | issues, |
| 323 | 339 | unadopted: |
| 324 | 340 | repo === undefined ? new Set() : unadoptedAmong(db, repo, issues), |
| @@ -509,14 +525,22 @@ function clearNow(graph: Graph): Ranked[] { | ||
| 509 | 525 | * reader's suggestion only once someone who writes here adopted it, and |
| 510 | 526 | * never the plan's own furniture: an umbrella or the Later gate is not |
| 511 | 527 | * 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. | |
| 512 | 532 | */ |
| 513 | 533 | export function readyNow(graph: Graph): Ranked[] { |
| 514 | return clearNow(graph).filter( | |
| 534 | const ready = clearNow(graph).filter( | |
| 515 | 535 | (r) => |
| 516 | 536 | !graph.unadopted.has(r.issue.id) && |
| 517 | 537 | !graph.structural.has(r.issue.id) && |
| 518 | 538 | !graph.deferred.has(r.issue.id), |
| 519 | 539 | ); |
| 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)); | |
| 520 | 544 | } |
| 521 | 545 | |
| 522 | 546 | /** |
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 |
||
| 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 @@ | ||
| 15 | 15 | |
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | 17 | 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"; | |
| 18 | 26 | import type { Issue } from "./issues.ts"; |
| 19 | 27 | import { |
| 20 | 28 | isStructural, |
| @@ -106,6 +114,8 @@ export interface Milestone extends Limb { | ||
| 106 | 114 | } |
| 107 | 115 | |
| 108 | 116 | 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 }; | |
| 109 | 119 | /** The top-level limbs, in order: the pillars and Later, or the plan's roots. */ |
| 110 | 120 | readonly limbs: readonly Limb[]; |
| 111 | 121 | /** |
| @@ -165,39 +175,10 @@ export function readPlan( | ||
| 165 | 175 | const byId = new Map(graph.issues.map((i) => [i.id, i])); |
| 166 | 176 | const byNumber = new Map(graph.issues.map((i) => [i.number, i])); |
| 167 | 177 | |
| 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; | |
| 201 | 182 | const memo = new Map<string, Set<string>>(); |
| 202 | 183 | const walking = new Set<string>(); |
| 203 | 184 | const beneath = (id: string): Set<string> => { |
| @@ -214,6 +195,11 @@ export function readPlan( | ||
| 214 | 195 | memo.set(id, out); |
| 215 | 196 | return out; |
| 216 | 197 | }; |
| 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); | |
| 217 | 203 | |
| 218 | 204 | // Where the crew is: the issue a run is on, or the issue its pull closes. |
| 219 | 205 | const proposed = openPullsClosing(db, repo.id); |
| @@ -354,24 +340,13 @@ export function readPlan( | ||
| 354 | 340 | }; |
| 355 | 341 | }; |
| 356 | 342 | |
| 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 | ); | |
| 373 | 347 | |
| 374 | 348 | return { |
| 349 | read: { graph, tree, places }, | |
| 375 | 350 | limbs: top.map(limbOf), |
| 376 | 351 | limb(number: number): { own: Issue[]; shared: Issue[] } | undefined { |
| 377 | 352 | const issue = byNumber.get(number); |
| @@ -484,11 +459,7 @@ export function readPlan( | ||
| 484 | 459 | .map((id) => byId.get(id)) |
| 485 | 460 | .filter((i): i is Issue => i !== undefined) |
| 486 | 461 | .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)); | |
| 492 | 463 | const closedThisWeek = under.filter( |
| 493 | 464 | (i) => |
| 494 | 465 | i.state === "closed" && counted(i) && (i.closedAt ?? "") >= since, |
| @@ -534,9 +505,9 @@ export function readPlan( | ||
| 534 | 505 | .filter((pull) => pull.closes.some((n) => numbers.has(n))) |
| 535 | 506 | .map(({ number: n, title, at }) => ({ number: n, title, at })), |
| 536 | 507 | estimate, |
| 537 | limbs: children.filter(isLimb).map(limbOf), | |
| 508 | limbs: arranged(issue.id, [...limbIds]).map(limbOf), | |
| 538 | 509 | leaves: children |
| 539 | .filter((c) => !isLimb(c)) | |
| 510 | .filter((c) => !limbIds.has(c.id)) | |
| 540 | 511 | .map((c) => { |
| 541 | 512 | const state = stateOf(c); |
| 542 | 513 | const why = |
src/core/schema.ts +11
| @@ -1584,6 +1584,17 @@ export const migrations: readonly string[] = [ | ||
| 1584 | 1584 | ALTER TABLE look ADD COLUMN attached_by TEXT; |
| 1585 | 1585 | ALTER TABLE look ADD COLUMN note TEXT; |
| 1586 | 1586 | `, |
| 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. |
||
| 1594 | place INTEGER NOT NULL, | |
| 1595 | PRIMARY KEY (repo_id, parent_id, issue_id) | |
| 1596 | ); | |
| 1597 | `, | |
| 1587 | 1598 | ]; |
| 1588 | 1599 | |
| 1589 | 1600 | /** |
src/fleet/verbs.ts +49
| @@ -23,6 +23,7 @@ import { cancelRepoRun, requeueRepoRun } from "../actions/runs.ts"; | ||
| 23 | 23 | import { isCrew } from "../core/agents.ts"; |
| 24 | 24 | import { mergePull } from "../actions/pulls.ts"; |
| 25 | 25 | import { bringUpToDate } from "../crew/autonomy.ts"; |
| 26 | import { moveLimb, parseSeat, type Seating } from "../core/limb_seats.ts"; | |
| 26 | 27 | import { getHold } from "../core/holds.ts"; |
| 27 | 28 | import { recordInteraction, removeDismissals } from "../core/interactions.ts"; |
| 28 | 29 | import { getPull } from "../core/pulls.ts"; |
| @@ -69,6 +70,8 @@ export interface Target { | ||
| 69 | 70 | readonly pillars?: Pillars; |
| 70 | 71 | /** For an issue: the numbers of the umbrellas that wait for it directly, ascending. */ |
| 71 | 72 | readonly under?: readonly number[]; |
| 73 | /** For an issue: its repository's limbs in order, read on the first ask. */ | |
| 74 | readonly seating?: () => Seating; | |
| 72 | 75 | /** For an issue: the ids of every row this operation is over, itself included. */ |
| 73 | 76 | readonly peers?: ReadonlySet<string>; |
| 74 | 77 | } |
| @@ -676,4 +679,50 @@ export const VERBS: Record<string, Verb> = { | ||
| 676 | 679 | ); |
| 677 | 680 | }, |
| 678 | 681 | }, |
| 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 | }, | |
| 679 | 728 | }; |
src/http/api.ts +5 −2
| @@ -2510,7 +2510,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2510 | 2510 | }, |
| 2511 | 2511 | ); |
| 2512 | 2512 | // 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 | |
| 2514 | 2514 | // read credential, an admin token to apply, the receipt in |
| 2515 | 2515 | // /api/v1/fleet/ops/:id and its undo at …/undo. |
| 2516 | 2516 | router.add( |
| @@ -2525,11 +2525,14 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2525 | 2525 | const number = positiveInt(ctx.params.number); |
| 2526 | 2526 | if (!repo || number === undefined) return notFound(); |
| 2527 | 2527 | 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"); | |
| 2529 | 2529 | const request = { |
| 2530 | 2530 | action: input.action, |
| 2531 | 2531 | ...(typeof input.reason === "string" ? { reason: input.reason } : {}), |
| 2532 | 2532 | ...(typeof input.pillar === "string" ? { pillar: input.pillar } : {}), |
| 2533 | ...(typeof input.under === "string" || typeof input.under === "number" | |
| 2534 | ? { under: String(input.under) } | |
| 2535 | : {}), | |
| 2533 | 2536 | }; |
| 2534 | 2537 | if (dryRun) { |
| 2535 | 2538 | 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[] = [ | ||
| 1249 | 1249 | { |
| 1250 | 1250 | name: "limb", |
| 1251 | 1251 | 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.", | |
| 1253 | 1253 | inputSchema: { |
| 1254 | 1254 | type: "object", |
| 1255 | 1255 | properties: { |
| @@ -1259,7 +1259,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1259 | 1259 | minimum: 1, |
| 1260 | 1260 | description: "The limb: the number of its issue.", |
| 1261 | 1261 | }, |
| 1262 | action: { type: "string", enum: ["defer", "resume", "close", "undo"] }, | |
| 1262 | action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "undo"] }, | |
| 1263 | 1263 | reason: { |
| 1264 | 1264 | type: "string", |
| 1265 | 1265 | description: "For close: why it is not being done, in one sentence.", |
| @@ -1269,6 +1269,11 @@ const TOOLS: readonly Tool[] = [ | ||
| 1269 | 1269 | description: |
| 1270 | 1270 | "For resume: the pillar to put it back under, by name or short form; under none when absent.", |
| 1271 | 1271 | }, |
| 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 | }, | |
| 1272 | 1277 | operation: { |
| 1273 | 1278 | type: "string", |
| 1274 | 1279 | description: "For undo: the id of the operation to reverse.", |
| @@ -1303,11 +1308,14 @@ const TOOLS: readonly Tool[] = [ | ||
| 1303 | 1308 | !Number.isInteger(number) || |
| 1304 | 1309 | number < 1 |
| 1305 | 1310 | ) |
| 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"); | |
| 1307 | 1312 | const request = { |
| 1308 | 1313 | action: args["action"], |
| 1309 | 1314 | ...(typeof args["reason"] === "string" ? { reason: args["reason"] } : {}), |
| 1310 | 1315 | ...(typeof args["pillar"] === "string" ? { pillar: args["pillar"] } : {}), |
| 1316 | ...(typeof args["under"] === "string" || typeof args["under"] === "number" | |
| 1317 | ? { under: String(args["under"]) } | |
| 1318 | : {}), | |
| 1311 | 1319 | }; |
| 1312 | 1320 | if (dry) { |
| 1313 | 1321 | 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 }> = { | ||
| 50 | 50 | does: "Every open issue that only this limb needs closes as not doing, with your reason said on each.", |
| 51 | 51 | go: "Close as not doing", |
| 52 | 52 | }, |
| 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}`; | |
| 53 | 69 | }; |
| 54 | 70 | |
| 55 | 71 | const shown = (value: FleetValue): string => |
| @@ -58,7 +74,7 @@ const shown = (value: FleetValue): string => | ||
| 58 | 74 | : Array.isArray(value) |
| 59 | 75 | ? value.length === 0 |
| 60 | 76 | ? "filed nowhere" |
| 61 | : value.join(", ") | |
| 77 | : value.map(seatWords).join(", ") | |
| 62 | 78 | : String(value); |
| 63 | 79 | |
| 64 | 80 | /** How many issues a list shows before the rest folds away. */ |
| @@ -134,10 +150,12 @@ function requestOf( | ||
| 134 | 150 | ): LimbRequest { |
| 135 | 151 | const reason = get("reason").trim(); |
| 136 | 152 | const pillar = get("pillar").trim(); |
| 153 | const under = get("under").trim(); | |
| 137 | 154 | return { |
| 138 | 155 | action, |
| 139 | 156 | ...(reason === "" ? {} : { reason }), |
| 140 | 157 | ...(pillar === "" ? {} : { pillar }), |
| 158 | ...(under === "" ? {} : { under }), | |
| 141 | 159 | }; |
| 142 | 160 | } |
| 143 | 161 | |
| @@ -179,6 +197,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 179 | 197 | } |
| 180 | 198 | const p = plan.value; |
| 181 | 199 | const willWrite = p.counts.change > 0; |
| 200 | const moving = action === "raise" || action === "lower"; | |
| 182 | 201 | const pillars = |
| 183 | 202 | action === "resume" ? pillarsOf(ctx.app.db, repo).pillars : []; |
| 184 | 203 | const body = html`${repoHeader(ctx, repo, "map")} |
| @@ -189,6 +208,41 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 189 | 208 | <h1>${words.ask} “${p.limb.title}”?</h1> |
| 190 | 209 | <p>${words.does}</p> |
| 191 | 210 | </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 | )} | |
| 192 | 246 | ${when( |
| 193 | 247 | p.taken.length > 0, |
| 194 | 248 | () => |
| @@ -213,12 +267,14 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 213 | 267 | ${issueList(base, p.kept)} |
| 214 | 268 | </section>`, |
| 215 | 269 | )} |
| 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>`} | |
| 222 | 278 | ${willWrite |
| 223 | 279 | ? html`<form |
| 224 | 280 | method="post" |
| @@ -226,6 +282,10 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 226 | 282 | class="limb-confirm mt" |
| 227 | 283 | > |
| 228 | 284 | <input type="hidden" name="action" value="${action}" /> |
| 285 | ${when( | |
| 286 | request.under, | |
| 287 | (under) => html`<input type="hidden" name="under" value="${under}" />`, | |
| 288 | )} | |
| 229 | 289 | ${when( |
| 230 | 290 | action === "close", |
| 231 | 291 | () => |
| @@ -305,7 +365,9 @@ export async function limbSubmit(ctx: Ctx): Promise<Response> { | ||
| 305 | 365 | if (!op.ok) |
| 306 | 366 | return op.error.code === "not_found" |
| 307 | 367 | ? 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 | ); | |
| 309 | 371 | return redirect(`${milestoneHref(base, number)}/limb/${op.value.id}`); |
| 310 | 372 | } |
| 311 | 373 | |
| @@ -313,6 +375,7 @@ export async function limbSubmit(ctx: Ctx): Promise<Response> { | ||
| 313 | 375 | function done(op: Operation): string { |
| 314 | 376 | if (op.undoOf !== null) return "Undone"; |
| 315 | 377 | if (op.verb === "drop") return "Closed as not doing"; |
| 378 | if (op.verb === "place") return "Moved"; | |
| 316 | 379 | return op.value === "later" ? "Deferred" : "Resumed"; |
| 317 | 380 | } |
| 318 | 381 |
src/web/pages/milestones.ts +72 −8
| @@ -17,7 +17,12 @@ import { | ||
| 17 | 17 | type LeafState, |
| 18 | 18 | type Limb, |
| 19 | 19 | type Milestone, |
| 20 | type Plan, | |
| 20 | 21 | } 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"; | |
| 21 | 26 | import type { LimbAction } from "../../actions/limbs.ts"; |
| 22 | 27 | import { isStructural, PILLAR_DAYS, pillarsOf } from "../../core/pillars.ts"; |
| 23 | 28 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| @@ -47,7 +52,53 @@ export const limbHref = ( | ||
| 47 | 52 | base: string, |
| 48 | 53 | number: number, |
| 49 | 54 | 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 |
||
| 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 | } | |
| 51 | 102 | |
| 52 | 103 | /** |
| 53 | 104 | * The actions on a whole limb, for whoever administers the repository: |
| @@ -60,10 +111,12 @@ function limbActions( | ||
| 60 | 111 | m: Milestone, |
| 61 | 112 | furniture: boolean, |
| 62 | 113 | lead: Html | null, |
| 114 | move: Html | null, | |
| 63 | 115 | ): Html { |
| 64 | 116 | const number = m.issue.number; |
| 65 | 117 | return html`<p class="bar ms-actions" id="limb-actions"> |
| 66 | 118 | ${lead} |
| 119 | ${move} | |
| 67 | 120 | ${furniture |
| 68 | 121 | ? null |
| 69 | 122 | : m.deferred |
| @@ -101,7 +154,7 @@ function weightWords(limb: Limb): Html { | ||
| 101 | 154 | } |
| 102 | 155 | |
| 103 | 156 | /** 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 { | |
| 105 | 158 | const w = limb.weight; |
| 106 | 159 | return html`<li class="ms-limb" data-trim="${limb.trim === undefined ? "no" : "yes"}"> |
| 107 | 160 | <a class="ms-name" href="${milestoneHref(base, limb.issue.number)}" |
| @@ -120,6 +173,7 @@ function limbLine(base: string, limb: Limb): Html { | ||
| 120 | 173 | (why) => |
| 121 | 174 | html`<span class="ms-trim small">A candidate to trim: ${why}.</span>`, |
| 122 | 175 | )} |
| 176 | ${move} | |
| 123 | 177 | </li>`; |
| 124 | 178 | } |
| 125 | 179 | |
| @@ -131,6 +185,7 @@ export function milestonesPage(ctx: Ctx): Response { | ||
| 131 | 185 | spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user), |
| 132 | 186 | }); |
| 133 | 187 | const base = repoBase(repo); |
| 188 | const move = moverFor(ctx.app.db, repo, ctx.auth.user, plan.read); | |
| 134 | 189 | const body = html`${repoHeader(ctx, repo, "map")} |
| 135 | 190 | <div class="page-head mt"> |
| 136 | 191 | <h1>Milestones</h1> |
| @@ -145,7 +200,7 @@ export function milestonesPage(ctx: Ctx): Response { | ||
| 145 | 200 | show. <a href="${base}/map">See the map</a>. |
| 146 | 201 | </p>` |
| 147 | 202 | : 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))} | |
| 149 | 204 | </ul>`}`; |
| 150 | 205 | return page({ |
| 151 | 206 | ctx, |
| @@ -208,6 +263,7 @@ function milestoneBody( | ||
| 208 | 263 | m: Milestone, |
| 209 | 264 | names: boolean, |
| 210 | 265 | actions: Html | null, |
| 266 | move?: Mover, | |
| 211 | 267 | ): Html { |
| 212 | 268 | const base = repoBase(repo); |
| 213 | 269 | const issue = (number: number, title: string): Html => |
| @@ -257,7 +313,9 @@ function milestoneBody( | ||
| 257 | 313 | html`<section class="ms-section" id="ms-limbs"> |
| 258 | 314 | <h2>Limbs</h2> |
| 259 | 315 | <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 | )} | |
| 261 | 319 | </ul> |
| 262 | 320 | </section>`, |
| 263 | 321 | )} |
| @@ -378,10 +436,11 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 378 | 436 | if (!repo) return notFoundPage(ctx); |
| 379 | 437 | const number = Number(ctx.params.number); |
| 380 | 438 | 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 }) | |
| 383 | 441 | : undefined; |
| 384 | if (milestone === undefined) return notFoundPage(ctx); | |
| 442 | const milestone = plan?.milestone(number); | |
| 443 | if (plan === undefined || milestone === undefined) return notFoundPage(ctx); | |
| 385 | 444 | const viewer = ctx.auth.user; |
| 386 | 445 | // Watch is each person's own; acting on the whole limb is for whoever |
| 387 | 446 | // administers the repository. One row, Watch first. |
| @@ -393,12 +452,16 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 393 | 452 | isWatching(ctx.app.db, viewer.id, milestone.issue.id), |
| 394 | 453 | ) |
| 395 | 454 | : 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); | |
| 396 | 458 | const actions = canAdmin(ctx.app.db, repo, ctx.auth.user) |
| 397 | 459 | ? limbActions( |
| 398 | 460 | repoBase(repo), |
| 399 | 461 | milestone, |
| 400 | 462 | isStructural(pillarsOf(ctx.app.db, repo), milestone.issue.id), |
| 401 | 463 | watch, |
| 464 | seat === undefined ? null : (move?.(milestone.issue, seat) ?? null), | |
| 402 | 465 | ) |
| 403 | 466 | : watch === null |
| 404 | 467 | ? null |
| @@ -409,7 +472,7 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 409 | 472 | crumbs: crumbsFor(repo), |
| 410 | 473 | palette: repoPalette(ctx, repo), |
| 411 | 474 | body: html`${repoHeader(ctx, repo, "map")} |
| 412 | ${milestoneBody(repo, milestone, owner, actions)}`, | |
| 475 | ${milestoneBody(repo, milestone, owner, actions, move)}`, | |
| 413 | 476 | styleCss: milestonesCss, |
| 414 | 477 | }); |
| 415 | 478 | } |
| @@ -492,6 +555,7 @@ export const milestonesCss = /* css */ ` | ||
| 492 | 555 | .ms-top h1, .ms-top p { margin: 0; } |
| 493 | 556 | .ms-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; } |
| 494 | 557 | .ms-actions { margin-top: 4px; } |
| 558 | .ms-move { grid-column: 1 / -1; display: flex; gap: 14px; } | |
| 495 | 559 | .ms-section { margin-top: 22px; } |
| 496 | 560 | .ms-section h2 { font-size: 13px; margin: 0 0 6px; } |
| 497 | 561 | .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&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&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 | }); |
This gate is what undo has to bring back. Today it only flips one way: once a group is stored,
limbRankis on for the repository even after every move is undone.