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

Prompt a business: Atlas turns a description into a map of apps and adapters, as issues #10124

Merged opened this

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

Merged

scout/forge:scout/439-prompt-a-business-atlas-turns-a-descript → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/439-prompt-a-business-atlas-turns-a-descript was merged into main as cc9e104.

Review

Sign in to review this change.
15 files changed +1131 −1
docs/PLANNING.md +7
@@ -656,3 +656,10 @@ then store one proposal — title, why, the ordered issues with their
656656 Off, the plan opens its linked issues all or none. A person's nod opens a
657657 stored proposal: `approve` in `src/actions/proposals.ts`, from the inbox card
658658 or the MCP `asks_proposal`.
659
660 ## A business, as a map (#439)
661
662 Atlas turns a business described in words into apps, adapters and what
663 waits for what, dry-run first and opened through `openPlan` once: the API
664 (`POST …/atlas`) and the terminal's **Atlas plan**. How to use it:
665 [docs/features/atlas-plan.md](features/atlas-plan.md).
docs/features/atlas-plan.md added +54
@@ -0,0 +1,54 @@
1 # A business, as a map (#439)
2
3 Atlas answers a business described in words with a plan, not code: one
4 value, `{ description, apps: [{ slug, purpose }], adapters: [{ system, data }],
5 edges: [{ from, waitsFor }] }`, an app per capability and an adapter per
6 external system, an edge naming an app slug or an adapter system on each
7 side. It is checked once (`src/actions/business_plan.ts`) and reached by three
8 doors that share that path: the API, the terminal and MCP.
9
10 ## The API
11
12 `POST /api/v1/repos/:owner/:name/atlas` with `{ "plan": …, "pillar"?: … }`
13 (`rw` scope) answers a dry run: the issues it would open, their links, its
14 `stamp`, and the `spawn` seed (`direction` and `fresh` for a spawn plan,
15 docs/SPAWN.md), so the same map can ship as its own forge. Nothing opens
16 until `"confirm": true`:
17
18 - `201 { status: "opened", opened, ready }`: opened through `openPlan`, all or nothing.
19 - `202 { status: "proposed", id }`: where plans need a nod, kept as a proposal.
20 - `200 { status: "already", numbers }` or `{ status: "already-proposed", id }`:
21 this plan was confirmed here before; nothing more is opened or proposed.
22 - `403` for someone who may read but not write; `404` for someone who may not read.
23
24 ## The terminal
25
26 In a repository, the palette's **Atlas plan** (offered to writers and the
27 crew, as the API's confirm decides) takes the same JSON body, pasted over
28 the template. `ctrl+d` shows the issues it would open; `ctrl+d` again on the
29 same plan opens them through the same confirm, so web viewers see them live
30 as they do from the API. Any edit to the plan previews again.
31
32 ## MCP
33
34 The `atlas` tool takes `{ app, plan, pillar?, confirm? }` and answers as the
35 API does: a dry run, then `opened`, `proposed`, `already` or
36 `already-proposed`.
37
38 ## What is refused
39
40 With 422 and nothing opened: a description that is not text, empty, holds
41 control characters or is over 4000 characters; more than 12 apps and
42 adapters together; a slug that is not 1-39 of `a-z0-9-`; a name used twice;
43 an edge naming something not in the plan; an edge loop; a `pillar` that is
44 not one of the repository's pillars (`unknown_pillar`). A body over 64 KiB,
45 counted as it is read (chunked or not), is 413 `too_large`.
46
47 ## Opening once
48
49 Every issue it opens ends with the line `Atlas plan \`<stamp>\``, the hash
50 of the plan's canonical form; a later confirm looks only for issues whose
51 body ends with that line (an issue quoting it elsewhere does not count), and
52 for pending proposals of the same plan. The look and the opening run in one
53 write transaction, so two confirms at once open the plan once. No issue is
54 assigned: a person picks who builds what.
src/actions/business_plan.ts added +337
@@ -0,0 +1,337 @@
1 /**
2 * A business, described in words, as a map of what to build (#439): Atlas
3 * answers a description with one value — the apps (one per capability), the
4 * adapters (one per external system) and what waits for what — and this
5 * module checks that value, shows it as the issues it would open, and opens
6 * them through the planner's own path (`actions/plan.ts`), all or nothing.
7 *
8 * A plan, not code: nothing here runs a model or assigns anyone. The issues
9 * open unassigned; a person picks who builds them. The same value can seed a
10 * spawn plan (`spawn/plan.ts`), so the business can ship as its own forge.
11 *
12 * Every plan carries a stamp (a hash of its canonical form) in each issue it
13 * opens, so confirming the same plan twice answers with the issues the first
14 * confirm opened and opens nothing more.
15 */
16
17 import { createHash } from "node:crypto";
18 import type { App } from "../app.ts";
19 import { MAX_TITLE, getIssue } from "../core/issues.ts";
20 import { isCrew } from "../core/agents.ts";
21 import { canRead, canWrite, type Repo } from "../core/repos.ts";
22 import { findPillar, pillarsOf } from "../core/pillars.ts";
23 import { transact } from "../kernel/db.ts";
24 import type { User } from "../core/users.ts";
25 import { err, ok, type Result } from "../kernel/result.ts";
26 import { MAX_DIRECTION_CHARS, MAX_FRESH_CHARS } from "../spawn/plan.ts";
27 import { openPlan, PLAN_MAX, proposePlan, type PlanItem, type Planned, type Proposed } from "./plan.ts";
28
29 /** The description, in words: long enough for a business, short enough to read. */
30 export const DESCRIPTION_MAX = 4_000;
31 /** One sentence for what an app is for, or what data an adapter moves. */
32 export const LINE_MAX = 300;
33 /** Apps and adapters together open one issue each: no more than one plan holds. */
34 export const PARTS_MAX = PLAN_MAX;
35 export const EDGES_MAX = 60;
36 const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
37 const SYSTEM_MAX = 60;
38
39 export interface BusinessApp {
40 readonly slug: string;
41 readonly purpose: string;
42 }
43
44 export interface BusinessAdapter {
45 /** The external system, by name ("Stripe", "the shop's POS"). */
46 readonly system: string;
47 /** What data it moves, in one sentence. */
48 readonly data: string;
49 }
50
51 /** `from` waits for `waitsFor`; both name an app slug or an adapter system. */
52 export interface BusinessEdge {
53 readonly from: string;
54 readonly waitsFor: string;
55 }
56
57 export interface BusinessPlan {
58 readonly description: string;
59 readonly apps: readonly BusinessApp[];
60 readonly adapters: readonly BusinessAdapter[];
61 readonly edges: readonly BusinessEdge[];
62 }
63
64 export interface BusinessRefusal {
65 readonly error: string;
66 readonly message: string;
67 }
68
69 const refuse = (error: string, message: string) => err<BusinessRefusal>({ error, message });
70
71 /** One line of text, trimmed and with its runs of space folded, or why not. */
72 function line(raw: unknown, at: string, max: number): Result<string, BusinessRefusal> {
73 if (typeof raw !== "string") return refuse("invalid", `${at} must be text`);
74 const text = raw.trim().replace(/\s+/g, " ");
75 if (text === "") return refuse("invalid", `${at} is empty`);
76 if (text.length > max) return refuse("too_long", `${at} is over ${max} characters`);
77 return ok(text);
78 }
79
80 const isObject = (v: unknown): v is Record<string, unknown> =>
81 v !== null && typeof v === "object" && !Array.isArray(v);
82
83 /** The value Atlas answered with, checked into a plan, or the first thing wrong with it. */
84 export function parseBusinessPlan(raw: unknown): Result<BusinessPlan, BusinessRefusal> {
85 if (!isObject(raw)) return refuse("invalid", "plan must be an object");
86 const d = raw["description"];
87 if (typeof d !== "string" || d.trim() === "") return refuse("invalid", "description is the business, in words");
88 const description = d.trim();
89 if (description.length > DESCRIPTION_MAX)
90 return refuse("too_long", `description is over ${DESCRIPTION_MAX} characters`);
91 if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(description))
92 return refuse("invalid", "description holds control characters");
93
94 const rawApps = raw["apps"] ?? [];
95 const rawAdapters = raw["adapters"] ?? [];
96 const rawEdges = raw["edges"] ?? [];
97 if (!Array.isArray(rawApps)) return refuse("invalid", "apps must be a list");
98 if (!Array.isArray(rawAdapters)) return refuse("invalid", "adapters must be a list");
99 if (!Array.isArray(rawEdges)) return refuse("invalid", "edges must be a list");
100 const parts = rawApps.length + rawAdapters.length;
101 if (parts === 0) return refuse("invalid", "a plan names at least one app or adapter");
102 if (parts > PARTS_MAX) return refuse("too_many", `a plan is at most ${PARTS_MAX} apps and adapters together`);
103 if (rawEdges.length > EDGES_MAX) return refuse("too_many", `a plan is at most ${EDGES_MAX} edges`);
104
105 // Names are one space: an edge says which part it means without saying which kind.
106 const names = new Set<string>();
107 const apps: BusinessApp[] = [];
108 for (const [i, a] of rawApps.entries()) {
109 const at = `apps[${i}]`;
110 if (!isObject(a)) return refuse("invalid", `${at} must be an object`);
111 const slug = a["slug"];
112 if (typeof slug !== "string" || !SLUG.test(slug))
113 return refuse("invalid", `${at}: slug is 1-39 characters of a-z, 0-9 and "-"`);
114 if (names.has(slug.toLowerCase())) return refuse("duplicate", `${at}: "${slug}" is named twice`);
115 names.add(slug.toLowerCase());
116 const purpose = line(a["purpose"], `${at}: purpose`, LINE_MAX);
117 if (!purpose.ok) return purpose;
118 apps.push({ slug, purpose: purpose.value });
119 }
120 const adapters: BusinessAdapter[] = [];
121 for (const [i, a] of rawAdapters.entries()) {
122 const at = `adapters[${i}]`;
123 if (!isObject(a)) return refuse("invalid", `${at} must be an object`);
124 const system = line(a["system"], `${at}: system`, SYSTEM_MAX);
125 if (!system.ok) return system;
126 const key = system.value.toLowerCase();
127 if (names.has(key)) return refuse("duplicate", `${at}: "${system.value}" is named twice`);
128 names.add(key);
129 const data = line(a["data"], `${at}: data`, LINE_MAX);
130 if (!data.ok) return data;
131 adapters.push({ system: system.value, data: data.value });
132 }
133
134 const edges: BusinessEdge[] = [];
135 const seenEdge = new Set<string>();
136 for (const [i, e] of rawEdges.entries()) {
137 const at = `edges[${i}]`;
138 if (!isObject(e)) return refuse("invalid", `${at} must be an object`);
139 const from = e["from"];
140 const waitsFor = e["waitsFor"];
141 if (typeof from !== "string" || typeof waitsFor !== "string")
142 return refuse("invalid", `${at}: from and waitsFor name an app or an adapter`);
143 for (const n of [from, waitsFor])
144 if (!names.has(n.trim().toLowerCase())) return refuse("unknown", `${at}: "${n.slice(0, 60)}" is not in this plan`);
145 const f = from.trim().toLowerCase();
146 const w = waitsFor.trim().toLowerCase();
147 if (f === w) return refuse("cycle", `${at}: "${from.trim()}" cannot wait for itself`);
148 if (seenEdge.has(`${f}\n${w}`)) continue;
149 seenEdge.add(`${f}\n${w}`);
150 edges.push({ from: f, waitsFor: w });
151 }
152 const loop = loopIn(edges);
153 if (loop !== undefined) return refuse("cycle", `"${loop}" is on a loop: nothing on it could be built first`);
154 return ok({ description, apps, adapters, edges });
155 }
156
157 /** A name on a loop of `waitsFor`, if the edges hold one. */
158 function loopIn(edges: readonly BusinessEdge[]): string | undefined {
159 const out = new Map<string, string[]>();
160 for (const e of edges) out.set(e.from, [...(out.get(e.from) ?? []), e.waitsFor]);
161 const state = new Map<string, "walking" | "done">();
162 const visit = (n: string): string | undefined => {
163 if (state.get(n) === "done") return undefined;
164 if (state.get(n) === "walking") return n;
165 state.set(n, "walking");
166 for (const next of out.get(n) ?? []) {
167 const hit = visit(next);
168 if (hit !== undefined) return hit;
169 }
170 state.set(n, "done");
171 return undefined;
172 };
173 for (const n of out.keys()) {
174 const hit = visit(n);
175 if (hit !== undefined) return hit;
176 }
177 return undefined;
178 }
179
180 /** The plan's stamp: the same plan, however spaced or ordered on the wire, has one stamp. */
181 export function stampOf(plan: BusinessPlan): string {
182 const canonical = JSON.stringify({
183 d: plan.description,
184 a: plan.apps.map((a) => [a.slug.toLowerCase(), a.purpose]).sort(),
185 x: plan.adapters.map((a) => [a.system.toLowerCase(), a.data]).sort(),
186 e: plan.edges.map((e) => [e.from, e.waitsFor]).sort(),
187 });
188 return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
189 }
190
191 /** The line each opened issue ends with, which a second confirm finds. */
192 export const stampLine = (stamp: string): string => `Atlas plan \`${stamp}\``;
193
194 const titled = (head: string, rest: string): string => {
195 const t = `${head} — ${rest}`;
196 return t.length <= MAX_TITLE ? t : `${t.slice(0, MAX_TITLE - 1)}…`;
197 };
198
199 /** The plan as the planner's items: an issue per app and per adapter, linked by its edges. */
200 export function businessItems(plan: BusinessPlan, pillar?: string): PlanItem[] {
201 const stamp = stampOf(plan);
202 const keyOf = new Map<string, string>();
203 plan.apps.forEach((a, i) => keyOf.set(a.slug.toLowerCase(), `app${i + 1}`));
204 plan.adapters.forEach((a, i) => keyOf.set(a.system.toLowerCase(), `adapter${i + 1}`));
205 const waits = (name: string): string[] =>
206 plan.edges.filter((e) => e.from === name).map((e) => keyOf.get(e.waitsFor)!);
207 const context = `**The business:** ${plan.description}`;
208 const purpose = pillar === undefined ? {} : { pillar };
209 return [
210 ...plan.apps.map((a) => ({
211 key: keyOf.get(a.slug.toLowerCase())!,
212 title: titled(`App ${a.slug}`, a.purpose),
213 body: `An app for one capability: ${a.purpose}\n\n${context}\n\n${stampLine(stamp)}`,
214 blockedBy: waits(a.slug.toLowerCase()),
215 ...purpose,
216 })),
217 ...plan.adapters.map((a) => ({
218 key: keyOf.get(a.system.toLowerCase())!,
219 title: titled(`Adapter for ${a.system}`, a.data),
220 body: `An adapter to ${a.system}, which moves: ${a.data}\n\n${context}\n\n${stampLine(stamp)}`,
221 blockedBy: waits(a.system.toLowerCase()),
222 ...purpose,
223 })),
224 ];
225 }
226
227 /**
228 * What the plan gives a spawn plan (`spawn/plan.ts`): the description as the
229 * child's direction and its first app as the fresh app it starts with. The
230 * caller adds the name, the organisation and what to carry.
231 */
232 export function spawnSeed(plan: BusinessPlan): { direction: string; fresh?: string } {
233 const clipTo = (t: string, max: number) => (t.length <= max ? t : `${t.slice(0, max - 1)}…`);
234 const first = plan.apps[0];
235 return {
236 direction: clipTo(plan.description, MAX_DIRECTION_CHARS),
237 ...(first ? { fresh: clipTo(`${first.slug}: ${first.purpose}`, MAX_FRESH_CHARS) } : {}),
238 };
239 }
240
241 /** The largest body either door reads: a description, twelve parts and their edges, with room. */
242 export const ATLAS_BODY_MAX = 64 * 1024;
243
244 /**
245 * The issues an earlier confirm of this plan opened here, by number. The
246 * stamp line is matched only where `businessItems` puts it, at the very end
247 * of the body, so an issue that merely quotes it is not taken for one.
248 */
249 function openedBefore(app: App, repo: Repo, stamp: string): number[] {
250 const tail = `\n\n${stampLine(stamp)}`;
251 const rows = app.db
252 .query<{ number: number }, [string, string]>(
253 `SELECT number FROM issue
254 WHERE repo_id = ?1 AND length(body) >= length(?2) AND substr(body, -length(?2)) = ?2
255 ORDER BY number`,
256 )
257 .all(repo.id, tail);
258 return rows.map((r) => r.number).filter((n) => getIssue(app.db, repo.id, n)?.kind === "issue");
259 }
260
261 /** A proposal of this plan still waiting for its nod here, if one is. */
262 function pendingProposal(app: App, repo: Repo, stamp: string): string | undefined {
outdated

The plansNeedNod path skips the stamp check, so confirming twice stores two proposals. Apply the duplicate check here too.

263 // Each item's body, as JSON keeps it, ends with the stamp line and its quote.
264 const tail = `\\n\\n${stampLine(stamp)}"`;
265 return (
outdated

Check-then-open is not atomic. Two concurrent confirms both see no earlier issues and both open the plan. Wrap this in a transaction or serialise per repo.

266 app.db
267 .query<{ id: string }, [string, string]>(
268 `SELECT id FROM plan_proposal WHERE repo_id = ?1 AND state = 'pending' AND instr(issues, ?2) > 0
269 ORDER BY created_at LIMIT 1`,
270 )
271 .get(repo.id, tail)?.id ?? undefined
272 );
273 }
274
275 /**
276 * The pillar a plan says it serves, checked against this repository's
277 * pillars: absent is fine, anything else must name one of them.
278 */
279 export function checkPillar(app: App, repo: Repo, pillar: unknown): Result<string | undefined, BusinessRefusal> {
280 if (pillar === undefined) return ok(undefined);
281 if (typeof pillar !== "string" || pillar.trim() === "" || pillar.length > 100)
282 return refuse("invalid", "pillar is the name of a pillar");
283 const { pillars } = pillarsOf(app.db, repo);
284 const found = findPillar(pillars, pillar);
285 if (found === undefined)
286 return refuse(
287 "unknown_pillar",
288 pillars.length === 0
289 ? "this repository has no pillars to serve"
290 : `"${pillar.trim()}" is not a pillar here; they are ${pillars.map((p) => p.short).join(", ")}`,
291 );
292 return ok(found.short);
293 }
294
295 export type Confirmed =
296 | ({ readonly status: "opened"; readonly stamp: string } & Planned)
297 | { readonly status: "already"; readonly stamp: string; readonly numbers: number[] }
298 | { readonly status: "already-proposed"; readonly stamp: string; readonly id: string }
299 | ({ readonly stamp: string } & Proposed);
300
301 /**
302 * Confirming: the plan's issues and links, opened through `openPlan` (or kept
303 * as a proposal where plans need a nod). A plan already confirmed here opens
304 * (or proposes) nothing and says what the earlier confirm made. The look for
305 * an earlier confirm and the opening run in one write transaction, so two
306 * confirms at once — even from two processes on the same data — open once.
307 */
308 export function confirmBusinessPlan(
309 app: App,
310 actor: User | undefined,
311 repo: Repo,
312 plan: BusinessPlan,
313 pillar?: string,
314 ): Result<Confirmed, BusinessRefusal> {
315 const stamp = stampOf(plan);
316 const items = businessItems(plan, pillar);
317 // Only someone who may open the plan learns what an earlier confirm opened.
318 if (!actor || !canRead(app.db, repo, actor))
319 return refuse("not_found", "no such app or repository (owner/name)");
320 if (!canWrite(app.db, repo, actor) && !isCrew(app.db, repo.id, actor.id))
321 return refuse("forbidden", "you need write access to plan here");
322 return transact(app.db, (): Result<Confirmed, BusinessRefusal> => {
323 const before = openedBefore(app, repo, stamp);
324 if (before.length > 0) return ok({ status: "already", stamp, numbers: before });
325 if (repo.plansNeedNod) {
326 const waiting = pendingProposal(app, repo, stamp);
327 if (waiting !== undefined) return ok({ status: "already-proposed", stamp, id: waiting });
328 const proposed = proposePlan(app, actor, repo, items, {
329 title: "Atlas: a map of apps and adapters",
330 why: plan.description,
331 });
332 return proposed.ok ? ok({ ...proposed.value, stamp }) : proposed;
333 }
334 const planned = openPlan(app, actor, repo, items);
335 return planned.ok ? ok({ status: "opened", stamp, ...planned.value }) : planned;
336 });
337 }
src/contract/openapi/routes/index.ts +1
@@ -269,6 +269,7 @@ export { route as postApiV1ReposOwnerNameAppNotesIdBan } from "./postApiV1ReposO
269269 export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1ReposOwnerNameAppNotesIdHide.ts";
270270 export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts";
271271 export { route as postApiV1ReposOwnerNameAppPull } from "./postApiV1ReposOwnerNameAppPull.ts";
272 export { route as postApiV1ReposOwnerNameAtlas } from "./postApiV1ReposOwnerNameAtlas.ts";
272273 export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts";
273274 export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts";
274275 export { route as postApiV1ReposOwnerNameBuildsLabelCart } from "./postApiV1ReposOwnerNameBuildsLabelCart.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameAtlas.ts added +9
@@ -0,0 +1,9 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: `${REPO}/atlas`,
6 summary: "Check a business plan of apps, adapters and edges; with confirm, open its issues once",
7 auth: true,
8 body: { plan: "object" },
9 };
src/http/api.ts +2
@@ -348,6 +348,7 @@ import { displayRoutes } from "./api/displays.ts";
348348 import { followingRoutes } from "./api/following.ts";
349349 import { labelsRoutes } from "./api/labels.ts";
350350 import { visionRoutes } from "./api/vision.ts";
351 import { businessPlanRoutes } from "./api/business_plan.ts";
351352 import { tokenRoutes } from "./api/tokens.ts";
352353 import { accountAdmin, adminRoutes } from "./api/admin.ts";
353354 import { maintenanceRoutes } from "./api/maintenance.ts";
@@ -2083,6 +2084,7 @@ export function apiRoutes(router: Router<Handler>): void {
20832084
20842085 labelsRoutes(router);
20852086 visionRoutes(router);
2087 businessPlanRoutes(router);
20862088 router.add(
20872089 "PUT",
20882090 "/api/v1/repos/:owner/:name/pulls/:number/labels",
src/http/api/business_plan.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * `POST /api/v1/repos/:owner/:name/atlas` (#439): a business plan from Atlas
3 * (apps, adapters, what waits for what), shown as the issues it would open
4 * unless `confirm: true`, which opens them through the planner's path.
5 */
6
7 import {
8 ATLAS_BODY_MAX,
9 businessItems,
10 checkPillar,
11 confirmBusinessPlan,
12 parseBusinessPlan,
13 spawnSeed,
14 stampOf,
15 } from "../../actions/business_plan.ts";
16 import { loadRepo } from "../api.ts";
17 import type { Handler } from "../context.ts";
18 import { apiError, boundedBody, json, notFound, requireScope } from "../respond.ts";
19 import type { Router } from "../router.ts";
20
21 const BODY_MAX = ATLAS_BODY_MAX;
22
23 export function businessPlanRoutes(router: Router<Handler>): void {
24 router.add("POST", "/api/v1/repos/:owner/:name/atlas", async (ctx) => {
outdated

The Content-Length check is bypassed by a chunked body. Use boundedBody(ctx.request, BODY_MAX) so the bytes actually read are counted, and answer 413 on too_large.

25 const denied = requireScope(ctx, "rw");
26 if (denied) return denied;
27 const repo = loadRepo(ctx);
28 if (!repo) return notFound();
29 // The bytes read are what is counted: a chunked body has no length to claim.
30 const input = await boundedBody(ctx.request, BODY_MAX);
31 if (input === "too_large") return apiError("too_large", 413, `a plan is at most ${BODY_MAX} bytes`);
32 const plan = parseBusinessPlan(input.plan);
33 if (!plan.ok) return apiError(plan.error.error, 422, plan.error.message);
34 const served = checkPillar(ctx.app, repo, input.pillar);
35 if (!served.ok) return apiError(served.error.error, 422, served.error.message);
36 const pillar = served.value;
37 if (input.confirm !== undefined && typeof input.confirm !== "boolean")
38 return apiError("invalid", 422, "confirm is true or false");
39 if (input.confirm !== true)
40 return json({
41 status: "dry-run",
42 stamp: stampOf(plan.value),
43 plan: plan.value,
44 issues: businessItems(plan.value, pillar),
45 spawn: spawnSeed(plan.value),
46 });
47 const done = confirmBusinessPlan(ctx.app, ctx.auth.user, repo, plan.value, pillar);
48 if (!done.ok)
49 return apiError(
50 done.error.error,
51 done.error.error === "not_found" ? 404 : done.error.error === "forbidden" ? 403 : 422,
52 done.error.message,
53 );
54 const status = done.value.status === "opened" ? 201 : done.value.status === "proposed" ? 202 : 200;
55 return json(done.value, status);
56 });
57 }
src/http/mcp/tools/atlas.ts added +63
@@ -0,0 +1,63 @@
1 import {
2 ATLAS_BODY_MAX,
3 businessItems,
4 checkPillar,
5 confirmBusinessPlan,
6 parseBusinessPlan,
7 spawnSeed,
8 stampOf,
9 } from "../../../actions/business_plan.ts";
10 import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
11
12 /** `POST …/atlas` (#439) as a tool: the same checks, dry run unless `confirm`. */
13 export const tool: Tool = {
14 name: "atlas",
15 doc: "`{ app, plan: { description, apps: [{slug,purpose}], adapters: [{system,data}], edges: [{from,waitsFor}] }, pillar?, confirm? }`: a business as issues; dry run unless confirm",
16 description: "Atlas business plan: shows the issues it would open; confirm:true opens them once.",
17 inputSchema: {
18 type: "object",
19 properties: {
20 app: APP_ARG,
21 plan: {
22 type: "object",
23 description: "description, apps, adapters, edges: see llms.txt",
24 },
25 pillar: { type: "string", maxLength: 100 },
26 confirm: { type: "boolean" },
27 },
28 required: ["app", "plan"],
29 },
30 writes: true,
31 run(ctx, user, args) {
32 const repo = planRepo(ctx, user, args);
33 if (isAnswer(repo)) return repo;
34 if (JSON.stringify(args["plan"] ?? null).length > ATLAS_BODY_MAX)
35 return bad("too_large", `a plan is at most ${ATLAS_BODY_MAX} bytes`);
36 const plan = parseBusinessPlan(args["plan"]);
37 if (!plan.ok) return bad(plan.error.error, plan.error.message);
38 const served = checkPillar(ctx.app, repo, args["pillar"]);
39 if (!served.ok) return bad(served.error.error, served.error.message);
40 if (args["confirm"] !== undefined && typeof args["confirm"] !== "boolean")
41 return bad("invalid", "confirm is true or false");
42 if (args["confirm"] !== true) {
43 const issues = businessItems(plan.value, served.value);
44 return good({
45 says: `Dry run: ${issues.length} issues would open. Call again with confirm:true to open them.`,
46 status: "dry-run",
47 stamp: stampOf(plan.value),
48 issues,
49 spawn: spawnSeed(plan.value),
50 });
51 }
52 const done = confirmBusinessPlan(ctx.app, user, repo, plan.value, served.value);
53 if (!done.ok) return bad(done.error.error, done.error.message);
54 const r = done.value;
55 const says =
56 r.status === "opened"
57 ? `Opened ${r.opened.map((o) => `#${o.number}`).join(", ")}.`
58 : r.status === "already"
59 ? `Already opened: ${r.numbers.map((n) => `#${n}`).join(", ")}; nothing more opened.`
60 : `Proposed: nothing opens until a person nods.`;
61 return good({ says, ...r });
62 },
63 };
src/http/mcp/tools/index.ts +1
@@ -22,6 +22,7 @@ export { tool as asks_proposal } from "./asks_proposal.ts";
2222 export { tool as asks_undecide } from "./asks_undecide.ts";
2323 export { tool as assign } from "./assign.ts";
2424 export { tool as assigned } from "./assigned.ts";
25 export { tool as atlas } from "./atlas.ts";
2526 export { tool as attach_look } from "./attach_look.ts";
2627 export { tool as audit } from "./audit.ts";
2728 export { tool as backups } from "./backups.ts";
src/tui/screens/atlas.ts added +167
@@ -0,0 +1,167 @@
1 /**
2 * Atlas in the terminal (#439): the door `POST …/atlas` has on the API. The
3 * plan is typed or pasted as the same JSON body (`{"plan": …, "pillar": …}`);
4 * ctrl+d shows the issues it would open (the dry run), and ctrl+d again on
5 * the same plan opens them through `confirmBusinessPlan`, the path the API
6 * takes — so the gate, the duplicate check and the bus events that keep web
7 * viewers live are one code path. Offered to whoever may plan here.
8 */
9
10 import {
11 ATLAS_BODY_MAX,
12 businessItems,
13 checkPillar,
14 confirmBusinessPlan,
15 parseBusinessPlan,
16 stampOf,
17 type Confirmed,
18 } from "../../actions/business_plan.ts";
19 import type { PlanItem } from "../../actions/plan.ts";
20 import { isCrew } from "../../core/agents.ts";
21 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
22 import type { Ctx, Screen } from "../app/app.ts";
23 import { binding } from "../app/keys.ts";
24 import type { Command } from "../app/palette.ts";
25 import { style } from "../term/style.ts";
26 import { truncate } from "../term/width.ts";
27 import type { Session } from "../session.ts";
28 import { fill, len, split } from "../ui/layout.ts";
29 import { Textarea } from "../ui/textarea.ts";
30 import { card } from "../ui/widgets.ts";
31
32 /** Who may plan here: as the API's confirm decides it. */
33 export function mayPlan(f: Session, repo: Repo): boolean {
34 const u = f.user;
35 if (u === undefined || !canRead(f.db, repo, u)) return false;
36 return canWrite(f.db, repo, u) || isCrew(f.db, repo.id, u.id);
37 }
38
39 export type AtlasStep =
40 | { readonly kind: "error"; readonly message: string }
41 | { readonly kind: "preview"; readonly stamp: string; readonly items: PlanItem[] }
42 | { readonly kind: "done"; readonly result: Confirmed };
43
44 /**
45 * One ctrl+d: `text` checked as the API checks its body, then previewed —
46 * or, when it is the plan just previewed (`previewed` is its stamp), confirmed.
47 */
48 export function atlasStep(f: Session, repo: Repo, text: string, previewed?: string): AtlasStep {
49 if (!mayPlan(f, repo)) return { kind: "error", message: "you need write access to plan here" };
50 if (new TextEncoder().encode(text).length > ATLAS_BODY_MAX)
51 return { kind: "error", message: `a plan is at most ${ATLAS_BODY_MAX} bytes` };
52 let input: unknown;
53 try {
54 input = JSON.parse(text);
55 } catch {
56 return { kind: "error", message: "not JSON: the body is {\"plan\": {…}, \"pillar\": \"…\"}" };
57 }
58 if (typeof input !== "object" || input === null || Array.isArray(input))
59 return { kind: "error", message: "the body is an object: {\"plan\": {…}}" };
60 const body = input as Record<string, unknown>;
61 const plan = parseBusinessPlan(body["plan"]);
62 if (!plan.ok) return { kind: "error", message: plan.error.message };
63 const served = checkPillar(f.app, repo, body["pillar"]);
64 if (!served.ok) return { kind: "error", message: served.error.message };
65 const stamp = stampOf(plan.value);
66 if (previewed !== stamp) return { kind: "preview", stamp, items: businessItems(plan.value, served.value) };
67 const done = confirmBusinessPlan(f.app, f.user, repo, plan.value, served.value);
68 return done.ok ? { kind: "done", result: done.value } : { kind: "error", message: done.error.message };
69 }
70
71 /** What a confirm did, in one line. */
72 export function confirmedLine(r: Confirmed): string {
73 switch (r.status) {
74 case "opened":
75 return `opened ${r.opened.map((o) => `#${o.number}`).join(" ")}`;
76 case "already":
77 return `already opened: ${r.numbers.map((n) => `#${n}`).join(" ")}`;
78 case "already-proposed":
79 return "already proposed: it waits for a nod";
80 default:
81 return "proposed: it waits for a nod";
82 }
83 }
84
85 const TEMPLATE = `{
86 "plan": {
87 "description": "",
88 "apps": [{ "slug": "", "purpose": "" }],
89 "adapters": [{ "system": "", "data": "" }],
90 "edges": []
91 }
92 }`;
93
94 export function atlasScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
95 let editor = new Textarea(TEMPLATE);
96 let step: AtlasStep | undefined;
97
98 const submit = (): void => {
99 const previewed = step?.kind === "preview" ? step.stamp : undefined;
100 step = atlasStep(f, repo, editor.value, previewed);
101 if (step.kind === "error") ctx.toast(step.message, "danger");
102 if (step.kind === "done") ctx.toast(confirmedLine(step.result), "success");
103 };
104
105 const lines = (): string[] => {
106 if (step === undefined) return ["ctrl+d shows the issues this plan would open; nothing opens yet."];
107 if (step.kind === "error") return [step.message];
108 if (step.kind === "done") return [confirmedLine(step.result)];
109 const titleOf = new Map(step.items.map((i) => [i.key, i.title]));
110 return [
111 `Dry run: ${step.items.length} issues. ctrl+d again opens them; any edit previews again.`,
112 ...step.items.map((i) =>
113 i.blockedBy.length === 0
114 ? `· ${i.title}`
115 : `· ${i.title} (waits for ${i.blockedBy.map((k) => titleOf.get(String(k)) ?? String(k)).join(", ")})`,
116 ),
117 ];
118 };
119
120 return {
121 title: `Atlas plan · ${repo.ownerName}/${repo.name}`,
122 keys: [binding(["ctrl+d"], "preview, then open"), binding(["esc"], "back")],
123 render(area, s) {
124 const c = ctx.theme.c;
125 const shown = lines();
126 const [top, rest] = split(area, "v", [fill(1), len(Math.min(shown.length, 10) + 2)]);
127 const inner = card(s, top!, c, { title: "Plan (JSON, as the API takes it)", footer: "ctrl+d", focused: true });
128 ctx.setCursor(editor.render(s, inner, c, true));
129 const out = card(s, rest!, c, { title: step?.kind === "preview" ? "Would open" : "Atlas" });
130 const tone = step?.kind === "error" ? c.danger : c.mutedFg;
131 shown.slice(0, out.h).forEach((t, i) => {
132 s.text(out.x, out.y + i, truncate(t, out.w), style(i === 0 ? tone : c.fg, c.card), out.x + out.w);
133 });
134 },
135 paste(text) {
136 // A plan pasted over the untouched template takes its place.
137 if (editor.value === TEMPLATE) editor = new Textarea(text);
138 else editor.paste(text);
139 if (step?.kind === "preview") step = undefined;
140 return true;
141 },
142 key(e) {
143 const before = editor.value;
144 const out = editor.key(e);
145 if (out === "submit") {
146 submit();
147 return true;
148 }
149 if (out === "cancel") return false;
150 if (editor.value !== before && step?.kind === "preview") step = undefined;
151 return out !== "ignored";
152 },
153 };
154 }
155
156 /** The per-repository command: offered to whoever may plan here. */
157 export function atlasCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
158 if (repo === undefined || !mayPlan(f, repo)) return [];
159 return [
160 {
161 id: "atlas-plan",
162 title: `${repo.ownerName}/${repo.name} — Atlas plan`,
163 hint: "a business as apps and adapters, opened as issues",
164 run: () => ctx.push(atlasScreen(f, ctx, repo)),
165 },
166 ];
167 }
src/tui/screens/workspace.ts +2
@@ -142,6 +142,7 @@ import { appEnvCommands } from "./appenv.ts";
142142 import { autonomyCommands } from "./autonomy.ts";
143143 import { trafficCommands } from "./trafficview.ts";
144144 import { adapterCommands } from "./adapter.ts";
145 import { atlasCommands } from "./atlas.ts";
145146 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
146147 import { finderScreen } from "./finder.ts";
147148 import { issuesScreen } from "./issues.ts";
@@ -1464,6 +1465,7 @@ export function workspaceScreen(
14641465 ...autonomyCommands(f, ctx, repo),
14651466 ...trafficCommands(f, ctx, repo),
14661467 ...adapterCommands(f, ctx, repo),
1468 ...atlasCommands(f, ctx, repo),
14671469 ],
14681470 get title() {
14691471 return sourceName(src);
test/business-plan.test.ts added +289
@@ -0,0 +1,289 @@
1 /**
2 * Invariant (#439): an Atlas business plan is one validated value. A
3 * malformed or oversized description and a plan with a loop are refused;
4 * without `confirm` nothing opens; confirming opens an issue per app and
5 * adapter with its links, unassigned; confirming again opens nothing more;
6 * the same value seeds a spawn plan.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { DESCRIPTION_MAX, parseBusinessPlan, spawnSeed } from "../src/actions/business_plan.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { blockerNumbersFor } from "../src/core/dependencies.ts";
16 import { listIssues } from "../src/core/issues.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { createIssue } from "../src/core/issues.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { planSpawn } from "../src/spawn/plan.ts";
23
24 const bakery = {
25 description: "A bakery that takes orders online, bakes to a schedule and is paid by card.",
26 apps: [
27 { slug: "orders", purpose: "Customers place and track orders" },
28 { slug: "schedule", purpose: "The bakers see what to bake when" },
29 ],
30 adapters: [{ system: "Stripe", data: "Card payments and refunds" }],
31 edges: [
32 { from: "orders", waitsFor: "Stripe" },
33 { from: "schedule", waitsFor: "orders" },
34 ],
35 };
36
37 describe("parseBusinessPlan", () => {
38 const code = (raw: unknown) => {
39 const r = parseBusinessPlan(raw);
40 return r.ok ? "ok" : r.error.error;
41 };
42
43 test("a good plan passes, and its edges are kept", () => {
44 const r = parseBusinessPlan(bakery);
45 expect(r.ok).toBe(true);
46 if (r.ok) expect(r.value.edges.length).toBe(2);
47 });
48
49 test("a malformed or oversized description is refused", () => {
50 expect(code({ ...bakery, description: 5 })).toBe("invalid");
51 expect(code({ ...bakery, description: " " })).toBe("invalid");
52 expect(code({ ...bakery, description: "a\u0000b" })).toBe("invalid");
53 expect(code({ ...bakery, description: "x".repeat(DESCRIPTION_MAX + 1) })).toBe("too_long");
54 expect(code("a bakery")).toBe("invalid");
55 expect(code(null)).toBe("invalid");
56 });
57
58 test("a plan with a dependency cycle is refused", () => {
59 expect(code({ ...bakery, edges: [...bakery.edges, { from: "Stripe", waitsFor: "schedule" }] })).toBe("cycle");
60 expect(code({ ...bakery, edges: [{ from: "orders", waitsFor: "orders" }] })).toBe("cycle");
61 });
62
63 test("bad parts are refused: unknown names, twice-named parts, too many, bad slugs", () => {
64 expect(code({ ...bakery, edges: [{ from: "orders", waitsFor: "PayPal" }] })).toBe("unknown");
65 expect(code({ ...bakery, apps: [...bakery.apps, { slug: "orders", purpose: "again" }] })).toBe("duplicate");
66 expect(code({ ...bakery, apps: [{ slug: "Not A Slug", purpose: "x" }] })).toBe("invalid");
67 const many = Array.from({ length: 13 }, (_, i) => ({ slug: `app-${i}`, purpose: "x" }));
68 expect(code({ ...bakery, apps: many, edges: [] })).toBe("too_many");
69 expect(code({ description: "x", apps: [], adapters: [] })).toBe("invalid");
70 });
71
72 test("the plan seeds a spawn plan the spawn checks accept", () => {
73 const r = parseBusinessPlan(bakery);
74 if (!r.ok) throw new Error(r.error.message);
75 const seed = spawnSeed(r.value);
76 const spawn = planSpawn(
77 { name: "bakery", org: "bakery", apps: [], carryBriefs: false, carrySubscriptions: false, ...seed },
78 { catalog: [], briefs: [], subscriptions: [] },
79 );
80 expect(spawn.ok).toBe(true);
81 if (spawn.ok) {
82 expect(spawn.value.direction).toBe(bakery.description);
83 expect(spawn.value.fresh?.description).toContain("orders");
84 }
85 });
86 });
87
88 describe("POST …/atlas", () => {
89 let dataDir = "";
90 let app: App;
91 let servers: Servers;
92 let repo: Repo;
93 let rw = "";
94 let bobRw = "";
95 let alice: User;
96 const post = (payload: unknown, name = "bakery", token = rw) =>
97 fetch(`${servers.url}/api/v1/repos/alice/${name}/atlas`, {
98 method: "POST",
99 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
100 body: JSON.stringify(payload),
101 });
102 const issues = (r = repo) => listIssues(app.db, r.id, {});
103 const another = async (name: string, visibility: "public" | "private" = "private"): Promise<Repo> => {
104 const made = await createRepo(app, alice, { name, visibility });
105 if (!made.ok) throw new Error(String(made.error));
106 return made.value;
107 };
108
109 beforeAll(async () => {
110 dataDir = await mkdtemp(join(tmpdir(), "forge-atlas-"));
111 app = createApp({ dataDir, port: 0 });
112 servers = await serve(app);
113 const a = await createUser(app.db, {
114 origin: "seed",
115 trusted: true,
116 username: "alice",
117 email: "[email protected]",
118 password: "a-good-passphrase",
119 adminIfFirst: true,
120 });
121 if (!a.ok) throw new Error(a.error);
122 alice = a.value;
123 rw = createToken(app.db, a.value.id, "rw", "rw").token;
124 const b = await createUser(app.db, {
125 origin: "seed",
126 trusted: true,
127 username: "bob",
128 email: "[email protected]",
129 password: "b-good-passphrase",
130 });
131 if (!b.ok) throw new Error(b.error);
132 bobRw = createToken(app.db, b.value.id, "rw", "rw").token;
133 const created = await createRepo(app, a.value, { name: "bakery", visibility: "private" });
134 if (!created.ok) throw new Error(String(created.error));
135 repo = created.value;
136 const seeded = await commitToBranch(app, a.value, repo, {
137 message: "Start",
138 files: [{ path: "README.md", content: "# bakery\n" }],
139 });
140 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
141 });
142
143 afterAll(async () => {
144 servers.stop();
145 app.close();
146 await rm(dataDir, { recursive: true, force: true });
147 });
148
149 test("without confirm it is a dry run: the issues are shown and none opens", async () => {
150 const res = await post({ plan: bakery });
151 expect(res.status).toBe(200);
152 const out = (await res.json()) as { status: string; issues: { title: string; blockedBy: string[] }[]; spawn: { direction: string } };
153 expect(out.status).toBe("dry-run");
154 expect(out.issues.map((i) => i.title)).toEqual([
155 "App orders — Customers place and track orders",
156 "App schedule — The bakers see what to bake when",
157 "Adapter for Stripe — Card payments and refunds",
158 ]);
159 expect(out.issues[0]!.blockedBy).toEqual(["adapter1"]);
160 expect(out.spawn.direction).toBe(bakery.description);
161 expect(issues().length).toBe(0);
162 });
163
164 test("a refused plan opens nothing", async () => {
165 const res = await post({ plan: { ...bakery, edges: [...bakery.edges, { from: "Stripe", waitsFor: "schedule" }] }, confirm: true });
166 expect(res.status).toBe(422);
167 expect(((await res.json()) as { error: string }).error).toBe("cycle");
168 expect(issues().length).toBe(0);
169 });
170
171 test("confirming opens the issues and links, unassigned; confirming twice opens no duplicates", async () => {
172 const res = await post({ plan: bakery, confirm: true });
173 expect(res.status).toBe(201);
174 const out = (await res.json()) as { status: string; opened: { key: string; number: number }[]; ready: number[] };
175 expect(out.status).toBe("opened");
176 expect(out.opened.length).toBe(3);
177 const all = issues();
178 expect(all.length).toBe(3);
179 expect(all.every((i) => i.assigneeId === null)).toBe(true);
180 const n = new Map(out.opened.map((o) => [o.key, o.number]));
181 const byNumber = new Map(all.map((i) => [i.number, i]));
182 const blockers = blockerNumbersFor(app.db, all.map((i) => i.id));
183 expect(blockers.get(byNumber.get(n.get("app1")!)!.id)).toEqual([n.get("adapter1")!]);
184 expect(blockers.get(byNumber.get(n.get("app2")!)!.id)).toEqual([n.get("app1")!]);
185 expect(out.ready).toEqual([n.get("adapter1")!]);
186
187 // Respaced and reordered, it is the same plan.
188 const again = await post({
189 plan: { ...bakery, description: ` ${bakery.description} `, apps: [...bakery.apps].reverse() },
190 confirm: true,
191 });
192 expect(again.status).toBe(200);
193 const second = (await again.json()) as { status: string; numbers: number[] };
194 expect(second.status).toBe("already");
195 expect(second.numbers.sort()).toEqual([...n.values()].sort());
196 expect(issues().length).toBe(3);
197 });
198
199 test("a body over the cap is refused by the bytes read, even chunked with no length", async () => {
200 const big = JSON.stringify({ plan: { ...bakery, description: "x".repeat(80 * 1024) } });
201 const res = await fetch(`${servers.url}/api/v1/repos/alice/bakery/atlas`, {
202 method: "POST",
203 headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" },
204 body: new ReadableStream({
205 start(c) {
206 const bytes = new TextEncoder().encode(big);
207 for (let i = 0; i < bytes.length; i += 8192) c.enqueue(bytes.slice(i, i + 8192));
208 c.close();
209 },
210 }),
211 });
212 expect(res.status).toBe(413);
213 expect(((await res.json()) as { error: string }).error).toBe("too_large");
214 });
215
216 test("a pillar the repository does not have is refused at the door", async () => {
217 const res = await post({ plan: bakery, pillar: "P9 Nothing" });
218 expect(res.status).toBe(422);
219 expect(((await res.json()) as { error: string }).error).toBe("unknown_pillar");
220 expect((await post({ plan: bakery, pillar: 7 })).status).toBe(422);
221 });
222
223 test("someone who may only read gets 403 on confirm, and nothing opens", async () => {
224 const market = await another("market", "public");
225 const res = await post({ plan: bakery, confirm: true }, "market", bobRw);
226 expect(res.status).toBe(403);
227 expect(issues(market).length).toBe(0);
228 });
229
230 test("two confirms at once open the plan once", async () => {
231 const twin = await another("twin");
232 const [a, b] = await Promise.all([
233 post({ plan: bakery, confirm: true }, "twin"),
234 post({ plan: bakery, confirm: true }, "twin"),
235 ]);
236 expect([a.status, b.status].sort()).toEqual([200, 201]);
237 expect(issues(twin).length).toBe(3);
238 });
239
240 test("where plans need a nod, confirming proposes once and confirming twice proposes nothing more", async () => {
241 const nod = await another("nod");
242 app.db.query("UPDATE repo SET plans_need_nod = 1 WHERE id = ?1").run(nod.id);
243 const first = await post({ plan: bakery, confirm: true }, "nod");
244 expect(first.status).toBe(202);
245 const proposed = (await first.json()) as { status: string; id: string };
246 expect(proposed.status).toBe("proposed");
247 const again = await post({ plan: bakery, confirm: true }, "nod");
248 expect(again.status).toBe(200);
249 expect(await again.json()).toMatchObject({ status: "already-proposed", id: proposed.id });
250 const count = app.db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM plan_proposal WHERE repo_id = ?1").get(nod.id);
251 expect(count?.n).toBe(1);
252 expect(issues(nod).length).toBe(0);
253 });
254
255 test("an issue that only quotes the stamp line is not taken for an earlier confirm", async () => {
256 const quoted = await another("quoted");
257 const dry = (await (await post({ plan: bakery }, "quoted")).json()) as { stamp: string };
258 createIssue(app.db, {
259 repoId: quoted.id,
260 authorId: alice.id,
261 title: "Someone pasted the stamp",
262 body: `Look at Atlas plan \`${dry.stamp}\` — what is it?`,
263 });
264 const res = await post({ plan: bakery, confirm: true }, "quoted");
265 expect(res.status).toBe(201);
266 expect(issues(quoted).length).toBe(4);
267 });
268
269 test("the MCP door: atlas dry-runs, then opens once, with the API's checks", async () => {
270 const viaMcp = await another("viamcp");
271 let id = 1;
272 const call = async (args: Record<string, unknown>) => {
273 const res = await fetch(`${servers.url}/mcp`, {
274 method: "POST",
275 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${rw}` },
276 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "atlas", arguments: args } }),
277 });
278 const out = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
279 const text = out.result.content[0]!.text;
280 return out.result.isError === true ? { error: text } : { value: JSON.parse(text) as { status: string } };
281 };
282 expect((await call({ app: "alice/viamcp", plan: bakery })).value?.status).toBe("dry-run");
283 expect(issues(viaMcp).length).toBe(0);
284 expect((await call({ app: "alice/viamcp", plan: bakery, pillar: "P9" })).error).toContain("pillars");
285 expect((await call({ app: "alice/viamcp", plan: bakery, confirm: true })).value?.status).toBe("opened");
286 expect((await call({ app: "alice/viamcp", plan: bakery, confirm: true })).value?.status).toBe("already");
287 expect(issues(viaMcp).length).toBe(3);
288 });
289 });
test/conversation-confirm.test.ts +1 −1
@@ -120,7 +120,7 @@ describe("the one list of what asks first", () => {
120120 // Writes that do not ask first, on purpose: each reads, plans, or only changes the caller's own
121121 // settings and can be put back. A new `writes: true` tool is on neither list until someone decides.
122122 const NOT_ASKED = new Set([
123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide",
123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", "atlas",
124124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126126 "follow_mode", "fork", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "maintenance", "make_it_yours", "map", "merge", "new_app",
test/mcp.test.ts +1
@@ -348,6 +348,7 @@ describe("the forge over MCP", () => {
348348 "attach_look",
349349 "hold",
350350 "plan",
351 "atlas",
351352 "access",
352353 "capacity",
353354 "scorecard",
test/tui-atlas.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * Atlas in the terminal (#439): the door the API has. A writer opens the
3 * repository's "Atlas plan" command, pastes the body the API takes, sees the
4 * dry run with nothing opened, and opens the issues with a second ctrl+d —
5 * once, however often it is pressed. A reader is offered no command and is
6 * refused if the step is reached anyway; a pillar the repository lacks is
7 * refused here as at the API.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { createApp, type App as Forge } from "../src/app.ts";
15 import { listIssues } from "../src/core/issues.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { atlasCommands, atlasStep } from "../src/tui/screens/atlas.ts";
20 import { Session, type TuiIdentity } from "../src/tui/session.ts";
21 import type { Ctx } from "../src/tui/app/app.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { promote } from "./helpers/members.ts";
25 import { settleApp } from "./helpers/tui.ts";
26
27 quietLogs();
28
29 const body = {
30 plan: {
31 description: "A bakery that takes orders online and is paid by card.",
32 apps: [{ slug: "orders", purpose: "Customers place and track orders" }],
33 adapters: [{ system: "Stripe", data: "Card payments" }],
34 edges: [{ from: "orders", waitsFor: "Stripe" }],
35 },
36 };
37
38 describe("Atlas in the terminal", () => {
39 let dataDir: string;
40 let forge: Forge;
41 let ada: User;
42 let bob: User;
43 let repo: Repo;
44 const issues = () => listIssues(forge.db, repo.id, {});
45 const session = (user: User) => new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
46
47 function boot(user: User) {
48 const terminal = headlessTerminal(120, 32);
49 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
50 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
51 app.start();
52 const key = (k: string) => {
53 const ctrl = k.startsWith("ctrl+");
54 app.dispatch({ type: "key", key: ctrl ? k.slice(5) : k, ctrl, alt: false, shift: false });
55 };
56 const press = async (...keys: string[]) => {
57 for (const k of keys) key(k);
58 await settleApp(app);
59 };
60 const type = async (text: string) => {
61 for (const ch of text) key(ch);
62 await settleApp(app);
63 };
64 const paste = async (text: string) => {
65 app.dispatch({ type: "paste", text });
66 await settleApp(app);
67 };
68 const open = async () => {
69 await settleApp(app);
70 await press("ctrl+k");
71 await type("Open a repository");
72 await press("enter");
73 await type("ada/shop");
74 await press("enter");
75 await press("ctrl+k");
76 await type("Atlas plan");
77 await press("enter");
78 };
79 return { text: () => app.screen.toText(), press, paste, open, stop: () => app.stop() };
80 }
81
82 beforeAll(async () => {
83 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-atlas-"));
84 forge = createApp({ dataDir, port: 0 });
85 const make = async (username: string, adminIfFirst: boolean): Promise<User> => {
86 const made = await createUser(forge.db, {
87 origin: "seed",
88 trusted: true,
89 username,
90 email: `${username}@forge.local`,
91 password: "a-good-passphrase",
92 adminIfFirst,
93 });
94 if (!made.ok) throw new Error(made.error);
95 return made.value;
96 };
97 ada = await make("ada", true);
98 bob = await make("bob", false);
99 promote(forge.db, ada, bob);
100 const made = await createRepo(forge, ada, { name: "shop", visibility: "public", description: "" });
101 if (!made.ok) throw new Error(JSON.stringify(made.error));
102 repo = made.value;
103 }, 60_000);
104
105 afterAll(async () => {
106 forge?.close();
107 if (dataDir) await rm(dataDir, { recursive: true, force: true });
108 });
109
110 test("a reader is offered no Atlas command, and the step refuses them", () => {
111 const f = session(bob);
112 expect(atlasCommands(f, {} as Ctx, repo)).toEqual([]);
113 expect(atlasStep(f, repo, JSON.stringify(body)).kind).toBe("error");
114 expect(atlasCommands(session(ada), {} as Ctx, repo).map((c) => c.id)).toEqual(["atlas-plan"]);
115 });
116
117 test("a pillar the repository lacks is refused, as at the API", () => {
118 const step = atlasStep(session(ada), repo, JSON.stringify({ ...body, pillar: "P9 Nothing" }));
119 expect(step).toMatchObject({ kind: "error" });
120 expect(issues().length).toBe(0);
121 });
122
123 test("a writer previews, then opens the plan once", async () => {
124 const h = boot(ada);
125 await h.open();
126 await h.paste(JSON.stringify(body));
127 await h.press("ctrl+d");
128 expect(h.text()).toContain("Dry run: 2 issues");
129 expect(h.text()).toContain("App orders");
130 expect(issues().length).toBe(0);
131 await h.press("ctrl+d");
132 expect(h.text()).toContain("opened #");
133 expect(issues().length).toBe(2);
134 // Again: the same plan is previewed and then found, not opened twice.
135 await h.press("ctrl+d", "ctrl+d");
136 expect(h.text()).toContain("already opened");
137 expect(issues().length).toBe(2);
138 h.stop();
139 });
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs