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.
14 files+633−130/14 viewed
| 109 | 109 | within the free minutes. While g1t is being built out it is recorded but | |
| 110 | 110 | not charged. | |
| 111 | 111 | ||
| 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 | + | ||
| 112 | 146 | ## Add credit | |
| 113 | 147 | ||
| 114 | 148 | Only an owner of the workspace can add credit. |
| 7 | 7 | type DeployUsage, | |
| 8 | 8 | type Feature, | |
| 9 | 9 | type FeatureState, | |
| 10 | + | type Limit, | |
| 10 | 11 | } from "@g1t/contracts"; | |
| 11 | 12 | ||
| 12 | 13 | import type { Route } from "./+types/billing"; | |
| 46 | 47 | await billing.confirm(slug, viewer, session); | |
| 47 | 48 | throw redirect(`/${slug}/-/billing?added=1`); | |
| 48 | 49 | } | |
| 49 | − | const [account, ledger, features, deployUsage] = await Promise.all([ | |
| 50 | + | const [account, ledger, features, deployUsage, limit] = await Promise.all([ | |
| 50 | 51 | billing.account(slug, viewer), | |
| 51 | 52 | billing.ledger(slug, viewer), | |
| 52 | 53 | billing.features(slug, viewer), | |
| 53 | 54 | deployments.usage(slug, viewer), | |
| 55 | + | billing.limit(slug, viewer), | |
| 54 | 56 | ]); | |
| 55 | 57 | return { | |
| 56 | 58 | slug, | |
| 59 | 61 | ledger: unwrap(ledger), | |
| 60 | 62 | features: unwrap(features), | |
| 61 | 63 | deployUsage: deployUsage.ok ? deployUsage.value : null, | |
| 64 | + | limit: limit.ok ? limit.value : null, | |
| 62 | 65 | added: url.searchParams.has("added"), | |
| 63 | 66 | subscribed: url.searchParams.has("subscribed"), | |
| 64 | 67 | }; | |
| 70 | 73 | const form = await request.formData(); | |
| 71 | 74 | const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`; | |
| 72 | 75 | 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 | + | } | |
| 73 | 88 | if (intent === "subscribe" || intent === "cancel" || intent === "resume") { | |
| 74 | 89 | const feature = String(form.get("feature")) as Feature; | |
| 75 | 90 | if (intent !== "subscribe") { | |
| 99 | 114 | } | |
| 100 | 115 | ||
| 101 | 116 | 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; | |
| 103 | 118 | const { status } = account; | |
| 104 | 119 | const paying = useNavigation().state === "submitting"; | |
| 105 | 120 | const empty = account.balanceMicros <= 0; | |
| 117 | 132 | </p> | |
| 118 | 133 | </div> | |
| 119 | 134 | )} | |
| 135 | + | {limit && account.status.enabled && ( | |
| 136 | + | <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} /> | |
| 137 | + | )} | |
| 138 | + | ||
| 120 | 139 | <h2 className="font-medium">Plans</h2> | |
| 121 | 140 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 122 | 141 | Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of | |
| 143 | 162 | <h2 className="mt-12 font-medium">Agent credit</h2> | |
| 144 | 163 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 145 | 164 | 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. | |
| 148 | 168 | </p> | |
| 149 | 169 | ||
| 150 | 170 | <div | |
| 414 | 434 | </div> | |
| 415 | 435 | ); | |
| 416 | 436 | } | |
| 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 | + | } |
| 338 | 338 | pub reference: String, | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 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 | + | ||
| 341 | 414 | /// What a feature's plan costs and includes. | |
| 342 | 415 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 343 | 416 | #[serde(rename_all = "camelCase")] |
| 72 | 72 | * open to them: a few dollars of model cost each, out of one pool, until a | |
| 73 | 73 | * date. Mirrors `Trial` in `crates/contracts/src/billing.rs`. | |
| 74 | 74 | */ | |
| 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 | + | ||
| 75 | 96 | export type Trial = { | |
| 76 | 97 | open: boolean; | |
| 77 | 98 | usedMicros: number; | |
| 184 | 205 | repo?: string | null; | |
| 185 | 206 | reference: string; | |
| 186 | 207 | }): 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>>; | |
| 187 | 214 | /** | |
| 188 | 215 | * How long a sandbox ran for a workspace, reported when it stops. Its | |
| 189 | 216 | * cost is always recorded; seconds past the month's free minutes are |
| 200 | 200 | hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }), | |
| 201 | 201 | chargeFeature: (charge) => call("charge_feature", charge), | |
| 202 | 202 | 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 }), | |
| 203 | 207 | }; | |
| 204 | 208 | } | |
| 205 | 209 |
| 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 | + | ); |
| 61 | 61 | ||
| 62 | 62 | /// Dollars to the cent, or finer for prices under a cent, so that a | |
| 63 | 63 | /// build minute's $0.0015 does not read as nothing. | |
| 64 | − | fn dollars(micros: i64) -> String { | |
| 64 | + | pub(crate) fn dollars(micros: i64) -> String { | |
| 65 | 65 | let text = format!("{:.4}", micros as f64 / MICROS_PER_DOLLAR as f64); | |
| 66 | 66 | let (whole, fraction) = text.split_once('.').unwrap_or((&text, "")); | |
| 67 | 67 | let fraction = fraction.trim_end_matches('0'); |
| 17 | 17 | //! the methods and their arguments. | |
| 18 | 18 | ||
| 19 | 19 | mod features; | |
| 20 | + | mod limits; | |
| 20 | 21 | mod stripe; | |
| 21 | 22 | ||
| 22 | 23 | use g1t_contracts::billing::*; | |
| 134 | 135 | trial: Option<TrialConfig>, | |
| 135 | 136 | /// The Deployments plan's monthly price (`DEPLOYMENTS_MONTHLY_CENTS`). | |
| 136 | 137 | deployments_monthly_cents: u32, | |
| 138 | + | /// How far unpaid usage may go; see `limits`. | |
| 139 | + | ceilings: limits::Ceilings, | |
| 137 | 140 | } | |
| 138 | 141 | ||
| 139 | 142 | /// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`. | |
| 567 | 570 | if self.stripe.is_none() { | |
| 568 | 571 | return Ok(Outcome::Ok(true)); | |
| 569 | 572 | } | |
| 573 | + | if let Some(stopped) = self.stopped(&a.workspace).await? { | |
| 574 | + | return Ok(stopped); | |
| 575 | + | } | |
| 570 | 576 | Ok(self | |
| 571 | 577 | .out_of_credit(&a.workspace.to_lowercase()) | |
| 572 | 578 | .await? | |
| 578 | 584 | return Ok(Outcome::Ok(None)); | |
| 579 | 585 | } | |
| 580 | 586 | let workspace = a.workspace.to_lowercase(); | |
| 587 | + | if let Some(stopped) = self.stopped(&workspace).await? { | |
| 588 | + | return Ok(stopped); | |
| 589 | + | } | |
| 581 | 590 | if let Some(refused) = self.out_of_credit(&workspace).await? { | |
| 582 | 591 | return Ok(refused); | |
| 583 | 592 | } | |
| 809 | 818 | .and_then(|fee| fee.to_string().parse().ok()) | |
| 810 | 819 | .unwrap_or(100_000), | |
| 811 | 820 | free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"), | |
| 821 | + | ceilings: limits::Ceilings::from_env(&env), | |
| 812 | 822 | deployments_monthly_cents: env | |
| 813 | 823 | .var("DEPLOYMENTS_MONTHLY_CENTS") | |
| 814 | 824 | .ok() | |
| 852 | 862 | "has_feature" => reply(&billing.has_feature(args(body)?).await?), | |
| 853 | 863 | "charge_feature" => reply(&billing.charge_feature(args(body)?).await?), | |
| 854 | 864 | "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?), | |
| 855 | 868 | _ => Response::error("Unknown method", 404), | |
| 856 | 869 | } | |
| 857 | 870 | } |
| 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 | + | } |
| 30 | 30 | // recorded with what they cost, and nothing is charged. Set to | |
| 31 | 31 | // "false" when pricing starts. | |
| 32 | 32 | "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", | |
| 33 | 40 | // A free allowance on g1t's hosted models, so people can try g1t's | |
| 34 | 41 | // agents without a key of their own: each workspace may use this | |
| 35 | 42 | // much model cost ($1), out of one pool for everyone ($40), until |
| 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; |
| 34 | 34 | const ASSETS_ONLY = `export default { fetch(request, env) { return env.ASSETS.fetch(request); } };\n`; | |
| 35 | 35 | ||
| 36 | 36 | /** 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 | + | ||
| 37 | 43 | const TAKEN_DOWN = `export default { | |
| 38 | 44 | fetch() { | |
| 39 | 45 | 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" } }); | |
| 164 | 170 | * reaches the edge as fast as any deploy, and deleted after. | |
| 165 | 171 | */ | |
| 166 | 172 | 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); | |
| 174 | 174 | try { | |
| 175 | 175 | await this.call("DELETE", `${this.scriptPath(script)}?force=true`); | |
| 176 | 176 | } catch (error) { | |
| 179 | 179 | } | |
| 180 | 180 | ||
| 181 | 181 | /** | |
| 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 | + | /** | |
| 182 | 201 | * Requests and CPU time per app over a period, from Workers analytics. | |
| 183 | 202 | * Apps with no traffic are absent. | |
| 184 | 203 | */ |
| 147 | 147 | deployed_at: string; | |
| 148 | 148 | created_at: string; | |
| 149 | 149 | last_request_at: string | null; | |
| 150 | + | /** Set while its workspace is over its limit; see `holdToLimits`. */ | |
| 151 | + | paused_at: string | null; | |
| 150 | 152 | }; | |
| 151 | 153 | ||
| 152 | 154 | function toDeployment(row: DeploymentRow): Deployment { | |
| 578 | 580 | const id = newId("dpl"); | |
| 579 | 581 | const token = randomToken(); | |
| 580 | 582 | const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments"); | |
| 583 | + | const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace); | |
| 581 | 584 | const cloudflare = this.cloudflare; | |
| 582 | 585 | const refused = !plan.ok | |
| 583 | 586 | ? plan.error.message | |
| 587 | + | : limit.ok && limit.value.state === "stopped" | |
| 588 | + | ? (limit.value.message ?? "The workspace reached its usage limit.") | |
| 584 | 589 | : !cloudflare | |
| 585 | 590 | ? "Deployments are not set up on this g1t: it has no Cloudflare token." | |
| 586 | 591 | : null; | |
| 784 | 789 | .prepare( | |
| 785 | 790 | `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at) | |
| 786 | 791 | 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`, | |
| 788 | 793 | ) | |
| 789 | 794 | .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at), | |
| 790 | 795 | ]); | |
| 948 | 953 | ||
| 949 | 954 | // Apps of workspaces whose plan has ended come down. | |
| 950 | 955 | const billing = billingClient(this.env.BILLING); | |
| 956 | + | await this.holdToLimits(apps).catch((error) => console.error("could not apply limits", error)); | |
| 951 | 957 | for (const workspace of workspaces) { | |
| 952 | 958 | const plan = await billing.hasFeature(workspace, "deployments"); | |
| 953 | 959 | if (!plan.ok && plan.error.code === "payment_required") { | |
| 961 | 967 | await this.chargeMonths(); | |
| 962 | 968 | } | |
| 963 | 969 | ||
| 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 | + | ||
| 964 | 1009 | /** Scripts in the namespace that no app holds, such as ones renamed. */ | |
| 965 | 1010 | private async removeOrphans(apps: AppRow[]): Promise<void> { | |
| 966 | 1011 | const cloudflare = this.cloudflare; |
| 704 | 704 | repo: RepoPath; | |
| 705 | 705 | timeoutMinutes: number; | |
| 706 | 706 | }): Promise<Result<true>> { | |
| 707 | + | const over = await this.overLimit(args.repo.namespace); | |
| 708 | + | if (over) return { ok: false, error: { code: "payment_required", message: over } }; | |
| 707 | 709 | // The same workspaces that may use g1t's sandboxes for agents. | |
| 708 | 710 | if (!(await this.workspaceAllowed(args.repo.namespace))) { | |
| 709 | 711 | return { | |
| 787 | 789 | ||
| 788 | 790 | /** Whether a workspace's repositories may use g1t's agents and sandboxes at all. */ | |
| 789 | 791 | private async workspaceAllowed(namespace: string): Promise<boolean> { | |
| 792 | + | if (await this.overLimit(namespace)) return false; | |
| 790 | 793 | const access = await this.modelAccess(namespace); | |
| 791 | 794 | return access.own != null || access.hosted; | |
| 792 | 795 | } | |
| 793 | 796 | ||
| 794 | 797 | /** | |
| 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 | + | /** | |
| 795 | 810 | * Whether `viewer` may put agents to work: in `repo`'s workspace, which | |
| 796 | 811 | * must be allowed and theirs, or with no repo named, in any workspace of | |
| 797 | 812 | * theirs that is allowed. |