diff --git a/src/app/api/agents/[id]/heartbeat/route.ts b/src/app/api/agents/[id]/heartbeat/route.ts index 6ee4eac..27d9620 100644 --- a/src/app/api/agents/[id]/heartbeat/route.ts +++ b/src/app/api/agents/[id]/heartbeat/route.ts @@ -3,6 +3,7 @@ import { getDatabase, db_helpers } from '@/lib/db'; import { requireRole } from '@/lib/auth'; import { agentHeartbeatLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; +import { resolveTaskImplementationTarget } from '@/lib/task-routing'; /** * GET /api/agents/[id]/heartbeat - Agent heartbeat check @@ -80,8 +81,8 @@ export async function GET( AND status IN ('assigned', 'in_progress') ORDER BY priority DESC, created_at ASC LIMIT 10 - `).all(agent.name, workspaceId); - + `).all(agent.name, workspaceId) as any[]; + if (assignedTasks.length > 0) { workItems.push({ type: 'assigned_tasks', @@ -91,7 +92,8 @@ export async function GET( title: t.title, status: t.status, priority: t.priority, - due_date: t.due_date + due_date: t.due_date, + ...resolveTaskImplementationTarget(t), })) }); } diff --git a/src/lib/__tests__/task-routing.test.ts b/src/lib/__tests__/task-routing.test.ts new file mode 100644 index 0000000..3c2e58b --- /dev/null +++ b/src/lib/__tests__/task-routing.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { resolveTaskImplementationTarget } from '@/lib/task-routing' + +describe('resolveTaskImplementationTarget', () => { + it('returns explicit implementation target metadata when present', () => { + const result = resolveTaskImplementationTarget({ + metadata: { + implementation_repo: 'torreypjones/cloudstack-razor', + code_location: '/apps/api', + }, + }) + + expect(result).toEqual({ + implementation_repo: 'torreypjones/cloudstack-razor', + code_location: '/apps/api', + }) + }) + + it('supports legacy metadata keys for backward compatibility', () => { + const result = resolveTaskImplementationTarget({ + metadata: { + github_repo: 'torreypjones/cloudstack-razor', + path: '/packages/core', + }, + }) + + expect(result).toEqual({ + implementation_repo: 'torreypjones/cloudstack-razor', + code_location: '/packages/core', + }) + }) + + it('prefers explicit implementation target metadata over legacy fallback keys', () => { + const result = resolveTaskImplementationTarget({ + metadata: { + implementation_repo: 'torreypjones/cloudstack-razor', + github_repo: 'legacy/repo', + code_location: '/apps/api', + path: '/legacy/path', + }, + }) + + expect(result).toEqual({ + implementation_repo: 'torreypjones/cloudstack-razor', + code_location: '/apps/api', + }) + }) + + it('returns empty object for missing metadata', () => { + expect(resolveTaskImplementationTarget({ metadata: null })).toEqual({}) + }) +}) diff --git a/src/lib/__tests__/validation.test.ts b/src/lib/__tests__/validation.test.ts index 9f8d537..71a6ef7 100644 --- a/src/lib/__tests__/validation.test.ts +++ b/src/lib/__tests__/validation.test.ts @@ -55,6 +55,17 @@ describe('createTaskSchema', () => { expect(result.success).toBe(true) }) + it('accepts implementation target metadata fields', () => { + const result = createTaskSchema.safeParse({ + title: 'Route this task', + metadata: { + implementation_repo: 'torreypjones/cloudstack-razor', + code_location: '/apps/api', + }, + }) + expect(result.success).toBe(true) + }) + it('rejects invalid feedback_rating', () => { const result = createTaskSchema.safeParse({ title: 'Invalid rating test', @@ -62,6 +73,16 @@ describe('createTaskSchema', () => { }) expect(result.success).toBe(false) }) + + it('rejects non-string implementation target metadata fields', () => { + const result = createTaskSchema.safeParse({ + title: 'Bad metadata', + metadata: { + implementation_repo: 123, + }, + }) + expect(result.success).toBe(false) + }) }) describe('createAgentSchema', () => { diff --git a/src/lib/task-routing.ts b/src/lib/task-routing.ts new file mode 100644 index 0000000..fb2d29a --- /dev/null +++ b/src/lib/task-routing.ts @@ -0,0 +1,60 @@ +export type TaskMetadata = Record + +export interface TaskLike { + metadata?: string | TaskMetadata | null +} + +export interface TaskImplementationTarget { + implementation_repo?: string + code_location?: string +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function parseMetadata(metadata: TaskLike['metadata']): TaskMetadata { + if (!metadata) return {} + + if (typeof metadata === 'string') { + try { + const parsed = JSON.parse(metadata) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as TaskMetadata + } + return {} + } catch { + return {} + } + } + + if (typeof metadata === 'object' && !Array.isArray(metadata)) { + return metadata + } + + return {} +} + +export function resolveTaskImplementationTarget(task: TaskLike): TaskImplementationTarget { + const metadata = parseMetadata(task.metadata) + + const implementationRepoCandidates = [ + metadata.implementation_repo, + metadata.implementationRepo, + metadata.github_repo, + ] + + const codeLocationCandidates = [ + metadata.code_location, + metadata.codeLocation, + metadata.path, + ] + + const implementation_repo = implementationRepoCandidates.find(isNonEmptyString) + const code_location = codeLocationCandidates.find(isNonEmptyString) + + return { + ...(implementation_repo ? { implementation_repo } : {}), + ...(code_location ? { code_location } : {}), + } +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 7710348..c85301c 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -26,6 +26,11 @@ export async function validateBody( } } +const taskMetadataSchema = z.object({ + implementation_repo: z.string().min(1, 'implementation_repo cannot be empty').max(200).optional(), + code_location: z.string().min(1, 'code_location cannot be empty').max(500).optional(), +}).catchall(z.unknown()) + export const createTaskSchema = z.object({ title: z.string().min(1, 'Title is required').max(500), description: z.string().max(5000).optional(), @@ -45,7 +50,7 @@ export const createTaskSchema = z.object({ retry_count: z.number().int().min(0).optional(), completed_at: z.number().int().min(0).max(4102444800).optional(), tags: z.array(z.string().min(1).max(100)).max(50).default([] as string[]), - metadata: z.record(z.string(), z.unknown()).default({} as Record), + metadata: taskMetadataSchema.default({} as Record), }) export const updateTaskSchema = createTaskSchema.partial() diff --git a/tests/tasks-crud.spec.ts b/tests/tasks-crud.spec.ts index b3b74f0..6f03c8a 100644 --- a/tests/tasks-crud.spec.ts +++ b/tests/tasks-crud.spec.ts @@ -44,6 +44,20 @@ test.describe('Tasks CRUD', () => { expect(body.task.metadata).toEqual({ source: 'e2e' }) }) + test('POST persists implementation target metadata for deterministic repo routing', async ({ request }) => { + const { id, res, body } = await createTestTask(request, { + metadata: { + implementation_repo: 'torreypjones/cloudstack-razor', + code_location: '/apps/api', + }, + }) + cleanup.push(id) + + expect(res.status()).toBe(201) + expect(body.task.metadata.implementation_repo).toBe('torreypjones/cloudstack-razor') + expect(body.task.metadata.code_location).toBe('/apps/api') + }) + test('POST ignores client-supplied created_by and uses authenticated actor', async ({ request }) => { const title = `e2e-task-actor-${Date.now()}` const res = await request.post('/api/tasks', { @@ -164,6 +178,30 @@ test.describe('Tasks CRUD', () => { expect(body.task.priority).toBe('high') }) + test('PUT updates implementation target metadata and GET returns persisted values', async ({ request }) => { + const { id } = await createTestTask(request, { + metadata: { implementation_repo: 'torreypjones/cloudstack-razor', code_location: '/apps/api' }, + }) + cleanup.push(id) + + const updateRes = await request.put(`/api/tasks/${id}`, { + headers: API_KEY_HEADER, + data: { + metadata: { + implementation_repo: 'torreypjones/mission-control', + code_location: '/src/app/api/tasks', + }, + }, + }) + expect(updateRes.status()).toBe(200) + + const readRes = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER }) + expect(readRes.status()).toBe(200) + const readBody = await readRes.json() + expect(readBody.task.metadata.implementation_repo).toBe('torreypjones/mission-control') + expect(readBody.task.metadata.code_location).toBe('/src/app/api/tasks') + }) + test('PUT returns 404 for missing task', async ({ request }) => { const res = await request.put('/api/tasks/999999', { headers: API_KEY_HEADER,