|
| 1 | +/** |
| 2 | + * Wish retire-session-names-id-only Group 3 — Spawn writes ONE row. |
| 3 | + * |
| 4 | + * Asserts the post-G3 invariants: |
| 5 | + * 1. `register()` rejects bare-name ids loudly (UUID OR `dir:<name>` only). |
| 6 | + * 2. The legitimate spawn path (`findOrCreateAgent` → `register`) lands a |
| 7 | + * single UUID-keyed agents row — no bare-name shadow twin. |
| 8 | + * 3. `register()` accepts `dir:<name>` master-row ids. |
| 9 | + * |
| 10 | + * The bare-name shadow rejection is mirrored in migration 061's |
| 11 | + * `agents_id_shape_check`; this test covers the application-level guard so |
| 12 | + * the failure message is "loud throw at the call site" instead of a deep |
| 13 | + * SQL CHECK violation. |
| 14 | + */ |
| 15 | + |
| 16 | +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; |
| 17 | +import { findOrCreateAgent, list, register, unregister } from '../agent-registry.js'; |
| 18 | +import { getConnection } from '../db.js'; |
| 19 | +import { DB_AVAILABLE, setupTestDatabase } from '../test-db.js'; |
| 20 | + |
| 21 | +describe.skipIf(!DB_AVAILABLE)('spawn-single-row — wish retire-session-names-id-only G3', () => { |
| 22 | + let cleanup: () => Promise<void>; |
| 23 | + |
| 24 | + beforeAll(async () => { |
| 25 | + cleanup = await setupTestDatabase(); |
| 26 | + }); |
| 27 | + |
| 28 | + afterAll(async () => { |
| 29 | + await cleanup(); |
| 30 | + }); |
| 31 | + |
| 32 | + afterEach(async () => { |
| 33 | + const sql = await getConnection(); |
| 34 | + await sql`DELETE FROM executors`; |
| 35 | + await sql`DELETE FROM agents`; |
| 36 | + }); |
| 37 | + |
| 38 | + function makeRuntimeAgent(id: string, customName: string, team: string) { |
| 39 | + return { |
| 40 | + id, |
| 41 | + paneId: '%17', |
| 42 | + session: team, |
| 43 | + worktree: null, |
| 44 | + customName, |
| 45 | + role: customName, |
| 46 | + team, |
| 47 | + startedAt: new Date().toISOString(), |
| 48 | + state: 'spawning' as const, |
| 49 | + lastStateChange: new Date().toISOString(), |
| 50 | + repoPath: '/tmp/test', |
| 51 | + provider: 'claude' as const, |
| 52 | + transport: 'tmux' as const, |
| 53 | + }; |
| 54 | + } |
| 55 | + |
| 56 | + test('register rejects bare-name id with a useful error', async () => { |
| 57 | + const bareName = makeRuntimeAgent('engineer-4d48', 'engineer-4d48', 'genie'); |
| 58 | + let caught: Error | null = null; |
| 59 | + try { |
| 60 | + await register(bareName); |
| 61 | + } catch (err) { |
| 62 | + caught = err as Error; |
| 63 | + } |
| 64 | + expect(caught).not.toBeNull(); |
| 65 | + expect(caught!.message).toContain('non-UUID/non-dir agent id'); |
| 66 | + expect(caught!.message).toContain('findOrCreateAgent'); |
| 67 | + }); |
| 68 | + |
| 69 | + test('register accepts dir: master-row id', async () => { |
| 70 | + const master = makeRuntimeAgent('dir:engineer', 'engineer', 'genie'); |
| 71 | + await register(master); |
| 72 | + const sql = await getConnection(); |
| 73 | + const rows = await sql<{ id: string }[]>`SELECT id FROM agents WHERE id = 'dir:engineer'`; |
| 74 | + expect(rows.length).toBe(1); |
| 75 | + await unregister('dir:engineer'); |
| 76 | + }); |
| 77 | + |
| 78 | + test('full spawn path lands ONE UUID-keyed row (no bare-name shadow)', async () => { |
| 79 | + // Step 1 (mirroring agents.ts:resolveSpawnIdentity → findOrCreateAgent): |
| 80 | + // resolve the durable identity row keyed by (custom_name, team). |
| 81 | + const identity = await findOrCreateAgent('engineer', 'genie', 'engineer'); |
| 82 | + expect(identity.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); |
| 83 | + |
| 84 | + // Step 2 (mirroring agents.ts:registerSpawnWorker): register runtime fields |
| 85 | + // under the SAME UUID. workerId carries the human-readable label only. |
| 86 | + const workerId = 'engineer-4d48'; |
| 87 | + const runtime = makeRuntimeAgent(identity.id, workerId, 'genie'); |
| 88 | + await register(runtime); |
| 89 | + |
| 90 | + // Assertion 1: exactly ONE row exists for this (custom_name, team). |
| 91 | + const all = await list(); |
| 92 | + const matching = all.filter((a) => a.team === 'genie' && (a.customName === 'engineer' || a.id === identity.id)); |
| 93 | + expect(matching.length).toBe(1); |
| 94 | + |
| 95 | + // Assertion 2: the row is UUID-keyed (no bare-name shadow twin). |
| 96 | + const sql = await getConnection(); |
| 97 | + const shadowRows = await sql<{ id: string }[]>` |
| 98 | + SELECT id FROM agents |
| 99 | + WHERE id NOT LIKE 'dir:%' |
| 100 | + AND id !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' |
| 101 | + `; |
| 102 | + expect(shadowRows.length).toBe(0); |
| 103 | + |
| 104 | + // Assertion 3: runtime fields landed on the identity row (single-source). |
| 105 | + const refreshed = matching[0]; |
| 106 | + expect(refreshed.paneId).toBe('%17'); |
| 107 | + expect(refreshed.state).toBe('spawning'); |
| 108 | + expect(refreshed.id).toBe(identity.id); |
| 109 | + }); |
| 110 | + |
| 111 | + test('repeated register() against same identity is idempotent (no shadow twin)', async () => { |
| 112 | + const identity = await findOrCreateAgent('reviewer', 'genie', 'reviewer'); |
| 113 | + const runtime1 = makeRuntimeAgent(identity.id, 'reviewer-aaaa', 'genie'); |
| 114 | + const runtime2 = makeRuntimeAgent(identity.id, 'reviewer-bbbb', 'genie'); |
| 115 | + await register(runtime1); |
| 116 | + await register(runtime2); // ON CONFLICT (id) DO UPDATE merges runtime fields |
| 117 | + |
| 118 | + const all = await list(); |
| 119 | + const matching = all.filter((a) => a.team === 'genie' && a.customName === 'reviewer'); |
| 120 | + // Single row — register's ON CONFLICT (id) DO UPDATE is the upsert. |
| 121 | + // custom_name stays 'reviewer' from findOrCreateAgent (COALESCE preserves |
| 122 | + // the existing non-null value). |
| 123 | + expect(matching.length).toBe(1); |
| 124 | + expect(matching[0].id).toBe(identity.id); |
| 125 | + }); |
| 126 | +}); |
0 commit comments