| 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 | */ |
| 8 | import type { Repo, RepoPath, Usage, User, WorkspaceSignals } from "@g1t/contracts"; |
| 9 | |
| 10 | import { billing, identity, work } from "./services.server"; |
| 11 | import { type Group, type WeekTotals, mergeSignals, signalsRange, weekStarts, weekTotals, weeklySpend } from "./signals"; |
| 12 | |
| 13 | /** Repositories asked of the work service at once: its limit. */ |
| 14 | const BATCH = 100; |
| 15 | /** Teams the rhythm reads the projects of. */ |
| 16 | const MAX_TEAMS = 8; |
| 17 | |
| 18 | export 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 | |
| 33 | async 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. */ |
| 39 | const KEEP_MS = 5 * 60_000; |
| 40 | /** Readings kept in the isolate at once. */ |
| 41 | const KEEP_MAX = 50; |
| 42 | /** Where readings are kept in the data centre's cache: a name nothing outside can ask for. */ |
| 43 | const KEEP_ORIGIN = "https://signals.g1t.internal/"; |
| 44 | |
| 45 | const 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 | */ |
| 55 | export 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 | |
| 87 | function 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). */ |
| 94 | function sharedCache(): Cache | null { |
| 95 | try { |
| 96 | return (globalThis as { caches?: { default?: Cache } }).caches?.default ?? null; |
| 97 | } catch { |
| 98 | return null; |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | async 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 | |
| 107 | async 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 | */ |
| 150 | export 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 | } |