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 | */ | |
| 17 | import type { ArchivedAgent, User } from "@g1t/contracts"; | |
| 18 | ||
| 19 | // Relative, with extensions, so Node runs the tests on this file as it is. | |
| 20 | import { readLook } from "../../../packages/contracts/src/agent-look.ts"; | |
| 21 | import type { Failure, FailureCode } from "../../../packages/contracts/src/result.ts"; | |
| 22 | import { MANAGE_REFUSAL, type Owned, canArchive, canManage, isOwnerOf } from "./access.ts"; | |
| 23 | import { checkHandle } from "./handle.ts"; | |
| 24 | ||
| 25 | const refusal = (code: FailureCode, message: string): Failure => ({ code, message }); | |
| 26 | ||
| 27 | type Viewer = Pick<User, "kind" | "username" | "workspaces" | "verified"> & { id?: string }; | |
| 28 | ||
| 29 | /** What the decisions read of an agent's row. */ | |
| 30 | export type ArchivedLike = Owned & { handle: string; archived_at: string | null }; | |
| 31 | ||
| 32 | /** The refusal for restoring `agent`, or null when `viewer` may. */ | |
| 33 | export 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 | */ | |
| 44 | export 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 | */ | |
| 59 | export 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. */ | |
| 81 | export 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. */ | |
| 100 | export function destroyStatements(db: D1Database, agentId: string): D1PreparedStatement[] { | |
| 101 | return DESTROYED.map(({ table, where }) => db.prepare(`DELETE FROM ${table} WHERE ${where}`).bind(agentId)); | |
| 102 | } | |
| 103 | ||
| 104 | type 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 | */ | |
| 130 | export 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 | } |