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:
Lukas May
2026-03-03 11:22:53 +01:00
parent 8c38d958ce
commit 34578d39c6
535 changed files with 75452 additions and 687 deletions

View File

@@ -0,0 +1,529 @@
/**
* MultiProviderAgentManager Tests
*
* Unit tests for the MultiProviderAgentManager adapter.
* Mocks child_process.spawn since we can't spawn real Claude CLI in tests.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MultiProviderAgentManager } from './manager.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import { EventEmitterBus } from '../events/index.js';
import type { DomainEvent } from '../events/index.js';
// Mock child_process.spawn and execFile
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
execFile: vi.fn((_cmd: string, _args: string[], _opts: unknown, cb?: Function) => {
if (cb) cb(null, '', '');
}),
}));
// Import spawn to get the mock
import { spawn } from 'node:child_process';
const mockSpawn = vi.mocked(spawn);
// Mock SimpleGitWorktreeManager so spawn doesn't need a real git repo
vi.mock('../git/manager.js', () => {
return {
SimpleGitWorktreeManager: class MockWorktreeManager {
create = vi.fn().mockResolvedValue({ id: 'workspace', path: '/tmp/test-workspace/agent-workdirs/gastown/workspace', branch: 'agent/gastown' });
get = vi.fn().mockResolvedValue(null);
list = vi.fn().mockResolvedValue([]);
remove = vi.fn().mockResolvedValue(undefined);
},
};
});
// Mock fs operations for file-based output
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
// Create a mock write stream
const mockWriteStream = {
write: vi.fn(),
end: vi.fn(),
on: vi.fn(),
};
return {
...actual,
openSync: vi.fn().mockReturnValue(99),
closeSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
createWriteStream: vi.fn().mockReturnValue(mockWriteStream),
existsSync: vi.fn().mockReturnValue(true), // Default to true for our new validation
};
});
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual('node:fs/promises');
return {
...actual,
readFile: vi.fn().mockResolvedValue(''),
readdir: vi.fn().mockRejectedValue(new Error('ENOENT')),
rm: vi.fn().mockResolvedValue(undefined),
};
});
// Mock FileTailer to avoid actual file watching
vi.mock('./file-tailer.js', () => ({
FileTailer: class MockFileTailer {
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
isStopped = false;
},
}));
import type { ChildProcess } from 'node:child_process';
/**
* Create a mock ChildProcess for detached spawning.
* The process is spawned detached and unreferenced.
*/
function createMockChildProcess(options?: {
pid?: number;
}) {
const { pid = 123 } = options ?? {};
// Create a minimal mock that satisfies the actual usage in spawnDetached
const childProcess = {
pid,
unref: vi.fn(),
on: vi.fn().mockReturnThis(),
kill: vi.fn(),
} as unknown as ChildProcess;
return childProcess;
}
describe('MultiProviderAgentManager', () => {
let manager: MultiProviderAgentManager;
let mockRepository: AgentRepository;
let mockProjectRepository: ProjectRepository;
let eventBus: EventEmitterBus;
let capturedEvents: DomainEvent[];
const mockAgent = {
id: 'agent-123',
name: 'gastown',
taskId: 'task-456',
initiativeId: null as string | null,
sessionId: 'session-789',
worktreeId: 'gastown',
status: 'idle' as const,
mode: 'execute' as const,
provider: 'claude',
accountId: null as string | null,
pid: null as number | null,
outputFilePath: null as string | null,
result: null as string | null,
pendingQuestions: null as string | null,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(() => {
vi.clearAllMocks();
capturedEvents = [];
mockRepository = {
create: vi.fn().mockResolvedValue(mockAgent),
findById: vi.fn().mockResolvedValue(mockAgent),
findByName: vi.fn().mockResolvedValue(null), // No duplicate by default
findByTaskId: vi.fn().mockResolvedValue(mockAgent),
findBySessionId: vi.fn().mockResolvedValue(mockAgent),
findAll: vi.fn().mockResolvedValue([mockAgent]),
findByStatus: vi.fn().mockResolvedValue([mockAgent]),
update: vi.fn().mockResolvedValue(mockAgent),
delete: vi.fn().mockResolvedValue(undefined),
};
mockProjectRepository = {
create: vi.fn(),
findById: vi.fn(),
findByName: vi.fn(),
findAll: vi.fn().mockResolvedValue([]),
update: vi.fn(),
delete: vi.fn(),
addProjectToInitiative: vi.fn(),
removeProjectFromInitiative: vi.fn(),
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
setInitiativeProjects: vi.fn(),
};
eventBus = new EventEmitterBus();
// Subscribe to all agent events
eventBus.on('agent:spawned', (e) => capturedEvents.push(e));
eventBus.on('agent:stopped', (e) => capturedEvents.push(e));
eventBus.on('agent:crashed', (e) => capturedEvents.push(e));
eventBus.on('agent:resumed', (e) => capturedEvents.push(e));
eventBus.on('agent:waiting', (e) => capturedEvents.push(e));
manager = new MultiProviderAgentManager(
mockRepository,
'/tmp/test-workspace',
mockProjectRepository,
undefined,
eventBus
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('spawn', () => {
it('creates agent record with provided name', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
const result = await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test task',
});
expect(mockRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'gastown' })
);
expect(result.name).toBe('gastown');
});
it('rejects duplicate agent names', async () => {
mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent);
await expect(
manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test',
})
).rejects.toThrow("Agent with name 'gastown' already exists");
});
it('emits AgentSpawned event with name', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test',
});
const spawnedEvent = capturedEvents.find(
(e) => e.type === 'agent:spawned'
);
expect(spawnedEvent).toBeDefined();
expect(
(spawnedEvent as { payload: { name: string } }).payload.name
).toBe('gastown');
});
it('writes diagnostic files for workdir verification', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
// Mock fs.writeFileSync to capture diagnostic file writing
const { writeFileSync } = await import('node:fs');
const mockWriteFileSync = vi.mocked(writeFileSync);
// The existsSync is already mocked globally to return true
await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test task',
});
// Verify diagnostic file was written
const diagnosticCalls = mockWriteFileSync.mock.calls.filter(call =>
call[0].toString().includes('spawn-diagnostic.json')
);
expect(diagnosticCalls).toHaveLength(1);
// Parse the diagnostic data to verify structure
const diagnosticCall = diagnosticCalls[0];
const diagnosticData = JSON.parse(diagnosticCall[1] as string);
expect(diagnosticData).toMatchObject({
agentId: expect.any(String),
alias: 'gastown',
intendedCwd: expect.stringContaining('/agent-workdirs/gastown/workspace'),
worktreeId: 'gastown',
provider: 'claude',
command: expect.any(String),
args: expect.any(Array),
env: expect.any(Object),
cwdExistsAtSpawn: true,
initiativeId: null,
customCwdProvided: false,
accountId: null,
timestamp: expect.any(String),
});
});
it('uses custom cwd if provided', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
await manager.spawn({
name: 'chinatown',
taskId: 'task-789',
prompt: 'Test task',
cwd: '/custom/path',
});
// Verify spawn was called with custom cwd
expect(mockSpawn).toHaveBeenCalledWith(
'claude',
expect.arrayContaining(['-p', expect.stringContaining('Test task'), '--output-format', 'stream-json']),
expect.objectContaining({ cwd: '/custom/path' })
);
});
});
describe('stop', () => {
it('stops running agent and updates status', async () => {
// When we call stop, it looks up the agent by ID
// The repository mock returns mockAgent which has id 'agent-123'
await manager.stop(mockAgent.id);
expect(mockRepository.update).toHaveBeenCalledWith(
mockAgent.id,
{ status: 'stopped', pendingQuestions: null }
);
});
it('kills detached process if running', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
// Spawn returns immediately since process is detached
const spawned = await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test',
});
// Now stop using the returned agent ID
await manager.stop(spawned.id);
// Verify status was updated (process.kill is called internally, not on the child object)
expect(mockRepository.update).toHaveBeenCalledWith(
spawned.id,
{ status: 'stopped', pendingQuestions: null }
);
});
it('throws if agent not found', async () => {
mockRepository.findById = vi.fn().mockResolvedValue(null);
await expect(manager.stop('nonexistent')).rejects.toThrow(
"Agent 'nonexistent' not found"
);
});
it('emits AgentStopped event with user_requested reason', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
const spawned = await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test',
});
await manager.stop(spawned.id);
const stoppedEvent = capturedEvents.find(
(e) => e.type === 'agent:stopped'
);
expect(stoppedEvent).toBeDefined();
expect(
(stoppedEvent as { payload: { reason: string } }).payload.reason
).toBe('user_requested');
});
});
describe('list', () => {
it('returns all agents with names', async () => {
const agents = await manager.list();
expect(agents).toHaveLength(1);
expect(agents[0].name).toBe('gastown');
});
});
describe('get', () => {
it('finds agent by id', async () => {
const agent = await manager.get('agent-123');
expect(mockRepository.findById).toHaveBeenCalledWith('agent-123');
expect(agent?.id).toBe('agent-123');
});
it('returns null if agent not found', async () => {
mockRepository.findById = vi.fn().mockResolvedValue(null);
const agent = await manager.get('nonexistent');
expect(agent).toBeNull();
});
});
describe('getByName', () => {
it('finds agent by name', async () => {
mockRepository.findByName = vi.fn().mockResolvedValue(mockAgent);
const agent = await manager.getByName('gastown');
expect(mockRepository.findByName).toHaveBeenCalledWith('gastown');
expect(agent?.name).toBe('gastown');
});
it('returns null if agent not found', async () => {
mockRepository.findByName = vi.fn().mockResolvedValue(null);
const agent = await manager.getByName('nonexistent');
expect(agent).toBeNull();
});
});
describe('resume', () => {
it('resumes agent waiting for input with answers map', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
status: 'waiting_for_input',
});
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
await manager.resume(mockAgent.id, { q1: 'Answer one', q2: 'Answer two' });
// Verify spawn was called with resume args
expect(mockSpawn).toHaveBeenCalledWith(
'claude',
expect.arrayContaining([
'--resume',
'session-789',
'--output-format',
'stream-json',
]),
expect.any(Object)
);
});
it('rejects if agent not waiting for input', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
status: 'running',
});
await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow(
'not waiting for input'
);
});
it('rejects if agent has no session', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
status: 'waiting_for_input',
sessionId: null,
});
await expect(manager.resume(mockAgent.id, { q1: 'Answer' })).rejects.toThrow(
'has no session to resume'
);
});
it('emits AgentResumed event', async () => {
mockRepository.findById = vi.fn().mockResolvedValue({
...mockAgent,
status: 'waiting_for_input',
});
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
await manager.resume(mockAgent.id, { q1: 'User answer' });
const resumedEvent = capturedEvents.find(
(e) => e.type === 'agent:resumed'
);
expect(resumedEvent).toBeDefined();
expect(
(resumedEvent as { payload: { sessionId: string } }).payload.sessionId
).toBe('session-789');
});
});
describe('getResult', () => {
it('returns null when agent has no result', async () => {
const result = await manager.getResult('agent-123');
expect(result).toBeNull();
});
});
describe('delete', () => {
it('deletes agent and clears active state', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
// Spawn an agent first
const spawned = await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test',
});
// Delete the agent
await manager.delete(spawned.id);
// Verify DB record was deleted
expect(mockRepository.delete).toHaveBeenCalledWith(spawned.id);
});
it('emits agent:deleted event', async () => {
const mockChild = createMockChildProcess();
mockSpawn.mockReturnValue(mockChild);
eventBus.on('agent:deleted', (e) => capturedEvents.push(e));
const spawned = await manager.spawn({
name: 'gastown',
taskId: 'task-456',
prompt: 'Test',
});
await manager.delete(spawned.id);
const deletedEvent = capturedEvents.find(
(e) => e.type === 'agent:deleted'
);
expect(deletedEvent).toBeDefined();
expect(
(deletedEvent as { payload: { name: string } }).payload.name
).toBe('gastown');
});
it('throws if agent not found', async () => {
mockRepository.findById = vi.fn().mockResolvedValue(null);
await expect(manager.delete('nonexistent')).rejects.toThrow(
"Agent 'nonexistent' not found"
);
});
it('handles missing workdir gracefully', async () => {
// Agent exists in DB but has no active state and workdir doesn't exist
// The delete should succeed (best-effort cleanup)
await manager.delete(mockAgent.id);
expect(mockRepository.delete).toHaveBeenCalledWith(mockAgent.id);
});
});
});