flagon-io/g1t

public

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

g1t/services/billing/src/compute.rs

937 lines43,607 bytesCodeBlame
1//! Whether a workspace may start compute, and holding what it may cost.
2//!
3//! Every service that starts something that costs g1t real money asks
4//! here first (see `g1t_contracts::billing::ReserveArgs`):
5//!
6//! - **`entitlements`**: the workspace's plan, whether it may start compute
7//! at all, its caps (agents at once, a run's time and spend, an issue's
8//! spend), its ceiling and exposure, and whether compute is paused.
9//! - **`reserve`**: holds the work's estimated cost against what may pay
10//! for it (the plan's included usage, the trial, the open-source pool,
11//! then on-demand room under the ceiling and the spend limit), so starts
12//! at the same moment cannot overshoot together. Answers who pays first,
13//! or refuses with a stable code and a message for the owner.
14//! - **`settle`**: releases the hold. The charge itself goes on the ledger
15//! the usual way; a hold never settled lapses after three hours.
16//!
17//! **No card, no compute.** A free workspace's forge is free, but compute
18//! needs the plan, or a card check: it unlocks the one-time trial and g1t's
19//! open-source pool (checks, workflows and the merge queue on public
20//! repositories). The card check is what keeps g1t's free compute from
21//! being mined: one trial per card, and a real person behind each.
22//!
23//! **Spikes.** An hour's spend above `SPIKE_FACTOR` (5) times the
24//! workspace's usual hour over the last week, and at least
25//! `SPIKE_FLOOR_MICROS` ($5), pauses new compute until an owner answers:
26//! keep going (for 24 hours, or until the hour's spend doubles again) or
27//! stop. Runs already under way finish. g1t's own workspaces are watched
28//! but never paused.
29
30use 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,
471 free_private_storage_bytes: if has_plan { self.plans.plan_storage_bytes } else { self.plans.free_storage_bytes },
472 private_storage_bytes: stored,
473 oss_paid_micros: oss,
474 build_seconds_included: if has_plan { self.plans.build_seconds } else { 0 },
475 build_seconds_used: self.allowance_used("build_seconds", &workspace, &month).await?.max(0) as u32,
476 git_operations: self.git_operations_this_month(&workspace).await?,
477 git_operations_included: self.plans.git_included,
478 min_charge_micros: self.plans.min_charge_micros,
479 spike,
480 alerts: self.alerts_from(&workspace, &limit).await?,
481 workspace,
482 })
483 }
484
485 /// `reserve`: holds a start's estimated cost, or says why not.
486 pub(crate) async fn reserve(&self, a: ReserveArgs) -> Result<Outcome<Reservation>> {
487 let workspace = a.workspace.to_lowercase();
488 let now = now_ms();
489 let expires_at = rfc3339(now + RESERVATION_HOURS * 60 * 60 * 1000);
490 let repo = format!("{}/{}", a.repo.namespace, a.repo.name).to_lowercase();
491 // A g1t that does not charge holds nothing.
492 if self.stripe.is_none() {
493 return Ok(Outcome::Ok(Reservation { id: new_id("rsv", now), paid_by: PaidBy::OnDemand, held_micros: 0, expires_at }));
494 }
495 let plan = self.plan_kind(&workspace).await?;
496 let limit = if plan == PlanKind::Internal { None } else { Some(self.limit_of(&workspace).await?) };
497 let (paused, _, code) = self.pause_reason(&workspace, plan, limit.as_ref()).await?;
498 if let (Some(why), Some(code)) = (paused, code) {
499 return Ok(refusal(code, &workspace, a.kind, &why));
500 }
501 let account = self.account_of(&workspace).await?;
502 let month = credits::month_of(&rfc3339(now));
503 let estimate = credits::with_margin(a.estimate_micros, self.margin_percent);
504 let verified = matches!(plan, PlanKind::Internal | PlanKind::Enterprise | PlanKind::Paid) || self.card_checked(&workspace).await?;
505 let has_plan = plan != PlanKind::Free;
506 let credit = if has_plan {
507 left(self.plans.plan_included_micros, self.allowance_used("plan_credit", &workspace, &month).await?)
508 } else {
509 0
510 };
511 let trial = if a.kind == ComputeKind::Deploy || !verified {
512 0
513 } else {
514 self.grant_of(&workspace).await?.map_or(0, |g| left(g.granted_micros, g.used_micros))
515 };
516 let oss_eligible = a.public && a.kind.open_source_pool() && verified;
517 let oss = if oss_eligible { self.oss_left(&workspace, &repo, &month).await? } else { 0 };
518 let on_demand = match plan {
519 PlanKind::Free => Some(0),
520 PlanKind::Internal => None,
521 _ => {
522 let Some(limit) = &limit else { unreachable!("only g1t's own workspaces skip the limit") };
523 let under_ceiling = limit.ceiling_micros.map(|c| (c - limit.exposure_micros).max(0));
524 let under_spend = limit.spend_limit_micros.map(|s| (s - limit.spent_micros).max(0));
525 match (under_ceiling, under_spend) {
526 (Some(c), Some(s)) => Some(c.min(s)),
527 (c, s) => c.or(s),
528 }
529 }
530 };
531 let room = Room { credit, trial, oss, on_demand };
532 // Optimistic: what was held is read, and the hold is written only if
533 // nothing was held meanwhile; otherwise read again.
534 for _ in 0..4 {
535 let held = self.held(&account.workspaces).await?;
536 let (paid_by, hold) = match place(&room, held, estimate, has_plan) {
537 Ok(placed) => placed,
538 Err(short) => return Ok(self.short(&workspace, plan, &a, verified, &room, short).await?),
539 };
540 let id = new_id("rsv", now);
541 let mut values: Vec<JsValue> = vec![
542 id.as_str().into(),
543 workspace.as_str().into(),
544 repo.as_str().into(),
545 a.kind.as_str().into(),
546 u8::from(a.public).into(),
547 (a.estimate_micros.max(0) as f64).into(),
548 (hold as f64).into(),
549 paid_by_text(paid_by).into(),
550 rfc3339(now).into(),
551 expires_at.as_str().into(),
552 (held as f64).into(),
553 ];
554 values.extend(account.workspaces.iter().map(|w| JsValue::from(w.as_str())));
555 if account.workspaces.is_empty() {
556 values.push("".into());
557 }
558 let placed = self
559 .db
560 .prepare(format!(
561 "INSERT INTO reservations (id, workspace, repo, kind, public, estimate_micros, hold_micros, paid_by, created_at, expires_at)
562 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
563 WHERE (SELECT COALESCE(SUM(hold_micros), 0) FROM reservations
564 WHERE workspace IN ({marks}) AND settled_at IS NULL AND expires_at > ?9) = ?11
565 RETURNING id",
566 marks = (12..12 + account.workspaces.len().max(1)).map(|i| format!("?{i}")).collect::<Vec<_>>().join(", ")
567 ))
568 .bind(&values)?
569 .first::<serde_json::Value>(None)
570 .await?;
571 if placed.is_some() {
572 // What is held, at cost, as it was asked.
573 let held_cost = if hold >= estimate { a.estimate_micros.max(0) } else { hold * 100 / i64::from(100 + self.margin_percent) };
574 return Ok(Outcome::Ok(Reservation { id, paid_by, held_micros: held_cost, expires_at }));
575 }
576 }
577 Ok(refusal(FailureCode::Limit, &workspace, a.kind, "Too many starts at once to hold this one; try again in a moment."))
578 }
579
580 /// The refusal for a start nothing pays for.
581 async fn short(
582 &self,
583 workspace: &str,
584 plan: PlanKind,
585 a: &ReserveArgs,
586 verified: bool,
587 room: &Room,
588 short: Short,
589 ) -> Result<Outcome<Reservation>> {
590 if plan != PlanKind::Free {
591 let limit = self.limit_of(workspace).await?;
592 let detail = match short {
593 Short::TooSmall => format!(
594 "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).",
595 dollars(room.credit + room.trial + room.oss + room.on_demand.unwrap_or(0)),
596 dollars(limit.spent_micros),
597 dollars(limit.spend_limit_micros.unwrap_or_default()),
598 dollars(limit.exposure_micros),
599 dollars(limit.ceiling_micros.unwrap_or_default()),
600 ),
601 Short::Empty => limit.message.unwrap_or_else(|| "The workspace reached its limit for this month.".to_owned()),
602 };
603 return Ok(refusal(FailureCode::Limit, workspace, a.kind, &detail));
604 }
605 if !verified {
606 return Ok(refusal(FailureCode::NotPaid, workspace, a.kind, ""));
607 }
608 let oss_eligible = a.public && a.kind.open_source_pool();
609 let trial = self.grant_of(workspace).await?;
610 if oss_eligible && room.oss == 0 {
611 let month = credits::month_of(&rfc3339(now_ms()));
612 let repo = format!("{}/{}", a.repo.namespace, a.repo.name).to_lowercase();
613 let pool = left(self.plans.oss_pool_micros, self.allowance_used("oss_pool", "", &month).await?);
614 let detail = if pool > 0 { format!(" for {repo} (its share is {})", dollars(self.oss_repo_cap(workspace).await?)) } else { String::new() };
615 if trial.as_ref().is_none_or(|g| g.used_micros >= g.granted_micros) {
616 return Ok(refusal(FailureCode::OssPoolEmpty, workspace, a.kind, &detail));
617 }
618 }
619 match trial {
620 Some(grant) if grant.used_micros >= grant.granted_micros => Ok(refusal(FailureCode::TrialUsed, workspace, a.kind, "")),
621 _ => Ok(refusal(FailureCode::NotPaid, workspace, a.kind, "")),
622 }
623 }
624
625 /// `settle`: releases a hold.
626 pub(crate) async fn settle_reservation(&self, a: SettleArgs) -> Result<Outcome<bool>> {
627 let now = rfc3339(now_ms());
628 let settled = self
629 .db
630 .prepare(
631 "UPDATE reservations SET settled_at = ?1, actual_micros = ?2
632 WHERE id = ?3 AND settled_at IS NULL AND expires_at > ?1 RETURNING id",
633 )
634 .bind(&[now.as_str().into(), (a.actual_micros.max(0) as f64).into(), a.reservation_id.as_str().into()])?
635 .first::<serde_json::Value>(None)
636 .await?;
637 Ok(Outcome::Ok(settled.is_some()))
638 }
639
640 /// Clears reservations long settled or lapsed.
641 pub(crate) async fn sweep_reservations(&self) -> Result<()> {
642 let week_ago = rfc3339(now_ms() - 7 * 24 * 60 * 60 * 1000);
643 self.db
644 .prepare("DELETE FROM reservations WHERE expires_at < ?1")
645 .bind(&[week_ago.into()])?
646 .run()
647 .await?;
648 Ok(())
649 }
650
651 /// `confirm_spike`: an owner keeps going, or stops.
652 pub(crate) async fn confirm_spike(&self, a: ConfirmSpikeArgs) -> Result<Outcome<Entitlements>> {
653 let workspace = a.workspace.to_lowercase();
654 if a.actor.role_in(&workspace) != Some(Role::Owner) {
655 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can answer a spend spike."));
656 }
657 let Some(spike) = self.latest_spike(&workspace).await? else {
658 return Ok(Outcome::fail(FailureCode::NotFound, "There is no spend spike to answer."));
659 };
660 let now = now_ms();
661 let (status, until) = if a.keep_going { ("continued", Some(rfc3339(now + KEEP_GOING_MS))) } else { ("stopped", None) };
662 self.db
663 .prepare("UPDATE spikes SET status = ?1, decided_by = ?2, decided_at = ?3, until = ?4 WHERE id = ?5")
664 .bind(&[
665 status.into(),
666 a.actor.username.as_str().into(),
667 rfc3339(now).into(),
668 crate::optional(until.as_deref()),
669 spike.id.as_str().into(),
670 ])?
671 .run()
672 .await?;
673 let account = self.account_of(&workspace).await?;
674 self.audit(
675 &account.id,
676 "spike",
677 &format!("{workspace}: {} after {} in an hour", if a.keep_going { "kept going" } else { "stopped" }, dollars(spike.hour_micros)),
678 &a.actor.username,
679 )
680 .await?;
681 Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?))
682 }
683
684 /// The owners' own run and issue caps, if they set them.
685 async fn owner_caps(&self, workspace: &str) -> Result<(Option<i64>, Option<i64>)> {
686 #[derive(Deserialize)]
687 struct Row {
688 run_cap_micros: Option<i64>,
689 issue_cap_micros: Option<i64>,
690 }
691 Ok(self
692 .db
693 .prepare("SELECT run_cap_micros, issue_cap_micros FROM limits WHERE workspace = ?")
694 .bind(&[workspace.into()])?
695 .first::<Row>(None)
696 .await?
697 .map_or((None, None), |r| (r.run_cap_micros, r.issue_cap_micros)))
698 }
699
700 /// `set_caps`: the owners' own run and issue caps.
701 pub(crate) async fn set_caps(&self, a: SetCapsArgs) -> Result<Outcome<Entitlements>> {
702 let workspace = a.workspace.to_lowercase();
703 if a.actor.role_in(&workspace) != Some(Role::Owner) {
704 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can set the workspace's caps."));
705 }
706 if let Err(why) = cap_bounds(a.run_cap_micros, a.issue_cap_micros) {
707 return Ok(Outcome::fail(FailureCode::Invalid, why));
708 }
709 let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into());
710 let now = rfc3339(now_ms());
711 self.db
712 .prepare(
713 "INSERT INTO limits (workspace, run_cap_micros, issue_cap_micros, updated_at) VALUES (?1, ?2, ?3, ?4)
714 ON CONFLICT (workspace) DO UPDATE SET run_cap_micros = ?2, issue_cap_micros = ?3, updated_at = ?4",
715 )
716 .bind(&[workspace.as_str().into(), opt(a.run_cap_micros), opt(a.issue_cap_micros), now.into()])?
717 .run()
718 .await?;
719 let account = self.account_of(&workspace).await?;
720 let shown = |m: Option<i64>| m.map_or_else(|| "the default".to_owned(), dollars);
721 self.audit(
722 &account.id,
723 "caps",
724 &format!("{workspace}: run cap {}, issue cap {}", shown(a.run_cap_micros), shown(a.issue_cap_micros)),
725 &a.actor.username,
726 )
727 .await?;
728 Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?))
729 }
730
731 /// Emails owners about spikes that paused their workspace, once each.
732 pub(crate) async fn tell_spikes(&self, identity: &worker::Fetcher) -> Result<()> {
733 #[derive(Deserialize)]
734 struct Open {
735 id: String,
736 workspace: String,
737 hour_micros: i64,
738 average_micros: i64,
739 }
740 let open = self
741 .db
742 .prepare("SELECT id, workspace, hour_micros, average_micros FROM spikes WHERE status = 'open' AND told_at IS NULL LIMIT 20")
743 .all()
744 .await?
745 .results::<Open>()?;
746 for spike in open {
747 let workspace = &spike.workspace;
748 let intro = format!(
749 "{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.",
750 dollars(spike.hour_micros),
751 self.plans.spike_factor,
752 dollars(spike.average_micros)
753 );
754 let link = format!("https://g1t.sh/{workspace}/-/billing");
755 if crate::limits::notify(identity, workspace, &format!("g1t: spending on {workspace} spiked, so new work is paused"), &intro, "Keep going or stop", &link).await {
756 self.db
757 .prepare("UPDATE spikes SET told_at = ? WHERE id = ?")
758 .bind(&[rfc3339(now_ms()).into(), spike.id.as_str().into()])?
759 .run()
760 .await?;
761 }
762 }
763 Ok(())
764 }
765
766 /// What the workspace's private repositories held at the last measure.
767 pub(crate) async fn private_storage(&self, workspace: &str) -> Result<i64> {
768 #[derive(Deserialize)]
769 struct Stored {
770 private_bytes: Option<i64>,
771 }
772 Ok(self
773 .db
774 .prepare("SELECT private_bytes FROM storage_days WHERE workspace = ? ORDER BY day DESC LIMIT 1")
775 .bind(&[workspace.into()])?
776 .first::<Stored>(None)
777 .await?
778 .and_then(|s| s.private_bytes)
779 .unwrap_or(0))
780 }
781
782 /// What g1t's open-source pool paid for the workspace in `month`.
783 async fn oss_paid(&self, workspace: &str, month: &str) -> Result<i64> {
784 #[derive(Deserialize)]
785 struct Sum {
786 micros: Option<i64>,
787 }
788 Ok(self
789 .db
790 .prepare("SELECT SUM(oss_micros) AS micros FROM ledger WHERE workspace = ? AND created_at >= ?")
791 .bind(&[workspace.into(), format!("{month}-01").into()])?
792 .first::<Sum>(None)
793 .await?
794 .and_then(|s| s.micros)
795 .unwrap_or(0))
796 }
797}
798
799/// Whether owners' caps are in bounds: a run $0.10 to $100 (the
800/// guardrails' most), an issue $1 to $1,000.
801pub(crate) fn cap_bounds(run: Option<i64>, issue: Option<i64>) -> std::result::Result<(), String> {
802 if run.is_some_and(|m| !(100_000..=100_000_000).contains(&m)) {
803 return Err("A run's cap is between $0.10 and $100.".to_owned());
804 }
805 if issue.is_some_and(|m| !(1_000_000..=1_000_000_000).contains(&m)) {
806 return Err("An issue's cap is between $1 and $1,000.".to_owned());
807 }
808 Ok(())
809}
810
811pub(crate) fn paid_by_text(paid_by: PaidBy) -> &'static str {
812 match paid_by {
813 PaidBy::Credit => "credit",
814 PaidBy::Trial => "trial",
815 PaidBy::Oss => "oss",
816 PaidBy::OnDemand => "on_demand",
817 }
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823
824 fn paid(credit: i64, on_demand: i64) -> Room {
825 Room { credit, trial: 0, oss: 0, on_demand: Some(on_demand) }
826 }
827
828 #[test]
829 fn included_usage_pays_first_then_on_demand() {
830 // $10 included, $100 under the ceiling, nothing held: included pays first.
831 assert_eq!(place(&paid(10_000_000, 100_000_000), 0, 2_400_000, true), Ok((PaidBy::Credit, 2_400_000)));
832 // Holds already cover the included usage: on demand.
833 assert_eq!(place(&paid(10_000_000, 100_000_000), 10_000_000, 2_400_000, true), Ok((PaidBy::OnDemand, 2_400_000)));
834 // A free workspace on its trial.
835 let trial = Room { trial: 5_000_000, on_demand: Some(0), ..Room::default() };
836 assert_eq!(place(&trial, 0, 2_400_000, false), Ok((PaidBy::Trial, 2_400_000)));
837 // A public repository's checks, from the pool.
838 let pool = Room { oss: 2_000_000, on_demand: Some(0), ..Room::default() };
839 assert_eq!(place(&pool, 0, 600_000, false), Ok((PaidBy::Oss, 600_000)));
840 }
841
842 #[test]
843 fn concurrent_starts_cannot_overshoot_the_ceiling() {
844 // $5 of room in all, and each start may cost up to $2.40.
845 let room = paid(0, 5_000_000);
846 let mut held = 0;
847 let mut started = 0;
848 for _ in 0..5 {
849 match place(&room, held, 2_400_000, true) {
850 Ok((_, hold)) => {
851 held += hold;
852 started += 1;
853 }
854 Err(short) => assert_eq!(short, Short::TooSmall),
855 }
856 }
857 assert_eq!(started, 2);
858 assert!(held <= 5_000_000);
859 // Once the first settles, a third fits.
860 assert!(place(&room, held - 2_400_000, 2_400_000, true).is_ok());
861 // Nothing left at all.
862 assert_eq!(place(&room, 5_000_000, 1, true), Err(Short::Empty));
863 }
864
865 #[test]
866 fn a_free_workspace_may_use_its_last_bit_of_trial() {
867 let room = Room { trial: 300_000, on_demand: Some(0), ..Room::default() };
868 // The whole estimate does not fit, but what is left is held.
869 assert_eq!(place(&room, 0, 2_400_000, false), Ok((PaidBy::Trial, 300_000)));
870 // Once it is held, nothing more starts.
871 assert_eq!(place(&room, 300_000, 2_400_000, false), Err(Short::Empty));
872 // Nothing at all: no plan, no trial, no pool.
873 assert_eq!(place(&Room { on_demand: Some(0), ..Room::default() }, 0, 1, false), Err(Short::Empty));
874 }
875
876 #[test]
877 fn g1ts_own_workspaces_are_never_short() {
878 let room = Room { credit: 10_000_000, on_demand: None, ..Room::default() };
879 assert_eq!(place(&room, 50_000_000_000, 2_400_000, true), Ok((PaidBy::OnDemand, 2_400_000)));
880 assert_eq!(place(&room, 0, 2_400_000, true), Ok((PaidBy::Credit, 2_400_000)));
881 }
882
883 #[test]
884 fn a_spike_is_five_times_the_usual_hour_and_at_least_five_dollars() {
885 let (factor, floor) = (5, 5_000_000);
886 // A new workspace with no history: $5 in an hour is a spike, $4 is not.
887 assert!(is_spike(5_000_000, 0, factor, floor));
888 assert!(!is_spike(4_000_000, 0, factor, floor));
889 // Usually $2 an hour: $10 is not above five times, $10.01 is.
890 assert!(!is_spike(10_000_000, 2_000_000, factor, floor));
891 assert!(is_spike(10_010_000, 2_000_000, factor, floor));
892 // A busy workspace at its usual pace is never a spike.
893 assert!(!is_spike(40_000_000, 30_000_000, factor, floor));
894 }
895
896 #[test]
897 fn keep_going_lasts_a_day_or_until_spend_doubles() {
898 let until = "2026-10-06T12:00:00Z";
899 assert!(still_continued(Some(until), "2026-10-06T11:00:00Z", 6_000_000, 8_000_000));
900 // Doubled again: paused again.
901 assert!(!still_continued(Some(until), "2026-10-06T11:00:00Z", 6_000_000, 12_000_000));
902 // A day later: watched afresh.
903 assert!(!still_continued(Some(until), "2026-10-06T12:00:01Z", 6_000_000, 1_000_000));
904 assert!(!still_continued(None, "2026-10-06T11:00:00Z", 6_000_000, 1));
905 }
906
907 #[test]
908 fn owners_caps_stay_in_bounds() {
909 assert!(cap_bounds(None, None).is_ok());
910 assert!(cap_bounds(Some(5_000_000), Some(50_000_000)).is_ok());
911 assert!(cap_bounds(Some(50_000), None).is_err());
912 assert!(cap_bounds(Some(101_000_000), None).is_err());
913 assert!(cap_bounds(None, Some(500_000)).is_err());
914 assert!(cap_bounds(None, Some(2_000_000_000)).is_err());
915 }
916
917 #[test]
918 fn caps_are_tight_in_the_first_month_and_on_the_trial() {
919 assert_eq!(caps(PlanKind::Paid, true, false, None), (2, 60));
920 assert_eq!(caps(PlanKind::Free, false, true, None), (2, 60));
921 assert_eq!(caps(PlanKind::Paid, false, false, None), (10, g1t_contracts::guardrails::MAX_MINUTES));
922 assert_eq!(caps(PlanKind::Internal, false, false, None).0, 10);
923 // Staff can set agents at once.
924 assert_eq!(caps(PlanKind::Paid, true, false, Some(6)).0, 6);
925 }
926
927 #[test]
928 fn every_refusal_says_what_to_do_and_where() {
929 for code in [FailureCode::NotPaid, FailureCode::TrialUsed, FailureCode::OssPoolEmpty, FailureCode::Limit, FailureCode::Paused] {
930 let Outcome::Fail(failure) = refusal(code, "acme", ComputeKind::Check, "Detail.") else { panic!() };
931 assert_eq!(failure.code, code);
932 assert!(failure.message.contains("/acme/-/billing"), "{}", failure.message);
933 }
934 let Outcome::Fail(failure) = refusal(FailureCode::NotPaid, "acme", ComputeKind::Agent, "") else { panic!() };
935 assert!(failure.message.contains("$5 trial") && failure.message.contains("never charged"));
936 }
937}