feat(tasks): add outcome tracking and feedback analytics
This commit is contained in:
parent
e948a1399b
commit
6cf4256460
|
|
@ -123,6 +123,13 @@ export async function PUT(
|
|||
due_date,
|
||||
estimated_hours,
|
||||
actual_hours,
|
||||
outcome,
|
||||
error_message,
|
||||
resolution,
|
||||
feedback_rating,
|
||||
feedback_notes,
|
||||
retry_count,
|
||||
completed_at,
|
||||
tags,
|
||||
metadata
|
||||
} = body;
|
||||
|
|
@ -219,6 +226,37 @@ export async function PUT(
|
|||
fieldsToUpdate.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) {
|
||||
fieldsToUpdate.push('tags = ?');
|
||||
updateParams.push(JSON.stringify(tags));
|
||||
|
|
@ -293,6 +331,9 @@ export async function PUT(
|
|||
if (project_id !== undefined && project_id !== currentTask.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) {
|
||||
const newMentionRecipients = new Set(descriptionMentionResolution.recipients);
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -171,6 +171,14 @@ export async function POST(request: NextRequest) {
|
|||
created_by = user?.username || 'system',
|
||||
due_date,
|
||||
estimated_hours,
|
||||
actual_hours,
|
||||
outcome,
|
||||
error_message,
|
||||
resolution,
|
||||
feedback_rating,
|
||||
feedback_notes,
|
||||
retry_count = 0,
|
||||
completed_at,
|
||||
tags = [],
|
||||
metadata = {}
|
||||
} = body;
|
||||
|
|
@ -191,6 +199,8 @@ export async function POST(request: NextRequest) {
|
|||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedCompletedAt = completed_at ?? (normalizedStatus === 'done' ? now : null)
|
||||
|
||||
const createTaskTx = db.transaction(() => {
|
||||
const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
|
||||
db.prepare(`
|
||||
|
|
@ -207,8 +217,10 @@ export async function POST(request: NextRequest) {
|
|||
const insertStmt = db.prepare(`
|
||||
INSERT INTO tasks (
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
created_at, updated_at, due_date, estimated_hours, actual_hours,
|
||||
outcome, error_message, resolution, feedback_rating, feedback_notes, retry_count, completed_at,
|
||||
tags, metadata, workspace_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const dbResult = insertStmt.run(
|
||||
|
|
@ -224,6 +236,14 @@ export async function POST(request: NextRequest) {
|
|||
now,
|
||||
due_date,
|
||||
estimated_hours,
|
||||
actual_hours,
|
||||
outcome,
|
||||
error_message,
|
||||
resolution,
|
||||
feedback_rating,
|
||||
feedback_notes,
|
||||
retry_count,
|
||||
resolvedCompletedAt,
|
||||
JSON.stringify(tags),
|
||||
JSON.stringify(metadata),
|
||||
workspaceId
|
||||
|
|
@ -238,7 +258,8 @@ export async function POST(request: NextRequest) {
|
|||
title,
|
||||
status: normalizedStatus,
|
||||
priority,
|
||||
assigned_to
|
||||
assigned_to,
|
||||
...(outcome ? { outcome } : {})
|
||||
}, workspaceId);
|
||||
|
||||
if (created_by) {
|
||||
|
|
@ -317,6 +338,11 @@ export async function PUT(request: NextRequest) {
|
|||
SET status = ?, updated_at = ?
|
||||
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
|
||||
|
||||
|
|
@ -329,7 +355,11 @@ export async function PUT(request: NextRequest) {
|
|||
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
|
||||
if (oldTask && oldTask.status !== task.status) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,13 @@ export interface Task {
|
|||
due_date?: number
|
||||
estimated_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[]
|
||||
metadata?: any
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,27 @@ describe('createTaskSchema', () => {
|
|||
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', () => {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,13 @@ export interface Task {
|
|||
due_date?: number;
|
||||
estimated_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
|
||||
metadata?: string; // JSON string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ export const createTaskSchema = z.object({
|
|||
due_date: z.number().optional(),
|
||||
estimated_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[]),
|
||||
metadata: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -105,6 +105,13 @@ export interface Task {
|
|||
due_date?: number
|
||||
estimated_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[]
|
||||
metadata?: any
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue