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
package/src/cli/convoy/events.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
import {
|
|
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'])
|
|
8
|
+
import { scanForSecrets } from './gates.js'
|
|
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
|
+
}
|
|
3
18
|
|
|
4
19
|
export interface ConvoyEventEmitter {
|
|
5
20
|
emit(
|
|
@@ -7,35 +22,206 @@ export interface ConvoyEventEmitter {
|
|
|
7
22
|
data?: Record<string, unknown>,
|
|
8
23
|
ids?: { convoy_id?: string; task_id?: string; worker_id?: string },
|
|
9
24
|
): void
|
|
25
|
+
close(): void
|
|
10
26
|
}
|
|
11
27
|
|
|
12
|
-
export function createEventEmitter(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
28
|
+
export function createEventEmitter(
|
|
29
|
+
store: ConvoyStore,
|
|
30
|
+
options?: { ndjsonPath?: string },
|
|
31
|
+
): ConvoyEventEmitter {
|
|
32
|
+
if (typeof options === 'string') {
|
|
33
|
+
throw new TypeError('createEventEmitter options must be an object, not a string')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let fd: number | null = null
|
|
37
|
+
if (options?.ndjsonPath) {
|
|
38
|
+
mkdirSync(dirname(options.ndjsonPath), { recursive: true })
|
|
39
|
+
fd = openSync(options.ndjsonPath, 'a')
|
|
40
|
+
}
|
|
16
41
|
|
|
42
|
+
// NDJSON writes are supplementary — SQLite is the primary store. Use async
|
|
43
|
+
// retries to avoid blocking the Node.js event loop.
|
|
44
|
+
async function writeNdjson(
|
|
45
|
+
type: string,
|
|
46
|
+
data: Record<string, unknown> | undefined,
|
|
47
|
+
ids: { convoy_id?: string; task_id?: string; worker_id?: string } | undefined,
|
|
48
|
+
now: string,
|
|
49
|
+
eventId: number,
|
|
50
|
+
currentFd: number,
|
|
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
|
+
}
|
|
58
|
+
const record = {
|
|
59
|
+
_event_id: eventId,
|
|
60
|
+
timestamp: now,
|
|
61
|
+
type,
|
|
62
|
+
convoy_id: ids?.convoy_id ?? null,
|
|
63
|
+
task_id: ids?.task_id ?? null,
|
|
64
|
+
worker_id: ids?.worker_id ?? null,
|
|
65
|
+
...safeData,
|
|
66
|
+
}
|
|
67
|
+
const jsonLine = JSON.stringify(record) + '\n'
|
|
68
|
+
|
|
69
|
+
const scanResult = scanForSecrets(jsonLine, 'ndjson')
|
|
70
|
+
if (!scanResult.clean) {
|
|
71
|
+
// Block the NDJSON write — record the blocked event in SQLite only
|
|
17
72
|
store.insertEvent({
|
|
18
73
|
convoy_id: ids?.convoy_id ?? null,
|
|
19
74
|
task_id: ids?.task_id ?? null,
|
|
20
75
|
worker_id: ids?.worker_id ?? null,
|
|
21
|
-
type,
|
|
22
|
-
data:
|
|
76
|
+
type: 'secret_leak_prevented',
|
|
77
|
+
data: JSON.stringify({ original_type: type, patterns: scanResult.findings.map(f => f.pattern) }),
|
|
23
78
|
created_at: now,
|
|
24
79
|
})
|
|
80
|
+
return
|
|
81
|
+
}
|
|
25
82
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
83
|
+
try {
|
|
84
|
+
appendFileSync(currentFd, jsonLine)
|
|
85
|
+
fsyncSync(currentFd)
|
|
86
|
+
} catch {
|
|
87
|
+
// Retry once after 100ms (non-blocking)
|
|
88
|
+
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
|
89
|
+
try {
|
|
90
|
+
appendFileSync(currentFd, jsonLine)
|
|
91
|
+
fsyncSync(currentFd)
|
|
92
|
+
} catch {
|
|
93
|
+
// Emit failure meta-event to SQLite only (do NOT recurse into NDJSON write)
|
|
94
|
+
store.insertEvent({
|
|
30
95
|
convoy_id: ids?.convoy_id ?? null,
|
|
31
96
|
task_id: ids?.task_id ?? null,
|
|
32
97
|
worker_id: ids?.worker_id ?? null,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
98
|
+
type: 'ndjson_write_failed',
|
|
99
|
+
data: JSON.stringify({ original_type: type }),
|
|
100
|
+
created_at: new Date().toISOString(),
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
emit(type, data, ids) {
|
|
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
|
+
}
|
|
118
|
+
const now = new Date().toISOString()
|
|
119
|
+
|
|
120
|
+
const eventId = store.insertEvent({
|
|
121
|
+
convoy_id: ids?.convoy_id ?? null,
|
|
122
|
+
task_id: ids?.task_id ?? null,
|
|
123
|
+
worker_id: ids?.worker_id ?? null,
|
|
124
|
+
type,
|
|
125
|
+
data: data !== undefined ? JSON.stringify(data) : null,
|
|
126
|
+
created_at: now,
|
|
38
127
|
})
|
|
128
|
+
|
|
129
|
+
// Fire-and-forget: SQLite record (above) is the source of truth.
|
|
130
|
+
// NDJSON is supplementary — no need to await or block on it.
|
|
131
|
+
if (fd !== null) {
|
|
132
|
+
writeNdjson(type, data, ids, now, eventId, fd).catch(() => {
|
|
133
|
+
// Swallow unhandled rejection — failure already recorded in SQLite via writeNdjson
|
|
134
|
+
})
|
|
135
|
+
}
|
|
39
136
|
},
|
|
137
|
+
|
|
138
|
+
close() {
|
|
139
|
+
if (fd !== null) {
|
|
140
|
+
closeSync(fd)
|
|
141
|
+
fd = null
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
|
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
|
+
}
|
|
40
226
|
}
|
|
41
227
|
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { scanForSecrets } from './gates.js'
|
|
4
|
+
|
|
5
|
+
const EXPERTISE_PATH = '.opencastle/AGENT-EXPERTISE.md'
|
|
6
|
+
|
|
7
|
+
export function readExpertise(
|
|
8
|
+
agentName: string,
|
|
9
|
+
basePath?: string,
|
|
10
|
+
): { strong: string[]; weak: string[]; files: string[] } {
|
|
11
|
+
const base = basePath ?? process.cwd()
|
|
12
|
+
const filePath = join(base, EXPERTISE_PATH)
|
|
13
|
+
const empty = { strong: [] as string[], weak: [] as string[], files: [] as string[] }
|
|
14
|
+
if (!existsSync(filePath)) return empty
|
|
15
|
+
|
|
16
|
+
const content = readFileSync(filePath, 'utf8')
|
|
17
|
+
const lines = content.split('\n')
|
|
18
|
+
const agentHeaderRegex = new RegExp(
|
|
19
|
+
'^## ' + agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$',
|
|
20
|
+
'i',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
let inAgentSection = false
|
|
24
|
+
let currentSubsection = ''
|
|
25
|
+
const result = { strong: [] as string[], weak: [] as string[], files: [] as string[] }
|
|
26
|
+
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (agentHeaderRegex.test(line)) {
|
|
29
|
+
inAgentSection = true
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (inAgentSection) {
|
|
33
|
+
if (line.startsWith('## ')) break
|
|
34
|
+
if (line.startsWith('### ')) {
|
|
35
|
+
currentSubsection = line.replace(/^###\s*/, '').trim()
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (line.startsWith('- ')) {
|
|
39
|
+
const item = line.replace(/^-\s*/, '').trim()
|
|
40
|
+
if (currentSubsection === 'Strong Areas') result.strong.push(item)
|
|
41
|
+
else if (currentSubsection === 'Weak Areas') result.weak.push(item)
|
|
42
|
+
else if (currentSubsection === 'File Familiarity') result.files.push(item)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return result
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appendBulletToSubsection(
|
|
51
|
+
lines: string[],
|
|
52
|
+
agentName: string,
|
|
53
|
+
subsection: string,
|
|
54
|
+
item: string,
|
|
55
|
+
): void {
|
|
56
|
+
const agentHeaderRegex = new RegExp(
|
|
57
|
+
'^## ' + agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$',
|
|
58
|
+
'i',
|
|
59
|
+
)
|
|
60
|
+
const subsectionHeader = '### ' + subsection
|
|
61
|
+
|
|
62
|
+
let inAgentSection = false
|
|
63
|
+
let inSubsection = false
|
|
64
|
+
let insertAfterLine = -1
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
if (agentHeaderRegex.test(lines[i])) {
|
|
68
|
+
inAgentSection = true
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (inAgentSection) {
|
|
72
|
+
if (lines[i].startsWith('## ')) break
|
|
73
|
+
if (lines[i] === subsectionHeader) {
|
|
74
|
+
inSubsection = true
|
|
75
|
+
insertAfterLine = i
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (inSubsection) {
|
|
79
|
+
if (lines[i].startsWith('### ') || lines[i].startsWith('## ')) break
|
|
80
|
+
if (lines[i].startsWith('- ')) {
|
|
81
|
+
insertAfterLine = i
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (insertAfterLine !== -1) {
|
|
88
|
+
lines.splice(insertAfterLine + 1, 0, '- ' + item)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ensureAgentSection(lines: string[], agentName: string): void {
|
|
93
|
+
const agentHeaderRegex = new RegExp(
|
|
94
|
+
'^## ' + agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*$',
|
|
95
|
+
'i',
|
|
96
|
+
)
|
|
97
|
+
const exists = lines.some(l => agentHeaderRegex.test(l))
|
|
98
|
+
if (!exists) {
|
|
99
|
+
lines.push(
|
|
100
|
+
'',
|
|
101
|
+
'## ' + agentName,
|
|
102
|
+
'',
|
|
103
|
+
'### Strong Areas',
|
|
104
|
+
'',
|
|
105
|
+
'### Weak Areas',
|
|
106
|
+
'',
|
|
107
|
+
'### File Familiarity',
|
|
108
|
+
'',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function updateExpertise(
|
|
114
|
+
agentName: string,
|
|
115
|
+
taskResult: { taskId: string; success: boolean; retries: number; files: string[] },
|
|
116
|
+
basePath?: string,
|
|
117
|
+
): { updated: boolean; reason?: string } {
|
|
118
|
+
const base = basePath ?? process.cwd()
|
|
119
|
+
const filePath = join(base, EXPERTISE_PATH)
|
|
120
|
+
|
|
121
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
122
|
+
const outcome = taskResult.success ? 'success' : 'failed'
|
|
123
|
+
const entryText =
|
|
124
|
+
'[' + date + '] ' + taskResult.taskId + ': ' + outcome +
|
|
125
|
+
'/retries=' + taskResult.retries + ', files: [' + taskResult.files.join(', ') + ']'
|
|
126
|
+
|
|
127
|
+
const scanResult = scanForSecrets(entryText, 'expertise')
|
|
128
|
+
if (!scanResult.clean) {
|
|
129
|
+
return { updated: false, reason: 'secrets_detected' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const initialContent = existsSync(filePath)
|
|
133
|
+
? readFileSync(filePath, 'utf8')
|
|
134
|
+
: '# Agent Expertise\n\nTracking agent performance across tasks.\n'
|
|
135
|
+
|
|
136
|
+
const lines = initialContent.split('\n')
|
|
137
|
+
ensureAgentSection(lines, agentName)
|
|
138
|
+
|
|
139
|
+
if (taskResult.success && taskResult.retries === 0) {
|
|
140
|
+
appendBulletToSubsection(lines, agentName, 'Strong Areas', entryText)
|
|
141
|
+
} else if (taskResult.success && taskResult.retries > 0) {
|
|
142
|
+
appendBulletToSubsection(lines, agentName, 'Strong Areas', entryText)
|
|
143
|
+
appendBulletToSubsection(lines, agentName, 'Weak Areas', entryText)
|
|
144
|
+
} else {
|
|
145
|
+
appendBulletToSubsection(lines, agentName, 'Weak Areas', entryText)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const existing = readExpertise(agentName, base)
|
|
149
|
+
const currentFiles = new Set(existing.files)
|
|
150
|
+
for (const f of taskResult.files) {
|
|
151
|
+
if (!currentFiles.has(f)) {
|
|
152
|
+
appendBulletToSubsection(lines, agentName, 'File Familiarity', f)
|
|
153
|
+
currentFiles.add(f)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
writeFileSync(filePath, lines.join('\n'), 'utf8')
|
|
158
|
+
return { updated: true }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function feedCircuitBreaker(agentName: string, basePath?: string): string[] {
|
|
162
|
+
return readExpertise(agentName, basePath).weak
|
|
163
|
+
}
|