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

Parked opened this in answer to
Closed without merging

mason/forge:mason/11154-a-new-forge-comes-with-a-crew-that-build → main 4 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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`;
133133 | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 |
134134 | `lift` | — | Lift an issue out from behind the Later gate | 214 |
135135 | `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 =======
136139 | `limits` | — | Every limit the owner can hit, value and bounds | 949 |
140 >>>>>>> origin/main
137141 | `maintenance` | `status`, `prepare`, `resume` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 256 |
138142 | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 |
139143 | `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 {
7878 import { validPolishShare } from "../crew/polish_share.ts";
7979 import { removeInstallCache } from "../crew/workdir.ts";
8080 import type { SettingsChanged } from "../core/settings_changed.ts";
81 import { seatStarterCrew } from "../spawn/starter_crew.ts";
8182
8283 export interface CreateRepoInput {
8384 /** Owner name: the actor themselves (default) or an org they can write to. */
@@ -180,6 +181,7 @@ export async function createRepo(
180181 });
181182 if (!created.ok) return created;
182183 storageChanged(app, ownerId);
184 seatStarterCrew(app.db, created.value, user.value.id);
183185 app.bus.append({
184186 kind: "repo.created",
185187 repoId: created.value.id,
src/actions/spawn.ts +7
@@ -39,6 +39,8 @@ import { runGit } from "../git/env.ts";
3939 import { repoDir } from "../git/repos.ts";
4040 import { commitFiles } from "../git/write.ts";
4141 import { adoptForgeCode, forkForgeCode } from "../spawn/forge_code.ts";
42 import { lendIncludedCredit } from "../spawn/included_credit.ts";
43 import { seedStarterCrew } from "../spawn/starter_crew.ts";
4244 import { birthFromTemplate, type TemplateMark } from "../spawn/template_birth.ts";
4345 import { sha256 } from "../kernel/hash.ts";
4446 import { secret } from "../kernel/id.ts";
@@ -324,6 +326,7 @@ async function make(
324326 const copied = await copyInto(app, owner, plan, ledger, deps.modelKey, deps.modelEndpoint);
325327 if (!copied.ok) return failed(app, deps, "copy", copied.error);
326328 const claimUrl = claimLink(liveChildUrl(app, plan.name), copied.value);
329 lendIncludedCredit(app, plan.name, childDirOf(app, ledger.bodyId), deps.modelKey !== undefined);
327330
328331 grantPlanCredit(app, plan, ledger.bodyId);
329332 ledger.done = true;
@@ -503,6 +506,10 @@ async function copyInto(
503506 setCrewRole(child.db, repo.id, theirs.user.id, role);
504507 }
505508
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
506513 // The fork is the child's own source: its `forgeRepo`, and the crew seated on it.
507514 adoptForgeCode(child, org.username, founder.id);
508515
src/http/settings_routes.ts +2
@@ -7,6 +7,7 @@
77 * in the pattern list the usage page reads, so this module never imports it.
88 */
99
10 import { crewKeyAdd } from "../web/studio/crew_key.ts";
1011 import type { Handler } from "./context.ts";
1112 import type { Router } from "./router.ts";
1213 import { parentLinkStart } from "./rp.ts";
@@ -174,6 +175,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
174175 // The crew: a forge admin's section, hidden from everyone else.
175176 router.add("GET", "/settings/subscriptions", subscriptionsPage);
176177 router.add("POST", "/settings/subscriptions", subscriptionAdd);
178 router.add("POST", "/settings/crew/key", crewKeyAdd);
177179 router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete);
178180 router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake);
179181 router.add(
src/settings/limits.ts +9
@@ -26,6 +26,7 @@ export type LimitKey =
2626 | "crew.concurrency"
2727 | "checks.concurrency"
2828 | "agent.budget_max_usd"
29 | "spawn.included_credit_usd"
2930 | "subscription.budget_max_usd"
3031 | "app.members_max"
3132 | "front.pinned_max"
@@ -95,6 +96,14 @@ export const LIMITS: Record<LimitKey, LimitDef> = {
9596 max: 1_000_000,
9697 reason: "A crew member's per-run budget is spent unattended, so what one run may cost has a top.",
9798 },
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 },
98107 "subscription.budget_max_usd": {
99108 label: "Pool account budget ceiling",
100109 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 @@
55 * who, when, from what to what. Anyone else gets the not-found page.
66 */
77
8 import { usdWords } from "../../spawn/credit.ts";
9 import { worstCaseDailyUsd } from "../../spawn/included_credit.ts";
810 import { QUOTA_INVALID } from "../../actions/admin_limits_words.ts";
911 import { changeLimit, listLimits } from "../../actions/limits.ts";
1012 import { changeQuotas, readQuotas } from "../../actions/quotas.ts";
@@ -201,6 +203,7 @@ export function limitsPage(ctx: Ctx): Response {
201203 ${historyBlock("Invite links made", inviteRows)}
202204 <section class="settings-block mt">
203205 <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>
204207 <div class="table-scroll">
205208 <table class="table setting-table" data-dense>
206209 <caption class="sr-only">Limits, their values and how to change them</caption>
src/web/pages/apps.ts +2
@@ -12,6 +12,7 @@
1212 * 404 for everyone.
1313 */
1414
15 import { crewKeyCard } from "../studio/crew_key.ts";
1516 import { storageLimitMessage } from "../../core/storage.ts";
1617 import { repositoryLimitMessage } from "../../actions/repos.ts";
1718 import { appLimitWords } from "../../actions/app_limit.ts";
@@ -328,6 +329,7 @@ function newAppForm(
328329 ${pageHead("New app", {
329330 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.",
330331 })}
332 ${crewKeyCard(ctx)}
331333 ${html`<form method="post" action="/apps/new">
332334 ${values.template
333335 ? formField({ label: "Copy of", name: "template", value: values.template, readonly: true, mono: true })
src/web/pages/claim.ts +5 −2
@@ -18,6 +18,7 @@
1818 * so before anyone submits, and where to remove it.
1919 */
2020
21 import { crewCanRun, hasCrew } from "../studio/crew_key.ts";
2122 import { PASSWORD_TOO_LONG, withinPasswordCap } from "../../core/hash-gate.ts";
2223 import { givenEmail, MIN_SIGNUP_PASSWORD, noEmail } from "../../actions/users.ts";
2324 import type { Credential } from "../../auth/webauthn.ts";
@@ -152,8 +153,10 @@ function claimForm(
152153 ${pageHead("This forge is yours")}
153154 <section class="settings-block">
154155 <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
157160 is the forge's admin, so it can change everything here.
158161 </p>
159162 <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";
6060 import { accountSections } from "./origins.ts";
6161 import { liveRegionsJs } from "../client.ts";
6262 import { markOf } from "../../core/marks.ts";
63 import { crewKeyCard, includedCredit, isHostedForge } from "../studio/crew_key.ts";
64 import { usdWords } from "../../spawn/credit.ts";
6365 import { limitValue } from "../../settings/limits.ts";
6466
6567 const POOL = "/settings/subscriptions";
@@ -108,8 +110,11 @@ export function subscriptionsPage(ctx: Ctx): Response {
108110 const denied = requireAdmin(ctx);
109111 if (denied) return denied;
110112 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).
111116 const pool = timePhase(ctx.request, "credentials", () =>
112 listCredentials(ctx.app.db),
117 listCredentials(ctx.app.db).filter((s) => s.id !== PLATFORM || !isHostedForge(ctx.app)),
113118 );
114119 const places = timePhase(ctx.request, "places", () =>
115120 listGrantPlaces(ctx.app.db),
@@ -270,6 +275,11 @@ export function subscriptionsPage(ctx: Ctx): Response {
270275 { extra: "setting-list" },
271276 );
272277
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>`;
273283 const add = html`<section class="settings-block"><h2>Add one</h2>
274284 <form class="stack" method="post" action="${POOL}">
275285 <div class="field">
@@ -336,7 +346,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
336346
337347 const response = shell(
338348 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>`}
340350 <p class="mt muted small">What they spent: <a href="/settings/spend">Usage → Spend</a>.</p>
341351 <div class="mt">${add}</div>
342352 ${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";
6161 import { adapterCard } from "./adapter.ts";
6262 import { appPanels } from "./previews.ts";
6363 import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts";
64 import { crewKeyNote } from "./crew.ts";
64 import { crewKeyCard } from "./crew_key.ts";
6565 import {
6666 notePhase,
6767 takeSessionTime,
@@ -500,7 +500,7 @@ export async function studioApp(
500500 ? archivedParentLine(ctx, repo)
501501 : followingCard(ctx, repo, following)}
502502 <div id="app-head">${head}</div>
503 ${ownerFacts ? crewKeyNote(ctx) : null}
503 ${ownerFacts ? crewKeyCard(ctx, true) : null}
504504 <div class="stack">
505505 ${adapter}
506506 ${tellRegion}
@@ -520,7 +520,7 @@ export async function studioApp(
520520 <div id="app-head">${head}</div>
521521 ${askingLine(ctx, repo)}
522522 ${policy}
523 ${ownerFacts ? crewKeyNote(ctx) : null}
523 ${ownerFacts ? crewKeyCard(ctx, true) : null}
524524 ${ownerFacts
525525 ? egressNote(
526526 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";
3030 import { getAppByRepo } from "../../core/apps.ts";
3131 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3232 import { seatsOf, type Seat } from "../../crew/seats.ts";
33 import { crewHasCredential } from "../../core/subscriptions.ts";
33 import { crewKeyCard } from "./crew_key.ts";
3434 import { autonomyStatus } from "../../crew/autonomy.ts";
3535 import { governorSeen } from "../../crew/governed.ts";
3636 import { crewOffInPreview } from "../../crew/preview.ts";
@@ -417,16 +417,10 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html {
417417 }
418418
419419 /**
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.
423423 */
424424 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);
432426 }
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
4141 import { readCredit } from "../src/spawn/credit.ts";
4242 import { fundedEnv, fundingOf, fundingWords, refreshFunding } from "../src/spawn/funding.ts";
4343 import { listPriceUsd } from "../src/spawn/list_price.ts";
44 import { setLimit } from "../src/settings/limits.ts";
4445 import { quietLogs } from "./helpers/globals.ts";
4546
4647 quietLogs();
@@ -105,6 +106,8 @@ describe("a parent funds a child's crew", () => {
105106 const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true });
106107 const bob = await createUser(parent.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-credit-passphrase" });
107108 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);
108111 adminRw = createToken(parent.db, alice.value.id, "rw", "rw").token;
109112 bobRw = createToken(parent.db, bob.value.id, "rw", "rw").token;
110113 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 () =>
119119 "src/packs/mirror.ts": ["seed"],
120120 "src/packs/up.ts": ["seed"],
121121 "src/spawn/preview-seed.ts": ["seed", "seed"],
122 "src/spawn/starter_crew.ts": ["spawn"],
122123 "src/web/pages/claim.ts": ["claim"],
123124 "src/web/pages/everyday.ts": ["everyday"],
124125 "src/web/pages/start_claim.ts": ["claimer"],
test/spawn-child-apps.test.ts +10 −7
@@ -5,6 +5,7 @@
55 * credential, the home and the app page say so and where to add one.
66 */
77
8 import { setLimit } from "../src/settings/limits.ts";
89 import { seatAs } from "./helpers/seat.ts";
910 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1011 import { mkdtemp, rm } from "node:fs/promises";
@@ -55,6 +56,8 @@ describe("a spawned child's apps", () => {
5556 });
5657 if (!made.ok) throw new Error("user");
5758 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);
5861 // One app (its forge.json says so) and one plain repository.
5962 for (const [name, files] of [
6063 [
@@ -175,18 +178,18 @@ describe("a spawned child's apps", () => {
175178 ).replace(/\s+/g, " ");
176179 expect(home).toContain("bakery");
177180 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"');
180183 const appPage = await (
181184 await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } })
182185 ).text();
183 expect(appPage).toContain("The crew cannot start work yet");
186 expect(appPage).toContain("The crew needs a model key to start");
184187 });
185188
186189 test("on the app page the key note is an owner fact: not for a stranger or a plain reader", async () => {
187190 const anonymous = await (await fetch(`${servers.url}/marias/bakery`)).text();
188191 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");
190193 const reader = await createUser(child.db, { origin: "seed", trusted: true,
191194 username: "reader",
192195 email: "[email protected]",
@@ -201,14 +204,14 @@ describe("a spawned child's apps", () => {
201204 })
202205 ).text();
203206 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");
205208 // Nor on the home: there the note is for an admin or a writer of an app.
206209 const readerHome = await (
207210 await fetch(`${servers.url}/`, {
208211 headers: { cookie: `forge_session=${session.token}` },
209212 })
210213 ).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");
212215 });
213216
214217 test("first boot registers apps as the founder, an account with no verified email", () => {
@@ -262,6 +265,6 @@ describe("a spawned child's apps", () => {
262265 const home = await (
263266 await fetch(`${servers.url}/`, { headers: { cookie } })
264267 ).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");
266269 });
267270 });
test/spawn.test.ts +2
@@ -90,6 +90,8 @@ describe("spawnForge", () => {
9090 statusHash: expect.stringMatching(/^[0-9a-f]{64}$/),
9191 // The child signs heartbeats to its parent with it (#425).
9292 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\./) },
9395 });
9496 });
9597
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs