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

Merged opened this in answer to

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

Merged

mason/forge:mason/7637-auto-mode-with-a-direction-and-no-planne → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/7637-auto-mode-with-a-direction-and-no-planne was merged into main as 2c3dd7e.

Review

Sign in to review this change.
9 files changed +257 −7
README.md +8
@@ -464,6 +464,14 @@ leading `Bearer ` are dropped (`normalizeToken`, `src/core/subscriptions.ts`),
464464 and what is left is sealed if it is not empty and at most 4096 characters. Its
465465 shape is not judged; the provider answers a wrong key on the first call.
466466
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
467475 The running list of what the forge does, one bullet each, lives in
468476 [`docs/STATUS.md`](docs/STATUS.md).
469477
docs/MCP.md +1 −1
@@ -188,7 +188,7 @@ for a tunnel and a forge that sleeps.
188188 | `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 |
189189 | `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 | |
190190 | `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 | |
192192 | `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 |
193193 | `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 |
194194 | `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 {
4545 crewRole,
4646 } from "../core/agents.ts";
4747 import { pickBuilder, routeIssue } from "./route.ts";
48 import { ensurePlanner } from "./planner.ts";
4849 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
4950 import {
5051 type Decision,
@@ -710,10 +711,12 @@ async function planIfThin(
710711 const limbs = pendingReplans(app.db, repo);
711712 const reserve = reserveOf(ready, repo.autonomySlots);
712713 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");
715714 if (repo.autonomyDirection.trim() === "")
716715 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");
717720 const direction = ensureDirectionIssue(app, by, repo);
718721 if (!direction) return notPlanned(repo, "no direction issue");
719722 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
outdated

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.

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 by is the autonomy actor, not a checked person. A tick should not widen who can read a private repo. Go through the same permission/grant check as addToCrew, and do nothing (notPlanned with a reason) when it fails. Also make sure the agent's spend/subscription limits apply to the lent planner.

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 @@
11 import { getPull } from "../../../core/pulls.ts";
22 import { directionParts, previewVision, readDefaultVision } from "../../../crew/vision.ts";
3 import { directionStall } from "../../../crew/planner.ts";
34 import { canAdmin } from "../../../core/repos.ts";
45 import { NO_VISION } from "../../../core/vision.ts";
56 import { runGit } from "../../../git/env.ts";
@@ -8,7 +9,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
89
910 export const tool: Tool = {
1011 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",
1213 description:
1314 "VISION.md: purpose, order, rules.",
1415 inputSchema: {
@@ -34,6 +35,7 @@ export const tool: Tool = {
3435 return good({
3536 ...vision,
3637 direction: admin ? repo.autonomyDirection : null,
38 directionStall: admin ? directionStall(ctx.app, repo) : null,
3739 directionParts: admin ? directionParts(repo.autonomyDirection) : null,
3840 preview: shown === null ? null : shown.ok ? { today: shown.today, after: shown.after } : { why: shown.why },
3941 updatedAt: last?.ok && last.value.trim() ? last.value.trim() : null,
src/tui/screens/vision.ts +17 −3
@@ -14,6 +14,7 @@
1414 import { canAdmin, canRead, getRepoById, type Repo } from "../../core/repos.ts";
1515 import { getIssueById } from "../../core/issues.ts";
1616 import { NO_VISION, NO_VISION_LINE, type Vision } from "../../core/vision.ts";
17 import { directionStall } from "../../crew/planner.ts";
1718 import { directionParts, readDefaultVision, type DirectionPart } from "../../crew/vision.ts";
1819 import type { Ctx, Screen } from "../app/app.ts";
1920 import { binding } from "../app/keys.ts";
@@ -49,11 +50,11 @@ function hanging(lead: string, text: string, width: number): string[] {
4950 }
5051
5152 /** 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[] {
5354 const body = (text: string): VisionLine => ({ text, head: false });
5455 const head = (text: string): VisionLine => ({ text, head: true });
5556 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);
5758 const out: VisionLine[] = empty ? wrap(NO_VISION_LINE, width).map(body) : [];
5859 if (v.purpose !== null) out.push(...wrap(v.purpose, width).map(body));
5960 if (v.order.length > 0) {
@@ -79,9 +80,20 @@ export function visionLines(v: Vision, width: number, direction?: Direction): Vi
7980 out.push(...wrap(p.text, width).map(body));
8081 }
8182 }
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 }
8288 return out;
8389 }
8490
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
8597 /** The open direction issue and its parts, for someone who may read the settings; undefined otherwise. */
8698 function readDirection(f: Session, repo: Repo): Direction | undefined {
8799 const fresh = getRepoById(f.db, repo.id) ?? repo;
@@ -95,6 +107,7 @@ export function visionScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
95107 const scroll = new Scroll();
96108 let vision: Vision | undefined;
97109 let direction: Direction | undefined;
110 let stall: string | undefined;
98111 let lines: VisionLine[] = [];
99112 return {
100113 title: "Vision",
@@ -104,13 +117,14 @@ export function visionScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
104117 // viewer who may not read the repository is shown nothing of it.
105118 vision = canRead(f.db, repo, f.user) ? ((await readDefaultVision(f.app.reposRoot, repo)) ?? NO_VISION) : HIDDEN;
106119 direction = vision === HIDDEN ? undefined : readDirection(f, repo);
120 stall = vision === HIDDEN ? undefined : readStall(f, repo);
107121 ctx.invalidate();
108122 },
109123 render(area, s) {
110124 const c = ctx.theme.c;
111125 // One column for the scrollbar `drawLines` keeps, one for breathing room.
112126 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);
114128 const spans = lines.map((l) => [{ text: l.text, style: l.head ? style(c.accent, c.bg, BOLD) : style(c.fg, c.bg) }]);
115129 drawLines(s, area, c, scroll, spans);
116130 hitScroll(ctx, area, scroll, lines.length);
src/web/studio/crew.ts +3
@@ -39,6 +39,7 @@ import { html, raw, type Html } from "../html.ts";
3939 import { icon } from "../icons.ts";
4040 import { crewReadiness } from "../pages/crew.ts";
4141 import { grantsSection } from "../pages/subscriptions.ts";
42 import { directionStall } from "../../crew/planner.ts";
4243 import { directionMarked } from "./direction.ts";
4344 import { fileHoldsCard } from "./file_holds.ts";
4445 import { markOf } from "../../core/marks.ts";
@@ -255,6 +256,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html
255256 const planner = listCrewRoles(ctx.app.db, repo.id).find(
256257 (c) => c.role === "planner",
257258 );
259 const stall = admin ? directionStall(ctx.app, repo) : null;
258260 const status = repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo);
259261 const governor = governorSeen(ctx.app, ctx.auth.user, repo, new Date());
260262 const state = crewOffInPreview()
@@ -336,6 +338,7 @@ ${repo.autonomyDirection}</textarea
336338 : html`No planner on the crew yet — set one under Crew, or the
337339 map is only what people write.`}
338340 </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.

339342 </div>
340343 ${selectField({
341344 label: "Decisions here",
test/mcp-vision.test.ts +1
@@ -70,6 +70,7 @@ test("this repository's VISION.md is parsed", async () => {
7070 expect(v.rules.length).toBe(4);
7171 expect(v.rules[2]).toStartWith("Done means reachable");
7272 expect(v.direction).toBe("ship the feed");
73 expect(v.directionStall).toBeNull();
7374 expect(v.updatedAt).toMatch(/^\d{4}-\d\d-\d\dT/);
7475 });
7576
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs