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.
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 1 | /** |
| 2 | * Agents' cards in chat that people act on in place (docs/WORKSPACE.md, | |
| 3 | * "Cards"): a session's card (message it, stop it, approve more), and an | |
| 4 | * issue an agent drafted (file it, discard it). Chat checks the person can | |
| 5 | * read the conversation and that the card offers the action; this decides | |
| 6 | * whether they may, acts as them, and updates the card for everyone. | |
| 7 | * | |
| 8 | * The rules, decided here in code: | |
| 9 | * - **Stop, message:** anyone who can read the conversation the session | |
| 10 | * reports in, as they could from its page. | |
| 11 | * - **Approve more:** the workspace's owners only, to a cap above what it | |
| 12 | * has spent. | |
| 13 | * - **File issue:** whoever presses it files it as themselves, and only if | |
| 14 | * they can read the repository; the agent files nothing. | |
| 15 | * - **Discard:** whoever asked for it, or an owner. | |
| 16 | */ | |
| 17 | import { | |
| 18 | type AgentCardAction, | |
| 19 | type CardActionResult, | |
| 20 | type MessageCard, | |
| 21 | type Result, | |
| 22 | type User, | |
| 23 | chatClient, | |
| 24 | fail, | |
| 25 | newId, | |
| 26 | ok, | |
| 27 | reposClient, | |
| 28 | workClient, | |
| 29 | } from "@g1t/contracts"; | |
| 30 | ||
| 31 | import { canManage } from "./access.ts"; | |
| 32 | import { draftCard, parseMoney } from "./card-views.ts"; | |
| 33 | ||
| 34 | export { draftCard, parseMoney } from "./card-views.ts"; | |
| 35 | import { dollars } from "./money.ts"; | |
| 36 | import { type SessionEnv, approve, sessionRow, steer, stop } from "./sessions.ts"; | |
| 37 | ||
| 38 | export type DraftRow = { | |
| 39 | id: string; | |
| 40 | agent_id: string; | |
| 41 | workspace_id: string; | |
| 42 | workspace: string; | |
| 43 | channel_id: string; | |
| 44 | message_id: string | null; | |
| 45 | session_id: string | null; | |
| 46 | repo_id: string; | |
| 47 | repo: string; | |
| 48 | title: string; | |
| 49 | body: string; | |
| 50 | labels: string; | |
| 51 | asked_by: string | null; | |
| 52 | status: string; | |
| 53 | filed_by: string | null; | |
| 54 | number: number | null; | |
| 55 | created_at: string; | |
| 56 | updated_at: string; | |
| 57 | }; | |
| 58 | ||
| 59 | /** Records a draft and posts its card with `post`. Returns the draft's id. */ | |
| 60 | export async function postDraft( | |
| 61 | env: SessionEnv, | |
| 62 | input: { agent_id: string; workspace_id: string; workspace: string; channel_id: string; session_id: string | null; repo_id: string; repo: string; title: string; body: string; labels: string[]; asked_by: string | null }, | |
| 63 | post: (card: MessageCard) => Promise<string | null>, | |
| 64 | ): Promise<string | null> { | |
| 65 | const id = newId("drf"); | |
| 66 | const now = new Date().toISOString(); | |
| 67 | const row: DraftRow = { ...input, id, labels: JSON.stringify(input.labels), message_id: null, status: "draft", filed_by: null, number: null, created_at: now, updated_at: now }; | |
| 68 | await env.DB.prepare( | |
| 69 | `INSERT INTO agent_drafts (id, agent_id, workspace_id, workspace, channel_id, session_id, repo_id, repo, title, body, labels, asked_by, status, created_at, updated_at) | |
| 70 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`, | |
| 71 | ) | |
| 72 | .bind(id, row.agent_id, row.workspace_id, row.workspace, row.channel_id, row.session_id, row.repo_id, row.repo, row.title, row.body, row.labels, row.asked_by, now, now) | |
| 73 | .run(); | |
| 74 | const messageId = await post(draftCard(row)); | |
| 75 | if (!messageId) return null; | |
| 76 | await env.DB.prepare("UPDATE agent_drafts SET message_id = ? WHERE id = ?").bind(messageId, id).run(); | |
| 77 | return id; | |
| 78 | } | |
| 79 | ||
| 80 | const done = (message: string | null): Result<CardActionResult> => ok({ ok: true, message }); | |
| 81 | const no = (message: string): Result<CardActionResult> => ok({ ok: false, message }); | |
| 82 | ||
| 83 | /** A person pressed an action on one of agents' cards. */ | |
| 84 | export async function cardAction(env: SessionEnv, a: AgentCardAction): Promise<Result<CardActionResult>> { | |
| 85 | const viewer = a?.viewer as User | undefined; | |
| 86 | if (!viewer || !a.card?.ref) return fail("invalid", "No such card."); | |
| 87 | if (a.card.kind === "session") return sessionAction(env, a, viewer); | |
| 88 | if (a.card.kind === "draft_issue") return draftAction(env, a, viewer); | |
| 89 | return fail("invalid", "That card has no such action."); | |
| 90 | } | |
| 91 | ||
| 92 | async function sessionAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { | |
| 93 | const row = await sessionRow(env.DB, a.card.ref!); | |
| 94 | // The card is in the conversation the session reports in; chat checked the person reads it. | |
| 95 | if (!row || row.workspace !== a.workspace.toLowerCase() || row.channel_id !== a.channel_id) return fail("not_found", "No such session."); | |
| 96 | const live = ["queued", "working", "waiting", "needs_approval"].includes(row.status); | |
| 97 | switch (a.action_id) { | |
| 98 | case "stop": | |
| 99 | if (!live) return no("It already ended."); | |
| 100 | await stop(env, row, viewer.username); | |
| 101 | return done("Stopped."); | |
| 102 | case "steer": { | |
| 103 | const body = (a.input ?? "").trim(); | |
| 104 | if (!body) return no("Say something to it."); | |
| 105 | await steer(env, row, viewer.username, body); | |
| 106 | return done(live ? "It'll read that at its next step." : "It's picking up again with that."); | |
| 107 | } | |
| 108 | case "approve": { | |
| 109 | if (!canManage(viewer, a.workspace)) return no("Only the workspace's owners can approve more spend."); | |
| 110 | if (row.status !== "needs_approval") return no("It isn't waiting for approval."); | |
| 111 | const cap = parseMoney(a.input); | |
| 112 | if (!cap || cap <= row.charged_micros) return no(`The new cap must be more than the ${dollars(row.charged_micros)} it has spent.`); | |
| 113 | await approve(env, row, viewer.username, cap); | |
| 114 | return done(`Approved up to ${dollars(cap)}. It's going on.`); | |
| 115 | } | |
| 116 | default: | |
| 117 | return fail("invalid", "That card has no such action."); | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | async function draftAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { | |
| 122 | const db = env.DB; | |
| 123 | const draft = await db.prepare("SELECT * FROM agent_drafts WHERE id = ?").bind(a.card.ref).first<DraftRow>(); | |
| 124 | if (!draft || draft.workspace !== a.workspace.toLowerCase() || draft.channel_id !== a.channel_id) return fail("not_found", "No such draft."); | |
| 125 | const update = async (row: DraftRow) => { | |
| 126 | if (!row.message_id) return; | |
| 127 | await chatClient(env.CHAT) | |
| 128 | .updateAsAgent(row.workspace, row.channel_id, row.agent_id, row.message_id, { card: draftCard(row) }) | |
| 129 | .catch((error: unknown) => console.error("agents: a draft's card was not updated", row.id, String(error))); | |
| 130 | }; | |
| 131 | if (draft.status !== "draft") return no(draft.status === "filed" ? `It's already filed as #${draft.number}.` : "It was discarded."); | |
| 132 | const now = new Date().toISOString(); | |
| 133 | if (a.action_id === "discard") { | |
| 134 | if (draft.asked_by !== viewer.id && !canManage(viewer, a.workspace)) return no("Only whoever asked for it, or an owner, can discard it."); | |
| 135 | const claimed = await db.prepare("UPDATE agent_drafts SET status = 'discarded', updated_at = ? WHERE id = ? AND status = 'draft'").bind(now, draft.id).run(); | |
| 136 | if (!claimed.meta.changes) return no("Someone got there first."); | |
| 137 | await update({ ...draft, status: "discarded" }); | |
| 138 | return done("Discarded."); | |
| 139 | } | |
| 140 | if (a.action_id !== "file") return fail("invalid", "That card has no such action."); | |
| 141 | // Filed as the person who pressed it, only where they can read. | |
| 142 | const [repo] = await reposClient(env.REPOS) | |
| 143 | .readable([draft.repo_id], viewer) | |
| 144 | .catch(() => []); | |
| 145 | if (!repo) return no("You can't file in that repository."); | |
| 146 | // Claimed first, so two presses never file it twice. | |
| 147 | const claimed = await db.prepare("UPDATE agent_drafts SET status = 'filing', updated_at = ? WHERE id = ? AND status = 'draft'").bind(now, draft.id).run(); | |
| 148 | if (!claimed.meta.changes) return no("Someone got there first."); | |
| 149 | const agent = await db.prepare("SELECT handle, display_name FROM agents WHERE id = ?").bind(draft.agent_id).first<{ handle: string; display_name: string }>(); | |
| 150 | const footer = agent ? `\n\n---\n_Drafted by ${agent.display_name} (@${agent.handle}), filed by @${viewer.username}._` : ""; | |
| 151 | let labels: string[] = []; | |
| 152 | try { | |
| 153 | labels = JSON.parse(draft.labels || "[]"); | |
| 154 | } catch { | |
| 155 | labels = []; | |
| 156 | } | |
| 157 | const opened = await workClient(env.WORK).openIssue(viewer, { namespace: repo.namespace, name: repo.name }, { title: draft.title, body: `${draft.body}${footer}`, labels }); | |
| 158 | if (!opened.ok) { | |
| 159 | await db.prepare("UPDATE agent_drafts SET status = 'draft', updated_at = ? WHERE id = ?").bind(new Date().toISOString(), draft.id).run(); | |
| 160 | return no(`It couldn't be filed: ${opened.error.message}`); | |
| 161 | } | |
| 162 | const filed: DraftRow = { ...draft, status: "filed", filed_by: viewer.username, number: opened.value.number }; | |
| 163 | await db.prepare("UPDATE agent_drafts SET status = 'filed', filed_by = ?, number = ?, updated_at = ? WHERE id = ?").bind(viewer.username, filed.number, new Date().toISOString(), draft.id).run(); | |
| 164 | if (draft.session_id) { | |
| 165 | const row = await sessionRow(db, draft.session_id); | |
| 166 | if (row) { | |
| 167 | const outputs = (() => { | |
| 168 | try { | |
| 169 | return JSON.parse(row.outputs || "[]") as unknown[]; | |
| 170 | } catch { | |
| 171 | return []; | |
| 172 | } | |
| 173 | })(); | |
| 174 | outputs.push({ kind: "issue", repo: draft.repo, number: filed.number, title: draft.title }); | |
| 175 | await db.prepare("UPDATE agent_sessions SET outputs = ? WHERE id = ?").bind(JSON.stringify(outputs.slice(-50)), row.id).run(); | |
| 176 | } | |
| 177 | } | |
| 178 | await update(filed); | |
| 179 | return done(`Filed ${draft.repo}#${filed.number}.`); | |
| 180 | } |