From 25f98fcbe1358cfc6cfb7421a609e64ef5117865 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 20:01:00 +0100 Subject: [PATCH] feat(04-01): create DrizzleAgentRepository adapter with tests - Implement DrizzleAgentRepository with all AgentRepository methods - Add findByName, findByTaskId, findBySessionId lookups - Add findByStatus for filtering including waiting_for_input - Add updateStatus and updateSessionId for state changes - Add agents table to test-helpers.ts SQL - Export DrizzleAgentRepository from drizzle/index.ts - 22 tests covering all operations and edge cases --- src/db/repositories/agent-repository.ts | 16 +- src/db/repositories/drizzle/agent.test.ts | 299 ++++++++++++++++++++ src/db/repositories/drizzle/agent.ts | 132 +++++++++ src/db/repositories/drizzle/index.ts | 1 + src/db/repositories/drizzle/test-helpers.ts | 12 + src/db/repositories/index.ts | 6 +- 6 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 src/db/repositories/drizzle/agent.test.ts create mode 100644 src/db/repositories/drizzle/agent.ts diff --git a/src/db/repositories/agent-repository.ts b/src/db/repositories/agent-repository.ts index 7c1e785..daa0a91 100644 --- a/src/db/repositories/agent-repository.ts +++ b/src/db/repositories/agent-repository.ts @@ -5,13 +5,25 @@ * Implementations (Drizzle, etc.) are adapters. */ -import type { Agent, NewAgent } from '../schema.js'; +import type { Agent } from '../schema.js'; /** * Agent status values. */ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' | 'crashed'; +/** + * Data for creating a new agent. + * Omits system-managed fields and makes optional fields explicit. + */ +export interface CreateAgentData { + name: string; + worktreeId: string; + taskId?: string | null; + sessionId?: string | null; + status?: AgentStatus; +} + /** * Agent Repository Port * @@ -24,7 +36,7 @@ export interface AgentRepository { * Generates id and sets timestamps automatically. * Name must be unique. */ - create(agent: Omit): Promise; + create(agent: CreateAgentData): Promise; /** * Find an agent by its ID. diff --git a/src/db/repositories/drizzle/agent.test.ts b/src/db/repositories/drizzle/agent.test.ts new file mode 100644 index 0000000..9caeeb9 --- /dev/null +++ b/src/db/repositories/drizzle/agent.test.ts @@ -0,0 +1,299 @@ +/** + * DrizzleAgentRepository Tests + * + * Tests for the Agent repository adapter. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DrizzleAgentRepository } from './agent.js'; +import { DrizzleTaskRepository } from './task.js'; +import { DrizzlePlanRepository } from './plan.js'; +import { DrizzlePhaseRepository } from './phase.js'; +import { DrizzleInitiativeRepository } from './initiative.js'; +import { createTestDatabase } from './test-helpers.js'; +import type { DrizzleDatabase } from '../../index.js'; + +describe('DrizzleAgentRepository', () => { + let db: DrizzleDatabase; + let agentRepo: DrizzleAgentRepository; + let taskRepo: DrizzleTaskRepository; + let planRepo: DrizzlePlanRepository; + let phaseRepo: DrizzlePhaseRepository; + let initiativeRepo: DrizzleInitiativeRepository; + let testTaskId: string; + + beforeEach(async () => { + db = createTestDatabase(); + agentRepo = new DrizzleAgentRepository(db); + taskRepo = new DrizzleTaskRepository(db); + planRepo = new DrizzlePlanRepository(db); + phaseRepo = new DrizzlePhaseRepository(db); + initiativeRepo = new DrizzleInitiativeRepository(db); + + // Create full hierarchy for FK constraint + const initiative = await initiativeRepo.create({ + name: 'Test Initiative', + }); + const phase = await phaseRepo.create({ + initiativeId: initiative.id, + number: 1, + name: 'Test Phase', + }); + const plan = await planRepo.create({ + phaseId: phase.id, + number: 1, + name: 'Test Plan', + }); + const task = await taskRepo.create({ + planId: plan.id, + name: 'Test Task', + order: 1, + }); + testTaskId = task.id; + }); + + describe('create', () => { + it('should create an agent with generated id and timestamps', async () => { + const agent = await agentRepo.create({ + name: 'gastown', + worktreeId: 'worktree-123', + taskId: testTaskId, + }); + + expect(agent.id).toBeDefined(); + expect(agent.id.length).toBeGreaterThan(0); + expect(agent.name).toBe('gastown'); + expect(agent.worktreeId).toBe('worktree-123'); + expect(agent.taskId).toBe(testTaskId); + expect(agent.sessionId).toBeNull(); + expect(agent.status).toBe('idle'); + expect(agent.createdAt).toBeInstanceOf(Date); + expect(agent.updatedAt).toBeInstanceOf(Date); + }); + + it('should create agent without taskId', async () => { + const agent = await agentRepo.create({ + name: 'standalone', + worktreeId: 'worktree-456', + }); + + expect(agent.taskId).toBeNull(); + }); + + it('should reject duplicate names', async () => { + await agentRepo.create({ + name: 'unique-name', + worktreeId: 'worktree-1', + }); + + await expect( + agentRepo.create({ + name: 'unique-name', + worktreeId: 'worktree-2', + }) + ).rejects.toThrow(); + }); + }); + + describe('findById', () => { + it('should return null for non-existent agent', async () => { + const result = await agentRepo.findById('non-existent-id'); + expect(result).toBeNull(); + }); + + it('should find an existing agent', async () => { + const created = await agentRepo.create({ + name: 'findme', + worktreeId: 'worktree-123', + }); + + const found = await agentRepo.findById(created.id); + expect(found).not.toBeNull(); + expect(found!.id).toBe(created.id); + expect(found!.name).toBe('findme'); + }); + }); + + describe('findByName', () => { + it('should return null for non-existent name', async () => { + const result = await agentRepo.findByName('nonexistent'); + expect(result).toBeNull(); + }); + + it('should find agent by human-readable name', async () => { + await agentRepo.create({ + name: 'chinatown', + worktreeId: 'worktree-123', + }); + + const found = await agentRepo.findByName('chinatown'); + expect(found).not.toBeNull(); + expect(found!.name).toBe('chinatown'); + }); + }); + + describe('findByTaskId', () => { + it('should return null when no agent assigned to task', async () => { + const result = await agentRepo.findByTaskId(testTaskId); + expect(result).toBeNull(); + }); + + it('should find agent by task', async () => { + await agentRepo.create({ + name: 'task-agent', + worktreeId: 'worktree-123', + taskId: testTaskId, + }); + + const found = await agentRepo.findByTaskId(testTaskId); + expect(found).not.toBeNull(); + expect(found!.taskId).toBe(testTaskId); + }); + }); + + describe('findBySessionId', () => { + it('should return null when no agent has session', async () => { + const result = await agentRepo.findBySessionId('session-123'); + expect(result).toBeNull(); + }); + + it('should find agent by session ID', async () => { + const agent = await agentRepo.create({ + name: 'session-agent', + worktreeId: 'worktree-123', + }); + await agentRepo.updateSessionId(agent.id, 'session-abc'); + + const found = await agentRepo.findBySessionId('session-abc'); + expect(found).not.toBeNull(); + expect(found!.sessionId).toBe('session-abc'); + }); + }); + + describe('findAll', () => { + it('should return empty array when no agents', async () => { + const agents = await agentRepo.findAll(); + expect(agents).toEqual([]); + }); + + it('should return all agents', async () => { + await agentRepo.create({ name: 'agent-1', worktreeId: 'wt-1' }); + await agentRepo.create({ name: 'agent-2', worktreeId: 'wt-2' }); + await agentRepo.create({ name: 'agent-3', worktreeId: 'wt-3' }); + + const agents = await agentRepo.findAll(); + expect(agents.length).toBe(3); + }); + }); + + describe('findByStatus', () => { + it('should return empty array when no agents have status', async () => { + const agents = await agentRepo.findByStatus('running'); + expect(agents).toEqual([]); + }); + + it('should filter by status correctly', async () => { + const agent1 = await agentRepo.create({ + name: 'idle-agent', + worktreeId: 'wt-1', + }); + const agent2 = await agentRepo.create({ + name: 'running-agent', + worktreeId: 'wt-2', + }); + await agentRepo.updateStatus(agent2.id, 'running'); + + const idleAgents = await agentRepo.findByStatus('idle'); + const runningAgents = await agentRepo.findByStatus('running'); + + expect(idleAgents.length).toBe(1); + expect(idleAgents[0].name).toBe('idle-agent'); + expect(runningAgents.length).toBe(1); + expect(runningAgents[0].name).toBe('running-agent'); + }); + + it('should filter by waiting_for_input status', async () => { + const agent = await agentRepo.create({ + name: 'waiting-agent', + worktreeId: 'wt-1', + }); + await agentRepo.updateStatus(agent.id, 'waiting_for_input'); + + const waitingAgents = await agentRepo.findByStatus('waiting_for_input'); + expect(waitingAgents.length).toBe(1); + expect(waitingAgents[0].status).toBe('waiting_for_input'); + }); + }); + + describe('updateStatus', () => { + it('should change status and updatedAt', async () => { + const created = await agentRepo.create({ + name: 'status-test', + worktreeId: 'wt-1', + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const updated = await agentRepo.updateStatus(created.id, 'running'); + + expect(updated.status).toBe('running'); + expect(updated.updatedAt.getTime()).toBeGreaterThan( + created.updatedAt.getTime() + ); + }); + + it('should throw for non-existent agent', async () => { + await expect( + agentRepo.updateStatus('non-existent-id', 'running') + ).rejects.toThrow('Agent not found'); + }); + }); + + describe('updateSessionId', () => { + it('should change sessionId and updatedAt', async () => { + const created = await agentRepo.create({ + name: 'session-test', + worktreeId: 'wt-1', + }); + expect(created.sessionId).toBeNull(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const updated = await agentRepo.updateSessionId( + created.id, + 'new-session-id' + ); + + expect(updated.sessionId).toBe('new-session-id'); + expect(updated.updatedAt.getTime()).toBeGreaterThan( + created.updatedAt.getTime() + ); + }); + + it('should throw for non-existent agent', async () => { + await expect( + agentRepo.updateSessionId('non-existent-id', 'session') + ).rejects.toThrow('Agent not found'); + }); + }); + + describe('delete', () => { + it('should delete an existing agent', async () => { + const created = await agentRepo.create({ + name: 'to-delete', + worktreeId: 'wt-1', + }); + + await agentRepo.delete(created.id); + + const found = await agentRepo.findById(created.id); + expect(found).toBeNull(); + }); + + it('should throw for non-existent agent', async () => { + await expect(agentRepo.delete('non-existent-id')).rejects.toThrow( + 'Agent not found' + ); + }); + }); +}); diff --git a/src/db/repositories/drizzle/agent.ts b/src/db/repositories/drizzle/agent.ts new file mode 100644 index 0000000..d398bc1 --- /dev/null +++ b/src/db/repositories/drizzle/agent.ts @@ -0,0 +1,132 @@ +/** + * Drizzle Agent Repository Adapter + * + * Implements AgentRepository interface using Drizzle ORM. + */ + +import { eq } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import type { DrizzleDatabase } from '../../index.js'; +import { agents, type Agent } from '../../schema.js'; +import type { + AgentRepository, + AgentStatus, + CreateAgentData, +} from '../agent-repository.js'; + +/** + * Drizzle adapter for AgentRepository. + * + * Uses dependency injection for database instance, + * enabling isolated test databases. + */ +export class DrizzleAgentRepository implements AgentRepository { + constructor(private db: DrizzleDatabase) {} + + async create(data: CreateAgentData): Promise { + const id = nanoid(); + const now = new Date(); + + await this.db.insert(agents).values({ + id, + name: data.name, + taskId: data.taskId ?? null, + sessionId: data.sessionId ?? null, + worktreeId: data.worktreeId, + status: data.status ?? 'idle', + createdAt: now, + updatedAt: now, + }); + + // Fetch to get the complete record with all defaults applied + const created = await this.findById(id); + return created!; + } + + async findById(id: string): Promise { + const result = await this.db + .select() + .from(agents) + .where(eq(agents.id, id)) + .limit(1); + + return result[0] ?? null; + } + + async findByName(name: string): Promise { + const result = await this.db + .select() + .from(agents) + .where(eq(agents.name, name)) + .limit(1); + + return result[0] ?? null; + } + + async findByTaskId(taskId: string): Promise { + const result = await this.db + .select() + .from(agents) + .where(eq(agents.taskId, taskId)) + .limit(1); + + return result[0] ?? null; + } + + async findBySessionId(sessionId: string): Promise { + const result = await this.db + .select() + .from(agents) + .where(eq(agents.sessionId, sessionId)) + .limit(1); + + return result[0] ?? null; + } + + async findAll(): Promise { + return this.db.select().from(agents); + } + + async findByStatus(status: AgentStatus): Promise { + return this.db.select().from(agents).where(eq(agents.status, status)); + } + + async updateStatus(id: string, status: AgentStatus): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new Error(`Agent not found: ${id}`); + } + + const now = new Date(); + await this.db + .update(agents) + .set({ status, updatedAt: now }) + .where(eq(agents.id, id)); + + return { ...existing, status, updatedAt: now }; + } + + async updateSessionId(id: string, sessionId: string): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new Error(`Agent not found: ${id}`); + } + + const now = new Date(); + await this.db + .update(agents) + .set({ sessionId, updatedAt: now }) + .where(eq(agents.id, id)); + + return { ...existing, sessionId, updatedAt: now }; + } + + async delete(id: string): Promise { + const existing = await this.findById(id); + if (!existing) { + throw new Error(`Agent not found: ${id}`); + } + + await this.db.delete(agents).where(eq(agents.id, id)); + } +} diff --git a/src/db/repositories/drizzle/index.ts b/src/db/repositories/drizzle/index.ts index d906b48..297291c 100644 --- a/src/db/repositories/drizzle/index.ts +++ b/src/db/repositories/drizzle/index.ts @@ -9,3 +9,4 @@ export { DrizzleInitiativeRepository } from './initiative.js'; export { DrizzlePhaseRepository } from './phase.js'; export { DrizzlePlanRepository } from './plan.js'; export { DrizzleTaskRepository } from './task.js'; +export { DrizzleAgentRepository } from './agent.js'; diff --git a/src/db/repositories/drizzle/test-helpers.ts b/src/db/repositories/drizzle/test-helpers.ts index af9907d..c41e695 100644 --- a/src/db/repositories/drizzle/test-helpers.ts +++ b/src/db/repositories/drizzle/test-helpers.ts @@ -70,6 +70,18 @@ CREATE TABLE IF NOT EXISTS task_dependencies ( depends_on_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, created_at INTEGER NOT NULL ); + +-- Agents table +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE, + task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, + session_id TEXT, + worktree_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); `; /** diff --git a/src/db/repositories/index.ts b/src/db/repositories/index.ts index 0378dce..e043070 100644 --- a/src/db/repositories/index.ts +++ b/src/db/repositories/index.ts @@ -30,4 +30,8 @@ export type { UpdateTaskData, } from './task-repository.js'; -export type { AgentRepository, AgentStatus } from './agent-repository.js'; +export type { + AgentRepository, + AgentStatus, + CreateAgentData, +} from './agent-repository.js';