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 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; | |
| 3 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 4 | import type { ActivityDigest, ActorCount, AgentSession, ChatActivity, Deployment, InboxItem, InstallRequest, Memory, RepoDigest, WorkflowRun } from "@g1t/contracts"; |
| 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. | 5 | |
| 6 | import { | |
| 7 | type AttentionRow, | |
| 8 | type CodeNeed, | |
| 9 | type CodePull, | |
| 10 | acceptance, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 11 | actorKind, |
| 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. | 12 | agentRow, |
| 13 | attention, | |
| 14 | chatRow, | |
| 15 | codeRow, | |
| 16 | dayIn, | |
| 17 | decisionsIn, | |
| 18 | deploysIn, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 19 | digestOf, |
| 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. | 20 | duration, |
| 21 | installRequestRow, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 22 | landed, |
| 23 | landedDeploys, | |
| 24 | landedFromActivity, | |
| 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 | landedIn, |
| 26 | limitRow, | |
| 27 | notificationRow, | |
| 28 | parseWindow, | |
| 29 | pullOutcome, | |
| 30 | rankAttention, | |
| 31 | running, | |
| 32 | runningDeploys, | |
| 33 | runningSessions, | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 34 | runningWorkflows, |
| 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. | 35 | sessionOutcome, |
| 36 | sinceWords, | |
| 37 | spanFor, | |
| 38 | spanMonths, | |
| 39 | spanSentence, | |
| 40 | spanWork, | |
| 41 | spendIn, | |
| 42 | startHere, | |
| 43 | trendLabel, | |
| 44 | waitingSentence, | |
| 45 | } from "./home.ts"; | |
| 46 | ||
| 47 | const HOUR = 3_600_000; | |
| 48 | const DAY = 24 * HOUR; | |
| 49 | // Friday 2026-10-09, 18:00 UTC; 11:00 in Los Angeles. | |
| 50 | const NOW = Date.parse("2026-10-09T18:00:00Z"); | |
| 51 | const LA = "America/Los_Angeles"; | |
| 52 | const repo = { namespace: "acme", name: "web" }; | |
| 53 | const iso = (ms: number) => new Date(ms).toISOString(); | |
| 54 | ||
| 55 | function pull(over: Partial<CodePull> = {}): CodePull { | |
| 56 | return { | |
| 57 | repo, | |
| 58 | number: 1, | |
| 59 | title: "Fix the flaky test", | |
| 60 | status: "merged", | |
| 61 | mergedAt: iso(NOW - HOUR), | |
| 62 | createdAt: iso(NOW - 3 * HOUR), | |
| 63 | updatedAt: iso(NOW - HOUR), | |
| 64 | author: { username: "g1t", kind: "agent" }, | |
| 65 | ...over, | |
| 66 | }; | |
| 67 | } | |
| 68 | ||
| 69 | function session(over: Partial<AgentSession> = {}): AgentSession { | |
| 70 | return { | |
| 71 | id: "s1", | |
| 72 | workspace_id: "w", | |
| 73 | agent_id: "a", | |
| 74 | agent_handle: "margo", | |
| 75 | agent_name: "Margo", | |
| 76 | agent_avatar_seed: "margo", | |
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 77 | agent_look: 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. | 78 | subagent: null, |
| 79 | kind: "chat", | |
| 80 | parent_id: null, | |
| 81 | root_id: "s1", | |
| 82 | payer_agent_id: "a", | |
| 83 | title: "Draft the launch notes", | |
| 84 | goal: "", | |
| 85 | status: "done", | |
| 86 | status_note: null, | |
| 87 | summary: null, | |
| 88 | channel_id: "c", | |
| 89 | channel_kind: "channel", | |
| 90 | channel_name: "launch", | |
| 91 | card_message_id: null, | |
| 92 | asked_by: null, | |
| 93 | asked_by_username: "bo", | |
| 94 | routine_id: null, | |
| 95 | steps: 3, | |
| 96 | tool_calls: 2, | |
| 97 | input_tokens: 0, | |
| 98 | output_tokens: 0, | |
| 99 | charged_micros: 400_000, | |
| 100 | cap_micros: 2_000_000, | |
| 101 | model: null, | |
| 102 | outputs: [], | |
| 103 | created_at: iso(NOW - 2 * HOUR), | |
| 104 | updated_at: iso(NOW - HOUR), | |
| 105 | finished_at: iso(NOW - HOUR), | |
| 106 | visible: true, | |
| 107 | ...over, | |
| 108 | }; | |
| 109 | } | |
| 110 | const need = (over: Partial<CodeNeed>): CodeNeed => ({ | |
| 111 | key: "pull:p1", | |
| 112 | reason: "ready_to_merge", | |
| 113 | repo, | |
| 114 | ref: "#12", | |
| 115 | title: "Add retries", | |
| 116 | ask: "Waiting for you to merge it.", | |
| 117 | by: { name: "g1t", agent: true }, | |
| 118 | for: "bo", | |
| 119 | at: NOW - 2 * HOUR, | |
| 120 | to: "/acme/web/pull/12", | |
| 121 | facts: [{ label: "Files changed", value: "4", tone: null }], | |
| 122 | ...over, | |
| 123 | }); | |
| 124 | ||
| 125 | test("Code's needs become rows with what is at stake", () => { | |
| 126 | const ready = codeRow(need({})); | |
| 127 | assert.equal(ready.kind, "ready"); | |
| 128 | assert.equal(ready.stake?.text, "4 files to land"); | |
| 129 | assert.equal(ready.action.label, "Merge"); | |
| 130 | const deploy = codeRow(need({ key: "deploy:site", reason: "blocking", facts: [{ label: "Production", value: "Serving the build before", tone: "good" }] })); | |
| 131 | assert.equal(deploy.kind, "deploy"); | |
| 132 | assert.equal(deploy.stake?.text, "Production on an older build"); | |
| 133 | assert.equal(codeRow(need({ reason: "checks_failing" })).kind, "pull_blocked"); | |
| 134 | assert.equal(codeRow(need({ key: "run:r1", reason: "stalled", facts: [{ label: "Cost so far", value: "$1.20", tone: null }] })).stake?.text, "$1.20 so far"); | |
| 135 | }); | |
| 136 | ||
| 137 | const row = (over: Partial<AttentionRow>): AttentionRow => ({ | |
| 138 | key: "k", | |
| 139 | kind: "chat", | |
| 140 | title: "t", | |
| 141 | detail: "d", | |
| 142 | stake: null, | |
| 143 | action: { label: "Open", to: "/x" }, | |
| 144 | owner: null, | |
| 145 | at: NOW, | |
| 146 | from: "chat", | |
| 147 | ...over, | |
| 148 | }); | |
| 149 | ||
| 150 | test("Start here: the most pressing tier first, then what has waited longest", () => { | |
| 151 | const rows = [ | |
| 152 | row({ key: "chat:old", kind: "chat", at: NOW - 5 * DAY, action: { label: "Reply", to: "/acme/-/chat/general" } }), | |
| 153 | row({ key: "ready:new", kind: "ready", at: NOW - HOUR, action: { label: "Merge", to: "/acme/web/pull/2" } }), | |
| 154 | row({ key: "ready:old", kind: "ready", at: NOW - 3 * HOUR, action: { label: "Merge", to: "/acme/web/pull/1" } }), | |
| 155 | row({ key: "session:s", kind: "session_cap", at: NOW - 10 * 60_000, action: { label: "Approve more", to: "/acme/-/agents/margo/sessions/s" } }), | |
| 156 | ]; | |
| 157 | const ranked = rankAttention(rows); | |
| 158 | assert.deepEqual(ranked.map((r) => r.key), ["session:s", "ready:old", "ready:new", "chat:old"]); | |
| 159 | const start = startHere(ranked); | |
| 160 | assert.equal(start?.row.key, "session:s"); | |
| 161 | assert.match(start?.why ?? "", /spend cap/); | |
| 162 | // A failed production deploy beats everything. | |
| 163 | const withDeploy = rankAttention([...rows, row({ key: "deploy:site", kind: "deploy", at: NOW, action: { label: "See the build", to: "/acme/site/deployments/1" } })]); | |
| 164 | assert.equal(startHere(withDeploy)?.row.key, "deploy:site"); | |
| 165 | // Ties in tier say so. | |
| 166 | const two = startHere(rankAttention(rows.filter((r) => r.kind === "ready"))); | |
| 167 | assert.equal(two?.row.key, "ready:old"); | |
| 168 | assert.match(two?.why ?? "", /Of the 2 like it, it has waited longest/); | |
| 169 | assert.equal(startHere([]), null); | |
| 170 | }); | |
| 171 | ||
| 172 | test("the same order whatever order the rows came in", () => { | |
| 173 | const rows = [row({ key: "b", kind: "review", at: NOW }), row({ key: "a", kind: "review", at: NOW, action: { label: "Review", to: "/y" } })]; | |
| 174 | assert.deepEqual(rankAttention(rows).map((r) => r.key), ["a", "b"]); | |
| 175 | assert.deepEqual(rankAttention([...rows].reverse()).map((r) => r.key), ["a", "b"]); | |
| 176 | }); | |
| 177 | ||
| 178 | const item = (over: Partial<InboxItem>): InboxItem => ({ | |
| 179 | id: "n1", | |
| 180 | reason: "ci_activity", | |
| 181 | severity: "error", | |
| 182 | title: "Checks failed on #12", | |
| 183 | body: "Add retries", | |
| 184 | event: "check.failed", | |
| 185 | repo: "acme/web", | |
| 186 | workspace: "acme", | |
| 187 | subject: "pull", | |
| 188 | number: 12, | |
| 189 | url: "/acme/web/pull/12", | |
| 190 | actor: "g1t", | |
| 191 | count: 1, | |
| 192 | createdAt: iso(NOW - HOUR), | |
| 193 | updatedAt: iso(NOW - HOUR), | |
| 194 | readAt: null, | |
| 195 | doneAt: null, | |
| 196 | saved: false, | |
| 197 | snoozedUntil: null, | |
| 198 | ...over, | |
| 199 | }); | |
| 200 | ||
| 201 | test("notifications: warnings and failures in this workspace; none about repositories without Code", () => { | |
| 202 | assert.equal(notificationRow(item({}), "acme", true)?.kind, "notification"); | |
| 203 | assert.equal(notificationRow(item({}), "acme", false), null); | |
| 204 | assert.equal(notificationRow(item({ repo: null, url: "/acme/-/agents/margo" }), "acme", false)?.kind, "notification"); | |
| 205 | assert.equal(notificationRow(item({ severity: "info" }), "acme", true), null); | |
| 206 | assert.equal(notificationRow(item({ workspace: "other", repo: "other/web" }), "acme", true), null); | |
| 207 | assert.equal(notificationRow(item({ readAt: iso(NOW) }), "acme", true), null); | |
| 208 | }); | |
| 209 | ||
| 210 | test("a notification about something already listed is left out", () => { | |
| 211 | const out = attention({ | |
| 212 | slug: "acme", | |
| 213 | code: true, | |
| 214 | codeNeeds: [need({})], | |
| 215 | capped: [], | |
| 216 | agents: [], | |
| 217 | canManage: false, | |
| 218 | notifications: [item({})], | |
| 219 | chat: [], | |
| 220 | }); | |
| 221 | assert.deepEqual(out.rows.map((r) => r.key), ["pull:p1"]); | |
| 222 | assert.deepEqual(out.missing, []); | |
| 223 | }); | |
| 224 | ||
| 225 | test("without Code access no Code row is asked for or shown, and a silent source is named", () => { | |
| 226 | const out = attention({ | |
| 227 | slug: "acme", | |
| 228 | code: false, | |
| 229 | codeNeeds: [need({})], | |
| 230 | capped: null, | |
| 231 | agents: null, | |
| 232 | canManage: false, | |
| 233 | notifications: [item({}), item({ id: "n2", repo: null, reason: "agent", severity: "warning", url: "/acme/-/agents/margo" })], | |
| 234 | chat: null, | |
| 235 | }); | |
| 236 | assert.deepEqual(out.rows.map((r) => r.key), ["notification:n2"]); | |
| 237 | assert.equal(out.rows.some((r) => r.from === "code"), false); | |
| 238 | assert.deepEqual(out.missing, ["Agents", "Chat"]); | |
| 239 | }); | |
| 240 | ||
| 241 | test("an agent out of budget is listed only for those who may raise it", () => { | |
| 242 | const agent = { id: "a1", handle: "margo", display_name: "Margo", status: "out_of_budget", spent_month_micros: 52_000_000, budget: { monthly_micros: 50_000_000 }, updated_at: iso(NOW) }; | |
| 243 | assert.equal(agentRow(agent, "acme")?.stake?.text, "$0.00 left"); | |
| 244 | const base = { slug: "acme", code: true, codeNeeds: [], capped: [], notifications: [], chat: [] }; | |
| 245 | assert.equal(attention({ ...base, agents: [agent], canManage: false }).rows.length, 0); | |
| 246 | assert.equal(attention({ ...base, agents: [agent], canManage: true }).rows[0]?.kind, "agent_budget"); | |
| 247 | }); | |
| 248 | ||
| 249 | test("chat: mentions anywhere and unread direct messages, never muted", () => { | |
| 250 | const channel = { id: "c1", kind: "channel" as const, name: "general", last_message_at: iso(NOW) }; | |
| 251 | assert.equal(chatRow({ channel, title: "general", muted: false, unread: 4, mentions: 0 }, "acme"), null); | |
| 252 | assert.equal(chatRow({ channel, title: "general", muted: false, unread: 4, mentions: 2 }, "acme")?.stake?.text, "2 mentions"); | |
| 253 | assert.equal(chatRow({ channel, title: "general", muted: true, unread: 4, mentions: 2 }, "acme"), null); | |
| 254 | const dm = chatRow({ channel: { ...channel, kind: "dm", name: null }, title: "Bo Reed", muted: false, unread: 1, mentions: 0 }, "acme"); | |
| 255 | assert.equal(dm?.action.to, "/acme/-/chat/dm/c1"); | |
| 256 | }); | |
| 257 | ||
| 258 | // The span is NOW back to 3 days ago unless a test says otherwise. | |
| 259 | const SPAN = { from: NOW - 3 * DAY, now: NOW }; | |
| 260 | ||
| 261 | test("a day is the reader's day", () => { | |
| 262 | assert.equal(dayIn(NOW, LA), "2026-10-09"); | |
| 263 | assert.equal(dayIn(Date.parse("2026-10-10T02:00:00Z"), LA), "2026-10-09"); | |
| 264 | assert.equal(dayIn(Date.parse("2026-10-10T02:00:00Z"), null), "2026-10-10"); | |
| 265 | assert.equal(dayIn(NOW, "Not/AZone"), "2026-10-09"); | |
| 266 | }); | |
| 267 | ||
| 268 | test("since your last visit, in words, in the reader's time zone", () => { | |
| 269 | // NOW is Friday 11:00 in Los Angeles. | |
| 270 | assert.equal(sinceWords(NOW - 20_000, NOW, LA), "a moment ago"); | |
| 271 | assert.equal(sinceWords(NOW - 40 * 60_000, NOW, LA), "40 minutes ago"); | |
| 272 | assert.equal(sinceWords(NOW - HOUR - 60_000, NOW, LA), "an hour ago"); | |
| 273 | assert.equal(sinceWords(NOW - 2 * HOUR, NOW, LA), "2 hours ago"); | |
| 274 | // 07:00 Friday. | |
| 275 | assert.equal(sinceWords(NOW - 4 * HOUR, NOW, LA), "this morning"); | |
| 276 | // 02:00 Friday is Thursday night. | |
| 277 | assert.equal(sinceWords(NOW - 9 * HOUR, NOW, LA), "last night"); | |
| 278 | // 19:00 Thursday. | |
| 279 | assert.equal(sinceWords(NOW - 16 * HOUR, NOW, LA), "yesterday evening"); | |
| 280 | // 19:00 Tuesday. | |
| 281 | assert.equal(sinceWords(NOW - 2 * DAY - 16 * HOUR, NOW, LA), "Tuesday evening"); | |
| 282 | // 02:00 Wednesday is Tuesday night. | |
| 283 | assert.equal(sinceWords(NOW - 2 * DAY - 9 * HOUR, NOW, LA), "Tuesday night"); | |
| 284 | assert.equal(sinceWords(NOW - 9 * DAY, NOW, LA), "Sep 30"); | |
| 285 | }); | |
| 286 | ||
| 287 | test("how long a span is", () => { | |
| 288 | assert.equal(duration(30_000), "a moment"); | |
| 289 | assert.equal(duration(45 * 60_000), "45 minutes"); | |
| 290 | assert.equal(duration(HOUR), "1 hour"); | |
| 291 | assert.equal(duration(3 * DAY + HOUR), "3 days"); | |
| 292 | assert.equal(duration(15 * DAY), "2 weeks"); | |
| 293 | }); | |
| 294 | ||
| 295 | test("the span: since your last visit, capped, or a fixed window", () => { | |
| 296 | assert.equal(parseWindow("24h"), "24h"); | |
| 297 | assert.equal(parseWindow("7d"), "7d"); | |
| 298 | assert.equal(parseWindow("nonsense"), "last"); | |
| 299 | assert.equal(parseWindow(null), "last"); | |
| 300 | const away = spanFor("last", NOW, NOW - 2 * DAY - 16 * HOUR, LA); | |
| 301 | assert.equal(away.from, NOW - 2 * DAY - 16 * HOUR); | |
| 302 | assert.equal(away.words, "Since Tuesday evening"); | |
| 303 | assert.equal(away.length, "2 days"); | |
| 304 | assert.equal(away.note, null); | |
| 305 | // Never visited: the last 24 hours, and it says why. | |
| 306 | const first = spanFor("last", NOW, null, LA); | |
| 307 | assert.equal(first.from, NOW - DAY); | |
| 308 | assert.equal(first.note, "first"); | |
| 309 | assert.equal(spanFor("last", NOW, undefined, LA).note, "unknown"); | |
| 310 | // Away a month: 14 days at most. | |
| 311 | const capped = spanFor("last", NOW, NOW - 30 * DAY, LA); | |
| 312 | assert.equal(capped.from, NOW - 14 * DAY); | |
| 313 | assert.equal(capped.note, "capped"); | |
| 314 | assert.equal(capped.words, "In the last 14 days"); | |
| 315 | // The fixed windows ignore the last visit. | |
| 316 | assert.equal(spanFor("24h", NOW, NOW - 3 * DAY, LA).from, NOW - DAY); | |
| 317 | assert.deepEqual([spanFor("7d", NOW, NOW - HOUR, LA).from, spanFor("7d", NOW, NOW - HOUR, LA).words], [NOW - 7 * DAY, "In the last 7 days"]); | |
| 318 | }); | |
| 319 | ||
| 320 | test("an agent's pull request is accepted first time when it merged in the span with no revision", () => { | |
| 321 | assert.equal(pullOutcome(pull(), 0, SPAN), "accepted"); | |
| 322 | assert.equal(pullOutcome(pull(), 2, SPAN), "fixed"); | |
| 323 | assert.equal(pullOutcome(pull({ mergedAt: iso(NOW - 4 * DAY) }), 0, SPAN), null); | |
| 324 | assert.equal(pullOutcome(pull({ status: "closed", mergedAt: null }), 0, SPAN), "dropped"); | |
| 325 | // Still open is not settled: it is under Running now. | |
| 326 | assert.equal(pullOutcome(pull({ status: "open", mergedAt: null }), 1, SPAN), null); | |
| 327 | }); | |
| 328 | ||
| 329 | test("a session counts once, at the root of its tree, when it settled in the span", () => { | |
| 330 | assert.equal(sessionOutcome(session(), SPAN), "finished"); | |
| 331 | assert.equal(sessionOutcome(session({ parent_id: "s0" }), SPAN), null); | |
| 332 | assert.equal(sessionOutcome(session({ status: "failed" }), SPAN), "dropped"); | |
| 333 | assert.equal(sessionOutcome(session({ status: "stopped" }), SPAN), "dropped"); | |
| 334 | assert.equal(sessionOutcome(session({ status: "working", finished_at: null }), SPAN), null); | |
| 335 | assert.equal(sessionOutcome(session({ finished_at: iso(NOW - 4 * DAY) }), SPAN), null); | |
| 336 | }); | |
| 337 | ||
| 338 | test("accepted first time is accepted over every agent pull request that settled", () => { | |
| 339 | assert.deepEqual(acceptance(["accepted", "accepted", "fixed", "dropped", "finished"]), { value: 0.5, of: 4 }); | |
| 340 | assert.equal(acceptance(["finished"]), null); | |
| 341 | }); | |
| 342 | ||
| 343 | test("the span's work: pull requests and sessions, sources, and the 7 days before", () => { | |
| 344 | const work = spanWork({ | |
| 345 | span: SPAN, | |
| 346 | slug: "acme", | |
| 347 | code: { | |
| 348 | pulls: [ | |
| 349 | pull({ number: 1 }), | |
| 350 | pull({ number: 2 }), | |
| 351 | pull({ number: 3, status: "open", mergedAt: null }), | |
| 352 | // A person's pull request is not agent work. | |
| 353 | pull({ number: 4, author: { username: "bo", kind: "user" } }), | |
| 354 | // The 7 days before the span: one accepted, one fixed. | |
| 355 | pull({ number: 5, mergedAt: iso(NOW - 4 * DAY), updatedAt: iso(NOW - 4 * DAY) }), | |
| 356 | pull({ number: 6, mergedAt: iso(NOW - 5 * DAY), updatedAt: iso(NOW - 5 * DAY) }), | |
| 357 | // Older than that: not counted. | |
| 358 | pull({ number: 7, mergedAt: iso(NOW - 11 * DAY), updatedAt: iso(NOW - 11 * DAY) }), | |
| 359 | ], | |
| 360 | revisions: { "acme/web#2": 1, "acme/web#6": 2 }, | |
| 361 | complete: true, | |
| 362 | }, | |
| 363 | sessions: { sessions: [session(), session({ id: "s2", kind: "routine", status: "working", finished_at: null }), session({ id: "s3", parent_id: "s1" })], complete: true }, | |
| 364 | }); | |
| 365 | assert.equal(work.tasks.length, 3); | |
| 366 | assert.deepEqual(work.counts, { accepted: 1, fixed: 1, finished: 1, dropped: 0 }); | |
| 367 | assert.deepEqual(work.rate, { value: 0.5, of: 2 }); | |
| 368 | assert.deepEqual(work.before, { value: 0.5, of: 2 }); | |
| 369 | assert.deepEqual(work.sources, [ | |
| 370 | { source: "code", count: 2 }, | |
| 371 | { source: "chat", count: 1 }, | |
| 372 | ]); | |
| 373 | assert.equal(work.code, "read"); | |
| 374 | assert.equal(work.partial, false); | |
| 375 | }); | |
| 376 | ||
| 377 | test("no trend when the 7 days before could not all be read, and none without Code", () => { | |
| 378 | const partial = spanWork({ span: SPAN, slug: "acme", code: { pulls: [pull()], revisions: {}, complete: false }, sessions: null }); | |
| 379 | assert.equal(partial.before, null); | |
| 380 | assert.equal(partial.partial, true); | |
| 381 | assert.equal(partial.sessions, "unavailable"); | |
| 382 | const member = spanWork({ span: SPAN, slug: "acme", code: "no_access", sessions: { sessions: [session()], complete: true } }); | |
| 383 | assert.equal(member.code, "no_access"); | |
| 384 | assert.equal(member.rate, null); | |
| 385 | assert.equal(member.tasks.every((t) => t.source !== "code"), true); | |
| 386 | }); | |
| 387 | ||
| 388 | test("the trend is in whole points", () => { | |
| 389 | assert.equal(trendLabel({ value: 0.89 }, { value: 0.83 }), "+6 pts vs the 7 days before"); | |
| 390 | assert.equal(trendLabel({ value: 0.5 }, { value: 0.75 }), "−25 pts vs the 7 days before"); | |
| 391 | assert.equal(trendLabel({ value: 0.5 }, { value: 0.5 }), "Same as the 7 days before"); | |
| 392 | assert.equal(trendLabel(null, { value: 0.5 }), null); | |
| 393 | }); | |
| 394 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 395 | // --- People and agents ------------------------------------------------------------ |
| 396 | ||
| 397 | const ANA = "user:usr_ana"; | |
| 398 | const BO = "user:usr_bo"; | |
| 399 | const CY = "user:usr_cy"; | |
| 400 | const MARGO = "agent:agt_margo"; | |
| 401 | const G1T = "user:usr_g1t_agent"; | |
| 402 | const NAMES = { [ANA]: "ana", [BO]: "bo", [CY]: "cy", [MARGO]: "margo" }; | |
| 403 | const REPOS = { | |
| 404 | rep_web: { namespace: "acme", name: "web", defaultBranch: "main" }, | |
| 405 | rep_api: { namespace: "acme", name: "api", defaultBranch: "main" }, | |
| 406 | rep_docs: { namespace: "acme", name: "docs", defaultBranch: "main" }, | |
| 407 | rep_cli: { namespace: "acme", name: "cli", defaultBranch: "trunk" }, | |
| 408 | }; | |
| 409 | ||
| 410 | const count = (actor: string, n: number): ActorCount => ({ actor, count: n }); | |
| 411 | const quietRepo = (repo_id: string): RepoDigest => ({ | |
| 412 | repo_id, | |
| 413 | pushes: { count: 0, commits: 0, branches: [], by: [], default_branch: { count: 0, commits: 0, by: [], last_at: null } }, | |
| 414 | pulls: { opened: [], merged: [], closed: [] }, | |
| 415 | issues: { opened: [], closed: [] }, | |
| 416 | reviews: [], | |
| 417 | comments: [], | |
| 418 | deployments: { succeeded: [], failed: [], production: 0 }, | |
| 419 | releases: [], | |
| 420 | packages: [], | |
| 421 | }); | |
| 422 | ||
| 423 | /** | |
| 424 | * A day on a busy workspace: 3 people pushed 41 commits to 4 projects, | |
| 425 | * merged 5 pull requests and opened 2; agents (g1t and Margo) pushed 6, | |
| 426 | * opened 3 pull requests and reviewed 2; 2 production deploys went out; | |
| 427 | * 120 messages were sent, 15 of them Margo's; 7 docs were edited. | |
| 428 | */ | |
| 429 | function busyDay(): ActivityDigest { | |
| 430 | return { | |
| 431 | from: iso(SPAN.from), | |
| 432 | until: iso(SPAN.now), | |
| 433 | complete: true, | |
| 434 | repos: [ | |
| 435 | { | |
| 436 | ...quietRepo("rep_web"), | |
| 437 | pushes: { | |
| 438 | count: 14, | |
| 439 | commits: 33, | |
| 440 | branches: ["fix-login", "main"], | |
| 441 | by: [ | |
| 442 | { actor: ANA, count: 8, commits: 24 }, | |
| 443 | { actor: BO, count: 4, commits: 5 }, | |
| 444 | { actor: G1T, count: 2, commits: 4 }, | |
| 445 | ], | |
| 446 | default_branch: { count: 3, commits: 9, by: [{ actor: ANA, count: 3, commits: 9 }], last_at: iso(NOW - 2 * HOUR) }, | |
| 447 | }, | |
| 448 | pulls: { opened: [count(ANA, 1), count(G1T, 2), count(MARGO, 1)], merged: [count(ANA, 3), count(BO, 1)], closed: [] }, | |
| 449 | issues: { opened: [count(BO, 2)], closed: [count(ANA, 1), count(G1T, 1)] }, | |
| 450 | reviews: [count(BO, 2), count(G1T, 2)], | |
| 451 | comments: [count(ANA, 4), count(MARGO, 3)], | |
| 452 | deployments: { succeeded: [count(ANA, 2)], failed: [], production: 2 }, | |
| 453 | releases: [{ tag: "v1.4.0", name: "Autumn", actor: ANA, at: iso(NOW - 3 * HOUR) }], | |
| 454 | packages: [], | |
| 455 | }, | |
| 456 | { | |
| 457 | ...quietRepo("rep_api"), | |
| 458 | pushes: { count: 5, commits: 8, branches: ["main"], by: [{ actor: CY, count: 5, commits: 8 }], default_branch: { count: 5, commits: 8, by: [{ actor: CY, count: 5, commits: 8 }], last_at: iso(NOW - 5 * HOUR) } }, | |
| 459 | pulls: { opened: [count(CY, 1)], merged: [count(CY, 1)], closed: [] }, | |
| 460 | packages: [{ ecosystem: "container", name: "api", version: "1.4.0", actor: G1T, at: iso(NOW - HOUR) }], | |
| 461 | }, | |
| 462 | { | |
| 463 | ...quietRepo("rep_docs"), | |
| 464 | pushes: { count: 2, commits: 2, branches: ["typo"], by: [{ actor: BO, count: 2, commits: 2 }], default_branch: { count: 0, commits: 0, by: [], last_at: null } }, | |
| 465 | }, | |
| 466 | { | |
| 467 | ...quietRepo("rep_cli"), | |
| 468 | pushes: { count: 3, commits: 4, branches: ["trunk"], by: [{ actor: ANA, count: 1, commits: 2 }, { actor: MARGO, count: 2, commits: 2 }], default_branch: { count: 3, commits: 4, by: [{ actor: ANA, count: 1, commits: 2 }, { actor: MARGO, count: 2, commits: 2 }], last_at: iso(NOW - 30 * 60_000) } }, | |
| 469 | }, | |
| 470 | ], | |
| 471 | folios: { | |
| 472 | created: [count(ANA, 1)], | |
| 473 | edited_count: 7, | |
| 474 | edited: [ | |
| 475 | { folio_id: "fol_1", kind: "doc", authors: [ANA, MARGO] }, | |
| 476 | { folio_id: "fol_2", kind: "doc", authors: [ANA] }, | |
| 477 | { folio_id: "fol_3", kind: "doc", authors: [BO] }, | |
| 478 | { folio_id: "fol_4", kind: "doc", authors: [BO] }, | |
| 479 | { folio_id: "fol_5", kind: "doc", authors: [CY] }, | |
| 480 | { folio_id: "fol_6", kind: "doc", authors: [CY] }, | |
| 481 | { folio_id: "fol_7", kind: "doc", authors: [MARGO] }, | |
| 482 | ], | |
| 483 | }, | |
| 484 | }; | |
| 485 | } | |
| 486 | ||
| 487 | const busyChat: ChatActivity = { | |
| 488 | from: iso(SPAN.from), | |
| 489 | until: iso(SPAN.now), | |
| 490 | messages: 120, | |
| 491 | channels: 5, | |
| 492 | authors: [ | |
| 493 | { key: ANA, messages: 60 }, | |
| 494 | { key: BO, messages: 30 }, | |
| 495 | { key: CY, messages: 15 }, | |
| 496 | { key: MARGO, messages: 15 }, | |
| 497 | ], | |
| 498 | }; | |
| 499 | ||
| 500 | /** Seven agent tasks that settled well, and one that did not. */ | |
| 501 | function busyWork() { | |
| 502 | return spanWork({ | |
| 503 | span: SPAN, | |
| 504 | slug: "acme", | |
| 505 | code: { | |
| 506 | pulls: [1, 2, 3, 4, 5].map((n) => pull({ number: n, mergedAt: iso(NOW - n * HOUR), updatedAt: iso(NOW - n * HOUR) })).concat(pull({ number: 6, status: "closed", mergedAt: null })), | |
| 507 | revisions: { "acme/web#2": 1 }, | |
| 508 | complete: true, | |
| 509 | }, | |
| 510 | sessions: { sessions: [session(), session({ id: "s2", kind: "routine" })], complete: true }, | |
| 511 | }); | |
| 512 | } | |
| 513 | ||
| 514 | test("an actor is an agent when it is one of the workspace's agents or g1t; everyone else is a person", () => { | |
| 515 | assert.equal(actorKind(MARGO), "agent"); | |
| 516 | assert.equal(actorKind(G1T), "agent"); | |
| 517 | assert.equal(actorKind(ANA), "person"); | |
| 518 | assert.equal(actorKind(""), "person"); | |
| 519 | assert.equal(actorKind("user:usr_bot", new Set(["user:usr_bot"])), "agent"); | |
| 520 | }); | |
| 521 | ||
| 522 | test("the digest: what people did, in lines with counts, and who", () => { | |
| 523 | const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true }); | |
| 524 | assert.deepEqual( | |
| 525 | digest.people.lines.map((line) => [line.text, line.to]), | |
| 526 | [ | |
| 527 | ["3 people pushed 41 commits to 4 projects", "/acme/-/repositories"], | |
| 528 | ["opened 2 pull requests, merged 5", "/acme/-/repositories"], | |
| 529 | ["reviewed 2 pull requests", "/acme/web/pulls"], | |
| 530 | ["opened 2 issues, closed 1", "/acme/web/issues"], | |
| 531 | ["left 4 comments", "/acme/web/issues"], | |
| 532 | ["2 deploys went out", "/acme/web/deployments"], | |
| 533 | ["published 1 release", "/acme/web/releases"], | |
| 534 | ["created 1 doc, edited 6 docs", "/acme/-/artifacts"], | |
| 535 | ["sent 105 messages in 5 conversations", "/acme/-/chat"], | |
| 536 | ], | |
| 537 | ); | |
| 538 | // Faces: most active first, by name. | |
| 539 | assert.deepEqual( | |
| 540 | digest.people.actors.map((actor) => [actor.name, actor.kind]), | |
| 541 | [ | |
| 542 | ["ana", "person"], | |
| 543 | ["bo", "person"], | |
| 544 | ["cy", "person"], | |
| 545 | ], | |
| 546 | ); | |
| 547 | assert.deepEqual(digest.missing, []); | |
| 548 | assert.equal(digest.partial, false); | |
| 549 | }); | |
| 550 | ||
| 551 | test("the digest: what agents did, their tasks first, and g1t and the workspace's agents as agents", () => { | |
| 552 | const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true }); | |
| 553 | assert.deepEqual( | |
| 554 | digest.agents.lines.map((line) => line.text), | |
| 555 | [ | |
| 556 | "finished 7 tasks, 1 didn't finish", | |
| 557 | "ran 2 sessions", | |
| 558 | "pushed 6 commits to 2 projects", | |
| 559 | "opened 3 pull requests", | |
| 560 | "reviewed 2 pull requests", | |
| 561 | "closed 1 issue", | |
| 562 | "left 3 comments", | |
| 563 | "published 1 package version", | |
| 564 | "edited 2 docs", | |
| 565 | "replied 15 times in chat", | |
| 566 | ], | |
| 567 | ); | |
| 568 | assert.deepEqual( | |
| 569 | digest.agents.actors.map((actor) => [actor.name, actor.kind, actor.key]), | |
| 570 | [ | |
| 571 | ["margo", "agent", MARGO], | |
| 572 | ["g1t", "agent", G1T], | |
| 573 | ], | |
| 574 | ); | |
| 575 | assert.deepEqual(digest.summary, { people: 3, commits: 41, merged: 5, finished: 7, dropped: 1, said: 105, anything: true, peopleRead: true }); | |
| 576 | }); | |
| 577 | ||
| 578 | test("the digest says which part didn't answer, and counts what could be read", () => { | |
| 579 | const noChat = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: null, names: NAMES, work: busyWork(), code: true }); | |
| 580 | assert.deepEqual(noChat.missing, ["Chat"]); | |
| 581 | assert.equal(noChat.people.lines.some((line) => line.key === "chat"), false); | |
| 582 | const noCode = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: busyChat, names: NAMES, work: null, code: true }); | |
| 583 | assert.deepEqual(noCode.missing, ["Code", "Agents"]); | |
| 584 | assert.deepEqual( | |
| 585 | noCode.people.lines.map((line) => line.text), | |
| 586 | ["sent 105 messages in 5 conversations"], | |
| 587 | ); | |
| 588 | assert.equal(noCode.summary.peopleRead, true); | |
| 589 | // Without Code access, events are read for artifacts only: no repositories, nothing missing. | |
| 590 | const member = digestOf({ slug: "acme", repos: {}, activity: { ...busyDay(), repos: [] }, chat: busyChat, names: NAMES, work: busyWork(), code: false }); | |
| 591 | assert.deepEqual(member.missing, []); | |
| 592 | assert.deepEqual( | |
| 593 | member.people.lines.map((line) => line.key), | |
| 594 | ["docs", "chat"], | |
| 595 | ); | |
| 596 | // A digest cut short says so. | |
| 597 | const cut = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), complete: false }, chat: busyChat, names: NAMES, work: busyWork(), code: true }); | |
| 598 | assert.equal(cut.partial, true); | |
| 599 | // Nobody named: a mirror's pushes are still commits. | |
| 600 | const mirror = digestOf({ | |
| 601 | slug: "acme", | |
| 602 | repos: REPOS, | |
| 603 | activity: { ...busyDay(), folios: null, repos: [{ ...quietRepo("rep_web"), pushes: { count: 2, commits: 7, branches: ["main"], by: [{ actor: "", count: 2, commits: 7 }], default_branch: { count: 2, commits: 7, by: [{ actor: "", count: 2, commits: 7 }], last_at: iso(NOW - HOUR) } } }] }, | |
| 604 | chat: null, | |
| 605 | names: {}, | |
| 606 | work: null, | |
| 607 | code: true, | |
| 608 | }); | |
| 609 | assert.deepEqual( | |
| 610 | mirror.people.lines.map((line) => [line.text, line.to]), | |
| 611 | [["7 commits pushed to 1 project", "/acme/web/activity"]], | |
| 612 | ); | |
| 613 | assert.equal(mirror.summary.people, 0); | |
| 614 | }); | |
| 615 | ||
| 616 | test("the sentence: people first, then the agents' tasks, then what landed; honest when quiet or unread", () => { | |
| 617 | const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true }); | |
| 618 | assert.equal( | |
| 619 | spanSentence({ words: "Since Tuesday evening" }, digest, 12), | |
| 620 | "Since Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents finished 7 tasks, and 12 changes landed.", | |
| 621 | ); | |
| 622 | const quiet = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), repos: [], folios: { created: [], edited: [], edited_count: 0 } }, chat: { ...busyChat, messages: 0, channels: 0, authors: [] }, names: {}, work: busyWork(), code: true }); | |
| 623 | quiet.summary.finished = 0; | |
| 624 | quiet.summary.dropped = 0; | |
| 625 | quiet.summary.anything = false; | |
| 626 | assert.equal(spanSentence({ words: "Since Tuesday evening" }, quiet, 0), "Quiet since Tuesday evening: nothing pushed, merged, deployed or said."); | |
| 627 | assert.equal(spanSentence({ words: "In the last 24 hours" }, quiet, 0), "Quiet in the last 24 hours: nothing pushed, merged, deployed or said."); | |
| 628 | // Only agents' tasks and merges. | |
| 629 | const agentsOnly = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), repos: [], folios: null }, chat: { ...busyChat, authors: [], messages: 0, channels: 0 }, names: {}, work: busyWork(), code: true }); | |
| 630 | assert.equal(spanSentence({ words: "Since this morning" }, agentsOnly, 5), "Since this morning, agents finished 7 tasks and 5 changes landed."); | |
| 631 | // People only talked. | |
| 632 | const talked = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), repos: [], folios: null }, chat: busyChat, names: NAMES, work: null, code: true }); | |
| 633 | assert.equal(spanSentence({ words: "Since an hour ago" }, talked, 0), "Since an hour ago, 3 people sent 105 messages."); | |
| 634 | // Neither events nor chat answered. | |
| 635 | const unread = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: null, names: {}, work: busyWork(), code: true }); | |
| 636 | assert.equal(spanSentence({ words: "Since this morning" }, unread, 5), "Since this morning, agents finished 7 tasks and 5 changes landed; what people did couldn't be read."); | |
| 637 | const nothingRead = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: null, names: {}, work: null, code: true }); | |
| 638 | assert.equal(spanSentence({ words: "Since this morning" }, nothingRead, 0), "Since this morning, what people did couldn't be read, and no agent task settled."); | |
| 639 | assert.equal(spanSentence({ words: "Since this morning" }, null, null), "Since this morning, what happened couldn't be read."); | |
| 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. | 640 | assert.equal(waitingSentence(0), "Nothing needs you right now."); |
| 641 | assert.equal(waitingSentence(1), "1 thing needs you."); | |
| 642 | assert.equal(waitingSentence(8), "8 things need you."); | |
| 643 | }); | |
| 644 | ||
| 645 | test("what landed: every merge in the span, a person's or an agent's, newest first", () => { | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 646 | const merged = landedIn( |
| 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. | 647 | [ |
| 648 | pull({ number: 1, mergedAt: iso(NOW - 2 * DAY), mergedBy: "bo" }), | |
| 649 | pull({ number: 2, author: { username: "bo", kind: "user" }, mergedAt: iso(NOW - HOUR) }), | |
| 650 | pull({ number: 3, mergedAt: iso(NOW - 5 * DAY) }), | |
| 651 | pull({ number: 4, status: "open", mergedAt: null }), | |
| 652 | ], | |
| 653 | SPAN, | |
| 654 | ); | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 655 | assert.deepEqual(merged.map((change) => [change.kind, change.title, change.detail, change.agent]), [ |
| 656 | ["merge", "Fix the flaky test", "web#2 · by bo", false], | |
| 657 | ["merge", "Fix the flaky test", "web#1 · by @g1t, merged by bo", true], | |
| 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. | 658 | ]); |
| 659 | }); | |
| 660 | ||
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 661 | test("what landed, besides merges: direct pushes, releases, packages and production builds, newest first", () => { |
| 662 | const fromActivity = landedFromActivity(busyDay(), REPOS, NAMES, "acme"); | |
| 663 | assert.deepEqual( | |
| 664 | fromActivity.map((change) => [change.kind, change.title, change.detail, change.to, change.agent]), | |
| 665 | [ | |
| 666 | ["push", "9 commits pushed to main", "web · 3 pushes by ana", "/acme/web/activity", false], | |
| 667 | ["release", "v1.4.0 · Autumn", "web · released by ana", "/acme/web/releases/tag/v1.4.0", false], | |
| 668 | ["push", "8 commits pushed to main", "api · 5 pushes by cy", "/acme/api/activity", false], | |
| 669 | ["package", "api 1.4.0", "container package · published by @g1t", "/acme/-/packages", true], | |
| 670 | ["push", "4 commits pushed to trunk", "cli · 3 pushes by ana and @margo", "/acme/cli/activity", false], | |
| 671 | ], | |
| 672 | ); | |
| 673 | const builds = landedDeploys([ | |
| 674 | { key: "deploy:site:d1", project: "site", kind: "production", outcome: "live", commit: "abcdef1", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d1", by: "g1t" }, | |
| 675 | { key: "deploy:site:d2", project: "site", kind: "preview", outcome: "live", commit: "abcdef2", branch: "fix", at: NOW - HOUR, to: "/acme/site/deployments/d2", by: "bo" }, | |
| 676 | { key: "deploy:site:d3", project: "site", kind: "production", outcome: "failed", commit: "abcdef3", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d3", by: "bo" }, | |
| 677 | ]); | |
| 678 | assert.deepEqual(builds.map((change) => [change.kind, change.title, change.agent]), [["deploy", "Production of site went live", true]]); | |
| 679 | const all = landed([fromActivity, builds]); | |
| 680 | assert.deepEqual( | |
| 681 | all.map((change) => change.key), | |
| 682 | ["push:rep_cli", "landed:deploy:site:d1", "package:rep_api:container:api:1.4.0", "push:rep_web", "release:rep_web:v1.4.0", "push:rep_api"], | |
| 683 | ); | |
| 684 | }); | |
| 685 | ||
| 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. | 686 | const deploy = (over: Partial<Deployment>): Deployment => ({ |
| 687 | id: "d1", | |
| 688 | kind: "production", | |
| 689 | branch: null, | |
| 690 | number: null, | |
| 691 | commit: "abcdef1234", | |
| 692 | status: "ready", | |
| 693 | url: "https://site.g1t.page", | |
| 694 | error: null, | |
| 695 | warnings: [], | |
| 696 | buildSeconds: 40, | |
| 697 | createdBy: "bo", | |
| 698 | createdAt: iso(NOW - 2 * HOUR), | |
| 699 | finishedAt: iso(NOW - HOUR), | |
| 700 | ...over, | |
| 701 | }); | |
| 702 | ||
| 703 | test("builds that finished in the span: live or failed, never skipped", () => { | |
| 704 | const rows = deploysIn( | |
| 705 | [ | |
| 706 | { | |
| 707 | slug: "site", | |
| 708 | deployments: [ | |
| 709 | deploy({}), | |
| 710 | deploy({ id: "d2", status: "replaced", finishedAt: iso(NOW - DAY) }), | |
| 711 | deploy({ id: "d3", status: "failed", finishedAt: iso(NOW - 2 * DAY) }), | |
| 712 | deploy({ id: "d4", status: "skipped", finishedAt: iso(NOW - HOUR) }), | |
| 713 | deploy({ id: "d5", status: "building", finishedAt: null }), | |
| 714 | deploy({ id: "d6", finishedAt: iso(NOW - 5 * DAY) }), | |
| 715 | ], | |
| 716 | }, | |
| 717 | ], | |
| 718 | "acme", | |
| 719 | SPAN, | |
| 720 | ); | |
| 721 | assert.deepEqual(rows.map((row) => [row.key, row.outcome]), [ | |
| 722 | ["deploy:site:d1", "live"], | |
| 723 | ["deploy:site:d2", "live"], | |
| 724 | ["deploy:site:d3", "failed"], | |
| 725 | ]); | |
| 726 | assert.equal(rows[0]!.to, "/acme/site/deployments/d1"); | |
| 727 | assert.equal(rows[0]!.commit, "abcdef1"); | |
| 728 | }); | |
| 729 | ||
| 730 | const request = (over: Partial<InstallRequest>): InstallRequest => ({ | |
| 731 | id: "r1", | |
| 732 | listing: "integration:sentry", | |
| 733 | kind: "integration", | |
| 734 | name: "Sentry", | |
| 735 | note: "For the error alerts", | |
| 736 | requested_by: "bo", | |
| 737 | requested_at: iso(NOW - 2 * DAY), | |
| 738 | status: "open", | |
| 739 | resolved_by: null, | |
| 740 | resolved_at: null, | |
| 741 | ...over, | |
| 742 | }); | |
| 743 | ||
| 744 | const memory = (over: Partial<Memory>): Memory => ({ | |
| 745 | id: "m1", | |
| 746 | scope: "workspace", | |
| 747 | workspace: "acme", | |
| 748 | repo: null, | |
| 749 | text: "Use Postgres for the ledger", | |
| 750 | kind: "decision", | |
| 751 | source: { kind: "person" } as Memory["source"], | |
| 752 | createdBy: "ada", | |
| 753 | pinned: false, | |
| 754 | createdAt: iso(NOW - DAY), | |
| 755 | updatedAt: iso(NOW - DAY), | |
| 756 | lastUsedAt: null, | |
| 757 | ...over, | |
| 758 | }); | |
| 759 | ||
| 760 | test("decisions: kept decisions in memory and requests answered, in the span", () => { | |
| 761 | const rows = decisionsIn( | |
| 762 | { | |
| 763 | memories: [memory({}), memory({ id: "m2", kind: "fact" }), memory({ id: "m3", status: "candidate" }), memory({ id: "m4", createdAt: iso(NOW - 6 * DAY) })], | |
| 764 | requests: [request({}), request({ id: "r2", status: "declined", resolved_by: "ada", resolved_at: iso(NOW - HOUR) })], | |
| 765 | }, | |
| 766 | "acme", | |
| 767 | SPAN, | |
| 768 | ); | |
| 769 | assert.deepEqual(rows.map((row) => row.key), ["request:r2", "memory:m1"]); | |
| 770 | assert.equal(rows[0]!.text, "Turned down Sentry, which bo asked for"); | |
| 771 | }); | |
| 772 | ||
| 773 | test("an open install request needs an owner; answered ones don't", () => { | |
| 774 | assert.equal(installRequestRow(request({}), "acme")?.action.to, "/acme/-/marketplace/requests"); | |
| 775 | assert.equal(installRequestRow(request({ status: "done" }), "acme"), null); | |
| 776 | const base = { slug: "acme", code: true, codeNeeds: [], capped: [], agents: [], canManage: true, notifications: [], chat: [] }; | |
| 777 | assert.equal(attention({ ...base, requests: [request({})] }).rows[0]?.kind, "install_request"); | |
| 778 | assert.equal(attention({ ...base, requests: null }).rows.length, 0); | |
| 779 | }); | |
| 780 | ||
| 781 | test("the workspace's agent budget used up comes first, for those who may raise it", () => { | |
| 782 | assert.equal(limitRow({ alert: 90, spentMicros: 1, since: NOW }, "acme"), null); | |
| 783 | const limit = { alert: 100, spentMicros: 500_000_000, since: NOW - DAY }; | |
| 784 | assert.equal(limitRow(limit, "acme")?.action.to, "/acme/-/spend"); | |
| 785 | const base = { slug: "acme", code: true, codeNeeds: [need({})], capped: [], agents: [], notifications: [], chat: [], limit }; | |
| 786 | assert.equal(attention({ ...base, canManage: true }).start?.row.kind, "limit"); | |
| 787 | assert.equal(attention({ ...base, canManage: false }).rows.some((r) => r.kind === "limit"), false); | |
| 788 | }); | |
| 789 | ||
| 790 | test("running now: live root sessions, agents' changes and builds going; a capped session is under Needs you", () => { | |
| 791 | const sessions = runningSessions( | |
| 792 | [ | |
| 793 | session({ id: "a", status: "working", finished_at: null, created_at: iso(NOW - HOUR) }), | |
| 794 | session({ id: "b", status: "needs_approval", finished_at: null }), | |
| 795 | session({ id: "c", status: "queued", finished_at: null, parent_id: "a" }), | |
| 796 | session({ id: "d", status: "done" }), | |
| 797 | session({ id: "e", status: "queued", finished_at: null, visible: false, created_at: iso(NOW - 2 * HOUR) }), | |
| 798 | ], | |
| 799 | "acme", | |
| 800 | ); | |
| 801 | assert.deepEqual(sessions.map((row) => [row.key, row.status]), [ | |
| 802 | ["session:a", "Working"], | |
| 803 | ["session:e", "Queued"], | |
| 804 | ]); | |
| 805 | assert.equal(sessions[1]!.title, "A session in a conversation you're not in"); | |
| 806 | const builds = runningDeploys( | |
| 807 | [ | |
| 808 | { slug: "site", enabled: true, production: null, previews: 0, latest: deploy({ status: "building", finishedAt: null }) }, | |
| 809 | { slug: "docs", enabled: true, production: null, previews: 0, latest: deploy({}) }, | |
| 810 | ], | |
| 811 | "acme", | |
| 812 | ); | |
| 813 | assert.deepEqual(builds.map((row) => row.status), ["Building"]); | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 814 | const run = (over: Partial<WorkflowRun>): WorkflowRun => ({ |
| 815 | id: "run_1", | |
| 816 | workflowId: "wf_1", | |
| 817 | path: ".g1t/workflows/ci.yml", | |
| 818 | name: "CI", | |
| 819 | title: "Fix the flaky test", | |
| 820 | number: 12, | |
| 821 | attempt: 1, | |
| 822 | event: "push", | |
| 823 | ref: "refs/heads/main", | |
| 824 | sha: "abcdef1", | |
| 825 | pull: null, | |
| 826 | status: "in_progress", | |
| 827 | conclusion: null, | |
| 828 | error: null, | |
| 829 | actor: "ana", | |
| 830 | createdAt: iso(NOW - 10 * 60_000), | |
| 831 | startedAt: iso(NOW - 9 * 60_000), | |
| 832 | finishedAt: null, | |
| 833 | ...over, | |
| 834 | }); | |
| 835 | const workflows = runningWorkflows([ | |
| 836 | { repo, runs: [run({}), run({ id: "run_2", status: "queued", startedAt: null, title: "" }), run({ id: "run_3", status: "completed", conclusion: "success" }), run({ id: "run_4", status: "action_required" })] }, | |
| 837 | ]); | |
| 838 | assert.deepEqual( | |
| 839 | workflows.map((row) => [row.key, row.title, row.detail, row.status, row.to]), | |
| 840 | [ | |
| 841 | ["workflow:acme/web:run_1", "CI: Fix the flaky test", "acme/web main · by ana", "Running", "/acme/web/actions/runs/run_1"], | |
| 842 | ["workflow:acme/web:run_2", "CI", "acme/web main · by ana", "Queued", "/acme/web/actions/runs/run_2"], | |
| 843 | ["workflow:acme/web:run_4", "CI: Fix the flaky test", "acme/web main · by ana", "Needs approval", "/acme/web/actions/runs/run_4"], | |
| 844 | ], | |
| 845 | ); | |
| 846 | // Longest-going first within a kind: sessions, then changes, then workflow runs, then builds. | |
| 847 | assert.deepEqual(running([builds, workflows.slice(0, 1), sessions]).map((row) => row.key), ["session:e", "session:a", "workflow:acme/web:run_1", "deploy:site:d1"]); | |
| 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. | 848 | }); |
| 849 | ||
| 850 | ||
| 851 | test("the span's months: one, or the ones it crosses", () => { | |
| 852 | assert.deepEqual(spanMonths(SPAN), ["2026-10"]); | |
| 853 | assert.deepEqual(spanMonths({ from: Date.parse("2026-09-28T00:00:00Z"), now: NOW }), ["2026-09", "2026-10"]); | |
| 854 | assert.deepEqual(spanMonths({ from: Date.parse("2025-12-30T00:00:00Z"), now: Date.parse("2026-01-02T00:00:00Z") }), ["2025-12", "2026-01"]); | |
| 855 | }); | |
| 856 | ||
| 857 | test("spend in the span: the UTC days it covers across months, at price, without money in", () => { | |
| 858 | const september = { | |
| 859 | groups: [ | |
| 860 | { key: "2026-09-29", label: "", chargedMicros: 0, lines: [{ kind: "Agent runs", count: 2, chargedMicros: 1_000_000, costMicros: 0 }] }, | |
| 861 | { key: "2026-09-20", label: "", chargedMicros: 0, lines: [{ kind: "Agent runs", count: 9, chargedMicros: 9_000_000, costMicros: 0 }] }, | |
| 862 | ], | |
| 863 | totals: { chargedMicros: 10_000_000, paidMicros: 0, costMicros: 0, entries: 2 }, | |
| 864 | }; | |
| 865 | const october = { | |
| 866 | groups: [ | |
| 867 | { | |
| 868 | key: "2026-10-09", | |
| 869 | label: "2026-10-09", | |
| 870 | chargedMicros: 0, | |
| 871 | lines: [ | |
| 872 | { kind: "Agent runs", count: 12, chargedMicros: 3_000_000, costMicros: 2_500_000, priceMicros: 4_000_000 }, | |
| 873 | { kind: "Sandbox time", count: 12, chargedMicros: 600_000, costMicros: 500_000 }, | |
| 874 | { kind: "Payments", count: 1, chargedMicros: -50_000_000, costMicros: 0, priceMicros: 0 }, | |
| 875 | ], | |
| 876 | }, | |
| 877 | ], | |
| 878 | totals: { chargedMicros: 12_600_000, paidMicros: 50_000_000, costMicros: 0, entries: 4, priceMicros: 13_600_000 }, | |
| 879 | }; | |
| 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. | 880 | const spend = spendIn([september, october], { from: Date.parse("2026-09-29T15:00:00Z"), now: NOW }, 13_609_000); |
| 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. | 881 | assert.deepEqual([spend.from, spend.to], ["2026-09-29", "2026-10-09"]); |
| 882 | assert.equal(spend.totalMicros, 5_600_000); | |
| 883 | assert.deepEqual(spend.lines.map((l) => [l.kind, l.micros, l.count]), [ | |
| 884 | ["Agent runs", 5_000_000, 14], | |
| 885 | ["Sandbox time", 600_000, 12], | |
| 886 | ]); | |
| 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. | 887 | // The month so far is billing's usage report, the top bar's figure, not the statement: it carries what is not yet closed. |
| 888 | assert.equal(spend.monthMicros, 13_609_000); | |
| 889 | assert.equal(spendIn([september, october], { from: Date.parse("2026-09-29T15:00:00Z"), now: NOW }, null).monthMicros, 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. | 890 | }); |