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

A spawned forge carries a fork of the forge it came from, and the chain shows both ways #1862

Merged opened this in answer to

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

Merged

scout/forge:scout/1085-a-spawned-forge-carries-a-fork-of-the-fo → main 13 commits fork 13 threads · 13 unresolved

  • Merged. scout/1085-a-spawned-forge-carries-a-fork-of-the-fo was merged into main as 083157a.

Review

Sign in to review this change.
29 files changed +1320 −80
README.md +9
@@ -212,6 +212,15 @@ taking a release is not done here. This forge publishes its own
212212 `/releases.json` (the version names, each linking to that document), so any
213213 forge can be a parent. The terminal shows the count, not the release names.
214214
215 A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen
216 `followMode` `tell-me` or `take-updates`; recorded, not yet acted on). Its chain
217 to the root is at `/:owner/forge/following`, in `chain` of `GET
218 /api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the
219 terminal's **Chain** command; the owner severs it with the page's Sever,
220 `POST …/following/sever` or the MCP `sever` tool (nothing is deleted). Taking
221 updates into a conflict opens the converge issue at every door. See
222 `docs/SPAWN.md` and `docs/FOLLOWING.md`.
223
215224 ## Archive
216225
217226 An admin puts a repository away from **Settings → Archive**, with
docs/FOLLOWING.md +13
@@ -180,3 +180,16 @@ the fork owner's name, `trevato/main` proposing into `main`. Landing it
180180 there is landing it upstream.
181181 `POST /api/v1/repos/:owner/:name/following/propose` is the same press over
182182 the API.
183
184 A fork across forges (#1085): a spawned forge's `<org>/forge` follows its
185 parent's forge code in the mode the spawner chose, `tell-me` (default) or
186 `take-updates`; `/:owner/forge/following` shows the chain to the root and
187 the owner's **Sever**, which cuts the link and deletes nothing (the page
188 and the parent's `/family` say "follows nothing since …"). `docs/SPAWN.md`
189 has the shape. A **conflict** on **Take updates** is a tell for the crew:
190 the press opens (or finds) the converge issue naming the files in conflict,
191 assigned to a builder when the repository has one, and says why not when it
192 has none. The step is `takeOrTell` in `src/actions/follow.ts`, shared by the
193 page, `POST /api/v1/repos/:owner/:name/following/take` (409, naming the
194 converge issue), and the MCP `take_updates` tool, so the same press does the
195 same thing at every door.
docs/SPAWN.md +36
@@ -69,3 +69,39 @@ Children report in; the parent never reaches in. Every ten minutes a spawned chi
6969 ## Callers
7070
7171 `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `dryRun` is true unless it is `false`: the answer is the plan and what is left behind, and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make.
72
73 ## The forge's own code, and the chain (#1085)
74
75 Every child is born with `<org>/forge`: a fork, with full history, of the
76 repository its parent forge is deployed from (`forgeRepo`; a spawned forge
77 has none configured, so it uses the fork it was born with). The child's owner
78 may change it freely. A grandchild forks the child's `forge`, so the chain
79 reads child → parent → … → root.
80
81 `parent.json` keeps `forgeCode`: `repo`, `upstream`, `mode`, `tip` (the
82 upstream's tip at birth) and `chain` (the ancestors' hops, copied from the
83 parent's own `parent.json`, so a grandchild reads the whole chain offline).
84 The spawner chooses `followMode` in the spawn input: `tell-me` (default) or
85 `take-updates`; it is part of the plan. `/:owner/forge/following` on the
86 child shows the chain, root first, one line per hop with its mode and how
87 many commits of its own it had when the next hop was born (the last hop is
88 counted now). Behind-counts are not shown: they need a fetch of the parent,
89 which spawn does not do; that waits on cross-forge following (#1052), which
90 is also what will make `mode` act. Until a follower acts on it, `tell-me`
91 and `take-updates` are the owner's recorded choice, shown on the chain, not
92 behaviour.
93
94 Every door reads and cuts the chain the same way: the page
95 (`GET /:owner/forge/following`), `GET /api/v1/repos/:owner/forge/following`
96 (`chain`, `severedAt`), `POST …/following/sever` (rw token, owner only), the
97 MCP `following` and `sever` tools, and the terminal's **Chain** command.
98 Sever's one implementation is `severForge` in `src/actions/follow.ts`.
99
100 A child's heartbeat adds `forgeTip` and `severedAt` when it carries a fork;
101 `/family` on the parent shows `code at <tip>` or `follows nothing since …`.
102
103 **Sever** (`POST /:owner/forge/following/sever`, owner only, same-origin,
104 type `sever`) writes `severedAt` into `forgeCode` and nothing else: the
105 fork, its history and the record of where it came from stay, and nothing
106 leaves. The page and the parent's tree then say it follows nothing since
107 the date.
src/actions/follow.ts added +112
@@ -0,0 +1,112 @@
1 /**
2 * What a press on a fork's following does, once, for every door (page, API,
3 * MCP, terminal): take the parent's updates and, when both sides changed the
4 * same files, tell the crew; and sever a forge's link to the forge its code
5 * came from. The doors only word the answer.
6 */
7
8 import type { App } from "../app.ts";
9 import { DIRECTION_NOTE } from "../crew/direct.ts";
10 import { convergeFork, type Converge } from "../crew/converge.ts";
11 import { canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts";
12 import type { User } from "../core/users.ts";
13 import { err, ok, type Result } from "../kernel/result.ts";
14 import { chainOfRepo, severChain } from "../spawn/chain.ts";
15 import { requireUser } from "./access.ts";
16 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
17
18 export type TakeOrTell = Result<ForkSync & { merged: boolean }, TakeUpdatesError | Conflict>;
19
20 /** A conflict taken, with what the crew did about it. */
21 export interface Conflict {
22 readonly kind: "conflict";
23 readonly files: readonly string[];
24 readonly converge: Converge;
25 }
26
27 /** Take updates; a conflict opens (or finds) the converge issue naming its files. */
28 export async function takeOrTell(
29 app: App,
30 actor: User | undefined,
31 fork: Repo,
32 ): Promise<TakeOrTell> {
33 const took = await takeUpdates(app, actor, fork);
34 if (took.ok || typeof took.error === "string" || took.error.kind !== "conflict") return took;
35 const parent = forkParentFor(app.db, fork, actor);
36 if (parent === undefined) return took;
37 const converge = await convergeFork(app, fork, parent, actor);
38 return err({ kind: "conflict", files: took.error.files, converge });
39 }
40
41 export type SeverError = "unauthorized" | "forbidden" | "not_following";
42
43 /** Cut a forge's link to its parent's code: the repository's owner only. Nothing is deleted. */
44 export async function severForge(
45 app: App,
46 actor: User | undefined,
47 repo: Repo,
48 ): Promise<Result<string, SeverError>> {
49 const who = requireUser(actor);
50 if (!who.ok) return err("unauthorized");
51 if ((await chainOfRepo(app, repo)) === undefined) return err("not_following");
52 if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden");
53 const cut = await severChain(app);
54 if (!cut.ok) return err("not_following");
55 app.bus.append({

This event goes onto the bus, but no page subscribes to it, so a sever made from another door does not reach an open chain page or the parent's /family live. Tracked in #2213; it does not block this pull.

56 kind: "forge.severed",

Minor: nothing listens for forge.severed. An open /following page, or another session, doesn't see the sever until it reloads.

57 actorId: who.value.id,
58 payload: { repo: `${repo.ownerName}/${repo.name}`, at: cut.value },
59 });
60 return ok(cut.value);
61 }
62
63 /** What the crew did about a conflict, in a sentence; `danger` when nothing is in hand. */
64 export function convergeWords(
65 outcome: Converge,
66 parentName: string,
67 ): { tone?: "danger"; message: string } {
68 switch (outcome.kind) {
69 case "queued":
70 return {
71 message: `${outcome.agent.user.username} is converging with ${parentName} on #${outcome.issue.number}; the proposal comes back as a pull.`,
72 };
73 case "working":
74 return {
75 message: `The crew is already converging on #${outcome.issue.number}.`,
76 };
77 case "proposed":
78 return {
79 message: `The crew's proposal is already open: pull #${outcome.pull.number}.`,
80 };
81 case "parked":
82 return {
83 tone: "danger",
84 message: `The crew tried its limit on #${outcome.issue.number}; say there what to do.`,
85 };
86 case "nothing":
87 return {
88 message:
89 outcome.relation?.state === "diverged"
90 ? "They merge cleanly; Take updates does it without the crew."
91 : "Nothing to converge.",
92 };
93 case "no_crew":
94 return {
95 tone: "danger",
96 message:
97 "Nobody is on the crew here; add someone under Settings → Crew.",
98 };
99 case "nobody":
100 return {
101 tone: "danger",
102 message: "Nobody can open the converge issue here.",
103 };
104 case "forbidden":
105 return { tone: "danger", message: DIRECTION_NOTE };
106 case "ceiling":
107 return {
108 tone: "danger",
109 message: "The crew is at today's ceiling; it converges tomorrow.",
110 };
111 }
112 }
src/actions/spawn.ts +10 −5
@@ -53,6 +53,7 @@ import { loadOrCreateHostKey } from "../ssh/keys.ts";
5353 import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts";
5454 import { childPublicUrl, type Placement } from "../spawn/placement.ts";
5555 import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts";
56 import { chainForChild, DEFAULT_MODE, forgeLinkOf, type FollowMode, type Hop } from "../spawn/chain.ts";
5657 import { requireUser } from "./access.ts";
5758
5859 export interface Spawned {
@@ -364,7 +365,7 @@ async function copyInto(
364365 // A cross-forge fork: the history is copied by git, and the upstream
365366 // is recorded in parent.json for the child to pull from. Skipped when
366367 // the forge does not name its code repository (`forgeRepo`).
367 const forgeCode = await forkForgeCode(app, child, org.username);
368 const forgeCode = await forkForgeCode(app, child, org.username, plan.followMode ?? DEFAULT_MODE);
368369 if (!forgeCode.ok) return err(forgeCode.error);
369370
370371 for (const b of plan.briefs) {
@@ -441,7 +442,7 @@ async function copyInto(
441442 // the child can follow its parent's updates.
442443 ...(forgeCode.value === undefined
443444 ? {}
444 : { forgeCode: { repo: `${plan.org}/forge`, upstream: forgeCode.value } }),
445 : { forgeCode: { repo: `${plan.org}/forge`, ...forgeCode.value } }),
445446 // Only the hash: the parent alone holds what it answers to.
446447 ...(ledger.statusToken === undefined ? {} : { statusHash: sha256(ledger.statusToken) }),
447448 // The key the child signs its heartbeats with; the parent holds the same.
@@ -527,8 +528,11 @@ async function forkForgeCode(
527528 app: App,
528529 child: App,
529530 orgName: string,
530 ): Promise<Result<string | undefined, string>> {
531 const ref = app.config.forgeRepo;
531 mode: FollowMode,
532 ): Promise<Result<{ upstream: string; mode: FollowMode; tip: string; chain: readonly Hop[] } | undefined, string>> {
533 // A spawned forge names no `forgeRepo` of its own: its code is the fork
534 // it was born with, so a grandchild forks that.
535 const ref = app.config.forgeRepo ?? (await forgeLinkOf(app))?.repo;
532536 if (ref === undefined) return ok(undefined);
533537 const parts = ref.split("/");
534538 if (parts.length !== 2 || !parts[0] || !parts[1]) return ok(undefined);
@@ -559,7 +563,8 @@ async function forkForgeCode(
559563 { cwd: repoDir(child.reposRoot, orgName, name), timeoutMs: 600_000 },
560564 );
561565 if (!fetched.ok) return err(`could not copy the forge's code: ${fetched.error.stderr}`);
562 return ok(`${o}/${n}`);
566 const born = await chainForChild(app, `${o}/${n}`);
567 return ok({ upstream: `${o}/${n}`, mode, tip: born?.tip ?? "", chain: born?.chain ?? [] });
563568 }
564569
565570 /**
src/http/api.ts +30 −4
@@ -58,7 +58,9 @@ import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts";
5858 import { reissueDoor, spawnDoor } from "../spawn/door.ts";
5959 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
6060 import { proposeUpstream } from "../actions/upstream.ts";
61 import { relationOf, takeUpdates } from "../actions/forks.ts";
61 import { severForge, takeOrTell } from "../actions/follow.ts";
62 import { relationOf } from "../actions/forks.ts";
63 import { chainOfRepo } from "../spawn/chain.ts";
6264 import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts";
6365 import { flowFor, historyFor, sourcesOut, systemFor, welcome } from "../system/telemetry.ts";
6466 import { convergeFork, convergeState } from "../crew/converge.ts";
@@ -385,7 +387,7 @@ import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
385387
386388 /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */
387389 async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> {
388 const took = await takeUpdates(ctx.app, ctx.auth.user, repo);
390 const took = await takeOrTell(ctx.app, ctx.auth.user, repo);
389391 if (!took.ok) {
390392 const e = took.error;
391393 if (typeof e === "string")
@@ -396,8 +398,10 @@ async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> {
396398 return json(
397399 {
398400 error: "conflict",
399 message: "both sides changed these files; the crew can converge",
401 message: "both sides changed these files; the crew was told",
400402 files: e.files,
403 converge: "converge" in e ? e.converge.kind : null,
404 issue: "converge" in e && "issue" in e.converge ? e.converge.issue.number : null,
401405 },
402406 409,
403407 );
@@ -2035,13 +2039,18 @@ export function apiRoutes(router: Router<Handler>): void {
20352039 const repo = loadRepo(ctx);
20362040 if (!repo) return notFound();
20372041 const relation = await relationOf(ctx.app, ctx.auth.user, repo);
2042 const chain = await chainOfRepo(ctx.app, repo);
20382043 if (relation === undefined)
2039 return apiError("not_a_fork", 404, "this repository follows nothing");
2044 return chain === undefined
2045 ? apiError("not_a_fork", 404, "this repository follows nothing")
2046 : json({ chain: chain.lines, severedAt: chain.severedAt });
20402047 const converge =
20412048 relation.state === "diverged" && !relation.clean
20422049 ? convergeState(ctx.app, repo, relation.parent)
20432050 : undefined;
20442051 return json({
2052 chain: chain?.lines ?? null,
2053 severedAt: chain?.severedAt ?? null,
20452054 parent: `${relation.parent.ownerName}/${relation.parent.name}`,
20462055 parentBranch: relation.parent.defaultBranch,
20472056 // A parent whose origin was disconnected: the counts below are against
@@ -2089,6 +2098,23 @@ export function apiRoutes(router: Router<Handler>): void {
20892098 },
20902099 );
20912100
2101 router.add(
2102 "POST",
2103 "/api/v1/repos/:owner/:name/following/sever",
2104 async (ctx) => {
2105 const denied = requireScope(ctx, "rw");
2106 if (denied) return denied;
2107 const repo = loadRepo(ctx);
2108 if (!repo) return notFound();
2109 const cut = await severForge(ctx.app, ctx.auth.user, repo);
2110 if (!cut.ok)
2111 return cut.error === "not_following"
2112 ? apiError("not_following", 404, "this repository follows nothing across forges")
2113 : respondAccess(cut.error, 403);
2114 return json({ severedAt: cut.value });
2115 },
2116 );
2117
20922118 router.add(
20932119 "POST",
20942120 "/api/v1/repos/:owner/:name/following/propose",
src/http/mcp.ts +41 −5
@@ -27,7 +27,9 @@ import { changeSignup, readSignup } from "../actions/signup.ts";
2727 import { SIGNUP_MODES } from "../settings/signup.ts";
2828 import { QUOTA_KEYS } from "../settings/quotas.ts";
2929 import { LIMIT_KEYS } from "../settings/limits.ts";
30 import { relationOf, takeUpdates } from "../actions/forks.ts";
30 import { convergeWords, severForge, takeOrTell } from "../actions/follow.ts";
31 import { relationOf } from "../actions/forks.ts";
32 import { chainOfRepo } from "../spawn/chain.ts";
3133 import { watchMilestone } from "../actions/watches.ts";
3234 import {
3335 addCommentThrough,
@@ -1006,13 +1008,18 @@ const TOOLS: readonly Tool[] = [
10061008 const repo = repoArg(ctx, user, args);
10071009 if (isAnswer(repo)) return repo;
10081010 const relation = await relationOf(ctx.app, user, repo);
1011 const chain = await chainOfRepo(ctx.app, repo);
10091012 if (relation === undefined)
1010 return bad("not_a_fork", "this repository follows nothing");
1013 return chain === undefined
1014 ? bad("not_a_fork", "this repository follows nothing")
1015 : good({ chain: chain.lines, severedAt: chain.severedAt });
10111016 const converge =
10121017 relation.state === "diverged" && !relation.clean
10131018 ? convergeState(ctx.app, repo, relation.parent)
10141019 : undefined;
10151020 return good({
1021 chain: chain?.lines ?? null,
1022 severedAt: chain?.severedAt ?? null,
10161023 parent: `${relation.parent.ownerName}/${relation.parent.name}`,
10171024 state: relation.state,
10181025 behind: relation.behind,
@@ -1055,6 +1062,12 @@ const TOOLS: readonly Tool[] = [
10551062 description:
10561063 "One line (at most 200 characters) on a fresh app to start in the child beside the copied ones: it gets a README and a small app that runs.",
10571064 },
1065 followMode: {

MCP can choose the mode, but the page and the terminal can't. Give those two spawn doors the same choice.

1066 type: "string",
1067 enum: ["tell-me", "take-updates"],
1068 description:
1069 "How the child follows this forge's code: tell-me (default) says when the parent has moved; take-updates takes them.",
1070 },
10581071 keepAccess: {
10591072 type: "boolean",
10601073 description:
@@ -1136,7 +1149,7 @@ const TOOLS: readonly Tool[] = [
11361149 {
11371150 name: "take_updates",
11381151 description:
1139 "Take the parent's updates into a fork: a fast-forward, or a merge when git merges cleanly. A conflict is refused with its files; use `converge` then.",
1152 "Take the parent's updates into a fork: a fast-forward, or a merge when git merges cleanly. A conflict is refused with its files, and the crew is told: the converge issue is opened (or found) and named in the answer.",
11401153 inputSchema: {
11411154 type: "object",
11421155 properties: { repo: REPO_ARG },
@@ -1146,7 +1159,7 @@ const TOOLS: readonly Tool[] = [
11461159 async run(ctx, user, args) {
11471160 const repo = repoArg(ctx, user, args);
11481161 if (isAnswer(repo)) return repo;
1149 const took = await takeUpdates(ctx.app, user, repo);
1162 const took = await takeOrTell(ctx.app, user, repo);
11501163 if (took.ok)
11511164 return good({
11521165 says: !took.value.updated
@@ -1157,6 +1170,8 @@ const TOOLS: readonly Tool[] = [
11571170 ...took.value,
11581171 });
11591172 const e = took.error;
1173 const up = forkParentFor(ctx.app.db, repo, user);
1174 const parentName = up === undefined ? "the parent" : `${up.ownerName}/${up.name}`;
11601175 if (typeof e === "string")
11611176 return bad(
11621177 e,
@@ -1167,7 +1182,7 @@ const TOOLS: readonly Tool[] = [
11671182 return e.kind === "conflict"
11681183 ? bad(
11691184 "conflict",
1170 `both sides changed ${e.files.join(", ")}; converge with the crew`,
1185 `both sides changed ${e.files.join(", ")}. ${"converge" in e ? convergeWords(e.converge, parentName).message : ""}`,
11711186 )
11721187 : bad(
11731188 e.kind,
@@ -1175,6 +1190,27 @@ const TOOLS: readonly Tool[] = [
11751190 );
11761191 },
11771192 },
1193 {
1194 name: "sever",
1195 description:
1196 "Cut a spawned forge's link to the forge its code came from (its `forge` repository's owner only). Nothing is deleted or sent; the chain then says it follows nothing since today.",
1197 inputSchema: {
1198 type: "object",
1199 properties: { repo: REPO_ARG },
1200 required: ["repo"],
1201 },
1202 writes: true,
1203 async run(ctx, user, args) {
1204 const repo = repoArg(ctx, user, args);
1205 if (isAnswer(repo)) return repo;
1206 const cut = await severForge(ctx.app, user, repo);
1207 if (cut.ok) return good({ says: `Severed: following nothing since ${cut.value.slice(0, 10)}.`, severedAt: cut.value });
1208 return bad(
1209 cut.error,
1210 cut.error === "not_following" ? "this repository follows nothing across forges" : "only this forge's owner can sever it",
1211 );
1212 },
1213 },
11781214 {
11791215 name: "converge",
11801216 description:
src/http/server.ts +3
@@ -114,6 +114,7 @@ import {
114114 signinPasskey,
115115 } from "../web/pages/passkeys.ts";
116116 import { confirmPage, searchPage } from "../web/pages/search.ts";
117 import { chainPage, severSubmit } from "../web/pages/chain.ts";
117118 import { followingSubmit } from "../web/pages/following.ts";
118119 import { reconnectSubmit } from "../web/pages/frozen.ts";
119120 import { activityAtom } from "../web/feeds.ts";
@@ -1198,6 +1199,8 @@ export function buildRouter(): Router<Handler> {
11981199 router.add("POST", "/:owner/:name/make-it-yours", makeItYoursSubmit);
11991200 // A fork takes what it follows: git's part by itself, the rest by the crew.
12001201 router.add("POST", "/:owner/:name/following", followingSubmit);
1202 router.add("GET", "/:owner/:name/following", chainPage);
1203 router.add("POST", "/:owner/:name/following/sever", severSubmit);
12011204 // A mirror pulls on demand; a pin says "keep this on my machine". Both are
12021205 // ordinary repo-scoped writes, not account-level ones.
12031206 router.add("POST", "/:owner/:name/sync", repoSyncSubmit);
src/spawn/chain.ts added +228
@@ -0,0 +1,228 @@
1 /**
2 * The chain of forges a spawned forge's own code descends from. Each child
3 * carries `<org>/forge`, a fork of the forge it came from; `parent.json`
4 * keeps where, how the owner chose to follow (`tell-me` or `take-updates`),
5 * the tip it was born at, and the hops above it, copied from the parent's
6 * own `parent.json` at spawn. So a grandchild reads its whole chain to the
7 * root without asking anyone, and the numbers are honest: each is as of
8 * the day that hop was made, except the last, which is counted here.
9 *
10 * A forge is sovereign: severing writes `severedAt` and nothing else. The
11 * fork, its history and the record of where it came from all stay.
12 */
13
14 import { join } from "node:path";
15 import { rename } from "node:fs/promises";
16 import type { App } from "../app.ts";
17 import { runGit } from "../git/env.ts";
18 import { repoDir } from "../git/repos.ts";
19 import { err, ok, type Result } from "../kernel/result.ts";
20
21 export const FOLLOW_MODES = ["tell-me", "take-updates"] as const;
22 export type FollowMode = (typeof FOLLOW_MODES)[number];
23 export const DEFAULT_MODE: FollowMode = "tell-me";
24
25 /** One forge in the chain, as recorded when the hop below it was made. */
26 export interface Hop {
27 /** `owner/name` of its forge repository on its own forge. */
28 readonly repo: string;
29 /** Its forge's address; "" when it did not say. */
30 readonly forge: string;
31 /** How it follows the hop above it; null for the root. */
32 readonly mode: FollowMode | null;
33 /** Its default branch's tip when the next hop was born. */
34 readonly tip: string;
35 /** Commits it had of its own then; null for the root or when unknown. */
36 readonly ahead: number | null;
37 }
38
39 /** The `forgeCode` record in `parent.json`. */
40 export interface ForgeLink {
41 readonly repo: string;
42 readonly upstream: string;
43 readonly mode: FollowMode;
44 /** The upstream's tip at birth. */
45 readonly tip: string;
46 /** Root first, ending with the parent. */
47 readonly chain: readonly Hop[];
48 readonly severedAt?: string;
49 }
50
51 const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
52 const TIP = /^[0-9a-f]{40,64}$/;
53
54 const isMode = (v: unknown): v is FollowMode =>
55 typeof v === "string" && (FOLLOW_MODES as readonly string[]).includes(v);
56
57 /** The origin of an http(s) address, or "" when it is not one; parsed once, here, so nothing downstream can throw on it. */
58 export function originOf(v: unknown): string {
59 if (typeof v !== "string" || v.length > 200) return "";
60 const u = URL.parse(v);
61 return u !== null && (u.protocol === "http:" || u.protocol === "https:") ? u.origin : "";
62 }
63
64 function readHop(v: unknown): Hop | undefined {
65 if (typeof v !== "object" || v === null) return undefined;
66 const h = v as Record<string, unknown>;
67 if (typeof h["repo"] !== "string" || !REPO.test(h["repo"])) return undefined;
68 if (typeof h["tip"] !== "string" || !TIP.test(h["tip"])) return undefined;
69 const forge = originOf(h["forge"]);
70 const ahead =
71 typeof h["ahead"] === "number" && Number.isSafeInteger(h["ahead"]) && h["ahead"] >= 0 ? h["ahead"] : null;
72 return { repo: h["repo"], forge, mode: isMode(h["mode"]) ? h["mode"] : null, tip: h["tip"], ahead };
73 }
74
75 /** Everything in `parent.json`, untouched, so a rewrite keeps what it does not know. */
76 async function readDoc(app: App): Promise<Record<string, unknown> | undefined> {
77 try {
78 const doc = await Bun.file(join(app.config.dataDir, "parent.json")).json();
79 return typeof doc === "object" && doc !== null && !Array.isArray(doc) ? (doc as Record<string, unknown>) : undefined;
80 } catch {
81 return undefined;
82 }
83 }
84
85 /** This forge's link to the forge its code came from, when it has one. */
86 export async function forgeLinkOf(app: App): Promise<ForgeLink | undefined> {
87 const doc = await readDoc(app);
88 const f = doc?.["forgeCode"];
89 if (typeof f !== "object" || f === null) return undefined;
90 const l = f as Record<string, unknown>;
91 if (typeof l["repo"] !== "string" || !REPO.test(l["repo"])) return undefined;
92 if (typeof l["upstream"] !== "string" || !REPO.test(l["upstream"])) return undefined;
93 const chain = Array.isArray(l["chain"])
94 ? l["chain"].map(readHop).filter((h): h is Hop => h !== undefined).slice(0, 16)
95 : [];
96 const severedAt =
97 typeof l["severedAt"] === "string" && !Number.isNaN(Date.parse(l["severedAt"])) ? l["severedAt"] : undefined;
98 return {
99 repo: l["repo"],
100 upstream: l["upstream"],
101 mode: isMode(l["mode"]) ? l["mode"] : DEFAULT_MODE,
102 tip: typeof l["tip"] === "string" && TIP.test(l["tip"]) ? l["tip"] : "",
103 chain,
104 ...(severedAt === undefined ? {} : { severedAt }),
105 };
106 }
107
108 async function tipOf(app: App, repo: string, base?: string): Promise<{ tip: string; ahead: number | null } | undefined> {
109 const [o, n] = repo.split("/") as [string, string];
110 const dir = repoDir(app.reposRoot, o, n);
111 const head = await runGit(["rev-parse", "--verify", "-q", "HEAD"], { cwd: dir });
112 if (!head.ok) return undefined;
113 const tip = head.value.trim();
114 if (base === undefined || base === "") return { tip, ahead: null };
115 const n2 = await runGit(["rev-list", "--count", `${base}..${tip}`], { cwd: dir });
116 const count = n2.ok ? Number(n2.value.trim()) : NaN;
117 return { tip, ahead: Number.isSafeInteger(count) ? count : null };
118 }
119
120 /**
121 * The chain a child spawned from this forge is born with: this forge's own
122 * ancestors, then this forge. `repo` is this forge's code repository.
123 */
124 export async function chainForChild(
125 app: App,
126 repo: string,
127 ): Promise<{ chain: Hop[]; tip: string } | undefined> {
128 const link = await forgeLinkOf(app);
129 const doc = await readDoc(app);
130 const here = await tipOf(app, repo, link?.tip);
131 if (here === undefined) return undefined;
132 const url = app.config.url ?? (typeof doc?.["url"] === "string" ? doc["url"] : "");
133 const me: Hop = {
134 repo,
135 forge: originOf(url),
136 mode: link === undefined || link.severedAt !== undefined ? null : link.mode,
137 tip: here.tip,
138 ahead: here.ahead,
139 };
140 return { chain: [...(link?.chain ?? []), me], tip: here.tip };
141 }
142
143 export interface ChainLine {
144 readonly repo: string;
145 /** Its forge's host, "" when unsaid. */
146 readonly host: string;
147 /** How this hop follows the one above it; null for the root. */
148 readonly mode: FollowMode | null;
149 readonly ahead: number | null;
150 readonly here: boolean;
151 readonly severedAt: string | null;
152 }
153
154 export interface ChainView {
155 readonly lines: readonly ChainLine[];
156 readonly severedAt: string | null;
157 }
158
159 /** The chain to the root, root first, this forge last; undefined when this forge carries no fork. */
160 export async function chainView(app: App): Promise<ChainView | undefined> {
161 const link = await forgeLinkOf(app);
162 if (link === undefined) return undefined;
163 const own = await tipOf(app, link.repo, link.tip);
164 const lines: ChainLine[] = link.chain.map((h) => ({
165 repo: h.repo,
166 host: h.forge === "" ? "" : new URL(h.forge).host,
167 mode: h.mode,
168 ahead: h.ahead,
169 here: false,
170 severedAt: null,
171 }));
172 lines.push({
173 repo: link.repo,
174 host: originOf(app.config.url) === "" ? "" : new URL(originOf(app.config.url)).host,
175 mode: link.severedAt === undefined ? link.mode : null,
176 ahead: own?.ahead ?? null,
177 here: true,
178 severedAt: link.severedAt ?? null,
179 });
180 return { lines, severedAt: link.severedAt ?? null };
181 }
182
183 /** The tip of this forge's own code, for the heartbeat; null when there is none. */
184 export async function forgeTipOf(app: App): Promise<string | null> {
185 const link = await forgeLinkOf(app);
186 if (link === undefined) return null;
187 return (await tipOf(app, link.repo))?.tip ?? null;
188 }
189
190 /**
191 * Cut the upstream link. Only the record changes: the fork, its history and
192 * the note of where it came from stay. Idempotent; the first date stands.
193 */
194 export async function severChain(app: App, now = new Date()): Promise<Result<string, "not_following">> {
195 const doc = await readDoc(app);
196 const link = await forgeLinkOf(app);
197 if (doc === undefined || link === undefined) return err("not_following");
198 if (link.severedAt !== undefined) return ok(link.severedAt);
199 const at = now.toISOString();
200 const path = join(app.config.dataDir, "parent.json");
201 await Bun.write(
202 `${path}.tmp`,
203 JSON.stringify({ ...doc, forgeCode: { ...(doc["forgeCode"] as object), severedAt: at } }),
204 );
205 await rename(`${path}.tmp`, path);
206 return ok(at);
207 }
208
209 /** The chain, when `repo` is this forge's own code repository; undefined for any other. */
210 export async function chainOfRepo(app: App, repo: { ownerName: string; name: string }): Promise<ChainView | undefined> {
211 const link = await forgeLinkOf(app);
212 if (link === undefined || link.repo !== `${repo.ownerName}/${repo.name}`) return undefined;
213 return chainView(app);
214 }
215
216 /** The chain as plain lines, for a terminal: one per hop, root first. */
217 export function chainLines(view: ChainView): [string, string][] {
218 return view.lines.map((l) => {
219 const how =
220 l.severedAt !== null
221 ? `follows nothing since ${l.severedAt.slice(0, 10)}`
222 : l.mode === null
223 ? "follows nothing"
224 : l.mode;
225 const bits = [how, l.ahead === null ? "" : `${l.ahead} of its own`, l.host].filter((b) => b !== "");
226 return [l.here ? `${l.repo} (here)` : l.repo, bits.join(" · ")];
227 });
228 }
src/spawn/door.ts +1
@@ -234,6 +234,7 @@ export async function spawnDoor(
234234 keepAccess: anon ? false : raw["keepAccess"],
235235 budgetUsd: raw["budgetUsd"],
236236 fresh: raw["fresh"],
237 followMode: raw["followMode"],

followMode is wired here for the API and MCP only. The new-forge page (src/web/pages/new-forge.ts) and the terminal's New forge screen (src/tui/screens/newforge.ts) never send it, so a person spawning from either always gets tell-me. Add the field to both forms and a test for each.

237238 ...(anon ? {} : { for: raw["for"] }),
238239 };
239240
src/spawn/family.ts +1
@@ -78,6 +78,7 @@ export function familyLines(view: FamilyView): [string, string][] {
7878 : [
7979 c.state,
8080 c.version === null ? "" : `version ${c.version}`,
81 c.severedAt !== null ? `follows nothing since ${c.severedAt.slice(0, 10)}` : c.forgeTip === null ? "" : `code at ${c.forgeTip.slice(0, 7)}`,
8182 c.openProposals === null ? "" : `${c.openProposals} open`,
8283 ].filter((b) => b !== "");
8384 out.push([`child ${c.name}`, bits.join(" · ")]);
src/spawn/heartbeat.ts +36 −3
@@ -19,6 +19,7 @@ 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";
2223 import { running } from "../kernel/version.ts";
2324
2425 export const SIGNATURE_HEADER = "x-forge-signature";
@@ -41,6 +42,10 @@ export interface Beat {
4142 readonly version: string;
4243 readonly openProposals: number;
4344 readonly children: number;
45 /** The tip of the child's own forge code; absent from an older child, null when it carries none. */
46 readonly forgeTip?: string | null;
47 /** When the child cut its upstream link; null while it follows. */
48 readonly severedAt?: string | null;
4449 }
4550
4651 export function sign(key: string, body: string): string {
@@ -66,14 +71,31 @@ export function parseBeat(raw: string): Beat | undefined {
6671 }
6772 if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return undefined;
6873 const d = doc as Record<string, unknown>;
69 const keys = Object.keys(d).sort().join(",");
74 const keys = Object.keys(d)
75 .filter((k) => k !== "forgeTip" && k !== "severedAt")
76 .sort()
77 .join(",");
7078 if (keys !== "at,children,name,openProposals,version") return undefined;
71 const { name, at, version, openProposals, children } = d;
79 const { name, at, version, openProposals, children, forgeTip, severedAt } = d;
80 const tipOk = forgeTip === undefined || forgeTip === null || (typeof forgeTip === "string" && /^[0-9a-f]{40,64}$/.test(forgeTip));
81 const sevOk =
82 severedAt === undefined ||
83 severedAt === null ||
84 (typeof severedAt === "string" && severedAt.length <= 40 && !Number.isNaN(Date.parse(severedAt)));
85 if (!tipOk || !sevOk) return undefined;
7286 if (typeof name !== "string" || !NAME.test(name)) return undefined;
7387 if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return undefined;
7488 if (typeof version !== "string" || !VERSION.test(version)) return undefined;
7589 if (!count(openProposals) || !count(children)) return undefined;
76 return { name, at, version, openProposals, children };
90 return {
91 name,
92 at,
93 version,
94 openProposals,
95 children,
96 ...(forgeTip === undefined ? {} : { forgeTip: forgeTip as string | null }),
97 ...(severedAt === undefined ? {} : { severedAt: severedAt as string | null }),
98 };
7799 }
78100
79101 const beatPath = (app: App, name: string): string =>
@@ -189,11 +211,16 @@ export async function sendBeat(
189211 const link = await parentLink(app);
190212 if (link === undefined) return false;
191213 const own = (await listChildren(app)).length;
214 const forge = await forgeLinkOf(app);
192215 // Strictly later than the last one sent, so a quick restart is not a replay.
193216 lastAt = Math.max(now, lastAt + 1);
194217 const body = JSON.stringify({
195218 ...standing(app, link.name, lastAt),
196219 children: Math.min(own, COUNT_MAX),
220 // Only a forge that carries a fork of its parent's code has these to say.
221 ...(forge === undefined
222 ? {}
223 : { forgeTip: await forgeTipOf(app), severedAt: forge.severedAt ?? null }),
197224 });
198225 try {
199226 const res = await fetcher(`${link.upstream}/api/v1/heartbeat`, {
@@ -228,6 +255,10 @@ export interface ChildStanding {
228255 readonly behind: number | null;
229256 readonly openProposals: number | null;
230257 readonly ownChildren: number | null;
258 /** The tip of the child's own forge code, as last reported; null when none. */
259 readonly forgeTip: string | null;
260 /** When the child cut its link to this forge's code; null while it follows or when unheard. */
261 readonly severedAt: string | null;
231262 }
232263
233264 /** How many releases of this forge are newer than `version`; null when it is not one of them. */
@@ -254,6 +285,8 @@ export async function childStandings(
254285 behind: heard === undefined ? null : releasesBehind(heard.beat.version),
255286 openProposals: heard?.beat.openProposals ?? null,
256287 ownChildren: heard?.beat.children ?? null,
288 forgeTip: heard?.beat.forgeTip ?? null,
289 severedAt: heard?.beat.severedAt ?? null,
257290 });
258291 }
259292 return { items, more: Math.max(0, all.length - MAX_CHILDREN) };
src/spawn/plan.ts +14
@@ -8,6 +8,7 @@
88 */
99
1010 import { err, ok, type Result } from "../kernel/result.ts";
11 import { DEFAULT_MODE, type FollowMode } from "./chain.ts";
1112
1213 export const MAX_APPS = 50;
1314 export const MAX_BRIEFS = 100;
@@ -66,6 +67,8 @@ export interface SpawnInput {
6667 readonly budgetUsd?: unknown;
6768 /** One line describing a fresh app to start in the child, beside the forked ones; optional. */
6869 readonly fresh?: unknown;
70 /** How the child follows the forge's code: `tell-me` (default) or `take-updates`. */
71 readonly followMode?: unknown;
6972 }
7073
7174 /** What the parent has to offer. Extra fields are ignored, never copied. */
@@ -104,6 +107,8 @@ export interface SpawnPlan {
104107 readonly forWhom?: string;
105108 /** A fresh app, present only when asked for. */
106109 readonly fresh?: PlannedFresh;
110 /** How the child follows its parent's forge code; absent means `tell-me`. */
111 readonly followMode?: FollowMode;
107112 }
108113
109114 export interface DryRun {
@@ -237,6 +242,14 @@ export function planSpawn(
237242 };
238243 }
239244
245 let followMode: FollowMode | undefined;
246 if (input.followMode !== undefined && input.followMode !== null && input.followMode !== "") {
247 if (input.followMode !== "tell-me" && input.followMode !== "take-updates")
248 return refuse("followMode", "must be tell-me or take-updates");
249 // Only a chosen mode is named, so a plan without one hashes as it always did.
250 if (input.followMode !== DEFAULT_MODE) followMode = input.followMode;
251 }
252
240253 // `forWhom` only when said, so a plan without it hashes as it always did.
241254 return ok({
242255 name,
@@ -249,6 +262,7 @@ export function planSpawn(
249262 budgetUsd,
250263 ...(forWhom === "" ? {} : { forWhom }),
251264 ...(fresh === undefined ? {} : { fresh }),
265 ...(followMode === undefined ? {} : { followMode }),
252266 });
253267 }
254268
src/tui/forge.ts +10
@@ -29,6 +29,7 @@ import {
2929 openOnWeb,
3030 runAsync,
3131 } from "./screens/common.ts";
32 import { chainScreen } from "./screens/chain.ts";
3233 import { canSeeMemory } from "../boards/memory.ts";
3334 import { boardsScreen } from "./screens/boards.ts";
3435 import { askedScreen } from "./screens/asked.ts";
@@ -362,6 +363,15 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
362363 ctx.push(familyScreen(ctx, familyLines(view)));
363364 }),
364365 });
366 commands.push({
367 id: "chain",
368 title: "Chain",
369 hint: "the forges this forge's code descends from, root first",
370 run: () =>
371 runAsync(ctx, async () => {
372 ctx.push(await chainScreen(session.app, session.user, ctx));
373 }),
374 });
365375 commands.push({
366376 id: "quit",
367377 title: "Quit",
src/tui/screens/chain.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * The chain of forges this forge's code descends from, in a card. The gate is
3 * the page's: only someone who can read `<org>/forge` sees it, and only that
4 * repository's owner is offered Sever, which asks for the same typed word.
5 */
6
7 import type { App as Forge } from "../../app.ts";
8 import { severForge } from "../../actions/follow.ts";
9 import { canAdmin, canRead, getRepo, type Repo } from "../../core/repos.ts";
10 import type { User } from "../../core/users.ts";
11 import { chainLines, chainOfRepo, forgeLinkOf, type ChainView } from "../../spawn/chain.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import { card } from "../ui/widgets.ts";
15 import { drawFacts, promptScreen } from "./common.ts";
16 import { familyScreen } from "./family.ts";
17
18 /** The forge repository and its chain, when this viewer may read that repository. */
19 export async function readableChain(
20 app: Forge,
21 user: User | undefined,
22 ): Promise<{ repo: Repo; view: ChainView } | undefined> {
23 const link = await forgeLinkOf(app);
24 if (link === undefined) return undefined;
25 const [owner, name] = link.repo.split("/") as [string, string];
26 const repo = getRepo(app.db, owner, name);
27 if (!repo || !canRead(app.db, repo, user)) return undefined;
28 const view = await chainOfRepo(app, repo);
29 return view === undefined ? undefined : { repo, view };
30 }
31
32 export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): Promise<Screen> {
33 const found = await readableChain(app, user);
34 if (found === undefined) return familyScreen(ctx, [["chain", "this forge follows nothing across forges"]], "Chain");
35 const { repo, view } = found;
36 const rows = chainLines(view);
37 const owner = user !== undefined && view.severedAt === null && canAdmin(app.db, repo, user);
38 if (!owner) return familyScreen(ctx, rows, "Chain");
39 const self: Screen = {
40 title: "Chain",
41 keys: [binding(["s"], "sever"), binding(["esc"], "close")],
42 render(area, s) {
43 const c = ctx.theme.c;
44 const w = Math.min(78, Math.max(30, area.w - 6));
45 const h = Math.min(area.h, rows.length + 2);
46 const r = { x: area.x + ((area.w - w) >> 1), y: area.y + ((area.h - h) >> 1), w, h };
47 const inner = card(s, r, c, { title: "Chain", focused: true });
48 drawFacts(s, { x: inner.x + 1, y: inner.y, w: Math.max(0, inner.w - 1), h: inner.h }, c, rows, c.card);
49 },
50 key(e) {
51 if (e.key === "esc" || e.key === "q") ctx.pop();
52 else if (e.key === "s")
53 ctx.push(
54 promptScreen(ctx, self, {
55 title: "Sever from the parent forge",
56 label: "type sever to confirm",
57 onSubmit: (word) => {
58 if (word.trim() !== "sever") return "Type sever to confirm.";
59 return severForge(app, user, repo).then((cut) => {
60 if (!cut.ok) return ctx.toast(`Not severed: ${cut.error}`, "danger");
61 ctx.pop();
62 ctx.toast("Severed: this forge follows nothing now", "success");
63 });
64 },
65 }),
66 );
67 return true;
68 },
69 };
70 return self;
71 }
src/tui/screens/family.ts +3 −3
@@ -9,16 +9,16 @@ import { binding } from "../app/keys.ts";
99 import { card } from "../ui/widgets.ts";
1010 import { drawFacts } from "./common.ts";
1111
12 export function familyScreen(ctx: Ctx, rows: readonly (readonly [string, string])[]): Screen {
12 export function familyScreen(ctx: Ctx, rows: readonly (readonly [string, string])[], title = "Family"): Screen {
1313 return {
14 title: "Family",
14 title,
1515 keys: [binding(["esc"], "close")],
1616 render(area, s) {
1717 const c = ctx.theme.c;
1818 const w = Math.min(78, Math.max(30, area.w - 6));
1919 const h = Math.min(area.h, rows.length + 2);
2020 const r = { x: area.x + ((area.w - w) >> 1), y: area.y + ((area.h - h) >> 1), w, h };
21 const inner = card(s, r, c, { title: "Family", focused: true });
21 const inner = card(s, r, c, { title, focused: true });
2222 drawFacts(s, { x: inner.x + 1, y: inner.y, w: Math.max(0, inner.w - 1), h: inner.h }, c, rows, c.card);
2323 },
2424 key(e) {
src/tui/screens/newforge.ts +3
@@ -33,6 +33,7 @@ const SPECS: readonly Spec[] = [
3333 { key: "modelKey", label: "Model key", hint: "Sealed in the child, never shown again. Optional.", secret: true },
3434 { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." },
3535 { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` },
36 { key: "followMode", label: "Follows this forge's code", hint: "tell-me (default) or take-updates. Recorded only; nothing acts on it yet." },
3637 ];
3738
3839 /** The door's "field: reason" refusal, as the field it names and the reason. */
@@ -58,6 +59,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
5859 name: val("name"),
5960 apps,
6061 ...(val("fresh") === "" ? {} : { fresh: val("fresh") }),
62 followMode: val("followMode") === "" ? "tell-me" : val("followMode"),
6163 ...(val("for") === "" ? {} : { for: val("for") }),
6264 ...(val("direction") === "" ? {} : { direction: val("direction") }),
6365 ...(val("modelKey") === "" ? {} : { modelKey: val("modelKey") }),
@@ -107,6 +109,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
107109 const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null;
108110 plan = [
109111 `A forge at ${childUrlOf(f.app, val("name"))}.`,
112 `Recorded for now, not acted on: it follows this forge's code as ${val("followMode") === "" ? "tell-me" : val("followMode")}.`,
110113 "Whoever opens the link becomes its admin.",
111114 ...(budget === null ? [] : [`Its crew runs on your key, up to $${budget} for the key's whole life.`]),
112115 "Copied into it:",
src/web/pages/chain.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * `/:owner/forge/following`: the chain of forges this forge's own code
3 * descends from, root first, one line per hop with its follow mode and how
4 * far ahead it had gone (`spawn/chain.ts`), and the owner's one way out:
5 * sever. A severed forge says so with the date and nothing is deleted.
6 */
7
8 import { severForge } from "../../actions/follow.ts";
9 import { canAdmin } from "../../core/repos.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { chainView, forgeLinkOf, type ChainLine } from "../../spawn/chain.ts";
12 import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts";
13 import { field, readForm } from "../form.ts";
14 import { html, type Html } from "../html.ts";
15 import { flashOrJson, page, redirect } from "../layout.ts";
16 import { btn, card } from "../ui.ts";
17
18 const MODE_WORDS = { "tell-me": "tells when the parent moves", "take-updates": "takes updates" } as const;
19
20 function lineRow(l: ChainLine): Html {
outdated

new URL throws on forges the readHop regex accepts (for example http://[), and that 500s the page. Parse it in readHop instead.

21 const where = l.host === "" ? "" : ` on ${l.host}`;
22 const how =
23 l.severedAt !== null
24 ? `follows nothing since ${l.severedAt.slice(0, 10)}`
25 : l.mode === null
26 ? l.here
27 ? "follows nothing"
28 : "the root: follows nothing"
29 : MODE_WORDS[l.mode];
30 const ahead = l.ahead === null ? "" : l.ahead === 0 ? " · nothing of its own" : ` · ${l.ahead} of its own`;
31 return html`<li>
32 <div class="row">
33 <span class="row-main">
34 <span class="row-title"
35 >${l.repo}${where}${l.here ? html` <span class="lineage-here">you are here</span>` : null}</span
36 >
37 <span class="row-meta">${how}${ahead}</span>
38 </span>
39 </div>
40 </li>`;
41 }
42
43 /** Whether this repository is the forge code a spawned forge carries. */
44 async function carriesChain(ctx: Ctx): Promise<boolean> {
45 const repo = loadRepo(ctx);
46 const link = await forgeLinkOf(ctx.app);
47 return repo !== undefined && link !== undefined && link.repo === `${repo.ownerName}/${repo.name}`;
48 }
49
50 // GET /:owner/:name/following
51 export async function chainPage(ctx: Ctx): Promise<Response> {
52 const repo = loadRepo(ctx);
53 if (!repo) return notFoundPage(ctx);
54 const view = (await carriesChain(ctx)) ? await chainView(ctx.app) : undefined;
55 // Any other repository's following is the card on its home.
56 if (view === undefined) return redirect(`${repoBase(repo)}#following`);
57 const base = repoBase(repo);
58 const owner = canAdmin(ctx.app.db, repo, ctx.auth.user);
59 return page({
60 ctx,
61 title: "Following",
62 body: html`<div class="narrow-wide">
63 ${pageHead("Following", {
64 sub: "The forges this one's code descends from, root first. Numbers are as of the day each hop was made; the last is counted now.",
65 })}
66 ${card(
67 html`<ul class="list lineage" aria-label="Forges, root first">
68 ${view.lines.map(lineRow)}
69 </ul>`,
70 { pad: false },
71 )}
72 ${view.severedAt !== null
73 ? html`<p class="muted" id="severed">
74 This forge follows nothing since ${view.severedAt.slice(0, 10)}. Its code and history are
75 all still here.
76 </p>`
77 : owner
78 ? card(
79 html`<p>
80 Severing cuts this forge's link to the one above it. Nothing is deleted and
81 nothing leaves; the fork stays yours, and the tree says it follows nothing
82 from today.
83 </p>
84 <form method="post" action="${base}/following/sever" class="mt">
85 <label class="form-field"
86 ><span>Type sever to continue</span>
87 <input name="confirm" required autocomplete="off" class="mono"
88 /></label>
89 <div class="form-actions">${btn("Sever", { variant: "danger", type: "submit" })}</div>
90 </form>`,
91 { head: html`<h2>Sever</h2>` },
92 )
93 : null}
94 </div>`,
95 });
96 }
97
98 /** POST /:owner/:name/following/sever {confirm: sever}; the owner only. */
99 export async function severSubmit(ctx: Ctx): Promise<Response> {
100 const repo = loadRepo(ctx);
101 if (!repo) return notFoundPage(ctx);
102 if (!ctx.auth.user) return signInFirst(ctx);
103 const form = await readForm(ctx, "rw");
104 if (form instanceof Response) return form;
105 const to = `${repoBase(repo)}/following`;
106 if (field(form, "confirm") !== "sever")
107 return flashOrJson(ctx, to, { tone: "danger", message: "Type sever to confirm." });
108 const cut = await severForge(ctx.app, ctx.auth.user, repo);
109 if (!cut.ok)
110 return flashOrJson(ctx, to, {
111 tone: "danger",
112 message: cut.error === "forbidden" ? "Only this forge's owner can sever it." : "This repository follows nothing.",
113 });
114 return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` });
115 }
src/web/pages/family.ts +2
@@ -40,6 +40,8 @@ function standingNode(c: FamilyChild): Node {
4040 meta.push(html`${c.behind === 0 ? "up to date" : `${plural(c.behind, "release")} behind`}`);
4141 if (c.openProposals !== null)
4242 meta.push(html`${plural(c.openProposals, "open proposal")}`);
43 if (c.severedAt !== null) meta.push(html`follows nothing since ${c.severedAt.slice(0, 10)}`);

Nothing tests this rendering on /family. Add a test that the parent's page shows follows nothing since … after a severed beat, and code at <tip> before it.

44 else if (c.forgeTip !== null) meta.push(html`code at <span class="mono">${c.forgeTip.slice(0, 7)}</span>`);
4345 if (c.ownChildren !== null && c.ownChildren > 0)
4446 meta.push(html`${plural(c.ownChildren, "forge")} of its own`);
4547 }
src/web/pages/following.ts +9 −56
@@ -10,10 +10,8 @@
1010 * — it becomes the crew's proposal, or an honest sentence about why not.
1111 */
1212
13 import { DIRECTION_NOTE } from "../../crew/direct.ts";
1413 import {
1514 relationOf,
16 takeUpdates,
1715 type ForkRelation,
1816 } from "../../actions/forks.ts";
1917 import { listCrewRoles } from "../../core/agents.ts";
@@ -39,6 +37,7 @@ import {
3937 } from "../../core/parent_releases.ts";
4038 import { running } from "../../kernel/version.ts";
4139 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
40 import { convergeWords, takeOrTell } from "../../actions/follow.ts";
4241 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
4342 import { field, readForm } from "../form.ts";
4443 import { html, raw, when, type Html } from "../html.ts";
@@ -440,7 +439,7 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> {
440439 );
441440 }
442441
443 const took = await takeUpdates(ctx.app, ctx.auth.user, repo);
442 const took = await takeOrTell(ctx.app, ctx.auth.user, repo);
444443 if (!took.ok) {
445444 const e = took.error;
446445 if (typeof e === "string")
@@ -451,12 +450,16 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> {
451450 ? "This repository follows nothing."
452451 : "You need write access to take updates here.",
453452 });
453 // A conflict is a tell for the crew (`actions/follow.ts`).
454 if (e.kind === "conflict") {
455 const files = `${e.files.length === 1 ? "One file needs" : `${e.files.length} files need`} a decision (${e.files.join(", ")})`;
456 const words = "converge" in e ? convergeWords(e.converge, parentName) : { message: "" };
outdated

The conflict-to-issue tell only happens here. The API /following/take route (and the MCP tool) still answers 409 without opening the converge issue. Put this in one place both doors share.

457 return flashOrJson(ctx, base, { tone: "danger", message: `${files}. ${words.message}` });
458 }
454459 return flashOrJson(ctx, base, {
455460 tone: "danger",
456461 message:
457 e.kind === "conflict"
458 ? `${e.files.length === 1 ? "One file needs" : `${e.files.length} files need`} a decision (${e.files.join(", ")}); the crew can converge it.`
459 : e.kind === "moved"
462 e.kind === "moved"
460463 ? "Something moved while this was read; try again."
461464 : `Git could not take the updates: ${e.message}`,
462465 });
@@ -469,53 +472,3 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> {
469472 : `Took ${parentName}'s updates: ${repo.defaultBranch} is at ${took.value.to.slice(0, 7)}.`,
470473 });
471474 }
472
473 function convergeWords(
474 outcome: Converge,
475 parentName: string,
476 ): { tone?: "danger"; message: string } {
477 switch (outcome.kind) {
478 case "queued":
479 return {
480 message: `${outcome.agent.user.username} is converging with ${parentName} on #${outcome.issue.number}; the proposal comes back as a pull.`,
481 };
482 case "working":
483 return {
484 message: `The crew is already converging on #${outcome.issue.number}.`,
485 };
486 case "proposed":
487 return {
488 message: `The crew's proposal is already open: pull #${outcome.pull.number}.`,
489 };
490 case "parked":
491 return {
492 tone: "danger",
493 message: `The crew tried its limit on #${outcome.issue.number}; say there what to do.`,
494 };
495 case "nothing":
496 return {
497 message:
498 outcome.relation?.state === "diverged"
499 ? "They merge cleanly; Take updates does it without the crew."
500 : "Nothing to converge.",
501 };
502 case "no_crew":
503 return {
504 tone: "danger",
505 message:
506 "Nobody is on the crew here; add someone under Settings → Crew.",
507 };
508 case "nobody":
509 return {
510 tone: "danger",
511 message: "Nobody can open the converge issue here.",
512 };
513 case "forbidden":
514 return { tone: "danger", message: DIRECTION_NOTE };
515 case "ceiling":
516 return {
517 tone: "danger",
518 message: "The crew is at today's ceiling; it converges tomorrow.",
519 };
520 }
521 }
src/web/pages/new-forge.ts +32
@@ -34,6 +34,7 @@ interface Values {
3434 readonly apps: readonly string[];
3535 readonly crew: readonly string[];
3636 readonly fresh: string;
37 readonly followMode: string;
3738 readonly keepAccess: boolean;
3839 readonly budgetUsd: string;
3940 readonly modelEndpoint: string;
@@ -48,6 +49,7 @@ const EMPTY: Values = {
4849 apps: [],
4950 crew: [],
5051 fresh: "",
52 followMode: "tell-me",
5153 keepAccess: false,
5254 budgetUsd: "",
5355 modelEndpoint: "",
@@ -176,6 +178,34 @@ function formView(
176178 hint: `One line on what it is; the child gets a small app to start from (at most ${MAX_FRESH_CHARS} characters).`,
177179 ...(errors["fresh"] ? { error: errors["fresh"] } : {}),
178180 })}
181 <fieldset class="field" ${errors["followMode"] ? html`data-invalid` : null}>
182 <legend>How it follows this forge's code (recorded for now; nothing acts on it yet)</legend>
183 <label class="check">
184 <input
185 type="radio"
186 name="followMode"
187 value="tell-me"
188 ${values.followMode !== "take-updates" ? html`checked` : null}
189 />
190 <span
191 >Tell me <span class="muted small">is recorded as your choice; nothing tells you yet.</span></span
outdated

Nothing tells anyone when this forge changes; copy overpromises. Say it is only recorded for now.

192 >
193 </label>
194 <label class="check">
195 <input
196 type="radio"
197 name="followMode"
198 value="take-updates"
199 ${values.followMode === "take-updates" ? html`checked` : null}
200 />
201 <span

Nothing takes updates automatically yet (mode is not acted on until #1052). Reword to the truth so the choice is not a false promise.

202 >Take updates <span class="muted small">is recorded as your choice; nothing takes updates automatically yet.</span></span
203 >
204 </label>
205 ${errors["followMode"]
206 ? html`<span class="error" id="f-followMode-err">${errors["followMode"]}</span>`
207 : null}
208 </fieldset>
179209 ${crew.length === 0
180210 ? null
181211 : html`<fieldset class="field">
@@ -360,6 +390,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
360390 apps: all(form, "apps"),
361391 crew: all(form, "crew"),
362392 fresh: field(form, "fresh").trim(),
393 followMode: field(form, "followMode").trim() || "tell-me",
363394 keepAccess: asBool(form, "keepAccess"),
364395 budgetUsd: field(form, "budgetUsd").trim(),
365396 modelEndpoint: field(form, "modelEndpoint").trim(),
@@ -373,6 +404,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
373404 crew: values.crew,
374405 keepAccess: values.keepAccess,
375406 ...(values.fresh === "" ? {} : { fresh: values.fresh }),
407 followMode: values.followMode,
376408 ...(values.forWhom === "" ? {} : { for: values.forWhom }),
377409 ...(values.direction.trim() === "" ? {} : { direction: values.direction }),
378410 ...(key === "" ? {} : { modelKey: key }),
test/family-heartbeat.test.ts +17
@@ -309,4 +309,21 @@ describe("family heartbeats", () => {
309309 // A forge that was not spawned has no one to tell.
310310 expect(await sendBeat(parent, async () => new Response("{}"))).toBe(false);
311311 });
312
313 test("the parent's /family says where a child's code is, and when it stopped following", async () => {
314 const tip = "0123456789abcdef0123456789abcdef01234567";
315 const send = async (o: Record<string, unknown>) => {
316 const body = beat({ at: Date.now() + 1000, ...o });
317 expect((await post(body, sign(key, body))).status).toBeLessThan(300);
318 };
319 await send({ forgeTip: tip });
320 const before = (await family(adminCookie)).replace(/\s+/g, " ");
321 expect(before).toContain("code at");
322 expect(before).toContain("0123456");
323 expect(before).not.toContain("follows nothing since");
324 await send({ forgeTip: tip, severedAt: "2030-02-03T04:05:06.000Z", at: Date.now() + 2000 });
325 const after = (await family(adminCookie)).replace(/\s+/g, " ");
326 expect(after).toContain("follows nothing since 2030-02-03");
327 expect(after).not.toContain("code at");
328 });
312329 });
test/following.test.ts +39 −3
@@ -236,9 +236,9 @@ describe("a fork that follows its parent", () => {
236236 headers: as(bobToken),
237237 });
238238 expect(res.status).toBe(409);
239 expect(((await res.json()) as { files: string[] }).files).toEqual([
240 "README.md",
241 ]);
239 const said = (await res.json()) as { files: string[]; converge: string | null };
240 expect(said.files).toEqual(["README.md"]);
241 expect(said.converge).toBe("no_crew");
242242 const page = await fetch(`${base}/bob/game`, {
243243 headers: { authorization: `Bearer ${bobToken}`, accept: "text/html" },
244244 }).then((p) => p.text());
@@ -382,6 +382,42 @@ describe("a fork that follows its parent", () => {
382382 expect(prompt).toContain("Never drop this repository's own work");
383383 });
384384
385 test("taking updates into a conflict tells the crew: the same issue, never a second", async () => {
386 const issue = listIssues(app.db, fork.id, { state: "open" }).find(
387 (i) => i.title === convergeTitle(fresh(parent)),
388 );
389 expect(issue).toBeDefined();
390 const before = listIssues(app.db, fork.id, { state: "open" }).length;
391 const res = await fetch(`${base}/bob/game/following`, {
392 method: "POST",
393 headers: {
394 authorization: `Bearer ${bobToken}`,
395 "content-type": "application/x-www-form-urlencoded",
396 accept: "application/json",
397 },
398 body: "do=take",
399 });
400 expect(res.status).toBe(422);
401 const said = ((await res.json()) as { error: string }).error;
402 expect(said).toContain("README.md");
403 expect(said).toContain(`#${issue!.number}`);
404 expect(listIssues(app.db, fork.id, { state: "open" }).length).toBe(before);
405 });
406
407 test("the API's take tells the crew the same way: the converge issue is named", async () => {
408 const issue = listIssues(app.db, fork.id, { state: "open" }).find(
409 (i) => i.title === convergeTitle(fresh(parent)),
410 );
411 const res = await fetch(`${base}/api/v1/repos/bob/game/following/take`, {
412 method: "POST",
413 headers: as(bobToken),
414 });
415 expect(res.status).toBe(409);
416 const said = (await res.json()) as { issue: number | null; converge: string };
417 expect(said.converge).toBe("working");
418 expect(said.issue).toBe(issue!.number);
419 });
420
385421 test("a fork with autonomy on converges by itself when its parent moves", async () => {
386422 // A second follower, with a crew and autonomy on, and its own README.
387423 const c = await createUser(app.db, { trusted: true,
test/mcp.test.ts +1
@@ -184,6 +184,7 @@ describe("the forge over MCP", () => {
184184 "forges",
185185 "claim_link",
186186 "take_updates",
187 "sever",
187188 "converge",
188189 "family",
189190 "next_steps",
test/new-forge.test.ts +31
@@ -259,6 +259,37 @@ describe("+ New forge", () => {
259259 expect(await again.text()).not.toMatch(/claim#[A-Za-z0-9_-]{43}/);
260260 });
261261
262 test("the form offers how the child follows the forge's code, tell-me by default, and take-updates is recorded", async () => {
263 const html = (await (await get(admin, "/forges/new")).text()).replace(/\s+/g, " ");
264 expect(html).toMatch(/name="followMode" value="tell-me" checked/);
265 expect(html).toContain('value="take-updates"');
266 (app.config as { forgeRepo?: string }).forgeRepo = "alice/bakery";
267 try {
268 const res = await post(admin, [
269 ["name", "follower"],
270 ["followMode", "take-updates"],
271 ["do", "spawn"],
272 ]);
273 expect(res.status).toBe(201);
274 const docs = await Promise.all(
275 (await readdir(join(dir, "children"))).map((d) =>
276 Bun.file(join(dir, "children", d, "parent.json")).json().catch(() => undefined),
277 ),
278 );
279 const mine = docs.find((d) => d?.name === "follower");
280 expect(mine?.forgeCode?.mode).toBe("take-updates");
281 const bad = await post(admin, [
282 ["name", "wrongmode"],
283 ["followMode", "yolo"],
284 ["do", "plan"],
285 ]);
286 expect(bad.status).toBe(422);
287 expect(await bad.text()).toContain("must be tell-me or take-updates");
288 } finally {
289 delete (app.config as { forgeRepo?: string }).forgeRepo;
290 }
291 });
292
262293 test("a forge at its depth limit shows no door, and its page says why", async () => {
263294 usePlacement(app, hostedPlacement({ host: "forge.test", depth: 2 }));
264295 expect(await (await get(admin, "/")).text()).not.toContain('href="/forges/new"');
test/spawn-anon.test.ts +3 −1
@@ -140,7 +140,9 @@ describe("anonymous spawn", () => {
140140 const parent = (await Bun.file(
141141 join(childDirOf(app, body.bodyId), "parent.json"),
142142 ).json()) as any;
143 expect(parent.forgeCode).toEqual({ repo: "kid/forge", upstream: "alice/forge-code" });
143 expect(parent.forgeCode).toEqual(
144 expect.objectContaining({ repo: "kid/forge", upstream: "alice/forge-code", mode: "tell-me" }),
145 );
144146 } finally {
145147 child.close();
146148 }
test/spawn-chain-doors.test.ts added +194
@@ -0,0 +1,194 @@
1 /**
2 * The chain's other doors (#1085): MCP `sever` and the chain in `following`,
3 * and the terminal's Chain card, which shows it only to someone who can read
4 * the forge repository and offers Sever to its owner alone.
5 */
6
7 import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { getRepo, updateRepoSettings } from "../src/core/repos.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, getUserByName, type User } from "../src/core/users.ts";
17 import { repoDir } from "../src/git/repos.ts";
18 import { commitFiles } from "../src/git/write.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { forgeLinkOf } from "../src/spawn/chain.ts";
21 import { hostedPlacement } from "../src/spawn/placement.ts";
22 import { createForgeApp } from "../src/tui/forge.ts";
23 import type { TuiIdentity } from "../src/tui/session.ts";
24 import { headlessTerminal } from "../src/tui/term/terminal.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26 import { settleApp, settleUntil } from "./helpers/tui.ts";
27
28 quietLogs();
29
30 describe("the chain at the MCP and terminal doors", () => {
31 let dir = "";
32 let root: App;
33 let child: App;
34 let servers: Servers;
35 let alice: User;
36 let reader: User;
37 let rw = "";
38 let ro = "";
39 let readerRw = "";
40 let id = 1;
41
42 const tool = async (token: string, name: string, args: unknown) => {
43 const res = await fetch(`${servers.url}/mcp`, {
44 method: "POST",
45 headers: {
46 "content-type": "application/json",
47 accept: "application/json, text/event-stream",
48 authorization: `Bearer ${token}`,
49 },
50 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }),
51 });
52 const body = (await res.json()) as any;
53 if (body.error !== undefined) return { error: String(body.error.message) };
54 const text: string = body.result.content[0].text;
55 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
56 };
57
58 const boot = (user: User | undefined) => {
59 const identity: TuiIdentity = { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", user };
60 const { app } = createForgeApp(child, identity, { terminal: headlessTerminal(120, 30), exitHooks: false });
61 app.start();
62 const press = async (...keys: string[]) => {
63 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
64 await settleApp(app);
65 };
66 const openChain = async () => {
67 await settleApp(app);
68 await press(":");
69 for (const ch of "Chain") await press(ch);
70 await press("enter");
71 await settleApp(app);
72 };
73 return { app, press, openChain, text: () => app.screen.toText() };
74 };
75
76 beforeAll(async () => {
77 dir = await mkdtemp(join(tmpdir(), "forge-chain-doors-"));
78 root = createApp({ dataDir: dir, url: "https://root.test", port: 0, forgeRepo: "alice/forge-code" });
79 const a = await createUser(root.db, { trusted: true, username: "alice", email: "[email protected]", isAdmin: true });
80 if (!a.ok) throw new Error("user");
81 const made = await createRepo(root, a.value, { name: "forge-code", visibility: "public" });
82 if (!made.ok) throw new Error("repo");
83 const c = await commitFiles(repoDir(root.reposRoot, "alice", "forge-code"), {
84 branch: "main",
85 message: "forge v1",
86 files: [{ path: "README.md", content: "# forge\n" }],
87 author: { name: "a", email: "[email protected]" },
88 });
89 if (!c.ok) throw new Error("commit");
90 const spawned = await spawnForge(
91 root,
92 a.value,
93 { name: "kid", org: "kid", apps: [], carryBriefs: false, carrySubscriptions: false },
94 { catalog: [], briefs: [], subscriptions: [] },
95 {
96 placement: hostedPlacement({ host: "root.test" }),
97 request: new Request("https://root.test/x", { method: "POST", headers: { origin: "https://root.test" } }),
98 url: new URL("https://root.test/x"),
99 },
100 );
101 if (!spawned.ok) throw new Error(JSON.stringify(spawned.error));
102 child = createApp({ dataDir: childDirOf(root, spawned.value.bodyId), url: "https://kid.test", port: 0 });
103 servers = await serve(child);
104 alice = getUserByName(child.db, "alice")!;
105 const r = await createUser(child.db, { trusted: true, username: "reader", email: "[email protected]" });
106 if (!r.ok) throw new Error("reader");
107 reader = r.value;
108 rw = createToken(child.db, alice.id, "rw", "rw").token;
109 ro = createToken(child.db, alice.id, "ro", "ro").token;
110 readerRw = createToken(child.db, reader.id, "rr", "rw").token;
111 }, 60_000);
112
113 // Every test starts from a followed forge with a public forge repository.
114 beforeEach(async () => {
115 const file = join(child.config.dataDir, "parent.json");
116 const doc = JSON.parse(await Bun.file(file).text());
117 delete doc.forgeCode.severedAt;
118 await Bun.write(file, JSON.stringify(doc));
119 updateRepoSettings(child.db, getRepo(child.db, "kid", "forge")!, { visibility: "public" });
120 });
121
122 afterAll(async () => {
123 servers.stop();
124 child.close();
125 root.close();
126 await rm(dir, { recursive: true, force: true });
127 });
128
129 test("MCP following carries the chain, root first", async () => {
130 const got = await tool(rw, "following", { repo: "kid/forge" });
131 expect(got.value.chain.map((l: any) => l.repo)).toEqual(["alice/forge-code", "kid/forge"]);
132 expect(got.value.severedAt).toBeNull();
133 });
134
135 test("the terminal shows the chain to a reader, and Sever only to the owner", async () => {
136 const guest = boot(undefined);
137 await guest.openChain();
138 expect(guest.text()).toContain("root.test");
139 expect(guest.text()).not.toContain("sever");
140 guest.app.stop();
141
142 const other = boot(reader);
143 await other.openChain();
144 expect(other.text()).toContain("kid.test");
145 await other.press("s");
146 expect(other.text()).not.toContain("type sever");
147 other.app.stop();
148 expect((await forgeLinkOf(child))?.severedAt).toBeUndefined();
149 });
150
151 test("a guest on the terminal sees nothing of a private forge repository", async () => {
152 const repo = getRepo(child.db, "kid", "forge")!;
153 updateRepoSettings(child.db, repo, { visibility: "private" });
154 const guest = boot(undefined);
155 await guest.openChain();
156 expect(guest.text()).not.toContain("root.test");
157 expect(guest.text()).not.toContain("kid.test");
158 guest.app.stop();
159 const owner = boot(alice);
160 await owner.openChain();
161 expect(owner.text()).toContain("root.test");
162 owner.app.stop();
163 });
164
165 test("MCP sever refuses a reader and a read-only token, and changes nothing", async () => {
166 expect((await tool(readerRw, "sever", { repo: "kid/forge" })).error).toBeDefined();
167 expect((await tool(ro, "sever", { repo: "kid/forge" })).error).toBeDefined();
168 expect((await forgeLinkOf(child))?.severedAt).toBeUndefined();
169 });
170
171 test("the terminal's owner types the word to sever; a wrong word does nothing", async () => {
172 const owner = boot(alice);
173 await owner.openChain();
174 await owner.press("s");
175 for (const ch of "nope") await owner.press(ch);
176 await owner.press("enter");
177 expect(owner.text()).toContain("Type sever to confirm");
178 expect((await forgeLinkOf(child))?.severedAt).toBeUndefined();
179 for (const _ of "nope") await owner.press("backspace");
180 for (const ch of "sever") await owner.press(ch);
181 await owner.press("enter");
182 await settleUntil(owner.app, "Severed");
183 expect((await forgeLinkOf(child))?.severedAt).toBeDefined();
184 owner.app.stop();
185 });
186
187 test("MCP sever by the owner is idempotent and says when", async () => {
188 const first = await tool(rw, "sever", { repo: "kid/forge" });
189 const before = first.value.severedAt;
190 const got = await tool(rw, "sever", { repo: "kid/forge" });
191 expect(got.value.severedAt).toBe(before);
192 expect((await tool(rw, "following", { repo: "kid/forge" })).value.severedAt).toBe(before);
193 });
194 });
test/spawn-chain.test.ts added +236
@@ -0,0 +1,236 @@
1 /**
2 * The chain of forges (#1085): a child carries a fork of its parent's forge
3 * code with the follow mode its spawner chose; a grandchild's fork names the
4 * child's; the chain reads the same from both ends; severing cuts the link
5 * and nothing else; the heartbeat tells the parent.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { getRepo } from "../src/core/repos.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createSession } from "../src/core/sessions.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { runGit } from "../src/git/env.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { commitFiles } from "../src/git/write.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { chainLines, chainView, forgeLinkOf, severChain } from "../src/spawn/chain.ts";
24 import { parseBeat, sendBeat, standing } from "../src/spawn/heartbeat.ts";
25 import { planSpawn } from "../src/spawn/plan.ts";
26 import { hostedPlacement } from "../src/spawn/placement.ts";
27 import { quietLogs } from "./helpers/globals.ts";
28
29 quietLogs();
30
31 const author = { name: "a", email: "[email protected]" };
32 const source = { catalog: [], briefs: [], subscriptions: [] };
33
34 const spawn = (app: App, owner: any, name: string, host: string, followMode?: string) =>
35 spawnForge(
36 app,
37 owner,
38 { name, org: name, apps: [], carryBriefs: false, carrySubscriptions: false, ...(followMode ? { followMode } : {}) },
39 source,
40 {
41 placement: hostedPlacement({ host }),
42 request: new Request(`https://${host}/x`, { method: "POST", headers: { origin: `https://${host}` } }),
43 url: new URL(`https://${host}/x`),
44 },
45 );
46
47 describe("the chain of forges", () => {
48 let dir = "";
49 let root: App;
50 let child: App;
51 let grand: App;
52 let servers: Servers;
53 let cookie = "";
54
55 beforeAll(async () => {
56 dir = await mkdtemp(join(tmpdir(), "forge-chain-"));
57 root = createApp({ dataDir: dir, url: "https://root.test", port: 0, forgeRepo: "alice/forge-code" });
58 const alice = await createUser(root.db, { trusted: true, username: "alice", email: "[email protected]", isAdmin: true });
59 if (!alice.ok) throw new Error("user");
60 const made = await createRepo(root, alice.value, { name: "forge-code", visibility: "public" });
61 if (!made.ok) throw new Error("repo");
62 const c = await commitFiles(repoDir(root.reposRoot, "alice", "forge-code"), {
63 branch: "main",
64 message: "forge v1",
65 files: [{ path: "README.md", content: "# forge\n" }],
66 author,
67 });
68 if (!c.ok) throw new Error("commit");
69
70 const a = await spawn(root, alice.value, "kid", "root.test", "take-updates");
71 if (!a.ok) throw new Error(JSON.stringify(a.error));
72 child = createApp({ dataDir: childDirOf(root, a.value.bodyId), url: "https://kid.test", port: 0 });
73 // The child's owner changes their forge all they want.
74 const own = await commitFiles(repoDir(child.reposRoot, "kid", "forge"), {
75 branch: "main",
76 message: "mine",
77 files: [{ path: "NOTES.md", content: "mine\n" }],
78 author,
79 });
80 if (!own.ok) throw new Error("own commit");
81
82 const founder = getUserByName(child.db, "alice")!;
83 const b = await spawn(child, founder, "tot", "kid.test");
84 if (!b.ok) throw new Error(JSON.stringify(b.error));
85 grand = createApp({ dataDir: childDirOf(child, b.value.bodyId), port: 0 });
86 servers = await serve(grand);
87 const owner = getUserByName(grand.db, "alice")!;
88 cookie = `forge_session=${createSession(grand.db, owner.id).token}`;
89 }, 60_000);
90
91 afterAll(async () => {
92 servers.stop();
93 grand.close();
94 child.close();
95 root.close();
96 await rm(dir, { recursive: true, force: true });
97 });
98
99 test("followMode is validated and defaults to tell-me", () => {
100 const base = { name: "x", org: "x", apps: [], carryBriefs: false, carrySubscriptions: false };
101 expect(planSpawn(base, source).ok).toBe(true);
102 const bad = planSpawn({ ...base, followMode: "yolo" }, source);
103 expect(bad.ok).toBe(false);
104 const grandLink = planSpawn({ ...base, followMode: "tell-me" }, source);
105 expect(grandLink.ok && grandLink.value.followMode).toBeUndefined();
106 });
107
108 test("the child carries a fork of the parent's forge code in the chosen mode", async () => {
109 const link = await forgeLinkOf(child);
110 expect(link?.repo).toBe("kid/forge");
111 expect(link?.upstream).toBe("alice/forge-code");
112 expect(link?.mode).toBe("take-updates");
113 expect(link?.chain.map((h) => h.repo)).toEqual(["alice/forge-code"]);
114 expect(getRepo(child.db, "kid", "forge")).toBeDefined();
115 });
116
117 test("the grandchild's fork names the child's forge, mode tell-me by default", async () => {
118 const link = await forgeLinkOf(grand);
119 expect(link?.upstream).toBe("kid/forge");
120 expect(link?.mode).toBe("tell-me");
121 const log = await runGit(["log", "--oneline"], { cwd: repoDir(grand.reposRoot, "tot", "forge") });
122 expect(log.ok && log.value).toContain("mine");
123 });
124
125 test("the chain reads to the root from the grandchild, and from the child", async () => {
126 const g = await chainView(grand);
127 expect(g?.lines.map((l) => l.repo)).toEqual(["alice/forge-code", "kid/forge", "tot/forge"]);
128 expect(g?.lines.map((l) => l.mode)).toEqual([null, "take-updates", "tell-me"]);
129 expect(g?.lines[1]!.ahead).toBe(1);
130 expect(g?.lines[2]!.here).toBe(true);
131 const c = await chainView(child);
132 expect(c?.lines.map((l) => l.repo)).toEqual(["alice/forge-code", "kid/forge"]);
133 expect(await chainView(root)).toBeUndefined();
134
135 const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
136 expect(page).toContain("alice/forge-code");
137 expect(page).toContain("kid/forge");
138 expect(page).toContain("takes updates");
139 expect(page).toContain("tells when the parent moves");
140 expect(page).toContain("1 of its own");
141 });
142
143 test("the heartbeat carries the forge's tip", async () => {
144 const beat = JSON.parse(JSON.stringify(standing(grand, "tot")));
145 expect(beat.forgeTip ?? null).toBeNull(); // standing() is counts; the sent body adds the tip
146 let sent = "";
147 await sendBeat(grand, (async (_u: string, init?: RequestInit) => {
148 sent = String(init?.body);
149 return new Response("{}");
150 }) as any);
151 const parsed = parseBeat(sent);
152 expect(parsed?.forgeTip).toMatch(/^[0-9a-f]{40,64}$/);
153 expect(parsed?.severedAt).toBeNull();
154 expect(parseBeat(sent.replace(/"forgeTip":"[^"]*"/, '"forgeTip":"<x>"'))).toBeUndefined();
155 });
156
157 test("the API reads the chain, and only the owner severs it there", async () => {
158 const owner = getUserByName(grand.db, "alice")!;
159 const bearer = (id: string) => ({ authorization: `Bearer ${createToken(grand.db, id, "t", "rw").token}` });
160 const got = (await (await fetch(`${servers.url}/api/v1/repos/tot/forge/following`, { headers: bearer(owner.id) })).json()) as any;
161 expect(got.chain.map((l: any) => l.repo)).toEqual(["alice/forge-code", "kid/forge", "tot/forge"]);
162 expect(got.severedAt).toBeNull();
163 const reader = await createUser(grand.db, { trusted: true, username: "apireader", email: "[email protected]" });
164 if (!reader.ok) throw new Error("reader");
165 const denied = await fetch(`${servers.url}/api/v1/repos/tot/forge/following/sever`, { method: "POST", headers: bearer(reader.value.id) });
166 expect(denied.status).toBeGreaterThanOrEqual(401);
167 expect((await forgeLinkOf(grand))?.severedAt).toBeUndefined();
168 const other = await fetch(`${servers.url}/api/v1/repos/tot/forge/following/sever`, { method: "POST" });
169 expect(other.status).toBeGreaterThanOrEqual(401);
170 expect(chainLines((await chainView(grand))!).map(([k]) => k)).toEqual(["alice/forge-code", "kid/forge", "tot/forge (here)"]);
171 });
172
173 test("a hop with an address URL rejects does not break the page", async () => {
174 const file = join(grand.config.dataDir, "parent.json");
175 const doc = JSON.parse(await Bun.file(file).text());
176 doc.forgeCode.chain[0].forge = "http://[";
177 await Bun.write(file, JSON.stringify(doc));
178 const res = await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } });
179 expect(res.status).toBe(200);
180 expect(await res.text()).toContain("alice/forge-code");
181 });
182
183 test("sever needs the owner, a same-origin post and the word; it deletes nothing", async () => {
184 const url = `${servers.url}/tot/forge/following/sever`;
185 const body = (confirm: string) => new URLSearchParams({ confirm });
186 const anon = await fetch(url, { method: "POST", headers: { origin: servers.url }, body: body("sever"), redirect: "manual" });
187 expect(anon.status).toBeGreaterThanOrEqual(300);
188 expect((await forgeLinkOf(grand))?.severedAt).toBeUndefined();
189 const cross = await fetch(url, { method: "POST", headers: { cookie, origin: "https://evil.test" }, body: body("sever"), redirect: "manual" });
190 expect(cross.status).toBeGreaterThanOrEqual(400);
191 const reader = await createUser(grand.db, { trusted: true, username: "reader", email: "[email protected]" });
192 if (!reader.ok) throw new Error("reader");
193 const theirs = `forge_session=${createSession(grand.db, reader.value.id).token}`;
194 await fetch(url, { method: "POST", headers: { cookie: theirs, origin: servers.url }, body: body("sever"), redirect: "manual" });
195 expect((await forgeLinkOf(grand))?.severedAt).toBeUndefined();
196 await fetch(url, { method: "POST", headers: { cookie, origin: servers.url }, body: body("nope"), redirect: "manual" });
197 expect((await forgeLinkOf(grand))?.severedAt).toBeUndefined();
198
199 const ok = await fetch(url, { method: "POST", headers: { cookie, origin: servers.url }, body: body("sever"), redirect: "manual" });
200 expect(ok.status).toBe(303);
201 const link = await forgeLinkOf(grand);
202 expect(link?.severedAt).toBeDefined();
203 expect(link?.upstream).toBe("kid/forge");
204 // Nothing deleted: the fork and its history are there.
205 expect(getRepo(grand.db, "tot", "forge")).toBeDefined();
206 const log = await runGit(["log", "--oneline"], { cwd: repoDir(grand.reposRoot, "tot", "forge") });
207 expect(log.ok && log.value).toContain("forge v1");
208 const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
209 expect(page).toContain("follows nothing since");
210 // Again changes nothing and keeps the first date.
211 const again = await severChain(grand, new Date("2030-01-01"));
212 expect(again.ok && again.value).toBe(link!.severedAt!);
213 });
214
215 test("the API severs for the owner, again changing nothing", async () => {
216 const owner = getUserByName(grand.db, "alice")!;
217 const before = (await forgeLinkOf(grand))!.severedAt!;
218 const res = await fetch(`${servers.url}/api/v1/repos/tot/forge/following/sever`, {
219 method: "POST",
220 headers: { authorization: `Bearer ${createToken(grand.db, owner.id, "t2", "rw").token}` },
221 });
222 expect(res.status).toBe(200);
223 expect(((await res.json()) as any).severedAt).toBe(before);
224 const got = (await (await fetch(`${servers.url}/api/v1/repos/tot/forge/following`, { headers: { cookie } })).json()) as any;
225 expect(got.severedAt).toBe(before);
226 });
227
228 test("a severed forge says so in its beat", async () => {
229 let sent = "";
230 await sendBeat(grand, (async (_u: string, init?: RequestInit) => {
231 sent = String(init?.body);
232 return new Response("{}");
233 }) as any);
234 expect(parseBeat(sent)?.severedAt).toBe((await forgeLinkOf(grand))!.severedAt!);
235 });
236 });
test/tui-newforge.test.ts +20
@@ -158,4 +158,24 @@ describe("the new forge screen", () => {
158158 expect(await children()).toBe(before + 1);
159159 expect(tui.screen.toText()).toMatch(/https:\/\/zed--forge\.test\/claim#/);
160160 }));
161
162 test("the follow mode is a field: take-updates reaches parent.json", () =>
163 withScreen(admin, async ({ tui, terminal }) => {
164 (app.config as { forgeRepo?: string }).forgeRepo = "alice/bakery";
165 try {
166 terminal.feed("tuifollow" + "\t".repeat(8) + "take-updates\x10");
167 await settleApp(tui);
168 expect(tui.screen.toText()).toContain("take-updates");
169 terminal.feed("\r");
170 await settleApp(tui);
171 const docs = await Promise.all(
172 (await readdir(join(dir, "children"))).map((d) =>
173 Bun.file(join(dir, "children", d, "parent.json")).json().catch(() => undefined),
174 ),
175 );
176 expect(docs.find((d) => d?.name === "tuifollow")?.forgeCode?.mode).toBe("take-updates");
177 } finally {
178 delete (app.config as { forgeRepo?: string }).forgeRepo;
179 }
180 }));
161181 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs