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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2631-a-spawned-forge-fetches-its-parent-s-cod → main 5 commits fork 9 threads · 9 unresolved
README.md +8 −1
| @@ -381,7 +381,14 @@ taking a release is not done here. This forge publishes its own | ||
| 381 | 381 | forge can be a parent. The terminal shows the count, not the release names. |
| 382 | 382 | |
| 383 | 383 | 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 | |
| 385 | 392 | to the root is at `/:owner/forge/following`, in `chain` of `GET |
| 386 | 393 | /api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the |
| 387 | 394 | terminal's **Chain** command; the owner severs it with the page's Sever, |
src/http/api.ts +3 −1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { upstreamPayload } from "../spawn/upstream.ts"; | |
| 11 | 12 | import { setRole } from "../actions/roles.ts"; |
| 12 | 13 | import { listPeople, NOT_A_MEMBER } from "../core/roles.ts"; |
| 13 | 14 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; |
| @@ -2283,7 +2284,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2283 | 2284 | if (relation === undefined) |
| 2284 | 2285 | return chain === undefined |
| 2285 | 2286 | ? 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) }); | |
| 2287 | 2288 | const converge = |
| 2288 | 2289 | relation.state === "diverged" && !relation.clean |
| 2289 | 2290 | ? convergeState(ctx.app, repo, relation.parent) |
| @@ -2291,6 +2292,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2291 | 2292 | return json({ |
| 2292 | 2293 | chain: chain?.lines ?? null, |
| 2293 | 2294 | severedAt: chain?.severedAt ?? null, |
| 2295 | upstream: chain === undefined ? null : await upstreamPayload(ctx.app), | |
| 2294 | 2296 | parent: `${relation.parent.ownerName}/${relation.parent.name}`, |
| 2295 | 2297 | parentBranch: relation.parent.defaultBranch, |
| 2296 | 2298 | // A parent whose origin was disconnected: the counts below are against |
src/http/mcp.ts +3 −1
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | * no page. `ro` tokens read; `rw` tokens act. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { upstreamPayload } from "../spawn/upstream.ts"; | |
| 20 | 21 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 21 | 22 | import { voteVariant } from "../actions/variants.ts"; |
| 22 | 23 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| @@ -1395,7 +1396,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1395 | 1396 | if (relation === undefined) |
| 1396 | 1397 | return chain === undefined |
| 1397 | 1398 | ? 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) }); | |
| 1399 | 1400 | const converge = |
| 1400 | 1401 | relation.state === "diverged" && !relation.clean |
| 1401 | 1402 | ? convergeState(ctx.app, repo, relation.parent) |
| @@ -1403,6 +1404,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1403 | 1404 | return good({ |
| 1404 | 1405 | chain: chain?.lines ?? null, |
| 1405 | 1406 | severedAt: chain?.severedAt ?? null, |
| 1407 | upstream: chain === undefined ? null : await upstreamPayload(ctx.app), | |
| 1406 | 1408 | parent: `${relation.parent.ownerName}/${relation.parent.name}`, |
| 1407 | 1409 | state: relation.state, |
| 1408 | 1410 | behind: relation.behind, |
src/http/server.ts +2 −1
| @@ -128,7 +128,7 @@ import { | ||
| 128 | 128 | signinPasskey, |
| 129 | 129 | } from "../web/pages/passkeys.ts"; |
| 130 | 130 | 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"; | |
| 132 | 132 | import { followingSubmit } from "../web/pages/following.ts"; |
| 133 | 133 | import { reconnectSubmit } from "../web/pages/frozen.ts"; |
| 134 | 134 | import { activityAtom } from "../web/feeds.ts"; |
| @@ -1274,6 +1274,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1274 | 1274 | router.add("POST", "/:owner/:name/following/sever", severSubmit); |
| 1275 | 1275 | router.add("POST", "/:owner/:name/following/mode", modeSubmit); |
| 1276 | 1276 | router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit); |
| 1277 | router.add("POST", "/:owner/:name/following/check", checkSubmit); | |
| 1277 | 1278 | // A mirror pulls on demand; a pin says "keep this on my machine". Both are |
| 1278 | 1279 | // ordinary repo-scoped writes, not account-level ones. |
| 1279 | 1280 | router.add("POST", "/:owner/:name/sync", repoSyncSubmit); |
src/main.ts +4
| @@ -20,6 +20,7 @@ import { syncAllPillars } from "./actions/pillars.ts"; | ||
| 20 | 20 | import { bootHostedChildren } from "./spawn/host.ts"; |
| 21 | 21 | import { firstBoot } from "./spawn/first-boot.ts"; |
| 22 | 22 | import { startHeartbeat } from "./spawn/heartbeat.ts"; |
| 23 | import { startUpstreamWatch } from "./spawn/upstream.ts"; | |
| 23 | 24 | import { pruneSessions } from "./core/sessions.ts"; |
| 24 | 25 | import { runDelivery } from "./core/webhooks.ts"; |
| 25 | 26 | import { isHookName, runHookClient } from "./git/hooks.ts"; |
| @@ -406,6 +407,8 @@ void bootHostedChildren(app); | ||
| 406 | 407 | await firstBoot(app); |
| 407 | 408 | // A spawned child reports its standing to its parent (spawn/heartbeat.ts). |
| 408 | 409 | const heartbeatTimer = startHeartbeat(app); |
| 410 | // ...and looks upstream once an hour (spawn/upstream.ts). | |
| 411 | const upstreamTimer = startUpstreamWatch(app); | |
| 409 | 412 | // Prompts from Instagram (docs/APPS.md): once a day, for each repository |
| 410 | 413 | // that connected an account, the top comment on its latest Reel is told to |
| 411 | 414 | // 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> { | ||
| 595 | 598 | drainStreams(); |
| 596 | 599 | clearInterval(deliveryTimer); |
| 597 | 600 | clearInterval(heartbeatTimer); |
| 601 | clearInterval(upstreamTimer); | |
| 598 | 602 | clearInterval(syncTimer); |
| 599 | 603 | clearInterval(staleTimer); |
| 600 | 604 | 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(() => { | |
| 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 |
||
| 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 @@ | ||
| 8 | 8 | import type { App as Forge } from "../../app.ts"; |
| 9 | 9 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts"; |
| 10 | 10 | import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts"; |
| 11 | import { checkUpstream, upstreamState, upstreamWords } from "../../spawn/upstream.ts"; | |
| 11 | 12 | import { parentCredential } from "../../spawn/parent_credential.ts"; |
| 12 | 13 | import { canAdmin, canRead, canWrite, getRepo, type Repo } from "../../core/repos.ts"; |
| 13 | 14 | import type { User } from "../../core/users.ts"; |
| @@ -41,6 +42,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 41 | 42 | const proposing = view.severedAt === null && (await parentCredential(app)) !== undefined; |
| 42 | 43 | const linesOf = async (): Promise<[string, string][]> => [ |
| 43 | 44 | ...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 |
||
| 44 | 46 | ...(proposing |
| 45 | 47 | ? (await forgeProposals(app, repo, Date.now(), user !== undefined)).map( |
| 46 | 48 | (p): [string, string] => ["proposal", forgeProposalLine(p)], |
| @@ -52,6 +54,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 52 | 54 | const self: Screen = { |
| 53 | 55 | title: "Chain", |
| 54 | 56 | keys: [ |
outdated
This key and the |
||
| 57 | ...(writer ? [binding(["c"], "check now")] : []), | |
| 55 | 58 | ...(writer && proposing ? [binding(["p"], "propose")] : []), |
| 56 | 59 | ...(owner ? [binding(["m"], "follow mode"), binding(["s"], "sever")] : []), |
| 57 | 60 | binding(["esc"], "close"), |
| @@ -66,6 +69,12 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 66 | 69 | }, |
| 67 | 70 | key(e) { |
| 68 | 71 | 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 | }); | |
| 69 | 78 | else if (e.key === "p" && writer && proposing) |
| 70 | 79 | void proposeToParentForge(app, user, repo).then(async (made) => { |
| 71 | 80 | if (!made.ok) { |
src/web/pages/chain.ts +27 −5
| @@ -10,6 +10,7 @@ import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../ | ||
| 10 | 10 | import { canAdmin, canWrite } from "../../core/repos.ts"; |
| 11 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 12 | import { chainView, FOLLOW_MODES, forgeLinkOf, type ChainLine, type FollowMode } from "../../spawn/chain.ts"; |
| 13 | import { checkUpstream, upstreamState, upstreamWords } from "../../spawn/upstream.ts"; | |
| 13 | 14 | import { parentCredential } from "../../spawn/parent_credential.ts"; |
| 14 | 15 | import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts"; |
| 15 | 16 | import { field, readForm } from "../form.ts"; |
| @@ -18,7 +19,7 @@ import { flashOrJson, page, redirect } from "../layout.ts"; | ||
| 18 | 19 | import { btn, card } from "../ui.ts"; |
| 19 | 20 | |
| 20 | 21 | /** 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. |
||
| 22 | 23 | |
| 23 | 24 | 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 |
||
| 24 | 25 | "tell-me": "Tell me when the parent has something new", |
| @@ -43,9 +44,9 @@ function modeCard(base: string, current: FollowMode | null): Html { | ||
| 43 | 44 | ); |
| 44 | 45 | } |
| 45 | 46 | |
| 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; | |
| 47 | 48 | |
| 48 | function lineRow(l: ChainLine): Html { | |
| 49 | function lineRow(l: ChainLine, upstream?: string): Html { | |
| 49 | 50 | const where = l.host === "" ? "" : ` on ${l.host}`; |
| 50 | 51 | const how = |
| 51 | 52 | l.severedAt !== null |
| @@ -62,7 +63,7 @@ function lineRow(l: ChainLine): Html { | ||
| 62 | 63 | <span class="row-title" |
| 63 | 64 | >${l.repo}${where}${l.here ? html` <span class="lineage-here">you are here</span>` : null}</span |
| 64 | 65 | > |
| 65 | <span class="row-meta">${how}${ahead}</span> | |
| 66 | <span class="row-meta">${how}${ahead}${upstream === undefined ? null : ` · ${upstream}`}</span> | |
| 66 | 67 | </span> |
| 67 | 68 | </div> |
| 68 | 69 | </li>`; |
| @@ -116,6 +117,7 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 116 | 117 | const proposing = view.severedAt === null && (await parentCredential(ctx.app)) !== undefined; |
| 117 | 118 | const proposals = proposing ? await forgeProposals(ctx.app, repo, Date.now(), ctx.auth.user !== undefined) : []; |
| 118 | 119 | const writer = canWrite(ctx.app.db, repo, ctx.auth.user); |
| 120 | const state = await upstreamState(ctx.app); | |
| 119 | 121 | return page({ |
| 120 | 122 | ctx, |
| 121 | 123 | title: "Following", |
| @@ -130,10 +132,15 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 130 | 132 | > |
| 131 | 133 | ${card( |
| 132 | 134 | 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))} | |
| 134 | 136 | </ul>`, |
| 135 | 137 | { pad: false }, |
| 136 | 138 | )} |
| 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} | |
| 137 | 144 | ${owner && view.severedAt === null ? modeCard(base, view.lines[view.lines.length - 1]?.mode ?? null) : null} |
| 138 | 145 | ${proposing ? proposalCard(base, proposals, writer) : null} |
| 139 | 146 | ${view.severedAt !== null |
| @@ -182,6 +189,21 @@ export async function severSubmit(ctx: Ctx): Promise<Response> { | ||
| 182 | 189 | return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` }); |
| 183 | 190 | } |
| 184 | 191 | |
| 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 |
||
| 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 | ||
| 185 | 207 | /** POST /:owner/:name/following/mode {mode}; the owner only. */ |
| 186 | 208 | export async function modeSubmit(ctx: Ctx): Promise<Response> { |
| 187 | 209 | const repo = loadRepo(ctx); |
test/following.test.ts +1 −1
| @@ -1092,7 +1092,7 @@ describe("an open chain page hears a sever", () => { | ||
| 1092 | 1092 | test("the page carries a live region for forge.severed, scoped to its repository", async () => { |
| 1093 | 1093 | const page = await chainPage(); |
| 1094 | 1094 | 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"'); | |
| 1096 | 1096 | expect(page).toContain('data-live-repo="kid/forge"'); |
| 1097 | 1097 | expect(page).not.toContain("follows nothing since"); |
| 1098 | 1098 | }); |
test/mcp-upstream.test.ts +11
| @@ -20,6 +20,7 @@ import { runGit } from "../src/git/env.ts"; | ||
| 20 | 20 | import { repoDir } from "../src/git/repos.ts"; |
| 21 | 21 | import { proposalLimiter } from "../src/http/proposal_gate.ts"; |
| 22 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 23 | import { checkUpstream } from "../src/spawn/upstream.ts"; | |
| 23 | 24 | import { keepParentCredential } from "../src/spawn/parent_credential.ts"; |
| 24 | 25 | import { quietLogs } from "./helpers/globals.ts"; |
| 25 | 26 | |
| @@ -164,6 +165,16 @@ describe("MCP proposes to the parent forge and lists proposals", () => { | ||
| 164 | 165 | return { error: body.result.isError === true, text }; |
| 165 | 166 | }; |
| 166 | 167 | |
| 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 | ||
| 167 | 178 | test("tools/list names both", async () => { |
| 168 | 179 | const token = createToken(child.db, kid.id, "rw", "rw").token; |
| 169 | 180 | 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"; | ||
| 20 | 20 | import { repoDir } from "../src/git/repos.ts"; |
| 21 | 21 | import { commitFiles } from "../src/git/write.ts"; |
| 22 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 23 | import { upstreamState } from "../src/spawn/upstream.ts"; | |
| 23 | 24 | import { chainLines, chainView, forgeLinkOf, severChain } from "../src/spawn/chain.ts"; |
| 24 | 25 | import { parseBeat, sendBeat, standing } from "../src/spawn/heartbeat.ts"; |
| 25 | 26 | import { planSpawn } from "../src/spawn/plan.ts"; |
| @@ -135,8 +136,9 @@ describe("the chain of forges", () => { | ||
| 135 | 136 | const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " "); |
| 136 | 137 | expect(page).toContain("alice/forge-code"); |
| 137 | 138 | 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"); | |
| 140 | 142 | expect(page).toContain("1 of its own"); |
| 141 | 143 | }); |
| 142 | 144 | |
| @@ -180,6 +182,32 @@ describe("the chain of forges", () => { | ||
| 180 | 182 | expect(await res.text()).toContain("alice/forge-code"); |
| 181 | 183 | }); |
| 182 | 184 | |
| 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 | ||
| 183 | 211 | test("sever needs the owner, a same-origin post and the word; it deletes nothing", async () => { |
| 184 | 212 | const url = `${servers.url}/tot/forge/following/sever`; |
| 185 | 213 | 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"; | ||
| 27 | 27 | import { repoDir } from "../src/git/repos.ts"; |
| 28 | 28 | import { proposalLimiter } from "../src/http/proposal_gate.ts"; |
| 29 | 29 | 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"; | |
| 30 | 32 | import { chainScreen } from "../src/tui/screens/chain.ts"; |
| 33 | import { checkUpstream, upstreamState } from "../src/spawn/upstream.ts"; | |
| 31 | 34 | import { keepParentCredential } from "../src/spawn/parent_credential.ts"; |
| 32 | 35 | import { quietLogs } from "./helpers/globals.ts"; |
| 33 | 36 | |
| @@ -388,4 +391,50 @@ describe("a child forge proposes to its parent forge", () => { | ||
| 388 | 391 | expect(api.status).toBe(502); |
| 389 | 392 | expect(((await api.json()) as { error: { code?: string } | string }).error).toBeDefined(); |
| 390 | 393 | }); |
| 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 |
||
| 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 | }); | |
| 391 | 440 | }); |
Low:
inFlightshares one promise across callers but ignoresoptions. A second caller with a differentremoteornowsilently gets the first caller's result. Only tests pass options today, buttest/upstream-forge.test.tsdepends on thecpress's fetch having finished before it callscheckUpstream(child, { remote }). Either key the in-flight promise on the remote, or have tests await the in-flight one first.