Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
14 commits
- Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboardChase Piercecfc1609
- The docs' top bar is the app's: 4rem tall with a line under it, above the section tabsChase Pierced5d554f
- Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint keptChase Piercea75b1a4
- Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time indexChase Pierced5df0de
- Deploys print no DEP0190: npm's commands go to the shell as one stringChase Piercee57ddea
- Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0Chase Pierce53503e0
- The sidebar asks billing whether it is on, not for the account, so no page waits on the card processorChase Pierce72f0aa1
- g1t-runner 0.1.0 is released: signed binaries for five platforms at g1t.sh/downloads/runner; the Artifacts checks' resultsChase Pierce36fb043
- Usage lists Other once: kinds of work without words of their own are added into itChase Pierceb9a8376
- A click away from a live page arrives: pages stop refreshing themselves while a navigation is pendingChase Pierce5916a7e
- Reviews, plans and replies are written where the guardrail allows: g1t's answer files are inside itChase Pierceec6f51e
- The early answer ends with a flush only; git's HTTP transport adds the response-end packet itselfChase Piercec50c0e3
- A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expectsChase Pierce2245712
- The runner base keeps its old comment, so the pushed base image still matches its folderChase Pierce70ff875
| 48 | 48 | key: runner-release-${{ hashFiles('Cargo.lock') }} | |
| 49 | 49 | - name: Build every platform | |
| 50 | 50 | env: | |
| 51 | − | G1T_RUNNER_RELEASE_KEY: ${{ vars.G1T_RUNNER_RELEASE_KEY }} | |
| 51 | + | G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }} | |
| 52 | 52 | RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }} | |
| 53 | 53 | run: node scripts/runner-release.mjs build | |
| 54 | 54 | - name: Sign | |
| 55 | 55 | env: | |
| 56 | 56 | 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 }} | |
| 58 | 58 | run: | | |
| 59 | 59 | node scripts/runner-release.mjs sign | |
| 60 | 60 | node scripts/runner-release.mjs verify |
| 909 | 909 | name = "g1t-billing" | |
| 910 | 910 | version = "0.1.0" | |
| 911 | 911 | dependencies = [ | |
| 912 | + | "futures-util", | |
| 912 | 913 | "g1t-contracts", | |
| 913 | 914 | "g1t-kit", | |
| 914 | 915 | "getrandom 0.2.17", |
| 7 | 7 | - **Collaborate.** Git over HTTPS, public and private repositories, issues, | |
| 8 | 8 | pull requests, line comments and reviews, protected branches, workspaces, | |
| 9 | 9 | 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 | |
| 11 | 11 | connect Claude Code, Codex, OpenCode or Cursor over MCP. Hand g1t an | |
| 12 | 12 | outcome and a planner splits it into issues with dependencies that agents | |
| 13 | 13 | take up as they unblock. Agents see what the others are changing, ask each | |
| 70 | 70 | - Overlap: each pull request shows which others in progress change the | |
| 71 | 71 | same files, while the work is still going on. | |
| 72 | 72 | - 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 | |
| 75 | 75 | a verdict. | |
| 76 | 76 | - Importing a public repository from any git host by its address, and | |
| 77 | 77 | public or private repositories through g1t's GitHub App, imported once, |
| 26 | 26 | flex-direction: column; | |
| 27 | 27 | height: 100%; | |
| 28 | 28 | } | |
| 29 | + | /* The same bar as the app's: 4rem, and a line under it. */ | |
| 29 | 30 | .g1t-header-top { | |
| 30 | − | height: 3.5rem; | |
| 31 | + | box-sizing: border-box; | |
| 32 | + | height: 4rem; | |
| 33 | + | flex-shrink: 0; | |
| 31 | 34 | padding: 0 var(--sl-nav-pad-x); | |
| 35 | + | border-bottom: 1px solid var(--g1t-line); | |
| 32 | 36 | } | |
| 33 | 37 | /* On phones the menu button sits at the top row's end: leave it room. */ | |
| 34 | 38 | @media (max-width: 49.99rem) { |
| 9 | 9 | on it, register it once, and it picks up jobs that ask for it with | |
| 10 | 10 | `runs-on: self-hosted`. | |
| 11 | 11 | ||
| 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 | − | ||
| 18 | 12 | - **Time on your runners is $0**, on every plan, including the free one. | |
| 19 | 13 | It shows on [usage](/guides/usage-and-billing/) as self-hosted minutes. | |
| 20 | 14 | - **It only connects out.** The runner polls `api.g1t.sh` over HTTPS for | |
| 181 | 175 | your own [model provider](/guides/models/), g1t charges nothing for the | |
| 182 | 176 | run; otherwise the model is paid as usual and the machine is free. | |
| 183 | 177 | - 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. | |
| 187 | 181 | - [Guardrails](/guides/guardrails/) still apply to what the agent does, but | |
| 188 | 182 | their network list cannot be enforced on your machine. The run's session | |
| 189 | 183 | says so when it starts. | |
| 232 | 226 | ||
| 233 | 227 | ## Docker and Kubernetes | |
| 234 | 228 | ||
| 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 | + | ||
| 235 | 234 | The runner's image runs `register-and-run`, which registers once and then | |
| 236 | 235 | runs. Each option can also come from `G1T_RUNNER_<OPTION>` in the | |
| 237 | 236 | environment, such as `G1T_RUNNER_TOKEN` and `G1T_RUNNER_LABELS`, so a |
| 37 | 37 | --sl-text-body: 0.9375rem; | |
| 38 | 38 | --sl-line-height: 1.75; | |
| 39 | 39 | --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; | |
| 42 | 43 | --sl-sidebar-width: 17rem; | |
| 43 | 44 | ||
| 44 | 45 | color-scheme: dark; | |
| 62 | 63 | } | |
| 63 | 64 | /* On phones the menu button sits in the header's top row, not its middle. */ | |
| 64 | 65 | .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; | |
| 66 | 67 | } | |
| 67 | 68 | site-search button[data-open-modal] { | |
| 68 | 69 | border-radius: 0.5rem; |
| 1 | 1 | import { Webhook } from "lucide-react"; | |
| 2 | − | import { Link } from "react-router"; | |
| 3 | 2 | ||
| 4 | 3 | import type { StripeStatus } from "@g1t/contracts"; | |
| 5 | 4 | ||
| 6 | 5 | import type { Route } from "./+types/stripe"; | |
| 7 | 6 | import { Badge, Button, EmptyState, Notice, Section, When } from "~/components/ui"; | |
| 8 | − | import { text } from "~/lib/forms"; | |
| 9 | 7 | import { admin } from "~/lib/services.server"; | |
| 10 | 8 | import { requireStaff } from "~/lib/staff"; | |
| 11 | 9 | ||
| 16 | 14 | return { status: await admin.stripe() }; | |
| 17 | 15 | } | |
| 18 | 16 | ||
| 19 | − | type ActionData = { review: true } | { status: StripeStatus; registered: true }; | |
| 17 | + | type ActionData = { status: StripeStatus; fixed: true }; | |
| 20 | 18 | ||
| 21 | − | export async function action({ request, context }: Route.ActionArgs) { | |
| 19 | + | export async function action({ context }: Route.ActionArgs) { | |
| 22 | 20 | 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; | |
| 27 | 22 | } | |
| 28 | 23 | ||
| 29 | 24 | const MODE: Record<string, { label: string; tone: "warn" | "mint" | "danger" }> = { | |
| 32 | 27 | off: { label: "Off", tone: "danger" }, | |
| 33 | 28 | }; | |
| 34 | 29 | ||
| 30 | + | const WEBHOOK_URL = "https://api.g1t.sh/stripe/webhook"; | |
| 31 | + | ||
| 35 | 32 | export default function Stripe({ loaderData, actionData }: Route.ComponentProps) { | |
| 36 | 33 | 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; | |
| 40 | 36 | const mode = MODE[status.mode] ?? { label: status.mode, tone: "warn" as const }; | |
| 41 | 37 | 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; | |
| 43 | 40 | ||
| 44 | 41 | return ( | |
| 45 | 42 | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 46 | 43 | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 47 | 44 | <div> | |
| 48 | 45 | <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> | |
| 50 | 47 | </div> | |
| 51 | 48 | <Badge tone={mode.tone}>{mode.label}</Badge> | |
| 52 | 49 | </div> | |
| 53 | 50 | ||
| 54 | 51 | <div className="mt-6 space-y-3"> | |
| 55 | 52 | {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>} | |
| 77 | 56 | </div> | |
| 78 | 57 | ||
| 79 | 58 | <Section | |
| 80 | 59 | 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." | |
| 82 | 61 | className="mt-6" | |
| 83 | 62 | 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"> | |
| 88 | 66 | <Webhook size={14} /> | |
| 89 | − | {verb} webhook | |
| 67 | + | Fix destination | |
| 90 | 68 | </Button> | |
| 91 | 69 | </form> | |
| 92 | 70 | ) : null | |
| 93 | 71 | } | |
| 94 | 72 | > | |
| 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> | |
| 117 | 125 | </Section> | |
| 118 | 126 | ||
| 119 | 127 | <Section title="Recent events" description="What Stripe sent, newest first, and what billing did with it." className="mt-6"> |
| 6 | 6 | */ | |
| 7 | 7 | import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react"; | |
| 8 | 8 | 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"; | |
| 10 | 11 | ||
| 11 | 12 | import { | |
| 12 | 13 | type AgentRun, | |
| 41 | 42 | ||
| 42 | 43 | /** Revalidates the page every few seconds while `live`, as the merge queue does. */ | |
| 43 | 44 | 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); | |
| 52 | 46 | } | |
| 53 | 47 | ||
| 54 | 48 | export function formatCost(usd: number | null | undefined): string | null { |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | + | foldTasks, | |
| 5 | 6 | gigabytes, | |
| 6 | 7 | shownMeters, | |
| 7 | 8 | alertText, | |
| 234 | 235 | assert.equal(gigabytes(1_500_000_000), "1.5 GB"); | |
| 235 | 236 | assert.equal(gigabytes(500_000_000), "500 MB"); | |
| 236 | 237 | }); | |
| 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 | + | }); |
| 270 | 270 | return USAGE_TASKS[key] ?? USAGE_TASKS.other!; | |
| 271 | 271 | } | |
| 272 | 272 | ||
| 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 | + | ||
| 273 | 288 | /** The workspace's month at a glance, for the Usage card on its overview. */ | |
| 274 | 289 | export type UsageGlance = { | |
| 275 | 290 | /** |
| 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 | + | } |
| 138 | 138 | // something (lib/cache.server.ts). | |
| 139 | 139 | const kept = <T,>(what: string, load: () => Promise<T>) => | |
| 140 | 140 | 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([ | |
| 142 | 142 | kept("projects", () => projects.list(workspace!.slug, user)), | |
| 143 | 143 | 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), | |
| 145 | 147 | kept(`usage:${monthStart}`, () => billing.usage(workspace!.slug, user, monthStart)), | |
| 146 | 148 | kept("limit", () => billing.limit(workspace!.slug, user)).catch(() => null), | |
| 147 | 149 | kept("entitlements", () => billing.entitlements(workspace!.slug)).catch(() => null), | |
| 170 | 172 | : null, | |
| 171 | 173 | // Where the workspace stands against its usage limit, once billing is on. | |
| 172 | 174 | limit: | |
| 173 | − | account?.ok && account.value.status.enabled && limit?.ok | |
| 175 | + | status?.enabled && limit?.ok | |
| 174 | 176 | ? { | |
| 175 | 177 | exposureMicros: limit.value.exposureMicros, | |
| 176 | 178 | ceilingMicros: limit.value.ceilingMicros, | |
| 178 | 180 | comped: limit.value.trust === "internal", | |
| 179 | 181 | } | |
| 180 | 182 | : null, | |
| 181 | − | free: account?.ok ? Boolean(account.value.status.free) : false, | |
| 183 | + | free: Boolean(status?.free), | |
| 182 | 184 | // A spend spike or a hold pauses new compute: the shell says so on every page. | |
| 183 | 185 | compute: | |
| 184 | 186 | workspace && entitlements && (entitlements.paused || entitlements.spike?.status === "open") |
| 1 | 1 | 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"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Annotation, Job, StepState } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 12 | 12 | import { actions } from "../../lib/services.server"; | |
| 13 | 13 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 14 | 14 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 15 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 15 | 16 | ||
| 16 | 17 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 17 | 18 | const run = loaderData?.detail.run; | |
| 170 | 171 | const base = `/${params.owner}/${params.repo}`; | |
| 171 | 172 | const [search] = useSearchParams(); | |
| 172 | 173 | const busy = useNavigation().state === "submitting"; | |
| 173 | − | const revalidator = useRevalidator(); | |
| 174 | 174 | 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); | |
| 182 | 176 | ||
| 183 | 177 | // The job asked for, else one that failed, is running, or the first. | |
| 184 | 178 | const selected = |
| 1 | 1 | 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"; | |
| 4 | 4 | ||
| 5 | 5 | import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 15 | 15 | import { actions } from "../../lib/services.server"; | |
| 16 | 16 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 17 | 17 | import { accessFor, refusal, repoFor } from "../../lib/access.server"; | |
| 18 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 18 | 19 | ||
| 19 | 20 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 20 | 21 | return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` }); | |
| 78 | 79 | ||
| 79 | 80 | /** Re-reads the page every few seconds while something is still running. */ | |
| 80 | 81 | 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); | |
| 89 | 83 | } | |
| 90 | 84 | ||
| 91 | 85 | const EVENT_WORDS: Record<string, string> = { |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | 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"; | |
| 5 | 4 | ||
| 6 | 5 | import { type Pull, PROVIDERS, workOwner } from "@g1t/contracts"; | |
| 7 | 6 | ||
| 41 | 40 | import { identity, integrations, work } from "../../lib/services.server"; | |
| 42 | 41 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 43 | 42 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 43 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 44 | 44 | ||
| 45 | − | const REFRESH_MS = 4000; | |
| 46 | 45 | ||
| 47 | 46 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 48 | 47 | const issue = loaderData?.issue; | |
| 236 | 235 | const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData; | |
| 237 | 236 | ||
| 238 | 237 | // Follow agents at work without a manual reload. | |
| 239 | − | const revalidator = useRevalidator(); | |
| 240 | 238 | const navigation = useNavigation(); | |
| 241 | 239 | const running = pulls.some( | |
| 242 | 240 | (pull) => | |
| 253 | 251 | pull.runtime === "hosted" && | |
| 254 | 252 | (pull.status === "draft" || pull.status === "open"), | |
| 255 | 253 | ); | |
| 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)); | |
| 263 | 255 | ||
| 264 | 256 | const starting = navigation.formData?.get("action") === "run-hosted"; | |
| 265 | 257 | const base = `/${params.owner}/${params.repo}`; |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import type { G1tEvent } from "@g1t/contracts"; | |
| 3 | 3 | 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"; | |
| 6 | 5 | ||
| 7 | 6 | import type { Route } from "./+types/plan"; | |
| 8 | 7 | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 21 | 20 | requireUser, | |
| 22 | 21 | unwrap, | |
| 23 | 22 | } from "../../lib/session.server"; | |
| 23 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 24 | 24 | ||
| 25 | − | const REFRESH_MS = 4000; | |
| 26 | 25 | ||
| 27 | 26 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 28 | 27 | return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` }); | |
| 95 | 94 | const applying = useNavigation().state === "submitting"; | |
| 96 | 95 | ||
| 97 | 96 | // The agent is still writing it. | |
| 98 | − | const revalidator = useRevalidator(); | |
| 99 | 97 | const planning = plan.status === "planning"; | |
| 100 | 98 | // Planning, or agents still converging what was applied. | |
| 101 | 99 | const moving = | |
| 102 | 100 | 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); | |
| 110 | 102 | const converging = plan.status === "applied" && plan.progress.length > 0; | |
| 111 | 103 | ||
| 112 | 104 | const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length; |
| 23 | 23 | User, | |
| 24 | 24 | Wrench, | |
| 25 | 25 | } 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"; | |
| 28 | 28 | ||
| 29 | 29 | import { | |
| 30 | 30 | type Capability, | |
| 76 | 76 | import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server"; | |
| 77 | 77 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 78 | 78 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 79 | + | import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh"; | |
| 79 | 80 | ||
| 80 | − | const REFRESH_MS = 4000; | |
| 81 | 81 | const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false }; | |
| 82 | 82 | const TABS = ["conversation", "session", "changes"] as const; | |
| 83 | 83 | type Tab = (typeof TABS)[number]; | |
| 561 | 561 | const active = pull.status === "draft" || pull.status === "open"; | |
| 562 | 562 | ||
| 563 | 563 | // Follow an agent at work, or checks in progress, without a manual reload. | |
| 564 | − | const revalidator = useRevalidator(); | |
| 565 | 564 | const working = pull.status === "draft"; | |
| 566 | 565 | const checking = statuses.some((status) => status.state === "pending"); | |
| 567 | 566 | const reviews = verdicts(comments); | |
| 601 | 600 | // Whether it merges cleanly is being worked out, so the box will change. | |
| 602 | 601 | const probing = active && mergeable === "checking"; | |
| 603 | 602 | 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); | |
| 611 | 604 | // Why the merge button cannot be pressed, if it cannot. | |
| 612 | 605 | const mergeBlocked = conflicting | |
| 613 | 606 | ? "Resolve the conflicts first." |
| 8 | 8 | Minus, | |
| 9 | 9 | X, | |
| 10 | 10 | } from "lucide-react"; | |
| 11 | − | import { useEffect } from "react"; | |
| 12 | − | import { Link, useRevalidator } from "react-router"; | |
| 11 | + | import { Link } from "react-router"; | |
| 13 | 12 | ||
| 14 | 13 | import type { QueueEntry, QueueState } from "@g1t/contracts"; | |
| 15 | 14 | ||
| 19 | 18 | import { work } from "../../lib/services.server"; | |
| 20 | 19 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 21 | 20 | import { accessFor, repoFor } from "../../lib/access.server"; | |
| 21 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 22 | 22 | ||
| 23 | − | const REFRESH_MS = 4000; | |
| 24 | 23 | ||
| 25 | 24 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 26 | 25 | return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` }); | |
| 149 | 148 | export default function Queue({ loaderData, params }: Route.ComponentProps) { | |
| 150 | 149 | const { queue, defaultBranch, member } = loaderData; | |
| 151 | 150 | const base = `/${params.owner}/${params.repo}`; | |
| 152 | − | const revalidator = useRevalidator(); | |
| 153 | 151 | 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); | |
| 161 | 153 | ||
| 162 | 154 | const testing = queue.active.filter((entry) => entry.state !== "waiting").length; | |
| 163 | 155 | return ( |
| 1 | 1 | import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation, useRevalidator } from "react-router"; | |
| 3 | + | import { Form, Link, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 23 | 23 | import { deployments } from "../../lib/services.server"; | |
| 24 | 24 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 25 | 25 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 26 | + | import { useRefreshWhile } from "../../lib/refresh"; | |
| 26 | 27 | ||
| 27 | 28 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 28 | 29 | return page(args, { title: `Domains · ${params.owner}/${params.repo} · g1t` }); | |
| 106 | 107 | }, [added, actionData]); | |
| 107 | 108 | ||
| 108 | 109 | // While a domain is on its way, the page follows it. | |
| 109 | − | const revalidator = useRevalidator(); | |
| 110 | 110 | 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); | |
| 118 | 112 | ||
| 119 | 113 | // A redirecting domain is shown under the one it redirects to. | |
| 120 | 114 | const primary = domains.filter((d) => !d.redirectTo || !domains.some((p) => p.hostname === d.redirectTo)); |
| 4 | 4 | import { MICROS_PER_DOLLAR, type UsageSlice } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/usage"; | |
| 7 | − | import { usageTask } from "../../lib/billing"; | |
| 7 | + | import { foldTasks, usageTask } from "../../lib/billing"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { ButtonLink } from "../../components/ui"; | |
| 10 | 10 | import { billing } from "../../lib/services.server"; | |
| 181 | 181 | ||
| 182 | 182 | export default function UsagePage({ loaderData, params }: Route.ComponentProps) { | |
| 183 | 183 | const { period, since, usage, account } = loaderData; | |
| 184 | + | const byTask = foldTasks(usage.byTask); | |
| 184 | 185 | const base = `/${params.owner}`; | |
| 185 | 186 | const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000)); | |
| 186 | 187 | // While g1t is free nothing is charged, so usage is measured at cost | |
| 257 | 258 | <div className="flex flex-wrap items-baseline justify-between gap-3"> | |
| 258 | 259 | <h3 className="text-sm font-medium">{usage.free ? "Usage per day" : "Spend per day"}</h3> | |
| 259 | 260 | <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted"> | |
| 260 | − | {usage.byTask.map((slice) => ( | |
| 261 | + | {byTask.map((slice) => ( | |
| 261 | 262 | <li key={slice.key} className="flex items-center gap-1.5"> | |
| 262 | 263 | <span className="size-2 rounded-sm" style={{ background: task(slice.key).color }} /> | |
| 263 | 264 | {task(slice.key).label} | |
| 273 | 274 | <div className="grid gap-4 lg:grid-cols-2"> | |
| 274 | 275 | <Breakdown | |
| 275 | 276 | title="By kind of work" | |
| 276 | − | slices={usage.byTask} | |
| 277 | + | slices={byTask} | |
| 277 | 278 | total={total} | |
| 278 | 279 | label={(key) => task(key).label} | |
| 279 | 280 | color={(key) => task(key).color} |
| 1444 | 1444 | } | |
| 1445 | 1445 | ||
| 1446 | 1446 | /// `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. | |
| 1449 | 1449 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 1450 | 1450 | pub struct AdminStripeArgs { | |
| 1451 | 1451 | #[serde(default)] | |
| 1452 | − | pub setup: bool, | |
| 1452 | + | pub fix: bool, | |
| 1453 | 1453 | #[serde(default)] | |
| 1454 | 1454 | pub by: Option<String>, | |
| 1455 | 1455 | } | |
| 1459 | 1459 | pub struct StripeStatus { | |
| 1460 | 1460 | /// `test` or `live`, from the key; `off` without one. | |
| 1461 | 1461 | 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. | |
| 1462 | 1465 | pub webhook: Option<StripeWebhook>, | |
| 1466 | + | /// Events billing handles that the destination does not send. | |
| 1467 | + | pub missing_events: Vec<String>, | |
| 1463 | 1468 | /// The latest events handled, newest first. | |
| 1464 | 1469 | 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. | |
| 1466 | 1471 | pub error: Option<String>, | |
| 1467 | 1472 | } | |
| 1468 | 1473 | ||
| 1471 | 1476 | pub struct StripeWebhook { | |
| 1472 | 1477 | pub url: String, | |
| 1473 | 1478 | pub endpoint_id: String, | |
| 1479 | + | /// `enabled` or `disabled`. | |
| 1480 | + | pub status: String, | |
| 1474 | 1481 | pub events: Vec<String>, | |
| 1475 | − | pub created_by: String, | |
| 1476 | 1482 | pub created_at: String, | |
| 1477 | 1483 | } | |
| 1478 | 1484 |
| 417 | 417 | format!("/{}", parts.join("/")) | |
| 418 | 418 | } | |
| 419 | 419 | ||
| 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. | |
| 422 | 426 | fn inside_allowed(path: &str, place: &Place) -> bool { | |
| 427 | + | if ANSWER_FILES.contains(&path) { | |
| 428 | + | return true; | |
| 429 | + | } | |
| 423 | 430 | let roots = [ | |
| 424 | 431 | crate::WORKDIR.to_owned(), | |
| 425 | 432 | "/tmp".to_owned(), | |
| 814 | 821 | } | |
| 815 | 822 | ||
| 816 | 823 | #[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] | |
| 817 | 836 | fn force_pushes_are_refused_however_written() { | |
| 818 | 837 | let policy = all_on(); | |
| 819 | 838 | for command in [ |
| 23 | 23 | use crate::report::Reporter; | |
| 24 | 24 | use crate::{WORKDIR, auth_option, env, harness}; | |
| 25 | 25 | ||
| 26 | − | const PLAN_FILE: &str = "/work/plan.json"; | |
| 26 | + | pub(crate) const PLAN_FILE: &str = "/work/plan.json"; | |
| 27 | 27 | ||
| 28 | 28 | const INSTRUCTIONS: &str = "You are planning work for a team of coding agents. The repository is checked out in the current directory. \ | |
| 29 | 29 | Read enough of it to understand how it is built and tested. Do not modify it. |
| 21 | 21 | use crate::report::Reporter; | |
| 22 | 22 | use crate::{WORKDIR, auth_option, env, git, harness}; | |
| 23 | 23 | ||
| 24 | − | const ANSWER_FILE: &str = "/work/answer.md"; | |
| 24 | + | pub(crate) const ANSWER_FILE: &str = "/work/answer.md"; | |
| 25 | 25 | const MAX_ANSWER_CHARS: usize = 20_000; | |
| 26 | 26 | ||
| 27 | 27 | const INSTRUCTIONS: &str = "Write your answer to /work/answer.md as Markdown, addressed to whoever asked: \ |
| 23 | 23 | use crate::{WORKDIR, auth_option, env, git, harness}; | |
| 24 | 24 | ||
| 25 | 25 | 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"; | |
| 27 | 27 | ||
| 28 | 28 | const INSTRUCTIONS: &str = "You are reviewing a pull request. The repository is checked out in the current directory at the pull request's head. \ | |
| 29 | 29 | 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. |
| 113 | 113 | "worker": "g1t-billing", | |
| 114 | 114 | "d1": { "database": "g1t-billing", "migrations": "migrations" }, | |
| 115 | 115 | "stage": "core", | |
| 116 | − | "secrets": ["STRIPE_SECRET_KEY", "CLOUDFLARE_USAGE_TOKEN"], | |
| 116 | + | "secrets": ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "CLOUDFLARE_USAGE_TOKEN"], | |
| 117 | 117 | "self_host": "run" | |
| 118 | 118 | }, | |
| 119 | 119 | "integrations": { |
| 387 | 387 | Ratios over 1: something reaches Artifacts that g1t does not meter, such as sandboxes pushing | |
| 388 | 388 | directly with handed-out credentials. | |
| 389 | 389 | - 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). | |
| 392 | 421 | ||
| 393 | 422 | ### R2: running and reading `scripts/ops/fork-storage-test.mjs` | |
| 394 | 423 | ||
| 413 | 442 | Either way R2 retires forks; the answer decides `FORK_RETENTION_DAYS` (shared: a week is fine; | |
| 414 | 443 | copied: shorten it to 1 or 2 days and ask Cloudflare to raise the 1 TB account limit). | |
| 415 | 444 | ||
| 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 | + | ||
| 416 | 464 | ### R7: making more namespaces (yours to run, when needed) | |
| 417 | 465 | ||
| 418 | 466 | ```sh |
| 281 | 281 | `breaker_lifted`). It resets by itself at 00:00 UTC. | |
| 282 | 282 | - **Change a default**: edit the variable in `services/billing/wrangler.jsonc` | |
| 283 | 283 | 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. |
| 562 | 562 | ||
| 563 | 563 | 1. `node scripts/runner-release.mjs keygen`. Put `RUNNER_RELEASE_KEY` in the | |
| 564 | 564 | 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. | |
| 567 | 569 | 2. `npx wrangler r2 bucket create g1t-downloads`, and deploy the site so it | |
| 568 | 570 | has the `DOWNLOADS` binding. | |
| 569 | 571 | 3. Set the variables `RUNNER_IMAGE` (the image's name in a public registry), |
| 13 | 13 | "devDependencies": { | |
| 14 | 14 | "typescript": "^5.9.3", | |
| 15 | 15 | "wrangler": "^4.146.0" | |
| 16 | + | }, | |
| 17 | + | "engines": { | |
| 18 | + | "node": ">=22.22.0" | |
| 16 | 19 | } | |
| 17 | 20 | }, | |
| 18 | 21 | "apps/docs": { | |
| 6931 | 6934 | } | |
| 6932 | 6935 | }, | |
| 6933 | 6936 | "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==", | |
| 6937 | 6940 | "license": "BSD-2-Clause" | |
| 6938 | 6941 | }, | |
| 6939 | 6942 | "node_modules/http-proxy-agent": { |
| 342 | 342 | export type StripeStatus = { | |
| 343 | 343 | /** `test` or `live`, from the key; `off` without one. */ | |
| 344 | 344 | 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[]; | |
| 346 | 351 | recentEvents: { id: string; kind: string; outcome: string; receivedAt: string }[]; | |
| 347 | 352 | error: string | null; | |
| 348 | 353 | }; | |
| 513 | 518 | /** The workspace's Stripe billing page, to send to the customer. Logged. */ | |
| 514 | 519 | billingLink(workspace: string, by: string): Promise<Result<BillingLink>>; | |
| 515 | 520 | /** 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>; | |
| 517 | 523 | /** Where an enterprise's invoices go; makes its Stripe customer. */ | |
| 518 | 524 | enterpriseBilling(id: string, email: string, by: string): Promise<Result<PayingAccount>>; | |
| 519 | 525 | /** Sends an enterprise its invoice now, for what its workspaces owe. */ |
| 421 | 421 | attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }), | |
| 422 | 422 | credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }), | |
| 423 | 423 | 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 }), | |
| 425 | 425 | enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }), | |
| 426 | 426 | invoiceEnterprise: (id, by) => call("admin_invoice_enterprise", { id, by }), | |
| 427 | 427 | accountsFor: (workspaces) => call("admin_accounts", { query: null, workspaces }), |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.