Skip to content
183 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 { type AgentSession, type AgentsOverview, type Deployment, type InstallRequests, type Memory, type ProjectDeploys, type Repo, type User, notifyClient } from "@g1t/contracts";
9
10import { type CodePull, type CodeWork, type SessionWork, type Span, type Spend, pullKey, spanMonths, spendIn } from "./home";
11import { monthSpan, spentMicros } from "./spend";
12import { agents, billing, deployments, repos, work, workspaceAgents } from "./services.server";
13
14/** Pull requests read per project and list: `LIST_PAGE` in services/work. */
15const PULL_PAGE = 100;
16/** The most projects whose pull requests are read. */
17const MAX_PROJECTS = 50;
18/** The most projects whose builds are read for the span. */
19const MAX_DEPLOY_PROJECTS = 20;
20/** Revise runs and sessions read: the services' most in one list. */
21const RUN_PAGE = 200;
22const SESSION_PAGE = 200;
23const DAY = 24 * 60 * 60 * 1000;
24
25const warn = (what: string) => (error: unknown) => {
26 console.warn(`home: ${what} failed`, error);
27 return null;
28};
29
30/**
31 * When the viewer was last on this workspace's Home, from notify, where it
32 * is kept with them (the same on every device). Null when there is no
33 * visit on record; undefined when notify did not answer.
34 */
35export async function loadLastVisit(viewer: User, slug: string): Promise<number | null | undefined> {
36 if (!env.NOTIFY) return undefined;
37 const visit = await notifyClient(env.NOTIFY).lastVisit(viewer, slug).catch(warn("last visit"));
38 if (!visit) return undefined;
39 return visit.seen_at ? Date.parse(visit.seen_at) : null;
40}
41
42/** Marks the viewer's visit at `at`, the time the page they looked at loaded. */
43export async function markVisit(viewer: User, slug: string, at: number): Promise<boolean> {
44 if (!env.NOTIFY) return false;
45 const marked = await notifyClient(env.NOTIFY).markVisit(viewer, slug, new Date(at).toISOString()).catch(warn("mark visit"));
46 return marked != null;
47}
48
49/**
50 * The workspace's projects' pull requests, open and recently merged or
51 * closed, any author's, with how many times each agent's was sent back to
52 * revise. `complete` when every list reaches back to `reach`. Null when
53 * Code did not answer.
54 */
55export async function loadCodeWork(viewer: User, slug: string, reach: number): Promise<CodeWork | null> {
56 const [list, runs] = await Promise.all([
57 repos.list(viewer, { namespace: slug }).catch(warn("repos")),
58 agents.listRuns(viewer, { workspace: slug, kind: "revise", limit: RUN_PAGE }).catch(warn("revise runs")),
59 ]);
60 if (!list || !runs?.ok) return null;
61 const owned = list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug);
62 const chosen = owned.slice(0, MAX_PROJECTS);
63 const byId = new Map<string, Repo>(chosen.map((repo) => [repo.id, repo]));
64 const batch = chosen.length > 0 ? await work.pullsForRepos([...byId.keys()], viewer, PULL_PAGE).catch(warn("pull requests")) : [];
65 if (!batch) return null;
66 const pulls: CodePull[] = [];
67 let complete = owned.length <= MAX_PROJECTS;
68 for (const entry of batch) {
69 const repo = byId.get(entry.repoId);
70 if (!repo) continue;
71 const path = { namespace: repo.namespace, name: repo.name };
72 for (const pull of [...entry.open, ...entry.closed]) {
73 pulls.push({
74 repo: path,
75 number: pull.number,
76 title: pull.title,
77 status: pull.status,
78 mergedAt: pull.mergedAt,
79 mergedBy: pull.mergedBy,
80 createdAt: pull.createdAt,
81 updatedAt: pull.updatedAt,
82 author: { username: pull.author.username, kind: pull.author.kind },
83 });
84 }
85 // A full page that does not reach back far enough may leave some out.
86 for (const list of [entry.open, entry.closed]) {
87 if (list.length >= PULL_PAGE) {
88 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)));
89 if (oldest > reach) complete = false;
90 }
91 }
92 }
93 const revisions: Record<string, number> = {};
94 for (const run of runs.value) {
95 if (run.number == null) continue;
96 const key = pullKey(run.repo, run.number);
97 revisions[key] = (revisions[key] ?? 0) + 1;
98 }
99 if (runs.value.length >= RUN_PAGE && Date.parse(runs.value[runs.value.length - 1].createdAt) > reach) complete = false;
100 return { pulls, revisions, complete };
101}
102
103/** The workspace's newest sessions; `complete` when the list reaches back before `from`. */
104export async function loadSessions(viewer: User, slug: string, from: number): Promise<SessionWork | null> {
105 const listed = await workspaceAgents.sessions(slug, viewer, { limit: SESSION_PAGE }).catch(warn("sessions"));
106 if (!listed?.ok) return null;
107 const sessions: AgentSession[] = listed.value;
108 const oldest = sessions.at(-1);
109 const complete = sessions.length < SESSION_PAGE || (oldest != null && Date.parse(oldest.created_at) < from);
110 return { sessions, complete };
111}
112
113/** The agents overview: sessions waiting on the viewer, live ones, and the workspace's budget alert. */
114export async function loadAgentsOverview(viewer: User, slug: string): Promise<AgentsOverview | null> {
115 const overview = await workspaceAgents.overview(slug, viewer).catch(warn("agents overview"));
116 return overview?.ok ? overview.value : null;
117}
118
119/** Install requests as the viewer sees them; only an owner's (`can_resolve`) are anyone else's. */
120export async function loadInstallRequests(viewer: User, slug: string): Promise<InstallRequests | null> {
121 const requests = await workspaceAgents.installRequests(slug, viewer).catch(warn("install requests"));
122 return requests?.ok ? requests.value : null;
123}
124
125/** The workspace's memory, for the decisions recorded in it. */
126export async function loadMemories(viewer: User, slug: string): Promise<Memory[] | null> {
127 const memories = await agents.listMemories(viewer, slug, null).catch(warn("memories"));
128 return memories?.ok ? memories.value.workspace : null;
129}
130
131/**
132 * Every project's builds at a glance, and the builds of the projects that
133 * deploy, for the span: the projects whose newest build is older than the
134 * span are not read again.
135 */
136export async function loadDeploys(
137 viewer: User,
138 slug: string,
139 from: number,
140): Promise<{ overview: ProjectDeploys[]; projects: { slug: string; deployments: Deployment[] }[]; complete: boolean } | null> {
141 const overview = await deployments.overview(slug, viewer).catch(warn("deploys overview"));
142 if (!overview?.ok) return null;
143 const recent = overview.value.filter((project) => project.latest && Date.parse(project.latest.finishedAt ?? project.latest.createdAt) >= from);
144 const read = recent.slice(0, MAX_DEPLOY_PROJECTS);
145 const lists = await Promise.all(
146 read.map((project) =>
147 deployments
148 .list({ workspace: slug, slug: project.slug }, viewer)
149 .then((result) => (result.ok ? { slug: project.slug, deployments: result.value.deployments } : null))
150 .catch(warn(`deploys of ${project.slug}`)),
151 ),
152 );
153 const projects = lists.filter((entry) => entry != null);
154 return { overview: overview.value, projects, complete: projects.length === recent.length };
155}
156
157/**
158 * The span's spend from the statements of the months it touches, and the
159 * month so far from the usage report the top bar, Spend and Usage read
160 * (`spend.ts` `monthSpan`, `spentMicros`), so Home's figure is theirs. Null
161 * when billing did not answer; the month alone null when only it did not.
162 */
163export async function loadSpend(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<Spend | null> {
164 const months = spanMonths(span);
165 const [statements, month] = await Promise.all([
166 Promise.all(months.map((month) => billing.statement(slug, viewer, month, "day").catch(warn(`statement ${month}`)))),
167 billing
168 .usageReport(slug, viewer, monthSpan(new Date(span.now)))
169 .then((result) => (result.ok ? spentMicros(result.value) : null))
170 .catch(warn("month usage")),
171 ]);
172 if (statements.some((statement) => !statement?.ok)) return null;
173 return spendIn(
174 statements.map((statement) => (statement as Extract<typeof statement, { ok: true }>).value),
175 span,
176 month,
177 );
178}
179
180/** How far back Code is read: 7 days before the span, for the comparison. */
181export function reachFor(span: Pick<Span, "from">): number {
182 return span.from - 7 * DAY;
183}