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

A settings change is on the trail with who and from what to what, and `events` filters by kind #7700

Merged opened this

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

Merged

scout/forge:scout/7653-a-settings-change-is-on-the-trail-with-w → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/7653-a-settings-change-is-on-the-trail-with-w was merged into main as d122d98.

Review

Sign in to review this change.
18 files changed +269 −37
README.md +2 −2
@@ -312,7 +312,7 @@ runs no check and no preview until a writer approves its head or lets it run
312312 (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull
313313 opened with it says "proposed by child … from …" on every tab of the pull's
314314 page, in the terminal's pull view, and as `proposal` in the pull's JSON
315 (`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. `GET …/repos/:owner/:name/branches` answers `{ items, next_cursor }` in the order it always had, `limit` 1 to 200 (default 50) and `cursor` the `next_cursor` of the page before (the name of the last branch on it); anything else is a 422, and a cursor naming a branch deleted between two requests is `invalid_cursor`, so start over. It used to answer a bare array, so a client that read the array reads `items` now. `GET …/repos/:owner/:name/forks`, `…/labels` and `…/hooks` answer `{ items, next_cursor }` too, in the order they always had, `limit` 1 to 200 (default 50) and `cursor` the `next_cursor` of the page before (the id of the last row on it); anything else is a 422, and a cursor naming a row deleted between two requests is `invalid_cursor`, so start over. They used to answer a bare array, so a client that read the array reads `items` now.
315 (`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. Both take `kind` to keep one event kind; every `repo.settings_updated` carries `changed: { setting: { from, to } }`. `GET …/repos/:owner/:name/branches` answers `{ items, next_cursor }` in the order it always had, `limit` 1 to 200 (default 50) and `cursor` the `next_cursor` of the page before (the name of the last branch on it); anything else is a 422, and a cursor naming a branch deleted between two requests is `invalid_cursor`, so start over. It used to answer a bare array, so a client that read the array reads `items` now. `GET …/repos/:owner/:name/forks`, `…/labels` and `…/hooks` answer `{ items, next_cursor }` too, in the order they always had, `limit` 1 to 200 (default 50) and `cursor` the `next_cursor` of the page before (the id of the last row on it); anything else is a 422, and a cursor naming a row deleted between two requests is `invalid_cursor`, so start over. They used to answer a bare array, so a client that read the array reads `items` now.
316316
317317 ### Proposing to a parent forge
318318
@@ -435,7 +435,7 @@ This page is the front door. Everything else is a file of its own under
435435 - The `flow` label: ready issues labelled `flow` go first (`readyNow` in `src/core/dependencies.ts` is the one place); when none is ready, builders take other ready work as usual. There is no switch and no state. A writer puts an issue first with the **Flow first** button on the issue and on each row of its list (`POST …/issues/:number/flow`), with `f` on the terminal issue screen, or with MCP `labels` and `action: "add"` (all three make the label on first use, for a writer only); the map's "Ready now" shows labelled work on top, marked `flow`.
436436 - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`.
437437 - Prompt source at the terminal: `i` on the Deploys screen opens the app's Instagram prompt source, as Settings › Prompts does, and is offered only to the repository's admin who is a person (an agent, a reader or a guest sees no `i` and it does nothing). It lists the account, when it last ran and what it said, and when the token is refreshed again. `a` connects one (the token typed masked, then the hour in UTC; it is never drawn or logged), `f` fetches now and says what came back, `d` removes it after a `y/n` confirm. A second `f` while a fetch is running is ignored. Refusals are the page's words (`invalid_token`, `invalid_at`, `no_such_source`, `not_running`, and `already_connected` only when an account was connected on the web while the screen was open: the screen offers `a` only when there is none).
438 - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins').
438 - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `s` keeps only settings changes (the same `kind=repo.settings_updated` filter) and shows each as who changed what, from what to what (`alice changed repository settings: requireReview false → true`), `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins').
439439 - Why it is not moving: MCP `flow` with `{ repo }`, `{ repo, issue }` or `{ repo, pull }` (a writer's) answers the first gate that holds it (`ceiling`, `hot_file`, `blocked_by`, `later_gate`, `no_builder`, `no_headroom`, `red_check`, `waiting_review`) with the action that clears it, or `state: closed`/`merged` when nothing is left to move; a pull is read against the head git has now. The terminal's About card (`I`) carries the repository's answer in a **Moving** row for a writer (`src/flow/why.ts`).
440440 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
441441 - A ready issue held on a hot file names the open pulls on that file, oldest first, at most 8: `held.pulls` in `GET …/map` and the MCP `map` tool, links after the sentence in the flow panel's "Ready, and why it waits" card, and `#N` lines in the terminal Flow dialog. Read from the pull files the picker caches (`src/crew/hot_files.ts`), per reader: a sealed pull is named and counted for the owner only.
docs/MCP.md +1 −1
@@ -77,7 +77,7 @@ for a tunnel and a forge that sleeps.
7777 | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes |
7878 | `egress_grants` | `{ repo, op?, destination? }`: the private `host:port` destinations an app's forge.json asks for and which are granted; `op` grant or revoke by a site admin (never an agent), and every op needs an admin-scope token; the same as the settings page and `/api/v1/repos/:owner/:name/egress-grants` | yes |
7979 | `email_verify` | no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link | yes |
80 | `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` | |
80 | `events` | `{ repo, kind?, limit?, cursor? }`: the repository's events (of one `kind`, e.g. `repo.settings_updated`, when given), 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` | |
8181 | `explore` | `{ query?, cursor? }`: the apps and people you can see, ranked, with their numbers; `kind:` `sort:` and words in one line; page on with `cursor` | |
8282 | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | |
8383 | `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`; `digest` (first page only) is home's "Since you were here" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out; an item with `lines: [{ text, link }]` and `more` is a folded burst (3+ issues one author opened in one repository within 2 minutes: `planned N issues in owner/name`, linking to its map) — `lines` are the first three, `more` the rest | |
src/actions/provenance.ts +2 −1
@@ -8,6 +8,7 @@
88 import type { App } from "../app.ts";
99 import { getProvenance, setProvenanceShown } from "../core/provenance.ts";
1010 import type { Repo } from "../core/repos.ts";
11 import { settingMoved } from "../core/settings_changed.ts";
1112 import type { User } from "../core/users.ts";
1213 import { err, ok, type Result } from "../kernel/result.ts";
1314 import { requireRepoAdmin } from "./access.ts";
@@ -42,7 +43,7 @@ export function showProvenance(
4243 kind: "repo.settings_updated",
4344 repoId: repo.id,
4445 actorId: admin.value.id,
45 payload: { changed: was === shown ? [] : ["provenance"], shown },
46 payload: { changed: settingMoved("provenance", was, shown), shown },
4647 });
4748 return ok({ shown, was, dryRun });
4849 }
src/actions/repos.ts +32 −16
@@ -66,6 +66,7 @@ import {
6666 type AccessError,
6767 } from "./access.ts";
6868 import { removeInstallCache } from "../crew/workdir.ts";
69 import type { SettingsChanged } from "../core/settings_changed.ts";
6970
7071 export interface CreateRepoInput {
7172 /** Owner name: the actor themselves (default) or an org they can write to. */
@@ -333,6 +334,31 @@ function moveArchive(app: App, repoId: string, archived: boolean): Promise<boole
333334 });
334335 }
335336
337 /** The settings `updateRepoSettings` audits, in the order the trail names them. */
338 const AUDITED = [
339 "visibility",
340 "description",
341 "protectDefault",
342 "defaultBranch",
343 "requireReview",
344 "requireChecks",
345 "crewOutsiders",
346 "decisionsBy",
347 "plansNeedNod",
348 ] as const;
349
350 export type { SettingsChanged };
351
352 /** Each audited setting `settings` moves on `repo`, from what to what. */
353 export function settingsChanged(repo: Repo, settings: RepoSettings): SettingsChanged {
354 const changed: SettingsChanged = {};
355 for (const k of AUDITED) {
356 const to = settings[k];
357 if (to !== undefined && to !== repo[k]) changed[k] = { from: repo[k], to };
358 }
359 return changed;
outdated

Only this writer emits the new shape. Make provenance, vision and origins/sync use it too, so the event kind has a single payload shape.

360 }
361
336362 /**
337363 * Change settings. A new default branch must exist; git HEAD is moved first
338364 * and the row follows (git is truth, the index is derived from it).
@@ -374,21 +400,11 @@ export async function updateRepoSettings(
374400 archived === undefined ? false : await moveArchive(app, repo.id, archived);
375401 const updated = getRepoById(app.db, repo.id);
376402 if (updated === undefined) return err("not_found");
377 // `changed` is the audit line: it names settings.
378 const changed: string[] = (
379 [
380 "visibility",
381 "description",
382 "protectDefault",
383 "defaultBranch",
384 "requireReview",
385 "requireChecks",
386 "crewOutsiders",
387 "decisionsBy",
388 "plansNeedNod",
389 ] as const
390 ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]);
391 if (moved) changed.push("archived");
403 // `changed` is the audit line: each setting this call moved, from what to
404 // what. None of these is a secret; a secret setting would say only
405 // that it changed (`{ secret: true }`), never its value.
406 const changed = settingsChanged(repo, settings);
407 if (moved) changed["archived"] = { from: !archived, to: archived === true };
392408 app.bus.append({
393409 kind: "repo.settings_updated",
394410 repoId: repo.id,
@@ -397,7 +413,7 @@ export async function updateRepoSettings(
397413 });
398414 // Who may start and steer the crew is its own line in the record, on and
399415 // off: on is a door opened to everyone who can read (`crew/direct.ts`).
400 if (changed.includes("crewOutsiders"))
416 if ("crewOutsiders" in changed)
401417 app.bus.append({
402418 kind: "repo.crew_outsiders",
403419 repoId: repo.id,
src/contract/openapi/routes/getApiV1ReposOwnerNameEvents.ts +1 −1
@@ -1,3 +1,3 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { 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" } };
3 export const route: Route = { 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, kind keeps one event kind (e.g. repo.settings_updated); a bad limit, kind or cursor is a 422", auth: false, query: { kind: "string", limit: "integer", cursor: "string" } };
src/core/events_page.ts +7 −1
@@ -17,6 +17,9 @@ export function eventsLimit(raw: unknown): number | undefined {
1717 /** A cursor is the id of the last event of the page before. */
1818 export const validEventsCursor = (raw: string): boolean => /^[A-Za-z0-9_-]{1,64}$/.test(raw);
1919
20 /** A `kind` filter names one event kind, like `repo.settings_updated`. */
21 export const validEventsKind = (raw: string): boolean => /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,3}$/.test(raw) && raw.length <= 64;

The API and MCP take the filter, but src/tui/screens/events.ts does not. Pass kind through there as well.

22
2023 /** Rows read per batch, and batches per request: one request reads at most 1,000 events. */
2124 const BATCH = 200;
2225 const BATCHES = 5;
@@ -26,7 +29,8 @@ const BATCHES = 5;
2629 * one bounded page. The audience filter runs before the page is cut, so a
2730 * hidden event never shifts a page. The read is bounded too: when most of
2831 * the outbox is hidden from this reader, a page may come back short, with
29 * a `next_cursor` that resumes after the last row scanned.
32 * a `next_cursor` that resumes after the last row scanned. `kind`, when
33 * given, keeps only events of that kind, in SQL, before either cut.
3034 */
3135 export function eventsPage(
3236 db: Database,
@@ -34,6 +38,7 @@ export function eventsPage(
3438 viewer: Parameters<typeof audienceFilter>[1],
3539 limit: number,
3640 cursor?: string,
41 kind?: string,
3742 ) {
3843 const toReader = eventForReader(db, viewer);
3944 const found: ReturnType<typeof listEvents> = [];
@@ -45,6 +50,7 @@ export function eventsPage(
4550 repoId,
4651 limit: BATCH,
4752 ...(before === undefined ? {} : { beforeId: before }),
53 ...(kind === undefined ? {} : { kind }),
4854 });
4955 const visible = audienceFilter(db, viewer, batch);
5056 for (const event of batch) if (visible(event)) found.push(event);
src/core/settings_changed.ts added +12
@@ -0,0 +1,12 @@
1 /**
2 * The one shape of `changed` on `repo.settings_updated`: each setting a change
3 * moved, from what to what. Every writer of that event builds it here, so one
4 * read of the kind answers "from what to what" for every row.
5 */
6
7 export type SettingsChanged = Record<string, { from: unknown; to: unknown }>;
8
9 /** `{ [setting]: { from, to } }`, or `{}` when nothing moved. */
10 export function settingMoved(setting: string, from: unknown, to: unknown): SettingsChanged {
11 return from === to ? {} : { [setting]: { from, to } };
12 }
src/crew/vision.ts +2 −1
@@ -14,6 +14,7 @@ import { createLog } from "../kernel/log.ts";
1414 import { editFiles } from "../actions/contents.ts";
1515 import { pullDir, type Pull } from "../core/pulls.ts";
1616 import type { Repo } from "../core/repos.ts";
17 import { settingMoved } from "../core/settings_changed.ts";
1718 import type { User } from "../core/users.ts";
1819 import { blobForRender, listTree, readerFor } from "../git/read.ts";
1920 import { repoDir } from "../git/repos.ts";
@@ -224,6 +225,6 @@ export async function landVision(app: App, merger: User, repo: Repo, pull: Pull)
224225 kind: "repo.settings_updated",
225226 repoId: repo.id,
226227 actorId: merger.id,
227 payload: { changed: ["autonomyDirection"], pull: pull.number },
228 payload: { changed: settingMoved("autonomyDirection", fresh.autonomyDirection, next.text), pull: pull.number },
228229 });
229230 }
src/http/api.ts +5 −2
@@ -114,7 +114,7 @@ import {
114114 SIGNUP_CLOSED_MESSAGE,
115115 } from "../actions/users.ts";
116116 import { OFFSET_LIMIT_MAX, offsetPage, offsetQuery } from "./offset_page.ts";
117 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts";
117 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor, validEventsKind } from "../core/events_page.ts";
118118 import { audienceFor } from "../core/audience.ts";
119119 import {
120120 forkParentFor,
@@ -1512,8 +1512,11 @@ export function apiRoutes(router: Router<Handler>): void {
15121512 const cursor = q.get("cursor");
15131513 if (cursor !== null && !validEventsCursor(cursor))
15141514 return apiError("invalid_cursor", 422, "cursor is the next_cursor of a page before");
1515 const kind = q.get("kind");
1516 if (kind !== null && !validEventsKind(kind))
1517 return apiError("invalid_kind", 422, "kind is an event kind, like repo.settings_updated");
15151518 const page = timePhase(ctx.request, "events", () =>
1516 eventsPage(ctx.app.db, repo.id, ctx.auth.user, limit, cursor ?? undefined),
1519 eventsPage(ctx.app.db, repo.id, ctx.auth.user, limit, cursor ?? undefined, kind ?? undefined),
15171520 );
15181521 return timePhase(ctx.request, "render", () => json(page));
15191522 });
src/http/mcp/tools/events.ts +7 −3
@@ -1,15 +1,16 @@
1 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../../../core/events_page.ts";
1 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor, validEventsKind } from "../../../core/events_page.ts";
22 import { CURSOR_NOT_ISSUED } from "../../../actions/feed_words.ts";
33 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
44
55 export const tool: Tool = {
66 name: "events",
7 doc: "`{ 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`",
7 doc: "`{ repo, kind?, limit?, cursor? }`: the repository's events (of one `kind`, e.g. `repo.settings_updated`, when given), 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`",
88 description: "Repository events.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
1212 repo: REPO_ARG,
13 kind: { type: "string" },
1314 limit: { type: "integer" },
1415 cursor: { type: "string" },
1516 },
@@ -24,6 +25,9 @@ export const tool: Tool = {
2425 const given = args["cursor"];
2526 if (given !== undefined && (typeof given !== "string" || !validEventsCursor(given)))
2627 return bad("invalid", CURSOR_NOT_ISSUED);
27 return good(eventsPage(ctx.app.db, repo.id, user, limit, given));
28 const kind = args["kind"];
29 if (kind !== undefined && (typeof kind !== "string" || !validEventsKind(kind)))
30 return bad("invalid", "kind is an event kind, like repo.settings_updated");
31 return good(eventsPage(ctx.app.db, repo.id, user, limit, given, kind));
2832 },
2933 };
src/origins/sync.ts +2 −1
@@ -40,6 +40,7 @@ import {
4040 } from "../core/origins.ts";
4141 import { storageStale } from "../core/storage.ts";
4242 import { getRepoById } from "../core/repos.ts";
43 import { settingMoved } from "../core/settings_changed.ts";
4344 import { pullDiff, type Pull, getPull } from "../core/pulls.ts";
4445 import { repoDir } from "../git/repos.ts";
4546 import { createLog } from "../kernel/log.ts";
@@ -326,7 +327,7 @@ async function syncMirrorUnguarded(
326327 kind: "repo.settings_updated",
327328 repoId: repo.id,
328329 actorId: origin.userId,
329 payload: { changed: ["visibility"], visibility: wanted, via: "origin" },
330 payload: { changed: settingMoved("visibility", repo.visibility, wanted), visibility: wanted, via: "origin" },
330331 });
331332 repo = getRepoById(app.db, repo.id)!;
332333 }
src/tui/screens/events.ts +36 −5
@@ -5,7 +5,8 @@
55 * filter runs there, so a webhook's URL and an app's variable names reach
66 * only the admins. One line per event — when, who, what — in the words the
77 * activity feed uses (`eventActor`, `eventVerb`). Fifty at a time; `n` reads
8 * the next page from the cursor the last one returned.
8 * the next page from the cursor the last one returned; `s` keeps only the
9 * settings changes (the same `kind` filter), each saying from what to what.
910 */
1011
1112 import { EVENTS_LIMIT_DEFAULT, eventsPage } from "../../core/events_page.ts";
@@ -19,8 +20,27 @@ import type { Session } from "../session.ts";
1920 import { emptyState } from "../ui/widgets.ts";
2021 import { ListState, ago, drawList, type ListRow } from "./common.ts";
2122
23 /** The one kind `s` keeps. */
24 export const SETTINGS_KIND = "repo.settings_updated";
25
26 /** A setting's value on one line, clipped: `true`, `"a yard"`. */
27 function shown(value: unknown): string {
28 const text = JSON.stringify(value) ?? String(value);
29 return text.length > 40 ? `${text.slice(0, 39)}…` : text;
30 }
31
32 /** What a settings change moved: `requireReview false → true, …`. */
33 export function settingsMoved(event: ForgeEvent): string {
34 const changed = event.payload["changed"];
35 if (changed === null || typeof changed !== "object" || Array.isArray(changed)) return "";
36 return Object.entries(changed as Record<string, { from?: unknown; to?: unknown }>)
37 .map(([k, v]) => `${k} ${shown(v?.from)} → ${shown(v?.to)}`)
38 .join(", ");
39 }
40
2241 /** What an event is about, when it says: `#12`, or the branch a push moved. */
2342 function subjectOf(event: ForgeEvent): string {
43 if (event.kind === SETTINGS_KIND) return settingsMoved(event);
2444 const number = event.payload["number"];
2545 if (typeof number === "number") return `#${number}`;
2646 if (event.kind !== "push") return "";
@@ -45,8 +65,9 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4565 let count = 0;
4666 const rows: ListRow[] = [];
4767 let next: string | null = null;
68 let kind: string | undefined;
4869 const read = (cursor?: string): void => {
49 const page = eventsPage(f.db, repo.id, f.user, EVENTS_LIMIT_DEFAULT, cursor);
70 const page = eventsPage(f.db, repo.id, f.user, EVENTS_LIMIT_DEFAULT, cursor, kind);
5071 count += page.items.length;
5172 rows.push(...eventRows(f, page.items));
5273 next = page.next_cursor;
@@ -54,19 +75,22 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5475 };
5576 read();
5677 return {
57 title: "Events",
78 get title() {
79 return kind === undefined ? "Events" : "Events · settings";
80 },
5881 get keys() {
5982 return [
6083 binding(["j", "k"], "move"),
6184 ...(next === null ? [] : [binding(["n"], "next page")]),
85 binding(["s"], kind === undefined ? "settings only" : "all events"),
6286 binding(["esc", "q"], "back"),
6387 ];
6488 },
6589 render(area, s) {
6690 if (count === 0) {
6791 emptyState(s, area, ctx.theme.c, {
68 title: "No events yet",
69 body: "Pushes, merges and deploys land here.",
92 title: kind === undefined ? "No events yet" : "No settings changes yet",
93 body: kind === undefined ? "Pushes, merges and deploys land here." : "Press s for every event.",
7094 });
7195 return;
7296 }
@@ -76,6 +100,13 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
76100 if (e.key === "esc" || e.key === "q") ctx.pop();
77101 else if (e.key === "n") {
78102 if (next !== null) read(next);
103 } else if (e.key === "s") {
104 kind = kind === undefined ? SETTINGS_KIND : undefined;
105 count = 0;
106 rows.length = 0;
107 state.index = 0;
108 state.offset = 0;
109 read();
79110 } else state.key(e, count);
80111 return true;
81112 },
test/apps-archive-race.test.ts +1 −1
@@ -27,7 +27,7 @@ describe("archive and wake race", () => {
2727 const archivedMoves = (): number =>
2828 listEvents(forge.app.db, { limit: 1000 }).filter((e) => {
2929 const changed = e.payload["changed"];
30 return e.kind === "repo.settings_updated" && Array.isArray(changed) && changed.includes("archived");
30 return e.kind === "repo.settings_updated" && typeof changed === "object" && changed !== null && "archived" in changed;
3131 }).length;
3232 const production = () => forge.runtime.envStatus(record, "production")?.state;
3333
test/origins-security-auth.test.ts +3
@@ -1178,6 +1178,9 @@ describe("authorization across origins, mirrors, pins and disconnect", () => {
11781178 expect(
11791179 (await api("/api/v1/repos/alice/widgets", { none: true })).status,
11801180 ).toBe(404);
1181 // The settings trail says from what to what, in the one shape every writer uses.
1182 const narrowed = listEvents(app.db, { kind: "repo.settings_updated", limit: 1 })[0]!;
1183 expect(narrowed.payload["changed"]).toEqual({ visibility: { from: "public", to: "private" } });
11811184
11821185 // Upstream goes public again. The sync does NOT widen the mirror back:
11831186 // narrowing is the origin's call to make for us, widening is the owner's
test/parity.test.ts +1 −1
@@ -360,7 +360,7 @@ describe("parity: full control through actions and the API", () => {
360360 expect(settingsEvents.length).toBeGreaterThanOrEqual(2);
361361 expect(
362362 settingsEvents.some((e) =>
363 (e.payload.changed as string[]).includes("defaultBranch"),
363 "defaultBranch" in (e.payload.changed as object),
364364 ),
365365 ).toBe(true);
366366 });
test/provenance.test.ts +5
@@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import { createApp, type App } from "../src/app.ts";
1313 import { createRepo } from "../src/actions/repos.ts";
14 import { listEvents } from "../src/core/events.ts";
1415 import { showProvenance } from "../src/actions/provenance.ts";
1516 import {
1617 cleanParents,
@@ -202,6 +203,10 @@ describe("the record and the line", () => {
202203 ok: true,
203204 value: { shown: false, was: true, dryRun: false },
204205 });
206 // The settings trail says from what to what, in the one shape every writer uses.
207 const trail = listEvents(app.db, { repoId: source.id, kind: "repo.settings_updated", limit: 1 })[0]!;
208 expect(trail.actorId).toBe(owner.id);
209 expect(trail.payload["changed"]).toEqual({ provenance: { from: true, to: false } });
205210 expect(lineageLine(app.db, source, undefined)).toBeNull();
206211 expect(getProvenance(app.db, source.id)?.parents).toEqual([
207212 "TheRobotStudio/SO-ARM100",
test/settings-trail.test.ts added +147
@@ -0,0 +1,147 @@
1 /**
2 * The settings trail (#7653): a settings change says who made it and what it
3 * moved, from what to what, and the outbox reads one kind of event at a time,
4 * on the API and through MCP `events`, still paging with `next_cursor`.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
12 import { eventsPage } from "../src/core/events_page.ts";
13 import { getRepoById, type Repo } from "../src/core/repos.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { eventsScreen } from "../src/tui/screens/events.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { settleApp } from "./helpers/tui.ts";
21
22 type Item = { kind: string; actorId: string | null; payload: Record<string, any> };
23
24 describe("the settings trail", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let alice: User;
29 let token: string;
30 let repo: Repo;
31
32 beforeAll(async () => {
33 dataDir = await mkdtemp(join(tmpdir(), "forge-settings-trail-"));
34 app = createApp({ dataDir, port: 0 });
35 servers = await serve(app);
36 const a = await createUser(app.db, {
37 origin: "seed",
38 trusted: true,
39 username: "alice",
40 email: "[email protected]",
41 password: "a-good-passphrase",
42 adminIfFirst: true,
43 });
44 if (!a.ok) throw new Error(a.error);
45 alice = a.value;
46 token = createToken(app.db, alice.id, "t", "admin").token;
47 const made = await createRepo(app, alice, { name: "yard", visibility: "public" });
48 if (!made.ok) throw new Error(String(made.error));
49 repo = made.value;
50 }, 60_000);
51
52 afterAll(async () => {
53 servers.stop();
54 app.close();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 const fresh = (): Repo => getRepoById(app.db, repo.id)!;
59
60 test("requireReview flipped twice reads back newest first, with the actor and from/to", async () => {
61 expect((await updateRepoSettings(app, alice, fresh(), { requireReview: true })).ok).toBe(true);
62 expect((await updateRepoSettings(app, alice, fresh(), { requireReview: false, description: "a yard" })).ok).toBe(true);
63 const page = eventsPage(app.db, repo.id, alice, 50, undefined, "repo.settings_updated");
64 expect(page.items.map((e) => e.kind)).toEqual(["repo.settings_updated", "repo.settings_updated"]);
65 const [off, on] = page.items;
66 expect(off!.actorId).toBe(alice.id);
67 expect(off!.payload["changed"]).toEqual({
68 description: { from: "", to: "a yard" },
69 requireReview: { from: true, to: false },
70 });
71 expect(on!.actorId).toBe(alice.id);
72 expect(on!.payload["changed"]).toEqual({ requireReview: { from: false, to: true } });
73 });
74
75 test("the API takes kind and pages it with next_cursor; a bad kind is a 422", async () => {
76 expect((await updateRepoSettings(app, alice, fresh(), { requireChecks: true })).ok).toBe(true);
77 expect((await updateRepoSettings(app, alice, fresh(), { requireChecks: false })).ok).toBe(true);
78 const get = (qs: string) =>
79 fetch(`${servers.url}/api/v1/repos/alice/yard/events${qs}`, {
80 headers: { authorization: `Bearer ${token}` },
81 });
82 const first = (await (await get("?kind=repo.settings_updated&limit=2")).json()) as {
83 items: Item[];
84 next_cursor: string | null;
85 };
86 expect(first.items.length).toBe(2);
87 expect(first.items.every((e) => e.kind === "repo.settings_updated")).toBe(true);
88 expect(first.next_cursor).not.toBeNull();
89 const rest = (await (await get(`?kind=repo.settings_updated&limit=2&cursor=${first.next_cursor}`)).json()) as {
90 items: Item[];
91 };
92 expect(rest.items.length).toBe(2);
93 expect(rest.items.every((e) => e.kind === "repo.settings_updated")).toBe(true);
94 expect(rest.items.map((e) => e.payload["changed"])).toContainEqual({ requireReview: { from: false, to: true } });
95 expect((await get("?kind=<script>")).status).toBe(422);
96 });
97
98 test("MCP `events` takes kind", async () => {
99 const call = async (args: unknown) => {
100 const res = await fetch(`${servers.url}/mcp`, {
101 method: "POST",
102 headers: {
103 "content-type": "application/json",
104 accept: "application/json, text/event-stream",
105 authorization: `Bearer ${token}`,
106 },
107 body: JSON.stringify({
108 jsonrpc: "2.0",
109 id: 1,
110 method: "tools/call",
111 params: { name: "events", arguments: args },
112 }),
113 });
114 return ((await res.json()) as any).result as { isError?: boolean; content: { text: string }[] };
115 };
116 const got = await call({ repo: "alice/yard", kind: "repo.settings_updated", limit: 1 });
117 expect(got.isError).not.toBe(true);
118 const page = JSON.parse(got.content[0]!.text) as { items: Item[]; next_cursor: string | null };
119 expect(page.items.map((e) => e.kind)).toEqual(["repo.settings_updated"]);
120 expect(page.items[0]!.payload["changed"]).toEqual({ requireChecks: { from: true, to: false } });
121 expect(page.next_cursor).not.toBeNull();
122 expect((await call({ repo: "alice/yard", kind: "Not A Kind" })).isError).toBe(true);
123 });
124
125 test("the terminal's Events screen shows who and from/to, and `s` keeps only settings changes", async () => {
126 app.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: alice.id, payload: { number: 4242 } });
127 const { app: tui, session } = createForgeApp(
128 app,
129 { sshUsername: alice.username, remoteAddress: "127.0.0.1", user: alice },
130 { terminal: headlessTerminal(140, 30), exitHooks: false },
131 );
132 tui.start();
133 await settleApp(tui);
134 tui.push(eventsScreen(session, tui.ctx, fresh()));
135 await settleApp(tui);
136 expect(tui.screen.toText()).toContain("#4242");
137 tui.dispatch({ type: "key", key: "s", ctrl: false, alt: false, shift: false });
138 await settleApp(tui);
139 const text = tui.screen.toText();
140 expect(text).not.toContain("#4242");
141 expect(text).toContain("alice changed repository settings: requireChecks true → false");
142 expect(text).toContain("requireReview false → true");
143 tui.dispatch({ type: "key", key: "s", ctrl: false, alt: false, shift: false });
144 await settleApp(tui);
145 expect(tui.screen.toText()).toContain("#4242");
146 });
147 });
test/vision-land.test.ts +3 −1
@@ -131,7 +131,9 @@ describe("landing a vision pull", () => {
131131 expect(text).toContain(`Done means reachable ${n}.`);
132132 expect(text.length).toBeLessThanOrEqual(DIRECTION_LIMIT);
133133 const e = listEvents(app.db, { repoId: repo.id, kind: "repo.settings_updated", limit: 1 })[0]!;
134 expect(e.payload["changed"]).toEqual(["autonomyDirection"]);
134 expect(e.payload["changed"]).toEqual({
135 autonomyDirection: { from: `Ship small.\n\n${BLOCK_START}\nold\n${BLOCK_END}\n\nNever page me.`, to: text },
136 });
135137 expect(e.actorId).toBe(owner.id);
136138 });
137139

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs