Skip to content
160 linesCodeBlameRaw
1/**
2 * Archived agents (docs.g1t.sh/guides/agents/, "Archive, restore and
3 * delete"): the Archived section's list, bringing one back, and deleting
4 * one for good. Archiving itself is `archive` in index.ts.
5 *
6 * An archived agent is out of every list, every lookup by handle and every
7 * conversation's wake, but its row and everything it did stay. Restoring
8 * clears `archived_at`, under its old handle or a new one if a live agent
9 * took the old. Deleting removes every row the service keeps about it, in
10 * one batch (`destroyStatements`), and index.ts then tells the runner and
11 * chat. What stays: its messages in chat (under the name it had), the
12 * workspace's month totals (`workspace_agent_spend`), and the audit log.
13 *
14 * The decisions are pure and tested on their own (archived.test.ts); the
15 * reads and the batch take the database.
16 */
17import type { ArchivedAgent, User } from "@g1t/contracts";
18
19// Relative, with extensions, so Node runs the tests on this file as it is.
20import { readLook } from "../../../packages/contracts/src/agent-look.ts";
21import type { Failure, FailureCode } from "../../../packages/contracts/src/result.ts";
22import { MANAGE_REFUSAL, type Owned, canArchive, canManage, isOwnerOf } from "./access.ts";
23import { checkHandle } from "./handle.ts";
24
25const refusal = (code: FailureCode, message: string): Failure => ({ code, message });
26
27type Viewer = Pick<User, "kind" | "username" | "workspaces" | "verified"> & { id?: string };
28
29/** What the decisions read of an agent's row. */
30export type ArchivedLike = Owned & { handle: string; archived_at: string | null };
31
32/** The refusal for restoring `agent`, or null when `viewer` may. */
33export function restoreRefusal(viewer: Viewer | null | undefined, workspace: string, agent: ArchivedLike): Failure | null {
34 if (!agent.archived_at) return refusal("invalid", `@${agent.handle} isn't archived.`);
35 if (!canArchive(viewer, workspace, agent)) return refusal("forbidden", MANAGE_REFUSAL);
36 return null;
37}
38
39/**
40 * The refusal for deleting `agent` for good, or null when `viewer` may:
41 * owners any agent, a member their own personal one; never the built-in
42 * @g1t; only an archived agent, and only with its handle typed back.
43 */
44export function destroyRefusal(viewer: Viewer | null | undefined, workspace: string, agent: ArchivedLike, confirm: unknown): Failure | null {
45 if (agent.builtin) return refusal("invalid", "@g1t is every workspace's orchestrator and can't be deleted.");
46 if (!canManage(viewer, workspace) && !isOwnerOf(viewer, agent)) return refusal("forbidden", "Only the workspace's owners delete an agent, and a member their own personal agent.");
47 if (!agent.archived_at) return refusal("invalid", `Archive @${agent.handle} first. Deleting is for archived agents, so nothing goes by accident.`);
48 if (typeof confirm !== "string" || confirm.trim().replace(/^@/, "").toLowerCase() !== agent.handle) {
49 return refusal("invalid", `Type @${agent.handle} to confirm.`);
50 }
51 return null;
52}
53
54/**
55 * The handle an agent is restored under: its own, or `given` when the
56 * person chose a new one. `conflict` when the handle is taken by a live
57 * agent (`taken` says so), with what to do about it.
58 */
59export function restoreHandle(agent: ArchivedLike, given: unknown, taken: (handle: string) => boolean): { ok: true; handle: string } | { ok: false; error: Failure } {
60 let handle = agent.handle;
61 if (given !== undefined && given !== null && given !== "") {
62 const checked = checkHandle(given);
63 if (!checked.ok) return { ok: false, error: refusal("invalid", checked.message) };
64 handle = checked.handle;
65 }
66 if (taken(handle)) {
67 return {
68 ok: false,
69 error: refusal(
70 "conflict",
71 handle === agent.handle
72 ? `A live agent is called @${handle} now. Rename that one first, or restore this one under a new handle.`
73 : `A live agent is already called @${handle}. Pick another handle.`,
74 ),
75 };
76 }
77 return { ok: true, handle };
78}
79
80/** The table and the column each delete runs on, so the docs and the tests say the same. */
81export const DESTROYED: readonly { table: string; where: string }[] = [
82 { table: "agent_session_events", where: "session_id IN (SELECT id FROM agent_sessions WHERE agent_id = ?)" },
83 { table: "agent_sessions", where: "agent_id = ?" },
84 { table: "agent_routine_runs", where: "routine_id IN (SELECT id FROM agent_routines WHERE agent_id = ?)" },
85 { table: "agent_routines", where: "agent_id = ?" },
86 { table: "agent_replies", where: "agent_id = ?" },
87 { table: "agent_tool_calls", where: "agent_id = ?" },
88 { table: "agent_memories", where: "agent_id = ?" },
89 { table: "agent_drafts", where: "agent_id = ?" },
90 { table: "agent_recommendations", where: "agent_id = ?" },
91 { table: "agent_ability_requests", where: "agent_id = ?" },
92 { table: "skill_attachments", where: "scope = 'agent' AND target = ?" },
93 { table: "agent_spend", where: "agent_id = ?" },
94 { table: "agent_versions", where: "agent_id = ?" },
95 // Last, and only while still archived: a restore that raced the delete keeps the agent and all of the above is already gone for it.
96 { table: "agents", where: "id = ? AND archived_at IS NOT NULL" },
97];
98
99/** Every row the service keeps about one agent, deleted in one batch. The `agents` row goes last. */
100export function destroyStatements(db: D1Database, agentId: string): D1PreparedStatement[] {
101 return DESTROYED.map(({ table, where }) => db.prepare(`DELETE FROM ${table} WHERE ${where}`).bind(agentId));
102}
103
104type ArchivedRow = {
105 id: string;
106 handle: string;
107 display_name: string;
108 title: string | null;
109 role: string;
110 avatar: string | null;
111 avatar_seed: string | null;
112 look: string | null;
113 scope: string | null;
114 owner_id: string | null;
115 owner_username: string | null;
116 archived_at: string;
117 archived_by: string | null;
118 sessions: number;
119 replies: number;
120 memories: number;
121 routines: number;
122};
123
124/**
125 * The workspace's archived agents, newest archived first: every one for an
126 * owner, the viewer's own archived personal agents for anyone else. Each
127 * with what deleting it would take: its sessions, replies, memories and
128 * routines, counted in the same read.
129 */
130export async function listArchived(db: D1Database, workspaceId: string, viewer: { id: string; owner: boolean }): Promise<ArchivedAgent[]> {
131 const rows = await db
132 .prepare(
133 `SELECT a.id, a.handle, a.display_name, a.title, a.role, a.avatar, a.avatar_seed, a.look, a.scope, a.owner_id, a.owner_username, a.archived_at, a.archived_by,
134 (SELECT COUNT(*) FROM agent_sessions s WHERE s.agent_id = a.id) AS sessions,
135 (SELECT COUNT(*) FROM agent_replies r WHERE r.agent_id = a.id) AS replies,
136 (SELECT COUNT(*) FROM agent_memories m WHERE m.agent_id = a.id) AS memories,
137 (SELECT COUNT(*) FROM agent_routines t WHERE t.agent_id = a.id) AS routines
138 FROM agents a
139 WHERE a.workspace_id = ?1 AND a.archived_at IS NOT NULL AND a.builtin = 0
140 AND (?2 = 1 OR (a.scope = 'personal' AND a.owner_id = ?3))
141 ORDER BY a.archived_at DESC, a.handle`,
142 )
143 .bind(workspaceId, viewer.owner ? 1 : 0, viewer.id)
144 .all<ArchivedRow>();
145 return rows.results.map((row) => ({
146 id: row.id,
147 handle: row.handle,
148 display_name: row.display_name,
149 title: row.title ?? "",
150 role: row.role,
151 avatar: row.avatar,
152 avatar_seed: row.avatar_seed || row.handle,
153 look: readLook(row.look),
154 scope: row.scope === "personal" ? "personal" : "workspace",
155 personal_owner: row.scope === "personal" ? (row.owner_username ?? null) : null,
156 archived_at: row.archived_at,
157 archived_by: row.archived_by ?? null,
158 counts: { sessions: row.sessions ?? 0, replies: row.replies ?? 0, memories: row.memories ?? 0, routines: row.routines ?? 0 },
159 }));
160}