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 | /** |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 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. | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 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. | |
| Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how. | 17 | * - **Allow, deny** (an Ask-first card, abilities.ts): whoever the agent |
| 18 | * acts for, or an owner. Allowing runs the call as the person who | |
| 19 | * pressed it, and the agent hears the result; a session goes on. | |
| 20 | * - **Ask the owners** (a Request card): anyone; an integration becomes a | |
| 21 | * Marketplace install request, an ability a notification to the owners. | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 22 | */ |
| 23 | import { | |
| 24 | type AgentCardAction, | |
| 25 | type CardActionResult, | |
| 26 | type MessageCard, | |
| 27 | type Result, | |
| 28 | type User, | |
| 29 | chatClient, | |
| 30 | fail, | |
| Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how. | 31 | identityClient, |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 32 | newId, |
| 33 | ok, | |
| 34 | reposClient, | |
| 35 | workClient, | |
| 36 | } from "@g1t/contracts"; | |
| 37 | ||
| Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how. | 38 | import { type AbilityRequestRow, abilitiesPath, recordDecision, runAllowed, tellOwnersOfRequest } from "./abilities.ts"; |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 39 | import { canManage } from "./access.ts"; |
| Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how. | 40 | import { abilityCard, draftCard, parseMoney, requestCard } from "./card-views.ts"; |
| 41 | import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts"; | |
| 42 | import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts"; | |
| 43 | import { findListing, openRequest } from "./installs.ts"; | |
| 44 | import { type Row, definitionOf, isPersonal, periods, selectAgents } from "./store.ts"; | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 45 | |
| 46 | export { draftCard, parseMoney } from "./card-views.ts"; | |
| 47 | import { dollars } from "./money.ts"; | |
| Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how. | 48 | import { type SessionEnv, approve, pushInbox, resumeAfterDecision, sessionRow, steer, stop } from "./sessions.ts"; |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 49 | |
| 50 | export type DraftRow = { | |
| 51 | id: string; | |
| 52 | agent_id: string; | |
| 53 | workspace_id: string; | |
| 54 | workspace: string; | |
| 55 | channel_id: string; | |
| 56 | message_id: string | null; | |
| 57 | session_id: string | null; | |
| 58 | repo_id: string; | |
| 59 | repo: string; | |
| 60 | title: string; | |
| 61 | body: string; | |
| 62 | labels: string; | |
| 63 | asked_by: string | null; | |
| 64 | status: string; | |
| 65 | filed_by: string | null; | |
| 66 | number: number | null; | |
| 67 | created_at: string; | |
| 68 | updated_at: string; | |
| 69 | }; | |
| 70 | ||
| 71 | /** Records a draft and posts its card with `post`. Returns the draft's id. */ | |
| 72 | export async function postDraft( | |
| 73 | env: SessionEnv, | |
| 74 | 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 }, | |
| 75 | post: (card: MessageCard) => Promise<string | null>, | |
| 76 | ): Promise<string | null> { | |
| 77 | const id = newId("drf"); | |
| 78 | const now = new Date().toISOString(); | |
| 79 | 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 }; | |
| 80 | await env.DB.prepare( | |
| 81 | `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) | |
| 82 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`, | |
| 83 | ) | |
| 84 | .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) | |
| 85 | .run(); | |
| 86 | const messageId = await post(draftCard(row)); | |
| 87 | if (!messageId) return null; | |
| 88 | await env.DB.prepare("UPDATE agent_drafts SET message_id = ? WHERE id = ?").bind(messageId, id).run(); | |
| 89 | return id; | |
| 90 | } | |
| 91 | ||
| 92 | const done = (message: string | null): Result<CardActionResult> => ok({ ok: true, message }); | |
| 93 | const no = (message: string): Result<CardActionResult> => ok({ ok: false, message }); | |
| 94 | ||
| 95 | /** A person pressed an action on one of agents' cards. */ | |
| 96 | export async function cardAction(env: SessionEnv, a: AgentCardAction): Promise<Result<CardActionResult>> { | |
| 97 | const viewer = a?.viewer as User | undefined; | |
| 98 | if (!viewer || !a.card?.ref) return fail("invalid", "No such card."); | |
| 99 | if (a.card.kind === "session") return sessionAction(env, a, viewer); | |
| 100 | if (a.card.kind === "draft_issue") return draftAction(env, a, viewer); | |
| Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how. | 101 | if (a.card.kind === "ability") return abilityAction(env, a, viewer); |
| 102 | if (a.card.kind === "request") return requestAction(env, a, viewer); | |
| 103 | return fail("invalid", "That card has no such action."); | |
| 104 | } | |
| 105 | ||
| 106 | /** The agent a card belongs to, by id. */ | |
| 107 | async function agentById(db: D1Database, id: string): Promise<Row | null> { | |
| 108 | return db | |
| 109 | .prepare(selectAgents("a.id = ?3")) | |
| 110 | .bind(...periods(new Date()), id) | |
| 111 | .first<Row>(); | |
| 112 | } | |
| 113 | ||
| 114 | /** Allow or deny an Ask-first call (abilities.ts): whoever the agent acts for, or an owner. */ | |
| 115 | async function abilityAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { | |
| 116 | const db = env.DB; | |
| 117 | const request = await db.prepare("SELECT * FROM agent_ability_requests WHERE id = ?").bind(a.card.ref).first<AbilityRequestRow>(); | |
| 118 | if (!request || request.workspace !== a.workspace.toLowerCase() || request.channel_id !== a.channel_id) return fail("not_found", "No such request."); | |
| 119 | if (a.action_id !== "allow" && a.action_id !== "deny") return fail("invalid", "That card has no such action."); | |
| 120 | if (request.status !== "pending") return no(request.status === "denied" ? "It was denied." : "It was already answered."); | |
| 121 | if (request.asked_by !== viewer.id && !canManage(viewer, a.workspace)) return no("Only whoever the agent is working for, or an owner, can answer this."); | |
| 122 | const agent = await agentById(db, request.agent_id); | |
| 123 | if (!agent) return fail("not_found", "The agent is gone."); | |
| 124 | const definition = definitionOf(agent); | |
| 125 | const sections = resolveAbilities({ connectors: CONNECTORS, abilities: definition.abilities, autonomy: definition.autonomy, connected: [request.ability.split(":")[1] ?? ""], personal: isPersonal(agent) }); | |
| 126 | const found = findAbility(sections, request.ability); | |
| 127 | const about = { | |
| 128 | agent: agent.display_name, | |
| 129 | asker: await usernameOf(env, request.asked_by), | |
| 130 | rule: found ? `${found.source.name}: ${found.ability.label}` : request.ability, | |
| 131 | level: found?.ability.level ?? ("ask" as const), | |
| 132 | note: null, | |
| 133 | body: bodyOf(request.input), | |
| 134 | }; | |
| 135 | const now = new Date().toISOString(); | |
| 136 | const update = async (row: AbilityRequestRow) => { | |
| 137 | if (!row.message_id) return; | |
| 138 | await chatClient(env.CHAT) | |
| 139 | .updateAsAgent(row.workspace, row.channel_id, row.agent_id, row.message_id, { card: abilityCard(row, about) }) | |
| 140 | .catch((error: unknown) => console.error("agents: an ability card was not updated", row.id, String(error))); | |
| 141 | }; | |
| 142 | // Claimed first, so two presses never run it twice. | |
| 143 | const claimed = await db.prepare("UPDATE agent_ability_requests SET status = ?, decided_by = ?, decided_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'").bind(a.action_id === "allow" ? "running" : "denied", viewer.username, now, now, request.id).run(); | |
| 144 | if (!claimed.meta.changes) return no("Someone got there first."); | |
| 145 | recordDecision(env, { by: viewer, agent, workspace: a.workspace, request, allowed: a.action_id === "allow" }); | |
| 146 | if (a.action_id === "deny") { | |
| 147 | const denied = { ...request, status: "denied", decided_by: viewer.username, decided_at: now }; | |
| 148 | await update(denied); | |
| 149 | await resumeAfterDecision(env, request, `@${viewer.username} denied: ${request.summary}. Don't try another way; say so.`); | |
| 150 | return done("Denied. It won't be done."); | |
| 151 | } | |
| 152 | const ran = await runAllowed(env, request, agent, definition, viewer); | |
| 153 | const status = ran.ok ? "allowed" : "failed"; | |
| 154 | await db.prepare("UPDATE agent_ability_requests SET status = ?, result = ?, updated_at = ? WHERE id = ?").bind(status, ran.message.slice(0, 20_000), new Date().toISOString(), request.id).run(); | |
| 155 | const fresh = { ...request, status, decided_by: viewer.username, decided_at: now, result: ran.message.slice(0, 300) }; | |
| 156 | await update(fresh); | |
| 157 | await resumeAfterDecision(env, request, ran.ok ? `@${viewer.username} allowed "${request.summary}", and it ran:\n${ran.message}` : `@${viewer.username} allowed "${request.summary}", but it didn't work: ${ran.message}`); | |
| 158 | return ran.ok ? done(`Allowed. ${ran.message.slice(0, 200)}`) : no(`Allowed, but it didn't work: ${ran.message.slice(0, 300)}`); | |
| 159 | } | |
| 160 | ||
| 161 | /** The text a call would write, for the card. */ | |
| 162 | function bodyOf(input: string): string | null { | |
| 163 | try { | |
| 164 | const args = JSON.parse(input) as { text?: unknown }; | |
| 165 | return typeof args.text === "string" && args.text.trim() ? args.text.trim().slice(0, 900) : null; | |
| 166 | } catch { | |
| 167 | return null; | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | async function usernameOf(env: SessionEnv, id: string | null): Promise<string | null> { | |
| 172 | if (!id) return null; | |
| 173 | const [user] = await identityClient(env.IDENTITY) | |
| 174 | .usersForAudience([id]) | |
| 175 | .catch(() => [] as User[]); | |
| 176 | return user?.username ?? null; | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * Ask the owners, from a Request card: an integration becomes a Marketplace | |
| 181 | * install request (the same one the Marketplace opens); an ability, a | |
| 182 | * notification to the owners with a link to the agent's Abilities tab. | |
| 183 | */ | |
| 184 | async function requestAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { | |
| 185 | if (a.action_id !== "ask") return fail("invalid", "That card has no such action."); | |
| 186 | const ref = a.card.ref ?? ""; | |
| 187 | const [kind, agentId, ...rest] = ref.split(":"); | |
| 188 | const agent = agentId ? await agentById(env.DB, agentId) : null; | |
| 189 | if (!agent) return fail("not_found", "No such card."); | |
| 190 | const slug = a.workspace.toLowerCase(); | |
| 191 | const update = async (card: MessageCard) => { | |
| 192 | await chatClient(env.CHAT) | |
| 193 | .updateAsAgent(slug, a.channel_id, agent.id, a.message_id, { card }) | |
| 194 | .catch((error: unknown) => console.error("agents: a request card was not updated", a.message_id, String(error))); | |
| 195 | }; | |
| 196 | const who = { id: agent.id, handle: agent.handle, display_name: agent.display_name }; | |
| 197 | if (kind === "connector") { | |
| 198 | const connector = CONNECTORS.find((c) => c.id === rest[0]); | |
| 199 | if (!connector) return fail("not_found", "No such integration."); | |
| 200 | if (canManage(viewer, slug)) return no(`You're an owner: connect ${connector.name} from the Marketplace.`); | |
| 201 | const listing = findListing(`integration:${connector.id}`); | |
| 202 | if (!listing) return no(`${connector.name} can't be connected to a workspace yet.`); | |
| 203 | const why = `${agent.display_name} needs it for @${viewer.username}.`; | |
| 204 | const opened = await openRequest(env.DB, agent.workspace_id, newId("ins"), listing, viewer, why); | |
| 205 | if (!opened.ok && opened.error.code !== "conflict") return no(opened.error.message); | |
| 206 | if (opened.ok) { | |
| 207 | await tellOwnersOfRequest(env, { workspace: slug, by: viewer, title: `@${viewer.username} asks you to add ${connector.name}`, body: why, href: `/${slug}/-/marketplace/requests`, id: opened.value.id }); | |
| 208 | } | |
| 209 | await update(requestCard({ agent: who, workspace: slug, connector: { id: connector.id, name: connector.name, available: true }, ability: null, why, status: "asked", by: viewer.username })); | |
| 210 | return done(opened.ok ? "Asked. The owners have your request." : "You'd already asked; the owners have it."); | |
| 211 | } | |
| 212 | if (kind === "ability") { | |
| 213 | const abilityId = rest.join(":"); | |
| 214 | const definition = definitionOf(agent); | |
| 215 | const sections = resolveAbilities({ connectors: CONNECTORS, abilities: definition.abilities, autonomy: definition.autonomy, connected: CONNECTORS.map((c) => c.id), personal: isPersonal(agent) }); | |
| 216 | const found = findAbility(sections, abilityId); | |
| 217 | const label = found ? `${found.source.name}: ${found.ability.label}` : abilityId; | |
| 218 | if (canManage(viewer, slug)) return no(`You're an owner: allow it on ${agent.display_name}'s Abilities tab.`); | |
| 219 | const why = `${agent.display_name} needs it for @${viewer.username}.`; | |
| 220 | await tellOwnersOfRequest(env, { workspace: slug, by: viewer, title: `@${viewer.username} asks you to let ${agent.display_name} ${found ? found.ability.label.toLowerCase() : "do more"}`, body: `${label}. ${why}`, href: abilitiesPath(slug, agent.handle), id: `${agent.id}:${abilityId}:${viewer.id}` }); | |
| 221 | await update(requestCard({ agent: who, workspace: slug, connector: null, ability: { id: abilityId, label }, why, status: "asked", by: viewer.username })); | |
| 222 | return done("Asked. The owners have been told."); | |
| 223 | } | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 224 | return fail("invalid", "That card has no such action."); |
| 225 | } | |
| 226 | ||
| 227 | async function sessionAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { | |
| 228 | const row = await sessionRow(env.DB, a.card.ref!); | |
| 229 | // The card is in the conversation the session reports in; chat checked the person reads it. | |
| 230 | if (!row || row.workspace !== a.workspace.toLowerCase() || row.channel_id !== a.channel_id) return fail("not_found", "No such session."); | |
| 231 | const live = ["queued", "working", "waiting", "needs_approval"].includes(row.status); | |
| 232 | switch (a.action_id) { | |
| 233 | case "stop": | |
| 234 | if (!live) return no("It already ended."); | |
| 235 | await stop(env, row, viewer.username); | |
| 236 | return done("Stopped."); | |
| 237 | case "steer": { | |
| 238 | const body = (a.input ?? "").trim(); | |
| 239 | if (!body) return no("Say something to it."); | |
| 240 | await steer(env, row, viewer.username, body); | |
| 241 | return done(live ? "It'll read that at its next step." : "It's picking up again with that."); | |
| 242 | } | |
| 243 | case "approve": { | |
| 244 | if (!canManage(viewer, a.workspace)) return no("Only the workspace's owners can approve more spend."); | |
| 245 | if (row.status !== "needs_approval") return no("It isn't waiting for approval."); | |
| 246 | const cap = parseMoney(a.input); | |
| 247 | if (!cap || cap <= row.charged_micros) return no(`The new cap must be more than the ${dollars(row.charged_micros)} it has spent.`); | |
| 248 | await approve(env, row, viewer.username, cap); | |
| 249 | return done(`Approved up to ${dollars(cap)}. It's going on.`); | |
| 250 | } | |
| 251 | default: | |
| 252 | return fail("invalid", "That card has no such action."); | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | async function draftAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> { | |
| 257 | const db = env.DB; | |
| 258 | const draft = await db.prepare("SELECT * FROM agent_drafts WHERE id = ?").bind(a.card.ref).first<DraftRow>(); | |
| 259 | if (!draft || draft.workspace !== a.workspace.toLowerCase() || draft.channel_id !== a.channel_id) return fail("not_found", "No such draft."); | |
| 260 | const update = async (row: DraftRow) => { | |
| 261 | if (!row.message_id) return; | |
| 262 | await chatClient(env.CHAT) | |
| 263 | .updateAsAgent(row.workspace, row.channel_id, row.agent_id, row.message_id, { card: draftCard(row) }) | |
| 264 | .catch((error: unknown) => console.error("agents: a draft's card was not updated", row.id, String(error))); | |
| 265 | }; | |
| 266 | if (draft.status !== "draft") return no(draft.status === "filed" ? `It's already filed as #${draft.number}.` : "It was discarded."); | |
| 267 | const now = new Date().toISOString(); | |
| 268 | if (a.action_id === "discard") { | |
| 269 | if (draft.asked_by !== viewer.id && !canManage(viewer, a.workspace)) return no("Only whoever asked for it, or an owner, can discard it."); | |
| 270 | const claimed = await db.prepare("UPDATE agent_drafts SET status = 'discarded', updated_at = ? WHERE id = ? AND status = 'draft'").bind(now, draft.id).run(); | |
| 271 | if (!claimed.meta.changes) return no("Someone got there first."); | |
| 272 | await update({ ...draft, status: "discarded" }); | |
| 273 | return done("Discarded."); | |
| 274 | } | |
| 275 | if (a.action_id !== "file") return fail("invalid", "That card has no such action."); | |
| 276 | // Filed as the person who pressed it, only where they can read. | |
| 277 | const [repo] = await reposClient(env.REPOS) | |
| 278 | .readable([draft.repo_id], viewer) | |
| 279 | .catch(() => []); | |
| 280 | if (!repo) return no("You can't file in that repository."); | |
| 281 | // Claimed first, so two presses never file it twice. | |
| 282 | const claimed = await db.prepare("UPDATE agent_drafts SET status = 'filing', updated_at = ? WHERE id = ? AND status = 'draft'").bind(now, draft.id).run(); | |
| 283 | if (!claimed.meta.changes) return no("Someone got there first."); | |
| 284 | const agent = await db.prepare("SELECT handle, display_name FROM agents WHERE id = ?").bind(draft.agent_id).first<{ handle: string; display_name: string }>(); | |
| 285 | const footer = agent ? `\n\n---\n_Drafted by ${agent.display_name} (@${agent.handle}), filed by @${viewer.username}._` : ""; | |
| 286 | let labels: string[] = []; | |
| 287 | try { | |
| 288 | labels = JSON.parse(draft.labels || "[]"); | |
| 289 | } catch { | |
| 290 | labels = []; | |
| 291 | } | |
| 292 | const opened = await workClient(env.WORK).openIssue(viewer, { namespace: repo.namespace, name: repo.name }, { title: draft.title, body: `${draft.body}${footer}`, labels }); | |
| 293 | if (!opened.ok) { | |
| 294 | await db.prepare("UPDATE agent_drafts SET status = 'draft', updated_at = ? WHERE id = ?").bind(new Date().toISOString(), draft.id).run(); | |
| 295 | return no(`It couldn't be filed: ${opened.error.message}`); | |
| 296 | } | |
| 297 | const filed: DraftRow = { ...draft, status: "filed", filed_by: viewer.username, number: opened.value.number }; | |
| 298 | 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(); | |
| 299 | if (draft.session_id) { | |
| 300 | const row = await sessionRow(db, draft.session_id); | |
| 301 | if (row) { | |
| 302 | const outputs = (() => { | |
| 303 | try { | |
| 304 | return JSON.parse(row.outputs || "[]") as unknown[]; | |
| 305 | } catch { | |
| 306 | return []; | |
| 307 | } | |
| 308 | })(); | |
| 309 | outputs.push({ kind: "issue", repo: draft.repo, number: filed.number, title: draft.title }); | |
| 310 | await db.prepare("UPDATE agent_sessions SET outputs = ? WHERE id = ?").bind(JSON.stringify(outputs.slice(-50)), row.id).run(); | |
| 311 | } | |
| 312 | } | |
| 313 | await update(filed); | |
| 314 | return done(`Filed ${draft.repo}#${filed.number}.`); | |
| 315 | } |