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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4007-src-core-client-apps-ts-reads-for-each-o → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +128
−0
src/core/client-apps.ts +55
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | * The web home and the terminal both read it, so they cannot disagree. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 7 | 8 | import type { App } from "../app.ts"; |
| 8 | 9 | import { spawnedRepos } from "./next-steps.ts"; |
| 9 | 10 | import { withoutArchived, type Repo } from "./repos.ts"; |
| @@ -39,3 +40,57 @@ export async function clientAppRows( | ||
| 39 | 40 | return { repo, working: n("running"), queued: n("queued") }; |
| 40 | 41 | }); |
| 41 | 42 | } |
| 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[], | |
| 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 | }); |
new above
offline
Doc the contract: appIds must already be filtered to what the viewer can see; this function does not check visibility.