Public: anyone can read this; the crew can change it.
A new forge's first ask makes a new app from the ask and files it there as an issue #11390
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11342-a-new-forge-s-first-ask-makes-a-new-app → main 7 commits fork 3 threads · 3 unresolved
docs/features/starter-crew.md +2
| @@ -9,3 +9,5 @@ A hosted forge is made with a crew: a `builder` and a `reviewer`, seated on ever | ||
| 9 | 9 | **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. |
| 10 | 10 | |
| 11 | 11 | Try: open `/` on a new forge, paste a key, write one ask. |
| 12 | ||
| 13 | The first ask on a new forge makes a new app named from it and files the ask there as an issue (target `new-app`), startable by a builder when the crew can run. |
src/actions/first_ask.ts added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | /** | |
| 2 | * The first ask of a new forge (#11342): one sentence makes a new app named | |
| 3 | * from it, turns the crew loose on it when the crew can run, and files the | |
| 4 | * sentence there as the app's one issue. The page calls | |
| 5 | * this. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import type { Repo } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | import { newAppFromSentence, NEW_APP_ERRORS } from "./apps.ts"; | |
| 13 | import { updateRepoSettings } from "./repos.ts"; | |
| 14 | import { NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "./tell_words.ts"; | |
| 15 | import { FROM_WORDS, TOO_LONG_WORDS, tell, type TellError, type Told } from "./tell.ts"; | |
| 16 | import { parseFrom } from "./tell_from.ts"; | |
| 17 | import { requireVerified } from "./verify.ts"; | |
| 18 | import { MAX_BODY } from "../core/issues.ts"; | |
| 19 | import { DIRECTION_NOTE } from "../crew/direct.ts"; | |
| 20 | ||
| 21 | export interface FirstAsk { | |
| 22 | /** The app made from the ask. */ | |
| 23 | readonly repo: Repo; | |
| 24 | /** True when the crew could run but autonomy could not be set: the ask is filed on an app a builder will not start by itself. */ | |
| 25 | readonly autonomyFailed: boolean; | |
| 26 | /** The ask's own tell, refused or told. */ | |
| 27 | readonly told: Result<Told, TellError>; | |
| 28 | } | |
| 29 | ||
| 30 | /** What a failed first ask says, in words: the empty sentence, or why no app could be made. */ | |
| 31 | export type FirstAskError = { readonly words: string }; | |
| 32 | ||
| 33 | /** The note a person is given when the app was made and filed but autonomy could not be turned on. */ | |
| 34 | export const AUTONOMY_FAILED_WORDS = "The app is made, but the crew could not be set to start on it by itself. Turn on autonomy in its settings."; | |
| 35 | ||
| 36 | export async function firstAsk( | |
| 37 | app: App, | |
| 38 | actor: User | undefined, | |
| 39 | said: string, | |
| 40 | crewCanRun: boolean, | |
| 41 | from: string | null = null, | |
| 42 | ): Promise<Result<FirstAsk, FirstAskError>> { | |
| 43 | const text = said.trim(); | |
| 44 | if (text === "") return err({ words: SAY_SOMETHING_WORDS }); | |
outdated
The app is made before the tell is validated. If tell refuses (body_too_long, or a direction refusal), the person gets an error but an empty app now exists, and a retry makes a second one. Check the length and every refusal the tell can give before creating the app, and test that an over-long ask makes no app. |
||
| 45 | // Everything the tell could refuse is asked before the app exists, so a | |
| 46 | // refused ask leaves nothing behind and a retry makes no second app. The | |
| 47 | // new app's owner is the actor, who may direct the crew on it. | |
| 48 | if (text.length > MAX_BODY) return err({ words: TOO_LONG_WORDS }); | |
| 49 | if (actor === undefined || actor.kind === "org") return err({ words: DIRECTION_NOTE }); | |
| 50 | if (!requireVerified(app, actor).ok) return err({ words: "Verify your email first." }); | |
| 51 | if (from !== null && typeof parseFrom(from) !== "string") return err({ words: FROM_WORDS }); | |
| 52 | // An empty description: the issue the tell files is the one issue of the ask. | |
| 53 | const made = await newAppFromSentence(app, actor, { sentence: text, description: "" }); | |
| 54 | if (!made.ok) return err({ words: NEW_APP_ERRORS[made.code] ?? NOT_TOLD_WORDS }); | |
| 55 | let repo = made.repo; | |
| 56 | let autonomyFailed = false; | |
| 57 | if (crewCanRun) { | |
| 58 | const on = await updateRepoSettings(app, actor, repo, { autonomy: "autonomous" }); | |
| 59 | if (on.ok) { | |
| 60 | repo = on.value; | |
| 61 | } else autonomyFailed = true; | |
| 62 | } | |
| 63 | const told = await tell(app, actor, repo, text, 1, from); | |
| 64 | return ok({ repo, autonomyFailed, told }); | |
| 65 | } |
src/web/pages/next-steps.ts +2 −2
| @@ -27,7 +27,7 @@ import { tellHref } from "../studio/nouns.ts"; | ||
| 27 | 27 | import { everydayAsk } from "./everyday.ts"; |
| 28 | 28 | import { EVERYDAY_WHY } from "../../core/everyday.ts"; |
| 29 | 29 | import { crewFirstLine } from "../studio/crew_key.ts"; |
| 30 | import { FORGE_TARGET } from "../studio/feed.ts"; | |
| 30 | import { NEW_APP_TARGET } from "../studio/feed.ts"; | |
| 31 | 31 | import { COMPOSER_FIELDS, TELL_MAX } from "../share_target.ts"; |
| 32 | 32 | |
| 33 | 33 | /** One line per app: the app is the row's title, the crew member and what they are doing its meta. */ |
| @@ -67,7 +67,7 @@ function firstTell(ctx: Ctx, user: User, crew: Html[]): Html { | ||
| 67 | 67 | return html`<section id="next-steps" data-first-tell> |
| 68 | 68 | <h2>What do you want built?</h2> |
| 69 | 69 | <form id="composer" class="stack" method="post" action="/tell"> |
| 70 | <input type="hidden" name="${COMPOSER_FIELDS.app}" value="${FORGE_TARGET}" /> | |
| 70 | <input type="hidden" name="${COMPOSER_FIELDS.app}" value="${NEW_APP_TARGET}" /> | |
| 71 | 71 | <textarea class="input" name="${COMPOSER_FIELDS.text}" rows="3" maxlength="${String(TELL_MAX)}" required placeholder="A weekly menu page for the bakery" aria-label="Your request" data-draft="${user.id}"></textarea> |
| 72 | 72 | <div class="bar">${btn("Tell the crew", { variant: "primary" })}</div> |
| 73 | 73 | </form> |
src/web/studio/actions.ts +25 −2
| @@ -34,7 +34,8 @@ import { isLocalPath } from "../flash.ts"; | ||
| 34 | 34 | import { html } from "../html.ts"; |
| 35 | 35 | import { flashOrJson, flashTo, page } from "../layout.ts"; |
| 36 | 36 | import { crewCanRun, NO_CREDIT_WORDS } from "./crew_key.ts"; |
| 37 | import { FORGE_TARGET } from "./feed.ts"; | |
| 37 | import { FORGE_TARGET, NEW_APP_TARGET } from "./feed.ts"; | |
| 38 | import { AUTONOMY_FAILED_WORDS, firstAsk } from "../../actions/first_ask.ts"; | |
| 38 | 39 | import { changeHref } from "./nouns.ts"; |
| 39 | 40 | |
| 40 | 41 | /** A merge refusal in a person's words. */ |
| @@ -73,7 +74,20 @@ async function tellFrom( | ||
| 73 | 74 | tone: "danger", |
| 74 | 75 | message: SAY_SOMETHING_WORDS, |
| 75 | 76 | }); |
| 76 | const told = await tell(ctx.app, ctx.auth.user, repo, text, 1, from); | |
| 77 | return toldAnswer(ctx, repo, form, back, from, await tell(ctx.app, ctx.auth.user, repo, text, 1, from), onTold); | |
| 78 | } | |
| 79 | ||
| 80 | /** The answer to one tell, however it was made: refused as the crew's direction says, or told. */ | |
| 81 | function toldAnswer( | |
| 82 | ctx: Ctx, | |
| 83 | repo: Repo, | |
| 84 | form: Form, | |
| 85 | back: string, | |
| 86 | from: string | null, | |
| 87 | told: Awaited<ReturnType<typeof tell>>, | |
| 88 | onTold: () => void, | |
| 89 | note = "", | |
| 90 | ): Promise<Response> | Response { | |
| 77 | 91 | if (!told.ok && told.error === "body_too_long") |
| 78 | 92 | return flashOrJson(ctx, back, { |
| 79 | 93 | tone: "danger", |
| @@ -98,6 +112,7 @@ async function tellFrom( | ||
| 98 | 112 | const at = { href: `${repoBase(repo)}/issues/${told.value.issue.number}` }; |
| 99 | 113 | // "Build" never silently does nothing: with no credit the person is told, with the way forward. |
| 100 | 114 | if (!crewCanRun(ctx.app)) return flashOrJson(ctx, back, noCreditFlash(ctx), at); |
| 115 | if (note !== "") return flashOrJson(ctx, back, { tone: "attn", message: note }, at); | |
| 101 | 116 | if (routed === undefined) |
| 102 | 117 | return flashOrJson( |
| 103 | 118 | ctx, |
| @@ -219,6 +234,14 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> { | ||
| 219 | 234 | |
| 220 | 235 | async function composerTell(ctx: Ctx, form: Form, target: string, home: string, onTold: () => void): Promise<Response> { |
| 221 | 236 | if (!ctx.auth.user) return signInFirst(ctx); |
"the same action the terminal calls" is false: the terminal still keeps a thought. Fix the comment, and do the terminal in a follow-up so both doors match. |
||
| 237 | // The first ask of a new forge: one app made from the ask, the ask filed there (the terminal's first ask still keeps a thought). | |
| 238 | if (target === NEW_APP_TARGET) { | |
| 239 | const from = parseFrom(field(form, "from") || undefined) ?? null; | |
| 240 | const first = await firstAsk(ctx.app, ctx.auth.user, field(form, COMPOSER_FIELDS.text), crewCanRun(ctx.app), from); | |
| 241 | if (!first.ok) return flashOrJson(ctx, home, { tone: "danger", message: first.error.words }); | |
| 242 | const { repo, told, autonomyFailed } = first.value; | |
| 243 | return toldAnswer(ctx, repo, form, repoBase(repo), from, told, onTold, autonomyFailed ? AUTONOMY_FAILED_WORDS : ""); | |
| 244 | } | |
| 222 | 245 | // "the forge" is no app: the words are kept as a thought, exactly as typed. |
| 223 | 246 | if (target === FORGE_TARGET) { |
| 224 | 247 | if (field(form, COMPOSER_FIELDS.text).trim() === "") |
src/web/studio/feed.ts +2
| @@ -187,6 +187,8 @@ export function feedCard( | ||
| 187 | 187 | |
| 188 | 188 | /** The composer's app value for "the forge": no app, so no slash to collide with one. */ |
| 189 | 189 | export const FORGE_TARGET = "forge"; |
| 190 | /** The first-tell form's target: "a new app", made from the ask. */ | |
| 191 | export const NEW_APP_TARGET = "new-app"; | |
| 190 | 192 | |
| 191 | 193 | /** How many thoughts stand at the top of the feed. */ |
| 192 | 194 | const THOUGHTS_SHOWN = 3; |
test/first-ask-new-app.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * The first ask of a spawned forge (#11342): "What do you want built?" makes | |
| 3 | * a new app named from the ask and files the ask there as an issue; no | |
| 4 | * thought is kept on `forge`, and the person lands on the new app. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { listIssues, MAX_BODY } from "../src/core/issues.ts"; | |
| 9 | import { TOO_LONG_WORDS } from "../src/actions/tell.ts"; | |
| 10 | import { SAY_SOMETHING_WORDS } from "../src/actions/tell_words.ts"; | |
| 11 | import { listReposOwnedBy } from "../src/core/repos.ts"; | |
| 12 | import { recentThoughts } from "../src/core/thoughts.ts"; | |
| 13 | import { fundingOf, keepFunding } from "../src/spawn/funding.ts"; | |
| 14 | import { crewCanRun } from "../src/web/studio/crew_key.ts"; | |
| 15 | import { getUserByName } from "../src/core/users.ts"; | |
| 16 | import { claimedForge, type ClaimedForge } from "./helpers/claimed-forge.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let forge: ClaimedForge; | |
| 22 | beforeAll(async () => { | |
| 23 | forge = await claimedForge("forge-first-ask-"); | |
| 24 | }, 60_000); | |
| 25 | afterAll(() => forge.stop()); | |
| 26 | ||
| 27 | test("the first-tell form targets a new app, not the forge", async () => { | |
| 28 | const html = await (await fetch(`${forge.base}/`, { headers: { cookie: forge.owner } })).text(); | |
| 29 | const card = html.slice(html.indexOf("data-first-tell")); | |
| 30 | expect(card).toContain('value="new-app"'); | |
| 31 | expect(card).not.toContain('value="forge"'); | |
| 32 | }); | |
| 33 | ||
| 34 | test("the first tell makes one app, files one open issue on it, keeps no thought", async () => { | |
| 35 | const db = forge.child.db; | |
| 36 | const maria = getUserByName(db, "maria")!; | |
| 37 | const before = listReposOwnedBy(db, maria.id).length; | |
| 38 | const ask = "A weekly menu page for the bakery"; | |
| 39 | const res = await forge.post(forge.owner, "/tell", { app: "new-app", text: ask }); | |
| 40 | expect(res.status).toBe(303); | |
| 41 | const made = listReposOwnedBy(db, maria.id); | |
| 42 | expect(made.length).toBe(before + 1); | |
| 43 | const repo = made.find((r) => r.name.includes("menu"))!; | |
| 44 | expect(repo).toBeDefined(); | |
| 45 | expect(res.headers.get("location")).toContain(`/maria/${repo.name}`); | |
| 46 | const issues = listIssues(db, repo.id, { state: "open" }); | |
| 47 | expect(issues.length).toBe(1); | |
| 48 | expect(issues[0]!.body + issues[0]!.title).toContain("weekly menu page"); | |
| 49 | // The forge's included credit lets the crew run: the new app starts autonomous (box 2). | |
| 50 | expect(crewCanRun(forge.child)).toBe(true); | |
| 51 | expect(repo.autonomy).toBe("autonomous"); | |
| 52 | expect(recentThoughts(db, maria.id, 10).length).toBe(0); | |
| 53 | }); | |
| 54 | ||
| 55 | test("with no credit the app is made and filed, and left as it was made", async () => { | |
| 56 | const db = forge.child.db; | |
| 57 | const maria = getUserByName(db, "maria")!; | |
| 58 | const dir = forge.child.config.dataDir; | |
| 59 | const funding = fundingOf(dir)!; | |
| 60 | keepFunding(dir, { token: funding.token, capUsd: 2, spentUsd: 2 }); | |
| 61 | try { | |
| 62 | expect(crewCanRun(forge.child)).toBe(false); | |
| 63 | const res = await forge.post(forge.owner, "/tell", { app: "new-app", text: "A parking map for the market" }); | |
| 64 | expect(res.status).toBe(303); | |
| 65 | const repo = listReposOwnedBy(db, maria.id).find((r) => r.name.includes("parking"))!; | |
| 66 | expect(listIssues(db, repo.id, { state: "open" }).length).toBe(1); | |
| 67 | expect(repo.autonomy).not.toBe("autonomous"); | |
| 68 | } finally { | |
| 69 | keepFunding(dir, funding); | |
| 70 | } | |
| 71 | }); | |
| 72 | ||
| 73 | function flashOf(res: Response): string { | |
| 74 | return decodeURIComponent(res.headers.getSetCookie().join(";")); | |
| 75 | } | |
| 76 | ||
| 77 | test("an empty ask makes no app and says so", async () => { | |
| 78 | const db = forge.child.db; | |
| 79 | const before = listReposOwnedBy(db, getUserByName(db, "maria")!.id).length; | |
| 80 | const res = await forge.post(forge.owner, "/tell", { app: "new-app", text: " " }); | |
| 81 | expect(res.status).toBe(303); | |
| 82 | expect(flashOf(res)).toContain(SAY_SOMETHING_WORDS); | |
| 83 | expect(listReposOwnedBy(db, getUserByName(db, "maria")!.id).length).toBe(before); | |
| 84 | }); | |
| 85 | ||
| 86 | test("an over-long ask makes no app and says so", async () => { | |
| 87 | const db = forge.child.db; | |
| 88 | const before = listReposOwnedBy(db, getUserByName(db, "maria")!.id).length; | |
| 89 | const res = await forge.post(forge.owner, "/tell", { app: "new-app", text: "zebra ".repeat(MAX_BODY) }); | |
| 90 | expect(res.status).toBe(303); | |
| 91 | expect(flashOf(res)).toContain(TOO_LONG_WORDS); | |
| 92 | expect(listReposOwnedBy(db, getUserByName(db, "maria")!.id).length).toBe(before); | |
| 93 | }); |
autonomousis never read by the caller (only autonomyFailed is). Remove it; no dead fields.