Public: anyone can read this; the crew can change it.
An app made from a new forge's first ask has autonomy on and a crew on it: today the crew passes with "autonomy is off" #11511
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11508-an-app-made-from-a-new-forge-s-first-ask → main 3 commits fork 5 threads · 5 unresolved
docs/features/starter-crew.md +1 −1
| @@ -10,4 +10,4 @@ A hosted forge is made with a crew: a `builder` and a `reviewer`, seated on ever | ||
| 10 | 10 | |
| 11 | 11 | Try: open `/` on a new forge, paste a key, write one ask. |
| 12 | 12 | |
| 13 | The first ask on a new forge makes a new app named from it and files the ask there as an issue (target `new-app`), startable by a builder when the crew can run. | |
| 13 | The first ask on a new forge makes a new app named from it and files the ask there as an issue (target `new-app`), startable by a builder when the crew can run. When it can, the app is seated by `seatFirstAskCrew` (`src/spawn/starter_crew.ts`) and autonomy goes on as the asker (`autonomyBy`), so the next tick starts the issue instead of pausing with "autonomy is off". A forge with agents of its own keeps them: the first builder and the first reviewer (two different agents) are seated; a role nobody holds is made as the starter `builder` or `reviewer`. Both seats or nothing: if one cannot be filled (say a person already holds the name `reviewer`), autonomy stays off and the page says the crew could not be set to start by itself. Other apps are left as they are. While included credit is left, an app's page never asks for a key. |
src/actions/first_ask.ts +5 −5
| @@ -10,7 +10,8 @@ import type { Repo } from "../core/repos.ts"; | ||
| 10 | 10 | import type { User } from "../core/users.ts"; |
| 11 | 11 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 12 | 12 | import { newAppFromSentence, NEW_APP_ERRORS } from "./apps.ts"; |
| 13 | import { updateRepoSettings } from "./repos.ts"; | |
| 13 | import { getRepoById } from "../core/repos.ts"; | |
| 14 | import { seatFirstAskCrew } from "../spawn/starter_crew.ts"; | |
| 14 | 15 | import { FORGE_REPO_WORDS, NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "./tell_words.ts"; |
| 15 | 16 | import { FROM_WORDS, TOO_LONG_WORDS, tell, type TellError, type Told } from "./tell.ts"; |
| 16 | 17 | import { parseFrom } from "./tell_from.ts"; |
| @@ -64,10 +65,9 @@ export async function firstAsk( | ||
| 64 | 65 | let repo = made.repo; |
| 65 | 66 | let autonomyFailed = false; |
| 66 | 67 | if (crewCanRun) { |
| 67 | const on = await updateRepoSettings(app, actor, repo, { autonomy: "autonomous" }); | |
| 68 | if (on.ok) { | |
| 69 | repo = on.value; | |
| 70 | } else autonomyFailed = true; | |
| 68 | // A builder and a reviewer seated and autonomy on as the asker, so the next tick starts the ask (#11508). | |
| 69 | if (await seatFirstAskCrew(app.db, repo, actor.id)) repo = getRepoById(app.db, repo.id) ?? repo; | |
| 70 | else autonomyFailed = true; | |
| 71 | 71 | } |
| 72 | 72 | const told = await tell(app, actor, repo, text, 1, from); |
| 73 | 73 | return ok({ repo, autonomyFailed, told }); |
src/spawn/starter_crew.ts +54 −15
| @@ -45,24 +45,64 @@ export const hasStarterCrew = (db: Database, orgId: string): boolean => getUserS | ||
| 45 | 45 | * the organisation. Returns whether the crew exists afterwards. |
| 46 | 46 | */ |
| 47 | 47 | export async function seedStarterCrew(db: Database, orgId: string, founderId: string): Promise<boolean> { |
| 48 | if (listAgents(db).length === 0) { | |
| 49 | for (const seat of STARTER_SEATS) { | |
| 50 | const made = await createUser(db, { | |
| 51 | username: seat.name, | |
| 52 | email: `${seat.name}@agents.spawn.forge.invalid`, | |
| 53 | agent: true, | |
| 54 | origin: "spawn", | |
| 55 | }); | |
| 56 | if (!made.ok) return false; | |
| 57 | createAgentRow(db, { userId: made.value.id, brief: seat.brief, roles: [seat.role], createdBy: founderId }); | |
| 58 | } | |
| 59 | } | |
| 60 | if (STARTER_SEATS.some((s) => getAgentByName(db, s.name) === undefined)) return false; | |
| 48 | if (!(await makeStarterAgents(db, founderId))) return false; | |
| 61 | 49 | setUserSetting(db, orgId, MARK, "on"); |
| 62 | 50 | for (const repo of listReposOwnedBy(db, orgId)) seatStarterCrew(db, repo, founderId); |
| 63 | 51 | return true; |
| 64 | 52 | } |
| 65 | 53 | |
| 54 | /** Make one starter agent; false when its name could not be taken. */ | |
| 55 | async function makeSeat(db: Database, seat: Seat, founderId: string): Promise<boolean> { | |
| 56 | const made = await createUser(db, { | |
| 57 | username: seat.name, | |
| 58 | email: `${seat.name}@agents.spawn.forge.invalid`, | |
| 59 | agent: true, | |
| 60 | origin: "spawn", | |
| 61 | }); | |
| 62 | if (!made.ok) return false; | |
| 63 | createAgentRow(db, { userId: made.value.id, brief: seat.brief, roles: [seat.role], createdBy: founderId }); | |
| 64 | return true; | |
| 65 | } | |
| 66 | ||
| 67 | /** Make the builder and reviewer when the forge has no agent at all; true when both exist afterwards. */ | |
| 68 | async function makeStarterAgents(db: Database, founderId: string): Promise<boolean> { | |
| 69 | if (listAgents(db).length === 0) { | |
| 70 | for (const seat of STARTER_SEATS) if (!(await makeSeat(db, seat, founderId))) return false; | |
| 71 | } | |
| 72 | return STARTER_SEATS.every((s) => getAgentByName(db, s.name) !== undefined); | |
| 73 | } | |
| 74 | ||
| 75 | /** Seat one agent on a repository in one role; `setCrewRole` as well, because an existing seat is kept by `addCrew`. */ | |
| 76 | function seatAgent(db: Database, repo: Repo, userId: string, founderId: string, role: CrewRole): void { | |
| 77 | addCrew(db, repo.id, userId, founderId, role); | |
| 78 | setCrewRole(db, repo.id, userId, role); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * The first ask's app (#11508): a builder and a reviewer are seated on this | |
| 83 | * one app and autonomy is turned on as the founder, so the next tick starts | |
| 84 | * the ready issue. A forge with agents of its own keeps them (the first of | |
| 85 | * each role, two different agents); a role nobody holds is made as the starter | |
| 86 | * agent. Both seats or nothing: false (and autonomy untouched) when either | |
| 87 | * cannot be filled, so the caller says so. Other apps are left as they are. | |
| 88 | */ | |
| 89 | 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")); | |
| 92 | if (builder === undefined && (await makeSeat(db, STARTER_SEATS[0]!, founderId))) | |
| 93 | builder = getAgentByName(db, STARTER_SEATS[0]!.name); | |
outdated
seatFirstAskCrew duplicates seatStarterCrew and makeStarterAgents: the seeding loop here, then agents.find, then addCrew plus setCrewRole twice. Reuse seatStarterCrew's seating, or extract one shared 'seat these two agents on this repo' helper. One concept per module, no third copy. Also check whether addCrew followed by setCrewRole is redundant. |
||
| 94 | if (builder === undefined) return false; | |
| 95 | const other = (): ReturnType<typeof listAgents>[number] | undefined => | |
| 96 | listAgents(db).find((a) => a.roles.includes("reviewer") && a.user.id !== builder!.user.id); | |
| 97 | let reviewer = other(); | |
| 98 | if (reviewer === undefined && (await makeSeat(db, STARTER_SEATS[1]!, founderId))) reviewer = other(); | |
| 99 | if (reviewer === undefined) return false; | |
| 100 | seatAgent(db, repo, builder.user.id, founderId, "builder"); | |
| 101 | seatAgent(db, repo, reviewer.user.id, founderId, "reviewer"); | |
outdated
A forge with no other reviewer ends up with only a builder seated and still returns true, so autonomy goes on with no reviewer. Either require both seats, or say in the app's page that no reviewer is seated. Add a test for this case. |
||
| 102 | updateRepoSettings(db, getRepoById(db, repo.id) ?? repo, { autonomy: "autonomous", autonomyBy: founderId }); | |
| 103 | return true; | |
| 104 | } | |
| 105 | ||
| 66 | 106 | /** Seat the starter crew on one repository and turn autonomy on, unless a person already chose either. */ |
| 67 | 107 | export function seatStarterCrew(db: Database, repo: Repo, founderId: string): void { |
| 68 | 108 | // The forge's own code is not an app the owner made: it is seated by `adoptForgeCode`, autonomy as the owner sets it. |
| @@ -70,8 +110,7 @@ export function seatStarterCrew(db: Database, repo: Repo, founderId: string): vo | ||
| 70 | 110 | for (const seat of STARTER_SEATS) { |
| 71 | 111 | const agent = getAgentByName(db, seat.name); |
| 72 | 112 | if (agent === undefined) continue; |
| 73 | addCrew(db, repo.id, agent.user.id, founderId, seat.role); | |
| 74 | setCrewRole(db, repo.id, agent.user.id, seat.role); | |
| 113 | seatAgent(db, repo, agent.user.id, founderId, seat.role); | |
| 75 | 114 | } |
| 76 | 115 | const fresh = getRepoById(db, repo.id) ?? repo; |
| 77 | 116 | if (fresh.autonomy === "off" && fresh.autonomyBy === null) |
src/web/studio/crew_key.ts +4 −1
| @@ -87,12 +87,15 @@ const keyField = (): Html => html`<form class="stack" method="post" action="${KE | ||
| 87 | 87 | |
| 88 | 88 | /** What the crew runs on, said once, with the field when a key would change anything (`compact`: a line and a link, no field). */ |
| 89 | 89 | export function crewKeyCard(ctx: Ctx, compact = false): Html | null { |
| 90 | // Credit left is something to run on: the card never asks for a key then, in any form (#11508). | |
| 90 | 91 | if (crewHasCredential(ctx.app.db)) return null; |
| 91 | 92 | const admin = ctx.auth.user?.isAdmin === true; |
| 92 | 93 | // On a repository page: one line and a link, the field lives on the home page. |
| 93 | if (compact) | |
| 94 | if (compact) { | |
| 95 | if (crewCanRun(ctx.app)) return null; | |
| 94 | 96 | return html`<p class="small muted" id="crew-key-note" role="status">${icon("alert")} The crew needs a model key to start. |
| 95 | 97 | ${admin ? html`<a href="/#crew-key-note">Add one on the home page</a>.` : "Ask this forge's admin to add one."}</p>`; |
| 98 | } | |
| 96 | 99 | const credit = includedCredit(ctx.app); |
| 97 | 100 | if (credit !== undefined && credit.leftUsd > 0) |
| 98 | 101 | return html`<section class="settings-block" id="crew-key-note" role="status"> |
test/first-ask-new-app.test.ts +39 −1
| @@ -9,9 +9,11 @@ import { listIssues, MAX_BODY } from "../src/core/issues.ts"; | ||
| 9 | 9 | import { TOO_LONG_WORDS } from "../src/actions/tell.ts"; |
| 10 | 10 | import { FORGE_REPO_WORDS, SAY_SOMETHING_WORDS } from "../src/actions/tell_words.ts"; |
| 11 | 11 | import { listReposOwnedBy } from "../src/core/repos.ts"; |
| 12 | import { autonomyStatus } from "../src/crew/autonomy.ts"; | |
| 13 | import { listCrewRoles } from "../src/core/agents.ts"; | |
| 12 | 14 | import { recentThoughts } from "../src/core/thoughts.ts"; |
| 13 | 15 | import { fundingOf, keepFunding } from "../src/spawn/funding.ts"; |
| 14 | import { crewCanRun } from "../src/web/studio/crew_key.ts"; | |
| 16 | import { crewCanRun, crewKeyCard } from "../src/web/studio/crew_key.ts"; | |
| 15 | 17 | import { getUserByName } from "../src/core/users.ts"; |
| 16 | 18 | import { deriveAppName } from "../src/web/pages/apps-name.ts"; |
| 17 | 19 | import { claimedForge, type ClaimedForge } from "./helpers/claimed-forge.ts"; |
| @@ -50,6 +52,14 @@ test("the first tell makes one app, files one open issue on it, keeps no thought | ||
| 50 | 52 | // The forge's included credit lets the crew run: the new app starts autonomous (box 2). |
| 51 | 53 | expect(crewCanRun(forge.child)).toBe(true); |
| 52 | 54 | expect(repo.autonomy).toBe("autonomous"); |
| 55 | // Autonomy is on as the asker and a builder and reviewer are seated: the tick does not pause (#11508). | |
| 56 | expect(repo.autonomyBy).toBe(maria.id); | |
| 57 | expect(autonomyStatus(forge.child, repo).paused).toBeNull(); | |
| 58 | expect(listCrewRoles(db, repo.id).map((c) => c.role).sort()).toEqual(["builder", "reviewer"]); | |
| 59 | // With credit left the app's page asks for no key and says nobody is missing. | |
| 60 | const page = await (await fetch(`${forge.base}/maria/${repo.name}`, { headers: { cookie: forge.owner } })).text(); | |
| 61 | expect(page).not.toContain("needs a model key"); | |
| 62 | expect(page).not.toContain("Nobody on the crew yet"); | |
| 53 | 63 | expect(recentThoughts(db, maria.id, 10).length).toBe(0); |
| 54 | 64 | }); |
| 55 | 65 | |
| @@ -110,3 +120,31 @@ test("an ask that would name the forge's own repository is refused and makes no | ||
| 110 | 120 | else forge.child.config.forgeRepo = before; |
| 111 | 121 | } |
| 112 | 122 | }); |
| 123 | ||
| 124 | const cardCtx = (admin: boolean) => | |
| 125 | ({ app: forge.child, auth: { user: { isAdmin: admin } } }) as unknown as Parameters<typeof crewKeyCard>[0]; | |
| 126 | ||
| 127 | test("an app's page never asks for a key while included credit is left (#11508)", () => { | |
| 128 | expect(crewCanRun(forge.child)).toBe(true); | |
outdated
This test builds a fake ctx with an |
||
| 129 | for (const admin of [true, false]) expect(crewKeyCard(cardCtx(admin), true)).toBeNull(); | |
| 130 | }); | |
| 131 | ||
| 132 | test("with credit left the home card, not the app's page, still offers the key to an admin only", () => { | |
| 133 | expect(String(crewKeyCard(cardCtx(true), false))).toContain("crew-key-form"); | |
| 134 | expect(String(crewKeyCard(cardCtx(false), false))).not.toContain("crew-key-form"); | |
| 135 | }); | |
| 136 | ||
| 137 | test("with the credit spent an app's page says a key is needed, with the link for an admin only", () => { | |
| 138 | const dir = forge.child.config.dataDir; | |
| 139 | const funding = fundingOf(dir)!; | |
| 140 | keepFunding(dir, { token: funding.token, capUsd: 2, spentUsd: 2 }); | |
| 141 | try { | |
| 142 | expect(String(crewKeyCard(cardCtx(true), true))).toContain("needs a model key"); | |
| 143 | expect(String(crewKeyCard(cardCtx(true), true))).toContain("/#crew-key-note"); | |
| 144 | const person = String(crewKeyCard(cardCtx(false), true)); | |
| 145 | expect(person).toContain("needs a model key"); | |
| 146 | expect(person).not.toContain("/#crew-key-note"); | |
| 147 | } finally { | |
| 148 | keepFunding(dir, funding); | |
| 149 | } | |
| 150 | }); |
test/first-ask-seating.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * Seating the first ask's app (#11508): the forge's own agents are kept, a | |
| 3 | * missing role is made, and a seat that cannot be filled says so (false). | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterEach, beforeEach, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createAgentRow, listAgents, listCrewRoles } from "../src/core/agents.ts"; | |
| 12 | import { createRepo as createRepoRow, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { seatFirstAskCrew } from "../src/spawn/starter_crew.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dir = ""; | |
| 20 | let app: App; | |
| 21 | let owner = ""; | |
| 22 | let repo: Repo; | |
| 23 | ||
| 24 | beforeEach(async () => { | |
| 25 | dir = await mkdtemp(join(tmpdir(), "forge-first-ask-seat-")); | |
| 26 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 27 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "maria", email: "[email protected]", password: "a-long-seating-passphrase", isAdmin: true }); | |
| 28 | if (!u.ok) throw new Error("user"); | |
| 29 | owner = u.value.id; | |
| 30 | const r = await createRepoRow(app.db, join(dir, "repos"), { ownerId: owner, name: "menu" }); | |
| 31 | if (!r.ok) throw new Error("repo"); | |
| 32 | repo = r.value; | |
| 33 | }); | |
| 34 | afterEach(async () => { | |
| 35 | app.db.close(); | |
| 36 | await rm(dir, { recursive: true, force: true }); | |
| 37 | }); | |
| 38 | ||
| 39 | async function agent(name: string, roles: ("builder" | "reviewer")[]): Promise<string> { | |
| 40 | const u = await createUser(app.db, { username: name, email: `${name}@agents.spawn.forge.invalid`, agent: true, origin: "spawn" }); | |
| 41 | if (!u.ok) throw new Error("agent"); | |
| 42 | createAgentRow(app.db, { userId: u.value.id, roles }); | |
| 43 | return u.value.id; | |
| 44 | } | |
| 45 | ||
| 46 | const seats = () => listCrewRoles(app.db, repo.id).map((c) => `${c.agent.user.username}:${c.role}`).sort(); | |
| 47 | ||
| 48 | test("a forge with no agents gets the starter pair, seated, autonomy on as the asker", async () => { | |
| 49 | expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(true); | |
| 50 | expect(seats()).toEqual(["builder:builder", "reviewer:reviewer"]); | |
| 51 | const fresh = getRepoById(app.db, repo.id)!; | |
| 52 | expect(fresh.autonomy).toBe("autonomous"); | |
| 53 | expect(fresh.autonomyBy).toBe(owner); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a forge with its own agents keeps them and makes none", async () => { | |
| 57 | await agent("ada", ["builder"]); | |
| 58 | await agent("grace", ["reviewer"]); | |
| 59 | expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(true); | |
| 60 | expect(seats()).toEqual(["ada:builder", "grace:reviewer"]); | |
| 61 | expect(listAgents(app.db).length).toBe(2); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a builder with no reviewer on the forge gets the starter reviewer beside them", async () => { | |
| 65 | await agent("ada", ["builder"]); | |
| 66 | expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(true); | |
| 67 | expect(seats()).toEqual(["ada:builder", "reviewer:reviewer"]); | |
| 68 | }); | |
| 69 | ||
| 70 | test("one agent holding both roles is not seated as their own reviewer", async () => { | |
| 71 | await agent("ada", ["builder", "reviewer"]); | |
| 72 | expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(true); | |
| 73 | expect(seats()).toEqual(["ada:builder", "reviewer:reviewer"]); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a seat that cannot be filled returns false and leaves autonomy off", async () => { | |
| 77 | // A person already holds the name the starter reviewer would take. | |
| 78 | await agent("ada", ["builder"]); | |
| 79 | const p = await createUser(app.db, { origin: "seed", trusted: true, username: "reviewer", email: "[email protected]", password: "a-long-seating-passphrase" }); | |
| 80 | expect(p.ok).toBe(true); | |
| 81 | expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(false); | |
| 82 | expect(listCrewRoles(app.db, repo.id)).toEqual([]); | |
| 83 | expect(getRepoById(app.db, repo.id)!.autonomy).toBe("off"); | |
| 84 | }); |
The first guard (listAgents empty then makeStarterAgents) and this per-role seeding loop overlap: when there are no agents the loop seeds nothing new, and the name check hides a failure silently. A failed makeSeat is ignored here, so the function can return true with a reviewer missing and nobody told. Pick one path and surface the failure.