Nyk commited on
Commit
bc8debc
·
2 Parent(s): 3fbaffda6ce3e0

chore: merge main into feat/task-outcome-feedback-192 to resolve PR #207 conflicts

Browse files
src/app/api/agents/[id]/heartbeat/route.ts CHANGED
@@ -180,7 +180,7 @@ export async function GET(
180
  * - connection_id: update direct_connections.last_heartbeat
181
  * - status: agent status override
182
  * - last_activity: activity description
183
- * - token_usage: { model, inputTokens, outputTokens } for inline token reporting
184
  */
185
  export async function POST(
186
  request: NextRequest,
@@ -221,10 +221,35 @@ export async function POST(
221
 
222
  if (agent) {
223
  const sessionId = `${agent.name}:cli`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  db.prepare(
225
- `INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at)
226
- VALUES (?, ?, ?, ?, ?)`
227
- ).run(token_usage.model, sessionId, token_usage.inputTokens, token_usage.outputTokens, now);
 
 
 
 
 
 
 
 
228
  tokenRecorded = true;
229
  }
230
  }
 
180
  * - connection_id: update direct_connections.last_heartbeat
181
  * - status: agent status override
182
  * - last_activity: activity description
183
+ * - token_usage: { model, inputTokens, outputTokens, taskId? } for inline token reporting
184
  */
185
  export async function POST(
186
  request: NextRequest,
 
221
 
222
  if (agent) {
223
  const sessionId = `${agent.name}:cli`;
224
+ const parsedTaskId =
225
+ token_usage.taskId != null && Number.isFinite(Number(token_usage.taskId))
226
+ ? Number(token_usage.taskId)
227
+ : null
228
+
229
+ let taskId: number | null = null
230
+ if (parsedTaskId && parsedTaskId > 0) {
231
+ const taskRow = db.prepare(
232
+ 'SELECT id FROM tasks WHERE id = ? AND workspace_id = ?'
233
+ ).get(parsedTaskId, workspaceId) as { id?: number } | undefined
234
+ if (taskRow?.id) {
235
+ taskId = taskRow.id
236
+ } else {
237
+ logger.warn({ taskId: parsedTaskId, workspaceId, agent: agent.name }, 'Ignoring token usage with unknown taskId')
238
+ }
239
+ }
240
+
241
  db.prepare(
242
+ `INSERT INTO token_usage (model, session_id, input_tokens, output_tokens, created_at, workspace_id, task_id)
243
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
244
+ ).run(
245
+ token_usage.model,
246
+ sessionId,
247
+ token_usage.inputTokens,
248
+ token_usage.outputTokens,
249
+ now,
250
+ workspaceId,
251
+ taskId
252
+ );
253
  tokenRecorded = true;
254
  }
255
  }
src/app/api/tokens/route.ts CHANGED
@@ -8,6 +8,7 @@ import { logger } from '@/lib/logger'
8
  import { getDatabase } from '@/lib/db'
9
  import { calculateTokenCost } from '@/lib/token-pricing'
10
  import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions'
 
11
 
12
  const DATA_PATH = config.tokensPath
13
 
@@ -22,6 +23,8 @@ interface TokenUsageRecord {
22
  totalTokens: number
23
  cost: number
24
  operation: string
 
 
25
  duration?: number
26
  }
27
 
@@ -40,6 +43,8 @@ interface ExportData {
40
  sessions: Record<string, TokenStats>
41
  }
42
 
 
 
43
  function extractAgentName(sessionId: string): string {
44
  const trimmed = sessionId.trim()
45
  if (!trimmed) return 'unknown'
@@ -53,18 +58,21 @@ interface DbTokenUsageRow {
53
  session_id: string
54
  input_tokens: number
55
  output_tokens: number
 
 
56
  created_at: number
57
  }
58
 
59
- function loadTokenDataFromDb(providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
60
  try {
61
  const db = getDatabase()
62
  const rows = db.prepare(`
63
- SELECT id, model, session_id, input_tokens, output_tokens, created_at
64
  FROM token_usage
 
65
  ORDER BY created_at DESC, id DESC
66
  LIMIT 10000
67
- `).all() as DbTokenUsageRow[]
68
 
69
  return rows.map((row) => {
70
  const totalTokens = row.input_tokens + row.output_tokens
@@ -79,6 +87,8 @@ function loadTokenDataFromDb(providerSubscriptions: Record<string, boolean>): To
79
  totalTokens,
80
  cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }),
81
  operation: 'heartbeat',
 
 
82
  }
83
  })
84
  } catch (error) {
@@ -107,6 +117,8 @@ function normalizeTokenRecord(
107
  totalTokens,
108
  cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })),
109
  operation: String(record.operation ?? 'chat_completion'),
 
 
110
  duration: record.duration,
111
  }
112
  }
@@ -124,6 +136,8 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] {
124
  record.outputTokens,
125
  record.totalTokens,
126
  record.operation,
 
 
127
  record.duration ?? '',
128
  ].join('|')
129
  if (seen.has(key)) continue
@@ -134,7 +148,7 @@ function dedupeTokenRecords(records: TokenUsageRecord[]): TokenUsageRecord[] {
134
  return deduped
135
  }
136
 
137
- async function loadTokenDataFromFile(providerSubscriptions: Record<string, boolean>): Promise<TokenUsageRecord[]> {
138
  try {
139
  ensureDirExists(dirname(DATA_PATH))
140
  await access(DATA_PATH)
@@ -145,6 +159,11 @@ async function loadTokenDataFromFile(providerSubscriptions: Record<string, boole
145
  return parsed
146
  .map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record, providerSubscriptions))
147
  .filter((record): record is TokenUsageRecord => record !== null)
 
 
 
 
 
148
  } catch {
149
  return []
150
  }
@@ -153,24 +172,24 @@ async function loadTokenDataFromFile(providerSubscriptions: Record<string, boole
153
  /**
154
  * Load token data from persistent file, falling back to deriving from session stores.
155
  */
156
- async function loadTokenData(): Promise<TokenUsageRecord[]> {
157
  const providerSubscriptions = getProviderSubscriptionFlags()
158
- const dbRecords = loadTokenDataFromDb(providerSubscriptions)
159
- const fileRecords = await loadTokenDataFromFile(providerSubscriptions)
160
  const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp)
161
  if (combined.length > 0) {
162
  return combined
163
  }
164
 
165
  // Final fallback: derive from in-memory sessions
166
- return deriveFromSessions(providerSubscriptions)
167
  }
168
 
169
  /**
170
  * Derive token usage records from OpenClaw session stores.
171
  * Each session has totalTokens, inputTokens, outputTokens, model, etc.
172
  */
173
- function deriveFromSessions(providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
174
  const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age
175
  const records: TokenUsageRecord[] = []
176
 
@@ -192,6 +211,8 @@ function deriveFromSessions(providerSubscriptions: Record<string, boolean>): Tok
192
  totalTokens,
193
  cost,
194
  operation: session.chatType || 'chat',
 
 
195
  })
196
  }
197
 
@@ -253,17 +274,48 @@ function filterByTimeframe(records: TokenUsageRecord[], timeframe: string): Toke
253
  return records.filter(record => record.timestamp >= cutoffTime)
254
  }
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  export async function GET(request: NextRequest) {
257
  const auth = requireRole(request, 'viewer')
258
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
259
 
260
  try {
261
  const { searchParams } = new URL(request.url)
262
- const action = searchParams.get('action') || 'list'
263
  const timeframe = searchParams.get('timeframe') || 'all'
264
  const format = searchParams.get('format') || 'json'
265
 
266
- const tokenData = await loadTokenData()
 
267
  const filteredData = filterByTimeframe(tokenData, timeframe)
268
 
269
  if (action === 'list') {
@@ -377,6 +429,34 @@ export async function GET(request: NextRequest) {
377
  })
378
  }
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  if (action === 'export') {
381
  const overallStats = calculateStats(filteredData)
382
  const modelStats: Record<string, TokenStats> = {}
@@ -468,7 +548,7 @@ export async function GET(request: NextRequest) {
468
  return NextResponse.json({ trends, timeframe })
469
  }
470
 
471
- return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
472
  } catch (error) {
473
  logger.error({ err: error }, 'Tokens API error')
474
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
@@ -481,7 +561,8 @@ export async function POST(request: NextRequest) {
481
 
482
  try {
483
  const body = await request.json()
484
- const { model, sessionId, inputTokens, outputTokens, operation = 'chat_completion', duration } = body
 
485
 
486
  if (!model || !sessionId || typeof inputTokens !== 'number' || typeof outputTokens !== 'number') {
487
  return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
@@ -490,6 +571,19 @@ export async function POST(request: NextRequest) {
490
  const totalTokens = inputTokens + outputTokens
491
  const providerSubscriptions = getProviderSubscriptionFlags()
492
  const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })
 
 
 
 
 
 
 
 
 
 
 
 
 
493
 
494
  const record: TokenUsageRecord = {
495
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
@@ -502,11 +596,13 @@ export async function POST(request: NextRequest) {
502
  totalTokens,
503
  cost,
504
  operation,
 
 
505
  duration,
506
  }
507
 
508
  // Persist only manually posted usage records in the JSON file.
509
- const existingData = await loadTokenDataFromFile(providerSubscriptions)
510
  existingData.unshift(record)
511
 
512
  if (existingData.length > 10000) {
 
8
  import { getDatabase } from '@/lib/db'
9
  import { calculateTokenCost } from '@/lib/token-pricing'
10
  import { getProviderSubscriptionFlags } from '@/lib/provider-subscriptions'
11
+ import { buildTaskCostReport, type TaskCostMetadata } from '@/lib/task-costs'
12
 
13
  const DATA_PATH = config.tokensPath
14
 
 
23
  totalTokens: number
24
  cost: number
25
  operation: string
26
+ taskId?: number | null
27
+ workspaceId?: number
28
  duration?: number
29
  }
30
 
 
43
  sessions: Record<string, TokenStats>
44
  }
45
 
46
+ interface TaskMetadataRow extends TaskCostMetadata {}
47
+
48
  function extractAgentName(sessionId: string): string {
49
  const trimmed = sessionId.trim()
50
  if (!trimmed) return 'unknown'
 
58
  session_id: string
59
  input_tokens: number
60
  output_tokens: number
61
+ task_id?: number | null
62
+ workspace_id?: number
63
  created_at: number
64
  }
65
 
66
+ function loadTokenDataFromDb(workspaceId: number, providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
67
  try {
68
  const db = getDatabase()
69
  const rows = db.prepare(`
70
+ SELECT id, model, session_id, input_tokens, output_tokens, task_id, workspace_id, created_at
71
  FROM token_usage
72
+ WHERE workspace_id = ?
73
  ORDER BY created_at DESC, id DESC
74
  LIMIT 10000
75
+ `).all(workspaceId) as DbTokenUsageRow[]
76
 
77
  return rows.map((row) => {
78
  const totalTokens = row.input_tokens + row.output_tokens
 
87
  totalTokens,
88
  cost: calculateTokenCost(row.model, row.input_tokens, row.output_tokens, { providerSubscriptions }),
89
  operation: 'heartbeat',
90
+ taskId: row.task_id ?? null,
91
+ workspaceId: row.workspace_id ?? workspaceId,
92
  }
93
  })
94
  } catch (error) {
 
117
  totalTokens,
118
  cost: Number(record.cost ?? calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })),
119
  operation: String(record.operation ?? 'chat_completion'),
120
+ taskId: record.taskId != null && Number.isFinite(Number(record.taskId)) ? Number(record.taskId) : null,
121
+ workspaceId: record.workspaceId != null && Number.isFinite(Number(record.workspaceId)) ? Number(record.workspaceId) : 1,
122
  duration: record.duration,
123
  }
124
  }
 
136
  record.outputTokens,
137
  record.totalTokens,
138
  record.operation,
139
+ record.taskId ?? '',
140
+ record.workspaceId ?? 1,
141
  record.duration ?? '',
142
  ].join('|')
143
  if (seen.has(key)) continue
 
148
  return deduped
149
  }
150
 
151
+ async function loadTokenDataFromFile(workspaceId: number, providerSubscriptions: Record<string, boolean>): Promise<TokenUsageRecord[]> {
152
  try {
153
  ensureDirExists(dirname(DATA_PATH))
154
  await access(DATA_PATH)
 
159
  return parsed
160
  .map((record: Partial<TokenUsageRecord>) => normalizeTokenRecord(record, providerSubscriptions))
161
  .filter((record): record is TokenUsageRecord => record !== null)
162
+ .filter((record) => {
163
+ if (record.workspaceId === workspaceId) return true
164
+ // Backward compatibility for pre-workspace records
165
+ return workspaceId === 1 && (!record.workspaceId || record.workspaceId === 1)
166
+ })
167
  } catch {
168
  return []
169
  }
 
172
  /**
173
  * Load token data from persistent file, falling back to deriving from session stores.
174
  */
175
+ async function loadTokenData(workspaceId: number): Promise<TokenUsageRecord[]> {
176
  const providerSubscriptions = getProviderSubscriptionFlags()
177
+ const dbRecords = loadTokenDataFromDb(workspaceId, providerSubscriptions)
178
+ const fileRecords = await loadTokenDataFromFile(workspaceId, providerSubscriptions)
179
  const combined = dedupeTokenRecords([...dbRecords, ...fileRecords]).sort((a, b) => b.timestamp - a.timestamp)
180
  if (combined.length > 0) {
181
  return combined
182
  }
183
 
184
  // Final fallback: derive from in-memory sessions
185
+ return deriveFromSessions(workspaceId, providerSubscriptions)
186
  }
187
 
188
  /**
189
  * Derive token usage records from OpenClaw session stores.
190
  * Each session has totalTokens, inputTokens, outputTokens, model, etc.
191
  */
192
+ function deriveFromSessions(workspaceId: number, providerSubscriptions: Record<string, boolean>): TokenUsageRecord[] {
193
  const sessions = getAllGatewaySessions(Infinity) // Get ALL sessions regardless of age
194
  const records: TokenUsageRecord[] = []
195
 
 
211
  totalTokens,
212
  cost,
213
  operation: session.chatType || 'chat',
214
+ taskId: null,
215
+ workspaceId,
216
  })
217
  }
218
 
 
274
  return records.filter(record => record.timestamp >= cutoffTime)
275
  }
276
 
277
+ function loadTaskMetadataById(workspaceId: number, taskIds: number[]): Record<number, TaskCostMetadata> {
278
+ if (taskIds.length === 0) return {}
279
+ const db = getDatabase()
280
+ const placeholders = taskIds.map(() => '?').join(', ')
281
+ const rows = db.prepare(`
282
+ SELECT
283
+ t.id,
284
+ t.title,
285
+ t.status,
286
+ t.priority,
287
+ t.assigned_to,
288
+ t.project_id,
289
+ p.name as project_name,
290
+ p.slug as project_slug,
291
+ p.ticket_prefix as project_prefix,
292
+ t.project_ticket_no
293
+ FROM tasks t
294
+ LEFT JOIN projects p
295
+ ON p.id = t.project_id AND p.workspace_id = t.workspace_id
296
+ WHERE t.workspace_id = ?
297
+ AND t.id IN (${placeholders})
298
+ `).all(workspaceId, ...taskIds) as TaskMetadataRow[]
299
+
300
+ const out: Record<number, TaskCostMetadata> = {}
301
+ for (const row of rows) {
302
+ out[row.id] = row
303
+ }
304
+ return out
305
+ }
306
+
307
  export async function GET(request: NextRequest) {
308
  const auth = requireRole(request, 'viewer')
309
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
310
 
311
  try {
312
  const { searchParams } = new URL(request.url)
313
+ const action = (searchParams.get('action') || 'list').trim().toLowerCase()
314
  const timeframe = searchParams.get('timeframe') || 'all'
315
  const format = searchParams.get('format') || 'json'
316
 
317
+ const workspaceId = auth.user.workspace_id ?? 1
318
+ const tokenData = await loadTokenData(workspaceId)
319
  const filteredData = filterByTimeframe(tokenData, timeframe)
320
 
321
  if (action === 'list') {
 
429
  })
430
  }
431
 
432
+ if (action === 'task-costs' || action === 'task_costs' || action === 'taskcosts') {
433
+ const attributedTaskIds = [...new Set(
434
+ filteredData
435
+ .map((record) => record.taskId)
436
+ .filter((taskId): taskId is number => Number.isFinite(taskId) && Number(taskId) > 0)
437
+ .map((taskId) => Number(taskId))
438
+ )]
439
+ const taskMetadataById = loadTaskMetadataById(workspaceId, attributedTaskIds)
440
+ const report = buildTaskCostReport(
441
+ filteredData.map((record) => ({
442
+ model: record.model,
443
+ agentName: record.agentName || extractAgentName(record.sessionId),
444
+ timestamp: record.timestamp,
445
+ totalTokens: record.totalTokens,
446
+ cost: record.cost,
447
+ taskId: record.taskId ?? null,
448
+ })),
449
+ taskMetadataById
450
+ )
451
+
452
+ return NextResponse.json({
453
+ ...report,
454
+ timeframe,
455
+ recordCount: filteredData.length,
456
+ attributedRecordCount: filteredData.filter((record) => Number.isFinite(record.taskId)).length,
457
+ })
458
+ }
459
+
460
  if (action === 'export') {
461
  const overallStats = calculateStats(filteredData)
462
  const modelStats: Record<string, TokenStats> = {}
 
548
  return NextResponse.json({ trends, timeframe })
549
  }
550
 
551
+ return NextResponse.json({ error: 'Invalid action', action }, { status: 400 })
552
  } catch (error) {
553
  logger.error({ err: error }, 'Tokens API error')
554
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
 
561
 
562
  try {
563
  const body = await request.json()
564
+ const workspaceId = auth.user.workspace_id ?? 1
565
+ const { model, sessionId, inputTokens, outputTokens, operation = 'chat_completion', duration, taskId } = body
566
 
567
  if (!model || !sessionId || typeof inputTokens !== 'number' || typeof outputTokens !== 'number') {
568
  return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
 
571
  const totalTokens = inputTokens + outputTokens
572
  const providerSubscriptions = getProviderSubscriptionFlags()
573
  const cost = calculateTokenCost(model, inputTokens, outputTokens, { providerSubscriptions })
574
+ const parsedTaskId =
575
+ taskId != null && Number.isFinite(Number(taskId)) && Number(taskId) > 0
576
+ ? Number(taskId)
577
+ : null
578
+
579
+ let validatedTaskId: number | null = null
580
+ if (parsedTaskId) {
581
+ const db = getDatabase()
582
+ const taskRow = db.prepare(
583
+ 'SELECT id FROM tasks WHERE id = ? AND workspace_id = ?'
584
+ ).get(parsedTaskId, workspaceId) as { id?: number } | undefined
585
+ if (taskRow?.id) validatedTaskId = taskRow.id
586
+ }
587
 
588
  const record: TokenUsageRecord = {
589
  id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
 
596
  totalTokens,
597
  cost,
598
  operation,
599
+ taskId: validatedTaskId,
600
+ workspaceId,
601
  duration,
602
  }
603
 
604
  // Persist only manually posted usage records in the JSON file.
605
+ const existingData = await loadTokenDataFromFile(workspaceId, providerSubscriptions)
606
  existingData.unshift(record)
607
 
608
  if (existingData.length > 10000) {
src/lib/__tests__/task-costs.test.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildTaskCostReport, calculateStats, type TaskCostMetadata, type TokenCostRecord } from '@/lib/task-costs'
3
+
4
+ describe('task-cost analytics', () => {
5
+ it('calculates stats correctly', () => {
6
+ const stats = calculateStats([
7
+ { model: 'a', agentName: 'alpha', timestamp: 1000, totalTokens: 100, cost: 0.1 },
8
+ { model: 'b', agentName: 'alpha', timestamp: 2000, totalTokens: 300, cost: 0.3 },
9
+ ])
10
+
11
+ expect(stats.totalTokens).toBe(400)
12
+ expect(stats.totalCost).toBeCloseTo(0.4)
13
+ expect(stats.requestCount).toBe(2)
14
+ expect(stats.avgTokensPerRequest).toBe(200)
15
+ expect(stats.avgCostPerRequest).toBeCloseTo(0.2)
16
+ })
17
+
18
+ it('builds task, agent, project and unattributed rollups', () => {
19
+ const records: TokenCostRecord[] = [
20
+ { model: 'sonnet', agentName: 'alpha', timestamp: Date.parse('2026-03-05T01:00:00Z'), totalTokens: 100, cost: 0.1, taskId: 101 },
21
+ { model: 'sonnet', agentName: 'alpha', timestamp: Date.parse('2026-03-05T02:00:00Z'), totalTokens: 150, cost: 0.15, taskId: 101 },
22
+ { model: 'haiku', agentName: 'beta', timestamp: Date.parse('2026-03-05T03:00:00Z'), totalTokens: 50, cost: 0.02, taskId: 202 },
23
+ { model: 'haiku', agentName: 'beta', timestamp: Date.parse('2026-03-05T03:30:00Z'), totalTokens: 75, cost: 0.03 },
24
+ ]
25
+
26
+ const taskMetadata: Record<number, TaskCostMetadata> = {
27
+ 101: {
28
+ id: 101,
29
+ title: 'Task One',
30
+ status: 'in_progress',
31
+ priority: 'high',
32
+ assigned_to: 'alpha',
33
+ project_id: 1,
34
+ project_name: 'Core',
35
+ project_slug: 'core',
36
+ project_prefix: 'CORE',
37
+ project_ticket_no: 12,
38
+ },
39
+ 202: {
40
+ id: 202,
41
+ title: 'Task Two',
42
+ status: 'assigned',
43
+ priority: 'medium',
44
+ assigned_to: 'beta',
45
+ project_id: 2,
46
+ project_name: 'Ops',
47
+ project_slug: 'ops',
48
+ project_prefix: 'OPS',
49
+ project_ticket_no: 7,
50
+ },
51
+ }
52
+
53
+ const report = buildTaskCostReport(records, taskMetadata)
54
+
55
+ expect(report.tasks).toHaveLength(2)
56
+ expect(report.tasks[0]?.taskId).toBe(101)
57
+ expect(report.tasks[0]?.stats.totalCost).toBeCloseTo(0.25)
58
+ expect(report.tasks[0]?.project.ticketRef).toBe('CORE-012')
59
+
60
+ expect(report.agents.alpha?.stats.totalCost).toBeCloseTo(0.25)
61
+ expect(report.agents.alpha?.taskIds).toEqual([101])
62
+ expect(report.agents.beta?.taskIds).toEqual([202])
63
+
64
+ expect(report.projects['1']?.taskCount).toBe(1)
65
+ expect(report.projects['2']?.taskCount).toBe(1)
66
+
67
+ expect(report.summary.totalCost).toBeCloseTo(0.27)
68
+ expect(report.unattributed.totalCost).toBeCloseTo(0.03)
69
+ })
70
+ })
src/lib/migrations.ts CHANGED
@@ -755,7 +755,27 @@ const migrations: Migration[] = [
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'`)
 
755
  }
756
  },
757
  {
758
+ id: '025_token_usage_task_attribution',
759
+ up: (db) => {
760
+ const hasTokenUsageTable = db
761
+ .prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'token_usage'`)
762
+ .get() as { ok?: number } | undefined
763
+
764
+ if (!hasTokenUsageTable?.ok) return
765
+
766
+ const cols = db.prepare(`PRAGMA table_info(token_usage)`).all() as Array<{ name: string }>
767
+ const hasCol = (name: string) => cols.some((c) => c.name === name)
768
+
769
+ if (!hasCol('task_id')) {
770
+ db.exec(`ALTER TABLE token_usage ADD COLUMN task_id INTEGER`)
771
+ }
772
+
773
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_task_id ON token_usage(task_id)`)
774
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_task_time ON token_usage(workspace_id, task_id, created_at)`)
775
+ }
776
+ },
777
+ {
778
+ id: '026_task_outcome_tracking',
779
  up: (db) => {
780
  const hasTasks = db
781
  .prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`)
src/lib/task-costs.ts ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface TokenCostRecord {
2
+ model: string
3
+ agentName: string
4
+ timestamp: number
5
+ totalTokens: number
6
+ cost: number
7
+ taskId?: number | null
8
+ }
9
+
10
+ export interface TokenStats {
11
+ totalTokens: number
12
+ totalCost: number
13
+ requestCount: number
14
+ avgTokensPerRequest: number
15
+ avgCostPerRequest: number
16
+ }
17
+
18
+ export interface TaskCostMetadata {
19
+ id: number
20
+ title: string
21
+ status: string
22
+ priority: string
23
+ assigned_to?: string | null
24
+ project_id?: number | null
25
+ project_name?: string | null
26
+ project_slug?: string | null
27
+ project_ticket_no?: number | null
28
+ project_prefix?: string | null
29
+ }
30
+
31
+ export interface TaskCostEntry {
32
+ taskId: number
33
+ title: string
34
+ status: string
35
+ priority: string
36
+ assignedTo?: string | null
37
+ project: {
38
+ id?: number | null
39
+ name?: string | null
40
+ slug?: string | null
41
+ ticketRef?: string | null
42
+ }
43
+ stats: TokenStats
44
+ models: Record<string, TokenStats>
45
+ timeline: Array<{ date: string; cost: number; tokens: number }>
46
+ }
47
+
48
+ export interface AgentTaskCostEntry {
49
+ stats: TokenStats
50
+ taskCount: number
51
+ taskIds: number[]
52
+ }
53
+
54
+ export interface ProjectTaskCostEntry {
55
+ stats: TokenStats
56
+ taskCount: number
57
+ taskIds: number[]
58
+ }
59
+
60
+ export interface TaskCostReport {
61
+ summary: TokenStats
62
+ tasks: TaskCostEntry[]
63
+ agents: Record<string, AgentTaskCostEntry>
64
+ projects: Record<string, ProjectTaskCostEntry>
65
+ unattributed: TokenStats
66
+ }
67
+
68
+ export function calculateStats(records: TokenCostRecord[]): TokenStats {
69
+ if (records.length === 0) {
70
+ return {
71
+ totalTokens: 0,
72
+ totalCost: 0,
73
+ requestCount: 0,
74
+ avgTokensPerRequest: 0,
75
+ avgCostPerRequest: 0,
76
+ }
77
+ }
78
+
79
+ const totalTokens = records.reduce((sum, r) => sum + r.totalTokens, 0)
80
+ const totalCost = records.reduce((sum, r) => sum + r.cost, 0)
81
+ const requestCount = records.length
82
+
83
+ return {
84
+ totalTokens,
85
+ totalCost,
86
+ requestCount,
87
+ avgTokensPerRequest: Math.round(totalTokens / requestCount),
88
+ avgCostPerRequest: totalCost / requestCount,
89
+ }
90
+ }
91
+
92
+ function groupByModel(records: TokenCostRecord[]): Record<string, TokenStats> {
93
+ const modelGroups: Record<string, TokenCostRecord[]> = {}
94
+ for (const record of records) {
95
+ if (!modelGroups[record.model]) modelGroups[record.model] = []
96
+ modelGroups[record.model].push(record)
97
+ }
98
+
99
+ const result: Record<string, TokenStats> = {}
100
+ for (const [model, modelRecords] of Object.entries(modelGroups)) {
101
+ result[model] = calculateStats(modelRecords)
102
+ }
103
+ return result
104
+ }
105
+
106
+ function buildTimeline(records: TokenCostRecord[]): Array<{ date: string; cost: number; tokens: number }> {
107
+ const byDate: Record<string, { cost: number; tokens: number }> = {}
108
+
109
+ for (const record of records) {
110
+ const date = new Date(record.timestamp).toISOString().split('T')[0]
111
+ if (!byDate[date]) {
112
+ byDate[date] = { cost: 0, tokens: 0 }
113
+ }
114
+ byDate[date].cost += record.cost
115
+ byDate[date].tokens += record.totalTokens
116
+ }
117
+
118
+ return Object.entries(byDate)
119
+ .sort(([a], [b]) => a.localeCompare(b))
120
+ .map(([date, totals]) => ({ date, ...totals }))
121
+ }
122
+
123
+ function formatTicketRef(prefix?: string | null, num?: number | null): string | null {
124
+ if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return null
125
+ return `${prefix}-${String(num).padStart(3, '0')}`
126
+ }
127
+
128
+ export function buildTaskCostReport(records: TokenCostRecord[], taskMetadata: Record<number, TaskCostMetadata>): TaskCostReport {
129
+ const attributedRecords = records.filter((record) => Number.isFinite(record.taskId))
130
+ const unattributedRecords = records.filter((record) => !Number.isFinite(record.taskId))
131
+
132
+ const byTask: Record<number, TokenCostRecord[]> = {}
133
+ for (const record of attributedRecords) {
134
+ const taskId = Number(record.taskId)
135
+ if (!taskMetadata[taskId]) continue
136
+ if (!byTask[taskId]) byTask[taskId] = []
137
+ byTask[taskId].push(record)
138
+ }
139
+
140
+ const tasks: TaskCostEntry[] = Object.entries(byTask)
141
+ .map(([taskIdRaw, taskRecords]) => {
142
+ const taskId = Number(taskIdRaw)
143
+ const meta = taskMetadata[taskId]
144
+ return {
145
+ taskId,
146
+ title: meta.title,
147
+ status: meta.status,
148
+ priority: meta.priority,
149
+ assignedTo: meta.assigned_to || null,
150
+ project: {
151
+ id: meta.project_id ?? null,
152
+ name: meta.project_name ?? null,
153
+ slug: meta.project_slug ?? null,
154
+ ticketRef: formatTicketRef(meta.project_prefix, meta.project_ticket_no),
155
+ },
156
+ stats: calculateStats(taskRecords),
157
+ models: groupByModel(taskRecords),
158
+ timeline: buildTimeline(taskRecords),
159
+ }
160
+ })
161
+ .sort((a, b) => b.stats.totalCost - a.stats.totalCost)
162
+
163
+ const byAgent: Record<string, TokenCostRecord[]> = {}
164
+ for (const record of attributedRecords) {
165
+ const taskId = Number(record.taskId)
166
+ if (!taskMetadata[taskId]) continue
167
+ if (!byAgent[record.agentName]) byAgent[record.agentName] = []
168
+ byAgent[record.agentName].push(record)
169
+ }
170
+
171
+ const agentTaskIds: Record<string, Set<number>> = {}
172
+ for (const task of tasks) {
173
+ const taskRecords = byTask[task.taskId] || []
174
+ for (const record of taskRecords) {
175
+ const agent = record.agentName
176
+ if (!agentTaskIds[agent]) agentTaskIds[agent] = new Set()
177
+ agentTaskIds[agent].add(task.taskId)
178
+ }
179
+ }
180
+
181
+ const agents: Record<string, AgentTaskCostEntry> = {}
182
+ for (const [agent, agentRecords] of Object.entries(byAgent)) {
183
+ const taskIds = [...(agentTaskIds[agent] || new Set<number>())].sort((a, b) => a - b)
184
+ agents[agent] = {
185
+ stats: calculateStats(agentRecords),
186
+ taskCount: taskIds.length,
187
+ taskIds,
188
+ }
189
+ }
190
+
191
+ const byProject: Record<string, TokenCostRecord[]> = {}
192
+ const projectTaskIds: Record<string, Set<number>> = {}
193
+ for (const record of attributedRecords) {
194
+ const taskId = Number(record.taskId)
195
+ const meta = taskMetadata[taskId]
196
+ if (!meta) continue
197
+ const key = meta.project_id ? String(meta.project_id) : 'unscoped'
198
+ if (!byProject[key]) byProject[key] = []
199
+ byProject[key].push(record)
200
+ if (!projectTaskIds[key]) projectTaskIds[key] = new Set()
201
+ projectTaskIds[key].add(taskId)
202
+ }
203
+
204
+ const projects: Record<string, ProjectTaskCostEntry> = {}
205
+ for (const [projectKey, projectRecords] of Object.entries(byProject)) {
206
+ const taskIds = [...(projectTaskIds[projectKey] || new Set<number>())].sort((a, b) => a - b)
207
+ projects[projectKey] = {
208
+ stats: calculateStats(projectRecords),
209
+ taskCount: taskIds.length,
210
+ taskIds,
211
+ }
212
+ }
213
+
214
+ return {
215
+ summary: calculateStats(attributedRecords.filter((record) => Number.isFinite(record.taskId) && taskMetadata[Number(record.taskId)])),
216
+ tasks,
217
+ agents,
218
+ projects,
219
+ unattributed: calculateStats(unattributedRecords),
220
+ }
221
+ }
tests/agent-costs.spec.ts CHANGED
@@ -97,4 +97,64 @@ test.describe('Agent Costs API', () => {
97
  const res = await request.get('/api/tokens?action=agent-costs&timeframe=all')
98
  expect(res.status()).toBe(401)
99
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  })
 
97
  const res = await request.get('/api/tokens?action=agent-costs&timeframe=all')
98
  expect(res.status()).toBe(401)
99
  })
100
+
101
+ test('GET action=task-costs returns task-level attribution and unattributed rollup', async ({ request }) => {
102
+ const agentName = `e2e-taskcost-agent-${Date.now()}`
103
+ const createTaskRes = await request.post('/api/tasks', {
104
+ headers: API_KEY_HEADER,
105
+ data: {
106
+ title: `E2E Task Cost ${Date.now()}`,
107
+ description: 'Task cost attribution test',
108
+ assigned_to: agentName,
109
+ },
110
+ })
111
+ expect(createTaskRes.status()).toBe(201)
112
+ const createdTask = await createTaskRes.json()
113
+ const taskId = createdTask.task.id as number
114
+
115
+ const postAttributed = await request.post('/api/tokens', {
116
+ headers: API_KEY_HEADER,
117
+ data: {
118
+ model: 'claude-sonnet-4',
119
+ sessionId: `${agentName}:chat`,
120
+ inputTokens: 300,
121
+ outputTokens: 100,
122
+ taskId,
123
+ },
124
+ })
125
+ expect(postAttributed.status()).toBe(200)
126
+
127
+ const postUnattributed = await request.post('/api/tokens', {
128
+ headers: API_KEY_HEADER,
129
+ data: {
130
+ model: 'claude-haiku-3.5',
131
+ sessionId: `${agentName}:chat`,
132
+ inputTokens: 50,
133
+ outputTokens: 50,
134
+ },
135
+ })
136
+ expect(postUnattributed.status()).toBe(200)
137
+
138
+ const res = await request.get('/api/tokens?action=task-costs&timeframe=hour', {
139
+ headers: API_KEY_HEADER,
140
+ })
141
+ const responseText = await res.text()
142
+ expect(res.status(), responseText).toBe(200)
143
+ const body = JSON.parse(responseText)
144
+
145
+ expect(body).toHaveProperty('summary')
146
+ expect(body).toHaveProperty('tasks')
147
+ expect(body).toHaveProperty('agents')
148
+ expect(body).toHaveProperty('projects')
149
+ expect(body).toHaveProperty('unattributed')
150
+ expect(Array.isArray(body.tasks)).toBe(true)
151
+
152
+ const matchingTask = body.tasks.find((task: any) => task.taskId === taskId)
153
+ expect(matchingTask).toBeTruthy()
154
+ expect(matchingTask.title).toBe(createdTask.task.title)
155
+ expect(matchingTask.stats.totalTokens).toBe(400)
156
+ expect(matchingTask.stats.requestCount).toBeGreaterThanOrEqual(1)
157
+ expect(body.agents[agentName].taskIds).toContain(taskId)
158
+ expect(body.unattributed.requestCount).toBeGreaterThanOrEqual(1)
159
+ })
160
  })