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:
parent
a86e939072
commit
1046f91229
|
|
@ -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),
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue