opencastle 0.27.1 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  2. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  3. package/dist/cli/convoy/dashboard-types.js +2 -0
  4. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  5. package/dist/cli/convoy/engine.d.ts +0 -1
  6. package/dist/cli/convoy/engine.d.ts.map +1 -1
  7. package/dist/cli/convoy/engine.js +31 -99
  8. package/dist/cli/convoy/engine.js.map +1 -1
  9. package/dist/cli/convoy/engine.test.js +88 -1
  10. package/dist/cli/convoy/engine.test.js.map +1 -1
  11. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  12. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  13. package/dist/cli/convoy/event-schemas.js +185 -0
  14. package/dist/cli/convoy/event-schemas.js.map +1 -0
  15. package/dist/cli/convoy/events.d.ts +8 -0
  16. package/dist/cli/convoy/events.d.ts.map +1 -1
  17. package/dist/cli/convoy/events.js +117 -5
  18. package/dist/cli/convoy/events.js.map +1 -1
  19. package/dist/cli/convoy/events.test.js +173 -3
  20. package/dist/cli/convoy/events.test.js.map +1 -1
  21. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  22. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  23. package/dist/cli/convoy/log-merge.test.js +147 -0
  24. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  25. package/dist/cli/convoy/store.d.ts +52 -2
  26. package/dist/cli/convoy/store.d.ts.map +1 -1
  27. package/dist/cli/convoy/store.js +244 -17
  28. package/dist/cli/convoy/store.js.map +1 -1
  29. package/dist/cli/convoy/store.test.js +481 -22
  30. package/dist/cli/convoy/store.test.js.map +1 -1
  31. package/dist/cli/convoy/types.d.ts +271 -3
  32. package/dist/cli/convoy/types.d.ts.map +1 -1
  33. package/dist/cli/convoy/types.js +42 -1
  34. package/dist/cli/convoy/types.js.map +1 -1
  35. package/dist/cli/log.d.ts +11 -0
  36. package/dist/cli/log.d.ts.map +1 -1
  37. package/dist/cli/log.js +114 -2
  38. package/dist/cli/log.js.map +1 -1
  39. package/package.json +5 -1
  40. package/src/cli/convoy/TELEMETRY.md +203 -0
  41. package/src/cli/convoy/dashboard-types.ts +141 -0
  42. package/src/cli/convoy/engine.test.ts +99 -1
  43. package/src/cli/convoy/engine.ts +27 -96
  44. package/src/cli/convoy/event-schemas.ts +195 -0
  45. package/src/cli/convoy/events.test.ts +207 -3
  46. package/src/cli/convoy/events.ts +119 -5
  47. package/src/cli/convoy/log-merge.test.ts +179 -0
  48. package/src/cli/convoy/store.test.ts +545 -22
  49. package/src/cli/convoy/store.ts +274 -21
  50. package/src/cli/convoy/types.ts +108 -3
  51. package/src/cli/log.ts +120 -2
  52. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  53. package/src/dashboard/dist/data/.gitkeep +0 -0
  54. package/src/dashboard/dist/data/convoy-list.json +1 -0
  55. package/src/dashboard/dist/data/overall-stats.json +24 -0
  56. package/src/dashboard/dist/index.html +701 -3
  57. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  58. package/src/dashboard/public/data/.gitkeep +0 -0
  59. package/src/dashboard/public/data/convoy-list.json +1 -0
  60. package/src/dashboard/public/data/overall-stats.json +24 -0
  61. package/src/dashboard/scripts/etl.test.ts +210 -0
  62. package/src/dashboard/scripts/etl.ts +108 -0
  63. package/src/dashboard/scripts/integration-test.ts +504 -0
  64. package/src/dashboard/src/pages/index.astro +854 -15
  65. package/src/dashboard/src/styles/dashboard.css +557 -1
  66. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -86,11 +86,11 @@ describe('DB creation', () => {
86
86
  db.close();
87
87
  expect(row.journal_mode).toBe('wal');
88
88
  });
89
- it('sets schema version to 9', () => {
89
+ it('sets schema version to 10', () => {
90
90
  const db = new DatabaseSync(dbPath);
91
91
  const row = db.prepare('PRAGMA user_version').get();
92
92
  db.close();
93
- expect(row.user_version).toBe(9);
93
+ expect(row.user_version).toBe(10);
94
94
  });
95
95
  it('creates all required tables', () => {
96
96
  const db = new DatabaseSync(dbPath);
@@ -116,7 +116,7 @@ describe('DB creation', () => {
116
116
  store2.close();
117
117
  // Reassign so afterEach does not double-close
118
118
  store = createConvoyStore(dbPath);
119
- expect(row.user_version).toBe(9);
119
+ expect(row.user_version).toBe(10);
120
120
  });
121
121
  });
122
122
  describe('schema migration', () => {
@@ -189,8 +189,8 @@ describe('schema migration', () => {
189
189
  const version = verifyDb.prepare('PRAGMA user_version').get();
190
190
  verifyDb.close();
191
191
  expect(cols.map(c => c.name)).toContain('adapter');
192
- // v1 chains through v2→v3→v4→...→v7→v8→v9 in one init, so final version is 9
193
- expect(version.user_version).toBe(9);
192
+ // v1 chains through v2→v3→v4→...→v7→v8→v9→v10 in one init, so final version is 10
193
+ expect(version.user_version).toBe(10);
194
194
  });
195
195
  it('schema migration v2 to v3 adds cost columns', () => {
196
196
  // Create a v2 database manually (has adapter column but no cost columns)
@@ -270,7 +270,7 @@ describe('schema migration', () => {
270
270
  const convoyColNames = convoyCols.map(c => c.name);
271
271
  expect(convoyColNames).toContain('total_tokens');
272
272
  expect(convoyColNames).toContain('total_cost_usd');
273
- expect(version.user_version).toBe(9);
273
+ expect(version.user_version).toBe(10);
274
274
  });
275
275
  it('schema migration v1 to v3 chains correctly in a single init', () => {
276
276
  // Create a v1 database (task table without adapter or cost columns)
@@ -350,7 +350,7 @@ describe('schema migration', () => {
350
350
  const convoyColNames = convoyCols.map(c => c.name);
351
351
  expect(convoyColNames).toContain('total_tokens');
352
352
  expect(convoyColNames).toContain('total_cost_usd');
353
- expect(version.user_version).toBe(9);
353
+ expect(version.user_version).toBe(10);
354
354
  });
355
355
  it('schema migration v3 to v4 creates pipeline table and adds pipeline_id to convoy', () => {
356
356
  const v3DbPath = join(tmpDir, 'v3.db');
@@ -429,7 +429,7 @@ describe('schema migration', () => {
429
429
  verifyDb.close();
430
430
  expect(convoyCols.map(c => c.name)).toContain('pipeline_id');
431
431
  expect(tables.map(t => t.name)).toContain('pipeline');
432
- expect(version.user_version).toBe(9);
432
+ expect(version.user_version).toBe(10);
433
433
  });
434
434
  });
435
435
  // ── convoy CRUD ───────────────────────────────────────────────────────────────
@@ -480,11 +480,11 @@ describe('convoy CRUD', () => {
480
480
  store.updateConvoyStatus('convoy-1', 'done', {
481
481
  finished_at: '2026-01-01T01:00:00.000Z',
482
482
  total_tokens: 5000,
483
- total_cost_usd: '0.015000',
483
+ total_cost_usd: 0.015,
484
484
  });
485
485
  const retrieved = store.getConvoy('convoy-1');
486
486
  expect(retrieved.total_tokens).toBe(5000);
487
- expect(retrieved.total_cost_usd).toBe('0.015000');
487
+ expect(retrieved.total_cost_usd).toBe(0.015);
488
488
  });
489
489
  });
490
490
  // ── task CRUD ─────────────────────────────────────────────────────────────────
@@ -564,13 +564,13 @@ describe('task CRUD', () => {
564
564
  prompt_tokens: 1200,
565
565
  completion_tokens: 800,
566
566
  total_tokens: 2000,
567
- cost_usd: '0.006000',
567
+ cost_usd: 0.006,
568
568
  });
569
569
  const task = store.getTask('task-1', 'convoy-1');
570
570
  expect(task.prompt_tokens).toBe(1200);
571
571
  expect(task.completion_tokens).toBe(800);
572
572
  expect(task.total_tokens).toBe(2000);
573
- expect(task.cost_usd).toBe('0.006000');
573
+ expect(task.cost_usd).toBe(0.006);
574
574
  });
575
575
  });
576
576
  // ── getReadyTasks ─────────────────────────────────────────────────────────────
@@ -804,11 +804,11 @@ describe('pipeline CRUD', () => {
804
804
  store.updatePipelineStatus('pipeline-1', 'done', {
805
805
  finished_at: '2026-01-01T01:00:00.000Z',
806
806
  total_tokens: 12000,
807
- total_cost_usd: '0.036000',
807
+ total_cost_usd: 0.036,
808
808
  });
809
809
  const p = store.getPipeline('pipeline-1');
810
810
  expect(p.total_tokens).toBe(12000);
811
- expect(p.total_cost_usd).toBe('0.036000');
811
+ expect(p.total_cost_usd).toBe(0.036);
812
812
  });
813
813
  it('pipeline status can transition through all states', () => {
814
814
  store.insertPipeline(makePipeline());
@@ -1292,7 +1292,7 @@ describe('schema migration v5 → v6', () => {
1292
1292
  const task = migratedStore.getTask('task-auto', 'convoy-auto');
1293
1293
  v5Verify.close();
1294
1294
  migratedStore.close();
1295
- expect(row.user_version).toBe(9);
1295
+ expect(row.user_version).toBe(10);
1296
1296
  expect(taskStepTable?.name).toBe('task_step');
1297
1297
  expect(convoy?.id).toBe('convoy-auto');
1298
1298
  expect(task?.id).toBe('task-auto');
@@ -1442,7 +1442,7 @@ describe('schema migration v6→v7 (drift detection columns)', () => {
1442
1442
  verifyDb.close();
1443
1443
  expect(cols.map(c => c.name)).toContain('drift_score');
1444
1444
  expect(cols.map(c => c.name)).toContain('drift_retried');
1445
- expect(version.user_version).toBe(9);
1445
+ expect(version.user_version).toBe(10);
1446
1446
  });
1447
1447
  it('new databases include drift_score and drift_retried in CREATE TABLE', () => {
1448
1448
  const cols = new DatabaseSync(dbPath)
@@ -1623,9 +1623,9 @@ describe('artifact CRUD', () => {
1623
1623
  expect(deleted).toBe(1);
1624
1624
  });
1625
1625
  });
1626
- // ── migration full chain v4→v9 ────────────────────────────────────────────────
1627
- describe('migration full chain v4→v9', () => {
1628
- it('migrates a seeded v4 database to v9, preserving data and adding all tables/columns', () => {
1626
+ // ── migration full chain v4→v10 ────────────────────────────────────────────────
1627
+ describe('migration full chain v4→v10', () => {
1628
+ it('migrates a seeded v4 database to v10, preserving data and adding all tables/columns', () => {
1629
1629
  const chainDbPath = join(tmpDir, 'v4-chain.db');
1630
1630
  const v4Db = createV4Db(chainDbPath);
1631
1631
  // Seed realistic v4 data
@@ -1638,13 +1638,13 @@ describe('migration full chain v4→v9', () => {
1638
1638
  v4Db.prepare(`INSERT INTO event (convoy_id, task_id, type, created_at)
1639
1639
  VALUES ('convoy-chain', 'task-chain', 'task_started', '2026-01-01T00:00:00.000Z')`).run();
1640
1640
  v4Db.close();
1641
- // Trigger the full v4→v9 migration chain
1641
+ // Trigger the full v4→v10 migration chain
1642
1642
  const migratedStore = createConvoyStore(chainDbPath);
1643
1643
  migratedStore.close();
1644
1644
  const verifyDb = new DatabaseSync(chainDbPath);
1645
- // Verify user_version = 9
1645
+ // Verify user_version = 10
1646
1646
  const version = verifyDb.prepare('PRAGMA user_version').get().user_version;
1647
- expect(version).toBe(9);
1647
+ expect(version).toBe(10);
1648
1648
  // Verify all new tables exist
1649
1649
  const tables = verifyDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
1650
1650
  for (const table of ['task_step', 'dlq', 'artifact', 'agent_identity', 'scratchpad']) {
@@ -2145,6 +2145,125 @@ describe('v8→v9 migration', () => {
2145
2145
  rmSync(migDir, { recursive: true, force: true });
2146
2146
  });
2147
2147
  });
2148
+ describe('v9→v10 migration', () => {
2149
+ it('adds numeric cost columns and backfills data from TEXT columns', () => {
2150
+ const migDir = realpathSync(mkdtempSync(join(tmpdir(), 'mig-v10-test-')));
2151
+ const migDb = join(migDir, 'mig.db');
2152
+ const db = new DatabaseSync(migDb);
2153
+ db.exec('PRAGMA journal_mode = WAL');
2154
+ db.exec(`
2155
+ CREATE TABLE convoy (
2156
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, spec_hash TEXT NOT NULL,
2157
+ status TEXT NOT NULL DEFAULT 'pending', branch TEXT,
2158
+ created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT,
2159
+ spec_yaml TEXT NOT NULL, total_tokens INTEGER, total_cost_usd TEXT,
2160
+ pipeline_id TEXT, circuit_state TEXT, review_tokens_total INTEGER, review_budget INTEGER
2161
+ );
2162
+ CREATE TABLE pipeline (
2163
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
2164
+ branch TEXT, spec_yaml TEXT NOT NULL, convoy_specs TEXT NOT NULL,
2165
+ created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT,
2166
+ total_tokens INTEGER, total_cost_usd TEXT
2167
+ );
2168
+ CREATE TABLE task (
2169
+ id TEXT PRIMARY KEY, convoy_id TEXT NOT NULL REFERENCES convoy(id),
2170
+ phase INTEGER NOT NULL, prompt TEXT NOT NULL, agent TEXT NOT NULL DEFAULT 'developer',
2171
+ adapter TEXT, model TEXT, timeout_ms INTEGER NOT NULL DEFAULT 1800000,
2172
+ status TEXT NOT NULL DEFAULT 'pending', worker_id TEXT, worktree TEXT, output TEXT,
2173
+ exit_code INTEGER, started_at TEXT, finished_at TEXT,
2174
+ retries INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 1,
2175
+ files TEXT, depends_on TEXT, prompt_tokens INTEGER, completion_tokens INTEGER,
2176
+ total_tokens INTEGER, cost_usd TEXT, gates TEXT,
2177
+ on_exhausted TEXT NOT NULL DEFAULT 'dlq', injected INTEGER NOT NULL DEFAULT 0,
2178
+ provenance TEXT, idempotency_key TEXT, current_step INTEGER, total_steps INTEGER,
2179
+ review_level TEXT, review_verdict TEXT, review_tokens INTEGER, review_model TEXT,
2180
+ panel_attempts INTEGER NOT NULL DEFAULT 0, dispute_id TEXT,
2181
+ drift_score REAL, drift_retried INTEGER NOT NULL DEFAULT 0,
2182
+ outputs TEXT, inputs TEXT, discovered_issues TEXT
2183
+ );
2184
+ CREATE TABLE worker (
2185
+ id TEXT PRIMARY KEY, task_id TEXT REFERENCES task(id), adapter TEXT NOT NULL,
2186
+ pid INTEGER, session_id TEXT, status TEXT NOT NULL DEFAULT 'spawned',
2187
+ worktree TEXT, created_at TEXT NOT NULL, finished_at TEXT, last_heartbeat TEXT
2188
+ );
2189
+ CREATE TABLE event (
2190
+ id INTEGER PRIMARY KEY AUTOINCREMENT, convoy_id TEXT REFERENCES convoy(id),
2191
+ task_id TEXT, worker_id TEXT, type TEXT NOT NULL, data TEXT, created_at TEXT NOT NULL
2192
+ );
2193
+ CREATE TABLE dlq (
2194
+ id TEXT PRIMARY KEY, convoy_id TEXT NOT NULL REFERENCES convoy(id),
2195
+ task_id TEXT NOT NULL REFERENCES task(id), agent TEXT NOT NULL,
2196
+ failure_type TEXT NOT NULL, error_output TEXT, attempts INTEGER NOT NULL,
2197
+ tokens_spent INTEGER, escalation_task_id TEXT, resolved INTEGER NOT NULL DEFAULT 0,
2198
+ resolution TEXT, created_at TEXT NOT NULL, resolved_at TEXT
2199
+ );
2200
+ CREATE TABLE task_step (
2201
+ id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES task(id),
2202
+ step_index INTEGER NOT NULL, prompt TEXT NOT NULL, gates TEXT,
2203
+ status TEXT NOT NULL DEFAULT 'pending', exit_code INTEGER, output TEXT,
2204
+ started_at TEXT, finished_at TEXT
2205
+ );
2206
+ CREATE TABLE artifact (
2207
+ id TEXT PRIMARY KEY, convoy_id TEXT NOT NULL REFERENCES convoy(id),
2208
+ task_id TEXT NOT NULL REFERENCES task(id), name TEXT NOT NULL, type TEXT NOT NULL,
2209
+ content TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(convoy_id, name)
2210
+ );
2211
+ CREATE TABLE agent_identity (
2212
+ id TEXT PRIMARY KEY, agent TEXT NOT NULL, convoy_id TEXT NOT NULL,
2213
+ task_id TEXT NOT NULL, summary TEXT NOT NULL, created_at TEXT NOT NULL,
2214
+ retention_days INTEGER NOT NULL DEFAULT 90
2215
+ );
2216
+ CREATE TABLE scratchpad (
2217
+ key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL
2218
+ );
2219
+ PRAGMA user_version = 9;
2220
+ `);
2221
+ // Seed data with TEXT cost values (pre-migration state)
2222
+ db.prepare(`INSERT INTO convoy (id, name, spec_hash, status, created_at, spec_yaml, total_cost_usd)
2223
+ VALUES ('c-1', 'Test', 'hash1', 'done', '2026-01-01T00:00:00.000Z', 'name: test', '1.23')`).run();
2224
+ db.prepare(`INSERT INTO convoy (id, name, spec_hash, status, created_at, spec_yaml, total_cost_usd)
2225
+ VALUES ('c-null', 'NullCost', 'hash2', 'pending', '2026-01-01T00:00:00.000Z', 'name: test', NULL)`).run();
2226
+ db.prepare(`INSERT INTO task (id, convoy_id, phase, prompt, agent, timeout_ms, status, retries, max_retries, cost_usd)
2227
+ VALUES ('t-1', 'c-1', 0, 'Do it', 'developer', 1800000, 'done', 0, 1, '0.45')`).run();
2228
+ db.prepare(`INSERT INTO task (id, convoy_id, phase, prompt, agent, timeout_ms, status, retries, max_retries, cost_usd)
2229
+ VALUES ('t-null', 'c-null', 0, 'Do it', 'developer', 1800000, 'pending', 0, 1, NULL)`).run();
2230
+ db.close();
2231
+ // Open with createConvoyStore — triggers v9→v10 migration
2232
+ const migratedStore = createConvoyStore(migDb);
2233
+ // Verify convoy cost is numeric
2234
+ const convoy = migratedStore.getConvoy('c-1');
2235
+ expect(convoy.total_cost_usd).toBe(1.23);
2236
+ expect(convoy.total_cost_usd.toFixed(2)).toBe('1.23');
2237
+ expect(convoy.total_cost_usd > 0).toBe(true);
2238
+ // Verify null preservation
2239
+ const convoyNull = migratedStore.getConvoy('c-null');
2240
+ expect(convoyNull.total_cost_usd).toBeNull();
2241
+ // Verify task cost is numeric
2242
+ const task = migratedStore.getTask('t-1', 'c-1');
2243
+ expect(task.cost_usd).toBe(0.45);
2244
+ expect(task.cost_usd.toFixed(2)).toBe('0.45');
2245
+ expect(task.cost_usd > 0).toBe(true);
2246
+ // Verify task null preservation
2247
+ const taskNull = migratedStore.getTask('t-null', 'c-null');
2248
+ expect(taskNull.cost_usd).toBeNull();
2249
+ migratedStore.close();
2250
+ // Verify version = 10
2251
+ const verifyDb = new DatabaseSync(migDb);
2252
+ const version = verifyDb.prepare('PRAGMA user_version').get().user_version;
2253
+ expect(version).toBe(10);
2254
+ // Verify new REAL columns exist
2255
+ const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all().map(c => c.name);
2256
+ expect(convoyCols).toContain('total_cost_usd_num');
2257
+ const taskCols = verifyDb.prepare('PRAGMA table_info(task)').all().map(c => c.name);
2258
+ expect(taskCols).toContain('cost_usd_num');
2259
+ const pipelineCols = verifyDb.prepare('PRAGMA table_info(pipeline)').all().map(c => c.name);
2260
+ expect(pipelineCols).toContain('total_cost_usd_num');
2261
+ verifyDb.close();
2262
+ // Verify backup was created
2263
+ expect(existsSync(`${migDb}.v9.bak`)).toBe(true);
2264
+ rmSync(migDir, { recursive: true, force: true });
2265
+ });
2266
+ });
2148
2267
  describe('size limit enforcement', () => {
2149
2268
  it('insertConvoy rejects spec_yaml exceeding 256KB', () => {
2150
2269
  const bigSpecYaml = 'x'.repeat(256 * 1024 + 1);
@@ -2206,4 +2325,344 @@ describe('size limit enforcement', () => {
2206
2325
  })).toThrow(FieldSizeLimitError);
2207
2326
  });
2208
2327
  });
2328
+ // ── Dashboard aggregate methods ───────────────────────────────────────────────
2329
+ describe('Dashboard aggregate methods', () => {
2330
+ // Seed helper: inserts 5 convoys, 20 tasks, 3 DLQ entries
2331
+ function seedDashboardData() {
2332
+ // Convoy 1: done, 30s duration
2333
+ store.insertConvoy(makeConvoy({ id: 'dash-c1', name: 'Dash Convoy 1', status: 'pending', created_at: '2026-01-01T10:00:00.000Z' }));
2334
+ store.updateConvoyStatus('dash-c1', 'done', { started_at: '2026-01-01T10:00:00.000Z', finished_at: '2026-01-01T10:00:30.000Z', total_tokens: 1000, total_cost_usd: 0.01 });
2335
+ // Convoy 2: done, 60s duration
2336
+ store.insertConvoy(makeConvoy({ id: 'dash-c2', name: 'Dash Convoy 2', status: 'pending', created_at: '2026-01-02T10:00:00.000Z' }));
2337
+ store.updateConvoyStatus('dash-c2', 'done', { started_at: '2026-01-02T10:00:00.000Z', finished_at: '2026-01-02T10:01:00.000Z', total_tokens: 2000, total_cost_usd: 0.02 });
2338
+ // Convoy 3: running
2339
+ store.insertConvoy(makeConvoy({ id: 'dash-c3', name: 'Dash Convoy 3', status: 'pending', created_at: '2026-01-03T10:00:00.000Z' }));
2340
+ store.updateConvoyStatus('dash-c3', 'running', { started_at: '2026-01-03T10:00:00.000Z' });
2341
+ // Convoy 4: failed, 20s duration
2342
+ store.insertConvoy(makeConvoy({ id: 'dash-c4', name: 'Dash Convoy 4', status: 'pending', created_at: '2026-01-04T10:00:00.000Z' }));
2343
+ store.updateConvoyStatus('dash-c4', 'failed', { started_at: '2026-01-04T10:00:00.000Z', finished_at: '2026-01-04T10:00:20.000Z', total_tokens: 500, total_cost_usd: 0.005 });
2344
+ // Convoy 5: pending (no timestamps)
2345
+ store.insertConvoy(makeConvoy({ id: 'dash-c5', name: 'Dash Convoy 5', status: 'pending', created_at: '2026-01-05T10:00:00.000Z' }));
2346
+ // Tasks across convoys (20 total)
2347
+ const taskDefs = [
2348
+ { id: 'dt-1', convoy_id: 'dash-c1', agent: 'developer', model: 'gpt-4o', status: 'done', retries: 0, total_tokens: 100 },
2349
+ { id: 'dt-2', convoy_id: 'dash-c1', agent: 'developer', model: 'gpt-4o', status: 'done', retries: 0, total_tokens: 150 },
2350
+ { id: 'dt-3', convoy_id: 'dash-c1', agent: 'reviewer', model: 'gpt-4o-mini', status: 'done', retries: 0, total_tokens: 50 },
2351
+ { id: 'dt-4', convoy_id: 'dash-c1', agent: 'reviewer', model: 'gpt-4o-mini', status: 'done', retries: 1, total_tokens: 60 },
2352
+ { id: 'dt-5', convoy_id: 'dash-c2', agent: 'developer', model: 'gpt-4o', status: 'done', retries: 0, total_tokens: 200 },
2353
+ { id: 'dt-6', convoy_id: 'dash-c2', agent: 'developer', model: 'gpt-4o', status: 'done', retries: 0, total_tokens: 250 },
2354
+ { id: 'dt-7', convoy_id: 'dash-c2', agent: 'developer', model: 'gpt-4o', status: 'done', retries: 2, total_tokens: 300 },
2355
+ { id: 'dt-8', convoy_id: 'dash-c2', agent: 'qa', model: null, status: 'done', retries: 0, total_tokens: 80 },
2356
+ { id: 'dt-9', convoy_id: 'dash-c3', agent: 'developer', model: 'gpt-4o', status: 'running', retries: 0, total_tokens: null },
2357
+ { id: 'dt-10', convoy_id: 'dash-c3', agent: 'developer', model: 'gpt-4o', status: 'assigned', retries: 0, total_tokens: null },
2358
+ { id: 'dt-11', convoy_id: 'dash-c3', agent: 'reviewer', model: 'gpt-4o-mini', status: 'pending', retries: 0, total_tokens: null },
2359
+ { id: 'dt-12', convoy_id: 'dash-c3', agent: 'reviewer', model: 'gpt-4o-mini', status: 'pending', retries: 0, total_tokens: null },
2360
+ { id: 'dt-13', convoy_id: 'dash-c4', agent: 'developer', model: 'gpt-4o', status: 'failed', retries: 3, total_tokens: 120 },
2361
+ { id: 'dt-14', convoy_id: 'dash-c4', agent: 'developer', model: 'gpt-4o', status: 'gate-failed', retries: 0, total_tokens: 90 },
2362
+ { id: 'dt-15', convoy_id: 'dash-c4', agent: 'qa', model: null, status: 'review-blocked', retries: 0, total_tokens: 40 },
2363
+ { id: 'dt-16', convoy_id: 'dash-c4', agent: 'qa', model: null, status: 'disputed', retries: 1, total_tokens: 30 },
2364
+ { id: 'dt-17', convoy_id: 'dash-c5', agent: 'developer', model: 'gpt-4o', status: 'pending', retries: 0, total_tokens: null },
2365
+ { id: 'dt-18', convoy_id: 'dash-c5', agent: 'developer', model: 'gpt-4o', status: 'pending', retries: 0, total_tokens: null },
2366
+ { id: 'dt-19', convoy_id: 'dash-c5', agent: 'reviewer', model: 'gpt-4o-mini', status: 'pending', retries: 0, total_tokens: null },
2367
+ { id: 'dt-20', convoy_id: 'dash-c5', agent: 'qa', model: null, status: 'pending', retries: 0, total_tokens: null },
2368
+ ];
2369
+ for (const t of taskDefs) {
2370
+ try {
2371
+ store.insertTask(makeTask({ id: t.id, convoy_id: t.convoy_id, agent: t.agent, model: t.model, retries: t.retries }));
2372
+ }
2373
+ catch {
2374
+ // already exists
2375
+ }
2376
+ if (t.status !== 'pending') {
2377
+ store.updateTaskStatus(t.id, t.convoy_id, t.status, t.total_tokens !== null ? { total_tokens: t.total_tokens } : undefined);
2378
+ }
2379
+ }
2380
+ // 3 DLQ entries with different failure_types
2381
+ const dlqEntries = [
2382
+ { id: 'dlq-1', convoy_id: 'dash-c4', task_id: 'dt-13', agent: 'developer', failure_type: 'timeout' },
2383
+ { id: 'dlq-2', convoy_id: 'dash-c4', task_id: 'dt-14', agent: 'developer', failure_type: 'gate_failure' },
2384
+ { id: 'dlq-3', convoy_id: 'dash-c4', task_id: 'dt-15', agent: 'qa', failure_type: 'timeout' },
2385
+ ];
2386
+ for (const d of dlqEntries) {
2387
+ store.insertDlqEntry({
2388
+ id: d.id,
2389
+ convoy_id: d.convoy_id,
2390
+ task_id: d.task_id,
2391
+ agent: d.agent,
2392
+ failure_type: d.failure_type,
2393
+ error_output: null,
2394
+ attempts: 1,
2395
+ tokens_spent: null,
2396
+ escalation_task_id: null,
2397
+ resolved: 0,
2398
+ resolution: null,
2399
+ created_at: new Date().toISOString(),
2400
+ resolved_at: null,
2401
+ });
2402
+ }
2403
+ }
2404
+ describe('getConvoyCounts', () => {
2405
+ it('returns all zeros on empty database', () => {
2406
+ const result = store.getConvoyCounts();
2407
+ expect(result).toEqual({ total: 0, running: 0, done: 0, failed: 0, gate_failed: 0 });
2408
+ });
2409
+ it('returns correct counts with seeded data', () => {
2410
+ seedDashboardData();
2411
+ const result = store.getConvoyCounts();
2412
+ expect(result.total).toBe(5);
2413
+ expect(result.done).toBe(2);
2414
+ expect(result.running).toBe(1);
2415
+ expect(result.failed).toBe(1);
2416
+ });
2417
+ });
2418
+ describe('getConvoyDurationStats', () => {
2419
+ it('returns all null on empty database', () => {
2420
+ const result = store.getConvoyDurationStats();
2421
+ expect(result).toEqual({ avg_sec: null, p95_sec: null, max_sec: null });
2422
+ });
2423
+ it('returns reasonable values with seeded data', () => {
2424
+ seedDashboardData();
2425
+ const result = store.getConvoyDurationStats();
2426
+ // 3 convoys have both timestamps: 30s, 60s, 20s — avg = 36.67
2427
+ expect(result.avg_sec).not.toBeNull();
2428
+ expect(result.avg_sec).toBeGreaterThan(0);
2429
+ expect(result.max_sec).toBeGreaterThanOrEqual(result.avg_sec);
2430
+ expect(result.p95_sec).not.toBeNull();
2431
+ });
2432
+ it('returns correct avg for single completed convoy', () => {
2433
+ store.insertConvoy(makeConvoy({ id: 'single-c', name: 'Single', status: 'pending' }));
2434
+ store.updateConvoyStatus('single-c', 'done', {
2435
+ started_at: '2026-01-01T10:00:00.000Z',
2436
+ finished_at: '2026-01-01T10:01:00.000Z',
2437
+ });
2438
+ const result = store.getConvoyDurationStats();
2439
+ expect(result.avg_sec).toBeCloseTo(60, 0);
2440
+ expect(result.p95_sec).toBeCloseTo(60, 0);
2441
+ expect(result.max_sec).toBeCloseTo(60, 0);
2442
+ });
2443
+ });
2444
+ describe('getTokenAndCostTotals', () => {
2445
+ it('returns zeros on empty database', () => {
2446
+ const result = store.getTokenAndCostTotals();
2447
+ expect(result).toEqual({ total_tokens: 0, total_cost_usd: 0 });
2448
+ });
2449
+ it('returns correct sums with seeded data', () => {
2450
+ seedDashboardData();
2451
+ const result = store.getTokenAndCostTotals();
2452
+ // convoy totals: 1000+2000+500 = 3500 tokens (c3 and c5 have none)
2453
+ expect(result.total_tokens).toBe(3500);
2454
+ // cost: 0.01+0.02+0.005 = 0.035
2455
+ expect(result.total_cost_usd).toBeCloseTo(0.035, 5);
2456
+ });
2457
+ });
2458
+ describe('getTopAgents', () => {
2459
+ it('returns empty array on empty database', () => {
2460
+ const result = store.getTopAgents(5);
2461
+ expect(result).toEqual([]);
2462
+ });
2463
+ it('returns agents ordered by task_count DESC', () => {
2464
+ seedDashboardData();
2465
+ const result = store.getTopAgents(10);
2466
+ expect(result.length).toBeGreaterThan(0);
2467
+ // developer should be top agent (appears most)
2468
+ expect(result[0].agent).toBe('developer');
2469
+ // verify descending order
2470
+ for (let i = 1; i < result.length; i++) {
2471
+ expect(result[i - 1].task_count).toBeGreaterThanOrEqual(result[i].task_count);
2472
+ }
2473
+ });
2474
+ it('respects the limit parameter', () => {
2475
+ seedDashboardData();
2476
+ const result = store.getTopAgents(1);
2477
+ expect(result).toHaveLength(1);
2478
+ });
2479
+ it('each entry has agent, task_count, total_tokens', () => {
2480
+ seedDashboardData();
2481
+ const result = store.getTopAgents(5);
2482
+ for (const row of result) {
2483
+ expect(typeof row.agent).toBe('string');
2484
+ expect(typeof row.task_count).toBe('number');
2485
+ expect(typeof row.total_tokens).toBe('number');
2486
+ }
2487
+ });
2488
+ });
2489
+ describe('getTopModels', () => {
2490
+ it('returns empty array on empty database', () => {
2491
+ const result = store.getTopModels(5);
2492
+ expect(result).toEqual([]);
2493
+ });
2494
+ it('returns models ordered by task_count DESC', () => {
2495
+ seedDashboardData();
2496
+ const result = store.getTopModels(10);
2497
+ expect(result.length).toBeGreaterThan(0);
2498
+ for (let i = 1; i < result.length; i++) {
2499
+ expect(result[i - 1].task_count).toBeGreaterThanOrEqual(result[i].task_count);
2500
+ }
2501
+ });
2502
+ it('excludes tasks with null model', () => {
2503
+ seedDashboardData();
2504
+ const result = store.getTopModels(10);
2505
+ for (const row of result) {
2506
+ expect(row.model).not.toBeNull();
2507
+ }
2508
+ });
2509
+ });
2510
+ describe('getDlqSummary', () => {
2511
+ it('returns count=0 and empty array on empty database', () => {
2512
+ const result = store.getDlqSummary();
2513
+ expect(result).toEqual({ count: 0, top_failure_types: [] });
2514
+ });
2515
+ it('returns count=3 with seeded data', () => {
2516
+ seedDashboardData();
2517
+ const result = store.getDlqSummary();
2518
+ expect(result.count).toBe(3);
2519
+ });
2520
+ it('groups failure types correctly', () => {
2521
+ seedDashboardData();
2522
+ const result = store.getDlqSummary();
2523
+ const timeoutEntry = result.top_failure_types.find(t => t.type === 'timeout');
2524
+ const gateEntry = result.top_failure_types.find(t => t.type === 'gate_failure');
2525
+ expect(timeoutEntry?.count).toBe(2);
2526
+ expect(gateEntry?.count).toBe(1);
2527
+ });
2528
+ it('orders failure types by count DESC', () => {
2529
+ seedDashboardData();
2530
+ const result = store.getDlqSummary();
2531
+ for (let i = 1; i < result.top_failure_types.length; i++) {
2532
+ expect(result.top_failure_types[i - 1].count).toBeGreaterThanOrEqual(result.top_failure_types[i].count);
2533
+ }
2534
+ });
2535
+ });
2536
+ describe('getConvoyTaskSummary', () => {
2537
+ it('returns all zeros for non-existent convoy', () => {
2538
+ const result = store.getConvoyTaskSummary('nonexistent');
2539
+ expect(result).toEqual({
2540
+ total: 0, done: 0, running: 0, failed: 0,
2541
+ review_blocked: 0, disputed: 0, reviewed: 0, panel_reviewed: 0,
2542
+ tasks_with_drift: 0, max_drift_score: null, drift_retried: 0,
2543
+ });
2544
+ });
2545
+ it('returns correct per-status counts (done convoys)', () => {
2546
+ seedDashboardData();
2547
+ const result = store.getConvoyTaskSummary('dash-c1');
2548
+ // c1 has tasks dt-1..dt-4: all done
2549
+ expect(result.total).toBe(4);
2550
+ expect(result.done).toBe(4);
2551
+ expect(result.running).toBe(0);
2552
+ expect(result.failed).toBe(0);
2553
+ });
2554
+ it('returns correct counts for running convoy with mixed statuses', () => {
2555
+ seedDashboardData();
2556
+ const result = store.getConvoyTaskSummary('dash-c3');
2557
+ // c3: running(1), assigned(1), pending(2)
2558
+ expect(result.total).toBe(4);
2559
+ expect(result.running).toBe(2); // running + assigned
2560
+ });
2561
+ it('returns failed and review_blocked and disputed counts', () => {
2562
+ seedDashboardData();
2563
+ const result = store.getConvoyTaskSummary('dash-c4');
2564
+ // c4: failed(1), gate-failed(1), review-blocked(1), disputed(1)
2565
+ expect(result.failed).toBe(2); // failed + gate-failed
2566
+ expect(result.review_blocked).toBe(1);
2567
+ expect(result.disputed).toBe(1);
2568
+ });
2569
+ });
2570
+ describe('getConvoyList', () => {
2571
+ it('returns empty array on empty database', () => {
2572
+ const result = store.getConvoyList(10, 0);
2573
+ expect(result).toEqual([]);
2574
+ });
2575
+ it('returns convoys ordered by created_at DESC', () => {
2576
+ seedDashboardData();
2577
+ const result = store.getConvoyList(10, 0);
2578
+ expect(result.length).toBeGreaterThan(0);
2579
+ for (let i = 1; i < result.length; i++) {
2580
+ expect(result[i - 1].created_at >= result[i].created_at).toBe(true);
2581
+ }
2582
+ });
2583
+ it('respects limit', () => {
2584
+ seedDashboardData();
2585
+ const result = store.getConvoyList(2, 0);
2586
+ expect(result).toHaveLength(2);
2587
+ });
2588
+ it('respects offset for pagination', () => {
2589
+ seedDashboardData();
2590
+ const first = store.getConvoyList(5, 0);
2591
+ const second = store.getConvoyList(5, 2);
2592
+ // Items 2+ should appear in second page
2593
+ expect(second[0].id).toBe(first[2].id);
2594
+ });
2595
+ it('returns ConvoyRecord with total_cost_usd_num alias', () => {
2596
+ seedDashboardData();
2597
+ const result = store.getConvoyList(5, 0);
2598
+ // Should not throw and should return records with expected shape
2599
+ for (const r of result) {
2600
+ expect(typeof r.id).toBe('string');
2601
+ expect(typeof r.status).toBe('string');
2602
+ }
2603
+ });
2604
+ });
2605
+ describe('getConvoyDetails', () => {
2606
+ it('returns null for non-existent convoy', () => {
2607
+ const result = store.getConvoyDetails('nonexistent');
2608
+ expect(result).toBeNull();
2609
+ });
2610
+ it('returns full detail object for existing convoy', () => {
2611
+ seedDashboardData();
2612
+ const result = store.getConvoyDetails('dash-c1');
2613
+ expect(result).not.toBeNull();
2614
+ expect(result).toHaveProperty('convoy');
2615
+ expect(result).toHaveProperty('taskSummary');
2616
+ expect(result).toHaveProperty('quality');
2617
+ expect(result).toHaveProperty('drift');
2618
+ expect(result).toHaveProperty('dlq_count');
2619
+ expect(result).toHaveProperty('dlq_entries');
2620
+ expect(result).toHaveProperty('artifact_count');
2621
+ expect(result).toHaveProperty('artifacts');
2622
+ expect(result).toHaveProperty('has_more_events');
2623
+ expect(result).toHaveProperty('events');
2624
+ expect(result).toHaveProperty('tasks');
2625
+ });
2626
+ it('convoy sub-object has correct fields', () => {
2627
+ seedDashboardData();
2628
+ const result = store.getConvoyDetails('dash-c1');
2629
+ expect(result.convoy.id).toBe('dash-c1');
2630
+ expect(result.convoy.name).toBe('Dash Convoy 1');
2631
+ expect(result.convoy.status).toBe('done');
2632
+ expect(result.convoy.total_tokens).toBe(1000);
2633
+ expect(typeof result.convoy.total_cost_usd).toBe('number');
2634
+ });
2635
+ it('tasks list matches getTasksByConvoy', () => {
2636
+ seedDashboardData();
2637
+ const detail = store.getConvoyDetails('dash-c1');
2638
+ const direct = store.getTasksByConvoy('dash-c1');
2639
+ expect(detail.tasks).toHaveLength(direct.length);
2640
+ const detailIds = detail.tasks.map(t => t.id).sort();
2641
+ const directIds = direct.map(t => t.id).sort();
2642
+ expect(detailIds).toEqual(directIds);
2643
+ });
2644
+ it('taskSummary matches getConvoyTaskSummary', () => {
2645
+ seedDashboardData();
2646
+ const detail = store.getConvoyDetails('dash-c1');
2647
+ const direct = store.getConvoyTaskSummary('dash-c1');
2648
+ expect(detail.taskSummary.total).toBe(direct.total);
2649
+ expect(detail.taskSummary.done).toBe(direct.done);
2650
+ });
2651
+ it('dlq_count and dlq_entries match listDlqEntries for convoy with DLQ', () => {
2652
+ seedDashboardData();
2653
+ const detail = store.getConvoyDetails('dash-c4');
2654
+ const direct = store.listDlqEntries('dash-c4');
2655
+ expect(detail.dlq_count).toBe(direct.length);
2656
+ expect(detail.dlq_entries).toHaveLength(direct.length);
2657
+ const detailIds = detail.dlq_entries.map(d => d.id).sort();
2658
+ const directIds = direct.map(d => d.id).sort();
2659
+ expect(detailIds).toEqual(directIds);
2660
+ });
2661
+ it('has_more_events is false when no events', () => {
2662
+ seedDashboardData();
2663
+ const result = store.getConvoyDetails('dash-c1');
2664
+ expect(result.has_more_events).toBe(false);
2665
+ });
2666
+ });
2667
+ });
2209
2668
  //# sourceMappingURL=store.test.js.map