Skip to content
1,544 linesCodeBlameRaw
1/**
2 * Home, the workspace's front page: where you're needed now, what happened
3 * since you were last here, and what is running. It never depends on the
4 * calendar: what needs you has no time window, and what happened is
5 * counted over a span you choose, since your last visit by default. Every
6 * number is worked out here from what the services return, so each
7 * definition is tested on its own (home.test.ts) and written down in the
8 * Home guide (apps/docs/.../guides/home.md). Imports only types.
9 */
10import type {
11 ActivityDigest,
12 ActorCount,
13 AgentSession,
14 AgentSessionKind,
15 ChatActivity,
16 Deployment,
17 InboxItem,
18 InstallRequest,
19 Memory,
20 ProjectDeploys,
21 Pull,
22 RepoDigest,
23 RepoPath,
24 Statement,
25 WorkflowRun,
26} from "@g1t/contracts";
27
28import { money } from "./money.ts";
29
30const MINUTE = 60_000;
31const HOUR = 60 * MINUTE;
32const DAY = 24 * HOUR;
33
34/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */
35export function dayIn(at: number, timeZone: string | null): string {
36 try {
37 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
38 } catch {
39 return new Date(at).toISOString().slice(0, 10);
40 }
41}
42
43/** The hour of the day (0–23) in a time zone; UTC when it is unknown. */
44export function hourIn(at: number, timeZone: string | null): number {
45 const read = (zone: string) => Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: zone }).format(at)) % 24;
46 try {
47 return read(timeZone || "UTC");
48 } catch {
49 return read("UTC");
50 }
51}
52
53function formatIn(at: number, timeZone: string | null, options: Intl.DateTimeFormatOptions): string {
54 try {
55 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(at);
56 } catch {
57 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(at);
58 }
59}
60
61/** "Oct 9", in the reader's time zone. */
62export function shortDate(at: number, timeZone: string | null): string {
63 return formatIn(at, timeZone, { month: "short", day: "numeric" });
64}
65
66// --- The span -----------------------------------------------------------------
67
68/** Which span "what happened" covers: since your last visit, or a fixed one. */
69export type WindowKey = "last" | "24h" | "7d";
70
71export const WINDOWS: { key: WindowKey; label: string }[] = [
72 { key: "last", label: "Since last visit" },
73 { key: "24h", label: "Last 24 hours" },
74 { key: "7d", label: "Last 7 days" },
75];
76
77/** The furthest back "since your last visit" looks. */
78export const MAX_AWAY_DAYS = 14;
79
80/** A `?window=` value, or the default: since your last visit. */
81export function parseWindow(value: string | null | undefined): WindowKey {
82 return value === "24h" || value === "7d" ? value : "last";
83}
84
85export type Span = {
86 key: WindowKey;
87 /** Epoch ms: the start of the span. */
88 from: number;
89 now: number;
90 /** "Since Tuesday evening", "In the last 24 hours". */
91 words: string;
92 /** How long it covers: "3 days", "45 minutes". */
93 length: string;
94 /**
95 * Why "since your last visit" covers something else: `first` when there
96 * is no visit on record (it shows the last 24 hours), `capped` when the
97 * last one was longer ago than MAX_AWAY_DAYS, `unknown` when it could
98 * not be read (it shows the last 24 hours).
99 */
100 note: "first" | "capped" | "unknown" | null;
101};
102
103const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
104
105/** How long a stretch of time is, roughly: "a moment", "40 minutes", "3 hours", "3 days", "2 weeks". */
106export function duration(ms: number): string {
107 const minutes = Math.floor(Math.max(0, ms) / MINUTE);
108 if (minutes < 1) return "a moment";
109 if (minutes < 60) return plural(minutes, "minute");
110 const hours = Math.floor(minutes / 60);
111 if (hours < 24) return plural(hours, "hour");
112 const days = Math.floor(hours / 24);
113 if (days < 14) return plural(days, "day");
114 return plural(Math.floor(days / 7), "week");
115}
116
117type Part = "night" | "morning" | "afternoon" | "evening";
118
119/**
120 * When `from` was, in words, from `now`: "a moment ago", "40 minutes ago",
121 * "2 hours ago", "this morning", "last night", "yesterday evening",
122 * "Tuesday evening", "Oct 2". The small hours belong to the night before:
123 * 2 a.m. on Wednesday is Tuesday night.
124 */
125export function sinceWords(from: number, now: number, timeZone: string | null): string {
126 const ago = now - from;
127 if (ago < MINUTE) return "a moment ago";
128 if (ago < HOUR) return `${plural(Math.floor(ago / MINUTE), "minute")} ago`;
129 if (ago < 3 * HOUR) return Math.floor(ago / HOUR) === 1 ? "an hour ago" : `${Math.floor(ago / HOUR)} hours ago`;
130 const hour = hourIn(from, timeZone);
131 const part: Part = hour < 5 ? "night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening";
132 // The night is the evening's day's.
133 const day = dayIn(part === "night" ? from - 6 * HOUR : from, timeZone);
134 const today = dayIn(now, timeZone);
135 const yesterday = dayIn(now - DAY, timeZone);
136 const twoDaysAgo = dayIn(now - 2 * DAY, timeZone);
137 if (day === today) return `this ${part === "night" ? "evening" : part}`;
138 if (day === yesterday) return part === "night" ? "last night" : `yesterday ${part}`;
139 if (part === "night" && day === twoDaysAgo) return "the night before last";
140 if (ago < 6 * DAY) return `${formatIn(part === "night" ? from - 6 * HOUR : from, timeZone, { weekday: "long" })} ${part}`;
141 return shortDate(from, timeZone);
142}
143
144/**
145 * The span for `key`: since `lastSeen` (your last visit, kept by notify),
146 * or the last 24 hours or 7 days. Since your last visit falls back to the
147 * last 24 hours when there is none (`null`) or it could not be read
148 * (`undefined`), and to MAX_AWAY_DAYS when it was longer ago.
149 */
150export function spanFor(key: WindowKey, now: number, lastSeen: number | null | undefined, timeZone: string | null): Span {
151 if (key === "7d") return { key, from: now - 7 * DAY, now, words: "In the last 7 days", length: "7 days", note: null };
152 if (key === "24h" || lastSeen == null || lastSeen > now) {
153 const note = key !== "last" ? null : lastSeen === undefined ? "unknown" : "first";
154 return { key, from: now - DAY, now, words: "In the last 24 hours", length: "24 hours", note };
155 }
156 const furthest = now - MAX_AWAY_DAYS * DAY;
157 if (lastSeen < furthest) return { key, from: furthest, now, words: `In the last ${MAX_AWAY_DAYS} days`, length: `${MAX_AWAY_DAYS} days`, note: "capped" };
158 return { key, from: lastSeen, now, words: `Since ${sinceWords(lastSeen, now, timeZone)}`, length: duration(now - lastSeen), note: null };
159}
160
161/** Whether `at` (epoch ms or RFC 3339) falls in the span. */
162export function within(at: number | string | null | undefined, span: Pick<Span, "from" | "now">): boolean {
163 if (at == null) return false;
164 const ms = typeof at === "number" ? at : Date.parse(at);
165 return Number.isFinite(ms) && ms >= span.from && ms <= span.now;
166}
167
168// --- Agent work in the span ---------------------------------------------------
169
170/**
171 * How one piece of agent work that settled in the span went:
172 * - `accepted`: an agent's pull request merged without the agent being sent back to revise it.
173 * - `fixed`: an agent's pull request merged after one or more revisions.
174 * - `finished`: an agent session that finished. Sessions are not reviewed, so they are never `accepted`.
175 * - `dropped`: an agent's pull request closed without merging, or a session that failed or was stopped.
176 * Work still going is not here: it is under Running now.
177 */
178export type TaskOutcome = "accepted" | "fixed" | "finished" | "dropped";
179
180/** Where a task came from. */
181export type TaskSource = "chat" | "schedule" | "colleague" | "code";
182
183export const OUTCOME_LABEL: Record<TaskOutcome, string> = {
184 accepted: "Accepted first time",
185 fixed: "Fixed after review",
186 finished: "Sessions finished",
187 dropped: "Didn't finish",
188};
189
190export const SOURCE_LABEL: Record<TaskSource, string> = {
191 chat: "Chat",
192 schedule: "Schedules",
193 colleague: "Another agent",
194 code: "Code",
195};
196
197/** One mark on the strip. */
198export type WorkTask = {
199 key: string;
200 outcome: TaskOutcome;
201 source: TaskSource;
202 /** Null for a session in a conversation the viewer is not in. */
203 title: string | null;
204 to: string | null;
205 /** When it settled: the strip's order. */
206 at: number;
207};
208
209/** A pull request with where it is: what Home reads from Code. */
210export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & {
211 repo: RepoPath;
212 author: { username: string; kind?: string };
213 /** Who merged it, when it merged. */
214 mergedBy?: string | null;
215};
216
217/** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */
218export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean {
219 const name = pull.author.username;
220 return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent");
221}
222
223/** `acme/web#12`, lowercase: how a pull request and its runs are matched. */
224export function pullKey(repo: RepoPath, number: number): string {
225 return `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
226}
227
228/**
229 * How an agent's pull request settled in the span, or null when it did not
230 * settle in it. `revisions` counts its `revise` runs: each time g1t sent
231 * the agent back for failed checks or a review.
232 */
233export function pullOutcome(pull: CodePull, revisions: number, span: Pick<Span, "from" | "now">): TaskOutcome | null {
234 if (pull.status === "merged") return within(pull.mergedAt, span) ? (revisions > 0 ? "fixed" : "accepted") : null;
235 if (pull.status === "closed") return within(pull.updatedAt, span) ? "dropped" : null;
236 return null;
237}
238
239const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]);
240
241/**
242 * How a session settled in the span, or null. Only a session at the root
243 * of its tree counts: the subagents and colleagues it brought in are part
244 * of its task.
245 */
246export function sessionOutcome(
247 session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">,
248 span: Pick<Span, "from" | "now">,
249): TaskOutcome | null {
250 if (session.parent_id || LIVE.has(session.status)) return null;
251 if (!within(session.finished_at ?? session.updated_at, span)) return null;
252 return session.status === "done" ? "finished" : "dropped";
253}
254
255export function sessionSource(kind: AgentSessionKind): TaskSource {
256 return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague";
257}
258
259/** The agents' work that settled in the span, and how the 7 days before it compare. */
260export type SpanWork = {
261 tasks: WorkTask[];
262 counts: Record<TaskOutcome, number>;
263 /** Tasks by where they came from, most first; only sources with any. */
264 sources: { source: TaskSource; count: number }[];
265 /**
266 * Accepted first time: of the agents' pull requests that merged or closed
267 * in the span, the share that merged without a revision. Null with none.
268 */
269 rate: { value: number; of: number } | null;
270 /** The same share over the 7 days before the span; null when there were none, or they could not all be read. */
271 before: { value: number; of: number } | null;
272 /** Whether Code was read: false for a member without Code access, or when Code did not answer. */
273 code: "read" | "no_access" | "unavailable";
274 /** Whether sessions were read. */
275 sessions: "read" | "unavailable";
276 /** True when a list was cut short before the start of the span, so some work may be missing. */
277 partial: boolean;
278};
279
280export type CodeWork = {
281 pulls: CodePull[];
282 /** `revise` runs per pull request (`pullKey`). */
283 revisions: Record<string, number>;
284 /** Whether every list read reached back to 7 days before the span. */
285 complete: boolean;
286};
287
288export type SessionWork = { sessions: AgentSession[]; complete: boolean };
289
290/** Accepted first time over a set of settled outcomes: null when no pull request settled. */
291export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null {
292 const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped");
293 if (settled.length === 0) return null;
294 return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length };
295}
296
297export function spanWork(input: {
298 span: Pick<Span, "from" | "now">;
299 slug: string;
300 code: CodeWork | null | "no_access";
301 sessions: SessionWork | null;
302}): SpanWork {
303 const { span, slug } = input;
304 const tasks: WorkTask[] = [];
305 let before: SpanWork["before"] = null;
306 const code = input.code;
307 if (code && code !== "no_access") {
308 const prior = { from: span.from - 7 * DAY, now: span.from - 1 };
309 const earlier: TaskOutcome[] = [];
310 for (const pull of code.pulls) {
311 if (!isAgentPull(pull)) continue;
312 const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0;
313 const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`;
314 const outcome = pullOutcome(pull, revisions, span);
315 if (outcome) {
316 const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt);
317 tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at });
318 continue;
319 }
320 const then = pullOutcome(pull, revisions, prior);
321 if (then) earlier.push(then);
322 }
323 before = code.complete ? acceptance(earlier) : null;
324 }
325 for (const session of input.sessions?.sessions ?? []) {
326 const outcome = sessionOutcome(session, span);
327 if (!outcome) continue;
328 tasks.push({
329 key: `session:${session.id}`,
330 outcome,
331 source: sessionSource(session.kind),
332 title: session.visible ? session.title : null,
333 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
334 at: Date.parse(session.finished_at ?? session.updated_at),
335 });
336 }
337 // In the order they settled.
338 tasks.sort((a, b) => a.at - b.at || a.key.localeCompare(b.key));
339 const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0 };
340 const bySource = new Map<TaskSource, number>();
341 for (const task of tasks) {
342 counts[task.outcome]++;
343 bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1);
344 }
345 const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"];
346 return {
347 tasks,
348 counts,
349 sources: [...bySource]
350 .map(([source, count]) => ({ source, count }))
351 .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)),
352 rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)),
353 before,
354 code: code === "no_access" ? "no_access" : code ? "read" : "unavailable",
355 sessions: input.sessions ? "read" : "unavailable",
356 partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete),
357 };
358}
359
360/** The change in percentage points from the 7 days before, rounded: "+6 pts vs the 7 days before", "Same as the 7 days before". */
361export function trendLabel(rate: { value: number } | null, before: { value: number } | null): string | null {
362 if (!rate || !before) return null;
363 const points = Math.round(rate.value * 100) - Math.round(before.value * 100);
364 if (points === 0) return "Same as the 7 days before";
365 return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`;
366}
367
368// --- People and agents in the span -----------------------------------------------
369
370/**
371 * Who an actor is. Actors are member keys, as the events and chat services
372 * return them: `user:<id>` for an account, `agent:<id>` for one of the
373 * workspace's agents acting as itself, `""` for nobody (a push a mirror
374 * copied in). An agent is one of the workspace's agents, or g1t itself;
375 * everyone else is a person. `agents` names more keys that are agents'
376 * (accounts of kind `agent`), when known.
377 */
378export type ActorKind = "person" | "agent";
379
380/** g1t's own account, as events name it (identity's `AGENT_ID`). */
381export const G1T_ACTOR = "user:usr_g1t_agent";
382
383export function actorKind(key: string, agents?: ReadonlySet<string>): ActorKind {
384 return key.startsWith("agent:") || key === G1T_ACTOR || agents?.has(key) ? "agent" : "person";
385}
386
387/** Whether a username is an agent's: g1t, or an agent account. */
388export function isAgentName(name: string | null | undefined): boolean {
389 return name === "g1t" || (name != null && name.endsWith("-agent"));
390}
391
392/** A repository as the digest's links and lines need it. */
393export type RepoRef = RepoPath & { defaultBranch: string };
394
395/** Someone who did something in the span: for the faces beside a column. */
396export type DigestActor = {
397 key: string;
398 kind: ActorKind;
399 /** Their username or handle; `someone` when it could not be read. */
400 name: string;
401 /** How many things they did, over every line: the order of the faces. */
402 count: number;
403};
404
405/** One line of a column: what was done, with its count, and where that is listed. */
406export type DigestLine = {
407 key: string;
408 text: string;
409 to: string | null;
410};
411
412export type DigestColumn = {
413 kind: ActorKind;
414 lines: DigestLine[];
415 /** Most active first. */
416 actors: DigestActor[];
417};
418
419/** What the sentence under the heading needs. */
420export type DigestSummary = {
421 /** Distinct people who did anything. */
422 people: number;
423 /** Commits people pushed. */
424 commits: number;
425 /** Pull requests people merged. */
426 merged: number;
427 /** Agent tasks that settled well, and that did not. */
428 finished: number;
429 dropped: number;
430 /** Messages people sent. */
431 said: number;
432 /** Whether anything at all was counted, in either column. */
433 anything: boolean;
434 /** Whether what people did could be read at all (events and chat both silent: no). */
435 peopleRead: boolean;
436};
437
438export type Digest = {
439 people: DigestColumn;
440 agents: DigestColumn;
441 summary: DigestSummary;
442 /** Sources that did not answer, by name, for the card to say so: Code, Chat, Agents. */
443 missing: string[];
444 /** True when a source was cut short, so some counts are low. */
445 partial: boolean;
446};
447
448export type DigestInput = {
449 slug: string;
450 /** The workspace's repositories by id: for links, and to count projects. */
451 repos: Record<string, RepoRef>;
452 /** Events' digest; null when events did not answer. Without Code access it has no repositories. */
453 activity: ActivityDigest | null;
454 /** Chat's; null when chat did not answer. */
455 chat: ChatActivity | null;
456 /** Names for member keys: a username, or an agent's handle. */
457 names: Record<string, string>;
458 /** Keys of accounts that are agents, beyond g1t and the workspace's agents. */
459 agentKeys?: ReadonlySet<string>;
460 /** The agents' tasks, for their column; null when neither Code nor Agents answered. */
461 work: SpanWork | null;
462 code: boolean;
463};
464
465const people = (n: number) => plural(n, "person", "people");
466
467/** `a, b and c`. */
468export const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
469
470/** What one kind did, from a tally: how many, and by whom (non-empty keys). */
471function part(counts: ActorCount[], kind: ActorKind, agents?: ReadonlySet<string>): { count: number; actors: Map<string, number> } {
472 const actors = new Map<string, number>();
473 let count = 0;
474 for (const entry of counts) {
475 if (actorKind(entry.actor, agents) !== kind) continue;
476 count += entry.count;
477 if (entry.actor) actors.set(entry.actor, (actors.get(entry.actor) ?? 0) + entry.count);
478 }
479 return { count, actors };
480}
481
482/** Where a line points: the one project's page, or the workspace's projects when several. */
483function placeFor(slug: string, repos: Record<string, RepoRef>, repoIds: Iterable<string>, page: string): string {
484 const ids = [...new Set(repoIds)];
485 const only = ids.length === 1 ? repos[ids[0]!] : null;
486 return only ? `/${only.namespace}/${only.name}/${page}` : `/${slug}/-/repositories`;
487}
488
489/**
490 * One column: what people, or agents, did in the span, as lines with
491 * counts and links, and who did it. Pure: everything comes from the
492 * services' counts and the names looked up for them.
493 */
494export function digestColumn(kind: ActorKind, input: DigestInput): DigestColumn {
495 const { slug, repos, activity, chat, names, agentKeys, work } = input;
496 const lines: DigestLine[] = [];
497 const did = new Map<string, number>();
498 const credit = (actors: Map<string, number>) => {
499 for (const [key, n] of actors) did.set(key, (did.get(key) ?? 0) + n);
500 };
501
502 // The agents' tasks come first in their column: what Home has always counted.
503 if (kind === "agent" && work) {
504 const { accepted, fixed, finished, dropped } = work.counts;
505 const done = accepted + fixed + finished;
506 if (done > 0 || dropped > 0) {
507 const text = done > 0 ? `finished ${plural(done, "task")}${dropped > 0 ? `, ${dropped.toLocaleString("en-US")} didn't finish` : ""}` : `finished none of ${plural(dropped, "task")}`;
508 lines.push({ key: "tasks", text, to: `/${slug}/-/agents` });
509 }
510 const sessions = work.tasks.filter((task) => task.source !== "code").length;
511 if (sessions > 0) lines.push({ key: "sessions", text: `ran ${plural(sessions, "session")}`, to: `/${slug}/-/agents` });
512 }
513
514 if (activity) {
515 // Pushes: commits, projects, and who.
516 const pushers = new Map<string, number>();
517 const pushed = new Set<string>();
518 let commits = 0;
519 let pushes = 0;
520 for (const repo of activity.repos) {
521 let any = false;
522 for (const by of repo.pushes.by) {
523 if (actorKind(by.actor, agentKeys) !== kind) continue;
524 any = true;
525 commits += by.commits;
526 pushes += by.count;
527 if (by.actor) pushers.set(by.actor, (pushers.get(by.actor) ?? 0) + by.count);
528 }
529 if (any) pushed.add(repo.repo_id);
530 }
531 if (pushes > 0) {
532 const where = `to ${plural(pushed.size, "project")}`;
533 const text =
534 kind === "person"
535 ? pushers.size > 0
536 ? `${people(pushers.size)} pushed ${plural(commits, "commit")} ${where}`
537 : `${plural(commits, "commit")} pushed ${where}`
538 : `pushed ${plural(commits, "commit")} ${where}`;
539 lines.push({ key: "pushes", text, to: placeFor(slug, repos, pushed, "activity") });
540 credit(pushers);
541 }
542
543 // Pull requests, reviews, issues, comments, deploys, releases, packages: summed over projects.
544 const sum = (pick: (repo: RepoDigest) => ActorCount[]) => {
545 const total = { count: 0, actors: new Map<string, number>(), repoIds: [] as string[] };
546 for (const repo of activity.repos) {
547 const { count, actors } = part(pick(repo), kind, agentKeys);
548 if (count === 0) continue;
549 total.count += count;
550 total.repoIds.push(repo.repo_id);
551 for (const [key, n] of actors) total.actors.set(key, (total.actors.get(key) ?? 0) + n);
552 }
553 return total;
554 };
555 const opened = sum((repo) => repo.pulls.opened);
556 const merged = sum((repo) => repo.pulls.merged);
557 const closed = sum((repo) => repo.pulls.closed);
558 if (opened.count + merged.count + closed.count > 0) {
559 const parts: string[] = [];
560 if (opened.count > 0) parts.push(`opened ${plural(opened.count, "pull request")}`);
561 if (merged.count > 0) parts.push(`merged ${opened.count > 0 ? merged.count.toLocaleString("en-US") : plural(merged.count, "pull request")}`);
562 if (closed.count > 0) parts.push(`closed ${closed.count.toLocaleString("en-US")} without merging`);
563 lines.push({ key: "pulls", text: parts.join(", "), to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds], "pulls") });
564 credit(opened.actors);
565 credit(merged.actors);
566 credit(closed.actors);
567 }
568 const reviews = sum((repo) => repo.reviews);
569 if (reviews.count > 0) {
570 lines.push({ key: "reviews", text: `reviewed ${plural(reviews.count, "pull request")}`, to: placeFor(slug, repos, reviews.repoIds, "pulls") });
571 credit(reviews.actors);
572 }
573 const issuesOpened = sum((repo) => repo.issues.opened);
574 const issuesClosed = sum((repo) => repo.issues.closed);
575 if (issuesOpened.count + issuesClosed.count > 0) {
576 const parts: string[] = [];
577 if (issuesOpened.count > 0) parts.push(`opened ${plural(issuesOpened.count, "issue")}`);
578 if (issuesClosed.count > 0) parts.push(`closed ${issuesOpened.count > 0 ? issuesClosed.count.toLocaleString("en-US") : plural(issuesClosed.count, "issue")}`);
579 lines.push({ key: "issues", text: parts.join(", "), to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds], "issues") });
580 credit(issuesOpened.actors);
581 credit(issuesClosed.actors);
582 }
583 const comments = sum((repo) => repo.comments);
584 if (comments.count > 0) {
585 lines.push({ key: "comments", text: `left ${plural(comments.count, "comment")}`, to: placeFor(slug, repos, comments.repoIds, "issues") });
586 credit(comments.actors);
587 }
588 const succeeded = sum((repo) => repo.deployments.succeeded);
589 const failed = sum((repo) => repo.deployments.failed);
590 if (succeeded.count + failed.count > 0) {
591 const text = succeeded.count > 0 ? `${plural(succeeded.count, "deploy")} went out${failed.count > 0 ? `, ${failed.count.toLocaleString("en-US")} failed` : ""}` : `${plural(failed.count, "deploy")} failed`;
592 lines.push({ key: "deploys", text, to: placeFor(slug, repos, [...succeeded.repoIds, ...failed.repoIds], "deployments") });
593 credit(succeeded.actors);
594 credit(failed.actors);
595 }
596 const releases = sum((repo) => tallyOf(repo.releases.map((release) => release.actor)));
597 if (releases.count > 0) {
598 lines.push({ key: "releases", text: `published ${plural(releases.count, "release")}`, to: placeFor(slug, repos, releases.repoIds, "releases") });
599 credit(releases.actors);
600 }
601 const packages = sum((repo) => tallyOf(repo.packages.map((pkg) => pkg.actor)));
602 if (packages.count > 0) {
603 lines.push({ key: "packages", text: `published ${plural(packages.count, "package version")}`, to: `/${slug}/-/packages` });
604 credit(packages.actors);
605 }
606
607 // Artifacts: made, and whose content changed.
608 if (activity.folios) {
609 const made = part(activity.folios.created, kind, agentKeys);
610 const edited = new Set<string>();
611 const editors = new Map<string, number>();
612 let allDocs = true;
613 for (const folio of activity.folios.edited) {
614 const mine = folio.authors.filter((author) => actorKind(author, agentKeys) === kind);
615 if (mine.length === 0) continue;
616 edited.add(folio.folio_id);
617 if (folio.kind !== "doc") allDocs = false;
618 for (const author of mine) editors.set(author, (editors.get(author) ?? 0) + 1);
619 }
620 const what = (n: number) => plural(n, allDocs ? "doc" : "artifact");
621 if (made.count + edited.size > 0) {
622 const parts: string[] = [];
623 if (made.count > 0) parts.push(`created ${what(made.count)}`);
624 if (edited.size > 0) parts.push(`edited ${what(edited.size)}`);
625 lines.push({ key: "docs", text: parts.join(", "), to: `/${slug}/-/artifacts` });
626 credit(made.actors);
627 credit(editors);
628 }
629 }
630 }
631
632 // Chat: what each kind said.
633 if (chat) {
634 const said = part(
635 chat.authors.map((author) => ({ actor: author.key, count: author.messages })),
636 kind,
637 agentKeys,
638 );
639 if (said.count > 0) {
640 const text = kind === "person" ? `sent ${plural(said.count, "message")} in ${plural(chat.channels, "conversation")}` : `replied ${plural(said.count, "time")} in chat`;
641 lines.push({ key: "chat", text, to: `/${slug}/-/chat` });
642 credit(said.actors);
643 }
644 }
645
646 const actors: DigestActor[] = [...did]
647 .map(([key, count]) => ({ key, kind, name: names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"), count }))
648 .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
649 return { kind, lines, actors };
650}
651
652/** A tally with one of each actor, for lists that name an actor per item. */
653function tallyOf(actors: string[]): ActorCount[] {
654 const counts = new Map<string, number>();
655 for (const actor of actors) counts.set(actor, (counts.get(actor) ?? 0) + 1);
656 return [...counts].map(([actor, count]) => ({ actor, count }));
657}
658
659/** Both columns, what the sentence needs, and which sources were silent. */
660export function digestOf(input: DigestInput): Digest {
661 const people = digestColumn("person", input);
662 const agents = digestColumn("agent", input);
663 const missing: string[] = [];
664 if (input.code && !input.activity) missing.push("Code");
665 if (!input.chat) missing.push("Chat");
666 if (!input.work) missing.push("Agents");
667 let commits = 0;
668 let merged = 0;
669 for (const repo of input.activity?.repos ?? []) {
670 for (const by of repo.pushes.by) if (actorKind(by.actor, input.agentKeys) === "person") commits += by.commits;
671 merged += part(repo.pulls.merged, "person", input.agentKeys).count;
672 }
673 const said = part(
674 (input.chat?.authors ?? []).map((author) => ({ actor: author.key, count: author.messages })),
675 "person",
676 input.agentKeys,
677 ).count;
678 const counts = input.work?.counts;
679 return {
680 people,
681 agents,
682 summary: {
683 people: people.actors.length,
684 commits,
685 merged,
686 finished: counts ? counts.accepted + counts.fixed + counts.finished : 0,
687 dropped: counts?.dropped ?? 0,
688 said,
689 anything: people.lines.length > 0 || agents.lines.length > 0,
690 peopleRead: input.activity != null || input.chat != null,
691 },
692 missing,
693 partial: (input.activity != null && !input.activity.complete) || (input.work?.partial ?? false),
694 };
695}
696
697// --- What landed in the span -----------------------------------------------------
698
699/**
700 * A change that landed: a pull request merged; commits pushed straight to
701 * a default branch; a production build that went live; a release or a
702 * package version published. By a person or an agent.
703 */
704export type LandedChange = {
705 key: string;
706 kind: "merge" | "push" | "deploy" | "release" | "package";
707 title: string;
708 /** One line under it: where, and by whom. */
709 detail: string;
710 to: string;
711 at: number;
712 /** An agent's doing, or a person's. */
713 agent: boolean;
714};
715
716/** Every pull request that merged in the span, newest first. */
717export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
718 return pulls
719 .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))
720 .map((pull) => {
721 const agent = isAgentPull(pull);
722 const mergedBy = pull.mergedBy && pull.mergedBy !== pull.author.username ? `, merged by ${pull.mergedBy}` : "";
723 return {
724 key: `landed:${pullKey(pull.repo, pull.number)}`,
725 kind: "merge" as const,
726 title: pull.title,
727 detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`,
728 to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
729 at: Date.parse(pull.mergedAt!),
730 agent,
731 };
732 })
733 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
734}
735
736/** The names behind member keys, for a line: `ana and bo`, `@margo`, `a mirror` for nobody. */
737function namesOf(keys: string[], names: Record<string, string>, agentKeys?: ReadonlySet<string>): string {
738 const shown = keys.map((key) => {
739 if (!key) return "a mirror";
740 const name = names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone");
741 return actorKind(key, agentKeys) === "agent" ? `@${name}` : name;
742 });
743 return listOf([...new Set(shown)]);
744}
745
746/**
747 * Commits pushed straight to a default branch, one row per project with
748 * any: what landed without a pull request. Releases and package versions
749 * published, one row each. Pushes within the span only: the digest was
750 * asked for the span.
751 */
752export function landedFromActivity(activity: ActivityDigest | null, repos: Record<string, RepoRef>, names: Record<string, string>, slug: string, agentKeys?: ReadonlySet<string>): LandedChange[] {
753 const rows: LandedChange[] = [];
754 for (const repo of activity?.repos ?? []) {
755 const path = repos[repo.repo_id];
756 if (!path) continue;
757 const direct = repo.pushes.default_branch;
758 if (direct.count > 0 && direct.last_at) {
759 const by = direct.by.map((entry) => entry.actor);
760 rows.push({
761 key: `push:${repo.repo_id}`,
762 kind: "push",
763 title: `${plural(direct.commits, "commit")} pushed to ${path.defaultBranch}`,
764 detail: `${path.name} · ${plural(direct.count, "push", "pushes")} by ${namesOf(by, names, agentKeys)}`,
765 to: `/${path.namespace}/${path.name}/activity`,
766 at: Date.parse(direct.last_at),
767 agent: by.length > 0 && by.every((key) => key && actorKind(key, agentKeys) === "agent"),
768 });
769 }
770 for (const release of repo.releases) {
771 rows.push({
772 key: `release:${repo.repo_id}:${release.tag}`,
773 kind: "release",
774 title: release.name ? `${release.tag} · ${release.name}` : release.tag,
775 detail: `${path.name} · released by ${namesOf([release.actor], names, agentKeys)}`,
776 to: `/${path.namespace}/${path.name}/releases/tag/${encodeURIComponent(release.tag)}`,
777 at: Date.parse(release.at),
778 agent: actorKind(release.actor, agentKeys) === "agent",
779 });
780 }
781 for (const pkg of repo.packages) {
782 rows.push({
783 key: `package:${repo.repo_id}:${pkg.ecosystem}:${pkg.name}:${pkg.version}`,
784 kind: "package",
785 title: `${pkg.name} ${pkg.version}`,
786 detail: `${pkg.ecosystem} package · published by ${namesOf([pkg.actor], names, agentKeys)}`,
787 to: `/${slug}/-/packages`,
788 at: Date.parse(pkg.at),
789 agent: actorKind(pkg.actor, agentKeys) === "agent",
790 });
791 }
792 }
793 return rows;
794}
795
796/** Production builds that went live in the span, as landed changes; previews stay in Deploys. */
797export function landedDeploys(deploys: DeployRow[]): LandedChange[] {
798 return deploys
799 .filter((row) => row.kind === "production" && row.outcome === "live")
800 .map((row) => ({
801 key: `landed:${row.key}`,
802 kind: "deploy" as const,
803 title: `Production of ${row.project} went live`,
804 detail: `${row.commit} · by ${row.by}`,
805 to: row.to,
806 at: row.at,
807 agent: isAgentName(row.by),
808 }));
809}
810
811/** Everything that landed, newest first. */
812export function landed(parts: LandedChange[][]): LandedChange[] {
813 return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
814}
815
816/** A build that finished in the span. */
817export type DeployRow = {
818 key: string;
819 project: string;
820 kind: Deployment["kind"];
821 /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */
822 outcome: "live" | "failed";
823 commit: string;
824 branch: string | null;
825 at: number;
826 to: string;
827 by: string;
828};
829
830/**
831 * Builds that finished in the span, newest first: production builds each,
832 * and previews too. A build that went live and was replaced since still
833 * went live. Skipped builds are left out.
834 */
835export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">): DeployRow[] {
836 const rows: DeployRow[] = [];
837 for (const project of projects) {
838 for (const deploy of project.deployments) {
839 const ended = deploy.finishedAt ?? null;
840 if (!within(ended, span)) continue;
841 const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null;
842 if (!outcome) continue;
843 rows.push({
844 key: `deploy:${project.slug}:${deploy.id}`,
845 project: project.slug,
846 kind: deploy.kind,
847 outcome,
848 commit: deploy.commit.slice(0, 7),
849 branch: deploy.branch,
850 at: Date.parse(ended!),
851 to: `/${workspace}/${project.slug}/deployments/${deploy.id}`,
852 by: deploy.createdBy,
853 });
854 }
855 }
856 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
857}
858
859/** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */
860export type DecisionRow = {
861 key: string;
862 text: string;
863 by: string | null;
864 at: number;
865 to: string;
866 kind: "memory" | "request";
867};
868
869export function decisionsIn(
870 input: { memories: Memory[] | null; requests: InstallRequest[] | null },
871 slug: string,
872 span: Pick<Span, "from" | "now">,
873): DecisionRow[] {
874 const rows: DecisionRow[] = [];
875 for (const memory of input.memories ?? []) {
876 if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue;
877 rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" });
878 }
879 for (const request of input.requests ?? []) {
880 if (request.status === "open" || !within(request.resolved_at, span)) continue;
881 rows.push({
882 key: `request:${request.id}`,
883 text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`,
884 by: request.resolved_by,
885 at: Date.parse(request.resolved_at!),
886 to: `/${slug}/-/marketplace/requests`,
887 kind: "request",
888 });
889 }
890 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
891}
892
893// --- Running now ----------------------------------------------------------------
894
895/** Something going right now: a session, an agent's change, a workflow run, a build. */
896export type RunningRow = {
897 key: string;
898 kind: "session" | "change" | "workflow" | "deploy";
899 title: string;
900 /** One line: who and where. */
901 detail: string;
902 /** "Working", "Queued", "Building". */
903 status: string;
904 /** Epoch ms: when it started. */
905 at: number;
906 to: string;
907};
908
909const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" };
910
911/** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */
912export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] {
913 return sessions
914 .filter((session) => !session.parent_id && SESSION_STATUS[session.status])
915 .map((session) => ({
916 key: `session:${session.id}`,
917 kind: "session" as const,
918 title: session.visible ? session.title : "A session in a conversation you're not in",
919 detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`,
920 status: SESSION_STATUS[session.status]!,
921 at: Date.parse(session.created_at),
922 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
923 }));
924}
925
926/** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */
927export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string };
928
929export function runningChanges(changes: AgentChange[]): RunningRow[] {
930 return changes.map((change) => ({
931 key: `change:${change.key}`,
932 kind: "change" as const,
933 title: change.title,
934 detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`,
935 status: change.chip,
936 at: change.at,
937 to: change.to,
938 }));
939}
940
941/** Builds queued or going now, one per project (its newest). */
942export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] {
943 return overview.flatMap((project) => {
944 const latest = project.latest;
945 if (!latest || (latest.status !== "queued" && latest.status !== "building")) return [];
946 return [
947 {
948 key: `deploy:${project.slug}:${latest.id}`,
949 kind: "deploy" as const,
950 title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`,
951 detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`,
952 status: latest.status === "queued" ? "Queued" : "Building",
953 at: Date.parse(latest.createdAt),
954 to: `/${slug}/${project.slug}/deployments/${latest.id}`,
955 },
956 ];
957 });
958}
959
960const RUN_STATUS: Partial<Record<WorkflowRun["status"], string>> = {
961 pending: "Queued",
962 queued: "Queued",
963 action_required: "Needs approval",
964 in_progress: "Running",
965 waiting: "Waiting",
966};
967
968/** Workflow runs not finished, in the projects read: queued, waiting, running, or waiting for approval. */
969export function runningWorkflows(projects: { repo: RepoPath; runs: WorkflowRun[] }[]): RunningRow[] {
970 return projects.flatMap(({ repo, runs }) =>
971 runs.flatMap((run) => {
972 const status = RUN_STATUS[run.status];
973 if (!status) return [];
974 return [
975 {
976 key: `workflow:${repo.namespace}/${repo.name}:${run.id}`.toLowerCase(),
977 kind: "workflow" as const,
978 title: run.title ? `${run.name}: ${run.title}` : run.name,
979 detail: `${repo.namespace}/${repo.name} ${run.ref.replace(/^refs\/heads\//, "")}${run.actor ? ` · by ${run.actor}` : ""}`,
980 status,
981 at: Date.parse(run.startedAt ?? run.createdAt),
982 to: `/${repo.namespace}/${repo.name}/actions/runs/${run.id}`,
983 },
984 ];
985 }),
986 );
987}
988
989/** Everything running, longest-going first within each kind: sessions, then changes, then workflow runs, then builds. */
990export function running(parts: RunningRow[][]): RunningRow[] {
991 const order = { session: 0, change: 1, workflow: 2, deploy: 3 } as const;
992 return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
993}
994
995// --- Needs you ------------------------------------------------------------------
996
997/**
998 * What a row is. Tiers, most pressing first:
999 * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`);
1000 * 1. agent work that has stopped and can't go on without you;
1001 * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something);
1002 * 3. something stuck that may need a look;
1003 * 4. an unread notification that is a warning or a failure;
1004 * 5. a mention or a direct message.
1005 */
1006export type AttentionKind =
1007 | "limit"
1008 | "deploy"
1009 | "session_cap"
1010 | "agent_budget"
1011 | "pull_blocked"
1012 | "ready"
1013 | "review"
1014 | "invitation"
1015 | "install_request"
1016 | "agent_waiting"
1017 | "stuck"
1018 | "runner"
1019 | "notification"
1020 | "chat";
1021
1022export const TIER: Record<AttentionKind, number> = {
1023 limit: 0,
1024 deploy: 0,
1025 session_cap: 1,
1026 agent_budget: 1,
1027 pull_blocked: 1,
1028 ready: 2,
1029 review: 2,
1030 invitation: 2,
1031 install_request: 2,
1032 agent_waiting: 2,
1033 stuck: 3,
1034 runner: 3,
1035 notification: 4,
1036 chat: 5,
1037};
1038
1039export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
1040
1041/** One row of Needs you. */
1042export type AttentionRow = {
1043 key: string;
1044 kind: AttentionKind;
1045 title: string;
1046 /** One line: what it is and where. */
1047 detail: string;
1048 /** What is at stake, in a few words. */
1049 stake: Stake | null;
1050 /** The action, right there. */
1051 action: { label: string; to: string };
1052 /** Who is on it: the agent, or the person who asked. */
1053 owner: { name: string; agent: boolean } | null;
1054 /** Epoch ms: when it started waiting. */
1055 at: number;
1056 /** Where it came from, for a member without Code access never `code`. */
1057 from: "code" | "agents" | "notifications" | "chat" | "marketplace";
1058};
1059
1060/**
1061 * Most pressing first: by tier, then what has waited longest, then by key
1062 * so the order never depends on the order rows were read. One of each key,
1063 * and one of each place: a notification about a pull request already on
1064 * the list is left out.
1065 */
1066export function rankAttention(rows: AttentionRow[]): AttentionRow[] {
1067 const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
1068 const keys = new Set<string>();
1069 const places = new Set<string>();
1070 return sorted.filter((row) => {
1071 const place = row.action.to.split(/[?#]/)[0];
1072 if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false;
1073 keys.add(row.key);
1074 places.add(place);
1075 return true;
1076 });
1077}
1078
1079/** Start here: the first row by `rankAttention`, with why it was picked. */
1080export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null {
1081 const row = ranked[0];
1082 if (!row) return null;
1083 const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length;
1084 const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : "";
1085 return { row, why: `${WHY[row.kind]}${tail}` };
1086}
1087
1088const WHY: Record<AttentionKind, string> = {
1089 limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.",
1090 install_request: "A member asked for it, and only an owner can add it or turn it down.",
1091 deploy: "Production is serving an older build until this is fixed, so it comes before anything else.",
1092 session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.",
1093 agent_budget: "An agent used up its budget and takes no new work until it is raised.",
1094 pull_blocked: "An agent stopped on this change and won't go on until a person decides.",
1095 ready: "The work is done and only waits for you to land it.",
1096 review: "You were asked by name, so it waits for your verdict.",
1097 invitation: "It is addressed to you; no one else can answer it.",
1098 agent_waiting: "An agent is waiting on you to go on.",
1099 stuck: "A running agent has stopped reporting and may be stuck.",
1100 runner: "A job is waiting for a runner that is not there.",
1101 notification: "It is the most pressing unread notification.",
1102 chat: "Nothing else is waiting, and this is the oldest message for you.",
1103};
1104
1105// --- Rows from each source ----------------------------------------------------
1106
1107/** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */
1108export type CodeNeed = {
1109 key: string;
1110 reason: string;
1111 repo: RepoPath | null;
1112 ref: string | null;
1113 title: string;
1114 ask: string;
1115 by: { name: string; agent: boolean } | null;
1116 for: string | null;
1117 at: number;
1118 to: string;
1119 facts: { label: string; value: string; tone: string | null }[];
1120};
1121
1122const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null;
1123
1124const REASON_STAKE: Record<string, Stake> = {
1125 blocking: { text: "Blocking", tone: "danger" },
1126 checks_failing: { text: "Checks failing", tone: "danger" },
1127 outside_guardrails: { text: "Over a guardrail", tone: "warn" },
1128 low_confidence: { text: "Low confidence", tone: "warn" },
1129 needs_review: { text: "Needs a verdict", tone: "warn" },
1130 stalled: { text: "Stalled", tone: "warn" },
1131};
1132
1133/** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */
1134export function codeRow(need: CodeNeed): AttentionRow {
1135 const prefix = need.key.split(":")[0];
1136 const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null;
1137 const detail = where ? `${where} · ${need.ask}` : need.ask;
1138 const owner = need.by;
1139 const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const };
1140 switch (prefix) {
1141 case "deploy":
1142 return {
1143 ...base,
1144 kind: "deploy",
1145 stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" },
1146 action: { label: "See the build", to: need.to },
1147 };
1148 case "invitation":
1149 return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } };
1150 case "review": {
1151 const lines = fact(need, "Lines");
1152 return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } };
1153 }
1154 case "runner":
1155 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
1156 case "run": {
1157 const cost = fact(need, "Cost so far");
1158 return {
1159 ...base,
1160 kind: "stuck",
1161 stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" },
1162 action: { label: "Look", to: need.to },
1163 };
1164 }
1165 default: {
1166 if (need.reason === "ready_to_merge") {
1167 const files = fact(need, "Files changed");
1168 return {
1169 ...base,
1170 kind: "ready",
1171 stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" },
1172 action: { label: "Merge", to: need.to },
1173 };
1174 }
1175 return {
1176 ...base,
1177 kind: "pull_blocked",
1178 stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" },
1179 action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to },
1180 };
1181 }
1182 }
1183}
1184
1185/** A session stopped at its spend cap that the viewer may approve more for. */
1186export function sessionCapRow(session: AgentSession, slug: string): AttentionRow {
1187 const cap = session.cap_micros;
1188 return {
1189 key: `session:${session.id}`,
1190 kind: "session_cap",
1191 title: session.visible ? session.title : `A session of @${session.agent_handle}`,
1192 detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`,
1193 stake: { text: cap != null ? `${money(session.charged_micros)} of ${money(cap)} cap` : `${money(session.charged_micros)} spent`, tone: "warn" },
1194 action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` },
1195 owner: { name: session.agent_handle, agent: true },
1196 at: Date.parse(session.updated_at),
1197 from: "agents",
1198 };
1199}
1200
1201export type AgentLike = {
1202 id: string;
1203 handle: string;
1204 display_name: string;
1205 status: string;
1206 spent_month_micros: number;
1207 budget: { monthly_micros: number | null };
1208 updated_at: string;
1209};
1210
1211/** An agent out of budget (for those who may raise it), or waiting on someone. */
1212export function agentRow(agent: AgentLike, slug: string): AttentionRow | null {
1213 const base = {
1214 key: `agent:${agent.id}`,
1215 owner: { name: agent.handle, agent: true },
1216 at: Date.parse(agent.updated_at),
1217 from: "agents" as const,
1218 };
1219 if (agent.status === "out_of_budget") {
1220 const budget = agent.budget.monthly_micros;
1221 return {
1222 ...base,
1223 kind: "agent_budget",
1224 title: `${agent.display_name} is out of budget`,
1225 detail: `@${agent.handle} takes no new work this month until its budget is raised.`,
1226 stake: { text: budget != null ? `${money(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" },
1227 action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` },
1228 };
1229 }
1230 if (agent.status === "waiting") {
1231 return {
1232 ...base,
1233 kind: "agent_waiting",
1234 title: `${agent.display_name} is waiting`,
1235 detail: `@${agent.handle} is waiting before it goes on.`,
1236 stake: null,
1237 action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` },
1238 };
1239 }
1240 return null;
1241}
1242
1243const NOTIFICATION_STAKE: Record<string, string> = {
1244 agent: "Agent waiting",
1245 review_requested: "Review requested",
1246 assign: "Assigned to you",
1247 mention: "Mentioned",
1248 team_mention: "Team mentioned",
1249 ci_activity: "CI activity",
1250 security_alert: "Security alert",
1251 state_change: "State changed",
1252 author: "Your work",
1253 comment: "Comment",
1254 manual: "Subscribed",
1255 subscribed: "Watching",
1256};
1257
1258/**
1259 * An unread notification that is a warning (someone or something is
1260 * waiting) or a failure, in this workspace. A member without Code access
1261 * gets none about a repository.
1262 */
1263export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null {
1264 if (item.readAt != null || item.doneAt != null) return null;
1265 if (item.severity !== "warning" && item.severity !== "error") return null;
1266 const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug;
1267 if (!inWorkspace) return null;
1268 if (!code && item.repo) return null;
1269 return {
1270 key: `notification:${item.id}`,
1271 kind: "notification",
1272 title: item.body || item.title,
1273 detail: item.body ? item.title : (item.repo ?? "A notification"),
1274 stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" },
1275 action: { label: "Open", to: item.url },
1276 owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null,
1277 at: Date.parse(item.updatedAt),
1278 from: "notifications",
1279 };
1280}
1281
1282export type ChatEntryLike = {
1283 channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null };
1284 title: string;
1285 muted: boolean;
1286 unread: number;
1287 mentions: number;
1288};
1289
1290/** A conversation with a mention of you, or a direct message you haven't read. */
1291export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null {
1292 if (entry.muted) return null;
1293 if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null;
1294 const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`;
1295 const dm = entry.channel.kind === "dm";
1296 return {
1297 key: `chat:${entry.channel.id}`,
1298 kind: "chat",
1299 title: dm ? entry.title : `#${entry.title}`,
1300 detail: dm ? "A direct message you haven't read." : "You were mentioned.",
1301 stake: {
1302 text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`,
1303 tone: entry.mentions > 0 ? "accent" : null,
1304 },
1305 action: { label: "Reply", to },
1306 owner: null,
1307 at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0,
1308 from: "chat",
1309 };
1310}
1311
1312/** A member's open request to add something from the Marketplace, for an owner. */
1313export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null {
1314 if (request.status !== "open") return null;
1315 return {
1316 key: `request:${request.id}`,
1317 kind: "install_request",
1318 title: `${request.requested_by} asked to add ${request.name}`,
1319 detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.",
1320 stake: { text: "Only owners can add it", tone: null },
1321 action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` },
1322 owner: { name: request.requested_by, agent: false },
1323 at: Date.parse(request.requested_at),
1324 from: "marketplace",
1325 };
1326}
1327
1328/**
1329 * The workspace's agents at 100% of its monthly agent budget, for those who
1330 * may raise it. `since` is when it was reached as far as is known: the
1331 * newest session that stopped at its cap, else the start of the month.
1332 */
1333export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null {
1334 if (input.alert == null || input.alert < 100) return null;
1335 return {
1336 key: "limit:agents",
1337 kind: "limit",
1338 title: "Agents used up the workspace's budget",
1339 detail: `${money(input.spentMicros)} spent this month. Agents start nothing new until the budget is raised or the month turns.`,
1340 stake: { text: "No new agent work", tone: "danger" },
1341 action: { label: "Raise the budget", to: `/${slug}/-/spend` },
1342 owner: null,
1343 at: input.since,
1344 from: "agents",
1345 };
1346}
1347
1348/** What Needs you and Start here show, and which sources did not answer. */
1349export type Attention = {
1350 rows: AttentionRow[];
1351 start: { row: AttentionRow; why: string } | null;
1352 /** Sources that did not answer, by name, for the card to say so. */
1353 missing: string[];
1354};
1355
1356export function attention(input: {
1357 slug: string;
1358 code: boolean;
1359 codeNeeds: CodeNeed[] | null;
1360 capped: AgentSession[] | null;
1361 agents: AgentLike[] | null;
1362 canManage: boolean;
1363 /** The workspace's agent budget, for those who may manage agents; null for anyone else. */
1364 limit?: { alert: number | null; spentMicros: number; since: number } | null;
1365 /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */
1366 requests?: InstallRequest[] | null;
1367 notifications: InboxItem[] | null;
1368 chat: ChatEntryLike[] | null;
1369}): Attention {
1370 const rows: AttentionRow[] = [];
1371 const missing: string[] = [];
1372 if (input.code) {
1373 if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow));
1374 else missing.push("Code");
1375 }
1376 if (input.limit && input.canManage) {
1377 const row = limitRow(input.limit, input.slug);
1378 if (row) rows.push(row);
1379 }
1380 for (const request of input.requests ?? []) {
1381 const row = installRequestRow(request, input.slug);
1382 if (row) rows.push(row);
1383 }
1384 if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug)));
1385 if (input.agents) {
1386 const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle));
1387 for (const agent of input.agents) {
1388 if (agent.status === "out_of_budget" && !input.canManage) continue;
1389 if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue;
1390 const row = agentRow(agent, input.slug);
1391 if (row) rows.push(row);
1392 }
1393 }
1394 if (input.capped == null || input.agents == null) missing.push("Agents");
1395 if (input.notifications) {
1396 for (const item of input.notifications) {
1397 const row = notificationRow(item, input.slug, input.code);
1398 if (row) rows.push(row);
1399 }
1400 } else missing.push("Notifications");
1401 if (input.chat) {
1402 for (const entry of input.chat) {
1403 const row = chatRow(entry, input.slug);
1404 if (row) rows.push(row);
1405 }
1406 } else missing.push("Chat");
1407 const ranked = rankAttention(rows);
1408 return { rows: ranked, start: startHere(ranked), missing };
1409}
1410
1411// --- Spend in the span ----------------------------------------------------------
1412
1413/** Money in, which the statement lists but which is not spend. */
1414const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]);
1415
1416export type Spend = {
1417 /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */
1418 from: string;
1419 to: string;
1420 /** Usage at price over those days, in micros. */
1421 totalMicros: number;
1422 /** A line per kind of charge, as the statement names it, most first. */
1423 lines: { kind: string; micros: number; count: number }[];
1424 /** Usage at price this month so far, the top bar's and Usage's figure; null when it could not be read. */
1425 monthMicros: number | null;
1426};
1427
1428/** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */
1429export function spanMonths(span: Pick<Span, "from" | "now">): string[] {
1430 const months: string[] = [];
1431 const first = new Date(span.from);
1432 let year = first.getUTCFullYear();
1433 let month = first.getUTCMonth();
1434 const last = new Date(span.now).toISOString().slice(0, 7);
1435 for (let i = 0; i < 3; i++) {
1436 const key = `${year}-${String(month + 1).padStart(2, "0")}`;
1437 months.push(key);
1438 if (key >= last) break;
1439 month++;
1440 if (month === 12) {
1441 month = 0;
1442 year++;
1443 }
1444 }
1445 return months;
1446}
1447
1448/**
1449 * The span's spend from the statements grouped by day: each usage line at
1450 * its price (what was charged, plus what the plan, a trial, a pool or a
1451 * discount paid of it), leaving out money in. The statement keeps whole
1452 * UTC days, so the span is counted from the start of the UTC day it began.
1453 * `statements` is the months of `spanMonths`, the current one last.
1454 * `monthMicros` is this month so far as billing's usage report counts it,
1455 * the month's usage not yet closed included: the figure the top bar's
1456 * pill, Spend and Usage show, so Home shows no other.
1457 */
1458export function spendIn(statements: Pick<Statement, "groups" | "totals">[], span: Pick<Span, "from" | "now">, monthMicros: number | null): Spend {
1459 const from = new Date(span.from).toISOString().slice(0, 10);
1460 const to = new Date(span.now).toISOString().slice(0, 10);
1461 const byKind = new Map<string, { micros: number; count: number }>();
1462 for (const statement of statements) {
1463 for (const group of statement.groups) {
1464 if (group.key < from || group.key > to) continue;
1465 for (const line of group.lines) {
1466 if (MONEY_IN.has(line.kind)) continue;
1467 const micros = line.priceMicros ?? line.chargedMicros;
1468 const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 };
1469 byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count });
1470 }
1471 }
1472 }
1473 const lines = [...byKind]
1474 .map(([kind, value]) => ({ kind, ...value }))
1475 .filter((line) => line.micros > 0)
1476 .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind));
1477 return {
1478 from,
1479 to,
1480 totalMicros: lines.reduce((sum, line) => sum + line.micros, 0),
1481 lines,
1482 monthMicros,
1483 };
1484}
1485
1486// --- The sentence -----------------------------------------------------------
1487
1488/**
1489 * The sentence under the heading, honest about what happened: "Since
1490 * Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents
1491 * finished 7 tasks, and 12 changes landed." What people did comes first
1492 * (commits and merges; else what they said), then the agents' tasks that
1493 * settled well (accepted, fixed, sessions finished), then everything that
1494 * landed. When nothing at all was counted: "Quiet since Tuesday evening:
1495 * nothing pushed, merged, deployed or said." When what people did could
1496 * not be read, it says so.
1497 */
1498export function spanSentence(span: Pick<Span, "words">, digest: Pick<Digest, "summary"> | null, landed: number | null): string {
1499 const lead = span.words;
1500 if (!digest) return `${lead}, what happened couldn't be read.`;
1501 const { people: count, commits, merged, finished, dropped, said, anything, peopleRead } = digest.summary;
1502 const landedCount = landed ?? 0;
1503 if (!anything && landedCount === 0) {
1504 if (!peopleRead) return `${lead}, what people did couldn't be read, and no agent task settled.`;
1505 return `Quiet ${lead.charAt(0).toLowerCase()}${lead.slice(1)}: nothing pushed, merged, deployed or said.`;
1506 }
1507 const parts: string[] = [];
1508 if (count > 0) {
1509 if (commits > 0 || merged > 0) {
1510 const did: string[] = [];
1511 if (commits > 0) did.push(`pushed ${plural(commits, "commit")}`);
1512 if (merged > 0) did.push(`merged ${plural(merged, "change")}`);
1513 parts.push(`${people(count)} ${did.join(" and ")}`);
1514 } else if (said > 0) parts.push(`${people(count)} sent ${plural(said, "message")}`);
1515 else parts.push(`${people(count)} ${count === 1 ? "was" : "were"} active`);
1516 }
1517 if (finished > 0) parts.push(`agents finished ${plural(finished, "task")}`);
1518 else if (dropped > 0) parts.push(`agents finished none of ${plural(dropped, "task")}`);
1519 if (landedCount > 0) parts.push(`${plural(landedCount, "change")} landed`);
1520 const tail = peopleRead ? "" : "; what people did couldn't be read";
1521 if (parts.length === 0) return `${lead}, work went on, but nothing landed and no agent task finished${tail}.`;
1522 const joined = parts.length > 2 ? `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}` : parts.join(" and ");
1523 return `${lead}, ${joined}${tail}.`;
1524}
1525
1526/** "8 things need you.", or that nothing does. */
1527export function waitingSentence(total: number): string {
1528 if (total === 0) return "Nothing needs you right now.";
1529 return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`;
1530}
1531
1532/** How long something has waited: "45 min", "17 h", "2 d". */
1533export function waited(at: number, now: number): string {
1534 const minutes = Math.max(0, Math.floor((now - at) / MINUTE));
1535 if (minutes < 60) return `${minutes} min`;
1536 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`;
1537 return `${Math.floor(minutes / (24 * 60))} d`;
1538}
1539
1540/** "3:40 PM" when it is the same day as `now`, else "Oct 7". */
1541export function whenShort(at: number, now: number, timeZone: string | null): string {
1542 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
1543 return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone);
1544}