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