Skip to content
1,063 linesCodeBlameRaw

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;
Merge main (membership, two-factor, GitHub repo roles) into tokens36use g1t_contracts::{FailureCode, Outcome, new_id};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37use 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?;
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept248 if let Some(spike) = &latest
249 && (spike.status == "open" || spike.status == "stopped") {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look250 return Ok(latest);
251 }
252 if plan == PlanKind::Internal || self.stripe.is_none() {
253 return Ok(None);
254 }
255 let pace = self.pace(workspace).await?;
256 let now = rfc3339(now_ms());
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept257 if let Some(spike) = &latest
258 && spike.status == "continued" && still_continued(spike.until.as_deref(), &now, spike.hour_micros, pace.last_hour) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look259 return Ok(None);
260 }
261 if !is_spike(pace.last_hour, pace.usual_hour, self.plans.spike_factor, self.plans.spike_floor_micros) {
262 return Ok(None);
263 }
264 let id = new_id("spk", now_ms());
265 self.db
266 .prepare(
267 "INSERT INTO spikes (id, workspace, status, hour_micros, average_micros, detected_at)
268 SELECT ?1, ?2, 'open', ?3, ?4, ?5
269 WHERE NOT EXISTS (SELECT 1 FROM spikes WHERE workspace = ?2 AND status = 'open')",
270 )
271 .bind(&[id.as_str().into(), workspace.into(), (pace.last_hour as f64).into(), (pace.usual_hour as f64).into(), now.as_str().into()])?
272 .run()
273 .await?;
274 self.latest_spike(workspace).await
275 }
276
277 /// The alerts a workspace has reached this month: its plan's included
278 /// usage, its spend limit and g1t's ceiling, from 50%.
279 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 index280 let account = self.account_of(workspace).await?;
281 let plan = self.plan_kind_for(workspace, &account).await?;
282 let has_plan = plan != PlanKind::Free;
283 let month = credits::month_of(&rfc3339(now_ms()));
284 let (limit, used) = try_join(self.limit_with(workspace, &account, plan), async {
285 if has_plan { self.allowance_used("plan_credit", workspace, &month).await } else { Ok(0) }
286 })
287 .await?;
288 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 look289 }
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 index290}
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/// The alerts reached, from a limit already worked out, whether the
293/// workspace has the plan, and what of its included usage it has used.
294fn alerts_from(
295 workspace: &str,
296 limit: &g1t_contracts::billing::Limit,
297 has_plan: bool,
298 used: i64,
299 included: i64,
300) -> Vec<UsageAlert> {
301 let mut alerts = vec![];
302 if has_plan && limit.trust != g1t_contracts::billing::Trust::Internal {
303 let level = alert_level(used, included);
304 if level > 0 {
305 alerts.push(UsageAlert {
306 meter: "included".into(),
307 level,
308 used_micros: used,
309 limit_micros: included,
310 message: if level >= 100 {
311 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))
312 } else {
313 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))
314 },
315 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look316 }
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 index317 }
318 if let Some(spend_limit) = limit.spend_limit_micros {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit319 let level = crate::limits::alert_level_in(limit.spent_micros, spend_limit, &limit.alert_levels);
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 index320 if level > 0 {
321 alerts.push(UsageAlert {
322 meter: "spend_limit".into(),
323 level,
324 used_micros: limit.spent_micros,
325 limit_micros: spend_limit,
326 message: format!(
327 "{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.",
328 dollars(limit.spent_micros),
329 dollars(spend_limit)
330 ),
331 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look332 }
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 index333 }
334 if let Some(ceiling) = limit.ceiling_micros.filter(|_| limit.trust != g1t_contracts::billing::Trust::New) {
335 let level = alert_level(limit.exposure_micros, ceiling);
336 if level > 0 {
337 alerts.push(UsageAlert {
338 meter: "ceiling".into(),
339 level,
340 used_micros: limit.exposure_micros,
341 limit_micros: ceiling,
342 message: format!(
343 "{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.",
344 dollars(limit.exposure_micros),
345 dollars(ceiling)
346 ),
347 });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look348 }
349 }
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 index350 alerts
351}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look352
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 index353impl Billing {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look354 /// Whether a card check was done for the workspace.
355 pub(crate) async fn card_checked(&self, workspace: &str) -> Result<bool> {
356 Ok(self
357 .db
358 .prepare("SELECT workspace FROM card_checks WHERE workspace = ?")
359 .bind(&[workspace.into()])?
360 .first::<serde_json::Value>(None)
361 .await?
362 .is_some())
363 }
364
365 /// What open reservations hold across `members`, at price.
366 async fn held(&self, members: &[String]) -> Result<i64> {
367 #[derive(Deserialize)]
368 struct Row {
369 held: Option<i64>,
370 }
371 let marks = vec!["?"; members.len().max(1)].join(", ");
372 let mut values: Vec<JsValue> = members.iter().map(|m| JsValue::from(m.as_str())).collect();
373 if values.is_empty() {
374 values.push("".into());
375 }
376 values.push(rfc3339(now_ms()).into());
377 Ok(self
378 .db
379 .prepare(format!(
380 "SELECT SUM(hold_micros) AS held FROM reservations
381 WHERE workspace IN ({marks}) AND settled_at IS NULL AND expires_at > ?"
382 ))
383 .bind(&values)?
384 .first::<Row>(None)
385 .await?
386 .and_then(|r| r.held)
387 .unwrap_or(0))
388 }
389
390 /// Why new compute is paused, if it is: a staff hold, a spike waiting
391 /// for an owner (or stopped by one), or the limit reached.
392 async fn pause_reason(
393 &self,
394 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 index395 account: &BillingAccount,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look396 plan: PlanKind,
397 limit: Option<&g1t_contracts::billing::Limit>,
398 ) -> Result<(Option<String>, Option<Spike>, Option<FailureCode>)> {
399 if let Some(hold) = account.allowances.hold.as_deref().filter(|h| !h.trim().is_empty()) {
400 return Ok((Some(format!("g1t staff put a hold on new compute ({}).", hold.trim())), None, Some(FailureCode::Paused)));
401 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays402 // 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 index403 if let Some(why) = self.comped_stop(account).await? {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays404 return Ok((Some(why), None, Some(FailureCode::Paused)));
405 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look406 let spike = self.spike_pause(workspace, plan).await?;
407 if let Some(spike) = &spike {
408 let why = if spike.status == "stopped" {
409 format!(
410 "an owner stopped new compute after a spend spike ({} in an hour). An owner can choose Keep going.",
411 dollars(spike.hour_micros)
412 )
413 } else {
414 format!(
415 "{} was spent in an hour, more than {} times the usual {} an hour, so new compute waits for an owner to confirm.",
416 dollars(spike.hour_micros),
417 self.plans.spike_factor,
418 dollars(spike.average_micros)
419 )
420 };
421 return Ok((Some(why), Some(spike.clone()), Some(FailureCode::Paused)));
422 }
423 let latest = self.latest_spike(workspace).await?;
424 if plan != PlanKind::Internal && self.stripe.is_some() {
425 let worked_out;
426 let limit = match limit {
427 Some(limit) => limit,
428 None => {
429 worked_out = self.limit_of(workspace).await?;
430 &worked_out
431 }
432 };
433 if limit.state == LimitState::Stopped {
434 return Ok((limit.message.clone(), latest, Some(FailureCode::Limit)));
435 }
436 }
437 Ok((None, latest, None))
438 }
439
440 /// `entitlements`: what the workspace may do now.
441 pub(crate) async fn entitlements(&self, a: EntitlementsArgs) -> Result<Entitlements> {
442 let workspace = a.workspace.to_lowercase();
443 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 index444 let plan = self.plan_kind_for(&workspace, &account).await?;
445 let has_plan = plan != PlanKind::Free;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look446 let now = rfc3339(now_ms());
447 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 index448 // Every signed-in page asks for this, so what does not need another
449 // answer is read at once: the limit (and the pause, from it), the
450 // trial, and the month's counts.
451 let standing = async {
452 let limit = self.limit_with(&workspace, &account, plan).await?;
453 let pause = self.pause_reason(&workspace, &account, plan, Some(&limit)).await?;
454 Ok::<_, worker::Error>((limit, pause))
455 };
456 let trial = async {
457 let (checked, grant) = try_join(
458 async {
459 if matches!(plan, PlanKind::Internal | PlanKind::Enterprise) { Ok(true) } else { self.card_checked(&workspace).await }
460 },
461 self.grant_of(&workspace),
462 )
463 .await?;
464 // A card checked while the month's pool was empty: granted once
465 // it has room.
466 let grant = match grant {
467 Some(grant) => Some(grant),
468 None if checked && plan == PlanKind::Free && self.trial_allowed(&workspace).await? => self.ensure_grant(&workspace).await?,
469 None => None,
470 };
471 Ok::<_, worker::Error>((checked, grant))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look472 };
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 index473 let counts = try_join5(
474 self.owner_caps(&workspace),
475 self.private_storage(&workspace),
476 self.oss_paid(&workspace, &month),
477 self.held(&account.workspaces),
478 async { if has_plan { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) } },
479 );
480 let more = try_join(self.allowance_used("build_seconds", &workspace, &month), self.git_operations_this_month(&workspace));
481 let ((limit, (paused, spike, _)), (verified, grant), (owners, stored, oss, held, included_used), (build_seconds, git_operations)) =
482 try_join4(standing, trial, counts, more).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look483 let trial_left = grant.as_ref().map_or(0, |g| left(g.granted_micros, g.used_micros));
484 let first_month = limit.first_month;
485 let (max_agents, max_minutes) = caps(plan, first_month, trial_left > 0, account.allowances.max_concurrent_agents);
486 let ceiling = match plan {
487 PlanKind::Free => 0,
488 PlanKind::Internal => UNLIMITED_MICROS,
489 _ => limit.ceiling_micros.unwrap_or(UNLIMITED_MICROS),
490 };
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 index491 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 look492 Ok(Entitlements {
493 plan,
494 compute: has_plan || trial_left > 0,
495 trial_micros_left: trial_left,
496 trial_verified: verified,
497 first_month,
498 max_concurrent_agents: max_agents,
499 max_run_minutes: max_minutes,
500 run_cap_micros: account.allowances.run_cap_micros.or(owners.0).unwrap_or(self.plans.run_cap_micros),
501 issue_cap_micros: account.allowances.issue_cap_micros.or(owners.1).unwrap_or(self.plans.issue_cap_micros),
502 ceiling_micros: ceiling,
503 exposure_micros: limit.exposure_micros,
504 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 index505 held_micros: held,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look506 prepaid_micros: limit.prepaid_micros,
507 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 index508 included_used_micros: included_used,
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo509 audit_retention_days: crate::retention::effective_days(&self.plans, plan, account.allowances.audit_retention_days),
510 audit_retention_custom: account.allowances.audit_retention_days.is_some(),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas511 free_private_storage_bytes: self.plans.free_storage_bytes,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member512 // For the packages service: on the plan nothing is refused; without it,
513 // pushes past these amounts are.
514 has_plan,
515 package_public_free_bytes: self.plans.public_package_free_bytes,
516 package_private_free_bytes: self.plans.private_package_free_bytes,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look517 private_storage_bytes: stored,
518 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 index519 build_seconds_used: build_seconds.max(0) as u32,
520 git_operations,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look521 git_operations_included: self.plans.git_included,
522 min_charge_micros: self.plans.min_charge_micros,
523 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 index524 alerts,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look525 workspace,
526 })
527 }
528
529 /// `reserve`: holds a start's estimated cost, or says why not.
530 pub(crate) async fn reserve(&self, a: ReserveArgs) -> Result<Outcome<Reservation>> {
531 let workspace = a.workspace.to_lowercase();
532 let now = now_ms();
533 let expires_at = rfc3339(now + RESERVATION_HOURS * 60 * 60 * 1000);
534 let repo = format!("{}/{}", a.repo.namespace, a.repo.name).to_lowercase();
Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006)535 // A platform pause holds for everyone, a g1t that does not charge
536 // included (platform.rs): kept 30 seconds in the isolate.
537 if let Some(why) = self.platform_refuses(a.kind).await {
538 return Ok(Outcome::fail(FailureCode::Paused, why));
539 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look540 // A g1t that does not charge holds nothing.
541 if self.stripe.is_none() {
542 return Ok(Outcome::Ok(Reservation { id: new_id("rsv", now), paid_by: PaidBy::OnDemand, held_micros: 0, expires_at }));
543 }
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays544 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 index545 let plan = self.plan_kind_for(&workspace, &account).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays546 // g1t's own caps (`budget`), in their own words: a comped account's
547 // monthly budget, and the daily breaker.
548 if let Some(why) = self.comped_stop(&account).await? {
549 return Ok(Outcome::fail(FailureCode::Paused, why));
550 }
551 if let Some(why) = self.breaker_refuses(plan, &account, a.kind, a.hosted_model).await? {
552 return Ok(Outcome::fail(FailureCode::Paused, why));
553 }
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 index554 let limit = if plan == PlanKind::Internal { None } else { Some(self.limit_with(&workspace, &account, plan).await?) };
555 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 look556 if let (Some(why), Some(code)) = (paused, code) {
557 return Ok(refusal(code, &workspace, a.kind, &why));
558 }
559 let month = credits::month_of(&rfc3339(now));
560 let estimate = credits::with_margin(a.estimate_micros, self.margin_percent);
561 let verified = matches!(plan, PlanKind::Internal | PlanKind::Enterprise | PlanKind::Paid) || self.card_checked(&workspace).await?;
562 let has_plan = plan != PlanKind::Free;
563 let credit = if has_plan {
564 left(self.plans.plan_included_micros, self.allowance_used("plan_credit", &workspace, &month).await?)
565 } else {
566 0
567 };
568 let trial = if a.kind == ComputeKind::Deploy || !verified {
569 0
570 } else {
571 self.grant_of(&workspace).await?.map_or(0, |g| left(g.granted_micros, g.used_micros))
572 };
573 let oss_eligible = a.public && a.kind.open_source_pool() && verified;
574 let oss = if oss_eligible { self.oss_left(&workspace, &repo, &month).await? } else { 0 };
575 let on_demand = match plan {
576 PlanKind::Free => Some(0),
577 PlanKind::Internal => None,
578 _ => {
579 let Some(limit) = &limit else { unreachable!("only g1t's own workspaces skip the limit") };
580 let under_ceiling = limit.ceiling_micros.map(|c| (c - limit.exposure_micros).max(0));
581 let under_spend = limit.spend_limit_micros.map(|s| (s - limit.spent_micros).max(0));
582 match (under_ceiling, under_spend) {
583 (Some(c), Some(s)) => Some(c.min(s)),
584 (c, s) => c.or(s),
585 }
586 }
587 };
588 let room = Room { credit, trial, oss, on_demand };
589 // Optimistic: what was held is read, and the hold is written only if
590 // nothing was held meanwhile; otherwise read again.
591 for _ in 0..4 {
592 let held = self.held(&account.workspaces).await?;
593 let (paid_by, hold) = match place(&room, held, estimate, has_plan) {
594 Ok(placed) => placed,
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept595 Err(short) => return self.short(&workspace, plan, &a, verified, &room, short).await,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look596 };
597 let id = new_id("rsv", now);
598 let mut values: Vec<JsValue> = vec![
599 id.as_str().into(),
600 workspace.as_str().into(),
601 repo.as_str().into(),
602 a.kind.as_str().into(),
603 u8::from(a.public).into(),
604 (a.estimate_micros.max(0) as f64).into(),
605 (hold as f64).into(),
606 paid_by_text(paid_by).into(),
607 rfc3339(now).into(),
608 expires_at.as_str().into(),
609 (held as f64).into(),
610 ];
611 values.extend(account.workspaces.iter().map(|w| JsValue::from(w.as_str())));
612 if account.workspaces.is_empty() {
613 values.push("".into());
614 }
615 let placed = self
616 .db
617 .prepare(format!(
618 "INSERT INTO reservations (id, workspace, repo, kind, public, estimate_micros, hold_micros, paid_by, created_at, expires_at)
619 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
620 WHERE (SELECT COALESCE(SUM(hold_micros), 0) FROM reservations
621 WHERE workspace IN ({marks}) AND settled_at IS NULL AND expires_at > ?9) = ?11
622 RETURNING id",
623 marks = (12..12 + account.workspaces.len().max(1)).map(|i| format!("?{i}")).collect::<Vec<_>>().join(", ")
624 ))
625 .bind(&values)?
626 .first::<serde_json::Value>(None)
627 .await?;
628 if placed.is_some() {
629 // What is held, at cost, as it was asked.
630 let held_cost = if hold >= estimate { a.estimate_micros.max(0) } else { hold * 100 / i64::from(100 + self.margin_percent) };
631 return Ok(Outcome::Ok(Reservation { id, paid_by, held_micros: held_cost, expires_at }));
632 }
633 }
634 Ok(refusal(FailureCode::Limit, &workspace, a.kind, "Too many starts at once to hold this one; try again in a moment."))
635 }
636
637 /// The refusal for a start nothing pays for.
638 async fn short(
639 &self,
640 workspace: &str,
641 plan: PlanKind,
642 a: &ReserveArgs,
643 verified: bool,
644 room: &Room,
645 short: Short,
646 ) -> Result<Outcome<Reservation>> {
647 if plan != PlanKind::Free {
648 let limit = self.limit_of(workspace).await?;
649 let detail = match short {
650 Short::TooSmall => format!(
651 "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).",
652 dollars(room.credit + room.trial + room.oss + room.on_demand.unwrap_or(0)),
653 dollars(limit.spent_micros),
654 dollars(limit.spend_limit_micros.unwrap_or_default()),
655 dollars(limit.exposure_micros),
656 dollars(limit.ceiling_micros.unwrap_or_default()),
657 ),
658 Short::Empty => limit.message.unwrap_or_else(|| "The workspace reached its limit for this month.".to_owned()),
659 };
660 return Ok(refusal(FailureCode::Limit, workspace, a.kind, &detail));
661 }
662 if !verified {
663 return Ok(refusal(FailureCode::NotPaid, workspace, a.kind, ""));
664 }
665 let oss_eligible = a.public && a.kind.open_source_pool();
666 let trial = self.grant_of(workspace).await?;
667 if oss_eligible && room.oss == 0 {
668 let month = credits::month_of(&rfc3339(now_ms()));
669 let repo = format!("{}/{}", a.repo.namespace, a.repo.name).to_lowercase();
670 let pool = left(self.plans.oss_pool_micros, self.allowance_used("oss_pool", "", &month).await?);
671 let detail = if pool > 0 { format!(" for {repo} (its share is {})", dollars(self.oss_repo_cap(workspace).await?)) } else { String::new() };
672 if trial.as_ref().is_none_or(|g| g.used_micros >= g.granted_micros) {
673 return Ok(refusal(FailureCode::OssPoolEmpty, workspace, a.kind, &detail));
674 }
675 }
676 match trial {
677 Some(grant) if grant.used_micros >= grant.granted_micros => Ok(refusal(FailureCode::TrialUsed, workspace, a.kind, "")),
678 _ => Ok(refusal(FailureCode::NotPaid, workspace, a.kind, "")),
679 }
680 }
681
682 /// `settle`: releases a hold.
683 pub(crate) async fn settle_reservation(&self, a: SettleArgs) -> Result<Outcome<bool>> {
684 let now = rfc3339(now_ms());
685 let settled = self
686 .db
687 .prepare(
688 "UPDATE reservations SET settled_at = ?1, actual_micros = ?2
689 WHERE id = ?3 AND settled_at IS NULL AND expires_at > ?1 RETURNING id",
690 )
691 .bind(&[now.as_str().into(), (a.actual_micros.max(0) as f64).into(), a.reservation_id.as_str().into()])?
692 .first::<serde_json::Value>(None)
693 .await?;
694 Ok(Outcome::Ok(settled.is_some()))
695 }
696
697 /// Clears reservations long settled or lapsed.
698 pub(crate) async fn sweep_reservations(&self) -> Result<()> {
699 let week_ago = rfc3339(now_ms() - 7 * 24 * 60 * 60 * 1000);
700 self.db
701 .prepare("DELETE FROM reservations WHERE expires_at < ?1")
702 .bind(&[week_ago.into()])?
703 .run()
704 .await?;
705 Ok(())
706 }
707
708 /// `confirm_spike`: an owner keeps going, or stops.
709 pub(crate) async fn confirm_spike(&self, a: ConfirmSpikeArgs) -> Result<Outcome<Entitlements>> {
710 let workspace = a.workspace.to_lowercase();
Merge main (membership, two-factor, GitHub repo roles) into tokens711 if !a.actor.manages_billing(&workspace) {
712 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can answer a spend spike."));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look713 }
714 let Some(spike) = self.latest_spike(&workspace).await? else {
715 return Ok(Outcome::fail(FailureCode::NotFound, "There is no spend spike to answer."));
716 };
717 let now = now_ms();
718 let (status, until) = if a.keep_going { ("continued", Some(rfc3339(now + KEEP_GOING_MS))) } else { ("stopped", None) };
719 self.db
720 .prepare("UPDATE spikes SET status = ?1, decided_by = ?2, decided_at = ?3, until = ?4 WHERE id = ?5")
721 .bind(&[
722 status.into(),
723 a.actor.username.as_str().into(),
724 rfc3339(now).into(),
725 crate::optional(until.as_deref()),
726 spike.id.as_str().into(),
727 ])?
728 .run()
729 .await?;
730 let account = self.account_of(&workspace).await?;
731 self.audit(
732 &account.id,
733 "spike",
734 &format!("{workspace}: {} after {} in an hour", if a.keep_going { "kept going" } else { "stopped" }, dollars(spike.hour_micros)),
735 &a.actor.username,
736 )
737 .await?;
738 Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?))
739 }
740
741 /// The owners' own run and issue caps, if they set them.
742 async fn owner_caps(&self, workspace: &str) -> Result<(Option<i64>, Option<i64>)> {
743 #[derive(Deserialize)]
744 struct Row {
745 run_cap_micros: Option<i64>,
746 issue_cap_micros: Option<i64>,
747 }
748 Ok(self
749 .db
750 .prepare("SELECT run_cap_micros, issue_cap_micros FROM limits WHERE workspace = ?")
751 .bind(&[workspace.into()])?
752 .first::<Row>(None)
753 .await?
754 .map_or((None, None), |r| (r.run_cap_micros, r.issue_cap_micros)))
755 }
756
757 /// `set_caps`: the owners' own run and issue caps.
758 pub(crate) async fn set_caps(&self, a: SetCapsArgs) -> Result<Outcome<Entitlements>> {
759 let workspace = a.workspace.to_lowercase();
Merge main (membership, two-factor, GitHub repo roles) into tokens760 if !a.actor.manages_billing(&workspace) {
761 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can set the workspace's caps."));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look762 }
763 if let Err(why) = cap_bounds(a.run_cap_micros, a.issue_cap_micros) {
764 return Ok(Outcome::fail(FailureCode::Invalid, why));
765 }
766 let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
767 let now = rfc3339(now_ms());
768 self.db
769 .prepare(
770 "INSERT INTO limits (workspace, run_cap_micros, issue_cap_micros, updated_at) VALUES (?1, ?2, ?3, ?4)
771 ON CONFLICT (workspace) DO UPDATE SET run_cap_micros = ?2, issue_cap_micros = ?3, updated_at = ?4",
772 )
773 .bind(&[workspace.as_str().into(), opt(a.run_cap_micros), opt(a.issue_cap_micros), now.into()])?
774 .run()
775 .await?;
776 let account = self.account_of(&workspace).await?;
777 let shown = |m: Option<i64>| m.map_or_else(|| "the default".to_owned(), dollars);
778 self.audit(
779 &account.id,
780 "caps",
781 &format!("{workspace}: run cap {}, issue cap {}", shown(a.run_cap_micros), shown(a.issue_cap_micros)),
782 &a.actor.username,
783 )
784 .await?;
785 Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?))
786 }
787
788 /// Emails owners about spikes that paused their workspace, once each.
789 pub(crate) async fn tell_spikes(&self, identity: &worker::Fetcher) -> Result<()> {
790 #[derive(Deserialize)]
791 struct Open {
792 id: String,
793 workspace: String,
794 hour_micros: i64,
795 average_micros: i64,
796 }
797 let open = self
798 .db
799 .prepare("SELECT id, workspace, hour_micros, average_micros FROM spikes WHERE status = 'open' AND told_at IS NULL LIMIT 20")
800 .all()
801 .await?
802 .results::<Open>()?;
803 for spike in open {
804 let workspace = &spike.workspace;
805 let intro = format!(
806 "{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.",
807 dollars(spike.hour_micros),
808 self.plans.spike_factor,
809 dollars(spike.average_micros)
810 );
811 let link = format!("https://g1t.sh/{workspace}/-/billing");
812 if crate::limits::notify(identity, workspace, &format!("g1t: spending on {workspace} spiked, so new work is paused"), &intro, "Keep going or stop", &link).await {
813 self.db
814 .prepare("UPDATE spikes SET told_at = ? WHERE id = ?")
815 .bind(&[rfc3339(now_ms()).into(), spike.id.as_str().into()])?
816 .run()
817 .await?;
818 }
819 }
820 Ok(())
821 }
822
823 /// What the workspace's private repositories held at the last measure.
824 pub(crate) async fn private_storage(&self, workspace: &str) -> Result<i64> {
825 #[derive(Deserialize)]
826 struct Stored {
827 private_bytes: Option<i64>,
828 }
829 Ok(self
830 .db
831 .prepare("SELECT private_bytes FROM storage_days WHERE workspace = ? ORDER BY day DESC LIMIT 1")
832 .bind(&[workspace.into()])?
833 .first::<Stored>(None)
834 .await?
835 .and_then(|s| s.private_bytes)
836 .unwrap_or(0))
837 }
838
839 /// What g1t's open-source pool paid for the workspace in `month`.
840 async fn oss_paid(&self, workspace: &str, month: &str) -> Result<i64> {
841 #[derive(Deserialize)]
842 struct Sum {
843 micros: Option<i64>,
844 }
845 Ok(self
846 .db
847 .prepare("SELECT SUM(oss_micros) AS micros FROM ledger WHERE workspace = ? AND created_at >= ?")
848 .bind(&[workspace.into(), format!("{month}-01").into()])?
849 .first::<Sum>(None)
850 .await?
851 .and_then(|s| s.micros)
852 .unwrap_or(0))
853 }
854}
855
856/// Whether owners' caps are in bounds: a run $0.10 to $100 (the
857/// guardrails' most), an issue $1 to $1,000.
858pub(crate) fn cap_bounds(run: Option<i64>, issue: Option<i64>) -> std::result::Result<(), String> {
859 if run.is_some_and(|m| !(100_000..=100_000_000).contains(&m)) {
860 return Err("A run's cap is between $0.10 and $100.".to_owned());
861 }
862 if issue.is_some_and(|m| !(1_000_000..=1_000_000_000).contains(&m)) {
863 return Err("An issue's cap is between $1 and $1,000.".to_owned());
864 }
865 Ok(())
866}
867
868pub(crate) fn paid_by_text(paid_by: PaidBy) -> &'static str {
869 match paid_by {
870 PaidBy::Credit => "credit",
871 PaidBy::Trial => "trial",
872 PaidBy::Oss => "oss",
873 PaidBy::OnDemand => "on_demand",
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880
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 index881 fn limit(trust: g1t_contracts::billing::Trust, spent: i64, spend_limit: Option<i64>, exposure: i64, ceiling: Option<i64>) -> g1t_contracts::billing::Limit {
882 g1t_contracts::billing::Limit {
883 workspace: "acme".into(),
884 account: "acc_acme".into(),
885 account_name: "acme".into(),
886 trust,
887 exposure_micros: exposure,
888 ceiling_micros: ceiling,
889 trust_ceiling_micros: ceiling,
890 spend_limit_micros: spend_limit,
891 state: LimitState::Ok,
892 message: None,
893 spent_micros: spent,
894 default_spend_limit: false,
895 available_micros: None,
896 growth: None,
897 prepaid_micros: 0,
898 max_ceiling_micros: None,
899 raise_once_micros: None,
900 raised_at: None,
901 first_month: false,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit902 alert_levels: crate::limits::ALERT_LEVELS.to_vec(),
903 pause_at_limit: true,
904 budget_webhook: None,
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 index905 }
906 }
907
908 #[test]
909 fn alerts_come_from_the_answers_already_read() {
910 use g1t_contracts::billing::Trust;
911 let meters = |alerts: Vec<UsageAlert>| alerts.into_iter().map(|a| (a.meter, a.level)).collect::<Vec<_>>();
912 // On the plan: included usage at 90%, the spend limit at 50%, the ceiling at 75%.
913 let paid = limit(Trust::Paid, 100_000_000, Some(200_000_000), 75_000_000, Some(100_000_000));
914 assert_eq!(
915 meters(alerts_from("acme", &paid, true, 9_000_000, 10_000_000)),
916 vec![("included".to_owned(), 90), ("spend_limit".to_owned(), 50), ("ceiling".to_owned(), 75)]
917 );
918 // Without the plan, what was used of the included usage is not an alert.
919 assert_eq!(meters(alerts_from("acme", &paid, false, 9_000_000, 10_000_000)).len(), 2);
920 // g1t's own workspaces have no included usage to warn of, and a new
921 // workspace's ceiling is not one either.
922 let internal = limit(Trust::Internal, 0, None, 0, None);
923 assert!(alerts_from("acme", &internal, true, 10_000_000, 10_000_000).is_empty());
924 let new = limit(Trust::New, 0, None, 3_000_000, Some(3_000_000));
925 assert!(alerts_from("acme", &new, false, 0, 10_000_000).is_empty());
926 }
927
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look928 fn paid(credit: i64, on_demand: i64) -> Room {
929 Room { credit, trial: 0, oss: 0, on_demand: Some(on_demand) }
930 }
931
932 #[test]
933 fn included_usage_pays_first_then_on_demand() {
934 // $10 included, $100 under the ceiling, nothing held: included pays first.
935 assert_eq!(place(&paid(10_000_000, 100_000_000), 0, 2_400_000, true), Ok((PaidBy::Credit, 2_400_000)));
936 // Holds already cover the included usage: on demand.
937 assert_eq!(place(&paid(10_000_000, 100_000_000), 10_000_000, 2_400_000, true), Ok((PaidBy::OnDemand, 2_400_000)));
938 // A free workspace on its trial.
939 let trial = Room { trial: 5_000_000, on_demand: Some(0), ..Room::default() };
940 assert_eq!(place(&trial, 0, 2_400_000, false), Ok((PaidBy::Trial, 2_400_000)));
941 // A public repository's checks, from the pool.
942 let pool = Room { oss: 2_000_000, on_demand: Some(0), ..Room::default() };
943 assert_eq!(place(&pool, 0, 600_000, false), Ok((PaidBy::Oss, 600_000)));
944 }
945
946 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas947 fn a_paid_workspace_is_stopped_only_by_its_limit_never_by_a_count() {
948 // The included $10 is gone and the workspace has already built,
949 // served and stored far past what used to be quotas: the next start
950 // still goes on demand, as long as its spend limit has room.
951 let room = paid(0, 250_000_000);
952 let held = 0;
953 for start in 0..1_000 {
954 let placed = place(&room, held + start * 100_000, 100_000, true);
955 assert_eq!(placed, Ok((PaidBy::OnDemand, 100_000)));
956 }
957 // Only at its limit does it stop, with the limit's refusal.
958 assert_eq!(place(&room, 250_000_000, 100_000, true), Err(Short::Empty));
959 // A free workspace has no on-demand room at all: with no trial or
960 // pool left, nothing starts (`short` says NotPaid or TrialUsed).
961 let free = Room { on_demand: Some(0), ..Room::default() };
962 assert_eq!(place(&free, 0, 100_000, false), Err(Short::Empty));
963 // g1t's own workspaces: no limit.
964 let internal = Room { on_demand: None, ..Room::default() };
965 assert_eq!(place(&internal, i64::MAX / 2, 100_000, true), Ok((PaidBy::OnDemand, 100_000)));
966 }
967
968 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look969 fn concurrent_starts_cannot_overshoot_the_ceiling() {
970 // $5 of room in all, and each start may cost up to $2.40.
971 let room = paid(0, 5_000_000);
972 let mut held = 0;
973 let mut started = 0;
974 for _ in 0..5 {
975 match place(&room, held, 2_400_000, true) {
976 Ok((_, hold)) => {
977 held += hold;
978 started += 1;
979 }
980 Err(short) => assert_eq!(short, Short::TooSmall),
981 }
982 }
983 assert_eq!(started, 2);
984 assert!(held <= 5_000_000);
985 // Once the first settles, a third fits.
986 assert!(place(&room, held - 2_400_000, 2_400_000, true).is_ok());
987 // Nothing left at all.
988 assert_eq!(place(&room, 5_000_000, 1, true), Err(Short::Empty));
989 }
990
991 #[test]
992 fn a_free_workspace_may_use_its_last_bit_of_trial() {
993 let room = Room { trial: 300_000, on_demand: Some(0), ..Room::default() };
994 // The whole estimate does not fit, but what is left is held.
995 assert_eq!(place(&room, 0, 2_400_000, false), Ok((PaidBy::Trial, 300_000)));
996 // Once it is held, nothing more starts.
997 assert_eq!(place(&room, 300_000, 2_400_000, false), Err(Short::Empty));
998 // Nothing at all: no plan, no trial, no pool.
999 assert_eq!(place(&Room { on_demand: Some(0), ..Room::default() }, 0, 1, false), Err(Short::Empty));
1000 }
1001
1002 #[test]
1003 fn g1ts_own_workspaces_are_never_short() {
1004 let room = Room { credit: 10_000_000, on_demand: None, ..Room::default() };
1005 assert_eq!(place(&room, 50_000_000_000, 2_400_000, true), Ok((PaidBy::OnDemand, 2_400_000)));
1006 assert_eq!(place(&room, 0, 2_400_000, true), Ok((PaidBy::Credit, 2_400_000)));
1007 }
1008
1009 #[test]
1010 fn a_spike_is_five_times_the_usual_hour_and_at_least_five_dollars() {
1011 let (factor, floor) = (5, 5_000_000);
1012 // A new workspace with no history: $5 in an hour is a spike, $4 is not.
1013 assert!(is_spike(5_000_000, 0, factor, floor));
1014 assert!(!is_spike(4_000_000, 0, factor, floor));
1015 // Usually $2 an hour: $10 is not above five times, $10.01 is.
1016 assert!(!is_spike(10_000_000, 2_000_000, factor, floor));
1017 assert!(is_spike(10_010_000, 2_000_000, factor, floor));
1018 // A busy workspace at its usual pace is never a spike.
1019 assert!(!is_spike(40_000_000, 30_000_000, factor, floor));
1020 }
1021
1022 #[test]
1023 fn keep_going_lasts_a_day_or_until_spend_doubles() {
1024 let until = "2026-10-06T12:00:00Z";
1025 assert!(still_continued(Some(until), "2026-10-06T11:00:00Z", 6_000_000, 8_000_000));
1026 // Doubled again: paused again.
1027 assert!(!still_continued(Some(until), "2026-10-06T11:00:00Z", 6_000_000, 12_000_000));
1028 // A day later: watched afresh.
1029 assert!(!still_continued(Some(until), "2026-10-06T12:00:01Z", 6_000_000, 1_000_000));
1030 assert!(!still_continued(None, "2026-10-06T11:00:00Z", 6_000_000, 1));
1031 }
1032
1033 #[test]
1034 fn owners_caps_stay_in_bounds() {
1035 assert!(cap_bounds(None, None).is_ok());
1036 assert!(cap_bounds(Some(5_000_000), Some(50_000_000)).is_ok());
1037 assert!(cap_bounds(Some(50_000), None).is_err());
1038 assert!(cap_bounds(Some(101_000_000), None).is_err());
1039 assert!(cap_bounds(None, Some(500_000)).is_err());
1040 assert!(cap_bounds(None, Some(2_000_000_000)).is_err());
1041 }
1042
1043 #[test]
1044 fn caps_are_tight_in_the_first_month_and_on_the_trial() {
1045 assert_eq!(caps(PlanKind::Paid, true, false, None), (2, 60));
1046 assert_eq!(caps(PlanKind::Free, false, true, None), (2, 60));
1047 assert_eq!(caps(PlanKind::Paid, false, false, None), (10, g1t_contracts::guardrails::MAX_MINUTES));
1048 assert_eq!(caps(PlanKind::Internal, false, false, None).0, 10);
1049 // Staff can set agents at once.
1050 assert_eq!(caps(PlanKind::Paid, true, false, Some(6)).0, 6);
1051 }
1052
1053 #[test]
1054 fn every_refusal_says_what_to_do_and_where() {
1055 for code in [FailureCode::NotPaid, FailureCode::TrialUsed, FailureCode::OssPoolEmpty, FailureCode::Limit, FailureCode::Paused] {
1056 let Outcome::Fail(failure) = refusal(code, "acme", ComputeKind::Check, "Detail.") else { panic!() };
1057 assert_eq!(failure.code, code);
1058 assert!(failure.message.contains("/acme/-/billing"), "{}", failure.message);
1059 }
1060 let Outcome::Fail(failure) = refusal(FailureCode::NotPaid, "acme", ComputeKind::Agent, "") else { panic!() };
1061 assert!(failure.message.contains("$5 trial") && failure.message.contains("never charged"));
1062 }
1063}

This file's history is long; its oldest lines are credited to the oldest commit read.