Public: anyone can read this; the crew can change it.
With 'plans need a nod' on, POST …/plan stores one proposal and opens no issues #1874
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1813-with-plans-need-a-nod-on-post-plan-store → main 8 commits fork 7 threads · 7 unresolved
docs/PLANNING.md +11
| @@ -565,3 +565,14 @@ not confirm its own plan. Split also moves the parent's blocked-by edges to | ||
| 565 | 565 | the issues named in `into` (a child that would make a loop is skipped), and |
| 566 | 566 | the parent waits for nothing. Reopening clears the reason. Closes that |
| 567 | 567 | predate reasons count as done. |
| 568 | ||
| 569 | ## Plans need a nod | |
| 570 | ||
| 571 | A repository setting, `plansNeedNod` (PATCH `/api/v1/repos/:owner/:name`, | |
| 572 | admin only, never an agent), off by default: nothing reads a stored proposal | |
| 573 | yet, so on would hide the planner's work. On, `POST …/plan` and the MCP | |
| 574 | `plan` tool run every check as ever (`no_pillar`, loops, unknown keys) and | |
| 575 | then store one proposal — title, why, the ordered issues with their | |
| 576 | `blockedBy` — answering `proposed` with its id (HTTP 202). No issue is opened. | |
| 577 | Off, the plan opens its linked issues all or none. A person's nod that opens | |
outdated
If the nod isn't built, the default can't be on. As written, plans disappear into a table nobody can see. |
||
| 578 | a stored proposal is not built yet. |
src/actions/plan.ts +73 −2
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | import { canRead, canWrite, type Repo } from "../core/repos.ts"; |
| 26 | 26 | import type { User } from "../core/users.ts"; |
| 27 | 27 | import { transact } from "../kernel/db.ts"; |
| 28 | import { id as newId } from "../kernel/id.ts"; | |
| 28 | 29 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 29 | 30 | import { isMirror } from "../origins/write-through.ts"; |
| 30 | 31 | import { followLink } from "./pillars.ts"; |
| @@ -186,12 +187,22 @@ function loopAt(items: readonly PlanItem[]): string | undefined { | ||
| 186 | 187 | return undefined; |
| 187 | 188 | } |
| 188 | 189 | |
| 189 | export function openPlan( | |
| 190 | /** What a good plan was checked against, for opening it. */ | |
| 191 | interface Vetted { | |
| 192 | readonly actor: User; | |
| 193 | readonly existing: Map<number, Issue>; | |
| 194 | readonly serves: Map<string, Pillar>; | |
| 195 | readonly gate: Issue | undefined; | |
| 196 | readonly later: Issue | undefined; | |
| 197 | } | |
| 198 | ||
| 199 | /** Every refusal a plan can meet, before anything is written. */ | |
| 200 | function vet( | |
| 190 | 201 | app: App, |
| 191 | 202 | actor: User | undefined, |
| 192 | 203 | repo: Repo, |
| 193 | 204 | items: readonly PlanItem[], |
| 194 | ): Result<Planned, PlanError> { | |
| 205 | ): Result<Vetted, PlanError> { | |
| 195 | 206 | if (!actor || !canRead(app.db, repo, actor)) |
| 196 | 207 | return refuse("not_found", "no such app or repository (owner/name)"); |
| 197 | 208 | if (!canWrite(app.db, repo, actor) && !isCrew(app.db, repo.id, actor.id)) |
| @@ -258,6 +269,66 @@ export function openPlan( | ||
| 258 | 269 | `"${item.key}" names no pillar: give it ${namesOf(pillars)}, or later: true and why`, |
| 259 | 270 | ); |
| 260 | 271 | } |
| 272 | return ok({ actor, existing, serves, gate, later }); | |
| 273 | } | |
| 274 | ||
| 275 | export interface Proposed { | |
| 276 | readonly status: "proposed"; | |
| 277 | readonly id: string; | |
| 278 | } | |
| 279 | ||
| 280 | /** | |
| 281 | * "Plans need a nod": the same checks as `openPlan`, then the plan is kept | |
| 282 | * as one proposal (title, why, the ordered issues with their blockedBy) and | |
| 283 | * no issue is opened. A person's nod opens it. | |
| 284 | */ | |
| 285 | export function proposePlan( | |
Nothing caps how many proposals a repo or an author can keep, and nothing ever closes one. Cap the open proposals per repo, or replace the author's previous one. |
||
| 286 | app: App, | |
| 287 | actor: User | undefined, | |
| 288 | repo: Repo, | |
| 289 | items: readonly PlanItem[], | |
| 290 | say: { title?: unknown; why?: unknown } = {}, | |
Minor: title and why are checked before |
||
| 291 | ): Result<Proposed, PlanError> { | |
| 292 | const title = typeof say.title === "string" ? say.title.trim() : ""; | |
| 293 | if (say.title !== undefined && typeof say.title !== "string") | |
| 294 | return refuse("invalid", "title must be text"); | |
| 295 | if (title.length > MAX_TITLE) | |
| 296 | return refuse("title_too_long", `title is over ${MAX_TITLE} characters`); | |
| 297 | if (say.why !== undefined && typeof say.why !== "string") | |
| 298 | return refuse("invalid", "why must be text"); | |
| 299 | const why = ((say.why ?? "") as string).trim(); | |
| 300 | if (why.length > MAX_BODY) | |
| 301 | return refuse("body_too_long", `why is over ${MAX_BODY} characters`); | |
| 302 | const vetted = vet(app, actor, repo, items); | |
| 303 | if (!vetted.ok) return vetted; | |
| 304 | const id = newId(); | |
| 305 | app.db | |
| 306 | .query( | |
| 307 | `INSERT INTO plan_proposal (id, repo_id, author_id, title, why, issues, created_at) | |
| 308 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`, | |
| 309 | ) | |
| 310 | .run( | |
| 311 | id, | |
| 312 | repo.id, | |
| 313 | vetted.value.actor.id, | |
| 314 | title === "" ? items[0]!.title : title, | |
| 315 | why, | |
| 316 | JSON.stringify(items), | |
| 317 | new Date().toISOString(), | |
| 318 | ); | |
| 319 | return ok({ status: "proposed", id }); | |
| 320 | } | |
| 321 | ||
| 322 | export function openPlan( | |
| 323 | app: App, | |
| 324 | actor: User | undefined, | |
| 325 | repo: Repo, | |
| 326 | items: readonly PlanItem[], | |
| 327 | ): Result<Planned, PlanError> { | |
| 328 | const vetted = vet(app, actor, repo, items); | |
| 329 | if (!vetted.ok) return vetted; | |
| 330 | const { existing, serves, gate, later } = vetted.value; | |
| 331 | actor = vetted.value.actor; | |
| 261 | 332 | |
| 262 | 333 | const opened: { key: string; number: number }[] = []; |
| 263 | 334 | /** The links an item's purpose adds: [the issue that waits, what it waits for]. */ |
src/actions/repos.ts +3 −1
| @@ -249,7 +249,8 @@ export async function updateRepoSettings( | ||
| 249 | 249 | // one on a team that administers the repository (`crew/direct.ts`). |
| 250 | 250 | if ( |
| 251 | 251 | (settings.crewOutsiders !== undefined || |
| 252 | settings.decisionsBy !== undefined) && | |
| 252 | settings.decisionsBy !== undefined || | |
| 253 | settings.plansNeedNod !== undefined) && | |
This new refusal of an agent has no test. Add one where an agent's PATCH of |
||
| 253 | 254 | admin.value.kind === "agent" |
| 254 | 255 | ) |
| 255 | 256 | return err("not_found"); |
| @@ -276,6 +277,7 @@ export async function updateRepoSettings( | ||
| 276 | 277 | "requireChecks", |
| 277 | 278 | "crewOutsiders", |
| 278 | 279 | "decisionsBy", |
| 280 | "plansNeedNod", | |
| 279 | 281 | ] as const |
| 280 | 282 | ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]); |
| 281 | 283 | app.bus.append({ |
src/core/migrations/20261015000100-plans-need-a-nod.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | // plans need a nod (#1813): with the setting on, a plan is stored as | |
| 2 | // one proposal (its ordered issues as JSON) and opens nothing until a | |
| 3 | // person nods. Off is the default until a person can read and nod a stored proposal. | |
| 4 | export default ` | |
| 5 | ALTER TABLE repo ADD COLUMN plans_need_nod INTEGER NOT NULL DEFAULT 0; | |
outdated
High: DEFAULT 1 turns this on for every existing repository, and no nod exists yet. Every plan from then on is stored where nothing reads it. Use DEFAULT 0 until a proposal can be seen and opened, or build the nod in this pull.
outdated
High: |
||
| 6 | CREATE TABLE plan_proposal ( | |
| 7 | id TEXT PRIMARY KEY, | |
| 8 | repo_id TEXT NOT NULL, | |
| 9 | author_id TEXT NOT NULL, | |
| 10 | title TEXT NOT NULL, | |
| 11 | why TEXT NOT NULL, | |
| 12 | issues TEXT NOT NULL, | |
| 13 | created_at TEXT NOT NULL | |
| 14 | ); | |
| 15 | CREATE INDEX plan_proposal_repo ON plan_proposal(repo_id, created_at); | |
| 16 | `; |
src/core/repos.ts +8 −1
| @@ -118,6 +118,8 @@ export interface Repo { | ||
| 118 | 118 | readonly crewOutsiders: boolean; |
| 119 | 119 | /** The standing rule for decisions here: `me`, `decider`, or `decider_tell`. */ |
| 120 | 120 | readonly decisionsBy: DecisionsBy; |
| 121 | /** "Plans need a nod": a plan is stored as a proposal, not opened. */ | |
| 122 | readonly plansNeedNod: boolean; | |
| 121 | 123 | /** The issue the planner is given, when the map has run dry. */ |
| 122 | 124 | readonly directionIssueId: string | null; |
| 123 | 125 | /** The repo this was forked from, or null. */ |
| @@ -163,6 +165,7 @@ interface RepoRow { | ||
| 163 | 165 | autonomy_by: string | null; |
| 164 | 166 | crew_outsiders: number; |
| 165 | 167 | decisions_by: string; |
| 168 | plans_need_nod: number; | |
| 166 | 169 | direction_issue_id: string | null; |
| 167 | 170 | fork_parent_id: string | null; |
| 168 | 171 | sealed_for: string | null; |
| @@ -201,6 +204,7 @@ function toRepo(row: RepoRow): Repo { | ||
| 201 | 204 | autonomyBy: row.autonomy_by, |
| 202 | 205 | crewOutsiders: row.crew_outsiders === 1, |
| 203 | 206 | decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me", |
| 207 | plansNeedNod: row.plans_need_nod === 1, | |
| 204 | 208 | directionIssueId: row.direction_issue_id, |
| 205 | 209 | forkParentId: row.fork_parent_id, |
| 206 | 210 | sealedFor: row.sealed_for, |
| @@ -1036,6 +1040,7 @@ export interface RepoSettings { | ||
| 1036 | 1040 | /** Whether someone who cannot write here may start or steer a run. */ |
| 1037 | 1041 | crewOutsiders?: boolean; |
| 1038 | 1042 | decisionsBy?: DecisionsBy; |
| 1043 | plansNeedNod?: boolean; | |
| 1039 | 1044 | } |
| 1040 | 1045 | |
| 1041 | 1046 | /** Update the mutable repo fields, leaving unspecified ones untouched. */ |
| @@ -1048,7 +1053,8 @@ export function updateRepoSettings( | ||
| 1048 | 1053 | `UPDATE repo SET visibility = ?1, description = ?2, protect_default = ?3, |
| 1049 | 1054 | default_branch = ?4, require_review = ?5, require_checks = ?7, |
| 1050 | 1055 | autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10, |
| 1051 | autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14 | |
| 1056 | autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14, | |
| 1057 | plans_need_nod = ?15 | |
| 1052 | 1058 | WHERE id = ?6`, |
| 1053 | 1059 | ).run( |
| 1054 | 1060 | settings.visibility ?? repo.visibility, |
| @@ -1065,6 +1071,7 @@ export function updateRepoSettings( | ||
| 1065 | 1071 | settings.autonomyBy === undefined ? repo.autonomyBy : settings.autonomyBy, |
| 1066 | 1072 | (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0, |
| 1067 | 1073 | settings.decisionsBy ?? repo.decisionsBy, |
| 1074 | (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0, | |
| 1068 | 1075 | ); |
| 1069 | 1076 | return getRepoById(db, repo.id)!; |
| 1070 | 1077 | } |
src/http/api.ts +21 −8
| @@ -277,7 +277,7 @@ import { | ||
| 277 | 277 | unlinkIssue, |
| 278 | 278 | confirmIssue, |
| 279 | 279 | } from "../actions/dependencies.ts"; |
| 280 | import { openPlan, parsePlan } from "../actions/plan.ts"; | |
| 280 | import { openPlan, parsePlan, proposePlan } from "../actions/plan.ts"; | |
| 281 | 281 | import { dependenciesOf, repoGraph } from "../core/dependencies.ts"; |
| 282 | 282 | import { pillarView } from "../core/pillars.ts"; |
| 283 | 283 | import { askToLiveFor } from "../web/ask_to_live.ts"; |
| @@ -1820,6 +1820,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1820 | 1820 | 422, |
| 1821 | 1821 | "decisionsBy is me, decider or decider_tell", |
| 1822 | 1822 | ); |
| 1823 | if (input.plansNeedNod !== undefined && typeof input.plansNeedNod !== "boolean") | |
| 1824 | return apiError("bad_plans_need_nod", 422, "plansNeedNod is true or false"); | |
| 1823 | 1825 | const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, { |
| 1824 | 1826 | ...(input.visibility === "public" || input.visibility === "private" |
| 1825 | 1827 | ? { visibility: input.visibility } |
| @@ -1847,6 +1849,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1847 | 1849 | ...(isDecisionsBy(input.decisionsBy) |
| 1848 | 1850 | ? { decisionsBy: input.decisionsBy } |
| 1849 | 1851 | : {}), |
outdated
A non-boolean value is dropped silently here. Refuse it with a 422, the way |
||
| 1852 | // Whether a plan waits for a nod: an admin's, never an agent's. | |
| 1853 | ...(typeof input.plansNeedNod === "boolean" | |
| 1854 | ? { plansNeedNod: input.plansNeedNod } | |
| 1855 | : {}), | |
| 1850 | 1856 | }); |
| 1851 | 1857 | if (!updated.ok) return respondAccess(updated.error, 422); |
| 1852 | 1858 | return json(repoJson(ctx, updated.value)); |
| @@ -2611,15 +2617,22 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2611 | 2617 | const items = parsePlan(input.issues); |
| 2612 | 2618 | if (!items.ok) |
| 2613 | 2619 | return apiError(items.error.error, 422, items.error.message); |
| 2614 | const planned = openPlan(ctx.app, ctx.auth.user, repo, items.value); | |
| 2615 | if (!planned.ok) { | |
| 2616 | const { error, message } = planned.error; | |
| 2617 | return apiError( | |
| 2618 | error, | |
| 2619 | error === "not_found" ? 404 : error === "forbidden" ? 403 : 422, | |
| 2620 | message, | |
| 2620 | const failed = (e: { error: string; message: string }) => | |
| 2621 | apiError( | |
| 2622 | e.error, | |
| 2623 | e.error === "not_found" ? 404 : e.error === "forbidden" ? 403 : 422, | |
| 2624 | e.message, | |
| 2621 | 2625 | ); |
| 2626 | // With "plans need a nod" the plan is stored, not opened. | |
| 2627 | if (repo.plansNeedNod) { | |
| 2628 | const proposed = proposePlan(ctx.app, ctx.auth.user, repo, items.value, { | |
| 2629 | title: input.title, | |
| 2630 | why: input.why, | |
| 2631 | }); | |
| 2632 | return proposed.ok ? json(proposed.value, 202) : failed(proposed.error); | |
| 2622 | 2633 | } |
| 2634 | const planned = openPlan(ctx.app, ctx.auth.user, repo, items.value); | |
| 2635 | if (!planned.ok) return failed(planned.error); | |
| 2623 | 2636 | return json(planned.value, 201); |
| 2624 | 2637 | }); |
| 2625 | 2638 | router.add( |
src/http/mcp.ts +16 −2
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | unlinkIssue, |
| 40 | 40 | } from "../actions/dependencies.ts"; |
| 41 | 41 | import { holdPull, releasePull } from "../actions/holds.ts"; |
| 42 | import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts"; | |
| 42 | import { openPlan, parsePlan, PLAN_MAX, proposePlan } from "../actions/plan.ts"; | |
| 43 | 43 | import { cancelRepoRun } from "../actions/runs.ts"; |
| 44 | 44 | import { familyView } from "../spawn/family.ts"; |
| 45 | 45 | import { clientRows } from "../spawn/clients.ts"; |
| @@ -1897,11 +1897,13 @@ const TOOLS: readonly Tool[] = [ | ||
| 1897 | 1897 | }, |
| 1898 | 1898 | { |
| 1899 | 1899 | name: "plan", |
| 1900 | description: `Open several small issues and link them in one call: all of them or none. Each item has a key, a title, a body and optionally blockedBy, a list of keys of this plan or numbers of issues already open. Where the app has pillars (umbrella issues titled like "P0 Trust: …"), each item also says what it is for: pillar, the one it serves, or later: true with why, one sentence, which puts it behind the Later gate instead of on the ready list; an item that says neither is refused. A bad link (loop, unknown key) refuses the whole plan and names the item. At most ${PLAN_MAX} issues. Answers with the numbers opened, by key, what is ready now, and what waits for later.`, | |
| 1900 | description: `Open several small issues and link them in one call: all of them or none. Each item has a key, a title, a body and optionally blockedBy, a list of keys of this plan or numbers of issues already open. Where the app has pillars (umbrella issues titled like "P0 Trust: …"), each item also says what it is for: pillar, the one it serves, or later: true with why, one sentence, which puts it behind the Later gate instead of on the ready list; an item that says neither is refused. A bad link (loop, unknown key) refuses the whole plan and names the item. At most ${PLAN_MAX} issues. Answers with the numbers opened, by key, what is ready now, and what waits for later. Where the app has "plans need a nod" on, the same checks run and one proposal is stored (optional title and why for the whole plan); nothing is opened, and it answers status proposed with the proposal id.`, | |
| 1901 | 1901 | inputSchema: { |
| 1902 | 1902 | type: "object", |
| 1903 | 1903 | properties: { |
| 1904 | 1904 | app: APP_ARG, |
| 1905 | title: { type: "string", maxLength: 256 }, | |
| 1906 | why: { type: "string", maxLength: 65536 }, | |
| 1905 | 1907 | issues: { |
| 1906 | 1908 | type: "array", |
| 1907 | 1909 | minItems: 1, |
| @@ -1948,6 +1950,18 @@ const TOOLS: readonly Tool[] = [ | ||
| 1948 | 1950 | if (isAnswer(repo)) return repo; |
| 1949 | 1951 | const items = parsePlan(args["issues"]); |
| 1950 | 1952 | if (!items.ok) return bad(items.error.error, items.error.message); |
| 1953 | if (repo.plansNeedNod) { | |
| 1954 | const proposed = proposePlan(ctx.app, user, repo, items.value, { | |
| 1955 | title: args["title"], | |
| 1956 | why: args["why"], | |
| 1957 | }); | |
| 1958 | if (!proposed.ok) | |
| 1959 | return bad(proposed.error.error, proposed.error.message); | |
| 1960 | return good({ | |
| 1961 | says: `Proposed plan ${proposed.value.id}: nothing is opened until a person nods.`, | |
| 1962 | ...proposed.value, | |
| 1963 | }); | |
| 1964 | } | |
| 1951 | 1965 | const planned = openPlan(ctx.app, user, repo, items.value); |
| 1952 | 1966 | if (!planned.ok) return bad(planned.error.error, planned.error.message); |
| 1953 | 1967 | const { opened, ready, later, gate } = planned.value; |
test/crew-direction.test.ts +19
| @@ -145,6 +145,25 @@ afterAll(async () => { | ||
| 145 | 145 | await rm(dataDir, { recursive: true, force: true }); |
| 146 | 146 | }); |
| 147 | 147 | |
| 148 | describe("plans need a nod setting", () => { | |
| 149 | test("an agent's PATCH of it is refused and the setting stays; a person's works", async () => { | |
| 150 | const own = await createRepoRow(app.db, app.reposRoot, { | |
| 151 | ownerId: bot.user.id, | |
| 152 | name: "botplans", | |
| 153 | visibility: "public", | |
| 154 | }); | |
| 155 | if (!own.ok) throw new Error(String(own.error)); | |
| 156 | const refused = await updateRepoSettings(app, bot.user, own.value, { plansNeedNod: true }); | |
| 157 | expect(refused.ok).toBe(false); | |
| 158 | if (!refused.ok) expect(refused.error).toBe("not_found"); | |
| 159 | expect(getRepoById(app.db, own.value.id)!.plansNeedNod).toBe(false); | |
| 160 | const mine = await updateRepoSettings(app, alice, fresh(), { plansNeedNod: true }); | |
| 161 | expect(mine.ok).toBe(true); | |
| 162 | expect(fresh().plansNeedNod).toBe(true); | |
| 163 | await updateRepoSettings(app, alice, fresh(), { plansNeedNod: false }); | |
| 164 | }); | |
| 165 | }); | |
| 166 | ||
| 148 | 167 | describe("the rule", () => { |
| 149 | 168 | test("a writer and the crew's reviewer direct; a reader, a builder agent and nobody do not", () => { |
| 150 | 169 | expect(mayDirectCrew(app.db, repo, alice)).toBe(true); |
test/mcp.test.ts +34 −1
| @@ -14,7 +14,7 @@ import { openPull } from "../src/actions/pulls.ts"; | ||
| 14 | 14 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; |
| 15 | 15 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| 16 | 16 | import { listIssues } from "../src/core/issues.ts"; |
| 17 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 17 | import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 18 | 18 | import { createRun, listRuns, setRunBase } from "../src/core/runs.ts"; |
| 19 | 19 | import { createToken } from "../src/core/tokens.ts"; |
| 20 | 20 | import { createUser, type User } from "../src/core/users.ts"; |
| @@ -552,7 +552,40 @@ describe("the forge over MCP", () => { | ||
| 552 | 552 | expect(after.ready.map((i: any) => i.number)).toContain(b); |
| 553 | 553 | }); |
| 554 | 554 | |
| 555 | test("with plans need a nod on, the plan tool stores one proposal and opens nothing", async () => { | |
| 556 | updateRepoSettings(app.db, getRepoById(app.db, game.id)!, { plansNeedNod: true }); | |
| 557 | const count = async () => | |
| 558 | (await tool(ro, "map", { app: "alice/game" })).value.issues.length; | |
| 559 | const before = await count(); | |
| 560 | const issues = [ | |
| 561 | { key: "a", title: "First" }, | |
| 562 | { key: "b", title: "Second", blockedBy: ["a"] }, | |
| 563 | ]; | |
| 564 | const done = await tool(rw, "plan", { | |
| 565 | app: "alice/game", | |
| 566 | title: "Two steps", | |
| 567 | why: "worth a look", | |
| 568 | issues, | |
| 569 | }); | |
| 570 | expect(done.value.status).toBe("proposed"); | |
| 571 | const row = app.db | |
| 572 | .query<{ title: string; why: string; issues: string }, [string]>( | |
| 573 | "SELECT title, why, issues FROM plan_proposal WHERE id = ?1", | |
| 574 | ) | |
| 575 | .get(done.value.id)!; | |
| 576 | expect(row.title).toBe("Two steps"); | |
| 577 | expect(JSON.parse(row.issues).map((i: any) => i.key)).toEqual(["a", "b"]); | |
| 578 | expect(await count()).toBe(before); | |
| 579 | // The checks run as ever: a loop is refused and stores nothing. | |
| 580 | const loop = await tool(rw, "plan", { | |
| 581 | app: "alice/game", | |
| 582 | issues: [{ key: "x", title: "X", blockedBy: ["x"] }], | |
| 583 | }); | |
| 584 | expect(loop.error).toContain("self"); | |
| 585 | }); | |
| 586 | ||
| 555 | 587 | test("plan opens linked issues all or none", async () => { |
| 588 | updateRepoSettings(app.db, getRepoById(app.db, game.id)!, { plansNeedNod: false }); | |
| 556 | 589 | const issues = [ |
| 557 | 590 | { key: "a", title: "First" }, |
| 558 | 591 | { key: "b", title: "Second", body: "two" }, |
test/pillars.test.ts +3 −2
| @@ -40,7 +40,7 @@ import { | ||
| 40 | 40 | } from "../src/core/issues.ts"; |
| 41 | 41 | import { pillarsOf, pillarView } from "../src/core/pillars.ts"; |
| 42 | 42 | import { askToLiveFor } from "../src/web/ask_to_live.ts"; |
| 43 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 43 | import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 44 | 44 | import { createToken } from "../src/core/tokens.ts"; |
| 45 | 45 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 46 | 46 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -169,7 +169,8 @@ describe("pillars", () => { | ||
| 169 | 169 | visibility: "public", |
| 170 | 170 | }); |
| 171 | 171 | if (!made.ok) throw new Error(String(made.error)); |
| 172 | repo = made.value; | |
| 172 | // These plans open issues: plans need a nod is off (test/plan-route.test.ts covers on). | |
| 173 | repo = updateRepoSettings(app.db, made.value, { plansNeedNod: false }); | |
| 173 | 174 | const seeded = await commitToBranch(app, alice, repo, { |
| 174 | 175 | message: "Start", |
| 175 | 176 | files: [{ path: "README.md", content: "# engine\n" }], |
test/plan-pillars.test.ts +4 −3
| @@ -21,7 +21,7 @@ import { | ||
| 21 | 21 | type Issue, |
| 22 | 22 | } from "../src/core/issues.ts"; |
| 23 | 23 | import { findPillar, pillarsOf } from "../src/core/pillars.ts"; |
| 24 | import type { Repo } from "../src/core/repos.ts"; | |
| 24 | import { updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 25 | 25 | import { createToken } from "../src/core/tokens.ts"; |
| 26 | 26 | import { createUser, type User } from "../src/core/users.ts"; |
| 27 | 27 | import { systemPrompt } from "../src/crew/brief.ts"; |
| @@ -81,7 +81,8 @@ describe("a plan says what each issue is for", () => { | ||
| 81 | 81 | const repo = async (name: string): Promise<Repo> => { |
| 82 | 82 | const made = await createRepo(app, alice, { name, visibility: "private" }); |
| 83 | 83 | if (!made.ok) throw new Error(String(made.error)); |
| 84 | return made.value; | |
| 84 | // These plans open issues: plans need a nod is off (test/plan-route.test.ts covers on). | |
| 85 | return updateRepoSettings(app.db, made.value, { plansNeedNod: false }); | |
| 85 | 86 | }; |
| 86 | 87 | |
| 87 | 88 | beforeAll(async () => { |
| @@ -404,7 +405,7 @@ describe("whose issue stands for a pillar", () => { | ||
| 404 | 405 | visibility: "public", |
| 405 | 406 | }); |
| 406 | 407 | if (!made.ok) throw new Error(String(made.error)); |
| 407 | open = made.value; | |
| 408 | open = updateRepoSettings(app.db, made.value, { plansNeedNod: false }); | |
| 408 | 409 | }, 30_000); |
| 409 | 410 | |
| 410 | 411 | afterAll(async () => { |
test/plan-route.test.ts +75 −1
| @@ -8,11 +8,12 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | import { proposePlan } from "../src/actions/plan.ts"; | |
| 11 | 12 | import { openIssue } from "../src/actions/issues.ts"; |
| 12 | 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | 14 | import { listIssues } from "../src/core/issues.ts"; |
| 14 | 15 | import { pillarsOf } from "../src/core/pillars.ts"; |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 16 | 17 | import { createToken } from "../src/core/tokens.ts"; |
| 17 | 18 | import { createUser, type User } from "../src/core/users.ts"; |
| 18 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -68,7 +69,79 @@ describe("POST …/plan", () => { | ||
| 68 | 69 | await rm(dataDir, { recursive: true, force: true }); |
| 69 | 70 | }); |
| 70 | 71 | |
| 72 | const proposals = () => | |
| 73 | app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM plan_proposal").get()!.n; | |
| 74 | const nod = (on: boolean) => | |
| 75 | updateRepoSettings(app.db, getRepoById(app.db, repo.id)!, { plansNeedNod: on }); | |
| 76 | ||
| 77 | test("plans need a nod, on: one proposal is stored and no issue is opened", async () => { | |
| 78 | expect(getRepoById(app.db, repo.id)!.plansNeedNod).toBe(false); | |
| 79 | nod(true); | |
| 80 | const before = titles().length; | |
| 81 | const stored = proposals(); | |
| 82 | const res = await post(rw, [ | |
| 83 | { key: "a", title: "Prune the roses", pillar: "P0" }, | |
| 84 | { key: "b", title: "Mulch", pillar: "P0", blockedBy: ["a"] }, | |
| 85 | ]); | |
| 86 | expect(res.status).toBe(202); | |
| 87 | const body = (await res.json()) as { status: string; id: string }; | |
| 88 | expect(body.status).toBe("proposed"); | |
| 89 | expect(titles().length).toBe(before); | |
| 90 | expect(proposals()).toBe(stored + 1); | |
| 91 | const row = app.db | |
| 92 | .query<{ issues: string }, [string]>("SELECT issues FROM plan_proposal WHERE id = ?1") | |
| 93 | .get(body.id)!; | |
| 94 | expect(JSON.parse(row.issues).map((i: { key: string }) => i.key)).toEqual(["a", "b"]); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a title or why that is not text or is too long is refused and stores nothing", () => { | |
| 98 | nod(true); | |
| 99 | const r = getRepoById(app.db, repo.id)!; | |
| 100 | const stored = proposals(); | |
| 101 | const items = [{ key: "a", title: "Prune", body: "", pillar: "P0", blockedBy: [] }]; | |
| 102 | const refuse = (say: { title?: unknown; why?: unknown }) => { | |
| 103 | const out = proposePlan(app, alice, r, items, say); | |
| 104 | expect(out.ok).toBe(false); | |
| 105 | return out.ok ? "" : out.error.error; | |
| 106 | }; | |
| 107 | expect(refuse({ title: 5 })).toBe("invalid"); | |
| 108 | expect(refuse({ why: ["x"] })).toBe("invalid"); | |
| 109 | expect(refuse({ title: "x".repeat(10_000) })).toBe("title_too_long"); | |
| 110 | expect(refuse({ why: "x".repeat(1_000_000) })).toBe("body_too_long"); | |
| 111 | expect(proposals()).toBe(stored); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a non-boolean plansNeedNod in the PATCH is a 422 and the setting stays", async () => { | |
| 115 | nod(false); | |
| 116 | const res = await fetch(`${servers.url}/api/v1/repos/alice/garden`, { | |
| 117 | method: "PATCH", | |
| 118 | headers: { authorization: `Bearer ${createToken(app.db, alice.id, "adm", "admin").token}`, "content-type": "application/json" }, | |
| 119 | body: JSON.stringify({ plansNeedNod: "yes" }), | |
| 120 | }); | |
| 121 | expect(res.status).toBe(422); | |
| 122 | expect(getRepoById(app.db, repo.id)!.plansNeedNod).toBe(false); | |
| 123 | }); | |
| 124 | ||
| 125 | test("on, a pillarless issue is still refused with no_pillar and nothing is stored", async () => { | |
| 126 | const stored = proposals(); | |
| 127 | const res = await post(rw, [{ key: "a", title: "Prune" }]); | |
| 128 | expect(res.status).toBe(422); | |
| 129 | expect(((await res.json()) as { error: string }).error).toBe("no_pillar"); | |
| 130 | expect(proposals()).toBe(stored); | |
| 131 | }); | |
| 132 | ||
| 133 | test("off, the plan opens its issues as before and stores no proposal", async () => { | |
| 134 | nod(false); | |
| 135 | const stored = proposals(); | |
| 136 | const res = await post(rw, [{ key: "a", title: "Sweep the path", pillar: "P0" }]); | |
| 137 | expect(res.status).toBe(201); | |
| 138 | expect(titles()).toContain("Sweep the path"); | |
| 139 | expect(proposals()).toBe(stored); | |
| 140 | nod(true); | |
| 141 | }); | |
| 142 | ||
| 71 | 143 | test("a plan with a pillarless issue is refused with no_pillar and opens nothing; with the pillar named it opens the issues under the umbrella", async () => { |
| 144 | nod(false); | |
| 72 | 145 | const before = titles().length; |
| 73 | 146 | const first = await post(rw, [{ key: "a", title: "Water the beds" }]); |
| 74 | 147 | expect(((await first.json()) as { error: string }).error).toBe("no_pillar"); |
| @@ -90,6 +163,7 @@ describe("POST …/plan", () => { | ||
| 90 | 163 | }); |
| 91 | 164 | |
| 92 | 165 | test("a person's token plans the same way, all or nothing, and a read token is refused", async () => { |
| 166 | nod(false); | |
| 93 | 167 | const before = titles().length; |
| 94 | 168 | const refused = await post(rw, [ |
| 95 | 169 | { key: "x", title: "Fine", pillar: "P0" }, |
'not built yet' while the setting is on by default means plans go nowhere. Either the nod lands here or the default is off. Also document the settings-page line and the terminal key once they exist.