Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

14 commits

63 files+341−1900/63 viewed
+2−2
4848 key: runner-release-${{ hashFiles('Cargo.lock') }}
4949 - name: Build every platform
5050 env:
51− G1T_RUNNER_RELEASE_KEY: ${{ vars.G1T_RUNNER_RELEASE_KEY }}
51+ G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
5252 RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }}
5353 run: node scripts/runner-release.mjs build
5454 - name: Sign
5555 env:
5656 RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }}
57− G1T_RUNNER_RELEASE_KEY: ${{ vars.G1T_RUNNER_RELEASE_KEY }}
57+ G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
5858 run: |
5959 node scripts/runner-release.mjs sign
6060 node scripts/runner-release.mjs verify
+1−0
909909 name = "g1t-billing"
910910 version = "0.1.0"
911911 dependencies = [
912+ "futures-util",
912913 "g1t-contracts",
913914 "g1t-kit",
914915 "getrandom 0.2.17",
+3−3
77 - **Collaborate.** Git over HTTPS, public and private repositories, issues,
88 pull requests, line comments and reviews, protected branches, workspaces,
99 profiles and site-wide search.
10−- **Agents as teammates.** Assign an issue to g1t's agent or mention it, or
10+- **Agents as teammates.** Assign an issue to g1t or mention `@g1t`, or
1111 connect Claude Code, Codex, OpenCode or Cursor over MCP. Hand g1t an
1212 outcome and a planner splits it into issues with dependencies that agents
1313 take up as they unblock. Agents see what the others are changing, ask each
7070 - Overlap: each pull request shows which others in progress change the
7171 same files, while the work is still going on.
7272 - Catch-up: when `main` has moved under a pull request, g1t merges it in,
73− and a g1t agent resolves any conflict.
74−- Reviews written by a g1t agent, on request: line comments, a summary and
73+ and g1t resolves any conflict.
74+- Reviews written by g1t, on request: line comments, a summary and
7575 a verdict.
7676 - Importing a public repository from any git host by its address, and
7777 public or private repositories through g1t's GitHub App, imported once,
+5−1
2626 flex-direction: column;
2727 height: 100%;
2828 }
29+ /* The same bar as the app's: 4rem, and a line under it. */
2930 .g1t-header-top {
30− height: 3.5rem;
31+ box-sizing: border-box;
32+ height: 4rem;
33+ flex-shrink: 0;
3134 padding: 0 var(--sl-nav-pad-x);
35+ border-bottom: 1px solid var(--g1t-line);
3236 }
3337 /* On phones the menu button sits at the top row's end: leave it room. */
3438 @media (max-width: 49.99rem) {
+8−9
99 on it, register it once, and it picks up jobs that ask for it with
1010 `runs-on: self-hosted`.
1111
12−**Not available yet:** the first release of `g1t-runner` has not been
13−published, so the downloads below and the `flagonio/g1t-runner` image do
14−not exist yet. g1t's side (**Settings → Runners**, registration tokens,
15−groups and the API) is live. This page says how the runner works once it
16−is released.
17−
1812 - **Time on your runners is $0**, on every plan, including the free one.
1913 It shows on [usage](/guides/usage-and-billing/) as self-hosted minutes.
2014 - **It only connects out.** The runner polls `api.g1t.sh` over HTTPS for
181175 your own [model provider](/guides/models/), g1t charges nothing for the
182176 run; otherwise the model is paid as usual and the machine is free.
183177 - Agent work needs an image with git, Node and the agent's CLI: register
184− the runner with `--agent-image`, which the release names, or run it with
185− `--no-docker` on a Linux machine set aside for it, where `/work` can be
186− written.
178+ the runner with `--agent-image` and an image of yours that has them, or
179+ run it with `--no-docker` on a Linux machine set aside for it, where
180+ `/work` can be written. g1t does not publish an agent image yet.
187181 - [Guardrails](/guides/guardrails/) still apply to what the agent does, but
188182 their network list cannot be enforced on your machine. The run's session
189183 says so when it starts.
232226
233227 ## Docker and Kubernetes
234228
229+**The `flagonio/g1t-runner` image is not published yet.** Until it is, run
230+the binary on the machine, or build the image from
231+[`deploy/runner/Dockerfile`](https://g1t.sh/flagon-io/g1t/blob/main/deploy/runner/Dockerfile)
232+with the Linux binary beside it.
233+
235234 The runner's image runs `register-and-run`, which registers once and then
236235 runs. Each option can also come from `G1T_RUNNER_<OPTION>` in the
237236 environment, such as `G1T_RUNNER_TOKEN` and `G1T_RUNNER_LABELS`, so a
+4−3
3737 --sl-text-body: 0.9375rem;
3838 --sl-line-height: 1.75;
3939 --sl-content-width: 46rem;
40− /* The header's row, and the tabs beneath it. */
41− --sl-nav-height: 6.25rem;
40+ /* The header's row, as tall as the app's top bar (4rem), and the tabs
41+ beneath it. */
42+ --sl-nav-height: 6.75rem;
4243 --sl-sidebar-width: 17rem;
4344
4445 color-scheme: dark;
6263 }
6364 /* On phones the menu button sits in the header's top row, not its middle. */
6465 .sl-menu-button {
65− top: calc((3.5rem - var(--sl-menu-button-size)) / 2) !important;
66+ top: calc((4rem - var(--sl-menu-button-size)) / 2) !important;
6667 }
6768 site-search button[data-open-modal] {
6869 border-radius: 0.5rem;
+70−62
11 import { Webhook } from "lucide-react";
2−import { Link } from "react-router";
32
43 import type { StripeStatus } from "@g1t/contracts";
54
65 import type { Route } from "./+types/stripe";
76 import { Badge, Button, EmptyState, Notice, Section, When } from "~/components/ui";
8−import { text } from "~/lib/forms";
97 import { admin } from "~/lib/services.server";
108 import { requireStaff } from "~/lib/staff";
119
1614 return { status: await admin.stripe() };
1715 }
1816
19−type ActionData = { review: true } | { status: StripeStatus; registered: true };
17+type ActionData = { status: StripeStatus; fixed: true };
2018
21−export async function action({ request, context }: Route.ActionArgs) {
19+export async function action({ context }: Route.ActionArgs) {
2220 const staff = requireStaff(context);
23− const form = await request.formData();
24− if (text(form, "intent") !== "webhook") return { review: true } satisfies ActionData;
25− if (text(form, "confirm") !== "yes") return { review: true } satisfies ActionData;
26− return { status: await admin.stripe(true, staff.email), registered: true } satisfies ActionData;
21+ return { status: await admin.stripe(true, staff.email), fixed: true } satisfies ActionData;
2722 }
2823
2924 const MODE: Record<string, { label: string; tone: "warn" | "mint" | "danger" }> = {
3227 off: { label: "Off", tone: "danger" },
3328 };
3429
30+const WEBHOOK_URL = "https://api.g1t.sh/stripe/webhook";
31+
3532 export default function Stripe({ loaderData, actionData }: Route.ComponentProps) {
3633 const result = actionData as ActionData | undefined;
37− const status = result && "status" in result ? result.status : loaderData.status;
38− const reviewing = result != null && "review" in result;
39− const registered = result != null && "registered" in result;
34+ const status = result?.status ?? loaderData.status;
35+ const fixed = result != null;
4036 const mode = MODE[status.mode] ?? { label: status.mode, tone: "warn" as const };
4137 const { webhook } = status;
42− const verb = webhook ? "Replace" : "Register";
38+ const needsFix = webhook != null && (webhook.status !== "enabled" || status.missingEvents.length > 0);
39+ const ready = status.mode !== "off" && status.secretSet && webhook != null && !needsFix;
4340
4441 return (
4542 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
4643 <div className="flex flex-wrap items-start justify-between gap-4">
4744 <div>
4845 <h1 className="text-2xl font-semibold tracking-tight">Stripe</h1>
49− <p className="mt-1 text-sm text-muted">How billing talks to Stripe: its keys' mode, the webhook Stripe calls, and what it sent lately.</p>
46+ <p className="mt-1 text-sm text-muted">How billing hears from Stripe: the destination in Stripe, its signing secret here, and what it sent lately.</p>
5047 </div>
5148 <Badge tone={mode.tone}>{mode.label}</Badge>
5249 </div>
5350
5451 <div className="mt-6 space-y-3">
5552 {status.error && <Notice tone="error">{status.error}</Notice>}
56− {registered && !status.error && <Notice tone="ok">Webhook registered. Stripe sends its events to it from now on.</Notice>}
57− {status.mode === "off" && <Notice tone="warn">Billing has no Stripe key, so nothing reaches Stripe and no webhook can be registered.</Notice>}
58− {reviewing && (
59− <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${webhook ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}>
60− <h2 className="font-semibold tracking-tight">{verb} the {mode.label.toLowerCase()} webhook?</h2>
61− <p className="mt-2 text-sm text-muted">
62− Billing {webhook ? "deletes the endpoint it made before, then asks" : "asks"} Stripe for a new webhook endpoint, and keeps
63− its signing secret itself; nobody sees it.
64− </p>
65− <form method="post" action="/stripe#top" className="mt-4 flex flex-wrap items-center gap-2">
66− <input type="hidden" name="intent" value="webhook" />
67− <input type="hidden" name="confirm" value="yes" />
68− <Button type="submit" variant={webhook ? "danger" : "lavender"}>
69− {verb} webhook
70− </Button>
71− <Link to="/stripe" className="px-2 text-sm text-muted hover:text-fg">
72− Cancel
73− </Link>
74− </form>
75− </section>
76− )}
53+ {fixed && !status.error && <Notice tone="ok">Destination checked: enabled, and sending every event billing handles.</Notice>}
54+ {status.mode === "off" && <Notice tone="warn">Billing has no Stripe key (STRIPE_SECRET_KEY), so nothing reaches Stripe.</Notice>}
55+ {ready && <Notice tone="ok">Ready: Stripe sends to billing, and billing can check what it sends.</Notice>}
7756 </div>
7857
7958 <Section
8059 title="Webhook"
81− description="Replacing it makes a new signing secret, kept only in billing. Do it once per mode, and again after switching to live keys."
60+ description="Made in Stripe's dashboard; its signing secret is the billing Worker's secret STRIPE_WEBHOOK_SECRET. Billing keeps the destination's events and enables it again if Stripe turns it off; it never changes the secret."
8261 className="mt-6"
8362 actions={
84− status.mode !== "off" && !reviewing ? (
85− <form method="post" action="/stripe#review">
86− <input type="hidden" name="intent" value="webhook" />
87− <Button type="submit" variant="quiet">
63+ needsFix ? (
64+ <form method="post" action="/stripe#top">
65+ <Button type="submit" variant="lavender">
8866 <Webhook size={14} />
89− {verb} webhook
67+ Fix destination
9068 </Button>
9169 </form>
9270 ) : null
9371 }
9472 >
95− {webhook ? (
96− <dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
97− <dt className="text-muted">URL</dt>
98− <dd className="font-mono text-xs break-all">{webhook.url}</dd>
99− <dt className="text-muted">Endpoint id</dt>
100− <dd className="font-mono text-xs break-all text-fg-soft">{webhook.endpointId}</dd>
101− <dt className="text-muted">Events</dt>
102− <dd className="flex flex-wrap gap-1.5">
103− {webhook.events.map((event) => (
104− <span key={event} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-xs">
105− {event}
106− </span>
107− ))}
108− </dd>
109− <dt className="text-muted">Registered</dt>
110− <dd className="text-fg-soft">
111− <When at={webhook.createdAt} time /> by <span className="font-mono">{webhook.createdBy}</span>
112− </dd>
113− </dl>
114− ) : (
115− <p className="text-sm text-muted">Not registered. Until it is, billing does not hear about payments, disputes or invoices from Stripe.</p>
116− )}
73+ <dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
74+ <dt className="text-muted">Signing secret</dt>
75+ <dd>
76+ {status.secretSet ? (
77+ <Badge tone="mint">Set</Badge>
78+ ) : (
79+ <span className="text-fg-soft">
80+ <Badge tone="danger">Not set</Badge> Every event is refused until it is. In Stripe: the destination, Signing secret, Reveal; then, in{" "}
81+ <span className="font-mono text-xs">services/billing</span>,{" "}
82+ <span className="font-mono text-xs">npx wrangler secret put STRIPE_WEBHOOK_SECRET</span>.
83+ </span>
84+ )}
85+ </dd>
86+ <dt className="text-muted">Destination</dt>
87+ <dd className="min-w-0">
88+ {webhook ? (
89+ <span className="flex flex-wrap items-center gap-2">
90+ <span className="font-mono text-xs break-all">{webhook.url}</span>
91+ <Badge tone={webhook.status === "enabled" ? "mint" : "danger"}>{webhook.status}</Badge>
92+ <span className="font-mono text-xs text-faint">{webhook.endpointId}</span>
93+ </span>
94+ ) : status.mode === "off" ? (
95+ <span className="text-muted">Not checked without a key.</span>
96+ ) : (
97+ <span className="text-fg-soft">
98+ None in {mode.label.toLowerCase()}. In Stripe: Developers, Webhooks, Add destination, endpoint URL{" "}
99+ <span className="font-mono text-xs">{WEBHOOK_URL}</span>, any events (billing adds what it needs here). Then set its secret.
100+ </span>
101+ )}
102+ </dd>
103+ {webhook && (
104+ <>
105+ <dt className="text-muted">Made</dt>
106+ <dd className="text-fg-soft">
107+ <When at={webhook.createdAt} time />
108+ </dd>
109+ <dt className="text-muted">Events</dt>
110+ <dd className="flex flex-wrap gap-1.5">
111+ {webhook.events.map((event) => (
112+ <span key={event} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-xs">
113+ {event}
114+ </span>
115+ ))}
116+ {status.missingEvents.map((event) => (
117+ <span key={event} className="rounded border border-danger/40 bg-danger/5 px-1.5 py-0.5 font-mono text-xs text-danger" title="Billing handles this; the destination does not send it">
118+ {event} missing
119+ </span>
120+ ))}
121+ </dd>
122+ </>
123+ )}
124+ </dl>
117125 </Section>
118126
119127 <Section title="Recent events" description="What Stripe sent, newest first, and what billing did with it." className="mt-6">
+3−9
66 */
77 import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react";
88 import { type ReactNode, useEffect, useMemo, useState } from "react";
9−import { Link, useFetcher, useRevalidator } from "react-router";
9+import { Link, useFetcher } from "react-router";
10+import { useRefreshWhile } from "../lib/refresh";
1011
1112 import {
1213 type AgentRun,
4142
4243 /** Revalidates the page every few seconds while `live`, as the merge queue does. */
4344 export function useLiveRefresh(live: boolean) {
44− const revalidator = useRevalidator();
45− useEffect(() => {
46− if (!live) return;
47− const timer = setInterval(() => {
48− if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate();
49− }, LIVE_MS);
50− return () => clearInterval(timer);
51− }, [live, revalidator]);
45+ useRefreshWhile(live, LIVE_MS);
5246 }
5347
5448 export function formatCost(usd: number | null | undefined): string | null {
+17−0
22 import { test } from "node:test";
33
44 import {
5+ foldTasks,
56 gigabytes,
67 shownMeters,
78 alertText,
234235 assert.equal(gigabytes(1_500_000_000), "1.5 GB");
235236 assert.equal(gigabytes(500_000_000), "500 MB");
236237 });
238+
239+test("kinds of work without words of their own are one Other, listed once and last", () => {
240+ const slices = [
241+ { key: "check", micros: 5, runs: 50 },
242+ { key: "review", micros: 3, runs: 5 },
243+ { key: "other", micros: 1, runs: 7 },
244+ { key: "implement", micros: 9, runs: 4 },
245+ { key: "answer", micros: 2, runs: 11 },
246+ ];
247+ assert.deepEqual(foldTasks(slices), [
248+ { key: "review", micros: 3, runs: 5 },
249+ { key: "implement", micros: 9, runs: 4 },
250+ { key: "other", micros: 8, runs: 68 },
251+ ]);
252+ assert.deepEqual(foldTasks([{ key: "plan", micros: 1, runs: 1 }]), [{ key: "plan", micros: 1, runs: 1 }]);
253+});
+15−0
270270 return USAGE_TASKS[key] ?? USAGE_TASKS.other!;
271271 }
272272
273+/**
274+ * Usage by kind of work with every kind that has no words of its own
275+ * added into one Other, last, so Other is listed once.
276+ */
277+export function foldTasks<T extends { key: string; micros: number; runs: number }>(slices: T[]): T[] {
278+ const known = slices.filter((slice) => slice.key !== "other" && slice.key in USAGE_TASKS);
279+ const rest = slices.filter((slice) => !known.includes(slice));
280+ if (rest.length === 0) return known;
281+ const other = rest.reduce(
282+ (sum, slice) => ({ ...sum, micros: sum.micros + slice.micros, runs: sum.runs + slice.runs }),
283+ { ...rest[0]!, key: "other", micros: 0, runs: 0 },
284+ );
285+ return [...known, other];
286+}
287+
273288 /** The workspace's month at a glance, for the Usage card on its overview. */
274289 export type UsageGlance = {
275290 /**
+23−0
1+import { useEffect } from "react";
2+import { useNavigation, useRevalidator } from "react-router";
3+
4+/** How often a page with something moving asks again. */
5+export const REFRESH_MS = 4000;
6+
7+/**
8+ * Revalidates the page every `everyMs` while `on` and the tab is visible.
9+ * Never while a navigation is pending: a revalidation then restarts the
10+ * navigation, aborting its loaders, so a click to a page slower than the
11+ * interval would never arrive. Never on top of a revalidation still going.
12+ */
13+export function useRefreshWhile(on: boolean, everyMs = REFRESH_MS) {
14+ const revalidator = useRevalidator();
15+ const navigating = useNavigation().state !== "idle";
16+ useEffect(() => {
17+ if (!on || navigating) return;
18+ const timer = setInterval(() => {
19+ if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate();
20+ }, everyMs);
21+ return () => clearInterval(timer);
22+ }, [on, navigating, everyMs, revalidator]);
23+}
+6−4
138138 // something (lib/cache.server.ts).
139139 const kept = <T,>(what: string, load: () => Promise<T>) =>
140140 workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null);
141− const [listed, counts, account, usage, limit, entitlements, shared] = await Promise.all([
141+ const [listed, counts, status, usage, limit, entitlements, shared] = await Promise.all([
142142 kept("projects", () => projects.list(workspace!.slug, user)),
143143 path ? countsFor(context, params) : Promise.resolve(null),
144− kept("account", () => billing.account(workspace!.slug, user)),
144+ // Whether billing is on, without reading the account: that asks the
145+ // card processor about the workspace's cards, too slow for every page.
146+ kept("status", () => billing.status()).catch(() => null),
145147 kept(`usage:${monthStart}`, () => billing.usage(workspace!.slug, user, monthStart)),
146148 kept("limit", () => billing.limit(workspace!.slug, user)).catch(() => null),
147149 kept("entitlements", () => billing.entitlements(workspace!.slug)).catch(() => null),
170172 : null,
171173 // Where the workspace stands against its usage limit, once billing is on.
172174 limit:
173− account?.ok && account.value.status.enabled && limit?.ok
175+ status?.enabled && limit?.ok
174176 ? {
175177 exposureMicros: limit.value.exposureMicros,
176178 ceilingMicros: limit.value.ceilingMicros,
178180 comped: limit.value.trust === "internal",
179181 }
180182 : null,
181− free: account?.ok ? Boolean(account.value.status.free) : false,
183+ free: Boolean(status?.free),
182184 // A spend spike or a hold pauses new compute: the shell says so on every page.
183185 compute:
184186 workspace && entitlements && (entitlements.paused || entitlements.spike?.status === "open")
+4−10
11 import { AlertTriangle, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Info, Package, RotateCw, ServerCog, Square, XCircle } from "lucide-react";
2−import { type ReactNode, useEffect } from "react";
3−import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
2+import { type ReactNode } from "react";
3+import { Form, Link, useNavigation, useSearchParams } from "react-router";
44
55 import type { Annotation, Job, StepState } from "@g1t/contracts";
66
1212 import { actions } from "../../lib/services.server";
1313 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1414 import { accessTo, refusal } from "../../lib/access.server";
15+import { useRefreshWhile } from "../../lib/refresh";
1516
1617 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
1718 const run = loaderData?.detail.run;
170171 const base = `/${params.owner}/${params.repo}`;
171172 const [search] = useSearchParams();
172173 const busy = useNavigation().state === "submitting";
173− const revalidator = useRevalidator();
174174 const live = run.status !== "completed";
175− useEffect(() => {
176− if (!live) return;
177− const timer = setInterval(() => {
178− if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
179− }, 2500);
180− return () => clearInterval(timer);
181− }, [live, revalidator]);
175+ useRefreshWhile(live, 2500);
182176
183177 // The job asked for, else one that failed, is running, or the first.
184178 const selected =
+4−10
11 import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
2−import { useEffect, useState } from "react";
3−import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
2+import { useState } from "react";
3+import { Form, Link, useNavigation, useSearchParams } from "react-router";
44
55 import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
66
1515 import { actions } from "../../lib/services.server";
1616 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1717 import { accessFor, refusal, repoFor } from "../../lib/access.server";
18+import { useRefreshWhile } from "../../lib/refresh";
1819
1920 export function meta({ params, ...args }: Route.MetaArgs) {
2021 return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` });
7879
7980 /** Re-reads the page every few seconds while something is still running. */
8081 function useLiveWhile(running: boolean) {
81− const revalidator = useRevalidator();
82− useEffect(() => {
83− if (!running) return;
84− const timer = setInterval(() => {
85− if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
86− }, 3000);
87− return () => clearInterval(timer);
88− }, [running, revalidator]);
82+ useRefreshWhile(running, 3000);
8983 }
9084
9185 const EVENT_WORDS: Record<string, string> = {
+3−11
11 import { env } from "cloudflare:workers";
22 import { Bot, ExternalLink, GitCommitHorizontal, GitMerge, Play, Sparkles } from "lucide-react";
3−import { useEffect } from "react";
4−import { Form, Link, redirect, useNavigation, useRevalidator } from "react-router";
3+import { Form, Link, redirect, useNavigation } from "react-router";
54
65 import { type Pull, PROVIDERS, workOwner } from "@g1t/contracts";
76
4140 import { identity, integrations, work } from "../../lib/services.server";
4241 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
4342 import { accessTo, refusal } from "../../lib/access.server";
43+import { useRefreshWhile } from "../../lib/refresh";
4444
45−const REFRESH_MS = 4000;
4645
4746 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
4847 const issue = loaderData?.issue;
236235 const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData;
237236
238237 // Follow agents at work without a manual reload.
239− const revalidator = useRevalidator();
240238 const navigation = useNavigation();
241239 const running = pulls.some(
242240 (pull) =>
253251 pull.runtime === "hosted" &&
254252 (pull.status === "draft" || pull.status === "open"),
255253 );
256− useEffect(() => {
257− if (!running && !assigned) return;
258− const timer = setInterval(() => {
259− if (document.visibilityState === "visible") revalidator.revalidate();
260− }, REFRESH_MS);
261− return () => clearInterval(timer);
262− }, [running, assigned, revalidator]);
254+ useRefreshWhile(Boolean(running || assigned));
263255
264256 const starting = navigation.formData?.get("action") === "run-hosted";
265257 const base = `/${params.owner}/${params.repo}`;
+3−11
11 import { env } from "cloudflare:workers";
22 import type { G1tEvent } from "@g1t/contracts";
33 import { ArrowRight, CircleCheck, FileCode2, Sparkles } from "lucide-react";
4−import { useEffect } from "react";
5−import { Form, Link, data, useNavigation, useRevalidator } from "react-router";
4+import { Form, Link, data, useNavigation } from "react-router";
65
76 import type { Route } from "./+types/plan";
87 import { refusal, requireRepo } from "../../lib/access.server";
2120 requireUser,
2221 unwrap,
2322 } from "../../lib/session.server";
23+import { useRefreshWhile } from "../../lib/refresh";
2424
25−const REFRESH_MS = 4000;
2625
2726 export function meta({ params, ...args }: Route.MetaArgs) {
2827 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
9594 const applying = useNavigation().state === "submitting";
9695
9796 // The agent is still writing it.
98− const revalidator = useRevalidator();
9997 const planning = plan.status === "planning";
10098 // Planning, or agents still converging what was applied.
10199 const moving =
102100 planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
103− useEffect(() => {
104− if (!moving) return;
105− const timer = setInterval(() => {
106− if (document.visibilityState === "visible") revalidator.revalidate();
107− }, REFRESH_MS);
108− return () => clearInterval(timer);
109− }, [moving, revalidator]);
101+ useRefreshWhile(moving);
110102 const converging = plan.status === "applied" && plan.progress.length > 0;
111103
112104 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
+4−11
2323 User,
2424 Wrench,
2525 } from "lucide-react";
26−import { Suspense, useEffect } from "react";
27−import { Await, Form, Link, redirect, useNavigation, useRevalidator } from "react-router";
26+import { Suspense } from "react";
27+import { Await, Form, Link, redirect, useNavigation } from "react-router";
2828
2929 import {
3030 type Capability,
7676 import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server";
7777 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
7878 import { accessTo, refusal } from "../../lib/access.server";
79+import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh";
7980
80−const REFRESH_MS = 4000;
8181 const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
8282 const TABS = ["conversation", "session", "changes"] as const;
8383 type Tab = (typeof TABS)[number];
561561 const active = pull.status === "draft" || pull.status === "open";
562562
563563 // Follow an agent at work, or checks in progress, without a manual reload.
564− const revalidator = useRevalidator();
565564 const working = pull.status === "draft";
566565 const checking = statuses.some((status) => status.state === "pending");
567566 const reviews = verdicts(comments);
601600 // Whether it merges cleanly is being worked out, so the box will change.
602601 const probing = active && mergeable === "checking";
603602 const conflicting = active && mergeable === "conflicting";
604− useEffect(() => {
605− if (!working && !checking && !reviewPending && !catchingUp && !settling && !moving && !landing && !probing) return;
606− const timer = setInterval(() => {
607− if (document.visibilityState === "visible") revalidator.revalidate();
608− }, REFRESH_MS);
609− return () => clearInterval(timer);
610− }, [working, checking, reviewPending, catchingUp, settling, moving, landing, probing, revalidator]);
603+ useRefreshWhile(working || checking || reviewPending || catchingUp || settling || moving || landing || probing);
611604 // Why the merge button cannot be pressed, if it cannot.
612605 const mergeBlocked = conflicting
613606 ? "Resolve the conflicts first."
+3−11
88 Minus,
99 X,
1010 } from "lucide-react";
11−import { useEffect } from "react";
12−import { Link, useRevalidator } from "react-router";
11+import { Link } from "react-router";
1312
1413 import type { QueueEntry, QueueState } from "@g1t/contracts";
1514
1918 import { work } from "../../lib/services.server";
2019 import { getViewer, unwrap } from "../../lib/session.server";
2120 import { accessFor, repoFor } from "../../lib/access.server";
21+import { useRefreshWhile } from "../../lib/refresh";
2222
23−const REFRESH_MS = 4000;
2423
2524 export function meta({ params, ...args }: Route.MetaArgs) {
2625 return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` });
149148 export default function Queue({ loaderData, params }: Route.ComponentProps) {
150149 const { queue, defaultBranch, member } = loaderData;
151150 const base = `/${params.owner}/${params.repo}`;
152− const revalidator = useRevalidator();
153151 const moving = queue.active.length > 0;
154− useEffect(() => {
155− if (!moving) return;
156− const timer = setInterval(() => {
157− if (document.visibilityState === "visible") revalidator.revalidate();
158− }, REFRESH_MS);
159− return () => clearInterval(timer);
160− }, [moving, revalidator]);
152+ useRefreshWhile(moving);
161153
162154 const testing = queue.active.filter((entry) => entry.state !== "waiting").length;
163155 return (
+3−9
11 import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react";
22 import { useEffect, useState } from "react";
3−import { Form, Link, useNavigation, useRevalidator } from "react-router";
3+import { Form, Link, useNavigation } from "react-router";
44
55 import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts";
66
2323 import { deployments } from "../../lib/services.server";
2424 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2525 import { requireCapability, requireInsider } from "../../lib/access.server";
26+import { useRefreshWhile } from "../../lib/refresh";
2627
2728 export function meta({ params, ...args }: Route.MetaArgs) {
2829 return page(args, { title: `Domains · ${params.owner}/${params.repo} · g1t` });
106107 }, [added, actionData]);
107108
108109 // While a domain is on its way, the page follows it.
109− const revalidator = useRevalidator();
110110 const waiting = domains.some((d) => d.status === "pending" || d.status === "verifying");
111− useEffect(() => {
112− if (!waiting) return;
113− const timer = setInterval(() => {
114− if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
115− }, 10_000);
116− return () => clearInterval(timer);
117− }, [waiting, revalidator]);
111+ useRefreshWhile(waiting, 10_000);
118112
119113 // A redirecting domain is shown under the one it redirects to.
120114 const primary = domains.filter((d) => !d.redirectTo || !domains.some((p) => p.hostname === d.redirectTo));
+4−3
44 import { MICROS_PER_DOLLAR, type UsageSlice } from "@g1t/contracts";
55
66 import type { Route } from "./+types/usage";
7−import { usageTask } from "../../lib/billing";
7+import { foldTasks, usageTask } from "../../lib/billing";
88 import { page } from "../../lib/meta";
99 import { ButtonLink } from "../../components/ui";
1010 import { billing } from "../../lib/services.server";
181181
182182 export default function UsagePage({ loaderData, params }: Route.ComponentProps) {
183183 const { period, since, usage, account } = loaderData;
184+ const byTask = foldTasks(usage.byTask);
184185 const base = `/${params.owner}`;
185186 const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000));
186187 // While g1t is free nothing is charged, so usage is measured at cost
257258 <div className="flex flex-wrap items-baseline justify-between gap-3">
258259 <h3 className="text-sm font-medium">{usage.free ? "Usage per day" : "Spend per day"}</h3>
259260 <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
260− {usage.byTask.map((slice) => (
261+ {byTask.map((slice) => (
261262 <li key={slice.key} className="flex items-center gap-1.5">
262263 <span className="size-2 rounded-sm" style={{ background: task(slice.key).color }} />
263264 {task(slice.key).label}
273274 <div className="grid gap-4 lg:grid-cols-2">
274275 <Breakdown
275276 title="By kind of work"
276− slices={usage.byTask}
277+ slices={byTask}
277278 total={total}
278279 label={(key) => task(key).label}
279280 color={(key) => task(key).color}
+11−5
14441444 }
14451445
14461446 /// `admin_stripe`: where billing stands with Stripe. Staff only. Returns
1447−/// `StripeStatus`. With `setup: true`, registers (or replaces) the webhook
1448−/// endpoint for the current mode first.
1447+/// `StripeStatus`. With `fix: true`, first enables the destination at
1448+/// billing's address and gives it the events billing needs.
14491449 #[derive(Debug, Default, Serialize, Deserialize)]
14501450 pub struct AdminStripeArgs {
14511451 #[serde(default)]
1452− pub setup: bool,
1452+ pub fix: bool,
14531453 #[serde(default)]
14541454 pub by: Option<String>,
14551455 }
14591459 pub struct StripeStatus {
14601460 /// `test` or `live`, from the key; `off` without one.
14611461 pub mode: String,
1462+ /// Whether `STRIPE_WEBHOOK_SECRET` is set, so events can be checked.
1463+ pub secret_set: bool,
1464+ /// The destination at billing's address in Stripe, as Stripe has it.
14621465 pub webhook: Option<StripeWebhook>,
1466+ /// Events billing handles that the destination does not send.
1467+ pub missing_events: Vec<String>,
14631468 /// The latest events handled, newest first.
14641469 pub recent_events: Vec<StripeEventSummary>,
1465− /// What went wrong setting up, if it did.
1470+ /// What went wrong reading or fixing the destination, if it did.
14661471 pub error: Option<String>,
14671472 }
14681473
14711476 pub struct StripeWebhook {
14721477 pub url: String,
14731478 pub endpoint_id: String,
1479+ /// `enabled` or `disabled`.
1480+ pub status: String,
14741481 pub events: Vec<String>,
1475− pub created_by: String,
14761482 pub created_at: String,
14771483 }
14781484
+21−2
417417 format!("/{}", parts.join("/"))
418418 }
419419
420−/// Where file tools may go: the project, scratch space, and the caches
421−/// where dependencies' sources are.
420+/// The files the runner asks the agent to write its answer to, outside
421+/// the project so they are never committed: a review, a plan, a reply.
422+const ANSWER_FILES: [&str; 3] = [crate::review::REVIEW_FILE, crate::plan::PLAN_FILE, crate::reply::ANSWER_FILE];
423+
424+/// Where file tools may go: the project, scratch space, the caches where
425+/// dependencies' sources are, and the answer files.
422426 fn inside_allowed(path: &str, place: &Place) -> bool {
427+ if ANSWER_FILES.contains(&path) {
428+ return true;
429+ }
423430 let roots = [
424431 crate::WORKDIR.to_owned(),
425432 "/tmp".to_owned(),
814821 }
815822
816823 #[test]
824+ fn the_answer_files_may_be_written_and_nothing_else_beside_the_project() {
825+ let policy = all_on();
826+ let write = |path: &str| decide(&policy, "Write", &json!({ "file_path": path, "content": "{}" }), &place());
827+ for path in ["/work/review.json", "/work/plan.json", "/work/answer.md", "/work/repo/src/lib.rs", "/tmp/x"] {
828+ assert_eq!(write(path), None, "{path}");
829+ }
830+ for path in ["/work/notes.json", "/work/review.json.bak", "/etc/hosts"] {
831+ assert!(write(path).is_some(), "{path}");
832+ }
833+ }
834+
835+ #[test]
817836 fn force_pushes_are_refused_however_written() {
818837 let policy = all_on();
819838 for command in [
+1−1
2323 use crate::report::Reporter;
2424 use crate::{WORKDIR, auth_option, env, harness};
2525
26−const PLAN_FILE: &str = "/work/plan.json";
26+pub(crate) const PLAN_FILE: &str = "/work/plan.json";
2727
2828 const INSTRUCTIONS: &str = "You are planning work for a team of coding agents. The repository is checked out in the current directory. \
2929 Read enough of it to understand how it is built and tested. Do not modify it.
+1−1
2121 use crate::report::Reporter;
2222 use crate::{WORKDIR, auth_option, env, git, harness};
2323
24−const ANSWER_FILE: &str = "/work/answer.md";
24+pub(crate) const ANSWER_FILE: &str = "/work/answer.md";
2525 const MAX_ANSWER_CHARS: usize = 20_000;
2626
2727 const INSTRUCTIONS: &str = "Write your answer to /work/answer.md as Markdown, addressed to whoever asked: \
+1−1
2323 use crate::{WORKDIR, auth_option, env, git, harness};
2424
2525 const DIFF_FILE: &str = "/work/change.diff";
26−const REVIEW_FILE: &str = "/work/review.json";
26+pub(crate) const REVIEW_FILE: &str = "/work/review.json";
2727
2828 const INSTRUCTIONS: &str = "You are reviewing a pull request. The repository is checked out in the current directory at the pull request's head. \
2929 The change under review is in /work/change.diff; read it first, then read the surrounding code as needed. You may run the project's tests. Do not modify the repository.
+1−1
113113 "worker": "g1t-billing",
114114 "d1": { "database": "g1t-billing", "migrations": "migrations" },
115115 "stage": "core",
116− "secrets": ["STRIPE_SECRET_KEY", "CLOUDFLARE_USAGE_TOKEN"],
116+ "secrets": ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "CLOUDFLARE_USAGE_TOKEN"],
117117 "self_host": "run"
118118 },
119119 "integrations": {
+50−2
387387 Ratios over 1: something reaches Artifacts that g1t does not meter, such as sandboxes pushing
388388 directly with handed-out credentials.
389389 - Only days after the meters were deployed compare; before that only `git_operations` exists.
390−- Binding calls (`binding.*`) never appear among Cloudflare's event types; if Cloudflare says
391− they are billed, map them in `operation_mapping`.
390+- Binding calls do appear: Cloudflare's events include `read` and `token_create` actions (and
391+ `namespace_*`) besides the five documented ones. If Cloudflare says they are billed, map the
392+ `binding.*` meters in `operation_mapping`.
393+
394+A global API key works in place of the token: `CLOUDFLARE_API_KEY` with `CLOUDFLARE_EMAIL`
395+(both scripts).
396+
397+**Result, 2026-10-06** (31 days; g1t's meters cover only 2026-10-06, the day they shipped):
398+
399+| Cloudflare event | 31 days | 2026-10-06 | g1t's meters, 2026-10-06 |
400+| --- | --- | --- | --- |
401+| `read` | 137,225 | 107,616 | `binding.get` 85,206, `read_file` 49,846, `read_tree` 9,208, `read_blob` 4,964, `log` 2,798 |
402+| `pull` | 646 | 101 | `git.fetch` 29, `git.ls_refs` 26, `git.info_refs` 426 (+ 129 internal) |
403+| `push` | 385 | 10 | `git.receive_pack` 3 |
404+| `token_create` | 2,721 | 338 | `binding.create_token` 34 |
405+| `fork` / `create` / `delete` | 96 / 14 / 8 | 2 / 0 / 0 | |
406+| errors | 699 (688 client) | 476 client | |
407+
408+- `pull` is not one per upload-pack fetch: 101 pulls against 29 fetches on the one day both
409+ exist. Over 31 days `pull` ≈ fetch + ls-refs + info/refs (ratio 1.06), so listing refs likely
410+ counts as a pull. Not yet changed in `operation_mapping`: one day of meters is too little, and
411+ re-check after a week before setting `cost_operations` for `git.info_refs` and `git.ls_refs`.
412+- `read` is the open question that matters. If reads are billed as operations at $0.15 per
413+ 1,000, today's demo-scale traffic alone is about 3.2 million a month (~$480). Ask Cloudflare
414+ (Q1) before 2026-10-14. Either way the volume is mostly waste: every repos call opens a handle
415+ with `get` even when the answer is cached, and the object cache may not be hitting (no hit/miss
416+ meter yet). Fixes, ranked: lazy `get`; a real cache for objects named by hash (KV or an
417+ in-isolate LRU, with hit/miss meters); Actions reading workflows from the synced table instead
418+ of the store on every event; caller attribution in the meters.
419+- 476 client errors on 2026-10-06 are unexplained; the fetch fix below accounts for some (every
420+ failed negotiation was one).
392421
393422 ### R2: running and reading `scripts/ops/fork-storage-test.mjs`
394423
413442 Either way R2 retires forks; the answer decides `FORK_RETENTION_DAYS` (shared: a week is fine;
414443 copied: shorten it to 1 or 2 days and ask Cloudflare to raise the 1 TB account limit).
415444
445+**Result, 2026-10-06: forks are stored and billed as copies.** A 100 MB source took 57 s to
446+push; each of 5 forks took 4–6 s. The storage dataset (`artifactsStorageAdaptiveGroups`,
447+`max.repositorySizeBytes`) gave every fork the source's full 105,582,592 bytes: about 633 MB for
448+the six, not about 106 MB. Whatever Artifacts shares underneath, storage billing and the 1 TB
449+account limit see full copies. So:
450+
451+- `FORK_RETENTION_DAYS` should drop from 7 to 1 (not yet changed).
452+- g1t meters a workspace's storage once per repository (`stored_bytes`), so an open pull
453+ request's working copy is Cloudflare cost g1t absorbs: about $0.05 a month per 100 MB per open
454+ pull request. Small now; decide whether open working copies count toward a workspace's
455+ storage before agent pull requests reach thousands.
456+- Ask Cloudflare whether forks share objects physically, and for a higher account limit.
457+
458+Also found while testing (fixed in `git_http.rs`): the store answers a protocol v2 fetch that is
459+still negotiating, and whose `have`s it does not know, with `acknowledgments`, `NAK`, then a pack.
460+git refuses that ("expected no other sections to be sent after no 'ready'"). g1t now ends such
461+an answer after the acknowledgments with a flush, and the client negotiates again. Report it to
462+Cloudflare.
463+
416464 ### R7: making more namespaces (yours to run, when needed)
417465
418466 ```sh
+51−0
281281 `breaker_lifted`). It resets by itself at 00:00 UTC.
282282 - **Change a default**: edit the variable in `services/billing/wrangler.jsonc`
283283 and deploy g1t-billing.
284+
285+## Stripe
286+
287+Billing keeps what it needs from Stripe so reads never wait on it, and
288+hears of changes three ways (`webhooks.rs`, `stripe_sync.rs`).
289+
290+**The webhook.** A destination made in Stripe's dashboard (Developers →
291+Webhooks → Add destination) with the endpoint URL
292+`https://api.g1t.sh/stripe/webhook`, in the mode of billing's key (at
293+launch, make one in live mode and put its secret). Its signing secret
294+(`whsec_…`: the destination, Signing secret, Reveal) is the billing
295+Worker's secret:
296+
297+```sh
298+cd services/billing && npx wrangler secret put STRIPE_WEBHOOK_SECRET
299+```
300+
301+Without it every event is refused with 400. After rolling the secret in
302+Stripe, put the new one; during the roll Stripe signs with both, so there
303+is no gap. The event list need not be exact: billing adds any event it
304+handles that the destination does not send (daily, or **Fix destination**
305+in sudo → Stripe), and enables it again if Stripe disabled it. It never
306+changes the secret. sudo → Stripe shows whether the secret is set, the
307+destination and its status, missing events, and the latest events.
308+Events are claimed once each in `stripe_events`; a handler that fails
309+forgets its claim, and Stripe retries.
310+
311+**What is kept, and how it stays current**
312+
313+| Kept | Where | Refreshed by |
314+| --- | --- | --- |
315+| Saved card (brand, last 4, expiry) | `accounts.card_*`, `card_synced_at` | `customer.updated`, `payment_method.*`, `setup_intent.succeeded`; forgotten when g1t sets a default card, so the next read asks once; the daily pass for cards older than 7 days |
316+| Plans | `subscriptions` | `customer.subscription.*`, `invoice.*`; a read asks Stripe at most hourly once a period is over; the daily pass for rows older than a day |
317+| Payments, refunds, disputes | ledger, `checkouts`, invoices | their events |
318+
319+**Every cron run (every 15 minutes)** replays missed events: Stripe's event
320+list from an hour before `stripe_sync.through`, oldest first, through the
321+same once-only claim. `through` moves to 5 minutes before now when all were
322+handled, back to the first failure otherwise, and stays when more than
323+1,000 events were listed. Claims stuck at `handling` for 10 minutes are
324+dropped so the replay retries them. The first run reads 3 days back.
325+
326+**Daily** (`keeper::DAILY`): the destination at billing's address is
327+enabled again if Stripe disabled it and given any missing event, audited as
328+`stripe`/`webhook`; then up to 25 stale cards and 25 stale plans are read
329+again.
330+
331+**What still calls Stripe on a request**: starting a payment page, a plan
332+or a card check; opening the billing portal; settling a page the person
333+came back from; renaming a workspace (the customer's name). Nothing a page
334+view reads.
+4−2
562562
563563 1. `node scripts/runner-release.mjs keygen`. Put `RUNNER_RELEASE_KEY` in the
564564 repository's secrets (production environment) and keep a copy offline;
565− put `G1T_RUNNER_RELEASE_KEY` in its variables. A build made without the
566− public key never updates itself.
565+ put the public key in its variables as `RUNNER_RELEASE_PUBLIC_KEY` (names
566+ starting `G1T_` are reserved; the workflow hands it to the build as
567+ `G1T_RUNNER_RELEASE_KEY`). A build made without the public key never
568+ updates itself.
567569 2. `npx wrangler r2 bucket create g1t-downloads`, and deploy the site so it
568570 has the `DOWNLOADS` binding.
569571 3. Set the variables `RUNNER_IMAGE` (the image's name in a public registry),
+6−3
1313 "devDependencies": {
1414 "typescript": "^5.9.3",
1515 "wrangler": "^4.146.0"
16+ },
17+ "engines": {
18+ "node": ">=22.22.0"
1619 }
1720 },
1821 "apps/docs": {
69316934 }
69326935 },
69336936 "node_modules/http-cache-semantics": {
6934− "version": "4.2.0",
6935− "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
6936− "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
6937+ "version": "4.3.0",
6938+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz",
6939+ "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==",
69376940 "license": "BSD-2-Clause"
69386941 },
69396942 "node_modules/http-proxy-agent": {
+8−2
342342 export type StripeStatus = {
343343 /** `test` or `live`, from the key; `off` without one. */
344344 mode: "test" | "live" | "off" | string;
345− webhook: { url: string; endpointId: string; events: string[]; createdBy: string; createdAt: string } | null;
345+ /** Whether `STRIPE_WEBHOOK_SECRET` is set, so events can be checked. */
346+ secretSet: boolean;
347+ /** The destination at billing's address in Stripe, as Stripe has it. */
348+ webhook: { url: string; endpointId: string; status: "enabled" | "disabled" | string; events: string[]; createdAt: string } | null;
349+ /** Events billing handles that the destination does not send. */
350+ missingEvents: string[];
346351 recentEvents: { id: string; kind: string; outcome: string; receivedAt: string }[];
347352 error: string | null;
348353 };
513518 /** The workspace's Stripe billing page, to send to the customer. Logged. */
514519 billingLink(workspace: string, by: string): Promise<Result<BillingLink>>;
515520 /** Where billing stands with Stripe; with `setup`, registers the webhook first. */
516− stripe(setup?: boolean, by?: string): Promise<StripeStatus>;
521+ /** Stripe's state for billing; `fix` enables the destination and adds missing events first. */
522+ stripe(fix?: boolean, by?: string): Promise<StripeStatus>;
517523 /** Where an enterprise's invoices go; makes its Stripe customer. */
518524 enterpriseBilling(id: string, email: string, by: string): Promise<Result<PayingAccount>>;
519525 /** Sends an enterprise its invoice now, for what its workspaces owe. */
+1−1
421421 attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }),
422422 credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }),
423423 billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }),
424− stripe: (setup = false, by) => call("admin_stripe", { setup, by: by ?? null }),
424+ stripe: (fix = false, by) => call("admin_stripe", { fix, by: by ?? null }),
425425 enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }),
426426 invoiceEnterprise: (id, by) => call("admin_invoice_enterprise", { id, by }),
427427 accountsFor: (workspaces) => call("admin_accounts", { query: null, workspaces }),
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.