opencastle 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/bin/cli.mjs +6 -0
  2. package/dist/cli/agents.d.ts +3 -0
  3. package/dist/cli/agents.d.ts.map +1 -0
  4. package/dist/cli/agents.js +161 -0
  5. package/dist/cli/agents.js.map +1 -0
  6. package/dist/cli/baselines.d.ts +3 -0
  7. package/dist/cli/baselines.d.ts.map +1 -0
  8. package/dist/cli/baselines.js +128 -0
  9. package/dist/cli/baselines.js.map +1 -0
  10. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  11. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  12. package/dist/cli/convoy/dashboard-types.js +2 -0
  13. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  14. package/dist/cli/convoy/engine.d.ts +67 -2
  15. package/dist/cli/convoy/engine.d.ts.map +1 -1
  16. package/dist/cli/convoy/engine.js +2036 -28
  17. package/dist/cli/convoy/engine.js.map +1 -1
  18. package/dist/cli/convoy/engine.test.js +1659 -70
  19. package/dist/cli/convoy/engine.test.js.map +1 -1
  20. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  21. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  22. package/dist/cli/convoy/event-schemas.js +185 -0
  23. package/dist/cli/convoy/event-schemas.js.map +1 -0
  24. package/dist/cli/convoy/events.d.ts +12 -1
  25. package/dist/cli/convoy/events.d.ts.map +1 -1
  26. package/dist/cli/convoy/events.js +186 -13
  27. package/dist/cli/convoy/events.js.map +1 -1
  28. package/dist/cli/convoy/events.test.js +325 -28
  29. package/dist/cli/convoy/events.test.js.map +1 -1
  30. package/dist/cli/convoy/expertise.d.ts +16 -0
  31. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  32. package/dist/cli/convoy/expertise.js +121 -0
  33. package/dist/cli/convoy/expertise.js.map +1 -0
  34. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  35. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  36. package/dist/cli/convoy/expertise.test.js +96 -0
  37. package/dist/cli/convoy/expertise.test.js.map +1 -0
  38. package/dist/cli/convoy/export.test.js +1 -0
  39. package/dist/cli/convoy/export.test.js.map +1 -1
  40. package/dist/cli/convoy/formula.d.ts +19 -0
  41. package/dist/cli/convoy/formula.d.ts.map +1 -0
  42. package/dist/cli/convoy/formula.js +142 -0
  43. package/dist/cli/convoy/formula.js.map +1 -0
  44. package/dist/cli/convoy/formula.test.d.ts +2 -0
  45. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  46. package/dist/cli/convoy/formula.test.js +342 -0
  47. package/dist/cli/convoy/formula.test.js.map +1 -0
  48. package/dist/cli/convoy/gates.d.ts +128 -0
  49. package/dist/cli/convoy/gates.d.ts.map +1 -0
  50. package/dist/cli/convoy/gates.js +606 -0
  51. package/dist/cli/convoy/gates.js.map +1 -0
  52. package/dist/cli/convoy/gates.test.d.ts +2 -0
  53. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  54. package/dist/cli/convoy/gates.test.js +976 -0
  55. package/dist/cli/convoy/gates.test.js.map +1 -0
  56. package/dist/cli/convoy/health.d.ts +11 -0
  57. package/dist/cli/convoy/health.d.ts.map +1 -1
  58. package/dist/cli/convoy/health.js +54 -0
  59. package/dist/cli/convoy/health.js.map +1 -1
  60. package/dist/cli/convoy/health.test.js +56 -1
  61. package/dist/cli/convoy/health.test.js.map +1 -1
  62. package/dist/cli/convoy/issues.d.ts +8 -0
  63. package/dist/cli/convoy/issues.d.ts.map +1 -0
  64. package/dist/cli/convoy/issues.js +98 -0
  65. package/dist/cli/convoy/issues.js.map +1 -0
  66. package/dist/cli/convoy/issues.test.d.ts +2 -0
  67. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  68. package/dist/cli/convoy/issues.test.js +107 -0
  69. package/dist/cli/convoy/issues.test.js.map +1 -0
  70. package/dist/cli/convoy/knowledge.d.ts +5 -0
  71. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  72. package/dist/cli/convoy/knowledge.js +116 -0
  73. package/dist/cli/convoy/knowledge.js.map +1 -0
  74. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  75. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  76. package/dist/cli/convoy/knowledge.test.js +87 -0
  77. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  78. package/dist/cli/convoy/lessons.d.ts +17 -0
  79. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  80. package/dist/cli/convoy/lessons.js +149 -0
  81. package/dist/cli/convoy/lessons.js.map +1 -0
  82. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  83. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  84. package/dist/cli/convoy/lessons.test.js +135 -0
  85. package/dist/cli/convoy/lessons.test.js.map +1 -0
  86. package/dist/cli/convoy/lock.d.ts +13 -0
  87. package/dist/cli/convoy/lock.d.ts.map +1 -0
  88. package/dist/cli/convoy/lock.js +88 -0
  89. package/dist/cli/convoy/lock.js.map +1 -0
  90. package/dist/cli/convoy/lock.test.d.ts +2 -0
  91. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  92. package/dist/cli/convoy/lock.test.js +136 -0
  93. package/dist/cli/convoy/lock.test.js.map +1 -0
  94. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  95. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  96. package/dist/cli/convoy/log-merge.test.js +147 -0
  97. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  98. package/dist/cli/convoy/merge.d.ts +4 -0
  99. package/dist/cli/convoy/merge.d.ts.map +1 -1
  100. package/dist/cli/convoy/merge.js +18 -1
  101. package/dist/cli/convoy/merge.js.map +1 -1
  102. package/dist/cli/convoy/merge.test.js +6 -7
  103. package/dist/cli/convoy/merge.test.js.map +1 -1
  104. package/dist/cli/convoy/partition.d.ts +51 -0
  105. package/dist/cli/convoy/partition.d.ts.map +1 -0
  106. package/dist/cli/convoy/partition.js +186 -0
  107. package/dist/cli/convoy/partition.js.map +1 -0
  108. package/dist/cli/convoy/partition.test.d.ts +2 -0
  109. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  110. package/dist/cli/convoy/partition.test.js +315 -0
  111. package/dist/cli/convoy/partition.test.js.map +1 -0
  112. package/dist/cli/convoy/pipeline.test.js +6 -0
  113. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  114. package/dist/cli/convoy/store.d.ts +99 -7
  115. package/dist/cli/convoy/store.d.ts.map +1 -1
  116. package/dist/cli/convoy/store.js +764 -31
  117. package/dist/cli/convoy/store.js.map +1 -1
  118. package/dist/cli/convoy/store.test.js +1810 -18
  119. package/dist/cli/convoy/store.test.js.map +1 -1
  120. package/dist/cli/convoy/types.d.ts +427 -5
  121. package/dist/cli/convoy/types.d.ts.map +1 -1
  122. package/dist/cli/convoy/types.js +42 -1
  123. package/dist/cli/convoy/types.js.map +1 -1
  124. package/dist/cli/log.d.ts +11 -0
  125. package/dist/cli/log.d.ts.map +1 -1
  126. package/dist/cli/log.js +114 -2
  127. package/dist/cli/log.js.map +1 -1
  128. package/dist/cli/run/adapters/claude.d.ts +2 -0
  129. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  130. package/dist/cli/run/adapters/claude.js +89 -49
  131. package/dist/cli/run/adapters/claude.js.map +1 -1
  132. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  133. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  134. package/dist/cli/run/adapters/claude.test.js +205 -0
  135. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  137. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  138. package/dist/cli/run/adapters/copilot.js +84 -46
  139. package/dist/cli/run/adapters/copilot.js.map +1 -1
  140. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  141. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  142. package/dist/cli/run/adapters/copilot.test.js +195 -0
  143. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  145. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  146. package/dist/cli/run/adapters/cursor.js +83 -47
  147. package/dist/cli/run/adapters/cursor.js.map +1 -1
  148. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  149. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  150. package/dist/cli/run/adapters/cursor.test.js +129 -0
  151. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  153. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  154. package/dist/cli/run/adapters/opencode.js +81 -47
  155. package/dist/cli/run/adapters/opencode.js.map +1 -1
  156. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  157. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  158. package/dist/cli/run/adapters/opencode.test.js +119 -0
  159. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  160. package/dist/cli/run/executor.js +1 -1
  161. package/dist/cli/run/executor.js.map +1 -1
  162. package/dist/cli/run/schema.d.ts.map +1 -1
  163. package/dist/cli/run/schema.js +245 -4
  164. package/dist/cli/run/schema.js.map +1 -1
  165. package/dist/cli/run/schema.test.js +669 -0
  166. package/dist/cli/run/schema.test.js.map +1 -1
  167. package/dist/cli/run.d.ts.map +1 -1
  168. package/dist/cli/run.js +362 -22
  169. package/dist/cli/run.js.map +1 -1
  170. package/dist/cli/types.d.ts +85 -2
  171. package/dist/cli/types.d.ts.map +1 -1
  172. package/dist/cli/types.js.map +1 -1
  173. package/dist/cli/watch.d.ts +15 -0
  174. package/dist/cli/watch.d.ts.map +1 -0
  175. package/dist/cli/watch.js +279 -0
  176. package/dist/cli/watch.js.map +1 -0
  177. package/package.json +5 -1
  178. package/src/cli/agents.ts +177 -0
  179. package/src/cli/baselines.ts +143 -0
  180. package/src/cli/convoy/TELEMETRY.md +203 -0
  181. package/src/cli/convoy/dashboard-types.ts +141 -0
  182. package/src/cli/convoy/engine.test.ts +1937 -70
  183. package/src/cli/convoy/engine.ts +2350 -40
  184. package/src/cli/convoy/event-schemas.ts +195 -0
  185. package/src/cli/convoy/events.test.ts +384 -39
  186. package/src/cli/convoy/events.ts +202 -16
  187. package/src/cli/convoy/expertise.test.ts +128 -0
  188. package/src/cli/convoy/expertise.ts +163 -0
  189. package/src/cli/convoy/export.test.ts +1 -0
  190. package/src/cli/convoy/formula.test.ts +405 -0
  191. package/src/cli/convoy/formula.ts +174 -0
  192. package/src/cli/convoy/gates.test.ts +1169 -0
  193. package/src/cli/convoy/gates.ts +774 -0
  194. package/src/cli/convoy/health.test.ts +64 -2
  195. package/src/cli/convoy/health.ts +80 -2
  196. package/src/cli/convoy/issues.test.ts +143 -0
  197. package/src/cli/convoy/issues.ts +136 -0
  198. package/src/cli/convoy/knowledge.test.ts +101 -0
  199. package/src/cli/convoy/knowledge.ts +132 -0
  200. package/src/cli/convoy/lessons.test.ts +188 -0
  201. package/src/cli/convoy/lessons.ts +164 -0
  202. package/src/cli/convoy/lock.test.ts +181 -0
  203. package/src/cli/convoy/lock.ts +103 -0
  204. package/src/cli/convoy/log-merge.test.ts +179 -0
  205. package/src/cli/convoy/merge.test.ts +6 -7
  206. package/src/cli/convoy/merge.ts +19 -1
  207. package/src/cli/convoy/partition.test.ts +423 -0
  208. package/src/cli/convoy/partition.ts +232 -0
  209. package/src/cli/convoy/pipeline.test.ts +6 -0
  210. package/src/cli/convoy/store.test.ts +2041 -20
  211. package/src/cli/convoy/store.ts +945 -46
  212. package/src/cli/convoy/types.ts +278 -4
  213. package/src/cli/log.ts +120 -2
  214. package/src/cli/run/adapters/claude.test.ts +234 -0
  215. package/src/cli/run/adapters/claude.ts +45 -5
  216. package/src/cli/run/adapters/copilot.test.ts +224 -0
  217. package/src/cli/run/adapters/copilot.ts +34 -4
  218. package/src/cli/run/adapters/cursor.test.ts +144 -0
  219. package/src/cli/run/adapters/cursor.ts +33 -2
  220. package/src/cli/run/adapters/opencode.test.ts +135 -0
  221. package/src/cli/run/adapters/opencode.ts +30 -2
  222. package/src/cli/run/executor.ts +1 -1
  223. package/src/cli/run/schema.test.ts +758 -0
  224. package/src/cli/run/schema.ts +300 -25
  225. package/src/cli/run.ts +341 -21
  226. package/src/cli/types.ts +86 -1
  227. package/src/cli/watch.ts +298 -0
  228. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  229. package/src/dashboard/dist/data/.gitkeep +0 -0
  230. package/src/dashboard/dist/data/convoy-list.json +1 -0
  231. package/src/dashboard/dist/data/overall-stats.json +24 -0
  232. package/src/dashboard/dist/index.html +701 -3
  233. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  234. package/src/dashboard/public/data/.gitkeep +0 -0
  235. package/src/dashboard/public/data/convoy-list.json +1 -0
  236. package/src/dashboard/public/data/overall-stats.json +24 -0
  237. package/src/dashboard/scripts/etl.test.ts +210 -0
  238. package/src/dashboard/scripts/etl.ts +108 -0
  239. package/src/dashboard/scripts/integration-test.ts +504 -0
  240. package/src/dashboard/src/pages/index.astro +854 -15
  241. package/src/dashboard/src/styles/dashboard.css +557 -1
  242. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -0,0 +1,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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
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
- vi.clearAllMocks()
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('calls appendEvent for NDJSON dual-write', () => {
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
- expect(mockAppend).toHaveBeenCalledOnce()
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('passes logs dir to appendEvent', () => {
73
- const emitter = createEventEmitter(store, '/some/logs')
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
- expect(mockAppend).toHaveBeenCalledWith(
76
- expect.objectContaining({ type: 'convoy_started', convoy_id: 'c1' }),
77
- '/some/logs',
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
- const db = require('node:sqlite').DatabaseSync
85
- // Verify via NDJSON mock payload
86
- expect(mockAppend).toHaveBeenCalledWith(
87
- expect.objectContaining({
88
- convoy_id: null,
89
- task_id: null,
90
- worker_id: null,
91
- }),
92
- null,
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, tmpDir)
108
+ const emitter = createEventEmitter(store, { ndjsonPath })
98
109
  emitter.emit('worker_spawned', {}, { convoy_id: 'c1', task_id: 't1', worker_id: 'w1' })
99
- expect(mockAppend).toHaveBeenCalledWith(
100
- expect.objectContaining({ convoy_id: 'c1', task_id: 't1', worker_id: 'w1' }),
101
- tmpDir,
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 write fails', () => {
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
+