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,8 +1,48 @@
1
+ import { copyFileSync } from 'node:fs';
1
2
  import { DatabaseSync } from 'node:sqlite';
2
- const SCHEMA_VERSION = 4;
3
+ const SCHEMA_VERSION = 10;
4
+ // ── Size limits (bytes) ────────────────────────────────────────────────────────
5
+ const LIMIT_SPEC_YAML = 256 * 1024; // 256 KB
6
+ const LIMIT_OUTPUT = 1024 * 1024; // 1 MB (head 512KB + tail 512KB)
7
+ const LIMIT_OUTPUT_HALF = 512 * 1024; // 512 KB per half
8
+ const LIMIT_EVENT_DATA = 64 * 1024; // 64 KB
9
+ const LIMIT_SUMMARY = 4096; // 4 KB
10
+ export class FieldSizeLimitError extends Error {
11
+ constructor(field, actual, limit) {
12
+ super(`Field "${field}" exceeds size limit: ${actual} bytes > ${limit} bytes`);
13
+ this.name = 'FieldSizeLimitError';
14
+ }
15
+ }
16
+ function enforceLimit(value, field, limit) {
17
+ if (value == null)
18
+ return;
19
+ const size = Buffer.byteLength(value, 'utf8');
20
+ if (size > limit) {
21
+ throw new FieldSizeLimitError(field, size, limit);
22
+ }
23
+ }
24
+ function truncateOutput(value) {
25
+ if (value == null)
26
+ return null;
27
+ const size = Buffer.byteLength(value, 'utf8');
28
+ if (size <= LIMIT_OUTPUT)
29
+ return value;
30
+ // Head + tail truncation with marker
31
+ const head = value.slice(0, LIMIT_OUTPUT_HALF);
32
+ const tail = value.slice(-LIMIT_OUTPUT_HALF);
33
+ return head + '\n\n... [truncated: ' + size + ' bytes total, showing first/last 512KB] ...\n\n' + tail;
34
+ }
35
+ export class ConvoyArtifactLimitError extends Error {
36
+ constructor(convoyId) {
37
+ super(`Convoy ${convoyId} has reached the maximum of 50 artifacts`);
38
+ this.name = 'ConvoyArtifactLimitError';
39
+ }
40
+ }
3
41
  class ConvoyStoreImpl {
4
42
  db;
43
+ dbPath;
5
44
  constructor(dbPath) {
45
+ this.dbPath = dbPath;
6
46
  this.db = new DatabaseSync(dbPath);
7
47
  this.db.exec('PRAGMA journal_mode = WAL');
8
48
  this.db.exec('PRAGMA synchronous = NORMAL');
@@ -13,18 +53,22 @@ class ConvoyStoreImpl {
13
53
  if (version === 0) {
14
54
  this.db.exec(`
15
55
  CREATE TABLE IF NOT EXISTS convoy (
16
- id TEXT PRIMARY KEY,
17
- name TEXT NOT NULL,
18
- spec_hash TEXT NOT NULL,
19
- status TEXT NOT NULL DEFAULT 'pending',
20
- branch TEXT,
21
- created_at TEXT NOT NULL,
22
- started_at TEXT,
23
- finished_at TEXT,
24
- spec_yaml TEXT NOT NULL,
25
- total_tokens INTEGER,
26
- total_cost_usd TEXT,
27
- pipeline_id TEXT
56
+ id TEXT PRIMARY KEY,
57
+ name TEXT NOT NULL,
58
+ spec_hash TEXT NOT NULL,
59
+ status TEXT NOT NULL DEFAULT 'pending',
60
+ branch TEXT,
61
+ created_at TEXT NOT NULL,
62
+ started_at TEXT,
63
+ finished_at TEXT,
64
+ spec_yaml TEXT NOT NULL,
65
+ total_tokens INTEGER,
66
+ total_cost_usd TEXT,
67
+ total_cost_usd_num REAL,
68
+ pipeline_id TEXT,
69
+ circuit_state TEXT,
70
+ review_tokens_total INTEGER,
71
+ review_budget INTEGER
28
72
  );
29
73
 
30
74
  CREATE TABLE IF NOT EXISTS pipeline (
@@ -38,7 +82,8 @@ class ConvoyStoreImpl {
38
82
  started_at TEXT,
39
83
  finished_at TEXT,
40
84
  total_tokens INTEGER,
41
- total_cost_usd TEXT
85
+ total_cost_usd TEXT,
86
+ total_cost_usd_num REAL
42
87
  );
43
88
 
44
89
  CREATE TABLE IF NOT EXISTS task (
@@ -64,7 +109,42 @@ class ConvoyStoreImpl {
64
109
  prompt_tokens INTEGER,
65
110
  completion_tokens INTEGER,
66
111
  total_tokens INTEGER,
67
- cost_usd TEXT
112
+ cost_usd TEXT,
113
+ cost_usd_num REAL,
114
+ gates TEXT,
115
+ on_exhausted TEXT NOT NULL DEFAULT 'dlq',
116
+ injected INTEGER NOT NULL DEFAULT 0,
117
+ provenance TEXT,
118
+ idempotency_key TEXT,
119
+ current_step INTEGER,
120
+ total_steps INTEGER,
121
+ review_level TEXT,
122
+ review_verdict TEXT,
123
+ review_tokens INTEGER,
124
+ review_model TEXT,
125
+ panel_attempts INTEGER NOT NULL DEFAULT 0,
126
+ dispute_id TEXT,
127
+ drift_score REAL,
128
+ drift_retried INTEGER NOT NULL DEFAULT 0,
129
+ outputs TEXT,
130
+ inputs TEXT,
131
+ discovered_issues TEXT
132
+ );
133
+
134
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_task_idempotency ON task(convoy_id, idempotency_key)
135
+ WHERE idempotency_key IS NOT NULL;
136
+
137
+ CREATE TABLE IF NOT EXISTS task_step (
138
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ task_id TEXT NOT NULL REFERENCES task(id),
140
+ step_index INTEGER NOT NULL,
141
+ prompt TEXT NOT NULL,
142
+ gates TEXT,
143
+ status TEXT NOT NULL DEFAULT 'pending',
144
+ exit_code INTEGER,
145
+ output TEXT,
146
+ started_at TEXT,
147
+ finished_at TEXT
68
148
  );
69
149
 
70
150
  CREATE TABLE IF NOT EXISTS worker (
@@ -89,6 +169,57 @@ class ConvoyStoreImpl {
89
169
  data TEXT,
90
170
  created_at TEXT NOT NULL
91
171
  );
172
+
173
+ CREATE TABLE IF NOT EXISTS dlq (
174
+ id TEXT PRIMARY KEY,
175
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
176
+ task_id TEXT NOT NULL REFERENCES task(id),
177
+ agent TEXT NOT NULL,
178
+ failure_type TEXT NOT NULL,
179
+ error_output TEXT,
180
+ attempts INTEGER NOT NULL,
181
+ tokens_spent INTEGER,
182
+ escalation_task_id TEXT,
183
+ resolved INTEGER NOT NULL DEFAULT 0,
184
+ resolution TEXT,
185
+ created_at TEXT NOT NULL,
186
+ resolved_at TEXT
187
+ );
188
+
189
+ CREATE TABLE IF NOT EXISTS artifact (
190
+ id TEXT PRIMARY KEY,
191
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
192
+ task_id TEXT NOT NULL REFERENCES task(id),
193
+ name TEXT NOT NULL,
194
+ type TEXT NOT NULL,
195
+ content TEXT NOT NULL CHECK (length(content) <= 1048576),
196
+ created_at TEXT NOT NULL,
197
+ UNIQUE(convoy_id, name)
198
+ );
199
+
200
+ CREATE TABLE IF NOT EXISTS agent_identity (
201
+ id TEXT PRIMARY KEY,
202
+ agent TEXT NOT NULL,
203
+ convoy_id TEXT NOT NULL,
204
+ task_id TEXT NOT NULL,
205
+ summary TEXT NOT NULL,
206
+ created_at TEXT NOT NULL,
207
+ retention_days INTEGER NOT NULL DEFAULT 90
208
+ );
209
+
210
+ CREATE TABLE IF NOT EXISTS scratchpad (
211
+ key TEXT PRIMARY KEY,
212
+ value TEXT NOT NULL,
213
+ updated_at TEXT NOT NULL
214
+ );
215
+
216
+ CREATE TABLE IF NOT EXISTS engine_lock (
217
+ id INTEGER PRIMARY KEY,
218
+ pid INTEGER NOT NULL,
219
+ hostname TEXT NOT NULL,
220
+ started_at TEXT NOT NULL,
221
+ last_heartbeat TEXT NOT NULL
222
+ );
92
223
  `);
93
224
  this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
94
225
  version = SCHEMA_VERSION;
@@ -128,21 +259,50 @@ class ConvoyStoreImpl {
128
259
  this.db.exec('PRAGMA user_version = 4');
129
260
  version = 4;
130
261
  }
262
+ if (version === 4) {
263
+ migrateSchema(this.db, this.dbPath, 4, 5);
264
+ version = 5;
265
+ }
266
+ if (version === 5) {
267
+ migrateSchema(this.db, this.dbPath, 5, 6);
268
+ version = 6;
269
+ }
270
+ if (version === 6) {
271
+ migrateSchema(this.db, this.dbPath, 6, 7);
272
+ version = 7;
273
+ }
274
+ if (version === 7) {
275
+ migrateSchema(this.db, this.dbPath, 7, 8);
276
+ version = 8;
277
+ }
278
+ if (version === 8) {
279
+ migrateSchema(this.db, this.dbPath, 8, 9);
280
+ version = 9;
281
+ }
282
+ if (version === 9) {
283
+ migrateSchema(this.db, this.dbPath, 9, 10);
284
+ version = 10;
285
+ }
131
286
  }
132
287
  insertConvoy(record) {
288
+ enforceLimit(record.spec_yaml, 'spec_yaml', LIMIT_SPEC_YAML);
133
289
  this.db
134
- .prepare(`INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, started_at, finished_at, spec_yaml, pipeline_id)
135
- VALUES (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL, :spec_yaml, :pipeline_id)`)
290
+ .prepare(`INSERT INTO convoy
291
+ (id, name, spec_hash, status, branch, created_at, started_at, finished_at,
292
+ spec_yaml, pipeline_id)
293
+ VALUES
294
+ (:id, :name, :spec_hash, :status, :branch, :created_at, NULL, NULL,
295
+ :spec_yaml, :pipeline_id)`)
136
296
  .run({ ...record, pipeline_id: record.pipeline_id ?? null });
137
297
  }
138
298
  getConvoy(id) {
139
299
  return this.db
140
- .prepare('SELECT * FROM convoy WHERE id = :id')
300
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM convoy WHERE id = :id')
141
301
  .get({ id });
142
302
  }
143
303
  getLatestConvoy() {
144
304
  return this.db
145
- .prepare('SELECT * FROM convoy ORDER BY created_at DESC LIMIT 1')
305
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM convoy ORDER BY created_at DESC LIMIT 1')
146
306
  .get();
147
307
  }
148
308
  updateConvoyStatus(id, status, extra) {
@@ -161,37 +321,101 @@ class ConvoyStoreImpl {
161
321
  params.total_tokens = extra.total_tokens;
162
322
  }
163
323
  if (extra?.total_cost_usd !== undefined) {
164
- sets.push('total_cost_usd = :total_cost_usd');
165
- params.total_cost_usd = extra.total_cost_usd;
324
+ sets.push('total_cost_usd = :total_cost_usd_text');
325
+ sets.push('total_cost_usd_num = :total_cost_usd_num');
326
+ params.total_cost_usd_text = extra.total_cost_usd !== null ? String(extra.total_cost_usd) : null;
327
+ params.total_cost_usd_num = extra.total_cost_usd;
166
328
  }
167
329
  this.db.prepare(`UPDATE convoy SET ${sets.join(', ')} WHERE id = :id`).run(params);
168
330
  }
331
+ updateConvoyReviewTokens(convoyId, tokens) {
332
+ this.db
333
+ .prepare(`UPDATE convoy
334
+ SET review_tokens_total = :tokens
335
+ WHERE id = :id`)
336
+ .run({ id: convoyId, tokens });
337
+ }
338
+ updateConvoyCircuitState(convoyId, state) {
339
+ this.db
340
+ .prepare('UPDATE convoy SET circuit_state = :state WHERE id = :id')
341
+ .run({ id: convoyId, state: state ?? null });
342
+ }
169
343
  insertTask(record) {
170
344
  this.db
171
345
  .prepare(`INSERT INTO task
172
346
  (id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms, status,
173
347
  worker_id, worktree, output, exit_code, started_at, finished_at,
174
- retries, max_retries, files, depends_on)
348
+ retries, max_retries, files, depends_on, gates,
349
+ on_exhausted, injected, provenance, idempotency_key,
350
+ outputs, inputs)
175
351
  VALUES
176
352
  (:id, :convoy_id, :phase, :prompt, :agent, :adapter, :model, :timeout_ms, :status,
177
353
  NULL, NULL, NULL, NULL, NULL, NULL,
178
- :retries, :max_retries, :files, :depends_on)`)
354
+ :retries, :max_retries, :files, :depends_on, :gates,
355
+ 'dlq', 0, NULL, NULL,
356
+ :outputs, :inputs)`)
357
+ .run({ ...record, outputs: record.outputs ?? null, inputs: record.inputs ?? null });
358
+ }
359
+ insertInjectedTask(record) {
360
+ this.db
361
+ .prepare(`INSERT INTO task
362
+ (id, convoy_id, phase, prompt, agent, adapter, model, timeout_ms, status,
363
+ worker_id, worktree, output, exit_code, started_at, finished_at,
364
+ retries, max_retries, files, depends_on, gates,
365
+ on_exhausted, injected, provenance, idempotency_key,
366
+ current_step, total_steps, review_level, review_verdict,
367
+ review_tokens, review_model, panel_attempts, dispute_id,
368
+ drift_score, drift_retried, outputs, inputs, discovered_issues)
369
+ VALUES
370
+ (:id, :convoy_id, :phase, :prompt, :agent, :adapter, :model, :timeout_ms, :status,
371
+ :worker_id, :worktree, :output, :exit_code, :started_at, :finished_at,
372
+ :retries, :max_retries, :files, :depends_on, :gates,
373
+ :on_exhausted, :injected, :provenance, :idempotency_key,
374
+ :current_step, :total_steps, :review_level, :review_verdict,
375
+ :review_tokens, :review_model, :panel_attempts, :dispute_id,
376
+ :drift_score, :drift_retried, :outputs, :inputs, :discovered_issues)`)
179
377
  .run(record);
180
378
  }
181
379
  getTask(id, convoyId) {
182
380
  return this.db
183
- .prepare('SELECT * FROM task WHERE id = :id AND convoy_id = :convoy_id')
381
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE id = :id AND convoy_id = :convoy_id')
184
382
  .get({ id, convoy_id: convoyId });
185
383
  }
186
384
  getTasksByConvoy(convoyId) {
187
385
  return this.db
188
- .prepare('SELECT * FROM task WHERE convoy_id = :convoy_id ORDER BY phase, id')
386
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE convoy_id = :convoy_id ORDER BY phase, id')
189
387
  .all({ convoy_id: convoyId });
190
388
  }
389
+ getTaskByIdempotencyKey(convoyId, key) {
390
+ return this.db
391
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE convoy_id = :convoy_id AND idempotency_key = :key')
392
+ .get({ convoy_id: convoyId, key });
393
+ }
394
+ getTaskByDisputeId(disputeId) {
395
+ return this.db
396
+ .prepare('SELECT *, cost_usd_num AS cost_usd FROM task WHERE dispute_id = :dispute_id LIMIT 1')
397
+ .get({ dispute_id: disputeId });
398
+ }
399
+ getDisputedTasks(convoyId) {
400
+ if (convoyId) {
401
+ return this.db
402
+ .prepare("SELECT *, cost_usd_num AS cost_usd FROM task WHERE status = 'disputed' AND convoy_id = :convoy_id ORDER BY phase, id")
403
+ .all({ convoy_id: convoyId });
404
+ }
405
+ return this.db
406
+ .prepare("SELECT *, cost_usd_num AS cost_usd FROM task WHERE status = 'disputed' ORDER BY convoy_id, phase, id")
407
+ .all({});
408
+ }
191
409
  updateTaskStatus(id, convoyId, status, extra) {
410
+ if (extra?.output !== undefined) {
411
+ extra = { ...extra, output: truncateOutput(extra.output) };
412
+ }
192
413
  const sets = ['status = :status'];
193
414
  const params = { id, convoy_id: convoyId, status };
194
- const extraFields = ['worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at', 'retries', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd'];
415
+ const extraFields = [
416
+ 'worker_id', 'worktree', 'output', 'exit_code', 'started_at', 'finished_at',
417
+ 'retries', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd', 'prompt',
418
+ ];
195
419
  if (extra) {
196
420
  for (const field of extraFields) {
197
421
  if (field in extra && extra[field] !== undefined) {
@@ -199,6 +423,10 @@ class ConvoyStoreImpl {
199
423
  params[field] = extra[field];
200
424
  }
201
425
  }
426
+ if ('cost_usd' in extra && extra.cost_usd !== undefined) {
427
+ sets.push('cost_usd_num = :cost_usd_num');
428
+ params.cost_usd_num = extra.cost_usd;
429
+ }
202
430
  }
203
431
  this.db
204
432
  .prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`)
@@ -216,6 +444,65 @@ class ConvoyStoreImpl {
216
444
  return deps.length === 0 || deps.every(depId => doneTaskIds.has(depId));
217
445
  });
218
446
  }
447
+ insertTaskStep(record) {
448
+ this.db
449
+ .prepare(`INSERT INTO task_step
450
+ (task_id, step_index, prompt, gates, status, exit_code, output, started_at, finished_at)
451
+ VALUES
452
+ (:task_id, :step_index, :prompt, :gates, :status, :exit_code, :output, :started_at, :finished_at)`)
453
+ .run(record);
454
+ const row = this.db.prepare('SELECT last_insert_rowid() AS id').get();
455
+ return row.id;
456
+ }
457
+ updateTaskStep(id, fields) {
458
+ const sets = [];
459
+ const params = { id };
460
+ const stepFields = ['status', 'exit_code', 'output', 'started_at', 'finished_at'];
461
+ for (const field of stepFields) {
462
+ if (field in fields && fields[field] !== undefined) {
463
+ sets.push(`${field} = :${field}`);
464
+ params[field] = fields[field];
465
+ }
466
+ }
467
+ if (sets.length === 0)
468
+ return;
469
+ this.db.prepare(`UPDATE task_step SET ${sets.join(', ')} WHERE id = :id`).run(params);
470
+ }
471
+ updateTaskReview(taskId, convoyId, fields) {
472
+ const sets = [];
473
+ const params = { id: taskId, convoy_id: convoyId };
474
+ const reviewFields = ['review_level', 'review_verdict', 'review_tokens', 'review_model', 'panel_attempts', 'dispute_id'];
475
+ for (const field of reviewFields) {
476
+ if (field in fields && fields[field] !== undefined) {
477
+ sets.push(`${field} = :${field}`);
478
+ params[field] = fields[field];
479
+ }
480
+ }
481
+ if (sets.length === 0)
482
+ return;
483
+ this.db.prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`).run(params);
484
+ }
485
+ updateTaskDrift(taskId, convoyId, fields) {
486
+ const sets = [];
487
+ const params = { id: taskId, convoy_id: convoyId };
488
+ if (fields.drift_score !== undefined) {
489
+ sets.push('drift_score = :drift_score');
490
+ params.drift_score = fields.drift_score;
491
+ }
492
+ if (fields.drift_retried !== undefined) {
493
+ sets.push('drift_retried = :drift_retried');
494
+ params.drift_retried = fields.drift_retried;
495
+ }
496
+ if (sets.length === 0)
497
+ return;
498
+ this.db.prepare(`UPDATE task SET ${sets.join(', ')} WHERE id = :id AND convoy_id = :convoy_id`).run(params);
499
+ }
500
+ updateTaskDisputeStatus(taskId, convoyId, status, disputeId) {
501
+ this.db
502
+ .prepare(`UPDATE task SET status = :status, dispute_id = :dispute_id
503
+ WHERE id = :id AND convoy_id = :convoy_id`)
504
+ .run({ id: taskId, convoy_id: convoyId, status, dispute_id: disputeId });
505
+ }
219
506
  insertWorker(record) {
220
507
  this.db
221
508
  .prepare(`INSERT INTO worker
@@ -249,17 +536,142 @@ class ConvoyStoreImpl {
249
536
  this.db.prepare(`UPDATE worker SET ${sets.join(', ')} WHERE id = :id`).run(params);
250
537
  }
251
538
  insertEvent(record) {
539
+ enforceLimit(record.data, 'event.data', LIMIT_EVENT_DATA);
252
540
  this.db
253
541
  .prepare(`INSERT INTO event (convoy_id, task_id, worker_id, type, data, created_at)
254
542
  VALUES (:convoy_id, :task_id, :worker_id, :type, :data, :created_at)`)
255
543
  .run(record);
544
+ const row = this.db.prepare('SELECT last_insert_rowid() AS id').get();
545
+ return row.id;
256
546
  }
257
547
  getEvents(convoyId) {
258
548
  return this.db
259
549
  .prepare('SELECT * FROM event WHERE convoy_id = :convoy_id ORDER BY id')
260
550
  .all({ convoy_id: convoyId });
261
551
  }
552
+ insertDlqEntry(record) {
553
+ this.db
554
+ .prepare(`INSERT INTO dlq
555
+ (id, convoy_id, task_id, agent, failure_type, error_output, attempts,
556
+ tokens_spent, escalation_task_id, resolved, resolution, created_at, resolved_at)
557
+ VALUES
558
+ (:id, :convoy_id, :task_id, :agent, :failure_type, :error_output, :attempts,
559
+ :tokens_spent, :escalation_task_id, :resolved, :resolution, :created_at, :resolved_at)`)
560
+ .run(record);
561
+ }
562
+ listDlqEntries(convoyIdFilter) {
563
+ if (convoyIdFilter) {
564
+ return this.db
565
+ .prepare('SELECT * FROM dlq WHERE convoy_id = :convoy_id ORDER BY created_at DESC')
566
+ .all({ convoy_id: convoyIdFilter });
567
+ }
568
+ return this.db
569
+ .prepare('SELECT * FROM dlq ORDER BY created_at DESC')
570
+ .all();
571
+ }
572
+ resolveDlqEntry(id, resolution) {
573
+ this.db
574
+ .prepare(`UPDATE dlq SET resolved = 1, resolution = :resolution, resolved_at = :resolved_at
575
+ WHERE id = :id`)
576
+ .run({ id, resolution, resolved_at: new Date().toISOString() });
577
+ }
578
+ insertArtifact(record) {
579
+ const count = this.db
580
+ .prepare('SELECT COUNT(*) AS cnt FROM artifact WHERE convoy_id = :convoy_id')
581
+ .get({ convoy_id: record.convoy_id }).cnt;
582
+ if (count >= 50) {
583
+ throw new ConvoyArtifactLimitError(record.convoy_id);
584
+ }
585
+ this.db
586
+ .prepare(`INSERT INTO artifact (id, convoy_id, task_id, name, type, content, created_at)
587
+ VALUES (:id, :convoy_id, :task_id, :name, :type, :content, :created_at)`)
588
+ .run(record);
589
+ }
590
+ getArtifact(convoyId, name) {
591
+ return this.db
592
+ .prepare('SELECT * FROM artifact WHERE convoy_id = :convoy_id AND name = :name')
593
+ .get({ convoy_id: convoyId, name });
594
+ }
595
+ getArtifactsByTask(taskId) {
596
+ return this.db
597
+ .prepare('SELECT * FROM artifact WHERE task_id = :task_id ORDER BY created_at')
598
+ .all({ task_id: taskId });
599
+ }
600
+ getArtifactsByConvoy(convoyId) {
601
+ return this.db
602
+ .prepare('SELECT * FROM artifact WHERE convoy_id = :convoy_id ORDER BY created_at')
603
+ .all({ convoy_id: convoyId });
604
+ }
605
+ deleteArtifactsOlderThan(days) {
606
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
607
+ const result = this.db
608
+ .prepare(`DELETE FROM artifact WHERE convoy_id IN (
609
+ SELECT id FROM convoy WHERE finished_at IS NOT NULL AND finished_at < :cutoff
610
+ )`)
611
+ .run({ cutoff });
612
+ return result.changes;
613
+ }
614
+ insertAgentIdentity(record) {
615
+ const summarySize = Buffer.byteLength(record.summary, 'utf8');
616
+ const truncatedSummary = summarySize > LIMIT_SUMMARY
617
+ ? record.summary.slice(0, LIMIT_SUMMARY)
618
+ : record.summary;
619
+ this.db
620
+ .prepare(`INSERT INTO agent_identity
621
+ (id, agent, convoy_id, task_id, summary, created_at, retention_days)
622
+ VALUES
623
+ (:id, :agent, :convoy_id, :task_id, :summary, :created_at, :retention_days)`)
624
+ .run({ ...record, summary: truncatedSummary });
625
+ }
626
+ getAgentIdentities(agent, limit) {
627
+ return this.db
628
+ .prepare('SELECT * FROM agent_identity WHERE agent = :agent ORDER BY created_at DESC LIMIT :limit')
629
+ .all({ agent, limit });
630
+ }
631
+ listAgentIdentitySummary() {
632
+ return this.db
633
+ .prepare(`SELECT agent, COUNT(*) AS task_count, MAX(created_at) AS latest_date
634
+ FROM agent_identity GROUP BY agent ORDER BY agent`)
635
+ .all();
636
+ }
637
+ purgeAgentIdentities(agent) {
638
+ const result = this.db
639
+ .prepare('DELETE FROM agent_identity WHERE agent = :agent')
640
+ .run({ agent });
641
+ return result.changes;
642
+ }
643
+ deleteAgentIdentitiesOlderThan(days) {
644
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
645
+ const result = this.db
646
+ .prepare(`DELETE FROM agent_identity
647
+ WHERE created_at < :cutoff
648
+ OR (retention_days IS NOT NULL
649
+ AND created_at < datetime('now', '-' || retention_days || ' days'))`)
650
+ .run({ cutoff });
651
+ return result.changes;
652
+ }
653
+ getScratchpadValue(key) {
654
+ const row = this.db
655
+ .prepare('SELECT value FROM scratchpad WHERE key = :key')
656
+ .get({ key });
657
+ return row?.value ?? null;
658
+ }
659
+ setScratchpadValue(key, value) {
660
+ this.db
661
+ .prepare(`INSERT INTO scratchpad (key, value, updated_at)
662
+ VALUES (:key, :value, :updated_at)
663
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
664
+ .run({ key, value, updated_at: new Date().toISOString() });
665
+ }
666
+ clearScratchpad() {
667
+ this.db.exec('DELETE FROM scratchpad');
668
+ }
669
+ clearScratchpadOlderThan(days) {
670
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
671
+ this.db.prepare('DELETE FROM scratchpad WHERE updated_at < :cutoff').run({ cutoff });
672
+ }
262
673
  insertPipeline(record) {
674
+ enforceLimit(record.spec_yaml, 'pipeline.spec_yaml', LIMIT_SPEC_YAML);
263
675
  this.db
264
676
  .prepare(`INSERT INTO pipeline (id, name, status, branch, spec_yaml, convoy_specs, created_at,
265
677
  started_at, finished_at, total_tokens, total_cost_usd)
@@ -269,12 +681,12 @@ class ConvoyStoreImpl {
269
681
  }
270
682
  getPipeline(id) {
271
683
  return this.db
272
- .prepare('SELECT * FROM pipeline WHERE id = :id')
684
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM pipeline WHERE id = :id')
273
685
  .get({ id });
274
686
  }
275
687
  getLatestPipeline() {
276
688
  return this.db
277
- .prepare('SELECT * FROM pipeline ORDER BY created_at DESC LIMIT 1')
689
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM pipeline ORDER BY created_at DESC LIMIT 1')
278
690
  .get();
279
691
  }
280
692
  updatePipelineStatus(id, status, extra) {
@@ -293,16 +705,215 @@ class ConvoyStoreImpl {
293
705
  params.total_tokens = extra.total_tokens;
294
706
  }
295
707
  if (extra?.total_cost_usd !== undefined) {
296
- sets.push('total_cost_usd = :total_cost_usd');
297
- params.total_cost_usd = extra.total_cost_usd;
708
+ sets.push('total_cost_usd = :total_cost_usd_text');
709
+ sets.push('total_cost_usd_num = :total_cost_usd_num');
710
+ params.total_cost_usd_text = extra.total_cost_usd !== null ? String(extra.total_cost_usd) : null;
711
+ params.total_cost_usd_num = extra.total_cost_usd;
298
712
  }
299
713
  this.db.prepare(`UPDATE pipeline SET ${sets.join(', ')} WHERE id = :id`).run(params);
300
714
  }
301
715
  getConvoysByPipeline(pipelineId) {
302
716
  return this.db
303
- .prepare('SELECT * FROM convoy WHERE pipeline_id = :pipeline_id ORDER BY created_at')
717
+ .prepare('SELECT *, total_cost_usd_num AS total_cost_usd FROM convoy WHERE pipeline_id = :pipeline_id ORDER BY created_at')
304
718
  .all({ pipeline_id: pipelineId });
305
719
  }
720
+ getConvoyCounts() {
721
+ const rows = this.db
722
+ .prepare('SELECT status, COUNT(*) AS cnt FROM convoy GROUP BY status')
723
+ .all();
724
+ const map = {};
725
+ for (const row of rows)
726
+ map[row.status] = row.cnt;
727
+ return {
728
+ total: rows.reduce((s, r) => s + r.cnt, 0),
729
+ running: map['running'] ?? 0,
730
+ done: map['done'] ?? 0,
731
+ failed: (map['failed'] ?? 0),
732
+ gate_failed: (map['gate-failed'] ?? 0) + (map['hook-failed'] ?? 0),
733
+ };
734
+ }
735
+ getConvoyDurationStats() {
736
+ const statsRow = this.db.prepare(`SELECT
737
+ AVG((julianday(finished_at) - julianday(started_at)) * 86400) AS avg_sec,
738
+ MAX((julianday(finished_at) - julianday(started_at)) * 86400) AS max_sec,
739
+ COUNT(*) AS cnt
740
+ FROM convoy
741
+ WHERE finished_at IS NOT NULL AND started_at IS NOT NULL`).get();
742
+ if (!statsRow || statsRow.cnt === 0)
743
+ return { avg_sec: null, p95_sec: null, max_sec: null };
744
+ const offset = Math.max(0, Math.floor(statsRow.cnt * 0.95) - 1);
745
+ const p95Row = this.db.prepare(`SELECT (julianday(finished_at) - julianday(started_at)) * 86400 AS duration
746
+ FROM convoy
747
+ WHERE finished_at IS NOT NULL AND started_at IS NOT NULL
748
+ ORDER BY duration
749
+ LIMIT 1 OFFSET :offset`).get({ offset });
750
+ return {
751
+ avg_sec: statsRow.avg_sec,
752
+ p95_sec: p95Row?.duration ?? null,
753
+ max_sec: statsRow.max_sec,
754
+ };
755
+ }
756
+ getTokenAndCostTotals() {
757
+ const row = this.db.prepare(`SELECT COALESCE(SUM(total_tokens), 0) AS total_tokens,
758
+ COALESCE(SUM(total_cost_usd_num), 0) AS total_cost_usd
759
+ FROM convoy`).get();
760
+ return { total_tokens: row.total_tokens, total_cost_usd: row.total_cost_usd };
761
+ }
762
+ getTopAgents(limit) {
763
+ return this.db.prepare(`SELECT agent,
764
+ COUNT(*) AS task_count,
765
+ COALESCE(SUM(total_tokens), 0) AS total_tokens
766
+ FROM task
767
+ GROUP BY agent
768
+ ORDER BY task_count DESC
769
+ LIMIT :limit`).all({ limit });
770
+ }
771
+ getTopModels(limit) {
772
+ return this.db.prepare(`SELECT model,
773
+ COUNT(*) AS task_count,
774
+ COALESCE(SUM(total_tokens), 0) AS total_tokens
775
+ FROM task
776
+ WHERE model IS NOT NULL
777
+ GROUP BY model
778
+ ORDER BY task_count DESC
779
+ LIMIT :limit`).all({ limit });
780
+ }
781
+ getDlqSummary() {
782
+ const countRow = this.db.prepare('SELECT COUNT(*) AS cnt FROM dlq').get();
783
+ const typeRows = this.db.prepare(`SELECT failure_type AS type, COUNT(*) AS count
784
+ FROM dlq
785
+ GROUP BY failure_type
786
+ ORDER BY count DESC`).all();
787
+ return { count: countRow.cnt, top_failure_types: typeRows };
788
+ }
789
+ getConvoyTaskSummary(convoyId) {
790
+ const row = this.db.prepare(`SELECT
791
+ COUNT(*) AS total,
792
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done,
793
+ SUM(CASE WHEN status IN ('running', 'assigned') THEN 1 ELSE 0 END) AS running,
794
+ SUM(CASE WHEN status IN ('failed', 'gate-failed', 'timed-out', 'hook-failed') THEN 1 ELSE 0 END) AS failed,
795
+ SUM(CASE WHEN status = 'review-blocked' THEN 1 ELSE 0 END) AS review_blocked,
796
+ SUM(CASE WHEN status = 'disputed' THEN 1 ELSE 0 END) AS disputed,
797
+ SUM(CASE WHEN review_verdict IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
798
+ SUM(CASE WHEN panel_attempts > 0 THEN 1 ELSE 0 END) AS panel_reviewed,
799
+ SUM(CASE WHEN drift_score IS NOT NULL THEN 1 ELSE 0 END) AS tasks_with_drift,
800
+ MAX(drift_score) AS max_drift_score,
801
+ SUM(CASE WHEN drift_retried = 1 THEN 1 ELSE 0 END) AS drift_retried
802
+ FROM task
803
+ WHERE convoy_id = :convoy_id`).get({ convoy_id: convoyId });
804
+ if (!row || row.total === 0) {
805
+ 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 };
806
+ }
807
+ return {
808
+ total: row.total ?? 0,
809
+ done: row.done ?? 0,
810
+ running: row.running ?? 0,
811
+ failed: row.failed ?? 0,
812
+ review_blocked: row.review_blocked ?? 0,
813
+ disputed: row.disputed ?? 0,
814
+ reviewed: row.reviewed ?? 0,
815
+ panel_reviewed: row.panel_reviewed ?? 0,
816
+ tasks_with_drift: row.tasks_with_drift ?? 0,
817
+ max_drift_score: row.max_drift_score ?? null,
818
+ drift_retried: row.drift_retried ?? 0,
819
+ };
820
+ }
821
+ getConvoyList(limit, offset) {
822
+ return this.db.prepare(`SELECT *, total_cost_usd_num AS total_cost_usd
823
+ FROM convoy
824
+ ORDER BY created_at DESC
825
+ LIMIT :limit OFFSET :offset`).all({ limit, offset });
826
+ }
827
+ getConvoyDetails(convoyId) {
828
+ const convoy = this.getConvoy(convoyId);
829
+ if (!convoy)
830
+ return null;
831
+ const tasks = this.getTasksByConvoy(convoyId);
832
+ const taskSummary = this.getConvoyTaskSummary(convoyId);
833
+ const dlqEntries = this.listDlqEntries(convoyId);
834
+ const rawEvents = this.getEvents(convoyId);
835
+ const artifacts = this.getArtifactsByConvoy(convoyId);
836
+ const limitedEvents = rawEvents.slice().reverse().slice(0, 500);
837
+ return {
838
+ convoy: {
839
+ id: convoy.id,
840
+ name: convoy.name,
841
+ status: convoy.status,
842
+ created_at: convoy.created_at,
843
+ finished_at: convoy.finished_at,
844
+ branch: convoy.branch,
845
+ total_tokens: convoy.total_tokens,
846
+ total_cost_usd: convoy.total_cost_usd,
847
+ },
848
+ taskSummary,
849
+ quality: {
850
+ reviewed_tasks: taskSummary.reviewed,
851
+ review_blocked_tasks: taskSummary.review_blocked,
852
+ disputed_tasks: taskSummary.disputed,
853
+ panel_reviews: taskSummary.panel_reviewed,
854
+ },
855
+ drift: {
856
+ tasks_with_drift: taskSummary.tasks_with_drift,
857
+ max_drift_score: taskSummary.max_drift_score,
858
+ drift_retried_tasks: taskSummary.drift_retried,
859
+ },
860
+ dlq_count: dlqEntries.length,
861
+ dlq_entries: dlqEntries.map(d => ({
862
+ id: d.id,
863
+ task_id: d.task_id,
864
+ agent: d.agent,
865
+ failure_type: d.failure_type,
866
+ attempts: d.attempts,
867
+ resolved: d.resolved,
868
+ })),
869
+ artifact_count: artifacts.length,
870
+ artifacts: artifacts.map(a => ({
871
+ id: a.id,
872
+ name: a.name,
873
+ type: a.type,
874
+ task_id: a.task_id,
875
+ created_at: a.created_at,
876
+ })),
877
+ has_more_events: rawEvents.length > 500,
878
+ events: limitedEvents.map(e => ({
879
+ type: e.type,
880
+ task_id: e.task_id,
881
+ data: e.data ? (() => { try {
882
+ return JSON.parse(e.data);
883
+ }
884
+ catch {
885
+ return e.data;
886
+ } })() : null,
887
+ created_at: e.created_at,
888
+ })),
889
+ tasks: tasks.map(t => ({
890
+ id: t.id,
891
+ phase: t.phase,
892
+ agent: t.agent,
893
+ model: t.model,
894
+ status: t.status,
895
+ retries: t.retries,
896
+ started_at: t.started_at,
897
+ finished_at: t.finished_at,
898
+ total_tokens: t.total_tokens,
899
+ cost_usd: t.cost_usd,
900
+ review_level: t.review_level,
901
+ review_verdict: t.review_verdict,
902
+ review_tokens: t.review_tokens,
903
+ review_model: t.review_model,
904
+ panel_attempts: t.panel_attempts,
905
+ dispute_id: t.dispute_id,
906
+ drift_score: t.drift_score,
907
+ drift_retried: t.drift_retried,
908
+ files: t.files ? (() => { try {
909
+ return JSON.parse(t.files);
910
+ }
911
+ catch {
912
+ return null;
913
+ } })() : null,
914
+ })),
915
+ };
916
+ }
306
917
  withTransaction(fn) {
307
918
  this.db.exec('BEGIN');
308
919
  try {
@@ -319,6 +930,128 @@ class ConvoyStoreImpl {
319
930
  this.db.close();
320
931
  }
321
932
  }
933
+ export function migrateSchema(db, dbPath, fromVersion, toVersion) {
934
+ for (let v = fromVersion; v < toVersion; v++) {
935
+ const backupPath = `${dbPath}.v${v}.bak`;
936
+ copyFileSync(dbPath, backupPath);
937
+ db.exec('BEGIN');
938
+ try {
939
+ if (v === 4) {
940
+ db.exec(`
941
+ ALTER TABLE task ADD COLUMN gates TEXT;
942
+ ALTER TABLE task ADD COLUMN on_exhausted TEXT NOT NULL DEFAULT 'dlq';
943
+ ALTER TABLE task ADD COLUMN injected INTEGER NOT NULL DEFAULT 0;
944
+ ALTER TABLE task ADD COLUMN provenance TEXT;
945
+ ALTER TABLE task ADD COLUMN idempotency_key TEXT;
946
+ CREATE UNIQUE INDEX idx_task_idempotency ON task(convoy_id, idempotency_key)
947
+ WHERE idempotency_key IS NOT NULL;
948
+ ALTER TABLE convoy ADD COLUMN circuit_state TEXT;
949
+ CREATE TABLE dlq (
950
+ id TEXT PRIMARY KEY,
951
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
952
+ task_id TEXT NOT NULL REFERENCES task(id),
953
+ agent TEXT NOT NULL,
954
+ failure_type TEXT NOT NULL,
955
+ error_output TEXT,
956
+ attempts INTEGER NOT NULL,
957
+ tokens_spent INTEGER,
958
+ escalation_task_id TEXT,
959
+ resolved INTEGER NOT NULL DEFAULT 0,
960
+ resolution TEXT,
961
+ created_at TEXT NOT NULL,
962
+ resolved_at TEXT
963
+ );
964
+ `);
965
+ }
966
+ if (v === 5) {
967
+ db.exec(`
968
+ ALTER TABLE task ADD COLUMN current_step INTEGER;
969
+ ALTER TABLE task ADD COLUMN total_steps INTEGER;
970
+ ALTER TABLE task ADD COLUMN review_level TEXT;
971
+ ALTER TABLE task ADD COLUMN review_verdict TEXT;
972
+ ALTER TABLE task ADD COLUMN review_tokens INTEGER;
973
+ ALTER TABLE task ADD COLUMN review_model TEXT;
974
+ ALTER TABLE task ADD COLUMN panel_attempts INTEGER NOT NULL DEFAULT 0;
975
+ ALTER TABLE task ADD COLUMN dispute_id TEXT;
976
+ ALTER TABLE convoy ADD COLUMN review_tokens_total INTEGER;
977
+ ALTER TABLE convoy ADD COLUMN review_budget INTEGER;
978
+ CREATE TABLE task_step (
979
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
980
+ task_id TEXT NOT NULL REFERENCES task(id),
981
+ step_index INTEGER NOT NULL,
982
+ prompt TEXT NOT NULL,
983
+ gates TEXT,
984
+ status TEXT NOT NULL DEFAULT 'pending',
985
+ exit_code INTEGER,
986
+ output TEXT,
987
+ started_at TEXT,
988
+ finished_at TEXT
989
+ );
990
+ `);
991
+ }
992
+ if (v === 6) {
993
+ db.exec(`
994
+ ALTER TABLE task ADD COLUMN drift_score REAL;
995
+ ALTER TABLE task ADD COLUMN drift_retried INTEGER NOT NULL DEFAULT 0;
996
+ `);
997
+ }
998
+ if (v === 7) {
999
+ db.exec(`
1000
+ ALTER TABLE task ADD COLUMN outputs TEXT;
1001
+ ALTER TABLE task ADD COLUMN inputs TEXT;
1002
+ ALTER TABLE task ADD COLUMN discovered_issues TEXT;
1003
+ CREATE TABLE artifact (
1004
+ id TEXT PRIMARY KEY,
1005
+ convoy_id TEXT NOT NULL REFERENCES convoy(id),
1006
+ task_id TEXT NOT NULL REFERENCES task(id),
1007
+ name TEXT NOT NULL,
1008
+ type TEXT NOT NULL,
1009
+ content TEXT NOT NULL CHECK (length(content) <= 1048576),
1010
+ created_at TEXT NOT NULL,
1011
+ UNIQUE(convoy_id, name)
1012
+ );
1013
+ CREATE TABLE agent_identity (
1014
+ id TEXT PRIMARY KEY,
1015
+ agent TEXT NOT NULL,
1016
+ convoy_id TEXT NOT NULL,
1017
+ task_id TEXT NOT NULL,
1018
+ summary TEXT NOT NULL,
1019
+ created_at TEXT NOT NULL,
1020
+ retention_days INTEGER NOT NULL DEFAULT 90
1021
+ );
1022
+ `);
1023
+ }
1024
+ if (v === 8) {
1025
+ db.exec(`
1026
+ CREATE TABLE scratchpad (
1027
+ key TEXT PRIMARY KEY,
1028
+ value TEXT NOT NULL,
1029
+ updated_at TEXT NOT NULL
1030
+ );
1031
+ `);
1032
+ }
1033
+ if (v === 9) {
1034
+ db.exec(`
1035
+ ALTER TABLE convoy ADD COLUMN total_cost_usd_num REAL;
1036
+ ALTER TABLE task ADD COLUMN cost_usd_num REAL;
1037
+ ALTER TABLE pipeline ADD COLUMN total_cost_usd_num REAL;
1038
+ UPDATE convoy SET total_cost_usd_num = CAST(total_cost_usd AS REAL) WHERE total_cost_usd IS NOT NULL;
1039
+ UPDATE task SET cost_usd_num = CAST(cost_usd AS REAL) WHERE cost_usd IS NOT NULL;
1040
+ UPDATE pipeline SET total_cost_usd_num = CAST(total_cost_usd AS REAL) WHERE total_cost_usd IS NOT NULL;
1041
+ `);
1042
+ }
1043
+ db.exec('COMMIT');
1044
+ }
1045
+ catch (err) {
1046
+ try {
1047
+ db.exec('ROLLBACK');
1048
+ }
1049
+ catch { /* ignore */ }
1050
+ throw new Error(`Migration v${v}→v${v + 1} failed. Backup at ${backupPath}. Original error: ${err.message}`);
1051
+ }
1052
+ db.exec(`PRAGMA user_version = ${v + 1}`);
1053
+ }
1054
+ }
322
1055
  export function createConvoyStore(dbPath) {
323
1056
  return new ConvoyStoreImpl(dbPath);
324
1057
  }