Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 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 | */ | |
| 10 | import type { | |
| 11 | AgentSession, | |
| 12 | AgentSessionKind, | |
| 13 | Deployment, | |
| 14 | InboxItem, | |
| 15 | InstallRequest, | |
| 16 | Memory, | |
| 17 | ProjectDeploys, | |
| 18 | Pull, | |
| 19 | RepoPath, | |
| 20 | Statement, | |
| 21 | } from "@g1t/contracts"; | |
| 22 | ||
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 23 | import { money } from "./money.ts"; |
| 24 | ||
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 25 | const MINUTE = 60_000; |
| 26 | const HOUR = 60 * MINUTE; | |
| 27 | const DAY = 24 * HOUR; | |
| 28 | ||
| 29 | /** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */ | |
| 30 | export function dayIn(at: number, timeZone: string | null): string { | |
| 31 | try { | |
| 32 | return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at); | |
| 33 | } catch { | |
| 34 | return new Date(at).toISOString().slice(0, 10); | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | /** The hour of the day (0–23) in a time zone; UTC when it is unknown. */ | |
| 39 | export function hourIn(at: number, timeZone: string | null): number { | |
| 40 | const read = (zone: string) => Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: zone }).format(at)) % 24; | |
| 41 | try { | |
| 42 | return read(timeZone || "UTC"); | |
| 43 | } catch { | |
| 44 | return read("UTC"); | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | function formatIn(at: number, timeZone: string | null, options: Intl.DateTimeFormatOptions): string { | |
| 49 | try { | |
| 50 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(at); | |
| 51 | } catch { | |
| 52 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(at); | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /** "Oct 9", in the reader's time zone. */ | |
| 57 | export function shortDate(at: number, timeZone: string | null): string { | |
| 58 | return formatIn(at, timeZone, { month: "short", day: "numeric" }); | |
| 59 | } | |
| 60 | ||
| 61 | // --- The span ----------------------------------------------------------------- | |
| 62 | ||
| 63 | /** Which span "what happened" covers: since your last visit, or a fixed one. */ | |
| 64 | export type WindowKey = "last" | "24h" | "7d"; | |
| 65 | ||
| 66 | export const WINDOWS: { key: WindowKey; label: string }[] = [ | |
| 67 | { key: "last", label: "Since last visit" }, | |
| 68 | { key: "24h", label: "Last 24 hours" }, | |
| 69 | { key: "7d", label: "Last 7 days" }, | |
| 70 | ]; | |
| 71 | ||
| 72 | /** The furthest back "since your last visit" looks. */ | |
| 73 | export const MAX_AWAY_DAYS = 14; | |
| 74 | ||
| 75 | /** A `?window=` value, or the default: since your last visit. */ | |
| 76 | export function parseWindow(value: string | null | undefined): WindowKey { | |
| 77 | return value === "24h" || value === "7d" ? value : "last"; | |
| 78 | } | |
| 79 | ||
| 80 | export type Span = { | |
| 81 | key: WindowKey; | |
| 82 | /** Epoch ms: the start of the span. */ | |
| 83 | from: number; | |
| 84 | now: number; | |
| 85 | /** "Since Tuesday evening", "In the last 24 hours". */ | |
| 86 | words: string; | |
| 87 | /** How long it covers: "3 days", "45 minutes". */ | |
| 88 | length: string; | |
| 89 | /** | |
| 90 | * Why "since your last visit" covers something else: `first` when there | |
| 91 | * is no visit on record (it shows the last 24 hours), `capped` when the | |
| 92 | * last one was longer ago than MAX_AWAY_DAYS, `unknown` when it could | |
| 93 | * not be read (it shows the last 24 hours). | |
| 94 | */ | |
| 95 | note: "first" | "capped" | "unknown" | null; | |
| 96 | }; | |
| 97 | ||
| 98 | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 99 | ||
| 100 | /** How long a stretch of time is, roughly: "a moment", "40 minutes", "3 hours", "3 days", "2 weeks". */ | |
| 101 | export function duration(ms: number): string { | |
| 102 | const minutes = Math.floor(Math.max(0, ms) / MINUTE); | |
| 103 | if (minutes < 1) return "a moment"; | |
| 104 | if (minutes < 60) return plural(minutes, "minute"); | |
| 105 | const hours = Math.floor(minutes / 60); | |
| 106 | if (hours < 24) return plural(hours, "hour"); | |
| 107 | const days = Math.floor(hours / 24); | |
| 108 | if (days < 14) return plural(days, "day"); | |
| 109 | return plural(Math.floor(days / 7), "week"); | |
| 110 | } | |
| 111 | ||
| 112 | type Part = "night" | "morning" | "afternoon" | "evening"; | |
| 113 | ||
| 114 | /** | |
| 115 | * When `from` was, in words, from `now`: "a moment ago", "40 minutes ago", | |
| 116 | * "2 hours ago", "this morning", "last night", "yesterday evening", | |
| 117 | * "Tuesday evening", "Oct 2". The small hours belong to the night before: | |
| 118 | * 2 a.m. on Wednesday is Tuesday night. | |
| 119 | */ | |
| 120 | export function sinceWords(from: number, now: number, timeZone: string | null): string { | |
| 121 | const ago = now - from; | |
| 122 | if (ago < MINUTE) return "a moment ago"; | |
| 123 | if (ago < HOUR) return `${plural(Math.floor(ago / MINUTE), "minute")} ago`; | |
| 124 | if (ago < 3 * HOUR) return Math.floor(ago / HOUR) === 1 ? "an hour ago" : `${Math.floor(ago / HOUR)} hours ago`; | |
| 125 | const hour = hourIn(from, timeZone); | |
| 126 | const part: Part = hour < 5 ? "night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"; | |
| 127 | // The night is the evening's day's. | |
| 128 | const day = dayIn(part === "night" ? from - 6 * HOUR : from, timeZone); | |
| 129 | const today = dayIn(now, timeZone); | |
| 130 | const yesterday = dayIn(now - DAY, timeZone); | |
| 131 | const twoDaysAgo = dayIn(now - 2 * DAY, timeZone); | |
| 132 | if (day === today) return `this ${part === "night" ? "evening" : part}`; | |
| 133 | if (day === yesterday) return part === "night" ? "last night" : `yesterday ${part}`; | |
| 134 | if (part === "night" && day === twoDaysAgo) return "the night before last"; | |
| 135 | if (ago < 6 * DAY) return `${formatIn(part === "night" ? from - 6 * HOUR : from, timeZone, { weekday: "long" })} ${part}`; | |
| 136 | return shortDate(from, timeZone); | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * The span for `key`: since `lastSeen` (your last visit, kept by notify), | |
| 141 | * or the last 24 hours or 7 days. Since your last visit falls back to the | |
| 142 | * last 24 hours when there is none (`null`) or it could not be read | |
| 143 | * (`undefined`), and to MAX_AWAY_DAYS when it was longer ago. | |
| 144 | */ | |
| 145 | export function spanFor(key: WindowKey, now: number, lastSeen: number | null | undefined, timeZone: string | null): Span { | |
| 146 | if (key === "7d") return { key, from: now - 7 * DAY, now, words: "In the last 7 days", length: "7 days", note: null }; | |
| 147 | if (key === "24h" || lastSeen == null || lastSeen > now) { | |
| 148 | const note = key !== "last" ? null : lastSeen === undefined ? "unknown" : "first"; | |
| 149 | return { key, from: now - DAY, now, words: "In the last 24 hours", length: "24 hours", note }; | |
| 150 | } | |
| 151 | const furthest = now - MAX_AWAY_DAYS * DAY; | |
| 152 | if (lastSeen < furthest) return { key, from: furthest, now, words: `In the last ${MAX_AWAY_DAYS} days`, length: `${MAX_AWAY_DAYS} days`, note: "capped" }; | |
| 153 | return { key, from: lastSeen, now, words: `Since ${sinceWords(lastSeen, now, timeZone)}`, length: duration(now - lastSeen), note: null }; | |
| 154 | } | |
| 155 | ||
| 156 | /** Whether `at` (epoch ms or RFC 3339) falls in the span. */ | |
| 157 | export function within(at: number | string | null | undefined, span: Pick<Span, "from" | "now">): boolean { | |
| 158 | if (at == null) return false; | |
| 159 | const ms = typeof at === "number" ? at : Date.parse(at); | |
| 160 | return Number.isFinite(ms) && ms >= span.from && ms <= span.now; | |
| 161 | } | |
| 162 | ||
| 163 | // --- Agent work in the span --------------------------------------------------- | |
| 164 | ||
| 165 | /** | |
| 166 | * How one piece of agent work that settled in the span went: | |
| 167 | * - `accepted`: an agent's pull request merged without the agent being sent back to revise it. | |
| 168 | * - `fixed`: an agent's pull request merged after one or more revisions. | |
| 169 | * - `finished`: an agent session that finished. Sessions are not reviewed, so they are never `accepted`. | |
| 170 | * - `dropped`: an agent's pull request closed without merging, or a session that failed or was stopped. | |
| 171 | * Work still going is not here: it is under Running now. | |
| 172 | */ | |
| 173 | export type TaskOutcome = "accepted" | "fixed" | "finished" | "dropped"; | |
| 174 | ||
| 175 | /** Where a task came from. */ | |
| 176 | export type TaskSource = "chat" | "schedule" | "colleague" | "code"; | |
| 177 | ||
| 178 | export const OUTCOME_LABEL: Record<TaskOutcome, string> = { | |
| 179 | accepted: "Accepted first time", | |
| 180 | fixed: "Fixed after review", | |
| 181 | finished: "Sessions finished", | |
| 182 | dropped: "Didn't finish", | |
| 183 | }; | |
| 184 | ||
| 185 | export const SOURCE_LABEL: Record<TaskSource, string> = { | |
| 186 | chat: "Chat", | |
| 187 | schedule: "Schedules", | |
| 188 | colleague: "Another agent", | |
| 189 | code: "Code", | |
| 190 | }; | |
| 191 | ||
| 192 | /** One mark on the strip. */ | |
| 193 | export type WorkTask = { | |
| 194 | key: string; | |
| 195 | outcome: TaskOutcome; | |
| 196 | source: TaskSource; | |
| 197 | /** Null for a session in a conversation the viewer is not in. */ | |
| 198 | title: string | null; | |
| 199 | to: string | null; | |
| 200 | /** When it settled: the strip's order. */ | |
| 201 | at: number; | |
| 202 | }; | |
| 203 | ||
| 204 | /** A pull request with where it is: what Home reads from Code. */ | |
| 205 | export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & { | |
| 206 | repo: RepoPath; | |
| 207 | author: { username: string; kind?: string }; | |
| 208 | /** Who merged it, when it merged. */ | |
| 209 | mergedBy?: string | null; | |
| 210 | }; | |
| 211 | ||
| 212 | /** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */ | |
| 213 | export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean { | |
| 214 | const name = pull.author.username; | |
| 215 | return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent"); | |
| 216 | } | |
| 217 | ||
| 218 | /** `acme/web#12`, lowercase: how a pull request and its runs are matched. */ | |
| 219 | export function pullKey(repo: RepoPath, number: number): string { | |
| 220 | return `${repo.namespace}/${repo.name}#${number}`.toLowerCase(); | |
| 221 | } | |
| 222 | ||
| 223 | /** | |
| 224 | * How an agent's pull request settled in the span, or null when it did not | |
| 225 | * settle in it. `revisions` counts its `revise` runs: each time g1t sent | |
| 226 | * the agent back for failed checks or a review. | |
| 227 | */ | |
| 228 | export function pullOutcome(pull: CodePull, revisions: number, span: Pick<Span, "from" | "now">): TaskOutcome | null { | |
| 229 | if (pull.status === "merged") return within(pull.mergedAt, span) ? (revisions > 0 ? "fixed" : "accepted") : null; | |
| 230 | if (pull.status === "closed") return within(pull.updatedAt, span) ? "dropped" : null; | |
| 231 | return null; | |
| 232 | } | |
| 233 | ||
| 234 | const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]); | |
| 235 | ||
| 236 | /** | |
| 237 | * How a session settled in the span, or null. Only a session at the root | |
| 238 | * of its tree counts: the subagents and colleagues it brought in are part | |
| 239 | * of its task. | |
| 240 | */ | |
| 241 | export function sessionOutcome( | |
| 242 | session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">, | |
| 243 | span: Pick<Span, "from" | "now">, | |
| 244 | ): TaskOutcome | null { | |
| 245 | if (session.parent_id || LIVE.has(session.status)) return null; | |
| 246 | if (!within(session.finished_at ?? session.updated_at, span)) return null; | |
| 247 | return session.status === "done" ? "finished" : "dropped"; | |
| 248 | } | |
| 249 | ||
| 250 | export function sessionSource(kind: AgentSessionKind): TaskSource { | |
| 251 | return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague"; | |
| 252 | } | |
| 253 | ||
| 254 | /** The agents' work that settled in the span, and how the 7 days before it compare. */ | |
| 255 | export type SpanWork = { | |
| 256 | tasks: WorkTask[]; | |
| 257 | counts: Record<TaskOutcome, number>; | |
| 258 | /** Tasks by where they came from, most first; only sources with any. */ | |
| 259 | sources: { source: TaskSource; count: number }[]; | |
| 260 | /** | |
| 261 | * Accepted first time: of the agents' pull requests that merged or closed | |
| 262 | * in the span, the share that merged without a revision. Null with none. | |
| 263 | */ | |
| 264 | rate: { value: number; of: number } | null; | |
| 265 | /** The same share over the 7 days before the span; null when there were none, or they could not all be read. */ | |
| 266 | before: { value: number; of: number } | null; | |
| 267 | /** Whether Code was read: false for a member without Code access, or when Code did not answer. */ | |
| 268 | code: "read" | "no_access" | "unavailable"; | |
| 269 | /** Whether sessions were read. */ | |
| 270 | sessions: "read" | "unavailable"; | |
| 271 | /** True when a list was cut short before the start of the span, so some work may be missing. */ | |
| 272 | partial: boolean; | |
| 273 | }; | |
| 274 | ||
| 275 | export type CodeWork = { | |
| 276 | pulls: CodePull[]; | |
| 277 | /** `revise` runs per pull request (`pullKey`). */ | |
| 278 | revisions: Record<string, number>; | |
| 279 | /** Whether every list read reached back to 7 days before the span. */ | |
| 280 | complete: boolean; | |
| 281 | }; | |
| 282 | ||
| 283 | export type SessionWork = { sessions: AgentSession[]; complete: boolean }; | |
| 284 | ||
| 285 | /** Accepted first time over a set of settled outcomes: null when no pull request settled. */ | |
| 286 | export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null { | |
| 287 | const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped"); | |
| 288 | if (settled.length === 0) return null; | |
| 289 | return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length }; | |
| 290 | } | |
| 291 | ||
| 292 | export function spanWork(input: { | |
| 293 | span: Pick<Span, "from" | "now">; | |
| 294 | slug: string; | |
| 295 | code: CodeWork | null | "no_access"; | |
| 296 | sessions: SessionWork | null; | |
| 297 | }): SpanWork { | |
| 298 | const { span, slug } = input; | |
| 299 | const tasks: WorkTask[] = []; | |
| 300 | let before: SpanWork["before"] = null; | |
| 301 | const code = input.code; | |
| 302 | if (code && code !== "no_access") { | |
| 303 | const prior = { from: span.from - 7 * DAY, now: span.from - 1 }; | |
| 304 | const earlier: TaskOutcome[] = []; | |
| 305 | for (const pull of code.pulls) { | |
| 306 | if (!isAgentPull(pull)) continue; | |
| 307 | const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0; | |
| 308 | const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`; | |
| 309 | const outcome = pullOutcome(pull, revisions, span); | |
| 310 | if (outcome) { | |
| 311 | const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt); | |
| 312 | tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at }); | |
| 313 | continue; | |
| 314 | } | |
| 315 | const then = pullOutcome(pull, revisions, prior); | |
| 316 | if (then) earlier.push(then); | |
| 317 | } | |
| 318 | before = code.complete ? acceptance(earlier) : null; | |
| 319 | } | |
| 320 | for (const session of input.sessions?.sessions ?? []) { | |
| 321 | const outcome = sessionOutcome(session, span); | |
| 322 | if (!outcome) continue; | |
| 323 | tasks.push({ | |
| 324 | key: `session:${session.id}`, | |
| 325 | outcome, | |
| 326 | source: sessionSource(session.kind), | |
| 327 | title: session.visible ? session.title : null, | |
| 328 | to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`, | |
| 329 | at: Date.parse(session.finished_at ?? session.updated_at), | |
| 330 | }); | |
| 331 | } | |
| 332 | // In the order they settled. | |
| 333 | tasks.sort((a, b) => a.at - b.at || a.key.localeCompare(b.key)); | |
| 334 | const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0 }; | |
| 335 | const bySource = new Map<TaskSource, number>(); | |
| 336 | for (const task of tasks) { | |
| 337 | counts[task.outcome]++; | |
| 338 | bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1); | |
| 339 | } | |
| 340 | const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"]; | |
| 341 | return { | |
| 342 | tasks, | |
| 343 | counts, | |
| 344 | sources: [...bySource] | |
| 345 | .map(([source, count]) => ({ source, count })) | |
| 346 | .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)), | |
| 347 | rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)), | |
| 348 | before, | |
| 349 | code: code === "no_access" ? "no_access" : code ? "read" : "unavailable", | |
| 350 | sessions: input.sessions ? "read" : "unavailable", | |
| 351 | partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete), | |
| 352 | }; | |
| 353 | } | |
| 354 | ||
| 355 | /** The change in percentage points from the 7 days before, rounded: "+6 pts vs the 7 days before", "Same as the 7 days before". */ | |
| 356 | export function trendLabel(rate: { value: number } | null, before: { value: number } | null): string | null { | |
| 357 | if (!rate || !before) return null; | |
| 358 | const points = Math.round(rate.value * 100) - Math.round(before.value * 100); | |
| 359 | if (points === 0) return "Same as the 7 days before"; | |
| 360 | return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`; | |
| 361 | } | |
| 362 | ||
| 363 | // --- What else happened in the span ------------------------------------------- | |
| 364 | ||
| 365 | /** A change that landed: a pull request merged, by a person or an agent. */ | |
| 366 | export type LandedChange = { | |
| 367 | key: string; | |
| 368 | title: string; | |
| 369 | repo: RepoPath; | |
| 370 | number: number; | |
| 371 | to: string; | |
| 372 | at: number; | |
| 373 | /** An agent's change, or a person's. */ | |
| 374 | agent: boolean; | |
| 375 | author: string; | |
| 376 | mergedBy: string | null; | |
| 377 | }; | |
| 378 | ||
| 379 | /** Every pull request that merged in the span, newest first. */ | |
| 380 | export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] { | |
| 381 | return pulls | |
| 382 | .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span)) | |
| 383 | .map((pull) => ({ | |
| 384 | key: `landed:${pullKey(pull.repo, pull.number)}`, | |
| 385 | title: pull.title, | |
| 386 | repo: pull.repo, | |
| 387 | number: pull.number, | |
| 388 | to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`, | |
| 389 | at: Date.parse(pull.mergedAt!), | |
| 390 | agent: isAgentPull(pull), | |
| 391 | author: pull.author.username, | |
| 392 | mergedBy: pull.mergedBy ?? null, | |
| 393 | })) | |
| 394 | .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 395 | } | |
| 396 | ||
| 397 | /** A build that finished in the span. */ | |
| 398 | export type DeployRow = { | |
| 399 | key: string; | |
| 400 | project: string; | |
| 401 | kind: Deployment["kind"]; | |
| 402 | /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */ | |
| 403 | outcome: "live" | "failed"; | |
| 404 | commit: string; | |
| 405 | branch: string | null; | |
| 406 | at: number; | |
| 407 | to: string; | |
| 408 | by: string; | |
| 409 | }; | |
| 410 | ||
| 411 | /** | |
| 412 | * Builds that finished in the span, newest first: production builds each, | |
| 413 | * and previews too. A build that went live and was replaced since still | |
| 414 | * went live. Skipped builds are left out. | |
| 415 | */ | |
| 416 | export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">): DeployRow[] { | |
| 417 | const rows: DeployRow[] = []; | |
| 418 | for (const project of projects) { | |
| 419 | for (const deploy of project.deployments) { | |
| 420 | const ended = deploy.finishedAt ?? null; | |
| 421 | if (!within(ended, span)) continue; | |
| 422 | const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null; | |
| 423 | if (!outcome) continue; | |
| 424 | rows.push({ | |
| 425 | key: `deploy:${project.slug}:${deploy.id}`, | |
| 426 | project: project.slug, | |
| 427 | kind: deploy.kind, | |
| 428 | outcome, | |
| 429 | commit: deploy.commit.slice(0, 7), | |
| 430 | branch: deploy.branch, | |
| 431 | at: Date.parse(ended!), | |
| 432 | to: `/${workspace}/${project.slug}/deployments/${deploy.id}`, | |
| 433 | by: deploy.createdBy, | |
| 434 | }); | |
| 435 | } | |
| 436 | } | |
| 437 | return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 438 | } | |
| 439 | ||
| 440 | /** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */ | |
| 441 | export type DecisionRow = { | |
| 442 | key: string; | |
| 443 | text: string; | |
| 444 | by: string | null; | |
| 445 | at: number; | |
| 446 | to: string; | |
| 447 | kind: "memory" | "request"; | |
| 448 | }; | |
| 449 | ||
| 450 | export function decisionsIn( | |
| 451 | input: { memories: Memory[] | null; requests: InstallRequest[] | null }, | |
| 452 | slug: string, | |
| 453 | span: Pick<Span, "from" | "now">, | |
| 454 | ): DecisionRow[] { | |
| 455 | const rows: DecisionRow[] = []; | |
| 456 | for (const memory of input.memories ?? []) { | |
| 457 | if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue; | |
| 458 | rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" }); | |
| 459 | } | |
| 460 | for (const request of input.requests ?? []) { | |
| 461 | if (request.status === "open" || !within(request.resolved_at, span)) continue; | |
| 462 | rows.push({ | |
| 463 | key: `request:${request.id}`, | |
| 464 | text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`, | |
| 465 | by: request.resolved_by, | |
| 466 | at: Date.parse(request.resolved_at!), | |
| 467 | to: `/${slug}/-/marketplace/requests`, | |
| 468 | kind: "request", | |
| 469 | }); | |
| 470 | } | |
| 471 | return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 472 | } | |
| 473 | ||
| 474 | // --- Running now ---------------------------------------------------------------- | |
| 475 | ||
| 476 | /** Something going right now: a session, an agent's change, a build. */ | |
| 477 | export type RunningRow = { | |
| 478 | key: string; | |
| 479 | kind: "session" | "change" | "deploy"; | |
| 480 | title: string; | |
| 481 | /** One line: who and where. */ | |
| 482 | detail: string; | |
| 483 | /** "Working", "Queued", "Building". */ | |
| 484 | status: string; | |
| 485 | /** Epoch ms: when it started. */ | |
| 486 | at: number; | |
| 487 | to: string; | |
| 488 | }; | |
| 489 | ||
| 490 | const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" }; | |
| 491 | ||
| 492 | /** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */ | |
| 493 | export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] { | |
| 494 | return sessions | |
| 495 | .filter((session) => !session.parent_id && SESSION_STATUS[session.status]) | |
| 496 | .map((session) => ({ | |
| 497 | key: `session:${session.id}`, | |
| 498 | kind: "session" as const, | |
| 499 | title: session.visible ? session.title : "A session in a conversation you're not in", | |
| 500 | detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`, | |
| 501 | status: SESSION_STATUS[session.status]!, | |
| 502 | at: Date.parse(session.created_at), | |
| 503 | to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`, | |
| 504 | })); | |
| 505 | } | |
| 506 | ||
| 507 | /** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */ | |
| 508 | export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string }; | |
| 509 | ||
| 510 | export function runningChanges(changes: AgentChange[]): RunningRow[] { | |
| 511 | return changes.map((change) => ({ | |
| 512 | key: `change:${change.key}`, | |
| 513 | kind: "change" as const, | |
| 514 | title: change.title, | |
| 515 | detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`, | |
| 516 | status: change.chip, | |
| 517 | at: change.at, | |
| 518 | to: change.to, | |
| 519 | })); | |
| 520 | } | |
| 521 | ||
| 522 | /** Builds queued or going now, one per project (its newest). */ | |
| 523 | export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] { | |
| 524 | return overview.flatMap((project) => { | |
| 525 | const latest = project.latest; | |
| 526 | if (!latest || (latest.status !== "queued" && latest.status !== "building")) return []; | |
| 527 | return [ | |
| 528 | { | |
| 529 | key: `deploy:${project.slug}:${latest.id}`, | |
| 530 | kind: "deploy" as const, | |
| 531 | title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`, | |
| 532 | detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`, | |
| 533 | status: latest.status === "queued" ? "Queued" : "Building", | |
| 534 | at: Date.parse(latest.createdAt), | |
| 535 | to: `/${slug}/${project.slug}/deployments/${latest.id}`, | |
| 536 | }, | |
| 537 | ]; | |
| 538 | }); | |
| 539 | } | |
| 540 | ||
| 541 | /** Everything running, longest-going first within each kind: sessions, then changes, then builds. */ | |
| 542 | export function running(parts: RunningRow[][]): RunningRow[] { | |
| 543 | const order = { session: 0, change: 1, deploy: 2 } as const; | |
| 544 | return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key)); | |
| 545 | } | |
| 546 | ||
| 547 | // --- Needs you ------------------------------------------------------------------ | |
| 548 | ||
| 549 | /** | |
| 550 | * What a row is. Tiers, most pressing first: | |
| 551 | * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`); | |
| 552 | * 1. agent work that has stopped and can't go on without you; | |
| 553 | * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something); | |
| 554 | * 3. something stuck that may need a look; | |
| 555 | * 4. an unread notification that is a warning or a failure; | |
| 556 | * 5. a mention or a direct message. | |
| 557 | */ | |
| 558 | export type AttentionKind = | |
| 559 | | "limit" | |
| 560 | | "deploy" | |
| 561 | | "session_cap" | |
| 562 | | "agent_budget" | |
| 563 | | "pull_blocked" | |
| 564 | | "ready" | |
| 565 | | "review" | |
| 566 | | "invitation" | |
| 567 | | "install_request" | |
| 568 | | "agent_waiting" | |
| 569 | | "stuck" | |
| 570 | | "runner" | |
| 571 | | "notification" | |
| 572 | | "chat"; | |
| 573 | ||
| 574 | export const TIER: Record<AttentionKind, number> = { | |
| 575 | limit: 0, | |
| 576 | deploy: 0, | |
| 577 | session_cap: 1, | |
| 578 | agent_budget: 1, | |
| 579 | pull_blocked: 1, | |
| 580 | ready: 2, | |
| 581 | review: 2, | |
| 582 | invitation: 2, | |
| 583 | install_request: 2, | |
| 584 | agent_waiting: 2, | |
| 585 | stuck: 3, | |
| 586 | runner: 3, | |
| 587 | notification: 4, | |
| 588 | chat: 5, | |
| 589 | }; | |
| 590 | ||
| 591 | export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null }; | |
| 592 | ||
| 593 | /** One row of Needs you. */ | |
| 594 | export type AttentionRow = { | |
| 595 | key: string; | |
| 596 | kind: AttentionKind; | |
| 597 | title: string; | |
| 598 | /** One line: what it is and where. */ | |
| 599 | detail: string; | |
| 600 | /** What is at stake, in a few words. */ | |
| 601 | stake: Stake | null; | |
| 602 | /** The action, right there. */ | |
| 603 | action: { label: string; to: string }; | |
| 604 | /** Who is on it: the agent, or the person who asked. */ | |
| 605 | owner: { name: string; agent: boolean } | null; | |
| 606 | /** Epoch ms: when it started waiting. */ | |
| 607 | at: number; | |
| 608 | /** Where it came from, for a member without Code access never `code`. */ | |
| 609 | from: "code" | "agents" | "notifications" | "chat" | "marketplace"; | |
| 610 | }; | |
| 611 | ||
| 612 | /** | |
| 613 | * Most pressing first: by tier, then what has waited longest, then by key | |
| 614 | * so the order never depends on the order rows were read. One of each key, | |
| 615 | * and one of each place: a notification about a pull request already on | |
| 616 | * the list is left out. | |
| 617 | */ | |
| 618 | export function rankAttention(rows: AttentionRow[]): AttentionRow[] { | |
| 619 | const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key)); | |
| 620 | const keys = new Set<string>(); | |
| 621 | const places = new Set<string>(); | |
| 622 | return sorted.filter((row) => { | |
| 623 | const place = row.action.to.split(/[?#]/)[0]; | |
| 624 | if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false; | |
| 625 | keys.add(row.key); | |
| 626 | places.add(place); | |
| 627 | return true; | |
| 628 | }); | |
| 629 | } | |
| 630 | ||
| 631 | /** Start here: the first row by `rankAttention`, with why it was picked. */ | |
| 632 | export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null { | |
| 633 | const row = ranked[0]; | |
| 634 | if (!row) return null; | |
| 635 | const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length; | |
| 636 | const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : ""; | |
| 637 | return { row, why: `${WHY[row.kind]}${tail}` }; | |
| 638 | } | |
| 639 | ||
| 640 | const WHY: Record<AttentionKind, string> = { | |
| 641 | limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.", | |
| 642 | install_request: "A member asked for it, and only an owner can add it or turn it down.", | |
| 643 | deploy: "Production is serving an older build until this is fixed, so it comes before anything else.", | |
| 644 | session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.", | |
| 645 | agent_budget: "An agent used up its budget and takes no new work until it is raised.", | |
| 646 | pull_blocked: "An agent stopped on this change and won't go on until a person decides.", | |
| 647 | ready: "The work is done and only waits for you to land it.", | |
| 648 | review: "You were asked by name, so it waits for your verdict.", | |
| 649 | invitation: "It is addressed to you; no one else can answer it.", | |
| 650 | agent_waiting: "An agent is waiting on you to go on.", | |
| 651 | stuck: "A running agent has stopped reporting and may be stuck.", | |
| 652 | runner: "A job is waiting for a runner that is not there.", | |
| 653 | notification: "It is the most pressing unread notification.", | |
| 654 | chat: "Nothing else is waiting, and this is the oldest message for you.", | |
| 655 | }; | |
| 656 | ||
| 657 | // --- Rows from each source ---------------------------------------------------- | |
| 658 | ||
| 659 | /** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */ | |
| 660 | export type CodeNeed = { | |
| 661 | key: string; | |
| 662 | reason: string; | |
| 663 | repo: RepoPath | null; | |
| 664 | ref: string | null; | |
| 665 | title: string; | |
| 666 | ask: string; | |
| 667 | by: { name: string; agent: boolean } | null; | |
| 668 | for: string | null; | |
| 669 | at: number; | |
| 670 | to: string; | |
| 671 | facts: { label: string; value: string; tone: string | null }[]; | |
| 672 | }; | |
| 673 | ||
| 674 | const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null; | |
| 675 | ||
| 676 | const REASON_STAKE: Record<string, Stake> = { | |
| 677 | blocking: { text: "Blocking", tone: "danger" }, | |
| 678 | checks_failing: { text: "Checks failing", tone: "danger" }, | |
| 679 | outside_guardrails: { text: "Over a guardrail", tone: "warn" }, | |
| 680 | low_confidence: { text: "Low confidence", tone: "warn" }, | |
| 681 | needs_review: { text: "Needs a verdict", tone: "warn" }, | |
| 682 | stalled: { text: "Stalled", tone: "warn" }, | |
| 683 | }; | |
| 684 | ||
| 685 | /** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */ | |
| 686 | export function codeRow(need: CodeNeed): AttentionRow { | |
| 687 | const prefix = need.key.split(":")[0]; | |
| 688 | const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null; | |
| 689 | const detail = where ? `${where} · ${need.ask}` : need.ask; | |
| 690 | const owner = need.by; | |
| 691 | const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const }; | |
| 692 | switch (prefix) { | |
| 693 | case "deploy": | |
| 694 | return { | |
| 695 | ...base, | |
| 696 | kind: "deploy", | |
| 697 | stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" }, | |
| 698 | action: { label: "See the build", to: need.to }, | |
| 699 | }; | |
| 700 | case "invitation": | |
| 701 | return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } }; | |
| 702 | case "review": { | |
| 703 | const lines = fact(need, "Lines"); | |
| 704 | return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } }; | |
| 705 | } | |
| 706 | case "runner": | |
| 707 | return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } }; | |
| 708 | case "run": { | |
| 709 | const cost = fact(need, "Cost so far"); | |
| 710 | return { | |
| 711 | ...base, | |
| 712 | kind: "stuck", | |
| 713 | stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" }, | |
| 714 | action: { label: "Look", to: need.to }, | |
| 715 | }; | |
| 716 | } | |
| 717 | default: { | |
| 718 | if (need.reason === "ready_to_merge") { | |
| 719 | const files = fact(need, "Files changed"); | |
| 720 | return { | |
| 721 | ...base, | |
| 722 | kind: "ready", | |
| 723 | stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" }, | |
| 724 | action: { label: "Merge", to: need.to }, | |
| 725 | }; | |
| 726 | } | |
| 727 | return { | |
| 728 | ...base, | |
| 729 | kind: "pull_blocked", | |
| 730 | stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" }, | |
| 731 | action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to }, | |
| 732 | }; | |
| 733 | } | |
| 734 | } | |
| 735 | } | |
| 736 | ||
| 737 | /** A session stopped at its spend cap that the viewer may approve more for. */ | |
| 738 | export function sessionCapRow(session: AgentSession, slug: string): AttentionRow { | |
| 739 | const cap = session.cap_micros; | |
| 740 | return { | |
| 741 | key: `session:${session.id}`, | |
| 742 | kind: "session_cap", | |
| 743 | title: session.visible ? session.title : `A session of @${session.agent_handle}`, | |
| 744 | detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`, | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 745 | stake: { text: cap != null ? `${money(session.charged_micros)} of ${money(cap)} cap` : `${money(session.charged_micros)} spent`, tone: "warn" }, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 746 | action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` }, |
| 747 | owner: { name: session.agent_handle, agent: true }, | |
| 748 | at: Date.parse(session.updated_at), | |
| 749 | from: "agents", | |
| 750 | }; | |
| 751 | } | |
| 752 | ||
| 753 | export type AgentLike = { | |
| 754 | id: string; | |
| 755 | handle: string; | |
| 756 | display_name: string; | |
| 757 | status: string; | |
| 758 | spent_month_micros: number; | |
| 759 | budget: { monthly_micros: number | null }; | |
| 760 | updated_at: string; | |
| 761 | }; | |
| 762 | ||
| 763 | /** An agent out of budget (for those who may raise it), or waiting on someone. */ | |
| 764 | export function agentRow(agent: AgentLike, slug: string): AttentionRow | null { | |
| 765 | const base = { | |
| 766 | key: `agent:${agent.id}`, | |
| 767 | owner: { name: agent.handle, agent: true }, | |
| 768 | at: Date.parse(agent.updated_at), | |
| 769 | from: "agents" as const, | |
| 770 | }; | |
| 771 | if (agent.status === "out_of_budget") { | |
| 772 | const budget = agent.budget.monthly_micros; | |
| 773 | return { | |
| 774 | ...base, | |
| 775 | kind: "agent_budget", | |
| 776 | title: `${agent.display_name} is out of budget`, | |
| 777 | detail: `@${agent.handle} takes no new work this month until its budget is raised.`, | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 778 | stake: { text: budget != null ? `${money(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" }, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 779 | action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` }, |
| 780 | }; | |
| 781 | } | |
| 782 | if (agent.status === "waiting") { | |
| 783 | return { | |
| 784 | ...base, | |
| 785 | kind: "agent_waiting", | |
| 786 | title: `${agent.display_name} is waiting`, | |
| 787 | detail: `@${agent.handle} is waiting before it goes on.`, | |
| 788 | stake: null, | |
| 789 | action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` }, | |
| 790 | }; | |
| 791 | } | |
| 792 | return null; | |
| 793 | } | |
| 794 | ||
| 795 | const NOTIFICATION_STAKE: Record<string, string> = { | |
| 796 | agent: "Agent waiting", | |
| 797 | review_requested: "Review requested", | |
| 798 | assign: "Assigned to you", | |
| 799 | mention: "Mentioned", | |
| 800 | team_mention: "Team mentioned", | |
| 801 | ci_activity: "CI activity", | |
| 802 | security_alert: "Security alert", | |
| 803 | state_change: "State changed", | |
| 804 | author: "Your work", | |
| 805 | comment: "Comment", | |
| 806 | manual: "Subscribed", | |
| 807 | subscribed: "Watching", | |
| 808 | }; | |
| 809 | ||
| 810 | /** | |
| 811 | * An unread notification that is a warning (someone or something is | |
| 812 | * waiting) or a failure, in this workspace. A member without Code access | |
| 813 | * gets none about a repository. | |
| 814 | */ | |
| 815 | export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null { | |
| 816 | if (item.readAt != null || item.doneAt != null) return null; | |
| 817 | if (item.severity !== "warning" && item.severity !== "error") return null; | |
| 818 | const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug; | |
| 819 | if (!inWorkspace) return null; | |
| 820 | if (!code && item.repo) return null; | |
| 821 | return { | |
| 822 | key: `notification:${item.id}`, | |
| 823 | kind: "notification", | |
| 824 | title: item.body || item.title, | |
| 825 | detail: item.body ? item.title : (item.repo ?? "A notification"), | |
| 826 | stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" }, | |
| 827 | action: { label: "Open", to: item.url }, | |
| 828 | owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null, | |
| 829 | at: Date.parse(item.updatedAt), | |
| 830 | from: "notifications", | |
| 831 | }; | |
| 832 | } | |
| 833 | ||
| 834 | export type ChatEntryLike = { | |
| 835 | channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null }; | |
| 836 | title: string; | |
| 837 | muted: boolean; | |
| 838 | unread: number; | |
| 839 | mentions: number; | |
| 840 | }; | |
| 841 | ||
| 842 | /** A conversation with a mention of you, or a direct message you haven't read. */ | |
| 843 | export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null { | |
| 844 | if (entry.muted) return null; | |
| 845 | if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null; | |
| 846 | const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`; | |
| 847 | const dm = entry.channel.kind === "dm"; | |
| 848 | return { | |
| 849 | key: `chat:${entry.channel.id}`, | |
| 850 | kind: "chat", | |
| 851 | title: dm ? entry.title : `#${entry.title}`, | |
| 852 | detail: dm ? "A direct message you haven't read." : "You were mentioned.", | |
| 853 | stake: { | |
| 854 | text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`, | |
| 855 | tone: entry.mentions > 0 ? "accent" : null, | |
| 856 | }, | |
| 857 | action: { label: "Reply", to }, | |
| 858 | owner: null, | |
| 859 | at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0, | |
| 860 | from: "chat", | |
| 861 | }; | |
| 862 | } | |
| 863 | ||
| 864 | /** A member's open request to add something from the Marketplace, for an owner. */ | |
| 865 | export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null { | |
| 866 | if (request.status !== "open") return null; | |
| 867 | return { | |
| 868 | key: `request:${request.id}`, | |
| 869 | kind: "install_request", | |
| 870 | title: `${request.requested_by} asked to add ${request.name}`, | |
| 871 | detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.", | |
| 872 | stake: { text: "Only owners can add it", tone: null }, | |
| 873 | action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` }, | |
| 874 | owner: { name: request.requested_by, agent: false }, | |
| 875 | at: Date.parse(request.requested_at), | |
| 876 | from: "marketplace", | |
| 877 | }; | |
| 878 | } | |
| 879 | ||
| 880 | /** | |
| 881 | * The workspace's agents at 100% of its monthly agent budget, for those who | |
| 882 | * may raise it. `since` is when it was reached as far as is known: the | |
| 883 | * newest session that stopped at its cap, else the start of the month. | |
| 884 | */ | |
| 885 | export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null { | |
| 886 | if (input.alert == null || input.alert < 100) return null; | |
| 887 | return { | |
| 888 | key: "limit:agents", | |
| 889 | kind: "limit", | |
| 890 | title: "Agents used up the workspace's budget", | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 891 | detail: `${money(input.spentMicros)} spent this month. Agents start nothing new until the budget is raised or the month turns.`, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 892 | stake: { text: "No new agent work", tone: "danger" }, |
| 893 | action: { label: "Raise the budget", to: `/${slug}/-/spend` }, | |
| 894 | owner: null, | |
| 895 | at: input.since, | |
| 896 | from: "agents", | |
| 897 | }; | |
| 898 | } | |
| 899 | ||
| 900 | /** What Needs you and Start here show, and which sources did not answer. */ | |
| 901 | export type Attention = { | |
| 902 | rows: AttentionRow[]; | |
| 903 | start: { row: AttentionRow; why: string } | null; | |
| 904 | /** Sources that did not answer, by name, for the card to say so. */ | |
| 905 | missing: string[]; | |
| 906 | }; | |
| 907 | ||
| 908 | export function attention(input: { | |
| 909 | slug: string; | |
| 910 | code: boolean; | |
| 911 | codeNeeds: CodeNeed[] | null; | |
| 912 | capped: AgentSession[] | null; | |
| 913 | agents: AgentLike[] | null; | |
| 914 | canManage: boolean; | |
| 915 | /** The workspace's agent budget, for those who may manage agents; null for anyone else. */ | |
| 916 | limit?: { alert: number | null; spentMicros: number; since: number } | null; | |
| 917 | /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */ | |
| 918 | requests?: InstallRequest[] | null; | |
| 919 | notifications: InboxItem[] | null; | |
| 920 | chat: ChatEntryLike[] | null; | |
| 921 | }): Attention { | |
| 922 | const rows: AttentionRow[] = []; | |
| 923 | const missing: string[] = []; | |
| 924 | if (input.code) { | |
| 925 | if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow)); | |
| 926 | else missing.push("Code"); | |
| 927 | } | |
| 928 | if (input.limit && input.canManage) { | |
| 929 | const row = limitRow(input.limit, input.slug); | |
| 930 | if (row) rows.push(row); | |
| 931 | } | |
| 932 | for (const request of input.requests ?? []) { | |
| 933 | const row = installRequestRow(request, input.slug); | |
| 934 | if (row) rows.push(row); | |
| 935 | } | |
| 936 | if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug))); | |
| 937 | if (input.agents) { | |
| 938 | const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle)); | |
| 939 | for (const agent of input.agents) { | |
| 940 | if (agent.status === "out_of_budget" && !input.canManage) continue; | |
| 941 | if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue; | |
| 942 | const row = agentRow(agent, input.slug); | |
| 943 | if (row) rows.push(row); | |
| 944 | } | |
| 945 | } | |
| 946 | if (input.capped == null || input.agents == null) missing.push("Agents"); | |
| 947 | if (input.notifications) { | |
| 948 | for (const item of input.notifications) { | |
| 949 | const row = notificationRow(item, input.slug, input.code); | |
| 950 | if (row) rows.push(row); | |
| 951 | } | |
| 952 | } else missing.push("Notifications"); | |
| 953 | if (input.chat) { | |
| 954 | for (const entry of input.chat) { | |
| 955 | const row = chatRow(entry, input.slug); | |
| 956 | if (row) rows.push(row); | |
| 957 | } | |
| 958 | } else missing.push("Chat"); | |
| 959 | const ranked = rankAttention(rows); | |
| 960 | return { rows: ranked, start: startHere(ranked), missing }; | |
| 961 | } | |
| 962 | ||
| 963 | // --- Spend in the span ---------------------------------------------------------- | |
| 964 | ||
| 965 | /** Money in, which the statement lists but which is not spend. */ | |
| 966 | const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]); | |
| 967 | ||
| 968 | export type Spend = { | |
| 969 | /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */ | |
| 970 | from: string; | |
| 971 | to: string; | |
| 972 | /** Usage at price over those days, in micros. */ | |
| 973 | totalMicros: number; | |
| 974 | /** A line per kind of charge, as the statement names it, most first. */ | |
| 975 | lines: { kind: string; micros: number; count: number }[]; | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 976 | /** Usage at price this month so far, the top bar's and Usage's figure; null when it could not be read. */ |
| 977 | monthMicros: number | null; | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 978 | }; |
| 979 | ||
| 980 | /** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */ | |
| 981 | export function spanMonths(span: Pick<Span, "from" | "now">): string[] { | |
| 982 | const months: string[] = []; | |
| 983 | const first = new Date(span.from); | |
| 984 | let year = first.getUTCFullYear(); | |
| 985 | let month = first.getUTCMonth(); | |
| 986 | const last = new Date(span.now).toISOString().slice(0, 7); | |
| 987 | for (let i = 0; i < 3; i++) { | |
| 988 | const key = `${year}-${String(month + 1).padStart(2, "0")}`; | |
| 989 | months.push(key); | |
| 990 | if (key >= last) break; | |
| 991 | month++; | |
| 992 | if (month === 12) { | |
| 993 | month = 0; | |
| 994 | year++; | |
| 995 | } | |
| 996 | } | |
| 997 | return months; | |
| 998 | } | |
| 999 | ||
| 1000 | /** | |
| 1001 | * The span's spend from the statements grouped by day: each usage line at | |
| 1002 | * its price (what was charged, plus what the plan, a trial, a pool or a | |
| 1003 | * discount paid of it), leaving out money in. The statement keeps whole | |
| 1004 | * UTC days, so the span is counted from the start of the UTC day it began. | |
| 1005 | * `statements` is the months of `spanMonths`, the current one last. | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1006 | * `monthMicros` is this month so far as billing's usage report counts it, |
| 1007 | * the month's usage not yet closed included: the figure the top bar's | |
| 1008 | * pill, Spend and Usage show, so Home shows no other. | |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1009 | */ |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1010 | export function spendIn(statements: Pick<Statement, "groups" | "totals">[], span: Pick<Span, "from" | "now">, monthMicros: number | null): Spend { |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1011 | const from = new Date(span.from).toISOString().slice(0, 10); |
| 1012 | const to = new Date(span.now).toISOString().slice(0, 10); | |
| 1013 | const byKind = new Map<string, { micros: number; count: number }>(); | |
| 1014 | for (const statement of statements) { | |
| 1015 | for (const group of statement.groups) { | |
| 1016 | if (group.key < from || group.key > to) continue; | |
| 1017 | for (const line of group.lines) { | |
| 1018 | if (MONEY_IN.has(line.kind)) continue; | |
| 1019 | const micros = line.priceMicros ?? line.chargedMicros; | |
| 1020 | const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 }; | |
| 1021 | byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count }); | |
| 1022 | } | |
| 1023 | } | |
| 1024 | } | |
| 1025 | const lines = [...byKind] | |
| 1026 | .map(([kind, value]) => ({ kind, ...value })) | |
| 1027 | .filter((line) => line.micros > 0) | |
| 1028 | .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind)); | |
| 1029 | return { | |
| 1030 | from, | |
| 1031 | to, | |
| 1032 | totalMicros: lines.reduce((sum, line) => sum + line.micros, 0), | |
| 1033 | lines, | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 1034 | monthMicros, |
| The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how. | 1035 | }; |
| 1036 | } | |
| 1037 | ||
| 1038 | // --- The sentence ----------------------------------------------------------- | |
| 1039 | ||
| 1040 | /** | |
| 1041 | * The sentence under the heading: "Since Tuesday evening, agents finished | |
| 1042 | * 35 tasks and 12 changes landed. 4 needed a fix after review." Finished | |
| 1043 | * counts every task that settled well: accepted, fixed, and sessions that | |
| 1044 | * finished. | |
| 1045 | */ | |
| 1046 | export function spanSentence(span: Pick<Span, "words">, work: Pick<SpanWork, "counts" | "tasks"> | null, landed: number | null): string { | |
| 1047 | const lead = span.words; | |
| 1048 | if (!work) return `${lead}, agent work couldn't be read.`; | |
| 1049 | const { accepted, fixed, finished, dropped } = work.counts; | |
| 1050 | const done = accepted + fixed + finished; | |
| 1051 | const changes = landed && landed > 0 ? `${plural(landed, "change")} landed` : null; | |
| 1052 | if (work.tasks.length === 0 && !changes) return `${lead}, nothing new has settled.`; | |
| 1053 | const first = | |
| 1054 | done > 0 | |
| 1055 | ? `${lead}, agents finished ${plural(done, "task")}${changes ? ` and ${changes}` : ""}.` | |
| 1056 | : changes | |
| 1057 | ? `${lead}, ${changes}${work.tasks.length > 0 ? "; agents finished none of their tasks" : ""}.` | |
| 1058 | : `${lead}, agents finished no tasks.`; | |
| 1059 | const after: string[] = []; | |
| 1060 | if (fixed > 0) after.push(`${fixed.toLocaleString("en-US")} needed a fix after review`); | |
| 1061 | if (dropped > 0) after.push(`${dropped.toLocaleString("en-US")} didn't finish`); | |
| 1062 | if (after.length === 0) return first; | |
| 1063 | const text = after.length > 1 ? `${after[0]}, and ${after[1]}` : after[0]!; | |
| 1064 | return `${first} ${text.charAt(0).toUpperCase()}${text.slice(1)}.`; | |
| 1065 | } | |
| 1066 | ||
| 1067 | /** "8 things need you.", or that nothing does. */ | |
| 1068 | export function waitingSentence(total: number): string { | |
| 1069 | if (total === 0) return "Nothing needs you right now."; | |
| 1070 | return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`; | |
| 1071 | } | |
| 1072 | ||
| 1073 | /** How long something has waited: "45 min", "17 h", "2 d". */ | |
| 1074 | export function waited(at: number, now: number): string { | |
| 1075 | const minutes = Math.max(0, Math.floor((now - at) / MINUTE)); | |
| 1076 | if (minutes < 60) return `${minutes} min`; | |
| 1077 | if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`; | |
| 1078 | return `${Math.floor(minutes / (24 * 60))} d`; | |
| 1079 | } | |
| 1080 | ||
| 1081 | /** "3:40 PM" when it is the same day as `now`, else "Oct 7". */ | |
| 1082 | export function whenShort(at: number, now: number, timeZone: string | null): string { | |
| 1083 | if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" }); | |
| 1084 | return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone); | |
| 1085 | } |