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

Making a new app starts the crew on it: "Choose for me" seats a crew and the… #12261

Merged opened this

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

Merged

mason/forge:mason/12215-making-a-new-app-starts-the-crew-on-it-c → main 10 commits fork 4 threads · 4 unresolved

  • Merged. mason/12215-making-a-new-app-starts-the-crew-on-it-c was merged into main as d469905.

Review

Sign in to review this change.
8 files changed +232 −35
docs/features/new-app-crew.md added +16
@@ -0,0 +1,16 @@
1 # A new app starts the crew on it
2
3 Making an app with no agent named (the new-app page's "Choose for me" or blank agent, `new_app`,
4 `tell` on a new repository, a spawn's fresh app) seats the forge's crew and hands the first issue
5 to its builder, so a run can start with no admin step.
6
7 - A builder and a reviewer are seated, from the forge's own agents first; a role nobody holds is
8 made as the starter agent. Autonomy is turned on as the person who made the app.
9 - When "Choose for me" has the planner pick a builder, that builder is the one seated.
10 - Naming an agent seats only that agent, as before.
11 - A forge with no model credential and no credit left seats no one; the new-app confirmation
12 then adds "The crew cannot build until you add a model key or pick a plan."
13 - Seating is a few database writes after the app exists; nothing waits on a run.
14
15 Code: `newApp` in `src/actions/apps.ts`, `seatCrew` in `src/spawn/starter_crew.ts`.
16 Test: `test/new-app-crew.test.ts`.
src/actions/apps.ts +16
@@ -14,6 +14,8 @@ import { deriveAppName } from "../web/pages/apps-name.ts";
1414 import { REPO_INIT_FAILED, REPO_NAME_RULES } from "./repo_words.ts";
1515 import { NO_SUCH_AGENT } from "./crew_limit_words.ts";
1616 import { requireVerified } from "./verify.ts";
17 import { seatCrew } from "../spawn/starter_crew.ts";
18 import { NO_CREDIT_WORDS, crewCanRun } from "./crew_means.ts";
outdated

Layering: the action layer imports from src/web/. Move crewCanRun to a neutral module.

1719 import { requireAppRoom } from "./app_limit.ts";
1820 import type { App } from "../app.ts";
1921 import { checkWord, type CheckWord } from "../apps/check_word.ts";
@@ -285,6 +287,11 @@ export interface NewAppResult {
285287 readonly issue?: Issue;
286288 /** Who the plan picked and why, when `pickAgent` landed one. */
287289 readonly plan?: { agent: string; summary: string; confidence: number };
290 /**
291 * Said when no one was named and the forge has nothing to run a crew on: the issue waits, and
292 * why.
293 */
294 readonly idle?: string;
288295 }
289296
290297 export type NewAppError =
@@ -423,6 +430,14 @@ export async function newApp(
423430 }
424431 }
425432
433 // No one named: the forge's crew is seated and the first issue goes to its builder (#12175), so
434 // a run starts with no admin step. A forge that cannot run a crew seats no one. A person's own
435 // pick (`agent`) is exactly what they chose; a plan's pick is kept as the builder.
436 if (!agent && body !== "" && crewCanRun(app)) {
437 const seated = await seatCrew(app.db, repo.value, user.value.id, chosen?.user.id);
438 if (seated !== undefined) chosen = seated;
439 }
440
426441 let issue: Issue | undefined;
427442 if (chosen) {
428443 addCrew(app.db, repo.value.id, chosen.user.id, user.value.id, "builder");
@@ -460,6 +475,7 @@ export async function newApp(
460475 app: record.value,
461476 ...(issue === undefined ? {} : { issue }),
462477 ...(picked === undefined ? {} : { plan: picked }),
478 ...(!agent && !crewCanRun(app) ? { idle: NO_CREDIT_WORDS } : {}),
463479 });
464480 }
465481
src/actions/crew_means.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * What the crew can run on: a key of the owner's, or included credit with money left. One
3 * place for both doors and for the pages, so the action layer never reaches into the web layer.
4 */
5
6 import type { App } from "../app.ts";
7 import { crewHasCredential } from "../core/subscriptions.ts";
8 import { leftUsd } from "../spawn/credit.ts";
9 import { fundingOf } from "../spawn/funding.ts";
10
11 export interface Included {
12 readonly capUsd: number;
13 /** What the host counted the runs cost; a reservation or declared budget is never in it. */
14 readonly spentUsd: number;
15 readonly leftUsd: number;
16 }
17
18 /**
19 * The credit this forge came with and what is left of it (last heard from its host), or undefined
20 * when it has none.
21 */
22 export function includedCredit(app: App): Included | undefined {
23 const f = fundingOf(app.config.dataDir);
24 if (f === undefined || f.capUsd === null) return undefined;
25 const spentUsd = f.spentUsd ?? 0;
26 return { capUsd: f.capUsd, spentUsd, leftUsd: leftUsd({ capUsd: f.capUsd, spentUsd }) };
27 }
28
29 /** Whether the crew can run: a key of the owner's, or included credit with money left. */
30 export function crewCanRun(app: App): boolean {
31 if (crewHasCredential(app.db)) return true;
32 const c = includedCredit(app);
33 return c !== undefined && c.leftUsd > 0;
34 }
35
36 /** What a forge whose crew has nothing to run on says, on the page and in the terminal. */
37 export const NO_CREDIT_WORDS = "The crew cannot build until you add a model key or pick a plan.";
src/spawn/starter_crew.ts +21 −5
@@ -87,20 +87,36 @@ function seatAgent(db: Database, repo: Repo, userId: string, founderId: string,
8787 * cannot be filled, so the caller says so. Other apps are left as they are.
8888 */
8989 export async function seatFirstAskCrew(db: Database, repo: Repo, founderId: string): Promise<boolean> {
90 if (listAgents(db).length === 0 && !(await makeStarterAgents(db, founderId))) return false;
91 let builder = listAgents(db).find((a) => a.roles.includes("builder"));
90 return (await seatCrew(db, repo, founderId)) !== undefined;
91 }
92
93 /**
94 * `seatFirstAskCrew`, answering with the builder it seated (`prefer`, a builder's user id, when
95 * that agent is one), so a new app can hand its first issue to them (#12175); undefined when
96 * either seat cannot be filled.
97 */
98 export async function seatCrew(
99 db: Database,
100 repo: Repo,
101 founderId: string,
102 prefer?: string,
103 ): Promise<ReturnType<typeof listAgents>[number] | undefined> {
104 if (listAgents(db).length === 0 && !(await makeStarterAgents(db, founderId))) return undefined;
105 let builder =
106 listAgents(db).find((a) => a.user.id === prefer && a.roles.includes("builder")) ??
107 listAgents(db).find((a) => a.roles.includes("builder"));
92108 if (builder === undefined && (await makeSeat(db, STARTER_SEATS[0]!, founderId)))
93109 builder = getAgentByName(db, STARTER_SEATS[0]!.name);
94 if (builder === undefined) return false;
110 if (builder === undefined) return undefined;
95111 const other = (): ReturnType<typeof listAgents>[number] | undefined =>
96112 listAgents(db).find((a) => a.roles.includes("reviewer") && a.user.id !== builder!.user.id);
97113 let reviewer = other();
98114 if (reviewer === undefined && (await makeSeat(db, STARTER_SEATS[1]!, founderId))) reviewer = other();
99 if (reviewer === undefined) return false;
115 if (reviewer === undefined) return undefined;
100116 seatAgent(db, repo, builder.user.id, founderId, "builder");
101117 seatAgent(db, repo, reviewer.user.id, founderId, "reviewer");
102118 updateRepoSettings(db, getRepoById(db, repo.id) ?? repo, { autonomy: "autonomous", autonomyBy: founderId });
103 return true;
119 return builder;
104120 }
105121
106122 /** Seat the starter crew on one repository and turn autonomy on, unless a person already chose either. */
src/tui/screens/newapp.ts +1 −1
@@ -77,7 +77,7 @@ export function newAppScreen(
7777 ctx.toast(
7878 url === undefined
7979 ? `${made.repo.name} is an app, but nothing is serving apps on this forge.`
80 : `${made.repo.name} is starting at ${url}.`,
80 : `${made.repo.name} is starting at ${url}.${made.idle === undefined ? "" : ` ${made.idle}`}`,
8181 "success",
8282 );
8383 ctx.push(repoScreen(f, ctx, local(f, made.repo)));
src/web/pages/apps.ts +3 −2
@@ -561,7 +561,8 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
561561 made.error === "no_such_template" ? 404 : 422,
562562 );
563563 }
564 const { repo, app: record, issue, plan } = made.value;
564 const { repo, app: record, issue, plan, idle } = made.value;
565 const idleWords = idle === undefined ? "" : ` ${idle}`;
565566 const url = appsRuntime(ctx.app)?.urlFor(record, "production");
566567 const first =
567568 url === undefined
@@ -577,7 +578,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
577578 : ` #${issue.number} is assigned to ${issue.assigneeName}.`;
578579 // The repository's home is the app page (repoHome), once the record
579580 // exists; the code is its Files step.
580 return flashTo(ctx, repoBase(repo), { message: `${first}${second}` });
581 return flashTo(ctx, repoBase(repo), { message: `${first}${second}${idleWords}` });
outdated

The nested ternary is hard to read; name the condition. The sentence is also web-only, so the terminal door is missing it.

581582 }
582583
583584 // ---- the repo home's strip ----
src/web/studio/crew_key.ts +5 −27
@@ -14,8 +14,8 @@ import { listAgents } from "../../core/agents.ts";
1414 import { saveCrewKey } from "../../actions/crew_key.ts";
1515 import { wokeWords } from "../../crew/wake.ts";
1616 import { crewHasCredential } from "../../core/subscriptions.ts";
17 import { leftUsd, usdWords } from "../../spawn/credit.ts";
18 import { fundingOf } from "../../spawn/funding.ts";
17 import { usdWords } from "../../spawn/credit.ts";
18 import { NO_CREDIT_WORDS, crewCanRun, includedCredit } from "../../actions/crew_means.ts";
1919 import type { Ctx } from "../../http/context.ts";
2020 import { notFoundPage, signInFirst } from "../chrome.ts";
2121 import { field, readForm } from "../form.ts";
@@ -24,35 +24,16 @@ import { icon } from "../icons.ts";
2424 import { flashTo } from "../layout.ts";
2525 import { btn } from "../ui.ts";
2626
27 export { NO_CREDIT_WORDS, crewCanRun, includedCredit };
28 export type { Included } from "../../actions/crew_means.ts";
29
2730 export const KEY_ACTION = "/settings/crew/key";
2831 /** The form is the key and little else: far under the general body limit. */
2932 const KEY_FORM_MAX = 2048;
3033
31 export interface Included {
32 readonly capUsd: number;
33 /** What the host counted the runs actually cost; a reservation or a run's declared budget is never in it. */
34 readonly spentUsd: number;
35 readonly leftUsd: number;
36 }
37
38 /** The credit this forge came with and what is left of it (last heard from its host), or undefined when it has none. */
39 export function includedCredit(app: App): Included | undefined {
40 const f = fundingOf(app.config.dataDir);
41 if (f === undefined || f.capUsd === null) return undefined;
42 const spentUsd = f.spentUsd ?? 0;
43 return { capUsd: f.capUsd, spentUsd, leftUsd: leftUsd({ capUsd: f.capUsd, spentUsd }) };
44 }
45
4634 /** Whether a forge is a hosted child: its host's file is in its data directory. */
4735 export const isHostedForge = (app: App): boolean => existsSync(join(app.config.dataDir, "parent.json"));
4836
49 /** Whether the crew has anything to run on: a key of the owner's, or included credit with money left. */
50 export function crewCanRun(app: App): boolean {
51 if (crewHasCredential(app.db)) return true;
52 const c = includedCredit(app);
53 return c !== undefined && c.leftUsd > 0;
54 }
55
5637 /** Whether the forge has a crew at all, so a line about it is true. */
5738 export const hasCrew = (app: App): boolean => listAgents(app.db).length > 0;
5839
@@ -70,9 +51,6 @@ export function crewNote(app: App): string {
7051 return NO_CREDIT_WORDS;
7152 }
7253
73 /** What a forge whose crew has nothing to run on says, on the page and in the terminal. */
74 export const NO_CREDIT_WORDS = "The crew cannot build until you add a model key or pick a plan.";
75
7654 /** The first screen's line as plain text, for the terminal: the same words as `crewFirstLine`, no links. */
7755 export function crewFirstText(app: App): string {
7856 return crewCanRun(app) ? crewNote(app) : NO_CREDIT_WORDS;
test/new-app-crew.test.ts added +133
@@ -0,0 +1,133 @@
1 /**

Missing tests: the planner's pick is the builder seated; the page confirmation carries the no-credit sentence; seatFirstAskCrew still behaves as before.

2 * Making a new app starts the crew on it (#12175): with no one named, a builder and a reviewer
3 * are seated and the first issue is the builder's; a person's own pick is exactly what they
4 * chose; a forge with nothing to run a crew on seats no one.
5 */
6
7 import { afterEach, beforeEach, expect, test } from "bun:test";
8 import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { newApp } from "../src/actions/apps.ts";
13 import { NO_CREDIT_WORDS } from "../src/actions/crew_means.ts";
14 import { serve } from "../src/http/server.ts";
15 import { createAgentRow, listCrewRoles } from "../src/core/agents.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let dir = "";
22 let app: App;
23 let maria: User;
24 const KEY = process.env["ANTHROPIC_API_KEY"];
25
26 beforeEach(async () => {
27 dir = await mkdtemp(join(tmpdir(), "forge-new-app-crew-"));
28 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
29 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "maria", email: "[email protected]", password: "a-long-new-app-passphrase", isAdmin: true });
30 if (!u.ok) throw new Error("user");
31 maria = u.value;
32 process.env["ANTHROPIC_API_KEY"] = "test-key";
33 });
34 afterEach(async () => {
35 if (KEY === undefined) delete process.env["ANTHROPIC_API_KEY"];
36 else process.env["ANTHROPIC_API_KEY"] = KEY;
37 app.db.close();
38 await rm(dir, { recursive: true, force: true });
39 });
40
41 const seats = (repoId: string) => listCrewRoles(app.db, repoId).map((c) => `${c.agent.user.username}:${c.role}`).sort();
42
43 test("a new app with no one named seats a builder and a reviewer and gives the first issue to the builder", async () => {
44 const made = await newApp(app, maria, { name: "menu", description: "A weekly menu page" });
45 if (!made.ok) throw new Error(made.error);
46 expect(seats(made.value.repo.id)).toEqual(["builder:builder", "reviewer:reviewer"]);
47 expect(made.value.issue?.assigneeName).toBe("builder");
48 });
49
50 test("a person's own pick seats only that agent", async () => {
51 const u = await createUser(app.db, { username: "ada", email: "[email protected]", agent: true, origin: "spawn" });
52 if (!u.ok) throw new Error("agent");
53 createAgentRow(app.db, { userId: u.value.id, roles: ["builder"] });
54 const made = await newApp(app, maria, { name: "menu", description: "A weekly menu page", agent: "ada" });
55 if (!made.ok) throw new Error(made.error);
56 expect(seats(made.value.repo.id)).toEqual(["ada:builder"]);
57 expect(made.value.issue?.assigneeName).toBe("ada");
58 });
59
60 test("a forge with nothing to run a crew on seats no one", async () => {
61 delete process.env["ANTHROPIC_API_KEY"];
62 const made = await newApp(app, maria, { name: "menu", description: "A weekly menu page" });
63 if (!made.ok) throw new Error(made.error);
64 expect(seats(made.value.repo.id)).toEqual([]);
65 expect(made.value.issue?.assigneeName).toBeNull();
66 });
67
68 test("a plan's pick is the builder seated, not the first builder on the forge", async () => {
69 // Two builders on the forge: without the pick, the first one found would be seated.
70 for (const name of ["zed", "ada"]) {
71 const u = await createUser(app.db, { username: name, email: `${name}@agents.spawn.forge.invalid`, agent: true, origin: "spawn" });
72 if (!u.ok) throw new Error("agent");
73 createAgentRow(app.db, { userId: u.value.id, roles: ["builder"] });
74 }
75 const fake = join(dir, "fake-claude");
76 await writeFile(
77 fake,
78 `#!/bin/sh
79 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
80 printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"A menu\\",\\"checklist\\":[\\"x\\"],\\"agent\\":\\"ada\\",\\"confidence\\":0.9}"}'
81 `,
82 );
83 await chmod(fake, 0o755);
84 const was = process.env["FORGE_CLAUDE"];
85 process.env["FORGE_CLAUDE"] = fake;
86 try {
87 const made = await newApp(app, maria, { name: "menu", description: "A weekly menu page", pickAgent: true });
88 if (!made.ok) throw new Error(made.error);
89 expect(made.value.plan?.agent).toBe("ada");
90 expect(made.value.issue?.assigneeName).toBe("ada");
91 expect(seats(made.value.repo.id)).toContain("ada:builder");
92 expect(seats(made.value.repo.id)).not.toContain("zed:builder");
93 } finally {
94 if (was === undefined) delete process.env["FORGE_CLAUDE"];
95 else process.env["FORGE_CLAUDE"] = was;
96 }
97 });
98
99 test("no one named and nothing to run a crew on: the result carries the idle words; otherwise none", async () => {
100 const running = await newApp(app, maria, { name: "menu-a", description: "A weekly menu page" });
101 if (!running.ok) throw new Error(running.error);
102 expect(running.value.idle).toBeUndefined();
103 delete process.env["ANTHROPIC_API_KEY"];
104 const idle = await newApp(app, maria, { name: "menu-b", description: "A weekly menu page" });
105 if (!idle.ok) throw new Error(idle.error);
106 expect(idle.value.idle).toBe(NO_CREDIT_WORDS);
107 });
108
109 test("the web confirmation says the crew cannot build when the forge has nothing to run it on", async () => {
110 delete process.env["ANTHROPIC_API_KEY"];
111 const servers = await serve(app);
112 try {
113 const signin = await fetch(`${servers.url}/signin`, {
114 method: "POST",
115 headers: { origin: "https://forge.test" },
116 body: new URLSearchParams({ username: "maria", password: "a-long-new-app-passphrase" }),
117 redirect: "manual",
118 });
119 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
120 const post = await fetch(`${servers.url}/apps/new`, {
121 method: "POST",
122 headers: { cookie, origin: "https://forge.test" },
123 body: new URLSearchParams({ name: "menu-web", description: "A weekly menu page", agent: "" }),
124 redirect: "manual",
125 });
126 expect(post.status).toBe(303);
127 const jar = [cookie, ...post.headers.getSetCookie().map((c) => c.split(";")[0]!)].join("; ");
128 const landed = await (await fetch(new URL(post.headers.get("location")!, servers.url), { headers: { cookie: jar } })).text();
129 expect(landed).toContain(NO_CREDIT_WORDS);
130 } finally {
131 servers.stop();
132 }
133 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs