opencastle 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/bin/cli.mjs +6 -0
  2. package/dist/cli/agents.d.ts +3 -0
  3. package/dist/cli/agents.d.ts.map +1 -0
  4. package/dist/cli/agents.js +161 -0
  5. package/dist/cli/agents.js.map +1 -0
  6. package/dist/cli/baselines.d.ts +3 -0
  7. package/dist/cli/baselines.d.ts.map +1 -0
  8. package/dist/cli/baselines.js +128 -0
  9. package/dist/cli/baselines.js.map +1 -0
  10. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  11. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  12. package/dist/cli/convoy/dashboard-types.js +2 -0
  13. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  14. package/dist/cli/convoy/engine.d.ts +67 -2
  15. package/dist/cli/convoy/engine.d.ts.map +1 -1
  16. package/dist/cli/convoy/engine.js +2036 -28
  17. package/dist/cli/convoy/engine.js.map +1 -1
  18. package/dist/cli/convoy/engine.test.js +1659 -70
  19. package/dist/cli/convoy/engine.test.js.map +1 -1
  20. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  21. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  22. package/dist/cli/convoy/event-schemas.js +185 -0
  23. package/dist/cli/convoy/event-schemas.js.map +1 -0
  24. package/dist/cli/convoy/events.d.ts +12 -1
  25. package/dist/cli/convoy/events.d.ts.map +1 -1
  26. package/dist/cli/convoy/events.js +186 -13
  27. package/dist/cli/convoy/events.js.map +1 -1
  28. package/dist/cli/convoy/events.test.js +325 -28
  29. package/dist/cli/convoy/events.test.js.map +1 -1
  30. package/dist/cli/convoy/expertise.d.ts +16 -0
  31. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  32. package/dist/cli/convoy/expertise.js +121 -0
  33. package/dist/cli/convoy/expertise.js.map +1 -0
  34. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  35. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  36. package/dist/cli/convoy/expertise.test.js +96 -0
  37. package/dist/cli/convoy/expertise.test.js.map +1 -0
  38. package/dist/cli/convoy/export.test.js +1 -0
  39. package/dist/cli/convoy/export.test.js.map +1 -1
  40. package/dist/cli/convoy/formula.d.ts +19 -0
  41. package/dist/cli/convoy/formula.d.ts.map +1 -0
  42. package/dist/cli/convoy/formula.js +142 -0
  43. package/dist/cli/convoy/formula.js.map +1 -0
  44. package/dist/cli/convoy/formula.test.d.ts +2 -0
  45. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  46. package/dist/cli/convoy/formula.test.js +342 -0
  47. package/dist/cli/convoy/formula.test.js.map +1 -0
  48. package/dist/cli/convoy/gates.d.ts +128 -0
  49. package/dist/cli/convoy/gates.d.ts.map +1 -0
  50. package/dist/cli/convoy/gates.js +606 -0
  51. package/dist/cli/convoy/gates.js.map +1 -0
  52. package/dist/cli/convoy/gates.test.d.ts +2 -0
  53. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  54. package/dist/cli/convoy/gates.test.js +976 -0
  55. package/dist/cli/convoy/gates.test.js.map +1 -0
  56. package/dist/cli/convoy/health.d.ts +11 -0
  57. package/dist/cli/convoy/health.d.ts.map +1 -1
  58. package/dist/cli/convoy/health.js +54 -0
  59. package/dist/cli/convoy/health.js.map +1 -1
  60. package/dist/cli/convoy/health.test.js +56 -1
  61. package/dist/cli/convoy/health.test.js.map +1 -1
  62. package/dist/cli/convoy/issues.d.ts +8 -0
  63. package/dist/cli/convoy/issues.d.ts.map +1 -0
  64. package/dist/cli/convoy/issues.js +98 -0
  65. package/dist/cli/convoy/issues.js.map +1 -0
  66. package/dist/cli/convoy/issues.test.d.ts +2 -0
  67. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  68. package/dist/cli/convoy/issues.test.js +107 -0
  69. package/dist/cli/convoy/issues.test.js.map +1 -0
  70. package/dist/cli/convoy/knowledge.d.ts +5 -0
  71. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  72. package/dist/cli/convoy/knowledge.js +116 -0
  73. package/dist/cli/convoy/knowledge.js.map +1 -0
  74. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  75. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  76. package/dist/cli/convoy/knowledge.test.js +87 -0
  77. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  78. package/dist/cli/convoy/lessons.d.ts +17 -0
  79. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  80. package/dist/cli/convoy/lessons.js +149 -0
  81. package/dist/cli/convoy/lessons.js.map +1 -0
  82. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  83. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  84. package/dist/cli/convoy/lessons.test.js +135 -0
  85. package/dist/cli/convoy/lessons.test.js.map +1 -0
  86. package/dist/cli/convoy/lock.d.ts +13 -0
  87. package/dist/cli/convoy/lock.d.ts.map +1 -0
  88. package/dist/cli/convoy/lock.js +88 -0
  89. package/dist/cli/convoy/lock.js.map +1 -0
  90. package/dist/cli/convoy/lock.test.d.ts +2 -0
  91. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  92. package/dist/cli/convoy/lock.test.js +136 -0
  93. package/dist/cli/convoy/lock.test.js.map +1 -0
  94. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  95. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  96. package/dist/cli/convoy/log-merge.test.js +147 -0
  97. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  98. package/dist/cli/convoy/merge.d.ts +4 -0
  99. package/dist/cli/convoy/merge.d.ts.map +1 -1
  100. package/dist/cli/convoy/merge.js +18 -1
  101. package/dist/cli/convoy/merge.js.map +1 -1
  102. package/dist/cli/convoy/merge.test.js +6 -7
  103. package/dist/cli/convoy/merge.test.js.map +1 -1
  104. package/dist/cli/convoy/partition.d.ts +51 -0
  105. package/dist/cli/convoy/partition.d.ts.map +1 -0
  106. package/dist/cli/convoy/partition.js +186 -0
  107. package/dist/cli/convoy/partition.js.map +1 -0
  108. package/dist/cli/convoy/partition.test.d.ts +2 -0
  109. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  110. package/dist/cli/convoy/partition.test.js +315 -0
  111. package/dist/cli/convoy/partition.test.js.map +1 -0
  112. package/dist/cli/convoy/pipeline.test.js +6 -0
  113. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  114. package/dist/cli/convoy/store.d.ts +99 -7
  115. package/dist/cli/convoy/store.d.ts.map +1 -1
  116. package/dist/cli/convoy/store.js +764 -31
  117. package/dist/cli/convoy/store.js.map +1 -1
  118. package/dist/cli/convoy/store.test.js +1810 -18
  119. package/dist/cli/convoy/store.test.js.map +1 -1
  120. package/dist/cli/convoy/types.d.ts +427 -5
  121. package/dist/cli/convoy/types.d.ts.map +1 -1
  122. package/dist/cli/convoy/types.js +42 -1
  123. package/dist/cli/convoy/types.js.map +1 -1
  124. package/dist/cli/log.d.ts +11 -0
  125. package/dist/cli/log.d.ts.map +1 -1
  126. package/dist/cli/log.js +114 -2
  127. package/dist/cli/log.js.map +1 -1
  128. package/dist/cli/run/adapters/claude.d.ts +2 -0
  129. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  130. package/dist/cli/run/adapters/claude.js +89 -49
  131. package/dist/cli/run/adapters/claude.js.map +1 -1
  132. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  133. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  134. package/dist/cli/run/adapters/claude.test.js +205 -0
  135. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  137. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  138. package/dist/cli/run/adapters/copilot.js +84 -46
  139. package/dist/cli/run/adapters/copilot.js.map +1 -1
  140. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  141. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  142. package/dist/cli/run/adapters/copilot.test.js +195 -0
  143. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  145. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  146. package/dist/cli/run/adapters/cursor.js +83 -47
  147. package/dist/cli/run/adapters/cursor.js.map +1 -1
  148. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  149. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  150. package/dist/cli/run/adapters/cursor.test.js +129 -0
  151. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  153. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  154. package/dist/cli/run/adapters/opencode.js +81 -47
  155. package/dist/cli/run/adapters/opencode.js.map +1 -1
  156. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  157. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  158. package/dist/cli/run/adapters/opencode.test.js +119 -0
  159. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  160. package/dist/cli/run/executor.js +1 -1
  161. package/dist/cli/run/executor.js.map +1 -1
  162. package/dist/cli/run/schema.d.ts.map +1 -1
  163. package/dist/cli/run/schema.js +245 -4
  164. package/dist/cli/run/schema.js.map +1 -1
  165. package/dist/cli/run/schema.test.js +669 -0
  166. package/dist/cli/run/schema.test.js.map +1 -1
  167. package/dist/cli/run.d.ts.map +1 -1
  168. package/dist/cli/run.js +362 -22
  169. package/dist/cli/run.js.map +1 -1
  170. package/dist/cli/types.d.ts +85 -2
  171. package/dist/cli/types.d.ts.map +1 -1
  172. package/dist/cli/types.js.map +1 -1
  173. package/dist/cli/watch.d.ts +15 -0
  174. package/dist/cli/watch.d.ts.map +1 -0
  175. package/dist/cli/watch.js +279 -0
  176. package/dist/cli/watch.js.map +1 -0
  177. package/package.json +5 -1
  178. package/src/cli/agents.ts +177 -0
  179. package/src/cli/baselines.ts +143 -0
  180. package/src/cli/convoy/TELEMETRY.md +203 -0
  181. package/src/cli/convoy/dashboard-types.ts +141 -0
  182. package/src/cli/convoy/engine.test.ts +1937 -70
  183. package/src/cli/convoy/engine.ts +2350 -40
  184. package/src/cli/convoy/event-schemas.ts +195 -0
  185. package/src/cli/convoy/events.test.ts +384 -39
  186. package/src/cli/convoy/events.ts +202 -16
  187. package/src/cli/convoy/expertise.test.ts +128 -0
  188. package/src/cli/convoy/expertise.ts +163 -0
  189. package/src/cli/convoy/export.test.ts +1 -0
  190. package/src/cli/convoy/formula.test.ts +405 -0
  191. package/src/cli/convoy/formula.ts +174 -0
  192. package/src/cli/convoy/gates.test.ts +1169 -0
  193. package/src/cli/convoy/gates.ts +774 -0
  194. package/src/cli/convoy/health.test.ts +64 -2
  195. package/src/cli/convoy/health.ts +80 -2
  196. package/src/cli/convoy/issues.test.ts +143 -0
  197. package/src/cli/convoy/issues.ts +136 -0
  198. package/src/cli/convoy/knowledge.test.ts +101 -0
  199. package/src/cli/convoy/knowledge.ts +132 -0
  200. package/src/cli/convoy/lessons.test.ts +188 -0
  201. package/src/cli/convoy/lessons.ts +164 -0
  202. package/src/cli/convoy/lock.test.ts +181 -0
  203. package/src/cli/convoy/lock.ts +103 -0
  204. package/src/cli/convoy/log-merge.test.ts +179 -0
  205. package/src/cli/convoy/merge.test.ts +6 -7
  206. package/src/cli/convoy/merge.ts +19 -1
  207. package/src/cli/convoy/partition.test.ts +423 -0
  208. package/src/cli/convoy/partition.ts +232 -0
  209. package/src/cli/convoy/pipeline.test.ts +6 -0
  210. package/src/cli/convoy/store.test.ts +2041 -20
  211. package/src/cli/convoy/store.ts +945 -46
  212. package/src/cli/convoy/types.ts +278 -4
  213. package/src/cli/log.ts +120 -2
  214. package/src/cli/run/adapters/claude.test.ts +234 -0
  215. package/src/cli/run/adapters/claude.ts +45 -5
  216. package/src/cli/run/adapters/copilot.test.ts +224 -0
  217. package/src/cli/run/adapters/copilot.ts +34 -4
  218. package/src/cli/run/adapters/cursor.test.ts +144 -0
  219. package/src/cli/run/adapters/cursor.ts +33 -2
  220. package/src/cli/run/adapters/opencode.test.ts +135 -0
  221. package/src/cli/run/adapters/opencode.ts +30 -2
  222. package/src/cli/run/executor.ts +1 -1
  223. package/src/cli/run/schema.test.ts +758 -0
  224. package/src/cli/run/schema.ts +300 -25
  225. package/src/cli/run.ts +341 -21
  226. package/src/cli/types.ts +86 -1
  227. package/src/cli/watch.ts +298 -0
  228. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  229. package/src/dashboard/dist/data/.gitkeep +0 -0
  230. package/src/dashboard/dist/data/convoy-list.json +1 -0
  231. package/src/dashboard/dist/data/overall-stats.json +24 -0
  232. package/src/dashboard/dist/index.html +701 -3
  233. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  234. package/src/dashboard/public/data/.gitkeep +0 -0
  235. package/src/dashboard/public/data/convoy-list.json +1 -0
  236. package/src/dashboard/public/data/overall-stats.json +24 -0
  237. package/src/dashboard/scripts/etl.test.ts +210 -0
  238. package/src/dashboard/scripts/etl.ts +108 -0
  239. package/src/dashboard/scripts/integration-test.ts +504 -0
  240. package/src/dashboard/src/pages/index.astro +854 -15
  241. package/src/dashboard/src/styles/dashboard.css +557 -1
  242. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -0,0 +1,103 @@
1
+ import { hostname as getHostname } from 'node:os'
2
+ import { DatabaseSync } from 'node:sqlite'
3
+
4
+ export class EngineAlreadyRunningError extends Error {
5
+ constructor(public readonly pid: number, public readonly hostname: string) {
6
+ super(
7
+ `Another opencastle process (PID ${pid} on ${hostname}) is already running against this database.`,
8
+ )
9
+ this.name = 'EngineAlreadyRunningError'
10
+ }
11
+ }
12
+
13
+ type LockRow = { pid: number; hostname: string; last_heartbeat: string }
14
+
15
+ function checkStaleness(row: LockRow): boolean {
16
+ const heartbeatAge = Date.now() - new Date(row.last_heartbeat).getTime()
17
+ if (heartbeatAge <= 30_000) return false
18
+ if (row.hostname !== getHostname()) return true
19
+ try {
20
+ process.kill(row.pid, 0)
21
+ return false // PID is alive on this host
22
+ } catch {
23
+ return true // PID is dead
24
+ }
25
+ }
26
+
27
+ export function isLockStale(db: DatabaseSync): boolean {
28
+ const row = db
29
+ .prepare('SELECT pid, hostname, last_heartbeat FROM engine_lock WHERE id = 1')
30
+ .get() as LockRow | undefined
31
+ if (!row) return true
32
+ return checkStaleness(row)
33
+ }
34
+
35
+ export function releaseEngineLock(db: DatabaseSync): void {
36
+ db.exec('DELETE FROM engine_lock WHERE id = 1')
37
+ }
38
+
39
+ export function acquireEngineLock(
40
+ db: DatabaseSync,
41
+ _dbPath: string,
42
+ ): {
43
+ release: () => void
44
+ startHeartbeat: () => NodeJS.Timeout
45
+ } {
46
+ // BEGIN IMMEDIATE acquires a write lock upfront, preventing concurrent writers
47
+ try {
48
+ db.exec('BEGIN IMMEDIATE')
49
+ } catch (err) {
50
+ const msg = (err as Error).message ?? ''
51
+ if (msg.includes('SQLITE_BUSY') || msg.includes('database is locked')) {
52
+ throw new EngineAlreadyRunningError(0, 'unknown')
53
+ }
54
+ throw err
55
+ }
56
+
57
+ const existing = db
58
+ .prepare('SELECT pid, hostname, last_heartbeat FROM engine_lock WHERE id = 1')
59
+ .get() as LockRow | undefined
60
+
61
+ if (existing) {
62
+ const stale = checkStaleness(existing)
63
+ if (!stale) {
64
+ db.exec('ROLLBACK')
65
+ throw new EngineAlreadyRunningError(existing.pid, existing.hostname)
66
+ }
67
+ }
68
+
69
+ const now = new Date().toISOString()
70
+ db.prepare(
71
+ 'INSERT OR REPLACE INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
72
+ ).run(process.pid, getHostname(), now, now)
73
+ db.exec('COMMIT')
74
+
75
+ let heartbeatInterval: NodeJS.Timeout | undefined
76
+
77
+ function startHeartbeat(): NodeJS.Timeout {
78
+ heartbeatInterval = setInterval(() => {
79
+ try {
80
+ db.prepare('UPDATE engine_lock SET last_heartbeat = ? WHERE id = 1').run(
81
+ new Date().toISOString(),
82
+ )
83
+ } catch {
84
+ // Ignore errors — DB may have been closed
85
+ }
86
+ }, 10_000)
87
+ return heartbeatInterval
88
+ }
89
+
90
+ function release(): void {
91
+ if (heartbeatInterval !== undefined) {
92
+ clearInterval(heartbeatInterval)
93
+ heartbeatInterval = undefined
94
+ }
95
+ try {
96
+ db.exec('DELETE FROM engine_lock WHERE id = 1')
97
+ } catch {
98
+ // Ignore errors — DB may have been closed
99
+ }
100
+ }
101
+
102
+ return { release, startHeartbeat }
103
+ }
@@ -0,0 +1,179 @@
1
+ import { mkdtempSync, rmSync, realpathSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
5
+ import { mergeConvoyLogs } from '../log.js'
6
+
7
+ const CONVOYS_REL = '.opencastle/logs/convoys'
8
+ const OUTPUT_REL = '.opencastle/logs/convoy-events.ndjson'
9
+
10
+ function makeBase(): string {
11
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'log-merge-test-')))
12
+ mkdirSync(join(dir, CONVOYS_REL), { recursive: true })
13
+ return dir
14
+ }
15
+
16
+ function writeConvoyFile(base: string, convoyId: string, records: object[]): void {
17
+ const path = join(base, CONVOYS_REL, `${convoyId}.ndjson`)
18
+ writeFileSync(path, records.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8')
19
+ }
20
+
21
+ let tmpDir: string
22
+
23
+ beforeEach(() => {
24
+ tmpDir = makeBase()
25
+ })
26
+
27
+ afterEach(() => {
28
+ rmSync(tmpDir, { recursive: true, force: true })
29
+ })
30
+
31
+ describe('mergeConvoyLogs', () => {
32
+ it('returns zeros when convoys directory is missing', async () => {
33
+ rmSync(join(tmpDir, '.opencastle'), { recursive: true, force: true })
34
+ const result = await mergeConvoyLogs({ basePath: tmpDir })
35
+ expect(result).toEqual({ merged: 0, deduplicated: 0, written: 0 })
36
+ })
37
+
38
+ it('returns zeros when convoys directory is empty', async () => {
39
+ const result = await mergeConvoyLogs({ basePath: tmpDir })
40
+ expect(result).toEqual({ merged: 0, deduplicated: 0, written: 0 })
41
+ })
42
+
43
+ it('merges records from 3 convoy files', async () => {
44
+ writeConvoyFile(tmpDir, 'convoy-a', [
45
+ { _event_id: 1, timestamp: '2026-01-01T10:00:00.000Z', type: 'task_started' },
46
+ ])
47
+ writeConvoyFile(tmpDir, 'convoy-b', [
48
+ { _event_id: 2, timestamp: '2026-01-02T10:00:00.000Z', type: 'task_done' },
49
+ ])
50
+ writeConvoyFile(tmpDir, 'convoy-c', [
51
+ { _event_id: 3, timestamp: '2026-01-03T10:00:00.000Z', type: 'session' },
52
+ ])
53
+
54
+ const result = await mergeConvoyLogs({ basePath: tmpDir })
55
+ expect(result.merged).toBe(3)
56
+ expect(result.written).toBe(3)
57
+ })
58
+
59
+ it('output is sorted by timestamp ascending', async () => {
60
+ writeConvoyFile(tmpDir, 'convoy-z', [
61
+ { _event_id: 10, timestamp: '2026-03-01T00:00:00.000Z', type: 'task_done' },
62
+ { _event_id: 11, timestamp: '2026-01-01T00:00:00.000Z', type: 'task_started' },
63
+ ])
64
+ writeConvoyFile(tmpDir, 'convoy-a', [
65
+ { _event_id: 12, timestamp: '2026-02-01T00:00:00.000Z', type: 'session' },
66
+ ])
67
+
68
+ const outputPath = join(tmpDir, 'merged.ndjson')
69
+ await mergeConvoyLogs({ basePath: tmpDir, output: outputPath })
70
+
71
+ const lines = readFileSync(outputPath, 'utf8').split('\n').filter(l => l.trim())
72
+ const timestamps = lines.map(l => (JSON.parse(l) as { timestamp: string }).timestamp)
73
+ expect(timestamps).toEqual([
74
+ '2026-01-01T00:00:00.000Z',
75
+ '2026-02-01T00:00:00.000Z',
76
+ '2026-03-01T00:00:00.000Z',
77
+ ])
78
+ })
79
+
80
+ it('deduplicates records by _event_id (keeps first occurrence)', async () => {
81
+ writeConvoyFile(tmpDir, 'convoy-a', [
82
+ { _event_id: 5, timestamp: '2026-01-01T00:00:00.000Z', type: 'task_started', note: 'first' },
83
+ ])
84
+ writeConvoyFile(tmpDir, 'convoy-b', [
85
+ { _event_id: 5, timestamp: '2026-01-01T00:00:00.000Z', type: 'task_started', note: 'duplicate' },
86
+ { _event_id: 6, timestamp: '2026-01-02T00:00:00.000Z', type: 'task_done' },
87
+ ])
88
+
89
+ const outputPath = join(tmpDir, 'merged.ndjson')
90
+ const result = await mergeConvoyLogs({ basePath: tmpDir, output: outputPath })
91
+
92
+ expect(result.merged).toBe(3)
93
+ expect(result.deduplicated).toBe(1)
94
+ expect(result.written).toBe(2)
95
+
96
+ const lines = readFileSync(outputPath, 'utf8').split('\n').filter(l => l.trim())
97
+ expect(lines).toHaveLength(2)
98
+ const first = JSON.parse(lines[0]) as { note: string }
99
+ expect(first.note).toBe('first')
100
+ })
101
+
102
+ it('filters by --since (inclusive)', async () => {
103
+ writeConvoyFile(tmpDir, 'convoy-a', [
104
+ { _event_id: 1, timestamp: '2026-01-01T00:00:00.000Z', type: 'session' },
105
+ { _event_id: 2, timestamp: '2026-02-01T00:00:00.000Z', type: 'session' },
106
+ { _event_id: 3, timestamp: '2026-03-01T00:00:00.000Z', type: 'session' },
107
+ ])
108
+
109
+ const outputPath = join(tmpDir, 'merged.ndjson')
110
+ const result = await mergeConvoyLogs({ basePath: tmpDir, since: '2026-02-01T00:00:00.000Z', output: outputPath })
111
+
112
+ expect(result.written).toBe(2)
113
+ const lines = readFileSync(outputPath, 'utf8').split('\n').filter(l => l.trim())
114
+ expect(lines).toHaveLength(2)
115
+ })
116
+
117
+ it('filters by --until (inclusive)', async () => {
118
+ writeConvoyFile(tmpDir, 'convoy-a', [
119
+ { _event_id: 1, timestamp: '2026-01-01T00:00:00.000Z', type: 'session' },
120
+ { _event_id: 2, timestamp: '2026-02-01T00:00:00.000Z', type: 'session' },
121
+ { _event_id: 3, timestamp: '2026-03-01T00:00:00.000Z', type: 'session' },
122
+ ])
123
+
124
+ const outputPath = join(tmpDir, 'merged.ndjson')
125
+ const result = await mergeConvoyLogs({ basePath: tmpDir, until: '2026-02-01T00:00:00.000Z', output: outputPath })
126
+
127
+ expect(result.written).toBe(2)
128
+ })
129
+
130
+ it('filters by --since and --until together', async () => {
131
+ writeConvoyFile(tmpDir, 'convoy-a', [
132
+ { _event_id: 1, timestamp: '2026-01-01T00:00:00.000Z', type: 'session' },
133
+ { _event_id: 2, timestamp: '2026-02-15T00:00:00.000Z', type: 'session' },
134
+ { _event_id: 3, timestamp: '2026-03-01T00:00:00.000Z', type: 'session' },
135
+ ])
136
+
137
+ const outputPath = join(tmpDir, 'merged.ndjson')
138
+ const result = await mergeConvoyLogs({
139
+ basePath: tmpDir,
140
+ since: '2026-02-01T00:00:00.000Z',
141
+ until: '2026-02-28T23:59:59.999Z',
142
+ output: outputPath,
143
+ })
144
+
145
+ expect(result.written).toBe(1)
146
+ const lines = readFileSync(outputPath, 'utf8').split('\n').filter(l => l.trim())
147
+ const record = JSON.parse(lines[0]) as { timestamp: string }
148
+ expect(record.timestamp).toBe('2026-02-15T00:00:00.000Z')
149
+ })
150
+
151
+ it('writes to default output path when --output not specified', async () => {
152
+ writeConvoyFile(tmpDir, 'convoy-a', [
153
+ { _event_id: 1, timestamp: '2026-01-01T00:00:00.000Z', type: 'session' },
154
+ ])
155
+
156
+ await mergeConvoyLogs({ basePath: tmpDir })
157
+
158
+ const defaultPath = join(tmpDir, '.opencastle', 'logs', 'convoy-events.ndjson')
159
+ expect(existsSync(defaultPath)).toBe(true)
160
+ })
161
+
162
+ it('returns written: 0 when all records filtered out', async () => {
163
+ writeConvoyFile(tmpDir, 'convoy-a', [
164
+ { _event_id: 1, timestamp: '2026-01-01T00:00:00.000Z', type: 'session' },
165
+ ])
166
+
167
+ const result = await mergeConvoyLogs({ basePath: tmpDir, since: '2027-01-01T00:00:00.000Z' })
168
+ expect(result.written).toBe(0)
169
+ expect(result.merged).toBe(1)
170
+ })
171
+
172
+ it('skips malformed JSON lines gracefully', async () => {
173
+ const path = join(tmpDir, CONVOYS_REL, 'convoy-bad.ndjson')
174
+ writeFileSync(path, '{"_event_id":1,"timestamp":"2026-01-01T00:00:00.000Z","type":"session"}\nnot-valid-json\n{"_event_id":2,"timestamp":"2026-01-02T00:00:00.000Z","type":"task_done"}\n', 'utf8')
175
+
176
+ const result = await mergeConvoyLogs({ basePath: tmpDir })
177
+ expect(result.written).toBe(2)
178
+ })
179
+ })
@@ -4,7 +4,7 @@ import { join } from 'node:path'
4
4
  import { execFile as execFileCb } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
- import { createMergeQueue } from './merge.js'
7
+ import { createMergeQueue, MergeConflictError } from './merge.js'
8
8
  import type { MergeQueue } from './merge.js'
9
9
 
10
10
  const execFile = promisify(execFileCb)
@@ -103,7 +103,7 @@ describe('merge - no changes', () => {
103
103
  // ── merge conflict ────────────────────────────────────────────────────────────
104
104
 
105
105
  describe('merge - conflict', () => {
106
- it('returns conflicted: true and aborts when two worktrees edit the same file', async () => {
106
+ it('throws MergeConflictError and aborts when two worktrees edit the same file', async () => {
107
107
  const worktree1 = await addWorktree(repoPath, 'worker1', featureBranch)
108
108
  const worktree2 = await addWorktree(repoPath, 'worker2', featureBranch)
109
109
 
@@ -113,10 +113,8 @@ describe('merge - conflict', () => {
113
113
  const first = await queue.merge(worktree1, 'convoy-worker1', featureBranch)
114
114
  expect(first).toEqual({ success: true, conflicted: false, message: 'Merged successfully' })
115
115
 
116
- const second = await queue.merge(worktree2, 'convoy-worker2', featureBranch)
117
- expect(second.success).toBe(false)
118
- expect(second.conflicted).toBe(true)
119
- expect(second.message).toContain('conflict')
116
+ await expect(queue.merge(worktree2, 'convoy-worker2', featureBranch))
117
+ .rejects.toThrow(MergeConflictError)
120
118
  })
121
119
 
122
120
  it('leaves the repo in a clean state (no pending merge) after aborting a conflict', async () => {
@@ -127,7 +125,8 @@ describe('merge - conflict', () => {
127
125
  writeFileSync(join(worktree2, 'shared.txt'), 'content from worker 2')
128
126
 
129
127
  await queue.merge(worktree1, 'convoy-worker1', featureBranch)
130
- await queue.merge(worktree2, 'convoy-worker2', featureBranch)
128
+ await expect(queue.merge(worktree2, 'convoy-worker2', featureBranch))
129
+ .rejects.toBeInstanceOf(MergeConflictError)
131
130
 
132
131
  // --untracked-files=no excludes the .opencastle/worktrees/ dir from the check;
133
132
  // we only want to verify there is no pending merge (no staged/modified tracked files).
@@ -10,6 +10,16 @@ export interface MergeResult {
10
10
  message: string
11
11
  }
12
12
 
13
+ export class MergeConflictError extends Error {
14
+ constructor(
15
+ public readonly conflictingFiles: string[],
16
+ message?: string,
17
+ ) {
18
+ super(message ?? `Merge conflict in: ${conflictingFiles.join(', ')}`)
19
+ this.name = 'MergeConflictError'
20
+ }
21
+ }
22
+
13
23
  export interface MergeQueue {
14
24
  /**
15
25
  * Merge a single worktree's changes back onto the target branch.
@@ -78,8 +88,16 @@ export function createMergeQueue(repoPath: string): MergeQueue {
78
88
  error.code === 1 &&
79
89
  ((error.stderr ?? '').includes('CONFLICT') || (error.stdout ?? '').includes('CONFLICT'))
80
90
  if (isConflict) {
91
+ // Collect conflicting files before aborting
92
+ let conflictingFiles: string[] = []
93
+ try {
94
+ const { stdout: conflictOut } = await execFile('git', [
95
+ '-C', repoPath, 'diff', '--name-only', '--diff-filter=U',
96
+ ])
97
+ conflictingFiles = conflictOut.split('\n').filter(Boolean)
98
+ } catch { /* ignore — we still abort */ }
81
99
  await execFile('git', ['-C', repoPath, 'merge', '--abort'])
82
- return { success: false, conflicted: true, message: 'Merge conflict detected; merge aborted' }
100
+ throw new MergeConflictError(conflictingFiles)
83
101
  }
84
102
  throw err
85
103
  }