Skip to content

Commit

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.

syntaqxcommitted Parent85a2646Browse files
25 files+2771−2590/25 viewed
+127−25
88
99 1. **Where you're needed**: everything waiting on you, however long ago
1010 it started, with the action beside each item.
11−2. **What happened while you were away**: the agents' work and how much
12− of it was accepted the first time, what landed, deploys, decisions and
13− spend, since you were last here.
14−3. **What is running now**: agent sessions, agents' changes and builds
15− still going.
11+2. **What happened while you were away**: what people did and what
12+ agents did, side by side; what landed; deploys, decisions and spend,
13+ since you were last here.
14+3. **What is running now**: agent sessions, agents' changes, workflow
15+ runs and builds still going.
1616
1717 **Start here** picks the one item to do first, and **Ask g1t** at the top
1818 sends a message to g1t from the page.
9292
9393 The sentence under the heading sums it up, for example:
9494
95−> 8 things need you. Since Tuesday evening, agents finished 35 tasks and
96−> 12 changes landed. 4 needed a fix after review.
95+> 8 things need you. Since Tuesday evening, 3 people pushed 41 commits and
96+> merged 5 changes, agents finished 7 tasks, and 12 changes landed.
97+
98+It says what people did first (the commits they pushed and the changes
99+they merged; if neither, the messages they sent), then the agents' tasks
100+that settled well, then everything that [landed](#landed). When nothing at
101+all was counted, it says so plainly:
102+
103+> Quiet since Tuesday evening: nothing pushed, merged, deployed or said.
104+
105+When Code and Chat both failed to answer, the sentence ends with "what
106+people did couldn't be read" rather than reading as quiet.
107+
108+### People and agents
109+
110+The first card has two columns, **People** and **Agents**. Each lists what
111+that side did in the span, one line per kind of thing with its count, and
112+each line opens the place that lists them: a project's Activity tab for
113+pushes, its pull requests or issues, Artifacts, Chat. Beside the heading
114+are the faces of the five most active, and how many more there were.
115+
116+| Line | What counts | From |
117+| --- | --- | --- |
118+| *3 people pushed 41 commits to 4 projects* | Every push to a branch of one of the workspace's repositories (`git.push`; tags are not pushes). The commits are the ones each push brought to its branch, along the branch's first-parent line from where it is now back to where it was, at most 50, and one for a new branch. A push recorded before commits were counted counts as one commit. | Events |
119+| *opened 2 pull requests, merged 5, closed 1 without merging* | `pull.opened`, `pull.merged`, `pull.closed` | Events |
120+| *reviewed 3 pull requests* | A person's review is a comment with a verdict; an agent's is `review.completed` | Events |
121+| *opened 4 issues, closed 6* | `issue.opened`, `issue.closed` | Events |
122+| *left 9 comments* | `comment.created` without a verdict | Events |
123+| *2 deploys went out, 1 failed* | `deployment.succeeded` and `deployment.failed`, by who set the build off | Events |
124+| *published 1 release*, *published 2 package versions* | `release.published`, `package.published` | Events |
125+| *created 1 doc, edited 7 docs* | Artifacts made (`folio.created`), and artifacts whose content changed (`folio.updated`): each artifact once, and everyone whose changes a version holds edited it. "Docs" when every one is a doc, else "artifacts". Counts only: Home never shows an artifact's title here. | Events |
126+| *sent 120 messages in 5 conversations* (people), *replied 15 times in chat* (agents) | Text messages and thread replies that were not deleted, in the conversations you can read: every public channel, and the private channels and direct messages you are in. Cards agents post are not messages. Conversations are those with at least one message. | Chat |
127+| *finished 7 tasks, 1 didn't finish*, *ran 2 sessions* (agents only) | The agents' [tasks](#tasks) that settled in the span, and of those the sessions | Code and Agents |
128+
129+How a side is decided: everything is recorded with who did it. One of the
130+workspace's agents acting as itself, or g1t, is an agent; everyone else is
131+a person. A comment a workspace agent writes is the agent's, not the
132+person's it was working for. A push copied in by a mirror names nobody, so
133+it is counted under People without a face.
134+
135+Under the Agents column's lines, a compact row says how their work was
136+received: accepted first time, fixed after review, didn't finish, and the
137+share accepted first time with its change from the 7 days before (see
138+[Accepted first time](#accepted-first-time)), then one mark per task in
139+the order they settled.
140+
141+Each column says when it has nothing to show, and names the part that
142+didn't answer (Code, Chat or Agents) so an empty column never reads as a
143+quiet day.
97144
98145 ### Tasks
99146
125172
126173 ### Accepted first time
127174
128−The large percentage is:
175+The share in the Agents column is:
129176
130177 > pull requests accepted first time ÷ agents' pull requests that merged or
131178 > closed in the span
132179
133180 A pull request closed without merging counts against it. Sessions are not
134−part of it. With no agent pull request settled in the span, it shows as a
135−dash.
181+part of it. With no agent pull request settled in the span, the row says
182+so instead of a share.
136183
137−Under it, the change from **the 7 days before** is the same share over the
184+Beside it, the change from **the 7 days before** is the same share over the
138185 seven days before the span began, in whole percentage points. It is left
139186 out when nothing settled in those days, or when g1t could not read back far
140187 enough to count them all.
141188
142−Beside the percentage are the counts (tasks settled, accepted first time,
143−fixed after review, didn't finish) and a strip with one mark for each task,
144−coloured by its outcome, in the order they settled. Point at a mark to see
145−what it is, and select it to open it. A session in a conversation you are
146−not in shows as "A session in a conversation you're not in".
189+Under it, the counts (accepted first time, fixed after review, didn't
190+finish) and a strip with one mark for each task, coloured by its outcome,
191+in the order they settled. Point at a mark to see what it is, and select
192+it to open it. A session in a conversation you are not in shows as "A
193+session in a conversation you're not in".
147194
148195 ### Landed
149196
150−Every pull request that merged in the span, a person's or an agent's,
151−newest first, with who wrote it and who merged it when that was someone
152−else. The card's line counts them and how many were agents'.
197+Everything that landed in the span, newest first, a person's or an
198+agent's:
199+
200+| Row | What it is |
201+| --- | --- |
202+| A pull request merged | With who wrote it, and who merged it when that was someone else |
203+| Commits pushed to a default branch | One row per project with any: how many commits landed on its default branch without a pull request, in how many pushes, and by whom |
204+| A production build that went live | A project's production build that built and was served (previews stay under Deploys) |
205+| A release published | Its tag and name, and who released it |
206+| A package version published | Its name and version, and who published it |
207+
208+The card's line counts them by kind and how many were agents'. The first
209+five show; **Show more** opens the rest. When one of its sources (pull
210+requests, pushes and releases, deployments) didn't answer, the card says
211+so under the list.
153212
154213 ### Deploys
155214
194253 | --- | --- |
195254 | Session | An agent session at the root of its tree that is queued, working, or waiting on the helpers it started. One stopped at its spend cap waits on a person, so it is under Where you're needed instead. |
196255 | Change | An agent's pull request that is in the agent's hands: being worked on, waiting on its checks, or a draft still being made |
256+| Workflow run | A [workflow](/guides/actions/) run that is queued, waiting, running, or waiting for approval, in the ten projects pushed to most in the span (their newest 20 runs each) |
197257 | Build | A project's newest build, queued or building |
198258
199259 ## Without Code access
201261 A member whose membership does not include Code sees Home without anything
202262 from Code:
203263
204−- Tasks are sessions only. The large number is **Finished**, and the
205− counts are sessions finished and didn't finish. Accepted first time is
206− not shown, since it is measured on pull requests.
264+- People shows what was said and the artifacts edited; nothing from
265+ repositories. Agents shows sessions only: finished, didn't finish, and
266+ what they said and edited. Accepted first time is not shown, since it is
267+ measured on pull requests.
207268 - Where you're needed has no rows from Code, and no notifications about a
208269 repository.
209270 - Landed, Deploys and memory decisions are not shown, and Running now has
217278
218279 - Where you're needed and Running now name the parts that didn't answer,
219280 so an empty list never reads as nothing waiting.
220−- The agents' work counts what could be read and says which part is
221− missing. If neither answered, it says so instead of showing counts.
281+- People and Agents count what could be read, and each column names the
282+ part that is missing (Code, Chat or Agents). If nothing could be read,
283+ the card says so instead of showing columns, and the sentence under the
284+ heading says what people did couldn't be read.
222285 - Landed, Deploys and Spent each say their part didn't answer.
223286 - If your last visit can't be read, the span is the last 24 hours and the
224287 page says why.
225288
226289 When a list g1t reads is longer than it reads at once (200 sessions, 200
227290 revise runs, 100 pull requests per project and state, 50 projects, builds
228−of 20 projects), the page says that some work may be missing.
291+of 20 projects, 5,000 events of the span), the page says that some work
292+may be missing or some counts are low.
293+
294+## How it scales
295+
296+Every event in g1t is in one log, and its ids sort by time, so a span is a
297+range of ids. Home asks the events service for one digest of the span: for
298+each of the workspace's repositories (the first 50) and each kind of event
299+it counts, that is one range scan of an index on repository, type and id,
300+and the workspace's artifact events are the same range under no
301+repository. A busy week of tens of repositories is a few thousand rows,
302+read in one round trip; chat's count is one query over the conversations
303+you can read. When a workspace has hundreds of repositories, the next step
304+is a daily rollup per workspace that the digest reads instead of the log.
305+It is not built yet.
306+
307+## For services: activity_digest and activity
308+
309+Home's counts come from two methods other services can call too.
310+
311+**`activity_digest`** on the events service (`repo_ids`, `workspace`,
312+`from`, `until`; RFC 3339, `[from, until)`; the caller has checked the
313+viewer may read each repository) returns, per repository that had any
314+event: `pushes` (`count`, `commits`, `branches`, `by` with each actor's
315+pushes and commits, and `default_branch` with the same for pushes straight
316+to it and `last_at`), `pulls` (`opened`, `merged`, `closed`), `issues`
317+(`opened`, `closed`), `reviews`, `comments`, `deployments` (`succeeded`,
318+`failed`, `production`), `releases` and `packages` (newest first, at most
319+50 each); and for the workspace named, `folios` (`created`, `edited` with
320+each folio's authors, `edited_count`). Every count by actor is a list of
321+`{ actor, count }`, where `actor` is a member key: `user:<id>` for an
322+account (g1t is `user:usr_g1t_agent`), `agent:<id>` for one of the
323+workspace's agents acting as itself, or empty for nobody. `complete` is
324+false when more than 5,000 events fell in the span, or more than 50
325+repositories were named.
326+
327+**`activity`** on the chat service (`workspace`, `viewer`, `from`,
328+`until`) returns `messages`, `channels` (conversations with at least one)
329+and `authors` (`key`, `messages`; most first), over text messages and
330+thread replies not deleted in the conversations the viewer can read.
+1−1
6666
6767 | Event | When |
6868 | --- | --- |
69−| `git.push` | A branch moved. `data.ref`, `data.after`, `data.default_branch`. A push of more than 3 tags, or more than 1,000 branches, sends fewer; see [pushes of many branches or tags](/guides/git/#pushes-of-many-branches-or-tags). |
69+| `git.push` | A branch moved. `data.ref`, `data.after`, `data.default_branch`, and `data.commits` (how many commits the push brought to the branch, at most 50; absent for a tag, a deleted branch or a merge). A push of more than 3 tags, or more than 1,000 branches, sends fewer; see [pushes of many branches or tags](/guides/git/#pushes-of-many-branches-or-tags). |
7070 | `repo.created`, `repo.forked` | A repository was made, or forked for a pull request. |
7171 | `repo.updated` | Its description, website, topics, protection or visibility changed. |
7272 | `repo.visibility_changed` | It was made public or private. |
+254−117
11 /**
22 * Home's parts (routes/workspace/home.tsx): Where you're needed, Start
3− * here, what happened in the span (the agents' work, what landed, builds,
4− * decisions, spend) and Running now. Each takes what lib/home.ts worked
5− * out and only draws it.
3+ * here, what happened in the span (what people and agents did, what
4+ * landed, builds, decisions, spend) and Running now. Each takes what
5+ * lib/home.ts worked out and only draws it.
66 */
77 import {
88 AlertTriangle,
1111 Bell,
1212 Bot,
1313 CircleDollarSign,
14+ GitCommitHorizontal,
1415 GitMerge,
1516 GitPullRequest,
1617 Hourglass,
1718 Lightbulb,
1819 Loader,
1920 Mail,
21+ Package,
2022 Rocket,
2123 Server,
2224 Sparkles,
2325 Store,
26+ Tag,
2427 User,
28+ Users,
29+ Workflow,
2530 } from "lucide-react";
2631 import type { ReactNode } from "react";
2732 import { Link } from "react-router";
3237 type AttentionRow,
3338 type DecisionRow,
3439 type DeployRow,
40+ type Digest,
41+ type DigestActor,
42+ type DigestColumn,
3543 type LandedChange,
3644 type RunningRow,
3745 type Span,
4250 OUTCOME_LABEL,
4351 SOURCE_LABEL,
4452 WINDOWS,
53+ listOf,
4554 trendLabel,
4655 waited,
4756 whenShort,
4857 } from "../lib/home";
4958 import { cn } from "../lib/cn";
5059 import { money } from "../lib/money";
60+import { type AgentLike, AgentAvatar } from "./agent-avatar";
5161 import { ButtonLink } from "./ui";
62+import { Avatar } from "./ui/avatar";
63+import { Badge } from "./ui/badge";
5264 import { Skeleton } from "./ui/skeleton";
5365 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
5466
89101 return <p className="px-5 pb-5 text-sm text-muted">{children}</p>;
90102 }
91103
92−const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
93−
94104 function Missing({ names, what }: { names: string[]; what: string }) {
95105 if (names.length === 0) return null;
96106 return (
341351 return span.key === "last" && span.note == null ? "since you were last here" : span.words.charAt(0).toLowerCase() + span.words.slice(1);
342352 }
343353
344−export function SpanCard({ work, span }: { work: SpanWork | null; span: Pick<Span, "key" | "note" | "words"> }) {
345− if (!work) {
346− return (
347− <section className="rounded-2xl border border-line bg-surface p-5">
348− <p className="text-sm text-muted">The agents&apos; work couldn&apos;t be read right now. It shows here once Agents answers.</p>
349− </section>
350− );
354+/** The faces of a column's most active, with a tooltip naming each, and how many more there were. */
355+const FACES = 5;
356+
357+/** An agent's face by its member key, from the workspace's agents; g1t wears its mark. */
358+function faceOf(actor: DigestActor, faces: AgentLike[]): ReactNode {
359+ if (actor.kind === "agent") {
360+ const agent = faces.find((face) => `agent:${face.id}` === actor.key);
361+ return <AgentAvatar agent={agent ?? { handle: actor.name, builtin: actor.name === "g1t" }} size={22} />;
351362 }
363+ return <Avatar name={actor.name} size={22} />;
364+}
365+
366+function Faces({ actors, faces }: { actors: DigestActor[]; faces: AgentLike[] }) {
367+ if (actors.length === 0) return null;
368+ const shown = actors.slice(0, FACES);
369+ const more = actors.length - shown.length;
370+ return (
371+ <div className="flex items-center gap-1.5">
372+ <ul className="flex -space-x-1.5" aria-label="Most active">
373+ {shown.map((actor) => (
374+ <li key={actor.key} className="rounded-full ring-2 ring-surface">
375+ <Tooltip>
376+ <TooltipTrigger asChild>
377+ <span className="block">{faceOf(actor, faces)}</span>
378+ </TooltipTrigger>
379+ <TooltipContent>
380+ {actor.kind === "agent" ? `@${actor.name}` : actor.name} · {actor.count.toLocaleString("en-US")} {actor.count === 1 ? "thing" : "things"}
381+ </TooltipContent>
382+ </Tooltip>
383+ </li>
384+ ))}
385+ </ul>
386+ {more > 0 && <span className="text-xs text-muted">+{more.toLocaleString("en-US")}</span>}
387+ </div>
388+ );
389+}
390+
391+/** One column's lines: each what was done, with its count in the words, linked to where it is listed. */
392+function Lines({ column }: { column: DigestColumn }) {
393+ return (
394+ <ul className="mt-3 space-y-1.5">
395+ {column.lines.map((line) => (
396+ <li key={line.key} className="flex items-start gap-2 text-sm text-fg">
397+ <span className="mt-1.75 size-1.5 shrink-0 rounded-full bg-fg-soft/60" aria-hidden />
398+ {line.to ? (
399+ <Link to={line.to} className="hover:underline">
400+ {line.text}
401+ </Link>
402+ ) : (
403+ <span>{line.text}</span>
404+ )}
405+ </li>
406+ ))}
407+ </ul>
408+ );
409+}
410+
411+/** The agents' acceptance, in a compact row: the counts, the share accepted first time and its trend, and a mark per task. */
412+function Acceptance({ work, span }: { work: SpanWork; span: Pick<Span, "key" | "note" | "words"> }) {
352413 const { counts, rate, before, tasks } = work;
353414 const trend = trendLabel(rate, before);
354415 const up = rate && before ? Math.round(rate.value * 100) - Math.round(before.value * 100) : 0;
355416 // Without Code, nothing is reviewed: no accepted or fixed to count.
356417 const reviewed = work.code === "read";
357418 const shown = (Object.keys(MARK) as TaskOutcome[]).filter((o) => counts[o] > 0 || (reviewed && (o === "accepted" || o === "fixed")));
358− const when = inSpan(span);
359419 return (
360− <section className="grid overflow-hidden rounded-2xl border border-line bg-surface md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)]">
361− <div className="border-b border-line p-5 md:border-r md:border-b-0">
362− {work.code === "no_access" ? (
420+ <div className="mt-4 border-t border-line pt-4">
421+ <div className="grid grid-cols-3 gap-3">
422+ {reviewed ? (
363423 <>
364− <h3 className="text-sm font-semibold text-fg">Finished</h3>
365− <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{(counts.accepted + counts.fixed + counts.finished).toLocaleString("en-US")}</div>
366− <p className="mt-2 text-xs leading-relaxed text-muted">How much was accepted the first time is measured on pull requests, which are part of Code.</p>
424+ <Stat label="Accepted first time" value={counts.accepted} mark={MARK.accepted} />
425+ <Stat label="Fixed after review" value={counts.fixed} mark={MARK.fixed} />
367426 </>
368427 ) : (
369428 <>
370− <h3 className="text-sm font-semibold text-fg">Accepted first time</h3>
371− {rate ? (
372− <>
373− <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{percent(rate.value)}</div>
374− <p className="mt-2 text-xs text-muted">
375− of {rate.of.toLocaleString("en-US")} agent {rate.of === 1 ? "pull request" : "pull requests"} merged or closed {when}
376− </p>
377− <p className={`mt-1.5 text-xs font-medium ${up > 0 ? "text-success" : up < 0 ? "text-warn" : "text-muted"}`}>
378− {trend ?? (work.code === "read" ? "Nothing settled in the 7 days before to compare." : "")}
379− </p>
380− </>
381− ) : (
382− <>
383− <div className="mt-2 text-5xl font-semibold tracking-tight text-faint">—</div>
384− <p className="mt-2 text-xs leading-relaxed text-muted">
385− {work.code === "unavailable" ? "Code didn't answer, so pull requests aren't counted." : `No agent pull request merged or closed ${when}.`}
386− {before ? ` The 7 days before: ${percent(before.value)} of ${before.of}.` : ""}
387− </p>
388− </>
389− )}
429+ <Stat label="Sessions finished" value={counts.finished} mark={MARK.finished} />
430+ <Stat label="Tasks settled" value={tasks.length} />
390431 </>
391432 )}
433+ <Stat label="Didn't finish" value={counts.dropped} mark={MARK.dropped} />
392434 </div>
393− <div className="min-w-0 p-5">
394− <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
395− <Stat label="Tasks settled" value={tasks.length} />
396− {reviewed ? (
435+ {reviewed && (
436+ <p className="mt-2 text-xs text-muted">
437+ {rate ? (
397438 <>
398− <Stat label="Accepted first time" value={counts.accepted} mark={MARK.accepted} />
399− <Stat label="Fixed after review" value={counts.fixed} mark={MARK.fixed} />
439+ <span className="font-medium text-fg">{percent(rate.value)}</span> of {rate.of.toLocaleString("en-US")} agent {rate.of === 1 ? "pull request" : "pull requests"} accepted first time
440+ {trend && <span className={cn("ml-1.5 font-medium", up > 0 ? "text-success" : up < 0 ? "text-warn" : "text-muted")}>{trend}</span>}
400441 </>
401442 ) : (
402− <Stat label="Sessions finished" value={counts.finished} mark={MARK.finished} />
443+ <>
444+ No agent pull request merged or closed {inSpan(span)}.{before ? ` The 7 days before: ${percent(before.value)} of ${before.of}.` : ""}
445+ </>
403446 )}
404− <Stat label="Didn't finish" value={counts.dropped} mark={MARK.dropped} />
447+ </p>
448+ )}
449+ {tasks.length > 0 && (
450+ <ul className="mt-3 flex flex-wrap gap-0.75" aria-label="Agent work that settled, one mark per task, in the order they settled">
451+ {tasks.map((task) => (
452+ <li key={task.key}>
453+ <Tooltip>
454+ <TooltipTrigger asChild>
455+ {task.to ? (
456+ <Link to={task.to} aria-label={`${task.title ?? "A session"}: ${OUTCOME_LABEL[task.outcome]}`} className={`block h-4 w-2 rounded-xs ${MARK[task.outcome]} transition-opacity hover:opacity-70`} />
457+ ) : (
458+ <span className={`block h-4 w-2 rounded-xs ${MARK[task.outcome]}`} />
459+ )}
460+ </TooltipTrigger>
461+ <TooltipContent>
462+ <span className="block font-medium">{task.title ?? "A session in a conversation you're not in"}</span>
463+ <span className="text-muted">
464+ {OUTCOME_LABEL[task.outcome]} · {SOURCE_LABEL[task.source]}
465+ </span>
466+ </TooltipContent>
467+ </Tooltip>
468+ </li>
469+ ))}
470+ </ul>
471+ )}
472+ {tasks.length > 0 && (
473+ <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[0.6875rem] text-muted">
474+ {shown.map((outcome) => (
475+ <span key={outcome} className="inline-flex items-center gap-1">
476+ <span className={`size-1.5 rounded-xs ${MARK[outcome]}`} aria-hidden />
477+ {OUTCOME_LABEL[outcome]} <span className="text-fg-soft tabular-nums">{counts[outcome]}</span>
478+ </span>
479+ ))}
405480 </div>
406− {tasks.length === 0 ? (
407− <p className="mt-5 text-sm text-muted">No agent work settled {when}.</p>
408− ) : (
409− <div className="mt-5">
410− <ul className="flex flex-wrap gap-[3px]" aria-label="Agent work that settled">
411− {tasks.map((task) => (
412− <li key={task.key}>
413− <Tooltip>
414− <TooltipTrigger asChild>
415− {task.to ? (
416− <Link to={task.to} aria-label={`${task.title ?? "A session"}: ${OUTCOME_LABEL[task.outcome]}`} className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]} transition-opacity hover:opacity-70`} />
417− ) : (
418− <span className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]}`} />
419− )}
420− </TooltipTrigger>
421− <TooltipContent>
422− <span className="block font-medium">{task.title ?? "A session in a conversation you're not in"}</span>
423− <span className="text-muted">
424− {OUTCOME_LABEL[task.outcome]} · {SOURCE_LABEL[task.source]}
425− </span>
426− </TooltipContent>
427− </Tooltip>
428− </li>
429− ))}
430− </ul>
431− <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted">
432− <span className="text-faint">One mark per task, in the order they settled</span>
433− {shown.map((outcome) => (
434− <span key={outcome} className="inline-flex items-center gap-1.5">
435− <span className={`size-2 rounded-[2px] ${MARK[outcome]}`} aria-hidden />
436− {OUTCOME_LABEL[outcome]} <span className="text-fg-soft tabular-nums">{counts[outcome]}</span>
437− </span>
438− ))}
439− </div>
440− </div>
481+ )}
482+ </div>
483+ );
484+}
485+
486+/**
487+ * What people did and what agents did in the span, side by side: lines
488+ * with counts linked to where each thing is listed, the most active faces,
489+ * and in the Agents column how their work was received. Each column says
490+ * when it is empty, and which part didn't answer.
491+ */
492+export function PeopleAgentsCard({
493+ digest,
494+ work,
495+ span,
496+ faces,
497+ code,
498+}: {
499+ digest: Digest | null;
500+ work: SpanWork | null;
501+ span: Pick<Span, "key" | "note" | "words">;
502+ faces: AgentLike[];
503+ code: boolean;
504+}) {
505+ const when = inSpan(span);
506+ if (!digest) {
507+ return (
508+ <section className="rounded-2xl border border-line bg-surface p-5">
509+ <p className="text-sm text-muted">What happened couldn&apos;t be read right now. It shows here once Code, Chat and Agents answer.</p>
510+ </section>
511+ );
512+ }
513+ const missing = (names: string[]) => names.filter((name) => digest.missing.includes(name));
514+ const peopleMissing = missing(["Code", "Chat"]);
515+ const agentsMissing = missing(["Code", "Chat", "Agents"]);
516+ const column = (title: string, icon: ReactNode, col: DigestColumn, silent: string[], empty: string, children?: ReactNode) => (
517+ <div className="min-w-0 p-5">
518+ <div className="flex flex-wrap items-center gap-3">
519+ <h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-fg">
520+ {icon}
521+ {title}
522+ </h3>
523+ {col.actors.length > 0 && (
524+ <Badge tone="neutral" size="md" className="tabular-nums">
525+ {col.actors.length.toLocaleString("en-US")} active
526+ </Badge>
441527 )}
442− {(work.partial || work.sessions === "unavailable" || work.code === "unavailable") && (
443− <p className="mt-3 text-xs text-faint">
444− {work.sessions === "unavailable"
445− ? "Agent sessions couldn't be read, so only pull requests are counted. "
446− : work.code === "unavailable"
447− ? "Code didn't answer, so only agent sessions are counted. "
448− : ""}
449− {work.partial ? "A list was cut short, so some work may be missing." : ""}
450− </p>
528+ <span className="ml-auto">
529+ <Faces actors={col.actors} faces={faces} />
530+ </span>
531+ </div>
532+ {col.lines.length > 0 ? <Lines column={col} /> : <p className="mt-3 text-sm text-muted">{silent.length > 0 ? `Nothing from what could be read ${when}.` : empty}</p>}
533+ {silent.length > 0 && (
534+ <p className="mt-3 flex items-center gap-1.5 text-xs text-faint">
535+ <AlertTriangle size={12} className="shrink-0" />
536+ {listOf(silent)} didn&apos;t answer, so {silent.length === 1 ? "its part" : "their parts"} {silent.length === 1 ? "isn't" : "aren't"} counted.
537+ </p>
538+ )}
539+ {children}
540+ </div>
541+ );
542+ return (
543+ <section className="grid overflow-hidden rounded-2xl border border-line bg-surface md:grid-cols-2 md:divide-x md:divide-line" aria-label="What people and agents did">
544+ <div className="border-b border-line md:border-b-0">
545+ {column(
546+ "People",
547+ <Users size={15} className="text-muted" />,
548+ digest.people,
549+ peopleMissing,
550+ code ? `Nobody pushed, opened, merged, reviewed, edited a doc or said anything ${when}.` : `Nobody edited a doc or said anything ${when}.`,
451551 )}
452552 </div>
553+ <div>
554+ {column(
555+ "Agents",
556+ <Bot size={15} className="text-accent" />,
557+ digest.agents,
558+ agentsMissing,
559+ `No agent settled a task, pushed, opened a pull request or replied ${when}.`,
560+ <>
561+ {work && (work.tasks.length > 0 || work.code === "read") && <Acceptance work={work} span={span} />}
562+ {work?.partial && <p className="mt-3 text-xs text-faint">A list was cut short, so some agent work may be missing.</p>}
563+ </>,
564+ )}
565+ </div>
566+ {digest.partial && !work?.partial && <p className="border-t border-line px-5 py-2.5 text-xs text-faint md:col-span-2">More happened than Home reads at once, so some counts are low.</p>}
453567 </section>
454568 );
455569 }
456570
457571 export function SpanSkeleton() {
458572 return (
459− <section className="grid rounded-2xl border border-line bg-surface md:grid-cols-[15rem_1fr]" aria-busy="true">
460− <div className="space-y-3 border-b border-line p-5 md:border-r md:border-b-0">
461− <Skeleton className="h-3.5 w-32" />
462− <Skeleton className="h-12 w-24" />
463− <Skeleton className="h-3 w-40" />
464− </div>
465− <div className="space-y-5 p-5">
466− <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
467− {[0, 1, 2, 3].map((i) => (
468− <div key={i} className="space-y-2">
469− <Skeleton className="h-3 w-20" />
470− <Skeleton className="h-5 w-8" />
471− </div>
472− ))}
573+ <section className="grid rounded-2xl border border-line bg-surface md:grid-cols-2 md:divide-x md:divide-line" aria-busy="true">
574+ {[0, 1].map((column) => (
575+ <div key={column} className={cn("space-y-3 p-5", column === 0 && "border-b border-line md:border-b-0")}>
576+ <div className="flex items-center justify-between">
577+ <Skeleton className="h-3.5 w-20" />
578+ <Skeleton className="h-5 w-24 rounded-full" />
579+ </div>
580+ <Skeleton className="h-3.5 w-3/4" />
581+ <Skeleton className="h-3.5 w-1/2" />
582+ <Skeleton className="h-3.5 w-2/3" />
473583 </div>
474− <Skeleton className="h-6 w-3/4" />
475− </div>
584+ ))}
476585 </section>
477586 );
478587 }
525634
526635 const rowClass = "flex items-center gap-3 border-t border-line px-5 py-2.5 first:border-t-0";
527636
528−export function LandedCard({ landed, code, now, tz }: { landed: LandedChange[] | null; code: boolean; now: number; tz: string | null }) {
637+const LANDED_ICON: Record<LandedChange["kind"], (agent: boolean) => ReactNode> = {
638+ merge: (agent) => <GitMerge size={15} className={cn("shrink-0", agent ? "text-accent" : "text-success")} />,
639+ push: (agent) => <GitCommitHorizontal size={15} className={cn("shrink-0", agent ? "text-accent" : "text-success")} />,
640+ deploy: () => <Rocket size={15} className="shrink-0 text-success" />,
641+ release: () => <Tag size={15} className="shrink-0 text-info" />,
642+ package: () => <Package size={15} className="shrink-0 text-info" />,
643+};
644+
645+/** A few words on the card's line: how many of each kind landed. */
646+function landedHint(landed: LandedChange[]): string {
647+ const count = (kind: LandedChange["kind"]) => landed.filter((change) => change.kind === kind).length;
648+ const parts: string[] = [];
649+ const merged = count("merge");
650+ const pushes = count("push");
651+ const deploys = count("deploy");
652+ const published = count("release") + count("package");
653+ if (merged > 0) parts.push(`${merged.toLocaleString("en-US")} merged`);
654+ if (pushes > 0) parts.push(`${pushes.toLocaleString("en-US")} pushed direct`);
655+ if (deploys > 0) parts.push(`${deploys.toLocaleString("en-US")} ${deploys === 1 ? "deploy" : "deploys"}`);
656+ if (published > 0) parts.push(`${published.toLocaleString("en-US")} published`);
657+ const agents = landed.filter((change) => change.agent).length;
658+ parts.push(`${agents.toLocaleString("en-US")} by agents`);
659+ return parts.join(" · ");
660+}
661+
662+/**
663+ * Everything that landed in the span, newest first: pull requests merged,
664+ * commits pushed straight to a default branch, production builds that went
665+ * live, releases and package versions published.
666+ */
667+export function LandedCard({ landed, missing, code, now, tz }: { landed: LandedChange[] | null; missing: string[]; code: boolean; now: number; tz: string | null }) {
529668 if (!code) return null;
530− const agents = landed?.filter((change) => change.agent).length ?? 0;
531669 return (
532− <Card title="Landed" hint={landed && landed.length > 0 ? `${landed.length.toLocaleString("en-US")} merged · ${agents.toLocaleString("en-US")} by agents` : undefined}>
670+ <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}>
533671 {!landed ? (
534672 <Quiet>Code didn&apos;t answer, so what landed can&apos;t be listed right now.</Quiet>
535673 ) : landed.length === 0 ? (
536− <Quiet>Nothing merged in this span.</Quiet>
674+ <Quiet>{missing.length > 0 ? "Nothing landed in what could be read." : "Nothing merged, pushed to a default branch, deployed to production or published in this span."}</Quiet>
537675 ) : (
538676 <Folded
539677 items={landed}
540678 render={(change) => (
541679 <li key={change.key} className={rowClass}>
542− <GitMerge size={15} className={cn("shrink-0", change.agent ? "text-accent" : "text-success")} />
680+ {LANDED_ICON[change.kind](change.agent)}
543681 <div className="min-w-0 grow">
544682 <Link to={change.to} className="block truncate text-sm text-fg hover:underline">
545683 {change.title}
546684 </Link>
547− <p className="truncate text-xs text-muted">
548− {change.repo.name}#{change.number} · {change.agent ? `by @${change.author}` : `by ${change.author}`}
549− {change.mergedBy && change.mergedBy !== change.author ? `, merged by ${change.mergedBy}` : ""}
550− </p>
685+ <p className="truncate text-xs text-muted">{change.detail}</p>
551686 </div>
552687 <When at={change.at} now={now} tz={tz} />
553688 </li>
554689 )}
555690 />
556691 )}
692+ {landed && <Missing names={missing} what="everything that landed" />}
557693 </Card>
558694 );
559695 }
701837 const RUNNING_ICON: Record<RunningRow["kind"], ReactNode> = {
702838 session: <Sparkles size={15} className="text-accent" />,
703839 change: <GitPullRequest size={15} className="text-info" />,
840+ workflow: <Workflow size={15} className="text-info" />,
704841 deploy: <Rocket size={15} className="text-info" />,
705842 };
706843
708845 return (
709846 <>
710847 {rows.length === 0 ? (
711− <Quiet>{missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Agent sessions, agents' changes and builds show here while they go."}</Quiet>
848+ <Quiet>{missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Agent sessions, agents' changes, workflow runs and builds show here while they go."}</Quiet>
712849 ) : (
713850 <Folded
714851 items={rows}
+144−10
55 */
66 import { env } from "cloudflare:workers";
77
8−import { type AgentSession, type AgentsOverview, type Deployment, type InstallRequests, type Memory, type ProjectDeploys, type Repo, type User, notifyClient } from "@g1t/contracts";
8+import {
9+ type ActivityDigest,
10+ type AgentSession,
11+ type AgentsOverview,
12+ type ChatActivity,
13+ type Deployment,
14+ type InstallRequests,
15+ MAX_DIGEST_REPOS,
16+ type Memory,
17+ type ProjectDeploys,
18+ type Repo,
19+ type RepoPath,
20+ type User,
21+ type WorkflowRun,
22+ type WorkspaceAgent,
23+ notifyClient,
24+} from "@g1t/contracts";
925
10−import { type CodePull, type CodeWork, type SessionWork, type Span, type Spend, pullKey, spanMonths, spendIn } from "./home";
26+import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type SessionWork, type Span, type Spend, pullKey, spanMonths, spendIn } from "./home";
1127 import { monthSpan, spentMicros } from "./spend";
12−import { agents, billing, deployments, repos, work, workspaceAgents } from "./services.server";
28+import { actions, agents, billing, chat, deployments, events, identity, repos, work, workspaceAgents } from "./services.server";
1329
1430 /** Pull requests read per project and list: `LIST_PAGE` in services/work. */
1531 const PULL_PAGE = 100;
1733 const MAX_PROJECTS = 50;
1834 /** The most projects whose builds are read for the span. */
1935 const MAX_DEPLOY_PROJECTS = 20;
36+/** The most projects whose workflow runs are read for Running now: those pushed to most in the span. */
37+const MAX_WORKFLOW_PROJECTS = 10;
38+/** Runs read per project: enough to find the ones not finished. */
39+const WORKFLOW_RUNS = 20;
2040 /** Revise runs and sessions read: the services' most in one list. */
2141 const RUN_PAGE = 200;
2242 const SESSION_PAGE = 200;
43+/** The most member keys named in one lookup. */
44+const MAX_NAMES = 200;
2345 const DAY = 24 * 60 * 60 * 1000;
2446
2547 const warn = (what: string) => (error: unknown) => {
4769 }
4870
4971 /**
72+ * The workspace's own repositories the viewer may read (no pull request
73+ * working copies), as every Code section reads them. Null when repos did
74+ * not answer.
75+ */
76+export async function loadRepos(viewer: User, slug: string): Promise<Repo[] | null> {
77+ const list = await repos.list(viewer, { namespace: slug }).catch(warn("repos"));
78+ return list ? list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug) : null;
79+}
80+
81+/** The repositories by id, as the digest's lines and links need them. */
82+export function repoRefs(list: Repo[] | null): Record<string, RepoRef> {
83+ const refs: Record<string, RepoRef> = {};
84+ for (const repo of list ?? []) refs[repo.id] = { namespace: repo.namespace, name: repo.name, defaultBranch: repo.defaultBranch };
85+ return refs;
86+}
87+
88+/**
5089 * The workspace's projects' pull requests, open and recently merged or
5190 * closed, any author's, with how many times each agent's was sent back to
5291 * revise. `complete` when every list reaches back to `reach`. Null when
5392 * Code did not answer.
5493 */
55−export async function loadCodeWork(viewer: User, slug: string, reach: number): Promise<CodeWork | null> {
56− const [list, runs] = await Promise.all([
57− repos.list(viewer, { namespace: slug }).catch(warn("repos")),
58− agents.listRuns(viewer, { workspace: slug, kind: "revise", limit: RUN_PAGE }).catch(warn("revise runs")),
59− ]);
60− if (!list || !runs?.ok) return null;
61− const owned = list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug);
94+export async function loadCodeWork(viewer: User, slug: string, reach: number, owned: Repo[] | null): Promise<CodeWork | null> {
95+ const runs = await agents.listRuns(viewer, { workspace: slug, kind: "revise", limit: RUN_PAGE }).catch(warn("revise runs"));
96+ if (!owned || !runs?.ok) return null;
6297 const chosen = owned.slice(0, MAX_PROJECTS);
6398 const byId = new Map<string, Repo>(chosen.map((repo) => [repo.id, repo]));
6499 const batch = chosen.length > 0 ? await work.pullsForRepos([...byId.keys()], viewer, PULL_PAGE).catch(warn("pull requests")) : [];
100135 return { pulls, revisions, complete };
101136 }
102137
138+/**
139+ * What happened in the span, counted by who did it: the events service's
140+ * digest of the workspace's repositories (the first MAX_DIGEST_REPOS of
141+ * them; none without Code access) and of its artifacts. Null when events
142+ * did not answer.
143+ */
144+export async function loadActivity(slug: string, span: Pick<Span, "from" | "now">, owned: Repo[] | null): Promise<ActivityDigest | null> {
145+ const digest = await events
146+ .activityDigest({
147+ repo_ids: (owned ?? []).slice(0, MAX_DIGEST_REPOS).map((repo) => repo.id),
148+ workspace: slug,
149+ from: new Date(span.from).toISOString(),
150+ until: new Date(span.now + 1).toISOString(),
151+ })
152+ .catch(warn("activity digest"));
153+ if (!digest) return null;
154+ // More repositories than the digest reads: it says so.
155+ return owned && owned.length > MAX_DIGEST_REPOS ? { ...digest, complete: false } : digest;
156+}
157+
158+/** What was said in the span where the viewer can read, counted. Null when chat did not answer. */
159+export async function loadChatActivity(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<ChatActivity | null> {
160+ const activity = await chat
161+ .activity(slug, viewer, { from: new Date(span.from).toISOString(), until: new Date(span.now + 1).toISOString() })
162+ .catch(warn("chat activity"));
163+ return activity?.ok ? activity.value : null;
164+}
165+
166+/** Every member key the digest and chat name. */
167+export function actorKeys(activity: ActivityDigest | null, chatActivity: ChatActivity | null): string[] {
168+ const keys = new Set<string>();
169+ for (const repo of activity?.repos ?? []) {
170+ for (const by of [...repo.pushes.by, ...repo.pushes.default_branch.by]) keys.add(by.actor);
171+ for (const list of [repo.pulls.opened, repo.pulls.merged, repo.pulls.closed, repo.issues.opened, repo.issues.closed, repo.reviews, repo.comments, repo.deployments.succeeded, repo.deployments.failed]) {
172+ for (const entry of list) keys.add(entry.actor);
173+ }
174+ for (const release of repo.releases) keys.add(release.actor);
175+ for (const pkg of repo.packages) keys.add(pkg.actor);
176+ }
177+ for (const entry of activity?.folios?.created ?? []) keys.add(entry.actor);
178+ for (const folio of activity?.folios?.edited ?? []) for (const author of folio.authors) keys.add(author);
179+ for (const author of chatActivity?.authors ?? []) keys.add(author.key);
180+ keys.delete("");
181+ return [...keys];
182+}
183+
184+/**
185+ * The names behind member keys: people's usernames from identity (one
186+ * lookup for them all), agents' handles from the workspace's agents, g1t
187+ * as itself. A key identity does not know is left out, and shows as
188+ * "someone".
189+ */
190+export async function loadNames(keys: string[], agentsList: Pick<WorkspaceAgent, "id" | "handle">[] | null): Promise<Record<string, string>> {
191+ const names: Record<string, string> = {};
192+ const userIds: string[] = [];
193+ const byAgentId = new Map((agentsList ?? []).map((agent) => [agent.id, agent.handle]));
194+ for (const key of keys.slice(0, MAX_NAMES)) {
195+ if (key === G1T_ACTOR) names[key] = "g1t";
196+ else if (key.startsWith("agent:")) {
197+ const handle = byAgentId.get(key.slice("agent:".length));
198+ if (handle) names[key] = handle;
199+ } else if (key.startsWith("user:")) userIds.push(key.slice("user:".length));
200+ }
201+ if (userIds.length > 0) {
202+ const found = await identity.usernames(userIds).catch(warn("usernames"));
203+ for (const [id, username] of Object.entries(found ?? {})) names[`user:${id}`] = username;
204+ }
205+ return names;
206+}
207+
208+/**
209+ * The newest workflow runs of the projects pushed to most in the span (at
210+ * most MAX_WORKFLOW_PROJECTS), for the ones still going. A project whose
211+ * runs could not be read is left out; `complete` says whether every one was.
212+ */
213+export async function loadWorkflowRuns(
214+ viewer: User,
215+ activity: ActivityDigest | null,
216+ owned: Repo[] | null,
217+): Promise<{ projects: { repo: RepoPath; runs: WorkflowRun[] }[]; complete: boolean }> {
218+ const byId = new Map((owned ?? []).map((repo) => [repo.id, repo]));
219+ const active = [...(activity?.repos ?? [])]
220+ .filter((repo) => repo.pushes.count > 0 && byId.has(repo.repo_id))
221+ .sort((a, b) => b.pushes.count - a.pushes.count || a.repo_id.localeCompare(b.repo_id))
222+ .slice(0, MAX_WORKFLOW_PROJECTS);
223+ const lists = await Promise.all(
224+ active.map((digest) => {
225+ const repo = byId.get(digest.repo_id)!;
226+ const path = { namespace: repo.namespace, name: repo.name };
227+ return actions
228+ .runs(path, viewer, { limit: WORKFLOW_RUNS })
229+ .then((result) => (result.ok ? { repo: path, runs: result.value } : null))
230+ .catch(warn(`workflow runs of ${repo.name}`));
231+ }),
232+ );
233+ const projects = lists.filter((entry) => entry != null);
234+ return { projects, complete: projects.length === active.length };
235+}
236+
103237 /** The workspace's newest sessions; `complete` when the list reaches back before `from`. */
104238 export async function loadSessions(viewer: User, slug: string, from: number): Promise<SessionWork | null> {
105239 const listed = await workspaceAgents.sessions(slug, viewer, { limit: SESSION_PAGE }).catch(warn("sessions"));
+315−17
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import type { AgentSession, Deployment, InboxItem, InstallRequest, Memory } from "@g1t/contracts";
4+import type { ActivityDigest, ActorCount, AgentSession, ChatActivity, Deployment, InboxItem, InstallRequest, Memory, RepoDigest, WorkflowRun } from "@g1t/contracts";
55
66 import {
77 type AttentionRow,
88 type CodeNeed,
99 type CodePull,
1010 acceptance,
11+ actorKind,
1112 agentRow,
1213 attention,
1314 chatRow,
1516 dayIn,
1617 decisionsIn,
1718 deploysIn,
19+ digestOf,
1820 duration,
1921 installRequestRow,
22+ landed,
23+ landedDeploys,
24+ landedFromActivity,
2025 landedIn,
2126 limitRow,
2227 notificationRow,
2631 running,
2732 runningDeploys,
2833 runningSessions,
34+ runningWorkflows,
2935 sessionOutcome,
3036 sinceWords,
3137 spanFor,
365371 ]);
366372 assert.equal(work.code, "read");
367373 assert.equal(work.partial, false);
368− assert.equal(
369− spanSentence({ words: "Since Tuesday evening" }, work, 3),
370− "Since Tuesday evening, agents finished 3 tasks and 3 changes landed. 1 needed a fix after review.",
371− );
372374 });
373375
374376 test("no trend when the 7 days before could not all be read, and none without Code", () => {
389391 assert.equal(trendLabel(null, { value: 0.5 }), null);
390392 });
391393
392−test("the sentence for a quiet span, and for what needs you", () => {
393− const quiet = spanWork({ span: SPAN, slug: "acme", code: { pulls: [], revisions: {}, complete: true }, sessions: { sessions: [], complete: true } });
394− assert.equal(spanSentence({ words: "Since an hour ago" }, quiet, 0), "Since an hour ago, nothing new has settled.");
395− assert.equal(spanSentence({ words: "In the last 24 hours" }, quiet, 2), "In the last 24 hours, 2 changes landed.");
396− assert.equal(spanSentence({ words: "Since this morning" }, null, null), "Since this morning, agent work couldn't be read.");
394+// --- People and agents ------------------------------------------------------------
395+
396+const ANA = "user:usr_ana";
397+const BO = "user:usr_bo";
398+const CY = "user:usr_cy";
399+const MARGO = "agent:agt_margo";
400+const G1T = "user:usr_g1t_agent";
401+const NAMES = { [ANA]: "ana", [BO]: "bo", [CY]: "cy", [MARGO]: "margo" };
402+const REPOS = {
403+ rep_web: { namespace: "acme", name: "web", defaultBranch: "main" },
404+ rep_api: { namespace: "acme", name: "api", defaultBranch: "main" },
405+ rep_docs: { namespace: "acme", name: "docs", defaultBranch: "main" },
406+ rep_cli: { namespace: "acme", name: "cli", defaultBranch: "trunk" },
407+};
408+
409+const count = (actor: string, n: number): ActorCount => ({ actor, count: n });
410+const quietRepo = (repo_id: string): RepoDigest => ({
411+ repo_id,
412+ pushes: { count: 0, commits: 0, branches: [], by: [], default_branch: { count: 0, commits: 0, by: [], last_at: null } },
413+ pulls: { opened: [], merged: [], closed: [] },
414+ issues: { opened: [], closed: [] },
415+ reviews: [],
416+ comments: [],
417+ deployments: { succeeded: [], failed: [], production: 0 },
418+ releases: [],
419+ packages: [],
420+});
421+
422+/**
423+ * A day on a busy workspace: 3 people pushed 41 commits to 4 projects,
424+ * merged 5 pull requests and opened 2; agents (g1t and Margo) pushed 6,
425+ * opened 3 pull requests and reviewed 2; 2 production deploys went out;
426+ * 120 messages were sent, 15 of them Margo's; 7 docs were edited.
427+ */
428+function busyDay(): ActivityDigest {
429+ return {
430+ from: iso(SPAN.from),
431+ until: iso(SPAN.now),
432+ complete: true,
433+ repos: [
434+ {
435+ ...quietRepo("rep_web"),
436+ pushes: {
437+ count: 14,
438+ commits: 33,
439+ branches: ["fix-login", "main"],
440+ by: [
441+ { actor: ANA, count: 8, commits: 24 },
442+ { actor: BO, count: 4, commits: 5 },
443+ { actor: G1T, count: 2, commits: 4 },
444+ ],
445+ default_branch: { count: 3, commits: 9, by: [{ actor: ANA, count: 3, commits: 9 }], last_at: iso(NOW - 2 * HOUR) },
446+ },
447+ pulls: { opened: [count(ANA, 1), count(G1T, 2), count(MARGO, 1)], merged: [count(ANA, 3), count(BO, 1)], closed: [] },
448+ issues: { opened: [count(BO, 2)], closed: [count(ANA, 1), count(G1T, 1)] },
449+ reviews: [count(BO, 2), count(G1T, 2)],
450+ comments: [count(ANA, 4), count(MARGO, 3)],
451+ deployments: { succeeded: [count(ANA, 2)], failed: [], production: 2 },
452+ releases: [{ tag: "v1.4.0", name: "Autumn", actor: ANA, at: iso(NOW - 3 * HOUR) }],
453+ packages: [],
454+ },
455+ {
456+ ...quietRepo("rep_api"),
457+ 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) } },
458+ pulls: { opened: [count(CY, 1)], merged: [count(CY, 1)], closed: [] },
459+ packages: [{ ecosystem: "container", name: "api", version: "1.4.0", actor: G1T, at: iso(NOW - HOUR) }],
460+ },
461+ {
462+ ...quietRepo("rep_docs"),
463+ pushes: { count: 2, commits: 2, branches: ["typo"], by: [{ actor: BO, count: 2, commits: 2 }], default_branch: { count: 0, commits: 0, by: [], last_at: null } },
464+ },
465+ {
466+ ...quietRepo("rep_cli"),
467+ 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) } },
468+ },
469+ ],
470+ folios: {
471+ created: [count(ANA, 1)],
472+ edited_count: 7,
473+ edited: [
474+ { folio_id: "fol_1", kind: "doc", authors: [ANA, MARGO] },
475+ { folio_id: "fol_2", kind: "doc", authors: [ANA] },
476+ { folio_id: "fol_3", kind: "doc", authors: [BO] },
477+ { folio_id: "fol_4", kind: "doc", authors: [BO] },
478+ { folio_id: "fol_5", kind: "doc", authors: [CY] },
479+ { folio_id: "fol_6", kind: "doc", authors: [CY] },
480+ { folio_id: "fol_7", kind: "doc", authors: [MARGO] },
481+ ],
482+ },
483+ };
484+}
485+
486+const busyChat: ChatActivity = {
487+ from: iso(SPAN.from),
488+ until: iso(SPAN.now),
489+ messages: 120,
490+ channels: 5,
491+ authors: [
492+ { key: ANA, messages: 60 },
493+ { key: BO, messages: 30 },
494+ { key: CY, messages: 15 },
495+ { key: MARGO, messages: 15 },
496+ ],
497+};
498+
499+/** Seven agent tasks that settled well, and one that did not. */
500+function busyWork() {
501+ return spanWork({
502+ span: SPAN,
503+ slug: "acme",
504+ code: {
505+ 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 })),
506+ revisions: { "acme/web#2": 1 },
507+ complete: true,
508+ },
509+ sessions: { sessions: [session(), session({ id: "s2", kind: "routine" })], complete: true },
510+ });
511+}
512+
513+test("an actor is an agent when it is one of the workspace's agents or g1t; everyone else is a person", () => {
514+ assert.equal(actorKind(MARGO), "agent");
515+ assert.equal(actorKind(G1T), "agent");
516+ assert.equal(actorKind(ANA), "person");
517+ assert.equal(actorKind(""), "person");
518+ assert.equal(actorKind("user:usr_bot", new Set(["user:usr_bot"])), "agent");
519+});
520+
521+test("the digest: what people did, in lines with counts, and who", () => {
522+ const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true });
523+ assert.deepEqual(
524+ digest.people.lines.map((line) => [line.text, line.to]),
525+ [
526+ ["3 people pushed 41 commits to 4 projects", "/acme/-/repositories"],
527+ ["opened 2 pull requests, merged 5", "/acme/-/repositories"],
528+ ["reviewed 2 pull requests", "/acme/web/pulls"],
529+ ["opened 2 issues, closed 1", "/acme/web/issues"],
530+ ["left 4 comments", "/acme/web/issues"],
531+ ["2 deploys went out", "/acme/web/deployments"],
532+ ["published 1 release", "/acme/web/releases"],
533+ ["created 1 doc, edited 6 docs", "/acme/-/artifacts"],
534+ ["sent 105 messages in 5 conversations", "/acme/-/chat"],
535+ ],
536+ );
537+ // Faces: most active first, by name.
538+ assert.deepEqual(
539+ digest.people.actors.map((actor) => [actor.name, actor.kind]),
540+ [
541+ ["ana", "person"],
542+ ["bo", "person"],
543+ ["cy", "person"],
544+ ],
545+ );
546+ assert.deepEqual(digest.missing, []);
547+ assert.equal(digest.partial, false);
548+});
549+
550+test("the digest: what agents did, their tasks first, and g1t and the workspace's agents as agents", () => {
551+ const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true });
552+ assert.deepEqual(
553+ digest.agents.lines.map((line) => line.text),
554+ [
555+ "finished 7 tasks, 1 didn't finish",
556+ "ran 2 sessions",
557+ "pushed 6 commits to 2 projects",
558+ "opened 3 pull requests",
559+ "reviewed 2 pull requests",
560+ "closed 1 issue",
561+ "left 3 comments",
562+ "published 1 package version",
563+ "edited 2 docs",
564+ "replied 15 times in chat",
565+ ],
566+ );
567+ assert.deepEqual(
568+ digest.agents.actors.map((actor) => [actor.name, actor.kind, actor.key]),
569+ [
570+ ["margo", "agent", MARGO],
571+ ["g1t", "agent", G1T],
572+ ],
573+ );
574+ assert.deepEqual(digest.summary, { people: 3, commits: 41, merged: 5, finished: 7, dropped: 1, said: 105, anything: true, peopleRead: true });
575+});
576+
577+test("the digest says which part didn't answer, and counts what could be read", () => {
578+ const noChat = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: null, names: NAMES, work: busyWork(), code: true });
579+ assert.deepEqual(noChat.missing, ["Chat"]);
580+ assert.equal(noChat.people.lines.some((line) => line.key === "chat"), false);
581+ const noCode = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: busyChat, names: NAMES, work: null, code: true });
582+ assert.deepEqual(noCode.missing, ["Code", "Agents"]);
583+ assert.deepEqual(
584+ noCode.people.lines.map((line) => line.text),
585+ ["sent 105 messages in 5 conversations"],
586+ );
587+ assert.equal(noCode.summary.peopleRead, true);
588+ // Without Code access, events are read for artifacts only: no repositories, nothing missing.
589+ const member = digestOf({ slug: "acme", repos: {}, activity: { ...busyDay(), repos: [] }, chat: busyChat, names: NAMES, work: busyWork(), code: false });
590+ assert.deepEqual(member.missing, []);
591+ assert.deepEqual(
592+ member.people.lines.map((line) => line.key),
593+ ["docs", "chat"],
594+ );
595+ // A digest cut short says so.
596+ const cut = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), complete: false }, chat: busyChat, names: NAMES, work: busyWork(), code: true });
597+ assert.equal(cut.partial, true);
598+ // Nobody named: a mirror's pushes are still commits.
599+ const mirror = digestOf({
600+ slug: "acme",
601+ repos: REPOS,
602+ 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) } } }] },
603+ chat: null,
604+ names: {},
605+ work: null,
606+ code: true,
607+ });
608+ assert.deepEqual(
609+ mirror.people.lines.map((line) => [line.text, line.to]),
610+ [["7 commits pushed to 1 project", "/acme/web/activity"]],
611+ );
612+ assert.equal(mirror.summary.people, 0);
613+});
614+
615+test("the sentence: people first, then the agents' tasks, then what landed; honest when quiet or unread", () => {
616+ const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, work: busyWork(), code: true });
617+ assert.equal(
618+ spanSentence({ words: "Since Tuesday evening" }, digest, 12),
619+ "Since Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents finished 7 tasks, and 12 changes landed.",
620+ );
621+ 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 });
622+ quiet.summary.finished = 0;
623+ quiet.summary.dropped = 0;
624+ quiet.summary.anything = false;
625+ assert.equal(spanSentence({ words: "Since Tuesday evening" }, quiet, 0), "Quiet since Tuesday evening: nothing pushed, merged, deployed or said.");
626+ assert.equal(spanSentence({ words: "In the last 24 hours" }, quiet, 0), "Quiet in the last 24 hours: nothing pushed, merged, deployed or said.");
627+ // Only agents' tasks and merges.
628+ const agentsOnly = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), repos: [], folios: null }, chat: { ...busyChat, authors: [], messages: 0, channels: 0 }, names: {}, work: busyWork(), code: true });
629+ assert.equal(spanSentence({ words: "Since this morning" }, agentsOnly, 5), "Since this morning, agents finished 7 tasks and 5 changes landed.");
630+ // People only talked.
631+ const talked = digestOf({ slug: "acme", repos: REPOS, activity: { ...busyDay(), repos: [], folios: null }, chat: busyChat, names: NAMES, work: null, code: true });
632+ assert.equal(spanSentence({ words: "Since an hour ago" }, talked, 0), "Since an hour ago, 3 people sent 105 messages.");
633+ // Neither events nor chat answered.
634+ const unread = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: null, names: {}, work: busyWork(), code: true });
635+ 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.");
636+ const nothingRead = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: null, names: {}, work: null, code: true });
637+ assert.equal(spanSentence({ words: "Since this morning" }, nothingRead, 0), "Since this morning, what people did couldn't be read, and no agent task settled.");
638+ assert.equal(spanSentence({ words: "Since this morning" }, null, null), "Since this morning, what happened couldn't be read.");
397639 assert.equal(waitingSentence(0), "Nothing needs you right now.");
398640 assert.equal(waitingSentence(1), "1 thing needs you.");
399641 assert.equal(waitingSentence(8), "8 things need you.");
400642 });
401643
402644 test("what landed: every merge in the span, a person's or an agent's, newest first", () => {
403− const landed = landedIn(
645+ const merged = landedIn(
404646 [
405647 pull({ number: 1, mergedAt: iso(NOW - 2 * DAY), mergedBy: "bo" }),
406648 pull({ number: 2, author: { username: "bo", kind: "user" }, mergedAt: iso(NOW - HOUR) }),
409651 ],
410652 SPAN,
411653 );
412− assert.deepEqual(landed.map((change) => [change.number, change.agent]), [
413− [2, false],
414− [1, true],
654+ assert.deepEqual(merged.map((change) => [change.kind, change.title, change.detail, change.agent]), [
655+ ["merge", "Fix the flaky test", "web#2 · by bo", false],
656+ ["merge", "Fix the flaky test", "web#1 · by @g1t, merged by bo", true],
415657 ]);
416− assert.equal(landed[1]!.mergedBy, "bo");
417658 });
418659
660+test("what landed, besides merges: direct pushes, releases, packages and production builds, newest first", () => {
661+ const fromActivity = landedFromActivity(busyDay(), REPOS, NAMES, "acme");
662+ assert.deepEqual(
663+ fromActivity.map((change) => [change.kind, change.title, change.detail, change.to, change.agent]),
664+ [
665+ ["push", "9 commits pushed to main", "web · 3 pushes by ana", "/acme/web/activity", false],
666+ ["release", "v1.4.0 · Autumn", "web · released by ana", "/acme/web/releases/tag/v1.4.0", false],
667+ ["push", "8 commits pushed to main", "api · 5 pushes by cy", "/acme/api/activity", false],
668+ ["package", "api 1.4.0", "container package · published by @g1t", "/acme/-/packages", true],
669+ ["push", "4 commits pushed to trunk", "cli · 3 pushes by ana and @margo", "/acme/cli/activity", false],
670+ ],
671+ );
672+ const builds = landedDeploys([
673+ { key: "deploy:site:d1", project: "site", kind: "production", outcome: "live", commit: "abcdef1", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d1", by: "g1t" },
674+ { key: "deploy:site:d2", project: "site", kind: "preview", outcome: "live", commit: "abcdef2", branch: "fix", at: NOW - HOUR, to: "/acme/site/deployments/d2", by: "bo" },
675+ { key: "deploy:site:d3", project: "site", kind: "production", outcome: "failed", commit: "abcdef3", branch: null, at: NOW - HOUR, to: "/acme/site/deployments/d3", by: "bo" },
676+ ]);
677+ assert.deepEqual(builds.map((change) => [change.kind, change.title, change.agent]), [["deploy", "Production of site went live", true]]);
678+ const all = landed([fromActivity, builds]);
679+ assert.deepEqual(
680+ all.map((change) => change.key),
681+ ["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"],
682+ );
683+});
684+
419685 const deploy = (over: Partial<Deployment>): Deployment => ({
420686 id: "d1",
421687 kind: "production",
544810 "acme",
545811 );
546812 assert.deepEqual(builds.map((row) => row.status), ["Building"]);
547− // Longest-going first within a kind.
548− assert.deepEqual(running([builds, sessions]).map((row) => row.key), ["session:e", "session:a", "deploy:site:d1"]);
813+ const run = (over: Partial<WorkflowRun>): WorkflowRun => ({
814+ id: "run_1",
815+ workflowId: "wf_1",
816+ path: ".g1t/workflows/ci.yml",
817+ name: "CI",
818+ title: "Fix the flaky test",
819+ number: 12,
820+ attempt: 1,
821+ event: "push",
822+ ref: "refs/heads/main",
823+ sha: "abcdef1",
824+ pull: null,
825+ status: "in_progress",
826+ conclusion: null,
827+ error: null,
828+ actor: "ana",
829+ createdAt: iso(NOW - 10 * 60_000),
830+ startedAt: iso(NOW - 9 * 60_000),
831+ finishedAt: null,
832+ ...over,
833+ });
834+ const workflows = runningWorkflows([
835+ { 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" })] },
836+ ]);
837+ assert.deepEqual(
838+ workflows.map((row) => [row.key, row.title, row.detail, row.status, row.to]),
839+ [
840+ ["workflow:acme/web:run_1", "CI: Fix the flaky test", "acme/web main · by ana", "Running", "/acme/web/actions/runs/run_1"],
841+ ["workflow:acme/web:run_2", "CI", "acme/web main · by ana", "Queued", "/acme/web/actions/runs/run_2"],
842+ ["workflow:acme/web:run_4", "CI: Fix the flaky test", "acme/web main · by ana", "Needs approval", "/acme/web/actions/runs/run_4"],
843+ ],
844+ );
845+ // Longest-going first within a kind: sessions, then changes, then workflow runs, then builds.
846+ 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"]);
549847 });
550848
551849
+503−44
88 * Home guide (apps/docs/.../guides/home.md). Imports only types.
99 */
1010 import type {
11+ ActivityDigest,
12+ ActorCount,
1113 AgentSession,
1214 AgentSessionKind,
15+ ChatActivity,
1316 Deployment,
1417 InboxItem,
1518 InstallRequest,
1619 Memory,
1720 ProjectDeploys,
1821 Pull,
22+ RepoDigest,
1923 RepoPath,
2024 Statement,
25+ WorkflowRun,
2126 } from "@g1t/contracts";
2227
2328 import { money } from "./money.ts";
360365 return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`;
361366 }
362367
363−// --- What else happened in the span -------------------------------------------
368+// --- People and agents in the span -----------------------------------------------
369+
370+/**
371+ * Who an actor is. Actors are member keys, as the events and chat services
372+ * return them: `user:<id>` for an account, `agent:<id>` for one of the
373+ * workspace's agents acting as itself, `""` for nobody (a push a mirror
374+ * copied in). An agent is one of the workspace's agents, or g1t itself;
375+ * everyone else is a person. `agents` names more keys that are agents'
376+ * (accounts of kind `agent`), when known.
377+ */
378+export type ActorKind = "person" | "agent";
379+
380+/** g1t's own account, as events name it (identity's `AGENT_ID`). */
381+export const G1T_ACTOR = "user:usr_g1t_agent";
382+
383+export function actorKind(key: string, agents?: ReadonlySet<string>): ActorKind {
384+ return key.startsWith("agent:") || key === G1T_ACTOR || agents?.has(key) ? "agent" : "person";
385+}
386+
387+/** Whether a username is an agent's: g1t, or an agent account. */
388+export function isAgentName(name: string | null | undefined): boolean {
389+ return name === "g1t" || (name != null && name.endsWith("-agent"));
390+}
391+
392+/** A repository as the digest's links and lines need it. */
393+export type RepoRef = RepoPath & { defaultBranch: string };
394+
395+/** Someone who did something in the span: for the faces beside a column. */
396+export type DigestActor = {
397+ key: string;
398+ kind: ActorKind;
399+ /** Their username or handle; `someone` when it could not be read. */
400+ name: string;
401+ /** How many things they did, over every line: the order of the faces. */
402+ count: number;
403+};
404+
405+/** One line of a column: what was done, with its count, and where that is listed. */
406+export type DigestLine = {
407+ key: string;
408+ text: string;
409+ to: string | null;
410+};
411+
412+export type DigestColumn = {
413+ kind: ActorKind;
414+ lines: DigestLine[];
415+ /** Most active first. */
416+ actors: DigestActor[];
417+};
418+
419+/** What the sentence under the heading needs. */
420+export type DigestSummary = {
421+ /** Distinct people who did anything. */
422+ people: number;
423+ /** Commits people pushed. */
424+ commits: number;
425+ /** Pull requests people merged. */
426+ merged: number;
427+ /** Agent tasks that settled well, and that did not. */
428+ finished: number;
429+ dropped: number;
430+ /** Messages people sent. */
431+ said: number;
432+ /** Whether anything at all was counted, in either column. */
433+ anything: boolean;
434+ /** Whether what people did could be read at all (events and chat both silent: no). */
435+ peopleRead: boolean;
436+};
437+
438+export type Digest = {
439+ people: DigestColumn;
440+ agents: DigestColumn;
441+ summary: DigestSummary;
442+ /** Sources that did not answer, by name, for the card to say so: Code, Chat, Agents. */
443+ missing: string[];
444+ /** True when a source was cut short, so some counts are low. */
445+ partial: boolean;
446+};
447+
448+export type DigestInput = {
449+ slug: string;
450+ /** The workspace's repositories by id: for links, and to count projects. */
451+ repos: Record<string, RepoRef>;
452+ /** Events' digest; null when events did not answer. Without Code access it has no repositories. */
453+ activity: ActivityDigest | null;
454+ /** Chat's; null when chat did not answer. */
455+ chat: ChatActivity | null;
456+ /** Names for member keys: a username, or an agent's handle. */
457+ names: Record<string, string>;
458+ /** Keys of accounts that are agents, beyond g1t and the workspace's agents. */
459+ agentKeys?: ReadonlySet<string>;
460+ /** The agents' tasks, for their column; null when neither Code nor Agents answered. */
461+ work: SpanWork | null;
462+ code: boolean;
463+};
464+
465+const people = (n: number) => plural(n, "person", "people");
466+
467+/** `a, b and c`. */
468+export const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
469+
470+/** What one kind did, from a tally: how many, and by whom (non-empty keys). */
471+function part(counts: ActorCount[], kind: ActorKind, agents?: ReadonlySet<string>): { count: number; actors: Map<string, number> } {
472+ const actors = new Map<string, number>();
473+ let count = 0;
474+ for (const entry of counts) {
475+ if (actorKind(entry.actor, agents) !== kind) continue;
476+ count += entry.count;
477+ if (entry.actor) actors.set(entry.actor, (actors.get(entry.actor) ?? 0) + entry.count);
478+ }
479+ return { count, actors };
480+}
481+
482+/** Where a line points: the one project's page, or the workspace's projects when several. */
483+function placeFor(slug: string, repos: Record<string, RepoRef>, repoIds: Iterable<string>, page: string): string {
484+ const ids = [...new Set(repoIds)];
485+ const only = ids.length === 1 ? repos[ids[0]!] : null;
486+ return only ? `/${only.namespace}/${only.name}/${page}` : `/${slug}/-/repositories`;
487+}
488+
489+/**
490+ * One column: what people, or agents, did in the span, as lines with
491+ * counts and links, and who did it. Pure: everything comes from the
492+ * services' counts and the names looked up for them.
493+ */
494+export function digestColumn(kind: ActorKind, input: DigestInput): DigestColumn {
495+ const { slug, repos, activity, chat, names, agentKeys, work } = input;
496+ const lines: DigestLine[] = [];
497+ const did = new Map<string, number>();
498+ const credit = (actors: Map<string, number>) => {
499+ for (const [key, n] of actors) did.set(key, (did.get(key) ?? 0) + n);
500+ };
501+
502+ // The agents' tasks come first in their column: what Home has always counted.
503+ if (kind === "agent" && work) {
504+ const { accepted, fixed, finished, dropped } = work.counts;
505+ const done = accepted + fixed + finished;
506+ if (done > 0 || dropped > 0) {
507+ const text = done > 0 ? `finished ${plural(done, "task")}${dropped > 0 ? `, ${dropped.toLocaleString("en-US")} didn't finish` : ""}` : `finished none of ${plural(dropped, "task")}`;
508+ lines.push({ key: "tasks", text, to: `/${slug}/-/agents` });
509+ }
510+ const sessions = work.tasks.filter((task) => task.source !== "code").length;
511+ if (sessions > 0) lines.push({ key: "sessions", text: `ran ${plural(sessions, "session")}`, to: `/${slug}/-/agents` });
512+ }
364513
365−/** A change that landed: a pull request merged, by a person or an agent. */
514+ if (activity) {
515+ // Pushes: commits, projects, and who.
516+ const pushers = new Map<string, number>();
517+ const pushed = new Set<string>();
518+ let commits = 0;
519+ let pushes = 0;
520+ for (const repo of activity.repos) {
521+ let any = false;
522+ for (const by of repo.pushes.by) {
523+ if (actorKind(by.actor, agentKeys) !== kind) continue;
524+ any = true;
525+ commits += by.commits;
526+ pushes += by.count;
527+ if (by.actor) pushers.set(by.actor, (pushers.get(by.actor) ?? 0) + by.count);
528+ }
529+ if (any) pushed.add(repo.repo_id);
530+ }
531+ if (pushes > 0) {
532+ const where = `to ${plural(pushed.size, "project")}`;
533+ const text =
534+ kind === "person"
535+ ? pushers.size > 0
536+ ? `${people(pushers.size)} pushed ${plural(commits, "commit")} ${where}`
537+ : `${plural(commits, "commit")} pushed ${where}`
538+ : `pushed ${plural(commits, "commit")} ${where}`;
539+ lines.push({ key: "pushes", text, to: placeFor(slug, repos, pushed, "activity") });
540+ credit(pushers);
541+ }
542+
543+ // Pull requests, reviews, issues, comments, deploys, releases, packages: summed over projects.
544+ const sum = (pick: (repo: RepoDigest) => ActorCount[]) => {
545+ const total = { count: 0, actors: new Map<string, number>(), repoIds: [] as string[] };
546+ for (const repo of activity.repos) {
547+ const { count, actors } = part(pick(repo), kind, agentKeys);
548+ if (count === 0) continue;
549+ total.count += count;
550+ total.repoIds.push(repo.repo_id);
551+ for (const [key, n] of actors) total.actors.set(key, (total.actors.get(key) ?? 0) + n);
552+ }
553+ return total;
554+ };
555+ const opened = sum((repo) => repo.pulls.opened);
556+ const merged = sum((repo) => repo.pulls.merged);
557+ const closed = sum((repo) => repo.pulls.closed);
558+ if (opened.count + merged.count + closed.count > 0) {
559+ const parts: string[] = [];
560+ if (opened.count > 0) parts.push(`opened ${plural(opened.count, "pull request")}`);
561+ if (merged.count > 0) parts.push(`merged ${opened.count > 0 ? merged.count.toLocaleString("en-US") : plural(merged.count, "pull request")}`);
562+ if (closed.count > 0) parts.push(`closed ${closed.count.toLocaleString("en-US")} without merging`);
563+ lines.push({ key: "pulls", text: parts.join(", "), to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds], "pulls") });
564+ credit(opened.actors);
565+ credit(merged.actors);
566+ credit(closed.actors);
567+ }
568+ const reviews = sum((repo) => repo.reviews);
569+ if (reviews.count > 0) {
570+ lines.push({ key: "reviews", text: `reviewed ${plural(reviews.count, "pull request")}`, to: placeFor(slug, repos, reviews.repoIds, "pulls") });
571+ credit(reviews.actors);
572+ }
573+ const issuesOpened = sum((repo) => repo.issues.opened);
574+ const issuesClosed = sum((repo) => repo.issues.closed);
575+ if (issuesOpened.count + issuesClosed.count > 0) {
576+ const parts: string[] = [];
577+ if (issuesOpened.count > 0) parts.push(`opened ${plural(issuesOpened.count, "issue")}`);
578+ if (issuesClosed.count > 0) parts.push(`closed ${issuesOpened.count > 0 ? issuesClosed.count.toLocaleString("en-US") : plural(issuesClosed.count, "issue")}`);
579+ lines.push({ key: "issues", text: parts.join(", "), to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds], "issues") });
580+ credit(issuesOpened.actors);
581+ credit(issuesClosed.actors);
582+ }
583+ const comments = sum((repo) => repo.comments);
584+ if (comments.count > 0) {
585+ lines.push({ key: "comments", text: `left ${plural(comments.count, "comment")}`, to: placeFor(slug, repos, comments.repoIds, "issues") });
586+ credit(comments.actors);
587+ }
588+ const succeeded = sum((repo) => repo.deployments.succeeded);
589+ const failed = sum((repo) => repo.deployments.failed);
590+ if (succeeded.count + failed.count > 0) {
591+ const text = succeeded.count > 0 ? `${plural(succeeded.count, "deploy")} went out${failed.count > 0 ? `, ${failed.count.toLocaleString("en-US")} failed` : ""}` : `${plural(failed.count, "deploy")} failed`;
592+ lines.push({ key: "deploys", text, to: placeFor(slug, repos, [...succeeded.repoIds, ...failed.repoIds], "deployments") });
593+ credit(succeeded.actors);
594+ credit(failed.actors);
595+ }
596+ const releases = sum((repo) => tallyOf(repo.releases.map((release) => release.actor)));
597+ if (releases.count > 0) {
598+ lines.push({ key: "releases", text: `published ${plural(releases.count, "release")}`, to: placeFor(slug, repos, releases.repoIds, "releases") });
599+ credit(releases.actors);
600+ }
601+ const packages = sum((repo) => tallyOf(repo.packages.map((pkg) => pkg.actor)));
602+ if (packages.count > 0) {
603+ lines.push({ key: "packages", text: `published ${plural(packages.count, "package version")}`, to: `/${slug}/-/packages` });
604+ credit(packages.actors);
605+ }
606+
607+ // Artifacts: made, and whose content changed.
608+ if (activity.folios) {
609+ const made = part(activity.folios.created, kind, agentKeys);
610+ const edited = new Set<string>();
611+ const editors = new Map<string, number>();
612+ let allDocs = true;
613+ for (const folio of activity.folios.edited) {
614+ const mine = folio.authors.filter((author) => actorKind(author, agentKeys) === kind);
615+ if (mine.length === 0) continue;
616+ edited.add(folio.folio_id);
617+ if (folio.kind !== "doc") allDocs = false;
618+ for (const author of mine) editors.set(author, (editors.get(author) ?? 0) + 1);
619+ }
620+ const what = (n: number) => plural(n, allDocs ? "doc" : "artifact");
621+ if (made.count + edited.size > 0) {
622+ const parts: string[] = [];
623+ if (made.count > 0) parts.push(`created ${what(made.count)}`);
624+ if (edited.size > 0) parts.push(`edited ${what(edited.size)}`);
625+ lines.push({ key: "docs", text: parts.join(", "), to: `/${slug}/-/artifacts` });
626+ credit(made.actors);
627+ credit(editors);
628+ }
629+ }
630+ }
631+
632+ // Chat: what each kind said.
633+ if (chat) {
634+ const said = part(
635+ chat.authors.map((author) => ({ actor: author.key, count: author.messages })),
636+ kind,
637+ agentKeys,
638+ );
639+ if (said.count > 0) {
640+ const text = kind === "person" ? `sent ${plural(said.count, "message")} in ${plural(chat.channels, "conversation")}` : `replied ${plural(said.count, "time")} in chat`;
641+ lines.push({ key: "chat", text, to: `/${slug}/-/chat` });
642+ credit(said.actors);
643+ }
644+ }
645+
646+ const actors: DigestActor[] = [...did]
647+ .map(([key, count]) => ({ key, kind, name: names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"), count }))
648+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
649+ return { kind, lines, actors };
650+}
651+
652+/** A tally with one of each actor, for lists that name an actor per item. */
653+function tallyOf(actors: string[]): ActorCount[] {
654+ const counts = new Map<string, number>();
655+ for (const actor of actors) counts.set(actor, (counts.get(actor) ?? 0) + 1);
656+ return [...counts].map(([actor, count]) => ({ actor, count }));
657+}
658+
659+/** Both columns, what the sentence needs, and which sources were silent. */
660+export function digestOf(input: DigestInput): Digest {
661+ const people = digestColumn("person", input);
662+ const agents = digestColumn("agent", input);
663+ const missing: string[] = [];
664+ if (input.code && !input.activity) missing.push("Code");
665+ if (!input.chat) missing.push("Chat");
666+ if (!input.work) missing.push("Agents");
667+ let commits = 0;
668+ let merged = 0;
669+ for (const repo of input.activity?.repos ?? []) {
670+ for (const by of repo.pushes.by) if (actorKind(by.actor, input.agentKeys) === "person") commits += by.commits;
671+ merged += part(repo.pulls.merged, "person", input.agentKeys).count;
672+ }
673+ const said = part(
674+ (input.chat?.authors ?? []).map((author) => ({ actor: author.key, count: author.messages })),
675+ "person",
676+ input.agentKeys,
677+ ).count;
678+ const counts = input.work?.counts;
679+ return {
680+ people,
681+ agents,
682+ summary: {
683+ people: people.actors.length,
684+ commits,
685+ merged,
686+ finished: counts ? counts.accepted + counts.fixed + counts.finished : 0,
687+ dropped: counts?.dropped ?? 0,
688+ said,
689+ anything: people.lines.length > 0 || agents.lines.length > 0,
690+ peopleRead: input.activity != null || input.chat != null,
691+ },
692+ missing,
693+ partial: (input.activity != null && !input.activity.complete) || (input.work?.partial ?? false),
694+ };
695+}
696+
697+// --- What landed in the span -----------------------------------------------------
698+
699+/**
700+ * A change that landed: a pull request merged; commits pushed straight to
701+ * a default branch; a production build that went live; a release or a
702+ * package version published. By a person or an agent.
703+ */
366704 export type LandedChange = {
367705 key: string;
706+ kind: "merge" | "push" | "deploy" | "release" | "package";
368707 title: string;
369− repo: RepoPath;
370− number: number;
708+ /** One line under it: where, and by whom. */
709+ detail: string;
371710 to: string;
372711 at: number;
373− /** An agent's change, or a person's. */
712+ /** An agent's doing, or a person's. */
374713 agent: boolean;
375− author: string;
376− mergedBy: string | null;
377714 };
378715
379716 /** Every pull request that merged in the span, newest first. */
380717 export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
381718 return pulls
382719 .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− }))
720+ .map((pull) => {
721+ const agent = isAgentPull(pull);
722+ const mergedBy = pull.mergedBy && pull.mergedBy !== pull.author.username ? `, merged by ${pull.mergedBy}` : "";
723+ return {
724+ key: `landed:${pullKey(pull.repo, pull.number)}`,
725+ kind: "merge" as const,
726+ title: pull.title,
727+ detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`,
728+ to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
729+ at: Date.parse(pull.mergedAt!),
730+ agent,
731+ };
732+ })
394733 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
395734 }
396735
736+/** The names behind member keys, for a line: `ana and bo`, `@margo`, `a mirror` for nobody. */
737+function namesOf(keys: string[], names: Record<string, string>, agentKeys?: ReadonlySet<string>): string {
738+ const shown = keys.map((key) => {
739+ if (!key) return "a mirror";
740+ const name = names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone");
741+ return actorKind(key, agentKeys) === "agent" ? `@${name}` : name;
742+ });
743+ return listOf([...new Set(shown)]);
744+}
745+
746+/**
747+ * Commits pushed straight to a default branch, one row per project with
748+ * any: what landed without a pull request. Releases and package versions
749+ * published, one row each. Pushes within the span only: the digest was
750+ * asked for the span.
751+ */
752+export function landedFromActivity(activity: ActivityDigest | null, repos: Record<string, RepoRef>, names: Record<string, string>, slug: string, agentKeys?: ReadonlySet<string>): LandedChange[] {
753+ const rows: LandedChange[] = [];
754+ for (const repo of activity?.repos ?? []) {
755+ const path = repos[repo.repo_id];
756+ if (!path) continue;
757+ const direct = repo.pushes.default_branch;
758+ if (direct.count > 0 && direct.last_at) {
759+ const by = direct.by.map((entry) => entry.actor);
760+ rows.push({
761+ key: `push:${repo.repo_id}`,
762+ kind: "push",
763+ title: `${plural(direct.commits, "commit")} pushed to ${path.defaultBranch}`,
764+ detail: `${path.name} · ${plural(direct.count, "push", "pushes")} by ${namesOf(by, names, agentKeys)}`,
765+ to: `/${path.namespace}/${path.name}/activity`,
766+ at: Date.parse(direct.last_at),
767+ agent: by.length > 0 && by.every((key) => key && actorKind(key, agentKeys) === "agent"),
768+ });
769+ }
770+ for (const release of repo.releases) {
771+ rows.push({
772+ key: `release:${repo.repo_id}:${release.tag}`,
773+ kind: "release",
774+ title: release.name ? `${release.tag} · ${release.name}` : release.tag,
775+ detail: `${path.name} · released by ${namesOf([release.actor], names, agentKeys)}`,
776+ to: `/${path.namespace}/${path.name}/releases/tag/${encodeURIComponent(release.tag)}`,
777+ at: Date.parse(release.at),
778+ agent: actorKind(release.actor, agentKeys) === "agent",
779+ });
780+ }
781+ for (const pkg of repo.packages) {
782+ rows.push({
783+ key: `package:${repo.repo_id}:${pkg.ecosystem}:${pkg.name}:${pkg.version}`,
784+ kind: "package",
785+ title: `${pkg.name} ${pkg.version}`,
786+ detail: `${pkg.ecosystem} package · published by ${namesOf([pkg.actor], names, agentKeys)}`,
787+ to: `/${slug}/-/packages`,
788+ at: Date.parse(pkg.at),
789+ agent: actorKind(pkg.actor, agentKeys) === "agent",
790+ });
791+ }
792+ }
793+ return rows;
794+}
795+
796+/** Production builds that went live in the span, as landed changes; previews stay in Deploys. */
797+export function landedDeploys(deploys: DeployRow[]): LandedChange[] {
798+ return deploys
799+ .filter((row) => row.kind === "production" && row.outcome === "live")
800+ .map((row) => ({
801+ key: `landed:${row.key}`,
802+ kind: "deploy" as const,
803+ title: `Production of ${row.project} went live`,
804+ detail: `${row.commit} · by ${row.by}`,
805+ to: row.to,
806+ at: row.at,
807+ agent: isAgentName(row.by),
808+ }));
809+}
810+
811+/** Everything that landed, newest first. */
812+export function landed(parts: LandedChange[][]): LandedChange[] {
813+ return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
814+}
815+
397816 /** A build that finished in the span. */
398817 export type DeployRow = {
399818 key: string;
473892
474893 // --- Running now ----------------------------------------------------------------
475894
476−/** Something going right now: a session, an agent's change, a build. */
895+/** Something going right now: a session, an agent's change, a workflow run, a build. */
477896 export type RunningRow = {
478897 key: string;
479− kind: "session" | "change" | "deploy";
898+ kind: "session" | "change" | "workflow" | "deploy";
480899 title: string;
481900 /** One line: who and where. */
482901 detail: string;
538957 });
539958 }
540959
541−/** Everything running, longest-going first within each kind: sessions, then changes, then builds. */
960+const RUN_STATUS: Partial<Record<WorkflowRun["status"], string>> = {
961+ pending: "Queued",
962+ queued: "Queued",
963+ action_required: "Needs approval",
964+ in_progress: "Running",
965+ waiting: "Waiting",
966+};
967+
968+/** Workflow runs not finished, in the projects read: queued, waiting, running, or waiting for approval. */
969+export function runningWorkflows(projects: { repo: RepoPath; runs: WorkflowRun[] }[]): RunningRow[] {
970+ return projects.flatMap(({ repo, runs }) =>
971+ runs.flatMap((run) => {
972+ const status = RUN_STATUS[run.status];
973+ if (!status) return [];
974+ return [
975+ {
976+ key: `workflow:${repo.namespace}/${repo.name}:${run.id}`.toLowerCase(),
977+ kind: "workflow" as const,
978+ title: run.title ? `${run.name}: ${run.title}` : run.name,
979+ detail: `${repo.namespace}/${repo.name} ${run.ref.replace(/^refs\/heads\//, "")}${run.actor ? ` · by ${run.actor}` : ""}`,
980+ status,
981+ at: Date.parse(run.startedAt ?? run.createdAt),
982+ to: `/${repo.namespace}/${repo.name}/actions/runs/${run.id}`,
983+ },
984+ ];
985+ }),
986+ );
987+}
988+
989+/** Everything running, longest-going first within each kind: sessions, then changes, then workflow runs, then builds. */
542990 export function running(parts: RunningRow[][]): RunningRow[] {
543− const order = { session: 0, change: 1, deploy: 2 } as const;
991+ const order = { session: 0, change: 1, workflow: 2, deploy: 3 } as const;
544992 return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
545993 }
546994
10381486 // --- The sentence -----------------------------------------------------------
10391487
10401488 /**
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.
1489+ * The sentence under the heading, honest about what happened: "Since
1490+ * Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents
1491+ * finished 7 tasks, and 12 changes landed." What people did comes first
1492+ * (commits and merges; else what they said), then the agents' tasks that
1493+ * settled well (accepted, fixed, sessions finished), then everything that
1494+ * landed. When nothing at all was counted: "Quiet since Tuesday evening:
1495+ * nothing pushed, merged, deployed or said." When what people did could
1496+ * not be read, it says so.
10451497 */
1046−export function spanSentence(span: Pick<Span, "words">, work: Pick<SpanWork, "counts" | "tasks"> | null, landed: number | null): string {
1498+export function spanSentence(span: Pick<Span, "words">, digest: Pick<Digest, "summary"> | null, landed: number | null): string {
10471499 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)}.`;
1500+ if (!digest) return `${lead}, what happened couldn't be read.`;
1501+ const { people: count, commits, merged, finished, dropped, said, anything, peopleRead } = digest.summary;
1502+ const landedCount = landed ?? 0;
1503+ if (!anything && landedCount === 0) {
1504+ if (!peopleRead) return `${lead}, what people did couldn't be read, and no agent task settled.`;
1505+ return `Quiet ${lead.charAt(0).toLowerCase()}${lead.slice(1)}: nothing pushed, merged, deployed or said.`;
1506+ }
1507+ const parts: string[] = [];
1508+ if (count > 0) {
1509+ if (commits > 0 || merged > 0) {
1510+ const did: string[] = [];
1511+ if (commits > 0) did.push(`pushed ${plural(commits, "commit")}`);
1512+ if (merged > 0) did.push(`merged ${plural(merged, "change")}`);
1513+ parts.push(`${people(count)} ${did.join(" and ")}`);
1514+ } else if (said > 0) parts.push(`${people(count)} sent ${plural(said, "message")}`);
1515+ else parts.push(`${people(count)} ${count === 1 ? "was" : "were"} active`);
1516+ }
1517+ if (finished > 0) parts.push(`agents finished ${plural(finished, "task")}`);
1518+ else if (dropped > 0) parts.push(`agents finished none of ${plural(dropped, "task")}`);
1519+ if (landedCount > 0) parts.push(`${plural(landedCount, "change")} landed`);
1520+ const tail = peopleRead ? "" : "; what people did couldn't be read";
1521+ if (parts.length === 0) return `${lead}, work went on, but nothing landed and no agent task finished${tail}.`;
1522+ const joined = parts.length > 2 ? `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}` : parts.join(" and ");
1523+ return `${lead}, ${joined}${tail}.`;
10651524 }
10661525
10671526 /** "8 things need you.", or that nothing does. */
+2−1
119119 "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
121121 "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs " +
122− "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit team_agent_index"
122+ "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit team_agent_index " +
123+ "activity_digest activity"
123124 ).split(" "),
124125 );
125126
+65−23
1111 DeploysCard,
1212 LandedCard,
1313 NeedsList,
14+ PeopleAgentsCard,
1415 RowsSkeleton,
1516 RunningList,
1617 Sources,
17− SpanCard,
1818 SpanSkeleton,
1919 SpendCard,
2020 SpendSkeleton,
2828 import { sidebarOrNull } from "../../lib/chat.server";
2929 import {
3030 type Attention,
31+ type Digest,
3132 type SpanWork,
3233 attention,
3334 decisionsIn,
3435 deploysIn,
36+ digestOf,
37+ landed,
38+ landedDeploys,
39+ landedFromActivity,
3540 landedIn,
3641 parseWindow,
3742 running,
3843 runningChanges,
3944 runningDeploys,
4045 runningSessions,
46+ runningWorkflows,
4147 spanFor,
4248 spanSentence,
4349 spanWork,
4450 waitingSentence,
4551 } from "../../lib/home";
4652 import {
53+ actorKeys,
54+ loadActivity,
4755 loadAgentsOverview,
56+ loadChatActivity,
4857 loadCodeWork,
4958 loadDeploys,
5059 loadInstallRequests,
5160 loadLastVisit,
5261 loadMemories,
62+ loadNames,
63+ loadRepos,
5364 loadSessions,
5465 loadSpend,
66+ loadWorkflowRuns,
5567 reachFor,
68+ repoRefs,
5669 } from "../../lib/home.server";
5770 import { readCookie } from "../../lib/mission";
5871 import { loadMissionControl } from "../../lib/mission-control.server";
7184 /**
7285 * Home: the workspace's front page. Where you're needed now, with no time
7386 * window; what happened since you were last here (or over the last 24
74− * hours or 7 days, `?window=`); what is running; what the span cost; and
75− * one place to start. The last visit is kept by notify, per person and
87+ * hours or 7 days, `?window=`): what people did and what agents did, from
88+ * the events service's digest of the span, chat's, and the agents' tasks,
89+ * then what landed; what is running; what the span cost; and one place to
90+ * start. The last visit is kept by notify, per person and
7691 * workspace, and marked by the page once it has been looked at
7792 * (routes/workspace/home-seen.ts), so a refresh never wipes it. Only the
7893 * agents list (for Ask g1t) and the last visit (for the span) are waited
126141
127142 const span = spanFor(windowKey, now, await lastSeenP, tz);
128143
129− // What happened in the span.
130− const codeWorkP = code ? loadCodeWork(viewer, slug, reachFor(span)).catch(() => null) : Promise.resolve("no_access" as const);
144+ // What happened in the span: the repositories once, for every section that reads them.
145+ const reposP = code ? loadRepos(viewer, slug).catch(() => null) : Promise.resolve(null);
146+ const codeWorkP = code ? reposP.then((owned) => loadCodeWork(viewer, slug, reachFor(span), owned)).catch(() => null) : Promise.resolve("no_access" as const);
131147 const sessionsP = loadSessions(viewer, slug, span.from).catch(() => null);
132148 const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null);
133149 const memoriesP = code ? loadMemories(viewer, slug).catch(() => null) : Promise.resolve(null);
150+ const activityP = reposP.then((owned) => loadActivity(slug, span, owned)).catch(() => null);
151+ const chatActivityP = loadChatActivity(viewer, slug, span).catch(() => null);
152+ const namesP = Promise.all([activityP, chatActivityP, agentsP])
153+ .then(([activity, chatActivity, agents]) => loadNames(actorKeys(activity, chatActivity), agents))
154+ .catch(() => ({}) as Record<string, string>);
134155
135156 const work: Promise<SpanWork | null> = Promise.all([codeWorkP, sessionsP])
136157 .then(([codeWork, sessions]) => (codeWork == null && sessions == null ? null : spanWork({ span, slug, code: codeWork, sessions })))
137158 .catch(() => null);
138− const happened = Promise.all([codeWorkP, deploysP, memoriesP, requestsP])
139− .then(([codeWork, deploys, memories, requests]) => ({
140− landed: codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null,
141− deploys: deploys ? deploysIn(deploys.projects, slug, span) : null,
142− deploysComplete: deploys?.complete ?? true,
143− decisions: decisionsIn({ memories, requests: requests?.requests ?? null }, slug, span),
144− decisionsRead: { memories: !code || memories != null, requests: requests != null },
145− }))
159+ // People and agents: what each did, from events, chat and the agents' tasks.
160+ const digest: Promise<Digest | null> = Promise.all([activityP, chatActivityP, namesP, work, reposP])
161+ .then(([activity, chatActivity, names, spanWork, owned]) => digestOf({ slug, repos: repoRefs(owned), activity, chat: chatActivity, names, work: spanWork, code }))
162+ .catch(() => null);
163+ const happened = Promise.all([codeWorkP, deploysP, memoriesP, requestsP, activityP, namesP, reposP])
164+ .then(([codeWork, deploys, memories, requests, activity, names, owned]) => {
165+ const deployRows = deploys ? deploysIn(deploys.projects, slug, span) : null;
166+ const merges = codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null;
167+ return {
168+ // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read.
169+ landed: merges == null && activity == null ? null : landed([merges ?? [], landedFromActivity(activity, repoRefs(owned), names, slug), landedDeploys(deployRows ?? [])]),
170+ landedMissing: [...(code && merges == null ? ["Pull requests"] : []), ...(code && activity == null ? ["Pushes and releases"] : []), ...(code && deployRows == null ? ["Deployments"] : [])],
171+ deploys: deployRows,
172+ deploysComplete: deploys?.complete ?? true,
173+ decisions: decisionsIn({ memories, requests: requests?.requests ?? null }, slug, span),
174+ decisionsRead: { memories: !code || memories != null, requests: requests != null },
175+ };
176+ })
146177 .catch(() => null);
147− const runningNow = Promise.all([sessionsP, missionP, deploysP])
148− .then(([sessions, mission, deploys]) => ({
178+ const workflowsP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadWorkflowRuns(viewer, activity, owned)).catch(() => null) : Promise.resolve(null);
179+ const runningNow = Promise.all([sessionsP, missionP, deploysP, workflowsP])
180+ .then(([sessions, mission, deploys, workflows]) => ({
149181 rows: running([
150182 runningSessions(sessions?.sessions ?? [], slug),
151183 runningChanges(mission?.waiting ?? []),
184+ runningWorkflows(workflows?.projects ?? []),
152185 runningDeploys(deploys?.overview ?? [], slug),
153186 ]),
154− missing: [...(sessions ? [] : ["Agents"]), ...(code && !mission ? ["Code"] : []), ...(code && !deploys ? ["Deployments"] : [])],
187+ missing: [
188+ ...(sessions ? [] : ["Agents"]),
189+ ...(code && !mission ? ["Code"] : []),
190+ ...(code && (!workflows || !workflows.complete) ? ["Workflows"] : []),
191+ ...(code && !deploys ? ["Deployments"] : []),
192+ ],
155193 }))
156194 .catch(() => ({ rows: [], missing: ["Agents"] }));
157195 const spend = loadSpend(viewer, slug, span).catch(() => null);
164202 tz,
165203 span,
166204 orchestrator: agents?.some(isOrchestrator) ?? false,
205+ /** The agents' faces, for the Agents column. */
206+ faces: (agents ?? []).map(({ id, handle, display_name, avatar, avatar_seed, builtin }) => ({ id, handle, display_name, avatar, avatar_seed, builtin })),
167207 work,
208+ /** The People and Agents card needs both the digest and the agents' tasks. */
209+ since: Promise.all([digest, work]),
168210 happened,
169211 attention: waiting,
170212 running: runningNow,
171− /** The sentence under the heading needs the work, what landed and what waits. */
172− head: Promise.all([work, happened, waiting]),
213+ /** The sentence under the heading needs the digest, what landed and what waits. */
214+ head: Promise.all([digest, happened, waiting]),
173215 spend,
174216 };
175217 }
276318 }
277319
278320 export default function Home({ loaderData, actionData }: Route.ComponentProps) {
279− const { slug, now, tz, span, orchestrator, work, happened, attention: waiting, running: runningNow, head, spend } = loaderData;
321+ const { slug, now, tz, span, orchestrator, faces, work, since, happened, attention: waiting, running: runningNow, head, spend } = loaderData;
280322 useMarkSeen(slug, now);
281323 return (
282324 <div className="mx-auto w-full max-w-[1040px] space-y-5 px-4 py-5 md:px-10 md:py-8">
286328 <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted">
287329 <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}>
288330 <Await resolve={head}>
289− {([work, happened, att]) => (
331+ {([digest, happened, att]) => (
290332 <>
291− <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, work, happened?.landed?.length ?? null)}
333+ <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)}
292334 </>
293335 )}
294336 </Await>
337379 </p>
338380 </div>
339381 <Suspense fallback={<SpanSkeleton />}>
340− <Await resolve={work}>{(value) => <SpanCard work={value} span={span} />}</Await>
382+ <Await resolve={since}>{([value, spanWork]) => <PeopleAgentsCard digest={value} work={spanWork} span={span} faces={faces} code={loaderData.code} />}</Await>
341383 </Suspense>
342384 <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
343385 <div className="space-y-5">
348390 </Card>
349391 }
350392 >
351− <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} code={loaderData.code} now={now} tz={tz} />}</Await>
393+ <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} />}</Await>
352394 </Suspense>
353395 <Suspense fallback={null}>
354396 <Await resolve={happened}>
+189−0
119119 /// a repository that leads.
120120 #[serde(skip_serializing_if = "Option::is_none")]
121121 pub mirror: Option<crate::mirrors::RepoMirror>,
122+ /// How many commits the push brought to the branch, counted along its
123+ /// first-parent line from `after` back to `before` (a merge commit is
124+ /// one), at most 50; one for a new branch. Absent when it was not
125+ /// counted: a tag, a branch deleted, a pull request landed, a push the
126+ /// store could not read back, or one from before this was recorded.
127+ /// Home's digest (`activity_digest`) sums it.
128+ #[serde(skip_serializing_if = "Option::is_none")]
129+ pub commits: Option<u32>,
122130 }
123131
124132 /// The payload of `issue.opened`, `issue.updated`, `issue.assigned`,
10221030 pub owners: Vec<String>,
10231031 }
10241032
1033+/// `activity_digest`: what happened in some repositories, and to a
1034+/// workspace's artifacts, over a span, counted. Returns [`ActivityDigest`].
1035+/// Home reads it for what people and agents did since you were last there.
1036+/// Fields are snake_case, as every service's arguments are.
1037+///
1038+/// The span is `[from, until)` in RFC 3339. Event ids sort by time, so the
1039+/// service reads each repository's events of each type it counts as one
1040+/// range of the `(repo_id, type, id)` index, from `id_floor(from)` to
1041+/// `id_floor(until)`; a workspace's folio events (which carry no
1042+/// repository) are the same range under a null repository, narrowed to
1043+/// the workspace. That is indexed range scans per repository and type, and
1044+/// it suits a workspace of tens of repositories. When a workspace has
1045+/// hundreds, the next step is a daily rollup per workspace that this
1046+/// method reads instead of the log; it is not built yet.
1047+#[derive(Debug, Default, Serialize, serde::Deserialize)]
1048+pub struct ActivityDigestArgs {
1049+ /// The repositories to count, by id. The caller has checked the
1050+ /// viewer may read each. At most `MAX_DIGEST_REPOS` are read.
1051+ #[serde(default)]
1052+ pub repo_ids: Vec<String>,
1053+ /// The workspace's slug, for its `folio.*` events; none leaves
1054+ /// artifacts out.
1055+ #[serde(default)]
1056+ pub workspace: Option<String>,
1057+ /// The start of the span, inclusive. RFC 3339.
1058+ pub from: String,
1059+ /// The end of the span, exclusive. RFC 3339.
1060+ pub until: String,
1061+}
1062+
1063+/// The most repositories one digest reads.
1064+pub const MAX_DIGEST_REPOS: usize = 50;
1065+/// The most events one digest reads of repositories, and of folios; past
1066+/// either the digest says it is not complete.
1067+pub const MAX_DIGEST_EVENTS: u32 = 5000;
1068+/// The most releases, packages and edited folios a digest lists.
1069+pub const MAX_DIGEST_LISTED: usize = 50;
1070+
1071+/// What `activity_digest` returns.
1072+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq)]
1073+pub struct ActivityDigest {
1074+ pub from: String,
1075+ pub until: String,
1076+ /// One per repository that had any event counted; none for a quiet one.
1077+ pub repos: Vec<RepoDigest>,
1078+ /// The workspace's artifacts, when a workspace was named.
1079+ pub folios: Option<FolioDigest>,
1080+ /// False when more events fell in the span than were read, or more
1081+ /// repositories were named than are read, so counts are low.
1082+ pub complete: bool,
1083+}
1084+
1085+/// How many times one actor did something. The actor is a member key:
1086+/// `user:<id>` for a person (or g1t, `user:usr_g1t_agent`), `agent:<id>`
1087+/// for one of the workspace's agents acting as itself, or empty when the
1088+/// event named nobody (a push copied in by a mirror).
1089+#[derive(Debug, Clone, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1090+pub struct ActorCount {
1091+ pub actor: String,
1092+ pub count: u32,
1093+}
1094+
1095+/// Pushes by one actor, with the commits they brought.
1096+#[derive(Debug, Clone, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1097+pub struct PushesBy {
1098+ pub actor: String,
1099+ pub count: u32,
1100+ pub commits: u32,
1101+}
1102+
1103+/// Pushes to a repository's branches (`git.push` events for `refs/heads/`;
1104+/// tags are not pushes here). A push whose commits were not counted
1105+/// counts as one commit.
1106+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1107+pub struct PushDigest {
1108+ pub count: u32,
1109+ pub commits: u32,
1110+ /// The branches pushed to, by name, sorted.
1111+ pub branches: Vec<String>,
1112+ /// Most pushes first.
1113+ pub by: Vec<PushesBy>,
1114+ /// Of those, the pushes straight to the default branch: what landed
1115+ /// without a pull request.
1116+ pub default_branch: DefaultBranchPushes,
1117+}
1118+
1119+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1120+pub struct DefaultBranchPushes {
1121+ pub count: u32,
1122+ pub commits: u32,
1123+ pub by: Vec<PushesBy>,
1124+ /// When the newest of them was. RFC 3339.
1125+ pub last_at: Option<String>,
1126+}
1127+
1128+/// Pull requests opened, merged and closed without merging, each by who did it.
1129+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1130+pub struct PullDigest {
1131+ pub opened: Vec<ActorCount>,
1132+ pub merged: Vec<ActorCount>,
1133+ pub closed: Vec<ActorCount>,
1134+}
1135+
1136+/// Issues opened and closed, each by who did it.
1137+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1138+pub struct IssueDigest {
1139+ pub opened: Vec<ActorCount>,
1140+ pub closed: Vec<ActorCount>,
1141+}
1142+
1143+/// Builds that finished, by who set them off, and how many of the
1144+/// successes were production.
1145+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1146+pub struct DeploymentDigest {
1147+ pub succeeded: Vec<ActorCount>,
1148+ pub failed: Vec<ActorCount>,
1149+ pub production: u32,
1150+}
1151+
1152+/// A release published in the span.
1153+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1154+pub struct ReleaseDigest {
1155+ pub tag: String,
1156+ pub name: Option<String>,
1157+ pub actor: String,
1158+ /// RFC 3339.
1159+ pub at: String,
1160+}
1161+
1162+/// A package version published in the span.
1163+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1164+pub struct PackageDigest {
1165+ pub ecosystem: String,
1166+ pub name: String,
1167+ pub version: String,
1168+ pub actor: String,
1169+ /// RFC 3339.
1170+ pub at: String,
1171+}
1172+
1173+/// One repository's span.
1174+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq)]
1175+pub struct RepoDigest {
1176+ pub repo_id: String,
1177+ pub pushes: PushDigest,
1178+ pub pulls: PullDigest,
1179+ pub issues: IssueDigest,
1180+ /// Reviews: `review.completed` (g1t's), and a comment with a verdict (a person's).
1181+ pub reviews: Vec<ActorCount>,
1182+ /// Comments without a verdict; one by an agent as itself is the agent's.
1183+ pub comments: Vec<ActorCount>,
1184+ pub deployments: DeploymentDigest,
1185+ /// Newest first, at most `MAX_DIGEST_LISTED`.
1186+ pub releases: Vec<ReleaseDigest>,
1187+ /// Newest first, at most `MAX_DIGEST_LISTED`.
1188+ pub packages: Vec<PackageDigest>,
1189+}
1190+
1191+/// One folio (an artifact: a doc, slides, a design, a dashboard) whose
1192+/// content changed, with everyone whose changes are in it, as member
1193+/// keys. Never its title.
1194+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1195+pub struct FolioEdited {
1196+ pub folio_id: String,
1197+ pub kind: String,
1198+ pub authors: Vec<String>,
1199+}
1200+
1201+/// A workspace's artifacts over the span: `folio.created` by who made
1202+/// them, and the folios whose content changed (`folio.updated`).
1203+#[derive(Debug, Default, Serialize, serde::Deserialize, PartialEq, Eq)]
1204+pub struct FolioDigest {
1205+ pub created: Vec<ActorCount>,
1206+ /// Each folio once, at most `MAX_DIGEST_LISTED`.
1207+ pub edited: Vec<FolioEdited>,
1208+ /// How many folios changed, counting those past the cap.
1209+ pub edited_count: u32,
1210+}
1211+
10251212 #[cfg(test)]
10261213 mod tests {
10271214 use super::*;
11091296 caused_by_job: job_run_of(&actor).map(str::to_owned),
11101297 mirrored: false,
11111298 mirror: None,
1299+ commits: Some(3),
11121300 };
11131301 let push = serde_json::to_value(push).unwrap();
11141302 assert_eq!(caused_by_job(&push), Some("run_9"));
11281316 caused_by_job: None,
11291317 mirrored: false,
11301318 mirror: None,
1319+ commits: None,
11311320 };
11321321 let quiet = serde_json::to_value(push(false)).unwrap();
11331322 assert!(quiet.get("unscanned").is_none());
+30−0
3939 bytes[6] = 0x70 | (counter >> 8) as u8; // version 7
4040 bytes[7] = counter as u8;
4141 bytes[8] = 0x80 | (bytes[8] & 0x3f); // RFC 9562 variant
42+ encode(prefix, bytes)
43+}
4244
45+/// The smallest id with `prefix` that could have been made at or after
46+/// `now_ms`: its time, and every other bit zero. Ids sort by time, so
47+/// `id >= id_floor(p, from) AND id < id_floor(p, until)` picks the ids made
48+/// in `[from, until)` as a range of any index that ends in the id, without
49+/// a time column. No real id ever equals one: a real id's version bits are
50+/// set.
51+pub fn id_floor(prefix: &str, now_ms: u64) -> String {
52+ let mut bytes = [0u8; 16];
53+ bytes[..6].copy_from_slice(&now_ms.to_be_bytes()[2..]);
54+ encode(prefix, bytes)
55+}
56+
57+fn encode(prefix: &str, bytes: [u8; 16]) -> String {
4358 let value = u128::from_be_bytes(bytes);
4459 let mut id = String::with_capacity(prefix.len() + 27);
4560 id.push_str(prefix);
7691 let same_ms: Vec<String> = (0..100).map(|_| new_id("evt", 1_790_000_000_002)).collect();
7792 assert!(same_ms.windows(2).all(|pair| pair[0] < pair[1]));
7893 }
94+
95+ #[test]
96+ fn a_floor_bounds_the_ids_of_a_span() {
97+ let from = 1_790_000_000_000;
98+ let before = new_id("evt", from - 1);
99+ let at = new_id("evt", from);
100+ let later = new_id("evt", from + 60_000);
101+ let floor = id_floor("evt", from);
102+ let ceiling = id_floor("evt", from + 60_000);
103+ assert_eq!(floor.len(), at.len());
104+ assert!(before < floor);
105+ assert!(floor < at && at < ceiling);
106+ // An id made at the very millisecond of the ceiling is past it.
107+ assert!(later >= ceiling);
108+ }
79109 }
+1−1
4949 pub mod webhooks;
5050 pub mod work;
5151
52−pub use ids::new_id;
52+pub use ids::{id_floor, new_id};
5353 pub use names::{
5454 Username, aliasable_name, claimable_namespace, claimable_username, is_namespace_shaped, is_reserved_name, is_route_name,
5555 is_valid_namespace, is_valid_repo_name,
+21−0
288288 can: ChatPermissions;
289289 };
290290
291+/**
292+ * `activity`: what was said over a span, in the conversations the viewer
293+ * can read (every public channel, and the private channels and direct
294+ * messages they are in). Messages are text messages and thread replies
295+ * that were not deleted; cards agents post are not messages. Home reads it
296+ * for what people and agents said since you were last there.
297+ */
298+export type ChatActivity = {
299+ /** RFC 3339: `[from, until)`. */
300+ from: string;
301+ until: string;
302+ messages: number;
303+ /** Conversations with at least one message. */
304+ channels: number;
305+ /** Who said how much: member keys (`user:<id>`, `agent:<id>`), most first. */
306+ authors: { key: string; messages: number }[];
307+};
308+
291309 export type MessagePage = {
292310 messages: ChatMessage[];
293311 /** Pass as `before` to read further back; null at the beginning. */
426444
427445 export type ChatApi = {
428446 sidebar(workspace: string, viewer: User): Promise<Result<ChatSidebar>>;
447+ /** What was said in `[from, until)` (RFC 3339) where the viewer can read, counted. */
448+ activity(workspace: string, viewer: User, span: { from: string; until: string }): Promise<Result<ChatActivity>>;
429449 channel(
430450 workspace: string,
431451 channelId: string,
620640 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
621641 return {
622642 sidebar: (workspace, viewer) => call("sidebar", { workspace, viewer }),
643+ activity: (workspace, viewer, span) => call("activity", { workspace, viewer, from: span.from, until: span.until }),
623644 channel: (workspace, channelId, viewer) => call("channel", { workspace, channel_id: channelId, viewer }),
624645 channelByName: (workspace, name, viewer) => call("channel_by_name", { workspace, name, viewer }),
625646 browse: (workspace, viewer, options) => call("browse", { workspace, viewer, archived: options?.archived ?? false }),
+1−0
757757 return {
758758 publish: (events) => call("publish", { events }),
759759 list: (query) => call("list", query),
760+ activityDigest: (args) => call("activity_digest", args),
760761 };
761762 }
762763
+92−0
193193 mirrored?: boolean;
194194 /** The repository's mirror state when it landed; absent for one that leads. */
195195 mirror?: RepoMirror;
196+ /**
197+ * How many commits the push brought to the branch, along its
198+ * first-parent line from `after` back to `before` (a merge commit is
199+ * one), at most 50; one for a new branch. Absent when it was not
200+ * counted: a tag, a branch deleted, a pull request landed, a push the
201+ * store could not read back, or one from before this was recorded.
202+ */
203+ commits?: number;
196204 };
197205 /**
198206 * `author` is who opened it: g1t, for one its agent filed while at work,
579587 limit?: number;
580588 };
581589
590+/**
591+ * `activity_digest`: what happened in some repositories, and to a
592+ * workspace's artifacts, over `[from, until)`, counted by who did it. Home
593+ * reads it for what people and agents did since you were last there.
594+ * snake_case, as the Rust service reads it
595+ * (`g1t_contracts::events::ActivityDigestArgs`).
596+ */
597+export type ActivityDigestArgs = {
598+ /** By id; the caller has checked the viewer may read each. At most `MAX_DIGEST_REPOS` are read. */
599+ repo_ids: string[];
600+ /** The workspace's slug, for its `folio.*` events (which carry no repository); null leaves artifacts out. */
601+ workspace: string | null;
602+ /** RFC 3339, inclusive. */
603+ from: string;
604+ /** RFC 3339, exclusive. */
605+ until: string;
606+};
607+
608+/** The most repositories one digest reads. */
609+export const MAX_DIGEST_REPOS = 50;
610+
611+/**
612+ * How many times one actor did something. The actor is a member key:
613+ * `user:<id>` for a person (or g1t, `user:usr_g1t_agent`), `agent:<id>` for
614+ * one of the workspace's agents acting as itself, or `""` when the event
615+ * named nobody (a push copied in by a mirror).
616+ */
617+export type ActorCount = { actor: string; count: number };
618+
619+/** Pushes by one actor, with the commits they brought. */
620+export type PushesBy = { actor: string; count: number; commits: number };
621+
622+/** Pushes to a repository's branches (tags are not pushes here). A push whose commits were not counted counts as one commit. */
623+export type PushDigest = {
624+ count: number;
625+ commits: number;
626+ /** The branches pushed to, by name, sorted. */
627+ branches: string[];
628+ /** Most pushes first. */
629+ by: PushesBy[];
630+ /** Of those, the pushes straight to the default branch: what landed without a pull request. */
631+ default_branch: { count: number; commits: number; by: PushesBy[]; last_at: string | null };
632+};
633+
634+export type RepoDigest = {
635+ repo_id: string;
636+ pushes: PushDigest;
637+ /** Pull requests opened, merged and closed without merging, each by who did it. */
638+ pulls: { opened: ActorCount[]; merged: ActorCount[]; closed: ActorCount[] };
639+ issues: { opened: ActorCount[]; closed: ActorCount[] };
640+ /** `review.completed` (g1t's), and a comment with a verdict (a person's). */
641+ reviews: ActorCount[];
642+ /** Comments without a verdict; one by an agent as itself is the agent's. */
643+ comments: ActorCount[];
644+ /** Builds that finished, by who set them off; `production` is how many successes were production. */
645+ deployments: { succeeded: ActorCount[]; failed: ActorCount[]; production: number };
646+ /** Releases published, newest first, at most 50. */
647+ releases: { tag: string; name: string | null; actor: string; at: string }[];
648+ /** Package versions published, newest first, at most 50. */
649+ packages: { ecosystem: string; name: string; version: string; actor: string; at: string }[];
650+};
651+
652+/** A workspace's artifacts over the span. Never a title. */
653+export type FolioDigest = {
654+ created: ActorCount[];
655+ /** Each folio whose content changed, once, with everyone whose changes are in it (member keys); at most 50. */
656+ edited: { folio_id: string; kind: "doc" | "slides" | "design" | "dashboard" | string; authors: string[] }[];
657+ /** How many folios changed, counting those past the cap. */
658+ edited_count: number;
659+};
660+
661+export type ActivityDigest = {
662+ from: string;
663+ until: string;
664+ /** One per repository that had any event counted; none for a quiet one. */
665+ repos: RepoDigest[];
666+ /** When a workspace was named. */
667+ folios: FolioDigest | null;
668+ /** False when more events fell in the span than were read, or more repositories were named than are read, so counts are low. */
669+ complete: boolean;
670+};
671+
582672 /** The event bus and its durable log. */
583673 export interface EventsApi {
584674 publish(events: NewEvent[]): Promise<void>;
585675 /** Newest first. */
586676 list(query: EventQuery): Promise<G1tEvent[]>;
677+ /** A span of some repositories' and a workspace's artifacts' events, counted by who did it. */
678+ activityDigest(args: ActivityDigestArgs): Promise<ActivityDigest>;
587679 }
+25−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { idFloor, newId } from "./ids.ts";
5+
6+test("an id has its prefix and a 26-character suffix, and sorts by time", () => {
7+ const earlier = newId("evt", 1_790_000_000_000);
8+ const later = newId("evt", 1_790_000_000_001);
9+ assert.match(earlier, /^evt_[0-9a-hjkmnp-tv-z]{26}$/);
10+ assert.ok(earlier < later);
11+});
12+
13+test("a floor bounds the ids of a span, as a range of any index that ends in the id", () => {
14+ const from = 1_790_000_000_000;
15+ const before = newId("msg", from - 1);
16+ const at = newId("msg", from);
17+ const later = newId("msg", from + 60_000);
18+ const floor = idFloor("msg", from);
19+ const ceiling = idFloor("msg", from + 60_000);
20+ assert.equal(floor.length, at.length);
21+ assert.ok(before < floor);
22+ assert.ok(floor < at && at < ceiling);
23+ // An id made at the very millisecond of the ceiling is past it.
24+ assert.ok(later >= ceiling);
25+});
+20−0
2929 bytes[6] = 0x70 | (counter >> 8); // version 7
3030 bytes[7] = counter & 0xff;
3131 bytes[8] = 0x80 | (bytes[8] & 0x3f); // RFC 9562 variant
32+ return encode(prefix, bytes);
33+}
34+
35+/**
36+ * The smallest id with `prefix` that could have been made at or after
37+ * `now`: its time, and every other bit zero. Ids sort by time, so
38+ * `id >= idFloor(p, from) AND id < idFloor(p, until)` picks the ids made in
39+ * `[from, until)` as a range of any index that ends in the id, without a
40+ * time column. No real id ever equals one: a real id's version bits are
41+ * set. The same as `id_floor` in crates/contracts.
42+ */
43+export function idFloor(prefix: IdPrefix, now: number): string {
44+ const bytes = new Uint8Array(16);
45+ const time = BigInt(Math.max(0, Math.floor(now)));
46+ for (let i = 0; i < 6; i++) {
47+ bytes[i] = Number((time >> BigInt(40 - 8 * i)) & 0xffn);
48+ }
49+ return encode(prefix, bytes);
50+}
3251
52+function encode(prefix: string, bytes: Uint8Array): string {
3353 let value = 0n;
3454 for (const byte of bytes) value = (value << 8n) | BigInt(byte);
3555 let suffix = "";
+41−0
1+import { test } from "node:test";
2+import assert from "node:assert/strict";
3+
4+import { activitySpan, chatActivity } from "./activity.ts";
5+
6+const span = { from: "2026-10-08T18:00:00.000Z", until: "2026-10-09T18:00:00.000Z" };
7+
8+test("messages, conversations with any, and who said how much, most first", () => {
9+ const activity = chatActivity(
10+ [
11+ { channel_id: "chn_general", author: "user:usr_ana", n: 70 },
12+ { channel_id: "chn_general", author: "agent:agt_margo", n: 15 },
13+ { channel_id: "chn_dev", author: "user:usr_bo", n: 30 },
14+ { channel_id: "chn_dev", author: "user:usr_ana", n: 5 },
15+ // A conversation with nothing in the span is not active.
16+ { channel_id: "chn_quiet", author: "user:usr_bo", n: 0 },
17+ ],
18+ span,
19+ );
20+ assert.deepEqual(activity, {
21+ ...span,
22+ messages: 120,
23+ channels: 2,
24+ authors: [
25+ { key: "user:usr_ana", messages: 75 },
26+ { key: "user:usr_bo", messages: 30 },
27+ { key: "agent:agt_margo", messages: 15 },
28+ ],
29+ });
30+});
31+
32+test("nothing said is zeros, not an error", () => {
33+ assert.deepEqual(chatActivity([], span), { ...span, messages: 0, channels: 0, authors: [] });
34+});
35+
36+test("a span is two times in order", () => {
37+ assert.equal(activitySpan(span.from, span.until)?.fromMs, Date.parse(span.from));
38+ assert.equal(activitySpan(span.until, span.from), null);
39+ assert.equal(activitySpan("yesterday", span.until), null);
40+ assert.equal(activitySpan(null, span.until), null);
41+});
+41−0
1+/**
2+ * What was said over a span, counted (`activity`, for Home). Pure, so the
3+ * counting is tested apart from the service: the service reads one row per
4+ * (conversation, author) with how many messages, over the conversations
5+ * the viewer can read, and this adds them up.
6+ */
7+
8+import type { ChatActivity } from "@g1t/contracts";
9+
10+/** One conversation's messages by one author in the span. */
11+export type ActivityRow = { channel_id: string; author: string; n: number };
12+
13+/** Messages, conversations with any, and who said how much, most first. */
14+export function chatActivity(rows: ActivityRow[], span: { from: string; until: string }): ChatActivity {
15+ const channels = new Set<string>();
16+ const byAuthor = new Map<string, number>();
17+ let messages = 0;
18+ for (const row of rows) {
19+ const n = Math.max(0, Math.floor(Number(row.n) || 0));
20+ if (n === 0) continue;
21+ messages += n;
22+ channels.add(row.channel_id);
23+ byAuthor.set(row.author, (byAuthor.get(row.author) ?? 0) + n);
24+ }
25+ const authors = [...byAuthor]
26+ .map(([key, count]) => ({ key, messages: count }))
27+ .sort((a, b) => b.messages - a.messages || a.key.localeCompare(b.key));
28+ return { from: span.from, until: span.until, messages, channels: channels.size, authors };
29+}
30+
31+/**
32+ * The span as a pair of RFC 3339 times, or null when either is not a time
33+ * or the span is empty.
34+ */
35+export function activitySpan(from: unknown, until: unknown): { from: string; until: string; fromMs: number; untilMs: number } | null {
36+ if (typeof from !== "string" || typeof until !== "string") return null;
37+ const fromMs = Date.parse(from);
38+ const untilMs = Date.parse(until);
39+ if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) return null;
40+ return { from, until, fromMs, untilMs };
41+}
+38−0
1717 CHAT_VIEWER_HEADER,
1818 fail,
1919 identityClient,
20+ idFloor,
2021 newId,
2122 ok,
2223 openD1,
4243 type EmojiUpload,
4344 type ChatSettings,
4445 type ChatSettingsView,
46+ type ChatActivity,
4547 type ChatSidebar,
4648 type ChatSidebarEntry,
4749 CONVERSATION_PEOPLE_SHOWN,
6365 type WorkspaceAgent,
6466 } from "@g1t/contracts";
6567
68+import { type ActivityRow, activitySpan, chatActivity } from "./activity.ts";
6669 import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts";
6770 import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, type Chain, type Wake } from "./delivery.ts";
6871 import {
608611 return ok({ entries: sidebarOrder(entries), browsable: browsable?.n ?? 0, can: permissionsFor(settings, roleOf(a.viewer, slug)) });
609612 }
610613
614+ /**
615+ * What was said in `[from, until)` where the viewer can read: every
616+ * public channel of the workspace, and the private channels and direct
617+ * messages they are in (as `place` reads). Text messages and thread
618+ * replies not deleted; never a card. Message ids sort by time, so each
619+ * conversation's span is one range of `messages_by_channel`, read in one
620+ * round trip for them all. Counts only, nothing of what was said.
621+ */
622+ async activity(a: { workspace: string; viewer: Viewer; from: string; until: string }): Promise<Result<ChatActivity>> {
623+ const found = await this.viewerWorkspace(a.workspace, a.viewer);
624+ if (!found.ok) return found;
625+ const workspace = found.value;
626+ const span = activitySpan(a.from, a.until);
627+ if (!span) return fail("invalid", "from and until must be RFC 3339 times, from before until.");
628+ const me = userKey(a.viewer!);
629+ const rows = await this.db
630+ .prepare(
631+ `SELECT msg.channel_id, msg.author, COUNT(*) AS n
632+ FROM channels c
633+ JOIN messages msg ON msg.channel_id = c.id AND msg.id >= ?3 AND msg.id < ?4
634+ WHERE c.workspace_id = ?1 AND c.archived_at IS NULL
635+ AND (
636+ (c.kind = 'channel' AND c.private = 0)
637+ OR EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id = c.id AND m.principal = ?2)
638+ )
639+ AND msg.deleted_at IS NULL AND msg.kind = 'text'
640+ GROUP BY msg.channel_id, msg.author`,
641+ )
642+ .bind(workspace.id, me, idFloor("msg", span.fromMs), idFloor("msg", span.untilMs))
643+ .all<ActivityRow>();
644+ return ok(chatActivity(rows.results, { from: span.from, until: span.until }));
645+ }
646+
611647 // ── Channels ────────────────────────────────────────────────────────────
612648
613649 async channel(a: { workspace: string; channel_id: string; viewer: Viewer }): Promise<Result<ChannelDetail>> {
20012037 switch (method) {
20022038 case "sidebar":
20032039 return Response.json(await service.sidebar(args));
2040+ case "activity":
2041+ return Response.json(await service.activity(args));
20042042 case "channel":
20052043 return Response.json(await service.channel(args));
20062044 case "channel_by_name":
+823−0
1+//! `activity_digest`: what happened in a workspace's repositories, and to
2+//! its artifacts, over a span, counted by who did it. Home reads it for
3+//! what people and agents did since you were last there
4+//! (docs.g1t.sh/guides/home/). See `g1t_contracts::events::ActivityDigestArgs`.
5+//!
6+//! How it scales: event ids sort by time, so the span is a range of ids,
7+//! and the `(repo_id, type, id)` index (migrations/0008) makes each
8+//! repository's events of each counted type one range scan; one statement
9+//! covers every repository and type asked for, and a second the
10+//! workspace's folio events, which carry no repository. That is a few
11+//! thousand rows at most for a busy week of tens of repositories, read in
12+//! one round trip. A workspace with hundreds of repositories wants a daily
13+//! rollup per workspace instead, which this method would read in place of
14+//! the log; that is the next step and is not built yet.
15+
16+use std::collections::{BTreeMap, BTreeSet};
17+
18+use g1t_contracts::events::{
19+ ActivityDigest, ActivityDigestArgs, ActorCount, DefaultBranchPushes, DeploymentDigest, Event,
20+ FolioDigest, FolioEdited, IssueDigest, MAX_DIGEST_EVENTS, MAX_DIGEST_LISTED, MAX_DIGEST_REPOS,
21+ PackageDigest, PullDigest, PushDigest, PushesBy, ReleaseDigest, RepoDigest,
22+};
23+use g1t_contracts::id_floor;
24+use g1t_contracts::time::parse_rfc3339;
25+use serde_json::Value;
26+use worker::{D1Database, Error, Result};
27+
28+use crate::EventRow;
29+
30+/// The repository events a digest counts.
31+const REPO_TYPES: [&str; 12] = [
32+ "git.push",
33+ "pull.opened",
34+ "pull.merged",
35+ "pull.closed",
36+ "issue.opened",
37+ "issue.closed",
38+ "review.completed",
39+ "comment.created",
40+ "deployment.succeeded",
41+ "deployment.failed",
42+ "release.published",
43+ "package.published",
44+];
45+/// The artifact events a digest counts: published with no repository.
46+const FOLIO_TYPES: [&str; 2] = ["folio.created", "folio.updated"];
47+
48+/// Reads the span's events and counts them.
49+pub async fn activity_digest(db: &D1Database, a: ActivityDigestArgs) -> Result<ActivityDigest> {
50+ let from =
51+ parse_rfc3339(&a.from).ok_or_else(|| Error::RustError("from must be RFC 3339".into()))?;
52+ let until =
53+ parse_rfc3339(&a.until).ok_or_else(|| Error::RustError("until must be RFC 3339".into()))?;
54+ let (low, high) = (id_floor("evt", from), id_floor("evt", until));
55+ let mut complete = a.repo_ids.len() <= MAX_DIGEST_REPOS;
56+ let repo_ids: Vec<&str> = a
57+ .repo_ids
58+ .iter()
59+ .take(MAX_DIGEST_REPOS)
60+ .map(String::as_str)
61+ .collect();
62+ // One past the cap says whether there was more.
63+ let limit = f64::from(MAX_DIGEST_EVENTS + 1);
64+ let mut statements = Vec::with_capacity(2);
65+ if !repo_ids.is_empty() {
66+ statements.push(
67+ db.prepare(
68+ "SELECT id, type, source, time, repo_id, actor, data FROM events
69+ WHERE repo_id IN (SELECT value FROM json_each(?1))
70+ AND type IN (SELECT value FROM json_each(?2))
71+ AND id >= ?3 AND id < ?4
72+ ORDER BY id LIMIT ?5",
73+ )
74+ .bind(&[
75+ serde_json::to_string(&repo_ids)?.into(),
76+ serde_json::to_string(&REPO_TYPES)?.into(),
77+ low.as_str().into(),
78+ high.as_str().into(),
79+ limit.into(),
80+ ])?,
81+ );
82+ }
83+ if let Some(workspace) = a.workspace.as_deref().filter(|slug| !slug.is_empty()) {
84+ statements.push(
85+ db.prepare(
86+ "SELECT id, type, source, time, repo_id, actor, data FROM events
87+ WHERE repo_id IS NULL
88+ AND type IN (SELECT value FROM json_each(?1))
89+ AND id >= ?2 AND id < ?3
90+ AND lower(json_extract(data, '$.workspace')) = ?4
91+ ORDER BY id LIMIT ?5",
92+ )
93+ .bind(&[
94+ serde_json::to_string(&FOLIO_TYPES)?.into(),
95+ low.as_str().into(),
96+ high.as_str().into(),
97+ workspace.to_lowercase().into(),
98+ limit.into(),
99+ ])?,
100+ );
101+ }
102+ let mut results = if statements.is_empty() {
103+ Vec::new()
104+ } else {
105+ db.batch(statements).await?
106+ }
107+ .into_iter();
108+ let mut read = |wanted: bool| -> Result<Option<Vec<Event>>> {
109+ if !wanted {
110+ return Ok(None);
111+ }
112+ let Some(result) = results.next() else {
113+ return Ok(Some(Vec::new()));
114+ };
115+ let mut events: Vec<Event> = result
116+ .results::<EventRow>()?
117+ .into_iter()
118+ .map(Event::from)
119+ .collect();
120+ if events.len() > MAX_DIGEST_EVENTS as usize {
121+ events.truncate(MAX_DIGEST_EVENTS as usize);
122+ complete = false;
123+ }
124+ Ok(Some(events))
125+ };
126+ let repo_events = read(!repo_ids.is_empty())?.unwrap_or_default();
127+ let folio_events = read(a.workspace.as_deref().is_some_and(|slug| !slug.is_empty()))?;
128+ Ok(shape(&a, &repo_events, folio_events.as_deref(), complete))
129+}
130+
131+/// Counts `events` (a repository's, any order) and `folios` (a workspace's
132+/// folio events) into the digest. Pure, so it is tested on its own.
133+pub fn shape(
134+ a: &ActivityDigestArgs,
135+ events: &[Event],
136+ folios: Option<&[Event]>,
137+ complete: bool,
138+) -> ActivityDigest {
139+ let mut repos: BTreeMap<&str, Counting> = BTreeMap::new();
140+ for event in events {
141+ let Some(repo_id) = event.repo_id.as_deref() else {
142+ continue;
143+ };
144+ repos.entry(repo_id).or_default().count(event);
145+ }
146+ ActivityDigest {
147+ from: a.from.clone(),
148+ until: a.until.clone(),
149+ repos: repos
150+ .into_iter()
151+ .map(|(repo_id, counting)| counting.into_digest(repo_id))
152+ .collect(),
153+ folios: folios.map(folio_digest),
154+ complete,
155+ }
156+}
157+
158+/// An event's actor as a member key: `user:<id>`, or empty for nobody.
159+fn member_key(actor: Option<&str>) -> String {
160+ match actor {
161+ Some(id) if !id.is_empty() => format!("user:{id}"),
162+ _ => String::new(),
163+ }
164+}
165+
166+fn text<'a>(data: &'a Value, key: &str) -> Option<&'a str> {
167+ data.get(key).and_then(Value::as_str)
168+}
169+
170+/// A running count per actor.
171+#[derive(Default)]
172+struct Tally(BTreeMap<String, u32>);
173+
174+impl Tally {
175+ fn add(&mut self, actor: String) {
176+ *self.0.entry(actor).or_insert(0) += 1;
177+ }
178+
179+ /// Most first, then by actor, so the order never depends on the log's.
180+ fn into_counts(self) -> Vec<ActorCount> {
181+ let mut counts: Vec<ActorCount> = self
182+ .0
183+ .into_iter()
184+ .map(|(actor, count)| ActorCount { actor, count })
185+ .collect();
186+ counts.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.actor.cmp(&b.actor)));
187+ counts
188+ }
189+}
190+
191+/// Pushes per actor, with their commits.
192+#[derive(Default)]
193+struct Pushing {
194+ count: u32,
195+ commits: u32,
196+ by: BTreeMap<String, (u32, u32)>,
197+}
198+
199+impl Pushing {
200+ fn add(&mut self, actor: String, commits: u32) {
201+ self.count += 1;
202+ self.commits += commits;
203+ let entry = self.by.entry(actor).or_insert((0, 0));
204+ entry.0 += 1;
205+ entry.1 += commits;
206+ }
207+
208+ fn into_by(self) -> Vec<PushesBy> {
209+ let mut by: Vec<PushesBy> = self
210+ .by
211+ .into_iter()
212+ .map(|(actor, (count, commits))| PushesBy {
213+ actor,
214+ count,
215+ commits,
216+ })
217+ .collect();
218+ by.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.actor.cmp(&b.actor)));
219+ by
220+ }
221+}
222+
223+#[derive(Default)]
224+struct Counting {
225+ pushes: Pushing,
226+ branches: BTreeSet<String>,
227+ default_branch: Pushing,
228+ default_branch_last: Option<String>,
229+ pulls_opened: Tally,
230+ pulls_merged: Tally,
231+ pulls_closed: Tally,
232+ issues_opened: Tally,
233+ issues_closed: Tally,
234+ reviews: Tally,
235+ comments: Tally,
236+ deploys_succeeded: Tally,
237+ deploys_failed: Tally,
238+ production: u32,
239+ releases: Vec<ReleaseDigest>,
240+ packages: Vec<PackageDigest>,
241+}
242+
243+impl Counting {
244+ fn count(&mut self, event: &Event) {
245+ let data = &event.data;
246+ let actor = member_key(event.actor.as_deref());
247+ match event.kind.as_str() {
248+ "git.push" => {
249+ let Some(branch) = text(data, "ref").and_then(|r| r.strip_prefix("refs/heads/"))
250+ else {
251+ return;
252+ };
253+ // A push whose commits were not counted brought at least one.
254+ let commits = data
255+ .get("commits")
256+ .and_then(Value::as_u64)
257+ .map_or(1, |n| n.min(u64::from(u32::MAX)) as u32);
258+ self.branches.insert(branch.to_owned());
259+ self.pushes.add(actor.clone(), commits);
260+ if data.get("defaultBranch").and_then(Value::as_bool) == Some(true) {
261+ self.default_branch.add(actor, commits);
262+ if self
263+ .default_branch_last
264+ .as_deref()
265+ .is_none_or(|last| event.time.as_str() > last)
266+ {
267+ self.default_branch_last = Some(event.time.clone());
268+ }
269+ }
270+ }
271+ "pull.opened" => self.pulls_opened.add(actor),
272+ "pull.merged" => self.pulls_merged.add(actor),
273+ "pull.closed" => self.pulls_closed.add(actor),
274+ "issue.opened" => self.issues_opened.add(actor),
275+ "issue.closed" => self.issues_closed.add(actor),
276+ "review.completed" => self.reviews.add(actor),
277+ "comment.created" => {
278+ // One of the workspace's agents, as itself: the event's actor
279+ // is the person it acted for.
280+ let by = match data.get("agent").and_then(|agent| text(agent, "id")) {
281+ Some(id) => format!("agent:{id}"),
282+ None => actor,
283+ };
284+ if data
285+ .get("verdict")
286+ .is_some_and(|verdict| !verdict.is_null())
287+ {
288+ self.reviews.add(by);
289+ } else {
290+ self.comments.add(by);
291+ }
292+ }
293+ "deployment.succeeded" => {
294+ if text(data, "kind") == Some("production") {
295+ self.production += 1;
296+ }
297+ self.deploys_succeeded.add(actor);
298+ }
299+ "deployment.failed" => self.deploys_failed.add(actor),
300+ "release.published" => self.releases.push(ReleaseDigest {
301+ tag: text(data, "tagName").unwrap_or_default().to_owned(),
302+ name: data
303+ .get("release")
304+ .and_then(|release| text(release, "name"))
305+ .filter(|name| !name.is_empty())
306+ .map(str::to_owned),
307+ actor,
308+ at: event.time.clone(),
309+ }),
310+ "package.published" => self.packages.push(PackageDigest {
311+ ecosystem: text(data, "ecosystem").unwrap_or_default().to_owned(),
312+ name: text(data, "name").unwrap_or_default().to_owned(),
313+ version: text(data, "version").unwrap_or_default().to_owned(),
314+ actor,
315+ at: event.time.clone(),
316+ }),
317+ _ => {}
318+ }
319+ }
320+
321+ fn into_digest(mut self, repo_id: &str) -> RepoDigest {
322+ // Newest first, the most recent `MAX_DIGEST_LISTED`.
323+ self.releases
324+ .sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.tag.cmp(&b.tag)));
325+ self.releases.truncate(MAX_DIGEST_LISTED);
326+ self.packages.sort_by(|a, b| {
327+ b.at.cmp(&a.at)
328+ .then_with(|| a.name.cmp(&b.name))
329+ .then_with(|| a.version.cmp(&b.version))
330+ });
331+ self.packages.truncate(MAX_DIGEST_LISTED);
332+ RepoDigest {
333+ repo_id: repo_id.to_owned(),
334+ pushes: PushDigest {
335+ count: self.pushes.count,
336+ commits: self.pushes.commits,
337+ branches: self.branches.into_iter().collect(),
338+ by: self.pushes.into_by(),
339+ default_branch: DefaultBranchPushes {
340+ count: self.default_branch.count,
341+ commits: self.default_branch.commits,
342+ by: self.default_branch.into_by(),
343+ last_at: self.default_branch_last,
344+ },
345+ },
346+ pulls: PullDigest {
347+ opened: self.pulls_opened.into_counts(),
348+ merged: self.pulls_merged.into_counts(),
349+ closed: self.pulls_closed.into_counts(),
350+ },
351+ issues: IssueDigest {
352+ opened: self.issues_opened.into_counts(),
353+ closed: self.issues_closed.into_counts(),
354+ },
355+ reviews: self.reviews.into_counts(),
356+ comments: self.comments.into_counts(),
357+ deployments: DeploymentDigest {
358+ succeeded: self.deploys_succeeded.into_counts(),
359+ failed: self.deploys_failed.into_counts(),
360+ production: self.production,
361+ },
362+ releases: self.releases,
363+ packages: self.packages,
364+ }
365+ }
366+}
367+
368+/// A workspace's folio events counted: who made folios, and which folios'
369+/// content changed with everyone whose changes are in them.
370+fn folio_digest(events: &[Event]) -> FolioDigest {
371+ let mut created = Tally::default();
372+ let mut edited: BTreeMap<&str, (String, BTreeSet<String>)> = BTreeMap::new();
373+ for event in events {
374+ let data = &event.data;
375+ match event.kind.as_str() {
376+ "folio.created" => created.add(member_key(event.actor.as_deref())),
377+ "folio.updated" => {
378+ let Some(folio_id) = text(data, "folioId") else {
379+ continue;
380+ };
381+ let kind = text(data, "kind").unwrap_or("doc").to_owned();
382+ let entry = edited
383+ .entry(folio_id)
384+ .or_insert_with(|| (kind, BTreeSet::new()));
385+ let authors = data
386+ .get("authors")
387+ .and_then(Value::as_array)
388+ .into_iter()
389+ .flatten()
390+ .filter_map(Value::as_str);
391+ let mut any = false;
392+ for author in authors {
393+ any = true;
394+ entry.1.insert(author.to_owned());
395+ }
396+ // A version with no authors named is the actor's.
397+ if !any {
398+ let actor = member_key(event.actor.as_deref());
399+ if !actor.is_empty() {
400+ entry.1.insert(actor);
401+ }
402+ }
403+ }
404+ _ => {}
405+ }
406+ }
407+ FolioDigest {
408+ created: created.into_counts(),
409+ edited_count: edited.len() as u32,
410+ edited: edited
411+ .into_iter()
412+ .take(MAX_DIGEST_LISTED)
413+ .map(|(folio_id, (kind, authors))| FolioEdited {
414+ folio_id: folio_id.to_owned(),
415+ kind,
416+ authors: authors.into_iter().collect(),
417+ })
418+ .collect(),
419+ }
420+}
421+
422+#[cfg(test)]
423+mod tests {
424+ use super::*;
425+ use serde_json::json;
426+
427+ fn event(
428+ id: &str,
429+ kind: &str,
430+ repo_id: Option<&str>,
431+ actor: Option<&str>,
432+ data: Value,
433+ ) -> Event {
434+ Event {
435+ id: id.to_owned(),
436+ kind: kind.to_owned(),
437+ source: "test".to_owned(),
438+ time: format!("2026-10-09T1{}:00:00.000Z", &id[id.len() - 1..]),
439+ repo_id: repo_id.map(str::to_owned),
440+ actor: actor.map(str::to_owned),
441+ data,
442+ }
443+ }
444+
445+ fn args() -> ActivityDigestArgs {
446+ ActivityDigestArgs {
447+ repo_ids: vec!["rep_1".into(), "rep_2".into()],
448+ workspace: Some("acme".into()),
449+ from: "2026-10-09T00:00:00.000Z".into(),
450+ until: "2026-10-10T00:00:00.000Z".into(),
451+ }
452+ }
453+
454+ #[test]
455+ fn pushes_are_counted_by_branch_and_actor_with_their_commits() {
456+ let events = vec![
457+ event(
458+ "evt_1",
459+ "git.push",
460+ Some("rep_1"),
461+ Some("usr_ana"),
462+ json!({ "ref": "refs/heads/main", "after": "b", "before": "a", "defaultBranch": true, "commits": 7 }),
463+ ),
464+ event(
465+ "evt_2",
466+ "git.push",
467+ Some("rep_1"),
468+ Some("usr_ana"),
469+ json!({ "ref": "refs/heads/fix", "after": "c", "defaultBranch": false, "commits": 2 }),
470+ ),
471+ // No count recorded: at least one commit.
472+ event(
473+ "evt_3",
474+ "git.push",
475+ Some("rep_1"),
476+ Some("usr_bo"),
477+ json!({ "ref": "refs/heads/main", "after": "d", "before": "b", "defaultBranch": true }),
478+ ),
479+ // A mirror's push names nobody.
480+ event(
481+ "evt_4",
482+ "git.push",
483+ Some("rep_1"),
484+ None,
485+ json!({ "ref": "refs/heads/main", "after": "e", "before": "d", "defaultBranch": true, "mirrored": true, "commits": 3 }),
486+ ),
487+ // A tag is not a push to a branch.
488+ event(
489+ "evt_5",
490+ "git.push",
491+ Some("rep_1"),
492+ Some("usr_ana"),
493+ json!({ "ref": "refs/tags/v1", "after": "e", "defaultBranch": false }),
494+ ),
495+ ];
496+ let digest = shape(&args(), &events, None, true);
497+ assert_eq!(digest.repos.len(), 1);
498+ let pushes = &digest.repos[0].pushes;
499+ assert_eq!((pushes.count, pushes.commits), (4, 13));
500+ assert_eq!(pushes.branches, vec!["fix", "main"]);
501+ assert_eq!(
502+ pushes.by,
503+ vec![
504+ PushesBy {
505+ actor: "user:usr_ana".into(),
506+ count: 2,
507+ commits: 9
508+ },
509+ PushesBy {
510+ actor: "".into(),
511+ count: 1,
512+ commits: 3
513+ },
514+ PushesBy {
515+ actor: "user:usr_bo".into(),
516+ count: 1,
517+ commits: 1
518+ },
519+ ]
520+ );
521+ assert_eq!(
522+ (pushes.default_branch.count, pushes.default_branch.commits),
523+ (3, 11)
524+ );
525+ assert_eq!(
526+ pushes.default_branch.last_at.as_deref(),
527+ Some("2026-10-09T14:00:00.000Z")
528+ );
529+ assert!(digest.folios.is_none());
530+ assert!(digest.complete);
531+ }
532+
533+ #[test]
534+ fn work_is_counted_per_repository_by_who_did_it() {
535+ let g1t = Some("usr_g1t_agent");
536+ let events = vec![
537+ event(
538+ "evt_1",
539+ "pull.opened",
540+ Some("rep_1"),
541+ Some("usr_ana"),
542+ json!({ "number": 1 }),
543+ ),
544+ event(
545+ "evt_2",
546+ "pull.opened",
547+ Some("rep_1"),
548+ g1t,
549+ json!({ "number": 2 }),
550+ ),
551+ event(
552+ "evt_3",
553+ "pull.merged",
554+ Some("rep_1"),
555+ Some("usr_ana"),
556+ json!({ "number": 2 }),
557+ ),
558+ event(
559+ "evt_4",
560+ "pull.closed",
561+ Some("rep_2"),
562+ Some("usr_bo"),
563+ json!({ "number": 3 }),
564+ ),
565+ event(
566+ "evt_5",
567+ "issue.opened",
568+ Some("rep_2"),
569+ Some("usr_bo"),
570+ json!({ "number": 4 }),
571+ ),
572+ event(
573+ "evt_6",
574+ "issue.closed",
575+ Some("rep_2"),
576+ g1t,
577+ json!({ "number": 4 }),
578+ ),
579+ // A person's review is a comment with a verdict; g1t's is review.completed.
580+ event(
581+ "evt_7",
582+ "comment.created",
583+ Some("rep_1"),
584+ Some("usr_bo"),
585+ json!({ "number": 1, "verdict": "approve" }),
586+ ),
587+ event(
588+ "evt_8",
589+ "review.completed",
590+ Some("rep_1"),
591+ g1t,
592+ json!({ "number": 1, "verdict": "approve" }),
593+ ),
594+ // A workspace agent's comment as itself is the agent's, not the person's it acted for.
595+ event(
596+ "evt_9",
597+ "comment.created",
598+ Some("rep_1"),
599+ Some("usr_ana"),
600+ json!({ "number": 1, "agent": { "id": "agt_margo", "handle": "margo" } }),
601+ ),
602+ event(
603+ "evt_a",
604+ "comment.created",
605+ Some("rep_1"),
606+ Some("usr_ana"),
607+ json!({ "number": 1 }),
608+ ),
609+ event(
610+ "evt_b",
611+ "deployment.succeeded",
612+ Some("rep_1"),
613+ Some("usr_ana"),
614+ json!({ "kind": "production" }),
615+ ),
616+ event(
617+ "evt_c",
618+ "deployment.succeeded",
619+ Some("rep_1"),
620+ Some("usr_ana"),
621+ json!({ "kind": "preview" }),
622+ ),
623+ event(
624+ "evt_d",
625+ "deployment.failed",
626+ Some("rep_1"),
627+ g1t,
628+ json!({ "kind": "production" }),
629+ ),
630+ event(
631+ "evt_e",
632+ "release.published",
633+ Some("rep_1"),
634+ Some("usr_ana"),
635+ json!({ "tagName": "v1.2.0", "release": { "name": "Autumn" } }),
636+ ),
637+ event(
638+ "evt_f",
639+ "package.published",
640+ Some("rep_1"),
641+ Some("usr_bo"),
642+ json!({ "ecosystem": "container", "name": "web", "version": "1.2.0" }),
643+ ),
644+ // Not counted.
645+ event(
646+ "evt_g",
647+ "pull.updated",
648+ Some("rep_1"),
649+ Some("usr_ana"),
650+ json!({ "number": 1 }),
651+ ),
652+ ];
653+ let digest = shape(&args(), &events, None, false);
654+ assert!(!digest.complete);
655+ assert_eq!(digest.repos.len(), 2);
656+ let web = &digest.repos[0];
657+ assert_eq!(web.repo_id, "rep_1");
658+ assert_eq!(
659+ web.pulls.opened,
660+ vec![
661+ ActorCount {
662+ actor: "user:usr_ana".into(),
663+ count: 1
664+ },
665+ ActorCount {
666+ actor: "user:usr_g1t_agent".into(),
667+ count: 1
668+ }
669+ ]
670+ );
671+ assert_eq!(
672+ web.pulls.merged,
673+ vec![ActorCount {
674+ actor: "user:usr_ana".into(),
675+ count: 1
676+ }]
677+ );
678+ assert!(web.pulls.closed.is_empty());
679+ assert_eq!(
680+ web.reviews,
681+ vec![
682+ ActorCount {
683+ actor: "user:usr_bo".into(),
684+ count: 1
685+ },
686+ ActorCount {
687+ actor: "user:usr_g1t_agent".into(),
688+ count: 1
689+ }
690+ ]
691+ );
692+ assert_eq!(
693+ web.comments,
694+ vec![
695+ ActorCount {
696+ actor: "agent:agt_margo".into(),
697+ count: 1
698+ },
699+ ActorCount {
700+ actor: "user:usr_ana".into(),
701+ count: 1
702+ }
703+ ]
704+ );
705+ assert_eq!(
706+ web.deployments.succeeded,
707+ vec![ActorCount {
708+ actor: "user:usr_ana".into(),
709+ count: 2
710+ }]
711+ );
712+ assert_eq!(
713+ web.deployments.failed,
714+ vec![ActorCount {
715+ actor: "user:usr_g1t_agent".into(),
716+ count: 1
717+ }]
718+ );
719+ assert_eq!(web.deployments.production, 1);
720+ assert_eq!(
721+ web.releases,
722+ vec![ReleaseDigest {
723+ tag: "v1.2.0".into(),
724+ name: Some("Autumn".into()),
725+ actor: "user:usr_ana".into(),
726+ at: "2026-10-09T1e:00:00.000Z".into()
727+ }]
728+ );
729+ assert_eq!(web.packages[0].name, "web");
730+ assert_eq!(web.packages[0].actor, "user:usr_bo");
731+ let api = &digest.repos[1];
732+ assert_eq!(
733+ api.pulls.closed,
734+ vec![ActorCount {
735+ actor: "user:usr_bo".into(),
736+ count: 1
737+ }]
738+ );
739+ assert_eq!(
740+ api.issues.opened,
741+ vec![ActorCount {
742+ actor: "user:usr_bo".into(),
743+ count: 1
744+ }]
745+ );
746+ assert_eq!(
747+ api.issues.closed,
748+ vec![ActorCount {
749+ actor: "user:usr_g1t_agent".into(),
750+ count: 1
751+ }]
752+ );
753+ assert_eq!(api.pushes.count, 0);
754+ }
755+
756+ #[test]
757+ fn folios_made_and_the_folios_whose_content_changed() {
758+ let folios = vec![
759+ event(
760+ "evt_1",
761+ "folio.created",
762+ None,
763+ Some("usr_ana"),
764+ json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc" }),
765+ ),
766+ event(
767+ "evt_2",
768+ "folio.updated",
769+ None,
770+ Some("usr_ana"),
771+ json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc", "authors": ["user:usr_ana", "agent:agt_margo"] }),
772+ ),
773+ event(
774+ "evt_3",
775+ "folio.updated",
776+ None,
777+ Some("usr_bo"),
778+ json!({ "workspace": "acme", "folioId": "fol_1", "kind": "doc", "authors": ["user:usr_bo"] }),
779+ ),
780+ // No authors named: the actor's.
781+ event(
782+ "evt_4",
783+ "folio.updated",
784+ None,
785+ Some("usr_bo"),
786+ json!({ "workspace": "acme", "folioId": "fol_2", "kind": "slides", "authors": [] }),
787+ ),
788+ ];
789+ let digest = shape(&args(), &[], Some(&folios), true);
790+ let folios = digest.folios.unwrap();
791+ assert_eq!(
792+ folios.created,
793+ vec![ActorCount {
794+ actor: "user:usr_ana".into(),
795+ count: 1
796+ }]
797+ );
798+ assert_eq!(folios.edited_count, 2);
799+ assert_eq!(
800+ folios.edited,
801+ vec![
802+ FolioEdited {
803+ folio_id: "fol_1".into(),
804+ kind: "doc".into(),
805+ authors: vec![
806+ "agent:agt_margo".into(),
807+ "user:usr_ana".into(),
808+ "user:usr_bo".into()
809+ ]
810+ },
811+ FolioEdited {
812+ folio_id: "fol_2".into(),
813+ kind: "slides".into(),
814+ authors: vec!["user:usr_bo".into()]
815+ },
816+ ]
817+ );
818+ // A workspace named but quiet still answers for its artifacts.
819+ let quiet = shape(&args(), &[], Some(&[]), true);
820+ assert_eq!(quiet.folios, Some(FolioDigest::default()));
821+ assert!(quiet.repos.is_empty());
822+ }
823+}
+7−2
1313 //!
1414 //! Other services reach it over `POST /rpc/<method>`; see
1515 //! `g1t_contracts::events`, `audit` and `inbox` for the methods and their
16−//! arguments.
16+//! arguments. `activity_digest` (digest.rs) counts a span of the log for
17+//! Home.
1718
1819 mod audit;
20+mod digest;
1921 mod fanout;
2022 mod inbox;
2123 mod subscriptions;
4951 /// How long a record of which queues a batch reached is kept for its retries.
5052 const FANOUT_KEEP_MS: u64 = 24 * 60 * 60 * 1000;
5153
54+/// An event as the log's table holds it.
5255 #[derive(Deserialize)]
53−struct EventRow {
56+pub(crate) struct EventRow {
5457 id: String,
5558 #[serde(rename = "type")]
5659 kind: String,
300303 match method.as_str() {
301304 "publish" => reply(&events.publish(args(body)?).await?),
302305 "list" => reply(&events.list(args(body)?).await?),
306+ // Home's digest of a span: counted, by who did it (digest.rs).
307+ "activity_digest" => reply(&digest::activity_digest(&events.db, args(body)?).await?),
303308 "audit_record" => reply(&audit::record(&events.db, args(body)?).await?),
304309 "audit_list" => reply(&audit::list(&events.db, args(body)?).await?),
305310 "inbox_list" => {
+1−1
498498 return Ok(Outcome::fail(FailureCode::Conflict, format!("The tag {tag} could not be made: {reason}")));
499499 }
500500 self.refs_moved(&repo.id).await;
501− self.publish_push(&repo, &git_ref, None, &commit.hash, Some(&a.actor)).await?;
501+ self.publish_push(&repo, &git_ref, None, &commit.hash, Some(&a.actor), None).await?;
502502 commit.hash
503503 }
504504 };
+1−1
598598 self.record_git(entry).await;
599599 // As any push does: the pull request's head moves, its checks run
600600 // again, and whether it merges cleanly is worked out anew.
601− self.publish_push(&source, &git_ref, Some(&head.hash), &commit_id, Some(&a.actor))
601+ self.publish_push(&source, &git_ref, Some(&head.hash), &commit_id, Some(&a.actor), Some(1))
602602 .await?;
603603 Ok(Outcome::Ok(PullBranchUpdate::Updated {
604604 commit: commit_id,
+1−1
176176 }
177177 entry.result = Some("ok".to_owned());
178178 self.record_git(entry).await;
179− self.publish_push(&repo, &git_ref, None, &commit_id, Some(&a.actor)).await?;
179+ self.publish_push(&repo, &git_ref, None, &commit_id, Some(&a.actor), Some(1)).await?;
180180 Ok(Outcome::Ok(CommittedFile {
181181 branch: a.branch,
182182 commit: commit_id,
+28−15
717717 if repo.mirror.is_some() {
718718 self.publish_mirrored_push(&repo, git_ref, None, head).await?;
719719 } else {
720− self.publish_push(&repo, git_ref, None, head, None).await?;
720+ self.publish_push(&repo, git_ref, None, head, None, None).await?;
721721 }
722722 }
723723 Ok(Outcome::Ok(repo))
15981598 old.as_deref(),
15991599 &new,
16001600 Some(&a.actor),
1601+ None,
16011602 )
16021603 .await?;
16031604 Ok(Outcome::Ok(Landed {
16791680
16801681 /// Reports that `git_ref` of `repo` (a full ref) now points to `after`,
16811682 /// moved by `actor` (marked when that was a workflow job's token).
1683+ /// `commits` is how many the move brought, when the caller knows: one
1684+ /// for a file committed on the site or a branch brought up to date.
16821685 async fn publish_push(
16831686 &self,
16841687 repo: &Repo,
16861689 before: Option<&str>,
16871690 after: &str,
16881691 actor: Option<&User>,
1692+ commits: Option<u32>,
16891693 ) -> Result<()> {
16901694 let caused_by_job = actor.and_then(g1t_contracts::events::job_run_of).map(str::to_owned);
1691− self.publish_git_push(repo, git_ref, before, after, actor.map(|user| user.id.clone()), false, caused_by_job, false)
1695+ self.publish_git_push(repo, git_ref, before, after, actor.map(|user| user.id.clone()), false, caused_by_job, false, commits)
16921696 .await
16931697 }
16941698
16951699 /// A push copied in from the remote a mirror follows (mirror.rs), or
16961700 /// made by filling a new mirror from it.
16971701 async fn publish_mirrored_push(&self, repo: &Repo, git_ref: &str, before: Option<&str>, after: &str) -> Result<()> {
1698− self.publish_git_push(repo, git_ref, before, after, None, false, None, true).await
1702+ self.publish_git_push(repo, git_ref, before, after, None, false, None, true, None).await
16991703 }
17001704
17011705 /// `publish_push`, saying whether the push reached the store without
17111715 unscanned: bool,
17121716 caused_by_job: Option<String>,
17131717 mirrored: bool,
1718+ commits: Option<u32>,
17141719 ) -> Result<()> {
17151720 self.publish(NewEvent {
17161721 kind: "git.push",
17281733 caused_by_job,
17291734 mirrored,
17301735 mirror: repo.mirror.clone(),
1736+ commits,
17311737 },
17321738 })
17331739 .await
22542260 head.first().is_none_or(|commit| commit.hash == pushed.after)
22552261 }),
22562262 };
2257− // The person's contribution calendar (push_commits.rs). A
2258− // failure only leaves the day short.
2259− if moved
2260− && let (Some(user_id), Some(branch)) = (credit.as_deref(), pushed.branch())
2263+ // How many commits a branch push brought (push_commits.rs), for
2264+ // the event and the person's contribution calendar. A log the
2265+ // store cannot read leaves the event without a count and the
2266+ // day short.
2267+ let commits = match (moved, pushed.branch()) {
2268+ (true, Some(_)) => match stored.log(&pushed.after, push_commits::MOST_PER_PUSH + 1).await {
2269+ Ok(log) => Some(push_commits::new_commits(&log, pushed.before.as_deref())),
2270+ Err(error) => {
2271+ worker::console_error!("commits pushed to {} not counted: {error}", repo.name);
2272+ None
2273+ }
2274+ },
2275+ _ => None,
2276+ };
2277+ if let (Some(commits), Some(user_id), Some(branch)) = (commits, credit.as_deref(), pushed.branch())
22612278 && push_commits::counts(branch, &repo.default_branch)
2262− {
2263− let credited = async {
2264− let log = stored.log(&pushed.after, push_commits::MOST_PER_PUSH + 1).await?;
2265− let commits = push_commits::new_commits(&log, pushed.before.as_deref());
2279+ && let Err(error) =
22662280 push_commits::record(&self.registry.db, user_id, &repo.id, &rfc3339(now_ms())[..10], commits).await
2267− };
2268− if let Err(error) = credited.await {
2269− worker::console_error!("commits pushed to {} not credited: {error}", repo.name);
2270− }
2281+ {
2282+ worker::console_error!("commits pushed to {} not credited: {error}", repo.name);
22712283 }
22722284 if moved {
22732285 self.publish_git_push(
22792291 unscanned,
22802292 caused_by_job.clone(),
22812293 false,
2294+ commits,
22822295 )
22832296 .await?;
22842297 }