Public: anyone can read this; the crew can change it.

A Small forge (1 CPU, 2 GiB) builds a first app: today the host stops it for… #11979

Merged opened this in answer to

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

Merged

scout/forge:scout/11975-a-small-forge-1-cpu-2-gib-builds-a-first → main 10 commits fork 2 threads · 2 unresolved

  • Merged. scout/11975-a-small-forge-1-cpu-2-gib-builds-a-first was merged into main as 6b25387.

Review

Sign in to review this change.
3 files changed +138 −0
docs/features/ssh-first-ask.md added +8
@@ -0,0 +1,8 @@
1 # The first ask by ssh makes an app (#11975)
2
3 On a forge spawned for a person, the first `ssh <forge> tell <words>` from someone who has not told the crew yet makes a new app from the words and files the ask there (the same `firstAsk` the home page uses), instead of filing it on the forge's own source repository. It prints the new issue's address.
4
5 - **Only the first:** once the person has any issue of their own, `tell` goes to the forge's repository as before.
6 - **Not on an app's login:** `ssh <app>@<forge> tell` still goes to that app.
7 - **Not on other forges:** a forge that was not spawned (no `parent.json`) is unchanged.
8 - Code: `src/tui/ssh-tell.ts`; test: `test/ssh-tell-first-ask.test.ts`.
src/tui/ssh-tell.ts +24
@@ -20,6 +20,9 @@ import type { User } from "../core/users.ts";
2020 import type { EnvName } from "../apps/data.ts";
2121 import type { SshSession } from "../ssh/server.ts";
2222 import { tellAnswer } from "./screens/tell.ts";
23 import { AUTONOMY_FAILED_WORDS, firstAsk } from "../actions/first_ask.ts";
24 import { nextStepsFor } from "../core/next-steps.ts";
25 import { crewCanRun } from "../web/studio/crew_key.ts";
outdated

Merge with the import on the line above (one import per module). The first-ask path also needs a docs/features how-to.

2326
2427 const CRLF = "\r\n";
2528
@@ -78,6 +81,9 @@ async function told(
7881 if (/[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(words)) return refuse(session, CONTROL_WORDS);
7982 const said = words.trim();
8083 if (said === "") return refuse(session, SAY_SOMETHING_WORDS);
84 // A claimed forge's first ask makes a new app, as the page's does:
85 // never an issue on the forge's own source.
86 if (appRepo === undefined && (await isFirstAsk(app, user))) return firstTold(app, session, user, said);
8187 const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/");
8288 const repo = appRepo ?? getRepo(app.db, owner, name);
8389 if (repo === undefined) return refuse(session, NO_CREW_WORDS);
@@ -90,3 +96,21 @@ async function told(
9096 );
9197 session.exit(0);
9298 }
99
100 /** Whether this is the person's first ask on a forge spawned for them: it makes an app. */
101 async function isFirstAsk(app: App, user: User): Promise<boolean> {
102 const steps = await nextStepsFor(app, user);
103 return steps?.some((s) => s.key === "tell" && !s.done) === true;
104 }
105
106 async function firstTold(app: App, session: SshSession, user: User, said: string): Promise<void> {
107 const first = await firstAsk(app, user, said, crewCanRun(app));
108 if (!first.ok) return refuse(session, first.error.words);
109 const { repo, told, autonomyFailed } = first.value;
110 if (!told.ok) return refuse(session, tellAnswer(told).text);
111 const base = app.config.url ?? `http://localhost:${app.config.port ?? 3000}`;
112 session.write(
113 `${tellAnswer(told).text}${CRLF}${autonomyFailed ? `${AUTONOMY_FAILED_WORDS}${CRLF}` : ""}${base}/${repo.ownerName}/${repo.name}/issues/${told.value.issue.number}${CRLF}`,
114 );
115 session.exit(0);
116 }
test/ssh-tell-first-ask.test.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * `ssh <forge> tell` as a spawned forge's first ask (#11975): it makes a new
3 * app and files the ask there, never on the forge's own source; the next tell
4 * goes to the forge's repository as before.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createOrg } from "../src/core/orgs.ts";
13 import { addKey, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { forgeSshSession } from "../src/tui/ssh.ts";
16 import type { SshSession } from "../src/ssh/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo carol";
22
23 let dir = "";
24 let app: App;
25
26 const exec = async (command: string) => {
27 const parsed = parseAuthorizedKey(KEY);
28 if (!parsed.ok) throw new Error("key");
29 let out = "";
30 let err = "";
31 let code: number | undefined;
32 await new Promise<void>((resolve) => {
33 const session: SshSession = {
34 key: { algo: parsed.value.algo, blob: parsed.value.blob, fingerprint: fingerprint(parsed.value.blob), line: KEY },
35 username: "whoever",
36 remoteAddress: "203.0.113.9",
37 kind: "exec",
38 command,
39 term: undefined,
40 env: {},
41 cols: 0,
42 rows: 0,
43 widthPx: 0,
44 heightPx: 0,
45 ended: false,
46 pending: 0,
47 write: (d: Uint8Array | string) => void (out += typeof d === "string" ? d : Buffer.from(d).toString()),
48 writeStderr: (d: string) => void (err += d),
49 onData() {},
50 onResize() {},
51 onEof() {},
52 onClose() {},
53 exit(c: number) {
54 code ??= c;
55 resolve();
56 },
57 drain: async () => {},
58 };
59 forgeSshSession(app)(session);
60 });
61 return { out, err, code };
62 };
63
64 const issuesOn = (name: string) =>
65 app.db
66 .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = (SELECT id FROM repo WHERE name = ?1)")
67 .get(name)!.n;
68
69 beforeAll(async () => {
70 dir = await mkdtemp(join(tmpdir(), "forge-ssh-first-"));
71 app = createApp({ dataDir: dir, port: 0, forgeRepo: "carol/forge", url: "https://forge.test" });
72 const u = await createUser(app.db, {
73 origin: "seed",
74 trusted: true,
75 username: "carol",
76 email: "[email protected]",
77 password: "a-good-passphrase",
78 adminIfFirst: true,
79 });
80 if (!u.ok) throw new Error(u.error);
81 const parsed = parseAuthorizedKey(KEY);
82 if (!parsed.ok) throw new Error("key");
83 addKey(app.db, u.value.id, "laptop", parsed.value);
84 const org = createOrg(app.db, "kin", u.value.id);
85 if (!org.ok) throw new Error(org.error);
86 await Bun.write(join(dir, "parent.json"), JSON.stringify({ org: "kin" }));
87 const made = await createRepo(app, u.value, { owner: "carol", name: "forge" });
88 if (!made.ok) throw new Error(made.error);
89 });
90
91 afterAll(async () => {
92 app.close();
93 await rm(dir, { recursive: true, force: true });
94 });
95
96 test("the first tell makes a new app and files there; the second goes to the forge's repository", async () => {
97 const first = await exec("tell a weekly menu page for the bakery");
98 expect(first.code).toBe(0);
99 expect(first.out).toContain("/carol/");
100 expect(first.out).not.toContain("/carol/forge/");
101 expect(issuesOn("forge")).toBe(0);
102 const second = await exec("tell make it blue");
103 expect(second.code).toBe(0);
104 expect(second.out).toContain("/carol/forge/issues/");
105 expect(issuesOn("forge")).toBe(1);
106 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs