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.
- patchi/__init__.py +1 -0
- patchi/__main__.py +13 -0
- patchi/cli/__init__.py +0 -0
- patchi/cli/command_families.py +235 -0
- patchi/cli/commands/__init__.py +0 -0
- patchi/cli/commands/access_cmd.py +65 -0
- patchi/cli/commands/agent_stats_cmd.py +273 -0
- patchi/cli/commands/agents_cmd.py +187 -0
- patchi/cli/commands/ai_cmd.py +308 -0
- patchi/cli/commands/assure_cmd.py +538 -0
- patchi/cli/commands/audit_cmd.py +286 -0
- patchi/cli/commands/auto_cmd.py +110 -0
- patchi/cli/commands/blame_cmd.py +53 -0
- patchi/cli/commands/chains_cmd.py +327 -0
- patchi/cli/commands/charter_cmd.py +173 -0
- patchi/cli/commands/chat_cmd.py +597 -0
- patchi/cli/commands/check_cmd.py +232 -0
- patchi/cli/commands/cleanup_cmd.py +328 -0
- patchi/cli/commands/cockpit_cmd.py +208 -0
- patchi/cli/commands/commands_cmd.py +160 -0
- patchi/cli/commands/cross_repo_cmd.py +113 -0
- patchi/cli/commands/deps_cmd.py +110 -0
- patchi/cli/commands/dev_check_cmd.py +342 -0
- patchi/cli/commands/dev_cmd.py +445 -0
- patchi/cli/commands/doctor_cmd.py +457 -0
- patchi/cli/commands/findings_cmd.py +187 -0
- patchi/cli/commands/fix_cmd.py +648 -0
- patchi/cli/commands/fix_review_cmd.py +273 -0
- patchi/cli/commands/heatmap_cmd.py +62 -0
- patchi/cli/commands/help_cmd.py +159 -0
- patchi/cli/commands/hosted_cmd.py +980 -0
- patchi/cli/commands/init.py +456 -0
- patchi/cli/commands/key_cmd.py +571 -0
- patchi/cli/commands/learn_cmd.py +140 -0
- patchi/cli/commands/link_cmd.py +104 -0
- patchi/cli/commands/log_cmd.py +57 -0
- patchi/cli/commands/memory_cmd.py +215 -0
- patchi/cli/commands/model_cmd.py +207 -0
- patchi/cli/commands/notify_cmd.py +213 -0
- patchi/cli/commands/notify_prompts.py +29 -0
- patchi/cli/commands/patch_cmd.py +231 -0
- patchi/cli/commands/plan_cmd.py +128 -0
- patchi/cli/commands/plugins_cmd.py +307 -0
- patchi/cli/commands/queue_cmd.py +152 -0
- patchi/cli/commands/quick_cmd.py +159 -0
- patchi/cli/commands/ready_cmd.py +292 -0
- patchi/cli/commands/report_cmd.py +416 -0
- patchi/cli/commands/restrict_cmd.py +127 -0
- patchi/cli/commands/review_cmd.py +225 -0
- patchi/cli/commands/rules_cmd.py +233 -0
- patchi/cli/commands/scan_cmd.py +1908 -0
- patchi/cli/commands/settings_cmd.py +45 -0
- patchi/cli/commands/status_cmd.py +679 -0
- patchi/cli/commands/test_cmd.py +1052 -0
- patchi/cli/commands/trend_cmd.py +296 -0
- patchi/cli/commands/undo_cmd.py +235 -0
- patchi/cli/commands/update_cmd.py +345 -0
- patchi/cli/commands/verify_cmd.py +56 -0
- patchi/cli/commands/vr_cmd.py +440 -0
- patchi/cli/commands/watch_cmd.py +304 -0
- patchi/cli/commands/web_cmd.py +114 -0
- patchi/cli/console.py +101 -0
- patchi/cli/display/__init__.py +1 -0
- patchi/cli/display/live_audit.py +269 -0
- patchi/cli/display/live_progress.py +169 -0
- patchi/cli/framework.py +241 -0
- patchi/cli/logo.py +128 -0
- patchi/cli/main.py +222 -0
- patchi/cli/registry.py +1171 -0
- patchi/cli/style.py +68 -0
- patchi/cli/themes.py +187 -0
- patchi/cli/ux.py +444 -0
- patchi/core/__init__.py +0 -0
- patchi/core/agents/__init__.py +0 -0
- patchi/core/agents/api_fuzzer_agent.py +141 -0
- patchi/core/agents/attack_agent.py +242 -0
- patchi/core/agents/base.py +1012 -0
- patchi/core/agents/bug_predictor_agent.py +58 -0
- patchi/core/agents/build_tool_validator.py +150 -0
- patchi/core/agents/cache.py +192 -0
- patchi/core/agents/chaos_agent.py +133 -0
- patchi/core/agents/cicd_generator.py +240 -0
- patchi/core/agents/comment_scanner.py +280 -0
- patchi/core/agents/console_logging_agent.py +156 -0
- patchi/core/agents/coordinator.py +858 -0
- patchi/core/agents/core_scanner.py +269 -0
- patchi/core/agents/coverage_prioritizer.py +181 -0
- patchi/core/agents/dead_code_hygiene.py +296 -0
- patchi/core/agents/dead_code_scanner.py +692 -0
- patchi/core/agents/dependency_scanner.py +725 -0
- patchi/core/agents/doc_claim_agent.py +573 -0
- patchi/core/agents/duplicate_scanner.py +568 -0
- patchi/core/agents/env_scanner.py +410 -0
- patchi/core/agents/feature_flag_agent.py +89 -0
- patchi/core/agents/frontend_framework_agent.py +133 -0
- patchi/core/agents/goroutine_agent.py +82 -0
- patchi/core/agents/governor.py +1972 -0
- patchi/core/agents/i18n_agent.py +95 -0
- patchi/core/agents/license_compliance.py +247 -0
- patchi/core/agents/linking_agent.py +251 -0
- patchi/core/agents/memory_profiler_agent.py +102 -0
- patchi/core/agents/modernization_agent.py +84 -0
- patchi/core/agents/mutation_agent.py +174 -0
- patchi/core/agents/nl_query_agent.py +74 -0
- patchi/core/agents/promise_rejection_agent.py +100 -0
- patchi/core/agents/refactoring_agent.py +286 -0
- patchi/core/agents/resource_leak_agent.py +91 -0
- patchi/core/agents/route_graph_scanner.py +951 -0
- patchi/core/agents/run_agent.py +106 -0
- patchi/core/agents/rust_unwrap_agent.py +100 -0
- patchi/core/agents/sbom_generator.py +284 -0
- patchi/core/agents/scanners.py +67 -0
- patchi/core/agents/side/__init__.py +1 -0
- patchi/core/agents/side/build_agent.py +65 -0
- patchi/core/agents/side/format_agent.py +50 -0
- patchi/core/agents/side/install_agent.py +63 -0
- patchi/core/agents/side_file_scanner.py +549 -0
- patchi/core/agents/snapshot_drift_detector.py +146 -0
- patchi/core/agents/spa_route_inventory.py +203 -0
- patchi/core/agents/test_scanner.py +442 -0
- patchi/core/agents/tool_health.py +109 -0
- patchi/core/agents/tool_runner.py +162 -0
- patchi/core/agents/type_scanner.py +168 -0
- patchi/core/agents/ui_scanner.py +558 -0
- patchi/core/ai/__init__.py +20 -0
- patchi/core/ai/agent_audit.py +153 -0
- patchi/core/ai/agent_profiler.py +277 -0
- patchi/core/ai/client.py +499 -0
- patchi/core/ai/cost_tracker.py +107 -0
- patchi/core/ai/model_router.py +318 -0
- patchi/core/ai/orchestrator.py +625 -0
- patchi/core/ai/prompts.py +681 -0
- patchi/core/ai/smart.py +302 -0
- patchi/core/ai/tool_executor.py +24 -0
- patchi/core/ai/tools/__init__.py +10 -0
- patchi/core/ai/tools/executor.py +621 -0
- patchi/core/ai/tools/realize.py +1160 -0
- patchi/core/ai/tools/registry.py +1577 -0
- patchi/core/assurance/__init__.py +33 -0
- patchi/core/assurance/builder.py +211 -0
- patchi/core/assurance/graph.py +342 -0
- patchi/core/assurance/invariants.py +168 -0
- patchi/core/atomic.py +61 -0
- patchi/core/attackers/__init__.py +17 -0
- patchi/core/attackers/api_abuse.py +89 -0
- patchi/core/attackers/auth_bypass.py +73 -0
- patchi/core/attackers/base.py +91 -0
- patchi/core/attackers/business_logic.py +88 -0
- patchi/core/attackers/planner.py +108 -0
- patchi/core/attackers/priv_escalation.py +88 -0
- patchi/core/attackers/recon.py +87 -0
- patchi/core/brain/__init__.py +0 -0
- patchi/core/brain/ast_utils/__init__.py +81 -0
- patchi/core/brain/ast_utils/assignments.py +219 -0
- patchi/core/brain/ast_utils/calls.py +139 -0
- patchi/core/brain/ast_utils/config.py +132 -0
- patchi/core/brain/ast_utils/control_flow.py +74 -0
- patchi/core/brain/ast_utils/dead_symbols.py +205 -0
- patchi/core/brain/ast_utils/decorators.py +108 -0
- patchi/core/brain/ast_utils/helpers.py +144 -0
- patchi/core/brain/ast_utils/imports.py +169 -0
- patchi/core/brain/ast_utils/scan.py +143 -0
- patchi/core/brain/ast_utils/taint.py +310 -0
- patchi/core/brain/audit.py +379 -0
- patchi/core/brain/baseline.py +195 -0
- patchi/core/brain/blast_radius.py +312 -0
- patchi/core/brain/body_tags.py +144 -0
- patchi/core/brain/brain.py +1161 -0
- patchi/core/brain/brain_context.py +393 -0
- patchi/core/brain/brain_watcher.py +340 -0
- patchi/core/brain/charter.py +473 -0
- patchi/core/brain/classifier.py +214 -0
- patchi/core/brain/cockpit.py +350 -0
- patchi/core/brain/code_query.py +710 -0
- patchi/core/brain/contract.py +889 -0
- patchi/core/brain/contract_diff.py +155 -0
- patchi/core/brain/council.py +800 -0
- patchi/core/brain/cross_repo.py +104 -0
- patchi/core/brain/dead_code_tools.py +157 -0
- patchi/core/brain/doc_validator.py +197 -0
- patchi/core/brain/domain_activator.py +2183 -0
- patchi/core/brain/enriched_context.py +220 -0
- patchi/core/brain/file_corpus.py +220 -0
- patchi/core/brain/framework.py +953 -0
- patchi/core/brain/freshness.py +273 -0
- patchi/core/brain/git_aware.py +206 -0
- patchi/core/brain/heatmap.py +104 -0
- patchi/core/brain/ignore_parser.py +153 -0
- patchi/core/brain/import_graph.py +486 -0
- patchi/core/brain/languages.py +397 -0
- patchi/core/brain/layered_brain.py +419 -0
- patchi/core/brain/learning.py +229 -0
- patchi/core/brain/orphaned_endpoints.py +313 -0
- patchi/core/brain/personas/__init__.py +648 -0
- patchi/core/brain/personas/bad_user.py +165 -0
- patchi/core/brain/personas/base.py +349 -0
- patchi/core/brain/proactive.py +807 -0
- patchi/core/brain/project_context.py +121 -0
- patchi/core/brain/project_reader.py +521 -0
- patchi/core/brain/reasoning.py +269 -0
- patchi/core/brain/route_detector/__init__.py +15 -0
- patchi/core/brain/route_detector/base.py +31 -0
- patchi/core/brain/route_detector/csharp.py +209 -0
- patchi/core/brain/route_detector/file_based.py +81 -0
- patchi/core/brain/route_detector/go.py +152 -0
- patchi/core/brain/route_detector/java.py +195 -0
- patchi/core/brain/route_detector/javascript.py +197 -0
- patchi/core/brain/route_detector/php.py +158 -0
- patchi/core/brain/route_detector/python.py +193 -0
- patchi/core/brain/route_detector/registry.py +25 -0
- patchi/core/brain/route_detector/ruby.py +134 -0
- patchi/core/brain/route_detector/rust.py +216 -0
- patchi/core/brain/route_detector/swift.py +118 -0
- patchi/core/brain/route_mapper.py +265 -0
- patchi/core/brain/scanner.py +2136 -0
- patchi/core/brain/secrets.py +280 -0
- patchi/core/brain/symbol_graph.py +1955 -0
- patchi/core/brain/team.py +28 -0
- patchi/core/brain/trace_log.py +132 -0
- patchi/core/brain/type_checker/__init__.py +65 -0
- patchi/core/brain/type_checker/base.py +60 -0
- patchi/core/brain/type_checker/csharp.py +59 -0
- patchi/core/brain/type_checker/dart.py +59 -0
- patchi/core/brain/type_checker/go.py +81 -0
- patchi/core/brain/type_checker/java.py +75 -0
- patchi/core/brain/type_checker/kotlin.py +75 -0
- patchi/core/brain/type_checker/php.py +88 -0
- patchi/core/brain/type_checker/python.py +87 -0
- patchi/core/brain/type_checker/rust.py +81 -0
- patchi/core/brain/type_checker/swift.py +59 -0
- patchi/core/brain/type_checker/typescript.py +298 -0
- patchi/core/brain/understander.py +130 -0
- patchi/core/brain/verify.py +379 -0
- patchi/core/campaigns/__init__.py +14 -0
- patchi/core/campaigns/base.py +62 -0
- patchi/core/campaigns/data_flow_abuse.py +153 -0
- patchi/core/campaigns/orchestrator.py +140 -0
- patchi/core/campaigns/state_transitions.py +178 -0
- patchi/core/ci_bundle.py +104 -0
- patchi/core/config.py +311 -0
- patchi/core/constants.py +245 -0
- patchi/core/debug/__init__.py +25 -0
- patchi/core/debug/adapters/__init__.py +11 -0
- patchi/core/debug/adapters/codelldb.py +379 -0
- patchi/core/debug/adapters/node.py +222 -0
- patchi/core/debug/adapters/powershell.py +210 -0
- patchi/core/debug/adapters/python.py +244 -0
- patchi/core/debug/capture.py +210 -0
- patchi/core/debug/dap_client.py +268 -0
- patchi/core/detector/__init__.py +0 -0
- patchi/core/detector/audit.py +194 -0
- patchi/core/detector/bus.py +226 -0
- patchi/core/detector/dispatcher.py +275 -0
- patchi/core/detector/event.py +254 -0
- patchi/core/detector/sigma_engine.py +378 -0
- patchi/core/detector/triage.py +289 -0
- patchi/core/export/sarif.py +113 -0
- patchi/core/fix/__init__.py +17 -0
- patchi/core/fix/applier.py +574 -0
- patchi/core/fix/base.py +510 -0
- patchi/core/fix/code_fixer.py +169 -0
- patchi/core/fix/dead_code_remover.py +131 -0
- patchi/core/fix/fix_agents.py +775 -0
- patchi/core/fix/patch.py +444 -0
- patchi/core/fix/risk_gate.py +383 -0
- patchi/core/fix/security_fixer.py +93 -0
- patchi/core/fix/verify_loop.py +204 -0
- patchi/core/fuzz/__init__.py +8 -0
- patchi/core/fuzz/corpus.py +137 -0
- patchi/core/fuzz/input_fuzzer.py +256 -0
- patchi/core/fuzz/sequence_fuzzer.py +162 -0
- patchi/core/fuzz/state_fuzzer.py +236 -0
- patchi/core/health.py +407 -0
- patchi/core/hosted/__init__.py +1 -0
- patchi/core/hosted/anomaly.py +298 -0
- patchi/core/hosted/audit_log.py +102 -0
- patchi/core/hosted/compliance_report.py +148 -0
- patchi/core/hosted/ip_reputation.py +169 -0
- patchi/core/hosted/log_parsers.py +251 -0
- patchi/core/hosted/tokens.py +127 -0
- patchi/core/hosted/watchlist.py +164 -0
- patchi/core/hosted/webhooks.py +156 -0
- patchi/core/memory.py +437 -0
- patchi/core/notifications/__init__.py +18 -0
- patchi/core/notifications/channels.py +192 -0
- patchi/core/notifications/digest.py +141 -0
- patchi/core/notifications/escalation.py +158 -0
- patchi/core/notifications/notifier.py +182 -0
- patchi/core/notifications/quiet_hours.py +69 -0
- patchi/core/plugins/__init__.py +48 -0
- patchi/core/plugins/analyzer.py +326 -0
- patchi/core/plugins/analyzers/__init__.py +6 -0
- patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
- patchi/core/plugins/analyzers/secret_scanner.py +158 -0
- patchi/core/plugins/analyzers/todo_scanner.py +115 -0
- patchi/core/plugins/registry.py +335 -0
- patchi/core/queue.py +375 -0
- patchi/core/reliability/__init__.py +13 -0
- patchi/core/reliability/chaos.py +163 -0
- patchi/core/reliability/fault_injector.py +172 -0
- patchi/core/reliability/idempotency.py +128 -0
- patchi/core/reliability/recovery.py +147 -0
- patchi/core/runtime/__init__.py +5 -0
- patchi/core/runtime/tracer.py +230 -0
- patchi/core/scan_bus.py +156 -0
- patchi/core/security/__init__.py +1 -0
- patchi/core/security/ai_validator.py +462 -0
- patchi/core/security/app_mapper.py +216 -0
- patchi/core/security/app_profile.py +464 -0
- patchi/core/security/attack_feedback.py +141 -0
- patchi/core/security/attack_scenarios/__init__.py +1 -0
- patchi/core/security/attack_tree.py +176 -0
- patchi/core/security/auth_audit_agent.py +344 -0
- patchi/core/security/authz_agent.py +602 -0
- patchi/core/security/auto_fix_proactive.py +620 -0
- patchi/core/security/auto_fixer.py +660 -0
- patchi/core/security/auto_ticket.py +53 -0
- patchi/core/security/bandit_agent.py +152 -0
- patchi/core/security/blast_radius.py +226 -0
- patchi/core/security/browser_tester.py +333 -0
- patchi/core/security/business_logic_agent.py +263 -0
- patchi/core/security/catch_block_auditor.py +395 -0
- patchi/core/security/cdn_cache_agent.py +357 -0
- patchi/core/security/chain_analyzer.py +321 -0
- patchi/core/security/chain_to_assurance.py +168 -0
- patchi/core/security/charter.py +510 -0
- patchi/core/security/cloud_waf_detector.py +295 -0
- patchi/core/security/codeql_agent.py +233 -0
- patchi/core/security/compliance_agent.py +348 -0
- patchi/core/security/confidence_gate.py +423 -0
- patchi/core/security/container_scanner.py +282 -0
- patchi/core/security/crypto_agent.py +378 -0
- patchi/core/security/cve_monitor.py +262 -0
- patchi/core/security/dast_agent.py +865 -0
- patchi/core/security/defectdojo.py +238 -0
- patchi/core/security/defenders/__init__.py +75 -0
- patchi/core/security/defenders/auth_middleware.py +115 -0
- patchi/core/security/defenders/base.py +97 -0
- patchi/core/security/defenders/block_ip.py +71 -0
- patchi/core/security/defenders/block_ws_origin.py +36 -0
- patchi/core/security/defenders/crypto_fix.py +118 -0
- patchi/core/security/defenders/enforce_rate_limit.py +108 -0
- patchi/core/security/defenders/fix_code.py +93 -0
- patchi/core/security/defenders/invalidate_session.py +40 -0
- patchi/core/security/defenders/patch_config.py +70 -0
- patchi/core/security/defenders/rotate_secret.py +54 -0
- patchi/core/security/defenders/suspend_account.py +41 -0
- patchi/core/security/defenders/update_dependency.py +160 -0
- patchi/core/security/defense_layer.py +217 -0
- patchi/core/security/dependency_vulnerability_agent.py +616 -0
- patchi/core/security/detection_pipeline.py +289 -0
- patchi/core/security/dns_security_agent.py +497 -0
- patchi/core/security/domain_activator_v2.py +913 -0
- patchi/core/security/domain_loader.py +1004 -0
- patchi/core/security/domains/__init__.py +0 -0
- patchi/core/security/email_authentication_agent.py +417 -0
- patchi/core/security/env_var_validator.py +304 -0
- patchi/core/security/evidence.py +100 -0
- patchi/core/security/falco_runtime_agent.py +387 -0
- patchi/core/security/fix-playbooks/__init__.py +0 -0
- patchi/core/security/gated_finding.py +85 -0
- patchi/core/security/git_diff_activator.py +367 -0
- patchi/core/security/governance.py +208 -0
- patchi/core/security/gradual_gate.py +34 -0
- patchi/core/security/history.py +315 -0
- patchi/core/security/iac_scanner.py +250 -0
- patchi/core/security/ignore_expiry.py +111 -0
- patchi/core/security/ignore_learner.py +416 -0
- patchi/core/security/injection_agent.py +319 -0
- patchi/core/security/insecure_randomness_agent.py +288 -0
- patchi/core/security/intent_analyzer.py +326 -0
- patchi/core/security/jwt_agent.py +453 -0
- patchi/core/security/kubernetes_agent.py +638 -0
- patchi/core/security/layer2_orchestrator.py +341 -0
- patchi/core/security/llm_security_agent.py +252 -0
- patchi/core/security/misconfig_agent.py +600 -0
- patchi/core/security/mobile_security_agent.py +474 -0
- patchi/core/security/network_agent.py +376 -0
- patchi/core/security/noise_filter.py +274 -0
- patchi/core/security/orchestrator.py +313 -0
- patchi/core/security/pattern_context.py +125 -0
- patchi/core/security/pattern_loader.py +101 -0
- patchi/core/security/pentest/__init__.py +4 -0
- patchi/core/security/pentest/base.py +58 -0
- patchi/core/security/pentest/dalfox_adapter.py +40 -0
- patchi/core/security/pentest/ffuf_adapter.py +48 -0
- patchi/core/security/pentest/nuclei_adapter.py +50 -0
- patchi/core/security/pentest/registry.py +80 -0
- patchi/core/security/pentest/shannon_adapter.py +107 -0
- patchi/core/security/pentest/sqlmap_adapter.py +56 -0
- patchi/core/security/pentest/zap_adapter.py +52 -0
- patchi/core/security/plan_auditor.py +276 -0
- patchi/core/security/policy_engine.py +272 -0
- patchi/core/security/prechecks.py +172 -0
- patchi/core/security/precommit_hook.py +247 -0
- patchi/core/security/privacy_agent.py +361 -0
- patchi/core/security/push_notification_agent.py +417 -0
- patchi/core/security/pysa_agent.py +143 -0
- patchi/core/security/reasoning.py +442 -0
- patchi/core/security/red_team_agent.py +184 -0
- patchi/core/security/red_team_engine.py +1142 -0
- patchi/core/security/remediation.py +360 -0
- patchi/core/security/request_interceptor.py +294 -0
- patchi/core/security/runtime_validator.py +245 -0
- patchi/core/security/saml_sso_agent.py +401 -0
- patchi/core/security/sast_agent.py +213 -0
- patchi/core/security/sast_gate.py +95 -0
- patchi/core/security/scheduler.py +287 -0
- patchi/core/security/secrets_runtime_agent.py +476 -0
- patchi/core/security/security_agents.py +175 -0
- patchi/core/security/security_config.py +551 -0
- patchi/core/security/security_probe.py +762 -0
- patchi/core/security/security_taint.py +1005 -0
- patchi/core/security/sensitive_data_agent.py +271 -0
- patchi/core/security/service_mesh_agent.py +412 -0
- patchi/core/security/session_management_agent.py +245 -0
- patchi/core/security/ssrf_agent.py +422 -0
- patchi/core/security/supply_chain.py +449 -0
- patchi/core/security/threat_model_generator.py +378 -0
- patchi/core/security/threat_model_updater.py +196 -0
- patchi/core/security/tool_adapters.py +135 -0
- patchi/core/security/tool_verify.py +173 -0
- patchi/core/security/websocket_security_agent.py +188 -0
- patchi/core/snapshot.py +242 -0
- patchi/core/tenant.py +386 -0
- patchi/core/testing/__init__.py +17 -0
- patchi/core/testing/_browser.py +147 -0
- patchi/core/testing/ads_agent.py +141 -0
- patchi/core/testing/api_contract_agent.py +450 -0
- patchi/core/testing/app_discovery_agent.py +641 -0
- patchi/core/testing/app_launcher.py +184 -0
- patchi/core/testing/browser_test_agent.py +516 -0
- patchi/core/testing/e2e_flow_agent.py +354 -0
- patchi/core/testing/flake_detector_agent.py +292 -0
- patchi/core/testing/gate.py +31 -0
- patchi/core/testing/live_test_runner.py +304 -0
- patchi/core/testing/live_v2/__init__.py +1 -0
- patchi/core/testing/live_v2/browser_pool.py +349 -0
- patchi/core/testing/live_v2/browser_test_runner.py +313 -0
- patchi/core/testing/live_v2/runner.py +626 -0
- patchi/core/testing/live_v2/screenshot_manager.py +474 -0
- patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
- patchi/core/testing/live_v2/video_recorder.py +303 -0
- patchi/core/testing/regression_agent.py +302 -0
- patchi/core/testing/security_test_agent.py +253 -0
- patchi/core/testing/stress_test_agent.py +203 -0
- patchi/core/testing/test_agents.py +50 -0
- patchi/core/testing/test_config.py +194 -0
- patchi/core/testing/ui_accessibility_agent.py +324 -0
- patchi/core/testing/ui_button_agent.py +294 -0
- patchi/core/testing/ui_layout_agent.py +330 -0
- patchi/core/testing/unit_test_agent.py +1149 -0
- patchi/core/testing/visual_regression_agent.py +260 -0
- patchi/web/__init__.py +1 -0
- patchi/web/api/__init__.py +1 -0
- patchi/web/api/brain_map.py +114 -0
- patchi/web/api/charts.py +47 -0
- patchi/web/api/chat.py +224 -0
- patchi/web/api/cicd.py +576 -0
- patchi/web/api/dast_evidence.py +49 -0
- patchi/web/api/dev_check.py +320 -0
- patchi/web/api/fix.py +134 -0
- patchi/web/api/guard.py +65 -0
- patchi/web/api/hosted.py +128 -0
- patchi/web/api/hosted_v2.py +234 -0
- patchi/web/api/live_testing.py +699 -0
- patchi/web/api/scan.py +582 -0
- patchi/web/api/smart.py +111 -0
- patchi/web/api/tenant.py +117 -0
- patchi/web/api/tools.py +85 -0
- patchi/web/api_legacy.py +2401 -0
- patchi/web/app.py +310 -0
- patchi/web/events.py +483 -0
- patchi/web/routes/__init__.py +1 -0
- patchi/web/routes/assurance.py +541 -0
- patchi/web/routes/brain.py +36 -0
- patchi/web/routes/charter.py +227 -0
- patchi/web/routes/chat.py +23 -0
- patchi/web/routes/dashboard.py +558 -0
- patchi/web/routes/dashboard_v2.py +402 -0
- patchi/web/routes/findings.py +330 -0
- patchi/web/routes/guard.py +35 -0
- patchi/web/routes/history.py +126 -0
- patchi/web/routes/landing.py +52 -0
- patchi/web/routes/live_testing.py +24 -0
- patchi/web/routes/review.py +57 -0
- patchi/web/routes/self_improvement.py +110 -0
- patchi/web/routes/settings.py +49 -0
- patchi/web/routes/tokens.py +29 -0
- patchi/web/spawn.py +139 -0
- patchi/web/ws.py +141 -0
- patchi-0.7.5.dist-info/METADATA +315 -0
- patchi-0.7.5.dist-info/RECORD +496 -0
- patchi-0.7.5.dist-info/WHEEL +5 -0
- patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
- patchi-0.7.5.dist-info/top_level.txt +1 -0
patchi/web/api_legacy.py
ADDED
|
@@ -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)})
|