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

Merged opened this

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

Merged

scout/forge:scout/4772-a-repository-s-events-page-through-the-a → main 10 commits fork 5 threads · 5 unresolved

  • Merged. scout/4772-a-repository-s-events-page-through-the-a was merged into main as 12fd6fc.

Review

Sign in to review this change.
11 files changed +212 −31
README.md +1 −1
@@ -208,7 +208,7 @@ runs no check and no preview until a writer approves its head or lets it run
208208 (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull
209209 opened with it says "proposed by child … from …" on every tab of the pull's
210210 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.

Nit: "it used to answer a bare array of every event" overstates it. The base listEvents defaulted to limit: 100, so the old route answered the newest 100. Say "a bare array of the newest 100 events".

212212
213213 ### Proposing to a parent forge
214214
docs/MCP.md +1
@@ -47,6 +47,7 @@ for a tunnel and a forge that sleeps.
4747 | `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 |
4848 | `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 |
4949 | `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 |
5051 | `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 |
5152 | `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 |
5253 | `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[] = [
9292 auth: true,
9393 body: { body: "string" },
9494 },
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" } },
9596 { method: "post", path: `${REPO}/forks`, summary: "Fork a repository", auth: true },
9697 {
9798 method: "post",
src/core/events.ts +11
@@ -110,7 +110,14 @@ const afterEvent = (n: number): string =>
110110 THEN event.seq > (SELECT c.seq FROM event AS c WHERE c.id = ?${n})
111111 ELSE event.id > ?${n} END)`;
112112
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
113118 export interface ListEventsOptions {
119 /** Return events written before this one (exclusive; the next page of a newest-first read). */
120 beforeId?: string;
114121 repoId?: string;
115122 /** Restrict to events of this kind. */
116123 kind?: string;
@@ -206,6 +213,10 @@ export function listEvents(
206213 params.push(options.afterId);
207214 clauses.push(afterEvent(params.length));
208215 }
216 if (options.beforeId !== undefined) {
217 params.push(options.beforeId);
218 clauses.push(beforeEvent(params.length));
219 }
209220 params.push(options.limit ?? 100);
210221 const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
211222 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

audienceFilter(db, viewer) without batch resolves sealed subjects one query each. Build the filter per batch with audienceFilter(db, viewer, batch) so it is one query per chunk, which is how its doc comment says to call it when the caller has the events.

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 found.length and running out of events stop it. When most of a repository's events are hidden from the reader (private app run./look./app.*, admin kinds, sealed subjects), one anonymous request reads the whole outbox. Cap the batches per request (for example 5) and return a cursor that resumes after the last scanned row.

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, next_cursor is scanned. That is the id of the last row read, and it can be an event the reader is not allowed to see (in your test it is a webhook.deleted row). Ids carry a timestamp, so a non-admin learns that a hidden event exists and roughly when it was written. The docstring's line "a hidden event never shifts a page" holds, but the cursor no longer stays inside the reader's view. If you want to close it, make the cursor opaque, for example the event's seq instead of its id (and beforeEvent would compare on seq), so it says nothing more than a position. This is not a blocker.

Low: when the 1,000-row cap stops the scan, next_cursor is scanned, the last row read. That row can be an event hidden from this reader, so a guest learns a hidden event's id, which carries its time. Follow-up as agreed: make the cursor opaque. Encode the visible boundary's seq, or sign it, and leave this PR's shape as it is.

57 return { items: page.map(toReader), next_cursor: next };
58 }
src/http/api.ts +9 −7
@@ -178,9 +178,8 @@ import {
178178 SIGNUP_CLOSED_MESSAGE,
179179 updateProfile,
180180 } from "../actions/users.ts";
181 import { listEvents } from "../core/events.ts";
181 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts";
182182 import { audienceFor } from "../core/audience.ts";
183 import { audienceFilter, eventForReader } from "../core/event_audience.ts";
184183 import {
185184 forkParentFor,
186185 getReposByIds,
@@ -2478,11 +2477,14 @@ export function apiRoutes(router: Router<Handler>): void {
24782477 if (!repo) return notFound();
24792478 // The outbox, as this reader is meant to see it: a webhook's URL and an
24802479 // 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));
24862488 });
24872489
24882490 router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => {
src/http/mcp.ts +41 −16
@@ -18,6 +18,7 @@
1818 */
1919
2020 import { showProvenance } from "../actions/provenance.ts";
21 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts";
2122 import { getProvenance, provenanceFacts, trailFacts } from "../core/provenance.ts";
2223 import { upstreamPayload } from "../spawn/upstream.ts";
2324 import { NOT_A_MEMBER } from "../core/roles.ts";
@@ -491,11 +492,11 @@ const TOOLS: readonly Tool[] = [
491492 type: "object",
492493 properties: {
493494 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." },
495496 owner: { type: "string", maxLength: 39 },
496497 visibility: { type: "string", enum: ["public", "private"], description: "Private unless you say public." },
497498 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." },
499500 },
500501 required: ["name"],
501502 },
@@ -598,7 +599,7 @@ const TOOLS: readonly Tool[] = [
598599 {
599600 name: "tell",
600601 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.",
602603 inputSchema: {
603604 type: "object",
604605 properties: {
@@ -977,7 +978,7 @@ const TOOLS: readonly Tool[] = [
977978 {
978979 name: "close",
979980 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`).",
981982 inputSchema: {
982983 type: "object",
983984 properties: {
@@ -1089,7 +1090,7 @@ const TOOLS: readonly Tool[] = [
10891090 {
10901091 name: "asks_act",
10911092 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>`.",
10931094 inputSchema: {
10941095 type: "object",
10951096 properties: {
@@ -1599,7 +1600,7 @@ const TOOLS: readonly Tool[] = [
15991600 {
16001601 name: "model_ceiling",
16011602 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.",
16031604 inputSchema: {
16041605 type: "object",
16051606 properties: {
@@ -1815,6 +1816,30 @@ const TOOLS: readonly Tool[] = [
18151816 });
18161817 },
18171818 },
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 },
18181843 {
18191844 name: "branches",
18201845 description:
@@ -1973,7 +1998,7 @@ const TOOLS: readonly Tool[] = [
19731998 {
19741999 name: "spawn",
19752000 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).",
19772002 inputSchema: {
19782003 type: "object",
19792004 properties: {
@@ -1991,18 +2016,18 @@ const TOOLS: readonly Tool[] = [
19912016 direction: {
19922017 type: "string",
19932018 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 fresh below) drops "max 2000 chars" and "max 200 chars". The schema has no maxLength for either, so an agent is now told nothing about the limits it will be refused on. When the ceiling is next raised or descriptions are cut in bulk, move these limits into maxLength in the schema. That costs fewer bytes than the prose did.

19952020 },
19962021 fresh: {
19972022 type: "string",
19982023 description:
1999 "One line (max 200 chars): a fresh app to start beside the copied ones.",
2024 "A fresh app beside the copied ones.",
20002025 },
20012026 followMode: {
20022027 type: "string",
20032028 enum: ["tell-me", "take-updates"],
20042029 description:
2005 "tell-me (default) says when the parent moved; take-updates takes it.",
2030 "tell-me (default) or take-updates.",
20062031 },
20072032 size: {
20082033 type: "string",
@@ -2013,7 +2038,7 @@ const TOOLS: readonly Tool[] = [
20132038 keepAccess: {
20142039 type: "boolean",
20152040 description:
2016 "Stay an owner after the child is claimed (default false).",
2041 "Stay an owner once claimed.",
20172042 },
20182043 dryRun: {
20192044 type: "boolean",
@@ -2972,7 +2997,7 @@ const TOOLS: readonly Tool[] = [
29722997 {
29732998 name: "spend",
29742999 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.",
29763001 inputSchema: {
29773002 type: "object",
29783003 properties: {
@@ -3088,7 +3113,7 @@ const TOOLS: readonly Tool[] = [
30883113 },
30893114 {
30903115 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.",
30923117 inputSchema: {
30933118 type: "object",
30943119 properties: {
@@ -3256,7 +3281,7 @@ const TOOLS: readonly Tool[] = [
32563281 },
32573282 {
32583283 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\`.`,
32603285 inputSchema: {
32613286 type: "object",
32623287 properties: {
@@ -3388,7 +3413,7 @@ const TOOLS: readonly Tool[] = [
33883413 inputSchema: {
33893414 type: "object",
33903415 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." },
33923417 ua: { type: "string", maxLength: 100, description: "Part of the user agent." },
33933418 path: { type: "string", maxLength: 200, description: "Path prefix." },
33943419 },
@@ -3777,7 +3802,7 @@ const TOOLS: readonly Tool[] = [
37773802 type: "object",
37783803 properties: {
37793804 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." },
37813806 from: { type: "string", maxLength: 64 },
37823807 to: { type: "string", maxLength: 64 },
37833808 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", () => {
186186 await shOk(["git", "push", "origin", "main"], dir);
187187
188188 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;
193192 const push = list.find((e) => e.kind === "push");
194193 expect(push).toBeDefined();
195194 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)", () => {
150150 headers: { authorization: `Bearer ${token}` },
151151 })
152152 ).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;
154156 expect(
155157 list.some((e) => e.kind === "push" && e.payload.viaApi === true),
156158 ).toBe(true);
test/event-audience.test.ts +83 −2
@@ -187,8 +187,8 @@ describe("who an event is for", () => {
187187 });
188188 const read = (headers: Record<string, string>) =>
189189 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")!);
192192 for (const headers of [{}, { authorization: `Bearer ${bobToken}` }]) {
193193 const seen = await read(headers);
194194 expect(seen.payload["runId"]).toBe("r1");
@@ -203,6 +203,87 @@ describe("who an event is for", () => {
203203 expect(owner.payload["node"]).toBe("vx-secret-node");
204204 });
205205
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
206287 test("the activity rail on the front page says nothing of it to a visitor", async () => {
207288 const page = await fetch(`${base}/`, {
208289 headers: { accept: "text/html" },
test/mcp.test.ts +1
@@ -207,6 +207,7 @@ describe("the forge over MCP", () => {
207207 "decisions_by",
208208 "archive",
209209 "trail",
210 "events",
210211 "branches",
211212 "commits",
212213 "probe",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs