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.

Per-account quotas on hosted minutes, running jobs, sandboxes and repositories (#121)

The account that owns a repository pays: hosted Actions jobs and agent
sandboxes count toward its monthly hosted minutes; running hosted jobs past
its limit wait in the queue; sandboxes past its limit are refused; and new,
imported or transferred repositories past its limit are refused. Defaults
come from QUOTA_* and a site administrator can override them per account.
Accounts run by a site administrator have no quotas.


Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent e162310commit 1631fd0e91ba4585bdda7ae017597fdb13c92e82Browse files
16 files changed+523−9
Files (16)
modifiedapps/diffinery/docs/reference/limits.md+12−1View file
@@ -1,6 +1,6 @@
11 ---
22 title: Limits
3-description: Every hard limit in Diffinery and where it applies: sign-in, tokens, API, search, archives, releases, git, Actions, sandboxes, agent tasks, agent review, packages, imports, email.
3+description: Every hard limit in Diffinery and where it applies: account quotas, sign-in, tokens, API, search, archives, releases, git, Actions, sandboxes, agent tasks, agent review, packages, imports, email.
44 ---
55
66 This page lists the fixed limits built into Diffinery: sizes, counts, timeouts and lifetimes. Use it when a push, a workflow, an upload or an API call fails with a size or count error, or when you plan something large. Values come from the code of the running version. Where a limit comes from Cloudflare rather than from Diffinery, the table says so.
@@ -166,6 +166,17 @@
166166 | Merge queue build concurrency | 1 to 20, default 5 | How many merge groups a queue builds and tests at the same time (`mergeQueue.buildConcurrency`). |
167167 | Merge queue check timeout | 1 to 360 minutes, default 60 | How long a merge group's required checks may take before the pull request leaves the queue (`mergeQueue.checkTimeoutMinutes`). |
168168
169+## Account quotas
170+
171+Quotas are per account, and the account that pays is the one that owns the repository (a person or an organization), whoever started the work. A site administrator can change any of them for one account (`PATCH /api/v3/admin/accounts/{login}/quotas`), and an operator can change the defaults with the `QUOTA_*` variables in [Configuration](/docs/self-hosting/configuration). Accounts run by a site administrator have no quotas: their own account, and organizations with a site administrator as an owner. See what an account uses with `GET /api/v3/user/usage` or `GET /api/v3/orgs/{org}/usage` (owners and admins).
172+
173+| Quota | Default | What happens past it |
174+| --- | --- | --- |
175+| Hosted minutes per month | 3,000 | Hosted Actions jobs and agent sandboxes (including agent tasks and reviews) add up their running time, rounded up to the minute, per calendar month in UTC. Once it is used up, a hosted job fails without starting ("The job was not started: this account has used its 3000 hosted minutes for this month ..."), and a new sandbox is refused with `429`. Self-hosted runners don't count. |
176+| Hosted jobs at once | 5 | Across all the account's repositories. Further jobs wait as queued and start, oldest first, as running ones finish (checked at least once a minute). A job that waits 6 hours fails with "The job waited 6 hours for one of this account's hosted jobs to finish, and was not started." |
177+| Agent sandboxes open at once | 5 | Across all the account's repositories, including agent tasks' and reviews' sandboxes. A new one is refused with `429 this account already has 5 agent sandboxes open; close one or wait for one to finish`. |
178+| Repositories | 500 | Creating, importing or transferring in a repository past it is refused with `403 <account> has reached its limit of 500 repositories; delete one or ask a site administrator to raise it`. |
179+
169180 ## Actions
170181
171182 | Limit | Value | Applies to |
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
@@ -105,6 +105,7 @@
105105 | `org.member.set`, `org.member.remove` | A site administrator adds themselves with **Add people**, a member's role is changed, or a member is removed | the organization's login | `user`, `role` |
106106 | `invite.create` | A site admin creates an invite from **Site administration** | the email address | none |
107107 | `site.invite` | A site admin invites someone with `POST /api/v3/invitations` | none | `email` |
108+| `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` |
108109
109110 ### Repositories
110111
modifiedapps/diffinery/docs/self-hosting/configuration.md+1−0View file
@@ -29,6 +29,7 @@
2929 | `ARTIFACT_QUOTA_BYTES` | Not set | No | Workflow artifact storage per repository, in bytes: artifacts that haven't expired plus uploads in progress. Default `10737418240` (10 GB). Anything that isn't a positive whole number counts as not set. Add it under `vars` in `wrangler.jsonc`; the deploy keeps it. When a repository reaches it, uploads fail with `413 the repository's artifact storage quota (N bytes) is used up; delete artifacts or wait for them to expire`. See [Caches and artifacts](/docs/reference/limits#caches-and-artifacts). |
3030 | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). |
3131 | `IMPORT_MAX_REPO_KB` | Not set | No | The largest GitHub repository a one-repository import accepts, in KB as GitHub reports a repository's `size`. Default `10485760` (10 GB). An empty value, `0`, a negative number or text that isn't a number counts as not set. A larger source is refused before the import starts with `422 the repository is about N GB, over the M GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. See [GitHub imports](#github-imports). |
32+| `QUOTA_HOSTED_MINUTES`, `QUOTA_HOSTED_JOBS`, `QUOTA_SANDBOXES`, `QUOTA_REPOSITORIES` | Not set | No | The default per-account quotas: hosted minutes per month (default `3000`), hosted Actions jobs at once (`5`), agent sandboxes open at once (`5`) and repositories (`500`). `-1` is unlimited; text that isn't a whole number counts as not set. Site administrators change them for one account with `PATCH /api/v3/admin/accounts/{login}/quotas`, and accounts run by a site administrator have none. See [Limits](/docs/reference/limits#account-quotas). |
3233 | `IMPORT_GHE_HOST` | Not set | No | The host name of a GitHub Enterprise Server (like `ghe.example.com`) that imports may download release assets from. Without it, imports download release assets only from github.com and its asset hosts, and assets of an Enterprise import are skipped with a warning. |
3334 | `GITHUB_OAUTH_CLIENT_ID` | Not set | No | The client ID of a GitHub OAuth app, for linking GitHub accounts with a GitHub sign-in instead of a gist. Needs `GITHUB_OAUTH_CLIENT_SECRET` too. See [GitHub imports](#github-imports). |
3435 | `IMPORT_GIT_AGENT` | Not set in production; `external` in the `dev` environment | Never in production | With `external`, an import's Git transfer doesn't start a container: it waits for an import agent started by hand (see [The dev environment](#the-dev-environment)). |
addedapps/diffinery/migrations/0018_quotas.sql+23−0View file
@@ -1,0 +1,23 @@
1+-- Hosted compute an account pays for (the owner of the repository): Actions jobs on hosted runners
2+-- and agent sandboxes. A row is open until the job or sandbox ends (lib/quotas.ts).
3+CREATE TABLE IF NOT EXISTS compute_usage (
4+ id TEXT PRIMARY KEY,
5+ account_id TEXT NOT NULL,
6+ repo_id TEXT NOT NULL,
7+ kind TEXT NOT NULL,
8+ started_at TEXT NOT NULL,
9+ ended_at TEXT
10+);
11+CREATE INDEX IF NOT EXISTS compute_usage_open ON compute_usage(account_id, kind, ended_at);
12+CREATE INDEX IF NOT EXISTS compute_usage_started ON compute_usage(account_id, started_at);
13+
14+-- A site administrator's per-account overrides of the default quotas; NULL keeps the default, -1 is unlimited.
15+CREATE TABLE IF NOT EXISTS account_quotas (
16+ account_id TEXT PRIMARY KEY REFERENCES accounts(id),
17+ hosted_minutes INTEGER,
18+ hosted_jobs INTEGER,
19+ sandboxes INTEGER,
20+ repositories INTEGER,
21+ updated_at TEXT NOT NULL,
22+ updated_by TEXT
23+);
modifiedapps/diffinery/src/docs/api-meta.ts+22−0View file
@@ -45,6 +45,10 @@
4545 "GET /user/memberships/orgs": { scope: "read" },
4646 "GET /user/memberships/orgs/{org}": { scope: "read" },
4747 "PATCH /user/memberships/orgs/{org}": { scope: "write" },
48+ "GET /user/usage": { scope: "read", anyToken: true },
49+ "GET /orgs/{org}/usage": { scope: "read", anyToken: true },
50+ "GET /admin/accounts/{login}/quotas": { scope: "read", anyToken: true },
51+ "PATCH /admin/accounts/{login}/quotas": { scope: "admin" },
4852 "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" },
4953 "GET /invitations": { scope: "read" },
5054 "DELETE /invitations/{id}": { scope: "admin" },
@@ -124,6 +128,24 @@
124128 },
125129 "DELETE /user/avatar": { summary: "Remove your profile picture", description: "Diffinery only. Goes back to generated initials and returns the `avatar_url`. Same token rules as setting one. Audited.", access: "account" },
126130 "GET /users/{login}": { summary: "Get an account", description: "A user, organization or agent by login.", access: "account" },
131+ "GET /user/usage": {
132+ summary: "Get your quotas and usage",
133+ description: "Hosted minutes used this month, hosted jobs running, sandboxes open and repositories owned, each with its `limit` (`-1` is unlimited), and `exempt` for accounts run by a site administrator. An agent gets its owner's. See [Limits](/docs/reference/limits#account-quotas).",
134+ access: "account",
135+ },
136+ "GET /orgs/{org}/usage": { summary: "Get an organization's quotas and usage", description: "The same as `GET /user/usage`, for the organization's owners and admins.", access: "org-admin" },
137+ "GET /admin/accounts/{login}/quotas": { summary: "Get an account's quotas and usage", description: "Site administrators only.", access: "site-admin" },
138+ "PATCH /admin/accounts/{login}/quotas": {
139+ summary: "Change an account's quotas",
140+ description: "Site administrators only. Send only the quotas to change: a whole number, `-1` for unlimited, or `null` for the default. Audited as `account.quotas.set`.",
141+ access: "site-admin",
142+ body: {
143+ hosted_minutes: ["integer", "Hosted minutes per calendar month (UTC)."],
144+ hosted_jobs: ["integer", "Hosted Actions jobs running at once."],
145+ sandboxes: ["integer", "Agent sandboxes open at once."],
146+ repositories: ["integer", "Repositories the account owns."],
147+ },
148+ },
127149 "GET /user/orgs": { summary: "List your organizations", description: "Each organization carries your `role` in it (member, admin or owner).", access: "account" },
128150 "GET /user/repository_invitations": { summary: "List your repository invitations", description: "Pending invitations to repositories, sent to your username or your email address.", access: "account" },
129151 "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/env.ts+5−0View file
@@ -44,6 +44,11 @@
4444 IMPORTER?: DurableObjectNamespace<ImportContainer>;
4545 /** "external": an import agent started outside the Worker claims the Git transfer (local development and tests). */
4646 IMPORT_GIT_AGENT?: string;
47+ /** Default per-account quotas (lib/quotas.ts); -1 is unlimited. Site administrators override them per account. */
48+ QUOTA_HOSTED_MINUTES?: string;
49+ QUOTA_HOSTED_JOBS?: string;
50+ QUOTA_SANDBOXES?: string;
51+ QUOTA_REPOSITORIES?: string;
4752 /** Largest source repository an import will take, in KB (GitHub `size`); default 10 GB. */
4853 IMPORT_MAX_REPO_KB?: string;
4954 /** Optional GitHub OAuth app, for linking an account through the web flow instead of a gist. */
modifiedapps/diffinery/src/http/api.ts+53−0View file
@@ -4,6 +4,7 @@
44 import { callerFor, parseScopes, requireAccount, requireAccountScope, requireBrowserSession, requireRecentSignIn, requireUnlimitedToken, tokenRepos, type AppContext, type AppVars } from "../auth/principal.js";
55 import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js";
66 import { encryptSecret } from "../lib/crypto.js";
7+import { monthStart, quotasFor, setQuotas, usageOf } from "../lib/quotas.js";
78 import { publicJwk } from "../lib/jwt.js";
89 import { HttpError, b64, fromB64 } from "../lib/util.js";
910 import { atLeast, ROLE_LEVEL, type ProtectionRules, type Role } from "../repo/types.js";
@@ -78,6 +79,58 @@
7879 if (!a) throw new HttpError(404, "not found");
7980 return c.json(userJson(c, a));
8081 });
82+// Quotas and what an account uses of them (lib/quotas.ts): your own, an organization's for its owners
83+// and admins, and any account's for site administrators, who can also change them.
84+const usageJson = async (c: AppContext, account: Account) => {
85+ const [q, u] = await Promise.all([quotasFor(c.env, account.id), usageOf(c.env, account.id)]);
86+ return {
87+ account: account.login,
88+ exempt: q.exempt,
89+ period_start: monthStart(),
90+ hosted_minutes: { used: u.hosted_minutes, limit: q.hosted_minutes },
91+ hosted_jobs: { running: u.hosted_jobs, limit: q.hosted_jobs },
92+ sandboxes: { open: u.sandboxes, limit: q.sandboxes },
93+ repositories: { count: u.repositories, limit: q.repositories },
94+ };
95+};
96+api.get("/user/usage", async (c) => {
97+ requireAccountScope(c, "read");
98+ const me = requireAccount(c);
99+ return c.json(await usageJson(c, me.kind === "agent" && me.owner_id ? ((await c.get("dir").account(me.owner_id)) ?? me) : me));
100+});
101+api.get("/orgs/:org/usage", async (c) => {
102+ requireAccountScope(c, "read");
103+ const a = requireAccount(c);
104+ const dir = c.get("dir");
105+ const org = await dir.accountByLogin(c.req.param("org"));
106+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
107+ const role = await dir.orgRole(org.id, a.id);
108+ if (!role && !a.site_admin) throw new HttpError(404, "not found");
109+ if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "only organization owners and admins can see its usage");
110+ return c.json(await usageJson(c, org));
111+});
112+const quotaTarget = async (c: AppContext) => {
113+ const a = requireAccount(c);
114+ if (!a.site_admin) throw new HttpError(403, "only site administrators can see and change quotas");
115+ const account = await c.get("dir").accountByLogin(c.req.param("login") ?? "");
116+ if (!account || account.kind === "agent") throw new HttpError(404, "no person or organization with that name");
117+ return { a, account };
118+};
119+api.get("/admin/accounts/:login/quotas", async (c) => {
120+ requireAccountScope(c, "read");
121+ const { account } = await quotaTarget(c);
122+ return c.json(await usageJson(c, account));
123+});
124+api.patch("/admin/accounts/:login/quotas", async (c) => {
125+ requireAccountScope(c, "admin");
126+ requireUnlimitedToken(c, "change quotas");
127+ const { a, account } = await quotaTarget(c);
128+ const b = await body<Record<string, number | null>>(c);
129+ if (!b || typeof b !== "object" || Array.isArray(b)) throw new HttpError(422, "send the quotas to change, e.g. {\"hosted_minutes\": 6000}");
130+ await setQuotas(c.env, account.id, b, a.login);
131+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "account.quotas.set", target: account.login, detail: b }));
132+ return c.json(await usageJson(c, account));
133+});
81134 api.get("/user/orgs", async (c) => c.json((await c.get("dir").orgsFor(requireAccount(c).id)).map((o) => ({ ...userJson(c, o), role: o.role }))));
82135 api.post("/user/orgs", async (c) => {
83136 requireAccountScope(c, "admin");
modifiedapps/diffinery/src/lib/directory.ts+3−0View file
@@ -6,6 +6,7 @@
66 import { enqueueMail } from "../mail/send.js";
77 import { archivePrefix, deletePrefixes } from "./storage-keys.js";
88 import { inviteProof } from "../auth/invite-proof.js";
9+import { checkRepositoryQuota } from "./quotas.js";
910
1011 export interface Account {
1112 id: string;
@@ -678,6 +679,7 @@
678679 if (role === "member") creatorGrant = subject;
679680 } else if (owner.id !== creator.id && !creator.site_admin) throw new HttpError(403, "you can only create repositories for yourself or your organizations");
680681 if (await this.repo(owner.login, input.name)) throw new HttpError(422, "a repository with this name already exists");
682+ await checkRepositoryQuota(this.env, owner.id, owner.login);
681683 const id = newId();
682684 const t = now();
683685 const branch = input.defaultBranch || "main";
@@ -759,6 +761,7 @@
759761 const name = newName?.trim() || repo.name;
760762 if (!isValidName(name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'", { errors: [{ resource: "Repository", code: "invalid", field: "new_name" }] });
761763 if (await this.repo(target.login, name)) throw new HttpError(422, `${target.login} already has a repository named ${name}`, { errors: [{ resource: "Repository", code: "custom", field: "name", message: "name already exists on this account" }] });
764+ await checkRepositoryQuota(this.env, target.id, target.login);
762765 // Packages linked to the repository move with it; an image of the same name under the target would collide.
763766 const clash = await this.db
764767 .prepare("SELECT p.name FROM packages p WHERE p.owner_id = ? AND p.name IN (SELECT name FROM packages WHERE repo_id = ?) LIMIT 1")
addedapps/diffinery/src/lib/quotas.ts+145−0View file
@@ -1,0 +1,145 @@
1+import type { Env } from "../env.js";
2+import { HttpError, now } from "./util.js";
3+
4+/**
5+ * Per-account quotas on what Diffinery runs and keeps for an account. The account that pays is the
6+ * owner of the repository (a person or an organization), whoever started the work.
7+ *
8+ * - hosted_minutes: minutes of hosted Actions jobs and agent sandboxes per calendar month (UTC).
9+ * - hosted_jobs: hosted Actions jobs running at once across the account's repositories; more wait.
10+ * - sandboxes: agent sandboxes open at once across the account's repositories.
11+ * - repositories: repositories the account owns.
12+ *
13+ * Defaults come from the environment (QUOTA_*) and otherwise DEFAULT_QUOTAS; a site administrator
14+ * can override them per account (account_quotas). -1 means unlimited. Accounts run by a site
15+ * administrator (one, or an organization with one as owner) have no quotas, so the instance's own
16+ * work never stops on them.
17+ */
18+export type QuotaKey = "hosted_minutes" | "hosted_jobs" | "sandboxes" | "repositories";
19+export const QUOTA_KEYS: readonly QuotaKey[] = ["hosted_minutes", "hosted_jobs", "sandboxes", "repositories"];
20+export type Quotas = Record<QuotaKey, number>;
21+
22+export const DEFAULT_QUOTAS: Quotas = { hosted_minutes: 3000, hosted_jobs: 5, sandboxes: 5, repositories: 500 };
23+const ENV_KEYS: Record<QuotaKey, string> = { hosted_minutes: "QUOTA_HOSTED_MINUTES", hosted_jobs: "QUOTA_HOSTED_JOBS", sandboxes: "QUOTA_SANDBOXES", repositories: "QUOTA_REPOSITORIES" };
24+
25+/**
26+ * The longest a job or sandbox can run (a job's 6 hours plus start-up, a sandbox's 24-hour token):
27+ * a row still open after that is counted as ended then, so a lost end can't hold a slot forever.
28+ */
29+export const MAX_RUN_MS: Record<"job" | "sandbox", number> = { job: 7 * 3600_000, sandbox: 24 * 3600_000 };
30+
31+const UNLIMITED: Quotas = { hosted_minutes: -1, hosted_jobs: -1, sandboxes: -1, repositories: -1 };
32+
33+function envDefault(env: Env, k: QuotaKey): number {
34+ const raw = (env as unknown as Record<string, string | undefined>)[ENV_KEYS[k]];
35+ const n = raw === undefined || raw === "" ? NaN : Math.trunc(Number(raw));
36+ return Number.isFinite(n) && n >= -1 ? n : DEFAULT_QUOTAS[k];
37+}
38+
39+/** Whether the account is run by a site administrator (itself, or an organization with one as owner). */
40+async function adminRun(env: Env, accountId: string): Promise<boolean> {
41+ const r = await env.DB.prepare(
42+ "SELECT 1 FROM accounts a WHERE a.id = ? AND (a.site_admin = 1 OR EXISTS (SELECT 1 FROM org_members m JOIN accounts o ON o.id = m.user_id WHERE m.org_id = a.id AND m.role = 'owner' AND o.site_admin = 1 AND o.status = 'active'))",
43+ )
44+ .bind(accountId)
45+ .first();
46+ return !!r;
47+}
48+
49+/** The quotas that apply to an account, and whether they're its own (overridden) or the defaults. */
50+export async function quotasFor(env: Env, accountId: string): Promise<Quotas & { exempt: boolean }> {
51+ if (await adminRun(env, accountId)) return { ...UNLIMITED, exempt: true };
52+ const o = await env.DB.prepare("SELECT * FROM account_quotas WHERE account_id = ?").bind(accountId).first<Record<QuotaKey, number | null>>();
53+ const q = {} as Quotas;
54+ for (const k of QUOTA_KEYS) q[k] = o?.[k] ?? envDefault(env, k);
55+ return { ...q, exempt: false };
56+}
57+
58+/** Start of the current calendar month (UTC). */
59+export function monthStart(at = new Date()): string {
60+ return new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), 1)).toISOString();
61+}
62+
63+export interface Usage {
64+ /** Whole minutes of hosted jobs and sandboxes this month, rounded up per job or sandbox. */
65+ hosted_minutes: number;
66+ hosted_jobs: number;
67+ sandboxes: number;
68+ repositories: number;
69+}
70+
71+/** What an account uses now. */
72+export async function usageOf(env: Env, accountId: string, at = new Date()): Promise<Usage> {
73+ const t = at.getTime();
74+ const month = monthStart(at);
75+ // Anything that started earlier ended before the month began (or is counted as ended by then).
76+ const rows = await env.DB.prepare("SELECT kind, started_at, ended_at FROM compute_usage WHERE account_id = ? AND started_at >= ?")
77+ .bind(accountId, new Date(Math.min(Date.parse(month), t) - MAX_RUN_MS.sandbox).toISOString())
78+ .all<{ kind: "job" | "sandbox"; started_at: string; ended_at: string | null }>();
79+ let minutes = 0;
80+ let jobs = 0;
81+ let sandboxes = 0;
82+ for (const r of rows.results) {
83+ const start = Date.parse(r.started_at);
84+ const cap = start + MAX_RUN_MS[r.kind];
85+ const end = Math.min(r.ended_at ? Date.parse(r.ended_at) : t, cap);
86+ const from = Math.max(start, Date.parse(month));
87+ if (end > from) minutes += Math.ceil((end - from) / 60_000);
88+ if (!r.ended_at && t < cap) {
89+ if (r.kind === "job") jobs++;
90+ else sandboxes++;
91+ }
92+ }
93+ const repos = await env.DB.prepare("SELECT count(*) AS n FROM repos WHERE owner_id = ?").bind(accountId).first<{ n: number }>();
94+ return { hosted_minutes: minutes, hosted_jobs: jobs, sandboxes, repositories: repos?.n ?? 0 };
95+}
96+
97+const within = (limit: number, used: number) => limit < 0 || used < limit;
98+
99+/**
100+ * Whether the account may start a hosted job or a sandbox now: "ok", "wait" (a job waits for one of
101+ * the account's running jobs to end), or an error message (minutes used up, or too many sandboxes).
102+ */
103+export async function mayStart(env: Env, accountId: string, kind: "job" | "sandbox"): Promise<{ ok: true } | { ok: false; wait: boolean; message: string }> {
104+ const q = await quotasFor(env, accountId);
105+ if (q.exempt) return { ok: true };
106+ const u = await usageOf(env, accountId);
107+ if (!within(q.hosted_minutes, u.hosted_minutes))
108+ return { ok: false, wait: false, message: `this account has used its ${q.hosted_minutes} hosted minutes for this month (Actions jobs on hosted runners and agent sandboxes); they renew on the 1st (UTC). Self-hosted runners still work` };
109+ if (kind === "job" && !within(q.hosted_jobs, u.hosted_jobs)) return { ok: false, wait: true, message: `waiting for one of this account's ${q.hosted_jobs} hosted jobs to finish` };
110+ if (kind === "sandbox" && !within(q.sandboxes, u.sandboxes)) return { ok: false, wait: false, message: `this account already has ${q.sandboxes} agent sandboxes open; close one or wait for one to finish` };
111+ return { ok: true };
112+}
113+
114+/** Record that a hosted job or sandbox started; `id` is the job or sandbox id. */
115+export async function startCompute(env: Env, row: { id: string; accountId: string; repoId: string; kind: "job" | "sandbox" }): Promise<void> {
116+ await env.DB.prepare("INSERT OR REPLACE INTO compute_usage (id, account_id, repo_id, kind, started_at) VALUES (?, ?, ?, ?, ?)").bind(row.id, row.accountId, row.repoId, row.kind, now()).run();
117+}
118+
119+/** Record that a hosted job or sandbox ended (idempotent). */
120+export async function endCompute(env: Env, id: string): Promise<void> {
121+ await env.DB.prepare("UPDATE compute_usage SET ended_at = ? WHERE id = ? AND ended_at IS NULL").bind(now(), id).run();
122+}
123+
124+/** Refuse a new repository past the owner's quota. */
125+export async function checkRepositoryQuota(env: Env, ownerId: string, ownerLogin: string): Promise<void> {
126+ const q = await quotasFor(env, ownerId);
127+ if (q.exempt || q.repositories < 0) return;
128+ const n = (await env.DB.prepare("SELECT count(*) AS n FROM repos WHERE owner_id = ?").bind(ownerId).first<{ n: number }>())?.n ?? 0;
129+ if (n >= q.repositories) throw new HttpError(403, `${ownerLogin} has reached its limit of ${q.repositories} repositories; delete one or ask a site administrator to raise it`);
130+}
131+
132+/** A site administrator's overrides for an account; null clears one back to the default. */
133+export async function setQuotas(env: Env, accountId: string, patch: Partial<Record<QuotaKey, number | null>>, by: string): Promise<void> {
134+ for (const [k, v] of Object.entries(patch)) {
135+ if (!QUOTA_KEYS.includes(k as QuotaKey)) throw new HttpError(422, `unknown quota ${k}; use ${QUOTA_KEYS.join(", ")}`);
136+ if (v !== null && (typeof v !== "number" || !Number.isInteger(v) || v < -1 || v > 1e9)) throw new HttpError(422, `${k} must be a whole number, -1 for unlimited, or null for the default`);
137+ }
138+ const cur = (await env.DB.prepare("SELECT * FROM account_quotas WHERE account_id = ?").bind(accountId).first<Record<QuotaKey, number | null>>()) ?? null;
139+ const next = Object.fromEntries(QUOTA_KEYS.map((k) => [k, k in patch ? (patch[k] ?? null) : (cur?.[k] ?? null)])) as Record<QuotaKey, number | null>;
140+ await env.DB.prepare(
141+ "INSERT INTO account_quotas (account_id, hosted_minutes, hosted_jobs, sandboxes, repositories, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET hosted_minutes = excluded.hosted_minutes, hosted_jobs = excluded.hosted_jobs, sandboxes = excluded.sandboxes, repositories = excluded.repositories, updated_at = excluded.updated_at, updated_by = excluded.updated_by",
142+ )
143+ .bind(accountId, next.hosted_minutes, next.hosted_jobs, next.sandboxes, next.repositories, now(), by)
144+ .run();
145+}
modifiedapps/diffinery/src/repo/RepoDO.ts+15−2View file
@@ -73,6 +73,8 @@
7373 export const MAX_PURGE_OBJECTS = 300_000;
7474 /** Storage key that marks a deleted repository whose R2 objects are still being deleted. */
7575 const PURGE_KEY = "purge";
76+/** How often jobs waiting on the account's hosted-job quota are retried. */
77+const HELD_RETRY_MS = 60_000;
7678
7779 export type TreeItem = {
7880 name: string;
@@ -221,15 +223,26 @@
221223 this.ctx.waitUntil(p.catch((e) => console.error("background task failed", e)));
222224 }
223225
224- /** Set (or with null, clear) the object's single alarm; the merge queue uses it for check timeouts. */
226+ /**
227+ * Set (or with null, clear) the object's single alarm. The merge queue uses it for check timeouts;
228+ * while jobs wait on the account's hosted-job quota it also fires every minute to retry them.
229+ */
225230 armAlarm(at: number | null): void {
226- this.ctx.waitUntil(at === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(at));
231+ const held = this.actions.hasAccountHeldJobs() ? Date.now() + HELD_RETRY_MS : null;
232+ const next = at === null ? held : held === null ? at : Math.min(at, held);
233+ this.ctx.waitUntil(next === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(next));
227234 }
228235
229236 override async alarm(): Promise<void> {
230237 const purging = await this.ctx.storage.get<string>(PURGE_KEY);
231238 if (purging) return this.purge(purging);
232239 await this.queue.process();
240+ if (this.actions.hasAccountHeldJobs()) {
241+ await this.actions.releaseAccountHeld();
242+ // Keep the queue's own alarm if it set an earlier one.
243+ const current = await this.ctx.storage.getAlarm();
244+ if (this.actions.hasAccountHeldJobs() && (current === null || current <= Date.now() || current > Date.now() + HELD_RETRY_MS)) await this.ctx.storage.setAlarm(Date.now() + HELD_RETRY_MS);
245+ }
233246 }
234247
235248 /** Queue an event (webhooks, notifications) and show it live to open pages; `live: false` skips the pages (e.g. draft releases, which readers can't see). */
modifiedapps/diffinery/src/repo/actions.ts+45−3View file
@@ -22,6 +22,7 @@
2222 import { signRuntimeToken } from "../lib/runtime-auth.js";
2323 import { ghRepo, numericId } from "../http/gh.js";
2424 import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js";
25+import { endCompute, mayStart, startCompute } from "../lib/quotas.js";
2526 import type { Env } from "../env.js";
2627 import type { RepoDO } from "./RepoDO.js";
2728 import { completePayload } from "./event-payloads.js";
@@ -983,6 +984,19 @@
983984 this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
984985 return;
985986 }
987+ // The account that owns the repository pays (lib/quotas.ts): past its monthly minutes the job
988+ // fails; past its hosted jobs at once it waits (fleet_held = 2, retried by the alarm) for one to end.
989+ const quota = await mayStart(this.env, m.ownerId, "job").catch((e: unknown) => {
990+ console.error("quota check failed", e);
991+ return { ok: true } as const;
992+ });
993+ if (!quota.ok) {
994+ if (!quota.wait) return this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job was not started: ${quota.message}.` } });
995+ this.sql.exec("UPDATE jobs SET status = 'queued', fleet_held = 2 WHERE id = ?", jobId);
996+ this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
997+ this.repo.armAlarm(null);
998+ return;
999+ }
9861000 // One repository can't take the whole fleet: past its share, the job waits for one of its own to finish.
9871001 const busy = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM jobs WHERE labels IS NULL AND container IS NOT NULL AND status IN ('waiting', 'in_progress') AND id != ?", jobId).one().n;
9881002 if (busy >= MAX_HOSTED_JOBS) {
@@ -994,6 +1008,7 @@
9941008 const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`;
9951009 const container = `job-${jobId}`;
9961010 this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId);
1011+ await startCompute(this.env, { id: jobId, accountId: m.ownerId, repoId: m.repoId, kind: "job" }).catch((e: unknown) => console.error("compute usage failed", e));
9971012 this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
9981013 try {
9991014 const stub = this.env.RUNNER.get(this.env.RUNNER.idFromName(container));
@@ -1219,6 +1234,8 @@
12191234 return;
12201235 }
12211236 this.sql.exec("UPDATE jobs SET status = 'queued', container = NULL, token_hash = NULL, placement_tries = ? WHERE id = ?", tries, jobId);
1237+ // Not running while it waits: it holds none of the account's hosted jobs and uses no minutes.
1238+ await endCompute(this.env, jobId).catch((e: unknown) => console.error("compute usage failed", e));
12221239 this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId });
12231240 try {
12241241 const instance = await this.env.RUN_WORKFLOW.get(job.run_id);
@@ -1261,9 +1278,26 @@
12611278 return true;
12621279 }
12631280
1281+ /** Whether jobs wait on the account's hosted-job quota (RepoDO retries them on its alarm). */
1282+ hasAccountHeldJobs(): boolean {
1283+ return this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM jobs WHERE fleet_held = 2 AND status = 'queued'").one().n > 0;
1284+ }
1285+
1286+ /** Try again to start jobs waiting on the account's hosted-job quota, oldest first, until one waits again. */
1287+ async releaseAccountHeld(): Promise<void> {
1288+ for (const { id } of this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held = 2 AND status = 'queued' ORDER BY rowid").toArray()) {
1289+ await this.launch(id);
1290+ if (this.jobRow(id).fleet_held === 2) return;
1291+ }
1292+ }
1293+
12641294 async jobTimedOut(jobId: string): Promise<void> {
12651295 const j = this.jobRow(jobId);
12661296 if (j.status === "completed") return;
1297+ if (j.status === "queued" && j.fleet_held === 2) {
1298+ await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: "The job waited 6 hours for one of this account's hosted jobs to finish, and was not started." } }, "timed_out");
1299+ return;
1300+ }
12671301 await this.stopContainer(j);
12681302 await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job exceeded its timeout of ${j.timeout_minutes} minutes.` } }, "timed_out");
12691303 }
@@ -1760,9 +1794,10 @@
17601794 }
17611795 // An ephemeral runner is done once its one job is.
17621796 if (job.runner_id) this.sql.exec("DELETE FROM runners WHERE id = ? AND ephemeral = 1 AND token_hash IS NULL", job.runner_id);
1763- // A hosted job hands its slot to the oldest job waiting for one.
1797+ // A hosted job hands its slot to the oldest job waiting for one (the repository's or the account's).
17641798 if (!job.labels && job.container) {
1765- const held = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held = 1 AND status = 'queued' ORDER BY rowid LIMIT 1").toArray()[0];
1799+ await endCompute(this.env, jobId).catch((e: unknown) => console.error("compute usage failed", e));
1800+ const held = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held > 0 AND status = 'queued' ORDER BY rowid LIMIT 1").toArray()[0];
17661801 if (held) await this.launch(held.id);
17671802 }
17681803 // A job that held its concurrency group hands it to the oldest pending job (of any run).
@@ -1981,6 +2016,9 @@
19812016 .one().n;
19822017 if (open >= MAX_OPEN_SANDBOXES) throw new HttpError(429, `you have ${open} open sandboxes in this repository, the most allowed; close one first`);
19832018 }
2019+ // The account that owns the repository pays for sandboxes too (lib/quotas.ts).
2020+ const quota = await mayStart(this.env, this.repo.meta.ownerId, "sandbox");
2021+ if (!quota.ok) throw new HttpError(429, quota.message);
19842022 const sha = await this.repo.resolve(ref ?? "HEAD");
19852023 if (!sha) throw new HttpError(422, "unknown ref");
19862024 const id = newId();
@@ -1997,6 +2035,7 @@
19972035 task ? "task" : "api",
19982036 task?.personId ?? null,
19992037 );
2038+ await startCompute(this.env, { id, accountId: m.ownerId, repoId: m.repoId, kind: "sandbox" });
20002039 const stub = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id));
20012040 try {
20022041 await stub.boot({
@@ -2009,10 +2048,11 @@
20092048 GIT_AUTHOR_EMAIL: `${caller.login}@users.noreply.diffinery.com`,
20102049 GIT_COMMITTER_NAME: caller.login,
20112050 GIT_COMMITTER_EMAIL: `${caller.login}@users.noreply.diffinery.com`,
2012- }, secret, id);
2051+ }, secret, id, { id, accountId: m.ownerId, repoId: m.repoId });
20132052 } catch (e) {
20142053 console.error("sandbox boot failed", e);
20152054 this.sql.exec("UPDATE sandboxes SET status = 'failed' WHERE id = ?", id);
2055+ await endCompute(this.env, id).catch(() => {});
20162056 await stub.shutdown().catch(() => {});
20172057 throw new HttpError(503, "the sandbox container did not start; try again");
20182058 }
@@ -2031,6 +2071,7 @@
20312071 async closeSandbox(id: string, caller: Caller): Promise<void> {
20322072 this.getSandbox(id, caller);
20332073 this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id);
2074+ await endCompute(this.env, id);
20342075 await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown();
20352076 }
20362077
@@ -2039,6 +2080,7 @@
20392080 const s = this.sql.exec<{ kind: string }>("SELECT kind FROM sandboxes WHERE id = ?", id).toArray()[0];
20402081 if (s?.kind !== "task") return;
20412082 this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id);
2083+ await endCompute(this.env, id);
20422084 await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown();
20432085 }
20442086 }
modifiedapps/diffinery/src/runner/containers.ts+16−1View file
@@ -2,6 +2,7 @@
22 import { Container } from "@cloudflare/containers";
33 import type { StopParams } from "@cloudflare/containers";
44 import type { Env } from "../env.js";
5+import { endCompute, startCompute } from "../lib/quotas.js";
56
67 /**
78 * One container per workflow job. The runner agent inside fetches its job spec
@@ -51,8 +52,10 @@
5152 override sleepAfter = "30m";
5253 override enableInternet = true;
5354
54- async boot(envVars: Record<string, string>, secret: string, _name?: string): Promise<void> {
55+ async boot(envVars: Record<string, string>, secret: string, _name?: string, usage?: { id: string; accountId: string; repoId: string }): Promise<void> {
5556 await this.ctx.storage.put("secret", secret);
57+ // Its running time counts against the repository owner's hosted minutes (lib/quotas.ts).
58+ if (usage) await this.ctx.storage.put("usage", usage);
5659 // The first start on a host after a deploy pulls the runner image, which takes
5760 // well over the library's 8 s / 20 s defaults.
5861 await this.startAndWaitForPorts({
@@ -65,6 +68,13 @@
6568 /** Forward an API call (JSON text) to the sandbox's HTTP server with its secret attached. */
6669 async call(path: string, body: string): Promise<{ status: number; body: string; json: boolean }> {
6770 const secret = await this.ctx.storage.get<string>("secret");
71+ // A sandbox that slept after 30 idle minutes starts again for this call: its time counts again.
72+ const usage = await this.ctx.storage.get<{ id: string; accountId: string; repoId: string; part?: number }>("usage");
73+ if (usage && !this.ctx.container?.running) {
74+ const part = (usage.part ?? 0) + 1;
75+ await startCompute(this.env, { id: `${usage.id}:${part}`, accountId: usage.accountId, repoId: usage.repoId, kind: "sandbox" });
76+ await this.ctx.storage.put("usage", { ...usage, part });
77+ }
6878 const res = await this.containerFetch(`http://sandbox${path}`, {
6979 method: "POST",
7080 headers: { "content-type": "application/json", authorization: `Bearer ${secret}` },
@@ -76,6 +86,11 @@
7686 async shutdown(): Promise<void> {
7787 if (this.ctx.container?.running) await this.stop();
7888 }
89+
90+ override async onStop(): Promise<void> {
91+ const usage = await this.ctx.storage.get<{ id: string; part?: number }>("usage");
92+ if (usage) await endCompute(this.env, usage.part ? `${usage.id}:${usage.part}` : usage.id);
93+ }
7994 }
8095
8196 /**
modifiedapps/diffinery/test/e2e/security.test.mjs+61−1View file
@@ -39,7 +39,7 @@
3939
4040 const hash = (s) => createHash("sha256").update(s).digest("hex");
4141 const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
42-const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") };
42+const T = { sully: tok("sully"), olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") };
4343 const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}`, sam: `sam-session-${randomBytes(12).toString("hex")}`, oliveOld: `olive-old-${randomBytes(12).toString("hex")}`, oliveStale: `olive-stale-${randomBytes(12).toString("hex")}`, oliveSpare: `olive-spare-${randomBytes(12).toString("hex")}` };
4444 const OWNER = "olive";
4545
@@ -139,6 +139,9 @@
139139 "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
140140 [
141141 account("u-olive", "olive", "user", "active"),
142+ account("u-sully", "sully", "user", "active"),
143+ "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'",
144+ token("t-sully", "u-sully", T.sully, ["admin"]),
142145 account("a-bot", "olive-bot", "agent", "active", "u-olive"),
143146 account("u-rita", "rita", "user", "active"),
144147 account("u-penny", "penny", "user", "pending"),
@@ -2354,3 +2357,60 @@
23542357 await status(204, "DELETE", `/repos/${OWNER}/invited-then-deleted`);
23552358 assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []);
23562359 });
2360+
2361+test("security: per-account quotas on hosted minutes, hosted jobs at once, sandboxes and repositories", async () => {
2362+ // Only site administrators see or change another account's quotas.
2363+ await status(403, "PATCH", "/admin/accounts/rita/quotas", { repositories: 1 });
2364+ await status(403, "GET", "/admin/accounts/rita/quotas", undefined, T.rita);
2365+ await status(422, "PATCH", "/admin/accounts/rita/quotas", { storage: 1 }, T.sully);
2366+ const set = await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: 1, hosted_minutes: 0, sandboxes: 0 }, T.sully);
2367+ assert.deepEqual([set.repositories.limit, set.hosted_minutes.limit, set.sandboxes.limit, set.exempt], [1, 0, 0, false]);
2368+ await until("the audit entry", async () => auditLog("account.quotas.set").some((e) => e.target === "rita" && e.actor_login === "sully"));
2369+ // rita sees her own usage and limits.
2370+ const mine = await ok("GET", "/user/usage", undefined, T.rita);
2371+ assert.deepEqual([mine.account, mine.repositories.limit, mine.hosted_minutes.used], ["rita", 1, 0]);
2372+
2373+ // ------------------------------------------------------------ repositories
2374+ const owned = mine.repositories.count;
2375+ await setRitaRepos(owned + 1);
2376+ await ok("POST", "/user/repos", { name: "rita-quota-1" }, T.rita);
2377+ assert.match((await status(403, "POST", "/user/repos", { name: "rita-quota-2" }, T.rita)).message, /rita has reached its limit of \d+ repositories/);
2378+
2379+ // ------------------------------------------------------------ hosted minutes: a hosted job isn't started
2380+ const R = "/repos/rita/rita-quota-1";
2381+ await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n") }, T.rita);
2382+ const job = await until("the job to fail", async () => {
2383+ const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs;
2384+ if (!runs.length) return null;
2385+ const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs;
2386+ return jobs[0]?.status === "completed" ? { run: runs[0].id, job: jobs[0] } : null;
2387+ });
2388+ assert.equal(job.job.conclusion, "failure");
2389+ const page = await (await fetch(`${base}/rita/rita-quota-1/actions/runs/${job.run}`, { headers: { authorization: `Bearer ${T.rita}` } })).text();
2390+ assert.match(page, /used its 0 hosted minutes for this month/);
2391+
2392+ // ------------------------------------------------------------ sandboxes: the minutes count for them too, and so does their own limit
2393+ assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /used its 0 hosted minutes/);
2394+ await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_minutes: null, hosted_jobs: 0 }, T.sully);
2395+ assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /already has 0 agent sandboxes open/);
2396+
2397+ // ------------------------------------------------------------ hosted jobs at once: the job waits, then starts once there's room
2398+ const file = await ok("GET", `${R}/contents/.github/workflows/ci.yml`, undefined, T.rita);
2399+ await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "again", sha: file.sha, content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo again\n") }, T.rita);
2400+ const held = await until("a second run", async () => {
2401+ const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs;
2402+ if (runs.length < 2) return null;
2403+ const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs;
2404+ return jobs[0] ? { run: runs[0].id, job: jobs[0] } : null;
2405+ });
2406+ await sleep(3000);
2407+ assert.equal((await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status, "queued");
2408+ // With room again, the alarm starts it within a minute (here it then fails: no containers under wrangler dev).
2409+ await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_jobs: null }, T.sully);
2410+ await until("the held job to leave the queue", async () => (await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status !== "queued", 120_000);
2411+});
2412+
2413+/** Set rita's repository quota (site administrators only). */
2414+async function setRitaRepos(n) {
2415+ await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: n }, T.sully);
2416+}
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+10−1View file
@@ -2,6 +2,7 @@
22 import { BodyTooLarge, STEP_OPS, batchParts, blockKey, chunkKey, compose, finalKey, listStaged, parseBlockList, parseRange, planCompose, putStream, runComposeStep, stagingPrefix, type BlobBucket, type ComposeLimits, type ComposeStep } from "../../src/lib/blob-store.js";
33 import { DatabaseSync } from "node:sqlite";
44 import { Actions, MAX_ACTIVE_RUNS, MAX_HOSTED_JOBS, MAX_PLACEMENT_TRIES, placementDelay } from "../../src/repo/actions.js";
5+import { testD1 } from "./stubs/d1.js";
56 import { DEFAULT_ARTIFACT_QUOTA, Runtime, artifactFileName, artifactObjectKeys } from "../../src/repo/runtime.js";
67 import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
78 import type { RepoDO } from "../../src/repo/RepoDO.js";
@@ -688,13 +689,17 @@
688689 const events: { type: string; payload: Record<string, unknown> }[] = [];
689690 const repo = {
690691 sql,
691- meta: { repoId: "r", owner: "acme", name: "app", defaultBranch: "main" },
692+ meta: { repoId: "r", owner: "acme", ownerId: "acme", name: "app", defaultBranch: "main" },
692693 upsertCheck: () => {},
693694 broadcast: () => {},
694695 deploys: { jobCompleted: () => {} },
695696 runtime: { sweepJob: async () => {} },
696697 } as unknown as RepoDO;
698+ const DB = testD1();
699+ DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, created_at) VALUES ('acme', 'acme', 'org', 'active', ?)").run(new Date().toISOString());
700+ const running = () => DB.sqlite.prepare("SELECT id FROM compute_usage WHERE ended_at IS NULL").all();
697701 const env = {
702+ DB,
698703 PUBLIC_URL: "https://d.test",
699704 BUCKET: memoryBucket().bucket,
700705 RUNNER: {
@@ -719,14 +724,18 @@
719724 await launch("j1");
720725 expect(job("j1")).toMatchObject({ status: "queued", conclusion: null, container: null, placement_tries: 1 });
721726 expect(events).toEqual([{ type: "job-j1", payload: { retryInSeconds: placementDelay(1) } }]);
727+ // While it waits it isn't running, so it holds none of the account's hosted jobs (lib/quotas.ts).
728+ expect(running()).toEqual([]);
722729
723730 // Its container stopping before the runner reported in counts the same way; a started job's loss doesn't.
724731 full = false;
725732 await actions.relaunchJob("j1");
726733 expect(job("j1")).toMatchObject({ status: "waiting", container: "job-j1" });
734+ expect(running()).toEqual([{ id: "j1" }]);
727735 await actions.containerStopped("j1", null);
728736 expect(job("j1")).toMatchObject({ status: "queued", placement_tries: 2 });
729737 expect(events.at(-1)).toEqual({ type: "job-j1", payload: { retryInSeconds: placementDelay(2) } });
738+ expect(running()).toEqual([]);
730739 await launch("j2");
731740 sql.exec("UPDATE jobs SET status = 'in_progress' WHERE id = 'j2'");
732741 await actions.containerStopped("j2", null);
addedapps/diffinery/test/unit/quotas.test.ts+87−0View file
@@ -1,0 +1,87 @@
1+import { describe, expect, it } from "vitest";
2+import { checkRepositoryQuota, DEFAULT_QUOTAS, endCompute, mayStart, monthStart, quotasFor, setQuotas, startCompute, usageOf } from "../../src/lib/quotas.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+function setup(vars: Record<string, string> = {}) {
9+ const DB = testD1();
10+ 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);
11+ add("ann", "user");
12+ add("root", "user", 1);
13+ add("acme", "org");
14+ add("ops", "org");
15+ DB.sqlite.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme', 'ann', 'owner', ?), ('ops', 'root', 'owner', ?)").run(at, at);
16+ const usage = (id: string, account: string, kind: "job" | "sandbox", start: string, end: string | null) =>
17+ DB.sqlite.prepare("INSERT INTO compute_usage (id, account_id, repo_id, kind, started_at, ended_at) VALUES (?, ?, 'r', ?, ?, ?)").run(id, account, kind, start, end);
18+ return { env: { DB, ...vars } as unknown as Env, DB, usage };
19+}
20+
21+describe("quotas", () => {
22+ it("uses the defaults, the environment's, then a site administrator's override", async () => {
23+ const { env } = setup();
24+ expect(await quotasFor(env, "ann")).toEqual({ ...DEFAULT_QUOTAS, exempt: false });
25+ const fromEnv = setup({ QUOTA_HOSTED_MINUTES: "120", QUOTA_SANDBOXES: "junk" }).env;
26+ expect((await quotasFor(fromEnv, "ann")).hosted_minutes).toBe(120);
27+ expect((await quotasFor(fromEnv, "ann")).sandboxes).toBe(DEFAULT_QUOTAS.sandboxes);
28+ await setQuotas(env, "ann", { hosted_jobs: 1, repositories: -1 }, "root");
29+ await setQuotas(env, "ann", { hosted_minutes: 10 }, "root");
30+ expect(await quotasFor(env, "ann")).toEqual({ ...DEFAULT_QUOTAS, hosted_jobs: 1, repositories: -1, hosted_minutes: 10, exempt: false });
31+ await setQuotas(env, "ann", { hosted_jobs: null }, "root");
32+ expect((await quotasFor(env, "ann")).hosted_jobs).toBe(DEFAULT_QUOTAS.hosted_jobs);
33+ await expect(setQuotas(env, "ann", { storage: 1 } as never, "root")).rejects.toThrow(/unknown quota storage/);
34+ await expect(setQuotas(env, "ann", { hosted_jobs: -5 }, "root")).rejects.toThrow(/whole number/);
35+ });
36+
37+ it("doesn't limit accounts run by a site administrator", async () => {
38+ const { env } = setup();
39+ expect((await quotasFor(env, "root")).exempt).toBe(true);
40+ expect((await quotasFor(env, "ops")).exempt).toBe(true);
41+ expect((await quotasFor(env, "acme")).exempt).toBe(false);
42+ });
43+
44+ it("counts this month's minutes and what is running now", async () => {
45+ const { env, usage } = setup();
46+ const now = new Date("2026-10-13T12:00:00.000Z");
47+ usage("j1", "acme", "job", "2026-10-13T10:00:00.000Z", "2026-10-13T10:10:30.000Z"); // 11 minutes (rounded up)
48+ usage("j2", "acme", "job", "2026-10-13T11:30:00.000Z", null); // running: 30 minutes so far
49+ usage("s1", "acme", "sandbox", "2026-10-13T11:00:00.000Z", null); // open: 60 minutes
50+ usage("j3", "acme", "job", "2026-09-30T23:50:00.000Z", "2026-10-01T00:20:00.000Z"); // 20 of its minutes are this month's
51+ usage("j4", "acme", "job", "2026-09-10T10:00:00.000Z", "2026-09-10T11:00:00.000Z"); // last month
52+ usage("j5", "acme", "job", "2026-10-12T00:00:00.000Z", null); // lost its end: counted as ended after 7 hours
53+ usage("j6", "ann", "job", "2026-10-13T11:00:00.000Z", null); // someone else's
54+ expect(await usageOf(env, "acme", now)).toEqual({ hosted_minutes: 11 + 30 + 60 + 20 + 420, hosted_jobs: 1, sandboxes: 1, repositories: 0 });
55+ expect(monthStart(now)).toBe("2026-10-01T00:00:00.000Z");
56+ });
57+
58+ it("refuses work past the minutes, holds jobs past the running jobs, and refuses sandboxes past theirs", async () => {
59+ const { env } = setup();
60+ await setQuotas(env, "acme", { hosted_jobs: 1, sandboxes: 1 }, "root");
61+ expect(await mayStart(env, "acme", "job")).toEqual({ ok: true });
62+ await startCompute(env, { id: "job-a", accountId: "acme", repoId: "r", kind: "job" });
63+ expect(await mayStart(env, "acme", "job")).toMatchObject({ ok: false, wait: true });
64+ await endCompute(env, "job-a");
65+ await endCompute(env, "job-a");
66+ expect(await mayStart(env, "acme", "job")).toEqual({ ok: true });
67+ await startCompute(env, { id: "sb", accountId: "acme", repoId: "r", kind: "sandbox" });
68+ expect(await mayStart(env, "acme", "sandbox")).toMatchObject({ ok: false, wait: false, message: expect.stringMatching(/already has 1 agent sandboxes open/) });
69+ // A job still starts: sandboxes and jobs have their own counts.
70+ expect(await mayStart(env, "acme", "job")).toEqual({ ok: true });
71+ await setQuotas(env, "acme", { hosted_minutes: 0 }, "root");
72+ for (const kind of ["job", "sandbox"] as const) expect(await mayStart(env, "acme", kind)).toMatchObject({ ok: false, wait: false, message: expect.stringMatching(/used its 0 hosted minutes for this month/) });
73+ // The administrator's own organization is never held up.
74+ await setQuotas(env, "ops", { hosted_minutes: 0 }, "root");
75+ expect(await mayStart(env, "ops", "job")).toEqual({ ok: true });
76+ });
77+
78+ it("caps the repositories an account owns", async () => {
79+ const { env, DB } = setup();
80+ await setQuotas(env, "ann", { repositories: 1 }, "root");
81+ await checkRepositoryQuota(env, "ann", "ann");
82+ DB.sqlite.prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES ('r1', 'ann', 'one', '', 'private', 'main', ?, ?)").run(at, at);
83+ await expect(checkRepositoryQuota(env, "ann", "ann")).rejects.toThrow(/ann has reached its limit of 1 repositories/);
84+ await setQuotas(env, "ann", { repositories: -1 }, "root");
85+ await checkRepositoryQuota(env, "ann", "ann");
86+ });
87+});
addedapps/diffinery/test/unit/stubs/d1.ts+24−0View file
@@ -1,0 +1,24 @@
1+import { DatabaseSync } from "node:sqlite";
2+import { readdirSync, readFileSync } from "node:fs";
3+import { join } from "node:path";
4+
5+/** A D1Database over node:sqlite with every migration applied: enough for prepare/bind/first/all/run and batch. */
6+export function testD1(): D1Database & { sqlite: DatabaseSync } {
7+ const db = new DatabaseSync(":memory:");
8+ const dir = join(import.meta.dirname, "../../../migrations");
9+ for (const f of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) db.exec(readFileSync(join(dir, f), "utf8"));
10+ const statement = (query: string, bindings: unknown[] = []) => ({
11+ bind: (...b: unknown[]) => statement(query, b),
12+ first: async <T>() => (db.prepare(query).get(...(bindings as never[])) as T | undefined) ?? null,
13+ all: async <T>() => ({ results: db.prepare(query).all(...(bindings as never[])) as T[], success: true, meta: {} }),
14+ run: async () => {
15+ const r = db.prepare(query).run(...(bindings as never[]));
16+ return { success: true, meta: { changes: Number(r.changes) } };
17+ },
18+ });
19+ return {
20+ sqlite: db,
21+ prepare: (q: string) => statement(q),
22+ batch: async (stmts: Array<{ run: () => Promise<unknown> }>) => Promise.all(stmts.map((s) => s.run())),
23+ } as unknown as D1Database & { sqlite: DatabaseSync };
24+}