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.
- package/README.md +4 -0
- package/bin/cli.mjs +4 -0
- package/dist/cli/convoy/events.d.ts +10 -0
- package/dist/cli/convoy/events.d.ts.map +1 -0
- package/dist/cli/convoy/events.js +27 -0
- package/dist/cli/convoy/events.js.map +1 -0
- package/dist/cli/convoy/events.test.d.ts +2 -0
- package/dist/cli/convoy/events.test.d.ts.map +1 -0
- package/dist/cli/convoy/events.test.js +94 -0
- package/dist/cli/convoy/events.test.js.map +1 -0
- package/dist/cli/convoy/store.d.ts +23 -0
- package/dist/cli/convoy/store.d.ts.map +1 -0
- package/dist/cli/convoy/store.js +210 -0
- package/dist/cli/convoy/store.js.map +1 -0
- package/dist/cli/convoy/store.test.d.ts +2 -0
- package/dist/cli/convoy/store.test.d.ts.map +1 -0
- package/dist/cli/convoy/store.test.js +387 -0
- package/dist/cli/convoy/store.test.js.map +1 -0
- package/dist/cli/convoy/types.d.ts +56 -0
- package/dist/cli/convoy/types.d.ts.map +1 -0
- package/dist/cli/convoy/types.js +2 -0
- package/dist/cli/convoy/types.js.map +1 -0
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +5 -1
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/init.test.js +1 -1
- package/dist/cli/init.test.js.map +1 -1
- package/dist/cli/lesson.d.ts +17 -0
- package/dist/cli/lesson.d.ts.map +1 -0
- package/dist/cli/lesson.js +294 -0
- package/dist/cli/lesson.js.map +1 -0
- package/dist/cli/log.d.ts +7 -0
- package/dist/cli/log.d.ts.map +1 -0
- package/dist/cli/log.js +131 -0
- package/dist/cli/log.js.map +1 -0
- package/dist/cli/run/executor.js.map +1 -1
- package/dist/cli/run/executor.test.js +1 -0
- package/dist/cli/run/executor.test.js.map +1 -1
- package/dist/cli/run/loop-executor.d.ts +3 -0
- package/dist/cli/run/loop-executor.d.ts.map +1 -0
- package/dist/cli/run/loop-executor.js +155 -0
- package/dist/cli/run/loop-executor.js.map +1 -0
- package/dist/cli/run/loop-reporter.d.ts +6 -0
- package/dist/cli/run/loop-reporter.d.ts.map +1 -0
- package/dist/cli/run/loop-reporter.js +112 -0
- package/dist/cli/run/loop-reporter.js.map +1 -0
- package/dist/cli/run/reporter.d.ts.map +1 -1
- package/dist/cli/run/reporter.js +28 -1
- package/dist/cli/run/reporter.js.map +1 -1
- package/dist/cli/run/schema.d.ts +4 -0
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +178 -50
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +598 -1
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +84 -3
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +78 -1
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +54 -1
- package/dist/cli/update.js.map +1 -1
- package/package.json +3 -2
- package/src/cli/convoy/events.test.ts +118 -0
- package/src/cli/convoy/events.ts +41 -0
- package/src/cli/convoy/store.test.ts +446 -0
- package/src/cli/convoy/store.ts +308 -0
- package/src/cli/convoy/types.ts +68 -0
- package/src/cli/dashboard.ts +5 -1
- package/src/cli/init.test.ts +1 -1
- package/src/cli/lesson.ts +312 -0
- package/src/cli/log.ts +133 -0
- package/src/cli/run/executor.test.ts +1 -0
- package/src/cli/run/executor.ts +8 -8
- package/src/cli/run/loop-executor.ts +199 -0
- package/src/cli/run/loop-reporter.ts +125 -0
- package/src/cli/run/reporter.ts +30 -1
- package/src/cli/run/schema.test.ts +704 -3
- package/src/cli/run/schema.ts +206 -56
- package/src/cli/run.ts +82 -5
- package/src/cli/types.ts +87 -1
- package/src/cli/update.ts +62 -1
- package/src/dashboard/dist/index.html +14 -15
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/scripts/generate-seed-data.ts +23 -43
- package/src/dashboard/seed-data/events.ndjson +104 -0
- package/src/dashboard/src/pages/index.astro +14 -15
- package/src/orchestrator/agents/api-designer.agent.md +1 -1
- package/src/orchestrator/agents/architect.agent.md +1 -1
- package/src/orchestrator/agents/content-engineer.agent.md +1 -1
- package/src/orchestrator/agents/copywriter.agent.md +1 -1
- package/src/orchestrator/agents/data-expert.agent.md +1 -1
- package/src/orchestrator/agents/database-engineer.agent.md +1 -1
- package/src/orchestrator/agents/developer.agent.md +1 -1
- package/src/orchestrator/agents/devops-expert.agent.md +1 -1
- package/src/orchestrator/agents/documentation-writer.agent.md +1 -1
- package/src/orchestrator/agents/performance-expert.agent.md +1 -1
- package/src/orchestrator/agents/release-manager.agent.md +1 -1
- package/src/orchestrator/agents/security-expert.agent.md +1 -1
- package/src/orchestrator/agents/seo-specialist.agent.md +1 -1
- package/src/orchestrator/agents/session-guard.agent.md +9 -21
- package/src/orchestrator/agents/team-lead.agent.md +8 -34
- package/src/orchestrator/agents/testing-expert.agent.md +1 -1
- package/src/orchestrator/agents/ui-ux-expert.agent.md +1 -1
- package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +11 -12
- package/src/orchestrator/customizations/DISPUTES.md +2 -2
- package/src/orchestrator/customizations/README.md +1 -3
- package/src/orchestrator/customizations/logs/README.md +66 -14
- package/src/orchestrator/instructions/ai-optimization.instructions.md +21 -132
- package/src/orchestrator/instructions/general.instructions.md +35 -181
- package/src/orchestrator/plugins/nx/SKILL.md +1 -1
- package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +4 -8
- package/src/orchestrator/prompts/bug-fix.prompt.md +4 -4
- package/src/orchestrator/prompts/implement-feature.prompt.md +3 -3
- package/src/orchestrator/prompts/quick-refinement.prompt.md +3 -3
- package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +1 -1
- package/src/orchestrator/skills/agent-hooks/SKILL.md +11 -11
- package/src/orchestrator/skills/decomposition/SKILL.md +1 -1
- package/src/orchestrator/skills/fast-review/SKILL.md +4 -19
- package/src/orchestrator/skills/git-workflow/SKILL.md +72 -0
- package/src/orchestrator/skills/memory-merger/SKILL.md +1 -1
- package/src/orchestrator/skills/observability-logging/SKILL.md +129 -0
- package/src/orchestrator/skills/orchestration-protocols/SKILL.md +2 -2
- package/src/orchestrator/skills/panel-majority-vote/SKILL.md +4 -7
- package/src/orchestrator/skills/self-improvement/SKILL.md +13 -26
- package/src/orchestrator/skills/team-lead-reference/SKILL.md +2 -2
- package/src/orchestrator/customizations/logs/delegations.ndjson +0 -1
- package/src/orchestrator/customizations/logs/panels.ndjson +0 -1
- package/src/orchestrator/customizations/logs/reviews.ndjson +0 -0
- package/src/orchestrator/customizations/logs/sessions.ndjson +0 -1
- /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
|
+
})
|