Public: anyone can read this; the crew can change it.
"How is my forge?" recommends "Ask the owner" for every item, so "make the recommended choices" does nothing #12378
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12377-how-is-my-forge-recommends-ask-the-owner → main 5 commits fork
6 files
changed +337
−19
docs/features/forge-needs.md +16 −1
| @@ -4,6 +4,21 @@ One stack of what needs the owner: `needsStack` (`src/actions/needs.ts`) is the | ||
| 4 | 4 | |
| 5 | 5 | The terminal has it too: palette command "How is my forge?" (`src/tui/screens/needs.ts`, offered to anyone signed in). `j`/`k` move, `a` takes the recommended choice of the need under the cursor. It reads and writes through the same actions as the tool, so a person's gates are the page's, and an agent's session applies nothing. Any bus event redraws it, so a change made on the page shows there. |
| 6 | 6 | |
| 7 | Each need: `sentence`, `why` it ranks, `waits` (what happens if nobody acts), `choices`, and `recommended`: `choice`, `reason`, `undo` (`receipt` or `none`), `safe`, and for an unsafe one the `ask` to put to the owner. A recommendation comes from state (`recommend`). Only a close the crew advised on a pull is safe: it has a receipt. Everything else is "Ask the owner" with the question, including a green pull: a merge has no undo but a revert pull, and green is not a person's approval. | |
| 7 | Each need: `sentence`, `why` it ranks, `waits` (what happens if nobody acts), `choices`, and `recommended`. | |
| 8 | `recommended` has `choice`, `reason`, `undo` (`receipt` or `none`), `safe`, and for an unsafe one the `ask` to put to the owner. | |
| 9 | A recommendation comes from state (`recommend`). | |
| 10 | ||
| 11 | Safe defaults, taken by apply (`done` says what each did): | |
| 12 | - A close the crew advised on a pull: it has a receipt. | |
| 13 | - A restore drill (an admin's): it restores into a scratch place and changes nothing live. | |
| 14 | - A stopped pull: handed back to its builder with a comment and an assignment; the pull is re-checked as still stopped first. | |
| 15 | - An issue assigned to the owner: handed to the crew's builder. | |
| 16 | ||
| 17 | Decisions, left out of apply by name, each with its `options` and the question: | |
| 18 | - Everything else says "Needs your decision". | |
| 19 | - A green pull is one: a merge has no undo but a revert pull, and green is not a person's approval. | |
| 20 | - "Ask the owner" is only for someone who cannot make the choice. | |
| 21 | ||
| 22 | In the terminal, `a` reports the same `done` rows, or the `refused` reason. | |
| 8 | 23 | |
| 9 | 24 | `forge {apply: "recommended"}` plans (dry by default); `dryRun: false` does the safe ones through the asks' bulk actions (`asks_bulk.ts`), so the per-row access check and the receipt are theirs. Undo with `asks_act {action: "undo", operation}`. The rest is returned untouched under `left`. |
src/actions/needs.ts +118 −14
| @@ -6,9 +6,14 @@ | ||
| 6 | 6 | * Applying "recommended" goes through the asks' bulk actions (`asks_bulk.ts`), |
| 7 | 7 | * so the preview, the per-row access check, the receipt and the undo are theirs. |
| 8 | 8 | */ |
| 9 | import type { App } from "../app.ts"; | |
| 9 | 10 | import { applyAsksBulk, receiptOf } from "./asks_bulk.ts"; |
| 10 | 11 | import { askStackFor, type askLinesFor } from "../web/studio/asks.ts"; |
| 11 | 12 | import { needLine } from "./health.ts"; |
| 13 | import { drillNow } from "./backups_now.ts"; | |
| 14 | import { builderOf, giveBack, handBack } from "./give_back.ts"; | |
| 15 | import { getIssueById } from "../core/issues.ts"; | |
| 16 | import { canWrite, getRepoById } from "../core/repos.ts"; | |
| 12 | 17 | import { forgeHealth } from "../kernel/guard.ts"; |
| 13 | 18 | import type { Chips } from "../core/feed_chips.ts"; |
| 14 | 19 | import type { User } from "../core/users.ts"; |
| @@ -17,7 +22,11 @@ import type { Ctx } from "../http/context.ts"; | ||
| 17 | 22 | type Home = Pick<Ctx, "app">; |
| 18 | 23 | |
| 19 | 24 | export interface Recommended { |
| 20 | /** The choice, in the words `choices` use; "Ask the owner" when the forge cannot tell. */ | |
| 25 | /** | |
| 26 | * The choice, in the words `choices` use. "Needs your decision" when no default is safe: | |
| 27 | * `options` then names the real ones. "Ask the owner" only for someone who cannot make the | |
| 28 | * choice. | |
| 29 | */ | |
| 21 | 30 | readonly choice: string; |
| 22 | 31 | readonly reason: string; |
| 23 | 32 | /** What undoing it takes: "undo" (a receipt), "revert pull", or "none". */ |
| @@ -26,8 +35,13 @@ export interface Recommended { | ||
| 26 | 35 | readonly safe: boolean; |
| 27 | 36 | /** For an item not safe: the question to put to the owner. */ |
| 28 | 37 | readonly ask?: string; |
| 29 | /** The asks' bulk action that carries it out, for a safe one: only a close (it has a receipt). */ | |
| 30 | readonly action?: "close"; | |
| 38 | /** | |
| 39 | * What carries it out, for a safe one: a close (it has a receipt), a drill, or a hand back to | |
| 40 | * the crew. | |
| 41 | */ | |
| 42 | readonly action?: "close" | "drill" | "give_back"; | |
| 43 | /** For a need with no safe default: the real options the owner picks between. */ | |
| 44 | readonly options?: readonly string[]; | |
| 31 | 45 | } |
| 32 | 46 | |
| 33 | 47 | export interface Need { |
| @@ -44,31 +58,38 @@ export interface Need { | ||
| 44 | 58 | |
| 45 | 59 | type Row = ReturnType<typeof askLinesFor>[number]; |
| 46 | 60 | |
| 47 | const askOwner = (question: string, reason: string): Recommended => ({ | |
| 48 | choice: "Ask the owner", | |
| 61 | /** | |
| 62 | * No safe default: the owner's call, with the options it is between. Left out of "recommended" | |
| 63 | * by name. | |
| 64 | */ | |
| 65 | const needsDecision = (question: string, reason: string, options: readonly string[]): Recommended => ({ | |
| 66 | choice: "Needs your decision", | |
| 49 | 67 | reason, |
| 50 | 68 | undo: "none", |
| 51 | 69 | safe: false, |
| 52 | 70 | ask: question, |
| 71 | options, | |
| 53 | 72 | }); |
| 54 | 73 | |
| 55 | 74 | /** What to do about one row, from its state: its kind, what it waits for, the crew's advice. */ |
| 56 | 75 | export function recommend(row: Row): Recommended { |
| 57 | 76 | const about = row.line; |
| 77 | const options = row.words.options; | |
| 58 | 78 | if (row.items.length !== 1) |
| 59 | return askOwner(`Which of these should be taken first: ${about}?`, "several changes share this row; each is its own call"); | |
| 79 | return needsDecision(`Which of these should be taken first: ${about}?`, "several changes share this row; each is its own call", options); | |
| 60 | 80 | const advice = row.recommendation; |
| 61 | 81 | const conflicts = row.waitingFor.word === "conflicts with main"; |
| 62 | 82 | if (row.numberOf === "pull" && !conflicts) { |
| 63 | 83 | // A merge has no undo but a revert pull, so no change is shipped on a recommendation: |
| 64 | 84 | // the owner's word is the approval. |
| 65 | 85 | if (row.kind === "look") |
| 66 | return askOwner( | |
| 86 | return needsDecision( | |
| 67 | 87 | `${about}: ship it? Green, no conflicts, nothing asks for changes.`, |
| 68 | 88 | "a merge is undone only by a revert pull, and green is not a person's approval", |
| 89 | options, | |
| 69 | 90 | ); |
| 70 | 91 | if (row.kind === "waiting" && advice?.verdict === "merge") |
| 71 | return askOwner(`${about}: ship it? The crew advises merging: ${advice.reason || "no reason given"}.`, "a merge is undone only by a revert pull, and the crew's advice is not a person's approval"); | |
| 92 | return needsDecision(`${about}: ship it? The crew advises merging: ${advice.reason || "no reason given"}.`, "a merge is undone only by a revert pull, and the crew's advice is not a person's approval", options); | |
| 72 | 93 | if (advice?.verdict === "close" && (row.kind === "waiting" || row.kind === "handover" || row.kind === "stopped")) |
| 73 | 94 | return { |
| 74 | 95 | choice: "Close it", |
| @@ -78,14 +99,49 @@ export function recommend(row: Row): Recommended { | ||
| 78 | 99 | action: "close", |
| 79 | 100 | }; |
| 80 | 101 | } |
| 81 | if (conflicts) return askOwner(`${about}: it conflicts with main; should its author rebase it?`, "it cannot land until its conflicts are gone"); | |
| 102 | if (row.kind === "stopped" && !conflicts && row.numberOf === "pull") | |
| 103 | return { | |
| 104 | choice: "Start the crew on it again", | |
| 105 | reason: "the check still fails and the crew stopped; handing it back to its builder is a comment and an assignment, nothing is shipped", | |
| 106 | undo: "none", | |
| 107 | safe: true, | |
| 108 | action: "give_back", | |
| 109 | }; | |
| 110 | if (row.kind === "assigned" && row.numberOf === "issue") | |
| 111 | return { | |
| 112 | choice: "Give it to the crew", | |
| 113 | reason: "it is assigned to you and open; the crew's builder takes it, and you can take it back by assigning it to yourself", | |
| 114 | undo: "none", | |
| 115 | safe: true, | |
| 116 | action: "give_back", | |
| 117 | }; | |
| 118 | if (conflicts) return needsDecision(`${about}: it conflicts with main; should its author rebase it?`, "it cannot land until its conflicts are gone", options); | |
| 82 | 119 | if (row.kind === "proposal" || row.kind === "declined") |
| 83 | return askOwner(`${about}: what direction should the crew take?`, "a product decision: only the owner can make it"); | |
| 120 | return needsDecision(`${about}: what direction should the crew take?`, "a product decision: only the owner can make it", options); | |
| 84 | 121 | if (row.kind === "paused") |
| 85 | return askOwner(`${about}: raise today's ceiling?`, "it spends money above what is set"); | |
| 122 | return needsDecision(`${about}: raise today's ceiling?`, "it spends money above what is set", options); | |
| 86 | 123 | if (row.kind === "review") |
| 87 | return askOwner(`${about}: ship it, or say what is off?`, "an agent's change is landed after a person's review"); | |
| 88 | return askOwner(`${about}: what should happen?`, "the state does not say which way to go"); | |
| 124 | return needsDecision(`${about}: ship it, or say what is off?`, "an agent's change is landed after a person's review", options); | |
| 125 | return needsDecision(`${about}: what should happen?`, "the state does not say which way to go", options); | |
| 126 | } | |
| 127 | ||
| 128 | /** What to do about one of the forge's own needs, from its cause. */ | |
| 129 | export function forgeRecommended(cause: string, sentence: string): Recommended { | |
| 130 | if (cause === "drill") | |
| 131 | return { | |
| 132 | choice: "Run the drill now", | |
| 133 | reason: "no restore has ever been proven; a drill restores the newest snapshot into a scratch place and changes nothing live", | |
| 134 | undo: "none", | |
| 135 | safe: true, | |
| 136 | action: "drill", | |
| 137 | }; | |
| 138 | if (cause.startsWith("drill:")) | |
| 139 | return needsDecision(sentence, "a standby drill causes a real fault and needs an operator at the keyboard; the forge cannot run it for you", ["Run it with the command", "Leave it"]); | |
| 140 | if (cause.startsWith("db-")) | |
| 141 | return needsDecision(`${sentence} Make room, or leave it?`, "the database's size has no safe default: clearing it loses data, leaving it keeps growing", ["Make room", "Leave it"]); | |
| 142 | if (cause.startsWith("queue:")) | |
| 143 | return needsDecision(`${sentence} Raise the check slots, or leave them?`, "more slots use more of the machine", ["Raise the check slots", "Leave them"]); | |
| 144 | return needsDecision(`${sentence} Look into it?`, "the forge's own health is the owner's to act on", ["Open it", "Leave it"]); | |
| 89 | 145 | } |
| 90 | 146 | |
| 91 | 147 | /** The stack: the forge's own needs first (an admin's), then the asks, most consequential first. */ |
| @@ -98,7 +154,7 @@ export function needsStack(ctx: Home, user: User, chips?: Chips): Need[] { | ||
| 98 | 154 | why: "the forge itself is degraded", |
| 99 | 155 | waits: "it stays that way until someone looks", |
| 100 | 156 | choices: ["Open it"], |
| 101 | recommended: askOwner(`${d.sentence} Look into it?`, "the forge's own health is the owner's to act on"), | |
| 157 | recommended: forgeRecommended(d.cause, d.sentence), | |
| 102 | 158 | link: d.href, |
| 103 | 159 | items: [], |
| 104 | 160 | }), |
| @@ -134,12 +190,44 @@ export interface Applied { | ||
| 134 | 190 | readonly dryRun: boolean; |
| 135 | 191 | /** What was (or would be) done, one row each. */ |
| 136 | 192 | readonly plan: { id: string; choice: string; reason: string; undo: Recommended["undo"] }[]; |
| 193 | /** What was done, one row each, with what it did. */ | |
| 194 | readonly done: { id: string; choice: string; result: string }[]; | |
| 137 | 195 | /** Left for the owner, each with the question to ask. */ |
| 138 | 196 | readonly left: { id: string; sentence: string; ask: string }[]; |
| 139 | 197 | readonly receipts: { operation: string; receipt: string; undo?: string }[]; |
| 140 | 198 | readonly refused: { item: string; reason: string }[]; |
| 141 | 199 | } |
| 142 | 200 | |
| 201 | /** Hand a stopped pull, or an assigned issue, to the crew's builder: the card's own give-back. */ | |
| 202 | export async function handedBack( | |
| 203 | app: App, | |
| 204 | user: User, | |
| 205 | item: string, | |
| 206 | ): Promise<{ ok: true; says: string } | { ok: false; reason: string }> { | |
| 207 | if (item.startsWith("assigned:")) { | |
| 208 | const back = giveBack(app, user, item); | |
| 209 | return back.ok ? { ok: true, says: `handed to @${back.value.builder}` } : { ok: false, reason: back.error.message }; | |
| 210 | } | |
| 211 | // The `stopped:` prefix is the item's own word: what is acted on is a pull that is open, | |
| 212 | // writable by this person and still on the stack as stopped. | |
| 213 | const pull = getIssueById(app.db, item.slice(item.indexOf(":") + 1)); | |
| 214 | const repo = pull === undefined ? undefined : getRepoById(app.db, pull.repoId); | |
| 215 | const stillStopped = askStackFor({ app }, user).rows.some((r) => r.kind === "stopped" && r.items.includes(item)); | |
| 216 | if ( | |
| 217 | pull === undefined || | |
| 218 | repo === undefined || | |
| 219 | pull.kind !== "pull" || | |
| 220 | pull.state !== "open" || | |
| 221 | !stillStopped || | |
| 222 | !canWrite(app.db, repo, user) | |
| 223 | ) | |
| 224 | return { ok: false, reason: "it no longer asks" }; | |
| 225 | const builder = builderOf(app.db, repo, pull); | |
| 226 | if (builder === undefined) return { ok: false, reason: "no builder on the crew opened it, so there is no one to give it to" }; | |
| 227 | const given = handBack(app, user, repo, pull, builder, `Given back to the crew by @${user.username}: the recommended choice for a stopped run.`); | |
| 228 | return given.ok ? { ok: true, says: `handed back to @${builder.username}` } : { ok: false, reason: given.error.message }; | |
| 229 | } | |
| 230 | ||
| 143 | 231 | /** Apply the recommended choice of every safe item; dry unless `dryRun` is false. */ |
| 144 | 232 | export async function applyRecommended(ctx: Home, user: User, dryRun: boolean, only?: string): Promise<Applied> { |
| 145 | 233 | const stack = needsStack(ctx, user).filter((n) => only === undefined || n.id === only); |
| @@ -150,10 +238,22 @@ export async function applyRecommended(ctx: Home, user: User, dryRun: boolean, o | ||
| 150 | 238 | left: stack |
| 151 | 239 | .filter((n) => !safe.includes(n)) |
| 152 | 240 | .map((n) => ({ id: n.id, sentence: n.sentence, ask: n.recommended.ask ?? n.sentence })), |
| 241 | done: [], | |
| 153 | 242 | receipts: [], |
| 154 | 243 | refused: [], |
| 155 | 244 | }; |
| 156 | 245 | if (dryRun) return out; |
| 246 | for (const n of safe.filter((n) => n.recommended.action === "drill")) { | |
| 247 | const drilled = await drillNow(ctx.app, user); | |
| 248 | out.done.push({ id: n.id, choice: n.recommended.choice, result: drilled.ok ? drilled.value.says : drilled.error.says }); | |
| 249 | } | |
| 250 | for (const n of safe.filter((n) => n.recommended.action === "give_back")) { | |
| 251 | for (const item of n.items) { | |
| 252 | const back = await handedBack(ctx.app, user, item); | |
| 253 | if (back.ok) out.done.push({ id: n.id, choice: n.recommended.choice, result: back.says }); | |
| 254 | else out.refused.push({ item, reason: back.reason }); | |
| 255 | } | |
| 256 | } | |
| 157 | 257 | for (const action of ["close"] as const) { |
| 158 | 258 | const items = safe.filter((n) => n.recommended.action === action).flatMap((n) => [...n.items]); |
| 159 | 259 | if (items.length === 0) continue; |
| @@ -163,6 +263,10 @@ export async function applyRecommended(ctx: Home, user: User, dryRun: boolean, o | ||
| 163 | 263 | out.refused.push(...items.map((item) => ({ item, reason: done.error.message }))); |
| 164 | 264 | continue; |
| 165 | 265 | } |
| 266 | const skipped = new Set(done.value.skipped.map((s) => s.item)); | |
| 267 | for (const n of safe.filter((n) => n.recommended.action === action)) | |
| 268 | for (const item of n.items) | |
| 269 | if (!skipped.has(item)) out.done.push({ id: n.id, choice: n.recommended.choice, result: "closed, with a receipt" }); | |
| 166 | 270 | out.refused.push(...done.value.skipped); |
| 167 | 271 | out.receipts.push({ |
| 168 | 272 | operation: done.value.id, |
src/http/mcp/tools/forge.ts +1 −1
| @@ -13,7 +13,7 @@ export const tool: Tool = { | ||
| 13 | 13 | }, |
| 14 | 14 | }, |
| 15 | 15 | doc: |
| 16 | "`{ apply?, dryRun? }`: `{ says, needs, recommended }`: one or two sentences, then what needs you (the home page's own stack, most consequential first: `sentence`, `why`, `choices`, `recommended` with `reason`, `undo`, `safe`, and the `ask` to put to the owner when not), and the call that applies them. `apply: \"recommended\"` (or one need's `id`) takes the recommended choice of each safe need (a close the crew advised, which has a receipt); a ship is never one: a merge has no undo, so it is a question for the owner and lists the rest, untouched, with their questions; planned first (`dryRun` is true unless `false`); each receipt names its undo (`asks_act` `undo`)", | |
| 16 | "`{ apply?, dryRun? }`: `{ says, needs, recommended }`: one or two sentences, then what needs you (the home page's own stack, most consequential first: `sentence`, `why`, `choices`, `recommended` with `reason`, `undo`, `safe`, and the `ask` to put to the owner when not), and the call that applies them. `apply: \"recommended\"` (or one need's `id`) takes the recommended choice of each safe need (a close the crew advised, a restore drill, a stopped run or assigned issue handed to the crew) and answers `done` with what each did; a ship is never one (a merge has no undo), nor a need with no safe default: those say `Needs your decision` with their real `options`, and are listed under `left` by name; planned first (`dryRun` is true unless `false`); each receipt names its undo (`asks_act` `undo`)", | |
| 17 | 17 | writes: true, |
| 18 | 18 | dryByDefault: true, |
| 19 | 19 | readsWithout: "apply", |
src/tui/screens/needs.ts +2 −1
| @@ -78,7 +78,8 @@ export function needsScreen(f: Session, ctx: Ctx): Screen { | ||
| 78 | 78 | runAsync(ctx, async () => { |
| 79 | 79 | const done = await applyRecommended({ app: f.app }, f.user!, false, at.id); |
| 80 | 80 | const left = done.refused[0]; |
| 81 | ctx.toast(left ? `Refused: ${left.reason}` : done.receipts[0]?.receipt ?? "Nothing to do.", left ? "danger" : undefined); | |
| 81 | const said = done.done.map((d) => d.result).join("; "); | |
| 82 | ctx.toast(left ? `Refused: ${left.reason}` : said || (done.receipts[0]?.receipt ?? "Nothing to do."), left ? "danger" : undefined); | |
| 82 | 83 | reload(); |
| 83 | 84 | }); |
| 84 | 85 | return true; |
test/forge-needs-defaults.test.ts added +198
| @@ -0,0 +1,198 @@ | ||
| 1 | /** | |
| 2 | * "Make the recommended choices" (#12377): a need the owner can decide has a real | |
| 3 | * recommendation, apply carries it out and says what it did; one with no safe default | |
| 4 | * says "Needs your decision" with its options and is left out by name. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { applyRecommended, forgeRecommended, handedBack, needsStack } from "../src/actions/needs.ts"; | |
| 15 | import { removeCrew } from "../src/core/agents.ts"; | |
| 16 | import { getIssue, setAssignee } from "../src/core/issues.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 19 | import { openPull } from "../src/actions/pulls.ts"; | |
| 20 | import { recordDecision } from "../src/core/decisions.ts"; | |
| 21 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 22 | import { recordVerdict } from "../src/snapshot/verdict.ts"; | |
| 23 | import type { DrillResult } from "../src/snapshot/drill.ts"; | |
| 24 | import { startSnapshots } from "../src/snapshot/service.ts"; | |
| 25 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 26 | import { needsScreen } from "../src/tui/screens/needs.ts"; | |
| 27 | import type { Ctx as TuiCtx } from "../src/tui/app/app.ts"; | |
| 28 | import type { Session } from "../src/tui/session.ts"; | |
| 29 | import type { ForgeEvent } from "../src/core/events.ts"; | |
| 30 | import { quietLogs } from "./helpers/globals.ts"; | |
| 31 | ||
| 32 | quietLogs(); | |
| 33 | ||
| 34 | let dataDir = ""; | |
| 35 | let app: App; | |
| 36 | let alice: User; | |
| 37 | let repo: Repo; | |
| 38 | let number = 0; | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dataDir = await mkdtemp(join(tmpdir(), "forge-needs-defaults-")); | |
| 42 | app = createApp({ dataDir, port: 0 }); | |
| 43 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-needs-passphrase", adminIfFirst: true }); | |
| 44 | if (!a.ok) throw new Error(a.error); | |
| 45 | alice = a.value; | |
| 46 | await createAgent(app, alice, { username: "bot" }); | |
| 47 | const made = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 48 | if (!made.ok) throw new Error(String(made.error)); | |
| 49 | repo = made.value; | |
| 50 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# open\n" }] }); | |
| 51 | addToCrew(app, alice, repo, "bot"); | |
| 52 | const issue = openIssue(app, alice, repo, { title: "Mine" }); | |
| 53 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 54 | number = issue.value.number; | |
| 55 | const assigned = assignIssue(app, alice, repo, issue.value, alice); | |
| 56 | if (!assigned.ok) throw new Error(String(assigned.error)); | |
| 57 | }, 30_000); | |
| 58 | ||
| 59 | afterAll(() => rm(dataDir, { recursive: true, force: true })); | |
| 60 | ||
| 61 | test("an issue assigned to the owner is handed to the crew by apply, and the reply says so", async () => { | |
| 62 | const need = needsStack({ app }, alice).find((n) => n.link.endsWith(`/issues/${number}`))!; | |
| 63 | expect(need.recommended).toMatchObject({ choice: "Give it to the crew", safe: true, action: "give_back" }); | |
| 64 | const dry = await applyRecommended({ app }, alice, true); | |
| 65 | expect(dry.plan.map((p) => p.id)).toContain(need.id); | |
| 66 | expect(getIssue(app.db, repo.id, number)!.assigneeId).toBe(alice.id); | |
| 67 | const done = await applyRecommended({ app }, alice, false); | |
| 68 | expect(done.done).toEqual([expect.objectContaining({ id: need.id, result: expect.stringContaining("@bot") })]); | |
| 69 | expect(getIssue(app.db, repo.id, number)!.assigneeId).toBe(getUserByName(app.db, "bot")!.id); | |
| 70 | }); | |
| 71 | ||
| 72 | test("forge needs: the restore drill is a default; a database, a queue and a standby drill are decisions with options", () => { | |
| 73 | expect(forgeRecommended("drill", "x")).toMatchObject({ safe: true, action: "drill" }); | |
| 74 | for (const cause of ["db-size", "queue:agent", "drill:never"]) { | |
| 75 | const r = forgeRecommended(cause, "x"); | |
| 76 | expect(r).toMatchObject({ choice: "Needs your decision", safe: false }); | |
| 77 | expect(r.options!.length).toBe(2); | |
| 78 | } | |
| 79 | }); | |
| 80 | ||
| 81 | let branchN = 0; | |
| 82 | /** | |
| 83 | * An open pull by `author`, its check failed; `stopped` adds the crew's stop, so it is a stopped | |
| 84 | * card. | |
| 85 | */ | |
| 86 | const redPull = async (author: string, stopped: boolean) => { | |
| 87 | const n = ++branchN; | |
| 88 | const who = getUserByName(app.db, author)!; | |
| 89 | await commitToBranch(app, alice, repo, { branch: `${author}/r${n}`, base: "main", message: `Work ${n}`, files: [{ path: `R${n}.md`, content: `${n}\n` }] }); | |
| 90 | const opened = await openPull(app, who, repo, { title: `Red ${n}`, body: "", baseBranch: "main", headBranch: `${author}/r${n}` }); | |
| 91 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 92 | const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: opened.value.id, env: `pull-${opened.value.number}`, commitOid: opened.value.headOid, reason: "pull" }); | |
| 93 | claimRun(app.db, run.id); | |
| 94 | finishRun(app.db, run.id, { state: "failed", summary: "red" }); | |
| 95 | if (stopped) recordDecision(app.db, { kind: "retry", repoId: repo.id, issueId: opened.value.id, state: {}, answer: "stop", p: 1, by: "rule", ms: 1 }); | |
| 96 | return opened.value; | |
| 97 | }; | |
| 98 | const needOf = (user: User, number: number) => needsStack({ app }, user).find((n) => n.link.endsWith(`/pulls/${number}`)); | |
| 99 | const carolOf = async (): Promise<User> => { | |
| 100 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-carol-passphrase" }); | |
| 101 | if (!made.ok) throw new Error(made.error); | |
| 102 | return made.value; | |
| 103 | }; | |
| 104 | ||
| 105 | test("a stopped pull recommends starting the crew again, and apply hands it back with a comment and the assignment", async () => { | |
| 106 | const pull = await redPull("bot", true); | |
| 107 | // A pull its builder opened starts assigned to it; the person took it, so it is theirs now. | |
| 108 | setAssignee(app.db, pull.id, alice.id); | |
| 109 | const need = needOf(alice, pull.number)!; | |
| 110 | expect(need.recommended).toMatchObject({ choice: "Start the crew on it again", safe: true, action: "give_back" }); | |
| 111 | const done = await applyRecommended({ app }, alice, false, need.id); | |
| 112 | expect(done.refused).toEqual([]); | |
| 113 | expect(done.done).toEqual([expect.objectContaining({ id: need.id, result: "handed back to @bot" })]); | |
| 114 | expect(getIssue(app.db, repo.id, pull.number)!.assigneeId).toBe(getUserByName(app.db, "bot")!.id); | |
| 115 | const comments = app.db.query<{ body: string }, [string]>("SELECT body FROM comment WHERE issue_id = ?1").all(pull.id); | |
| 116 | expect(comments.map((c) => c.body).join("\n")).toContain("Given back to the crew by @alice"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("handedBack refuses a pull with no builder, a closed one, a non-writer, an issue, and one no longer stopped", async () => { | |
| 120 | await createAgent(app, alice, { username: "ghost" }); | |
| 121 | addToCrew(app, alice, repo, "ghost"); | |
| 122 | const orphan = await redPull("ghost", true); | |
| 123 | removeCrew(app.db, repo.id, getUserByName(app.db, "ghost")!.id); | |
| 124 | expect(await handedBack(app, alice, `stopped:${orphan.id}`)).toEqual({ ok: false, reason: expect.stringContaining("no builder") }); | |
| 125 | expect(getIssue(app.db, repo.id, orphan.number)!.assigneeId).not.toBe(getUserByName(app.db, "bot")!.id); | |
| 126 | ||
| 127 | const closed = await redPull("bot", true); | |
| 128 | app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(closed.id); | |
| 129 | expect(await handedBack(app, alice, `stopped:${closed.id}`)).toEqual({ ok: false, reason: "it no longer asks" }); | |
| 130 | ||
| 131 | const stopped = await redPull("bot", true); | |
| 132 | const carol = await carolOf(); | |
| 133 | expect(await handedBack(app, carol, `stopped:${stopped.id}`)).toEqual({ ok: false, reason: "it no longer asks" }); | |
| 134 | ||
| 135 | const issue = openIssue(app, alice, repo, { title: "An issue, not a pull" }); | |
| 136 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 137 | expect(await handedBack(app, alice, `stopped:${issue.value.id}`)).toEqual({ ok: false, reason: "it no longer asks" }); | |
| 138 | ||
| 139 | // Failed, but the crew has not stopped: working, not stopped. | |
| 140 | const working = await redPull("bot", false); | |
| 141 | expect(await handedBack(app, alice, `stopped:${working.id}`)).toEqual({ ok: false, reason: "it no longer asks" }); | |
| 142 | expect(app.db.query("SELECT 1 FROM comment WHERE issue_id = ?1").all(working.id)).toEqual([]); | |
| 143 | }); | |
| 144 | ||
| 145 | const redDrill = (): DrillResult => | |
| 146 | ({ at: new Date().toISOString(), snapshotId: "s1", ms: 5, counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, health: "unreached", verdict: "red", failedStep: "fetch", phases: [], budgetMs: 1000, reason: null }) as DrillResult; | |
| 147 | ||
| 148 | test("the drill need runs the drill for an admin and says so; a non-admin never sees it and runs nothing", async () => { | |
| 149 | let runs = 0; | |
| 150 | const handle = startSnapshots(app, dirStore(join(dataDir, "store")), { | |
| 151 | version: "0", | |
| 152 | nightly: false, | |
| 153 | drill: async () => { | |
| 154 | runs++; | |
| 155 | return { ...redDrill(), verdict: "green", failedStep: null, health: "ok" } as DrillResult; | |
| 156 | }, | |
| 157 | }); | |
| 158 | try { | |
| 159 | recordVerdict(dataDir, redDrill()); | |
| 160 | const need = needsStack({ app }, alice).find((n) => n.id === "forge:drill")!; | |
| 161 | expect(need.recommended).toMatchObject({ safe: true, action: "drill" }); | |
| 162 | const dry = await applyRecommended({ app }, alice, true, need.id); | |
| 163 | expect(dry.plan.map((p) => p.id)).toEqual(["forge:drill"]); | |
| 164 | expect(runs).toBe(0); | |
| 165 | const carol = getUserByName(app.db, "carol") ?? (await carolOf()); | |
| 166 | expect(needsStack({ app }, carol).some((n) => n.id === "forge:drill")).toBe(false); | |
| 167 | const theirs = await applyRecommended({ app }, carol, false, "forge:drill"); | |
| 168 | expect(theirs.done).toEqual([]); | |
| 169 | expect(runs).toBe(0); | |
| 170 | const done = await applyRecommended({ app }, alice, false, need.id); | |
| 171 | expect(runs).toBe(1); | |
| 172 | expect(done.done).toEqual([{ id: "forge:drill", choice: "Run the drill now", result: "The drill restored green." }]); | |
| 173 | } finally { | |
| 174 | await handle.stop(); | |
| 175 | } | |
| 176 | }); | |
| 177 | ||
| 178 | test("a in the terminal hands a stopped pull back, and the web sees the comment on the bus", async () => { | |
| 179 | // Stopped pulls of one repository share a row; this one stands alone. | |
| 180 | app.db.query("UPDATE issue SET state = 'closed' WHERE kind = 'pull' AND title LIKE 'Red %'").run(); | |
| 181 | const pull = await redPull("bot", true); | |
| 182 | const toasts: string[] = []; | |
| 183 | const tracked: Promise<unknown>[] = []; | |
| 184 | const tui = { toast: (t: string) => toasts.push(t), invalidate() {}, push() {}, track: (p: Promise<unknown>) => (tracked.push(p), p) } as unknown as TuiCtx; | |
| 185 | const screen = needsScreen({ user: alice, db: app.db, app } as unknown as Session, tui); | |
| 186 | const at = needsStack({ app }, alice).findIndex((n) => n.link.endsWith(`/pulls/${pull.number}`)); | |
| 187 | expect(at).toBeGreaterThanOrEqual(0); | |
| 188 | const key = (k: string) => ({ type: "key" as const, key: k, ctrl: false, alt: false, shift: false }); | |
| 189 | for (let i = 0; i < at; i++) screen.key?.(key("j")); | |
| 190 | const events: ForgeEvent[] = []; | |
| 191 | const off = app.bus.subscribe((e) => void events.push(e)); | |
| 192 | expect(screen.key?.(key("a"))).toBe(true); | |
| 193 | await Promise.all(tracked); | |
| 194 | off(); | |
| 195 | expect(getIssue(app.db, repo.id, pull.number)!.assigneeId).toBe(getUserByName(app.db, "bot")!.id); | |
| 196 | expect(toasts).toEqual(["handed back to @bot"]); | |
| 197 | expect(events.some((e) => e.kind === "pull.commented" && e.repoId === repo.id)).toBe(true); | |
| 198 | }); |
test/forge-needs-one-stack.test.ts +2 −2
| @@ -155,9 +155,9 @@ test("a green agent pull is a question for the owner, never safe to ship", async | ||
| 155 | 155 | const { needs } = await forge(); |
| 156 | 156 | const pull = needs.find((n) => n.link.endsWith(`/pulls/${pullNumber}`))!; |
| 157 | 157 | const parked = needs.find((n) => n.link.endsWith(`/issues/${issueNumber}`))!; |
| 158 | expect(pull.recommended).toMatchObject({ choice: "Ask the owner", safe: false }); | |
| 158 | expect(pull.recommended).toMatchObject({ choice: "Needs your decision", safe: false }); | |
| 159 | 159 | expect(pull.recommended.ask).toContain("ship it?"); |
| 160 | expect(parked.recommended).toMatchObject({ choice: "Ask the owner", safe: false }); | |
| 160 | expect(parked.recommended).toMatchObject({ choice: "Needs your decision", safe: false }); | |
| 161 | 161 | expect(parked.recommended.ask).toContain(`#${issueNumber}`); |
| 162 | 162 | }); |
| 163 | 163 |
new above
offline