Public: anyone can read this; the crew can change it.
MCP reads the rows a fleet query line selects #10153
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10132-mcp-reads-the-rows-a-fleet-query-line-se → main 2 commits fork
3 files
changed +101
−3
docs/features/fleet.md +3 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Fleet rows |
| 2 | 2 | |
| 3 | Before a fleet operation acts, see the rows its query line selects. There are two doors, and both show the same rows. | |
| 3 | Before a fleet operation acts, see the rows its query line selects. There are four doors, and all show the same rows. | |
| 4 | 4 | |
| 5 | 5 | **On the web:** `GET /fleet?q=<query line>&sort=<column>` (`src/web/pages/fleet.ts`), linked from the fleet graph as "Rows". |
| 6 | 6 | |
| @@ -19,6 +19,8 @@ Before a fleet operation acts, see the rows its query line selects. There are tw | ||
| 19 | 19 | - `sort` is a column key, `-` leading to reverse; an unknown one is `invalid_sort` (422). |
| 20 | 20 | - A line that does not parse, an empty `q`, or a `q` over 500 characters is `invalid_query` (422) with the reason. |
| 21 | 21 | |
| 22 | **In MCP:** the `flow` tool with `{ q, sort? }` answers `{ kind, columns, items }`, the same rows `/fleet` shows, for the caller (public repositories only when the token's user cannot read more). A bad line, a bad `sort` or an empty `q` is an error with the reason, so an agent previews a line without applying anything. | |
| 23 | ||
| 22 | 24 | **The query line:** a kind, then `field:value` terms that must all hold. Both doors show the same grammar under the line: one sentence, then each kind with its fields (`GRAMMAR` and `fieldsOf` in `src/fleet/selection.ts`). |
| 23 | 25 | |
| 24 | 26 | - A value starting with `!` negates its term: `repo:!trevato/forge`. |
src/http/mcp/tools/flow.ts +20 −2
| @@ -5,6 +5,8 @@ import { canWrite } from "../../../core/repos.ts"; | ||
| 5 | 5 | import { mayView } from "../../../core/sealed.ts"; |
| 6 | 6 | import { whyIssue, whyPull, liveHead, whyRepo, type Why } from "../../../flow/why.ts"; |
| 7 | 7 | import { countRead } from "../../../system/telemetry_reads.ts"; |
| 8 | import { COLUMNS, readSort, sortRows } from "../../../fleet/columns.ts"; | |
| 9 | import { viewFleet } from "../../../fleet/rows.ts"; | |
| 8 | 10 | import { openBugs } from "../../../flow/bugs.ts"; |
| 9 | 11 | import { flowCached } from "../../../system/flow_cache.ts"; |
| 10 | 12 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| @@ -23,13 +25,15 @@ const said = (subject: string, why: Why) => | ||
| 23 | 25 | |
| 24 | 26 | export const tool: Tool = { |
| 25 | 27 | name: "flow", |
| 26 | 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`; 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 writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new 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", | |
| 28 | 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`; with `{ q, sort? }`, the rows a fleet query line selects, as `/fleet` shows them: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; 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 writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new 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", | |
| 27 | 29 | description: |
| 28 | "Pipeline (admin); `kind: \"bugs\"`: open bugs on their clocks; with `repo` and `issue` or `pull`: why it is not moving.", | |
| 30 | "Pipeline (admin); `q`: `/fleet` rows; `kind:bugs`: bugs; `repo`+`issue`/`pull`: why stuck.", | |
| 29 | 31 | inputSchema: { |
| 30 | 32 | type: "object", |
| 31 | 33 | properties: { |
| 32 | 34 | kind: { type: "string", enum: ["bugs"] }, |
| 35 | q: { type: "string", maxLength: 500 }, | |
| 36 | sort: { type: "string" }, | |
| 33 | 37 | repo: REPO_ARG, |
| 34 | 38 | issue: { type: "integer", minimum: 1 }, |
| 35 | 39 | pull: { type: "integer", minimum: 1 }, |
| @@ -37,6 +41,20 @@ export const tool: Tool = { | ||
| 37 | 41 | }, |
| 38 | 42 | writes: false, |
| 39 | 43 | async run(ctx, user, args) { |
| 44 | if (args["q"] !== undefined) { | |
| 45 | const q = args["q"]; | |
| 46 | if (typeof q !== "string" || q.trim() === "" || q.length > 500) | |
| 47 | return bad("invalid", "q is a fleet query line of at most 500 characters, like: repo autonomy:autonomous"); | |
| 48 | if (args["sort"] !== undefined && typeof args["sort"] !== "string") return bad("invalid", "sort is a column key"); | |
| 49 | // As `/fleet`: the same `viewFleet`, so the same rows for the same viewer. | |
| 50 | const view = viewFleet(ctx.app.db, user, q); | |
| 51 | if (!view.ok) return bad("invalid_query", view.error); | |
| 52 | const asked = args["sort"]; | |
| 53 | const sort = asked === undefined || asked === "" ? undefined : readSort(view.kind, asked); | |
| 54 | if (asked !== undefined && asked !== "" && sort === undefined) | |
| 55 | return bad("invalid_sort", "sort is a column of the kind, optionally led by -"); | |
| 56 | return good({ kind: view.kind, columns: COLUMNS[view.kind], items: sortRows(view.rows, sort) }); | |
| 57 | } | |
| 40 | 58 | if (args["kind"] !== undefined) { |
| 41 | 59 | if (args["kind"] !== "bugs") return bad("invalid", "kind is bugs"); |
| 42 | 60 | // As the terminal's bugs screen: an admin sees every repository, anyone else public ones. |
test/mcp-fleet-rows.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** `flow` with `q` (#10132): the rows a fleet line selects, the same ones `/fleet` shows, only those the caller may read. */ | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | ||
| 13 | let dir: string; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | let aliceTok: string; | |
| 17 | let carolTok: string; | |
| 18 | let carolCookie: string; | |
| 19 | let id = 1; | |
| 20 | ||
| 21 | const call = async (token: string, args: unknown) => { | |
| 22 | const res = await fetch(`${servers.url}/mcp`, { | |
| 23 | method: "POST", | |
| 24 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 25 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "flow", arguments: args } }), | |
| 26 | }); | |
| 27 | const body: any = await res.json(); | |
| 28 | const text: string = body.result.content[0].text; | |
| 29 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 30 | }; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dir = await mkdtemp(join(tmpdir(), "forge-mcp-fleet-rows-")); | |
| 34 | app = createApp({ dataDir: dir, port: 0 }); | |
| 35 | servers = await serve(app); | |
| 36 | const mk = async (username: string, first = false) => { | |
| 37 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 38 | if (!u.ok) throw new Error(u.error); | |
| 39 | return u.value; | |
| 40 | }; | |
| 41 | const alice = await mk("alice", true); | |
| 42 | const carol = await mk("carol"); | |
| 43 | aliceTok = createToken(app.db, alice.id, "rw", "rw").token; | |
| 44 | carolTok = createToken(app.db, carol.id, "rw", "rw").token; | |
| 45 | carolCookie = `forge_session=${createSession(app.db, carol.id).token}`; | |
| 46 | for (const [name, visibility] of [["pubrepo", "public"], ["privrepo", "private"]] as const) { | |
| 47 | const r = await createRepo(app, alice, { name, visibility }); | |
| 48 | if (!r.ok) throw new Error(String(r.error)); | |
| 49 | } | |
| 50 | }, 30_000); | |
| 51 | ||
| 52 | afterAll(async () => { | |
| 53 | servers.stop(); | |
| 54 | app.close(); | |
| 55 | await rm(dir, { recursive: true, force: true }); | |
| 56 | }); | |
| 57 | ||
| 58 | test("MCP and /fleet select the same rows for one line, each for its caller", async () => { | |
| 59 | const line = "repo owner:alice"; | |
| 60 | const mcp = await call(carolTok, { q: line }); | |
| 61 | expect(mcp.value.kind).toBe("repo"); | |
| 62 | expect(mcp.value.columns.length).toBeGreaterThan(0); | |
| 63 | const names = mcp.value.items.map((r: any) => r.repo); | |
| 64 | expect(names).toEqual(["alice/pubrepo"]); | |
| 65 | const page = await (await fetch(`${servers.url}/fleet?${new URLSearchParams({ q: line })}`, { headers: { cookie: carolCookie } })).text(); | |
| 66 | expect(page).toContain("alice/pubrepo"); | |
| 67 | expect(page).not.toContain("alice/privrepo"); | |
| 68 | ||
| 69 | const admin = await call(aliceTok, { q: line, sort: "-repo" }); | |
| 70 | expect(admin.value.items.map((r: any) => r.repo).sort()).toEqual(["alice/privrepo", "alice/pubrepo"]); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a bad line answers its reason; a bad sort and an empty q are refused", async () => { | |
| 74 | const bad = await call(aliceTok, { q: "run cost:abc" }); | |
| 75 | expect(bad.error).toContain("cost wants a number"); | |
| 76 | expect((await call(aliceTok, { q: "repo", sort: "nope" })).error).toContain("sort"); | |
| 77 | expect((await call(aliceTok, { q: " " })).error).toContain("q is"); | |
| 78 | }); |