g1t/apps/web/app/routes/repo/plan.tsx

293 lines12,791 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents as a team: lifecycle, merge queue, billing and a new shell1import { env } from "cloudflare:workers";
What happened across an outcome, as a feed beside its graph2import type { G1tEvent } from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell3import { ArrowRight, FileCode2, Sparkles, Terminal } from "lucide-react";
4import { useEffect } from "react";
5import { Form, Link, data, useNavigation, useRevalidator } from "react-router";
6
7import type { Route } from "./+types/plan";
8import { Markdown } from "../../components/markdown";
What happened across an outcome, as a feed beside its graph9import { Activity } from "../../components/activity";
Dark gray base with lavender as an accent, and a live outcome view for plans10import { Outcome } from "../../components/outcome";
Agents as a team: lifecycle, merge queue, billing and a new shell11import { Button, ErrorText, TimeAgo } from "../../components/ui";
12import { Label } from "../../components/work";
What happened across an outcome, as a feed beside its graph13import { billing, events, identity, work } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell14import {
15 assertSameOrigin,
16 getViewer,
17 requireUser,
18 roleIn,
19 unwrap,
20} from "../../lib/session.server";
21
22const REFRESH_MS = 4000;
23
24export function meta({ params }: Route.MetaArgs) {
25 return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }];
26}
27
28export async function loader({ params, context }: Route.LoaderArgs) {
29 const viewer = getViewer(context);
30 // Members only; to anyone else the page does not exist.
31 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
32 const path = { namespace: params.owner, name: params.repo };
Dark gray base with lavender as an accent, and a live outcome view for plans33 const [plan, ledger] = await Promise.all([
34 work.getPlan(path, viewer, params.id),
35 billing.ledger(params.owner, viewer),
36 ]);
37 const found = unwrap(plan);
38 // What the agents working on this outcome have cost so far.
39 const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])));
40 const repo = `${params.owner}/${params.repo}`;
41 const costMicros = ledger.ok
42 ? ledger.value
43 .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
44 .reduce((sum, entry) => sum - entry.amountMicros, 0)
45 : null;
What happened across an outcome, as a feed beside its graph46 // 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 };
Agents as a team: lifecycle, merge queue, billing and a new shell72}
73
74export async function action({ request, params, context }: Route.ActionArgs) {
75 assertSameOrigin(request);
76 const user = requireUser(context, request);
77 const form = await request.formData();
78 const path = { namespace: params.owner, name: params.repo };
79 const applied = await env.RUNNER.applyPlan(user, path, params.id, {
80 assign: form.get("action") === "assign",
81 keep: form.getAll("keep").map(Number),
82 });
83 return applied.ok ? null : { error: applied.error.message };
84}
85
86export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
What happened across an outcome, as a feed beside its graph87 const { plan, costMicros, activity } = loaderData;
Agents as a team: lifecycle, merge queue, billing and a new shell88 const base = `/${params.owner}/${params.repo}`;
89 const applying = useNavigation().state === "submitting";
90
91 // The agent is still writing it.
92 const revalidator = useRevalidator();
93 const planning = plan.status === "planning";
Dark gray base with lavender as an accent, and a live outcome view for plans94 // Planning, or agents still converging what was applied.
95 const moving =
96 planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
Agents as a team: lifecycle, merge queue, billing and a new shell97 useEffect(() => {
Dark gray base with lavender as an accent, and a live outcome view for plans98 if (!moving) return;
Agents as a team: lifecycle, merge queue, billing and a new shell99 const timer = setInterval(() => {
100 if (document.visibilityState === "visible") revalidator.revalidate();
101 }, REFRESH_MS);
102 return () => clearInterval(timer);
Dark gray base with lavender as an accent, and a live outcome view for plans103 }, [moving, revalidator]);
104 const converging = plan.status === "applied" && plan.progress.length > 0;
Agents as a team: lifecycle, merge queue, billing and a new shell105
106 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
107 return (
Dark gray base with lavender as an accent, and a live outcome view for plans108 <div className={converging ? "" : "max-w-4xl"}>
Agents as a team: lifecycle, merge queue, billing and a new shell109 <p className="text-sm">
110 <Link to={`${base}/plans`} className="text-muted hover:text-fg">
111 Plans
112 </Link>
113 </p>
114 <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2>
115 <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm whitespace-pre-wrap">
116 {plan.brief}
117 </div>
118 <p className="mt-2 text-xs text-faint">
119 Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} />
120 </p>
121
Dark gray base with lavender as an accent, and a live outcome view for plans122 {converging && (
123 <section className="mt-8">
124 <Outcome plan={plan} base={base} costMicros={costMicros} />
What happened across an outcome, as a feed beside its graph125 {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 )}
Dark gray base with lavender as an accent, and a live outcome view for plans133 </section>
134 )}
135
Agents as a team: lifecycle, merge queue, billing and a new shell136 {planning && (
137 <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm">
138 <span className="size-2 animate-pulse rounded-full bg-accent" />
139 <span>
140 An agent is reading the repository and writing the plan. This takes a
141 minute or two; the page fills in when it is done.
142 </span>
143 </div>
144 )}
145 {plan.status === "failed" && (
146 <div className="mt-8 rounded-xl border border-danger/40 bg-danger/5 px-4 py-4 text-sm">
147 <p className="font-medium">The plan could not be written</p>
148 <p className="mt-1 text-muted">{plan.error}</p>
149 <p className="mt-3">
150 <Link to={`${base}/plans`} className="text-fg underline underline-offset-4">
151 Try again
152 </Link>
153 </p>
154 </div>
155 )}
156
157 {plan.issues.length > 0 && (
158 <Form method="post" className="mt-8">
159 <div className="flex flex-wrap items-baseline justify-between gap-2">
160 <h2 className="text-xl font-semibold tracking-tight">
161 {plan.status === "applied" ? "What was opened" : "The plan"}
162 </h2>
163 <p className="text-sm text-muted">
164 {plan.issues.length} {plan.issues.length === 1 ? "issue" : "issues"},{" "}
165 {independent} that can start at once
166 </p>
167 </div>
168 {plan.summary && <p className="mt-2 text-sm text-muted">{plan.summary}</p>}
169
170 <ol className="mt-5 space-y-3">
171 {plan.issues.map((issue, index) => {
172 const position = index + 1;
173 return (
174 <li
175 key={position}
176 className="flex gap-3 rounded-xl border border-line bg-surface p-4"
177 >
178 {plan.status === "ready" ? (
179 <input
180 type="checkbox"
181 name="keep"
182 value={position}
183 defaultChecked
184 aria-label={`Open issue ${position}`}
185 className="mt-1.5 accent-accent"
186 />
187 ) : (
188 <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint">
189 {position}
190 </span>
191 )}
192 <div className="min-w-0 grow">
193 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
194 {issue.number != null ? (
195 <Link
196 to={`${base}/issues/${issue.number}`}
197 className="font-medium hover:underline"
198 >
199 {issue.title}{" "}
200 <span className="font-normal text-faint">#{issue.number}</span>
201 </Link>
202 ) : (
203 <span className="font-medium">
204 <span className="mr-1.5 font-mono text-sm font-normal text-faint">
205 {position}.
206 </span>
207 {issue.title}
208 </span>
209 )}
210 {issue.labels.map((name) => (
211 <Label key={name} name={name} />
212 ))}
213 </div>
214 <p className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
215 {issue.dependsOn.length === 0 ? (
216 <span className="text-accent">Starts at once</span>
217 ) : (
218 <span className="flex items-center gap-1">
219 <ArrowRight size={12} />
220 After{" "}
221 {issue.dependsOn
222 .map((earlier) => {
223 const number = plan.issues[earlier - 1]?.number;
224 return number != null ? `#${number}` : `${earlier}`;
225 })
226 .join(", ")}
227 </span>
228 )}
229 {issue.checks.map((check) => (
230 <span key={check} className="flex items-center gap-1 font-mono">
231 <Terminal size={12} />
232 {check}
233 </span>
234 ))}
235 </p>
236 {issue.files.length > 0 && (
237 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs text-faint">
238 <FileCode2 size={12} />
239 {issue.files.map((file) => (
240 <span key={file}>{file}</span>
241 ))}
242 </p>
243 )}
244 <details className="mt-2">
245 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
246 What the agent will be told
247 </summary>
248 <div className="mt-2 border-t border-line pt-3">
249 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
250 </div>
251 </details>
252 </div>
253 </li>
254 );
255 })}
256 </ol>
257
258 {plan.status === "ready" && (
259 <div className="mt-6 flex flex-wrap items-center gap-3">
260 <Button variant="accent" type="submit" name="action" value="assign" disabled={applying}>
261 <Sparkles size={15} />
262 {applying ? "Opening issues…" : "Open these and assign g1t agents"}
263 </Button>
264 <Button variant="quiet" type="submit" name="action" value="open" disabled={applying}>
265 Only open the issues
266 </Button>
267 <span className="text-xs text-muted">
268 Untick any you do not want. Agents work on the independent ones at once
269 and the rest follow as what they depend on merges.
270 </span>
271 </div>
272 )}
273 {plan.status === "applied" && (
274 <p className="mt-6 text-sm text-muted">
275 These are open. Follow them under{" "}
276 <Link to={`${base}/issues`} className="text-fg underline underline-offset-4">
277 Issues
278 </Link>{" "}
279 or in{" "}
280 <Link to="/" className="text-fg underline underline-offset-4">
281 mission control
282 </Link>
283 .
284 </p>
285 )}
286 <div className="mt-3">
287 <ErrorText>{actionData?.error}</ErrorText>
288 </div>
289 </Form>
290 )}
291 </div>
292 );
293}