Public: anyone can read this; the crew can change it.
Re-plan a limb lands from pull #2561's branch, with sealed titles tested, a bounded query and the refusal for non-admins #2588
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2585-re-plan-a-limb-lands-from-pull-2561-s-br → main 7 commits fork 4 threads · 4 unresolved
README.md +4 −1
| @@ -300,7 +300,10 @@ repository's limbs in the order the crew takes ready work (`enter` opens a | ||
| 300 | 300 | limb's own limbs). A person who administers the repository presses `r` to |
| 301 | 301 | Raise or `l` to Lower the limb under the cursor: a preview of the new order |
| 302 | 302 | and of the top of the ready list first, then `enter` to apply (`esc` backs |
| 303 | out), then `u` to undo the last move. It is the same fleet operation the web | |
| 303 | out), then `u` to undo the last move. `p` hands the limb to the planner to | |
| 304 | re-plan as fewer, larger pieces: it lists what the planner will be given, then | |
| 305 | `enter` confirms; the row reads *with the planner*, and `p` takes it back | |
| 306 | until the planner starts. It is the same fleet operation the web | |
| 304 | 307 | milestones page uses, and a move is heard by that page when it is open. |
| 305 | 308 | Anyone else sees the order and no keys to change it; a guest sees only what |
| 306 | 309 | the web shows them. Whoever may see owner facts also gets two lines under |
docs/PLANNING.md +22 −2
| @@ -325,13 +325,33 @@ one undo. | ||
| 325 | 325 | the ready order changes. It is one fleet row, the verb `place`; undo puts |
| 326 | 326 | the place back. Through the API and the MCP `limb` tool (`action` raise or |
| 327 | 327 | lower, `under` when the limb sits in several places), dry-run by default. |
| 328 | - **Re-plan** (#761) hands the limb to the planner to come back as fewer, | |
| 329 | larger pieces. The preview lists every open issue the planner will be | |
| 330 | given. Pressing it writes the hand-over (`limb_replan`, with the issues | |
| 331 | it was given) and owes the planner one pass, even with a full map, even | |
| 332 | after it declined the direction, and without waiting out the hour since | |
| 333 | its last pass. Its task names the limb and lists the issues. Until the | |
| 334 | planner has answered — a planning run begun after the hand-over has | |
| 335 | ended — the limb reads _With the planner_ on its milestone and the crew | |
| 336 | picks nothing beneath it (the `with_planner` hold). The planner may close | |
| 337 | an issue beneath it only as `split` into open issues made since the | |
| 338 | hand-over, as for a twice-parked issue; it may not drop one, and the | |
| 339 | hand-over grants nothing outside the limb. A person may **take it back** | |
| 340 | until a planning run has begun. Afterwards the milestone lists which old | |
| 341 | issue closed into which. Not a fleet operation: it writes no issue row, | |
| 342 | so there is no receipt to undo; taking it back is the undo. | |
| 343 | In the terminal, `p` on the limbs screen (Milestones in a repository) | |
| 344 | previews what the planner is given and hands the limb over on confirm; the | |
| 345 | row then reads _with the planner_, and `p` takes it back while allowed. | |
| 346 | Through the API and the MCP `limb` tool as `action` `replan` or | |
| 347 | `takeback`, dry-run by default (the dry run is the list of issues). | |
| 328 | 348 | |
| 329 | 349 | An umbrella or the gate is the plan's own and is not filed. A leaf beneath |
| 330 | 350 | a limb that is not deferred cannot be deferred alone: defer the limb. A |
| 331 | candidate to trim offers Defer first. Handing one to the planner is #761. | |
| 351 | candidate to trim offers Defer first. | |
| 332 | 352 | |
| 333 | 353 | The same is `POST /api/v1/repos/:owner/:name/milestones/:number/limb` |
| 334 | `{ "action": "defer" | "resume" | "close" | "raise" | "lower", "reason"?, "pillar"?, "under"?, "dryRun"? }` | |
| 354 | `{ "action": "defer" | "resume" | "close" | "raise" | "lower" | "replan" | "takeback", "reason"?, "pillar"?, "under"?, "dryRun"? }` | |
| 335 | 355 | and the MCP's `limb`, both a dry run unless `dryRun` is false. Applying and |
| 336 | 356 | undoing take an admin token of a person who administers the repository; an |
| 337 | 357 | agent may look and never apply. |
src/actions/issues.ts +8 −2
| @@ -55,6 +55,7 @@ import { | ||
| 55 | 55 | unadoptedAmong, |
| 56 | 56 | } from "../crew/direct.ts"; |
| 57 | 57 | import { laterGate } from "../crew/later.ts"; |
| 58 | import { replanSince } from "../core/limb_replan.ts"; | |
| 58 | 59 | import { handedOver } from "../crew/parks.ts"; |
| 59 | 60 | import { alreadyDone, CLOSED_NOTE } from "../core/done_on_main.ts"; |
| 60 | 61 | import { listReviews } from "../core/reviews.ts"; |
| @@ -511,17 +512,22 @@ function handedToPlanner( | ||
| 511 | 512 | // Replacements must be newer than the hand-over when the crew handed the |
| 512 | 513 | // issue over, and newer than the issue itself when the planner re-plans it |
| 513 | 514 | // on its own pass. |
| 514 | const since = handedOver(app.db, repo, issue.id) ?? { | |
| 515 | // A limb a person handed over (`core/limb_replan.ts`): newer than that | |
| 516 | // hand-over, and only as a split — a person's issue beneath it is not dropped. | |
| 517 | const limbSince = replanSince(app.db, repo, issue); | |
| 518 | const own = handedOver(app.db, repo, issue.id) ?? { | |
| 515 | 519 | at: issue.createdAt, |
| 516 | 520 | id: issue.id, |
| 517 | 521 | }; |
| 522 | const since = limbSince !== undefined && limbSince > own.at ? { at: limbSince, id: "" } : own; | |
| 518 | 523 | // Done on main: only when commits already name the issue, as the close |
| 519 | 524 | // note keeps them. |
| 520 | 525 | if (closing.reason === undefined || closing.reason === "done") |
| 521 | 526 | return (alreadyDone(app.db, [issue.id]).get(issue.id) ?? []).length > 0; |
| 522 | 527 | // Dropped only when no person's ask stands in it — not one the planner |
| 523 | 528 | // wrote to replace a person's, however many times it was split since. |
| 524 | if (closing.reason === "dropped") return !standsForPerson(app.db, issue); | |
| 529 | if (closing.reason === "dropped") | |
| 530 | return limbSince === undefined && !standsForPerson(app.db, issue); | |
| 525 | 531 | if (closing.reason !== "split") return false; |
| 526 | 532 | const into = closing.into ?? []; |
| 527 | 533 | if (into.length === 0) return false; |
src/actions/limb_replan.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * Hand a limb to the planner (#761): `replan` writes the hand-over, `takeback` | |
| 3 | * withdraws it before the planner has been given it. Both are a person's | |
| 4 | * who administers the repository; anyone who can read may see the preview, | |
| 5 | * which lists every issue the planner will be given. The planner's own | |
| 6 | * grant to close what is beneath is in `actions/issues.ts`. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { listCrewRoles } from "../core/agents.ts"; | |
| 11 | import { | |
| 12 | answeredFor, | |
| 13 | given, | |
| 14 | openBeneath, | |
| 15 | pendingFor, | |
| 16 | recordReplan, | |
| 17 | recordTakenBack, | |
| 18 | splitsOf, | |
| 19 | type Split, | |
| 20 | } from "../core/limb_replan.ts"; | |
| 21 | import { canAdmin, canRead, type Repo } from "../core/repos.ts"; | |
| 22 | import type { User } from "../core/users.ts"; | |
| 23 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 24 | import { getIssueById } from "../core/issues.ts"; | |
| 25 | import type { FleetError } from "./fleet.ts"; | |
| 26 | import type { LimbIssue } from "./limbs.ts"; | |
| 27 | ||
| 28 | export const REPLAN_ACTIONS = ["replan", "takeback"] as const; | |
| 29 | export type ReplanAction = (typeof REPLAN_ACTIONS)[number]; | |
| 30 | ||
| 31 | export const isReplanAction = (value: unknown): value is ReplanAction => | |
| 32 | (REPLAN_ACTIONS as readonly unknown[]).includes(value); | |
| 33 | ||
| 34 | export interface ReplanPreview { | |
| 35 | readonly limb: LimbIssue; | |
| 36 | /** Every open issue the planner will be given: the limb's own, the limb first. */ | |
| 37 | readonly issues: readonly LimbIssue[]; | |
| 38 | /** Whether it is with the planner already, and whether it can still be taken back. */ | |
| 39 | readonly withPlanner: boolean; | |
| 40 | readonly takeBack: boolean; | |
| 41 | /** Why it cannot be handed over now, when it cannot. */ | |
| 42 | readonly blocked?: string; | |
| 43 | } | |
| 44 | ||
| 45 | export interface ReplanDone { | |
| 46 | readonly done: "replan" | "takeback"; | |
| 47 | readonly limb: LimbIssue; | |
| 48 | readonly issues: readonly LimbIssue[]; | |
| 49 | } | |
| 50 | ||
| 51 | const fail = (code: FleetError["code"], message: string) => | |
| 52 | err<FleetError>({ code, message }); | |
| 53 | ||
| 54 | const brief = (i: { number: number; title: string }): LimbIssue => ({ | |
| 55 | number: i.number, | |
| 56 | title: i.title, | |
| 57 | }); | |
| 58 | ||
| 59 | function find( | |
| 60 | app: App, | |
| 61 | actor: User | undefined, | |
| 62 | repo: Repo, | |
| 63 | number: number, | |
| 64 | ): Result<ReplanPreview, FleetError> { | |
| 65 | if (!actor) return fail("unauthorized", "sign in to hand a limb to the planner"); | |
| 66 | if (!canRead(app.db, repo, actor)) return fail("not_found", "no such milestone here"); | |
| 67 | const issues = openBeneath(app.db, repo, number); | |
| 68 | const limb = issues.find((i) => i.number === number); | |
| 69 | if (limb === undefined) return fail("not_found", "no such milestone here"); | |
| 70 | const pending = pendingFor(app.db, repo, limb.id); | |
| 71 | const blocked = | |
| 72 | mayAct(app, actor, repo)?.message ?? | |
| 73 | (pending !== undefined | |
| 74 | ? "it is with the planner already" | |
| 75 | : repo.autonomy === "off" | |
| 76 | ? "the crew is not running here, so there is no planner to ask" | |
| 77 | : !listCrewRoles(app.db, repo.id).some((c) => c.role === "planner") | |
| 78 | ? "there is no planner on the crew" | |
| 79 | : issues.length < 2 | |
| 80 | ? "nothing is beneath it to re-plan" | |
| 81 | : undefined); | |
| 82 | return ok({ | |
| 83 | limb: brief(limb), | |
| 84 | issues: issues.map(brief), | |
| 85 | withPlanner: pending !== undefined, | |
| 86 | takeBack: pending !== undefined && !given(app.db, repo, pending.at), | |
| 87 | ...(blocked === undefined ? {} : { blocked }), | |
| 88 | }); | |
| 89 | } | |
| 90 | ||
| 91 | /** The dry run: every issue the planner will be given, and nothing written. */ | |
| 92 | export function previewReplan( | |
| 93 | app: App, | |
| 94 | actor: User | undefined, | |
| 95 | repo: Repo, | |
| 96 | number: number, | |
| 97 | ): Result<ReplanPreview, FleetError> { | |
| 98 | return find(app, actor, repo, number); | |
| 99 | } | |
| 100 | ||
| 101 | function mayAct(app: App, actor: User, repo: Repo): FleetError | undefined { | |
| 102 | if (actor.kind === "agent") | |
| 103 | return { code: "forbidden", message: "handing a limb to the planner is a person's call" }; | |
| 104 | if (!canAdmin(app.db, repo, actor)) | |
| 105 | return { code: "forbidden", message: "only a person who administers this app hands a limb over" }; | |
| 106 | return undefined; | |
| 107 | } | |
| 108 | ||
| 109 | /** Hand the limb over: the planner's next pass is owed it, once. */ | |
| 110 | export function replanLimb( | |
| 111 | app: App, | |
| 112 | actor: User | undefined, | |
| 113 | repo: Repo, | |
| 114 | number: number, | |
| 115 | ): Result<ReplanDone, FleetError> { | |
| 116 | const seen = find(app, actor, repo, number); | |
| 117 | if (!seen.ok) return seen; | |
| 118 | const refused = mayAct(app, actor!, repo); | |
| 119 | if (refused) return err(refused); | |
| 120 | if (seen.value.blocked !== undefined) return fail("bad_action", seen.value.blocked); | |
| 121 | const issues = openBeneath(app.db, repo, number); | |
| 122 | const limb = issues.find((i) => i.number === number)!; | |
| 123 | recordReplan(app.db, repo, limb, actor!.id, issues); | |
| 124 | app.bus.append({ | |
| 125 | kind: "limb.replan", | |
| 126 | repoId: repo.id, | |
| 127 | actorId: actor!.id, | |
| 128 | payload: { number, issues: issues.map((i) => i.number) }, | |
| 129 | }); | |
| 130 | return ok({ done: "replan", limb: brief(limb), issues: issues.map(brief) }); | |
| 131 | } | |
| 132 | ||
| 133 | /** Take it back, before the planner has been given it. */ | |
| 134 | export function takeBackLimb( | |
| 135 | app: App, | |
| 136 | actor: User | undefined, | |
| 137 | repo: Repo, | |
| 138 | number: number, | |
| 139 | ): Result<ReplanDone, FleetError> { | |
| 140 | const seen = find(app, actor, repo, number); | |
| 141 | if (!seen.ok) return seen; | |
| 142 | const refused = mayAct(app, actor!, repo); | |
| 143 | if (refused) return err(refused); | |
| 144 | const limb = openBeneath(app.db, repo, number).find((i) => i.number === number)!; | |
| 145 | const pending = pendingFor(app.db, repo, limb.id); | |
| 146 | if (pending === undefined) return fail("bad_action", "it is not with the planner"); | |
| 147 | if (!seen.value.takeBack) | |
| 148 | return fail("bad_action", "the planner has it in hand already"); | |
| 149 | recordTakenBack(app.db, pending.id); | |
| 150 | app.bus.append({ | |
| 151 | kind: "limb.replan_taken_back", | |
| 152 | repoId: repo.id, | |
| 153 | actorId: actor!.id, | |
| 154 | payload: { number }, | |
| 155 | }); | |
| 156 | return ok({ done: "takeback", limb: brief(limb), issues: seen.value.issues }); | |
| 157 | } | |
| 158 | ||
| 159 | /** What the planner's last answer on this limb changed: each old issue and what replaced it. */ | |
| 160 | export function replanResult( | |
| 161 | app: App, | |
| 162 | repo: Repo, | |
| 163 | limbId: string, | |
| 164 | ): { readonly at: string; readonly splits: readonly Split[] } | undefined { | |
| 165 | if (getIssueById(app.db, limbId) === undefined) return undefined; | |
| 166 | const r = answeredFor(app.db, repo, limbId); | |
| 167 | return r === undefined ? undefined : { at: r.at, splits: splitsOf(app.db, repo, r) }; | |
| 168 | } |
src/core/limb_replan.ts added +230
| @@ -0,0 +1,230 @@ | ||
| 1 | /** | |
| 2 | * A limb handed to the planner (#761). A person presses Re-plan on a | |
| 3 | * milestone: the limb and every open issue beneath it are written down, and | |
| 4 | * the planner's next pass is owed them, once. The hand-over is pending until | |
| 5 | * a planning run that was given it has ended, or the person takes it back | |
| 6 | * before any run starts. While it is pending nothing beneath the limb is | |
| 7 | * picked by the crew (`crew/held.ts`), and the planner may close what is | |
| 8 | * beneath it only as split into what it opens after the hand-over | |
| 9 | * (`actions/issues.ts`). After, the rows say which issue became which. | |
| 10 | * | |
| 11 | * Plain reads and writes over two tables; who may is the action's. | |
| 12 | */ | |
| 13 | ||
| 14 | import type { Database } from "bun:sqlite"; | |
| 15 | import { id } from "../kernel/id.ts"; | |
| 16 | import { transact } from "../kernel/db.ts"; | |
| 17 | import { getIssueById, type Issue } from "./issues.ts"; | |
| 18 | import { readPlan } from "./milestones.ts"; | |
| 19 | import type { Repo } from "./repos.ts"; | |
| 20 | ||
| 21 | export interface Replan { | |
| 22 | readonly id: string; | |
| 23 | readonly limbId: string; | |
| 24 | readonly byUser: string; | |
| 25 | readonly at: string; | |
| 26 | readonly takenBackAt: string | null; | |
| 27 | } | |
| 28 | ||
| 29 | interface Row { | |
| 30 | id: string; | |
| 31 | limb_issue_id: string; | |
| 32 | by_user: string; | |
| 33 | at: string; | |
| 34 | taken_back_at: string | null; | |
| 35 | } | |
| 36 | ||
| 37 | const of = (r: Row): Replan => ({ | |
| 38 | id: r.id, | |
| 39 | limbId: r.limb_issue_id, | |
| 40 | byUser: r.by_user, | |
| 41 | at: r.at, | |
| 42 | takenBackAt: r.taken_back_at, | |
| 43 | }); | |
| 44 | ||
| 45 | /** Whether a planning run was started after `at`, a cancelled one aside: the planner has been given it. */ | |
| 46 | export function given(db: Database, repo: Repo, at: string): boolean { | |
| 47 | if (repo.directionIssueId === null) return false; | |
| 48 | return ( | |
| 49 | db | |
| 50 | .query<{ n: number }, [string, string]>( | |
| 51 | `SELECT COUNT(*) AS n FROM run | |
| 52 | WHERE issue_id = ?1 AND kind = 'agent' AND state != 'cancelled' | |
outdated
High: this counts cancelled runs as "given", but |
||
| 53 | AND created_at > ?2`, | |
| 54 | ) | |
| 55 | .get(repo.directionIssueId, at)?.n ?? 0 | |
| 56 | ) > 0; | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * The start of the last finished planning run: a hand-over made after it has | |
| 61 | * not been answered, one made before it has. One bound in SQL, no per-row query. | |
| 62 | */ | |
| 63 | const LAST_ANSWER = `COALESCE((SELECT MAX(created_at) FROM run | |
| 64 | WHERE issue_id = ?2 AND kind = 'agent' AND state IN ('ok','failed')), '')`; | |
| 65 | ||
| 66 | /** The hand-overs the planner has not answered and nobody took back, newest first. */ | |
| 67 | export function pendingReplans(db: Database, repo: Repo): Replan[] { | |
| 68 | return db | |
| 69 | .query<Row, [string, string | null]>( | |
| 70 | `SELECT * FROM limb_replan | |
| 71 | WHERE repo_id = ?1 AND taken_back_at IS NULL AND at >= ${LAST_ANSWER} | |
| 72 | ORDER BY at DESC, id DESC`, | |
| 73 | ) | |
| 74 | .all(repo.id, repo.directionIssueId) | |
| 75 | .map(of); | |
| 76 | } | |
| 77 | ||
| 78 | /** The pending hand-over of this limb, if there is one. */ | |
| 79 | export function pendingFor( | |
| 80 | db: Database, | |
| 81 | repo: Repo, | |
| 82 | limbId: string, | |
| 83 | ): Replan | undefined { | |
| 84 | return pendingReplans(db, repo).find((r) => r.limbId === limbId); | |
| 85 | } | |
| 86 | ||
| 87 | /** The newest hand-over of this limb the planner answered. */ | |
| 88 | export function answeredFor( | |
| 89 | db: Database, | |
| 90 | repo: Repo, | |
| 91 | limbId: string, | |
| 92 | ): Replan | undefined { | |
| 93 | const row = db | |
| 94 | .query<Row, [string, string | null, string]>( | |
| 95 | `SELECT * FROM limb_replan | |
| 96 | WHERE repo_id = ?1 AND limb_issue_id = ?3 AND taken_back_at IS NULL | |
| 97 | AND at < ${LAST_ANSWER} | |
| 98 | ORDER BY at DESC, id DESC LIMIT 1`, | |
| 99 | ) | |
| 100 | .get(repo.id, repo.directionIssueId, limbId); | |
| 101 | return row === null ? undefined : of(row); | |
| 102 | } | |
| 103 | ||
| 104 | /** The issues a hand-over was given, as they were then. */ | |
| 105 | export function givenIssueIds(db: Database, replanId: string): string[] { | |
| 106 | return db | |
| 107 | .query<{ issue_id: string }, [string]>( | |
| 108 | `SELECT issue_id FROM limb_replan_issue WHERE replan_id = ?1`, | |
| 109 | ) | |
| 110 | .all(replanId) | |
| 111 | .map((r) => r.issue_id); | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * The open issues of a limb now, the limb included, in plan order: what | |
| 116 | * only it needs, so what something outside also waits for stays out. | |
| 117 | */ | |
| 118 | export function openBeneath( | |
| 119 | db: Database, | |
| 120 | repo: Repo, | |
| 121 | limbNumber: number, | |
| 122 | ): Issue[] { | |
| 123 | return ( | |
| 124 | readPlan(db, repo) | |
| 125 | .limb(limbNumber) | |
| 126 | ?.own.filter((i) => i.state === "open" && i.kind === "issue") ?? [] | |
| 127 | ); | |
| 128 | } | |
| 129 | ||
| 130 | /** Write the hand-over down: the limb, and each issue it was given. */ | |
| 131 | export function recordReplan( | |
| 132 | db: Database, | |
| 133 | repo: Repo, | |
| 134 | limb: Issue, | |
| 135 | byUser: string, | |
| 136 | issues: readonly Issue[], | |
| 137 | ): Replan { | |
| 138 | const rid = id(); | |
| 139 | transact(db, () => { | |
| 140 | db.query( | |
| 141 | `INSERT INTO limb_replan (id, repo_id, limb_issue_id, by_user) VALUES (?1, ?2, ?3, ?4)`, | |
| 142 | ).run(rid, repo.id, limb.id, byUser); | |
| 143 | for (const issue of issues) | |
| 144 | db.query( | |
| 145 | `INSERT INTO limb_replan_issue (replan_id, issue_id) VALUES (?1, ?2)`, | |
| 146 | ).run(rid, issue.id); | |
| 147 | }); | |
| 148 | return of(db.query<Row, [string]>(`SELECT * FROM limb_replan WHERE id = ?1`).get(rid)!); | |
| 149 | } | |
| 150 | ||
| 151 | /** Take it back: it is no longer pending. */ | |
| 152 | export function recordTakenBack(db: Database, replanId: string): void { | |
| 153 | db.query( | |
| 154 | `UPDATE limb_replan SET taken_back_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 155 | WHERE id = ?1 AND taken_back_at IS NULL`, | |
| 156 | ).run(replanId); | |
| 157 | } | |
| 158 | ||
| 159 | /** What a pending hand-over covers now: what it was given, and what is beneath the limb today. */ | |
| 160 | function covered(db: Database, repo: Repo, r: Replan): Set<string> { | |
| 161 | const ids = new Set(givenIssueIds(db, r.id)); | |
| 162 | const limb = getIssueById(db, r.limbId); | |
| 163 | if (limb !== undefined) | |
| 164 | for (const i of openBeneath(db, repo, limb.number)) ids.add(i.id); | |
| 165 | return ids; | |
| 166 | } | |
| 167 | ||
| 168 | /** Every issue the crew may not pick because its limb is with the planner. */ | |
| 169 | export function withPlanner(db: Database, repo: Repo): Set<string> { | |
| 170 | const out = new Set<string>(); | |
| 171 | for (const r of pendingReplans(db, repo)) | |
| 172 | for (const i of covered(db, repo, r)) out.add(i); | |
| 173 | return out; | |
| 174 | } | |
| 175 | ||
| 176 | /** | |
| 177 | * When a limb this issue sits beneath was handed over, while that is | |
| 178 | * pending: the planner may split the issue only into what is made after. | |
| 179 | * Nothing for an issue no pending hand-over covers. | |
| 180 | */ | |
| 181 | export function replanSince( | |
| 182 | db: Database, | |
| 183 | repo: Repo, | |
| 184 | issue: Issue, | |
| 185 | ): string | undefined { | |
| 186 | let since: string | undefined; | |
| 187 | for (const r of pendingReplans(db, repo)) { | |
| 188 | if (!covered(db, repo, r).has(issue.id)) continue; | |
| 189 | if (since === undefined || r.at > since) since = r.at; | |
| 190 | } | |
| 191 | return since; | |
| 192 | } | |
| 193 | ||
| 194 | /** An issue the planner closed as split, and what it became. */ | |
| 195 | export interface Split { | |
| 196 | readonly number: number; | |
| 197 | readonly title: string; | |
| 198 | readonly into: readonly { readonly number: number; readonly title: string }[]; | |
| 199 | } | |
| 200 | ||
| 201 | /** What a hand-over changed: each issue it was given that closed as split, into which. */ | |
| 202 | export function splitsOf(db: Database, repo: Repo, r: Replan): Split[] { | |
| 203 | const out: Split[] = []; | |
| 204 | for (const issueId of givenIssueIds(db, r.id)) { | |
| 205 | const issue = getIssueById(db, issueId); | |
| 206 | // A sealed issue is never named to a reader. | |
| 207 | if (issue === undefined || issue.sealed || issue.state !== "closed" || issue.closeReason !== "split") | |
| 208 | continue; | |
| 209 | const into = db | |
| 210 | .query<{ parts: string }, [string, number]>( | |
| 211 | `SELECT json_extract(payload, '$.into') AS parts FROM event | |
| 212 | WHERE repo_id = ?1 AND kind = 'issue.closed' | |
| 213 | AND json_extract(payload, '$.reason') = 'split' | |
| 214 | AND json_extract(payload, '$.number') = ?2 | |
| 215 | ORDER BY id DESC LIMIT 1`, | |
| 216 | ) | |
| 217 | .get(repo.id, issue.number); | |
| 218 | const numbers: unknown = into === null || into === undefined ? [] : JSON.parse(into.parts); | |
| 219 | const parts = (Array.isArray(numbers) ? numbers : []).flatMap((n) => { | |
| 220 | const part = typeof n === "number" ? db | |
| 221 | .query<{ number: number; title: string }, [string, number]>( | |
| 222 | `SELECT number, title FROM issue WHERE repo_id = ?1 AND number = ?2 AND sealed = 0`, | |
| 223 | ) | |
| 224 | .get(repo.id, n) : null; | |
| 225 | return part === null ? [] : [part]; | |
| 226 | }); | |
| 227 | out.push({ number: issue.number, title: issue.title, into: parts }); | |
| 228 | } | |
| 229 | return out.sort((a, b) => a.number - b.number); | |
| 230 | } |
src/core/migrations/20261018110000-limb-replan.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | // A person hands a limb to the planner to re-plan as fewer, larger pieces | |
| 2 | // (#761): one row for the hand-over, one for each open issue it was given. | |
| 3 | export default ` | |
| 4 | CREATE TABLE limb_replan ( | |
| 5 | id TEXT PRIMARY KEY, | |
| 6 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 7 | limb_issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE, | |
| 8 | by_user TEXT NOT NULL, | |
| 9 | at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 10 | taken_back_at TEXT | |
| 11 | ); | |
| 12 | CREATE INDEX limb_replan_repo ON limb_replan(repo_id, at); | |
| 13 | CREATE TABLE limb_replan_issue ( | |
| 14 | replan_id TEXT NOT NULL REFERENCES limb_replan(id) ON DELETE CASCADE, | |
| 15 | issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE, | |
| 16 | PRIMARY KEY (replan_id, issue_id) | |
| 17 | ); | |
| 18 | `; |
src/crew/autonomy.ts +14 −5
| @@ -89,6 +89,7 @@ import { reviewSummary } from "../core/reviews.ts"; | ||
| 89 | 89 | import { standing } from "../core/standing.ts"; |
| 90 | 90 | import { pace, type Pace } from "./pacing.ts"; |
| 91 | 91 | import { BREAKER_NOTE, breakerTripped } from "./breaker.ts"; |
| 92 | import { given, pendingReplans } from "../core/limb_replan.ts"; | |
| 92 | 93 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; |
| 93 | 94 | import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts"; |
| 94 | 95 | import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; |
| @@ -562,7 +563,8 @@ async function planIfThin( | ||
| 562 | 563 | stuck: Held | null = null, |
| 563 | 564 | ): Promise<boolean> { |
| 564 | 565 | const handed = [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)]; |
| 565 | if (ready >= THIN_MAP && handed.length === 0) return false; | |
| 566 | const limbs = pendingReplans(app.db, repo); | |
| 567 | if (ready >= THIN_MAP && handed.length === 0 && limbs.length === 0) return false; | |
| 566 | 568 | const planner = crew.find((c) => c.role === "planner")?.agent; |
| 567 | 569 | if (planner === undefined) return notPlanned(repo, "no planner on the crew"); |
| 568 | 570 | if (repo.autonomyDirection.trim() === "") |
| @@ -582,11 +584,14 @@ async function planIfThin( | ||
| 582 | 584 | // The hand-overs the planner has not been given: newer than its last pass. |
| 583 | 585 | const seen = lastPlan?.created_at ?? ""; |
| 584 | 586 | const owed = handed.filter((issue) => issue.at > seen); |
| 585 | if (ready >= THIN_MAP && owed.length === 0) return false; | |
| 587 | // A limb a person handed over is owed one pass, whatever the map and | |
| 588 | // whatever the planner said of the direction before (#761). | |
| 589 | const limbsOwed = limbs.filter((r) => !given(app.db, repo, r.at)); | |
outdated
|
||
| 590 | if (ready >= THIN_MAP && owed.length === 0 && limbsOwed.length === 0) return false; | |
| 586 | 591 | // Nothing startable for issues held since its last pass that re-planning |
| 587 | 592 | // lifts: owed one pass like a hand-over, once (#2244). |
| 588 | 593 | const unstuck = stuck === null ? [] : replannable(stuck).filter((issue) => heldSince(app, issue, seen)); |
| 589 | const news = owed.length + unstuck.length; | |
| 594 | const news = owed.length + unstuck.length + limbsOwed.length; | |
| 590 | 595 | // The direction is the planner's standing issue, not one issue retried: |
| 591 | 596 | // it is held to its own day's share, never the builders' cap (#2143). |
| 592 | 597 | if (atRunCap(app, repo, direction)) |
| @@ -606,9 +611,11 @@ async function planIfThin( | ||
| 606 | 611 | return notPlanned(repo, "the planner declined; waiting for a person"); |
| 607 | 612 | if (lastPlan.state === "queued" || lastPlan.state === "running") |
| 608 | 613 | return notPlanned(repo, "a planning run is in flight"); |
| 614 | // A person's hand-over is not made to wait for the last pass's hour. | |
| 609 | 615 | if ( |
| 616 | limbsOwed.length === 0 && | |
| 610 | 617 | now.getTime() - new Date(lastPlan.created_at).getTime() < |
| 611 | REPLAN_AFTER_MS | |
| 618 | REPLAN_AFTER_MS | |
| 612 | 619 | ) |
| 613 | 620 | return notPlanned(repo, "planned recently"); |
| 614 | 621 | // Nobody spoke and the map has not moved since the last pass: another |
| @@ -631,7 +638,9 @@ async function planIfThin( | ||
| 631 | 638 | if (!assigned.ok) |
| 632 | 639 | return notPlanned(repo, `could not assign: ${String(assigned.error)}`); |
| 633 | 640 | log.info( |
| 634 | owed.length > 0 | |
| 641 | limbsOwed.length > 0 | |
| 642 | ? "a limb was handed over; planner asked" | |
| 643 | : owed.length > 0 | |
| 635 | 644 | ? "parked twice; planner asked" |
| 636 | 645 | : unstuck.length > 0 |
| 637 | 646 | ? "nothing startable; planner asked" |
src/crew/brief.ts +31 −1
| @@ -186,6 +186,8 @@ export interface TaskInput extends BriefInput { | ||
| 186 | 186 | readonly looks?: readonly string[] | undefined; |
| 187 | 187 | /** For a planner: the issues the crew parked twice, with why. */ |
| 188 | 188 | readonly parkedTwice?: readonly HandedOver[] | undefined; |
| 189 | /** For a planner: the limbs a person handed over to re-plan as fewer, larger pieces. */ | |
| 190 | readonly limbs?: readonly HandedLimb[] | undefined; | |
| 189 | 191 | /** For a planner: the issue deferred work waits behind, if there is one. */ |
| 190 | 192 | readonly laterGate?: number | undefined; |
| 191 | 193 | /** For a planner: nothing ready can start, and what holds it (`held.ts`). */ |
| @@ -204,6 +206,13 @@ export interface Stuck { | ||
| 204 | 206 | }[]; |
| 205 | 207 | } |
| 206 | 208 | |
| 209 | /** A limb a person handed to the planner, with every open issue beneath it. */ | |
| 210 | export interface HandedLimb { | |
| 211 | readonly number: number; | |
| 212 | readonly title: string; | |
| 213 | readonly issues: readonly { readonly number: number; readonly title: string }[]; | |
| 214 | } | |
| 215 | ||
| 207 | 216 | /** An issue the crew parked twice, as the planner is told of it. */ |
| 208 | 217 | export interface HandedOver { |
| 209 | 218 | readonly number: number; |
| @@ -234,7 +243,7 @@ function taskBody(input: TaskInput): string { | ||
| 234 | 243 | const { issue } = input; |
| 235 | 244 | const out: string[] = []; |
| 236 | 245 | const isAgent = (name: string): boolean => name === input.agent.user.username; |
| 237 | const handed = [parkedTwiceLines(input), stuckLines(input)] | |
| 246 | const handed = [parkedTwiceLines(input), limbLines(input), stuckLines(input)] | |
| 238 | 247 | .filter((lines) => lines.length > 0) |
| 239 | 248 | .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines])); |
| 240 | 249 | const readers = input.readers ?? new Set<string>(); |
| @@ -533,6 +542,27 @@ function parkedTwiceLines(input: TaskInput): string[] { | ||
| 533 | 542 | ]; |
| 534 | 543 | } |
| 535 | 544 | |
| 545 | /** | |
| 546 | * What a planner is told of a limb a person handed it: the limb and every | |
| 547 | * open issue beneath it, to come back as fewer, larger issues. Each old | |
| 548 | * issue is closed as split into the new ones, never dropped — the person | |
| 549 | * asked for them. Nothing outside the limb is touched. | |
| 550 | */ | |
| 551 | function limbLines(input: TaskInput): string[] { | |
| 552 | const limbs = input.role === "planner" ? (input.limbs ?? []) : []; | |
| 553 | if (limbs.length === 0) return []; | |
| 554 | const issues = `$FORGE_URL/api/v1/repos/${input.repo.ownerName}/${input.repo.name}/issues`; | |
| 555 | return [ | |
| 556 | "A person handed you these limbs to re-plan as fewer, larger pieces. Nothing beneath one is picked by the crew until you have answered:", | |
| 557 | ...limbs.flatMap((limb) => [ | |
| 558 | `- Limb #${limb.number} ${limb.title}, ${limb.issues.length} open issue${limb.issues.length === 1 ? "" : "s"}:`, | |
| 559 | ...limb.issues.map((i) => ` #${i.number} ${i.title}`), | |
| 560 | ]), | |
| 561 | `Open the larger issues first, each saying what done looks like. Then close every old issue of the limb as split into them — PATCH ${issues}/N with {"state":"closed","reason":"split","into":[A,B]}. Only open issues made since the hand-over are accepted, and nothing outside the limb is yours to close this way. Do not drop an issue of the limb: a person asked for it, so what it asked for must be in a larger piece.`, | |
| 562 | `If other issues wait for one you close, make them wait for what replaces it: DELETE ${issues}/D/dependencies/N, then POST ${issues}/D/dependencies with {"blockedBy":M}. Make the new issues wait for the limb's issue, so they stay beneath it.`, | |
| 563 | ]; | |
| 564 | } | |
| 565 | ||
| 536 | 566 | /** |
| 537 | 567 | * What a planner is told when no ready issue can start while builders sit |
| 538 | 568 | * free: the count per filter, and each stalled or capped issue, to re-plan |
src/crew/handed_limbs.ts added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | /** | |
| 2 | * The limbs a person handed to the planner (#761), as its task is told of | |
| 3 | * them: each with every open issue beneath it. Only those it has not yet | |
| 4 | * answered (`core/limb_replan.ts`). | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { getIssueById } from "../core/issues.ts"; | |
| 9 | import { openBeneath, pendingReplans } from "../core/limb_replan.ts"; | |
| 10 | import type { Repo } from "../core/repos.ts"; | |
| 11 | import type { HandedLimb } from "./brief.ts"; | |
| 12 | ||
| 13 | export function handedLimbs(app: App, repo: Repo): HandedLimb[] { | |
| 14 | return pendingReplans(app.db, repo).flatMap((r) => { | |
| 15 | const limb = getIssueById(app.db, r.limbId); | |
| 16 | if (limb === undefined) return []; | |
| 17 | const beneath = openBeneath(app.db, repo, limb.number).filter((i) => i.id !== limb.id); | |
| 18 | return beneath.length === 0 | |
| 19 | ? [] | |
| 20 | : [{ number: limb.number, title: limb.title, issues: beneath.map((i) => ({ number: i.number, title: i.title })) }]; | |
| 21 | }); | |
| 22 | } |
src/crew/held.ts +6
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | * - `running`: a run is in flight on it. |
| 10 | 10 | * - `open_pull`: an open pull already proposes it (`direct.ts`). |
| 11 | 11 | * - `planner`: the planner or a reviewer holds it, not a builder. |
| 12 | * - `with_planner`: its limb was handed to the planner by a person and the | |
| 13 | * planner has not answered (`core/limb_replan.ts`). | |
| 12 | 14 | * - `done_on_main`: the crew found it done on the base branch and waits for |
| 13 | 15 | * a person to close it (`done_on_main.ts`); nothing is left to plan. |
| 14 | 16 | * - `stalled`: a builder ran on it and left without a pull, and nobody |
| @@ -27,6 +29,7 @@ import { listCrewRoles, type Agent, type CrewRole } from "../core/agents.ts"; | ||
| 27 | 29 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 28 | 30 | import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; |
| 29 | 31 | import type { Issue } from "../core/issues.ts"; |
| 32 | import { withPlanner } from "../core/limb_replan.ts"; | |
| 30 | 33 | import type { Repo } from "../core/repos.ts"; |
| 31 | 34 | import { listActive, type Run } from "../core/runs.ts"; |
| 32 | 35 | import { getUserById } from "../core/users.ts"; |
| @@ -42,6 +45,7 @@ export const HOLDS = [ | ||
| 42 | 45 | "running", |
| 43 | 46 | "open_pull", |
| 44 | 47 | "planner", |
| 48 | "with_planner", | |
| 45 | 49 | "done_on_main", |
| 46 | 50 | "stalled", |
| 47 | 51 | "run_cap", |
| @@ -115,10 +119,12 @@ export function heldBy( | ||
| 115 | 119 | const planners = new Set( |
| 116 | 120 | crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id), |
| 117 | 121 | ); |
| 122 | const handed = withPlanner(app.db, repo); | |
| 118 | 123 | const holdOf = (issue: Issue): HoldName | null => { |
| 119 | 124 | if (busyIssues.has(issue.id)) return "running"; |
| 120 | 125 | if (proposed.has(issue.number)) return "open_pull"; |
| 121 | 126 | if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner"; |
| 127 | if (handed.has(issue.id)) return "with_planner"; | |
| 122 | 128 | if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main"; |
| 123 | 129 | if (stalled(app, repo, issue)) return "stalled"; |
| 124 | 130 | if (atRunCap(app, repo, issue)) return "run_cap"; |
src/crew/runner.ts +2
| @@ -108,6 +108,7 @@ import { | ||
| 108 | 108 | import { clearStaleLocks, lockedOut } from "./locks.ts"; |
| 109 | 109 | import { interruptedSummary } from "./interrupted.ts"; |
| 110 | 110 | import { laterGate } from "./later.ts"; |
| 111 | import { handedLimbs } from "./handed_limbs.ts"; | |
| 111 | 112 | import { parkedTwice } from "./parks.ts"; |
| 112 | 113 | import { heldNow, stuckOf } from "./held.ts"; |
| 113 | 114 | import { noteLimit } from "../core/standing.ts"; |
| @@ -974,6 +975,7 @@ async function work( | ||
| 974 | 975 | ...(brief.role === "planner" && issue.id === repo.directionIssueId |
| 975 | 976 | ? { |
| 976 | 977 | parkedTwice: [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)], |
| 978 | limbs: handedLimbs(app, repo), | |
| 977 | 979 | laterGate: laterGate(app.db, repo.id), |
| 978 | 980 | stuck: stuckOf(heldNow(app, repo)), |
| 979 | 981 | } |
src/http/api.ts +18 −1
| @@ -313,6 +313,12 @@ import { | ||
| 313 | 313 | milestonesJson, |
| 314 | 314 | } from "../web/pages/milestones.ts"; |
| 315 | 315 | import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts"; |
| 316 | import { | |
| 317 | isReplanAction, | |
| 318 | previewReplan, | |
| 319 | replanLimb, | |
| 320 | takeBackLimb, | |
| 321 | } from "../actions/limb_replan.ts"; | |
| 316 | 322 | import { appsRuntime, envsFor, type AppsRuntime } from "../apps/runtime.ts"; |
| 317 | 323 | import { isEnvName } from "../apps/data.ts"; |
| 318 | 324 | import { getAppByRepo } from "../core/apps.ts"; |
| @@ -3157,8 +3163,19 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3157 | 3163 | const repo = loadRepo(ctx); |
| 3158 | 3164 | const number = positiveInt(ctx.params.number); |
| 3159 | 3165 | if (!repo || number === undefined) return notFound(); |
| 3166 | if (isReplanAction(input.action)) { | |
| 3167 | if (dryRun) { | |
| 3168 | const plan = previewReplan(ctx.app, ctx.auth.user, repo, number); | |
| 3169 | return plan.ok ? json({ dryRun: true, ...plan.value }) : respondFleet(plan.error); | |
| 3170 | } | |
| 3171 | const done = | |
| 3172 | input.action === "replan" | |
| 3173 | ? replanLimb(ctx.app, ctx.auth.user, repo, number) | |
| 3174 | : takeBackLimb(ctx.app, ctx.auth.user, repo, number); | |
| 3175 | return done.ok ? json({ dryRun: false, ...done.value }, 201) : respondFleet(done.error); | |
| 3176 | } | |
| 3160 | 3177 | if (!isLimbAction(input.action)) |
| 3161 | return apiError("bad_action", 422, "action is defer, resume, close, raise or lower"); | |
| 3178 | return apiError("bad_action", 422, "action is defer, resume, close, raise, lower, replan or takeback"); | |
| 3162 | 3179 | const request = { |
| 3163 | 3180 | action: input.action, |
| 3164 | 3181 | ...(typeof input.reason === "string" ? { reason: input.reason } : {}), |
src/http/mcp.ts +25 −3
| @@ -132,6 +132,12 @@ import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos. | ||
| 132 | 132 | import { mapJson, marksFor } from "../web/pages/map.ts"; |
| 133 | 133 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts"; |
| 134 | 134 | import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts"; |
| 135 | import { | |
| 136 | isReplanAction, | |
| 137 | previewReplan, | |
| 138 | replanLimb, | |
| 139 | takeBackLimb, | |
| 140 | } from "../actions/limb_replan.ts"; | |
| 135 | 141 | import { |
| 136 | 142 | milestoneData, |
| 137 | 143 | milestonesJson, |
| @@ -2076,7 +2082,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2076 | 2082 | { |
| 2077 | 2083 | name: "limb", |
| 2078 | 2084 | description: |
| 2079 | "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.", | |
| 2085 | "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`. `replan` hands the limb to the planner to re-plan as fewer, larger pieces: the dry run lists every open issue it will be given, applying owes the planner's next pass once and holds the limb from the crew until it answers, and `takeback` withdraws it before the planner starts. `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.", | |
| 2080 | 2086 | inputSchema: { |
| 2081 | 2087 | type: "object", |
| 2082 | 2088 | properties: { |
| @@ -2086,7 +2092,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2086 | 2092 | minimum: 1, |
| 2087 | 2093 | description: "The limb: the number of its issue.", |
| 2088 | 2094 | }, |
| 2089 | action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "undo"] }, | |
| 2095 | action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "replan", "takeback", "undo"] }, | |
| 2090 | 2096 | reason: { |
| 2091 | 2097 | type: "string", |
| 2092 | 2098 | description: "For close: why it is not being done, in one sentence.", |
| @@ -2129,13 +2135,29 @@ const TOOLS: readonly Tool[] = [ | ||
| 2129 | 2135 | return undone.ok ? good(undone.value) : refused(undone.error); |
| 2130 | 2136 | } |
| 2131 | 2137 | const number = args["issue"]; |
| 2138 | if ( | |
| 2139 | isReplanAction(args["action"]) && | |
| 2140 | typeof number === "number" && | |
| 2141 | Number.isInteger(number) && | |
| 2142 | number > 0 | |
| 2143 | ) { | |
| 2144 | if (dry) { | |
| 2145 | const plan = previewReplan(ctx.app, user, repo, number); | |
| 2146 | return plan.ok ? good({ dryRun: true, ...plan.value }) : refused(plan.error); | |
| 2147 | } | |
| 2148 | const done = | |
| 2149 | args["action"] === "replan" | |
| 2150 | ? replanLimb(ctx.app, user, repo, number) | |
| 2151 | : takeBackLimb(ctx.app, user, repo, number); | |
| 2152 | return done.ok ? good({ dryRun: false, ...done.value }) : refused(done.error); | |
| 2153 | } | |
| 2132 | 2154 | if ( |
| 2133 | 2155 | !isLimbAction(args["action"]) || |
| 2134 | 2156 | typeof number !== "number" || |
| 2135 | 2157 | !Number.isInteger(number) || |
| 2136 | 2158 | number < 1 |
| 2137 | 2159 | ) |
| 2138 | return bad("invalid", "give the limb's issue number and an action: defer, resume, close, raise, lower or undo"); | |
| 2160 | return bad("invalid", "give the limb's issue number and an action: defer, resume, close, raise, lower, replan, takeback or undo"); | |
| 2139 | 2161 | const request = { |
| 2140 | 2162 | action: args["action"], |
| 2141 | 2163 | ...(typeof args["reason"] === "string" ? { reason: args["reason"] } : {}), |
src/kernel/plural.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | /** | |
| 2 | * "1 comment", "0 comments". A count and its noun are one phrase, so they are | |
| 3 | * formatted in one place — the alternative is `1 commits` in whichever screen | |
| 4 | * was written last, which is precisely what happened before this existed. | |
| 5 | */ | |
| 6 | export function plural(n: number, word: string, many = `${word}s`): string { | |
| 7 | return `${n} ${n === 1 ? word : many}`; | |
| 8 | } |
src/tui/screens/common.ts +1 −8
| @@ -1035,11 +1035,4 @@ export function ago(iso: string): string { | ||
| 1035 | 1035 | return rel === "now" ? "just now" : `${rel} ago`; |
| 1036 | 1036 | } |
| 1037 | 1037 | |
| 1038 | /** | |
| 1039 | * "1 comment", "0 comments". A count and its noun are one phrase, so they are | |
| 1040 | * formatted in one place — the alternative is `1 commits` in whichever screen | |
| 1041 | * was written last, which is precisely what happened before this existed. | |
| 1042 | */ | |
| 1043 | export function plural(n: number, word: string, many = `${word}s`): string { | |
| 1044 | return `${n} ${n === 1 ? word : many}`; | |
| 1045 | } | |
| 1038 | export { plural } from "../../kernel/plural.ts"; |
src/tui/screens/limbs.ts +65 −2
| @@ -12,6 +12,11 @@ | ||
| 12 | 12 | * facts (`canSeeOwnerFacts`, as on the page) also gets, under the list, what |
| 13 | 13 | * the limb under the cursor cost and landed, and its costliest issues. |
| 14 | 14 | * |
| 15 | * `p` hands the limb under the cursor to the planner to re-plan as fewer, | |
| 16 | * larger pieces (#761): `previewReplan` lists what it will be given, then | |
| 17 | * `replanLimb` on confirm. The row reads "with the planner" until it | |
| 18 | * answers, and `p` then takes it back while that is still allowed. | |
| 19 | * | |
| 15 | 20 | * `enter` on a limb that has limbs of its own opens them, so a milestone's |
| 16 | 21 | * sub-limbs can be moved among themselves (the `under` of the action). |
| 17 | 22 | * Subscribed to the bus while on the stack, so a move made on the web, or in |
| @@ -19,6 +24,8 @@ | ||
| 19 | 24 | */ |
| 20 | 25 | |
| 21 | 26 | import { undoFleetOp } from "../../actions/fleet.ts"; |
| 27 | import { previewReplan, replanLimb, takeBackLimb } from "../../actions/limb_replan.ts"; | |
| 28 | import { pendingReplans } from "../../core/limb_replan.ts"; | |
| 22 | 29 | import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts"; |
| 23 | 30 | import { canAdmin, canRead, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 24 | 31 | import { readPlan, type Limb, type Milestone } from "../../core/milestones.ts"; |
| @@ -40,7 +47,7 @@ import { | ||
| 40 | 47 | } from "./common.ts"; |
| 41 | 48 | |
| 42 | 49 | /** What reorders or reshapes the limbs: a move, or an issue opening or closing. */ |
| 43 | const LIVE_KINDS = ["limb.moved", "issue.opened", "issue.closed", "issue.reopened"]; | |
| 50 | const LIVE_KINDS = ["limb.moved", "limb.replan", "limb.replan_taken_back", "issue.opened", "issue.closed", "issue.reopened"]; | |
| 44 | 51 | |
| 45 | 52 | /** How many lines of the new order and of the ready list a preview shows. */ |
| 46 | 53 | const SHOWN = 6; |
| @@ -104,6 +111,8 @@ export function limbsScreen( | ||
| 104 | 111 | let spends = new Map<number, NonNullable<Milestone["spend"]>>(); |
| 105 | 112 | /** The receipt of the last move made here, which `u` reverses once. */ |
| 106 | 113 | let last: string | undefined; |
| 114 | /** The limbs with the planner, by issue id (#761). */ | |
| 115 | let withPlanner = new Set<string>(); | |
| 107 | 116 | // Read at use, not at open: a repository's admin can change under a session. |
| 108 | 117 | const may = (): boolean => |
| 109 | 118 | f.user !== undefined && f.user.kind === "user" && canAdmin(f.db, repo, f.user); |
| @@ -117,6 +126,7 @@ export function limbsScreen( | ||
| 117 | 126 | return; |
| 118 | 127 | } |
| 119 | 128 | limbs = limbsOf(f, repo, group) ?? []; |
| 129 | withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId)); | |
| 120 | 130 | spends = new Map(); |
| 121 | 131 | if (canSeeOwnerFacts(f.db, repo, f.user)) { |
| 122 | 132 | const plan = readPlan(f.db, repo, { spend: true }); |
| @@ -175,6 +185,57 @@ export function limbsScreen( | ||
| 175 | 185 | ); |
| 176 | 186 | }; |
| 177 | 187 | |
| 188 | /** Re-plan: hand the limb to the planner after reading what it is given; or take it back. */ | |
| 189 | const replan = (): void => { | |
| 190 | const limb = current(); | |
| 191 | if (limb === undefined || !may()) return; | |
| 192 | const number = limb.issue.number; | |
| 193 | const seen = previewReplan(f.app, f.user, repo, number); | |
| 194 | if (!seen.ok) { | |
| 195 | ctx.toast(seen.error.message, "danger"); | |
| 196 | return; | |
| 197 | } | |
| 198 | const p = seen.value; | |
| 199 | if (p.withPlanner && !p.takeBack) { | |
| 200 | ctx.toast("the planner has it in hand already", "danger"); | |
| 201 | return; | |
| 202 | } | |
| 203 | if (!p.withPlanner && p.blocked !== undefined) { | |
| 204 | ctx.toast(p.blocked, "danger"); | |
| 205 | return; | |
| 206 | } | |
| 207 | const taking = p.withPlanner; | |
| 208 | const body = [ | |
| 209 | taking | |
| 210 | ? "It is with the planner; nothing beneath it is picked. Take it back before the planner starts:" | |
| 211 | : `The planner will be given ${plural(p.issues.length, "issue")}, to close as split into fewer, larger ones:`, | |
| 212 | ...p.issues.slice(0, SHOWN).map((i) => ` ${line(i.number, i.title)}`), | |
| 213 | ...(p.issues.length > SHOWN ? [` … ${p.issues.length - SHOWN} more`] : []), | |
| 214 | ]; | |
| 215 | ctx.push( | |
| 216 | confirmScreen(ctx, screen, { | |
| 217 | title: `${taking ? "Take back" : "Re-plan"} “${limb.name}”?`, | |
| 218 | body, | |
| 219 | actions: [ | |
| 220 | { label: taking ? "Take it back" : "Hand to the planner", variant: "default" }, | |
| 221 | { label: "Cancel", variant: "ghost" }, | |
| 222 | ], | |
| 223 | onChoose: (choice) => { | |
| 224 | if (choice !== 0) return; | |
| 225 | const done = taking | |
| 226 | ? takeBackLimb(f.app, f.user, repo, number) | |
| 227 | : replanLimb(f.app, f.user, repo, number); | |
| 228 | if (!done.ok) { | |
| 229 | ctx.toast(done.error.message, "danger"); | |
| 230 | return; | |
| 231 | } | |
| 232 | reload(number); | |
| 233 | ctx.toast(taking ? "taken back" : "with the planner", "success"); | |
| 234 | }, | |
| 235 | }), | |
| 236 | ); | |
| 237 | }; | |
| 238 | ||
| 178 | 239 | const undo = (): void => { |
| 179 | 240 | const id = last; |
| 180 | 241 | if (id === undefined || !may()) return; |
| @@ -202,7 +263,7 @@ export function limbsScreen( | ||
| 202 | 263 | const rows = (): ListRow[] => |
| 203 | 264 | limbs.map((l, n) => ({ |
| 204 | 265 | title: `${n + 1}. ${line(l.issue.number, l.name)}`, |
| 205 | secondary: l.deferred ? "deferred" : "", | |
| 266 | secondary: withPlanner.has(l.issue.id) ? "with the planner" : l.deferred ? "deferred" : "", | |
| 206 | 267 | meta: `${l.weight.done}/${l.weight.total} done`, |
| 207 | 268 | })); |
| 208 | 269 | |
| @@ -215,6 +276,7 @@ export function limbsScreen( | ||
| 215 | 276 | binding(["enter"], "open"), |
| 216 | 277 | binding(["r"], "raise", { disabled: !moves }), |
| 217 | 278 | binding(["l"], "lower", { disabled: !moves }), |
| 279 | binding(["p"], withPlanner.has(current()?.issue.id ?? "") ? "take back" : "re-plan", { disabled: !may() || current() === undefined }), | |
| 218 | 280 | binding(["u"], "undo", { disabled: !may() || last === undefined }), |
| 219 | 281 | ]; |
| 220 | 282 | }, |
| @@ -246,6 +308,7 @@ export function limbsScreen( | ||
| 246 | 308 | if (e.key === "enter") open(); |
| 247 | 309 | else if (e.key === "r") move("raise"); |
| 248 | 310 | else if (e.key === "l") move("lower"); |
| 311 | else if (e.key === "p") replan(); | |
| 249 | 312 | else if (e.key === "u") undo(); |
| 250 | 313 | else return false; |
| 251 | 314 | return true; |
src/web/live.ts +1 −1
| @@ -302,7 +302,7 @@ export const liveClientJs = /* js */ ` | ||
| 302 | 302 | 'label.created', 'label.updated', 'label.deleted', |
| 303 | 303 | 'run.queued', 'run.started', 'run.finished', 'app.serving', |
| 304 | 304 | 'app.created', 'app.updated', 'app.deleted', 'app.stopped', |
| 305 | 'limb.moved', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed', | |
| 305 | 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed', | |
| 306 | 306 | ]; |
| 307 | 307 | var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean); |
| 308 | 308 | var watching = [repo].concat(extra).filter(function (r, i, all) { |
src/web/pages/limb.ts +4
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * take it back once. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { isReplanAction } from "../../actions/limb_replan.ts"; | |
| 9 | 10 | import { getFleetOp, undoFleetOp } from "../../actions/fleet.ts"; |
| 10 | 11 | import { |
| 11 | 12 | applyLimb, |
| @@ -32,6 +33,7 @@ import { field, readForm } from "../form.ts"; | ||
| 32 | 33 | import { html, when, type Html } from "../html.ts"; |
| 33 | 34 | import { page, redirect } from "../layout.ts"; |
| 34 | 35 | import { btn } from "../ui.ts"; |
| 36 | import { replanPreviewPage, replanSubmit } from "./limb_replan.ts"; | |
| 35 | 37 | import { milestoneHref, milestonesCss } from "./milestones.ts"; |
| 36 | 38 | |
| 37 | 39 | const WORDS: Record<LimbAction, { ask: string; does: string; go: string }> = { |
| @@ -166,6 +168,7 @@ export function limbPreviewPage(ctx: Ctx): Response { | ||
| 166 | 168 | if (!ctx.auth.user) return signInFirst(ctx); |
| 167 | 169 | const number = Number(ctx.params.number); |
| 168 | 170 | const action = ctx.url.searchParams.get("action"); |
| 171 | if (action === "replan") return replanPreviewPage(ctx, repo, number); | |
| 169 | 172 | if (!Number.isInteger(number) || !isLimbAction(action)) |
| 170 | 173 | return notFoundPage(ctx); |
| 171 | 174 | const request = requestOf(action, (n) => ctx.url.searchParams.get(n) ?? ""); |
| @@ -350,6 +353,7 @@ export async function limbSubmit(ctx: Ctx): Promise<Response> { | ||
| 350 | 353 | if (form instanceof Response) return form; |
| 351 | 354 | const number = Number(ctx.params.number); |
| 352 | 355 | const action = field(form, "action"); |
| 356 | if (isReplanAction(action)) return replanSubmit(ctx, repo, number, action); | |
| 353 | 357 | if (!Number.isInteger(number) || !isLimbAction(action)) |
| 354 | 358 | return notFoundPage(ctx); |
| 355 | 359 | const base = repoBase(repo); |
src/web/pages/limb_replan.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * Handing a limb to the planner from its milestone (#761): the preview that | |
| 3 | * lists every issue the planner will be given, the confirm, and taking it | |
| 4 | * back. Over `actions/limb_replan.ts`; the same pattern as `limb.ts`. | |
| 5 | */ | |
| 6 | ||
| 7 | import { | |
| 8 | previewReplan, | |
| 9 | replanLimb, | |
| 10 | takeBackLimb, | |
| 11 | type ReplanAction, | |
| 12 | } from "../../actions/limb_replan.ts"; | |
| 13 | import type { Repo } from "../../core/repos.ts"; | |
| 14 | import type { Ctx } from "../../http/context.ts"; | |
| 15 | import { | |
| 16 | crumbsFor, | |
| 17 | notFoundPage, | |
| 18 | repoBase, | |
| 19 | repoHeader, | |
| 20 | repoPalette, | |
| 21 | } from "../chrome.ts"; | |
| 22 | import { html } from "../html.ts"; | |
| 23 | import { page, redirect } from "../layout.ts"; | |
| 24 | import { plural } from "../../kernel/plural.ts"; | |
outdated
Low: a web page now imports from the TUI layer. Move |
||
| 25 | import { btn } from "../ui.ts"; | |
| 26 | import { milestoneHref, milestonesCss } from "./milestones.ts"; | |
| 27 | ||
| 28 | /** GET /:owner/:name/milestones/:number/limb?action=replan — what the planner will be given. */ | |
| 29 | export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Response { | |
| 30 | const plan = previewReplan(ctx.app, ctx.auth.user, repo, number); | |
| 31 | if (!plan.ok) return notFoundPage(ctx); | |
| 32 | const base = repoBase(repo); | |
| 33 | const p = plan.value; | |
| 34 | const back = milestoneHref(base, number); | |
| 35 | return page({ | |
| 36 | ctx, | |
| 37 | title: `Re-plan ${p.limb.title} · ${repo.name}`, | |
| 38 | crumbs: crumbsFor(repo), | |
| 39 | palette: repoPalette(ctx, repo), | |
| 40 | styleCss: milestonesCss, | |
| 41 | body: html`${repoHeader(ctx, repo, "map")} | |
| 42 | <div class="ms-top mt"> | |
| 43 | <p class="small muted"><a href="${back}">${p.limb.title}</a></p> | |
| 44 | <h1>Re-plan “${p.limb.title}”?</h1> | |
| 45 | <p> | |
| 46 | The planner's next pass is given this limb to re-plan as fewer, | |
| 47 | larger pieces. Each issue below is closed as split into what | |
| 48 | replaces it; none is dropped. The crew picks nothing beneath the | |
| 49 | limb until the planner has answered, and you can take it back | |
| 50 | before it starts. | |
| 51 | </p> | |
| 52 | </div> | |
| 53 | <section class="ms-section" id="replan-issues"> | |
| 54 | <h2>${plural(p.issues.length, "issue")} the planner will be given</h2> | |
| 55 | <ul class="ms-rows"> | |
| 56 | ${p.issues.map( | |
| 57 | (i) => | |
| 58 | html`<li> | |
| 59 | <a href="${base}/issues/${i.number}">${i.title}</a> | |
| 60 | <span class="muted small">#${i.number}</span> | |
| 61 | </li>`, | |
| 62 | )} | |
| 63 | </ul> | |
| 64 | </section> | |
| 65 | ${p.blocked !== undefined | |
| 66 | ? html`<p class="mt" id="replan-refused"> | |
| 67 | Not now: ${p.blocked}. <a href="${back}">Back</a> | |
| 68 | </p>` | |
| 69 | : html`<form method="post" action="${back}/limb" class="bar mt"> | |
| 70 | <input type="hidden" name="action" value="replan" /> | |
| 71 | ${btn("Hand to the planner", { variant: "primary" })} | |
| 72 | ${btn("Back", { href: back, variant: "ghost" })} | |
| 73 | </form>`}`, | |
| 74 | }); | |
| 75 | } | |
| 76 | ||
| 77 | /** POST /:owner/:name/milestones/:number/limb with action replan or takeback, its form already read. */ | |
| 78 | export function replanSubmit( | |
| 79 | ctx: Ctx, | |
| 80 | repo: Repo, | |
| 81 | number: number, | |
| 82 | action: ReplanAction, | |
| 83 | ): Response { | |
| 84 | if (!Number.isInteger(number)) return notFoundPage(ctx); | |
| 85 | const done = | |
| 86 | action === "replan" | |
| 87 | ? replanLimb(ctx.app, ctx.auth.user, repo, number) | |
| 88 | : takeBackLimb(ctx.app, ctx.auth.user, repo, number); | |
| 89 | if (!done.ok && done.error.code === "not_found") return notFoundPage(ctx); | |
| 90 | const back = milestoneHref(repoBase(repo), number); | |
| 91 | if (done.ok) return redirect(back); | |
| 92 | // Said, not hidden: the press did nothing, and why. | |
| 93 | return page({ | |
| 94 | ctx, | |
| 95 | title: `Not done · ${repo.name}`, | |
| 96 | crumbs: crumbsFor(repo), | |
| 97 | palette: repoPalette(ctx, repo), | |
| 98 | styleCss: milestonesCss, | |
| 99 | body: html`${repoHeader(ctx, repo, "map")} | |
| 100 | <div class="ms-top mt"> | |
| 101 | <h1>${action === "replan" ? "Not handed over" : "Not taken back"}</h1> | |
| 102 | <p id="replan-refused">Nothing changed: ${done.error.message}.</p> | |
| 103 | <p><a href="${back}">Back</a></p> | |
| 104 | </div>`, | |
| 105 | }, 409); | |
| 106 | } |
src/web/pages/milestones.ts +65 −4
| @@ -34,6 +34,8 @@ import { | ||
| 34 | 34 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 35 | 35 | import { isWatching } from "../../core/watches.ts"; |
| 36 | 36 | import { watchMilestone } from "../../actions/watches.ts"; |
| 37 | import { replanResult } from "../../actions/limb_replan.ts"; | |
| 38 | import { given, pendingFor } from "../../core/limb_replan.ts"; | |
| 37 | 39 | import type { Ctx } from "../../http/context.ts"; |
| 38 | 40 | import { askToLiveFor } from "../ask_to_live.ts"; |
| 39 | 41 | import { |
| @@ -59,7 +61,7 @@ export const milestoneHref = (base: string, number: number): string => | ||
| 59 | 61 | export const limbHref = ( |
| 60 | 62 | base: string, |
| 61 | 63 | number: number, |
| 62 | action: LimbAction, | |
| 64 | action: LimbAction | "replan", | |
| 63 | 65 | under?: string, |
| 64 | 66 | ): string => |
| 65 | 67 | `${milestoneHref(base, number)}/limb?action=${action}${under === undefined ? "" : `&under=${under}`}`; |
| @@ -120,11 +122,13 @@ function limbActions( | ||
| 120 | 122 | furniture: boolean, |
| 121 | 123 | lead: Html | null, |
| 122 | 124 | move: Html | null, |
| 125 | replan: Html | null, | |
| 123 | 126 | ): Html { |
| 124 | 127 | const number = m.issue.number; |
| 125 | 128 | return html`<p class="bar ms-actions" id="limb-actions"> |
| 126 | 129 | ${lead} |
| 127 | 130 | ${move} |
| 131 | ${replan} | |
| 128 | 132 | ${furniture |
| 129 | 133 | ? null |
| 130 | 134 | : m.deferred |
| @@ -242,6 +246,58 @@ function folded(rows: Html[], what: string, shown: number): Html { | ||
| 242 | 246 | </details>`; |
| 243 | 247 | } |
| 244 | 248 | |
| 249 | /** | |
| 250 | * Where a limb stands with the planner (#761): "with the planner" while it | |
| 251 | * waits, with a way to take it back until the planner has been given it; | |
| 252 | * otherwise Re-plan, for whoever administers the repository. | |
| 253 | */ | |
| 254 | function replanControl( | |
| 255 | ctx: Ctx, | |
| 256 | repo: Repo, | |
| 257 | m: Milestone, | |
| 258 | admin: boolean, | |
| 259 | ): Html | null { | |
| 260 | const base = repoBase(repo); | |
| 261 | const pending = pendingFor(ctx.app.db, repo, m.issue.id); | |
| 262 | if (pending === undefined) | |
| 263 | return admin && m.weight.open > 1 | |
| 264 | ? btn("Re-plan", { | |
| 265 | href: limbHref(base, m.issue.number, "replan"), | |
| 266 | size: "sm", | |
| 267 | title: "Hand this limb to the planner to re-plan as fewer, larger pieces", | |
| 268 | }) | |
| 269 | : null; | |
| 270 | return html`<span class="ms-planner" id="ms-with-planner" | |
| 271 | ><b>With the planner</b> | |
| 272 | <span class="muted small">Nothing beneath it is picked until it answers.</span></span | |
| 273 | >${admin && !given(ctx.app.db, repo, pending.at) | |
| 274 | ? html`<form method="post" action="${milestoneHref(base, m.issue.number)}/limb"> | |
| 275 | <input type="hidden" name="action" value="takeback" /> | |
| 276 | ${btn("Take it back", { size: "sm", variant: "ghost" })} | |
| 277 | </form>` | |
| 278 | : null}`; | |
| 279 | } | |
| 280 | ||
| 281 | /** What the planner changed on this limb, once it has answered: each old issue and what replaced it. */ | |
| 282 | function replanChanged(ctx: Ctx, repo: Repo, m: Milestone): Html | null { | |
| 283 | const base = repoBase(repo); | |
| 284 | const result = replanResult(ctx.app, repo, m.issue.id); | |
| 285 | if (result === undefined || result.splits.length === 0) return null; | |
| 286 | const link = (n: { number: number; title: string }): Html => | |
| 287 | html`<a href="${base}/issues/${n.number}">${n.title}</a> | |
| 288 | <span class="muted small">#${n.number}</span>`; | |
| 289 | return html`<section class="ms-section" id="ms-replanned"> | |
| 290 | <h2>Re-planned ${time(result.at)}</h2> | |
| 291 | <ul class="ms-rows"> | |
| 292 | ${result.splits.map( | |
| 293 | (s) => | |
| 294 | html`<li>${link(s)} <span class="muted small">closed as split into</span> | |
| 295 | ${s.into.map((p, n) => html`${n === 0 ? "" : ", "}${link(p)}`)}</li>`, | |
| 296 | )} | |
| 297 | </ul> | |
| 298 | </section>`; | |
| 299 | } | |
| 300 | ||
| 245 | 301 | /** |
| 246 | 302 | * Watch, or stop: one button, for a person who is signed in. Watching puts |
| 247 | 303 | * the milestone's progress in their feed and tells their phone once, when |
| @@ -490,17 +546,20 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 490 | 546 | const move = moverFor(ctx.app.db, repo, viewer, plan.read); |
| 491 | 547 | // Its own place: among the limbs of whichever milestone holds it first. |
| 492 | 548 | const seat = move?.homeOf(milestone.issue); |
| 493 | const actions = canAdmin(ctx.app.db, repo, ctx.auth.user) | |
| 549 | const admin = canAdmin(ctx.app.db, repo, ctx.auth.user); | |
| 550 | const replan = replanControl(ctx, repo, milestone, admin); | |
| 551 | const actions = admin | |
| 494 | 552 | ? limbActions( |
| 495 | 553 | repoBase(repo), |
| 496 | 554 | milestone, |
| 497 | 555 | isStructural(pillarsOf(ctx.app.db, repo), milestone.issue.id), |
| 498 | 556 | watch, |
| 499 | 557 | seat === undefined ? null : (move?.(milestone.issue, seat) ?? null), |
| 558 | replan, | |
| 500 | 559 | ) |
| 501 | : watch === null | |
| 560 | : watch === null && replan === null | |
| 502 | 561 | ? null |
| 503 | : html`<p class="bar ms-actions">${watch}</p>`; | |
| 562 | : html`<p class="bar ms-actions">${watch}${replan}</p>`; | |
| 504 | 563 | return page({ |
| 505 | 564 | ctx, |
| 506 | 565 | title: `${milestone.name} · ${repo.name}`, |
| @@ -508,6 +567,7 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 508 | 567 | palette: repoPalette(ctx, repo), |
| 509 | 568 | body: html`${repoHeader(ctx, repo, "map")} |
| 510 | 569 | ${milestoneBody(repo, milestone, owner, actions, move)} |
| 570 | ${replanChanged(ctx, repo, milestone)} | |
| 511 | 571 | ${liveMount({ repo: repoBase(repo) })}`, |
| 512 | 572 | styleCss: milestonesCss, |
| 513 | 573 | scriptJs: liveClientJs, |
| @@ -621,6 +681,7 @@ export const milestonesCss = /* css */ ` | ||
| 621 | 681 | .ms-top h1, .ms-top p { margin: 0; } |
| 622 | 682 | .ms-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; } |
| 623 | 683 | .ms-actions { margin-top: 4px; } |
| 684 | .ms-planner { display: inline-flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; } | |
| 624 | 685 | .ms-move { grid-column: 1 / -1; display: flex; gap: 14px; } |
| 625 | 686 | .ms-section { margin-top: 22px; } |
| 626 | 687 | .ms-section h2 { font-size: 13px; margin: 0 0 6px; } |
test/limb-replan.test.ts added +363
| @@ -0,0 +1,363 @@ | ||
| 1 | /** | |
| 2 | * A limb handed to the planner (#761). The refusals come first: a reader, a | |
| 3 | * crew agent and an `ro` token cannot hand a limb over, and the hand-over | |
| 4 | * grants the planner nothing outside the limb. Then the hand-over itself: | |
| 5 | * owed once, even with a full map and after a decline; the brief lists the | |
| 6 | * limb; nothing beneath is picked until the planner answers; the planner | |
| 7 | * closes only as split into what is new; a person takes it back before it | |
| 8 | * runs; the milestone shows the change. | |
| 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 { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 18 | import { previewReplan, replanLimb, takeBackLimb } from "../src/actions/limb_replan.ts"; | |
| 19 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 20 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 21 | import { readyNow, repoGraph } from "../src/core/dependencies.ts"; | |
| 22 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 23 | import { pendingReplans, replanSince } from "../src/core/limb_replan.ts"; | |
| 24 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 25 | import { cancelRun, claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 26 | import { createToken } from "../src/core/tokens.ts"; | |
| 27 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 28 | import { taskPrompt } from "../src/crew/brief.ts"; | |
| 29 | import { driveRepo, ensureDirectionIssue } from "../src/crew/autonomy.ts"; | |
| 30 | import { heldNow } from "../src/crew/held.ts"; | |
| 31 | import { handedLimbs } from "../src/crew/handed_limbs.ts"; | |
| 32 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 33 | ||
| 34 | describe("a limb handed to the planner", () => { | |
| 35 | let dataDir: string; | |
| 36 | let app: App; | |
| 37 | let servers: Servers; | |
| 38 | let base: string; | |
| 39 | let alice: User; | |
| 40 | let bob: User; | |
| 41 | let mason: User; | |
| 42 | let atlas: User; | |
| 43 | let repo: Repo; | |
| 44 | let limb: Issue; | |
| 45 | let leaves: Issue[]; | |
| 46 | let outside: Issue; | |
| 47 | let direction: Issue; | |
| 48 | const cookies: Record<string, string> = {}; | |
| 49 | ||
| 50 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 51 | const current = (i: Issue) => getIssueById(app.db, i.id)!; | |
| 52 | const open = (title: string, by: User = alice): Issue => { | |
| 53 | const made = openIssue(app, by, fresh(), { title }); | |
| 54 | if (!made.ok) throw new Error(String(made.error)); | |
| 55 | return made.value; | |
| 56 | }; | |
| 57 | const tick = () => Bun.sleep(5); | |
| 58 | /** A planning pass on the direction: queued, claimed, ended. */ | |
| 59 | const planned = (state: "ok" | "failed", summary: string) => { | |
| 60 | const run = createRun(app.db, { | |
| 61 | repoId: repo.id, | |
| 62 | kind: "agent", | |
| 63 | issueId: direction.id, | |
| 64 | actorId: atlas.id, | |
| 65 | reason: "assigned", | |
| 66 | }); | |
| 67 | claimRun(app.db, run.id); | |
| 68 | finishRun(app.db, run.id, { state, summary }); | |
| 69 | return run; | |
| 70 | }; | |
| 71 | const tokenOf = (user: User, scope: "ro" | "rw" | "admin") => | |
| 72 | createToken(app.db, user.id, scope, scope).token; | |
| 73 | const post = (token: string, path: string, body: unknown) => | |
| 74 | fetch(`${base}${path}`, { | |
| 75 | method: "POST", | |
| 76 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 77 | body: JSON.stringify(body), | |
| 78 | }); | |
| 79 | const page = (who: string, path: string) => | |
| 80 | fetch(`${base}${path}`, { headers: { cookie: cookies[who]! }, redirect: "manual" }); | |
| 81 | const submit = (who: string, path: string, form: Record<string, string>) => | |
| 82 | fetch(`${base}${path}`, { | |
| 83 | method: "POST", | |
| 84 | headers: { cookie: cookies[who]!, origin: base }, | |
| 85 | body: new URLSearchParams(form), | |
| 86 | redirect: "manual", | |
| 87 | }); | |
| 88 | const brief = (limbs: ReturnType<typeof handedLimbs>) => | |
| 89 | taskPrompt({ | |
| 90 | agent: { user: atlas } as never, | |
| 91 | repo: fresh(), | |
| 92 | issue: current(direction), | |
| 93 | branch: "atlas/1-direction", | |
| 94 | forgeUrl: base, | |
| 95 | isApp: false, | |
| 96 | resuming: false, | |
| 97 | role: "planner", | |
| 98 | reason: "assigned", | |
| 99 | comments: [], | |
| 100 | reviews: [], | |
| 101 | reviewComments: [], | |
| 102 | since: direction.createdAt, | |
| 103 | limbs, | |
| 104 | }); | |
| 105 | ||
| 106 | beforeAll(async () => { | |
| 107 | dataDir = await mkdtemp(join(tmpdir(), "forge-limb-replan-")); | |
| 108 | app = createApp({ dataDir, port: 0 }); | |
| 109 | servers = await serve(app); | |
| 110 | base = servers.url; | |
| 111 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 112 | const made = await createUser(app.db, { | |
| 113 | origin: "seed", | |
| 114 | trusted: true, | |
| 115 | username, | |
| 116 | email: `${username}@forge.local`, | |
| 117 | password: "a-good-passphrase", | |
| 118 | adminIfFirst, | |
| 119 | }); | |
| 120 | if (!made.ok) throw new Error(made.error); | |
| 121 | } | |
| 122 | alice = getUserByName(app.db, "alice")!; | |
| 123 | bob = getUserByName(app.db, "bob")!; | |
| 124 | for (const name of ["alice", "bob"]) | |
| 125 | cookies[name] = ( | |
| 126 | ( | |
| 127 | await fetch(`${base}/signin`, { | |
| 128 | method: "POST", | |
| 129 | body: new URLSearchParams({ username: name, password: "a-good-passphrase" }), | |
| 130 | redirect: "manual", | |
| 131 | }) | |
| 132 | ).headers.get("set-cookie") ?? "" | |
| 133 | ).split(";")[0]!; | |
| 134 | const made = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 135 | if (!made.ok) throw new Error(String(made.error)); | |
| 136 | repo = made.value; | |
| 137 | for (const name of ["atlas", "mason"]) { | |
| 138 | const agent = await createAgent(app, alice, { username: name }); | |
| 139 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 140 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 141 | } | |
| 142 | atlas = getUserByName(app.db, "atlas")!; | |
| 143 | mason = getUserByName(app.db, "mason")!; | |
| 144 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 145 | const set = await updateRepoSettings(app, alice, repo, { | |
| 146 | autonomy: "autonomous", | |
| 147 | autonomyDirection: "Keep the engine small.", | |
| 148 | autonomyCeilingUsd: 100, | |
| 149 | autonomyBy: alice.id, | |
| 150 | }); | |
| 151 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 152 | // The limb waits for four small leaves; four more issues fill the map. | |
| 153 | limb = open("Search"); | |
| 154 | leaves = ["Index titles", "Index bodies", "Rank hits", "Highlight hits"].map((t) => open(t)); | |
| 155 | for (const leaf of leaves) linkIssue(app, alice, fresh(), current(limb), current(leaf)); | |
| 156 | outside = open("Unrelated work"); | |
| 157 | for (const t of ["Filler one", "Filler two", "Filler three"]) open(t); | |
| 158 | direction = ensureDirectionIssue(app, alice, fresh())!; | |
| 159 | }); | |
| 160 | ||
| 161 | afterAll(async () => { | |
| 162 | servers.stop(); | |
| 163 | app.close(); | |
| 164 | await rm(dataDir, { recursive: true, force: true }); | |
| 165 | }); | |
| 166 | ||
| 167 | test("a reader, a crew agent and an ro token cannot hand a limb over", async () => { | |
| 168 | for (const who of [bob, mason, atlas, undefined]) { | |
| 169 | const refused = replanLimb(app, who, fresh(), limb.number); | |
| 170 | expect(refused.ok).toBe(false); | |
| 171 | } | |
| 172 | expect(takeBackLimb(app, bob, fresh(), limb.number).ok).toBe(false); | |
| 173 | expect(pendingReplans(app.db, fresh())).toEqual([]); | |
| 174 | const path = `/api/v1/repos/alice/engine/milestones/${limb.number}/limb`; | |
| 175 | // A read token plans and changes nothing, even when it asks to apply. | |
| 176 | expect((await post(tokenOf(alice, "ro"), path, { action: "replan", dryRun: false })).status).toBe(403); | |
| 177 | expect((await post(tokenOf(alice, "rw"), path, { action: "replan", dryRun: false })).status).toBe(403); | |
| 178 | const dry = await post(tokenOf(alice, "ro"), path, { action: "replan" }); | |
| 179 | expect(dry.status).toBe(200); | |
| 180 | const body = (await dry.json()) as { dryRun: boolean; issues: { number: number }[] }; | |
| 181 | expect(body.dryRun).toBe(true); | |
| 182 | expect(body.issues.map((i) => i.number).sort()).toEqual( | |
| 183 | [limb, ...leaves].map((i) => i.number).sort(), | |
| 184 | ); | |
| 185 | const rpc = await post(tokenOf(alice, "ro"), "/mcp", { | |
| 186 | jsonrpc: "2.0", | |
| 187 | id: 1, | |
| 188 | method: "tools/call", | |
| 189 | params: { | |
| 190 | name: "limb", | |
| 191 | arguments: { app: "alice/engine", issue: limb.number, action: "replan", dryRun: false }, | |
| 192 | }, | |
| 193 | }); | |
| 194 | expect(JSON.stringify(await rpc.json())).toContain("forbidden"); | |
| 195 | // A reader's button and form do nothing. | |
| 196 | expect((await submit("bob", `/alice/engine/milestones/${limb.number}/limb`, { action: "replan" })).status).toBe(409); | |
| 197 | const said = await submit("bob", `/alice/engine/milestones/${limb.number}/limb`, { action: "replan" }); | |
| 198 | expect(await said.text()).toContain("only a person who administers this app"); | |
| 199 | expect(pendingReplans(app.db, fresh())).toEqual([]); | |
| 200 | expect(await (await page("bob", `/alice/engine/milestones/${limb.number}`)).text()).not.toContain("Re-plan"); | |
| 201 | }); | |
| 202 | ||
| 203 | test("the preview lists every issue the planner will be given, and writes nothing", async () => { | |
| 204 | const seen = previewReplan(app, bob, fresh(), limb.number); | |
| 205 | expect(seen.ok && seen.value.issues.map((i) => i.number)).toEqual( | |
| 206 | expect.arrayContaining([limb, ...leaves].map((i) => i.number)), | |
| 207 | ); | |
| 208 | expect(seen.ok && seen.value.issues.some((i) => i.number === outside.number)).toBe(false); | |
| 209 | const html = await (await page("alice", `/alice/engine/milestones/${limb.number}/limb?action=replan`)).text(); | |
| 210 | for (const leaf of leaves) expect(html).toContain(leaf.title); | |
| 211 | expect(html).not.toContain("Unrelated work"); | |
| 212 | expect(html).toContain("Hand to the planner"); | |
| 213 | expect(await (await page("alice", `/alice/engine/milestones/${limb.number}`)).text()).toContain("Re-plan"); | |
| 214 | expect(pendingReplans(app.db, fresh())).toEqual([]); | |
| 215 | }); | |
| 216 | ||
| 217 | test("owed once, with a full map and after a decline; the brief lists the limb", async () => { | |
| 218 | // The planner declined the direction and nobody has answered; the map is full. | |
| 219 | planned("failed", "declined: nothing to add"); | |
| 220 | await tick(); | |
| 221 | expect(readyNow(repoGraph(app.db, repo.id, { sealed: true })).length).toBeGreaterThanOrEqual(3); | |
| 222 | const done = replanLimb(app, alice, fresh(), limb.number); | |
| 223 | expect(done.ok).toBe(true); | |
| 224 | expect(replanLimb(app, alice, fresh(), limb.number).ok).toBe(false); | |
| 225 | await driveRepo(app, fresh()); | |
| 226 | expect(current(direction).assigneeId).toBe(atlas.id); | |
| 227 | const told = brief(handedLimbs(app, fresh())); | |
| 228 | expect(told).toContain(`Limb #${limb.number} Search, 4 open issues`); | |
| 229 | for (const leaf of leaves) expect(told).toContain(`#${leaf.number} ${leaf.title}`); | |
| 230 | expect(told).not.toContain("Unrelated work"); | |
| 231 | expect(told).toContain('"reason":"split"'); | |
| 232 | expect(brief([])).not.toContain("A person handed you these limbs"); | |
| 233 | // The pass is owed once: with its run begun, the next tick does not ask again. | |
| 234 | planned("ok", "re-planning"); | |
| 235 | expect(pendingReplans(app.db, fresh())).toEqual([]); | |
| 236 | }); | |
| 237 | ||
| 238 | test("until the planner answers nothing beneath the limb is picked, and the page says so", async () => { | |
| 239 | await tick(); | |
| 240 | const second = replanLimb(app, alice, fresh(), limb.number); | |
| 241 | expect(second.ok).toBe(true); | |
| 242 | const startable = () => heldNow(app, fresh()).startable.map((r) => r.issue.number); | |
| 243 | for (const leaf of leaves) expect(startable()).not.toContain(leaf.number); | |
| 244 | expect(heldNow(app, fresh()).by.with_planner.length).toBe(4); | |
| 245 | expect(startable()).toContain(outside.number); | |
| 246 | const html = await (await page("bob", `/alice/engine/milestones/${limb.number}`)).text(); | |
| 247 | expect(html).toContain("With the planner"); | |
| 248 | expect(html).not.toContain("Re-plan"); | |
| 249 | // A reader cannot take it back; a person can, before the planner runs. | |
| 250 | expect(takeBackLimb(app, bob, fresh(), limb.number).ok).toBe(false); | |
| 251 | expect(await (await page("alice", `/alice/engine/milestones/${limb.number}`)).text()).toContain("Take it back"); | |
| 252 | expect(takeBackLimb(app, alice, fresh(), limb.number).ok).toBe(true); | |
| 253 | expect(pendingReplans(app.db, fresh())).toEqual([]); | |
| 254 | expect(startable()).toContain(leaves[0]!.number); | |
| 255 | expect(takeBackLimb(app, alice, fresh(), limb.number).ok).toBe(false); | |
| 256 | }); | |
| 257 | ||
| 258 | test("the planner may close beneath the limb only as split into what is new; nothing outside", async () => { | |
| 259 | await tick(); | |
| 260 | const old = open("Made before the hand-over", atlas); | |
| 261 | await tick(); | |
| 262 | expect(replanLimb(app, alice, fresh(), limb.number).ok).toBe(true); | |
| 263 | await tick(); | |
| 264 | const [a, b] = leaves.map((l) => current(l)); | |
| 265 | expect(replanSince(app.db, fresh(), a!)).toBeDefined(); | |
| 266 | expect(replanSince(app.db, fresh(), current(outside))).toBeUndefined(); | |
| 267 | const close = (i: Issue, closing: Parameters<typeof setIssueState>[5]) => | |
| 268 | setIssueState(app, atlas, fresh(), current(i), "closed", closing); | |
| 269 | // Not dropped, not into an older issue, not without a piece. | |
| 270 | expect(close(a!, { reason: "dropped" }).ok).toBe(false); | |
| 271 | expect(close(a!, { reason: "split", into: [old.number] }).ok).toBe(false); | |
| 272 | expect(close(a!, { reason: "split", into: [] }).ok).toBe(false); | |
| 273 | expect(current(a!).state).toBe("open"); | |
| 274 | // A person's issue outside the limb is not the planner's to drop. | |
| 275 | expect(close(outside, { reason: "dropped" }).ok).toBe(false); | |
| 276 | expect(current(outside).state).toBe("open"); | |
| 277 | const larger = open("Search over titles and bodies", atlas); | |
| 278 | expect(close(a!, { reason: "split", into: [larger.number] }).ok).toBe(true); | |
| 279 | expect(close(b!, { reason: "split", into: [larger.number] }).ok).toBe(true); | |
| 280 | // Once it has answered, the grant is gone. | |
| 281 | planned("ok", "re-planned"); | |
| 282 | expect(replanSince(app.db, fresh(), current(leaves[2]!))).toBeUndefined(); | |
| 283 | expect(pendingReplans(app.db, fresh())).toEqual([]); | |
| 284 | }); | |
| 285 | ||
| 286 | test("afterwards the milestone shows which issues were closed into which", async () => { | |
| 287 | const html = await (await page("bob", `/alice/engine/milestones/${limb.number}`)).text(); | |
| 288 | expect(html).toContain("Re-planned"); | |
| 289 | expect(html).toContain("closed as split into"); | |
| 290 | expect(html).toContain(leaves[0]!.title); | |
| 291 | expect(html).toContain("Search over titles and bodies"); | |
| 292 | expect(html).not.toContain("With the planner"); | |
| 293 | }); | |
| 294 | ||
| 295 | test("a reader and a guest never see a sealed report's title on the milestone page", async () => { | |
| 296 | const path = `/alice/engine/milestones/${limb.number}`; | |
| 297 | const seen = async (who: string | undefined) => | |
| 298 | (await fetch(`${base}${path}`, { headers: who ? { cookie: cookies[who]! } : {} })).text(); | |
| 299 | const seal = (i: Issue) => app.db.query("UPDATE issue SET sealed = 1 WHERE id = ?1").run(i.id); | |
| 300 | // Sealed while it is split from the limb: the old issue is not named. | |
| 301 | seal(leaves[1]!); | |
| 302 | for (const who of ["bob", undefined]) { | |
| 303 | const html = await seen(who); | |
| 304 | expect(html).toContain("closed as split into"); | |
| 305 | expect(html).not.toContain(leaves[1]!.title); | |
| 306 | } | |
| 307 | // Sealed as the piece it was split into: the new issue is not named. | |
| 308 | const larger = app.db | |
| 309 | .query<{ id: string }, [string]>("SELECT id FROM issue WHERE repo_id = ?1 AND title = 'Search over titles and bodies'") | |
| 310 | .get(repo.id)!; | |
| 311 | app.db.query("UPDATE issue SET sealed = 1 WHERE id = ?1").run(larger.id); | |
| 312 | for (const who of ["bob", undefined]) expect(await seen(who)).not.toContain("Search over titles and bodies"); | |
| 313 | }); | |
| 314 | ||
| 315 | test("a viewer who cannot administer sees the refusal in place of the button", async () => { | |
| 316 | const html = await (await page("bob", `/alice/engine/milestones/${limb.number}/limb?action=replan`)).text(); | |
| 317 | expect(html).toContain("only a person who administers this app"); | |
| 318 | expect(html).not.toContain("Hand to the planner"); | |
| 319 | const seen = previewReplan(app, bob, fresh(), limb.number); | |
| 320 | expect(seen.ok && seen.value.blocked).toContain("administers"); | |
| 321 | }); | |
| 322 | ||
| 323 | test("the preview says 1 issue, not 1 issues", async () => { | |
| 324 | const solo = open("Solo limb"); | |
| 325 | const html = await (await page("alice", `/alice/engine/milestones/${solo.number}/limb?action=replan`)).text(); | |
| 326 | expect(html).toContain("1 issue the planner"); | |
| 327 | expect(html).not.toContain("1 issues"); | |
| 328 | }); | |
| 329 | ||
| 330 | test("a take-back is refused once the planner has been given the limb", async () => { | |
| 331 | await tick(); | |
| 332 | expect(replanLimb(app, alice, fresh(), limb.number).ok).toBe(true); | |
| 333 | await tick(); | |
| 334 | createRun(app.db, { repoId: repo.id, kind: "agent", issueId: direction.id, actorId: atlas.id, reason: "assigned" }); | |
| 335 | const seen = previewReplan(app, alice, fresh(), limb.number); | |
| 336 | expect(seen.ok && seen.value.takeBack).toBe(false); | |
| 337 | expect(takeBackLimb(app, alice, fresh(), limb.number).ok).toBe(false); | |
| 338 | }); | |
| 339 | ||
| 340 | test("a planning run that is cancelled leaves the limb takeable back and owed again", async () => { | |
| 341 | const runs = () => | |
| 342 | app.db | |
| 343 | .query<{ id: string; state: string }, [string]>( | |
| 344 | "SELECT id, state FROM run WHERE issue_id = ?1 AND kind = 'agent'", | |
| 345 | ) | |
| 346 | .all(direction.id); | |
| 347 | expect(pendingReplans(app.db, fresh()).length).toBe(1); | |
| 348 | for (const r of runs()) if (r.state === "queued" || r.state === "running") cancelRun(app.db, r.id); | |
| 349 | const seen = previewReplan(app, alice, fresh(), limb.number); | |
| 350 | expect(seen.ok && seen.value.takeBack).toBe(true); | |
| 351 | expect(pendingReplans(app.db, fresh()).length).toBe(1); | |
| 352 | const asked = () => | |
| 353 | app.db | |
| 354 | .query<{ n: number }, [string, number]>( | |
| 355 | "SELECT COUNT(*) AS n FROM event WHERE repo_id = ?1 AND kind = 'issue.assigned' AND json_extract(payload, '$.number') = ?2", | |
| 356 | ) | |
| 357 | .get(repo.id, direction.number)!.n; | |
| 358 | const before = asked(); | |
| 359 | // One action a tick: the planner is asked within a few. | |
| 360 | for (let n = 0; n < 4 && asked() === before; n++) await driveRepo(app, fresh()); | |
| 361 | expect(asked()).toBeGreaterThan(before); | |
| 362 | }); | |
| 363 | }); |
test/tui-limbs.test.ts +53 −1
| @@ -14,7 +14,10 @@ import { tmpdir } from "node:os"; | ||
| 14 | 14 | import { join } from "node:path"; |
| 15 | 15 | import { linkIssue } from "../src/actions/dependencies.ts"; |
| 16 | 16 | import { openIssue } from "../src/actions/issues.ts"; |
| 17 | import { createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 18 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 19 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 20 | import { pendingReplans } from "../src/core/limb_replan.ts"; | |
| 18 | 21 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 19 | 22 | import { listEvents } from "../src/core/events.ts"; |
| 20 | 23 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| @@ -248,5 +251,54 @@ describe("raise and lower a limb in the terminal", () => { | ||
| 248 | 251 | const body = await res.text(); |
| 249 | 252 | expect(body).toContain('id="live"'); |
| 250 | 253 | expect(body).toContain("limb.moved"); |
| 254 | // A hand-over to the planner, or taking it back, reaches an open page too. | |
| 255 | expect(body).toMatch(/limb\.replan[^_]/); | |
| 256 | expect(body).toContain("limb.replan_taken_back"); | |
| 257 | }); | |
| 258 | ||
| 259 | test("p hands a limb to the planner after a preview, shows it with the planner, and takes it back; a reader has no key", async () => { | |
| 260 | const made = await createRepo(forge, alice, { name: "plan", visibility: "public" }); | |
| 261 | if (!made.ok) throw new Error("repo"); | |
| 262 | const r = made.value; | |
| 263 | const agent = await createAgent(forge, alice, { username: "atlas" }); | |
| 264 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 265 | addToCrew(forge, alice, r, "atlas"); | |
| 266 | setCrewRole(forge.db, r.id, getUserByName(forge.db, "atlas")!.id, "planner"); | |
| 267 | await updateRepoSettings(forge, alice, r, { | |
| 268 | autonomy: "autonomous", | |
| 269 | autonomyDirection: "Keep it small.", | |
| 270 | autonomyCeilingUsd: 10, | |
| 271 | autonomyBy: alice.id, | |
| 272 | }); | |
| 273 | const search = open(r, alice, "Search everything"); | |
| 274 | for (const t of ["Index titles", "Index bodies"]) link(r, search, open(r, alice, t)); | |
| 275 | const pending = () => pendingReplans(forge.db, getRepoById(forge.db, r.id)!).length; | |
| 276 | ||
| 277 | const reader = boot(bob); | |
| 278 | await reader.toMilestones("alice/plan"); | |
| 279 | await reader.press("p"); | |
| 280 | expect(reader.text()).not.toContain("Hand to the planner"); | |
| 281 | expect(pending()).toBe(0); | |
| 282 | reader.stop(); | |
| 283 | ||
| 284 | const h = boot(alice); | |
| 285 | await h.toMilestones("alice/plan"); | |
| 286 | await h.press("p"); | |
| 287 | // The preview lists what the planner will be given; nothing is written yet. | |
| 288 | expect(h.text()).toContain("Index titles"); | |
| 289 | expect(h.text()).toContain("Hand to the planner"); | |
| 290 | expect(pending()).toBe(0); | |
| 291 | await h.press("esc"); | |
| 292 | expect(pending()).toBe(0); | |
| 293 | await h.press("p", "enter"); | |
| 294 | expect(pending()).toBe(1); | |
| 295 | expect(h.text()).toContain("with the planner"); | |
| 296 | // Take it back while the planner has not started. | |
| 297 | await h.press("p"); | |
| 298 | expect(h.text()).toContain("Take it back"); | |
| 299 | await h.press("enter"); | |
| 300 | expect(pending()).toBe(0); | |
| 301 | expect(h.text()).not.toContain("with the planner"); | |
| 302 | h.stop(); | |
| 251 | 303 | }); |
| 252 | 304 | }); |
After this insertion, "It is the same fleet operation the web milestones page uses" now follows the re-plan sentence. Re-plan is explicitly not a fleet operation (docs/PLANNING.md says so). Move this sentence back so it follows Raise/Lower/undo, or name the move, so a reader doesn't look for a receipt to undo a hand-over.