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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2643-a-parent-forge-s-family-shows-how-each-c → main 3 commits fork 3 threads · 3 unresolved
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 | ||
| 99 | 99 | MCP `following` and `sever` tools, and the terminal's **Chain** command. |
| 100 | 100 | Sever's one implementation is `severForge` in `src/actions/follow.ts`. |
| 101 | 101 | |
| 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. | |
| 104 | 108 | |
| 105 | 109 | **Sever** (`POST /:owner/forge/following/sever`, owner only, same-origin, |
| 106 | 110 | 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 | ||
| 33 | 33 | return `${head} — ${s.notRunning.map((n) => `${n.slug} ${reason(n.why)}`).join("; ")}`; |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | /** "takes updates · 3 commits behind · conflict open", or null when the child said nothing of it. */ | |
| 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 | ||
| 36 | 46 | export interface FamilyView { |
| 37 | 47 | readonly parent: { readonly url: string; readonly host: string; readonly behind: number | null } | null; |
| 38 | 48 | readonly here: { readonly host: string | null; readonly version: string }; |
| @@ -82,6 +92,8 @@ export function familyLines(view: FamilyView): [string, string][] { | ||
| 82 | 92 | c.openProposals === null ? "" : `${c.openProposals} open`, |
| 83 | 93 | ].filter((b) => b !== ""); |
| 84 | 94 | 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. |
||
| 85 | 97 | if (c.apps !== null) out.push(["", c.apps]); |
| 86 | 98 | } |
| 87 | 99 | 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"; | ||
| 19 | 19 | import type { App } from "../app.ts"; |
| 20 | 20 | import { beatKeyOf, listChildren } from "../actions/spawn.ts"; |
| 21 | 21 | 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"; | |
| 23 | 24 | import { running } from "../kernel/version.ts"; |
| 24 | 25 | |
| 25 | 26 | export const SIGNATURE_HEADER = "x-forge-signature"; |
| @@ -46,8 +47,16 @@ export interface Beat { | ||
| 46 | 47 | readonly forgeTip?: string | null; |
| 47 | 48 | /** When the child cut its upstream link; null while it follows. */ |
| 48 | 49 | 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; | |
| 49 | 56 | } |
| 50 | 57 | |
| 58 | const OPTIONAL = new Set(["forgeTip", "severedAt", "mode", "upstreamBehind", "conflict"]); | |
| 59 | ||
| 51 | 60 | export function sign(key: string, body: string): string { |
| 52 | 61 | return createHmac("sha256", key).update(body).digest("hex"); |
| 53 | 62 | } |
| @@ -72,17 +81,20 @@ export function parseBeat(raw: string): Beat | undefined { | ||
| 72 | 81 | if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return undefined; |
| 73 | 82 | const d = doc as Record<string, unknown>; |
| 74 | 83 | const keys = Object.keys(d) |
| 75 | .filter((k) => k !== "forgeTip" && k !== "severedAt") | |
| 84 | .filter((k) => !OPTIONAL.has(k)) | |
| 76 | 85 | .sort() |
| 77 | 86 | .join(","); |
| 78 | 87 | 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; | |
| 80 | 89 | const tipOk = forgeTip === undefined || forgeTip === null || (typeof forgeTip === "string" && /^[0-9a-f]{40,64}$/.test(forgeTip)); |
| 81 | 90 | const sevOk = |
| 82 | 91 | severedAt === undefined || |
| 83 | 92 | severedAt === null || |
| 84 | 93 | (typeof severedAt === "string" && severedAt.length <= 40 && !Number.isNaN(Date.parse(severedAt))); |
| 85 | 94 | 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; | |
| 86 | 98 | if (typeof name !== "string" || !NAME.test(name)) return undefined; |
| 87 | 99 | if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return undefined; |
| 88 | 100 | if (typeof version !== "string" || !VERSION.test(version)) return undefined; |
| @@ -95,6 +107,9 @@ export function parseBeat(raw: string): Beat | undefined { | ||
| 95 | 107 | children, |
| 96 | 108 | ...(forgeTip === undefined ? {} : { forgeTip: forgeTip as string | null }), |
| 97 | 109 | ...(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 }), | |
| 98 | 113 | }; |
| 99 | 114 | } |
| 100 | 115 | |
| @@ -199,6 +214,19 @@ export function standing(app: App, name: string, at = Date.now()): Beat { | ||
| 199 | 214 | }; |
| 200 | 215 | } |
| 201 | 216 | |
| 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 | ||
| 202 | 230 | type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; |
| 203 | 231 | let lastAt = 0; |
| 204 | 232 | |
| @@ -220,7 +248,7 @@ export async function sendBeat( | ||
| 220 | 248 | // Only a forge that carries a fork of its parent's code has these to say. |
| 221 | 249 | ...(forge === undefined |
| 222 | 250 | ? {} |
| 223 | : { forgeTip: await forgeTipOf(app), severedAt: forge.severedAt ?? null }), | |
| 251 | : await forkReport(app, forge)), | |
| 224 | 252 | }); |
| 225 | 253 | try { |
| 226 | 254 | const res = await fetcher(`${link.upstream}/api/v1/heartbeat`, { |
| @@ -259,6 +287,12 @@ export interface ChildStanding { | ||
| 259 | 287 | readonly forgeTip: string | null; |
| 260 | 288 | /** When the child cut its link to this forge's code; null while it follows or when unheard. */ |
| 261 | 289 | 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; | |
| 262 | 296 | } |
| 263 | 297 | |
| 264 | 298 | /** 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( | ||
| 287 | 321 | ownChildren: heard?.beat.children ?? null, |
| 288 | 322 | forgeTip: heard?.beat.forgeTip ?? null, |
| 289 | 323 | severedAt: heard?.beat.severedAt ?? null, |
| 324 | mode: heard?.beat.mode ?? null, | |
| 325 | upstreamBehind: heard?.beat.upstreamBehind ?? null, | |
| 326 | conflict: heard?.beat.conflict ?? false, | |
| 290 | 327 | }); |
| 291 | 328 | } |
| 292 | 329 | return { items, more: Math.max(0, all.length - MAX_CHILDREN) }; |
src/web/pages/family.ts +3 −1
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { hostedChildApps } from "../../spawn/host.ts"; |
| 14 | 14 | import { MAX_DEPTH } from "../../spawn/heartbeat.ts"; |
| 15 | 15 | import { pageHead } from "../chrome.ts"; |
| @@ -45,6 +45,8 @@ function standingNode(c: FamilyChild): Node { | ||
| 45 | 45 | if (c.ownChildren !== null && c.ownChildren > 0) |
| 46 | 46 | meta.push(html`${plural(c.ownChildren, "forge")} of its own`); |
| 47 | 47 | } |
| 48 | const follow = followLine(c); | |
| 49 | if (follow !== null) meta.push(html`<span data-follow>${follow}</span>`); | |
| 48 | 50 | if (c.apps !== null) meta.push(html`<span data-apps>${c.apps}</span>`); |
| 49 | 51 | return { |
| 50 | 52 | label: c.name, |
test/family-heartbeat.test.ts +79 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | 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"; | |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createRepo } from "../src/actions/repos.ts"; |
| @@ -328,4 +328,82 @@ describe("family heartbeats", () => { | ||
| 328 | 328 | expect(after).toContain("follows nothing since 2030-02-03"); |
| 329 | 329 | expect(after).not.toContain("code at"); |
| 330 | 330 | }); |
| 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 | }); | |
| 331 | 409 | }); |
new above
offline
Docs: describe this line in docs/SPAWN.md next to the forgeTip/severedAt paragraph.