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

Runs that finished without a push, comment or failure leave the timeline and show as one "N quiet runs" line #5829

Merged opened this

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

Merged

scout/forge:scout/5813-runs-that-finished-without-a-push-commen → main 5 commits fork 8 threads · 8 unresolved

  • Merged. scout/5813-runs-that-finished-without-a-push-commen was merged into main as 863dfd4.

Review

Sign in to review this change.
8 files changed +406 −13
docs/MCP.md +1 −1
@@ -62,7 +62,7 @@ for a tunnel and a forge that sleeps.
6262 | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | |
6363 | `explore` | `{ query? }`: the apps and people you can see, ranked by forks, ships, use and freshness, with their numbers; `kind:` `sort:` and words in one line | |
6464 | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | |
65 | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed` | |
65 | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out | |
6666 | `feed_chips` | `{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on | yes |
6767 | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes |
6868 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
src/core/migrations/20261026090000-event-actor-index.ts added +6
@@ -0,0 +1,6 @@
1 // The feed asks whether an agent wrote anything to a repository between two
2 // moments (`feed/quiet.ts`). Without this the answer for a run that wrote
3 // nothing reads every event the repository ever had. Additive.
4 export default `
5 CREATE INDEX IF NOT EXISTS event_repo_actor_at ON event(repo_id, actor_id, created_at);
6 `;
src/feed/feed.ts +60 −4
@@ -14,7 +14,7 @@ import { affinityFor } from "./affinity.ts";
1414 import { canRead, getRepoById } from "../core/repos.ts";
1515 import { deliveredFor, drainQueue, senderWeight } from "../core/shares.ts";
1616 import { getUserById, type User } from "../core/users.ts";
17 import { candidatesFor, type Candidate } from "./items.ts";
17 import { candidatesFor, quietRunsFor, type Candidate } from "./items.ts";
1818 import { writeStamp } from "../core/write_clock.ts";
1919 import { Stamped } from "../kernel/stamped.ts";
2020 import { rank, type FeedItem } from "./rank.ts";
@@ -52,6 +52,48 @@ export function feedCandidateCache(db: Database): Stamped<Candidate[]> {
5252 return cache;
5353 }
5454
55 export interface QuietRuns {
56 readonly count: number;
57 /** The repository with the most of them: where the runs list is worth opening. */
58 readonly repoId: string | null;
59 }
60
61 const quietCaches = new WeakMap<Database, Stamped<QuietRuns>>();
62
63 /** The runs left out of the last day's timeline, less what the viewer muted or switched off. */
64 export function quietRunCount(
65 db: Database,
66 viewer: User,
67 now: number,
68 chips: Chips,
69 muted: ReadonlySet<string>,
70 cached: boolean,
71 ): QuietRuns {
72 const count = (): QuietRuns => {
73 const per = new Map<string, number>();
74 for (const q of quietRunsFor(db, viewer, now)) {
75 if (muted.has(q.repoId) || chipHides(chips, { kind: "run", actorId: q.actorId })) continue;
76 per.set(q.repoId, (per.get(q.repoId) ?? 0) + 1);
77 }
78 let top: string | null = null;
79 let total = 0;
80 for (const [repoId, n] of per) {
81 total += n;
82 if (top === null || n > per.get(top)!) top = repoId;
83 }
84 return { count: total, repoId: top };
85 };
86 const stamp = writeStamp(db);
87 if (!cached || stamp === undefined) return count();
88 let cache = quietCaches.get(db);
89 if (cache === undefined) {
90 cache = new Stamped<QuietRuns>({ maxEntries: 200, maxAgeMs: FEED_CANDIDATES_MAX_AGE_MS });
91 quietCaches.set(db, cache);
92 }
93 const key = `${viewer.id}|${[...chips.people].sort().join(",")}`;
94 return cache.get(key, stamp, count);
95 }
96
5597 function viewerCandidates(
5698 db: Database,
5799 viewer: User,
@@ -76,10 +118,17 @@ export function parseCursor(
76118 return m ? { score: Number(m[1]), id: m[2]! } : undefined;
77119 }
78120
121 /** The page as the API gives it: the quiet runs as a count. */
79122 export function readFeed(
80123 db: Database,
81124 viewer: User,
82 options: {
125 options: FeedOptions = {},
126 ): { items: FeedItem[]; next_cursor: string | null; quietRuns: number } {
127 const { quiet, ...page } = readFeedPage(db, viewer, options);
128 return { ...page, quietRuns: quiet.count };
129 }
130
131 type FeedOptions = {
83132 limit?: number;
84133 cursor?: { score: number; id: string };
85134 now?: number;
@@ -88,8 +137,14 @@ export function readFeed(
88137 withoutAsks?: boolean;
89138 /** Told how long the settled-state read took, for Server-Timing. */
90139 phase?: (name: string, ms: number) => void;
91 } = {},
92 ): { items: FeedItem[]; next_cursor: string | null } {
140 };
141
142 /** The page, with the quiet runs it left out and where most of them are. */
143 export function readFeedPage(
144 db: Database,
145 viewer: User,
146 options: FeedOptions = {},
147 ): { items: FeedItem[]; next_cursor: string | null; quiet: QuietRuns } {
93148 const now = options.now ?? Date.now();
94149 const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX);
95150 const muted = mutedRepos(db, viewer.id);
@@ -151,6 +206,7 @@ export function readFeed(
151206 items,
152207 next_cursor:
153208 ranked.length > limit && last !== undefined ? cursorOf(last) : null,
209 quiet: quietRunCount(db, viewer, now, chips, muted, options.now === undefined),

The cache is keyed on writeStamp, which changes with every write anywhere. On a busy forge this misses on nearly every read, so it does not protect the page from the scan in quiet.ts.

154210 };
155211 }
156212
src/feed/items.ts +64 −2
@@ -30,6 +30,9 @@ import { getRunErrorLine } from "../core/runs.ts";
3030 import { getUserById, type User } from "../core/users.ts";
3131 import { watchedBy } from "../core/watches.ts";
3232 import { chooseCandidates } from "./choose.ts";
33 import { writeStamp } from "../core/write_clock.ts";
34 import { Stamped } from "../kernel/stamped.ts";
35 import { QUIET_WINDOW_MS, quietRunEventIds } from "./quiet.ts";
3336 import { hotSays, standingHot } from "../crew/hot_files.ts";
3437 import { ago } from "./rank.ts";
3538 import { askLinesFor } from "../web/studio/asks.ts";
@@ -92,6 +95,64 @@ export interface FeedAsk {
9295
9396 export const EVENT_WINDOW = 300;
9497 const COST_FLOOR_USD = 1;
98
99 /** A run that cost enough to be a post of its own is never quiet. */
100 const cheap = (e: ForgeEvent): boolean => {
101 const cost = e.payload["costUsd"];
102 return !(typeof cost === "number" && cost >= COST_FLOOR_USD);
103 };
104
105 /** What one read of the viewer's recent events found: the heard ones and the quiet runs among them. */
106 interface Heard {
107 readonly heard: ForgeEvent[];
108 /** Quiet `run.finished` events of the last day, by id. */
109 readonly quiet: Set<string>;
110 readonly finished: ForgeEvent[];
111 }
112
113 const heardCache = new WeakMap<Database, Stamped<Heard>>();
114
115 /**
116 * The viewer's visible recent events and which of the last day's runs among
117 * them were quiet, worked out once and shared by the timeline filter and the
118 * count (both read it under the same write stamp). A quiet run older than the
119 * window stays a post, so none vanishes uncounted.
120 */
outdated

This repeats the work candidatesFor has just done: the same 300 events, audienceFilter, and the per-run quiet lookups, including getForkOf for each run. Work out the quiet set once per read and hand it to both the timeline filter and the count.

121 function heardBy(db: Database, viewer: User, now: number): Heard {
122 const work = (): Heard => {

Low: the quiet set and the count only look at the newest EVENT_WINDOW (300) visible events. On a forge busy enough to write 300 events in under a day, "N quiet runs in the last 24 h" undercounts and gives no sign of it. That is acceptable for now, but say so in the doc comment on quietRunsFor, or word the line so it does not promise the full day.

123 const recent = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW });
124 const heard = recent.filter(audienceFilter(db, viewer, recent));
125 const since = new Date(now - QUIET_WINDOW_MS).toISOString();
126 const finished = heard.filter(
127 (e) => e.kind === "run.finished" && cheap(e) && e.createdAt >= since,
128 );
129 return { heard, finished, quiet: quietRunEventIds(db, finished) };
130 };
131 const stamp = writeStamp(db);
132 if (stamp === undefined) return work();
133 let cache = heardCache.get(db);
134 if (cache === undefined) {
135 cache = new Stamped<Heard>({ maxEntries: 100, maxAgeMs: 60_000 });
136 heardCache.set(db, cache);
137 }
138 return cache.get(`${viewer.id}|${Math.floor(now / 60_000)}`, stamp, work);
139 }
140
141 /**
142 * How many quiet runs the viewer was spared in the last day, and where each
143 * was: the repository and the agent, so the viewer's mutes and chips apply.
144 */
145 export function quietRunsFor(
146 db: Database,
147 viewer: User,
148 now: number,
149 ): { repoId: string; actorId: string | null; id: string }[] {
150 const { quiet, finished } = heardBy(db, viewer, now);
151 const repos = getReposByIds(db, finished.map((e) => e.repoId!));
152 return finished
153 .filter((e) => quiet.has(e.id) && repos.get(e.repoId!)?.archivedAt == null)
154 .map((e) => ({ repoId: e.repoId!, actorId: e.actorId, id: e.id }));
155 }
95156 const clip = (s: string, n = 120): string =>
96157 s.length > n ? `${s.slice(0, n - 1)}…` : s;
97158
@@ -245,8 +306,9 @@ export function candidatesFor(
245306 /** False when the caller shows asks itself (the home pins them): they are not read twice. */
246307 withAsks = true,
247308 ): Candidate[] {
248 const recent = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW });
249 const events = recent.filter(audienceFilter(db, viewer, recent));
309 // A quiet run is counted at the end of the first page, not posted.
310 const { heard, quiet } = heardBy(db, viewer, now);
311 const events = heard.filter((e) => !quiet.has(e.id));
250312 const writable = listReposWritableTo(db, viewer, { limit: 200 });
251313 const asks = withAsks ? askLinesFor(db, viewer, chips) : [];
252314 const decided = listDecidedIn(
src/feed/quiet.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * Quiet runs: an agent run that finished without failing and left nothing
3 * behind — no push (to the repository or the agent's fork), no pull, no comment, no issue — is noise above the posts
4 * that matter. The run's own record says so: its agent, and the window it
5 * worked in, against what that agent wrote to the repository in that window.
6 * A run the record cannot find is never called quiet.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import type { ForgeEvent } from "../core/events.ts";
11 import { getForkOf } from "../core/repos.ts";
12 import { getRunsByIds } from "../core/runs.ts";
13
14 /** What an agent leaves behind that is worth a post of its own. */
15 const LEFT_BEHIND = [
16 "push",
17 "pull.opened",
18 "pull.commented",
19 "pull.reviewed",
20 "pull.merged",
21 "issue.opened",
22 "issue.commented",
23 ];
24
25 /** How far back the feed counts quiet runs. */
26 export const QUIET_WINDOW_MS = 24 * 60 * 60 * 1000;
27
28 const ids = (value: unknown): string => (typeof value === "string" ? value : "");
29
30 /** Of these `run.finished` events, the ids of those that were quiet runs. */
31 export function quietRunEventIds(
32 db: Database,
33 finished: readonly ForgeEvent[],
34 ): Set<string> {
35 const candidates = finished.filter(
36 (e) =>
37 e.kind === "run.finished" &&
38 e.payload["state"] !== "failed" &&
39 e.payload["kind"] === "agent" &&
40 ids(e.payload["runId"]) !== "",
41 );
42 const runs = getRunsByIds(
43 db,
44 candidates.map((e) => ids(e.payload["runId"])),
45 );
46 const marks = LEFT_BEHIND.map((_, n) => `?${n + 5}`).join(",");
47 const left = db.query<{ one: number }, (string | null)[]>(
48 `SELECT 1 AS one FROM event
outdated

High: repo_id = ?1 is the base repository, but crew agents push to their fork (forkFor / landWork in src/crew/runner.ts). The fork's push event has the fork's repo_id, and a fork pull's head refresh writes no event on the base. So every follow-up run that pushed fixes to an open pull counts as quiet and leaves the timeline. Decide this from the run (did landWork push?), or also match the agent's fork's repo_id.

49 WHERE repo_id IN (?1, ?2) AND actor_id = ?3 AND created_at >= ?4

High: this query is planned as SEARCH event USING INDEX event_repo_seq (repo_id=?), and actor_id and created_at are filtered row by row. For a quiet run there is no match, so LIMIT 1 never stops early and SQLite reads every event of both repositories. That happens once per candidate run, on every feed read. Bound it on id or seq derived from startedAt and finishedAt so it uses event_repo(repo_id, id), or add an index on (repo_id, actor_id, created_at) in a migration.

50 AND kind IN (${marks}) AND created_at <= ?${LEFT_BEHIND.length + 5} LIMIT 1`,
51 );
52 const quiet = new Set<string>();
53 const forks = new Map<string, string>();
54 for (const e of candidates) {
55 const run = runs.get(ids(e.payload["runId"]));
56 if (
57 run === undefined ||
58 run.kind !== "agent" ||
59 run.state === "failed" ||
60 run.actorId === null ||
61 run.startedAt === null ||
62 run.finishedAt === null
63 )
64 continue;
65 // Crew agents push to their own fork of the repository, not to it.
66 const forkKey = `${run.actorId}|${run.repoId}`;
67 if (!forks.has(forkKey))
68 forks.set(forkKey, getForkOf(db, run.actorId, run.repoId)?.id ?? run.repoId);
69 const wrote = left.get(
70 run.repoId,
71 forks.get(forkKey)!,
72 run.actorId,
73 run.startedAt,
74 ...LEFT_BEHIND,
75 run.finishedAt,
76 );
77 if (wrote === null) quiet.add(e.id);
78 }
79 return quiet;
80 }
src/http/mcp/tools/feed.ts +1 −1
@@ -3,7 +3,7 @@ import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "feed",
6 doc: "`{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`",
6 doc: "`{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out",
77 description:
88 "Your ranked home timeline across visible apps; `cursor` pages; saved filter (`feed_chips`) applies.",
99 inputSchema: {
src/web/studio/feed.ts +11 −5
@@ -15,10 +15,10 @@ import type { Ctx } from "../../http/context.ts";
1515 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1616 import { placementsOf, type Placement } from "../../core/placements.ts";
1717 import { recentThoughts, type Thought } from "../../core/thoughts.ts";
18 import { parseCursor, readFeed } from "../../feed/feed.ts";
18 import { parseCursor, readFeedPage } from "../../feed/feed.ts";
1919 import type { FeedItem } from "../../feed/rank.ts";
2020 import { getAppByRepo } from "../../core/apps.ts";
21 import { getRepo } from "../../core/repos.ts";
21 import { getRepo, getRepoById } from "../../core/repos.ts";
2222 import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts";
2323 import { mayFetchLook, pictured } from "../pages/looks.ts";
2424 import { html, type Html } from "../html.ts";
@@ -293,7 +293,7 @@ function readRegion(user: User, ctx: Ctx) {
293293 // ask is never a second time a post below it; the feed API keeps them.
294294 // `?cursor=` is a page the line below the last post pointed at; one not issued here is the first page.
295295 const cursor = parseCursor(ctx.url.searchParams.get("cursor") ?? "");
296 const page = readFeed(ctx.app.db, user, {
296 const page = readFeedPage(ctx.app.db, user, {
297297 limit: FEED_FIRST_PAGE,
298298 withoutAsks: true,
299299 phase: (name, ms) => notePhase(ctx.request, name, ms),
@@ -307,7 +307,10 @@ function readRegion(user: User, ctx: Ctx) {
307307 ctx.app.db,
308308 items.map((item) => item.actor ?? ""),
309309 );
310 return { items, next: page.next_cursor, cursor, thoughts, placed, marks, pictures: picturesFor(ctx, items) };
310 const quiet = page.quiet;
311 const quietRepo = quiet.repoId === null ? undefined : getRepoById(ctx.app.db, quiet.repoId);
312 const quietLink = quietRepo === undefined ? "/explore" : `/${quietRepo.ownerName}/${quietRepo.name}/runs?kind=agent`;
313 return { items, next: page.next_cursor, cursor, thoughts, placed, marks, pictures: picturesFor(ctx, items), quiet: quiet.count, quietLink };
311314 }
312315
313316 /** The region: cards, or one line saying there is nothing to do. */
@@ -335,7 +338,7 @@ export function caughtUp(items: readonly FeedItem[], next: string | null, cursor
335338 }
336339
337340 function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): Html {
338 const { items, next, cursor, thoughts, placed, marks, pictures } = read;
341 const { items, next, cursor, thoughts, placed, marks, pictures, quiet, quietLink } = read;
339342 return html`<section
340343 id="live-feed"
341344 class="feed"
@@ -352,6 +355,9 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
352355 note: "When something happens in your apps, it lands here.",
353356 })
354357 : items.map((item) => feedCard(item, marks, pictures.get(item.id)))}
358 ${quiet === 0
359 ? ""
360 : html`<p class="small muted"><a href="${quietLink}">${quiet} quiet ${quiet === 1 ? "run" : "runs"} in the last 24 h</a></p>`}

Low: drawRegion draws this line on every ?cursor= page, but the pull description says first page only. On older pages it repeats the same 24 h count. Gate it on cursor === undefined so it reads as a summary of the newest page.

355361 ${caughtUp(items, next, cursor)}
356362 </section>`;
357363 }
test/feed-quiet.test.ts added +183
@@ -0,0 +1,183 @@
1 /**
2 * Quiet runs: an agent run that ended without failing and left no push,
3 * pull, comment or issue is out of the timeline and counted in one line.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
14 import { forkRepo, type Repo } from "../src/core/repos.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { readFeed, quietRunCount } from "../src/feed/feed.ts";
18 import { candidatesFor } from "../src/feed/items.ts";
19 import { noChips } from "../src/core/feed_chips.ts";
20 import { mcpTool } from "./helpers/mcp.ts";
21 import { promote } from "./helpers/members.ts";
outdated

Missing tests: a muted app's quiet runs are not counted, a hidden person's quiet runs are not counted (chipHides), and a quiet run older than 24 h is not counted. These are the behaviours quietRunCount and QUIET_WINDOW_MS add, and nothing here would fail without them.

22
23 describe("quiet runs", () => {
24 let dataDir: string;
25 let app: App;
26 let servers: Servers;
27 let viewer: User;
28 let quietAgent: User;
29 let busyAgent: User;
30 let forkAgent: User;
31 let repo: Repo;
32 let token: string;
33
34 const mk = async (username: string, first = false) => {
35 const r = await createUser(app.db, {
36 origin: "seed",
37 trusted: true,
38 username,
39 allowReserved: true,
40 email: `${username}@forge.local`,
41 password: "a-good-passphrase",
42 adminIfFirst: first,
43 });
44 if (!r.ok) throw new Error(r.error);
45 promote(app.db, r.value);
46 return r.value;
47 };
48
49 /** A real run row, started and finished, and its `run.finished` on the outbox. */
50 const finished = (actor: User, state: "ok" | "failed", costUsd = 0, push = false) => {
51 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: actor.id });
outdated

This seeds push on the base repo, and that is not where a crew push lands. Add a case where the agent's push lands on its fork (a forkRepo for the agent, push on that repo), and assert the run is still a post. That test fails on this head.

52 claimRun(app.db, run.id);
53 if (push)
54 app.bus.append({ kind: "push", repoId: repo.id, actorId: actor.id, payload: {} });
55 const done = finishRun(app.db, run.id, { state, summary: "did it", costUsd })!;
56 app.bus.append({
57 kind: "run.finished",
58 repoId: repo.id,
59 actorId: actor.id,
60 payload: { runId: done.id, kind: "agent", state, summary: "did it", costUsd },
61 });
62 };
63
64 beforeAll(async () => {
65 dataDir = await mkdtemp(join(tmpdir(), "forge-quiet-"));
66 app = createApp({ dataDir, port: 0 });
67 servers = await serve(app);
68 await mk("root", true);
69 viewer = await mk("viewer");
70 quietAgent = await mk("quiet-agent");
71 busyAgent = await mk("busy-agent");
72 forkAgent = await mk("fork-agent");
73 token = createToken(app.db, viewer.id, "t", "rw").token;
74 const r = await createRepo(app, viewer, { name: "open", visibility: "public" });
75 if (!r.ok) throw new Error("repo");
76 repo = r.value;
77 for (let i = 0; i < 5; i++) finished(quietAgent, "ok");
78 for (let i = 0; i < 2; i++) finished(busyAgent, "ok", 0, true);
79 });
80
81 afterAll(async () => {
82 servers.stop();
83 app.close();
84 await rm(dataDir, { recursive: true, force: true });
85 });
86
87 test("5 quiet runs and 2 that pushed: 2 posts and 5 quiet runs", () => {
88 const page = readFeed(app.db, viewer, { now: Date.now() });
89 expect(page.items.filter((i) => i.kind === "run")).toHaveLength(2);
90 expect(page.quietRuns).toBe(5);
91 });
92
93 test("a run that pushed only to the agent's fork stays in the timeline", async () => {
94 const forked = await forkRepo(app.db, app.reposRoot, repo, forkAgent.id);
95 if (!forked.ok) throw new Error("fork");
96 const before = readFeed(app.db, viewer, { now: Date.now() });
97 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: forkAgent.id });
98 claimRun(app.db, run.id);
99 app.bus.append({ kind: "push", repoId: forked.value.id, actorId: forkAgent.id, payload: {} });
100 const done = finishRun(app.db, run.id, { state: "ok", summary: "fixed", costUsd: 0 })!;
101 app.bus.append({
102 kind: "run.finished",
103 repoId: repo.id,
104 actorId: forkAgent.id,
105 payload: { runId: done.id, kind: "agent", state: "ok", summary: "fixed", costUsd: 0 },
106 });
107 const page = readFeed(app.db, viewer, { now: Date.now() });
108 expect(page.items.filter((i) => i.kind === "run")).toHaveLength(
109 before.items.filter((i) => i.kind === "run").length + 1,
110 );
111 expect(page.quietRuns).toBe(before.quietRuns);
112 });
113
114 test("a failed run and a costly run stay in the timeline", () => {
115 finished(quietAgent, "failed");
116 finished(quietAgent, "ok", 2);
117 const page = readFeed(app.db, viewer, { now: Date.now() });
118 expect(page.items.filter((i) => i.kind === "run" || i.kind === "cost")).toHaveLength(5);
119 expect(page.quietRuns).toBe(5);
120 });
121
122 test("paging stays correct: cursor pages hold the posts and no quiet run", () => {
123 const first = readFeed(app.db, viewer, { limit: 2, now: Date.now() });
124 expect(first.next_cursor).not.toBeNull();
125 const [id, score] = [first.items.at(-1)!.id, first.items.at(-1)!.score];
126 const next = readFeed(app.db, viewer, { limit: 20, cursor: { id, score }, now: Date.now() });
127 const ids = new Set([...first.items, ...next.items].map((i) => i.id));
128 expect(ids.size).toBe(first.items.length + next.items.length);
129 expect(first.items.length + next.items.length).toBe(
130 readFeed(app.db, viewer, { limit: 50, now: Date.now() }).items.length,
131 );
132 });
133
134 test("the home says it in one line linking to the runs list", async () => {
135 const session = createSession(app.db, viewer.id);
136 const res = await fetch(`${servers.url}/live/feed`, {
137 headers: { cookie: `forge_session=${session.token}` },
138 });
139 const text = await res.text();
140 expect(text).toContain("5 quiet runs in the last 24 h");
141 expect(text).toContain(`href="/${repo.ownerName}/open/runs?kind=agent"`);
142 });
143
144 test("MCP feed and the API give quietRuns", async () => {
145 const mcp = await mcpTool(servers.url, token, "feed");
146 expect(mcp.value.quietRuns).toBe(5);
147 expect(mcp.value.items.filter((i: { kind: string }) => i.kind === "run")).toHaveLength(4);
148 const api = await fetch(`${servers.url}/api/v1/feed`, {
149 headers: { authorization: `Bearer ${token}` },
150 });
151 expect(((await api.json()) as { quietRuns: number }).quietRuns).toBe(5);
152 });
153
154 test("a muted app's quiet runs are not counted", () => {
155 const now = Date.now();
156 expect(quietRunCount(app.db, viewer, now, noChips, new Set(), false).count).toBe(5);
157 expect(quietRunCount(app.db, viewer, now, noChips, new Set([repo.id]), false).count).toBe(0);
158 });
159
160 test("a hidden person's quiet runs are not counted", () => {
161 const now = Date.now();
162 const hidden = { kinds: new Set<never>(), people: new Set([quietAgent.id]) };
163 expect(quietRunCount(app.db, viewer, now, hidden, new Set(), false).count).toBe(0);
164 });
165
166 test("quiet runs older than a day are neither counted nor hidden", () => {
167 const later = Date.now() + 25 * 60 * 60 * 1000;
168 expect(quietRunCount(app.db, viewer, later, noChips, new Set(), false).count).toBe(0);
169 const runs = candidatesFor(app.db, viewer, later).filter((c) => c.kind === "run");
170 expect(runs.length).toBeGreaterThanOrEqual(7);
171 });
172
173 test("the per-run lookup uses the actor index", () => {
174 const plan = app.db
175 .query<{ detail: string }, []>(
176 "EXPLAIN QUERY PLAN SELECT 1 FROM event WHERE repo_id IN ('a','b') AND actor_id = 'c' AND created_at >= 'd' AND kind IN ('push') AND created_at <= 'e' LIMIT 1",
177 )
178 .all()
179 .map((r) => r.detail)
180 .join(" ");
181 expect(plan).toContain("event_repo_actor_at");
182 });
183 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs