| 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 { AdoptedAgentTeam } from "@g1t/contracts"; |
| 7 | |
| 8 | import { claimsByWorkspace, moveAgentTeams } from "./team-move.ts"; |
| 9 | |
| 10 | /** D1 over node's SQLite with the service's migrations. */ |
| 11 | function fakeD1(): D1Database { |
| 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[] = []): any => ({ |
| 16 | sql, |
| 17 | params, |
| 18 | bind: (...values: unknown[]) => statement(sql, values), |
| 19 | first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null, |
| 20 | run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...(params as never[])).changes) } }), |
| 21 | all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }), |
| 22 | }); |
| 23 | return { prepare: (sql: string) => statement(sql) } as unknown as D1Database; |
| 24 | } |
| 25 | |
| 26 | const at = "2026-10-10T12:00:00Z"; |
| 27 | |
| 28 | async function addAgent(db: D1Database, id: string, workspace: string, team: string | null, extra: { scope?: string; archived?: boolean } = {}): Promise<void> { |
| 29 | await db |
| 30 | .prepare( |
| 31 | `INSERT INTO agents (id, workspace_id, handle, display_name, role, instructions, routing, budget, autonomy, created_by, created_at, updated_at, team, scope, archived_at) |
| 32 | VALUES (?, ?, ?, ?, 'r', 'i', '{}', '{}', '{}', 'ana', ?, ?, ?, ?, ?)`, |
| 33 | ) |
| 34 | .bind(id, workspace, id, id, at, at, team, extra.scope ?? "workspace", extra.archived ? at : null) |
| 35 | .run(); |
| 36 | } |
| 37 | |
| 38 | /** Identity's side: teams by workspace, and the memberships it holds. */ |
| 39 | function identity(teams: Record<string, string[]>) { |
| 40 | const on = new Set<string>(); |
| 41 | const calls: string[] = []; |
| 42 | const adopt = async (workspaceId: string, agents: { agent_id: string; team: string }[]): Promise<AdoptedAgentTeam[]> => { |
| 43 | calls.push(workspaceId); |
| 44 | return agents.map(({ agent_id, team }) => { |
| 45 | const slug = team.trim().toLowerCase(); |
| 46 | if (!(teams[workspaceId] ?? []).includes(slug)) return { agent_id, team, outcome: "no_team" }; |
| 47 | const key = `${workspaceId}/${slug}/${agent_id}`; |
| 48 | if (on.has(key)) return { agent_id, team, outcome: "already" }; |
| 49 | on.add(key); |
| 50 | return { agent_id, team, outcome: "added" }; |
| 51 | }); |
| 52 | }; |
| 53 | return { adopt, on, calls }; |
| 54 | } |
| 55 | |
| 56 | test("only workspace agents that aren't archived join a team", () => { |
| 57 | const claims = claimsByWorkspace([ |
| 58 | { id: "agt_a", workspace_id: "w1", team: "qa", scope: "workspace", archived_at: null }, |
| 59 | { id: "agt_b", workspace_id: "w1", team: "qa", scope: "personal", archived_at: null }, |
| 60 | { id: "agt_c", workspace_id: "w2", team: "sales", scope: null, archived_at: null }, |
| 61 | { id: "agt_d", workspace_id: "w2", team: "sales", scope: "workspace", archived_at: at }, |
| 62 | { id: "agt_e", workspace_id: "w2", team: " ", scope: "workspace", archived_at: null }, |
| 63 | ]); |
| 64 | assert.deepEqual([...claims], [ |
| 65 | ["w1", [{ agent_id: "agt_a", team: "qa" }]], |
| 66 | ["w2", [{ agent_id: "agt_c", team: "sales" }]], |
| 67 | ]); |
| 68 | }); |
| 69 | |
| 70 | test("agents' old teams become memberships once: matched by slug, the rest dropped, a second run does nothing", async () => { |
| 71 | const db = fakeD1(); |
| 72 | await addAgent(db, "agt_margo", "w1", "QA"); |
| 73 | await addAgent(db, "agt_david", "w1", "sales"); |
| 74 | await addAgent(db, "agt_pax", "w1", "billing"); |
| 75 | await addAgent(db, "agt_otto", "w2", "qa"); |
| 76 | await addAgent(db, "agt_mine", "w1", "qa", { scope: "personal" }); |
| 77 | await addAgent(db, "agt_none", "w1", null); |
| 78 | const fake = identity({ w1: ["qa", "sales"], w2: [] }); |
| 79 | const first = await moveAgentTeams(db, fake.adopt, new Date(at)); |
| 80 | assert.equal(first.added, 2); |
| 81 | assert.deepEqual(first.dropped.map((d) => d.agent_id).sort(), ["agt_otto", "agt_pax"]); |
| 82 | assert.equal(first.seen, 5, "the personal agent is marked without joining"); |
| 83 | assert.deepEqual([...fake.on].sort(), ["w1/qa/agt_margo", "w1/sales/agt_david"]); |
| 84 | const second = await moveAgentTeams(db, fake.adopt, new Date(at)); |
| 85 | assert.deepEqual(second, { seen: 0, added: 0, already: 0, dropped: [] }); |
| 86 | assert.equal(fake.calls.length, 2, "identity is asked once per workspace, and never again"); |
| 87 | }); |
| 88 | |
| 89 | test("a workspace identity couldn't answer for is asked again next run", async () => { |
| 90 | const db = fakeD1(); |
| 91 | await addAgent(db, "agt_margo", "w1", "qa"); |
| 92 | let fail = true; |
| 93 | const fake = identity({ w1: ["qa"] }); |
| 94 | const adopt = async (id: string, agents: { agent_id: string; team: string }[]) => { |
| 95 | if (fail) throw new Error("identity is down"); |
| 96 | return fake.adopt(id, agents); |
| 97 | }; |
| 98 | assert.equal((await moveAgentTeams(db, adopt)).seen, 0); |
| 99 | fail = false; |
| 100 | assert.equal((await moveAgentTeams(db, adopt)).added, 1); |
| 101 | }); |