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

API /map and MCP `map` list open issues by default and count the done ones: trevato/forge's answer under 150 KB #7163

Merged opened this

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

Merged

scout/forge:scout/6176-api-map-and-mcp-map-list-open-issues-by → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/6176-api-map-and-mcp-map-list-open-issues-by was merged into main as 0abc77a.

Review

Sign in to review this change.
8 files changed +128 −14
docs/MCP.md +1 −1
@@ -97,7 +97,7 @@ for a tunnel and a forge that sleeps.
9797 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
9898 | `limits` | `{ key?, value?, dryRun? }` (forge admin): every limit the forge's owner can hit, with its value, default and bounds; with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); a value out of bounds is refused, applying needs an admin-scope token | yes |
9999 | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes |
100 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card | |
100 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`, `done` to list closed issues too); draws the `ui://forge/map` card | |
101101 | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes |
102102 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, `closedPerDay` (14 days) and `paceDate`, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
103103 | `mint_invite` | `{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token | yes |
docs/PLANNING.md +1 −1
@@ -93,7 +93,7 @@ a level and a flow are different units: the line says whether the plan is
9393 converging, the bars say whether anything is moving.
9494
9595 The same three lists are data at `GET /api/v1/repos/:owner/:name/map`,
96 which is what an agent reads before it starts and what a repository in
96 which is what an agent reads before it starts (`issues` and `edges` hold only open issues; `?done=1`, or `done: true` on MCP `map`, brings the closed ones back; `counts.done` always says how many) and what a repository in
9797 autonomous mode reads to pick the next thing. Both carry where the crew
9898 is: an issue with an agent run in flight is marked _working_ on the
9999 drawing and listed under `working` in the data; every issue in the data
src/contract/openapi/routes/getApiV1ReposOwnerNameMap.ts +2 −1
@@ -1,3 +1,4 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars; `startable` lists the ready, unassigned, unheld numbers", auth: false };
3 export const route: Route = { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars; `issues` and `edges` hold open issues only unless ?done=1, counts.done says how many are done; `startable` lists the ready, unassigned, unheld numbers", auth: false,
4 query: { done: "string" } };
src/http/api.ts +4 −3
@@ -302,7 +302,7 @@ import {
302302 } from "../core/decisions.ts";
303303 import { forReader } from "../crew/handover.ts";
304304 import { globalMapJson } from "../web/pages/global-map.ts";
305 import { mapJson, marksFor } from "../web/pages/map.ts";
305 import { mapJson, marksFor, withoutDone } from "../web/pages/map.ts";
306306 import { cachedMap } from "../web/map_snapshot.ts";
307307 import {
308308 milestoneData,
@@ -3092,7 +3092,8 @@ export function apiRoutes(router: Router<Handler>): void {
30923092 },
30933093 );
30943094 // The map as data: ready now, blocked on, the critical path, every edge,
3095 // and where the crew is.
3095 // and where the crew is. `issues` and `edges` hold the open ones unless
3096 // `?done=1`.
30963097 router.add("GET", "/api/v1/repos/:owner/:name/map", async (ctx) => {
30973098 const repo = loadRepo(ctx);
30983099 if (!repo) return notFound();
@@ -3123,7 +3124,7 @@ export function apiRoutes(router: Router<Handler>): void {
31233124 if (!built)
31243125 for (const name of ["graph", "marks", "pillars", "asktolive", "held", "render"])
31253126 notePhase(request, name, 0, hit);
3126 return json(value);
3127 return json(ctx.url.searchParams.get("done") === "1" ? value : withoutDone(value));
31273128 });
31283129 // What people asked of the crew in the repositories the caller can write —
31293130 // by default what has nothing merged yet, oldest first — and what became of each (core/asked.ts, the /asked page).
src/http/mcp/tools/map.ts +6 −7
@@ -4,17 +4,17 @@ import { mapHeldFor } from "../../../crew/held.ts";
44 import { pillarView } from "../../../core/pillars.ts";
55 import { askToLiveFor } from "../../../web/ask_to_live.ts";
66 import { listActiveRuns } from "../../../core/runs.ts";
7 import { mapJson, marksFor } from "../../../web/pages/map.ts";
7 import { mapJson, marksFor, withoutDone } from "../../../web/pages/map.ts";
88 import { good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
99
1010 export const tool: Tool = {
1111 name: "map",
12 doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card",
12 doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`, `done` to list closed issues too); draws the `ui://forge/map` card",
1313 description:
14 "An app's plan: open issues ready or waiting, critical path, edges, crew, `parkedTwice`.",
14 "An app's plan: open issues ready or waiting, critical path, edges, crew, `parkedTwice`; `done: true` adds closed issues.",
1515 inputSchema: {
1616 type: "object",
17 properties: { app: APP_ARG },
17 properties: { app: APP_ARG, done: { type: "boolean" } },
1818 required: ["app"],
1919 },
2020 writes: false,
@@ -24,8 +24,7 @@ export const tool: Tool = {
2424 const scope = sealedScope(ctx.app.db, repo, user);
2525 const graph = repoGraph(ctx.app.db, repo.id, { ...scope });
2626 const pillars = pillarView(ctx.app.db, repo);
27 return good(
28 mapJson(
27 const map = mapJson(
2928 repo,
3029 graph,
3130 marksFor(
@@ -37,7 +36,7 @@ export const tool: Tool = {
3736 pillars,
3837 askToLiveFor(ctx.app, repo, pillars, user),
3938 mapHeldFor(ctx.app, repo, graph, scope),
40 ),
4139 );
40 return good(args.done === true ? map : withoutDone(map));
4241 },
4342 };
src/web/pages/map.ts +16
@@ -1472,6 +1472,22 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
14721472 return response;
14731473 }
14741474
1475 /**
1476 * The map's answer without its closed history (#6176): `issues` keeps the
1477 * open ones and `edges` those between them. Everything computed on the whole
1478 * graph, `counts.done` included, stays as it was.
1479 */
1480 export function withoutDone(json: Record<string, unknown>): Record<string, unknown> {
1481 const issues = (json.issues as { number: number; state: string }[]).filter(
1482 (i) => i.state === "open",
1483 );
1484 const open = new Set(issues.map((i) => i.number));
1485 const edges = (json.edges as { issue?: number; blockedBy?: number }[]).filter(
1486 (e) => open.has(e.issue ?? -1) && open.has(e.blockedBy ?? -1),
1487 );
1488 return { ...json, issues, edges };
1489 }
1490
14751491 /**
14761492 * The map as data, for the crew and the tick: the same three lists,
14771493 * `working`, the issues the crew is on right now, and every issue with its
test/map-open-only.test.ts added +97
@@ -0,0 +1,97 @@
1 /** GET …/map and MCP `map` hold open issues by default; `done` brings the closed back (#6176). */
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 { openIssue, setIssueState } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { addDependency } from "../src/core/dependencies.ts";
10 import { getIssue } from "../src/core/issues.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14
15 let dataDir = "";
16 let app: App, servers: Servers, token = "";
17 const CLOSED = 2000;
18 const OPEN = 299; // plus "Open after": 300 open
19
20 beforeAll(async () => {
21 dataDir = await mkdtemp(join(tmpdir(), "forge-mapopen-"));
22 app = createApp({ dataDir, port: 0 });
23 servers = await serve(app);
24 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
25 if (!u.ok) throw new Error(u.error);
26 const made = await createRepo(app, u.value, { name: "plan", visibility: "public" });
27 if (!made.ok) throw new Error(String(made.error));
28 const repo = made.value;
29 const open = (title: string) => {
30 const o = openIssue(app, u.value, repo, { title });
31 if (!o.ok) throw new Error(String(o.error));
32 return o.value;
33 };
34 const first = open("Closed blocker");
35 for (let i = 1; i < CLOSED; i++) open(`Closed ${i}`);
36 const waiter = open("Open waiter");
37 for (let i = 1; i < OPEN; i++) open(`Open ${i}`);
38 // The graph holds a closed issue only while an open one waited on it.
39 for (let n = first.number; n < first.number + CLOSED; n++)
40 addDependency(app.db, waiter, getIssue(app.db, repo.id, n)!, u.value.id);
41 // One edge between two open issues: it must survive the filter.
42 addDependency(app.db, open("Open after"), waiter, u.value.id);
43 token = createToken(app.db, u.value.id, "t", "rw").token;
44 for (let n = first.number; n < first.number + CLOSED; n++)
45 setIssueState(app, u.value, repo, getIssue(app.db, repo.id, n)!, "closed", {});
46 }, 120_000);
47
48 afterAll(async () => {
49 servers.stop();
50 app.close();
51 await rm(dataDir, { recursive: true, force: true });
52 });
53
54 type Map = { issues: { state: string }[]; edges: unknown[]; counts: { done: number }; ready: unknown[] };
55 const read = async (q = ""): Promise<{ map: Map; bytes: number }> => {
56 const res = await fetch(`${servers.url}/api/v1/repos/alice/plan/map${q}`);
57 const text = await res.text();
58 return { map: JSON.parse(text) as Map, bytes: text.length };
59 };
60
61 test("by default only open issues and the edges between them, under 150 KB", async () => {
outdated

With only open→closed edges in the seed, an empty list can't tell 'kept the open-to-open edges' from 'dropped every edge'. Add one dependency between two open issues and assert it is the one edge left.

62 const { map, bytes } = await read();
63 expect(map.issues.length).toBe(OPEN + 1);
64 expect(map.issues.every((i) => i.state === "open")).toBe(true);
65 expect(map.edges.length).toBe(1);
66 expect(map.counts.done).toBe(CLOSED);
67 expect(bytes).toBeLessThan(150_000);
68 });
69
70 test("?done=1 brings every issue and edge back; the computed lists are the same", async () => {
71 const all = await read("?done=1");
72 const open = await read();
73 expect(all.map.issues.length).toBe(CLOSED + OPEN + 1);
74 expect(all.map.edges.length).toBe(CLOSED + 1);
outdated

This only checks the schema. It never calls the tool, so the MCP filter (args.done === true ? map : withoutDone(map)) is untested: delete it and this still passes. Call tool.run for the seeded repo with and without done: true, and assert the issue counts (OPEN vs CLOSED + OPEN) and counts.done.

75 expect(open.map.ready).toEqual(all.map.ready);
76 });
77
78 const mcpMap = async (args: Record<string, unknown>): Promise<Map> => {
79 const res = await fetch(`${servers.url}/mcp`, {
80 method: "POST",
81 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
82 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "map", arguments: { app: "alice/plan", ...args } } }),
83 });
84 const body = (await res.json()) as { result: { content: { text: string }[] } };
85 return JSON.parse(body.result.content[0]!.text) as Map;
86 };
87
88 test("MCP map is open-only by default and takes done: true", async () => {
89 const open = await mcpMap({});
90 expect(open.issues.length).toBe(OPEN + 1);
91 expect(open.issues.every((i) => i.state === "open")).toBe(true);
92 expect(open.edges.length).toBe(1);
93 expect(open.counts.done).toBe(CLOSED);
94 const all = await mcpMap({ done: true });
95 expect(all.issues.length).toBe(CLOSED + OPEN + 1);
96 expect(all.edges.length).toBe(CLOSED + 1);
97 });
test/map-seed-timing.test.ts +1 −1
@@ -22,7 +22,7 @@ let app: App, servers: Servers, bearer = "", repoId = "";
2222
2323 const get = async (): Promise<{ body: string; ms: number; timing: string }> => {
2424 const t0 = performance.now();
25 const res = await fetch(`${servers.url}/api/v1/repos/alice/notes/map`, { headers: { authorization: `Bearer ${bearer}` } });
25 const res = await fetch(`${servers.url}/api/v1/repos/alice/notes/map?done=1`, { headers: { authorization: `Bearer ${bearer}` } });
2626 const body = await res.text();
2727 return { body, ms: performance.now() - t0, timing: res.headers.get("server-timing") ?? "" };
2828 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs