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

Merged opened this

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

Merged

scout/forge:scout/5941-forges-lists-the-sent-forges-that-need-t → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/5941-forges-lists-the-sent-forges-that-need-t was merged into main as d51116f.

Review

Sign in to review this change.
7 files changed +133 −8
README.md +4
@@ -21,6 +21,10 @@ any URL — and it is the rest of this page.
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
2323 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.
outdated

This line runs past the file's wrap width; break it after 'child's own data, and /forges says how many are running and why any is not.'

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.
2428 In the terminal an admin's palette offers "Forges you sent": each forge with its
2529 claim state and the same apps-running note, never a claim link. Both carry the
2630 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 {
3737 readonly lastActivityAt: string | null;
3838 /** The apps this parent runs for the child; null when it runs none here. */
3939 readonly running: ChildAppsSummary | null;
40 /** Why this forge needs its owner now; null when it does not. */
41 readonly attention: Attention | null;
4042 /** Dollars the child's crew spent today, from its last heartbeat; null when it has not said. */
4143 readonly spendTodayUsd: number | null;
4244 /** The child's forge cap in dollars; null when none or unsaid. */
@@ -181,8 +183,8 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
181183 }),
182184 );
183185 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) => ({
186188 name: c.name,
187189 url: c.url,
188190 for: c.for,
@@ -195,9 +197,48 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
195197 spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null,
196198 ceilingUsd: beats[i]?.beat.ceilingUsd ?? null,
197199 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) }))
199238 .sort(
200239 (a, b) =>
240 tier(a.attention) - tier(b.attention) ||
241 (b.lastActivityAt ?? "").localeCompare(a.lastActivityAt ?? "") ||
201242 (b.sentAt ?? "").localeCompare(a.sentAt ?? "") ||
202243 a.name.localeCompare(b.name),
203244 );
src/tui/screens/forges.ts +3 −3
@@ -6,16 +6,16 @@
66
77 import type { Ctx, Screen } from "../app/app.ts";
88 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";
1010 import { card } from "../ui/widgets.ts";
1111 import { drawFacts } from "./common.ts";
1212
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. */
1414 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), 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]
1919 .filter((m): m is string => m !== null)
2020 .join(" · "),
2121 ]);
src/web/pages/forges.ts +4 −2
@@ -7,7 +7,7 @@
77 */
88
99 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";
1111 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1212 import { notFoundPage, pageHead } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
@@ -57,7 +57,9 @@ function forgeRow(r: ClientRow): Html {
5757 title: html`<a href="${r.url}">${r.name}</a>`,
5858 meta: html`${meta.map((m, i) => html`${i > 0 ? " · " : ""}${m}`)}`,
5959 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], {
6163 ...(state === "yes" ? { tone: "success" as const } : { variant: "outline" as const }),
6264 })}${state === "no"
6365 ? html`<form method="post" action="/forges/${r.name}/claim-link">
test/client-list.test.ts +5
@@ -208,6 +208,11 @@ describe("the client list", () => {
208208 expect(maria).toContain('class="row"');
209209 expect(maria).toContain("Send a fresh link");
210210 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");
211216 // Neither has reported yet.
212217 expect(maria).toContain("has not reported yet");
213218 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 => ({
2525 claimed: false,
2626 lastActivityAt: null,
2727 running: null,
28 attention: null,
2829 spendTodayUsd: null,
2930 ceilingUsd: null,
3031 nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null },
@@ -113,6 +114,10 @@ describe("forges you sent in the terminal", () => {
113114 ]);
114115 expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown");
115116 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/);
116121 expect(forgeLines([row({ nextStep: { key: "tell", words: "no tell yet", lastTellAt: null } })])[0]![1]).toContain("no tell yet");
117122 expect(JSON.stringify(forgeLines([row({})]))).not.toContain("http");
118123 expect(runningWords(row({ running: { total: 0, running: 0, notRunning: [] } }))).toBeNull();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs