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

A parent forge's /family shows how each child follows it: mode, commits behind, and an open conflict #2647

Merged opened this

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

Merged

scout/forge:scout/2643-a-parent-forge-s-family-shows-how-each-c → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/2643-a-parent-forge-s-family-shows-how-each-c was merged into main as a6a8d63.

Review

Sign in to review this change.
5 files changed +141 −8
docs/SPAWN.md +6 −2
@@ -99,8 +99,12 @@ Every door reads and cuts the chain the same way: the page
9999 MCP `following` and `sever` tools, and the terminal's **Chain** command.
100100 Sever's one implementation is `severForge` in `src/actions/follow.ts`.
101101
102 A child's heartbeat adds `forgeTip` and `severedAt` when it carries a fork;
103 `/family` on the parent shows `code at <tip>` or `follows nothing since …`.
102 A child's heartbeat adds `forgeTip`, `severedAt`, `mode`, `upstreamBehind`
103 and `conflict` when it carries a fork; `/family` on the parent shows
104 `code at <tip>` or `follows nothing since …`, and a line such as `takes
105 updates · 3 commits behind · conflict open` (`tells its owner` for
106 `tell-me`, `up to date` at zero behind). `conflict` is the state at the
107 child's last hourly check, not a live look at whether the issue is still open.
104108
105109 **Sever** (`POST /:owner/forge/following/sever`, owner only, same-origin,
106110 type `sever`) writes `severedAt` into `forgeCode` and nothing else: the
src/spawn/family.ts +12
@@ -33,6 +33,16 @@ export function appsLine(s: ChildAppsSummary | undefined | null): string | null
3333 return `${head} — ${s.notRunning.map((n) => `${n.slug} ${reason(n.why)}`).join("; ")}`;
3434 }
3535
36 /** "takes updates · 3 commits behind · conflict open", or null when the child said nothing of it. */

Docs: describe this line in docs/SPAWN.md next to the forgeTip/severedAt paragraph.

37 export function followLine(c: ChildStanding): string | null {
38 const bits: string[] = [];
39 if (c.mode !== null) bits.push(c.mode === "take-updates" ? "takes updates" : "tells its owner");
40 if (c.upstreamBehind !== null)
41 bits.push(c.upstreamBehind === 0 ? "up to date" : `${c.upstreamBehind} ${c.upstreamBehind === 1 ? "commit" : "commits"} behind`);
42 if (c.conflict) bits.push("conflict open");
43 return bits.length === 0 ? null : bits.join(" · ");
44 }
45
3646 export interface FamilyView {
3747 readonly parent: { readonly url: string; readonly host: string; readonly behind: number | null } | null;
3848 readonly here: { readonly host: string | null; readonly version: string };
@@ -82,6 +92,8 @@ export function familyLines(view: FamilyView): [string, string][] {
8292 c.openProposals === null ? "" : `${c.openProposals} open`,
8393 ].filter((b) => b !== "");
8494 out.push([`child ${c.name}`, bits.join(" · ")]);
95 const follow = followLine(c);
96 if (follow !== null) out.push(["", follow]);

This row is the terminal's copy of the feature and no test covers it. Assert it in the new heartbeat test, for admin and guest.

8597 if (c.apps !== null) out.push(["", c.apps]);
8698 }
8799 if (view.more > 0) out.push(["", `${view.more} more not shown`]);
src/spawn/heartbeat.ts +41 −4
@@ -19,7 +19,8 @@ import { join } from "node:path";
1919 import type { App } from "../app.ts";
2020 import { beatKeyOf, listChildren } from "../actions/spawn.ts";
2121 import releases from "../core/releases.json" with { type: "json" };
22 import { forgeLinkOf, forgeTipOf } from "./chain.ts";
22 import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts";
23 import { upstreamState } from "./upstream.ts";
2324 import { running } from "../kernel/version.ts";
2425
2526 export const SIGNATURE_HEADER = "x-forge-signature";
@@ -46,8 +47,16 @@ export interface Beat {
4647 readonly forgeTip?: string | null;
4748 /** When the child cut its upstream link; null while it follows. */
4849 readonly severedAt?: string | null;
50 /** How the child follows its parent's code (`parent.json`); absent from an older child. */
51 readonly mode?: FollowMode;
52 /** Commits behind the parent's code (`upstream.json`); null when unknown, absent from an older child. */
53 readonly upstreamBehind?: number | null;
54 /** True while a converge issue is open on the child; absent from an older child. */
55 readonly conflict?: boolean;
4956 }
5057
58 const OPTIONAL = new Set(["forgeTip", "severedAt", "mode", "upstreamBehind", "conflict"]);
59
5160 export function sign(key: string, body: string): string {
5261 return createHmac("sha256", key).update(body).digest("hex");
5362 }
@@ -72,17 +81,20 @@ export function parseBeat(raw: string): Beat | undefined {
7281 if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return undefined;
7382 const d = doc as Record<string, unknown>;
7483 const keys = Object.keys(d)
75 .filter((k) => k !== "forgeTip" && k !== "severedAt")
84 .filter((k) => !OPTIONAL.has(k))
7685 .sort()
7786 .join(",");
7887 if (keys !== "at,children,name,openProposals,version") return undefined;
79 const { name, at, version, openProposals, children, forgeTip, severedAt } = d;
88 const { name, at, version, openProposals, children, forgeTip, severedAt, mode, upstreamBehind, conflict } = d;
8089 const tipOk = forgeTip === undefined || forgeTip === null || (typeof forgeTip === "string" && /^[0-9a-f]{40,64}$/.test(forgeTip));
8190 const sevOk =
8291 severedAt === undefined ||
8392 severedAt === null ||
8493 (typeof severedAt === "string" && severedAt.length <= 40 && !Number.isNaN(Date.parse(severedAt)));
8594 if (!tipOk || !sevOk) return undefined;
95 if (mode !== undefined && !(typeof mode === "string" && (FOLLOW_MODES as readonly string[]).includes(mode))) return undefined;
96 if (upstreamBehind !== undefined && upstreamBehind !== null && !count(upstreamBehind)) return undefined;
97 if (conflict !== undefined && typeof conflict !== "boolean") return undefined;
8698 if (typeof name !== "string" || !NAME.test(name)) return undefined;
8799 if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return undefined;
88100 if (typeof version !== "string" || !VERSION.test(version)) return undefined;
@@ -95,6 +107,9 @@ export function parseBeat(raw: string): Beat | undefined {
95107 children,
96108 ...(forgeTip === undefined ? {} : { forgeTip: forgeTip as string | null }),
97109 ...(severedAt === undefined ? {} : { severedAt: severedAt as string | null }),
110 ...(mode === undefined ? {} : { mode: mode as FollowMode }),
111 ...(upstreamBehind === undefined ? {} : { upstreamBehind: upstreamBehind as number | null }),
112 ...(conflict === undefined ? {} : { conflict }),
98113 };
99114 }
100115
@@ -199,6 +214,19 @@ export function standing(app: App, name: string, at = Date.now()): Beat {
199214 };
200215 }
201216
217 /** What a fork says of how it follows: its tip, whether it cut the link, and how far behind it is. */
218 async function forkReport(app: App, forge: ForgeLink) {

Untested send path: assert via sendBeat with a fake fetcher that this report reaches the body, including the COUNT_MAX clamp and conflict only when taken.issue is non-null.

219 const up = await upstreamState(app);
220 const behind = up?.behind ?? null;
221 return {
222 forgeTip: await forgeTipOf(app),
223 severedAt: forge.severedAt ?? null,
224 mode: forge.mode,
225 upstreamBehind: behind === null ? null : Math.min(behind, COUNT_MAX),
226 conflict: up?.taken?.kind === "conflict" && up.taken.issue !== null,
227 };
228 }
229
202230 type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
203231 let lastAt = 0;
204232
@@ -220,7 +248,7 @@ export async function sendBeat(
220248 // Only a forge that carries a fork of its parent's code has these to say.
221249 ...(forge === undefined
222250 ? {}
223 : { forgeTip: await forgeTipOf(app), severedAt: forge.severedAt ?? null }),
251 : await forkReport(app, forge)),
224252 });
225253 try {
226254 const res = await fetcher(`${link.upstream}/api/v1/heartbeat`, {
@@ -259,6 +287,12 @@ export interface ChildStanding {
259287 readonly forgeTip: string | null;
260288 /** When the child cut its link to this forge's code; null while it follows or when unheard. */
261289 readonly severedAt: string | null;
290 /** How the child follows this forge's code; null when it did not say. */
291 readonly mode: FollowMode | null;
292 /** Commits the child's code is behind this forge's; null when unknown or unsaid. */
293 readonly upstreamBehind: number | null;
294 /** A converge issue is open on the child; false when none or unsaid. */
295 readonly conflict: boolean;
262296 }
263297
264298 /** How many releases of this forge are newer than `version`; null when it is not one of them. */
@@ -287,6 +321,9 @@ export async function childStandings(
287321 ownChildren: heard?.beat.children ?? null,
288322 forgeTip: heard?.beat.forgeTip ?? null,
289323 severedAt: heard?.beat.severedAt ?? null,
324 mode: heard?.beat.mode ?? null,
325 upstreamBehind: heard?.beat.upstreamBehind ?? null,
326 conflict: heard?.beat.conflict ?? false,
290327 });
291328 }
292329 return { items, more: Math.max(0, all.length - MAX_CHILDREN) };
src/web/pages/family.ts +3 −1
@@ -9,7 +9,7 @@
99
1010 import type { Ctx } from "../../http/context.ts";
1111 import type { ChildAppsSummary } from "../../spawn/child-apps.ts";
12 import { familyView, type FamilyChild } from "../../spawn/family.ts";
12 import { familyView, followLine, type FamilyChild } from "../../spawn/family.ts";
1313 import { hostedChildApps } from "../../spawn/host.ts";
1414 import { MAX_DEPTH } from "../../spawn/heartbeat.ts";
1515 import { pageHead } from "../chrome.ts";
@@ -45,6 +45,8 @@ function standingNode(c: FamilyChild): Node {
4545 if (c.ownChildren !== null && c.ownChildren > 0)
4646 meta.push(html`${plural(c.ownChildren, "forge")} of its own`);
4747 }
48 const follow = followLine(c);
49 if (follow !== null) meta.push(html`<span data-follow>${follow}</span>`);
4850 if (c.apps !== null) meta.push(html`<span data-apps>${c.apps}</span>`);
4951 return {
5052 label: c.name,
test/family-heartbeat.test.ts +79 −1
@@ -5,7 +5,7 @@
55 */
66
77 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, readFile, rm } from "node:fs/promises";
8 import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createRepo } from "../src/actions/repos.ts";
@@ -328,4 +328,82 @@ describe("family heartbeats", () => {
328328 expect(after).toContain("follows nothing since 2030-02-03");
329329 expect(after).not.toContain("code at");
330330 });
331
332 test("a beat may say how the child follows: mode, commits behind, conflict", async () => {
333 const good = (o: Record<string, unknown>) => parseBeat(beat(o));
334 expect(good({})?.mode).toBeUndefined();
335 expect(good({ mode: "take-updates", upstreamBehind: 3, conflict: true })).toMatchObject({
336 mode: "take-updates",
337 upstreamBehind: 3,
338 conflict: true,
339 });
340 expect(good({ mode: "tell-me", upstreamBehind: null, conflict: false })?.upstreamBehind).toBeNull();
341 for (const bad of [
342 { upstreamBehind: -1 },
343 { upstreamBehind: 1.5 },
344 { upstreamBehind: "3" },
345 { mode: "yolo" },
346 { mode: 1 },
347 { conflict: "yes" },
348 ])
349 expect(good(bad)).toBeUndefined();
350
351 const send = async (o: Record<string, unknown>) => {
352 const body = beat({ at: Date.now() + 5000, ...o });
353 expect((await post(body, sign(key, body))).status).toBeLessThan(300);
354 };
355 await send({ mode: "take-updates", upstreamBehind: 3, conflict: true });
356 const text = (await family(adminCookie)).replace(/\s+/g, " ");
357 expect(text).toContain("takes updates · 3 commits behind · conflict open");
358 const api = (await (await fetch(`${servers.url}/api/v1/family`, { headers: { cookie: adminCookie } })).json()) as {
359 children: { mode: string; upstreamBehind: number; conflict: boolean }[];
360 };
361 expect(api.children[0]).toMatchObject({ mode: "take-updates", upstreamBehind: 3, conflict: true });
362
363 // The terminal's copy: the same line for an admin, nothing for a guest.
364 const word = "takes updates · 3 commits behind · conflict open";
365 expect(familyLines(await familyView(parent, true)).some(([k, v]) => k === "" && v === word)).toBe(true);
366 expect(JSON.stringify(familyLines(await familyView(parent, false)))).not.toContain("commits behind");
367 });
368 test("a fork child puts its mode, commits behind and conflict on the wire; an ordinary child sends none", async () => {
369 const dataDir = child.config.dataDir;
370 const parentJson = join(dataDir, "parent.json");
371 const original = await readFile(parentJson, "utf8");
372 const bodyOf = async () => {
373 let body = "";
374 await sendBeat(child, async (_url, init) => {
375 body = String(init?.body);
376 return new Response("{}");
377 });
378 return JSON.parse(body) as Record<string, unknown>;
379 };
380 const upstream = (behind: number, taken: unknown) =>
381 writeFile(join(dataDir, "upstream.json"), JSON.stringify({ behind, checkedAt: Date.now(), taken }));
382 await mkdir(repoDir(child.reposRoot, "kid", "forge"), { recursive: true });
383 const keys = ["mode", "upstreamBehind", "conflict"];
384 try {
385 const ordinary = await bodyOf();
386 for (const k of keys) expect(k in ordinary).toBe(false);
387
388 const doc = JSON.parse(original) as Record<string, unknown>;
389 const fork = (mode: string) =>
390 writeFile(
391 parentJson,
392 JSON.stringify({ ...doc, forgeCode: { repo: "kid/forge", upstream: "alice/lib", mode, tip: "", chain: [] } }),
393 );
394 await fork("take-updates");
395 await upstream(3, { kind: "conflict", files: ["a.ts"], issue: 7 });
396 expect(await bodyOf()).toMatchObject({ mode: "take-updates", upstreamBehind: 3, conflict: true });
397
398 await upstream(2_000_000, { kind: "conflict", files: ["a.ts"], issue: null });
399 expect(await bodyOf()).toMatchObject({ upstreamBehind: 1_000_000, conflict: false });
400
401 await fork("tell-me");
402 await rm(join(dataDir, "upstream.json"));
403 expect(await bodyOf()).toMatchObject({ mode: "tell-me", upstreamBehind: null, conflict: false });
404 } finally {
405 await writeFile(parentJson, original);
406 await rm(join(dataDir, "upstream.json"), { force: true });
407 }
408 });
331409 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs