Public: anyone can read this; the crew can change it.

"How is my forge?" has one answer everywhere: the same needs-you stack as the… #12186

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12183-how-is-my-forge-has-one-answer-everywher → main 8 commits fork

  • Merged. scout/12183-how-is-my-forge-has-one-answer-everywher was merged into main as 47e6991.

Review

Sign in to review this change.
12 files changed +614 −14
docs/MCP.md +1
@@ -114,6 +114,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
114114 | `flow` | `bugs`, `cpu`, `drift`, `growth` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 530 |
115115 | `follow_mode` | — | How a fork follows its parent: take updates, tell me, or don't follow | 281 |
116116 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
117 | `forge` | — | How is my forge? What needs you, recommended; `apply` takes them | 199 |
117118 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
118119 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 282 |
119120 | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 431 |
docs/features/forge-needs.md added +9
@@ -0,0 +1,9 @@
1 # How is my forge? (#12183)
2
3 One stack of what needs the owner: `needsStack` (`src/actions/needs.ts`) is the asks' rows plus the forge's own needs (a site admin's), most consequential first, from `askStackFor` (`src/web/studio/asks.ts`). The home page's count, `/asks` and the MCP `forge` tool read that stack. `forge` is found with `find_tool`; it is not in the default tool list, so `tools/list` does not grow.
4
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
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.
8
9 `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 added +174
@@ -0,0 +1,174 @@
1 /**
2 * The one stack of what needs the owner (#12183): the home page's needs-you
3 * count, `/asks`, the terminal and the MCP `forge` tool all read `needsStack`,
4 * which is the asks' own rows (`askLinesFor`) plus the forge's own needs,
5 * each with a recommended choice derived from state — never a default.
6 * Applying "recommended" goes through the asks' bulk actions (`asks_bulk.ts`),
7 * so the preview, the per-row access check, the receipt and the undo are theirs.
8 */
9 import { applyAsksBulk, receiptOf } from "./asks_bulk.ts";
10 import { askStackFor, type askLinesFor } from "../web/studio/asks.ts";
11 import { needLine } from "./health.ts";
12 import { forgeHealth } from "../kernel/guard.ts";
13 import type { Chips } from "../core/feed_chips.ts";
14 import type { User } from "../core/users.ts";
15 import type { Ctx } from "../http/context.ts";
16
17 type Home = Pick<Ctx, "app">;
18
19 export interface Recommended {
20 /** The choice, in the words `choices` use; "Ask the owner" when the forge cannot tell. */
21 readonly choice: string;
22 readonly reason: string;
23 /** What undoing it takes: "undo" (a receipt), "revert pull", or "none". */
24 readonly undo: "receipt" | "a revert pull" | "none";
25 /** Whether it is taken without a person's word: reversible, and already allowed. */
26 readonly safe: boolean;
27 /** For an item not safe: the question to put to the owner. */
28 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";
31 }
32
33 export interface Need {
34 readonly id: string;
35 readonly sentence: string;
36 readonly why: string;
37 readonly waits: string;
38 readonly choices: readonly string[];
39 readonly recommended: Recommended;
40 readonly link: string;
41 /** The asks' ids the item holds: what a bulk action takes. */
42 readonly items: readonly string[];
43 }
44
45 type Row = ReturnType<typeof askLinesFor>[number];
46
47 const askOwner = (question: string, reason: string): Recommended => ({
48 choice: "Ask the owner",
49 reason,
50 undo: "none",
51 safe: false,
52 ask: question,
53 });
54
55 /** What to do about one row, from its state: its kind, what it waits for, the crew's advice. */
56 export function recommend(row: Row): Recommended {
57 const about = row.line;
58 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");
60 const advice = row.recommendation;
61 const conflicts = row.waitingFor.word === "conflicts with main";
62 if (row.numberOf === "pull" && !conflicts) {
63 // A merge has no undo but a revert pull, so no change is shipped on a recommendation:
64 // the owner's word is the approval.
65 if (row.kind === "look")
66 return askOwner(
67 `${about}: ship it? Green, no conflicts, nothing asks for changes.`,
68 "a merge is undone only by a revert pull, and green is not a person's approval",
69 );
70 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");
72 if (advice?.verdict === "close" && (row.kind === "waiting" || row.kind === "handover" || row.kind === "stopped"))
73 return {
74 choice: "Close it",
75 reason: `the crew advises closing: ${advice.reason || "no reason given"}`,
76 undo: "receipt",
77 safe: true,
78 action: "close",
79 };
80 }
81 if (conflicts) return askOwner(`${about}: it conflicts with main; should its author rebase it?`, "it cannot land until its conflicts are gone");
82 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");
84 if (row.kind === "paused")
85 return askOwner(`${about}: raise today's ceiling?`, "it spends money above what is set");
86 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");
89 }
90
91 /** The stack: the forge's own needs first (an admin's), then the asks, most consequential first. */
92 export function needsStack(ctx: Home, user: User, chips?: Chips): Need[] {
93 const stack = askStackFor(ctx, user, chips);
94 const forge = stack.forge.map(
95 (d, i): Need => ({
96 id: `forge:${d.cause || i}`,
97 sentence: needLine(d),
98 why: "the forge itself is degraded",
99 waits: "it stays that way until someone looks",
100 choices: ["Open it"],
101 recommended: askOwner(`${d.sentence} Look into it?`, "the forge's own health is the owner's to act on"),
102 link: d.href,
103 items: [],
104 }),
105 );
106 const rows = stack.rows.map(
107 (row): Need => ({
108 id: row.id,
109 sentence: row.line,
110 why: row.consequence.why,
111 waits: row.words.ifNothing,
112 choices: row.words.options,
113 recommended: recommend(row),
114 link: row.link,
115 items: row.items,
116 }),
117 );
118 return [...forge, ...rows];
119 }
120
121 /** One or two sentences: serving or not, the crew, what is unusual. */
122 export function forgeSays(stack: readonly Need[], running: number): string {
123 const serving = forgeHealth().ok ? "The forge is serving" : "The forge is not serving normally";
124 const crew = running === 0 ? "the crew is idle" : `the crew has ${running} ${running === 1 ? "run" : "runs"} going`;
125 const safe = stack.filter((n) => n.recommended.safe).length;
126 const unusual =
127 stack.length === 0
128 ? "nothing needs you."
129 : `${stack.length} ${stack.length === 1 ? "thing needs" : "things need"} you, ${safe} with a recommendation safe to take now.`;
130 return `${serving}, ${crew}; ${unusual}`;
131 }
132
133 export interface Applied {
134 readonly dryRun: boolean;
135 /** What was (or would be) done, one row each. */
136 readonly plan: { id: string; choice: string; reason: string; undo: Recommended["undo"] }[];
137 /** Left for the owner, each with the question to ask. */
138 readonly left: { id: string; sentence: string; ask: string }[];
139 readonly receipts: { operation: string; receipt: string; undo?: string }[];
140 readonly refused: { item: string; reason: string }[];
141 }
142
143 /** Apply the recommended choice of every safe item; dry unless `dryRun` is false. */
144 export async function applyRecommended(ctx: Home, user: User, dryRun: boolean, only?: string): Promise<Applied> {
145 const stack = needsStack(ctx, user).filter((n) => only === undefined || n.id === only);
146 const safe = stack.filter((n) => n.recommended.safe && n.recommended.action !== undefined);
147 const out: Applied = {
148 dryRun,
149 plan: safe.map((n) => ({ id: n.id, choice: n.recommended.choice, reason: n.recommended.reason, undo: n.recommended.undo })),
150 left: stack
151 .filter((n) => !safe.includes(n))
152 .map((n) => ({ id: n.id, sentence: n.sentence, ask: n.recommended.ask ?? n.sentence })),
153 receipts: [],
154 refused: [],
155 };
156 if (dryRun) return out;
157 for (const action of ["close"] as const) {
158 const items = safe.filter((n) => n.recommended.action === action).flatMap((n) => [...n.items]);
159 if (items.length === 0) continue;
160 const reason = "Closed by the recommended choice: the crew advised it.";
161 const done = await applyAsksBulk(ctx.app, user, action, items, reason);
162 if (!done.ok) {
163 out.refused.push(...items.map((item) => ({ item, reason: done.error.message })));
164 continue;
165 }
166 out.refused.push(...done.value.skipped);
167 out.receipts.push({
168 operation: done.value.id,
169 receipt: receiptOf(done.value),
170 ...(done.value.reversible ? { undo: `asks_act {action:"undo", operation:"${done.value.id}", dryRun:false}` } : {}),
171 });
172 }
173 return out;
174 }
src/http/mcp/tools/forge.ts added +34
@@ -0,0 +1,34 @@
1 import { crewNow } from "../../../actions/crew_now.ts";
2 import { applyRecommended, forgeSays, needsStack } from "../../../actions/needs.ts";
3 import { good, bad, type Tool } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "forge",
7 description: "How is my forge? What needs you, recommended; `apply` takes them.",
8 inputSchema: {
9 type: "object",
10 properties: {
11 apply: { type: "string" },
12 dryRun: { type: "boolean" },
13 },
14 },
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`)",
17 writes: true,
18 dryByDefault: true,
19 readsWithout: "apply",
20 async run(ctx, user, args) {
21 const apply = args["apply"];
22 if (apply !== undefined) {
23 if (typeof apply !== "string" || apply === "") return bad("invalid", "apply is \"recommended\" or a need's id");
24 return good(await applyRecommended(ctx, user, args["dryRun"] !== false, apply === "recommended" ? undefined : apply));
25 }
26 const needs = needsStack(ctx, user);
27 const running = crewNow(ctx, user).filter((r) => r.run !== null).length;
28 return good({
29 says: forgeSays(needs, running),
30 needs,
31 recommended: needs.some((n) => n.recommended.safe) ? 'forge {apply:"recommended", dryRun:false}' : null,
32 });
33 },
34 };
src/http/mcp/tools/index.ts +1
@@ -73,6 +73,7 @@ export { tool as flags } from "./flags.ts";
7373 export { tool as flow } from "./flow.ts";
7474 export { tool as follow_mode } from "./follow_mode.ts";
7575 export { tool as following } from "./following.ts";
76 export { tool as forge } from "./forge.ts";
7677 export { tool as forge_proposals } from "./forge_proposals.ts";
7778 export { tool as forge_view } from "./forge_view.ts";
7879 export { tool as forges } from "./forges.ts";
src/tui/forge.ts +2
@@ -18,6 +18,7 @@ import { sourceFor } from "../view/source.ts";
1818 import { fleetCommand } from "./screens/fleet.ts";
1919 import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
21 import { needsCommands } from "./screens/needs.ts";
2122 import { sendChangeCommand } from "./screens/send_change.ts";
2223 import { interruptedOutCommand } from "./screens/interrupted_out.ts";
2324 import { getForgeCommand } from "./screens/getforge.ts";
@@ -257,6 +258,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
257258 hint: "Atom address",
258259 run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"),
259260 },
261 ...needsCommands(session, ctx),
260262 packsCommand(session, ctx),
261263 sendChangeCommand(ctx),
262264 interruptedOutCommand(session, ctx),
src/tui/screens/needs.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * "How is my forge?" in the terminal (#12183): the stack the home page and the
3 * MCP `forge` tool show (`needsStack`), one row per need with the recommended
4 * choice under it. Anyone signed in sees their own stack, gated by the same
5 * reads as the page. `a` takes the recommended choice of the need under the
6 * cursor through `applyRecommended`, the asks' bulk action with its per-row
7 * access check and receipt; only a person's, and only a close the crew
8 * advised: a ship is a merge with no undo, so it stays a question. Any event
9 * on the bus redraws the stack, so what the page does shows here and back.
10 */
11
12 import { crewNow } from "../../actions/crew_now.ts";
13 import { applyRecommended, forgeSays, needsStack, type Need } from "../../actions/needs.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 { style } from "../term/style.ts";
18 import { truncate } from "../term/width.ts";
19 import { fill, len, split } from "../ui/layout.ts";
20 import { ListState, drawList, live, runAsync, type ListRow } from "./common.ts";
21 import type { Command } from "../app/palette.ts";
22
23 /** The stack this session's user may see; none when nobody is signed in. */
24 export function stackFor(f: Session): Need[] {
25 return f.user === undefined ? [] : needsStack({ app: f.app }, f.user);
26 }
27
28 /** How many of the crew's runs this session's user may see going: what the MCP tool counts. */
29 export function runningFor(f: Session): number {
30 return f.user === undefined ? 0 : crewNow({ app: f.app }, f.user).filter((r) => r.run !== null).length;
31 }
32
33 /** Why `a` does nothing on a need, in words; empty when it applies. */
34 export function whyNot(f: Session, need: Need | undefined): string {
35 if (need === undefined) return "Nothing needs you.";
36 if (f.user === undefined || f.user.kind !== "user") return "A person applies a recommendation, never an agent.";
37 if (!need.recommended.safe) return need.recommended.ask ?? "That one is for the owner to decide.";
38 return "";
39 }
40
41 export function needsScreen(f: Session, ctx: Ctx): Screen {
42 const state = new ListState();
43 let stack = stackFor(f);
44 let running = runningFor(f);
45 const reload = (): void => {
46 stack = stackFor(f);
47 running = runningFor(f);
48 ctx.invalidate();
49 };
50 return {
51 title: "How is my forge?",
52 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "take the recommended choice")],
53 async load(signal) {
54 live(f, signal, {}, reload);
55 },
56 render(area, s) {
57 const c = ctx.theme.c;
58 const [head, list] = split(area, "v", [len(1), fill(1)]);
59 const says = f.user === undefined ? "Sign in to see what needs you." : forgeSays(stack, running);
60 s.text(head!.x, head!.y, truncate(says, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
61 const rows: ListRow[] = stack.map((n) => ({
62 title: n.sentence,
63 tone: n.recommended.safe ? "success" : "attn",
64 note: `${n.recommended.choice}: ${n.recommended.reason}`,
65 ...(n.recommended.safe ? { meta: "a applies" } : {}),
66 }));
67 drawList(s, list!, c, state, rows);
68 },
69 key(e) {
70 if (state.key(e, stack.length)) return true;
71 if (e.ctrl || e.alt || e.key !== "a") return false;
72 const at = stack[state.index];
73 const why = whyNot(f, at);
74 if (why !== "" || at === undefined || f.user === undefined) {
75 ctx.toast(why, "danger");
76 return true;
77 }
78 runAsync(ctx, async () => {
79 const done = await applyRecommended({ app: f.app }, f.user!, false, at.id);
80 const left = done.refused[0];
81 ctx.toast(left ? `Refused: ${left.reason}` : done.receipts[0]?.receipt ?? "Nothing to do.", left ? "danger" : undefined);
82 reload();
83 });
84 return true;
85 },
86 };
87 }
88
89 /** Offered to anyone signed in: the stack is their own. */
90 export const needsCommands = (f: Session, ctx: Ctx): Command[] =>
91 f.user === undefined
92 ? []
93 : [
94 {
95 id: "needs",
96 title: "How is my forge?",
97 hint: "what needs you, with a recommendation each; a takes a safe one",
98 run: () => ctx.push(needsScreen(f, ctx)),
99 },
100 ];
src/web/studio/asks.ts +20 −13
@@ -1185,24 +1185,30 @@ export function askLinesFor(
11851185 * health answer (`/api/v1/health`) as one ask. Anyone else gets none — the
11861186 * forge's standing is the admin's to read and nobody else's to know about.
11871187 */
1188 export function forgeNeedsFor(ctx: Ctx, user: User | undefined): Degraded[] {
1188 export function forgeNeedsFor(ctx: Pick<Ctx, "app">, user: User | undefined): Degraded[] {
11891189 if (!user?.isAdmin) return [];
11901190 return forgeVitals(ctx.app).degraded;
11911191 }
11921192
11931193 /**
1194 * How many things ask the viewer: the one number the studio header and the
1195 * asks page both show. It counts the rows the cards are made into
1196 * (`askRows` over `waitingOn`) plus the forge's own needs, so the two
1197 * cannot disagree.
1194 * The one stack of what asks the viewer: the forge's own needs (an admin's)
1195 * and the asks' rows. `needsStack` (`actions/needs.ts`) words it, `askCountFor`
1196 * counts it and `asksAnswer` returns it, so they cannot disagree.
11981197 */
1199 export function askCountFor(ctx: Ctx, user: User | undefined): number {
1198 export function askStackFor(
1199 ctx: Pick<Ctx, "app">,
1200 user: User,
1201 chips?: Chips,
1202 onHidden?: (rows: number) => void,
1203 ) {
1204 return { forge: forgeNeedsFor(ctx, user), rows: askLinesFor(ctx.app.db, user, chips, onHidden) };
1205 }
1206
1207 /** How many things ask the viewer: the stack's length, the number the header and asks page show. */
1208 export function askCountFor(ctx: Pick<Ctx, "app">, user: User | undefined): number {
12001209 if (!user) return 0;
1201 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200, withoutArchived: true });
1202 return (
1203 askRows(waitingOn(ctx.app.db, user, repos)).length +
1204 forgeNeedsFor(ctx, user).length
1205 );
1210 const stack = askStackFor(ctx, user);
1211 return stack.forge.length + stack.rows.length;
12061212 }
12071213
12081214 /** The single sentence the asks page leads with, and the dashboard links with. */
@@ -1221,12 +1227,13 @@ export function askSentence(count: number): string {
12211227 export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) {
12221228 const set = reviewSet(ctx, user);
12231229 let hidden = 0;
1224 const rows = askLinesFor(ctx.app.db, user, chips, (n) => (hidden = n));
1230 const stack = askStackFor(ctx, user, chips, (n) => (hidden = n));
1231 const rows = stack.rows;
12251232 return {
12261233 // The very figures Home's line and /review show: `count` is live asks plus the forge's needs; `stale` are older than 30 days.
12271234 waiting: { count: set.count, stale: set.stale.length },
12281235 rows,
1229 forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)),
1236 forge: stack.forge.map((d) => needLine(d)),
12301237 measure: { days: ASKS_WINDOW_DAYS, ...askMeasureWeek(ctx.app.db, user.id) },
12311238 ...(hasAskFilters(askFiltersOf(ctx.app.db, user.id)) ? { filters: { hidden, line: hiddenLine(hidden) } } : {}),
12321239 };
test/conversation-confirm.test.ts +1 −1
@@ -123,7 +123,7 @@ describe("the one list of what asks first", () => {
123123 "access", "account_requests", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
124124 "asks", "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "economics", "edit", "email_verify", "estimate", "feed_chips", "feed_dismiss", "feed_like", "flag_set", "flag_winner",
126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
126 "follow_mode", "forge", "fork", "front_page", "hold", "issue_teams", "keep_plan", "knobs", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "week", "notify", "open_pull", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
128128 "repo", "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
129129 "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file",
test/forge-needs-one-stack.test.ts added +268
@@ -0,0 +1,268 @@
1 /**
2 * One answer to "how is my forge?" (#12183): MCP, home, asks and terminal
3 * read one stack; apply never merges on a recommendation.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { addToCrew, createAgent } from "../src/actions/crew.ts";
11 import { addComment, openIssue } from "../src/actions/issues.ts";
12 import { openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { getPull } from "../src/core/pulls.ts";
15 import { addComment as commentAs } from "../src/core/issues.ts";
16 import { id as newId } from "../src/kernel/id.ts";
17 import { applyAsksBulk } from "../src/actions/asks_bulk.ts";
18 import type { ForgeEvent } from "../src/core/events.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
21 import { createToken } from "../src/core/tokens.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { BREAKER_NOTE } from "../src/crew/breaker.ts";
24 import type { Ctx } from "../src/http/context.ts";
25 import { serve, type Servers } from "../src/http/server.ts";
26 import { askAnchor, askCountFor, asksAnswer } from "../src/web/studio/asks.ts";
27 import { forgeSays, needsStack, type Need } from "../src/actions/needs.ts";
28 import { crewNow } from "../src/actions/crew_now.ts";
29 import { createApp as createAppRecord } from "../src/core/apps.ts";
30 import { needsCommands, needsScreen, runningFor } from "../src/tui/screens/needs.ts";
31 import type { Ctx as TuiCtx } from "../src/tui/app/app.ts";
32 import type { Session } from "../src/tui/session.ts";
33 import { needsYouSection } from "../src/web/studio/asks_page.ts";
34 import { mcpTool } from "./helpers/mcp.ts";
35 import { quietLogs } from "./helpers/globals.ts";
36
37 const press = (key: string) => ({ type: "key" as const, key, ctrl: false, alt: false, shift: false });
38
39 quietLogs();
40
41 let dataDir = "";
42 let app: App;
43 let servers: Servers;
44 let alice: User;
45 let token = "";
46 let repo: Repo;
47 let pullId = "";
48 let pullNumber = 0;
49 let issueNumber = 0;
50
51 const ctx = (): Ctx => ({
52 app,
53 request: new Request("http://x/"),
54 url: new URL("http://x/"),
55 params: {},
56 auth: { user: alice, scope: "rw", via: "session" },
57 theme: undefined,
58 });
59 interface Answer {
60 says: string;
61 needs: Need[];
62 recommended: string | null;
63 dryRun: boolean;
64 plan: { id: string }[];
65 left: { id: string; ask: string }[];
66 receipts: unknown[];
67 }
68 const forge = async (args: unknown = {}): Promise<Answer> => (await mcpTool(servers.url, token, "forge", args)).value as Answer;
69 const session = (user: User | undefined) => ({ user, db: app.db, app }) as unknown as Session;
70 const tracked: Promise<unknown>[] = [];
71 const tui = (toasts: string[]) =>
72 ({
73 toast: (t: string) => toasts.push(t),
74 invalidate() {},
75 push() {},
76 track: (p: Promise<unknown>) => (tracked.push(p), p),
77 }) as unknown as TuiCtx;
78
79 /** A bot's pull no person has touched for two days, the crew advising to close it: a safe need. */
80 const staleAdvisedClose = (title: string): { id: string; number: number } => {
81 const bot = getUserByName(app.db, "bot")!;
82 const issueId = newId();
83 const number = app.db
84 .query<{ issue_seq: number }, [string]>("UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq")
85 .get(repo.id)!.issue_seq;
86 const ago = new Date(Date.now() - 50 * 3_600_000).toISOString();
87 app.db
88 .query(
89 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at)
90 VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`,
91 )
92 .run(issueId, repo.id, number, title, bot.id, ago);
93 app.db
94 .query(
95 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id)
96 VALUES (?1, 'main', ?2, ?3, ?3, ?4)`,
97 )
98 .run(issueId, `stale/${number}`, "0".repeat(40), repo.id);
99 commentAs(app.db, { issueId, authorId: bot.id, body: "Recommendation: close — superseded by the green work." });
100 return { id: issueId, number };
101 };
102 const stateOf = (number: number): string => getPull(app.db, repo.id, number)!.state;
103
104 beforeAll(async () => {
105 dataDir = await mkdtemp(join(tmpdir(), "forge-needs-"));
106 app = createApp({ dataDir, port: 0 });
107 servers = await serve(app);
108 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-needs-passphrase", adminIfFirst: true });
109 if (!a.ok) throw new Error(a.error);
110 alice = a.value;
111 token = createToken(app.db, alice.id, "rw", "rw").token;
112 await createAgent(app, alice, { username: "bot" });
113 const made = await createRepo(app, alice, { name: "open", visibility: "public" });
114 if (!made.ok) throw new Error(String(made.error));
115 repo = made.value;
116 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# open\n" }] });
117 addToCrew(app, alice, repo, "bot");
118 await commitToBranch(app, alice, repo, { branch: "bot/1", base: "main", message: "Green work", files: [{ path: "F.md", content: "1\n" }] });
119 const opened = await openPull(app, getUserByName(app.db, "bot")!, repo, { title: "Green work", body: "", baseBranch: "main", headBranch: "bot/1" });
120 if (!opened.ok) throw new Error(String(opened.error));
121 pullId = opened.value.id;
122 pullNumber = opened.value.number;
123 const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: pullId, env: `pull-${pullNumber}`, commitOid: opened.value.headOid, reason: "pull" });
124 claimRun(app.db, run.id);
125 finishRun(app.db, run.id, { state: "ok", summary: "ok" });
126 const issue = openIssue(app, alice, repo, { title: "Stuck thing" });
127 if (!issue.ok) throw new Error(String(issue.error));
128 issueNumber = issue.value.number;
129 addComment(app, alice, repo, issue.value, BREAKER_NOTE);
130 }, 30_000);
131
132 afterAll(async () => {
133 await servers.stop();
134 await rm(dataDir, { recursive: true, force: true });
135 });
136
137 test("`forge` answers with the stack the home, asks and count show, same ids, same order; it is found, not listed first", async () => {
138 const answer = await forge();
139 const ids = answer.needs.map((n) => n.id);
140 expect(ids).toEqual(needsStack(ctx(), alice).map((n) => n.id));
141 expect(ids).toEqual([...asksAnswer(ctx(), alice).rows.map((r) => r.id)]);
142 expect(ids.length).toBe(2);
143 const home = needsYouSection(ctx(), alice).body.text;
144 const at = ids.map((id) => home.indexOf(askAnchor(id)));
145 expect(at.every((i) => i >= 0)).toBe(true);
146 expect([...at].sort((x, y) => x - y)).toEqual(at);
147 expect(askCountFor(ctx(), alice)).toBe(ids.length);
148 expect(home).toContain(`${ids.length} things need you`);
149 expect(typeof answer.says).toBe("string");
150 const found = (await mcpTool(servers.url, token, "find_tool", { query: "forge needs" })).value as { tools: { name: string }[] };
151 expect(found.tools.map((t) => t.name)).toContain("forge");
152 });
153
154 test("a green agent pull is a question for the owner, never safe to ship", async () => {
155 const { needs } = await forge();
156 const pull = needs.find((n) => n.link.endsWith(`/pulls/${pullNumber}`))!;
157 const parked = needs.find((n) => n.link.endsWith(`/issues/${issueNumber}`))!;
158 expect(pull.recommended).toMatchObject({ choice: "Ask the owner", safe: false });
159 expect(pull.recommended.ask).toContain("ship it?");
160 expect(parked.recommended).toMatchObject({ choice: "Ask the owner", safe: false });
161 expect(parked.recommended.ask).toContain(`#${issueNumber}`);
162 });
163
164 test("apply recommended does not merge an unapproved agent pull, dry or not", async () => {
165 const dry = await forge({ apply: "recommended" });
166 expect(dry.dryRun).toBe(true);
167 expect(dry.plan).toHaveLength(0);
168 expect(dry.left).toHaveLength(2);
169 const done = await forge({ apply: "recommended", dryRun: false });
170 expect(done.receipts).toHaveLength(0);
171 expect(getPull(app.db, repo.id, pullNumber)!.mergedAt).toBeNull();
172 expect(done.left.map((l) => l.ask).join(" ")).toContain("ship it?");
173 expect((await forge()).needs).toHaveLength(2);
174 });
175
176 test("the terminal reads the same stack and refuses what the page refuses", () => {
177 expect(needsCommands(session(undefined), tui([]))).toEqual([]);
178 expect(needsCommands(session(alice), tui([]))).toHaveLength(1);
179 const toasts: string[] = [];
180 const screen = needsScreen(session(alice), tui(toasts));
181 const rows = needsStack(ctx(), alice);
182 for (let i = 0; i < rows.findIndex((n) => n.link.endsWith(`/pulls/${pullNumber}`)); i++) screen.key?.(press("j"));
183 expect(screen.key?.(press("a"))).toBe(true);
184 expect(toasts.join(" ")).toContain("ship it?");
185 expect(getPull(app.db, repo.id, pullNumber)!.mergedAt).toBeNull();
186 const bot = getUserByName(app.db, "bot")!;
187 const botToasts: string[] = [];
188 expect(needsScreen(session(bot), tui(botToasts)).key?.(press("a"))).toBe(true);
189 expect(botToasts.join(" ")).toMatch(/never an agent|ship it|Nothing needs/);
190 });
191
192 test("a close the crew advised is applied, leaves a receipt, reaches the bus and is undone", async () => {
193 const stale = staleAdvisedClose("Superseded work");
194 const need = needsStack(ctx(), alice).find((n) => n.link.endsWith(`/pulls/${stale.number}`))!;
195 expect(need.recommended).toMatchObject({ choice: "Close it", safe: true, action: "close", undo: "receipt" });
196 const dry = await forge({ apply: "recommended" });
197 expect(dry.plan.map((p) => p.id)).toEqual([need.id]);
198 expect(stateOf(stale.number)).toBe("open");
199 const events: ForgeEvent[] = [];
200 const off = app.bus.subscribe((e) => void events.push(e));
201 const done = (await mcpTool(servers.url, token, "forge", { apply: "recommended", dryRun: false })).value as {
202 receipts: { operation: string; receipt: string; undo?: string }[];
203 refused: unknown[];
204 };
205 off();
206 expect(done.refused).toEqual([]);
207 expect(done.receipts).toHaveLength(1);
208 expect(done.receipts[0]!.undo).toContain(done.receipts[0]!.operation);
209 expect(stateOf(stale.number)).toBe("closed");
210 expect(getPull(app.db, repo.id, pullNumber)!.mergedAt).toBeNull();
211 expect(events.some((e) => e.kind.endsWith("closed") && e.repoId === repo.id)).toBe(true);
212 expect((await forge()).needs.some((n) => n.id === need.id)).toBe(false);
213 const undo = await mcpTool(servers.url, token, "asks_act", { action: "undo", operation: done.receipts[0]!.operation, dryRun: false });
214 expect(undo.value).toBeDefined();
215 expect(stateOf(stale.number)).toBe("open");
216 });
217
218 test("someone without the right to close it is refused, by the stack and per row", async () => {
219 const stale = staleAdvisedClose("Not yours to close");
220 const need = needsStack(ctx(), alice).find((n) => n.link.endsWith(`/pulls/${stale.number}`))!;
221 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-carol-passphrase" });
222 if (!made.ok) throw new Error(made.error);
223 const carol = made.value;
224 const carolToken = createToken(app.db, carol.id, "rw", "rw").token;
225 const theirs = (await mcpTool(servers.url, carolToken, "forge", { apply: need.id, dryRun: false })).value as Answer;
226 expect(theirs.plan).toEqual([]);
227 expect(theirs.receipts).toEqual([]);
228 const direct = await applyAsksBulk(app, carol, "close", [...need.items], "not mine");
229 if (direct.ok) expect(direct.value.skipped.map((s) => s.item)).toEqual([...need.items]);
230 expect(stateOf(stale.number)).toBe("open");
231 const toasts: string[] = [];
232 expect(needsScreen(session(undefined), tui(toasts)).key?.(press("a"))).toBe(true);
233 expect(toasts.join(" ")).toContain("Nothing needs you");
234 expect(stateOf(stale.number)).toBe("open");
235 });
236
237 test("a in the terminal closes the advised one, with its receipt, and the web sees it on the bus", async () => {
238 const stale = staleAdvisedClose("Close from the terminal");
239 const toasts: string[] = [];
240 const screen = needsScreen(session(alice), tui(toasts));
241 const at = needsStack(ctx(), alice).findIndex((n) => n.link.endsWith(`/pulls/${stale.number}`));
242 expect(at).toBeGreaterThanOrEqual(0);
243 for (let i = 0; i < at; i++) screen.key?.(press("j"));
244 const events: ForgeEvent[] = [];
245 const off = app.bus.subscribe((e) => void events.push(e));
246 tracked.length = 0;
247 expect(screen.key?.(press("a"))).toBe(true);
248 await Promise.all(tracked);
249 off();
250 expect(stateOf(stale.number)).toBe("closed");
251 expect(toasts.join(" ")).not.toContain("Refused");
252 expect(toasts.length).toBe(1);
253 expect(events.some((e) => e.kind.endsWith("closed") && e.repoId === repo.id)).toBe(true);
254 expect(needsStack(ctx(), alice).some((n) => n.link.endsWith(`/pulls/${stale.number}`))).toBe(false);
255 });
256
257 test("the terminal's sentence counts the crew's runs as the MCP tool does, not a fixed idle", () => {
258 createAppRecord(app.db, { repoId: repo.id, slug: "open", access: "public" });
259 expect(runningFor(session(alice))).toBe(crewNow({ app }, alice).filter((r) => r.run !== null).length);
260 const before = runningFor(session(alice));
261 const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: pullId, env: "", reason: "work" });
262 claimRun(app.db, run.id);
263 expect(runningFor(session(alice))).toBe(before + 1);
264 expect(forgeSays([], runningFor(session(alice)))).toContain(`the crew has ${before + 1}`);
265 finishRun(app.db, run.id, { state: "ok", summary: "ok" });
266 expect(runningFor(session(alice))).toBe(before);
267 expect(runningFor(session(undefined))).toBe(0);
268 });
test/mcp.test.ts +1
@@ -300,6 +300,7 @@ describe("the forge over MCP", () => {
300300 "flag_set",
301301 "economics",
302302 "follow_mode",
303 "forge",
303304 "fork",
304305 "forks",
305306 "fork_tree",
test/palette-roster.test.ts +3
@@ -48,6 +48,7 @@ test("a site-admin person is offered the whole roster", () => {
4848 ["home", "Home", "your repositories"],
4949 ["explore", "Explore", "on the web"],
5050 ["explore-feed", "Explore feed", "Atom address"],
51 ["needs", "How is my forge?", "what needs you, with a recommendation each; a takes a safe one"],
5152 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5253 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
5354 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
@@ -213,6 +214,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
213214 ["home", "Home", "your repositories"],
214215 ["explore", "Explore", "on the web"],
215216 ["explore-feed", "Explore feed", "Atom address"],
217 ["needs", "How is my forge?", "what needs you, with a recommendation each; a takes a safe one"],
216218 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
217219 ["send-change", "Send a change", "fork, patch, pull: no forge of your own"],
218220 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
@@ -303,6 +305,7 @@ test("a member person is offered the signed-in roster without the admin entries"
303305 ["home", "Home", "your repositories"],
304306 ["explore", "Explore", "on the web"],
305307 ["explore-feed", "Explore feed", "Atom address"],
308 ["needs", "How is my forge?", "what needs you, with a recommendation each; a takes a safe one"],
306309 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
307310 ["send-change", "Send a change", "fork, patch, pull: no forge of your own"],
308311 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs