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

A proposal to the parent forge reaches an open chain page live, through any door #2436

Merged opened this

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

Merged

scout/forge:scout/2230-a-proposal-to-the-parent-forge-reaches-a → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2230-a-proposal-to-the-parent-forge-reaches-a was merged into main as a653a6e.

Review

Sign in to review this change.
4 files changed +49 −5
src/actions/upstream.ts +23 −3
@@ -271,18 +271,32 @@ export async function proposeToParentForge(
271271 return err({ kind: "refused", reason: "the parent's answer was not a pull" });
272272
273273 const now = Date.now();
274 app.db
274 const inserted = app.db
275275 .query(
276276 `INSERT INTO upstream_proposal (repo_id, branch, number, title, source, state, reachable, checked_at, created_at)
277277 VALUES (?1, ?2, ?3, ?4, ?5, 'open', 1, ?6, ?6) ON CONFLICT (repo_id, branch) DO NOTHING`,
278278 )
279279 .run(repo.id, branch, made.number, title.slice(0, 200), source, now);
280 if (inserted.changes > 0) announce(app, repo, actor, made.number, "open");
280281 const row = app.db
281282 .query<Row, [string, string]>("SELECT * FROM upstream_proposal WHERE repo_id = ?1 AND branch = ?2")
282283 .get(repo.id, branch)!;
283284 return ok({ ...asProposal(cred, row), opened: true });
284285 }
285286
287 /**
288 * Tell an open chain page its Proposals card changed. Tied to the repository,
289 * so the stream hands it only to those who can already see the forge.
290 */
291 function announce(app: App, repo: Repo, actor: User | undefined, number: number, state: ForgeProposalState): void {
292 app.bus.append({
293 kind: "forge.proposal",
294 repoId: repo.id,
295 ...(actor === undefined ? {} : { actorId: actor.id }),
296 payload: { repo: `${repo.ownerName}/${repo.name}`, number, state },
297 });
298 }
299
286300 /** Ask the parent how one pull stands; undefined when it did not say. */
287301 async function readState(cred: ParentCredential, number: number): Promise<ForgeProposalState | undefined> {
288302 try {
@@ -323,9 +337,15 @@ export async function forgeProposals(
323337 if (!refresh || i >= REFRESHED || r.state !== "open" || now - r.checked_at < STATE_TTL_MS) return r;
324338 const state = await readState(cred, r.number);
325339 const next = { ...r, state: state ?? r.state, reachable: state === undefined ? 0 : 1, checked_at: now };
340 // The state is compared in the write itself, so two readers racing to
341 // record the same change publish it once between them.
342 const moved = app.db
343 .query("UPDATE upstream_proposal SET state = ?1 WHERE id = ?2 AND state != ?1")
344 .run(next.state, r.id);
326345 app.db
327 .query("UPDATE upstream_proposal SET state = ?1, reachable = ?2, checked_at = ?3 WHERE id = ?4")
328 .run(next.state, next.reachable, now, r.id);
346 .query("UPDATE upstream_proposal SET reachable = ?1, checked_at = ?2 WHERE id = ?3")
347 .run(next.reachable, now, r.id);
348 if (moved.changes > 0) announce(app, repo, undefined, r.number, next.state);
329349 return next;
330350 }),
331351 );

Nit: the two UPDATEs are not atomic. A crash between them leaves state recorded but checked_at stale, which only means one extra read, so harmless. A single UPDATE with RETURNING of the old state would be tidier.

src/web/pages/chain.ts +1 −1
@@ -18,7 +18,7 @@ import { flashOrJson, page, redirect } from "../layout.ts";
1818 import { btn, card } from "../ui.ts";
1919
2020 /** The events that change what the chain page says. */
21 export const CHAIN_LIVE = "forge.severed";
21 export const CHAIN_LIVE = "forge.severed,forge.proposal";
2222
2323 const MODE_WORDS = { "tell-me": "recorded: tell me (nothing tells it yet)", "take-updates": "recorded: take updates (nothing acts on it yet)" } as const;
2424
test/following.test.ts +1 −1
@@ -1082,7 +1082,7 @@ describe("an open chain page hears a sever", () => {
10821082 test("the page carries a live region for forge.severed, scoped to its repository", async () => {
10831083 const page = await chainPage();
10841084 expect(page).toContain('id="live-chain"');
1085 expect(page).toContain('data-live="forge.severed"');
1085 expect(page).toContain('data-live="forge.severed,forge.proposal"');
10861086 expect(page).toContain('data-live-repo="kid/forge"');
10871087 expect(page).not.toContain("follows nothing since");
10881088 });
test/upstream-forge.test.ts +24
@@ -256,11 +256,35 @@ describe("a child forge proposes to its parent forge", () => {
256256 await land("termed", "Add termed");
257257 const toasts: string[] = [];
258258 const ctx = { toast: (m: string) => toasts.push(m), pop() {}, push() {}, theme: {} } as any;
259 const heard: any[] = [];
260 const off = child.bus.subscribe((e) => e.kind === "forge.proposal" && heard.push(e));
259261 const screen = await chainScreen(child, kid, ctx);
260262 expect(screen.keys.map((k: any) => k.keys?.[0] ?? k[0])).toContain("p");
261263 screen.key({ key: "p" } as any);
262264 await Bun.sleep(1500);
265 off();
263266 expect(toasts.join("\n")).toContain("Proposed to the parent forge as #");
267 // One event, tied to the forge's repository so only its readers hear it.
268 expect(heard).toHaveLength(1);
269 expect(heard[0].repoId).toBe(forge.id);
270 expect(heard[0].payload).toMatchObject({ repo: "kid/forge", state: "open" });
271 expect(toasts.join("\n")).toContain(`#${heard[0].payload.number}`);
272 });
273
274 test("a state the parent changes is published once, and not again while it holds", async () => {
275 const open = (await forgeProposals(child, forge, Date.now() + 20 * STATE_TTL_MS)).find((p) => p.state === "open")!;
276 const heard: any[] = [];
277 const off = child.bus.subscribe((e) => e.kind === "forge.proposal" && heard.push(e));
278 // Asked again with the parent unchanged: nothing to say.
279 await forgeProposals(child, forge, Date.now() + 40 * STATE_TTL_MS);
280 expect(heard).toHaveLength(0);
281 parent.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number = ?2").run(lib.id, open.number);
282 await forgeProposals(child, forge, Date.now() + 60 * STATE_TTL_MS);
283 await forgeProposals(child, forge, Date.now() + 80 * STATE_TTL_MS);
284 off();
285 expect(heard).toHaveLength(1);
286 expect(heard[0].payload).toMatchObject({ number: open.number, state: "closed" });
287 expect(heard[0].repoId).toBe(forge.id);
264288 });
265289
266290 test("the page button: a writer's form POST proposes; a cross-origin one is refused", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs