flagon-io/g1t

public

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

Commit

Mission control shows where you are needed and what agents landed without you; git answers in about 200ms

- Mission control: what needs you, what agents are working on and what landed today, each with the ask, what the agent already knows and why it needs you, a week of landed-by-agents against needed-a-person, and activity that tells agents from people. - Git: the repository is read once, store credentials are kept across isolates (encrypted), and branch listings are cached per repository version, bumped by every ref writer; Server-Timing shows where a request's time went.

syntaqxcommitted Parentfc9b9e1Browse files
24 files+3654−8970/24 viewed
+1−0
991991 "g1t-contracts",
992992 "g1t-kit",
993993 "g1t-scan",
994+ "g1t-secrets",
994995 "serde",
995996 "serde_json",
996997 "similar",
+37−0
125125 until the month turns. Counting starts on 2026-10-14. See
126126 [git operations](/guides/usage-and-billing/#git-operations).
127127
128+## Where a slow request's time went
129+
130+Every answer g1t gives git carries a `Server-Timing` header: how many
131+milliseconds each step of the request took. To see it, run git with its
132+HTTP trace on:
133+
134+```sh
135+GIT_TRACE_CURL=1 git ls-remote https://g1t.sh/<owner>/<repo>.git 2>&1 | grep -i server-timing
136+```
137+
138+| Step | What it is |
139+| --- | --- |
140+| `repo` | Finding the repository, and checking your credentials if you sent any |
141+| `moved` | Only for an address with no repository: looking for a renamed workspace or a transferred repository to send you to |
142+| `access` | Deciding whether you may fetch from or push to it |
143+| `kept` | A free workspace's limits, and looking for a ref listing and a store credential made a moment ago |
144+| `mint` | Only when no credential was kept: the git store making one for the request |
145+| `store` | The git store's answer; for a push, checking it for secrets first |
146+| `refs` | Only for a push: recording that the repository's refs changed |
147+| `total` | Everything g1t did |
148+| `repos` | The same, measured where your request arrived |
149+
150+Two entries say how a step went rather than how long it took:
151+
152+| Entry | Values |
153+| --- | --- |
154+| `refs;desc=` | `hit-colo` or `hit-shared` when the ref listing came from g1t's cache, `miss` when the git store was asked |
155+| `cred;desc=` | `isolate` or `shared` for a store credential made a moment ago, `mint` for a new one |
156+
157+The ref listing git asks for first on every clone and fetch is kept for up
158+to a minute, and only the same question about the same refs gets the same
159+answer: a push, a merge or any other change to a repository's branches and
160+tags makes the next fetch ask the git store again. A change can take up to
161+5 seconds to reach every fetch.
162+
163+Include the header when you report a slow clone, fetch or push.
164+
128165 ## SSH
129166
130167 Git over SSH is not available yet. Use HTTPS, which works for clone,
+54−0
269269 for the roles that can use it. A link straight to any of these pages opens
270270 the sidebar already there.
271271
272+## Mission control
273+
274+Mission control, `g1t.sh` when you are signed in, is your home page. It
275+shows where you are needed in the workspace you have chosen in the
276+sidebar, what its agents are doing, and what landed without you.
277+
278+Under the greeting, one line sums up the week, such as *Agents landed 39
279+of 47 changes this week without you*. A change landed without you when
280+g1t merged it, by auto-merge or from the [merge queue](/guides/merge-queue/),
281+with no person pressing merge. **Review N that need you** jumps to the
282+list, and **New issue** opens a new issue in the project you pick.
283+
284+| Across the top | What it counts |
285+| --- | --- |
286+| **Projects** | The workspace's projects, and how many were added this month. |
287+| **Agents** | Agent runs going now, and the hours agents worked in the last 7 days. |
288+| **Changes this week** | Pull requests merged in the last 7 days, and the change from the 7 days before. The change is left out when g1t cannot read far enough back to count it. |
289+| **Landed without you** | The share of those changes that g1t merged with no person pressing merge. |
290+| **Need you** | What is waiting on you, and how many of those block work. |
291+
292+The list has three tabs. Each row opens to say more; the first is open.
293+
294+| Tab | What it lists |
295+| --- | --- |
296+| **Needs you** | Pull requests g1t stopped seeing through, reviews asked of you, changes ready for you to merge, failed checks, quiet agents, failed production builds, repository invitations and a usage limit that is close or reached. |
297+| **Waiting on agents** | Pull requests in an agent's hands (making the change, checking, reviewing, revising, catching up or in the merge queue), and runs going now. |
298+| **Landed today** | Pull requests merged today in your time zone, and whether a person merged them. |
299+
300+Each row in **Needs you** carries the reason it needs you:
301+
302+| Reason | Means |
303+| --- | --- |
304+| `BLOCKING` | Nothing moves until a person acts: a failed production build, a merge g1t could not make, or the usage limit. |
305+| `ASKED FOR YOU` | A review or an invitation addressed to you by name. |
306+| `CHECKS FAILING` | The checks still fail after the agent revised, or could not run. |
307+| `OUTSIDE GUARDRAILS` | A run reached a cost or time cap set in [Guardrails](/guides/guardrails/). |
308+| `NEEDS REVIEW` | The repository wants a person's approval, or the review still asks for changes after the agent revised. |
309+| `STALLED` | An agent stopped, or has reported nothing for 10 minutes. |
310+| `READY TO MERGE` | Checks passed and it was approved; the repository lands changes only when a person merges them. |
311+
312+Opened, a row shows **The ask** (what g1t stopped with, and who the work
313+was started for), **What the agent already knows** (its checks, the files
314+and lines it changes, the test files it touches, how often the agent was
315+sent back, and what its runs cost) and **Why this needs you**. From
316+there, **Review and respond** opens it, and where it can be done without
317+leaving the page you can approve the change, merge it or run its checks
318+again. **By impact** puts the most urgent first; **Newest** sorts by time.
319+
320+On the right, **This week** charts the changes landed each day, split
321+into those agents landed alone and those a person merged, with what
322+agents and sandboxes cost over the same days. **Activity** lists what
323+moved across the workspace, agents marked apart from people. The page
324+refreshes itself while agents are at work.
325+
272326 ## Workspace access tokens
273327
274328 A workspace has access tokens of its own, for CI, integrations and agents
+938−505
1−import {
2− Activity as ActivityIcon,
3− ArrowRight,
4− ArrowUpRight,
5− Bot,
6− Box,
7− CircleDot,
8− Coins,
9− GitPullRequest,
10− Hand,
11− Lock,
12− Plus,
13− Radio,
14− Settings,
15− Sparkles,
16− Users,
17−} from "lucide-react";
1+import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, LoaderCircle, Plus } from "lucide-react";
182 import { type ReactNode, useEffect, useState } from "react";
19−import { Link, useRouteLoaderData } from "react-router";
3+import { Link, useFetcher, useRouteLoaderData, useSearchParams } from "react-router";
204
215 import { trialClosed } from "../lib/trial";
22−import { type Need, formatSpan, greetingFor, rankNeeds } from "../lib/mission";
6+import { type ActivityGroup, type Verb, greetingFor, isAgent } from "../lib/mission";
7+import {
8+ BLOCKING,
9+ type Fact,
10+ type LandedRow,
11+ type NeedRow,
12+ type QuickAction,
13+ REASON_LABEL,
14+ type Reason,
15+ type Sort,
16+ type Tab,
17+ type WaitingRow,
18+ type Week,
19+ type Who,
20+ change,
21+ dateLine,
22+ parseSort,
23+ parseTab,
24+ signedPercent,
25+ sortRows,
26+ usd,
27+ whyFor,
28+} from "../lib/mission-control";
29+import { cn } from "../lib/cn";
2330 import { AgentSetup } from "./agent-setup";
2431 import type { ShellData } from "./shell";
25−import { STAGE_LABEL, StageDots } from "./lifecycle";
26−import { RunCard, formatCost, useLiveRefresh } from "./agents";
27−import { CheckBadge } from "./checks";
28−import { DEPLOY_STATUS, StatusDot, host } from "./deploy";
29−import { ActivityFeed, Meter, NeedsList, Panel, PulseTile, Quiet, Unavailable, percent } from "./mission";
32+import { useLiveRefresh } from "./agents";
33+import { Unavailable } from "./mission";
3034 import { Avatar, TimeAgo } from "./ui";
31−import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
32−import { IssueIcon, PullIcon } from "./work-icons";
35+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
3336 import type { Loaded } from "../routes/home";
3437
3538 /*
36− * Mission control: the home page of someone signed in. It is a chunk of its
37− * own, loaded only for them, so the signed-out home page does not carry it.
38− * routes/home.tsx loads its data.
39+ * Mission control: the home page of someone signed in. It answers where
40+ * the viewer is needed, what agents are doing, and what landed without
41+ * them. It is a chunk of its own, loaded only for them, so the signed-out
42+ * home page does not carry it. routes/home.tsx loads its data;
43+ * lib/mission-control.ts shapes it.
3944 */
4045
4146 /** The viewer's time zone, set by the page itself, so the greeting fits their day. */
4247 const TZ_COOKIE = "g1t_tz";
48+/** Rows a tab shows before "See all". */
49+const ROWS = 6;
4350
4451 const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`;
52+const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
4553
46−// --- Pieces ---------------------------------------------------------------------
54+// --- Small pieces -----------------------------------------------------------------
4755
48−const TONE = { fg: "text-fg", warn: "text-warn", danger: "text-danger", accent: "text-accent" };
56+const CHIP_TONE: Record<Reason, string> = {
57+ blocking: "border-danger/35 bg-danger/10 text-danger",
58+ checks_failing: "border-danger/35 bg-danger/10 text-danger",
59+ outside_guardrails: "border-warn/35 bg-warn/10 text-warn",
60+ stalled: "border-warn/35 bg-warn/10 text-warn",
61+ asked_for_you: "border-merged/35 bg-merged/10 text-merged",
62+ needs_review: "border-info/35 bg-info/10 text-info",
63+ ready_to_merge: "border-accent/35 bg-accent/10 text-accent",
64+};
4965
50−function Digest({ parts, seenBefore }: { parts: Loaded["digest"]; seenBefore: number | null }) {
51− const when = seenBefore ? (
52− <>
53− Since you were last here, <TimeAgo at={seenBefore} />
54− </>
55− ) : (
56− "In the last day"
66+function Chip({ children, tone }: { children: ReactNode; tone: string }) {
67+ return (
68+ <span
69+ className={cn(
70+ "inline-flex shrink-0 items-center rounded border px-1.5 py-px font-mono text-[0.625rem] font-medium tracking-wider whitespace-nowrap uppercase",
71+ tone,
72+ )}
73+ >
74+ {children}
75+ </span>
5776 );
58− if (parts.length === 0) {
59− return <p className="mt-1 text-sm text-muted">{when}: nothing new landed, and nothing is waiting on you.</p>;
60− }
77+}
78+
79+/** A person or an agent, by name, wearing the mark that tells them apart. */
80+function Person({ who, size = 16, className }: { who: Who; size?: number; className?: string }) {
6181 return (
62− <p className="mt-1 text-sm leading-6 text-muted">
63− {when}:{" "}
64− {parts.map((part, index) => (
65− <span key={part.text}>
66− {index > 0 && (index === parts.length - 1 ? " and " : ", ")}
67− <a href={`#${part.anchor}`} className={`font-medium underline decoration-line-strong underline-offset-4 hover:decoration-current ${TONE[part.tone]}`}>
68− {part.text}
69− </a>
70− </span>
82+ <span className={cn("inline-flex min-w-0 items-center gap-1.5", className)}>
83+ <Avatar name={who.name} size={size} />
84+ <span className="truncate">{who.name}</span>
85+ </span>
86+ );
87+}
88+
89+function Eyebrow({ children }: { children: ReactNode }) {
90+ return <p className="font-mono text-[0.625rem] font-medium tracking-[0.12em] text-faint uppercase">{children}</p>;
91+}
92+
93+const FACT_TONE = { good: "text-accent", warn: "text-warn", bad: "text-danger" } as const;
94+
95+function Facts({ facts }: { facts: Fact[] }) {
96+ if (facts.length === 0) return <p className="text-sm text-muted">Nothing more is known about it yet.</p>;
97+ return (
98+ <dl className="grid grid-cols-2 gap-x-4 gap-y-2.5">
99+ {facts.map((fact) => (
100+ <div key={fact.label} className="min-w-0">
101+ <dt className="text-xs text-faint">{fact.label}</dt>
102+ <dd className={cn("mt-0.5 truncate text-sm font-medium tabular-nums", fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft")}>
103+ {fact.value}
104+ </dd>
105+ </div>
71106 ))}
72− .
73− </p>
107+ </dl>
74108 );
75109 }
76110
77−function PullList({ rows, empty }: { rows: Loaded["pullsTabs"]["authored"]; empty: ReactNode }) {
78− if (rows.length === 0) return <Quiet>{empty}</Quiet>;
111+/** A form that acts on a pull request from here, saying so before the page catches up. */
112+function QuickForm({ quick, variant = "quiet" }: { quick: QuickAction; variant?: "quiet" | "accent" }) {
113+ const fetcher = useFetcher<{ error?: string } | null>();
114+ const [sent, setSent] = useState(false);
115+ const busy = fetcher.state !== "idle";
116+ const done = sent && fetcher.state === "idle" && !fetcher.data?.error;
79117 return (
80− <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
81− {rows.map((row) => (
82− <li key={row.key}>
83− <Link
84− prefetch="intent"
85− to={`/${row.repo.namespace}/${row.repo.name}/pull/${row.number}`}
86− className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised"
87− >
88− <PullIcon status={row.status} />
89− <span className="min-w-0 grow">
90− <span className="block truncate text-sm font-medium">{row.title}</span>
91− <span className="block truncate font-mono text-xs text-muted">
92− {row.repo.name}#{row.number}
93− {row.agent && ` · ${row.agent}`}
94− </span>
118+ <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents">
119+ {Object.entries(quick.fields).map(([name, value]) => (
120+ <input key={name} type="hidden" name={name} value={value} />
121+ ))}
122+ <button
123+ type="submit"
124+ disabled={busy || done}
125+ className={cn(
126+ "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default",
127+ variant === "accent"
128+ ? "bg-merged text-bg hover:bg-[#c9bfff]"
129+ : "border border-line-strong text-fg/90 hover:bg-raised hover:text-fg",
130+ done && "border-accent/40 text-accent",
131+ )}
132+ >
133+ {busy ? <LoaderCircle size={14} className="animate-spin" /> : done ? <Check size={14} /> : null}
134+ {done ? quick.done : quick.label}
135+ </button>
136+ {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>}
137+ </fetcher.Form>
138+ );
139+}
140+
141+function ProjectMark({ repo }: { repo: { namespace: string; name: string } | null }) {
142+ return <Avatar name={repo ? repo.name : "g1t"} size={32} square />;
143+}
144+
145+/** A row that opens to what is known about it; the first one starts open. */
146+function Row({
147+ open,
148+ repo,
149+ by,
150+ title,
151+ sub,
152+ chip,
153+ at,
154+ children,
155+}: {
156+ open: boolean;
157+ repo: { namespace: string; name: string } | null;
158+ by: Who | null;
159+ title: ReactNode;
160+ sub: ReactNode;
161+ chip: ReactNode;
162+ at: number;
163+ children: ReactNode;
164+}) {
165+ return (
166+ <li>
167+ <details open={open} className="group">
168+ <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-3 transition-colors select-none hover:bg-raised/60 sm:px-5 [&::-webkit-details-marker]:hidden">
169+ <ProjectMark repo={repo} />
170+ <span className="min-w-0 grow">
171+ <span className="block truncate text-sm">{title}</span>
172+ <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
173+ <span className="shrink-0 font-medium text-fg-soft">{repo ? repo.name : "Workspace"}</span>
174+ {by && (
175+ <>
176+ <span className="text-faint">·</span>
177+ <Person who={by} size={14} className={cn("shrink-0", by.agent ? "text-merged" : "")} />
178+ </>
179+ )}
180+ {sub && (
181+ <>
182+ <span className="text-faint">—</span>
183+ <span className="truncate">{sub}</span>
184+ </>
185+ )}
95186 </span>
96− {row.stage ? (
97− <span className="hidden shrink-0 items-center gap-2 text-xs text-muted sm:flex">
98− <StageDots stage={row.stage} />
99− <span className={row.stage === "needs_you" ? "text-warn" : ""}>{STAGE_LABEL[row.stage]}</span>
100− </span>
101− ) : (
102− <span className="hidden shrink-0 sm:block">
103− <CheckBadge status={row.checkStatus} />
104− </span>
105− )}
106− <span className="w-14 shrink-0 text-right text-xs text-faint">
107− <TimeAgo at={row.updatedAt} />
187+ </span>
188+ <span className="hidden sm:inline-flex">{chip}</span>
189+ <span className="hidden w-16 shrink-0 text-right text-xs text-faint sm:block">
190+ <TimeAgo at={at} />
191+ </span>
192+ <ChevronDown size={16} className="shrink-0 text-faint transition-transform group-open:rotate-180" />
193+ </summary>
194+ <div className="px-4 pb-4 sm:px-5 sm:pb-5">
195+ <div className="mb-3 flex items-center gap-2 sm:hidden">
196+ {chip}
197+ <span className="text-xs text-faint">
198+ <TimeAgo at={at} />
108199 </span>
109− </Link>
110− </li>
111− ))}
112− </ul>
200+ </div>
201+ {children}
202+ </div>
203+ </details>
204+ </li>
113205 );
114206 }
115207
116−function IssueList({ rows, empty }: { rows: Loaded["issuesTabs"]["assigned"]; empty: ReactNode }) {
117− if (rows.length === 0) return <Quiet>{empty}</Quiet>;
208+function NeedCard({ row, first }: { row: NeedRow; first: boolean }) {
209+ const isPull = row.to.includes("/pull/");
118210 return (
119− <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
120− {rows.map((row) => (
121− <li key={row.key}>
211+ <Row
212+ open={first}
213+ repo={row.repo}
214+ by={row.by}
215+ title={
216+ <>
217+ <span className="font-medium">{row.title}</span>
218+ {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>}
219+ </>
220+ }
221+ sub={row.ask}
222+ chip={<Chip tone={CHIP_TONE[row.reason]}>{REASON_LABEL[row.reason]}</Chip>}
223+ at={row.at}
224+ >
225+ <div className="grid gap-4 rounded-xl border border-line bg-bg/50 p-4 md:grid-cols-2 md:gap-x-6 2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,0.95fr)]">
226+ <div className="min-w-0">
227+ <Eyebrow>The ask</Eyebrow>
228+ <p className="mt-2 text-sm leading-6 text-fg-soft">{row.ask}</p>
229+ <p className="mt-2 text-xs leading-5 text-muted">
230+ <Link to={row.to} className="font-medium text-fg hover:underline">
231+ {row.repo ? `${row.repo.name}${row.ref ?? ""}` : row.title}
232+ </Link>
233+ {row.for && (
234+ <>
235+ {" "}
236+ · started for <span className="text-fg-soft">{row.for}</span>
237+ </>
238+ )}
239+ </p>
240+ </div>
241+ <div className="min-w-0">
242+ <Eyebrow>What the agent already knows</Eyebrow>
243+ <div className="mt-2">
244+ <Facts facts={row.facts} />
245+ </div>
246+ </div>
247+ <div className="rounded-lg border border-warn/20 bg-warn/[0.06] p-3.5 md:col-span-2 2xl:col-span-1">
248+ <p className="text-xs font-semibold text-warn">Why this needs you</p>
249+ <p className="mt-1.5 text-sm leading-6 text-fg-soft">{row.why}</p>
250+ <p className="mt-2 text-xs text-faint">
251+ Waiting <TimeAgo at={row.at} />
252+ </p>
253+ </div>
254+ </div>
255+ <div className="mt-3 flex flex-wrap items-center gap-2">
256+ <Link
257+ to={row.to}
258+ prefetch="intent"
259+ className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-white"
260+ >
261+ Review and respond <ArrowUpRight size={14} />
262+ </Link>
263+ {row.quick && <QuickForm quick={row.quick} />}
264+ {row.link && (
122265 <Link
123− prefetch="intent"
124− to={`/${row.repo.namespace}/${row.repo.name}/issues/${row.number}`}
125− className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised"
266+ to={row.link.to}
267+ className="inline-flex items-center rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
126268 >
127− <IssueIcon issue={{ state: "open", reason: null }} />
128− <span className="min-w-0 grow">
129− <span className="block truncate text-sm font-medium">{row.title}</span>
130− <span className="block truncate font-mono text-xs text-muted">
131− {row.repo.name}#{row.number}
132− </span>
133− </span>
134− {row.agent ? (
135− <span className="hidden shrink-0 items-center gap-1 text-xs text-merged sm:flex">
136− <Bot size={12} /> {row.agent} at work
137− </span>
138− ) : row.queued ? (
139− <span className="hidden shrink-0 text-xs text-muted sm:block">Queued for an agent</span>
140− ) : null}
141− <span className="w-14 shrink-0 text-right text-xs text-faint">
142− <TimeAgo at={row.updatedAt} />
143− </span>
269+ {row.link.label}
270+ </Link>
271+ )}
272+ {row.open !== row.to && (
273+ <Link to={row.open} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg">
274+ {row.open.includes("/issues/") ? "Open issue" : isPull ? "See the changes" : "Open pull request"}
144275 </Link>
145− </li>
146− ))}
147− </ul>
276+ )}
277+ </div>
278+ </Row>
148279 );
149280 }
150281
151−function ProjectCard({ project, workspace }: { project: Loaded["projects"][number]; workspace: string }) {
152− const base = `/${workspace}/${project.slug}`;
153− const latest = project.latest;
282+function WaitingCard({ row, first }: { row: WaitingRow; first: boolean }) {
154283 return (
155− <li className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
156− <div className="flex items-center gap-2.5">
157− <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
158− {project.private ? <Lock size={13} /> : <Box size={13} />}
159− </span>
160− <Link to={base} prefetch="intent" className="min-w-0 grow truncate font-medium hover:text-accent">
161− {project.name}
284+ <Row
285+ open={first}
286+ repo={row.repo}
287+ by={row.by}
288+ title={
289+ <>
290+ <span className="font-medium">{row.title}</span>
291+ {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>}
292+ </>
293+ }
294+ sub={row.detail}
295+ chip={
296+ <Chip tone="border-line-strong bg-raised text-muted">
297+ {row.live && <span className="mr-1.5 size-1.5 animate-pulse rounded-full bg-accent" />}
298+ {row.chip}
299+ </Chip>
300+ }
301+ at={row.at}
302+ >
303+ <div className="grid gap-4 rounded-xl border border-line bg-bg/50 p-4 md:grid-cols-2 md:gap-6">
304+ <div className="min-w-0">
305+ <Eyebrow>{row.live ? "Doing now" : "Where it stands"}</Eyebrow>
306+ <p className="mt-2 text-sm leading-6 text-fg-soft">{row.detail}</p>
307+ </div>
308+ <div className="min-w-0">
309+ <Eyebrow>What is known</Eyebrow>
310+ <div className="mt-2">
311+ <Facts facts={row.facts} />
312+ </div>
313+ </div>
314+ </div>
315+ <div className="mt-3 flex flex-wrap gap-2">
316+ <Link
317+ to={row.to}
318+ prefetch="intent"
319+ className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
320+ >
321+ {row.to.includes("/runs/") ? "Watch the run" : "Open pull request"} <ArrowUpRight size={14} />
162322 </Link>
163− {project.agents > 0 && (
164− <span className="inline-flex items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-[0.6875rem] text-merged">
165− <span className="size-1.5 animate-pulse rounded-full bg-merged" />
166− {project.agents} at work
167− </span>
323+ {row.run && row.run !== row.to && (
324+ <Link to={row.run} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg">
325+ Watch the run
326+ </Link>
168327 )}
169328 </div>
170− <div className="mt-3 min-h-10 text-xs">
171− {project.production ? (
172− <>
173− <a href={project.production.url} className="flex items-center gap-1 truncate font-mono text-fg-soft hover:text-accent">
174− {host(project.production.url)}
175− <ArrowUpRight size={12} className="shrink-0 text-faint" />
176− </a>
177− <p className="mt-1 flex items-center gap-2 text-muted">
178− {latest && <StatusDot status={latest.status} />}
179− <span>
180− deployed <TimeAgo at={project.production.deployedAt} />
181− </span>
182− </p>
183− </>
184− ) : project.deploys ? (
185− <p className="text-muted">
186− {latest ? <StatusDot status={latest.status} label={`${DEPLOY_STATUS[latest.status].label}, not live yet`} /> : "Deploys on the next push to the default branch."}
187− </p>
329+ </Row>
330+ );
331+}
332+
333+function LandedCard({ row, first }: { row: LandedRow; first: boolean }) {
334+ return (
335+ <Row
336+ open={first}
337+ repo={row.repo}
338+ by={row.byAgents ? { name: row.agent, agent: isAgent(row.agent) } : row.by}
339+ title={
340+ <>
341+ <span className="font-medium">{row.title}</span>
342+ <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>
343+ </>
344+ }
345+ sub={row.byAgents ? "landed without a person" : `merged by ${row.by?.name ?? "a person"}`}
346+ chip={
347+ row.byAgents ? (
348+ <Chip tone="border-merged/35 bg-merged/10 text-merged">By agents</Chip>
188349 ) : (
189− <p className="text-faint">
190− Deployments are off.{" "}
191− <Link to={`${base}/settings/deployments`} className="text-muted hover:text-fg">
192− Turn on deployments
193− </Link>
350+ <Chip tone="border-warn/35 bg-warn/10 text-warn">Needed a person</Chip>
351+ )
352+ }
353+ at={row.at}
354+ >
355+ <div className="grid gap-4 rounded-xl border border-line bg-bg/50 p-4 md:grid-cols-2 md:gap-6">
356+ <div className="min-w-0">
357+ <Eyebrow>How it landed</Eyebrow>
358+ <p className="mt-2 text-sm leading-6 text-fg-soft">
359+ {row.byAgents
360+ ? `g1t merged it once everything the repository asks for was met, with no one pressing merge.`
361+ : `${row.by?.name ?? "A person"} merged it.`}
362+ {row.agent && isAgent(row.agent) && ` ${row.agent} made the change.`}
194363 </p>
195− )}
364+ </div>
365+ <div className="min-w-0">
366+ <Eyebrow>What changed</Eyebrow>
367+ <div className="mt-2">
368+ <Facts facts={row.facts} />
369+ </div>
370+ </div>
371+ </div>
372+ <div className="mt-3">
373+ <Link
374+ to={row.to}
375+ prefetch="intent"
376+ className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
377+ >
378+ Open pull request <ArrowUpRight size={14} />
379+ </Link>
196380 </div>
197− <dl className="mt-3 grid grid-cols-3 gap-2 border-t border-line pt-3 text-xs">
198− <div>
199− <dt className="text-faint">Open PRs</dt>
200− <dd className="mt-0.5 font-medium tabular-nums">
201− <Link to={`${base}/pulls`} className="hover:text-accent">
202− {project.openPulls ?? "—"}
203− </Link>
204− </dd>
205− </div>
381+ </Row>
382+ );
383+}
384+
385+function List({ children }: { children: ReactNode }) {
386+ return <ul className="divide-y divide-line">{children}</ul>;
387+}
388+
389+function Empty({ children, action }: { children: ReactNode; action?: ReactNode }) {
390+ return (
391+ <div className="px-5 py-10 text-center">
392+ <div className="mx-auto max-w-md text-sm leading-6 text-muted">{children}</div>
393+ {action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>}
394+ </div>
395+ );
396+}
397+
398+// --- The stat strip -----------------------------------------------------------------
399+
400+function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) {
401+ return (
402+ <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4" title={title}>
403+ <p className="flex items-center gap-1.5 truncate text-xs text-muted">
404+ {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />}
405+ {label}
406+ </p>
407+ <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p>
408+ <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>
409+ </div>
410+ );
411+}
412+
413+// --- This week ----------------------------------------------------------------------
414+
415+/** Seven days of landed changes, each split into what agents landed alone and what a person merged. */
416+function WeekChart({ week }: { week: Week }) {
417+ const max = Math.max(1, ...week.days.map((d) => d.agents + d.people));
418+ const height = 112;
419+ const delta = change(week.total, week.previous);
420+ return (
421+ <div>
422+ <div className="flex items-end justify-between gap-3">
206423 <div>
207− <dt className="text-faint">Agents</dt>
208− <dd className="mt-0.5 font-medium tabular-nums">
209− <Link to={`${base}/agents`} className="hover:text-accent">
210− {project.agents}
211− </Link>
212− </dd>
424+ <p className="font-display text-3xl font-semibold tracking-tight tabular-nums">{week.total.toLocaleString("en-US")}</p>
425+ <p className="text-xs text-muted">changes landed in 7 days</p>
213426 </div>
214− <div title={project.checks ? `${project.checks} recent check runs` : "No recent check runs"}>
215− <dt className="text-faint">Checks</dt>
216− <dd className="mt-0.5 font-medium tabular-nums">{percent(project.passRate)}</dd>
217− <dd className="mt-1">
218− <Meter value={project.passRate} tone={project.passRate != null && project.passRate < 0.7 ? "bg-warn" : "bg-accent"} />
219− </dd>
427+ {delta != null ? (
428+ <span
429+ className={cn(
430+ "rounded-full px-2 py-0.5 text-xs font-medium tabular-nums ring-1",
431+ delta >= 0 ? "text-accent ring-accent/30" : "text-warn ring-warn/30",
432+ )}
433+ >
434+ {signedPercent(delta)} vs last week
435+ </span>
436+ ) : week.previous === 0 && week.total > 0 ? (
437+ <span className="text-xs text-faint">none the week before</span>
438+ ) : null}
439+ </div>
440+ {week.total === 0 ? (
441+ <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm leading-6 text-muted">
442+ Nothing landed in the last 7 days. Each change that does shows here, by day, split by whether a person had to merge it.
443+ </p>
444+ ) : (
445+ <div className="relative mt-5" style={{ height: height + 20 }}>
446+ <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} />
447+ <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}>
448+ {week.days.map((day, index) => {
449+ const total = day.agents + day.people;
450+ const agentsH = Math.round((day.agents / max) * (height - 4));
451+ const peopleH = Math.round((day.people / max) * (height - 4));
452+ const today = index === week.days.length - 1;
453+ return (
454+ <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end">
455+ {total > 0 && (
456+ <span
457+ className={cn(
458+ "mb-1 text-[0.625rem] tabular-nums",
459+ today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100",
460+ )}
461+ >
462+ {total}
463+ </span>
464+ )}
465+ <div className="flex w-full max-w-6 flex-col items-stretch gap-[2px]">
466+ {day.people > 0 && <span className="block rounded-t bg-warn" style={{ height: Math.max(3, peopleH) }} />}
467+ {day.agents > 0 && (
468+ <span className={cn("block bg-merged", day.people > 0 ? "" : "rounded-t")} style={{ height: Math.max(3, agentsH) }} />
469+ )}
470+ {total === 0 && <span className="block h-[2px] rounded-full bg-line-strong" />}
471+ </div>
472+ <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 group-hover:block">
473+ <p className="font-medium text-fg">{day.label}</p>
474+ <p className="mt-0.5 flex items-center gap-1.5 text-muted">
475+ <span className="size-1.5 rounded-full bg-merged" /> {day.agents} by agents
476+ </p>
477+ <p className="flex items-center gap-1.5 text-muted">
478+ <span className="size-1.5 rounded-full bg-warn" /> {day.people} needed a person
479+ </p>
480+ </div>
481+ </div>
482+ );
483+ })}
484+ </div>
485+ <div className="absolute inset-x-0 bottom-0 flex justify-between gap-1">
486+ {week.days.map((day, index) => (
487+ <span
488+ key={day.key}
489+ className={cn(
490+ "flex-1 text-center text-[0.6875rem]",
491+ index === week.days.length - 1 ? "font-medium text-fg-soft" : "text-faint",
492+ )}
493+ >
494+ {day.label}
495+ </span>
496+ ))}
497+ </div>
220498 </div>
221− </dl>
499+ )}
500+ <div className={cn("mt-3 flex-wrap gap-x-4 gap-y-1 text-xs text-muted", week.total === 0 ? "hidden" : "flex")}>
501+ <span className="inline-flex items-center gap-1.5">
502+ <span className="size-2 rounded-sm bg-merged" /> Landed by agents
503+ </span>
504+ <span className="inline-flex items-center gap-1.5">
505+ <span className="size-2 rounded-sm bg-warn" /> Needed a person
506+ </span>
507+ </div>
508+ <table className="sr-only">
509+ <caption>Changes landed each day</caption>
510+ <thead>
511+ <tr>
512+ <th>Day</th>
513+ <th>Landed by agents</th>
514+ <th>Needed a person</th>
515+ </tr>
516+ </thead>
517+ <tbody>
518+ {week.days.map((day) => (
519+ <tr key={day.key}>
520+ <td>{day.key}</td>
521+ <td>{day.agents}</td>
522+ <td>{day.people}</td>
523+ </tr>
524+ ))}
525+ </tbody>
526+ </table>
527+ </div>
528+ );
529+}
530+
531+// --- Activity -----------------------------------------------------------------------
532+
533+const VERB: Record<Verb, string> = {
534+ landed: "landed",
535+ opened_issue: "opened",
536+ closed_issue: "closed",
537+ started: "started on",
538+ ready: "marked ready",
539+ checks_passed: "checks passed on",
540+ checks_failed: "checks failed on",
541+ approved: "approved",
542+ changes_requested: "asked for changes on",
543+ commented: "commented on",
544+ asked: "was asked about",
545+ deployed: "deployed production",
546+ deploy_failed: "production build failed",
547+ learned: "learned",
548+};
549+
550+/** One line of the feed: who, what, and the one thing it was about, by name. */
551+function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) {
552+ const part = group.parts[0];
553+ const base = `/${group.repo.namespace}/${group.repo.name}`;
554+ const actor = group.actor ?? "g1t";
555+ const agent = group.actor == null || isAgent(group.actor);
556+ const number = part.numbers[0];
557+ const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined;
558+ const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1);
559+ return (
560+ <li className="flex gap-2.5 py-2">
561+ <span className="mt-px">
562+ <Avatar name={actor} size={22} />
563+ </span>
564+ <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted">
565+ <span className={cn("font-medium", agent ? "text-merged" : "text-fg")}>{actor}</span> {VERB[part.verb]}{" "}
566+ {part.verb === "learned" ? (
567+ <Link to={part.to ?? `${base}/memory`} className="text-fg-soft hover:text-fg">
568+ “{part.texts[0]}”
569+ </Link>
570+ ) : number != null ? (
571+ <Link to={`${base}/issues/${number}`} prefetch="intent" className="text-fg-soft hover:text-fg">
572+ <span className="font-mono text-xs">#{number}</span>
573+ {title && <> {title}</>}
574+ </Link>
575+ ) : part.to ? (
576+ <Link to={part.to} className="text-fg-soft hover:text-fg">
577+ {group.repo.name}
578+ </Link>
579+ ) : null}
580+ {more > 0 && <span className="text-faint"> and {more} more</span>}
581+ <span className="block text-xs text-faint">
582+ <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} />
583+ </span>
584+ </span>
222585 </li>
223586 );
224587 }
225588
589+// --- Get started --------------------------------------------------------------------
590+
226591 type Step = { done: boolean; title: string; about: string; to: string | null; action: string };
227592
228−/**
229− * The first things to do, ticked off as they are done, until work has been
230− * handed to agents.
231− */
593+/** The first things to do, ticked off as they are done, until work has been handed to agents. */
232594 function GetStarted({ steps }: { steps: Step[] }) {
233595 const left = steps.filter((step) => !step.done).length;
234596 return (
235− <section className="rounded-2xl bg-surface p-5 ring-1 ring-line">
597+ <section className="rounded-xl border border-line bg-surface p-5">
236598 <div className="flex items-baseline justify-between gap-3">
237− <h2 className="font-semibold tracking-tight">Get started</h2>
599+ <h2 className="text-base font-semibold tracking-tight">Get started</h2>
238600 <span className="text-xs text-muted">
239601 {steps.length - left} of {steps.length} done
240602 </span>
243605 {steps.map((step, index) => (
244606 <li
245607 key={step.title}
246− className={`flex items-start gap-3 rounded-xl px-3 py-2.5 ${step.done ? "" : "bg-bg/50 ring-1 ring-line"}`}
608+ className={cn("flex items-start gap-3 rounded-lg px-3 py-2.5", step.done ? "" : "bg-bg/50 ring-1 ring-line")}
247609 >
248610 <span
249− className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium ${
250− step.done ? "bg-accent text-bg" : "text-muted ring-1 ring-line-strong"
251− }`}
611+ className={cn(
612+ "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium",
613+ step.done ? "bg-accent text-bg" : "text-muted ring-1 ring-line-strong",
614+ )}
252615 >
253− {step.done ? "✓" : index + 1}
616+ {step.done ? <Check size={12} /> : index + 1}
254617 </span>
255618 <span className="min-w-0 grow">
256− <span className={`block text-sm font-medium ${step.done ? "text-muted line-through decoration-faint" : ""}`}>
257− {step.title}
258− </span>
619+ <span className={cn("block text-sm font-medium", step.done && "text-muted line-through decoration-faint")}>{step.title}</span>
259620 {!step.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{step.about}</span>}
260621 </span>
261622 {!step.done && step.to && (
273634 );
274635 }
275636
276−/** The workspace being looked at: what it holds and what is moving in it. */
277−function WorkspaceCard({
278− slug,
279− name,
280− avatar,
281− role,
282− projects,
283− needs,
284− live,
285−}: {
286− slug: string;
287− name: string;
288− avatar?: string | null;
289− role: string;
290− projects: number;
291− needs: number;
292− live: number;
293−}) {
294− const stats = [
295− { value: projects, label: "projects" },
296− { value: live, label: "agents at work" },
297− { value: needs, label: "need you" },
298− ];
299− return (
300− <div className="rounded-xl border border-line bg-surface p-5">
301− <div className="flex items-center gap-3">
302− <Avatar name={slug} image={avatar} size={32} square />
303− <div className="min-w-0">
304− <Link to={`/${slug}`} title={name} className="block truncate font-medium hover:underline">
305− {name}
306− </Link>
307− <p className="truncate text-xs text-muted">
308− <span className="font-mono">g1t.sh/{slug}</span> · <span className="capitalize">{role}</span>
309− </p>
310− </div>
311− </div>
312− <dl className="mt-4 grid grid-cols-3 gap-2">
313− {stats.map((stat) => (
314− <div key={stat.label} className="rounded-lg bg-bg/60 px-2.5 py-2 ring-1 ring-line">
315− <dt className="sr-only">{stat.label}</dt>
316− <dd className="text-lg font-semibold tabular-nums tracking-tight">{stat.value}</dd>
317− <dd className="text-[0.6875rem] leading-tight text-muted">{stat.label}</dd>
318− </div>
319− ))}
320− </dl>
321− <div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm">
322− <Link to={`/${slug}/-/people`} className="inline-flex items-center gap-1.5 text-muted hover:text-fg">
323− <Users size={13} />
324− Members
325− </Link>
326− <Link
327− to={role === "owner" ? `/${slug}/-/settings` : `/${slug}/-/people`}
328− className="inline-flex items-center gap-1.5 text-muted hover:text-fg"
329− >
330− <Settings size={13} />
331− Settings
332− </Link>
333− <Link to={`/${slug}/-/agents`} className="inline-flex items-center gap-1.5 text-muted hover:text-fg">
334− <Radio size={13} />
335− Fleet
336− </Link>
337− </div>
338− </div>
339− );
340−}
341−
342−function UsageCard({ shell, weekCost }: { shell: ShellData; weekCost: number }) {
343− const slug = shell.workspace?.slug;
344− const used = shell.monthUsageMicros ?? 0;
345− const limit = shell.limit;
346− const ceiling = limit && !limit.comped ? limit.ceilingMicros : null;
347− const share = ceiling ? Math.min(1, limit!.exposureMicros / ceiling) : null;
348− const month = new Date().toLocaleDateString("en-US", { month: "long" });
349− return (
350− <div className="rounded-xl border border-line bg-surface p-5">
351− <div className="flex items-center justify-between">
352− <h2 className="flex items-center gap-1.5 text-sm font-semibold">
353− <Coins size={14} className="text-faint" />
354− Usage in {month}
355− </h2>
356− {slug && (
357− <Link to={`/${slug}/-/usage`} className="text-xs text-muted hover:text-fg">
358− Details
359− </Link>
360− )}
361− </div>
362− <p className="mt-3 text-2xl font-semibold tracking-tight tabular-nums">{dollars(used)}</p>
363− <p className="text-xs text-muted">
364− {shell.free ? "At cost. g1t charges nothing while it is being built out." : "Charged so far this month."}
365− {weekCost > 0 && ` ${formatCost(weekCost)} of it in the last 7 days.`}
366− </p>
367− {ceiling != null && (
368− <div className="mt-3">
369− <Meter value={share} tone={limit!.state === "ok" ? "bg-accent" : limit!.state === "warning" ? "bg-warn" : "bg-danger"} />
370− <p className="mt-1.5 text-[0.6875rem] text-faint">
371− {dollars(limit!.exposureMicros)} of the {dollars(ceiling)} limit
372− </p>
373− </div>
374− )}
375− </div>
376− );
377−}
378−
637+// --- The page -----------------------------------------------------------------------
379638
380−// --- The page -------------------------------------------------------------------
639+const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting on agents", landed: "Landed today" };
640+const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting", landed: "Today" };
641+const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" };
381642
382643 export default function MissionControl({ loaderData }: { loaderData: Loaded }) {
383644 const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined;
384645 const loaded: Loaded = loaderData;
646+ const [params] = useSearchParams();
385647 // Agents are at work, so the page changes without anyone touching it.
386− const changing =
387− (loaded?.liveTotal ?? 0) > 0 ||
388− (loaded?.active ?? []).some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready");
389− useLiveRefresh(changing);
390− const [greeting, setGreeting] = useState(loaded?.greeting ?? "");
391− const [pullsTab, setPullsTab] = useState(() => (loaded && loaded.pullsTabs.review.length > 0 ? "review" : "authored"));
648+ useLiveRefresh(loaded.changing);
649+ const [greeting, setGreeting] = useState(loaded.greeting);
650+ const [date, setDate] = useState(loaded.date);
392651 useEffect(() => {
393− // The greeting follows the viewer's own clock, and the server learns
394− // their zone for next time.
652+ // The greeting and date follow the viewer's own clock, and the server
653+ // learns their zone for next time.
395654 setGreeting(greetingFor(new Date().getHours()));
396655 try {
397656 const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
657+ setDate(dateLine(Date.now(), zone ?? null));
398658 if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`;
399659 } catch {
400660 // Nothing to remember.
401661 }
402662 }, []);
403663
404− const { viewer, repos, active, needs: serverNeeds, live, liveTotal, pullsTabs, issuesTabs, groups, pulse, projects, canRunAgents, trial } = loaded;
664+ const { viewer, repos, waiting, landed, liveTotal, week, groups, titles, stats, canRunAgents, trial } = loaded;
665+ const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null;
405666
406− const workspace = shell?.workspace?.slug ?? viewer.workspaces?.[0]?.slug ?? null;
407667 // The usage limit, as the sidebar knows it, heads the list when it bites.
408− const limitNeed: Need[] =
409− shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace
410− ? [
411− {
668+ const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null;
669+ const limitRow: NeedRow[] = limit
670+ ? [
671+ (() => {
672+ const detail =
673+ limit.state === "stopped"
674+ ? "No new agent starts until the limit is raised or a card is added."
675+ : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`;
676+ return {
412677 key: "limit",
413− kind: "limit",
414− title: shell.limit.state === "stopped" ? "Agents are stopped: the usage limit was reached" : "Usage is nearing its limit",
415− detail:
416− shell.limit.state === "stopped"
417− ? "No new agent starts until the limit is raised or a card is added."
418− : `${dollars(shell.limit.exposureMicros)} of ${dollars(shell.limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`,
678+ reason: "blocking" as const,
679+ repo: null,
680+ ref: null,
681+ title: limit.state === "stopped" ? "Agents are stopped: the usage limit was reached" : "Usage is nearing its limit",
682+ ask: detail,
683+ by: null,
684+ for: null,
685+ at: Date.now(),
419686 to: `/${workspace}/-/billing`,
420− action: "Billing",
421− at: Date.now(),
422− where: null,
423− },
424− ]
425− : [];
426− const needs = rankNeeds([...limitNeed, ...serverNeeds]);
687+ open: `/${workspace}/-/billing`,
688+ facts: [
689+ { label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const },
690+ ],
691+ why: whyFor("blocking", { kind: "limit", detail }),
692+ quick: null,
693+ link: null,
694+ };
695+ })(),
696+ ]
697+ : [];
698+ const needs = [...limitRow, ...loaded.needs];
699+ const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length;
427700
428− const first = repos[0];
429− const handedOff = active.length > 0 || (shell?.monthUsageMicros ?? 0) > 0;
701+ const counts: Record<Tab, number> = { needs: needs.length, waiting: waiting.length, landed: landed.length };
702+ const tab = parseTab(params.get("tab")) ?? "needs";
703+ const sort = parseSort(params.get("sort"));
704+ const all = params.get("all") === "1";
705+ const everyActivity = params.get("activity") === "all";
706+ const link = (changes: Record<string, string | null>) => {
707+ const next = new URLSearchParams(params);
708+ for (const [key, value] of Object.entries(changes)) {
709+ if (value == null) next.delete(key);
710+ else next.set(key, value);
711+ }
712+ const text = next.toString();
713+ return text ? `?${text}` : "/";
714+ };
715+
430716 const steps: Step[] = [
431717 {
432718 done: Boolean(workspace),
456742 ? {
457743 done: Boolean(shell?.free) || shell?.limit == null || shell.limit.comped || shell.limit.state === "ok",
458744 title: "Keep work running",
459− about: "Usage is charged after it runs. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.",
745+ about:
746+ "Usage is charged after it runs. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.",
460747 to: workspace ? `/${workspace}/-/billing` : null,
461748 action: "Billing",
462749 }
470757 action: "Connect",
471758 },
472759 {
473− done: handedOff,
760+ done: loaded.handedOff,
474761 title: "Hand off an outcome",
475− about: "Describe what you want done above, or write an outcome on a project's Plans page and let a planner split it into issues.",
476− to: first ? `/${first.namespace}/${first.name}/plans` : null,
762+ about:
763+ "Open an issue and assign it to g1t-agent, or write an outcome on a project's Plans page and let a planner split it into issues.",
764+ to: repos[0] ? `/${repos[0].namespace}/${repos[0].name}/plans` : null,
477765 action: "Write one",
478766 },
479767 ];
480768 const starting = steps.some((step) => !step.done);
481− const counts = (n: number) => (n > 0 ? <span className="ml-1 tabular-nums text-faint">{n}</span> : null);
769+ const noProjects = repos.length === 0;
770+
771+ const rows = tab === "needs" ? sortRows(needs, sort) : tab === "waiting" ? sortRows(waiting, sort) : sortRows(landed, sort);
772+ const shown = all ? rows : rows.slice(0, ROWS);
773+ const share = week.total > 0 ? week.byAgents / week.total : null;
774+ const delta = change(week.total, week.previous);
775+ const feed = everyActivity ? groups : groups.slice(0, 8);
776+
777+ const newIssue =
778+ repos.length > 0 ? (
779+ <DropdownMenu>
780+ <DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg">
781+ <Plus size={14} /> New issue
782+ </DropdownMenuTrigger>
783+ <DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
784+ <DropdownMenuLabel>In which project?</DropdownMenuLabel>
785+ {repos.map((repo) => (
786+ <DropdownMenuItem key={repo.name} asChild>
787+ <Link to={`/${repo.namespace}/${repo.name}/issues/new`}>
788+ <Avatar name={repo.name} size={18} square />
789+ {repo.name}
790+ </Link>
791+ </DropdownMenuItem>
792+ ))}
793+ </DropdownMenuContent>
794+ </DropdownMenu>
795+ ) : (
796+ <Link
797+ to={workspace ? `/new?workspace=${workspace}` : "/new"}
798+ className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
799+ >
800+ <Plus size={14} /> New project
801+ </Link>
802+ );
482803
483804 return (
484− <main className="mx-auto grid max-w-7xl gap-x-10 gap-y-10 px-4 py-8 sm:py-10 lg:grid-cols-[minmax(0,1fr)_19rem]">
485− <div className="min-w-0 space-y-10">
486− <header>
487− <h1 className="text-2xl font-semibold tracking-tight sm:text-[1.75rem]" suppressHydrationWarning>
805+ <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
806+ <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
807+ <div className="min-w-0">
808+ <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight sm:text-[2rem]" suppressHydrationWarning>
488809 {greeting}, {loaded.name}
489810 </h1>
490− <Digest parts={loaded.digest} seenBefore={loaded.seenBefore} />
491− </header>
811+ <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning>
812+ <span className="text-fg-soft">{date}</span> · {loaded.summary}
813+ </p>
814+ </div>
815+ <div className="flex flex-wrap items-center gap-2">
816+ {newIssue}
817+ {needs.length > 0 && (
818+ <Link
819+ to={`${link({ tab: "needs", all: null })}#work`}
820+ preventScrollReset
821+ className="inline-flex items-center gap-1.5 rounded-md bg-merged px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-[#c9bfff]"
822+ >
823+ Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} />
824+ </Link>
825+ )}
826+ </div>
827+ </header>
492828
493− {starting && <GetStarted steps={steps} />}
829+ {starting && <GetStarted steps={steps} />}
494830
495− <Panel id="needs-you" title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}>
496− {needs.length === 0 ? (
497− <Quiet>Nothing is waiting on you. Reviews, stopped work and failed deploys show up here first.</Quiet>
498− ) : (
499− <NeedsList needs={needs} />
500− )}
501− </Panel>
831+ <section
832+ aria-label="At a glance"
833+ className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-5"
834+ >
835+ <Stat
836+ label="Projects"
837+ value={stats.projects == null ? "—" : String(stats.projects)}
838+ hint={
839+ stats.projectsThisMonth == null
840+ ? "in this workspace"
841+ : stats.projectsThisMonth > 0
842+ ? `+${stats.projectsThisMonth} this month`
843+ : "none new this month"
844+ }
845+ />
846+ <Stat
847+ label="Agents"
848+ value={loaded.runsLoaded || !workspace ? String(liveTotal) : "—"}
849+ dot={liveTotal > 0 ? "bg-accent animate-pulse" : undefined}
850+ hint={
851+ liveTotal > 0
852+ ? `live now · ${stats.agentHours < 10 ? stats.agentHours.toFixed(1) : Math.round(stats.agentHours)}h this week`
853+ : stats.agentHours > 0
854+ ? `none live · ${stats.agentHours < 10 ? stats.agentHours.toFixed(1) : Math.round(stats.agentHours)}h this week`
855+ : "none live now"
856+ }
857+ />
858+ <Stat
859+ label="Changes this week"
860+ value={loaded.perRepoLoaded ? String(week.total) : "—"}
861+ hint={
862+ delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merged pull requests"
863+ }
864+ />
865+ <Stat
866+ label="Landed without you"
867+ dot="bg-merged"
868+ value={share == null ? "—" : `${Math.round(share * 100)}%`}
869+ hint={share == null ? "nothing landed yet" : `${week.byAgents} of ${week.total}`}
870+ title="Merged by g1t, by auto-merge or the merge queue, with no person pressing merge."
871+ />
872+ <div className="col-span-2 lg:col-span-1">
873+ <Stat
874+ label="Need you"
875+ dot="bg-warn"
876+ value={String(needs.length)}
877+ hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"}
878+ />
879+ </div>
880+ </section>
502881
503− <Panel
504− id="live"
505− title="Live now"
506− icon={<span className={`block size-2 rounded-full ${liveTotal > 0 ? "animate-pulse bg-merged" : "bg-line-strong"}`} />}
507− count={liveTotal}
508− all={workspace ? { to: `/${workspace}/-/agents`, label: "Fleet" } : null}
509− >
510− {!loaded.runsLoaded && workspace ? (
511− <Unavailable what="Agent runs" />
512− ) : live.length === 0 ? (
513− <Quiet>No agent is at work right now. Assign an issue to g1t-agent and one starts on it in seconds.</Quiet>
514− ) : (
515− <ul className="space-y-3">
516− {live.map((run) => (
517− <RunCard key={run.id} run={run} member showRepo />
882+ <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
883+ <section id="work" className="min-w-0 scroll-mt-20 self-start overflow-hidden rounded-xl border border-line bg-surface">
884+ <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3">
885+ <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto [scrollbar-width:none]" aria-label="Mission control">
886+ {(["needs", "waiting", "landed"] as const).map((value) => (
887+ <Link
888+ key={value}
889+ to={link({ tab: value, all: null })}
890+ preventScrollReset
891+ aria-current={tab === value ? "page" : undefined}
892+ className={cn(
893+ "flex shrink-0 items-center gap-1.5 border-b-2 px-2 py-3 text-sm whitespace-nowrap transition-colors sm:px-2.5",
894+ tab === value ? "border-merged font-medium text-fg" : "border-transparent text-muted hover:text-fg",
895+ )}
896+ >
897+ <span className="sm:hidden">{TAB_SHORT[value]}</span>
898+ <span className="hidden sm:inline">{TAB_LABEL[value]}</span>
899+ <span
900+ className={cn(
901+ "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
902+ tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted",
903+ )}
904+ >
905+ {counts[value]}
906+ </span>
907+ </Link>
518908 ))}
519− {liveTotal > live.length && workspace && (
520− <li className="text-xs text-muted">
521− <Link to={`/${workspace}/-/agents`} className="hover:text-fg">
522− {liveTotal - live.length} more at work in the fleet
523− </Link>
524− </li>
525− )}
526− </ul>
527− )}
528− </Panel>
909+ </nav>
910+ <DropdownMenu>
911+ <DropdownMenuTrigger
912+ className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted hover:bg-raised hover:text-fg"
913+ aria-label="Sort"
914+ >
915+ <ArrowDownWideNarrow size={14} />
916+ <span className="hidden sm:inline">{SORT_LABEL[sort]}</span>
917+ </DropdownMenuTrigger>
918+ <DropdownMenuContent align="end">
919+ {(["impact", "newest"] as const).map((value) => (
920+ <DropdownMenuItem key={value} asChild>
921+ <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset>
922+ {sort === value ? <Check /> : <span className="size-4" />}
923+ {SORT_LABEL[value]}
924+ </Link>
925+ </DropdownMenuItem>
926+ ))}
927+ </DropdownMenuContent>
928+ </DropdownMenu>
929+ </div>
930+
931+ {!loaded.perRepoLoaded && tab !== "waiting" ? (
932+ <div className="p-4">
933+ <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} />
934+ </div>
935+ ) : null}
529936
530− <Panel
531− title="This week"
532− icon={<ActivityIcon size={14} />}
533− all={workspace ? { to: `/${workspace}/-/usage`, label: "Usage" } : null}
534− >
535− <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
536− <PulseTile
537− label="Changes landed"
538− value={String(pulse.merged)}
539− hint="last 7 days"
540− values={pulse.mergedDays}
541− format={(v) => `${v} landed`}
542− />
543− <PulseTile
544− label="Agent hours"
545− value={pulse.hours < 10 ? pulse.hours.toFixed(1) : String(Math.round(pulse.hours))}
546− hint="time agents spent working"
547− values={pulse.hoursDays}
548− format={(v) => `${v.toFixed(1)} h`}
549− to={workspace ? `/${workspace}/-/agents` : undefined}
550− />
551− <PulseTile
552− label="Agent cost"
553− value={formatCost(pulse.cost) ?? "$0.00"}
554− hint="model and sandbox"
555− values={pulse.costDays}
556− format={(v) => formatCost(v) ?? "$0.00"}
557− to={workspace ? `/${workspace}/-/usage` : undefined}
558− />
559− <PulseTile
560− label="Issue to landed"
561− value={formatSpan(pulse.issueToMerge)}
562− hint={pulse.mergedWithIssue ? `median of ${pulse.mergedWithIssue}` : "no issue landed yet"}
563− />
564− <PulseTile
565− label="Checks pass first time"
566− value={percent(pulse.firstPass)}
567− hint={pulse.firstPassOf ? `of ${pulse.firstPassOf} pull requests` : "no checks ran yet"}
568− />
569− </div>
570− </Panel>
937+ {tab === "needs" &&
938+ (needs.length === 0 ? (
939+ noProjects ? (
940+ <Empty
941+ action={
942+ <Link
943+ to={workspace ? `/new?workspace=${workspace}` : "/new"}
944+ className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-white"
945+ >
946+ Create or import a project
947+ </Link>
948+ }
949+ >
950+ <p className="font-medium text-fg">No projects yet</p>
951+ <p className="mt-1">
952+ Create a repository or import one. Assign its issues to g1t-agent, and what needs you shows up here.
953+ </p>
954+ </Empty>
955+ ) : (
956+ <>
957+ <Empty>
958+ <p className="font-medium text-fg">Nothing needs you</p>
959+ <p className="mt-1">
960+ Agents are handling everything.{" "}
961+ {landed.length > 0
962+ ? "Here's what they landed today."
963+ : "Reviews, stopped work and failed deploys show up here first."}
964+ </p>
965+ </Empty>
966+ {landed.length > 0 && (
967+ <div className="border-t border-line">
968+ <List>
969+ {landed.slice(0, ROWS).map((row) => (
970+ <LandedCard key={row.key} row={row} first={false} />
971+ ))}
972+ </List>
973+ </div>
974+ )}
975+ </>
976+ )
977+ ) : (
978+ <List>
979+ {(shown as NeedRow[]).map((row, index) => (
980+ <NeedCard key={row.key} row={row} first={index === 0} />
981+ ))}
982+ </List>
983+ ))}
571984
572− <div className="grid gap-10 xl:grid-cols-2">
573− <Tabs value={pullsTab} onValueChange={setPullsTab}>
574− <Panel
575− id="your-pulls"
576− title="Your pull requests"
577− icon={<GitPullRequest size={14} />}
578− all={{ to: `/u/${viewer.username}?tab=pulls` }}
579− >
580− <TabsList>
581− <TabsTrigger value="authored">Opened{counts(pullsTabs.authored.length)}</TabsTrigger>
582− <TabsTrigger value="review">Review requested{counts(pullsTabs.review.length)}</TabsTrigger>
583− <TabsTrigger value="assigned">Assigned{counts(pullsTabs.assigned.length)}</TabsTrigger>
584− </TabsList>
585− <TabsContent value="authored">
586− <PullList rows={pullsTabs.authored} empty="None open. Pull requests you or your agents start show here with where each stands." />
587− </TabsContent>
588− <TabsContent value="review">
589− {loaded.perRepoLoaded ? (
590− <PullList rows={pullsTabs.review} empty="No one is waiting on your review." />
591− ) : (
592− <Unavailable what="Review requests" />
593− )}
594− </TabsContent>
595− <TabsContent value="assigned">
596− <PullList rows={pullsTabs.assigned} empty="No open pull request is assigned to you." />
597− </TabsContent>
598− </Panel>
599− </Tabs>
985+ {tab === "waiting" &&
986+ (!loaded.runsLoaded && workspace ? (
987+ <div className="p-4">
988+ <Unavailable what="Agent runs" />
989+ </div>
990+ ) : waiting.length === 0 ? (
991+ <Empty>
992+ <p className="font-medium text-fg">No agent is at work right now</p>
993+ <p className="mt-1">Assign an issue to g1t-agent and one starts on it in seconds. Its run shows here while it works.</p>
994+ </Empty>
995+ ) : (
996+ <List>
997+ {(shown as WaitingRow[]).map((row, index) => (
998+ <WaitingCard key={row.key} row={row} first={index === 0} />
999+ ))}
1000+ </List>
1001+ ))}
6001002
601− <Tabs defaultValue="assigned">
602− <Panel title="Your issues" icon={<CircleDot size={14} />} all={{ to: `/u/${viewer.username}?tab=issues` }}>
603− <TabsList>
604− <TabsTrigger value="assigned">Assigned{counts(issuesTabs.assigned.length)}</TabsTrigger>
605− <TabsTrigger value="authored">Opened{counts(issuesTabs.authored.length)}</TabsTrigger>
606− </TabsList>
607− <TabsContent value="assigned">
608− <IssueList rows={issuesTabs.assigned} empty="Nothing is assigned to you." />
609− </TabsContent>
610− <TabsContent value="authored">
611− <IssueList rows={issuesTabs.authored} empty="You have no open issues." />
612− </TabsContent>
613− </Panel>
614− </Tabs>
615− </div>
1003+ {tab === "landed" &&
1004+ loaded.perRepoLoaded &&
1005+ (landed.length === 0 ? (
1006+ <Empty>
1007+ <p className="font-medium text-fg">Nothing has landed today yet</p>
1008+ <p className="mt-1">
1009+ {week.total > 0
1010+ ? `${plural(week.total, "change")} landed in the last 7 days.`
1011+ : "Merged pull requests show up here the moment they land."}
1012+ </p>
1013+ </Empty>
1014+ ) : (
1015+ <List>
1016+ {(shown as LandedRow[]).map((row, index) => (
1017+ <LandedCard key={row.key} row={row} first={index === 0} />
1018+ ))}
1019+ </List>
1020+ ))}
6161021
617− <Panel id="activity" title="Activity" icon={<ActivityIcon size={14} />}>
618− {!loaded.perRepoLoaded ? (
619− <Unavailable what="Activity" />
620− ) : (
621− <ActivityFeed groups={groups} empty="Nothing has moved yet. Merges, deploys, checks, reviews and what agents learn show up here." />
1022+ {rows.length > ROWS && (
1023+ <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5">
1024+ <span>
1025+ Showing {shown.length} of {rows.length}
1026+ </span>
1027+ <Link
1028+ to={link({ all: all ? null : "1" })}
1029+ preventScrollReset
1030+ className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg"
1031+ >
1032+ {all ? "Show fewer" : "See all"} <ChevronRight size={12} />
1033+ </Link>
1034+ </div>
6221035 )}
623− </Panel>
1036+ </section>
6241037
625− {workspace && (
626− <Panel
627− id="projects"
628− title="Projects"
629− icon={<Box size={14} />}
630− count={projects.length}
631− all={{ to: `/${workspace}`, label: "Workspace" }}
632− extra={
633− <Link to={`/new?workspace=${workspace}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
634− <Plus size={12} /> New
1038+ <aside className="min-w-0 space-y-6">
1039+ <section className="rounded-xl border border-line bg-surface p-5">
1040+ <div className="flex items-center justify-between">
1041+ <h2 className="text-base font-semibold tracking-tight">This week</h2>
1042+ {workspace && (
1043+ <Link to={`/${workspace}/-/agents`} className="text-xs text-muted hover:text-fg">
1044+ Fleet
1045+ </Link>
1046+ )}
1047+ </div>
1048+ <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div>
1049+ {workspace && stats.weekCost > 0 && (
1050+ <Link
1051+ to={`/${workspace}/-/usage`}
1052+ className="mt-4 flex items-center justify-between border-t border-line pt-3 text-xs text-muted hover:text-fg"
1053+ >
1054+ <span>Agents and sandboxes cost {usd(stats.weekCost)} this week</span>
1055+ <ChevronRight size={12} />
6351056 </Link>
636− }
637− >
638− {!loaded.projectsLoaded ? (
639− <Unavailable what="Projects" />
640− ) : projects.length === 0 ? (
641− <Quiet>No projects yet. Create a repository or import one, and it gets a project of its own.</Quiet>
1057+ )}
1058+ </section>
1059+
1060+ <section id="activity" className="scroll-mt-20 rounded-xl border border-line bg-surface p-5">
1061+ <div className="flex items-center justify-between">
1062+ <h2 className="text-base font-semibold tracking-tight">Activity</h2>
1063+ {groups.length > 8 && (
1064+ <Link
1065+ to={`${link({ activity: everyActivity ? null : "all" })}#activity`}
1066+ preventScrollReset
1067+ className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg"
1068+ >
1069+ {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} />
1070+ </Link>
1071+ )}
1072+ </div>
1073+ <p className="mt-1 flex items-center gap-3 text-[0.6875rem] text-faint">
1074+ <span className="inline-flex items-center gap-1.5">
1075+ <Avatar name="g1t-agent" size={12} /> agents
1076+ </span>
1077+ <span className="inline-flex items-center gap-1.5">
1078+ <Avatar name={viewer.username} size={12} /> people
1079+ </span>
1080+ </p>
1081+ {!loaded.perRepoLoaded ? (
1082+ <div className="mt-3">
1083+ <Unavailable what="Activity" />
1084+ </div>
1085+ ) : feed.length === 0 ? (
1086+ <p className="mt-3 text-sm leading-6 text-muted">
1087+ Nothing has moved yet. Merges, deploys, checks, reviews and what agents learn show up here.
1088+ </p>
6421089 ) : (
643− <ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
644− {projects.map((project) => (
645− <ProjectCard key={project.slug} project={project} workspace={workspace} />
1090+ <ol className="mt-2 divide-y divide-line/70">
1091+ {feed.map((group) => (
1092+ <FeedLine key={group.id} group={group} titles={titles} />
6461093 ))}
647− </ul>
1094+ </ol>
6481095 )}
649− </Panel>
650− )}
651− </div>
1096+ </section>
6521097
653− <aside className="space-y-4">
654− {shell?.workspace && (
655− <WorkspaceCard
656− slug={shell.workspace.slug.toLowerCase()}
657− name={shell.workspace.name || shell.workspace.slug}
658− avatar={shell.workspace.avatar}
659− role={shell.workspace.role}
660− projects={shell.repos.length}
661− needs={needs.length}
662− live={liveTotal}
663− />
664− )}
665− {shell?.workspace && <UsageCard shell={shell} weekCost={pulse.cost} />}
666− <div className="rounded-xl border border-line bg-surface p-5">
667− <h2 className="text-sm font-semibold">Connect your own agent</h2>
668− <p className="mt-1.5 text-xs leading-5 text-muted">
669− Add g1t to your coding agent. It signs in through your browser; there is no token to copy.
670− </p>
671− <AgentSetup className="mt-3" />
672− <Link
673− to="https://docs.g1t.sh/guides/bring-your-own-agent/"
674− className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
675− >
676− How it works <ArrowRight size={12} />
677− </Link>
678− </div>
679− </aside>
1098+ <section className="rounded-xl border border-line bg-surface p-5">
1099+ <h2 className="text-sm font-semibold">Connect your own agent</h2>
1100+ <p className="mt-1.5 text-xs leading-5 text-muted">
1101+ Add g1t to your coding agent. It signs in through your browser; there is no token to copy.
1102+ </p>
1103+ <AgentSetup className="mt-3" />
1104+ <Link
1105+ to="https://docs.g1t.sh/guides/bring-your-own-agent/"
1106+ className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
1107+ >
1108+ How it works <ArrowRight size={12} />
1109+ </Link>
1110+ </section>
1111+ </aside>
1112+ </div>
6801113 </main>
6811114 );
6821115 }
+263−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ type Merged,
6+ change,
7+ dayKey,
8+ isTestFile,
9+ landedByAgents,
10+ landedToday,
11+ parseSort,
12+ parseTab,
13+ pullFacts,
14+ reachesBack,
15+ reasonFor,
16+ signedPercent,
17+ sortRows,
18+ stallReason,
19+ summaryLine,
20+ waitingRows,
21+ weekOf,
22+ whyFor,
23+} from "./mission-control.ts";
24+
25+const HOUR = 3_600_000;
26+const DAY = 24 * HOUR;
27+// Monday 2026-10-05, 18:00 UTC.
28+const NOW = Date.parse("2026-10-05T18:00:00Z");
29+const repo = { namespace: "acme", name: "web" };
30+
31+test("each kind of need gets its reason chip", () => {
32+ assert.equal(reasonFor({ kind: "deploy", detail: "" }), "blocking");
33+ assert.equal(reasonFor({ kind: "limit", detail: "" }), "blocking");
34+ assert.equal(reasonFor({ kind: "review", detail: "" }), "asked_for_you");
35+ assert.equal(reasonFor({ kind: "invitation", detail: "" }), "asked_for_you");
36+ assert.equal(reasonFor({ kind: "ready", detail: "" }), "ready_to_merge");
37+ assert.equal(reasonFor({ kind: "checks", detail: "" }), "checks_failing");
38+ assert.equal(reasonFor({ kind: "stuck", detail: "" }), "stalled");
39+});
40+
41+test("a stopped pull request's reason comes from the sentence g1t stopped with", () => {
42+ assert.equal(stallReason("The acceptance checks still fail after the agent revised 3 times."), "checks_failing");
43+ assert.equal(stallReason("CI / push still fails after the agent revised 2 times."), "checks_failing");
44+ assert.equal(stallReason("The acceptance checks could not be run."), "checks_failing");
45+ assert.equal(
46+ stallReason("g1t stopped the agent's implement run when it reached its cost cap. Raise the cap under Settings, Guardrails."),
47+ "outside_guardrails",
48+ );
49+ assert.equal(stallReason("g1t stopped the agent's review run when it reached its time cap."), "outside_guardrails");
50+ assert.equal(stallReason("g1t could not merge this: the branch moved"), "blocking");
51+ assert.equal(stallReason("Needs 1 approving review from someone with write access."), "needs_review");
52+ assert.equal(stallReason("The review still asks for changes after the agent revised 3 times."), "needs_review");
53+ assert.equal(stallReason("alex asked for changes, and the agent has already revised 3 times."), "needs_review");
54+ assert.equal(stallReason("syntaqx stopped the agent's implement run."), "stalled");
55+ assert.equal(stallReason("g1t could not start the next step: no slot"), "stalled");
56+});
57+
58+test("why a need needs a person is said for every reason", () => {
59+ for (const kind of ["deploy", "limit", "invitation", "review", "stuck", "checks", "ready", "conflict"] as const) {
60+ const need = { kind, detail: "" };
61+ assert.ok(whyFor(reasonFor(need), need).length > 20, kind);
62+ }
63+ const stalled = { kind: "stalled" as const, detail: "It reached its cost cap." };
64+ assert.match(whyFor(reasonFor(stalled), stalled), /Guardrails/);
65+});
66+
67+test("what the agent knows lists only what is known", () => {
68+ const facts = pullFacts({
69+ checkStatus: "failed",
70+ files: [
71+ { path: "src/retry.ts", additions: 40, deletions: 2 },
72+ { path: "src/retry.test.ts", additions: 60, deletions: 0 },
73+ ],
74+ lifecycle: { revisions: 2 },
75+ runs: [
76+ { kind: "implement", costUsd: 0.5 },
77+ { kind: "revise", costUsd: 0.25 },
78+ { kind: "checks", costUsd: null },
79+ ],
80+ });
81+ const by = Object.fromEntries(facts.map((f) => [f.label, f]));
82+ assert.equal(by.Checks.value, "Failing");
83+ assert.equal(by.Checks.tone, "bad");
84+ assert.equal(by["Files changed"].value, "2");
85+ assert.equal(by.Lines.value, "+100 −2");
86+ assert.equal(by.Tests.value, "1 file");
87+ assert.equal(by["Sent back"].value, "2 times");
88+ assert.equal(by["Agent runs"].value, "2 · $0.75");
89+
90+ const bare = pullFacts({ checkStatus: null, files: [] });
91+ assert.deepEqual(bare, [{ label: "Checks", value: "Not run", tone: null }]);
92+});
93+
94+test("test files are recognised by the names test runners use", () => {
95+ assert.ok(isTestFile("src/a.test.ts"));
96+ assert.ok(isTestFile("src/__tests__/a.ts"));
97+ assert.ok(isTestFile("tests/api.rs"));
98+ assert.ok(isTestFile("pkg/store_test.go"));
99+ assert.ok(!isTestFile("src/testing-utils.ts"));
100+ assert.ok(!isTestFile("src/contest.ts"));
101+});
102+
103+const merged = (daysAgo: number, mergedBy: string | null, number = 1): Merged => ({
104+ repo,
105+ number,
106+ title: `Change ${number}`,
107+ agent: "g1t-agent",
108+ mergedBy,
109+ mergedAt: new Date(NOW - daysAgo * DAY).toISOString(),
110+ files: [],
111+});
112+
113+test("a change landed without a person when g1t merged it", () => {
114+ assert.ok(landedByAgents({ mergedBy: "g1t" }));
115+ assert.ok(landedByAgents({ mergedBy: "g1t-agent" }));
116+ assert.ok(landedByAgents({ mergedBy: null }));
117+ assert.ok(!landedByAgents({ mergedBy: "syntaqx" }));
118+});
119+
120+test("the week is seven days, each split by who landed it, with the week before", () => {
121+ const week = weekOf(
122+ [merged(0, "g1t"), merged(0, "syntaqx"), merged(1, "g1t"), merged(6, "g1t"), merged(8, "g1t"), merged(10, "alex"), merged(20, "g1t")],
123+ NOW,
124+ "UTC",
125+ );
126+ assert.equal(week.days.length, 7);
127+ assert.equal(week.days[6].key, "2026-10-05");
128+ assert.equal(week.days[6].label, "Mon");
129+ assert.deepEqual(
130+ week.days.map((d) => [d.agents, d.people]),
131+ [[1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 1]],
132+ );
133+ assert.equal(week.total, 4);
134+ assert.equal(week.byAgents, 3);
135+ assert.equal(week.previous, 2);
136+ // Not knowing the week before is not the same as nothing in it.
137+ assert.equal(weekOf([merged(0, "g1t")], NOW, "UTC", false).previous, null);
138+});
139+
140+test("days follow the viewer's time zone", () => {
141+ // 02:00 UTC on the 5th is still the 4th in Los Angeles.
142+ const late = Date.parse("2026-10-05T02:00:00Z");
143+ assert.equal(dayKey(late, "America/Los_Angeles"), "2026-10-04");
144+ assert.equal(dayKey(late, "UTC"), "2026-10-05");
145+ assert.equal(dayKey(late, "Not/AZone"), "2026-10-05");
146+ const week = weekOf([{ mergedAt: new Date(late).toISOString(), mergedBy: "g1t" }], NOW, "America/Los_Angeles");
147+ assert.equal(week.days.find((d) => d.key === "2026-10-04")?.agents, 1);
148+});
149+
150+test("the change from last week, and how it reads", () => {
151+ assert.equal(change(12, 10), 0.2);
152+ assert.equal(change(5, 0), null);
153+ assert.equal(change(5, null), null);
154+ assert.equal(signedPercent(0.2), "+20%");
155+ assert.equal(signedPercent(-0.054), "−5%");
156+ assert.equal(signedPercent(0.001), "0%");
157+});
158+
159+test("a repository's merged list reaches back when it is not full or its oldest is old enough", () => {
160+ const since = NOW - 14 * DAY;
161+ const at = (days: number) => ({ mergedAt: new Date(NOW - days * DAY).toISOString(), updatedAt: new Date(NOW).toISOString() });
162+ assert.ok(reachesBack([at(1), at(2)], since, 100));
163+ assert.ok(reachesBack([at(1), at(20)], since, 2));
164+ assert.ok(!reachesBack([at(1), at(3)], since, 2));
165+});
166+
167+test("landed today is the viewer's calendar day, newest first", () => {
168+ const rows = landedToday([merged(0.1, "g1t", 3), merged(0.5, "syntaqx", 4), merged(1, "g1t", 2)], NOW, "UTC");
169+ assert.deepEqual(
170+ rows.map((r) => [r.ref, r.byAgents]),
171+ [
172+ ["#3", true],
173+ ["#4", false],
174+ ],
175+ );
176+ assert.equal(rows[1].by?.name, "syntaqx");
177+});
178+
179+test("waiting on agents holds each pull request once, running ones first, and nothing that needs you", () => {
180+ const pull = (number: number, minutesAgo: number) => ({
181+ number,
182+ title: `Pull ${number}`,
183+ agent: "g1t-agent",
184+ updatedAt: new Date(NOW - minutesAgo * 60_000).toISOString(),
185+ checkStatus: null,
186+ files: [],
187+ });
188+ const rows = waitingRows({
189+ active: [
190+ { pull: pull(1, 30), lifecycle: { stage: "checking", detail: "The acceptance checks are running.", revisions: 0 }, repo },
191+ { pull: pull(2, 5), lifecycle: { stage: "needs_you", detail: "Stopped.", revisions: 0 }, repo },
192+ { pull: pull(3, 50), lifecycle: { stage: "queued", detail: "In the merge queue.", revisions: 0 }, repo },
193+ { pull: pull(9, 1), lifecycle: { stage: "reviewing", detail: "Reviewing.", revisions: 0 }, repo },
194+ ],
195+ live: [
196+ {
197+ id: "r1",
198+ repo,
199+ number: 3,
200+ title: "Pull 3",
201+ kind: "update",
202+ agent: "g1t-agent",
203+ step: "Merging main in",
204+ costUsd: 0.1,
205+ startedAt: new Date(NOW - 60_000).toISOString(),
206+ createdAt: new Date(NOW - 60_000).toISOString(),
207+ updatedAt: new Date(NOW - 10_000).toISOString(),
208+ },
209+ {
210+ id: "r2",
211+ repo,
212+ number: null,
213+ title: null,
214+ kind: "plan",
215+ agent: "g1t-agent",
216+ step: null,
217+ costUsd: null,
218+ startedAt: null,
219+ createdAt: new Date(NOW - 120_000).toISOString(),
220+ updatedAt: new Date(NOW - 120_000).toISOString(),
221+ },
222+ ],
223+ drafts: [
224+ { ...pull(1, 30), repo },
225+ { ...pull(4, 20), repo },
226+ { ...pull(5, 20), agent: "syntaqx", repo },
227+ ],
228+ needKeys: new Set(["acme/web#9"]),
229+ });
230+ assert.deepEqual(
231+ rows.map((r) => [r.ref, r.chip, r.live]),
232+ [
233+ ["#3", "In queue", true],
234+ [null, "Planning", true],
235+ ["#4", "Working", false],
236+ ["#1", "Checking", false],
237+ ],
238+ );
239+ assert.equal(rows[0].detail, "Merging main in");
240+ assert.equal(rows[0].run, "/acme/web/agents/runs/r1");
241+ assert.equal(rows[1].title, "Planning in web");
242+});
243+
244+test("tabs and sorting come from the address", () => {
245+ assert.equal(parseTab("waiting"), "waiting");
246+ assert.equal(parseTab("nope"), null);
247+ assert.equal(parseSort("newest"), "newest");
248+ assert.equal(parseSort(null), "impact");
249+ const rows = [{ at: 1 }, { at: 3 }, { at: 2 }];
250+ assert.deepEqual(sortRows(rows, "impact"), rows);
251+ assert.deepEqual(
252+ sortRows(rows, "newest").map((r) => r.at),
253+ [3, 2, 1],
254+ );
255+});
256+
257+test("the summary says the week honestly", () => {
258+ assert.equal(summaryLine({ total: 47, byAgents: 39, live: 2, needs: 8 }), "Agents landed 39 of 47 changes this week without you.");
259+ assert.equal(summaryLine({ total: 3, byAgents: 3, live: 0, needs: 0 }), "Agents landed all 3 changes this week without you.");
260+ assert.equal(summaryLine({ total: 2, byAgents: 0, live: 0, needs: 0 }), "2 changes landed this week, each merged by a person.");
261+ assert.equal(summaryLine({ total: 0, byAgents: 0, live: 1, needs: 0 }), "1 agent is at work. Nothing has landed this week yet.");
262+ assert.match(summaryLine({ total: 0, byAgents: 0, live: 0, needs: 0 }), /Assign an issue/);
263+});
+533−0
1+/**
2+ * The shaping behind mission control's dashboard: why each thing needs the
3+ * viewer, what an agent already knows about it, the three tabs, the week
4+ * split into what agents landed alone and what a person merged, and the
5+ * one sentence under the greeting. Pure, so it is tested on its own; it
6+ * imports only types.
7+ */
8+import type { AgentRun, CheckStatus, ChangedFile, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts";
9+
10+import type { Need } from "./mission";
11+
12+const DAY = 24 * 60 * 60 * 1000;
13+
14+/** Accounts that are g1t's own agents and machinery: `isAgent` in ./mission, kept here so this module imports only types. */
15+export function isAgent(name: string | null | undefined): boolean {
16+ return name === "g1t-agent" || name === "g1t" || (name ?? "").endsWith("-agent");
17+}
18+
19+// --- Reasons ----------------------------------------------------------------
20+
21+/** Why something is waiting on a person, as the chip beside it says. */
22+export type Reason = "blocking" | "asked_for_you" | "checks_failing" | "outside_guardrails" | "needs_review" | "stalled" | "ready_to_merge";
23+
24+export const REASON_LABEL: Record<Reason, string> = {
25+ blocking: "Blocking",
26+ asked_for_you: "Asked for you",
27+ checks_failing: "Checks failing",
28+ outside_guardrails: "Outside guardrails",
29+ needs_review: "Needs review",
30+ stalled: "Stalled",
31+ ready_to_merge: "Ready to merge",
32+};
33+
34+/**
35+ * The reason a need is shown, from its kind and, for a pull request g1t
36+ * stopped seeing through, the sentence it stopped with.
37+ */
38+export function reasonFor(need: Pick<Need, "kind" | "detail">): Reason {
39+ switch (need.kind) {
40+ case "limit":
41+ case "deploy":
42+ case "conflict":
43+ return "blocking";
44+ case "invitation":
45+ case "review":
46+ return "asked_for_you";
47+ case "checks":
48+ return "checks_failing";
49+ case "ready":
50+ return "ready_to_merge";
51+ case "stuck":
52+ return "stalled";
53+ case "stalled":
54+ return stallReason(need.detail);
55+ }
56+}
57+
58+/** What a pull request's `needs_you` sentence says it is waiting for. */
59+export function stallReason(detail: string): Reason {
60+ if (/cost cap|time cap|unusual CPU/i.test(detail)) return "outside_guardrails";
61+ if (/conflict|could not (?:be )?merge/i.test(detail)) return "blocking";
62+ if (/checks? (?:still )?fail|still fails|could not be run/i.test(detail)) return "checks_failing";
63+ if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review";
64+ return "stalled";
65+}
66+
67+/** Why only a person can move it: the callout beside what the agent knows. */
68+export function whyFor(reason: Reason, need: Pick<Need, "kind" | "detail">): string {
69+ if (need.kind === "limit") return "Agents start nothing new past the workspace's usage limit. Only an owner can raise it or add a card.";
70+ if (need.kind === "deploy")
71+ return "Production still serves the build before this one. Every push to the default branch builds again, so this fails until what broke is fixed.";
72+ if (need.kind === "invitation") return "An invitation is to you. No one else can accept it.";
73+ if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict.";
74+ if (need.kind === "stuck")
75+ return "A running agent has stopped reporting. It may be working on something long, or stuck; a look at its session tells which.";
76+ switch (reason) {
77+ case "outside_guardrails":
78+ return "The run reached a limit set in Guardrails. g1t does not lift a cap on its own; a person raises it, then asks for the next step.";
79+ case "blocking":
80+ return "g1t could not land it and stopped rather than guess. A person decides how it lands, or whether it should.";
81+ case "checks_failing":
82+ return "The agent revised and the checks still fail, so g1t stopped sending it back instead of looping. Guide it, fix it yourself, or close it.";
83+ case "needs_review":
84+ return "This repository wants a person's verdict before it lands, and the agent has done what it can without one.";
85+ case "ready_to_merge":
86+ return "Checks passed and it was approved. This repository lands a change only when a person merges it.";
87+ case "stalled":
88+ return "The agent stopped and g1t does not start it again on its own. Ask for a review, a revision or a catch-up, or close it.";
89+ case "asked_for_you":
90+ return "It was put to you by name.";
91+ }
92+}
93+
94+/** Reasons where nothing moves until a person acts on it. */
95+export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]);
96+
97+// --- What the agent knows ---------------------------------------------------
98+
99+export type FactTone = "good" | "warn" | "bad" | null;
100+export type Fact = { label: string; value: string; tone: FactTone };
101+
102+const CHECKS: Record<CheckStatus, { text: string; tone: FactTone }> = {
103+ passed: { text: "Passed", tone: "good" },
104+ failed: { text: "Failing", tone: "bad" },
105+ errored: { text: "Could not run", tone: "bad" },
106+ running: { text: "Running", tone: null },
107+ queued: { text: "Queued", tone: null },
108+};
109+
110+/** Test files, by the names test runners look for. */
111+export function isTestFile(path: string): boolean {
112+ return /(^|\/)(__tests__|tests?|spec)\//i.test(path) || /[._-](test|spec)\.[a-z0-9]+$/i.test(path) || /_test\.(go|rs|py)$/i.test(path);
113+}
114+
115+const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
116+
117+/** "$0.42", or "<$0.01" for a sliver. */
118+export function usd(value: number): string {
119+ if (value > 0 && value < 0.01) return "<$0.01";
120+ return `$${value.toFixed(2)}`;
121+}
122+
123+/**
124+ * What is known about a pull request without opening it: its checks, how
125+ * much it changes, the tests it touches, how often the agent was sent back,
126+ * and what its runs cost. Only what is known is listed.
127+ */
128+export function pullFacts(input: {
129+ checkStatus: CheckStatus | null;
130+ files: ChangedFile[];
131+ lifecycle?: Pick<Lifecycle, "revisions"> | null;
132+ runs?: Pick<AgentRun, "costUsd" | "kind">[];
133+}): Fact[] {
134+ const facts: Fact[] = [];
135+ const checks = input.checkStatus ? CHECKS[input.checkStatus] : null;
136+ facts.push({ label: "Checks", value: checks?.text ?? "Not run", tone: checks?.tone ?? null });
137+ if (input.files.length > 0) {
138+ const added = input.files.reduce((sum, f) => sum + f.additions, 0);
139+ const removed = input.files.reduce((sum, f) => sum + f.deletions, 0);
140+ facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null });
141+ facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null });
142+ const tests = input.files.filter((f) => isTestFile(f.path)).length;
143+ facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" });
144+ }
145+ if (input.lifecycle && input.lifecycle.revisions > 0) {
146+ facts.push({
147+ label: "Sent back",
148+ value: plural(input.lifecycle.revisions, "time"),
149+ tone: input.lifecycle.revisions > 1 ? "warn" : null,
150+ });
151+ }
152+ const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck");
153+ if (agentRuns.length > 0) {
154+ const costs = agentRuns.filter((run) => run.costUsd != null);
155+ const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0);
156+ facts.push({
157+ label: "Agent runs",
158+ value: costs.length > 0 ? `${agentRuns.length} · ${usd(cost)}` : String(agentRuns.length),
159+ tone: null,
160+ });
161+ }
162+ return facts;
163+}
164+
165+// --- Rows -------------------------------------------------------------------
166+
167+/** Someone or something on a row: an agent, a person, or g1t itself. */
168+export type Who = { name: string; agent: boolean };
169+
170+export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null);
171+
172+/** A form on mission control that acts without leaving it. */
173+export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string };
174+
175+/** One thing that needs the viewer, with what is known about it. */
176+export type NeedRow = {
177+ key: string;
178+ reason: Reason;
179+ repo: RepoPath | null;
180+ /** "#41", when it is an issue or pull request. */
181+ ref: string | null;
182+ title: string;
183+ /** The ask, in one sentence. */
184+ ask: string;
185+ /** Who is waiting: the agent on it, or the person who asked. */
186+ by: Who | null;
187+ /** The person who started it, when an agent did the work. */
188+ for: string | null;
189+ at: number;
190+ to: string;
191+ open: string;
192+ facts: Fact[];
193+ why: string;
194+ quick: QuickAction | null;
195+ /** Somewhere else to act, when that is the next step. */
196+ link: { label: string; to: string } | null;
197+};
198+
199+/** The agents' stages a pull request can wait in, as their chips say them. */
200+export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = {
201+ working: "Working",
202+ checking: "Checking",
203+ reviewing: "Reviewing",
204+ revising: "Revising",
205+ catching_up: "Catching up",
206+ answering: "Answering",
207+ queued: "In queue",
208+};
209+
210+/** A run's kind, as its chip says it. */
211+export const RUN_LABEL: Record<RunKind, string> = {
212+ implement: "Working",
213+ revise: "Revising",
214+ review: "Reviewing",
215+ answer: "Answering",
216+ update: "Catching up",
217+ plan: "Planning",
218+ checks: "Checking",
219+ queue: "In queue",
220+ mergecheck: "Checking",
221+};
222+
223+/** Something agents are doing, which the viewer can leave to them. */
224+export type WaitingRow = {
225+ key: string;
226+ repo: RepoPath;
227+ ref: string | null;
228+ title: string;
229+ chip: string;
230+ detail: string;
231+ by: Who | null;
232+ at: number;
233+ to: string;
234+ /** Whether a run is going on it right now. */
235+ live: boolean;
236+ /** The run going on it, to watch. */
237+ run: string | null;
238+ facts: Fact[];
239+};
240+
241+const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase();
242+
243+/**
244+ * What is in agents' hands: pull requests in an agent's stage, runs going
245+ * now, and drafts agents are still making. Each once, and nothing that is
246+ * already waiting on the viewer.
247+ */
248+export function waitingRows(input: {
249+ active: {
250+ pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] };
251+ lifecycle: Lifecycle | null;
252+ repo: RepoPath;
253+ }[];
254+ live: Pick<
255+ AgentRun,
256+ "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt"
257+ >[];
258+ drafts: {
259+ number: number;
260+ title: string;
261+ agent: string;
262+ updatedAt: string;
263+ checkStatus: CheckStatus | null;
264+ files: ChangedFile[];
265+ repo: RepoPath;
266+ }[];
267+ needKeys: ReadonlySet<string>;
268+}): WaitingRow[] {
269+ const rows = new Map<string, WaitingRow>();
270+ for (const { pull, lifecycle, repo } of input.active) {
271+ if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue;
272+ const key = pathKey(repo, pull.number);
273+ if (input.needKeys.has(key)) continue;
274+ rows.set(key, {
275+ key,
276+ repo,
277+ ref: `#${pull.number}`,
278+ title: pull.title,
279+ chip: STAGE_CHIP[lifecycle.stage],
280+ detail: lifecycle.detail,
281+ by: who(pull.agent),
282+ at: Date.parse(pull.updatedAt),
283+ to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
284+ live: false,
285+ run: null,
286+ facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }),
287+ });
288+ }
289+ for (const run of input.live) {
290+ const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`;
291+ if (input.needKeys.has(key)) continue;
292+ const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`;
293+ const existing = rows.get(key);
294+ const started = Date.parse(run.startedAt ?? run.createdAt);
295+ const runFacts: Fact[] = [
296+ { label: "Run", value: RUN_LABEL[run.kind], tone: null },
297+ ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []),
298+ ];
299+ if (existing) {
300+ existing.live = true;
301+ existing.run = runTo;
302+ if (run.step) existing.detail = run.step;
303+ existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")];
304+ existing.at = Math.max(existing.at, Date.parse(run.updatedAt));
305+ continue;
306+ }
307+ rows.set(key, {
308+ key,
309+ repo: run.repo,
310+ ref: run.number != null ? `#${run.number}` : null,
311+ title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`,
312+ chip: RUN_LABEL[run.kind],
313+ detail: run.step ?? "Starting.",
314+ by: who(run.agent),
315+ at: Number.isFinite(started) ? started : Date.parse(run.updatedAt),
316+ to: runTo,
317+ live: true,
318+ run: runTo,
319+ facts: runFacts,
320+ });
321+ }
322+ for (const draft of input.drafts) {
323+ const key = pathKey(draft.repo, draft.number);
324+ if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue;
325+ rows.set(key, {
326+ key,
327+ repo: draft.repo,
328+ ref: `#${draft.number}`,
329+ title: draft.title,
330+ chip: "Working",
331+ detail: `${draft.agent} is making the change.`,
332+ by: who(draft.agent),
333+ at: Date.parse(draft.updatedAt),
334+ to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`,
335+ live: false,
336+ run: null,
337+ facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }),
338+ });
339+ }
340+ // Running now first, then what moved most recently.
341+ return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at);
342+}
343+
344+/** The key a need is known by when matching it to agents' work. */
345+export const needPathKey = pathKey;
346+
347+// --- Landed -----------------------------------------------------------------
348+
349+/** A merged pull request, as the week counts it. */
350+export type Merged = {
351+ repo: RepoPath;
352+ number: number;
353+ title: string;
354+ agent: string;
355+ mergedBy: string | null;
356+ mergedAt: string;
357+ files: ChangedFile[];
358+};
359+
360+/**
361+ * Whether a change landed without a person: g1t merged it, by auto-merge or
362+ * from the merge queue, rather than someone pressing merge.
363+ */
364+export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean {
365+ return change.mergedBy == null || isAgent(change.mergedBy);
366+}
367+
368+/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */
369+export function dayKey(at: number, timeZone: string | null): string {
370+ try {
371+ return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
372+ } catch {
373+ return new Date(at).toISOString().slice(0, 10);
374+ }
375+}
376+
377+export type WeekDay = { key: string; label: string; agents: number; people: number };
378+
379+export type Week = {
380+ days: WeekDay[];
381+ /** Changes landed in the last seven days, and how many without a person. */
382+ total: number;
383+ byAgents: number;
384+ /** The seven days before, or null when the lists read do not reach back that far. */
385+ previous: number | null;
386+};
387+
388+/**
389+ * The last seven days in the viewer's zone, oldest first, each split into
390+ * what agents landed alone and what a person merged, and the week before
391+ * as one number. `complete` says whether what was read reaches back two
392+ * weeks; when it does not, the week before is not guessed.
393+ */
394+export function weekOf(changes: Pick<Merged, "mergedAt" | "mergedBy">[], now: number, timeZone: string | null, complete = true): Week {
395+ const days: WeekDay[] = [];
396+ const index = new Map<string, number>();
397+ for (let back = 6; back >= 0; back -= 1) {
398+ const at = now - back * DAY;
399+ const key = dayKey(at, timeZone);
400+ if (index.has(key)) continue;
401+ index.set(key, days.length);
402+ let label: string;
403+ try {
404+ label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at);
405+ } catch {
406+ label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
407+ }
408+ days.push({ key, label, agents: 0, people: 0 });
409+ }
410+ const oldest = days[0]?.key ?? "";
411+ const twoWeeks = dayKey(now - 13 * DAY, timeZone);
412+ let previous = 0;
413+ for (const change of changes) {
414+ const at = Date.parse(change.mergedAt);
415+ if (!Number.isFinite(at) || at > now) continue;
416+ const key = dayKey(at, timeZone);
417+ const slot = index.get(key);
418+ if (slot != null) {
419+ if (landedByAgents(change)) days[slot].agents += 1;
420+ else days[slot].people += 1;
421+ } else if (key < oldest && key >= twoWeeks) {
422+ previous += 1;
423+ }
424+ }
425+ const byAgents = days.reduce((sum, day) => sum + day.agents, 0);
426+ const total = byAgents + days.reduce((sum, day) => sum + day.people, 0);
427+ return { days, total, byAgents, previous: complete ? previous : null };
428+}
429+
430+/** The change from one number to another, as a share; null from nothing. */
431+export function change(current: number, previous: number | null): number | null {
432+ if (previous == null || previous === 0) return null;
433+ return (current - previous) / previous;
434+}
435+
436+/** "+18%", "−5%", "0%". */
437+export function signedPercent(share: number): string {
438+ const n = Math.round(share * 100);
439+ return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%";
440+}
441+
442+/**
443+ * Whether the merged pull requests read for one repository reach back to
444+ * `since`: the list is not full, or its oldest goes back that far.
445+ */
446+export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean {
447+ if (list.length < page) return true;
448+ const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite));
449+ return oldest <= since;
450+}
451+
452+/** One change that landed, as the third tab lists it. */
453+export type LandedRow = {
454+ key: string;
455+ repo: RepoPath;
456+ ref: string;
457+ title: string;
458+ by: Who | null;
459+ agent: string;
460+ byAgents: boolean;
461+ at: number;
462+ to: string;
463+ facts: Fact[];
464+};
465+
466+/** What landed on the viewer's calendar day, newest first. */
467+export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] {
468+ const today = dayKey(now, timeZone);
469+ return changes
470+ .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today)
471+ .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt))
472+ .map((change) => ({
473+ key: pathKey(change.repo, change.number),
474+ repo: change.repo,
475+ ref: `#${change.number}`,
476+ title: change.title,
477+ by: who(change.mergedBy ?? "g1t"),
478+ agent: change.agent,
479+ byAgents: landedByAgents(change),
480+ at: Date.parse(change.mergedAt),
481+ to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
482+ facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Checks"),
483+ }));
484+}
485+
486+// --- Tabs and sorting -------------------------------------------------------
487+
488+export type Tab = "needs" | "waiting" | "landed";
489+export type Sort = "impact" | "newest";
490+
491+export const TABS: Tab[] = ["needs", "waiting", "landed"];
492+
493+export function parseTab(value: string | null): Tab | null {
494+ return value === "needs" || value === "waiting" || value === "landed" ? value : null;
495+}
496+
497+export function parseSort(value: string | null): Sort {
498+ return value === "newest" ? "newest" : "impact";
499+}
500+
501+/** Rows in the order chosen: as ranked (most urgent first), or newest first. */
502+export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] {
503+ return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows;
504+}
505+
506+// --- The summary ------------------------------------------------------------
507+
508+/** The sentence under the greeting: the week, honestly, in one line. */
509+export function summaryLine(input: { total: number; byAgents: number; live: number; needs: number }): string {
510+ const { total, byAgents, live, needs } = input;
511+ if (total > 0) {
512+ const landed =
513+ byAgents === total
514+ ? `Agents landed all ${plural(total, "change")} this week without you.`
515+ : byAgents === 0
516+ ? `${plural(total, "change")} landed this week, each merged by a person.`
517+ : `Agents landed ${byAgents} of ${plural(total, "change")} this week without you.`;
518+ return landed;
519+ }
520+ if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`;
521+ if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
522+ return "Nothing has landed this week yet. Assign an issue to g1t-agent and it starts in seconds.";
523+}
524+
525+/** "Monday, Oct 5", in the viewer's zone. */
526+export function dateLine(now: number, timeZone: string | null): string {
527+ const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" };
528+ try {
529+ return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
530+ } catch {
531+ return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
532+ }
533+}
+258−240
11 import { env } from "cloudflare:workers";
22 import { Suspense, lazy } from "react";
3−import { data } from "react-router";
3+import { type ShouldRevalidateFunctionArgs, data } from "react-router";
44
55 import {
6− type AgentRun,
7− type AuthoredItem,
8− type CheckStatus,
9− type Deployment,
10− type G1tEvent,
11− type Issue,
126 type Lifecycle,
13− type LiveApp,
147 type Pull,
15− type PullStatus,
168 type Repo,
179 type RepoPath,
18− type Stage,
1910 REPO_ROLE_LABELS,
2011 isActiveRun,
2112 } from "@g1t/contracts";
2617 import {
2718 type ActivityItem,
2819 type Need,
29− SEEN_COOKIE,
3020 TIME,
3121 agentHours,
3222 dailyBuckets,
33− digestParts,
3423 eventItem,
35− firstPassRate,
3624 greetingFor,
3725 groupActivity,
3826 hourIn,
39− issueToMerge,
40− median,
41− nextSeen,
27+ isAgent,
4228 rankNeeds,
4329 readCookie,
4430 stuckMinutes,
4531 } from "../lib/mission";
32+import {
33+ type Fact,
34+ type Merged,
35+ type NeedRow,
36+ type QuickAction,
37+ RUN_LABEL,
38+ dateLine,
39+ dayKey,
40+ landedToday,
41+ needPathKey,
42+ pullFacts,
43+ reachesBack,
44+ reasonFor,
45+ summaryLine,
46+ usd,
47+ waitingRows,
48+ weekOf,
49+ who,
50+ whyFor,
51+} from "../lib/mission-control";
4652 import { Landing } from "../components/landing";
4753 import {
4854 agents,
6167 /** The most projects whose pull requests and events are read for the page. */
6268 const MAX_PROJECTS = 10;
6369 const EVENTS_PER_PROJECT = 80;
70+/** How many pull requests work lists at once (`LIST_PAGE` in services/work). */
71+const PULL_PAGE = 100;
6472
6573 export function meta(args: Route.MetaArgs) {
6674 return page(args, {
7987 return out;
8088 }
8189
82−/** A row of the viewer's pull requests, whichever list it comes from. */
83−type PullRow = {
84− key: string;
85− repo: RepoPath;
86− number: number;
87− title: string;
88− status: PullStatus;
89− agent: string | null;
90− stage: Stage | null;
91− detail: string | null;
92− checkStatus: CheckStatus | null;
93− updatedAt: string;
94−};
95−
96−/** A row of the viewer's issues. */
97−type IssueRow = {
98− key: string;
99− repo: RepoPath;
100− number: number;
101− title: string;
102− agent: string | null;
103− queued: boolean;
104− updatedAt: string;
105−};
106−
107−type ProjectHealth = {
108− slug: string;
109− name: string;
110− private: boolean;
111− repo: RepoPath | null;
112− deploys: boolean;
113− production: LiveApp | null;
114− latest: Deployment | null;
115− openPulls: number | null;
116− agents: number;
117− passRate: number | null;
118− checks: number;
119−};
90+/**
91+ * Switching tabs, the sort or the activity list changes only the address:
92+ * everything is already loaded. A refresh, a form, or anything else loads
93+ * again as usual.
94+ */
95+export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
96+ if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false;
97+ return defaultShouldRevalidate;
98+}
12099
121−const pullRow = (pull: Pull, repo: RepoPath, lifecycle: Lifecycle | null): PullRow => ({
122− key: pull.id,
123− repo,
124− number: pull.number,
125− title: pull.title,
126− status: pull.status,
127− agent: pull.agent,
128− stage: lifecycle?.stage ?? null,
129− detail: lifecycle?.detail ?? null,
130− checkStatus: pull.checkStatus,
131− updatedAt: pull.updatedAt,
132−});
100+/** Where a need came from, so its row can say what is known about it. */
101+type Extra = Partial<Pick<NeedRow, "repo" | "ref" | "by" | "for" | "facts" | "quick" | "link" | "open">>;
133102
134−const authoredPull = (item: AuthoredItem): PullRow => ({
135− key: `${item.repo.namespace}/${item.repo.name}#${item.number}`,
136− repo: item.repo,
137− number: item.number,
138− title: item.title,
139− status: item.status ?? (item.draft ? "draft" : "open"),
140− agent: null,
141− stage: null,
142− detail: null,
143− checkStatus: null,
144− updatedAt: item.updatedAt,
145−});
146−
147103 export async function loader({ context, request }: Route.LoaderArgs) {
148104 const viewer = getViewer(context);
149105 if (!viewer) {
169125 const okOr = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
170126
171127 const cookies = request.headers.get("cookie");
172− const seen = nextSeen(readCookie(cookies, SEEN_COOKIE), now);
173128 const tz = readCookie(cookies, TZ_COOKIE);
174129 const memberships = viewer.workspaces ?? [];
175130 // The workspace you chose, as the sidebar shows it (lib/workspace-choice.ts).
176131 const slug = chosenWorkspace(memberships, readCookie(cookies, WORKSPACE_COOKIE))?.slug ?? null;
177− const mine = new Set(memberships.map((m) => m.slug.toLowerCase()));
178132 const username = viewer.username;
179133
180134 const reposP = soft("repos", reposApi.list(viewer, { memberOnly: true }));
181− // The workspace's projects' open pull requests and recent events, read
182− // once each, all at once, as soon as the projects are known.
135+ // The chosen workspace's projects: their open and recently merged pull
136+ // requests and recent events, read once each, all at once, as soon as
137+ // the projects are known.
183138 const perRepoP = reposP.then((repos) =>
184139 Promise.all(
185140 (repos ?? [])
186− .filter((repo) => mine.has(repo.namespace.toLowerCase()))
141+ .filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase())
187142 .slice(0, MAX_PROJECTS)
188143 .map(async (repo) => {
189144 const path = { namespace: repo.namespace, name: repo.name };
190− const [pulls, log] = await Promise.all([
145+ const [pulls, closed, log] = await Promise.all([
191146 work.listPulls(path, viewer, "open").catch(() => null),
147+ work.listPulls(path, viewer, "closed").catch(() => null),
192148 eventLog.list({ repoId: repo.id, limit: EVENTS_PER_PROJECT }).catch(() => null),
193149 ]);
194− return { repo, pulls: pulls?.ok ? pulls.value.slice(0, 60) : null, events: log };
150+ return {
151+ repo,
152+ pulls: pulls?.ok ? pulls.value.slice(0, 60) : null,
153+ closed: closed?.ok ? closed.value : null,
154+ events: log,
155+ };
195156 }),
196157 ),
197158 );
198159
199− const [repos, perRepo, active, assigned, models, profile, runs, overview, usage, authoredPulls, authoredIssues, projectList, memories, invitations] =
200− await Promise.all([
201− reposP,
202− soft("projects", perRepoP),
203− soft("pulls", work.listActivePulls(viewer)),
204− soft("assigned", work.listAssignedIssues(viewer)),
205− slug ? soft("models", env.RUNNER.modelAccess(slug)) : null,
206− soft("profile", identity.profile(username)),
207− slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null,
208− slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
209− slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null,
210− soft("authoredPulls", work.byAuthor(username, viewer, { kind: "pull", state: "open", sort: "updated", limit: 10 })),
211− soft("authoredIssues", work.byAuthor(username, viewer, { kind: "issue", state: "open", sort: "updated", limit: 10 })),
212− slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
213− slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null,
214− // Repositories someone has invited the viewer to.
215− soft("invitations", identity.myRepoInvitations(viewer)),
216− ]);
160+ const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations] = await Promise.all([
161+ reposP,
162+ soft("projects", perRepoP),
163+ soft("pulls", work.listActivePulls(viewer)),
164+ slug ? soft("models", env.RUNNER.modelAccess(slug)) : null,
165+ soft("profile", identity.profile(username)),
166+ slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null,
167+ slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
168+ slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null,
169+ slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
170+ slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null,
171+ // Repositories someone has invited the viewer to.
172+ soft("invitations", identity.myRepoInvitations(viewer)),
173+ ]);
217174
218175 const repoList = repos ?? [];
219− // Each issue and pull request is shown under its repository. Most are in
220− // the viewer's own, already listed; the rest are looked up once each.
176+ // Each pull request is shown under its repository. Most are in the
177+ // viewer's own, already listed; the rest are looked up once each.
221178 const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo]));
222− const missing = [
223− ...new Set([...(assigned ?? []).map((issue) => issue.repoId), ...(active ?? []).map(({ pull }) => pull.repoId)]),
224− ].filter((id) => !known.has(id));
179+ const missing = [...new Set((active ?? []).map(({ pull }) => pull.repoId))].filter((id) => !known.has(id));
225180 const looked = await Promise.all(missing.map((id) => reposApi.getById(id, viewer).catch(() => null)));
226181 for (const found of looked) if (found?.ok) known.set(found.value.id, found.value);
227182 const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name });
183+ const inWorkspace = (repo: RepoPath) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase();
228184
229185 const activeList = (active ?? []).flatMap((item) => {
230186 const repo = known.get(item.pull.repoId);
234190 const liveRuns = runList.filter((run) => isActiveRun(run.status));
235191 const overviewList = okOr(overview ?? null);
236192 const projectsOk = okOr(projectList ?? null);
193+ const runsOn = (repo: RepoPath, number: number) =>
194+ runList.filter(
195+ (run) => run.number === number && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === `${repo.namespace}/${repo.name}`.toLowerCase(),
196+ );
237197
238− // --- What the viewer's pull requests and issues are ----------------------
239198 const lower = username.toLowerCase();
240199 const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo })));
241200 const reviewRequested = openPulls.filter(
244203 pull.author.username.toLowerCase() !== lower &&
245204 pull.reviewers.some((name) => name.toLowerCase() === lower),
246205 );
247− const assignedPulls = openPulls.filter(({ pull }) => pull.assignees.some((name) => name.toLowerCase() === lower));
248− const authoredRows =
249− activeList.length > 0
250− ? activeList.map(({ pull, lifecycle, repo }) => pullRow(pull, pathOf(repo), lifecycle))
251− : (okOr(authoredPulls)?.items ?? []).map(authoredPull);
252− const pullsTabs = {
253− authored: authoredRows.slice(0, 8),
254− review: reviewRequested.slice(0, 8).map(({ pull, repo }) => pullRow(pull, pathOf(repo), null)),
255− assigned: assignedPulls.slice(0, 8).map(({ pull, repo }) => pullRow(pull, pathOf(repo), null)),
206+
207+ // --- Needs you ------------------------------------------------------------
208+ const needs: Need[] = [];
209+ const extras = new Map<string, Extra>();
210+ /** What every pull request's row shares: where it is, who is on it, what is known. */
211+ const pullExtra = (pull: Pull, repo: Repo, lifecycle: Lifecycle | null): Extra => {
212+ const base = `/${repo.namespace}/${repo.name}`;
213+ const agentWork = isAgent(pull.agent);
214+ return {
215+ repo: pathOf(repo),
216+ ref: `#${pull.number}`,
217+ by: agentWork ? who(pull.agent) : who(pull.author.username),
218+ for: agentWork ? pull.author.username : null,
219+ facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle, runs: runsOn(repo, pull.number) }),
220+ open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}?tab=changes`,
221+ };
256222 };
257− const issueRow = (issue: Issue, repo: RepoPath): IssueRow => ({
258− key: issue.id,
259− repo,
260− number: issue.number,
261− title: issue.title,
262− agent: issue.agent,
263− queued: issue.queued,
264− updatedAt: issue.updatedAt,
223+ const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({
224+ ...quick,
225+ to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
265226 });
266− const issuesTabs = {
267− assigned: (assigned ?? []).flatMap((issue) => {
268− const repo = known.get(issue.repoId);
269− return repo ? [issueRow(issue, pathOf(repo))] : [];
270− }).slice(0, 8),
271− authored: (okOr(authoredIssues)?.items ?? []).slice(0, 8).map((item) => ({
272− key: `${item.repo.namespace}/${item.repo.name}#${item.number}`,
273− repo: item.repo,
274− number: item.number,
275− title: item.title,
276− agent: null,
277− queued: false,
278− updatedAt: item.updatedAt,
279− })),
280− };
227+ const approve = (repo: Repo, pull: Pull) =>
228+ pullAction(repo, pull, {
229+ label: isAgent(pull.agent) ? "Approve the agent's change" : "Approve the change",
230+ fields: { action: "comment", verdict: "approve", body: "" },
231+ done: "Approved",
232+ });
281233
282− // --- Needs you ------------------------------------------------------------
283− const needs: Need[] = [];
284234 for (const invitation of invitations ?? []) {
285235 if (invitation.status !== "pending") continue;
236+ const key = `invitation:${invitation.id}`;
286237 needs.push({
287− key: `invitation:${invitation.id}`,
238+ key,
288239 kind: "invitation",
289240 title: `${invitation.invited_by ?? "Someone"} invited you to ${invitation.repo}`,
290241 detail: `With the ${REPO_ROLE_LABELS[invitation.role]} role. The invitation expires ${new Date(invitation.expires_at).toISOString().slice(0, 10)}.`,
293244 at: Date.parse(invitation.created_at),
294245 where: invitation.repo,
295246 });
247+ const [namespace, name] = invitation.repo.split("/");
248+ extras.set(key, {
249+ repo: namespace && name ? { namespace, name } : null,
250+ by: who(invitation.invited_by),
251+ facts: [
252+ { label: "Role", value: REPO_ROLE_LABELS[invitation.role], tone: null },
253+ { label: "Expires", value: new Date(invitation.expires_at).toISOString().slice(0, 10), tone: null },
254+ ],
255+ });
296256 }
297257 for (const entry of overviewList ?? []) {
298258 const latest = entry.latest;
299259 if (latest?.kind === "production" && latest.status === "failed" && slug) {
260+ const key = `deploy:${entry.slug}`;
261+ const to = `/${slug}/${entry.slug}/deployments/${latest.id}`;
300262 needs.push({
301− key: `deploy:${entry.slug}`,
263+ key,
302264 kind: "deploy",
303265 title: `Production build of ${entry.slug} failed`,
304266 detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.",
305− to: `/${slug}/${entry.slug}/deployments/${latest.id}`,
267+ to,
306268 action: "See the build",
307269 at: Date.parse(latest.finishedAt ?? latest.createdAt),
308270 where: `${slug}/${entry.slug}`,
309271 });
272+ const facts: Fact[] = [
273+ { label: "Commit", value: latest.commit.slice(0, 7), tone: null },
274+ {
275+ label: "Production",
276+ value: entry.production ? "Serving the build before" : "Not live yet",
277+ tone: entry.production ? "good" : "warn",
278+ },
279+ ];
280+ extras.set(key, {
281+ repo: { namespace: slug, name: entry.slug },
282+ by: who(latest.createdBy),
283+ facts,
284+ link: { label: "Deployment settings", to: `/${slug}/${entry.slug}/settings/deployments` },
285+ });
310286 }
311287 }
312288 for (const { pull, lifecycle, repo } of activeList) {
313289 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
314290 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
291+ const key = `pull:${pull.id}`;
292+ const extra = pullExtra(pull, repo, lifecycle);
315293 if (lifecycle?.stage === "needs_you") {
316294 const conflict = /conflict/i.test(lifecycle.detail);
317− needs.push({
318− key: `pull:${pull.id}`,
295+ const need: Need = {
296+ key,
319297 kind: conflict ? "conflict" : "stalled",
320298 title: pull.title,
321299 detail: lifecycle.detail,
323301 action: conflict ? "Resolve" : "Decide",
324302 at: Date.parse(pull.updatedAt),
325303 where,
304+ };
305+ needs.push(need);
306+ const reason = reasonFor(need);
307+ extras.set(key, {
308+ ...extra,
309+ quick:
310+ reason === "needs_review"
311+ ? approve(repo, pull)
312+ : /could not be run/i.test(lifecycle.detail)
313+ ? pullAction(repo, pull, { label: "Run the checks again", fields: { action: "recheck" }, done: "Checks started" })
314+ : null,
315+ link: reason === "outside_guardrails" ? { label: "Raise the cap", to: `/${repo.namespace}/${repo.name}/settings/guardrails` } : null,
326316 });
327317 } else if (lifecycle?.stage === "ready") {
328− needs.push({ key: `pull:${pull.id}`, kind: "ready", title: pull.title, detail: "Checks passed and it was approved. It lands when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where });
318+ needs.push({ key, kind: "ready", title: pull.title, detail: "Checks passed and it was approved. It lands when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where });
319+ extras.set(key, {
320+ ...extra,
321+ quick: pullAction(repo, pull, {
322+ label: isAgent(pull.agent) ? "Merge the agent's change" : "Merge it",
323+ fields: { action: "merge" },
324+ done: "Merging",
325+ }),
326+ });
329327 } else if (!lifecycle && pull.status === "open" && pull.checkStatus === "failed") {
330− needs.push({ key: `pull:${pull.id}`, kind: "checks", title: pull.title, detail: "Its acceptance checks failed on the latest push.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
328+ needs.push({ key, kind: "checks", title: pull.title, detail: "Its acceptance checks failed on the latest push.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
329+ extras.set(key, {
330+ ...extra,
331+ quick: pullAction(repo, pull, { label: "Run the checks again", fields: { action: "recheck" }, done: "Checks started" }),
332+ });
331333 }
332334 }
333335 for (const { pull, repo } of reviewRequested) {
336+ const key = `review:${pull.id}`;
334337 needs.push({
335− key: `review:${pull.id}`,
338+ key,
336339 kind: "review",
337340 title: pull.title,
338341 detail: `${pull.author.username} asked for your review.`,
341344 at: Date.parse(pull.updatedAt),
342345 where: `${repo.namespace}/${repo.name}#${pull.number}`,
343346 });
347+ extras.set(key, { ...pullExtra(pull, repo, null), quick: approve(repo, pull) });
344348 }
345− let quietest: number | null = null;
346349 for (const run of liveRuns) {
347350 const minutes = stuckMinutes(run, now);
348351 if (minutes == null) continue;
349− quietest = Math.max(quietest ?? 0, minutes);
352+ const key = `run:${run.id}`;
350353 needs.push({
351− key: `run:${run.id}`,
354+ key,
352355 kind: "stuck",
353356 title: run.title ?? `${run.agent}'s run`,
354357 detail: `${run.agent} has reported nothing for ${minutes} min${run.step ? `. Last: ${run.step}` : ""}.`,
357360 at: Date.parse(run.updatedAt),
358361 where: `${run.repo.namespace}/${run.repo.name}${run.number != null ? `#${run.number}` : ""}`,
359362 });
363+ extras.set(key, {
364+ repo: run.repo,
365+ ref: run.number != null ? `#${run.number}` : null,
366+ by: who(run.agent),
367+ facts: [
368+ { label: "Run", value: RUN_LABEL[run.kind], tone: null },
369+ { label: "Quiet for", value: `${minutes} min`, tone: "warn" },
370+ { label: "Steps so far", value: String(run.stepCount), tone: null },
371+ ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []),
372+ ],
373+ open: run.number != null ? `/${run.repo.namespace}/${run.repo.name}/pull/${run.number}` : undefined,
374+ });
360375 }
361376
377+ const needRows: NeedRow[] = rankNeeds(needs).map((need) => {
378+ const extra = extras.get(need.key) ?? {};
379+ const reason = reasonFor(need);
380+ return {
381+ key: need.key,
382+ reason,
383+ repo: extra.repo ?? null,
384+ ref: extra.ref ?? null,
385+ title: need.title,
386+ ask: need.detail,
387+ by: extra.by ?? null,
388+ for: extra.for ?? null,
389+ at: need.at,
390+ to: need.to,
391+ open: extra.open ?? need.to,
392+ facts: extra.facts ?? [],
393+ why: whyFor(reason, need),
394+ quick: extra.quick ?? null,
395+ link: extra.link ?? null,
396+ };
397+ });
398+
399+ // --- Waiting on agents ----------------------------------------------------
400+ const needKeys = new Set(
401+ needRows.flatMap((row) => (row.repo && row.ref ? [needPathKey(row.repo, Number(row.ref.slice(1)))] : [])),
402+ );
403+ const waiting = waitingRows({
404+ active: activeList.filter(({ repo }) => inWorkspace(repo)).map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo: pathOf(repo) })),
405+ live: liveRuns,
406+ drafts: openPulls.filter(({ pull }) => pull.status === "draft").map(({ pull, repo }) => ({ ...pull, repo: pathOf(repo) })),
407+ needKeys,
408+ });
409+
410+ // --- Landed ---------------------------------------------------------------
411+ const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) =>
412+ (closed ?? []).flatMap((pull) =>
413+ pull.status === "merged" && pull.mergedAt
414+ ? [{ repo: pathOf(repo), number: pull.number, title: pull.title, agent: pull.agent, mergedBy: pull.mergedBy, mergedAt: pull.mergedAt, files: pull.files }]
415+ : [],
416+ ),
417+ );
418+ const twoWeeksAgo = now - 14 * TIME.DAY;
419+ const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE));
420+ const week = weekOf(merged, now, tz, complete);
421+ const landed = landedToday(merged, now, tz);
422+
362423 // --- Activity -------------------------------------------------------------
363424 const items: ActivityItem[] = [];
364− const allEvents: { repo: string; event: G1tEvent }[] = [];
365− for (const { repo, events } of perRepo ?? []) {
425+ const titles: Record<string, string> = {};
426+ const titleKey = (repo: RepoPath, number: number) => `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
427+ for (const { repo, pulls, closed, events } of perRepo ?? []) {
428+ for (const pull of [...(pulls ?? []), ...(closed ?? [])]) titles[titleKey(repo, pull.number)] = pull.title;
366429 for (const event of events ?? []) {
367− allEvents.push({ repo: repo.id, event });
430+ if (event.type === "issue.opened") titles[titleKey(repo, event.data.number)] ??= event.data.title;
368431 const item = eventItem(event, pathOf(repo));
369432 if (item) items.push(item);
370433 }
397460 to: `/${slug}/-/memory`,
398461 });
399462 }
400− const groups = groupActivity(items).slice(0, 60);
401−
402− // --- Since you were last here --------------------------------------------
403− const since = seen.since ?? now - TIME.DAY;
404− const after = (verb: ActivityItem["verb"]) => items.filter((item) => item.verb === verb && item.at > since).length;
405− const digest = digestParts({
406− landed: after("landed"),
407− reviews: reviewRequested.length,
408− opened: after("opened_issue"),
409− deploys: after("deployed"),
410− failedDeploys: after("deploy_failed"),
411− stuck: quietest,
412− });
463+ const groups = groupActivity(items).slice(0, 40);
464+ // Only the titles the feed names travel to the page.
465+ const shownTitles: Record<string, string> = {};
466+ for (const group of groups) {
467+ for (const part of group.parts) {
468+ for (const number of part.numbers) {
469+ const key = titleKey(group.repo, number);
470+ if (titles[key]) shownTitles[key] = titles[key];
471+ }
472+ }
473+ }
413474
414− // --- Pulse ----------------------------------------------------------------
415− const week = allEvents.filter(({ event }) => Date.parse(event.time) >= weekAgo);
416− const mergedWeek = week.filter(({ event }) => event.type === "pull.merged");
417− const spans = issueToMerge(
418− allEvents.flatMap(({ repo, event }) => (event.type === "issue.opened" ? [{ repo, number: event.data.number, at: Date.parse(event.time) }] : [])),
419− mergedWeek.flatMap(({ repo, event }) => (event.type === "pull.merged" ? [{ repo, issue: event.data.issue ?? null, at: Date.parse(event.time) }] : [])),
420− );
421− const checkEvents = allEvents.flatMap(({ repo, event }) =>
422− event.type === "checks.completed" ? [{ repo, number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [],
423− );
424− const firstPass = firstPassRate(checkEvents.filter((c) => c.at >= weekAgo));
475+ // --- The strip ------------------------------------------------------------
425476 const usageOk = okOr(usage ?? null);
426477 const costPoints = usageOk
427478 ? usageOk.byDay.map((slice) => ({ at: Date.parse(`${slice.key.split("/")[0]}T12:00:00Z`), value: slice.micros / 1_000_000 }))
428479 : runList.filter((run) => run.costUsd != null).map((run) => ({ at: Date.parse(run.createdAt), value: run.costUsd ?? 0 }));
429− const costDays = dailyBuckets(costPoints, 7, now);
430− const pulse = {
431− merged: mergedWeek.length,
432− mergedDays: dailyBuckets(mergedWeek.map(({ event }) => ({ at: Date.parse(event.time) })), 7, now),
433− hours: agentHours(runList, weekAgo, now),
434− hoursDays: dailyBuckets(
435− runList
436− .filter((run) => run.startedAt)
437− .map((run) => ({ at: Date.parse(run.startedAt!), value: agentHours([run], weekAgo, now) })),
438− 7,
439− now,
440− ),
441− cost: costDays.reduce((sum, v) => sum + v, 0),
442− costDays,
443− issueToMerge: median(spans),
444− mergedWithIssue: spans.length,
445− firstPass: firstPass.rate,
446− firstPassOf: firstPass.of,
447− };
480+ const weekCost = dailyBuckets(costPoints, 7, now).reduce((sum, v) => sum + v, 0);
481+ const month = dayKey(now, tz).slice(0, 7);
482+ const projectCount = projectsOk?.length ?? (perRepo != null ? perRepo.length : null);
483+ const projectsThisMonth = projectsOk ? projectsOk.filter((project) => dayKey(Date.parse(project.createdAt), tz).slice(0, 7) === month).length : null;
448484
449− // --- Projects -------------------------------------------------------------
450− const byRepoName = new Map((perRepo ?? []).map((entry) => [`${entry.repo.namespace}/${entry.repo.name}`.toLowerCase(), entry]));
451− const projectsHealth: ProjectHealth[] = (projectsOk ?? []).slice(0, 12).map((project) => {
452− const repo = project.source.kind === "hosted" ? project.source.repo : null;
453− const key = repo ? `${repo.namespace}/${repo.name}`.toLowerCase() : "";
454− const entry = byRepoName.get(key);
455− const deploy = overviewList?.find((d) => d.slug === project.slug) ?? null;
456− const checks = (entry?.events ?? []).flatMap((event) => (event.type === "checks.completed" ? [event.data.status === "passed"] : []));
457− return {
458− slug: project.slug,
459− name: project.name,
460− private: project.private,
461− repo,
462− deploys: deploy?.enabled ?? false,
463− production: deploy?.production ?? null,
464− latest: deploy?.latest ?? null,
465− openPulls: entry?.pulls ? entry.pulls.length : null,
466− agents: liveRuns.filter((run) => repo && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === key).length,
467− passRate: checks.length ? checks.filter(Boolean).length / checks.length : null,
468− checks: checks.length,
469− };
470− });
471−
472485 const models_ = models ?? null;
473486 times.total = Date.now() - started;
474487 const serverTiming = Object.entries(times)
475488 .map(([name, ms]) => `${name};dur=${ms}`)
476489 .join(", ");
477− const secure = new URL(request.url).protocol === "https:" ? "; Secure" : "";
478490 return data(
479491 {
480492 signedIn: true as const,
481493 viewer,
482494 name: profile?.name?.trim() || username,
483495 greeting: greetingFor(hourIn(now, tz)),
484− seenBefore: seen.since,
485− repos: repoList,
496+ date: dateLine(now, tz),
497+ summary: summaryLine({ total: week.total, byAgents: week.byAgents, live: liveRuns.length, needs: needRows.length }),
498+ workspace: slug,
499+ repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf),
486500 canRunAgents: models_ == null || models_.hosted || models_.own != null,
487501 trial: models_?.own == null ? (models_?.trial ?? null) : null,
488− active: activeList.map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo })),
489− needs: rankNeeds(needs),
490− live: liveRuns.slice(0, 12) as AgentRun[],
502+ handedOff: activeList.length > 0 || runList.length > 0 || merged.length > 0,
503+ needs: needRows,
504+ waiting,
505+ landed,
491506 liveTotal: liveRuns.length,
492507 runsLoaded: runs != null && runs.ok,
493− pullsTabs,
494− issuesTabs,
495508 perRepoLoaded: perRepo != null,
509+ stats: {
510+ projects: projectCount,
511+ projectsThisMonth,
512+ agentHours: agentHours(runList, weekAgo, now),
513+ weekCost,
514+ },
515+ week,
496516 groups,
497− digest,
498− pulse,
499− projects: projectsHealth,
500− projectsLoaded: projectsOk != null,
517+ titles: shownTitles,
518+ // A run that is going makes the page worth refreshing on its own.
519+ changing:
520+ liveRuns.length > 0 ||
521+ activeList.some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready"),
501522 },
502523 {
503− headers: {
504− "Server-Timing": serverTiming,
505− "Set-Cookie": `${SEEN_COOKIE}=${seen.value}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${secure}`,
506− },
524+ headers: { "Server-Timing": serverTiming },
507525 },
508526 );
509527 }
+15−1
3232 // renamed workspace's old name answers with a 301, which git follows and
3333 // must see, so the redirect is never followed here.
3434 if (GIT_PATH.test(pathname)) {
35− return env.REPOS.fetch(new Request(request, { redirect: "manual" }));
35+ return proxyGit(env, request);
3636 }
3737 const avatar = AVATAR_PATH.exec(pathname);
3838 if (avatar) {
4949 } satisfies ExportedHandler<Env>;
5050
5151 /**
52+ * A git request, answered by the repos service. Its `Server-Timing` header
53+ * gains `repos`: how long the answer took to start from here, so the time
54+ * between this Worker and the repos service shows beside the steps the
55+ * repos service reports.
56+ */
57+async function proxyGit(env: Env, request: Request): Promise<Response> {
58+ const started = Date.now();
59+ const answer = await env.REPOS.fetch(new Request(request, { redirect: "manual" }));
60+ const response = new Response(answer.body, answer);
61+ response.headers.append("server-timing", `repos;dur=${Date.now() - started}`);
62+ return response;
63+}
64+
65+/**
5266 * An uploaded avatar. Its address is its hash, so it never changes and is
5367 * kept for good. It is served as nothing but an image: the stored type,
5468 * no sniffing, and a policy that lets nothing in it run.
+59−1
4545 format!("{VERSION}{}", STANDARD.encode(out))
4646 }
4747
48+ /// Bytes sealed the same way, kept as bytes: the version, the nonce,
49+ /// then the ciphertext. For values stored as bytes, such as a cache's.
50+ pub fn seal_bytes(&self, plaintext: &[u8], bound_to: &str) -> Vec<u8> {
51+ let mut nonce = [0u8; 12];
52+ getrandom::getrandom(&mut nonce).expect("no source of randomness");
53+ let sealed = self
54+ .cipher
55+ .encrypt(
56+ Nonce::from_slice(&nonce),
57+ Payload {
58+ msg: plaintext,
59+ aad: bound_to.as_bytes(),
60+ },
61+ )
62+ .expect("encrypting cannot fail");
63+ let mut out = VERSION.as_bytes().to_vec();
64+ out.extend(nonce);
65+ out.extend(sealed);
66+ out
67+ }
68+
69+ /// What [`Sealer::seal_bytes`] sealed; `None` under another key, for
70+ /// another row, or for anything else.
71+ pub fn open_bytes(&self, sealed: &[u8], bound_to: &str) -> Option<Vec<u8>> {
72+ let bytes = sealed.strip_prefix(VERSION.as_bytes())?;
73+ if bytes.len() < 12 {
74+ return None;
75+ }
76+ let (nonce, ciphertext) = bytes.split_at(12);
77+ self.cipher
78+ .decrypt(
79+ Nonce::from_slice(nonce),
80+ Payload {
81+ msg: ciphertext,
82+ aad: bound_to.as_bytes(),
83+ },
84+ )
85+ .ok()
86+ }
87+
4888 /// `None` when it was sealed under another key or for another row.
4989 pub fn open(&self, sealed: &str, bound_to: &str) -> Option<String> {
5090 let bytes = STANDARD.decode(sealed.strip_prefix(VERSION)?).ok()?;
67107 }
68108
69109 pub fn sha256_hex(value: &str) -> String {
70− hex::encode(Sha256::digest(value.as_bytes()))
110+ sha256_hex_bytes(value.as_bytes())
111+}
112+
113+pub fn sha256_hex_bytes(value: &[u8]) -> String {
114+ hex::encode(Sha256::digest(value))
71115 }
72116
73117 pub fn random_hex(bytes: usize) -> String {
117161 }
118162
119163 #[test]
164+ fn sealed_bytes_open_only_for_their_own_key() {
165+ let sealer = Sealer::new(KEY).unwrap();
166+ let plain = b"0032HEAD\0symref=HEAD:refs/heads/main\n";
167+ let sealed = sealer.seal_bytes(plain, "refs:rep_1:abc");
168+ assert!(!sealed.windows(4).any(|window| window == b"HEAD"));
169+ assert_eq!(sealer.open_bytes(&sealed, "refs:rep_1:abc").as_deref(), Some(&plain[..]));
170+ assert_eq!(sealer.open_bytes(&sealed, "refs:rep_2:abc"), None);
171+ let other = Sealer::new(&"ff".repeat(32)).unwrap();
172+ assert_eq!(other.open_bytes(&sealed, "refs:rep_1:abc"), None);
173+ assert_eq!(sealer.open_bytes(b"v1:short", "refs:rep_1:abc"), None);
174+ assert_eq!(sealer.open_bytes(plain, "refs:rep_1:abc"), None);
175+ }
176+
177+ #[test]
120178 fn a_key_of_the_wrong_length_is_refused() {
121179 assert!(Sealer::new("abcd").is_none());
122180 }
+1−1
9595 | `apps/sudo` | TS Worker plus assets | Access JWT | Not run |
9696 | `apps/docs` | Static | — | Not run (docs.g1t.sh serves them) |
9797 | `services/identity` | Rust | Email Sending, KV `AVATARS` | Runs unchanged; `EMAIL` goes to the mail shim |
98−| `services/repos` | Rust | **Artifacts**, Cache API | Runs unchanged; `ARTIFACTS` goes to the git store |
98+| `services/repos` | Rust | **Artifacts**, Cache API, optional KV `GIT_CACHE` with `REPOS_KEY` | Runs unchanged; `ARTIFACTS` goes to the git store. Without `GIT_CACHE` and `REPOS_KEY`, credentials and ref listings are kept per isolate only |
9999 | `services/work` | Rust | Queue consumer | Runs unchanged |
100100 | `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to |
101101 | `services/projects` | TS | Queue consumer | Runs unchanged |
+1−0
1212 g1t-contracts.workspace = true
1313 g1t-kit.workspace = true
1414 g1t-scan.workspace = true
15+g1t-secrets.workspace = true
1516 serde.workspace = true
1617 serde_json.workspace = true
1718 worker.workspace = true
+14−0
1+-- Where a repository's refs stand, for the answers that list them, which
2+-- git asks for first on every clone and fetch and which are kept for a
3+-- moment (src/refs_cache.rs).
4+--
5+-- refs_version: goes up after everything g1t does that changes the refs:
6+-- a push through git over HTTPS, a merge, a pull request brought up to
7+-- date, a branch deleted or renamed, the default branch changed, a mirror
8+-- catching up, an import. An answer is kept under the version it was
9+-- made at, so a change leaves it behind.
10+-- refs_open_until: milliseconds since the epoch. Set when a credential
11+-- that can push is handed out of g1t's hands (`git_access`); until then
12+-- nothing is kept, because a push with it would not move the version.
13+ALTER TABLE repos ADD COLUMN refs_version INTEGER NOT NULL DEFAULT 0;
14+ALTER TABLE repos ADD COLUMN refs_open_until INTEGER;
+1−0
525525 // Only if the branch is still where it was: a push that landed
526526 // meanwhile is kept, and this is refused.
527527 let pushed = land::push_pack(&source_access, &branch, Some(&head.hash), &commit_id, pack).await?;
528+ self.refs_moved(&source.id).await;
528529 let git_ref = format!("refs/heads/{branch}");
529530 let path = RepoPath {
530531 namespace: source.namespace.clone(),
+78−3
5858 })
5959 }
6060
61+/// How long each step of a git request took, sent back to git as a
62+/// `Server-Timing` header so that a slow clone shows where its time went.
63+/// Step names and whole milliseconds only. The Workers clock moves only
64+/// while a request waits on something, so each step is the time spent
65+/// waiting on the database, another service or the git store. A note
66+/// says how a step went without a duration: `refs;desc=hit-colo`.
67+pub struct Timing {
68+ started: u64,
69+ last: u64,
70+ steps: Vec<(&'static str, u64)>,
71+ notes: Vec<(&'static str, &'static str)>,
72+}
73+
74+impl Timing {
75+ pub fn start() -> Self {
76+ let now = g1t_kit::now_ms();
77+ Self {
78+ started: now,
79+ last: now,
80+ steps: Vec::new(),
81+ notes: Vec::new(),
82+ }
83+ }
84+
85+ /// Says how `name` went: where an answer or a credential came from.
86+ pub fn note(&mut self, name: &'static str, description: &'static str) {
87+ self.notes.push((name, description));
88+ }
89+
90+ /// Ends a step, named `step`, that began when the last one ended.
91+ pub fn mark(&mut self, step: &'static str) {
92+ let now = g1t_kit::now_ms();
93+ self.steps.push((step, now.saturating_sub(self.last)));
94+ self.last = now;
95+ }
96+
97+ /// `response`, with how long each step took.
98+ pub fn apply(&self, response: Response) -> Result<Response> {
99+ let total = g1t_kit::now_ms().saturating_sub(self.started);
100+ let headers = response.headers().clone();
101+ headers.set("server-timing", &server_timing(&self.steps, &self.notes, total))?;
102+ Ok(response.with_headers(headers))
103+ }
104+}
105+
106+/// A `Server-Timing` value: each step with its duration, the notes, then
107+/// the total.
108+fn server_timing(steps: &[(&str, u64)], notes: &[(&str, &str)], total: u64) -> String {
109+ steps
110+ .iter()
111+ .map(|(step, ms)| format!("{step};dur={ms}"))
112+ .chain(notes.iter().map(|(name, description)| format!("{name};desc={description}")))
113+ .chain(std::iter::once(format!("total;dur={total}")))
114+ .collect::<Vec<_>>()
115+ .join(", ")
116+}
117+
61118 /// The user named by an HTTP Basic `Authorization` header, as git sends it.
62119 pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> {
63120 let Some(header) = request.headers().get("authorization")? else {
471528 /// Sends the request on to the git store and returns its response as is,
472529 /// unless it is a push that would change the `protected` branch, or one
473530 /// that `scan` (push protection) answers itself. A fetch's ref listing has
474−/// its `HEAD` pointed at `default_branch` (see [`with_head`]).
531+/// its `HEAD` pointed at `default_branch` (see [`with_head`]). A POST's
532+/// body is `read` when the caller has read it already.
475533 pub async fn forward(
476534 mut request: Request,
535+ read: Option<Vec<u8>>,
477536 git: &GitRequest,
478537 access: &GitAccess,
479538 protected: Option<&str>,
499558 let mut lists_head = request.method() == Method::Get && names_head(git, None);
500559 if request.method() == Method::Post {
501560 // Pushes are capped at 100 MB by the platform, so buffering is safe.
502− let body = request.bytes().await?;
561+ let body = match read {
562+ Some(body) => body,
563+ None => request.bytes().await?,
564+ };
503565 if git.endpoint == "git-receive-pack" {
504566 if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) {
505567 let headers = Headers::new();
539601
540602 #[cfg(test)]
541603 mod tests {
542− use super::{Pushed, RepoPath, Url, ZERO_ID, framed, pack_bytes, pushed_branches, refusal, transferred, with_head, with_namespace};
604+ use super::{Pushed, RepoPath, Url, ZERO_ID, framed, pack_bytes, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace};
605+
606+ #[test]
607+ fn server_timing_names_each_step_and_the_total() {
608+ assert_eq!(
609+ server_timing(&[("repo", 12), ("token", 0), ("store", 140)], &[], 153),
610+ "repo;dur=12, token;dur=0, store;dur=140, total;dur=153"
611+ );
612+ assert_eq!(server_timing(&[], &[], 3), "total;dur=3");
613+ assert_eq!(
614+ server_timing(&[("repo", 1), ("cache", 2)], &[("refs", "hit-colo")], 4),
615+ "repo;dur=1, cache;dur=2, refs;desc=hit-colo, total;dur=4"
616+ );
617+ }
543618
544619 #[test]
545620 fn a_renamed_repository_redirects_to_its_new_name() {
+412−111
1616 mod listing;
1717 mod mirror;
1818 mod refs;
19+mod refs_cache;
1920 mod registry;
2021 mod run_access;
2122 mod secret_scan;
23+mod shared;
2224 mod store;
2325 mod transfer;
2426
3234 use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer, is_valid_repo_name, new_id};
3335 use g1t_kit::{args, now_ms, reply, rpc_method};
3436 use std::collections::{HashMap, HashSet, VecDeque};
37+use std::rc::Rc;
3538
3639 use serde::Serialize;
3740 use worker::{
150153 identity: Option<Fetcher>,
151154 /// What a free workspace's private repositories may hold.
152155 free_private_bytes: i64,
156+ /// What isolates share: answers that list refs (refs_cache.rs).
157+ shared: Option<Rc<shared::Shared>>,
153158 }
154159
155160 impl<S: GitStore> Repos<S> {
161+ /// Records that the refs of the repository with this id changed, once
162+ /// they have, so that the answers kept that list them go stale (see
163+ /// refs_cache.rs). Everything that changes a repository's refs calls
164+ /// this after it (`every_ref_writer_records_the_change` checks). A
165+ /// failure is logged: the change itself happened, and what was kept
166+ /// expires within `refs_cache::TTL_SECONDS` regardless.
167+ pub(crate) async fn refs_moved(&self, repo_id: &str) {
168+ if let Err(error) = self.registry.refs_moved(repo_id).await {
169+ worker::console_error!("refs of {repo_id} changed but not recorded: {error}");
170+ }
171+ }
172+
156173 pub(crate) async fn publish<T: Serialize>(&self, event: NewEvent<T>) -> Result<()> {
157174 g1t_kit::call(
158175 &self.events,
475492 .await?;
476493 let stored =
477494 land::push_pack(&access, &repo.default_branch, None, &remote.head, pack).await?;
495+ self.refs_moved(&repo.id).await;
478496 if let Err(reason) = stored {
479497 self.registry.remove(&repo.id).await?;
480498 return Ok(Outcome::fail(
492510 .access(Scope::Write)
493511 .await?;
494512 let target = mirror::Endpoint::bearer(&access.remote, &access.token);
495− match mirror::copy(&source, &target, mirror::Prune::Yes).await? {
513+ let copied = mirror::copy(&source, &target, mirror::Prune::Yes).await?;
514+ self.refs_moved(&repo.id).await;
515+ match copied {
496516 Ok(copied) => {
497517 let branch = format!("refs/heads/{}", repo.default_branch);
498518 pushed = copied
789809 return Ok(Outcome::Ok(false));
790810 };
791811 let access = git.access(Scope::Write).await?;
792− if let Err(reason) = land::delete_ref(&access, &a.branch, &old).await? {
812+ let deleted = land::delete_ref(&access, &a.branch, &old).await?;
813+ self.refs_moved(&repo.id).await;
814+ if let Err(reason) = deleted {
793815 return Ok(Outcome::fail(
794816 FailureCode::Conflict,
795817 format!("{} could not be deleted: {reason}", a.branch),
851873 }
852874
853875 async fn git_access(&self, a: GitAccessArgs) -> Result<Outcome<GitAccess>> {
876+ let found = self.registry.by_path(&a.path).await?;
877+ Ok(match self.authorize_git(&a.path, &a.viewer, a.service, found).await? {
878+ Outcome::Ok(repo) => {
879+ let write = a.service == GitService::ReceivePack;
880+ if write {
881+ // A push with this credential would not pass through
882+ // here, so nothing that lists the refs is kept until it
883+ // has expired (see refs_cache.rs).
884+ let until = now_ms() + store::CREDENTIAL_LIFE_MS + 60_000;
885+ if let Err(error) = self.registry.refs_open(&repo.id, until).await {
886+ // Before the column exists nothing is kept anyway.
887+ if registry::refs_state(&repo.id).is_some() {
888+ return Err(error);
889+ }
890+ }
891+ }
892+ let scope = if write { Scope::Write } else { Scope::Read };
893+ Outcome::Ok(self.store.access(&store_key(&repo), scope).await?)
894+ }
895+ Outcome::Fail(failure) => Outcome::Fail(failure),
896+ })
897+ }
898+
899+ /// The repository at `path` (`found`, as just read), if the viewer may
900+ /// use `service` on it: fetch from it, or push to it. A push to a path
901+ /// with nothing there makes the repository, in a workspace the pusher
902+ /// belongs to.
903+ async fn authorize_git(
904+ &self,
905+ path: &RepoPath,
906+ viewer: &Viewer,
907+ service: GitService,
908+ found: Option<Repo>,
909+ ) -> Result<Outcome<Repo>> {
910+ let a = GitAccessArgs {
911+ path: path.clone(),
912+ viewer: viewer.clone(),
913+ service,
914+ };
854915 let write = a.service == GitService::ReceivePack;
855916 // Anonymous callers are asked to authenticate whether or not the repo
856917 // exists, so private repos cannot be told apart from missing ones.
872933 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
873934 }
874935
875− let repo = match self.registry.by_path(&a.path).await? {
936+ let repo = match found {
876937 Some(repo) => {
877938 let allowed = if write {
878939 can_write(&repo, &a.viewer)
927988 }
928989 }
929990 };
930− let git = self.store.open(&store_key(&repo)).await?;
931− let scope = if write { Scope::Write } else { Scope::Read };
932− Ok(Outcome::Ok(git.access(scope).await?))
991+ Ok(Outcome::Ok(repo))
933992 }
934993
935994 async fn land(&self, a: LandArgs) -> Result<Outcome<Landed>> {
10221081 let pushed =
10231082 land::fast_forward(&source_access, &target_access, branch, old.as_deref(), &new)
10241083 .await?;
1084+ self.refs_moved(&target.id).await;
10251085 if let Err(reason) = pushed {
10261086 // Most often another pull request landed between the check and the push.
10271087 return Ok(Outcome::fail(
11331193 .await
11341194 }
11351195
1136− /// Git over HTTPS.
1137− async fn git_http(&self, request: Request, env: &Env) -> Result<Response> {
1196+ /// Git over HTTPS. Only what decides the answer happens before it:
1197+ /// the repository, who is asking and whether they may, the free
1198+ /// workspace limits, push protection, and the store's own answer. The
1199+ /// audit entry and what a push changed are recorded once git has its
1200+ /// answer. Each answer says how long its steps took (`Server-Timing`).
1201+ async fn git_http(&self, request: Request, env: &Env, ctx: &Context) -> Result<Response> {
1202+ let mut timing = git_http::Timing::start();
11381203 let Some(git) = git_http::parse(&request.url()?) else {
11391204 return Response::error("Not found", 404);
11401205 };
1141− // A workspace that was renamed: git follows a redirect when it
1142− // first asks for refs, and uses the new address from then on.
1143− if self.registry.by_path(&git.path).await?.is_none() {
1144− if let Some(location) = git_http::renamed(&request.url()?, &env.service("IDENTITY")?).await? {
1145− return git_http::moved(&location, request.method() == Method::Get);
1206+ let response = self.answer_git(request, &git, env, ctx, &mut timing).await?;
1207+ timing.apply(response)
1208+ }
1209+
1210+ async fn answer_git(
1211+ &self,
1212+ request: Request,
1213+ git: &git_http::GitRequest,
1214+ env: &Env,
1215+ ctx: &Context,
1216+ timing: &mut git_http::Timing,
1217+ ) -> Result<Response> {
1218+ let write = git.service == GitService::ReceivePack;
1219+ let get = request.method() == Method::Get;
1220+ let identity = env.service("IDENTITY")?;
1221+ // The repository and the caller's credentials, at once. A fetch may
1222+ // go by the row as read a moment ago, for the same clone's next
1223+ // request; a push always reads it. Anonymous callers cost nothing.
1224+ let lookup = async {
1225+ if write {
1226+ self.registry.by_path(&git.path).await
1227+ } else {
1228+ self.registry.by_path_recent(&git.path).await
1229+ }
1230+ };
1231+ let (found, viewer) =
1232+ futures_util::future::join(lookup, git_http::viewer(&request, &identity)).await;
1233+ let found = found?;
1234+ timing.mark("repo");
1235+ if found.is_none() {
1236+ // A workspace that was renamed: git follows a redirect when it
1237+ // first asks for refs, and uses the new address from then on.
1238+ // A repository transferred to another workspace: the same, to
1239+ // its new path. Fetches and pushes both follow either.
1240+ let url = request.url()?;
1241+ let (renamed, moved) = futures_util::future::join(
1242+ git_http::renamed(&url, &identity),
1243+ self.registry.resolve_moved(&git.path),
1244+ )
1245+ .await;
1246+ timing.mark("moved");
1247+ if let Some(location) = renamed? {
1248+ return git_http::moved(&location, get);
11461249 }
1147− // A repository transferred to another workspace: the same,
1148− // to its new path. Fetches and pushes both follow it.
1149− if let Some(now) = self.registry.resolve_moved(&git.path).await?
1150− && let Some(location) = git_http::transferred(&request.url()?, &now)
1250+ if let Some(now) = moved?
1251+ && let Some(location) = git_http::transferred(&url, &now)
11511252 {
1152− return git_http::moved(&location, request.method() == Method::Get);
1253+ return git_http::moved(&location, get);
11531254 }
11541255 }
1155− let viewer = git_http::viewer(&request, &env.service("IDENTITY")?).await?;
1256+ let viewer = viewer?;
11561257 // A run credential is checked against its grants, then acts as the
11571258 // person it works for. See run_access.rs.
1158− let (request, viewer, audit) = match self.admit_git(request, &git, viewer).await? {
1259+ let (request, viewer, audit) = match self.admit_git(request, git, viewer, found.as_ref()).await? {
11591260 run_access::Admitted::Go { request, viewer, entry } => (request, viewer, entry),
11601261 run_access::Admitted::Refused(response) => return Ok(response),
11611262 };
1162− let access = self
1163− .git_access(GitAccessArgs {
1164− path: git.path.clone(),
1165− viewer: viewer.clone(),
1166− service: git.service,
1167− })
1168− .await?;
1169− let access = match access {
1170− Outcome::Ok(access) => access,
1263+ let mut after = AfterGit {
1264+ audit,
1265+ status: 0,
1266+ message: None,
1267+ push: None,
1268+ };
1269+ let repo = match self.authorize_git(&git.path, &viewer, git.service, found).await? {
1270+ Outcome::Ok(repo) => repo,
11711271 refused => {
11721272 let response = git_http::refuse(refused)?;
1173− self.finish_git(audit, response.status_code(), None).await;
1273+ after.ended(response.status_code(), None);
1274+ after.spawn(env, ctx);
11741275 return Ok(response);
11751276 }
11761277 };
1278+ timing.mark("access");
11771279 // A protected default branch takes changes only from a merged pull
11781280 // request, which lands without going through here.
1179− let here = self.registry.by_path(&git.path).await?;
1180− let protected = match &here {
1181− Some(repo) if repo.protected && repo.fork_of.is_none() => Some(repo.default_branch.clone()),
1182− _ => None,
1183− };
1281+ let protected = (repo.protected && repo.fork_of.is_none()).then(|| repo.default_branch.clone());
11841282 // Clones check out the default branch g1t keeps, which can have
11851283 // changed since the store made the repository.
1186− let default_branch = here
1187− .as_ref()
1188− .filter(|repo| repo.fork_of.is_none())
1189− .map(|repo| repo.default_branch.clone());
1190− // Each clone, fetch and push is a git operation, which the git store
1191− // charges g1t for: counted for billing, and a free workspace far
1192− // past its share is slowed down rather than charged. See git_ops.rs.
1193− if request.method() == Method::Post && git.endpoint != "info/refs" {
1194− let namespace = git.path.namespace.to_lowercase();
1195− match git_ops::count(&self.registry.db, &namespace, &rfc3339(now_ms())).await {
1196− Ok((month, hour)) => {
1197− let limits = git_ops::Limits::from_env(env);
1198− if git_ops::slow_down(month, hour, limits.free_cap, limits.hourly)
1199− && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await
1200− {
1201− self.finish_git(audit, 429, Some("Too many git operations this hour.".to_owned())).await;
1202− return git_ops::too_many(&namespace, limits.free_cap, limits.hourly);
1284+ let default_branch = repo.fork_of.is_none().then(|| repo.default_branch.clone());
1285+ let key = store_key(&repo);
1286+ let scope = if write { Scope::Write } else { Scope::Read };
1287+ let mut request = request;
1288+ let protocol = refs_cache::protocol(request.headers().get("git-protocol")?.as_deref());
1289+ // A fetch's POST is read here, to tell an `ls-refs` from a fetch of
1290+ // objects; the store would have it read in full anyway.
1291+ let body = if !write && !get { Some(request.bytes().await?) } else { None };
1292+ // An answer that lists refs may have been kept: see refs_cache.rs.
1293+ let kept_key = refs_cache::kind(git, get, protocol, body.as_deref())
1294+ .zip(refs_cache::usable(registry::refs_state(&repo.id), now_ms()))
1295+ .map(|(kind, version)| {
1296+ refs_cache::Key::new(&repo.id, version, default_branch.as_deref(), protocol, &kind)
1297+ });
1298+ // A kept answer and the free workspace limits, with a kept
1299+ // credential looked up alongside. A kept answer goes back without
1300+ // waiting for the credential, which it does not need.
1301+ let ((answer, limited), kept_access) = {
1302+ let shared = self.shared.as_deref();
1303+ let answer_and_limits = std::pin::pin!(futures_util::future::join(
1304+ async {
1305+ match &kept_key {
1306+ Some(kept_key) => refs_cache::get(shared, kept_key).await,
1307+ None => None,
12031308 }
1309+ },
1310+ self.git_limits(&request, git, &repo, env),
1311+ ));
1312+ let kept_access = std::pin::pin!(self.store.kept_access(&key, scope));
1313+ match futures_util::future::select(answer_and_limits, kept_access).await {
1314+ futures_util::future::Either::Left((first, kept_access)) => {
1315+ let answered = first.0.is_some() || matches!(first.1, Ok(Some(_)) | Err(_));
1316+ (first, if answered { None } else { kept_access.await })
12041317 }
1205− Err(error) => worker::console_error!("git operation for {namespace} not counted: {error}"),
1318+ futures_util::future::Either::Right((kept_access, first)) => (first.await, kept_access),
12061319 }
1320+ };
1321+ timing.mark("kept");
1322+ if let Some((response, status, message)) = limited? {
1323+ after.ended(status, Some(message.to_owned()));
1324+ after.spawn(env, ctx);
1325+ return Ok(response);
12071326 }
1208− // A free workspace is never charged for private storage: once its
1209− // private repositories hold the free amount, pushes to them stop.
1210− // Checked when a push begins, so git shows the reason.
1211− if git.service == GitService::ReceivePack && git.endpoint == "info/refs" {
1212− let namespace = git.path.namespace.to_lowercase();
1213− let private = self.registry.by_path(&git.path).await?.is_some_and(|repo| repo.is_private);
1214− if private {
1215− let free = git_ops::free_private_bytes(env);
1216− let held = self.registry.private_bytes(&namespace).await.unwrap_or(0);
1217− if git_ops::storage_full(held, free)
1218− && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await
1219− {
1220− self.finish_git(audit, 403, Some("Free private storage is full.".to_owned())).await;
1221− return git_ops::storage_full_response(&namespace, held, free);
1222− }
1327+ if let (Some((entry, found)), Some(kept_key)) = (answer, &kept_key) {
1328+ timing.note("refs", found.as_str());
1329+ if found == refs_cache::Found::Shared {
1330+ let (kept_key, entry) = (kept_key.clone(), entry.clone());
1331+ ctx.wait_until(async move { refs_cache::keep_in_colo(&kept_key, &entry).await });
12231332 }
1333+ after.ended(200, None);
1334+ after.spawn(env, ctx);
1335+ return entry.response();
1336+ }
1337+ if kept_key.is_some() {
1338+ timing.note("refs", "miss");
12241339 }
1340+ // The store's credential: one made a moment ago, here or in another
1341+ // isolate (see store.rs), or a new one.
1342+ let access = match kept_access {
1343+ Some((access, from)) => {
1344+ timing.note("cred", from.as_str());
1345+ access
1346+ }
1347+ None => {
1348+ let access = self.store.mint_access(&key, scope).await?;
1349+ timing.mark("mint");
1350+ timing.note("cred", "mint");
1351+ access
1352+ }
1353+ };
1354+ // Should the store turn a kept credential down, a fetch's first
1355+ // request is tried again with a new one; the requests after it then
1356+ // have that one too.
1357+ let again = if get { Some(request.clone()?) } else { None };
12251358 // Push protection: a push that adds a secret is refused. See secret_scan.rs.
1226− let scan = async |body: &[u8]| self.protect(&git.path, viewer.as_ref(), body).await;
1359+ let scan = async |body: &[u8]| self.protect(&repo, viewer.as_ref(), body).await;
1360+ let mut outcome = git_http::forward(
1361+ request,
1362+ body,
1363+ git,
1364+ &access,
1365+ protected.as_deref(),
1366+ default_branch.as_deref(),
1367+ scan,
1368+ )
1369+ .await?;
1370+ let turned_down = matches!(
1371+ &outcome,
1372+ git_http::Push::Forwarded(forwarded) if matches!(forwarded.response.status_code(), 401 | 403)
1373+ );
1374+ if turned_down {
1375+ self.store.forget_access(&key).await;
1376+ if let Some(again) = again {
1377+ let access = self.store.mint_access(&key, scope).await?;
1378+ let nothing = async |_: &[u8]| Ok(None);
1379+ outcome = git_http::forward(again, None, git, &access, protected.as_deref(), default_branch.as_deref(), nothing)
1380+ .await?;
1381+ }
1382+ }
12271383 let forwarded =
1228− match git_http::forward(request, &git, &access, protected.as_deref(), default_branch.as_deref(), scan).await? {
1384+ match outcome {
12291385 git_http::Push::Forwarded(forwarded) => forwarded,
12301386 git_http::Push::Refused(response) => {
1231− self.finish_git(audit, 403, Some("The push would change a protected branch.".to_owned())).await;
1387+ after.ended(403, Some("The push would change a protected branch.".to_owned()));
1388+ after.spawn(env, ctx);
12321389 return Ok(response);
12331390 }
12341391 git_http::Push::Blocked(response) => {
1235− self.finish_git(audit, 403, Some("The push adds a secret.".to_owned())).await;
1392+ after.ended(403, Some("The push adds a secret.".to_owned()));
1393+ after.spawn(env, ctx);
12361394 return Ok(response);
12371395 }
12381396 };
1239− self.finish_git(audit, forwarded.response.status_code(), None).await;
1397+ timing.mark("store");
1398+ let mut response = forwarded.response;
1399+ let status = response.status_code();
1400+ if write && !get {
1401+ // A push: the store has moved its refs once it has answered in
1402+ // full, so the answer is read before the change is recorded, and
1403+ // only then goes back. Whoever fetches after it sees the push.
1404+ let headers = response.headers().clone();
1405+ headers.delete("content-length")?;
1406+ let report = response.bytes().await?;
1407+ self.refs_moved(&repo.id).await;
1408+ timing.mark("refs");
1409+ response = Response::from_bytes(report)?.with_headers(headers).with_status(status);
1410+ } else if let (Some(kept_key), 200) = (&kept_key, status) {
1411+ // A miss: this answer is kept for the next to ask.
1412+ let headers = response.headers().clone();
1413+ headers.delete("content-length")?;
1414+ let body = response.bytes().await?;
1415+ if let Some(content_type) = headers.get("content-type")? {
1416+ let entry = refs_cache::Entry { content_type, body: body.clone() };
1417+ if entry.keepable() {
1418+ let shared = self.shared.clone();
1419+ let kept_key = kept_key.clone();
1420+ ctx.wait_until(async move { refs_cache::keep(shared.as_deref(), &kept_key, &entry).await });
1421+ }
1422+ }
1423+ response = Response::from_bytes(body)?.with_headers(headers).with_status(status);
1424+ }
1425+ after.ended(status, None);
1426+ if status == 200 && (forwarded.pack_bytes > 0 || !forwarded.pushed.is_empty()) {
1427+ after.push = Some(PushDone {
1428+ repo,
1429+ pushed: forwarded.pushed,
1430+ pack_bytes: forwarded.pack_bytes,
1431+ actor: viewer.map(|user: User| user.id),
1432+ });
1433+ }
1434+ after.spawn(env, ctx);
1435+ Ok(response)
1436+ }
1437+
1438+ /// The answer for a request a free workspace's limits stop, with its
1439+ /// status and reason for the audit log; `None` to go on.
1440+ ///
1441+ /// Each clone, fetch and push is a git operation, which the git store
1442+ /// charges g1t for: counted for billing, and a free workspace far past
1443+ /// its share is slowed down rather than charged (see git_ops.rs). And a
1444+ /// free workspace is never charged for private storage: once its
1445+ /// private repositories hold the free amount, pushes to them stop,
1446+ /// checked when a push begins so that git shows the reason.
1447+ async fn git_limits(
1448+ &self,
1449+ request: &Request,
1450+ git: &git_http::GitRequest,
1451+ repo: &Repo,
1452+ env: &Env,
1453+ ) -> Result<Option<(Response, u16, &'static str)>> {
1454+ let namespace = git.path.namespace.to_lowercase();
1455+ if request.method() == Method::Post && git.endpoint != "info/refs" {
1456+ match git_ops::count(&self.registry.db, &namespace, &rfc3339(now_ms())).await {
1457+ Ok((month, hour)) => {
1458+ let limits = git_ops::Limits::from_env(env);
1459+ if git_ops::slow_down(month, hour, limits.free_cap, limits.hourly)
1460+ && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await
1461+ {
1462+ return Ok(Some((
1463+ git_ops::too_many(&namespace, limits.free_cap, limits.hourly)?,
1464+ 429,
1465+ "Too many git operations this hour.",
1466+ )));
1467+ }
1468+ }
1469+ Err(error) => worker::console_error!("git operation for {namespace} not counted: {error}"),
1470+ }
1471+ }
1472+ if git.service == GitService::ReceivePack && git.endpoint == "info/refs" && repo.is_private {
1473+ let free = git_ops::free_private_bytes(env);
1474+ let held = self.registry.private_bytes(&namespace).await.unwrap_or(0);
1475+ if git_ops::storage_full(held, free)
1476+ && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await
1477+ {
1478+ return Ok(Some((
1479+ git_ops::storage_full_response(&namespace, held, free)?,
1480+ 403,
1481+ "Free private storage is full.",
1482+ )));
1483+ }
1484+ }
1485+ Ok(None)
1486+ }
12401487
1488+ /// What a push changed, recorded once git has its answer.
1489+ async fn record_push(&self, push: PushDone) -> Result<()> {
1490+ let PushDone {
1491+ repo,
1492+ pushed,
1493+ pack_bytes,
1494+ actor,
1495+ } = push;
12411496 // What the push stored, for billing's storage meter. A failure only
12421497 // leaves the count short.
1243− if forwarded.response.status_code() == 200
1244− && forwarded.pack_bytes > 0
1245− && let Some(repo) = self.registry.by_path(&git.path).await?
1246− && let Err(error) = self.registry.add_stored_bytes(&repo, forwarded.pack_bytes).await
1498+ if pack_bytes > 0
1499+ && let Err(error) = self.registry.add_stored_bytes(&repo, pack_bytes).await
12471500 {
1248− worker::console_error!("stored bytes for {} not counted: {error}", git.path.name);
1501+ worker::console_error!("stored bytes for {} not counted: {error}", repo.name);
12491502 }
1250−
1503+ if pushed.is_empty() {
1504+ return Ok(());
1505+ }
12511506 // Artifacts' own push notifications are per repository, which does
12521507 // not fit a repo per pull request, so the front end reports pushes
12531508 // itself: one event for each branch that moved.
1254− let accepted = forwarded.response.status_code() == 200 && !forwarded.pushed.is_empty();
1255− if accepted && let Some(repo) = self.registry.by_path(&git.path).await? {
1256− let stored = self.store.open(&store_key(&repo)).await?;
1257− let actor = viewer.map(|user: User| user.id);
1258− for pushed in &forwarded.pushed {
1259− // The store can refuse one ref and accept another, so each
1260− // branch is checked against where it actually is. A tag the
1261− // store cannot read back is taken as pushed.
1262− let moved = match pushed.branch() {
1263− Some(branch) => stored
1264− .log(branch, 1)
1265− .await?
1266− .first()
1267− .is_some_and(|commit| commit.hash == pushed.after),
1268− None => stored.log(&pushed.git_ref, 1).await.map_or(true, |head| {
1269− head.first().is_none_or(|commit| commit.hash == pushed.after)
1270− }),
1271− };
1272− if moved {
1273− self.publish_push(
1274− &repo,
1275− &pushed.git_ref,
1276− pushed.before.as_deref(),
1277− &pushed.after,
1278− actor.clone(),
1279− )
1280− .await?;
1509+ let stored = self.store.open(&store_key(&repo)).await?;
1510+ for pushed in &pushed {
1511+ // The store can refuse one ref and accept another, so each
1512+ // branch is checked against where it actually is. A tag the
1513+ // store cannot read back is taken as pushed.
1514+ let moved = match pushed.branch() {
1515+ Some(branch) => stored
1516+ .log(branch, 1)
1517+ .await?
1518+ .first()
1519+ .is_some_and(|commit| commit.hash == pushed.after),
1520+ None => stored.log(&pushed.git_ref, 1).await.map_or(true, |head| {
1521+ head.first().is_none_or(|commit| commit.hash == pushed.after)
1522+ }),
1523+ };
1524+ if moved {
1525+ self.publish_push(
1526+ &repo,
1527+ &pushed.git_ref,
1528+ pushed.before.as_deref(),
1529+ &pushed.after,
1530+ actor.clone(),
1531+ )
1532+ .await?;
1533+ }
1534+ }
1535+ Ok(())
1536+ }
1537+}
1538+
1539+/// A push the store accepted, to be recorded once git has its answer.
1540+struct PushDone {
1541+ repo: Repo,
1542+ pushed: Vec<git_http::Pushed>,
1543+ pack_bytes: u64,
1544+ actor: Option<String>,
1545+}
1546+
1547+/// What a git request leaves for after its answer: its audit entry, with
1548+/// how the request ended, and what a push changed.
1549+struct AfterGit {
1550+ audit: Option<Box<g1t_contracts::audit::NewAuditEntry>>,
1551+ status: u16,
1552+ message: Option<String>,
1553+ push: Option<PushDone>,
1554+}
1555+
1556+impl AfterGit {
1557+ fn ended(&mut self, status: u16, message: Option<String>) {
1558+ self.status = status;
1559+ self.message = message;
1560+ }
1561+
1562+ /// Does the work once the response is on its way. A failure is logged:
1563+ /// git has already been told how its request went.
1564+ fn spawn(self, env: &Env, ctx: &Context) {
1565+ if self.audit.is_none() && self.push.is_none() {
1566+ return;
1567+ }
1568+ let env = env.clone();
1569+ ctx.wait_until(async move {
1570+ let repos = match service(&env) {
1571+ Ok(repos) => repos,
1572+ Err(error) => {
1573+ worker::console_error!("git request not recorded: {error}");
1574+ return;
12811575 }
1576+ };
1577+ repos.finish_git(self.audit, self.status, self.message).await;
1578+ if let Some(push) = self.push
1579+ && let Err(error) = repos.record_push(push).await
1580+ {
1581+ worker::console_error!("push not recorded: {error}");
12821582 }
1283− }
1284− Ok(forwarded.response)
1583+ });
12851584 }
12861585 }
12871586
12881587 fn service(env: &Env) -> Result<Repos<ArtifactsStore>> {
1588+ let shared = shared::Shared::from_env(env).map(Rc::new);
12891589 Ok(Repos {
12901590 registry: Registry { db: env.d1("DB")? },
1291− store: ArtifactsStore::new(env)?,
1591+ store: ArtifactsStore::new(env, shared.clone())?,
1592+ shared,
12921593 events: env.service("EVENTS")?,
12931594 security: env.service("SECURITY").ok(),
12941595 billing: env.service("BILLING").ok(),
12981599 }
12991600
13001601 #[event(fetch)]
1301−async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
1602+async fn fetch(mut request: Request, env: Env, ctx: Context) -> Result<Response> {
13021603 let repos = service(&env)?;
13031604 let Some(method) = rpc_method(&request) else {
1304− return repos.git_http(request, &env).await;
1605+ return repos.git_http(request, &env, &ctx).await;
13051606 };
13061607 let body: serde_json::Value = request.json().await?;
13071608
+21−9
11351135 ));
11361136 }
11371137 self.registry.set_default_branch(&repo.id, &branch).await?;
1138+ // HEAD in what git is told follows it.
1139+ self.refs_moved(&repo.id).await;
11381140 let from = repo.default_branch.clone();
11391141 let changed = Repo {
11401142 default_branch: branch.clone(),
11951197 return Ok(not_found());
11961198 };
11971199 let access = git.access(Scope::Write).await?;
1198− if let Err(reason) = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await? {
1200+ let made = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await?;
1201+ self.refs_moved(&repo.id).await;
1202+ if let Err(reason) = made {
11991203 return Ok(Outcome::fail(FailureCode::Conflict, format!("{to} could not be made: {reason}")));
12001204 }
12011205 // The default moves before the old name goes, so it never names a
12031207 if is_default {
12041208 self.registry.set_default_branch(&repo.id, &to).await?;
12051209 }
1206− if let Err(reason) = land::delete_ref(&access, &from, &head).await? {
1210+ let removed = land::delete_ref(&access, &from, &head).await;
1211+ self.refs_moved(&repo.id).await;
1212+ if let Err(reason) = removed? {
12071213 worker::console_error!("{from} not removed after renaming it to {to}: {reason}");
12081214 }
12091215 self.registry.add_branch_redirect(&repo.id, &from, &to).await?;
12791285 return Ok(());
12801286 };
12811287 let access = git.access(Scope::Write).await?;
1282− if !branches.iter().any(|b| b.name == to)
1283− && let Err(reason) = land::push_pack(&access, to, None, &head, EMPTY_PACK.to_vec()).await?
1284− {
1285− worker::console_error!("working copy {} did not get {to}: {reason}", fork.id);
1286− return Ok(());
1288+ if !branches.iter().any(|b| b.name == to) {
1289+ let made = land::push_pack(&access, to, None, &head, EMPTY_PACK.to_vec()).await;
1290+ self.refs_moved(&fork.id).await;
1291+ if let Err(reason) = made? {
1292+ worker::console_error!("working copy {} did not get {to}: {reason}", fork.id);
1293+ return Ok(());
1294+ }
12871295 }
12881296 self.registry.set_default_branch(&fork.id, to).await?;
1289− if renamed && let Err(reason) = land::delete_ref(&access, from, &head).await? {
1290− worker::console_error!("working copy {} kept {from}: {reason}", fork.id);
1297+ if renamed {
1298+ let removed = land::delete_ref(&access, from, &head).await;
1299+ self.refs_moved(&fork.id).await;
1300+ if let Err(reason) = removed? {
1301+ worker::console_error!("working copy {} kept {from}: {reason}", fork.id);
1302+ }
12911303 }
12921304 Ok(())
12931305 }
+5−1
380380 let ours = Endpoint::bearer(&access.remote, &access.token);
381381 let theirs = Endpoint::github(&url, &a.token);
382382 let copied = match a.direction {
383− MirrorDirection::Pull => copy(&theirs, &ours, Prune::Yes).await?,
383+ MirrorDirection::Pull => {
384+ let copied = copy(&theirs, &ours, Prune::Yes).await?;
385+ self.refs_moved(&repo.id).await;
386+ copied
387+ }
384388 MirrorDirection::Push => copy(&ours, &theirs, Prune::No).await?,
385389 };
386390 let copied = match copied {
+404−0
1+//! The answers that list a repository's refs, kept for a moment: the ref
2+//! advertisement git asks for first on every clone and fetch
3+//! (`info/refs?service=git-upload-pack`), and protocol v2's `ls-refs`.
4+//! Asking the git store for one takes hundreds of milliseconds; a kept one
5+//! is a few.
6+//!
7+//! An answer is kept under the repository's id and the version of its refs
8+//! (`refs_version`, see registry.rs), which goes up after everything g1t
9+//! does that changes them, so a change leaves the old answer behind rather
10+//! than having to find and remove it. The key also holds the default branch
11+//! (the answer's `HEAD` is rewritten to it, see `git_http::with_head`), the
12+//! protocol version, and for `ls-refs` the whole request. Answers are kept
13+//! in this colo's cache and, sealed, in the cache isolates share
14+//! (shared.rs), each for [`TTL_SECONDS`] at most, which bounds how stale
15+//! one can be should a change ever fail to move the version.
16+//!
17+//! Only ever served after the request was authorized, like any answer
18+//! from the store: a private repository's answers are kept like any
19+//! other's, and read only by whoever may read it.
20+
21+use crate::git_http::GitRequest;
22+use crate::registry::RefsState;
23+use crate::shared::Shared;
24+use g1t_contracts::repos::GitService;
25+use worker::{Headers, Response, Result};
26+
27+/// How long an answer is kept, at most.
28+pub const TTL_SECONDS: u64 = 60;
29+/// Larger answers (repositories with tens of thousands of refs) are not kept.
30+const MAX_KEPT_BYTES: usize = 1024 * 1024;
31+/// An `ls-refs` request larger than this (a great many ref prefixes) is
32+/// passed through.
33+const MAX_REQUEST_BYTES: usize = 64 * 1024;
34+/// Where answers live in this colo's cache. Not reachable from outside.
35+const COLO_CACHE: &str = "https://refs.g1t.internal/";
36+
37+/// What a request asks for that can be kept.
38+#[derive(Debug, PartialEq, Eq)]
39+pub enum Kind {
40+ /// `GET info/refs?service=git-upload-pack`: the ref advertisement, or
41+ /// for protocol v2 the capabilities.
42+ Advertisement,
43+ /// A protocol v2 `ls-refs` command, by the SHA-256 of the whole request,
44+ /// so only the same question gets the same answer.
45+ LsRefs { request: String },
46+}
47+
48+/// What `git` asks that can be kept, if anything: only fetches, and of
49+/// those only the answers that list refs. `body` is a POST's.
50+pub fn kind(git: &GitRequest, get: bool, protocol: u8, body: Option<&[u8]>) -> Option<Kind> {
51+ if git.service != GitService::UploadPack {
52+ return None;
53+ }
54+ match (get, git.endpoint, body) {
55+ (true, "info/refs", _) => Some(Kind::Advertisement),
56+ (false, "git-upload-pack", Some(body)) if protocol == 2 && is_ls_refs(body) => Some(Kind::LsRefs {
57+ request: g1t_secrets::sha256_hex_bytes(body),
58+ }),
59+ _ => None,
60+ }
61+}
62+
63+/// Whether `body` is a whole protocol v2 request whose command is `ls-refs`.
64+fn is_ls_refs(body: &[u8]) -> bool {
65+ if body.len() > MAX_REQUEST_BYTES {
66+ return false;
67+ }
68+ let (lines, end) = crate::land::read_pkt_lines(body);
69+ end == body.len()
70+ && lines
71+ .first()
72+ .is_some_and(|line| line.strip_suffix(b"\n").unwrap_or(line) == b"command=ls-refs")
73+}
74+
75+/// The protocol version a `Git-Protocol` header asks for: `version=2`
76+/// among its colon-separated parameters. 0 without one.
77+pub fn protocol(header: Option<&str>) -> u8 {
78+ header
79+ .into_iter()
80+ .flat_map(|value| value.split(':'))
81+ .filter_map(|parameter| parameter.trim().strip_prefix("version="))
82+ .filter_map(|version| version.parse().ok())
83+ .next_back()
84+ .unwrap_or(0)
85+}
86+
87+/// The version to keep answers under, if they may be kept now: not before
88+/// the version is known, nor while a credential that could push is out of
89+/// g1t's hands.
90+pub fn usable(state: Option<RefsState>, now: u64) -> Option<u64> {
91+ state.filter(|state| now >= state.open_until).map(|state| state.version)
92+}
93+
94+/// Where one answer is kept.
95+#[derive(Debug, PartialEq, Eq, Clone)]
96+pub struct Key {
97+ repo_id: String,
98+ hash: String,
99+}
100+
101+impl Key {
102+ /// `head` is the default branch `HEAD` is rewritten to, if it is.
103+ pub fn new(repo_id: &str, version: u64, head: Option<&str>, protocol: u8, kind: &Kind) -> Key {
104+ let what = match kind {
105+ Kind::Advertisement => "advertisement".to_owned(),
106+ Kind::LsRefs { request } => format!("ls-refs {request}"),
107+ };
108+ // Branch names can hold characters a URL cannot, and git forbids
109+ // newlines in them.
110+ let head = head.map_or_else(|| "-".to_owned(), |branch| format!("refs/heads/{branch}"));
111+ Key {
112+ repo_id: repo_id.to_owned(),
113+ hash: g1t_secrets::sha256_hex(&format!("{version}\nv{protocol}\n{what}\n{head}")),
114+ }
115+ }
116+
117+ fn colo_url(&self) -> String {
118+ format!("{COLO_CACHE}{}/{}", self.repo_id, self.hash)
119+ }
120+
121+ fn shared_key(&self) -> String {
122+ format!("refs:{}:{}", self.repo_id, self.hash)
123+ }
124+}
125+
126+/// A kept answer.
127+#[derive(Debug, PartialEq, Eq, Clone)]
128+pub struct Entry {
129+ pub content_type: String,
130+ pub body: Vec<u8>,
131+}
132+
133+impl Entry {
134+ /// Whether it is small enough to keep.
135+ pub fn keepable(&self) -> bool {
136+ // Every answer that lists refs ends with a flush packet; one that
137+ // does not (an error the store sent as a 200) is not kept.
138+ self.body.len() <= MAX_KEPT_BYTES && self.body.ends_with(b"0000") && !self.content_type.contains('\n')
139+ }
140+
141+ fn encode(&self) -> Vec<u8> {
142+ let mut out = self.content_type.as_bytes().to_vec();
143+ out.push(b'\n');
144+ out.extend_from_slice(&self.body);
145+ out
146+ }
147+
148+ fn decode(bytes: &[u8]) -> Option<Entry> {
149+ let at = bytes.iter().position(|byte| *byte == b'\n')?;
150+ Some(Entry {
151+ content_type: String::from_utf8(bytes[..at].to_vec()).ok()?,
152+ body: bytes[at + 1..].to_vec(),
153+ })
154+ }
155+
156+ /// The answer, as the git store gives it.
157+ pub fn response(&self) -> Result<Response> {
158+ let headers = Headers::new();
159+ headers.set("content-type", &self.content_type)?;
160+ headers.set("cache-control", "no-cache")?;
161+ Ok(Response::from_bytes(self.body.clone())?.with_headers(headers))
162+ }
163+}
164+
165+/// Where a kept answer was found, for `Server-Timing`.
166+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167+pub enum Found {
168+ Colo,
169+ Shared,
170+}
171+
172+impl Found {
173+ pub fn as_str(self) -> &'static str {
174+ match self {
175+ Found::Colo => "hit-colo",
176+ Found::Shared => "hit-shared",
177+ }
178+ }
179+}
180+
181+/// The answer kept under `key`: this colo's first, then the shared one.
182+pub async fn get(shared: Option<&Shared>, key: &Key) -> Option<(Entry, Found)> {
183+ if let Ok(Some(mut response)) = worker::Cache::default().get(key.colo_url(), false).await {
184+ let content_type = response.headers().get("content-type").ok().flatten();
185+ if let (Some(content_type), Ok(body)) = (content_type, response.bytes().await) {
186+ return Some((Entry { content_type, body }, Found::Colo));
187+ }
188+ }
189+ let bytes = shared?.get(&key.shared_key()).await?;
190+ Some((Entry::decode(&bytes)?, Found::Shared))
191+}
192+
193+/// Keeps `entry` in this colo's cache. A failure only costs a later miss.
194+pub async fn keep_in_colo(key: &Key, entry: &Entry) {
195+ let headers = Headers::new();
196+ let _ = headers.set("content-type", &entry.content_type);
197+ let _ = headers.set("cache-control", &format!("public, max-age={TTL_SECONDS}"));
198+ let Ok(response) = Response::from_bytes(entry.body.clone()) else {
199+ return;
200+ };
201+ let _ = worker::Cache::default()
202+ .put(key.colo_url(), response.with_headers(headers))
203+ .await;
204+}
205+
206+/// Keeps `entry` in this colo's cache and the shared one.
207+pub async fn keep(shared: Option<&Shared>, key: &Key, entry: &Entry) {
208+ if !entry.keepable() {
209+ return;
210+ }
211+ let shared_put = async {
212+ if let Some(shared) = shared {
213+ shared.put(&key.shared_key(), &entry.encode(), TTL_SECONDS).await;
214+ }
215+ };
216+ futures_util::future::join(keep_in_colo(key, entry), shared_put).await;
217+}
218+
219+#[cfg(test)]
220+mod tests {
221+ use super::*;
222+ use g1t_contracts::repos::RepoPath;
223+
224+ fn git(endpoint: &'static str, service: GitService) -> GitRequest {
225+ GitRequest {
226+ path: RepoPath {
227+ namespace: "acme".into(),
228+ name: "rocket".into(),
229+ },
230+ endpoint,
231+ service,
232+ }
233+ }
234+
235+ fn pkt(payload: &str) -> Vec<u8> {
236+ format!("{:04x}{payload}", payload.len() + 4).into_bytes()
237+ }
238+
239+ fn ls_refs(prefix: &str) -> Vec<u8> {
240+ [
241+ pkt("command=ls-refs\n"),
242+ pkt("agent=git/2.45.0\n"),
243+ pkt("object-format=sha1\n"),
244+ b"0001".to_vec(),
245+ pkt("peel\n"),
246+ pkt("symrefs\n"),
247+ pkt(&format!("ref-prefix {prefix}\n")),
248+ b"0000".to_vec(),
249+ ]
250+ .concat()
251+ }
252+
253+ #[test]
254+ fn only_the_answers_that_list_refs_are_kept() {
255+ let refs = git("info/refs", GitService::UploadPack);
256+ assert_eq!(kind(&refs, true, 0, None), Some(Kind::Advertisement));
257+ assert_eq!(kind(&refs, true, 2, None), Some(Kind::Advertisement));
258+ // A push's advertisement is never kept: a push needs the refs as
259+ // they are.
260+ assert_eq!(kind(&git("info/refs", GitService::ReceivePack), true, 0, None), None);
261+ let pack = git("git-upload-pack", GitService::UploadPack);
262+ let listing = ls_refs("refs/heads/");
263+ assert!(matches!(kind(&pack, false, 2, Some(&listing)), Some(Kind::LsRefs { .. })));
264+ // Only under protocol v2, and never a fetch of objects.
265+ assert_eq!(kind(&pack, false, 0, Some(&listing)), None);
266+ let fetch = [pkt("command=fetch\n"), b"0001".to_vec(), pkt("want 1111111111111111111111111111111111111111\n"), pkt("done\n"), b"0000".to_vec()].concat();
267+ assert_eq!(kind(&pack, false, 2, Some(&fetch)), None);
268+ let v0 = [pkt("want 1111111111111111111111111111111111111111 side-band-64k\n"), b"0000".to_vec(), pkt("done\n")].concat();
269+ assert_eq!(kind(&pack, false, 0, Some(&v0)), None);
270+ // Not a whole request, or one too large: passed through.
271+ assert_eq!(kind(&pack, false, 2, Some(&listing[..listing.len() - 2])), None);
272+ let huge = [pkt("command=ls-refs\n"), b"0001".to_vec(), (0..3000).flat_map(|n| pkt(&format!("ref-prefix refs/heads/branch-{n}\n"))).collect(), b"0000".to_vec()].concat();
273+ assert_eq!(kind(&pack, false, 2, Some(&huge)), None);
274+ assert_eq!(kind(&pack, false, 2, None), None);
275+ }
276+
277+ #[test]
278+ fn the_protocol_version_is_read_from_the_header() {
279+ assert_eq!(protocol(None), 0);
280+ assert_eq!(protocol(Some("version=2")), 2);
281+ assert_eq!(protocol(Some("version=1")), 1);
282+ assert_eq!(protocol(Some("other=x:version=2")), 2);
283+ assert_eq!(protocol(Some("version=banana")), 0);
284+ }
285+
286+ #[test]
287+ fn nothing_is_kept_without_a_version_or_while_a_push_credential_is_out() {
288+ assert_eq!(usable(None, 1_000), None);
289+ assert_eq!(usable(Some(RefsState { version: 4, open_until: 0 }), 1_000), Some(4));
290+ assert_eq!(usable(Some(RefsState { version: 4, open_until: 2_000 }), 1_000), None);
291+ assert_eq!(usable(Some(RefsState { version: 4, open_until: 2_000 }), 2_000), Some(4));
292+ }
293+
294+ #[test]
295+ fn every_part_of_the_question_is_in_the_key() {
296+ let base = Key::new("rep_1", 3, Some("main"), 0, &Kind::Advertisement);
297+ assert_eq!(base, Key::new("rep_1", 3, Some("main"), 0, &Kind::Advertisement));
298+ // A change to the refs moves the version and leaves the answer behind.
299+ assert_ne!(base, Key::new("rep_1", 4, Some("main"), 0, &Kind::Advertisement));
300+ // So does a new default branch, which HEAD is rewritten to.
301+ assert_ne!(base, Key::new("rep_1", 3, Some("trunk"), 0, &Kind::Advertisement));
302+ assert_ne!(base, Key::new("rep_1", 3, None, 0, &Kind::Advertisement));
303+ // v0 and v2 answer differently.
304+ assert_ne!(base, Key::new("rep_1", 3, Some("main"), 2, &Kind::Advertisement));
305+ assert_ne!(base, Key::new("rep_2", 3, Some("main"), 0, &Kind::Advertisement));
306+ let heads = Kind::LsRefs { request: g1t_secrets::sha256_hex_bytes(&ls_refs("refs/heads/")) };
307+ let tags = Kind::LsRefs { request: g1t_secrets::sha256_hex_bytes(&ls_refs("refs/tags/")) };
308+ assert_ne!(Key::new("rep_1", 3, Some("main"), 2, &heads), Key::new("rep_1", 3, Some("main"), 2, &tags));
309+ assert_ne!(Key::new("rep_1", 3, Some("main"), 2, &heads), Key::new("rep_1", 3, Some("main"), 2, &Kind::Advertisement));
310+ // Odd branch names still make a usable address.
311+ let odd = Key::new("rep_1", 3, Some("fix/#12 %20"), 0, &Kind::Advertisement);
312+ assert!(odd.colo_url().starts_with("https://refs.g1t.internal/rep_1/"));
313+ assert!(!odd.colo_url().contains('#') && !odd.colo_url().contains('%'));
314+ assert!(odd.shared_key().starts_with("refs:rep_1:"));
315+ }
316+
317+ /// The functions in `source` (outside its tests) that call any of
318+ /// `writes`, each with whether it also records the change.
319+ fn writers(source: &str, writes: &[&str]) -> Vec<(String, bool)> {
320+ let code = source.split("#[cfg(test)]").next().unwrap_or_default();
321+ let lines: Vec<&str> = code.lines().collect();
322+ let starts: Vec<usize> = lines
323+ .iter()
324+ .enumerate()
325+ .filter(|(_, line)| {
326+ let indent = line.len() - line.trim_start().len();
327+ let rest = line.trim_start();
328+ indent <= 4
329+ && ["fn ", "async fn ", "pub fn ", "pub async fn ", "pub(crate) fn ", "pub(crate) async fn "]
330+ .iter()
331+ .any(|prefix| rest.starts_with(prefix))
332+ })
333+ .map(|(at, _)| at)
334+ .collect();
335+ let mut found = Vec::new();
336+ for (n, start) in starts.iter().enumerate() {
337+ let end = starts.get(n + 1).copied().unwrap_or(lines.len());
338+ let body = lines[*start..end].join("\n");
339+ if writes.iter().any(|write| body.contains(write)) {
340+ found.push((lines[*start].trim().to_owned(), body.contains("refs_moved(")));
341+ }
342+ }
343+ found
344+ }
345+
346+ /// Everything that changes a repository's refs moves its version, or a
347+ /// kept answer would list them as they were. A new way of writing refs
348+ /// belongs in this list, and its caller must call `refs_moved`.
349+ #[test]
350+ fn every_ref_writer_records_the_change() {
351+ let writes = [
352+ "land::push_pack(",
353+ "land::delete_ref(",
354+ "land::fast_forward(",
355+ "mirror::copy(",
356+ "copy(&theirs, &ours",
357+ ];
358+ let sources = [
359+ ("lib.rs", include_str!("lib.rs")),
360+ ("catch_up.rs", include_str!("catch_up.rs")),
361+ ("lifecycle.rs", include_str!("lifecycle.rs")),
362+ ("mirror.rs", include_str!("mirror.rs")),
363+ ("import.rs", include_str!("import.rs")),
364+ ("transfer.rs", include_str!("transfer.rs")),
365+ ("secret_scan.rs", include_str!("secret_scan.rs")),
366+ ("run_access.rs", include_str!("run_access.rs")),
367+ ("git_http.rs", include_str!("git_http.rs")),
368+ ];
369+ let mut all = Vec::new();
370+ for (file, source) in sources {
371+ for (function, records) in writers(source, &writes) {
372+ assert!(records, "{file}: `{function}` changes refs without calling refs_moved");
373+ all.push(function);
374+ }
375+ }
376+ // The writers known today, so that the check is seen to find them.
377+ for expected in ["create", "delete_branch", "land", "update_pull_branch", "mirror", "rename_branch", "forks_follow"] {
378+ assert!(
379+ all.iter().any(|function| function.contains(&format!("fn {expected}("))),
380+ "{expected} not found among {all:?}"
381+ );
382+ }
383+ // A push through git over HTTPS, and the default branch, which HEAD follows.
384+ let forwards = writers(include_str!("lib.rs"), &["git_http::forward("]);
385+ assert!(forwards.iter().any(|(function, _)| function.contains("fn answer_git(")));
386+ assert!(forwards.iter().all(|(_, records)| *records));
387+ let defaults = writers(include_str!("lifecycle.rs"), &["registry.set_default_branch("]);
388+ assert_eq!(defaults.len(), 3);
389+ assert!(defaults.iter().all(|(_, records)| *records));
390+ }
391+
392+ #[test]
393+ fn an_entry_survives_being_kept() {
394+ let entry = Entry {
395+ content_type: "application/x-git-upload-pack-advertisement".into(),
396+ body: b"001e# service=git-upload-pack\n0000".to_vec(),
397+ };
398+ assert_eq!(Entry::decode(&entry.encode()), Some(entry.clone()));
399+ assert!(entry.keepable());
400+ let large = Entry { body: vec![b'0'; MAX_KEPT_BYTES + 1], ..entry };
401+ assert!(!large.keepable());
402+ assert_eq!(Entry::decode(b"no newline"), None);
403+ }
404+}
+218−0
3535 archived_at: Option<String>,
3636 #[serde(default)]
3737 deleted_at: Option<String>,
38+ /// Bumped by everything that changes the repository's refs; see
39+ /// [`RefsState`]. Absent on rows read before the column existed.
40+ #[serde(default)]
41+ refs_version: Option<f64>,
42+ #[serde(default)]
43+ refs_open_until: Option<f64>,
44+}
45+
46+/// Where a repository's refs stand, as its row last said: `version` goes up
47+/// with every change g1t makes to them, so an answer that lists them (see
48+/// refs_cache.rs) is kept under the version it was made at, and a change
49+/// leaves it behind. Until `open_until` (milliseconds) a credential that
50+/// can change them is out of g1t's hands, and nothing is kept.
51+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52+pub struct RefsState {
53+ pub version: u64,
54+ pub open_until: u64,
55+}
56+
57+/// The newest [`RefsState`] this isolate has read or written, by
58+/// repository id. A version only goes up, so an older read finishing late
59+/// never takes a newer one back.
60+#[derive(Default)]
61+pub struct RefsStates {
62+ states: HashMap<String, RefsState>,
3863 }
3964
65+impl RefsStates {
66+ pub fn note(&mut self, id: &str, state: RefsState) {
67+ let kept = self.states.entry(id.to_owned()).or_default();
68+ kept.version = kept.version.max(state.version);
69+ kept.open_until = kept.open_until.max(state.open_until);
70+ }
71+
72+ pub fn get(&self, id: &str) -> Option<RefsState> {
73+ self.states.get(id).copied()
74+ }
75+}
76+
4077 thread_local! {
78+ static REFS: RefCell<RefsStates> = RefCell::new(RefsStates::default());
79+}
80+
81+/// Where the refs of the repository with this id stand, as this isolate
82+/// last read them; `None` before the column existed or before its row was
83+/// read here.
84+pub fn refs_state(id: &str) -> Option<RefsState> {
85+ REFS.with(|refs| refs.borrow().get(id))
86+}
87+
88+fn note_refs(id: &str, version: Option<f64>, open_until: Option<f64>) {
89+ if let Some(version) = version {
90+ let state = RefsState {
91+ version: version as u64,
92+ open_until: open_until.unwrap_or(0.0) as u64,
93+ };
94+ REFS.with(|refs| refs.borrow_mut().note(id, state));
95+ }
96+}
97+
98+thread_local! {
4199 /// Store keys that differ from the one a repository's path gives: those
42100 /// of repositories whose workspace was renamed after they were made.
43101 /// Filled whenever a row is read or written, so every `Repo` this
46104 static MOVED: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
47105 }
48106
107+/// How long a fetch may go by a repository's row as it was read a moment
108+/// ago: a clone is two or three requests in quick succession, and each
109+/// would otherwise read the same row. Short enough that making a repository
110+/// private, archiving or deleting it applies within seconds.
111+pub const RECENT_MS: u64 = 5_000;
112+
113+/// Repositories read in the last [`RECENT_MS`], by path. Only rows that
114+/// were found are kept, so a repository just made is never missed.
115+#[derive(Default)]
116+pub struct Recent {
117+ rows: HashMap<(String, String), (Repo, u64)>,
118+}
119+
120+impl Recent {
121+ fn key(path: &RepoPath) -> (String, String) {
122+ (path.namespace.to_lowercase(), path.name.to_lowercase())
123+ }
124+
125+ pub fn get(&self, path: &RepoPath, now: u64) -> Option<Repo> {
126+ self.rows
127+ .get(&Self::key(path))
128+ .filter(|(_, read)| now.saturating_sub(*read) < RECENT_MS)
129+ .map(|(repo, _)| repo.clone())
130+ }
131+
132+ pub fn keep(&mut self, path: &RepoPath, repo: &Repo, now: u64) {
133+ self.rows.retain(|_, (_, read)| now.saturating_sub(*read) < RECENT_MS);
134+ self.rows.insert(Self::key(path), (repo.clone(), now));
135+ }
136+}
137+
138+thread_local! {
139+ static RECENT: RefCell<Recent> = RefCell::new(Recent::default());
140+}
141+
49142 /// The key a repository's path gives: what every repository was stored
50143 /// under before workspaces could be renamed.
51144 pub fn path_key(repo: &Repo) -> String {
83176 if let Some(store) = &row.store {
84177 remember_store(&repo, store);
85178 }
179+ note_refs(&repo.id, row.refs_version, row.refs_open_until);
86180 repo
87181 }
88182 }
156250 .map(Repo::from))
157251 }
158252
253+ /// The repository at `path`, as read in the last few seconds if it was
254+ /// (see [`RECENT_MS`]). For fetches only: a push always reads the row.
255+ pub async fn by_path_recent(&self, path: &RepoPath) -> Result<Option<Repo>> {
256+ let now = g1t_kit::now_ms();
257+ if let Some(repo) = RECENT.with(|recent| recent.borrow().get(path, now)) {
258+ return Ok(Some(repo));
259+ }
260+ let found = self.by_path(path).await?;
261+ if let Some(repo) = &found {
262+ RECENT.with(|recent| recent.borrow_mut().keep(path, repo, now));
263+ }
264+ Ok(found)
265+ }
266+
159267 /// Its details; who can see it changes with `set_private`.
160268 pub async fn update(
161269 &self,
516624 .map_or(0, |row| row.left))
517625 }
518626
627+ /// Records that the refs of the repository with this id changed, after
628+ /// they did: what anything that lists them keeps goes stale.
629+ pub async fn refs_moved(&self, id: &str) -> Result<()> {
630+ self.bump_refs(
631+ "UPDATE repos SET refs_version = refs_version + 1 WHERE id = ?
632+ RETURNING refs_version, refs_open_until",
633+ &[id.into()],
634+ id,
635+ )
636+ .await
637+ }
638+
639+ /// Records that a credential able to change the refs of the repository
640+ /// with this id was handed out of g1t's hands, until `until`
641+ /// (milliseconds): until then, nothing that lists them is kept.
642+ pub async fn refs_open(&self, id: &str, until: u64) -> Result<()> {
643+ self.bump_refs(
644+ "UPDATE repos SET refs_version = refs_version + 1,
645+ refs_open_until = max(coalesce(refs_open_until, 0), ?)
646+ WHERE id = ? RETURNING refs_version, refs_open_until",
647+ &[(until as f64).into(), id.into()],
648+ id,
649+ )
650+ .await
651+ }
652+
653+ async fn bump_refs(&self, sql: &str, params: &[JsValue], id: &str) -> Result<()> {
654+ #[derive(Deserialize)]
655+ struct Bumped {
656+ refs_version: Option<f64>,
657+ refs_open_until: Option<f64>,
658+ }
659+ let bumped = self
660+ .db
661+ .prepare(sql)
662+ .bind(params)?
663+ .first::<Bumped>(None)
664+ .await?;
665+ if let Some(bumped) = bumped {
666+ note_refs(id, bumped.refs_version, bumped.refs_open_until);
667+ }
668+ Ok(())
669+ }
670+
519671 pub async fn insert(&self, repo: &Repo) -> Result<()> {
520672 self.db
521673 .prepare(
548700 use g1t_contracts::access::{BasePermission, RepoGrant};
549701 use g1t_contracts::{Membership, Role, User};
550702
703+ #[test]
704+ fn the_refs_state_kept_only_moves_forward() {
705+ let mut states = RefsStates::default();
706+ assert_eq!(states.get("rep_1"), None);
707+ states.note("rep_1", RefsState { version: 3, open_until: 0 });
708+ // A read that started before a bump and finished after it.
709+ states.note("rep_1", RefsState { version: 2, open_until: 0 });
710+ assert_eq!(states.get("rep_1").unwrap().version, 3);
711+ states.note("rep_1", RefsState { version: 4, open_until: 9_000 });
712+ states.note("rep_1", RefsState { version: 5, open_until: 0 });
713+ assert_eq!(states.get("rep_1"), Some(RefsState { version: 5, open_until: 9_000 }));
714+ assert_eq!(states.get("rep_2"), None);
715+ }
716+
717+ #[test]
718+ fn a_row_from_before_the_column_has_no_refs_state() {
719+ let row = |version: Option<f64>| RepoRow {
720+ id: format!("rep_row_{}", version.is_some()),
721+ namespace: "acme".into(),
722+ name: "rocket".into(),
723+ description: None,
724+ is_private: 0,
725+ owner_id: "usr_owner".into(),
726+ default_branch: "main".into(),
727+ fork_of: None,
728+ protected: 0,
729+ created_at: String::new(),
730+ store: None,
731+ topics: None,
732+ website: None,
733+ archived_at: None,
734+ deleted_at: None,
735+ refs_version: version,
736+ refs_open_until: None,
737+ };
738+ let old = Repo::from(row(None));
739+ assert_eq!(refs_state(&old.id), None);
740+ let new = Repo::from(row(Some(7.0)));
741+ assert_eq!(refs_state(&new.id), Some(RefsState { version: 7, open_until: 0 }));
742+ }
743+
744+ #[test]
745+ fn a_repository_read_a_moment_ago_is_reused_for_a_few_seconds() {
746+ let mut recent = Recent::default();
747+ let path = RepoPath {
748+ namespace: "Acme".into(),
749+ name: "Rocket".into(),
750+ };
751+ recent.keep(&path, &repo(false), 1_000);
752+ // Paths are matched as the table matches them, ignoring case.
753+ let lower = RepoPath {
754+ namespace: "acme".into(),
755+ name: "rocket".into(),
756+ };
757+ assert_eq!(recent.get(&lower, 1_000 + RECENT_MS - 1).unwrap().id, "rep_1");
758+ assert!(recent.get(&lower, 1_000 + RECENT_MS).is_none());
759+ let other = RepoPath {
760+ namespace: "acme".into(),
761+ name: "booster".into(),
762+ };
763+ assert!(recent.get(&other, 1_000).is_none());
764+ // Keeping another later drops the stale row.
765+ recent.keep(&other, &repo(true), 1_000 + RECENT_MS);
766+ assert_eq!(recent.rows.len(), 1);
767+ }
768+
551769 fn repo(private: bool) -> Repo {
552770 Repo {
553771 id: "rep_1".into(),
+11−4
99
1010 use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
1111 use g1t_contracts::credentials::{Decision, as_person, decide_git, decide_refs, limits_branches};
12−use g1t_contracts::repos::{GitService, RepoPath};
12+use g1t_contracts::repos::{GitService, Repo, RepoPath};
1313 use g1t_contracts::{PrincipalKind, Viewer};
1414 use worker::js_sys::Uint8Array;
1515 use worker::{Headers, Method, Request, RequestInit, Response, Result, console_error};
8484 /// Where a git request's entry belongs: the repository a fork came
8585 /// from, so that a pull request's pushes are in its workspace's log.
8686 pub(crate) async fn audit_target(&self, path: &RepoPath) -> Result<AuditTarget> {
87+ let found = self.registry.by_path(path).await?;
88+ self.audit_target_of(path, found.as_ref()).await
89+ }
90+
91+ /// The same, for the repository at `path` as already read (`found`).
92+ async fn audit_target_of(&self, path: &RepoPath, found: Option<&Repo>) -> Result<AuditTarget> {
8793 let mut repo = path.clone();
88− if let Some(found) = self.registry.by_path(path).await?
94+ if let Some(found) = found
8995 && let Some(source) = found.fork_of.as_deref()
9096 && let Some(source) = self.registry.by_id(source).await?
9197 {
122128 mut request: Request,
123129 git: &GitRequest,
124130 viewer: Viewer,
131+ found: Option<&Repo>,
125132 ) -> Result<Admitted> {
126133 let post = request.method() == Method::Post;
127134 let write = git.service == GitService::ReceivePack;
164171 PrincipalKind::User => "person",
165172 };
166173 Some(Box::new(entry(
167− self.audit_target(&git.path).await?,
174+ self.audit_target_of(&git.path, found).await?,
168175 &Decision::allow(rule),
169176 )))
170177 } else {
188195 // A fork is the pull request's own: any branch of it.
189196 refs.clear();
190197 }
191− let mut target = self.audit_target(&git.path).await?;
198+ let mut target = self.audit_target_of(&git.path, found).await?;
192199 if !refs.is_empty() {
193200 target.git_ref = Some(refs.join(" "));
194201 }
+12−8
1212 use futures_util::future::try_join_all;
1313 use g1t_contracts::User;
1414 use g1t_contracts::accounts::{CommitIdentityArgs, PushEmailGuard, mask_email};
15−use g1t_contracts::repos::{EntryKind, RepoPath};
15+use g1t_contracts::repos::{EntryKind, Repo, RepoPath};
1616 use g1t_contracts::security::{
1717 FindLockfilesArgs, HistoryPage, LockfileText, Lockfiles, NewSecret, PushBlockedArgs, PushVerdict,
1818 ScanHistoryArgs,
308308 /// Push protection: the response refusing a push that adds secrets
309309 /// nobody has allowed, or that would publish the pusher's private
310310 /// address, or `None` to let it through.
311− pub(crate) async fn protect(&self, path: &RepoPath, pusher: Option<&User>, body: &[u8]) -> Result<Option<Response>> {
312− if let Some(guard) = self.push_email_guard(pusher).await
311+ /// `repo` is the repository pushed to, as the request read it.
312+ pub(crate) async fn protect(&self, repo: &Repo, pusher: Option<&User>, body: &[u8]) -> Result<Option<Response>> {
313+ // Asking identity about the pusher's address and scanning the push
314+ // do not depend on each other, so they happen at once.
315+ let scan = async {
316+ let git = self.store.open(&store_key(repo)).await?;
317+ scan_push(&git, body).await
318+ };
319+ let (guard, found) = futures_util::future::join(self.push_email_guard(pusher), scan).await;
320+ if let Some(guard) = guard
313321 && let Some((commit, email)) = exposed_address(body, &guard)
314322 {
315323 return Ok(Some(crate::git_http::declined(
318326 &exposed_message(&commit, &email, &guard.noreply),
319327 )?));
320328 }
321− let Some(repo) = self.registry.by_path(path).await? else {
322− return Ok(None);
323− };
324− let git = self.store.open(&store_key(&repo)).await?;
325− let found = scan_push(&git, body).await?;
329+ let found = found?;
326330 if found.is_empty() {
327331 return Ok(None);
328332 }
+54−0
1+//! What isolates share with each other: a key-value store whose every value
2+//! is sealed (AES-256-GCM) under the service's own key, `REPOS_KEY`, with
3+//! the value's own key as associated data, so a value copied onto another
4+//! key does not open.
5+//!
6+//! Optional: without the `GIT_CACHE` binding or the key, nothing is shared
7+//! and each isolate keeps only what it made itself. Self-hosted, any
8+//! Workers KV-compatible store will do.
9+
10+use g1t_secrets::Sealer;
11+use worker::Env;
12+use worker::kv::KvStore;
13+
14+/// The shortest life Workers KV gives a value.
15+pub const MIN_TTL_SECONDS: u64 = 60;
16+
17+pub struct Shared {
18+ kv: KvStore,
19+ sealer: Sealer,
20+}
21+
22+impl Shared {
23+ pub fn from_env(env: &Env) -> Option<Shared> {
24+ let kv = env.kv("GIT_CACHE").ok()?;
25+ let sealer = Sealer::new(&env.secret("REPOS_KEY").ok()?.to_string())?;
26+ Some(Shared { kv, sealer })
27+ }
28+
29+ /// The value under `key`, if there is one that opens. A failure to
30+ /// read is a miss.
31+ pub async fn get(&self, key: &str) -> Option<Vec<u8>> {
32+ let sealed = self.kv.get(key).bytes().await.ok()??;
33+ self.sealer.open_bytes(&sealed, key)
34+ }
35+
36+ /// Keeps `value` under `key` for `ttl_seconds` (at least a minute). A
37+ /// failure only costs a later miss.
38+ pub async fn put(&self, key: &str, value: &[u8], ttl_seconds: u64) {
39+ let sealed = self.sealer.seal_bytes(value, key);
40+ let put = match self.kv.put_bytes(key, &sealed) {
41+ Ok(put) => put.expiration_ttl(ttl_seconds.max(MIN_TTL_SECONDS)),
42+ Err(_) => return,
43+ };
44+ if let Err(error) = put.execute().await {
45+ worker::console_error!("shared cache: {key} not kept: {error}");
46+ }
47+ }
48+
49+ pub async fn delete(&self, key: &str) {
50+ if let Err(error) = self.kv.delete(key).await {
51+ worker::console_error!("shared cache: {key} not removed: {error}");
52+ }
53+ }
54+}
+258−13
66 use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry};
77 use g1t_contracts::time::rfc3339;
88 use g1t_kit::js;
9−use serde::Deserialize;
9+use serde::{Deserialize, Serialize};
10+use std::cell::RefCell;
11+use std::collections::HashMap;
12+use std::rc::Rc;
1013 use worker::js_sys::{Reflect, Uint8Array};
1114 use worker::wasm_bindgen::{JsCast, JsValue};
1215 use worker::{Env, Result};
1316
1417 /// How long a credential handed to git stays valid.
1518 const TOKEN_TTL_SECONDS: u32 = 300;
19+/// The same, in milliseconds.
20+pub const CREDENTIAL_LIFE_MS: u64 = TOKEN_TTL_SECONDS as u64 * 1000;
21+/// How long a credential is reused for, so that every one used has at
22+/// least two minutes left. Credentials never leave this service: g1t has
23+/// already decided who may do what before one is used.
24+const TOKEN_REUSE_MS: u64 = 180_000;
1625
17−#[derive(Clone, Copy)]
26+#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
1827 pub enum Scope {
1928 Read,
2029 Write,
2130 }
2231
32+impl Scope {
33+ fn as_str(self) -> &'static str {
34+ match self {
35+ Scope::Read => "read",
36+ Scope::Write => "write",
37+ }
38+ }
39+}
40+
41+/// Where a credential handed out came from, for `Server-Timing`.
42+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
43+pub enum Kept {
44+ /// This isolate made it, or had it from another, a moment ago.
45+ Isolate,
46+ /// Another isolate made it and shared it.
47+ Shared,
48+}
49+
50+impl Kept {
51+ pub fn as_str(self) -> &'static str {
52+ match self {
53+ Kept::Isolate => "isolate",
54+ Kept::Shared => "shared",
55+ }
56+ }
57+}
58+
2359 /// A place repositories live. `key` is the store's own name for a repo.
2460 #[allow(async_fn_in_trait)]
2561 pub trait GitStore {
3369 default_branch: &str,
3470 ) -> Result<()>;
3571 async fn open(&self, key: &str) -> Result<Self::Repo>;
72+ /// A credential for `key` made a moment ago, if the store keeps one.
73+ async fn kept_access(&self, _key: &str, _scope: Scope) -> Option<(GitAccess, Kept)> {
74+ None
75+ }
76+ /// A new credential for `key`, which the store may keep for next time.
77+ async fn mint_access(&self, key: &str, scope: Scope) -> Result<GitAccess> {
78+ self.open(key).await?.access(scope).await
79+ }
80+ /// A remote URL and credential for git itself, for the repository at
81+ /// `key`. A store may hand out one it made a moment ago.
82+ async fn access(&self, key: &str, scope: Scope) -> Result<GitAccess> {
83+ match self.kept_access(key, scope).await {
84+ Some((access, _)) => Ok(access),
85+ None => self.mint_access(key, scope).await,
86+ }
87+ }
88+ /// Stops handing out the credentials it keeps for `key`: the store
89+ /// turned one down, or the repository is gone.
90+ async fn forget_access(&self, _key: &str) {}
3691 /// Removes a repository and everything in it, for good. Succeeds if it
3792 /// is already gone.
3893 async fn delete(&self, key: &str) -> Result<()>;
59114
60115 pub struct ArtifactsStore {
61116 binding: JsValue,
117+ /// Where isolates share the credentials they make; see shared.rs.
118+ shared: Option<Rc<crate::shared::Shared>>,
62119 }
63120
64121 impl ArtifactsStore {
65− pub fn new(env: &Env) -> Result<Self> {
122+ pub fn new(env: &Env, shared: Option<Rc<crate::shared::Shared>>) -> Result<Self> {
66123 Ok(Self {
67124 binding: js::binding(env, "ARTIFACTS")?,
125+ shared,
68126 })
69127 }
70128 }
71129
130+/// A credential as isolates share it, sealed (see shared.rs): with when it
131+/// was made, so that one shared is reused no longer than one kept here.
132+#[derive(Serialize, Deserialize)]
133+struct SharedCredential {
134+ remote: String,
135+ token: String,
136+ made: u64,
137+}
138+
139+/// The shared cache's key for a credential: the store's key for the
140+/// repository, and the scope.
141+fn shared_key(key: &str, scope: Scope) -> String {
142+ format!("cred:{key}:{}", scope.as_str())
143+}
144+
145+/// A shared credential, if it was made less than [`TOKEN_REUSE_MS`] before
146+/// `now`; with when it was made.
147+fn shared_credential(bytes: &[u8], now: u64) -> Option<(GitAccess, u64)> {
148+ let kept: SharedCredential = serde_json::from_slice(bytes).ok()?;
149+ (now.saturating_sub(kept.made) < TOKEN_REUSE_MS).then_some((
150+ GitAccess {
151+ remote: kept.remote,
152+ token: kept.token,
153+ },
154+ kept.made,
155+ ))
156+}
157+
158+/// Credentials made in the last few minutes, by repository and scope.
159+/// Making one is a round trip to the store on every git request; reusing
160+/// it saves that, and the store's lookup of the repository with it.
161+#[derive(Default)]
162+pub struct Credentials {
163+ kept: HashMap<(String, Scope), (GitAccess, u64)>,
164+}
165+
166+impl Credentials {
167+ /// One made for `key` and `scope` less than [`TOKEN_REUSE_MS`] before `now`.
168+ pub fn get(&self, key: &str, scope: Scope, now: u64) -> Option<GitAccess> {
169+ self.kept
170+ .get(&(key.to_owned(), scope))
171+ .filter(|(_, made)| now.saturating_sub(*made) < TOKEN_REUSE_MS)
172+ .map(|(access, _)| access.clone())
173+ }
174+
175+ pub fn keep(&mut self, key: &str, scope: Scope, access: GitAccess, now: u64) {
176+ // Expired ones go first, so the map stays as small as the isolate's
177+ // recent repositories.
178+ self.kept
179+ .retain(|_, (_, made)| now.saturating_sub(*made) < TOKEN_REUSE_MS);
180+ self.kept.insert((key.to_owned(), scope), (access, now));
181+ }
182+
183+ pub fn forget(&mut self, key: &str) {
184+ self.kept.retain(|(kept, _), _| kept != key);
185+ }
186+}
187+
188+thread_local! {
189+ static CREDENTIALS: RefCell<Credentials> = RefCell::new(Credentials::default());
190+}
191+
72192 impl GitStore for ArtifactsStore {
73193 type Repo = ArtifactsRepo;
74194
195+ /// One kept in this isolate, else one another isolate shared. A shared
196+ /// one is kept here only for the rest of its own reuse window.
197+ async fn kept_access(&self, key: &str, scope: Scope) -> Option<(GitAccess, Kept)> {
198+ let now = g1t_kit::now_ms();
199+ if let Some(access) = CREDENTIALS.with(|kept| kept.borrow().get(key, scope, now)) {
200+ return Some((access, Kept::Isolate));
201+ }
202+ let bytes = self.shared.as_ref()?.get(&shared_key(key, scope)).await?;
203+ let (access, made) = shared_credential(&bytes, now)?;
204+ CREDENTIALS.with(|kept| kept.borrow_mut().keep(key, scope, access.clone(), made));
205+ Some((access, Kept::Shared))
206+ }
207+
208+ /// Made by the store, then kept here and shared with other isolates.
209+ async fn mint_access(&self, key: &str, scope: Scope) -> Result<GitAccess> {
210+ let now = g1t_kit::now_ms();
211+ let access = self.open(key).await?.access(scope).await?;
212+ CREDENTIALS.with(|kept| kept.borrow_mut().keep(key, scope, access.clone(), now));
213+ if let Some(shared) = &self.shared {
214+ let value = SharedCredential {
215+ remote: access.remote.clone(),
216+ token: access.token.clone(),
217+ made: now,
218+ };
219+ if let Ok(bytes) = serde_json::to_vec(&value) {
220+ shared
221+ .put(&shared_key(key, scope), &bytes, TOKEN_REUSE_MS / 1000)
222+ .await;
223+ }
224+ }
225+ Ok(access)
226+ }
227+
228+ async fn forget_access(&self, key: &str) {
229+ CREDENTIALS.with(|kept| kept.borrow_mut().forget(key));
230+ if let Some(shared) = &self.shared {
231+ futures_util::future::join(
232+ shared.delete(&shared_key(key, Scope::Read)),
233+ shared.delete(&shared_key(key, Scope::Write)),
234+ )
235+ .await;
236+ }
237+ }
238+
75239 async fn create(
76240 &self,
77241 key: &str,
90254 }
91255
92256 async fn delete(&self, key: &str) -> Result<()> {
257+ self.forget_access(key).await;
93258 match js::call(&self.binding, "delete", &[key.into()]).await {
94259 // Gone already: an earlier purge got this far.
95260 Err(thrown) if !thrown.is("NOT_FOUND") => Err(thrown.into()),
199364
200365 impl GitRepo for ArtifactsRepo {
201366 async fn access(&self, scope: Scope) -> Result<GitAccess> {
202− let scope = match scope {
203− Scope::Read => "read",
204− Scope::Write => "write",
205− };
206− let info: RawInfo = js::from_js(&js::call(&self.handle, "info", &[]).await?)?;
207− let token: RawToken = js::from_js(
208− &js::call(
367+ let scope = scope.as_str();
368+ // Two round trips to the store, at once.
369+ let (info, token) = futures_util::future::join(
370+ js::call(&self.handle, "info", &[]),
371+ js::call(
209372 &self.handle,
210373 "createToken",
211374 &[scope.into(), TOKEN_TTL_SECONDS.into()],
212− )
213− .await?,
214− )?;
375+ ),
376+ )
377+ .await;
378+ let info: RawInfo = js::from_js(&info?)?;
379+ let token: RawToken = js::from_js(&token?)?;
215380 Ok(GitAccess {
216381 remote: info.remote,
217382 token: token.plaintext,
293458 }
294459 }
295460 }
461+
462+#[cfg(test)]
463+mod tests {
464+ use super::{Credentials, GitAccess, Scope, SharedCredential, TOKEN_REUSE_MS, shared_credential, shared_key};
465+
466+ fn access(token: &str) -> GitAccess {
467+ GitAccess {
468+ remote: "https://store.example/acme--rocket.git".to_owned(),
469+ token: token.to_owned(),
470+ }
471+ }
472+
473+ #[test]
474+ fn a_credential_is_reused_only_while_it_has_time_left() {
475+ let mut kept = Credentials::default();
476+ kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000);
477+ assert_eq!(kept.get("acme--rocket", Scope::Read, 1_000).unwrap().token, "r1");
478+ assert_eq!(
479+ kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS - 1).unwrap().token,
480+ "r1"
481+ );
482+ assert!(kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS).is_none());
483+ }
484+
485+ #[test]
486+ fn a_credential_is_kept_for_its_own_repository_and_scope() {
487+ let mut kept = Credentials::default();
488+ kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000);
489+ // A read credential never stands in for a write one.
490+ assert!(kept.get("acme--rocket", Scope::Write, 1_000).is_none());
491+ assert!(kept.get("acme--booster", Scope::Read, 1_000).is_none());
492+ kept.keep("acme--rocket", Scope::Write, access("w1"), 1_000);
493+ assert_eq!(kept.get("acme--rocket", Scope::Write, 1_000).unwrap().token, "w1");
494+ assert_eq!(kept.get("acme--rocket", Scope::Read, 1_000).unwrap().token, "r1");
495+ }
496+
497+ #[test]
498+ fn a_shared_credential_is_reused_only_in_its_own_window() {
499+ let value = serde_json::to_vec(&SharedCredential {
500+ remote: "https://store.example/acme--rocket.git".to_owned(),
501+ token: "r1".to_owned(),
502+ made: 10_000,
503+ })
504+ .unwrap();
505+ let (access, made) = shared_credential(&value, 10_000 + TOKEN_REUSE_MS - 1).unwrap();
506+ assert_eq!(access.token, "r1");
507+ // Kept here only for what is left of its window, not a new one.
508+ assert_eq!(made, 10_000);
509+ assert!(shared_credential(&value, 10_000 + TOKEN_REUSE_MS).is_none());
510+ // Anything else is a miss.
511+ assert!(shared_credential(b"not json", 10_000).is_none());
512+ // Each repository and scope has its own key.
513+ assert_eq!(shared_key("acme--rocket", Scope::Read), "cred:acme--rocket:read");
514+ assert_ne!(shared_key("acme--rocket", Scope::Read), shared_key("acme--rocket", Scope::Write));
515+ }
516+
517+ #[test]
518+ fn a_shared_credential_kept_here_expires_with_the_original() {
519+ let mut kept = Credentials::default();
520+ // Made at 1_000 elsewhere, found here at 100_000.
521+ kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000);
522+ assert!(kept.get("acme--rocket", Scope::Read, 100_000).is_some());
523+ assert!(kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS).is_none());
524+ }
525+
526+ #[test]
527+ fn a_turned_down_credential_is_forgotten_and_old_ones_are_dropped() {
528+ let mut kept = Credentials::default();
529+ kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000);
530+ kept.keep("acme--rocket", Scope::Write, access("w1"), 1_000);
531+ kept.keep("acme--booster", Scope::Read, access("b1"), 1_000);
532+ kept.forget("acme--rocket");
533+ assert!(kept.get("acme--rocket", Scope::Read, 1_000).is_none());
534+ assert!(kept.get("acme--rocket", Scope::Write, 1_000).is_none());
535+ assert!(kept.get("acme--booster", Scope::Read, 1_000).is_some());
536+ // Keeping another later drops the expired one from the map.
537+ kept.keep("acme--other", Scope::Read, access("o1"), 1_000 + TOKEN_REUSE_MS);
538+ assert_eq!(kept.kept.len(), 1);
539+ }
540+}
+6−0
1818 }
1919 ],
2020 "artifacts": [{ "binding": "ARTIFACTS", "namespace": "g1t" }],
21+ // Shared between isolates (src/shared.rs): the git store's credentials
22+ // (src/store.rs) and the ref listings git asks for first
23+ // (src/refs_cache.rs), every value sealed with the REPOS_KEY secret (64
24+ // hex characters). Without the binding or the secret each isolate keeps
25+ // only its own. Made by `npx wrangler kv namespace create g1t-repos-git-cache`:
26+ "kv_namespaces": [{ "binding": "GIT_CACHE", "id": "be765052d0124c2a935b3db4dff99f1f" }],
2127 "services": [
2228 { "binding": "IDENTITY", "service": "g1t-identity" },
2329 { "binding": "EVENTS", "service": "g1t-events" },