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

/forges: an owner's forges side by side, with size, use against size, apps and last heartbeat #6579

Merged opened this

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

Merged

scout/forge:scout/2933-forges-an-owner-s-forges-side-by-side-wi → main 4 commits fork

  • Merged. scout/2933-forges-an-owner-s-forges-side-by-side-wi was merged into main as 2e6f276.

Review

Sign in to review this change.
11 files changed +307 −6
README.md +1 −1
@@ -21,7 +21,7 @@ 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.
24 The list leads with the forges that need you, each badged
24 `/forges` opens with the forges side by side (size, memory/CPU/disk against it or "shared", apps, last heartbeat; `docs/features/forges-side-by-side.md`). The list leads with the forges that need you, each badged
2525 with why (also in the terminal and the MCP `forges`): "Not reporting" (no word
2626 in 24 hours), "App down", or "Unclaimed" three days after it was sent; the rest
2727 follow by last activity.
docs/features/forges-side-by-side.md added +7
@@ -0,0 +1,7 @@
1 # /forges side by side (#2933)
2
3 `/forges` opens with a table of every forge this one sent: name, address, size, memory, CPU and disk in use against the size, included apps, and the last heartbeat received. A child in its own cell reads from the cell's watch (disk from a bounded walk of its data directory, taken in the background (the page reads the last figure; the first view says "measuring"), kept 30 s); a hosted child shares this forge's process and reads "shared"; a cell not running reads "not running". The same fields (`size`, `use`, `lastHeartbeatAt`) are in the `forges` MCP tool.
4
5 Visible to this forge's admins; anyone else gets `not_found`.
6
7 The table is for comparing; the list below it stays for what needs doing (claim state, a fresh claim link, spend, next step). The terminal's "Forges you sent" card ends each line with the same size, use and heartbeat words. A cell that is not running reads "not running" under each use column; its limits are in the Size column. The disk reading is cached for 30 s per forge and forgotten after five minutes unasked.
src/apps/contain.ts +15
@@ -723,6 +723,20 @@ export interface CellCounter {
723723 readonly cpuUsec: number;
724724 /** Resident memory of every process in the tree, bytes (`VmRSS`). */
725725 readonly memoryBytes: number;
726 /** Cores used over the watch's CPU window; null until the window has filled. */
727 readonly cores?: number | null;
728 }
729
730 /** Cores used since the oldest sample; null until the samples span half the CPU window. */
731 export function coresOf(
732 samples: readonly { at: number; ticks: number }[],
733 last: { ticks: number } | undefined,
734 now: number,
735 ): number | null {
736 const first = samples[0];
737 if (first === undefined || last === undefined) return null;
738 const span = (now - first.at) / 1000;
739 return span >= WATCH.cpuWindowMs / 1000 / 2 ? Math.max(0, (last.ticks - first.ticks) / CLK_TCK / span) : null;
726740 }
727741
728742 /** Every labelled cell the watch has read at least once. */
@@ -735,6 +749,7 @@ export function cellCounters(): CellCounter[] {
735749 label: e.label,
736750 cpuUsec: (e.last.ticks * 1_000_000) / CLK_TCK,
737751 memoryBytes: e.last.kib * 1024,
752 cores: coresOf(e.samples, e.last, Date.now()),
738753 });
739754 }
740755 return out;
src/spawn/clients.ts +12 −1
@@ -10,13 +10,15 @@
1010 */
1111
1212 import type { App } from "../app.ts";
13 import { listChildren, statusTokenOf, type Child } from "../actions/spawn.ts";
13 import { childDirOf, listChildren, statusTokenOf, type Child } from "../actions/spawn.ts";
1414 import { timingSafeEqual } from "node:crypto";
1515 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";
1919 import { lastHeard, type Beat } from "./heartbeat.ts";
20 import type { Size } from "./sizes.ts";
21 import { forgeUse, type ForgeUse } from "./usage.ts";
2022
2123 /** What a spawned forge says about itself to anyone: nothing more. */
2224 export interface ForgeState {
@@ -43,6 +45,12 @@ export interface ClientRow {
4345 readonly spendTodayUsd: number | null;
4446 /** The child's forge cap in dollars; null when none or unsaid. */
4547 readonly ceilingUsd: number | null;
48 /** Its size (`spawn/sizes.ts`). */
49 readonly size: Size;
50 /** What it uses against that size: its cell's reading, or "shared" when it is hosted here. */
51 readonly use: ForgeUse;
52 /** When its last heartbeat reached this forge, ISO; null when none has. */
53 readonly lastHeartbeatAt: string | null;
4654 /** The first step its person has not taken, read from the child's heartbeat. */
4755 readonly nextStep: NextStep;
4856 }
@@ -196,6 +204,9 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
196204 running: hostedChildApps(app, c.name) ?? null,
197205 spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null,
198206 ceilingUsd: beats[i]?.beat.ceilingUsd ?? null,
207 size: c.size,
208 use: forgeUse(c, childDirOf(app, c.bodyId)),
209 lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(),
199210 nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat),
200211 })),
201212 now,
src/spawn/usage.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * What a child forge uses against its size (#2933). A child in a cell of
3 * its own is read off the cell's watch (`apps/contain.ts`) and its data
4 * directory; a hosted child shares this forge's process, so it has no
5 * figures of its own and reads "shared". Plain data in, plain data out.
6 */
7
8 import { diskBytes, cellCounters } from "../apps/contain.ts";
9 import type { Child } from "../actions/spawn.ts";
10 import { SIZES, type Size, type SizeLimits } from "./sizes.ts";
11
12 export type ForgeUse =
13 | { readonly kind: "shared" }
14 /** In a cell that is not running now (it starts on the next request). */
15 | { readonly kind: "idle"; readonly limits: SizeLimits }
16 | {
17 readonly kind: "cell";
18 readonly limits: SizeLimits;
19 readonly memoryMiB: number;
20 /** Cores used lately; null until the watch has enough to say. */
21 readonly cores: number | null;
22 /** Null until the first disk reading lands (it is taken off the request) or when the directory is unknown. */
23 readonly diskMiB: number | null;
24 };
25
26 /** The disk walk is bounded, but not free: a reading is kept this long, and refreshed off the request. */
27 const DISK_FRESH_MS = 30_000;
28 const disks = new Map<string, { at: number; bytes: number }>();
29 const walking = new Set<string>();
30
31 /**
32 * The last reading of a directory, or undefined before the first. A stale or
33 * missing one is refreshed after the caller has answered (a timer, never in
34 * the request), so a page reads memory and costs no walk.
35 */
36 function diskOf(dir: string, now: number): number | undefined {
37 // Forget readings nobody has asked for lately (a deleted forge's), so the map stays as big as the list.
38 for (const [d, r] of disks) if (now - r.at > DISK_FRESH_MS * 10) disks.delete(d);
39 const kept = disks.get(dir);
40 if ((kept === undefined || now - kept.at >= DISK_FRESH_MS) && !walking.has(dir)) {
41 walking.add(dir);
42 setTimeout(() => {
43 try {
44 disks.set(dir, { at: Date.now(), bytes: diskBytes([dir]) });
45 } finally {
46 walking.delete(dir);
47 }
48 }, 0).unref();
49 }
50 return kept?.bytes;
51 }
52
53 /** One child's use; `dir` is where its data lives, `now` the clock. */
54 export function forgeUse(child: Child, dir?: string, now = Date.now()): ForgeUse {
55 if (child.placement !== "cell") return { kind: "shared" };
56 const limits = SIZES[child.size];
57 const counter = cellCounters().find((c) => c.label === `forge:${child.name}`);
58 if (counter === undefined) return { kind: "idle", limits };
59 const disk = dir === undefined ? undefined : diskOf(dir, now);
60 return {
61 kind: "cell",
62 limits,
63 memoryMiB: Math.round(counter.memoryBytes / 1024 / 1024),
64 cores: counter.cores == null ? null : Math.round(counter.cores * 100) / 100,
65 diskMiB: disk === undefined ? null : Math.round(disk / 1024 / 1024),
66 };
67 }
68
69 /** "512 MiB", "2 GiB": a size's figure the way a person says it. */
70 export function mibWords(mib: number): string {
71 return mib >= 1024 ? `${Math.round((mib / 1024) * 10) / 10} GiB` : `${mib} MiB`;
72 }
73
74 /** The cell's reading as three short phrases (memory, CPU, disk); one "shared" for a hosted child. */
75 export function useWords(u: ForgeUse): { memory: string; cpu: string; disk: string } {
76 if (u.kind === "shared") return { memory: "shared", cpu: "shared", disk: "shared" };
77 const cores = (n: number) => `${n} ${n === 1 ? "core" : "cores"}`;
78 if (u.kind === "idle")
79 return { memory: "not running", cpu: "not running", disk: "not running" };
80 return {
81 memory: `${mibWords(u.memoryMiB)} of ${mibWords(u.limits.memoryMiB)}`,
82 cpu: u.cores === null ? `of ${cores(u.limits.cpu)}` : `${u.cores} of ${cores(u.limits.cpu)}`,
83 disk: u.diskMiB === null ? `measuring · ${mibWords(u.limits.diskMiB)}` : `${mibWords(u.diskMiB)} of ${mibWords(u.limits.diskMiB)}`,
84 };
85 }
86
87 /** A size with what it holds: "medium · 2 GiB, 2 cores, 10 GiB". */
88 export function sizeWords(size: Size): string {
89 const l = SIZES[size];
90 return `${size} · ${mibWords(l.memoryMiB)}, ${l.cpu} ${l.cpu === 1 ? "core" : "cores"}, ${mibWords(l.diskMiB)}`;
91 }
92
93 /** One line for the terminal: size, then use ("shared", or memory, CPU and disk). */
94 export function useLine(size: Size, u: ForgeUse): string {
95 const w = useWords(u);
96 return u.kind === "shared" ? `${size} · shared` : `${sizeWords(size)} · memory ${w.memory} · cpu ${w.cpu} · disk ${w.disk}`;
97 }
src/tui/screens/forges.ts +3 −1
@@ -7,6 +7,8 @@
77 import type { Ctx, Screen } from "../app/app.ts";
88 import { binding } from "../app/keys.ts";
99 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
10 import { useLine } from "../../spawn/usage.ts";
11 import { timeAgo } from "../../web/ui.ts";
1012 import { card } from "../ui/widgets.ts";
1113 import { drawFacts } from "./common.ts";
1214
@@ -15,7 +17,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
1517 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
1618 return rows.map((r) => [
1719 r.name,
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]
20 [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, useLine(r.size, r.use), r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
1921 .filter((m): m is string => m !== null)
2022 .join(" · "),
2123 ]);
src/web/pages/forges.ts +39
@@ -16,6 +16,7 @@ import { reissueDoor } from "../../spawn/door.ts";
1616 import { readForm } from "../form.ts";
1717 import { badge, btn, card, empty, row, time } from "../ui.ts";
1818 import { noRoom } from "../../spawn/door.ts";
19 import { sizeWords, useWords } from "../../spawn/usage.ts";
1920 import { claimLinkBody } from "./new-forge.ts";
2021
2122
@@ -69,6 +70,41 @@ function forgeRow(r: ClientRow): Html {
6970 });
7071 }
7172
73 /** The forges side by side: one row each, the same columns, so none shifts. */
74 export function sideBySide(rows: readonly ClientRow[]): Html {
75 return html`<div class="table-scroll" role="region" tabindex="0" aria-label="Forges side by side">
76 <table class="table" data-dense data-sticky-lead data-by="forges">
77 <thead>
78 <tr>
79 <th scope="col">Forge</th>
80 <th scope="col">Address</th>
81 <th scope="col">Size</th>
82 <th scope="col">Memory</th>
83 <th scope="col">CPU</th>
84 <th scope="col">Disk</th>
85 <th scope="col">Apps</th>
86 <th scope="col">Last heartbeat</th>
87 </tr>
88 </thead>
89 <tbody>
90 ${rows.map((r) => {
91 const use = useWords(r.use);
92 return html`<tr data-compare="${r.name}" data-use="${r.use.kind}">
93 <th scope="row">${r.name}</th>
94 <td><a href="${r.url}">${r.url.replace(/^https?:\/\//, "")}</a></td>
95 <td>${sizeWords(r.size)}</td>
96 <td>${use.memory}</td>
97 <td>${use.cpu}</td>
98 <td>${use.disk}</td>
99 <td>${r.apps.length === 0 ? "none" : r.apps.join(", ")}</td>
100 <td>${r.lastHeartbeatAt === null ? "none yet" : time(r.lastHeartbeatAt)}</td>
101 </tr>`;
102 })}
103 </tbody>
104 </table>
105 </div>`;
106 }
107
72108 // GET /forges — an admin's only.
73109 export async function forgesPage(ctx: Ctx): Promise<Response> {
74110 const user = ctx.auth.user;
@@ -97,6 +133,9 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
97133 note: "A whole forge for someone: the apps you choose, a crew, and one link to send.",
98134 action,
99135 })
136 : card(sideBySide(rows), { pad: false, id: "forges-compare" })}
137 ${rows.length === 0
138 ? null
100139 : card(
101140 html`<ul class="list setting-list">
102141 ${rows.map(forgeRow)}
test/client-list.test.ts +13
@@ -220,6 +220,19 @@ describe("the client list", () => {
220220 expect(html).not.toContain(claimUrl.split("#")[1]!);
221221 });
222222
223 test("the admin's page carries the forges side by side: columns, shared use, no heartbeat yet", async () => {
224 const res = await list(admin);
225 expect(res.status).toBe(200);
226 const html = (await res.text()).replace(/\s+/g, " ");
227 for (const col of ["Forge", "Address", "Size", "Memory", "CPU", "Disk", "Apps", "Last heartbeat"])
228 expect(html).toContain(`<th scope="col">${col}</th>`);
229 const maria = html.slice(html.indexOf('data-compare="maria"'), html.indexOf("</tr>", html.indexOf('data-compare="maria"')));
230 expect(maria).toContain('data-use="shared"');
231 expect(maria).toContain("<td>small · 512 MiB, 1 core, 2 GiB</td>");
232 expect(maria).toContain("<td>shared</td>");
233 expect(maria).toContain("<td>none yet</td>");
234 });
235
223236 test("an unclaimed row offers a fresh link: it replaces the old one, shown once; only an admin may ask", async () => {
224237 const html = (await (await list(admin)).text()).replace(/\s+/g, " ");
225238 const maria = html.slice(html.indexOf('data-forge="maria"'), html.indexOf("</li>", html.indexOf('data-forge="maria"')));
test/forges-attention.test.ts +3
@@ -20,6 +20,9 @@ const row = (name: string, o: Partial<ClientRow> = {}): Omit<ClientRow, "attenti
2020 claimed: true,
2121 lastActivityAt: ago(1),
2222 running: null,
23 size: "small",
24 use: { kind: "shared" },
25 lastHeartbeatAt: null,
2326 nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null },
2427 spendTodayUsd: null,
2528 ceilingUsd: null,
test/forges-side-by-side.test.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * /forges side by side (#2933): size, use against size ("shared" for a
3 * hosted child), apps and last heartbeat; an admin's only.
4 */
5
6 import { describe, expect, test } from "bun:test";
7 import { forgeUse, mibWords, useWords } from "../src/spawn/usage.ts";
8 import { watchLabelled, cellCounters, coresOf, WATCH } from "../src/apps/contain.ts";
9 import type { Child } from "../src/actions/spawn.ts";
10 import { forgesPage, sideBySide } from "../src/web/pages/forges.ts";
11 import { createApp } from "../src/app.ts";
12 import { mkdtempSync } from "node:fs";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15
16 const child = (over: Partial<Child>): Child => ({
17 name: "serious", url: "https://serious.test", bodyId: "b", done: true, for: null, apps: [],
18 createdAt: null, size: "medium", placement: "hosted", placementNote: null, ...over,
19 });
20
21 describe("what a forge uses against its size", () => {
22 test("a hosted child shares this forge's process", () => {
23 const u = forgeUse(child({}));
24 expect(u).toEqual({ kind: "shared" });
25 expect(useWords(u)).toEqual({ memory: "shared", cpu: "shared", disk: "shared" });
26 });
27 test("a cell that is not running says so, with its size's figures", () => {
28 const u = forgeUse(child({ placement: "cell", name: "nobody-home" }));
29 expect(u.kind).toBe("idle");
30 expect(useWords(u).memory).toBe("not running");
31 });
32 test("a running cell reads its memory off the watch", async () => {
33 const stop = watchLabelled(process.pid, "forge:fun");
34 try {
35 const wait = Date.now() + 3000;
36 while (!cellCounters().some((c) => c.label === "forge:fun") && Date.now() < wait) await Bun.sleep(50);
37 const dir = mkdtempSync(join(tmpdir(), "forge-sbs-d-"));
38 const first = forgeUse(child({ placement: "cell", name: "fun", size: "small" }), dir);
39 // The request never walks the disk: the first reading says so, a later one has the figure.
40 expect(first.kind === "cell" && first.diskMiB).toBeNull();
41 await Bun.sleep(100);
42 const u = forgeUse(child({ placement: "cell", name: "fun", size: "small" }), dir);
43 expect(u.kind === "cell" && u.diskMiB).not.toBeNull();
44 expect(forgeUse(child({ placement: "cell", name: "fun" })).kind === "cell").toBe(true);
45 expect(u.kind).toBe("cell");
46 if (u.kind === "cell") {
47 expect(u.memoryMiB).toBeGreaterThan(0);
48 expect(useWords(u).memory).toMatch(/ of 512 MiB$/);
49 }
50 } finally {
51 stop();
52 }
53 });
54 test("figures in words", () => {
55 expect(mibWords(512)).toBe("512 MiB");
56 expect(mibWords(8192)).toBe("8 GiB");
57 });
58 });
59
60 describe("GET /forges", () => {
61 test("anyone but an admin gets not_found", async () => {
62 const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-sbs-")), url: "https://x.test", port: 0 });
63 const request = new Request("https://x.test/forges");
64 const res = await forgesPage({ app, request, auth: { user: undefined }, params: {}, url: new URL(request.url) } as never);
65 expect(res.status).toBe(404);
66 });
67 });
68
69 describe("coresOf", () => {
70 const samples = [{ at: 0, ticks: 0 }];
71 test("null before the samples span half the window, a figure after", () => {
72 expect(coresOf(samples, { ticks: 500 }, WATCH.cpuWindowMs / 2 - 1)).toBeNull();
73 expect(coresOf([], { ticks: 500 }, 99_999)).toBeNull();
74 expect(coresOf(samples, undefined, 99_999)).toBeNull();
75 // 1500 ticks at 100/s over 30 s is half a core.
76 expect(coresOf(samples, { ticks: 1500 }, 30_000)).toBeCloseTo(0.5, 5);
77 });
78 });
79
80 describe("the table", () => {
81 const base = { name: "fun", url: "https://fun.test", apps: [], lastHeartbeatAt: null } as never;
82 test("a cell that is not running says so under each use column; one running reads X of Y", () => {
83 const idle = String(sideBySide([{ ...(base as object), size: "small", use: { kind: "idle", limits: { cpu: 1, memoryMiB: 512, diskMiB: 2048 } } } as never]));
84 expect(idle).toContain('data-use="idle"');
85 expect(idle.match(/<td>not running<\/td>/g)).toHaveLength(3);
86 const live = String(sideBySide([{ ...(base as object), size: "small", lastHeartbeatAt: new Date().toISOString(), use: { kind: "cell", limits: { cpu: 1, memoryMiB: 512, diskMiB: 2048 }, memoryMiB: 310, cores: null, diskMiB: null } } as never]));
87 expect(live).toContain("<td>310 MiB of 512 MiB</td>");
88 expect(live).toContain("<td>of 1 core</td>");
89 expect(live).toContain("<td>measuring · 2 GiB</td>");
90 expect(live).toContain("just now");
91 expect(live).toContain('role="region" tabindex="0" aria-label="Forges side by side"');
92 expect(live).toContain("data-sticky-lead");
93 });
94 });
test/tui-forges.test.ts +23 −3
@@ -28,6 +28,9 @@ const row = (over: Partial<ClientRow>): ClientRow => ({
2828 attention: null,
2929 spendTodayUsd: null,
3030 ceilingUsd: null,
31 size: "small",
32 use: { kind: "shared" },
33 lastHeartbeatAt: null,
3134 nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null },
3235 ...over,
3336 });
@@ -103,19 +106,36 @@ describe("forges you sent in the terminal", () => {
103106 expect(forgeLines([row({})])[0]![1]).not.toContain("today");
104107 });
105108
109 test("a row says its size and use: shared when hosted, X of Y in a cell", () => {
110 const hosted = forgeLines([row({ size: "medium" })])[0]![1];
111 expect(hosted).toContain("medium · shared");
112 const cell = row({
113 size: "small",
114 use: { kind: "cell", limits: { cpu: 1, memoryMiB: 512, diskMiB: 2048 }, memoryMiB: 310, cores: 0.4, diskMiB: 100 },
115 lastHeartbeatAt: new Date().toISOString(),
116 });
117 const line = forgeLines([cell])[0]![1];
118 expect(line).toContain("memory 310 MiB of 512 MiB");
119 expect(line).toContain("cpu 0.4 of 1 core");
120 expect(line).toContain("disk 100 MiB of 2 GiB");
121 expect(line).toContain("heartbeat just now");
122 const idle = row({ size: "small", use: { kind: "idle", limits: { cpu: 1, memoryMiB: 512, diskMiB: 2048 } } });
123 expect(forgeLines([idle])[0]![1]).toContain("memory not running");
124 });
125
106126 test("a row says who, the claim state and the apps note; never a link", () => {
107127 const up = { total: 3, running: 3, notRunning: [] };
108128 const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] };
109129 expect(forgeLines([row({ running: up })])).toEqual([
110 ["maria", "for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet"],
130 ["maria", "for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet · small · shared · no heartbeat yet"],
111131 ]);
112132 expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([
113 ["maria", "Claimed · 1 app running · notes: build failed · has not reported yet"],
133 ["maria", "Claimed · 1 app running · notes: build failed · has not reported yet · small · shared · no heartbeat yet"],
114134 ]);
115135 expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown");
116136 expect(forgeLines([row({ made: false })])[0]![1]).toContain("Still being made");
117137 expect(forgeLines([row({ attention: "down", claimed: true, for: null })])).toEqual([
118 ["maria", "App down · Claimed · has not reported yet"],
138 ["maria", "App down · Claimed · has not reported yet · small · shared · no heartbeat yet"],
119139 ]);
120140 expect(forgeLines([row({})])[0]![1]).not.toMatch(/Not reporting|App down|Unclaimed/);
121141 expect(forgeLines([row({ nextStep: { key: "tell", words: "no tell yet", lastTellAt: null } })])[0]![1]).toContain("no tell yet");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs