Commit

What happened across an outcome, as a feed beside its graph

The outcome page lists every step across the plan's issues and pull requests: agents starting, checks, reviews, landings, and agents filing issues or commenting on each other's work, which stand out. Identity can now name the accounts behind the ids events record. The graph's dependency lines draw again.

syntaqxcommitted Parent2a402f8Browse files
11 files+219−80/11 viewed
+135−0
1+import {
2+ CircleCheck,
3+ CircleDot,
4+ CircleSlash,
5+ GitMerge,
6+ GitPullRequest,
7+ MessageSquare,
8+ Play,
9+ Terminal,
10+} from "lucide-react";
11+import type { ReactNode } from "react";
12+import { Link } from "react-router";
13+
14+import type { G1tEvent } from "@g1t/contracts";
15+
16+import { Avatar, TimeAgo } from "./ui";
17+
18+/** The accounts that are g1t's own agents and machinery. */
19+const AGENTS = new Set(["g1t-agent", "g1t"]);
20+
21+type Line = { icon: ReactNode; tone: string; actor: string | null; text: ReactNode; coordination?: boolean };
22+
23+/** One event as a sentence, or null for those not worth a line. */
24+function line(event: G1tEvent, base: string): Line | null {
25+ const ref = (number: number) => (
26+ <Link to={`${base}/issues/${number}`} prefetch="intent" className="font-medium text-fg hover:underline">
27+ #{number}
28+ </Link>
29+ );
30+ const actor = event.actor;
31+ switch (event.type) {
32+ case "issue.opened":
33+ return {
34+ icon: <CircleDot size={14} />,
35+ tone: actor && AGENTS.has(actor) ? "text-merged" : "text-muted",
36+ actor,
37+ text: actor && AGENTS.has(actor) ? <>filed {ref(event.data.number)} for something it found</> : <>opened {ref(event.data.number)}</>,
38+ coordination: actor != null && AGENTS.has(actor),
39+ };
40+ case "pull.opened":
41+ return {
42+ icon: <Play size={14} />,
43+ tone: "text-merged",
44+ actor: event.data.agent,
45+ text: (
46+ <>
47+ started on {event.data.issue != null ? ref(event.data.issue) : "a change"} in {ref(event.data.number)}
48+ </>
49+ ),
50+ };
51+ case "pull.ready":
52+ return { icon: <GitPullRequest size={14} />, tone: "text-info", actor, text: <>marked {ref(event.data.number)} ready for review</> };
53+ case "checks.completed":
54+ return {
55+ icon: <Terminal size={14} />,
56+ tone: event.data.status === "passed" ? "text-accent" : "text-danger",
57+ actor: null,
58+ text: (
59+ <>
60+ checks {event.data.status} on {ref(event.data.number)}
61+ </>
62+ ),
63+ };
64+ case "review.completed":
65+ return {
66+ icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />,
67+ tone: event.data.verdict === "approve" ? "text-accent" : "text-warn",
68+ actor: "g1t-agent",
69+ text: (
70+ <>
71+ {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)}
72+ </>
73+ ),
74+ };
75+ case "comment.created":
76+ if (event.data.verdict) {
77+ return {
78+ icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />,
79+ tone: event.data.verdict === "approve" ? "text-accent" : "text-warn",
80+ actor,
81+ text: (
82+ <>
83+ {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)}
84+ </>
85+ ),
86+ };
87+ }
88+ return {
89+ icon: <MessageSquare size={14} />,
90+ tone: actor && AGENTS.has(actor) ? "text-merged" : "text-muted",
91+ actor,
92+ text: <>commented on {ref(event.data.number)}</>,
93+ coordination: actor != null && AGENTS.has(actor),
94+ };
95+ case "pull.merged":
96+ return { icon: <GitMerge size={14} />, tone: "text-accent", actor, text: <>landed {ref(event.data.number)} on main</> };
97+ case "pull.merge_requested":
98+ return { icon: <GitMerge size={14} />, tone: "text-muted", actor, text: <>is bringing {ref(event.data.number)} up to date</> };
99+ default:
100+ return null;
101+ }
102+}
103+
104+/**
105+ * What happened across an outcome's issues and pull requests, newest
106+ * first. Agents talking to each other through the forge stand out.
107+ */
108+export function Activity({ events, base }: { events: G1tEvent[]; base: string }) {
109+ const lines = events.map((event) => ({ event, line: line(event, base) })).filter((item) => item.line != null);
110+ if (lines.length === 0) return null;
111+ return (
112+ <ol className="space-y-0.5">
113+ {lines.map(({ event, line }) => (
114+ <li
115+ key={event.id}
116+ className={`flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm ${
117+ line!.coordination ? "bg-merged/[0.07] ring-1 ring-merged/25" : ""
118+ }`}
119+ >
120+ <span className={`shrink-0 ${line!.tone}`}>{line!.icon}</span>
121+ {line!.actor && (
122+ <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-fg-soft">
123+ <Avatar name={line!.actor} size={14} />
124+ {line!.actor}
125+ </span>
126+ )}
127+ <span className="min-w-0 grow truncate text-muted">{line!.text}</span>
128+ <span className="shrink-0 text-xs text-faint">
129+ <TimeAgo at={event.time} />
130+ </span>
131+ </li>
132+ ))}
133+ </ol>
134+ );
135+}
+4−2
1010 Terminal,
1111 X,
1212 } from "lucide-react";
13−import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
13+import { type ReactNode, useEffect, useRef, useState } from "react";
1414 import { Link } from "react-router";
1515
1616 import type { IssueProgress, Plan } from "@g1t/contracts";
150150 /** Lines from each issue to the ones that depend on it, measured from the page. */
151151 function Edges({ plan, container }: { plan: Plan; container: React.RefObject<HTMLDivElement | null> }) {
152152 const [paths, setPaths] = useState<{ d: string; done: boolean }[]>([]);
153− useLayoutEffect(() => {
153+ // After commit, when the graph's own ref is attached: a child's layout
154+ // effect runs before its parent's ref is set.
155+ useEffect(() => {
154156 const element = container.current;
155157 if (!element) return;
156158 const draw = () => {
+2−1
11 import { env } from "cloudflare:workers";
22
3−import { billingClient, identityClient, reposClient, workClient } from "@g1t/contracts";
3+import { billingClient, eventsClient, identityClient, reposClient, workClient } from "@g1t/contracts";
44
55 export const identity = identityClient(env.IDENTITY);
66 export const repos = reposClient(env.REPOS);
77 export const work = workClient(env.WORK);
88 export const billing = billingClient(env.BILLING);
9+export const events = eventsClient(env.EVENTS);
+38−3
11 import { env } from "cloudflare:workers";
2+import type { G1tEvent } from "@g1t/contracts";
23 import { ArrowRight, FileCode2, Sparkles, Terminal } from "lucide-react";
34 import { useEffect } from "react";
45 import { Form, Link, data, useNavigation, useRevalidator } from "react-router";
56
67 import type { Route } from "./+types/plan";
78 import { Markdown } from "../../components/markdown";
9+import { Activity } from "../../components/activity";
810 import { Outcome } from "../../components/outcome";
911 import { Button, ErrorText, TimeAgo } from "../../components/ui";
1012 import { Label } from "../../components/work";
11−import { billing, work } from "../../lib/services.server";
13+import { billing, events, identity, work } from "../../lib/services.server";
1214 import {
1315 assertSameOrigin,
1416 getViewer,
4143 .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
4244 .reduce((sum, entry) => sum - entry.amountMicros, 0)
4345 : null;
44− return { plan: found, costMicros };
46+ // What happened across the outcome's issues and pull requests.
47+ let activity: G1tEvent[] = [];
48+ if (found.status === "applied" && found.progress.length > 0) {
49+ const numbers = new Set([
50+ ...found.progress.map((item) => item.number),
51+ ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])),
52+ ]);
53+ const since = found.finishedAt ?? found.createdAt;
54+ const recent = await events.list({ repoId: found.repoId, limit: 200 }).catch(() => []);
55+ activity = recent
56+ .filter((event) => event.time >= since)
57+ .filter((event) => {
58+ const data = event.data as { number?: number; issue?: number };
59+ // Issues an agent filed while working on this outcome belong to it too.
60+ if (event.type === "issue.opened" && event.actor === "usr_g1t_agent") return true;
61+ return (data.number != null && numbers.has(data.number)) || (data.issue != null && numbers.has(data.issue));
62+ })
63+ .slice(0, 40);
64+ // Events name accounts by id; show names.
65+ const named = await identity
66+ .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))])
67+ .catch(() => ({}) as Record<string, string>);
68+ const known: Record<string, string> = { ...named, usr_g1t_agent: "g1t-agent", g1t_policy: "g1t" };
69+ activity = activity.map((event) => ({ ...event, actor: event.actor ? (known[event.actor] ?? event.actor) : null }));
70+ }
71+ return { plan: found, costMicros, activity };
4572 }
4673
4774 export async function action({ request, params, context }: Route.ActionArgs) {
5784 }
5885
5986 export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
60− const { plan, costMicros } = loaderData;
87+ const { plan, costMicros, activity } = loaderData;
6188 const base = `/${params.owner}/${params.repo}`;
6289 const applying = useNavigation().state === "submitting";
6390
95122 {converging && (
96123 <section className="mt-8">
97124 <Outcome plan={plan} base={base} costMicros={costMicros} />
125+ {activity.length > 0 && (
126+ <div className="mt-10">
127+ <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">What happened</h3>
128+ <div className="mt-3 max-w-3xl">
129+ <Activity events={activity} base={base} />
130+ </div>
131+ </div>
132+ )}
98133 </section>
99134 )}
100135
+1−0
99 WORK: ServiceBinding;
1010 RUNNER: RunnerApi;
1111 BILLING: ServiceBinding;
12+ EVENTS: ServiceBinding;
1213 }
1314 }
1415 interface Env extends Cloudflare.Env {}
+2−1
1414 { "binding": "REPOS", "service": "g1t-repos" },
1515 { "binding": "WORK", "service": "g1t-work" },
1616 { "binding": "RUNNER", "service": "g1t-runner" },
17− { "binding": "BILLING", "service": "g1t-billing" }
17+ { "binding": "BILLING", "service": "g1t-billing" },
18+ { "binding": "EVENTS", "service": "g1t-events" }
1819 ],
1920 "observability": { "enabled": true },
2021 "upload_source_maps": true
+8−0
7878 pub username: String,
7979 }
8080
81+/// `usernames`: the names behind account and workspace ids, as events and
82+/// other records store them. Returns a map from id to name; ids it does
83+/// not know are left out.
84+#[derive(Debug, Serialize, Deserialize)]
85+pub struct UsernamesArgs {
86+ pub ids: Vec<String>,
87+}
88+
8189 /// `list_ssh_keys` and `list_access_tokens`.
8290 #[derive(Debug, Serialize, Deserialize)]
8391 pub struct UserArgs {
+1−0
7373 userForAccessToken: (token) => call("user_for_access_token", { token }),
7474 userForSshKey: (fingerprint) => call("user_for_ssh_key", { fingerprint }),
7575 userByUsername: (username) => call("user_by_username", { username }),
76+ usernames: (ids) => call("usernames", { ids }),
7677 listSshKeys: (user) => call("list_ssh_keys", { user }),
7778 addSshKey: (user, title, publicKey) =>
7879 call("add_ssh_key", { user, title, publicKey }),
+1−1
5353 number: number;
5454 verdict?: "approve" | "request_changes";
5555 };
56− /** `number` is the issue or pull request commented on. */
5756 /** A repository's merge queue gained, lost or settled an entry. */
5857 "queue.changed": { repoId: string };
58+ /** `number` is the issue or pull request commented on. */
5959 "comment.created": {
6060 commentId: string;
6161 repoId: string;
+2−0
186186 userForAccessToken(token: string): Promise<Viewer>;
187187 userForSshKey(fingerprint: string): Promise<Viewer>;
188188 userByUsername(username: string): Promise<Viewer>;
189+ /** The names behind account and workspace ids; unknown ids are left out. */
190+ usernames(ids: string[]): Promise<Record<string, string>>;
189191
190192 listSshKeys(user: User): Promise<SshKey[]>;
191193 /** Takes one line in OpenSSH public key format. */
+25−0
424424 .await
425425 }
426426
427+ async fn usernames(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
428+ #[derive(serde::Deserialize)]
429+ struct Named {
430+ id: String,
431+ name: String,
432+ }
433+ let ids: Vec<String> = a.ids.into_iter().take(200).collect();
434+ let mut names = std::collections::HashMap::new();
435+ if ids.is_empty() {
436+ return Ok(names);
437+ }
438+ let marks = vec!["?"; ids.len()].join(", ");
439+ let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
440+ for sql in [
441+ format!("SELECT id, username AS name FROM users WHERE id IN ({marks})"),
442+ format!("SELECT id, slug AS name FROM workspaces WHERE id IN ({marks})"),
443+ ] {
444+ for row in self.db.prepare(sql).bind(&bind)?.all().await?.results::<Named>()? {
445+ names.insert(row.id, row.name);
446+ }
447+ }
448+ Ok(names)
449+ }
450+
427451 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
428452 let rows = self
429453 .db
540564 }
541565 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
542566 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
567+ "usernames" => reply(&identity.usernames(args(body)?).await?),
543568 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
544569 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
545570 "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?),