Public: anyone can read this; the crew can change it.
A milestone's page shows what its limb cost and landed, with its costliest issues #1984
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1596-a-milestone-s-page-shows-what-its-limb-c → main 3 commits fork
7 files
changed +203
−6
README.md +6 −1
| @@ -131,7 +131,12 @@ and of the top of the ready list first, then `enter` to apply (`esc` backs | ||
| 131 | 131 | out), then `u` to undo the last move. It is the same fleet operation the web |
| 132 | 132 | milestones page uses, and a move is heard by that page when it is open. |
| 133 | 133 | Anyone else sees the order and no keys to change it; a guest sees only what |
| 134 | the web shows them. | |
| 134 | the web shows them. Whoever may see owner facts also gets two lines under | |
| 135 | the list for the limb under the cursor: **Cost and landed** (dollars spent, | |
| 136 | changes landed) and its costliest three issues; a reader or guest sees none. | |
| 137 | The web page has the same *Cost and landed* section on a milestone, and the | |
| 138 | milestones API and MCP carry it as `spend` (`usd`, `landed`, `costliest`), | |
| 139 | owner only. | |
| 135 | 140 | |
| 136 | 141 | Working on the UI itself? `bun run src/tui/main.ts --user alice` runs it over |
| 137 | 142 | stdio against your local data directory. That `--user` flag is a development |
docs/MCP.md +1 −1
| @@ -52,7 +52,7 @@ for a tunnel and a forge that sleeps. | ||
| 52 | 52 | | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes | |
| 53 | 53 | | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes | |
| 54 | 54 | | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`) | | |
| 55 | | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate) | | | |
| 55 | | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | | | |
| 56 | 56 | | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes | |
| 57 | 57 | | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes | |
| 58 | 58 | | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | | |
src/core/milestones.ts +53
| @@ -40,6 +40,9 @@ import { getUsersByIds } from "./users.ts"; | ||
| 40 | 40 | /** A limb with this many open issues beneath it and nobody building is one to look at. */ |
| 41 | 41 | export const TRIM_OPEN = 8; |
| 42 | 42 | |
| 43 | /** How many issues a milestone's spend names. */ | |
| 44 | export const COSTLIEST = 3; | |
| 45 | ||
| 43 | 46 | /** How heavy a limb is: what is beneath it, and whether it moves. */ |
| 44 | 47 | export interface Weight { |
| 45 | 48 | /** The work beneath it: what is open, and what closed as done. */ |
| @@ -100,6 +103,17 @@ export interface Milestone extends Limb { | ||
| 100 | 103 | readonly title: string; |
| 101 | 104 | readonly at: string; |
| 102 | 105 | }[]; |
| 106 | /** | |
| 107 | * What the limb cost and landed, ever: owner facts only, so nothing for a | |
| 108 | * viewer who may not see them. `costliest` is at most `COSTLIEST` issues | |
| 109 | * beneath it, dearest first; an issue that cost nothing is not listed. | |
| 110 | */ | |
| 111 | readonly spend?: { | |
| 112 | readonly usd: number; | |
| 113 | /** Pulls merged, ever, for the issues beneath it. */ | |
| 114 | readonly landed: number; | |
| 115 | readonly costliest: readonly { readonly issue: Issue; readonly usd: number }[]; | |
| 116 | }; | |
| 103 | 117 | /** A plain sentence: when it lands at this week's pace, or why nobody can say. */ |
| 104 | 118 | readonly estimate: string; |
| 105 | 119 | /** What it waits for directly that has a tree of its own. */ |
| @@ -270,6 +284,18 @@ export function readPlan( | ||
| 270 | 284 | } |
| 271 | 285 | } |
| 272 | 286 | |
| 287 | // Every pull ever merged, by the issues it closes: what a limb landed. | |
| 288 | // One query that parses each merged pull's body; owner reads only. | |
| 289 | const everMerged: (readonly number[])[] = options.spend | |
| 290 | ? db | |
| 291 | .query<{ body: string }, [string]>( | |
| 292 | `SELECT i.body FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 293 | WHERE i.repo_id = ?1 AND p.merged_at IS NOT NULL`, | |
| 294 | ) | |
| 295 | .all(repo.id) | |
| 296 | .map((row) => closesFromBody(row.body)) | |
| 297 | : []; | |
| 298 | ||
| 273 | 299 | const stateOf = (issue: Issue): LeafState => { |
| 274 | 300 | if (issue.state === "closed") return counted(issue) ? "done" : "gone"; |
| 275 | 301 | if (building.has(issue.id)) return "building"; |
| @@ -504,6 +530,21 @@ export function readPlan( | ||
| 504 | 530 | merged: merged |
| 505 | 531 | .filter((pull) => pull.closes.some((n) => numbers.has(n))) |
| 506 | 532 | .map(({ number: n, title, at }) => ({ number: n, title, at })), |
| 533 | ...(options.spend | |
| 534 | ? { | |
| 535 | spend: { | |
| 536 | usd: limb.weight.spentUsd ?? 0, | |
| 537 | landed: everMerged.filter((closes) => | |
| 538 | closes.some((n) => numbers.has(n)), | |
| 539 | ).length, | |
| 540 | costliest: under | |
| 541 | .filter((i) => (cost.get(i.id) ?? 0) > 0) | |
| 542 | .map((i) => ({ issue: i, usd: cost.get(i.id)! })) | |
| 543 | .sort((a, b) => b.usd - a.usd || a.issue.number - b.issue.number) | |
| 544 | .slice(0, COSTLIEST), | |
| 545 | }, | |
| 546 | } | |
| 547 | : {}), | |
| 507 | 548 | estimate, |
| 508 | 549 | limbs: arranged(issue.id, [...limbIds]).map(limbOf), |
| 509 | 550 | leaves: children |
| @@ -576,6 +617,18 @@ export function milestoneJson( | ||
| 576 | 617 | on: b.on.map(brief), |
| 577 | 618 | })), |
| 578 | 619 | merged: milestone.merged, |
| 620 | ...(milestone.spend === undefined | |
| 621 | ? {} | |
| 622 | : { | |
| 623 | spend: { | |
| 624 | usd: milestone.spend.usd, | |
| 625 | landed: milestone.spend.landed, | |
| 626 | costliest: milestone.spend.costliest.map((c) => ({ | |
| 627 | ...brief(c.issue), | |
| 628 | usd: c.usd, | |
| 629 | })), | |
| 630 | }, | |
| 631 | }), | |
| 579 | 632 | limbs: milestone.limbs.map(limbJson), |
| 580 | 633 | leaves: milestone.leaves.map((leaf) => ({ |
| 581 | 634 | ...brief(leaf.issue), |
src/tui/screens/limbs.ts +31 −4
| @@ -8,7 +8,9 @@ | ||
| 8 | 8 | * verb `place`. What the screen decides is only what to draw and which keys |
| 9 | 9 | * exist; the action decides who may. A reader sees the order and no keys to |
| 10 | 10 | * change it, an agent likewise, and a guest sees what `canRead` lets the web |
| 11 | * show — the plan is read without the owner's spend. | |
| 11 | * show — the plan is read without the owner's spend. Whoever may see owner | |
| 12 | * facts (`canSeeOwnerFacts`, as on the page) also gets, under the list, what | |
| 13 | * the limb under the cursor cost and landed, and its costliest issues. | |
| 12 | 14 | * |
| 13 | 15 | * `enter` on a limb that has limbs of its own opens them, so a milestone's |
| 14 | 16 | * sub-limbs can be moved among themselves (the `under` of the action). |
| @@ -18,8 +20,8 @@ | ||
| 18 | 20 | |
| 19 | 21 | import { undoFleetOp } from "../../actions/fleet.ts"; |
| 20 | 22 | import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts"; |
| 21 | import { canAdmin, canRead, type Repo } from "../../core/repos.ts"; | |
| 22 | import { readPlan, type Limb } from "../../core/milestones.ts"; | |
| 23 | import { canAdmin, canRead, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 24 | import { readPlan, type Limb, type Milestone } from "../../core/milestones.ts"; | |
| 23 | 25 | import type { Ctx, Screen } from "../app/app.ts"; |
| 24 | 26 | import { binding } from "../app/keys.ts"; |
| 25 | 27 | import { fill, len, split } from "../ui/layout.ts"; |
| @@ -51,6 +53,15 @@ function limbsOf(f: Session, repo: Repo, group: string): Limb[] | undefined { | ||
| 51 | 53 | return milestone === undefined ? undefined : [...milestone.limbs]; |
| 52 | 54 | } |
| 53 | 55 | |
| 56 | /** What a limb cost and landed, as the two lines under the list. */ | |
| 57 | export function spendLines(spend: NonNullable<Milestone["spend"]>): string[] { | |
| 58 | const dear = spend.costliest.map((c) => `#${c.issue.number} $${c.usd.toFixed(2)}`); | |
| 59 | return [ | |
| 60 | `Cost and landed: $${spend.usd.toFixed(2)} spent · ${plural(spend.landed, "change")} landed`, | |
| 61 | dear.length === 0 ? "Nothing has cost anything yet." : `Costliest: ${dear.join(" · ")}`, | |
| 62 | ]; | |
| 63 | } | |
| 64 | ||
| 54 | 65 | const line = (n: number, title: string): string => `#${n} ${title}`; |
| 55 | 66 | |
| 56 | 67 | /** What a preview says before anything is written, as dialog lines. */ |
| @@ -89,6 +100,8 @@ export function limbsScreen( | ||
| 89 | 100 | const state = new ListState(); |
| 90 | 101 | let limbs: Limb[] = []; |
| 91 | 102 | let loaded = false; |
| 103 | /** What each limb here cost and landed, by issue number; empty unless owner facts. */ | |
| 104 | let spends = new Map<number, NonNullable<Milestone["spend"]>>(); | |
| 92 | 105 | /** The receipt of the last move made here, which `u` reverses once. */ |
| 93 | 106 | let last: string | undefined; |
| 94 | 107 | // Read at use, not at open: a repository's admin can change under a session. |
| @@ -98,11 +111,20 @@ export function limbsScreen( | ||
| 98 | 111 | const reload = (follow?: number): void => { |
| 99 | 112 | if (!canRead(f.db, repo, f.user)) { |
| 100 | 113 | limbs = []; |
| 114 | spends = new Map(); | |
| 101 | 115 | loaded = true; |
| 102 | 116 | ctx.invalidate(); |
| 103 | 117 | return; |
| 104 | 118 | } |
| 105 | 119 | limbs = limbsOf(f, repo, group) ?? []; |
| 120 | spends = new Map(); | |
| 121 | if (canSeeOwnerFacts(f.db, repo, f.user)) { | |
| 122 | const plan = readPlan(f.db, repo, { spend: true }); | |
| 123 | for (const l of limbs) { | |
| 124 | const spend = plan.milestone(l.issue.number)?.spend; | |
| 125 | if (spend !== undefined) spends.set(l.issue.number, spend); | |
| 126 | } | |
| 127 | } | |
| 106 | 128 | loaded = true; |
| 107 | 129 | if (follow !== undefined) { |
| 108 | 130 | const at = limbs.findIndex((l) => l.issue.number === follow); |
| @@ -203,7 +225,8 @@ export function limbsScreen( | ||
| 203 | 225 | }, |
| 204 | 226 | render(area, s) { |
| 205 | 227 | const c = ctx.theme.c; |
| 206 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 228 | const spend = current() === undefined ? undefined : spends.get(current()!.issue.number); | |
| 229 | const [head, list, foot] = split(area, "v", [len(1), fill(1), len(spend ? 2 : 0)]); | |
| 207 | 230 | const note = !loaded |
| 208 | 231 | ? "Loading…" |
| 209 | 232 | : limbs.length === 0 |
| @@ -211,6 +234,10 @@ export function limbsScreen( | ||
| 211 | 234 | : `${plural(limbs.length, "limb")} in the order the crew takes ready work${may() ? "" : " · you can read this order, not change it"}`; |
| 212 | 235 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 213 | 236 | drawList(s, list!, c, state, rows()); |
| 237 | if (spend !== undefined) | |
| 238 | spendLines(spend).forEach((text, n) => | |
| 239 | s.text(foot!.x, foot!.y + n, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w), | |
| 240 | ); | |
| 214 | 241 | hitList(ctx, list!, state, limbs.length, { open }); |
| 215 | 242 | }, |
| 216 | 243 | key(e) { |
src/web/pages/milestones.ts +26
| @@ -376,6 +376,31 @@ function milestoneBody( | ||
| 376 | 376 | )} |
| 377 | 377 | </section>`, |
| 378 | 378 | )} |
| 379 | ${when( | |
| 380 | m.spend, | |
| 381 | (spend) => | |
| 382 | html`<section class="ms-section" id="ms-spend"> | |
| 383 | <h2>Cost and landed</h2> | |
| 384 | <p class="ms-figures"> | |
| 385 | <span><b>${dollars(spend.usd)}</b> spent</span> | |
| 386 | <span | |
| 387 | ><b>${spend.landed}</b> change${spend.landed === 1 ? "" : "s"} | |
| 388 | landed</span | |
| 389 | > | |
| 390 | </p> | |
| 391 | ${spend.costliest.length === 0 | |
| 392 | ? html`<p class="muted small">Nothing has cost anything yet.</p>` | |
| 393 | : html`<ol class="ms-rows"> | |
| 394 | ${spend.costliest.map( | |
| 395 | (c) => | |
| 396 | html`<li> | |
| 397 | ${issue(c.issue.number, c.issue.title)} | |
| 398 | <span class="ms-state small">${dollars(c.usd)}</span> | |
| 399 | </li>`, | |
| 400 | )} | |
| 401 | </ol>`} | |
| 402 | </section>`, | |
| 403 | )} | |
| 379 | 404 | <section class="ms-section" id="ms-merged"> |
| 380 | 405 | <h2>Merged in ${PILLAR_DAYS} days</h2> |
| 381 | 406 | ${m.merged.length === 0 |
| @@ -601,6 +626,7 @@ export const milestonesCss = /* css */ ` | ||
| 601 | 626 | .ms-section h2 { font-size: 13px; margin: 0 0 6px; } |
| 602 | 627 | .ms-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; } |
| 603 | 628 | .ms-rows li > span { margin-left: 6px; } |
| 629 | .ms-figures { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 0 0 8px; font-variant-numeric: tabular-nums; } | |
| 604 | 630 | .ms-state { color: var(--muted-fg); } |
| 605 | 631 | .ms-rows li[data-state="building"] .ms-state { color: var(--accent); } |
| 606 | 632 | .ms-rows li[data-state="blocked"] .ms-state { color: var(--attn); } |
test/milestones.test.ts +54
| @@ -373,6 +373,14 @@ describe("milestones", () => { | ||
| 373 | 373 | expect(one).toContain(`href="/alice/engine/issues/${network.number}"`); |
| 374 | 374 | expect(one).toContain(`href="/alice/engine/pulls/${landed}"`); |
| 375 | 375 | expect(one).toContain("$1.50"); |
| 376 | // What it cost and landed, with its costliest: the shell, $1.50, one change. | |
| 377 | const spend = one.slice( | |
| 378 | one.indexOf('id="ms-spend"'), | |
| 379 | one.indexOf("</section>", one.indexOf('id="ms-spend"')), | |
| 380 | ); | |
| 381 | expect(spend).toMatch(/<b>\$1\.50<\/b> spent/); | |
| 382 | expect(spend).toMatch(/<b>1<\/b> change\s+landed/); | |
| 383 | expect(spend).toContain("Sandbox the shell"); | |
| 376 | 384 | // Each issue once: the network is under Building now and the disk under |
| 377 | 385 | // Blocked, so Issues lists neither again, only what closed. |
| 378 | 386 | const issues = one.slice( |
| @@ -390,6 +398,7 @@ describe("milestones", () => { | ||
| 390 | 398 | ).text(); |
| 391 | 399 | expect(theirs).toMatch(/<b>1<\/b> of 3 done/); |
| 392 | 400 | expect(theirs).not.toContain("$1.50"); |
| 401 | expect(theirs).not.toContain('id="ms-spend"'); | |
| 393 | 402 | expect(theirs).not.toContain("mason"); |
| 394 | 403 | expect(theirs).toMatch(/the crew · since/); |
| 395 | 404 | |
| @@ -484,6 +493,12 @@ describe("milestones", () => { | ||
| 484 | 493 | }) |
| 485 | 494 | ).json()) as Record<string, any>; |
| 486 | 495 | expect("spentUsd" in theirs).toBe(false); |
| 496 | expect("spend" in theirs).toBe(false); | |
| 497 | expect(one["spend"]).toMatchObject({ | |
| 498 | usd: 1.5, | |
| 499 | landed: 1, | |
| 500 | costliest: [{ number: shell.number, usd: 1.5 }], | |
| 501 | }); | |
| 487 | 502 | expect(theirs["buildingNow"][0].who).toBe("the crew"); |
| 488 | 503 | expect(theirs["done"]).toBe(1); |
| 489 | 504 | }); |
| @@ -515,4 +530,43 @@ describe("milestones", () => { | ||
| 515 | 530 | expect(page).toMatch(/<b>0<\/b> of 2 done/); |
| 516 | 531 | expect(page).toContain("A profit slider"); |
| 517 | 532 | }); |
| 533 | ||
| 534 | test("a milestone's spend adds up: its dollars are its issues' dollars, the three dearest are named, and a reader sees none", () => { | |
| 535 | const top = issue(plain, "Cost 1.0"); | |
| 536 | const parts = [0.5, 3, 1, 2.25].map((usd, n) => { | |
| 537 | const part = issue(plain, `Costed ${n + 1}`); | |
| 538 | link(plain, top, part); | |
| 539 | const run = createRun(app.db, { | |
| 540 | repoId: plain.id, | |
| 541 | kind: "agent", | |
| 542 | issueId: part.id, | |
| 543 | actorId: mason.id, | |
| 544 | reason: "assigned", | |
| 545 | }); | |
| 546 | claimRun(app.db, run.id); | |
| 547 | finishRun(app.db, run.id, { state: "ok", summary: "x", costUsd: usd }); | |
| 548 | return part; | |
| 549 | }); | |
| 550 | const free = issue(plain, "Costed nothing"); | |
| 551 | link(plain, top, free); | |
| 552 | const m = readPlan(app.db, fresh(plain), { spend: true }).milestone( | |
| 553 | top.number, | |
| 554 | )!; | |
| 555 | expect(m.spend!.usd).toBe(6.75); | |
| 556 | expect(m.spend!.usd).toBe(m.weight.spentUsd!); | |
| 557 | expect(m.spend!.landed).toBe(0); | |
| 558 | expect(m.spend!.costliest.map((c) => [c.issue.number, c.usd])).toEqual([ | |
| 559 | [parts[1]!.number, 3], | |
| 560 | [parts[3]!.number, 2.25], | |
| 561 | [parts[2]!.number, 1], | |
| 562 | ]); | |
| 563 | // The three named are never more than the whole. | |
| 564 | expect(m.spend!.costliest.reduce((sum, c) => sum + c.usd, 0)).toBeLessThanOrEqual( | |
| 565 | m.spend!.usd, | |
| 566 | ); | |
| 567 | const reader = readPlan(app.db, fresh(plain), { spend: false }).milestone( | |
| 568 | top.number, | |
| 569 | )!; | |
| 570 | expect(reader.spend).toBeUndefined(); | |
| 571 | }); | |
| 518 | 572 | }); |
test/tui-limbs.test.ts +32
| @@ -19,6 +19,7 @@ import { createApp, type App as Forge } from "../src/app.ts"; | ||
| 19 | 19 | import { listEvents } from "../src/core/events.ts"; |
| 20 | 20 | import { getIssueById, type Issue } from "../src/core/issues.ts"; |
| 21 | 21 | import { readPlan } from "../src/core/milestones.ts"; |
| 22 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 22 | 23 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 23 | 24 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 24 | 25 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -117,6 +118,15 @@ describe("raise and lower a limb in the terminal", () => { | ||
| 117 | 118 | if (r === repo) { |
| 118 | 119 | trust = a; |
| 119 | 120 | feed = b; |
| 121 | const run = createRun(forge.db, { | |
| 122 | repoId: r.id, | |
| 123 | kind: "agent", | |
| 124 | issueId: leafA.id, | |
| 125 | actorId: alice.id, | |
| 126 | reason: "assigned", | |
| 127 | }); | |
| 128 | claimRun(forge.db, run.id); | |
| 129 | finishRun(forge.db, run.id, { state: "ok", summary: "x", costUsd: 2.5 }); | |
| 120 | 130 | } |
| 121 | 131 | } |
| 122 | 132 | }, 60_000); |
| @@ -193,6 +203,28 @@ describe("raise and lower a limb in the terminal", () => { | ||
| 193 | 203 | h.stop(); |
| 194 | 204 | }); |
| 195 | 205 | |
| 206 | test("an owner sees what the limb under the cursor cost and landed; a reader and a guest see none", async () => { | |
| 207 | const h = boot(alice); | |
| 208 | await h.toMilestones("alice/order"); | |
| 209 | expect(h.text()).toContain("Cost and landed: $2.50 spent · 0 changes landed"); | |
| 210 | expect(h.text()).toContain("Costliest: #"); | |
| 211 | expect(h.text()).toContain("$2.50"); | |
| 212 | // The Feed limb cost nothing. | |
| 213 | await h.press("j"); | |
| 214 | expect(h.text()).toContain("Cost and landed: $0.00 spent"); | |
| 215 | expect(h.text()).toContain("Nothing has cost anything yet."); | |
| 216 | h.stop(); | |
| 217 | ||
| 218 | for (const who of [bob, undefined]) { | |
| 219 | const r = boot(who); | |
| 220 | await r.toMilestones("alice/order"); | |
| 221 | expect(r.text()).toContain("Trust"); | |
| 222 | expect(r.text()).not.toContain("Cost and landed"); | |
| 223 | expect(r.text()).not.toContain("$2.50"); | |
| 224 | r.stop(); | |
| 225 | } | |
| 226 | }); | |
| 227 | ||
| 196 | 228 | test("a guest sees a public order, never a private one, and moves nothing", async () => { |
| 197 | 229 | const h = boot(undefined); |
| 198 | 230 | await h.toMilestones("alice/vault"); |
new above
offline