Merge pull request #269 from claude/faster-pages
Faster pages: read side by side what each page needs, skip unused scripts
cnbrkdmrci authored 2 parents 5d1cb0cc95ca03commit 683b739beb0ea3e50cf58e3a579f825990056406Browse files
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to CloudflareFailed
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
18 files changed+398−121
Files (18)
- apps/diffinery/docs/actions/runners.md+1−1
- apps/diffinery/docs/reference/rest-api.md+1−1
- apps/diffinery/src/agentic/today.ts+23−12
- apps/diffinery/src/auth/principal.ts+39−9
- apps/diffinery/src/auth/sessions.ts+7−3
- apps/diffinery/src/http/common.ts+27−4
- apps/diffinery/src/http/ghctx.ts+30−17
- apps/diffinery/src/lib/directory.ts+59−15
- apps/diffinery/src/lib/timing.ts+25−3
- apps/diffinery/src/repo/actions.ts+3−2
- apps/diffinery/src/web/account.tsx+6−3
- apps/diffinery/src/web/actions.tsx+11−6
- apps/diffinery/src/web/collab.tsx+50−20
- apps/diffinery/src/web/kill-switches.tsx+7−3
- apps/diffinery/src/web/ui.tsx+26−18
- apps/diffinery/test/unit/actions-runtime.test.ts+5−1
- apps/diffinery/test/unit/role-grants.test.ts+75−0
- apps/diffinery/test/unit/timing.test.ts+3−3
modifiedapps/diffinery/docs/actions/runners.md+1−1View file
| @@ -440,7 +440,7 @@ | ||
| 440 | 440 | | `403 admin access required` | The runner's token or account lacks admin on the repository, or the token isn't `admin`-scoped. | |
| 441 | 441 | | `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`. | |
| 442 | 442 | | `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`. | | |
| 444 | 444 | | `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). | |
| 445 | 445 | | `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. | |
| 446 | 446 | | `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 @@ | ||
| 291 | 291 | | `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. | |
| 292 | 292 | | `DELETE` endpoints and some `PUT` endpoints | No body (`204`) | |
| 293 | 293 | |
| 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. | |
| 295 | 295 | |
| 296 | 296 | ## Errors |
| 297 | 297 |
modifiedapps/diffinery/src/agentic/today.ts+23−12View file
| @@ -181,25 +181,36 @@ | ||
| 181 | 181 | * Gather Today for one account (a person, or an agent asking over the API or MCP). |
| 182 | 182 | * `repoIds` limits it to the repositories a token is restricted to. |
| 183 | 183 | */ |
| 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> { | |
| 185 | 190 | const now = opts.now ?? new Date(); |
| 186 | 191 | // The morning digest passes its own start: the last digest, which can be days ago after a weekend. |
| 187 | 192 | const since = opts.since ?? new Date(now.getTime() - SINCE_HOURS * 3600_000).toISOString(); |
| 188 | 193 | 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. | |
| 191 | 197 | 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); | |
| 199 | 212 | const allowed = opts.repoIds ? visible.filter((r) => opts.repoIds!.includes(r.id)) : visible; |
| 200 | 213 | 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); | |
| 203 | 214 | const agentIds = new Set(agents.map((a) => a.id)); |
| 204 | 215 | const ownAgentIds = new Set(agents.filter((a) => a.owner_id === me.id).map((a) => a.id)); |
| 205 | 216 | if (me.kind === "agent") agentIds.add(me.id); |
modifiedapps/diffinery/src/auth/principal.ts+39−9View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { Context } from "hono"; |
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | 3 | 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"; | |
| 5 | 5 | import { Auth, SESSION_COOKIE, cookieFrom } from "./sessions.js"; |
| 6 | 6 | import { HttpError, fromB64 } from "../lib/util.js"; |
| 7 | 7 | import type { Caller, Role } from "../repo/types.js"; |
| @@ -9,9 +9,22 @@ | ||
| 9 | 9 | import { taskPersonAllowed } from "../agentic/access.js"; |
| 10 | 10 | import { blockSubjects, isBlocked } from "../lib/moderation.js"; |
| 11 | 11 | import { agentMayMerge } from "../lib/agent-setup.js"; |
| 12 | +import type { WatchLevel } from "../mail/prefs.js"; | |
| 12 | 13 | |
| 13 | 14 | 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 | + } | |
| 15 | 28 | | { type: "repo-token"; repoId: string; token: string } |
| 16 | 29 | | { type: "anonymous" }; |
| 17 | 30 | |
| @@ -22,6 +35,8 @@ | ||
| 22 | 35 | readOnly?: boolean; |
| 23 | 36 | /** Suspended people whose content this request hides, once looked up (http/common.ts hiddenAuthors). */ |
| 24 | 37 | 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> }; | |
| 25 | 40 | } |
| 26 | 41 | |
| 27 | 42 | export type AppContext = Context<{ Bindings: Env; Variables: AppVars }>; |
| @@ -74,9 +89,9 @@ | ||
| 74 | 89 | const path = new URL(req.url).pathname; |
| 75 | 90 | const agent = path.startsWith(`/api/v3/diffinery/imports/${v.importId}/agent/`); |
| 76 | 91 | 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 }; | |
| 78 | 93 | } |
| 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 }; | |
| 80 | 95 | } |
| 81 | 96 | // Job and sandbox tokens, and self-hosted runners' own tokens (which only claim jobs: runnerAuth refuses them). |
| 82 | 97 | if (cred.startsWith("djt_") || cred.startsWith("dst_") || cred.startsWith("drn_")) { |
| @@ -182,6 +197,19 @@ | ||
| 182 | 197 | throw new SignInAgain(next); |
| 183 | 198 | } |
| 184 | 199 | |
| 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 | + | |
| 185 | 213 | /** Validates scopes sent by a client: known names only, default write. */ |
| 186 | 214 | export function parseScopes(input: unknown): TokenScope[] { |
| 187 | 215 | if (input === undefined || input === null) return ["write"]; |
| @@ -195,7 +223,7 @@ | ||
| 195 | 223 | * Resolve the caller's effective role on a repository, combining account |
| 196 | 224 | * permissions with token scopes and repo restrictions. Returns null for no access. |
| 197 | 225 | */ |
| 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> { | |
| 199 | 227 | const p = c.get("principal"); |
| 200 | 228 | const dir = c.get("dir"); |
| 201 | 229 | // A suspended person's repositories are hidden from everyone but site administrators, and their |
| @@ -235,13 +263,15 @@ | ||
| 235 | 263 | return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null; |
| 236 | 264 | } |
| 237 | 265 | 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; | |
| 239 | 271 | if (!role) return null; |
| 240 | 272 | const cap = p.scopes.reduce<Role>((best, s) => (ROLE_LEVEL[SCOPE_CAP[s]] > ROLE_LEVEL[best] ? SCOPE_CAP[s] : best), "read"); |
| 241 | 273 | 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; | |
| 245 | 275 | // Someone the repository's owner blocked (or their agent) can still read, but not take part (lib/moderation.ts). |
| 246 | 276 | // Checked for changes only: reads stay as fast as they were. |
| 247 | 277 | const writing = c.req.method !== "GET" && c.req.method !== "HEAD"; |
modifiedapps/diffinery/src/auth/sessions.ts+7−3View file
| @@ -81,10 +81,14 @@ | ||
| 81 | 81 | /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days, up to 90 days after the sign-in. */ |
| 82 | 82 | async session(token: string): Promise<{ session: SessionRow; account: Account } | null> { |
| 83 | 83 | 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 | + ]); | |
| 85 | 90 | 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; | |
| 88 | 92 | if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) { |
| 89 | 93 | const until = days(SESSION_DAYS); |
| 90 | 94 | const last = lastDay(row.created_at); |
modifiedapps/diffinery/src/http/common.ts+27−4View file
| @@ -6,9 +6,10 @@ | ||
| 6 | 6 | import { errorRecord, recordServerError, requestId } from "../lib/server-errors.js"; |
| 7 | 7 | import { movedPath, movedStatus } from "../lib/redirects.js"; |
| 8 | 8 | 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"; | |
| 10 | 10 | import { atLeast, type Caller, type Role } from "../repo/types.js"; |
| 11 | 11 | import { suspendedIds } from "../lib/moderation.js"; |
| 12 | +import { MailStore, type WatchLevel } from "../mail/prefs.js"; | |
| 12 | 13 | |
| 13 | 14 | export const repoStub = (env: Env, repoId: string) => env.REPO.get(env.REPO.idFromName(repoId)); |
| 14 | 15 | |
| @@ -53,6 +54,9 @@ | ||
| 53 | 54 | * `follow` (Git, MCP) serves the repository under the old name instead, and |
| 54 | 55 | * `moved` says it was reached that way. |
| 55 | 56 | */ |
| 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 | + | |
| 56 | 60 | 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 }> { |
| 57 | 61 | const dir = c.get("dir"); |
| 58 | 62 | const anonymous = c.get("principal").type === "anonymous"; |
| @@ -62,11 +66,15 @@ | ||
| 62 | 66 | // API, as on GitHub, and a 401 everywhere else, so git asks for credentials and the web offers sign-in. |
| 63 | 67 | if (anonymous && !reading) throw new HttpError(401, "authentication required"); |
| 64 | 68 | 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; | |
| 66 | 74 | const moved = !repo; |
| 67 | 75 | repo ??= await dir.movedRepo(owner, name); |
| 68 | 76 | if (!repo) throw hidden(); |
| 69 | - const caller = await callerFor(c, repo); | |
| 77 | + const caller = await callerFor(c, repo, grants); | |
| 70 | 78 | if (!caller) throw hidden(); |
| 71 | 79 | // A job or sandbox token reading another public repository acts as nobody in particular: it may read, never write. |
| 72 | 80 | if (caller.id === "anonymous" && !reading) throw new HttpError(403, "this token can only write to its own repository"); |
| @@ -87,14 +95,29 @@ | ||
| 87 | 95 | } |
| 88 | 96 | if (repo.archived && !reading && !opts.whileArchived) throw new HttpError(403, "repository is archived (read-only)"); |
| 89 | 97 | 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 | + } | |
| 90 | 103 | if (!reading && !opts.whileImporting) { |
| 91 | 104 | const holder = await stub.importLockHolder(); |
| 92 | - const p = c.get("principal"); | |
| 93 | 105 | 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"); |
| 94 | 106 | } |
| 95 | 107 | return { repo, caller, stub, moved }; |
| 96 | 108 | } |
| 97 | 109 | |
| 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 | + | |
| 98 | 121 | /** |
| 99 | 122 | * Suspended people (lib/moderation.ts): the issues, pull requests, reviews and comments they wrote are |
| 100 | 123 | * 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 @@ | ||
| 31 | 31 | /** Accounts for a set of logins (assignees, reviewers), so user objects carry real ids and types. */ |
| 32 | 32 | export async function peopleFor(c: AppContext, logins: Iterable<string>): Promise<Map<string, Who>> { |
| 33 | 33 | 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); | |
| 38 | 44 | if (a) out.set(login, { id: a.id, login: a.login, kind: a.kind }); |
| 39 | 45 | } |
| 40 | 46 | return out; |
| @@ -42,18 +48,21 @@ | ||
| 42 | 48 | |
| 43 | 49 | /** Everything ghIssue/ghPull need for `issues`, plus reaction counts for extra `subjects` (comments). */ |
| 44 | 50 | 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 | - ); | |
| 49 | 51 | 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 | + ]); | |
| 50 | 59 | return { |
| 51 | 60 | base: baseOf(c), |
| 52 | 61 | owner: repo.owner_login, |
| 53 | 62 | repo: repo.name, |
| 54 | 63 | repoId: repo.id, |
| 55 | 64 | labels: new Map(extras.labels.map((l) => [l.name, l])), |
| 56 | - people: await peopleFor(c, logins), | |
| 65 | + people, | |
| 57 | 66 | reactions: extras.reactions, |
| 58 | 67 | lockReasons: extras.lockReasons, |
| 59 | 68 | }; |
| @@ -65,8 +74,8 @@ | ||
| 65 | 74 | } |
| 66 | 75 | |
| 67 | 76 | 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])); | |
| 70 | 79 | const repoJson = ghRepo(x.base, repo); |
| 71 | 80 | return issues.map((i) => ghPull(x, i, repoJson, ownerOf(repo), heads.get(i.pull!.base) ?? null)); |
| 72 | 81 | } |
| @@ -113,17 +122,21 @@ | ||
| 113 | 122 | export async function pullDetail(c: AppContext, repo: Repo, stub: Stub, number: number, caller: Caller) { |
| 114 | 123 | const { issue, comments } = await stub.issue(number, await hiddenAuthors(c)); |
| 115 | 124 | 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 })]); | |
| 119 | 125 | 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 | + ]); | |
| 122 | 135 | return { |
| 123 | 136 | ...pull!, |
| 124 | 137 | mergeable: open ? m.conflicts.length === 0 : null, |
| 125 | 138 | 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, | |
| 127 | 140 | review_comments: comments.filter((x) => x.reviewId).length, |
| 128 | 141 | commits, |
| 129 | 142 | additions: files?.additions ?? 0, |
modifiedapps/diffinery/src/lib/directory.ts+59−15View file
| @@ -83,6 +83,9 @@ | ||
| 83 | 83 | /** A person's own pending invitations (binds: now, account id, account email). */ |
| 84 | 84 | 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 = ?))"; |
| 85 | 85 | |
| 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 | + | |
| 86 | 89 | export class Directory { |
| 87 | 90 | constructor(private env: Env) {} |
| 88 | 91 | |
| @@ -501,14 +504,20 @@ | ||
| 501 | 504 | await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ?").bind(now(), id, accountId).run(); |
| 502 | 505 | } |
| 503 | 506 | |
| 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 | + ]); | |
| 506 | 515 | 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; | |
| 509 | 517 | // 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; | |
| 510 | 519 | 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); | |
| 512 | 521 | if (!owner || owner.suspended_at || owner.status !== "active") return null; |
| 513 | 522 | // A connected app acting as an organization's agent (auth/oauth.ts) stops the moment the person who |
| 514 | 523 | // approved it is no longer an owner or admin of that organization, not only at its next refresh. |
| @@ -521,7 +530,7 @@ | ||
| 521 | 530 | if (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > 3600_000) { |
| 522 | 531 | await this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").bind(now(), row.id!).run(); |
| 523 | 532 | } |
| 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 }; | |
| 525 | 534 | } |
| 526 | 535 | |
| 527 | 536 | /** Revoke a token by id whoever owns it (server-managed tokens such as an import's push token). */ |
| @@ -1127,18 +1136,57 @@ | ||
| 1127 | 1136 | } |
| 1128 | 1137 | |
| 1129 | 1138 | /** 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> { | |
| 1131 | 1175 | if (account.status !== "active") return null; |
| 1132 | 1176 | if (account.kind === "agent") { |
| 1133 | 1177 | if (!account.owner_id) return null; |
| 1134 | 1178 | // An agent from Agent setup reaches its one repository and nothing else, whatever token it uses. |
| 1135 | 1179 | const only = agentRepoId(account); |
| 1136 | 1180 | 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 | + ]); | |
| 1138 | 1188 | 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); | |
| 1140 | 1189 | 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 }>(); | |
| 1142 | 1190 | const policy = account.agent_policy ? (JSON.parse(account.agent_policy) as { maxRole?: Role }) : {}; |
| 1143 | 1191 | // A grant on the repository can lower an agent's role, never lift it past the maximum its owner set: |
| 1144 | 1192 | // any of the repository's admins can grant, and only the owner decides how far the agent goes. |
| @@ -1153,19 +1201,15 @@ | ||
| 1153 | 1201 | if (r && (!best || ROLE_LEVEL[r] > ROLE_LEVEL[best])) best = r; |
| 1154 | 1202 | }; |
| 1155 | 1203 | if (repo.owner_id === account.id) bump("admin"); |
| 1204 | + const { m, explicit } = await this.grantsOf(account, repo, grants); | |
| 1156 | 1205 | if (repo.owner_kind === "org") { |
| 1157 | 1206 | // 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 }>(); | |
| 1162 | 1207 | if (m?.role === "owner" || m?.role === "admin") bump("admin"); |
| 1163 | 1208 | else if (m?.role === "member") { |
| 1164 | 1209 | const base = baseRole(m); |
| 1165 | 1210 | if (base !== "none") bump(base); |
| 1166 | 1211 | } |
| 1167 | 1212 | } |
| 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 }>(); | |
| 1169 | 1213 | bump(explicit?.role ?? null); |
| 1170 | 1214 | if (repo.visibility !== "private") bump("read"); |
| 1171 | 1215 | return best; |
modifiedapps/diffinery/src/lib/timing.ts+25−3View file
| @@ -10,7 +10,16 @@ | ||
| 10 | 10 | export class Timing { |
| 11 | 11 | private readonly start = Date.now(); |
| 12 | 12 | 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; | |
| 13 | 15 | |
| 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 | + | |
| 14 | 23 | /** Times `p` under `kind` and hands it back unchanged. */ |
| 15 | 24 | track<T>(kind: string, p: T): T { |
| 16 | 25 | if (!(p && typeof (p as { then?: unknown }).then === "function")) return p; |
| @@ -27,7 +36,10 @@ | ||
| 27 | 36 | |
| 28 | 37 | header(colo?: string): string { |
| 29 | 38 | 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 | + } | |
| 31 | 43 | if (colo && /^[A-Z]{3}$/.test(colo)) out.push(`colo;desc="${colo}"`); |
| 32 | 44 | return out.join(", "); |
| 33 | 45 | } |
| @@ -41,7 +53,12 @@ | ||
| 41 | 53 | try { |
| 42 | 54 | for (const m of STATEMENT_RUNS) { |
| 43 | 55 | 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 | + }; | |
| 45 | 62 | } |
| 46 | 63 | const bind = st.bind; |
| 47 | 64 | if (typeof bind === "function") st.bind = (...a: unknown[]) => timedStatement(t, bind.apply(s, a) as D1PreparedStatement); |
| @@ -57,7 +74,12 @@ | ||
| 57 | 74 | const v = Reflect.get(target, prop, target) as unknown; |
| 58 | 75 | if (typeof v !== "function") return v; |
| 59 | 76 | 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 | + }; | |
| 61 | 83 | return (v as (...a: unknown[]) => unknown).bind(target); |
| 62 | 84 | }, |
| 63 | 85 | }); |
modifiedapps/diffinery/src/repo/actions.ts+3−2View file
| @@ -138,9 +138,10 @@ | ||
| 138 | 138 | /** |
| 139 | 139 | * A container start that failed on the platform's side and works when tried again: no machine free, |
| 140 | 140 | * 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). | |
| 142 | 143 | */ |
| 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; | |
| 144 | 145 | /** Hosted containers one matrix starts at once: each start takes a couple of seconds. */ |
| 145 | 146 | const START_AT_ONCE = 8; |
| 146 | 147 | /** 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 @@ | ||
| 238 | 238 | ); |
| 239 | 239 | } |
| 240 | 240 | 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); | |
| 241 | 244 | const [repos, orgs, notes, today] = await Promise.all([ |
| 242 | - dir.reposVisibleTo(me), | |
| 243 | - dir.orgsFor(me.id), | |
| 245 | + visible, | |
| 246 | + memberships, | |
| 244 | 247 | dir.notifications(me.id, true), |
| 245 | 248 | // 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) => { | |
| 247 | 250 | console.error("today failed", e); |
| 248 | 251 | return null; |
| 249 | 252 | }), |
modifiedapps/diffinery/src/web/actions.tsx+11−6View file
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | import { ICONS } from "./assets.js"; |
| 12 | 12 | import { KCheckbox, KSelect } from "./kumo.js"; |
| 13 | 13 | import { csrfToken } from "./csrf.js"; |
| 14 | -import { ActionsBlockedBanner } from "./kill-switches.js"; | |
| 14 | +import { ActionsBlockedBanner, actionsSwitches } from "./kill-switches.js"; | |
| 15 | 15 | import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js"; |
| 16 | 16 | |
| 17 | 17 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| @@ -185,6 +185,7 @@ | ||
| 185 | 185 | const status = c.req.query("status") ?? ""; |
| 186 | 186 | const page = pageNum(c); |
| 187 | 187 | const per = 25; |
| 188 | + const switches = actionsSwitches(c.env, repo.id); | |
| 188 | 189 | const [runs, workflows, names] = await Promise.all([ |
| 189 | 190 | stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }), |
| 190 | 191 | stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>), |
| @@ -224,7 +225,7 @@ | ||
| 224 | 225 | </section> |
| 225 | 226 | </aside> |
| 226 | 227 | <div> |
| 227 | - <ActionsBlockedBanner env={c.env} repoId={repo.id} /> | |
| 228 | + <ActionsBlockedBanner env={c.env} repoId={repo.id} switches={switches} /> | |
| 228 | 229 | <div class="row between mb"> |
| 229 | 230 | <h1 class="m0">{selected ? selected.name : "All workflows"}</h1> |
| 230 | 231 | {selected ? <span class="mono muted small">{selected.path}</span> : null} |
| @@ -352,10 +353,14 @@ | ||
| 352 | 353 | const { repo, caller, stub } = l; |
| 353 | 354 | const base = repoUrl(repo); |
| 354 | 355 | 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 | + ]); | |
| 357 | 363 | const principal = c.get("principal"); |
| 358 | - const viewer = await viewerFor(c, repo); | |
| 359 | 364 | const [pending, reviews] = await Promise.all([run.status !== "completed" ? stub.pendingDeployments(run.id, viewer) : Promise.resolve([] as PendingDeployment[]), stub.deploymentReviews(run.id)]); |
| 360 | 365 | const groups = new Map<string, JobInfo[]>(); |
| 361 | 366 | for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]); |
| @@ -366,7 +371,7 @@ | ||
| 366 | 371 | c, |
| 367 | 372 | { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined }, |
| 368 | 373 | <div data-fragment="run"> |
| 369 | - <ActionsBlockedBanner env={c.env} repoId={repo.id} /> | |
| 374 | + <ActionsBlockedBanner env={c.env} repoId={repo.id} switches={switches} /> | |
| 370 | 375 | <div class="row between mb" style="align-items:flex-start"> |
| 371 | 376 | <div class="grow"> |
| 372 | 377 | <div class="muted small"> |
modifiedapps/diffinery/src/web/collab.tsx+50−20View file
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | import { raw } from "hono/html"; |
| 4 | 4 | import type { Child, FC } from "hono/jsx"; |
| 5 | 5 | import type { AppContext } from "../auth/principal.js"; |
| 6 | -import { hiddenAuthors, loadRepo } from "../http/common.js"; | |
| 6 | +import { hiddenAuthors, loadRepo, watchLevelOnce } from "../http/common.js"; | |
| 7 | 7 | import { HttpError } from "../lib/util.js"; |
| 8 | 8 | import type { Check, Comment, Issue, Mergeability, Review } from "../repo/collab.js"; |
| 9 | 9 | import { atLeast, type Role } from "../repo/types.js"; |
| @@ -114,6 +114,8 @@ | ||
| 114 | 114 | // ---------------------------------------------------------------- lists |
| 115 | 115 | |
| 116 | 116 | 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(() => {}); | |
| 117 | 119 | const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 118 | 120 | const base = repoUrl(repo); |
| 119 | 121 | const state = c.req.query("state") === "closed" ? "closed" : "open"; |
| @@ -1069,21 +1071,48 @@ | ||
| 1069 | 1071 | |
| 1070 | 1072 | // ---------------------------------------------------------------- pages |
| 1071 | 1073 | |
| 1072 | -async function loadConv(c: AppContext): Promise<ConvCtx> { | |
| 1074 | +/** | |
| 1075 | + * A conversation and what its page shows around it. `alongside` reads what a page needs once the conversation is known | |
| 1076 | + * (a pull request's checks, say), at the same time as its authors' owners. | |
| 1077 | + */ | |
| 1078 | +async function loadConv<T = null>(c: AppContext, alongside?: (l: Loaded, issue: Issue) => Promise<T>): Promise<ConvCtx & { extra: T }> { | |
| 1079 | + // Suspended authors are left out of the conversation (http/common.ts): start that lookup before the repository's. | |
| 1080 | + hiddenAuthors(c).catch(() => {}); | |
| 1073 | 1081 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 1074 | 1082 | const n = issueNumber(c); |
| 1075 | - const [data, labels, milestones] = await Promise.all([l.stub.issue(n, await hiddenAuthors(c)), l.stub.labels(), l.stub.milestones("open")]); | |
| 1076 | - const behalf = await onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]); | |
| 1077 | 1083 | const me = principalAccount(c); |
| 1078 | - let sub: ConvCtx["sub"]; | |
| 1079 | - if (me?.kind === "user") { | |
| 1080 | - const store = new MailStore(c.env.DB); | |
| 1081 | - const [explicit, watch] = await Promise.all([store.threadSub(me.id, l.repo.id, n), store.watchLevel(me.id, l.repo.id)]); | |
| 1082 | - sub = { explicit, watch }; | |
| 1083 | - } | |
| 1084 | - return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf, sub, milestones }; | |
| 1084 | + // Everything the page needs that doesn't depend on the conversation itself, side by side with it. | |
| 1085 | + const [data, labels, milestones, sub] = await Promise.all([ | |
| 1086 | + hiddenAuthors(c).then((hidden) => l.stub.issue(n, hidden)), | |
| 1087 | + l.stub.labels(), | |
| 1088 | + l.stub.milestones("open"), | |
| 1089 | + 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, | |
| 1090 | + ]); | |
| 1091 | + const [behalf, extra] = await Promise.all([ | |
| 1092 | + onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]), | |
| 1093 | + alongside ? alongside(l, data.issue) : (null as T), | |
| 1094 | + ]); | |
| 1095 | + return { c, l, issue: data.issue, comments: data.comments, reviews: data.reviews, labels, behalf, sub, milestones, extra }; | |
| 1085 | 1096 | } |
| 1086 | 1097 | |
| 1098 | +type PullTab = "conversation" | "commits" | "files" | "checks"; | |
| 1099 | + | |
| 1100 | +/** What a pull request's tab shows besides the conversation, read side by side. */ | |
| 1101 | +async function pullTabData(c: AppContext, l: Loaded, i: Issue, tab: PullTab) { | |
| 1102 | + const { stub, caller } = l; | |
| 1103 | + const n = i.number; | |
| 1104 | + const headSha = i.pull?.headSha ?? ""; | |
| 1105 | + const [agentReview, m, checks, commits, runs, diff] = await Promise.all([ | |
| 1106 | + tab === "conversation" ? pullReviewCtx(c, l) : null, | |
| 1107 | + tab === "conversation" && i.state === "open" ? stub.mergeability(n, caller).catch(() => null) : null, | |
| 1108 | + tab === "conversation" || tab === "checks" ? stub.checksAndStatuses(headSha).catch(() => [] as Check[]) : ([] as Check[]), | |
| 1109 | + tab === "commits" ? stub.pullCommits(n).catch(() => []) : [], | |
| 1110 | + tab === "checks" ? stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 })) : { runs: [], total: 0 }, | |
| 1111 | + tab === "files" ? stub.pullFiles(n) : null, | |
| 1112 | + ]); | |
| 1113 | + return { agentReview, m, checks, commits, runs, diff }; | |
| 1114 | +} | |
| 1115 | + | |
| 1087 | 1116 | async function issuePage(c: AppContext): Promise<Response> { |
| 1088 | 1117 | const x = await loadConv(c); |
| 1089 | 1118 | const base = repoUrl(x.l.repo); |
| @@ -1107,13 +1136,14 @@ | ||
| 1107 | 1136 | ); |
| 1108 | 1137 | } |
| 1109 | 1138 | |
| 1110 | -async function pullPage(c: AppContext, tab: "conversation" | "commits" | "files" | "checks"): Promise<Response> { | |
| 1111 | - const x = await loadConv(c); | |
| 1112 | - const { repo, caller, stub } = x.l; | |
| 1139 | +async function pullPage(c: AppContext, tab: PullTab): Promise<Response> { | |
| 1140 | + const x = await loadConv(c, (l, i) => (i.kind === "pull" && i.pull ? pullTabData(c, l, i, tab) : Promise.resolve(null))); | |
| 1141 | + const { repo, caller } = x.l; | |
| 1113 | 1142 | const base = repoUrl(repo); |
| 1114 | 1143 | const i = x.issue; |
| 1115 | - if (i.kind !== "pull" || !i.pull) return c.redirect(`${base}/issues/${i.number}`, 302); | |
| 1116 | - if (tab === "conversation") x.agentReview = await pullReviewCtx(c, x.l); | |
| 1144 | + if (i.kind !== "pull" || !i.pull || !x.extra) return c.redirect(`${base}/issues/${i.number}`, 302); | |
| 1145 | + const d = x.extra; | |
| 1146 | + if (tab === "conversation") x.agentReview = d.agentReview; | |
| 1117 | 1147 | const n = i.number; |
| 1118 | 1148 | const page = (body: Child, wide = false) => |
| 1119 | 1149 | render( |
| @@ -1127,7 +1157,7 @@ | ||
| 1127 | 1157 | ); |
| 1128 | 1158 | |
| 1129 | 1159 | if (tab === "conversation") { |
| 1130 | - 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[])]); | |
| 1160 | + const { m, checks } = d; | |
| 1131 | 1161 | return page( |
| 1132 | 1162 | <div class="grid"> |
| 1133 | 1163 | <div> |
| @@ -1156,7 +1186,7 @@ | ||
| 1156 | 1186 | } |
| 1157 | 1187 | |
| 1158 | 1188 | if (tab === "commits") { |
| 1159 | - const commits = await stub.pullCommits(n).catch(() => []); | |
| 1189 | + const { commits } = d; | |
| 1160 | 1190 | return page( |
| 1161 | 1191 | <div class="box"> |
| 1162 | 1192 | {commits.length ? ( |
| @@ -1173,7 +1203,7 @@ | ||
| 1173 | 1203 | } |
| 1174 | 1204 | |
| 1175 | 1205 | if (tab === "checks") { |
| 1176 | - const [checks, runs] = await Promise.all([stub.checksAndStatuses(i.pull.headSha).catch(() => [] as Check[]), stub.runs({ prNumber: n, limit: 30 }).catch(() => ({ runs: [], total: 0 }))]); | |
| 1206 | + const { checks, runs } = d; | |
| 1177 | 1207 | return page( |
| 1178 | 1208 | <div data-fragment="checks"> |
| 1179 | 1209 | <div class="box"> |
| @@ -1229,7 +1259,7 @@ | ||
| 1229 | 1259 | } |
| 1230 | 1260 | |
| 1231 | 1261 | // Files changed |
| 1232 | - const diff = await stub.pullFiles(n); | |
| 1262 | + const diff = d.diff!; | |
| 1233 | 1263 | const threads = new Map<string, LineThread[]>(); |
| 1234 | 1264 | const byKey = new Map<string, Comment[]>(); |
| 1235 | 1265 | for (const cm of x.comments) { |
modifiedapps/diffinery/src/web/kill-switches.tsx+7−3View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** @jsxImportSource hono/jsx */ |
| 2 | 2 | import type { FC } from "hono/jsx"; |
| 3 | 3 | 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"; | |
| 5 | 5 | import { Form, Time } from "./ui.js"; |
| 6 | 6 | |
| 7 | 7 | /** |
| @@ -28,9 +28,13 @@ | ||
| 28 | 28 | ); |
| 29 | 29 | }; |
| 30 | 30 | |
| 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 | + | |
| 31 | 34 | /** 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)); | |
| 34 | 38 | if (!k) return <></>; |
| 35 | 39 | const s = k.repo ?? k.account; |
| 36 | 40 | if (s) |
modifiedapps/diffinery/src/web/ui.tsx+26−18View file
| @@ -9,13 +9,13 @@ | ||
| 9 | 9 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 10 | 10 | import { HttpError, randomToken } from "../lib/util.js"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { CSS, ICONS, JS, type IconName } from "./assets.js"; |
| 14 | 14 | import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js"; |
| 15 | 15 | import { fenceLangs, renderMarkdown, type MarkdownBase } from "./markdown.js"; |
| 16 | 16 | import { diffLineTokens, fenceHighlighter, langForPath, LIMITS, ready, tokenize, type Lang, type TokLine } from "./highlight.js"; |
| 17 | 17 | import { KSelect, kumoAssets } from "./kumo.js"; |
| 18 | -import { MailStore, type WatchLevel } from "../mail/prefs.js"; | |
| 18 | +import type { WatchLevel } from "../mail/prefs.js"; | |
| 19 | 19 | import { MoreMenu, ReportItem } from "./moderation.js"; |
| 20 | 20 | import { SeoHead, SITE } from "./seo.js"; |
| 21 | 21 | import { avatarPath } from "../http/avatars.js"; |
| @@ -413,8 +413,7 @@ | ||
| 413 | 413 | const p = c.get("principal"); |
| 414 | 414 | const me = p?.type === "account" ? p.account : null; |
| 415 | 415 | 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) | |
| 418 | 417 | .catch(() => "participating" as const) |
| 419 | 418 | .then((w) => renderPage(c, opts, body, w)); |
| 420 | 419 | } |
| @@ -461,7 +460,15 @@ | ||
| 461 | 460 | </div> |
| 462 | 461 | ); |
| 463 | 462 | |
| 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); | |
| 465 | 472 | csrfToken(c); |
| 466 | 473 | const nonce = randomToken(16); |
| 467 | 474 | const fl = takeFlash(c); |
| @@ -494,7 +501,7 @@ | ||
| 494 | 501 | nonce, |
| 495 | 502 | })} |
| 496 | 503 | <style>{raw(CSS)}</style> |
| 497 | - <link rel="stylesheet" href={kumoAssets().css} /> | |
| 504 | + {kumo ? <link rel="stylesheet" href={kumoAssets().css} /> : null} | |
| 498 | 505 | {opts.head ?? null} |
| 499 | 506 | {opts.turnstile ? <script nonce={nonce} src={TURNSTILE_SCRIPT} async defer></script> : null} |
| 500 | 507 | </head> |
| @@ -563,7 +570,7 @@ | ||
| 563 | 570 | {r ? <RepoHeader r={r} watch={watch} t={csrfToken(c)} /> : null} |
| 564 | 571 | <main class="wrap" style={opts.wide ? "max-width:none" : undefined}> |
| 565 | 572 | {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null} |
| 566 | - {body} | |
| 573 | + {raw(content)} | |
| 567 | 574 | </main> |
| 568 | 575 | <footer class="site-foot"> |
| 569 | 576 | {active ? null : <SiteFooter />} |
| @@ -599,7 +606,7 @@ | ||
| 599 | 606 | </div> |
| 600 | 607 | </dialog> |
| 601 | 608 | ) : null} |
| 602 | - <script nonce={nonce} src={kumoAssets().js} defer></script> | |
| 609 | + {kumo ? <script nonce={nonce} src={kumoAssets().js} defer></script> : null} | |
| 603 | 610 | <script nonce={nonce}>{raw(JS)}</script> |
| 604 | 611 | {opts.script ? <script nonce={nonce}>{raw(opts.script)}</script> : null} |
| 605 | 612 | {(opts.scriptSrc ?? []).map((src) => ( |
| @@ -981,17 +988,18 @@ | ||
| 981 | 988 | /** Look up agents' owners for "on behalf of" labels. */ |
| 982 | 989 | export async function onBehalfOf(c: AppContext, actors: Array<{ login: string; kind: string }>): Promise<Map<string, string>> { |
| 983 | 990 | const out = new Map<string, string>(); |
| 984 | - const dir = c.get("dir"); | |
| 985 | 991 | 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 | + } | |
| 995 | 1003 | return out; |
| 996 | 1004 | } |
| 997 | 1005 |
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+5−1View file
| @@ -813,6 +813,10 @@ | ||
| 813 | 813 | startError = "Container sidecar is shutting down"; |
| 814 | 814 | await actions.relaunchJob("j1"); |
| 815 | 815 | 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 }); | |
| 816 | 820 | sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status) VALUES ('j3', 'run1', 'k3', 'k3', 'queued')"); |
| 817 | 821 | startError = "image not found"; |
| 818 | 822 | await launch("j3"); |
| @@ -821,7 +825,7 @@ | ||
| 821 | 825 | |
| 822 | 826 | // Past the limit it fails, and says why. |
| 823 | 827 | 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"); | |
| 825 | 829 | expect(job("j1")).toMatchObject({ status: "queued", placement_tries: MAX_PLACEMENT_TRIES }); |
| 826 | 830 | await actions.relaunchJob("j1"); |
| 827 | 831 | 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 @@ | ||
| 11 | 11 | return { sql: this.sql, params: this.params }; |
| 12 | 12 | } |
| 13 | 13 | async all() { |
| 14 | - return { results: [] }; | |
| 14 | + return { results: [], meta: { served_by_region: "WEUR", served_by_primary: true } }; | |
| 15 | 15 | } |
| 16 | 16 | } |
| 17 | 17 | |
| @@ -44,8 +44,8 @@ | ||
| 44 | 44 | await Promise.resolve(); |
| 45 | 45 | const h = t.header("IST"); |
| 46 | 46 | 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"/); | |
| 49 | 49 | expect(h).toContain('colo;desc="IST"'); |
| 50 | 50 | expect(t.header("not a colo")).not.toContain("colo"); |
| 51 | 51 | }); |