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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/439-prompt-a-business-atlas-turns-a-descript → main 5 commits fork 3 threads · 3 unresolved
15 files
changed +1131
−1
docs/PLANNING.md +7
| @@ -656,3 +656,10 @@ then store one proposal — title, why, the ordered issues with their | ||
| 656 | 656 | Off, the plan opens its linked issues all or none. A person's nod opens a |
| 657 | 657 | stored proposal: `approve` in `src/actions/proposals.ts`, from the inbox card |
| 658 | 658 | 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 | [](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 { | |
| 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 | ||
| 269 | 269 | export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1ReposOwnerNameAppNotesIdHide.ts"; |
| 270 | 270 | export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts"; |
| 271 | 271 | export { route as postApiV1ReposOwnerNameAppPull } from "./postApiV1ReposOwnerNameAppPull.ts"; |
| 272 | export { route as postApiV1ReposOwnerNameAtlas } from "./postApiV1ReposOwnerNameAtlas.ts"; | |
| 272 | 273 | export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts"; |
| 273 | 274 | export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts"; |
| 274 | 275 | 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"; | ||
| 348 | 348 | import { followingRoutes } from "./api/following.ts"; |
| 349 | 349 | import { labelsRoutes } from "./api/labels.ts"; |
| 350 | 350 | import { visionRoutes } from "./api/vision.ts"; |
| 351 | import { businessPlanRoutes } from "./api/business_plan.ts"; | |
| 351 | 352 | import { tokenRoutes } from "./api/tokens.ts"; |
| 352 | 353 | import { accountAdmin, adminRoutes } from "./api/admin.ts"; |
| 353 | 354 | import { maintenanceRoutes } from "./api/maintenance.ts"; |
| @@ -2083,6 +2084,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2083 | 2084 | |
| 2084 | 2085 | labelsRoutes(router); |
| 2085 | 2086 | visionRoutes(router); |
| 2087 | businessPlanRoutes(router); | |
| 2086 | 2088 | router.add( |
| 2087 | 2089 | "PUT", |
| 2088 | 2090 | "/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 |
||
| 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"; | ||
| 22 | 22 | export { tool as asks_undecide } from "./asks_undecide.ts"; |
| 23 | 23 | export { tool as assign } from "./assign.ts"; |
| 24 | 24 | export { tool as assigned } from "./assigned.ts"; |
| 25 | export { tool as atlas } from "./atlas.ts"; | |
| 25 | 26 | export { tool as attach_look } from "./attach_look.ts"; |
| 26 | 27 | export { tool as audit } from "./audit.ts"; |
| 27 | 28 | 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"; | ||
| 142 | 142 | import { autonomyCommands } from "./autonomy.ts"; |
| 143 | 143 | import { trafficCommands } from "./trafficview.ts"; |
| 144 | 144 | import { adapterCommands } from "./adapter.ts"; |
| 145 | import { atlasCommands } from "./atlas.ts"; | |
| 145 | 146 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 146 | 147 | import { finderScreen } from "./finder.ts"; |
| 147 | 148 | import { issuesScreen } from "./issues.ts"; |
| @@ -1464,6 +1465,7 @@ export function workspaceScreen( | ||
| 1464 | 1465 | ...autonomyCommands(f, ctx, repo), |
| 1465 | 1466 | ...trafficCommands(f, ctx, repo), |
| 1466 | 1467 | ...adapterCommands(f, ctx, repo), |
| 1468 | ...atlasCommands(f, ctx, repo), | |
| 1467 | 1469 | ], |
| 1468 | 1470 | get title() { |
| 1469 | 1471 | 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", () => { | ||
| 120 | 120 | // Writes that do not ask first, on purpose: each reads, plans, or only changes the caller's own |
| 121 | 121 | // settings and can be put back. A new `writes: true` tool is on neither list until someone decides. |
| 122 | 122 | 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", | |
| 124 | 124 | "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner", |
| 126 | 126 | "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", () => { | ||
| 348 | 348 | "attach_look", |
| 349 | 349 | "hold", |
| 350 | 350 | "plan", |
| 351 | "atlas", | |
| 351 | 352 | "access", |
| 352 | 353 | "capacity", |
| 353 | 354 | "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 | }); |
The plansNeedNod path skips the stamp check, so confirming twice stores two proposals. Apply the duplicate check here too.