opencastle 0.27.1 → 0.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  2. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  3. package/dist/cli/convoy/dashboard-types.js +2 -0
  4. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  5. package/dist/cli/convoy/engine.d.ts +0 -1
  6. package/dist/cli/convoy/engine.d.ts.map +1 -1
  7. package/dist/cli/convoy/engine.js +31 -99
  8. package/dist/cli/convoy/engine.js.map +1 -1
  9. package/dist/cli/convoy/engine.test.js +88 -1
  10. package/dist/cli/convoy/engine.test.js.map +1 -1
  11. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  12. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  13. package/dist/cli/convoy/event-schemas.js +185 -0
  14. package/dist/cli/convoy/event-schemas.js.map +1 -0
  15. package/dist/cli/convoy/events.d.ts +8 -0
  16. package/dist/cli/convoy/events.d.ts.map +1 -1
  17. package/dist/cli/convoy/events.js +117 -5
  18. package/dist/cli/convoy/events.js.map +1 -1
  19. package/dist/cli/convoy/events.test.js +173 -3
  20. package/dist/cli/convoy/events.test.js.map +1 -1
  21. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  22. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  23. package/dist/cli/convoy/log-merge.test.js +147 -0
  24. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  25. package/dist/cli/convoy/store.d.ts +52 -2
  26. package/dist/cli/convoy/store.d.ts.map +1 -1
  27. package/dist/cli/convoy/store.js +244 -17
  28. package/dist/cli/convoy/store.js.map +1 -1
  29. package/dist/cli/convoy/store.test.js +481 -22
  30. package/dist/cli/convoy/store.test.js.map +1 -1
  31. package/dist/cli/convoy/types.d.ts +271 -3
  32. package/dist/cli/convoy/types.d.ts.map +1 -1
  33. package/dist/cli/convoy/types.js +42 -1
  34. package/dist/cli/convoy/types.js.map +1 -1
  35. package/dist/cli/log.d.ts +11 -0
  36. package/dist/cli/log.d.ts.map +1 -1
  37. package/dist/cli/log.js +114 -2
  38. package/dist/cli/log.js.map +1 -1
  39. package/dist/cli/run.d.ts.map +1 -1
  40. package/dist/cli/run.js +37 -1
  41. package/dist/cli/run.js.map +1 -1
  42. package/package.json +6 -1
  43. package/src/cli/convoy/TELEMETRY.md +203 -0
  44. package/src/cli/convoy/dashboard-types.ts +141 -0
  45. package/src/cli/convoy/engine.test.ts +99 -1
  46. package/src/cli/convoy/engine.ts +27 -96
  47. package/src/cli/convoy/event-schemas.ts +195 -0
  48. package/src/cli/convoy/events.test.ts +207 -3
  49. package/src/cli/convoy/events.ts +119 -5
  50. package/src/cli/convoy/log-merge.test.ts +179 -0
  51. package/src/cli/convoy/store.test.ts +545 -22
  52. package/src/cli/convoy/store.ts +274 -21
  53. package/src/cli/convoy/types.ts +108 -3
  54. package/src/cli/log.ts +120 -2
  55. package/src/cli/run.ts +37 -1
  56. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  57. package/src/dashboard/dist/data/.gitkeep +0 -0
  58. package/src/dashboard/dist/data/convoy-list.json +20 -0
  59. package/src/dashboard/dist/data/convoys/demo-convoy-1.json +111 -0
  60. package/src/dashboard/dist/data/convoys/demo-convoy-2.json +72 -0
  61. package/src/dashboard/dist/data/overall-stats.json +36 -0
  62. package/src/dashboard/dist/index.html +701 -3
  63. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  64. package/src/dashboard/public/data/.gitkeep +0 -0
  65. package/src/dashboard/public/data/convoy-list.json +20 -0
  66. package/src/dashboard/public/data/convoys/demo-convoy-1.json +111 -0
  67. package/src/dashboard/public/data/convoys/demo-convoy-2.json +72 -0
  68. package/src/dashboard/public/data/overall-stats.json +36 -0
  69. package/src/dashboard/scripts/etl.test.ts +210 -0
  70. package/src/dashboard/scripts/etl.ts +121 -0
  71. package/src/dashboard/scripts/generate-demo-db.test.ts +30 -0
  72. package/src/dashboard/scripts/generate-demo-db.ts +140 -0
  73. package/src/dashboard/scripts/integration-test.ts +504 -0
  74. package/src/dashboard/scripts/verify-demo-data.sh +51 -0
  75. package/src/dashboard/src/pages/index.astro +854 -15
  76. package/src/dashboard/src/styles/dashboard.css +557 -1
  77. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "d3546064",
2
+ "hash": "5561dc89",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "2237f08d",
5
- "browserHash": "25793c93",
4
+ "lockfileHash": "e2a32132",
5
+ "browserHash": "c5e077c4",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "e7dc1213",
10
+ "fileHash": "a5407c2f",
11
11
  "needsInterop": true
12
12
  },
13
13
  "astro > aria-query": {
14
14
  "src": "../../../../../node_modules/aria-query/lib/index.js",
15
15
  "file": "astro___aria-query.js",
16
- "fileHash": "f017ff62",
16
+ "fileHash": "3bbe7ca8",
17
17
  "needsInterop": true
18
18
  },
19
19
  "astro > axobject-query": {
20
20
  "src": "../../../../../node_modules/axobject-query/lib/index.js",
21
21
  "file": "astro___axobject-query.js",
22
- "fileHash": "2b8684e9",
22
+ "fileHash": "a3c7a104",
23
23
  "needsInterop": true
24
24
  }
25
25
  },
File without changes
@@ -0,0 +1,20 @@
1
+ [
2
+ {
3
+ "id": "demo-convoy-2",
4
+ "name": "Demo Convoy Beta",
5
+ "status": "running",
6
+ "created_at": "2026-01-15T10:05:00.000Z",
7
+ "finished_at": null,
8
+ "total_tokens": null,
9
+ "total_cost_usd": null
10
+ },
11
+ {
12
+ "id": "demo-convoy-1",
13
+ "name": "Demo Convoy Alpha",
14
+ "status": "done",
15
+ "created_at": "2026-01-15T10:00:00.000Z",
16
+ "finished_at": "2026-01-15T10:00:33.000Z",
17
+ "total_tokens": 8432,
18
+ "total_cost_usd": 0.84
19
+ }
20
+ ]
@@ -0,0 +1,111 @@
1
+ {
2
+ "convoy": {
3
+ "id": "demo-convoy-1",
4
+ "name": "Demo Convoy Alpha",
5
+ "status": "done",
6
+ "created_at": "2026-01-15T10:00:00.000Z",
7
+ "finished_at": "2026-01-15T10:00:33.000Z",
8
+ "branch": "main",
9
+ "total_tokens": 8432,
10
+ "total_cost_usd": 0.84
11
+ },
12
+ "taskSummary": {
13
+ "total": 2,
14
+ "done": 2,
15
+ "running": 0,
16
+ "failed": 0,
17
+ "review_blocked": 0,
18
+ "disputed": 0,
19
+ "reviewed": 0,
20
+ "panel_reviewed": 0,
21
+ "tasks_with_drift": 0,
22
+ "max_drift_score": null,
23
+ "drift_retried": 0
24
+ },
25
+ "quality": {
26
+ "reviewed_tasks": 0,
27
+ "review_blocked_tasks": 0,
28
+ "disputed_tasks": 0,
29
+ "panel_reviews": 0
30
+ },
31
+ "drift": {
32
+ "tasks_with_drift": 0,
33
+ "max_drift_score": null,
34
+ "drift_retried_tasks": 0
35
+ },
36
+ "dlq_count": 0,
37
+ "dlq_entries": [],
38
+ "artifact_count": 0,
39
+ "artifacts": [],
40
+ "has_more_events": false,
41
+ "events": [
42
+ {
43
+ "type": "task_done",
44
+ "task_id": "task-1-b",
45
+ "data": null,
46
+ "created_at": "2026-01-15T10:00:32.000Z"
47
+ },
48
+ {
49
+ "type": "task_started",
50
+ "task_id": "task-1-b",
51
+ "data": null,
52
+ "created_at": "2026-01-15T10:00:16.000Z"
53
+ },
54
+ {
55
+ "type": "task_done",
56
+ "task_id": "task-1-a",
57
+ "data": null,
58
+ "created_at": "2026-01-15T10:00:15.000Z"
59
+ },
60
+ {
61
+ "type": "task_started",
62
+ "task_id": "task-1-a",
63
+ "data": null,
64
+ "created_at": "2026-01-15T10:00:01.000Z"
65
+ }
66
+ ],
67
+ "tasks": [
68
+ {
69
+ "id": "task-1-a",
70
+ "phase": 1,
71
+ "agent": "developer",
72
+ "model": "claude-sonnet-4-6",
73
+ "status": "done",
74
+ "retries": 0,
75
+ "started_at": "2026-01-15T10:00:01.000Z",
76
+ "finished_at": "2026-01-15T10:00:15.000Z",
77
+ "total_tokens": 4200,
78
+ "cost_usd": 0.42,
79
+ "review_level": null,
80
+ "review_verdict": null,
81
+ "review_tokens": null,
82
+ "review_model": null,
83
+ "panel_attempts": 0,
84
+ "dispute_id": null,
85
+ "drift_score": null,
86
+ "drift_retried": 0,
87
+ "files": null
88
+ },
89
+ {
90
+ "id": "task-1-b",
91
+ "phase": 2,
92
+ "agent": "developer",
93
+ "model": "claude-sonnet-4-6",
94
+ "status": "done",
95
+ "retries": 1,
96
+ "started_at": "2026-01-15T10:00:16.000Z",
97
+ "finished_at": "2026-01-15T10:00:32.000Z",
98
+ "total_tokens": 4232,
99
+ "cost_usd": 0.42,
100
+ "review_level": null,
101
+ "review_verdict": null,
102
+ "review_tokens": null,
103
+ "review_model": null,
104
+ "panel_attempts": 0,
105
+ "dispute_id": null,
106
+ "drift_score": null,
107
+ "drift_retried": 0,
108
+ "files": null
109
+ }
110
+ ]
111
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "convoy": {
3
+ "id": "demo-convoy-2",
4
+ "name": "Demo Convoy Beta",
5
+ "status": "running",
6
+ "created_at": "2026-01-15T10:05:00.000Z",
7
+ "finished_at": null,
8
+ "branch": "feature/x",
9
+ "total_tokens": null,
10
+ "total_cost_usd": null
11
+ },
12
+ "taskSummary": {
13
+ "total": 1,
14
+ "done": 0,
15
+ "running": 1,
16
+ "failed": 0,
17
+ "review_blocked": 0,
18
+ "disputed": 0,
19
+ "reviewed": 0,
20
+ "panel_reviewed": 0,
21
+ "tasks_with_drift": 0,
22
+ "max_drift_score": null,
23
+ "drift_retried": 0
24
+ },
25
+ "quality": {
26
+ "reviewed_tasks": 0,
27
+ "review_blocked_tasks": 0,
28
+ "disputed_tasks": 0,
29
+ "panel_reviews": 0
30
+ },
31
+ "drift": {
32
+ "tasks_with_drift": 0,
33
+ "max_drift_score": null,
34
+ "drift_retried_tasks": 0
35
+ },
36
+ "dlq_count": 0,
37
+ "dlq_entries": [],
38
+ "artifact_count": 0,
39
+ "artifacts": [],
40
+ "has_more_events": false,
41
+ "events": [
42
+ {
43
+ "type": "task_started",
44
+ "task_id": "task-2-a",
45
+ "data": null,
46
+ "created_at": "2026-01-15T10:05:01.000Z"
47
+ }
48
+ ],
49
+ "tasks": [
50
+ {
51
+ "id": "task-2-a",
52
+ "phase": 1,
53
+ "agent": "developer",
54
+ "model": "claude-sonnet-4-6",
55
+ "status": "running",
56
+ "retries": 0,
57
+ "started_at": "2026-01-15T10:05:01.000Z",
58
+ "finished_at": null,
59
+ "total_tokens": null,
60
+ "cost_usd": null,
61
+ "review_level": null,
62
+ "review_verdict": null,
63
+ "review_tokens": null,
64
+ "review_model": null,
65
+ "panel_attempts": 0,
66
+ "dispute_id": null,
67
+ "drift_score": null,
68
+ "drift_retried": 0,
69
+ "files": null
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "convoyCounts": {
3
+ "total": 2,
4
+ "running": 1,
5
+ "done": 1,
6
+ "failed": 0,
7
+ "gate_failed": 0
8
+ },
9
+ "durationStats": {
10
+ "avg_sec": 33.000022172927856,
11
+ "p95_sec": 33.000022172927856,
12
+ "max_sec": 33.000022172927856
13
+ },
14
+ "tokenCostTotals": {
15
+ "total_tokens": 8432,
16
+ "total_cost_usd": 0.84
17
+ },
18
+ "topAgents": [
19
+ {
20
+ "agent": "developer",
21
+ "task_count": 3,
22
+ "total_tokens": 8432
23
+ }
24
+ ],
25
+ "topModels": [
26
+ {
27
+ "model": "claude-sonnet-4-6",
28
+ "task_count": 3,
29
+ "total_tokens": 8432
30
+ }
31
+ ],
32
+ "dlqSummary": {
33
+ "count": 0,
34
+ "top_failure_types": []
35
+ }
36
+ }
@@ -0,0 +1,210 @@
1
+ import { mkdtempSync, rmSync, realpathSync, readFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
5
+ import { runEtl } from './etl.js'
6
+ import { createConvoyStore } from '../../cli/convoy/store.js'
7
+
8
+ function makeTmpDir(): string {
9
+ return realpathSync(mkdtempSync(join(tmpdir(), 'etl-test-')))
10
+ }
11
+
12
+ let tmpDir: string
13
+ let outputDir: string
14
+
15
+ beforeEach(() => {
16
+ tmpDir = makeTmpDir()
17
+ outputDir = join(tmpDir, 'data')
18
+ })
19
+
20
+ afterEach(() => {
21
+ rmSync(tmpDir, { recursive: true, force: true })
22
+ })
23
+
24
+ describe('runEtl — no database', () => {
25
+ it('writes empty overall-stats.json when db is missing', async () => {
26
+ const dbPath = join(tmpDir, 'nonexistent.db')
27
+ await runEtl({ dbPath, outputDir })
28
+ const stats = JSON.parse(readFileSync(join(outputDir, 'overall-stats.json'), 'utf8'))
29
+ expect(stats).toMatchObject({
30
+ convoyCounts: { total: 0, running: 0, done: 0, failed: 0, gate_failed: 0 },
31
+ durationStats: { avg_sec: null, p95_sec: null, max_sec: null },
32
+ tokenCostTotals: { total_tokens: 0, total_cost_usd: 0 },
33
+ topAgents: [],
34
+ topModels: [],
35
+ dlqSummary: { count: 0, top_failure_types: [] },
36
+ })
37
+ })
38
+
39
+ it('writes empty convoy-list.json when db is missing', async () => {
40
+ const dbPath = join(tmpDir, 'nonexistent.db')
41
+ await runEtl({ dbPath, outputDir })
42
+ const list = JSON.parse(readFileSync(join(outputDir, 'convoy-list.json'), 'utf8'))
43
+ expect(Array.isArray(list)).toBe(true)
44
+ expect(list).toHaveLength(0)
45
+ })
46
+
47
+ it('returns zero counts when db is missing', async () => {
48
+ const dbPath = join(tmpDir, 'nonexistent.db')
49
+ const result = await runEtl({ dbPath, outputDir })
50
+ expect(result).toEqual({ convoyCount: 0, taskCount: 0 })
51
+ })
52
+
53
+ it('creates the output directory structure even when db is missing', async () => {
54
+ const dbPath = join(tmpDir, 'nonexistent.db')
55
+ await runEtl({ dbPath, outputDir })
56
+ expect(existsSync(outputDir)).toBe(true)
57
+ expect(existsSync(join(outputDir, 'convoys'))).toBe(true)
58
+ })
59
+ })
60
+
61
+ describe('runEtl — with seeded database', () => {
62
+ let dbPath: string
63
+
64
+ beforeEach(() => {
65
+ dbPath = join(tmpDir, 'convoy.db')
66
+ const store = createConvoyStore(dbPath)
67
+ try {
68
+ store.insertConvoy({
69
+ id: 'convoy-abc',
70
+ name: 'Test Convoy',
71
+ spec_hash: 'abc123',
72
+ status: 'done',
73
+ branch: 'main',
74
+ created_at: '2026-03-01T10:00:00.000Z',
75
+ spec_yaml: 'tasks: []',
76
+ })
77
+ store.insertConvoy({
78
+ id: 'convoy-def',
79
+ name: 'Second Convoy',
80
+ spec_hash: 'def456',
81
+ status: 'failed',
82
+ branch: null,
83
+ created_at: '2026-03-02T10:00:00.000Z',
84
+ spec_yaml: 'tasks: []',
85
+ })
86
+ store.insertTask({
87
+ id: 'task-001',
88
+ convoy_id: 'convoy-abc',
89
+ phase: 1,
90
+ prompt: 'Do the thing',
91
+ agent: 'developer',
92
+ adapter: null,
93
+ model: 'claude-opus-4-6',
94
+ timeout_ms: 30000,
95
+ status: 'done',
96
+ retries: 0,
97
+ depends_on: null,
98
+ files: null,
99
+ gates: null,
100
+ max_retries: 3,
101
+ })
102
+ store.insertTask({
103
+ id: 'task-002',
104
+ convoy_id: 'convoy-abc',
105
+ phase: 2,
106
+ prompt: 'Do another thing',
107
+ agent: 'reviewer',
108
+ adapter: null,
109
+ model: 'claude-opus-4-6',
110
+ timeout_ms: 30000,
111
+ status: 'done',
112
+ retries: 1,
113
+ depends_on: null,
114
+ files: null,
115
+ gates: null,
116
+ max_retries: 3,
117
+ })
118
+ } finally {
119
+ store.close()
120
+ }
121
+ })
122
+
123
+ it('returns correct convoy and task counts', async () => {
124
+ const result = await runEtl({ dbPath, outputDir })
125
+ expect(result.convoyCount).toBe(2)
126
+ expect(result.taskCount).toBe(2)
127
+ })
128
+
129
+ it('overall-stats.json has correct convoy counts', async () => {
130
+ await runEtl({ dbPath, outputDir })
131
+ const stats = JSON.parse(readFileSync(join(outputDir, 'overall-stats.json'), 'utf8'))
132
+ expect(stats.convoyCounts).toMatchObject({ total: 2 })
133
+ expect(stats.durationStats).toHaveProperty('avg_sec')
134
+ expect(stats.tokenCostTotals).toHaveProperty('total_tokens')
135
+ expect(Array.isArray(stats.topAgents)).toBe(true)
136
+ expect(Array.isArray(stats.topModels)).toBe(true)
137
+ expect(stats.dlqSummary).toHaveProperty('count')
138
+ })
139
+
140
+ it('convoy-list.json contains all convoys with required fields', async () => {
141
+ await runEtl({ dbPath, outputDir })
142
+ const list = JSON.parse(readFileSync(join(outputDir, 'convoy-list.json'), 'utf8'))
143
+ expect(list).toHaveLength(2)
144
+ for (const item of list) {
145
+ expect(item).toHaveProperty('id')
146
+ expect(item).toHaveProperty('name')
147
+ expect(item).toHaveProperty('status')
148
+ expect(item).toHaveProperty('created_at')
149
+ expect(item).toHaveProperty('finished_at')
150
+ expect(item).toHaveProperty('total_tokens')
151
+ expect(item).toHaveProperty('total_cost_usd')
152
+ }
153
+ })
154
+
155
+ it('creates per-convoy detail JSON files', async () => {
156
+ await runEtl({ dbPath, outputDir })
157
+ const detailPath = join(outputDir, 'convoys', 'convoy-abc.json')
158
+ expect(existsSync(detailPath)).toBe(true)
159
+ const detail = JSON.parse(readFileSync(detailPath, 'utf8'))
160
+ expect(detail.convoy.id).toBe('convoy-abc')
161
+ expect(detail.convoy.name).toBe('Test Convoy')
162
+ expect(detail.convoy.status).toBe('done')
163
+ expect(detail.convoy).toHaveProperty('branch')
164
+ expect(detail.convoy).toHaveProperty('total_tokens')
165
+ expect(detail.convoy).toHaveProperty('total_cost_usd')
166
+ expect(detail).toHaveProperty('taskSummary')
167
+ expect(detail.taskSummary).toHaveProperty('total')
168
+ expect(Array.isArray(detail.tasks)).toBe(true)
169
+ })
170
+
171
+ it('detail file has correct task fields', async () => {
172
+ await runEtl({ dbPath, outputDir })
173
+ const detail = JSON.parse(
174
+ readFileSync(join(outputDir, 'convoys', 'convoy-abc.json'), 'utf8'),
175
+ )
176
+ expect(detail.tasks).toHaveLength(2)
177
+ for (const task of detail.tasks) {
178
+ expect(task).toHaveProperty('id')
179
+ expect(task).toHaveProperty('phase')
180
+ expect(task).toHaveProperty('agent')
181
+ expect(task).toHaveProperty('model')
182
+ expect(task).toHaveProperty('status')
183
+ expect(task).toHaveProperty('retries')
184
+ expect(task).toHaveProperty('started_at')
185
+ expect(task).toHaveProperty('finished_at')
186
+ expect(task).toHaveProperty('total_tokens')
187
+ expect(task).toHaveProperty('cost_usd')
188
+ expect(task).toHaveProperty('review_level')
189
+ expect(task).toHaveProperty('review_verdict')
190
+ expect(task).toHaveProperty('drift_score')
191
+ }
192
+ })
193
+
194
+ it('creates detail file for each convoy', async () => {
195
+ await runEtl({ dbPath, outputDir })
196
+ expect(existsSync(join(outputDir, 'convoys', 'convoy-abc.json'))).toBe(true)
197
+ expect(existsSync(join(outputDir, 'convoys', 'convoy-def.json'))).toBe(true)
198
+ })
199
+
200
+ it('detail file includes artifacts and events fields', async () => {
201
+ await runEtl({ dbPath, outputDir })
202
+ const detail = JSON.parse(
203
+ readFileSync(join(outputDir, 'convoys', 'convoy-abc.json'), 'utf8'),
204
+ )
205
+ expect(Array.isArray(detail.artifacts)).toBe(true)
206
+ expect(typeof detail.artifact_count).toBe('number')
207
+ expect(typeof detail.has_more_events).toBe('boolean')
208
+ expect(Array.isArray(detail.events)).toBe(true)
209
+ })
210
+ })
@@ -0,0 +1,121 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
+ import { resolve, dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __filename = fileURLToPath(import.meta.url)
6
+ const __dirname = dirname(__filename)
7
+
8
+ export interface EtlOptions {
9
+ dbPath: string
10
+ outputDir: string
11
+ }
12
+
13
+ export interface EtlResult {
14
+ convoyCount: number
15
+ taskCount: number
16
+ }
17
+
18
+ const EMPTY_OVERALL_STATS = {
19
+ convoyCounts: { total: 0, running: 0, done: 0, failed: 0, gate_failed: 0 },
20
+ durationStats: { avg_sec: null, p95_sec: null, max_sec: null },
21
+ tokenCostTotals: { total_tokens: 0, total_cost_usd: 0 },
22
+ topAgents: [] as unknown[],
23
+ topModels: [] as unknown[],
24
+ dlqSummary: { count: 0, top_failure_types: [] as unknown[] },
25
+ }
26
+
27
+ export async function runEtl(options: EtlOptions): Promise<EtlResult> {
28
+ const { dbPath, outputDir } = options
29
+
30
+ mkdirSync(outputDir, { recursive: true })
31
+ mkdirSync(resolve(outputDir, 'convoys'), { recursive: true })
32
+
33
+ if (!existsSync(dbPath)) {
34
+ console.warn(` \u26a0 No convoy database found at ${dbPath}. Writing empty JSON files.`)
35
+ writeFileSync(
36
+ resolve(outputDir, 'overall-stats.json'),
37
+ JSON.stringify(EMPTY_OVERALL_STATS, null, 2),
38
+ 'utf8',
39
+ )
40
+ writeFileSync(resolve(outputDir, 'convoy-list.json'), JSON.stringify([], null, 2), 'utf8')
41
+ return { convoyCount: 0, taskCount: 0 }
42
+ }
43
+
44
+ const { createConvoyStore } = await import('../../cli/convoy/store.js')
45
+ const store = createConvoyStore(dbPath)
46
+
47
+ try {
48
+ const overallStats = {
49
+ convoyCounts: store.getConvoyCounts(),
50
+ durationStats: store.getConvoyDurationStats(),
51
+ tokenCostTotals: store.getTokenAndCostTotals(),
52
+ topAgents: store.getTopAgents(5),
53
+ topModels: store.getTopModels(5),
54
+ dlqSummary: store.getDlqSummary(),
55
+ }
56
+ writeFileSync(
57
+ resolve(outputDir, 'overall-stats.json'),
58
+ JSON.stringify(overallStats, null, 2),
59
+ 'utf8',
60
+ )
61
+
62
+ const allConvoys = store.getConvoyList(1000, 0)
63
+ const convoyList = allConvoys.map(c => ({
64
+ id: c.id,
65
+ name: c.name,
66
+ status: c.status,
67
+ created_at: c.created_at,
68
+ finished_at: c.finished_at,
69
+ total_tokens: c.total_tokens,
70
+ total_cost_usd: c.total_cost_usd,
71
+ }))
72
+ writeFileSync(
73
+ resolve(outputDir, 'convoy-list.json'),
74
+ JSON.stringify(convoyList, null, 2),
75
+ 'utf8',
76
+ )
77
+
78
+ let totalTasks = 0
79
+ for (const convoy of allConvoys) {
80
+ const detail = store.getConvoyDetails(convoy.id)
81
+ if (detail) {
82
+ totalTasks += detail.tasks.length
83
+ writeFileSync(
84
+ resolve(outputDir, 'convoys', `${convoy.id}.json`),
85
+ JSON.stringify(detail, null, 2),
86
+ 'utf8',
87
+ )
88
+ }
89
+ }
90
+
91
+ console.log(`ETL complete: ${allConvoys.length} convoys exported, ${totalTasks} tasks.`)
92
+ return { convoyCount: allConvoys.length, taskCount: totalTasks }
93
+ } finally {
94
+ store.close()
95
+ }
96
+ }
97
+
98
+ function parseArgs(): { db?: string; out?: string } {
99
+ const args = process.argv.slice(2)
100
+ const result: Record<string, string> = {}
101
+ for (let i = 0; i < args.length; i++) {
102
+ const a = args[i]
103
+ if (a === '--db' && args[i+1]) { result.db = args[++i] }
104
+ else if (a === '--out' && args[i+1]) { result.out = args[++i] }
105
+ }
106
+ return result
107
+ }
108
+
109
+ const isMain =
110
+ process.argv[1] != null &&
111
+ fileURLToPath(import.meta.url) === resolve(process.argv[1])
112
+
113
+ if (isMain) {
114
+ const parsed = parseArgs()
115
+ const dbPath = parsed.db != null ? resolve(process.cwd(), parsed.db) : resolve(process.cwd(), '.opencastle', 'convoy.db')
116
+ const outputDir = parsed.out != null ? resolve(process.cwd(), parsed.out) : resolve(__dirname, '..', 'public', 'data')
117
+ runEtl({ dbPath, outputDir }).catch((err: unknown) => {
118
+ console.error('ETL failed:', (err as Error).message)
119
+ process.exit(1)
120
+ })
121
+ }
@@ -0,0 +1,30 @@
1
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
5
+ import { createDemoDb } from './generate-demo-db.js'
6
+ import { runEtl } from './etl.js'
7
+
8
+ function makeTmp(): string {
9
+ return mkdtempSync(join(tmpdir(), 'demo-db-test-'))
10
+ }
11
+
12
+ let tmp: string
13
+ beforeEach(() => { tmp = makeTmp() })
14
+ afterEach(() => { try { rmSync(tmp, { recursive: true, force: true }) } catch {} })
15
+
16
+ describe('generate-demo-db + etl', () => {
17
+ it('creates a demo DB and produces ETL JSON', async () => {
18
+ const dbPath = join(tmp, 'convoy-demo.db')
19
+ await createDemoDb(dbPath)
20
+
21
+ const outDir = join(tmp, 'out')
22
+ const res = await runEtl({ dbPath, outputDir: outDir })
23
+
24
+ expect(res.convoyCount).toBeGreaterThanOrEqual(1)
25
+ const overall = JSON.parse(readFileSync(join(outDir, 'overall-stats.json'), 'utf8'))
26
+ const list = JSON.parse(readFileSync(join(outDir, 'convoy-list.json'), 'utf8'))
27
+ expect(Array.isArray(list)).toBe(true)
28
+ expect(overall).toHaveProperty('convoyCounts')
29
+ })
30
+ })