opencastle 0.27.1 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  2. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  3. package/dist/cli/convoy/dashboard-types.js +2 -0
  4. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  5. package/dist/cli/convoy/engine.d.ts +0 -1
  6. package/dist/cli/convoy/engine.d.ts.map +1 -1
  7. package/dist/cli/convoy/engine.js +31 -99
  8. package/dist/cli/convoy/engine.js.map +1 -1
  9. package/dist/cli/convoy/engine.test.js +88 -1
  10. package/dist/cli/convoy/engine.test.js.map +1 -1
  11. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  12. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  13. package/dist/cli/convoy/event-schemas.js +185 -0
  14. package/dist/cli/convoy/event-schemas.js.map +1 -0
  15. package/dist/cli/convoy/events.d.ts +8 -0
  16. package/dist/cli/convoy/events.d.ts.map +1 -1
  17. package/dist/cli/convoy/events.js +117 -5
  18. package/dist/cli/convoy/events.js.map +1 -1
  19. package/dist/cli/convoy/events.test.js +173 -3
  20. package/dist/cli/convoy/events.test.js.map +1 -1
  21. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  22. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  23. package/dist/cli/convoy/log-merge.test.js +147 -0
  24. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  25. package/dist/cli/convoy/store.d.ts +52 -2
  26. package/dist/cli/convoy/store.d.ts.map +1 -1
  27. package/dist/cli/convoy/store.js +244 -17
  28. package/dist/cli/convoy/store.js.map +1 -1
  29. package/dist/cli/convoy/store.test.js +481 -22
  30. package/dist/cli/convoy/store.test.js.map +1 -1
  31. package/dist/cli/convoy/types.d.ts +271 -3
  32. package/dist/cli/convoy/types.d.ts.map +1 -1
  33. package/dist/cli/convoy/types.js +42 -1
  34. package/dist/cli/convoy/types.js.map +1 -1
  35. package/dist/cli/log.d.ts +11 -0
  36. package/dist/cli/log.d.ts.map +1 -1
  37. package/dist/cli/log.js +114 -2
  38. package/dist/cli/log.js.map +1 -1
  39. package/package.json +5 -1
  40. package/src/cli/convoy/TELEMETRY.md +203 -0
  41. package/src/cli/convoy/dashboard-types.ts +141 -0
  42. package/src/cli/convoy/engine.test.ts +99 -1
  43. package/src/cli/convoy/engine.ts +27 -96
  44. package/src/cli/convoy/event-schemas.ts +195 -0
  45. package/src/cli/convoy/events.test.ts +207 -3
  46. package/src/cli/convoy/events.ts +119 -5
  47. package/src/cli/convoy/log-merge.test.ts +179 -0
  48. package/src/cli/convoy/store.test.ts +545 -22
  49. package/src/cli/convoy/store.ts +274 -21
  50. package/src/cli/convoy/types.ts +108 -3
  51. package/src/cli/log.ts +120 -2
  52. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  53. package/src/dashboard/dist/data/.gitkeep +0 -0
  54. package/src/dashboard/dist/data/convoy-list.json +1 -0
  55. package/src/dashboard/dist/data/overall-stats.json +24 -0
  56. package/src/dashboard/dist/index.html +701 -3
  57. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  58. package/src/dashboard/public/data/.gitkeep +0 -0
  59. package/src/dashboard/public/data/convoy-list.json +1 -0
  60. package/src/dashboard/public/data/overall-stats.json +24 -0
  61. package/src/dashboard/scripts/etl.test.ts +210 -0
  62. package/src/dashboard/scripts/etl.ts +108 -0
  63. package/src/dashboard/scripts/integration-test.ts +504 -0
  64. package/src/dashboard/src/pages/index.astro +854 -15
  65. package/src/dashboard/src/styles/dashboard.css +557 -1
  66. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -1,5 +1,6 @@
1
1
  import { copyFileSync } from 'node:fs'
2
2
  import { DatabaseSync } from 'node:sqlite'
3
+ import type { DashboardConvoyDetail } from './dashboard-types.js'
3
4
  import type {
4
5
  ConvoyRecord,
5
6
  ConvoyStatus,
@@ -16,7 +17,7 @@ import type {
16
17
  TaskStepRecord,
17
18
  } from './types.js'
18
19
 
19
- const SCHEMA_VERSION = 9
20
+ const SCHEMA_VERSION = 10
20
21
 
21
22
  // ── Size limits (bytes) ────────────────────────────────────────────────────────
22
23
  const LIMIT_SPEC_YAML = 256 * 1024 // 256 KB
@@ -70,7 +71,7 @@ export interface ConvoyStore {
70
71
  updateConvoyStatus(
71
72
  id: string,
72
73
  status: ConvoyStatus,
73
- extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
74
+ extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: number | null },
74
75
  ): void
75
76
  updateConvoyReviewTokens(convoyId: string, tokens: number): void
76
77
  updateConvoyCircuitState(convoyId: string, state: string | null): void
@@ -135,6 +136,7 @@ export interface ConvoyStore {
135
136
  insertArtifact(record: ArtifactRecord): void
136
137
  getArtifact(convoyId: string, name: string): ArtifactRecord | undefined
137
138
  getArtifactsByTask(taskId: string): ArtifactRecord[]
139
+ getArtifactsByConvoy(convoyId: string): ArtifactRecord[]
138
140
  deleteArtifactsOlderThan(days: number): number
139
141
  insertAgentIdentity(record: AgentIdentityRecord): void
140
142
  getAgentIdentities(agent: string, limit: number): AgentIdentityRecord[]
@@ -151,9 +153,18 @@ export interface ConvoyStore {
151
153
  updatePipelineStatus(
152
154
  id: string,
153
155
  status: PipelineStatus,
154
- extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
156
+ extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: number | null },
155
157
  ): void
156
158
  getConvoysByPipeline(pipelineId: string): ConvoyRecord[]
159
+ getConvoyCounts(): { total: number; running: number; done: number; failed: number; gate_failed: number }
160
+ getConvoyDurationStats(): { avg_sec: number | null; p95_sec: number | null; max_sec: number | null }
161
+ getTokenAndCostTotals(): { total_tokens: number; total_cost_usd: number }
162
+ getTopAgents(limit: number): Array<{ agent: string; task_count: number; total_tokens: number }>
163
+ getTopModels(limit: number): Array<{ model: string; task_count: number; total_tokens: number }>
164
+ getDlqSummary(): { count: number; top_failure_types: Array<{ type: string; count: number }> }
165
+ getConvoyTaskSummary(convoyId: string): { total: number; done: number; running: number; failed: number; review_blocked: number; disputed: number; reviewed: number; panel_reviewed: number; tasks_with_drift: number; max_drift_score: number | null; drift_retried: number }
166
+ getConvoyList(limit: number, offset: number): ConvoyRecord[]
167
+ getConvoyDetails(convoyId: string): DashboardConvoyDetail | null
157
168
  withTransaction<T>(fn: () => T): T
158
169
  close(): void
159
170
  }
@@ -186,6 +197,7 @@ class ConvoyStoreImpl implements ConvoyStore {
186
197
  spec_yaml TEXT NOT NULL,
187
198
  total_tokens INTEGER,
188
199
  total_cost_usd TEXT,
200
+ total_cost_usd_num REAL,
189
201
  pipeline_id TEXT,
190
202
  circuit_state TEXT,
191
203
  review_tokens_total INTEGER,
@@ -203,7 +215,8 @@ class ConvoyStoreImpl implements ConvoyStore {
203
215
  started_at TEXT,
204
216
  finished_at TEXT,
205
217
  total_tokens INTEGER,
206
- total_cost_usd TEXT
218
+ total_cost_usd TEXT,
219
+ total_cost_usd_num REAL
207
220
  );
208
221
 
209
222
  CREATE TABLE IF NOT EXISTS task (
@@ -230,6 +243,7 @@ class ConvoyStoreImpl implements ConvoyStore {
230
243
  completion_tokens INTEGER,
231
244
  total_tokens INTEGER,
232
245
  cost_usd TEXT,
246
+ cost_usd_num REAL,
233
247
  gates TEXT,
234
248
  on_exhausted TEXT NOT NULL DEFAULT 'dlq',
235
249
  injected INTEGER NOT NULL DEFAULT 0,
@@ -398,6 +412,10 @@ class ConvoyStoreImpl implements ConvoyStore {
398
412
  migrateSchema(this.db, this.dbPath, 8, 9)
399
413
  version = 9
400
414
  }
415
+ if (version === 9) {
416
+ migrateSchema(this.db, this.dbPath, 9, 10)
417
+ version = 10
418
+ }
401
419
  }
402
420
 
403
421
  insertConvoy(
@@ -422,20 +440,20 @@ class ConvoyStoreImpl implements ConvoyStore {
422
440
 
423
441
  getConvoy(id: string): ConvoyRecord | undefined {
424
442
  return this.db
425
- .prepare('SELECT * FROM convoy WHERE id = :id')
443
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM convoy WHERE id = :id')
426
444
  .get({ id }) as ConvoyRecord | undefined
427
445
  }
428
446
 
429
447
  getLatestConvoy(): ConvoyRecord | undefined {
430
448
  return this.db
431
- .prepare('SELECT * FROM convoy ORDER BY created_at DESC LIMIT 1')
449
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM convoy ORDER BY created_at DESC LIMIT 1')
432
450
  .get() as ConvoyRecord | undefined
433
451
  }
434
452
 
435
453
  updateConvoyStatus(
436
454
  id: string,
437
455
  status: ConvoyStatus,
438
- extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
456
+ extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: number | null },
439
457
  ): void {
440
458
  const sets = ['status = :status']
441
459
  const params: Record<string, string | number | null> = { id, status }
@@ -453,8 +471,10 @@ class ConvoyStoreImpl implements ConvoyStore {
453
471
  params.total_tokens = extra.total_tokens
454
472
  }
455
473
  if (extra?.total_cost_usd !== undefined) {
456
- sets.push('total_cost_usd = :total_cost_usd')
457
- params.total_cost_usd = extra.total_cost_usd
474
+ sets.push('total_cost_usd = :total_cost_usd_text')
475
+ sets.push('total_cost_usd_num = :total_cost_usd_num')
476
+ params.total_cost_usd_text = extra.total_cost_usd !== null ? String(extra.total_cost_usd) : null
477
+ params.total_cost_usd_num = extra.total_cost_usd
458
478
  }
459
479
 
460
480
  this.db.prepare(`UPDATE convoy SET ${sets.join(', ')} WHERE id = :id`).run(params)
@@ -530,36 +550,36 @@ class ConvoyStoreImpl implements ConvoyStore {
530
550
 
531
551
  getTask(id: string, convoyId: string): TaskRecord | undefined {
532
552
  return this.db
533
- .prepare('SELECT * FROM task WHERE id = :id AND convoy_id = :convoy_id')
553
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE id = :id AND convoy_id = :convoy_id')
534
554
  .get({ id, convoy_id: convoyId }) as TaskRecord | undefined
535
555
  }
536
556
 
537
557
  getTasksByConvoy(convoyId: string): TaskRecord[] {
538
558
  return this.db
539
- .prepare('SELECT * FROM task WHERE convoy_id = :convoy_id ORDER BY phase, id')
559
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE convoy_id = :convoy_id ORDER BY phase, id')
540
560
  .all({ convoy_id: convoyId }) as unknown as TaskRecord[]
541
561
  }
542
562
 
543
563
  getTaskByIdempotencyKey(convoyId: string, key: string): TaskRecord | undefined {
544
564
  return this.db
545
- .prepare('SELECT * FROM task WHERE convoy_id = :convoy_id AND idempotency_key = :key')
565
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE convoy_id = :convoy_id AND idempotency_key = :key')
546
566
  .get({ convoy_id: convoyId, key }) as TaskRecord | undefined
547
567
  }
548
568
 
549
569
  getTaskByDisputeId(disputeId: string): TaskRecord | undefined {
550
570
  return this.db
551
- .prepare('SELECT * FROM task WHERE dispute_id = :dispute_id LIMIT 1')
571
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE dispute_id = :dispute_id LIMIT 1')
552
572
  .get({ dispute_id: disputeId }) as TaskRecord | undefined
553
573
  }
554
574
 
555
575
  getDisputedTasks(convoyId?: string): TaskRecord[] {
556
576
  if (convoyId) {
557
577
  return this.db
558
- .prepare("SELECT * FROM task WHERE status = 'disputed' AND convoy_id = :convoy_id ORDER BY phase, id")
578
+ .prepare("SELECT *, cost_usd_num AS cost_usd FROM task WHERE status = 'disputed' AND convoy_id = :convoy_id ORDER BY phase, id")
559
579
  .all({ convoy_id: convoyId }) as unknown as TaskRecord[]
560
580
  }
561
581
  return this.db
562
- .prepare("SELECT * FROM task WHERE status = 'disputed' ORDER BY convoy_id, phase, id")
582
+ .prepare("SELECT *, cost_usd_num AS cost_usd FROM task WHERE status = 'disputed' ORDER BY convoy_id, phase, id")
563
583
  .all({}) as unknown as TaskRecord[]
564
584
  }
565
585
 
@@ -592,6 +612,10 @@ class ConvoyStoreImpl implements ConvoyStore {
592
612
  params[field] = extra[field] as string | number | null
593
613
  }
594
614
  }
615
+ if ('cost_usd' in extra && extra.cost_usd !== undefined) {
616
+ sets.push('cost_usd_num = :cost_usd_num')
617
+ params.cost_usd_num = extra.cost_usd
618
+ }
595
619
  }
596
620
 
597
621
  this.db
@@ -816,6 +840,12 @@ class ConvoyStoreImpl implements ConvoyStore {
816
840
  .all({ task_id: taskId }) as unknown as ArtifactRecord[]
817
841
  }
818
842
 
843
+ getArtifactsByConvoy(convoyId: string): ArtifactRecord[] {
844
+ return this.db
845
+ .prepare('SELECT * FROM artifact WHERE convoy_id = :convoy_id ORDER BY created_at')
846
+ .all({ convoy_id: convoyId }) as unknown as ArtifactRecord[]
847
+ }
848
+
819
849
  deleteArtifactsOlderThan(days: number): number {
820
850
  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
821
851
  const result = this.db
@@ -920,20 +950,20 @@ class ConvoyStoreImpl implements ConvoyStore {
920
950
 
921
951
  getPipeline(id: string): PipelineRecord | undefined {
922
952
  return this.db
923
- .prepare('SELECT * FROM pipeline WHERE id = :id')
953
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM pipeline WHERE id = :id')
924
954
  .get({ id }) as PipelineRecord | undefined
925
955
  }
926
956
 
927
957
  getLatestPipeline(): PipelineRecord | undefined {
928
958
  return this.db
929
- .prepare('SELECT * FROM pipeline ORDER BY created_at DESC LIMIT 1')
959
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM pipeline ORDER BY created_at DESC LIMIT 1')
930
960
  .get() as PipelineRecord | undefined
931
961
  }
932
962
 
933
963
  updatePipelineStatus(
934
964
  id: string,
935
965
  status: PipelineStatus,
936
- extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: string | null },
966
+ extra?: { started_at?: string; finished_at?: string; total_tokens?: number | null; total_cost_usd?: number | null },
937
967
  ): void {
938
968
  const sets = ['status = :status']
939
969
  const params: Record<string, string | number | null> = { id, status }
@@ -951,8 +981,10 @@ class ConvoyStoreImpl implements ConvoyStore {
951
981
  params.total_tokens = extra.total_tokens
952
982
  }
953
983
  if (extra?.total_cost_usd !== undefined) {
954
- sets.push('total_cost_usd = :total_cost_usd')
955
- params.total_cost_usd = extra.total_cost_usd
984
+ sets.push('total_cost_usd = :total_cost_usd_text')
985
+ sets.push('total_cost_usd_num = :total_cost_usd_num')
986
+ params.total_cost_usd_text = extra.total_cost_usd !== null ? String(extra.total_cost_usd) : null
987
+ params.total_cost_usd_num = extra.total_cost_usd
956
988
  }
957
989
 
958
990
  this.db.prepare(`UPDATE pipeline SET ${sets.join(', ')} WHERE id = :id`).run(params)
@@ -960,10 +992,221 @@ class ConvoyStoreImpl implements ConvoyStore {
960
992
 
961
993
  getConvoysByPipeline(pipelineId: string): ConvoyRecord[] {
962
994
  return this.db
963
- .prepare('SELECT * FROM convoy WHERE pipeline_id = :pipeline_id ORDER BY created_at')
995
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM convoy WHERE pipeline_id = :pipeline_id ORDER BY created_at')
964
996
  .all({ pipeline_id: pipelineId }) as unknown as ConvoyRecord[]
965
997
  }
966
998
 
999
+ getConvoyCounts(): { total: number; running: number; done: number; failed: number; gate_failed: number } {
1000
+ const rows = this.db
1001
+ .prepare('SELECT status, COUNT(*) AS cnt FROM convoy GROUP BY status')
1002
+ .all() as Array<{ status: string; cnt: number }>
1003
+ const map: Record<string, number> = {}
1004
+ for (const row of rows) map[row.status] = row.cnt
1005
+ return {
1006
+ total: rows.reduce((s, r) => s + r.cnt, 0),
1007
+ running: map['running'] ?? 0,
1008
+ done: map['done'] ?? 0,
1009
+ failed: (map['failed'] ?? 0),
1010
+ gate_failed: (map['gate-failed'] ?? 0) + (map['hook-failed'] ?? 0),
1011
+ }
1012
+ }
1013
+
1014
+ getConvoyDurationStats(): { avg_sec: number | null; p95_sec: number | null; max_sec: number | null } {
1015
+ const statsRow = this.db.prepare(
1016
+ `SELECT
1017
+ AVG((julianday(finished_at) - julianday(started_at)) * 86400) AS avg_sec,
1018
+ MAX((julianday(finished_at) - julianday(started_at)) * 86400) AS max_sec,
1019
+ COUNT(*) AS cnt
1020
+ FROM convoy
1021
+ WHERE finished_at IS NOT NULL AND started_at IS NOT NULL`,
1022
+ ).get() as { avg_sec: number | null; max_sec: number | null; cnt: number } | undefined
1023
+ if (!statsRow || statsRow.cnt === 0) return { avg_sec: null, p95_sec: null, max_sec: null }
1024
+ const offset = Math.max(0, Math.floor(statsRow.cnt * 0.95) - 1)
1025
+ const p95Row = this.db.prepare(
1026
+ `SELECT (julianday(finished_at) - julianday(started_at)) * 86400 AS duration
1027
+ FROM convoy
1028
+ WHERE finished_at IS NOT NULL AND started_at IS NOT NULL
1029
+ ORDER BY duration
1030
+ LIMIT 1 OFFSET :offset`,
1031
+ ).get({ offset }) as { duration: number } | undefined
1032
+ return {
1033
+ avg_sec: statsRow.avg_sec,
1034
+ p95_sec: p95Row?.duration ?? null,
1035
+ max_sec: statsRow.max_sec,
1036
+ }
1037
+ }
1038
+
1039
+ getTokenAndCostTotals(): { total_tokens: number; total_cost_usd: number } {
1040
+ const row = this.db.prepare(
1041
+ `SELECT COALESCE(SUM(total_tokens), 0) AS total_tokens,
1042
+ COALESCE(SUM(total_cost_usd_num), 0) AS total_cost_usd
1043
+ FROM convoy`,
1044
+ ).get() as { total_tokens: number; total_cost_usd: number }
1045
+ return { total_tokens: row.total_tokens, total_cost_usd: row.total_cost_usd }
1046
+ }
1047
+
1048
+ getTopAgents(limit: number): Array<{ agent: string; task_count: number; total_tokens: number }> {
1049
+ return this.db.prepare(
1050
+ `SELECT agent,
1051
+ COUNT(*) AS task_count,
1052
+ COALESCE(SUM(total_tokens), 0) AS total_tokens
1053
+ FROM task
1054
+ GROUP BY agent
1055
+ ORDER BY task_count DESC
1056
+ LIMIT :limit`,
1057
+ ).all({ limit }) as Array<{ agent: string; task_count: number; total_tokens: number }>
1058
+ }
1059
+
1060
+ getTopModels(limit: number): Array<{ model: string; task_count: number; total_tokens: number }> {
1061
+ return this.db.prepare(
1062
+ `SELECT model,
1063
+ COUNT(*) AS task_count,
1064
+ COALESCE(SUM(total_tokens), 0) AS total_tokens
1065
+ FROM task
1066
+ WHERE model IS NOT NULL
1067
+ GROUP BY model
1068
+ ORDER BY task_count DESC
1069
+ LIMIT :limit`,
1070
+ ).all({ limit }) as Array<{ model: string; task_count: number; total_tokens: number }>
1071
+ }
1072
+
1073
+ getDlqSummary(): { count: number; top_failure_types: Array<{ type: string; count: number }> } {
1074
+ const countRow = this.db.prepare('SELECT COUNT(*) AS cnt FROM dlq').get() as { cnt: number }
1075
+ const typeRows = this.db.prepare(
1076
+ `SELECT failure_type AS type, COUNT(*) AS count
1077
+ FROM dlq
1078
+ GROUP BY failure_type
1079
+ ORDER BY count DESC`,
1080
+ ).all() as Array<{ type: string; count: number }>
1081
+ return { count: countRow.cnt, top_failure_types: typeRows }
1082
+ }
1083
+
1084
+ getConvoyTaskSummary(convoyId: string): { total: number; done: number; running: number; failed: number; review_blocked: number; disputed: number; reviewed: number; panel_reviewed: number; tasks_with_drift: number; max_drift_score: number | null; drift_retried: number } {
1085
+ const row = this.db.prepare(
1086
+ `SELECT
1087
+ COUNT(*) AS total,
1088
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done,
1089
+ SUM(CASE WHEN status IN ('running', 'assigned') THEN 1 ELSE 0 END) AS running,
1090
+ SUM(CASE WHEN status IN ('failed', 'gate-failed', 'timed-out', 'hook-failed') THEN 1 ELSE 0 END) AS failed,
1091
+ SUM(CASE WHEN status = 'review-blocked' THEN 1 ELSE 0 END) AS review_blocked,
1092
+ SUM(CASE WHEN status = 'disputed' THEN 1 ELSE 0 END) AS disputed,
1093
+ SUM(CASE WHEN review_verdict IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
1094
+ SUM(CASE WHEN panel_attempts > 0 THEN 1 ELSE 0 END) AS panel_reviewed,
1095
+ SUM(CASE WHEN drift_score IS NOT NULL THEN 1 ELSE 0 END) AS tasks_with_drift,
1096
+ MAX(drift_score) AS max_drift_score,
1097
+ SUM(CASE WHEN drift_retried = 1 THEN 1 ELSE 0 END) AS drift_retried
1098
+ FROM task
1099
+ WHERE convoy_id = :convoy_id`,
1100
+ ).get({ convoy_id: convoyId }) as { total: number; done: number; running: number; failed: number; review_blocked: number; disputed: number; reviewed: number; panel_reviewed: number; tasks_with_drift: number; max_drift_score: number | null; drift_retried: number } | undefined
1101
+ if (!row || row.total === 0) {
1102
+ return { total: 0, done: 0, running: 0, failed: 0, review_blocked: 0, disputed: 0, reviewed: 0, panel_reviewed: 0, tasks_with_drift: 0, max_drift_score: null, drift_retried: 0 }
1103
+ }
1104
+ return {
1105
+ total: row.total ?? 0,
1106
+ done: row.done ?? 0,
1107
+ running: row.running ?? 0,
1108
+ failed: row.failed ?? 0,
1109
+ review_blocked: row.review_blocked ?? 0,
1110
+ disputed: row.disputed ?? 0,
1111
+ reviewed: row.reviewed ?? 0,
1112
+ panel_reviewed: row.panel_reviewed ?? 0,
1113
+ tasks_with_drift: row.tasks_with_drift ?? 0,
1114
+ max_drift_score: row.max_drift_score ?? null,
1115
+ drift_retried: row.drift_retried ?? 0,
1116
+ }
1117
+ }
1118
+
1119
+ getConvoyList(limit: number, offset: number): ConvoyRecord[] {
1120
+ return this.db.prepare(
1121
+ `SELECT *, total_cost_usd_num AS total_cost_usd
1122
+ FROM convoy
1123
+ ORDER BY created_at DESC
1124
+ LIMIT :limit OFFSET :offset`,
1125
+ ).all({ limit, offset }) as unknown as ConvoyRecord[]
1126
+ }
1127
+
1128
+ getConvoyDetails(convoyId: string): DashboardConvoyDetail | null {
1129
+ const convoy = this.getConvoy(convoyId)
1130
+ if (!convoy) return null
1131
+
1132
+ const tasks = this.getTasksByConvoy(convoyId)
1133
+ const taskSummary = this.getConvoyTaskSummary(convoyId)
1134
+ const dlqEntries = this.listDlqEntries(convoyId)
1135
+ const rawEvents = this.getEvents(convoyId)
1136
+ const artifacts = this.getArtifactsByConvoy(convoyId)
1137
+ const limitedEvents = rawEvents.slice().reverse().slice(0, 500)
1138
+
1139
+ return {
1140
+ convoy: {
1141
+ id: convoy.id,
1142
+ name: convoy.name,
1143
+ status: convoy.status,
1144
+ created_at: convoy.created_at,
1145
+ finished_at: convoy.finished_at,
1146
+ branch: convoy.branch,
1147
+ total_tokens: convoy.total_tokens,
1148
+ total_cost_usd: convoy.total_cost_usd,
1149
+ },
1150
+ taskSummary,
1151
+ quality: {
1152
+ reviewed_tasks: taskSummary.reviewed,
1153
+ review_blocked_tasks: taskSummary.review_blocked,
1154
+ disputed_tasks: taskSummary.disputed,
1155
+ panel_reviews: taskSummary.panel_reviewed,
1156
+ },
1157
+ drift: {
1158
+ tasks_with_drift: taskSummary.tasks_with_drift,
1159
+ max_drift_score: taskSummary.max_drift_score,
1160
+ drift_retried_tasks: taskSummary.drift_retried,
1161
+ },
1162
+ dlq_count: dlqEntries.length,
1163
+ dlq_entries: dlqEntries.map(d => ({
1164
+ id: d.id,
1165
+ task_id: d.task_id,
1166
+ agent: d.agent,
1167
+ failure_type: d.failure_type,
1168
+ attempts: d.attempts,
1169
+ resolved: d.resolved,
1170
+ })),
1171
+ artifact_count: artifacts.length,
1172
+ artifacts: artifacts.map(a => ({
1173
+ id: a.id,
1174
+ name: a.name,
1175
+ type: a.type,
1176
+ task_id: a.task_id,
1177
+ created_at: a.created_at,
1178
+ })),
1179
+ has_more_events: rawEvents.length > 500,
1180
+ events: limitedEvents.map(e => ({
1181
+ type: e.type,
1182
+ task_id: e.task_id,
1183
+ data: e.data ? (() => { try { return JSON.parse(e.data as string) } catch { return e.data } })() : null,
1184
+ created_at: e.created_at,
1185
+ })),
1186
+ tasks: tasks.map(t => ({
1187
+ id: t.id,
1188
+ phase: t.phase,
1189
+ agent: t.agent,
1190
+ model: t.model,
1191
+ status: t.status,
1192
+ retries: t.retries,
1193
+ started_at: t.started_at,
1194
+ finished_at: t.finished_at,
1195
+ total_tokens: t.total_tokens,
1196
+ cost_usd: t.cost_usd,
1197
+ review_level: t.review_level,
1198
+ review_verdict: t.review_verdict,
1199
+ review_tokens: t.review_tokens,
1200
+ review_model: t.review_model,
1201
+ panel_attempts: t.panel_attempts,
1202
+ dispute_id: t.dispute_id,
1203
+ drift_score: t.drift_score,
1204
+ drift_retried: t.drift_retried,
1205
+ files: t.files ? (() => { try { return JSON.parse(t.files as string) } catch { return null } })() : null,
1206
+ })),
1207
+ }
1208
+ }
1209
+
967
1210
  withTransaction<T>(fn: () => T): T {
968
1211
  this.db.exec('BEGIN')
969
1212
  try {
@@ -1081,6 +1324,16 @@ export function migrateSchema(db: DatabaseSync, dbPath: string, fromVersion: num
1081
1324
  );
1082
1325
  `)
1083
1326
  }
1327
+ if (v === 9) {
1328
+ db.exec(`
1329
+ ALTER TABLE convoy ADD COLUMN total_cost_usd_num REAL;
1330
+ ALTER TABLE task ADD COLUMN cost_usd_num REAL;
1331
+ ALTER TABLE pipeline ADD COLUMN total_cost_usd_num REAL;
1332
+ UPDATE convoy SET total_cost_usd_num = CAST(total_cost_usd AS REAL) WHERE total_cost_usd IS NOT NULL;
1333
+ UPDATE task SET cost_usd_num = CAST(cost_usd AS REAL) WHERE cost_usd IS NOT NULL;
1334
+ UPDATE pipeline SET total_cost_usd_num = CAST(total_cost_usd AS REAL) WHERE total_cost_usd IS NOT NULL;
1335
+ `)
1336
+ }
1084
1337
  db.exec('COMMIT')
1085
1338
  } catch (err) {
1086
1339
  try { db.exec('ROLLBACK') } catch { /* ignore */ }
@@ -29,7 +29,7 @@ export interface ConvoyRecord {
29
29
  finished_at: string | null
30
30
  spec_yaml: string
31
31
  total_tokens: number | null
32
- total_cost_usd: string | null
32
+ total_cost_usd: number | null
33
33
  pipeline_id: string | null
34
34
  circuit_state: string | null
35
35
  review_tokens_total: number | null
@@ -59,7 +59,7 @@ export interface TaskRecord {
59
59
  prompt_tokens: number | null
60
60
  completion_tokens: number | null
61
61
  total_tokens: number | null
62
- cost_usd: string | null
62
+ cost_usd: number | null
63
63
  gates: string | null
64
64
  on_exhausted: 'dlq' | 'skip' | 'stop'
65
65
  injected: number
@@ -114,7 +114,7 @@ export interface PipelineRecord {
114
114
  started_at: string | null
115
115
  finished_at: string | null
116
116
  total_tokens: number | null
117
- total_cost_usd: string | null
117
+ total_cost_usd: number | null
118
118
  }
119
119
 
120
120
  export interface BuiltInGatesConfig {
@@ -259,3 +259,108 @@ export interface MCPServerConfig {
259
259
  url?: string
260
260
  config?: Record<string, unknown>
261
261
  }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Discriminated union covering every canonical convoy event type.
265
+ // Each variant constrains the `data` shape that callers may pass to emit().
266
+ // ---------------------------------------------------------------------------
267
+ export type ConvoyEventType =
268
+ | { type: 'convoy_started'; data?: { name?: string } }
269
+ | { type: 'convoy_finished'; data?: { status: string } }
270
+ | { type: 'convoy_failed'; data?: { status: string; reason?: string } }
271
+ | { type: 'convoy_guard'; data?: { checks?: string[]; [key: string]: unknown } }
272
+ | { type: 'task_started'; data?: { worker_id?: string } }
273
+ | { type: 'task_done'; data?: { status?: string; retries?: number; worker_id?: string } }
274
+ | { type: 'task_failed'; data?: { reason: string; worker_id?: string; gate?: string; hook?: string } }
275
+ | { type: 'task_skipped'; data?: { reason: string } }
276
+ | { type: 'task_retried'; data?: { previous_status: string } }
277
+ | { type: 'task_waiting_input'; data?: { task_id?: string; reason?: string } }
278
+ | { type: 'review_started'; data?: { level: string; task_id?: string; model?: string } }
279
+ | {
280
+ type: 'review_verdict'
281
+ data?: {
282
+ level: string
283
+ verdict: string
284
+ tokens: number
285
+ model?: string
286
+ feedback_length?: number
287
+ budget_exceeded?: boolean
288
+ budget_downgrade?: boolean
289
+ budget_skip?: boolean
290
+ passes?: number
291
+ blocks?: number
292
+ }
293
+ }
294
+ | { type: 'dispute_opened'; data?: { dispute_id: string; task_id: string; agent?: string; reason?: string } }
295
+ | { type: 'dlq_entry_created'; data?: { dlq_id: string; task_id: string; agent?: string; attempts?: number } }
296
+ | { type: 'drift_check_result'; data?: { score?: number; threshold?: number; passed?: boolean } }
297
+ | { type: 'drift_detected'; data?: { score?: number; files?: string[] } }
298
+ | { type: 'circuit_breaker_tripped'; data?: { agent?: string; failure_count?: number; threshold?: number } }
299
+ | { type: 'circuit_breaker_fallback'; data?: { original_agent?: string; fallback_agent?: string; task_id?: string } }
300
+ | { type: 'circuit_breaker_blocked'; data?: { agent?: string; task_id?: string } }
301
+ | { type: 'merge_conflict_detected'; data?: { task_id?: string; files?: string[] } }
302
+ | { type: 'merge_conflict_failed'; data?: { task_id?: string; error?: string } }
303
+ | { type: 'file_injection_received'; data?: { task_id?: string; from_task?: string; name?: string } }
304
+ | { type: 'artifact_limit_reached'; data?: { task_id?: string; limit?: number; current?: number } }
305
+ | { type: 'agent_identity_captured'; data?: { agent?: string; task_id?: string } }
306
+ | { type: 'agent_identity_rejected'; data?: { agent?: string; task_id?: string; reason?: string } }
307
+ | { type: 'weak_area_skipped'; data?: { agent?: string; weak_areas?: string[]; task_files?: string[] } }
308
+ | { type: 'swarm_concurrency_update'; data?: { new_concurrency?: number; reason?: string } }
309
+ | { type: 'post_convoy_hook_failed'; data?: { hook?: string; error?: string } }
310
+ | { type: 'session'; data?: { agent?: string; model?: string; task?: string; outcome?: string; duration_min?: number } }
311
+ | { type: 'delegation'; data?: { agent?: string; model?: string; tier?: string; mechanism?: string; outcome?: string } }
312
+ | {
313
+ type: 'secret_leak_prevented'
314
+ data?: { original_type?: string; patterns?: string[]; task_id?: string; findings_count?: number; context?: string }
315
+ }
316
+ | { type: 'ndjson_write_failed'; data?: { original_type?: string } }
317
+ | { type: 'built_in_gate_result'; data?: { gate: string; passed: boolean; output?: string; level?: string } }
318
+ | { type: 'watch_started'; data?: { trigger_type?: string; pid?: number } }
319
+ | { type: 'watch_cycle_start'; data?: { cycle_number?: number; triggered_by?: string } }
320
+ | { type: 'watch_cycle_end'; data?: { cycle_number?: number; status?: string } }
321
+ | { type: 'watch_stopped'; data?: { reason?: string } }
322
+ | { type: 'worker_killed'; data?: { reason?: string; worker_id?: string; task_id?: string } }
323
+ | { type: 'discovered_issue'; data?: { task_id?: string; title?: string; file?: string; description?: string; severity?: string } }
324
+
325
+ /** All canonical convoy event type strings. Used for runtime validation. */
326
+ export const KNOWN_EVENT_TYPES: Set<string> = new Set<ConvoyEventType['type']>([
327
+ 'convoy_started',
328
+ 'convoy_finished',
329
+ 'convoy_failed',
330
+ 'convoy_guard',
331
+ 'task_started',
332
+ 'task_done',
333
+ 'task_failed',
334
+ 'task_skipped',
335
+ 'task_retried',
336
+ 'task_waiting_input',
337
+ 'review_started',
338
+ 'review_verdict',
339
+ 'dispute_opened',
340
+ 'dlq_entry_created',
341
+ 'drift_check_result',
342
+ 'drift_detected',
343
+ 'circuit_breaker_tripped',
344
+ 'circuit_breaker_fallback',
345
+ 'circuit_breaker_blocked',
346
+ 'merge_conflict_detected',
347
+ 'merge_conflict_failed',
348
+ 'file_injection_received',
349
+ 'artifact_limit_reached',
350
+ 'agent_identity_captured',
351
+ 'agent_identity_rejected',
352
+ 'weak_area_skipped',
353
+ 'swarm_concurrency_update',
354
+ 'post_convoy_hook_failed',
355
+ 'session',
356
+ 'delegation',
357
+ 'secret_leak_prevented',
358
+ 'ndjson_write_failed',
359
+ 'built_in_gate_result',
360
+ 'watch_started',
361
+ 'watch_cycle_start',
362
+ 'watch_cycle_end',
363
+ 'watch_stopped',
364
+ 'worker_killed',
365
+ 'discovered_issue',
366
+ ])