| 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync, readdirSync } from "node:fs"; |
| 3 | import { DatabaseSync } from "node:sqlite"; |
| 4 | import { test } from "node:test"; |
| 5 | |
| 6 | import type { User } from "@g1t/contracts"; |
| 7 | |
| 8 | import { 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`. */ |
| 11 | function 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 | |
| 27 | const WS = "wsp_acme"; |
| 28 | const owner = { id: "u_owner", kind: "user", username: "ana", verified: true, workspaces: [{ slug: "acme", role: "owner" }] } as unknown as User; |
| 29 | const member = { id: "u_mem", kind: "user", username: "mike", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User; |
| 30 | const other = { id: "u_other", kind: "user", username: "sam", verified: true, workspaces: [{ slug: "acme", role: "member" }] } as unknown as User; |
| 31 | |
| 32 | type 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. */ |
| 35 | function 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. */ |
| 45 | function 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 | |
| 73 | const 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 | |
| 75 | test("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 | |
| 95 | test("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 | |
| 109 | test("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 | |
| 119 | test("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 | |
| 133 | test("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 | |
| 148 | test("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 | |
| 176 | test("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 | }); |