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

The MCP plans in one call: `plan` opens several small issues and links them #355

Merged opened this

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

Merged

scout/forge:scout/349-the-mcp-plans-in-one-call-plan-opens-sev → main 1 commit fork

  • Merged. scout/349-the-mcp-plans-in-one-call-plan-opens-sev was merged into main as aac1cf6.

Review

Sign in to review this change.
4 files changed +362 −0
docs/MCP.md +25
@@ -40,6 +40,7 @@ for a tunnel and a forge that sleeps.
4040 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is (`app`) | |
4141 | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes |
4242 | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes |
43 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
4344
4445 Answers are JSON in a text block; most carry a `says` sentence short enough
4546 to relay as it is.
@@ -61,3 +62,27 @@ does not speak yet; that is the next step here, and it changes nothing above
6162 person pastes.
6263
6364 `src/http/mcp.ts`, `src/actions/tell.ts`, `test/mcp.test.ts`.
65
66 ## `plan`
67
68 Opens several small issues and their links in one transaction. Each item is
69 `{ key, title, body?, blockedBy? }`; `blockedBy` lists keys of the same call or
70 numbers of issues already open. A loop, an unknown key, an unknown number, an
71 over-long title or body, or more than 12 items refuses the whole plan, naming
72 the item, and nothing is opened. Issues belong to the token's owner and emit
73 the same events as opening and linking one by one. It needs an `rw` token.
74
75 ```json
76 { "app": "alice/game", "issues": [
77 { "key": "board", "title": "Draw the board", "body": "8x8 grid" },
78 { "key": "moves", "title": "Legal moves" },
79 { "key": "play", "title": "Play a game", "blockedBy": ["board", "moves"] } ] }
80 ```
81
82 answers
83
84 ```json
85 { "says": "Opened 3 issues: board is #7, moves is #8, play is #9.",
86 "opened": [{ "key": "board", "number": 7 }, { "key": "moves", "number": 8 }, { "key": "play", "number": 9 }],
87 "ready": [7, 8] }
88 ```
src/actions/plan.ts added +240
@@ -0,0 +1,240 @@
1 /**
2 * Opening a plan: several small issues and the links between them, all or
3 * nothing. Every refusal names the item that was wrong, and nothing is
4 * written (or announced) until the whole plan is known to be good.
5 */
6
7 import type { App } from "../app.ts";
8 import { isCrew } from "../core/agents.ts";
9 import { addDependency, repoGraph, readyNow } from "../core/dependencies.ts";
10 import {
11 createIssue,
12 getIssue,
13 MAX_BODY,
14 MAX_TITLE,
15 type Issue,
16 } from "../core/issues.ts";
17 import { canRead, canWrite, type Repo } from "../core/repos.ts";
18 import type { User } from "../core/users.ts";
19 import { transact } from "../kernel/db.ts";
20 import { err, ok, type Result } from "../kernel/result.ts";
21 import { isMirror } from "../origins/write-through.ts";
22
23 export const PLAN_MAX = 12;
24 const KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
25
26 export interface PlanItem {
27 readonly key: string;
28 readonly title: string;
29 readonly body: string;
30 /** Keys of this plan, or numbers of issues already open in the repository. */
31 readonly blockedBy: readonly (string | number)[];
32 }
33
34 /** A refusal: a stable code, and a sentence that names the item at fault. */
35 export interface PlanError {
36 readonly error: string;
37 readonly message: string;
38 }
39
40 const refuse = (error: string, message: string) => err({ error, message });
41
42 /** The plan as the wire gave it, checked into plain items. */
43 export function parsePlan(raw: unknown): Result<PlanItem[], PlanError> {
44 if (!Array.isArray(raw) || raw.length === 0)
45 return refuse("invalid", "issues must be a non-empty list");
46 if (raw.length > PLAN_MAX)
47 return refuse("too_many", `a plan is at most ${PLAN_MAX} issues`);
48 const items: PlanItem[] = [];
49 const seen = new Set<string>();
50 for (const [i, entry] of raw.entries()) {
51 const at = `issues[${i}]`;
52 if (entry === null || typeof entry !== "object" || Array.isArray(entry))
53 return refuse("invalid", `${at} must be an object`);
54 const o = entry as Record<string, unknown>;
55 if (typeof o["key"] !== "string" || !KEY.test(o["key"]))
56 return refuse(
57 "invalid",
58 `${at}: key must be a short name starting with a letter`,
59 );
60 const key = o["key"];
61 if (seen.has(key))
62 return refuse("duplicate_key", `${at}: key "${key}" is used twice`);
63 seen.add(key);
64 const title = typeof o["title"] === "string" ? o["title"].trim() : "";
65 if (title === "") return refuse("empty_title", `"${key}" needs a title`);
66 if (title.length > MAX_TITLE)
67 return refuse(
68 "title_too_long",
69 `"${key}": title is over ${MAX_TITLE} characters`,
70 );
71 const body = o["body"] ?? "";
72 if (typeof body !== "string")
73 return refuse("invalid", `"${key}": body must be text`);
74 if (body.length > MAX_BODY)
75 return refuse(
76 "body_too_long",
77 `"${key}": body is over ${MAX_BODY} characters`,
78 );
79 const waits = o["blockedBy"] ?? [];
80 if (!Array.isArray(waits) || waits.length > PLAN_MAX * 2)
81 return refuse("invalid", `"${key}": blockedBy must be a short list`);
82 for (const w of waits) {
83 const good =
84 typeof w === "string" ||
85 (typeof w === "number" && Number.isInteger(w) && w > 0);
86 if (!good)
87 return refuse(
88 "invalid",
89 `"${key}": blockedBy takes keys or issue numbers`,
90 );
91 }
92 items.push({ key, title, body, blockedBy: waits as (string | number)[] });
93 }
94 return ok(items);
95 }
96
97 export interface Planned {
98 readonly opened: { key: string; number: number }[];
99 /** Numbers of the open issues nothing open stands in front of. */
100 readonly ready: number[];
101 }
102
103 /** Which key sits on a loop among the plan's own links, if any. */
104 function loopAt(items: readonly PlanItem[]): string | undefined {
105 const by = new Map(items.map((i) => [i.key, i]));
106 const state = new Map<string, "walking" | "done">();
107 const visit = (key: string): string | undefined => {
108 if (state.get(key) === "done") return undefined;
109 if (state.get(key) === "walking") return key;
110 state.set(key, "walking");
111 for (const w of by.get(key)!.blockedBy)
112 if (typeof w === "string") {
113 const hit = visit(w);
114 if (hit !== undefined) return hit;
115 }
116 state.set(key, "done");
117 return undefined;
118 };
119 for (const i of items) {
120 const hit = visit(i.key);
121 if (hit !== undefined) return hit;
122 }
123 return undefined;
124 }
125
126 export function openPlan(
127 app: App,
128 actor: User | undefined,
129 repo: Repo,
130 items: readonly PlanItem[],
131 ): Result<Planned, PlanError> {
132 if (!actor || !canRead(app.db, repo, actor))
133 return refuse("not_found", "no such app or repository (owner/name)");
134 if (!canWrite(app.db, repo, actor) && !isCrew(app.db, repo.id, actor.id))
135 return refuse("forbidden", "you need write access to plan here");
136 if (isMirror(app, repo))
137 return refuse(
138 "mirrored",
139 "this repository is mirrored; plan at its origin",
140 );
141
142 const keys = new Set(items.map((i) => i.key));
143 const existing = new Map<number, Issue>();
144 for (const item of items)
145 for (const w of item.blockedBy) {
146 if (typeof w === "string") {
147 if (!keys.has(w))
148 return refuse(
149 "unknown_key",
150 `"${item.key}" waits for "${w}", which is not in this plan`,
151 );
152 if (w === item.key)
153 return refuse(
154 "self",
155 `"${item.key}": ${"an issue cannot wait for itself"}`,
156 );
157 } else {
158 const found = getIssue(app.db, repo.id, w);
159 if (!found || found.kind !== "issue")
160 return refuse(
161 "no_such_issue",
162 `"${item.key}" waits for #${w}, which is not an issue here`,
163 );
164 existing.set(w, found);
165 }
166 }
167 const loop = loopAt(items);
168 if (loop !== undefined)
169 return refuse("cycle", `"${loop}" is on a loop: that would make a loop`);
170
171 const opened: { key: string; number: number }[] = [];
172 const failed = transact(app.db, (): PlanError | undefined => {
173 app.db.exec("SAVEPOINT plan");
174 const made = new Map<string, Issue>();
175 const undo = (e: PlanError) => {
176 app.db.exec("ROLLBACK TO plan");
177 app.db.exec("RELEASE plan");
178 return e;
179 };
180 for (const item of items) {
181 const created = createIssue(app.db, {
182 repoId: repo.id,
183 authorId: actor.id,
184 title: item.title,
185 body: item.body,
186 });
187 if (!created.ok)
188 return undo({
189 error: created.error,
190 message: `"${item.key}": ${created.error}`,
191 });
192 made.set(item.key, created.value);
193 }
194 for (const item of items)
195 for (const w of item.blockedBy) {
196 const blocker = typeof w === "string" ? made.get(w)! : existing.get(w)!;
197 const linked = addDependency(
198 app.db,
199 made.get(item.key)!,
200 blocker,
201 actor.id,
202 );
203 if (!linked.ok)
204 return undo({
205 error: linked.error,
206 message: `"${item.key}" cannot wait for ${String(w)}: ${linked.error}`,
207 });
208 }
209 app.db.exec("RELEASE plan");
210 for (const item of items)
211 opened.push({ key: item.key, number: made.get(item.key)!.number });
212 return undefined;
213 });
214 if (failed) return err(failed);
215
216 // Announced only once the plan is committed, the same events as one by one.
217 const number = new Map(opened.map((o) => [o.key, o.number]));
218 for (const item of items) {
219 app.bus.append({
220 kind: "issue.opened",
221 repoId: repo.id,
222 actorId: actor.id,
223 payload: { number: number.get(item.key)! },
224 });
225 for (const w of item.blockedBy)
226 app.bus.append({
227 kind: "issue.blocked",
228 repoId: repo.id,
229 actorId: actor.id,
230 payload: {
231 number: number.get(item.key)!,
232 blockedBy: typeof w === "string" ? number.get(w)! : w,
233 },
234 });
235 }
236 return ok({
237 opened,
238 ready: readyNow(repoGraph(app.db, repo.id)).map((r) => r.issue.number),
239 });
240 }
src/http/mcp.ts +49
@@ -24,6 +24,7 @@ import {
2424 linkIssue,
2525 unlinkIssue,
2626 } from "../actions/dependencies.ts";
27 import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts";
2728 import { cancelRepoRun } from "../actions/runs.ts";
2829 import { tell, toldWords } from "../actions/tell.ts";
2930 import { appsRuntime } from "../apps/runtime.ts";
@@ -535,6 +536,54 @@ const TOOLS: readonly Tool[] = [
535536 : bad(unlinked.error, "you need write access to plan here");
536537 },
537538 },
539 {
540 name: "plan",
541 description: `Open several small issues and link them in one call: all of them or none. Each item has a key, a title, a body and optionally blockedBy, a list of keys of this plan or numbers of issues already open. A bad link (loop, unknown key) refuses the whole plan and names the item. At most ${PLAN_MAX} issues. Answers with the numbers opened, by key, and what is ready now.`,
542 inputSchema: {
543 type: "object",
544 properties: {
545 app: APP_ARG,
546 issues: {
547 type: "array",
548 minItems: 1,
549 maxItems: PLAN_MAX,
550 items: {
551 type: "object",
552 properties: {
553 key: {
554 type: "string",
555 description: "A short name for this item, unique in the plan.",
556 },
557 title: { type: "string" },
558 body: { type: "string" },
559 blockedBy: {
560 type: "array",
561 items: { type: ["string", "integer"] },
562 description: "Keys in this plan, or numbers of open issues.",
563 },
564 },
565 required: ["key", "title"],
566 },
567 },
568 },
569 required: ["app", "issues"],
570 },
571 writes: true,
572 run(ctx, user, args) {
573 const repo = planRepo(ctx, user, args);
574 if (isAnswer(repo)) return repo;
575 const items = parsePlan(args["issues"]);
576 if (!items.ok) return bad(items.error.error, items.error.message);
577 const planned = openPlan(ctx.app, user, repo, items.value);
578 if (!planned.ok) return bad(planned.error.error, planned.error.message);
579 const { opened, ready } = planned.value;
580 return good({
581 says: `Opened ${opened.length} issue${opened.length === 1 ? "" : "s"}: ${opened.map((o) => `${o.key} is #${o.number}`).join(", ")}.`,
582 opened,
583 ready,
584 });
585 },
586 },
538587 ];
539588
540589 function rpcOne(
test/mcp.test.ts +48
@@ -167,6 +167,7 @@ describe("the forge over MCP", () => {
167167 "map",
168168 "link",
169169 "unlink",
170 "plan",
170171 ]);
171172 const byName = new Map<string, any>(
172173 listed.body.result.tools.map((t: any) => [t.name, t]),
@@ -449,6 +450,53 @@ describe("the forge over MCP", () => {
449450 expect(after.ready.map((i: any) => i.number)).toContain(b);
450451 });
451452
453 test("plan opens linked issues all or none", async () => {
454 const issues = [
455 { key: "a", title: "First" },
456 { key: "b", title: "Second", body: "two" },
457 { key: "c", title: "Third", blockedBy: ["a", "b"] },
458 ];
459 const done = (await tool(rw, "plan", { app: "alice/game", issues })).value;
460 const n = Object.fromEntries(
461 done.opened.map((o: any) => [o.key, o.number]),
462 );
463 expect(done.ready).toEqual(expect.arrayContaining([n.a, n.b]));
464 expect(done.ready).not.toContain(n.c);
465 const map = (await tool(ro, "map", { app: "alice/game" })).value;
466 expect(map.edges).toContainEqual({ issue: n.c, blockedBy: n.a });
467 expect(map.edges).toContainEqual({ issue: n.c, blockedBy: n.b });
468
469 const count = async () =>
470 (await tool(ro, "map", { app: "alice/game" })).value.issues.length;
471 const before = await count();
472 const loop = await tool(rw, "plan", {
473 app: "alice/game",
474 issues: [
475 { key: "x", title: "X", blockedBy: ["y"] },
476 { key: "y", title: "Y", blockedBy: ["x"] },
477 ],
478 });
479 expect(loop.error).toContain("cycle");
480 const unknown = await tool(rw, "plan", {
481 app: "alice/game",
482 issues: [{ key: "x", title: "X", blockedBy: ["nope"] }],
483 });
484 expect(unknown.error).toContain('"nope"');
485 const many = await tool(rw, "plan", {
486 app: "alice/game",
487 issues: Array.from({ length: 13 }, (_, i) => ({
488 key: `k${i}`,
489 title: "t",
490 })),
491 });
492 expect(many.error).toContain("too_many");
493 expect(await count()).toBe(before);
494
495 expect(
496 (await tool(ro, "plan", { app: "alice/game", issues })).error,
497 ).toContain("forbidden");
498 });
499
452500 test("a browser session is not a way in, even signed in", async () => {
453501 const signin = await fetch(`${base}/signin`, {
454502 method: "POST",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs