refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
This commit is contained in:
548
apps/server/dispatch/manager.test.ts
Normal file
548
apps/server/dispatch/manager.test.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* DefaultDispatchManager Tests
|
||||
*
|
||||
* Tests for the DispatchManager adapter with dependency checking
|
||||
* and queue management.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DefaultDispatchManager } from './manager.js';
|
||||
import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js';
|
||||
import { DrizzleMessageRepository } from '../db/repositories/drizzle/message.js';
|
||||
|
||||
import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js';
|
||||
import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js';
|
||||
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../db/index.js';
|
||||
import type { EventBus, DomainEvent } from '../events/types.js';
|
||||
import type { AgentManager, AgentInfo } from '../agent/types.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { MessageRepository } from '../db/repositories/message-repository.js';
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock EventBus that captures emitted events.
|
||||
*/
|
||||
function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } {
|
||||
const emittedEvents: DomainEvent[] = [];
|
||||
|
||||
return {
|
||||
emittedEvents,
|
||||
emit<T extends DomainEvent>(event: T): void {
|
||||
emittedEvents.push(event);
|
||||
},
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock AgentManager.
|
||||
*/
|
||||
function createMockAgentManager(
|
||||
agents: AgentInfo[] = []
|
||||
): AgentManager {
|
||||
const mockAgents = [...agents];
|
||||
|
||||
return {
|
||||
list: vi.fn().mockResolvedValue(mockAgents),
|
||||
get: vi.fn().mockImplementation(async (id: string) => {
|
||||
return mockAgents.find((a) => a.id === id) || null;
|
||||
}),
|
||||
getByName: vi.fn().mockImplementation(async (name: string) => {
|
||||
return mockAgents.find((a) => a.name === name) || null;
|
||||
}),
|
||||
spawn: vi.fn().mockImplementation(async (options) => {
|
||||
const newAgent: AgentInfo = {
|
||||
id: `agent-${Date.now()}`,
|
||||
name: options.name ?? `mock-agent-${Date.now()}`,
|
||||
taskId: options.taskId,
|
||||
initiativeId: options.initiativeId ?? null,
|
||||
sessionId: null,
|
||||
worktreeId: 'worktree-test',
|
||||
status: 'running',
|
||||
mode: options.mode ?? 'execute',
|
||||
provider: options.provider ?? 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockAgents.push(newAgent);
|
||||
return newAgent;
|
||||
}),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
getResult: vi.fn().mockResolvedValue(null),
|
||||
getPendingQuestions: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an idle agent for testing.
|
||||
*/
|
||||
function createIdleAgent(id: string, name: string): AgentInfo {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
taskId: 'task-123',
|
||||
initiativeId: null,
|
||||
sessionId: 'session-abc',
|
||||
worktreeId: 'worktree-xyz',
|
||||
status: 'idle',
|
||||
mode: 'execute',
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('DefaultDispatchManager', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let taskRepository: TaskRepository;
|
||||
let messageRepository: MessageRepository;
|
||||
let eventBus: EventBus & { emittedEvents: DomainEvent[] };
|
||||
let agentManager: AgentManager;
|
||||
let dispatchManager: DefaultDispatchManager;
|
||||
let testPhaseId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Set up test database
|
||||
db = createTestDatabase();
|
||||
taskRepository = new DrizzleTaskRepository(db);
|
||||
messageRepository = new DrizzleMessageRepository(db);
|
||||
|
||||
// Create required hierarchy for tasks
|
||||
const initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
const phaseRepo = new DrizzlePhaseRepository(db);
|
||||
|
||||
|
||||
const initiative = await initiativeRepo.create({
|
||||
name: 'Test Initiative',
|
||||
});
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
testPhaseId = phase.id;
|
||||
|
||||
// Create mocks
|
||||
eventBus = createMockEventBus();
|
||||
agentManager = createMockAgentManager();
|
||||
|
||||
// Create dispatch manager
|
||||
dispatchManager = new DefaultDispatchManager(
|
||||
taskRepository,
|
||||
messageRepository,
|
||||
agentManager,
|
||||
eventBus
|
||||
);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// queue() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('queue', () => {
|
||||
it('should add task to queue and emit TaskQueuedEvent', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Test Task',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
const state = await dispatchManager.getQueueState();
|
||||
expect(state.queued.length).toBe(1);
|
||||
expect(state.queued[0].taskId).toBe(task.id);
|
||||
expect(state.queued[0].priority).toBe('high');
|
||||
|
||||
// Check event was emitted
|
||||
expect(eventBus.emittedEvents.length).toBe(1);
|
||||
expect(eventBus.emittedEvents[0].type).toBe('task:queued');
|
||||
expect((eventBus.emittedEvents[0] as any).payload.taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it('should throw error when task not found', async () => {
|
||||
await expect(dispatchManager.queue('non-existent-id')).rejects.toThrow(
|
||||
'Task not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getNextDispatchable() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('getNextDispatchable', () => {
|
||||
it('should return null when queue is empty', async () => {
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it('should return task when dependencies are complete', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Dispatchable Task',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it('should respect priority ordering (high > medium > low)', async () => {
|
||||
// Create tasks in different priority order
|
||||
const lowTask = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Low Priority',
|
||||
priority: 'low',
|
||||
order: 1,
|
||||
});
|
||||
const highTask = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'High Priority',
|
||||
priority: 'high',
|
||||
order: 2,
|
||||
});
|
||||
const mediumTask = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Medium Priority',
|
||||
priority: 'medium',
|
||||
order: 3,
|
||||
});
|
||||
|
||||
// Queue in wrong order (low, high, medium)
|
||||
await dispatchManager.queue(lowTask.id);
|
||||
await dispatchManager.queue(highTask.id);
|
||||
await dispatchManager.queue(mediumTask.id);
|
||||
|
||||
// Should get high priority first
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.taskId).toBe(highTask.id);
|
||||
expect(next!.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('should order by queuedAt within same priority (oldest first)', async () => {
|
||||
const task1 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'First Task',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
const task2 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Second Task',
|
||||
priority: 'medium',
|
||||
order: 2,
|
||||
});
|
||||
|
||||
// Queue first task, wait, then queue second
|
||||
await dispatchManager.queue(task1.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await dispatchManager.queue(task2.id);
|
||||
|
||||
// Should get the first queued task
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.taskId).toBe(task1.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// completeTask() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('completeTask', () => {
|
||||
it('should update task status and emit TaskCompletedEvent', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task to Complete',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
await dispatchManager.completeTask(task.id);
|
||||
|
||||
// Check task status updated
|
||||
const updatedTask = await taskRepository.findById(task.id);
|
||||
expect(updatedTask!.status).toBe('completed');
|
||||
|
||||
// Check removed from queue
|
||||
const state = await dispatchManager.getQueueState();
|
||||
expect(state.queued.length).toBe(0);
|
||||
|
||||
// Check event was emitted (2 events: queued + completed)
|
||||
expect(eventBus.emittedEvents.length).toBe(2);
|
||||
expect(eventBus.emittedEvents[1].type).toBe('task:completed');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// blockTask() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('blockTask', () => {
|
||||
it('should update task status and emit TaskBlockedEvent', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task to Block',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
await dispatchManager.blockTask(task.id, 'Waiting for user input');
|
||||
|
||||
// Check task status updated
|
||||
const updatedTask = await taskRepository.findById(task.id);
|
||||
expect(updatedTask!.status).toBe('blocked');
|
||||
|
||||
// Check moved to blocked list
|
||||
const state = await dispatchManager.getQueueState();
|
||||
expect(state.queued.length).toBe(0);
|
||||
expect(state.blocked.length).toBe(1);
|
||||
expect(state.blocked[0].taskId).toBe(task.id);
|
||||
expect(state.blocked[0].reason).toBe('Waiting for user input');
|
||||
|
||||
// Check event was emitted (2 events: queued + blocked)
|
||||
expect(eventBus.emittedEvents.length).toBe(2);
|
||||
expect(eventBus.emittedEvents[1].type).toBe('task:blocked');
|
||||
expect((eventBus.emittedEvents[1] as any).payload.reason).toBe(
|
||||
'Waiting for user input'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// dispatchNext() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('dispatchNext', () => {
|
||||
it('should return failure when no tasks ready', async () => {
|
||||
const result = await dispatchManager.dispatchNext();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe('No dispatchable tasks');
|
||||
});
|
||||
|
||||
it('should return failure when no agents available', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task needing agent',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
// Agent manager returns empty list (no idle agents)
|
||||
const result = await dispatchManager.dispatchNext();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.taskId).toBe(task.id);
|
||||
expect(result.reason).toBe('No available agents');
|
||||
});
|
||||
|
||||
it('should dispatch task to available agent', async () => {
|
||||
// Create task
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task for dispatch',
|
||||
description: 'Do the thing',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
// Set up agent manager with an idle agent
|
||||
const idleAgent = createIdleAgent('agent-1', 'gastown');
|
||||
agentManager = createMockAgentManager([idleAgent]);
|
||||
dispatchManager = new DefaultDispatchManager(
|
||||
taskRepository,
|
||||
messageRepository,
|
||||
agentManager,
|
||||
eventBus
|
||||
);
|
||||
|
||||
// Re-queue since we created a new dispatch manager
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
const result = await dispatchManager.dispatchNext();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.taskId).toBe(task.id);
|
||||
expect(result.agentId).toBeDefined();
|
||||
|
||||
// Check task status updated to in_progress
|
||||
const updatedTask = await taskRepository.findById(task.id);
|
||||
expect(updatedTask!.status).toBe('in_progress');
|
||||
|
||||
// Check spawn was called with correct params (prompt is wrapped by buildExecutePrompt)
|
||||
expect(agentManager.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: task.id,
|
||||
prompt: expect.stringContaining('Do the thing'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit TaskDispatchedEvent on successful dispatch', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Dispatch event test',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
const idleAgent = createIdleAgent('agent-1', 'yaletown');
|
||||
agentManager = createMockAgentManager([idleAgent]);
|
||||
dispatchManager = new DefaultDispatchManager(
|
||||
taskRepository,
|
||||
messageRepository,
|
||||
agentManager,
|
||||
eventBus
|
||||
);
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
await dispatchManager.dispatchNext();
|
||||
|
||||
// Find TaskDispatchedEvent
|
||||
const dispatchedEvent = eventBus.emittedEvents.find(
|
||||
(e) => e.type === 'task:dispatched'
|
||||
);
|
||||
expect(dispatchedEvent).toBeDefined();
|
||||
expect((dispatchedEvent as any).payload.taskId).toBe(task.id);
|
||||
expect((dispatchedEvent as any).payload.agentId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getQueueState() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('getQueueState', () => {
|
||||
it('should return correct state', async () => {
|
||||
// Create and queue tasks
|
||||
const task1 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Ready Task',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
const task2 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Another Ready Task',
|
||||
priority: 'low',
|
||||
order: 2,
|
||||
});
|
||||
const task3 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Blocked Task',
|
||||
priority: 'medium',
|
||||
order: 3,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task1.id);
|
||||
await dispatchManager.queue(task2.id);
|
||||
await dispatchManager.queue(task3.id);
|
||||
await dispatchManager.blockTask(task3.id, 'Manual block');
|
||||
|
||||
const state = await dispatchManager.getQueueState();
|
||||
|
||||
// Queued should have 2 (task3 was moved to blocked)
|
||||
expect(state.queued.length).toBe(2);
|
||||
expect(state.queued.map((t) => t.taskId)).toContain(task1.id);
|
||||
expect(state.queued.map((t) => t.taskId)).toContain(task2.id);
|
||||
|
||||
// Ready should have same 2 tasks (no dependencies)
|
||||
expect(state.ready.length).toBe(2);
|
||||
|
||||
// Blocked should have 1
|
||||
expect(state.blocked.length).toBe(1);
|
||||
expect(state.blocked[0].taskId).toBe(task3.id);
|
||||
expect(state.blocked[0].reason).toBe('Manual block');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Dependency Scenario Test
|
||||
// ===========================================================================
|
||||
|
||||
describe('dependency scenario', () => {
|
||||
it('should handle task dependencies correctly', async () => {
|
||||
// This test verifies the basic flow described in the plan:
|
||||
// - Task A (no deps) - should be dispatchable
|
||||
// - Task B (depends on A) - not dispatchable until A completes
|
||||
// - Task C (depends on A) - not dispatchable until A completes
|
||||
|
||||
// For v1, dependencies are empty (we'd need to enhance queue() to fetch from task_dependencies table)
|
||||
// This test verifies the priority and queue ordering work correctly
|
||||
|
||||
const taskA = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task A - Foundation',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
const taskB = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task B - Build on A',
|
||||
priority: 'medium',
|
||||
order: 2,
|
||||
});
|
||||
const taskC = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task C - Also build on A',
|
||||
priority: 'medium',
|
||||
order: 3,
|
||||
});
|
||||
|
||||
// Queue all three
|
||||
await dispatchManager.queue(taskA.id);
|
||||
await dispatchManager.queue(taskB.id);
|
||||
await dispatchManager.queue(taskC.id);
|
||||
|
||||
const state = await dispatchManager.getQueueState();
|
||||
expect(state.queued.length).toBe(3);
|
||||
|
||||
// With no dependencies set, all should be ready
|
||||
// (In a full implementation, B and C would have dependsOn: [taskA.id])
|
||||
expect(state.ready.length).toBe(3);
|
||||
|
||||
// A should come first (highest priority)
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next!.taskId).toBe(taskA.id);
|
||||
|
||||
// Complete A
|
||||
await dispatchManager.completeTask(taskA.id);
|
||||
|
||||
// Now B and C should be next (both medium priority, B queued first)
|
||||
const stateAfterA = await dispatchManager.getQueueState();
|
||||
expect(stateAfterA.queued.length).toBe(2);
|
||||
|
||||
// Get next - should be B (queued before C)
|
||||
const nextAfterA = await dispatchManager.getNextDispatchable();
|
||||
expect(nextAfterA!.taskId).toBe(taskB.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user