Public: anyone can read this; the crew can change it.
/forges lists the sent forges that need the owner first: not reporting, apps down, or unclaimed after 3 days #5947
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5941-forges-lists-the-sent-forges-that-need-t → main 6 commits fork 2 threads · 2 unresolved
7 files
changed +133
−8
README.md +4
| @@ -21,6 +21,10 @@ any URL — and it is the rest of this page. | ||
| 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 | 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 | The list leads with the forges that need you, each badged | |
| 25 | with why (also in the terminal and the MCP `forges`): "Not reporting" (no word | |
| 26 | in 24 hours), "App down", or "Unclaimed" three days after it was sent; the rest | |
| 27 | follow by last activity. | |
| 24 | 28 | In the terminal an admin's palette offers "Forges you sent": each forge with its |
| 25 | 29 | claim state and the same apps-running note, never a claim link. Both carry the |
| 26 | 30 | crew's spend today against the daily ceiling that pauses it (`$3.20 of $10 today`), as the |
src/spawn/clients.ts +44 −3
| @@ -37,6 +37,8 @@ export interface ClientRow { | ||
| 37 | 37 | readonly lastActivityAt: string | null; |
| 38 | 38 | /** The apps this parent runs for the child; null when it runs none here. */ |
| 39 | 39 | readonly running: ChildAppsSummary | null; |
| 40 | /** Why this forge needs its owner now; null when it does not. */ | |
| 41 | readonly attention: Attention | null; | |
| 40 | 42 | /** Dollars the child's crew spent today, from its last heartbeat; null when it has not said. */ |
| 41 | 43 | readonly spendTodayUsd: number | null; |
| 42 | 44 | /** The child's forge cap in dollars; null when none or unsaid. */ |
| @@ -181,8 +183,8 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 181 | 183 | }), |
| 182 | 184 | ); |
| 183 | 185 | const beats = await Promise.all(children.map((c) => lastHeard(app, c.name))); |
| 184 | return children | |
| 185 | .map((c, i) => ({ | |
| 186 | return orderRows( | |
| 187 | children.map((c, i) => ({ | |
| 186 | 188 | name: c.name, |
| 187 | 189 | url: c.url, |
| 188 | 190 | for: c.for, |
| @@ -195,9 +197,48 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 195 | 197 | spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null, |
| 196 | 198 | ceilingUsd: beats[i]?.beat.ceilingUsd ?? null, |
| 197 | 199 | nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat), |
| 198 | })) | |
| 200 | })), | |
| 201 | now, | |
| 202 | ); | |
| 203 | } | |
| 204 | ||
| 205 | /** Why a row needs its owner, in the order that matters; null when it does not. */ | |
| 206 | export type Attention = "silent" | "down" | "unclaimed"; | |
| 207 | ||
| 208 | export const ATTENTION_WORDS: Record<Attention, string> = { | |
outdated
The cast hides the type: make attentionOf take Omit<ClientRow, "attention"> and the cast goes. |
||
| 209 | silent: "Not reporting", | |
| 210 | down: "App down", | |
| 211 | unclaimed: "Unclaimed", | |
| 212 | }; | |
| 213 | ||
| 214 | const ATTENTION_ORDER: readonly Attention[] = ["silent", "down", "unclaimed"]; | |
| 215 | const SILENT_MS = 24 * 3_600_000; | |
| 216 | const UNCLAIMED_MS = 3 * 86_400_000; | |
| 217 | ||
| 218 | /** | |
| 219 | * The first reason this row needs the owner: no word from it in 24 h (it | |
| 220 | * did not answer, or its last activity is older), an app not running, or | |
| 221 | * unclaimed three days after it was sent. A forge still being made waits. | |
| 222 | */ | |
| 223 | export function attentionOf(r: ClientRow, now: number): Attention | null { | |
| 224 | if (!r.made) return null; | |
| 225 | const last = r.lastActivityAt === null ? NaN : Date.parse(r.lastActivityAt); | |
| 226 | if (r.claimed === null || (!Number.isNaN(last) && now - last > SILENT_MS)) return "silent"; | |
| 227 | if (r.running !== null && r.running.notRunning.length > 0) return "down"; | |
| 228 | const sent = r.sentAt === null ? NaN : Date.parse(r.sentAt); | |
| 229 | if (r.claimed === false && !Number.isNaN(sent) && now - sent > UNCLAIMED_MS) return "unclaimed"; | |
| 230 | return null; | |
| 231 | } | |
| 232 | ||
| 233 | /** Rows that need the owner first, by reason; then by last activity, newest first. */ | |
| 234 | export function orderRows(rows: readonly Omit<ClientRow, "attention">[], now: number): ClientRow[] { | |
| 235 | const tier = (a: Attention | null) => (a === null ? ATTENTION_ORDER.length : ATTENTION_ORDER.indexOf(a)); | |
| 236 | return rows | |
| 237 | .map((r) => ({ ...r, attention: attentionOf(r as ClientRow, now) })) | |
| 199 | 238 | .sort( |
| 200 | 239 | (a, b) => |
| 240 | tier(a.attention) - tier(b.attention) || | |
| 241 | (b.lastActivityAt ?? "").localeCompare(a.lastActivityAt ?? "") || | |
| 201 | 242 | (b.sentAt ?? "").localeCompare(a.sentAt ?? "") || |
| 202 | 243 | a.name.localeCompare(b.name), |
| 203 | 244 | ); |
src/tui/screens/forges.ts +3 −3
| @@ -6,16 +6,16 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Ctx, Screen } from "../app/app.ts"; |
| 8 | 8 | import { binding } from "../app/keys.ts"; |
| 9 | import { CLAIM_WORDS, claimState, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 9 | import { ATTENTION_WORDS, CLAIM_WORDS, claimState, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | 10 | import { card } from "../ui/widgets.ts"; |
| 11 | 11 | import { drawFacts } from "./common.ts"; |
| 12 | 12 | |
| 13 | /** [name, "for Maria · Claimed · 3 apps running"] per row; an empty line when none. */ | |
| 13 | /** [name, "Not reporting · for Maria · Claimed · 3 apps running"] per row; an empty line when none. */ | |
| 14 | 14 | 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), r.nextStep.words, spendWords(r)?.words ?? null] | |
| 18 | [r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null] | |
| 19 | 19 | .filter((m): m is string => m !== null) |
| 20 | 20 | .join(" · "), |
| 21 | 21 | ]); |
src/web/pages/forges.ts +4 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | import { CLAIM_WORDS, claimState, clientRows, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | import { ATTENTION_WORDS, CLAIM_WORDS, claimState, clientRows, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 12 | 12 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| @@ -57,7 +57,9 @@ function forgeRow(r: ClientRow): Html { | ||
| 57 | 57 | title: html`<a href="${r.url}">${r.name}</a>`, |
| 58 | 58 | meta: html`${meta.map((m, i) => html`${i > 0 ? " · " : ""}${m}`)}`, |
| 59 | 59 | data: { forge: r.name, claimed: state }, |
| 60 | trail: html`${badge(CLAIM_WORDS[state], { | |
| 60 | trail: html`${r.attention === null | |
| 61 | ? null | |
| 62 | : html`<span data-attention="${r.attention}">${badge(ATTENTION_WORDS[r.attention], { tone: "attn" })}</span>`}${badge(CLAIM_WORDS[state], { | |
| 61 | 63 | ...(state === "yes" ? { tone: "success" as const } : { variant: "outline" as const }), |
| 62 | 64 | })}${state === "no" |
| 63 | 65 | ? html`<form method="post" action="/forges/${r.name}/claim-link"> |
test/client-list.test.ts +5
| @@ -208,6 +208,11 @@ describe("the client list", () => { | ||
| 208 | 208 | expect(maria).toContain('class="row"'); |
| 209 | 209 | expect(maria).toContain("Send a fresh link"); |
| 210 | 210 | expect(lou).toContain("it did not answer just now"); |
| 211 | // It did not answer, so it carries the one attention badge and leads the list. | |
| 212 | expect(lou).toContain('data-attention="silent"'); | |
| 213 | expect(lou).toContain("Not reporting"); | |
| 214 | expect(html.indexOf('data-forge="lou"')).toBeLessThan(html.indexOf('data-forge="maria"')); | |
| 215 | expect(maria).not.toContain("data-attention"); | |
| 211 | 216 | // Neither has reported yet. |
| 212 | 217 | expect(maria).toContain("has not reported yet"); |
| 213 | 218 | expect(lou).toContain("has not reported yet"); |
test/forges-attention.test.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * #5941: /forges and the MCP `forges` open on what needs the owner: no word | |
| 3 | * in 24 h, an app not running, unclaimed after 3 days, then the rest by last | |
| 4 | * activity. One order, made in spawn/clients.ts. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { ATTENTION_WORDS, orderRows, type ClientRow } from "../src/spawn/clients.ts"; | |
| 9 | ||
| 10 | const NOW = Date.parse("2026-10-25T12:00:00Z"); | |
| 11 | const ago = (h: number) => new Date(NOW - h * 3_600_000).toISOString(); | |
| 12 | ||
| 13 | const row = (name: string, o: Partial<ClientRow> = {}): Omit<ClientRow, "attention"> => ({ | |
| 14 | name, | |
| 15 | url: `https://${name}.test`, | |
| 16 | for: null, | |
| 17 | apps: [], | |
| 18 | sentAt: ago(1), | |
| 19 | made: true, | |
| 20 | claimed: true, | |
| 21 | lastActivityAt: ago(1), | |
| 22 | running: null, | |
| 23 | nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null }, | |
| 24 | spendTodayUsd: null, | |
| 25 | ceilingUsd: null, | |
| 26 | ...o, | |
| 27 | }); | |
| 28 | ||
| 29 | test("four children in the wrong order come back by need, each with its reason", () => { | |
| 30 | const rows = [ | |
| 31 | row("fine", { lastActivityAt: ago(2) }), | |
| 32 | row("old-unclaimed", { claimed: false, sentAt: ago(24 * 4), lastActivityAt: ago(3) }), | |
| 33 | row("down", { | |
| 34 | lastActivityAt: ago(4), | |
| 35 | running: { total: 2, running: 1, notRunning: [{ slug: "shop", why: "crashed" }] }, | |
| 36 | }), | |
| 37 | row("quiet", { lastActivityAt: ago(30) }), | |
| 38 | ]; | |
| 39 | const out = orderRows(rows, NOW); | |
| 40 | expect(out.map((r) => [r.name, r.attention])).toEqual([ | |
| 41 | ["quiet", "silent"], | |
| 42 | ["down", "down"], | |
| 43 | ["old-unclaimed", "unclaimed"], | |
| 44 | ["fine", null], | |
| 45 | ]); | |
| 46 | expect(ATTENTION_WORDS.silent).toBe("Not reporting"); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a child that did not answer is silent; a new unclaimed one and one still being made are not flagged", () => { | |
| 50 | const out = orderRows( | |
| 51 | [ | |
| 52 | row("new", { claimed: false, sentAt: ago(24 * 2) }), | |
| 53 | row("making", { made: false, claimed: null }), | |
| 54 | row("lost", { claimed: null, lastActivityAt: null }), | |
| 55 | ], | |
| 56 | NOW, | |
| 57 | ); | |
| 58 | expect(out.map((r) => [r.name, r.attention])).toEqual([ | |
| 59 | ["lost", "silent"], | |
| 60 | ["making", null], | |
| 61 | ["new", null], | |
| 62 | ]); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the rest are ordered by last activity, newest first", () => { | |
| 66 | const out = orderRows([row("a", { lastActivityAt: ago(5) }), row("b", { lastActivityAt: ago(1) })], NOW); | |
| 67 | expect(out.map((r) => r.name)).toEqual(["b", "a"]); | |
| 68 | }); |
test/tui-forges.test.ts +5
| @@ -25,6 +25,7 @@ const row = (over: Partial<ClientRow>): ClientRow => ({ | ||
| 25 | 25 | claimed: false, |
| 26 | 26 | lastActivityAt: null, |
| 27 | 27 | running: null, |
| 28 | attention: null, | |
| 28 | 29 | spendTodayUsd: null, |
| 29 | 30 | ceilingUsd: null, |
| 30 | 31 | nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null }, |
| @@ -113,6 +114,10 @@ describe("forges you sent in the terminal", () => { | ||
| 113 | 114 | ]); |
| 114 | 115 | expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown"); |
| 115 | 116 | expect(forgeLines([row({ made: false })])[0]![1]).toContain("Still being made"); |
| 117 | expect(forgeLines([row({ attention: "down", claimed: true, for: null })])).toEqual([ | |
| 118 | ["maria", "App down · Claimed · has not reported yet"], | |
| 119 | ]); | |
| 120 | expect(forgeLines([row({})])[0]![1]).not.toMatch(/Not reporting|App down|Unclaimed/); | |
| 116 | 121 | expect(forgeLines([row({ nextStep: { key: "tell", words: "no tell yet", lastTellAt: null } })])[0]![1]).toContain("no tell yet"); |
| 117 | 122 | expect(JSON.stringify(forgeLines([row({})]))).not.toContain("http"); |
| 118 | 123 | expect(runningWords(row({ running: { total: 0, running: 0, notRunning: [] } }))).toBeNull(); |
new above
offline
This line runs past the file's wrap width; break it after 'child's own data, and
/forgessays how many are running and why any is not.'