Public: anyone can read this; the crew can change it.
tell-me: when the parent forge's code moves, the owner gets one inbox card that says so #2641
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2633-tell-me-when-the-parent-forge-s-code-mov → main 1 commit fork
4 files
changed +156
−7
README.md +1 −1
| @@ -388,7 +388,7 @@ on demand, a forge that follows and has not severed fetches its parent's code in | ||
| 388 | 388 | the count shows on the chain page, in the terminal's **Chain** (`c` checks now), and as |
| 389 | 389 | `upstream` in the API and MCP payloads. `POST /:owner/forge/following/check` (a writer, |
| 390 | 390 | same-origin) checks on demand; a fetch is cut at 60 s and a failure is recorded, not |
| 391 | thrown. Its chain | |
| 391 | thrown. In `tell-me`, a new parent tip makes one inbox card for the owner (an issue on the fork assigned to them, "Your parent forge has N new commits", linking the chain page); `toldTip` in `upstream.json` keeps it to one per tip, and `take-updates` or a severed link makes none. Its chain | |
| 392 | 392 | to the root is at `/:owner/forge/following`, in `chain` of `GET |
| 393 | 393 | /api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the |
| 394 | 394 | terminal's **Chain** command; the owner severs it with the page's Sever, |
src/spawn/upstream.ts +39 −5
| @@ -13,7 +13,10 @@ | ||
| 13 | 13 | import { rename } from "node:fs/promises"; |
| 14 | 14 | import { join } from "node:path"; |
| 15 | 15 | import type { App } from "../app.ts"; |
| 16 | import { getRepo } from "../core/repos.ts"; | |
| 16 | import { getAgentByName } from "../core/agents.ts"; | |
| 17 | import { createIssue, setAssignee } from "../core/issues.ts"; | |
| 18 | import { getRepo, type Repo } from "../core/repos.ts"; | |
| 19 | import { getUserById, getUserByName } from "../core/users.ts"; | |
| 17 | 20 | import { runGit } from "../git/env.ts"; |
| 18 | 21 | import { repoDir } from "../git/repos.ts"; |
| 19 | 22 | import { forgeLinkOf, originOf } from "./chain.ts"; |
| @@ -31,6 +34,8 @@ export interface UpstreamState { | ||
| 31 | 34 | readonly checkedAt: number; |
| 32 | 35 | /** One line; "" when the last attempt worked. */ |
| 33 | 36 | readonly error: string; |
| 37 | /** The parent tip the owner was last told about; "" when never. */ | |
| 38 | readonly toldTip: string; | |
| 34 | 39 | } |
| 35 | 40 | |
| 36 | 41 | const statePath = (app: App): string => join(app.config.dataDir, "upstream.json"); |
| @@ -46,6 +51,7 @@ export async function upstreamState(app: App): Promise<UpstreamState | undefined | ||
| 46 | 51 | parentTip: typeof d["parentTip"] === "string" && TIP.test(d["parentTip"]) ? d["parentTip"] : "", |
| 47 | 52 | checkedAt: d["checkedAt"], |
| 48 | 53 | error: typeof d["error"] === "string" ? d["error"].slice(0, 200) : "", |
| 54 | toldTip: typeof d["toldTip"] === "string" && TIP.test(d["toldTip"]) ? d["toldTip"] : "", | |
| 49 | 55 | }; |
| 50 | 56 | } catch { |
| 51 | 57 | return undefined; |
| @@ -108,7 +114,7 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis | ||
| 108 | 114 | const line = fetched.error.stderr.split("\n").find((l) => l.trim() !== "")?.trim(); |
| 109 | 115 | const error = line ?? (fetched.error.code === 137 ? "the fetch timed out after 60 s" : "the fetch failed"); |
| 110 | 116 | const before = await upstreamState(app); |
| 111 | state = { behind: before?.behind ?? null, parentTip: before?.parentTip ?? "", checkedAt, error: error.slice(0, 200) }; | |
| 117 | state = { behind: before?.behind ?? null, parentTip: before?.parentTip ?? "", checkedAt, error: error.slice(0, 200), toldTip: before?.toldTip ?? "" }; | |
| 112 | 118 | } else { |
| 113 | 119 | const tip = await runGit(["rev-parse", "--verify", "-q", "refs/upstream/HEAD"], { cwd: dir }); |
| 114 | 120 | const count = tip.ok |
| @@ -120,11 +126,17 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis | ||
| 120 | 126 | parentTip: tip.ok && TIP.test(tip.value.trim()) ? tip.value.trim() : "", |
| 121 | 127 | checkedAt, |
| 122 | 128 | error: Number.isSafeInteger(behind) ? "" : "the parent has no code to compare", |
| 129 | toldTip: (await upstreamState(app))?.toldTip ?? "", | |
| 123 | 130 | }; |
| 124 | 131 | } |
| 125 | await keep(app, state); | |
| 126 | // Tell an open chain page its line changed; no actor when the timer checks. | |
| 127 | 132 | const repo = getRepo(app.db, o, n); |
| 133 | // Remember the tip before the card, so a crash between them can lose a card but never repeat one. | |
| 134 | const tell = repo !== undefined && link.mode === "tell-me" && state.error === "" && state.parentTip !== "" && | |
| 135 | (state.behind ?? 0) > 0 && state.parentTip !== state.toldTip; | |
| 136 | if (tell) state = { ...state, toldTip: state.parentTip }; | |
| 137 | await keep(app, state); | |
| 138 | if (tell) tellOwner(app, repo, link.repo, state.behind!); | |
| 139 | // Tell an open chain page its line changed; no actor when the timer checks. | |
| 128 | 140 | if (repo !== undefined) |
| 129 | 141 | app.bus.append({ |
| 130 | 142 | kind: "forge.upstream", |
| @@ -133,7 +145,29 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis | ||
| 133 | 145 | }); |
| 134 | 146 | return state; |
| 135 | 147 | } catch (e) { |
| 136 | return { behind: null, parentTip: "", checkedAt: Date.now(), error: e instanceof Error ? e.message.slice(0, 200) : "the check failed" }; | |
| 148 | return { behind: null, parentTip: "", checkedAt: Date.now(), error: e instanceof Error ? e.message.slice(0, 200) : "the check failed", toldTip: "" }; | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * One inbox card for the owner: an issue on the fork assigned to them, which | |
| 154 | * the inbox shows as "assigned to you", and which links the chain page. The | |
| 155 | * sentinel files it when the forge has one, so it is not the owner's own ask. | |
| 156 | */ | |
| 157 | function tellOwner(app: App, repo: Repo, fork: string, behind: number): void { | |
| 158 | try { | |
| 159 | const owner = getUserById(app.db, repo.ownerId); | |
| 160 | if (owner === undefined) return; | |
| 161 | const sentinel = getAgentByName(app.db, "sentinel") === undefined ? undefined : getUserByName(app.db, "sentinel"); | |
| 162 | const made = createIssue(app.db, { | |
| 163 | repoId: repo.id, | |
| 164 | authorId: (sentinel ?? owner).id, | |
| 165 | title: `Your parent forge has ${behind} new commit${behind === 1 ? "" : "s"}`, | |
| 166 | body: `Your parent forge's code moved. See where you stand and take it from [the chain page](/${fork}/following).`, | |
| 167 | }); | |
| 168 | if (made.ok) setAssignee(app.db, made.value.id, owner.id); | |
| 169 | } catch { | |
| 170 | // The card is a courtesy; the check must not fail on it. | |
| 137 | 171 | } |
| 138 | 172 | } |
| 139 | 173 |
test/spawn-upstream-card.test.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * Telling the owner (#2633): a new parent tip in `tell-me` is one inbox card, | |
| 3 | * never repeated for the same tip; `take-updates` and a severed link get none. | |
| 4 | * Two local bare repositories, no network. | |
| 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 { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { listAssignedTo } from "../src/core/issues.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { runGit } from "../src/git/env.ts"; | |
| 16 | import { repoDir } from "../src/git/repos.ts"; | |
| 17 | import { checkUpstream } from "../src/spawn/upstream.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | const git = async (cwd: string, ...args: string[]) => { | |
| 23 | const r = await runGit(["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd }); | |
| 24 | if (!r.ok) throw new Error(r.error.stderr); | |
| 25 | return r.value; | |
| 26 | }; | |
| 27 | ||
| 28 | describe("the parent moved: one card", () => { | |
| 29 | let dir = ""; | |
| 30 | let app: App; | |
| 31 | let parent = ""; | |
| 32 | let work = ""; | |
| 33 | let ownerId = ""; | |
| 34 | ||
| 35 | const commit = async (n: number) => { | |
| 36 | await Bun.write(join(work, `f${n}.txt`), String(n)); | |
| 37 | await git(work, "add", "."); | |
| 38 | await git(work, "commit", "-m", `c${n}`); | |
| 39 | await git(work, "push", "-q", parent, "HEAD:refs/heads/main"); | |
| 40 | }; | |
| 41 | const link = async (patch: Record<string, unknown>) => { | |
| 42 | const file = join(app.config.dataDir, "parent.json"); | |
| 43 | const doc = await Bun.file(file).json(); | |
| 44 | Object.assign(doc.forgeCode, patch); | |
| 45 | await Bun.write(file, JSON.stringify(doc)); | |
| 46 | }; | |
| 47 | const cards = () => listAssignedTo(app.db, ownerId).map((i) => i.title); | |
| 48 | ||
| 49 | beforeAll(async () => { | |
| 50 | dir = await mkdtemp(join(tmpdir(), "forge-upcard-")); | |
| 51 | app = createApp({ dataDir: join(dir, "data"), url: "https://child.test", port: 0 }); | |
| 52 | parent = join(dir, "parent.git"); | |
| 53 | work = join(dir, "work"); | |
| 54 | await git(dir, "init", "-q", "--bare", "-b", "main", parent); | |
| 55 | await git(dir, "init", "-q", "-b", "main", work); | |
| 56 | await commit(0); | |
| 57 | const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "kid", email: "[email protected]", isAdmin: true }); | |
| 58 | if (!owner.ok) throw new Error("no owner"); | |
| 59 | ownerId = owner.value.id; | |
| 60 | const made = await createRepo(app, owner.value, { name: "forge", visibility: "public" }); | |
| 61 | if (!made.ok) throw new Error("no repo"); | |
| 62 | await rm(repoDir(app.reposRoot, "kid", "forge"), { recursive: true, force: true }); | |
| 63 | await mkdir(join(app.reposRoot, "kid"), { recursive: true }); | |
| 64 | await git(dir, "clone", "-q", "--bare", parent, repoDir(app.reposRoot, "kid", "forge")); | |
| 65 | await Bun.write( | |
| 66 | join(app.config.dataDir, "parent.json"), | |
| 67 | JSON.stringify({ | |
| 68 | upstream: "https://parent.test", | |
| 69 | forgeCode: { repo: "kid/forge", upstream: "root/forge", mode: "tell-me", tip: "a".repeat(40), chain: [] }, | |
| 70 | }), | |
| 71 | ); | |
| 72 | }); | |
| 73 | afterAll(() => rm(dir, { recursive: true, force: true })); | |
| 74 | ||
| 75 | test("up to date: no card", async () => { | |
| 76 | await checkUpstream(app, { remote: parent }); | |
| 77 | expect(cards()).toEqual([]); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a new tip is one card that links the chain page; the same tip adds none", async () => { | |
| 81 | for (const n of [1, 2]) await commit(n); | |
| 82 | await checkUpstream(app, { remote: parent }); | |
| 83 | expect(cards()).toEqual(["Your parent forge has 2 new commits"]); | |
| 84 | expect(listAssignedTo(app.db, ownerId)[0]!.body).toContain("(/kid/forge/following)"); | |
| 85 | await checkUpstream(app, { remote: parent }); | |
| 86 | expect(cards()).toHaveLength(1); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a failed check in between does not make the next one repeat", async () => { | |
| 90 | await checkUpstream(app, { remote: join(dir, "nowhere.git") }); | |
| 91 | await checkUpstream(app, { remote: parent }); | |
| 92 | expect(cards()).toHaveLength(1); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a later tip is a second card", async () => { | |
| 96 | await commit(3); | |
| 97 | await checkUpstream(app, { remote: parent }); | |
| 98 | expect(cards()).toContain("Your parent forge has 3 new commits"); | |
| 99 | expect(cards()).toHaveLength(2); | |
| 100 | }); | |
| 101 | ||
| 102 | test("take-updates gets none", async () => { | |
| 103 | await link({ mode: "take-updates" }); | |
| 104 | await commit(4); | |
| 105 | await checkUpstream(app, { remote: parent }); | |
| 106 | expect(cards()).toHaveLength(2); | |
| 107 | }); | |
| 108 | ||
| 109 | test("severed gets none", async () => { | |
| 110 | await link({ mode: "tell-me", severedAt: new Date().toISOString() }); | |
| 111 | await commit(5); | |
| 112 | await checkUpstream(app, { remote: parent }); | |
| 113 | expect(cards()).toHaveLength(2); | |
| 114 | }); | |
| 115 | }); |
test/spawn-upstream.test.ts +1 −1
| @@ -80,7 +80,7 @@ describe("looking upstream", () => { | ||
| 80 | 80 | }); |
| 81 | 81 | |
| 82 | 82 | test("words", () => { |
| 83 | const base = { behind: 3, parentTip: "", checkedAt: 0, error: "" }; | |
| 83 | const base = { behind: 3, parentTip: "", checkedAt: 0, error: "", toldTip: "" }; | |
| 84 | 84 | const at = 12 * 60_000; |
| 85 | 85 | expect(upstreamWords({ ...base }, at)).toBe("your parent has 3 new commits (checked 12 min ago)"); |
| 86 | 86 | expect(upstreamWords({ ...base, behind: 0 }, at)).toBe("up to date (checked 12 min ago)"); |
new above
offline