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.
11 files+219−80/11 viewed
| 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 | + | } |
| 10 | 10 | Terminal, | |
| 11 | 11 | X, | |
| 12 | 12 | } from "lucide-react"; | |
| 13 | − | import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; | |
| 13 | + | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 14 | 14 | import { Link } from "react-router"; | |
| 15 | 15 | ||
| 16 | 16 | import type { IssueProgress, Plan } from "@g1t/contracts"; | |
| 150 | 150 | /** Lines from each issue to the ones that depend on it, measured from the page. */ | |
| 151 | 151 | function Edges({ plan, container }: { plan: Plan; container: React.RefObject<HTMLDivElement | null> }) { | |
| 152 | 152 | 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(() => { | |
| 154 | 156 | const element = container.current; | |
| 155 | 157 | if (!element) return; | |
| 156 | 158 | const draw = () => { |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | − | import { billingClient, identityClient, reposClient, workClient } from "@g1t/contracts"; | |
| 3 | + | import { billingClient, eventsClient, identityClient, reposClient, workClient } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | export const identity = identityClient(env.IDENTITY); | |
| 6 | 6 | export const repos = reposClient(env.REPOS); | |
| 7 | 7 | export const work = workClient(env.WORK); | |
| 8 | 8 | export const billing = billingClient(env.BILLING); | |
| 9 | + | export const events = eventsClient(env.EVENTS); |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | + | import type { G1tEvent } from "@g1t/contracts"; | |
| 2 | 3 | import { ArrowRight, FileCode2, Sparkles, Terminal } from "lucide-react"; | |
| 3 | 4 | import { useEffect } from "react"; | |
| 4 | 5 | import { Form, Link, data, useNavigation, useRevalidator } from "react-router"; | |
| 5 | 6 | ||
| 6 | 7 | import type { Route } from "./+types/plan"; | |
| 7 | 8 | import { Markdown } from "../../components/markdown"; | |
| 9 | + | import { Activity } from "../../components/activity"; | |
| 8 | 10 | import { Outcome } from "../../components/outcome"; | |
| 9 | 11 | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 10 | 12 | import { Label } from "../../components/work"; | |
| 11 | − | import { billing, work } from "../../lib/services.server"; | |
| 13 | + | import { billing, events, identity, work } from "../../lib/services.server"; | |
| 12 | 14 | import { | |
| 13 | 15 | assertSameOrigin, | |
| 14 | 16 | getViewer, | |
| 41 | 43 | .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number)) | |
| 42 | 44 | .reduce((sum, entry) => sum - entry.amountMicros, 0) | |
| 43 | 45 | : 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 }; | |
| 45 | 72 | } | |
| 46 | 73 | ||
| 47 | 74 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 57 | 84 | } | |
| 58 | 85 | ||
| 59 | 86 | export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 60 | − | const { plan, costMicros } = loaderData; | |
| 87 | + | const { plan, costMicros, activity } = loaderData; | |
| 61 | 88 | const base = `/${params.owner}/${params.repo}`; | |
| 62 | 89 | const applying = useNavigation().state === "submitting"; | |
| 63 | 90 | ||
| 95 | 122 | {converging && ( | |
| 96 | 123 | <section className="mt-8"> | |
| 97 | 124 | <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 | + | )} | |
| 98 | 133 | </section> | |
| 99 | 134 | )} | |
| 100 | 135 |
| 9 | 9 | WORK: ServiceBinding; | |
| 10 | 10 | RUNNER: RunnerApi; | |
| 11 | 11 | BILLING: ServiceBinding; | |
| 12 | + | EVENTS: ServiceBinding; | |
| 12 | 13 | } | |
| 13 | 14 | } | |
| 14 | 15 | interface Env extends Cloudflare.Env {} |
| 14 | 14 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 15 | 15 | { "binding": "WORK", "service": "g1t-work" }, | |
| 16 | 16 | { "binding": "RUNNER", "service": "g1t-runner" }, | |
| 17 | − | { "binding": "BILLING", "service": "g1t-billing" } | |
| 17 | + | { "binding": "BILLING", "service": "g1t-billing" }, | |
| 18 | + | { "binding": "EVENTS", "service": "g1t-events" } | |
| 18 | 19 | ], | |
| 19 | 20 | "observability": { "enabled": true }, | |
| 20 | 21 | "upload_source_maps": true |
| 78 | 78 | pub username: String, | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 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 | + | ||
| 81 | 89 | /// `list_ssh_keys` and `list_access_tokens`. | |
| 82 | 90 | #[derive(Debug, Serialize, Deserialize)] | |
| 83 | 91 | pub struct UserArgs { |
| 73 | 73 | userForAccessToken: (token) => call("user_for_access_token", { token }), | |
| 74 | 74 | userForSshKey: (fingerprint) => call("user_for_ssh_key", { fingerprint }), | |
| 75 | 75 | userByUsername: (username) => call("user_by_username", { username }), | |
| 76 | + | usernames: (ids) => call("usernames", { ids }), | |
| 76 | 77 | listSshKeys: (user) => call("list_ssh_keys", { user }), | |
| 77 | 78 | addSshKey: (user, title, publicKey) => | |
| 78 | 79 | call("add_ssh_key", { user, title, publicKey }), |
| 53 | 53 | number: number; | |
| 54 | 54 | verdict?: "approve" | "request_changes"; | |
| 55 | 55 | }; | |
| 56 | − | /** `number` is the issue or pull request commented on. */ | |
| 57 | 56 | /** A repository's merge queue gained, lost or settled an entry. */ | |
| 58 | 57 | "queue.changed": { repoId: string }; | |
| 58 | + | /** `number` is the issue or pull request commented on. */ | |
| 59 | 59 | "comment.created": { | |
| 60 | 60 | commentId: string; | |
| 61 | 61 | repoId: string; |
| 186 | 186 | userForAccessToken(token: string): Promise<Viewer>; | |
| 187 | 187 | userForSshKey(fingerprint: string): Promise<Viewer>; | |
| 188 | 188 | 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>>; | |
| 189 | 191 | ||
| 190 | 192 | listSshKeys(user: User): Promise<SshKey[]>; | |
| 191 | 193 | /** Takes one line in OpenSSH public key format. */ |
| 424 | 424 | .await | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 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 | + | ||
| 427 | 451 | async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> { | |
| 428 | 452 | let rows = self | |
| 429 | 453 | .db | |
| 540 | 564 | } | |
| 541 | 565 | "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?), | |
| 542 | 566 | "user_by_username" => reply(&identity.user_by_username(args(body)?).await?), | |
| 567 | + | "usernames" => reply(&identity.usernames(args(body)?).await?), | |
| 543 | 568 | "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?), | |
| 544 | 569 | "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?), | |
| 545 | 570 | "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?), |