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.
- package/bin/cli.mjs +6 -0
- package/dist/cli/agents.d.ts +3 -0
- package/dist/cli/agents.d.ts.map +1 -0
- package/dist/cli/agents.js +161 -0
- package/dist/cli/agents.js.map +1 -0
- package/dist/cli/baselines.d.ts +3 -0
- package/dist/cli/baselines.d.ts.map +1 -0
- package/dist/cli/baselines.js +128 -0
- package/dist/cli/baselines.js.map +1 -0
- package/dist/cli/convoy/dashboard-types.d.ts +146 -0
- package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
- package/dist/cli/convoy/dashboard-types.js +2 -0
- package/dist/cli/convoy/dashboard-types.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts +67 -2
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +2036 -28
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1659 -70
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/event-schemas.d.ts +9 -0
- package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
- package/dist/cli/convoy/event-schemas.js +185 -0
- package/dist/cli/convoy/event-schemas.js.map +1 -0
- package/dist/cli/convoy/events.d.ts +12 -1
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +186 -13
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +325 -28
- package/dist/cli/convoy/events.test.js.map +1 -1
- package/dist/cli/convoy/expertise.d.ts +16 -0
- package/dist/cli/convoy/expertise.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.js +121 -0
- package/dist/cli/convoy/expertise.js.map +1 -0
- package/dist/cli/convoy/expertise.test.d.ts +2 -0
- package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
- package/dist/cli/convoy/expertise.test.js +96 -0
- package/dist/cli/convoy/expertise.test.js.map +1 -0
- package/dist/cli/convoy/export.test.js +1 -0
- package/dist/cli/convoy/export.test.js.map +1 -1
- package/dist/cli/convoy/formula.d.ts +19 -0
- package/dist/cli/convoy/formula.d.ts.map +1 -0
- package/dist/cli/convoy/formula.js +142 -0
- package/dist/cli/convoy/formula.js.map +1 -0
- package/dist/cli/convoy/formula.test.d.ts +2 -0
- package/dist/cli/convoy/formula.test.d.ts.map +1 -0
- package/dist/cli/convoy/formula.test.js +342 -0
- package/dist/cli/convoy/formula.test.js.map +1 -0
- package/dist/cli/convoy/gates.d.ts +128 -0
- package/dist/cli/convoy/gates.d.ts.map +1 -0
- package/dist/cli/convoy/gates.js +606 -0
- package/dist/cli/convoy/gates.js.map +1 -0
- package/dist/cli/convoy/gates.test.d.ts +2 -0
- package/dist/cli/convoy/gates.test.d.ts.map +1 -0
- package/dist/cli/convoy/gates.test.js +976 -0
- package/dist/cli/convoy/gates.test.js.map +1 -0
- package/dist/cli/convoy/health.d.ts +11 -0
- package/dist/cli/convoy/health.d.ts.map +1 -1
- package/dist/cli/convoy/health.js +54 -0
- package/dist/cli/convoy/health.js.map +1 -1
- package/dist/cli/convoy/health.test.js +56 -1
- package/dist/cli/convoy/health.test.js.map +1 -1
- package/dist/cli/convoy/issues.d.ts +8 -0
- package/dist/cli/convoy/issues.d.ts.map +1 -0
- package/dist/cli/convoy/issues.js +98 -0
- package/dist/cli/convoy/issues.js.map +1 -0
- package/dist/cli/convoy/issues.test.d.ts +2 -0
- package/dist/cli/convoy/issues.test.d.ts.map +1 -0
- package/dist/cli/convoy/issues.test.js +107 -0
- package/dist/cli/convoy/issues.test.js.map +1 -0
- package/dist/cli/convoy/knowledge.d.ts +5 -0
- package/dist/cli/convoy/knowledge.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.js +116 -0
- package/dist/cli/convoy/knowledge.js.map +1 -0
- package/dist/cli/convoy/knowledge.test.d.ts +2 -0
- package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
- package/dist/cli/convoy/knowledge.test.js +87 -0
- package/dist/cli/convoy/knowledge.test.js.map +1 -0
- package/dist/cli/convoy/lessons.d.ts +17 -0
- package/dist/cli/convoy/lessons.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.js +149 -0
- package/dist/cli/convoy/lessons.js.map +1 -0
- package/dist/cli/convoy/lessons.test.d.ts +2 -0
- package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
- package/dist/cli/convoy/lessons.test.js +135 -0
- package/dist/cli/convoy/lessons.test.js.map +1 -0
- package/dist/cli/convoy/lock.d.ts +13 -0
- package/dist/cli/convoy/lock.d.ts.map +1 -0
- package/dist/cli/convoy/lock.js +88 -0
- package/dist/cli/convoy/lock.js.map +1 -0
- package/dist/cli/convoy/lock.test.d.ts +2 -0
- package/dist/cli/convoy/lock.test.d.ts.map +1 -0
- package/dist/cli/convoy/lock.test.js +136 -0
- package/dist/cli/convoy/lock.test.js.map +1 -0
- package/dist/cli/convoy/log-merge.test.d.ts +2 -0
- package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
- package/dist/cli/convoy/log-merge.test.js +147 -0
- package/dist/cli/convoy/log-merge.test.js.map +1 -0
- package/dist/cli/convoy/merge.d.ts +4 -0
- package/dist/cli/convoy/merge.d.ts.map +1 -1
- package/dist/cli/convoy/merge.js +18 -1
- package/dist/cli/convoy/merge.js.map +1 -1
- package/dist/cli/convoy/merge.test.js +6 -7
- package/dist/cli/convoy/merge.test.js.map +1 -1
- package/dist/cli/convoy/partition.d.ts +51 -0
- package/dist/cli/convoy/partition.d.ts.map +1 -0
- package/dist/cli/convoy/partition.js +186 -0
- package/dist/cli/convoy/partition.js.map +1 -0
- package/dist/cli/convoy/partition.test.d.ts +2 -0
- package/dist/cli/convoy/partition.test.d.ts.map +1 -0
- package/dist/cli/convoy/partition.test.js +315 -0
- package/dist/cli/convoy/partition.test.js.map +1 -0
- package/dist/cli/convoy/pipeline.test.js +6 -0
- package/dist/cli/convoy/pipeline.test.js.map +1 -1
- package/dist/cli/convoy/store.d.ts +99 -7
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +764 -31
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +1810 -18
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +427 -5
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/convoy/types.js +42 -1
- package/dist/cli/convoy/types.js.map +1 -1
- package/dist/cli/log.d.ts +11 -0
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +114 -2
- package/dist/cli/log.js.map +1 -1
- package/dist/cli/run/adapters/claude.d.ts +2 -0
- package/dist/cli/run/adapters/claude.d.ts.map +1 -1
- package/dist/cli/run/adapters/claude.js +89 -49
- package/dist/cli/run/adapters/claude.js.map +1 -1
- package/dist/cli/run/adapters/claude.test.d.ts +2 -0
- package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/claude.test.js +205 -0
- package/dist/cli/run/adapters/claude.test.js.map +1 -0
- package/dist/cli/run/adapters/copilot.d.ts +1 -0
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
- package/dist/cli/run/adapters/copilot.js +84 -46
- package/dist/cli/run/adapters/copilot.js.map +1 -1
- package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
- package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/copilot.test.js +195 -0
- package/dist/cli/run/adapters/copilot.test.js.map +1 -0
- package/dist/cli/run/adapters/cursor.d.ts +1 -0
- package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
- package/dist/cli/run/adapters/cursor.js +83 -47
- package/dist/cli/run/adapters/cursor.js.map +1 -1
- package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
- package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/cursor.test.js +129 -0
- package/dist/cli/run/adapters/cursor.test.js.map +1 -0
- package/dist/cli/run/adapters/opencode.d.ts +1 -0
- package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
- package/dist/cli/run/adapters/opencode.js +81 -47
- package/dist/cli/run/adapters/opencode.js.map +1 -1
- package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
- package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
- package/dist/cli/run/adapters/opencode.test.js +119 -0
- package/dist/cli/run/adapters/opencode.test.js.map +1 -0
- package/dist/cli/run/executor.js +1 -1
- package/dist/cli/run/executor.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +245 -4
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +669 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +362 -22
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +85 -2
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js.map +1 -1
- package/dist/cli/watch.d.ts +15 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +279 -0
- package/dist/cli/watch.js.map +1 -0
- package/package.json +5 -1
- package/src/cli/agents.ts +177 -0
- package/src/cli/baselines.ts +143 -0
- package/src/cli/convoy/TELEMETRY.md +203 -0
- package/src/cli/convoy/dashboard-types.ts +141 -0
- package/src/cli/convoy/engine.test.ts +1937 -70
- package/src/cli/convoy/engine.ts +2350 -40
- package/src/cli/convoy/event-schemas.ts +195 -0
- package/src/cli/convoy/events.test.ts +384 -39
- package/src/cli/convoy/events.ts +202 -16
- package/src/cli/convoy/expertise.test.ts +128 -0
- package/src/cli/convoy/expertise.ts +163 -0
- package/src/cli/convoy/export.test.ts +1 -0
- package/src/cli/convoy/formula.test.ts +405 -0
- package/src/cli/convoy/formula.ts +174 -0
- package/src/cli/convoy/gates.test.ts +1169 -0
- package/src/cli/convoy/gates.ts +774 -0
- package/src/cli/convoy/health.test.ts +64 -2
- package/src/cli/convoy/health.ts +80 -2
- package/src/cli/convoy/issues.test.ts +143 -0
- package/src/cli/convoy/issues.ts +136 -0
- package/src/cli/convoy/knowledge.test.ts +101 -0
- package/src/cli/convoy/knowledge.ts +132 -0
- package/src/cli/convoy/lessons.test.ts +188 -0
- package/src/cli/convoy/lessons.ts +164 -0
- package/src/cli/convoy/lock.test.ts +181 -0
- package/src/cli/convoy/lock.ts +103 -0
- package/src/cli/convoy/log-merge.test.ts +179 -0
- package/src/cli/convoy/merge.test.ts +6 -7
- package/src/cli/convoy/merge.ts +19 -1
- package/src/cli/convoy/partition.test.ts +423 -0
- package/src/cli/convoy/partition.ts +232 -0
- package/src/cli/convoy/pipeline.test.ts +6 -0
- package/src/cli/convoy/store.test.ts +2041 -20
- package/src/cli/convoy/store.ts +945 -46
- package/src/cli/convoy/types.ts +278 -4
- package/src/cli/log.ts +120 -2
- package/src/cli/run/adapters/claude.test.ts +234 -0
- package/src/cli/run/adapters/claude.ts +45 -5
- package/src/cli/run/adapters/copilot.test.ts +224 -0
- package/src/cli/run/adapters/copilot.ts +34 -4
- package/src/cli/run/adapters/cursor.test.ts +144 -0
- package/src/cli/run/adapters/cursor.ts +33 -2
- package/src/cli/run/adapters/opencode.test.ts +135 -0
- package/src/cli/run/adapters/opencode.ts +30 -2
- package/src/cli/run/executor.ts +1 -1
- package/src/cli/run/schema.test.ts +758 -0
- package/src/cli/run/schema.ts +300 -25
- package/src/cli/run.ts +341 -21
- package/src/cli/types.ts +86 -1
- package/src/cli/watch.ts +298 -0
- package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
- package/src/dashboard/dist/data/.gitkeep +0 -0
- package/src/dashboard/dist/data/convoy-list.json +1 -0
- package/src/dashboard/dist/data/overall-stats.json +24 -0
- package/src/dashboard/dist/index.html +701 -3
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/public/data/.gitkeep +0 -0
- package/src/dashboard/public/data/convoy-list.json +1 -0
- package/src/dashboard/public/data/overall-stats.json +24 -0
- package/src/dashboard/scripts/etl.test.ts +210 -0
- package/src/dashboard/scripts/etl.ts +108 -0
- package/src/dashboard/scripts/integration-test.ts +504 -0
- package/src/dashboard/src/pages/index.astro +854 -15
- package/src/dashboard/src/styles/dashboard.css +557 -1
- package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
1
|
+
import { mkdtempSync, rmSync, realpathSync, existsSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
5
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
-
import { createConvoyStore } from './store.js';
|
|
6
|
+
import { createConvoyStore, migrateSchema, FieldSizeLimitError } from './store.js';
|
|
7
7
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
8
8
|
let tmpDir;
|
|
9
9
|
let dbPath;
|
|
10
10
|
let store;
|
|
11
11
|
beforeEach(() => {
|
|
12
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'convoy-test-'));
|
|
12
|
+
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'convoy-test-')));
|
|
13
13
|
dbPath = join(tmpDir, 'test.db');
|
|
14
14
|
store = createConvoyStore(dbPath);
|
|
15
15
|
});
|
|
@@ -45,6 +45,7 @@ function makeTask(overrides = {}) {
|
|
|
45
45
|
max_retries: 1,
|
|
46
46
|
files: null,
|
|
47
47
|
depends_on: null,
|
|
48
|
+
gates: null,
|
|
48
49
|
...overrides,
|
|
49
50
|
};
|
|
50
51
|
}
|
|
@@ -85,11 +86,11 @@ describe('DB creation', () => {
|
|
|
85
86
|
db.close();
|
|
86
87
|
expect(row.journal_mode).toBe('wal');
|
|
87
88
|
});
|
|
88
|
-
it('sets schema version to
|
|
89
|
+
it('sets schema version to 10', () => {
|
|
89
90
|
const db = new DatabaseSync(dbPath);
|
|
90
91
|
const row = db.prepare('PRAGMA user_version').get();
|
|
91
92
|
db.close();
|
|
92
|
-
expect(row.user_version).toBe(
|
|
93
|
+
expect(row.user_version).toBe(10);
|
|
93
94
|
});
|
|
94
95
|
it('creates all required tables', () => {
|
|
95
96
|
const db = new DatabaseSync(dbPath);
|
|
@@ -103,6 +104,8 @@ describe('DB creation', () => {
|
|
|
103
104
|
expect(names).toContain('worker');
|
|
104
105
|
expect(names).toContain('event');
|
|
105
106
|
expect(names).toContain('pipeline');
|
|
107
|
+
expect(names).toContain('artifact');
|
|
108
|
+
expect(names).toContain('agent_identity');
|
|
106
109
|
});
|
|
107
110
|
it('reopening an existing DB does not reset schema version', () => {
|
|
108
111
|
store.close();
|
|
@@ -113,10 +116,9 @@ describe('DB creation', () => {
|
|
|
113
116
|
store2.close();
|
|
114
117
|
// Reassign so afterEach does not double-close
|
|
115
118
|
store = createConvoyStore(dbPath);
|
|
116
|
-
expect(row.user_version).toBe(
|
|
119
|
+
expect(row.user_version).toBe(10);
|
|
117
120
|
});
|
|
118
121
|
});
|
|
119
|
-
// ── schema migration ─────────────────────────────────────────────────────────
|
|
120
122
|
describe('schema migration', () => {
|
|
121
123
|
it('schema migration v1 to v2 adds adapter column', () => {
|
|
122
124
|
// Create a v1 database manually: task table without adapter column
|
|
@@ -187,8 +189,8 @@ describe('schema migration', () => {
|
|
|
187
189
|
const version = verifyDb.prepare('PRAGMA user_version').get();
|
|
188
190
|
verifyDb.close();
|
|
189
191
|
expect(cols.map(c => c.name)).toContain('adapter');
|
|
190
|
-
// v1 chains through v2→v3→v4 in one init, so final version is
|
|
191
|
-
expect(version.user_version).toBe(
|
|
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);
|
|
192
194
|
});
|
|
193
195
|
it('schema migration v2 to v3 adds cost columns', () => {
|
|
194
196
|
// Create a v2 database manually (has adapter column but no cost columns)
|
|
@@ -268,7 +270,7 @@ describe('schema migration', () => {
|
|
|
268
270
|
const convoyColNames = convoyCols.map(c => c.name);
|
|
269
271
|
expect(convoyColNames).toContain('total_tokens');
|
|
270
272
|
expect(convoyColNames).toContain('total_cost_usd');
|
|
271
|
-
expect(version.user_version).toBe(
|
|
273
|
+
expect(version.user_version).toBe(10);
|
|
272
274
|
});
|
|
273
275
|
it('schema migration v1 to v3 chains correctly in a single init', () => {
|
|
274
276
|
// Create a v1 database (task table without adapter or cost columns)
|
|
@@ -348,7 +350,7 @@ describe('schema migration', () => {
|
|
|
348
350
|
const convoyColNames = convoyCols.map(c => c.name);
|
|
349
351
|
expect(convoyColNames).toContain('total_tokens');
|
|
350
352
|
expect(convoyColNames).toContain('total_cost_usd');
|
|
351
|
-
expect(version.user_version).toBe(
|
|
353
|
+
expect(version.user_version).toBe(10);
|
|
352
354
|
});
|
|
353
355
|
it('schema migration v3 to v4 creates pipeline table and adds pipeline_id to convoy', () => {
|
|
354
356
|
const v3DbPath = join(tmpDir, 'v3.db');
|
|
@@ -427,7 +429,7 @@ describe('schema migration', () => {
|
|
|
427
429
|
verifyDb.close();
|
|
428
430
|
expect(convoyCols.map(c => c.name)).toContain('pipeline_id');
|
|
429
431
|
expect(tables.map(t => t.name)).toContain('pipeline');
|
|
430
|
-
expect(version.user_version).toBe(
|
|
432
|
+
expect(version.user_version).toBe(10);
|
|
431
433
|
});
|
|
432
434
|
});
|
|
433
435
|
// ── convoy CRUD ───────────────────────────────────────────────────────────────
|
|
@@ -478,11 +480,11 @@ describe('convoy CRUD', () => {
|
|
|
478
480
|
store.updateConvoyStatus('convoy-1', 'done', {
|
|
479
481
|
finished_at: '2026-01-01T01:00:00.000Z',
|
|
480
482
|
total_tokens: 5000,
|
|
481
|
-
total_cost_usd:
|
|
483
|
+
total_cost_usd: 0.015,
|
|
482
484
|
});
|
|
483
485
|
const retrieved = store.getConvoy('convoy-1');
|
|
484
486
|
expect(retrieved.total_tokens).toBe(5000);
|
|
485
|
-
expect(retrieved.total_cost_usd).toBe(
|
|
487
|
+
expect(retrieved.total_cost_usd).toBe(0.015);
|
|
486
488
|
});
|
|
487
489
|
});
|
|
488
490
|
// ── task CRUD ─────────────────────────────────────────────────────────────────
|
|
@@ -562,13 +564,13 @@ describe('task CRUD', () => {
|
|
|
562
564
|
prompt_tokens: 1200,
|
|
563
565
|
completion_tokens: 800,
|
|
564
566
|
total_tokens: 2000,
|
|
565
|
-
cost_usd:
|
|
567
|
+
cost_usd: 0.006,
|
|
566
568
|
});
|
|
567
569
|
const task = store.getTask('task-1', 'convoy-1');
|
|
568
570
|
expect(task.prompt_tokens).toBe(1200);
|
|
569
571
|
expect(task.completion_tokens).toBe(800);
|
|
570
572
|
expect(task.total_tokens).toBe(2000);
|
|
571
|
-
expect(task.cost_usd).toBe(
|
|
573
|
+
expect(task.cost_usd).toBe(0.006);
|
|
572
574
|
});
|
|
573
575
|
});
|
|
574
576
|
// ── getReadyTasks ─────────────────────────────────────────────────────────────
|
|
@@ -802,11 +804,11 @@ describe('pipeline CRUD', () => {
|
|
|
802
804
|
store.updatePipelineStatus('pipeline-1', 'done', {
|
|
803
805
|
finished_at: '2026-01-01T01:00:00.000Z',
|
|
804
806
|
total_tokens: 12000,
|
|
805
|
-
total_cost_usd:
|
|
807
|
+
total_cost_usd: 0.036,
|
|
806
808
|
});
|
|
807
809
|
const p = store.getPipeline('pipeline-1');
|
|
808
810
|
expect(p.total_tokens).toBe(12000);
|
|
809
|
-
expect(p.total_cost_usd).toBe(
|
|
811
|
+
expect(p.total_cost_usd).toBe(0.036);
|
|
810
812
|
});
|
|
811
813
|
it('pipeline status can transition through all states', () => {
|
|
812
814
|
store.insertPipeline(makePipeline());
|
|
@@ -873,4 +875,1794 @@ describe('close', () => {
|
|
|
873
875
|
expect(() => freshStore.close()).not.toThrow();
|
|
874
876
|
});
|
|
875
877
|
});
|
|
878
|
+
// ── v4 schema helper ──────────────────────────────────────────────────────────
|
|
879
|
+
function createV4Db(path) {
|
|
880
|
+
const db = new DatabaseSync(path);
|
|
881
|
+
db.exec(`
|
|
882
|
+
CREATE TABLE convoy (
|
|
883
|
+
id TEXT PRIMARY KEY,
|
|
884
|
+
name TEXT NOT NULL,
|
|
885
|
+
spec_hash TEXT NOT NULL,
|
|
886
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
887
|
+
branch TEXT,
|
|
888
|
+
created_at TEXT NOT NULL,
|
|
889
|
+
started_at TEXT,
|
|
890
|
+
finished_at TEXT,
|
|
891
|
+
spec_yaml TEXT NOT NULL,
|
|
892
|
+
total_tokens INTEGER,
|
|
893
|
+
total_cost_usd TEXT,
|
|
894
|
+
pipeline_id TEXT
|
|
895
|
+
);
|
|
896
|
+
CREATE TABLE pipeline (
|
|
897
|
+
id TEXT PRIMARY KEY,
|
|
898
|
+
name TEXT NOT NULL,
|
|
899
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
900
|
+
branch TEXT,
|
|
901
|
+
spec_yaml TEXT NOT NULL,
|
|
902
|
+
convoy_specs TEXT NOT NULL,
|
|
903
|
+
created_at TEXT NOT NULL,
|
|
904
|
+
started_at TEXT,
|
|
905
|
+
finished_at TEXT,
|
|
906
|
+
total_tokens INTEGER,
|
|
907
|
+
total_cost_usd TEXT
|
|
908
|
+
);
|
|
909
|
+
CREATE TABLE task (
|
|
910
|
+
id TEXT PRIMARY KEY,
|
|
911
|
+
convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
912
|
+
phase INTEGER NOT NULL,
|
|
913
|
+
prompt TEXT NOT NULL,
|
|
914
|
+
agent TEXT NOT NULL DEFAULT 'developer',
|
|
915
|
+
adapter TEXT,
|
|
916
|
+
model TEXT,
|
|
917
|
+
timeout_ms INTEGER NOT NULL DEFAULT 1800000,
|
|
918
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
919
|
+
worker_id TEXT,
|
|
920
|
+
worktree TEXT,
|
|
921
|
+
output TEXT,
|
|
922
|
+
exit_code INTEGER,
|
|
923
|
+
started_at TEXT,
|
|
924
|
+
finished_at TEXT,
|
|
925
|
+
retries INTEGER NOT NULL DEFAULT 0,
|
|
926
|
+
max_retries INTEGER NOT NULL DEFAULT 1,
|
|
927
|
+
files TEXT,
|
|
928
|
+
depends_on TEXT,
|
|
929
|
+
prompt_tokens INTEGER,
|
|
930
|
+
completion_tokens INTEGER,
|
|
931
|
+
total_tokens INTEGER,
|
|
932
|
+
cost_usd TEXT
|
|
933
|
+
);
|
|
934
|
+
CREATE TABLE worker (
|
|
935
|
+
id TEXT PRIMARY KEY,
|
|
936
|
+
task_id TEXT REFERENCES task(id),
|
|
937
|
+
adapter TEXT NOT NULL,
|
|
938
|
+
pid INTEGER,
|
|
939
|
+
session_id TEXT,
|
|
940
|
+
status TEXT NOT NULL DEFAULT 'spawned',
|
|
941
|
+
worktree TEXT,
|
|
942
|
+
created_at TEXT NOT NULL,
|
|
943
|
+
finished_at TEXT,
|
|
944
|
+
last_heartbeat TEXT
|
|
945
|
+
);
|
|
946
|
+
CREATE TABLE event (
|
|
947
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
948
|
+
convoy_id TEXT REFERENCES convoy(id),
|
|
949
|
+
task_id TEXT,
|
|
950
|
+
worker_id TEXT,
|
|
951
|
+
type TEXT NOT NULL,
|
|
952
|
+
data TEXT,
|
|
953
|
+
created_at TEXT NOT NULL
|
|
954
|
+
);
|
|
955
|
+
`);
|
|
956
|
+
db.exec('PRAGMA user_version = 4');
|
|
957
|
+
return db;
|
|
958
|
+
}
|
|
959
|
+
// ── schema migration v4 → v5 ──────────────────────────────────────────────────
|
|
960
|
+
describe('schema migration v4 → v5', () => {
|
|
961
|
+
it('happy path: migrates from v4 to v5 and sets user_version to 5', () => {
|
|
962
|
+
const v4DbPath = join(tmpDir, 'v4-happy.db');
|
|
963
|
+
const db = createV4Db(v4DbPath);
|
|
964
|
+
migrateSchema(db, v4DbPath, 4, 5);
|
|
965
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
966
|
+
db.close();
|
|
967
|
+
expect(row.user_version).toBe(5);
|
|
968
|
+
});
|
|
969
|
+
it('new task columns exist after migration', () => {
|
|
970
|
+
const v4DbPath = join(tmpDir, 'v4-task-cols.db');
|
|
971
|
+
const db = createV4Db(v4DbPath);
|
|
972
|
+
migrateSchema(db, v4DbPath, 4, 5);
|
|
973
|
+
const cols = db.prepare('PRAGMA table_info(task)').all();
|
|
974
|
+
db.close();
|
|
975
|
+
const names = cols.map(c => c.name);
|
|
976
|
+
expect(names).toContain('gates');
|
|
977
|
+
expect(names).toContain('on_exhausted');
|
|
978
|
+
expect(names).toContain('injected');
|
|
979
|
+
expect(names).toContain('provenance');
|
|
980
|
+
expect(names).toContain('idempotency_key');
|
|
981
|
+
});
|
|
982
|
+
it('circuit_state column exists on convoy after migration', () => {
|
|
983
|
+
const v4DbPath = join(tmpDir, 'v4-convoy-col.db');
|
|
984
|
+
const db = createV4Db(v4DbPath);
|
|
985
|
+
migrateSchema(db, v4DbPath, 4, 5);
|
|
986
|
+
const cols = db.prepare('PRAGMA table_info(convoy)').all();
|
|
987
|
+
db.close();
|
|
988
|
+
expect(cols.map(c => c.name)).toContain('circuit_state');
|
|
989
|
+
});
|
|
990
|
+
it('dlq table created with correct columns after migration', () => {
|
|
991
|
+
const v4DbPath = join(tmpDir, 'v4-dlq.db');
|
|
992
|
+
const db = createV4Db(v4DbPath);
|
|
993
|
+
migrateSchema(db, v4DbPath, 4, 5);
|
|
994
|
+
const tables = db
|
|
995
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
|
996
|
+
.all();
|
|
997
|
+
const dlqCols = db.prepare('PRAGMA table_info(dlq)').all();
|
|
998
|
+
db.close();
|
|
999
|
+
expect(tables.map(t => t.name)).toContain('dlq');
|
|
1000
|
+
const colNames = dlqCols.map(c => c.name);
|
|
1001
|
+
expect(colNames).toContain('id');
|
|
1002
|
+
expect(colNames).toContain('convoy_id');
|
|
1003
|
+
expect(colNames).toContain('task_id');
|
|
1004
|
+
expect(colNames).toContain('agent');
|
|
1005
|
+
expect(colNames).toContain('failure_type');
|
|
1006
|
+
expect(colNames).toContain('error_output');
|
|
1007
|
+
expect(colNames).toContain('attempts');
|
|
1008
|
+
expect(colNames).toContain('tokens_spent');
|
|
1009
|
+
expect(colNames).toContain('resolved');
|
|
1010
|
+
expect(colNames).toContain('resolution');
|
|
1011
|
+
expect(colNames).toContain('created_at');
|
|
1012
|
+
expect(colNames).toContain('resolved_at');
|
|
1013
|
+
});
|
|
1014
|
+
it('idx_task_idempotency partial unique index created after migration', () => {
|
|
1015
|
+
const v4DbPath = join(tmpDir, 'v4-index.db');
|
|
1016
|
+
const db = createV4Db(v4DbPath);
|
|
1017
|
+
migrateSchema(db, v4DbPath, 4, 5);
|
|
1018
|
+
const indexes = db
|
|
1019
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_task_idempotency'")
|
|
1020
|
+
.all();
|
|
1021
|
+
db.close();
|
|
1022
|
+
expect(indexes).toHaveLength(1);
|
|
1023
|
+
expect(indexes[0].name).toBe('idx_task_idempotency');
|
|
1024
|
+
});
|
|
1025
|
+
it('existing data intact after migration', () => {
|
|
1026
|
+
const v4DbPath = join(tmpDir, 'v4-data.db');
|
|
1027
|
+
const db = createV4Db(v4DbPath);
|
|
1028
|
+
db.prepare(`INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, spec_yaml)
|
|
1029
|
+
VALUES ('convoy-test', 'Test', 'hash', 'pending', NULL, '2026-01-01T00:00:00.000Z', 'name: test')`).run();
|
|
1030
|
+
db.prepare(`INSERT INTO task (id, convoy_id, phase, prompt, agent, timeout_ms, status, retries, max_retries)
|
|
1031
|
+
VALUES ('task-test', 'convoy-test', 0, 'Do something', 'developer', 1800000, 'pending', 0, 1)`).run();
|
|
1032
|
+
migrateSchema(db, v4DbPath, 4, 5);
|
|
1033
|
+
const convoyCount = db.prepare('SELECT COUNT(*) as cnt FROM convoy').get();
|
|
1034
|
+
const taskCount = db.prepare('SELECT COUNT(*) as cnt FROM task').get();
|
|
1035
|
+
const convoy = db.prepare('SELECT id FROM convoy WHERE id = :id').get({ id: 'convoy-test' });
|
|
1036
|
+
db.close();
|
|
1037
|
+
expect(convoyCount.cnt).toBe(1);
|
|
1038
|
+
expect(taskCount.cnt).toBe(1);
|
|
1039
|
+
expect(convoy.id).toBe('convoy-test');
|
|
1040
|
+
});
|
|
1041
|
+
it('backup file created before migration', () => {
|
|
1042
|
+
const v4DbPath = join(tmpDir, 'v4-backup.db');
|
|
1043
|
+
const db = createV4Db(v4DbPath);
|
|
1044
|
+
migrateSchema(db, v4DbPath, 4, 5);
|
|
1045
|
+
db.close();
|
|
1046
|
+
expect(existsSync(`${v4DbPath}.v4.bak`)).toBe(true);
|
|
1047
|
+
});
|
|
1048
|
+
it('failure mode: rolls back on error, version stays at 4 and backup exists', () => {
|
|
1049
|
+
const v4DbPath = join(tmpDir, 'v4-fail.db');
|
|
1050
|
+
const db = createV4Db(v4DbPath);
|
|
1051
|
+
// Pre-add gates column so the first ALTER in migration will fail with duplicate column
|
|
1052
|
+
db.exec('ALTER TABLE task ADD COLUMN gates TEXT');
|
|
1053
|
+
expect(() => migrateSchema(db, v4DbPath, 4, 5)).toThrow();
|
|
1054
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
1055
|
+
expect(row.user_version).toBe(4);
|
|
1056
|
+
expect(existsSync(`${v4DbPath}.v4.bak`)).toBe(true);
|
|
1057
|
+
db.close();
|
|
1058
|
+
});
|
|
1059
|
+
it('rolls back and preserves backup on mid-migration failure', () => {
|
|
1060
|
+
const v4Path = join(tmpDir, 'v4-fail-rb.db');
|
|
1061
|
+
const v4db = createV4Db(v4Path);
|
|
1062
|
+
// Insert a test row to verify data integrity after rollback
|
|
1063
|
+
v4db
|
|
1064
|
+
.prepare("INSERT INTO convoy (id, name, spec_hash, status, created_at, spec_yaml) VALUES ('test-c', 'Test', 'hash', 'pending', '2026-01-01', 'yaml')")
|
|
1065
|
+
.run();
|
|
1066
|
+
// Sabotage: add a column that migration v4→v5 will try to add, causing it to fail
|
|
1067
|
+
v4db.exec('ALTER TABLE task ADD COLUMN gates TEXT');
|
|
1068
|
+
// Attempt migration — should fail because 'gates' column already exists
|
|
1069
|
+
expect(() => migrateSchema(v4db, v4Path, 4, 5)).toThrow(/Migration v4→v5 failed/);
|
|
1070
|
+
// Verify: user_version unchanged (still 4)
|
|
1071
|
+
const version = v4db.prepare('PRAGMA user_version').get().user_version;
|
|
1072
|
+
expect(version).toBe(4);
|
|
1073
|
+
// Verify: backup file exists and is a valid SQLite database
|
|
1074
|
+
const backupPath = `${v4Path}.v4.bak`;
|
|
1075
|
+
expect(existsSync(backupPath)).toBe(true);
|
|
1076
|
+
const backupDb = new DatabaseSync(backupPath);
|
|
1077
|
+
const backupRow = backupDb
|
|
1078
|
+
.prepare("SELECT id FROM convoy WHERE id = 'test-c'")
|
|
1079
|
+
.get();
|
|
1080
|
+
expect(backupRow?.id).toBe('test-c');
|
|
1081
|
+
backupDb.close();
|
|
1082
|
+
// Verify: original data intact in main DB (rollback preserved it)
|
|
1083
|
+
const origRow = v4db
|
|
1084
|
+
.prepare("SELECT id FROM convoy WHERE id = 'test-c'")
|
|
1085
|
+
.get();
|
|
1086
|
+
expect(origRow?.id).toBe('test-c');
|
|
1087
|
+
v4db.close();
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
// ── helper: build a v5 database ───────────────────────────────────────────────
|
|
1091
|
+
function createV5Db(path) {
|
|
1092
|
+
// v5 = v4 schema + gates/on_exhausted/injected/provenance/idempotency_key on task
|
|
1093
|
+
// + circuit_state on convoy + dlq table
|
|
1094
|
+
const db = new DatabaseSync(path);
|
|
1095
|
+
db.exec(`
|
|
1096
|
+
CREATE TABLE convoy (
|
|
1097
|
+
id TEXT PRIMARY KEY,
|
|
1098
|
+
name TEXT NOT NULL,
|
|
1099
|
+
spec_hash TEXT NOT NULL,
|
|
1100
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1101
|
+
branch TEXT,
|
|
1102
|
+
created_at TEXT NOT NULL,
|
|
1103
|
+
started_at TEXT,
|
|
1104
|
+
finished_at TEXT,
|
|
1105
|
+
spec_yaml TEXT NOT NULL,
|
|
1106
|
+
total_tokens INTEGER,
|
|
1107
|
+
total_cost_usd TEXT,
|
|
1108
|
+
pipeline_id TEXT,
|
|
1109
|
+
circuit_state TEXT
|
|
1110
|
+
);
|
|
1111
|
+
CREATE TABLE pipeline (
|
|
1112
|
+
id TEXT PRIMARY KEY,
|
|
1113
|
+
name TEXT NOT NULL,
|
|
1114
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1115
|
+
branch TEXT,
|
|
1116
|
+
spec_yaml TEXT NOT NULL,
|
|
1117
|
+
convoy_specs TEXT NOT NULL,
|
|
1118
|
+
created_at TEXT NOT NULL,
|
|
1119
|
+
started_at TEXT,
|
|
1120
|
+
finished_at TEXT,
|
|
1121
|
+
total_tokens INTEGER,
|
|
1122
|
+
total_cost_usd TEXT
|
|
1123
|
+
);
|
|
1124
|
+
CREATE TABLE task (
|
|
1125
|
+
id TEXT PRIMARY KEY,
|
|
1126
|
+
convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
1127
|
+
phase INTEGER NOT NULL,
|
|
1128
|
+
prompt TEXT NOT NULL,
|
|
1129
|
+
agent TEXT NOT NULL DEFAULT 'developer',
|
|
1130
|
+
adapter TEXT,
|
|
1131
|
+
model TEXT,
|
|
1132
|
+
timeout_ms INTEGER NOT NULL DEFAULT 1800000,
|
|
1133
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1134
|
+
worker_id TEXT,
|
|
1135
|
+
worktree TEXT,
|
|
1136
|
+
output TEXT,
|
|
1137
|
+
exit_code INTEGER,
|
|
1138
|
+
started_at TEXT,
|
|
1139
|
+
finished_at TEXT,
|
|
1140
|
+
retries INTEGER NOT NULL DEFAULT 0,
|
|
1141
|
+
max_retries INTEGER NOT NULL DEFAULT 1,
|
|
1142
|
+
files TEXT,
|
|
1143
|
+
depends_on TEXT,
|
|
1144
|
+
prompt_tokens INTEGER,
|
|
1145
|
+
completion_tokens INTEGER,
|
|
1146
|
+
total_tokens INTEGER,
|
|
1147
|
+
cost_usd TEXT,
|
|
1148
|
+
gates TEXT,
|
|
1149
|
+
on_exhausted TEXT NOT NULL DEFAULT 'dlq',
|
|
1150
|
+
injected INTEGER NOT NULL DEFAULT 0,
|
|
1151
|
+
provenance TEXT,
|
|
1152
|
+
idempotency_key TEXT
|
|
1153
|
+
);
|
|
1154
|
+
CREATE UNIQUE INDEX idx_task_idempotency ON task(convoy_id, idempotency_key)
|
|
1155
|
+
WHERE idempotency_key IS NOT NULL;
|
|
1156
|
+
CREATE TABLE worker (
|
|
1157
|
+
id TEXT PRIMARY KEY,
|
|
1158
|
+
task_id TEXT REFERENCES task(id),
|
|
1159
|
+
adapter TEXT NOT NULL,
|
|
1160
|
+
pid INTEGER,
|
|
1161
|
+
session_id TEXT,
|
|
1162
|
+
status TEXT NOT NULL DEFAULT 'spawned',
|
|
1163
|
+
worktree TEXT,
|
|
1164
|
+
created_at TEXT NOT NULL,
|
|
1165
|
+
finished_at TEXT,
|
|
1166
|
+
last_heartbeat TEXT
|
|
1167
|
+
);
|
|
1168
|
+
CREATE TABLE event (
|
|
1169
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1170
|
+
convoy_id TEXT REFERENCES convoy(id),
|
|
1171
|
+
task_id TEXT,
|
|
1172
|
+
worker_id TEXT,
|
|
1173
|
+
type TEXT NOT NULL,
|
|
1174
|
+
data TEXT,
|
|
1175
|
+
created_at TEXT NOT NULL
|
|
1176
|
+
);
|
|
1177
|
+
CREATE TABLE dlq (
|
|
1178
|
+
id TEXT PRIMARY KEY,
|
|
1179
|
+
convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
1180
|
+
task_id TEXT NOT NULL REFERENCES task(id),
|
|
1181
|
+
agent TEXT NOT NULL,
|
|
1182
|
+
failure_type TEXT NOT NULL,
|
|
1183
|
+
error_output TEXT,
|
|
1184
|
+
attempts INTEGER NOT NULL,
|
|
1185
|
+
tokens_spent INTEGER,
|
|
1186
|
+
escalation_task_id TEXT,
|
|
1187
|
+
resolved INTEGER NOT NULL DEFAULT 0,
|
|
1188
|
+
resolution TEXT,
|
|
1189
|
+
created_at TEXT NOT NULL,
|
|
1190
|
+
resolved_at TEXT
|
|
1191
|
+
);
|
|
1192
|
+
`);
|
|
1193
|
+
db.exec('PRAGMA user_version = 5');
|
|
1194
|
+
return db;
|
|
1195
|
+
}
|
|
1196
|
+
// ── schema migration v5 → v6 ──────────────────────────────────────────────────
|
|
1197
|
+
describe('schema migration v5 → v6', () => {
|
|
1198
|
+
it('happy path: migrates from v5 to v6 and sets user_version to 6', () => {
|
|
1199
|
+
const v5DbPath = join(tmpDir, 'v5-happy.db');
|
|
1200
|
+
const db = createV5Db(v5DbPath);
|
|
1201
|
+
migrateSchema(db, v5DbPath, 5, 6);
|
|
1202
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
1203
|
+
db.close();
|
|
1204
|
+
expect(row.user_version).toBe(6);
|
|
1205
|
+
});
|
|
1206
|
+
it('task_step table created with correct columns after migration', () => {
|
|
1207
|
+
const v5DbPath = join(tmpDir, 'v5-task-step.db');
|
|
1208
|
+
const db = createV5Db(v5DbPath);
|
|
1209
|
+
migrateSchema(db, v5DbPath, 5, 6);
|
|
1210
|
+
const tables = db
|
|
1211
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
|
1212
|
+
.all();
|
|
1213
|
+
const stepCols = db.prepare('PRAGMA table_info(task_step)').all();
|
|
1214
|
+
db.close();
|
|
1215
|
+
expect(tables.map(t => t.name)).toContain('task_step');
|
|
1216
|
+
const colNames = stepCols.map(c => c.name);
|
|
1217
|
+
expect(colNames).toContain('id');
|
|
1218
|
+
expect(colNames).toContain('task_id');
|
|
1219
|
+
expect(colNames).toContain('step_index');
|
|
1220
|
+
expect(colNames).toContain('prompt');
|
|
1221
|
+
expect(colNames).toContain('gates');
|
|
1222
|
+
expect(colNames).toContain('status');
|
|
1223
|
+
expect(colNames).toContain('exit_code');
|
|
1224
|
+
expect(colNames).toContain('output');
|
|
1225
|
+
expect(colNames).toContain('started_at');
|
|
1226
|
+
expect(colNames).toContain('finished_at');
|
|
1227
|
+
});
|
|
1228
|
+
it('new task columns added after migration', () => {
|
|
1229
|
+
const v5DbPath = join(tmpDir, 'v5-task-cols.db');
|
|
1230
|
+
const db = createV5Db(v5DbPath);
|
|
1231
|
+
migrateSchema(db, v5DbPath, 5, 6);
|
|
1232
|
+
const cols = db.prepare('PRAGMA table_info(task)').all();
|
|
1233
|
+
db.close();
|
|
1234
|
+
const names = cols.map(c => c.name);
|
|
1235
|
+
expect(names).toContain('current_step');
|
|
1236
|
+
expect(names).toContain('total_steps');
|
|
1237
|
+
expect(names).toContain('review_level');
|
|
1238
|
+
expect(names).toContain('review_verdict');
|
|
1239
|
+
expect(names).toContain('review_tokens');
|
|
1240
|
+
expect(names).toContain('review_model');
|
|
1241
|
+
expect(names).toContain('panel_attempts');
|
|
1242
|
+
expect(names).toContain('dispute_id');
|
|
1243
|
+
});
|
|
1244
|
+
it('new convoy columns added after migration', () => {
|
|
1245
|
+
const v5DbPath = join(tmpDir, 'v5-convoy-cols.db');
|
|
1246
|
+
const db = createV5Db(v5DbPath);
|
|
1247
|
+
migrateSchema(db, v5DbPath, 5, 6);
|
|
1248
|
+
const cols = db.prepare('PRAGMA table_info(convoy)').all();
|
|
1249
|
+
db.close();
|
|
1250
|
+
const names = cols.map(c => c.name);
|
|
1251
|
+
expect(names).toContain('review_tokens_total');
|
|
1252
|
+
expect(names).toContain('review_budget');
|
|
1253
|
+
});
|
|
1254
|
+
it('existing data survives migration intact', () => {
|
|
1255
|
+
const v5DbPath = join(tmpDir, 'v5-data.db');
|
|
1256
|
+
const db = createV5Db(v5DbPath);
|
|
1257
|
+
db.prepare(`INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, spec_yaml)
|
|
1258
|
+
VALUES ('convoy-v5', 'Test V5', 'hash5', 'pending', NULL, '2026-01-01T00:00:00.000Z', 'name: test')`).run();
|
|
1259
|
+
db.prepare(`INSERT INTO task (id, convoy_id, phase, prompt, agent, timeout_ms, status, retries, max_retries)
|
|
1260
|
+
VALUES ('task-v5', 'convoy-v5', 0, 'Do something', 'developer', 1800000, 'pending', 0, 1)`).run();
|
|
1261
|
+
migrateSchema(db, v5DbPath, 5, 6);
|
|
1262
|
+
const convoyCount = db.prepare('SELECT COUNT(*) as cnt FROM convoy').get();
|
|
1263
|
+
const taskCount = db.prepare('SELECT COUNT(*) as cnt FROM task').get();
|
|
1264
|
+
const convoy = db.prepare('SELECT id FROM convoy WHERE id = :id').get({ id: 'convoy-v5' });
|
|
1265
|
+
const task = db.prepare('SELECT id FROM task WHERE id = :id').get({ id: 'task-v5' });
|
|
1266
|
+
db.close();
|
|
1267
|
+
expect(convoyCount.cnt).toBe(1);
|
|
1268
|
+
expect(taskCount.cnt).toBe(1);
|
|
1269
|
+
expect(convoy.id).toBe('convoy-v5');
|
|
1270
|
+
expect(task.id).toBe('task-v5');
|
|
1271
|
+
});
|
|
1272
|
+
it('backup file created before migration', () => {
|
|
1273
|
+
const v5DbPath = join(tmpDir, 'v5-backup.db');
|
|
1274
|
+
const db = createV5Db(v5DbPath);
|
|
1275
|
+
migrateSchema(db, v5DbPath, 5, 6);
|
|
1276
|
+
db.close();
|
|
1277
|
+
expect(existsSync(`${v5DbPath}.v5.bak`)).toBe(true);
|
|
1278
|
+
});
|
|
1279
|
+
it('createConvoyStore on v5 database auto-migrates to v6', () => {
|
|
1280
|
+
const v5DbPath = join(tmpDir, 'v5-auto.db');
|
|
1281
|
+
const v5Db = createV5Db(v5DbPath);
|
|
1282
|
+
v5Db.prepare(`INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, spec_yaml)
|
|
1283
|
+
VALUES ('convoy-auto', 'Auto', 'hash', 'pending', NULL, '2026-01-01T00:00:00.000Z', 'name: auto')`).run();
|
|
1284
|
+
v5Db.prepare(`INSERT INTO task (id, convoy_id, phase, prompt, agent, timeout_ms, status, retries, max_retries)
|
|
1285
|
+
VALUES ('task-auto', 'convoy-auto', 0, 'Do it', 'developer', 1800000, 'pending', 0, 1)`).run();
|
|
1286
|
+
v5Db.close();
|
|
1287
|
+
const migratedStore = createConvoyStore(v5DbPath);
|
|
1288
|
+
const v5Verify = new DatabaseSync(v5DbPath);
|
|
1289
|
+
const row = v5Verify.prepare('PRAGMA user_version').get();
|
|
1290
|
+
const taskStepTable = v5Verify.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='task_step'").get();
|
|
1291
|
+
const convoy = migratedStore.getConvoy('convoy-auto');
|
|
1292
|
+
const task = migratedStore.getTask('task-auto', 'convoy-auto');
|
|
1293
|
+
v5Verify.close();
|
|
1294
|
+
migratedStore.close();
|
|
1295
|
+
expect(row.user_version).toBe(10);
|
|
1296
|
+
expect(taskStepTable?.name).toBe('task_step');
|
|
1297
|
+
expect(convoy?.id).toBe('convoy-auto');
|
|
1298
|
+
expect(task?.id).toBe('task-auto');
|
|
1299
|
+
});
|
|
1300
|
+
it('failure mode: rolls back on error and version stays at 5', () => {
|
|
1301
|
+
const v5DbPath = join(tmpDir, 'v5-fail.db');
|
|
1302
|
+
const db = createV5Db(v5DbPath);
|
|
1303
|
+
// Pre-add current_step to trigger duplicate column error
|
|
1304
|
+
db.exec('ALTER TABLE task ADD COLUMN current_step INTEGER');
|
|
1305
|
+
expect(() => migrateSchema(db, v5DbPath, 5, 6)).toThrow();
|
|
1306
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
1307
|
+
expect(row.user_version).toBe(5);
|
|
1308
|
+
expect(existsSync(`${v5DbPath}.v5.bak`)).toBe(true);
|
|
1309
|
+
db.close();
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
// ── updateTaskReview ──────────────────────────────────────────────────────────
|
|
1313
|
+
describe('updateTaskReview', () => {
|
|
1314
|
+
beforeEach(() => {
|
|
1315
|
+
store.insertConvoy(makeConvoy());
|
|
1316
|
+
store.insertTask(makeTask());
|
|
1317
|
+
});
|
|
1318
|
+
it('persists review_level and review_verdict', () => {
|
|
1319
|
+
store.updateTaskReview('task-1', 'convoy-1', {
|
|
1320
|
+
review_level: 'fast',
|
|
1321
|
+
review_verdict: 'pass',
|
|
1322
|
+
});
|
|
1323
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1324
|
+
expect(task.review_level).toBe('fast');
|
|
1325
|
+
expect(task.review_verdict).toBe('pass');
|
|
1326
|
+
});
|
|
1327
|
+
it('persists review_tokens and review_model', () => {
|
|
1328
|
+
store.updateTaskReview('task-1', 'convoy-1', {
|
|
1329
|
+
review_tokens: 123,
|
|
1330
|
+
review_model: 'gpt-4',
|
|
1331
|
+
});
|
|
1332
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1333
|
+
expect(task.review_tokens).toBe(123);
|
|
1334
|
+
expect(task.review_model).toBe('gpt-4');
|
|
1335
|
+
});
|
|
1336
|
+
it('increments panel_attempts correctly', () => {
|
|
1337
|
+
store.updateTaskReview('task-1', 'convoy-1', { panel_attempts: 1 });
|
|
1338
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1339
|
+
expect(task.panel_attempts).toBe(1);
|
|
1340
|
+
});
|
|
1341
|
+
it('persists dispute_id', () => {
|
|
1342
|
+
store.updateTaskReview('task-1', 'convoy-1', { dispute_id: 'dispute-42' });
|
|
1343
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1344
|
+
expect(task.dispute_id).toBe('dispute-42');
|
|
1345
|
+
});
|
|
1346
|
+
it('is a no-op when updates is empty', () => {
|
|
1347
|
+
store.updateTaskReview('task-1', 'convoy-1', {});
|
|
1348
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1349
|
+
expect(task.review_level).toBeNull();
|
|
1350
|
+
});
|
|
1351
|
+
it('does not throw for non-existent task', () => {
|
|
1352
|
+
expect(() => store.updateTaskReview('ghost', 'convoy-1', { review_level: 'fast' })).not.toThrow();
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
// ── updateConvoyReviewTokens ──────────────────────────────────────────────────
|
|
1356
|
+
describe('updateConvoyReviewTokens', () => {
|
|
1357
|
+
beforeEach(() => {
|
|
1358
|
+
store.insertConvoy(makeConvoy());
|
|
1359
|
+
});
|
|
1360
|
+
it('sets review_tokens_total on the convoy', () => {
|
|
1361
|
+
store.updateConvoyReviewTokens('convoy-1', 500);
|
|
1362
|
+
const convoy = store.getConvoy('convoy-1');
|
|
1363
|
+
expect(convoy.review_tokens_total).toBe(500);
|
|
1364
|
+
});
|
|
1365
|
+
it('overwrites with updated total on subsequent calls', () => {
|
|
1366
|
+
store.updateConvoyReviewTokens('convoy-1', 100);
|
|
1367
|
+
store.updateConvoyReviewTokens('convoy-1', 350);
|
|
1368
|
+
const convoy = store.getConvoy('convoy-1');
|
|
1369
|
+
expect(convoy.review_tokens_total).toBe(350);
|
|
1370
|
+
});
|
|
1371
|
+
it('review_tokens_total starts as null before any call', () => {
|
|
1372
|
+
const convoy = store.getConvoy('convoy-1');
|
|
1373
|
+
expect(convoy.review_tokens_total).toBeNull();
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
// ── schema migration v6→v7 ────────────────────────────────────────────────────
|
|
1377
|
+
describe('schema migration v6→v7 (drift detection columns)', () => {
|
|
1378
|
+
function createV6Db(dbPath) {
|
|
1379
|
+
const db = new DatabaseSync(dbPath);
|
|
1380
|
+
db.exec(`
|
|
1381
|
+
CREATE TABLE convoy (
|
|
1382
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, spec_hash TEXT NOT NULL,
|
|
1383
|
+
status TEXT NOT NULL DEFAULT 'pending', branch TEXT, created_at TEXT NOT NULL,
|
|
1384
|
+
started_at TEXT, finished_at TEXT, spec_yaml TEXT NOT NULL,
|
|
1385
|
+
total_tokens INTEGER, total_cost_usd TEXT, pipeline_id TEXT,
|
|
1386
|
+
circuit_state TEXT, review_tokens_total INTEGER, review_budget INTEGER
|
|
1387
|
+
);
|
|
1388
|
+
CREATE TABLE task (
|
|
1389
|
+
id TEXT PRIMARY KEY, convoy_id TEXT NOT NULL, phase INTEGER NOT NULL,
|
|
1390
|
+
prompt TEXT NOT NULL, agent TEXT NOT NULL DEFAULT 'developer', adapter TEXT,
|
|
1391
|
+
model TEXT, timeout_ms INTEGER NOT NULL DEFAULT 1800000,
|
|
1392
|
+
status TEXT NOT NULL DEFAULT 'pending', worker_id TEXT, worktree TEXT,
|
|
1393
|
+
output TEXT, exit_code INTEGER, started_at TEXT, finished_at TEXT,
|
|
1394
|
+
retries INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 1,
|
|
1395
|
+
files TEXT, depends_on TEXT, prompt_tokens INTEGER, completion_tokens INTEGER,
|
|
1396
|
+
total_tokens INTEGER, cost_usd TEXT, gates TEXT,
|
|
1397
|
+
on_exhausted TEXT NOT NULL DEFAULT 'dlq', injected INTEGER NOT NULL DEFAULT 0,
|
|
1398
|
+
provenance TEXT, idempotency_key TEXT, current_step INTEGER, total_steps INTEGER,
|
|
1399
|
+
review_level TEXT, review_verdict TEXT, review_tokens INTEGER, review_model TEXT,
|
|
1400
|
+
panel_attempts INTEGER NOT NULL DEFAULT 0, dispute_id TEXT
|
|
1401
|
+
);
|
|
1402
|
+
CREATE TABLE worker (
|
|
1403
|
+
id TEXT PRIMARY KEY, task_id TEXT, adapter TEXT NOT NULL, pid INTEGER,
|
|
1404
|
+
session_id TEXT, status TEXT NOT NULL DEFAULT 'spawned', worktree TEXT,
|
|
1405
|
+
created_at TEXT NOT NULL, finished_at TEXT, last_heartbeat TEXT
|
|
1406
|
+
);
|
|
1407
|
+
CREATE TABLE event (
|
|
1408
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, convoy_id TEXT, task_id TEXT,
|
|
1409
|
+
worker_id TEXT, type TEXT NOT NULL, data TEXT, created_at TEXT NOT NULL
|
|
1410
|
+
);
|
|
1411
|
+
CREATE TABLE task_step (
|
|
1412
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, step_index INTEGER NOT NULL,
|
|
1413
|
+
prompt TEXT NOT NULL, gates TEXT, status TEXT NOT NULL DEFAULT 'pending',
|
|
1414
|
+
exit_code INTEGER, output TEXT, started_at TEXT, finished_at TEXT
|
|
1415
|
+
);
|
|
1416
|
+
CREATE TABLE dlq (
|
|
1417
|
+
id TEXT PRIMARY KEY, convoy_id TEXT NOT NULL, task_id TEXT NOT NULL,
|
|
1418
|
+
agent TEXT NOT NULL, failure_type TEXT NOT NULL, error_output TEXT,
|
|
1419
|
+
attempts INTEGER NOT NULL, tokens_spent INTEGER, escalation_task_id TEXT,
|
|
1420
|
+
resolved INTEGER NOT NULL DEFAULT 0, resolution TEXT, created_at TEXT NOT NULL,
|
|
1421
|
+
resolved_at TEXT
|
|
1422
|
+
);
|
|
1423
|
+
CREATE TABLE pipeline (
|
|
1424
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
|
|
1425
|
+
branch TEXT, spec_yaml TEXT NOT NULL, convoy_specs TEXT NOT NULL,
|
|
1426
|
+
created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT,
|
|
1427
|
+
total_tokens INTEGER, total_cost_usd TEXT
|
|
1428
|
+
);
|
|
1429
|
+
`);
|
|
1430
|
+
db.exec('PRAGMA user_version = 6');
|
|
1431
|
+
return db;
|
|
1432
|
+
}
|
|
1433
|
+
it('migration v6→v7 adds drift_score and drift_retried columns to task table', () => {
|
|
1434
|
+
const v6DbPath = join(tmpDir, 'v6.db');
|
|
1435
|
+
const db = createV6Db(v6DbPath);
|
|
1436
|
+
db.close();
|
|
1437
|
+
const migratedStore = createConvoyStore(v6DbPath);
|
|
1438
|
+
migratedStore.close();
|
|
1439
|
+
const verifyDb = new DatabaseSync(v6DbPath);
|
|
1440
|
+
const cols = verifyDb.prepare('PRAGMA table_info(task)').all();
|
|
1441
|
+
const version = verifyDb.prepare('PRAGMA user_version').get();
|
|
1442
|
+
verifyDb.close();
|
|
1443
|
+
expect(cols.map(c => c.name)).toContain('drift_score');
|
|
1444
|
+
expect(cols.map(c => c.name)).toContain('drift_retried');
|
|
1445
|
+
expect(version.user_version).toBe(10);
|
|
1446
|
+
});
|
|
1447
|
+
it('new databases include drift_score and drift_retried in CREATE TABLE', () => {
|
|
1448
|
+
const cols = new DatabaseSync(dbPath)
|
|
1449
|
+
.prepare('PRAGMA table_info(task)')
|
|
1450
|
+
.all();
|
|
1451
|
+
const driftScore = cols.find(c => c.name === 'drift_score');
|
|
1452
|
+
const driftRetried = cols.find(c => c.name === 'drift_retried');
|
|
1453
|
+
expect(driftScore).toBeDefined();
|
|
1454
|
+
expect(driftRetried).toBeDefined();
|
|
1455
|
+
});
|
|
1456
|
+
it('failure mode: rolls back on error, version stays at 6 and backup exists', () => {
|
|
1457
|
+
const v6DbPath = join(tmpDir, 'v6-fail.db');
|
|
1458
|
+
const db = createV6Db(v6DbPath);
|
|
1459
|
+
// Pre-add drift_score to cause duplicate column error on migration
|
|
1460
|
+
db.exec('ALTER TABLE task ADD COLUMN drift_score REAL');
|
|
1461
|
+
expect(() => migrateSchema(db, v6DbPath, 6, 7)).toThrow();
|
|
1462
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
1463
|
+
expect(row.user_version).toBe(6);
|
|
1464
|
+
expect(existsSync(`${v6DbPath}.v6.bak`)).toBe(true);
|
|
1465
|
+
db.close();
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1468
|
+
// ── updateTaskDrift ───────────────────────────────────────────────────────────
|
|
1469
|
+
describe('updateTaskDrift', () => {
|
|
1470
|
+
beforeEach(() => {
|
|
1471
|
+
store.insertConvoy(makeConvoy());
|
|
1472
|
+
store.insertTask(makeTask());
|
|
1473
|
+
});
|
|
1474
|
+
it('sets drift_score on the task', () => {
|
|
1475
|
+
store.updateTaskDrift('task-1', 'convoy-1', { drift_score: 0.72 });
|
|
1476
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1477
|
+
expect(task.drift_score).toBeCloseTo(0.72);
|
|
1478
|
+
});
|
|
1479
|
+
it('sets drift_retried on the task', () => {
|
|
1480
|
+
store.updateTaskDrift('task-1', 'convoy-1', { drift_retried: 1 });
|
|
1481
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1482
|
+
expect(task.drift_retried).toBe(1);
|
|
1483
|
+
});
|
|
1484
|
+
it('updates both fields at once', () => {
|
|
1485
|
+
store.updateTaskDrift('task-1', 'convoy-1', { drift_score: 0.4, drift_retried: 1 });
|
|
1486
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1487
|
+
expect(task.drift_score).toBeCloseTo(0.4);
|
|
1488
|
+
expect(task.drift_retried).toBe(1);
|
|
1489
|
+
});
|
|
1490
|
+
it('no-op when called with empty updates', () => {
|
|
1491
|
+
const before = store.getTask('task-1', 'convoy-1');
|
|
1492
|
+
expect(() => store.updateTaskDrift('task-1', 'convoy-1', {})).not.toThrow();
|
|
1493
|
+
const after = store.getTask('task-1', 'convoy-1');
|
|
1494
|
+
expect(after.drift_score).toBe(before.drift_score);
|
|
1495
|
+
});
|
|
1496
|
+
it('drift_score and drift_retried start at NULL/0 for new tasks', () => {
|
|
1497
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1498
|
+
expect(task.drift_score).toBeNull();
|
|
1499
|
+
expect(task.drift_retried).toBe(0);
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
// ── updateTaskDisputeStatus ───────────────────────────────────────────────────
|
|
1503
|
+
describe('updateTaskDisputeStatus', () => {
|
|
1504
|
+
beforeEach(() => {
|
|
1505
|
+
store.insertConvoy(makeConvoy());
|
|
1506
|
+
store.insertTask(makeTask());
|
|
1507
|
+
});
|
|
1508
|
+
it('sets task status to disputed', () => {
|
|
1509
|
+
store.updateTaskDisputeStatus('task-1', 'convoy-1', 'disputed', 'dispute-task-1-123');
|
|
1510
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1511
|
+
expect(task.status).toBe('disputed');
|
|
1512
|
+
});
|
|
1513
|
+
it('sets dispute_id on the task', () => {
|
|
1514
|
+
store.updateTaskDisputeStatus('task-1', 'convoy-1', 'disputed', 'dispute-task-1-123');
|
|
1515
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1516
|
+
expect(task.dispute_id).toBe('dispute-task-1-123');
|
|
1517
|
+
});
|
|
1518
|
+
it('is idempotent when called twice with same dispute_id', () => {
|
|
1519
|
+
store.updateTaskDisputeStatus('task-1', 'convoy-1', 'disputed', 'dispute-task-1-abc');
|
|
1520
|
+
store.updateTaskDisputeStatus('task-1', 'convoy-1', 'disputed', 'dispute-task-1-abc');
|
|
1521
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
1522
|
+
expect(task.status).toBe('disputed');
|
|
1523
|
+
expect(task.dispute_id).toBe('dispute-task-1-abc');
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
// ── Artifact CRUD ──────────────────────────────────────────────────────────────
|
|
1527
|
+
describe('artifact CRUD', () => {
|
|
1528
|
+
it('inserts and retrieves an artifact', () => {
|
|
1529
|
+
store.insertConvoy(makeConvoy());
|
|
1530
|
+
store.insertTask(makeTask());
|
|
1531
|
+
store.insertArtifact({
|
|
1532
|
+
id: 'art-1',
|
|
1533
|
+
convoy_id: 'convoy-1',
|
|
1534
|
+
task_id: 'task-1',
|
|
1535
|
+
name: 'migration-sql',
|
|
1536
|
+
type: 'file',
|
|
1537
|
+
content: 'CREATE TABLE foo (id INT);',
|
|
1538
|
+
created_at: new Date().toISOString(),
|
|
1539
|
+
});
|
|
1540
|
+
const art = store.getArtifact('convoy-1', 'migration-sql');
|
|
1541
|
+
expect(art).toBeDefined();
|
|
1542
|
+
expect(art.name).toBe('migration-sql');
|
|
1543
|
+
expect(art.content).toBe('CREATE TABLE foo (id INT);');
|
|
1544
|
+
});
|
|
1545
|
+
it('enforces unique artifact name per convoy', () => {
|
|
1546
|
+
store.insertConvoy(makeConvoy());
|
|
1547
|
+
store.insertTask(makeTask());
|
|
1548
|
+
store.insertArtifact({
|
|
1549
|
+
id: 'art-1',
|
|
1550
|
+
convoy_id: 'convoy-1',
|
|
1551
|
+
task_id: 'task-1',
|
|
1552
|
+
name: 'dup-name',
|
|
1553
|
+
type: 'file',
|
|
1554
|
+
content: 'first',
|
|
1555
|
+
created_at: new Date().toISOString(),
|
|
1556
|
+
});
|
|
1557
|
+
expect(() => store.insertArtifact({
|
|
1558
|
+
id: 'art-2',
|
|
1559
|
+
convoy_id: 'convoy-1',
|
|
1560
|
+
task_id: 'task-1',
|
|
1561
|
+
name: 'dup-name',
|
|
1562
|
+
type: 'file',
|
|
1563
|
+
content: 'second',
|
|
1564
|
+
created_at: new Date().toISOString(),
|
|
1565
|
+
})).toThrow();
|
|
1566
|
+
});
|
|
1567
|
+
it('enforces max 50 artifacts per convoy', () => {
|
|
1568
|
+
store.insertConvoy(makeConvoy());
|
|
1569
|
+
store.insertTask(makeTask());
|
|
1570
|
+
for (let i = 0; i < 50; i++) {
|
|
1571
|
+
store.insertArtifact({
|
|
1572
|
+
id: `art-${i}`,
|
|
1573
|
+
convoy_id: 'convoy-1',
|
|
1574
|
+
task_id: 'task-1',
|
|
1575
|
+
name: `artifact-${i}`,
|
|
1576
|
+
type: 'summary',
|
|
1577
|
+
content: `content-${i}`,
|
|
1578
|
+
created_at: new Date().toISOString(),
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
expect(() => store.insertArtifact({
|
|
1582
|
+
id: 'art-51',
|
|
1583
|
+
convoy_id: 'convoy-1',
|
|
1584
|
+
task_id: 'task-1',
|
|
1585
|
+
name: 'artifact-50',
|
|
1586
|
+
type: 'summary',
|
|
1587
|
+
content: 'over limit',
|
|
1588
|
+
created_at: new Date().toISOString(),
|
|
1589
|
+
})).toThrow(/maximum of 50 artifacts/);
|
|
1590
|
+
});
|
|
1591
|
+
it('retrieves artifacts by task', () => {
|
|
1592
|
+
store.insertConvoy(makeConvoy());
|
|
1593
|
+
store.insertTask(makeTask());
|
|
1594
|
+
store.insertArtifact({
|
|
1595
|
+
id: 'art-1',
|
|
1596
|
+
convoy_id: 'convoy-1',
|
|
1597
|
+
task_id: 'task-1',
|
|
1598
|
+
name: 'a',
|
|
1599
|
+
type: 'file',
|
|
1600
|
+
content: 'file content',
|
|
1601
|
+
created_at: new Date().toISOString(),
|
|
1602
|
+
});
|
|
1603
|
+
const arts = store.getArtifactsByTask('task-1');
|
|
1604
|
+
expect(arts).toHaveLength(1);
|
|
1605
|
+
});
|
|
1606
|
+
it('deletes artifacts older than N days', () => {
|
|
1607
|
+
store.insertConvoy(makeConvoy());
|
|
1608
|
+
store.insertTask(makeTask());
|
|
1609
|
+
store.insertArtifact({
|
|
1610
|
+
id: 'art-old',
|
|
1611
|
+
convoy_id: 'convoy-1',
|
|
1612
|
+
task_id: 'task-1',
|
|
1613
|
+
name: 'old-artifact',
|
|
1614
|
+
type: 'summary',
|
|
1615
|
+
content: 'old',
|
|
1616
|
+
created_at: new Date().toISOString(),
|
|
1617
|
+
});
|
|
1618
|
+
// Mark convoy as done with a finished_at in the past
|
|
1619
|
+
store.updateConvoyStatus('convoy-1', 'done', {
|
|
1620
|
+
finished_at: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1621
|
+
});
|
|
1622
|
+
const deleted = store.deleteArtifactsOlderThan(30);
|
|
1623
|
+
expect(deleted).toBe(1);
|
|
1624
|
+
});
|
|
1625
|
+
});
|
|
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
|
+
const chainDbPath = join(tmpDir, 'v4-chain.db');
|
|
1630
|
+
const v4Db = createV4Db(chainDbPath);
|
|
1631
|
+
// Seed realistic v4 data
|
|
1632
|
+
v4Db.prepare(`INSERT INTO convoy (id, name, spec_hash, status, branch, created_at, spec_yaml)
|
|
1633
|
+
VALUES ('convoy-chain', 'Chain Test', 'hash-chain', 'pending', NULL, '2026-01-01T00:00:00.000Z', 'name: chain')`).run();
|
|
1634
|
+
v4Db.prepare(`INSERT INTO task (id, convoy_id, phase, prompt, agent, timeout_ms, status, retries, max_retries)
|
|
1635
|
+
VALUES ('task-chain', 'convoy-chain', 0, 'Do chain work', 'developer', 1800000, 'pending', 0, 1)`).run();
|
|
1636
|
+
v4Db.prepare(`INSERT INTO worker (id, task_id, adapter, status, created_at)
|
|
1637
|
+
VALUES ('worker-chain', 'task-chain', 'vscode', 'spawned', '2026-01-01T00:00:00.000Z')`).run();
|
|
1638
|
+
v4Db.prepare(`INSERT INTO event (convoy_id, task_id, type, created_at)
|
|
1639
|
+
VALUES ('convoy-chain', 'task-chain', 'task_started', '2026-01-01T00:00:00.000Z')`).run();
|
|
1640
|
+
v4Db.close();
|
|
1641
|
+
// Trigger the full v4→v10 migration chain
|
|
1642
|
+
const migratedStore = createConvoyStore(chainDbPath);
|
|
1643
|
+
migratedStore.close();
|
|
1644
|
+
const verifyDb = new DatabaseSync(chainDbPath);
|
|
1645
|
+
// Verify user_version = 10
|
|
1646
|
+
const version = verifyDb.prepare('PRAGMA user_version').get().user_version;
|
|
1647
|
+
expect(version).toBe(10);
|
|
1648
|
+
// Verify all new tables exist
|
|
1649
|
+
const tables = verifyDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
|
|
1650
|
+
for (const table of ['task_step', 'dlq', 'artifact', 'agent_identity', 'scratchpad']) {
|
|
1651
|
+
expect(tables).toContain(table);
|
|
1652
|
+
}
|
|
1653
|
+
// Verify all new columns on task
|
|
1654
|
+
const taskCols = verifyDb.prepare('PRAGMA table_info(task)').all().map(c => c.name);
|
|
1655
|
+
for (const col of [
|
|
1656
|
+
'gates', 'on_exhausted', 'injected', 'provenance', 'idempotency_key',
|
|
1657
|
+
'current_step', 'total_steps', 'review_level', 'review_verdict', 'review_tokens',
|
|
1658
|
+
'review_model', 'panel_attempts', 'dispute_id', 'drift_score', 'drift_retried',
|
|
1659
|
+
'outputs', 'inputs', 'discovered_issues',
|
|
1660
|
+
]) {
|
|
1661
|
+
expect(taskCols).toContain(col);
|
|
1662
|
+
}
|
|
1663
|
+
// Verify all new columns on convoy
|
|
1664
|
+
const convoyCols = verifyDb.prepare('PRAGMA table_info(convoy)').all().map(c => c.name);
|
|
1665
|
+
for (const col of ['circuit_state', 'review_tokens_total', 'review_budget']) {
|
|
1666
|
+
expect(convoyCols).toContain(col);
|
|
1667
|
+
}
|
|
1668
|
+
// Verify seed data is intact
|
|
1669
|
+
const convoy = verifyDb.prepare('SELECT id FROM convoy WHERE id = :id').get({ id: 'convoy-chain' });
|
|
1670
|
+
expect(convoy?.id).toBe('convoy-chain');
|
|
1671
|
+
const task = verifyDb.prepare('SELECT id FROM task WHERE id = :id').get({ id: 'task-chain' });
|
|
1672
|
+
expect(task?.id).toBe('task-chain');
|
|
1673
|
+
const worker = verifyDb.prepare('SELECT id FROM worker WHERE id = :id').get({ id: 'worker-chain' });
|
|
1674
|
+
expect(worker?.id).toBe('worker-chain');
|
|
1675
|
+
const eventCount = verifyDb.prepare('SELECT COUNT(*) AS cnt FROM event WHERE convoy_id = :id').get({ id: 'convoy-chain' }).cnt;
|
|
1676
|
+
expect(eventCount).toBe(1);
|
|
1677
|
+
// Verify FK constraints work: insert a task_step referencing the seeded task_id
|
|
1678
|
+
expect(() => {
|
|
1679
|
+
verifyDb.prepare(`INSERT INTO task_step (task_id, step_index, prompt, gates, status)
|
|
1680
|
+
VALUES ('task-chain', 0, 'Step prompt', NULL, 'pending')`).run();
|
|
1681
|
+
}).not.toThrow();
|
|
1682
|
+
verifyDb.close();
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
// ── Agent Identity ────────────────────────────────────────────────────────────
|
|
1686
|
+
describe('agent identity', () => {
|
|
1687
|
+
it('inserts an agent identity record without error', () => {
|
|
1688
|
+
store.insertConvoy(makeConvoy());
|
|
1689
|
+
store.insertTask(makeTask());
|
|
1690
|
+
expect(() => store.insertAgentIdentity({
|
|
1691
|
+
id: 'ai-1',
|
|
1692
|
+
agent: 'developer',
|
|
1693
|
+
convoy_id: 'convoy-1',
|
|
1694
|
+
task_id: 'task-1',
|
|
1695
|
+
summary: 'Implemented the feature successfully',
|
|
1696
|
+
created_at: new Date().toISOString(),
|
|
1697
|
+
retention_days: 90,
|
|
1698
|
+
})).not.toThrow();
|
|
1699
|
+
});
|
|
1700
|
+
});
|
|
1701
|
+
describe('agent identity persistence', () => {
|
|
1702
|
+
it('deleteAgentIdentitiesOlderThan removes expired identities', () => {
|
|
1703
|
+
store.insertConvoy(makeConvoy());
|
|
1704
|
+
store.insertTask(makeTask());
|
|
1705
|
+
const oldCreatedAt = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString();
|
|
1706
|
+
const recentCreatedAt = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
|
|
1707
|
+
store.insertAgentIdentity({
|
|
1708
|
+
id: 'ai-old',
|
|
1709
|
+
agent: 'Developer',
|
|
1710
|
+
convoy_id: 'convoy-1',
|
|
1711
|
+
task_id: 'task-1',
|
|
1712
|
+
summary: 'old identity',
|
|
1713
|
+
created_at: oldCreatedAt,
|
|
1714
|
+
retention_days: 90,
|
|
1715
|
+
});
|
|
1716
|
+
store.insertAgentIdentity({
|
|
1717
|
+
id: 'ai-recent',
|
|
1718
|
+
agent: 'Developer',
|
|
1719
|
+
convoy_id: 'convoy-1',
|
|
1720
|
+
task_id: 'task-1',
|
|
1721
|
+
summary: 'recent identity',
|
|
1722
|
+
created_at: recentCreatedAt,
|
|
1723
|
+
retention_days: 90,
|
|
1724
|
+
});
|
|
1725
|
+
const deleted = store.deleteAgentIdentitiesOlderThan(90);
|
|
1726
|
+
expect(deleted).toBe(1);
|
|
1727
|
+
const identities = store.getAgentIdentities('Developer', 10);
|
|
1728
|
+
expect(identities).toHaveLength(1);
|
|
1729
|
+
expect(identities[0].id).toBe('ai-recent');
|
|
1730
|
+
});
|
|
1731
|
+
it('deleteAgentIdentitiesOlderThan respects per-record retention_days', () => {
|
|
1732
|
+
store.insertConvoy(makeConvoy());
|
|
1733
|
+
store.insertTask(makeTask());
|
|
1734
|
+
const createdAt35DaysAgo = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString();
|
|
1735
|
+
store.insertAgentIdentity({
|
|
1736
|
+
id: 'ai-30d',
|
|
1737
|
+
agent: 'Developer',
|
|
1738
|
+
convoy_id: 'convoy-1',
|
|
1739
|
+
task_id: 'task-1',
|
|
1740
|
+
summary: 'expires early',
|
|
1741
|
+
created_at: createdAt35DaysAgo,
|
|
1742
|
+
retention_days: 30,
|
|
1743
|
+
});
|
|
1744
|
+
store.insertAgentIdentity({
|
|
1745
|
+
id: 'ai-180d',
|
|
1746
|
+
agent: 'Developer',
|
|
1747
|
+
convoy_id: 'convoy-1',
|
|
1748
|
+
task_id: 'task-1',
|
|
1749
|
+
summary: 'kept longer',
|
|
1750
|
+
created_at: createdAt35DaysAgo,
|
|
1751
|
+
retention_days: 180,
|
|
1752
|
+
});
|
|
1753
|
+
const deleted = store.deleteAgentIdentitiesOlderThan(90);
|
|
1754
|
+
expect(deleted).toBe(1);
|
|
1755
|
+
const identities = store.getAgentIdentities('Developer', 10);
|
|
1756
|
+
const ids = identities.map((i) => i.id);
|
|
1757
|
+
expect(ids).not.toContain('ai-30d');
|
|
1758
|
+
expect(ids).toContain('ai-180d');
|
|
1759
|
+
});
|
|
1760
|
+
it('stores summaries up to 4096 characters', () => {
|
|
1761
|
+
store.insertConvoy(makeConvoy());
|
|
1762
|
+
store.insertTask(makeTask());
|
|
1763
|
+
const summary = 'x'.repeat(4096);
|
|
1764
|
+
store.insertAgentIdentity({
|
|
1765
|
+
id: 'ai-4kb',
|
|
1766
|
+
agent: 'Developer',
|
|
1767
|
+
convoy_id: 'convoy-1',
|
|
1768
|
+
task_id: 'task-1',
|
|
1769
|
+
summary,
|
|
1770
|
+
created_at: new Date().toISOString(),
|
|
1771
|
+
retention_days: 90,
|
|
1772
|
+
});
|
|
1773
|
+
const identities = store.getAgentIdentities('Developer', 10);
|
|
1774
|
+
expect(identities).toHaveLength(1);
|
|
1775
|
+
expect(identities[0].summary).toBe(summary);
|
|
1776
|
+
expect(identities[0].summary.length).toBe(4096);
|
|
1777
|
+
});
|
|
1778
|
+
it('listAgentIdentitySummary returns counts per agent', () => {
|
|
1779
|
+
store.insertConvoy(makeConvoy());
|
|
1780
|
+
store.insertTask(makeTask());
|
|
1781
|
+
for (let i = 0; i < 3; i++) {
|
|
1782
|
+
store.insertAgentIdentity({
|
|
1783
|
+
id: `ai-dev-${i}`,
|
|
1784
|
+
agent: 'Developer',
|
|
1785
|
+
convoy_id: 'convoy-1',
|
|
1786
|
+
task_id: 'task-1',
|
|
1787
|
+
summary: `dev-${i}`,
|
|
1788
|
+
created_at: new Date(Date.now() + i).toISOString(),
|
|
1789
|
+
retention_days: 90,
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
for (let i = 0; i < 2; i++) {
|
|
1793
|
+
store.insertAgentIdentity({
|
|
1794
|
+
id: `ai-rev-${i}`,
|
|
1795
|
+
agent: 'Reviewer',
|
|
1796
|
+
convoy_id: 'convoy-1',
|
|
1797
|
+
task_id: 'task-1',
|
|
1798
|
+
summary: `review-${i}`,
|
|
1799
|
+
created_at: new Date(Date.now() + 10 + i).toISOString(),
|
|
1800
|
+
retention_days: 90,
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
const summary = store.listAgentIdentitySummary();
|
|
1804
|
+
const byAgent = Object.fromEntries(summary.map((row) => [row.agent, row]));
|
|
1805
|
+
expect(byAgent.Developer).toBeDefined();
|
|
1806
|
+
expect(byAgent.Developer.task_count).toBe(3);
|
|
1807
|
+
expect(byAgent.Reviewer).toBeDefined();
|
|
1808
|
+
expect(byAgent.Reviewer.task_count).toBe(2);
|
|
1809
|
+
});
|
|
1810
|
+
it('purgeAgentIdentities removes all identities for a specific agent', () => {
|
|
1811
|
+
store.insertConvoy(makeConvoy());
|
|
1812
|
+
store.insertTask(makeTask());
|
|
1813
|
+
store.insertAgentIdentity({
|
|
1814
|
+
id: 'ai-dev-1',
|
|
1815
|
+
agent: 'Developer',
|
|
1816
|
+
convoy_id: 'convoy-1',
|
|
1817
|
+
task_id: 'task-1',
|
|
1818
|
+
summary: 'dev one',
|
|
1819
|
+
created_at: new Date().toISOString(),
|
|
1820
|
+
retention_days: 90,
|
|
1821
|
+
});
|
|
1822
|
+
store.insertAgentIdentity({
|
|
1823
|
+
id: 'ai-dev-2',
|
|
1824
|
+
agent: 'Developer',
|
|
1825
|
+
convoy_id: 'convoy-1',
|
|
1826
|
+
task_id: 'task-1',
|
|
1827
|
+
summary: 'dev two',
|
|
1828
|
+
created_at: new Date().toISOString(),
|
|
1829
|
+
retention_days: 90,
|
|
1830
|
+
});
|
|
1831
|
+
store.insertAgentIdentity({
|
|
1832
|
+
id: 'ai-rev-1',
|
|
1833
|
+
agent: 'Reviewer',
|
|
1834
|
+
convoy_id: 'convoy-1',
|
|
1835
|
+
task_id: 'task-1',
|
|
1836
|
+
summary: 'review one',
|
|
1837
|
+
created_at: new Date().toISOString(),
|
|
1838
|
+
retention_days: 90,
|
|
1839
|
+
});
|
|
1840
|
+
const deleted = store.purgeAgentIdentities('Developer');
|
|
1841
|
+
expect(deleted).toBe(2);
|
|
1842
|
+
expect(store.getAgentIdentities('Developer', 10)).toHaveLength(0);
|
|
1843
|
+
expect(store.getAgentIdentities('Reviewer', 10)).toHaveLength(1);
|
|
1844
|
+
});
|
|
1845
|
+
it('truncates summaries longer than 4096 characters to exactly 4096 chars', () => {
|
|
1846
|
+
store.insertConvoy(makeConvoy());
|
|
1847
|
+
store.insertTask(makeTask());
|
|
1848
|
+
const longSummary = 'a'.repeat(5000);
|
|
1849
|
+
store.insertAgentIdentity({
|
|
1850
|
+
id: 'ai-trunc',
|
|
1851
|
+
agent: 'Developer',
|
|
1852
|
+
convoy_id: 'convoy-1',
|
|
1853
|
+
task_id: 'task-1',
|
|
1854
|
+
summary: longSummary,
|
|
1855
|
+
created_at: new Date().toISOString(),
|
|
1856
|
+
retention_days: 90,
|
|
1857
|
+
});
|
|
1858
|
+
const identities = store.getAgentIdentities('Developer', 10);
|
|
1859
|
+
const stored = identities.find(i => i.id === 'ai-trunc');
|
|
1860
|
+
expect(stored).toBeDefined();
|
|
1861
|
+
expect(stored.summary.length).toBe(4096);
|
|
1862
|
+
});
|
|
1863
|
+
});
|
|
1864
|
+
// ── Schema v7→v8 migration ──────────────────────────────────────────────────
|
|
1865
|
+
function createV7Db(dbPath) {
|
|
1866
|
+
// v7 = v6 schema + drift_score/drift_retried columns on task (before outputs/inputs/discovered_issues)
|
|
1867
|
+
const db = new DatabaseSync(dbPath);
|
|
1868
|
+
db.exec(`
|
|
1869
|
+
CREATE TABLE convoy (
|
|
1870
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, spec_hash TEXT NOT NULL,
|
|
1871
|
+
status TEXT NOT NULL DEFAULT 'pending', branch TEXT, created_at TEXT NOT NULL,
|
|
1872
|
+
started_at TEXT, finished_at TEXT, spec_yaml TEXT NOT NULL,
|
|
1873
|
+
total_tokens INTEGER, total_cost_usd TEXT, pipeline_id TEXT,
|
|
1874
|
+
circuit_state TEXT, review_tokens_total INTEGER, review_budget INTEGER
|
|
1875
|
+
);
|
|
1876
|
+
CREATE TABLE task (
|
|
1877
|
+
id TEXT PRIMARY KEY, convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
1878
|
+
phase INTEGER NOT NULL, prompt TEXT NOT NULL,
|
|
1879
|
+
agent TEXT NOT NULL DEFAULT 'developer', adapter TEXT,
|
|
1880
|
+
model TEXT, timeout_ms INTEGER NOT NULL DEFAULT 1800000,
|
|
1881
|
+
status TEXT NOT NULL DEFAULT 'pending', worker_id TEXT, worktree TEXT,
|
|
1882
|
+
output TEXT, exit_code INTEGER, started_at TEXT, finished_at TEXT,
|
|
1883
|
+
retries INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 1,
|
|
1884
|
+
files TEXT, depends_on TEXT, prompt_tokens INTEGER, completion_tokens INTEGER,
|
|
1885
|
+
total_tokens INTEGER, cost_usd TEXT, gates TEXT,
|
|
1886
|
+
on_exhausted TEXT NOT NULL DEFAULT 'dlq', injected INTEGER NOT NULL DEFAULT 0,
|
|
1887
|
+
provenance TEXT, idempotency_key TEXT, current_step INTEGER, total_steps INTEGER,
|
|
1888
|
+
review_level TEXT, review_verdict TEXT, review_tokens INTEGER, review_model TEXT,
|
|
1889
|
+
panel_attempts INTEGER NOT NULL DEFAULT 0, dispute_id TEXT,
|
|
1890
|
+
drift_score REAL, drift_retried INTEGER NOT NULL DEFAULT 0
|
|
1891
|
+
);
|
|
1892
|
+
PRAGMA user_version = 7;
|
|
1893
|
+
`);
|
|
1894
|
+
return db;
|
|
1895
|
+
}
|
|
1896
|
+
describe('v7→v8 migration', () => {
|
|
1897
|
+
it('creates artifact and agent_identity tables idempotently', () => {
|
|
1898
|
+
const migDir = realpathSync(mkdtempSync(join(tmpdir(), 'mig-test-')));
|
|
1899
|
+
const migDb = join(migDir, 'mig.db');
|
|
1900
|
+
const db = new DatabaseSync(migDb);
|
|
1901
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
1902
|
+
db.exec(`
|
|
1903
|
+
CREATE TABLE convoy (
|
|
1904
|
+
id TEXT PRIMARY KEY,
|
|
1905
|
+
name TEXT NOT NULL,
|
|
1906
|
+
spec_hash TEXT NOT NULL,
|
|
1907
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1908
|
+
branch TEXT,
|
|
1909
|
+
created_at TEXT NOT NULL,
|
|
1910
|
+
started_at TEXT,
|
|
1911
|
+
finished_at TEXT,
|
|
1912
|
+
spec_yaml TEXT NOT NULL,
|
|
1913
|
+
total_tokens INTEGER,
|
|
1914
|
+
total_cost_usd TEXT,
|
|
1915
|
+
pipeline_id TEXT,
|
|
1916
|
+
circuit_state TEXT,
|
|
1917
|
+
review_tokens_total INTEGER,
|
|
1918
|
+
review_budget INTEGER
|
|
1919
|
+
);
|
|
1920
|
+
CREATE TABLE task (
|
|
1921
|
+
id TEXT PRIMARY KEY,
|
|
1922
|
+
convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
1923
|
+
phase INTEGER NOT NULL,
|
|
1924
|
+
prompt TEXT NOT NULL,
|
|
1925
|
+
agent TEXT NOT NULL DEFAULT 'developer',
|
|
1926
|
+
adapter TEXT,
|
|
1927
|
+
model TEXT,
|
|
1928
|
+
timeout_ms INTEGER NOT NULL DEFAULT 1800000,
|
|
1929
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1930
|
+
worker_id TEXT,
|
|
1931
|
+
worktree TEXT,
|
|
1932
|
+
output TEXT,
|
|
1933
|
+
exit_code INTEGER,
|
|
1934
|
+
started_at TEXT,
|
|
1935
|
+
finished_at TEXT,
|
|
1936
|
+
retries INTEGER NOT NULL DEFAULT 0,
|
|
1937
|
+
max_retries INTEGER NOT NULL DEFAULT 1,
|
|
1938
|
+
files TEXT,
|
|
1939
|
+
depends_on TEXT,
|
|
1940
|
+
prompt_tokens INTEGER,
|
|
1941
|
+
completion_tokens INTEGER,
|
|
1942
|
+
total_tokens INTEGER,
|
|
1943
|
+
cost_usd TEXT,
|
|
1944
|
+
gates TEXT,
|
|
1945
|
+
on_exhausted TEXT NOT NULL DEFAULT 'dlq',
|
|
1946
|
+
injected INTEGER NOT NULL DEFAULT 0,
|
|
1947
|
+
provenance TEXT,
|
|
1948
|
+
idempotency_key TEXT,
|
|
1949
|
+
current_step INTEGER,
|
|
1950
|
+
total_steps INTEGER,
|
|
1951
|
+
review_level TEXT,
|
|
1952
|
+
review_verdict TEXT,
|
|
1953
|
+
review_tokens INTEGER,
|
|
1954
|
+
review_model TEXT,
|
|
1955
|
+
panel_attempts INTEGER NOT NULL DEFAULT 0,
|
|
1956
|
+
dispute_id TEXT,
|
|
1957
|
+
drift_score REAL DEFAULT NULL,
|
|
1958
|
+
drift_retried INTEGER NOT NULL DEFAULT 0
|
|
1959
|
+
);
|
|
1960
|
+
PRAGMA user_version = 7;
|
|
1961
|
+
`);
|
|
1962
|
+
migrateSchema(db, migDb, 7, 8);
|
|
1963
|
+
// Verify artifact table exists
|
|
1964
|
+
const artInfo = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='artifact'").get();
|
|
1965
|
+
expect(artInfo).toBeDefined();
|
|
1966
|
+
// Verify agent_identity table exists
|
|
1967
|
+
const aiInfo = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_identity'").get();
|
|
1968
|
+
expect(aiInfo).toBeDefined();
|
|
1969
|
+
// Verify new task columns exist
|
|
1970
|
+
const taskCols = db.prepare('PRAGMA table_info(task)').all();
|
|
1971
|
+
const colNames = taskCols.map(c => c.name);
|
|
1972
|
+
expect(colNames).toContain('outputs');
|
|
1973
|
+
expect(colNames).toContain('inputs');
|
|
1974
|
+
expect(colNames).toContain('discovered_issues');
|
|
1975
|
+
const artCols = db.prepare('PRAGMA table_info(artifact)').all();
|
|
1976
|
+
const idCol = artCols.find(c => c.name === 'id');
|
|
1977
|
+
expect(idCol).toBeDefined();
|
|
1978
|
+
expect(idCol.type.toUpperCase()).toBe('TEXT');
|
|
1979
|
+
expect(idCol.pk).toBe(1);
|
|
1980
|
+
// Verify version bumped to 8
|
|
1981
|
+
const version = db.prepare('PRAGMA user_version').get().user_version;
|
|
1982
|
+
expect(version).toBe(8);
|
|
1983
|
+
db.close();
|
|
1984
|
+
rmSync(migDir, { recursive: true, force: true });
|
|
1985
|
+
});
|
|
1986
|
+
it('failure mode: rolls back on error, version stays at 7 and backup exists', () => {
|
|
1987
|
+
const v7DbPath = join(tmpDir, 'v7-fail.db');
|
|
1988
|
+
const db = createV7Db(v7DbPath);
|
|
1989
|
+
// Pre-add outputs column to cause duplicate column error on migration
|
|
1990
|
+
db.exec('ALTER TABLE task ADD COLUMN outputs TEXT');
|
|
1991
|
+
expect(() => migrateSchema(db, v7DbPath, 7, 8)).toThrow();
|
|
1992
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
1993
|
+
expect(row.user_version).toBe(7);
|
|
1994
|
+
expect(existsSync(`${v7DbPath}.v7.bak`)).toBe(true);
|
|
1995
|
+
db.close();
|
|
1996
|
+
});
|
|
1997
|
+
});
|
|
1998
|
+
describe('v8→v9 migration', () => {
|
|
1999
|
+
it('creates scratchpad table', () => {
|
|
2000
|
+
const migDir = realpathSync(mkdtempSync(join(tmpdir(), 'mig-v9-test-')));
|
|
2001
|
+
const migDb = join(migDir, 'mig.db');
|
|
2002
|
+
const db = new DatabaseSync(migDb);
|
|
2003
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
2004
|
+
db.exec(`
|
|
2005
|
+
CREATE TABLE convoy (
|
|
2006
|
+
id TEXT PRIMARY KEY,
|
|
2007
|
+
name TEXT NOT NULL,
|
|
2008
|
+
spec_hash TEXT NOT NULL,
|
|
2009
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
2010
|
+
branch TEXT,
|
|
2011
|
+
created_at TEXT NOT NULL,
|
|
2012
|
+
started_at TEXT,
|
|
2013
|
+
finished_at TEXT,
|
|
2014
|
+
spec_yaml TEXT NOT NULL,
|
|
2015
|
+
total_tokens INTEGER,
|
|
2016
|
+
total_cost_usd TEXT,
|
|
2017
|
+
pipeline_id TEXT,
|
|
2018
|
+
circuit_state TEXT,
|
|
2019
|
+
review_tokens_total INTEGER,
|
|
2020
|
+
review_budget INTEGER
|
|
2021
|
+
);
|
|
2022
|
+
CREATE TABLE task (
|
|
2023
|
+
id TEXT PRIMARY KEY,
|
|
2024
|
+
convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
2025
|
+
phase INTEGER NOT NULL,
|
|
2026
|
+
prompt TEXT NOT NULL,
|
|
2027
|
+
agent TEXT NOT NULL DEFAULT 'developer',
|
|
2028
|
+
adapter TEXT,
|
|
2029
|
+
model TEXT,
|
|
2030
|
+
timeout_ms INTEGER NOT NULL DEFAULT 1800000,
|
|
2031
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
2032
|
+
worker_id TEXT,
|
|
2033
|
+
worktree TEXT,
|
|
2034
|
+
output TEXT,
|
|
2035
|
+
exit_code INTEGER,
|
|
2036
|
+
started_at TEXT,
|
|
2037
|
+
finished_at TEXT,
|
|
2038
|
+
retries INTEGER NOT NULL DEFAULT 0,
|
|
2039
|
+
max_retries INTEGER NOT NULL DEFAULT 1,
|
|
2040
|
+
files TEXT,
|
|
2041
|
+
depends_on TEXT,
|
|
2042
|
+
prompt_tokens INTEGER,
|
|
2043
|
+
completion_tokens INTEGER,
|
|
2044
|
+
total_tokens INTEGER,
|
|
2045
|
+
cost_usd TEXT,
|
|
2046
|
+
gates TEXT,
|
|
2047
|
+
on_exhausted TEXT NOT NULL DEFAULT 'dlq',
|
|
2048
|
+
injected INTEGER NOT NULL DEFAULT 0,
|
|
2049
|
+
provenance TEXT,
|
|
2050
|
+
idempotency_key TEXT,
|
|
2051
|
+
current_step INTEGER,
|
|
2052
|
+
total_steps INTEGER,
|
|
2053
|
+
review_level TEXT,
|
|
2054
|
+
review_verdict TEXT,
|
|
2055
|
+
review_tokens INTEGER,
|
|
2056
|
+
review_model TEXT,
|
|
2057
|
+
panel_attempts INTEGER NOT NULL DEFAULT 0,
|
|
2058
|
+
dispute_id TEXT,
|
|
2059
|
+
drift_score REAL,
|
|
2060
|
+
drift_retried INTEGER NOT NULL DEFAULT 0,
|
|
2061
|
+
outputs TEXT,
|
|
2062
|
+
inputs TEXT,
|
|
2063
|
+
discovered_issues TEXT
|
|
2064
|
+
);
|
|
2065
|
+
CREATE TABLE worker (
|
|
2066
|
+
id TEXT PRIMARY KEY,
|
|
2067
|
+
task_id TEXT REFERENCES task(id),
|
|
2068
|
+
adapter TEXT NOT NULL,
|
|
2069
|
+
pid INTEGER,
|
|
2070
|
+
session_id TEXT,
|
|
2071
|
+
status TEXT NOT NULL DEFAULT 'spawned',
|
|
2072
|
+
worktree TEXT,
|
|
2073
|
+
created_at TEXT NOT NULL,
|
|
2074
|
+
finished_at TEXT,
|
|
2075
|
+
last_heartbeat TEXT
|
|
2076
|
+
);
|
|
2077
|
+
CREATE TABLE event (
|
|
2078
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2079
|
+
convoy_id TEXT REFERENCES convoy(id),
|
|
2080
|
+
task_id TEXT,
|
|
2081
|
+
worker_id TEXT,
|
|
2082
|
+
type TEXT NOT NULL,
|
|
2083
|
+
data TEXT,
|
|
2084
|
+
created_at TEXT NOT NULL
|
|
2085
|
+
);
|
|
2086
|
+
CREATE TABLE dlq (
|
|
2087
|
+
id TEXT PRIMARY KEY,
|
|
2088
|
+
convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
2089
|
+
task_id TEXT NOT NULL REFERENCES task(id),
|
|
2090
|
+
agent TEXT NOT NULL,
|
|
2091
|
+
failure_type TEXT NOT NULL,
|
|
2092
|
+
error_output TEXT,
|
|
2093
|
+
attempts INTEGER NOT NULL,
|
|
2094
|
+
tokens_spent INTEGER,
|
|
2095
|
+
escalation_task_id TEXT,
|
|
2096
|
+
resolved INTEGER NOT NULL DEFAULT 0,
|
|
2097
|
+
resolution TEXT,
|
|
2098
|
+
created_at TEXT NOT NULL,
|
|
2099
|
+
resolved_at TEXT
|
|
2100
|
+
);
|
|
2101
|
+
CREATE TABLE task_step (
|
|
2102
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2103
|
+
task_id TEXT NOT NULL REFERENCES task(id),
|
|
2104
|
+
step_index INTEGER NOT NULL,
|
|
2105
|
+
prompt TEXT NOT NULL,
|
|
2106
|
+
gates TEXT,
|
|
2107
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
2108
|
+
exit_code INTEGER,
|
|
2109
|
+
output TEXT,
|
|
2110
|
+
started_at TEXT,
|
|
2111
|
+
finished_at TEXT
|
|
2112
|
+
);
|
|
2113
|
+
CREATE TABLE artifact (
|
|
2114
|
+
id TEXT PRIMARY KEY,
|
|
2115
|
+
convoy_id TEXT NOT NULL REFERENCES convoy(id),
|
|
2116
|
+
task_id TEXT NOT NULL REFERENCES task(id),
|
|
2117
|
+
name TEXT NOT NULL,
|
|
2118
|
+
type TEXT NOT NULL,
|
|
2119
|
+
content TEXT NOT NULL,
|
|
2120
|
+
created_at TEXT NOT NULL,
|
|
2121
|
+
UNIQUE(convoy_id, name)
|
|
2122
|
+
);
|
|
2123
|
+
CREATE TABLE agent_identity (
|
|
2124
|
+
id TEXT PRIMARY KEY,
|
|
2125
|
+
agent TEXT NOT NULL,
|
|
2126
|
+
convoy_id TEXT NOT NULL,
|
|
2127
|
+
task_id TEXT NOT NULL,
|
|
2128
|
+
summary TEXT NOT NULL,
|
|
2129
|
+
created_at TEXT NOT NULL,
|
|
2130
|
+
retention_days INTEGER NOT NULL DEFAULT 90
|
|
2131
|
+
);
|
|
2132
|
+
PRAGMA user_version = 8;
|
|
2133
|
+
`);
|
|
2134
|
+
migrateSchema(db, migDb, 8, 9);
|
|
2135
|
+
const scratchpadInfo = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='scratchpad'").get();
|
|
2136
|
+
expect(scratchpadInfo).toBeDefined();
|
|
2137
|
+
const scratchpadCols = db.prepare('PRAGMA table_info(scratchpad)').all();
|
|
2138
|
+
const colNames = scratchpadCols.map(c => c.name);
|
|
2139
|
+
expect(colNames).toContain('key');
|
|
2140
|
+
expect(colNames).toContain('value');
|
|
2141
|
+
expect(colNames).toContain('updated_at');
|
|
2142
|
+
const version = db.prepare('PRAGMA user_version').get().user_version;
|
|
2143
|
+
expect(version).toBe(9);
|
|
2144
|
+
db.close();
|
|
2145
|
+
rmSync(migDir, { recursive: true, force: true });
|
|
2146
|
+
});
|
|
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
|
+
});
|
|
2267
|
+
describe('size limit enforcement', () => {
|
|
2268
|
+
it('insertConvoy rejects spec_yaml exceeding 256KB', () => {
|
|
2269
|
+
const bigSpecYaml = 'x'.repeat(256 * 1024 + 1);
|
|
2270
|
+
expect(() => store.insertConvoy(makeConvoy({ spec_yaml: bigSpecYaml })))
|
|
2271
|
+
.toThrow(FieldSizeLimitError);
|
|
2272
|
+
});
|
|
2273
|
+
it('insertConvoy accepts spec_yaml at exactly 256KB', () => {
|
|
2274
|
+
const exactSpecYaml = 'x'.repeat(256 * 1024);
|
|
2275
|
+
expect(() => store.insertConvoy(makeConvoy({ id: 'convoy-exact', spec_yaml: exactSpecYaml }))).not.toThrow();
|
|
2276
|
+
});
|
|
2277
|
+
it('insertEvent rejects data exceeding 64KB', () => {
|
|
2278
|
+
store.insertConvoy(makeConvoy());
|
|
2279
|
+
const bigData = 'y'.repeat(64 * 1024 + 1);
|
|
2280
|
+
expect(() => store.insertEvent({
|
|
2281
|
+
convoy_id: 'convoy-1',
|
|
2282
|
+
task_id: null,
|
|
2283
|
+
worker_id: null,
|
|
2284
|
+
type: 'test',
|
|
2285
|
+
data: bigData,
|
|
2286
|
+
created_at: new Date().toISOString(),
|
|
2287
|
+
})).toThrow(FieldSizeLimitError);
|
|
2288
|
+
});
|
|
2289
|
+
it('updateTaskStatus truncates output exceeding 1MB', () => {
|
|
2290
|
+
store.insertConvoy(makeConvoy());
|
|
2291
|
+
store.insertTask(makeTask());
|
|
2292
|
+
const bigOutput = 'z'.repeat(3 * 1024 * 1024); // 3 MB — well over the 1 MB limit
|
|
2293
|
+
store.updateTaskStatus('task-1', 'convoy-1', 'done', { output: bigOutput });
|
|
2294
|
+
const task = store.getTask('task-1', 'convoy-1');
|
|
2295
|
+
expect(task?.output).toBeDefined();
|
|
2296
|
+
expect(task.output.length).toBeLessThan(bigOutput.length);
|
|
2297
|
+
expect(task.output).toContain('[truncated:');
|
|
2298
|
+
});
|
|
2299
|
+
it('insertAgentIdentity truncates summary exceeding 4KB', () => {
|
|
2300
|
+
store.insertConvoy(makeConvoy());
|
|
2301
|
+
store.insertTask(makeTask());
|
|
2302
|
+
const bigSummary = 's'.repeat(4097);
|
|
2303
|
+
store.insertAgentIdentity({
|
|
2304
|
+
id: 'identity-1',
|
|
2305
|
+
agent: 'developer',
|
|
2306
|
+
convoy_id: 'convoy-1',
|
|
2307
|
+
task_id: 'task-1',
|
|
2308
|
+
summary: bigSummary,
|
|
2309
|
+
created_at: new Date().toISOString(),
|
|
2310
|
+
retention_days: 90,
|
|
2311
|
+
});
|
|
2312
|
+
const identities = store.getAgentIdentities('developer', 10);
|
|
2313
|
+
expect(identities[0].summary.length).toBeLessThanOrEqual(4096);
|
|
2314
|
+
});
|
|
2315
|
+
it('insertPipeline rejects spec_yaml exceeding 256KB', () => {
|
|
2316
|
+
const bigSpecYaml = 'p'.repeat(256 * 1024 + 1);
|
|
2317
|
+
expect(() => store.insertPipeline({
|
|
2318
|
+
id: 'pipeline-1',
|
|
2319
|
+
name: 'Test Pipeline',
|
|
2320
|
+
status: 'pending',
|
|
2321
|
+
branch: null,
|
|
2322
|
+
spec_yaml: bigSpecYaml,
|
|
2323
|
+
convoy_specs: '[]',
|
|
2324
|
+
created_at: new Date().toISOString(),
|
|
2325
|
+
})).toThrow(FieldSizeLimitError);
|
|
2326
|
+
});
|
|
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
|
+
});
|
|
876
2668
|
//# sourceMappingURL=store.test.js.map
|