Public: anyone can read this; the crew can change it.
A pull drifting far behind main is flagged on /flow with the one action that rebases it #10877
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10861-a-pull-drifting-far-behind-main-is-flagg → main 11 commits fork 2 threads · 2 unresolved
15 files
changed +427
−22
docs/MCP.md +6 −1
| @@ -106,7 +106,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 106 | 106 | | `flag_set` | — | Set or clear a flag (admin) | 209 | |
| 107 | 107 | | `flag_winner` | — | An A/B flag winner becomes default | 206 | |
| 108 | 108 | | `flags` | — | Declared flags: why, default, owner issue, live value | 171 | |
| 109 | | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 480 | | |
| 109 | | `flow` | `bugs`, `drift` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 488 | | |
| 110 | 110 | | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 | |
| 111 | 111 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 112 | 112 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| @@ -536,3 +536,8 @@ answers | ||
| 536 | 536 | (the clone URL as the forge prints it), then `/plugin install forge@forge` |
| 537 | 537 | (the root `.claude-plugin/marketplace.json` names the marketplace `forge`). |
| 538 | 538 | From a clone, `claude --plugin-dir plugins/claude-code` is the local way. |
| 539 | ||
| 540 | `flow` with `{ kind: "drift" }` (#10861) is another: `{ drifting: [{ repo, number, landings, hours }] }`, | |
| 541 | open pulls over 25 landings or 6 h behind main, furthest first, from | |
| 542 | `src/flow/alarm_drift.ts`. Each is brought up to date by | |
| 543 | `POST /{repo}/pulls/{number}/update`, the button beside it on /boards. |
docs/features/drift.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Pulls drifting from main | |
| 2 | ||
| 3 | An open pull more than 25 landings or 6 h behind its base is listed on `/boards` (and so `/flow`) under "Drifting from main", furthest first, each with one button, "Bring up to date". Nothing is shown when none drifts. The same fact is the `drift` alarm. | |
| 4 | ||
| 5 | - **Page:** `src/web/pages/boards-drift.ts`, a plain section of `/boards` (admin only, as the page is). The button posts `POST /:owner/:name/pulls/:number/update` (`updateSubmit`, `src/web/studio/actions.ts`): same-origin, a viewer of the pull, and a writer of the repository; it asks the pull's agent to merge main into its branch (`bringUpToDate`) and lands nothing. Closed pulls, pulls with no agent, and a pull that has used its tries are refused in words. | |
| 6 | - **Terminal:** the palette entry "Drifting pulls" (`src/tui/screens/drift.ts`, admins who are people); `b` or enter asks, `r` reads again. Same reads, same action, same refusals. | |
| 7 | - **MCP:** `flow` with `{ kind: drift }` lists them: `{ drifting: [{ repo, number, landings, hours }] }`. There is no verb to ask the agent yet; use the route. | |
| 8 | - **Reads:** `src/flow/alarm_drift.ts`, git per pull cached by head and tip; merged and closed pulls never count. | |
| 9 | - **Tests:** `test/flow-alarm-drift.test.ts`, `test/studio-update-pull.test.ts` (writer, non-writer, closed, cross-origin), `test/tui-drift.test.ts`. |
scripts/unreached-allow.json −1
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | [ |
| 2 | 2 | "src/apps/cloud.ts", |
| 3 | 3 | "src/crew/governor_log.ts", |
| 4 | "src/flow/alarm_drift.ts", | |
| 5 | 4 | "src/flow/alarm_unproposed.ts", |
| 6 | 5 | "src/flow/approved_wait.ts", |
| 7 | 6 | "src/flow/clog_words.ts", |
src/flow/alarm_drift.ts +45 −14
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | * so a rebased pull would stay named); the hours run from the first of them. Merged and closed pulls never count. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { existsSync } from "node:fs"; | |
| 10 | ||
| 9 | 11 | import type { App } from "../app.ts"; |
| 10 | 12 | import { runGit } from "../git/env.ts"; |
| 11 | 13 | import { isSafeOid, isSafeRef } from "../git/guard.ts"; |
| @@ -40,7 +42,7 @@ interface Row { | ||
| 40 | 42 | /** Landings on `branch` past where `headOid` forked from it and when the first happened, read from git; zero when unreadable. */ |
| 41 | 43 | export async function readDrift(dir: string, branch: string, headOid: string): Promise<Omit<Drift, "number">> { |
| 42 | 44 | const none = { landings: 0, since: null }; |
| 43 | if (!isSafeOid(headOid) || !isSafeRef(branch)) return none; | |
| 45 | if (!isSafeOid(headOid) || !isSafeRef(branch) || !existsSync(dir)) return none; | |
| 44 | 46 | const tip = `refs/heads/${branch}`; |
| 45 | 47 | const fork = await runGit(["merge-base", headOid, tip], { cwd: dir }); |
| 46 | 48 | const base = fork.ok ? fork.value.trim() : ""; |
| @@ -64,7 +66,7 @@ export function clearDriftCache(): void { | ||
| 64 | 66 | |
| 65 | 67 | /** The tip of `branch`, one cheap git call; null when unreadable. */ |
| 66 | 68 | export async function branchTip(dir: string, branch: string): Promise<string | null> { |
| 67 | if (!isSafeRef(branch)) return null; | |
| 69 | if (!isSafeRef(branch) || !existsSync(dir)) return null; | |
| 68 | 70 | const out = await runGit(["rev-parse", "--verify", "-q", `refs/heads/${branch}`], { cwd: dir }); |
| 69 | 71 | const oid = out.ok ? out.value.trim() : ""; |
| 70 | 72 | return isSafeOid(oid) ? oid : null; |
| @@ -107,21 +109,25 @@ export function driftAlarm(drifts: readonly Drift[], now: number): DriftAlarm { | ||
| 107 | 109 | return { fired: true, since: new Date(since).toISOString(), says: `${named.join("; ")}${more}.` }; |
| 108 | 110 | } |
| 109 | 111 | |
| 110 | /** Reads every open pull's drift from its repository's main and answers the alarm. */ | |
| 111 | export async function alarmDrift( | |
| 112 | /** One open pull's drift and where it lives. */ | |
| 113 | export interface RepoDrift extends Drift { | |
| 114 | readonly repo: string; | |
| 115 | } | |
| 116 | ||
| 117 | /** Every open pull's drift from its repository's main; `publicOnly` leaves private repositories out. Git reads are cached per head and tip. */ | |
| 118 | export async function readDrifts( | |
| 112 | 119 | app: App, |
| 113 | now: Date | number = Date.now(), | |
| 114 | { read = readDrift, readTip = branchTip }: { read?: DriftReader; readTip?: typeof branchTip } = {}, | |
| 115 | ): Promise<DriftAlarm> { | |
| 116 | const at = typeof now === "number" ? now : now.getTime(); | |
| 120 | { read = readDrift, readTip = branchTip, publicOnly = false }: { read?: DriftReader; readTip?: typeof branchTip; publicOnly?: boolean } = {}, | |
| 121 | ): Promise<RepoDrift[]> { | |
| 117 | 122 | const rows = app.db |
| 118 | .query<Row, []>( | |
| 123 | .query<Row, [number]>( | |
| 119 | 124 | `SELECT i.number, p.base_branch, p.head_oid, o.username AS owner, r.name |
| 120 | 125 | FROM issue i JOIN pull p ON p.issue_id = i.id |
| 121 | 126 | JOIN repo r ON r.id = i.repo_id JOIN user o ON o.id = r.owner_id |
| 122 | WHERE i.kind = 'pull' AND i.state = 'open' AND p.merged_at IS NULL`, | |
| 127 | WHERE i.kind = 'pull' AND i.state = 'open' AND p.merged_at IS NULL | |
| 128 | AND (?1 = 0 OR r.visibility = 'public')`, | |
| 123 | 129 | ) |
| 124 | .all(); | |
| 130 | .all(publicOnly ? 1 : 0); | |
| 125 | 131 | const tips = new Map<string, Promise<string | null>>(); |
| 126 | 132 | const tipOf = (dir: string, branch: string) => { |
| 127 | 133 | const k = `${dir}\0${branch}`; |
| @@ -129,11 +135,36 @@ export async function alarmDrift( | ||
| 129 | 135 | if (!t) tips.set(k, (t = readTip(dir, branch))); |
| 130 | 136 | return t; |
| 131 | 137 | }; |
| 132 | const drifts = await Promise.all( | |
| 138 | return Promise.all( | |
| 133 | 139 | rows.map(async (r) => { |
| 134 | 140 | const dir = repoDir(app.reposRoot, r.owner, r.name); |
| 135 | return { number: r.number, ...(await cachedDrift(read, dir, r.base_branch, r.head_oid, await tipOf(dir, r.base_branch))) }; | |
| 141 | return { repo: `${r.owner}/${r.name}`, number: r.number, ...(await cachedDrift(read, dir, r.base_branch, r.head_oid, await tipOf(dir, r.base_branch))) }; | |
| 136 | 142 | }), |
| 137 | 143 | ); |
| 138 | return driftAlarm(drifts, at); | |
| 144 | } | |
| 145 | ||
| 146 | export interface DriftingPull { | |
| 147 | readonly repo: string; | |
| 148 | readonly number: number; | |
| 149 | readonly landings: number; | |
| 150 | /** Hours since main first moved past the pull's base, one decimal; 0 when it has not. */ | |
| 151 | readonly hours: number; | |
| 152 | } | |
| 153 | ||
| 154 | /** The pulls past either limit, the furthest behind first: what /boards and the MCP `flow` tool list beside the one action that brings each up to date. */ | |
| 155 | export function driftingPulls(drifts: readonly RepoDrift[], now: number): DriftingPull[] { | |
| 156 | return drifts | |
| 157 | .filter((d) => drifting(d, now)) | |
| 158 | .sort((a, b) => b.landings - a.landings || behind(b, now) - behind(a, now) || a.repo.localeCompare(b.repo) || a.number - b.number) | |
| 159 | .map((d) => ({ repo: d.repo, number: d.number, landings: d.landings, hours: Math.round(behind(d, now) / 360_000) / 10 })); | |
| 160 | } | |
| 161 | ||
| 162 | /** Reads every open pull's drift from its repository's main and answers the alarm. */ | |
| 163 | export async function alarmDrift( | |
| 164 | app: App, | |
| 165 | now: Date | number = Date.now(), | |
| 166 | opts: { read?: DriftReader; readTip?: typeof branchTip } = {}, | |
| 167 | ): Promise<DriftAlarm> { | |
| 168 | const at = typeof now === "number" ? now : now.getTime(); | |
| 169 | return driftAlarm(await readDrifts(app, opts), at); | |
| 139 | 170 | } |
src/http/mcp/tools/flow.ts +8 −3
| @@ -7,6 +7,7 @@ import { whyIssue, whyPull, liveHead, whyRepo, type Why } from "../../../flow/wh | ||
| 7 | 7 | import { countRead } from "../../../system/telemetry_reads.ts"; |
| 8 | 8 | import { COLUMNS, readSort, sortRows } from "../../../fleet/columns.ts"; |
| 9 | 9 | import { viewFleet } from "../../../fleet/rows.ts"; |
| 10 | import { driftingPulls, readDrifts } from "../../../flow/alarm_drift.ts"; | |
| 10 | 11 | import { openBugs } from "../../../flow/bugs.ts"; |
| 11 | 12 | import { flowCached } from "../../../system/flow_cache.ts"; |
| 12 | 13 | import { scopeAllows } from "../../../core/tokens.ts"; |
| @@ -27,13 +28,13 @@ const said = (subject: string, why: Why) => | ||
| 27 | 28 | |
| 28 | 29 | export const tool: Tool = { |
| 29 | 30 | name: "flow", |
| 30 | doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`, and `declines` (crew runs declined in 24 h, by reason) (the `ui://forge/flow` card); with `{ q, sort? }`, the rows a fleet query line selects: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a repository writer), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders", | |
| 31 | doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`, and `declines` (crew runs declined in 24 h, by reason) (the `ui://forge/flow` card); with `{ q, sort? }`, the rows a fleet query line selects: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: drift }`, `{ drifting: [{ repo, number, landings, hours }] }` the open pulls over 25 landings or 6 h behind main, furthest first; each is brought up to date by `POST /{repo}/pulls/{number}/update`; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a repository writer), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders", | |
| 31 | 32 | description: |
| 32 | 33 | "Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck.", |
| 33 | 34 | inputSchema: { |
| 34 | 35 | type: "object", |
| 35 | 36 | properties: { |
| 36 | kind: { type: "string", enum: ["bugs"] }, | |
| 37 | kind: { type: "string", enum: ["bugs", "drift"] }, | |
| 37 | 38 | q: { type: "string", maxLength: 500 }, |
| 38 | 39 | sort: { type: "string" }, |
| 39 | 40 | action: { type: "object" }, |
| @@ -79,7 +80,11 @@ export const tool: Tool = { | ||
| 79 | 80 | return good({ kind: view.kind, columns: COLUMNS[view.kind], items: sortRows(view.rows, sort) }); |
| 80 | 81 | } |
| 81 | 82 | if (args["kind"] !== undefined) { |
| 82 | if (args["kind"] !== "bugs") return bad("invalid", "kind is bugs"); | |
| 83 | if (args["kind"] === "drift") { | |
| 84 | const all = user.isAdmin && user.kind !== "agent" ? {} : { publicOnly: true }; | |
| 85 | return good({ drifting: driftingPulls(await readDrifts(ctx.app, all), Date.now()).slice(0, 100) }); | |
| 86 | } | |
| 87 | if (args["kind"] !== "bugs") return bad("invalid", "kind is bugs or drift"); | |
| 83 | 88 | // As the terminal's bugs screen: an admin sees every repository, anyone else public ones. |
| 84 | 89 | const all = openBugs(ctx.app.db, user.isAdmin && user.kind !== "agent" ? {} : { publicOnly: true }); |
| 85 | 90 | const now = Date.now(); |
src/http/server.ts +2
| @@ -252,6 +252,7 @@ import { | ||
| 252 | 252 | import { |
| 253 | 253 | saySubmit, |
| 254 | 254 | shipSubmit, |
| 255 | updateSubmit, | |
| 255 | 256 | takeBackSubmit, |
| 256 | 257 | holdSubmit, |
| 257 | 258 | releaseSubmit, |
| @@ -1196,6 +1197,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1196 | 1197 | router.add("POST", "/feed/like", likeSubmit); |
| 1197 | 1198 | router.add("POST", "/:owner/:name/pulls/:number/checks", pullChecksSubmit); |
| 1198 | 1199 | router.add("POST", "/:owner/:name/pulls/:number/ship", shipSubmit); |
| 1200 | router.add("POST", "/:owner/:name/pulls/:number/update", updateSubmit); | |
| 1199 | 1201 | router.add("POST", "/:owner/:name/pulls/:number/say", saySubmit); |
| 1200 | 1202 | router.add("POST", "/:owner/:name/pulls/:number/take-back", takeBackSubmit); |
| 1201 | 1203 | router.add("POST", "/:owner/:name/pulls/:number/hold", holdSubmit); |
src/tui/screens/drift.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * Pulls drifting from main, in the terminal: the section /boards shows, from | |
| 3 | * the same reads (`readDrifts`, `driftingPulls`), and the same one action, the | |
| 4 | * route's: `b` (or enter) asks the pull's agent to bring it up to date, for a | |
| 5 | * writer of its repository. Gated as /boards is: a site admin who is a person. | |
| 6 | * | |
| 7 | * b, enter bring the pull up to date r read again | |
| 8 | */ | |
| 9 | ||
| 10 | import { bringUpToDate } from "../../crew/autonomy.ts"; | |
| 11 | import { driftingPulls, readDrifts, type DriftingPull } from "../../flow/alarm_drift.ts"; | |
| 12 | import { getPull } from "../../core/pulls.ts"; | |
| 13 | import { canWrite, getRepo } from "../../core/repos.ts"; | |
| 14 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 15 | import { binding } from "../app/keys.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { ListState, drawList, metaOf, runAsync, type ListRow } from "./common.ts"; | |
| 18 | import type { PaletteEntry } from "./entries.ts"; | |
| 19 | import { emptyState } from "../ui/widgets.ts"; | |
| 20 | ||
| 21 | export const mayDrift = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 22 | ||
| 23 | export function driftRows(pulls: readonly DriftingPull[]): ListRow[] { | |
| 24 | return pulls.map((p) => ({ title: `${p.repo} #${p.number}`, meta: metaOf(`${p.landings} landings`, `${p.hours} h behind`) })); | |
| 25 | } | |
| 26 | ||
| 27 | /** Ask the pull's agent, as the route does; the words the toast says. */ | |
| 28 | export function askUpdate(f: Session, p: DriftingPull): { ok: boolean; says: string } { | |
| 29 | const [owner = "", name = ""] = p.repo.split("/"); | |
| 30 | const repo = getRepo(f.db, owner, name); | |
| 31 | const pull = repo ? getPull(f.db, repo.id, p.number) : undefined; | |
| 32 | if (!repo || !pull) return { ok: false, says: "That pull is gone." }; | |
| 33 | if (!canWrite(f.db, repo, f.user)) return { ok: false, says: "Only someone who can write here brings a pull up to date." }; | |
| 34 | const who = pull.state === "open" && pull.mergedAt === null ? bringUpToDate(f.app, repo, pull, []) : undefined; | |
| 35 | return who === undefined | |
| 36 | ? { ok: false, says: "The crew could not take it now (closed, no agent, or it has already tried its limit); say what to do on the pull." } | |
| 37 | : { ok: true, says: `${who} is bringing #${pull.number} up to date with ${pull.baseBranch}.` }; | |
| 38 | } | |
| 39 | ||
| 40 | export function driftScreen(f: Session, ctx: Ctx): Screen { | |
| 41 | const state = new ListState(); | |
| 42 | let pulls: readonly DriftingPull[] = []; | |
| 43 | let loaded = false; | |
| 44 | const read = async (): Promise<void> => { | |
| 45 | pulls = driftingPulls(await readDrifts(f.app), Date.now()); | |
| 46 | loaded = true; | |
| 47 | state.clamp(pulls.length); | |
| 48 | ctx.invalidate(); | |
| 49 | }; | |
| 50 | const ask = (): void => { | |
| 51 | const p = pulls[state.index]; | |
| 52 | if (p === undefined) return; | |
| 53 | const r = askUpdate(f, p); | |
| 54 | ctx.toast(r.says, r.ok ? "success" : "danger"); | |
| 55 | }; | |
| 56 | return { | |
| 57 | title: "Drifting from main", | |
| 58 | keys: [binding(["j", "k"], "move"), binding(["b", "enter"], "bring up to date"), binding(["r"], "read again"), binding(["esc", "q"], "close")], | |
| 59 | load: read, | |
| 60 | render(area, s) { | |
| 61 | if (loaded && pulls.length === 0) { | |
| 62 | emptyState(s, area, ctx.theme.c, { title: "Nothing drifts", body: "No open pull is more than 25 landings or 6 h behind main." }); | |
| 63 | return; | |
| 64 | } | |
| 65 | drawList(s, area, ctx.theme.c, state, driftRows(pulls)); | |
| 66 | }, | |
| 67 | key(e) { | |
| 68 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 69 | else if (e.key === "b" || e.key === "enter") ask(); | |
| 70 | else if (e.key === "r") runAsync(ctx, read); | |
| 71 | else state.key(e, pulls.length); | |
| 72 | return true; | |
| 73 | }, | |
| 74 | }; | |
| 75 | } | |
| 76 | ||
| 77 | export const entry: PaletteEntry = { | |
| 78 | id: "drift", | |
| 79 | title: "Drifting pulls", | |
| 80 | hint: "open pulls far behind main, and the one action that brings each up to date", | |
| 81 | after: ["boards"], | |
| 82 | offered: mayDrift, | |
| 83 | run: (session, ctx) => ctx.push(driftScreen(session, ctx)), | |
| 84 | }; |
src/tui/screens/entries.ts +2
| @@ -18,6 +18,7 @@ import { entry as dotfiles } from "./dotfiles.ts"; | ||
| 18 | 18 | import { entry as devices } from "./devices.ts"; |
| 19 | 19 | import { entry as bets } from "./bets.ts"; |
| 20 | 20 | import { entry as boards } from "./boards.ts"; |
| 21 | import { entry as drift } from "./drift.ts"; | |
| 21 | 22 | import { entry as bugs } from "./bugs.ts"; |
| 22 | 23 | import { entry as bulk } from "./bulk.ts"; |
| 23 | 24 | import { entry as childCredit } from "./childcredit.ts"; |
| @@ -93,6 +94,7 @@ export interface PaletteEntry { | ||
| 93 | 94 | export const ENTRIES: readonly PaletteEntry[] = [ |
| 94 | 95 | sentinel, |
| 95 | 96 | boards, |
| 97 | drift, | |
| 96 | 98 | askData, |
| 97 | 99 | bugs, |
| 98 | 100 | homePins, |
src/web/pages/boards-drift.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * The drifting pulls on /boards (and so /flow): open pulls more than 25 | |
| 3 | * landings or 6 h behind main, each a row with the one button that asks its | |
| 4 | * agent to bring it up to date (`POST …/pulls/N/update`). A plain section like | |
| 5 | * the other /boards blocks, no frame around the list. Nothing shown when none drifts. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { DriftingPull } from "../../flow/alarm_drift.ts"; | |
| 9 | import { html, type Html } from "../html.ts"; | |
| 10 | import { btn } from "../ui.ts"; | |
| 11 | ||
| 12 | export function driftSection(pulls: readonly DriftingPull[]): Html | null { | |
| 13 | if (pulls.length === 0) return null; | |
| 14 | return html`<section class="settings-block" id="drifting"> | |
| 15 | <h2>Drifting from main</h2> | |
| 16 | <ul class="list" data-drifting="${String(pulls.length)}"> | |
| 17 | ${pulls.map( | |
| 18 | (p) => html`<li data-drift="${p.repo}#${String(p.number)}"> | |
| 19 | <a href="/${p.repo}/pulls/${String(p.number)}">${p.repo} #${String(p.number)}</a> | |
| 20 | <span class="muted small">${String(p.landings)} landings · ${String(p.hours)} h behind</span> | |
| 21 | <form method="post" action="/${p.repo}/pulls/${String(p.number)}/update" class="bar"> | |
| 22 | <input type="hidden" name="back" value="/boards" /> | |
| 23 | ${btn("Bring up to date", { size: "sm" })} | |
| 24 | </form> | |
| 25 | </li>`, | |
| 26 | )} | |
| 27 | </ul> | |
| 28 | </section>`; | |
| 29 | } |
src/web/pages/boards-view.ts +4 −1
| @@ -13,6 +13,8 @@ import { CHART_NAMES } from "../../boards/chart.ts"; | ||
| 13 | 13 | import { MAX_H, MAX_PANELS, MAX_TITLE, MAX_W, RANGES, type Board, type Panel } from "../../boards/store.ts"; |
| 14 | 14 | import type { App } from "../../app.ts"; |
| 15 | 15 | import type { Ctx } from "../../http/context.ts"; |
| 16 | import { driftingPulls, readDrifts } from "../../flow/alarm_drift.ts"; | |
| 17 | import { driftSection } from "./boards-drift.ts"; | |
| 16 | 18 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 17 | 19 | import { liveRegionsJs } from "../client.ts"; |
| 18 | 20 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| @@ -113,6 +115,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 113 | 115 | </ul>` |
| 114 | 116 | }</section>`; |
| 115 | 117 | const flow = timePhase(ctx.request, "flow", () => flowPanel(ctx.app, { kind: "owner" }, ctx.url, "/boards")); |
| 118 | const drift = driftSection(await timePhaseAsync(ctx.request, "drift", async () => driftingPulls(await readDrifts(ctx.app), Date.now()))); | |
| 116 | 119 | const rendering = performance.now(); |
| 117 | 120 | const response = page({ |
| 118 | 121 | ctx, |
| @@ -121,7 +124,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 121 | 124 | ${btn("Flow", { href: "/flow", variant: "ghost" })} |
| 122 | 125 | ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div> |
| 123 | 126 | ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))} |
| 124 | ${body} ${make} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`, | |
| 127 | ${body} ${make} ${drift} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`, | |
| 125 | 128 | }); |
| 126 | 129 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 127 | 130 | return response; |
src/web/studio/actions.ts +23 −1
| @@ -22,7 +22,7 @@ import { mergePull } from "../../actions/pulls.ts"; | ||
| 22 | 22 | import { revertPull } from "../../actions/revert.ts"; |
| 23 | 23 | import { takeBackWords } from "../../actions/revert_words.ts"; |
| 24 | 24 | import { getPull } from "../../core/pulls.ts"; |
| 25 | import { canRead, getRepo, type Repo } from "../../core/repos.ts"; | |
| 25 | import { canRead, canWrite, getRepo, type Repo } from "../../core/repos.ts"; | |
| 26 | 26 | import { bringUpToDate } from "../../crew/autonomy.ts"; |
| 27 | 27 | import type { Ctx } from "../../http/context.ts"; |
| 28 | 28 | import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts"; |
| @@ -278,6 +278,28 @@ export async function shipSubmit(ctx: Ctx): Promise<Response> { | ||
| 278 | 278 | }); |
| 279 | 279 | } |
| 280 | 280 | |
| 281 | /** POST /:owner/:name/pulls/:number/update — ask the pull's agent to bring its branch up to date with main; nothing lands. A writer's. */ | |
No test exercises this route. Add one for a writer, a non-writer, a closed pull and a cross-origin post; the test file only checks the card renders. |
||
| 282 | export async function updateSubmit(ctx: Ctx): Promise<Response> { | |
| 283 | const repo = loadRepo(ctx); | |
| 284 | if (!repo) return notFoundPage(ctx); | |
| 285 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 286 | const form = await readForm(ctx, "rw"); | |
| 287 | if (form instanceof Response) return form; | |
| 288 | const base = repoBase(repo); | |
| 289 | const number = Number(ctx.params.number); | |
| 290 | const pull = Number.isInteger(number) ? getPull(ctx.app.db, repo.id, number) : undefined; | |
| 291 | if (!pull || !mayView(ctx.app.db, repo, pull, ctx.auth.user)) return notFoundPage(ctx); | |
| 292 | const back = backTo(form, changeHref(base, pull.number)); | |
| 293 | if (!canWrite(ctx.app.db, repo, ctx.auth.user)) | |
| 294 | return flashOrJson(ctx, back, { tone: "danger", message: "Only someone who can write here brings a pull up to date." }); | |
| 295 | const who = pull.state === "open" && pull.mergedAt === null ? bringUpToDate(ctx.app, repo, pull, []) : undefined; | |
| 296 | return flashOrJson(ctx, back, { | |
| 297 | ...(who === undefined | |
| 298 | ? { tone: "danger" as const, message: "The crew could not take it now (closed, no agent, or it has already tried its limit); say what to do on the pull." } | |
| 299 | : { message: `${who} is bringing #${pull.number} up to date with ${pull.baseBranch}.` }), | |
| 300 | }); | |
| 301 | } | |
| 302 | ||
| 281 | 303 | /** |
| 282 | 304 | * POST /:owner/:name/pulls/:number/take-back — open a new change that |
| 283 | 305 | * undoes exactly what a shipped one merged. Never touches the default |
test/flow-alarm-drift.test.ts +13 −1
| @@ -9,7 +9,8 @@ import { setIssueState } from "../src/core/issues.ts"; | ||
| 9 | 9 | import { createPull } from "../src/core/pulls.ts"; |
| 10 | 10 | import { createRepo } from "../src/core/repos.ts"; |
| 11 | 11 | import { createUser } from "../src/core/users.ts"; |
| 12 | import { alarmDrift, clearDriftCache, branchTip, cachedDrift, driftAlarm, readDrift } from "../src/flow/alarm_drift.ts"; | |
| 12 | import { driftSection } from "../src/web/pages/boards-drift.ts"; | |
| 13 | import { alarmDrift, driftingPulls, readDrifts, clearDriftCache, branchTip, cachedDrift, driftAlarm, readDrift } from "../src/flow/alarm_drift.ts"; | |
| 13 | 14 | import { repoDir } from "../src/git/repos.ts"; |
| 14 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 15 | 16 | |
| @@ -86,6 +87,17 @@ test("read from git: landings past a pull's base, open pulls only", async () => | ||
| 86 | 87 | expect(fired.says).toContain(`#${open.number} is 26 landings`); |
| 87 | 88 | const both = await alarmDrift(app, new Date(Date.now())); |
| 88 | 89 | expect(both.says).toBe(`#${open.number} is 26 landings behind main; #${shut.number} is 26 landings behind main.`); |
| 90 | // The list /boards and the MCP `flow` tool answer: furthest behind first, each with its update button. | |
| 91 | const listed = driftingPulls(await readDrifts(app), Date.now()); | |
| 92 | expect(listed.map((d) => [d.repo, d.number, d.landings])).toEqual([ | |
| 93 | ["ada/svc", open.number, 26], | |
| 94 | ["ada/svc", shut.number, 26], | |
| 95 | ]); | |
| 96 | expect(driftingPulls(await readDrifts(app, { publicOnly: true }), Date.now())).toHaveLength(2); | |
| 97 | const card = String(driftSection(listed)); | |
| 98 | expect(card).toContain(`action="/ada/svc/pulls/${open.number}/update"`); | |
| 99 | expect(card).toContain("26 landings"); | |
| 100 | expect(driftSection([])).toBeNull(); | |
| 89 | 101 | setIssueState(app.db, shut.id, "closed"); |
| 90 | 102 | const one = await alarmDrift(app, new Date(Date.now())); |
| 91 | 103 | expect(one.says).toBe(`#${open.number} is 26 landings behind main.`); |
test/palette-roster.test.ts +1
| @@ -95,6 +95,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 95 | 95 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 96 | 96 | ["sentinel", "Sentinel", "the forge's open faults"], |
| 97 | 97 | ["boards", "Boards", "the forge's boards and their panels"], |
| 98 | ["drift", "Drifting pulls", "open pulls far behind main, and the one action that brings each up to date"], | |
| 98 | 99 | ["ask-data", "Data explorer", "start from a question about the forge's data"], |
| 99 | 100 | ["bugs", "Bugs", "open bugs and the bug clock"], |
| 100 | 101 | ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"], |
test/studio-update-pull.test.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** POST /:owner/:name/pulls/:number/update: a writer asks the pull's agent to bring it up to date; nobody else, no closed pull, no cross-origin post. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { openPull } from "../src/actions/pulls.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { activeRunFor } from "../src/core/runs.ts"; | |
| 11 | import { setIssueState } from "../src/core/issues.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | let repo: Repo; | |
| 25 | let aliceCookie: string; | |
| 26 | let bobCookie: string; | |
| 27 | let n = 0; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dataDir = await mkdtemp(join(tmpdir(), "forge-update-pull-")); | |
| 31 | app = createApp({ dataDir, port: 0 }); | |
| 32 | servers = await serve(app); | |
| 33 | const mk = async (username: string) => { | |
| 34 | const u = await createUser(app.db, { | |
| 35 | origin: "seed", | |
| 36 | trusted: true, | |
| 37 | username, | |
| 38 | email: `${username}@forge.local`, | |
| 39 | password: "a-good-passphrase", | |
| 40 | adminIfFirst: username === "alice", | |
| 41 | }); | |
| 42 | if (!u.ok) throw new Error(String(u.error)); | |
| 43 | return u.value; | |
| 44 | }; | |
| 45 | alice = await mk("alice"); | |
| 46 | const bob = await mk("bob"); | |
| 47 | aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 48 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 49 | const made = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 50 | if (!made.ok) throw new Error(String(made.error)); | |
| 51 | repo = made.value; | |
| 52 | await commitToBranch(app, alice, repo, { message: "base", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 53 | }, 30_000); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | servers.stop(); | |
| 57 | app.close(); | |
| 58 | await rm(dataDir, { recursive: true, force: true }); | |
| 59 | }); | |
| 60 | ||
| 61 | /** An open pull assigned to a fresh agent, so there is someone to ask. */ | |
| 62 | async function agentPull() { | |
| 63 | const branch = `b${++n}`; | |
| 64 | const c = await commitToBranch(app, alice, repo, { branch, message: branch, files: [{ path: `${branch}.txt`, content: "x\n" }] }); | |
| 65 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 66 | const opened = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch }); | |
| 67 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 68 | const agent = await createAgent(app, alice, { username: `bot${n}` }); | |
| 69 | if (!agent.ok) throw new Error(JSON.stringify(agent.error)); | |
| 70 | app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(agent.value.user.id, opened.value.id); | |
| 71 | return opened.value; | |
| 72 | } | |
| 73 | ||
| 74 | const post = (number: number, cookie: string | undefined, origin = servers.url) => | |
| 75 | fetch(`${servers.url}/alice/notes/pulls/${number}/update`, { | |
| 76 | method: "POST", | |
| 77 | headers: { ...(cookie ? { cookie } : {}), origin, accept: "application/json" }, | |
| 78 | body: new URLSearchParams({ back: "/boards" }), | |
| 79 | redirect: "manual", | |
| 80 | }); | |
| 81 | ||
| 82 | test("a writer asks the agent: it is named and a run is queued", async () => { | |
| 83 | const pull = await agentPull(); | |
| 84 | const res = await post(pull.number, aliceCookie); | |
| 85 | expect(res.status).toBe(200); | |
| 86 | const body = JSON.stringify(await res.json()); | |
| 87 | expect(body).toContain("is bringing"); | |
| 88 | expect(body).toContain(`#${pull.number}`); | |
| 89 | expect(activeRunFor(app.db, "agent", pull.id)).toBeDefined(); | |
| 90 | }); | |
| 91 | ||
| 92 | test("someone who cannot write is refused and nothing is queued", async () => { | |
| 93 | const pull = await agentPull(); | |
| 94 | const res = await post(pull.number, bobCookie); | |
| 95 | expect(res.status).toBe(422); | |
| 96 | expect(JSON.stringify(await res.json())).toContain("Only someone who can write"); | |
| 97 | expect(activeRunFor(app.db, "agent", pull.id)).toBeUndefined(); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a closed pull cannot be asked", async () => { | |
| 101 | const pull = await agentPull(); | |
| 102 | setIssueState(app.db, pull.id, "closed"); | |
| 103 | const res = await post(pull.number, aliceCookie); | |
| 104 | expect(res.status).toBe(422); | |
| 105 | expect(JSON.stringify(await res.json())).toContain("could not take it"); | |
| 106 | expect(activeRunFor(app.db, "agent", pull.id)).toBeUndefined(); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a cross-origin post is refused and nothing is queued", async () => { | |
| 110 | const pull = await agentPull(); | |
| 111 | const res = await post(pull.number, aliceCookie, "https://evil.example"); | |
| 112 | expect(res.status).toBe(403); | |
| 113 | expect(activeRunFor(app.db, "agent", pull.id)).toBeUndefined(); | |
| 114 | }); |
test/tui-drift.test.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** The terminal's drifting-pulls screen: offered to a person admin only, says nothing drifts, and asks the agent as the route does. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { openPull } from "../src/actions/pulls.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import type { Repo } from "../src/core/repos.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { ENTRIES } from "../src/tui/screens/entries.ts"; | |
| 14 | import { askUpdate, driftRows, driftScreen, entry } from "../src/tui/screens/drift.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let alice: User; | |
| 24 | let bob: User; | |
| 25 | let repo: Repo; | |
| 26 | ||
| 27 | const open = (user: User) => | |
| 28 | createForgeApp(app, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }, { terminal: headlessTerminal(120, 30), exitHooks: false }); | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-drift-")); | |
| 32 | app = createApp({ dataDir, port: 0 }); | |
| 33 | const mk = async (username: string) => { | |
| 34 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: username === "alice" }); | |
| 35 | if (!u.ok) throw new Error(String(u.error)); | |
| 36 | return u.value; | |
| 37 | }; | |
| 38 | alice = await mk("alice"); | |
| 39 | bob = await mk("bob"); | |
| 40 | const made = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 41 | if (!made.ok) throw new Error(String(made.error)); | |
| 42 | repo = made.value; | |
| 43 | await commitToBranch(app, alice, repo, { message: "base", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 44 | }, 30_000); | |
| 45 | ||
| 46 | afterAll(async () => { | |
| 47 | app.close(); | |
| 48 | await rm(dataDir, { recursive: true, force: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | test("the entry is in the palette list and offered to a person admin alone", () => { | |
| 52 | expect(ENTRIES).toContain(entry); | |
| 53 | const a = open(alice); | |
| 54 | const b = open(bob); | |
| 55 | expect(entry.offered(a.session)).toBe(true); | |
| 56 | expect(entry.offered(b.session)).toBe(false); | |
| 57 | }); | |
| 58 | ||
| 59 | test("rows say landings and hours; an empty forge says nothing drifts", async () => { | |
| 60 | expect(driftRows([{ repo: "alice/notes", number: 3, landings: 26, hours: 1.5 }])).toEqual([ | |
| 61 | { title: "alice/notes #3", meta: expect.stringContaining("26 landings") }, | |
| 62 | ]); | |
| 63 | const { app: tui, session } = open(alice); | |
| 64 | tui.start(); | |
| 65 | await settleApp(tui); | |
| 66 | tui.push(driftScreen(session, tui.ctx)); | |
| 67 | await settleApp(tui); | |
| 68 | expect(tui.screen.toText()).toContain("Nothing drifts"); | |
| 69 | tui.stop(); | |
| 70 | }); | |
| 71 | ||
| 72 | test("asking: a writer's agent is named, a reader is refused", async () => { | |
| 73 | const c = await commitToBranch(app, alice, repo, { branch: "w", message: "w", files: [{ path: "w.txt", content: "w\n" }] }); | |
| 74 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 75 | const opened = await openPull(app, alice, repo, { title: "w", baseBranch: "main", headBranch: "w" }); | |
| 76 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 77 | const agent = await createAgent(app, alice, { username: "bot" }); | |
| 78 | if (!agent.ok) throw new Error(JSON.stringify(agent.error)); | |
| 79 | app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(agent.value.user.id, opened.value.id); | |
| 80 | const p = { repo: "alice/notes", number: opened.value.number, landings: 30, hours: 0 }; | |
| 81 | const refused = askUpdate(open(bob).session, p); | |
| 82 | expect(refused.ok).toBe(false); | |
| 83 | expect(refused.says).toContain("Only someone who can write"); | |
| 84 | const done = askUpdate(open(alice).session, p); | |
| 85 | expect(done.ok).toBe(true); | |
| 86 | expect(done.says).toContain("bot is bringing"); | |
| 87 | }); |
Principles 6 and 7 (rows over boxes, few borders): this wraps the list in
card(), which is a box around a list. Render the heading and the rows as a plain section like the other /boards blocks (settings-block). Also each row holds a form with a button per line; keep it, but do not frame it.