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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5906-each-row-on-forges-names-the-next-step-t → main 6 commits fork 3 threads · 3 unresolved
9 files
changed +152
−8
README.md +1 −1
| @@ -20,7 +20,7 @@ any URL — and it is the rest of this page. | ||
| 20 | 20 | |
| 21 | 21 | A forge this one spawned (hosted) runs the apps it was given too: its `bakery` |
| 22 | 22 | 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. | |
| 24 | 24 | In the terminal an admin's palette offers "Forges you sent": each forge with its |
| 25 | 25 | claim state and the same apps-running note, never a claim link. |
| 26 | 26 |
src/core/next-steps.ts +30 −1
| @@ -14,7 +14,7 @@ import { spawnedOrg } from "../spawn/claim.ts"; | ||
| 14 | 14 | import { adminOf, everydayOf, keptAdmin } from "./everyday.ts"; |
| 15 | 15 | import { listReposByOwner, type Repo } from "./repos.ts"; |
| 16 | 16 | import { getUserSetting, setUserSetting } from "./settings.ts"; |
| 17 | import type { User } from "./users.ts"; | |
| 17 | import { getUserById, type User } from "./users.ts"; | |
| 18 | 18 | |
| 19 | 19 | export type StepKey = "everyday" | "apps" | "tell"; |
| 20 | 20 | |
| @@ -70,6 +70,35 @@ export async function nextStepsFor( | ||
| 70 | 70 | ]; |
| 71 | 71 | } |
| 72 | 72 | |
| 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 | ||
| 73 | 102 | /** The owner (or their everyday account) opened an app on a spawned forge: the second step is done. */ |
| 74 | 103 | export async function noteAppOpened(app: App, user: User | undefined): Promise<void> { |
| 75 | 104 | if (!user) return; |
src/spawn/clients.ts +25
| @@ -16,6 +16,7 @@ import { sha256 } from "../kernel/hash.ts"; | ||
| 16 | 16 | import type { ChildAppsSummary } from "./child-apps.ts"; |
| 17 | 17 | import { hostedChildApps } from "./host.ts"; |
| 18 | 18 | import { hasAdmin, spawnedOrg } from "./claim.ts"; |
| 19 | import { lastHeard, type Beat } from "./heartbeat.ts"; | |
| 19 | 20 | |
| 20 | 21 | /** What a spawned forge says about itself to anyone: nothing more. */ |
| 21 | 22 | export interface ForgeState { |
| @@ -36,6 +37,28 @@ export interface ClientRow { | ||
| 36 | 37 | readonly lastActivityAt: string | null; |
| 37 | 38 | /** The apps this parent runs for the child; null when it runs none here. */ |
| 38 | 39 | 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 }; | |
| 39 | 62 | } |
| 40 | 63 | |
| 41 | 64 | type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; |
| @@ -153,6 +176,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 153 | 176 | return last.state; |
| 154 | 177 | }), |
| 155 | 178 | ); |
| 179 | const beats = await Promise.all(children.map((c) => lastHeard(app, c.name))); | |
| 156 | 180 | return children |
| 157 | 181 | .map((c, i) => ({ |
| 158 | 182 | name: c.name, |
| @@ -164,6 +188,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 164 | 188 | claimed: states[i]?.claimed ?? null, |
| 165 | 189 | lastActivityAt: states[i]?.lastActivityAt ?? null, |
| 166 | 190 | running: hostedChildApps(app, c.name) ?? null, |
| 191 | nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat), | |
| 167 | 192 | })) |
| 168 | 193 | .sort( |
| 169 | 194 | (a, b) => |
src/spawn/heartbeat.ts +25 −2
| @@ -21,6 +21,7 @@ import { beatKeyOf, listChildren } from "../actions/spawn.ts"; | ||
| 21 | 21 | import releases from "../core/releases.json" with { type: "json" }; |
| 22 | 22 | import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts"; |
| 23 | 23 | import { upstreamState } from "./upstream.ts"; |
| 24 | import { progress } from "../core/next-steps.ts"; | |
| 24 | 25 | import { running } from "../kernel/version.ts"; |
| 25 | 26 | |
| 26 | 27 | export const SIGNATURE_HEADER = "x-forge-signature"; |
| @@ -34,6 +35,7 @@ export const MAX_CHILDREN = 100; | ||
| 34 | 35 | export const MAX_DEPTH = 4; |
| 35 | 36 | const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; |
| 36 | 37 | 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$/; | |
| 37 | 39 | const COUNT_MAX = 1_000_000; |
| 38 | 40 | |
| 39 | 41 | export interface Beat { |
| @@ -53,9 +55,17 @@ export interface Beat { | ||
| 53 | 55 | readonly upstreamBehind?: number | null; |
| 54 | 56 | /** True while a converge issue is open on the child; absent from an older child. */ |
| 55 | 57 | 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; | |
| 56 | 64 | } |
| 57 | 65 | |
| 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 | ]); | |
| 59 | 69 | |
| 60 | 70 | export function sign(key: string, body: string): string { |
| 61 | 71 | return createHmac("sha256", key).update(body).digest("hex"); |
| @@ -95,6 +105,15 @@ export function parseBeat(raw: string): Beat | undefined { | ||
| 95 | 105 | if (mode !== undefined && !(typeof mode === "string" && (FOLLOW_MODES as readonly string[]).includes(mode))) return undefined; |
| 96 | 106 | if (upstreamBehind !== undefined && upstreamBehind !== null && !count(upstreamBehind)) return undefined; |
| 97 | 107 | 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. | |
| 115 | const toldOk = | |
| 116 | told === null || (typeof told === "string" && ISO_TIME.test(told) && !Number.isNaN(Date.parse(told))); | |
| 98 | 117 | if (typeof name !== "string" || !NAME.test(name)) return undefined; |
| 99 | 118 | if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return undefined; |
| 100 | 119 | if (typeof version !== "string" || !VERSION.test(version)) return undefined; |
| @@ -110,6 +129,9 @@ export function parseBeat(raw: string): Beat | undefined { | ||
| 110 | 129 | ...(mode === undefined ? {} : { mode: mode as FollowMode }), |
| 111 | 130 | ...(upstreamBehind === undefined ? {} : { upstreamBehind: upstreamBehind as number | null }), |
| 112 | 131 | ...(conflict === undefined ? {} : { conflict }), |
| 132 | ...(claimedOk ? { claimed } : {}), | |
| 133 | ...(everydayOk ? { everyday } : {}), | |
| 134 | ...(told !== undefined && toldOk ? { lastTellAt: told as string | null } : {}), | |
| 113 | 135 | }; |
| 114 | 136 | } |
| 115 | 137 | |
| @@ -122,7 +144,7 @@ export interface Heard { | ||
| 122 | 144 | readonly receivedAt: number; |
| 123 | 145 | } |
| 124 | 146 | |
| 125 | async function lastHeard(app: App, name: string): Promise<Heard | undefined> { | |
| 147 | export async function lastHeard(app: App, name: string): Promise<Heard | undefined> { | |
| 126 | 148 | try { |
| 127 | 149 | const doc = JSON.parse(await readFile(beatPath(app, name), "utf8")) as { |
| 128 | 150 | receivedAt?: unknown; |
| @@ -245,6 +267,7 @@ export async function sendBeat( | ||
| 245 | 267 | const body = JSON.stringify({ |
| 246 | 268 | ...standing(app, link.name, lastAt), |
| 247 | 269 | children: Math.min(own, COUNT_MAX), |
| 270 | ...progress(app), | |
| 248 | 271 | // Only a forge that carries a fork of its parent's code has these to say. |
| 249 | 272 | ...(forge === undefined |
| 250 | 273 | ? {} |
src/tui/screens/forges.ts +1 −1
| @@ -15,7 +15,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 15 | 15 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; |
| 16 | 16 | return rows.map((r) => [ |
| 17 | 17 | 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] | |
| 19 | 19 | .filter((m): m is string => m !== null) |
| 20 | 20 | .join(" · "), |
| 21 | 21 | ]); |
src/web/pages/forges.ts +3
| @@ -39,6 +39,9 @@ function forgeRow(r: ClientRow): Html { | ||
| 39 | 39 | r.lastActivityAt === null |
| 40 | 40 | ? null |
| 41 | 41 | : 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. |
||
| 43 | variant: "outline", | |
| 44 | }), | |
| 42 | 45 | ].filter((m): m is Html => m !== null); |
| 43 | 46 | return row({ |
| 44 | 47 | 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"; | ||
| 19 | 19 | import { commitFiles } from "../src/git/write.ts"; |
| 20 | 20 | import { repoDir } from "../src/git/repos.ts"; |
| 21 | 21 | 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"; | |
| 23 | 26 | import { firstBoot } from "../src/spawn/first-boot.ts"; |
| 24 | 27 | import { quietLogs } from "./helpers/globals.ts"; |
| 25 | 28 | import { passkeyFields } from "./helpers/passkey.ts"; |
| @@ -205,6 +208,9 @@ describe("the client list", () => { | ||
| 205 | 208 | expect(maria).toContain('class="row"'); |
| 206 | 209 | expect(maria).toContain("Send a fresh link"); |
| 207 | 210 | 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"); | |
| 208 | 214 | // The claim link itself is never kept, so never shown here. |
| 209 | 215 | expect(html).not.toContain(claimUrl.split("#")[1]!); |
| 210 | 216 | }); |
| @@ -262,6 +268,59 @@ describe("the client list", () => { | ||
| 262 | 268 | expect(rows.maria.claimed).toBe(true); |
| 263 | 269 | expect(typeof rows.maria.lastActivityAt).toBe("string"); |
| 264 | 270 | 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 | } | |
| 265 | 324 | }); |
| 266 | 325 | }); |
| 267 | 326 |
test/family-heartbeat.test.ts +3
| @@ -336,6 +336,9 @@ describe("family heartbeats", () => { | ||
| 336 | 336 | expect(Object.keys(JSON.parse(sent[0]!.body)).sort()).toEqual([ |
| 337 | 337 | "at", |
| 338 | 338 | "children", |
| 339 | "claimed", | |
| 340 | "everyday", | |
| 341 | "lastTellAt", | |
| 339 | 342 | "name", |
| 340 | 343 | "openProposals", |
| 341 | 344 | "version", |
test/tui-forges.test.ts +4 −2
| @@ -25,6 +25,7 @@ const row = (over: Partial<ClientRow>): ClientRow => ({ | ||
| 25 | 25 | claimed: false, |
| 26 | 26 | lastActivityAt: null, |
| 27 | 27 | running: null, |
| 28 | nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null }, | |
| 28 | 29 | ...over, |
| 29 | 30 | }); |
| 30 | 31 | |
| @@ -97,13 +98,14 @@ describe("forges you sent in the terminal", () => { | ||
| 97 | 98 | const up = { total: 3, running: 3, notRunning: [] }; |
| 98 | 99 | const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] }; |
| 99 | 100 | 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"], | |
| 101 | 102 | ]); |
| 102 | 103 | 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"], | |
| 104 | 105 | ]); |
| 105 | 106 | expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown"); |
| 106 | 107 | 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"); | |
| 107 | 109 | expect(JSON.stringify(forgeLines([row({})]))).not.toContain("http"); |
| 108 | 110 | expect(runningWords(row({ running: { total: 0, running: 0, notRunning: [] } }))).toBeNull(); |
| 109 | 111 | }); |
new above
offline
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.