flagon-io/g1t

public

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

Commit

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.

syntaqxcommitted Parent5bee8b1Browse files
7 files+238−40/7 viewed
+25−2
6969 | A review by a g1t agent | Yes |
7070 | Catching up with `main` | Yes, when it needed an agent |
7171 | 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) |
7475 | [Deployments](/guides/deployments/) | The plan, and builds and usage past it. Never free. |
7576 | Repositories, git, issues, pull requests, the API and MCP | No |
7677
8687 assigned the issue. That is why only members of a workspace can put g1t
8788 agents to work on its repositories.
8889
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+
89112 ## Add credit
90113
91114 Only an owner of the workspace can add credit.
+31−0
307307 pub const MICROS_PER_BUILD_SECOND: i64 = 21;
308308 }
309309
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+
310341 /// What a feature's plan costs and includes.
311342 #[derive(Clone, Debug, Serialize, Deserialize)]
312343 #[serde(rename_all = "camelCase")]
+12−0
184184 repo?: string | null;
185185 reference: string;
186186 }): 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>>;
187199 startRun(run: {
188200 workspace: string;
189201 repo: RepoPath;
+1−0
199199 call("cancel_subscription", { actor, workspace, feature, resume }),
200200 hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }),
201201 chargeFeature: (charge) => call("charge_feature", charge),
202+ recordSandbox: (usage) => call("record_sandbox", usage),
202203 };
203204 }
204205
+11−0
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);
+120−0
673673 }
674674 }
675675
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 = &timestamp[..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+
676780 fn members_only<T>() -> Outcome<T> {
677781 Outcome::fail(
678782 FailureCode::Forbidden,
747851 "cancel_subscription" => reply(&billing.cancel_subscription(args(body)?).await?),
748852 "has_feature" => reply(&billing.has_feature(args(body)?).await?),
749853 "charge_feature" => reply(&billing.charge_feature(args(body)?).await?),
854+ "record_sandbox" => reply(&billing.record_sandbox(args(body)?).await?),
750855 _ => Response::error("Unknown method", 404),
751856 }
752857 }
771876 }
772877
773878 #[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]
774894 fn an_absurd_cost_is_capped() {
775895 assert_eq!(charge_micros(1e9, 20), 120 * MICROS_PER_DOLLAR);
776896 }
+38−2
112112 | { kind: "actions"; jobId: string; token: string }
113113 /** A build of one commit, deployed to g1t.page. */
114114 | { 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+}
116123
117124 /** What the deployments service asks a sandbox to build. */
118125 type DeployJob = {
149156 sleepAfter = "45m";
150157
151158 async run(request: RunRequest): Promise<void> {
152− const { envVars, ...run } = request;
159+ const { envVars, meter, ...run } = request;
153160 await this.ctx.storage.put("run", run);
161+ if (meter) await this.ctx.storage.put("meter", { ...meter, started: Date.now() });
154162 await this.start({ envVars, enableInternet: true });
155163 }
156164
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+
157183 override async onStop({ exitCode, reason }: StopParams): Promise<void> {
184+ await this.meterStop();
158185 if (exitCode === 0) return;
159186 const run = await this.ctx.storage.get<Run>("run");
160187 console.log("sandbox stopped", run?.kind, "exit", exitCode, reason);
694721 kind: "actions",
695722 jobId: args.job,
696723 token: args.token,
724+ meter: meter(args.repo, `A workflow job in ${args.repo.namespace}/${args.repo.name}`),
697725 envVars: {
698726 MODE: "actions",
699727 G1T_API: "https://api.g1t.sh",
9871015 kind: "queue",
9881016 entryId: job.entryId,
9891017 token: job.token,
1018+ meter: meter(job.repo, `Merge queue on ${job.repo.namespace}/${job.repo.name}`),
9901019 envVars: {
9911020 MODE: "queue",
9921021 G1T_API: "https://api.g1t.sh",
10511080 await sandbox.run({
10521081 kind: "answer",
10531082 pullId: job.pullId,
1083+ meter: meter(job.repo, `Agent answering on ${job.repo.namespace}/${job.repo.name}#${job.number}`),
10541084 envVars: {
10551085 // Answered from its change as it stands: no merging in of the
10561086 // default branch, which would push a commit for a question.
10901120 await sandbox.run({
10911121 kind: "revise",
10921122 pullId: job.pullId,
1123+ meter: meter(job.repo, `Agent revising ${job.repo.namespace}/${job.repo.name}#${job.number}`),
10931124 envVars: {
10941125 MODE: "revise",
10951126 G1T_API: "https://api.g1t.sh",
11391170 kind: "checks",
11401171 runId: job.runId,
11411172 token: job.token,
1173+ meter: meter(job.repo, `Checks on ${job.repo.namespace}/${job.repo.name}#${job.number}`),
11421174 envVars: {
11431175 MODE: "checks",
11441176 G1T_API: "https://api.g1t.sh",
12491281 await sandbox.run({
12501282 kind: "update",
12511283 pullId: update.pullId,
1284+ meter: meter(repo, `Catching up ${repo.namespace}/${repo.name}#${number}`),
12521285 envVars: {
12531286 MODE: "update",
12541287 G1T_API: "https://api.g1t.sh",
13091342 kind: "review",
13101343 runId: job.runId,
13111344 token: job.token,
1345+ meter: meter(repo, `Review of ${repo.namespace}/${repo.name}#${number}`),
13121346 envVars: {
13131347 MODE: "review",
13141348 G1T_API: "https://api.g1t.sh",
13731407 kind: "plan",
13741408 planId: job.planId,
13751409 token: job.token,
1410+ meter: meter(repo, `Planning for ${repo.namespace}/${repo.name}`),
13761411 envVars: {
13771412 MODE: "plan",
13781413 G1T_API: "https://api.g1t.sh",
14531488 actor,
14541489 repo,
14551490 number: pull.number,
1491+ meter: meter(repo, `Agent on ${repo.namespace}/${repo.name}#${pull.number}`),
14561492 envVars: {
14571493 G1T_API: "https://api.g1t.sh",
14581494 G1T_TOKEN: token,