Public: anyone can read this; the crew can change it.
A new forge comes with a crew that builds at once: agents made, autonomy on, the plan's included credit to start, and one obvious place to add your own model key #11160
mason/forge:mason/11154-a-new-forge-comes-with-a-crew-that-build → main 4 commits fork
20 files
changed +439
−25
docs/MCP.md +4
| @@ -133,7 +133,11 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 133 | 133 | | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 | |
| 134 | 134 | | `lift` | — | Lift an issue out from behind the Later gate | 214 | |
| 135 | 135 | | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 | |
| 136 | <<<<<<< HEAD | |
| 137 | | `limits` | — | Every limit the owner can hit, value and bounds | 926 | | |
| 138 | ======= | |
| 136 | 139 | | `limits` | — | Every limit the owner can hit, value and bounds | 949 | |
| 140 | >>>>>>> origin/main | |
| 137 | 141 | | `maintenance` | `status`, `prepare`, `resume` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 256 | |
| 138 | 142 | | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 | |
| 139 | 143 | | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 | |
docs/features/starter-crew.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # A new forge builds at once (#11154) | |
| 2 | ||
| 3 | A hosted forge is made with a crew: a `builder` and a `reviewer`, seated on every app its owner makes, with autonomy on (`src/spawn/starter_crew.ts`). A forge handed a crew by its host keeps that one. | |
| 4 | ||
| 5 | **Bring your own key** is the main road. The forge's home shows one field when the crew has nothing to run on: paste a model API key or a Claude setup token. It is sealed in that forge (`POST /settings/crew/key`, `src/web/studio/crew_key.ts`), offered to the owner's crew, and never leaves it. The same field is at the top of Settings → Subscriptions. | |
| 6 | ||
| 7 | **Included credit** is the on-ramp. When no key was handed over, the host lends the new forge `spawn.included_credit_usd` dollars (default 2, once; 0 turns it off) through the child credit (`src/spawn/included_credit.ts`). The crew runs on the host's model door up to that amount; past it the door answers 402 and the forge says: add your own key to keep building, or wait for next month. The forge never holds the host's key, only a capped bearer. Settings → Limits shows the worst case a day: new accounts an hour × 24 × forges a person may own × the credit. | |
| 8 | ||
| 9 | Try: open `/` on a new forge, paste a key, write one ask. |
src/actions/repos.ts +2
| @@ -78,6 +78,7 @@ import { | ||
| 78 | 78 | import { validPolishShare } from "../crew/polish_share.ts"; |
| 79 | 79 | import { removeInstallCache } from "../crew/workdir.ts"; |
| 80 | 80 | import type { SettingsChanged } from "../core/settings_changed.ts"; |
| 81 | import { seatStarterCrew } from "../spawn/starter_crew.ts"; | |
| 81 | 82 | |
| 82 | 83 | export interface CreateRepoInput { |
| 83 | 84 | /** Owner name: the actor themselves (default) or an org they can write to. */ |
| @@ -180,6 +181,7 @@ export async function createRepo( | ||
| 180 | 181 | }); |
| 181 | 182 | if (!created.ok) return created; |
| 182 | 183 | storageChanged(app, ownerId); |
| 184 | seatStarterCrew(app.db, created.value, user.value.id); | |
| 183 | 185 | app.bus.append({ |
| 184 | 186 | kind: "repo.created", |
| 185 | 187 | repoId: created.value.id, |
src/actions/spawn.ts +7
| @@ -39,6 +39,8 @@ import { runGit } from "../git/env.ts"; | ||
| 39 | 39 | import { repoDir } from "../git/repos.ts"; |
| 40 | 40 | import { commitFiles } from "../git/write.ts"; |
| 41 | 41 | import { adoptForgeCode, forkForgeCode } from "../spawn/forge_code.ts"; |
| 42 | import { lendIncludedCredit } from "../spawn/included_credit.ts"; | |
| 43 | import { seedStarterCrew } from "../spawn/starter_crew.ts"; | |
| 42 | 44 | import { birthFromTemplate, type TemplateMark } from "../spawn/template_birth.ts"; |
| 43 | 45 | import { sha256 } from "../kernel/hash.ts"; |
| 44 | 46 | import { secret } from "../kernel/id.ts"; |
| @@ -324,6 +326,7 @@ async function make( | ||
| 324 | 326 | const copied = await copyInto(app, owner, plan, ledger, deps.modelKey, deps.modelEndpoint); |
| 325 | 327 | if (!copied.ok) return failed(app, deps, "copy", copied.error); |
| 326 | 328 | const claimUrl = claimLink(liveChildUrl(app, plan.name), copied.value); |
| 329 | lendIncludedCredit(app, plan.name, childDirOf(app, ledger.bodyId), deps.modelKey !== undefined); | |
| 327 | 330 | |
| 328 | 331 | grantPlanCredit(app, plan, ledger.bodyId); |
| 329 | 332 | ledger.done = true; |
| @@ -503,6 +506,10 @@ async function copyInto( | ||
| 503 | 506 | setCrewRole(child.db, repo.id, theirs.user.id, role); |
| 504 | 507 | } |
| 505 | 508 | |
| 509 | // A forge with no crew of its own comes with a builder and a reviewer, | |
| 510 | // seated on its apps with autonomy on (#11154). | |
| 511 | await seedStarterCrew(child.db, org.id, founder.id); | |
| 512 | ||
| 506 | 513 | // The fork is the child's own source: its `forgeRepo`, and the crew seated on it. |
| 507 | 514 | adoptForgeCode(child, org.username, founder.id); |
| 508 | 515 |
src/http/settings_routes.ts +2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * in the pattern list the usage page reads, so this module never imports it. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { crewKeyAdd } from "../web/studio/crew_key.ts"; | |
| 10 | 11 | import type { Handler } from "./context.ts"; |
| 11 | 12 | import type { Router } from "./router.ts"; |
| 12 | 13 | import { parentLinkStart } from "./rp.ts"; |
| @@ -174,6 +175,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 174 | 175 | // The crew: a forge admin's section, hidden from everyone else. |
| 175 | 176 | router.add("GET", "/settings/subscriptions", subscriptionsPage); |
| 176 | 177 | router.add("POST", "/settings/subscriptions", subscriptionAdd); |
| 178 | router.add("POST", "/settings/crew/key", crewKeyAdd); | |
| 177 | 179 | router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete); |
| 178 | 180 | router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake); |
| 179 | 181 | router.add( |
src/settings/limits.ts +9
| @@ -26,6 +26,7 @@ export type LimitKey = | ||
| 26 | 26 | | "crew.concurrency" |
| 27 | 27 | | "checks.concurrency" |
| 28 | 28 | | "agent.budget_max_usd" |
| 29 | | "spawn.included_credit_usd" | |
| 29 | 30 | | "subscription.budget_max_usd" |
| 30 | 31 | | "app.members_max" |
| 31 | 32 | | "front.pinned_max" |
| @@ -95,6 +96,14 @@ export const LIMITS: Record<LimitKey, LimitDef> = { | ||
| 95 | 96 | max: 1_000_000, |
| 96 | 97 | reason: "A crew member's per-run budget is spent unattended, so what one run may cost has a top.", |
| 97 | 98 | }, |
| 99 | "spawn.included_credit_usd": { | |
| 100 | label: "Crew credit a new forge comes with", | |
| 101 | unit: "dollars", | |
| 102 | default: 2, | |
| 103 | min: 0, | |
| 104 | max: 1_000, | |
| 105 | reason: "A new forge's crew runs on this forge's model access until its owner adds a key; 0 turns it off. Worst case a day is the visitor limit times this.", | |
| 106 | }, | |
| 98 | 107 | "subscription.budget_max_usd": { |
| 99 | 108 | label: "Pool account budget ceiling", |
| 100 | 109 | unit: "dollars", |
src/spawn/included_credit.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * The crew credit a new forge comes with (#11154): the `spawn.included_credit_usd` | |
| 3 | * limit, lent through the existing child credit (`spawn/credit.ts`) when the | |
| 4 | * forge was not handed a model key of its own. Lent once: a retry or a | |
| 5 | * restart finds the credit and leaves it, spent or not. The child never sees | |
| 6 | * the host's key, only a bearer good for the host's model door, capped. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { limitValue } from "../settings/limits.ts"; | |
| 11 | import { readCredit, setCap } from "./credit.ts"; | |
| 12 | import { keepFunding } from "./funding.ts"; | |
| 13 | ||
| 14 | /** The dollars lent, or 0 when none (off, already lent, or the forge brought its own key). */ | |
| 15 | export function lendIncludedCredit(app: App, name: string, childDir: string, hasOwnKey: boolean): number { | |
| 16 | if (hasOwnKey || readCredit(app, name) !== undefined) return 0; | |
| 17 | const usd = limitValue(app.db, "spawn.included_credit_usd"); | |
| 18 | if (!(usd > 0)) return 0; | |
| 19 | const set = setCap(app, name, usd, false, (token, credit) => | |
| 20 | keepFunding(childDir, { token, capUsd: credit.capUsd, spentUsd: credit.spentUsd }), | |
| 21 | ); | |
| 22 | return typeof set === "object" ? set.capUsd : 0; | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * The most the included credit can cost in a day: new accounts an hour for | |
| 27 | * the whole forge, over 24 hours, each with the forges one person may own, | |
| 28 | * each lent the credit. Every term is a limit an admin sets here. | |
| 29 | */ | |
| 30 | export function worstCaseDailyUsd(app: App): number { | |
| 31 | return ( | |
| 32 | limitValue(app.db, "signup.per_forge_hour") * | |
| 33 | 24 * | |
| 34 | limitValue(app.db, "forges.per_person") * | |
| 35 | limitValue(app.db, "spawn.included_credit_usd") | |
| 36 | ); | |
| 37 | } |
src/spawn/starter_crew.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The crew a new forge comes with (#11154): a builder and a reviewer, seated | |
| 3 | * on every app the owner makes, with autonomy on, so the first ask is taken | |
| 4 | * with no setup. Written into a child's own database by the parent at spawn | |
| 5 | * (`seedStarterCrew`); the owner's organisation carries a mark, and a | |
| 6 | * repository made later under it is seated the same way (`seatStarterCrew`, | |
| 7 | * called by `createRepo`). A forge that already has crew is left as it is. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { addCrew, createAgentRow, getAgentByName, listAgents, setCrewRole, type CrewRole } from "../core/agents.ts"; | |
| 12 | import { getRepoById, listReposOwnedBy, updateRepoSettings, type Repo } from "../core/repos.ts"; | |
| 13 | import { getUserSetting, setUserSetting } from "../core/settings.ts"; | |
| 14 | import { createUser } from "../core/users.ts"; | |
| 15 | import { FORGE_CODE_NAME } from "./forge_code.ts"; | |
| 16 | ||
| 17 | const MARK = "starter_crew"; | |
| 18 | ||
| 19 | interface Seat { | |
| 20 | readonly name: string; | |
| 21 | readonly role: CrewRole; | |
| 22 | readonly brief: string; | |
| 23 | } | |
| 24 | ||
| 25 | export const STARTER_SEATS: readonly Seat[] = [ | |
| 26 | { | |
| 27 | name: "builder", | |
| 28 | role: "builder", | |
| 29 | brief: | |
| 30 | "You build what the issue asks, in the smallest change that works. Read the repository first, keep its shape, add a test for every behaviour you add, and run the tests before you push.", | |
| 31 | }, | |
| 32 | { | |
| 33 | name: "reviewer", | |
| 34 | role: "reviewer", | |
| 35 | brief: | |
| 36 | "You review pulls against the issue they answer. Check that behaviour is tested, that inputs are validated and outputs escaped, and that nothing beyond the ask slipped in. Say plainly what to change.", | |
| 37 | }, | |
| 38 | ]; | |
| 39 | ||
| 40 | /** Whether this organisation was given the starter crew, so what it makes is seated on creation. */ | |
| 41 | export const hasStarterCrew = (db: Database, orgId: string): boolean => getUserSetting(db, orgId, MARK) === "on"; | |
| 42 | ||
| 43 | /** | |
| 44 | * Make the builder and reviewer when the forge has no crew at all, and mark | |
| 45 | * the organisation. Returns whether the crew exists afterwards. | |
| 46 | */ | |
| 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; | |
| 61 | setUserSetting(db, orgId, MARK, "on"); | |
| 62 | for (const repo of listReposOwnedBy(db, orgId)) seatStarterCrew(db, repo, founderId); | |
| 63 | return true; | |
| 64 | } | |
| 65 | ||
| 66 | /** Seat the starter crew on one repository and turn autonomy on, unless a person already chose either. */ | |
| 67 | export function seatStarterCrew(db: Database, repo: Repo, founderId: string): void { | |
| 68 | // The forge's own code is not an app the owner made: it is seated by `adoptForgeCode`, autonomy as the owner sets it. | |
| 69 | if (!hasStarterCrew(db, repo.ownerId) || repo.name === FORGE_CODE_NAME) return; | |
| 70 | for (const seat of STARTER_SEATS) { | |
| 71 | const agent = getAgentByName(db, seat.name); | |
| 72 | 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); | |
| 75 | } | |
| 76 | const fresh = getRepoById(db, repo.id) ?? repo; | |
| 77 | if (fresh.autonomy === "off" && fresh.autonomyBy === null) | |
| 78 | updateRepoSettings(db, fresh, { autonomy: "autonomous", autonomyBy: founderId }); | |
| 79 | } |
src/web/pages/admin-limits.ts +3
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | * who, when, from what to what. Anyone else gets the not-found page. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { usdWords } from "../../spawn/credit.ts"; | |
| 9 | import { worstCaseDailyUsd } from "../../spawn/included_credit.ts"; | |
| 8 | 10 | import { QUOTA_INVALID } from "../../actions/admin_limits_words.ts"; |
| 9 | 11 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| 10 | 12 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; |
| @@ -201,6 +203,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 201 | 203 | ${historyBlock("Invite links made", inviteRows)} |
| 202 | 204 | <section class="settings-block mt"> |
| 203 | 205 | <h2>Limits</h2> |
| 206 | <p class="small muted mb" id="included-credit-worst">Worst case a day for the crew credit new forges come with: ${usdWords(worstCaseDailyUsd(ctx.app))} (new accounts an hour × 24 × forges a person may own × the credit).</p> | |
| 204 | 207 | <div class="table-scroll"> |
| 205 | 208 | <table class="table setting-table" data-dense> |
| 206 | 209 | <caption class="sr-only">Limits, their values and how to change them</caption> |
src/web/pages/apps.ts +2
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * 404 for everyone. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { crewKeyCard } from "../studio/crew_key.ts"; | |
| 15 | 16 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 16 | 17 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 17 | 18 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| @@ -328,6 +329,7 @@ function newAppForm( | ||
| 328 | 329 | ${pageHead("New app", { |
| 329 | 330 | sub: "Describe what it should do. You get a repository, a first issue, a crew member on it, a live app, and a preview for every change.", |
| 330 | 331 | })} |
| 332 | ${crewKeyCard(ctx)} | |
| 331 | 333 | ${html`<form method="post" action="/apps/new"> |
| 332 | 334 | ${values.template |
| 333 | 335 | ? formField({ label: "Copy of", name: "template", value: values.template, readonly: true, mono: true }) |
src/web/pages/claim.ts +5 −2
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | * so before anyone submits, and where to remove it. |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | import { crewCanRun, hasCrew } from "../studio/crew_key.ts"; | |
| 21 | 22 | import { PASSWORD_TOO_LONG, withinPasswordCap } from "../../core/hash-gate.ts"; |
| 22 | 23 | import { givenEmail, MIN_SIGNUP_PASSWORD, noEmail } from "../../actions/users.ts"; |
| 23 | 24 | import type { Credential } from "../../auth/webauthn.ts"; |
| @@ -152,8 +153,10 @@ function claimForm( | ||
| 152 | 153 | ${pageHead("This forge is yours")} |
| 153 | 154 | <section class="settings-block"> |
| 154 | 155 | <p class="mb"> |
| 155 | ${spawned.org.username} was made for you, with its apps and a crew | |
| 156 | ready to build them. Make your account to take it: this first account | |
| 156 | ${spawned.org.username} was made for you, with its apps and | |
| 157 | ${crewCanRun(ctx.app) && hasCrew(ctx.app) | |
| 158 | ? "a crew ready to build them" | |
| 159 | : "a crew that builds them once you add a model key"}. Make your account to take it: this first account | |
| 157 | 160 | is the forge's admin, so it can change everything here. |
| 158 | 161 | </p> |
| 159 | 162 | <p class="mb" id="claim-access">${accessSentence(spawned, spawner)}</p> |
src/web/pages/subscriptions.ts +12 −2
| @@ -60,6 +60,8 @@ import { whenText } from "../../crew/limits.ts"; | ||
| 60 | 60 | import { accountSections } from "./origins.ts"; |
| 61 | 61 | import { liveRegionsJs } from "../client.ts"; |
| 62 | 62 | import { markOf } from "../../core/marks.ts"; |
| 63 | import { crewKeyCard, includedCredit, isHostedForge } from "../studio/crew_key.ts"; | |
| 64 | import { usdWords } from "../../spawn/credit.ts"; | |
| 63 | 65 | import { limitValue } from "../../settings/limits.ts"; |
| 64 | 66 | |
| 65 | 67 | const POOL = "/settings/subscriptions"; |
| @@ -108,8 +110,11 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 108 | 110 | const denied = requireAdmin(ctx); |
| 109 | 111 | if (denied) return denied; |
| 110 | 112 | const now = new Date(); |
| 113 | const included = includedCredit(ctx.app); | |
| 114 | // On a hosted forge the platform row is the host's included credit, said | |
| 115 | // as what is left of it, or it is not shown (#11154). | |
| 111 | 116 | const pool = timePhase(ctx.request, "credentials", () => |
| 112 | listCredentials(ctx.app.db), | |
| 117 | listCredentials(ctx.app.db).filter((s) => s.id !== PLATFORM || !isHostedForge(ctx.app)), | |
| 113 | 118 | ); |
| 114 | 119 | const places = timePhase(ctx.request, "places", () => |
| 115 | 120 | listGrantPlaces(ctx.app.db), |
| @@ -270,6 +275,11 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 270 | 275 | { extra: "setting-list" }, |
| 271 | 276 | ); |
| 272 | 277 | |
| 278 | const own = pool.some((s) => s.id !== PLATFORM) ? null : crewKeyCard(ctx); | |
| 279 | const lent = | |
| 280 | included === undefined | |
| 281 | ? null | |
| 282 | : html`<p class="small muted" id="included-credit">${icon("key")} Included crew credit: ${usdWords(included.leftUsd)} of ${usdWords(included.capUsd)} left. When it is spent the crew stops until you add your own key.</p>`; | |
| 273 | 283 | const add = html`<section class="settings-block"><h2>Add one</h2> |
| 274 | 284 | <form class="stack" method="post" action="${POOL}"> |
| 275 | 285 | <div class="field"> |
| @@ -336,7 +346,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 336 | 346 | |
| 337 | 347 | const response = shell( |
| 338 | 348 | ctx, |
| 339 | html`${list} ${none === null ? null : html`<div class="mt">${none}</div>`} | |
| 349 | html`${lent} ${own} ${isHostedForge(ctx.app) && sealed.length === 0 ? null : list} ${none === null || isHostedForge(ctx.app) ? null : html`<div class="mt">${none}</div>`} | |
| 340 | 350 | <p class="mt muted small">What they spent: <a href="/settings/spend">Usage → Spend</a>.</p> |
| 341 | 351 | <div class="mt">${add}</div> |
| 342 | 352 | ${used === null ? null : html`<div class="mt">${used}</div>`}`, |
src/web/studio/app.ts +3 −3
| @@ -61,7 +61,7 @@ import { notesCard } from "./notes.ts"; | ||
| 61 | 61 | import { adapterCard } from "./adapter.ts"; |
| 62 | 62 | import { appPanels } from "./previews.ts"; |
| 63 | 63 | import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts"; |
| 64 | import { crewKeyNote } from "./crew.ts"; | |
| 64 | import { crewKeyCard } from "./crew_key.ts"; | |
| 65 | 65 | import { |
| 66 | 66 | notePhase, |
| 67 | 67 | takeSessionTime, |
| @@ -500,7 +500,7 @@ export async function studioApp( | ||
| 500 | 500 | ? archivedParentLine(ctx, repo) |
| 501 | 501 | : followingCard(ctx, repo, following)} |
| 502 | 502 | <div id="app-head">${head}</div> |
| 503 | ${ownerFacts ? crewKeyNote(ctx) : null} | |
| 503 | ${ownerFacts ? crewKeyCard(ctx, true) : null} | |
| 504 | 504 | <div class="stack"> |
| 505 | 505 | ${adapter} |
| 506 | 506 | ${tellRegion} |
| @@ -520,7 +520,7 @@ export async function studioApp( | ||
| 520 | 520 | <div id="app-head">${head}</div> |
| 521 | 521 | ${askingLine(ctx, repo)} |
| 522 | 522 | ${policy} |
| 523 | ${ownerFacts ? crewKeyNote(ctx) : null} | |
| 523 | ${ownerFacts ? crewKeyCard(ctx, true) : null} | |
| 524 | 524 | ${ownerFacts |
| 525 | 525 | ? egressNote( |
| 526 | 526 | ctx.app.config.dataDir, |
src/web/studio/crew.ts +5 −11
| @@ -30,7 +30,7 @@ import { listCrewRoles, type Agent, type CrewRole } from "../../core/agents.ts"; | ||
| 30 | 30 | import { getAppByRepo } from "../../core/apps.ts"; |
| 31 | 31 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 32 | 32 | import { seatsOf, type Seat } from "../../crew/seats.ts"; |
| 33 | import { crewHasCredential } from "../../core/subscriptions.ts"; | |
| 33 | import { crewKeyCard } from "./crew_key.ts"; | |
| 34 | 34 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 35 | 35 | import { governorSeen } from "../../crew/governed.ts"; |
| 36 | 36 | import { crewOffInPreview } from "../../crew/preview.ts"; |
| @@ -417,16 +417,10 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html { | ||
| 417 | 417 | } |
| 418 | 418 | |
| 419 | 419 | /** |
| 420 | * One sentence when the crew has no model credential, so it cannot start: | |
| 421 | * what is missing and where it is added — for the admin, a link to it; for | |
| 422 | * anyone else, who can. Nothing when the crew can run. | |
| 420 | * What the crew runs on when it has no key of its own (`crew_key.ts`): the | |
| 421 | * one field to paste a key into, or the included credit and what is left of | |
| 422 | * it. Nothing when the crew can run on a key. | |
| 423 | 423 | */ |
| 424 | 424 | export function crewKeyNote(ctx: Ctx): Html | null { |
| 425 | if (crewHasCredential(ctx.app.db)) return null; | |
| 426 | return html`<p class="small muted" id="crew-key-note" role="status"> | |
| 427 | ${icon("alert")} The crew cannot start work yet: it needs a subscription. | |
| 428 | ${ctx.auth.user?.isAdmin | |
| 429 | ? html`<a href="/settings/subscriptions">Add one in Subscriptions</a>.` | |
| 430 | : "Ask this forge's admin to add one."} | |
| 431 | </p>`; | |
| 425 | return crewKeyCard(ctx); | |
| 432 | 426 | } |
src/web/studio/crew_key.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * Bring your own key (#11154): the one field that makes a forge's crew work, | |
| 3 | * the words for each state of the crew's means (own key, included credit | |
| 4 | * with money left, credit spent, nothing), and the POST that seals the key. | |
| 5 | * The key is sealed in this forge's database by `addSubscription`, offered to | |
| 6 | * the crew of the owner's apps, and goes nowhere else: never logged, never | |
| 7 | * echoed, never sent to the host. | |
| 8 | */ | |
| 9 | ||
| 10 | import { existsSync } from "node:fs"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import type { App } from "../../app.ts"; | |
| 13 | import { listAgents } from "../../core/agents.ts"; | |
| 14 | import { crewHasCredential, addSubscription, getSubscriptionByName, setGrants } from "../../core/subscriptions.ts"; | |
| 15 | import { leftUsd, usdWords } from "../../spawn/credit.ts"; | |
| 16 | import { fundingOf } from "../../spawn/funding.ts"; | |
| 17 | import { spawnedOrg } from "../../spawn/claim.ts"; | |
| 18 | import type { Ctx } from "../../http/context.ts"; | |
| 19 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 20 | import { field, readForm } from "../form.ts"; | |
| 21 | import { html, type Html } from "../html.ts"; | |
| 22 | import { icon } from "../icons.ts"; | |
| 23 | import { flashTo } from "../layout.ts"; | |
| 24 | import { btn } from "../ui.ts"; | |
| 25 | ||
| 26 | export const KEY_ACTION = "/settings/crew/key"; | |
| 27 | const KEY_NAME = "my-key"; | |
| 28 | ||
| 29 | export interface Included { | |
| 30 | readonly capUsd: number; | |
| 31 | readonly leftUsd: number; | |
| 32 | } | |
| 33 | ||
| 34 | /** The credit this forge came with and what is left of it (last heard from its host), or undefined when it has none. */ | |
| 35 | export function includedCredit(app: App): Included | undefined { | |
| 36 | const f = fundingOf(app.config.dataDir); | |
| 37 | if (f === undefined || f.capUsd === null) return undefined; | |
| 38 | return { capUsd: f.capUsd, leftUsd: leftUsd({ capUsd: f.capUsd, spentUsd: f.spentUsd ?? 0 }) }; | |
| 39 | } | |
| 40 | ||
| 41 | /** Whether a forge is a hosted child: its host's file is in its data directory. */ | |
| 42 | export const isHostedForge = (app: App): boolean => existsSync(join(app.config.dataDir, "parent.json")); | |
| 43 | ||
| 44 | /** Whether the crew has anything to run on: a key of the owner's, or included credit with money left. */ | |
| 45 | export function crewCanRun(app: App): boolean { | |
| 46 | if (crewHasCredential(app.db)) return true; | |
| 47 | const c = includedCredit(app); | |
| 48 | return c !== undefined && c.leftUsd > 0; | |
| 49 | } | |
| 50 | ||
| 51 | /** Whether the forge has a crew at all, so a line about it is true. */ | |
| 52 | export const hasCrew = (app: App): boolean => listAgents(app.db).length > 0; | |
| 53 | ||
| 54 | const keyField = (): Html => html`<form class="stack" method="post" action="${KEY_ACTION}" id="crew-key-form"> | |
| 55 | <div class="field"> | |
| 56 | <label for="crew-key">Your Anthropic API key or Claude login</label> | |
| 57 | <input class="input" id="crew-key" name="token" type="password" required autocomplete="off" spellcheck="false" placeholder="sk-ant-…" /> | |
| 58 | <span class="hint">Sealed in this forge and never shown again. It does not leave it.</span> | |
| 59 | </div> | |
| 60 | <div>${btn("Save key", { variant: "primary", size: "sm", icon: "key" })}</div> | |
| 61 | </form>`; | |
| 62 | ||
| 63 | /** What the crew runs on, said once, with the field when a key would change anything (`compact`: a line and a link, no field). */ | |
| 64 | export function crewKeyCard(ctx: Ctx, compact = false): Html | null { | |
| 65 | if (crewHasCredential(ctx.app.db)) return null; | |
| 66 | const admin = ctx.auth.user?.isAdmin === true; | |
| 67 | // On a repository page: one line and a link, the field lives on the home page. | |
| 68 | if (compact) | |
| 69 | return html`<p class="small muted" id="crew-key-note" role="status">${icon("alert")} The crew cannot start work yet: it needs a key. | |
| 70 | ${admin ? html`<a href="/settings/subscriptions">Add one in Subscriptions</a>.` : "Ask this forge's admin to add one."}</p>`; | |
| 71 | const credit = includedCredit(ctx.app); | |
| 72 | if (credit !== undefined && credit.leftUsd > 0) | |
| 73 | return html`<section class="settings-block" id="crew-key-note" role="status"> | |
| 74 | <p class="small muted">${icon("key")} The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit. | |
| 75 | ${admin ? "Add your own key any time to keep building after it:" : "Its admin can add a key to keep building after it."}</p> | |
| 76 | ${admin ? keyField() : null} | |
| 77 | </section>`; | |
| 78 | const said = | |
| 79 | credit !== undefined | |
| 80 | ? "The included credit is spent. Add your own key to keep building, or wait for next month." | |
| 81 | : "The crew needs a model key to start. Paste yours."; | |
| 82 | return html`<section class="settings-block" id="crew-key-note" role="status"> | |
| 83 | <p class="small muted">${icon("alert")} ${said}${admin ? "" : " Ask this forge's admin."}</p> | |
| 84 | ${admin ? keyField() : null} | |
| 85 | </section>`; | |
| 86 | } | |
| 87 | ||
| 88 | // POST /settings/crew/key | |
| 89 | export async function crewKeyAdd(ctx: Ctx): Promise<Response> { | |
| 90 | const user = ctx.auth.user; | |
| 91 | if (!user) return signInFirst(ctx); | |
| 92 | if (!user.isAdmin) return notFoundPage(ctx); | |
| 93 | const form = await readForm(ctx, "admin"); | |
| 94 | if (form instanceof Response) return form; | |
| 95 | const token = field(form, "token").trim(); | |
| 96 | const name = getSubscriptionByName(ctx.app.db, KEY_NAME) === undefined ? KEY_NAME : `${KEY_NAME}-${Date.now().toString(36)}`; | |
| 97 | const added = addSubscription(ctx.app.db, ctx.app.config.dataDir, { name, token, createdBy: user.id }); | |
| 98 | if (!added.ok) | |
| 99 | return flashTo(ctx, "/", { tone: "danger", message: "That key could not be saved. Check that it is the whole key and try again." }); | |
| 100 | const spawned = await spawnedOrg(ctx.app); | |
| 101 | for (const scope of [user.id, ...(spawned === undefined ? [] : [spawned.org.id])]) { | |
| 102 | const held = ctx.app.db | |
| 103 | .query<{ subscription_id: string }, [string]>("SELECT subscription_id FROM subscription_grant WHERE scope_id = ?1 AND agent_id = ''") | |
| 104 | .all(scope) | |
| 105 | .map((r) => r.subscription_id); | |
| 106 | setGrants(ctx.app.db, scope, "", [...held, added.value.id]); | |
| 107 | } | |
| 108 | return flashTo(ctx, "/", { message: "Key saved. The crew can work now." }); | |
| 109 | } |
test/child-credit.test.ts +3
| @@ -41,6 +41,7 @@ import { openBeatDoor, parentLocal, type BeatDoor } from "../src/spawn/beat-door | ||
| 41 | 41 | import { readCredit } from "../src/spawn/credit.ts"; |
| 42 | 42 | import { fundedEnv, fundingOf, fundingWords, refreshFunding } from "../src/spawn/funding.ts"; |
| 43 | 43 | import { listPriceUsd } from "../src/spawn/list_price.ts"; |
| 44 | import { setLimit } from "../src/settings/limits.ts"; | |
| 44 | 45 | import { quietLogs } from "./helpers/globals.ts"; |
| 45 | 46 | |
| 46 | 47 | quietLogs(); |
| @@ -105,6 +106,8 @@ describe("a parent funds a child's crew", () => { | ||
| 105 | 106 | const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true }); |
| 106 | 107 | const bob = await createUser(parent.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-credit-passphrase" }); |
| 107 | 108 | if (!alice.ok || !bob.ok) throw new Error("users"); |
| 109 | // These tests lend credit by hand: the credit a new forge comes with (#11154) is off here. | |
| 110 | setLimit(parent.db, "spawn.included_credit_usd", 0, alice.value.id); | |
| 108 | 111 | adminRw = createToken(parent.db, alice.value.id, "rw", "rw").token; |
| 109 | 112 | bobRw = createToken(parent.db, bob.value.id, "rw", "rw").token; |
| 110 | 113 | const repo = await createRepo(parent, alice.value, { name: "bakery" }); |
test/signup-gate-parent.test.ts +1
| @@ -119,6 +119,7 @@ test("every caller of createUser says where its account comes from", async () => | ||
| 119 | 119 | "src/packs/mirror.ts": ["seed"], |
| 120 | 120 | "src/packs/up.ts": ["seed"], |
| 121 | 121 | "src/spawn/preview-seed.ts": ["seed", "seed"], |
| 122 | "src/spawn/starter_crew.ts": ["spawn"], | |
| 122 | 123 | "src/web/pages/claim.ts": ["claim"], |
| 123 | 124 | "src/web/pages/everyday.ts": ["everyday"], |
| 124 | 125 | "src/web/pages/start_claim.ts": ["claimer"], |
test/spawn-child-apps.test.ts +10 −7
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * credential, the home and the app page say so and where to add one. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { setLimit } from "../src/settings/limits.ts"; | |
| 8 | 9 | import { seatAs } from "./helpers/seat.ts"; |
| 9 | 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -55,6 +56,8 @@ describe("a spawned child's apps", () => { | ||
| 55 | 56 | }); |
| 56 | 57 | if (!made.ok) throw new Error("user"); |
| 57 | 58 | const alice = made.value; |
| 59 | // This forge is made with no included credit (#11154), so it has neither credit nor key. | |
| 60 | setLimit(parent.db, "spawn.included_credit_usd", 0, alice.id); | |
| 58 | 61 | // One app (its forge.json says so) and one plain repository. |
| 59 | 62 | for (const [name, files] of [ |
| 60 | 63 | [ |
| @@ -175,18 +178,18 @@ describe("a spawned child's apps", () => { | ||
| 175 | 178 | ).replace(/\s+/g, " "); |
| 176 | 179 | expect(home).toContain("bakery"); |
| 177 | 180 | expect(home).not.toContain("No apps yet"); |
| 178 | expect(home).toContain("The crew cannot start work yet"); | |
| 179 | expect(home).toContain('href="/settings/subscriptions"'); | |
| 181 | expect(home).toContain("The crew needs a model key to start"); | |
| 182 | expect(home).toContain('action="/settings/crew/key"'); | |
| 180 | 183 | const appPage = await ( |
| 181 | 184 | await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } }) |
| 182 | 185 | ).text(); |
| 183 | expect(appPage).toContain("The crew cannot start work yet"); | |
| 186 | expect(appPage).toContain("The crew needs a model key to start"); | |
| 184 | 187 | }); |
| 185 | 188 | |
| 186 | 189 | test("on the app page the key note is an owner fact: not for a stranger or a plain reader", async () => { |
| 187 | 190 | const anonymous = await (await fetch(`${servers.url}/marias/bakery`)).text(); |
| 188 | 191 | expect(anonymous).toContain("bakery"); |
| 189 | expect(anonymous).not.toContain("The crew cannot start work yet"); | |
| 192 | expect(anonymous).not.toContain("The crew needs a model key to start"); | |
| 190 | 193 | const reader = await createUser(child.db, { origin: "seed", trusted: true, |
| 191 | 194 | username: "reader", |
| 192 | 195 | email: "[email protected]", |
| @@ -201,14 +204,14 @@ describe("a spawned child's apps", () => { | ||
| 201 | 204 | }) |
| 202 | 205 | ).text(); |
| 203 | 206 | expect(asReader).toContain("bakery"); |
| 204 | expect(asReader).not.toContain("The crew cannot start work yet"); | |
| 207 | expect(asReader).not.toContain("The crew needs a model key to start"); | |
| 205 | 208 | // Nor on the home: there the note is for an admin or a writer of an app. |
| 206 | 209 | const readerHome = await ( |
| 207 | 210 | await fetch(`${servers.url}/`, { |
| 208 | 211 | headers: { cookie: `forge_session=${session.token}` }, |
| 209 | 212 | }) |
| 210 | 213 | ).text(); |
| 211 | expect(readerHome).not.toContain("The crew cannot start work yet"); | |
| 214 | expect(readerHome).not.toContain("The crew needs a model key to start"); | |
| 212 | 215 | }); |
| 213 | 216 | |
| 214 | 217 | test("first boot registers apps as the founder, an account with no verified email", () => { |
| @@ -262,6 +265,6 @@ describe("a spawned child's apps", () => { | ||
| 262 | 265 | const home = await ( |
| 263 | 266 | await fetch(`${servers.url}/`, { headers: { cookie } }) |
| 264 | 267 | ).text(); |
| 265 | expect(home).not.toContain("The crew cannot start work yet"); | |
| 268 | expect(home).not.toContain("The crew needs a model key to start"); | |
| 266 | 269 | }); |
| 267 | 270 | }); |
test/spawn.test.ts +2
| @@ -90,6 +90,8 @@ describe("spawnForge", () => { | ||
| 90 | 90 | statusHash: expect.stringMatching(/^[0-9a-f]{64}$/), |
| 91 | 91 | // The child signs heartbeats to its parent with it (#425). |
| 92 | 92 | beatKey: expect.stringMatching(/^[A-Za-z0-9_-]{20,}$/), |
| 93 | // The plan's included credit, lent once (#11154). | |
| 94 | credit: { capUsd: 2, spentUsd: 0, token: expect.stringMatching(/^kid\./) }, | |
| 93 | 95 | }); |
| 94 | 96 | }); |
| 95 | 97 |
test/starter-crew.test.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * A new forge comes with a crew that builds at once (#11154): a builder and | |
| 3 | * a reviewer seated on its apps with autonomy on, the included credit lent | |
| 4 | * through the child credit, one field for the owner's own key, and no | |
| 5 | * promise of a crew when there is neither credit nor key. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { listAgents, listCrewRoles } from "../src/core/agents.ts"; | |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 17 | import { listTeamMembers, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts"; | |
| 18 | import { getUserByName } from "../src/core/users.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser } from "../src/core/users.ts"; | |
| 21 | import { listSubscriptions } from "../src/core/subscriptions.ts"; | |
| 22 | import { repoDir } from "../src/git/repos.ts"; | |
| 23 | import { commitFiles } from "../src/git/write.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { setLimit } from "../src/settings/limits.ts"; | |
| 26 | import { readCredit } from "../src/spawn/credit.ts"; | |
| 27 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 28 | import { fundingOf, keepFunding } from "../src/spawn/funding.ts"; | |
| 29 | import type { Ctx } from "../src/http/context.ts"; | |
| 30 | import { crewKeyCard, crewCanRun, includedCredit } from "../src/web/studio/crew_key.ts"; | |
| 31 | import { worstCaseDailyUsd } from "../src/spawn/included_credit.ts"; | |
| 32 | import { quietLogs } from "./helpers/globals.ts"; | |
| 33 | ||
| 34 | quietLogs(); | |
| 35 | ||
| 36 | describe("a new forge's starter crew", () => { | |
| 37 | let dir = ""; | |
| 38 | let parent: App; | |
| 39 | let child: App; | |
| 40 | let servers: Servers; | |
| 41 | let token = ""; | |
| 42 | let childDir = ""; | |
| 43 | ||
| 44 | const spawn = async (name: string): Promise<string> => { | |
| 45 | const made = await fetch(`${servers.url}/api/v1/forges`, { | |
| 46 | method: "POST", | |
| 47 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 48 | body: JSON.stringify({ name, apps: ["alice/bakery"], dryRun: false }), | |
| 49 | }); | |
| 50 | if (made.status !== 201) throw new Error(`spawn ${made.status}`); | |
| 51 | return ((await made.json()) as { bodyId: string }).bodyId; | |
| 52 | }; | |
| 53 | ||
| 54 | beforeAll(async () => { | |
| 55 | dir = await mkdtemp(join(tmpdir(), "forge-starter-crew-")); | |
| 56 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 57 | const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-starter-passphrase", isAdmin: true }); | |
| 58 | if (!alice.ok) throw new Error("user"); | |
| 59 | token = createToken(parent.db, alice.value.id, "rw", "rw").token; | |
| 60 | const repo = await createRepo(parent, alice.value, { name: "bakery" }); | |
| 61 | if (!repo.ok) throw new Error("repo"); | |
| 62 | const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), { | |
| 63 | branch: "main", | |
| 64 | message: "bakery", | |
| 65 | files: [ | |
| 66 | { path: "README.md", content: "# bakery\n" }, | |
| 67 | { path: "forge.json", content: JSON.stringify({ app: { run: "bun run index.ts" } }) }, | |
| 68 | ], | |
| 69 | author: { name: "a", email: "[email protected]" }, | |
| 70 | }); | |
| 71 | if (!c.ok) throw new Error("commit"); | |
| 72 | servers = await serve(parent); | |
| 73 | childDir = childDirOf(parent, await spawn("maria")); | |
| 74 | child = createApp({ dataDir: childDir, port: 0 }); | |
| 75 | await firstBoot(child); | |
| 76 | }); | |
| 77 | ||
| 78 | afterAll(async () => { | |
| 79 | servers?.stop(); | |
| 80 | child?.close(); | |
| 81 | parent?.close(); | |
| 82 | await rm(dir, { recursive: true, force: true }); | |
| 83 | }); | |
| 84 | ||
| 85 | test("it has a builder and a reviewer, seated on its app, autonomy on", () => { | |
| 86 | expect(listAgents(child.db).map((a) => a.user.username).sort()).toEqual(["builder", "reviewer"]); | |
| 87 | const repo = getRepo(child.db, "maria", "bakery")!; | |
| 88 | expect(listCrewRoles(child.db, repo.id).map((c) => c.role).sort()).toEqual(["builder", "reviewer"]); | |
| 89 | expect(repo.autonomy).toBe("autonomous"); | |
| 90 | expect(repo.autonomyBy).not.toBeNull(); | |
| 91 | }); | |
| 92 | ||
| 93 | test("it is lent the plan's included credit once, with no key of its own", () => { | |
| 94 | expect(readCredit(parent, "maria")?.capUsd).toBe(2); | |
| 95 | expect(fundingOf(childDir)?.capUsd).toBe(2); | |
| 96 | expect(listSubscriptions(child.db)).toEqual([]); | |
| 97 | expect(includedCredit(child)).toEqual({ capUsd: 2, leftUsd: 2 }); | |
| 98 | expect(crewCanRun(child)).toBe(true); | |
| 99 | }); | |
| 100 | ||
| 101 | test("spent credit and no key: the crew cannot run, and the page says what is needed", () => { | |
| 102 | keepFunding(childDir, { token: fundingOf(childDir)!.token, capUsd: 2, spentUsd: 2 }); | |
| 103 | expect(crewCanRun(child)).toBe(false); | |
| 104 | expect(includedCredit(child)?.leftUsd).toBe(0); | |
| 105 | }); | |
| 106 | ||
| 107 | test("the key card says what is needed, once, and never calls it a subscription", () => { | |
| 108 | const ctx = { app: child, auth: { user: { isAdmin: true } } } as unknown as Ctx; | |
| 109 | const spent = String(crewKeyCard(ctx)); | |
| 110 | expect(spent).toContain("included credit is spent"); | |
| 111 | expect(spent).toContain('name="token"'); | |
| 112 | expect(spent.toLowerCase()).not.toContain("subscription"); | |
| 113 | keepFunding(childDir, { token: fundingOf(childDir)!.token, capUsd: 2, spentUsd: 0.5 }); | |
| 114 | expect(String(crewKeyCard(ctx))).toContain("$1.50 of $2 included credit"); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a repository made later in the forge is seated and autonomous too", async () => { | |
| 118 | const owners = listTeams(child.db, getUserByName(child.db, "maria")!.id).find((t) => t.name === OWNERS_TEAM)!; | |
| 119 | const founder = listTeamMembers(child.db, owners.id)[0]!; | |
| 120 | const made = await createRepo(child, founder, { name: "second", owner: "maria" }); | |
| 121 | if (!made.ok) throw new Error(`repo ${made.error}`); | |
| 122 | expect(listCrewRoles(child.db, made.value.id).map((c) => c.role).sort()).toEqual(["builder", "reviewer"]); | |
| 123 | expect(getRepo(child.db, "maria", "second")!.autonomy).toBe("autonomous"); | |
| 124 | }); | |
| 125 | ||
| 126 | test("a forge brought its own key is lent nothing; 0 turns the credit off", async () => { | |
| 127 | setLimit(parent.db, "spawn.included_credit_usd", 0, parent.db.query<{ id: string }, []>("SELECT id FROM user LIMIT 1").get()!.id); | |
| 128 | await spawn("nolend"); | |
| 129 | expect(readCredit(parent, "nolend")).toBeUndefined(); | |
| 130 | }); | |
| 131 | ||
| 132 | test("the worst case a day is the visitor limit times the credit", () => { | |
| 133 | expect(worstCaseDailyUsd(parent)).toBe(0); | |
| 134 | }); | |
| 135 | }); |