g1t/services/billing/src/compute.rs

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