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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7653-a-settings-change-is-on-the-trail-with-w → main 4 commits fork 2 threads · 2 unresolved
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 | ||
| 312 | 312 | (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull |
| 313 | 313 | opened with it says "proposed by child … from …" on every tab of the pull's |
| 314 | 314 | 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. | |
| 316 | 316 | |
| 317 | 317 | ### Proposing to a parent forge |
| 318 | 318 | |
| @@ -435,7 +435,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 435 | 435 | - 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`. |
| 436 | 436 | - 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`. |
| 437 | 437 | - 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'). | |
| 439 | 439 | - 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`). |
| 440 | 440 | - [](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. |
| 441 | 441 | - 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. | ||
| 77 | 77 | | `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 | |
| 78 | 78 | | `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 | |
| 79 | 79 | | `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` | | | |
| 81 | 81 | | `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` | | |
| 82 | 82 | | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | | |
| 83 | 83 | | `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 @@ | ||
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { getProvenance, setProvenanceShown } from "../core/provenance.ts"; |
| 10 | 10 | import type { Repo } from "../core/repos.ts"; |
| 11 | import { settingMoved } from "../core/settings_changed.ts"; | |
| 11 | 12 | import type { User } from "../core/users.ts"; |
| 12 | 13 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | 14 | import { requireRepoAdmin } from "./access.ts"; |
| @@ -42,7 +43,7 @@ export function showProvenance( | ||
| 42 | 43 | kind: "repo.settings_updated", |
| 43 | 44 | repoId: repo.id, |
| 44 | 45 | actorId: admin.value.id, |
| 45 | payload: { changed: was === shown ? [] : ["provenance"], shown }, | |
| 46 | payload: { changed: settingMoved("provenance", was, shown), shown }, | |
| 46 | 47 | }); |
| 47 | 48 | return ok({ shown, was, dryRun }); |
| 48 | 49 | } |
src/actions/repos.ts +32 −16
| @@ -66,6 +66,7 @@ import { | ||
| 66 | 66 | type AccessError, |
| 67 | 67 | } from "./access.ts"; |
| 68 | 68 | import { removeInstallCache } from "../crew/workdir.ts"; |
| 69 | import type { SettingsChanged } from "../core/settings_changed.ts"; | |
| 69 | 70 | |
| 70 | 71 | export interface CreateRepoInput { |
| 71 | 72 | /** 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 | ||
| 333 | 334 | }); |
| 334 | 335 | } |
| 335 | 336 | |
| 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; | |
| 360 | } | |
| 361 | ||
| 336 | 362 | /** |
| 337 | 363 | * Change settings. A new default branch must exist; git HEAD is moved first |
| 338 | 364 | * and the row follows (git is truth, the index is derived from it). |
| @@ -374,21 +400,11 @@ export async function updateRepoSettings( | ||
| 374 | 400 | archived === undefined ? false : await moveArchive(app, repo.id, archived); |
| 375 | 401 | const updated = getRepoById(app.db, repo.id); |
| 376 | 402 | 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 }; | |
| 392 | 408 | app.bus.append({ |
| 393 | 409 | kind: "repo.settings_updated", |
| 394 | 410 | repoId: repo.id, |
| @@ -397,7 +413,7 @@ export async function updateRepoSettings( | ||
| 397 | 413 | }); |
| 398 | 414 | // Who may start and steer the crew is its own line in the record, on and |
| 399 | 415 | // off: on is a door opened to everyone who can read (`crew/direct.ts`). |
| 400 | if (changed.includes("crewOutsiders")) | |
| 416 | if ("crewOutsiders" in changed) | |
| 401 | 417 | app.bus.append({ |
| 402 | 418 | kind: "repo.crew_outsiders", |
| 403 | 419 | repoId: repo.id, |
src/contract/openapi/routes/getApiV1ReposOwnerNameEvents.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 { | ||
| 17 | 17 | /** A cursor is the id of the last event of the page before. */ |
| 18 | 18 | export const validEventsCursor = (raw: string): boolean => /^[A-Za-z0-9_-]{1,64}$/.test(raw); |
| 19 | 19 | |
| 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 |
||
| 22 | ||
| 20 | 23 | /** Rows read per batch, and batches per request: one request reads at most 1,000 events. */ |
| 21 | 24 | const BATCH = 200; |
| 22 | 25 | const BATCHES = 5; |
| @@ -26,7 +29,8 @@ const BATCHES = 5; | ||
| 26 | 29 | * one bounded page. The audience filter runs before the page is cut, so a |
| 27 | 30 | * hidden event never shifts a page. The read is bounded too: when most of |
| 28 | 31 | * 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. | |
| 30 | 34 | */ |
| 31 | 35 | export function eventsPage( |
| 32 | 36 | db: Database, |
| @@ -34,6 +38,7 @@ export function eventsPage( | ||
| 34 | 38 | viewer: Parameters<typeof audienceFilter>[1], |
| 35 | 39 | limit: number, |
| 36 | 40 | cursor?: string, |
| 41 | kind?: string, | |
| 37 | 42 | ) { |
| 38 | 43 | const toReader = eventForReader(db, viewer); |
| 39 | 44 | const found: ReturnType<typeof listEvents> = []; |
| @@ -45,6 +50,7 @@ export function eventsPage( | ||
| 45 | 50 | repoId, |
| 46 | 51 | limit: BATCH, |
| 47 | 52 | ...(before === undefined ? {} : { beforeId: before }), |
| 53 | ...(kind === undefined ? {} : { kind }), | |
| 48 | 54 | }); |
| 49 | 55 | const visible = audienceFilter(db, viewer, batch); |
| 50 | 56 | 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"; | ||
| 14 | 14 | import { editFiles } from "../actions/contents.ts"; |
| 15 | 15 | import { pullDir, type Pull } from "../core/pulls.ts"; |
| 16 | 16 | import type { Repo } from "../core/repos.ts"; |
| 17 | import { settingMoved } from "../core/settings_changed.ts"; | |
| 17 | 18 | import type { User } from "../core/users.ts"; |
| 18 | 19 | import { blobForRender, listTree, readerFor } from "../git/read.ts"; |
| 19 | 20 | import { repoDir } from "../git/repos.ts"; |
| @@ -224,6 +225,6 @@ export async function landVision(app: App, merger: User, repo: Repo, pull: Pull) | ||
| 224 | 225 | kind: "repo.settings_updated", |
| 225 | 226 | repoId: repo.id, |
| 226 | 227 | actorId: merger.id, |
| 227 | payload: { changed: ["autonomyDirection"], pull: pull.number }, | |
| 228 | payload: { changed: settingMoved("autonomyDirection", fresh.autonomyDirection, next.text), pull: pull.number }, | |
| 228 | 229 | }); |
| 229 | 230 | } |
src/http/api.ts +5 −2
| @@ -114,7 +114,7 @@ import { | ||
| 114 | 114 | SIGNUP_CLOSED_MESSAGE, |
| 115 | 115 | } from "../actions/users.ts"; |
| 116 | 116 | 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"; | |
| 118 | 118 | import { audienceFor } from "../core/audience.ts"; |
| 119 | 119 | import { |
| 120 | 120 | forkParentFor, |
| @@ -1512,8 +1512,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1512 | 1512 | const cursor = q.get("cursor"); |
| 1513 | 1513 | if (cursor !== null && !validEventsCursor(cursor)) |
| 1514 | 1514 | 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"); | |
| 1515 | 1518 | 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), | |
| 1517 | 1520 | ); |
| 1518 | 1521 | return timePhase(ctx.request, "render", () => json(page)); |
| 1519 | 1522 | }); |
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"; | |
| 2 | 2 | import { CURSOR_NOT_ISSUED } from "../../../actions/feed_words.ts"; |
| 3 | 3 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | 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`", | |
| 8 | 8 | description: "Repository events.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
| 12 | 12 | repo: REPO_ARG, |
| 13 | kind: { type: "string" }, | |
| 13 | 14 | limit: { type: "integer" }, |
| 14 | 15 | cursor: { type: "string" }, |
| 15 | 16 | }, |
| @@ -24,6 +25,9 @@ export const tool: Tool = { | ||
| 24 | 25 | const given = args["cursor"]; |
| 25 | 26 | if (given !== undefined && (typeof given !== "string" || !validEventsCursor(given))) |
| 26 | 27 | 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)); | |
| 28 | 32 | }, |
| 29 | 33 | }; |
src/origins/sync.ts +2 −1
| @@ -40,6 +40,7 @@ import { | ||
| 40 | 40 | } from "../core/origins.ts"; |
| 41 | 41 | import { storageStale } from "../core/storage.ts"; |
| 42 | 42 | import { getRepoById } from "../core/repos.ts"; |
| 43 | import { settingMoved } from "../core/settings_changed.ts"; | |
| 43 | 44 | import { pullDiff, type Pull, getPull } from "../core/pulls.ts"; |
| 44 | 45 | import { repoDir } from "../git/repos.ts"; |
| 45 | 46 | import { createLog } from "../kernel/log.ts"; |
| @@ -326,7 +327,7 @@ async function syncMirrorUnguarded( | ||
| 326 | 327 | kind: "repo.settings_updated", |
| 327 | 328 | repoId: repo.id, |
| 328 | 329 | actorId: origin.userId, |
| 329 | payload: { changed: ["visibility"], visibility: wanted, via: "origin" }, | |
| 330 | payload: { changed: settingMoved("visibility", repo.visibility, wanted), visibility: wanted, via: "origin" }, | |
| 330 | 331 | }); |
| 331 | 332 | repo = getRepoById(app.db, repo.id)!; |
| 332 | 333 | } |
src/tui/screens/events.ts +36 −5
| @@ -5,7 +5,8 @@ | ||
| 5 | 5 | * filter runs there, so a webhook's URL and an app's variable names reach |
| 6 | 6 | * only the admins. One line per event — when, who, what — in the words the |
| 7 | 7 | * 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. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { EVENTS_LIMIT_DEFAULT, eventsPage } from "../../core/events_page.ts"; |
| @@ -19,8 +20,27 @@ import type { Session } from "../session.ts"; | ||
| 19 | 20 | import { emptyState } from "../ui/widgets.ts"; |
| 20 | 21 | import { ListState, ago, drawList, type ListRow } from "./common.ts"; |
| 21 | 22 | |
| 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 | ||
| 22 | 41 | /** What an event is about, when it says: `#12`, or the branch a push moved. */ |
| 23 | 42 | function subjectOf(event: ForgeEvent): string { |
| 43 | if (event.kind === SETTINGS_KIND) return settingsMoved(event); | |
| 24 | 44 | const number = event.payload["number"]; |
| 25 | 45 | if (typeof number === "number") return `#${number}`; |
| 26 | 46 | if (event.kind !== "push") return ""; |
| @@ -45,8 +65,9 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 45 | 65 | let count = 0; |
| 46 | 66 | const rows: ListRow[] = []; |
| 47 | 67 | let next: string | null = null; |
| 68 | let kind: string | undefined; | |
| 48 | 69 | 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); | |
| 50 | 71 | count += page.items.length; |
| 51 | 72 | rows.push(...eventRows(f, page.items)); |
| 52 | 73 | next = page.next_cursor; |
| @@ -54,19 +75,22 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 54 | 75 | }; |
| 55 | 76 | read(); |
| 56 | 77 | return { |
| 57 | title: "Events", | |
| 78 | get title() { | |
| 79 | return kind === undefined ? "Events" : "Events · settings"; | |
| 80 | }, | |
| 58 | 81 | get keys() { |
| 59 | 82 | return [ |
| 60 | 83 | binding(["j", "k"], "move"), |
| 61 | 84 | ...(next === null ? [] : [binding(["n"], "next page")]), |
| 85 | binding(["s"], kind === undefined ? "settings only" : "all events"), | |
| 62 | 86 | binding(["esc", "q"], "back"), |
| 63 | 87 | ]; |
| 64 | 88 | }, |
| 65 | 89 | render(area, s) { |
| 66 | 90 | if (count === 0) { |
| 67 | 91 | 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.", | |
| 70 | 94 | }); |
| 71 | 95 | return; |
| 72 | 96 | } |
| @@ -76,6 +100,13 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 76 | 100 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 77 | 101 | else if (e.key === "n") { |
| 78 | 102 | 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(); | |
| 79 | 110 | } else state.key(e, count); |
| 80 | 111 | return true; |
| 81 | 112 | }, |
test/apps-archive-race.test.ts +1 −1
| @@ -27,7 +27,7 @@ describe("archive and wake race", () => { | ||
| 27 | 27 | const archivedMoves = (): number => |
| 28 | 28 | listEvents(forge.app.db, { limit: 1000 }).filter((e) => { |
| 29 | 29 | 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; | |
| 31 | 31 | }).length; |
| 32 | 32 | const production = () => forge.runtime.envStatus(record, "production")?.state; |
| 33 | 33 |
test/origins-security-auth.test.ts +3
| @@ -1178,6 +1178,9 @@ describe("authorization across origins, mirrors, pins and disconnect", () => { | ||
| 1178 | 1178 | expect( |
| 1179 | 1179 | (await api("/api/v1/repos/alice/widgets", { none: true })).status, |
| 1180 | 1180 | ).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" } }); | |
| 1181 | 1184 | |
| 1182 | 1185 | // Upstream goes public again. The sync does NOT widen the mirror back: |
| 1183 | 1186 | // 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", () => { | ||
| 360 | 360 | expect(settingsEvents.length).toBeGreaterThanOrEqual(2); |
| 361 | 361 | expect( |
| 362 | 362 | settingsEvents.some((e) => |
| 363 | (e.payload.changed as string[]).includes("defaultBranch"), | |
| 363 | "defaultBranch" in (e.payload.changed as object), | |
| 364 | 364 | ), |
| 365 | 365 | ).toBe(true); |
| 366 | 366 | }); |
test/provenance.test.ts +5
| @@ -11,6 +11,7 @@ import { tmpdir } from "node:os"; | ||
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 13 | import { createRepo } from "../src/actions/repos.ts"; |
| 14 | import { listEvents } from "../src/core/events.ts"; | |
| 14 | 15 | import { showProvenance } from "../src/actions/provenance.ts"; |
| 15 | 16 | import { |
| 16 | 17 | cleanParents, |
| @@ -202,6 +203,10 @@ describe("the record and the line", () => { | ||
| 202 | 203 | ok: true, |
| 203 | 204 | value: { shown: false, was: true, dryRun: false }, |
| 204 | 205 | }); |
| 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 } }); | |
| 205 | 210 | expect(lineageLine(app.db, source, undefined)).toBeNull(); |
| 206 | 211 | expect(getProvenance(app.db, source.id)?.parents).toEqual([ |
| 207 | 212 | "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", () => { | ||
| 131 | 131 | expect(text).toContain(`Done means reachable ${n}.`); |
| 132 | 132 | expect(text.length).toBeLessThanOrEqual(DIRECTION_LIMIT); |
| 133 | 133 | 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 | }); | |
| 135 | 137 | expect(e.actorId).toBe(owner.id); |
| 136 | 138 | }); |
| 137 | 139 |
new above
offline
Only this writer emits the new shape. Make
provenance,visionandorigins/syncuse it too, so the event kind has a single payload shape.