g1t/services/billing/src/compute.rs

1,051 lines48,636 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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