Skip to content
317 linesCodeBlameRaw
1/**
2 * Home's reads: each section's data from its own service, every call on
3 * its own so one that fails leaves only its section saying so. The shaping
4 * is in ./home.ts.
5 */
6import { env } from "cloudflare:workers";
7
8import {
9 type ActivityDigest,
10 type AgentSession,
11 type AgentsOverview,
12 type ChatActivity,
13 type Deployment,
14 type InstallRequests,
15 MAX_DIGEST_REPOS,
16 type Memory,
17 type ProjectDeploys,
18 type Repo,
19 type RepoPath,
20 type User,
21 type WorkflowRun,
22 type WorkspaceAgent,
23 notifyClient,
24} from "@g1t/contracts";
25
26import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type SessionWork, type Span, type Spend, pullKey, spanMonths, spendIn } from "./home";
27import { monthSpan, spentMicros } from "./spend";
28import { actions, agents, billing, chat, deployments, events, identity, repos, work, workspaceAgents } from "./services.server";
29
30/** Pull requests read per project and list: `LIST_PAGE` in services/work. */
31const PULL_PAGE = 100;
32/** The most projects whose pull requests are read. */
33const MAX_PROJECTS = 50;
34/** The most projects whose builds are read for the span. */
35const MAX_DEPLOY_PROJECTS = 20;
36/** The most projects whose workflow runs are read for Running now: those pushed to most in the span. */
37const MAX_WORKFLOW_PROJECTS = 10;
38/** Runs read per project: enough to find the ones not finished. */
39const WORKFLOW_RUNS = 20;
40/** Revise runs and sessions read: the services' most in one list. */
41const RUN_PAGE = 200;
42const SESSION_PAGE = 200;
43/** The most member keys named in one lookup. */
44const MAX_NAMES = 200;
45const DAY = 24 * 60 * 60 * 1000;
46
47const warn = (what: string) => (error: unknown) => {
48 console.warn(`home: ${what} failed`, error);
49 return null;
50};
51
52/**
53 * When the viewer was last on this workspace's Home, from notify, where it
54 * is kept with them (the same on every device). Null when there is no
55 * visit on record; undefined when notify did not answer.
56 */
57export async function loadLastVisit(viewer: User, slug: string): Promise<number | null | undefined> {
58 if (!env.NOTIFY) return undefined;
59 const visit = await notifyClient(env.NOTIFY).lastVisit(viewer, slug).catch(warn("last visit"));
60 if (!visit) return undefined;
61 return visit.seen_at ? Date.parse(visit.seen_at) : null;
62}
63
64/** Marks the viewer's visit at `at`, the time the page they looked at loaded. */
65export async function markVisit(viewer: User, slug: string, at: number): Promise<boolean> {
66 if (!env.NOTIFY) return false;
67 const marked = await notifyClient(env.NOTIFY).markVisit(viewer, slug, new Date(at).toISOString()).catch(warn("mark visit"));
68 return marked != null;
69}
70
71/**
72 * The workspace's own repositories the viewer may read (no pull request
73 * working copies), as every Code section reads them. Null when repos did
74 * not answer.
75 */
76export async function loadRepos(viewer: User, slug: string): Promise<Repo[] | null> {
77 const list = await repos.list(viewer, { namespace: slug }).catch(warn("repos"));
78 return list ? list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug) : null;
79}
80
81/** The repositories by id, as the digest's lines and links need them. */
82export function repoRefs(list: Repo[] | null): Record<string, RepoRef> {
83 const refs: Record<string, RepoRef> = {};
84 for (const repo of list ?? []) refs[repo.id] = { namespace: repo.namespace, name: repo.name, defaultBranch: repo.defaultBranch };
85 return refs;
86}
87
88/**
89 * The workspace's projects' pull requests, open and recently merged or
90 * closed, any author's, with how many times each agent's was sent back to
91 * revise. `complete` when every list reaches back to `reach`. Null when
92 * Code did not answer.
93 */
94export async function loadCodeWork(viewer: User, slug: string, reach: number, owned: Repo[] | null): Promise<CodeWork | null> {
95 const runs = await agents.listRuns(viewer, { workspace: slug, kind: "revise", limit: RUN_PAGE }).catch(warn("revise runs"));
96 if (!owned || !runs?.ok) return null;
97 const chosen = owned.slice(0, MAX_PROJECTS);
98 const byId = new Map<string, Repo>(chosen.map((repo) => [repo.id, repo]));
99 const batch = chosen.length > 0 ? await work.pullsForRepos([...byId.keys()], viewer, PULL_PAGE).catch(warn("pull requests")) : [];
100 if (!batch) return null;
101 const pulls: CodePull[] = [];
102 let complete = owned.length <= MAX_PROJECTS;
103 for (const entry of batch) {
104 const repo = byId.get(entry.repoId);
105 if (!repo) continue;
106 const path = { namespace: repo.namespace, name: repo.name };
107 for (const pull of [...entry.open, ...entry.closed]) {
108 pulls.push({
109 repo: path,
110 number: pull.number,
111 title: pull.title,
112 status: pull.status,
113 mergedAt: pull.mergedAt,
114 mergedBy: pull.mergedBy,
115 createdAt: pull.createdAt,
116 updatedAt: pull.updatedAt,
117 author: { username: pull.author.username, kind: pull.author.kind },
118 });
119 }
120 // A full page that does not reach back far enough may leave some out.
121 for (const list of [entry.open, entry.closed]) {
122 if (list.length >= PULL_PAGE) {
123 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)));
124 if (oldest > reach) complete = false;
125 }
126 }
127 }
128 const revisions: Record<string, number> = {};
129 for (const run of runs.value) {
130 if (run.number == null) continue;
131 const key = pullKey(run.repo, run.number);
132 revisions[key] = (revisions[key] ?? 0) + 1;
133 }
134 if (runs.value.length >= RUN_PAGE && Date.parse(runs.value[runs.value.length - 1].createdAt) > reach) complete = false;
135 return { pulls, revisions, complete };
136}
137
138/**
139 * What happened in the span, counted by who did it: the events service's
140 * digest of the workspace's repositories (the first MAX_DIGEST_REPOS of
141 * them; none without Code access) and of its artifacts. Null when events
142 * did not answer.
143 */
144export async function loadActivity(slug: string, span: Pick<Span, "from" | "now">, owned: Repo[] | null): Promise<ActivityDigest | null> {
145 const digest = await events
146 .activityDigest({
147 repo_ids: (owned ?? []).slice(0, MAX_DIGEST_REPOS).map((repo) => repo.id),
148 workspace: slug,
149 from: new Date(span.from).toISOString(),
150 until: new Date(span.now + 1).toISOString(),
151 })
152 .catch(warn("activity digest"));
153 if (!digest) return null;
154 // More repositories than the digest reads: it says so.
155 return owned && owned.length > MAX_DIGEST_REPOS ? { ...digest, complete: false } : digest;
156}
157
158/** What was said in the span where the viewer can read, counted. Null when chat did not answer. */
159export async function loadChatActivity(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<ChatActivity | null> {
160 const activity = await chat
161 .activity(slug, viewer, { from: new Date(span.from).toISOString(), until: new Date(span.now + 1).toISOString() })
162 .catch(warn("chat activity"));
163 return activity?.ok ? activity.value : null;
164}
165
166/** Every member key the digest and chat name. */
167export function actorKeys(activity: ActivityDigest | null, chatActivity: ChatActivity | null): string[] {
168 const keys = new Set<string>();
169 for (const repo of activity?.repos ?? []) {
170 for (const by of [...repo.pushes.by, ...repo.pushes.default_branch.by]) keys.add(by.actor);
171 for (const list of [repo.pulls.opened, repo.pulls.merged, repo.pulls.closed, repo.issues.opened, repo.issues.closed, repo.reviews, repo.comments, repo.deployments.succeeded, repo.deployments.failed]) {
172 for (const entry of list) keys.add(entry.actor);
173 }
174 for (const release of repo.releases) keys.add(release.actor);
175 for (const pkg of repo.packages) keys.add(pkg.actor);
176 }
177 for (const entry of activity?.folios?.created ?? []) keys.add(entry.actor);
178 for (const folio of activity?.folios?.edited ?? []) for (const author of folio.authors) keys.add(author);
179 for (const author of chatActivity?.authors ?? []) keys.add(author.key);
180 keys.delete("");
181 return [...keys];
182}
183
184/**
185 * The names behind member keys: people's usernames from identity (one
186 * lookup for them all), agents' handles from the workspace's agents, g1t
187 * as itself. A key identity does not know is left out, and shows as
188 * "someone".
189 */
190export async function loadNames(keys: string[], agentsList: Pick<WorkspaceAgent, "id" | "handle">[] | null): Promise<Record<string, string>> {
191 const names: Record<string, string> = {};
192 const userIds: string[] = [];
193 const byAgentId = new Map((agentsList ?? []).map((agent) => [agent.id, agent.handle]));
194 for (const key of keys.slice(0, MAX_NAMES)) {
195 if (key === G1T_ACTOR) names[key] = "g1t";
196 else if (key.startsWith("agent:")) {
197 const handle = byAgentId.get(key.slice("agent:".length));
198 if (handle) names[key] = handle;
199 } else if (key.startsWith("user:")) userIds.push(key.slice("user:".length));
200 }
201 if (userIds.length > 0) {
202 const found = await identity.usernames(userIds).catch(warn("usernames"));
203 for (const [id, username] of Object.entries(found ?? {})) names[`user:${id}`] = username;
204 }
205 return names;
206}
207
208/**
209 * The newest workflow runs of the projects pushed to most in the span (at
210 * most MAX_WORKFLOW_PROJECTS), for the ones still going. A project whose
211 * runs could not be read is left out; `complete` says whether every one was.
212 */
213export async function loadWorkflowRuns(
214 viewer: User,
215 activity: ActivityDigest | null,
216 owned: Repo[] | null,
217): Promise<{ projects: { repo: RepoPath; runs: WorkflowRun[] }[]; complete: boolean }> {
218 const byId = new Map((owned ?? []).map((repo) => [repo.id, repo]));
219 const active = [...(activity?.repos ?? [])]
220 .filter((repo) => repo.pushes.count > 0 && byId.has(repo.repo_id))
221 .sort((a, b) => b.pushes.count - a.pushes.count || a.repo_id.localeCompare(b.repo_id))
222 .slice(0, MAX_WORKFLOW_PROJECTS);
223 const lists = await Promise.all(
224 active.map((digest) => {
225 const repo = byId.get(digest.repo_id)!;
226 const path = { namespace: repo.namespace, name: repo.name };
227 return actions
228 .runs(path, viewer, { limit: WORKFLOW_RUNS })
229 .then((result) => (result.ok ? { repo: path, runs: result.value } : null))
230 .catch(warn(`workflow runs of ${repo.name}`));
231 }),
232 );
233 const projects = lists.filter((entry) => entry != null);
234 return { projects, complete: projects.length === active.length };
235}
236
237/** The workspace's newest sessions; `complete` when the list reaches back before `from`. */
238export async function loadSessions(viewer: User, slug: string, from: number): Promise<SessionWork | null> {
239 const listed = await workspaceAgents.sessions(slug, viewer, { limit: SESSION_PAGE }).catch(warn("sessions"));
240 if (!listed?.ok) return null;
241 const sessions: AgentSession[] = listed.value;
242 const oldest = sessions.at(-1);
243 const complete = sessions.length < SESSION_PAGE || (oldest != null && Date.parse(oldest.created_at) < from);
244 return { sessions, complete };
245}
246
247/** The agents overview: sessions waiting on the viewer, live ones, and the workspace's budget alert. */
248export async function loadAgentsOverview(viewer: User, slug: string): Promise<AgentsOverview | null> {
249 const overview = await workspaceAgents.overview(slug, viewer).catch(warn("agents overview"));
250 return overview?.ok ? overview.value : null;
251}
252
253/** Install requests as the viewer sees them; only an owner's (`can_resolve`) are anyone else's. */
254export async function loadInstallRequests(viewer: User, slug: string): Promise<InstallRequests | null> {
255 const requests = await workspaceAgents.installRequests(slug, viewer).catch(warn("install requests"));
256 return requests?.ok ? requests.value : null;
257}
258
259/** The workspace's memory, for the decisions recorded in it. */
260export async function loadMemories(viewer: User, slug: string): Promise<Memory[] | null> {
261 const memories = await agents.listMemories(viewer, slug, null).catch(warn("memories"));
262 return memories?.ok ? memories.value.workspace : null;
263}
264
265/**
266 * Every project's builds at a glance, and the builds of the projects that
267 * deploy, for the span: the projects whose newest build is older than the
268 * span are not read again.
269 */
270export async function loadDeploys(
271 viewer: User,
272 slug: string,
273 from: number,
274): Promise<{ overview: ProjectDeploys[]; projects: { slug: string; deployments: Deployment[] }[]; complete: boolean } | null> {
275 const overview = await deployments.overview(slug, viewer).catch(warn("deploys overview"));
276 if (!overview?.ok) return null;
277 const recent = overview.value.filter((project) => project.latest && Date.parse(project.latest.finishedAt ?? project.latest.createdAt) >= from);
278 const read = recent.slice(0, MAX_DEPLOY_PROJECTS);
279 const lists = await Promise.all(
280 read.map((project) =>
281 deployments
282 .list({ workspace: slug, slug: project.slug }, viewer)
283 .then((result) => (result.ok ? { slug: project.slug, deployments: result.value.deployments } : null))
284 .catch(warn(`deploys of ${project.slug}`)),
285 ),
286 );
287 const projects = lists.filter((entry) => entry != null);
288 return { overview: overview.value, projects, complete: projects.length === recent.length };
289}
290
291/**
292 * The span's spend from the statements of the months it touches, and the
293 * month so far from the usage report the top bar, Spend and Usage read
294 * (`spend.ts` `monthSpan`, `spentMicros`), so Home's figure is theirs. Null
295 * when billing did not answer; the month alone null when only it did not.
296 */
297export async function loadSpend(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<Spend | null> {
298 const months = spanMonths(span);
299 const [statements, month] = await Promise.all([
300 Promise.all(months.map((month) => billing.statement(slug, viewer, month, "day").catch(warn(`statement ${month}`)))),
301 billing
302 .usageReport(slug, viewer, monthSpan(new Date(span.now)))
303 .then((result) => (result.ok ? spentMicros(result.value) : null))
304 .catch(warn("month usage")),
305 ]);
306 if (statements.some((statement) => !statement?.ok)) return null;
307 return spendIn(
308 statements.map((statement) => (statement as Extract<typeof statement, { ok: true }>).value),
309 span,
310 month,
311 );
312}
313
314/** How far back Code is read: 7 days before the span, for the comparison. */
315export function reachFor(span: Pick<Span, "from">): number {
316 return span.from - 7 * DAY;
317}