pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/compute.rs
| 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 | |
| 30 | use g1t_contracts::billing::{ |
| 31 | ComputeKind, ConfirmSpikeArgs, SetCapsArgs, Entitlements, EntitlementsArgs, LimitState, PaidBy, PlanKind, Reservation, |
| 32 | ReserveArgs, SettleArgs, Spike, UNLIMITED_MICROS, UsageAlert, RESERVATION_HOURS, |
| 33 | }; |
| 34 | use g1t_contracts::time::rfc3339; |
| 35 | use g1t_contracts::{FailureCode, Outcome, Role, new_id}; |
| 36 | use g1t_kit::now_ms; |
| 37 | use serde::Deserialize; |
| 38 | use worker::Result; |
| 39 | use worker::wasm_bindgen::JsValue; |
| 40 | |
| 41 | use crate::Billing; |
| 42 | use crate::credits::{self, left}; |
| 43 | use crate::features::dollars; |
| 44 | use crate::limits::alert_level; |
| 45 | |
| 46 | /// Agents at once in the first month or on the trial, and after. |
| 47 | pub(crate) const FIRST_MONTH_AGENTS: u32 = 2; |
| 48 | pub(crate) const AGENTS: u32 = 10; |
| 49 | /// The longest run in the first month or on the trial, in minutes. |
| 50 | pub(crate) const FIRST_MONTH_MINUTES: u32 = 60; |
| 51 | /// How long "keep going" lifts a spike's pause. |
| 52 | const KEEP_GOING_MS: u64 = 24 * 60 * 60 * 1000; |
| 53 | /// The week a usual hour is measured over. |
| 54 | const 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)] |
| 58 | pub(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)] |
| 68 | pub(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. |
| 81 | pub(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`. |
| 112 | pub(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. |
| 118 | pub(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. |
| 123 | pub(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. |
| 131 | pub(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)] |
| 162 | struct 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 | |
| 173 | impl 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)] |
| 191 | pub(crate) struct Pace { |
| 192 | pub last_hour: i64, |
| 193 | pub usual_hour: i64, |
| 194 | pub last_day: i64, |
| 195 | } |
| 196 | |
| 197 | impl 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. |
| 801 | pub(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 | |
| 811 | pub(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)] |
| 821 | mod 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 | } |