Public: anyone can read this; the crew can change it.
A repository's events page through the API and MCP `events`: `{ items, next_cursor }`, bounded, with the reader's view kept #4781
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4772-a-repository-s-events-page-through-the-a → main 10 commits fork 5 threads · 5 unresolved
README.md +1 −1
| @@ -208,7 +208,7 @@ runs no check and no preview until a writer approves its head or lets it run | ||
| 208 | 208 | (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull |
| 209 | 209 | opened with it says "proposed by child … from …" on every tab of the pull's |
| 210 | 210 | page, in the terminal's pull view, and as `proposal` in the pull's JSON |
| 211 | (`GET …/pulls/:number`, and MCP's `pull` tool). `GET …/pulls/:number?diff=0` reads the pull without its diff (and skips computing it); `diff` is `0` or `1`, default `1`, and anything else answers 422. `GET …/issues?fields=brief` lists issues with only number, kind, title, state, labels, assignee, author, originAuthor, authorIsAgent, createdAt and updatedAt (and `untrusted` where it applies); no bodies or comments. `fields` is `brief` or left out, and anything else answers 422 `invalid_fields`. | |
| 211 | (`GET …/pulls/:number`, and MCP's `pull` tool). `GET …/pulls/:number?diff=0` reads the pull without its diff (and skips computing it); `diff` is `0` or `1`, default `1`, and anything else answers 422. `GET …/issues?fields=brief` lists issues with only number, kind, title, state, labels, assignee, author, originAuthor, authorIsAgent, createdAt and updatedAt (and `untrusted` where it applies); no bodies or comments. `fields` is `brief` or left out, and anything else answers 422 `invalid_fields`. `GET …/repos/:owner/:name/events` answers `{ items, next_cursor }`, newest first, `limit` 1 to 200 (default 50) and `cursor` from `next_cursor` (anything else is a 422); it used to answer a bare array of every event, so a client that read the array reads `items` now. A request reads at most 1,000 events, so when most of them are hidden from the reader a page can come back short with a `next_cursor`: keep following it until it is null. MCP's `events` tool answers the same. | |
| 212 | 212 | |
| 213 | 213 | ### Proposing to a parent forge |
| 214 | 214 |
docs/MCP.md +1
| @@ -47,6 +47,7 @@ for a tunnel and a forge that sleeps. | ||
| 47 | 47 | | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 48 | 48 | | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 49 | 49 | | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes | |
| 50 | | `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` | no | | |
| 50 | 51 | | `trail` | `{ repo, shown?, dryRun? }`: where a migrated repository came from (`provenance`: source, sourceUrl, parents, migratedAt) and whether it is shown; with `shown`, shows or hides the trail — planned first (`dryRun` is true unless `false`), only the repository's admin with an admin token, never an agent. Hiding keeps the record; a hidden one reads `null` to anyone but an admin | yes | |
| 51 | 52 | | `branches` | `{ repo, name?, action?, from? }`: without `name`, lists the branches; with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes | |
| 52 | 53 | | `commits` | `{ repo, ref?, limit? }`: a branch's recent commits, newest first (`limit` 1–100, default 30; refused outside that, where REST clamps), as `GET …/commits` answers | | |
src/contract/openapi.ts +1
| @@ -92,6 +92,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 92 | 92 | auth: true, |
| 93 | 93 | body: { body: "string" }, |
| 94 | 94 | }, |
| 95 | { method: "get", path: `${REPO}/events`, summary: "Page through a repository's events as the caller may see them, newest first: {items, next_cursor}; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", auth: false, query: { limit: "integer", cursor: "string" } }, | |
| 95 | 96 | { method: "post", path: `${REPO}/forks`, summary: "Fork a repository", auth: true }, |
| 96 | 97 | { |
| 97 | 98 | method: "post", |
src/core/events.ts +11
| @@ -110,7 +110,14 @@ const afterEvent = (n: number): string => | ||
| 110 | 110 | THEN event.seq > (SELECT c.seq FROM event AS c WHERE c.id = ?${n}) |
| 111 | 111 | ELSE event.id > ?${n} END)`; |
| 112 | 112 | |
| 113 | const beforeEvent = (n: number): string => | |
| 114 | `(CASE WHEN EXISTS (SELECT 1 FROM event AS c WHERE c.id = ?${n}) | |
| 115 | THEN event.seq < (SELECT c.seq FROM event AS c WHERE c.id = ?${n}) | |
| 116 | ELSE event.id < ?${n} END)`; | |
| 117 | ||
| 113 | 118 | export interface ListEventsOptions { |
| 119 | /** Return events written before this one (exclusive; the next page of a newest-first read). */ | |
| 120 | beforeId?: string; | |
| 114 | 121 | repoId?: string; |
| 115 | 122 | /** Restrict to events of this kind. */ |
| 116 | 123 | kind?: string; |
| @@ -206,6 +213,10 @@ export function listEvents( | ||
| 206 | 213 | params.push(options.afterId); |
| 207 | 214 | clauses.push(afterEvent(params.length)); |
| 208 | 215 | } |
| 216 | if (options.beforeId !== undefined) { | |
| 217 | params.push(options.beforeId); | |
| 218 | clauses.push(beforeEvent(params.length)); | |
| 219 | } | |
| 209 | 220 | params.push(options.limit ?? 100); |
| 210 | 221 | const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; |
| 211 | 222 | const limit = params.length; |
src/core/events_page.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | import type { Database } from "bun:sqlite"; | |
| 2 | import { audienceFilter, eventForReader } from "./event_audience.ts"; | |
| 3 | import { listEvents } from "./events.ts"; | |
| 4 | ||
| 5 | export const EVENTS_LIMIT_DEFAULT = 50; | |
| 6 | export const EVENTS_LIMIT_MAX = 200; | |
| 7 | ||
| 8 | /** Read a `limit` as the API and MCP both take it: a whole number, 1 to 200, or undefined. */ | |
| 9 | export function eventsLimit(raw: unknown): number | undefined { | |
| 10 | if (raw === undefined || raw === null) return EVENTS_LIMIT_DEFAULT; | |
| 11 | const n = typeof raw === "string" ? (/^\d{1,4}$/.test(raw) ? Number(raw) : 0) : Number(raw); | |
| 12 | return Number.isInteger(n) && n >= 1 && n <= EVENTS_LIMIT_MAX ? n : undefined; | |
| 13 | } | |
| 14 | ||
| 15 | /** A cursor is the id of the last event of the page before. */ | |
| 16 | export const validEventsCursor = (raw: string): boolean => /^[A-Za-z0-9_-]{1,64}$/.test(raw); | |
| 17 | ||
| 18 | /** Rows read per batch, and batches per request: one request reads at most 1,000 events. */ | |
| 19 | const BATCH = 200; | |
| 20 | const BATCHES = 5; | |
| 21 | ||
| 22 | /** | |
| 23 | * A repository's events as this reader is meant to see them, newest first, | |
| 24 | * one bounded page. The audience filter runs before the page is cut, so a | |
| 25 | * hidden event never shifts a page. The read is bounded too: when most of | |
| 26 | * the outbox is hidden from this reader, a page may come back short, with | |
| 27 | * a `next_cursor` that resumes after the last row scanned. | |
| 28 | */ | |
| 29 | export function eventsPage( | |
| 30 | db: Database, | |
outdated
|
||
| 31 | repoId: string, | |
| 32 | viewer: Parameters<typeof audienceFilter>[1], | |
| 33 | limit: number, | |
| 34 | cursor?: string, | |
outdated
High: this loop has no cap on rows scanned. Only |
||
| 35 | ) { | |
| 36 | const toReader = eventForReader(db, viewer); | |
| 37 | const found: ReturnType<typeof listEvents> = []; | |
| 38 | let before = cursor; | |
| 39 | let scanned: string | undefined; | |
| 40 | let more = false; | |
| 41 | for (let n = 0; n < BATCHES && found.length <= limit; n++) { | |
| 42 | const batch = listEvents(db, { | |
| 43 | repoId, | |
| 44 | limit: BATCH, | |
| 45 | ...(before === undefined ? {} : { beforeId: before }), | |
| 46 | }); | |
| 47 | const visible = audienceFilter(db, viewer, batch); | |
| 48 | for (const event of batch) if (visible(event)) found.push(event); | |
| 49 | if (batch.length > 0) scanned = batch[batch.length - 1]!.id; | |
| 50 | more = batch.length === BATCH; | |
| 51 | if (!more) break; | |
| 52 | before = scanned; | |
| 53 | } | |
| 54 | const page = found.slice(0, limit); | |
| 55 | const next = | |
| 56 | found.length > limit ? page[page.length - 1]!.id : more && scanned !== undefined ? scanned : null; | |
Low: when the scan cap stops the read, Low: when the 1,000-row cap stops the scan, |
||
| 57 | return { items: page.map(toReader), next_cursor: next }; | |
| 58 | } |
src/http/api.ts +9 −7
| @@ -178,9 +178,8 @@ import { | ||
| 178 | 178 | SIGNUP_CLOSED_MESSAGE, |
| 179 | 179 | updateProfile, |
| 180 | 180 | } from "../actions/users.ts"; |
| 181 | import { listEvents } from "../core/events.ts"; | |
| 181 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts"; | |
| 182 | 182 | import { audienceFor } from "../core/audience.ts"; |
| 183 | import { audienceFilter, eventForReader } from "../core/event_audience.ts"; | |
| 184 | 183 | import { |
| 185 | 184 | forkParentFor, |
| 186 | 185 | getReposByIds, |
| @@ -2478,11 +2477,14 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2478 | 2477 | if (!repo) return notFound(); |
| 2479 | 2478 | // The outbox, as this reader is meant to see it: a webhook's URL and an |
| 2480 | 2479 | // app's variable names are the admins', not every reader's. |
| 2481 | return json( | |
| 2482 | listEvents(ctx.app.db, { repoId: repo.id }).filter( | |
| 2483 | audienceFilter(ctx.app.db, ctx.auth.user), | |
| 2484 | ).map(eventForReader(ctx.app.db, ctx.auth.user)), | |
| 2485 | ); | |
| 2480 | const q = ctx.url.searchParams; | |
| 2481 | const limit = eventsLimit(q.get("limit")); | |
| 2482 | if (limit === undefined) | |
| 2483 | return apiError("invalid_limit", 422, `limit is a whole number from 1 to ${EVENTS_LIMIT_MAX}`); | |
| 2484 | const cursor = q.get("cursor"); | |
| 2485 | if (cursor !== null && !validEventsCursor(cursor)) | |
| 2486 | return apiError("invalid_cursor", 422, "cursor is the next_cursor of a page before"); | |
| 2487 | return json(eventsPage(ctx.app.db, repo.id, ctx.auth.user, limit, cursor ?? undefined)); | |
| 2486 | 2488 | }); |
| 2487 | 2489 | |
| 2488 | 2490 | router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => { |
src/http/mcp.ts +41 −16
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | 20 | import { showProvenance } from "../actions/provenance.ts"; |
| 21 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts"; | |
| 21 | 22 | import { getProvenance, provenanceFacts, trailFacts } from "../core/provenance.ts"; |
| 22 | 23 | import { upstreamPayload } from "../spawn/upstream.ts"; |
| 23 | 24 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| @@ -491,11 +492,11 @@ const TOOLS: readonly Tool[] = [ | ||
| 491 | 492 | type: "object", |
| 492 | 493 | properties: { |
| 493 | 494 | name: { type: "string", maxLength: 100 }, |
| 494 | description: { type: "string", maxLength: 4000, description: "What it should become; opens the first issue." }, | |
| 495 | description: { type: "string", maxLength: 4000, description: "What it should become; the first issue." }, | |
| 495 | 496 | owner: { type: "string", maxLength: 39 }, |
| 496 | 497 | visibility: { type: "string", enum: ["public", "private"], description: "Private unless you say public." }, |
| 497 | 498 | agent: { type: "string", maxLength: 39, description: "Agent for the crew and first issue." }, |
| 498 | pick: { type: "boolean", description: "Pick the agent from the description when `agent` is not given." }, | |
| 499 | pick: { type: "boolean", description: "Pick the agent from the text." }, | |
| 499 | 500 | }, |
| 500 | 501 | required: ["name"], |
| 501 | 502 | }, |
| @@ -598,7 +599,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 598 | 599 | { |
| 599 | 600 | name: "tell", |
| 600 | 601 | description: |
| 601 | "Tell an app's crew what should change (first line is the title). Without `repo` it is only kept, answering a thought id.", | |
| 602 | "Tell an app's crew what should change (first line is the title). Without `repo` it is only kept; answers a thought id.", | |
| 602 | 603 | inputSchema: { |
| 603 | 604 | type: "object", |
| 604 | 605 | properties: { |
| @@ -977,7 +978,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 977 | 978 | { |
| 978 | 979 | name: "close", |
| 979 | 980 | description: |
| 980 | "Close an issue or pull, or reopen it (`state` open); the PATCH route's refusals apply. `reason`: done, dropped, or split (with `into`).", | |
| 981 | "Close an issue or pull, or reopen it (`state` open); PATCH refusals apply. `reason`: done, dropped, split (`into`).", | |
| 981 | 982 | inputSchema: { |
| 982 | 983 | type: "object", |
| 983 | 984 | properties: { |
| @@ -1089,7 +1090,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1089 | 1090 | { |
| 1090 | 1091 | name: "asks_act", |
| 1091 | 1092 | description: |
| 1092 | "Decide many asks at once: dismiss, decline, ship (never undone), close (`reason`), mute, replan. `items`: ids from `asks`, or `issue:<id>`.", | |
| 1093 | "Decide many asks: dismiss, decline, ship (never undone), close (`reason`), mute, replan. `items`: ids from `asks`, or `issue:<id>`.", | |
| 1093 | 1094 | inputSchema: { |
| 1094 | 1095 | type: "object", |
| 1095 | 1096 | properties: { |
| @@ -1599,7 +1600,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1599 | 1600 | { |
| 1600 | 1601 | name: "model_ceiling", |
| 1601 | 1602 | description: |
| 1602 | "Set an account's daily and per-run model spend ceilings in dollars; a run that would pass either does not start.", | |
| 1603 | "Set an account's daily and per-run model spend ceilings in dollars; a run past either does not start.", | |
| 1603 | 1604 | inputSchema: { |
| 1604 | 1605 | type: "object", |
| 1605 | 1606 | properties: { |
| @@ -1815,6 +1816,30 @@ const TOOLS: readonly Tool[] = [ | ||
| 1815 | 1816 | }); |
| 1816 | 1817 | }, |
| 1817 | 1818 | }, |
| 1819 | { | |
| 1820 | name: "events", | |
| 1821 | description: "A repository's events, paged.", | |
| 1822 | inputSchema: { | |
| 1823 | type: "object", | |
| 1824 | properties: { | |
| 1825 | repo: REPO_ARG, | |
| 1826 | limit: { type: "integer" }, | |
| 1827 | cursor: { type: "string" }, | |
| 1828 | }, | |
| 1829 | required: ["repo"], | |
| 1830 | }, | |
| 1831 | writes: false, | |
| 1832 | run(ctx, user, args) { | |
| 1833 | const repo = repoArg(ctx, user, args); | |
| 1834 | if (isAnswer(repo)) return repo; | |
| 1835 | const limit = eventsLimit(args["limit"]); | |
| 1836 | if (limit === undefined) return bad("invalid", `limit is 1 to ${EVENTS_LIMIT_MAX}`); | |
| 1837 | const given = args["cursor"]; | |
| 1838 | if (given !== undefined && (typeof given !== "string" || !validEventsCursor(given))) | |
| 1839 | return bad("invalid", "that cursor was not issued here"); | |
| 1840 | return good(eventsPage(ctx.app.db, repo.id, user, limit, given)); | |
| 1841 | }, | |
| 1842 | }, | |
| 1818 | 1843 | { |
| 1819 | 1844 | name: "branches", |
| 1820 | 1845 | description: |
| @@ -1973,7 +1998,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1973 | 1998 | { |
| 1974 | 1999 | name: "spawn", |
| 1975 | 2000 | description: |
| 1976 | "Spawn a child forge with the named apps and the crew's briefs. Plans first (dryRun false makes it).", | |
| 2001 | "Spawn a child forge with the named apps and the crew's briefs. Plans first (dryRun false spawns).", | |
| 1977 | 2002 | inputSchema: { |
| 1978 | 2003 | type: "object", |
| 1979 | 2004 | properties: { |
| @@ -1991,18 +2016,18 @@ const TOOLS: readonly Tool[] = [ | ||
| 1991 | 2016 | direction: { |
| 1992 | 2017 | type: "string", |
| 1993 | 2018 | description: |
| 1994 | "What the child is for (max 2000 chars); its planner's first issue.", | |
| 2019 | "What the child is for; its first issue.", | |
This trim (and |
||
| 1995 | 2020 | }, |
| 1996 | 2021 | fresh: { |
| 1997 | 2022 | type: "string", |
| 1998 | 2023 | description: |
| 1999 | "One line (max 200 chars): a fresh app to start beside the copied ones.", | |
| 2024 | "A fresh app beside the copied ones.", | |
| 2000 | 2025 | }, |
| 2001 | 2026 | followMode: { |
| 2002 | 2027 | type: "string", |
| 2003 | 2028 | enum: ["tell-me", "take-updates"], |
| 2004 | 2029 | description: |
| 2005 | "tell-me (default) says when the parent moved; take-updates takes it.", | |
| 2030 | "tell-me (default) or take-updates.", | |
| 2006 | 2031 | }, |
| 2007 | 2032 | size: { |
| 2008 | 2033 | type: "string", |
| @@ -2013,7 +2038,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2013 | 2038 | keepAccess: { |
| 2014 | 2039 | type: "boolean", |
| 2015 | 2040 | description: |
| 2016 | "Stay an owner after the child is claimed (default false).", | |
| 2041 | "Stay an owner once claimed.", | |
| 2017 | 2042 | }, |
| 2018 | 2043 | dryRun: { |
| 2019 | 2044 | type: "boolean", |
| @@ -2972,7 +2997,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2972 | 2997 | { |
| 2973 | 2998 | name: "spend", |
| 2974 | 2999 | description: |
| 2975 | "Report what your session spent on an app's work the forge did not run: who, model, tokens and/or dollars, issue or pull.", | |
| 3000 | "Report what your session spent on work the forge did not run: who, model, tokens or dollars, issue or pull.", | |
| 2976 | 3001 | inputSchema: { |
| 2977 | 3002 | type: "object", |
| 2978 | 3003 | properties: { |
| @@ -3088,7 +3113,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3088 | 3113 | }, |
| 3089 | 3114 | { |
| 3090 | 3115 | name: "wait_for", |
| 3091 | description: "Make an issue wait for another in the same app, or stop waiting with `stop: true`.", | |
| 3116 | description: "Make an issue wait for another; `stop: true` ends it.", | |
| 3092 | 3117 | inputSchema: { |
| 3093 | 3118 | type: "object", |
| 3094 | 3119 | properties: { |
| @@ -3256,7 +3281,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3256 | 3281 | }, |
| 3257 | 3282 | { |
| 3258 | 3283 | name: "plan", |
| 3259 | description: `Open and link several small issues, all or none (max ${PLAN_MAX}). With pillars, each has \`pillar\` or \`later: true\` with \`why\`.`, | |
| 3284 | description: `Open and link small issues, all or none (max ${PLAN_MAX}); each has \`pillar\` or \`later: true\` with \`why\`.`, | |
| 3260 | 3285 | inputSchema: { |
| 3261 | 3286 | type: "object", |
| 3262 | 3287 | properties: { |
| @@ -3388,7 +3413,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3388 | 3413 | inputSchema: { |
| 3389 | 3414 | type: "object", |
| 3390 | 3415 | properties: { |
| 3391 | since: { type: "string", maxLength: 40, description: "ISO time; default last day, max 14 days." }, | |
| 3416 | since: { type: "string", maxLength: 40, description: "ISO time; default a day, max 14 days." }, | |
| 3392 | 3417 | ua: { type: "string", maxLength: 100, description: "Part of the user agent." }, |
| 3393 | 3418 | path: { type: "string", maxLength: 200, description: "Path prefix." }, |
| 3394 | 3419 | }, |
| @@ -3777,7 +3802,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3777 | 3802 | type: "object", |
| 3778 | 3803 | properties: { |
| 3779 | 3804 | slug: { type: "string", maxLength: 40 }, |
| 3780 | panel: { type: "integer", minimum: 0, maximum: 23, description: "One panel's data, by place from 0." }, | |
| 3805 | panel: { type: "integer", minimum: 0, maximum: 23, description: "One panel, by place from 0." }, | |
| 3781 | 3806 | from: { type: "string", maxLength: 64 }, |
| 3782 | 3807 | to: { type: "string", maxLength: 64 }, |
| 3783 | 3808 | step: { type: "string", maxLength: 16, description: "With panel: seconds per bucket, or a count of s, m, h or d." }, |
test/app.test.ts +3 −4
| @@ -186,10 +186,9 @@ describe("the forge, end to end", () => { | ||
| 186 | 186 | await shOk(["git", "push", "origin", "main"], dir); |
| 187 | 187 | |
| 188 | 188 | const events = await api("/api/v1/repos/alice/app/events"); |
| 189 | const list = (await events.json()) as { | |
| 190 | kind: string; | |
| 191 | payload: { updates?: { ref: string }[] }; | |
| 192 | }[]; | |
| 189 | const list = ((await events.json()) as { | |
| 190 | items: { kind: string; payload: { updates?: { ref: string }[] } }[]; | |
| 191 | }).items; | |
| 193 | 192 | const push = list.find((e) => e.kind === "push"); |
| 194 | 193 | expect(push).toBeDefined(); |
| 195 | 194 | expect(push?.payload.updates?.[0]?.ref).toBe("refs/heads/main"); |
test/contents-api.test.ts +3 −1
| @@ -150,7 +150,9 @@ describe("commit via API (no git client)", () => { | ||
| 150 | 150 | headers: { authorization: `Bearer ${token}` }, |
| 151 | 151 | }) |
| 152 | 152 | ).json(); |
| 153 | const list = events as { kind: string; payload: { viaApi?: boolean } }[]; | |
| 153 | const list = ( | |
| 154 | events as { items: { kind: string; payload: { viaApi?: boolean } }[] } | |
| 155 | ).items; | |
| 154 | 156 | expect( |
| 155 | 157 | list.some((e) => e.kind === "push" && e.payload.viaApi === true), |
| 156 | 158 | ).toBe(true); |
test/event-audience.test.ts +83 −2
| @@ -187,8 +187,8 @@ describe("who an event is for", () => { | ||
| 187 | 187 | }); |
| 188 | 188 | const read = (headers: Record<string, string>) => |
| 189 | 189 | fetch(`${base}/api/v1/repos/alice/open/events`, { headers }) |
| 190 | .then((r) => r.json() as Promise<{ kind: string; payload: Record<string, unknown> }[]>) | |
| 191 | .then((all) => all.find((e) => e.kind === "run.finished")!); | |
| 190 | .then((r) => r.json() as Promise<{ items: { kind: string; payload: Record<string, unknown> }[] }>) | |
| 191 | .then((all) => all.items.find((e) => e.kind === "run.finished")!); | |
| 192 | 192 | for (const headers of [{}, { authorization: `Bearer ${bobToken}` }]) { |
| 193 | 193 | const seen = await read(headers); |
| 194 | 194 | expect(seen.payload["runId"]).toBe("r1"); |
| @@ -203,6 +203,87 @@ describe("who an event is for", () => { | ||
| 203 | 203 | expect(owner.payload["node"]).toBe("vx-secret-node"); |
| 204 | 204 | }); |
| 205 | 205 | |
| 206 | test("MCP `events` keeps the reader's view: no webhook URL or variable name for a reader, all for an admin", async () => { | |
| 207 | const call = async (token: string, args: unknown) => { | |
| 208 | const res = await fetch(`${base}/mcp`, { | |
| 209 | method: "POST", | |
| 210 | headers: { | |
| 211 | "content-type": "application/json", | |
| 212 | accept: "application/json, text/event-stream", | |
| 213 | authorization: `Bearer ${token}`, | |
| 214 | }, | |
| 215 | body: JSON.stringify({ | |
| 216 | jsonrpc: "2.0", | |
| 217 | id: 1, | |
| 218 | method: "tools/call", | |
| 219 | params: { name: "events", arguments: args }, | |
| 220 | }), | |
| 221 | }); | |
| 222 | return ((await res.json()) as any).result as { isError?: boolean; content: { text: string }[] }; | |
| 223 | }; | |
| 224 | const reader = await call(bobToken, { repo: "alice/open" }); | |
| 225 | expect(reader.isError).not.toBe(true); | |
| 226 | const text = reader.content[0]!.text; | |
| 227 | expect(text).toContain('"push"'); | |
| 228 | expect(text).toContain("next_cursor"); | |
| 229 | expect(text).not.toContain("hooks.example.test"); | |
| 230 | expect(text).not.toContain("STRIPE_SECRET_KEY"); | |
| 231 | const admin = (await call(aliceToken, { repo: "alice/open" })).content[0]!.text; | |
| 232 | expect(admin).toContain("webhook.created"); | |
| 233 | expect(admin).toContain("app.env_set"); | |
| 234 | expect((await call(bobToken, { repo: "alice/open", limit: 0 })).isError).toBe(true); | |
| 235 | expect((await call(bobToken, { repo: "alice/open", limit: 201 })).isError).toBe(true); | |
| 236 | }); | |
| 237 | ||
| 238 | test("the API pages events: limit cuts, next_cursor reaches the end, a bad limit is a 422", async () => { | |
| 239 | const get = (qs: string) => | |
| 240 | fetch(`${base}/api/v1/repos/alice/open/events${qs}`, { | |
| 241 | headers: { authorization: `Bearer ${aliceToken}` }, | |
| 242 | }); | |
| 243 | type Page = { items: { id: string }[]; next_cursor: string | null }; | |
| 244 | const everything = (await (await get("")).json()) as Page; | |
| 245 | expect(everything.next_cursor).toBeNull(); | |
| 246 | expect(everything.items.length).toBeGreaterThan(3); | |
| 247 | const seen: string[] = []; | |
| 248 | let cursor: string | null = null; | |
| 249 | let pages = 0; | |
| 250 | do { | |
| 251 | const page = (await (await get(`?limit=2${cursor ? `&cursor=${cursor}` : ""}`)).json()) as Page; | |
| 252 | expect(page.items.length).toBeLessThanOrEqual(2); | |
| 253 | seen.push(...page.items.map((e) => e.id)); | |
| 254 | cursor = page.next_cursor; | |
| 255 | pages++; | |
| 256 | } while (cursor !== null && pages < 100); | |
| 257 | expect(cursor).toBeNull(); | |
| 258 | expect(pages).toBeGreaterThan(1); | |
| 259 | expect(seen).toEqual(everything.items.map((e) => e.id)); | |
| 260 | for (const bad of ["limit=0", "limit=201", "limit=abc", "limit=1.5", "cursor=a%20b"]) | |
| 261 | expect((await get(`?${bad}`)).status).toBe(422); | |
| 262 | }); | |
| 263 | ||
| 264 | test("a reader's page of a mostly hidden outbox is bounded and paging reaches the same end", async () => { | |
| 265 | for (let i = 0; i < 1100; i++) | |
| 266 | app.bus.append({ kind: "webhook.deleted", repoId: repo.id, actorId: alice.id, payload: { n: i } }); | |
| 267 | app.bus.append({ kind: "push", repoId: repo.id, actorId: alice.id, payload: { late: true } }); | |
| 268 | type Page = { items: { id: string; kind: string }[]; next_cursor: string | null }; | |
| 269 | const get = (qs: string) => fetch(`${base}/api/v1/repos/alice/open/events${qs}`).then((r) => r.json() as Promise<Page>); | |
| 270 | const first = await get("?limit=200"); | |
| 271 | // The newest are visible, but 1,100 hidden rows lie before the older ones: the scan stops. | |
| 272 | expect(first.items.length).toBeLessThan(200); | |
| 273 | expect(first.next_cursor).not.toBeNull(); | |
| 274 | const seen: string[] = []; | |
| 275 | let page = first; | |
| 276 | for (let i = 0; i < 50; i++) { | |
| 277 | seen.push(...page.items.map((e) => e.id)); | |
| 278 | if (page.next_cursor === null) break; | |
| 279 | page = await get(`?limit=200&cursor=${page.next_cursor}`); | |
| 280 | } | |
| 281 | expect(page.next_cursor).toBeNull(); | |
| 282 | const all = listEvents(app.db, { repoId: repo.id, limit: 100_000 }).filter(audienceFilter(app.db, undefined)); | |
| 283 | expect(seen).toEqual(all.map((e) => e.id)); | |
| 284 | expect(seen.length).toBeGreaterThan(0); | |
| 285 | }); | |
| 286 | ||
| 206 | 287 | test("the activity rail on the front page says nothing of it to a visitor", async () => { |
| 207 | 288 | const page = await fetch(`${base}/`, { |
| 208 | 289 | headers: { accept: "text/html" }, |
test/mcp.test.ts +1
| @@ -207,6 +207,7 @@ describe("the forge over MCP", () => { | ||
| 207 | 207 | "decisions_by", |
| 208 | 208 | "archive", |
| 209 | 209 | "trail", |
| 210 | "events", | |
| 210 | 211 | "branches", |
| 211 | 212 | "commits", |
| 212 | 213 | "probe", |
Nit: "it used to answer a bare array of every event" overstates it. The base
listEventsdefaulted tolimit: 100, so the old route answered the newest 100. Say "a bare array of the newest 100 events".