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.

Kill switches: suspend Actions for a repository or an account, pause hosted compute (#131)

* Kill switches: suspend Actions for a repository or an account, pause hosted compute (WIP)

Site administrators can suspend Actions for one repository or every
repository an account owns, and pause hosted compute on the instance.
State in D1 (migration 0020), checked where jobs launch, runs are created
and sandboxes start; turning a switch on stops what is running.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* Kill switches: tests and docs

A unit test of lib/killswitch.ts over the node:sqlite D1 shim, an e2e test
in security.test.mjs (repository and account suspension, the hosted compute
pause, 403 for anyone but site administrators, audit entries), the
Kill switches section of Site administration, the audit actions and the
REST reference entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* Kill switches: audit even when stopping work fails; guard a re-run that didn't start

The suspend and pause audit entries are written in a finally block, a
sleeping sandbox's kill switch check fails open like the job checks, and a
re-run whose run wasn't created answers 409 instead of throwing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* Kill switches e2e: claim as a registered runner

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* Kill switches: web terminals end, can't be attached and can't be opened while sandboxes are stopped

Migration renumbered to 0021 (0019 is web terminals, 0020 sign-up).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 0f99813commit 92fbc27b73cf975fde0c13a735cea9d11e761f71Browse files
20 files changed+1092−15
Files (20)
modifiedapps/diffinery/docs/admin/site-administration.md+74−2View file
@@ -1,18 +1,19 @@
11 ---
22 title: Site administration
3-description: What site administrators do: manage people and access, invite people, issue sign-in links, oversee GitHub imports, read the audit log and check email.
3+description: What site administrators do: manage people and access, invite people, issue sign-in links, stop Actions with kill switches, oversee imports, read the audit log and check email.
44 ---
55
66 Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**.
77
88 ## Site administrators
99
10-Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar with three pages:
10+Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar with four pages:
1111
1212 | Page | Path | What it is for |
1313 | --- | --- | --- |
1414 | **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links |
1515 | **Email** | `/settings/admin/email` | Email delivery status, template previews and the log of every email |
16+| **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance |
1617 | **Audit log** | `/settings/audit` | The most recent security-relevant events |
1718
1819 Their **Settings → Profile** page shows **Site admin** as yes, and they have an **admin** badge in the users list.
@@ -106,6 +107,74 @@
106107
107108 Anyone with the link can add a passkey to the account, so send it only to that person. The link also works for a first sign-in, for an account that exists but has no passkey yet.
108109
110+## Kill switches
111+
112+Kill switches stop Actions and agent sandboxes quickly, for abuse, a runaway workflow or a cost problem. There are three, from the narrowest:
113+
114+| Switch | What stops | What keeps working |
115+| --- | --- | --- |
116+| **Suspend Actions for a repository** | Every workflow run of the repository, on hosted and self-hosted runners, and its agent sandboxes (including agent tasks' and reviews') | Everything else in the repository: Git, issues, pull requests |
117+| **Suspend Actions for an account** | The same, for every repository a person or an organization owns, including repositories created or transferred in later | The account's other repositories' Git and collaboration |
118+| **Pause hosted compute** | Hosted Actions jobs and agent sandboxes in every repository on the instance | Self-hosted runners, which cost the instance nothing, and workflow runs themselves |
119+
120+Nobody is exempt, not even site administrators' own accounts and organizations (unlike [account quotas](/docs/reference/limits#account-quotas)). Every change is in the audit log: `actions.suspend` and `actions.unsuspend` with the repository (`owner/name`) or the account's login as the target, `compute.pause` and `compute.unpause`, each with the reason and what was stopped.
121+
122+### While a switch is on
123+
124+While a repository or its owner is suspended:
125+
126+- no workflow run starts: pushes, pull requests, schedules and other events start nothing, and no run is recorded;
127+- **Run workflow** (`workflow_dispatch`), `repository_dispatch` and **Re-run** are refused with `403 Actions are suspended for this repository by a site administrator: <reason>` (or `... for every repository of <account> ...`);
128+- a job that would start fails instead, with "The job was not started: Actions are suspended ...", and a self-hosted runner isn't given a job that was already waiting;
129+- a new sandbox is refused with the same `403`, so agent tasks and reviews fail when they try to start theirs, with that message.
130+- [web terminals](/docs/agents/terminal) can't be opened or attached to, and open ones end within a minute ("A site administrator suspended Actions and sandboxes here").
131+
132+While hosted compute is paused:
133+
134+- a job on a hosted runner fails without starting: "The job was not started: Hosted runners are paused on this instance by a site administrator: <reason>. Self-hosted runners still work." Jobs of the same run on self-hosted runners run as usual;
135+- a new sandbox is refused with `403 Agent sandboxes are paused on this instance by a site administrator: <reason>` (agent tasks and reviews fail to start theirs), and a sandbox that went to sleep doesn't wake up.
136+- web terminals can't be opened or attached to, and open ones end within a minute.
137+
138+The repository's **Actions** page, its run pages and its **Settings** pages show a red banner with the reason, so the people affected know why. While hosted compute is paused, every **Site administration** page shows "Hosted compute is paused on this instance" at the top, so nobody forgets to turn it back on.
139+
140+### Turning a switch on stops what is running
141+
142+- Suspending a repository or an account cancels each of its unfinished runs ("Cancelled: Actions are suspended ...", including jobs on self-hosted runners) and closes each of its open sandboxes.
143+- Pausing hosted compute fails each running hosted job ("The job was stopped: Hosted runners are paused ...") and closes the open sandboxes, in every repository with hosted compute running (Diffinery finds them from the hosted minutes it is counting). Jobs that wait in the queue fail when they would start.
144+
145+The answer says what was stopped: `stopped` with `repositories`, `runs`, `jobs`, `sandboxes`, and `failed` for repositories that didn't answer (their work still can't start).
146+
147+Turning a switch off only lets new work start again. Nothing that was stopped restarts: re-run the runs you want and start new sandboxes.
148+
149+### From the web
150+
151+**Settings → Kill switches** (`/settings/admin/kill-switches`):
152+
153+- **Hosted compute** shows **running** or **paused**. Enter a **Reason** and click **Pause hosted compute** (you confirm first), or click **Resume hosted compute**.
154+- **Suspend Actions for an account**: the person's or organization's login and a **Reason**, then **Suspend**.
155+- **Suspend Actions for a repository**: `owner/name` and a **Reason**, then **Suspend**.
156+- **Suspended** lists every suspended account and repository with who suspended it, when and why, and a **Lift** button.
157+
158+A reason is required (at most 500 characters): it is shown to the people affected and kept in the audit log.
159+
160+### From the API
161+
162+Site administrators only (`403 only site administrators can ...` for anyone else). Reading needs any token that isn't limited to repositories; changing needs an `admin`-scoped token that isn't limited to repositories, or a browser session.
163+
164+| Method and path | Does |
165+| --- | --- |
166+| `GET /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | `suspended`, `reason`, `suspended_by`, `suspended_at`, and `owner_suspended` when the owner's Actions are suspended |
167+| `PUT /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | Suspend, with `{"reason": "..."}` |
168+| `DELETE /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | Lift |
169+| `GET`, `PUT`, `DELETE /api/v3/admin/accounts/{login}/actions-suspension` | The same for a person or an organization |
170+| `GET`, `PUT`, `DELETE /api/v3/admin/hosted-compute-pause` | `paused`, `reason`, `paused_by` and `paused_at`; pause with `{"reason": "..."}`, resume with `DELETE` |
171+
172+```sh
173+echo '{"reason": "crypto mining, see ticket 123"}' | diffinery api PUT /admin/repos/acme/website/actions-suspension --input -
174+diffinery api DELETE /admin/repos/acme/website/actions-suspension
175+echo '{"reason": "runner image incident"}' | diffinery api PUT /admin/hosted-compute-pause --input -
176+```
177+
109178 ## GitHub imports and linked accounts
110179
111180 Site administrators have extra reach over [imports from GitHub](/docs/migrate-from-github):
@@ -233,6 +302,9 @@
233302 | `403 site admin only` from `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | Your account isn't a site administrator. |
234303 | `404 link not found` | No GitHub link has that id. Ask the account's owner for the id from `GET /api/v3/diffinery/user/linked-accounts`. |
235304 | "you cannot change your own status" | Ask another site administrator. |
305+| `403 Actions are suspended ... by a site administrator` | A kill switch is on for the repository or its owner. Lift it under **Settings → Kill switches**. |
306+| "Hosted runners are paused on this instance" | Hosted compute is paused. Resume it under **Settings → Kill switches**, or run the job on a self-hosted runner. |
307+| `422 give a reason` | Kill switches need a reason. |
236308 | "enable this account first" | Sign-in links can't be created for disabled accounts. Set the status to `active`, then create the link. |
237309 | "enter a valid email address" | The invite form needs an address like `friend@example.com`. |
238310 | `403 only site admins can invite people without an organization` | Instance invites from the CLI or API need a site administrator; others must pass `--org`. |
modifiedapps/diffinery/docs/security/audit-log.md+5−1View file
@@ -85,7 +85,7 @@
8585 | `task.stop` | Someone stops a task (its starter, its agent or a repository admin) | `owner/repo#<task number>` | none |
8686 | `terminal.terms` | Someone accepts the notice for [terminals](/docs/agents/terminal) | `owner/repo` | `version` |
8787 | `terminal.open` | Someone opens a [terminal](/docs/agents/terminal) | `owner/repo` | `terminal`, `agent`, `ref`, `runtimes` (the Claude Code and Codex versions its buttons install) |
88-| `terminal.end` | A terminal ends | `owner/repo` | `terminal`, `reason` (`person`, `idle`, `limit`, `access`, `stopped`, `failed` or `disabled`) |
88+| `terminal.end` | A terminal ends | `owner/repo` | `terminal`, `reason` (`person`, `idle`, `limit`, `access`, `stopped`, `failed`, `disabled` or `suspended`) |
8989 | `project.memory` | Someone saves or restores the [project](/docs/agents/projects) memory | `owner/repo` | `version`, `length`, and `restored` (the version restored) for a restore |
9090 | `project.settings` | A repository admin changes **Settings → Project** | `owner/repo` | `max_running`, `instructions_length`, `plan_first` |
9191 | `project.coordinator` | Someone turns their [coordinator](/docs/agents/projects#coordinator) on, changes it or turns it off | `owner/repo` | `enabled`, and when on `agent`, `runtime`, `provider` and `model` (OpenRouter), `tokens_per_run`, `tokens_per_task` |
@@ -110,6 +110,10 @@
110110 | `invite.create` | A site admin creates an invite from **Site administration** | the email address | none |
111111 | `site.invite` | A site admin invites someone with `POST /api/v3/invitations` | none | `email` |
112112 | `account.quotas.set` | A site administrator changes an account's quotas with `PATCH /api/v3/admin/accounts/{login}/quotas` | the account's login | the quotas sent, e.g. `hosted_minutes` |
113+| `actions.suspend` | A site administrator suspends Actions for a repository or an account ([kill switches](/docs/admin/site-administration#kill-switches)), on **Settings → Kill switches** or with `PUT /api/v3/admin/repos/{owner}/{repo}/actions-suspension` or `PUT /api/v3/admin/accounts/{login}/actions-suspension` | `owner/name`, or the account's login | `kind` (`repo` or `account`), `reason`, `via` (`web` or `api`), `stopped` (runs, jobs and sandboxes stopped) |
114+| `actions.unsuspend` | A site administrator lifts that suspension (**Lift**, or `DELETE` on the same path) | `owner/name`, or the account's login | `kind`, `via` |
115+| `compute.pause` | A site administrator pauses hosted compute on the instance (**Pause hosted compute**, or `PUT /api/v3/admin/hosted-compute-pause`) | none | `reason`, `via`, `stopped` |
116+| `compute.unpause` | A site administrator resumes it (**Resume hosted compute**, or `DELETE /api/v3/admin/hosted-compute-pause`) | none | `via` |
113117
114118 ### Repositories
115119
addedapps/diffinery/migrations/0021_suspensions.sql+20−0View file
@@ -1,0 +1,20 @@
1+-- Kill switches (lib/killswitch.ts). A site administrator suspends Actions for one repository, or for
2+-- every repository an account owns: no new runs, queued jobs fail, no new agent sandboxes. NULL is not suspended.
3+ALTER TABLE repos ADD COLUMN actions_suspended_at TEXT;
4+ALTER TABLE repos ADD COLUMN actions_suspended_by TEXT;
5+ALTER TABLE repos ADD COLUMN actions_suspended_reason TEXT;
6+ALTER TABLE accounts ADD COLUMN actions_suspended_at TEXT;
7+ALTER TABLE accounts ADD COLUMN actions_suspended_by TEXT;
8+ALTER TABLE accounts ADD COLUMN actions_suspended_reason TEXT;
9+
10+-- Instance-wide switches; a row is a switch that is on. `hosted_compute_paused` stops hosted Actions
11+-- jobs and agent sandboxes everywhere (self-hosted runners keep working); its value is the reason.
12+CREATE TABLE IF NOT EXISTS site_flags (
13+ key TEXT PRIMARY KEY,
14+ value TEXT NOT NULL,
15+ updated_at TEXT NOT NULL,
16+ updated_by TEXT
17+);
18+
19+-- What is running now across the instance (pausing hosted compute stops it).
20+CREATE INDEX IF NOT EXISTS compute_usage_running ON compute_usage(ended_at, started_at);
modifiedapps/diffinery/src/agentic/ProjectDO.ts+3−0View file
@@ -1,4 +1,5 @@
11 import { DurableObject } from "cloudflare:workers";
2+import { blockedBy, killSwitches } from "../lib/killswitch.js";
23 import type { Env } from "../env.js";
34 import { Directory } from "../lib/directory.js";
45 import { HttpError, now, randomToken, sha256 } from "../lib/util.js";
@@ -1158,6 +1159,8 @@
11581159
11591160 private async tickTerminal(t: TerminalRow, terminals: Terminals, dir: Directory): Promise<void> {
11601161 if (!terminalsEnabled(this.env)) return endTerminal(this.env, dir, t, "disabled");
1162+ // A kill switch (lib/killswitch.ts) that stops sandboxes here ends the terminal too.
1163+ if (blockedBy(await killSwitches(this.env, t.repo_id), "sandbox")) return endTerminal(this.env, dir, t, "suspended");
11611164 // The same rule as tasks, checked again every time: the person drives the agent, and both may write.
11621165 const [person, agent, repo] = await Promise.all([dir.account(t.person_id), dir.account(t.agent_id), dir.repoById(t.repo_id)]);
11631166 let caller;
modifiedapps/diffinery/src/agentic/terminal-web.tsx+4−0View file
@@ -1,4 +1,5 @@
11 /** @jsxImportSource hono/jsx */
2+import { blockedBy, killSwitches } from "../lib/killswitch.js";
23 import type { FC } from "hono/jsx";
34 import { requireAccount, type AppContext } from "../auth/principal.js";
45 import { loadRepo } from "../http/common.js";
@@ -298,6 +299,9 @@
298299 const l = await forTerminals(c);
299300 const t = await new Terminals(c.env).mine(terminalId(c.req.param("id")), me, l.repo);
300301 if (t.status !== "running" || !t.sandbox_id || !terminalsEnabled(c.env)) return c.text("this terminal isn't running", 409);
302+ // A kill switch (lib/killswitch.ts) stops sandboxes here: no attaching until it's lifted.
303+ const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox");
304+ if (block) return c.text(block, 403);
301305 const dir = c.get("dir");
302306 const agent = await dir.account(t.agent_id);
303307 try {
modifiedapps/diffinery/src/agentic/terminals.ts+6−1View file
@@ -1,4 +1,5 @@
11 import type { AppContext } from "../auth/principal.js";
2+import { blockedBy, killSwitches } from "../lib/killswitch.js";
23 import type { Env } from "../env.js";
34 import type { loadRepo } from "../http/common.js";
45 import type { Account, Directory, Repo } from "../lib/directory.js";
@@ -31,7 +32,7 @@
3132 export const TERMINAL_TERMS_VERSION = 1;
3233
3334 export type TerminalStatus = "starting" | "running" | "ended";
34-export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled";
35+export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled" | "suspended";
3536
3637 export interface TerminalRow {
3738 id: string;
@@ -55,6 +56,7 @@
5556 stopped: "Its sandbox stopped.",
5657 failed: "Its sandbox didn't start.",
5758 disabled: "Terminals were switched off on this Diffinery.",
59+ suspended: "A site administrator suspended Actions and sandboxes here, or paused hosted compute on this Diffinery.",
5860 };
5961
6062 /** Terminals can be switched off for the whole instance (`TERMINALS=off`). */
@@ -163,6 +165,9 @@
163165 if (open.total >= MAX_TERMINALS) throw new HttpError(429, `you have ${MAX_TERMINALS} terminals open, the most allowed; end one first`);
164166 // The account that owns the repository pays for the sandbox (lib/quotas.ts); say so now rather than
165167 // after it fails to start. RepoDO.createSandbox checks again when it starts.
168+ // Kill switches (lib/killswitch.ts) refuse new sandboxes, terminals included.
169+ const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox");
170+ if (block) throw new HttpError(403, block);
166171 const quota = await mayStart(c.env, l.repo.owner_id, "sandbox");
167172 if (!quota.ok) throw new HttpError(429, quota.message);
168173 const t = await terminals.insert({ repo_id: l.repo.id, person_id: me.id, agent_id: agent.id, ref });
modifiedapps/diffinery/src/docs/api-meta.ts+37−0View file
@@ -49,6 +49,15 @@
4949 "GET /orgs/{org}/usage": { scope: "read", anyToken: true },
5050 "GET /admin/accounts/{login}/quotas": { scope: "read", anyToken: true },
5151 "PATCH /admin/accounts/{login}/quotas": { scope: "admin" },
52+ "GET /admin/repos/{owner}/{repo}/actions-suspension": { scope: "read" },
53+ "PUT /admin/repos/{owner}/{repo}/actions-suspension": { scope: "admin" },
54+ "DELETE /admin/repos/{owner}/{repo}/actions-suspension": { scope: "admin" },
55+ "GET /admin/accounts/{login}/actions-suspension": { scope: "read" },
56+ "PUT /admin/accounts/{login}/actions-suspension": { scope: "admin" },
57+ "DELETE /admin/accounts/{login}/actions-suspension": { scope: "admin" },
58+ "GET /admin/hosted-compute-pause": { scope: "read" },
59+ "PUT /admin/hosted-compute-pause": { scope: "admin" },
60+ "DELETE /admin/hosted-compute-pause": { scope: "admin" },
5261 "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" },
5362 "GET /invitations": { scope: "read" },
5463 "DELETE /invitations/{id}": { scope: "admin" },
@@ -146,6 +155,34 @@
146155 repositories: ["integer", "Repositories the account owns."],
147156 },
148157 },
158+ "GET /admin/repos/{owner}/{repo}/actions-suspension": {
159+ summary: "Get a repository's Actions suspension",
160+ description: "Site administrators only. `suspended`, `reason`, `suspended_by`, `suspended_at`, and `owner_suspended` when its owner's Actions are suspended.",
161+ access: "site-admin",
162+ },
163+ "PUT /admin/repos/{owner}/{repo}/actions-suspension": {
164+ summary: "Suspend Actions for a repository",
165+ description: "Site administrators only. No runs start, queued jobs fail and no sandboxes open; what runs now stops (`stopped`). Audited as `actions.suspend`.",
166+ access: "site-admin",
167+ body: { reason: ["string", "Why (up to 500 characters). Shown to the people affected and kept in the audit log.", true] },
168+ },
169+ "DELETE /admin/repos/{owner}/{repo}/actions-suspension": { summary: "Lift a repository's Actions suspension", description: "Site administrators only. New work can start again; nothing restarts. Audited as `actions.unsuspend`.", access: "site-admin" },
170+ "GET /admin/accounts/{login}/actions-suspension": { summary: "Get an account's Actions suspension", description: "Site administrators only. `suspended`, `reason`, `suspended_by` and `suspended_at`.", access: "site-admin" },
171+ "PUT /admin/accounts/{login}/actions-suspension": {
172+ summary: "Suspend Actions for an account",
173+ description: "Site administrators only. As for a repository, for every repository the person or organization owns; their running runs and sandboxes stop now. Audited as `actions.suspend`.",
174+ access: "site-admin",
175+ body: { reason: ["string", "Why (up to 500 characters). Shown to the people affected and kept in the audit log.", true] },
176+ },
177+ "DELETE /admin/accounts/{login}/actions-suspension": { summary: "Lift an account's Actions suspension", description: "Site administrators only. New work can start again; nothing restarts. Audited as `actions.unsuspend`.", access: "site-admin" },
178+ "GET /admin/hosted-compute-pause": { summary: "Get the hosted compute pause", description: "Site administrators only. `paused`, `reason`, `paused_by` and `paused_at`.", access: "site-admin" },
179+ "PUT /admin/hosted-compute-pause": {
180+ summary: "Pause hosted compute",
181+ description: "Site administrators only. Hosted jobs fail without starting and sandboxes are refused everywhere; running ones stop now. Self-hosted runners still work. Audited as `compute.pause`.",
182+ access: "site-admin",
183+ body: { reason: ["string", "Why (up to 500 characters). Shown to the people affected and kept in the audit log.", true] },
184+ },
185+ "DELETE /admin/hosted-compute-pause": { summary: "Resume hosted compute", description: "Site administrators only. New hosted jobs and sandboxes can start again; nothing restarts. Audited as `compute.unpause`.", access: "site-admin" },
149186 "GET /user/orgs": { summary: "List your organizations", description: "Each organization carries your `role` in it (member, admin or owner).", access: "account" },
150187 "GET /user/repository_invitations": { summary: "List your repository invitations", description: "Pending invitations to repositories, sent to your username or your email address.", access: "account" },
151188 "PATCH /user/repository_invitations/{id}": { summary: "Accept a repository invitation", description: "You get the role it offers. Audited as `invite.accept`.", access: "account", status: 204 },
modifiedapps/diffinery/src/http/api.ts+84−0View file
@@ -5,6 +5,7 @@
55 import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js";
66 import { encryptSecret } from "../lib/crypto.js";
77 import { monthStart, quotasFor, setQuotas, usageOf } from "../lib/quotas.js";
8+import { changePause, changeSuspension, cleanReason, computePause, killSwitches, suspensionOf, type Stopped } from "../lib/killswitch.js";
89 import { publicJwk } from "../lib/jwt.js";
910 import { HttpError, b64, fromB64 } from "../lib/util.js";
1011 import { atLeast, ROLE_LEVEL, type ProtectionRules, type Role } from "../repo/types.js";
@@ -131,6 +132,89 @@
131132 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "account.quotas.set", target: account.login, detail: b }));
132133 return c.json(await usageJson(c, account));
133134 });
135+// Kill switches (lib/killswitch.ts): site administrators suspend Actions for a repository or an
136+// account, or pause hosted compute on the whole instance. Same checks as the quotas above.
137+const siteAdmin = (c: AppContext, what: string) => {
138+ const a = requireAccount(c);
139+ if (!a.site_admin) throw new HttpError(403, `only site administrators can ${what}`);
140+ return a;
141+};
142+const stoppedJson = (s: Stopped | null) => (s ? { stopped: { repositories: s.repositories, runs: s.runs, jobs: s.jobs, sandboxes: s.sandboxes, failed: s.failed } } : {});
143+const repoSuspension = async (c: AppContext, write: boolean) => {
144+ if (write) {
145+ requireAccountScope(c, "admin");
146+ requireUnlimitedToken(c, "suspend Actions");
147+ } else requireAccountScope(c, "read");
148+ const a = siteAdmin(c, "see or change Actions suspensions");
149+ const repo = await c.get("dir").repo(c.req.param("owner") ?? "", c.req.param("repo") ?? "");
150+ if (!repo) throw new HttpError(404, "no repository with that name");
151+ return { a, repo, json: async (stopped: Stopped | null = null) => {
152+ const k = await killSwitches(c.env, repo.id);
153+ return { repository: `${repo.owner_login}/${repo.name}`, suspended: !!k.repo, reason: k.repo?.reason ?? null, suspended_by: k.repo?.by ?? null, suspended_at: k.repo?.at ?? null, owner_suspended: !!k.account, ...stoppedJson(stopped) };
154+ } };
155+};
156+api.get("/admin/repos/:owner/:repo/actions-suspension", async (c) => c.json(await (await repoSuspension(c, false)).json()));
157+api.put("/admin/repos/:owner/:repo/actions-suspension", async (c) => {
158+ const { a, repo, json } = await repoSuspension(c, true);
159+ const reason = cleanReason((await body<{ reason?: unknown }>(c).catch(() => ({}) as { reason?: unknown }))?.reason);
160+ const stopped = await changeSuspension(c.env, (e) => c.get("dir").audit(e), a, { kind: "repo", id: repo.id, name: `${repo.owner_login}/${repo.name}` }, reason, "api");
161+ return c.json(await json(stopped));
162+});
163+api.delete("/admin/repos/:owner/:repo/actions-suspension", async (c) => {
164+ const { a, repo, json } = await repoSuspension(c, true);
165+ await changeSuspension(c.env, (e) => c.get("dir").audit(e), a, { kind: "repo", id: repo.id, name: `${repo.owner_login}/${repo.name}` }, null, "api");
166+ return c.json(await json());
167+});
168+const accountSuspension = async (c: AppContext, write: boolean) => {
169+ if (write) {
170+ requireAccountScope(c, "admin");
171+ requireUnlimitedToken(c, "suspend Actions");
172+ } else requireAccountScope(c, "read");
173+ const a = siteAdmin(c, "see or change Actions suspensions");
174+ const account = await c.get("dir").accountByLogin(c.req.param("login") ?? "");
175+ if (!account || account.kind === "agent") throw new HttpError(404, "no person or organization with that name");
176+ return { a, account, json: async (stopped: Stopped | null = null) => {
177+ const s = await suspensionOf(c.env, { accountId: account.id });
178+ return { account: account.login, suspended: !!s, reason: s?.reason ?? null, suspended_by: s?.by ?? null, suspended_at: s?.at ?? null, ...stoppedJson(stopped) };
179+ } };
180+};
181+api.get("/admin/accounts/:login/actions-suspension", async (c) => c.json(await (await accountSuspension(c, false)).json()));
182+api.put("/admin/accounts/:login/actions-suspension", async (c) => {
183+ const { a, account, json } = await accountSuspension(c, true);
184+ const reason = cleanReason((await body<{ reason?: unknown }>(c).catch(() => ({}) as { reason?: unknown }))?.reason);
185+ const stopped = await changeSuspension(c.env, (e) => c.get("dir").audit(e), a, { kind: "account", id: account.id, login: account.login }, reason, "api");
186+ return c.json(await json(stopped));
187+});
188+api.delete("/admin/accounts/:login/actions-suspension", async (c) => {
189+ const { a, account, json } = await accountSuspension(c, true);
190+ await changeSuspension(c.env, (e) => c.get("dir").audit(e), a, { kind: "account", id: account.id, login: account.login }, null, "api");
191+ return c.json(await json());
192+});
193+const pauseJson = async (c: AppContext, stopped: Stopped | null = null) => {
194+ const p = await computePause(c.env);
195+ return { paused: !!p, reason: p?.reason ?? null, paused_by: p?.by ?? null, paused_at: p?.at ?? null, ...stoppedJson(stopped) };
196+};
197+const pauseAdmin = (c: AppContext, write: boolean) => {
198+ if (write) {
199+ requireAccountScope(c, "admin");
200+ requireUnlimitedToken(c, "pause hosted compute");
201+ } else requireAccountScope(c, "read");
202+ return siteAdmin(c, "see or change the hosted compute pause");
203+};
204+api.get("/admin/hosted-compute-pause", async (c) => {
205+ pauseAdmin(c, false);
206+ return c.json(await pauseJson(c));
207+});
208+api.put("/admin/hosted-compute-pause", async (c) => {
209+ const a = pauseAdmin(c, true);
210+ const reason = cleanReason((await body<{ reason?: unknown }>(c).catch(() => ({}) as { reason?: unknown }))?.reason);
211+ return c.json(await pauseJson(c, await changePause(c.env, (e) => c.get("dir").audit(e), a, reason, "api")));
212+});
213+api.delete("/admin/hosted-compute-pause", async (c) => {
214+ const a = pauseAdmin(c, true);
215+ await changePause(c.env, (e) => c.get("dir").audit(e), a, null, "api");
216+ return c.json(await pauseJson(c));
217+});
134218 api.get("/user/orgs", async (c) => c.json((await c.get("dir").orgsFor(requireAccount(c).id)).map((o) => ({ ...userJson(c, o), role: o.role }))));
135219 api.post("/user/orgs", async (c) => {
136220 requireAccountScope(c, "admin");
addedapps/diffinery/src/lib/killswitch.ts+227−0View file
@@ -1,0 +1,227 @@
1+import type { Env } from "../env.js";
2+import { MAX_RUN_MS } from "./quotas.js";
3+import { HttpError, now } from "./util.js";
4+
5+/**
6+ * Kill switches: a site administrator stops Actions and agent sandboxes at three levels.
7+ *
8+ * - A repository: its Actions are suspended (repos.actions_suspended_*).
9+ * - An account (a person or an organization): Actions are suspended for every repository it owns
10+ * (accounts.actions_suspended_*).
11+ * - The instance: hosted compute (hosted Actions jobs and agent sandboxes) is paused everywhere
12+ * (site_flags `hosted_compute_paused`). Self-hosted runners keep working: they cost the instance nothing.
13+ *
14+ * While a repository or its owner is suspended no workflow run starts, queued jobs (hosted or
15+ * self-hosted) fail instead of starting, and no sandbox opens. While the instance is paused hosted
16+ * jobs fail instead of starting and no sandbox opens. Suspending or pausing also stops what is
17+ * running then (RepoDO.stopActions); lifting one only allows new work again. Nobody is exempt,
18+ * not even site administrators' own accounts.
19+ */
20+
21+export const PAUSE_FLAG = "hosted_compute_paused";
22+/** Longest reason kept, in characters. */
23+export const MAX_REASON = 500;
24+
25+export interface Suspension {
26+ at: string;
27+ by: string | null;
28+ reason: string | null;
29+}
30+
31+export interface KillSwitches {
32+ repo: Suspension | null;
33+ /** The owning account's suspension, with its login. */
34+ account: (Suspension & { login: string }) | null;
35+ paused: Suspension | null;
36+}
37+
38+/** What wants to start: a workflow run, a hosted or self-hosted job, or an agent sandbox. */
39+export type Work = "run" | "hosted-job" | "self-hosted-job" | "sandbox";
40+
41+const tail = (reason: string | null) => (reason ? `: ${reason}` : "");
42+
43+/** Every switch that applies to a repository, in one query (this is on the path of every job and sandbox). */
44+export async function killSwitches(env: Env, repoId: string): Promise<KillSwitches> {
45+ const r = await env.DB.prepare(
46+ `SELECT r.actions_suspended_at AS r_at, r.actions_suspended_by AS r_by, r.actions_suspended_reason AS r_reason,
47+ a.login AS login, a.actions_suspended_at AS a_at, a.actions_suspended_by AS a_by, a.actions_suspended_reason AS a_reason,
48+ f.value AS p_reason, f.updated_at AS p_at, f.updated_by AS p_by
49+ FROM repos r JOIN accounts a ON a.id = r.owner_id LEFT JOIN site_flags f ON f.key = ?
50+ WHERE r.id = ?`,
51+ )
52+ .bind(PAUSE_FLAG, repoId)
53+ .first<Record<string, string | null>>();
54+ if (!r) return { repo: null, account: null, paused: await computePause(env) };
55+ return {
56+ repo: r.r_at ? { at: r.r_at, by: r.r_by ?? null, reason: r.r_reason ?? null } : null,
57+ account: r.a_at ? { at: r.a_at, by: r.a_by ?? null, reason: r.a_reason ?? null, login: r.login! } : null,
58+ paused: r.p_at ? { at: r.p_at, by: r.p_by ?? null, reason: r.p_reason || null } : null,
59+ };
60+}
61+
62+/** Why `work` may not start under these switches (a sentence without a final period), or null when it may. */
63+export function blockedBy(k: KillSwitches, work: Work): string | null {
64+ if (k.repo) return `Actions are suspended for this repository by a site administrator${tail(k.repo.reason)}`;
65+ if (k.account) return `Actions are suspended for every repository of ${k.account.login} by a site administrator${tail(k.account.reason)}`;
66+ if (k.paused && work === "hosted-job") return `Hosted runners are paused on this instance by a site administrator${tail(k.paused.reason)}. Self-hosted runners still work`;
67+ if (k.paused && work === "sandbox") return `Agent sandboxes are paused on this instance by a site administrator${tail(k.paused.reason)}`;
68+ return null;
69+}
70+
71+/** The instance's hosted compute pause, or null. */
72+export async function computePause(env: Env): Promise<Suspension | null> {
73+ const f = await env.DB.prepare("SELECT value, updated_at, updated_by FROM site_flags WHERE key = ?").bind(PAUSE_FLAG).first<{ value: string; updated_at: string; updated_by: string | null }>();
74+ return f ? { at: f.updated_at, by: f.updated_by, reason: f.value || null } : null;
75+}
76+
77+/** A reason as sent by an administrator: required, trimmed and bounded. */
78+export function cleanReason(v: unknown): string {
79+ const s = typeof v === "string" ? v.trim().replace(/\s+/g, " ") : "";
80+ if (!s) throw new HttpError(422, "give a reason (it is shown to the people affected and kept in the audit log)");
81+ if (s.length > MAX_REASON) throw new HttpError(422, `the reason can be at most ${MAX_REASON} characters`);
82+ return s;
83+}
84+
85+/** Suspend (with a reason) or lift (null) Actions for a repository or an account. */
86+export async function setSuspension(env: Env, target: { repoId: string } | { accountId: string }, s: { reason: string; by: string } | null): Promise<void> {
87+ const [table, id] = "repoId" in target ? ["repos", target.repoId] : ["accounts", target.accountId];
88+ await env.DB.prepare(`UPDATE ${table} SET actions_suspended_at = ?, actions_suspended_by = ?, actions_suspended_reason = ? WHERE id = ?`)
89+ .bind(s ? now() : null, s?.by ?? null, s?.reason ?? null, id)
90+ .run();
91+}
92+
93+/** A repository's or an account's own suspension (not its owner's), or null. */
94+export async function suspensionOf(env: Env, target: { repoId: string } | { accountId: string }): Promise<Suspension | null> {
95+ const [table, id] = "repoId" in target ? ["repos", target.repoId] : ["accounts", target.accountId];
96+ const r = await env.DB.prepare(`SELECT actions_suspended_at AS at, actions_suspended_by AS by, actions_suspended_reason AS reason FROM ${table} WHERE id = ?`).bind(id).first<{ at: string | null; by: string | null; reason: string | null }>();
97+ return r?.at ? { at: r.at, by: r.by, reason: r.reason } : null;
98+}
99+
100+/** Pause (with a reason) or resume (null) hosted compute on the instance. */
101+export async function setComputePause(env: Env, s: { reason: string; by: string } | null): Promise<void> {
102+ if (!s) await env.DB.prepare("DELETE FROM site_flags WHERE key = ?").bind(PAUSE_FLAG).run();
103+ else
104+ await env.DB.prepare("INSERT INTO site_flags (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by")
105+ .bind(PAUSE_FLAG, s.reason, now(), s.by)
106+ .run();
107+}
108+
109+/**
110+ * Repositories with hosted compute running now (compute_usage rows still open), with the ids of
111+ * their jobs and sandboxes. A sandbox that slept and woke has segment ids `<sandbox id>:<n>`;
112+ * these are reported as the sandbox's own id. Rows older than the longest a job or sandbox can
113+ * run are counted as ended, as quotas count them.
114+ */
115+export async function runningCompute(env: Env, at = new Date()): Promise<Map<string, { jobs: string[]; sandboxes: string[] }>> {
116+ const since = new Date(at.getTime() - MAX_RUN_MS.sandbox).toISOString();
117+ const rows = await env.DB.prepare("SELECT id, repo_id, kind, started_at FROM compute_usage WHERE ended_at IS NULL AND started_at > ?").bind(since).all<{ id: string; repo_id: string; kind: "job" | "sandbox"; started_at: string }>();
118+ const out = new Map<string, { jobs: string[]; sandboxes: string[] }>();
119+ for (const r of rows.results) {
120+ if (Date.parse(r.started_at) + MAX_RUN_MS[r.kind] <= at.getTime()) continue;
121+ const e = out.get(r.repo_id) ?? { jobs: [], sandboxes: [] };
122+ const id = r.kind === "sandbox" ? r.id.split(":")[0]! : r.id;
123+ const list = r.kind === "job" ? e.jobs : e.sandboxes;
124+ if (!list.includes(id)) list.push(id);
125+ out.set(r.repo_id, e);
126+ }
127+ return out;
128+}
129+
130+/** Suspended repositories and accounts, for the site administration page. */
131+export async function suspendedList(env: Env): Promise<{ accounts: Array<Suspension & { login: string; kind: string }>; repos: Array<Suspension & { owner: string; name: string }> }> {
132+ const [a, r] = await Promise.all([
133+ env.DB.prepare("SELECT login, kind, actions_suspended_at AS at, actions_suspended_by AS by, actions_suspended_reason AS reason FROM accounts WHERE actions_suspended_at IS NOT NULL ORDER BY actions_suspended_at DESC LIMIT 200").all<Suspension & { login: string; kind: string }>(),
134+ env.DB.prepare(
135+ "SELECT a.login AS owner, r.name AS name, r.actions_suspended_at AS at, r.actions_suspended_by AS by, r.actions_suspended_reason AS reason FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.actions_suspended_at IS NOT NULL ORDER BY r.actions_suspended_at DESC LIMIT 200",
136+ ).all<Suspension & { owner: string; name: string }>(),
137+ ]);
138+ return { accounts: a.results, repos: r.results };
139+}
140+
141+// ---------------------------------------------------------------- turning switches on and off (REST and web)
142+
143+export type Stopped = { repositories: number; runs: number; jobs: number; sandboxes: number; failed: number };
144+
145+/** Ask each repository's RepoDO to stop its work (RepoDO.stopActions), a few at a time. */
146+export async function stopIn(env: Env, repoIds: string[], o: { hostedOnly: boolean; message: string }): Promise<Stopped> {
147+ const out: Stopped = { repositories: 0, runs: 0, jobs: 0, sandboxes: 0, failed: 0 };
148+ const queue = [...repoIds];
149+ const worker = async () => {
150+ for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
151+ try {
152+ const r = await env.REPO.get(env.REPO.idFromName(id)).stopActions(o);
153+ out.repositories++;
154+ out.runs += r.runs;
155+ out.jobs += r.jobs;
156+ out.sandboxes += r.sandboxes;
157+ } catch (e) {
158+ console.error(`kill switch: stopping work in repository ${id} failed`, e);
159+ out.failed++;
160+ }
161+ }
162+ };
163+ await Promise.all(Array.from({ length: Math.min(8, queue.length) }, worker));
164+ return out;
165+}
166+
167+type Actor = { id: string; login: string };
168+type Audit = (entry: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>;
169+
170+/**
171+ * Suspend Actions for a repository or an account (`reason`), or lift the suspension (null), and
172+ * audit it as `actions.suspend` or `actions.unsuspend`. Suspending stops what runs now: every
173+ * unfinished run is cancelled and every open sandbox closed, in the repository or in each one
174+ * the account owns. Lifting what isn't suspended changes nothing and isn't audited.
175+ */
176+export async function changeSuspension(
177+ env: Env,
178+ audit: Audit,
179+ actor: Actor,
180+ target: { kind: "repo"; id: string; name: string } | { kind: "account"; id: string; login: string },
181+ reason: string | null,
182+ via: "api" | "web",
183+): Promise<Stopped | null> {
184+ const key = target.kind === "repo" ? { repoId: target.id } : { accountId: target.id };
185+ const label = target.kind === "repo" ? target.name : target.login;
186+ if (reason === null) {
187+ if (!(await suspensionOf(env, key))) return null;
188+ await setSuspension(env, key, null);
189+ await audit({ actorId: actor.id, actorLogin: actor.login, action: "actions.unsuspend", target: label, detail: { kind: target.kind, via } });
190+ return null;
191+ }
192+ await setSuspension(env, key, { reason, by: actor.login });
193+ const s = { at: now(), by: actor.login, reason };
194+ const message = blockedBy(target.kind === "repo" ? { repo: s, account: null, paused: null } : { repo: null, account: { ...s, login: target.login }, paused: null }, "run")!;
195+ let stopped: Stopped | null = null;
196+ try {
197+ const ids = target.kind === "repo" ? [target.id] : (await env.DB.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(target.id).all<{ id: string }>()).results.map((r) => r.id);
198+ stopped = await stopIn(env, ids, { hostedOnly: false, message });
199+ } finally {
200+ // The switch is on even if stopping what ran failed: it is recorded either way.
201+ await audit({ actorId: actor.id, actorLogin: actor.login, action: "actions.suspend", target: label, detail: { kind: target.kind, reason, via, stopped } });
202+ }
203+ return stopped;
204+}
205+
206+/**
207+ * Pause hosted compute on the instance (`reason`), or resume it (null), audited as
208+ * `compute.pause` or `compute.unpause`. Pausing stops the hosted jobs and sandboxes running now:
209+ * open compute_usage rows say which repositories to ask. Resuming what isn't paused changes nothing.
210+ */
211+export async function changePause(env: Env, audit: Audit, actor: Actor, reason: string | null, via: "api" | "web"): Promise<Stopped | null> {
212+ if (reason === null) {
213+ if (!(await computePause(env))) return null;
214+ await setComputePause(env, null);
215+ await audit({ actorId: actor.id, actorLogin: actor.login, action: "compute.unpause", detail: { via } });
216+ return null;
217+ }
218+ await setComputePause(env, { reason, by: actor.login });
219+ const message = blockedBy({ repo: null, account: null, paused: { at: now(), by: actor.login, reason } }, "hosted-job")!;
220+ let stopped: Stopped | null = null;
221+ try {
222+ stopped = await stopIn(env, [...(await runningCompute(env)).keys()], { hostedOnly: true, message });
223+ } finally {
224+ await audit({ actorId: actor.id, actorLogin: actor.login, action: "compute.pause", detail: { reason, via, stopped } });
225+ }
226+ return stopped;
227+}
modifiedapps/diffinery/src/mail/web.tsx+6−2View file
@@ -4,7 +4,8 @@
44 import type { Account } from "../lib/directory.js";
55 import { loadRepo } from "../http/common.js";
66 import { HttpError, now } from "../lib/util.js";
7-import { SettingsNav, type SettingsTab } from "../web/account.js";
7+import { ADMIN_TABS, SettingsNav, type SettingsTab } from "../web/account.js";
8+import { ComputePausedBanner } from "../web/kill-switches.js";
89 import { csrfToken, formData } from "../web/csrf.js";
910 import { KCheckbox, KSelect } from "../web/kumo.js";
1011 import { Form, Time, WATCH_OPTIONS, act, flash, post, prm, render, repoUrl, type WebApp } from "../web/ui.js";
@@ -34,7 +35,10 @@
3435 { title },
3536 <div class="grid left">
3637 <SettingsNav me={me} tab={tab} />
37- <div>{body}</div>
38+ <div>
39+ {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null}
40+ {body}
41+ </div>
3842 </div>,
3943 );
4044
modifiedapps/diffinery/src/repo/RepoDO.ts+6−0View file
@@ -1121,6 +1121,10 @@
11211121 closeTaskSandbox(...a: Parameters<Actions["closeTaskSandbox"]>) {
11221122 return this.actions.closeTaskSandbox(...a);
11231123 }
1124+ /** A kill switch was turned on (lib/killswitch.ts): stop what runs here now. */
1125+ stopActions(...a: Parameters<Actions["stopActions"]>) {
1126+ return this.actions.stopActions(...a);
1127+ }
11241128
11251129 plannedRunError(runId: string, message: string) {
11261130 return this.actions.plannedError(runId, message);
@@ -1562,6 +1566,8 @@
15621566 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to dispatch events");
15631567 const sha = this.getRef(this.headRef);
15641568 if (!sha) throw new HttpError(422, "the repository is empty");
1569+ // Suspended Actions refuse the dispatch with the reason (triggerWorkflows would swallow it).
1570+ await this.actions.refuseIfBlocked("run");
15651571 const m = this.meta;
15661572 const payload = { action: eventType, branch: m.defaultBranch, client_payload: clientPayload };
15671573 this.emit({ type: "repository_dispatch", action: eventType, repoId: m.repoId, owner: m.owner, repo: m.name, payload: JSON.parse(JSON.stringify(payload)), actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, at: now() });
modifiedapps/diffinery/src/repo/actions.ts+86−6View file
@@ -23,6 +23,7 @@
2323 import { ghRepo, numericId } from "../http/gh.js";
2424 import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js";
2525 import { endCompute, mayStart, startCompute } from "../lib/quotas.js";
26+import { blockedBy, killSwitches, type Work } from "../lib/killswitch.js";
2627 import type { Env } from "../env.js";
2728 import type { RepoDO } from "./RepoDO.js";
2829 import { completePayload } from "./event-payloads.js";
@@ -419,6 +420,7 @@
419420
420421 async dispatch(workflowFile: string, ref: string, inputs: Record<string, unknown>, caller: Caller): Promise<RunInfo> {
421422 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to run workflows");
423+ await this.refuseIfBlocked("run");
422424 const sha = await this.repo.resolve(ref);
423425 if (!sha) throw new HttpError(422, `unknown ref ${ref}`);
424426 const all = await this.loadWorkflows(sha);
@@ -504,6 +506,8 @@
504506 }
505507
506508 private async createFailedRun(path: string, event: string, ref: string, sha: string, caller: Actor, errors: string[]): Promise<void> {
509+ // While Actions are suspended no run is recorded, not even a failed one.
510+ if (await this.blocked("run")) return;
507511 const id = newId();
508512 const t = now();
509513 this.sql.exec(
@@ -556,6 +560,13 @@
556560 // runs, so workflows cannot trigger themselves in a loop; workflow_dispatch and
557561 // repository_dispatch are the exceptions.
558562 if (isJobCaller(o.caller) && o.event !== "workflow_dispatch" && o.event !== "repository_dispatch") return null;
563+ // A site administrator suspended Actions here (lib/killswitch.ts): events start nothing, and a
564+ // dispatch, which someone asked for, is refused with the reason.
565+ const block = await this.blocked("run");
566+ if (block) {
567+ if (o.event === "workflow_dispatch" || o.event === "repository_dispatch") throw new HttpError(403, block);
568+ return null;
569+ }
559570 // A dispatch loop (or a flood of pushes) stops growing here.
560571 const active = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE status != 'completed'").one().n;
561572 if (active >= MAX_ACTIVE_RUNS) {
@@ -975,7 +986,11 @@
975986
976987 private async launch(jobId: string): Promise<void> {
977988 const job = this.jobRow(jobId);
978- if (job.status === "completed" || !(await this.gate(job)) || !(await this.concurrencySlot(job))) return;
989+ if (job.status === "completed") return;
990+ // Kill switches (lib/killswitch.ts): a suspended repository or owner starts no job; a paused instance no hosted one.
991+ const block = await this.blocked(job.labels ? "self-hosted-job" : "hosted-job");
992+ if (block) return this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job was not started: ${block}.` } });
993+ if (!(await this.gate(job)) || !(await this.concurrencySlot(job))) return;
979994 const run = this.runRow(job.run_id);
980995 const m = this.repo.meta;
981996 if (job.labels) {
@@ -1318,6 +1333,12 @@
13181333 .toArray();
13191334 const job = waiting.find((j) => (JSON.parse(j.labels!) as string[]).every((l) => offered.has(l)));
13201335 if (!job) return null;
1336+ // A job parked before Actions were suspended here isn't handed out.
1337+ const block = await this.blocked("self-hosted-job");
1338+ if (block) {
1339+ await this.completeJob(job.id, { conclusion: "failure", outputs: {}, failure: { message: `The job was not started: ${block}.` } });
1340+ return null;
1341+ }
13211342 const m = this.repo.meta;
13221343 const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`;
13231344 const container = `self-hosted:${runnerName.slice(0, 64)}`;
@@ -1511,6 +1532,7 @@
15111532 async rerun(runId: string, caller: Caller): Promise<RunInfo> {
15121533 if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied");
15131534 if (caller.id.startsWith("job:")) throw new HttpError(403, "a workflow's GITHUB_TOKEN cannot re-run workflows");
1535+ await this.refuseIfBlocked("run");
15141536 const old = this.runRow(runId);
15151537 const wf = (await this.loadWorkflows(old.sha)).find((w) => w.path === old.workflow_path)?.workflow;
15161538 if (!wf) throw new HttpError(422, "workflow file no longer valid at this commit");
@@ -1527,10 +1549,12 @@
15271549 // Re-running code an agent wrote keeps it acting as the agent, whoever re-runs it.
15281550 agentOrigin: !!old.agent_origin,
15291551 });
1552+ // Not started after all (Actions suspended meanwhile, or too many unfinished runs).
1553+ if (!run) throw new HttpError(409, "the run was not started; try again later");
15301554 // Whoever started an earlier attempt still can't approve this one where an environment prevents self-review.
15311555 const prior = [...new Set([...((old.prior_actors ? JSON.parse(old.prior_actors) : []) as string[]), (JSON.parse(old.actor) as Actor).id])];
1532- this.sql.exec("UPDATE runs SET attempt = ?, run_number = ?, prior_actors = ? WHERE id = ?", old.attempt + 1, old.run_number, JSON.stringify(prior), run!.id);
1533- return toRun(this.runRow(run!.id));
1556+ this.sql.exec("UPDATE runs SET attempt = ?, run_number = ?, prior_actors = ? WHERE id = ?", old.attempt + 1, old.run_number, JSON.stringify(prior), run.id);
1557+ return toRun(this.runRow(run.id));
15341558 }
15351559
15361560 // ---------------------------------------------------------------- runner protocol
@@ -2017,6 +2041,7 @@
20172041 .one().n;
20182042 if (open >= MAX_OPEN_SANDBOXES) throw new HttpError(429, `you have ${open} open sandboxes in this repository, the most allowed; close one first`);
20192043 }
2044+ await this.refuseIfBlocked("sandbox", false);
20202045 // The account that owns the repository pays for sandboxes too (lib/quotas.ts).
20212046 const quota = await mayStart(this.env, this.repo.meta.ownerId, "sandbox");
20222047 if (!quota.ok) throw new HttpError(429, quota.message);
@@ -2072,6 +2097,11 @@
20722097
20732098 async closeSandbox(id: string, caller: Caller): Promise<void> {
20742099 this.getSandbox(id, caller);
2100+ await this.endSandbox(id);
2101+ }
2102+
2103+ /** Close a sandbox: its token stops working, its time stops counting and its container stops. */
2104+ private async endSandbox(id: string): Promise<void> {
20752105 this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id);
20762106 await endCompute(this.env, id);
20772107 await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown();
@@ -2081,8 +2111,58 @@
20812111 async closeTaskSandbox(id: string): Promise<void> {
20822112 const s = this.sql.exec<{ kind: string }>("SELECT kind FROM sandboxes WHERE id = ?", id).toArray()[0];
20832113 if (s?.kind !== "task" && s?.kind !== "terminal") return;
2084- this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id);
2085- await endCompute(this.env, id);
2086- await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown();
2114+ await this.endSandbox(id);
20872115 }
2116+
2117+ // ---------------------------------------------------------------- kill switches (lib/killswitch.ts)
2118+
2119+ /**
2120+ * Why `work` may not start here now, or null. One D1 read. A failed read lets work start
2121+ * (`failOpen`), as quotas do for jobs: a D1 outage shouldn't stop every repository's CI.
2122+ */
2123+ private async blocked(work: Work, failOpen = true): Promise<string | null> {
2124+ try {
2125+ return blockedBy(await killSwitches(this.env, this.repo.meta.repoId), work);
2126+ } catch (e) {
2127+ if (!failOpen) throw e;
2128+ console.error("kill switch check failed", e);
2129+ return null;
2130+ }
2131+ }
2132+
2133+ /** Refuse (403, with the reason) what someone asked to start while a kill switch stops it. */
2134+ async refuseIfBlocked(work: Work, failOpen = true): Promise<void> {
2135+ const block = await this.blocked(work, failOpen);
2136+ if (block) throw new HttpError(403, block);
2137+ }
2138+
2139+ /**
2140+ * A site administrator just suspended Actions here or for the owner (`hostedOnly` false): every
2141+ * unfinished run is cancelled and every open sandbox closed. Or paused hosted compute on the
2142+ * instance (`hostedOnly`): running hosted jobs fail and open sandboxes close; self-hosted jobs go on.
2143+ * `message` says why. Jobs still queued fail when they would start (launch).
2144+ */
2145+ async stopActions(o: { hostedOnly: boolean; message: string }): Promise<{ runs: number; jobs: number; sandboxes: number }> {
2146+ let runs = 0;
2147+ let jobs = 0;
2148+ let sandboxes = 0;
2149+ if (o.hostedOnly) {
2150+ for (const j of this.sql.exec<JobRow>("SELECT * FROM jobs WHERE labels IS NULL AND container IS NOT NULL AND status IN ('waiting', 'in_progress') ORDER BY rowid").toArray()) {
2151+ await this.stopContainer(j);
2152+ await this.completeJob(j.id, { conclusion: "failure", outputs: {}, failure: { message: `The job was stopped: ${o.message}.` } });
2153+ jobs++;
2154+ }
2155+ } else {
2156+ for (const r of this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE status != 'completed' ORDER BY rowid").toArray()) {
2157+ jobs += this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM jobs WHERE run_id = ? AND status != 'completed'", r.id).one().n;
2158+ await this.cancelInternal(r.id, `Cancelled: ${o.message}.`);
2159+ runs++;
2160+ }
2161+ }
2162+ for (const s of this.sql.exec<{ id: string }>("SELECT id FROM sandboxes WHERE status = 'running'").toArray()) {
2163+ await this.endSandbox(s.id).catch((e: unknown) => console.error("closing a sandbox failed", e));
2164+ sandboxes++;
2165+ }
2166+ return { runs, jobs, sandboxes };
2167+ }
20882168 }
modifiedapps/diffinery/src/runner/containers.ts+7−0View file
@@ -3,6 +3,7 @@
33 import type { StopParams } from "@cloudflare/containers";
44 import type { Env } from "../env.js";
55 import { endCompute, startCompute } from "../lib/quotas.js";
6+import { blockedBy, killSwitches } from "../lib/killswitch.js";
67
78 /**
89 * One container per workflow job. The runner agent inside fetches its job spec
@@ -71,6 +72,12 @@
7172 // A sandbox that slept after 30 idle minutes starts again for this call: its time counts again.
7273 const usage = await this.ctx.storage.get<{ id: string; accountId: string; repoId: string; part?: number }>("usage");
7374 if (usage && !this.ctx.container?.running) {
75+ // It doesn't wake while a kill switch stops sandboxes (lib/killswitch.ts).
76+ const block = await killSwitches(this.env, usage.repoId).then(
77+ (k) => blockedBy(k, "sandbox"),
78+ () => null,
79+ );
80+ if (block) return { status: 403, body: JSON.stringify({ message: block }), json: true };
7481 const part = (usage.part ?? 0) + 1;
7582 await startCompute(this.env, { id: `${usage.id}:${part}`, accountId: usage.accountId, repoId: usage.repoId, kind: "sandbox" });
7683 await this.ctx.storage.put("usage", { ...usage, part });
modifiedapps/diffinery/src/web/account.tsx+76−2View file
@@ -20,6 +20,8 @@
2020 import { MAX_AVATAR_BYTES } from "../lib/image.js";
2121 import { gatherToday } from "../agentic/today.js";
2222 import { TodayView } from "../agentic/web.js";
23+import { ComputePausedBanner, KillSwitchesPage } from "./kill-switches.js";
24+import { changePause, changeSuspension, cleanReason, type Stopped } from "../lib/killswitch.js";
2325
2426 // ---------------------------------------------------------------- shared bits
2527
@@ -641,7 +643,9 @@
641643
642644 // ---------------------------------------------------------------- /settings
643645
644-export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "linked" | "admin" | "email" | "audit";
646+export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "linked" | "admin" | "email" | "audit" | "kill-switches";
647+/** The site administration pages: they show when hosted compute is paused. */
648+export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "email", "audit", "kill-switches"];
645649
646650 export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
647651 <aside class="side vt-side">
@@ -694,6 +698,9 @@
694698 <a href="/settings/admin/email" class={tab === "email" ? "on" : ""}>
695699 Email
696700 </a>
701+ <a href="/settings/admin/kill-switches" class={tab === "kill-switches" ? "on" : ""}>
702+ Kill switches
703+ </a>
697704 <a href="/settings/audit" class={tab === "audit" ? "on" : ""}>
698705 Audit log
699706 </a>
@@ -733,7 +740,10 @@
733740 { title, status },
734741 <div class="grid left">
735742 <SettingsNav me={me} tab={tab} />
736- <div>{body}</div>
743+ <div>
744+ {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null}
745+ {body}
746+ </div>
737747 </div>,
738748 );
739749
@@ -1588,6 +1598,70 @@
15881598 );
15891599 });
15901600
1601+ // Kill switches (lib/killswitch.ts)
1602+ web.get("/settings/admin/kill-switches", async (c) => {
1603+ const me = requireSiteAdmin(c);
1604+ return page(c, me, "kill-switches", "Kill switches", <KillSwitchesPage env={c.env} t={csrfToken(c)} />);
1605+ });
1606+ const KS = "/settings/admin/kill-switches";
1607+ const auditFn = (c: AppContext) => (e: Parameters<Directory["audit"]>[0]) => c.get("dir").audit(e);
1608+ const stoppedNote = (s: Stopped | null) => (s ? ` Stopped ${s.runs} runs, ${s.jobs} jobs and ${s.sandboxes} sandboxes${s.failed ? `; ${s.failed} repositories didn't answer, so their work stops when it next tries to start` : ""}.` : "");
1609+ post(web, `${KS}/pause`, async (c, f) => {
1610+ const me = requireSiteAdmin(c);
1611+ return act(c, KS, async () => {
1612+ const stopped = await changePause(c.env, auditFn(c), me, cleanReason(f.s("reason")), "web");
1613+ flash(c, "notice", `Hosted compute is paused.${stoppedNote(stopped)}`);
1614+ });
1615+ });
1616+ post(web, `${KS}/resume`, async (c) => {
1617+ const me = requireSiteAdmin(c);
1618+ return act(c, KS, async () => changePause(c.env, auditFn(c), me, null, "web").then(() => undefined), "Hosted compute is running again. Nothing that was stopped restarts.");
1619+ });
1620+ const accountTarget = async (c: AppContext, login: string) => {
1621+ const account = await c.get("dir").accountByLogin(login.trim());
1622+ if (!account || account.kind === "agent") throw new HttpError(404, "no person or organization with that name");
1623+ return { kind: "account" as const, id: account.id, login: account.login };
1624+ };
1625+ const repoTarget = async (c: AppContext, owner: string, name: string) => {
1626+ const repo = await c.get("dir").repo(owner.trim(), name.trim());
1627+ if (!repo) throw new HttpError(404, "no repository with that name; use owner/name");
1628+ return { kind: "repo" as const, id: repo.id, name: `${repo.owner_login}/${repo.name}` };
1629+ };
1630+ post(web, `${KS}/accounts`, async (c, f) => {
1631+ const me = requireSiteAdmin(c);
1632+ return act(c, KS, async () => {
1633+ const target = await accountTarget(c, f.s("login"));
1634+ const stopped = await changeSuspension(c.env, auditFn(c), me, target, cleanReason(f.s("reason")), "web");
1635+ flash(c, "notice", `Actions are suspended for every repository of ${target.login}.${stoppedNote(stopped)}`);
1636+ });
1637+ });
1638+ post(web, `${KS}/accounts/:login/lift`, async (c) => {
1639+ const me = requireSiteAdmin(c);
1640+ return act(c, KS, async () => {
1641+ const target = await accountTarget(c, prm(c, "login"));
1642+ await changeSuspension(c.env, auditFn(c), me, target, null, "web");
1643+ flash(c, "notice", `Actions are no longer suspended for ${target.login}.`);
1644+ });
1645+ });
1646+ post(web, `${KS}/repos`, async (c, f) => {
1647+ const me = requireSiteAdmin(c);
1648+ return act(c, KS, async () => {
1649+ const [owner, name, extra] = f.s("repo").trim().replace(/^\/+|\/+$/g, "").split("/");
1650+ if (!owner || !name || extra !== undefined) throw new HttpError(422, "enter the repository as owner/name");
1651+ const target = await repoTarget(c, owner, name);
1652+ const stopped = await changeSuspension(c.env, auditFn(c), me, target, cleanReason(f.s("reason")), "web");
1653+ flash(c, "notice", `Actions are suspended for ${target.name}.${stoppedNote(stopped)}`);
1654+ });
1655+ });
1656+ post(web, `${KS}/repos/:owner/:repo/lift`, async (c) => {
1657+ const me = requireSiteAdmin(c);
1658+ return act(c, KS, async () => {
1659+ const target = await repoTarget(c, prm(c, "owner"), prm(c, "repo"));
1660+ await changeSuspension(c.env, auditFn(c), me, target, null, "web");
1661+ flash(c, "notice", `Actions are no longer suspended for ${target.name}.`);
1662+ });
1663+ });
1664+
15911665 web.get("/settings/audit", async (c) => {
15921666 const me = requireSiteAdmin(c);
15931667 const rows = await c.get("dir").auditLog(200);
modifiedapps/diffinery/src/web/actions.tsx+3−0View file
@@ -11,6 +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";
1415 import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js";
1516
1617 type Loaded = Awaited<ReturnType<typeof loadRepo>>;
@@ -221,6 +222,7 @@
221222 </section>
222223 </aside>
223224 <div>
225+ <ActionsBlockedBanner env={c.env} repoId={repo.id} />
224226 <div class="row between mb">
225227 <h1 class="m0">{selected ? selected.name : "All workflows"}</h1>
226228 {selected ? <span class="mono muted small">{selected.path}</span> : null}
@@ -362,6 +364,7 @@
362364 c,
363365 { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined },
364366 <div data-fragment="run">
367+ <ActionsBlockedBanner env={c.env} repoId={repo.id} />
365368 <div class="row between mb" style="align-items:flex-start">
366369 <div class="grow">
367370 <div class="muted small">
addedapps/diffinery/src/web/kill-switches.tsx+173−0View file
@@ -1,0 +1,173 @@
1+/** @jsxImportSource hono/jsx */
2+import type { FC } from "hono/jsx";
3+import type { Env } from "../env.js";
4+import { computePause, killSwitches, suspendedList, type Suspension } from "../lib/killswitch.js";
5+import { Form, Time } from "./ui.js";
6+
7+/**
8+ * Kill switches in the web UI (lib/killswitch.ts): the banners people see while one is on, and
9+ * the **Kill switches** page of site administration (its routes are in account.tsx).
10+ */
11+
12+const By: FC<{ s: Suspension }> = ({ s }) => (
13+ <>
14+ {s.by ? <> by {s.by}</> : null} <Time at={s.at} />
15+ </>
16+);
17+
18+/** On the admin pages: hosted compute is paused, so nobody forgets it is. */
19+export const ComputePausedBanner: FC<{ env: Env }> = async ({ env }) => {
20+ const p = await computePause(env).catch(() => null);
21+ if (!p) return <></>;
22+ return (
23+ <div class="flash error" role="status" data-kill="paused">
24+ <b>Hosted compute is paused on this instance.</b> Hosted Actions jobs fail without starting and no agent sandbox can start; self-hosted runners still work. Paused
25+ <By s={p} />
26+ {p.reason ? <>: {p.reason}</> : null}. <a href="/settings/admin/kill-switches">Kill switches</a>
27+ </div>
28+ );
29+};
30+
31+/** 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);
34+ if (!k) return <></>;
35+ const s = k.repo ?? k.account;
36+ if (s)
37+ return (
38+ <div class="flash error" role="status" data-kill="suspended">
39+ <b>Actions are suspended {k.repo ? "for this repository" : <>for every repository of {k.account!.login}</>} by a site administrator</b>
40+ {s.reason ? <>: {s.reason}</> : null}. No workflow runs start, queued jobs fail, and agent sandboxes can't start until a site administrator lifts the suspension. Suspended
41+ <By s={s} />.
42+ </div>
43+ );
44+ if (k.paused)
45+ return (
46+ <div class="flash error" role="status" data-kill="paused">
47+ <b>Hosted runners are paused on this instance by a site administrator</b>
48+ {k.paused.reason ? <>: {k.paused.reason}</> : null}. Jobs on hosted runners fail without starting and agent sandboxes can't start; jobs on self-hosted runners still run.
49+ </div>
50+ );
51+ return <></>;
52+};
53+
54+/** The body of Settings → Kill switches. */
55+export const KillSwitchesPage: FC<{ env: Env; t: string }> = async ({ env, t }) => {
56+ const [paused, list] = await Promise.all([computePause(env), suspendedList(env)]);
57+ return (
58+ <>
59+ <h1>Kill switches</h1>
60+ <p class="muted">
61+ Stop Actions and agent sandboxes quickly: for one repository, for every repository of an account, or hosted compute on the whole instance. Turning one on stops what is running at once; turning it off only lets new work start. Nobody is exempt, site administrators included. Every change is in the{" "}
62+ <a href="/settings/audit">audit log</a>.
63+ </p>
64+ <div class="box">
65+ <div class="head">
66+ <h2>Hosted compute</h2>
67+ {paused ? <span class="badge red">paused</span> : <span class="badge green">running</span>}
68+ </div>
69+ <div class="body">
70+ {paused ? (
71+ <>
72+ <p class="m0">
73+ Paused
74+ <By s={paused} />
75+ {paused.reason ? <>: {paused.reason}</> : null}.
76+ </p>
77+ <Form action="/settings/admin/kill-switches/resume" t={t} class="mt">
78+ <button class="btn primary">Resume hosted compute</button>
79+ </Form>
80+ </>
81+ ) : (
82+ <Form action="/settings/admin/kill-switches/pause" t={t} confirm="Pause hosted compute on the whole instance? Running hosted jobs and open sandboxes stop now.">
83+ <div class="formrow">
84+ <div class="field grow">
85+ <label for="pause-reason">Reason</label>
86+ <input id="pause-reason" name="reason" required maxlength={500} placeholder="e.g. abuse investigation, cost overrun" />
87+ </div>
88+ <button class="btn danger">Pause hosted compute</button>
89+ </div>
90+ <div class="hint">Hosted Actions jobs fail without starting and no agent sandbox can start, in every repository. Self-hosted runners keep working.</div>
91+ </Form>
92+ )}
93+ </div>
94+ </div>
95+ <Form action="/settings/admin/kill-switches/accounts" t={t} class="box" confirm="Suspend Actions for every repository of this account? Its runs are cancelled and its sandboxes closed now.">
96+ <div class="head">
97+ <h2>Suspend Actions for an account</h2>
98+ </div>
99+ <div class="body">
100+ <div class="formrow">
101+ <div class="field">
102+ <label for="ks-login">Person or organization</label>
103+ <input id="ks-login" name="login" required placeholder="login" autocomplete="off" />
104+ </div>
105+ <div class="field grow">
106+ <label for="ks-areason">Reason</label>
107+ <input id="ks-areason" name="reason" required maxlength={500} />
108+ </div>
109+ <button class="btn danger">Suspend</button>
110+ </div>
111+ <div class="hint">Every repository the account owns: no workflow runs (hosted or self-hosted) and no agent sandboxes.</div>
112+ </div>
113+ </Form>
114+ <Form action="/settings/admin/kill-switches/repos" t={t} class="box" confirm="Suspend Actions for this repository? Its runs are cancelled and its sandboxes closed now.">
115+ <div class="head">
116+ <h2>Suspend Actions for a repository</h2>
117+ </div>
118+ <div class="body">
119+ <div class="formrow">
120+ <div class="field">
121+ <label for="ks-repo">Repository</label>
122+ <input id="ks-repo" name="repo" required placeholder="owner/name" autocomplete="off" />
123+ </div>
124+ <div class="field grow">
125+ <label for="ks-rreason">Reason</label>
126+ <input id="ks-rreason" name="reason" required maxlength={500} />
127+ </div>
128+ <button class="btn danger">Suspend</button>
129+ </div>
130+ </div>
131+ </Form>
132+ <div class="box">
133+ <div class="head">
134+ <h2>Suspended</h2>
135+ <span class="badge">{list.accounts.length + list.repos.length}</span>
136+ </div>
137+ {list.accounts.length + list.repos.length ? (
138+ <ul class="list">
139+ {list.accounts.map((a) => (
140+ <li>
141+ <div class="grow">
142+ <b>{a.login}</b> <span class="badge">{a.kind === "org" ? "organization" : "person"}</span> <span class="muted small">every repository · suspended<By s={a} /></span>
143+ {a.reason ? <div class="meta">{a.reason}</div> : null}
144+ </div>
145+ <Form action={`/settings/admin/kill-switches/accounts/${encodeURIComponent(a.login)}/lift`} t={t} class="inline">
146+ <button class="btn sm">Lift</button>
147+ </Form>
148+ </li>
149+ ))}
150+ {list.repos.map((r) => (
151+ <li>
152+ <div class="grow">
153+ <a href={`/${r.owner}/${r.name}/actions`}>
154+ <b>
155+ {r.owner}/{r.name}
156+ </b>
157+ </a>{" "}
158+ <span class="badge">repository</span> <span class="muted small">suspended<By s={r} /></span>
159+ {r.reason ? <div class="meta">{r.reason}</div> : null}
160+ </div>
161+ <Form action={`/settings/admin/kill-switches/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.name)}/lift`} t={t} class="inline">
162+ <button class="btn sm">Lift</button>
163+ </Form>
164+ </li>
165+ ))}
166+ </ul>
167+ ) : (
168+ <div class="empty">No account or repository has its Actions suspended.</div>
169+ )}
170+ </div>
171+ </>
172+ );
173+};
modifiedapps/diffinery/src/web/repo-settings.tsx+5−1View file
@@ -10,6 +10,7 @@
1010 import { ICONS } from "./assets.js";
1111 import { KCheckbox, KRadio, KSelect, type KOption } from "./kumo.js";
1212 import { csrfToken, type FormData } from "./csrf.js";
13+import { ActionsBlockedBanner } from "./kill-switches.js";
1314 import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js";
1415
1516 type Loaded = Awaited<ReturnType<typeof loadRepo>>;
@@ -59,7 +60,10 @@
5960 </div>
6061 </section>
6162 </aside>
62- <div>{body}</div>
63+ <div>
64+ <ActionsBlockedBanner env={c.env} repoId={l.repo.id} />
65+ {body}
66+ </div>
6367 </div>,
6468 );
6569 }
modifiedapps/diffinery/test/e2e/security.test.mjs+107−0View file
@@ -20,6 +20,8 @@
2020 // images) that only readers can follow; emptied organizations can be deleted.
2121 // Agents land changes on protected branches only through auto-merge (or the
2222 // merge queue) where a person allowed it and a required check gates it, never by bypass.
23+// Site administrators' kill switches stop Actions and sandboxes for a repository, an
24+// account or (hosted compute) the whole instance.
2325 import { spawn, spawnSync, execFileSync } from "node:child_process";
2426 import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
2527 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -2414,3 +2416,108 @@
24142416 async function setRitaRepos(n) {
24152417 await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: n }, T.sully);
24162418 }
2419+
2420+test("security: kill switches suspend Actions for a repository or an account, and pause hosted compute on the instance", async () => {
2421+ const R = `/repos/${OWNER}/halted`;
2422+ const S = `/admin/repos/${OWNER}/halted/actions-suspension`;
2423+ const A = `/admin/accounts/${OWNER}/actions-suspension`;
2424+ const P = "/admin/hosted-compute-pause";
2425+ await repoWithReadme("halted");
2426+ const runs = async () => (await ok("GET", `${R}/actions/runs`)).workflow_runs;
2427+ const html = async (path, as = T.olive) => (await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${as}` } })).text();
2428+ const push = async (path, text) => ok("PUT", `${R}/contents/${path}`, { message: path, content: b64(text) });
2429+ // A self-hosted job that no runner takes yet: the run stays in progress.
2430+ await push(".github/workflows/ci.yml", "name: ci\non:\n push:\n workflow_dispatch:\n repository_dispatch:\njobs:\n build:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo hi\n");
2431+ const first = await until("a run waiting for a runner", async () => {
2432+ const [run] = await runs();
2433+ return run && (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs.length ? run : null;
2434+ });
2435+
2436+ try {
2437+ // ------------------------------------------------------------ only site administrators
2438+ for (const path of [S, A, P]) {
2439+ for (const method of ["GET", "PUT", "DELETE"]) assert.match((await status(403, method, path, method === "PUT" ? { reason: "mine" } : undefined, T.olive)).message, /only site administrators/);
2440+ await status(401, "PUT", path, { reason: "no token" }, null);
2441+ }
2442+ await status(422, "PUT", S, {}, T.sully);
2443+ await status(404, "PUT", `/admin/repos/${OWNER}/nowhere/actions-suspension`, { reason: "x" }, T.sully);
2444+ await status(404, "PUT", "/admin/accounts/olive-bot/actions-suspension", { reason: "x" }, T.sully);
2445+ assert.equal((await ok("GET", S, undefined, T.sully)).suspended, false);
2446+
2447+ // ------------------------------------------------------------ a repository: what runs stops, nothing new starts
2448+ const s = await ok("PUT", S, { reason: "crypto mining" }, T.sully);
2449+ assert.deepEqual([s.suspended, s.reason, s.suspended_by, s.stopped.runs, s.stopped.jobs], [true, "crypto mining", "sully", 1, 1]);
2450+ const cancelled = await until("the run to be cancelled", async () => {
2451+ const r = await ok("GET", `${R}/actions/runs/${first.id}`);
2452+ return r.status === "completed" ? r : null;
2453+ });
2454+ assert.equal(cancelled.conclusion, "cancelled");
2455+ assert.match(await html(`/${OWNER}/halted/actions/runs/${first.id}`), /Cancelled: Actions are suspended for this repository by a site administrator: crypto mining/);
2456+ assert.equal((await runnerClaim(R, ["linux"])).status, 204);
2457+ assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for this repository by a site administrator: crypto mining/);
2458+ assert.match((await status(403, "POST", `${R}/dispatches`, { event_type: "go" })).message, /suspended for this repository/);
2459+ assert.match((await status(403, "POST", `${R}/actions/runs/${first.id}/rerun`)).message, /suspended for this repository/);
2460+ assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /suspended for this repository/);
2461+ const before = (await runs()).length;
2462+ await push("one.txt", "one\n");
2463+ await sleep(3000);
2464+ assert.equal((await runs()).length, before, "a push starts no run while Actions are suspended");
2465+ for (const path of [`/${OWNER}/halted/actions`, `/${OWNER}/halted/settings`]) assert.match(await html(path), /Actions are suspended for this repository by a site administrator/, path);
2466+ assert.match(await html("/settings/admin/kill-switches", T.sully), new RegExp(`${OWNER}/halted`));
2467+ await until("the suspension's audit entry", async () => auditLog("actions.suspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully" && JSON.parse(e.detail).reason === "crypto mining"));
2468+
2469+ // Lifting it lets new work start; nothing restarts.
2470+ assert.equal((await ok("DELETE", S, undefined, T.sully)).suspended, false);
2471+ assert.equal((await ok("GET", `${R}/actions/runs/${first.id}`)).conclusion, "cancelled");
2472+ await push("two.txt", "two\n");
2473+ await until("a new run", async () => (await runs()).length > before);
2474+ await ok("POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" });
2475+ const sandbox = await api("POST", `${R}/diffinery/sandboxes`, {});
2476+ assert.notEqual(sandbox.status, 403, JSON.stringify(sandbox.json)); // 503 here: no sandbox pool under this test
2477+ await until("the lift's audit entry", async () => auditLog("actions.unsuspend").some((e) => e.target === `${OWNER}/halted` && e.actor_login === "sully"));
2478+
2479+ // ------------------------------------------------------------ an account: every repository it owns
2480+ const a = await ok("PUT", A, { reason: "abuse report" }, T.sully);
2481+ assert.deepEqual([a.account, a.suspended, a.reason], [OWNER, true, "abuse report"]);
2482+ assert.ok(a.stopped.runs >= 1, JSON.stringify(a.stopped));
2483+ assert.equal((await ok("GET", S, undefined, T.sully)).owner_suspended, true);
2484+ assert.match((await status(403, "POST", `${R}/actions/workflows/ci.yml/dispatches`, { ref: "main" })).message, /Actions are suspended for every repository of olive by a site administrator: abuse report/);
2485+ assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /every repository of olive/);
2486+ const during = (await runs()).length;
2487+ await push("three.txt", "three\n");
2488+ await sleep(3000);
2489+ assert.equal((await runs()).length, during);
2490+ assert.match(await html(`/${OWNER}/halted/actions`), /Actions are suspended for every repository of olive by a site administrator/);
2491+ assert.equal((await ok("DELETE", A, undefined, T.sully)).suspended, false);
2492+ await until("the account's audit entries", async () => auditLog("actions.suspend").some((e) => e.target === OWNER && JSON.parse(e.detail).kind === "account") && auditLog("actions.unsuspend").some((e) => e.target === OWNER));
2493+
2494+ // ------------------------------------------------------------ the instance: hosted compute stops, self-hosted runners go on
2495+ const p = await ok("PUT", P, { reason: "cost overrun" }, T.sully);
2496+ assert.deepEqual([p.paused, p.reason, p.paused_by], [true, "cost overrun", "sully"]);
2497+ assert.equal((await ok("GET", P, undefined, T.sully)).paused, true);
2498+ assert.match((await status(403, "POST", `${R}/diffinery/sandboxes`, {})).message, /Agent sandboxes are paused on this instance by a site administrator: cost overrun/);
2499+ assert.match(await html("/settings/admin", T.sully), /Hosted compute is paused on this instance/);
2500+ assert.match(await html(`/${OWNER}/halted/actions`), /Hosted runners are paused on this instance by a site administrator/);
2501+ await push(".github/workflows/hosted.yml", "name: hosted\non: push\njobs:\n cloud:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n");
2502+ const hosted = await until("the hosted job to fail", async () => {
2503+ const run = (await runs()).find((r) => r.name === "hosted");
2504+ if (!run) return null;
2505+ const [job] = (await ok("GET", `${R}/actions/runs/${run.id}/jobs`)).jobs;
2506+ return job?.status === "completed" ? { run: run.id, job } : null;
2507+ });
2508+ assert.equal(hosted.job.conclusion, "failure");
2509+ assert.match(await html(`/${OWNER}/halted/actions/runs/${hosted.run}`), /The job was not started: Hosted runners are paused on this instance by a site administrator: cost overrun/);
2510+ // The self-hosted job of the same push is still handed to a runner.
2511+ const claim = await until("a self-hosted job claimed", async () => {
2512+ const r = await runnerClaim(R, ["linux"]);
2513+ return r.status === 200 ? r.json : null;
2514+ });
2515+ await fetch(`${base}/runner/jobs/${claim.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${claim.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });
2516+ assert.equal((await ok("DELETE", P, undefined, T.sully)).paused, false);
2517+ assert.doesNotMatch(await html("/settings/admin", T.sully), /Hosted compute is paused/);
2518+ assert.notEqual((await api("POST", `${R}/diffinery/sandboxes`, {})).status, 403);
2519+ await until("the pause's audit entries", async () => auditLog("compute.pause").some((e) => e.actor_login === "sully" && JSON.parse(e.detail).reason === "cost overrun") && auditLog("compute.unpause").length > 0);
2520+ } finally {
2521+ for (const path of [S, A, P]) await api("DELETE", path, undefined, T.sully);
2522+ }
2523+});
modifiedapps/diffinery/test/e2e/tasks.test.mjs+34−0View file
@@ -1212,3 +1212,37 @@
12121212 assert.equal((await api("DELETE", "/diffinery/agents/term-bot")).status, 204);
12131213 await until("the second terminal to end", async () => /no longer have write access|ended/i.test((await page(`terminals/${secondId}`)).text) && !(await page(`terminals/${secondId}`)).text.includes("data-socket"), 150_000);
12141214 });
1215+
1216+test("a kill switch ends web terminals and refuses new ones until it's lifted", async () => {
1217+ const owner = (await ok("GET", "/user")).login;
1218+ assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-bot-2", name: "Term Bot 2" })).status, 201);
1219+ const page = (path) => fetch(`${base}/${owner}/term-demo/${path}`).then(async (r) => ({ status: r.status, text: await r.text() }));
1220+ async function form(path, fields) {
1221+ const res = await fetch(`${base}/${owner}/term-demo/terminals`);
1222+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
1223+ const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
1224+ return fetch(`${base}/${owner}/term-demo/${path}`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) });
1225+ }
1226+ const opened = await form("terminals", { agent: "term-bot-2", ref: "main" });
1227+ assert.equal(opened.status, 303, await opened.text());
1228+ const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop();
1229+ await until("the terminal to start", async () => (await page(`terminals/${id}`)).text.includes("data-socket"));
1230+ const url = `${base.replace("http", "ws")}/${owner}/term-demo/terminals/${id}/socket`;
1231+ const origin = new URL(base).origin;
1232+
1233+ // Suspending the repository's Actions ends the terminal, refuses attaching and opening another.
1234+ const S = `/admin/repos/${owner}/term-demo/actions-suspension`;
1235+ assert.equal((await api("PUT", S, { reason: "abuse report" })).status, 200);
1236+ const refused = await termSocket(url, { origin });
1237+ assert.ok([403, 409].includes(refused.status), JSON.stringify(refused));
1238+ const again = await form("terminals", { agent: "term-bot-2", ref: "main" });
1239+ assert.equal(again.status, 422);
1240+ assert.match(await again.text(), /Actions are suspended for this repository by a site administrator: abuse report/);
1241+ await until("the terminal to end", async () => /suspended Actions and sandboxes here|Its sandbox stopped/.test((await page(`terminals/${id}`)).text), 150_000);
1242+
1243+ // Lifted, a terminal opens again.
1244+ assert.equal((await api("DELETE", S)).status, 200);
1245+ const after = await form("terminals", { agent: "term-bot-2", ref: "main" });
1246+ assert.equal(after.status, 303, await after.text());
1247+ assert.equal((await form(`${new URL(after.headers.get("location"), base).pathname.split("/").slice(3).join("/")}/end`, {})).status, 303);
1248+});
addedapps/diffinery/test/unit/killswitch.test.ts+129−0View file
@@ -1,0 +1,129 @@
1+import { describe, expect, it } from "vitest";
2+import { blockedBy, changePause, changeSuspension, cleanReason, computePause, killSwitches, runningCompute, suspendedList, suspensionOf } from "../../src/lib/killswitch.js";
3+import type { Env } from "../../src/env.js";
4+import { testD1 } from "./stubs/d1.js";
5+
6+const at = "2026-10-01T00:00:00.000Z";
7+
8+/** A D1 with accounts ann (a person, site administrator) and acme (an organization), and repositories r1, r2 (acme's) and r3 (ann's); REPO answers stopActions. */
9+function setup() {
10+ const DB = testD1();
11+ const add = (id: string, kind: string, siteAdmin = 0) => DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, site_admin, created_at) VALUES (?, ?, ?, 'active', ?, ?)").run(id, id, kind, siteAdmin, at);
12+ add("ann", "user", 1);
13+ add("acme", "org");
14+ const repo = (id: string, owner: string) => DB.sqlite.prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, '', 'private', 'main', ?, ?)").run(id, owner, id, at, at);
15+ repo("r1", "acme");
16+ repo("r2", "acme");
17+ repo("r3", "ann");
18+ const stops: Array<{ repo: string; hostedOnly: boolean; message: string }> = [];
19+ const REPO = {
20+ idFromName: (n: string) => n,
21+ get: (id: string) => ({
22+ stopActions: async (o: { hostedOnly: boolean; message: string }) => {
23+ stops.push({ repo: id, ...o });
24+ if (id === "broken") throw new Error("gone");
25+ return { runs: 1, jobs: 2, sandboxes: 1 };
26+ },
27+ }),
28+ };
29+ const audits: Array<{ action: string; target?: string; detail?: unknown }> = [];
30+ const audit = async (e: { action: string; target?: string; detail?: unknown }) => void audits.push(e);
31+ return { env: { DB, REPO } as unknown as Env, DB, stops, audits, audit };
32+}
33+
34+const ann = { id: "ann", login: "ann" };
35+
36+describe("kill switches", () => {
37+ it("block nothing until one is on", async () => {
38+ const { env } = setup();
39+ const k = await killSwitches(env, "r1");
40+ expect(k).toEqual({ repo: null, account: null, paused: null });
41+ for (const w of ["run", "hosted-job", "self-hosted-job", "sandbox"] as const) expect(blockedBy(k, w)).toBeNull();
42+ });
43+
44+ it("suspending a repository stops all its work, and lifting it only allows new work", async () => {
45+ const { env, stops, audits, audit } = setup();
46+ const stopped = await changeSuspension(env, audit, ann, { kind: "repo", id: "r1", name: "acme/r1" }, "crypto mining", "api");
47+ expect(stopped).toEqual({ repositories: 1, runs: 1, jobs: 2, sandboxes: 1, failed: 0 });
48+ expect(stops).toEqual([{ repo: "r1", hostedOnly: false, message: "Actions are suspended for this repository by a site administrator: crypto mining" }]);
49+ const k = await killSwitches(env, "r1");
50+ expect(k.repo).toMatchObject({ by: "ann", reason: "crypto mining" });
51+ for (const w of ["run", "hosted-job", "self-hosted-job", "sandbox"] as const) expect(blockedBy(k, w)).toBe("Actions are suspended for this repository by a site administrator: crypto mining");
52+ // Its neighbour isn't.
53+ expect(blockedBy(await killSwitches(env, "r2"), "run")).toBeNull();
54+ expect(audits).toEqual([{ actorId: "ann", actorLogin: "ann", action: "actions.suspend", target: "acme/r1", detail: { kind: "repo", reason: "crypto mining", via: "api", stopped } }]);
55+
56+ expect(await changeSuspension(env, audit, ann, { kind: "repo", id: "r1", name: "acme/r1" }, null, "web")).toBeNull();
57+ expect(blockedBy(await killSwitches(env, "r1"), "run")).toBeNull();
58+ expect(stops).toHaveLength(1);
59+ expect(audits[1]).toEqual({ actorId: "ann", actorLogin: "ann", action: "actions.unsuspend", target: "acme/r1", detail: { kind: "repo", via: "web" } });
60+ // Lifting what isn't suspended changes nothing and isn't recorded.
61+ await changeSuspension(env, audit, ann, { kind: "repo", id: "r1", name: "acme/r1" }, null, "web");
62+ expect(audits).toHaveLength(2);
63+ });
64+
65+ it("suspending an account covers every repository it owns, even a site administrator's", async () => {
66+ const { env, stops, audit } = setup();
67+ await changeSuspension(env, audit, ann, { kind: "account", id: "acme", login: "acme" }, "abuse", "api");
68+ expect(stops.map((s) => s.repo).sort()).toEqual(["r1", "r2"]);
69+ expect(stops[0]!.message).toBe("Actions are suspended for every repository of acme by a site administrator: abuse");
70+ for (const r of ["r1", "r2"]) expect(blockedBy(await killSwitches(env, r), "self-hosted-job")).toMatch(/every repository of acme/);
71+ expect(blockedBy(await killSwitches(env, "r3"), "run")).toBeNull();
72+ // Nobody is exempt: ann is a site administrator.
73+ await changeSuspension(env, audit, ann, { kind: "account", id: "ann", login: "ann" }, "testing", "web");
74+ expect(blockedBy(await killSwitches(env, "r3"), "sandbox")).toMatch(/every repository of ann/);
75+ expect(await suspensionOf(env, { accountId: "acme" })).toMatchObject({ by: "ann", reason: "abuse" });
76+ const list = await suspendedList(env);
77+ expect(list.accounts.map((a) => a.login).sort()).toEqual(["acme", "ann"]);
78+ expect(list.repos).toEqual([]);
79+ });
80+
81+ it("pausing hosted compute stops hosted jobs and sandboxes only, in the repositories running some", async () => {
82+ const { env, DB, stops, audits, audit } = setup();
83+ const usage = (id: string, repo: string, kind: "job" | "sandbox", start: string, end: string | null = null) =>
84+ DB.sqlite.prepare("INSERT INTO compute_usage (id, account_id, repo_id, kind, started_at, ended_at) VALUES (?, 'acme', ?, ?, ?, ?)").run(id, repo, kind, start, end);
85+ const recent = new Date(Date.now() - 60_000).toISOString();
86+ usage("j1", "r1", "job", recent);
87+ usage("s1", "r2", "sandbox", recent, recent);
88+ usage("s1:1", "r2", "sandbox", recent); // woke up after sleeping
89+ usage("j2", "r3", "job", recent, recent); // ended
90+ usage("j3", "r3", "job", new Date(Date.now() - 8 * 3600_000).toISOString()); // lost its end long ago
91+ const running = await runningCompute(env);
92+ expect([...running.entries()].sort()).toEqual([
93+ ["r1", { jobs: ["j1"], sandboxes: [] }],
94+ ["r2", { jobs: [], sandboxes: ["s1"] }],
95+ ]);
96+
97+ const stopped = await changePause(env, audit, ann, "cost overrun", "api");
98+ expect(stopped).toMatchObject({ repositories: 2, failed: 0 });
99+ expect(stops.map((s) => [s.repo, s.hostedOnly]).sort()).toEqual([
100+ ["r1", true],
101+ ["r2", true],
102+ ]);
103+ expect(stops[0]!.message).toBe("Hosted runners are paused on this instance by a site administrator: cost overrun. Self-hosted runners still work");
104+ const k = await killSwitches(env, "r3");
105+ expect(blockedBy(k, "hosted-job")).toMatch(/^Hosted runners are paused on this instance by a site administrator: cost overrun/);
106+ expect(blockedBy(k, "sandbox")).toBe("Agent sandboxes are paused on this instance by a site administrator: cost overrun");
107+ // Self-hosted runners still work, and runs still start (for them).
108+ expect(blockedBy(k, "self-hosted-job")).toBeNull();
109+ expect(blockedBy(k, "run")).toBeNull();
110+ expect(await computePause(env)).toMatchObject({ by: "ann", reason: "cost overrun" });
111+ expect(audits[0]).toMatchObject({ action: "compute.pause", detail: { reason: "cost overrun", via: "api" } });
112+
113+ expect(await changePause(env, audit, ann, null, "web")).toBeNull();
114+ expect(await computePause(env)).toBeNull();
115+ expect(blockedBy(await killSwitches(env, "r3"), "hosted-job")).toBeNull();
116+ expect(audits[1]).toMatchObject({ action: "compute.unpause", detail: { via: "web" } });
117+ expect(stops).toHaveLength(2);
118+ });
119+
120+ it("counts repositories that couldn't be stopped, and needs a reason", async () => {
121+ const { env, DB, audit } = setup();
122+ DB.sqlite.prepare("INSERT INTO compute_usage (id, account_id, repo_id, kind, started_at) VALUES ('j', 'acme', 'broken', 'job', ?)").run(new Date().toISOString());
123+ expect(await changePause(env, audit, ann, "x", "api")).toMatchObject({ repositories: 0, failed: 1 });
124+ expect(() => cleanReason(" ")).toThrow(/give a reason/);
125+ expect(() => cleanReason(undefined)).toThrow(/give a reason/);
126+ expect(() => cleanReason("x".repeat(501))).toThrow(/at most 500/);
127+ expect(cleanReason(" too many\nspaces ")).toBe("too many spaces");
128+ });
129+});