Skip to content

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.sh
1,048 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-a6a121745e81f639f'1//! What Cloudflare charges g1t, day by day and product by product.
2//!
3//! Prices are what g1t pays plus 20%, so g1t has to know what it pays, as
4//! Cloudflare counts it, not as g1t assumes it. Some of what g1t runs on
5//! is in beta (Artifacts bills "operations" from 2026-10-14 without saying
6//! exactly which calls are one), so nothing here assumes a product list:
7//! every line Cloudflare bills is kept, whatever it is, and how a line
8//! maps to what g1t sells is data (`cost_map`), changed without a deploy.
9//!
10//! Once a day (`keeper::DAILY`) billing reads:
11//!
12//! - **Billable usage** (`GET /accounts/{id}/billable-usage`, FOCUS
13//! columns): one row per service per day, with its quantity and what it
14//! cost g1t. Every Cloudflare product g1t uses appears here once it is
15//! used: Workers, Workers for Platforms, D1, KV, R2, Queues, Containers,
16//! Durable Objects, Artifacts, Browser Rendering, Workers AI, Vectorize,
17//! Cloudflare for SaaS and Email.
18//! - **Artifacts events** (GraphQL `artifactsEventsAdaptiveGroups`), by
19//! event type and day: what Artifacts itself counted, to compare with
20//! what g1t counted (`margin`).
21//!
22//! Each becomes cost lines in `cost_lines`, one per (day, source,
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)23//! product, meter), upserted, so reading a day again replaces it. The bill
24//! is read over whole billing cycles, every page of it, and priced over
25//! each cycle (`cycle`): its included amounts are the account's, once a
26//! cycle. The Artifacts events: the first run reads the last 31 days; after
27//! that the last few, since Cloudflare restates recent days as usage
28//! settles.
Merge branch 'worktree-agent-a6a121745e81f639f'29//!
30//! The token is `CLOUDFLARE_BILLING_TOKEN` (Account: Billing Read and
31//! Account Analytics Read), or the keeper's `CLOUDFLARE_USAGE_TOKEN`,
32//! which has both. Without either, nothing is read and nothing fails.
33
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)34use std::collections::{BTreeMap, BTreeSet};
Merge branch 'worktree-agent-a6a121745e81f639f'35
36use g1t_contracts::repos::{GitOperationsArgs, WorkspaceGitOperations};
37use g1t_contracts::time::rfc3339;
38use g1t_kit::now_ms;
39use serde::Deserialize;
40use serde_json::{Value, json};
41use worker::Result;
42
43use crate::Billing;
44use crate::keeper::Keeper;
45
46/// Where a cost line came from.
47pub(crate) const SOURCE_BILLABLE: &str = "billable_usage";
48pub(crate) const SOURCE_ARTIFACTS: &str = "artifacts_events";
49
50/// How far back the first run reads: the 31 days GraphQL keeps.
51pub(crate) const BACKFILL_DAYS: u64 = 31;
52/// How many recent days every later run reads again.
53pub(crate) const RESTATE_DAYS: u64 = 4;
54
55pub(crate) const DAY_MS: u64 = 24 * 60 * 60 * 1000;
56
57/// One day of one meter of one Cloudflare product.
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)58#[derive(Clone, Debug, Default, PartialEq)]
Merge branch 'worktree-agent-a6a121745e81f639f'59pub(crate) struct CostLine {
60 /// YYYY-MM-DD, UTC.
61 pub day: String,
62 pub source: &'static str,
63 /// `containers`, `workers`, `workers_kv`, `artifacts`, … from
64 /// Cloudflare's own family name.
65 pub product: String,
66 /// The service within it, such as `container_memory_per_gib_second`
67 /// or `events_push`.
68 pub meter: String,
69 pub unit: String,
70 pub quantity: f64,
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)71 /// What g1t pays, in dollars: for billable usage, set over the billing
72 /// cycle by `cycle::price_lines` (Cloudflare's own cost where its lines
73 /// carry one, else the list price past the included amount).
Merge branch 'worktree-agent-a6a121745e81f639f'74 pub cost_usd: f64,
75 /// The name as Cloudflare gave it, for people.
76 pub raw_name: String,
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)77 /// What Cloudflare's line itself said it cost (contracted, billed or
78 /// effective; never list, which is before the included amounts).
79 pub billed_usd: f64,
80 /// Of `quantity`, what is past the cycle's included amount.
81 pub billable_quantity: f64,
82 /// Where `cost_usd` came from: `cycle::BASIS_*`, or empty for lines
83 /// that are not billable usage.
84 pub basis: &'static str,
Merge branch 'worktree-agent-a6a121745e81f639f'85}
86
87/// `Workers for Platforms CPU ms (First 60M ms are included)` →
88/// `workers_for_platforms_cpu_ms`: lower case, words joined by `_`, and
89/// what is in parentheses (the included amount, which changes) left out.
90pub(crate) fn slug(text: &str) -> String {
91 let mut out = String::new();
92 let mut depth = 0u32;
93 let mut gap = false;
94 for c in text.chars() {
95 match c {
96 '(' => depth += 1,
97 ')' => depth = depth.saturating_sub(1),
98 _ if depth > 0 => {}
99 c if c.is_ascii_alphanumeric() => {
100 if gap && !out.is_empty() {
101 out.push('_');
102 }
103 gap = false;
104 out.push(c.to_ascii_lowercase());
105 }
106 _ => gap = true,
107 }
108 }
109 out
110}
111
112/// The product and meter for a family and service name, such as
113/// (`D1`, `D1 - Rows Read (first 25 billion included)`) → (`d1`,
114/// `d1_rows_read`). Without a family, the service's first word.
115pub(crate) fn product_and_meter(family: &str, service: &str) -> (String, String) {
116 let (family, service) = if family.trim().is_empty() {
117 match service.split_once(" / ") {
118 Some((family, service)) => (family, service),
119 None => (service.split_whitespace().next().unwrap_or("other"), service),
120 }
121 } else {
122 (family, service)
123 };
124 let product = slug(family);
125 let product = if product.is_empty() { "other".to_owned() } else { product };
126 // Kept whole: `Workers for Platforms Requests` under `Workers` must stay
127 // `workers_for_platforms_requests`.
128 let meter = slug(service);
129 let meter = if meter.is_empty() { "usage".to_owned() } else { meter };
130 (product, meter)
131}
132
133fn text<'a>(row: &'a Value, keys: &[&str]) -> &'a str {
134 keys.iter().find_map(|k| row[*k].as_str()).unwrap_or_default()
135}
136
137fn number(row: &Value, keys: &[&str]) -> Option<f64> {
138 keys.iter()
139 .find_map(|k| row[*k].as_f64().or_else(|| row[*k].as_str().and_then(|s| s.trim().parse().ok())))
140}
141
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)142/// FOCUS's consumed quantity: what was used, in the meter's own units.
143const CONSUMED: [&str; 2] = ["ConsumedQuantity", "consumed_quantity"];
144/// FOCUS's pricing quantity: in pricing units, which can be blocks.
145const PRICING: [&str; 2] = ["PricingQuantity", "pricing_quantity"];
146/// What a line cost g1t, as Cloudflare says: contracted, billed, effective.
147/// Never `ListCost`: that is before the included amounts.
148const COSTS: [&[&str]; 3] = [&["ContractedCost", "contracted_cost"], &["BilledCost", "billed_cost"], &["EffectiveCost", "effective_cost"]];
149
150fn billed(row: &Value) -> f64 {
151 COSTS.iter().find_map(|keys| number(row, keys).filter(|c| *c > 0.0)).unwrap_or(0.0)
152}
153
Merge branch 'worktree-agent-a6a121745e81f639f'154/// One row of billable usage, read leniently: the API is new, and its
155/// field names are FOCUS's (in either case). None without a service or a
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)156/// day. The quantity is what was consumed (the included amounts are applied
157/// to it over the cycle, `cycle`), else the pricing quantity.
Merge branch 'worktree-agent-a6a121745e81f639f'158pub(crate) fn line_from_focus(row: &Value) -> Option<CostLine> {
159 let service = text(row, &["ServiceName", "service_name", "service"]);
160 let day = text(row, &["ChargePeriodStart", "charge_period_start", "UsageDate", "date"]);
161 if service.is_empty() || day.len() < 10 {
162 return None;
163 }
164 let family = text(row, &["ServiceFamilyName", "service_family_name", "ServiceCategory"]);
165 let (product, meter) = product_and_meter(family, service);
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)166 let consumed = number(row, &CONSUMED);
167 let billed = billed(row);
Merge branch 'worktree-agent-a6a121745e81f639f'168 Some(CostLine {
169 day: day[..10].to_owned(),
170 source: SOURCE_BILLABLE,
171 product,
172 meter,
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)173 unit: if consumed.is_some() {
174 text(row, &["ConsumedUnit", "consumed_unit", "PricingUnit", "pricing_unit"])
175 } else {
176 text(row, &["PricingUnit", "pricing_unit", "ConsumedUnit", "consumed_unit"])
177 }
178 .to_owned(),
179 quantity: consumed.or_else(|| number(row, &PRICING)).unwrap_or(0.0),
180 cost_usd: billed,
Merge branch 'worktree-agent-a6a121745e81f639f'181 raw_name: if family.is_empty() { service.to_owned() } else { format!("{family} / {service}") },
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)182 billed_usd: billed,
183 ..CostLine::default()
184 })
185}
186
187/// What a read of billable usage got back, to tell from sudo whether it
188/// was all of it and in which units.
189#[derive(Clone, Debug, Default, PartialEq)]
190pub(crate) struct ReadStats {
191 pub rows: u32,
192 pub pages: u32,
193 pub consumed_rows: u32,
194 pub pricing_only_rows: u32,
195 pub costed_rows: u32,
196}
197
198pub(crate) fn read_stats(body: &Value, pages: u32) -> ReadStats {
199 let rows = body["result"].as_array().cloned().unwrap_or_default();
200 let mut stats = ReadStats { rows: rows.len() as u32, pages, ..ReadStats::default() };
201 for row in &rows {
202 if number(row, &CONSUMED).is_some() {
203 stats.consumed_rows += 1;
204 } else if number(row, &PRICING).is_some() {
205 stats.pricing_only_rows += 1;
206 }
207 if billed(row) > 0.0 {
208 stats.costed_rows += 1;
209 }
210 }
211 stats
212}
213
214/// The query for the page after `page` of a v4 answer: by cursor where it
215/// gives one, else by page number while `total_pages` says there are more.
216/// None when that was the last.
217pub(crate) fn next_page(body: &Value, page: u32) -> Option<String> {
218 let info = &body["result_info"];
219 if body["result"].as_array().is_some_and(Vec::is_empty) {
220 return None;
221 }
222 if let Some(cursor) = info["cursor"].as_str().filter(|c| !c.is_empty()) {
223 let encoded: String = cursor
224 .bytes()
225 .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") })
226 .collect();
227 return Some(format!("cursor={encoded}"));
228 }
229 let total = info["total_pages"].as_u64().or_else(|| {
230 let (count, per) = (info["total_count"].as_u64()?, info["per_page"].as_u64().filter(|p| *p > 0)?);
231 Some(count.div_ceil(per))
232 })?;
233 (u64::from(page) < total).then(|| match info["per_page"].as_u64() {
234 Some(per) => format!("page={}&per_page={per}", page + 1),
235 None => format!("page={}", page + 1),
Merge branch 'worktree-agent-a6a121745e81f639f'236 })
237}
238
239/// Lines with the same key added together, in key order. Cloudflare can
240/// give one service several rows a day (regions, tiers); an upsert of
241/// each would keep only the last.
242pub(crate) fn aggregate(lines: Vec<CostLine>) -> Vec<CostLine> {
243 let mut out: Vec<CostLine> = Vec::new();
244 for line in lines {
245 match out
246 .iter_mut()
247 .find(|l| l.day == line.day && l.source == line.source && l.product == line.product && l.meter == line.meter)
248 {
249 Some(existing) => {
250 existing.quantity += line.quantity;
251 existing.cost_usd += line.cost_usd;
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)252 existing.billed_usd += line.billed_usd;
253 existing.billable_quantity += line.billable_quantity;
Merge branch 'worktree-agent-a6a121745e81f639f'254 }
255 None => out.push(line),
256 }
257 }
258 out.sort_by(|a, b| (&a.day, a.source, &a.product, &a.meter).cmp(&(&b.day, b.source, &b.product, &b.meter)));
259 out
260}
261
262/// Every line of a billable-usage answer, aggregated. Errors when
263/// Cloudflare says it failed.
264pub(crate) fn lines_from_billable(body: &Value) -> std::result::Result<Vec<CostLine>, String> {
265 if body["success"] == Value::Bool(false) {
266 return Err(format!("billable usage failed: {}", body["errors"]));
267 }
268 let rows = body["result"].as_array().cloned().unwrap_or_default();
269 Ok(aggregate(rows.iter().filter_map(line_from_focus).collect()))
270}
271
272/// The GraphQL query for Artifacts' events by type and day.
273pub(crate) const ARTIFACTS_QUERY: &str = "query ($account: String!, $since: Date!, $until: Date!) {
274 viewer { accounts(filter: { accountTag: $account }) {
275 artifactsEventsAdaptiveGroups(limit: 10000, filter: { date_geq: $since, date_leq: $until }) {
276 count
277 dimensions { date eventType repositoryName }
278 }
279 } }
280}";
281
282pub(crate) fn artifacts_variables(account: &str, since: &str, until: &str) -> Value {
283 json!({ "query": ARTIFACTS_QUERY, "variables": { "account": account, "since": since, "until": until } })
284}
285
286/// Artifacts' events as lines (`events_push`, `events_pull`, …), with no
287/// cost: billable usage carries the cost. Errors when GraphQL does.
288pub(crate) fn lines_from_artifacts(body: &Value) -> std::result::Result<Vec<CostLine>, String> {
289 if let Some(errors) = body["errors"].as_array().filter(|e| !e.is_empty()) {
290 return Err(format!("Artifacts events failed: {}", Value::Array(errors.clone())));
291 }
292 let groups = body["data"]["viewer"]["accounts"][0]["artifactsEventsAdaptiveGroups"]
293 .as_array()
294 .cloned()
295 .unwrap_or_default();
296 Ok(aggregate(
297 groups
298 .iter()
299 .filter_map(|g| {
300 let day = g["dimensions"]["date"].as_str()?;
301 let kind = g["dimensions"]["eventType"].as_str()?;
302 if day.len() < 10 {
303 return None;
304 }
305 Some(CostLine {
306 day: day[..10].to_owned(),
307 source: SOURCE_ARTIFACTS,
308 product: "artifacts".to_owned(),
309 meter: format!("events_{}", slug(kind)),
310 unit: "events".to_owned(),
311 quantity: g["count"].as_f64().unwrap_or(0.0),
312 cost_usd: 0.0,
313 raw_name: format!("Artifacts / {kind}"),
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)314 ..CostLine::default()
Merge branch 'worktree-agent-a6a121745e81f639f'315 })
316 })
317 .collect(),
318 ))
319}
320
Merge branch 'worktree-agent-a633ac0f7f66d419d'321/// Where a cost line came from: AI Gateway's analytics, what it priced
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.322/// g1t's own provider traffic at. No longer read since g1t stopped running
323/// agents; the lines already kept stay in the history.
Merge branch 'worktree-agent-a633ac0f7f66d419d'324pub(crate) const SOURCE_GATEWAY: &str = "ai_gateway";
325/// The meter prefix of requests Cloudflare billed itself (unified billing),
326/// which are on Cloudflare's bill as well as here.
327pub(crate) const GATEWAY_WHOLESALE: &str = "wholesale__";
328
Merge branch 'worktree-agent-a6a121745e81f639f'329/// The workspace a repository in the store belongs to: keys are
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)330/// `<workspace>--<repo>`; a pull request's working copy (`pulls--<id>`) is
331/// its repository's workspace's, from `owners` (repos' `pull_owners`), else
332/// no one's.
333pub(crate) fn workspace_of_store_key(key: &str, owners: &BTreeMap<String, String>) -> Option<String> {
Merge branch 'worktree-agent-a6a121745e81f639f'334 let (workspace, rest) = key.split_once("--")?;
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)335 if workspace.is_empty() || rest.is_empty() {
336 return None;
337 }
338 if workspace == "pulls" {
339 return owners.get(rest).map(|w| w.to_lowercase());
340 }
341 Some(workspace.to_lowercase())
Merge branch 'worktree-agent-a6a121745e81f639f'342}
343
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)344/// The pull request ids whose working copies Artifacts counted events for.
345pub(crate) fn pull_ids(body: &Value) -> Vec<String> {
346 let groups = body["data"]["viewer"]["accounts"][0]["artifactsEventsAdaptiveGroups"].as_array().cloned().unwrap_or_default();
347 let ids: BTreeSet<String> = groups
348 .iter()
349 .filter_map(|g| g["dimensions"]["repositoryName"].as_str()?.strip_prefix("pulls--").map(str::to_owned))
350 .filter(|id| !id.is_empty())
351 .collect();
352 ids.into_iter().collect()
353}
354
Merge branch 'worktree-agent-a6a121745e81f639f'355/// Artifacts' billable operations per workspace and day, by repository
356/// name: how Cloudflare's own count shares out. The meter is
357/// `cloudflare_git`, which shares out the git bucket's cost (`margin`).
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)358pub(crate) fn artifacts_by_workspace(body: &Value, owners: &BTreeMap<String, String>) -> Vec<(String, String, f64)> {
Merge branch 'worktree-agent-a6a121745e81f639f'359 let groups = body["data"]["viewer"]["accounts"][0]["artifactsEventsAdaptiveGroups"].as_array().cloned().unwrap_or_default();
360 let mut out: BTreeMap<(String, String), f64> = BTreeMap::new();
361 for g in &groups {
362 let (Some(day), Some(kind)) = (g["dimensions"]["date"].as_str(), g["dimensions"]["eventType"].as_str()) else { continue };
363 if day.len() < 10 || !ARTIFACTS_OPERATIONS.contains(&format!("events_{}", slug(kind)).as_str()) {
364 continue;
365 }
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)366 let Some(workspace) = g["dimensions"]["repositoryName"].as_str().and_then(|key| workspace_of_store_key(key, owners)) else { continue };
Merge branch 'worktree-agent-a6a121745e81f639f'367 *out.entry((day[..10].to_owned(), workspace)).or_default() += g["count"].as_f64().unwrap_or(0.0);
368 }
369 out.into_iter().map(|((day, workspace), count)| (day, workspace, count)).collect()
370}
371
372/// Artifacts' event types that are operations it bills: what its
373/// pricing names (create, push, pull, clone) and the metrics list.
374/// Errors such as `rateLimited` are not.
375pub(crate) const ARTIFACTS_OPERATIONS: [&str; 5] = ["events_create", "events_fork", "events_push", "events_pull", "events_delete"];
376
377/// The days to read: the 31 before today on the first run (`last` is
378/// None), else the last few, never further back than the backfill.
379pub(crate) fn window(last_fetched_day: Option<&str>, now_ms: u64) -> (String, String) {
380 let day = |ms: u64| g1t_contracts::time::rfc3339(ms)[..10].to_owned();
381 let today = day(now_ms);
382 let since = match last_fetched_day {
383 None => day(now_ms.saturating_sub(BACKFILL_DAYS * DAY_MS)),
384 Some(last) => {
385 let restate = day(now_ms.saturating_sub(RESTATE_DAYS * DAY_MS));
386 let floor = day(now_ms.saturating_sub(BACKFILL_DAYS * DAY_MS));
387 // A gap since the last run is read too, up to the backfill.
388 let from = if last < restate.as_str() { last.to_owned() } else { restate };
389 if from < floor { floor } else { from }
390 }
391 };
392 (since, today)
393}
394
395/// Every day from `since` to `until`, inclusive.
396pub(crate) fn days_between(since: &str, until: &str) -> Vec<String> {
397 let start = g1t_contracts::time::parse_rfc3339(&format!("{since}T00:00:00Z"));
398 let end = g1t_contracts::time::parse_rfc3339(&format!("{until}T00:00:00Z"));
399 match (start, end) {
400 (Some(start), Some(end)) if start <= end => (0..=((end - start) / DAY_MS))
401 .map(|n| g1t_contracts::time::rfc3339(start + n * DAY_MS)[..10].to_owned())
402 .collect(),
403 _ => Vec::new(),
404 }
405}
406
407/// A rule from `cost_map`: which Cloudflare lines feed which of g1t's
408/// products.
409#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
410pub(crate) struct Rule {
411 /// Cloudflare's product, as slugged here.
412 pub product: String,
413 /// A meter prefix, or `*` for any meter of the product.
414 pub meter: String,
415 /// g1t's product it is a cost of: `sandboxes`, `git`, `platform`, …
416 pub bucket: String,
417 /// The price book meter whose cost it measures, if any.
418 pub price_meter: Option<String>,
419 /// g1t's own count of the same units, to compare quantities.
420 pub own_meter: Option<String>,
421 /// How far g1t's count may be from Cloudflare's before it is drift.
422 pub drift_percent: f64,
423}
424
425/// The rule for a line: its product's rule with the longest matching
426/// meter prefix, `*` last. None means no one decided what pays for it:
427/// a leak until someone does.
428pub(crate) fn classify<'a>(rules: &'a [Rule], product: &str, meter: &str) -> Option<&'a Rule> {
429 rules
430 .iter()
431 .filter(|r| r.product == product && (r.meter == "*" || meter.starts_with(r.meter.as_str())))
432 .max_by_key(|r| if r.meter == "*" { 0 } else { r.meter.len() + 1 })
433}
434
435/// What a bucket is called in sudo.
436pub(crate) fn bucket_title(bucket: &str) -> String {
437 match bucket {
438 "sandboxes" => "Sandboxes and builds".into(),
439 "deployments" => "Deployments".into(),
440 "git" => "Git operations".into(),
441 "repo_storage" => "Repository storage".into(),
442 "actions_cache" => "Actions cache".into(),
443 "embeddings" => "Search embeddings".into(),
444 "security" => "Security scans".into(),
445 "domains" => "Custom domains".into(),
446 "models" => "Models".into(),
447 "platform" => "Running g1t (paid by the plan)".into(),
448 UNMAPPED => "Not mapped".into(),
449 other => other.replace('_', " "),
450 }
451}
452
453/// The bucket of a line no rule claims.
454pub(crate) const UNMAPPED: &str = "unmapped";
455
456/// A day's count per workspace from counts "from this day to the end of
457/// its month" (what the repos service's `git_operations` answers with a
458/// `since`): each day's is its own less the next day's, within a month.
459/// The last day (today, so far) is its own.
460pub(crate) fn daily_from_cumulative(days: &[String], cumulative: &[BTreeMap<String, u64>]) -> Vec<(String, String, u64)> {
461 let mut out = Vec::new();
462 for (index, day) in days.iter().enumerate() {
463 let Some(today) = cumulative.get(index) else { break };
464 let next = days
465 .get(index + 1)
466 .filter(|next| next[..7] == day[..7])
467 .and_then(|_| cumulative.get(index + 1));
468 for (workspace, count) in today {
469 let later = next.and_then(|n| n.get(workspace)).copied().unwrap_or(0);
470 let own = count.saturating_sub(later);
471 if own > 0 {
472 out.push((day.clone(), workspace.clone(), own));
473 }
474 }
475 }
476 out
477}
478
479/// One row of the repos service's `artifacts_usage`, read leniently while
480/// its shape settles: a day, a workspace, a raw meter and a count.
481pub(crate) fn raw_usage_row(row: &Value) -> Option<(String, String, String, f64)> {
482 let day = text(row, &["day", "date"]);
483 let workspace = text(row, &["namespace", "workspace"]);
484 let meter = text(row, &["meter", "kind", "event", "operation"]);
485 let count = number(row, &["count", "quantity", "operations", "value"])?;
486 (day.len() >= 10 && !workspace.is_empty() && !meter.is_empty())
487 .then(|| (day[..10].to_owned(), workspace.to_lowercase(), slug(meter), count))
488}
489
490/// The repos service's operation mapping, as `artifacts_usage` returns it
491/// beside the rows: for each raw meter (slugged), how many operations it
492/// is to Cloudflare (`cost_operations`) and to the customer
493/// (`billable_operations`). Repos owns this mapping
494/// (`set_operation_mapping`); billing only reads it.
495pub(crate) fn operation_mapping(body: &Value) -> BTreeMap<String, (f64, f64)> {
496 body["mapping"]
497 .as_array()
498 .map(|rows| {
499 rows.iter()
500 .filter_map(|r| {
501 let meter = r["meter"].as_str()?;
502 Some((slug(meter), (r["cost_operations"].as_f64().unwrap_or(0.0), r["billable_operations"].as_f64().unwrap_or(0.0))))
503 })
504 .collect()
505 })
506 .unwrap_or_default()
507}
508
509/// Raw counts weighted by one column of the mapping: what Cloudflare
510/// should count (`cost`), or what customers are charged for.
511pub(crate) fn weighted(raw: &[(String, f64)], mapping: &BTreeMap<String, (f64, f64)>, cost: bool) -> f64 {
512 raw.iter()
513 .map(|(meter, count)| count * mapping.get(meter).map_or(0.0, |(c, b)| if cost { *c } else { *b }))
514 .sum()
515}
516
517impl Billing {
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)518 /// Reads Cloudflare's bill into `cost_lines`: the billable usage over
519 /// whole billing cycles (`cycle::bill_since`), every page of it, priced
520 /// over each cycle (`cycle::price_lines`) and replacing what was kept
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.521 /// for those days; Artifacts' events for the days due (`window`).
522 /// Returns those days, how many lines, and the first day of the bill's
523 /// read. None without a token. What could not be read is added to
524 /// `problems`.
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises525 pub(crate) async fn read_cloudflare(
526 &self,
527 keeper: &Keeper,
528 problems: &mut Vec<String>,
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)529 ) -> Result<Option<(String, String, u32, String)>> {
Merge branch 'worktree-agent-a6a121745e81f639f'530 if !keeper.can_read_bill() {
531 return Ok(None);
532 }
533 #[derive(Deserialize)]
534 struct Last {
535 day: Option<String>,
536 }
537 let last = self
538 .db
539 .prepare("SELECT MAX(day) AS day FROM cost_lines WHERE source = ?")
540 .bind(&[SOURCE_BILLABLE.into()])?
541 .first::<Last>(None)
542 .await?
543 .and_then(|l| l.day);
544 let (since, until) = window(last.as_deref(), now_ms());
545 let fetched_at = rfc3339(now_ms());
546 let mut written = 0;
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)547 // The bill over whole cycles, every time: Cloudflare posts a day a
548 // day or two late and restates recent ones, and a cycle's included
549 // amounts can only be applied to all of its days.
550 let anchor = self.cycle_anchor().await?;
551 let bill_since = crate::cycle::bill_since(&until, anchor, RESTATE_DAYS as u32, last.is_none());
552 let read = keeper
553 .billable_usage_pages(&bill_since, &until)
554 .await
555 .map_err(|e| e.to_string())
556 .and_then(|(body, pages)| Ok((lines_from_billable(&body)?, read_stats(&body, pages))));
557 match read {
558 Ok((_, stats)) if stats.rows == 0 && last.is_some() => {
559 problems.push(format!("Cloudflare's billable usage answered no rows for {bill_since} to {until}; what was read before is kept."));
560 self.keep_read(&bill_since, &until, &stats, &fetched_at).await?;
561 }
562 Ok((mut lines, stats)) => {
563 crate::cycle::price_lines(&mut lines, anchor);
564 // The days read are replaced whole: a meter gone from a day
565 // must not keep its old line.
566 self.db
567 .prepare("DELETE FROM cost_lines WHERE source = ?1 AND day >= ?2 AND day <= ?3")
568 .bind(&[SOURCE_BILLABLE.into(), bill_since.as_str().into(), until.as_str().into()])?
569 .run()
570 .await?;
571 written += self.upsert_lines(&lines, &fetched_at).await?;
572 self.keep_read(&bill_since, &until, &stats, &fetched_at).await?;
573 }
Merge branch 'worktree-agent-a6a121745e81f639f'574 Err(error) => problems.push(format!("Cloudflare's billable usage could not be read: {error}")),
575 }
576 match keeper.graphql(artifacts_variables(keeper.account(), &since, &until)).await.map_err(|e| e.to_string()) {
577 Ok(body) => match lines_from_artifacts(&body) {
578 Ok(lines) => {
579 written += self.upsert_lines(&lines, &fetched_at).await?;
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)580 let owners = self.pull_owners(&pull_ids(&body)).await;
581 self.keep_cloudflare_counts(&since, &until, &artifacts_by_workspace(&body, &owners), &fetched_at).await?;
Merge branch 'worktree-agent-a6a121745e81f639f'582 }
583 Err(error) => problems.push(format!("Artifacts events could not be read: {error}")),
584 },
585 Err(error) => problems.push(format!("Artifacts events could not be read: {error}")),
Merge branch 'worktree-agent-a633ac0f7f66d419d'586 }
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)587 Ok(Some((since, until, written, bill_since)))
Merge branch 'worktree-agent-a6a121745e81f639f'588 }
589
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)590 /// What the last read of billable usage got back (`cost_reads`).
591 async fn keep_read(&self, since: &str, until: &str, stats: &ReadStats, read_at: &str) -> Result<()> {
592 self.db
593 .prepare(
594 "INSERT INTO cost_reads (source, read_at, since, until, rows, pages, consumed_rows, pricing_only_rows, costed_rows)
595 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
596 ON CONFLICT (source) DO UPDATE SET read_at = ?2, since = ?3, until = ?4, rows = ?5, pages = ?6,
597 consumed_rows = ?7, pricing_only_rows = ?8, costed_rows = ?9",
598 )
599 .bind(&[
600 SOURCE_BILLABLE.into(),
601 read_at.into(),
602 since.into(),
603 until.into(),
604 stats.rows.into(),
605 stats.pages.into(),
606 stats.consumed_rows.into(),
607 stats.pricing_only_rows.into(),
608 stats.costed_rows.into(),
609 ])?
610 .run()
611 .await?;
612 Ok(())
613 }
614
Merge branch 'worktree-agent-a6a121745e81f639f'615 /// Cloudflare's own per-workspace counts for the days, replacing what
616 /// was kept for them.
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)617 /// The workspace of each pull request's working copy, from repos;
618 /// nothing while repos does not answer (those events stay no one's).
619 async fn pull_owners(&self, pulls: &[String]) -> BTreeMap<String, String> {
620 let Some(repos) = &self.repos else { return BTreeMap::new() };
621 if pulls.is_empty() {
622 return BTreeMap::new();
623 }
624 match g1t_kit::call::<_, Value>(repos, "pull_owners", &json!({ "pulls": pulls })).await {
625 Ok(body) => body["owners"]
626 .as_object()
627 .map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
628 .unwrap_or_default(),
629 Err(error) => {
630 worker::console_error!("pull_owners: {error}");
631 BTreeMap::new()
632 }
633 }
634 }
635
Merge branch 'worktree-agent-a6a121745e81f639f'636 async fn keep_cloudflare_counts(&self, since: &str, until: &str, counts: &[(String, String, f64)], fetched_at: &str) -> Result<()> {
637 self.db
638 .prepare("DELETE FROM own_counts WHERE meter = 'cloudflare_git' AND day >= ?1 AND day <= ?2")
639 .bind(&[since.into(), until.into()])?
640 .run()
641 .await?;
642 for chunk in counts.chunks(50) {
643 let mut statements = Vec::with_capacity(chunk.len());
644 for (day, workspace, count) in chunk {
645 statements.push(
646 self.db
647 .prepare("INSERT OR REPLACE INTO own_counts (day, meter, workspace, quantity, fetched_at) VALUES (?, 'cloudflare_git', ?, ?, ?)")
648 .bind(&[day.as_str().into(), workspace.as_str().into(), (*count).into(), fetched_at.into()])?,
649 );
650 }
651 self.db.batch(statements).await?;
652 }
653 Ok(())
654 }
655
656 /// Upserts lines, a day's line replacing what was read for it before.
657 async fn upsert_lines(&self, lines: &[CostLine], fetched_at: &str) -> Result<u32> {
658 for chunk in lines.chunks(50) {
659 let mut statements = Vec::with_capacity(chunk.len());
660 for line in chunk {
661 statements.push(
662 self.db
663 .prepare(
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)664 "INSERT INTO cost_lines (day, source, product, meter, unit, quantity, cost_usd, raw_name, fetched_at, billed_usd, billable_quantity, basis)
665 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
Merge branch 'worktree-agent-a6a121745e81f639f'666 ON CONFLICT (day, source, product, meter) DO UPDATE SET
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)667 unit = ?5, quantity = ?6, cost_usd = ?7, raw_name = ?8, fetched_at = ?9,
668 billed_usd = ?10, billable_quantity = ?11, basis = ?12",
Merge branch 'worktree-agent-a6a121745e81f639f'669 )
670 .bind(&[
671 line.day.as_str().into(),
672 line.source.into(),
673 line.product.as_str().into(),
674 line.meter.as_str().into(),
675 line.unit.as_str().into(),
676 line.quantity.into(),
677 line.cost_usd.into(),
678 line.raw_name.as_str().into(),
679 fetched_at.into(),
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)680 line.billed_usd.into(),
681 line.billable_quantity.into(),
682 line.basis.into(),
Merge branch 'worktree-agent-a6a121745e81f639f'683 ])?,
684 );
685 }
686 self.db.batch(statements).await?;
687 }
688 Ok(lines.len() as u32)
689 }
690
691 /// The mapping from Cloudflare's meters to g1t's products.
692 pub(crate) async fn rules(&self) -> Result<Vec<Rule>> {
693 #[derive(Deserialize)]
694 struct Row {
695 product: String,
696 meter: String,
697 bucket: String,
698 price_meter: Option<String>,
699 own_meter: Option<String>,
700 drift_percent: f64,
701 }
702 Ok(self
703 .db
704 .prepare("SELECT product, meter, bucket, price_meter, own_meter, drift_percent FROM cost_map")
705 .all()
706 .await?
707 .results::<Row>()?
708 .into_iter()
709 .map(|r| Rule {
710 product: r.product,
711 meter: r.meter,
712 bucket: r.bucket,
713 price_meter: r.price_meter,
714 own_meter: r.own_meter,
715 drift_percent: r.drift_percent,
716 })
717 .collect())
718 }
719
720 /// g1t's own counts for the days, all from the repos service, which
721 /// owns the mapping from raw meters to operations (`operation_mapping`):
722 ///
723 /// - `git_operations`: what customers are charged for, as repos counts
724 /// it (`git_operations`, already through its mapping).
725 /// - `cost_operations`: what g1t expects Cloudflare to bill, from the
726 /// raw meters (`artifacts_usage`) and the mapping's cost column.
727 /// - `artifacts_<meter>`: each raw meter.
728 pub(crate) async fn count_own(&self, since: &str, until: &str) -> Result<()> {
729 let Some(repos) = &self.repos else { return Ok(()) };
730 let days = days_between(since, until);
731 let fetched_at = rfc3339(now_ms());
732 let mut rows: Vec<(String, String, String, f64)> = Vec::new();
733
734 // Raw meters and repos' mapping; skipped while repos does not answer.
735 if let Ok(body) = g1t_kit::call::<_, Value>(repos, "artifacts_usage", &json!({ "from": since, "to": until })).await {
736 let mapping = operation_mapping(&body);
737 let mut by: BTreeMap<(String, String), Vec<(String, f64)>> = BTreeMap::new();
738 for (day, workspace, meter, count) in body["rows"].as_array().map(|l| l.iter().filter_map(raw_usage_row).collect::<Vec<_>>()).unwrap_or_default() {
739 by.entry((day, workspace)).or_default().push((meter, count));
740 }
741 for ((day, workspace), counts) in by {
742 // Several stores (namespaces) can give the same meter.
743 let mut merged: BTreeMap<String, f64> = BTreeMap::new();
744 for (meter, count) in &counts {
745 *merged.entry(meter.clone()).or_default() += count;
746 }
747 for (meter, count) in &merged {
748 rows.push((day.clone(), format!("artifacts_{meter}"), workspace.clone(), *count));
749 }
750 if !mapping.is_empty() {
751 rows.push((day, "cost_operations".to_owned(), workspace, weighted(&counts, &mapping, true)));
752 }
753 }
754 }
755 // What customers are charged for, as repos counts it through its mapping.
756 let mut cumulative = Vec::with_capacity(days.len());
757 for day in &days {
758 let list: Vec<WorkspaceGitOperations> = g1t_kit::call(
759 repos,
760 "git_operations",
761 &GitOperationsArgs { month: day[..7].to_owned(), since: Some(format!("{day}T00")), namespace: None },
762 )
763 .await?;
764 cumulative.push(list.into_iter().map(|w| (w.namespace.to_lowercase(), w.operations)).collect::<BTreeMap<_, _>>());
765 }
766 for (day, workspace, count) in daily_from_cumulative(&days, &cumulative) {
767 rows.push((day, "git_operations".to_owned(), workspace, count as f64));
768 }
769
770 // Each day's counts replace what was there.
771 self.db
772 .prepare("DELETE FROM own_counts WHERE day >= ?1 AND day <= ?2 AND meter NOT LIKE 'cloudflare_%'")
773 .bind(&[since.into(), until.into()])?
774 .run()
775 .await?;
776 for chunk in rows.chunks(50) {
777 let mut statements = Vec::with_capacity(chunk.len());
778 for (day, meter, workspace, quantity) in chunk {
779 statements.push(
780 self.db
781 .prepare(
782 "INSERT INTO own_counts (day, meter, workspace, quantity, fetched_at) VALUES (?1, ?2, ?3, ?4, ?5)
783 ON CONFLICT (day, meter, workspace) DO UPDATE SET quantity = ?4, fetched_at = ?5",
784 )
785 .bind(&[day.as_str().into(), meter.as_str().into(), workspace.as_str().into(), (*quantity).into(), fetched_at.as_str().into()])?,
786 );
787 }
788 self.db.batch(statements).await?;
789 }
790 Ok(())
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
Merge branch 'worktree-agent-a633ac0f7f66d419d'797
Merge branch 'worktree-agent-a6a121745e81f639f'798 /// Billable usage as Cloudflare answered g1t on 2026-10-06 (two days,
799 /// trimmed), plus rows past the included amounts, which cost money.
800 fn billable_fixture() -> Value {
801 json!({
802 "success": true,
803 "errors": [],
804 "result": [
805 {
806 "ChargePeriodStart": "2026-10-03T00:00:00Z",
807 "ChargePeriodEnd": "2026-10-04T00:00:00Z",
808 "ServiceFamilyName": "Containers",
809 "ServiceName": "Container Memory, per GiB-Second (First 25 GiB-hours included)",
810 "PricingUnit": "Count",
811 "PricingQuantity": "14000",
812 "ContractedCost": 0, "BilledCost": 0, "ListCost": 0
813 },
814 {
815 "ChargePeriodStart": "2026-10-03T00:00:00Z",
816 "ServiceFamilyName": "D1",
817 "ServiceName": "D1 - Rows Read (first 25 billion included)",
818 "PricingUnit": "Count",
819 "PricingQuantity": 1200000,
820 "BilledCost": 0
821 },
822 {
823 "ChargePeriodStart": "2026-10-15T00:00:00Z",
824 "ServiceFamilyName": "Artifacts",
825 "ServiceName": "Artifacts Operations (First 10,000 included)",
826 "PricingUnit": "Count",
827 "PricingQuantity": "30000",
828 "BilledCost": "3.00",
829 "ListCost": 4.5
830 },
831 {
832 "ChargePeriodStart": "2026-10-15T00:00:00Z",
833 "ServiceFamilyName": "Artifacts",
834 "ServiceName": "Artifacts Operations (First 10,000 included)",
835 "PricingUnit": "Count",
836 "PricingQuantity": "10000",
837 "BilledCost": "1.50"
838 },
839 {
840 "ChargePeriodStart": "2026-10-15T00:00:00Z",
841 "ServiceFamilyName": "Workers",
842 "ServiceName": "Workers for Platforms Requests (First 20M are included)",
843 "PricingUnit": "Count",
844 "PricingQuantity": 25000000,
845 "ListCost": 1.5
846 },
847 { "ServiceName": "No day, no line" }
848 ]
849 })
850 }
851
852 #[test]
853 fn names_become_products_and_meters() {
854 assert_eq!(slug("Workers for Platforms CPU ms (First 60M ms are included)"), "workers_for_platforms_cpu_ms");
855 assert_eq!(product_and_meter("D1", "D1 - Rows Read (first 25 billion included)"), ("d1".into(), "d1_rows_read".into()));
856 assert_eq!(product_and_meter("Workers KV", "KV Read Operations (First 10M is included)"), ("workers_kv".into(), "kv_read_operations".into()));
857 assert_eq!(product_and_meter("", "Containers / Container vCPU"), ("containers".into(), "container_vcpu".into()));
858 assert_eq!(product_and_meter("Email", ""), ("email".into(), "usage".into()));
859 }
860
861 #[test]
862 fn billable_usage_is_parsed_and_rows_of_one_meter_are_added() {
863 let lines = lines_from_billable(&billable_fixture()).unwrap();
864 assert_eq!(lines.len(), 4, "{lines:#?}");
865 let artifacts = lines.iter().find(|l| l.product == "artifacts").unwrap();
866 // Two rows of the same day and meter: one line, added up.
867 assert_eq!(artifacts.meter, "artifacts_operations");
868 assert_eq!(artifacts.quantity, 40_000.0);
869 assert!((artifacts.cost_usd - 4.5).abs() < 1e-9, "billed, not list: {}", artifacts.cost_usd);
870 let wfp = lines.iter().find(|l| l.meter.starts_with("workers_for_platforms")).unwrap();
871 assert_eq!(wfp.product, "workers");
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)872 // A list cost is before the included amounts: never what g1t pays.
873 // The cycle prices it (`cycle::price_lines`).
874 assert_eq!((wfp.cost_usd, wfp.billed_usd), (0.0, 0.0));
Merge branch 'worktree-agent-a6a121745e81f639f'875 let memory = lines.iter().find(|l| l.product == "containers").unwrap();
876 assert_eq!((memory.day.as_str(), memory.quantity, memory.cost_usd), ("2026-10-03", 14_000.0, 0.0));
877 assert!(lines_from_billable(&json!({ "success": false, "errors": [{ "code": 10000 }] })).is_err());
878 }
879
880 #[test]
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)881 fn the_quantity_is_what_was_consumed_not_the_pricing_blocks() {
882 // FOCUS: ConsumedQuantity in the meter's units; PricingQuantity in
883 // pricing units, which can be blocks of a million.
884 let row = json!({
885 "ChargePeriodStart": "2026-10-07T00:00:00Z",
886 "ServiceFamilyName": "Workers",
887 "ServiceName": "Workers CPU ms (first 30M are included)",
888 "ConsumedQuantity": 11160000, "ConsumedUnit": "ms",
889 "PricingQuantity": 11.16, "PricingUnit": "1M ms",
890 "BilledCost": 0, "ListCost": 0.2232
891 });
892 let line = line_from_focus(&row).unwrap();
893 assert_eq!((line.quantity, line.unit.as_str(), line.cost_usd), (11_160_000.0, "ms", 0.0));
894 let body = json!({ "result": [row, { "ServiceName": "x", "ChargePeriodStart": "2026-10-07", "PricingQuantity": 3, "BilledCost": 0.5 }] });
895 assert_eq!(
896 read_stats(&body, 2),
897 ReadStats { rows: 2, pages: 2, consumed_rows: 1, pricing_only_rows: 1, costed_rows: 1 }
898 );
899 }
900
901 #[test]
902 fn every_page_of_an_answer_is_read() {
903 let page = |info: Value| json!({ "success": true, "result": [{ "ServiceName": "x" }], "result_info": info });
904 assert_eq!(next_page(&page(json!({ "page": 1, "per_page": 50, "total_pages": 3 })), 1).as_deref(), Some("page=2&per_page=50"));
905 assert_eq!(next_page(&page(json!({ "page": 3, "per_page": 50, "total_pages": 3 })), 3), None);
906 // From the count when the pages are not given.
907 assert_eq!(next_page(&page(json!({ "per_page": 100, "total_count": 230 })), 2).as_deref(), Some("page=3&per_page=100"));
908 assert_eq!(next_page(&page(json!({ "cursor": "a+b/c=" })), 1).as_deref(), Some("cursor=a%2Bb%2Fc%3D"));
909 assert_eq!(next_page(&page(json!({ "cursor": "" })), 1), None);
910 // No paging said, or nothing on the page: that was all.
911 assert_eq!(next_page(&json!({ "result": [{}] }), 1), None);
912 assert_eq!(next_page(&json!({ "result": [], "result_info": { "total_pages": 9 } }), 1), None);
913 }
914
915 #[test]
Merge branch 'worktree-agent-a6a121745e81f639f'916 fn reading_the_same_days_twice_gives_the_same_lines() {
917 // Idempotent: the same answer aggregates to the same keys and
918 // amounts, so the upsert replaces rather than adds.
919 let once = lines_from_billable(&billable_fixture()).unwrap();
920 let twice = lines_from_billable(&billable_fixture()).unwrap();
921 assert_eq!(once, twice);
922 let again = aggregate(once.clone());
923 assert_eq!(again, once);
924 }
925
926 #[test]
927 fn artifacts_events_are_counted_by_type_and_day() {
928 let body = json!({
929 "data": { "viewer": { "accounts": [{ "artifactsEventsAdaptiveGroups": [
930 { "count": 120, "dimensions": { "date": "2026-10-05", "eventType": "pull" } },
931 { "count": 30, "dimensions": { "date": "2026-10-05", "eventType": "push" } },
932 { "count": 2, "dimensions": { "date": "2026-10-05", "eventType": "rateLimited" } },
933 { "count": 5, "dimensions": { "date": "2026-10-06", "eventType": "pull" } }
934 ] }] } },
935 "errors": null
936 });
937 let lines = lines_from_artifacts(&body).unwrap();
938 assert_eq!(lines.len(), 4);
939 assert_eq!(lines[0].meter, "events_pull");
940 assert_eq!(lines[0].quantity, 120.0);
941 assert!(lines.iter().any(|l| l.meter == "events_ratelimited"));
942 // By workspace, from the repository's store key; forks say none.
943 let by_repo = json!({ "data": { "viewer": { "accounts": [{ "artifactsEventsAdaptiveGroups": [
944 { "count": 100, "dimensions": { "date": "2026-10-05", "eventType": "pull", "repositoryName": "acme--api" } },
945 { "count": 20, "dimensions": { "date": "2026-10-05", "eventType": "push", "repositoryName": "acme--web" } },
946 { "count": 7, "dimensions": { "date": "2026-10-05", "eventType": "fork", "repositoryName": "pulls--123" } },
947 { "count": 3, "dimensions": { "date": "2026-10-05", "eventType": "serverError", "repositoryName": "beta--x" } }
948 ] }] } } });
Costs: Cloudflare's count for a pull request's working copy is shared out to its repository's workspace (repos pull_owners)949 assert_eq!(artifacts_by_workspace(&by_repo, &BTreeMap::new()), vec![("2026-10-05".to_string(), "acme".to_string(), 120.0)]);
950 let none = BTreeMap::new();
951 assert_eq!(workspace_of_store_key("Acme--api", &none), Some("acme".into()));
952 assert_eq!(workspace_of_store_key("pulls--9", &none), None);
953 assert_eq!(workspace_of_store_key("plain", &none), None);
954 let owners = BTreeMap::from([("9".to_string(), "Acme".to_string())]);
955 assert_eq!(workspace_of_store_key("pulls--9", &owners), Some("acme".into()));
956 assert_eq!(workspace_of_store_key("pulls--10", &owners), None);
Merge branch 'worktree-agent-a6a121745e81f639f'957 let operations: f64 = lines
958 .iter()
959 .filter(|l| l.day == "2026-10-05" && ARTIFACTS_OPERATIONS.contains(&l.meter.as_str()))
960 .map(|l| l.quantity)
961 .sum();
962 assert_eq!(operations, 150.0);
963 let failed = json!({ "data": null, "errors": [{ "message": "unknown field" }] });
964 assert!(lines_from_artifacts(&failed).is_err());
965 }
966
967 #[test]
968 fn the_first_run_backfills_31_days_and_later_runs_restate_a_few() {
969 let now = g1t_contracts::time::parse_rfc3339("2026-10-06T04:17:00Z").unwrap();
970 assert_eq!(window(None, now), ("2026-09-05".into(), "2026-10-06".into()));
971 assert_eq!(window(Some("2026-10-06"), now), ("2026-10-02".into(), "2026-10-06".into()));
972 // A week without a run: from the last day read.
973 assert_eq!(window(Some("2026-09-28"), now), ("2026-09-28".into(), "2026-10-06".into()));
974 // Never past the backfill.
975 assert_eq!(window(Some("2026-01-01"), now), ("2026-09-05".into(), "2026-10-06".into()));
976 assert_eq!(days_between("2026-09-29", "2026-10-02"), vec!["2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02"]);
977 assert!(days_between("2026-10-02", "2026-10-01").is_empty());
978 }
979
980 #[test]
981 fn a_days_git_operations_are_its_count_less_the_next_days() {
982 let days: Vec<String> = ["2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02"].iter().map(|d| d.to_string()).collect();
983 let map = |pairs: &[(&str, u64)]| pairs.iter().map(|(w, n)| (w.to_string(), *n)).collect::<BTreeMap<_, _>>();
984 // From each day to the end of its month.
985 let cumulative = vec![map(&[("acme", 30), ("beta", 4)]), map(&[("acme", 10)]), map(&[("acme", 7)]), map(&[("acme", 2)])];
986 let daily = daily_from_cumulative(&days, &cumulative);
987 assert_eq!(
988 daily,
989 vec![
990 ("2026-09-29".into(), "acme".into(), 20),
991 ("2026-09-29".into(), "beta".into(), 4),
992 // The month's last day is its own.
993 ("2026-09-30".into(), "acme".into(), 10),
994 ("2026-10-01".into(), "acme".into(), 5),
995 // Today so far.
996 ("2026-10-02".into(), "acme".into(), 2),
997 ]
998 );
999 }
1000
1001 #[test]
1002 fn raw_meters_are_weighted_by_the_repos_mapping() {
1003 // As repos' artifacts_usage answers: rows, and its operation_mapping.
1004 let body = json!({
1005 "rows": [
1006 { "day": "2026-10-15", "store": "g1t", "workspace": "Acme", "meter": "git.fetch", "count": 12, "bytes_in": 0, "bytes_out": 0 },
1007 { "day": "2026-10-15", "store": "g1t", "workspace": "acme", "meter": "git.receive_pack", "count": 3, "bytes_in": 0, "bytes_out": 0 },
1008 { "day": "2026-10-15", "store": "g1t", "workspace": "acme", "meter": "binding.read_blob", "count": 400, "bytes_in": 0, "bytes_out": 0 }
1009 ],
1010 "mapping": [
1011 { "meter": "git.fetch", "cost_operations": 1, "billable_operations": 1 },
1012 { "meter": "git.receive_pack", "cost_operations": 1, "billable_operations": 1 },
1013 { "meter": "binding.read_blob", "cost_operations": 0, "billable_operations": 0 }
1014 ],
1015 "truncated": false
1016 });
1017 let row = raw_usage_row(&body["rows"][0]).unwrap();
1018 assert_eq!(row, ("2026-10-15".into(), "acme".into(), "git_fetch".into(), 12.0));
1019 assert!(raw_usage_row(&json!({ "day": "2026-10-15", "count": 1 })).is_none());
1020 let mut mapping = operation_mapping(&body);
1021 let raw: Vec<(String, f64)> = body["rows"].as_array().unwrap().iter().filter_map(raw_usage_row).map(|r| (r.2, r.3)).collect();
1022 assert_eq!(weighted(&raw, &mapping, true), 15.0);
1023 assert_eq!(weighted(&raw, &mapping, false), 15.0);
1024 // Cloudflare turns out to bill binding reads: repos changes one row
1025 // (set_operation_mapping), and the bill g1t expects follows.
1026 mapping.insert("binding_read_blob".into(), (1.0, 0.0));
1027 assert_eq!(weighted(&raw, &mapping, true), 415.0);
1028 assert_eq!(weighted(&raw, &mapping, false), 15.0);
1029 assert!(operation_mapping(&json!({})).is_empty());
1030 }
1031
1032 #[test]
1033 fn the_most_specific_rule_claims_a_line() {
1034 let rule = |product: &str, meter: &str, bucket: &str| Rule {
1035 product: product.into(),
1036 meter: meter.into(),
1037 bucket: bucket.into(),
1038 price_meter: None,
1039 own_meter: None,
1040 drift_percent: 10.0,
1041 };
1042 let rules = vec![rule("workers", "*", "platform"), rule("workers", "workers_for_platforms", "deployments"), rule("artifacts", "*", "git")];
1043 assert_eq!(classify(&rules, "workers", "workers_for_platforms_cpu_ms").unwrap().bucket, "deployments");
1044 assert_eq!(classify(&rules, "workers", "workers_cpu_ms").unwrap().bucket, "platform");
1045 assert_eq!(classify(&rules, "artifacts", "artifacts_operations").unwrap().bucket, "git");
1046 assert!(classify(&rules, "browser_rendering", "browser_hours").is_none());
1047 }
1048}

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