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,195 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
|
|
3
|
+
type AnySchema = v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>
|
|
4
|
+
|
|
5
|
+
export const EVENT_DATA_SCHEMAS: Record<string, AnySchema> = {
|
|
6
|
+
convoy_started: v.looseObject({ name: v.optional(v.string()) }),
|
|
7
|
+
convoy_finished: v.looseObject({ status: v.string() }),
|
|
8
|
+
convoy_failed: v.looseObject({ status: v.string(), reason: v.optional(v.string()) }),
|
|
9
|
+
convoy_guard: v.looseObject({ checks: v.optional(v.array(v.string())) }),
|
|
10
|
+
|
|
11
|
+
task_started: v.looseObject({ worker_id: v.optional(v.string()) }),
|
|
12
|
+
task_done: v.looseObject({
|
|
13
|
+
status: v.optional(v.string()),
|
|
14
|
+
retries: v.optional(v.number()),
|
|
15
|
+
worker_id: v.optional(v.string()),
|
|
16
|
+
}),
|
|
17
|
+
task_failed: v.looseObject({
|
|
18
|
+
reason: v.string(),
|
|
19
|
+
worker_id: v.optional(v.string()),
|
|
20
|
+
gate: v.optional(v.string()),
|
|
21
|
+
hook: v.optional(v.string()),
|
|
22
|
+
}),
|
|
23
|
+
task_skipped: v.looseObject({ reason: v.string() }),
|
|
24
|
+
task_retried: v.looseObject({ previous_status: v.string() }),
|
|
25
|
+
task_waiting_input: v.looseObject({
|
|
26
|
+
task_id: v.optional(v.string()),
|
|
27
|
+
reason: v.optional(v.string()),
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
review_started: v.looseObject({
|
|
31
|
+
level: v.string(),
|
|
32
|
+
task_id: v.optional(v.string()),
|
|
33
|
+
model: v.optional(v.string()),
|
|
34
|
+
}),
|
|
35
|
+
review_verdict: v.looseObject({
|
|
36
|
+
level: v.string(),
|
|
37
|
+
verdict: v.string(),
|
|
38
|
+
tokens: v.number(),
|
|
39
|
+
model: v.optional(v.string()),
|
|
40
|
+
feedback_length: v.optional(v.number()),
|
|
41
|
+
budget_exceeded: v.optional(v.boolean()),
|
|
42
|
+
budget_downgrade: v.optional(v.boolean()),
|
|
43
|
+
budget_skip: v.optional(v.boolean()),
|
|
44
|
+
passes: v.optional(v.number()),
|
|
45
|
+
blocks: v.optional(v.number()),
|
|
46
|
+
}),
|
|
47
|
+
dispute_opened: v.looseObject({
|
|
48
|
+
dispute_id: v.string(),
|
|
49
|
+
task_id: v.string(),
|
|
50
|
+
agent: v.optional(v.string()),
|
|
51
|
+
reason: v.optional(v.string()),
|
|
52
|
+
}),
|
|
53
|
+
dlq_entry_created: v.looseObject({
|
|
54
|
+
dlq_id: v.string(),
|
|
55
|
+
task_id: v.string(),
|
|
56
|
+
agent: v.optional(v.string()),
|
|
57
|
+
attempts: v.optional(v.number()),
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
drift_check_result: v.looseObject({
|
|
61
|
+
score: v.optional(v.number()),
|
|
62
|
+
threshold: v.optional(v.number()),
|
|
63
|
+
passed: v.optional(v.boolean()),
|
|
64
|
+
}),
|
|
65
|
+
drift_detected: v.looseObject({
|
|
66
|
+
score: v.optional(v.number()),
|
|
67
|
+
files: v.optional(v.array(v.string())),
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
circuit_breaker_tripped: v.looseObject({
|
|
71
|
+
agent: v.optional(v.string()),
|
|
72
|
+
failure_count: v.optional(v.number()),
|
|
73
|
+
threshold: v.optional(v.number()),
|
|
74
|
+
}),
|
|
75
|
+
circuit_breaker_fallback: v.looseObject({
|
|
76
|
+
original_agent: v.optional(v.string()),
|
|
77
|
+
fallback_agent: v.optional(v.string()),
|
|
78
|
+
task_id: v.optional(v.string()),
|
|
79
|
+
}),
|
|
80
|
+
circuit_breaker_blocked: v.looseObject({
|
|
81
|
+
agent: v.optional(v.string()),
|
|
82
|
+
task_id: v.optional(v.string()),
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
merge_conflict_detected: v.looseObject({
|
|
86
|
+
task_id: v.optional(v.string()),
|
|
87
|
+
files: v.optional(v.array(v.string())),
|
|
88
|
+
}),
|
|
89
|
+
merge_conflict_failed: v.looseObject({
|
|
90
|
+
task_id: v.optional(v.string()),
|
|
91
|
+
error: v.optional(v.string()),
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
file_injection_received: v.looseObject({
|
|
95
|
+
task_id: v.optional(v.string()),
|
|
96
|
+
from_task: v.optional(v.string()),
|
|
97
|
+
name: v.optional(v.string()),
|
|
98
|
+
}),
|
|
99
|
+
artifact_limit_reached: v.looseObject({
|
|
100
|
+
task_id: v.optional(v.string()),
|
|
101
|
+
limit: v.optional(v.number()),
|
|
102
|
+
current: v.optional(v.number()),
|
|
103
|
+
}),
|
|
104
|
+
|
|
105
|
+
agent_identity_captured: v.looseObject({
|
|
106
|
+
agent: v.optional(v.string()),
|
|
107
|
+
task_id: v.optional(v.string()),
|
|
108
|
+
}),
|
|
109
|
+
agent_identity_rejected: v.looseObject({
|
|
110
|
+
agent: v.optional(v.string()),
|
|
111
|
+
task_id: v.optional(v.string()),
|
|
112
|
+
reason: v.optional(v.string()),
|
|
113
|
+
}),
|
|
114
|
+
|
|
115
|
+
weak_area_skipped: v.looseObject({
|
|
116
|
+
agent: v.optional(v.string()),
|
|
117
|
+
weak_areas: v.optional(v.array(v.string())),
|
|
118
|
+
task_files: v.optional(v.array(v.string())),
|
|
119
|
+
}),
|
|
120
|
+
swarm_concurrency_update: v.looseObject({
|
|
121
|
+
new_concurrency: v.optional(v.number()),
|
|
122
|
+
reason: v.optional(v.string()),
|
|
123
|
+
}),
|
|
124
|
+
post_convoy_hook_failed: v.looseObject({
|
|
125
|
+
hook: v.optional(v.string()),
|
|
126
|
+
error: v.optional(v.string()),
|
|
127
|
+
}),
|
|
128
|
+
session: v.looseObject({
|
|
129
|
+
agent: v.optional(v.string()),
|
|
130
|
+
model: v.optional(v.string()),
|
|
131
|
+
task: v.optional(v.string()),
|
|
132
|
+
outcome: v.optional(v.string()),
|
|
133
|
+
duration_min: v.optional(v.number()),
|
|
134
|
+
}),
|
|
135
|
+
delegation: v.looseObject({
|
|
136
|
+
agent: v.optional(v.string()),
|
|
137
|
+
model: v.optional(v.string()),
|
|
138
|
+
tier: v.optional(v.string()),
|
|
139
|
+
mechanism: v.optional(v.string()),
|
|
140
|
+
outcome: v.optional(v.string()),
|
|
141
|
+
}),
|
|
142
|
+
secret_leak_prevented: v.looseObject({
|
|
143
|
+
original_type: v.optional(v.string()),
|
|
144
|
+
patterns: v.optional(v.array(v.string())),
|
|
145
|
+
task_id: v.optional(v.string()),
|
|
146
|
+
findings_count: v.optional(v.number()),
|
|
147
|
+
context: v.optional(v.string()),
|
|
148
|
+
}),
|
|
149
|
+
ndjson_write_failed: v.looseObject({ original_type: v.optional(v.string()) }),
|
|
150
|
+
built_in_gate_result: v.looseObject({
|
|
151
|
+
gate: v.string(),
|
|
152
|
+
passed: v.boolean(),
|
|
153
|
+
output: v.optional(v.string()),
|
|
154
|
+
level: v.optional(v.string()),
|
|
155
|
+
}),
|
|
156
|
+
watch_started: v.looseObject({
|
|
157
|
+
trigger_type: v.optional(v.string()),
|
|
158
|
+
pid: v.optional(v.number()),
|
|
159
|
+
}),
|
|
160
|
+
watch_cycle_start: v.looseObject({
|
|
161
|
+
cycle_number: v.optional(v.number()),
|
|
162
|
+
triggered_by: v.optional(v.string()),
|
|
163
|
+
}),
|
|
164
|
+
watch_cycle_end: v.looseObject({
|
|
165
|
+
cycle_number: v.optional(v.number()),
|
|
166
|
+
status: v.optional(v.string()),
|
|
167
|
+
}),
|
|
168
|
+
watch_stopped: v.looseObject({ reason: v.optional(v.string()) }),
|
|
169
|
+
worker_killed: v.looseObject({
|
|
170
|
+
reason: v.optional(v.string()),
|
|
171
|
+
worker_id: v.optional(v.string()),
|
|
172
|
+
task_id: v.optional(v.string()),
|
|
173
|
+
}),
|
|
174
|
+
discovered_issue: v.looseObject({
|
|
175
|
+
task_id: v.optional(v.string()),
|
|
176
|
+
title: v.optional(v.string()),
|
|
177
|
+
file: v.optional(v.string()),
|
|
178
|
+
description: v.optional(v.string()),
|
|
179
|
+
severity: v.optional(v.string()),
|
|
180
|
+
}),
|
|
181
|
+
}
|
|
182
|
+
export function validateEventData(
|
|
183
|
+
type: string,
|
|
184
|
+
data: unknown,
|
|
185
|
+
): { valid: boolean; issues?: string[] } {
|
|
186
|
+
const schema = EVENT_DATA_SCHEMAS[type]
|
|
187
|
+
if (schema === undefined) return { valid: true }
|
|
188
|
+
if (data === undefined || data === null) return { valid: true }
|
|
189
|
+
const result = v.safeParse(schema, data)
|
|
190
|
+
if (result.success) return { valid: true }
|
|
191
|
+
return {
|
|
192
|
+
valid: false,
|
|
193
|
+
issues: result.issues.map((i) => i.message),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -1,25 +1,21 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
1
|
+
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import {
|
|
4
|
+
import { realpathSync } from 'node:fs'
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
5
6
|
import { createConvoyStore } from './store.js'
|
|
6
|
-
import { createEventEmitter } from './events.js'
|
|
7
|
+
import { createEventEmitter, ndjsonPathForConvoy, recoverNdjson, validateEventType } from './events.js'
|
|
8
|
+
import { KNOWN_EVENT_TYPES } from './types.js'
|
|
7
9
|
import type { ConvoyStore } from './store.js'
|
|
8
10
|
|
|
9
|
-
vi.mock('../log.js', () => ({
|
|
10
|
-
appendEvent: vi.fn().mockResolvedValue(undefined),
|
|
11
|
-
}))
|
|
12
|
-
|
|
13
|
-
import { appendEvent } from '../log.js'
|
|
14
|
-
const mockAppend = vi.mocked(appendEvent)
|
|
15
|
-
|
|
16
11
|
let tmpDir: string
|
|
17
12
|
let store: ConvoyStore
|
|
13
|
+
let ndjsonPath: string
|
|
18
14
|
|
|
19
15
|
beforeEach(() => {
|
|
20
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'emitter-test-'))
|
|
16
|
+
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'emitter-test-')))
|
|
21
17
|
store = createConvoyStore(join(tmpDir, 'test.db'))
|
|
22
|
-
|
|
18
|
+
ndjsonPath = join(tmpDir, 'events.ndjson')
|
|
23
19
|
|
|
24
20
|
store.insertConvoy({
|
|
25
21
|
id: 'c1',
|
|
@@ -38,9 +34,17 @@ afterEach(() => {
|
|
|
38
34
|
})
|
|
39
35
|
|
|
40
36
|
describe('createEventEmitter', () => {
|
|
37
|
+
it('throws TypeError when options is a string', () => {
|
|
38
|
+
expect(() => createEventEmitter(store, 'bad' as unknown as any)).toThrow(TypeError)
|
|
39
|
+
expect(() => createEventEmitter(store, 'bad' as unknown as any)).toThrow(
|
|
40
|
+
'createEventEmitter options must be an object, not a string',
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
41
44
|
it('inserts the event into SQLite', () => {
|
|
42
45
|
const emitter = createEventEmitter(store)
|
|
43
46
|
emitter.emit('task_started', { msg: 'started' }, { convoy_id: 'c1' })
|
|
47
|
+
emitter.close()
|
|
44
48
|
const events = store.getEvents('c1')
|
|
45
49
|
expect(events).toHaveLength(1)
|
|
46
50
|
expect(events[0].type).toBe('task_started')
|
|
@@ -50,6 +54,7 @@ describe('createEventEmitter', () => {
|
|
|
50
54
|
it('serializes event data to JSON in SQLite', () => {
|
|
51
55
|
const emitter = createEventEmitter(store)
|
|
52
56
|
emitter.emit('task_done', { exitCode: 0, output: 'ok' }, { convoy_id: 'c1' })
|
|
57
|
+
emitter.close()
|
|
53
58
|
const events = store.getEvents('c1')
|
|
54
59
|
const parsed = JSON.parse(events[0].data!)
|
|
55
60
|
expect(parsed.exitCode).toBe(0)
|
|
@@ -59,60 +64,400 @@ describe('createEventEmitter', () => {
|
|
|
59
64
|
it('stores null data when no data object is provided', () => {
|
|
60
65
|
const emitter = createEventEmitter(store)
|
|
61
66
|
emitter.emit('heartbeat', undefined, { convoy_id: 'c1' })
|
|
67
|
+
emitter.close()
|
|
62
68
|
const events = store.getEvents('c1')
|
|
63
69
|
expect(events[0].data).toBeNull()
|
|
64
70
|
})
|
|
65
71
|
|
|
66
|
-
it('
|
|
67
|
-
const emitter = createEventEmitter(store)
|
|
72
|
+
it('writes NDJSON when ndjsonPath is provided', () => {
|
|
73
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
68
74
|
emitter.emit('convoy_started', { name: 'test' }, { convoy_id: 'c1' })
|
|
69
|
-
|
|
75
|
+
emitter.close()
|
|
76
|
+
expect(existsSync(ndjsonPath)).toBe(true)
|
|
77
|
+
const content = readFileSync(ndjsonPath, 'utf8')
|
|
78
|
+
expect(content.trim()).not.toBe('')
|
|
79
|
+
const line = JSON.parse(content.trim())
|
|
80
|
+
expect(line.type).toBe('convoy_started')
|
|
81
|
+
expect(line.convoy_id).toBe('c1')
|
|
70
82
|
})
|
|
71
83
|
|
|
72
|
-
it('
|
|
73
|
-
const emitter = createEventEmitter(store,
|
|
84
|
+
it('writes _event_id to NDJSON matching SQLite rowid', () => {
|
|
85
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
74
86
|
emitter.emit('convoy_started', {}, { convoy_id: 'c1' })
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
87
|
+
emitter.close()
|
|
88
|
+
const sqliteEvents = store.getEvents('c1')
|
|
89
|
+
const ndjsonLine = JSON.parse(readFileSync(ndjsonPath, 'utf8').trim())
|
|
90
|
+
expect(ndjsonLine._event_id).toBe(sqliteEvents[0].id)
|
|
79
91
|
})
|
|
80
92
|
|
|
81
93
|
it('defaults all ids to null when ids are not provided', () => {
|
|
82
|
-
const emitter = createEventEmitter(store)
|
|
94
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
83
95
|
emitter.emit('generic_event')
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
expect(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
96
|
+
emitter.close()
|
|
97
|
+
const events = store.getEvents('c1')
|
|
98
|
+
expect(events).toHaveLength(0)
|
|
99
|
+
// No convoy_id so not retrievable via getEvents('c1'), but event was inserted
|
|
100
|
+
const content = readFileSync(ndjsonPath, 'utf8')
|
|
101
|
+
const line = JSON.parse(content.trim())
|
|
102
|
+
expect(line.convoy_id).toBeNull()
|
|
103
|
+
expect(line.task_id).toBeNull()
|
|
104
|
+
expect(line.worker_id).toBeNull()
|
|
94
105
|
})
|
|
95
106
|
|
|
96
107
|
it('includes all provided ids in the NDJSON record', () => {
|
|
97
|
-
const emitter = createEventEmitter(store,
|
|
108
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
98
109
|
emitter.emit('worker_spawned', {}, { convoy_id: 'c1', task_id: 't1', worker_id: 'w1' })
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
110
|
+
emitter.close()
|
|
111
|
+
const line = JSON.parse(readFileSync(ndjsonPath, 'utf8').trim())
|
|
112
|
+
expect(line.convoy_id).toBe('c1')
|
|
113
|
+
expect(line.task_id).toBe('t1')
|
|
114
|
+
expect(line.worker_id).toBe('w1')
|
|
103
115
|
})
|
|
104
116
|
|
|
105
117
|
it('SQLite event stores correct ids', () => {
|
|
106
118
|
const emitter = createEventEmitter(store)
|
|
107
119
|
emitter.emit('worker_done', {}, { convoy_id: 'c1', task_id: 'task-x', worker_id: 'wkr-y' })
|
|
120
|
+
emitter.close()
|
|
108
121
|
const events = store.getEvents('c1')
|
|
109
122
|
expect(events[0].task_id).toBe('task-x')
|
|
110
123
|
expect(events[0].worker_id).toBe('wkr-y')
|
|
111
124
|
})
|
|
112
125
|
|
|
113
|
-
it('does not throw if NDJSON
|
|
114
|
-
mockAppend.mockRejectedValueOnce(new Error('disk full'))
|
|
126
|
+
it('does not throw if NDJSON path is not provided', () => {
|
|
115
127
|
const emitter = createEventEmitter(store)
|
|
116
128
|
expect(() => emitter.emit('test', {}, { convoy_id: 'c1' })).not.toThrow()
|
|
129
|
+
emitter.close()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('close() is idempotent', () => {
|
|
133
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
134
|
+
emitter.close()
|
|
135
|
+
expect(() => emitter.close()).not.toThrow()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('sanitizes reserved keys from caller data before NDJSON write', () => {
|
|
139
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
140
|
+
emitter.emit(
|
|
141
|
+
'task_started',
|
|
142
|
+
{
|
|
143
|
+
convoy_id: 'attacker',
|
|
144
|
+
timestamp: 'fake',
|
|
145
|
+
_event_id: 999,
|
|
146
|
+
type: 'evil',
|
|
147
|
+
task_id: 'injected',
|
|
148
|
+
worker_id: 'hacker',
|
|
149
|
+
custom_field: 'ok',
|
|
150
|
+
},
|
|
151
|
+
{ convoy_id: 'c1', task_id: 't1', worker_id: 'w1' },
|
|
152
|
+
)
|
|
153
|
+
emitter.close()
|
|
154
|
+
const content = readFileSync(ndjsonPath, 'utf8')
|
|
155
|
+
const line = JSON.parse(content.trim())
|
|
156
|
+
expect(line.convoy_id).toBe('c1')
|
|
157
|
+
expect(line.task_id).toBe('t1')
|
|
158
|
+
expect(line.worker_id).toBe('w1')
|
|
159
|
+
expect(line.type).toBe('task_started')
|
|
160
|
+
expect(line._event_id).not.toBe(999)
|
|
161
|
+
expect(line.timestamp).not.toBe('fake')
|
|
162
|
+
expect(line.custom_field).toBe('ok')
|
|
117
163
|
})
|
|
118
164
|
})
|
|
165
|
+
|
|
166
|
+
describe('crash resilience', () => {
|
|
167
|
+
it('1. mid-write crash: SQLite has events, recovery writes NDJSON', () => {
|
|
168
|
+
// Emit events using emitter WITHOUT ndjsonPath — simulates crash after SQLite commit
|
|
169
|
+
const emitter = createEventEmitter(store)
|
|
170
|
+
emitter.emit('task_started', { step: 1 }, { convoy_id: 'c1', task_id: 't1' })
|
|
171
|
+
emitter.emit('task_done', { step: 2 }, { convoy_id: 'c1', task_id: 't1' })
|
|
172
|
+
emitter.close()
|
|
173
|
+
|
|
174
|
+
// SQLite has both events
|
|
175
|
+
const sqliteEvents = store.getEvents('c1')
|
|
176
|
+
expect(sqliteEvents).toHaveLength(2)
|
|
177
|
+
|
|
178
|
+
// NDJSON file does not exist
|
|
179
|
+
expect(existsSync(ndjsonPath)).toBe(false)
|
|
180
|
+
|
|
181
|
+
// Recovery writes the missing events to NDJSON
|
|
182
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
183
|
+
|
|
184
|
+
expect(existsSync(ndjsonPath)).toBe(true)
|
|
185
|
+
const lines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
186
|
+
expect(lines).toHaveLength(2)
|
|
187
|
+
const types = lines.map(l => JSON.parse(l).type)
|
|
188
|
+
expect(types).toContain('task_started')
|
|
189
|
+
expect(types).toContain('task_done')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('2. recovery consistency: missing events replayed after partial crash', () => {
|
|
193
|
+
// Write some events to both SQLite + NDJSON via emitter
|
|
194
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
195
|
+
emitter.emit('convoy_started', {}, { convoy_id: 'c1' })
|
|
196
|
+
emitter.close()
|
|
197
|
+
|
|
198
|
+
// Simulate crash: two more events go only to SQLite (bypass emitter)
|
|
199
|
+
store.insertEvent({
|
|
200
|
+
convoy_id: 'c1', task_id: 't1', worker_id: null,
|
|
201
|
+
type: 'task_started', data: null, created_at: new Date().toISOString(),
|
|
202
|
+
})
|
|
203
|
+
store.insertEvent({
|
|
204
|
+
convoy_id: 'c1', task_id: 't1', worker_id: null,
|
|
205
|
+
type: 'task_done', data: null, created_at: new Date().toISOString(),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Before recovery: NDJSON has 1 line, SQLite has 3
|
|
209
|
+
const beforeLines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
210
|
+
expect(beforeLines).toHaveLength(1)
|
|
211
|
+
expect(store.getEvents('c1')).toHaveLength(3)
|
|
212
|
+
|
|
213
|
+
// Recovery replays the 2 missing events
|
|
214
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
215
|
+
|
|
216
|
+
const afterLines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
217
|
+
expect(afterLines).toHaveLength(3)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('3. no duplication: idempotent recovery when all synced', () => {
|
|
221
|
+
// Write 5 events — all go to both SQLite and NDJSON
|
|
222
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
223
|
+
for (let i = 0; i < 5; i++) {
|
|
224
|
+
emitter.emit('task_done', { i }, { convoy_id: 'c1', task_id: `t${i}` })
|
|
225
|
+
}
|
|
226
|
+
emitter.close()
|
|
227
|
+
|
|
228
|
+
const linesBefore = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
229
|
+
expect(linesBefore).toHaveLength(5)
|
|
230
|
+
|
|
231
|
+
// Run recovery — nothing should be added since all events already in NDJSON
|
|
232
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
233
|
+
|
|
234
|
+
const linesAfter = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
235
|
+
expect(linesAfter).toHaveLength(5)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('4. partial line recovery: incomplete write truncated and replayed', () => {
|
|
239
|
+
// Write one complete event then append a partial JSON line (no \\n terminator)
|
|
240
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
241
|
+
emitter.emit('convoy_started', {}, { convoy_id: 'c1' })
|
|
242
|
+
emitter.close()
|
|
243
|
+
|
|
244
|
+
// Append a partial line directly (simulating a crash mid-write)
|
|
245
|
+
const partialLine = '{"_event_id":999,"type":"partial_crash","convoy_id":"c1"' // no closing } or \n
|
|
246
|
+
const existingContent = readFileSync(ndjsonPath, 'utf8')
|
|
247
|
+
writeFileSync(ndjsonPath, existingContent + partialLine)
|
|
248
|
+
|
|
249
|
+
// Recovery should truncate the partial line and replay anything missing
|
|
250
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
251
|
+
|
|
252
|
+
const recovered = readFileSync(ndjsonPath, 'utf8')
|
|
253
|
+
// Every line should be valid JSON
|
|
254
|
+
const lines = recovered.split('\n').filter(l => l.trim())
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
expect(() => JSON.parse(line)).not.toThrow()
|
|
257
|
+
}
|
|
258
|
+
// The original complete event should be present
|
|
259
|
+
const types = lines.map(l => JSON.parse(l).type)
|
|
260
|
+
expect(types).toContain('convoy_started')
|
|
261
|
+
// The partial line should not appear
|
|
262
|
+
expect(types).not.toContain('partial_crash')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('5. large file: 1000 events all readable after emit and recovery', () => {
|
|
266
|
+
const count = 1000
|
|
267
|
+
const emitter = createEventEmitter(store, { ndjsonPath })
|
|
268
|
+
for (let i = 0; i < count; i++) {
|
|
269
|
+
emitter.emit('bench_event', { index: i }, { convoy_id: 'c1', task_id: `t${i}` })
|
|
270
|
+
}
|
|
271
|
+
emitter.close()
|
|
272
|
+
|
|
273
|
+
// All events in SQLite
|
|
274
|
+
expect(store.getEvents('c1')).toHaveLength(count)
|
|
275
|
+
|
|
276
|
+
// All events in NDJSON
|
|
277
|
+
const lines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
278
|
+
expect(lines).toHaveLength(count)
|
|
279
|
+
|
|
280
|
+
// Each line is valid JSON with the right type
|
|
281
|
+
for (const line of lines) {
|
|
282
|
+
const parsed = JSON.parse(line)
|
|
283
|
+
expect(parsed.type).toBe('bench_event')
|
|
284
|
+
expect(parsed.convoy_id).toBe('c1')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Recovery is a no-op (everything is synced)
|
|
288
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
289
|
+
const linesAfter = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
290
|
+
expect(linesAfter).toHaveLength(count)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('6. canonical-field protection: event.data cannot override DB row fields', () => {
|
|
294
|
+
// Insert an event whose data contains attacker-controlled reserved keys
|
|
295
|
+
store.insertEvent({
|
|
296
|
+
convoy_id: 'c1',
|
|
297
|
+
task_id: 'legit-task',
|
|
298
|
+
worker_id: 'legit-worker',
|
|
299
|
+
type: 'task_done',
|
|
300
|
+
data: JSON.stringify({
|
|
301
|
+
convoy_id: 'attacker',
|
|
302
|
+
timestamp: 'fake',
|
|
303
|
+
_event_id: 9999,
|
|
304
|
+
type: 'evil',
|
|
305
|
+
task_id: 'injected',
|
|
306
|
+
worker_id: 'hacker',
|
|
307
|
+
safe_field: 'this-is-fine',
|
|
308
|
+
}),
|
|
309
|
+
created_at: new Date().toISOString(),
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
recoverNdjson(store, 'c1', ndjsonPath)
|
|
313
|
+
|
|
314
|
+
const lines = readFileSync(ndjsonPath, 'utf8').split('\n').filter(l => l.trim())
|
|
315
|
+
expect(lines).toHaveLength(1)
|
|
316
|
+
const record = JSON.parse(lines[0]) as Record<string, unknown>
|
|
317
|
+
|
|
318
|
+
// Canonical fields must come from the DB row, not from data
|
|
319
|
+
expect(record.convoy_id).toBe('c1')
|
|
320
|
+
expect(record.task_id).toBe('legit-task')
|
|
321
|
+
expect(record.worker_id).toBe('legit-worker')
|
|
322
|
+
expect(record.type).toBe('task_done')
|
|
323
|
+
expect(record._event_id).not.toBe(9999)
|
|
324
|
+
expect(record.timestamp).not.toBe('fake')
|
|
325
|
+
|
|
326
|
+
// Attacker values must not appear
|
|
327
|
+
expect(record.convoy_id).not.toBe('attacker')
|
|
328
|
+
expect(record.type).not.toBe('evil')
|
|
329
|
+
expect(record.task_id).not.toBe('injected')
|
|
330
|
+
expect(record.worker_id).not.toBe('hacker')
|
|
331
|
+
|
|
332
|
+
// Safe non-reserved fields are preserved
|
|
333
|
+
expect(record.safe_field).toBe('this-is-fine')
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe('KNOWN_EVENT_TYPES', () => {
|
|
338
|
+
it('contains all canonical event types', () => {
|
|
339
|
+
const canonical = [
|
|
340
|
+
'convoy_started', 'convoy_finished', 'convoy_failed', 'convoy_guard',
|
|
341
|
+
'task_started', 'task_done', 'task_failed', 'task_skipped', 'task_retried', 'task_waiting_input',
|
|
342
|
+
'review_started', 'review_verdict', 'dispute_opened', 'dlq_entry_created',
|
|
343
|
+
'drift_check_result', 'drift_detected',
|
|
344
|
+
'circuit_breaker_tripped', 'circuit_breaker_fallback', 'circuit_breaker_blocked',
|
|
345
|
+
'merge_conflict_detected', 'merge_conflict_failed',
|
|
346
|
+
'file_injection_received', 'artifact_limit_reached',
|
|
347
|
+
'agent_identity_captured', 'agent_identity_rejected',
|
|
348
|
+
'weak_area_skipped', 'swarm_concurrency_update', 'post_convoy_hook_failed',
|
|
349
|
+
'session', 'delegation',
|
|
350
|
+
'secret_leak_prevented', 'ndjson_write_failed', 'built_in_gate_result',
|
|
351
|
+
'watch_started', 'watch_cycle_start', 'watch_cycle_end', 'watch_stopped',
|
|
352
|
+
'worker_killed', 'discovered_issue',
|
|
353
|
+
]
|
|
354
|
+
for (const type of canonical) {
|
|
355
|
+
expect(KNOWN_EVENT_TYPES.has(type)).toBe(true)
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('has no duplicates (Set size matches array)', () => {
|
|
360
|
+
expect(KNOWN_EVENT_TYPES.size).toBeGreaterThanOrEqual(37)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe('ndjsonPathForConvoy', () => {
|
|
365
|
+
it('returns correct per-convoy path with default basePath', () => {
|
|
366
|
+
const result = ndjsonPathForConvoy('abc-123')
|
|
367
|
+
expect(result).toBe(join(process.cwd(), '.opencastle', 'logs', 'convoys', 'abc-123.ndjson'))
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('returns correct per-convoy path with custom basePath', () => {
|
|
371
|
+
const result = ndjsonPathForConvoy('xyz-456', '/custom/base')
|
|
372
|
+
expect(result).toBe(join('/custom/base', '.opencastle', 'logs', 'convoys', 'xyz-456.ndjson'))
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
describe('createEventEmitter directory creation', () => {
|
|
377
|
+
it('creates parent directory when ndjsonPath is in a non-existent directory', () => {
|
|
378
|
+
const nestedPath = join(tmpDir, 'nested', 'dir', 'events.ndjson')
|
|
379
|
+
const emitter = createEventEmitter(store, { ndjsonPath: nestedPath })
|
|
380
|
+
emitter.emit('convoy_started', { name: 'test' }, { convoy_id: 'c1' })
|
|
381
|
+
emitter.close()
|
|
382
|
+
expect(existsSync(nestedPath)).toBe(true)
|
|
383
|
+
const content = readFileSync(nestedPath, 'utf8')
|
|
384
|
+
const line = JSON.parse(content.trim())
|
|
385
|
+
expect(line.type).toBe('convoy_started')
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
describe('validateEventType', () => {
|
|
390
|
+
it('returns true for known event types', () => {
|
|
391
|
+
expect(validateEventType('convoy_started')).toBe(true)
|
|
392
|
+
expect(validateEventType('task_done')).toBe(true)
|
|
393
|
+
expect(validateEventType('watch_stopped')).toBe(true)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('returns false for unknown event types', () => {
|
|
397
|
+
expect(validateEventType('unknown_event')).toBe(false)
|
|
398
|
+
expect(validateEventType('')).toBe(false)
|
|
399
|
+
expect(validateEventType('convoy_start')).toBe(false)
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
describe('emit-time data validation', () => {
|
|
404
|
+
let warnSpy: ReturnType<typeof vi.spyOn>
|
|
405
|
+
|
|
406
|
+
beforeEach(() => {
|
|
407
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
afterEach(() => {
|
|
411
|
+
warnSpy.mockRestore()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('valid data passes without warning', () => {
|
|
415
|
+
const emitter = createEventEmitter(store)
|
|
416
|
+
emitter.emit('convoy_finished', { status: 'done' }, { convoy_id: 'c1' })
|
|
417
|
+
emitter.close()
|
|
418
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('invalid data shape warns with correct message', () => {
|
|
422
|
+
const emitter = createEventEmitter(store)
|
|
423
|
+
emitter.emit('convoy_finished', { status: 123 } as unknown as Record<string, unknown>, { convoy_id: 'c1' })
|
|
424
|
+
emitter.close()
|
|
425
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
426
|
+
expect.stringContaining('Invalid data for event type "convoy_finished"'),
|
|
427
|
+
)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('missing required field warns', () => {
|
|
431
|
+
const emitter = createEventEmitter(store)
|
|
432
|
+
// task_failed requires reason: string — passing {} should fail
|
|
433
|
+
emitter.emit('task_failed', {} as Record<string, unknown>, { convoy_id: 'c1' })
|
|
434
|
+
emitter.close()
|
|
435
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
436
|
+
expect.stringContaining('Invalid data for event type "task_failed"'),
|
|
437
|
+
)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('extra fields are allowed (no warning)', () => {
|
|
441
|
+
const emitter = createEventEmitter(store)
|
|
442
|
+
emitter.emit('convoy_started', { name: 'test', extra: true } as Record<string, unknown>, { convoy_id: 'c1' })
|
|
443
|
+
emitter.close()
|
|
444
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('undefined data is valid', () => {
|
|
448
|
+
const emitter = createEventEmitter(store)
|
|
449
|
+
emitter.emit('convoy_started', undefined, { convoy_id: 'c1' })
|
|
450
|
+
emitter.close()
|
|
451
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('unknown event type bypasses data validation (only one warning)', () => {
|
|
455
|
+
const emitter = createEventEmitter(store)
|
|
456
|
+
emitter.emit('unknown_type_xyz', { any: 'data' }, { convoy_id: 'c1' })
|
|
457
|
+
emitter.close()
|
|
458
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
459
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown event type: "unknown_type_xyz"'))
|
|
460
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Invalid data'))
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|