| 1 | /** |
| 2 | * Ask g1t (components/ask-composer): `GET -/ask` says what Ask answers with |
| 3 | * in the workspace, or why it cannot yet, and whether the viewer is an |
| 4 | * owner who can choose; with `?sessions`, whether sessions can start on the |
| 5 | * workspace's Anthropic account. The conversation itself is the viewer's |
| 6 | * direct message with @g1t in chat (`-/chat/api`, intents `g1t_dm`, `post`, |
| 7 | * `g1t_stop`, `g1t_reset`, `g1t_retry`, `g1t_clear`): the chat service has g1t answer |
| 8 | * there, with the provider an owner chose, on the workspace's own key. |
| 9 | * |
| 10 | * `POST -/ask` starts a session g1t proposed in an answer (the proposal |
| 11 | * card in components/ask-steps): the person's click is the confirmation. |
| 12 | * `handoff` asks for the Claude Code command instead of starting one on |
| 13 | * the workspace's Anthropic account. A proposal with `new_repository` |
| 14 | * first creates the repository as the person (its project described with |
| 15 | * the issue's title), turns on its deployments when `deploy`, and tells |
| 16 | * the session to push the first version to the default branch, which |
| 17 | * deploys it. Sent again (a retry, or Copy after Start), a repository |
| 18 | * already made is used as it is. |
| 19 | */ |
| 20 | import { data } from "react-router"; |
| 21 | |
| 22 | import { AGENT_MODES, type AskProposalState, type User } from "@g1t/contracts"; |
| 23 | |
| 24 | import type { Route } from "./+types/ask"; |
| 25 | import { agentSessions, ai, chat, deployments, projects, repos } from "../../lib/services.server"; |
| 26 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; |
| 27 | |
| 28 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 29 | const viewer = getViewer(context); |
| 30 | const role = roleIn(viewer, params.owner); |
| 31 | if (!role) throw data(null, { status: 404 }); |
| 32 | const workspace = params.owner.toLowerCase(); |
| 33 | const headers = { "cache-control": "no-store" }; |
| 34 | if (new URL(request.url).searchParams.has("sessions")) { |
| 35 | const setup = await agentSessions.setup(workspace, viewer).catch(() => null); |
| 36 | return Response.json({ available: Boolean(setup?.ok && setup.value.available), model: setup?.ok ? setup.value.model : null }, { headers }); |
| 37 | } |
| 38 | const setup = await ai.askSettings(workspace, viewer); |
| 39 | return Response.json({ ...setup, owner: role === "owner" }, { headers }); |
| 40 | } |
| 41 | |
| 42 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 43 | assertSameOrigin(request); |
| 44 | const user = requireUser(context, request); |
| 45 | if (!roleIn(getViewer(context), params.owner)) throw data(null, { status: 404 }); |
| 46 | const input = (await request.json().catch(() => null)) as Record<string, unknown> | null; |
| 47 | const [namespace, name, extra] = String(input?.repo ?? "").split("/"); |
| 48 | // A proposal is for a repository in this workspace only. |
| 49 | if (!namespace || !name || extra !== undefined || namespace.toLowerCase() !== params.owner.toLowerCase()) { |
| 50 | return Response.json({ error: "That repository isn't in this workspace." }, { status: 400 }); |
| 51 | } |
| 52 | const text = (key: string) => (typeof input?.[key] === "string" && (input[key] as string).trim()) || undefined; |
| 53 | const handoff = input?.handoff === true; |
| 54 | // What the card did is kept on the answer that proposed it, step by step, so |
| 55 | // the card shows it wherever the answer is read and another click carries on. |
| 56 | const kept = keeper(user, params.owner, input); |
| 57 | const before = await kept.save({}); |
| 58 | if (before?.started && !handoff) { |
| 59 | return Response.json({ ...before, handoff: false, command: null, prompt: "" }); |
| 60 | } |
| 61 | if (!handoff && before?.starting_at && Date.now() - before.starting_at < STARTING_MS) { |
| 62 | return Response.json({ error: "It's already starting. This card shows it in a moment.", ...before }, { status: 409 }); |
| 63 | } |
| 64 | const asked = Number.isInteger(input?.issue) && (input?.issue as number) > 0 ? (input?.issue as number) : undefined; |
| 65 | // An issue a click already opened is worked on, not opened again. |
| 66 | const issue = asked ?? before?.issue; |
| 67 | const title = issue ? undefined : text("title"); |
| 68 | if (!issue && !title) return Response.json({ error: "The proposal names no issue." }, { status: 400 }); |
| 69 | const mode = AGENT_MODES.find((one) => one.mode === input?.mode)?.mode ?? "implement"; |
| 70 | if (!handoff) await kept.save({ starting_at: Date.now() }); |
| 71 | let instruction = text("instruction"); |
| 72 | let built: Built | null = null; |
| 73 | if (input?.new_repository === true) { |
| 74 | const made = before?.repo_url && before.default_branch |
| 75 | ? { repo_url: before.repo_url, default_branch: before.default_branch, production_url: before.production_url ?? null, deploy_error: before.deploy_error ?? null } |
| 76 | : await newRepository(user, namespace, name, { title: (title ?? text("title")) || name, isPrivate: input.private === true, deploy: input.deploy !== false }); |
| 77 | if ("error" in made) { |
| 78 | await kept.save({ starting_at: 0 }); |
| 79 | return Response.json({ error: made.error }, { status: 400 }); |
| 80 | } |
| 81 | built = made; |
| 82 | await kept.save(made); |
| 83 | instruction = [firstPush(made), instruction].filter(Boolean).join("\n\n"); |
| 84 | } |
| 85 | const result = await agentSessions |
| 86 | .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction, mode, handoff }) |
| 87 | .catch(() => null); |
| 88 | if (!result?.ok) { |
| 89 | if (!handoff) await kept.save({ starting_at: 0 }); |
| 90 | if (!result) return Response.json({ error: "The session could not be started. Try again in a moment.", ...built }, { status: 502 }); |
| 91 | return Response.json({ error: result.error.message, ...built }, { status: 400 }); |
| 92 | } |
| 93 | const started = result.value; |
| 94 | const session_url = started.sessionUrl ?? (started.session ? `/${namespace}/${name}/work/sessions/${started.session.id}` : null); |
| 95 | await kept.save( |
| 96 | started.handoff |
| 97 | ? { issue: started.issue } |
| 98 | : { issue: started.issue, started: true, session_url, provider_session_url: started.providerSessionUrl ?? null }, |
| 99 | ); |
| 100 | return Response.json({ |
| 101 | issue: started.issue, |
| 102 | session_url, |
| 103 | provider_session_url: started.providerSessionUrl, |
| 104 | handoff: started.handoff, |
| 105 | command: started.command, |
| 106 | prompt: started.prompt, |
| 107 | ...built, |
| 108 | }); |
| 109 | } |
| 110 | |
| 111 | /** How long a click that began starting a proposal holds off another. */ |
| 112 | const STARTING_MS = 90_000; |
| 113 | |
| 114 | /** |
| 115 | * Keeps what a proposal's card did on the @g1t answer it came in |
| 116 | * (`message_id` in `channel_id`, the viewer's direct message with g1t), |
| 117 | * through chat's `g1t_proposal`; `save({})` only reads it. Without a |
| 118 | * message, or when chat can't be reached, nothing is kept and null is said: |
| 119 | * the card still works, it just won't remember. |
| 120 | */ |
| 121 | function keeper(user: User, workspace: string, input: Record<string, unknown> | null) { |
| 122 | const id = typeof input?.id === "string" ? input.id : ""; |
| 123 | const channel = typeof input?.channel_id === "string" ? input.channel_id : ""; |
| 124 | const message = typeof input?.message_id === "string" ? input.message_id : ""; |
| 125 | return { |
| 126 | async save(change: Omit<AskProposalState, "id">): Promise<AskProposalState | null> { |
| 127 | if (!id || !channel || !message) return null; |
| 128 | const kept = await chat.g1tProposal(workspace.toLowerCase(), channel, user, message, { ...change, id }).catch((error: unknown) => { |
| 129 | console.error("ask: proposal state not kept", id, error); |
| 130 | return null; |
| 131 | }); |
| 132 | return kept?.ok ? kept.value : null; |
| 133 | }, |
| 134 | }; |
| 135 | } |
| 136 | |
| 137 | /** What the card made before the session: the repository, and where it deploys or why it can't. */ |
| 138 | type Built = { repo_url: string; default_branch: string; production_url: string | null; deploy_error: string | null }; |
| 139 | |
| 140 | /** |
| 141 | * The repository a new-repository proposal names, made as the person, or |
| 142 | * the one already made by an earlier click; its project described; its |
| 143 | * deployments on when asked. A deploy that can't be turned on (no plan, no |
| 144 | * Admin role) is said, not fatal: the session still builds it. |
| 145 | */ |
| 146 | async function newRepository( |
| 147 | user: User, |
| 148 | namespace: string, |
| 149 | name: string, |
| 150 | wanted: { title: string; isPrivate: boolean; deploy: boolean }, |
| 151 | ): Promise<Built | { error: string }> { |
| 152 | const created = await repos.create(user, { namespace, name, isPrivate: wanted.isPrivate }).catch(() => null); |
| 153 | let repo = created?.ok ? created.value : null; |
| 154 | if (!repo) { |
| 155 | // Already made: a second click, or Copy after Start. Anything else is the reason it wasn't. |
| 156 | const found = await repos.get({ namespace, name }, user).catch(() => null); |
| 157 | if (!found?.ok) return { error: created && !created.ok ? created.error.message : "The repository could not be created. Try again in a moment." }; |
| 158 | repo = found.value; |
| 159 | } else { |
| 160 | const described = await projects.describe(user, repo.id, wanted.title).catch((error: unknown) => String(error)); |
| 161 | if (typeof described === "string" || !described.ok) console.error("ask: new repository without its description", repo.id, described); |
| 162 | } |
| 163 | const built: Built = { repo_url: `/${repo.namespace}/${repo.name}`, default_branch: repo.defaultBranch, production_url: null, deploy_error: null }; |
| 164 | if (!wanted.deploy) return built; |
| 165 | const saved = await deployments.updateSettings(user, { workspace: repo.namespace, slug: repo.name }, { enabled: true }).catch(() => null); |
| 166 | if (saved?.ok) built.production_url = saved.value.productionUrl; |
| 167 | else built.deploy_error = saved ? saved.error.message : "Deployments could not be turned on. Try again from the project's Settings → Deployments."; |
| 168 | return built; |
| 169 | } |
| 170 | |
| 171 | /** What the session is told about a repository the card just made. */ |
| 172 | function firstPush(built: Built): string { |
| 173 | const live = built.production_url |
| 174 | ? `Its deployments are on: that push deploys production to ${built.production_url}. Watch it with \`g1t deploy list\` until it is live; if it fails, read why (\`g1t deploy view ID\`), fix it and push again. End your report with the address.` |
| 175 | : "Its deployments are off, so nothing deploys yet; say so in your report."; |
| 176 | return ( |
| 177 | `The repository was just created for this and is empty. Build the first working version and push it straight to the default branch ` + |
| 178 | `(\`git push origin HEAD:${built.default_branch}\`): there is nothing to open a pull request against yet, so open none. ` + |
| 179 | `Keep it small and make it build and serve from the repository's root. Put \`Closes #\` and the issue's number in the commit message, so the push closes it. ${live}` |
| 180 | ); |
| 181 | } |