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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5813-runs-that-finished-without-a-push-commen → main 5 commits fork 8 threads · 8 unresolved
docs/MCP.md +1 −1
| @@ -62,7 +62,7 @@ for a tunnel and a forge that sleeps. | ||
| 62 | 62 | | `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` | | |
| 63 | 63 | | `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 | | |
| 64 | 64 | | `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 | | | |
| 66 | 66 | | `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 | |
| 67 | 67 | | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes | |
| 68 | 68 | | `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"; | ||
| 14 | 14 | import { canRead, getRepoById } from "../core/repos.ts"; |
| 15 | 15 | import { deliveredFor, drainQueue, senderWeight } from "../core/shares.ts"; |
| 16 | 16 | 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"; | |
| 18 | 18 | import { writeStamp } from "../core/write_clock.ts"; |
| 19 | 19 | import { Stamped } from "../kernel/stamped.ts"; |
| 20 | 20 | import { rank, type FeedItem } from "./rank.ts"; |
| @@ -52,6 +52,48 @@ export function feedCandidateCache(db: Database): Stamped<Candidate[]> { | ||
| 52 | 52 | return cache; |
| 53 | 53 | } |
| 54 | 54 | |
| 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 | ||
| 55 | 97 | function viewerCandidates( |
| 56 | 98 | db: Database, |
| 57 | 99 | viewer: User, |
| @@ -76,10 +118,17 @@ export function parseCursor( | ||
| 76 | 118 | return m ? { score: Number(m[1]), id: m[2]! } : undefined; |
| 77 | 119 | } |
| 78 | 120 | |
| 121 | /** The page as the API gives it: the quiet runs as a count. */ | |
| 79 | 122 | export function readFeed( |
| 80 | 123 | db: Database, |
| 81 | 124 | 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 = { | |
| 83 | 132 | limit?: number; |
| 84 | 133 | cursor?: { score: number; id: string }; |
| 85 | 134 | now?: number; |
| @@ -88,8 +137,14 @@ export function readFeed( | ||
| 88 | 137 | withoutAsks?: boolean; |
| 89 | 138 | /** Told how long the settled-state read took, for Server-Timing. */ |
| 90 | 139 | 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 } { | |
| 93 | 148 | const now = options.now ?? Date.now(); |
| 94 | 149 | const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX); |
| 95 | 150 | const muted = mutedRepos(db, viewer.id); |
| @@ -151,6 +206,7 @@ export function readFeed( | ||
| 151 | 206 | items, |
| 152 | 207 | next_cursor: |
| 153 | 208 | ranked.length > limit && last !== undefined ? cursorOf(last) : null, |
| 209 | quiet: quietRunCount(db, viewer, now, chips, muted, options.now === undefined), | |
| 154 | 210 | }; |
| 155 | 211 | } |
| 156 | 212 |
src/feed/items.ts +64 −2
| @@ -30,6 +30,9 @@ import { getRunErrorLine } from "../core/runs.ts"; | ||
| 30 | 30 | import { getUserById, type User } from "../core/users.ts"; |
| 31 | 31 | import { watchedBy } from "../core/watches.ts"; |
| 32 | 32 | 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"; | |
| 33 | 36 | import { hotSays, standingHot } from "../crew/hot_files.ts"; |
| 34 | 37 | import { ago } from "./rank.ts"; |
| 35 | 38 | import { askLinesFor } from "../web/studio/asks.ts"; |
| @@ -92,6 +95,64 @@ export interface FeedAsk { | ||
| 92 | 95 | |
| 93 | 96 | export const EVENT_WINDOW = 300; |
| 94 | 97 | 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 |
||
| 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 |
||
| 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 | } | |
| 95 | 156 | const clip = (s: string, n = 120): string => |
| 96 | 157 | s.length > n ? `${s.slice(0, n - 1)}…` : s; |
| 97 | 158 | |
| @@ -245,8 +306,9 @@ export function candidatesFor( | ||
| 245 | 306 | /** False when the caller shows asks itself (the home pins them): they are not read twice. */ |
| 246 | 307 | withAsks = true, |
| 247 | 308 | ): 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)); | |
| 250 | 312 | const writable = listReposWritableTo(db, viewer, { limit: 200 }); |
| 251 | 313 | const asks = withAsks ? askLinesFor(db, viewer, chips) : []; |
| 252 | 314 | 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: |
||
| 49 | WHERE repo_id IN (?1, ?2) AND actor_id = ?3 AND created_at >= ?4 | |
High: this query is planned as |
||
| 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"; | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | 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", | |
| 7 | 7 | description: |
| 8 | 8 | "Your ranked home timeline across visible apps; `cursor` pages; saved filter (`feed_chips`) applies.", |
| 9 | 9 | inputSchema: { |
src/web/studio/feed.ts +11 −5
| @@ -15,10 +15,10 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 15 | 15 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 16 | 16 | import { placementsOf, type Placement } from "../../core/placements.ts"; |
| 17 | 17 | import { recentThoughts, type Thought } from "../../core/thoughts.ts"; |
| 18 | import { parseCursor, readFeed } from "../../feed/feed.ts"; | |
| 18 | import { parseCursor, readFeedPage } from "../../feed/feed.ts"; | |
| 19 | 19 | import type { FeedItem } from "../../feed/rank.ts"; |
| 20 | 20 | import { getAppByRepo } from "../../core/apps.ts"; |
| 21 | import { getRepo } from "../../core/repos.ts"; | |
| 21 | import { getRepo, getRepoById } from "../../core/repos.ts"; | |
| 22 | 22 | import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts"; |
| 23 | 23 | import { mayFetchLook, pictured } from "../pages/looks.ts"; |
| 24 | 24 | import { html, type Html } from "../html.ts"; |
| @@ -293,7 +293,7 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 293 | 293 | // ask is never a second time a post below it; the feed API keeps them. |
| 294 | 294 | // `?cursor=` is a page the line below the last post pointed at; one not issued here is the first page. |
| 295 | 295 | const cursor = parseCursor(ctx.url.searchParams.get("cursor") ?? ""); |
| 296 | const page = readFeed(ctx.app.db, user, { | |
| 296 | const page = readFeedPage(ctx.app.db, user, { | |
| 297 | 297 | limit: FEED_FIRST_PAGE, |
| 298 | 298 | withoutAsks: true, |
| 299 | 299 | phase: (name, ms) => notePhase(ctx.request, name, ms), |
| @@ -307,7 +307,10 @@ function readRegion(user: User, ctx: Ctx) { | ||
| 307 | 307 | ctx.app.db, |
| 308 | 308 | items.map((item) => item.actor ?? ""), |
| 309 | 309 | ); |
| 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 }; | |
| 311 | 314 | } |
| 312 | 315 | |
| 313 | 316 | /** 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 | ||
| 335 | 338 | } |
| 336 | 339 | |
| 337 | 340 | 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; | |
| 339 | 342 | return html`<section |
| 340 | 343 | id="live-feed" |
| 341 | 344 | class="feed" |
| @@ -352,6 +355,9 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>): | ||
| 352 | 355 | note: "When something happens in your apps, it lands here.", |
| 353 | 356 | }) |
| 354 | 357 | : 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: |
||
| 355 | 361 | ${caughtUp(items, next, cursor)} |
| 356 | 362 | </section>`; |
| 357 | 363 | } |
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 ( |
||
| 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 |
||
| 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 | }); |
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 inquiet.ts.