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

Each row on /forges names the next step that forge's person has not taken: claim it, make an everyday account, or tell the crew something #5921

Merged opened this

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

Merged

scout/forge:scout/5906-each-row-on-forges-names-the-next-step-t → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/5906-each-row-on-forges-names-the-next-step-t was merged into main as 59b69b5.

Review

Sign in to review this change.
9 files changed +152 −8
README.md +1 −1
@@ -20,7 +20,7 @@ any URL — and it is the rest of this page.
2020
2121 A forge this one spawned (hosted) runs the apps it was given too: its `bakery`
2222 answers at `bakery--<child>--<this forge's host>`, in this forge's lanes, on the
23 child's own data, and `/forges` says how many are running and why any is not.
23 child's own data, and `/forges` says how many are running and why any is not. Each row also ends with the first step its person has not taken (`not claimed yet`, `no everyday account yet`, `no tell yet`, or `going: last tell <time>`; `has not reported yet` before the child's first beat), read from the child's heartbeat; the terminal's forges screen and the MCP `forges` tool (`nextStep`) say the same.
2424 In the terminal an admin's palette offers "Forges you sent": each forge with its
2525 claim state and the same apps-running note, never a claim link.
2626
src/core/next-steps.ts +30 −1
@@ -14,7 +14,7 @@ import { spawnedOrg } from "../spawn/claim.ts";
1414 import { adminOf, everydayOf, keptAdmin } from "./everyday.ts";
1515 import { listReposByOwner, type Repo } from "./repos.ts";
1616 import { getUserSetting, setUserSetting } from "./settings.ts";
17 import type { User } from "./users.ts";
17 import { getUserById, type User } from "./users.ts";
1818
1919 export type StepKey = "everyday" | "apps" | "tell";
2020
@@ -70,6 +70,35 @@ export async function nextStepsFor(
7070 ];
7171 }
7272
73 /**
74 * Where this forge's person stands, for the heartbeat: claimed, whether they
75 * have an everyday account (or chose to keep working as admin), and when
76 * they last told the crew something (null before the first tell).
77 */
78 export function progress(app: App): { claimed: boolean; everyday: boolean; lastTellAt: string | null } {
79 const owner = app.db
80 .query<{ id: string }, []>(
81 "SELECT id FROM user WHERE is_admin = 1 AND is_agent = 0 AND kind = 'user' ORDER BY created_at LIMIT 1",
82 )
83 .get();
84 const admin = owner === null ? undefined : getUserById(app.db, owner.id);
85 if (admin === undefined) return { claimed: false, everyday: false, lastTellAt: null };
86 const daily = everydayOf(app.db, admin);
87 const people = [admin.id, ...(daily ? [daily.id] : [])];
88 const last = app.db
89 .query<{ at: string | null }, string[]>(
90 `SELECT MAX(i.created_at) AS at FROM issue i
91 WHERE i.kind = 'issue' AND i.author_id IN (${people.map((_, n) => `?${n + 1}`).join(",")})
92 AND i.id NOT IN (SELECT direction_issue_id FROM repo WHERE direction_issue_id IS NOT NULL)`,
93 )
94 .get(...people);
95 return {
96 claimed: true,
97 everyday: daily !== undefined || keptAdmin(app.db, admin),
98 lastTellAt: last?.at ?? null,
99 };
100 }
101
73102 /** The owner (or their everyday account) opened an app on a spawned forge: the second step is done. */
74103 export async function noteAppOpened(app: App, user: User | undefined): Promise<void> {
75104 if (!user) return;
src/spawn/clients.ts +25
@@ -16,6 +16,7 @@ import { sha256 } from "../kernel/hash.ts";
1616 import type { ChildAppsSummary } from "./child-apps.ts";
1717 import { hostedChildApps } from "./host.ts";
1818 import { hasAdmin, spawnedOrg } from "./claim.ts";
19 import { lastHeard, type Beat } from "./heartbeat.ts";
1920
2021 /** What a spawned forge says about itself to anyone: nothing more. */
2122 export interface ForgeState {
@@ -36,6 +37,28 @@ export interface ClientRow {
3637 readonly lastActivityAt: string | null;
3738 /** The apps this parent runs for the child; null when it runs none here. */
3839 readonly running: ChildAppsSummary | null;
40 /** The first step its person has not taken, read from the child's heartbeat. */
41 readonly nextStep: NextStep;
42 }
43
44 export type NextStepKey = "unreported" | "claim" | "everyday" | "tell" | "going";
45
46 export interface NextStep {
47 readonly key: NextStepKey;
48 readonly words: string;
49 /** When the person last told the crew something; set only while going. */
50 readonly lastTellAt: string | null;
51 }
52
53 /** The first step not yet taken, from the claim state and the child's last beat. */
54 export function nextStepOf(claimed: boolean | null, beat: Beat | undefined): NextStep {
55 const known = beat?.claimed ?? claimed;
56 if (beat === undefined || known === null || beat.everyday === undefined || beat.lastTellAt === undefined)
57 return { key: "unreported", words: "has not reported yet", lastTellAt: null };
58 if (!known) return { key: "claim", words: "not claimed yet", lastTellAt: null };
59 if (!beat.everyday) return { key: "everyday", words: "no everyday account yet", lastTellAt: null };
60 if (beat.lastTellAt === null) return { key: "tell", words: "no tell yet", lastTellAt: null };
61 return { key: "going", words: `going: last tell ${beat.lastTellAt}`, lastTellAt: beat.lastTellAt };
3962 }
4063
4164 type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
@@ -153,6 +176,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
153176 return last.state;
154177 }),
155178 );
179 const beats = await Promise.all(children.map((c) => lastHeard(app, c.name)));
156180 return children
157181 .map((c, i) => ({
158182 name: c.name,
@@ -164,6 +188,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
164188 claimed: states[i]?.claimed ?? null,
165189 lastActivityAt: states[i]?.lastActivityAt ?? null,
166190 running: hostedChildApps(app, c.name) ?? null,
191 nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat),
167192 }))
168193 .sort(
169194 (a, b) =>
src/spawn/heartbeat.ts +25 −2
@@ -21,6 +21,7 @@ import { beatKeyOf, listChildren } from "../actions/spawn.ts";
2121 import releases from "../core/releases.json" with { type: "json" };
2222 import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts";
2323 import { upstreamState } from "./upstream.ts";
24 import { progress } from "../core/next-steps.ts";
2425 import { running } from "../kernel/version.ts";
2526
2627 export const SIGNATURE_HEADER = "x-forge-signature";
@@ -34,6 +35,7 @@ export const MAX_CHILDREN = 100;
3435 export const MAX_DEPTH = 4;
3536 const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
3637 const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
38 const ISO_TIME = /^\d{4}-\d\d-\d\dT[\d:.]{5,12}Z$/;
3739 const COUNT_MAX = 1_000_000;
3840
3941 export interface Beat {
@@ -53,9 +55,17 @@ export interface Beat {
5355 readonly upstreamBehind?: number | null;
5456 /** True while a converge issue is open on the child; absent from an older child. */
5557 readonly conflict?: boolean;
58 /** Whether its person has claimed it; absent from an older child or when the value was bad. */
59 readonly claimed?: boolean;
60 /** Whether it has an everyday account (or chose to go without); absent likewise. */
61 readonly everyday?: boolean;
62 /** When its person last told the crew something; null before the first, absent likewise. */
63 readonly lastTellAt?: string | null;
5664 }
5765
58 const OPTIONAL = new Set(["forgeTip", "severedAt", "mode", "upstreamBehind", "conflict"]);
66 const OPTIONAL = new Set([
67 "forgeTip", "severedAt", "mode", "upstreamBehind", "conflict", "claimed", "everyday", "lastTellAt",
68 ]);
5969
6070 export function sign(key: string, body: string): string {
6171 return createHmac("sha256", key).update(body).digest("hex");
@@ -95,6 +105,15 @@ export function parseBeat(raw: string): Beat | undefined {
95105 if (mode !== undefined && !(typeof mode === "string" && (FOLLOW_MODES as readonly string[]).includes(mode))) return undefined;
96106 if (upstreamBehind !== undefined && upstreamBehind !== null && !count(upstreamBehind)) return undefined;
97107 if (conflict !== undefined && typeof conflict !== "boolean") return undefined;
108 // The person's first steps are only a hint: a bad value drops the field, never the beat.
109 const claimed = d["claimed"];
110 const everyday = d["everyday"];
111 const told = d["lastTellAt"];
112 const claimedOk = typeof claimed === "boolean";
113 const everydayOk = typeof everyday === "boolean";
114 // Strict ISO: it is printed into the parent admin's terminal, so nothing but digits and punctuation.
outdated

Date.parse is too lenient: it lets control characters through inside parentheses. Require a strict ISO format here, and test that a string with an ESC byte is dropped.

115 const toldOk =
116 told === null || (typeof told === "string" && ISO_TIME.test(told) && !Number.isNaN(Date.parse(told)));
98117 if (typeof name !== "string" || !NAME.test(name)) return undefined;
99118 if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return undefined;
100119 if (typeof version !== "string" || !VERSION.test(version)) return undefined;
@@ -110,6 +129,9 @@ export function parseBeat(raw: string): Beat | undefined {
110129 ...(mode === undefined ? {} : { mode: mode as FollowMode }),
111130 ...(upstreamBehind === undefined ? {} : { upstreamBehind: upstreamBehind as number | null }),
112131 ...(conflict === undefined ? {} : { conflict }),
132 ...(claimedOk ? { claimed } : {}),
133 ...(everydayOk ? { everyday } : {}),
134 ...(told !== undefined && toldOk ? { lastTellAt: told as string | null } : {}),
113135 };
114136 }
115137
@@ -122,7 +144,7 @@ export interface Heard {
122144 readonly receivedAt: number;
123145 }
124146
125 async function lastHeard(app: App, name: string): Promise<Heard | undefined> {
147 export async function lastHeard(app: App, name: string): Promise<Heard | undefined> {
126148 try {
127149 const doc = JSON.parse(await readFile(beatPath(app, name), "utf8")) as {
128150 receivedAt?: unknown;
@@ -245,6 +267,7 @@ export async function sendBeat(
245267 const body = JSON.stringify({
246268 ...standing(app, link.name, lastAt),
247269 children: Math.min(own, COUNT_MAX),
270 ...progress(app),
248271 // Only a forge that carries a fork of its parent's code has these to say.
249272 ...(forge === undefined
250273 ? {}
src/tui/screens/forges.ts +1 −1
@@ -15,7 +15,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
1515 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
1616 return rows.map((r) => [
1717 r.name,
18 [r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r)]
18 [r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words]
1919 .filter((m): m is string => m !== null)
2020 .join(" · "),
2121 ]);
src/web/pages/forges.ts +3
@@ -39,6 +39,9 @@ function forgeRow(r: ClientRow): Html {
3939 r.lastActivityAt === null
4040 ? null
4141 : html`last activity ${time(r.lastActivityAt)}`,
42 badge(r.nextStep.key === "going" ? html`going: last tell ${time(r.nextStep.lastTellAt!)}` : r.nextStep.words, {
outdated

Good: the page shows the step. src/tui/screens/forges.ts forgeLines needs the same r.nextStep.words, so the terminal door matches.

43 variant: "outline",
44 }),
4245 ].filter((m): m is Html => m !== null);
4346 return row({
4447 title: html`<a href="${r.url}">${r.name}</a>`,
test/client-list.test.ts +60 −1
@@ -19,7 +19,10 @@ import { createUser } from "../src/core/users.ts";
1919 import { commitFiles } from "../src/git/write.ts";
2020 import { repoDir } from "../src/git/repos.ts";
2121 import { serve, type Servers } from "../src/http/server.ts";
22 import { useChildFetch } from "../src/spawn/clients.ts";
22 import { keepAdmin } from "../src/core/everyday.ts";
23 import { getUserById } from "../src/core/users.ts";
24 import { nextStepOf, useChildFetch } from "../src/spawn/clients.ts";
25 import { parseBeat, sendBeat } from "../src/spawn/heartbeat.ts";
2326 import { firstBoot } from "../src/spawn/first-boot.ts";
2427 import { quietLogs } from "./helpers/globals.ts";
2528 import { passkeyFields } from "./helpers/passkey.ts";
@@ -205,6 +208,9 @@ describe("the client list", () => {
205208 expect(maria).toContain('class="row"');
206209 expect(maria).toContain("Send a fresh link");
207210 expect(lou).toContain("it did not answer just now");
211 // Neither has reported yet.
212 expect(maria).toContain("has not reported yet");
213 expect(lou).toContain("has not reported yet");
208214 // The claim link itself is never kept, so never shown here.
209215 expect(html).not.toContain(claimUrl.split("#")[1]!);
210216 });
@@ -262,6 +268,59 @@ describe("the client list", () => {
262268 expect(rows.maria.claimed).toBe(true);
263269 expect(typeof rows.maria.lastActivityAt).toBe("string");
264270 expect(rows.lou.claimed).toBeNull();
271 expect(rows.maria.nextStep.key).toBe("unreported");
272 });
273
274 test("a row ends with the first step its person has not taken, read from the child's beat", async () => {
275 const beatTo = () =>
276 sendBeat(child, (url, init) => fetch(url.replace("https://forge.test", parentServers.url), init));
277 const words = async () => {
278 const html = (await (await list(admin)).text()).replace(/\s+/g, " ");
279 const at = html.indexOf('data-forge="maria"');
280 const row = html.slice(at, html.indexOf("</li>", at));
281 return /not claimed yet|no everyday account yet|no tell yet|going: last tell|has not reported yet/.exec(row)?.[0];
282 };
283 const mariaStep = async () => {
284 const tool = (await (await mcp(adminRw)).json()) as any;
285 return JSON.parse(tool.result.content[0].text).forges.find((f: any) => f.name === "maria").nextStep;
286 };
287 // The claim made the child's person an admin; nothing else yet.
288 expect(await beatTo()).toBe(true);
289 expect(await words()).toBe("no everyday account yet");
290 expect((await mariaStep()).words).toBe("no everyday account yet");
291 const person = child.db.query<{ id: string }, []>("SELECT id FROM user WHERE is_admin = 1 AND kind = 'user' AND is_agent = 0").get()!;
292 keepAdmin(child.db, getUserById(child.db, person.id)!);
293 expect(await beatTo()).toBe(true);
294 expect(await words()).toBe("no tell yet");
295 expect((await mariaStep()).words).toBe("no tell yet");
296 const repo = child.db.query<{ id: string }, []>("SELECT id FROM repo LIMIT 1").get()!;
297 child.db
298 .query("INSERT INTO issue (id, repo_id, number, title, author_id, created_at) VALUES ('t1', ?1, 9999, 'hello', ?2, '2030-02-03T04:05:06.000Z')")
299 .run(repo.id, person.id);
300 expect(await beatTo()).toBe(true);
301 expect(await words()).toBe("going: last tell");
302 const step = await mariaStep();
303 expect(step).toMatchObject({ key: "going", lastTellAt: "2030-02-03T04:05:06.000Z" });
304 expect(step.words).toBe("going: last tell 2030-02-03T04:05:06.000Z");
305 });
306
307 test("each step in words, and a beat with a bad value loses the field, not the beat", () => {
308 const beat = (o: Record<string, unknown>) =>
309 parseBeat(JSON.stringify({ name: "maria", at: 1, version: "1.0.0", openProposals: 0, children: 0, ...o }));
310 const full = { claimed: true, everyday: true, lastTellAt: null };
311 expect(nextStepOf(null, undefined).words).toBe("has not reported yet");
312 expect(nextStepOf(false, beat({ ...full, claimed: false })).words).toBe("not claimed yet");
313 expect(nextStepOf(true, beat({ ...full, everyday: false })).words).toBe("no everyday account yet");
314 expect(nextStepOf(true, beat(full)).words).toBe("no tell yet");
315 expect(nextStepOf(true, beat({ ...full, lastTellAt: "2030-01-01T00:00:00Z" })).key).toBe("going");
316 // An older child says none of it.
317 expect(nextStepOf(true, beat({}))?.key).toBe("unreported");
318 for (const bad of [{ claimed: "yes" }, { everyday: 1 }, { lastTellAt: "soon" }, { lastTellAt: "2030-01-01T00:00:00Z (\u001b[31m)" }, { lastTellAt: "Jan 1 2030 (\u001b[31mx)" }, { lastTellAt: 5 }]) {
319 const kept = beat({ ...full, ...bad });
320 expect(kept).toBeDefined();
321 expect(kept?.name).toBe("maria");
322 expect(Object.keys(bad).every((k) => !(k in kept!))).toBe(true);
323 }
265324 });
266325 });
267326
test/family-heartbeat.test.ts +3
@@ -336,6 +336,9 @@ describe("family heartbeats", () => {
336336 expect(Object.keys(JSON.parse(sent[0]!.body)).sort()).toEqual([
337337 "at",
338338 "children",
339 "claimed",
340 "everyday",
341 "lastTellAt",
339342 "name",
340343 "openProposals",
341344 "version",
test/tui-forges.test.ts +4 −2
@@ -25,6 +25,7 @@ const row = (over: Partial<ClientRow>): ClientRow => ({
2525 claimed: false,
2626 lastActivityAt: null,
2727 running: null,
28 nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null },
2829 ...over,
2930 });
3031
@@ -97,13 +98,14 @@ describe("forges you sent in the terminal", () => {
9798 const up = { total: 3, running: 3, notRunning: [] };
9899 const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] };
99100 expect(forgeLines([row({ running: up })])).toEqual([
100 ["maria", "for Maria's bakery · Not claimed yet · 3 apps running"],
101 ["maria", "for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet"],
101102 ]);
102103 expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([
103 ["maria", "Claimed · 1 app running · notes: build failed"],
104 ["maria", "Claimed · 1 app running · notes: build failed · has not reported yet"],
104105 ]);
105106 expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown");
106107 expect(forgeLines([row({ made: false })])[0]![1]).toContain("Still being made");
108 expect(forgeLines([row({ nextStep: { key: "tell", words: "no tell yet", lastTellAt: null } })])[0]![1]).toContain("no tell yet");
107109 expect(JSON.stringify(forgeLines([row({})]))).not.toContain("http");
108110 expect(runningWords(row({ running: { total: 0, running: 0, notRunning: [] } }))).toBeNull();
109111 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs