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

A spawned forge fetches its parent's code every hour and its chain page says how far behind it is #2637

Merged opened this

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

Merged

scout/forge:scout/2631-a-spawned-forge-fetches-its-parent-s-cod → main 5 commits fork 9 threads · 9 unresolved

  • Merged. scout/2631-a-spawned-forge-fetches-its-parent-s-cod was merged into main as 6d4548b.

Review

Sign in to review this change.
13 files changed +403 −12
README.md +8 −1
@@ -381,7 +381,14 @@ taking a release is not done here. This forge publishes its own
381381 forge can be a parent. The terminal shows the count, not the release names.
382382
383383 A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen
384 `followMode` `tell-me` or `take-updates`; recorded, not yet acted on). Its chain
384 `followMode` `tell-me` or `take-updates`; recorded: the mode does not yet apply anything). Once an hour, and
385 on demand, a forge that follows and has not severed fetches its parent's code into
386 `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps
387 `{ behind, parentTip, checkedAt, error }` in `upstream.json` under `FORGE_APP_DATA`;
388 the count shows on the chain page, in the terminal's **Chain** (`c` checks now), and as
389 `upstream` in the API and MCP payloads. `POST /:owner/forge/following/check` (a writer,
390 same-origin) checks on demand; a fetch is cut at 60 s and a failure is recorded, not
391 thrown. Its chain
385392 to the root is at `/:owner/forge/following`, in `chain` of `GET
386393 /api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the
387394 terminal's **Chain** command; the owner severs it with the page's Sever,
src/http/api.ts +3 −1
@@ -8,6 +8,7 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { upstreamPayload } from "../spawn/upstream.ts";
1112 import { setRole } from "../actions/roles.ts";
1213 import { listPeople, NOT_A_MEMBER } from "../core/roles.ts";
1314 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
@@ -2283,7 +2284,7 @@ export function apiRoutes(router: Router<Handler>): void {
22832284 if (relation === undefined)
22842285 return chain === undefined
22852286 ? apiError("not_a_fork", 404, "this repository follows nothing")
2286 : json({ chain: chain.lines, severedAt: chain.severedAt });
2287 : json({ chain: chain.lines, severedAt: chain.severedAt, upstream: await upstreamPayload(ctx.app) });
22872288 const converge =
22882289 relation.state === "diverged" && !relation.clean
22892290 ? convergeState(ctx.app, repo, relation.parent)
@@ -2291,6 +2292,7 @@ export function apiRoutes(router: Router<Handler>): void {
22912292 return json({
22922293 chain: chain?.lines ?? null,
22932294 severedAt: chain?.severedAt ?? null,
2295 upstream: chain === undefined ? null : await upstreamPayload(ctx.app),
22942296 parent: `${relation.parent.ownerName}/${relation.parent.name}`,
22952297 parentBranch: relation.parent.defaultBranch,
22962298 // A parent whose origin was disconnected: the counts below are against
src/http/mcp.ts +3 −1
@@ -17,6 +17,7 @@
1717 * no page. `ro` tokens read; `rw` tokens act.
1818 */
1919
20 import { upstreamPayload } from "../spawn/upstream.ts";
2021 import { NOT_A_MEMBER } from "../core/roles.ts";
2122 import { voteVariant } from "../actions/variants.ts";
2223 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
@@ -1395,7 +1396,7 @@ const TOOLS: readonly Tool[] = [
13951396 if (relation === undefined)
13961397 return chain === undefined
13971398 ? bad("not_a_fork", "this repository follows nothing")
1398 : good({ chain: chain.lines, severedAt: chain.severedAt });
1399 : good({ chain: chain.lines, severedAt: chain.severedAt, upstream: await upstreamPayload(ctx.app) });
13991400 const converge =
14001401 relation.state === "diverged" && !relation.clean
14011402 ? convergeState(ctx.app, repo, relation.parent)
@@ -1403,6 +1404,7 @@ const TOOLS: readonly Tool[] = [
14031404 return good({
14041405 chain: chain?.lines ?? null,
14051406 severedAt: chain?.severedAt ?? null,
1407 upstream: chain === undefined ? null : await upstreamPayload(ctx.app),
14061408 parent: `${relation.parent.ownerName}/${relation.parent.name}`,
14071409 state: relation.state,
14081410 behind: relation.behind,
src/http/server.ts +2 −1
@@ -128,7 +128,7 @@ import {
128128 signinPasskey,
129129 } from "../web/pages/passkeys.ts";
130130 import { confirmPage, searchPage } from "../web/pages/search.ts";
131 import { chainPage, modeSubmit, proposeParentSubmit, severSubmit } from "../web/pages/chain.ts";
131 import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit } from "../web/pages/chain.ts";
132132 import { followingSubmit } from "../web/pages/following.ts";
133133 import { reconnectSubmit } from "../web/pages/frozen.ts";
134134 import { activityAtom } from "../web/feeds.ts";
@@ -1274,6 +1274,7 @@ export function buildRouter(): Router<Handler> {
12741274 router.add("POST", "/:owner/:name/following/sever", severSubmit);
12751275 router.add("POST", "/:owner/:name/following/mode", modeSubmit);
12761276 router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit);
1277 router.add("POST", "/:owner/:name/following/check", checkSubmit);
12771278 // A mirror pulls on demand; a pin says "keep this on my machine". Both are
12781279 // ordinary repo-scoped writes, not account-level ones.
12791280 router.add("POST", "/:owner/:name/sync", repoSyncSubmit);
src/main.ts +4
@@ -20,6 +20,7 @@ import { syncAllPillars } from "./actions/pillars.ts";
2020 import { bootHostedChildren } from "./spawn/host.ts";
2121 import { firstBoot } from "./spawn/first-boot.ts";
2222 import { startHeartbeat } from "./spawn/heartbeat.ts";
23 import { startUpstreamWatch } from "./spawn/upstream.ts";
2324 import { pruneSessions } from "./core/sessions.ts";
2425 import { runDelivery } from "./core/webhooks.ts";
2526 import { isHookName, runHookClient } from "./git/hooks.ts";
@@ -406,6 +407,8 @@ void bootHostedChildren(app);
406407 await firstBoot(app);
407408 // A spawned child reports its standing to its parent (spawn/heartbeat.ts).
408409 const heartbeatTimer = startHeartbeat(app);
410 // ...and looks upstream once an hour (spawn/upstream.ts).
411 const upstreamTimer = startUpstreamWatch(app);
409412 // Prompts from Instagram (docs/APPS.md): once a day, for each repository
410413 // that connected an account, the top comment on its latest Reel is told to
411414 // the crew. A minute's clock; nothing leaves the box until a source exists.
@@ -595,6 +598,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
595598 drainStreams();
596599 clearInterval(deliveryTimer);
597600 clearInterval(heartbeatTimer);
601 clearInterval(upstreamTimer);
598602 clearInterval(syncTimer);
599603 clearInterval(staleTimer);
600604 clearInterval(leaseTimer);
src/spawn/upstream.ts added +160
@@ -0,0 +1,160 @@
1 /**
2 * Looking upstream: a forge that follows fetches its parent's code into
3 * `refs/upstream/*` of its own `<org>/forge` repository and keeps how far
4 * behind it is in `<data>/upstream.json`. Fetching changes no branch of
5 * the fork; taking the commits stays the owner's press.
6 *
7 * The remote is built here from two validated parts, the parent's origin
8 * (`originOf`) and its repository (the `REPO` pattern in `chain.ts`), so no
9 * text a person typed reaches the command line. A failure, a hang cut at
10 * 60 s included, is recorded and never thrown: the scheduler cannot die of it.
11 */
12
13 import { rename } from "node:fs/promises";
14 import { join } from "node:path";
15 import type { App } from "../app.ts";
16 import { getRepo } from "../core/repos.ts";
17 import { runGit } from "../git/env.ts";
18 import { repoDir } from "../git/repos.ts";
19 import { forgeLinkOf, originOf } from "./chain.ts";
20
21 export const CHECK_MS = 60 * 60_000;
22 export const FETCH_TIMEOUT_MS = 60_000;
23 const TIP = /^[0-9a-f]{40,64}$/;
24
25 export interface UpstreamState {
26 /** Commits the parent has that this fork does not; null when unknown. */
27 readonly behind: number | null;
28 /** The parent's default branch tip as last fetched; "" when never. */
29 readonly parentTip: string;
30 /** ms since the epoch of the last attempt. */
31 readonly checkedAt: number;
32 /** One line; "" when the last attempt worked. */
33 readonly error: string;
34 }
35
36 const statePath = (app: App): string => join(app.config.dataDir, "upstream.json");
37
38 /** The last check, or undefined when none was made or the file is not one. */
39 export async function upstreamState(app: App): Promise<UpstreamState | undefined> {
40 try {
41 const d = (await Bun.file(statePath(app)).json()) as Record<string, unknown>;
42 const behind = d["behind"];
43 if (typeof d["checkedAt"] !== "number" || !Number.isFinite(d["checkedAt"])) return undefined;
44 return {
45 behind: typeof behind === "number" && Number.isSafeInteger(behind) && behind >= 0 ? behind : null,
46 parentTip: typeof d["parentTip"] === "string" && TIP.test(d["parentTip"]) ? d["parentTip"] : "",
47 checkedAt: d["checkedAt"],
48 error: typeof d["error"] === "string" ? d["error"].slice(0, 200) : "",
49 };
50 } catch {
51 return undefined;
52 }
53 }
54
55 async function keep(app: App, s: UpstreamState): Promise<void> {
56 try {
57 await Bun.write(`${statePath(app)}.tmp`, JSON.stringify(s));
58 await rename(`${statePath(app)}.tmp`, statePath(app));
59 } catch {
60 // Nothing to keep it in: the next check tries again.
61 }
62 }
63
64 /** The parent's code repository as a git remote, when this forge follows and has not severed. */
65 export async function upstreamRemote(app: App): Promise<string | undefined> {
66 const link = await forgeLinkOf(app);
67 if (link === undefined || link.severedAt !== undefined) return undefined;
68 try {
69 const doc = await Bun.file(join(app.config.dataDir, "parent.json")).json();
70 const origin = originOf((doc as Record<string, unknown>)["upstream"]);
71 return origin === "" ? undefined : `${origin}/${link.upstream}.git`;
72 } catch {
73 return undefined;
74 }
75 }
76
77 let inFlight: Promise<UpstreamState | undefined> | undefined;
78
79 /**
80 * Fetch the parent's code and record how far behind this fork is. Undefined
81 * when there is nothing to follow. `remote` stands in for the parent's
82 * address (tests point it at a local path); it never comes from a request.
83 */
84 export function checkUpstream(
85 app: App,
86 options: { remote?: string; now?: number } = {},
87 ): Promise<UpstreamState | undefined> {
88 inFlight ??= run(app, options).finally(() => {

Low: inFlight shares one promise across callers but ignores options. A second caller with a different remote or now silently gets the first caller's result. Only tests pass options today, but test/upstream-forge.test.ts depends on the c press's fetch having finished before it calls checkUpstream(child, { remote }). Either key the in-flight promise on the remote, or have tests await the in-flight one first.

89 inFlight = undefined;
90 });
91 return inFlight;
92 }
93
94 async function run(app: App, options: { remote?: string; now?: number }): Promise<UpstreamState | undefined> {
95 try {
96 const link = await forgeLinkOf(app);
97 const remote = options.remote ?? (await upstreamRemote(app));
98 if (link === undefined || link.severedAt !== undefined || remote === undefined) return undefined;
99 const [o, n] = link.repo.split("/") as [string, string];
100 const dir = repoDir(app.reposRoot, o, n);
101 const checkedAt = options.now ?? Date.now();
102 const fetched = await runGit(
103 ["fetch", "--quiet", "--no-tags", "--prune", "--", remote, "+refs/heads/*:refs/upstream/*", "+HEAD:refs/upstream/HEAD"],
104 { cwd: dir, timeoutMs: FETCH_TIMEOUT_MS },
105 );
106 let state: UpstreamState;
107 if (!fetched.ok) {
108 const line = fetched.error.stderr.split("\n").find((l) => l.trim() !== "")?.trim();
109 const error = line ?? (fetched.error.code === 137 ? "the fetch timed out after 60 s" : "the fetch failed");
110 const before = await upstreamState(app);
111 state = { behind: before?.behind ?? null, parentTip: before?.parentTip ?? "", checkedAt, error: error.slice(0, 200) };
112 } else {
113 const tip = await runGit(["rev-parse", "--verify", "-q", "refs/upstream/HEAD"], { cwd: dir });
114 const count = tip.ok
115 ? await runGit(["rev-list", "--count", "HEAD..refs/upstream/HEAD"], { cwd: dir })
116 : undefined;
117 const behind = count?.ok ? Number(count.value.trim()) : NaN;
118 state = {
119 behind: Number.isSafeInteger(behind) ? behind : null,
120 parentTip: tip.ok && TIP.test(tip.value.trim()) ? tip.value.trim() : "",
121 checkedAt,
122 error: Number.isSafeInteger(behind) ? "" : "the parent has no code to compare",
123 };
124 }
outdated

After keeping the state, append a forge.upstream event on app.bus so web viewers update live. Today a terminal c or the hourly check changes what the chain page says without telling it.

125 await keep(app, state);
126 // Tell an open chain page its line changed; no actor when the timer checks.
127 const repo = getRepo(app.db, o, n);
128 if (repo !== undefined)
129 app.bus.append({
130 kind: "forge.upstream",
131 repoId: repo.id,
132 payload: { repo: link.repo, behind: state.behind, error: state.error },
133 });
134 return state;
135 } catch (e) {
136 return { behind: null, parentTip: "", checkedAt: Date.now(), error: e instanceof Error ? e.message.slice(0, 200) : "the check failed" };
137 }
138 }
139
140 /** Check at boot and every hour; returns the timer to clear. */
141 export function startUpstreamWatch(app: App): ReturnType<typeof setInterval> {
142 void checkUpstream(app);
143 return setInterval(() => void checkUpstream(app), CHECK_MS);
144 }
145
146 /** The state in one plain sentence, escaped by whoever renders it. */
147 export function upstreamWords(s: UpstreamState | undefined, now = Date.now()): string {
148 if (s === undefined) return "not checked yet";
149 const mins = Math.max(0, Math.floor((now - s.checkedAt) / 60_000));
150 const when = mins < 1 ? "just now" : mins < 60 ? `${mins} min ago` : `${Math.floor(mins / 60)} h ago`;
151 if (s.error !== "") return `could not check your parent: ${s.error} (${when})`;
152 if (s.behind === null) return `checked ${when}`;
153 if (s.behind === 0) return `up to date (checked ${when})`;
154 return `your parent has ${s.behind} new commit${s.behind === 1 ? "" : "s"} (checked ${when})`;
155 }
156
157 /** The state as the API and MCP carry it; null before the first check. */
158 export async function upstreamPayload(app: App): Promise<UpstreamState | null> {
159 return (await upstreamState(app)) ?? null;
160 }
src/tui/screens/chain.ts +9
@@ -8,6 +8,7 @@
88 import type { App as Forge } from "../../app.ts";
99 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts";
1010 import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts";
11 import { checkUpstream, upstreamState, upstreamWords } from "../../spawn/upstream.ts";
1112 import { parentCredential } from "../../spawn/parent_credential.ts";
1213 import { canAdmin, canRead, canWrite, getRepo, type Repo } from "../../core/repos.ts";
1314 import type { User } from "../../core/users.ts";
@@ -41,6 +42,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
4142 const proposing = view.severedAt === null && (await parentCredential(app)) !== undefined;
4243 const linesOf = async (): Promise<[string, string][]> => [
4344 ...chainLines((await chainOfRepo(app, repo)) ?? view),
45 ...(view.lines.find((l) => l.here)?.mode != null ? [["upstream", upstreamWords(await upstreamState(app))] as [string, string]] : []),

Low: this row is gated on view, which is read once when the screen opens. After s severs from this screen, chainLines re-reads the chain but the upstream row stays until the screen is reopened. Decide on the freshly read chain, as the line just above does.

4446 ...(proposing
4547 ? (await forgeProposals(app, repo, Date.now(), user !== undefined)).map(
4648 (p): [string, string] => ["proposal", forgeProposalLine(p)],
@@ -52,6 +54,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
5254 const self: Screen = {
5355 title: "Chain",
5456 keys: [
outdated

This key and the upstream row have no test. Add one in test/upstream-forge.test.ts that checks the writer sees c and the row, a reader does not get c, and a severed forge shows neither.

57 ...(writer ? [binding(["c"], "check now")] : []),
5558 ...(writer && proposing ? [binding(["p"], "propose")] : []),
5659 ...(owner ? [binding(["m"], "follow mode"), binding(["s"], "sever")] : []),
5760 binding(["esc"], "close"),
@@ -66,6 +69,12 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
6669 },
6770 key(e) {
6871 if (e.key === "esc" || e.key === "q") ctx.pop();
72 else if (e.key === "c" && writer)
73 void checkUpstream(app).then(async (state) => {
74 if (state === undefined) return ctx.toast("This forge follows nothing", "danger");
75 rows = await linesOf();
76 ctx.toast(upstreamWords(state), state.error === "" ? "success" : "danger");
77 });
6978 else if (e.key === "p" && writer && proposing)
7079 void proposeToParentForge(app, user, repo).then(async (made) => {
7180 if (!made.ok) {
src/web/pages/chain.ts +27 −5
@@ -10,6 +10,7 @@ import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../
1010 import { canAdmin, canWrite } from "../../core/repos.ts";
1111 import type { Ctx } from "../../http/context.ts";
1212 import { chainView, FOLLOW_MODES, forgeLinkOf, type ChainLine, type FollowMode } from "../../spawn/chain.ts";
13 import { checkUpstream, upstreamState, upstreamWords } from "../../spawn/upstream.ts";
1314 import { parentCredential } from "../../spawn/parent_credential.ts";
1415 import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts";
1516 import { field, readForm } from "../form.ts";
@@ -18,7 +19,7 @@ import { flashOrJson, page, redirect } from "../layout.ts";
1819 import { btn, card } from "../ui.ts";
1920
2021 /** The events that change what the chain page says. */
21 export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode";
22 export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode,forge.upstream";
outdated

The upstream sentence now comes from this page too. Add the new upstream event here, or the page never refreshes after a check.

2223
2324 const MODE_LABELS: Record<FollowMode, string> = {
outdated

The page wording is updated here, but README.md ("recorded, not yet acted on") and the API/MCP following payload still say nothing about the upstream state. Bring them in line in this pull.

2425 "tell-me": "Tell me when the parent has something new",
@@ -43,9 +44,9 @@ function modeCard(base: string, current: FollowMode | null): Html {
4344 );
4445 }
4546
46 const MODE_WORDS = { "tell-me": "recorded: tell me (nothing tells it yet)", "take-updates": "recorded: take updates (nothing acts on it yet)" } as const;
47 const MODE_WORDS = { "tell-me": "tell me", "take-updates": "take updates (recorded; nothing applies them yet)" } as const;
4748
48 function lineRow(l: ChainLine): Html {
49 function lineRow(l: ChainLine, upstream?: string): Html {
4950 const where = l.host === "" ? "" : ` on ${l.host}`;
5051 const how =
5152 l.severedAt !== null
@@ -62,7 +63,7 @@ function lineRow(l: ChainLine): Html {
6263 <span class="row-title"
6364 >${l.repo}${where}${l.here ? html` <span class="lineage-here">you are here</span>` : null}</span
6465 >
65 <span class="row-meta">${how}${ahead}</span>
66 <span class="row-meta">${how}${ahead}${upstream === undefined ? null : ` · ${upstream}`}</span>
6667 </span>
6768 </div>
6869 </li>`;
@@ -116,6 +117,7 @@ export async function chainPage(ctx: Ctx): Promise<Response> {
116117 const proposing = view.severedAt === null && (await parentCredential(ctx.app)) !== undefined;
117118 const proposals = proposing ? await forgeProposals(ctx.app, repo, Date.now(), ctx.auth.user !== undefined) : [];
118119 const writer = canWrite(ctx.app.db, repo, ctx.auth.user);
120 const state = await upstreamState(ctx.app);
119121 return page({
120122 ctx,
121123 title: "Following",
@@ -130,10 +132,15 @@ export async function chainPage(ctx: Ctx): Promise<Response> {
130132 >
131133 ${card(
132134 html`<ul class="list lineage" aria-label="Forges, root first">
133 ${view.lines.map(lineRow)}
135 ${view.lines.map((l) => lineRow(l, l.here && l.mode !== null ? upstreamWords(state) : undefined))}
134136 </ul>`,
135137 { pad: false },
136138 )}
139 ${writer && view.severedAt === null
140 ? html`<form method="post" action="${base}/following/check" class="mt">
141 <div class="form-actions">${btn("Check the parent now", { type: "submit" })}</div>
142 </form>`
143 : null}
137144 ${owner && view.severedAt === null ? modeCard(base, view.lines[view.lines.length - 1]?.mode ?? null) : null}
138145 ${proposing ? proposalCard(base, proposals, writer) : null}
139146 ${view.severedAt !== null
@@ -182,6 +189,21 @@ export async function severSubmit(ctx: Ctx): Promise<Response> {
182189 return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` });
183190 }
184191
192 /** POST /:owner/:name/following/check: fetch the parent's code now; a writer only. */
193 export async function checkSubmit(ctx: Ctx): Promise<Response> {
194 const repo = loadRepo(ctx);
195 if (!repo) return notFoundPage(ctx);
196 if (!ctx.auth.user) return signInFirst(ctx);
197 const form = await readForm(ctx, "rw");
198 if (form instanceof Response) return form;
199 const to = `${repoBase(repo)}/following`;
200 if (!canWrite(ctx.app.db, repo, ctx.auth.user) || !(await carriesChain(ctx)))
201 return flashOrJson(ctx, to, { tone: "danger", message: "You need write access to a forge that follows." });
202 const state = await checkUpstream(ctx.app);

Low: any writer can repeat this check back to back, and each one is a fetch from the parent of up to 60 s. Concurrent presses are collapsed, but presses one after another are not. A short cooldown would cap it, for example answering from the last state when checkedAt is under a minute old.

203 if (state === undefined) return flashOrJson(ctx, to, { tone: "danger", message: "This forge follows nothing." });
204 return flashOrJson(ctx, to, state.error === "" ? { message: upstreamWords(state) } : { tone: "danger", message: upstreamWords(state) });
205 }
206
185207 /** POST /:owner/:name/following/mode {mode}; the owner only. */
186208 export async function modeSubmit(ctx: Ctx): Promise<Response> {
187209 const repo = loadRepo(ctx);
test/following.test.ts +1 −1
@@ -1092,7 +1092,7 @@ describe("an open chain page hears a sever", () => {
10921092 test("the page carries a live region for forge.severed, scoped to its repository", async () => {
10931093 const page = await chainPage();
10941094 expect(page).toContain('id="live-chain"');
1095 expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode"');
1095 expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode,forge.upstream"');
10961096 expect(page).toContain('data-live-repo="kid/forge"');
10971097 expect(page).not.toContain("follows nothing since");
10981098 });
test/mcp-upstream.test.ts +11
@@ -20,6 +20,7 @@ import { runGit } from "../src/git/env.ts";
2020 import { repoDir } from "../src/git/repos.ts";
2121 import { proposalLimiter } from "../src/http/proposal_gate.ts";
2222 import { serve, type Servers } from "../src/http/server.ts";
23 import { checkUpstream } from "../src/spawn/upstream.ts";
2324 import { keepParentCredential } from "../src/spawn/parent_credential.ts";
2425 import { quietLogs } from "./helpers/globals.ts";
2526
@@ -164,6 +165,16 @@ describe("MCP proposes to the parent forge and lists proposals", () => {
164165 return { error: body.result.isError === true, text };
165166 };
166167
168 test("the following tool carries the upstream state, null before a check", async () => {
169 const token = createToken(child.db, kid.id, "rw", "rw").token;
170 expect(JSON.parse((await call(token, "following")).text).upstream).toBeNull();
171 await checkUpstream(child, { remote: repoDir(parent.reposRoot, "alice", "lib") });
172 const got = JSON.parse((await call(token, "following")).text).upstream;
173 expect(got).toMatchObject({ error: "" });
174 expect(typeof got.behind).toBe("number");
175 expect(got.parentTip).toMatch(/^[0-9a-f]{40,64}$/);
176 });
177
167178 test("tools/list names both", async () => {
168179 const token = createToken(child.db, kid.id, "rw", "rw").token;
169180 const res = await fetch(`${childServer.url}/mcp`, {
test/spawn-chain.test.ts +30 −2
@@ -20,6 +20,7 @@ import { runGit } from "../src/git/env.ts";
2020 import { repoDir } from "../src/git/repos.ts";
2121 import { commitFiles } from "../src/git/write.ts";
2222 import { serve, type Servers } from "../src/http/server.ts";
23 import { upstreamState } from "../src/spawn/upstream.ts";
2324 import { chainLines, chainView, forgeLinkOf, severChain } from "../src/spawn/chain.ts";
2425 import { parseBeat, sendBeat, standing } from "../src/spawn/heartbeat.ts";
2526 import { planSpawn } from "../src/spawn/plan.ts";
@@ -135,8 +136,9 @@ describe("the chain of forges", () => {
135136 const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
136137 expect(page).toContain("alice/forge-code");
137138 expect(page).toContain("kid/forge");
138 expect(page).toContain("recorded: take updates");
139 expect(page).toContain("recorded: tell me");
139 expect(page).toContain("take updates (recorded");
140 expect(page).toContain("tell me · ");
141 expect(page).toContain("not checked yet");
140142 expect(page).toContain("1 of its own");
141143 });
142144
@@ -180,6 +182,32 @@ describe("the chain of forges", () => {
180182 expect(await res.text()).toContain("alice/forge-code");
181183 });
182184
185 test("check-now needs a writer and a same-origin post; nothing is fetched without them", async () => {
186 const url = `${servers.url}/tot/forge/following/check`;
187 const post = (headers: Record<string, string>) => fetch(url, { method: "POST", headers, body: new URLSearchParams({}), redirect: "manual" });
188 const guest = await post({ origin: servers.url });
189 expect(guest.status).toBeGreaterThanOrEqual(300);
190 expect(guest.headers.get("location") ?? "").toContain("/signin");
191 const cross = await post({ cookie, origin: "https://evil.test" });
192 expect(cross.status).toBeGreaterThanOrEqual(400);
193 const reader = await createUser(grand.db, { origin: "seed", trusted: true, username: "checkreader", email: "[email protected]" });
194 if (!reader.ok) throw new Error("reader");
195 const theirs = `forge_session=${createSession(grand.db, reader.value.id).token}`;
196 const denied = await post({ cookie: theirs, origin: servers.url });
197 expect(denied.status).toBeGreaterThanOrEqual(300);
198 expect(denied.status).toBeLessThan(400);
199 expect(denied.headers.get("location") ?? "").toContain("/tot/forge/following");
200 expect(decodeURIComponent(denied.headers.get("set-cookie") ?? "")).toContain("write access");
201 // Each of the three would have fetched, and so left a state behind.
202 expect(await upstreamState(grand)).toBeUndefined();
203 });
204
205 test("the API carries the upstream state, null before the first check", async () => {
206 const owner = getUserByName(grand.db, "alice")!;
207 const got = (await (await fetch(`${servers.url}/api/v1/repos/tot/forge/following`, { headers: { authorization: `Bearer ${createToken(grand.db, owner.id, "t2", "rw").token}` } })).json()) as any;
208 expect(got.upstream).toBeNull();
209 });
210
183211 test("sever needs the owner, a same-origin post and the word; it deletes nothing", async () => {
184212 const url = `${servers.url}/tot/forge/following/sever`;
185213 const body = (confirm: string) => new URLSearchParams({ confirm });
test/spawn-upstream.test.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * Looking upstream (#2631): two local bare repositories, parent and child,
3 * and no network. The child is behind by what the parent gained; an
4 * unreachable upstream is a recorded error, never a throw.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdir, mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { runGit } from "../src/git/env.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { checkUpstream, upstreamRemote, upstreamState, upstreamWords } from "../src/spawn/upstream.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 const git = async (cwd: string, ...args: string[]) => {
20 const r = await runGit(["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd });
21 if (!r.ok) throw new Error(r.error.stderr);
22 return r.value;
23 };
24
25 describe("looking upstream", () => {
26 let dir = "";
27 let app: App;
28 let parent = "";
29 let work = "";
30 let child = "";
31
32 const commit = async (n: number) => {
33 await Bun.write(join(work, `f${n}.txt`), String(n));
34 await git(work, "add", ".");
35 await git(work, "commit", "-m", `c${n}`);
36 await git(work, "push", "-q", parent, "HEAD:refs/heads/main");
37 };
38
39 beforeAll(async () => {
40 dir = await mkdtemp(join(tmpdir(), "forge-upstream-"));
41 app = createApp({ dataDir: join(dir, "data"), url: "https://child.test", port: 0 });
42 parent = join(dir, "parent.git");
43 work = join(dir, "work");
44 await git(dir, "init", "-q", "--bare", "-b", "main", parent);
45 await git(dir, "init", "-q", "-b", "main", work);
46 await commit(0);
47 child = repoDir(app.reposRoot, "kid", "forge");
48 await mkdir(join(app.reposRoot, "kid"), { recursive: true });
49 await git(dir, "clone", "-q", "--bare", parent, child);
50 await Bun.write(
51 join(app.config.dataDir, "parent.json"),
52 JSON.stringify({
53 upstream: "https://parent.test",
54 forgeCode: { repo: "kid/forge", upstream: "root/forge", mode: "tell-me", tip: "a".repeat(40), chain: [] },
55 }),
56 );
57 });
58 afterAll(() => rm(dir, { recursive: true, force: true }));
59
60 test("the remote is the parent's origin and repository", async () => {
61 expect(await upstreamRemote(app)).toBe("https://parent.test/root/forge.git");
62 });
63
64 test("up to date, then behind by what the parent gained", async () => {
65 const first = await checkUpstream(app, { remote: parent, now: 1000 });
66 expect(first).toMatchObject({ behind: 0, error: "", checkedAt: 1000 });
67 for (const n of [1, 2, 3]) await commit(n);
68 const next = await checkUpstream(app, { remote: parent, now: 2000 });
69 expect(next?.behind).toBe(3);
70 expect(next?.parentTip).toMatch(/^[0-9a-f]{40}$/);
71 expect(await upstreamState(app)).toEqual(next);
72 expect(await git(child, "rev-parse", "refs/upstream/main")).toBe(next!.parentTip);
73 });
74
75 test("an unreachable upstream keeps its error and what was known", async () => {
76 const s = await checkUpstream(app, { remote: join(dir, "nowhere.git"), now: 3000 });
77 expect(s?.error).not.toBe("");
78 expect(s?.behind).toBe(3);
79 expect((await upstreamState(app))?.error).toBe(s!.error);
80 });
81
82 test("words", () => {
83 const base = { behind: 3, parentTip: "", checkedAt: 0, error: "" };
84 const at = 12 * 60_000;
85 expect(upstreamWords({ ...base }, at)).toBe("your parent has 3 new commits (checked 12 min ago)");
86 expect(upstreamWords({ ...base, behind: 0 }, at)).toBe("up to date (checked 12 min ago)");
87 expect(upstreamWords({ ...base, error: "boom" }, at)).toBe("could not check your parent: boom (12 min ago)");
88 });
89
90 test("a severed forge is not checked", async () => {
91 const doc = await Bun.file(join(app.config.dataDir, "parent.json")).json();
92 doc.forgeCode.severedAt = new Date().toISOString();
93 await Bun.write(join(app.config.dataDir, "parent.json"), JSON.stringify(doc));
94 expect(await checkUpstream(app, { remote: parent })).toBeUndefined();
95 });
96 });
test/upstream-forge.test.ts +49
@@ -27,7 +27,10 @@ import { runGit } from "../src/git/env.ts";
2727 import { repoDir } from "../src/git/repos.ts";
2828 import { proposalLimiter } from "../src/http/proposal_gate.ts";
2929 import { serve, type Servers } from "../src/http/server.ts";
30 import { Screen } from "../src/tui/term/screen.ts";
31 import { theme } from "../src/tui/ui/theme.ts";
3032 import { chainScreen } from "../src/tui/screens/chain.ts";
33 import { checkUpstream, upstreamState } from "../src/spawn/upstream.ts";
3134 import { keepParentCredential } from "../src/spawn/parent_credential.ts";
3235 import { quietLogs } from "./helpers/globals.ts";
3336
@@ -388,4 +391,50 @@ describe("a child forge proposes to its parent forge", () => {
388391 expect(api.status).toBe(502);
389392 expect(((await api.json()) as { error: { code?: string } | string }).error).toBeDefined();
390393 });
394
395 test("a check tells the chain page, and the terminal shows and runs it for a writer only", async () => {
396 const remote = repoDir(parent.reposRoot, "alice", "lib");
397 const heard: any[] = [];
398 const off = child.bus.subscribe((e) => e.kind === "forge.upstream" && heard.push(e));
399 const toasts: string[] = [];
400 const ctx = { toast: (m: string) => toasts.push(m), pop() {}, push() {}, theme: theme("dark") } as any;
401 const keysOf = (screen: any) => screen.keys.map((k: any) => k.keys?.[0] ?? k[0]);
402 const text = (screen: any) => {
403 const grid = new Screen(100, 30);
404 screen.render({ x: 0, y: 0, w: 100, h: 30 }, grid);
405 return grid.toText();
406 };
407
408 const writer = await chainScreen(child, kid, ctx);
409 expect(keysOf(writer)).toContain("c");
410 expect(text(writer)).toContain("not checked yet");
411 await checkUpstream(child, { remote });
412 expect(heard).toHaveLength(1);
413 expect(heard[0].repoId).toBe(forge.id);
414 expect(heard[0].actorId ?? null).toBeNull();
415 expect(heard[0].payload).toMatchObject({ repo: "kid/forge", error: "" });
416
417 writer.key({ key: "c" } as any);
418 await Bun.sleep(300);

Low: this Bun.sleep(300) waits for a real fetch to an https:// parent to fail. Under enforced egress that fetch can take far longer than 300 ms, which leaves toasts empty and also leaks into the severed assertion through inFlight (see the comment on src/spawn/upstream.ts). Await the check instead of sleeping, for example by exposing the promise or polling upstreamState.

419 off();
420 expect(heard.length).toBeGreaterThanOrEqual(1);
421 expect(toasts.join("\n")).toMatch(/checked|up to date|new commit|could not check/);
422
423 const reader = await person(child, "looker");
424 const read = await chainScreen(child, reader, ctx);
425 expect(keysOf(read)).not.toContain("c");
426 expect(text(read)).toContain("upstream");
427 expect(keysOf(await chainScreen(child, undefined, ctx))).not.toContain("c");
428
429 // Severed: neither the row nor the key.
430 const file = join(child.config.dataDir, "parent.json");
431 const doc = JSON.parse(await Bun.file(file).text());
432 doc.forgeCode.severedAt = new Date().toISOString();
433 await Bun.write(file, JSON.stringify(doc));
434 const cut = await chainScreen(child, kid, ctx);
435 expect(keysOf(cut)).not.toContain("c");
436 expect(text(cut)).not.toContain("upstream");
437 expect(await checkUpstream(child, { remote })).toBeUndefined();
438 expect(await upstreamState(child)).toBeDefined();
439 });
391440 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs