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

src/core/client-apps.ts reads, for each of a client's apps, the title of the work running now or of the last change landed in 7 days #4009

Merged opened this

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

Merged

scout/forge:scout/4007-src-core-client-apps-ts-reads-for-each-o → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/4007-src-core-client-apps-ts-reads-for-each-o was merged into main as 432e50a.

Review

Sign in to review this change.
2 files changed +128 −0
src/core/client-apps.ts +55
@@ -4,6 +4,7 @@
44 * The web home and the terminal both read it, so they cannot disagree.
55 */
66
7 import type { Database } from "bun:sqlite";
78 import type { App } from "../app.ts";
89 import { spawnedRepos } from "./next-steps.ts";
910 import { withoutArchived, type Repo } from "./repos.ts";
@@ -39,3 +40,57 @@ export async function clientAppRows(
3940 return { repo, working: n("running"), queued: n("queued") };
4041 });
4142 }
43
44 /** What one app's agents are doing, in words a client can read. */
45 export type ClientDoing =
46 | { readonly kind: "working"; readonly title: string }
47 | { readonly kind: "landed"; readonly title: string; readonly at: string };
48
49 const WEEK_MS = 7 * 24 * 3600 * 1000;
50
51 /**
52 * For each of `appIds` (repo ids), the title of the work running now, else of
53 * the last change landed in the 7 days before `now`; an app with neither is
54 * left out. One query, at most one row per app.
55 */
56 export function clientDoings(
57 db: Database,
58 appIds: readonly string[],

Doc the contract: appIds must already be filtered to what the viewer can see; this function does not check visibility.

59 now: Date,
60 ): ReadonlyMap<string, ClientDoing> {
61 const doings = new Map<string, ClientDoing>();
62 if (appIds.length === 0) return doings;
63 const marks = appIds.map((_, i) => `?${i + 3}`).join(",");
64 const rows = db
65 .query<
66 { repo_id: string; kind: string; title: string; at: string },
67 (string | number)[]
68 >(
69 `SELECT repo_id, kind, title, at FROM (
70 SELECT repo_id, kind, title, at,
71 ROW_NUMBER() OVER (PARTITION BY repo_id ORDER BY rank, at DESC) AS n
72 FROM (
73 SELECT r.repo_id AS repo_id, 'working' AS kind, i.title AS title,
74 COALESCE(r.started_at, r.created_at) AS at, 0 AS rank
75 FROM run r JOIN issue i ON i.id = r.issue_id
76 WHERE r.kind = 'agent' AND r.state = 'running'
77 AND r.repo_id IN (${marks})
78 UNION ALL
79 SELECT i.repo_id, 'landed', i.title, p.merged_at, 1
80 FROM issue i JOIN pull p ON p.issue_id = i.id
81 WHERE i.kind = 'pull' AND i.state = 'closed'
82 AND i.repo_id IN (${marks}) AND p.merged_at >= ?1
83 )
84 ) WHERE n = 1 LIMIT ?2`,
85 )
86 .all(new Date(now.getTime() - WEEK_MS).toISOString(), appIds.length, ...appIds);
87 for (const row of rows) {
88 doings.set(
89 row.repo_id,
90 row.kind === "working"
91 ? { kind: "working", title: row.title }
92 : { kind: "landed", title: row.title, at: row.at },
93 );
94 }
95 return doings;
96 }
test/client-apps.test.ts added +73
@@ -0,0 +1,73 @@
1 /** What a client's apps are doing: the work running, else the last change landed this week (#4007). */
2 import { beforeEach, describe, expect, test } from "bun:test";
3 import type { Database } from "bun:sqlite";
4 import { clientDoings } from "../src/core/client-apps.ts";
5 import { migrations } from "../src/core/schema.ts";
6 import { openDb } from "../src/kernel/db.ts";
7
8 const NOW = new Date("2026-10-21T12:00:00.000Z");
9 let db: Database;
10
11 beforeEach(() => {
12 db = openDb(":memory:", migrations);
13 db.run("INSERT INTO user (id, username, email, password_hash) VALUES ('u','u','u@x','x')");
14 for (const r of ["a", "b", "c", "d", "e"]) {
15 db.run("INSERT INTO repo (id, owner_id, name) VALUES (?, 'u', ?)", [r, r]);
16 }
17 });
18
19 let n = 0;
20 function issue(repo: string, title: string, kind = "issue"): string {
21 const id = `i${++n}`;
22 db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?, ?, ?, ?, ?, 'u', ?)", [id, repo, n, kind, title, kind === "pull" ? "closed" : "open"]);
23 return id;
24 }
25 function landed(repo: string, title: string, at: string): void {
26 const id = issue(repo, title, "pull");
27 db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?, 'main', 'h', 'o', 'o', ?)", [id, at]);
28 }
29 function running(repo: string, issueId: string | null): void {
30 db.run("INSERT INTO run (id, repo_id, kind, state, issue_id) VALUES (?, ?, 'agent', 'running', ?)", [`r${++n}`, repo, issueId]);
31 }
32
33 describe("clientDoings", () => {
34 test("a running run names its issue", () => {
35 running("a", issue("a", "Add a contact form"));
36 expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "working", title: "Add a contact form" });
37 });
38
39 test("a change landed inside the week is reported with when", () => {
40 landed("a", "Fix the footer", "2026-10-15T13:00:00.000Z");
41 expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "landed", title: "Fix the footer", at: "2026-10-15T13:00:00.000Z" });
42 });
43
44 test("a change landed more than 7 days ago is ignored", () => {
45 landed("a", "Old", "2026-10-14T11:59:59.000Z");
46 expect(clientDoings(db, ["a"], NOW).size).toBe(0);
47 });
48
49 test("the latest landed change wins", () => {
50 landed("a", "First", "2026-10-16T10:00:00.000Z");
51 landed("a", "Second", "2026-10-18T10:00:00.000Z");
52 expect(clientDoings(db, ["a"], NOW).get("a")).toMatchObject({ title: "Second" });
53 });
54
55 test("working beats landed", () => {
56 landed("a", "Done", "2026-10-20T10:00:00.000Z");
57 running("a", issue("a", "Doing"));
58 expect(clientDoings(db, ["a"], NOW).get("a")).toEqual({ kind: "working", title: "Doing" });
59 });
60
61 test("a running run with no issue yields nothing", () => {
62 running("a", null);
63 expect(clientDoings(db, ["a"], NOW).size).toBe(0);
64 });
65
66 test("a quiet app, an unlisted app and no apps yield nothing; apps are kept apart", () => {
67 landed("b", "Mine", "2026-10-20T10:00:00.000Z");
68 landed("c", "Not asked", "2026-10-20T10:00:00.000Z");
69 const got = clientDoings(db, ["a", "b"], NOW);
70 expect([...got.keys()]).toEqual(["b"]);
71 expect(clientDoings(db, [], NOW).size).toBe(0);
72 });
73 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs