feat: enrich listWaitingAgents with task/phase/initiative context via DB joins

Replaces the in-memory filter (agentManager.list() + filter) with a direct
repository query that LEFT JOINs tasks, phases, and initiatives to return
taskName, phaseName, initiativeName, and taskDescription alongside agent fields.

- Adds AgentWithContext interface and findWaitingWithContext() to AgentRepository port
- Implements findWaitingWithContext() in DrizzleAgentRepository using getTableColumns
- Wires agentRepository into TRPCContext, CreateContextOptions, and TrpcAdapterOptions
- Adds requireAgentRepository() helper following existing pattern
- Updates listWaitingAgents to use repository query instead of agentManager
- Adds 5 unit tests for findWaitingWithContext() covering all FK join edge cases
- Updates existing AgentRepository mocks to satisfy updated interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 23:29:49 +01:00
parent 79a0bd0a74
commit 7e6921f01e
12 changed files with 151 additions and 10 deletions

View File

@@ -8,6 +8,14 @@
import type { Agent } from '../schema.js';
import type { AgentMode } from '../../agent/types.js';
/** Agent row enriched with joined task/phase/initiative context fields. */
export interface AgentWithContext extends Agent {
taskName: string | null;
phaseName: string | null;
initiativeName: string | null;
taskDescription: string | null;
}
/**
* Agent status values.
*/
@@ -117,4 +125,10 @@ export interface AgentRepository {
* Throws if agent not found.
*/
delete(id: string): Promise<void>;
/**
* Find all agents with status 'waiting_for_input', enriched with
* task, phase, and initiative names via LEFT JOINs.
*/
findWaitingWithContext(): Promise<AgentWithContext[]>;
}

View File

@@ -277,3 +277,91 @@ describe('DrizzleAgentRepository', () => {
});
});
});
describe('DrizzleAgentRepository.findWaitingWithContext()', () => {
let agentRepo: DrizzleAgentRepository;
let taskRepo: DrizzleTaskRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
beforeEach(() => {
const db = createTestDatabase();
agentRepo = new DrizzleAgentRepository(db);
taskRepo = new DrizzleTaskRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
});
it('returns empty array when no waiting agents exist', async () => {
const result = await agentRepo.findWaitingWithContext();
expect(result).toEqual([]);
});
it('only returns agents with status waiting_for_input', async () => {
await agentRepo.create({ name: 'running-agent', worktreeId: 'wt1', status: 'running' });
await agentRepo.create({ name: 'waiting-agent', worktreeId: 'wt2', status: 'waiting_for_input' });
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].name).toBe('waiting-agent');
});
it('populates taskName, phaseName, initiativeName, taskDescription when FK associations exist', async () => {
const initiative = await initiativeRepo.create({ name: 'My Initiative' });
const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1' });
const task = await taskRepo.create({
phaseId: phase.id,
name: 'Implement feature',
description: 'Write the feature code',
});
await agentRepo.create({
name: 'ctx-agent',
worktreeId: 'wt3',
status: 'waiting_for_input',
taskId: task.id,
initiativeId: initiative.id,
});
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].taskName).toBe('Implement feature');
expect(result[0].phaseName).toBe('Phase 1');
expect(result[0].initiativeName).toBe('My Initiative');
expect(result[0].taskDescription).toBe('Write the feature code');
});
it('returns null for context fields when agent has no taskId or initiativeId', async () => {
await agentRepo.create({ name: 'bare-agent', worktreeId: 'wt4', status: 'waiting_for_input' });
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].taskName).toBeNull();
expect(result[0].phaseName).toBeNull();
expect(result[0].initiativeName).toBeNull();
expect(result[0].taskDescription).toBeNull();
});
it('returns null phaseName when task has no phaseId', async () => {
const initiative = await initiativeRepo.create({ name: 'Orphan Init' });
const task = await taskRepo.create({
phaseId: null,
name: 'Orphan Task',
description: null,
});
await agentRepo.create({
name: 'orphan-agent',
worktreeId: 'wt5',
status: 'waiting_for_input',
taskId: task.id,
initiativeId: initiative.id,
});
const result = await agentRepo.findWaitingWithContext();
expect(result).toHaveLength(1);
expect(result[0].phaseName).toBeNull();
expect(result[0].taskName).toBe('Orphan Task');
expect(result[0].initiativeName).toBe('Orphan Init');
});
});

View File

@@ -4,13 +4,14 @@
* Implements AgentRepository interface using Drizzle ORM.
*/
import { eq } from 'drizzle-orm';
import { eq, getTableColumns } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { agents, type Agent } from '../../schema.js';
import { agents, tasks, phases, initiatives, type Agent } from '../../schema.js';
import type {
AgentRepository,
AgentStatus,
AgentWithContext,
CreateAgentData,
UpdateAgentData,
} from '../agent-repository.js';
@@ -116,4 +117,20 @@ export class DrizzleAgentRepository implements AgentRepository {
throw new Error(`Agent not found: ${id}`);
}
}
async findWaitingWithContext(): Promise<AgentWithContext[]> {
return this.db
.select({
...getTableColumns(agents),
taskName: tasks.name,
phaseName: phases.name,
initiativeName: initiatives.name,
taskDescription: tasks.description,
})
.from(agents)
.where(eq(agents.status, 'waiting_for_input'))
.leftJoin(tasks, eq(agents.taskId, tasks.id))
.leftJoin(phases, eq(tasks.phaseId, phases.id))
.leftJoin(initiatives, eq(agents.initiativeId, initiatives.id));
}
}