opencastle 0.10.7 → 0.12.0

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 (132) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.mjs +4 -0
  3. package/dist/cli/convoy/events.d.ts +10 -0
  4. package/dist/cli/convoy/events.d.ts.map +1 -0
  5. package/dist/cli/convoy/events.js +27 -0
  6. package/dist/cli/convoy/events.js.map +1 -0
  7. package/dist/cli/convoy/events.test.d.ts +2 -0
  8. package/dist/cli/convoy/events.test.d.ts.map +1 -0
  9. package/dist/cli/convoy/events.test.js +94 -0
  10. package/dist/cli/convoy/events.test.js.map +1 -0
  11. package/dist/cli/convoy/store.d.ts +23 -0
  12. package/dist/cli/convoy/store.d.ts.map +1 -0
  13. package/dist/cli/convoy/store.js +210 -0
  14. package/dist/cli/convoy/store.js.map +1 -0
  15. package/dist/cli/convoy/store.test.d.ts +2 -0
  16. package/dist/cli/convoy/store.test.d.ts.map +1 -0
  17. package/dist/cli/convoy/store.test.js +387 -0
  18. package/dist/cli/convoy/store.test.js.map +1 -0
  19. package/dist/cli/convoy/types.d.ts +56 -0
  20. package/dist/cli/convoy/types.d.ts.map +1 -0
  21. package/dist/cli/convoy/types.js +2 -0
  22. package/dist/cli/convoy/types.js.map +1 -0
  23. package/dist/cli/dashboard.d.ts.map +1 -1
  24. package/dist/cli/dashboard.js +5 -1
  25. package/dist/cli/dashboard.js.map +1 -1
  26. package/dist/cli/init.test.js +1 -1
  27. package/dist/cli/init.test.js.map +1 -1
  28. package/dist/cli/lesson.d.ts +17 -0
  29. package/dist/cli/lesson.d.ts.map +1 -0
  30. package/dist/cli/lesson.js +294 -0
  31. package/dist/cli/lesson.js.map +1 -0
  32. package/dist/cli/log.d.ts +7 -0
  33. package/dist/cli/log.d.ts.map +1 -0
  34. package/dist/cli/log.js +131 -0
  35. package/dist/cli/log.js.map +1 -0
  36. package/dist/cli/run/executor.js.map +1 -1
  37. package/dist/cli/run/executor.test.js +1 -0
  38. package/dist/cli/run/executor.test.js.map +1 -1
  39. package/dist/cli/run/loop-executor.d.ts +3 -0
  40. package/dist/cli/run/loop-executor.d.ts.map +1 -0
  41. package/dist/cli/run/loop-executor.js +155 -0
  42. package/dist/cli/run/loop-executor.js.map +1 -0
  43. package/dist/cli/run/loop-reporter.d.ts +6 -0
  44. package/dist/cli/run/loop-reporter.d.ts.map +1 -0
  45. package/dist/cli/run/loop-reporter.js +112 -0
  46. package/dist/cli/run/loop-reporter.js.map +1 -0
  47. package/dist/cli/run/reporter.d.ts.map +1 -1
  48. package/dist/cli/run/reporter.js +28 -1
  49. package/dist/cli/run/reporter.js.map +1 -1
  50. package/dist/cli/run/schema.d.ts +4 -0
  51. package/dist/cli/run/schema.d.ts.map +1 -1
  52. package/dist/cli/run/schema.js +178 -50
  53. package/dist/cli/run/schema.js.map +1 -1
  54. package/dist/cli/run/schema.test.js +598 -1
  55. package/dist/cli/run/schema.test.js.map +1 -1
  56. package/dist/cli/run.d.ts.map +1 -1
  57. package/dist/cli/run.js +84 -3
  58. package/dist/cli/run.js.map +1 -1
  59. package/dist/cli/types.d.ts +78 -1
  60. package/dist/cli/types.d.ts.map +1 -1
  61. package/dist/cli/update.d.ts.map +1 -1
  62. package/dist/cli/update.js +54 -1
  63. package/dist/cli/update.js.map +1 -1
  64. package/package.json +3 -2
  65. package/src/cli/convoy/events.test.ts +118 -0
  66. package/src/cli/convoy/events.ts +41 -0
  67. package/src/cli/convoy/store.test.ts +446 -0
  68. package/src/cli/convoy/store.ts +308 -0
  69. package/src/cli/convoy/types.ts +68 -0
  70. package/src/cli/dashboard.ts +5 -1
  71. package/src/cli/init.test.ts +1 -1
  72. package/src/cli/lesson.ts +312 -0
  73. package/src/cli/log.ts +133 -0
  74. package/src/cli/run/executor.test.ts +1 -0
  75. package/src/cli/run/executor.ts +8 -8
  76. package/src/cli/run/loop-executor.ts +199 -0
  77. package/src/cli/run/loop-reporter.ts +125 -0
  78. package/src/cli/run/reporter.ts +30 -1
  79. package/src/cli/run/schema.test.ts +704 -3
  80. package/src/cli/run/schema.ts +206 -56
  81. package/src/cli/run.ts +82 -5
  82. package/src/cli/types.ts +87 -1
  83. package/src/cli/update.ts +62 -1
  84. package/src/dashboard/dist/index.html +14 -15
  85. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  86. package/src/dashboard/scripts/generate-seed-data.ts +23 -43
  87. package/src/dashboard/seed-data/events.ndjson +104 -0
  88. package/src/dashboard/src/pages/index.astro +14 -15
  89. package/src/orchestrator/agents/api-designer.agent.md +1 -1
  90. package/src/orchestrator/agents/architect.agent.md +1 -1
  91. package/src/orchestrator/agents/content-engineer.agent.md +1 -1
  92. package/src/orchestrator/agents/copywriter.agent.md +1 -1
  93. package/src/orchestrator/agents/data-expert.agent.md +1 -1
  94. package/src/orchestrator/agents/database-engineer.agent.md +1 -1
  95. package/src/orchestrator/agents/developer.agent.md +1 -1
  96. package/src/orchestrator/agents/devops-expert.agent.md +1 -1
  97. package/src/orchestrator/agents/documentation-writer.agent.md +1 -1
  98. package/src/orchestrator/agents/performance-expert.agent.md +1 -1
  99. package/src/orchestrator/agents/release-manager.agent.md +1 -1
  100. package/src/orchestrator/agents/security-expert.agent.md +1 -1
  101. package/src/orchestrator/agents/seo-specialist.agent.md +1 -1
  102. package/src/orchestrator/agents/session-guard.agent.md +9 -21
  103. package/src/orchestrator/agents/team-lead.agent.md +8 -34
  104. package/src/orchestrator/agents/testing-expert.agent.md +1 -1
  105. package/src/orchestrator/agents/ui-ux-expert.agent.md +1 -1
  106. package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +11 -12
  107. package/src/orchestrator/customizations/DISPUTES.md +2 -2
  108. package/src/orchestrator/customizations/README.md +1 -3
  109. package/src/orchestrator/customizations/logs/README.md +66 -14
  110. package/src/orchestrator/instructions/ai-optimization.instructions.md +21 -132
  111. package/src/orchestrator/instructions/general.instructions.md +35 -181
  112. package/src/orchestrator/plugins/nx/SKILL.md +1 -1
  113. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +4 -8
  114. package/src/orchestrator/prompts/bug-fix.prompt.md +4 -4
  115. package/src/orchestrator/prompts/implement-feature.prompt.md +3 -3
  116. package/src/orchestrator/prompts/quick-refinement.prompt.md +3 -3
  117. package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +1 -1
  118. package/src/orchestrator/skills/agent-hooks/SKILL.md +11 -11
  119. package/src/orchestrator/skills/decomposition/SKILL.md +1 -1
  120. package/src/orchestrator/skills/fast-review/SKILL.md +4 -19
  121. package/src/orchestrator/skills/git-workflow/SKILL.md +72 -0
  122. package/src/orchestrator/skills/memory-merger/SKILL.md +1 -1
  123. package/src/orchestrator/skills/observability-logging/SKILL.md +129 -0
  124. package/src/orchestrator/skills/orchestration-protocols/SKILL.md +2 -2
  125. package/src/orchestrator/skills/panel-majority-vote/SKILL.md +4 -7
  126. package/src/orchestrator/skills/self-improvement/SKILL.md +13 -26
  127. package/src/orchestrator/skills/team-lead-reference/SKILL.md +2 -2
  128. package/src/orchestrator/customizations/logs/delegations.ndjson +0 -1
  129. package/src/orchestrator/customizations/logs/panels.ndjson +0 -1
  130. package/src/orchestrator/customizations/logs/reviews.ndjson +0 -0
  131. package/src/orchestrator/customizations/logs/sessions.ndjson +0 -1
  132. /package/src/orchestrator/customizations/logs/{disputes.ndjson → events.ndjson} +0 -0
@@ -0,0 +1,118 @@
1
+ import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
+ import { createConvoyStore } from './store.js'
6
+ import { createEventEmitter } from './events.js'
7
+ import type { ConvoyStore } from './store.js'
8
+
9
+ vi.mock('../log.js', () => ({
10
+ appendEvent: vi.fn().mockResolvedValue(undefined),
11
+ }))
12
+
13
+ import { appendEvent } from '../log.js'
14
+ const mockAppend = vi.mocked(appendEvent)
15
+
16
+ let tmpDir: string
17
+ let store: ConvoyStore
18
+
19
+ beforeEach(() => {
20
+ tmpDir = mkdtempSync(join(tmpdir(), 'emitter-test-'))
21
+ store = createConvoyStore(join(tmpDir, 'test.db'))
22
+ vi.clearAllMocks()
23
+
24
+ store.insertConvoy({
25
+ id: 'c1',
26
+ name: 'Test',
27
+ spec_hash: 'x',
28
+ status: 'pending',
29
+ branch: null,
30
+ created_at: new Date().toISOString(),
31
+ spec_yaml: 'name: test',
32
+ })
33
+ })
34
+
35
+ afterEach(() => {
36
+ store.close()
37
+ rmSync(tmpDir, { recursive: true, force: true })
38
+ })
39
+
40
+ describe('createEventEmitter', () => {
41
+ it('inserts the event into SQLite', () => {
42
+ const emitter = createEventEmitter(store)
43
+ emitter.emit('task_started', { msg: 'started' }, { convoy_id: 'c1' })
44
+ const events = store.getEvents('c1')
45
+ expect(events).toHaveLength(1)
46
+ expect(events[0].type).toBe('task_started')
47
+ expect(events[0].convoy_id).toBe('c1')
48
+ })
49
+
50
+ it('serializes event data to JSON in SQLite', () => {
51
+ const emitter = createEventEmitter(store)
52
+ emitter.emit('task_done', { exitCode: 0, output: 'ok' }, { convoy_id: 'c1' })
53
+ const events = store.getEvents('c1')
54
+ const parsed = JSON.parse(events[0].data!)
55
+ expect(parsed.exitCode).toBe(0)
56
+ expect(parsed.output).toBe('ok')
57
+ })
58
+
59
+ it('stores null data when no data object is provided', () => {
60
+ const emitter = createEventEmitter(store)
61
+ emitter.emit('heartbeat', undefined, { convoy_id: 'c1' })
62
+ const events = store.getEvents('c1')
63
+ expect(events[0].data).toBeNull()
64
+ })
65
+
66
+ it('calls appendEvent for NDJSON dual-write', () => {
67
+ const emitter = createEventEmitter(store)
68
+ emitter.emit('convoy_started', { name: 'test' }, { convoy_id: 'c1' })
69
+ expect(mockAppend).toHaveBeenCalledOnce()
70
+ })
71
+
72
+ it('passes logs dir to appendEvent', () => {
73
+ const emitter = createEventEmitter(store, '/some/logs')
74
+ emitter.emit('convoy_started', {}, { convoy_id: 'c1' })
75
+ expect(mockAppend).toHaveBeenCalledWith(
76
+ expect.objectContaining({ type: 'convoy_started', convoy_id: 'c1' }),
77
+ '/some/logs',
78
+ )
79
+ })
80
+
81
+ it('defaults all ids to null when ids are not provided', () => {
82
+ const emitter = createEventEmitter(store)
83
+ emitter.emit('generic_event')
84
+ const db = require('node:sqlite').DatabaseSync
85
+ // Verify via NDJSON mock payload
86
+ expect(mockAppend).toHaveBeenCalledWith(
87
+ expect.objectContaining({
88
+ convoy_id: null,
89
+ task_id: null,
90
+ worker_id: null,
91
+ }),
92
+ null,
93
+ )
94
+ })
95
+
96
+ it('includes all provided ids in the NDJSON record', () => {
97
+ const emitter = createEventEmitter(store, tmpDir)
98
+ emitter.emit('worker_spawned', {}, { convoy_id: 'c1', task_id: 't1', worker_id: 'w1' })
99
+ expect(mockAppend).toHaveBeenCalledWith(
100
+ expect.objectContaining({ convoy_id: 'c1', task_id: 't1', worker_id: 'w1' }),
101
+ tmpDir,
102
+ )
103
+ })
104
+
105
+ it('SQLite event stores correct ids', () => {
106
+ const emitter = createEventEmitter(store)
107
+ emitter.emit('worker_done', {}, { convoy_id: 'c1', task_id: 'task-x', worker_id: 'wkr-y' })
108
+ const events = store.getEvents('c1')
109
+ expect(events[0].task_id).toBe('task-x')
110
+ expect(events[0].worker_id).toBe('wkr-y')
111
+ })
112
+
113
+ it('does not throw if NDJSON write fails', () => {
114
+ mockAppend.mockRejectedValueOnce(new Error('disk full'))
115
+ const emitter = createEventEmitter(store)
116
+ expect(() => emitter.emit('test', {}, { convoy_id: 'c1' })).not.toThrow()
117
+ })
118
+ })
@@ -0,0 +1,41 @@
1
+ import { appendEvent as appendNdjson } from '../log.js'
2
+ import type { ConvoyStore } from './store.js'
3
+
4
+ export interface ConvoyEventEmitter {
5
+ emit(
6
+ type: string,
7
+ data?: Record<string, unknown>,
8
+ ids?: { convoy_id?: string; task_id?: string; worker_id?: string },
9
+ ): void
10
+ }
11
+
12
+ export function createEventEmitter(store: ConvoyStore, logsDir?: string): ConvoyEventEmitter {
13
+ return {
14
+ emit(type, data, ids) {
15
+ const now = new Date().toISOString()
16
+
17
+ store.insertEvent({
18
+ convoy_id: ids?.convoy_id ?? null,
19
+ task_id: ids?.task_id ?? null,
20
+ worker_id: ids?.worker_id ?? null,
21
+ type,
22
+ data: data !== undefined ? JSON.stringify(data) : null,
23
+ created_at: now,
24
+ })
25
+
26
+ appendNdjson(
27
+ {
28
+ timestamp: now,
29
+ type,
30
+ convoy_id: ids?.convoy_id ?? null,
31
+ task_id: ids?.task_id ?? null,
32
+ worker_id: ids?.worker_id ?? null,
33
+ ...(data ?? {}),
34
+ },
35
+ logsDir ?? null,
36
+ ).catch(() => {
37
+ // fire-and-forget: NDJSON write failure must not crash the convoy engine
38
+ })
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,446 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { DatabaseSync } from 'node:sqlite'
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
6
+ import { createConvoyStore } from './store.js'
7
+ import type { ConvoyStore } from './store.js'
8
+
9
+ // ── helpers ───────────────────────────────────────────────────────────────────
10
+
11
+ let tmpDir: string
12
+ let dbPath: string
13
+ let store: ConvoyStore
14
+
15
+ beforeEach(() => {
16
+ tmpDir = mkdtempSync(join(tmpdir(), 'convoy-test-'))
17
+ dbPath = join(tmpDir, 'test.db')
18
+ store = createConvoyStore(dbPath)
19
+ })
20
+
21
+ afterEach(() => {
22
+ store.close()
23
+ rmSync(tmpDir, { recursive: true, force: true })
24
+ })
25
+
26
+ function makeConvoy(overrides: Partial<Parameters<ConvoyStore['insertConvoy']>[0]> = {}) {
27
+ return {
28
+ id: 'convoy-1',
29
+ name: 'Test Convoy',
30
+ spec_hash: 'abc123',
31
+ status: 'pending' as const,
32
+ branch: null,
33
+ created_at: new Date().toISOString(),
34
+ spec_yaml: 'name: test',
35
+ ...overrides,
36
+ }
37
+ }
38
+
39
+ function makeTask(overrides: Partial<Parameters<ConvoyStore['insertTask']>[0]> = {}) {
40
+ return {
41
+ id: 'task-1',
42
+ convoy_id: 'convoy-1',
43
+ phase: 0,
44
+ prompt: 'Do something',
45
+ agent: 'developer',
46
+ model: null,
47
+ timeout_ms: 1_800_000,
48
+ status: 'pending' as const,
49
+ retries: 0,
50
+ max_retries: 1,
51
+ files: null,
52
+ depends_on: null,
53
+ ...overrides,
54
+ }
55
+ }
56
+
57
+ function makeWorker(overrides: Partial<Parameters<ConvoyStore['insertWorker']>[0]> = {}) {
58
+ return {
59
+ id: 'worker-1',
60
+ task_id: null,
61
+ adapter: 'copilot',
62
+ pid: null,
63
+ session_id: null,
64
+ status: 'spawned' as const,
65
+ worktree: null,
66
+ created_at: new Date().toISOString(),
67
+ ...overrides,
68
+ }
69
+ }
70
+
71
+ // ── DB creation and WAL mode ──────────────────────────────────────────────────
72
+
73
+ describe('DB creation', () => {
74
+ it('creates the database file at the given path', async () => {
75
+ const { existsSync } = await import('node:fs')
76
+ expect(existsSync(dbPath)).toBe(true)
77
+ })
78
+
79
+ it('sets WAL journal mode', () => {
80
+ const db = new DatabaseSync(dbPath)
81
+ const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }
82
+ db.close()
83
+ expect(row.journal_mode).toBe('wal')
84
+ })
85
+
86
+ it('sets schema version to 1', () => {
87
+ const db = new DatabaseSync(dbPath)
88
+ const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
89
+ db.close()
90
+ expect(row.user_version).toBe(1)
91
+ })
92
+
93
+ it('creates all required tables', () => {
94
+ const db = new DatabaseSync(dbPath)
95
+ const tables = db
96
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
97
+ .all() as { name: string }[]
98
+ db.close()
99
+ const names = tables.map(t => t.name).sort()
100
+ expect(names).toContain('convoy')
101
+ expect(names).toContain('task')
102
+ expect(names).toContain('worker')
103
+ expect(names).toContain('event')
104
+ })
105
+
106
+ it('reopening an existing DB does not reset schema version', () => {
107
+ store.close()
108
+ const store2 = createConvoyStore(dbPath)
109
+ const db = new DatabaseSync(dbPath)
110
+ const row = db.prepare('PRAGMA user_version').get() as { user_version: number }
111
+ db.close()
112
+ store2.close()
113
+ // Reassign so afterEach does not double-close
114
+ store = createConvoyStore(dbPath)
115
+ expect(row.user_version).toBe(1)
116
+ })
117
+ })
118
+
119
+ // ── convoy CRUD ───────────────────────────────────────────────────────────────
120
+
121
+ describe('convoy CRUD', () => {
122
+ it('inserts and retrieves a convoy record', () => {
123
+ const record = makeConvoy()
124
+ store.insertConvoy(record)
125
+ const retrieved = store.getConvoy('convoy-1')
126
+ expect(retrieved).toBeDefined()
127
+ expect(retrieved!.id).toBe('convoy-1')
128
+ expect(retrieved!.name).toBe('Test Convoy')
129
+ expect(retrieved!.status).toBe('pending')
130
+ expect(retrieved!.started_at).toBeNull()
131
+ expect(retrieved!.finished_at).toBeNull()
132
+ })
133
+
134
+ it('returns undefined for missing convoy', () => {
135
+ expect(store.getConvoy('does-not-exist')).toBeUndefined()
136
+ })
137
+
138
+ it('updates convoy status', () => {
139
+ store.insertConvoy(makeConvoy())
140
+ store.updateConvoyStatus('convoy-1', 'running')
141
+ expect(store.getConvoy('convoy-1')!.status).toBe('running')
142
+ })
143
+
144
+ it('updates convoy status with started_at', () => {
145
+ const ts = '2026-01-01T00:00:00.000Z'
146
+ store.insertConvoy(makeConvoy())
147
+ store.updateConvoyStatus('convoy-1', 'running', { started_at: ts })
148
+ const retrieved = store.getConvoy('convoy-1')!
149
+ expect(retrieved.status).toBe('running')
150
+ expect(retrieved.started_at).toBe(ts)
151
+ })
152
+
153
+ it('updates convoy status with finished_at', () => {
154
+ const ts = '2026-01-01T01:00:00.000Z'
155
+ store.insertConvoy(makeConvoy())
156
+ store.updateConvoyStatus('convoy-1', 'done', { finished_at: ts })
157
+ const retrieved = store.getConvoy('convoy-1')!
158
+ expect(retrieved.status).toBe('done')
159
+ expect(retrieved.finished_at).toBe(ts)
160
+ })
161
+ })
162
+
163
+ // ── task CRUD ─────────────────────────────────────────────────────────────────
164
+
165
+ describe('task CRUD', () => {
166
+ beforeEach(() => {
167
+ store.insertConvoy(makeConvoy())
168
+ })
169
+
170
+ it('inserts and retrieves a task record', () => {
171
+ store.insertTask(makeTask())
172
+ const retrieved = store.getTask('task-1', 'convoy-1')
173
+ expect(retrieved).toBeDefined()
174
+ expect(retrieved!.id).toBe('task-1')
175
+ expect(retrieved!.convoy_id).toBe('convoy-1')
176
+ expect(retrieved!.status).toBe('pending')
177
+ expect(retrieved!.worker_id).toBeNull()
178
+ expect(retrieved!.output).toBeNull()
179
+ })
180
+
181
+ it('returns undefined for missing task', () => {
182
+ expect(store.getTask('does-not-exist', 'convoy-1')).toBeUndefined()
183
+ })
184
+
185
+ it('stores JSON fields as strings', () => {
186
+ const task = makeTask({
187
+ id: 'task-json',
188
+ files: JSON.stringify(['src/a.ts', 'src/b.ts']),
189
+ depends_on: JSON.stringify(['task-prev']),
190
+ })
191
+ store.insertTask(task)
192
+ const retrieved = store.getTask('task-json', 'convoy-1')!
193
+ expect(JSON.parse(retrieved.files!)).toEqual(['src/a.ts', 'src/b.ts'])
194
+ expect(JSON.parse(retrieved.depends_on!)).toEqual(['task-prev'])
195
+ })
196
+
197
+ it('retrieves all tasks for a convoy ordered by phase', () => {
198
+ store.insertTask(makeTask({ id: 'task-2', phase: 1 }))
199
+ store.insertTask(makeTask({ id: 'task-1', phase: 0 }))
200
+ const tasks = store.getTasksByConvoy('convoy-1')
201
+ expect(tasks).toHaveLength(2)
202
+ expect(tasks[0].phase).toBe(0)
203
+ expect(tasks[1].phase).toBe(1)
204
+ })
205
+
206
+ it('updates task status', () => {
207
+ store.insertTask(makeTask())
208
+ store.updateTaskStatus('task-1', 'convoy-1', 'running')
209
+ expect(store.getTask('task-1', 'convoy-1')!.status).toBe('running')
210
+ })
211
+
212
+ it('updates task status with extra fields', () => {
213
+ const ts = '2026-01-01T00:00:00.000Z'
214
+ store.insertTask(makeTask())
215
+ store.updateTaskStatus('task-1', 'convoy-1', 'done', {
216
+ output: 'Task complete',
217
+ exit_code: 0,
218
+ finished_at: ts,
219
+ retries: 1,
220
+ })
221
+ const task = store.getTask('task-1', 'convoy-1')!
222
+ expect(task.status).toBe('done')
223
+ expect(task.output).toBe('Task complete')
224
+ expect(task.exit_code).toBe(0)
225
+ expect(task.finished_at).toBe(ts)
226
+ expect(task.retries).toBe(1)
227
+ })
228
+ })
229
+
230
+ // ── getReadyTasks ─────────────────────────────────────────────────────────────
231
+
232
+ describe('getReadyTasks', () => {
233
+ beforeEach(() => {
234
+ store.insertConvoy(makeConvoy())
235
+ })
236
+
237
+ it('returns a pending task with no dependencies', () => {
238
+ store.insertTask(makeTask({ id: 'task-a', depends_on: null }))
239
+ const ready = store.getReadyTasks('convoy-1')
240
+ expect(ready.map(t => t.id)).toContain('task-a')
241
+ })
242
+
243
+ it('returns a pending task with empty depends_on array', () => {
244
+ store.insertTask(makeTask({ id: 'task-a', depends_on: JSON.stringify([]) }))
245
+ const ready = store.getReadyTasks('convoy-1')
246
+ expect(ready.map(t => t.id)).toContain('task-a')
247
+ })
248
+
249
+ it('returns a task when its single dependency is done', () => {
250
+ store.insertTask(makeTask({ id: 'task-dep', depends_on: null }))
251
+ store.insertTask(makeTask({ id: 'task-a', depends_on: JSON.stringify(['task-dep']) }))
252
+ store.updateTaskStatus('task-dep', 'convoy-1', 'done')
253
+ const ready = store.getReadyTasks('convoy-1')
254
+ expect(ready.map(t => t.id)).toContain('task-a')
255
+ })
256
+
257
+ it('does not return a task when its single dependency is not done', () => {
258
+ store.insertTask(makeTask({ id: 'task-dep', depends_on: null }))
259
+ store.insertTask(makeTask({ id: 'task-a', depends_on: JSON.stringify(['task-dep']) }))
260
+ // task-dep stays pending
261
+ const ready = store.getReadyTasks('convoy-1')
262
+ expect(ready.map(t => t.id)).not.toContain('task-a')
263
+ })
264
+
265
+ it('returns a task when all multiple dependencies are done', () => {
266
+ store.insertTask(makeTask({ id: 'dep-1', depends_on: null }))
267
+ store.insertTask(makeTask({ id: 'dep-2', depends_on: null }))
268
+ store.insertTask(makeTask({ id: 'task-a', depends_on: JSON.stringify(['dep-1', 'dep-2']) }))
269
+ store.updateTaskStatus('dep-1', 'convoy-1', 'done')
270
+ store.updateTaskStatus('dep-2', 'convoy-1', 'done')
271
+ const ready = store.getReadyTasks('convoy-1')
272
+ expect(ready.map(t => t.id)).toContain('task-a')
273
+ })
274
+
275
+ it('does not return a task when only some of multiple dependencies are done', () => {
276
+ store.insertTask(makeTask({ id: 'dep-1', depends_on: null }))
277
+ store.insertTask(makeTask({ id: 'dep-2', depends_on: null }))
278
+ store.insertTask(makeTask({ id: 'task-a', depends_on: JSON.stringify(['dep-1', 'dep-2']) }))
279
+ store.updateTaskStatus('dep-1', 'convoy-1', 'done')
280
+ // dep-2 stays pending
281
+ const ready = store.getReadyTasks('convoy-1')
282
+ expect(ready.map(t => t.id)).not.toContain('task-a')
283
+ })
284
+
285
+ it('does not return tasks that are already running', () => {
286
+ store.insertTask(makeTask({ id: 'task-a', depends_on: null }))
287
+ store.updateTaskStatus('task-a', 'convoy-1', 'running')
288
+ const ready = store.getReadyTasks('convoy-1')
289
+ expect(ready.map(t => t.id)).not.toContain('task-a')
290
+ })
291
+
292
+ it('does not return tasks that are already done', () => {
293
+ store.insertTask(makeTask({ id: 'task-a', depends_on: null }))
294
+ store.updateTaskStatus('task-a', 'convoy-1', 'done')
295
+ const ready = store.getReadyTasks('convoy-1')
296
+ expect(ready.map(t => t.id)).not.toContain('task-a')
297
+ })
298
+
299
+ it('returns empty array when no tasks are ready', () => {
300
+ store.insertTask(makeTask({ id: 'dep-1', depends_on: null, status: 'running' }))
301
+ store.insertTask(makeTask({ id: 'task-a', depends_on: JSON.stringify(['dep-1']) }))
302
+ const ready = store.getReadyTasks('convoy-1')
303
+ expect(ready.map(t => t.id)).not.toContain('task-a')
304
+ })
305
+
306
+ it('returns empty array for a convoy with no tasks', () => {
307
+ const ready = store.getReadyTasks('convoy-1')
308
+ expect(ready).toHaveLength(0)
309
+ })
310
+ })
311
+
312
+ // ── worker CRUD ───────────────────────────────────────────────────────────────
313
+
314
+ describe('worker CRUD', () => {
315
+ it('inserts and retrieves a worker record', () => {
316
+ store.insertWorker(makeWorker())
317
+ const retrieved = store.getWorker('worker-1')
318
+ expect(retrieved).toBeDefined()
319
+ expect(retrieved!.id).toBe('worker-1')
320
+ expect(retrieved!.adapter).toBe('copilot')
321
+ expect(retrieved!.status).toBe('spawned')
322
+ expect(retrieved!.finished_at).toBeNull()
323
+ expect(retrieved!.last_heartbeat).toBeNull()
324
+ })
325
+
326
+ it('returns undefined for missing worker', () => {
327
+ expect(store.getWorker('does-not-exist')).toBeUndefined()
328
+ })
329
+
330
+ it('updates worker status', () => {
331
+ store.insertWorker(makeWorker())
332
+ store.updateWorkerStatus('worker-1', 'running')
333
+ expect(store.getWorker('worker-1')!.status).toBe('running')
334
+ })
335
+
336
+ it('updates worker status with finished_at and pid', () => {
337
+ const ts = '2026-01-01T01:00:00.000Z'
338
+ store.insertWorker(makeWorker())
339
+ store.updateWorkerStatus('worker-1', 'done', { finished_at: ts, pid: 12345 })
340
+ const worker = store.getWorker('worker-1')!
341
+ expect(worker.status).toBe('done')
342
+ expect(worker.finished_at).toBe(ts)
343
+ expect(worker.pid).toBe(12345)
344
+ })
345
+
346
+ it('updates worker last_heartbeat', () => {
347
+ const ts = '2026-01-01T00:30:00.000Z'
348
+ store.insertWorker(makeWorker())
349
+ store.updateWorkerStatus('worker-1', 'running', { last_heartbeat: ts })
350
+ expect(store.getWorker('worker-1')!.last_heartbeat).toBe(ts)
351
+ })
352
+ })
353
+
354
+ // ── event CRUD ────────────────────────────────────────────────────────────────
355
+
356
+ describe('event CRUD', () => {
357
+ beforeEach(() => {
358
+ store.insertConvoy(makeConvoy())
359
+ })
360
+
361
+ it('inserts and retrieves events for a convoy', () => {
362
+ store.insertEvent({
363
+ convoy_id: 'convoy-1',
364
+ task_id: null,
365
+ worker_id: null,
366
+ type: 'convoy_started',
367
+ data: JSON.stringify({ msg: 'hello' }),
368
+ created_at: new Date().toISOString(),
369
+ })
370
+ const events = store.getEvents('convoy-1')
371
+ expect(events).toHaveLength(1)
372
+ expect(events[0].type).toBe('convoy_started')
373
+ expect(events[0].convoy_id).toBe('convoy-1')
374
+ expect(JSON.parse(events[0].data!)).toEqual({ msg: 'hello' })
375
+ })
376
+
377
+ it('returns events ordered by id (insertion order)', () => {
378
+ const now = new Date().toISOString()
379
+ store.insertEvent({ convoy_id: 'convoy-1', task_id: null, worker_id: null, type: 'first', data: null, created_at: now })
380
+ store.insertEvent({ convoy_id: 'convoy-1', task_id: null, worker_id: null, type: 'second', data: null, created_at: now })
381
+ store.insertEvent({ convoy_id: 'convoy-1', task_id: null, worker_id: null, type: 'third', data: null, created_at: now })
382
+ const events = store.getEvents('convoy-1')
383
+ expect(events.map(e => e.type)).toEqual(['first', 'second', 'third'])
384
+ })
385
+
386
+ it('returns empty array for convoy with no events', () => {
387
+ expect(store.getEvents('convoy-1')).toHaveLength(0)
388
+ })
389
+
390
+ it('assigns autoincrement id', () => {
391
+ const now = new Date().toISOString()
392
+ store.insertEvent({ convoy_id: 'convoy-1', task_id: null, worker_id: null, type: 'ev', data: null, created_at: now })
393
+ store.insertEvent({ convoy_id: 'convoy-1', task_id: null, worker_id: null, type: 'ev2', data: null, created_at: now })
394
+ const events = store.getEvents('convoy-1')
395
+ expect(typeof events[0].id).toBe('number')
396
+ expect(events[1].id).toBeGreaterThan(events[0].id!)
397
+ })
398
+ })
399
+
400
+ // ── withTransaction ───────────────────────────────────────────────────────────
401
+
402
+ describe('withTransaction', () => {
403
+ beforeEach(() => {
404
+ store.insertConvoy(makeConvoy())
405
+ })
406
+
407
+ it('commits on success and returns the result', () => {
408
+ const result = store.withTransaction(() => {
409
+ store.insertTask(makeTask())
410
+ return 'done'
411
+ })
412
+ expect(result).toBe('done')
413
+ expect(store.getTask('task-1', 'convoy-1')).toBeDefined()
414
+ })
415
+
416
+ it('rolls back on error and re-throws', () => {
417
+ expect(() => {
418
+ store.withTransaction(() => {
419
+ store.insertTask(makeTask())
420
+ throw new Error('forced error')
421
+ })
422
+ }).toThrow('forced error')
423
+
424
+ expect(store.getTask('task-1', 'convoy-1')).toBeUndefined()
425
+ })
426
+
427
+ it('supports nested data operations inside transaction', () => {
428
+ const ts = new Date().toISOString()
429
+ store.withTransaction(() => {
430
+ store.insertTask(makeTask({ id: 'task-alpha' }))
431
+ store.updateTaskStatus('task-alpha', 'convoy-1', 'running', { started_at: ts })
432
+ })
433
+ const task = store.getTask('task-alpha', 'convoy-1')!
434
+ expect(task.status).toBe('running')
435
+ expect(task.started_at).toBe(ts)
436
+ })
437
+ })
438
+
439
+ // ── close ─────────────────────────────────────────────────────────────────────
440
+
441
+ describe('close', () => {
442
+ it('closes without error when DB is open', () => {
443
+ const freshStore = createConvoyStore(join(tmpDir, 'fresh.db'))
444
+ expect(() => freshStore.close()).not.toThrow()
445
+ })
446
+ })