Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Faster pages: read side by side what each page needs, skip unused scripts

Signed-in pages waited on their database and repository reads one after
another: 13 in a row for a pull request page, 16 for an agent's pull request
API call. Most don't depend on each other, so they now go out together:

- Sign-in: the session and its account, or a token, its account and an
  agent's owner, in one round trip; callerFor reuses that owner.
- Permissions: the repository and the caller's grants on it (grantsFor, by
  owner and name) side by side; roleFor takes them instead of reading again,
  and reads anything they don't cover. A unit test checks the roles match
  roleFor's own reads for people and agents on every kind of repository.
- Pages: the watch level starts with the repository lookup and is read once;
  suspended authors are looked up alongside the repository; a conversation's
  labels, milestones and subscription with the conversation; a pull
  request's tab data (checks, mergeability, files, commits) and the agents'
  owners (one query) together; Actions run pages and the kill-switch banner;
  Today's activity and agents; the REST pull request detail's eight calls.

Pages with no Kumo island (the landing, docs, marketing and sign-in pages)
no longer load its 480 KB script and 150 KB stylesheet; screenshots of them
are pixel-identical. Server-Timing now names the D1 region and whether the
primary or a replica answered. A container start that fails with the
platform's "internal error; reference = ..." goes back in line instead of
failing the job.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y8bkPkN4zSgEJouMu1LFS
Claude authored signed1 parent 0480c10commit c95ca03d509a77e0fba7866be27a8b8b74d6197eBrowse files
18 files changed+398−121
Files (18)
modifiedapps/diffinery/docs/actions/runners.md+1−1View file
@@ -440,7 +440,7 @@
440440 | `403 admin access required` | The runner's token or account lacks admin on the repository, or the token isn't `admin`-scoped. |
441441 | `no runner agent given` or `runner agent not found at ...` | Pass `--agent /path/to/agent.mjs` or set `DIFFINERY_RUNNER_AGENT`. Build it with `pnpm --filter @diffinery/runner build`. |
442442 | `E: Unable to locate package ...` | Run `apt-get update` first; the image ships without package lists. |
443-| A hosted job starts a minute or more late, and its log begins with `No hosted runner could start the job yet: ...` | No machine was free (`there is no container instance that can be provided`), the container stopped before the runner started, or a deploy was rolling out and the runner came up on the previous image. Diffinery puts the job back in line and tries again (after 30 s, then 1, 2, 4 and 5 minutes, up to 8 times; a first container stop before the runner started is retried after 5 s and not counted; a runner on the previous image during a deploy is retried every 20 s for up to 10 minutes, not counted, and then runs on whichever image it got); `placementTries` in the jobs API counts the waits. Nothing to fix for an occasional wait. If a large matrix often waits, cap it with `strategy.max-parallel`. |
443+| A hosted job starts a minute or more late, and its log begins with `No hosted runner could start the job yet: ...` | No machine was free (`there is no container instance that can be provided`), Cloudflare's container platform answered `internal error; reference = ...`, the container stopped before the runner started, or a deploy was rolling out and the runner came up on the previous image. Diffinery puts the job back in line and tries again (after 30 s, then 1, 2, 4 and 5 minutes, up to 8 times; a first container stop before the runner started is retried after 5 s and not counted; a runner on the previous image during a deploy is retried every 20 s for up to 10 minutes, not counted, and then runs on whichever image it got); `placementTries` in the jobs API counts the waits. Nothing to fix for an occasional wait. If a large matrix often waits, cap it with `strategy.max-parallel`. |
444444 | `The platform replaced this job's runner with a new version while step ... ran` or `The platform stopped this job's container while step ... ran` | A deploy replaced the job's container mid-step (rare), or the platform stopped or moved it. Re-run the job. A job that can only read starts again by itself instead: give jobs that only build and test `permissions: contents: read`, no `environment:` and no secrets other than `GITHUB_TOKEN`. See Platform updates under [Limits and timeouts](#limits-and-timeouts). |
445445 | `The runner stopped unexpectedly (exit code N).` | The hosted container exited, often from running out of memory. Reduce parallelism in the job (for example `make -j2`) or move it to a self-hosted runner. |
446446 | `Connection refused` or `Connection timed out` on SSH (`git@...`), SMTP or a database port, on a hosted runner | The instance sets [`EGRESS=web`](/docs/self-hosting/configuration#egress), which lets hosted jobs use only HTTP and HTTPS. Use an `https://` remote or API, or run the job on a self-hosted runner. diffinery.com doesn't set it. |
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
@@ -291,7 +291,7 @@
291291 | `GET /repos/{owner}/{repo}/actions/jobs/{id}/logs` | `text/plain`, one line per log line prefixed with its timestamp. Add `?format=json` for an array of `{t, step, text}` objects. |
292292 | `DELETE` endpoints and some `PUT` endpoints | No body (`204`) |
293293
294-Every response also carries security headers (`Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`) and, whenever the request read or wrote account-level data, the `x-d1-bookmark` header described above. Answers to signed-in people and tokens that aren't errors also carry a `Server-Timing` header, which browsers' developer tools show under Timing: the whole request (`total`), the database queries it made (`d1`, with their count) and the calls to the repository's storage (`do`, with their count), in milliseconds, and the Cloudflare location that answered (`colo`). Parallel calls overlap, so their times can add up to more than the total. Downloads (raw files, archives and release assets) are sent with `Content-Security-Policy: sandbox` and as attachments, so browsers never run them as pages.
294+Every response also carries security headers (`Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`) and, whenever the request read or wrote account-level data, the `x-d1-bookmark` header described above. Answers to signed-in people and tokens that aren't errors also carry a `Server-Timing` header, which browsers' developer tools show under Timing: the whole request (`total`), the database queries it made (`d1`, with their count and, when a query said so, the database region that answered and whether it was the primary or a nearby copy) and the calls to the repository's storage (`do`, with their count), in milliseconds, and the Cloudflare location that answered (`colo`). Parallel calls overlap, so their times can add up to more than the total. Downloads (raw files, archives and release assets) are sent with `Content-Security-Policy: sandbox` and as attachments, so browsers never run them as pages.
295295
296296 ## Errors
297297
modifiedapps/diffinery/src/agentic/today.ts+23−12View file
@@ -181,25 +181,36 @@
181181 * Gather Today for one account (a person, or an agent asking over the API or MCP).
182182 * `repoIds` limits it to the repositories a token is restricted to.
183183 */
184-export async function gatherToday(env: Env, dir: Directory, me: Account, opts: { repoIds?: string[] | null; now?: Date; since?: string } = {}): Promise<Today> {
184+export async function gatherToday(
185+ env: Env,
186+ dir: Directory,
187+ me: Account,
188+ opts: { repoIds?: string[] | null; now?: Date; since?: string; visible?: Promise<Repo[]>; orgs?: ReturnType<Directory["orgsFor"]> } = {},
189+): Promise<Today> {
185190 const now = opts.now ?? new Date();
186191 // The morning digest passes its own start: the last digest, which can be days ago after a weekend.
187192 const since = opts.since ?? new Date(now.getTime() - SINCE_HOURS * 3600_000).toISOString();
188193 const cutoff = new Date(now.getTime() - ACTIVE_DAYS * 86400_000).toISOString();
189- const [visible, orgs] = await Promise.all([dir.reposVisibleTo(me), me.kind === "user" ? dir.orgsFor(me.id) : Promise.resolve([])]);
190- // Activity of these repositories only, not of every repository active on the instance.
194+ // The caller (the dashboard) may already be reading these for itself.
195+ const [visible, orgs] = await Promise.all([opts.visible ?? dir.reposVisibleTo(me), me.kind === "user" ? (opts.orgs ?? dir.orgsFor(me.id)) : Promise.resolve([])]);
196+ // Activity of these repositories only, not of every repository active on the instance; the agents at the same time.
191197 const activity = new Map<string, string>();
192- for (let i = 0; i < visible.length; i += 90) {
193- const ids = visible.slice(i, i + 90).map((r) => r.id);
194- const rows = await env.DB.prepare(`SELECT repo_id, at FROM repo_activity WHERE repo_id IN (${ids.map(() => "?").join(", ")}) AND at >= ?`)
195- .bind(...ids, cutoff)
196- .all<{ repo_id: string; at: string }>();
197- for (const r of rows.results) activity.set(r.repo_id, r.at);
198- }
198+ const chunks: string[][] = [];
199+ for (let i = 0; i < visible.length; i += 90) chunks.push(visible.slice(i, i + 90).map((r) => r.id));
200+ const owners = me.kind === "agent" ? [] : [me.id, ...orgs.map((o) => o.id)];
201+ const [found, agents] = await Promise.all([
202+ Promise.all(
203+ chunks.map((ids) =>
204+ env.DB.prepare(`SELECT repo_id, at FROM repo_activity WHERE repo_id IN (${ids.map(() => "?").join(", ")}) AND at >= ?`)
205+ .bind(...ids, cutoff)
206+ .all<{ repo_id: string; at: string }>(),
207+ ),
208+ ),
209+ dir.agentsOwnedBy(owners),
210+ ]);
211+ for (const rows of found) for (const r of rows.results) activity.set(r.repo_id, r.at);
199212 const allowed = opts.repoIds ? visible.filter((r) => opts.repoIds!.includes(r.id)) : visible;
200213 const { repos, skipped } = pickRepos(allowed, activity, now);
201- const owners = me.kind === "agent" ? [] : [me.id, ...orgs.map((o) => o.id)];
202- const agents = await dir.agentsOwnedBy(owners);
203214 const agentIds = new Set(agents.map((a) => a.id));
204215 const ownAgentIds = new Set(agents.filter((a) => a.owner_id === me.id).map((a) => a.id));
205216 if (me.kind === "agent") agentIds.add(me.id);
modifiedapps/diffinery/src/auth/principal.ts+39−9View file
@@ -1,7 +1,7 @@
11 import type { Context } from "hono";
22 import type { Env } from "../env.js";
33 import { devAuth } from "./dev.js";
4-import { Directory, type Account, type Repo, type TokenScope } from "../lib/directory.js";
4+import { Directory, type Account, type Repo, type RepoGrants, type TokenScope } from "../lib/directory.js";
55 import { Auth, SESSION_COOKIE, cookieFrom } from "./sessions.js";
66 import { HttpError, fromB64 } from "../lib/util.js";
77 import type { Caller, Role } from "../repo/types.js";
@@ -9,9 +9,22 @@
99 import { taskPersonAllowed } from "../agentic/access.js";
1010 import { blockSubjects, isBlocked } from "../lib/moderation.js";
1111 import { agentMayMerge } from "../lib/agent-setup.js";
12+import type { WatchLevel } from "../mail/prefs.js";
1213
1314 export type Principal =
14- | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string; sessionAt?: string; importId?: string; tokenId?: string }
15+ | {
16+ type: "account";
17+ account: Account;
18+ via: "session" | "token" | "dev";
19+ scopes: TokenScope[];
20+ repoIds: string[] | null;
21+ sessionId?: string;
22+ sessionAt?: string;
23+ importId?: string;
24+ tokenId?: string;
25+ /** An agent's owner as its token's check read it (lib/directory.ts verifyToken), so the request needn't read it again. */
26+ owner?: Account | null;
27+ }
1528 | { type: "repo-token"; repoId: string; token: string }
1629 | { type: "anonymous" };
1730
@@ -22,6 +35,8 @@
2235 readOnly?: boolean;
2336 /** Suspended people whose content this request hides, once looked up (http/common.ts hiddenAuthors). */
2437 hidden?: Promise<string[]>;
38+ /** The signed-in person's watch level for the repository the request is about, once looked up (http/common.ts watchLevelOnce). */
39+ watch?: { repoId: string; level: Promise<WatchLevel> };
2540 }
2641
2742 export type AppContext = Context<{ Bindings: Env; Variables: AppVars }>;
@@ -74,9 +89,9 @@
7489 const path = new URL(req.url).pathname;
7590 const agent = path.startsWith(`/api/v3/diffinery/imports/${v.importId}/agent/`);
7691 if (!agent && !IMPORT_GIT_PATH.test(path)) throw new HttpError(403, "this token can only be used by its GitHub import");
77- return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, importId: v.importId, tokenId: v.id };
92+ return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, importId: v.importId, tokenId: v.id, owner: v.owner };
7893 }
79- return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, tokenId: v.id };
94+ return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, tokenId: v.id, owner: v.owner };
8095 }
8196 // Job and sandbox tokens, and self-hosted runners' own tokens (which only claim jobs: runnerAuth refuses them).
8297 if (cred.startsWith("djt_") || cred.startsWith("dst_") || cred.startsWith("drn_")) {
@@ -182,6 +197,19 @@
182197 throw new SignInAgain(next);
183198 }
184199
200+/**
201+ * grantsFor reads for whoever the caller's role in `owner/name` depends on (the person, or an agent and its owner),
202+ * so loadRepo can read them alongside the repository. Nothing for callers whose role needs no grants.
203+ */
204+export function grantsFor(c: AppContext, owner: string, name: string): Promise<RepoGrants[]> {
205+ const p = c.get("principal");
206+ if (p.type !== "account" || p.account.status !== "active" || p.account.site_admin) return Promise.resolve([]);
207+ const a = p.account;
208+ const ids = a.kind === "agent" ? [a.id, ...(a.owner_id && p.owner?.id === a.owner_id && p.owner.kind === "user" ? [a.owner_id] : [])] : a.kind === "user" ? [a.id] : [];
209+ const dir = c.get("dir");
210+ return Promise.all(ids.map((id) => dir.grantsFor(owner, name, id))).then((all) => all.flat());
211+}
212+
185213 /** Validates scopes sent by a client: known names only, default write. */
186214 export function parseScopes(input: unknown): TokenScope[] {
187215 if (input === undefined || input === null) return ["write"];
@@ -195,7 +223,7 @@
195223 * Resolve the caller's effective role on a repository, combining account
196224 * permissions with token scopes and repo restrictions. Returns null for no access.
197225 */
198-export async function callerFor(c: AppContext, repo: Repo): Promise<Caller | null> {
226+export async function callerFor(c: AppContext, repo: Repo, grants: RepoGrants[] = []): Promise<Caller | null> {
199227 const p = c.get("principal");
200228 const dir = c.get("dir");
201229 // A suspended person's repositories are hidden from everyone but site administrators, and their
@@ -235,13 +263,15 @@
235263 return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null;
236264 }
237265 if (p.repoIds && !p.repoIds.includes(repo.id)) return null;
238- let role = await dir.roleFor(p.account, repo);
266+ const a = p.account;
267+ // An agent's owner: from its token's check when that read it, else read alongside the role.
268+ const known = a.kind === "agent" && a.owner_id && p.owner?.id === a.owner_id ? p.owner : undefined;
269+ const [found, owner] = await Promise.all([dir.roleFor(a, repo, known, grants), a.kind === "agent" && a.owner_id ? (known ?? dir.account(a.owner_id)) : null]);
270+ let role = found;
239271 if (!role) return null;
240272 const cap = p.scopes.reduce<Role>((best, s) => (ROLE_LEVEL[SCOPE_CAP[s]] > ROLE_LEVEL[best] ? SCOPE_CAP[s] : best), "read");
241273 if (ROLE_LEVEL[role] > ROLE_LEVEL[cap]) role = cap;
242- const a = p.account;
243- let onBehalfOf: string | undefined;
244- if (a.kind === "agent" && a.owner_id) onBehalfOf = (await dir.account(a.owner_id))?.login;
274+ const onBehalfOf = owner?.login;
245275 // Someone the repository's owner blocked (or their agent) can still read, but not take part (lib/moderation.ts).
246276 // Checked for changes only: reads stay as fast as they were.
247277 const writing = c.req.method !== "GET" && c.req.method !== "HEAD";
modifiedapps/diffinery/src/auth/sessions.ts+7−3View file
@@ -81,10 +81,14 @@
8181 /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days, up to 90 days after the sign-in. */
8282 async session(token: string): Promise<{ session: SessionRow; account: Account } | null> {
8383 if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null;
84- const row = await this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(await sha256(token)).first<SessionRow>();
84+ const id = await sha256(token);
85+ // Both at once, the account through the session's own row: every signed-in page waits for this.
86+ const [row, account] = await Promise.all([
87+ this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(id).first<SessionRow>(),
88+ this.db.prepare("SELECT * FROM accounts WHERE id = (SELECT account_id FROM sessions WHERE id = ?)").bind(id).first<Account>(),
89+ ]);
8590 if (!row || row.expires_at < now() || lastDay(row.created_at) < now()) return null;
86- const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>();
87- if (!account || account.kind !== "user" || account.status === "disabled") return null;
91+ if (!account || account.id !== row.account_id || account.kind !== "user" || account.status === "disabled") return null;
8892 if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) {
8993 const until = days(SESSION_DAYS);
9094 const last = lastDay(row.created_at);
modifiedapps/diffinery/src/http/common.ts+27−4View file
@@ -6,9 +6,10 @@
66 import { errorRecord, recordServerError, requestId } from "../lib/server-errors.js";
77 import { movedPath, movedStatus } from "../lib/redirects.js";
88 import { GITHUB_DOCS } from "./gh.js";
9-import { callerFor, requireScope, type AppContext } from "../auth/principal.js";
9+import { callerFor, grantsFor, requireScope, type AppContext } from "../auth/principal.js";
1010 import { atLeast, type Caller, type Role } from "../repo/types.js";
1111 import { suspendedIds } from "../lib/moderation.js";
12+import { MailStore, type WatchLevel } from "../mail/prefs.js";
1213
1314 export const repoStub = (env: Env, repoId: string) => env.REPO.get(env.REPO.idFromName(repoId));
1415
@@ -53,6 +54,9 @@
5354 * `follow` (Git, MCP) serves the repository under the old name instead, and
5455 * `moved` says it was reached that way.
5556 */
57+/** Paths whose reads show issues, pull requests or their comments (the web pages and the REST API). */
58+const CONVERSATION_PATH = /\/(?:issues|pulls?|comments)(?:\/|$)/;
59+
5660 export async function loadRepo(c: AppContext, owner: string, name: string, min: Role = "read", opts: { readOnlyPost?: boolean; whileArchived?: boolean; whileImporting?: boolean; follow?: boolean } = {}): Promise<{ repo: Repo; caller: Caller; stub: ReturnType<typeof repoStub>; moved: boolean }> {
5761 const dir = c.get("dir");
5862 const anonymous = c.get("principal").type === "anonymous";
@@ -62,11 +66,15 @@
6266 // API, as on GitHub, and a 401 everywhere else, so git asks for credentials and the web offers sign-in.
6367 if (anonymous && !reading) throw new HttpError(401, "authentication required");
6468 const hidden = () => (anonymous && !c.req.path.startsWith("/api/") ? new HttpError(401, "authentication required") : new HttpError(404, "repository not found"));
65- let repo = await dir.repo(owner, name.replace(/\.git$/, ""));
69+ // Reads of conversations leave out suspended people's posts (hiddenAuthors): look them up alongside the repository.
70+ if (reading && CONVERSATION_PATH.test(c.req.path)) hiddenAuthors(c).catch(() => {});
71+ // The repository and the grants that decide the caller's role in it, side by side.
72+ const [found, grants] = await Promise.all([dir.repo(owner, name.replace(/\.git$/, "")), grantsFor(c, owner, name)]);
73+ let repo = found;
6674 const moved = !repo;
6775 repo ??= await dir.movedRepo(owner, name);
6876 if (!repo) throw hidden();
69- const caller = await callerFor(c, repo);
77+ const caller = await callerFor(c, repo, grants);
7078 if (!caller) throw hidden();
7179 // A job or sandbox token reading another public repository acts as nobody in particular: it may read, never write.
7280 if (caller.id === "anonymous" && !reading) throw new HttpError(403, "this token can only write to its own repository");
@@ -87,14 +95,29 @@
8795 }
8896 if (repo.archived && !reading && !opts.whileArchived) throw new HttpError(403, "repository is archived (read-only)");
8997 const stub = repoStub(c.env, repo.id);
98+ // A page a person opens shows whether they watch the repository: start reading it now, alongside the page's own reads.
99+ const p = c.get("principal");
100+ if (reading && c.req.method === "GET" && p.type === "account" && p.via !== "token" && !c.req.path.startsWith("/api/") && !c.req.path.endsWith("/live")) {
101+ watchLevelOnce(c, repo.id).catch(() => {});
102+ }
90103 if (!reading && !opts.whileImporting) {
91104 const holder = await stub.importLockHolder();
92- const p = c.get("principal");
93105 if (holder && !(p.type === "account" && p.importId === holder)) throw new HttpError(423, "repository is locked by an import in progress; try again when it finishes, or cancel the import");
94106 }
95107 return { repo, caller, stub, moved };
96108 }
97109
110+/** The signed-in person's watch level for a repository, read once per request: the page header and a conversation's subscription box both show it. */
111+export function watchLevelOnce(c: AppContext, repoId: string): Promise<WatchLevel> {
112+ const p = c.get("principal");
113+ if (p.type !== "account") return Promise.resolve("participating");
114+ const known = c.get("watch");
115+ if (known?.repoId === repoId) return known.level;
116+ const level = new MailStore(c.env.DB).watchLevel(p.account.id, repoId);
117+ c.set("watch", { repoId, level });
118+ return level;
119+}
120+
98121 /**
99122 * Suspended people (lib/moderation.ts): the issues, pull requests, reviews and comments they wrote are
100123 * left out for everyone but site administrators. Looked up once per request.
modifiedapps/diffinery/src/http/ghctx.ts+30−17View file
@@ -31,10 +31,16 @@
3131 /** Accounts for a set of logins (assignees, reviewers), so user objects carry real ids and types. */
3232 export async function peopleFor(c: AppContext, logins: Iterable<string>): Promise<Map<string, Who>> {
3333 const out = new Map<string, Who>();
34- const dir = c.get("dir");
35- for (const login of new Set([...logins].map((l) => l.toLowerCase()))) {
36- if (out.size >= 50) break;
37- const a = await dir.accountByLogin(login).catch(() => null);
34+ const wanted = [...new Set([...logins].map((l) => l.toLowerCase()))].slice(0, 50);
35+ if (!wanted.length) return out;
36+ // One query for all of them.
37+ const r = await c.env.DB.prepare(`SELECT id, login, kind FROM accounts WHERE login IN (${wanted.map(() => "?").join(", ")})`)
38+ .bind(...wanted)
39+ .all<Who>()
40+ .catch(() => null);
41+ const found = new Map((r?.results ?? []).map((a) => [a.login.toLowerCase(), a]));
42+ for (const login of wanted) {
43+ const a = found.get(login);
3844 if (a) out.set(login, { id: a.id, login: a.login, kind: a.kind });
3945 }
4046 return out;
@@ -42,18 +48,21 @@
4248
4349 /** Everything ghIssue/ghPull need for `issues`, plus reaction counts for extra `subjects` (comments). */
4450 export async function issueCtx(c: AppContext, repo: Repo, stub: Stub, issues: IssueLike[], subjects: string[] = []): Promise<IssueCtx> {
45- const extras = await stub.issueExtras(
46- issues.map((i) => i.number),
47- subjects,
48- );
4951 const logins = issues.flatMap((i) => [...i.assignees, ...(i.pull?.requestedReviewers ?? []), ...(i.pull?.mergedBy ? [i.pull.mergedBy] : [])]);
52+ const [extras, people] = await Promise.all([
53+ stub.issueExtras(
54+ issues.map((i) => i.number),
55+ subjects,
56+ ),
57+ peopleFor(c, logins),
58+ ]);
5059 return {
5160 base: baseOf(c),
5261 owner: repo.owner_login,
5362 repo: repo.name,
5463 repoId: repo.id,
5564 labels: new Map(extras.labels.map((l) => [l.name, l])),
56- people: await peopleFor(c, logins),
65+ people,
5766 reactions: extras.reactions,
5867 lockReasons: extras.lockReasons,
5968 };
@@ -65,8 +74,8 @@
6574 }
6675
6776 export async function pullsOut(c: AppContext, repo: Repo, stub: Stub, issues: IssueLike[]) {
68- const x = await issueCtx(c, repo, stub, issues);
69- const heads = new Map((await stub.branches()).map((b) => [b.name, b.sha]));
77+ const [x, branches] = await Promise.all([issueCtx(c, repo, stub, issues), stub.branches()]);
78+ const heads = new Map(branches.map((b) => [b.name, b.sha]));
7079 const repoJson = ghRepo(x.base, repo);
7180 return issues.map((i) => ghPull(x, i, repoJson, ownerOf(repo), heads.get(i.pull!.base) ?? null));
7281 }
@@ -113,17 +122,21 @@
113122 export async function pullDetail(c: AppContext, repo: Repo, stub: Stub, number: number, caller: Caller) {
114123 const { issue, comments } = await stub.issue(number, await hiddenAuthors(c));
115124 if (issue.kind !== "pull") throw new HttpError(404, "not a pull request");
116- const [pull] = await pullsOut(c, repo, stub, [issue]);
117- // For agents and workflow tokens, also what Diffinery itself would need to land it for them (auto-merge, the merge queue).
118- const [m, automatic] = await Promise.all([stub.mergeability(number, caller), isPerson(caller) ? null : stub.mergeability(number, caller, { automatic: true })]);
119125 const open = issue.state === "open";
120- const commits = open || issue.pull!.merged ? (await stub.pullCommits(number)).length : 0;
121- const files = await stub.pullFiles(number).catch(() => null);
126+ // Everything else at once. For agents and workflow tokens, also what Diffinery itself would need to land it for them (auto-merge, the merge queue).
127+ const [[pull], m, automatic, commits, files, baseHead] = await Promise.all([
128+ pullsOut(c, repo, stub, [issue]),
129+ stub.mergeability(number, caller),
130+ isPerson(caller) ? null : stub.mergeability(number, caller, { automatic: true }),
131+ open || issue.pull!.merged ? stub.pullCommits(number).then((list) => list.length) : 0,
132+ stub.pullFiles(number).catch(() => null),
133+ open ? stub.resolve(issue.pull!.base) : null,
134+ ]);
122135 return {
123136 ...pull!,
124137 mergeable: open ? m.conflicts.length === 0 : null,
125138 mergeable_state: mergeableState(m.state),
126- rebaseable: open ? m.conflicts.length === 0 && m.mergeBase === (await stub.resolve(issue.pull!.base)) : null,
139+ rebaseable: open ? m.conflicts.length === 0 && m.mergeBase === baseHead : null,
127140 review_comments: comments.filter((x) => x.reviewId).length,
128141 commits,
129142 additions: files?.additions ?? 0,
modifiedapps/diffinery/src/lib/directory.ts+59−15View file
@@ -83,6 +83,9 @@
8383 /** A person's own pending invitations (binds: now, account id, account email). */
8484 const MINE = "i.accepted_at IS NULL AND i.expires_at > ? AND (i.org_id IS NOT NULL OR i.repo_id IS NOT NULL) AND (i.account_id = ? OR (i.account_id IS NULL AND i.email = ?))";
8585
86+/** One account's grants on one repository, as Directory.grantsFor reads them. */
87+export type RepoGrants = { repoId: string; accountId: string; access: Role | null; memberRole: string | null; baseRole: string | null };
88+
8689 export class Directory {
8790 constructor(private env: Env) {}
8891
@@ -501,14 +504,20 @@
501504 await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ?").bind(now(), id, accountId).run();
502505 }
503506
504- async verifyToken(token: string): Promise<{ id: string; account: Account; scopes: TokenScope[]; repoIds: string[] | null; importId: string | null } | null> {
505- const row = await this.db.prepare("SELECT * FROM tokens WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>();
507+ async verifyToken(token: string): Promise<{ id: string; account: Account; owner: Account | null; scopes: TokenScope[]; repoIds: string[] | null; importId: string | null } | null> {
508+ const hash = await sha256(token);
509+ // The token, its account and that account's owner (for an agent) in one round trip: every API, git and MCP call waits for this.
510+ const [row, account, tokenOwner] = await Promise.all([
511+ this.db.prepare("SELECT * FROM tokens WHERE token_hash = ?").bind(hash).first<Record<string, string | null>>(),
512+ this.db.prepare("SELECT * FROM accounts WHERE id = (SELECT account_id FROM tokens WHERE token_hash = ?)").bind(hash).first<Account>(),
513+ this.db.prepare("SELECT * FROM accounts WHERE id = (SELECT a.owner_id FROM tokens t JOIN accounts a ON a.id = t.account_id WHERE t.token_hash = ?)").bind(hash).first<Account>(),
514+ ]);
506515 if (!row || row.revoked_at || row.expires_at! < now()) return null;
507- const account = await this.account(row.account_id!);
508- if (!account || account.status !== "active") return null;
516+ if (!account || account.id !== row.account_id || account.status !== "active") return null;
509517 // An agent's tokens stop working while its owner is suspended (lib/moderation.ts), disabled or otherwise not active.
518+ let owner: Account | null = null;
510519 if (account.kind === "agent" && account.owner_id) {
511- const owner = await this.account(account.owner_id);
520+ owner = tokenOwner?.id === account.owner_id ? tokenOwner : await this.account(account.owner_id);
512521 if (!owner || owner.suspended_at || owner.status !== "active") return null;
513522 // A connected app acting as an organization's agent (auth/oauth.ts) stops the moment the person who
514523 // approved it is no longer an owner or admin of that organization, not only at its next refresh.
@@ -521,7 +530,7 @@
521530 if (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > 3600_000) {
522531 await this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").bind(now(), row.id!).run();
523532 }
524- return { id: row.id!, account, scopes: JSON.parse(row.scopes!), repoIds: row.repo_ids ? JSON.parse(row.repo_ids) : null, importId: row.import_id ?? null };
533+ return { id: row.id!, account, owner, scopes: JSON.parse(row.scopes!), repoIds: row.repo_ids ? JSON.parse(row.repo_ids) : null, importId: row.import_id ?? null };
525534 }
526535
527536 /** Revoke a token by id whoever owns it (server-managed tokens such as an import's push token). */
@@ -1127,18 +1136,57 @@
11271136 }
11281137
11291138 /** Effective role of an account on a repository (null = no access). */
1130- async roleFor(account: Account, repo: Repo): Promise<Role | null> {
1139+ /**
1140+ * The grants that decide `accountId`'s role in the repository `owner/name` (its own access, and its membership of
1141+ * the owning organization), read by name so a request can read them at the same time as the repository itself.
1142+ * Empty when there is no such repository. roleFor takes them in place of reading them again.
1143+ */
1144+ async grantsFor(owner: string, name: string, accountId: string): Promise<RepoGrants[]> {
1145+ const r = await this.db
1146+ .prepare(
1147+ "SELECT r.id AS repo_id, ? AS account_id, ra.role AS access, m.role AS member_role, o.base_role FROM repos r JOIN accounts o ON o.id = r.owner_id LEFT JOIN repo_access ra ON ra.repo_id = r.id AND ra.account_id = ? LEFT JOIN org_members m ON m.org_id = r.owner_id AND m.user_id = ? WHERE o.login = ? AND r.name = ?",
1148+ )
1149+ .bind(accountId, accountId, accountId, owner, name.replace(/\.git$/, ""))
1150+ .all<{ repo_id: string; account_id: string; access: Role | null; member_role: string | null; base_role: string | null }>();
1151+ return r.results.map((g) => ({ repoId: g.repo_id, accountId: g.account_id, access: g.access, memberRole: g.member_role, baseRole: g.base_role }));
1152+ }
1153+
1154+ /** `account`'s own access to `repo` and its membership of the owning organization: from `grants` when they cover them, else read. */
1155+ private async grantsOf(account: Account, repo: Repo, grants: RepoGrants[]): Promise<{ explicit: { role: Role } | null; m: { role: string; base_role: string | null } | null }> {
1156+ const g = grants.find((x) => x.repoId === repo.id && x.accountId === account.id);
1157+ if (g) return { explicit: g.access ? { role: g.access } : null, m: repo.owner_kind === "org" && g.memberRole ? { role: g.memberRole, base_role: g.baseRole } : null };
1158+ const [m, explicit] = await Promise.all([
1159+ repo.owner_kind === "org"
1160+ ? this.db
1161+ .prepare("SELECT m.role, o.base_role FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.org_id = ? AND m.user_id = ?")
1162+ .bind(repo.owner_id, account.id)
1163+ .first<{ role: string; base_role: string | null }>()
1164+ : null,
1165+ this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>(),
1166+ ]);
1167+ return { explicit, m };
1168+ }
1169+
1170+ /**
1171+ * `knownOwner`: an agent's owner as the caller already read it this request (auth/principal.ts), else it is read here.
1172+ * `grants`: grantsFor's answers for this repository, read alongside it; any not covered are read here.
1173+ */
1174+ async roleFor(account: Account, repo: Repo, knownOwner?: Account | null, grants: RepoGrants[] = []): Promise<Role | null> {
11311175 if (account.status !== "active") return null;
11321176 if (account.kind === "agent") {
11331177 if (!account.owner_id) return null;
11341178 // An agent from Agent setup reaches its one repository and nothing else, whatever token it uses.
11351179 const only = agentRepoId(account);
11361180 if (only !== null && only !== repo.id) return null;
1137- const owner = await this.account(account.owner_id);
1181+ // The owner, the owner's role and the agent's own grant, each read once and side by side.
1182+ const ownerOf = knownOwner?.id === account.owner_id ? Promise.resolve(knownOwner) : this.account(account.owner_id);
1183+ const [owner, ownerRole, explicit] = await Promise.all([
1184+ ownerOf,
1185+ ownerOf.then((o) => (!o || o.status !== "active" ? null : o.kind === "org" ? (repo.owner_id === o.id ? ("admin" as const) : null) : this.roleFor(o, repo, undefined, grants))),
1186+ this.grantsOf(account, repo, grants).then((g) => g.explicit),
1187+ ]);
11381188 if (!owner || owner.status !== "active") return null;
1139- const ownerRole = owner.kind === "org" ? (repo.owner_id === owner.id ? "admin" : null) : await this.roleFor(owner, repo);
11401189 if (!ownerRole) return null;
1141- const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>();
11421190 const policy = account.agent_policy ? (JSON.parse(account.agent_policy) as { maxRole?: Role }) : {};
11431191 // A grant on the repository can lower an agent's role, never lift it past the maximum its owner set:
11441192 // any of the repository's admins can grant, and only the owner decides how far the agent goes.
@@ -1153,19 +1201,15 @@
11531201 if (r && (!best || ROLE_LEVEL[r] > ROLE_LEVEL[best])) best = r;
11541202 };
11551203 if (repo.owner_id === account.id) bump("admin");
1204+ const { m, explicit } = await this.grantsOf(account, repo, grants);
11561205 if (repo.owner_kind === "org") {
11571206 // Owners and admins administer every repository; members get the organization's base permission.
1158- const m = await this.db
1159- .prepare("SELECT m.role, o.base_role FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.org_id = ? AND m.user_id = ?")
1160- .bind(repo.owner_id, account.id)
1161- .first<{ role: string; base_role: string | null }>();
11621207 if (m?.role === "owner" || m?.role === "admin") bump("admin");
11631208 else if (m?.role === "member") {
11641209 const base = baseRole(m);
11651210 if (base !== "none") bump(base);
11661211 }
11671212 }
1168- const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>();
11691213 bump(explicit?.role ?? null);
11701214 if (repo.visibility !== "private") bump("read");
11711215 return best;
modifiedapps/diffinery/src/lib/timing.ts+25−3View file
@@ -10,7 +10,16 @@
1010 export class Timing {
1111 private readonly start = Date.now();
1212 private readonly spans = new Map<string, { n: number; ms: number }>();
13+ /** Where D1 answered from, as its results say (only `all`, `run` and `batch` results carry it). */
14+ private d1Where: string | null = null;
1315
16+ /** Notes where a D1 result came from: its region, and whether the primary or a replica answered. */
17+ d1Result(r: unknown): void {
18+ const meta = (r as { meta?: { served_by_region?: string; served_by_primary?: boolean } } | null)?.meta;
19+ if (this.d1Where || !meta?.served_by_region || !/^[A-Z]{2,8}$/.test(meta.served_by_region)) return;
20+ this.d1Where = `${meta.served_by_region} ${meta.served_by_primary ? "primary" : "replica"}`;
21+ }
22+
1423 /** Times `p` under `kind` and hands it back unchanged. */
1524 track<T>(kind: string, p: T): T {
1625 if (!(p && typeof (p as { then?: unknown }).then === "function")) return p;
@@ -27,7 +36,10 @@
2736
2837 header(colo?: string): string {
2938 const out = [`total;dur=${Date.now() - this.start}`];
30- for (const [kind, s] of this.spans) out.push(`${kind};dur=${s.ms};desc="${s.n} ${kind === "d1" ? "quer" + (s.n === 1 ? "y" : "ies") : "call" + (s.n === 1 ? "" : "s")}"`);
39+ for (const [kind, s] of this.spans) {
40+ const what = kind === "d1" ? `quer${s.n === 1 ? "y" : "ies"}${this.d1Where ? `, ${this.d1Where}` : ""}` : `call${s.n === 1 ? "" : "s"}`;
41+ out.push(`${kind};dur=${s.ms};desc="${s.n} ${what}"`);
42+ }
3143 if (colo && /^[A-Z]{3}$/.test(colo)) out.push(`colo;desc="${colo}"`);
3244 return out.join(", ");
3345 }
@@ -41,7 +53,12 @@
4153 try {
4254 for (const m of STATEMENT_RUNS) {
4355 const run = st[m];
44- if (typeof run === "function") st[m] = (...a: unknown[]) => t.track("d1", run.apply(s, a));
56+ if (typeof run === "function")
57+ st[m] = (...a: unknown[]) => {
58+ const p = t.track("d1", run.apply(s, a));
59+ if (m === "all" || m === "run") (p as Promise<unknown>).then((r) => t.d1Result(r), () => {});
60+ return p;
61+ };
4562 }
4663 const bind = st.bind;
4764 if (typeof bind === "function") st.bind = (...a: unknown[]) => timedStatement(t, bind.apply(s, a) as D1PreparedStatement);
@@ -57,7 +74,12 @@
5774 const v = Reflect.get(target, prop, target) as unknown;
5875 if (typeof v !== "function") return v;
5976 if (prop === "prepare") return (q: string) => timedStatement(t, (v as (q: string) => D1PreparedStatement).call(target, q));
60- if (prop === "batch" || prop === "exec") return (...a: unknown[]) => t.track("d1", (v as (...a: unknown[]) => unknown).apply(target, a));
77+ if (prop === "batch" || prop === "exec")
78+ return (...a: unknown[]) => {
79+ const p = t.track("d1", (v as (...a: unknown[]) => unknown).apply(target, a));
80+ if (prop === "batch") (p as Promise<unknown[]>).then((rs) => t.d1Result(rs?.[0]), () => {});
81+ return p;
82+ };
6183 return (v as (...a: unknown[]) => unknown).bind(target);
6284 },
6385 });
modifiedapps/diffinery/src/repo/actions.ts+3−2View file
@@ -138,9 +138,10 @@
138138 /**
139139 * A container start that failed on the platform's side and works when tried again: no machine free,
140140 * a container sidecar or Durable Object restarting under it (seen during rollouts as "Container
141- * sidecar is shutting down"), a dropped connection to it, or a container that didn't come up in time.
141+ * sidecar is shutting down"), a dropped connection to it, a container that didn't come up in time, or the
142+ * platform's own "internal error; reference = …" (one failed a required check outright on 2026-10-24).
142143 */
143-const RETRYABLE_START = /no container instance|is shutting down|network connection lost|durable object reset|overloaded|too many containers|aborted waiting for container to start/i;
144+const RETRYABLE_START = /no container instance|is shutting down|network connection lost|durable object reset|overloaded|too many containers|aborted waiting for container to start|internal error; reference =/i;
144145 /** Hosted containers one matrix starts at once: each start takes a couple of seconds. */
145146 const START_AT_ONCE = 8;
146147 /** Runs one repository may have unfinished at once; past it, new ones aren't started. */
modifiedapps/diffinery/src/web/account.tsx+6−3View file
@@ -238,12 +238,15 @@
238238 );
239239 }
240240 const dir = c.get("dir");
241+ // Read once, for the sidebar and for Today.
242+ const visible = dir.reposVisibleTo(me);
243+ const memberships = dir.orgsFor(me.id);
241244 const [repos, orgs, notes, today] = await Promise.all([
242- dir.reposVisibleTo(me),
243- dir.orgsFor(me.id),
245+ visible,
246+ memberships,
244247 dir.notifications(me.id, true),
245248 // The morning view (agentic/today.ts); the dashboard still renders if it fails.
246- gatherToday(c.env, dir, me).catch((e: unknown) => {
249+ gatherToday(c.env, dir, me, { visible, orgs: memberships }).catch((e: unknown) => {
247250 console.error("today failed", e);
248251 return null;
249252 }),
modifiedapps/diffinery/src/web/actions.tsx+11−6View file
@@ -11,7 +11,7 @@
1111 import { ICONS } from "./assets.js";
1212 import { KCheckbox, KSelect } from "./kumo.js";
1313 import { csrfToken } from "./csrf.js";
14-import { ActionsBlockedBanner } from "./kill-switches.js";
14+import { ActionsBlockedBanner, actionsSwitches } from "./kill-switches.js";
1515 import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js";
1616
1717 type Loaded = Awaited<ReturnType<typeof loadRepo>>;
@@ -185,6 +185,7 @@
185185 const status = c.req.query("status") ?? "";
186186 const page = pageNum(c);
187187 const per = 25;
188+ const switches = actionsSwitches(c.env, repo.id);
188189 const [runs, workflows, names] = await Promise.all([
189190 stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }),
190191 stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>),
@@ -224,7 +225,7 @@
224225 </section>
225226 </aside>
226227 <div>
227- <ActionsBlockedBanner env={c.env} repoId={repo.id} />
228+ <ActionsBlockedBanner env={c.env} repoId={repo.id} switches={switches} />
228229 <div class="row between mb">
229230 <h1 class="m0">{selected ? selected.name : "All workflows"}</h1>
230231 {selected ? <span class="mono muted small">{selected.path}</span> : null}
@@ -352,10 +353,14 @@
352353 const { repo, caller, stub } = l;
353354 const base = repoUrl(repo);
354355 const runId = prm(c, "runId");
355- const run = (await stub.run(runId)) as unknown as RunWithJobs;
356- const artifacts = await stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>);
356+ // The run, its artifacts and the viewer side by side, then its deployments (they need the run's own id and status).
357+ const switches = actionsSwitches(c.env, repo.id);
358+ const [run, artifacts, viewer] = await Promise.all([
359+ stub.run(runId) as unknown as Promise<RunWithJobs>,
360+ stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>),
361+ viewerFor(c, repo),
362+ ]);
357363 const principal = c.get("principal");
358- const viewer = await viewerFor(c, repo);
359364 const [pending, reviews] = await Promise.all([run.status !== "completed" ? stub.pendingDeployments(run.id, viewer) : Promise.resolve([] as PendingDeployment[]), stub.deploymentReviews(run.id)]);
360365 const groups = new Map<string, JobInfo[]>();
361366 for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]);
@@ -366,7 +371,7 @@
366371 c,
367372 { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined },
368373 <div data-fragment="run">
369- <ActionsBlockedBanner env={c.env} repoId={repo.id} />
374+ <ActionsBlockedBanner env={c.env} repoId={repo.id} switches={switches} />
370375 <div class="row between mb" style="align-items:flex-start">
371376 <div class="grow">
372377 <div class="muted small">
modifiedapps/diffinery/src/web/collab.tsx+50−20View file
@@ -3,7 +3,7 @@
33 import { raw } from "hono/html";
44 import type { Child, FC } from "hono/jsx";
55 import type { AppContext } from "../auth/principal.js";
6-import { hiddenAuthors, loadRepo } from "../http/common.js";
6+import { hiddenAuthors, loadRepo, watchLevelOnce } from "../http/common.js";
77 import { HttpError } from "../lib/util.js";
88 import type { Check, Comment, Issue, Mergeability, Review } from "../repo/collab.js";
99 import { atLeast, type Role } from "../repo/types.js";
@@ -114,6 +114,8 @@
114114 // ---------------------------------------------------------------- lists
115115
116116 async function listPage(c: AppContext, kind: "issue" | "pull"): Promise<Response> {
117+ // Suspended authors are left out of the list: start that lookup before the repository's.
118+ hiddenAuthors(c).catch(() => {});
117119 const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
118120 const base = repoUrl(repo);
119121 const state = c.req.query("state") === "closed" ? "closed" : "open";
@@ -1059,21 +1061,48 @@
10591061
10601062 // ---------------------------------------------------------------- pages
10611063
1062-async function loadConv(c: AppContext): Promise<ConvCtx> {
1064+/**
1065+ * A conversation and what its page shows around it. `alongside` reads what a page needs once the conversation is known
1066+ * (a pull request's checks, say), at the same time as its authors' owners.
1067+ */
1068+async function loadConv<T = null>(c: AppContext, alongside?: (l: Loaded, issue: Issue) => Promise<T>): Promise<ConvCtx & { extra: T }> {
1069+ // Suspended authors are left out of the conversation (http/common.ts): start that lookup before the repository's.
1070+ hiddenAuthors(c).catch(() => {});
10631071 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
10641072 const n = issueNumber(c);
1065- const [data, labels, milestones] = await Promise.all([l.stub.issue(n, await hiddenAuthors(c)), l.stub.labels(), l.stub.milestones("open")]);
1066- const behalf = await onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]);
10671073 const me = principalAccount(c);
1068- let sub: ConvCtx["sub"];
1069- if (me?.kind === "user") {
1070- const store = new MailStore(c.env.DB);
1071- const [explicit, watch] = await Promise.all([store.threadSub(me.id, l.repo.id, n), store.watchLevel(me.id, l.repo.id)]);
1072- sub = { explicit, watch };
1073- }
1074- return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf, sub, milestones };
1074+ // Everything the page needs that doesn't depend on the conversation itself, side by side with it.
1075+ const [data, labels, milestones, sub] = await Promise.all([
1076+ hiddenAuthors(c).then((hidden) => l.stub.issue(n, hidden)),
1077+ l.stub.labels(),
1078+ l.stub.milestones("open"),
1079+ me?.kind === "user" ? Promise.all([new MailStore(c.env.DB).threadSub(me.id, l.repo.id, n), watchLevelOnce(c, l.repo.id)]).then(([explicit, watch]) => ({ explicit, watch })) : undefined,
1080+ ]);
1081+ const [behalf, extra] = await Promise.all([
1082+ onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]),
1083+ alongside ? alongside(l, data.issue) : (null as T),
1084+ ]);
1085+ return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf, sub, milestones, extra };
10751086 }
10761087
1088+type PullTab = "conversation" | "commits" | "files" | "checks";
1089+
1090+/** What a pull request's tab shows besides the conversation, read side by side. */
1091+async function pullTabData(c: AppContext, l: Loaded, i: Issue, tab: PullTab) {
1092+ const { stub, caller } = l;
1093+ const n = i.number;
1094+ const headSha = i.pull?.headSha ?? "";
1095+ const [agentReview, m, checks, commits, runs, diff] = await Promise.all([
1096+ tab === "conversation" ? pullReviewCtx(c, l) : null,
1097+ tab === "conversation" && i.state === "open" ? stub.mergeability(n, caller).catch(() => null) : null,
1098+ tab === "conversation" || tab === "checks" ? stub.checksAndStatuses(headSha).catch(() => [] as Check[]) : ([] as Check[]),
1099+ tab === "commits" ? stub.pullCommits(n).catch(() => []) : [],
1100+ tab === "checks" ? stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 })) : { runs: [], total: 0 },
1101+ tab === "files" ? stub.pullFiles(n) : null,
1102+ ]);
1103+ return { agentReview, m, checks, commits, runs, diff };
1104+}
1105+
10771106 async function issuePage(c: AppContext): Promise<Response> {
10781107 const x = await loadConv(c);
10791108 const base = repoUrl(x.l.repo);
@@ -1097,13 +1126,14 @@
10971126 );
10981127 }
10991128
1100-async function pullPage(c: AppContext, tab: "conversation" | "commits" | "files" | "checks"): Promise<Response> {
1101- const x = await loadConv(c);
1102- const { repo, caller, stub } = x.l;
1129+async function pullPage(c: AppContext, tab: PullTab): Promise<Response> {
1130+ const x = await loadConv(c, (l, i) => (i.kind === "pull" && i.pull ? pullTabData(c, l, i, tab) : Promise.resolve(null)));
1131+ const { repo, caller } = x.l;
11031132 const base = repoUrl(repo);
11041133 const i = x.issue;
1105- if (i.kind !== "pull" || !i.pull) return c.redirect(`${base}/issues/${i.number}`, 302);
1106- if (tab === "conversation") x.agentReview = await pullReviewCtx(c, x.l);
1134+ if (i.kind !== "pull" || !i.pull || !x.extra) return c.redirect(`${base}/issues/${i.number}`, 302);
1135+ const d = x.extra;
1136+ if (tab === "conversation") x.agentReview = d.agentReview;
11071137 const n = i.number;
11081138 const page = (body: Child, wide = false) =>
11091139 render(
@@ -1117,7 +1147,7 @@
11171147 );
11181148
11191149 if (tab === "conversation") {
1120- const [m, checks] = await Promise.all([i.state === "open" ? stub.mergeability(n, caller).catch(() => null) : Promise.resolve(null), stub.checksAndStatuses(i.pull.headSha).catch(() => [] as Check[])]);
1150+ const { m, checks } = d;
11211151 return page(
11221152 <div class="grid">
11231153 <div>
@@ -1146,7 +1176,7 @@
11461176 }
11471177
11481178 if (tab === "commits") {
1149- const commits = await stub.pullCommits(n).catch(() => []);
1179+ const { commits } = d;
11501180 return page(
11511181 <div class="box">
11521182 {commits.length ? (
@@ -1163,7 +1193,7 @@
11631193 }
11641194
11651195 if (tab === "checks") {
1166- const [checks, runs] = await Promise.all([stub.checksAndStatuses(i.pull.headSha).catch(() => [] as Check[]), stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 }))]);
1196+ const { checks, runs } = d;
11671197 return page(
11681198 <div data-fragment="checks">
11691199 <div class="box">
@@ -1219,7 +1249,7 @@
12191249 }
12201250
12211251 // Files changed
1222- const diff = await stub.pullFiles(n);
1252+ const diff = d.diff!;
12231253 const threads = new Map<string, LineThread[]>();
12241254 const byKey = new Map<string, Comment[]>();
12251255 for (const cm of x.comments) {
modifiedapps/diffinery/src/web/kill-switches.tsx+7−3View file
@@ -1,7 +1,7 @@
11 /** @jsxImportSource hono/jsx */
22 import type { FC } from "hono/jsx";
33 import type { Env } from "../env.js";
4-import { computePause, killSwitches, suspendedList, type Suspension } from "../lib/killswitch.js";
4+import { computePause, killSwitches, suspendedList, type KillSwitches, type Suspension } from "../lib/killswitch.js";
55 import { Form, Time } from "./ui.js";
66
77 /**
@@ -28,9 +28,13 @@
2828 );
2929 };
3030
31+/** What ActionsBlockedBanner shows, for a page to start reading early. Never rejects. */
32+export const actionsSwitches = (env: Env, repoId: string): Promise<KillSwitches | null> => killSwitches(env, repoId).catch(() => null);
33+
3134 /** On a repository's Actions pages and settings: its Actions are suspended, or hosted compute is paused. */
32-export const ActionsBlockedBanner: FC<{ env: Env; repoId: string }> = async ({ env, repoId }) => {
33- const k = await killSwitches(env, repoId).catch(() => null);
35+export const ActionsBlockedBanner: FC<{ env: Env; repoId: string; switches?: Promise<KillSwitches | null> }> = async ({ env, repoId, switches }) => {
36+ // `switches`: read by the page alongside its own data (actionsSwitches), so the banner doesn't wait for it after them.
37+ const k = await (switches ?? actionsSwitches(env, repoId));
3438 if (!k) return <></>;
3539 const s = k.repo ?? k.account;
3640 if (s)
modifiedapps/diffinery/src/web/ui.tsx+26−18View file
@@ -9,13 +9,13 @@
99 import type { AppContext, AppVars } from "../auth/principal.js";
1010 import { HttpError, randomToken } from "../lib/util.js";
1111 import { atLeast, type Caller } from "../repo/types.js";
12-import { noteServerError, RepoMoved, type repoStub } from "../http/common.js";
12+import { noteServerError, RepoMoved, watchLevelOnce, type repoStub } from "../http/common.js";
1313 import { CSS, ICONS, JS, type IconName } from "./assets.js";
1414 import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js";
1515 import { fenceLangs, renderMarkdown, type MarkdownBase } from "./markdown.js";
1616 import { diffLineTokens, fenceHighlighter, langForPath, LIMITS, ready, tokenize, type Lang, type TokLine } from "./highlight.js";
1717 import { KSelect, kumoAssets } from "./kumo.js";
18-import { MailStore, type WatchLevel } from "../mail/prefs.js";
18+import type { WatchLevel } from "../mail/prefs.js";
1919 import { MoreMenu, ReportItem } from "./moderation.js";
2020 import { SeoHead, SITE } from "./seo.js";
2121 import { avatarPath } from "../http/avatars.js";
@@ -413,8 +413,7 @@
413413 const p = c.get("principal");
414414 const me = p?.type === "account" ? p.account : null;
415415 if (opts.repo && me?.status === "active" && me.kind === "user" && c.env.DB) {
416- return new MailStore(c.env.DB)
417- .watchLevel(me.id, opts.repo.repo.id)
416+ return watchLevelOnce(c, opts.repo.repo.id)
418417 .catch(() => "participating" as const)
419418 .then((w) => renderPage(c, opts, body, w));
420419 }
@@ -461,7 +460,15 @@
461460 </div>
462461 );
463462
464-function renderPage(c: AppContext, opts: PageOpts, body: Child, watch?: WatchLevel): Response | Promise<Response> {
463+/** Kumo islands' placeholders (kumo.tsx), which the islands bundle mounts. */
464+const ISLAND = ' data-k="';
465+
466+async function renderPage(c: AppContext, opts: PageOpts, body: Child, watch?: WatchLevel): Promise<Response> {
467+ // The body first (it waits for async components such as highlighted Markdown and diffs): the Kumo islands bundle,
468+ // about 480 KB of script and 150 KB of style, loads only on pages with an island to mount. Repository pages have
469+ // one in their header, and live updates can bring some in later fragments.
470+ const content = await (<>{body}</>).toString();
471+ const kumo = !!opts.repo || !!opts.live || content.includes(ISLAND);
465472 csrfToken(c);
466473 const nonce = randomToken(16);
467474 const fl = takeFlash(c);
@@ -494,7 +501,7 @@
494501 nonce,
495502 })}
496503 <style>{raw(CSS)}</style>
497- <link rel="stylesheet" href={kumoAssets().css} />
504+ {kumo ? <link rel="stylesheet" href={kumoAssets().css} /> : null}
498505 {opts.head ?? null}
499506 {opts.turnstile ? <script nonce={nonce} src={TURNSTILE_SCRIPT} async defer></script> : null}
500507 </head>
@@ -563,7 +570,7 @@
563570 {r ? <RepoHeader r={r} watch={watch} t={csrfToken(c)} /> : null}
564571 <main class="wrap" style={opts.wide ? "max-width:none" : undefined}>
565572 {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null}
566- {body}
573+ {raw(content)}
567574 </main>
568575 <footer class="site-foot">
569576 {active ? null : <SiteFooter />}
@@ -599,7 +606,7 @@
599606 </div>
600607 </dialog>
601608 ) : null}
602- <script nonce={nonce} src={kumoAssets().js} defer></script>
609+ {kumo ? <script nonce={nonce} src={kumoAssets().js} defer></script> : null}
603610 <script nonce={nonce}>{raw(JS)}</script>
604611 {opts.script ? <script nonce={nonce}>{raw(opts.script)}</script> : null}
605612 {(opts.scriptSrc ?? []).map((src) => (
@@ -981,17 +988,18 @@
981988 /** Look up agents' owners for "on behalf of" labels. */
982989 export async function onBehalfOf(c: AppContext, actors: Array<{ login: string; kind: string }>): Promise<Map<string, string>> {
983990 const out = new Map<string, string>();
984- const dir = c.get("dir");
985991 const logins = [...new Set(actors.filter((a) => a.kind === "agent").map((a) => a.login))].slice(0, 20);
986- await Promise.all(
987- logins.map(async (login) => {
988- const a = await dir.accountByLogin(login);
989- if (a?.owner_id) {
990- const owner = await dir.account(a.owner_id);
991- if (owner) out.set(login, owner.login);
992- }
993- }),
994- );
992+ if (!logins.length) return out;
993+ // Every agent and its owner in one query.
994+ const r = await c.env.DB.prepare(`SELECT a.login, o.login AS owner FROM accounts a JOIN accounts o ON o.id = a.owner_id WHERE a.login IN (${logins.map(() => "?").join(", ")})`)
995+ .bind(...logins)
996+ .all<{ login: string; owner: string }>();
997+ // Logins match without regard to case, as in the single lookups.
998+ const owners = new Map(r.results.map((row) => [row.login.toLowerCase(), row.owner]));
999+ for (const login of logins) {
1000+ const owner = owners.get(login.toLowerCase());
1001+ if (owner) out.set(login, owner);
1002+ }
9951003 return out;
9961004 }
9971005
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+5−1View file
@@ -813,6 +813,10 @@
813813 startError = "Container sidecar is shutting down";
814814 await actions.relaunchJob("j1");
815815 expect(job("j1")).toMatchObject({ status: "queued", conclusion: null, placement_tries: 4 });
816+ // So does the platform's own internal error starting a container (seen on 2026-10-24).
817+ startError = "internal error; reference = 807a2qccbmm6pf5macj9vg76";
818+ await actions.relaunchJob("j1");
819+ expect(job("j1")).toMatchObject({ status: "queued", conclusion: null, placement_tries: 5 });
816820 sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status) VALUES ('j3', 'run1', 'k3', 'k3', 'queued')");
817821 startError = "image not found";
818822 await launch("j3");
@@ -821,7 +825,7 @@
821825
822826 // Past the limit it fails, and says why.
823827 startError = "there is no container instance that can be provided to this durable object";
824- for (let i = 4; i < MAX_PLACEMENT_TRIES; i++) await actions.relaunchJob("j1");
828+ for (let i = 5; i < MAX_PLACEMENT_TRIES; i++) await actions.relaunchJob("j1");
825829 expect(job("j1")).toMatchObject({ status: "queued", placement_tries: MAX_PLACEMENT_TRIES });
826830 await actions.relaunchJob("j1");
827831 expect(job("j1")).toMatchObject({ status: "completed", conclusion: "failure" });
addedapps/diffinery/test/unit/role-grants.test.ts+75−0View file
@@ -1,0 +1,75 @@
1+import { describe, expect, it } from "vitest";
2+import { Directory, type Account, type RepoGrants } from "../../src/lib/directory.js";
3+import { testD1 } from "./stubs/d1.js";
4+import type { Env } from "../../src/env.js";
5+
6+/** roleFor with the grants loadRepo reads alongside the repository (grantsFor) gives the same role as reading them itself. */
7+function world() {
8+ const DB = testD1();
9+ const t = "2026-10-01T00:00:00.000Z";
10+ const acct = DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, site_admin, owner_id, agent_policy, base_role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
11+ acct.run("ada", "ada", "user", "active", 0, null, null, null, t);
12+ acct.run("bob", "bob", "user", "active", 0, null, null, null, t);
13+ acct.run("cy", "cy", "user", "active", 0, null, null, null, t);
14+ acct.run("dee", "dee", "user", "disabled", 0, null, null, null, t);
15+ acct.run("root", "root", "user", "active", 1, null, null, null, t);
16+ acct.run("eve", "eve", "user", "active", 0, null, null, null, t);
17+ acct.run("acme", "acme", "org", "active", 0, null, null, "write", t);
18+ acct.run("shut", "shut", "org", "active", 0, null, null, "none", t);
19+ acct.run("ada-bot", "ada-bot", "agent", "active", 0, "ada", null, null, t);
20+ acct.run("bob-bot", "bob-bot", "agent", "active", 0, "bob", JSON.stringify({ maxRole: "triage" }), null, t);
21+ acct.run("cy-bot", "cy-bot", "agent", "active", 0, "cy", null, null, t);
22+ acct.run("acme-bot", "acme-bot", "agent", "active", 0, "acme", null, null, t);
23+ acct.run("dee-bot", "dee-bot", "agent", "active", 0, "dee", null, null, t);
24+ const member = DB.sqlite.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, '2026-10-01')");
25+ member.run("acme", "bob", "member");
26+ member.run("acme", "ada", "owner");
27+ member.run("shut", "bob", "member");
28+ member.run("shut", "cy", "admin");
29+ const repo = DB.sqlite.prepare("INSERT INTO repos (id, owner_id, name, visibility, created_at, updated_at, pushed_at) VALUES (?, ?, ?, ?, ?, ?, ?)");
30+ repo.run("r-ada", "ada", "notes", "private", t, t, t);
31+ repo.run("r-acme", "acme", "app", "private", t, t, t);
32+ repo.run("r-acme-pub", "acme", "site", "public", t, t, t);
33+ repo.run("r-shut", "shut", "vault", "private", t, t, t);
34+ repo.run("r-pub", "cy", "tool", "public", t, t, t);
35+ const grant = DB.sqlite.prepare("INSERT INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)");
36+ grant.run("r-ada", "cy", "read");
37+ grant.run("r-acme", "eve", "maintain");
38+ grant.run("r-shut", "bob", "write");
39+ grant.run("r-acme", "bob-bot", "admin");
40+ grant.run("r-ada", "ada-bot", "read");
41+ grant.run("r-shut", "cy-bot", "triage");
42+ return new Directory({ DB } as unknown as Env);
43+}
44+
45+describe("roles from grants read alongside the repository", () => {
46+ it("match roleFor reading them itself, for people and agents", async () => {
47+ const dir = world();
48+ const everyone = (await dir.accountsByIds(["ada", "bob", "cy", "dee", "root", "eve", "ada-bot", "bob-bot", "cy-bot", "acme-bot", "dee-bot"])) as Account[];
49+ let compared = 0;
50+ for (const id of ["r-ada", "r-acme", "r-acme-pub", "r-shut", "r-pub"]) {
51+ const repo = (await dir.repoById(id))!;
52+ for (const a of everyone) {
53+ const owner = a.owner_id ? await dir.account(a.owner_id) : null;
54+ const ids = a.kind === "agent" ? [a.id, ...(owner?.kind === "user" ? [owner.id] : [])] : [a.id];
55+ const grants: RepoGrants[] = (await Promise.all(ids.map((x) => dir.grantsFor(repo.owner_login, repo.name, x)))).flat();
56+ expect(grants.every((g) => g.repoId === repo.id)).toBe(true);
57+ expect(await dir.roleFor(a, repo, owner, grants), `${a.login} on ${id}`).toBe(await dir.roleFor(a, repo));
58+ compared++;
59+ }
60+ }
61+ expect(compared).toBe(55);
62+ });
63+
64+ it("are ignored for another repository, and read by name with .git and any case", async () => {
65+ const dir = world();
66+ const bob = (await dir.account("bob"))!;
67+ const shut = (await dir.repoById("r-shut"))!;
68+ // Grants read for acme/app don't stand in for shut/vault: roleFor reads shut/vault's own.
69+ const other = await dir.grantsFor("acme", "app", "bob");
70+ expect(other[0]!.repoId).toBe("r-acme");
71+ expect(await dir.roleFor(bob, shut, undefined, other)).toBe("write");
72+ expect((await dir.grantsFor("SHUT", "vault.git", "bob"))[0]).toMatchObject({ repoId: "r-shut", access: "write", memberRole: "member", baseRole: "none" });
73+ expect(await dir.grantsFor("shut", "missing", "bob")).toEqual([]);
74+ });
75+});
modifiedapps/diffinery/test/unit/timing.test.ts+3−3View file
@@ -11,7 +11,7 @@
1111 return { sql: this.sql, params: this.params };
1212 }
1313 async all() {
14- return { results: [] };
14+ return { results: [], meta: { served_by_region: "WEUR", served_by_primary: true } };
1515 }
1616 }
1717
@@ -44,8 +44,8 @@
4444 await Promise.resolve();
4545 const h = t.header("IST");
4646 expect(h).toMatch(/^total;dur=\d+, /);
47- expect(h).toContain('d1;dur=0;desc="3 queries"');
48- expect(h).toContain('do;dur=0;desc="2 calls"');
47+ expect(h).toMatch(/d1;dur=\d+;desc="3 queries, WEUR primary"/);
48+ expect(h).toMatch(/do;dur=\d+;desc="2 calls"/);
4949 expect(h).toContain('colo;desc="IST"');
5050 expect(t.header("not a colo")).not.toContain("colo");
5151 });