feat(tasks): add outcome tracking and feedback analytics

This commit is contained in:
Nyk 2026-03-05 14:17:21 +07:00
parent e948a1399b
commit 6cf4256460
10 changed files with 396 additions and 4 deletions

View File

@ -123,6 +123,13 @@ export async function PUT(
due_date, due_date,
estimated_hours, estimated_hours,
actual_hours, actual_hours,
outcome,
error_message,
resolution,
feedback_rating,
feedback_notes,
retry_count,
completed_at,
tags, tags,
metadata metadata
} = body; } = body;
@ -219,6 +226,37 @@ export async function PUT(
fieldsToUpdate.push('actual_hours = ?'); fieldsToUpdate.push('actual_hours = ?');
updateParams.push(actual_hours); updateParams.push(actual_hours);
} }
if (outcome !== undefined) {
fieldsToUpdate.push('outcome = ?');
updateParams.push(outcome);
}
if (error_message !== undefined) {
fieldsToUpdate.push('error_message = ?');
updateParams.push(error_message);
}
if (resolution !== undefined) {
fieldsToUpdate.push('resolution = ?');
updateParams.push(resolution);
}
if (feedback_rating !== undefined) {
fieldsToUpdate.push('feedback_rating = ?');
updateParams.push(feedback_rating);
}
if (feedback_notes !== undefined) {
fieldsToUpdate.push('feedback_notes = ?');
updateParams.push(feedback_notes);
}
if (retry_count !== undefined) {
fieldsToUpdate.push('retry_count = ?');
updateParams.push(retry_count);
}
if (completed_at !== undefined) {
fieldsToUpdate.push('completed_at = ?');
updateParams.push(completed_at);
} else if (normalizedStatus === 'done' && !currentTask.completed_at) {
fieldsToUpdate.push('completed_at = ?');
updateParams.push(now);
}
if (tags !== undefined) { if (tags !== undefined) {
fieldsToUpdate.push('tags = ?'); fieldsToUpdate.push('tags = ?');
updateParams.push(JSON.stringify(tags)); updateParams.push(JSON.stringify(tags));
@ -293,6 +331,9 @@ export async function PUT(
if (project_id !== undefined && project_id !== currentTask.project_id) { if (project_id !== undefined && project_id !== currentTask.project_id) {
changes.push(`project: ${currentTask.project_id || 'none'}${project_id}`); changes.push(`project: ${currentTask.project_id || 'none'}${project_id}`);
} }
if (outcome !== undefined && outcome !== currentTask.outcome) {
changes.push(`outcome: ${currentTask.outcome || 'unset'}${outcome || 'unset'}`);
}
if (descriptionMentionResolution) { if (descriptionMentionResolution) {
const newMentionRecipients = new Set(descriptionMentionResolution.recipients); const newMentionRecipients = new Set(descriptionMentionResolution.recipients);

View File

@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth'
import { getDatabase } from '@/lib/db'
import { logger } from '@/lib/logger'
type Outcome = 'success' | 'failed' | 'partial' | 'abandoned'
function resolveSince(timeframe: string): number {
const now = Math.floor(Date.now() / 1000)
switch (timeframe) {
case 'day':
return now - 24 * 60 * 60
case 'week':
return now - 7 * 24 * 60 * 60
case 'month':
return now - 30 * 24 * 60 * 60
case 'all':
default:
return 0
}
}
function outcomeBuckets() {
return {
success: 0,
failed: 0,
partial: 0,
abandoned: 0,
unknown: 0,
}
}
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const workspaceId = auth.user.workspace_id ?? 1
const { searchParams } = new URL(request.url)
const timeframe = (searchParams.get('timeframe') || 'all').trim().toLowerCase()
const since = resolveSince(timeframe)
const db = getDatabase()
const rows = db.prepare(`
SELECT
id,
assigned_to,
priority,
outcome,
error_message,
retry_count,
created_at,
completed_at
FROM tasks
WHERE workspace_id = ?
AND status = 'done'
AND (? = 0 OR COALESCE(completed_at, updated_at) >= ?)
`).all(workspaceId, since, since) as Array<{
id: number
assigned_to?: string | null
priority?: string | null
outcome?: string | null
error_message?: string | null
retry_count?: number | null
created_at?: number | null
completed_at?: number | null
}>
const summary = {
total_done: rows.length,
with_outcome: 0,
by_outcome: outcomeBuckets(),
avg_retry_count: 0,
avg_time_to_resolution_seconds: 0,
success_rate: 0,
}
const byAgent: Record<string, ReturnType<typeof outcomeBuckets> & { total: number; success_rate: number }> = {}
const byPriority: Record<string, ReturnType<typeof outcomeBuckets> & { total: number; success_rate: number }> = {}
const errorMap = new Map<string, number>()
let totalRetryCount = 0
let totalResolutionSeconds = 0
let resolutionCount = 0
for (const row of rows) {
const outcome = (row.outcome || 'unknown') as Outcome | 'unknown'
const assignedTo = row.assigned_to || 'unassigned'
const priority = row.priority || 'unknown'
const retryCount = Number.isFinite(row.retry_count) ? Number(row.retry_count) : 0
if (outcome !== 'unknown') summary.with_outcome += 1
if (outcome in summary.by_outcome) {
summary.by_outcome[outcome as keyof typeof summary.by_outcome] += 1
} else {
summary.by_outcome.unknown += 1
}
if (!byAgent[assignedTo]) {
byAgent[assignedTo] = { ...outcomeBuckets(), total: 0, success_rate: 0 }
}
byAgent[assignedTo].total += 1
if (outcome in byAgent[assignedTo]) {
byAgent[assignedTo][outcome as keyof ReturnType<typeof outcomeBuckets>] += 1
} else {
byAgent[assignedTo].unknown += 1
}
if (!byPriority[priority]) {
byPriority[priority] = { ...outcomeBuckets(), total: 0, success_rate: 0 }
}
byPriority[priority].total += 1
if (outcome in byPriority[priority]) {
byPriority[priority][outcome as keyof ReturnType<typeof outcomeBuckets>] += 1
} else {
byPriority[priority].unknown += 1
}
totalRetryCount += retryCount
if (row.completed_at && row.created_at && row.completed_at >= row.created_at) {
totalResolutionSeconds += (row.completed_at - row.created_at)
resolutionCount += 1
}
const errorMessage = (row.error_message || '').trim()
if (errorMessage) {
errorMap.set(errorMessage, (errorMap.get(errorMessage) || 0) + 1)
}
}
summary.avg_retry_count = rows.length > 0 ? totalRetryCount / rows.length : 0
summary.avg_time_to_resolution_seconds = resolutionCount > 0 ? totalResolutionSeconds / resolutionCount : 0
summary.success_rate = summary.with_outcome > 0 ? summary.by_outcome.success / summary.with_outcome : 0
for (const agent of Object.values(byAgent)) {
const withOutcome = agent.total - agent.unknown
agent.success_rate = withOutcome > 0 ? agent.success / withOutcome : 0
}
for (const priority of Object.values(byPriority)) {
const withOutcome = priority.total - priority.unknown
priority.success_rate = withOutcome > 0 ? priority.success / withOutcome : 0
}
const commonErrors = [...errorMap.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([error_message, count]) => ({ error_message, count }))
return NextResponse.json({
timeframe,
summary,
by_agent: byAgent,
by_priority: byPriority,
common_errors: commonErrors,
record_count: rows.length,
})
} catch (error) {
logger.error({ err: error }, 'GET /api/tasks/outcomes error')
return NextResponse.json({ error: 'Failed to fetch task outcomes' }, { status: 500 })
}
}

View File

@ -171,6 +171,14 @@ export async function POST(request: NextRequest) {
created_by = user?.username || 'system', created_by = user?.username || 'system',
due_date, due_date,
estimated_hours, estimated_hours,
actual_hours,
outcome,
error_message,
resolution,
feedback_rating,
feedback_notes,
retry_count = 0,
completed_at,
tags = [], tags = [],
metadata = {} metadata = {}
} = body; } = body;
@ -191,6 +199,8 @@ export async function POST(request: NextRequest) {
}, { status: 400 }); }, { status: 400 });
} }
const resolvedCompletedAt = completed_at ?? (normalizedStatus === 'done' ? now : null)
const createTaskTx = db.transaction(() => { const createTaskTx = db.transaction(() => {
const resolvedProjectId = resolveProjectId(db, workspaceId, project_id) const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
db.prepare(` db.prepare(`
@ -207,8 +217,10 @@ export async function POST(request: NextRequest) {
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO tasks ( INSERT INTO tasks (
title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by, title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by,
created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id created_at, updated_at, due_date, estimated_hours, actual_hours,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) outcome, error_message, resolution, feedback_rating, feedback_notes, retry_count, completed_at,
tags, metadata, workspace_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`) `)
const dbResult = insertStmt.run( const dbResult = insertStmt.run(
@ -224,6 +236,14 @@ export async function POST(request: NextRequest) {
now, now,
due_date, due_date,
estimated_hours, estimated_hours,
actual_hours,
outcome,
error_message,
resolution,
feedback_rating,
feedback_notes,
retry_count,
resolvedCompletedAt,
JSON.stringify(tags), JSON.stringify(tags),
JSON.stringify(metadata), JSON.stringify(metadata),
workspaceId workspaceId
@ -238,7 +258,8 @@ export async function POST(request: NextRequest) {
title, title,
status: normalizedStatus, status: normalizedStatus,
priority, priority,
assigned_to assigned_to,
...(outcome ? { outcome } : {})
}, workspaceId); }, workspaceId);
if (created_by) { if (created_by) {
@ -317,6 +338,11 @@ export async function PUT(request: NextRequest) {
SET status = ?, updated_at = ? SET status = ?, updated_at = ?
WHERE id = ? AND workspace_id = ? WHERE id = ? AND workspace_id = ?
`); `);
const updateDoneStmt = db.prepare(`
UPDATE tasks
SET status = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?)
WHERE id = ? AND workspace_id = ?
`);
const actor = auth.user.username const actor = auth.user.username
@ -329,7 +355,11 @@ export async function PUT(request: NextRequest) {
throw new Error(`Aegis approval required for task ${task.id}`) throw new Error(`Aegis approval required for task ${task.id}`)
} }
updateStmt.run(task.status, now, task.id, workspaceId); if (task.status === 'done') {
updateDoneStmt.run(task.status, now, now, task.id, workspaceId);
} else {
updateStmt.run(task.status, now, task.id, workspaceId);
}
// Log status change if different // Log status change if different
if (oldTask && oldTask.status !== task.status) { if (oldTask && oldTask.status !== task.status) {

View File

@ -99,6 +99,13 @@ export interface Task {
due_date?: number due_date?: number
estimated_hours?: number estimated_hours?: number
actual_hours?: number actual_hours?: number
outcome?: 'success' | 'failed' | 'partial' | 'abandoned'
error_message?: string
resolution?: string
feedback_rating?: number
feedback_notes?: string
retry_count?: number
completed_at?: number
tags?: string[] tags?: string[]
metadata?: any metadata?: any
} }

View File

@ -41,6 +41,27 @@ describe('createTaskSchema', () => {
expect(result.success).toBe(true) expect(result.success).toBe(true)
} }
}) })
it('accepts outcome and feedback fields', () => {
const result = createTaskSchema.safeParse({
title: 'Investigate flaky test',
status: 'done',
outcome: 'partial',
feedback_rating: 4,
feedback_notes: 'Needs follow-up monitoring',
retry_count: 2,
completed_at: 1735600000,
})
expect(result.success).toBe(true)
})
it('rejects invalid feedback_rating', () => {
const result = createTaskSchema.safeParse({
title: 'Invalid rating test',
feedback_rating: 6,
})
expect(result.success).toBe(false)
})
}) })
describe('createAgentSchema', () => { describe('createAgentSchema', () => {

View File

@ -177,6 +177,13 @@ export interface Task {
due_date?: number; due_date?: number;
estimated_hours?: number; estimated_hours?: number;
actual_hours?: number; actual_hours?: number;
outcome?: 'success' | 'failed' | 'partial' | 'abandoned';
error_message?: string;
resolution?: string;
feedback_rating?: number;
feedback_notes?: string;
retry_count?: number;
completed_at?: number;
tags?: string; // JSON string tags?: string; // JSON string
metadata?: string; // JSON string metadata?: string; // JSON string
} }

View File

@ -753,6 +753,30 @@ const migrations: Migration[] = [
} }
} }
} }
},
{
id: '025_task_outcome_tracking',
up: (db) => {
const hasTasks = db
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`)
.get() as { ok?: number } | undefined
if (!hasTasks?.ok) return
const taskCols = db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>
const hasCol = (name: string) => taskCols.some((c) => c.name === name)
if (!hasCol('outcome')) db.exec(`ALTER TABLE tasks ADD COLUMN outcome TEXT`)
if (!hasCol('error_message')) db.exec(`ALTER TABLE tasks ADD COLUMN error_message TEXT`)
if (!hasCol('resolution')) db.exec(`ALTER TABLE tasks ADD COLUMN resolution TEXT`)
if (!hasCol('feedback_rating')) db.exec(`ALTER TABLE tasks ADD COLUMN feedback_rating INTEGER`)
if (!hasCol('feedback_notes')) db.exec(`ALTER TABLE tasks ADD COLUMN feedback_notes TEXT`)
if (!hasCol('retry_count')) db.exec(`ALTER TABLE tasks ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0`)
if (!hasCol('completed_at')) db.exec(`ALTER TABLE tasks ADD COLUMN completed_at INTEGER`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_outcome ON tasks(outcome)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_completed_at ON tasks(completed_at)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_outcome ON tasks(workspace_id, outcome, completed_at)`)
}
} }
] ]

View File

@ -37,6 +37,13 @@ export const createTaskSchema = z.object({
due_date: z.number().optional(), due_date: z.number().optional(),
estimated_hours: z.number().min(0).optional(), estimated_hours: z.number().min(0).optional(),
actual_hours: z.number().min(0).optional(), actual_hours: z.number().min(0).optional(),
outcome: z.enum(['success', 'failed', 'partial', 'abandoned']).optional(),
error_message: z.string().max(5000).optional(),
resolution: z.string().max(5000).optional(),
feedback_rating: z.number().int().min(1).max(5).optional(),
feedback_notes: z.string().max(5000).optional(),
retry_count: z.number().int().min(0).optional(),
completed_at: z.number().optional(),
tags: z.array(z.string()).default([] as string[]), tags: z.array(z.string()).default([] as string[]),
metadata: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>), metadata: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>),
}) })

View File

@ -105,6 +105,13 @@ export interface Task {
due_date?: number due_date?: number
estimated_hours?: number estimated_hours?: number
actual_hours?: number actual_hours?: number
outcome?: 'success' | 'failed' | 'partial' | 'abandoned'
error_message?: string
resolution?: string
feedback_rating?: number
feedback_notes?: string
retry_count?: number
completed_at?: number
tags?: string[] tags?: string[]
metadata?: any metadata?: any
} }

View File

@ -0,0 +1,85 @@
import { test, expect } from '@playwright/test'
import { API_KEY_HEADER } from './helpers'
test.describe('Task Outcomes API', () => {
test('POST /api/tasks with done status auto-populates completed_at and stores outcome fields', async ({ request }) => {
const title = `e2e-outcome-task-${Date.now()}`
const res = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: {
title,
status: 'done',
outcome: 'success',
feedback_rating: 5,
feedback_notes: 'Resolved cleanly',
retry_count: 1,
},
})
expect(res.status()).toBe(201)
const body = await res.json()
expect(body.task.title).toBe(title)
expect(body.task.status).toBe('done')
expect(body.task.outcome).toBe('success')
expect(body.task.feedback_rating).toBe(5)
expect(body.task.retry_count).toBe(1)
expect(typeof body.task.completed_at).toBe('number')
expect(body.task.completed_at).toBeGreaterThan(0)
})
test('GET /api/tasks/outcomes returns summary and error patterns', async ({ request }) => {
const base = Date.now()
const successRes = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: {
title: `e2e-outcome-success-${base}`,
status: 'done',
assigned_to: 'outcome-agent-a',
priority: 'high',
outcome: 'success',
},
})
expect(successRes.status()).toBe(201)
const failedRes = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: {
title: `e2e-outcome-failed-${base}`,
status: 'done',
assigned_to: 'outcome-agent-b',
priority: 'medium',
outcome: 'failed',
error_message: 'Dependency timeout',
resolution: 'Increased timeout and retried',
retry_count: 2,
},
})
expect(failedRes.status()).toBe(201)
const metrics = await request.get('/api/tasks/outcomes?timeframe=all', {
headers: API_KEY_HEADER,
})
expect(metrics.status()).toBe(200)
const body = await metrics.json()
expect(body).toHaveProperty('summary')
expect(body).toHaveProperty('by_agent')
expect(body).toHaveProperty('by_priority')
expect(body).toHaveProperty('common_errors')
expect(body.summary.total_done).toBeGreaterThanOrEqual(2)
expect(body.summary.by_outcome.success).toBeGreaterThanOrEqual(1)
expect(body.summary.by_outcome.failed).toBeGreaterThanOrEqual(1)
expect(body.by_agent['outcome-agent-a'].success).toBeGreaterThanOrEqual(1)
expect(body.by_agent['outcome-agent-b'].failed).toBeGreaterThanOrEqual(1)
const timeoutError = body.common_errors.find((e: any) => e.error_message === 'Dependency timeout')
expect(timeoutError).toBeTruthy()
})
test('GET /api/tasks/outcomes requires auth', async ({ request }) => {
const res = await request.get('/api/tasks/outcomes?timeframe=all')
expect(res.status()).toBe(401)
})
})