Public: anyone can read this; the crew can change it.
Auto mode with a direction and no planner does nothing, silently: a migrated repository gets a planner or says why not #7772
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7637-auto-mode-with-a-direction-and-no-planne → main 2 commits fork 3 threads · 3 unresolved
README.md +8
| @@ -464,6 +464,14 @@ leading `Bearer ` are dropped (`normalizeToken`, `src/core/subscriptions.ts`), | ||
| 464 | 464 | and what is left is sealed if it is not empty and at most 4096 characters. Its |
| 465 | 465 | shape is not judged; the provider answers a wrong key on the first call. |
| 466 | 466 | |
| 467 | A repository in auto mode with a direction and no planner on its crew is lent | |
| 468 | one (#7637): at the next planning turn the forge adds, through the same | |
| 469 | `addToCrew` an admin uses, the agent that plans on the most of the *owner's* | |
| 470 | other repositories, and hands it the direction. If the owner has none, or the | |
| 471 | autonomy actor may not add it, nothing is lent and the reason shows in the | |
| 472 | Direction field of the autonomy card (admins), the terminal's Vision screen, | |
| 473 | and MCP `vision` as `directionStall`. | |
| 474 | ||
| 467 | 475 | The running list of what the forge does, one bullet each, lives in |
| 468 | 476 | [](docs/STATUS.md). |
| 469 | 477 |
docs/MCP.md +1 −1
| @@ -188,7 +188,7 @@ for a tunnel and a forge that sleeps. | ||
| 188 | 188 | | `upstream` | `{ repo, enabled?, url?, branch?, check? }`: the repository a fork follows (an admin reads it); with `enabled` sets it, with `check: true` checks it now; setting and checking need an admin token | yes | |
| 189 | 189 | | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | | |
| 190 | 190 | | `variant` | `{ repo, number, answer, reason? }`: like or dislike one pull among its variants (`answer` like or dislike); a later vote replaces the earlier; a dislike closes the pull with `reason` | yes | |
| 191 | | `vision` | `{ repo, number? }`: `{ purpose, order: [{ n, name, text }], rules, direction, directionParts, preview, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) and `directionParts` (it cut into `{ kind: vision|owner, text }`: the fenced block that follows docs/VISION.md, and the owner's words) only for those who may read the repository's settings, else null; with `number` (an open vision pull) those same people get `preview`: `{ today, after }`, the direction as it stands and as an admin's merge will write it, or `{ why }` it would stay; else null | | | |
| 191 | | `vision` | `{ repo, number? }`: `{ purpose, order: [{ n, name, text }], rules, direction, directionStall, directionParts, preview, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `directionStall` (why a saved direction has no direction issue, else null; admins only), `direction` (the autonomy direction) and `directionParts` (it cut into `{ kind: vision|owner, text }`: the fenced block that follows docs/VISION.md, and the owner's words) only for those who may read the repository's settings, else null; with `number` (an open vision pull) those same people get `preview`: `{ today, after }`, the direction as it stands and as an admin's merge will write it, or `{ why }` it would stay; else null | | | |
| 192 | 192 | | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes | |
| 193 | 193 | | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes | |
| 194 | 194 | | `waves` | `{ repo, version? }`: the repository's waves newest first (version, date, headline; a broken file is `{ version, error }`), or with `version` one wave: headline, highlights with their why, pull numbers (with the `ui://forge/wave` card); a private repository is not found to a stranger | | |
src/crew/autonomy.ts +5 −2
| @@ -45,6 +45,7 @@ import { | ||
| 45 | 45 | crewRole, |
| 46 | 46 | } from "../core/agents.ts"; |
| 47 | 47 | import { pickBuilder, routeIssue } from "./route.ts"; |
| 48 | import { ensurePlanner } from "./planner.ts"; | |
| 48 | 49 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 49 | 50 | import { |
| 50 | 51 | type Decision, |
| @@ -710,10 +711,12 @@ async function planIfThin( | ||
| 710 | 711 | const limbs = pendingReplans(app.db, repo); |
| 711 | 712 | const reserve = reserveOf(ready, repo.autonomySlots); |
| 712 | 713 | if (!short(reserve) && handed.length === 0 && limbs.length === 0) return false; |
| 713 | const planner = crew.find((c) => c.role === "planner")?.agent; | |
| 714 | if (planner === undefined) return notPlanned(repo, "no planner on the crew"); | |
| 715 | 714 | if (repo.autonomyDirection.trim() === "") |
| 716 | 715 | return notPlanned(repo, "no direction written"); |
| 716 | // A direction with no planner would go nowhere; the forge lends one (#7637). | |
| 717 | const planner = | |
| 718 | crew.find((c) => c.role === "planner")?.agent ?? ensurePlanner(app, repo, by); | |
| 719 | if (planner === undefined) return notPlanned(repo, "no planner on the crew"); | |
| 717 | 720 | const direction = ensureDirectionIssue(app, by, repo); |
| 718 | 721 | if (!direction) return notPlanned(repo, "no direction issue"); |
| 719 | 722 | if (active.some((r) => r.issueId === direction.id)) |
src/crew/planner.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * The planner a direction needs. A repository in auto mode with a direction | |
| 3 | * and no planner on its crew would plan nothing, and say nothing (#7637): | |
| 4 | * the tick adds one — the agent that plans on the most of the owner's other repositories — | |
| 5 | * and the autonomy card says why when a saved direction has no issue yet. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { addToCrew } from "../actions/crew.ts"; | |
| 10 | import { getAgent, listCrewRoles, setCrewRole, type Agent } from "../core/agents.ts"; | |
| 11 | import type { Repo } from "../core/repos.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | ||
| 14 | /** | |
| 15 | * The most-used planner among the owner's own repositories that is not | |
| 16 | * already on this crew: an agent the owner already put to work, never one | |
| 17 | * seen only on someone else's (private) repository. | |
| 18 | */ | |
| 19 | function ownersPlanner(app: App, repo: Repo): Agent | undefined { | |
| 20 | const row = app.db | |
| 21 | .query<{ user_id: string }, [string, string]>( | |
| 22 | `SELECT rc.user_id FROM repo_crew rc | |
| 23 | JOIN repo r ON r.id = rc.repo_id | |
| 24 | JOIN agent a ON a.user_id = rc.user_id | |
| 25 | WHERE rc.role = 'planner' AND r.owner_id = ?2 AND rc.repo_id <> ?1 | |
| 26 | AND rc.user_id NOT IN (SELECT user_id FROM repo_crew WHERE repo_id = ?1) | |
| 27 | GROUP BY rc.user_id ORDER BY COUNT(*) DESC, rc.user_id LIMIT 1`, | |
| 28 | ) | |
| 29 | .get(repo.id, repo.ownerId); | |
| 30 | return row === null ? undefined : getAgent(app.db, row.user_id); | |
| 31 | } | |
| 32 | ||
| 33 | /** | |
| 34 | * The repository's planner, adding one when the crew has none. The agent | |
| 35 | * joins through `addToCrew`, so `by` must be allowed to add it (a verified | |
| 36 | * admin here) and the `crew.added` event is the same; the agent's own budget | |
outdated
HIGH: addCrew from core bypasses the authorization that the addToCrew action performs (src/actions/crew.ts, used in the test), and |
||
| 37 | * and the repository's ceiling bind it as any crew member. Undefined when | |
| 38 | * the owner has no planner to lend, or `by` may not add it: the caller says so. | |
| 39 | */ | |
| 40 | export function ensurePlanner(app: App, repo: Repo, by: User): Agent | undefined { | |
| 41 | const have = listCrewRoles(app.db, repo.id).find((c) => c.role === "planner"); | |
| 42 | if (have !== undefined) return have.agent; | |
| 43 | const chosen = ownersPlanner(app, repo); | |
| 44 | if (chosen === undefined) return undefined; | |
| 45 | if (!addToCrew(app, by, repo, chosen.user.username).ok) return undefined; | |
| 46 | setCrewRole(app.db, repo.id, chosen.user.id, "planner"); | |
| 47 | return chosen; | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Why a saved direction has no direction issue, or null when nothing is | |
| 52 | * wrong: autonomy off, no direction, or the issue exists. | |
| 53 | */ | |
| 54 | export function directionStall(app: App, repo: Repo): string | null { | |
| 55 | if (repo.autonomy === "off" || repo.autonomyDirection.trim() === "") return null; | |
| 56 | if (repo.directionIssueId !== null) return null; | |
| 57 | if (!listCrewRoles(app.db, repo.id).some((c) => c.role === "planner")) | |
| 58 | return "No planner is on the crew, so nothing will act on this direction. The next planning turn adds one if the forge has a planner to lend; otherwise add one under Crew."; | |
| 59 | return "The planner has not opened the direction issue yet; it does at the next planning turn, once the map runs thin."; | |
| 60 | } |
src/http/mcp/tools/vision.ts +3 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { getPull } from "../../../core/pulls.ts"; |
| 2 | 2 | import { directionParts, previewVision, readDefaultVision } from "../../../crew/vision.ts"; |
| 3 | import { directionStall } from "../../../crew/planner.ts"; | |
| 3 | 4 | import { canAdmin } from "../../../core/repos.ts"; |
| 4 | 5 | import { NO_VISION } from "../../../core/vision.ts"; |
| 5 | 6 | import { runGit } from "../../../git/env.ts"; |
| @@ -8,7 +9,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | ||
| 8 | 9 | |
| 9 | 10 | export const tool: Tool = { |
| 10 | 11 | name: "vision", |
| 11 | doc: "`{ repo, number? }`: `{ purpose, order: [{ n, name, text }], rules, direction, directionParts, preview, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) and `directionParts` (it cut into `{ kind: vision|owner, text }`: the fenced block that follows docs/VISION.md, and the owner's words) only for those who may read the repository's settings, else null; with `number` (an open vision pull) those same people get `preview`: `{ today, after }`, the direction as it stands and as an admin's merge will write it, or `{ why }` it would stay; else null", | |
| 12 | doc: "`{ repo, number? }`: `{ purpose, order: [{ n, name, text }], rules, direction, directionStall, directionParts, preview, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `directionStall` (why a saved direction has no direction issue, else null; admins only), `direction` (the autonomy direction) and `directionParts` (it cut into `{ kind: vision|owner, text }`: the fenced block that follows docs/VISION.md, and the owner's words) only for those who may read the repository's settings, else null; with `number` (an open vision pull) those same people get `preview`: `{ today, after }`, the direction as it stands and as an admin's merge will write it, or `{ why }` it would stay; else null", | |
| 12 | 13 | description: |
| 13 | 14 | "VISION.md: purpose, order, rules.", |
| 14 | 15 | inputSchema: { |
| @@ -34,6 +35,7 @@ export const tool: Tool = { | ||
| 34 | 35 | return good({ |
| 35 | 36 | ...vision, |
| 36 | 37 | direction: admin ? repo.autonomyDirection : null, |
| 38 | directionStall: admin ? directionStall(ctx.app, repo) : null, | |
| 37 | 39 | directionParts: admin ? directionParts(repo.autonomyDirection) : null, |
| 38 | 40 | preview: shown === null ? null : shown.ok ? { today: shown.today, after: shown.after } : { why: shown.why }, |
| 39 | 41 | updatedAt: last?.ok && last.value.trim() ? last.value.trim() : null, |
src/tui/screens/vision.ts +17 −3
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | import { canAdmin, canRead, getRepoById, type Repo } from "../../core/repos.ts"; |
| 15 | 15 | import { getIssueById } from "../../core/issues.ts"; |
| 16 | 16 | import { NO_VISION, NO_VISION_LINE, type Vision } from "../../core/vision.ts"; |
| 17 | import { directionStall } from "../../crew/planner.ts"; | |
| 17 | 18 | import { directionParts, readDefaultVision, type DirectionPart } from "../../crew/vision.ts"; |
| 18 | 19 | import type { Ctx, Screen } from "../app/app.ts"; |
| 19 | 20 | import { binding } from "../app/keys.ts"; |
| @@ -49,11 +50,11 @@ function hanging(lead: string, text: string, width: number): string[] { | ||
| 49 | 50 | } |
| 50 | 51 | |
| 51 | 52 | /** The screen's lines at `width` columns: purpose, numbered pillars, rules. */ |
| 52 | export function visionLines(v: Vision, width: number, direction?: Direction): VisionLine[] { | |
| 53 | export function visionLines(v: Vision, width: number, direction?: Direction, stall?: string): VisionLine[] { | |
| 53 | 54 | const body = (text: string): VisionLine => ({ text, head: false }); |
| 54 | 55 | const head = (text: string): VisionLine => ({ text, head: true }); |
| 55 | 56 | const empty = v.purpose === null && v.order.length === 0 && v.rules.length === 0; |
| 56 | if (empty && direction === undefined) return wrap(NO_VISION_LINE, width).map(body); | |
| 57 | if (empty && direction === undefined && stall === undefined) return wrap(NO_VISION_LINE, width).map(body); | |
| 57 | 58 | const out: VisionLine[] = empty ? wrap(NO_VISION_LINE, width).map(body) : []; |
| 58 | 59 | if (v.purpose !== null) out.push(...wrap(v.purpose, width).map(body)); |
| 59 | 60 | if (v.order.length > 0) { |
| @@ -79,9 +80,20 @@ export function visionLines(v: Vision, width: number, direction?: Direction): Vi | ||
| 79 | 80 | out.push(...wrap(p.text, width).map(body)); |
| 80 | 81 | } |
| 81 | 82 | } |
| 83 | if (stall !== undefined) { | |
| 84 | if (out.length > 0) out.push(body("")); | |
| 85 | out.push(head("Direction")); | |
| 86 | out.push(...wrap(stall, width).map(body)); | |
| 87 | } | |
| 82 | 88 | return out; |
| 83 | 89 | } |
| 84 | 90 | |
| 91 | /** Why a saved direction has no issue, for someone who may read the settings; undefined otherwise. */ | |
| 92 | function readStall(f: Session, repo: Repo): string | undefined { | |
| 93 | const fresh = getRepoById(f.db, repo.id) ?? repo; | |
| 94 | return canAdmin(f.db, fresh, f.user) ? (directionStall(f.app, fresh) ?? undefined) : undefined; | |
| 95 | } | |
| 96 | ||
| 85 | 97 | /** The open direction issue and its parts, for someone who may read the settings; undefined otherwise. */ |
| 86 | 98 | function readDirection(f: Session, repo: Repo): Direction | undefined { |
| 87 | 99 | const fresh = getRepoById(f.db, repo.id) ?? repo; |
| @@ -95,6 +107,7 @@ export function visionScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 95 | 107 | const scroll = new Scroll(); |
| 96 | 108 | let vision: Vision | undefined; |
| 97 | 109 | let direction: Direction | undefined; |
| 110 | let stall: string | undefined; | |
| 98 | 111 | let lines: VisionLine[] = []; |
| 99 | 112 | return { |
| 100 | 113 | title: "Vision", |
| @@ -104,13 +117,14 @@ export function visionScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 104 | 117 | // viewer who may not read the repository is shown nothing of it. |
| 105 | 118 | vision = canRead(f.db, repo, f.user) ? ((await readDefaultVision(f.app.reposRoot, repo)) ?? NO_VISION) : HIDDEN; |
| 106 | 119 | direction = vision === HIDDEN ? undefined : readDirection(f, repo); |
| 120 | stall = vision === HIDDEN ? undefined : readStall(f, repo); | |
| 107 | 121 | ctx.invalidate(); |
| 108 | 122 | }, |
| 109 | 123 | render(area, s) { |
| 110 | 124 | const c = ctx.theme.c; |
| 111 | 125 | // One column for the scrollbar `drawLines` keeps, one for breathing room. |
| 112 | 126 | const width = Math.max(1, area.w - 2); |
| 113 | lines = vision === undefined ? [{ text: "Reading…", head: false }] : visionLines(vision, width, direction); | |
| 127 | lines = vision === undefined ? [{ text: "Reading…", head: false }] : visionLines(vision, width, direction, stall); | |
| 114 | 128 | const spans = lines.map((l) => [{ text: l.text, style: l.head ? style(c.accent, c.bg, BOLD) : style(c.fg, c.bg) }]); |
| 115 | 129 | drawLines(s, area, c, scroll, spans); |
| 116 | 130 | hitScroll(ctx, area, scroll, lines.length); |
src/web/studio/crew.ts +3
| @@ -39,6 +39,7 @@ import { html, raw, type Html } from "../html.ts"; | ||
| 39 | 39 | import { icon } from "../icons.ts"; |
| 40 | 40 | import { crewReadiness } from "../pages/crew.ts"; |
| 41 | 41 | import { grantsSection } from "../pages/subscriptions.ts"; |
| 42 | import { directionStall } from "../../crew/planner.ts"; | |
| 42 | 43 | import { directionMarked } from "./direction.ts"; |
| 43 | 44 | import { fileHoldsCard } from "./file_holds.ts"; |
| 44 | 45 | import { markOf } from "../../core/marks.ts"; |
| @@ -255,6 +256,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html | ||
| 255 | 256 | const planner = listCrewRoles(ctx.app.db, repo.id).find( |
| 256 | 257 | (c) => c.role === "planner", |
| 257 | 258 | ); |
| 259 | const stall = admin ? directionStall(ctx.app, repo) : null; | |
| 258 | 260 | const status = repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); |
| 259 | 261 | const governor = governorSeen(ctx.app, ctx.auth.user, repo, new Date()); |
| 260 | 262 | const state = crewOffInPreview() |
| @@ -336,6 +338,7 @@ ${repo.autonomyDirection}</textarea | ||
| 336 | 338 | : html`No planner on the crew yet — set one under Crew, or the |
| 337 | 339 | map is only what people write.`} |
| 338 | 340 | </p> |
| 341 | ${stall === null ? null : html`<p class="hint" id="direction-stall">${stall}</p>`} | |
The stall is rendered for every viewer of the card, not just admins, and has no test. Gate it like the rest of the card (admin) or justify it, and add a page test. The same reason must be reachable in src/tty.ts. |
||
| 339 | 342 | </div> |
| 340 | 343 | ${selectField({ |
| 341 | 344 | label: "Decisions here", |
test/mcp-vision.test.ts +1
| @@ -70,6 +70,7 @@ test("this repository's VISION.md is parsed", async () => { | ||
| 70 | 70 | expect(v.rules.length).toBe(4); |
| 71 | 71 | expect(v.rules[2]).toStartWith("Done means reachable"); |
| 72 | 72 | expect(v.direction).toBe("ship the feed"); |
| 73 | expect(v.directionStall).toBeNull(); | |
| 73 | 74 | expect(v.updatedAt).toMatch(/^\d{4}-\d\d-\d\dT/); |
| 74 | 75 | }); |
| 75 | 76 |
test/planner-lent.test.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * A repository in auto mode with a direction and no planner gets one, and | |
| 3 | * its first planned issues start without a further step (#7637). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 11 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 12 | import { crewRole, setCrewRole } from "../src/core/agents.ts"; | |
| 13 | import { getIssueById } from "../src/core/issues.ts"; | |
| 14 | import { createRepo as coreCreateRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { visionLines } from "../src/tui/screens/vision.ts"; | |
| 18 | import { NO_VISION } from "../src/core/vision.ts"; | |
| 19 | import { driveRepo } from "../src/crew/autonomy.ts"; | |
| 20 | import { directionStall, ensurePlanner } from "../src/crew/planner.ts"; | |
| 21 | ||
| 22 | describe("a migrated repository with only a builder", () => { | |
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let alice: User; | |
| 26 | let pixel: Repo; | |
| 27 | let servers: Servers; | |
| 28 | let cookie: string; | |
| 29 | const fresh = () => getRepoById(app.db, pixel.id)!; | |
| 30 | ||
| 31 | async function repo(name: string): Promise<Repo> { | |
| 32 | const made = await createRepo(app, alice, { name, visibility: "private" }); | |
| 33 | if (!made.ok) throw new Error(String(made.error)); | |
| 34 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 35 | message: "Start", | |
| 36 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 37 | }); | |
| 38 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 39 | return made.value; | |
| 40 | } | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | dataDir = await mkdtemp(join(tmpdir(), "forge-lent-")); | |
| 44 | app = createApp({ dataDir, port: 0 }); | |
| 45 | const a = await createUser(app.db, { | |
| 46 | origin: "seed", | |
| 47 | trusted: true, | |
| 48 | username: "alice", | |
| 49 | email: "[email protected]", | |
| 50 | password: "a-good-passphrase", | |
| 51 | adminIfFirst: true, | |
| 52 | }); | |
| 53 | if (!a.ok) throw new Error(a.error); | |
| 54 | alice = a.value; | |
| 55 | servers = await serve(app); | |
| 56 | for (const name of ["atlas", "torque"]) { | |
| 57 | const agent = await createAgent(app, alice, { username: name }); | |
| 58 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 59 | } | |
| 60 | // Elsewhere on the forge atlas plans. | |
| 61 | const home = await repo("home"); | |
| 62 | expect(addToCrew(app, alice, home, "atlas").ok).toBe(true); | |
| 63 | setCrewRole(app.db, home.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 64 | // The migrated one: a builder, nothing else. | |
| 65 | pixel = await repo("pixel-plays"); | |
| 66 | expect(addToCrew(app, alice, pixel, "torque").ok).toBe(true); | |
| 67 | }); | |
| 68 | ||
| 69 | afterAll(async () => { | |
| 70 | servers.stop(); | |
| 71 | app.close(); | |
| 72 | await rm(dataDir, { recursive: true, force: true }); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the page says nothing will act on a direction with no planner", async () => { | |
| 76 | const on = await updateRepoSettings(app, alice, pixel, { | |
| 77 | autonomy: "autonomous", | |
| 78 | autonomyDirection: "build more games!", | |
| 79 | autonomyCeilingUsd: 10, | |
| 80 | autonomySlots: 1, | |
| 81 | autonomyBy: alice.id, | |
| 82 | }); | |
| 83 | expect(on.ok).toBe(true); | |
| 84 | expect(directionStall(app, fresh())).toContain("No planner is on the crew"); | |
| 85 | }); | |
| 86 | ||
| 87 | const stallOnPage = async () => { | |
| 88 | const res = await fetch(`${servers.url}/alice/pixel-plays/settings/autonomy`, { headers: { cookie } }); | |
| 89 | return (await res.text()).includes('id="direction-stall"'); | |
| 90 | }; | |
| 91 | ||
| 92 | test("the card renders the stall for an admin, and the terminal's Vision lines carry it", async () => { | |
| 93 | const signin = await fetch(`${servers.url}/signin`, { | |
| 94 | method: "POST", | |
| 95 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 96 | redirect: "manual", | |
| 97 | }); | |
| 98 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 99 | expect(await stallOnPage()).toBe(true); | |
| 100 | const lines = visionLines(NO_VISION, 80, undefined, directionStall(app, fresh())!); | |
| 101 | expect(lines.map((l) => l.text).join(" ")).toContain("No planner is on the crew"); | |
| 102 | }); | |
| 103 | ||
| 104 | test("the next turn adds a planner and hands it the direction", async () => { | |
| 105 | expect(await driveRepo(app, fresh())).toBe("planned"); | |
| 106 | const atlas = getUserByName(app.db, "atlas")!; | |
| 107 | expect(crewRole(app.db, pixel.id, atlas.id)).toBe("planner"); | |
| 108 | const direction = getIssueById(app.db, fresh().directionIssueId!)!; | |
| 109 | expect(direction.assigneeId).toBe(atlas.id); | |
| 110 | expect(directionStall(app, fresh())).toBeNull(); | |
| 111 | expect(await stallOnPage()).toBe(false); | |
| 112 | // Lent through addToCrew: the same event an admin's add emits. | |
| 113 | const added = app.db | |
| 114 | .query<{ n: number }, [string]>( | |
| 115 | "SELECT COUNT(*) AS n FROM event WHERE kind = 'crew.added' AND repo_id = ?1 AND payload LIKE '%atlas%'", | |
| 116 | ) | |
| 117 | .get(pixel.id)!; | |
| 118 | expect(added.n).toBe(1); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a planner with a crew but no direction issue yet reads the second message", async () => { | |
| 122 | const third = await repo("third"); | |
| 123 | expect(addToCrew(app, alice, third, "atlas").ok).toBe(true); | |
| 124 | setCrewRole(app.db, third.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 125 | await updateRepoSettings(app, alice, third, { | |
| 126 | autonomy: "assisted", | |
| 127 | autonomyDirection: "more", | |
| 128 | autonomyBy: alice.id, | |
| 129 | }); | |
| 130 | expect(directionStall(app, getRepoById(app.db, third.id)!)).toContain("has not opened the direction issue"); | |
| 131 | }); | |
| 132 | ||
| 133 | test("nothing is lent when the owner has no planner, or the actor may not add one", async () => { | |
| 134 | const bob = await createUser(app.db, { | |
| 135 | origin: "seed", | |
| 136 | trusted: true, | |
| 137 | username: "bob", | |
| 138 | email: "[email protected]", | |
| 139 | password: "a-good-passphrase", | |
| 140 | }); | |
| 141 | if (!bob.ok) throw new Error(bob.error); | |
| 142 | const made = await coreCreateRepo(app.db, app.reposRoot, { ownerId: bob.value.id, name: "bobs", visibility: "private" }); | |
| 143 | if (!made.ok) throw new Error(String(made.error)); | |
| 144 | expect(addToCrew(app, bob.value, made.value, "torque").ok).toBe(true); | |
| 145 | // atlas plans only on alice's repositories: bob's private repository is not lent it. | |
| 146 | expect(ensurePlanner(app, made.value, bob.value)).toBeUndefined(); | |
| 147 | // A candidate exists on alice's own repository, but bob may not add it there. | |
| 148 | const fourth = await repo("fourth"); | |
| 149 | expect(ensurePlanner(app, fourth, bob.value)).toBeUndefined(); | |
| 150 | expect(crewRole(app.db, fourth.id, getUserByName(app.db, "atlas")!.id)).toBeUndefined(); | |
| 151 | // And alice, not an admin of bob's repository, cannot add it either way. | |
| 152 | expect(ensurePlanner(app, made.value, alice)).toBeUndefined(); | |
| 153 | expect(await driveRepo(app, { ...made.value, autonomy: "autonomous", autonomyDirection: "go" })).not.toBe("planned"); | |
| 154 | expect(directionStall(app, { ...made.value, autonomy: "autonomous", autonomyDirection: "go" })).toContain( | |
| 155 | "No planner is on the crew", | |
| 156 | ); | |
| 157 | expect(crewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id)).toBeUndefined(); | |
| 158 | }); | |
| 159 | }); |
HIGH: the query ranges over every repo_crew row on the forge, private repos included, with no owner/visibility/permission filter. The chosen agent may belong to another user and be granted only to other repos. Restrict candidates to agents the repo's owner (or actor) is allowed to use and that are already visible to them (same owner, or agents the owner has been granted), and test that a private repo's planner owned by someone else is not lent.