Nyk commited on
Commit
3fbaffd
·
1 Parent(s): 6746400

feat(tasks): add outcome tracking and feedback analytics

Browse files
src/app/api/tasks/[id]/route.ts CHANGED
@@ -123,6 +123,13 @@ export async function PUT(
123
  due_date,
124
  estimated_hours,
125
  actual_hours,
 
 
 
 
 
 
 
126
  tags,
127
  metadata
128
  } = body;
@@ -219,6 +226,37 @@ export async function PUT(
219
  fieldsToUpdate.push('actual_hours = ?');
220
  updateParams.push(actual_hours);
221
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  if (tags !== undefined) {
223
  fieldsToUpdate.push('tags = ?');
224
  updateParams.push(JSON.stringify(tags));
@@ -293,6 +331,9 @@ export async function PUT(
293
  if (project_id !== undefined && project_id !== currentTask.project_id) {
294
  changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`);
295
  }
 
 
 
296
 
297
  if (descriptionMentionResolution) {
298
  const newMentionRecipients = new Set(descriptionMentionResolution.recipients);
 
123
  due_date,
124
  estimated_hours,
125
  actual_hours,
126
+ outcome,
127
+ error_message,
128
+ resolution,
129
+ feedback_rating,
130
+ feedback_notes,
131
+ retry_count,
132
+ completed_at,
133
  tags,
134
  metadata
135
  } = body;
 
226
  fieldsToUpdate.push('actual_hours = ?');
227
  updateParams.push(actual_hours);
228
  }
229
+ if (outcome !== undefined) {
230
+ fieldsToUpdate.push('outcome = ?');
231
+ updateParams.push(outcome);
232
+ }
233
+ if (error_message !== undefined) {
234
+ fieldsToUpdate.push('error_message = ?');
235
+ updateParams.push(error_message);
236
+ }
237
+ if (resolution !== undefined) {
238
+ fieldsToUpdate.push('resolution = ?');
239
+ updateParams.push(resolution);
240
+ }
241
+ if (feedback_rating !== undefined) {
242
+ fieldsToUpdate.push('feedback_rating = ?');
243
+ updateParams.push(feedback_rating);
244
+ }
245
+ if (feedback_notes !== undefined) {
246
+ fieldsToUpdate.push('feedback_notes = ?');
247
+ updateParams.push(feedback_notes);
248
+ }
249
+ if (retry_count !== undefined) {
250
+ fieldsToUpdate.push('retry_count = ?');
251
+ updateParams.push(retry_count);
252
+ }
253
+ if (completed_at !== undefined) {
254
+ fieldsToUpdate.push('completed_at = ?');
255
+ updateParams.push(completed_at);
256
+ } else if (normalizedStatus === 'done' && !currentTask.completed_at) {
257
+ fieldsToUpdate.push('completed_at = ?');
258
+ updateParams.push(now);
259
+ }
260
  if (tags !== undefined) {
261
  fieldsToUpdate.push('tags = ?');
262
  updateParams.push(JSON.stringify(tags));
 
331
  if (project_id !== undefined && project_id !== currentTask.project_id) {
332
  changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`);
333
  }
334
+ if (outcome !== undefined && outcome !== currentTask.outcome) {
335
+ changes.push(`outcome: ${currentTask.outcome || 'unset'} → ${outcome || 'unset'}`);
336
+ }
337
 
338
  if (descriptionMentionResolution) {
339
  const newMentionRecipients = new Set(descriptionMentionResolution.recipients);
src/app/api/tasks/outcomes/route.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { requireRole } from '@/lib/auth'
3
+ import { getDatabase } from '@/lib/db'
4
+ import { logger } from '@/lib/logger'
5
+
6
+ type Outcome = 'success' | 'failed' | 'partial' | 'abandoned'
7
+
8
+ function resolveSince(timeframe: string): number {
9
+ const now = Math.floor(Date.now() / 1000)
10
+ switch (timeframe) {
11
+ case 'day':
12
+ return now - 24 * 60 * 60
13
+ case 'week':
14
+ return now - 7 * 24 * 60 * 60
15
+ case 'month':
16
+ return now - 30 * 24 * 60 * 60
17
+ case 'all':
18
+ default:
19
+ return 0
20
+ }
21
+ }
22
+
23
+ function outcomeBuckets() {
24
+ return {
25
+ success: 0,
26
+ failed: 0,
27
+ partial: 0,
28
+ abandoned: 0,
29
+ unknown: 0,
30
+ }
31
+ }
32
+
33
+ export async function GET(request: NextRequest) {
34
+ const auth = requireRole(request, 'viewer')
35
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
36
+
37
+ try {
38
+ const workspaceId = auth.user.workspace_id ?? 1
39
+ const { searchParams } = new URL(request.url)
40
+ const timeframe = (searchParams.get('timeframe') || 'all').trim().toLowerCase()
41
+ const since = resolveSince(timeframe)
42
+
43
+ const db = getDatabase()
44
+ const rows = db.prepare(`
45
+ SELECT
46
+ id,
47
+ assigned_to,
48
+ priority,
49
+ outcome,
50
+ error_message,
51
+ retry_count,
52
+ created_at,
53
+ completed_at
54
+ FROM tasks
55
+ WHERE workspace_id = ?
56
+ AND status = 'done'
57
+ AND (? = 0 OR COALESCE(completed_at, updated_at) >= ?)
58
+ `).all(workspaceId, since, since) as Array<{
59
+ id: number
60
+ assigned_to?: string | null
61
+ priority?: string | null
62
+ outcome?: string | null
63
+ error_message?: string | null
64
+ retry_count?: number | null
65
+ created_at?: number | null
66
+ completed_at?: number | null
67
+ }>
68
+
69
+ const summary = {
70
+ total_done: rows.length,
71
+ with_outcome: 0,
72
+ by_outcome: outcomeBuckets(),
73
+ avg_retry_count: 0,
74
+ avg_time_to_resolution_seconds: 0,
75
+ success_rate: 0,
76
+ }
77
+
78
+ const byAgent: Record<string, ReturnType<typeof outcomeBuckets> & { total: number; success_rate: number }> = {}
79
+ const byPriority: Record<string, ReturnType<typeof outcomeBuckets> & { total: number; success_rate: number }> = {}
80
+ const errorMap = new Map<string, number>()
81
+
82
+ let totalRetryCount = 0
83
+ let totalResolutionSeconds = 0
84
+ let resolutionCount = 0
85
+
86
+ for (const row of rows) {
87
+ const outcome = (row.outcome || 'unknown') as Outcome | 'unknown'
88
+ const assignedTo = row.assigned_to || 'unassigned'
89
+ const priority = row.priority || 'unknown'
90
+ const retryCount = Number.isFinite(row.retry_count) ? Number(row.retry_count) : 0
91
+
92
+ if (outcome !== 'unknown') summary.with_outcome += 1
93
+ if (outcome in summary.by_outcome) {
94
+ summary.by_outcome[outcome as keyof typeof summary.by_outcome] += 1
95
+ } else {
96
+ summary.by_outcome.unknown += 1
97
+ }
98
+
99
+ if (!byAgent[assignedTo]) {
100
+ byAgent[assignedTo] = { ...outcomeBuckets(), total: 0, success_rate: 0 }
101
+ }
102
+ byAgent[assignedTo].total += 1
103
+ if (outcome in byAgent[assignedTo]) {
104
+ byAgent[assignedTo][outcome as keyof ReturnType<typeof outcomeBuckets>] += 1
105
+ } else {
106
+ byAgent[assignedTo].unknown += 1
107
+ }
108
+
109
+ if (!byPriority[priority]) {
110
+ byPriority[priority] = { ...outcomeBuckets(), total: 0, success_rate: 0 }
111
+ }
112
+ byPriority[priority].total += 1
113
+ if (outcome in byPriority[priority]) {
114
+ byPriority[priority][outcome as keyof ReturnType<typeof outcomeBuckets>] += 1
115
+ } else {
116
+ byPriority[priority].unknown += 1
117
+ }
118
+
119
+ totalRetryCount += retryCount
120
+
121
+ if (row.completed_at && row.created_at && row.completed_at >= row.created_at) {
122
+ totalResolutionSeconds += (row.completed_at - row.created_at)
123
+ resolutionCount += 1
124
+ }
125
+
126
+ const errorMessage = (row.error_message || '').trim()
127
+ if (errorMessage) {
128
+ errorMap.set(errorMessage, (errorMap.get(errorMessage) || 0) + 1)
129
+ }
130
+ }
131
+
132
+ summary.avg_retry_count = rows.length > 0 ? totalRetryCount / rows.length : 0
133
+ summary.avg_time_to_resolution_seconds = resolutionCount > 0 ? totalResolutionSeconds / resolutionCount : 0
134
+ summary.success_rate = summary.with_outcome > 0 ? summary.by_outcome.success / summary.with_outcome : 0
135
+
136
+ for (const agent of Object.values(byAgent)) {
137
+ const withOutcome = agent.total - agent.unknown
138
+ agent.success_rate = withOutcome > 0 ? agent.success / withOutcome : 0
139
+ }
140
+
141
+ for (const priority of Object.values(byPriority)) {
142
+ const withOutcome = priority.total - priority.unknown
143
+ priority.success_rate = withOutcome > 0 ? priority.success / withOutcome : 0
144
+ }
145
+
146
+ const commonErrors = [...errorMap.entries()]
147
+ .sort((a, b) => b[1] - a[1])
148
+ .slice(0, 10)
149
+ .map(([error_message, count]) => ({ error_message, count }))
150
+
151
+ return NextResponse.json({
152
+ timeframe,
153
+ summary,
154
+ by_agent: byAgent,
155
+ by_priority: byPriority,
156
+ common_errors: commonErrors,
157
+ record_count: rows.length,
158
+ })
159
+ } catch (error) {
160
+ logger.error({ err: error }, 'GET /api/tasks/outcomes error')
161
+ return NextResponse.json({ error: 'Failed to fetch task outcomes' }, { status: 500 })
162
+ }
163
+ }
src/app/api/tasks/route.ts CHANGED
@@ -171,6 +171,14 @@ export async function POST(request: NextRequest) {
171
  created_by = user?.username || 'system',
172
  due_date,
173
  estimated_hours,
 
 
 
 
 
 
 
 
174
  tags = [],
175
  metadata = {}
176
  } = body;
@@ -191,6 +199,8 @@ export async function POST(request: NextRequest) {
191
  }, { status: 400 });
192
  }
193
 
 
 
194
  const createTaskTx = db.transaction(() => {
195
  const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
196
  db.prepare(`
@@ -207,8 +217,10 @@ export async function POST(request: NextRequest) {
207
  const insertStmt = db.prepare(`
208
  INSERT INTO tasks (
209
  title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by,
210
- created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id
211
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 
 
212
  `)
213
 
214
  const dbResult = insertStmt.run(
@@ -224,6 +236,14 @@ export async function POST(request: NextRequest) {
224
  now,
225
  due_date,
226
  estimated_hours,
 
 
 
 
 
 
 
 
227
  JSON.stringify(tags),
228
  JSON.stringify(metadata),
229
  workspaceId
@@ -238,7 +258,8 @@ export async function POST(request: NextRequest) {
238
  title,
239
  status: normalizedStatus,
240
  priority,
241
- assigned_to
 
242
  }, workspaceId);
243
 
244
  if (created_by) {
@@ -317,6 +338,11 @@ export async function PUT(request: NextRequest) {
317
  SET status = ?, updated_at = ?
318
  WHERE id = ? AND workspace_id = ?
319
  `);
 
 
 
 
 
320
 
321
  const actor = auth.user.username
322
 
@@ -329,7 +355,11 @@ export async function PUT(request: NextRequest) {
329
  throw new Error(`Aegis approval required for task ${task.id}`)
330
  }
331
 
332
- updateStmt.run(task.status, now, task.id, workspaceId);
 
 
 
 
333
 
334
  // Log status change if different
335
  if (oldTask && oldTask.status !== task.status) {
 
171
  created_by = user?.username || 'system',
172
  due_date,
173
  estimated_hours,
174
+ actual_hours,
175
+ outcome,
176
+ error_message,
177
+ resolution,
178
+ feedback_rating,
179
+ feedback_notes,
180
+ retry_count = 0,
181
+ completed_at,
182
  tags = [],
183
  metadata = {}
184
  } = body;
 
199
  }, { status: 400 });
200
  }
201
 
202
+ const resolvedCompletedAt = completed_at ?? (normalizedStatus === 'done' ? now : null)
203
+
204
  const createTaskTx = db.transaction(() => {
205
  const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
206
  db.prepare(`
 
217
  const insertStmt = db.prepare(`
218
  INSERT INTO tasks (
219
  title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by,
220
+ created_at, updated_at, due_date, estimated_hours, actual_hours,
221
+ outcome, error_message, resolution, feedback_rating, feedback_notes, retry_count, completed_at,
222
+ tags, metadata, workspace_id
223
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
224
  `)
225
 
226
  const dbResult = insertStmt.run(
 
236
  now,
237
  due_date,
238
  estimated_hours,
239
+ actual_hours,
240
+ outcome,
241
+ error_message,
242
+ resolution,
243
+ feedback_rating,
244
+ feedback_notes,
245
+ retry_count,
246
+ resolvedCompletedAt,
247
  JSON.stringify(tags),
248
  JSON.stringify(metadata),
249
  workspaceId
 
258
  title,
259
  status: normalizedStatus,
260
  priority,
261
+ assigned_to,
262
+ ...(outcome ? { outcome } : {})
263
  }, workspaceId);
264
 
265
  if (created_by) {
 
338
  SET status = ?, updated_at = ?
339
  WHERE id = ? AND workspace_id = ?
340
  `);
341
+ const updateDoneStmt = db.prepare(`
342
+ UPDATE tasks
343
+ SET status = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?)
344
+ WHERE id = ? AND workspace_id = ?
345
+ `);
346
 
347
  const actor = auth.user.username
348
 
 
355
  throw new Error(`Aegis approval required for task ${task.id}`)
356
  }
357
 
358
+ if (task.status === 'done') {
359
+ updateDoneStmt.run(task.status, now, now, task.id, workspaceId);
360
+ } else {
361
+ updateStmt.run(task.status, now, task.id, workspaceId);
362
+ }
363
 
364
  // Log status change if different
365
  if (oldTask && oldTask.status !== task.status) {
src/index.ts CHANGED
@@ -99,6 +99,13 @@ export interface Task {
99
  due_date?: number
100
  estimated_hours?: number
101
  actual_hours?: number
 
 
 
 
 
 
 
102
  tags?: string[]
103
  metadata?: any
104
  }
 
99
  due_date?: number
100
  estimated_hours?: number
101
  actual_hours?: number
102
+ outcome?: 'success' | 'failed' | 'partial' | 'abandoned'
103
+ error_message?: string
104
+ resolution?: string
105
+ feedback_rating?: number
106
+ feedback_notes?: string
107
+ retry_count?: number
108
+ completed_at?: number
109
  tags?: string[]
110
  metadata?: any
111
  }
src/lib/__tests__/validation.test.ts CHANGED
@@ -41,6 +41,27 @@ describe('createTaskSchema', () => {
41
  expect(result.success).toBe(true)
42
  }
43
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  })
45
 
46
  describe('createAgentSchema', () => {
 
41
  expect(result.success).toBe(true)
42
  }
43
  })
44
+
45
+ it('accepts outcome and feedback fields', () => {
46
+ const result = createTaskSchema.safeParse({
47
+ title: 'Investigate flaky test',
48
+ status: 'done',
49
+ outcome: 'partial',
50
+ feedback_rating: 4,
51
+ feedback_notes: 'Needs follow-up monitoring',
52
+ retry_count: 2,
53
+ completed_at: 1735600000,
54
+ })
55
+ expect(result.success).toBe(true)
56
+ })
57
+
58
+ it('rejects invalid feedback_rating', () => {
59
+ const result = createTaskSchema.safeParse({
60
+ title: 'Invalid rating test',
61
+ feedback_rating: 6,
62
+ })
63
+ expect(result.success).toBe(false)
64
+ })
65
  })
66
 
67
  describe('createAgentSchema', () => {
src/lib/db.ts CHANGED
@@ -177,6 +177,13 @@ export interface Task {
177
  due_date?: number;
178
  estimated_hours?: number;
179
  actual_hours?: number;
 
 
 
 
 
 
 
180
  tags?: string; // JSON string
181
  metadata?: string; // JSON string
182
  }
 
177
  due_date?: number;
178
  estimated_hours?: number;
179
  actual_hours?: number;
180
+ outcome?: 'success' | 'failed' | 'partial' | 'abandoned';
181
+ error_message?: string;
182
+ resolution?: string;
183
+ feedback_rating?: number;
184
+ feedback_notes?: string;
185
+ retry_count?: number;
186
+ completed_at?: number;
187
  tags?: string; // JSON string
188
  metadata?: string; // JSON string
189
  }
src/lib/migrations.ts CHANGED
@@ -753,6 +753,30 @@ const migrations: Migration[] = [
753
  }
754
  }
755
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
756
  }
757
  ]
758
 
 
753
  }
754
  }
755
  }
756
+ },
757
+ {
758
+ id: '025_task_outcome_tracking',
759
+ up: (db) => {
760
+ const hasTasks = db
761
+ .prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`)
762
+ .get() as { ok?: number } | undefined
763
+ if (!hasTasks?.ok) return
764
+
765
+ const taskCols = db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>
766
+ const hasCol = (name: string) => taskCols.some((c) => c.name === name)
767
+
768
+ if (!hasCol('outcome')) db.exec(`ALTER TABLE tasks ADD COLUMN outcome TEXT`)
769
+ if (!hasCol('error_message')) db.exec(`ALTER TABLE tasks ADD COLUMN error_message TEXT`)
770
+ if (!hasCol('resolution')) db.exec(`ALTER TABLE tasks ADD COLUMN resolution TEXT`)
771
+ if (!hasCol('feedback_rating')) db.exec(`ALTER TABLE tasks ADD COLUMN feedback_rating INTEGER`)
772
+ if (!hasCol('feedback_notes')) db.exec(`ALTER TABLE tasks ADD COLUMN feedback_notes TEXT`)
773
+ if (!hasCol('retry_count')) db.exec(`ALTER TABLE tasks ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0`)
774
+ if (!hasCol('completed_at')) db.exec(`ALTER TABLE tasks ADD COLUMN completed_at INTEGER`)
775
+
776
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_outcome ON tasks(outcome)`)
777
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_completed_at ON tasks(completed_at)`)
778
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_outcome ON tasks(workspace_id, outcome, completed_at)`)
779
+ }
780
  }
781
  ]
782
 
src/lib/validation.ts CHANGED
@@ -37,6 +37,13 @@ export const createTaskSchema = z.object({
37
  due_date: z.number().optional(),
38
  estimated_hours: z.number().min(0).optional(),
39
  actual_hours: z.number().min(0).optional(),
 
 
 
 
 
 
 
40
  tags: z.array(z.string()).default([] as string[]),
41
  metadata: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>),
42
  })
 
37
  due_date: z.number().optional(),
38
  estimated_hours: z.number().min(0).optional(),
39
  actual_hours: z.number().min(0).optional(),
40
+ outcome: z.enum(['success', 'failed', 'partial', 'abandoned']).optional(),
41
+ error_message: z.string().max(5000).optional(),
42
+ resolution: z.string().max(5000).optional(),
43
+ feedback_rating: z.number().int().min(1).max(5).optional(),
44
+ feedback_notes: z.string().max(5000).optional(),
45
+ retry_count: z.number().int().min(0).optional(),
46
+ completed_at: z.number().optional(),
47
  tags: z.array(z.string()).default([] as string[]),
48
  metadata: z.record(z.string(), z.unknown()).default({} as Record<string, unknown>),
49
  })
src/store/index.ts CHANGED
@@ -105,6 +105,13 @@ export interface Task {
105
  due_date?: number
106
  estimated_hours?: number
107
  actual_hours?: number
 
 
 
 
 
 
 
108
  tags?: string[]
109
  metadata?: any
110
  }
 
105
  due_date?: number
106
  estimated_hours?: number
107
  actual_hours?: number
108
+ outcome?: 'success' | 'failed' | 'partial' | 'abandoned'
109
+ error_message?: string
110
+ resolution?: string
111
+ feedback_rating?: number
112
+ feedback_notes?: string
113
+ retry_count?: number
114
+ completed_at?: number
115
  tags?: string[]
116
  metadata?: any
117
  }
tests/task-outcomes.spec.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+ import { API_KEY_HEADER } from './helpers'
3
+
4
+ test.describe('Task Outcomes API', () => {
5
+ test('POST /api/tasks with done status auto-populates completed_at and stores outcome fields', async ({ request }) => {
6
+ const title = `e2e-outcome-task-${Date.now()}`
7
+ const res = await request.post('/api/tasks', {
8
+ headers: API_KEY_HEADER,
9
+ data: {
10
+ title,
11
+ status: 'done',
12
+ outcome: 'success',
13
+ feedback_rating: 5,
14
+ feedback_notes: 'Resolved cleanly',
15
+ retry_count: 1,
16
+ },
17
+ })
18
+
19
+ expect(res.status()).toBe(201)
20
+ const body = await res.json()
21
+ expect(body.task.title).toBe(title)
22
+ expect(body.task.status).toBe('done')
23
+ expect(body.task.outcome).toBe('success')
24
+ expect(body.task.feedback_rating).toBe(5)
25
+ expect(body.task.retry_count).toBe(1)
26
+ expect(typeof body.task.completed_at).toBe('number')
27
+ expect(body.task.completed_at).toBeGreaterThan(0)
28
+ })
29
+
30
+ test('GET /api/tasks/outcomes returns summary and error patterns', async ({ request }) => {
31
+ const base = Date.now()
32
+
33
+ const successRes = await request.post('/api/tasks', {
34
+ headers: API_KEY_HEADER,
35
+ data: {
36
+ title: `e2e-outcome-success-${base}`,
37
+ status: 'done',
38
+ assigned_to: 'outcome-agent-a',
39
+ priority: 'high',
40
+ outcome: 'success',
41
+ },
42
+ })
43
+ expect(successRes.status()).toBe(201)
44
+
45
+ const failedRes = await request.post('/api/tasks', {
46
+ headers: API_KEY_HEADER,
47
+ data: {
48
+ title: `e2e-outcome-failed-${base}`,
49
+ status: 'done',
50
+ assigned_to: 'outcome-agent-b',
51
+ priority: 'medium',
52
+ outcome: 'failed',
53
+ error_message: 'Dependency timeout',
54
+ resolution: 'Increased timeout and retried',
55
+ retry_count: 2,
56
+ },
57
+ })
58
+ expect(failedRes.status()).toBe(201)
59
+
60
+ const metrics = await request.get('/api/tasks/outcomes?timeframe=all', {
61
+ headers: API_KEY_HEADER,
62
+ })
63
+ expect(metrics.status()).toBe(200)
64
+ const body = await metrics.json()
65
+
66
+ expect(body).toHaveProperty('summary')
67
+ expect(body).toHaveProperty('by_agent')
68
+ expect(body).toHaveProperty('by_priority')
69
+ expect(body).toHaveProperty('common_errors')
70
+
71
+ expect(body.summary.total_done).toBeGreaterThanOrEqual(2)
72
+ expect(body.summary.by_outcome.success).toBeGreaterThanOrEqual(1)
73
+ expect(body.summary.by_outcome.failed).toBeGreaterThanOrEqual(1)
74
+ expect(body.by_agent['outcome-agent-a'].success).toBeGreaterThanOrEqual(1)
75
+ expect(body.by_agent['outcome-agent-b'].failed).toBeGreaterThanOrEqual(1)
76
+
77
+ const timeoutError = body.common_errors.find((e: any) => e.error_message === 'Dependency timeout')
78
+ expect(timeoutError).toBeTruthy()
79
+ })
80
+
81
+ test('GET /api/tasks/outcomes requires auth', async ({ request }) => {
82
+ const res = await request.get('/api/tasks/outcomes?timeframe=all')
83
+ expect(res.status()).toBe(401)
84
+ })
85
+ })