opencastle 0.27.0 → 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 (242) hide show
  1. package/bin/cli.mjs +6 -0
  2. package/dist/cli/agents.d.ts +3 -0
  3. package/dist/cli/agents.d.ts.map +1 -0
  4. package/dist/cli/agents.js +161 -0
  5. package/dist/cli/agents.js.map +1 -0
  6. package/dist/cli/baselines.d.ts +3 -0
  7. package/dist/cli/baselines.d.ts.map +1 -0
  8. package/dist/cli/baselines.js +128 -0
  9. package/dist/cli/baselines.js.map +1 -0
  10. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  11. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  12. package/dist/cli/convoy/dashboard-types.js +2 -0
  13. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  14. package/dist/cli/convoy/engine.d.ts +67 -2
  15. package/dist/cli/convoy/engine.d.ts.map +1 -1
  16. package/dist/cli/convoy/engine.js +2036 -28
  17. package/dist/cli/convoy/engine.js.map +1 -1
  18. package/dist/cli/convoy/engine.test.js +1659 -70
  19. package/dist/cli/convoy/engine.test.js.map +1 -1
  20. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  21. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  22. package/dist/cli/convoy/event-schemas.js +185 -0
  23. package/dist/cli/convoy/event-schemas.js.map +1 -0
  24. package/dist/cli/convoy/events.d.ts +12 -1
  25. package/dist/cli/convoy/events.d.ts.map +1 -1
  26. package/dist/cli/convoy/events.js +186 -13
  27. package/dist/cli/convoy/events.js.map +1 -1
  28. package/dist/cli/convoy/events.test.js +325 -28
  29. package/dist/cli/convoy/events.test.js.map +1 -1
  30. package/dist/cli/convoy/expertise.d.ts +16 -0
  31. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  32. package/dist/cli/convoy/expertise.js +121 -0
  33. package/dist/cli/convoy/expertise.js.map +1 -0
  34. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  35. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  36. package/dist/cli/convoy/expertise.test.js +96 -0
  37. package/dist/cli/convoy/expertise.test.js.map +1 -0
  38. package/dist/cli/convoy/export.test.js +1 -0
  39. package/dist/cli/convoy/export.test.js.map +1 -1
  40. package/dist/cli/convoy/formula.d.ts +19 -0
  41. package/dist/cli/convoy/formula.d.ts.map +1 -0
  42. package/dist/cli/convoy/formula.js +142 -0
  43. package/dist/cli/convoy/formula.js.map +1 -0
  44. package/dist/cli/convoy/formula.test.d.ts +2 -0
  45. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  46. package/dist/cli/convoy/formula.test.js +342 -0
  47. package/dist/cli/convoy/formula.test.js.map +1 -0
  48. package/dist/cli/convoy/gates.d.ts +128 -0
  49. package/dist/cli/convoy/gates.d.ts.map +1 -0
  50. package/dist/cli/convoy/gates.js +606 -0
  51. package/dist/cli/convoy/gates.js.map +1 -0
  52. package/dist/cli/convoy/gates.test.d.ts +2 -0
  53. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  54. package/dist/cli/convoy/gates.test.js +976 -0
  55. package/dist/cli/convoy/gates.test.js.map +1 -0
  56. package/dist/cli/convoy/health.d.ts +11 -0
  57. package/dist/cli/convoy/health.d.ts.map +1 -1
  58. package/dist/cli/convoy/health.js +54 -0
  59. package/dist/cli/convoy/health.js.map +1 -1
  60. package/dist/cli/convoy/health.test.js +56 -1
  61. package/dist/cli/convoy/health.test.js.map +1 -1
  62. package/dist/cli/convoy/issues.d.ts +8 -0
  63. package/dist/cli/convoy/issues.d.ts.map +1 -0
  64. package/dist/cli/convoy/issues.js +98 -0
  65. package/dist/cli/convoy/issues.js.map +1 -0
  66. package/dist/cli/convoy/issues.test.d.ts +2 -0
  67. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  68. package/dist/cli/convoy/issues.test.js +107 -0
  69. package/dist/cli/convoy/issues.test.js.map +1 -0
  70. package/dist/cli/convoy/knowledge.d.ts +5 -0
  71. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  72. package/dist/cli/convoy/knowledge.js +116 -0
  73. package/dist/cli/convoy/knowledge.js.map +1 -0
  74. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  75. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  76. package/dist/cli/convoy/knowledge.test.js +87 -0
  77. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  78. package/dist/cli/convoy/lessons.d.ts +17 -0
  79. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  80. package/dist/cli/convoy/lessons.js +149 -0
  81. package/dist/cli/convoy/lessons.js.map +1 -0
  82. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  83. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  84. package/dist/cli/convoy/lessons.test.js +135 -0
  85. package/dist/cli/convoy/lessons.test.js.map +1 -0
  86. package/dist/cli/convoy/lock.d.ts +13 -0
  87. package/dist/cli/convoy/lock.d.ts.map +1 -0
  88. package/dist/cli/convoy/lock.js +88 -0
  89. package/dist/cli/convoy/lock.js.map +1 -0
  90. package/dist/cli/convoy/lock.test.d.ts +2 -0
  91. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  92. package/dist/cli/convoy/lock.test.js +136 -0
  93. package/dist/cli/convoy/lock.test.js.map +1 -0
  94. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  95. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  96. package/dist/cli/convoy/log-merge.test.js +147 -0
  97. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  98. package/dist/cli/convoy/merge.d.ts +4 -0
  99. package/dist/cli/convoy/merge.d.ts.map +1 -1
  100. package/dist/cli/convoy/merge.js +18 -1
  101. package/dist/cli/convoy/merge.js.map +1 -1
  102. package/dist/cli/convoy/merge.test.js +6 -7
  103. package/dist/cli/convoy/merge.test.js.map +1 -1
  104. package/dist/cli/convoy/partition.d.ts +51 -0
  105. package/dist/cli/convoy/partition.d.ts.map +1 -0
  106. package/dist/cli/convoy/partition.js +186 -0
  107. package/dist/cli/convoy/partition.js.map +1 -0
  108. package/dist/cli/convoy/partition.test.d.ts +2 -0
  109. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  110. package/dist/cli/convoy/partition.test.js +315 -0
  111. package/dist/cli/convoy/partition.test.js.map +1 -0
  112. package/dist/cli/convoy/pipeline.test.js +6 -0
  113. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  114. package/dist/cli/convoy/store.d.ts +99 -7
  115. package/dist/cli/convoy/store.d.ts.map +1 -1
  116. package/dist/cli/convoy/store.js +764 -31
  117. package/dist/cli/convoy/store.js.map +1 -1
  118. package/dist/cli/convoy/store.test.js +1810 -18
  119. package/dist/cli/convoy/store.test.js.map +1 -1
  120. package/dist/cli/convoy/types.d.ts +427 -5
  121. package/dist/cli/convoy/types.d.ts.map +1 -1
  122. package/dist/cli/convoy/types.js +42 -1
  123. package/dist/cli/convoy/types.js.map +1 -1
  124. package/dist/cli/log.d.ts +11 -0
  125. package/dist/cli/log.d.ts.map +1 -1
  126. package/dist/cli/log.js +114 -2
  127. package/dist/cli/log.js.map +1 -1
  128. package/dist/cli/run/adapters/claude.d.ts +2 -0
  129. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  130. package/dist/cli/run/adapters/claude.js +89 -49
  131. package/dist/cli/run/adapters/claude.js.map +1 -1
  132. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  133. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  134. package/dist/cli/run/adapters/claude.test.js +205 -0
  135. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  137. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  138. package/dist/cli/run/adapters/copilot.js +84 -46
  139. package/dist/cli/run/adapters/copilot.js.map +1 -1
  140. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  141. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  142. package/dist/cli/run/adapters/copilot.test.js +195 -0
  143. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  145. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  146. package/dist/cli/run/adapters/cursor.js +83 -47
  147. package/dist/cli/run/adapters/cursor.js.map +1 -1
  148. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  149. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  150. package/dist/cli/run/adapters/cursor.test.js +129 -0
  151. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  153. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  154. package/dist/cli/run/adapters/opencode.js +81 -47
  155. package/dist/cli/run/adapters/opencode.js.map +1 -1
  156. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  157. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  158. package/dist/cli/run/adapters/opencode.test.js +119 -0
  159. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  160. package/dist/cli/run/executor.js +1 -1
  161. package/dist/cli/run/executor.js.map +1 -1
  162. package/dist/cli/run/schema.d.ts.map +1 -1
  163. package/dist/cli/run/schema.js +245 -4
  164. package/dist/cli/run/schema.js.map +1 -1
  165. package/dist/cli/run/schema.test.js +669 -0
  166. package/dist/cli/run/schema.test.js.map +1 -1
  167. package/dist/cli/run.d.ts.map +1 -1
  168. package/dist/cli/run.js +362 -22
  169. package/dist/cli/run.js.map +1 -1
  170. package/dist/cli/types.d.ts +85 -2
  171. package/dist/cli/types.d.ts.map +1 -1
  172. package/dist/cli/types.js.map +1 -1
  173. package/dist/cli/watch.d.ts +15 -0
  174. package/dist/cli/watch.d.ts.map +1 -0
  175. package/dist/cli/watch.js +279 -0
  176. package/dist/cli/watch.js.map +1 -0
  177. package/package.json +5 -1
  178. package/src/cli/agents.ts +177 -0
  179. package/src/cli/baselines.ts +143 -0
  180. package/src/cli/convoy/TELEMETRY.md +203 -0
  181. package/src/cli/convoy/dashboard-types.ts +141 -0
  182. package/src/cli/convoy/engine.test.ts +1937 -70
  183. package/src/cli/convoy/engine.ts +2350 -40
  184. package/src/cli/convoy/event-schemas.ts +195 -0
  185. package/src/cli/convoy/events.test.ts +384 -39
  186. package/src/cli/convoy/events.ts +202 -16
  187. package/src/cli/convoy/expertise.test.ts +128 -0
  188. package/src/cli/convoy/expertise.ts +163 -0
  189. package/src/cli/convoy/export.test.ts +1 -0
  190. package/src/cli/convoy/formula.test.ts +405 -0
  191. package/src/cli/convoy/formula.ts +174 -0
  192. package/src/cli/convoy/gates.test.ts +1169 -0
  193. package/src/cli/convoy/gates.ts +774 -0
  194. package/src/cli/convoy/health.test.ts +64 -2
  195. package/src/cli/convoy/health.ts +80 -2
  196. package/src/cli/convoy/issues.test.ts +143 -0
  197. package/src/cli/convoy/issues.ts +136 -0
  198. package/src/cli/convoy/knowledge.test.ts +101 -0
  199. package/src/cli/convoy/knowledge.ts +132 -0
  200. package/src/cli/convoy/lessons.test.ts +188 -0
  201. package/src/cli/convoy/lessons.ts +164 -0
  202. package/src/cli/convoy/lock.test.ts +181 -0
  203. package/src/cli/convoy/lock.ts +103 -0
  204. package/src/cli/convoy/log-merge.test.ts +179 -0
  205. package/src/cli/convoy/merge.test.ts +6 -7
  206. package/src/cli/convoy/merge.ts +19 -1
  207. package/src/cli/convoy/partition.test.ts +423 -0
  208. package/src/cli/convoy/partition.ts +232 -0
  209. package/src/cli/convoy/pipeline.test.ts +6 -0
  210. package/src/cli/convoy/store.test.ts +2041 -20
  211. package/src/cli/convoy/store.ts +945 -46
  212. package/src/cli/convoy/types.ts +278 -4
  213. package/src/cli/log.ts +120 -2
  214. package/src/cli/run/adapters/claude.test.ts +234 -0
  215. package/src/cli/run/adapters/claude.ts +45 -5
  216. package/src/cli/run/adapters/copilot.test.ts +224 -0
  217. package/src/cli/run/adapters/copilot.ts +34 -4
  218. package/src/cli/run/adapters/cursor.test.ts +144 -0
  219. package/src/cli/run/adapters/cursor.ts +33 -2
  220. package/src/cli/run/adapters/opencode.test.ts +135 -0
  221. package/src/cli/run/adapters/opencode.ts +30 -2
  222. package/src/cli/run/executor.ts +1 -1
  223. package/src/cli/run/schema.test.ts +758 -0
  224. package/src/cli/run/schema.ts +300 -25
  225. package/src/cli/run.ts +341 -21
  226. package/src/cli/types.ts +86 -1
  227. package/src/cli/watch.ts +298 -0
  228. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  229. package/src/dashboard/dist/data/.gitkeep +0 -0
  230. package/src/dashboard/dist/data/convoy-list.json +1 -0
  231. package/src/dashboard/dist/data/overall-stats.json +24 -0
  232. package/src/dashboard/dist/index.html +701 -3
  233. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  234. package/src/dashboard/public/data/.gitkeep +0 -0
  235. package/src/dashboard/public/data/convoy-list.json +1 -0
  236. package/src/dashboard/public/data/overall-stats.json +24 -0
  237. package/src/dashboard/scripts/etl.test.ts +210 -0
  238. package/src/dashboard/scripts/etl.ts +108 -0
  239. package/src/dashboard/scripts/integration-test.ts +504 -0
  240. package/src/dashboard/src/pages/index.astro +854 -15
  241. package/src/dashboard/src/styles/dashboard.css +557 -1
  242. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -1,4 +1,6 @@
1
+ import { copyFileSync } from 'node:fs'
1
2
  import { DatabaseSync } from 'node:sqlite'
3
+ import type { DashboardConvoyDetail } from './dashboard-types.js'
2
4
  import type {
3
5
  ConvoyRecord,
4
6
  ConvoyStatus,
@@ -9,36 +11,116 @@ import type {
9
11
  EventRecord,
10
12
  PipelineRecord,
11
13
  PipelineStatus,
14
+ DlqRecord,
15
+ ArtifactRecord,
16
+ AgentIdentityRecord,
17
+ TaskStepRecord,
12
18
  } from './types.js'
13
19
 
14
- const SCHEMA_VERSION = 4
20
+ const SCHEMA_VERSION = 10
21
+
22
+ // ── Size limits (bytes) ────────────────────────────────────────────────────────
23
+ const LIMIT_SPEC_YAML = 256 * 1024 // 256 KB
24
+ const LIMIT_OUTPUT = 1024 * 1024 // 1 MB (head 512KB + tail 512KB)
25
+ const LIMIT_OUTPUT_HALF = 512 * 1024 // 512 KB per half
26
+ const LIMIT_EVENT_DATA = 64 * 1024 // 64 KB
27
+ const LIMIT_SUMMARY = 4096 // 4 KB
28
+
29
+ export class FieldSizeLimitError extends Error {
30
+ constructor(field: string, actual: number, limit: number) {
31
+ super(`Field "${field}" exceeds size limit: ${actual} bytes > ${limit} bytes`)
32
+ this.name = 'FieldSizeLimitError'
33
+ }
34
+ }
35
+
36
+ function enforceLimit(value: string | null | undefined, field: string, limit: number): void {
37
+ if (value == null) return
38
+ const size = Buffer.byteLength(value, 'utf8')
39
+ if (size > limit) {
40
+ throw new FieldSizeLimitError(field, size, limit)
41
+ }
42
+ }
43
+
44
+ function truncateOutput(value: string | null | undefined): string | null {
45
+ if (value == null) return null
46
+ const size = Buffer.byteLength(value, 'utf8')
47
+ if (size <= LIMIT_OUTPUT) return value
48
+ // Head + tail truncation with marker
49
+ const head = value.slice(0, LIMIT_OUTPUT_HALF)
50
+ const tail = value.slice(-LIMIT_OUTPUT_HALF)
51
+ return head + '\n\n... [truncated: ' + size + ' bytes total, showing first/last 512KB] ...\n\n' + tail
52
+ }
53
+
54
+ export class ConvoyArtifactLimitError extends Error {
55
+ constructor(convoyId: string) {
56
+ super(`Convoy ${convoyId} has reached the maximum of 50 artifacts`)
57
+ this.name = 'ConvoyArtifactLimitError'
58
+ }
59
+ }
15
60
 
16
61
  export interface ConvoyStore {
17
- insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd' | 'pipeline_id'> & { pipeline_id?: string | null }): void
62
+ insertConvoy(
63
+ record: Omit<
64
+ ConvoyRecord,
65
+ | 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'
66
+ | 'pipeline_id' | 'circuit_state' | 'review_tokens_total' | 'review_budget'
67
+ > & { pipeline_id?: string | null },
68
+ ): void
18
69
  getConvoy(id: string): ConvoyRecord | undefined
19
70
  getLatestConvoy(): ConvoyRecord | undefined
20
71
  updateConvoyStatus(
21
72
  id: string,
22
73
  status: ConvoyStatus,
23
- 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 },
24
75
  ): void
76
+ updateConvoyReviewTokens(convoyId: string, tokens: number): void
77
+ updateConvoyCircuitState(convoyId: string, state: string | null): void
25
78
  insertTask(
26
79
  record: Omit<
27
80
  TaskRecord,
28
- 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
29
- >,
81
+ | 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
82
+ | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
83
+ | 'on_exhausted' | 'injected' | 'provenance' | 'idempotency_key'
84
+ | 'current_step' | 'total_steps' | 'review_level' | 'review_verdict'
85
+ | 'review_tokens' | 'review_model' | 'panel_attempts' | 'dispute_id'
86
+ | 'drift_score' | 'drift_retried' | 'discovered_issues'
87
+ > & { outputs?: string | null; inputs?: string | null },
30
88
  ): void
89
+ insertInjectedTask(record: TaskRecord): void
31
90
  getTask(id: string, convoyId: string): TaskRecord | undefined
32
91
  getTasksByConvoy(convoyId: string): TaskRecord[]
92
+ getTaskByIdempotencyKey(convoyId: string, key: string): TaskRecord | undefined
93
+ getTaskByDisputeId(disputeId: string): TaskRecord | undefined
94
+ getDisputedTasks(convoyId?: string): TaskRecord[]
33
95
  updateTaskStatus(
34
96
  id: string,
35
97
  convoyId: string,
36
98
  status: ConvoyTaskStatus,
37
99
  extra?: Partial<
38
- Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'>
100
+ Pick<
101
+ TaskRecord,
102
+ | 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
103
+ | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd' | 'prompt'
104
+ >
39
105
  >,
40
106
  ): void
107
+ updateTaskReview(
108
+ taskId: string,
109
+ convoyId: string,
110
+ fields: Partial<Pick<TaskRecord, 'review_level' | 'review_verdict' | 'review_tokens' | 'review_model' | 'panel_attempts' | 'dispute_id'>>,
111
+ ): void
112
+ updateTaskDrift(
113
+ taskId: string,
114
+ convoyId: string,
115
+ fields: Partial<Pick<TaskRecord, 'drift_score' | 'drift_retried'>>,
116
+ ): void
117
+ updateTaskDisputeStatus(taskId: string, convoyId: string, status: ConvoyTaskStatus, disputeId: string): void
41
118
  getReadyTasks(convoyId: string): TaskRecord[]
119
+ insertTaskStep(record: Omit<TaskStepRecord, 'id'>): number
120
+ updateTaskStep(
121
+ id: number,
122
+ fields: Partial<Pick<TaskStepRecord, 'status' | 'exit_code' | 'output' | 'started_at' | 'finished_at'>>,
123
+ ): void
42
124
  insertWorker(record: Omit<WorkerRecord, 'finished_at' | 'last_heartbeat'>): void
43
125
  getWorker(id: string): WorkerRecord | undefined
44
126
  updateWorkerStatus(
@@ -46,25 +128,53 @@ export interface ConvoyStore {
46
128
  status: WorkerStatus,
47
129
  extra?: Partial<Pick<WorkerRecord, 'finished_at' | 'last_heartbeat' | 'pid'>>,
48
130
  ): void
49
- insertEvent(record: Omit<EventRecord, 'id'>): void
131
+ insertEvent(record: Omit<EventRecord, 'id'>): number
50
132
  getEvents(convoyId: string): EventRecord[]
133
+ insertDlqEntry(record: DlqRecord): void
134
+ listDlqEntries(convoyIdFilter?: string): DlqRecord[]
135
+ resolveDlqEntry(id: string, resolution: string): void
136
+ insertArtifact(record: ArtifactRecord): void
137
+ getArtifact(convoyId: string, name: string): ArtifactRecord | undefined
138
+ getArtifactsByTask(taskId: string): ArtifactRecord[]
139
+ getArtifactsByConvoy(convoyId: string): ArtifactRecord[]
140
+ deleteArtifactsOlderThan(days: number): number
141
+ insertAgentIdentity(record: AgentIdentityRecord): void
142
+ getAgentIdentities(agent: string, limit: number): AgentIdentityRecord[]
143
+ listAgentIdentitySummary(): Array<{ agent: string; task_count: number; latest_date: string }>
144
+ purgeAgentIdentities(agent: string): number
145
+ deleteAgentIdentitiesOlderThan(days: number): number
146
+ getScratchpadValue(key: string): string | null
147
+ setScratchpadValue(key: string, value: string): void
148
+ clearScratchpad(): void
149
+ clearScratchpadOlderThan(days: number): void
51
150
  insertPipeline(record: Omit<PipelineRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void
52
151
  getPipeline(id: string): PipelineRecord | undefined
53
152
  getLatestPipeline(): PipelineRecord | undefined
54
153
  updatePipelineStatus(
55
154
  id: string,
56
155
  status: PipelineStatus,
57
- 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 },
58
157
  ): void
59
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
60
168
  withTransaction<T>(fn: () => T): T
61
169
  close(): void
62
170
  }
63
171
 
64
172
  class ConvoyStoreImpl implements ConvoyStore {
65
173
  private db: DatabaseSync
174
+ private dbPath: string
66
175
 
67
176
  constructor(dbPath: string) {
177
+ this.dbPath = dbPath
68
178
  this.db = new DatabaseSync(dbPath)
69
179
  this.db.exec('PRAGMA journal_mode = WAL')
70
180
  this.db.exec('PRAGMA synchronous = NORMAL')
@@ -76,18 +186,22 @@ class ConvoyStoreImpl implements ConvoyStore {
76
186
  if (version === 0) {
77
187
  this.db.exec(`
78
188
  CREATE TABLE IF NOT EXISTS convoy (
79
- id TEXT PRIMARY KEY,
80
- name TEXT NOT NULL,
81
- spec_hash TEXT NOT NULL,
82
- status TEXT NOT NULL DEFAULT 'pending',
83
- branch TEXT,
84
- created_at TEXT NOT NULL,
85
- started_at TEXT,
86
- finished_at TEXT,
87
- spec_yaml TEXT NOT NULL,
88
- total_tokens INTEGER,
89
- total_cost_usd TEXT,
90
- pipeline_id TEXT
189
+ id TEXT PRIMARY KEY,
190
+ name TEXT NOT NULL,
191
+ spec_hash TEXT NOT NULL,
192
+ status TEXT NOT NULL DEFAULT 'pending',
193
+ branch TEXT,
194
+ created_at TEXT NOT NULL,
195
+ started_at TEXT,
196
+ finished_at TEXT,
197
+ spec_yaml TEXT NOT NULL,
198
+ total_tokens INTEGER,
199
+ total_cost_usd TEXT,
200
+ total_cost_usd_num REAL,
201
+ pipeline_id TEXT,
202
+ circuit_state TEXT,
203
+ review_tokens_total INTEGER,
204
+ review_budget INTEGER
91
205
  );
92
206
 
93
207
  CREATE TABLE IF NOT EXISTS pipeline (
@@ -101,7 +215,8 @@ class ConvoyStoreImpl implements ConvoyStore {
101
215
  started_at TEXT,
102
216
  finished_at TEXT,
103
217
  total_tokens INTEGER,
104
- total_cost_usd TEXT
218
+ total_cost_usd TEXT,
219
+ total_cost_usd_num REAL
105
220
  );
106
221
 
107
222
  CREATE TABLE IF NOT EXISTS task (
@@ -127,7 +242,42 @@ class ConvoyStoreImpl implements ConvoyStore {
127
242
  prompt_tokens INTEGER,
128
243
  completion_tokens INTEGER,
129
244
  total_tokens INTEGER,
130
- cost_usd TEXT
245
+ cost_usd TEXT,
246
+ cost_usd_num REAL,
247
+ gates TEXT,
248
+ on_exhausted TEXT NOT NULL DEFAULT 'dlq',
249
+ injected INTEGER NOT NULL DEFAULT 0,
250
+ provenance TEXT,
251
+ idempotency_key TEXT,
252
+ current_step INTEGER,
253
+ total_steps INTEGER,
254
+ review_level TEXT,
255
+ review_verdict TEXT,
256
+ review_tokens INTEGER,
257
+ review_model TEXT,
258
+ panel_attempts INTEGER NOT NULL DEFAULT 0,
259
+ dispute_id TEXT,
260
+ drift_score REAL,
261
+ drift_retried INTEGER NOT NULL DEFAULT 0,
262
+ outputs TEXT,
263
+ inputs TEXT,
264
+ discovered_issues TEXT
265
+ );
266
+
267
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_idempotency ON task(convoy_id, idempotency_key)
268
+ WHERE idempotency_key IS NOT NULL;
269
+
270
+ CREATE TABLE IF NOT EXISTS task_step (
271
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
272
+ task_id TEXT NOT NULL REFERENCES task(id),
273
+ step_index INTEGER NOT NULL,
274
+ prompt TEXT NOT NULL,
275
+ gates TEXT,
276
+ status TEXT NOT NULL DEFAULT 'pending',
277
+ exit_code INTEGER,
278
+ output TEXT,
279
+ started_at TEXT,
280
+ finished_at TEXT
131
281
  );
132
282
 
133
283
  CREATE TABLE IF NOT EXISTS worker (
@@ -152,6 +302,57 @@ class ConvoyStoreImpl implements ConvoyStore {
152
302
  data TEXT,
153
303
  created_at TEXT NOT NULL
154
304
  );
305
+
306
+ CREATE TABLE IF NOT EXISTS dlq (
307
+ id TEXT PRIMARY KEY,
308
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
309
+ task_id TEXT NOT NULL REFERENCES task(id),
310
+ agent TEXT NOT NULL,
311
+ failure_type TEXT NOT NULL,
312
+ error_output TEXT,
313
+ attempts INTEGER NOT NULL,
314
+ tokens_spent INTEGER,
315
+ escalation_task_id TEXT,
316
+ resolved INTEGER NOT NULL DEFAULT 0,
317
+ resolution TEXT,
318
+ created_at TEXT NOT NULL,
319
+ resolved_at TEXT
320
+ );
321
+
322
+ CREATE TABLE IF NOT EXISTS artifact (
323
+ id TEXT PRIMARY KEY,
324
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
325
+ task_id TEXT NOT NULL REFERENCES task(id),
326
+ name TEXT NOT NULL,
327
+ type TEXT NOT NULL,
328
+ content TEXT NOT NULL CHECK (length(content) <= 1048576),
329
+ created_at TEXT NOT NULL,
330
+ UNIQUE(convoy_id, name)
331
+ );
332
+
333
+ CREATE TABLE IF NOT EXISTS agent_identity (
334
+ id TEXT PRIMARY KEY,
335
+ agent TEXT NOT NULL,
336
+ convoy_id TEXT NOT NULL,
337
+ task_id TEXT NOT NULL,
338
+ summary TEXT NOT NULL,
339
+ created_at TEXT NOT NULL,
340
+ retention_days INTEGER NOT NULL DEFAULT 90
341
+ );
342
+
343
+ CREATE TABLE IF NOT EXISTS scratchpad (
344
+ key TEXT PRIMARY KEY,
345
+ value TEXT NOT NULL,
346
+ updated_at TEXT NOT NULL
347
+ );
348
+
349
+ CREATE TABLE IF NOT EXISTS engine_lock (
350
+ id INTEGER PRIMARY KEY,
351
+ pid INTEGER NOT NULL,
352
+ hostname TEXT NOT NULL,
353
+ started_at TEXT NOT NULL,
354
+ last_heartbeat TEXT NOT NULL
355
+ );
155
356
  `)
156
357
  this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
157
358
  version = SCHEMA_VERSION
@@ -191,33 +392,68 @@ class ConvoyStoreImpl implements ConvoyStore {
191
392
  this.db.exec('PRAGMA user_version = 4')
192
393
  version = 4
193
394
  }
395
+ if (version === 4) {
396
+ migrateSchema(this.db, this.dbPath, 4, 5)
397
+ version = 5
398
+ }
399
+ if (version === 5) {
400
+ migrateSchema(this.db, this.dbPath, 5, 6)
401
+ version = 6
402
+ }
403
+ if (version === 6) {
404
+ migrateSchema(this.db, this.dbPath, 6, 7)
405
+ version = 7
406
+ }
407
+ if (version === 7) {
408
+ migrateSchema(this.db, this.dbPath, 7, 8)
409
+ version = 8
410
+ }
411
+ if (version === 8) {
412
+ migrateSchema(this.db, this.dbPath, 8, 9)
413
+ version = 9
414
+ }
415
+ if (version === 9) {
416
+ migrateSchema(this.db, this.dbPath, 9, 10)
417
+ version = 10
418
+ }
194
419
  }
195
420
 
196
- insertConvoy(record: Omit<ConvoyRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd' | 'pipeline_id'> & { pipeline_id?: string | null }): void {
421
+ insertConvoy(
422
+ record: Omit<
423
+ ConvoyRecord,
424
+ | 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'
425
+ | 'pipeline_id' | 'circuit_state' | 'review_tokens_total' | 'review_budget'
426
+ > & { pipeline_id?: string | null },
427
+ ): void {
428
+ enforceLimit(record.spec_yaml, 'spec_yaml', LIMIT_SPEC_YAML)
197
429
  this.db
198
430
  .prepare(
199
- `INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml, pipeline_id)
200
- VALUES (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL, :spec_yaml, :pipeline_id)`,
431
+ `INSERT INTO convoy
432
+ (id, name, spec_hash, status, branch, created_at, started_at, finished_at,
433
+ spec_yaml, pipeline_id)
434
+ VALUES
435
+ (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL,
436
+ :spec_yaml, :pipeline_id)`,
201
437
  )
202
438
  .run({ ...record, pipeline_id: record.pipeline_id ?? null })
203
439
  }
204
440
 
205
441
  getConvoy(id: string): ConvoyRecord | undefined {
206
442
  return this.db
207
- .prepare('SELECT * FROM convoy WHERE id = :id')
443
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM convoy WHERE id = :id')
208
444
  .get({ id }) as ConvoyRecord | undefined
209
445
  }
210
446
 
211
447
  getLatestConvoy(): ConvoyRecord | undefined {
212
448
  return this.db
213
- .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')
214
450
  .get() as ConvoyRecord | undefined
215
451
  }
216
452
 
217
453
  updateConvoyStatus(
218
454
  id: string,
219
455
  status: ConvoyStatus,
220
- 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 },
221
457
  ): void {
222
458
  const sets = ['status = :status']
223
459
  const params: Record<string, string | number | null> = { id, status }
@@ -235,56 +471,139 @@ class ConvoyStoreImpl implements ConvoyStore {
235
471
  params.total_tokens = extra.total_tokens
236
472
  }
237
473
  if (extra?.total_cost_usd !== undefined) {
238
- sets.push('total_cost_usd = :total_cost_usd')
239
- 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
240
478
  }
241
479
 
242
480
  this.db.prepare(`UPDATE convoy SET ${sets.join(', ')} WHERE id = :id`).run(params)
243
481
  }
244
482
 
483
+ updateConvoyReviewTokens(convoyId: string, tokens: number): void {
484
+ this.db
485
+ .prepare(
486
+ `UPDATE convoy
487
+ SET review_tokens_total = :tokens
488
+ WHERE id = :id`,
489
+ )
490
+ .run({ id: convoyId, tokens })
491
+ }
492
+
493
+ updateConvoyCircuitState(convoyId: string, state: string | null): void {
494
+ this.db
495
+ .prepare('UPDATE convoy SET circuit_state = :state WHERE id = :id')
496
+ .run({ id: convoyId, state: state ?? null })
497
+ }
498
+
245
499
  insertTask(
246
500
  record: Omit<
247
501
  TaskRecord,
248
- 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
249
- >,
502
+ | 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
503
+ | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'
504
+ | 'on_exhausted' | 'injected' | 'provenance' | 'idempotency_key'
505
+ | 'current_step' | 'total_steps' | 'review_level' | 'review_verdict'
506
+ | 'review_tokens' | 'review_model' | 'panel_attempts' | 'dispute_id'
507
+ | 'drift_score' | 'drift_retried' | 'discovered_issues'
508
+ > & { outputs?: string | null; inputs?: string | null },
250
509
  ): void {
251
510
  this.db
252
511
  .prepare(
253
512
  `INSERT INTO task
254
513
  (id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms, status,
255
514
  worker_id, worktree, output, exit_code, started_at, finished_at,
256
- retries, max_retries, files, depends_on)
515
+ retries, max_retries, files, depends_on, gates,
516
+ on_exhausted, injected, provenance, idempotency_key,
517
+ outputs, inputs)
257
518
  VALUES
258
519
  (:id, :convoy_id, :phase, :prompt, :agent, :adapter, :model, :timeout_ms, :status,
259
520
  NULL, NULL, NULL, NULL, NULL, NULL,
260
- :retries, :max_retries, :files, :depends_on)`,
521
+ :retries, :max_retries, :files, :depends_on, :gates,
522
+ 'dlq', 0, NULL, NULL,
523
+ :outputs, :inputs)`,
261
524
  )
262
- .run(record)
525
+ .run({ ...record, outputs: record.outputs ?? null, inputs: record.inputs ?? null })
526
+ }
527
+
528
+ insertInjectedTask(record: TaskRecord): void {
529
+ this.db
530
+ .prepare(
531
+ `INSERT INTO task
532
+ (id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms, status,
533
+ worker_id, worktree, output, exit_code, started_at, finished_at,
534
+ retries, max_retries, files, depends_on, gates,
535
+ on_exhausted, injected, provenance, idempotency_key,
536
+ current_step, total_steps, review_level, review_verdict,
537
+ review_tokens, review_model, panel_attempts, dispute_id,
538
+ drift_score, drift_retried, outputs, inputs, discovered_issues)
539
+ VALUES
540
+ (:id, :convoy_id, :phase, :prompt, :agent, :adapter, :model, :timeout_ms, :status,
541
+ :worker_id, :worktree, :output, :exit_code, :started_at, :finished_at,
542
+ :retries, :max_retries, :files, :depends_on, :gates,
543
+ :on_exhausted, :injected, :provenance, :idempotency_key,
544
+ :current_step, :total_steps, :review_level, :review_verdict,
545
+ :review_tokens, :review_model, :panel_attempts, :dispute_id,
546
+ :drift_score, :drift_retried, :outputs, :inputs, :discovered_issues)`,
547
+ )
548
+ .run(record as unknown as Record<string, string | number | null>)
263
549
  }
264
550
 
265
551
  getTask(id: string, convoyId: string): TaskRecord | undefined {
266
552
  return this.db
267
- .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')
268
554
  .get({ id, convoy_id: convoyId }) as TaskRecord | undefined
269
555
  }
270
556
 
271
557
  getTasksByConvoy(convoyId: string): TaskRecord[] {
272
558
  return this.db
273
- .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')
274
560
  .all({ convoy_id: convoyId }) as unknown as TaskRecord[]
275
561
  }
276
562
 
563
+ getTaskByIdempotencyKey(convoyId: string, key: string): TaskRecord | undefined {
564
+ return this.db
565
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE convoy_id = :convoy_id AND idempotency_key = :key')
566
+ .get({ convoy_id: convoyId, key }) as TaskRecord | undefined
567
+ }
568
+
569
+ getTaskByDisputeId(disputeId: string): TaskRecord | undefined {
570
+ return this.db
571
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE dispute_id = :dispute_id LIMIT 1')
572
+ .get({ dispute_id: disputeId }) as TaskRecord | undefined
573
+ }
574
+
575
+ getDisputedTasks(convoyId?: string): TaskRecord[] {
576
+ if (convoyId) {
577
+ return this.db
578
+ .prepare("SELECT *, cost_usd_num AS cost_usd FROM task WHERE status = 'disputed' AND convoy_id = :convoy_id ORDER BY phase, id")
579
+ .all({ convoy_id: convoyId }) as unknown as TaskRecord[]
580
+ }
581
+ return this.db
582
+ .prepare("SELECT *, cost_usd_num AS cost_usd FROM task WHERE status = 'disputed' ORDER BY convoy_id, phase, id")
583
+ .all({}) as unknown as TaskRecord[]
584
+ }
585
+
277
586
  updateTaskStatus(
278
587
  id: string,
279
588
  convoyId: string,
280
589
  status: ConvoyTaskStatus,
281
590
  extra?: Partial<
282
- Pick<TaskRecord, 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at' | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd'>
591
+ Pick<
592
+ TaskRecord,
593
+ | 'worker_id' | 'worktree' | 'output' | 'exit_code' | 'started_at' | 'finished_at'
594
+ | 'retries' | 'prompt_tokens' | 'completion_tokens' | 'total_tokens' | 'cost_usd' | 'prompt'
595
+ >
283
596
  >,
284
597
  ): void {
598
+ if (extra?.output !== undefined) {
599
+ extra = { ...extra, output: truncateOutput(extra.output) }
600
+ }
285
601
  const sets = ['status = :status']
286
602
  const params: Record<string, string | number | null> = { id, convoy_id: convoyId, status }
287
- const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd'] as const
603
+ const extraFields = [
604
+ 'worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at',
605
+ 'retries', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd', 'prompt',
606
+ ] as const
288
607
 
289
608
  if (extra) {
290
609
  for (const field of extraFields) {
@@ -293,6 +612,10 @@ class ConvoyStoreImpl implements ConvoyStore {
293
612
  params[field] = extra[field] as string | number | null
294
613
  }
295
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
+ }
296
619
  }
297
620
 
298
621
  this.db
@@ -312,6 +635,88 @@ class ConvoyStoreImpl implements ConvoyStore {
312
635
  })
313
636
  }
314
637
 
638
+ insertTaskStep(record: Omit<TaskStepRecord, 'id'>): number {
639
+ this.db
640
+ .prepare(
641
+ `INSERT INTO task_step
642
+ (task_id, step_index, prompt, gates, status, exit_code, output, started_at, finished_at)
643
+ VALUES
644
+ (:task_id, :step_index, :prompt, :gates, :status, :exit_code, :output, :started_at, :finished_at)`,
645
+ )
646
+ .run(record)
647
+ const row = this.db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }
648
+ return row.id
649
+ }
650
+
651
+ updateTaskStep(
652
+ id: number,
653
+ fields: Partial<Pick<TaskStepRecord, 'status' | 'exit_code' | 'output' | 'started_at' | 'finished_at'>>,
654
+ ): void {
655
+ const sets: string[] = []
656
+ const params: Record<string, string | number | null> = { id }
657
+ const stepFields = ['status', 'exit_code', 'output', 'started_at', 'finished_at'] as const
658
+
659
+ for (const field of stepFields) {
660
+ if (field in fields && fields[field] !== undefined) {
661
+ sets.push(`${field} = :${field}`)
662
+ params[field] = fields[field] as string | number | null
663
+ }
664
+ }
665
+
666
+ if (sets.length === 0) return
667
+ this.db.prepare(`UPDATE task_step SET ${sets.join(', ')} WHERE id = :id`).run(params)
668
+ }
669
+
670
+ updateTaskReview(
671
+ taskId: string,
672
+ convoyId: string,
673
+ fields: Partial<Pick<TaskRecord, 'review_level' | 'review_verdict' | 'review_tokens' | 'review_model' | 'panel_attempts' | 'dispute_id'>>,
674
+ ): void {
675
+ const sets: string[] = []
676
+ const params: Record<string, string | number | null> = { id: taskId, convoy_id: convoyId }
677
+ const reviewFields = ['review_level', 'review_verdict', 'review_tokens', 'review_model', 'panel_attempts', 'dispute_id'] as const
678
+
679
+ for (const field of reviewFields) {
680
+ if (field in fields && fields[field] !== undefined) {
681
+ sets.push(`${field} = :${field}`)
682
+ params[field] = fields[field] as string | number | null
683
+ }
684
+ }
685
+
686
+ if (sets.length === 0) return
687
+ this.db.prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`).run(params)
688
+ }
689
+
690
+ updateTaskDrift(
691
+ taskId: string,
692
+ convoyId: string,
693
+ fields: Partial<Pick<TaskRecord, 'drift_score' | 'drift_retried'>>,
694
+ ): void {
695
+ const sets: string[] = []
696
+ const params: Record<string, string | number | null> = { id: taskId, convoy_id: convoyId }
697
+
698
+ if (fields.drift_score !== undefined) {
699
+ sets.push('drift_score = :drift_score')
700
+ params.drift_score = fields.drift_score
701
+ }
702
+ if (fields.drift_retried !== undefined) {
703
+ sets.push('drift_retried = :drift_retried')
704
+ params.drift_retried = fields.drift_retried
705
+ }
706
+
707
+ if (sets.length === 0) return
708
+ this.db.prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`).run(params)
709
+ }
710
+
711
+ updateTaskDisputeStatus(taskId: string, convoyId: string, status: ConvoyTaskStatus, disputeId: string): void {
712
+ this.db
713
+ .prepare(
714
+ `UPDATE task SET status = :status, dispute_id = :dispute_id
715
+ WHERE id = :id AND convoy_id = :convoy_id`,
716
+ )
717
+ .run({ id: taskId, convoy_id: convoyId, status, dispute_id: disputeId })
718
+ }
719
+
315
720
  insertWorker(record: Omit<WorkerRecord, 'finished_at' | 'last_heartbeat'>): void {
316
721
  this.db
317
722
  .prepare(
@@ -355,13 +760,16 @@ class ConvoyStoreImpl implements ConvoyStore {
355
760
  this.db.prepare(`UPDATE worker SET ${sets.join(', ')} WHERE id = :id`).run(params)
356
761
  }
357
762
 
358
- insertEvent(record: Omit<EventRecord, 'id'>): void {
763
+ insertEvent(record: Omit<EventRecord, 'id'>): number {
764
+ enforceLimit(record.data, 'event.data', LIMIT_EVENT_DATA)
359
765
  this.db
360
766
  .prepare(
361
767
  `INSERT INTO event (convoy_id, task_id, worker_id, type, data, created_at)
362
768
  VALUES (:convoy_id, :task_id, :worker_id, :type, :data, :created_at)`,
363
769
  )
364
770
  .run(record)
771
+ const row = this.db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }
772
+ return row.id
365
773
  }
366
774
 
367
775
  getEvents(convoyId: string): EventRecord[] {
@@ -370,7 +778,166 @@ class ConvoyStoreImpl implements ConvoyStore {
370
778
  .all({ convoy_id: convoyId }) as unknown as EventRecord[]
371
779
  }
372
780
 
781
+ insertDlqEntry(record: DlqRecord): void {
782
+ this.db
783
+ .prepare(
784
+ `INSERT INTO dlq
785
+ (id, convoy_id, task_id, agent, failure_type, error_output, attempts,
786
+ tokens_spent, escalation_task_id, resolved, resolution, created_at, resolved_at)
787
+ VALUES
788
+ (:id, :convoy_id, :task_id, :agent, :failure_type, :error_output, :attempts,
789
+ :tokens_spent, :escalation_task_id, :resolved, :resolution, :created_at, :resolved_at)`,
790
+ )
791
+ .run(record as unknown as Record<string, string | number | null>)
792
+ }
793
+
794
+ listDlqEntries(convoyIdFilter?: string): DlqRecord[] {
795
+ if (convoyIdFilter) {
796
+ return this.db
797
+ .prepare('SELECT * FROM dlq WHERE convoy_id = :convoy_id ORDER BY created_at DESC')
798
+ .all({ convoy_id: convoyIdFilter }) as unknown as DlqRecord[]
799
+ }
800
+ return this.db
801
+ .prepare('SELECT * FROM dlq ORDER BY created_at DESC')
802
+ .all() as unknown as DlqRecord[]
803
+ }
804
+
805
+ resolveDlqEntry(id: string, resolution: string): void {
806
+ this.db
807
+ .prepare(
808
+ `UPDATE dlq SET resolved = 1, resolution = :resolution, resolved_at = :resolved_at
809
+ WHERE id = :id`,
810
+ )
811
+ .run({ id, resolution, resolved_at: new Date().toISOString() })
812
+ }
813
+
814
+ insertArtifact(record: ArtifactRecord): void {
815
+ const count = (
816
+ this.db
817
+ .prepare('SELECT COUNT(*) AS cnt FROM artifact WHERE convoy_id = :convoy_id')
818
+ .get({ convoy_id: record.convoy_id }) as { cnt: number }
819
+ ).cnt
820
+ if (count >= 50) {
821
+ throw new ConvoyArtifactLimitError(record.convoy_id)
822
+ }
823
+ this.db
824
+ .prepare(
825
+ `INSERT INTO artifact (id, convoy_id, task_id, name, type, content, created_at)
826
+ VALUES (:id, :convoy_id, :task_id, :name, :type, :content, :created_at)`,
827
+ )
828
+ .run(record as unknown as Record<string, string | number | null>)
829
+ }
830
+
831
+ getArtifact(convoyId: string, name: string): ArtifactRecord | undefined {
832
+ return this.db
833
+ .prepare('SELECT * FROM artifact WHERE convoy_id = :convoy_id AND name = :name')
834
+ .get({ convoy_id: convoyId, name }) as ArtifactRecord | undefined
835
+ }
836
+
837
+ getArtifactsByTask(taskId: string): ArtifactRecord[] {
838
+ return this.db
839
+ .prepare('SELECT * FROM artifact WHERE task_id = :task_id ORDER BY created_at')
840
+ .all({ task_id: taskId }) as unknown as ArtifactRecord[]
841
+ }
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
+
849
+ deleteArtifactsOlderThan(days: number): number {
850
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
851
+ const result = this.db
852
+ .prepare(
853
+ `DELETE FROM artifact WHERE convoy_id IN (
854
+ SELECT id FROM convoy WHERE finished_at IS NOT NULL AND finished_at < :cutoff
855
+ )`,
856
+ )
857
+ .run({ cutoff })
858
+ return (result as unknown as { changes: number }).changes
859
+ }
860
+
861
+ insertAgentIdentity(record: AgentIdentityRecord): void {
862
+ const summarySize = Buffer.byteLength(record.summary, 'utf8')
863
+ const truncatedSummary = summarySize > LIMIT_SUMMARY
864
+ ? record.summary.slice(0, LIMIT_SUMMARY)
865
+ : record.summary
866
+ this.db
867
+ .prepare(
868
+ `INSERT INTO agent_identity
869
+ (id, agent, convoy_id, task_id, summary, created_at, retention_days)
870
+ VALUES
871
+ (:id, :agent, :convoy_id, :task_id, :summary, :created_at, :retention_days)`,
872
+ )
873
+ .run({ ...record, summary: truncatedSummary } as unknown as Record<string, string | number | null>)
874
+ }
875
+
876
+ getAgentIdentities(agent: string, limit: number): AgentIdentityRecord[] {
877
+ return this.db
878
+ .prepare(
879
+ 'SELECT * FROM agent_identity WHERE agent = :agent ORDER BY created_at DESC LIMIT :limit',
880
+ )
881
+ .all({ agent, limit }) as unknown as AgentIdentityRecord[]
882
+ }
883
+
884
+ listAgentIdentitySummary(): Array<{ agent: string; task_count: number; latest_date: string }> {
885
+ return this.db
886
+ .prepare(
887
+ `SELECT agent, COUNT(*) AS task_count, MAX(created_at) AS latest_date
888
+ FROM agent_identity GROUP BY agent ORDER BY agent`,
889
+ )
890
+ .all() as unknown as Array<{ agent: string; task_count: number; latest_date: string }>
891
+ }
892
+
893
+ purgeAgentIdentities(agent: string): number {
894
+ const result = this.db
895
+ .prepare('DELETE FROM agent_identity WHERE agent = :agent')
896
+ .run({ agent })
897
+ return (result as unknown as { changes: number }).changes
898
+ }
899
+
900
+ deleteAgentIdentitiesOlderThan(days: number): number {
901
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
902
+ const result = this.db
903
+ .prepare(
904
+ `DELETE FROM agent_identity
905
+ WHERE created_at < :cutoff
906
+ OR (retention_days IS NOT NULL
907
+ AND created_at < datetime('now', '-' || retention_days || ' days'))`,
908
+ )
909
+ .run({ cutoff })
910
+ return (result as unknown as { changes: number }).changes
911
+ }
912
+
913
+ getScratchpadValue(key: string): string | null {
914
+ const row = this.db
915
+ .prepare('SELECT value FROM scratchpad WHERE key = :key')
916
+ .get({ key }) as { value: string } | undefined
917
+ return row?.value ?? null
918
+ }
919
+
920
+ setScratchpadValue(key: string, value: string): void {
921
+ this.db
922
+ .prepare(
923
+ `INSERT INTO scratchpad (key, value, updated_at)
924
+ VALUES (:key, :value, :updated_at)
925
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
926
+ )
927
+ .run({ key, value, updated_at: new Date().toISOString() })
928
+ }
929
+
930
+ clearScratchpad(): void {
931
+ this.db.exec('DELETE FROM scratchpad')
932
+ }
933
+
934
+ clearScratchpadOlderThan(days: number): void {
935
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
936
+ this.db.prepare('DELETE FROM scratchpad WHERE updated_at < :cutoff').run({ cutoff })
937
+ }
938
+
373
939
  insertPipeline(record: Omit<PipelineRecord, 'started_at' | 'finished_at' | 'total_tokens' | 'total_cost_usd'>): void {
940
+ enforceLimit(record.spec_yaml, 'pipeline.spec_yaml', LIMIT_SPEC_YAML)
374
941
  this.db
375
942
  .prepare(
376
943
  `INSERT INTO pipeline (id, name, status, branch, spec_yaml, convoy_specs, created_at,
@@ -383,20 +950,20 @@ class ConvoyStoreImpl implements ConvoyStore {
383
950
 
384
951
  getPipeline(id: string): PipelineRecord | undefined {
385
952
  return this.db
386
- .prepare('SELECT * FROM pipeline WHERE id = :id')
953
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM pipeline WHERE id = :id')
387
954
  .get({ id }) as PipelineRecord | undefined
388
955
  }
389
956
 
390
957
  getLatestPipeline(): PipelineRecord | undefined {
391
958
  return this.db
392
- .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')
393
960
  .get() as PipelineRecord | undefined
394
961
  }
395
962
 
396
963
  updatePipelineStatus(
397
964
  id: string,
398
965
  status: PipelineStatus,
399
- 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 },
400
967
  ): void {
401
968
  const sets = ['status = :status']
402
969
  const params: Record<string, string | number | null> = { id, status }
@@ -414,8 +981,10 @@ class ConvoyStoreImpl implements ConvoyStore {
414
981
  params.total_tokens = extra.total_tokens
415
982
  }
416
983
  if (extra?.total_cost_usd !== undefined) {
417
- sets.push('total_cost_usd = :total_cost_usd')
418
- 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
419
988
  }
420
989
 
421
990
  this.db.prepare(`UPDATE pipeline SET ${sets.join(', ')} WHERE id = :id`).run(params)
@@ -423,10 +992,221 @@ class ConvoyStoreImpl implements ConvoyStore {
423
992
 
424
993
  getConvoysByPipeline(pipelineId: string): ConvoyRecord[] {
425
994
  return this.db
426
- .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')
427
996
  .all({ pipeline_id: pipelineId }) as unknown as ConvoyRecord[]
428
997
  }
429
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
+
430
1210
  withTransaction<T>(fn: () => T): T {
431
1211
  this.db.exec('BEGIN')
432
1212
  try {
@@ -444,6 +1224,125 @@ class ConvoyStoreImpl implements ConvoyStore {
444
1224
  }
445
1225
  }
446
1226
 
1227
+ export function migrateSchema(db: DatabaseSync, dbPath: string, fromVersion: number, toVersion: number): void {
1228
+ for (let v = fromVersion; v < toVersion; v++) {
1229
+ const backupPath = `${dbPath}.v${v}.bak`
1230
+ copyFileSync(dbPath, backupPath)
1231
+ db.exec('BEGIN')
1232
+ try {
1233
+ if (v === 4) {
1234
+ db.exec(`
1235
+ ALTER TABLE task ADD COLUMN gates TEXT;
1236
+ ALTER TABLE task ADD COLUMN on_exhausted TEXT NOT NULL DEFAULT 'dlq';
1237
+ ALTER TABLE task ADD COLUMN injected INTEGER NOT NULL DEFAULT 0;
1238
+ ALTER TABLE task ADD COLUMN provenance TEXT;
1239
+ ALTER TABLE task ADD COLUMN idempotency_key TEXT;
1240
+ CREATE UNIQUE INDEX idx_task_idempotency ON task(convoy_id, idempotency_key)
1241
+ WHERE idempotency_key IS NOT NULL;
1242
+ ALTER TABLE convoy ADD COLUMN circuit_state TEXT;
1243
+ CREATE TABLE dlq (
1244
+ id TEXT PRIMARY KEY,
1245
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
1246
+ task_id TEXT NOT NULL REFERENCES task(id),
1247
+ agent TEXT NOT NULL,
1248
+ failure_type TEXT NOT NULL,
1249
+ error_output TEXT,
1250
+ attempts INTEGER NOT NULL,
1251
+ tokens_spent INTEGER,
1252
+ escalation_task_id TEXT,
1253
+ resolved INTEGER NOT NULL DEFAULT 0,
1254
+ resolution TEXT,
1255
+ created_at TEXT NOT NULL,
1256
+ resolved_at TEXT
1257
+ );
1258
+ `)
1259
+ }
1260
+ if (v === 5) {
1261
+ db.exec(`
1262
+ ALTER TABLE task ADD COLUMN current_step INTEGER;
1263
+ ALTER TABLE task ADD COLUMN total_steps INTEGER;
1264
+ ALTER TABLE task ADD COLUMN review_level TEXT;
1265
+ ALTER TABLE task ADD COLUMN review_verdict TEXT;
1266
+ ALTER TABLE task ADD COLUMN review_tokens INTEGER;
1267
+ ALTER TABLE task ADD COLUMN review_model TEXT;
1268
+ ALTER TABLE task ADD COLUMN panel_attempts INTEGER NOT NULL DEFAULT 0;
1269
+ ALTER TABLE task ADD COLUMN dispute_id TEXT;
1270
+ ALTER TABLE convoy ADD COLUMN review_tokens_total INTEGER;
1271
+ ALTER TABLE convoy ADD COLUMN review_budget INTEGER;
1272
+ CREATE TABLE task_step (
1273
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1274
+ task_id TEXT NOT NULL REFERENCES task(id),
1275
+ step_index INTEGER NOT NULL,
1276
+ prompt TEXT NOT NULL,
1277
+ gates TEXT,
1278
+ status TEXT NOT NULL DEFAULT 'pending',
1279
+ exit_code INTEGER,
1280
+ output TEXT,
1281
+ started_at TEXT,
1282
+ finished_at TEXT
1283
+ );
1284
+ `)
1285
+ }
1286
+ if (v === 6) {
1287
+ db.exec(`
1288
+ ALTER TABLE task ADD COLUMN drift_score REAL;
1289
+ ALTER TABLE task ADD COLUMN drift_retried INTEGER NOT NULL DEFAULT 0;
1290
+ `)
1291
+ }
1292
+ if (v === 7) {
1293
+ db.exec(`
1294
+ ALTER TABLE task ADD COLUMN outputs TEXT;
1295
+ ALTER TABLE task ADD COLUMN inputs TEXT;
1296
+ ALTER TABLE task ADD COLUMN discovered_issues TEXT;
1297
+ CREATE TABLE artifact (
1298
+ id TEXT PRIMARY KEY,
1299
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
1300
+ task_id TEXT NOT NULL REFERENCES task(id),
1301
+ name TEXT NOT NULL,
1302
+ type TEXT NOT NULL,
1303
+ content TEXT NOT NULL CHECK (length(content) <= 1048576),
1304
+ created_at TEXT NOT NULL,
1305
+ UNIQUE(convoy_id, name)
1306
+ );
1307
+ CREATE TABLE agent_identity (
1308
+ id TEXT PRIMARY KEY,
1309
+ agent TEXT NOT NULL,
1310
+ convoy_id TEXT NOT NULL,
1311
+ task_id TEXT NOT NULL,
1312
+ summary TEXT NOT NULL,
1313
+ created_at TEXT NOT NULL,
1314
+ retention_days INTEGER NOT NULL DEFAULT 90
1315
+ );
1316
+ `)
1317
+ }
1318
+ if (v === 8) {
1319
+ db.exec(`
1320
+ CREATE TABLE scratchpad (
1321
+ key TEXT PRIMARY KEY,
1322
+ value TEXT NOT NULL,
1323
+ updated_at TEXT NOT NULL
1324
+ );
1325
+ `)
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
+ }
1337
+ db.exec('COMMIT')
1338
+ } catch (err) {
1339
+ try { db.exec('ROLLBACK') } catch { /* ignore */ }
1340
+ throw new Error(`Migration v${v}→v${v + 1} failed. Backup at ${backupPath}. Original error: ${(err as Error).message}`)
1341
+ }
1342
+ db.exec(`PRAGMA user_version = ${v + 1}`)
1343
+ }
1344
+ }
1345
+
447
1346
  export function createConvoyStore(dbPath: string): ConvoyStore {
448
1347
  return new ConvoyStoreImpl(dbPath)
449
1348
  }