flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

Commit

Usage limits: unpaid usage can only go so far

Every workspace has a ceiling on this month's usage not yet paid for, counted at cost or charge, whichever is more, so it holds while g1t is free too: $3 before any live payment, then twice what it has paid, $25 to $1,000. At the ceiling, no new sandboxes or builds start and deployed apps pause, to be rebuilt by themselves once it is under again. Owners can set a lower spend limit on the Billing page.

syntaqxcommitted Parent67798b4Browse files
14 files+633−130/14 viewed
+34−0
109109 within the free minutes. While g1t is being built out it is recorded but
110110 not charged.
111111
112+## Usage limits
113+
114+Everything a workspace uses costs g1t money at Cloudflare or a model
115+provider before the workspace pays for it. So, as Fly and Cloudflare do
116+with new accounts, every workspace has a limit on usage not yet paid for.
117+When it is reached, the workspace's work stops until it pays, or the
118+month turns:
119+
120+- **No new sandboxes.** Assigning an agent, planning, asking for a
121+ review and workflow jobs are refused with `402 payment_required` and the
122+ reason; acceptance checks and the merge queue wait. Runs already under
123+ way finish.
124+- **No new builds**, and **deployed apps pause**: they answer with a page
125+ saying so (`402`) and run nothing. Once the workspace is under its limit
126+ again, g1t rebuilds each one from the commit it was serving, by itself.
127+
128+What counts is this month's usage (UTC), each item at what it cost g1t or
129+what it is charged, whichever is more, less what was paid this month. It
130+counts while g1t is free too: free is a price of nothing, not a way around
131+the limit.
132+
133+| Workspace | Limit |
134+| --- | --- |
135+| **New**: has not paid g1t yet | $3: the free allowances and a little more |
136+| **Paid**: has paid g1t | twice what it has paid, from $25 up to $1,000 |
137+| **Reviewed** | what g1t set for it, after talking with you |
138+
139+Payments in test mode are not money, so they do not raise the limit. To
140+go past $1,000, write to support.
141+
142+At 80% the Billing page turns amber and says how close the workspace is.
143+An owner can set a lower **spend limit** of their own under **Settings →
144+Billing → Usage limit**; work stops at whichever is lower.
145+
112146 ## Add credit
113147
114148 Only an owner of the workspace can add credit.
+102−4
77 type DeployUsage,
88 type Feature,
99 type FeatureState,
10+ type Limit,
1011 } from "@g1t/contracts";
1112
1213 import type { Route } from "./+types/billing";
4647 await billing.confirm(slug, viewer, session);
4748 throw redirect(`/${slug}/-/billing?added=1`);
4849 }
49− const [account, ledger, features, deployUsage] = await Promise.all([
50+ const [account, ledger, features, deployUsage, limit] = await Promise.all([
5051 billing.account(slug, viewer),
5152 billing.ledger(slug, viewer),
5253 billing.features(slug, viewer),
5354 deployments.usage(slug, viewer),
55+ billing.limit(slug, viewer),
5456 ]);
5557 return {
5658 slug,
5961 ledger: unwrap(ledger),
6062 features: unwrap(features),
6163 deployUsage: deployUsage.ok ? deployUsage.value : null,
64+ limit: limit.ok ? limit.value : null,
6265 added: url.searchParams.has("added"),
6366 subscribed: url.searchParams.has("subscribed"),
6467 };
7073 const form = await request.formData();
7174 const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`;
7275 const intent = form.get("intent");
76+ if (intent === "spend-limit" || intent === "no-spend-limit") {
77+ const amount = Number(form.get("limit"));
78+ if (intent === "spend-limit" && !(Number.isFinite(amount) && amount >= 0)) {
79+ return { error: "A spend limit is a dollar amount." };
80+ }
81+ const set = await billing.setSpendLimit(
82+ user,
83+ params.owner,
84+ intent === "spend-limit" ? Math.round(amount * MICROS_PER_DOLLAR) : null,
85+ );
86+ return set.ok ? null : { error: set.error.message };
87+ }
7388 if (intent === "subscribe" || intent === "cancel" || intent === "resume") {
7489 const feature = String(form.get("feature")) as Feature;
7590 if (intent !== "subscribe") {
99114 }
100115
101116 export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
102− const { slug, role, account, ledger, features, deployUsage, added, subscribed } = loaderData;
117+ const { slug, role, account, ledger, features, deployUsage, limit, added, subscribed } = loaderData;
103118 const { status } = account;
104119 const paying = useNavigation().state === "submitting";
105120 const empty = account.balanceMicros <= 0;
117132 </p>
118133 </div>
119134 )}
135+ {limit && account.status.enabled && (
136+ <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} />
137+ )}
138+
120139 <h2 className="font-medium">Plans</h2>
121140 <p className="mt-1 max-w-2xl text-sm text-muted">
122141 Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
143162 <h2 className="mt-12 font-medium">Agent credit</h2>
144163 <p className="mt-1 max-w-2xl text-sm text-muted">
145164 g1t agents that work on this workspace's repositories are paid for from
146− its credit: what the model cost, plus {account.marginPercent}%. Checks run
147− free. With no credit, agents do not start.
165+ its credit: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge
166+ queue and workflows, is metered by the second past 500 free minutes a month. With no credit, agents do not
167+ start.
148168 </p>
149169
150170 <div
414434 </div>
415435 );
416436 }
437+
438+const TRUST: Record<Limit["trust"], { label: string; detail: string }> = {
439+ new: {
440+ label: "New",
441+ detail: "No payment to g1t yet, so the limit is small: the free allowances and a little more. It grows once the workspace pays.",
442+ },
443+ paid: { label: "Paid", detail: "Twice what the workspace has paid g1t, from $25 up to $1,000." },
444+ reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." },
445+ internal: { label: "g1t", detail: "One of g1t's own workspaces: no limit." },
446+};
447+
448+/**
449+ * How far this month's unpaid usage has gone, and where work stops: g1t's
450+ * ceiling for the workspace, or the owners' own spend limit if lower.
451+ */
452+function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
453+ const ceiling = limit.ceilingMicros;
454+ const share = ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
455+ const tone =
456+ limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface";
457+ const bar = limit.state === "stopped" ? "bg-danger" : limit.state === "warning" ? "bg-warn" : "bg-accent";
458+ const trust = TRUST[limit.trust];
459+ return (
460+ <section className={`mb-10 rounded-xl border p-5 ${tone}`}>
461+ <div className="flex flex-wrap items-baseline justify-between gap-2">
462+ <h2 className="font-medium">Usage limit</h2>
463+ <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span>
464+ </div>
465+ <p className="mt-1 max-w-2xl text-sm text-muted">
466+ What this month's usage cost g1t, or is charged, whichever is more, less what was paid this month. At the limit,
467+ new sandboxes and builds stop and apps pause until the workspace pays or the month turns. Work already running
468+ finishes.
469+ </p>
470+ <p className="mt-4 text-2xl font-semibold tabular-nums tracking-tight">
471+ {dollars(limit.exposureMicros)}
472+ <span className="text-base font-normal text-muted"> {ceiling == null ? "· no limit" : `of ${dollars(ceiling)}`}</span>
473+ </p>
474+ {ceiling != null && (
475+ <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
476+ <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
477+ </div>
478+ )}
479+ {limit.message && <p className="mt-3 text-sm">{limit.message}</p>}
480+ <p className="mt-3 text-xs text-faint">
481+ {trust.detail}
482+ {limit.trustCeilingMicros != null && limit.spendLimitMicros != null && ` g1t's limit is ${dollars(limit.trustCeilingMicros)}.`}
483+ </p>
484+ {owner && limit.trust !== "internal" && (
485+ <Form method="post" className="mt-4 flex flex-wrap items-end gap-2">
486+ <label className="text-sm">
487+ <span className="block text-xs text-muted">Your own monthly spend limit</span>
488+ <span className="mt-1 flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
489+ <span className="text-muted">$</span>
490+ <input
491+ name="limit"
492+ type="number"
493+ min={0}
494+ step={1}
495+ defaultValue={limit.spendLimitMicros != null ? limit.spendLimitMicros / MICROS_PER_DOLLAR : ""}
496+ placeholder="None"
497+ className="w-24 bg-transparent px-1 py-1.5 tabular-nums outline-none"
498+ />
499+ </span>
500+ </label>
501+ <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
502+ Set
503+ </Button>
504+ {limit.spendLimitMicros != null && (
505+ <Button variant="quiet" type="submit" name="intent" value="no-spend-limit" disabled={busy}>
506+ Remove
507+ </Button>
508+ )}
509+ <ErrorText>{error}</ErrorText>
510+ </Form>
511+ )}
512+ </section>
513+ );
514+}
+73−0
338338 pub reference: String,
339339 }
340340
341+/// How much a workspace has earned g1t's trust with money, which sets how
342+/// far its unpaid usage can go before its work stops.
343+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
344+#[serde(rename_all = "snake_case")]
345+pub enum Trust {
346+ /// No live payment yet: only a little past the free allowances.
347+ New,
348+ /// Has paid g1t real money: the ceiling grows with what it has paid.
349+ Paid,
350+ /// A ceiling g1t set by hand, after talking to the workspace.
351+ Reviewed,
352+ /// g1t's own workspaces: no ceiling.
353+ Internal,
354+}
355+
356+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
357+#[serde(rename_all = "snake_case")]
358+pub enum LimitState {
359+ Ok,
360+ /// Past 80% of the ceiling.
361+ Warning,
362+ /// At or past it: no new sandboxes, builds or app requests.
363+ Stopped,
364+}
365+
366+/// How far a workspace's unpaid usage has gone this month, and where its
367+/// work stops: like Fly's or Cloudflare's limits for new accounts, so no
368+/// one runs up costs g1t cannot collect. Usage counts at what it cost g1t
369+/// or what it is charged, whichever is more, so it counts while g1t is
370+/// free too.
371+#[derive(Clone, Debug, Serialize, Deserialize)]
372+#[serde(rename_all = "camelCase")]
373+pub struct Limit {
374+ pub workspace: String,
375+ pub trust: Trust,
376+ /// Usage this month (UTC) less what was paid this month.
377+ pub exposure_micros: i64,
378+ /// Where work stops: the lower of g1t's ceiling and the owner's own
379+ /// spend limit. None for g1t's own workspaces.
380+ pub ceiling_micros: Option<i64>,
381+ /// The ceiling g1t sets from `trust`.
382+ pub trust_ceiling_micros: Option<i64>,
383+ /// The owner's own monthly limit, if they set one.
384+ pub spend_limit_micros: Option<i64>,
385+ pub state: LimitState,
386+ /// What to tell people when work is stopped or close to it.
387+ pub message: Option<String>,
388+}
389+
390+/// `limit`: a workspace's limit, for its members. Returns `Outcome<Limit>`.
391+#[derive(Debug, Serialize, Deserialize)]
392+pub struct LimitArgs {
393+ pub workspace: String,
394+ pub viewer: Viewer,
395+}
396+
397+/// `check_limit`: the same, for the services that enforce it. Returns
398+/// `Outcome<Limit>`.
399+#[derive(Debug, Serialize, Deserialize)]
400+pub struct CheckLimitArgs {
401+ pub workspace: String,
402+}
403+
404+/// `set_spend_limit`: the owner's own monthly ceiling, under g1t's; None
405+/// removes it. Owners only. Returns `Outcome<Limit>`.
406+#[derive(Debug, Serialize, Deserialize)]
407+#[serde(rename_all = "camelCase")]
408+pub struct SetSpendLimitArgs {
409+ pub actor: User,
410+ pub workspace: String,
411+ pub spend_limit_micros: Option<i64>,
412+}
413+
341414 /// What a feature's plan costs and includes.
342415 #[derive(Clone, Debug, Serialize, Deserialize)]
343416 #[serde(rename_all = "camelCase")]
+27−0
7272 * open to them: a few dollars of model cost each, out of one pool, until a
7373 * date. Mirrors `Trial` in `crates/contracts/src/billing.rs`.
7474 */
75+/** How much a workspace has earned g1t's trust with money. */
76+export type Trust = "new" | "paid" | "reviewed" | "internal";
77+
78+/**
79+ * How far a workspace's unpaid usage has gone this month, and where its
80+ * work stops: past `ceilingMicros`, no new sandboxes, builds or app
81+ * requests. Usage counts at its cost to g1t or its charge, whichever is
82+ * more, so it counts while g1t is free too.
83+ */
84+export type Limit = {
85+ workspace: string;
86+ trust: Trust;
87+ exposureMicros: number;
88+ /** The lower of g1t's ceiling and the owner's spend limit; null for g1t's own. */
89+ ceilingMicros: number | null;
90+ trustCeilingMicros: number | null;
91+ spendLimitMicros: number | null;
92+ state: "ok" | "warning" | "stopped";
93+ message: string | null;
94+};
95+
7596 export type Trial = {
7697 open: boolean;
7798 usedMicros: number;
184205 repo?: string | null;
185206 reference: string;
186207 }): Promise<Result<boolean>>;
208+ /** A workspace's limit, for its members. */
209+ limit(workspace: string, viewer: Viewer): Promise<Result<Limit>>;
210+ /** The same, for the services that enforce it. */
211+ checkLimit(workspace: string): Promise<Result<Limit>>;
212+ /** The owner's own monthly ceiling, under g1t's; null removes it. Owners only. */
213+ setSpendLimit(actor: User, workspace: string, spendLimitMicros: number | null): Promise<Result<Limit>>;
187214 /**
188215 * How long a sandbox ran for a workspace, reported when it stops. Its
189216 * cost is always recorded; seconds past the month's free minutes are
+4−0
200200 hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }),
201201 chargeFeature: (charge) => call("charge_feature", charge),
202202 recordSandbox: (usage) => call("record_sandbox", usage),
203+ limit: (workspace, viewer) => call("limit", { workspace, viewer }),
204+ checkLimit: (workspace) => call("check_limit", { workspace }),
205+ setSpendLimit: (actor, workspace, spendLimitMicros) =>
206+ call("set_spend_limit", { actor, workspace, spendLimitMicros }),
203207 };
204208 }
205209
+11−0
1+-- Per-workspace limits on usage not yet paid for. A row only exists when
2+-- g1t set a ceiling by hand or an owner set a spend limit; everything
3+-- else is worked out from the ledger. See src/limits.rs.
4+CREATE TABLE limits (
5+ workspace TEXT PRIMARY KEY,
6+ -- Set by g1t after a review; replaces the ceiling trust would give.
7+ ceiling_micros INTEGER,
8+ -- The owner's own monthly limit, under g1t's.
9+ spend_limit_micros INTEGER,
10+ updated_at TEXT NOT NULL
11+);
+1−1
6161
6262 /// Dollars to the cent, or finer for prices under a cent, so that a
6363 /// build minute's $0.0015 does not read as nothing.
64−fn dollars(micros: i64) -> String {
64+pub(crate) fn dollars(micros: i64) -> String {
6565 let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64);
6666 let (whole, fraction) = text.split_once('.').unwrap_or((&text, ""));
6767 let fraction = fraction.trim_end_matches('0');
+13−0
1717 //! the methods and their arguments.
1818
1919 mod features;
20+mod limits;
2021 mod stripe;
2122
2223 use g1t_contracts::billing::*;
134135 trial: Option<TrialConfig>,
135136 /// The Deployments plan's monthly price (`DEPLOYMENTS_MONTHLY_CENTS`).
136137 deployments_monthly_cents: u32,
138+ /// How far unpaid usage may go; see `limits`.
139+ ceilings: limits::Ceilings,
137140 }
138141
139142 /// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`.
567570 if self.stripe.is_none() {
568571 return Ok(Outcome::Ok(true));
569572 }
573+ if let Some(stopped) = self.stopped(&a.workspace).await? {
574+ return Ok(stopped);
575+ }
570576 Ok(self
571577 .out_of_credit(&a.workspace.to_lowercase())
572578 .await?
578584 return Ok(Outcome::Ok(None));
579585 }
580586 let workspace = a.workspace.to_lowercase();
587+ if let Some(stopped) = self.stopped(&workspace).await? {
588+ return Ok(stopped);
589+ }
581590 if let Some(refused) = self.out_of_credit(&workspace).await? {
582591 return Ok(refused);
583592 }
809818 .and_then(|fee| fee.to_string().parse().ok())
810819 .unwrap_or(100_000),
811820 free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"),
821+ ceilings: limits::Ceilings::from_env(&env),
812822 deployments_monthly_cents: env
813823 .var("DEPLOYMENTS_MONTHLY_CENTS")
814824 .ok()
852862 "has_feature" => reply(&billing.has_feature(args(body)?).await?),
853863 "charge_feature" => reply(&billing.charge_feature(args(body)?).await?),
854864 "record_sandbox" => reply(&billing.record_sandbox(args(body)?).await?),
865+ "limit" => reply(&billing.limit(args(body)?).await?),
866+ "check_limit" => reply(&billing.check_limit(args(body)?).await?),
867+ "set_spend_limit" => reply(&billing.set_spend_limit(args(body)?).await?),
855868 _ => Response::error("Unknown method", 404),
856869 }
857870 }
+270−0
1+//! How far a workspace can run up costs g1t has not been paid for.
2+//!
3+//! Every sandbox second, build, app request and model token costs g1t
4+//! money at Cloudflare or a model provider before the workspace pays for
5+//! it. So, like Fly or Cloudflare with new accounts, each workspace has a
6+//! ceiling on that unpaid usage, set by how much it has paid g1t before:
7+//!
8+//! - **New**: no live payment yet. A few dollars, enough for the free
9+//! allowances and a little more.
10+//! - **Paid**: twice what it has paid g1t, within bounds.
11+//! - **Reviewed**: a ceiling g1t set by hand.
12+//! - **Internal**: g1t's own workspaces, with none.
13+//!
14+//! An owner can set a lower spend limit of their own. Past 80% the
15+//! workspace is warned; at the ceiling its work stops: no new sandboxes,
16+//! builds or app requests, until it pays or the month turns. Runs already
17+//! under way finish.
18+//!
19+//! Usage counts at what it cost g1t or what it is charged, whichever is
20+//! more, so it counts while g1t is free too: free is a price, not an
21+//! exemption from the ceiling. Test-mode payments are not money, so they
22+//! do not raise trust.
23+
24+use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, LimitState, SetSpendLimitArgs, Trust};
25+use g1t_contracts::time::rfc3339;
26+use g1t_contracts::{FailureCode, Outcome, Role};
27+use g1t_kit::now_ms;
28+use serde::Deserialize;
29+use worker::wasm_bindgen::JsValue;
30+use worker::{Env, Result};
31+
32+use crate::features::dollars as dollars_plain;
33+use crate::{Billing, members_only};
34+
35+/// The ceilings, from the billing service's variables.
36+pub(crate) struct Ceilings {
37+ /// `LIMIT_NEW_MICROS`.
38+ pub new: i64,
39+ /// `LIMIT_PAID_MIN_MICROS` and `LIMIT_PAID_MAX_MICROS`.
40+ pub paid_min: i64,
41+ pub paid_max: i64,
42+ /// `LIMIT_EXEMPT`: g1t's own workspaces, comma-separated.
43+ pub exempt: Vec<String>,
44+}
45+
46+impl Ceilings {
47+ pub(crate) fn from_env(env: &Env) -> Self {
48+ let number = |name: &str, default: i64| {
49+ env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()).unwrap_or(default)
50+ };
51+ Ceilings {
52+ new: number("LIMIT_NEW_MICROS", 3_000_000),
53+ paid_min: number("LIMIT_PAID_MIN_MICROS", 25_000_000),
54+ paid_max: number("LIMIT_PAID_MAX_MICROS", 1_000_000_000),
55+ exempt: env
56+ .var("LIMIT_EXEMPT")
57+ .map(|v| v.to_string())
58+ .unwrap_or_default()
59+ .split(',')
60+ .map(|name| name.trim().to_lowercase())
61+ .filter(|name| !name.is_empty())
62+ .collect(),
63+ }
64+ }
65+
66+ /// The ceiling for a workspace that has paid `paid` in live money.
67+ pub(crate) fn for_paid(&self, paid: i64) -> i64 {
68+ (paid * 2).clamp(self.paid_min, self.paid_max)
69+ }
70+}
71+
72+/// Where a workspace stands against its ceiling.
73+pub(crate) fn state(exposure: i64, ceiling: Option<i64>) -> LimitState {
74+ match ceiling {
75+ Some(ceiling) if exposure >= ceiling => LimitState::Stopped,
76+ Some(ceiling) if exposure * 5 >= ceiling * 4 => LimitState::Warning,
77+ _ => LimitState::Ok,
78+ }
79+}
80+
81+#[derive(Deserialize)]
82+struct LimitRow {
83+ ceiling_micros: Option<i64>,
84+ spend_limit_micros: Option<i64>,
85+}
86+
87+#[derive(Deserialize)]
88+struct Month {
89+ used: Option<i64>,
90+ paid: Option<i64>,
91+}
92+
93+#[derive(Deserialize)]
94+struct Paid {
95+ paid: Option<i64>,
96+}
97+
98+impl Billing {
99+ /// The workspace's limit, worked out from its ledger.
100+ pub(crate) async fn limit_of(&self, workspace: &str) -> Result<Limit> {
101+ let workspace = workspace.to_lowercase();
102+ let row = self
103+ .db
104+ .prepare("SELECT ceiling_micros, spend_limit_micros FROM limits WHERE workspace = ?")
105+ .bind(&[workspace.as_str().into()])?
106+ .first::<LimitRow>(None)
107+ .await?;
108+ let month_start = format!("{}-01", &rfc3339(now_ms())[..7]);
109+ // Each usage entry at its cost to g1t or its charge, whichever is
110+ // more; on the workspace's own provider, only g1t's fee is g1t's.
111+ let month = self
112+ .db
113+ .prepare(
114+ "SELECT
115+ SUM(CASE WHEN kind = 'usage' THEN
116+ CASE WHEN COALESCE(billed_to, 'g1t') = 'g1t'
117+ THEN MAX(COALESCE(cost_micros, 0), -amount_micros)
118+ ELSE -amount_micros END
119+ END) AS used,
120+ SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS paid
121+ FROM ledger WHERE workspace = ?1 AND created_at >= ?2",
122+ )
123+ .bind(&[workspace.as_str().into(), month_start.as_str().into()])?
124+ .first::<Month>(None)
125+ .await?;
126+ let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0)));
127+ // Test-mode payments are not money: they pay nothing off.
128+ let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
129+ let exposure = (used - if live { paid_month } else { 0 }).max(0);
130+
131+ let (trust, trust_ceiling) = if self.ceilings.exempt.iter().any(|name| *name == workspace) {
132+ (Trust::Internal, None)
133+ } else if let Some(ceiling) = row.as_ref().and_then(|row| row.ceiling_micros) {
134+ (Trust::Reviewed, Some(ceiling))
135+ } else {
136+ let paid = self.live_paid(&workspace).await?;
137+ if paid > 0 {
138+ (Trust::Paid, Some(self.ceilings.for_paid(paid)))
139+ } else {
140+ (Trust::New, Some(self.ceilings.new))
141+ }
142+ };
143+ let spend_limit = row.and_then(|row| row.spend_limit_micros);
144+ let ceiling = match (trust_ceiling, spend_limit) {
145+ (Some(ceiling), Some(own)) => Some(ceiling.min(own)),
146+ (None, Some(own)) => Some(own),
147+ (ceiling, None) => ceiling,
148+ };
149+ let state = state(exposure, ceiling);
150+ let message = match state {
151+ LimitState::Ok => None,
152+ LimitState::Warning => Some(format!(
153+ "The {workspace} workspace has used {} of its {} limit this month. At the limit, its sandboxes, builds and apps stop until it pays or the month turns.",
154+ dollars_plain(exposure),
155+ dollars_plain(ceiling.unwrap_or_default()),
156+ )),
157+ LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit {
158+ format!(
159+ "The {workspace} workspace reached the {} spend limit its owners set for this month, so its sandboxes, builds and apps are stopped. An owner can raise it under Billing.",
160+ dollars_plain(ceiling.unwrap_or_default()),
161+ )
162+ } else {
163+ format!(
164+ "The {workspace} workspace reached its {} limit for usage not yet paid for, so its sandboxes, builds and apps are stopped. The limit grows as a workspace pays g1t; an owner can pay under Billing, or write to support to have it raised.",
165+ dollars_plain(ceiling.unwrap_or_default()),
166+ )
167+ }),
168+ };
169+ Ok(Limit {
170+ workspace,
171+ trust,
172+ exposure_micros: exposure,
173+ ceiling_micros: ceiling,
174+ trust_ceiling_micros: trust_ceiling,
175+ spend_limit_micros: spend_limit,
176+ state,
177+ message,
178+ })
179+ }
180+
181+ /// Real money the workspace has paid g1t. Nothing in test mode.
182+ async fn live_paid(&self, workspace: &str) -> Result<i64> {
183+ if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
184+ return Ok(0);
185+ }
186+ Ok(self
187+ .db
188+ .prepare("SELECT SUM(amount_micros) AS paid FROM ledger WHERE workspace = ? AND kind = 'top_up'")
189+ .bind(&[workspace.into()])?
190+ .first::<Paid>(None)
191+ .await?
192+ .and_then(|row| row.paid)
193+ .unwrap_or(0))
194+ }
195+
196+ /// A refusal, with the reason, when the workspace's work is stopped.
197+ /// None while billing is off: a g1t without payments has no limits.
198+ pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
199+ if self.stripe.is_none() {
200+ return Ok(None);
201+ }
202+ let limit = self.limit_of(workspace).await?;
203+ Ok((limit.state == LimitState::Stopped).then(|| {
204+ Outcome::fail(
205+ FailureCode::PaymentRequired,
206+ limit.message.unwrap_or_else(|| "This workspace is over its limit.".to_owned()),
207+ )
208+ }))
209+ }
210+
211+ pub(crate) async fn limit(&self, a: LimitArgs) -> Result<Outcome<Limit>> {
212+ let workspace = a.workspace.to_lowercase();
213+ if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
214+ return Ok(members_only());
215+ }
216+ Ok(Outcome::Ok(self.limit_of(&workspace).await?))
217+ }
218+
219+ pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
220+ Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
221+ }
222+
223+ pub(crate) async fn set_spend_limit(&self, a: SetSpendLimitArgs) -> Result<Outcome<Limit>> {
224+ let workspace = a.workspace.to_lowercase();
225+ if a.actor.role_in(&workspace) != Some(Role::Owner) {
226+ return Ok(Outcome::fail(
227+ FailureCode::Forbidden,
228+ "Only an owner can set the workspace's spend limit.",
229+ ));
230+ }
231+ if a.spend_limit_micros.is_some_and(|limit| limit < 0) {
232+ return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative."));
233+ }
234+ let limit = a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into());
235+ self.db
236+ .prepare(
237+ "INSERT INTO limits (workspace, spend_limit_micros, updated_at) VALUES (?1, ?2, ?3)
238+ ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, updated_at = ?3",
239+ )
240+ .bind(&[workspace.as_str().into(), limit, rfc3339(now_ms()).into()])?
241+ .run()
242+ .await?;
243+ Ok(Outcome::Ok(self.limit_of(&workspace).await?))
244+ }
245+}
246+
247+#[cfg(test)]
248+mod tests {
249+ use super::*;
250+
251+ fn ceilings() -> Ceilings {
252+ Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000, exempt: vec![] }
253+ }
254+
255+ #[test]
256+ fn trust_grows_with_what_was_paid_within_bounds() {
257+ assert_eq!(ceilings().for_paid(5_000_000), 25_000_000);
258+ assert_eq!(ceilings().for_paid(100_000_000), 200_000_000);
259+ assert_eq!(ceilings().for_paid(10_000_000_000), 1_000_000_000);
260+ }
261+
262+ #[test]
263+ fn work_warns_at_eighty_percent_and_stops_at_the_ceiling() {
264+ assert_eq!(state(0, Some(100)), LimitState::Ok);
265+ assert_eq!(state(79, Some(100)), LimitState::Ok);
266+ assert_eq!(state(80, Some(100)), LimitState::Warning);
267+ assert_eq!(state(100, Some(100)), LimitState::Stopped);
268+ assert_eq!(state(1_000_000, None), LimitState::Ok);
269+ }
270+}
+7−0
3030 // recorded with what they cost, and nothing is charged. Set to
3131 // "false" when pricing starts.
3232 "FREE_WHILE_BUILDING": "true",
33+ // How far a workspace's unpaid usage may go before its work stops
34+ // (see src/limits.rs): $3 before any live payment, then twice what
35+ // it has paid, between $25 and $1,000. LIMIT_EXEMPT is g1t's own.
36+ "LIMIT_NEW_MICROS": "3000000",
37+ "LIMIT_PAID_MIN_MICROS": "25000000",
38+ "LIMIT_PAID_MAX_MICROS": "1000000000",
39+ "LIMIT_EXEMPT": "syntaqx",
3340 // A free allowance on g1t's hosted models, so people can try g1t's
3441 // agents without a key of their own: each workspace may use this
3542 // much model cost ($1), out of one pool for everyone ($40), until
+4−0
1+-- When the app was paused because its workspace reached its limit for
2+-- usage not yet paid for. Paused apps answer with a notice; they are
3+-- rebuilt from the same commit once the workspace is under it again.
4+ALTER TABLE apps ADD COLUMN paused_at TEXT;
+26−7
3434 const ASSETS_ONLY = `export default { fetch(request, env) { return env.ASSETS.fetch(request); } };\n`;
3535
3636 /** What an app answers between being taken down and being gone. */
37+const PAUSED = `export default {
38+ fetch() {
39+ return new Response("<!doctype html><meta charset=utf-8><meta name=robots content=noindex><title>Paused</title><body style='font:16px system-ui;background:#121214;color:#ececf1;display:grid;place-items:center;min-height:100vh;margin:0;padding:0 16px'><main style='max-width:32rem'><h1>This app is paused</h1><p style='color:#9a9aa6'>The workspace it belongs to reached its usage limit on g1t. It comes back by itself once the workspace is under it again.</p></main>", { status: 402, headers: { "content-type": "text/html; charset=utf-8", "x-robots-tag": "noindex", "cache-control": "no-store" } });
40+ },
41+};`;
42+
3743 const TAKEN_DOWN = `export default {
3844 fetch() {
3945 return new Response("<!doctype html><meta charset=utf-8><meta name=robots content=noindex><title>Not up</title><body style='font:16px system-ui;background:#121214;color:#ececf1;display:grid;place-items:center;min-height:100vh;margin:0'><main><h1>This app is not up</h1><p style='color:#9a9aa6'>It was taken down. Deploying it again brings it back.</p></main>", { status: 404, headers: { "content-type": "text/html; charset=utf-8", "x-robots-tag": "noindex", "cache-control": "no-store" } });
164170 * reaches the edge as fast as any deploy, and deleted after.
165171 */
166172 async deleteScript(script: string): Promise<void> {
167− const form = new FormData();
168− form.append(
169− "metadata",
170− JSON.stringify({ main_module: "index.js", compatibility_date: "2026-09-26", bindings: [], tags: ["taken-down"] }),
171− );
172− form.append("index.js", new File([TAKEN_DOWN], "index.js", { type: "application/javascript+module" }));
173− await this.call("PUT", this.scriptPath(script), form).catch(() => undefined);
173+ await this.placeholder(script, TAKEN_DOWN, "taken-down").catch(() => undefined);
174174 try {
175175 await this.call("DELETE", `${this.scriptPath(script)}?force=true`);
176176 } catch (error) {
179179 }
180180
181181 /**
182+ * Replaces an app with a notice that it is paused: its workspace reached
183+ * its limit. The notice costs next to nothing to answer with, and the app
184+ * comes back by being deployed again.
185+ */
186+ async pauseScript(script: string): Promise<void> {
187+ await this.placeholder(script, PAUSED, "paused");
188+ }
189+
190+ private async placeholder(script: string, code: string, tag: string): Promise<void> {
191+ const form = new FormData();
192+ form.append(
193+ "metadata",
194+ JSON.stringify({ main_module: "index.js", compatibility_date: "2026-09-26", bindings: [], tags: [tag] }),
195+ );
196+ form.append("index.js", new File([code], "index.js", { type: "application/javascript+module" }));
197+ await this.call("PUT", this.scriptPath(script), form);
198+ }
199+
200+ /**
182201 * Requests and CPU time per app over a period, from Workers analytics.
183202 * Apps with no traffic are absent.
184203 */
+46−1
147147 deployed_at: string;
148148 created_at: string;
149149 last_request_at: string | null;
150+ /** Set while its workspace is over its limit; see `holdToLimits`. */
151+ paused_at: string | null;
150152 };
151153
152154 function toDeployment(row: DeploymentRow): Deployment {
578580 const id = newId("dpl");
579581 const token = randomToken();
580582 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
583+ const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
581584 const cloudflare = this.cloudflare;
582585 const refused = !plan.ok
583586 ? plan.error.message
587+ : limit.ok && limit.value.state === "stopped"
588+ ? (limit.value.message ?? "The workspace reached its usage limit.")
584589 : !cloudflare
585590 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
586591 : null;
784789 .prepare(
785790 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
786791 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
787− ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9`,
792+ ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL`,
788793 )
789794 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
790795 ]);
948953
949954 // Apps of workspaces whose plan has ended come down.
950955 const billing = billingClient(this.env.BILLING);
956+ await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error));
951957 for (const workspace of workspaces) {
952958 const plan = await billing.hasFeature(workspace, "deployments");
953959 if (!plan.ok && plan.error.code === "payment_required") {
961967 await this.chargeMonths();
962968 }
963969
970+ /**
971+ * Pauses the apps of workspaces that reached their limit for usage not
972+ * yet paid for, and rebuilds them from the same commit once they are
973+ * under it again. Paused apps answer with a notice and run nothing.
974+ */
975+ private async holdToLimits(apps: AppRow[]): Promise<void> {
976+ const cloudflare = this.cloudflare;
977+ if (!cloudflare) return;
978+ const billing = billingClient(this.env.BILLING);
979+ for (const workspace of [...new Set(apps.map((app) => app.workspace))]) {
980+ const limit = await billing.checkLimit(workspace);
981+ if (!limit.ok) continue;
982+ const theirs = apps.filter((app) => app.workspace === workspace);
983+ if (limit.value.state === "stopped") {
984+ for (const app of theirs.filter((a) => !a.paused_at)) {
985+ await cloudflare.pauseScript(app.script);
986+ await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
987+ }
988+ continue;
989+ }
990+ const paused = theirs.filter((a) => a.paused_at);
991+ if (paused.length === 0) continue;
992+ const actor = await this.workspaceActor(workspace);
993+ if (!actor) continue;
994+ for (const app of paused) {
995+ const project = await this.projects.get(workspace, app.slug, actor);
996+ if (!project.ok) continue;
997+ // A failed or refused rebuild leaves it paused, to try again next time.
998+ const rebuilt =
999+ app.kind === "production"
1000+ ? await this.deployProduction(project.value, app.commit_sha, "g1t")
1001+ : app.number != null
1002+ ? await this.deployPreview(project.value, app.number, "g1t", true)
1003+ : null;
1004+ if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
1005+ }
1006+ }
1007+ }
1008+
9641009 /** Scripts in the namespace that no app holds, such as ones renamed. */
9651010 private async removeOrphans(apps: AppRow[]): Promise<void> {
9661011 const cloudflare = this.cloudflare;
+15−0
704704 repo: RepoPath;
705705 timeoutMinutes: number;
706706 }): Promise<Result<true>> {
707+ const over = await this.overLimit(args.repo.namespace);
708+ if (over) return { ok: false, error: { code: "payment_required", message: over } };
707709 // The same workspaces that may use g1t's sandboxes for agents.
708710 if (!(await this.workspaceAllowed(args.repo.namespace))) {
709711 return {
787789
788790 /** Whether a workspace's repositories may use g1t's agents and sandboxes at all. */
789791 private async workspaceAllowed(namespace: string): Promise<boolean> {
792+ if (await this.overLimit(namespace)) return false;
790793 const access = await this.modelAccess(namespace);
791794 return access.own != null || access.hosted;
792795 }
793796
794797 /**
798+ * Why the workspace can start no sandbox: it reached its limit for usage
799+ * not yet paid for. Null when it can, or when billing cannot say.
800+ */
801+ private async overLimit(namespace: string): Promise<string | null> {
802+ const limit = await billingClient(this.env.BILLING)
803+ .checkLimit(namespace)
804+ .catch(() => null);
805+ if (!limit?.ok || limit.value.state !== "stopped") return null;
806+ return limit.value.message ?? `The ${namespace} workspace reached its usage limit.`;
807+ }
808+
809+ /**
795810 * Whether `viewer` may put agents to work: in `repo`'s workspace, which
796811 * must be allowed and theirs, or with no repo named, in any workspace of
797812 * theirs that is allowed.