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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11342-a-new-forge-s-first-ask-makes-a-new-app → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/11342-a-new-forge-s-first-ask-makes-a-new-app was merged into main as 48da7e4.

Review

Sign in to review this change.
6 files changed +189 −4
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
99 **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.
1010
1111 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 {
outdated

autonomous is never read by the caller (only autonomyFailed is). Remove it; no dead fields.

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";
2727 import { everydayAsk } from "./everyday.ts";
2828 import { EVERYDAY_WHY } from "../../core/everyday.ts";
2929 import { crewFirstLine } from "../studio/crew_key.ts";
30 import { FORGE_TARGET } from "../studio/feed.ts";
30 import { NEW_APP_TARGET } from "../studio/feed.ts";
3131 import { COMPOSER_FIELDS, TELL_MAX } from "../share_target.ts";
3232
3333 /** 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 {
6767 return html`<section id="next-steps" data-first-tell>
6868 <h2>What do you want built?</h2>
6969 <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}" />
7171 <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>
7272 <div class="bar">${btn("Tell the crew", { variant: "primary" })}</div>
7373 </form>
src/web/studio/actions.ts +25 −2
@@ -34,7 +34,8 @@ import { isLocalPath } from "../flash.ts";
3434 import { html } from "../html.ts";
3535 import { flashOrJson, flashTo, page } from "../layout.ts";
3636 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";
3839 import { changeHref } from "./nouns.ts";
3940
4041 /** A merge refusal in a person's words. */
@@ -73,7 +74,20 @@ async function tellFrom(
7374 tone: "danger",
7475 message: SAY_SOMETHING_WORDS,
7576 });
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 {
7791 if (!told.ok && told.error === "body_too_long")
7892 return flashOrJson(ctx, back, {
7993 tone: "danger",
@@ -98,6 +112,7 @@ async function tellFrom(
98112 const at = { href: `${repoBase(repo)}/issues/${told.value.issue.number}` };
99113 // "Build" never silently does nothing: with no credit the person is told, with the way forward.
100114 if (!crewCanRun(ctx.app)) return flashOrJson(ctx, back, noCreditFlash(ctx), at);
115 if (note !== "") return flashOrJson(ctx, back, { tone: "attn", message: note }, at);
101116 if (routed === undefined)
102117 return flashOrJson(
103118 ctx,
@@ -219,6 +234,14 @@ export async function composerSubmit(ctx: Ctx): Promise<Response> {
219234
220235 async function composerTell(ctx: Ctx, form: Form, target: string, home: string, onTold: () => void): Promise<Response> {
221236 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 }
222245 // "the forge" is no app: the words are kept as a thought, exactly as typed.
223246 if (target === FORGE_TARGET) {
224247 if (field(form, COMPOSER_FIELDS.text).trim() === "")
src/web/studio/feed.ts +2
@@ -187,6 +187,8 @@ export function feedCard(
187187
188188 /** The composer's app value for "the forge": no app, so no slash to collide with one. */
189189 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";
190192
191193 /** How many thoughts stand at the top of the feed. */
192194 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs