flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

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

304 lines13,390 bytesCodeBlame
import { env } from "cloudflare:workers";
import type { G1tEvent } from "@g1t/contracts";
import { ArrowRight, FileCode2, Sparkles, Terminal } from "lucide-react";
import { useEffect } from "react";
import { Form, Link, data, useNavigation, useRevalidator } from "react-router";

import type { Route } from "./+types/plan";
import { Markdown } from "../../components/markdown";
import { Activity, Exchanges } from "../../components/activity";
import { Outcome } from "../../components/outcome";
import { Button, ErrorText, TimeAgo } from "../../components/ui";
import { Label } from "../../components/work";
import { billing, events, identity, work } from "../../lib/services.server";
import {
  assertSameOrigin,
  getViewer,
  requireUser,
  roleIn,
  unwrap,
} from "../../lib/session.server";

const REFRESH_MS = 4000;

export function meta({ params }: Route.MetaArgs) {
  return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }];
}

export async function loader({ params, context }: Route.LoaderArgs) {
  const viewer = getViewer(context);
  // Members only; to anyone else the page does not exist.
  if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
  const path = { namespace: params.owner, name: params.repo };
  const [plan, ledger] = await Promise.all([
    work.getPlan(path, viewer, params.id),
    billing.ledger(params.owner, viewer),
  ]);
  const found = unwrap(plan);
  // What the agents working on this outcome have cost so far.
  const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])));
  const repo = `${params.owner}/${params.repo}`;
  const costMicros = ledger.ok
    ? ledger.value
        .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
        .reduce((sum, entry) => sum - entry.amountMicros, 0)
    : null;
  // What happened across the outcome's issues and pull requests.
  let activity: G1tEvent[] = [];
  if (found.status === "applied" && found.progress.length > 0) {
    const numbers = new Set([
      ...found.progress.map((item) => item.number),
      ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])),
    ]);
    const since = found.finishedAt ?? found.createdAt;
    const recent = await events.list({ repoId: found.repoId, limit: 200 }).catch(() => []);
    activity = recent
      .filter((event) => event.time >= since)
      .filter((event) => {
        const data = event.data as { number?: number; issue?: number };
        // Issues an agent filed while working on this outcome belong to it too.
        if (event.type === "issue.opened" && event.actor === "usr_g1t_agent") return true;
        return (data.number != null && numbers.has(data.number)) || (data.issue != null && numbers.has(data.issue));
      })
      .slice(0, 40);
    // Events name accounts by id; show names.
    const named = await identity
      .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))])
      .catch(() => ({}) as Record<string, string>);
    const known: Record<string, string> = { ...named, usr_g1t_agent: "g1t-agent", g1t_policy: "g1t" };
    activity = activity.map((event) => ({ ...event, actor: event.actor ? (known[event.actor] ?? event.actor) : null }));
  }
  return { plan: found, costMicros, activity };
}

export async function action({ request, params, context }: Route.ActionArgs) {
  assertSameOrigin(request);
  const user = requireUser(context, request);
  const form = await request.formData();
  const path = { namespace: params.owner, name: params.repo };
  const applied = await env.RUNNER.applyPlan(user, path, params.id, {
    assign: form.get("action") === "assign",
    keep: form.getAll("keep").map(Number),
  });
  return applied.ok ? null : { error: applied.error.message };
}

export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
  const { plan, costMicros, activity } = loaderData;
  const base = `/${params.owner}/${params.repo}`;
  const applying = useNavigation().state === "submitting";

  // The agent is still writing it.
  const revalidator = useRevalidator();
  const planning = plan.status === "planning";
  // Planning, or agents still converging what was applied.
  const moving =
    planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
  useEffect(() => {
    if (!moving) return;
    const timer = setInterval(() => {
      if (document.visibilityState === "visible") revalidator.revalidate();
    }, REFRESH_MS);
    return () => clearInterval(timer);
  }, [moving, revalidator]);
  const converging = plan.status === "applied" && plan.progress.length > 0;

  const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
  return (
    <div className={converging ? "" : "max-w-4xl"}>
      <p className="text-sm">
        <Link to={`${base}/plans`} className="text-muted hover:text-fg">
          Plans
        </Link>
      </p>
      <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2>
      <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm">
        <Markdown source={plan.brief} repo={{ namespace: params.owner, name: params.repo }} />
      </div>
      <p className="mt-2 text-xs text-faint">
        Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} />
      </p>

      {converging && (
        <section className="mt-8">
          <Outcome plan={plan} base={base} costMicros={costMicros} />
          {plan.exchanges.length > 0 && (
            <div className="mt-10">
              <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">Agents talking</h3>
              <div className="mt-3 max-w-3xl">
                <Exchanges exchanges={plan.exchanges} base={base} />
              </div>
            </div>
          )}
          {activity.length > 0 && (
            <div className="mt-10">
              <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">What happened</h3>
              <div className="mt-3 max-w-3xl">
                <Activity events={activity} base={base} />
              </div>
            </div>
          )}
        </section>
      )}

      {planning && (
        <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm">
          <span className="size-2 animate-pulse rounded-full bg-accent" />
          <span>
            An agent is reading the repository and writing the plan. This takes a
            minute or two; the page fills in when it is done.
          </span>
        </div>
      )}
      {plan.status === "failed" && (
        <div className="mt-8 rounded-xl border border-danger/40 bg-danger/5 px-4 py-4 text-sm">
          <p className="font-medium">The plan could not be written</p>
          <p className="mt-1 text-muted">{plan.error}</p>
          <p className="mt-3">
            <Link to={`${base}/plans`} className="text-fg underline underline-offset-4">
              Try again
            </Link>
          </p>
        </div>
      )}

      {plan.issues.length > 0 && (
        <Form method="post" className="mt-8">
          <div className="flex flex-wrap items-baseline justify-between gap-2">
            <h2 className="text-xl font-semibold tracking-tight">
              {plan.status === "applied" ? "What was opened" : "The plan"}
            </h2>
            <p className="text-sm text-muted">
              {plan.issues.length} {plan.issues.length === 1 ? "issue" : "issues"},{" "}
              {independent} that can start at once
            </p>
          </div>
          {plan.summary && <p className="mt-2 text-sm text-muted">{plan.summary}</p>}

          <ol className="mt-5 space-y-3">
            {plan.issues.map((issue, index) => {
              const position = index + 1;
              return (
                <li
                  key={position}
                  className="flex gap-3 rounded-xl border border-line bg-surface p-4"
                >
                  {plan.status === "ready" ? (
                    <input
                      type="checkbox"
                      name="keep"
                      value={position}
                      defaultChecked
                      aria-label={`Open issue ${position}`}
                      className="mt-1.5 accent-accent"
                    />
                  ) : (
                    <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint">
                      {position}
                    </span>
                  )}
                  <div className="min-w-0 grow">
                    <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
                      {issue.number != null ? (
                        <Link
                          to={`${base}/issues/${issue.number}`}
                          className="font-medium hover:underline"
                        >
                          {issue.title}{" "}
                          <span className="font-normal text-faint">#{issue.number}</span>
                        </Link>
                      ) : (
                        <span className="font-medium">
                          <span className="mr-1.5 font-mono text-sm font-normal text-faint">
                            {position}.
                          </span>
                          {issue.title}
                        </span>
                      )}
                      {issue.labels.map((name) => (
                        <Label key={name} name={name} />
                      ))}
                    </div>
                    <p className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
                      {issue.dependsOn.length === 0 ? (
                        <span className="text-accent">Starts at once</span>
                      ) : (
                        <span className="flex items-center gap-1">
                          <ArrowRight size={12} />
                          After{" "}
                          {issue.dependsOn
                            .map((earlier) => {
                              const number = plan.issues[earlier - 1]?.number;
                              return number != null ? `#${number}` : `${earlier}`;
                            })
                            .join(", ")}
                        </span>
                      )}
                      {issue.checks.map((check) => (
                        <span key={check} className="flex items-center gap-1 font-mono">
                          <Terminal size={12} />
                          {check}
                        </span>
                      ))}
                    </p>
                    {issue.files.length > 0 && (
                      <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs text-faint">
                        <FileCode2 size={12} />
                        {issue.files.map((file) => (
                          <span key={file}>{file}</span>
                        ))}
                      </p>
                    )}
                    <details className="mt-2">
                      <summary className="cursor-pointer text-xs text-faint hover:text-fg">
                        What the agent will be told
                      </summary>
                      <div className="mt-2 border-t border-line pt-3">
                        <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
                      </div>
                    </details>
                  </div>
                </li>
              );
            })}
          </ol>

          {plan.status === "ready" && (
            <div className="mt-6 flex flex-wrap items-center gap-3">
              <Button variant="accent" type="submit" name="action" value="assign" disabled={applying}>
                <Sparkles size={15} />
                {applying ? "Opening issues…" : "Open these and assign g1t agents"}
              </Button>
              <Button variant="quiet" type="submit" name="action" value="open" disabled={applying}>
                Only open the issues
              </Button>
              <span className="text-xs text-muted">
                Untick any you do not want. Agents work on the independent ones at once
                and the rest follow as what they depend on merges.
              </span>
            </div>
          )}
          {plan.status === "applied" && !moving && (
            <p className="mt-6 text-sm text-muted">Everything in this plan has landed.</p>
          )}
          {plan.status === "applied" && moving && (
            <p className="mt-6 text-sm text-muted">
              Some are still open. Follow them under{" "}
              <Link to={`${base}/issues`} className="text-fg underline underline-offset-4">
                Issues
              </Link>{" "}
              or in{" "}
              <Link to="/" className="text-fg underline underline-offset-4">
                mission control
              </Link>
              .
            </p>
          )}
          <div className="mt-3">
            <ErrorText>{actionData?.error}</ErrorText>
          </div>
        </Form>
      )}
    </div>
  );
}