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

`flow` brings a drifting pull up to date with one action, once the drift list is on main #10959

Merged opened this

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

Merged

scout/forge:scout/10909-flow-brings-a-drifting-pull-up-to-date-w → main 3 commits fork

  • Merged. scout/10909-flow-brings-a-drifting-pull-up-to-date-w was merged into main as 69a60da.

Review

Sign in to review this change.
3 files changed +146 −6
docs/MCP.md +4 −1
@@ -106,7 +106,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
106106 | `flag_set` | — | Set or clear a flag (admin) | 209 |
107107 | `flag_winner` | — | An A/B flag winner becomes default | 206 |
108108 | `flags` | — | Declared flags: why, default, owner issue, live value | 171 |
109 | `flow` | `bugs`, `drift` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 488 |
109 | `flow` | `bugs`, `drift` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 473 |
110110 | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 |
111111 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
112112 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
@@ -541,3 +541,6 @@ answers
541541 open pulls over 25 landings or 6 h behind main, furthest first, from
542542 `src/flow/alarm_drift.ts`. Each is brought up to date by
543543 `POST /{repo}/pulls/{number}/update`, the button beside it on /boards.
544 `flow` with `{ kind: "drift", repo, pull, action: "update" }` does the same as a writer of
545 the repository: `{ updating, by }`, or the route's refusals in its words (only a writer;
546 closed, no agent, or tries used). `test/mcp-flow-drift-update.test.ts`.
src/http/mcp/tools/flow.ts +20 −5
@@ -2,6 +2,7 @@ import { noteRead, telemetryReader } from "../../../actions/telemetry.ts";
22 import { getIssue } from "../../../core/issues.ts";
33 import { getPull, pullDir, pullMergeState } from "../../../core/pulls.ts";
44 import { canWrite } from "../../../core/repos.ts";
5 import { bringUpToDate } from "../../../crew/autonomy.ts";
56 import { mayView } from "../../../core/sealed.ts";
67 import { whyIssue, whyPull, liveHead, whyRepo, type Why } from "../../../flow/why.ts";
78 import { countRead } from "../../../system/telemetry_reads.ts";
@@ -28,7 +29,7 @@ const said = (subject: string, why: Why) =>
2829
2930 export const tool: Tool = {
3031 name: "flow",
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",
32 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`, or with `{ kind: drift, repo, pull, action: update }` (a repository writer) the same: `{ updating, by }` names the agent, or a refusal in the route's words; 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",
3233 description:
3334 "Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck.",
3435 inputSchema: {
@@ -37,18 +38,32 @@ export const tool: Tool = {
3738 kind: { type: "string", enum: ["bugs", "drift"] },
3839 q: { type: "string", maxLength: 500 },
3940 sort: { type: "string" },
40 action: { type: "object" },
41 action: {},
4142 apply: { type: "boolean" },
4243 repo: REPO_ARG,
4344 issue: { type: "integer", minimum: 1 },
4445 pull: { type: "integer", minimum: 1 },
4546 },
4647 },
47 // Only `apply: true` changes anything; without `apply`, or with `apply: false`, a call reads or plans.
48 // Only `apply: true` or the drift `update` changes anything; any other call reads or plans.
4849 writes: true,
49 readsWithout: "apply",
50 readsWhen: (args) => args["apply"] === false,
50 readsWhen: (args) => args["apply"] !== true && args["action"] !== "update",
5151 async run(ctx, user, args) {
52 if (args["action"] === "update") {
53 // As `POST /{repo}/pulls/{number}/update`: a writer asks the pull's agent to bring its branch up to date.
54 if (args["kind"] !== "drift") return bad("invalid", "action: update goes with kind: drift, repo and pull");
55 const repo = repoArg(ctx, user, args);
56 if (isAnswer(repo)) return repo;
57 const n = number(args["pull"]);
58 if (n === undefined) return bad("invalid", "pull is the number of the drifting pull");
59 const pull = getPull(ctx.app.db, repo.id, n);
60 if (pull === undefined || !mayView(ctx.app.db, repo, pull, user)) return bad("no_such_pull", `no pull #${n}`);
61 if (!canWrite(ctx.app.db, repo, user)) return bad("forbidden", "Only someone who can write here brings a pull up to date.");
62 const who = pull.state === "open" && pull.mergedAt === null ? bringUpToDate(ctx.app, repo, pull, []) : undefined;
63 if (who === undefined)
64 return bad("refused", "The crew could not take it now (closed, no agent, or it has already tried its limit); say what to do on the pull.");
65 return good({ updating: `${repo.ownerName}/${repo.name}!${n}`, by: who });
66 }
5267 if (args["action"] !== undefined || args["apply"] !== undefined) {
5368 if (typeof args["q"] !== "string") return bad("invalid", "an action needs q, the fleet query line it acts on");
5469 if (args["apply"] !== undefined && typeof args["apply"] !== "boolean") return bad("invalid", "apply is true or false");
test/mcp-flow-drift-update.test.ts added +122
@@ -0,0 +1,122 @@
1 /** `flow` with { kind: drift, repo, pull, action: update } (#10909): what POST …/update does, as a writer, with its refusals. */
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 { setIssueState } from "../src/core/issues.ts";
11 import { activeRunFor } from "../src/core/runs.ts";
12 import { createToken } from "../src/core/tokens.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 dir: string;
21 let app: App;
22 let servers: Servers;
23 let alice: User;
24 let repo: Repo;
25 let aliceTok: string;
26 let bobTok: string;
27 let roTok: string;
28 let id = 1;
29 let n = 0;
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-mcp-drift-update-"));
33 app = createApp({ dataDir: dir, port: 0 });
34 servers = await serve(app);
35 const mk = async (username: string) => {
36 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: username === "alice" });
37 if (!u.ok) throw new Error(String(u.error));
38 return u.value;
39 };
40 alice = await mk("alice");
41 const bob = await mk("bob");
42 aliceTok = createToken(app.db, alice.id, "rw", "rw").token;
43 roTok = createToken(app.db, alice.id, "ro", "ro").token;
44 bobTok = createToken(app.db, bob.id, "rw", "rw").token;
45 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
46 if (!made.ok) throw new Error(String(made.error));
47 repo = made.value;
48 await commitToBranch(app, alice, repo, { message: "base", files: [{ path: "a.txt", content: "a\n" }] });
49 }, 30_000);
50
51 afterAll(async () => {
52 servers.stop();
53 app.close();
54 await rm(dir, { recursive: true, force: true });
55 });
56
57 async function pullWith(agent: boolean) {
58 const branch = `b${++n}`;
59 const c = await commitToBranch(app, alice, repo, { branch, message: branch, files: [{ path: `${branch}.txt`, content: "x\n" }] });
60 if (!c.ok) throw new Error(JSON.stringify(c.error));
61 const opened = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch });
62 if (!opened.ok) throw new Error(String(opened.error));
63 if (agent) {
64 const a = await createAgent(app, alice, { username: `bot${n}` });
65 if (!a.ok) throw new Error(JSON.stringify(a.error));
66 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(a.value.user.id, opened.value.id);
67 }
68 return opened.value;
69 }
70
71 const call = async (token: string, args: unknown) => {
72 const res = await fetch(`${servers.url}/mcp`, {
73 method: "POST",
74 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
75 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "flow", arguments: args } }),
76 });
77 const body: any = await res.json();
78 const text: string = body.result?.content?.[0]?.text ?? JSON.stringify(body);
79 return body.result?.isError === true || body.error ? { error: text } : { value: JSON.parse(text) };
80 };
81 const act = (tok: string, pull: number) => call(tok, { kind: "drift", repo: "alice/notes", pull, action: "update" });
82
83 test("a writer's update names the agent and queues its run", async () => {
84 const p = await pullWith(true);
85 const r = await act(aliceTok, p.number);
86 expect(r.value.updating).toBe(`alice/notes!${p.number}`);
87 expect(r.value.by).toStartWith("bot");
88 expect(activeRunFor(app.db, "agent", p.id)).toBeDefined();
89 });
90
91 test("a closed pull is refused in the route's words, nothing queued", async () => {
92 const p = await pullWith(true);
93 setIssueState(app.db, p.id, "closed");
94 const r = await act(aliceTok, p.number);
95 expect(r.error).toContain("could not take it");
96 expect(activeRunFor(app.db, "agent", p.id)).toBeUndefined();
97 });
98
99 test("a pull with no agent is refused", async () => {
100 const p = await pullWith(false);
101 expect((await act(aliceTok, p.number)).error).toContain("could not take it");
102 });
103
104 test("a pull whose tries are used is refused", async () => {
105 const p = await pullWith(true);
106 for (let i = 0; i < 20; i++) {
107 app.db.run(
108 "INSERT INTO run (id, kind, issue_id, repo_id, state, reason, created_at) VALUES (?1, 'agent', ?2, ?4, 'failed', 'check_failed', ?3)",
109 [`r${n}-${i}`, p.id, new Date(Date.now() + 60_000).toISOString(), repo.id],
110 );
111 }
112 const r = await act(aliceTok, p.number);
113 expect(r.error).toContain("could not take it");
114 });
115
116 test("someone who cannot write, a read-only token, or a missing pull is refused", async () => {
117 const p = await pullWith(true);
118 expect((await act(bobTok, p.number)).error).toContain("Only someone who can write");
119 expect((await act(roTok, p.number)).error).toBeDefined();
120 expect((await act(aliceTok, 9999)).error).toContain("no pull");
121 expect(activeRunFor(app.db, "agent", p.id)).toBeUndefined();
122 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs