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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12215-making-a-new-app-starts-the-crew-on-it-c → main 10 commits fork 4 threads · 4 unresolved
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"; | ||
| 14 | 14 | import { REPO_INIT_FAILED, REPO_NAME_RULES } from "./repo_words.ts"; |
| 15 | 15 | import { NO_SUCH_AGENT } from "./crew_limit_words.ts"; |
| 16 | 16 | import { requireVerified } from "./verify.ts"; |
| 17 | import { seatCrew } from "../spawn/starter_crew.ts"; | |
| 18 | import { NO_CREDIT_WORDS, crewCanRun } from "./crew_means.ts"; | |
| 17 | 19 | import { requireAppRoom } from "./app_limit.ts"; |
| 18 | 20 | import type { App } from "../app.ts"; |
| 19 | 21 | import { checkWord, type CheckWord } from "../apps/check_word.ts"; |
| @@ -285,6 +287,11 @@ export interface NewAppResult { | ||
| 285 | 287 | readonly issue?: Issue; |
| 286 | 288 | /** Who the plan picked and why, when `pickAgent` landed one. */ |
| 287 | 289 | 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; | |
| 288 | 295 | } |
| 289 | 296 | |
| 290 | 297 | export type NewAppError = |
| @@ -423,6 +430,14 @@ export async function newApp( | ||
| 423 | 430 | } |
| 424 | 431 | } |
| 425 | 432 | |
| 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 | ||
| 426 | 441 | let issue: Issue | undefined; |
| 427 | 442 | if (chosen) { |
| 428 | 443 | addCrew(app.db, repo.value.id, chosen.user.id, user.value.id, "builder"); |
| @@ -460,6 +475,7 @@ export async function newApp( | ||
| 460 | 475 | app: record.value, |
| 461 | 476 | ...(issue === undefined ? {} : { issue }), |
| 462 | 477 | ...(picked === undefined ? {} : { plan: picked }), |
| 478 | ...(!agent && !crewCanRun(app) ? { idle: NO_CREDIT_WORDS } : {}), | |
| 463 | 479 | }); |
| 464 | 480 | } |
| 465 | 481 |
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, | ||
| 87 | 87 | * cannot be filled, so the caller says so. Other apps are left as they are. |
| 88 | 88 | */ |
| 89 | 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")); | |
| 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")); | |
| 92 | 108 | if (builder === undefined && (await makeSeat(db, STARTER_SEATS[0]!, founderId))) |
| 93 | 109 | builder = getAgentByName(db, STARTER_SEATS[0]!.name); |
| 94 | if (builder === undefined) return false; | |
| 110 | if (builder === undefined) return undefined; | |
| 95 | 111 | const other = (): ReturnType<typeof listAgents>[number] | undefined => |
| 96 | 112 | listAgents(db).find((a) => a.roles.includes("reviewer") && a.user.id !== builder!.user.id); |
| 97 | 113 | let reviewer = other(); |
| 98 | 114 | if (reviewer === undefined && (await makeSeat(db, STARTER_SEATS[1]!, founderId))) reviewer = other(); |
| 99 | if (reviewer === undefined) return false; | |
| 115 | if (reviewer === undefined) return undefined; | |
| 100 | 116 | seatAgent(db, repo, builder.user.id, founderId, "builder"); |
| 101 | 117 | seatAgent(db, repo, reviewer.user.id, founderId, "reviewer"); |
| 102 | 118 | updateRepoSettings(db, getRepoById(db, repo.id) ?? repo, { autonomy: "autonomous", autonomyBy: founderId }); |
| 103 | return true; | |
| 119 | return builder; | |
| 104 | 120 | } |
| 105 | 121 | |
| 106 | 122 | /** 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( | ||
| 77 | 77 | ctx.toast( |
| 78 | 78 | url === undefined |
| 79 | 79 | ? `${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}`}`, | |
| 81 | 81 | "success", |
| 82 | 82 | ); |
| 83 | 83 | 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> { | ||
| 561 | 561 | made.error === "no_such_template" ? 404 : 422, |
| 562 | 562 | ); |
| 563 | 563 | } |
| 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}`; | |
| 565 | 566 | const url = appsRuntime(ctx.app)?.urlFor(record, "production"); |
| 566 | 567 | const first = |
| 567 | 568 | url === undefined |
| @@ -577,7 +578,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 577 | 578 | : ` #${issue.number} is assigned to ${issue.assigneeName}.`; |
| 578 | 579 | // The repository's home is the app page (repoHome), once the record |
| 579 | 580 | // 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. |
||
| 581 | 582 | } |
| 582 | 583 | |
| 583 | 584 | // ---- the repo home's strip ---- |
src/web/studio/crew_key.ts +5 −27
| @@ -14,8 +14,8 @@ import { listAgents } from "../../core/agents.ts"; | ||
| 14 | 14 | import { saveCrewKey } from "../../actions/crew_key.ts"; |
| 15 | 15 | import { wokeWords } from "../../crew/wake.ts"; |
| 16 | 16 | 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"; | |
| 19 | 19 | import type { Ctx } from "../../http/context.ts"; |
| 20 | 20 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 21 | 21 | import { field, readForm } from "../form.ts"; |
| @@ -24,35 +24,16 @@ import { icon } from "../icons.ts"; | ||
| 24 | 24 | import { flashTo } from "../layout.ts"; |
| 25 | 25 | import { btn } from "../ui.ts"; |
| 26 | 26 | |
| 27 | export { NO_CREDIT_WORDS, crewCanRun, includedCredit }; | |
| 28 | export type { Included } from "../../actions/crew_means.ts"; | |
| 29 | ||
| 27 | 30 | export const KEY_ACTION = "/settings/crew/key"; |
| 28 | 31 | /** The form is the key and little else: far under the general body limit. */ |
| 29 | 32 | const KEY_FORM_MAX = 2048; |
| 30 | 33 | |
| 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 | ||
| 46 | 34 | /** Whether a forge is a hosted child: its host's file is in its data directory. */ |
| 47 | 35 | export const isHostedForge = (app: App): boolean => existsSync(join(app.config.dataDir, "parent.json")); |
| 48 | 36 | |
| 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 | ||
| 56 | 37 | /** Whether the forge has a crew at all, so a line about it is true. */ |
| 57 | 38 | export const hasCrew = (app: App): boolean => listAgents(app.db).length > 0; |
| 58 | 39 | |
| @@ -70,9 +51,6 @@ export function crewNote(app: App): string { | ||
| 70 | 51 | return NO_CREDIT_WORDS; |
| 71 | 52 | } |
| 72 | 53 | |
| 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 | ||
| 76 | 54 | /** The first screen's line as plain text, for the terminal: the same words as `crewFirstLine`, no links. */ |
| 77 | 55 | export function crewFirstText(app: App): string { |
| 78 | 56 | 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 | }); |
new above
offline
Layering: the action layer imports from src/web/. Move crewCanRun to a neutral module.