@timmeck/brain 1.8.1 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/BRAIN_PLAN.md +3324 -3324
  2. package/LICENSE +21 -21
  3. package/dist/cli/commands/dashboard.js +595 -595
  4. package/dist/cli/commands/doctor.js +6 -1
  5. package/dist/cli/commands/doctor.js.map +1 -1
  6. package/dist/dashboard/server.js +25 -25
  7. package/dist/db/migrations/001_core_schema.js +115 -115
  8. package/dist/db/migrations/002_learning_schema.js +33 -33
  9. package/dist/db/migrations/003_code_schema.js +48 -48
  10. package/dist/db/migrations/004_synapses_schema.js +52 -52
  11. package/dist/db/migrations/005_fts_indexes.js +73 -73
  12. package/dist/db/migrations/007_feedback.js +8 -8
  13. package/dist/db/migrations/008_git_integration.js +33 -33
  14. package/dist/db/migrations/009_embeddings.js +3 -3
  15. package/dist/db/repositories/antipattern.repository.js +3 -3
  16. package/dist/db/repositories/code-module.repository.js +32 -32
  17. package/dist/db/repositories/notification.repository.js +3 -3
  18. package/dist/db/repositories/project.repository.js +21 -21
  19. package/dist/db/repositories/rule.repository.js +24 -24
  20. package/dist/db/repositories/solution.repository.js +50 -50
  21. package/dist/db/repositories/synapse.repository.js +18 -18
  22. package/dist/db/repositories/terminal.repository.js +24 -24
  23. package/dist/ipc/server.d.ts +8 -0
  24. package/dist/ipc/server.js +67 -1
  25. package/dist/ipc/server.js.map +1 -1
  26. package/dist/matching/error-matcher.js +5 -5
  27. package/dist/matching/fingerprint.js +6 -1
  28. package/dist/matching/fingerprint.js.map +1 -1
  29. package/dist/services/error.service.js +4 -3
  30. package/dist/services/error.service.js.map +1 -1
  31. package/dist/services/git.service.js +14 -14
  32. package/package.json +49 -49
  33. package/src/api/server.ts +395 -395
  34. package/src/brain.ts +266 -266
  35. package/src/cli/colors.ts +116 -116
  36. package/src/cli/commands/config.ts +169 -169
  37. package/src/cli/commands/dashboard.ts +755 -755
  38. package/src/cli/commands/doctor.ts +124 -118
  39. package/src/cli/commands/explain.ts +83 -83
  40. package/src/cli/commands/export.ts +31 -31
  41. package/src/cli/commands/import.ts +199 -199
  42. package/src/cli/commands/insights.ts +65 -65
  43. package/src/cli/commands/learn.ts +24 -24
  44. package/src/cli/commands/modules.ts +53 -53
  45. package/src/cli/commands/network.ts +67 -67
  46. package/src/cli/commands/projects.ts +42 -42
  47. package/src/cli/commands/query.ts +120 -120
  48. package/src/cli/commands/start.ts +62 -62
  49. package/src/cli/commands/status.ts +75 -75
  50. package/src/cli/commands/stop.ts +34 -34
  51. package/src/cli/ipc-helper.ts +22 -22
  52. package/src/cli/update-check.ts +63 -63
  53. package/src/code/fingerprint.ts +87 -87
  54. package/src/code/parsers/generic.ts +29 -29
  55. package/src/code/parsers/python.ts +54 -54
  56. package/src/code/parsers/typescript.ts +65 -65
  57. package/src/code/registry.ts +60 -60
  58. package/src/dashboard/server.ts +142 -142
  59. package/src/db/connection.ts +22 -22
  60. package/src/db/migrations/001_core_schema.ts +120 -120
  61. package/src/db/migrations/002_learning_schema.ts +38 -38
  62. package/src/db/migrations/003_code_schema.ts +53 -53
  63. package/src/db/migrations/004_synapses_schema.ts +57 -57
  64. package/src/db/migrations/005_fts_indexes.ts +78 -78
  65. package/src/db/migrations/006_synapses_phase3.ts +17 -17
  66. package/src/db/migrations/007_feedback.ts +13 -13
  67. package/src/db/migrations/008_git_integration.ts +38 -38
  68. package/src/db/migrations/009_embeddings.ts +8 -8
  69. package/src/db/repositories/antipattern.repository.ts +66 -66
  70. package/src/db/repositories/code-module.repository.ts +142 -142
  71. package/src/db/repositories/notification.repository.ts +66 -66
  72. package/src/db/repositories/project.repository.ts +93 -93
  73. package/src/db/repositories/rule.repository.ts +108 -108
  74. package/src/db/repositories/solution.repository.ts +154 -154
  75. package/src/db/repositories/synapse.repository.ts +153 -153
  76. package/src/db/repositories/terminal.repository.ts +101 -101
  77. package/src/embeddings/engine.ts +238 -238
  78. package/src/index.ts +63 -63
  79. package/src/ipc/client.ts +118 -118
  80. package/src/ipc/protocol.ts +35 -35
  81. package/src/ipc/router.ts +133 -133
  82. package/src/ipc/server.ts +176 -110
  83. package/src/learning/decay.ts +46 -46
  84. package/src/learning/pattern-extractor.ts +90 -90
  85. package/src/learning/rule-generator.ts +74 -74
  86. package/src/matching/error-matcher.ts +5 -5
  87. package/src/matching/fingerprint.ts +34 -29
  88. package/src/matching/similarity.ts +61 -61
  89. package/src/matching/tfidf.ts +74 -74
  90. package/src/matching/tokenizer.ts +41 -41
  91. package/src/mcp/auto-detect.ts +93 -93
  92. package/src/mcp/http-server.ts +140 -140
  93. package/src/mcp/server.ts +73 -73
  94. package/src/parsing/error-parser.ts +28 -28
  95. package/src/parsing/parsers/compiler.ts +93 -93
  96. package/src/parsing/parsers/generic.ts +28 -28
  97. package/src/parsing/parsers/go.ts +97 -97
  98. package/src/parsing/parsers/node.ts +69 -69
  99. package/src/parsing/parsers/python.ts +62 -62
  100. package/src/parsing/parsers/rust.ts +50 -50
  101. package/src/parsing/parsers/shell.ts +42 -42
  102. package/src/parsing/types.ts +47 -47
  103. package/src/research/gap-analyzer.ts +135 -135
  104. package/src/research/insight-generator.ts +123 -123
  105. package/src/research/research-engine.ts +116 -116
  106. package/src/research/synergy-detector.ts +126 -126
  107. package/src/research/template-extractor.ts +130 -130
  108. package/src/research/trend-analyzer.ts +127 -127
  109. package/src/services/code.service.ts +271 -271
  110. package/src/services/error.service.ts +4 -3
  111. package/src/services/git.service.ts +132 -132
  112. package/src/services/notification.service.ts +41 -41
  113. package/src/services/synapse.service.ts +59 -59
  114. package/src/services/terminal.service.ts +81 -81
  115. package/src/synapses/activation.ts +80 -80
  116. package/src/synapses/decay.ts +38 -38
  117. package/src/synapses/hebbian.ts +69 -69
  118. package/src/synapses/pathfinder.ts +81 -81
  119. package/src/synapses/synapse-manager.ts +109 -109
  120. package/src/types/code.types.ts +52 -52
  121. package/src/types/error.types.ts +67 -67
  122. package/src/types/ipc.types.ts +8 -8
  123. package/src/types/mcp.types.ts +53 -53
  124. package/src/types/research.types.ts +28 -28
  125. package/src/types/solution.types.ts +30 -30
  126. package/src/utils/events.ts +45 -45
  127. package/src/utils/hash.ts +5 -5
  128. package/src/utils/logger.ts +48 -48
  129. package/src/utils/paths.ts +19 -19
  130. package/tests/e2e/test_code_intelligence.py +1015 -0
  131. package/tests/e2e/test_error_memory.py +451 -0
  132. package/tests/e2e/test_full_integration.py +534 -0
  133. package/tests/fixtures/code-modules/modules.ts +83 -83
  134. package/tests/fixtures/errors/go.ts +9 -9
  135. package/tests/fixtures/errors/node.ts +24 -24
  136. package/tests/fixtures/errors/python.ts +21 -21
  137. package/tests/fixtures/errors/rust.ts +25 -25
  138. package/tests/fixtures/errors/shell.ts +15 -15
  139. package/tests/fixtures/solutions/solutions.ts +27 -27
  140. package/tests/helpers/setup-db.ts +52 -52
  141. package/tests/integration/code-flow.test.ts +86 -86
  142. package/tests/integration/error-flow.test.ts +83 -83
  143. package/tests/integration/ipc-flow.test.ts +166 -166
  144. package/tests/integration/learning-cycle.test.ts +82 -82
  145. package/tests/integration/synapse-flow.test.ts +117 -117
  146. package/tests/unit/code/analyzer.test.ts +58 -58
  147. package/tests/unit/code/fingerprint.test.ts +51 -51
  148. package/tests/unit/code/scorer.test.ts +55 -55
  149. package/tests/unit/learning/confidence-scorer.test.ts +60 -60
  150. package/tests/unit/learning/decay.test.ts +45 -45
  151. package/tests/unit/learning/pattern-extractor.test.ts +50 -50
  152. package/tests/unit/matching/error-matcher.test.ts +69 -69
  153. package/tests/unit/matching/fingerprint.test.ts +47 -47
  154. package/tests/unit/matching/similarity.test.ts +65 -65
  155. package/tests/unit/matching/tfidf.test.ts +71 -71
  156. package/tests/unit/matching/tokenizer.test.ts +83 -83
  157. package/tests/unit/parsing/parsers.test.ts +113 -113
  158. package/tests/unit/research/gap-analyzer.test.ts +45 -45
  159. package/tests/unit/research/trend-analyzer.test.ts +45 -45
  160. package/tests/unit/synapses/activation.test.ts +80 -80
  161. package/tests/unit/synapses/decay.test.ts +27 -27
  162. package/tests/unit/synapses/hebbian.test.ts +96 -96
  163. package/tests/unit/synapses/pathfinder.test.ts +72 -72
  164. package/tsconfig.json +18 -18
@@ -0,0 +1,534 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Brain v1.8.1 — Full System Integration Test
4
+ Tests EVERY remaining endpoint, CLI, MCP HTTP, SSE, error handling, and performance.
5
+ ~80 assertions covering the complete system.
6
+
7
+ Run AFTER test_error_memory.py and test_code_intelligence.py for richer data.
8
+ """
9
+
10
+ import sys
11
+ import time
12
+ import json
13
+ import uuid
14
+ import subprocess
15
+ import threading
16
+ import httpx
17
+
18
+ BASE = "http://localhost:7777/api/v1"
19
+ MCP_BASE = "http://localhost:7778"
20
+ PASS = 0
21
+ FAIL = 0
22
+ ERRORS: list[str] = []
23
+ PERF: list[tuple[str, float]] = []
24
+
25
+
26
+ def check(condition: bool, label: str) -> bool:
27
+ global PASS, FAIL
28
+ if condition:
29
+ PASS += 1
30
+ print(f" \033[32mPASS\033[0m {label}")
31
+ else:
32
+ FAIL += 1
33
+ ERRORS.append(label)
34
+ print(f" \033[31mFAIL\033[0m {label}")
35
+ return condition
36
+
37
+
38
+ def timed_get(path: str, params: dict | None = None, base: str = BASE) -> httpx.Response:
39
+ t0 = time.perf_counter()
40
+ r = httpx.get(f"{base}{path}", params=params, timeout=15)
41
+ dt = (time.perf_counter() - t0) * 1000
42
+ PERF.append((f"GET {path}", dt))
43
+ return r
44
+
45
+
46
+ def timed_post(path: str, json_data: dict | list | None = None, base: str = BASE) -> httpx.Response:
47
+ t0 = time.perf_counter()
48
+ r = httpx.post(f"{base}{path}", json=json_data or {}, timeout=15)
49
+ dt = (time.perf_counter() - t0) * 1000
50
+ PERF.append((f"POST {path}", dt))
51
+ return r
52
+
53
+
54
+ def post(path: str, json_data: dict | list | None = None, base: str = BASE) -> httpx.Response:
55
+ return httpx.post(f"{base}{path}", json=json_data or {}, timeout=15)
56
+
57
+
58
+ def get(path: str, params: dict | None = None, base: str = BASE) -> httpx.Response:
59
+ return httpx.get(f"{base}{path}", params=params, timeout=15)
60
+
61
+
62
+ def section(title: str) -> None:
63
+ print(f"\n{'-' * 50}")
64
+ print(f" {title}")
65
+ print(f"{'-' * 50}")
66
+
67
+
68
+ def main() -> int:
69
+ print("\n" + "=" * 60)
70
+ print(" BRAIN E2E TEST: Full System Integration")
71
+ print("=" * 60)
72
+
73
+ # ══════════════════════════════════════════════════════════
74
+ # Section A: REST Infrastructure
75
+ # ══════════════════════════════════════════════════════════
76
+ section("A: REST Infrastructure")
77
+
78
+ # A1: Health check
79
+ r = timed_get("/health")
80
+ check(r.status_code == 200, "Health endpoint returns 200")
81
+ health = r.json()
82
+ check(health.get("status") == "ok", "Health status is 'ok'")
83
+ check("timestamp" in health, "Health includes timestamp")
84
+
85
+ # A2: Methods listing
86
+ r = timed_get("/methods")
87
+ check(r.status_code == 200, "Methods endpoint returns 200")
88
+ methods = r.json().get("methods", [])
89
+ check(isinstance(methods, list) and len(methods) >= 30, f"Listed {len(methods)} methods (expect 30+)")
90
+
91
+ # A3: Single RPC call
92
+ r = timed_post("/rpc", {"method": "analytics.summary", "params": {}})
93
+ check(r.status_code == 200, "Single RPC returns 200")
94
+ check("result" in r.json(), "RPC response has 'result' key")
95
+
96
+ # A4: Batch RPC call
97
+ batch = [
98
+ {"method": "analytics.summary", "params": {}, "id": 1},
99
+ {"method": "synapse.stats", "params": {}, "id": 2},
100
+ {"method": "project.list", "params": {}, "id": 3},
101
+ ]
102
+ r = timed_post("/rpc", batch)
103
+ check(r.status_code == 200, "Batch RPC returns 200")
104
+ results = r.json()
105
+ check(isinstance(results, list) and len(results) == 3, f"Batch returned {len(results)} results")
106
+ check(all("result" in item or "error" in item for item in results), "All batch items have result or error")
107
+
108
+ # A5: RPC with unknown method
109
+ r = post("/rpc", {"method": "nonexistent.method", "params": {}})
110
+ check(r.status_code == 400, "Unknown RPC method returns 400")
111
+ check("error" in r.json(), "Unknown method has error message")
112
+
113
+ # A6: RPC with missing method field
114
+ r = post("/rpc", {"params": {}})
115
+ check(r.status_code == 400, "Missing method field returns 400")
116
+
117
+ # A7: RPC with empty body
118
+ r = httpx.post(f"{BASE}/rpc", content=b"", headers={"Content-Type": "application/json"}, timeout=10)
119
+ check(r.status_code == 400, "Empty RPC body returns 400")
120
+
121
+ # A8: 404 for unknown route
122
+ r = get("/nonexistent/route")
123
+ check(r.status_code == 404, "Unknown route returns 404")
124
+
125
+ # A9: CORS headers
126
+ r = httpx.options(f"{BASE}/health", timeout=10)
127
+ check(r.status_code == 204, "OPTIONS returns 204")
128
+ check("access-control-allow-origin" in r.headers, "CORS headers present")
129
+
130
+ # ══════════════════════════════════════════════════════════
131
+ # Section B: SSE Events
132
+ # ══════════════════════════════════════════════════════════
133
+ section("B: SSE Events")
134
+
135
+ sse_events: list[str] = []
136
+ sse_connected = threading.Event()
137
+
138
+ def sse_listener():
139
+ try:
140
+ with httpx.stream("GET", f"{BASE}/events", timeout=10) as stream:
141
+ for line in stream.iter_lines():
142
+ if line.startswith("data: "):
143
+ data = line[6:]
144
+ sse_events.append(data)
145
+ parsed = json.loads(data)
146
+ if parsed.get("type") == "connected":
147
+ sse_connected.set()
148
+ if len(sse_events) >= 3:
149
+ break
150
+ except (httpx.ReadTimeout, httpx.RemoteProtocolError):
151
+ pass
152
+
153
+ t = threading.Thread(target=sse_listener, daemon=True)
154
+ t.start()
155
+ sse_connected.wait(timeout=5)
156
+ check(sse_connected.is_set(), "SSE connection established")
157
+
158
+ # Trigger an event by reporting an error
159
+ post("/errors", {
160
+ "project": "test-sse",
161
+ "errorOutput": "Error: SSE test trigger\n at test (/tmp/test.js:1:1)",
162
+ })
163
+ time.sleep(1)
164
+ check(len(sse_events) >= 1, f"SSE received {len(sse_events)} event(s)")
165
+
166
+ # ══════════════════════════════════════════════════════════
167
+ # Section C: MCP HTTP/SSE
168
+ # ══════════════════════════════════════════════════════════
169
+ section("C: MCP HTTP/SSE")
170
+
171
+ # C1: Root endpoint
172
+ try:
173
+ r = get("/", base=MCP_BASE)
174
+ check(r.status_code == 200, "MCP root endpoint returns 200")
175
+ mcp_info = r.json()
176
+ check(mcp_info.get("name") == "brain", f"MCP name: {mcp_info.get('name')}")
177
+ check(mcp_info.get("protocol") == "MCP", "MCP protocol field present")
178
+ check("endpoints" in mcp_info, "MCP endpoints listed")
179
+ except httpx.ConnectError:
180
+ check(False, "MCP HTTP server reachable on port 7778")
181
+ check(False, "MCP name check (skipped)")
182
+ check(False, "MCP protocol check (skipped)")
183
+ check(False, "MCP endpoints check (skipped)")
184
+
185
+ # C2: SSE endpoint (just verify it starts streaming)
186
+ try:
187
+ with httpx.stream("GET", f"{MCP_BASE}/sse", timeout=5) as stream:
188
+ first_chunk = None
189
+ for line in stream.iter_lines():
190
+ first_chunk = line
191
+ break
192
+ check(first_chunk is not None, f"MCP SSE stream started: {first_chunk[:50] if first_chunk else 'empty'}...")
193
+ except (httpx.ReadTimeout, httpx.ConnectError):
194
+ check(False, "MCP SSE connection (timeout or unreachable)")
195
+
196
+ # C3: Messages endpoint without sessionId
197
+ try:
198
+ r = post("/messages", base=MCP_BASE)
199
+ check(r.status_code == 400, "MCP /messages without sessionId returns 400")
200
+ except httpx.ConnectError:
201
+ check(False, "MCP /messages reachable")
202
+
203
+ # ══════════════════════════════════════════════════════════
204
+ # Section D: All REST Endpoints
205
+ # ══════════════════════════════════════════════════════════
206
+ section("D: All REST Endpoints (comprehensive)")
207
+
208
+ # D1-D6: Error endpoints (basic shape verification)
209
+ r = timed_get("/errors")
210
+ check(r.status_code == 200, "GET /errors returns 200")
211
+ errors = r.json().get("result", [])
212
+ check(isinstance(errors, list), f"Errors list: {len(errors)} items")
213
+
214
+ if errors:
215
+ eid = errors[0]["id"] if isinstance(errors[0], dict) else errors[0]
216
+ r = timed_get(f"/errors/{eid}")
217
+ check(r.status_code == 200, f"GET /errors/{eid} returns 200")
218
+
219
+ r = timed_get(f"/errors/{eid}/match")
220
+ check(r.status_code == 200, f"GET /errors/{eid}/match returns 200")
221
+
222
+ r = timed_get(f"/errors/{eid}/chain")
223
+ check(r.status_code == 200, f"GET /errors/{eid}/chain returns 200")
224
+
225
+ # D7-D10: Solution endpoints
226
+ r = timed_get("/solutions")
227
+ check(r.status_code == 200, "GET /solutions returns 200")
228
+
229
+ r = timed_get("/solutions/efficiency")
230
+ check(r.status_code == 200, "GET /solutions/efficiency returns 200")
231
+
232
+ # D11: Projects
233
+ r = timed_get("/projects")
234
+ check(r.status_code == 200, "GET /projects returns 200")
235
+
236
+ # D12-D16: Code endpoints
237
+ r = timed_get("/code/modules")
238
+ check(r.status_code == 200, "GET /code/modules returns 200")
239
+ modules = r.json().get("result", [])
240
+ if modules and isinstance(modules[0], dict):
241
+ mid = modules[0]["id"]
242
+ r = timed_get(f"/code/{mid}")
243
+ check(r.status_code == 200, f"GET /code/{mid} returns 200")
244
+
245
+ r = timed_post("/code/find", {"query": "utility"})
246
+ check(r.status_code == 201, "POST /code/find returns 201")
247
+
248
+ r = timed_post("/code/similarity", {"source": "function test() { return 42; }", "language": "typescript"})
249
+ check(r.status_code == 201, "POST /code/similarity returns 201")
250
+
251
+ # D17-D19: Prevention endpoints
252
+ r = timed_post("/prevention/check", {"errorType": "Error", "message": "test"})
253
+ check(r.status_code == 201, "POST /prevention/check returns 201")
254
+
255
+ r = timed_post("/prevention/antipatterns", {"errorType": "Error", "message": "test"})
256
+ check(r.status_code == 201, "POST /prevention/antipatterns returns 201")
257
+
258
+ r = timed_post("/prevention/code", {"source": "let x = 1;", "filePath": "test.js"})
259
+ check(r.status_code == 201, "POST /prevention/code returns 201")
260
+
261
+ # D20-D23: Synapse endpoints
262
+ r = timed_get("/synapses/stats")
263
+ check(r.status_code == 200, "GET /synapses/stats returns 200")
264
+ syn_stats = r.json().get("result", {})
265
+ check(isinstance(syn_stats, dict), f"Synapse stats: {syn_stats.get('totalSynapses', '?')} synapses")
266
+
267
+ if errors:
268
+ eid = errors[0]["id"] if isinstance(errors[0], dict) else errors[0]
269
+ r = timed_get(f"/synapses/context/{eid}")
270
+ check(r.status_code == 200, f"GET /synapses/context/{eid} returns 200")
271
+
272
+ r = timed_post("/synapses/related", {"nodeType": "error", "nodeId": 1})
273
+ check(r.status_code == 201, "POST /synapses/related returns 201")
274
+
275
+ r = timed_post("/synapses/path", {"fromType": "error", "fromId": 1, "toType": "solution", "toId": 1})
276
+ check(r.status_code == 201, "POST /synapses/path returns 201")
277
+
278
+ # D24-D27: Research/Insights endpoints
279
+ r = timed_get("/research/insights")
280
+ check(r.status_code == 200, "GET /research/insights returns 200")
281
+
282
+ r = timed_get("/research/suggest", params={"context": "TypeError handling"})
283
+ check(r.status_code == 200, "GET /research/suggest returns 200")
284
+
285
+ r = timed_get("/research/trends")
286
+ check(r.status_code == 200, "GET /research/trends returns 200")
287
+
288
+ # D28-D29: Notifications
289
+ r = timed_get("/notifications")
290
+ check(r.status_code == 200, "GET /notifications returns 200")
291
+
292
+ # D30-D34: Analytics endpoints
293
+ r = timed_get("/analytics/summary")
294
+ check(r.status_code == 200, "GET /analytics/summary returns 200")
295
+ summary = r.json().get("result", {})
296
+ check(isinstance(summary, dict), "Analytics summary is a dict")
297
+
298
+ r = timed_get("/analytics/network")
299
+ check(r.status_code == 200, "GET /analytics/network returns 200")
300
+
301
+ r = timed_get("/analytics/health")
302
+ check(r.status_code == 200, "GET /analytics/health returns 200")
303
+
304
+ r = timed_get("/analytics/timeline")
305
+ check(r.status_code == 200, "GET /analytics/timeline returns 200")
306
+
307
+ if errors:
308
+ eid = errors[0]["id"] if isinstance(errors[0], dict) else errors[0]
309
+ r = timed_get(f"/analytics/explain/{eid}")
310
+ check(r.status_code == 200, f"GET /analytics/explain/{eid} returns 200")
311
+
312
+ # D35-D39: Git endpoints
313
+ r = timed_get("/git/context")
314
+ check(r.status_code == 200, "GET /git/context returns 200")
315
+
316
+ r = timed_get("/git/diff")
317
+ check(r.status_code == 200, "GET /git/diff returns 200")
318
+
319
+ # D40: Learning
320
+ r = timed_post("/learning/run")
321
+ check(r.status_code == 201, "POST /learning/run returns 201")
322
+
323
+ # ══════════════════════════════════════════════════════════
324
+ # Section E: CLI Commands
325
+ # ══════════════════════════════════════════════════════════
326
+ section("E: CLI Commands")
327
+
328
+ cli_commands = [
329
+ (["brain", "status"], "brain status"),
330
+ (["brain", "doctor"], "brain doctor"),
331
+ (["brain", "query", "TypeError"], "brain query TypeError"),
332
+ (["brain", "modules"], "brain modules"),
333
+ (["brain", "insights"], "brain insights"),
334
+ (["brain", "projects"], "brain projects"),
335
+ (["brain", "network"], "brain network"),
336
+ (["brain", "learn"], "brain learn"),
337
+ ]
338
+
339
+ for cmd, label in cli_commands:
340
+ try:
341
+ result = subprocess.run(
342
+ cmd, capture_output=True, text=True, timeout=30,
343
+ shell=(sys.platform == "win32"),
344
+ )
345
+ # Some commands may have non-zero exit codes if no data, but they shouldn't crash
346
+ check(result.returncode == 0 or result.returncode is not None,
347
+ f"CLI '{label}' ran (exit={result.returncode})")
348
+ except FileNotFoundError:
349
+ check(False, f"CLI '{label}' (brain not found in PATH)")
350
+ except subprocess.TimeoutExpired:
351
+ check(False, f"CLI '{label}' (timeout)")
352
+
353
+ # ══════════════════════════════════════════════════════════
354
+ # Section F: Git Integration
355
+ # ══════════════════════════════════════════════════════════
356
+ section("F: Git Integration")
357
+
358
+ # F1: Git context
359
+ r = get("/git/context")
360
+ ctx = r.json().get("result", {})
361
+ check(r.status_code == 200, "Git context returns 200")
362
+ check(ctx.get("branch") is not None or ctx.get("branch") is None, f"Git branch: {ctx.get('branch')}")
363
+
364
+ # F2: Link error to commit
365
+ fake_hash = "abc1234567890def1234567890abcdef12345678"
366
+ if errors:
367
+ eid = errors[0]["id"] if isinstance(errors[0], dict) else errors[0]
368
+ # Get project ID from the error
369
+ err_detail = get(f"/errors/{eid}").json().get("result", {})
370
+ pid = err_detail.get("project_id", 1)
371
+ r = post("/git/link-error", {
372
+ "errorId": eid,
373
+ "projectId": pid,
374
+ "commitHash": fake_hash,
375
+ "relationship": "introduced_by",
376
+ })
377
+ check(r.status_code == 201, "Git link-error returns 201")
378
+
379
+ # F3: Query commits by error
380
+ r = get(f"/git/errors/{eid}/commits")
381
+ check(r.status_code == 200, f"Git errorCommits returns 200")
382
+
383
+ # F4: Query errors by commit
384
+ r = get(f"/git/commits/{fake_hash}/errors")
385
+ check(r.status_code == 200, "Git commitErrors returns 200")
386
+
387
+ # F5: Git diff
388
+ r = get("/git/diff")
389
+ check(r.status_code == 200, "Git diff returns 200")
390
+
391
+ # ══════════════════════════════════════════════════════════
392
+ # Section G: Terminal Lifecycle
393
+ # ══════════════════════════════════════════════════════════
394
+ section("G: Terminal Lifecycle")
395
+
396
+ term_uuid = str(uuid.uuid4())
397
+
398
+ # G1: Register terminal
399
+ r = post("/terminal/register", {
400
+ "uuid": term_uuid,
401
+ "pid": 12345,
402
+ "shell": "bash",
403
+ "cwd": "/tmp/test-project",
404
+ })
405
+ check(r.status_code == 201, "Terminal register returns 201")
406
+ term_id = r.json().get("result")
407
+ check(term_id is not None, f"Terminal registered (id={term_id})")
408
+
409
+ # G2: Heartbeat
410
+ r = post("/terminal/heartbeat", {"uuid": term_uuid})
411
+ check(r.status_code == 201, "Terminal heartbeat returns 201")
412
+
413
+ # G3: Disconnect
414
+ r = post("/terminal/disconnect", {"uuid": term_uuid})
415
+ check(r.status_code == 201, "Terminal disconnect returns 201")
416
+
417
+ # ══════════════════════════════════════════════════════════
418
+ # Section H: Notifications
419
+ # ══════════════════════════════════════════════════════════
420
+ section("H: Notifications")
421
+
422
+ # H1: List notifications
423
+ r = get("/notifications")
424
+ check(r.status_code == 200, "Notifications list returns 200")
425
+ notifs = r.json().get("result", [])
426
+ check(isinstance(notifs, list), f"Notifications: {len(notifs)} items")
427
+
428
+ # H2: Acknowledge a notification (if any exist)
429
+ if notifs and isinstance(notifs[0], dict):
430
+ nid = notifs[0].get("id")
431
+ if nid:
432
+ r = post(f"/notifications/{nid}/ack")
433
+ check(r.status_code == 201, f"Notification {nid} acknowledged")
434
+
435
+ # H3: Verify dismissal
436
+ r = get("/notifications")
437
+ new_notifs = r.json().get("result", [])
438
+ dismissed = all(n.get("id") != nid for n in new_notifs if isinstance(n, dict))
439
+ check(dismissed or len(new_notifs) <= len(notifs),
440
+ f"Notification dismissed ({len(notifs)} → {len(new_notifs)})")
441
+ else:
442
+ check(True, "No notifications to acknowledge (OK)")
443
+ check(True, "No notifications to verify dismissal (OK)")
444
+
445
+ # ══════════════════════════════════════════════════════════
446
+ # Section I: Performance Benchmarks
447
+ # ══════════════════════════════════════════════════════════
448
+ section("I: Performance Benchmarks")
449
+
450
+ # Run a few extra timed calls for benchmark variety
451
+ for _ in range(3):
452
+ timed_get("/health")
453
+ timed_get("/analytics/summary")
454
+ timed_post("/rpc", {"method": "synapse.stats", "params": {}})
455
+
456
+ # Print performance table
457
+ if PERF:
458
+ print(f"\n {'Endpoint':<45} {'Time (ms)':>10}")
459
+ print(f" {'-' * 45} {'-' * 10}")
460
+
461
+ # Group by endpoint and compute averages
462
+ from collections import defaultdict
463
+ groups: dict[str, list[float]] = defaultdict(list)
464
+ for endpoint, ms in PERF:
465
+ groups[endpoint].append(ms)
466
+
467
+ for endpoint, times in sorted(groups.items()):
468
+ avg = sum(times) / len(times)
469
+ count = f" (×{len(times)})" if len(times) > 1 else ""
470
+ color = "\033[32m" if avg < 50 else "\033[33m" if avg < 200 else "\033[31m"
471
+ print(f" {endpoint:<45} {color}{avg:>8.1f}ms\033[0m{count}")
472
+
473
+ all_times = [t for _, t in PERF]
474
+ avg_all = sum(all_times) / len(all_times)
475
+ max_time = max(all_times)
476
+ p95 = sorted(all_times)[int(len(all_times) * 0.95)]
477
+ print(f"\n Average: {avg_all:.1f}ms | P95: {p95:.1f}ms | Max: {max_time:.1f}ms")
478
+
479
+ check(avg_all < 500, f"Average response time < 500ms ({avg_all:.1f}ms)")
480
+ check(max_time < 5000, f"Max response time < 5s ({max_time:.1f}ms)")
481
+
482
+ # ══════════════════════════════════════════════════════════
483
+ # Section J: Error Handling
484
+ # ══════════════════════════════════════════════════════════
485
+ section("J: Error Handling")
486
+
487
+ # J1: Invalid JSON body
488
+ r = httpx.post(f"{BASE}/errors", content=b"not json", headers={"Content-Type": "application/json"}, timeout=10)
489
+ check(r.status_code == 400, "Invalid JSON returns 400")
490
+
491
+ # J2: Missing required fields
492
+ r = post("/errors", {})
493
+ # Should still work but with empty/default values, or return error
494
+ check(r.status_code in (201, 400), f"Empty error body returns {r.status_code}")
495
+
496
+ # J3: Non-existent error ID
497
+ r = get("/errors/999999")
498
+ check(r.status_code in (200, 400, 404), f"Non-existent error ID returns {r.status_code}")
499
+
500
+ # J4: Non-existent code module
501
+ r = get("/code/999999")
502
+ check(r.status_code in (200, 400, 404), f"Non-existent module ID returns {r.status_code}")
503
+
504
+ # J5: Invalid RPC method type
505
+ r = post("/rpc", {"method": 12345, "params": {}})
506
+ check(r.status_code in (200, 400), f"Invalid method type returns {r.status_code}")
507
+
508
+ # ══════════════════════════════════════════════════════════
509
+ # Summary
510
+ # ══════════════════════════════════════════════════════════
511
+ print("\n" + "=" * 60)
512
+ total = PASS + FAIL
513
+ print(f" Results: {PASS}/{total} passed, {FAIL} failed")
514
+ if ERRORS:
515
+ print(f"\n Failed tests:")
516
+ for e in ERRORS:
517
+ print(f" - {e}")
518
+ print("=" * 60 + "\n")
519
+
520
+ return 0 if FAIL == 0 else 1
521
+
522
+
523
+ if __name__ == "__main__":
524
+ try:
525
+ sys.exit(main())
526
+ except httpx.ConnectError:
527
+ print("\n\033[31mERROR: Cannot connect to Brain daemon on port 7777.\033[0m")
528
+ print("Run 'brain start' or 'brain doctor' first.\n")
529
+ sys.exit(2)
530
+ except Exception as e:
531
+ print(f"\n\033[31mFATAL: {e}\033[0m\n")
532
+ import traceback
533
+ traceback.print_exc()
534
+ sys.exit(2)