feat(tasks): persist implementation target metadata for deterministic routing (#297)

* Add task implementation target metadata support for heartbeat routing

* test(tasks): verify implementation target metadata persistence and routing precedence

---------

Co-authored-by: clawdbot_nonadmin <clawdbot_nonadmin@clawdbot-admins-MacBook-Pro.local>
This commit is contained in:
RazorFin 2026-03-12 00:18:36 -06:00 committed by GitHub
parent a86e939072
commit 1046f91229
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 182 additions and 4 deletions

View File

@ -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),
}))
});
}

View File

@ -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({})
})
})

View File

@ -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', () => {

60
src/lib/task-routing.ts Normal file
View File

@ -0,0 +1,60 @@
export type TaskMetadata = Record<string, unknown>
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 } : {}),
}
}

View File

@ -26,6 +26,11 @@ export async function validateBody<T>(
}
}
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<string, unknown>),
metadata: taskMetadataSchema.default({} as Record<string, unknown>),
})
export const updateTaskSchema = createTaskSchema.partial()

View File

@ -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,