Skip to content
1,553 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 || isRobotKey(key) || agents?.has(key) ? "agent" : "person";
385}
386
387/**
388 * A member key that names a robot under `user:`: an agent by its own id,
389 * or g1t's upkeep as the workspace or as `g1t`. Events normalise these
390 * (services/events digest), so this is the safety net for older ones.
391 */
392export function isRobotKey(key: string): boolean {
393 return key.startsWith("user:agt_") || key.startsWith("user:wsp_") || key === "user:g1t";
394}
395
396/** Whether a username is an agent's: g1t, or an agent account. */
397export function isAgentName(name: string | null | undefined): boolean {
398 return name === "g1t" || (name != null && name.endsWith("-agent"));
399}
400
401/** A repository as the digest's links and lines need it. */
402export type RepoRef = RepoPath & { defaultBranch: string };
403
404/** Someone who did something in the span: for the faces beside a column. */
405export type DigestActor = {
406 key: string;
407 kind: ActorKind;
408 /** Their username or handle; `someone` when it could not be read. */
409 name: string;
410 /** How many things they did, over every line: the order of the faces. */
411 count: number;
412};
413
414/** One line of a column: what was done, with its count, and where that is listed. */
415export type DigestLine = {
416 key: string;
417 text: string;
418 to: string | null;
419};
420
421export type DigestColumn = {
422 kind: ActorKind;
423 lines: DigestLine[];
424 /** Most active first. */
425 actors: DigestActor[];
426};
427
428/** What the sentence under the heading needs. */
429export type DigestSummary = {
430 /** Distinct people who did anything. */
431 people: number;
432 /** Commits people pushed. */
433 commits: number;
434 /** Pull requests people merged. */
435 merged: number;
436 /** Agent tasks that settled well, and that did not. */
437 finished: number;
438 dropped: number;
439 /** Messages people sent. */
440 said: number;
441 /** Whether anything at all was counted, in either column. */
442 anything: boolean;
443 /** Whether what people did could be read at all (events and chat both silent: no). */
444 peopleRead: boolean;
445};
446
447export type Digest = {
448 people: DigestColumn;
449 agents: DigestColumn;
450 summary: DigestSummary;
451 /** Sources that did not answer, by name, for the card to say so: Code, Chat, Agents. */
452 missing: string[];
453 /** True when a source was cut short, so some counts are low. */
454 partial: boolean;
455};
456
457export type DigestInput = {
458 slug: string;
459 /** The workspace's repositories by id: for links, and to count projects. */
460 repos: Record<string, RepoRef>;
461 /** Events' digest; null when events did not answer. Without Code access it has no repositories. */
462 activity: ActivityDigest | null;
463 /** Chat's; null when chat did not answer. */
464 chat: ChatActivity | null;
465 /** Names for member keys: a username, or an agent's handle. */
466 names: Record<string, string>;
467 /** Keys of accounts that are agents, beyond g1t and the workspace's agents. */
468 agentKeys?: ReadonlySet<string>;
469 /** The agents' tasks, for their column; null when neither Code nor Agents answered. */
470 work: SpanWork | null;
471 code: boolean;
472};
473
474const people = (n: number) => plural(n, "person", "people");
475
476/** `a, b and c`. */
477export const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
478
479/** What one kind did, from a tally: how many, and by whom (non-empty keys). */
480function part(counts: ActorCount[], kind: ActorKind, agents?: ReadonlySet<string>): { count: number; actors: Map<string, number> } {
481 const actors = new Map<string, number>();
482 let count = 0;
483 for (const entry of counts) {
484 if (actorKind(entry.actor, agents) !== kind) continue;
485 count += entry.count;
486 if (entry.actor) actors.set(entry.actor, (actors.get(entry.actor) ?? 0) + entry.count);
487 }
488 return { count, actors };
489}
490
491/** Where a line points: the one project's page, or the workspace's projects when several. */
492function placeFor(slug: string, repos: Record<string, RepoRef>, repoIds: Iterable<string>, page: string): string {
493 const ids = [...new Set(repoIds)];
494 const only = ids.length === 1 ? repos[ids[0]!] : null;
495 return only ? `/${only.namespace}/${only.name}/${page}` : `/${slug}/-/repositories`;
496}
497
498/**
499 * One column: what people, or agents, did in the span, as lines with
500 * counts and links, and who did it. Pure: everything comes from the
501 * services' counts and the names looked up for them.
502 */
503export function digestColumn(kind: ActorKind, input: DigestInput): DigestColumn {
504 const { slug, repos, activity, chat, names, agentKeys, work } = input;
505 const lines: DigestLine[] = [];
506 const did = new Map<string, number>();
507 const credit = (actors: Map<string, number>) => {
508 for (const [key, n] of actors) did.set(key, (did.get(key) ?? 0) + n);
509 };
510
511 // The agents' tasks come first in their column: what Home has always counted.
512 if (kind === "agent" && work) {
513 const { accepted, fixed, finished, dropped } = work.counts;
514 const done = accepted + fixed + finished;
515 if (done > 0 || dropped > 0) {
516 const text = done > 0 ? `finished ${plural(done, "task")}${dropped > 0 ? `, ${dropped.toLocaleString("en-US")} didn't finish` : ""}` : `finished none of ${plural(dropped, "task")}`;
517 lines.push({ key: "tasks", text, to: `/${slug}/-/agents` });
518 }
519 const sessions = work.tasks.filter((task) => task.source !== "code").length;
520 if (sessions > 0) lines.push({ key: "sessions", text: `ran ${plural(sessions, "session")}`, to: `/${slug}/-/agents` });
521 }
522
523 if (activity) {
524 // Pushes: commits, projects, and who.
525 const pushers = new Map<string, number>();
526 const pushed = new Set<string>();
527 let commits = 0;
528 let pushes = 0;
529 for (const repo of activity.repos) {
530 let any = false;
531 for (const by of repo.pushes.by) {
532 if (actorKind(by.actor, agentKeys) !== kind) continue;
533 any = true;
534 commits += by.commits;
535 pushes += by.count;
536 if (by.actor) pushers.set(by.actor, (pushers.get(by.actor) ?? 0) + by.count);
537 }
538 if (any) pushed.add(repo.repo_id);
539 }
540 if (pushes > 0) {
541 const where = `to ${plural(pushed.size, "project")}`;
542 const text =
543 kind === "person"
544 ? pushers.size > 0
545 ? `${people(pushers.size)} pushed ${plural(commits, "commit")} ${where}`
546 : `${plural(commits, "commit")} pushed ${where}`
547 : `pushed ${plural(commits, "commit")} ${where}`;
548 lines.push({ key: "pushes", text, to: placeFor(slug, repos, pushed, "activity") });
549 credit(pushers);
550 }
551
552 // Pull requests, reviews, issues, comments, deploys, releases, packages: summed over projects.
553 const sum = (pick: (repo: RepoDigest) => ActorCount[]) => {
554 const total = { count: 0, actors: new Map<string, number>(), repoIds: [] as string[] };
555 for (const repo of activity.repos) {
556 const { count, actors } = part(pick(repo), kind, agentKeys);
557 if (count === 0) continue;
558 total.count += count;
559 total.repoIds.push(repo.repo_id);
560 for (const [key, n] of actors) total.actors.set(key, (total.actors.get(key) ?? 0) + n);
561 }
562 return total;
563 };
564 const opened = sum((repo) => repo.pulls.opened);
565 const merged = sum((repo) => repo.pulls.merged);
566 const closed = sum((repo) => repo.pulls.closed);
567 if (opened.count + merged.count + closed.count > 0) {
568 const parts: string[] = [];
569 if (opened.count > 0) parts.push(`opened ${plural(opened.count, "pull request")}`);
570 if (merged.count > 0) parts.push(`merged ${opened.count > 0 ? merged.count.toLocaleString("en-US") : plural(merged.count, "pull request")}`);
571 if (closed.count > 0) parts.push(`closed ${closed.count.toLocaleString("en-US")} without merging`);
572 lines.push({ key: "pulls", text: parts.join(", "), to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds], "pulls") });
573 credit(opened.actors);
574 credit(merged.actors);
575 credit(closed.actors);
576 }
577 const reviews = sum((repo) => repo.reviews);
578 if (reviews.count > 0) {
579 lines.push({ key: "reviews", text: `reviewed ${plural(reviews.count, "pull request")}`, to: placeFor(slug, repos, reviews.repoIds, "pulls") });
580 credit(reviews.actors);
581 }
582 const issuesOpened = sum((repo) => repo.issues.opened);
583 const issuesClosed = sum((repo) => repo.issues.closed);
584 if (issuesOpened.count + issuesClosed.count > 0) {
585 const parts: string[] = [];
586 if (issuesOpened.count > 0) parts.push(`opened ${plural(issuesOpened.count, "issue")}`);
587 if (issuesClosed.count > 0) parts.push(`closed ${issuesOpened.count > 0 ? issuesClosed.count.toLocaleString("en-US") : plural(issuesClosed.count, "issue")}`);
588 lines.push({ key: "issues", text: parts.join(", "), to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds], "issues") });
589 credit(issuesOpened.actors);
590 credit(issuesClosed.actors);
591 }
592 const comments = sum((repo) => repo.comments);
593 if (comments.count > 0) {
594 lines.push({ key: "comments", text: `left ${plural(comments.count, "comment")}`, to: placeFor(slug, repos, comments.repoIds, "issues") });
595 credit(comments.actors);
596 }
597 const succeeded = sum((repo) => repo.deployments.succeeded);
598 const failed = sum((repo) => repo.deployments.failed);
599 if (succeeded.count + failed.count > 0) {
600 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`;
601 lines.push({ key: "deploys", text, to: placeFor(slug, repos, [...succeeded.repoIds, ...failed.repoIds], "deployments") });
602 credit(succeeded.actors);
603 credit(failed.actors);
604 }
605 const releases = sum((repo) => tallyOf(repo.releases.map((release) => release.actor)));
606 if (releases.count > 0) {
607 lines.push({ key: "releases", text: `published ${plural(releases.count, "release")}`, to: placeFor(slug, repos, releases.repoIds, "releases") });
608 credit(releases.actors);
609 }
610 const packages = sum((repo) => tallyOf(repo.packages.map((pkg) => pkg.actor)));
611 if (packages.count > 0) {
612 lines.push({ key: "packages", text: `published ${plural(packages.count, "package version")}`, to: `/${slug}/-/packages` });
613 credit(packages.actors);
614 }
615
616 // Artifacts: made, and whose content changed.
617 if (activity.folios) {
618 const made = part(activity.folios.created, kind, agentKeys);
619 const edited = new Set<string>();
620 const editors = new Map<string, number>();
621 let allDocs = true;
622 for (const folio of activity.folios.edited) {
623 const mine = folio.authors.filter((author) => actorKind(author, agentKeys) === kind);
624 if (mine.length === 0) continue;
625 edited.add(folio.folio_id);
626 if (folio.kind !== "doc") allDocs = false;
627 for (const author of mine) editors.set(author, (editors.get(author) ?? 0) + 1);
628 }
629 const what = (n: number) => plural(n, allDocs ? "doc" : "artifact");
630 if (made.count + edited.size > 0) {
631 const parts: string[] = [];
632 if (made.count > 0) parts.push(`created ${what(made.count)}`);
633 if (edited.size > 0) parts.push(`edited ${what(edited.size)}`);
634 lines.push({ key: "docs", text: parts.join(", "), to: `/${slug}/-/artifacts` });
635 credit(made.actors);
636 credit(editors);
637 }
638 }
639 }
640
641 // Chat: what each kind said.
642 if (chat) {
643 const said = part(
644 chat.authors.map((author) => ({ actor: author.key, count: author.messages })),
645 kind,
646 agentKeys,
647 );
648 if (said.count > 0) {
649 const text = kind === "person" ? `sent ${plural(said.count, "message")} in ${plural(chat.channels, "conversation")}` : `replied ${plural(said.count, "time")} in chat`;
650 lines.push({ key: "chat", text, to: `/${slug}/-/chat` });
651 credit(said.actors);
652 }
653 }
654
655 const actors: DigestActor[] = [...did]
656 .map(([key, count]) => ({ key, kind, name: names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"), count }))
657 .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
658 return { kind, lines, actors };
659}
660
661/** A tally with one of each actor, for lists that name an actor per item. */
662function tallyOf(actors: string[]): ActorCount[] {
663 const counts = new Map<string, number>();
664 for (const actor of actors) counts.set(actor, (counts.get(actor) ?? 0) + 1);
665 return [...counts].map(([actor, count]) => ({ actor, count }));
666}
667
668/** Both columns, what the sentence needs, and which sources were silent. */
669export function digestOf(input: DigestInput): Digest {
670 const people = digestColumn("person", input);
671 const agents = digestColumn("agent", input);
672 const missing: string[] = [];
673 if (input.code && !input.activity) missing.push("Code");
674 if (!input.chat) missing.push("Chat");
675 if (!input.work) missing.push("Agents");
676 let commits = 0;
677 let merged = 0;
678 for (const repo of input.activity?.repos ?? []) {
679 for (const by of repo.pushes.by) if (actorKind(by.actor, input.agentKeys) === "person") commits += by.commits;
680 merged += part(repo.pulls.merged, "person", input.agentKeys).count;
681 }
682 const said = part(
683 (input.chat?.authors ?? []).map((author) => ({ actor: author.key, count: author.messages })),
684 "person",
685 input.agentKeys,
686 ).count;
687 const counts = input.work?.counts;
688 return {
689 people,
690 agents,
691 summary: {
692 people: people.actors.length,
693 commits,
694 merged,
695 finished: counts ? counts.accepted + counts.fixed + counts.finished : 0,
696 dropped: counts?.dropped ?? 0,
697 said,
698 anything: people.lines.length > 0 || agents.lines.length > 0,
699 peopleRead: input.activity != null || input.chat != null,
700 },
701 missing,
702 partial: (input.activity != null && !input.activity.complete) || (input.work?.partial ?? false),
703 };
704}
705
706// --- What landed in the span -----------------------------------------------------
707
708/**
709 * A change that landed: a pull request merged; commits pushed straight to
710 * a default branch; a production build that went live; a release or a
711 * package version published. By a person or an agent.
712 */
713export type LandedChange = {
714 key: string;
715 kind: "merge" | "push" | "deploy" | "release" | "package";
716 title: string;
717 /** One line under it: where, and by whom. */
718 detail: string;
719 to: string;
720 at: number;
721 /** An agent's doing, or a person's. */
722 agent: boolean;
723};
724
725/** Every pull request that merged in the span, newest first. */
726export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
727 return pulls
728 .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))
729 .map((pull) => {
730 const agent = isAgentPull(pull);
731 const mergedBy = pull.mergedBy && pull.mergedBy !== pull.author.username ? `, merged by ${pull.mergedBy}` : "";
732 return {
733 key: `landed:${pullKey(pull.repo, pull.number)}`,
734 kind: "merge" as const,
735 title: pull.title,
736 detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`,
737 to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
738 at: Date.parse(pull.mergedAt!),
739 agent,
740 };
741 })
742 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
743}
744
745/** The names behind member keys, for a line: `ana and bo`, `@margo`, `a mirror` for nobody. */
746function namesOf(keys: string[], names: Record<string, string>, agentKeys?: ReadonlySet<string>): string {
747 const shown = keys.map((key) => {
748 if (!key) return "a mirror";
749 const name = names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone");
750 return actorKind(key, agentKeys) === "agent" ? `@${name}` : name;
751 });
752 return listOf([...new Set(shown)]);
753}
754
755/**
756 * Commits pushed straight to a default branch, one row per project with
757 * any: what landed without a pull request. Releases and package versions
758 * published, one row each. Pushes within the span only: the digest was
759 * asked for the span.
760 */
761export function landedFromActivity(activity: ActivityDigest | null, repos: Record<string, RepoRef>, names: Record<string, string>, slug: string, agentKeys?: ReadonlySet<string>): LandedChange[] {
762 const rows: LandedChange[] = [];
763 for (const repo of activity?.repos ?? []) {
764 const path = repos[repo.repo_id];
765 if (!path) continue;
766 const direct = repo.pushes.default_branch;
767 if (direct.count > 0 && direct.last_at) {
768 const by = direct.by.map((entry) => entry.actor);
769 rows.push({
770 key: `push:${repo.repo_id}`,
771 kind: "push",
772 title: `${plural(direct.commits, "commit")} pushed to ${path.defaultBranch}`,
773 detail: `${path.name} · ${plural(direct.count, "push", "pushes")} by ${namesOf(by, names, agentKeys)}`,
774 to: `/${path.namespace}/${path.name}/activity`,
775 at: Date.parse(direct.last_at),
776 agent: by.length > 0 && by.every((key) => key && actorKind(key, agentKeys) === "agent"),
777 });
778 }
779 for (const release of repo.releases) {
780 rows.push({
781 key: `release:${repo.repo_id}:${release.tag}`,
782 kind: "release",
783 title: release.name ? `${release.tag} · ${release.name}` : release.tag,
784 detail: `${path.name} · released by ${namesOf([release.actor], names, agentKeys)}`,
785 to: `/${path.namespace}/${path.name}/releases/tag/${encodeURIComponent(release.tag)}`,
786 at: Date.parse(release.at),
787 agent: actorKind(release.actor, agentKeys) === "agent",
788 });
789 }
790 for (const pkg of repo.packages) {
791 rows.push({
792 key: `package:${repo.repo_id}:${pkg.ecosystem}:${pkg.name}:${pkg.version}`,
793 kind: "package",
794 title: `${pkg.name} ${pkg.version}`,
795 detail: `${pkg.ecosystem} package · published by ${namesOf([pkg.actor], names, agentKeys)}`,
796 to: `/${slug}/-/packages`,
797 at: Date.parse(pkg.at),
798 agent: actorKind(pkg.actor, agentKeys) === "agent",
799 });
800 }
801 }
802 return rows;
803}
804
805/** Production builds that went live in the span, as landed changes; previews stay in Deploys. */
806export function landedDeploys(deploys: DeployRow[]): LandedChange[] {
807 return deploys
808 .filter((row) => row.kind === "production" && row.outcome === "live")
809 .map((row) => ({
810 key: `landed:${row.key}`,
811 kind: "deploy" as const,
812 title: `Production of ${row.project} went live`,
813 detail: `${row.commit} · by ${row.by}`,
814 to: row.to,
815 at: row.at,
816 agent: isAgentName(row.by),
817 }));
818}
819
820/** Everything that landed, newest first. */
821export function landed(parts: LandedChange[][]): LandedChange[] {
822 return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
823}
824
825/** A build that finished in the span. */
826export type DeployRow = {
827 key: string;
828 project: string;
829 kind: Deployment["kind"];
830 /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */
831 outcome: "live" | "failed";
832 commit: string;
833 branch: string | null;
834 at: number;
835 to: string;
836 by: string;
837};
838
839/**
840 * Builds that finished in the span, newest first: production builds each,
841 * and previews too. A build that went live and was replaced since still
842 * went live. Skipped builds are left out.
843 */
844export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">): DeployRow[] {
845 const rows: DeployRow[] = [];
846 for (const project of projects) {
847 for (const deploy of project.deployments) {
848 const ended = deploy.finishedAt ?? null;
849 if (!within(ended, span)) continue;
850 const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null;
851 if (!outcome) continue;
852 rows.push({
853 key: `deploy:${project.slug}:${deploy.id}`,
854 project: project.slug,
855 kind: deploy.kind,
856 outcome,
857 commit: deploy.commit.slice(0, 7),
858 branch: deploy.branch,
859 at: Date.parse(ended!),
860 to: `/${workspace}/${project.slug}/deployments/${deploy.id}`,
861 by: deploy.createdBy,
862 });
863 }
864 }
865 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
866}
867
868/** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */
869export type DecisionRow = {
870 key: string;
871 text: string;
872 by: string | null;
873 at: number;
874 to: string;
875 kind: "memory" | "request";
876};
877
878export function decisionsIn(
879 input: { memories: Memory[] | null; requests: InstallRequest[] | null },
880 slug: string,
881 span: Pick<Span, "from" | "now">,
882): DecisionRow[] {
883 const rows: DecisionRow[] = [];
884 for (const memory of input.memories ?? []) {
885 if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue;
886 rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" });
887 }
888 for (const request of input.requests ?? []) {
889 if (request.status === "open" || !within(request.resolved_at, span)) continue;
890 rows.push({
891 key: `request:${request.id}`,
892 text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`,
893 by: request.resolved_by,
894 at: Date.parse(request.resolved_at!),
895 to: `/${slug}/-/marketplace/requests`,
896 kind: "request",
897 });
898 }
899 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
900}
901
902// --- Running now ----------------------------------------------------------------
903
904/** Something going right now: a session, an agent's change, a workflow run, a build. */
905export type RunningRow = {
906 key: string;
907 kind: "session" | "change" | "workflow" | "deploy";
908 title: string;
909 /** One line: who and where. */
910 detail: string;
911 /** "Working", "Queued", "Building". */
912 status: string;
913 /** Epoch ms: when it started. */
914 at: number;
915 to: string;
916};
917
918const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" };
919
920/** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */
921export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] {
922 return sessions
923 .filter((session) => !session.parent_id && SESSION_STATUS[session.status])
924 .map((session) => ({
925 key: `session:${session.id}`,
926 kind: "session" as const,
927 title: session.visible ? session.title : "A session in a conversation you're not in",
928 detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`,
929 status: SESSION_STATUS[session.status]!,
930 at: Date.parse(session.created_at),
931 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
932 }));
933}
934
935/** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */
936export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string };
937
938export function runningChanges(changes: AgentChange[]): RunningRow[] {
939 return changes.map((change) => ({
940 key: `change:${change.key}`,
941 kind: "change" as const,
942 title: change.title,
943 detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`,
944 status: change.chip,
945 at: change.at,
946 to: change.to,
947 }));
948}
949
950/** Builds queued or going now, one per project (its newest). */
951export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] {
952 return overview.flatMap((project) => {
953 const latest = project.latest;
954 if (!latest || (latest.status !== "queued" && latest.status !== "building")) return [];
955 return [
956 {
957 key: `deploy:${project.slug}:${latest.id}`,
958 kind: "deploy" as const,
959 title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`,
960 detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`,
961 status: latest.status === "queued" ? "Queued" : "Building",
962 at: Date.parse(latest.createdAt),
963 to: `/${slug}/${project.slug}/deployments/${latest.id}`,
964 },
965 ];
966 });
967}
968
969const RUN_STATUS: Partial<Record<WorkflowRun["status"], string>> = {
970 pending: "Queued",
971 queued: "Queued",
972 action_required: "Needs approval",
973 in_progress: "Running",
974 waiting: "Waiting",
975};
976
977/** Workflow runs not finished, in the projects read: queued, waiting, running, or waiting for approval. */
978export function runningWorkflows(projects: { repo: RepoPath; runs: WorkflowRun[] }[]): RunningRow[] {
979 return projects.flatMap(({ repo, runs }) =>
980 runs.flatMap((run) => {
981 const status = RUN_STATUS[run.status];
982 if (!status) return [];
983 return [
984 {
985 key: `workflow:${repo.namespace}/${repo.name}:${run.id}`.toLowerCase(),
986 kind: "workflow" as const,
987 title: run.title ? `${run.name}: ${run.title}` : run.name,
988 detail: `${repo.namespace}/${repo.name} ${run.ref.replace(/^refs\/heads\//, "")}${run.actor ? ` · by ${run.actor}` : ""}`,
989 status,
990 at: Date.parse(run.startedAt ?? run.createdAt),
991 to: `/${repo.namespace}/${repo.name}/actions/runs/${run.id}`,
992 },
993 ];
994 }),
995 );
996}
997
998/** Everything running, longest-going first within each kind: sessions, then changes, then workflow runs, then builds. */
999export function running(parts: RunningRow[][]): RunningRow[] {
1000 const order = { session: 0, change: 1, workflow: 2, deploy: 3 } as const;
1001 return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
1002}
1003
1004// --- Needs you ------------------------------------------------------------------
1005
1006/**
1007 * What a row is. Tiers, most pressing first:
1008 * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`);
1009 * 1. agent work that has stopped and can't go on without you;
1010 * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something);
1011 * 3. something stuck that may need a look;
1012 * 4. an unread notification that is a warning or a failure;
1013 * 5. a mention or a direct message.
1014 */
1015export type AttentionKind =
1016 | "limit"
1017 | "deploy"
1018 | "session_cap"
1019 | "agent_budget"
1020 | "pull_blocked"
1021 | "ready"
1022 | "review"
1023 | "invitation"
1024 | "install_request"
1025 | "agent_waiting"
1026 | "stuck"
1027 | "runner"
1028 | "notification"
1029 | "chat";
1030
1031export const TIER: Record<AttentionKind, number> = {
1032 limit: 0,
1033 deploy: 0,
1034 session_cap: 1,
1035 agent_budget: 1,
1036 pull_blocked: 1,
1037 ready: 2,
1038 review: 2,
1039 invitation: 2,
1040 install_request: 2,
1041 agent_waiting: 2,
1042 stuck: 3,
1043 runner: 3,
1044 notification: 4,
1045 chat: 5,
1046};
1047
1048export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
1049
1050/** One row of Needs you. */
1051export type AttentionRow = {
1052 key: string;
1053 kind: AttentionKind;
1054 title: string;
1055 /** One line: what it is and where. */
1056 detail: string;
1057 /** What is at stake, in a few words. */
1058 stake: Stake | null;
1059 /** The action, right there. */
1060 action: { label: string; to: string };
1061 /** Who is on it: the agent, or the person who asked. */
1062 owner: { name: string; agent: boolean } | null;
1063 /** Epoch ms: when it started waiting. */
1064 at: number;
1065 /** Where it came from, for a member without Code access never `code`. */
1066 from: "code" | "agents" | "notifications" | "chat" | "marketplace";
1067};
1068
1069/**
1070 * Most pressing first: by tier, then what has waited longest, then by key
1071 * so the order never depends on the order rows were read. One of each key,
1072 * and one of each place: a notification about a pull request already on
1073 * the list is left out.
1074 */
1075export function rankAttention(rows: AttentionRow[]): AttentionRow[] {
1076 const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
1077 const keys = new Set<string>();
1078 const places = new Set<string>();
1079 return sorted.filter((row) => {
1080 const place = row.action.to.split(/[?#]/)[0];
1081 if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false;
1082 keys.add(row.key);
1083 places.add(place);
1084 return true;
1085 });
1086}
1087
1088/** Start here: the first row by `rankAttention`, with why it was picked. */
1089export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null {
1090 const row = ranked[0];
1091 if (!row) return null;
1092 const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length;
1093 const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : "";
1094 return { row, why: `${WHY[row.kind]}${tail}` };
1095}
1096
1097const WHY: Record<AttentionKind, string> = {
1098 limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.",
1099 install_request: "A member asked for it, and only an owner can add it or turn it down.",
1100 deploy: "Production is serving an older build until this is fixed, so it comes before anything else.",
1101 session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.",
1102 agent_budget: "An agent used up its budget and takes no new work until it is raised.",
1103 pull_blocked: "An agent stopped on this change and won't go on until a person decides.",
1104 ready: "The work is done and only waits for you to land it.",
1105 review: "You were asked by name, so it waits for your verdict.",
1106 invitation: "It is addressed to you; no one else can answer it.",
1107 agent_waiting: "An agent is waiting on you to go on.",
1108 stuck: "A running agent has stopped reporting and may be stuck.",
1109 runner: "A job is waiting for a runner that is not there.",
1110 notification: "It is the most pressing unread notification.",
1111 chat: "Nothing else is waiting, and this is the oldest message for you.",
1112};
1113
1114// --- Rows from each source ----------------------------------------------------
1115
1116/** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */
1117export type CodeNeed = {
1118 key: string;
1119 reason: string;
1120 repo: RepoPath | null;
1121 ref: string | null;
1122 title: string;
1123 ask: string;
1124 by: { name: string; agent: boolean } | null;
1125 for: string | null;
1126 at: number;
1127 to: string;
1128 facts: { label: string; value: string; tone: string | null }[];
1129};
1130
1131const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null;
1132
1133const REASON_STAKE: Record<string, Stake> = {
1134 blocking: { text: "Blocking", tone: "danger" },
1135 checks_failing: { text: "Checks failing", tone: "danger" },
1136 outside_guardrails: { text: "Over a guardrail", tone: "warn" },
1137 low_confidence: { text: "Low confidence", tone: "warn" },
1138 needs_review: { text: "Needs a verdict", tone: "warn" },
1139 stalled: { text: "Stalled", tone: "warn" },
1140};
1141
1142/** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */
1143export function codeRow(need: CodeNeed): AttentionRow {
1144 const prefix = need.key.split(":")[0];
1145 const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null;
1146 const detail = where ? `${where} · ${need.ask}` : need.ask;
1147 const owner = need.by;
1148 const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const };
1149 switch (prefix) {
1150 case "deploy":
1151 return {
1152 ...base,
1153 kind: "deploy",
1154 stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" },
1155 action: { label: "See the build", to: need.to },
1156 };
1157 case "invitation":
1158 return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } };
1159 case "review": {
1160 const lines = fact(need, "Lines");
1161 return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } };
1162 }
1163 case "runner":
1164 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
1165 case "run": {
1166 const cost = fact(need, "Cost so far");
1167 return {
1168 ...base,
1169 kind: "stuck",
1170 stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" },
1171 action: { label: "Look", to: need.to },
1172 };
1173 }
1174 default: {
1175 if (need.reason === "ready_to_merge") {
1176 const files = fact(need, "Files changed");
1177 return {
1178 ...base,
1179 kind: "ready",
1180 stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" },
1181 action: { label: "Merge", to: need.to },
1182 };
1183 }
1184 return {
1185 ...base,
1186 kind: "pull_blocked",
1187 stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" },
1188 action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to },
1189 };
1190 }
1191 }
1192}
1193
1194/** A session stopped at its spend cap that the viewer may approve more for. */
1195export function sessionCapRow(session: AgentSession, slug: string): AttentionRow {
1196 const cap = session.cap_micros;
1197 return {
1198 key: `session:${session.id}`,
1199 kind: "session_cap",
1200 title: session.visible ? session.title : `A session of @${session.agent_handle}`,
1201 detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`,
1202 stake: { text: cap != null ? `${money(session.charged_micros)} of ${money(cap)} cap` : `${money(session.charged_micros)} spent`, tone: "warn" },
1203 action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` },
1204 owner: { name: session.agent_handle, agent: true },
1205 at: Date.parse(session.updated_at),
1206 from: "agents",
1207 };
1208}
1209
1210export type AgentLike = {
1211 id: string;
1212 handle: string;
1213 display_name: string;
1214 status: string;
1215 spent_month_micros: number;
1216 budget: { monthly_micros: number | null };
1217 updated_at: string;
1218};
1219
1220/** An agent out of budget (for those who may raise it), or waiting on someone. */
1221export function agentRow(agent: AgentLike, slug: string): AttentionRow | null {
1222 const base = {
1223 key: `agent:${agent.id}`,
1224 owner: { name: agent.handle, agent: true },
1225 at: Date.parse(agent.updated_at),
1226 from: "agents" as const,
1227 };
1228 if (agent.status === "out_of_budget") {
1229 const budget = agent.budget.monthly_micros;
1230 return {
1231 ...base,
1232 kind: "agent_budget",
1233 title: `${agent.display_name} is out of budget`,
1234 detail: `@${agent.handle} takes no new work this month until its budget is raised.`,
1235 stake: { text: budget != null ? `${money(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" },
1236 action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` },
1237 };
1238 }
1239 if (agent.status === "waiting") {
1240 return {
1241 ...base,
1242 kind: "agent_waiting",
1243 title: `${agent.display_name} is waiting`,
1244 detail: `@${agent.handle} is waiting before it goes on.`,
1245 stake: null,
1246 action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` },
1247 };
1248 }
1249 return null;
1250}
1251
1252const NOTIFICATION_STAKE: Record<string, string> = {
1253 agent: "Agent waiting",
1254 review_requested: "Review requested",
1255 assign: "Assigned to you",
1256 mention: "Mentioned",
1257 team_mention: "Team mentioned",
1258 ci_activity: "CI activity",
1259 security_alert: "Security alert",
1260 state_change: "State changed",
1261 author: "Your work",
1262 comment: "Comment",
1263 manual: "Subscribed",
1264 subscribed: "Watching",
1265};
1266
1267/**
1268 * An unread notification that is a warning (someone or something is
1269 * waiting) or a failure, in this workspace. A member without Code access
1270 * gets none about a repository.
1271 */
1272export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null {
1273 if (item.readAt != null || item.doneAt != null) return null;
1274 if (item.severity !== "warning" && item.severity !== "error") return null;
1275 const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug;
1276 if (!inWorkspace) return null;
1277 if (!code && item.repo) return null;
1278 return {
1279 key: `notification:${item.id}`,
1280 kind: "notification",
1281 title: item.body || item.title,
1282 detail: item.body ? item.title : (item.repo ?? "A notification"),
1283 stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" },
1284 action: { label: "Open", to: item.url },
1285 owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null,
1286 at: Date.parse(item.updatedAt),
1287 from: "notifications",
1288 };
1289}
1290
1291export type ChatEntryLike = {
1292 channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null };
1293 title: string;
1294 muted: boolean;
1295 unread: number;
1296 mentions: number;
1297};
1298
1299/** A conversation with a mention of you, or a direct message you haven't read. */
1300export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null {
1301 if (entry.muted) return null;
1302 if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null;
1303 const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`;
1304 const dm = entry.channel.kind === "dm";
1305 return {
1306 key: `chat:${entry.channel.id}`,
1307 kind: "chat",
1308 title: dm ? entry.title : `#${entry.title}`,
1309 detail: dm ? "A direct message you haven't read." : "You were mentioned.",
1310 stake: {
1311 text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`,
1312 tone: entry.mentions > 0 ? "accent" : null,
1313 },
1314 action: { label: "Reply", to },
1315 owner: null,
1316 at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0,
1317 from: "chat",
1318 };
1319}
1320
1321/** A member's open request to add something from the Marketplace, for an owner. */
1322export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null {
1323 if (request.status !== "open") return null;
1324 return {
1325 key: `request:${request.id}`,
1326 kind: "install_request",
1327 title: `${request.requested_by} asked to add ${request.name}`,
1328 detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.",
1329 stake: { text: "Only owners can add it", tone: null },
1330 action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` },
1331 owner: { name: request.requested_by, agent: false },
1332 at: Date.parse(request.requested_at),
1333 from: "marketplace",
1334 };
1335}
1336
1337/**
1338 * The workspace's agents at 100% of its monthly agent budget, for those who
1339 * may raise it. `since` is when it was reached as far as is known: the
1340 * newest session that stopped at its cap, else the start of the month.
1341 */
1342export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null {
1343 if (input.alert == null || input.alert < 100) return null;
1344 return {
1345 key: "limit:agents",
1346 kind: "limit",
1347 title: "Agents used up the workspace's budget",
1348 detail: `${money(input.spentMicros)} spent this month. Agents start nothing new until the budget is raised or the month turns.`,
1349 stake: { text: "No new agent work", tone: "danger" },
1350 action: { label: "Raise the budget", to: `/${slug}/-/spend` },
1351 owner: null,
1352 at: input.since,
1353 from: "agents",
1354 };
1355}
1356
1357/** What Needs you and Start here show, and which sources did not answer. */
1358export type Attention = {
1359 rows: AttentionRow[];
1360 start: { row: AttentionRow; why: string } | null;
1361 /** Sources that did not answer, by name, for the card to say so. */
1362 missing: string[];
1363};
1364
1365export function attention(input: {
1366 slug: string;
1367 code: boolean;
1368 codeNeeds: CodeNeed[] | null;
1369 capped: AgentSession[] | null;
1370 agents: AgentLike[] | null;
1371 canManage: boolean;
1372 /** The workspace's agent budget, for those who may manage agents; null for anyone else. */
1373 limit?: { alert: number | null; spentMicros: number; since: number } | null;
1374 /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */
1375 requests?: InstallRequest[] | null;
1376 notifications: InboxItem[] | null;
1377 chat: ChatEntryLike[] | null;
1378}): Attention {
1379 const rows: AttentionRow[] = [];
1380 const missing: string[] = [];
1381 if (input.code) {
1382 if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow));
1383 else missing.push("Code");
1384 }
1385 if (input.limit && input.canManage) {
1386 const row = limitRow(input.limit, input.slug);
1387 if (row) rows.push(row);
1388 }
1389 for (const request of input.requests ?? []) {
1390 const row = installRequestRow(request, input.slug);
1391 if (row) rows.push(row);
1392 }
1393 if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug)));
1394 if (input.agents) {
1395 const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle));
1396 for (const agent of input.agents) {
1397 if (agent.status === "out_of_budget" && !input.canManage) continue;
1398 if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue;
1399 const row = agentRow(agent, input.slug);
1400 if (row) rows.push(row);
1401 }
1402 }
1403 if (input.capped == null || input.agents == null) missing.push("Agents");
1404 if (input.notifications) {
1405 for (const item of input.notifications) {
1406 const row = notificationRow(item, input.slug, input.code);
1407 if (row) rows.push(row);
1408 }
1409 } else missing.push("Notifications");
1410 if (input.chat) {
1411 for (const entry of input.chat) {
1412 const row = chatRow(entry, input.slug);
1413 if (row) rows.push(row);
1414 }
1415 } else missing.push("Chat");
1416 const ranked = rankAttention(rows);
1417 return { rows: ranked, start: startHere(ranked), missing };
1418}
1419
1420// --- Spend in the span ----------------------------------------------------------
1421
1422/** Money in, which the statement lists but which is not spend. */
1423const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]);
1424
1425export type Spend = {
1426 /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */
1427 from: string;
1428 to: string;
1429 /** Usage at price over those days, in micros. */
1430 totalMicros: number;
1431 /** A line per kind of charge, as the statement names it, most first. */
1432 lines: { kind: string; micros: number; count: number }[];
1433 /** Usage at price this month so far, the top bar's and Usage's figure; null when it could not be read. */
1434 monthMicros: number | null;
1435};
1436
1437/** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */
1438export function spanMonths(span: Pick<Span, "from" | "now">): string[] {
1439 const months: string[] = [];
1440 const first = new Date(span.from);
1441 let year = first.getUTCFullYear();
1442 let month = first.getUTCMonth();
1443 const last = new Date(span.now).toISOString().slice(0, 7);
1444 for (let i = 0; i < 3; i++) {
1445 const key = `${year}-${String(month + 1).padStart(2, "0")}`;
1446 months.push(key);
1447 if (key >= last) break;
1448 month++;
1449 if (month === 12) {
1450 month = 0;
1451 year++;
1452 }
1453 }
1454 return months;
1455}
1456
1457/**
1458 * The span's spend from the statements grouped by day: each usage line at
1459 * its price (what was charged, plus what the plan, a trial, a pool or a
1460 * discount paid of it), leaving out money in. The statement keeps whole
1461 * UTC days, so the span is counted from the start of the UTC day it began.
1462 * `statements` is the months of `spanMonths`, the current one last.
1463 * `monthMicros` is this month so far as billing's usage report counts it,
1464 * the month's usage not yet closed included: the figure the top bar's
1465 * pill, Spend and Usage show, so Home shows no other.
1466 */
1467export function spendIn(statements: Pick<Statement, "groups" | "totals">[], span: Pick<Span, "from" | "now">, monthMicros: number | null): Spend {
1468 const from = new Date(span.from).toISOString().slice(0, 10);
1469 const to = new Date(span.now).toISOString().slice(0, 10);
1470 const byKind = new Map<string, { micros: number; count: number }>();
1471 for (const statement of statements) {
1472 for (const group of statement.groups) {
1473 if (group.key < from || group.key > to) continue;
1474 for (const line of group.lines) {
1475 if (MONEY_IN.has(line.kind)) continue;
1476 const micros = line.priceMicros ?? line.chargedMicros;
1477 const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 };
1478 byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count });
1479 }
1480 }
1481 }
1482 const lines = [...byKind]
1483 .map(([kind, value]) => ({ kind, ...value }))
1484 .filter((line) => line.micros > 0)
1485 .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind));
1486 return {
1487 from,
1488 to,
1489 totalMicros: lines.reduce((sum, line) => sum + line.micros, 0),
1490 lines,
1491 monthMicros,
1492 };
1493}
1494
1495// --- The sentence -----------------------------------------------------------
1496
1497/**
1498 * The sentence under the heading, honest about what happened: "Since
1499 * Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents
1500 * finished 7 tasks, and 12 changes landed." What people did comes first
1501 * (commits and merges; else what they said), then the agents' tasks that
1502 * settled well (accepted, fixed, sessions finished), then everything that
1503 * landed. When nothing at all was counted: "Quiet since Tuesday evening:
1504 * nothing pushed, merged, deployed or said." When what people did could
1505 * not be read, it says so.
1506 */
1507export function spanSentence(span: Pick<Span, "words">, digest: Pick<Digest, "summary"> | null, landed: number | null): string {
1508 const lead = span.words;
1509 if (!digest) return `${lead}, what happened couldn't be read.`;
1510 const { people: count, commits, merged, finished, dropped, said, anything, peopleRead } = digest.summary;
1511 const landedCount = landed ?? 0;
1512 if (!anything && landedCount === 0) {
1513 if (!peopleRead) return `${lead}, what people did couldn't be read, and no agent task settled.`;
1514 return `Quiet ${lead.charAt(0).toLowerCase()}${lead.slice(1)}: nothing pushed, merged, deployed or said.`;
1515 }
1516 const parts: string[] = [];
1517 if (count > 0) {
1518 if (commits > 0 || merged > 0) {
1519 const did: string[] = [];
1520 if (commits > 0) did.push(`pushed ${plural(commits, "commit")}`);
1521 if (merged > 0) did.push(`merged ${plural(merged, "change")}`);
1522 parts.push(`${people(count)} ${did.join(" and ")}`);
1523 } else if (said > 0) parts.push(`${people(count)} sent ${plural(said, "message")}`);
1524 else parts.push(`${people(count)} ${count === 1 ? "was" : "were"} active`);
1525 }
1526 if (finished > 0) parts.push(`agents finished ${plural(finished, "task")}`);
1527 else if (dropped > 0) parts.push(`agents finished none of ${plural(dropped, "task")}`);
1528 if (landedCount > 0) parts.push(`${plural(landedCount, "change")} landed`);
1529 const tail = peopleRead ? "" : "; what people did couldn't be read";
1530 if (parts.length === 0) return `${lead}, work went on, but nothing landed and no agent task finished${tail}.`;
1531 const joined = parts.length > 2 ? `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}` : parts.join(" and ");
1532 return `${lead}, ${joined}${tail}.`;
1533}
1534
1535/** "8 things need you.", or that nothing does. */
1536export function waitingSentence(total: number): string {
1537 if (total === 0) return "Nothing needs you right now.";
1538 return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`;
1539}
1540
1541/** How long something has waited: "45 min", "17 h", "2 d". */
1542export function waited(at: number, now: number): string {
1543 const minutes = Math.max(0, Math.floor((now - at) / MINUTE));
1544 if (minutes < 60) return `${minutes} min`;
1545 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`;
1546 return `${Math.floor(minutes / (24 * 60))} d`;
1547}
1548
1549/** "3:40 PM" when it is the same day as `now`, else "Oct 7". */
1550export function whenShort(at: number, now: number, timeZone: string | null): string {
1551 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
1552 return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone);
1553}