Skip to content
657 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1/**
2 * Mission control's data: what needs the viewer, what waits on agents, what
3 * landed, and the week, across a workspace's projects. Code's Overview
4 * (routes/workspace/code-overview.tsx) shows all of it; Home
5 * (routes/workspace/home.tsx) takes what needs you from it.
6 */
7import { env } from "cloudflare:workers";
8
9import {
10 type Confidence,
11 type G1tEvent,
12 type Lifecycle,
13 type Pull,
14 type Repo,
15 type RepoPath,
16 type User,
17 type Viewer,
18 REPO_ROLE_LABELS,
19 isActiveRun,
20 workOwner,
21} from "@g1t/contracts";
22
23import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice";
24import {
25 type ActivityItem,
26 type Need,
27 TIME,
28 actorIds,
29 agentHours,
30 dailyBuckets,
31 eventItem,
32 FEED_EVENT_TYPES,
33 greetingFor,
34 groupActivity,
35 hourIn,
36 isAgent,
37 nameActor,
38 rankNeeds,
39 readCookie,
40 stuckMinutes,
41 waitedFor,
42} from "./mission";
43import { G1T_COMMIT_EMAILS, normalizeEmail } from "./commit-people";
44import {
45 type Fact,
46 type Merged,
47 placePushes,
48 type NeedRow,
49 type QuickAction,
50 RUN_LABEL,
51 confidenceAsk,
52 dateLine,
53 dayKey,
54 landedToday,
55 needPathKey,
56 pullFacts,
57 reachesBack,
58 reasonFor,
59 summaryLine,
60 waitingRows,
61 weekOf,
62 historyCovers,
63 who,
64 whyFor,
65 withConfidence,
66} from "./mission-control";
67import { needsYou } from "./inbox";
68import {
69 agents,
70 billing,
71 deployments,
72 events as eventLog,
73 identity,
74 inbox,
75 projects as projectsApi,
76 repos as reposApi,
77 work,
78} from "./services.server";
79import { roleIn } from "./session.server";
80import { runners } from "./runners.server";
81import { readableRepos } from "./access.server";
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.82import { microsOf, money } from "./money";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)83
84/** The viewer's time zone, which mission control sets, so the greeting fits their day. */
85const TZ_COOKIE = "g1t_tz";
86/** The most projects whose pull requests and events are read for the page. */
87const MAX_PROJECTS = 10;
88const EVENTS_PER_PROJECT = 80;
89/** How many pull requests work lists at once (`LIST_PAGE` in services/work). */
90const PULL_PAGE = 100;
91/** Unread inbox items read for the Needs you card, and how many it shows. */
92const INBOX_READ = 50;
93const INBOX_SHOWN = 4;
94
95/** Where a need came from, so its row can say what is known about it. */
96type Extra = Partial<Pick<NeedRow, "repo" | "ref" | "by" | "for" | "facts" | "quick" | "link" | "open">> & {
97 /** How sure g1t is of the agent's change, for a pull request. */
98 confidence?: Confidence | null;
99};
100
101/** What the composer said back, when the issue opened but the agent did not start, or nothing opened. */
102/**
103 * Push events read per project, a page at a time, back two weeks. Pushes
104 * to every branch are in the log, so a fixed count (it was 40) lost a busy
105 * week's earlier days to agents' branches.
106 */
107const PUSH_PAGE = 200;
108const PUSH_PAGES = 5;
109/** The first page is smaller: most projects push far less in two weeks. */
110const FIRST_PUSH_PAGE = 50;
111/**
112 * Commits of the default branch read to place each push's commits: a
113 * short read first, which covers most projects' fortnight, and the longer
114 * one only when it does not reach the oldest push.
115 */
116const HISTORY_FIRST = 200;
117const HISTORY_READ = 1000;
118
119/**
120 * The commits people pushed straight to a project's default branch in the
121 * last two weeks, each with when its push landed. Merges and agents'
122 * commits are left out: pull requests count those.
123 */
124async function directCommits(repo: Repo, viewer: Viewer): Promise<{ hash: string; at: string }[]> {
125 const since = Date.now() - 14 * TIME.DAY;
126 const pushes: G1tEvent<"git.push">[] = [];
127 let before: string | undefined;
128 for (let page = 0; page < PUSH_PAGES; page++) {
129 const limit = page === 0 ? FIRST_PUSH_PAGE : PUSH_PAGE;
130 const batch = await eventLog.list({ repoId: repo.id, types: ["git.push"], limit, ...(before ? { before } : {}) });
131 for (const event of batch) {
132 if (event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since) pushes.push(event as G1tEvent<"git.push">);
133 }
134 const oldest = batch.at(-1);
135 if (batch.length < limit || !oldest || Date.parse(oldest.time) < since) break;
136 before = oldest.id;
137 }
138 if (pushes.length === 0) return [];
139 // A read of the branch from the newest push back; each push brought
140 // what lies between its `after` and its `before` in that history.
141 const path = { namespace: repo.namespace, name: repo.name };
142 const read = (limit: number) => reposApi.log(path, viewer, pushes[0].data.after, limit).catch(() => null);
143 let history = await read(HISTORY_FIRST);
144 if (history?.ok && !historyCovers(history.value, pushes, HISTORY_FIRST)) history = await read(HISTORY_READ);
145 if (!history?.ok) return [];
146 // g1t's own commits are told by their author address, never by name.
147 return placePushes(history.value, pushes, (commit) => G1T_COMMIT_EMAILS.has(normalizeEmail(commit.author.email)));
148}
149
150
151export async function loadMissionControl(viewer: User, request: Request, workspace: string | null) {
152
153 const now = Date.now();
154 const weekAgo = now - 7 * TIME.DAY;
155 const started = Date.now();
156 const times: Record<string, number> = {};
157 // Each call is timed, and one that fails leaves its section empty rather
158 // than taking the page down.
159 const soft = <T,>(name: string, promise: Promise<T>): Promise<T | null> =>
160 promise.then(
161 (value) => ((times[name] = Date.now() - started), value),
162 (error) => {
163 console.warn(`home: ${name} failed`, error);
164 times[name] = Date.now() - started;
165 return null;
166 },
167 );
168 const okOr = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
169
170 const cookies = request.headers.get("cookie");
171 const tz = readCookie(cookies, TZ_COOKIE);
172 const memberships = viewer.workspaces ?? [];
173 // The workspace asked for, else the one you chose, as the sidebar shows it (lib/workspace-choice.ts).
174 const slug =
175 (workspace && roleIn(viewer, workspace) ? workspace.toLowerCase() : null) ??
176 chosenWorkspace(memberships, readCookie(cookies, WORKSPACE_COOKIE))?.slug ??
177 null;
178 const username = viewer.username;
179
180 const reposP = soft("repos", reposApi.list(viewer, { memberOnly: true }));
181 // Workflow jobs stuck waiting for a self-hosted runner that is not there.
182 const stuckJobsP = soft("stuck_jobs", runners.stuck(viewer));
183 // The chosen workspace's projects: their open and recently merged pull
184 // requests, all in one call to work, and their recent events, read as
185 // soon as the projects are known.
186 const perRepoP = reposP.then(async (repos) => {
187 const chosen = (repos ?? [])
188 .filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase())
189 .slice(0, MAX_PROJECTS);
190 const [batch, logs, pushes] = await Promise.all([
191 work.pullsForRepos(chosen.map((repo) => repo.id), viewer, PULL_PAGE).catch(() => []),
192 Promise.all(chosen.map((repo) => eventLog.list({ repoId: repo.id, types: [...FEED_EVENT_TYPES], limit: EVENTS_PER_PROJECT }).catch(() => null))),
193 // People's pushes straight to the default branch, which no pull
194 // request counts: the commits each brought, for the week.
195 Promise.all(chosen.map((repo) => directCommits(repo, viewer).catch(() => []))),
196 ]);
197 const byId = new Map(batch.map((entry) => [entry.repoId, entry]));
198 return Promise.all(
199 chosen.map(async (repo, index) => {
200 const found = byId.get(repo.id);
201 if (found) return { repo, pulls: found.open.slice(0, 60), closed: found.closed, events: logs[index], direct: pushes[index] ?? [] };
202 // A fork, which the batch leaves out: asked on its own.
203 const path = { namespace: repo.namespace, name: repo.name };
204 const [pulls, closed] = await Promise.all([
205 work.listPulls(path, viewer, "open").catch(() => null),
206 work.listPulls(path, viewer, "closed").catch(() => null),
207 ]);
208 return {
209 repo,
210 pulls: pulls?.ok ? pulls.value.slice(0, 60) : null,
211 closed: closed?.ok ? closed.value : null,
212 events: logs[index],
213 direct: pushes[index] ?? [],
214 };
215 }),
216 );
217 });
218
219 const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations, tokens, myTokens, unread] = await Promise.all([
220 reposP,
221 soft("projects", perRepoP),
222 soft("pulls", work.listActivePulls(viewer)),
223 slug ? soft("models", env.RUNNER.modelAccess(slug)) : null,
224 soft("profile", identity.profile(username)),
225 slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null,
226 slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
227 slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null,
228 slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
229 slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null,
230 // Repositories someone has invited the viewer to.
231 soft("invitations", identity.myRepoInvitations(viewer)),
232 // Model tokens over the last six weeks: the workspace's, and yours.
233 slug ? soft("tokens", billing.tokenUsage(slug, viewer)) : null,
234 slug ? soft("my_tokens", billing.tokenUsage(slug, viewer, { person: username })) : null,
235 // What in their inbox is unread: an agent waiting on them, or a failure, goes on the Needs you card.
236 soft("inbox", inbox.list(viewer, { unread: true, limit: INBOX_READ })),
237 ]);
238
239 const repoList = repos ?? [];
240 // Each pull request is shown under its repository. Most are in the
241 // viewer's own, already listed; the rest are looked up once each.
242 const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo]));
243 const missing = [...new Set((active ?? []).map(({ pull }) => pull.repoId))].filter((id) => !known.has(id));
244 for (const repo of await readableRepos(missing, viewer)) known.set(repo.id, repo);
245 const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name });
246 const inWorkspace = (repo: RepoPath) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase();
247
248 const activeList = (active ?? []).flatMap((item) => {
249 const repo = known.get(item.pull.repoId);
250 return repo ? [{ ...item, repo }] : [];
251 });
252 const runList = okOr(runs ?? null) ?? [];
253 const liveRuns = runList.filter((run) => isActiveRun(run.status));
254 const overviewList = okOr(overview ?? null);
255 const projectsOk = okOr(projectList ?? null);
256 const runsOn = (repo: RepoPath, number: number) =>
257 runList.filter(
258 (run) => run.number === number && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === `${repo.namespace}/${repo.name}`.toLowerCase(),
259 );
260
261 const lower = username.toLowerCase();
262 const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo })));
263 // Never your own, nor one g1t made for you.
264 const reviewRequested = openPulls.filter(
265 ({ pull }) =>
266 pull.status === "open" &&
267 workOwner(pull).username.toLowerCase() !== lower &&
268 pull.reviewers.some((name) => name.toLowerCase() === lower),
269 );
270
271 // --- Needs you ------------------------------------------------------------
272 const needs: Need[] = [];
273 const extras = new Map<string, Extra>();
274 /** What every pull request's row shares: where it is, who is on it, what is known. */
275 const pullExtra = (pull: Pull, repo: Repo, lifecycle: Lifecycle | null): Extra => {
276 const base = `/${repo.namespace}/${repo.name}`;
277 const agentWork = isAgent(pull.agent);
278 return {
279 repo: pathOf(repo),
280 ref: `#${pull.number}`,
281 by: agentWork ? who(pull.agent) : who(pull.author.username),
282 for: agentWork ? workOwner(pull).username : null,
283 facts: pullFacts({
284 checkStatus: pull.checkStatus,
285 files: pull.files,
286 lifecycle,
287 runs: runsOn(repo, pull.number),
288 confidence: pull.confidence,
289 }),
290 open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}?tab=changes`,
291 confidence: pull.confidence ?? null,
292 };
293 };
294 const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({
295 ...quick,
296 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
297 });
298 const approve = (repo: Repo, pull: Pull) =>
299 pullAction(repo, pull, {
300 label: isAgent(pull.agent) ? "Approve the agent's change" : "Approve the change",
301 fields: { action: "comment", verdict: "approve", body: "" },
302 done: "Approved",
303 });
304
305 for (const invitation of invitations ?? []) {
306 if (invitation.status !== "pending") continue;
307 const key = `invitation:${invitation.id}`;
308 needs.push({
309 key,
310 kind: "invitation",
311 title: `${invitation.invited_by ?? "Someone"} invited you to ${invitation.repo}`,
312 detail: `With the ${REPO_ROLE_LABELS[invitation.role]} role. The invitation expires ${new Date(invitation.expires_at).toISOString().slice(0, 10)}.`,
313 to: `/${invitation.repo}/invitations`,
314 action: "Respond",
315 at: Date.parse(invitation.created_at),
316 where: invitation.repo,
317 });
318 const [namespace, name] = invitation.repo.split("/");
319 extras.set(key, {
320 repo: namespace && name ? { namespace, name } : null,
321 by: who(invitation.invited_by),
322 facts: [
323 { label: "Role", value: REPO_ROLE_LABELS[invitation.role], tone: null },
324 { label: "Expires", value: new Date(invitation.expires_at).toISOString().slice(0, 10), tone: null },
325 ],
326 });
327 }
328 for (const entry of overviewList ?? []) {
329 const latest = entry.latest;
330 if (latest?.kind === "production" && latest.status === "failed" && slug) {
331 const key = `deploy:${entry.slug}`;
332 const to = `/${slug}/${entry.slug}/deployments/${latest.id}`;
333 needs.push({
334 key,
335 kind: "deploy",
336 title: `Production build of ${entry.slug} failed`,
337 detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.",
338 to,
339 action: "See the build",
340 at: Date.parse(latest.finishedAt ?? latest.createdAt),
341 where: `${slug}/${entry.slug}`,
342 });
343 const facts: Fact[] = [
344 { label: "Commit", value: latest.commit.slice(0, 7), tone: null },
345 {
346 label: "Production",
347 value: entry.production ? "Serving the build before" : "Not live yet",
348 tone: entry.production ? "good" : "warn",
349 },
350 ];
351 extras.set(key, {
352 repo: { namespace: slug, name: entry.slug },
353 by: who(latest.createdBy),
354 facts,
355 link: { label: "Deployment settings", to: `/${slug}/${entry.slug}/settings/deployments` },
356 });
357 }
358 }
359 for (const { pull, lifecycle, repo } of activeList) {
360 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
361 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
362 const key = `pull:${pull.id}`;
363 const extra = pullExtra(pull, repo, lifecycle);
364 // Held for its low confidence: the ask says so in a sentence of its own.
365 const lowConfidence = pull.confidence?.level === "low" ? pull.confidence : null;
366 if (lifecycle?.stage === "needs_you") {
367 const held = lowConfidence != null && /confidence in this change is low/i.test(lifecycle.detail);
368 const conflict = !held && /conflict/i.test(lifecycle.detail);
369 const need: Need = {
370 key,
371 kind: conflict ? "conflict" : "stalled",
372 title: pull.title,
373 detail: held && lowConfidence ? confidenceAsk(lowConfidence) : lifecycle.detail,
374 to,
375 action: conflict ? "Resolve" : "Decide",
376 at: Date.parse(pull.updatedAt),
377 where,
378 };
379 needs.push(need);
380 const reason = held ? "low_confidence" : reasonFor(need);
381 extras.set(key, {
382 ...extra,
383 quick:
384 reason === "needs_review" || reason === "low_confidence"
385 ? approve(repo, pull)
386 : /required checks? .*still fails?\b/i.test(lifecycle.detail)
387 ? pullAction(repo, pull, { label: "Re-run failed jobs", fields: { action: "rerun-failed" }, done: "Re-running" })
388 : null,
389 link: reason === "outside_guardrails" ? { label: "Raise the cap", to: `/${repo.namespace}/${repo.name}/settings/guardrails` } : null,
390 });
391 } else if (lifecycle?.stage === "ready") {
392 needs.push({
393 key,
394 kind: "ready",
395 title: pull.title,
Merge branch 'site-fixes' into fast-push396 // The ask first: a list cuts the sentence short, and "it lands" alone reads as stuck.
397 detail: lowConfidence ? confidenceAsk(lowConfidence) : "Waiting for you to merge it. It was approved and its required checks passed.",
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)398 to,
399 action: "Merge",
400 at: Date.parse(pull.updatedAt),
401 where,
402 });
403 extras.set(key, {
404 ...extra,
405 quick: pullAction(repo, pull, {
406 label: isAgent(pull.agent) ? "Merge the agent's change" : "Merge it",
407 fields: { action: "merge" },
408 done: "Merging",
409 }),
Merge branch 'site-fixes' into fast-push410 // This repository leaves landing an agent's change to a person; say where that is changed.
411 link: isAgent(pull.agent) ? { label: "Merge automatically when ready", to: `/${repo.namespace}/${repo.name}/settings/branches` } : null,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)412 });
413 } else if (!lifecycle && pull.status === "open" && pull.checkStatus === "failed") {
414 // Taken out of the merge queue: its change failed combined with what was ahead.
415 needs.push({ key, kind: "checks", title: pull.title, detail: "It failed in the merge queue. Push a fix, then merge it again.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
416 extras.set(key, { ...extra, quick: null });
417 }
418 }
419 for (const { pull, repo } of reviewRequested) {
420 const key = `review:${pull.id}`;
421 needs.push({
422 key,
423 kind: "review",
424 title: pull.title,
425 detail: `${workOwner(pull).username} asked for your review.`,
426 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
427 action: "Review",
428 at: Date.parse(pull.updatedAt),
429 where: `${repo.namespace}/${repo.name}#${pull.number}`,
430 });
431 extras.set(key, { ...pullExtra(pull, repo, null), quick: approve(repo, pull) });
432 }
433 for (const job of (await stuckJobsP) ?? []) {
434 const key = `runner:${job.id}`;
435 const minutes = Math.max(10, Math.round((now - Date.parse(job.queuedAt)) / 60_000));
436 const [namespace, name] = job.repo.split("/");
437 needs.push({
438 key,
439 kind: "runner",
440 title: `${job.name} is waiting for a self-hosted runner`,
441 detail: `No runner with labels ${job.labels} is online. Start one, or change the job's runs-on.`,
442 to: `/${job.repo}/actions/runs/${job.runId}`,
443 action: "Look",
444 at: Date.parse(job.queuedAt),
445 where: job.repo,
446 });
447 extras.set(key, {
448 repo: namespace && name ? { namespace, name } : undefined,
449 facts: [
450 { label: "Labels", value: job.labels, tone: null },
451 { label: "Waiting", value: waitedFor(minutes), tone: "warn" },
452 ],
453 // Runners are the owners' to see to.
454 ...(namespace && roleIn(viewer, namespace) === "owner" ? { link: { label: "Runners", to: `/${namespace}/-/runners` } } : {}),
455 });
456 }
457 for (const run of liveRuns) {
458 const minutes = stuckMinutes(run, now);
459 if (minutes == null) continue;
460 const key = `run:${run.id}`;
461 needs.push({
462 key,
463 kind: "stuck",
464 title: run.title ?? `${run.agent}'s run`,
465 detail: `${run.agent} has reported nothing for ${waitedFor(minutes)}${run.step ? `. Last: ${run.step}` : ""}.`,
466 to: `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`,
467 action: "Look",
468 at: Date.parse(run.updatedAt),
469 where: `${run.repo.namespace}/${run.repo.name}${run.number != null ? `#${run.number}` : ""}`,
470 });
471 extras.set(key, {
472 repo: run.repo,
473 ref: run.number != null ? `#${run.number}` : null,
474 by: who(run.agent),
475 facts: [
476 { label: "Run", value: RUN_LABEL[run.kind], tone: null },
477 { label: "Quiet for", value: waitedFor(minutes), tone: "warn" },
478 { label: "Steps so far", value: String(run.stepCount), tone: null },
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.479 ...(run.costUsd != null ? [{ label: "Cost so far", value: money(microsOf(run.costUsd)), tone: null }] : []),
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)480 ],
481 open: run.number != null ? `/${run.repo.namespace}/${run.repo.name}/pull/${run.number}` : undefined,
482 });
483 }
484
485 const needRows: NeedRow[] = rankNeeds(needs).map((need) => {
486 const extra = extras.get(need.key) ?? {};
487 const reason = withConfidence(reasonFor(need), extra.confidence);
488 return {
489 key: need.key,
490 reason,
491 repo: extra.repo ?? null,
492 ref: extra.ref ?? null,
493 title: need.title,
494 ask: need.detail,
495 by: extra.by ?? null,
496 for: extra.for ?? null,
497 at: need.at,
498 to: need.to,
499 open: extra.open ?? need.to,
500 facts: extra.facts ?? [],
501 why: whyFor(reason, need, extra.confidence),
502 quick: extra.quick ?? null,
503 link: extra.link ?? null,
504 };
505 });
506
507 // --- Waiting on agents ----------------------------------------------------
508 const needKeys = new Set(
509 needRows.flatMap((row) => (row.repo && row.ref ? [needPathKey(row.repo, Number(row.ref.slice(1)))] : [])),
510 );
511 const waiting = waitingRows({
512 active: activeList.filter(({ repo }) => inWorkspace(repo)).map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo: pathOf(repo) })),
513 live: liveRuns,
514 drafts: openPulls.filter(({ pull }) => pull.status === "draft").map(({ pull, repo }) => ({ ...pull, repo: pathOf(repo) })),
515 needKeys,
516 });
517
518 // --- Landed ---------------------------------------------------------------
519 const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) =>
520 (closed ?? []).flatMap((pull) =>
521 pull.status === "merged" && pull.mergedAt
522 ? [
523 {
524 repo: pathOf(repo),
525 number: pull.number,
526 title: pull.title,
527 agent: pull.agent,
528 // Who wrote it, not who merged it: a person's change that g1t
529 // auto-merged is still theirs.
530 authoredByAgent: pull.author.kind === "agent" || isAgent(pull.author.username),
531 mergedBy: pull.mergedBy,
532 mergedAt: pull.mergedAt,
533 files: pull.files,
534 },
535 ]
536 : [],
537 ),
538 );
539 const twoWeeksAgo = now - 14 * TIME.DAY;
540 const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE));
541 // A person's commits pushed straight to the default branch are their own
542 // changes too, on the day they landed.
543 const direct = (perRepo ?? []).flatMap(({ direct }) =>
544 direct.map((commit) => ({ mergedAt: commit.at, mergedBy: null, authoredByAgent: false })),
545 );
546 const week = weekOf([...merged, ...direct], now, tz, complete);
547 const landed = landedToday(merged, now, tz);
548
549 // --- Activity -------------------------------------------------------------
550 const items: ActivityItem[] = [];
551 const titles: Record<string, string> = {};
552 const titleKey = (repo: RepoPath, number: number) => `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
553 for (const { repo, pulls, closed, events } of perRepo ?? []) {
554 for (const pull of [...(pulls ?? []), ...(closed ?? [])]) titles[titleKey(repo, pull.number)] = pull.title;
555 for (const event of events ?? []) {
556 if (event.type === "issue.opened") titles[titleKey(repo, event.data.number)] ??= event.data.title;
557 const item = eventItem(event, pathOf(repo));
558 if (item) items.push(item);
559 }
560 }
561 // Production deploys come from the log too (deployment_status.created),
562 // wherever they ran: g1t.page, g1t Actions or the API.
563 for (const memory of okOr(memories ?? null)?.workspace ?? []) {
564 const repo = memory.repo ?? memory.source.repo;
565 if (!repo || !slug) continue;
566 items.push({
567 id: `memory:${memory.id}`,
568 at: Date.parse(memory.createdAt),
569 repo,
570 actor: memory.createdBy,
571 verb: "learned",
572 number: null,
573 text: memory.text.length > 90 ? `${memory.text.slice(0, 88)}…` : memory.text,
574 to: `/${slug}/-/memory`,
575 });
576 }
577 // The log names people by account id: their usernames, in one lookup.
578 const ids = actorIds(items.map((item) => item.actor));
579 const names = ids.length > 0 ? await soft("usernames", identity.usernames(ids)) : {};
580 const groups = groupActivity(items.map((item) => ({ ...item, actor: nameActor(item.actor, names) }))).slice(0, 40);
581 // Only the titles the feed names travel to the page.
582 const shownTitles: Record<string, string> = {};
583 for (const group of groups) {
584 for (const part of group.parts) {
585 for (const number of part.numbers) {
586 const key = titleKey(group.repo, number);
587 if (titles[key]) shownTitles[key] = titles[key];
588 }
589 }
590 }
591
592 // --- The strip ------------------------------------------------------------
593 const usageOk = okOr(usage ?? null);
594 const costPoints = usageOk
595 ? usageOk.byDay.map((slice) => ({ at: Date.parse(`${slice.key.split("/")[0]}T12:00:00Z`), value: slice.micros / 1_000_000 }))
596 : runList.filter((run) => run.costUsd != null).map((run) => ({ at: Date.parse(run.createdAt), value: run.costUsd ?? 0 }));
597 const weekCost = dailyBuckets(costPoints, 7, now).reduce((sum, v) => sum + v, 0);
598 const month = dayKey(now, tz).slice(0, 7);
599 const projectCount = projectsOk?.length ?? (perRepo != null ? perRepo.length : null);
600 const projectsThisMonth = projectsOk ? projectsOk.filter((project) => dayKey(Date.parse(project.createdAt), tz).slice(0, 7) === month).length : null;
601
602 const models_ = models ?? null;
603 times.total = Date.now() - started;
604 const serverTiming = Object.entries(times)
605 .map(([name, ms]) => `${name};dur=${ms}`)
606 .join(", ");
607 return {
608 timing: serverTiming,
609 value: {
610 signedIn: true as const,
611 viewer,
612 name: profile?.name?.trim() || username,
613 greeting: greetingFor(hourIn(now, tz)),
614 date: dateLine(now, tz),
615 summary: summaryLine({
616 total: week.total,
617 byAgents: week.byAgents,
618 agentChanges: week.agentChanges,
619 people: week.people,
620 live: liveRuns.length,
621 needs: needRows.length,
622 }),
623 workspace: slug,
624 repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf),
625 canRunAgents: models_ == null || models_.hosted || models_.own != null,
626 trial: models_?.own == null ? (models_?.trial ?? null) : null,
627 handedOff: activeList.length > 0 || runList.length > 0 || merged.length > 0,
628 needs: needRows,
629 waiting,
630 landed,
631 liveTotal: liveRuns.length,
632 runsLoaded: runs != null && runs.ok,
633 perRepoLoaded: perRepo != null,
634 stats: {
635 projects: projectCount,
636 projectsThisMonth,
637 agentHours: agentHours(runList, weekAgo, now),
638 weekCost,
639 },
640 week,
641 tokens: { workspace: okOr(tokens), mine: okOr(myTokens) },
642 groups,
643 titles: shownTitles,
644 inboxNeeds: needsYou(unread?.items ?? [], INBOX_SHOWN),
645 // A run that is going makes the page worth refreshing on its own.
646 changing:
647 liveRuns.length > 0 ||
648 activeList.some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready"),
649 },
650 };
651}
652
653/** What Mission control's panels take. */
654export type Loaded = Awaited<ReturnType<typeof loadMissionControl>>["value"];
655
656/** The cookie that holds the viewer's time zone. */
657export { TZ_COOKIE };

This file's history is long; its oldest lines are credited to the oldest commit read.