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 | */ | |
| 7 | import { env } from "cloudflare:workers"; | |
| 8 | ||
| 9 | import { | |
| 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 | ||
| 23 | import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice"; | |
| 24 | import { | |
| 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"; | |
| 43 | import { G1T_COMMIT_EMAILS, normalizeEmail } from "./commit-people"; | |
| 44 | import { | |
| 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"; | |
| 67 | import { needsYou } from "./inbox"; | |
| 68 | import { | |
| 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"; | |
| 79 | import { roleIn } from "./session.server"; | |
| 80 | import { runners } from "./runners.server"; | |
| 81 | import { 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. | 82 | import { 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. */ | |
| 85 | const TZ_COOKIE = "g1t_tz"; | |
| 86 | /** The most projects whose pull requests and events are read for the page. */ | |
| 87 | const MAX_PROJECTS = 10; | |
| 88 | const EVENTS_PER_PROJECT = 80; | |
| 89 | /** How many pull requests work lists at once (`LIST_PAGE` in services/work). */ | |
| 90 | const PULL_PAGE = 100; | |
| 91 | /** Unread inbox items read for the Needs you card, and how many it shows. */ | |
| 92 | const INBOX_READ = 50; | |
| 93 | const INBOX_SHOWN = 4; | |
| 94 | ||
| 95 | /** Where a need came from, so its row can say what is known about it. */ | |
| 96 | type 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 | */ | |
| 107 | const PUSH_PAGE = 200; | |
| 108 | const PUSH_PAGES = 5; | |
| 109 | /** The first page is smaller: most projects push far less in two weeks. */ | |
| 110 | const 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 | */ | |
| 116 | const HISTORY_FIRST = 200; | |
| 117 | const 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 | */ | |
| 124 | async 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 | ||
| 151 | export 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-push | 396 | // 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-push | 410 | // 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. */ | |
| 654 | export type Loaded = Awaited<ReturnType<typeof loadMissionControl>>["value"]; | |
| 655 | ||
| 656 | /** The cookie that holds the viewer's time zone. */ | |
| 657 | export { TZ_COOKIE }; |
This file's history is long; its oldest lines are credited to the oldest commit read.