The spend caps of g1t's former agents are gone end to end. Billing no longer serves set_caps (the owners' run and issue caps, which nothing called), reads the owners' caps from limits, or puts a run cap and an issue cap in a workspace's entitlements; ISSUE_CAP_MICROS and RUN_CAP_MICROS are no longer read, and a staff override no longer carries either, so sudo stops sending them. The runner no longer turns a run cap into a cost cap, and the GUARDRAILS it hands a sandbox says the run's time cap and default branch only, without the command rules, deny patterns and cost cap nothing reads. A run's guardrails no longer carry restrictNetwork and hosts, sent only for runners from before network rules were removed. Stored data still reads: the limits and billing_accounts columns stay, no migration is touched, and a run recorded with a budget still shows it. Checked: cargo test for g1t-billing (243) and g1t-contracts (307), cargo check of the workspace, the TypeScript typecheck, the runner's 88 tests, sudo's 153, the contracts' 48 and the deploy tool's 92.
18 files+24−2340/18 viewed
| 122 | 122 | ossRepoMicros: null, | |
| 123 | 123 | trialMicros: null, | |
| 124 | 124 | maxConcurrentAgents: null, | |
| 125 | − | runCapMicros: null, | |
| 126 | − | issueCapMicros: null, | |
| 127 | 125 | auditRetentionDays: null, | |
| 128 | 126 | hold: null, | |
| 129 | 127 | }, | |
| ⋯ | |||
| 138 | 136 | trialMicros: 2_500_000, | |
| 139 | 137 | // Retired with g1t's own agents: always cleared, whatever an old form sends. | |
| 140 | 138 | maxConcurrentAgents: null, | |
| 141 | − | runCapMicros: null, | |
| 142 | − | issueCapMicros: null, | |
| 143 | 139 | auditRetentionDays: 365, | |
| 144 | 140 | hold: "Card disputed; waiting on the bank", | |
| 145 | 141 | }, | |
| 228 | 228 | ossRepoMicros: oss.value, | |
| 229 | 229 | trialMicros: trial.value, | |
| 230 | 230 | maxConcurrentAgents: null, | |
| 231 | − | runCapMicros: null, | |
| 232 | − | issueCapMicros: null, | |
| 233 | 231 | auditRetentionDays, | |
| 234 | 232 | hold: hold || null, | |
| 235 | 233 | }, |
| 795 | 795 | /// the trial, then 10). None: the default. | |
| 796 | 796 | #[serde(default)] | |
| 797 | 797 | pub max_concurrent_agents: Option<u32>, | |
| 798 | − | /// One run's spend cap, in place of `RUN_CAP_MICROS` and the owners' | |
| 799 | − | /// own. None: theirs, or the default. | |
| 800 | − | #[serde(default)] | |
| 801 | − | pub run_cap_micros: Option<i64>, | |
| 802 | − | /// What the agents on one issue may spend in all, in place of | |
| 803 | − | /// `ISSUE_CAP_MICROS` and the owners' own. None: theirs, or the default. | |
| 804 | − | #[serde(default)] | |
| 805 | − | pub issue_cap_micros: Option<i64>, | |
| 806 | 798 | /// Days of audit log its workspaces keep, in place of the plan's (7 | |
| 807 | 799 | /// free, 90 on the plan), longer or shorter. None: the plan's. | |
| 808 | 800 | #[serde(default)] | |
| ⋯ | |||
| 980 | 972 | /// The longest one run may take: 60 minutes in the first month or on | |
| 981 | 973 | /// the trial; otherwise the guardrails' own caps (`MAX_MINUTES`). | |
| 982 | 974 | pub max_run_minutes: u32, | |
| 983 | − | /// One run's spend cap (`RUN_CAP_MICROS`, $2 by default); staff can | |
| 984 | − | /// override it. | |
| 985 | − | pub run_cap_micros: i64, | |
| 986 | − | /// What agents may spend on one issue in all (`ISSUE_CAP_MICROS`, $10 | |
| 987 | − | /// by default); the owners can set it (`set_caps`), and staff override. | |
| 988 | − | pub issue_cap_micros: i64, | |
| 989 | 975 | /// Where on-demand work stops: g1t's ceiling on usage not yet paid | |
| 990 | 976 | /// for. `UNLIMITED_MICROS` for g1t's own workspaces; 0 for a free one, | |
| 991 | 977 | /// which has no on-demand usage. | |
| ⋯ | |||
| 1240 | 1226 | pub workspace: String, | |
| 1241 | 1227 | #[serde(alias = "keep_going")] | |
| 1242 | 1228 | pub keep_going: bool, | |
| 1243 | − | } | |
| 1244 | − | ||
| 1245 | − | /// `set_caps`: the owners' own caps on agents: one run's spend ($0.10 to | |
| 1246 | − | /// $100) and what the agents on one issue may spend in all ($1 to $1,000). | |
| 1247 | − | /// None goes back to the default ($2 and $10). A cap g1t staff set for the | |
| 1248 | − | /// account wins over both. Owners only. Returns `Outcome<Entitlements>`. | |
| 1249 | − | #[derive(Debug, Serialize, Deserialize)] | |
| 1250 | − | #[serde(rename_all = "camelCase")] | |
| 1251 | − | pub struct SetCapsArgs { | |
| 1252 | − | pub actor: User, | |
| 1253 | − | pub workspace: String, | |
| 1254 | − | #[serde(default, alias = "run_cap_micros")] | |
| 1255 | − | pub run_cap_micros: Option<i64>, | |
| 1256 | − | #[serde(default, alias = "issue_cap_micros")] | |
| 1257 | − | pub issue_cap_micros: Option<i64>, | |
| 1258 | 1229 | } | |
| 1259 | 1230 | ||
| 1260 | 1231 | /// What staff see beside a request: the workspace's history with g1t. | |
| 25 | 25 | /// The longest any run may be allowed to take, in minutes. | |
| 26 | 26 | pub const MAX_MINUTES: u32 = 240; | |
| 27 | 27 | ||
| 28 | − | /// One run's spend cap unless an owner or g1t staff set another, in | |
| 29 | − | /// micro-dollars: billing's spend cap per run. | |
| 30 | − | pub const DEFAULT_RUN_CAP_MICROS: i64 = 2_000_000; | |
| 31 | − | ||
| 32 | 28 | /// How long each kind of run may take unless the workspace says otherwise. | |
| 33 | 29 | pub fn default_minutes(kind: RunKind) -> u32 { | |
| 34 | 30 | match kind { | |
| ⋯ | |||
| 65 | 61 | pub struct Guardrails { | |
| 66 | 62 | /// Every kind of run. | |
| 67 | 63 | pub minutes: BTreeMap<String, u32>, | |
| 68 | − | /// Always false, and `hosts` always empty: sent for a runner deployed | |
| 69 | − | /// before network rules were removed, which reads them to start its | |
| 70 | − | /// sandboxes with an open network rather than failing to start them. | |
| 71 | − | /// Never read. Remove once the runner after this change is deployed. | |
| 72 | − | #[serde(skip_deserializing)] | |
| 73 | − | pub restrict_network: bool, | |
| 74 | − | #[serde(skip_deserializing)] | |
| 75 | − | pub hosts: Vec<String>, | |
| 76 | 64 | } | |
| 77 | 65 | ||
| 78 | 66 | impl Guardrails { | |
| ⋯ | |||
| 83 | 71 | .into_iter() | |
| 84 | 72 | .map(|kind| (kind.as_str().to_owned(), default_minutes(kind))) | |
| 85 | 73 | .collect(), | |
| 86 | − | restrict_network: false, | |
| 87 | − | hosts: Vec::new(), | |
| 88 | 74 | } | |
| 89 | 75 | } | |
| 90 | 76 | ||
| ⋯ | |||
| 316 | 302 | })) | |
| 317 | 303 | .unwrap(); | |
| 318 | 304 | assert_eq!(old.minutes_for(RunKind::Implement), 90); | |
| 319 | − | assert!(!old.restrict_network && old.hosts.is_empty()); | |
| 320 | − | } | |
| 321 | − | ||
| 322 | − | #[test] | |
| 323 | − | fn a_runner_from_before_reads_an_open_network() { | |
| 324 | 305 | let sent = serde_json::to_value(Guardrails::defaults()).unwrap(); | |
| 325 | − | assert_eq!(sent["restrictNetwork"], false); | |
| 326 | − | assert_eq!(sent["hosts"], serde_json::json!([])); | |
| 306 | + | assert!(sent.get("restrictNetwork").is_none() && sent.get("hosts").is_none()); | |
| 327 | 307 | } | |
| 328 | 308 | ||
| 329 | 309 | #[test] | |
| 92 | 92 | model?: string | null; | |
| 93 | 93 | sandbox: string; | |
| 94 | 94 | startedBy?: string | null; | |
| 95 | − | /** Its caps, from its guardrails. */ | |
| 96 | − | budgetUsd?: number | null; | |
| 95 | + | /** Its time cap, from its guardrails. */ | |
| 97 | 96 | timeCapMinutes?: number | null; | |
| 98 | 97 | }; | |
| 99 | 98 |
| 184 | 184 | trialMicros: number | null; | |
| 185 | 185 | /** Agents at once, in place of the plan's (2 in the first month or on the trial, then 10); null for the default. */ | |
| 186 | 186 | maxConcurrentAgents?: number | null; | |
| 187 | − | /** One run's spend cap, in place of the owners' and the default $2; null for none. */ | |
| 188 | − | runCapMicros?: number | null; | |
| 189 | − | /** What one issue's agents may spend in all, in place of the owners' and the default $10; null for none. */ | |
| 190 | − | issueCapMicros?: number | null; | |
| 191 | 187 | /** Days of audit log its workspaces keep, in place of the plan's (7 free, 90 on the plan), longer or shorter; null for the plan's. */ | |
| 192 | 188 | auditRetentionDays?: number | null; | |
| 193 | 189 | /** A hold on new compute, with why; null for none. */ | |
| ⋯ | |||
| 255 | 251 | maxConcurrentAgents: number; | |
| 256 | 252 | /** 60 in the first month or on the trial; otherwise the guardrails' own caps. */ | |
| 257 | 253 | maxRunMinutes: number; | |
| 258 | − | /** One run's spend cap, $2 by default; staff can override it. */ | |
| 259 | − | runCapMicros: number; | |
| 260 | − | /** Agent spend on one issue in all, $10 by default. */ | |
| 261 | − | issueCapMicros: number; | |
| 262 | 254 | /** g1t's ceiling on usage not yet paid for; `UNLIMITED_MICROS` for g1t's own; 0 for free. */ | |
| 263 | 255 | ceilingMicros: number; | |
| 264 | 256 | /** Usage not yet paid for this month, prepayment taken off. */ | |
| ⋯ | |||
| 1024 | 1016 | ): Promise<Result<LimitRequest>>; | |
| 1025 | 1017 | /** The workspace's requests and their answers, newest first. Members only. */ | |
| 1026 | 1018 | limitRequests(workspace: string, viewer: Viewer): Promise<Result<LimitRequest[]>>; | |
| 1027 | − | /** | |
| 1028 | − | * The owners' own caps on agents: one run's spend ($0.10 to $100) and one issue's ($1 to $1,000). | |
| 1029 | − | * Null goes back to the default ($2 and $10). A cap staff set wins. Owners only. | |
| 1030 | − | */ | |
| 1031 | − | setCaps(actor: User, workspace: string, caps: { runCapMicros: number | null; issueCapMicros: number | null }): Promise<Result<Entitlements>>; | |
| 1032 | 1019 | /** An owner's answer to a spend spike: keep going for 24 hours, or stop. */ | |
| 1033 | 1020 | confirmSpike(actor: User, workspace: string, keepGoing: boolean): Promise<Result<Entitlements>>; | |
| 1034 | 1021 | /** Every metered price and the recent changes. Public. */ | |
| 598 | 598 | requestLimit: (actor, workspace, request) => call("request_limit", { actor, workspace, ...request }), | |
| 599 | 599 | limitRequests: (workspace, viewer) => call("limit_requests", { workspace, viewer }), | |
| 600 | 600 | confirmSpike: (actor, workspace, keepGoing) => call("confirm_spike", { actor, workspace, keepGoing }), | |
| 601 | − | setCaps: (actor, workspace, caps) => call("set_caps", { actor, workspace, ...caps }), | |
| 602 | 601 | usageReport: (workspace, viewer, range) => | |
| 603 | 602 | call("usage_report", { workspace, viewer, from: range.from, until: range.until, products: range.products ?? [], projects: range.projects ?? [] }), | |
| 604 | 603 | setBudget: (actor, workspace, budget) => call("set_budget", { actor, workspace, ...budget }), |
| 68 | 68 | firstMonth: boolean; | |
| 69 | 69 | /** The longest any sandbox may run, in minutes; zero means no cap. */ | |
| 70 | 70 | maxRunMinutes: number; | |
| 71 | − | /** The most one sandbox run may cost; zero means no cap. */ | |
| 72 | − | runCapMicros: number; | |
| 73 | 71 | ceilingMicros: number; | |
| 74 | 72 | exposureMicros: number; | |
| 75 | 73 | /** Why g1t paused the workspace's compute; null when it is not paused. */ | |
| ⋯ | |||
| 121 | 119 | trialVerified: field(raw, "trialVerified") === true, | |
| 122 | 120 | firstMonth: field(raw, "firstMonth") === true, | |
| 123 | 121 | maxRunMinutes: num(field(raw, "maxRunMinutes")), | |
| 124 | − | runCapMicros: num(field(raw, "runCapMicros")), | |
| 125 | 122 | ceilingMicros: num(field(raw, "ceilingMicros")), | |
| 126 | 123 | exposureMicros: num(field(raw, "exposureMicros")), | |
| 127 | 124 | paused: typeof paused === "string" && paused.trim() ? paused.trim() : null, | |
| 19 | 19 | ||
| 20 | 20 | /** What a run actually gets. */ | |
| 21 | 21 | export type Guardrails = { | |
| 22 | − | /** | |
| 23 | − | * No longer sent: command rules, deny patterns and the cost cap were for | |
| 24 | − | * g1t's own agents. Kept optional for the runner, which still passes | |
| 25 | − | * them on (and a plan's cost cap) when they are there. | |
| 26 | − | */ | |
| 27 | − | rules?: Record<string, boolean>; | |
| 28 | − | deny?: string[]; | |
| 29 | − | budgetUsd?: number | null; | |
| 30 | 22 | minutes: Record<string, number>; | |
| 31 | 23 | }; | |
| 32 | 24 |
| 52 | 52 | #[serde(default)] | |
| 53 | 53 | max_concurrent_agents: Option<u32>, | |
| 54 | 54 | #[serde(default)] | |
| 55 | − | run_cap_micros: Option<i64>, | |
| 56 | − | #[serde(default)] | |
| 57 | − | issue_cap_micros: Option<i64>, | |
| 58 | − | #[serde(default)] | |
| 59 | 55 | audit_retention_days: Option<u32>, | |
| 60 | 56 | #[serde(default)] | |
| 61 | 57 | hold: Option<String>, | |
| ⋯ | |||
| 69 | 65 | oss_repo_micros: self.oss_repo_micros, | |
| 70 | 66 | trial_micros: self.trial_micros, | |
| 71 | 67 | max_concurrent_agents: self.max_concurrent_agents, | |
| 72 | − | run_cap_micros: self.run_cap_micros, | |
| 73 | − | issue_cap_micros: self.issue_cap_micros, | |
| 74 | 68 | audit_retention_days: self.audit_retention_days, | |
| 75 | 69 | hold: self.hold.clone().filter(|h| !h.trim().is_empty()), | |
| 76 | 70 | } | |
| ⋯ | |||
| 508 | 502 | return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change, and why, in the note.")); | |
| 509 | 503 | } | |
| 510 | 504 | let money = |m: Option<i64>| m.is_none_or(|m| (0..=1_000 * g1t_contracts::billing::MICROS_PER_DOLLAR).contains(&m)); | |
| 511 | − | if !money(a.allowances.oss_repo_micros) || !money(a.allowances.trial_micros) || !money(a.allowances.run_cap_micros) || !money(a.allowances.issue_cap_micros) { | |
| 512 | − | return Ok(Outcome::fail(FailureCode::Invalid, "A pool share or run cap is between $0 and $1,000.")); | |
| 505 | + | if !money(a.allowances.oss_repo_micros) || !money(a.allowances.trial_micros) { | |
| 506 | + | return Ok(Outcome::fail(FailureCode::Invalid, "A pool share or trial is between $0 and $1,000.")); | |
| 513 | 507 | } | |
| 514 | 508 | if a.allowances.max_concurrent_agents.is_some_and(|n| n == 0 || n > 1_000) { | |
| 515 | 509 | return Ok(Outcome::fail(FailureCode::Invalid, "Agents at once is between 1 and 1,000.")); | |
| ⋯ | |||
| 526 | 520 | self.db | |
| 527 | 521 | .prepare( | |
| 528 | 522 | "INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, note, created_by, created_at, | |
| 529 | − | team_granted, oss_repo_micros, trial_micros, max_concurrent_agents, run_cap_micros, hold, issue_cap_micros, | |
| 530 | − | audit_retention_days) | |
| 531 | − | VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) | |
| 523 | + | team_granted, oss_repo_micros, trial_micros, max_concurrent_agents, hold, audit_retention_days) | |
| 524 | + | VALUES (?1, ?2, ?3, 'standard', 0, '', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) | |
| 532 | 525 | ON CONFLICT (id) DO UPDATE SET team_granted = ?6, oss_repo_micros = ?7, trial_micros = ?8, | |
| 533 | − | max_concurrent_agents = ?9, run_cap_micros = ?10, hold = ?11, issue_cap_micros = ?12, | |
| 534 | − | audit_retention_days = ?13", | |
| 526 | + | max_concurrent_agents = ?9, hold = ?10, audit_retention_days = ?11", | |
| 535 | 527 | ) | |
| 536 | 528 | .bind(&[ | |
| 537 | 529 | account.id.as_str().into(), | |
| ⋯ | |||
| 543 | 535 | opt(a.allowances.oss_repo_micros), | |
| 544 | 536 | opt(a.allowances.trial_micros), | |
| 545 | 537 | a.allowances.max_concurrent_agents.map_or(JsValue::NULL, JsValue::from), | |
| 546 | − | opt(a.allowances.run_cap_micros), | |
| 547 | 538 | optional(a.allowances.hold.as_deref().map(str::trim).filter(|h| !h.is_empty())), | |
| 548 | − | opt(a.allowances.issue_cap_micros), | |
| 549 | 539 | a.allowances.audit_retention_days.map_or(JsValue::NULL, JsValue::from), | |
| 550 | 540 | ])? | |
| 551 | 541 | .run() | |
| ⋯ | |||
| 711 | 701 | } | |
| 712 | 702 | if let Some(n) = a.max_concurrent_agents { | |
| 713 | 703 | parts.push(format!("{n} agents at once")); | |
| 714 | − | } | |
| 715 | − | if let Some(m) = a.run_cap_micros { | |
| 716 | − | parts.push(format!("run cap {}", crate::features::dollars(m))); | |
| 717 | 704 | } | |
| 718 | − | if let Some(m) = a.issue_cap_micros { | |
| 719 | − | parts.push(format!("issue cap {}", crate::features::dollars(m))); | |
| 720 | − | } | |
| 721 | 705 | if let Some(days) = a.audit_retention_days { | |
| 722 | 706 | parts.push(format!("audit log {days} days")); | |
| 723 | 707 | } | |
| ⋯ | |||
| 810 | 794 | oss_repo_micros: Some(5_000_000), | |
| 811 | 795 | trial_micros: Some(2_000_000), | |
| 812 | 796 | max_concurrent_agents: Some(4), | |
| 813 | − | run_cap_micros: Some(3_000_000), | |
| 814 | − | issue_cap_micros: None, | |
| 815 | 797 | audit_retention_days: Some(365), | |
| 816 | 798 | hold: Some("mining".into()), | |
| 817 | 799 | }; | |
| 818 | 800 | assert_eq!( | |
| 819 | 801 | describe_allowances(&given), | |
| 820 | − | "plan given, open-source share $5.00 a repository, trial $2.00, 4 agents at once, run cap $3.00, audit log 365 days, hold: mining" | |
| 802 | + | "plan given, open-source share $5.00 a repository, trial $2.00, 4 agents at once, audit log 365 days, hold: mining" | |
| 821 | 803 | ); | |
| 822 | 804 | } | |
| 823 | 805 | ||
| 27 | 27 | //! stop. Runs already under way finish. g1t's own workspaces are watched | |
| 28 | 28 | //! but never paused. | |
| 29 | 29 | ||
| 30 | − | use futures_util::future::{try_join, try_join4, try_join5}; | |
| 30 | + | use futures_util::future::{try_join, try_join4}; | |
| 31 | 31 | use g1t_contracts::billing::{ | |
| 32 | − | BillingAccount, ComputeKind, ConfirmSpikeArgs, SetCapsArgs, Entitlements, EntitlementsArgs, LimitState, PaidBy, PlanKind, Reservation, | |
| 32 | + | BillingAccount, ComputeKind, ConfirmSpikeArgs, Entitlements, EntitlementsArgs, LimitState, PaidBy, PlanKind, Reservation, | |
| 33 | 33 | ReserveArgs, SettleArgs, Spike, UNLIMITED_MICROS, UsageAlert, RESERVATION_HOURS, | |
| 34 | 34 | }; | |
| 35 | 35 | use g1t_contracts::time::rfc3339; | |
| ⋯ | |||
| 473 | 473 | }; | |
| 474 | 474 | Ok::<_, worker::Error>((checked, grant)) | |
| 475 | 475 | }; | |
| 476 | − | let counts = try_join5( | |
| 477 | − | self.owner_caps(&workspace), | |
| 476 | + | let counts = try_join4( | |
| 478 | 477 | self.private_storage(&workspace), | |
| 479 | 478 | self.oss_paid(&workspace, &month), | |
| 480 | 479 | self.held(&account.workspaces), | |
| 481 | 480 | async { if has_plan { self.allowance_used("plan_credit", &workspace, &month).await } else { Ok(0) } }, | |
| 482 | 481 | ); | |
| 483 | 482 | let more = try_join(self.allowance_used("build_seconds", &workspace, &month), self.git_operations_this_month(&workspace)); | |
| 484 | − | let ((limit, (paused, spike, _)), (verified, grant), (owners, stored, oss, held, included_used), (build_seconds, git_operations)) = | |
| 483 | + | let ((limit, (paused, spike, _)), (verified, grant), (stored, oss, held, included_used), (build_seconds, git_operations)) = | |
| 485 | 484 | try_join4(standing, trial, counts, more).await?; | |
| 486 | 485 | let trial_left = grant.as_ref().map_or(0, |g| left(g.granted_micros, g.used_micros)); | |
| 487 | 486 | let first_month = limit.first_month; | |
| ⋯ | |||
| 500 | 499 | first_month, | |
| 501 | 500 | max_concurrent_agents: max_agents, | |
| 502 | 501 | max_run_minutes: max_minutes, | |
| 503 | − | run_cap_micros: account.allowances.run_cap_micros.or(owners.0).unwrap_or(self.plans.run_cap_micros), | |
| 504 | − | issue_cap_micros: account.allowances.issue_cap_micros.or(owners.1).unwrap_or(self.plans.issue_cap_micros), | |
| 505 | 502 | ceiling_micros: ceiling, | |
| 506 | 503 | exposure_micros: limit.exposure_micros, | |
| 507 | 504 | paused, | |
| ⋯ | |||
| 736 | 733 | &account.id, | |
| 737 | 734 | "spike", | |
| 738 | 735 | &format!("{workspace}: {} after {} in an hour", if a.keep_going { "kept going" } else { "stopped" }, dollars(spike.hour_micros)), | |
| 739 | − | &a.actor.username, | |
| 740 | − | ) | |
| 741 | − | .await?; | |
| 742 | − | Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?)) | |
| 743 | − | } | |
| 744 | − | ||
| 745 | − | /// The owners' own run and issue caps, if they set them. | |
| 746 | − | async fn owner_caps(&self, workspace: &str) -> Result<(Option<i64>, Option<i64>)> { | |
| 747 | − | #[derive(Deserialize)] | |
| 748 | − | struct Row { | |
| 749 | − | run_cap_micros: Option<i64>, | |
| 750 | − | issue_cap_micros: Option<i64>, | |
| 751 | − | } | |
| 752 | − | Ok(self | |
| 753 | − | .db | |
| 754 | − | .prepare("SELECT run_cap_micros, issue_cap_micros FROM limits WHERE workspace = ?") | |
| 755 | − | .bind(&[workspace.into()])? | |
| 756 | − | .first::<Row>(None) | |
| 757 | − | .await? | |
| 758 | − | .map_or((None, None), |r| (r.run_cap_micros, r.issue_cap_micros))) | |
| 759 | − | } | |
| 760 | − | ||
| 761 | − | /// `set_caps`: the owners' own run and issue caps. | |
| 762 | − | pub(crate) async fn set_caps(&self, a: SetCapsArgs) -> Result<Outcome<Entitlements>> { | |
| 763 | − | let workspace = a.workspace.to_lowercase(); | |
| 764 | − | if !a.actor.manages_billing(&workspace) { | |
| 765 | − | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can set the workspace's caps.")); | |
| 766 | − | } | |
| 767 | − | if let Err(why) = cap_bounds(a.run_cap_micros, a.issue_cap_micros) { | |
| 768 | − | return Ok(Outcome::fail(FailureCode::Invalid, why)); | |
| 769 | − | } | |
| 770 | − | let opt = |m: Option<i64>| m.map_or(JsValue::NULL, |m| (m as f64).into()); | |
| 771 | − | let now = rfc3339(now_ms()); | |
| 772 | − | self.db | |
| 773 | − | .prepare( | |
| 774 | − | "INSERT INTO limits (workspace, run_cap_micros, issue_cap_micros, updated_at) VALUES (?1, ?2, ?3, ?4) | |
| 775 | − | ON CONFLICT (workspace) DO UPDATE SET run_cap_micros = ?2, issue_cap_micros = ?3, updated_at = ?4", | |
| 776 | − | ) | |
| 777 | − | .bind(&[workspace.as_str().into(), opt(a.run_cap_micros), opt(a.issue_cap_micros), now.into()])? | |
| 778 | − | .run() | |
| 779 | − | .await?; | |
| 780 | − | let account = self.account_of(&workspace).await?; | |
| 781 | − | let shown = |m: Option<i64>| m.map_or_else(|| "the default".to_owned(), dollars); | |
| 782 | − | self.audit( | |
| 783 | − | &account.id, | |
| 784 | − | "caps", | |
| 785 | − | &format!("{workspace}: run cap {}, issue cap {}", shown(a.run_cap_micros), shown(a.issue_cap_micros)), | |
| 786 | 736 | &a.actor.username, | |
| 787 | 737 | ) | |
| 788 | 738 | .await?; | |
| ⋯ | |||
| 859 | 809 | ||
| 860 | 810 | /// Whether owners' caps are in bounds: a run $0.10 to $100 (the | |
| 861 | 811 | /// guardrails' most), an issue $1 to $1,000. | |
| 862 | − | pub(crate) fn cap_bounds(run: Option<i64>, issue: Option<i64>) -> std::result::Result<(), String> { | |
| 863 | − | if run.is_some_and(|m| !(100_000..=100_000_000).contains(&m)) { | |
| 864 | − | return Err("A run's cap is between $0.10 and $100.".to_owned()); | |
| 865 | − | } | |
| 866 | − | if issue.is_some_and(|m| !(1_000_000..=1_000_000_000).contains(&m)) { | |
| 867 | − | return Err("An issue's cap is between $1 and $1,000.".to_owned()); | |
| 868 | − | } | |
| 869 | − | Ok(()) | |
| 870 | − | } | |
| 871 | − | ||
| 872 | 812 | pub(crate) fn paid_by_text(paid_by: PaidBy) -> &'static str { | |
| 873 | 813 | match paid_by { | |
| 874 | 814 | PaidBy::Credit => "credit", | |
| ⋯ | |||
| 1032 | 972 | // A day later: watched afresh. | |
| 1033 | 973 | assert!(!still_continued(Some(until), "2026-10-06T12:00:01Z", 6_000_000, 1_000_000)); | |
| 1034 | 974 | assert!(!still_continued(None, "2026-10-06T11:00:00Z", 6_000_000, 1)); | |
| 1035 | − | } | |
| 1036 | − | ||
| 1037 | − | #[test] | |
| 1038 | − | fn owners_caps_stay_in_bounds() { | |
| 1039 | − | assert!(cap_bounds(None, None).is_ok()); | |
| 1040 | − | assert!(cap_bounds(Some(5_000_000), Some(50_000_000)).is_ok()); | |
| 1041 | − | assert!(cap_bounds(Some(50_000), None).is_err()); | |
| 1042 | − | assert!(cap_bounds(Some(101_000_000), None).is_err()); | |
| 1043 | − | assert!(cap_bounds(None, Some(500_000)).is_err()); | |
| 1044 | − | assert!(cap_bounds(None, Some(2_000_000_000)).is_err()); | |
| 1045 | 975 | } | |
| 1046 | 976 | ||
| 1047 | 977 | #[test] | |
| 72 | 72 | pub free_audit_days: u32, | |
| 73 | 73 | pub audit_days: u32, | |
| 74 | 74 | pub audit_max_days: u32, | |
| 75 | − | /// `RUN_CAP_MICROS` and `ISSUE_CAP_MICROS`: one run's spend cap, and | |
| 76 | − | /// agents' spend on one issue in all. | |
| 77 | − | pub run_cap_micros: i64, | |
| 78 | − | pub issue_cap_micros: i64, | |
| 79 | 75 | /// `LIMIT_PAID_START_MICROS`: a new paid workspace's ceiling in its | |
| 80 | 76 | /// first month. | |
| 81 | 77 | pub paid_start_micros: i64, | |
| ⋯ | |||
| 109 | 105 | free_audit_days: 7, | |
| 110 | 106 | audit_days: 90, | |
| 111 | 107 | audit_max_days: 400, | |
| 112 | − | run_cap_micros: g1t_contracts::guardrails::DEFAULT_RUN_CAP_MICROS, | |
| 113 | − | issue_cap_micros: 10_000_000, | |
| 114 | 108 | paid_start_micros: 100_000_000, | |
| 115 | 109 | spike_factor: 5, | |
| 116 | 110 | spike_floor_micros: 5_000_000, | |
| ⋯ | |||
| 140 | 134 | free_audit_days: number("FREE_AUDIT_RETENTION_DAYS", d.free_audit_days.into()).max(1) as u32, | |
| 141 | 135 | audit_days: number("AUDIT_RETENTION_DAYS", d.audit_days.into()).max(1) as u32, | |
| 142 | 136 | audit_max_days: number("AUDIT_MAX_DAYS", d.audit_max_days.into()).max(1) as u32, | |
| 143 | − | run_cap_micros: number("RUN_CAP_MICROS", d.run_cap_micros), | |
| 144 | − | issue_cap_micros: number("ISSUE_CAP_MICROS", d.issue_cap_micros), | |
| 145 | 137 | paid_start_micros: number("LIMIT_PAID_START_MICROS", d.paid_start_micros), | |
| 146 | 138 | spike_factor: number("SPIKE_FACTOR", d.spike_factor).max(1), | |
| 147 | 139 | spike_floor_micros: number("SPIKE_FLOOR_MICROS", d.spike_floor_micros), | |
| ⋯ | |||
| 782 | 774 | assert_eq!(c.free_audit_days, 7); | |
| 783 | 775 | assert_eq!(c.audit_days, 90); | |
| 784 | 776 | assert_eq!(c.audit_max_days, 400); | |
| 785 | − | assert_eq!(c.run_cap_micros, g1t_contracts::guardrails::DEFAULT_RUN_CAP_MICROS); | |
| 786 | − | assert_eq!(c.issue_cap_micros, 10_000_000); | |
| 787 | 777 | assert_eq!(c.paid_start_micros, 100_000_000); | |
| 788 | 778 | assert_eq!(c.forgive_cost_micros, 50_000_000); | |
| 789 | 779 | assert_eq!(c.git_included, 50_000); | |
| 1213 | 1213 | "request_limit" => reply(&billing.request_limit(args(body)?).await?), | |
| 1214 | 1214 | "limit_requests" => reply(&billing.limit_requests(args(body)?).await?), | |
| 1215 | 1215 | "confirm_spike" => reply(&billing.confirm_spike(args(body)?).await?), | |
| 1216 | − | "set_caps" => reply(&billing.set_caps(args(body)?).await?), | |
| 1217 | 1216 | "admin_limit_requests" => reply(&billing.admin_limit_requests(args(body)?).await?), | |
| 1218 | 1217 | "admin_decide_limit_request" => reply(&billing.admin_decide_limit_request(args(body)?).await?), | |
| 1219 | 1218 | "admin_overages" => reply(&billing.admin_overages(args(body)?).await?), |
| 88 | 88 | "LIMIT_PAID_START_MICROS": "100000000", | |
| 89 | 89 | "LIMIT_PAID_MIN_MICROS": "25000000", | |
| 90 | 90 | "LIMIT_PAID_MAX_MICROS": "1000000000", | |
| 91 | − | // Caps on agents (src/compute.rs): what the agents on one issue may | |
| 92 | − | // spend in all. One run's spend cap is DEFAULT_RUN_CAP_MICROS in | |
| 93 | − | // crates/contracts (guardrails.rs), shared with the guardrails' cost | |
| 94 | − | // per run; RUN_CAP_MICROS here would override it. Owners and staff | |
| 95 | − | // can change both. | |
| 96 | − | "ISSUE_CAP_MICROS": "10000000", | |
| 97 | 91 | // A spike: an hour's spend above 5 times the usual hour over the last | |
| 98 | 92 | // week, and at least $5, pauses new compute until an owner confirms. | |
| 99 | 93 | "SPIKE_FACTOR": "5", |
| 27 | 27 | assert.equal(allows(hosts, "badexample.com"), false); | |
| 28 | 28 | }); | |
| 29 | 29 | ||
| 30 | − | test("the harness is told the run's rules, caps and branch, and nothing about the network", () => { | |
| 31 | − | const guard = { | |
| 32 | − | policy: { rules: { sudo: true }, deny: ["Bash(kubectl:*)"], budgetUsd: 5, minutes: { implement: 90 } }, | |
| 33 | − | minutes: 90, | |
| 34 | − | }; | |
| 30 | + | test("the harness is told the run's time cap and branch, and nothing about the network", () => { | |
| 31 | + | const guard = { policy: { minutes: { checks: 90 } }, minutes: 90 }; | |
| 35 | 32 | const vars = harnessEnv(guard, { UPSTREAM_BRANCH: "trunk" }); | |
| 36 | 33 | assert.deepEqual(Object.keys(vars), ["GUARDRAILS"]); | |
| 37 | 34 | assert.deepEqual(JSON.parse(vars.GUARDRAILS), { | |
| 38 | − | rules: { sudo: true }, | |
| 39 | − | deny: ["Bash(kubectl:*)"], | |
| 40 | − | budgetUsd: 5, | |
| 41 | 35 | minutes: 90, | |
| 42 | 36 | defaultBranch: "trunk", | |
| 43 | 37 | }); |
| 72 | 72 | export function harnessEnv(guard: RunGuard, sandboxEnv: Record<string, string>): Record<string, string> { | |
| 73 | 73 | return { | |
| 74 | 74 | GUARDRAILS: JSON.stringify({ | |
| 75 | − | rules: guard.policy.rules, | |
| 76 | − | deny: guard.policy.deny, | |
| 77 | − | budgetUsd: guard.policy.budgetUsd, | |
| 78 | 75 | minutes: guard.minutes, | |
| 79 | 76 | defaultBranch: sandboxEnv.UPSTREAM_BRANCH ?? null, | |
| 80 | 77 | }), | |
| ⋯ | |||
| 152 | 149 | return caps.length ? Math.min(...caps) : null; | |
| 153 | 150 | } | |
| 154 | 151 | ||
| 155 | − | /** A plan's caps on one run, from its entitlements; null where it sets none. */ | |
| 156 | − | export type PlanLimits = { minutes?: number | null; budgetUsd?: number | null }; | |
| 152 | + | /** A plan's cap on one run, from its entitlements; null where it sets none. */ | |
| 153 | + | export type PlanLimits = { minutes?: number | null }; | |
| 157 | 154 | ||
| 158 | − | /** | |
| 159 | − | * A run's guardrails under its workspace's plan: the time and cost caps are | |
| 160 | − | * each the lower of the two. | |
| 161 | − | */ | |
| 155 | + | /** A run's guardrails under its workspace's plan: the time cap is the lower of the two. */ | |
| 162 | 156 | export function withPlanLimits(guard: RunGuard, limits: PlanLimits | null | undefined): RunGuard { | |
| 163 | 157 | if (!limits) return guard; | |
| 164 | − | return { | |
| 165 | − | policy: { ...guard.policy, budgetUsd: lower(guard.policy.budgetUsd, limits.budgetUsd) }, | |
| 166 | − | minutes: lower(guard.minutes, limits.minutes) ?? guard.minutes, | |
| 167 | − | }; | |
| 158 | + | return { policy: guard.policy, minutes: lower(guard.minutes, limits.minutes) ?? guard.minutes }; | |
| 168 | 159 | } | |
| 28 | 28 | trialVerified: true, | |
| 29 | 29 | firstMonth: false, | |
| 30 | 30 | maxRunMinutes: 0, | |
| 31 | − | runCapMicros: 2_000_000, | |
| 32 | 31 | ceilingMicros: 100_000_000, | |
| 33 | 32 | exposureMicros: 0, | |
| 34 | 33 | paused: null, | |
| ⋯ | |||
| 233 | 232 | ||
| 234 | 233 | // ---- Caps ------------------------------------------------------------------------------- | |
| 235 | 234 | ||
| 236 | − | test("caps are the lower of the guardrails' and the plan's", () => { | |
| 237 | − | const guard = { policy: { budgetUsd: 5 } as never, minutes: 90 }; | |
| 238 | − | const limited = withPlanLimits(guard as never, { minutes: 60, budgetUsd: 2 }); | |
| 239 | − | assert.equal(limited.minutes, 60); | |
| 240 | − | assert.equal((limited.policy as { budgetUsd: number }).budgetUsd, 2); | |
| 241 | − | const unlimited = withPlanLimits(guard as never, { minutes: null, budgetUsd: null }); | |
| 242 | − | assert.equal(unlimited.minutes, 90); | |
| 243 | − | assert.equal((unlimited.policy as { budgetUsd: number }).budgetUsd, 5); | |
| 235 | + | test("the time cap is the lower of the guardrails' and the plan's", () => { | |
| 236 | + | const guard = { policy: { minutes: {} }, minutes: 90 }; | |
| 237 | + | assert.equal(withPlanLimits(guard, { minutes: 60 }).minutes, 60); | |
| 238 | + | assert.equal(withPlanLimits(guard, { minutes: null }).minutes, 90); | |
| 244 | 239 | }); | |
| 245 | 240 | ||
| 246 | 241 | // ---- Sandboxes' network ------------------------------------------------------------------ | |
| 468 | 468 | ...track, | |
| 469 | 469 | model: null, | |
| 470 | 470 | sandbox: this.ctx.id.toString(), | |
| 471 | − | budgetUsd: guard?.policy.budgetUsd ?? null, | |
| 472 | 471 | timeCapMinutes: guard?.minutes ?? null, | |
| 473 | 472 | }) | |
| 474 | 473 | .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } })); | |
| ⋯ | |||
| 744 | 743 | mergecheck: 10, | |
| 745 | 744 | }; | |
| 746 | 745 | ||
| 747 | − | /** A plan's caps on one run, for the sandbox to apply under its guardrails'. */ | |
| 746 | + | /** A plan's cap on one run, for the sandbox to apply under its guardrails'. */ | |
| 748 | 747 | function limitsOf(ent: ComputeEntitlements | null): PlanLimits { | |
| 749 | − | return { | |
| 750 | − | minutes: ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null, | |
| 751 | − | budgetUsd: ent && ent.runCapMicros > 0 ? ent.runCapMicros / 1_000_000 : null, | |
| 752 | − | }; | |
| 748 | + | return { minutes: ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null }; | |
| 753 | 749 | } | |
| 754 | 750 | ||
| 755 | 751 | /** The shorter of a kind's time cap and the plan's, for an estimate. */ | |