Skip to content
180 linesCodeBlameRaw

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 calendar1/**
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 */
17import {
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
31import { canManage } from "./access.ts";
32import { draftCard, parseMoney } from "./card-views.ts";
33
34export { draftCard, parseMoney } from "./card-views.ts";
35import { dollars } from "./money.ts";
36import { type SessionEnv, approve, sessionRow, steer, stop } from "./sessions.ts";
37
38export 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. */
60export 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
80const done = (message: string | null): Result<CardActionResult> => ok({ ok: true, message });
81const no = (message: string): Result<CardActionResult> => ok({ ok: false, message });
82
83/** A person pressed an action on one of agents' cards. */
84export 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
92async 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
121async 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}