Skip to content
182 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.1import assert from "node:assert/strict";
2import { readFileSync, readdirSync } from "node:fs";
3import { DatabaseSync } from "node:sqlite";
4import { test } from "node:test";
5
6import type { User } from "@g1t/contracts";
7
8import { DESTROYED, destroyRefusal, destroyStatements, listArchived, restoreHandle, restoreRefusal } from "./archived.ts";
9
10/** D1, as far as archived agents use it, over node's SQLite with the service's migrations: statements, `first`, `all`, `run` and `batch`. */
11function fakeD1(): D1Database & { raw: DatabaseSync } {
12 const db = new DatabaseSync(":memory:");
13 const dir = new URL("../migrations/", import.meta.url);
14 for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
15 const statement = (sql: string, params: unknown[] = []) => ({
16 bind: (...values: unknown[]) => statement(sql, values),
17 first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
18 run: async () => {
19 const r = db.prepare(sql).run(...(params as never[]));
20 return { meta: { changes: Number(r.changes) } };
21 },
22 all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
23 });
24 return { prepare: (sql: string) => statement(sql), batch: async (statements: { run: () => Promise<unknown> }[]) => Promise.all(statements.map((s) => s.run())), raw: db } as unknown as D1Database & { raw: DatabaseSync };
25}
26
27const WS = "wsp_acme";
28const owner = { id: "u_owner", kind: "user", username: "ana", verified: true, workspaces: [{ slug: "acme", role: "owner" }] } as unknown as User;
29const member = { id: "u_mem", kind: "user", username: "mike", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User;
30const other = { id: "u_other", kind: "user", username: "sam", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User;
31
32type Seed = { id: string; handle: string; archived_at?: string | null; archived_by?: string | null; scope?: string; owner_id?: string | null; owner_username?: string | null; builtin?: number };
33
34/** An agent row with what the queries read; everything else is the default. */
35function seed(db: { raw: DatabaseSync }, a: Seed): void {
36 db.raw
37 .prepare(
38 `INSERT INTO agents (id, workspace_id, handle, display_name, avatar, role, instructions, routing, budget, autonomy, title, created_by, created_at, updated_at, archived_at, archived_by, scope, owner_id, owner_username, builtin)
39 VALUES (?, ?, ?, ?, NULL, 'Helps', 'Help.', '{}', '{}', '{}', 'Helper', 'ana', '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z', ?, ?, ?, ?, ?, ?)`,
40 )
41 .run(a.id, WS, a.handle, a.handle[0]!.toUpperCase() + a.handle.slice(1), a.archived_at ?? null, a.archived_by ?? null, a.scope ?? "workspace", a.owner_id ?? null, a.owner_username ?? null, a.builtin ?? 0);
42}
43
44/** Rows about one agent across the tables a delete reaches, so the test sees them go. */
45function seedHistory(db: { raw: DatabaseSync }, agentId: string, n: number): void {
46 const r = db.raw;
47 r.prepare("INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at) VALUES (?, 1, '{}', 'ana', '2026-10-01T00:00:00Z')").run(agentId);
48 for (let i = 0; i < n; i++) {
49 r.prepare(
50 `INSERT INTO agent_sessions (id, workspace_id, agent_id, kind, root_id, payer_agent_id, title, goal, status, workspace, channel_id, channel_kind, created_at, updated_at)
51 VALUES (?1, ?2, ?3, 'chat', ?1, ?3, 'Did a thing', 'Do the thing', 'done', 'acme', 'chn_1', 'dm', '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`,
52 ).run(`ses_${agentId}_${i}`, WS, agentId);
53 r.prepare("INSERT INTO agent_session_events (session_id, seq, kind, body, created_at) VALUES (?, 1, 'asked', 'do it', '2026-10-02T00:00:00Z')").run(`ses_${agentId}_${i}`);
54 r.prepare(
55 `INSERT INTO agent_replies (id, agent_id, workspace_id, channel_id, message_id, status, created_at)
56 VALUES (?, ?, ?, 'chn_1', ?, 'done', '2026-10-02T00:00:00Z')`,
57 ).run(`rep_${agentId}_${i}`, agentId, WS, `msg_${agentId}_${i}`);
58 }
59 r.prepare(
60 `INSERT INTO agent_memories (id, agent_id, workspace_id, scope, scope_ref, body, source_kind, created_by, created_by_kind, created_at, updated_at)
61 VALUES (?, ?, ?, 'workspace', '', 'Deploys are on Tuesdays', 'chat', 'ana', 'user', '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`,
62 ).run(`mem_${agentId}`, agentId, WS);
63 r.prepare(
64 `INSERT INTO agent_routines (id, agent_id, workspace_id, workspace, name, instructions, channel_id, sponsor, enabled, created_at, updated_at)
65 VALUES (?, ?, ?, 'acme', 'Standup', 'Post the standup', 'chn_1', 'u_owner', 1, '2026-10-02T00:00:00Z', '2026-10-02T00:00:00Z')`,
66 ).run(`rtn_${agentId}`, agentId, WS);
67 r.prepare("INSERT INTO agent_routine_runs (routine_id, run_key, created_at) VALUES (?, 'day:2026-10-03', '2026-10-03T00:00:00Z')").run(`rtn_${agentId}`);
68 r.prepare("INSERT INTO agent_spend (agent_id, period, micros, replies) VALUES (?, '2026-10', 1200000, 3)").run(agentId);
69 r.prepare("INSERT OR IGNORE INTO workspace_agent_spend (workspace_id, period, micros) VALUES (?, '2026-10', 1200000)").run(WS);
70 r.prepare("INSERT INTO skill_attachments (id, workspace_id, skill_id, scope, target, version, attached_by, attached_at) VALUES (?, ?, 'skl_1', 'agent', ?, 1, 'ana', '2026-10-02T00:00:00Z')").run(`att_${agentId}`, WS, agentId);
71}
72
73const count = (db: { raw: DatabaseSync }, table: string, where: string, ...params: unknown[]) => Number((db.raw.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get(...(params as never[])) as { n: number }).n);
74
75test("the archived list has archived agents only, newest archived first, with what deleting each would take", async () => {
76 const db = fakeD1();
77 seed(db, { id: "agt_live", handle: "live" });
78 seed(db, { id: "agt_old", handle: "old", archived_at: "2026-09-01T00:00:00Z", archived_by: "ana" });
79 seed(db, { id: "agt_new", handle: "new", archived_at: "2026-10-05T00:00:00Z", archived_by: "mike" });
80 seed(db, { id: "agt_g1t", handle: "g1t", builtin: 1 });
81 seedHistory(db, "agt_new", 2);
82 const listed = await listArchived(db, WS, { id: owner.id, owner: true });
83 assert.deepEqual(
84 listed.map((a) => [a.handle, a.archived_by, a.counts]),
85 [
86 ["new", "mike", { sessions: 2, replies: 2, memories: 1, routines: 1 }],
87 ["old", "ana", { sessions: 0, replies: 0, memories: 0, routines: 0 }],
88 ],
89 );
90 assert.equal(listed[0]!.display_name, "New");
91 assert.equal(listed[0]!.avatar_seed, "new");
92 assert.deepEqual(await listArchived(db, "wsp_other", { id: owner.id, owner: true }), []);
93});
94
95test("a member sees only their own archived personal agents", async () => {
96 const db = fakeD1();
97 seed(db, { id: "agt_ws", handle: "ws", archived_at: "2026-10-01T00:00:00Z" });
98 seed(db, { id: "agt_mine", handle: "mine", archived_at: "2026-10-02T00:00:00Z", scope: "personal", owner_id: member.id, owner_username: "mike" });
99 seed(db, { id: "agt_theirs", handle: "theirs", archived_at: "2026-10-03T00:00:00Z", scope: "personal", owner_id: other.id, owner_username: "sam" });
100 const mine = await listArchived(db, WS, { id: member.id, owner: false });
101 assert.deepEqual(
102 mine.map((a) => [a.handle, a.scope, a.personal_owner]),
103 [["mine", "personal", "mike"]],
104 );
105 // Owners see every member's, and the workspace's.
106 assert.deepEqual((await listArchived(db, WS, { id: owner.id, owner: true })).map((a) => a.handle), ["theirs", "mine", "ws"]);
107});
108
109test("restoring: owners any agent, a member their own personal one, and only an archived one", () => {
110 const archived = { handle: "sam", archived_at: "2026-10-01T00:00:00Z", scope: "workspace" };
111 assert.equal(restoreRefusal(owner, "acme", archived), null);
112 assert.equal(restoreRefusal(member, "acme", archived)?.code, "forbidden");
113 const personal = { handle: "me", archived_at: "2026-10-01T00:00:00Z", scope: "personal", owner_id: member.id };
114 assert.equal(restoreRefusal(member, "acme", personal), null);
115 assert.equal(restoreRefusal(other, "acme", personal)?.code, "forbidden");
116 assert.equal(restoreRefusal(owner, "acme", { ...archived, archived_at: null })?.code, "invalid");
117});
118
119test("restoring under a handle a live agent holds is a conflict that says what to do; a new handle is checked", () => {
120 const agent = { handle: "sam", archived_at: "2026-10-01T00:00:00Z" };
121 const taken = (h: string) => h === "sam" || h === "ana";
122 const conflict = restoreHandle(agent, null, taken);
123 assert.equal(!conflict.ok && conflict.error.code, "conflict");
124 assert.match(!conflict.ok ? conflict.error.message : "", /Rename that one first, or restore this one under a new handle/);
125 const again = restoreHandle(agent, "ana", taken);
126 assert.match(!again.ok ? again.error.message : "", /already called @ana. Pick another/);
127 assert.deepEqual(restoreHandle(agent, "@Sam-2", taken), { ok: true, handle: "sam-2" });
128 assert.deepEqual(restoreHandle(agent, "", () => false), { ok: true, handle: "sam" });
129 const bad = restoreHandle(agent, "a", taken);
130 assert.equal(!bad.ok && bad.error.code, "invalid");
131});
132
133test("deleting refuses a live agent, the built-in @g1t, the wrong person and a confirmation that is not the handle", () => {
134 const archived = { handle: "sam", archived_at: "2026-10-01T00:00:00Z", scope: "workspace" };
135 assert.equal(destroyRefusal(owner, "acme", archived, "sam"), null);
136 assert.equal(destroyRefusal(owner, "acme", archived, "@Sam "), null);
137 assert.match(destroyRefusal(owner, "acme", { ...archived, archived_at: null }, "sam")?.message ?? "", /Archive @sam first/);
138 assert.equal(destroyRefusal(owner, "acme", { ...archived, archived_at: null }, "sam")?.code, "invalid");
139 assert.match(destroyRefusal(owner, "acme", { ...archived, handle: "g1t", builtin: 1 }, "g1t")?.message ?? "", /can't be deleted/);
140 assert.equal(destroyRefusal(member, "acme", archived, "sam")?.code, "forbidden");
141 assert.equal(destroyRefusal(owner, "acme", archived, "same")?.code, "invalid");
142 assert.equal(destroyRefusal(owner, "acme", archived, undefined)?.code, "invalid");
143 const personal = { handle: "me", archived_at: "2026-10-01T00:00:00Z", scope: "personal", owner_id: member.id };
144 assert.equal(destroyRefusal(member, "acme", personal, "me"), null);
145 assert.equal(destroyRefusal(other, "acme", personal, "me")?.code, "forbidden");
146});
147
148test("deleting removes every row about the agent, and nothing of another's or of the workspace's totals", async () => {
149 const db = fakeD1();
150 seed(db, { id: "agt_gone", handle: "gone", archived_at: "2026-10-01T00:00:00Z" });
151 seed(db, { id: "agt_kept", handle: "kept" });
152 seedHistory(db, "agt_gone", 3);
153 seedHistory(db, "agt_kept", 1);
154 const results = (await db.batch(destroyStatements(db, "agt_gone"))) as { meta: { changes: number } }[];
155 assert.equal(results.length, DESTROYED.length);
156 assert.equal(results[results.length - 1]!.meta.changes, 1, "the agents row went last");
157 for (const { table } of DESTROYED) {
158 const column = table === "agents" ? "id" : table === "skill_attachments" ? "target" : table === "agent_session_events" ? "session_id" : table === "agent_routine_runs" ? "routine_id" : "agent_id";
159 const pattern = table === "agent_session_events" ? "ses_agt_gone_%" : table === "agent_routine_runs" ? "rtn_agt_gone" : "agt_gone";
160 assert.equal(count(db, table, `${column} LIKE ?`, pattern), 0, `${table} still has rows about the deleted agent`);
161 }
162 // The other agent is untouched, and the workspace's month total stays for the statement.
163 assert.equal(count(db, "agents", "id = ?", "agt_kept"), 1);
164 assert.equal(count(db, "agent_sessions", "agent_id = ?", "agt_kept"), 1);
165 assert.equal(count(db, "agent_session_events", "session_id LIKE ?", "ses_agt_kept_%"), 1);
166 assert.equal(count(db, "agent_replies", "agent_id = ?", "agt_kept"), 1);
167 assert.equal(count(db, "agent_memories", "agent_id = ?", "agt_kept"), 1);
168 assert.equal(count(db, "agent_routine_runs", "routine_id = ?", "rtn_agt_kept"), 1);
169 assert.equal(count(db, "skill_attachments", "target = ?", "agt_kept"), 1);
170 assert.equal(count(db, "workspace_agent_spend", "workspace_id = ?", WS), 1);
171 // A second delete finds nothing: the agents row is gone.
172 const again = (await db.batch(destroyStatements(db, "agt_gone"))) as { meta: { changes: number } }[];
173 assert.equal(again[again.length - 1]!.meta.changes, 0);
174});
175
176test("deleting leaves a live agent's row alone even when asked by id", async () => {
177 const db = fakeD1();
178 seed(db, { id: "agt_live", handle: "live" });
179 const results = (await db.batch(destroyStatements(db, "agt_live"))) as { meta: { changes: number } }[];
180 assert.equal(results[results.length - 1]!.meta.changes, 0);
181 assert.equal(count(db, "agents", "id = ?", "agt_live"), 1);
182});