Skip to content
160 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.

An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.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}