Skip to content
182 linesCodeBlameRaw
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});