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
This commit is contained in:
@@ -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<NewAgent, 'id' | 'createdAt' | 'updatedAt'>): Promise<Agent>;
|
||||
create(agent: CreateAgentData): Promise<Agent>;
|
||||
|
||||
/**
|
||||
* Find an agent by its ID.
|
||||
|
||||
299
src/db/repositories/drizzle/agent.test.ts
Normal file
299
src/db/repositories/drizzle/agent.test.ts
Normal file
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
src/db/repositories/drizzle/agent.ts
Normal file
132
src/db/repositories/drizzle/agent.ts
Normal file
@@ -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<Agent> {
|
||||
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<Agent | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Agent | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.name, name))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByTaskId(taskId: string): Promise<Agent | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.taskId, taskId))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findBySessionId(sessionId: string): Promise<Agent | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.sessionId, sessionId))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Agent[]> {
|
||||
return this.db.select().from(agents);
|
||||
}
|
||||
|
||||
async findByStatus(status: AgentStatus): Promise<Agent[]> {
|
||||
return this.db.select().from(agents).where(eq(agents.status, status));
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: AgentStatus): Promise<Agent> {
|
||||
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<Agent> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user