opencastle 0.27.1 → 0.27.3
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/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 +0 -1
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +31 -99
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +88 -1
- 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 +8 -0
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +117 -5
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +173 -3
- package/dist/cli/convoy/events.test.js.map +1 -1
- 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/store.d.ts +52 -2
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +244 -17
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +481 -22
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +271 -3
- 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.d.ts.map +1 -1
- package/dist/cli/run.js +37 -1
- package/dist/cli/run.js.map +1 -1
- package/package.json +6 -1
- package/src/cli/convoy/TELEMETRY.md +203 -0
- package/src/cli/convoy/dashboard-types.ts +141 -0
- package/src/cli/convoy/engine.test.ts +99 -1
- package/src/cli/convoy/engine.ts +27 -96
- package/src/cli/convoy/event-schemas.ts +195 -0
- package/src/cli/convoy/events.test.ts +207 -3
- package/src/cli/convoy/events.ts +119 -5
- package/src/cli/convoy/log-merge.test.ts +179 -0
- package/src/cli/convoy/store.test.ts +545 -22
- package/src/cli/convoy/store.ts +274 -21
- package/src/cli/convoy/types.ts +108 -3
- package/src/cli/log.ts +120 -2
- package/src/cli/run.ts +37 -1
- 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 +20 -0
- package/src/dashboard/dist/data/convoys/demo-convoy-1.json +111 -0
- package/src/dashboard/dist/data/convoys/demo-convoy-2.json +72 -0
- package/src/dashboard/dist/data/overall-stats.json +36 -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 +20 -0
- package/src/dashboard/public/data/convoys/demo-convoy-1.json +111 -0
- package/src/dashboard/public/data/convoys/demo-convoy-2.json +72 -0
- package/src/dashboard/public/data/overall-stats.json +36 -0
- package/src/dashboard/scripts/etl.test.ts +210 -0
- package/src/dashboard/scripts/etl.ts +121 -0
- package/src/dashboard/scripts/generate-demo-db.test.ts +30 -0
- package/src/dashboard/scripts/generate-demo-db.ts +140 -0
- package/src/dashboard/scripts/integration-test.ts +504 -0
- package/src/dashboard/scripts/verify-demo-data.sh +51 -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
package/src/cli/convoy/events.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
import { appendFileSync, closeSync, fsyncSync, openSync } from 'node:fs'
|
|
1
|
+
import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
2
3
|
import type { ConvoyStore } from './store.js'
|
|
4
|
+
import { KNOWN_EVENT_TYPES } from './types.js'
|
|
5
|
+
import { validateEventData } from './event-schemas.js'
|
|
6
|
+
|
|
7
|
+
const RESERVED_KEYS = new Set(['_event_id', 'convoy_id', 'task_id', 'worker_id', 'timestamp', 'type'])
|
|
3
8
|
import { scanForSecrets } from './gates.js'
|
|
4
9
|
|
|
10
|
+
export function validateEventType(type: string): boolean {
|
|
11
|
+
return KNOWN_EVENT_TYPES.has(type)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ndjsonPathForConvoy(convoyId: string, basePath?: string): string {
|
|
15
|
+
const base = basePath ?? process.cwd()
|
|
16
|
+
return join(base, '.opencastle', 'logs', 'convoys', `${convoyId}.ndjson`)
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
export interface ConvoyEventEmitter {
|
|
6
20
|
emit(
|
|
7
21
|
type: string,
|
|
@@ -15,8 +29,13 @@ export function createEventEmitter(
|
|
|
15
29
|
store: ConvoyStore,
|
|
16
30
|
options?: { ndjsonPath?: string },
|
|
17
31
|
): ConvoyEventEmitter {
|
|
32
|
+
if (typeof options === 'string') {
|
|
33
|
+
throw new TypeError('createEventEmitter options must be an object, not a string')
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
let fd: number | null = null
|
|
19
37
|
if (options?.ndjsonPath) {
|
|
38
|
+
mkdirSync(dirname(options.ndjsonPath), { recursive: true })
|
|
20
39
|
fd = openSync(options.ndjsonPath, 'a')
|
|
21
40
|
}
|
|
22
41
|
|
|
@@ -30,6 +49,12 @@ export function createEventEmitter(
|
|
|
30
49
|
eventId: number,
|
|
31
50
|
currentFd: number,
|
|
32
51
|
): Promise<void> {
|
|
52
|
+
const safeData: Record<string, unknown> = {}
|
|
53
|
+
if (data) {
|
|
54
|
+
for (const [k, v] of Object.entries(data)) {
|
|
55
|
+
if (!RESERVED_KEYS.has(k)) safeData[k] = v
|
|
56
|
+
}
|
|
57
|
+
}
|
|
33
58
|
const record = {
|
|
34
59
|
_event_id: eventId,
|
|
35
60
|
timestamp: now,
|
|
@@ -37,7 +62,7 @@ export function createEventEmitter(
|
|
|
37
62
|
convoy_id: ids?.convoy_id ?? null,
|
|
38
63
|
task_id: ids?.task_id ?? null,
|
|
39
64
|
worker_id: ids?.worker_id ?? null,
|
|
40
|
-
...
|
|
65
|
+
...safeData,
|
|
41
66
|
}
|
|
42
67
|
const jsonLine = JSON.stringify(record) + '\n'
|
|
43
68
|
|
|
@@ -80,9 +105,16 @@ export function createEventEmitter(
|
|
|
80
105
|
|
|
81
106
|
return {
|
|
82
107
|
emit(type, data, ids) {
|
|
83
|
-
//
|
|
84
|
-
// (task output, DLQ entries) is scanned at its source
|
|
85
|
-
//
|
|
108
|
+
// SQLite insert is not scanned; NDJSON write is scanned via writeNdjson().
|
|
109
|
+
// User-generated content (task output, DLQ entries) is scanned at its source
|
|
110
|
+
// before reaching the event emitter. See MF-4 in panel report.
|
|
111
|
+
if (!validateEventType(type)) {
|
|
112
|
+
console.warn(`[convoy] Unknown event type: "${type}"`)
|
|
113
|
+
}
|
|
114
|
+
const dataValidation = validateEventData(type, data)
|
|
115
|
+
if (!dataValidation.valid) {
|
|
116
|
+
console.warn(`[convoy] Invalid data for event type "${type}": ${dataValidation.issues?.join(', ')}`)
|
|
117
|
+
}
|
|
86
118
|
const now = new Date().toISOString()
|
|
87
119
|
|
|
88
120
|
const eventId = store.insertEvent({
|
|
@@ -111,3 +143,85 @@ export function createEventEmitter(
|
|
|
111
143
|
},
|
|
112
144
|
}
|
|
113
145
|
}
|
|
146
|
+
|
|
147
|
+
function safeJsonParse(raw: string): Record<string, unknown> {
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(raw) as Record<string, unknown>
|
|
150
|
+
} catch {
|
|
151
|
+
return {}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Truncate any trailing partial line in the NDJSON file, then replay any SQLite
|
|
157
|
+
* events for the given convoy that are missing from the file.
|
|
158
|
+
* Exported for unit testing.
|
|
159
|
+
*/
|
|
160
|
+
export function recoverNdjson(store: ConvoyStore, convoyId: string, ndjsonPath: string): void {
|
|
161
|
+
// 1. Read the NDJSON file (if it exists)
|
|
162
|
+
let fileContent: string
|
|
163
|
+
try {
|
|
164
|
+
fileContent = readFileSync(ndjsonPath, 'utf8')
|
|
165
|
+
} catch {
|
|
166
|
+
fileContent = ''
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 2. Truncate any partial trailing line (no \n terminator)
|
|
170
|
+
if (fileContent.length > 0 && !fileContent.endsWith('\n')) {
|
|
171
|
+
const lastNewline = fileContent.lastIndexOf('\n')
|
|
172
|
+
if (lastNewline === -1) {
|
|
173
|
+
writeFileSync(ndjsonPath, '')
|
|
174
|
+
fileContent = ''
|
|
175
|
+
} else {
|
|
176
|
+
writeFileSync(ndjsonPath, fileContent.slice(0, lastNewline + 1))
|
|
177
|
+
fileContent = fileContent.slice(0, lastNewline + 1)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 3. Count valid NDJSON event IDs for this convoy
|
|
182
|
+
const ndjsonIds = new Set<number>()
|
|
183
|
+
for (const line of fileContent.split('\n')) {
|
|
184
|
+
if (!line.trim()) continue
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
187
|
+
if (parsed.convoy_id === convoyId && parsed._event_id != null) {
|
|
188
|
+
ndjsonIds.add(parsed._event_id as number)
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// Skip unparseable lines
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4. Get all SQLite events for this convoy
|
|
196
|
+
const sqliteEvents = store.getEvents(convoyId)
|
|
197
|
+
|
|
198
|
+
// 5. Replay missing events (those in SQLite but not in NDJSON)
|
|
199
|
+
const missing = sqliteEvents.filter(e => e.id != null && !ndjsonIds.has(e.id!))
|
|
200
|
+
if (missing.length > 0) {
|
|
201
|
+
const fd = openSync(ndjsonPath, 'a')
|
|
202
|
+
try {
|
|
203
|
+
for (const event of missing) {
|
|
204
|
+
const parsedData = event.data ? safeJsonParse(event.data) : {}
|
|
205
|
+
// Strip reserved keys from event.data to prevent attacker-controlled
|
|
206
|
+
// values from overriding canonical fields from the DB row.
|
|
207
|
+
const safeData: Record<string, unknown> = {}
|
|
208
|
+
for (const [key, value] of Object.entries(parsedData)) {
|
|
209
|
+
if (!RESERVED_KEYS.has(key)) safeData[key] = value
|
|
210
|
+
}
|
|
211
|
+
const record = {
|
|
212
|
+
...safeData,
|
|
213
|
+
_event_id: event.id,
|
|
214
|
+
timestamp: event.created_at,
|
|
215
|
+
type: event.type,
|
|
216
|
+
convoy_id: event.convoy_id,
|
|
217
|
+
task_id: event.task_id,
|
|
218
|
+
worker_id: event.worker_id,
|
|
219
|
+
}
|
|
220
|
+
appendFileSync(fd, JSON.stringify(record) + '\n')
|
|
221
|
+
}
|
|
222
|
+
fsyncSync(fd)
|
|
223
|
+
} finally {
|
|
224
|
+
closeSync(fd)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -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
|
+
})
|