diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 68898e2..40bf44f 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -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); diff --git a/src/app/api/tasks/outcomes/route.ts b/src/app/api/tasks/outcomes/route.ts new file mode 100644 index 0000000..67c62c6 --- /dev/null +++ b/src/app/api/tasks/outcomes/route.ts @@ -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 & { total: number; success_rate: number }> = {} + const byPriority: Record & { total: number; success_rate: number }> = {} + const errorMap = new Map() + + 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] += 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] += 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 }) + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 09b7883..931d2de 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -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) { diff --git a/src/index.ts b/src/index.ts index 54c36f5..0176c30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 } diff --git a/src/lib/__tests__/validation.test.ts b/src/lib/__tests__/validation.test.ts index 425dd0b..31d8a24 100644 --- a/src/lib/__tests__/validation.test.ts +++ b/src/lib/__tests__/validation.test.ts @@ -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', () => { diff --git a/src/lib/db.ts b/src/lib/db.ts index 611e3fb..fb1a457 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 } diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index 0b1fc70..72dcf4e 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -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)`) + } } ] diff --git a/src/lib/validation.ts b/src/lib/validation.ts index fb13b93..0303d30 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -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), }) diff --git a/src/store/index.ts b/src/store/index.ts index e130a16..0ad9784 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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 } diff --git a/tests/task-outcomes.spec.ts b/tests/task-outcomes.spec.ts new file mode 100644 index 0000000..2e118fd --- /dev/null +++ b/tests/task-outcomes.spec.ts @@ -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) + }) +})