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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6176-api-map-and-mcp-map-list-open-issues-by → main 4 commits fork 3 threads · 3 unresolved
8 files
changed +128
−14
docs/MCP.md +1 −1
| @@ -97,7 +97,7 @@ for a tunnel and a forge that sleeps. | ||
| 97 | 97 | | `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 | |
| 98 | 98 | | `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 | |
| 99 | 99 | | `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 | | | |
| 101 | 101 | | `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 | |
| 102 | 102 | | `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) | | |
| 103 | 103 | | `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 | ||
| 93 | 93 | converging, the bars say whether anything is moving. |
| 94 | 94 | |
| 95 | 95 | 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 | |
| 97 | 97 | autonomous mode reads to pick the next thing. Both carry where the crew |
| 98 | 98 | is: an issue with an agent run in flight is marked _working_ on the |
| 99 | 99 | 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 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 { | ||
| 302 | 302 | } from "../core/decisions.ts"; |
| 303 | 303 | import { forReader } from "../crew/handover.ts"; |
| 304 | 304 | 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"; | |
| 306 | 306 | import { cachedMap } from "../web/map_snapshot.ts"; |
| 307 | 307 | import { |
| 308 | 308 | milestoneData, |
| @@ -3092,7 +3092,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3092 | 3092 | }, |
| 3093 | 3093 | ); |
| 3094 | 3094 | // 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`. | |
| 3096 | 3097 | router.add("GET", "/api/v1/repos/:owner/:name/map", async (ctx) => { |
| 3097 | 3098 | const repo = loadRepo(ctx); |
| 3098 | 3099 | if (!repo) return notFound(); |
| @@ -3123,7 +3124,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3123 | 3124 | if (!built) |
| 3124 | 3125 | for (const name of ["graph", "marks", "pillars", "asktolive", "held", "render"]) |
| 3125 | 3126 | notePhase(request, name, 0, hit); |
| 3126 | return json(value); | |
| 3127 | return json(ctx.url.searchParams.get("done") === "1" ? value : withoutDone(value)); | |
| 3127 | 3128 | }); |
| 3128 | 3129 | // What people asked of the crew in the repositories the caller can write — |
| 3129 | 3130 | // 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"; | ||
| 4 | 4 | import { pillarView } from "../../../core/pillars.ts"; |
| 5 | 5 | import { askToLiveFor } from "../../../web/ask_to_live.ts"; |
| 6 | 6 | 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"; | |
| 8 | 8 | import { good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts"; |
| 9 | 9 | |
| 10 | 10 | export const tool: Tool = { |
| 11 | 11 | 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", | |
| 13 | 13 | 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.", | |
| 15 | 15 | inputSchema: { |
| 16 | 16 | type: "object", |
| 17 | properties: { app: APP_ARG }, | |
| 17 | properties: { app: APP_ARG, done: { type: "boolean" } }, | |
| 18 | 18 | required: ["app"], |
| 19 | 19 | }, |
| 20 | 20 | writes: false, |
| @@ -24,8 +24,7 @@ export const tool: Tool = { | ||
| 24 | 24 | const scope = sealedScope(ctx.app.db, repo, user); |
| 25 | 25 | const graph = repoGraph(ctx.app.db, repo.id, { ...scope }); |
| 26 | 26 | const pillars = pillarView(ctx.app.db, repo); |
| 27 | return good( | |
| 28 | mapJson( | |
| 27 | const map = mapJson( | |
| 29 | 28 | repo, |
| 30 | 29 | graph, |
| 31 | 30 | marksFor( |
| @@ -37,7 +36,7 @@ export const tool: Tool = { | ||
| 37 | 36 | pillars, |
| 38 | 37 | askToLiveFor(ctx.app, repo, pillars, user), |
| 39 | 38 | mapHeldFor(ctx.app, repo, graph, scope), |
| 40 | ), | |
| 41 | 39 | ); |
| 40 | return good(args.done === true ? map : withoutDone(map)); | |
| 42 | 41 | }, |
| 43 | 42 | }; |
src/web/pages/map.ts +16
| @@ -1472,6 +1472,22 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1472 | 1472 | return response; |
| 1473 | 1473 | } |
| 1474 | 1474 | |
| 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 | ||
| 1475 | 1491 | /** |
| 1476 | 1492 | * The map as data, for the crew and the tick: the same three lists, |
| 1477 | 1493 | * `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 () => { | |
| 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 ( |
||
| 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 = ""; | ||
| 22 | 22 | |
| 23 | 23 | const get = async (): Promise<{ body: string; ms: number; timing: string }> => { |
| 24 | 24 | 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}` } }); | |
| 26 | 26 | const body = await res.text(); |
| 27 | 27 | return { body, ms: performance.now() - t0, timing: res.headers.get("server-timing") ?? "" }; |
| 28 | 28 | }; |
new above
offline
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.