Skip to content

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

180 linesCodeBlameRaw
1/**
2 * The daily signal's reads: the work service's `workspaceSignals` for the
3 * workspace's repositories, a hundred at a time; the agents' usage for the
4 * weeks and for the quarter; and, for the Workspace overview, its teams.
5 * Each comes back on its own, so one that fails leaves only what needs it
6 * saying so. The shaping is in ./signals.ts.
7 */
8import type { Repo, RepoPath, Usage, User, WorkspaceSignals } from "@g1t/contracts";
9
10import { billing, identity, work } from "./services.server";
11import { type Group, type WeekTotals, mergeSignals, signalsRange, weekStarts, weekTotals, weeklySpend } from "./signals";
12
13/** Repositories asked of the work service at once: its limit. */
14const BATCH = 100;
15/** Teams the rhythm reads the projects of. */
16const MAX_TEAMS = 8;
17
18export type SignalData = {
19 slug: string;
20 now: number;
21 range: ReturnType<typeof signalsRange>;
22 starts: string[];
23 repos: Record<string, RepoPath>;
24 signals: WorkspaceSignals;
25 /** Every batch answered: otherwise the figures leave some projects out. */
26 complete: boolean;
27 weeks: WeekTotals[];
28 /** Usage per week; null when billing did not answer. */
29 spend: number[] | null;
30 quarterUsage: Usage | null;
31};
32
33async function usageSince(viewer: User, slug: string, day: string): Promise<Usage | null> {
34 const result = await billing.usage(slug, viewer, `${day}T00:00:00.000Z`).catch(() => null);
35 return result?.ok ? result.value : null;
36}
37
38/** How long one reading of the work is used again: Home refreshes itself often, and the weeks barely move in minutes. */
39const KEEP_MS = 5 * 60_000;
40/** Readings kept in the isolate at once. */
41const KEEP_MAX = 50;
42/** Where readings are kept in the data centre's cache: a name nothing outside can ask for. */
43const KEEP_ORIGIN = "https://signals.g1t.internal/";
44
45const kept = new Map<string, { at: number; data: SignalData }>();
46
47/**
48 * What the workspace's work says, for a page drawn at `now`, read again
49 * at most every five minutes per person (what each person can read
50 * differs, so a reading is never shared between people). Kept in the
51 * isolate and in the data centre's cache; only a reading every batch of
52 * which answered is kept. Null when the work service answered none of
53 * the batches: the page then says so.
54 */
55export async function loadSignalData(viewer: User, slug: string, now: number, owned: Repo[] | null): Promise<SignalData | null> {
56 if (!owned) return null;
57 const key = `${viewer.id}/${slug}/${await digest(
58 owned
59 .map((repo) => repo.id)
60 .sort()
61 .join(","),
62 )}`;
63 const memory = kept.get(key);
64 if (memory && now - memory.at < KEEP_MS) return memory.data;
65 const shared = sharedCache();
66 const url = `${KEEP_ORIGIN}${encodeURIComponent(key)}`;
67 const found = shared ? await shared.match(url).catch(() => undefined) : undefined;
68 if (found) {
69 const data = (await found.json().catch(() => null)) as SignalData | null;
70 if (data && now - data.now < KEEP_MS) {
71 remember(key, data);
72 return data;
73 }
74 }
75 const data = await readSignalData(viewer, slug, now, owned);
76 if (data?.complete) {
77 remember(key, data);
78 const response = new Response(JSON.stringify(data), {
79 headers: { "content-type": "application/json", "cache-control": `private, max-age=${KEEP_MS / 1000}` },
80 });
81 // Kept for the next page; the answer never waits for it.
82 void shared?.put(url, response).catch(() => undefined);
83 }
84 return data;
85}
86
87function remember(key: string, data: SignalData) {
88 kept.delete(key);
89 kept.set(key, { at: data.now, data });
90 while (kept.size > KEEP_MAX) kept.delete(kept.keys().next().value!);
91}
92
93/** The data centre's cache, or null where there is none (tests, local development). */
94function sharedCache(): Cache | null {
95 try {
96 return (globalThis as { caches?: { default?: Cache } }).caches?.default ?? null;
97 } catch {
98 return null;
99 }
100}
101
102async function digest(text: string): Promise<string> {
103 const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
104 return [...new Uint8Array(bytes).slice(0, 12)].map((b) => b.toString(16).padStart(2, "0")).join("");
105}
106
107async function readSignalData(viewer: User, slug: string, now: number, owned: Repo[]): Promise<SignalData | null> {
108 const range = signalsRange(now);
109 const starts = weekStarts(range.until, range.weeks);
110 const ids = owned.map((repo) => repo.id);
111 const repos: Record<string, RepoPath> = {};
112 for (const repo of owned) repos[repo.id] = { namespace: repo.namespace, name: repo.name };
113 const [batches, weekly, quarterUsage] = await Promise.all([
114 Promise.allSettled(
115 Array.from({ length: Math.ceil(ids.length / BATCH) }, (_, i) =>
116 work.workspaceSignals(
117 { workspace: slug, repoIds: ids.slice(i * BATCH, (i + 1) * BATCH), until: range.until, weeks: range.weeks, days: range.days, since: range.since },
118 viewer,
119 ),
120 ),
121 ),
122 usageSince(viewer, slug, starts[0]!),
123 usageSince(viewer, slug, range.quarter),
124 ]);
125 const answered = batches.flatMap((batch) => (batch.status === "fulfilled" ? [batch.value] : []));
126 for (const batch of batches) if (batch.status === "rejected") console.warn("g1t: workspace signals could not be read:", batch.reason);
127 if (ids.length > 0 && answered.length === 0) return null;
128 const signals = mergeSignals(answered);
129 const weeks = weekTotals(signals.weeks, starts);
130 return {
131 slug,
132 now,
133 range,
134 starts,
135 repos,
136 signals,
137 complete: answered.length === batches.length,
138 weeks,
139 // g1t's metered usage each week, and what the harness sessions started in it cost.
140 spend: weeklySpend(weekly, starts)?.map((micros, i) => micros + Math.round((weeks[i]?.sessionCostUsd ?? 0) * 1_000_000)) ?? null,
141 quarterUsage,
142 };
143}
144
145/**
146 * What the rhythm's rows are: the workspace's teams by the projects they
147 * work on, with how many people each has; else, with no teams, the
148 * projects themselves. Null when identity did not answer.
149 */
150export async function loadGroups(viewer: User, slug: string, owned: Repo[]): Promise<{ groups: Group[]; by: "team" | "project" } | null> {
151 const projects = (): { groups: Group[]; by: "project" } => ({
152 by: "project",
153 groups: owned.map((repo) => ({ key: repo.id, label: repo.name, note: repo.namespace, to: `/${repo.namespace}/${repo.name}`, repoIds: [repo.id] })),
154 });
155 const listed = await identity.listTeams(viewer, slug).catch(() => null);
156 if (!listed) return null;
157 if (!listed.ok || listed.value.length === 0) return projects();
158 const teams = listed.value.slice(0, MAX_TEAMS);
159 const [repos, memberships] = await Promise.all([
160 Promise.all(teams.map((team) => identity.teamRepos(viewer, slug, team.slug).catch(() => null))),
161 identity.teamMemberships(viewer, slug).catch(() => null),
162 ]);
163 const people = new Map<string, number>();
164 if (memberships?.ok) for (const member of memberships.value) for (const team of member.teams) people.set(team.slug, (people.get(team.slug) ?? 0) + 1);
165 const groups = teams.flatMap((team, i) => {
166 const found = repos[i];
167 if (!found?.ok || found.value.length === 0) return [];
168 const count = people.get(team.slug);
169 return [
170 {
171 key: team.slug,
172 label: team.name,
173 note: count == null ? "" : `${count} ${count === 1 ? "person" : "people"}`,
174 to: `/${slug}/-/teams/${team.slug}`,
175 repoIds: found.value.map((repo) => repo.repo_id),
176 },
177 ];
178 });
179 return groups.length > 0 ? { by: "team", groups } : projects();
180}