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.
- package/bin/cli.mjs +6 -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/dashboard-types.d.ts +146 -0
- package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
- package/dist/cli/convoy/dashboard-types.js +2 -0
- package/dist/cli/convoy/dashboard-types.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts +67 -2
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +2036 -28
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1659 -70
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/event-schemas.d.ts +9 -0
- package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
- package/dist/cli/convoy/event-schemas.js +185 -0
- package/dist/cli/convoy/event-schemas.js.map +1 -0
- package/dist/cli/convoy/events.d.ts +12 -1
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +186 -13
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +325 -28
- 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/log-merge.test.d.ts +2 -0
- package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
- package/dist/cli/convoy/log-merge.test.js +147 -0
- package/dist/cli/convoy/log-merge.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 +99 -7
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +764 -31
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +1810 -18
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +427 -5
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/convoy/types.js +42 -1
- package/dist/cli/convoy/types.js.map +1 -1
- package/dist/cli/log.d.ts +11 -0
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +114 -2
- package/dist/cli/log.js.map +1 -1
- 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 +5 -1
- package/src/cli/agents.ts +177 -0
- package/src/cli/baselines.ts +143 -0
- package/src/cli/convoy/TELEMETRY.md +203 -0
- package/src/cli/convoy/dashboard-types.ts +141 -0
- package/src/cli/convoy/engine.test.ts +1937 -70
- package/src/cli/convoy/engine.ts +2350 -40
- package/src/cli/convoy/event-schemas.ts +195 -0
- package/src/cli/convoy/events.test.ts +384 -39
- package/src/cli/convoy/events.ts +202 -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/log-merge.test.ts +179 -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 +2041 -20
- package/src/cli/convoy/store.ts +945 -46
- package/src/cli/convoy/types.ts +278 -4
- package/src/cli/log.ts +120 -2
- 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/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
- package/src/dashboard/dist/data/.gitkeep +0 -0
- package/src/dashboard/dist/data/convoy-list.json +1 -0
- package/src/dashboard/dist/data/overall-stats.json +24 -0
- package/src/dashboard/dist/index.html +701 -3
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/public/data/.gitkeep +0 -0
- package/src/dashboard/public/data/convoy-list.json +1 -0
- package/src/dashboard/public/data/overall-stats.json +24 -0
- package/src/dashboard/scripts/etl.test.ts +210 -0
- package/src/dashboard/scripts/etl.ts +108 -0
- package/src/dashboard/scripts/integration-test.ts +504 -0
- package/src/dashboard/src/pages/index.astro +854 -15
- package/src/dashboard/src/styles/dashboard.css +557 -1
- 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('
|
|
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
|
-
|
|
117
|
-
|
|
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).
|
package/src/cli/convoy/merge.ts
CHANGED
|
@@ -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
|
-
|
|
100
|
+
throw new MergeConflictError(conflictingFiles)
|
|
83
101
|
}
|
|
84
102
|
throw err
|
|
85
103
|
}
|