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

Merged opened this

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

Merged

scout/forge:scout/2633-tell-me-when-the-parent-forge-s-code-mov → main 1 commit fork

  • Merged. scout/2633-tell-me-when-the-parent-forge-s-code-mov was merged into main as a03a850.

Review

Sign in to review this change.
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
388388 the count shows on the chain page, in the terminal's **Chain** (`c` checks now), and as
389389 `upstream` in the API and MCP payloads. `POST /:owner/forge/following/check` (a writer,
390390 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
392392 to the root is at `/:owner/forge/following`, in `chain` of `GET
393393 /api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the
394394 terminal's **Chain** command; the owner severs it with the page's Sever,
src/spawn/upstream.ts +39 −5
@@ -13,7 +13,10 @@
1313 import { rename } from "node:fs/promises";
1414 import { join } from "node:path";
1515 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";
1720 import { runGit } from "../git/env.ts";
1821 import { repoDir } from "../git/repos.ts";
1922 import { forgeLinkOf, originOf } from "./chain.ts";
@@ -31,6 +34,8 @@ export interface UpstreamState {
3134 readonly checkedAt: number;
3235 /** One line; "" when the last attempt worked. */
3336 readonly error: string;
37 /** The parent tip the owner was last told about; "" when never. */
38 readonly toldTip: string;
3439 }
3540
3641 const statePath = (app: App): string => join(app.config.dataDir, "upstream.json");
@@ -46,6 +51,7 @@ export async function upstreamState(app: App): Promise<UpstreamState | undefined
4651 parentTip: typeof d["parentTip"] === "string" && TIP.test(d["parentTip"]) ? d["parentTip"] : "",
4752 checkedAt: d["checkedAt"],
4853 error: typeof d["error"] === "string" ? d["error"].slice(0, 200) : "",
54 toldTip: typeof d["toldTip"] === "string" && TIP.test(d["toldTip"]) ? d["toldTip"] : "",
4955 };
5056 } catch {
5157 return undefined;
@@ -108,7 +114,7 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis
108114 const line = fetched.error.stderr.split("\n").find((l) => l.trim() !== "")?.trim();
109115 const error = line ?? (fetched.error.code === 137 ? "the fetch timed out after 60 s" : "the fetch failed");
110116 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 ?? "" };
112118 } else {
113119 const tip = await runGit(["rev-parse", "--verify", "-q", "refs/upstream/HEAD"], { cwd: dir });
114120 const count = tip.ok
@@ -120,11 +126,17 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis
120126 parentTip: tip.ok && TIP.test(tip.value.trim()) ? tip.value.trim() : "",
121127 checkedAt,
122128 error: Number.isSafeInteger(behind) ? "" : "the parent has no code to compare",
129 toldTip: (await upstreamState(app))?.toldTip ?? "",
123130 };
124131 }
125 await keep(app, state);
126 // Tell an open chain page its line changed; no actor when the timer checks.
127132 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.
128140 if (repo !== undefined)
129141 app.bus.append({
130142 kind: "forge.upstream",
@@ -133,7 +145,29 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis
133145 });
134146 return state;
135147 } 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.
137171 }
138172 }
139173
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", () => {
8080 });
8181
8282 test("words", () => {
83 const base = { behind: 3, parentTip: "", checkedAt: 0, error: "" };
83 const base = { behind: 3, parentTip: "", checkedAt: 0, error: "", toldTip: "" };
8484 const at = 12 * 60_000;
8585 expect(upstreamWords({ ...base }, at)).toBe("your parent has 3 new commits (checked 12 min ago)");
8686 expect(upstreamWords({ ...base, behind: 0 }, at)).toBe("up to date (checked 12 min ago)");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs