patchi 0.7.5__py3-none-any.whl

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 (496) hide show
  1. patchi/__init__.py +1 -0
  2. patchi/__main__.py +13 -0
  3. patchi/cli/__init__.py +0 -0
  4. patchi/cli/command_families.py +235 -0
  5. patchi/cli/commands/__init__.py +0 -0
  6. patchi/cli/commands/access_cmd.py +65 -0
  7. patchi/cli/commands/agent_stats_cmd.py +273 -0
  8. patchi/cli/commands/agents_cmd.py +187 -0
  9. patchi/cli/commands/ai_cmd.py +308 -0
  10. patchi/cli/commands/assure_cmd.py +538 -0
  11. patchi/cli/commands/audit_cmd.py +286 -0
  12. patchi/cli/commands/auto_cmd.py +110 -0
  13. patchi/cli/commands/blame_cmd.py +53 -0
  14. patchi/cli/commands/chains_cmd.py +327 -0
  15. patchi/cli/commands/charter_cmd.py +173 -0
  16. patchi/cli/commands/chat_cmd.py +597 -0
  17. patchi/cli/commands/check_cmd.py +232 -0
  18. patchi/cli/commands/cleanup_cmd.py +328 -0
  19. patchi/cli/commands/cockpit_cmd.py +208 -0
  20. patchi/cli/commands/commands_cmd.py +160 -0
  21. patchi/cli/commands/cross_repo_cmd.py +113 -0
  22. patchi/cli/commands/deps_cmd.py +110 -0
  23. patchi/cli/commands/dev_check_cmd.py +342 -0
  24. patchi/cli/commands/dev_cmd.py +445 -0
  25. patchi/cli/commands/doctor_cmd.py +457 -0
  26. patchi/cli/commands/findings_cmd.py +187 -0
  27. patchi/cli/commands/fix_cmd.py +648 -0
  28. patchi/cli/commands/fix_review_cmd.py +273 -0
  29. patchi/cli/commands/heatmap_cmd.py +62 -0
  30. patchi/cli/commands/help_cmd.py +159 -0
  31. patchi/cli/commands/hosted_cmd.py +980 -0
  32. patchi/cli/commands/init.py +456 -0
  33. patchi/cli/commands/key_cmd.py +571 -0
  34. patchi/cli/commands/learn_cmd.py +140 -0
  35. patchi/cli/commands/link_cmd.py +104 -0
  36. patchi/cli/commands/log_cmd.py +57 -0
  37. patchi/cli/commands/memory_cmd.py +215 -0
  38. patchi/cli/commands/model_cmd.py +207 -0
  39. patchi/cli/commands/notify_cmd.py +213 -0
  40. patchi/cli/commands/notify_prompts.py +29 -0
  41. patchi/cli/commands/patch_cmd.py +231 -0
  42. patchi/cli/commands/plan_cmd.py +128 -0
  43. patchi/cli/commands/plugins_cmd.py +307 -0
  44. patchi/cli/commands/queue_cmd.py +152 -0
  45. patchi/cli/commands/quick_cmd.py +159 -0
  46. patchi/cli/commands/ready_cmd.py +292 -0
  47. patchi/cli/commands/report_cmd.py +416 -0
  48. patchi/cli/commands/restrict_cmd.py +127 -0
  49. patchi/cli/commands/review_cmd.py +225 -0
  50. patchi/cli/commands/rules_cmd.py +233 -0
  51. patchi/cli/commands/scan_cmd.py +1908 -0
  52. patchi/cli/commands/settings_cmd.py +45 -0
  53. patchi/cli/commands/status_cmd.py +679 -0
  54. patchi/cli/commands/test_cmd.py +1052 -0
  55. patchi/cli/commands/trend_cmd.py +296 -0
  56. patchi/cli/commands/undo_cmd.py +235 -0
  57. patchi/cli/commands/update_cmd.py +345 -0
  58. patchi/cli/commands/verify_cmd.py +56 -0
  59. patchi/cli/commands/vr_cmd.py +440 -0
  60. patchi/cli/commands/watch_cmd.py +304 -0
  61. patchi/cli/commands/web_cmd.py +114 -0
  62. patchi/cli/console.py +101 -0
  63. patchi/cli/display/__init__.py +1 -0
  64. patchi/cli/display/live_audit.py +269 -0
  65. patchi/cli/display/live_progress.py +169 -0
  66. patchi/cli/framework.py +241 -0
  67. patchi/cli/logo.py +128 -0
  68. patchi/cli/main.py +222 -0
  69. patchi/cli/registry.py +1171 -0
  70. patchi/cli/style.py +68 -0
  71. patchi/cli/themes.py +187 -0
  72. patchi/cli/ux.py +444 -0
  73. patchi/core/__init__.py +0 -0
  74. patchi/core/agents/__init__.py +0 -0
  75. patchi/core/agents/api_fuzzer_agent.py +141 -0
  76. patchi/core/agents/attack_agent.py +242 -0
  77. patchi/core/agents/base.py +1012 -0
  78. patchi/core/agents/bug_predictor_agent.py +58 -0
  79. patchi/core/agents/build_tool_validator.py +150 -0
  80. patchi/core/agents/cache.py +192 -0
  81. patchi/core/agents/chaos_agent.py +133 -0
  82. patchi/core/agents/cicd_generator.py +240 -0
  83. patchi/core/agents/comment_scanner.py +280 -0
  84. patchi/core/agents/console_logging_agent.py +156 -0
  85. patchi/core/agents/coordinator.py +858 -0
  86. patchi/core/agents/core_scanner.py +269 -0
  87. patchi/core/agents/coverage_prioritizer.py +181 -0
  88. patchi/core/agents/dead_code_hygiene.py +296 -0
  89. patchi/core/agents/dead_code_scanner.py +692 -0
  90. patchi/core/agents/dependency_scanner.py +725 -0
  91. patchi/core/agents/doc_claim_agent.py +573 -0
  92. patchi/core/agents/duplicate_scanner.py +568 -0
  93. patchi/core/agents/env_scanner.py +410 -0
  94. patchi/core/agents/feature_flag_agent.py +89 -0
  95. patchi/core/agents/frontend_framework_agent.py +133 -0
  96. patchi/core/agents/goroutine_agent.py +82 -0
  97. patchi/core/agents/governor.py +1972 -0
  98. patchi/core/agents/i18n_agent.py +95 -0
  99. patchi/core/agents/license_compliance.py +247 -0
  100. patchi/core/agents/linking_agent.py +251 -0
  101. patchi/core/agents/memory_profiler_agent.py +102 -0
  102. patchi/core/agents/modernization_agent.py +84 -0
  103. patchi/core/agents/mutation_agent.py +174 -0
  104. patchi/core/agents/nl_query_agent.py +74 -0
  105. patchi/core/agents/promise_rejection_agent.py +100 -0
  106. patchi/core/agents/refactoring_agent.py +286 -0
  107. patchi/core/agents/resource_leak_agent.py +91 -0
  108. patchi/core/agents/route_graph_scanner.py +951 -0
  109. patchi/core/agents/run_agent.py +106 -0
  110. patchi/core/agents/rust_unwrap_agent.py +100 -0
  111. patchi/core/agents/sbom_generator.py +284 -0
  112. patchi/core/agents/scanners.py +67 -0
  113. patchi/core/agents/side/__init__.py +1 -0
  114. patchi/core/agents/side/build_agent.py +65 -0
  115. patchi/core/agents/side/format_agent.py +50 -0
  116. patchi/core/agents/side/install_agent.py +63 -0
  117. patchi/core/agents/side_file_scanner.py +549 -0
  118. patchi/core/agents/snapshot_drift_detector.py +146 -0
  119. patchi/core/agents/spa_route_inventory.py +203 -0
  120. patchi/core/agents/test_scanner.py +442 -0
  121. patchi/core/agents/tool_health.py +109 -0
  122. patchi/core/agents/tool_runner.py +162 -0
  123. patchi/core/agents/type_scanner.py +168 -0
  124. patchi/core/agents/ui_scanner.py +558 -0
  125. patchi/core/ai/__init__.py +20 -0
  126. patchi/core/ai/agent_audit.py +153 -0
  127. patchi/core/ai/agent_profiler.py +277 -0
  128. patchi/core/ai/client.py +499 -0
  129. patchi/core/ai/cost_tracker.py +107 -0
  130. patchi/core/ai/model_router.py +318 -0
  131. patchi/core/ai/orchestrator.py +625 -0
  132. patchi/core/ai/prompts.py +681 -0
  133. patchi/core/ai/smart.py +302 -0
  134. patchi/core/ai/tool_executor.py +24 -0
  135. patchi/core/ai/tools/__init__.py +10 -0
  136. patchi/core/ai/tools/executor.py +621 -0
  137. patchi/core/ai/tools/realize.py +1160 -0
  138. patchi/core/ai/tools/registry.py +1577 -0
  139. patchi/core/assurance/__init__.py +33 -0
  140. patchi/core/assurance/builder.py +211 -0
  141. patchi/core/assurance/graph.py +342 -0
  142. patchi/core/assurance/invariants.py +168 -0
  143. patchi/core/atomic.py +61 -0
  144. patchi/core/attackers/__init__.py +17 -0
  145. patchi/core/attackers/api_abuse.py +89 -0
  146. patchi/core/attackers/auth_bypass.py +73 -0
  147. patchi/core/attackers/base.py +91 -0
  148. patchi/core/attackers/business_logic.py +88 -0
  149. patchi/core/attackers/planner.py +108 -0
  150. patchi/core/attackers/priv_escalation.py +88 -0
  151. patchi/core/attackers/recon.py +87 -0
  152. patchi/core/brain/__init__.py +0 -0
  153. patchi/core/brain/ast_utils/__init__.py +81 -0
  154. patchi/core/brain/ast_utils/assignments.py +219 -0
  155. patchi/core/brain/ast_utils/calls.py +139 -0
  156. patchi/core/brain/ast_utils/config.py +132 -0
  157. patchi/core/brain/ast_utils/control_flow.py +74 -0
  158. patchi/core/brain/ast_utils/dead_symbols.py +205 -0
  159. patchi/core/brain/ast_utils/decorators.py +108 -0
  160. patchi/core/brain/ast_utils/helpers.py +144 -0
  161. patchi/core/brain/ast_utils/imports.py +169 -0
  162. patchi/core/brain/ast_utils/scan.py +143 -0
  163. patchi/core/brain/ast_utils/taint.py +310 -0
  164. patchi/core/brain/audit.py +379 -0
  165. patchi/core/brain/baseline.py +195 -0
  166. patchi/core/brain/blast_radius.py +312 -0
  167. patchi/core/brain/body_tags.py +144 -0
  168. patchi/core/brain/brain.py +1161 -0
  169. patchi/core/brain/brain_context.py +393 -0
  170. patchi/core/brain/brain_watcher.py +340 -0
  171. patchi/core/brain/charter.py +473 -0
  172. patchi/core/brain/classifier.py +214 -0
  173. patchi/core/brain/cockpit.py +350 -0
  174. patchi/core/brain/code_query.py +710 -0
  175. patchi/core/brain/contract.py +889 -0
  176. patchi/core/brain/contract_diff.py +155 -0
  177. patchi/core/brain/council.py +800 -0
  178. patchi/core/brain/cross_repo.py +104 -0
  179. patchi/core/brain/dead_code_tools.py +157 -0
  180. patchi/core/brain/doc_validator.py +197 -0
  181. patchi/core/brain/domain_activator.py +2183 -0
  182. patchi/core/brain/enriched_context.py +220 -0
  183. patchi/core/brain/file_corpus.py +220 -0
  184. patchi/core/brain/framework.py +953 -0
  185. patchi/core/brain/freshness.py +273 -0
  186. patchi/core/brain/git_aware.py +206 -0
  187. patchi/core/brain/heatmap.py +104 -0
  188. patchi/core/brain/ignore_parser.py +153 -0
  189. patchi/core/brain/import_graph.py +486 -0
  190. patchi/core/brain/languages.py +397 -0
  191. patchi/core/brain/layered_brain.py +419 -0
  192. patchi/core/brain/learning.py +229 -0
  193. patchi/core/brain/orphaned_endpoints.py +313 -0
  194. patchi/core/brain/personas/__init__.py +648 -0
  195. patchi/core/brain/personas/bad_user.py +165 -0
  196. patchi/core/brain/personas/base.py +349 -0
  197. patchi/core/brain/proactive.py +807 -0
  198. patchi/core/brain/project_context.py +121 -0
  199. patchi/core/brain/project_reader.py +521 -0
  200. patchi/core/brain/reasoning.py +269 -0
  201. patchi/core/brain/route_detector/__init__.py +15 -0
  202. patchi/core/brain/route_detector/base.py +31 -0
  203. patchi/core/brain/route_detector/csharp.py +209 -0
  204. patchi/core/brain/route_detector/file_based.py +81 -0
  205. patchi/core/brain/route_detector/go.py +152 -0
  206. patchi/core/brain/route_detector/java.py +195 -0
  207. patchi/core/brain/route_detector/javascript.py +197 -0
  208. patchi/core/brain/route_detector/php.py +158 -0
  209. patchi/core/brain/route_detector/python.py +193 -0
  210. patchi/core/brain/route_detector/registry.py +25 -0
  211. patchi/core/brain/route_detector/ruby.py +134 -0
  212. patchi/core/brain/route_detector/rust.py +216 -0
  213. patchi/core/brain/route_detector/swift.py +118 -0
  214. patchi/core/brain/route_mapper.py +265 -0
  215. patchi/core/brain/scanner.py +2136 -0
  216. patchi/core/brain/secrets.py +280 -0
  217. patchi/core/brain/symbol_graph.py +1955 -0
  218. patchi/core/brain/team.py +28 -0
  219. patchi/core/brain/trace_log.py +132 -0
  220. patchi/core/brain/type_checker/__init__.py +65 -0
  221. patchi/core/brain/type_checker/base.py +60 -0
  222. patchi/core/brain/type_checker/csharp.py +59 -0
  223. patchi/core/brain/type_checker/dart.py +59 -0
  224. patchi/core/brain/type_checker/go.py +81 -0
  225. patchi/core/brain/type_checker/java.py +75 -0
  226. patchi/core/brain/type_checker/kotlin.py +75 -0
  227. patchi/core/brain/type_checker/php.py +88 -0
  228. patchi/core/brain/type_checker/python.py +87 -0
  229. patchi/core/brain/type_checker/rust.py +81 -0
  230. patchi/core/brain/type_checker/swift.py +59 -0
  231. patchi/core/brain/type_checker/typescript.py +298 -0
  232. patchi/core/brain/understander.py +130 -0
  233. patchi/core/brain/verify.py +379 -0
  234. patchi/core/campaigns/__init__.py +14 -0
  235. patchi/core/campaigns/base.py +62 -0
  236. patchi/core/campaigns/data_flow_abuse.py +153 -0
  237. patchi/core/campaigns/orchestrator.py +140 -0
  238. patchi/core/campaigns/state_transitions.py +178 -0
  239. patchi/core/ci_bundle.py +104 -0
  240. patchi/core/config.py +311 -0
  241. patchi/core/constants.py +245 -0
  242. patchi/core/debug/__init__.py +25 -0
  243. patchi/core/debug/adapters/__init__.py +11 -0
  244. patchi/core/debug/adapters/codelldb.py +379 -0
  245. patchi/core/debug/adapters/node.py +222 -0
  246. patchi/core/debug/adapters/powershell.py +210 -0
  247. patchi/core/debug/adapters/python.py +244 -0
  248. patchi/core/debug/capture.py +210 -0
  249. patchi/core/debug/dap_client.py +268 -0
  250. patchi/core/detector/__init__.py +0 -0
  251. patchi/core/detector/audit.py +194 -0
  252. patchi/core/detector/bus.py +226 -0
  253. patchi/core/detector/dispatcher.py +275 -0
  254. patchi/core/detector/event.py +254 -0
  255. patchi/core/detector/sigma_engine.py +378 -0
  256. patchi/core/detector/triage.py +289 -0
  257. patchi/core/export/sarif.py +113 -0
  258. patchi/core/fix/__init__.py +17 -0
  259. patchi/core/fix/applier.py +574 -0
  260. patchi/core/fix/base.py +510 -0
  261. patchi/core/fix/code_fixer.py +169 -0
  262. patchi/core/fix/dead_code_remover.py +131 -0
  263. patchi/core/fix/fix_agents.py +775 -0
  264. patchi/core/fix/patch.py +444 -0
  265. patchi/core/fix/risk_gate.py +383 -0
  266. patchi/core/fix/security_fixer.py +93 -0
  267. patchi/core/fix/verify_loop.py +204 -0
  268. patchi/core/fuzz/__init__.py +8 -0
  269. patchi/core/fuzz/corpus.py +137 -0
  270. patchi/core/fuzz/input_fuzzer.py +256 -0
  271. patchi/core/fuzz/sequence_fuzzer.py +162 -0
  272. patchi/core/fuzz/state_fuzzer.py +236 -0
  273. patchi/core/health.py +407 -0
  274. patchi/core/hosted/__init__.py +1 -0
  275. patchi/core/hosted/anomaly.py +298 -0
  276. patchi/core/hosted/audit_log.py +102 -0
  277. patchi/core/hosted/compliance_report.py +148 -0
  278. patchi/core/hosted/ip_reputation.py +169 -0
  279. patchi/core/hosted/log_parsers.py +251 -0
  280. patchi/core/hosted/tokens.py +127 -0
  281. patchi/core/hosted/watchlist.py +164 -0
  282. patchi/core/hosted/webhooks.py +156 -0
  283. patchi/core/memory.py +437 -0
  284. patchi/core/notifications/__init__.py +18 -0
  285. patchi/core/notifications/channels.py +192 -0
  286. patchi/core/notifications/digest.py +141 -0
  287. patchi/core/notifications/escalation.py +158 -0
  288. patchi/core/notifications/notifier.py +182 -0
  289. patchi/core/notifications/quiet_hours.py +69 -0
  290. patchi/core/plugins/__init__.py +48 -0
  291. patchi/core/plugins/analyzer.py +326 -0
  292. patchi/core/plugins/analyzers/__init__.py +6 -0
  293. patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
  294. patchi/core/plugins/analyzers/secret_scanner.py +158 -0
  295. patchi/core/plugins/analyzers/todo_scanner.py +115 -0
  296. patchi/core/plugins/registry.py +335 -0
  297. patchi/core/queue.py +375 -0
  298. patchi/core/reliability/__init__.py +13 -0
  299. patchi/core/reliability/chaos.py +163 -0
  300. patchi/core/reliability/fault_injector.py +172 -0
  301. patchi/core/reliability/idempotency.py +128 -0
  302. patchi/core/reliability/recovery.py +147 -0
  303. patchi/core/runtime/__init__.py +5 -0
  304. patchi/core/runtime/tracer.py +230 -0
  305. patchi/core/scan_bus.py +156 -0
  306. patchi/core/security/__init__.py +1 -0
  307. patchi/core/security/ai_validator.py +462 -0
  308. patchi/core/security/app_mapper.py +216 -0
  309. patchi/core/security/app_profile.py +464 -0
  310. patchi/core/security/attack_feedback.py +141 -0
  311. patchi/core/security/attack_scenarios/__init__.py +1 -0
  312. patchi/core/security/attack_tree.py +176 -0
  313. patchi/core/security/auth_audit_agent.py +344 -0
  314. patchi/core/security/authz_agent.py +602 -0
  315. patchi/core/security/auto_fix_proactive.py +620 -0
  316. patchi/core/security/auto_fixer.py +660 -0
  317. patchi/core/security/auto_ticket.py +53 -0
  318. patchi/core/security/bandit_agent.py +152 -0
  319. patchi/core/security/blast_radius.py +226 -0
  320. patchi/core/security/browser_tester.py +333 -0
  321. patchi/core/security/business_logic_agent.py +263 -0
  322. patchi/core/security/catch_block_auditor.py +395 -0
  323. patchi/core/security/cdn_cache_agent.py +357 -0
  324. patchi/core/security/chain_analyzer.py +321 -0
  325. patchi/core/security/chain_to_assurance.py +168 -0
  326. patchi/core/security/charter.py +510 -0
  327. patchi/core/security/cloud_waf_detector.py +295 -0
  328. patchi/core/security/codeql_agent.py +233 -0
  329. patchi/core/security/compliance_agent.py +348 -0
  330. patchi/core/security/confidence_gate.py +423 -0
  331. patchi/core/security/container_scanner.py +282 -0
  332. patchi/core/security/crypto_agent.py +378 -0
  333. patchi/core/security/cve_monitor.py +262 -0
  334. patchi/core/security/dast_agent.py +865 -0
  335. patchi/core/security/defectdojo.py +238 -0
  336. patchi/core/security/defenders/__init__.py +75 -0
  337. patchi/core/security/defenders/auth_middleware.py +115 -0
  338. patchi/core/security/defenders/base.py +97 -0
  339. patchi/core/security/defenders/block_ip.py +71 -0
  340. patchi/core/security/defenders/block_ws_origin.py +36 -0
  341. patchi/core/security/defenders/crypto_fix.py +118 -0
  342. patchi/core/security/defenders/enforce_rate_limit.py +108 -0
  343. patchi/core/security/defenders/fix_code.py +93 -0
  344. patchi/core/security/defenders/invalidate_session.py +40 -0
  345. patchi/core/security/defenders/patch_config.py +70 -0
  346. patchi/core/security/defenders/rotate_secret.py +54 -0
  347. patchi/core/security/defenders/suspend_account.py +41 -0
  348. patchi/core/security/defenders/update_dependency.py +160 -0
  349. patchi/core/security/defense_layer.py +217 -0
  350. patchi/core/security/dependency_vulnerability_agent.py +616 -0
  351. patchi/core/security/detection_pipeline.py +289 -0
  352. patchi/core/security/dns_security_agent.py +497 -0
  353. patchi/core/security/domain_activator_v2.py +913 -0
  354. patchi/core/security/domain_loader.py +1004 -0
  355. patchi/core/security/domains/__init__.py +0 -0
  356. patchi/core/security/email_authentication_agent.py +417 -0
  357. patchi/core/security/env_var_validator.py +304 -0
  358. patchi/core/security/evidence.py +100 -0
  359. patchi/core/security/falco_runtime_agent.py +387 -0
  360. patchi/core/security/fix-playbooks/__init__.py +0 -0
  361. patchi/core/security/gated_finding.py +85 -0
  362. patchi/core/security/git_diff_activator.py +367 -0
  363. patchi/core/security/governance.py +208 -0
  364. patchi/core/security/gradual_gate.py +34 -0
  365. patchi/core/security/history.py +315 -0
  366. patchi/core/security/iac_scanner.py +250 -0
  367. patchi/core/security/ignore_expiry.py +111 -0
  368. patchi/core/security/ignore_learner.py +416 -0
  369. patchi/core/security/injection_agent.py +319 -0
  370. patchi/core/security/insecure_randomness_agent.py +288 -0
  371. patchi/core/security/intent_analyzer.py +326 -0
  372. patchi/core/security/jwt_agent.py +453 -0
  373. patchi/core/security/kubernetes_agent.py +638 -0
  374. patchi/core/security/layer2_orchestrator.py +341 -0
  375. patchi/core/security/llm_security_agent.py +252 -0
  376. patchi/core/security/misconfig_agent.py +600 -0
  377. patchi/core/security/mobile_security_agent.py +474 -0
  378. patchi/core/security/network_agent.py +376 -0
  379. patchi/core/security/noise_filter.py +274 -0
  380. patchi/core/security/orchestrator.py +313 -0
  381. patchi/core/security/pattern_context.py +125 -0
  382. patchi/core/security/pattern_loader.py +101 -0
  383. patchi/core/security/pentest/__init__.py +4 -0
  384. patchi/core/security/pentest/base.py +58 -0
  385. patchi/core/security/pentest/dalfox_adapter.py +40 -0
  386. patchi/core/security/pentest/ffuf_adapter.py +48 -0
  387. patchi/core/security/pentest/nuclei_adapter.py +50 -0
  388. patchi/core/security/pentest/registry.py +80 -0
  389. patchi/core/security/pentest/shannon_adapter.py +107 -0
  390. patchi/core/security/pentest/sqlmap_adapter.py +56 -0
  391. patchi/core/security/pentest/zap_adapter.py +52 -0
  392. patchi/core/security/plan_auditor.py +276 -0
  393. patchi/core/security/policy_engine.py +272 -0
  394. patchi/core/security/prechecks.py +172 -0
  395. patchi/core/security/precommit_hook.py +247 -0
  396. patchi/core/security/privacy_agent.py +361 -0
  397. patchi/core/security/push_notification_agent.py +417 -0
  398. patchi/core/security/pysa_agent.py +143 -0
  399. patchi/core/security/reasoning.py +442 -0
  400. patchi/core/security/red_team_agent.py +184 -0
  401. patchi/core/security/red_team_engine.py +1142 -0
  402. patchi/core/security/remediation.py +360 -0
  403. patchi/core/security/request_interceptor.py +294 -0
  404. patchi/core/security/runtime_validator.py +245 -0
  405. patchi/core/security/saml_sso_agent.py +401 -0
  406. patchi/core/security/sast_agent.py +213 -0
  407. patchi/core/security/sast_gate.py +95 -0
  408. patchi/core/security/scheduler.py +287 -0
  409. patchi/core/security/secrets_runtime_agent.py +476 -0
  410. patchi/core/security/security_agents.py +175 -0
  411. patchi/core/security/security_config.py +551 -0
  412. patchi/core/security/security_probe.py +762 -0
  413. patchi/core/security/security_taint.py +1005 -0
  414. patchi/core/security/sensitive_data_agent.py +271 -0
  415. patchi/core/security/service_mesh_agent.py +412 -0
  416. patchi/core/security/session_management_agent.py +245 -0
  417. patchi/core/security/ssrf_agent.py +422 -0
  418. patchi/core/security/supply_chain.py +449 -0
  419. patchi/core/security/threat_model_generator.py +378 -0
  420. patchi/core/security/threat_model_updater.py +196 -0
  421. patchi/core/security/tool_adapters.py +135 -0
  422. patchi/core/security/tool_verify.py +173 -0
  423. patchi/core/security/websocket_security_agent.py +188 -0
  424. patchi/core/snapshot.py +242 -0
  425. patchi/core/tenant.py +386 -0
  426. patchi/core/testing/__init__.py +17 -0
  427. patchi/core/testing/_browser.py +147 -0
  428. patchi/core/testing/ads_agent.py +141 -0
  429. patchi/core/testing/api_contract_agent.py +450 -0
  430. patchi/core/testing/app_discovery_agent.py +641 -0
  431. patchi/core/testing/app_launcher.py +184 -0
  432. patchi/core/testing/browser_test_agent.py +516 -0
  433. patchi/core/testing/e2e_flow_agent.py +354 -0
  434. patchi/core/testing/flake_detector_agent.py +292 -0
  435. patchi/core/testing/gate.py +31 -0
  436. patchi/core/testing/live_test_runner.py +304 -0
  437. patchi/core/testing/live_v2/__init__.py +1 -0
  438. patchi/core/testing/live_v2/browser_pool.py +349 -0
  439. patchi/core/testing/live_v2/browser_test_runner.py +313 -0
  440. patchi/core/testing/live_v2/runner.py +626 -0
  441. patchi/core/testing/live_v2/screenshot_manager.py +474 -0
  442. patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
  443. patchi/core/testing/live_v2/video_recorder.py +303 -0
  444. patchi/core/testing/regression_agent.py +302 -0
  445. patchi/core/testing/security_test_agent.py +253 -0
  446. patchi/core/testing/stress_test_agent.py +203 -0
  447. patchi/core/testing/test_agents.py +50 -0
  448. patchi/core/testing/test_config.py +194 -0
  449. patchi/core/testing/ui_accessibility_agent.py +324 -0
  450. patchi/core/testing/ui_button_agent.py +294 -0
  451. patchi/core/testing/ui_layout_agent.py +330 -0
  452. patchi/core/testing/unit_test_agent.py +1149 -0
  453. patchi/core/testing/visual_regression_agent.py +260 -0
  454. patchi/web/__init__.py +1 -0
  455. patchi/web/api/__init__.py +1 -0
  456. patchi/web/api/brain_map.py +114 -0
  457. patchi/web/api/charts.py +47 -0
  458. patchi/web/api/chat.py +224 -0
  459. patchi/web/api/cicd.py +576 -0
  460. patchi/web/api/dast_evidence.py +49 -0
  461. patchi/web/api/dev_check.py +320 -0
  462. patchi/web/api/fix.py +134 -0
  463. patchi/web/api/guard.py +65 -0
  464. patchi/web/api/hosted.py +128 -0
  465. patchi/web/api/hosted_v2.py +234 -0
  466. patchi/web/api/live_testing.py +699 -0
  467. patchi/web/api/scan.py +582 -0
  468. patchi/web/api/smart.py +111 -0
  469. patchi/web/api/tenant.py +117 -0
  470. patchi/web/api/tools.py +85 -0
  471. patchi/web/api_legacy.py +2401 -0
  472. patchi/web/app.py +310 -0
  473. patchi/web/events.py +483 -0
  474. patchi/web/routes/__init__.py +1 -0
  475. patchi/web/routes/assurance.py +541 -0
  476. patchi/web/routes/brain.py +36 -0
  477. patchi/web/routes/charter.py +227 -0
  478. patchi/web/routes/chat.py +23 -0
  479. patchi/web/routes/dashboard.py +558 -0
  480. patchi/web/routes/dashboard_v2.py +402 -0
  481. patchi/web/routes/findings.py +330 -0
  482. patchi/web/routes/guard.py +35 -0
  483. patchi/web/routes/history.py +126 -0
  484. patchi/web/routes/landing.py +52 -0
  485. patchi/web/routes/live_testing.py +24 -0
  486. patchi/web/routes/review.py +57 -0
  487. patchi/web/routes/self_improvement.py +110 -0
  488. patchi/web/routes/settings.py +49 -0
  489. patchi/web/routes/tokens.py +29 -0
  490. patchi/web/spawn.py +139 -0
  491. patchi/web/ws.py +141 -0
  492. patchi-0.7.5.dist-info/METADATA +315 -0
  493. patchi-0.7.5.dist-info/RECORD +496 -0
  494. patchi-0.7.5.dist-info/WHEEL +5 -0
  495. patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
  496. patchi-0.7.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2401 @@
1
+ """
2
+ REST API endpoints for the Patchi web UI (legacy, consolidated).
3
+
4
+ Mounted on the FastAPI app by app.py (legacy api.py renamed to avoid
5
+ shadowing the api/ package directory). All endpoints read from project
6
+ root stored in app.state.root.
7
+
8
+ Routes:
9
+ GET /api/status — mode, queue depth, brain freshness, agent count
10
+ GET /api/config — full config dict (no secrets)
11
+ POST /api/config — update one config key
12
+ GET /api/queue — queue items list
13
+ POST /api/queue/pause — pause the queue
14
+ POST /api/queue/resume — resume the queue
15
+ GET /api/findings — latest scan findings from memory
16
+ GET /api/brain/nodes — Brain Map node list (files + metrics)
17
+ GET /api/ants — active user-spawned ants
18
+ GET /api/review — pending fixes for review
19
+ GET /api/history — scan and fix history
20
+ GET /api/security — security findings
21
+ GET /api/tests — test results
22
+ GET /api/memory — memory categories and data
23
+ GET /api/memory/brain — brain knowledge viewer
24
+ GET /api/memory/patches — patch history
25
+ GET /api/memory/issues — known issues
26
+ GET /api/memory/failed — failed patches
27
+ GET /api/memory/scans — scan results
28
+ GET /api/memory/restrictions — restrictions
29
+ GET /api/memory/tokens — dev tokens
30
+ POST /api/security/quick-scan — trigger quick security scan
31
+ POST /api/security/full-scan — trigger full security scan
32
+ POST /api/tests/create-suite — create test suite
33
+ POST /api/hosted/init — initialize hosted mode
34
+ POST /api/fix/accept — accept a pending fix
35
+ POST /api/fix/reject — reject a pending fix
36
+ POST /api/fix/apply — apply a specific patch
37
+ POST /api/patch/apply — apply a specific patch
38
+ POST /api/patch/reject — reject a specific patch
39
+ POST /api/patch/delete — delete a patch from history
40
+ POST /api/issue/resolve — resolve a known issue
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import logging
46
+ from datetime import UTC
47
+ from pathlib import Path
48
+
49
+ from fastapi import APIRouter, HTTPException, Request
50
+ from fastapi.responses import JSONResponse
51
+
52
+ import patchi.core.config as cfg_mod
53
+ import patchi.core.health as health_mod
54
+ import patchi.core.memory as memory_mod
55
+ import patchi.core.queue as queue_mod
56
+ from patchi.core.constants import PROVIDERS, MemoryCategory
57
+
58
+ logger = logging.getLogger("patchi.web.api")
59
+
60
+ router = APIRouter(prefix="/api")
61
+
62
+
63
+ def _root(request: Request) -> Path:
64
+ return request.app.state.root
65
+
66
+
67
+ def _error_response(message: str, status_code: int = 500) -> JSONResponse:
68
+ """Standard error response with consistent format."""
69
+ return JSONResponse(
70
+ {"ok": False, "error": message},
71
+ status_code=status_code,
72
+ )
73
+
74
+
75
+ def _safe_json(request: Request, default=None):
76
+ """Safely parse JSON body from request, return default on failure."""
77
+ try:
78
+ import json as _json
79
+
80
+ body = request._body
81
+ if body is None:
82
+ return default
83
+ return _json.loads(body)
84
+ except Exception:
85
+ return default
86
+
87
+
88
+ # ── Status ────────────────────────────────────────────────────────────────────
89
+
90
+
91
+ @router.get("/status")
92
+ async def get_status(request: Request) -> JSONResponse:
93
+ root = _root(request)
94
+ try:
95
+ cfg = cfg_mod.load(root)
96
+ health = health_mod.compute(root)
97
+ spawner = request.app.state.spawner
98
+ from patchi.core.ai.cost_tracker import check_budget, get_stats
99
+
100
+ ai_stats = get_stats()
101
+ budget_warn = check_budget(cfg)
102
+ return JSONResponse(
103
+ {
104
+ "mode": cfg.get("mode", "confirm"),
105
+ "queue_depth": queue_mod.depth(root),
106
+ "queue_paused": queue_mod.is_paused(root),
107
+ "brain_fresh": health.total > 50,
108
+ "active_ants": spawner.active_count(),
109
+ "patchi_active": getattr(request.app.state.spawner, "_is_active", False),
110
+ "health_score": health.total,
111
+ "brain_last_scan": getattr(health, "last_scan_time", "unknown"),
112
+ "ai_calls": ai_stats.get("calls", 0),
113
+ "ai_tokens": ai_stats.get("total_tokens", 0),
114
+ "ai_cost": round(ai_stats.get("cost_estimate", 0.0), 6),
115
+ "ai_budget_warning": budget_warn or "",
116
+ }
117
+ )
118
+ except Exception as exc:
119
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
120
+
121
+
122
+ # ── Config ────────────────────────────────────────────────────────────────────
123
+
124
+ _SAFE_KEYS = frozenset(
125
+ {
126
+ "mode",
127
+ "theme",
128
+ "fps",
129
+ "device_tier",
130
+ "scan_depth",
131
+ "risk_threshold",
132
+ "dead_code_confirmation",
133
+ "show_confidence_scores",
134
+ "brain_freshness_any_change",
135
+ "digest_frequency",
136
+ "ollama_model",
137
+ "cost_limit",
138
+ "enable_cost_limit",
139
+ "lang_js",
140
+ "lang_ts",
141
+ "lang_py",
142
+ "lang_html",
143
+ "lang_css",
144
+ "scan_ignore_paths",
145
+ "scan_depth_limit",
146
+ "auto_rescan_on_save",
147
+ "risk_threshold_auto",
148
+ "require_blast_radius",
149
+ "max_fixes_per_cycle",
150
+ "queue_mode",
151
+ "max_parallel_agents",
152
+ "max_queue_depth",
153
+ "context_window_size",
154
+ "websocket_timeout",
155
+ "canvas_max_nodes",
156
+ "quiet_hours_start",
157
+ "quiet_hours_end",
158
+ "quiet_hours_timezone",
159
+ "temperature_scanner",
160
+ "temperature_security",
161
+ "temperature_fixer",
162
+ "temperature_tester",
163
+ "context_window_override",
164
+ "websocket_timeout_s",
165
+ "canvas_max_nodes_before_clustering",
166
+ "canvas_max_visible_ants",
167
+ "max_parallel_scanners",
168
+ "max_parallel_security",
169
+ "max_active_fix_agents",
170
+ "max_parallel_testers",
171
+ "max_total_agents_in_queue",
172
+ "animation_speed",
173
+ "font_size",
174
+ "compact_mode",
175
+ "language",
176
+ "local_model_path",
177
+ "key_priority_order",
178
+ "fallback_enabled",
179
+ "cost_limit_per_run",
180
+ "languages_include",
181
+ "paths_ignore",
182
+ "brain_freshness_threshold",
183
+ "auto_rescan_on_file_save",
184
+ "risk_threshold_for_auto_mode",
185
+ "require_blast_radius_high_risk",
186
+ "show_confidence_score_fix_cards",
187
+ "queue_mode_selector",
188
+ "max_parallel_agents_per_category",
189
+ "max_queue_depth_setting",
190
+ "hidden_by_default",
191
+ "temperature_per_agent",
192
+ "context_window_size_override",
193
+ "canvas_max_nodes_before_clustering_setting",
194
+ "canvas_max_visible_ants_setting",
195
+ }
196
+ )
197
+
198
+
199
+ @router.get("/config")
200
+ async def get_config(request: Request) -> JSONResponse:
201
+ root = _root(request)
202
+ try:
203
+ raw = cfg_mod.load(root)
204
+ # Strip sensitive keys before sending to browser
205
+ safe = {k: v for k, v in raw.items() if k in _SAFE_KEYS}
206
+ return JSONResponse(safe)
207
+ except Exception as e:
208
+ return JSONResponse({"error": str(e)}, status_code=500)
209
+
210
+
211
+ # /config POST is defined at end of file with full bulk+single support
212
+
213
+
214
+ # ── Queue ─────────────────────────────────────────────────────────────────────
215
+
216
+
217
+ @router.get("/queue")
218
+ async def get_queue(request: Request) -> JSONResponse:
219
+ root = _root(request)
220
+ try:
221
+ return JSONResponse(
222
+ {
223
+ "items": queue_mod.list_items(root=root),
224
+ "depth": queue_mod.depth(root),
225
+ "paused": queue_mod.is_paused(root),
226
+ }
227
+ )
228
+ except Exception as e:
229
+ return JSONResponse({"items": [], "depth": 0, "paused": False, "error": str(e)})
230
+
231
+
232
+ @router.post("/queue/pause")
233
+ async def pause_queue(request: Request) -> JSONResponse:
234
+ try:
235
+ queue_mod.pause(_root(request))
236
+ return JSONResponse({"ok": True, "paused": True})
237
+ except Exception as e:
238
+ return _error_response(str(e))
239
+
240
+
241
+ @router.post("/queue/resume")
242
+ async def resume_queue(request: Request) -> JSONResponse:
243
+ try:
244
+ queue_mod.resume(_root(request))
245
+ return JSONResponse({"ok": True, "paused": False})
246
+ except Exception as e:
247
+ return _error_response(str(e))
248
+
249
+
250
+ # ── Findings ──────────────────────────────────────────────────────────────────
251
+
252
+
253
+ @router.get("/findings")
254
+ async def get_findings(request: Request) -> JSONResponse:
255
+ root = _root(request)
256
+ try:
257
+ issues = memory_mod.list_issues(root)
258
+ # Also include scanner findings from scan results
259
+ scans = memory_mod.get_scan_results(root)
260
+ scanner_findings = []
261
+ for _agent_name, scan_data in scans.items():
262
+ for f in scan_data.get("findings", []):
263
+ if isinstance(f, dict):
264
+ scanner_findings.append(f)
265
+ all_findings = issues + scanner_findings
266
+ return JSONResponse({"findings": all_findings})
267
+ except Exception:
268
+ return JSONResponse({"findings": []})
269
+
270
+
271
+ # ── Brain Map nodes ───────────────────────────────────────────────────────────
272
+
273
+
274
+ @router.get("/brain/nodes")
275
+ async def get_brain_nodes(request: Request) -> JSONResponse:
276
+ """Return brain map nodes + edges from the import graph stored in brain memory."""
277
+ root = _root(request)
278
+ try:
279
+ brain_data = memory_mod.read(MemoryCategory.BRAIN, root=root)
280
+ if not isinstance(brain_data, dict):
281
+ brain_data = {}
282
+
283
+ # Build nodes from import_graph
284
+ import_graph = brain_data.get("import_graph", {})
285
+ graph_nodes = import_graph.get("nodes", [])
286
+ graph_edges_raw = import_graph.get("edges", {})
287
+
288
+ # Build per-file findings map from scan results
289
+ scan_results = memory_mod.read(MemoryCategory.SCANS, root=root)
290
+ findings_by_file = {}
291
+ if isinstance(scan_results, dict):
292
+ for _scanner_name, scanner_data in scan_results.items():
293
+ findings = (
294
+ scanner_data.get("findings", []) if isinstance(scanner_data, dict) else []
295
+ )
296
+ for f in findings:
297
+ fpath = f.get("file", "")
298
+ if not fpath:
299
+ continue
300
+ fentry = findings_by_file.setdefault(fpath, {"count": 0, "severities": []})
301
+ fentry["count"] += 1
302
+ sev = f.get("severity", "low")
303
+ fentry["severities"].append(sev)
304
+
305
+ nodes = []
306
+ for node_id in graph_nodes:
307
+ # Determine node type from file extension / role
308
+ ntype = "default"
309
+ fname = node_id.split("/")[-1].lower()
310
+ if node_id.endswith((".test.", ".spec.")) or fname.startswith("test_"):
311
+ ntype = "test"
312
+ elif node_id.endswith(
313
+ ("config.py", "settings.py", ".env", "config.json", "config.yaml")
314
+ ):
315
+ ntype = "config"
316
+ elif node_id in brain_data.get("dead_files", []):
317
+ ntype = "dead"
318
+ elif fname in ("main.py", "app.py", "index.py", "__init__.py", "server.py", "cli.py"):
319
+ ntype = "entry_point"
320
+ else:
321
+ # Check if file has routes (is a route handler)
322
+ routes = brain_data.get("routes", [])
323
+ if isinstance(routes, list):
324
+ for route in routes:
325
+ if isinstance(route, dict) and route.get("file") == node_id:
326
+ ntype = "route_handler"
327
+ break
328
+ elif hasattr(route, "file") and route.file == node_id:
329
+ ntype = "route_handler"
330
+ break
331
+ if ntype == "default":
332
+ if "sensitive" in fname or "secret" in fname or "credential" in fname:
333
+ ntype = "sensitive"
334
+ elif any(
335
+ r.get("path") == node_id
336
+ for r in (
337
+ brain_data.get("restrictions", [])
338
+ if isinstance(brain_data.get("restrictions"), list)
339
+ else []
340
+ )
341
+ ):
342
+ ntype = "restricted"
343
+
344
+ finfo = findings_by_file.get(node_id, {"count": 0, "severities": []})
345
+ sevs = finfo["severities"]
346
+ max_sev = "info"
347
+ if any(s == "critical" for s in sevs):
348
+ max_sev = "critical"
349
+ elif any(s == "high" for s in sevs):
350
+ max_sev = "high"
351
+ elif any(s == "medium" for s in sevs):
352
+ max_sev = "medium"
353
+ elif any(s == "low" for s in sevs):
354
+ max_sev = "low"
355
+
356
+ nodes.append(
357
+ {
358
+ "id": node_id,
359
+ "path": node_id,
360
+ "label": node_id.split("/")[-1],
361
+ "type": ntype,
362
+ "finding_count": finfo["count"],
363
+ "severity": max_sev,
364
+ }
365
+ )
366
+
367
+ edges = []
368
+ for source, targets in graph_edges_raw.items():
369
+ for target in targets:
370
+ edges.append({"from": source, "to": target, "type": "import_dependency"})
371
+
372
+ return JSONResponse({"nodes": nodes, "edges": edges})
373
+ except Exception:
374
+ return JSONResponse({"nodes": [], "edges": []})
375
+
376
+
377
+ # ── Active ants ───────────────────────────────────────────────────────────────
378
+
379
+
380
+ @router.get("/ants")
381
+ async def get_ants(request: Request) -> JSONResponse:
382
+ spawner = request.app.state.spawner
383
+ return JSONResponse({"ants": spawner.all_active()})
384
+
385
+
386
+ # ── Review ────────────────────────────────────────────────────────────────────
387
+
388
+
389
+ @router.get("/review")
390
+ async def get_review(request: Request) -> JSONResponse:
391
+ """Get pending fixes for review."""
392
+ root = _root(request)
393
+ try:
394
+ # Get all patches and filter for pending ones
395
+ all_patches = memory_mod.list_patches(root)
396
+ # Filter for patches that are not yet applied (pending review)
397
+ pending_patches = [p for p in all_patches if p.get("state") in ["proposed", "pending"]]
398
+ return JSONResponse({"pending_patches": pending_patches})
399
+ except Exception:
400
+ return JSONResponse({"pending_patches": []})
401
+
402
+
403
+ @router.post("/fix/accept")
404
+ async def accept_fix(request: Request) -> JSONResponse:
405
+ """Accept a pending fix — apply it to disk via PatchApplier."""
406
+ try:
407
+ body = await request.json()
408
+ patch_id = body.get("patch_id")
409
+ root = _root(request)
410
+
411
+ # Load patch from memory
412
+ patch_data = memory_mod.get_patch(patch_id, root)
413
+ if not patch_data:
414
+ return JSONResponse(
415
+ {"ok": False, "error": f"Patch {patch_id} not found"}, status_code=404
416
+ )
417
+
418
+ from patchi.core.fix.applier import PatchApplier
419
+ from patchi.core.fix.patch import Patch, PatchState
420
+
421
+ patch = Patch.from_dict(patch_data)
422
+ patch.state = PatchState.APPLYING
423
+ applier = PatchApplier(root)
424
+ result = applier.apply(patch)
425
+
426
+ if result.success:
427
+ patch.state = PatchState.APPLIED
428
+ memory_mod.save_patch(patch.to_dict(), root)
429
+ try:
430
+ import asyncio
431
+
432
+ from patchi.web.events import evt_review_updated, manager
433
+
434
+ pending = len(
435
+ [
436
+ p
437
+ for p in memory_mod.list_patches(root)
438
+ if p.get("state") in ("proposed", "pending")
439
+ ]
440
+ )
441
+ asyncio.get_running_loop().create_task(
442
+ manager.broadcast(evt_review_updated(pending))
443
+ )
444
+ except Exception as exc:
445
+ logger.warning("Failed to broadcast review_updated event: %s", exc)
446
+ return JSONResponse({"ok": True, "message": f"Patch {patch_id} applied"})
447
+ else:
448
+ patch.state = PatchState.FAILED
449
+ memory_mod.save_patch(patch.to_dict(), root)
450
+ memory_mod.save_failed(patch_id, result.error, root=root)
451
+ return JSONResponse({"ok": False, "error": result.error})
452
+ except Exception as e:
453
+ return _error_response(str(e))
454
+
455
+
456
+ @router.post("/fix/reject")
457
+ async def reject_fix(request: Request) -> JSONResponse:
458
+ """Reject a pending fix — mark as rejected in memory."""
459
+ try:
460
+ body = await request.json()
461
+ patch_id = body.get("patch_id")
462
+ root = _root(request)
463
+
464
+ patch_data = memory_mod.get_patch(patch_id, root)
465
+ if not patch_data:
466
+ return _error_response(f"Patch {patch_id} not found", status_code=404)
467
+
468
+ from patchi.core.fix.patch import Patch, PatchState
469
+
470
+ patch = Patch.from_dict(patch_data)
471
+ patch.state = PatchState.REJECTED
472
+ memory_mod.save_patch(patch.to_dict(), root)
473
+ return JSONResponse({"ok": True, "message": f"Patch {patch_id} rejected"})
474
+ except Exception as e:
475
+ return _error_response(str(e))
476
+
477
+
478
+ # ── History ───────────────────────────────────────────────────────────────────
479
+
480
+
481
+ @router.get("/history")
482
+ async def get_history(request: Request) -> JSONResponse:
483
+ """Get scan and fix history."""
484
+ root = _root(request)
485
+ try:
486
+ # Get patch history and other historical data
487
+ patches = memory_mod.list_patches(root)
488
+ scan_results = memory_mod.get_scan_results(root)
489
+ failed_patches = memory_mod.list_failed(root)
490
+
491
+ return JSONResponse(
492
+ {
493
+ "patches": patches,
494
+ "scan_results": scan_results,
495
+ "failed_patches": failed_patches,
496
+ "health_history": [], # Would come from health module
497
+ }
498
+ )
499
+ except Exception:
500
+ return JSONResponse({"patches": [], "scan_results": {}, "failed_patches": []})
501
+
502
+
503
+ # ── Security ──────────────────────────────────────────────────────────────────
504
+
505
+
506
+ @router.get("/security")
507
+ async def get_security(request: Request) -> JSONResponse:
508
+ """Get security findings."""
509
+ root = _root(request)
510
+ try:
511
+ # Get security-related findings from issues
512
+ all_issues = memory_mod.list_issues(root)
513
+ security_findings = [issue for issue in all_issues if issue.get("category") == "security"]
514
+ return JSONResponse({"findings": security_findings})
515
+ except Exception:
516
+ return JSONResponse({"findings": []})
517
+
518
+
519
+ @router.post("/security/quick-scan")
520
+ async def post_quick_security_scan(request: Request) -> JSONResponse:
521
+ """Trigger a quick security scan — runs targeted agents (taint, secrets, config)."""
522
+ import asyncio
523
+
524
+ root = _root(request)
525
+
526
+ async def _run():
527
+ from patchi.web.events import (
528
+ evt_security_finding,
529
+ evt_security_scan_completed,
530
+ evt_security_scan_started,
531
+ manager,
532
+ )
533
+
534
+ try:
535
+ import patchi.core.security.security_agents # noqa: F401
536
+ from patchi.core.agents.coordinator import Coordinator
537
+
538
+ await manager.broadcast(evt_security_scan_started("quick"))
539
+ coord = Coordinator(root)
540
+ results = await asyncio.to_thread(
541
+ coord.run_agents, ["TaintAnalyzer", "SecretScanner", "ConfigAuditAgent"]
542
+ )
543
+
544
+ counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
545
+ for r in results:
546
+ for f in r.findings:
547
+ sev = f.severity.value if hasattr(f.severity, "value") else str(f.severity)
548
+ counts[sev] = counts.get(sev, 0) + 1
549
+ await manager.broadcast(
550
+ evt_security_finding(sev, f.type, f.file, f.line, "", f.title)
551
+ )
552
+ await manager.broadcast(
553
+ evt_security_scan_completed(
554
+ counts["critical"], counts["high"], counts["medium"], counts["low"]
555
+ )
556
+ )
557
+ except Exception as exc:
558
+ logger.warning("Quick security scan failed: %s", exc)
559
+
560
+ asyncio.create_task(_run())
561
+ return JSONResponse({"ok": True, "message": "Quick security scan started"})
562
+
563
+
564
+ @router.post("/security/full-scan")
565
+ async def post_full_security_scan(request: Request) -> JSONResponse:
566
+ """Trigger a full security scan — runs all 19 defensive security agents."""
567
+ import asyncio
568
+
569
+ root = _root(request)
570
+
571
+ async def _run():
572
+ from patchi.web.events import (
573
+ evt_security_finding,
574
+ evt_security_scan_completed,
575
+ evt_security_scan_started,
576
+ manager,
577
+ )
578
+
579
+ try:
580
+ import patchi.core.security.security_agents # noqa: F401
581
+ from patchi.core.agents.base import AgentGroup
582
+ from patchi.core.agents.coordinator import Coordinator
583
+
584
+ await manager.broadcast(evt_security_scan_started("full"))
585
+ coord = Coordinator(root)
586
+ results = await asyncio.to_thread(coord.run_group, AgentGroup.SECURITY)
587
+
588
+ # Orchestrate: dedup, correlate, score
589
+ from patchi.core.security.orchestrator import SecurityOrchestrator
590
+
591
+ orch = SecurityOrchestrator()
592
+ report = orch.correlate(results)
593
+
594
+ counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
595
+ for c in report.findings:
596
+ sev = c.finding.severity.value
597
+ counts[sev] = counts.get(sev, 0) + 1
598
+ await manager.broadcast(
599
+ evt_security_finding(
600
+ sev,
601
+ c.finding.type,
602
+ c.finding.file,
603
+ c.finding.line,
604
+ c.owasp_category,
605
+ c.finding.message,
606
+ )
607
+ )
608
+ await manager.broadcast(
609
+ evt_security_scan_completed(
610
+ counts["critical"], counts["high"], counts["medium"], counts["low"]
611
+ )
612
+ )
613
+ except Exception as exc:
614
+ logger.warning("Full security scan failed: %s", exc)
615
+
616
+ asyncio.create_task(_run())
617
+ return JSONResponse({"ok": True, "message": "Full security scan started"})
618
+
619
+
620
+ @router.get("/security/report")
621
+ async def get_security_report(request: Request, fresh: str = "0") -> JSONResponse:
622
+ """Orchestrated security report: deduplicated, correlated, OWASP-mapped.
623
+
624
+ Cached-first: by default this correlates the findings already stored in
625
+ memory (instant). A plain GET used to run all ~54 security agents
626
+ synchronously — 3 minutes of freeze per request. Pass ?fresh=1 to force a
627
+ live agent run instead (still synchronous; prefer POST /security/scan for
628
+ the background variant).
629
+ """
630
+ root = _root(request)
631
+ try:
632
+ from patchi.core.agents.base import AgentGroup, AgentResult, Finding
633
+ from patchi.core.security.orchestrator import SecurityOrchestrator
634
+
635
+ orch = SecurityOrchestrator()
636
+
637
+ if fresh != "1":
638
+ results = memory_mod.get_scan_results(root)
639
+ synthesized: list[AgentResult] = []
640
+ last_scan = ""
641
+ for agent_name, data in results.items():
642
+ if not isinstance(data, dict):
643
+ continue
644
+ ts = data.get("timestamp", "")
645
+ if ts > last_scan:
646
+ last_scan = ts
647
+ agent_result = AgentResult(agent_name=agent_name, agent_group=AgentGroup.SECURITY)
648
+ for f in data.get("findings", []):
649
+ if isinstance(f, dict):
650
+ agent_result.findings.append(Finding.from_dict(f))
651
+ synthesized.append(agent_result)
652
+
653
+ if synthesized:
654
+ report = orch.correlate(synthesized)
655
+ payload = report.to_dict()
656
+ payload["cached"] = True
657
+ payload["last_scan"] = last_scan
658
+ payload["hint"] = "Pass ?fresh=1 to re-run agents live."
659
+ return JSONResponse(payload)
660
+
661
+ if fresh != "1":
662
+ # Nothing cached yet and no explicit fresh request — never run
663
+ # a multi-minute agent suite on a plain GET.
664
+ return JSONResponse(
665
+ {
666
+ "total_findings": 0,
667
+ "by_severity": {},
668
+ "by_owasp": {},
669
+ "agents_run": [],
670
+ "correlation_count": 0,
671
+ "findings": [],
672
+ "cached": True,
673
+ "last_scan": "",
674
+ "hint": "No scan data yet. Run `p security` or ?fresh=1 to populate.",
675
+ }
676
+ )
677
+
678
+ # Explicit fresh run
679
+ import patchi.core.security.security_agents # noqa
680
+ from patchi.core.agents.coordinator import Coordinator
681
+ from patchi.core.security.pattern_context import suppress_findings
682
+
683
+ coord = Coordinator(root)
684
+ results = coord.run_group(AgentGroup.SECURITY)
685
+ for r in results:
686
+ suppress_findings(r)
687
+ report = orch.correlate(results)
688
+ payload = report.to_dict()
689
+ payload["cached"] = False
690
+ return JSONResponse(payload)
691
+ except Exception as e:
692
+ raise HTTPException(status_code=500, detail=str(e)) from e
693
+
694
+
695
+ # ── Tests ─────────────────────────────────────────────────────────────────────
696
+
697
+
698
+ @router.get("/tests")
699
+ async def get_tests(request: Request) -> JSONResponse:
700
+ """Get test results."""
701
+ root = _root(request)
702
+ try:
703
+ # Get test results from memory
704
+ scan_results = memory_mod.get_scan_results(root)
705
+ # Extract test-related results
706
+ test_results = {k: v for k, v in scan_results.items() if "test" in k.lower()}
707
+ return JSONResponse(test_results)
708
+ except Exception:
709
+ return JSONResponse({"unit": [], "browser": [], "stress": []})
710
+
711
+
712
+ @router.post("/tests/create-suite")
713
+ async def post_create_test_suite(request: Request) -> JSONResponse:
714
+ """Create a test suite for the project."""
715
+ try:
716
+ # For now, return success - this would create actual test suite
717
+ return JSONResponse({"ok": True, "message": "Test suite creation started"})
718
+ except Exception as e:
719
+ raise HTTPException(status_code=500, detail=str(e)) from e
720
+
721
+
722
+ # ── Memory ────────────────────────────────────────────────────────────────────
723
+
724
+
725
+ @router.get("/memory")
726
+ async def get_memory(request: Request) -> JSONResponse:
727
+ """Get memory categories and data."""
728
+ root = _root(request)
729
+ try:
730
+ # Return memory data for various categories
731
+ memory_data = {
732
+ "brain_state": memory_mod.read(MemoryCategory.BRAIN, root),
733
+ "patch_history": memory_mod.read(MemoryCategory.PATCHES, root),
734
+ "failed_patches": memory_mod.read(MemoryCategory.FAILED, root),
735
+ "scan_results": memory_mod.read(MemoryCategory.SCANS, root),
736
+ "known_issues": memory_mod.read(MemoryCategory.ISSUES, root),
737
+ "restrictions": memory_mod.read(MemoryCategory.RESTRICTIONS, root),
738
+ "dev_tokens": memory_mod.read(MemoryCategory.TOKENS, root),
739
+ }
740
+ return JSONResponse(memory_data)
741
+ except Exception as e:
742
+ return JSONResponse({"error": str(e)})
743
+
744
+
745
+ @router.get("/memory/brain")
746
+ async def get_memory_brain(request: Request) -> JSONResponse:
747
+ """Get brain knowledge viewer data."""
748
+ root = _root(request)
749
+ try:
750
+ brain_data = memory_mod.get_brain(root)
751
+ return JSONResponse(brain_data)
752
+ except Exception as e:
753
+ return JSONResponse({"error": str(e)})
754
+
755
+
756
+ @router.get("/memory/patches")
757
+ async def get_memory_patches(request: Request) -> JSONResponse:
758
+ """Get patch history."""
759
+ root = _root(request)
760
+ try:
761
+ patches = memory_mod.list_patches(root)
762
+ return JSONResponse({"patches": patches})
763
+ except Exception:
764
+ return JSONResponse({"patches": []})
765
+
766
+
767
+ @router.get("/memory/issues")
768
+ async def get_memory_issues(request: Request) -> JSONResponse:
769
+ """Get known issues."""
770
+ root = _root(request)
771
+ try:
772
+ issues = memory_mod.list_issues(root)
773
+ return JSONResponse({"issues": issues})
774
+ except Exception:
775
+ return JSONResponse({"issues": []})
776
+
777
+
778
+ @router.get("/memory/failed")
779
+ async def get_memory_failed(request: Request) -> JSONResponse:
780
+ """Get failed patches."""
781
+ root = _root(request)
782
+ try:
783
+ failed = memory_mod.list_failed(root)
784
+ return JSONResponse({"failed": failed})
785
+ except Exception:
786
+ return JSONResponse({"failed": []})
787
+
788
+
789
+ @router.get("/memory/scans")
790
+ async def get_memory_scans(request: Request) -> JSONResponse:
791
+ """Get scan results."""
792
+ root = _root(request)
793
+ try:
794
+ scans = memory_mod.get_scan_results(root)
795
+ return JSONResponse({"scans": scans})
796
+ except Exception:
797
+ return JSONResponse({"scans": {}})
798
+
799
+
800
+ @router.get("/memory/restrictions")
801
+ async def get_memory_restrictions(request: Request) -> JSONResponse:
802
+ """Get restrictions."""
803
+ root = _root(request)
804
+ try:
805
+ restrictions = memory_mod.read(MemoryCategory.RESTRICTIONS, root)
806
+ return JSONResponse({"restrictions": restrictions})
807
+ except Exception:
808
+ return JSONResponse({"restrictions": []})
809
+
810
+
811
+ @router.get("/memory/tokens")
812
+ async def get_memory_tokens(request: Request) -> JSONResponse:
813
+ """Get dev tokens."""
814
+ root = _root(request)
815
+ try:
816
+ tokens = memory_mod.list_tokens(root)
817
+ return JSONResponse({"tokens": tokens})
818
+ except Exception:
819
+ return JSONResponse({"tokens": []})
820
+
821
+
822
+ # ── Patch Operations ──────────────────────────────────────────────────────────
823
+
824
+
825
+ @router.post("/patch/apply")
826
+ async def apply_patch(request: Request) -> JSONResponse:
827
+ """Apply a specific patch to disk."""
828
+ try:
829
+ body = await request.json()
830
+ patch_id = body.get("patch_id")
831
+ root = _root(request)
832
+
833
+ patch_data = memory_mod.get_patch(patch_id, root)
834
+ if not patch_data:
835
+ return _error_response(f"Patch {patch_id} not found", status_code=404)
836
+
837
+ from patchi.core.fix.applier import PatchApplier
838
+ from patchi.core.fix.patch import Patch, PatchState
839
+
840
+ patch = Patch.from_dict(patch_data)
841
+ patch.state = PatchState.APPLYING
842
+ applier = PatchApplier(root)
843
+ result = applier.apply(patch)
844
+
845
+ if result.success:
846
+ patch.state = PatchState.APPLIED
847
+ memory_mod.save_patch(patch.to_dict(), root)
848
+ return JSONResponse({"ok": True, "message": f"Patch {patch_id} applied"})
849
+ else:
850
+ patch.state = PatchState.FAILED
851
+ memory_mod.save_patch(patch.to_dict(), root)
852
+ return _error_response(result.error)
853
+ except Exception as e:
854
+ return _error_response(str(e))
855
+
856
+
857
+ @router.post("/patch/reject")
858
+ async def reject_patch(request: Request) -> JSONResponse:
859
+ """Reject a specific patch."""
860
+ try:
861
+ body = await request.json()
862
+ patch_id = body.get("patch_id")
863
+ root = _root(request)
864
+
865
+ patch_data = memory_mod.get_patch(patch_id, root)
866
+ if not patch_data:
867
+ return _error_response(f"Patch {patch_id} not found", status_code=404)
868
+
869
+ from patchi.core.fix.patch import Patch, PatchState
870
+
871
+ patch = Patch.from_dict(patch_data)
872
+ patch.state = PatchState.REJECTED
873
+ memory_mod.save_patch(patch.to_dict(), root)
874
+ return JSONResponse({"ok": True, "message": f"Patch {patch_id} rejected"})
875
+ except Exception as e:
876
+ return _error_response(str(e))
877
+
878
+
879
+ @router.post("/patch/delete")
880
+ async def delete_patch(request: Request) -> JSONResponse:
881
+ """Delete a patch from history by removing it from memory."""
882
+ try:
883
+ body = await request.json()
884
+ patch_id = body.get("patch_id")
885
+ root = _root(request)
886
+
887
+ patches = memory_mod.list_patches(root)
888
+ patches = [p for p in patches if p.get("id") != patch_id]
889
+ from patchi.core.constants import MemoryCategory
890
+
891
+ memory_mod._write(MemoryCategory.PATCHES, patches, root)
892
+ return JSONResponse({"ok": True, "message": f"Patch {patch_id} deleted"})
893
+ except Exception as e:
894
+ return _error_response(str(e))
895
+
896
+
897
+ # ── Issue Operations ──────────────────────────────────────────────────────────
898
+
899
+
900
+ @router.post("/issue/resolve")
901
+ async def resolve_issue(request: Request) -> JSONResponse:
902
+ """Resolve a known issue."""
903
+ try:
904
+ body = await request.json()
905
+ issue_id = body.get("issue_id")
906
+ root = _root(request)
907
+ success = memory_mod.resolve_issue(issue_id, root)
908
+ if success:
909
+ return JSONResponse({"ok": True, "message": f"Issue {issue_id} resolved"})
910
+ else:
911
+ return _error_response(f"Issue {issue_id} not found", status_code=404)
912
+ except Exception as e:
913
+ return _error_response(str(e))
914
+
915
+
916
+ # ── Hosted ────────────────────────────────────────────────────────────────────
917
+ # POST /hosted/init was removed — it duplicated api/hosted.py's /init
918
+ # (identical body) and shadowed it depending on router registration order.
919
+ # The canonical endpoint is POST /api/hosted/init (api/hosted.py).
920
+
921
+
922
+ # ── Scan lifecycle (activates/deactivates tap-to-spawn) ───────────────────────
923
+
924
+
925
+ @router.post("/scan/start")
926
+ async def scan_start(request: Request) -> JSONResponse:
927
+ """
928
+ Called when a scan begins — activates tap-to-spawn.
929
+ The web UI calls this before kicking off a scan via the API.
930
+ """
931
+ try:
932
+ request.app.state.spawner.set_active(True)
933
+ return JSONResponse({"ok": True, "active": True})
934
+ except Exception as e:
935
+ return _error_response(str(e))
936
+
937
+
938
+ @router.post("/scan/stop")
939
+ async def scan_stop(request: Request) -> JSONResponse:
940
+ """Called when a scan ends — deactivates tap-to-spawn."""
941
+ try:
942
+ request.app.state.spawner.set_active(False)
943
+ return JSONResponse({"ok": True, "active": False})
944
+ except Exception as e:
945
+ return _error_response(str(e))
946
+
947
+
948
+ # ── Action triggers (web UI → CLI command equivalent) ─────────────────────────
949
+
950
+
951
+ @router.post("/action/scan")
952
+ async def trigger_scan(request: Request) -> JSONResponse:
953
+ """Trigger a full brain scan + scanner agents in a background thread."""
954
+ import asyncio
955
+
956
+ root = _root(request)
957
+ spawner = request.app.state.spawner
958
+ spawner.set_active(True)
959
+
960
+ async def _run():
961
+ from patchi.web.events import (
962
+ evt_scan_completed,
963
+ evt_scan_failed,
964
+ evt_scan_file_found,
965
+ manager,
966
+ )
967
+ from patchi.web.ws import evt_scan_complete, evt_scan_started
968
+
969
+ try:
970
+ import patchi.core.agents.scanners # noqa: F401 — trigger registration
971
+ from patchi.core.agents.coordinator import Coordinator, CoordinatorProgress
972
+ from patchi.core.brain.brain import Brain, ScanProgress
973
+
974
+ def on_progress(sp: ScanProgress):
975
+ try:
976
+ loop = asyncio.get_running_loop()
977
+ loop.call_soon_threadsafe(
978
+ asyncio.ensure_future,
979
+ manager.broadcast(
980
+ {
981
+ "event": "scan.progress",
982
+ "data": {
983
+ "phase": sp.phase,
984
+ "current": sp.current,
985
+ "total": sp.total,
986
+ "message": sp.message,
987
+ },
988
+ }
989
+ ),
990
+ )
991
+ if sp.file_path:
992
+ loop.call_soon_threadsafe(
993
+ asyncio.ensure_future,
994
+ manager.broadcast(evt_scan_file_found(sp.file_path, "", sp.message)),
995
+ )
996
+ except Exception as exc:
997
+ logger.warning("Scan progress callback error: %s", exc)
998
+
999
+ brain = Brain(root, on_progress=on_progress)
1000
+ await evt_scan_started(0)
1001
+ report = await asyncio.to_thread(brain.scan)
1002
+
1003
+ # Run scanner agents
1004
+ def on_agent_progress(cp: CoordinatorProgress):
1005
+ pass
1006
+
1007
+ coord = Coordinator(root, on_progress=on_agent_progress)
1008
+ scope = list(report.import_graph.nodes) if report.import_graph else []
1009
+ agent_results = await asyncio.to_thread(coord.run_all_scanners, scope=scope)
1010
+
1011
+ total_findings = sum(r.finding_count for r in agent_results)
1012
+ await manager.broadcast(evt_scan_completed(report.file_count, report.route_count, 0))
1013
+ await evt_scan_complete(total_findings, 0)
1014
+ except Exception as e:
1015
+ await manager.broadcast(evt_scan_failed(str(e), {}))
1016
+ finally:
1017
+ spawner.set_active(False)
1018
+
1019
+ asyncio.create_task(_run())
1020
+ return JSONResponse({"ok": True, "message": "Scan started"})
1021
+
1022
+
1023
+ @router.post("/action/scan-deep")
1024
+ async def trigger_scan_deep(request: Request) -> JSONResponse:
1025
+ """Trigger a deep (LLM-assisted) scan."""
1026
+ import asyncio
1027
+
1028
+ root = _root(request)
1029
+ spawner = request.app.state.spawner
1030
+ spawner.set_active(True)
1031
+
1032
+ async def _run():
1033
+ from patchi.web.events import (
1034
+ evt_scan_completed,
1035
+ evt_scan_failed,
1036
+ manager,
1037
+ )
1038
+ from patchi.web.ws import evt_scan_complete, evt_scan_started
1039
+
1040
+ try:
1041
+ import patchi.core.agents.scanners # noqa: F401
1042
+ from patchi.core.agents.coordinator import Coordinator
1043
+ from patchi.core.brain.brain import Brain
1044
+
1045
+ await evt_scan_started(0)
1046
+ brain = Brain(root)
1047
+ report = await asyncio.to_thread(brain.scan)
1048
+
1049
+ coord = Coordinator(root)
1050
+ scope = list(report.import_graph.nodes) if report.import_graph else []
1051
+ agent_results = await asyncio.to_thread(coord.run_all_scanners, scope=scope)
1052
+
1053
+ total_findings = sum(r.finding_count for r in agent_results)
1054
+ await manager.broadcast(evt_scan_completed(report.file_count, report.route_count, 0))
1055
+ await evt_scan_complete(total_findings, 0)
1056
+ except Exception as e:
1057
+ await manager.broadcast(evt_scan_failed(str(e), {}))
1058
+ finally:
1059
+ spawner.set_active(False)
1060
+
1061
+ asyncio.create_task(_run())
1062
+ return JSONResponse({"ok": True, "message": "Deep scan started"})
1063
+
1064
+
1065
+ @router.post("/action/fix")
1066
+ async def trigger_fix(request: Request) -> JSONResponse:
1067
+ """Trigger the full fix cycle: load findings → run fix agents → risk gate → apply/queue."""
1068
+ import asyncio
1069
+
1070
+ root = _root(request)
1071
+
1072
+ async def _run():
1073
+ from patchi.web.events import evt_fix_proposed, manager
1074
+ from patchi.web.ws import evt_fix_applied, evt_scan_complete
1075
+
1076
+ try:
1077
+ import patchi.core.fix.fix_agents # noqa: F401 — trigger registration
1078
+ from patchi.core import memory as mem
1079
+ from patchi.core.agents.base import AgentGroup, list_agents
1080
+ from patchi.core.agents.coordinator import Coordinator
1081
+ from patchi.core.fix.applier import PatchApplier
1082
+ from patchi.core.fix.patch import Patch, PatchState
1083
+ from patchi.core.fix.risk_gate import RiskGate
1084
+
1085
+ issues = mem.list_issues(root)
1086
+ if not issues:
1087
+ await evt_scan_complete(0, 0)
1088
+ return
1089
+
1090
+ fix_classes = list_agents(AgentGroup.FIX)
1091
+ if not fix_classes:
1092
+ await evt_scan_complete(0, 0)
1093
+ return
1094
+
1095
+ coord = Coordinator(root)
1096
+ results = await asyncio.to_thread(
1097
+ coord.run_agents,
1098
+ [c.name for c in fix_classes],
1099
+ scope=None,
1100
+ extra={"findings": issues},
1101
+ )
1102
+
1103
+ # Collect patches from results
1104
+ patches: list[Patch] = []
1105
+ for r in results:
1106
+ if hasattr(r, "data") and r.data and "patch" in r.data:
1107
+ patches.append(r.data["patch"])
1108
+
1109
+ gate = RiskGate(root)
1110
+ applier = PatchApplier(root)
1111
+ applied = 0
1112
+ queued = 0
1113
+
1114
+ for patch in patches:
1115
+ gate_result = gate.evaluate(patch)
1116
+ if gate_result.is_blocked:
1117
+ continue
1118
+ if gate_result.is_auto:
1119
+ result = await asyncio.to_thread(applier.apply, patch)
1120
+ if result.success:
1121
+ applied += 1
1122
+ await evt_fix_applied(patch.id, "")
1123
+ else:
1124
+ patch.state = PatchState.PENDING
1125
+ mem.save_patch(patch.to_dict(), root)
1126
+ queued += 1
1127
+ await manager.broadcast(
1128
+ evt_fix_proposed(patch.id, patch.risk_score, patch.confidence, {}, [])
1129
+ )
1130
+
1131
+ await evt_scan_complete(applied + queued, 0)
1132
+ except Exception as exc:
1133
+ logger.warning("Fix cycle failed: %s", exc)
1134
+
1135
+ asyncio.create_task(_run())
1136
+ return JSONResponse({"ok": True, "message": "Fix cycle started"})
1137
+
1138
+
1139
+ @router.post("/action/security")
1140
+ async def trigger_security(request: Request) -> JSONResponse:
1141
+ """Trigger a security scan in the background."""
1142
+ import asyncio
1143
+
1144
+ body = await request.json()
1145
+ mode = body.get("mode", "full")
1146
+ root = _root(request)
1147
+
1148
+ async def _run():
1149
+ from patchi.web.events import (
1150
+ evt_security_finding,
1151
+ evt_security_scan_completed,
1152
+ evt_security_scan_started,
1153
+ manager,
1154
+ )
1155
+
1156
+ try:
1157
+ import patchi.core.security.security_agents # noqa: F401
1158
+ from patchi.core.agents.base import AgentGroup
1159
+ from patchi.core.agents.coordinator import Coordinator
1160
+
1161
+ await manager.broadcast(evt_security_scan_started(mode))
1162
+ coord = Coordinator(root)
1163
+ results = await asyncio.to_thread(coord.run_group, AgentGroup.SECURITY)
1164
+
1165
+ # Correlate via SecurityOrchestrator (WIRE-01)
1166
+ try:
1167
+ from patchi.core.security.orchestrator import SecurityOrchestrator
1168
+
1169
+ report = SecurityOrchestrator().correlate(results)
1170
+ correlated = report.findings
1171
+ except Exception as exc:
1172
+ logger.warning("SecurityOrchestrator.correlate failed, using fallback: %s", exc)
1173
+ correlated = []
1174
+ for r in results:
1175
+ for f in r.findings:
1176
+
1177
+ class _Wrapped:
1178
+ def __init__(self, finding):
1179
+ self.finding = finding
1180
+ self.owasp_category = ""
1181
+
1182
+ correlated.append(_Wrapped(f))
1183
+
1184
+ counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
1185
+ for c in correlated:
1186
+ sev = (
1187
+ c.finding.severity.value
1188
+ if hasattr(c.finding.severity, "value")
1189
+ else str(c.finding.severity)
1190
+ )
1191
+ counts[sev] = counts.get(sev, 0) + 1
1192
+ await manager.broadcast(
1193
+ evt_security_finding(
1194
+ sev,
1195
+ c.finding.type,
1196
+ c.finding.file,
1197
+ c.finding.line,
1198
+ c.owasp_category,
1199
+ c.finding.title,
1200
+ )
1201
+ )
1202
+
1203
+ await manager.broadcast(
1204
+ evt_security_scan_completed(
1205
+ counts["critical"], counts["high"], counts["medium"], counts["low"]
1206
+ )
1207
+ )
1208
+ except Exception as exc:
1209
+ logger.warning("Security scan background task failed: %s", exc)
1210
+
1211
+ asyncio.create_task(_run())
1212
+ return JSONResponse({"ok": True, "message": f"Security scan ({mode}) started"})
1213
+
1214
+
1215
+ @router.post("/action/test")
1216
+ async def trigger_test(request: Request) -> JSONResponse:
1217
+ """Trigger test suite in the background."""
1218
+ import asyncio
1219
+
1220
+ body = await request.json()
1221
+ kind = body.get("kind", "unit")
1222
+ root = _root(request)
1223
+
1224
+ async def _run():
1225
+ from patchi.web.events import (
1226
+ evt_test_case_passed,
1227
+ evt_test_suite_completed,
1228
+ evt_test_suite_started,
1229
+ manager,
1230
+ )
1231
+
1232
+ try:
1233
+ await manager.broadcast(evt_test_suite_started(kind, 0))
1234
+ import subprocess
1235
+
1236
+ if kind == "unit":
1237
+ import time as _time
1238
+
1239
+ _t0 = _time.monotonic()
1240
+ result = await asyncio.to_thread(
1241
+ subprocess.run,
1242
+ ["python", "-m", "pytest", "tests/", "-q", "--tb=short"],
1243
+ cwd=str(root),
1244
+ capture_output=True,
1245
+ text=True,
1246
+ timeout=120,
1247
+ )
1248
+ duration_ms = int((_time.monotonic() - _t0) * 1000)
1249
+ passed = result.returncode == 0
1250
+ await manager.broadcast(evt_test_case_passed("pytest", duration_ms))
1251
+ await manager.broadcast(
1252
+ evt_test_suite_completed(1 if passed else 0, 0 if passed else 1, 0)
1253
+ )
1254
+ else:
1255
+ await manager.broadcast(evt_test_suite_completed(0, 0, 0))
1256
+ except Exception as exc:
1257
+ logger.warning("Test run background task failed: %s", exc)
1258
+
1259
+ asyncio.create_task(_run())
1260
+ return JSONResponse({"ok": True, "message": f"Test run ({kind}) started"})
1261
+
1262
+
1263
+ @router.get("/health-breakdown")
1264
+ async def get_health_breakdown(request: Request) -> JSONResponse:
1265
+ """Full health score breakdown for the Overview panel."""
1266
+ try:
1267
+ import patchi.core.health as hm
1268
+
1269
+ h = hm.compute(_root(request))
1270
+ return JSONResponse(h.to_dict())
1271
+ except Exception as e:
1272
+ raise HTTPException(status_code=500, detail=str(e)) from e
1273
+
1274
+
1275
+ @router.get("/report/markdown")
1276
+ async def get_report_markdown(request: Request) -> JSONResponse:
1277
+ """Generate a markdown report from latest scan data."""
1278
+ try:
1279
+ from datetime import datetime
1280
+
1281
+ from patchi.core import health as hm
1282
+ from patchi.core import memory as mem
1283
+
1284
+ root = _root(request)
1285
+ brain = mem.get_brain(root)
1286
+ issues = mem.list_issues(root)
1287
+ patches = mem.list_patches(root)
1288
+ health = hm.compute(root)
1289
+
1290
+ # Build the data dict that _render_markdown expects
1291
+ findings_by_sev = {"critical": [], "high": [], "medium": [], "low": []}
1292
+ for issue in issues:
1293
+ sev = issue.get("severity", "low").lower()
1294
+ if sev in findings_by_sev:
1295
+ findings_by_sev[sev].append(issue)
1296
+
1297
+ totals = {k: len(v) for k, v in findings_by_sev.items()}
1298
+
1299
+ data = {
1300
+ "health": health.to_dict(),
1301
+ "project": {
1302
+ "root": str(root),
1303
+ "framework": brain.get("framework", "Unknown"),
1304
+ "file_count": brain.get("file_count", 0),
1305
+ "route_count": brain.get("route_count", 0),
1306
+ "last_scan": brain.get("last_scan", "never"),
1307
+ },
1308
+ "generated_at": datetime.now(UTC).isoformat(),
1309
+ "finding_totals": totals,
1310
+ "findings": findings_by_sev,
1311
+ "patches": [
1312
+ {
1313
+ "id": p.get("id", ""),
1314
+ "file": (p.get("changes", [{}])[0].get("path", "") if p.get("changes") else ""),
1315
+ "status": p.get("state", ""),
1316
+ }
1317
+ for p in patches[-20:]
1318
+ ],
1319
+ "recommendations": [
1320
+ f"Health score is {health.total}/100 — {'good' if health.total >= 80 else 'needs improvement'}",
1321
+ f"{totals['critical']} critical findings to address",
1322
+ f"{len(patches)} patches in history",
1323
+ ],
1324
+ }
1325
+
1326
+ from patchi.cli.commands.report_cmd import _render_markdown
1327
+
1328
+ md = _render_markdown(data)
1329
+ return JSONResponse({"ok": True, "markdown": md})
1330
+ except Exception as e:
1331
+ raise HTTPException(status_code=500, detail=str(e)) from e
1332
+
1333
+
1334
+ # ── Missing endpoints panels.js requires ──────────────────────────────────────
1335
+
1336
+
1337
+ @router.get("/guard")
1338
+ async def get_guard(request: Request) -> JSONResponse:
1339
+ """Guard panel — returns hosted mode status, alerts, and threat data."""
1340
+ root = _root(request)
1341
+ try:
1342
+ import patchi.core.config as cfg
1343
+
1344
+ config = cfg.load(root)
1345
+ hosted = config.get("hosted", {})
1346
+ enabled = hosted.get("enabled", False)
1347
+
1348
+ alerts: list[dict] = []
1349
+ top_threats: list[dict] = []
1350
+ blocked_count = 0
1351
+
1352
+ if enabled:
1353
+ try:
1354
+ import time
1355
+
1356
+ from patchi.core.hosted.audit_log import read_recent
1357
+
1358
+ raw = read_recent(root, 20)
1359
+ for e in raw:
1360
+ if e.get("event") == "anomaly_detected":
1361
+ d = e.get("data", {})
1362
+ alerts.append(
1363
+ {
1364
+ "type": d.get("detector", "anomaly"),
1365
+ "message": d.get("title", "Anomaly detected"),
1366
+ "level": d.get("severity", "medium"),
1367
+ "ip": d.get("ip", ""),
1368
+ "timestamp": time.strftime(
1369
+ "%H:%M:%S", time.localtime(e.get("timestamp", 0))
1370
+ ),
1371
+ }
1372
+ )
1373
+ except Exception as exc:
1374
+ logger.warning("Failed to read audit log for guard panel: %s", exc)
1375
+
1376
+ try:
1377
+ from patchi.core.hosted.watchlist import WatchlistTracker
1378
+
1379
+ tracker = WatchlistTracker(root)
1380
+ top_threats = tracker.top(10)
1381
+ except Exception as exc:
1382
+ logger.warning("Failed to load top threats for guard panel: %s", exc)
1383
+
1384
+ try:
1385
+ from patchi.core.hosted import ip_reputation
1386
+
1387
+ rep_data = ip_reputation._load(root)
1388
+ blocked_count = len(rep_data.get("blocked", []))
1389
+ except Exception as exc:
1390
+ logger.warning("Failed to load IP reputation data for guard panel: %s", exc)
1391
+
1392
+ return JSONResponse(
1393
+ {
1394
+ "connected": enabled,
1395
+ "log_path": hosted.get("log_path", ""),
1396
+ "log_format": hosted.get("log_format", ""),
1397
+ "last_seen": alerts[0]["timestamp"] if alerts else "—",
1398
+ "alerts": alerts,
1399
+ "top_threats": top_threats,
1400
+ "blocked_count": blocked_count,
1401
+ }
1402
+ )
1403
+ except Exception as e:
1404
+ return JSONResponse({"connected": False, "alerts": [], "error": str(e)})
1405
+
1406
+
1407
+ @router.post("/queue/clear")
1408
+ async def clear_queue(request: Request) -> JSONResponse:
1409
+ """Clear all pending queue items."""
1410
+ root = _root(request)
1411
+ try:
1412
+ queue_mod.clear(root)
1413
+ return JSONResponse({"ok": True})
1414
+ except Exception as e:
1415
+ return JSONResponse({"ok": False, "error": str(e)})
1416
+
1417
+
1418
+ @router.post("/memory/clear")
1419
+ async def clear_memory(request: Request) -> JSONResponse:
1420
+ """Clear all memory categories."""
1421
+ root = _root(request)
1422
+ try:
1423
+ memory_mod.clear_all(root)
1424
+ return JSONResponse({"ok": True})
1425
+ except Exception as e:
1426
+ return JSONResponse({"ok": False, "error": str(e)})
1427
+
1428
+
1429
+ @router.post("/config")
1430
+ async def set_config_bulk(request: Request) -> JSONResponse:
1431
+ """
1432
+ Save settings. Accepts either:
1433
+ { "key": "mode", "value": "confirm" } — single key
1434
+ { "mode": "confirm", "device_tier": ... } — bulk update from settings panel
1435
+ """
1436
+ root = _root(request)
1437
+ try:
1438
+ body = await request.json()
1439
+ current = cfg_mod.load(root)
1440
+
1441
+ _ALLOWED = {"mode", "queue_mode", "device_tier", "risk_threshold", "theme", "dev_mode"}
1442
+
1443
+ # Single-key form
1444
+ if "key" in body and "value" in body and len(body) == 2:
1445
+ k = body["key"]
1446
+ if k not in _ALLOWED:
1447
+ return JSONResponse(
1448
+ {"ok": False, "error": f"Key '{k}' is not configurable via web UI"},
1449
+ status_code=400,
1450
+ )
1451
+ current[k] = body["value"]
1452
+ cfg_mod.save(current, root)
1453
+ return JSONResponse({"ok": True})
1454
+
1455
+ # Bulk form — only overwrite allowed keys
1456
+ for k, v in body.items():
1457
+ if k in _ALLOWED:
1458
+ try:
1459
+ current[k] = int(v) if k == "risk_threshold" else v
1460
+ except (ValueError, TypeError):
1461
+ current[k] = v
1462
+ cfg_mod.save(current, root)
1463
+ return JSONResponse({"ok": True})
1464
+ except Exception as e:
1465
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
1466
+
1467
+
1468
+ # ── Key Management (web UI direct key entry) ──────────────────────────────────
1469
+
1470
+
1471
+ @router.get("/keys")
1472
+ async def list_keys_web(request: Request) -> JSONResponse:
1473
+ """List all configured API keys (names + status, no values)."""
1474
+ root = _root(request)
1475
+ try:
1476
+ import os
1477
+
1478
+ import patchi.core.config as cfg
1479
+
1480
+ config = cfg.load(root)
1481
+ raw = config.get("ai", {}).get("keys", [])
1482
+ result = []
1483
+ for k in raw:
1484
+ env_var = k.get("env_var", "")
1485
+ has_val = bool(os.environ.get(env_var) or _read_env_file_key(root, env_var))
1486
+ result.append(
1487
+ {
1488
+ "nickname": k.get("nickname", ""),
1489
+ "provider": k.get("base_url", "").replace("https://", "").split("/")[0],
1490
+ "model": k.get("model", ""),
1491
+ "env_var": env_var,
1492
+ "status": "ok" if has_val else "missing",
1493
+ }
1494
+ )
1495
+ return JSONResponse({"keys": result})
1496
+ except Exception as e:
1497
+ return JSONResponse({"keys": [], "error": str(e)})
1498
+
1499
+
1500
+ @router.post("/keys/add")
1501
+ async def add_key_web(request: Request) -> JSONResponse:
1502
+ """
1503
+ Add an API key directly from the web UI.
1504
+ Body: { provider: str, key: str, nickname?: str }
1505
+ Stores the key in .patchi/.env and records metadata in config.
1506
+ """
1507
+ root = _root(request)
1508
+ try:
1509
+ import patchi.core.config as cfg
1510
+
1511
+ body = await request.json()
1512
+ provider = body.get("provider", "").strip()
1513
+ key_val = body.get("key", "").strip()
1514
+ nickname = body.get("nickname", provider).strip() or provider
1515
+
1516
+ if not provider or not key_val:
1517
+ return JSONResponse(
1518
+ {"ok": False, "error": "provider and key are required"}, status_code=400
1519
+ )
1520
+
1521
+ # Map provider name to config using shared PROVIDERS constant
1522
+ pkey = provider.lower()
1523
+ pconf_raw = PROVIDERS.get(
1524
+ pkey, {"base_url": provider, "model": "gpt-4o-mini", "format": "openai"}
1525
+ )
1526
+ pconf = {
1527
+ "base": pconf_raw.get("base_url", ""),
1528
+ "model": pconf_raw.get("model", "gpt-4o-mini"),
1529
+ "fmt": pconf_raw.get("format", "openai"),
1530
+ }
1531
+
1532
+ env_var = f"PATCHI_KEY_{nickname.upper().replace(' ', '_').replace('-', '_')}"
1533
+
1534
+ # Write to .patchi/.env
1535
+ env_file = root / ".patchi" / ".env"
1536
+ _write_env_file_key(env_file, env_var, key_val)
1537
+
1538
+ # Update config
1539
+ config = cfg.load(root)
1540
+ keys = config.setdefault("ai", {}).setdefault("keys", [])
1541
+ # Remove if already exists by nickname
1542
+ keys[:] = [k for k in keys if k.get("nickname") != nickname]
1543
+ keys.insert(
1544
+ 0,
1545
+ {
1546
+ "nickname": nickname,
1547
+ "base_url": pconf["base"],
1548
+ "model": pconf["model"],
1549
+ "format": pconf["fmt"],
1550
+ "env_var": env_var,
1551
+ "status": "ok",
1552
+ },
1553
+ )
1554
+ cfg.save(config, root)
1555
+
1556
+ return JSONResponse({"ok": True, "env_var": env_var, "nickname": nickname})
1557
+ except Exception as e:
1558
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
1559
+
1560
+
1561
+ @router.post("/keys/remove")
1562
+ async def remove_key_web(request: Request) -> JSONResponse:
1563
+ """Remove a key by nickname."""
1564
+ root = _root(request)
1565
+ try:
1566
+ import patchi.core.config as cfg
1567
+
1568
+ body = await request.json()
1569
+ nickname = body.get("nickname", "").strip()
1570
+ config = cfg.load(root)
1571
+ keys = config.get("ai", {}).get("keys", [])
1572
+ target = next((k for k in keys if k.get("nickname") == nickname), None)
1573
+ if target:
1574
+ env_var = target.get("env_var", "")
1575
+ keys[:] = [k for k in keys if k.get("nickname") != nickname]
1576
+ cfg.save(config, root)
1577
+ if env_var:
1578
+ _remove_env_file_key(root / ".patchi" / ".env", env_var)
1579
+ return JSONResponse({"ok": True})
1580
+ except Exception as e:
1581
+ return JSONResponse({"ok": False, "error": str(e)})
1582
+
1583
+
1584
+ # ── Undo / Redo ──────────────────────────────────────────────────────────────
1585
+
1586
+
1587
+ @router.post("/undo")
1588
+ async def web_undo(request: Request) -> JSONResponse:
1589
+ """Undo last applied patch."""
1590
+ root = _root(request)
1591
+ try:
1592
+ from patchi.cli.commands.undo_cmd import _find_for_undo, _mark_undone
1593
+ from patchi.core.fix.applier import PatchApplier
1594
+ from patchi.core.fix.patch import Patch
1595
+
1596
+ target = _find_for_undo(None, root)
1597
+ if not target:
1598
+ return JSONResponse({"ok": False, "error": "Nothing to undo"})
1599
+ patch = Patch.from_dict(target)
1600
+ if not patch.snapshot_id:
1601
+ return JSONResponse({"ok": False, "error": "Patch has no snapshot"})
1602
+ applier = PatchApplier(root)
1603
+ result = applier.rollback(patch.id, patch.snapshot_id)
1604
+ if result.success:
1605
+ _mark_undone(patch.id, root)
1606
+ return JSONResponse({"ok": True, "patch_id": patch.id})
1607
+ return JSONResponse({"ok": False, "error": result.error})
1608
+ except Exception as e:
1609
+ return JSONResponse({"ok": False, "error": str(e)})
1610
+
1611
+
1612
+ @router.post("/redo")
1613
+ async def web_redo(request: Request) -> JSONResponse:
1614
+ """Redo last undone patch."""
1615
+ root = _root(request)
1616
+ try:
1617
+ from patchi.cli.commands.undo_cmd import _find_for_redo, _mark_applied
1618
+ from patchi.core.fix.applier import PatchApplier
1619
+ from patchi.core.fix.patch import Patch, PatchState
1620
+
1621
+ target = _find_for_redo(None, root)
1622
+ if not target:
1623
+ return JSONResponse({"ok": False, "error": "Nothing to redo"})
1624
+ patch = Patch.from_dict(target)
1625
+ patch.state = PatchState.PENDING
1626
+ applier = PatchApplier(root)
1627
+ result = applier.apply(patch)
1628
+ if result.success:
1629
+ _mark_applied(patch.id, root)
1630
+ return JSONResponse({"ok": True, "patch_id": patch.id})
1631
+ return JSONResponse({"ok": False, "error": result.error})
1632
+ except Exception as e:
1633
+ return JSONResponse({"ok": False, "error": str(e)})
1634
+
1635
+
1636
+ # ── Notifications ────────────────────────────────────────────────────────────
1637
+
1638
+
1639
+ @router.get("/notifications")
1640
+ async def get_notifications(request: Request):
1641
+ """List all notification channels."""
1642
+ root = _root(request)
1643
+ html = False
1644
+ try:
1645
+ import patchi.core.config as cfg
1646
+ from patchi.core.notifications.channels import channel_to_dict, load_channels
1647
+
1648
+ config = cfg.load(root)
1649
+ channels = load_channels(config)
1650
+ html = request.headers.get("hx-request", "").lower() == "true"
1651
+ if html:
1652
+ lst = (
1653
+ "".join(
1654
+ f'<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 8px;border-bottom:1px solid var(--border)">'
1655
+ f'<span><strong>{ch.get("name", "?")}</strong> <span style="color:var(--text-dim)">{ch.get("type", "?")}</span></span>'
1656
+ f'<span style="font-size:11px;color:var(--text-dim)">{ch.get("min_severity", "medium")}+</span>'
1657
+ f"</div>"
1658
+ for ch in [channel_to_dict(ch) for ch in channels]
1659
+ )
1660
+ or '<div style="color:var(--text-dim);padding:8px">No notification channels configured.</div>'
1661
+ )
1662
+ from fastapi.responses import HTMLResponse
1663
+
1664
+ return HTMLResponse(lst)
1665
+ return JSONResponse(
1666
+ {
1667
+ "channels": [channel_to_dict(ch) for ch in channels],
1668
+ }
1669
+ )
1670
+ except Exception as e:
1671
+ if html:
1672
+ from fastapi.responses import HTMLResponse
1673
+
1674
+ return HTMLResponse(f'<div style="color:var(--critical)">Error: {e}</div>')
1675
+ return JSONResponse({"channels": [], "error": str(e)})
1676
+
1677
+
1678
+ @router.post("/notifications/add")
1679
+ async def add_notification(request: Request) -> JSONResponse:
1680
+ """Add a notification channel."""
1681
+ root = _root(request)
1682
+ try:
1683
+ import patchi.core.config as cfg
1684
+ from patchi.core.notifications.channels import from_config
1685
+
1686
+ body = await request.json()
1687
+ entry = from_config(body)
1688
+ config = cfg.load(root)
1689
+ config.setdefault("notifications", []).append(
1690
+ {
1691
+ "name": entry.name,
1692
+ "type": entry.channel_type.value,
1693
+ "apprise_url": entry.apprise_url,
1694
+ "min_severity": entry.min_severity,
1695
+ "in_digest": entry.in_digest,
1696
+ }
1697
+ )
1698
+ cfg.save(config, root)
1699
+ return JSONResponse({"ok": True, "name": entry.name})
1700
+ except Exception as e:
1701
+ return JSONResponse({"ok": False, "error": str(e)})
1702
+
1703
+
1704
+ @router.post("/notifications/test")
1705
+ async def test_notification(request: Request) -> JSONResponse:
1706
+ """Test a notification channel by name."""
1707
+ root = _root(request)
1708
+ try:
1709
+ import patchi.core.config as cfg
1710
+ from patchi.core.notifications.notifier import Notifier
1711
+
1712
+ body = await request.json()
1713
+ name = body.get("name", "")
1714
+ config = cfg.load(root)
1715
+ notifier = Notifier(root, config)
1716
+ results = notifier.test(channel_name=name)
1717
+ ok = any(success for _, success in results)
1718
+ return JSONResponse({"ok": ok, "channel": name, "results": [(ch, s) for ch, s in results]})
1719
+ except Exception as e:
1720
+ return JSONResponse({"ok": False, "error": str(e)})
1721
+
1722
+
1723
+ @router.post("/notifications/remove")
1724
+ async def remove_notification(request: Request) -> JSONResponse:
1725
+ """Remove a notification channel by name."""
1726
+ root = _root(request)
1727
+ try:
1728
+ import patchi.core.config as cfg
1729
+
1730
+ body = await request.json()
1731
+ name = body.get("name", "")
1732
+ if not name:
1733
+ return JSONResponse({"ok": False, "error": "name required"})
1734
+
1735
+ config = cfg.load(root)
1736
+ channels = config.get("notifications", [])
1737
+ config["notifications"] = [ch for ch in channels if ch.get("name") != name]
1738
+ cfg.save(config, root)
1739
+ return JSONResponse({"ok": True, "name": name})
1740
+ except Exception as e:
1741
+ return JSONResponse({"ok": False, "error": str(e)})
1742
+
1743
+
1744
+ # ── Doctor ───────────────────────────────────────────────────────────────────
1745
+
1746
+
1747
+ @router.get("/doctor")
1748
+ async def web_doctor(request: Request) -> JSONResponse:
1749
+ """Run diagnostic checks and return structured results."""
1750
+ root = _root(request)
1751
+ try:
1752
+ import importlib
1753
+ import sys
1754
+
1755
+ checks: list[dict] = []
1756
+
1757
+ pyver = sys.version_info
1758
+ checks.append(
1759
+ {
1760
+ "label": "Python",
1761
+ "ok": pyver >= (3, 11),
1762
+ "note": f"{pyver.major}.{pyver.minor}.{pyver.micro}",
1763
+ }
1764
+ )
1765
+
1766
+ for import_name, pip_name, desc in [
1767
+ ("rich", "rich", "Terminal UI"),
1768
+ ("httpx", "httpx", "HTTP client"),
1769
+ ("fastapi", "fastapi", "Web backend"),
1770
+ ("uvicorn", "uvicorn", "Web server"),
1771
+ ("vulture", "vulture", "Dead code"),
1772
+ ("yaml", "pyyaml", "YAML"),
1773
+ ]:
1774
+ ok = False
1775
+ try:
1776
+ importlib.import_module(import_name)
1777
+ ok = True
1778
+ except ImportError:
1779
+ pass
1780
+ checks.append({"label": f"dep: {pip_name}", "ok": ok, "note": desc})
1781
+
1782
+ config = cfg_mod.load(root)
1783
+ ai_keys = config.get("ai", {}).get("keys", [])
1784
+ checks.append(
1785
+ {
1786
+ "label": "API keys",
1787
+ "ok": len(ai_keys) > 0,
1788
+ "note": f"{len(ai_keys)} key(s)" if ai_keys else "No keys configured",
1789
+ }
1790
+ )
1791
+
1792
+ local_model = config.get("ai", {}).get("local_model_name")
1793
+ checks.append(
1794
+ {
1795
+ "label": "Ollama",
1796
+ "ok": bool(local_model),
1797
+ "note": local_model or "Not configured",
1798
+ }
1799
+ )
1800
+
1801
+ errors = sum(1 for c in checks if not c["ok"])
1802
+ return JSONResponse({"checks": checks, "errors": errors, "warnings": 0})
1803
+ except Exception as e:
1804
+ return JSONResponse({"checks": [], "errors": 1, "warnings": 0, "error": str(e)})
1805
+
1806
+
1807
+ # ── Model management ─────────────────────────────────────────────────────────
1808
+
1809
+
1810
+ @router.get("/model/status")
1811
+ async def get_model_status(request: Request) -> JSONResponse:
1812
+ """Get current AI model configuration and Ollama status."""
1813
+ root = _root(request)
1814
+ try:
1815
+ import httpx
1816
+
1817
+ config = cfg_mod.load(root)
1818
+ ai_cfg = config.get("ai", {})
1819
+ local_model = ai_cfg.get("local_model_name", "")
1820
+
1821
+ ollama_running = False
1822
+ ollama_models: list[str] = []
1823
+ try:
1824
+ from patchi.core.constants import OLLAMA_TAGS_URL
1825
+
1826
+ async with httpx.AsyncClient() as client:
1827
+ resp = await client.get(OLLAMA_TAGS_URL, timeout=3)
1828
+ if resp.status_code == 200:
1829
+ ollama_running = True
1830
+ ollama_models = [m["name"] for m in resp.json().get("models", [])]
1831
+ except Exception as exc:
1832
+ logger.warning("Ollama connection check failed: %s", exc)
1833
+
1834
+ active_keys = ai_cfg.get("keys", [])
1835
+ return JSONResponse(
1836
+ {
1837
+ "local_model": local_model,
1838
+ "ollama_running": ollama_running,
1839
+ "ollama_models": ollama_models,
1840
+ "active_keys": [k.get("nickname", k.get("name", "?")) for k in active_keys],
1841
+ }
1842
+ )
1843
+ except Exception as e:
1844
+ return JSONResponse({"error": str(e)})
1845
+
1846
+
1847
+ @router.post("/model/set")
1848
+ async def set_model(request: Request) -> JSONResponse:
1849
+ """Set active local Ollama model."""
1850
+ root = _root(request)
1851
+ try:
1852
+ body = await request.json()
1853
+ model_name = body.get("model", "").strip()
1854
+ if not model_name:
1855
+ return JSONResponse({"ok": False, "error": "model name required"})
1856
+ config = cfg_mod.load(root)
1857
+ config.setdefault("ai", {})["local_model_name"] = model_name
1858
+ cfg_mod.save(config, root)
1859
+ return JSONResponse({"ok": True, "model": model_name})
1860
+ except Exception as e:
1861
+ return JSONResponse({"ok": False, "error": str(e)})
1862
+
1863
+
1864
+ # ── Agents list ──────────────────────────────────────────────────────────────
1865
+
1866
+
1867
+ @router.get("/agents")
1868
+ async def get_agents(request: Request) -> JSONResponse:
1869
+ """List all registered agents with their status."""
1870
+ try:
1871
+ import patchi.core.agents.scanners # noqa: F401
1872
+ import patchi.core.fix.fix_agents # noqa: F401
1873
+ import patchi.core.security.security_agents # noqa: F401
1874
+ import patchi.core.testing.test_agents # noqa: F401
1875
+ from patchi.core.agents.base import list_agents
1876
+
1877
+ agents = list_agents()
1878
+ result = []
1879
+ for a in agents:
1880
+ result.append(
1881
+ {
1882
+ "name": a.name,
1883
+ "group": a.group.value if hasattr(a.group, "value") else str(a.group),
1884
+ "description": getattr(a, "description", ""),
1885
+ }
1886
+ )
1887
+ return JSONResponse({"agents": result})
1888
+ except Exception as e:
1889
+ return JSONResponse({"agents": [], "error": str(e)})
1890
+
1891
+
1892
+ # ── Watch mode ───────────────────────────────────────────────────────────────
1893
+
1894
+
1895
+ @router.post("/watch/start")
1896
+ async def start_watch(request: Request) -> JSONResponse:
1897
+ """Start file watcher in background thread."""
1898
+ root = _root(request)
1899
+ try:
1900
+ from patchi.core.brain.freshness import BrainWatcher
1901
+
1902
+ watcher = BrainWatcher(root=root, debounce_ms=600)
1903
+ watcher.start()
1904
+ request.app.state.watcher = watcher
1905
+ return JSONResponse({"ok": True})
1906
+ except Exception as e:
1907
+ return JSONResponse({"ok": False, "error": str(e)})
1908
+
1909
+
1910
+ @router.post("/watch/stop")
1911
+ async def stop_watch(request: Request) -> JSONResponse:
1912
+ """Stop file watcher."""
1913
+ try:
1914
+ watcher = getattr(request.app.state, "watcher", None)
1915
+ if watcher:
1916
+ watcher.stop()
1917
+ request.app.state.watcher = None
1918
+ return JSONResponse({"ok": True})
1919
+ except Exception as e:
1920
+ return JSONResponse({"ok": False, "error": str(e)})
1921
+
1922
+
1923
+ def _read_env_file_key(root, env_var: str) -> str:
1924
+ env_file = root / ".patchi" / ".env"
1925
+ if not env_file.exists():
1926
+ return ""
1927
+ for line in env_file.read_text(encoding="utf-8").splitlines():
1928
+ line = line.strip()
1929
+ if line.startswith(f"{env_var}="):
1930
+ return line[len(env_var) + 1 :].strip().strip('"').strip("'")
1931
+ return ""
1932
+
1933
+
1934
+ def _write_env_file_key(env_file, env_var: str, value: str) -> None:
1935
+ env_file = Path(env_file)
1936
+ env_file.parent.mkdir(parents=True, exist_ok=True)
1937
+ lines = env_file.read_text(encoding="utf-8").splitlines() if env_file.exists() else []
1938
+ lines = [line for line in lines if not line.startswith(f"{env_var}=")]
1939
+ lines.append(f'{env_var}="{value}"')
1940
+ env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
1941
+ # Ensure .patchi/.env is in .gitignore
1942
+ gi = env_file.parent.parent / ".gitignore"
1943
+ if gi.exists():
1944
+ content = gi.read_text(encoding="utf-8")
1945
+ if ".patchi/.env" not in content:
1946
+ gi.write_text(content.rstrip() + "\n.patchi/.env\n", encoding="utf-8")
1947
+
1948
+
1949
+ def _remove_env_file_key(env_file, env_var: str) -> None:
1950
+ env_file = Path(env_file)
1951
+ if not env_file.exists():
1952
+ return
1953
+ lines = [
1954
+ line
1955
+ for line in env_file.read_text(encoding="utf-8").splitlines()
1956
+ if not line.startswith(f"{env_var}=")
1957
+ ]
1958
+ env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
1959
+
1960
+
1961
+ # ── Live Test Execution (web UI → live test runner) ───────────────────────────
1962
+
1963
+
1964
+ @router.post("/action/test-live")
1965
+ async def trigger_live_test(request: Request) -> JSONResponse:
1966
+ """
1967
+ Trigger live test execution via the LiveTestRunner.
1968
+ Streams results in real-time via WebSocket events.
1969
+ Body: { "types": ["buttons", "layout", "e2e", "visual", "smoke", "full"], "area": "optional" }
1970
+ """
1971
+ import asyncio
1972
+
1973
+ body = await request.json()
1974
+ test_types = body.get("types", ["smoke"])
1975
+ area = body.get("area")
1976
+ root = _root(request)
1977
+
1978
+ async def _run():
1979
+ from patchi.web.events import manager
1980
+
1981
+ try:
1982
+ from patchi.core.testing.live_test_runner import LiveTestRunner, TestRunConfig
1983
+
1984
+ runner = LiveTestRunner(root)
1985
+
1986
+ # Stream events to WebSocket
1987
+ async def on_event(event: str, data: dict):
1988
+ await manager.broadcast(
1989
+ {"event": f"test.{event}", "data": data, "ts": __import__("time").time()}
1990
+ )
1991
+
1992
+ runner.set_event_callback(on_event)
1993
+
1994
+ config = TestRunConfig(
1995
+ test_types=test_types,
1996
+ area=area,
1997
+ )
1998
+
1999
+ result = await runner.run(config)
2000
+
2001
+ # Final summary event
2002
+ await manager.broadcast(
2003
+ {
2004
+ "event": "test.run_completed",
2005
+ "data": result.to_dict(),
2006
+ "ts": __import__("time").time(),
2007
+ }
2008
+ )
2009
+
2010
+ except Exception as e:
2011
+ await manager.broadcast(
2012
+ {
2013
+ "event": "test.run_failed",
2014
+ "data": {"error": str(e)},
2015
+ "ts": __import__("time").time(),
2016
+ }
2017
+ )
2018
+
2019
+ asyncio.create_task(_run())
2020
+ return JSONResponse({"ok": True, "message": f"Live test run started: {', '.join(test_types)}"})
2021
+
2022
+
2023
+ @router.get("/test-agents")
2024
+ async def list_test_agents(request: Request) -> JSONResponse:
2025
+ """List all available test agents and their types."""
2026
+ try:
2027
+ import patchi.core.testing.e2e_flow_agent # noqa
2028
+ import patchi.core.testing.test_agents # noqa
2029
+ import patchi.core.testing.ui_accessibility_agent # noqa
2030
+ import patchi.core.testing.ui_button_agent # noqa
2031
+ import patchi.core.testing.ui_layout_agent # noqa
2032
+ import patchi.core.testing.visual_regression_agent # noqa
2033
+ from patchi.core.agents.base import AgentGroup, list_agents
2034
+
2035
+ agents = list_agents(AgentGroup.TEST)
2036
+ result = []
2037
+ for a in agents:
2038
+ result.append(
2039
+ {
2040
+ "name": a.name,
2041
+ "description": a.description if hasattr(a, "description") else "",
2042
+ "group": "test",
2043
+ }
2044
+ )
2045
+ return JSONResponse({"agents": result})
2046
+ except Exception as e:
2047
+ return JSONResponse({"agents": [], "error": str(e)})
2048
+
2049
+
2050
+ # ── Restrict endpoints ────────────────────────────────────────────────────────
2051
+
2052
+
2053
+ @router.get("/restrict")
2054
+ async def get_restrictions(request: Request) -> JSONResponse:
2055
+ """List all file restrictions."""
2056
+ root = _root(request)
2057
+ try:
2058
+ restrictions = cfg_mod.get_restrictions(root)
2059
+ return JSONResponse({"restrictions": restrictions})
2060
+ except Exception as e:
2061
+ return JSONResponse({"restrictions": [], "error": str(e)})
2062
+
2063
+
2064
+ @router.post("/restrict/add")
2065
+ async def add_restriction(request: Request) -> JSONResponse:
2066
+ """Add a file restriction."""
2067
+ root = _root(request)
2068
+ try:
2069
+ body = await request.json()
2070
+ path = body.get("path", "")
2071
+ rtype = body.get("type", "no_touch")
2072
+ reason = body.get("reason", "")
2073
+
2074
+ from patchi.core.constants import RestrictionType
2075
+
2076
+ type_map = {
2077
+ "no_touch": RestrictionType.NO_TOUCH,
2078
+ "scan_only": RestrictionType.SCAN_ONLY,
2079
+ "sensitive": RestrictionType.SENSITIVE,
2080
+ }
2081
+ restriction_type = type_map.get(rtype)
2082
+ if not restriction_type:
2083
+ return JSONResponse({"ok": False, "error": f"Invalid type: {rtype}"}, status_code=400)
2084
+
2085
+ cfg_mod.add_restriction(path, restriction_type, reason, root)
2086
+ return JSONResponse({"ok": True, "path": path, "type": rtype})
2087
+ except Exception as e:
2088
+ return JSONResponse({"ok": False, "error": str(e)})
2089
+
2090
+
2091
+ @router.post("/restrict/remove")
2092
+ async def remove_restriction(request: Request) -> JSONResponse:
2093
+ """Remove a file restriction."""
2094
+ root = _root(request)
2095
+ try:
2096
+ body = await request.json()
2097
+ path = body.get("path", "")
2098
+ removed = cfg_mod.remove_restriction(path, root)
2099
+ return JSONResponse({"ok": removed, "path": path})
2100
+ except Exception as e:
2101
+ return JSONResponse({"ok": False, "error": str(e)})
2102
+
2103
+
2104
+ @router.post("/restrict/toggle")
2105
+ async def toggle_restriction(request: Request) -> JSONResponse:
2106
+ """Enable or disable a file restriction."""
2107
+ root = _root(request)
2108
+ try:
2109
+ body = await request.json()
2110
+ path = body.get("path", "")
2111
+ enabled = body.get("enabled", True)
2112
+ ok = cfg_mod.toggle_restriction(path, enabled=enabled, root=root)
2113
+ return JSONResponse({"ok": ok, "path": path, "enabled": enabled})
2114
+ except Exception as e:
2115
+ return JSONResponse({"ok": False, "error": str(e)})
2116
+
2117
+
2118
+ # ── Explain endpoint ──────────────────────────────────────────────────────────
2119
+
2120
+
2121
+ @router.get("/explain")
2122
+ async def explain_findings(request: Request) -> JSONResponse:
2123
+ """Explain findings in plain English."""
2124
+ root = _root(request)
2125
+ try:
2126
+ from patchi.cli.commands.explain_cmd import _EXPLANATIONS
2127
+
2128
+ issues = memory_mod.list_issues(root)
2129
+
2130
+ explanations = []
2131
+ for issue in issues:
2132
+ ftype = issue.get("type", "unknown")
2133
+ if ftype in _EXPLANATIONS:
2134
+ info = _EXPLANATIONS[ftype].copy()
2135
+ info["finding"] = issue
2136
+ explanations.append(info)
2137
+
2138
+ return JSONResponse(
2139
+ {
2140
+ "explanations": explanations,
2141
+ "total": len(issues),
2142
+ "explained": len(explanations),
2143
+ "available_types": list(_EXPLANATIONS.keys()),
2144
+ }
2145
+ )
2146
+ except Exception as e:
2147
+ return JSONResponse({"explanations": [], "error": str(e)})
2148
+
2149
+
2150
+ # ── Blast radius endpoint ─────────────────────────────────────────────────────
2151
+
2152
+
2153
+ @router.get("/blast")
2154
+ async def get_blast_radius(request: Request) -> JSONResponse:
2155
+ """Get blast radius for a file."""
2156
+ root = _root(request)
2157
+ try:
2158
+ from collections import deque
2159
+
2160
+ from patchi.core.brain.import_graph import ImportGraph
2161
+
2162
+ # Build graph
2163
+ graph = ImportGraph()
2164
+ brain = memory_mod.get_brain(root)
2165
+ graph_data = brain.get("import_graph", {})
2166
+
2167
+ # Populate graph from brain data
2168
+ for src, targets in graph_data.get("edges", {}).items():
2169
+ for target in targets:
2170
+ graph.add_edge(src, target)
2171
+
2172
+ if not graph.nodes:
2173
+ return JSONResponse({"error": "No import graph data. Run scan first.", "radii": []})
2174
+
2175
+ # Calculate blast radius for all files
2176
+ radii = []
2177
+ for node in graph.nodes:
2178
+ direct = graph.reverse.get(node, set())
2179
+ all_affected = set()
2180
+ queue = deque(direct)
2181
+ while queue:
2182
+ n = queue.popleft()
2183
+ if n == node or n in all_affected:
2184
+ continue
2185
+ all_affected.add(n)
2186
+ for parent in graph.reverse.get(n, set()):
2187
+ if parent not in all_affected:
2188
+ queue.append(parent)
2189
+
2190
+ risk = "LOW"
2191
+ if len(all_affected) > 10:
2192
+ risk = "CRITICAL"
2193
+ elif len(all_affected) > 3:
2194
+ risk = "HIGH"
2195
+ elif len(all_affected) > 0:
2196
+ risk = "MEDIUM"
2197
+
2198
+ radii.append(
2199
+ {
2200
+ "file": node,
2201
+ "direct": len(direct),
2202
+ "total": len(all_affected),
2203
+ "risk": risk,
2204
+ }
2205
+ )
2206
+
2207
+ radii.sort(key=lambda x: -x["total"])
2208
+ return JSONResponse({"radii": radii[:50]})
2209
+ except Exception as e:
2210
+ return JSONResponse({"radii": [], "error": str(e)})
2211
+
2212
+
2213
+ # ── Audit endpoint ────────────────────────────────────────────────────────────
2214
+
2215
+
2216
+ @router.post("/action/audit")
2217
+ async def trigger_audit(request: Request) -> JSONResponse:
2218
+ """Run full project audit (scan + security + tests + health)."""
2219
+ import asyncio
2220
+
2221
+ root = _root(request)
2222
+
2223
+ async def _run():
2224
+ try:
2225
+ # Phase 1: Brain scan
2226
+ from patchi.core.brain.brain import Brain
2227
+
2228
+ brain_instance = Brain(root)
2229
+ brain_instance.scan()
2230
+ memory_mod.get_brain(root)
2231
+
2232
+ # Phase 2: Security scan
2233
+ import patchi.core.security.security_agents # noqa: F401
2234
+ from patchi.core.agents.base import AgentGroup, list_agents
2235
+ from patchi.core.agents.coordinator import Coordinator
2236
+
2237
+ sec_agents = [a.name for a in list_agents(AgentGroup.SECURITY)]
2238
+ coord = Coordinator(root)
2239
+ sec_results = await asyncio.to_thread(coord.run_agents, sec_agents)
2240
+
2241
+ # Phase 3: Health score
2242
+ from patchi.core import health as hm
2243
+
2244
+ health = hm.compute(root)
2245
+
2246
+ return JSONResponse(
2247
+ {
2248
+ "ok": True,
2249
+ "health": health.to_dict(),
2250
+ "security_findings": sum(
2251
+ r.finding_count for r in sec_results if hasattr(r, "finding_count")
2252
+ ),
2253
+ }
2254
+ )
2255
+ except Exception as e:
2256
+ return JSONResponse({"ok": False, "error": str(e)})
2257
+
2258
+ asyncio.create_task(_run())
2259
+ return JSONResponse({"ok": True, "message": "Full audit started"})
2260
+
2261
+
2262
+ # ── Trend endpoint ────────────────────────────────────────────────────────────
2263
+
2264
+
2265
+ @router.get("/trend")
2266
+ async def get_trend(request: Request) -> JSONResponse:
2267
+ """Get health and quality trend data."""
2268
+ root = _root(request)
2269
+ try:
2270
+ scans = memory_mod.get_scan_results(root)
2271
+
2272
+ trend_data = []
2273
+ for name, data in scans.items():
2274
+ if not isinstance(data, dict):
2275
+ continue
2276
+ trend_data.append(
2277
+ {
2278
+ "timestamp": data.get("timestamp", ""),
2279
+ "agent": name,
2280
+ "finding_count": data.get("finding_count", 0),
2281
+ "status": data.get("status", "done"),
2282
+ "duration_ms": data.get("duration_ms", 0),
2283
+ }
2284
+ )
2285
+
2286
+ trend_data.sort(key=lambda x: x.get("timestamp", ""))
2287
+ return JSONResponse({"trend": trend_data[-50:]})
2288
+ except Exception as e:
2289
+ return JSONResponse({"trend": [], "error": str(e)})
2290
+
2291
+
2292
+ # ── Learn endpoint ────────────────────────────────────────────────────────────
2293
+
2294
+
2295
+ @router.post("/action/learn")
2296
+ async def trigger_learn(request: Request) -> JSONResponse:
2297
+ """Learn project conventions."""
2298
+ import time as time_mod
2299
+
2300
+ root = _root(request)
2301
+ try:
2302
+ from patchi.cli.commands.learn_cmd import (
2303
+ _analyze_dependencies,
2304
+ _analyze_error_handling,
2305
+ _analyze_framework,
2306
+ _analyze_imports,
2307
+ _analyze_naming,
2308
+ _analyze_structure,
2309
+ _analyze_testing,
2310
+ )
2311
+
2312
+ brain = memory_mod.get_brain(root)
2313
+
2314
+ conventions = {
2315
+ "learned_at": time_mod.time(),
2316
+ "structure": _analyze_structure(root),
2317
+ "naming": _analyze_naming(root),
2318
+ "imports": _analyze_imports(root),
2319
+ "error_handling": _analyze_error_handling(root),
2320
+ "testing": _analyze_testing(root),
2321
+ "framework": _analyze_framework(root, brain),
2322
+ "dependencies": _analyze_dependencies(root),
2323
+ }
2324
+
2325
+ brain["conventions"] = conventions
2326
+ memory_mod.save_brain(brain, root)
2327
+
2328
+ return JSONResponse({"ok": True, "conventions": conventions})
2329
+ except Exception as e:
2330
+ return JSONResponse({"ok": False, "error": str(e)})
2331
+
2332
+
2333
+ # ── AI status endpoint ────────────────────────────────────────────────────────
2334
+
2335
+
2336
+ @router.get("/ai/status")
2337
+ async def get_ai_status(request: Request) -> JSONResponse:
2338
+ """Get AI provider status."""
2339
+ root = _root(request)
2340
+ try:
2341
+ config = cfg_mod.load(root)
2342
+ ai_cfg = config.get("ai", {})
2343
+
2344
+ return JSONResponse(
2345
+ {
2346
+ "local_model": ai_cfg.get("local_model_name", ""),
2347
+ "has_keys": len(ai_cfg.get("keys", [])) > 0,
2348
+ "key_count": len(ai_cfg.get("keys", [])),
2349
+ "cost_limit": ai_cfg.get("cost_limit", 0),
2350
+ "enable_cost_limit": ai_cfg.get("enable_cost_limit", False),
2351
+ }
2352
+ )
2353
+ except Exception as e:
2354
+ return JSONResponse({"error": str(e)})
2355
+
2356
+
2357
+ @router.post("/ai/test")
2358
+ async def test_ai(request: Request) -> JSONResponse:
2359
+ """Test AI provider connection."""
2360
+ try:
2361
+ body = await request.json()
2362
+ provider = body.get("provider", "horde")
2363
+
2364
+ if provider == "horde":
2365
+ from patchi.core.ai.client import _call_ai_horde
2366
+ from patchi.core.constants import AI_HORDE_ANON_KEY
2367
+
2368
+ result = _call_ai_horde(
2369
+ AI_HORDE_ANON_KEY, "Say 'Patchi AI Horde connection OK' in exactly 5 words.", 50
2370
+ )
2371
+ if result:
2372
+ return JSONResponse({"ok": True, "message": result.strip()})
2373
+ else:
2374
+ return JSONResponse(
2375
+ {
2376
+ "ok": False,
2377
+ "message": "AI Horde did not respond. It may be under heavy load — try again in a moment.",
2378
+ }
2379
+ )
2380
+ elif provider == "ollama":
2381
+ from patchi.core.ai.client import _call_ollama
2382
+
2383
+ cfg = cfg_mod.load(_root(request))
2384
+ result = _call_ollama(cfg, "Say OK", max_tokens=10)
2385
+ if result:
2386
+ return JSONResponse(
2387
+ {"ok": True, "message": f"Ollama responded: {result.strip()[:100]}"}
2388
+ )
2389
+ else:
2390
+ return JSONResponse(
2391
+ {"ok": False, "message": "Ollama not responding. Is it running? (ollama serve)"}
2392
+ )
2393
+ else:
2394
+ return JSONResponse(
2395
+ {
2396
+ "ok": False,
2397
+ "message": f"Provider '{provider}' test not implemented. Try 'ollama' or 'horde'.",
2398
+ }
2399
+ )
2400
+ except Exception as e:
2401
+ return JSONResponse({"ok": False, "error": str(e)})