Every sandbox is metered by the second
Agents, reviews, checks, the merge queue and workflow jobs each report how long their sandbox ran when it stops. Billing records what it cost g1t and charges the seconds past 500 free minutes a month, at $0.003 a minute (nothing while g1t is free). Deploy builds stay on their plan.
7 files+238−40/7 viewed
| 69 | 69 | | A review by a g1t agent | Yes | | |
| 70 | 70 | | Catching up with `main` | Yes, when it needed an agent | | |
| 71 | 71 | | Planning an [outcome](/guides/outcomes/) | Yes | | |
| 72 | − | | Acceptance checks | No | | |
| 73 | − | | The [merge queue](/guides/merge-queue/) | No | | |
| 72 | + | | Acceptance checks | [Sandbox time](#sandbox-time) | | |
| 73 | + | | The [merge queue](/guides/merge-queue/) | [Sandbox time](#sandbox-time) | | |
| 74 | + | | [Workflow](/guides/actions/) jobs | [Sandbox time](#sandbox-time) | | |
| 74 | 75 | | [Deployments](/guides/deployments/) | The plan, and builds and usage past it. Never free. | | |
| 75 | 76 | | Repositories, git, issues, pull requests, the API and MCP | No | | |
| 76 | 77 | ||
| 86 | 87 | assigned the issue. That is why only members of a workspace can put g1t | |
| 87 | 88 | agents to work on its repositories. | |
| 88 | 89 | ||
| 90 | + | ## Sandbox time | |
| 91 | + | ||
| 92 | + | Every sandbox g1t starts for a workspace runs on Cloudflare Containers, and | |
| 93 | + | Cloudflare charges g1t for every second of it. So each one is metered by | |
| 94 | + | the second, from start to stop, whatever it was for: agents, reviews, | |
| 95 | + | revisions, catch-ups, planning, acceptance checks, the merge queue and | |
| 96 | + | workflow jobs. It is charged to the workspace that owns the repository. | |
| 97 | + | ||
| 98 | + | | | | | |
| 99 | + | | --- | --- | | |
| 100 | + | | Free each month | 500 minutes (calendar month, UTC) | | |
| 101 | + | | Past that | $0.003 a minute, by the second | | |
| 102 | + | | What it costs g1t | about $0.0013 a minute (Containers, standard-1) | | |
| 103 | + | ||
| 104 | + | Deploy builds are not counted here: [Deployments](/guides/deployments/) | |
| 105 | + | charges them by the second on its own plan. | |
| 106 | + | ||
| 107 | + | Each sandbox is one line on the [statement](#the-statement), such as | |
| 108 | + | *Checks on acme/api#12: 3m 12s of sandbox time*, with whether it fell | |
| 109 | + | within the free minutes. While g1t is being built out it is recorded but | |
| 110 | + | not charged. | |
| 111 | + | ||
| 89 | 112 | ## Add credit | |
| 90 | 113 | ||
| 91 | 114 | Only an owner of the workspace can add credit. |
| 307 | 307 | pub const MICROS_PER_BUILD_SECOND: i64 = 21; | |
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | + | /// Sandbox time: every sandbox g1t starts for a workspace (agents, | |
| 311 | + | /// reviews, checks, the merge queue, workflow jobs) is metered by the | |
| 312 | + | /// second. Deploy builds are charged by the Deployments plan instead. | |
| 313 | + | pub mod sandbox_allowance { | |
| 314 | + | /// Free each calendar month (UTC): 500 minutes. | |
| 315 | + | pub const FREE_SECONDS: i64 = 30_000; | |
| 316 | + | /// What one second costs g1t (Cloudflare Containers, standard-1), | |
| 317 | + | /// rounded up. Recorded with every entry. | |
| 318 | + | pub const COST_MICROS_PER_SECOND: i64 = super::deployments_allowance::MICROS_PER_BUILD_SECOND; | |
| 319 | + | /// What one second past the free minutes is charged: $0.003 a minute. | |
| 320 | + | pub const MICROS_PER_SECOND: i64 = 50; | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// `record_sandbox`: how long one sandbox ran for a workspace, reported by | |
| 324 | + | /// the runner when it stops. Recorded once per `reference`, with what it | |
| 325 | + | /// cost g1t; seconds past the month's free minutes are charged at | |
| 326 | + | /// `sandbox_allowance::MICROS_PER_SECOND`, unless `FREE_WHILE_BUILDING`. | |
| 327 | + | /// Returns `Outcome<bool>`: false if that reference was recorded before. | |
| 328 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 329 | + | #[serde(rename_all = "camelCase")] | |
| 330 | + | pub struct RecordSandboxArgs { | |
| 331 | + | pub workspace: String, | |
| 332 | + | pub seconds: u32, | |
| 333 | + | /// What ran, e.g. `Checks on acme/api#12`. | |
| 334 | + | pub description: String, | |
| 335 | + | /// `namespace/name`. | |
| 336 | + | pub repo: Option<String>, | |
| 337 | + | /// Unique to the run. | |
| 338 | + | pub reference: String, | |
| 339 | + | } | |
| 340 | + | ||
| 310 | 341 | /// What a feature's plan costs and includes. | |
| 311 | 342 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 312 | 343 | #[serde(rename_all = "camelCase")] |
| 184 | 184 | repo?: string | null; | |
| 185 | 185 | reference: string; | |
| 186 | 186 | }): Promise<Result<boolean>>; | |
| 187 | + | /** | |
| 188 | + | * How long a sandbox ran for a workspace, reported when it stops. Its | |
| 189 | + | * cost is always recorded; seconds past the month's free minutes are | |
| 190 | + | * charged. False if `reference` was recorded before. | |
| 191 | + | */ | |
| 192 | + | recordSandbox(usage: { | |
| 193 | + | workspace: string; | |
| 194 | + | seconds: number; | |
| 195 | + | description: string; | |
| 196 | + | repo?: string | null; | |
| 197 | + | reference: string; | |
| 198 | + | }): Promise<Result<boolean>>; | |
| 187 | 199 | startRun(run: { | |
| 188 | 200 | workspace: string; | |
| 189 | 201 | repo: RepoPath; |
| 199 | 199 | call("cancel_subscription", { actor, workspace, feature, resume }), | |
| 200 | 200 | hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }), | |
| 201 | 201 | chargeFeature: (charge) => call("charge_feature", charge), | |
| 202 | + | recordSandbox: (usage) => call("record_sandbox", usage), | |
| 202 | 203 | }; | |
| 203 | 204 | } | |
| 204 | 205 |
| 1 | + | -- Sandbox time each workspace used, by calendar month (UTC), so that the | |
| 2 | + | -- free minutes are counted before any second is charged. | |
| 3 | + | CREATE TABLE sandbox_months ( | |
| 4 | + | workspace TEXT NOT NULL, | |
| 5 | + | -- YYYY-MM. | |
| 6 | + | month TEXT NOT NULL, | |
| 7 | + | seconds INTEGER NOT NULL DEFAULT 0, | |
| 8 | + | PRIMARY KEY (workspace, month) | |
| 9 | + | ); | |
| 10 | + | ||
| 11 | + | CREATE INDEX IF NOT EXISTS ledger_by_reference ON ledger (reference); |
| 673 | 673 | } | |
| 674 | 674 | } | |
| 675 | 675 | ||
| 676 | + | impl Billing { | |
| 677 | + | /// Records how long a sandbox ran: its cost always, and a charge for | |
| 678 | + | /// the seconds past the month's free minutes. | |
| 679 | + | async fn record_sandbox(&self, a: RecordSandboxArgs) -> Result<Outcome<bool>> { | |
| 680 | + | if self.stripe.is_none() || a.seconds == 0 { | |
| 681 | + | return Ok(Outcome::Ok(false)); | |
| 682 | + | } | |
| 683 | + | let workspace = a.workspace.to_lowercase(); | |
| 684 | + | let seen = self | |
| 685 | + | .db | |
| 686 | + | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 687 | + | .bind(&[a.reference.as_str().into()])? | |
| 688 | + | .first::<Touched>(None) | |
| 689 | + | .await?; | |
| 690 | + | if seen.is_some() { | |
| 691 | + | return Ok(Outcome::Ok(false)); | |
| 692 | + | } | |
| 693 | + | let now = now_ms(); | |
| 694 | + | let timestamp = rfc3339(now); | |
| 695 | + | let month = ×tamp[..7]; | |
| 696 | + | #[derive(Deserialize)] | |
| 697 | + | struct Used { | |
| 698 | + | seconds: i64, | |
| 699 | + | } | |
| 700 | + | let seconds = i64::from(a.seconds); | |
| 701 | + | let after = self | |
| 702 | + | .db | |
| 703 | + | .prepare( | |
| 704 | + | "INSERT INTO sandbox_months (workspace, month, seconds) VALUES (?1, ?2, ?3) | |
| 705 | + | ON CONFLICT (workspace, month) DO UPDATE SET seconds = seconds + ?3 | |
| 706 | + | RETURNING seconds", | |
| 707 | + | ) | |
| 708 | + | .bind(&[workspace.as_str().into(), month.into(), (seconds as f64).into()])? | |
| 709 | + | .first::<Used>(None) | |
| 710 | + | .await? | |
| 711 | + | .map_or(seconds, |used| used.seconds); | |
| 712 | + | let billable = sandbox_billable(after - seconds, seconds); | |
| 713 | + | let charge = if self.free { 0 } else { billable * sandbox_allowance::MICROS_PER_SECOND }; | |
| 714 | + | let mut description = format!("{}: {} of sandbox time", a.description, duration(seconds)); | |
| 715 | + | if billable < seconds { | |
| 716 | + | description.push_str(if billable == 0 { | |
| 717 | + | ", within the month's free minutes" | |
| 718 | + | } else { | |
| 719 | + | ", partly within the month's free minutes" | |
| 720 | + | }); | |
| 721 | + | } | |
| 722 | + | if self.free && billable > 0 { | |
| 723 | + | description.push_str(" (free while g1t is being built out)"); | |
| 724 | + | } | |
| 725 | + | self.db | |
| 726 | + | .batch(vec![ | |
| 727 | + | self.db | |
| 728 | + | .prepare( | |
| 729 | + | "INSERT INTO ledger | |
| 730 | + | (id, workspace, kind, amount_micros, description, repo, task, | |
| 731 | + | cost_micros, reference, created_at, billed_to) | |
| 732 | + | VALUES (?, ?, 'usage', ?, ?, ?, 'sandbox', ?, ?, ?, 'g1t')", | |
| 733 | + | ) | |
| 734 | + | .bind(&[ | |
| 735 | + | new_id("led", now).into(), | |
| 736 | + | workspace.as_str().into(), | |
| 737 | + | (-(charge as f64)).into(), | |
| 738 | + | description.as_str().into(), | |
| 739 | + | optional(a.repo.as_deref()), | |
| 740 | + | ((seconds * sandbox_allowance::COST_MICROS_PER_SECOND) as f64).into(), | |
| 741 | + | a.reference.as_str().into(), | |
| 742 | + | timestamp.as_str().into(), | |
| 743 | + | ])?, | |
| 744 | + | self.db | |
| 745 | + | .prepare( | |
| 746 | + | "INSERT INTO accounts (workspace, balance_micros, created_at) | |
| 747 | + | VALUES (?1, ?2, ?3) | |
| 748 | + | ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2", | |
| 749 | + | ) | |
| 750 | + | .bind(&[ | |
| 751 | + | workspace.as_str().into(), | |
| 752 | + | (-(charge as f64)).into(), | |
| 753 | + | timestamp.as_str().into(), | |
| 754 | + | ])?, | |
| 755 | + | ]) | |
| 756 | + | .await?; | |
| 757 | + | Ok(Outcome::Ok(true)) | |
| 758 | + | } | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | /// Of `seconds` used after `before` this month, how many are past the | |
| 762 | + | /// free minutes. | |
| 763 | + | fn sandbox_billable(before: i64, seconds: i64) -> i64 { | |
| 764 | + | let free_left = (sandbox_allowance::FREE_SECONDS - before).max(0); | |
| 765 | + | (seconds - free_left).max(0) | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | /// `1h 2m`, `3m 12s` or `40s`. | |
| 769 | + | fn duration(seconds: i64) -> String { | |
| 770 | + | let (h, m, s) = (seconds / 3600, seconds % 3600 / 60, seconds % 60); | |
| 771 | + | if h > 0 { | |
| 772 | + | format!("{h}h {m}m") | |
| 773 | + | } else if m > 0 { | |
| 774 | + | format!("{m}m {s}s") | |
| 775 | + | } else { | |
| 776 | + | format!("{s}s") | |
| 777 | + | } | |
| 778 | + | } | |
| 779 | + | ||
| 676 | 780 | fn members_only<T>() -> Outcome<T> { | |
| 677 | 781 | Outcome::fail( | |
| 678 | 782 | FailureCode::Forbidden, | |
| 747 | 851 | "cancel_subscription" => reply(&billing.cancel_subscription(args(body)?).await?), | |
| 748 | 852 | "has_feature" => reply(&billing.has_feature(args(body)?).await?), | |
| 749 | 853 | "charge_feature" => reply(&billing.charge_feature(args(body)?).await?), | |
| 854 | + | "record_sandbox" => reply(&billing.record_sandbox(args(body)?).await?), | |
| 750 | 855 | _ => Response::error("Unknown method", 404), | |
| 751 | 856 | } | |
| 752 | 857 | } | |
| 771 | 876 | } | |
| 772 | 877 | ||
| 773 | 878 | #[test] | |
| 879 | + | fn sandbox_seconds_are_charged_only_past_the_free_minutes() { | |
| 880 | + | let free = sandbox_allowance::FREE_SECONDS; | |
| 881 | + | assert_eq!(sandbox_billable(0, 600), 0); | |
| 882 | + | assert_eq!(sandbox_billable(free - 100, 600), 500); | |
| 883 | + | assert_eq!(sandbox_billable(free + 5, 600), 600); | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | #[test] | |
| 887 | + | fn durations_read_plainly() { | |
| 888 | + | assert_eq!(duration(40), "40s"); | |
| 889 | + | assert_eq!(duration(192), "3m 12s"); | |
| 890 | + | assert_eq!(duration(3720), "1h 2m"); | |
| 891 | + | } | |
| 892 | + | ||
| 893 | + | #[test] | |
| 774 | 894 | fn an_absurd_cost_is_capped() { | |
| 775 | 895 | assert_eq!(charge_micros(1e9, 20), 120 * MICROS_PER_DOLLAR); | |
| 776 | 896 | } |
| 112 | 112 | | { kind: "actions"; jobId: string; token: string } | |
| 113 | 113 | /** A build of one commit, deployed to g1t.page. */ | |
| 114 | 114 | | { kind: "deploy"; deployId: string; token: string }; | |
| 115 | − | type RunRequest = Run & { envVars: Record<string, string> }; | |
| 115 | + | /** Whose sandbox time it is, reported when the sandbox stops. */ | |
| 116 | + | type Meter = { workspace: string; repo: string; description: string }; | |
| 117 | + | /** Deploy builds are metered by the Deployments plan, not here. */ | |
| 118 | + | type RunRequest = Run & { envVars: Record<string, string>; meter?: Meter }; | |
| 119 | + | ||
| 120 | + | function meter(repo: RepoPath, description: string): Meter { | |
| 121 | + | return { workspace: repo.namespace, repo: `${repo.namespace}/${repo.name}`, description }; | |
| 122 | + | } | |
| 116 | 123 | ||
| 117 | 124 | /** What the deployments service asks a sandbox to build. */ | |
| 118 | 125 | type DeployJob = { | |
| 149 | 156 | sleepAfter = "45m"; | |
| 150 | 157 | ||
| 151 | 158 | async run(request: RunRequest): Promise<void> { | |
| 152 | − | const { envVars, ...run } = request; | |
| 159 | + | const { envVars, meter, ...run } = request; | |
| 153 | 160 | await this.ctx.storage.put("run", run); | |
| 161 | + | if (meter) await this.ctx.storage.put("meter", { ...meter, started: Date.now() }); | |
| 154 | 162 | await this.start({ envVars, enableInternet: true }); | |
| 155 | 163 | } | |
| 156 | 164 | ||
| 165 | + | /** Reports how long the sandbox ran, once, whatever it exited with. */ | |
| 166 | + | private async meterStop(): Promise<void> { | |
| 167 | + | const metered = await this.ctx.storage.get<Meter & { started: number }>("meter"); | |
| 168 | + | if (!metered) return; | |
| 169 | + | await this.ctx.storage.delete("meter"); | |
| 170 | + | const seconds = Math.max(1, Math.ceil((Date.now() - metered.started) / 1000)); | |
| 171 | + | const recorded = await billingClient(this.env.BILLING) | |
| 172 | + | .recordSandbox({ | |
| 173 | + | workspace: metered.workspace, | |
| 174 | + | seconds, | |
| 175 | + | description: metered.description, | |
| 176 | + | repo: metered.repo, | |
| 177 | + | reference: `sandbox/${this.ctx.id.toString()}/${metered.started}`, | |
| 178 | + | }) | |
| 179 | + | .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } })); | |
| 180 | + | if (!recorded.ok) console.log("sandbox time not recorded", metered.workspace, seconds, recorded.error.message); | |
| 181 | + | } | |
| 182 | + | ||
| 157 | 183 | override async onStop({ exitCode, reason }: StopParams): Promise<void> { | |
| 184 | + | await this.meterStop(); | |
| 158 | 185 | if (exitCode === 0) return; | |
| 159 | 186 | const run = await this.ctx.storage.get<Run>("run"); | |
| 160 | 187 | console.log("sandbox stopped", run?.kind, "exit", exitCode, reason); | |
| 694 | 721 | kind: "actions", | |
| 695 | 722 | jobId: args.job, | |
| 696 | 723 | token: args.token, | |
| 724 | + | meter: meter(args.repo, `A workflow job in ${args.repo.namespace}/${args.repo.name}`), | |
| 697 | 725 | envVars: { | |
| 698 | 726 | MODE: "actions", | |
| 699 | 727 | G1T_API: "https://api.g1t.sh", | |
| 987 | 1015 | kind: "queue", | |
| 988 | 1016 | entryId: job.entryId, | |
| 989 | 1017 | token: job.token, | |
| 1018 | + | meter: meter(job.repo, `Merge queue on ${job.repo.namespace}/${job.repo.name}`), | |
| 990 | 1019 | envVars: { | |
| 991 | 1020 | MODE: "queue", | |
| 992 | 1021 | G1T_API: "https://api.g1t.sh", | |
| 1051 | 1080 | await sandbox.run({ | |
| 1052 | 1081 | kind: "answer", | |
| 1053 | 1082 | pullId: job.pullId, | |
| 1083 | + | meter: meter(job.repo, `Agent answering on ${job.repo.namespace}/${job.repo.name}#${job.number}`), | |
| 1054 | 1084 | envVars: { | |
| 1055 | 1085 | // Answered from its change as it stands: no merging in of the | |
| 1056 | 1086 | // default branch, which would push a commit for a question. | |
| 1090 | 1120 | await sandbox.run({ | |
| 1091 | 1121 | kind: "revise", | |
| 1092 | 1122 | pullId: job.pullId, | |
| 1123 | + | meter: meter(job.repo, `Agent revising ${job.repo.namespace}/${job.repo.name}#${job.number}`), | |
| 1093 | 1124 | envVars: { | |
| 1094 | 1125 | MODE: "revise", | |
| 1095 | 1126 | G1T_API: "https://api.g1t.sh", | |
| 1139 | 1170 | kind: "checks", | |
| 1140 | 1171 | runId: job.runId, | |
| 1141 | 1172 | token: job.token, | |
| 1173 | + | meter: meter(job.repo, `Checks on ${job.repo.namespace}/${job.repo.name}#${job.number}`), | |
| 1142 | 1174 | envVars: { | |
| 1143 | 1175 | MODE: "checks", | |
| 1144 | 1176 | G1T_API: "https://api.g1t.sh", | |
| 1249 | 1281 | await sandbox.run({ | |
| 1250 | 1282 | kind: "update", | |
| 1251 | 1283 | pullId: update.pullId, | |
| 1284 | + | meter: meter(repo, `Catching up ${repo.namespace}/${repo.name}#${number}`), | |
| 1252 | 1285 | envVars: { | |
| 1253 | 1286 | MODE: "update", | |
| 1254 | 1287 | G1T_API: "https://api.g1t.sh", | |
| 1309 | 1342 | kind: "review", | |
| 1310 | 1343 | runId: job.runId, | |
| 1311 | 1344 | token: job.token, | |
| 1345 | + | meter: meter(repo, `Review of ${repo.namespace}/${repo.name}#${number}`), | |
| 1312 | 1346 | envVars: { | |
| 1313 | 1347 | MODE: "review", | |
| 1314 | 1348 | G1T_API: "https://api.g1t.sh", | |
| 1373 | 1407 | kind: "plan", | |
| 1374 | 1408 | planId: job.planId, | |
| 1375 | 1409 | token: job.token, | |
| 1410 | + | meter: meter(repo, `Planning for ${repo.namespace}/${repo.name}`), | |
| 1376 | 1411 | envVars: { | |
| 1377 | 1412 | MODE: "plan", | |
| 1378 | 1413 | G1T_API: "https://api.g1t.sh", | |
| 1453 | 1488 | actor, | |
| 1454 | 1489 | repo, | |
| 1455 | 1490 | number: pull.number, | |
| 1491 | + | meter: meter(repo, `Agent on ${repo.namespace}/${repo.name}#${pull.number}`), | |
| 1456 | 1492 | envVars: { | |
| 1457 | 1493 | G1T_API: "https://api.g1t.sh", | |
| 1458 | 1494 | G1T_TOKEN: token, |