Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,067 linesCodeBlameRaw
1//! Whether a workspace may start compute, and holding what it may cost.
2//!
3//! Every service that starts something that costs g1t real money asks
4//! here first (see `g1t_contracts::billing::ReserveArgs`):
5//!
6//! - **`entitlements`**: the workspace's plan, whether it may start compute
7//! at all, its caps (agents at once, a run's time and spend, an issue's
8//! spend), its ceiling and exposure, and whether compute is paused.
9//! - **`reserve`**: holds the work's estimated cost against what may pay
10//! for it (the plan's included usage, the trial, the open-source pool,
11//! then on-demand room under the ceiling and the spend limit), so starts
12//! at the same moment cannot overshoot together. Answers who pays first,
13//! or refuses with a stable code and a message for the owner.
14//! - **`settle`**: releases the hold. The charge itself goes on the ledger
15//! the usual way; a hold never settled lapses after three hours.
16//!
17//! **No card, no compute.** A free workspace's forge is free, but compute
18//! needs the plan, or a card check: it unlocks the one-time trial and g1t's
19//! open-source pool (checks, workflows and the merge queue on public
20//! repositories). The card check is what keeps g1t's free compute from
21//! being mined: one trial per card, and a real person behind each.
22//!
23//! **Spikes.** An hour's spend above `SPIKE_FACTOR` (5) times the
24//! workspace's usual hour over the last week, and at least
25//! `SPIKE_FLOOR_MICROS` ($5), pauses new compute until an owner answers:
26//! keep going (for 24 hours, or until the hour's spend doubles again) or
27//! stop. Runs already under way finish. g1t's own workspaces are watched
28//! but never paused.
29
30use futures_util::future::{try_join, try_join4, try_join5};
31use g1t_contracts::billing::{
32 BillingAccount, ComputeKind, ConfirmSpikeArgs, SetCapsArgs, Entitlements, EntitlementsArgs, LimitState, PaidBy, PlanKind, Reservation,
33 ReserveArgs, SettleArgs, Spike, UNLIMITED_MICROS, UsageAlert, RESERVATION_HOURS,
34};
35use g1t_contracts::time::rfc3339;
36use g1t_contracts::{FailureCode, Outcome, new_id};
37use g1t_kit::now_ms;
38use serde::Deserialize;
39use worker::Result;
40use worker::wasm_bindgen::JsValue;
41
42use crate::Billing;
43use crate::credits::{self, left};
44use crate::features::dollars;
45use crate::limits::alert_level;
46
47/// Sandboxes (workflow jobs, checks, the queue, deploy builds) a workspace
48/// runs at once in the first month, on the free plan or the trial, and
49/// after: room for a workflow's matrix and a deploy side by side, where 2
50/// and 10, sized for g1t's former hosted agents, held CI behind one another.
51pub(crate) const FIRST_MONTH_AGENTS: u32 = 20;
52pub(crate) const AGENTS: u32 = 40;
53/// The longest run in the first month or on the trial, in minutes.
54pub(crate) const FIRST_MONTH_MINUTES: u32 = 60;
55/// How long "keep going" lifts a spike's pause.
56const KEEP_GOING_MS: u64 = 24 * 60 * 60 * 1000;
57/// The week a usual hour is measured over.
58const WEEK_HOURS: i64 = 7 * 24;
59
60/// What may pay for reserved work, at price, in the order it pays.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62pub(crate) struct Room {
63 pub credit: i64,
64 pub trial: i64,
65 pub oss: i64,
66 /// Under the ceiling and the spend limit; None: no bound (g1t's own).
67 pub on_demand: Option<i64>,
68}
69
70/// Why `place` could not hold an estimate.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub(crate) enum Short {
73 /// Nothing left that pays for it.
74 Empty,
75 /// Some room, but less than a paid workspace's whole estimate.
76 TooSmall,
77}
78
79/// Where a new hold of `estimate` goes, given what open holds take
80/// (`held`): the source that pays first, and what to hold. Holds fill the
81/// sources in order, so the first source with room after them pays first.
82/// A paid workspace's whole estimate must fit, so the ceiling cannot be
83/// overshot; a free workspace may use its last bit of trial, and what the
84/// run costs past it is g1t's.
85pub(crate) fn place(room: &Room, held: i64, estimate: i64, whole: bool) -> std::result::Result<(PaidBy, i64), Short> {
86 let sources = [
87 (PaidBy::Credit, room.credit.max(0)),
88 (PaidBy::Trial, room.trial.max(0)),
89 (PaidBy::Oss, room.oss.max(0)),
90 ];
91 let pools: i64 = sources.iter().map(|(_, room)| room).sum();
92 let remaining = match room.on_demand {
93 None => i64::MAX,
94 Some(on_demand) => pools + on_demand.max(0) - held.max(0),
95 };
96 if remaining <= 0 {
97 return Err(Short::Empty);
98 }
99 let estimate = estimate.max(0);
100 if whole && estimate > remaining {
101 return Err(Short::TooSmall);
102 }
103 let hold = estimate.min(remaining);
104 let mut end = 0;
105 for (source, size) in sources {
106 end += size;
107 if held.max(0) < end {
108 return Ok((source, hold));
109 }
110 }
111 Ok((PaidBy::OnDemand, hold))
112}
113
114/// Whether the last hour is a spike: above `factor` times the usual hour,
115/// and at least `floor`.
116pub(crate) fn is_spike(last_hour: i64, usual_hour: i64, factor: i64, floor: i64) -> bool {
117 last_hour >= floor.max(1) && last_hour > usual_hour.max(0) * factor
118}
119
120/// Whether a spike an owner said to keep going on still lets work start:
121/// within its 24 hours, and the hour's spend not doubled again.
122pub(crate) fn still_continued(until: Option<&str>, now: &str, hour_at_spike: i64, last_hour: i64) -> bool {
123 until.is_some_and(|until| now < until) && last_hour < hour_at_spike.max(1) * 2
124}
125
126/// A workspace's caps, from its plan.
127pub(crate) fn caps(plan: PlanKind, first_month: bool, on_trial: bool, agents: Option<u32>) -> (u32, u32) {
128 let tight = first_month || (plan == PlanKind::Free && on_trial) || plan == PlanKind::Free;
129 let default_agents = if tight { FIRST_MONTH_AGENTS } else { AGENTS };
130 let minutes = if tight { FIRST_MONTH_MINUTES } else { g1t_contracts::guardrails::MAX_MINUTES };
131 (agents.unwrap_or(default_agents), minutes)
132}
133
134/// The refusal for a start that cannot be held, with what to do.
135pub(crate) fn refusal(code: FailureCode, workspace: &str, kind: ComputeKind, detail: &str) -> Outcome<Reservation> {
136 let link = format!("/{workspace}/-/billing");
137 let what = match kind {
138 ComputeKind::Agent => "Agents",
139 ComputeKind::Check => "Checks",
140 ComputeKind::Workflow => "Workflows",
141 ComputeKind::Queue => "The merge queue",
142 ComputeKind::Deploy => "Deployments",
143 ComputeKind::Embedding => "Semantic search",
144 };
145 let message = match code {
146 FailureCode::NotPaid if kind.open_source_pool() => format!(
147 "{what} run in g1t's sandboxes, which cost real money, so they need the g1t plan ($20 a month) or a card check. A card check gives public repositories g1t's open-source pool and starts the $5 trial; it is never charged. Both are at {link}."
148 ),
149 FailureCode::NotPaid => format!(
150 "{what} cost real money to run, so they need the g1t plan ($20 a month, with $10 of usage included) or the one-time $5 trial, which starts with a card check that is never charged. Both are at {link}."
151 ),
152 FailureCode::TrialUsed => format!(
153 "This workspace has used its $5 trial. Start the g1t plan ($20 a month, with $10 of usage included) to keep going: {link}."
154 ),
155 FailureCode::OssPoolEmpty => format!(
156 "g1t's open-source pool for this month is used up{detail}, so checks and workflows on public repositories wait until the 1st. The g1t plan runs them now: {link}."
157 ),
158 FailureCode::Limit => format!("{detail} An owner can raise the limit, prepay, or ask g1t for more at {link}."),
159 FailureCode::Paused => format!("New compute is paused: {detail} An owner can see why and answer at {link}."),
160 _ => detail.to_owned(),
161 };
162 Outcome::fail(code, message)
163}
164
165#[derive(Deserialize)]
166struct SpikeRow {
167 id: String,
168 status: String,
169 hour_micros: i64,
170 average_micros: i64,
171 detected_at: String,
172 decided_by: Option<String>,
173 decided_at: Option<String>,
174 until: Option<String>,
175}
176
177impl From<SpikeRow> for Spike {
178 fn from(row: SpikeRow) -> Self {
179 Spike {
180 id: row.id,
181 status: row.status,
182 hour_micros: row.hour_micros,
183 average_micros: row.average_micros,
184 detected_at: row.detected_at,
185 decided_by: row.decided_by,
186 decided_at: row.decided_at,
187 until: row.until,
188 }
189 }
190}
191
192/// A workspace's pace: the last hour, the usual hour over the last week,
193/// the last day, at price (what was charged plus what paid for it first).
194#[derive(Clone, Copy, Debug, Default)]
195pub(crate) struct Pace {
196 pub last_hour: i64,
197 pub usual_hour: i64,
198 pub last_day: i64,
199}
200
201impl Billing {
202 /// Spend at price over the last hour, day and week.
203 pub(crate) async fn pace(&self, workspace: &str) -> Result<Pace> {
204 #[derive(Deserialize)]
205 struct Row {
206 hour: Option<i64>,
207 day: Option<i64>,
208 week: Option<i64>,
209 }
210 let now = now_ms();
211 let hour_ago = rfc3339(now - 60 * 60 * 1000);
212 let day_ago = rfc3339(now - 24 * 60 * 60 * 1000);
213 let week_ago = rfc3339(now - 7 * 24 * 60 * 60 * 1000);
214 let gross = "(-amount_micros + credit_micros + trial_micros + oss_micros + given_micros)";
215 let row = self
216 .db
217 .prepare(format!(
218 "SELECT SUM(CASE WHEN created_at >= ?2 THEN {gross} END) AS hour,
219 SUM(CASE WHEN created_at >= ?3 THEN {gross} END) AS day,
220 SUM(CASE WHEN created_at < ?2 THEN {gross} END) AS week
221 FROM ledger WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?4"
222 ))
223 .bind(&[workspace.into(), hour_ago.into(), day_ago.into(), week_ago.into()])?
224 .first::<Row>(None)
225 .await?;
226 Ok(row.map_or_else(Pace::default, |r| Pace {
227 last_hour: r.hour.unwrap_or(0).max(0),
228 usual_hour: r.week.unwrap_or(0).max(0) / (WEEK_HOURS - 1),
229 last_day: r.day.unwrap_or(0).max(0),
230 }))
231 }
232
233 /// The workspace's latest spike, if any.
234 pub(crate) async fn latest_spike(&self, workspace: &str) -> Result<Option<Spike>> {
235 Ok(self
236 .db
237 .prepare(
238 "SELECT id, status, hour_micros, average_micros, detected_at, decided_by, decided_at, until
239 FROM spikes WHERE workspace = ? ORDER BY detected_at DESC LIMIT 1",
240 )
241 .bind(&[workspace.into()])?
242 .first::<SpikeRow>(None)
243 .await?
244 .map(Spike::from))
245 }
246
247 /// The spike pausing the workspace now, found or new. Never for g1t's
248 /// own workspaces, which are watched in sudo but never paused.
249 async fn spike_pause(&self, workspace: &str, plan: PlanKind) -> Result<Option<Spike>> {
250 let latest = self.latest_spike(workspace).await?;
251 if let Some(spike) = &latest
252 && (spike.status == "open" || spike.status == "stopped") {
253 return Ok(latest);
254 }
255 if plan == PlanKind::Internal || self.stripe.is_none() {
256 return Ok(None);
257 }
258 let pace = self.pace(workspace).await?;
259 let now = rfc3339(now_ms());
260 if let Some(spike) = &latest
261 && spike.status == "continued" && still_continued(spike.until.as_deref(), &now, spike.hour_micros, pace.last_hour) {
262 return Ok(None);
263 }
264 if !is_spike(pace.last_hour, pace.usual_hour, self.plans.spike_factor, self.plans.spike_floor_micros) {
265 return Ok(None);
266 }
267 let id = new_id("spk", now_ms());
268 self.db
269 .prepare(
270 "INSERT INTO spikes (id, workspace, status, hour_micros, average_micros, detected_at)
271 SELECT ?1, ?2, 'open', ?3, ?4, ?5
272 WHERE NOT EXISTS (SELECT 1 FROM spikes WHERE workspace = ?2 AND status = 'open')",
273 )
274 .bind(&[id.as_str().into(), workspace.into(), (pace.last_hour as f64).into(), (pace.usual_hour as f64).into(), now.as_str().into()])?
275 .run()
276 .await?;
277 self.latest_spike(workspace).await
278 }
279
280 /// The alerts a workspace has reached this month: its plan's included
281 /// usage, its spend limit and g1t's ceiling, from 50%.
282 pub(crate) async fn alerts_for(&self, workspace: &str) -> Result<Vec<UsageAlert>> {
283 let account = self.account_of(workspace).await?;
284 let plan = self.plan_kind_for(workspace, &account).await?;
285 let has_plan = plan != PlanKind::Free;
286 let month = credits::month_of(&rfc3339(now_ms()));
287 let (limit, used) = try_join(self.limit_with(workspace, &account, plan), async {
288 if has_plan { self.allowance_used("plan_credit", workspace, &month).await } else { Ok(0) }
289 })
290 .await?;
291 Ok(alerts_from(workspace, &limit, has_plan, used, self.plans.plan_included_micros))
292 }
293}
294
295/// The alerts reached, from a limit already worked out, whether the
296/// workspace has the plan, and what of its included usage it has used.
297fn alerts_from(
298 workspace: &str,
299 limit: &g1t_contracts::billing::Limit,
300 has_plan: bool,
301 used: i64,
302 included: i64,
303) -> Vec<UsageAlert> {
304 let mut alerts = vec![];
305 if has_plan && limit.trust != g1t_contracts::billing::Trust::Internal {
306 let level = alert_level(used, included);
307 if level > 0 {
308 alerts.push(UsageAlert {
309 meter: "included".into(),
310 level,
311 used_micros: used,
312 limit_micros: included,
313 message: if level >= 100 {
314 format!("{workspace} has used all {} of this month's included usage. Usage from here is charged at cost plus 20%, up to your spend limit.", dollars(included))
315 } else {
316 format!("{workspace} has used {} of this month's {} included usage ({level}%). Past it, usage is charged at cost plus 20%, up to your spend limit.", dollars(used), dollars(included))
317 },
318 });
319 }
320 }
321 if let Some(spend_limit) = limit.spend_limit_micros {
322 let level = crate::limits::alert_level_in(limit.spent_micros, spend_limit, &limit.alert_levels);
323 if level > 0 {
324 alerts.push(UsageAlert {
325 meter: "spend_limit".into(),
326 level,
327 used_micros: limit.spent_micros,
328 limit_micros: spend_limit,
329 message: format!(
330 "{workspace} has spent {} of its {} monthly spend limit ({level}%). At the limit, new sandboxes, builds and agents stop until the month turns or an owner raises it.",
331 dollars(limit.spent_micros),
332 dollars(spend_limit)
333 ),
334 });
335 }
336 }
337 if let Some(ceiling) = limit.ceiling_micros.filter(|_| limit.trust != g1t_contracts::billing::Trust::New) {
338 let level = alert_level(limit.exposure_micros, ceiling);
339 if level > 0 {
340 alerts.push(UsageAlert {
341 meter: "ceiling".into(),
342 level,
343 used_micros: limit.exposure_micros,
344 limit_micros: ceiling,
345 message: format!(
346 "{workspace} has {} of usage not yet paid for, of the {} g1t allows ({level}%). With a card on file g1t charges it as the limit nears; prepaying raises it at once.",
347 dollars(limit.exposure_micros),
348 dollars(ceiling)
349 ),
350 });
351 }
352 }
353 alerts
354}
355
356impl Billing {
357 /// Whether a card check was done for the workspace.
358 pub(crate) async fn card_checked(&self, workspace: &str) -> Result<bool> {
359 Ok(self
360 .db
361 .prepare("SELECT workspace FROM card_checks WHERE workspace = ?")
362 .bind(&[workspace.into()])?
363 .first::<serde_json::Value>(None)
364 .await?
365 .is_some())
366 }
367
368 /// What open reservations hold across `members`, at price.
369 async fn held(&self, members: &[String]) -> Result<i64> {
370 #[derive(Deserialize)]
371 struct Row {
372 held: Option<i64>,
373 }
374 let marks = vec!["?"; members.len().max(1)].join(", ");
375 let mut values: Vec<JsValue> = members.iter().map(|m| JsValue::from(m.as_str())).collect();
376 if values.is_empty() {
377 values.push("".into());
378 }
379 values.push(rfc3339(now_ms()).into());
380 Ok(self
381 .db
382 .prepare(format!(
383 "SELECT SUM(hold_micros) AS held FROM reservations
384 WHERE workspace IN ({marks}) AND settled_at IS NULL AND expires_at > ?"
385 ))
386 .bind(&values)?
387 .first::<Row>(None)
388 .await?
389 .and_then(|r| r.held)
390 .unwrap_or(0))
391 }
392
393 /// Why new compute is paused, if it is: a staff hold, a spike waiting
394 /// for an owner (or stopped by one), or the limit reached.
395 async fn pause_reason(
396 &self,
397 workspace: &str,
398 account: &BillingAccount,
399 plan: PlanKind,
400 limit: Option<&g1t_contracts::billing::Limit>,
401 ) -> Result<(Option<String>, Option<Spike>, Option<FailureCode>)> {
402 if let Some(hold) = account.allowances.hold.as_deref().filter(|h| !h.trim().is_empty()) {
403 return Ok((Some(format!("g1t staff put a hold on new compute ({}).", hold.trim())), None, Some(FailureCode::Paused)));
404 }
405 // A comped account past its monthly budget (`budget`).
406 if let Some(why) = self.comped_stop(account).await? {
407 return Ok((Some(why), None, Some(FailureCode::Paused)));
408 }
409 let spike = self.spike_pause(workspace, plan).await?;
410 if let Some(spike) = &spike {
411 let why = if spike.status == "stopped" {
412 format!(
413 "an owner stopped new compute after a spend spike ({} in an hour). An owner can choose Keep going.",
414 dollars(spike.hour_micros)
415 )
416 } else {
417 format!(
418 "{} was spent in an hour, more than {} times the usual {} an hour, so new compute waits for an owner to confirm.",
419 dollars(spike.hour_micros),
420 self.plans.spike_factor,
421 dollars(spike.average_micros)
422 )
423 };
424 return Ok((Some(why), Some(spike.clone()), Some(FailureCode::Paused)));
425 }
426 let latest = self.latest_spike(workspace).await?;
427 if plan != PlanKind::Internal && self.stripe.is_some() {
428 let worked_out;
429 let limit = match limit {
430 Some(limit) => limit,
431 None => {
432 worked_out = self.limit_of(workspace).await?;
433 &worked_out
434 }
435 };
436 if limit.state == LimitState::Stopped {
437 return Ok((limit.message.clone(), latest, Some(FailureCode::Limit)));
438 }
439 }
440 Ok((None, latest, None))
441 }
442
443 /// `entitlements`: what the workspace may do now.
444 pub(crate) async fn entitlements(&self, a: EntitlementsArgs) -> Result<Entitlements> {
445 let workspace = a.workspace.to_lowercase();
446 let account = self.account_of(&workspace).await?;
447 let plan = self.plan_kind_for(&workspace, &account).await?;
448 let has_plan = plan != PlanKind::Free;
449 let now = rfc3339(now_ms());
450 let month = credits::month_of(&now);
451 // Every signed-in page asks for this, so what does not need another
452 // answer is read at once: the limit (and the pause, from it), the
453 // trial, and the month's counts.
454 let standing = async {
455 let limit = self.limit_with(&workspace, &account, plan).await?;
456 let pause = self.pause_reason(&workspace, &account, plan, Some(&limit)).await?;
457 Ok::<_, worker::Error>((limit, pause))
458 };
459 let trial = async {
460 let (checked, grant) = try_join(
461 async {
462 if matches!(plan, PlanKind::Internal | PlanKind::Enterprise) { Ok(true) } else { self.card_checked(&workspace).await }
463 },
464 self.grant_of(&workspace),
465 )
466 .await?;
467 // A card checked while the month's pool was empty: granted once
468 // it has room.
469 let grant = match grant {
470 Some(grant) => Some(grant),
471 None if checked && plan == PlanKind::Free && self.trial_allowed(&workspace).await? => self.ensure_grant(&workspace).await?,
472 None => None,
473 };
474 Ok::<_, worker::Error>((checked, grant))
475 };
476 let counts = try_join5(
477 self.owner_caps(&workspace),
478 self.private_storage(&workspace),
479 self.oss_paid(&workspace, &month),
480 self.held(&account.workspaces),
481 async { if has_plan { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) } },
482 );
483 let more = try_join(self.allowance_used("build_seconds", &workspace, &month), self.git_operations_this_month(&workspace));
484 let ((limit, (paused, spike, _)), (verified, grant), (owners, stored, oss, held, included_used), (build_seconds, git_operations)) =
485 try_join4(standing, trial, counts, more).await?;
486 let trial_left = grant.as_ref().map_or(0, |g| left(g.granted_micros, g.used_micros));
487 let first_month = limit.first_month;
488 let (max_agents, max_minutes) = caps(plan, first_month, trial_left > 0, account.allowances.max_concurrent_agents);
489 let ceiling = match plan {
490 PlanKind::Free => 0,
491 PlanKind::Internal => UNLIMITED_MICROS,
492 _ => limit.ceiling_micros.unwrap_or(UNLIMITED_MICROS),
493 };
494 let alerts = alerts_from(&workspace, &limit, has_plan, included_used, self.plans.plan_included_micros);
495 Ok(Entitlements {
496 plan,
497 compute: has_plan || trial_left > 0,
498 trial_micros_left: trial_left,
499 trial_verified: verified,
500 first_month,
501 max_concurrent_agents: max_agents,
502 max_run_minutes: max_minutes,
503 run_cap_micros: account.allowances.run_cap_micros.or(owners.0).unwrap_or(self.plans.run_cap_micros),
504 issue_cap_micros: account.allowances.issue_cap_micros.or(owners.1).unwrap_or(self.plans.issue_cap_micros),
505 ceiling_micros: ceiling,
506 exposure_micros: limit.exposure_micros,
507 paused,
508 held: account.allowances.hold.as_deref().is_some_and(|hold| !hold.trim().is_empty()),
509 held_micros: held,
510 prepaid_micros: limit.prepaid_micros,
511 included_micros: if has_plan { self.plans.plan_included_micros } else { 0 },
512 included_used_micros: included_used,
513 audit_retention_days: crate::retention::effective_days(&self.plans, plan, account.allowances.audit_retention_days),
514 audit_retention_custom: account.allowances.audit_retention_days.is_some(),
515 free_private_storage_bytes: self.plans.free_storage_bytes,
516 // For the packages service: on the plan nothing is refused; without it,
517 // pushes past these amounts are.
518 has_plan,
519 package_public_free_bytes: self.plans.public_package_free_bytes,
520 package_private_free_bytes: self.plans.private_package_free_bytes,
521 private_storage_bytes: stored,
522 oss_paid_micros: oss,
523 build_seconds_used: build_seconds.max(0) as u32,
524 git_operations,
525 git_operations_included: self.plans.git_included,
526 min_charge_micros: self.plans.min_charge_micros,
527 spike,
528 alerts,
529 workspace,
530 })
531 }
532
533 /// `reserve`: holds a start's estimated cost, or says why not.
534 pub(crate) async fn reserve(&self, a: ReserveArgs) -> Result<Outcome<Reservation>> {
535 let workspace = a.workspace.to_lowercase();
536 let now = now_ms();
537 let expires_at = rfc3339(now + RESERVATION_HOURS * 60 * 60 * 1000);
538 let repo = format!("{}/{}", a.repo.namespace, a.repo.name).to_lowercase();
539 // A platform pause holds for everyone, a g1t that does not charge
540 // included (platform.rs): kept 30 seconds in the isolate.
541 if let Some(why) = self.platform_refuses(a.kind).await {
542 return Ok(Outcome::fail(FailureCode::Paused, why));
543 }
544 // A g1t that does not charge holds nothing.
545 if self.stripe.is_none() {
546 return Ok(Outcome::Ok(Reservation { id: new_id("rsv", now), paid_by: PaidBy::OnDemand, held_micros: 0, expires_at }));
547 }
548 let account = self.account_of(&workspace).await?;
549 let plan = self.plan_kind_for(&workspace, &account).await?;
550 // g1t's own caps (`budget`), in their own words: a comped account's
551 // monthly budget, and the daily breaker.
552 if let Some(why) = self.comped_stop(&account).await? {
553 return Ok(Outcome::fail(FailureCode::Paused, why));
554 }
555 if let Some(why) = self.breaker_refuses(plan, &account, a.kind).await? {
556 return Ok(Outcome::fail(FailureCode::Paused, why));
557 }
558 let limit = if plan == PlanKind::Internal { None } else { Some(self.limit_with(&workspace, &account, plan).await?) };
559 let (paused, _, code) = self.pause_reason(&workspace, &account, plan, limit.as_ref()).await?;
560 if let (Some(why), Some(code)) = (paused, code) {
561 return Ok(refusal(code, &workspace, a.kind, &why));
562 }
563 let month = credits::month_of(&rfc3339(now));
564 let estimate = credits::with_margin(a.estimate_micros, self.margin_percent);
565 let verified = matches!(plan, PlanKind::Internal | PlanKind::Enterprise | PlanKind::Paid) || self.card_checked(&workspace).await?;
566 let has_plan = plan != PlanKind::Free;
567 let credit = if has_plan {
568 left(self.plans.plan_included_micros, self.allowance_used("plan_credit", &workspace, &month).await?)
569 } else {
570 0
571 };
572 let trial = if a.kind == ComputeKind::Deploy || !verified {
573 0
574 } else {
575 self.grant_of(&workspace).await?.map_or(0, |g| left(g.granted_micros, g.used_micros))
576 };
577 let oss_eligible = a.public && a.kind.open_source_pool() && verified;
578 let oss = if oss_eligible { self.oss_left(&workspace, &repo, &month).await? } else { 0 };
579 let on_demand = match plan {
580 PlanKind::Free => Some(0),
581 PlanKind::Internal => None,
582 _ => {
583 let Some(limit) = &limit else { unreachable!("only g1t's own workspaces skip the limit") };
584 let under_ceiling = limit.ceiling_micros.map(|c| (c - limit.exposure_micros).max(0));
585 let under_spend = limit.spend_limit_micros.map(|s| (s - limit.spent_micros).max(0));
586 match (under_ceiling, under_spend) {
587 (Some(c), Some(s)) => Some(c.min(s)),
588 (c, s) => c.or(s),
589 }
590 }
591 };
592 let room = Room { credit, trial, oss, on_demand };
593 // Optimistic: what was held is read, and the hold is written only if
594 // nothing was held meanwhile; otherwise read again.
595 for _ in 0..4 {
596 let held = self.held(&account.workspaces).await?;
597 let (paid_by, hold) = match place(&room, held, estimate, has_plan) {
598 Ok(placed) => placed,
599 Err(short) => return self.short(&workspace, plan, &a, verified, &room, short).await,
600 };
601 let id = new_id("rsv", now);
602 let mut values: Vec<JsValue> = vec![
603 id.as_str().into(),
604 workspace.as_str().into(),
605 repo.as_str().into(),
606 a.kind.as_str().into(),
607 u8::from(a.public).into(),
608 (a.estimate_micros.max(0) as f64).into(),
609 (hold as f64).into(),
610 paid_by_text(paid_by).into(),
611 rfc3339(now).into(),
612 expires_at.as_str().into(),
613 (held as f64).into(),
614 ];
615 values.extend(account.workspaces.iter().map(|w| JsValue::from(w.as_str())));
616 if account.workspaces.is_empty() {
617 values.push("".into());
618 }
619 let placed = self
620 .db
621 .prepare(format!(
622 "INSERT INTO reservations (id, workspace, repo, kind, public, estimate_micros, hold_micros, paid_by, created_at, expires_at)
623 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
624 WHERE (SELECT COALESCE(SUM(hold_micros), 0) FROM reservations
625 WHERE workspace IN ({marks}) AND settled_at IS NULL AND expires_at > ?9) = ?11
626 RETURNING id",
627 marks = (12..12 + account.workspaces.len().max(1)).map(|i| format!("?{i}")).collect::<Vec<_>>().join(", ")
628 ))
629 .bind(&values)?
630 .first::<serde_json::Value>(None)
631 .await?;
632 if placed.is_some() {
633 // What is held, at cost, as it was asked.
634 let held_cost = if hold >= estimate { a.estimate_micros.max(0) } else { hold * 100 / i64::from(100 + self.margin_percent) };
635 return Ok(Outcome::Ok(Reservation { id, paid_by, held_micros: held_cost, expires_at }));
636 }
637 }
638 Ok(refusal(FailureCode::Limit, &workspace, a.kind, "Too many starts at once to hold this one; try again in a moment."))
639 }
640
641 /// The refusal for a start nothing pays for.
642 async fn short(
643 &self,
644 workspace: &str,
645 plan: PlanKind,
646 a: &ReserveArgs,
647 verified: bool,
648 room: &Room,
649 short: Short,
650 ) -> Result<Outcome<Reservation>> {
651 if plan != PlanKind::Free {
652 let limit = self.limit_of(workspace).await?;
653 let detail = match short {
654 Short::TooSmall => format!(
655 "This would take the workspace past its limit: about {} more could start now ({} spent of a {} spend limit, {} not yet paid of the {} g1t allows).",
656 dollars(room.credit + room.trial + room.oss + room.on_demand.unwrap_or(0)),
657 dollars(limit.spent_micros),
658 dollars(limit.spend_limit_micros.unwrap_or_default()),
659 dollars(limit.exposure_micros),
660 dollars(limit.ceiling_micros.unwrap_or_default()),
661 ),
662 Short::Empty => limit.message.unwrap_or_else(|| "The workspace reached its limit for this month.".to_owned()),
663 };
664 return Ok(refusal(FailureCode::Limit, workspace, a.kind, &detail));
665 }
666 if !verified {
667 return Ok(refusal(FailureCode::NotPaid, workspace, a.kind, ""));
668 }
669 let oss_eligible = a.public && a.kind.open_source_pool();
670 let trial = self.grant_of(workspace).await?;
671 if oss_eligible && room.oss == 0 {
672 let month = credits::month_of(&rfc3339(now_ms()));
673 let repo = format!("{}/{}", a.repo.namespace, a.repo.name).to_lowercase();
674 let pool = left(self.plans.oss_pool_micros, self.allowance_used("oss_pool", "", &month).await?);
675 let detail = if pool > 0 { format!(" for {repo} (its share is {})", dollars(self.oss_repo_cap(workspace).await?)) } else { String::new() };
676 if trial.as_ref().is_none_or(|g| g.used_micros >= g.granted_micros) {
677 return Ok(refusal(FailureCode::OssPoolEmpty, workspace, a.kind, &detail));
678 }
679 }
680 match trial {
681 Some(grant) if grant.used_micros >= grant.granted_micros => Ok(refusal(FailureCode::TrialUsed, workspace, a.kind, "")),
682 _ => Ok(refusal(FailureCode::NotPaid, workspace, a.kind, "")),
683 }
684 }
685
686 /// `settle`: releases a hold.
687 pub(crate) async fn settle_reservation(&self, a: SettleArgs) -> Result<Outcome<bool>> {
688 let now = rfc3339(now_ms());
689 let settled = self
690 .db
691 .prepare(
692 "UPDATE reservations SET settled_at = ?1, actual_micros = ?2
693 WHERE id = ?3 AND settled_at IS NULL AND expires_at > ?1 RETURNING id",
694 )
695 .bind(&[now.as_str().into(), (a.actual_micros.max(0) as f64).into(), a.reservation_id.as_str().into()])?
696 .first::<serde_json::Value>(None)
697 .await?;
698 Ok(Outcome::Ok(settled.is_some()))
699 }
700
701 /// Clears reservations long settled or lapsed.
702 pub(crate) async fn sweep_reservations(&self) -> Result<()> {
703 let week_ago = rfc3339(now_ms() - 7 * 24 * 60 * 60 * 1000);
704 self.db
705 .prepare("DELETE FROM reservations WHERE expires_at < ?1")
706 .bind(&[week_ago.into()])?
707 .run()
708 .await?;
709 Ok(())
710 }
711
712 /// `confirm_spike`: an owner keeps going, or stops.
713 pub(crate) async fn confirm_spike(&self, a: ConfirmSpikeArgs) -> Result<Outcome<Entitlements>> {
714 let workspace = a.workspace.to_lowercase();
715 if !a.actor.manages_billing(&workspace) {
716 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can answer a spend spike."));
717 }
718 let Some(spike) = self.latest_spike(&workspace).await? else {
719 return Ok(Outcome::fail(FailureCode::NotFound, "There is no spend spike to answer."));
720 };
721 let now = now_ms();
722 let (status, until) = if a.keep_going { ("continued", Some(rfc3339(now + KEEP_GOING_MS))) } else { ("stopped", None) };
723 self.db
724 .prepare("UPDATE spikes SET status = ?1, decided_by = ?2, decided_at = ?3, until = ?4 WHERE id = ?5")
725 .bind(&[
726 status.into(),
727 a.actor.username.as_str().into(),
728 rfc3339(now).into(),
729 crate::optional(until.as_deref()),
730 spike.id.as_str().into(),
731 ])?
732 .run()
733 .await?;
734 let account = self.account_of(&workspace).await?;
735 self.audit(
736 &account.id,
737 "spike",
738 &format!("{workspace}: {} after {} in an hour", if a.keep_going { "kept going" } else { "stopped" }, dollars(spike.hour_micros)),
739 &a.actor.username,
740 )
741 .await?;
742 Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?))
743 }
744
745 /// The owners' own run and issue caps, if they set them.
746 async fn owner_caps(&self, workspace: &str) -> Result<(Option<i64>, Option<i64>)> {
747 #[derive(Deserialize)]
748 struct Row {
749 run_cap_micros: Option<i64>,
750 issue_cap_micros: Option<i64>,
751 }
752 Ok(self
753 .db
754 .prepare("SELECT run_cap_micros, issue_cap_micros FROM limits WHERE workspace = ?")
755 .bind(&[workspace.into()])?
756 .first::<Row>(None)
757 .await?
758 .map_or((None, None), |r| (r.run_cap_micros, r.issue_cap_micros)))
759 }
760
761 /// `set_caps`: the owners' own run and issue caps.
762 pub(crate) async fn set_caps(&self, a: SetCapsArgs) -> Result<Outcome<Entitlements>> {
763 let workspace = a.workspace.to_lowercase();
764 if !a.actor.manages_billing(&workspace) {
765 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can set the workspace's caps."));
766 }
767 if let Err(why) = cap_bounds(a.run_cap_micros, a.issue_cap_micros) {
768 return Ok(Outcome::fail(FailureCode::Invalid, why));
769 }
770 let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
771 let now = rfc3339(now_ms());
772 self.db
773 .prepare(
774 "INSERT INTO limits (workspace, run_cap_micros, issue_cap_micros, updated_at) VALUES (?1, ?2, ?3, ?4)
775 ON CONFLICT (workspace) DO UPDATE SET run_cap_micros = ?2, issue_cap_micros = ?3, updated_at = ?4",
776 )
777 .bind(&[workspace.as_str().into(), opt(a.run_cap_micros), opt(a.issue_cap_micros), now.into()])?
778 .run()
779 .await?;
780 let account = self.account_of(&workspace).await?;
781 let shown = |m: Option<i64>| m.map_or_else(|| "the default".to_owned(), dollars);
782 self.audit(
783 &account.id,
784 "caps",
785 &format!("{workspace}: run cap {}, issue cap {}", shown(a.run_cap_micros), shown(a.issue_cap_micros)),
786 &a.actor.username,
787 )
788 .await?;
789 Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?))
790 }
791
792 /// Emails owners about spikes that paused their workspace, once each.
793 pub(crate) async fn tell_spikes(&self, identity: &worker::Fetcher) -> Result<()> {
794 #[derive(Deserialize)]
795 struct Open {
796 id: String,
797 workspace: String,
798 hour_micros: i64,
799 average_micros: i64,
800 }
801 let open = self
802 .db
803 .prepare("SELECT id, workspace, hour_micros, average_micros FROM spikes WHERE status = 'open' AND told_at IS NULL LIMIT 20")
804 .all()
805 .await?
806 .results::<Open>()?;
807 for spike in open {
808 let workspace = &spike.workspace;
809 let intro = format!(
810 "{workspace} spent {} in the last hour, more than {} times its usual {} an hour, so g1t paused new sandboxes, agents and builds until an owner confirms. Runs already going finish. If this was meant, choose Keep going and nothing pauses for 24 hours unless the hour's spend doubles again. If not, choose Stop; and if it was a mistake, tell g1t from the billing page.",
811 dollars(spike.hour_micros),
812 self.plans.spike_factor,
813 dollars(spike.average_micros)
814 );
815 let link = format!("https://g1t.sh/{workspace}/-/billing");
816 if crate::limits::notify(identity, workspace, &format!("g1t: spending on {workspace} spiked, so new work is paused"), &intro, "Keep going or stop", &link).await {
817 self.db
818 .prepare("UPDATE spikes SET told_at = ? WHERE id = ?")
819 .bind(&[rfc3339(now_ms()).into(), spike.id.as_str().into()])?
820 .run()
821 .await?;
822 }
823 }
824 Ok(())
825 }
826
827 /// What the workspace's private repositories held at the last measure.
828 pub(crate) async fn private_storage(&self, workspace: &str) -> Result<i64> {
829 #[derive(Deserialize)]
830 struct Stored {
831 private_bytes: Option<i64>,
832 }
833 Ok(self
834 .db
835 .prepare("SELECT private_bytes FROM storage_days WHERE workspace = ? ORDER BY day DESC LIMIT 1")
836 .bind(&[workspace.into()])?
837 .first::<Stored>(None)
838 .await?
839 .and_then(|s| s.private_bytes)
840 .unwrap_or(0))
841 }
842
843 /// What g1t's open-source pool paid for the workspace in `month`.
844 async fn oss_paid(&self, workspace: &str, month: &str) -> Result<i64> {
845 #[derive(Deserialize)]
846 struct Sum {
847 micros: Option<i64>,
848 }
849 Ok(self
850 .db
851 .prepare("SELECT SUM(oss_micros) AS micros FROM ledger WHERE workspace = ? AND created_at >= ?")
852 .bind(&[workspace.into(), format!("{month}-01").into()])?
853 .first::<Sum>(None)
854 .await?
855 .and_then(|s| s.micros)
856 .unwrap_or(0))
857 }
858}
859
860/// Whether owners' caps are in bounds: a run $0.10 to $100 (the
861/// guardrails' most), an issue $1 to $1,000.
862pub(crate) fn cap_bounds(run: Option<i64>, issue: Option<i64>) -> std::result::Result<(), String> {
863 if run.is_some_and(|m| !(100_000..=100_000_000).contains(&m)) {
864 return Err("A run's cap is between $0.10 and $100.".to_owned());
865 }
866 if issue.is_some_and(|m| !(1_000_000..=1_000_000_000).contains(&m)) {
867 return Err("An issue's cap is between $1 and $1,000.".to_owned());
868 }
869 Ok(())
870}
871
872pub(crate) fn paid_by_text(paid_by: PaidBy) -> &'static str {
873 match paid_by {
874 PaidBy::Credit => "credit",
875 PaidBy::Trial => "trial",
876 PaidBy::Oss => "oss",
877 PaidBy::OnDemand => "on_demand",
878 }
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884
885 fn limit(trust: g1t_contracts::billing::Trust, spent: i64, spend_limit: Option<i64>, exposure: i64, ceiling: Option<i64>) -> g1t_contracts::billing::Limit {
886 g1t_contracts::billing::Limit {
887 workspace: "acme".into(),
888 account: "acc_acme".into(),
889 account_name: "acme".into(),
890 trust,
891 exposure_micros: exposure,
892 ceiling_micros: ceiling,
893 trust_ceiling_micros: ceiling,
894 spend_limit_micros: spend_limit,
895 state: LimitState::Ok,
896 message: None,
897 spent_micros: spent,
898 default_spend_limit: false,
899 available_micros: None,
900 growth: None,
901 prepaid_micros: 0,
902 max_ceiling_micros: None,
903 raise_once_micros: None,
904 raised_at: None,
905 first_month: false,
906 alert_levels: crate::limits::ALERT_LEVELS.to_vec(),
907 pause_at_limit: true,
908 budget_webhook: None,
909 }
910 }
911
912 #[test]
913 fn alerts_come_from_the_answers_already_read() {
914 use g1t_contracts::billing::Trust;
915 let meters = |alerts: Vec<UsageAlert>| alerts.into_iter().map(|a| (a.meter, a.level)).collect::<Vec<_>>();
916 // On the plan: included usage at 90%, the spend limit at 50%, the ceiling at 75%.
917 let paid = limit(Trust::Paid, 100_000_000, Some(200_000_000), 75_000_000, Some(100_000_000));
918 assert_eq!(
919 meters(alerts_from("acme", &paid, true, 9_000_000, 10_000_000)),
920 vec![("included".to_owned(), 90), ("spend_limit".to_owned(), 50), ("ceiling".to_owned(), 75)]
921 );
922 // Without the plan, what was used of the included usage is not an alert.
923 assert_eq!(meters(alerts_from("acme", &paid, false, 9_000_000, 10_000_000)).len(), 2);
924 // g1t's own workspaces have no included usage to warn of, and a new
925 // workspace's ceiling is not one either.
926 let internal = limit(Trust::Internal, 0, None, 0, None);
927 assert!(alerts_from("acme", &internal, true, 10_000_000, 10_000_000).is_empty());
928 let new = limit(Trust::New, 0, None, 3_000_000, Some(3_000_000));
929 assert!(alerts_from("acme", &new, false, 0, 10_000_000).is_empty());
930 }
931
932 fn paid(credit: i64, on_demand: i64) -> Room {
933 Room { credit, trial: 0, oss: 0, on_demand: Some(on_demand) }
934 }
935
936 #[test]
937 fn included_usage_pays_first_then_on_demand() {
938 // $10 included, $100 under the ceiling, nothing held: included pays first.
939 assert_eq!(place(&paid(10_000_000, 100_000_000), 0, 2_400_000, true), Ok((PaidBy::Credit, 2_400_000)));
940 // Holds already cover the included usage: on demand.
941 assert_eq!(place(&paid(10_000_000, 100_000_000), 10_000_000, 2_400_000, true), Ok((PaidBy::OnDemand, 2_400_000)));
942 // A free workspace on its trial.
943 let trial = Room { trial: 5_000_000, on_demand: Some(0), ..Room::default() };
944 assert_eq!(place(&trial, 0, 2_400_000, false), Ok((PaidBy::Trial, 2_400_000)));
945 // A public repository's checks, from the pool.
946 let pool = Room { oss: 2_000_000, on_demand: Some(0), ..Room::default() };
947 assert_eq!(place(&pool, 0, 600_000, false), Ok((PaidBy::Oss, 600_000)));
948 }
949
950 #[test]
951 fn a_paid_workspace_is_stopped_only_by_its_limit_never_by_a_count() {
952 // The included $10 is gone and the workspace has already built,
953 // served and stored far past what used to be quotas: the next start
954 // still goes on demand, as long as its spend limit has room.
955 let room = paid(0, 250_000_000);
956 let held = 0;
957 for start in 0..1_000 {
958 let placed = place(&room, held + start * 100_000, 100_000, true);
959 assert_eq!(placed, Ok((PaidBy::OnDemand, 100_000)));
960 }
961 // Only at its limit does it stop, with the limit's refusal.
962 assert_eq!(place(&room, 250_000_000, 100_000, true), Err(Short::Empty));
963 // A free workspace has no on-demand room at all: with no trial or
964 // pool left, nothing starts (`short` says NotPaid or TrialUsed).
965 let free = Room { on_demand: Some(0), ..Room::default() };
966 assert_eq!(place(&free, 0, 100_000, false), Err(Short::Empty));
967 // g1t's own workspaces: no limit.
968 let internal = Room { on_demand: None, ..Room::default() };
969 assert_eq!(place(&internal, i64::MAX / 2, 100_000, true), Ok((PaidBy::OnDemand, 100_000)));
970 }
971
972 #[test]
973 fn concurrent_starts_cannot_overshoot_the_ceiling() {
974 // $5 of room in all, and each start may cost up to $2.40.
975 let room = paid(0, 5_000_000);
976 let mut held = 0;
977 let mut started = 0;
978 for _ in 0..5 {
979 match place(&room, held, 2_400_000, true) {
980 Ok((_, hold)) => {
981 held += hold;
982 started += 1;
983 }
984 Err(short) => assert_eq!(short, Short::TooSmall),
985 }
986 }
987 assert_eq!(started, 2);
988 assert!(held <= 5_000_000);
989 // Once the first settles, a third fits.
990 assert!(place(&room, held - 2_400_000, 2_400_000, true).is_ok());
991 // Nothing left at all.
992 assert_eq!(place(&room, 5_000_000, 1, true), Err(Short::Empty));
993 }
994
995 #[test]
996 fn a_free_workspace_may_use_its_last_bit_of_trial() {
997 let room = Room { trial: 300_000, on_demand: Some(0), ..Room::default() };
998 // The whole estimate does not fit, but what is left is held.
999 assert_eq!(place(&room, 0, 2_400_000, false), Ok((PaidBy::Trial, 300_000)));
1000 // Once it is held, nothing more starts.
1001 assert_eq!(place(&room, 300_000, 2_400_000, false), Err(Short::Empty));
1002 // Nothing at all: no plan, no trial, no pool.
1003 assert_eq!(place(&Room { on_demand: Some(0), ..Room::default() }, 0, 1, false), Err(Short::Empty));
1004 }
1005
1006 #[test]
1007 fn g1ts_own_workspaces_are_never_short() {
1008 let room = Room { credit: 10_000_000, on_demand: None, ..Room::default() };
1009 assert_eq!(place(&room, 50_000_000_000, 2_400_000, true), Ok((PaidBy::OnDemand, 2_400_000)));
1010 assert_eq!(place(&room, 0, 2_400_000, true), Ok((PaidBy::Credit, 2_400_000)));
1011 }
1012
1013 #[test]
1014 fn a_spike_is_five_times_the_usual_hour_and_at_least_five_dollars() {
1015 let (factor, floor) = (5, 5_000_000);
1016 // A new workspace with no history: $5 in an hour is a spike, $4 is not.
1017 assert!(is_spike(5_000_000, 0, factor, floor));
1018 assert!(!is_spike(4_000_000, 0, factor, floor));
1019 // Usually $2 an hour: $10 is not above five times, $10.01 is.
1020 assert!(!is_spike(10_000_000, 2_000_000, factor, floor));
1021 assert!(is_spike(10_010_000, 2_000_000, factor, floor));
1022 // A busy workspace at its usual pace is never a spike.
1023 assert!(!is_spike(40_000_000, 30_000_000, factor, floor));
1024 }
1025
1026 #[test]
1027 fn keep_going_lasts_a_day_or_until_spend_doubles() {
1028 let until = "2026-10-06T12:00:00Z";
1029 assert!(still_continued(Some(until), "2026-10-06T11:00:00Z", 6_000_000, 8_000_000));
1030 // Doubled again: paused again.
1031 assert!(!still_continued(Some(until), "2026-10-06T11:00:00Z", 6_000_000, 12_000_000));
1032 // A day later: watched afresh.
1033 assert!(!still_continued(Some(until), "2026-10-06T12:00:01Z", 6_000_000, 1_000_000));
1034 assert!(!still_continued(None, "2026-10-06T11:00:00Z", 6_000_000, 1));
1035 }
1036
1037 #[test]
1038 fn owners_caps_stay_in_bounds() {
1039 assert!(cap_bounds(None, None).is_ok());
1040 assert!(cap_bounds(Some(5_000_000), Some(50_000_000)).is_ok());
1041 assert!(cap_bounds(Some(50_000), None).is_err());
1042 assert!(cap_bounds(Some(101_000_000), None).is_err());
1043 assert!(cap_bounds(None, Some(500_000)).is_err());
1044 assert!(cap_bounds(None, Some(2_000_000_000)).is_err());
1045 }
1046
1047 #[test]
1048 fn caps_are_tight_in_the_first_month_and_on_the_trial() {
1049 assert_eq!(caps(PlanKind::Paid, true, false, None), (20, 60));
1050 assert_eq!(caps(PlanKind::Free, false, true, None), (20, 60));
1051 assert_eq!(caps(PlanKind::Paid, false, false, None), (40, g1t_contracts::guardrails::MAX_MINUTES));
1052 assert_eq!(caps(PlanKind::Internal, false, false, None).0, 40);
1053 // Staff can set agents at once.
1054 assert_eq!(caps(PlanKind::Paid, true, false, Some(6)).0, 6);
1055 }
1056
1057 #[test]
1058 fn every_refusal_says_what_to_do_and_where() {
1059 for code in [FailureCode::NotPaid, FailureCode::TrialUsed, FailureCode::OssPoolEmpty, FailureCode::Limit, FailureCode::Paused] {
1060 let Outcome::Fail(failure) = refusal(code, "acme", ComputeKind::Check, "Detail.") else { panic!() };
1061 assert_eq!(failure.code, code);
1062 assert!(failure.message.contains("/acme/-/billing"), "{}", failure.message);
1063 }
1064 let Outcome::Fail(failure) = refusal(FailureCode::NotPaid, "acme", ComputeKind::Agent, "") else { panic!() };
1065 assert!(failure.message.contains("$5 trial") && failure.message.contains("never charged"));
1066 }
1067}