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

309 lines13,892 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import type { G1tEvent } from "@g1t/contracts";
3import { ArrowRight, CircleCheck, FileCode2, Sparkles } from "lucide-react";
4import { useEffect } from "react";
5import { Form, Link, data, useNavigation, useRevalidator } from "react-router";
6
7import type { Route } from "./+types/plan";
8import { refusal, requireRepo } from "../../lib/access.server";
9import { whyNot } from "../../lib/access";
10import { page } from "../../lib/meta";
11import { Markdown } from "../../components/markdown";
12import { Activity, Exchanges } from "../../components/activity";
13import { Outcome } from "../../components/outcome";
14import { Button, ErrorText, TimeAgo } from "../../components/ui";
15import { Checkbox } from "../../components/ui/checkbox";
16import { Label } from "../../components/work";
17import { billing, events, identity, work } from "../../lib/services.server";
18import {
19 assertSameOrigin,
20 getViewer,
21 requireUser,
22 unwrap,
23} from "../../lib/session.server";
24
25const REFRESH_MS = 4000;
26
27export function meta({ params, ...args }: Route.MetaArgs) {
28 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
29}
30
31export async function loader({ params, context }: Route.LoaderArgs) {
32 const viewer = getViewer(context);
33 // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role.
34 const { access } = await requireRepo(context, params, "read");
35 const path = { namespace: params.owner, name: params.repo };
36 const [plan, ledger] = await Promise.all([
37 work.getPlan(path, viewer, params.id),
38 billing.ledger(params.owner, viewer),
39 ]);
40 const found = unwrap(plan);
41 // What the agents working on this outcome have cost so far.
42 const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])));
43 const repo = `${params.owner}/${params.repo}`;
44 const costMicros = ledger.ok
45 ? ledger.value
46 .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
47 .reduce((sum, entry) => sum - entry.amountMicros, 0)
48 : null;
49 // What happened across the outcome's issues and pull requests.
50 let activity: G1tEvent[] = [];
51 if (found.status === "applied" && found.progress.length > 0) {
52 const numbers = new Set([
53 ...found.progress.map((item) => item.number),
54 ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])),
55 ]);
56 const since = found.finishedAt ?? found.createdAt;
57 const recent = await events.list({ repoId: found.repoId, limit: 200 }).catch(() => []);
58 activity = recent
59 .filter((event) => event.time >= since)
60 .filter((event) => {
61 const data = event.data as { number?: number; issue?: number };
62 // Issues an agent filed while working on this outcome belong to it too.
63 if (event.type === "issue.opened" && event.actor === "usr_g1t_agent") return true;
64 return (data.number != null && numbers.has(data.number)) || (data.issue != null && numbers.has(data.issue));
65 })
66 .slice(0, 40);
67 // Events name accounts by id; show names.
68 const named = await identity
69 .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))])
70 .catch(() => ({}) as Record<string, string>);
71 const known: Record<string, string> = { ...named, usr_g1t_agent: "g1t", g1t_policy: "g1t" };
72 activity = activity.map((event) => ({ ...event, actor: event.actor ? (known[event.actor] ?? event.actor) : null }));
73 }
74 return { plan: found, costMicros, activity, can: access.can };
75}
76
77export async function action({ request, params, context }: Route.ActionArgs) {
78 assertSameOrigin(request);
79 const user = requireUser(context, request);
80 const form = await request.formData();
81 const path = { namespace: params.owner, name: params.repo };
82 // Opening the issues and putting agents on them: Write and up.
83 const refused = await refusal(context, params, "run");
84 if (refused) return { error: refused };
85 const applied = await env.RUNNER.applyPlan(user, path, params.id, {
86 assign: form.get("action") === "assign",
87 keep: form.getAll("keep").map(Number),
88 });
89 return applied.ok ? null : { error: applied.error.message };
90}
91
92export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
93 const { plan, costMicros, activity } = loaderData;
94 const base = `/${params.owner}/${params.repo}`;
95 const applying = useNavigation().state === "submitting";
96
97 // The agent is still writing it.
98 const revalidator = useRevalidator();
99 const planning = plan.status === "planning";
100 // Planning, or agents still converging what was applied.
101 const moving =
102 planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
103 useEffect(() => {
104 if (!moving) return;
105 const timer = setInterval(() => {
106 if (document.visibilityState === "visible") revalidator.revalidate();
107 }, REFRESH_MS);
108 return () => clearInterval(timer);
109 }, [moving, revalidator]);
110 const converging = plan.status === "applied" && plan.progress.length > 0;
111
112 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
113 return (
114 <div className={converging ? "" : "max-w-4xl"}>
115 <p className="text-sm">
116 <Link to={`${base}/plans`} className="text-muted hover:text-fg">
117 Plans
118 </Link>
119 </p>
120 <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2>
121 <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm">
122 <Markdown source={plan.brief} repo={{ namespace: params.owner, name: params.repo }} />
123 </div>
124 <p className="mt-2 text-xs text-faint">
125 Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} />
126 </p>
127
128 {converging && (
129 <section className="mt-8">
130 <Outcome plan={plan} base={base} costMicros={costMicros} />
131 {plan.exchanges.length > 0 && (
132 <div className="mt-10">
133 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">Agents talking</h3>
134 <div className="mt-3 max-w-3xl">
135 <Exchanges exchanges={plan.exchanges} base={base} />
136 </div>
137 </div>
138 )}
139 {activity.length > 0 && (
140 <div className="mt-10">
141 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">What happened</h3>
142 <div className="mt-3 max-w-3xl">
143 <Activity events={activity} base={base} />
144 </div>
145 </div>
146 )}
147 </section>
148 )}
149
150 {planning && (
151 <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm">
152 <span className="size-2 animate-pulse rounded-full bg-accent" />
153 <span>
154 An agent is reading the repository and writing the plan. This takes a
155 minute or two; the page fills in when it is done.
156 </span>
157 </div>
158 )}
159 {plan.status === "failed" && (
160 <div className="mt-8 rounded-xl border border-danger/40 bg-danger/5 px-4 py-4 text-sm">
161 <p className="font-medium">The plan could not be written</p>
162 <p className="mt-1 text-muted">{plan.error}</p>
163 <p className="mt-3">
164 <Link to={`${base}/plans`} className="text-fg underline underline-offset-4">
165 Try again
166 </Link>
167 </p>
168 </div>
169 )}
170
171 {plan.issues.length > 0 && (
172 <Form method="post" className="mt-8">
173 <div className="flex flex-wrap items-baseline justify-between gap-2">
174 <h2 className="text-xl font-semibold tracking-tight">
175 {plan.status === "applied" ? "What was opened" : "The plan"}
176 </h2>
177 <p className="text-sm text-muted">
178 {plan.issues.length} {plan.issues.length === 1 ? "issue" : "issues"},{" "}
179 {independent} that can start at once
180 </p>
181 </div>
182 {plan.summary && <p className="mt-2 text-sm text-muted">{plan.summary}</p>}
183
184 <ol className="mt-5 space-y-3">
185 {plan.issues.map((issue, index) => {
186 const position = index + 1;
187 return (
188 <li
189 key={position}
190 className="flex gap-3 rounded-xl border border-line bg-surface p-4"
191 >
192 {plan.status === "ready" ? (
193 <Checkbox
194 name="keep"
195 value={String(position)}
196 defaultChecked
197 aria-label={`Open issue ${position}`}
198 className="mt-1"
199 />
200 ) : (
201 <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint">
202 {position}
203 </span>
204 )}
205 <div className="min-w-0 grow">
206 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
207 {issue.number != null ? (
208 <Link
209 to={`${base}/issues/${issue.number}`}
210 className="font-medium hover:underline"
211 >
212 {issue.title}{" "}
213 <span className="font-normal text-faint">#{issue.number}</span>
214 </Link>
215 ) : (
216 <span className="font-medium">
217 <span className="mr-1.5 font-mono text-sm font-normal text-faint">
218 {position}.
219 </span>
220 {issue.title}
221 </span>
222 )}
223 {issue.labels.map((name) => (
224 <Label key={name} name={name} />
225 ))}
226 </div>
227 <p className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
228 {issue.dependsOn.length === 0 ? (
229 <span className="text-accent">Starts at once</span>
230 ) : (
231 <span className="flex items-center gap-1">
232 <ArrowRight size={12} />
233 After{" "}
234 {issue.dependsOn
235 .map((earlier) => {
236 const number = plan.issues[earlier - 1]?.number;
237 return number != null ? `#${number}` : `${earlier}`;
238 })
239 .join(", ")}
240 </span>
241 )}
242 {(issue.done ?? []).map((point) => (
243 <span key={point} className="flex items-center gap-1">
244 <CircleCheck size={12} />
245 {point}
246 </span>
247 ))}
248 </p>
249 {issue.files.length > 0 && (
250 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs text-faint">
251 <FileCode2 size={12} />
252 {issue.files.map((file) => (
253 <span key={file}>{file}</span>
254 ))}
255 </p>
256 )}
257 <details className="mt-2">
258 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
259 What the agent will be told
260 </summary>
261 <div className="mt-2 border-t border-line pt-3">
262 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
263 </div>
264 </details>
265 </div>
266 </li>
267 );
268 })}
269 </ol>
270
271 {plan.status === "ready" && (
272 <div className="mt-6 flex flex-wrap items-center gap-3">
273 <Button variant="accent" type="submit" name="action" value="assign" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
274 <Sparkles size={15} />
275 {applying ? "Opening issues…" : "Open these and assign g1t"}
276 </Button>
277 <Button variant="quiet" type="submit" name="action" value="open" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
278 Only open the issues
279 </Button>
280 <span className="text-xs text-muted">
281 Untick any you do not want. Agents work on the independent ones at once
282 and the rest follow as what they depend on merges.
283 </span>
284 </div>
285 )}
286 {plan.status === "applied" && !moving && (
287 <p className="mt-6 text-sm text-muted">Everything in this plan has landed.</p>
288 )}
289 {plan.status === "applied" && moving && (
290 <p className="mt-6 text-sm text-muted">
291 Some are still open. Follow them under{" "}
292 <Link to={`${base}/issues`} className="text-fg underline underline-offset-4">
293 Issues
294 </Link>{" "}
295 or in{" "}
296 <Link to="/" className="text-fg underline underline-offset-4">
297 mission control
298 </Link>
299 .
300 </p>
301 )}
302 <div className="mt-3">
303 <ErrorText>{actionData?.error}</ErrorText>
304 </div>
305 </Form>
306 )}
307 </div>
308 );
309}