opencastle 0.26.1 → 0.27.1
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 +7 -1
- package/bin/cli.mjs +10 -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/engine.d.ts +68 -2
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +2102 -26
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1572 -70
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/events.d.ts +4 -1
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +74 -13
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +154 -27
- 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/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 +47 -5
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +525 -19
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +1345 -12
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +156 -2
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/destroy.d.ts +3 -0
- package/dist/cli/destroy.d.ts.map +1 -0
- package/dist/cli/destroy.js +69 -0
- package/dist/cli/destroy.js.map +1 -0
- package/dist/cli/destroy.test.d.ts +2 -0
- package/dist/cli/destroy.test.d.ts.map +1 -0
- package/dist/cli/destroy.test.js +116 -0
- package/dist/cli/destroy.test.js.map +1 -0
- package/dist/cli/gitignore.d.ts +9 -0
- package/dist/cli/gitignore.d.ts.map +1 -1
- package/dist/cli/gitignore.js +29 -0
- package/dist/cli/gitignore.js.map +1 -1
- package/dist/cli/plan.d.ts +3 -0
- package/dist/cli/plan.d.ts.map +1 -0
- package/dist/cli/plan.js +288 -0
- package/dist/cli/plan.js.map +1 -0
- 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 +1 -1
- package/src/cli/agents.ts +177 -0
- package/src/cli/baselines.ts +143 -0
- package/src/cli/convoy/engine.test.ts +1839 -70
- package/src/cli/convoy/engine.ts +2417 -38
- package/src/cli/convoy/events.test.ts +179 -38
- package/src/cli/convoy/events.ts +88 -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/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 +1512 -14
- package/src/cli/convoy/store.ts +676 -30
- package/src/cli/convoy/types.ts +170 -1
- package/src/cli/destroy.test.ts +141 -0
- package/src/cli/destroy.ts +88 -0
- package/src/cli/gitignore.ts +36 -0
- package/src/cli/plan.ts +316 -0
- 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/node_modules/.vite/deps/_metadata.json +6 -6
|
@@ -1,25 +1,21 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
1
|
+
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import {
|
|
4
|
+
import { realpathSync } from 'node:fs'
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
5
6
|
import { createConvoyStore } from './store.js'
|
|
6
7
|
import { createEventEmitter } from './events.js'
|
|
8
|
+
import { recoverNdjson } from './engine.js'
|
|
7
9
|
import type { ConvoyStore } from './store.js'
|
|
8
10
|
|
|
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
11
|
let tmpDir: string
|
|
17
12
|
let store: ConvoyStore
|
|
13
|
+
let ndjsonPath: string
|
|
18
14
|
|
|
19
15
|
beforeEach(() => {
|
|
20
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'emitter-test-'))
|
|
16
|
+
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'emitter-test-')))
|
|
21
17
|
store = createConvoyStore(join(tmpDir, 'test.db'))
|
|
22
|
-
|
|
18
|
+
ndjsonPath = join(tmpDir, 'events.ndjson')
|
|
23
19
|
|
|
24
20
|
store.insertConvoy({
|
|
25
21
|
id: 'c1',
|
|
@@ -41,6 +37,7 @@ describe('createEventEmitter', () => {
|
|
|
41
37
|
it('inserts the event into SQLite', () => {
|
|
42
38
|
const emitter = createEventEmitter(store)
|
|
43
39
|
emitter.emit('task_started', { msg: 'started' }, { convoy_id: 'c1' })
|
|
40
|
+
emitter.close()
|
|
44
41
|
const events = store.getEvents('c1')
|
|
45
42
|
expect(events).toHaveLength(1)
|
|
46
43
|
expect(events[0].type).toBe('task_started')
|
|
@@ -50,6 +47,7 @@ describe('createEventEmitter', () => {
|
|
|
50
47
|
it('serializes event data to JSON in SQLite', () => {
|
|
51
48
|
const emitter = createEventEmitter(store)
|
|
52
49
|
emitter.emit('task_done', { exitCode: 0, output: 'ok' }, { convoy_id: 'c1' })
|
|
50
|
+
emitter.close()
|
|
53
51
|
const events = store.getEvents('c1')
|
|
54
52
|
const parsed = JSON.parse(events[0].data!)
|
|
55
53
|
expect(parsed.exitCode).toBe(0)
|
|
@@ -59,60 +57,203 @@ describe('createEventEmitter', () => {
|
|
|
59
57
|
it('stores null data when no data object is provided', () => {
|
|
60
58
|
const emitter = createEventEmitter(store)
|
|
61
59
|
emitter.emit('heartbeat', undefined, { convoy_id: 'c1' })
|
|
60
|
+
emitter.close()
|
|
62
61
|
const events = store.getEvents('c1')
|
|
63
62
|
expect(events[0].data).toBeNull()
|
|
64
63
|
})
|
|
65
64
|
|
|
66
|
-
it('
|
|
67
|
-
const emitter = createEventEmitter(store)
|
|
65
|
+
it('writes NDJSON when ndjsonPath is provided', () => {
|
|
66
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
68
67
|
emitter.emit('convoy_started', { name: 'test' }, { convoy_id: 'c1' })
|
|
69
|
-
|
|
68
|
+
emitter.close()
|
|
69
|
+
expect(existsSync(ndjsonPath)).toBe(true)
|
|
70
|
+
const content = readFileSync(ndjsonPath, 'utf8')
|
|
71
|
+
expect(content.trim()).not.toBe('')
|
|
72
|
+
const line = JSON.parse(content.trim())
|
|
73
|
+
expect(line.type).toBe('convoy_started')
|
|
74
|
+
expect(line.convoy_id).toBe('c1')
|
|
70
75
|
})
|
|
71
76
|
|
|
72
|
-
it('
|
|
73
|
-
const emitter = createEventEmitter(store,
|
|
77
|
+
it('writes _event_id to NDJSON matching SQLite rowid', () => {
|
|
78
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
74
79
|
emitter.emit('convoy_started', {}, { convoy_id: 'c1' })
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
80
|
+
emitter.close()
|
|
81
|
+
const sqliteEvents = store.getEvents('c1')
|
|
82
|
+
const ndjsonLine = JSON.parse(readFileSync(ndjsonPath, 'utf8').trim())
|
|
83
|
+
expect(ndjsonLine._event_id).toBe(sqliteEvents[0].id)
|
|
79
84
|
})
|
|
80
85
|
|
|
81
86
|
it('defaults all ids to null when ids are not provided', () => {
|
|
82
|
-
const emitter = createEventEmitter(store)
|
|
87
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
83
88
|
emitter.emit('generic_event')
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
expect(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
89
|
+
emitter.close()
|
|
90
|
+
const events = store.getEvents('c1')
|
|
91
|
+
expect(events).toHaveLength(0)
|
|
92
|
+
// No convoy_id so not retrievable via getEvents('c1'), but event was inserted
|
|
93
|
+
const content = readFileSync(ndjsonPath, 'utf8')
|
|
94
|
+
const line = JSON.parse(content.trim())
|
|
95
|
+
expect(line.convoy_id).toBeNull()
|
|
96
|
+
expect(line.task_id).toBeNull()
|
|
97
|
+
expect(line.worker_id).toBeNull()
|
|
94
98
|
})
|
|
95
99
|
|
|
96
100
|
it('includes all provided ids in the NDJSON record', () => {
|
|
97
|
-
const emitter = createEventEmitter(store,
|
|
101
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
98
102
|
emitter.emit('worker_spawned', {}, { convoy_id: 'c1', task_id: 't1', worker_id: 'w1' })
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
103
|
+
emitter.close()
|
|
104
|
+
const line = JSON.parse(readFileSync(ndjsonPath, 'utf8').trim())
|
|
105
|
+
expect(line.convoy_id).toBe('c1')
|
|
106
|
+
expect(line.task_id).toBe('t1')
|
|
107
|
+
expect(line.worker_id).toBe('w1')
|
|
103
108
|
})
|
|
104
109
|
|
|
105
110
|
it('SQLite event stores correct ids', () => {
|
|
106
111
|
const emitter = createEventEmitter(store)
|
|
107
112
|
emitter.emit('worker_done', {}, { convoy_id: 'c1', task_id: 'task-x', worker_id: 'wkr-y' })
|
|
113
|
+
emitter.close()
|
|
108
114
|
const events = store.getEvents('c1')
|
|
109
115
|
expect(events[0].task_id).toBe('task-x')
|
|
110
116
|
expect(events[0].worker_id).toBe('wkr-y')
|
|
111
117
|
})
|
|
112
118
|
|
|
113
|
-
it('does not throw if NDJSON
|
|
114
|
-
mockAppend.mockRejectedValueOnce(new Error('disk full'))
|
|
119
|
+
it('does not throw if NDJSON path is not provided', () => {
|
|
115
120
|
const emitter = createEventEmitter(store)
|
|
116
121
|
expect(() => emitter.emit('test', {}, { convoy_id: 'c1' })).not.toThrow()
|
|
122
|
+
emitter.close()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('close() is idempotent', () => {
|
|
126
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
127
|
+
emitter.close()
|
|
128
|
+
expect(() => emitter.close()).not.toThrow()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('crash resilience', () => {
|
|
133
|
+
it('1. mid-write crash: SQLite has events, recovery writes NDJSON', () => {
|
|
134
|
+
// Emit events using emitter WITHOUT ndjsonPath — simulates crash after SQLite commit
|
|
135
|
+
const emitter = createEventEmitter(store)
|
|
136
|
+
emitter.emit('task_started', { step: 1 }, { convoy_id: 'c1', task_id: 't1' })
|
|
137
|
+
emitter.emit('task_done', { step: 2 }, { convoy_id: 'c1', task_id: 't1' })
|
|
138
|
+
emitter.close()
|
|
139
|
+
|
|
140
|
+
// SQLite has both events
|
|
141
|
+
const sqliteEvents = store.getEvents('c1')
|
|
142
|
+
expect(sqliteEvents).toHaveLength(2)
|
|
143
|
+
|
|
144
|
+
// NDJSON file does not exist
|
|
145
|
+
expect(existsSync(ndjsonPath)).toBe(false)
|
|
146
|
+
|
|
147
|
+
// Recovery writes the missing events to NDJSON
|
|
148
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
149
|
+
|
|
150
|
+
expect(existsSync(ndjsonPath)).toBe(true)
|
|
151
|
+
const lines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
152
|
+
expect(lines).toHaveLength(2)
|
|
153
|
+
const types = lines.map(l => JSON.parse(l).type)
|
|
154
|
+
expect(types).toContain('task_started')
|
|
155
|
+
expect(types).toContain('task_done')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('2. recovery consistency: missing events replayed after partial crash', () => {
|
|
159
|
+
// Write some events to both SQLite + NDJSON via emitter
|
|
160
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
161
|
+
emitter.emit('convoy_started', {}, { convoy_id: 'c1' })
|
|
162
|
+
emitter.close()
|
|
163
|
+
|
|
164
|
+
// Simulate crash: two more events go only to SQLite (bypass emitter)
|
|
165
|
+
store.insertEvent({
|
|
166
|
+
convoy_id: 'c1', task_id: 't1', worker_id: null,
|
|
167
|
+
type: 'task_started', data: null, created_at: new Date().toISOString(),
|
|
168
|
+
})
|
|
169
|
+
store.insertEvent({
|
|
170
|
+
convoy_id: 'c1', task_id: 't1', worker_id: null,
|
|
171
|
+
type: 'task_done', data: null, created_at: new Date().toISOString(),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// Before recovery: NDJSON has 1 line, SQLite has 3
|
|
175
|
+
const beforeLines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
176
|
+
expect(beforeLines).toHaveLength(1)
|
|
177
|
+
expect(store.getEvents('c1')).toHaveLength(3)
|
|
178
|
+
|
|
179
|
+
// Recovery replays the 2 missing events
|
|
180
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
181
|
+
|
|
182
|
+
const afterLines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
183
|
+
expect(afterLines).toHaveLength(3)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('3. no duplication: idempotent recovery when all synced', () => {
|
|
187
|
+
// Write 5 events — all go to both SQLite and NDJSON
|
|
188
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
189
|
+
for (let i = 0; i < 5; i++) {
|
|
190
|
+
emitter.emit('task_done', { i }, { convoy_id: 'c1', task_id: `t${i}` })
|
|
191
|
+
}
|
|
192
|
+
emitter.close()
|
|
193
|
+
|
|
194
|
+
const linesBefore = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
195
|
+
expect(linesBefore).toHaveLength(5)
|
|
196
|
+
|
|
197
|
+
// Run recovery — nothing should be added since all events already in NDJSON
|
|
198
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
199
|
+
|
|
200
|
+
const linesAfter = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
201
|
+
expect(linesAfter).toHaveLength(5)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('4. partial line recovery: incomplete write truncated and replayed', () => {
|
|
205
|
+
// Write one complete event then append a partial JSON line (no \\n terminator)
|
|
206
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
207
|
+
emitter.emit('convoy_started', {}, { convoy_id: 'c1' })
|
|
208
|
+
emitter.close()
|
|
209
|
+
|
|
210
|
+
// Append a partial line directly (simulating a crash mid-write)
|
|
211
|
+
const partialLine = '{"_event_id":999,"type":"partial_crash","convoy_id":"c1"' // no closing } or \n
|
|
212
|
+
const existingContent = readFileSync(ndjsonPath, 'utf8')
|
|
213
|
+
writeFileSync(ndjsonPath, existingContent + partialLine)
|
|
214
|
+
|
|
215
|
+
// Recovery should truncate the partial line and replay anything missing
|
|
216
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
217
|
+
|
|
218
|
+
const recovered = readFileSync(ndjsonPath, 'utf8')
|
|
219
|
+
// Every line should be valid JSON
|
|
220
|
+
const lines = recovered.split('\n').filter(l => l.trim())
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
expect(() => JSON.parse(line)).not.toThrow()
|
|
223
|
+
}
|
|
224
|
+
// The original complete event should be present
|
|
225
|
+
const types = lines.map(l => JSON.parse(l).type)
|
|
226
|
+
expect(types).toContain('convoy_started')
|
|
227
|
+
// The partial line should not appear
|
|
228
|
+
expect(types).not.toContain('partial_crash')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('5. large file: 1000 events all readable after emit and recovery', () => {
|
|
232
|
+
const count = 1000
|
|
233
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
234
|
+
for (let i = 0; i < count; i++) {
|
|
235
|
+
emitter.emit('bench_event', { index: i }, { convoy_id: 'c1', task_id: `t${i}` })
|
|
236
|
+
}
|
|
237
|
+
emitter.close()
|
|
238
|
+
|
|
239
|
+
// All events in SQLite
|
|
240
|
+
expect(store.getEvents('c1')).toHaveLength(count)
|
|
241
|
+
|
|
242
|
+
// All events in NDJSON
|
|
243
|
+
const lines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
244
|
+
expect(lines).toHaveLength(count)
|
|
245
|
+
|
|
246
|
+
// Each line is valid JSON with the right type
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
const parsed = JSON.parse(line)
|
|
249
|
+
expect(parsed.type).toBe('bench_event')
|
|
250
|
+
expect(parsed.convoy_id).toBe('c1')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Recovery is a no-op (everything is synced)
|
|
254
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
255
|
+
const linesAfter = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
256
|
+
expect(linesAfter).toHaveLength(count)
|
|
117
257
|
})
|
|
118
258
|
})
|
|
259
|
+
|
package/src/cli/convoy/events.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { appendFileSync, closeSync, fsyncSync, openSync } from 'node:fs'
|
|
2
2
|
import type { ConvoyStore } from './store.js'
|
|
3
|
+
import { scanForSecrets } from './gates.js'
|
|
3
4
|
|
|
4
5
|
export interface ConvoyEventEmitter {
|
|
5
6
|
emit(
|
|
@@ -7,35 +8,106 @@ export interface ConvoyEventEmitter {
|
|
|
7
8
|
data?: Record<string, unknown>,
|
|
8
9
|
ids?: { convoy_id?: string; task_id?: string; worker_id?: string },
|
|
9
10
|
): void
|
|
11
|
+
close(): void
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
export function createEventEmitter(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
export function createEventEmitter(
|
|
15
|
+
store: ConvoyStore,
|
|
16
|
+
options?: { ndjsonPath?: string },
|
|
17
|
+
): ConvoyEventEmitter {
|
|
18
|
+
let fd: number | null = null
|
|
19
|
+
if (options?.ndjsonPath) {
|
|
20
|
+
fd = openSync(options.ndjsonPath, 'a')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// NDJSON writes are supplementary — SQLite is the primary store. Use async
|
|
24
|
+
// retries to avoid blocking the Node.js event loop.
|
|
25
|
+
async function writeNdjson(
|
|
26
|
+
type: string,
|
|
27
|
+
data: Record<string, unknown> | undefined,
|
|
28
|
+
ids: { convoy_id?: string; task_id?: string; worker_id?: string } | undefined,
|
|
29
|
+
now: string,
|
|
30
|
+
eventId: number,
|
|
31
|
+
currentFd: number,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const record = {
|
|
34
|
+
_event_id: eventId,
|
|
35
|
+
timestamp: now,
|
|
36
|
+
type,
|
|
37
|
+
convoy_id: ids?.convoy_id ?? null,
|
|
38
|
+
task_id: ids?.task_id ?? null,
|
|
39
|
+
worker_id: ids?.worker_id ?? null,
|
|
40
|
+
...(data ?? {}),
|
|
41
|
+
}
|
|
42
|
+
const jsonLine = JSON.stringify(record) + '\n'
|
|
16
43
|
|
|
44
|
+
const scanResult = scanForSecrets(jsonLine, 'ndjson')
|
|
45
|
+
if (!scanResult.clean) {
|
|
46
|
+
// Block the NDJSON write — record the blocked event in SQLite only
|
|
17
47
|
store.insertEvent({
|
|
18
48
|
convoy_id: ids?.convoy_id ?? null,
|
|
19
49
|
task_id: ids?.task_id ?? null,
|
|
20
50
|
worker_id: ids?.worker_id ?? null,
|
|
21
|
-
type,
|
|
22
|
-
data:
|
|
51
|
+
type: 'secret_leak_prevented',
|
|
52
|
+
data: JSON.stringify({ original_type: type, patterns: scanResult.findings.map(f => f.pattern) }),
|
|
23
53
|
created_at: now,
|
|
24
54
|
})
|
|
55
|
+
return
|
|
56
|
+
}
|
|
25
57
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
try {
|
|
59
|
+
appendFileSync(currentFd, jsonLine)
|
|
60
|
+
fsyncSync(currentFd)
|
|
61
|
+
} catch {
|
|
62
|
+
// Retry once after 100ms (non-blocking)
|
|
63
|
+
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
|
64
|
+
try {
|
|
65
|
+
appendFileSync(currentFd, jsonLine)
|
|
66
|
+
fsyncSync(currentFd)
|
|
67
|
+
} catch {
|
|
68
|
+
// Emit failure meta-event to SQLite only (do NOT recurse into NDJSON write)
|
|
69
|
+
store.insertEvent({
|
|
30
70
|
convoy_id: ids?.convoy_id ?? null,
|
|
31
71
|
task_id: ids?.task_id ?? null,
|
|
32
72
|
worker_id: ids?.worker_id ?? null,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
73
|
+
type: 'ndjson_write_failed',
|
|
74
|
+
data: JSON.stringify({ original_type: type }),
|
|
75
|
+
created_at: new Date().toISOString(),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
emit(type, data, ids) {
|
|
83
|
+
// Event data is NOT secret-scanned here because all user-generated content
|
|
84
|
+
// (task output, DLQ entries) is scanned at its source before reaching the
|
|
85
|
+
// event emitter. Re-scanning would be redundant. See MF-4 in panel report.
|
|
86
|
+
const now = new Date().toISOString()
|
|
87
|
+
|
|
88
|
+
const eventId = store.insertEvent({
|
|
89
|
+
convoy_id: ids?.convoy_id ?? null,
|
|
90
|
+
task_id: ids?.task_id ?? null,
|
|
91
|
+
worker_id: ids?.worker_id ?? null,
|
|
92
|
+
type,
|
|
93
|
+
data: data !== undefined ? JSON.stringify(data) : null,
|
|
94
|
+
created_at: now,
|
|
38
95
|
})
|
|
96
|
+
|
|
97
|
+
// Fire-and-forget: SQLite record (above) is the source of truth.
|
|
98
|
+
// NDJSON is supplementary — no need to await or block on it.
|
|
99
|
+
if (fd !== null) {
|
|
100
|
+
writeNdjson(type, data, ids, now, eventId, fd).catch(() => {
|
|
101
|
+
// Swallow unhandled rejection — failure already recorded in SQLite via writeNdjson
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
close() {
|
|
107
|
+
if (fd !== null) {
|
|
108
|
+
closeSync(fd)
|
|
109
|
+
fd = null
|
|
110
|
+
}
|
|
39
111
|
},
|
|
40
112
|
}
|
|
41
113
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, realpathSync, writeFileSync, mkdirSync, readFileSync } 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 { readExpertise, updateExpertise, feedCircuitBreaker } from './expertise.js'
|
|
6
|
+
|
|
7
|
+
vi.mock('./gates.js', () => ({
|
|
8
|
+
scanForSecrets: vi.fn(() => ({ clean: true, findings: [] })),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
const EXPERTISE_REL = '.opencastle/AGENT-EXPERTISE.md'
|
|
12
|
+
|
|
13
|
+
function makeBase(): string {
|
|
14
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'expertise-test-')))
|
|
15
|
+
mkdirSync(join(dir, '.opencastle'), { recursive: true })
|
|
16
|
+
return dir
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let tmpDir: string
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
tmpDir = makeBase()
|
|
23
|
+
vi.clearAllMocks()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('readExpertise', () => {
|
|
31
|
+
it('returns empty expertise for missing file', () => {
|
|
32
|
+
const result = readExpertise('developer', tmpDir)
|
|
33
|
+
expect(result).toEqual({ strong: [], weak: [], files: [] })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns empty expertise when agent section not present', () => {
|
|
37
|
+
writeFileSync(join(tmpDir, EXPERTISE_REL), '# Agent Expertise\n\n## other-agent\n### Strong Areas\n- Knows stuff\n')
|
|
38
|
+
const result = readExpertise('developer', tmpDir)
|
|
39
|
+
expect(result).toEqual({ strong: [], weak: [], files: [] })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('parses strong areas', () => {
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(tmpDir, EXPERTISE_REL),
|
|
45
|
+
'# Agent Expertise\n\n## developer\n### Strong Areas\n- TypeScript typing\n- React hooks\n### Weak Areas\n### File Familiarity\n',
|
|
46
|
+
)
|
|
47
|
+
const result = readExpertise('developer', tmpDir)
|
|
48
|
+
expect(result.strong).toContain('TypeScript typing')
|
|
49
|
+
expect(result.strong).toContain('React hooks')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('parses weak areas', () => {
|
|
53
|
+
writeFileSync(
|
|
54
|
+
join(tmpDir, EXPERTISE_REL),
|
|
55
|
+
'# Agent Expertise\n\n## developer\n### Strong Areas\n### Weak Areas\n- CSS animations\n### File Familiarity\n',
|
|
56
|
+
)
|
|
57
|
+
const result = readExpertise('developer', tmpDir)
|
|
58
|
+
expect(result.weak).toContain('CSS animations')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('parses file familiarity', () => {
|
|
62
|
+
writeFileSync(
|
|
63
|
+
join(tmpDir, EXPERTISE_REL),
|
|
64
|
+
'# Agent Expertise\n\n## developer\n### Strong Areas\n### Weak Areas\n### File Familiarity\n- src/cli/engine.ts\n',
|
|
65
|
+
)
|
|
66
|
+
const result = readExpertise('developer', tmpDir)
|
|
67
|
+
expect(result.files).toContain('src/cli/engine.ts')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('updateExpertise', () => {
|
|
72
|
+
it('creates file if missing when updating success with no retries', () => {
|
|
73
|
+
updateExpertise('developer', { taskId: 'task-1', success: true, retries: 0, files: ['src/app.ts'] }, tmpDir)
|
|
74
|
+
const content = readFileSync(join(tmpDir, EXPERTISE_REL), 'utf8')
|
|
75
|
+
expect(content).toContain('## developer')
|
|
76
|
+
expect(content).toContain('src/app.ts')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('appends success to Strong Areas', () => {
|
|
80
|
+
updateExpertise('developer', { taskId: 'task-1', success: true, retries: 0, files: ['src/engine.ts'] }, tmpDir)
|
|
81
|
+
const result = readExpertise('developer', tmpDir)
|
|
82
|
+
expect(result.strong.some(s => s.includes('task-1'))).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('appends failure to Weak Areas', () => {
|
|
86
|
+
updateExpertise('developer', { taskId: 'task-fail', success: false, retries: 3, files: ['src/hard.ts'] }, tmpDir)
|
|
87
|
+
const result = readExpertise('developer', tmpDir)
|
|
88
|
+
expect(result.weak.some(w => w.includes('task-fail'))).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('appends to Weak Areas when success with retries > 0', () => {
|
|
92
|
+
updateExpertise('developer', { taskId: 'task-retry', success: true, retries: 2, files: ['src/tricky.ts'] }, tmpDir)
|
|
93
|
+
const result = readExpertise('developer', tmpDir)
|
|
94
|
+
expect(result.weak.some(w => w.includes('task-retry'))).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('rejects secrets in expertise content', async () => {
|
|
98
|
+
const { scanForSecrets } = await import('./gates.js')
|
|
99
|
+
vi.mocked(scanForSecrets).mockReturnValueOnce({
|
|
100
|
+
clean: false,
|
|
101
|
+
findings: [{ pattern: 'Token', file: '', line: 1, snippet: 'x' }],
|
|
102
|
+
})
|
|
103
|
+
// Should not throw; silently skips the write
|
|
104
|
+
expect(() => {
|
|
105
|
+
updateExpertise('developer', { taskId: 'leak', success: true, retries: 0, files: [] }, tmpDir)
|
|
106
|
+
}).not.toThrow()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('feedCircuitBreaker', () => {
|
|
111
|
+
it('returns weak areas for the agent', () => {
|
|
112
|
+
writeFileSync(
|
|
113
|
+
join(tmpDir, EXPERTISE_REL),
|
|
114
|
+
'# Agent Expertise\n\n## developer\n### Strong Areas\n### Weak Areas\n- database migrations\n### File Familiarity\n',
|
|
115
|
+
)
|
|
116
|
+
const result = feedCircuitBreaker('developer', tmpDir)
|
|
117
|
+
expect(result).toContain('database migrations')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('returns empty array when the agent has no weak areas', () => {
|
|
121
|
+
writeFileSync(
|
|
122
|
+
join(tmpDir, EXPERTISE_REL),
|
|
123
|
+
'# Agent Expertise\n\n## developer\n### Strong Areas\n- everything\n### Weak Areas\n### File Familiarity\n',
|
|
124
|
+
)
|
|
125
|
+
const result = feedCircuitBreaker('developer', tmpDir)
|
|
126
|
+
expect(result).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
})
|