iflow-mcp-m507_ai-soc-agent 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/METADATA +410 -0
  2. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/RECORD +85 -0
  3. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/WHEEL +5 -0
  4. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/top_level.txt +1 -0
  7. src/__init__.py +8 -0
  8. src/ai_controller/README.md +139 -0
  9. src/ai_controller/__init__.py +12 -0
  10. src/ai_controller/agent_executor.py +596 -0
  11. src/ai_controller/cli/__init__.py +2 -0
  12. src/ai_controller/cli/main.py +243 -0
  13. src/ai_controller/session_manager.py +409 -0
  14. src/ai_controller/web/__init__.py +2 -0
  15. src/ai_controller/web/server.py +1181 -0
  16. src/ai_controller/web/static/css/README.md +102 -0
  17. src/api/__init__.py +13 -0
  18. src/api/case_management.py +271 -0
  19. src/api/edr.py +187 -0
  20. src/api/kb.py +136 -0
  21. src/api/siem.py +308 -0
  22. src/core/__init__.py +10 -0
  23. src/core/config.py +242 -0
  24. src/core/config_storage.py +684 -0
  25. src/core/dto.py +50 -0
  26. src/core/errors.py +36 -0
  27. src/core/logging.py +128 -0
  28. src/integrations/__init__.py +8 -0
  29. src/integrations/case_management/__init__.py +5 -0
  30. src/integrations/case_management/iris/__init__.py +11 -0
  31. src/integrations/case_management/iris/iris_client.py +885 -0
  32. src/integrations/case_management/iris/iris_http.py +274 -0
  33. src/integrations/case_management/iris/iris_mapper.py +263 -0
  34. src/integrations/case_management/iris/iris_models.py +128 -0
  35. src/integrations/case_management/thehive/__init__.py +8 -0
  36. src/integrations/case_management/thehive/thehive_client.py +193 -0
  37. src/integrations/case_management/thehive/thehive_http.py +147 -0
  38. src/integrations/case_management/thehive/thehive_mapper.py +190 -0
  39. src/integrations/case_management/thehive/thehive_models.py +125 -0
  40. src/integrations/cti/__init__.py +6 -0
  41. src/integrations/cti/local_tip/__init__.py +10 -0
  42. src/integrations/cti/local_tip/local_tip_client.py +90 -0
  43. src/integrations/cti/local_tip/local_tip_http.py +110 -0
  44. src/integrations/cti/opencti/__init__.py +10 -0
  45. src/integrations/cti/opencti/opencti_client.py +101 -0
  46. src/integrations/cti/opencti/opencti_http.py +418 -0
  47. src/integrations/edr/__init__.py +6 -0
  48. src/integrations/edr/elastic_defend/__init__.py +6 -0
  49. src/integrations/edr/elastic_defend/elastic_defend_client.py +351 -0
  50. src/integrations/edr/elastic_defend/elastic_defend_http.py +162 -0
  51. src/integrations/eng/__init__.py +10 -0
  52. src/integrations/eng/clickup/__init__.py +8 -0
  53. src/integrations/eng/clickup/clickup_client.py +513 -0
  54. src/integrations/eng/clickup/clickup_http.py +156 -0
  55. src/integrations/eng/github/__init__.py +8 -0
  56. src/integrations/eng/github/github_client.py +169 -0
  57. src/integrations/eng/github/github_http.py +158 -0
  58. src/integrations/eng/trello/__init__.py +8 -0
  59. src/integrations/eng/trello/trello_client.py +207 -0
  60. src/integrations/eng/trello/trello_http.py +162 -0
  61. src/integrations/kb/__init__.py +12 -0
  62. src/integrations/kb/fs_kb_client.py +313 -0
  63. src/integrations/siem/__init__.py +6 -0
  64. src/integrations/siem/elastic/__init__.py +6 -0
  65. src/integrations/siem/elastic/elastic_client.py +3319 -0
  66. src/integrations/siem/elastic/elastic_http.py +165 -0
  67. src/mcp/README.md +183 -0
  68. src/mcp/TOOLS.md +2827 -0
  69. src/mcp/__init__.py +13 -0
  70. src/mcp/__main__.py +18 -0
  71. src/mcp/agent_profiles.py +408 -0
  72. src/mcp/flow_agent_profiles.py +424 -0
  73. src/mcp/mcp_server.py +4086 -0
  74. src/mcp/rules_engine.py +487 -0
  75. src/mcp/runbook_manager.py +264 -0
  76. src/orchestrator/__init__.py +11 -0
  77. src/orchestrator/incident_workflow.py +244 -0
  78. src/orchestrator/tools_case.py +1085 -0
  79. src/orchestrator/tools_cti.py +359 -0
  80. src/orchestrator/tools_edr.py +315 -0
  81. src/orchestrator/tools_eng.py +378 -0
  82. src/orchestrator/tools_kb.py +156 -0
  83. src/orchestrator/tools_siem.py +1709 -0
  84. src/web/__init__.py +8 -0
  85. src/web/config_server.py +511 -0
@@ -0,0 +1,1181 @@
1
+ """
2
+ Web server for AI Controller.
3
+
4
+ Provides a web interface for managing and executing agent commands,
5
+ with real-time updates via WebSocket.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+ from datetime import datetime, timedelta
16
+
17
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
18
+ from fastapi.responses import HTMLResponse, JSONResponse
19
+ from fastapi.staticfiles import StaticFiles
20
+ from pydantic import BaseModel
21
+
22
+ from ..agent_executor import AgentExecutor, ExecutionResult
23
+ from ..session_manager import SessionManager, Session, SessionType, SessionStatus, AutorunConfig
24
+ from ...core.logging import get_logger
25
+
26
+ logger = get_logger("sami.ai_controller.web.server")
27
+
28
+ # Create FastAPI app
29
+ app = FastAPI(
30
+ title="SamiGPT AI Controller",
31
+ description="Web interface for managing and executing agent commands",
32
+ version="1.0.0",
33
+ )
34
+
35
+ # Initialize components
36
+ executor: Optional[AgentExecutor] = None
37
+ session_manager: Optional[SessionManager] = None
38
+
39
+ # UI behavior flags (e.g., controlled by CLI flags like --debug)
40
+ UI_DEBUG_MODE: bool = False
41
+
42
+ # WebSocket connections by session ID
43
+ active_connections: Dict[str, List[WebSocket]] = {}
44
+
45
+ # Currently running execution tasks keyed by session ID (for cancellation)
46
+ running_tasks: Dict[str, asyncio.Task] = {}
47
+
48
+ # Autorun scheduler state
49
+ autorun_scheduler_task: Optional[asyncio.Task] = None
50
+ running_autoruns: set[str] = set()
51
+
52
+
53
+ class CommandRequest(BaseModel):
54
+ """Request to execute a command."""
55
+ command: str
56
+ session_id: Optional[str] = None
57
+ session_name: Optional[str] = None
58
+
59
+
60
+ class AutorunCreateRequest(BaseModel):
61
+ """Request to create an autorun."""
62
+ name: str
63
+ command: str
64
+ interval_seconds: int
65
+ condition_function: Optional[str] = None # Function/tool name to check before executing
66
+
67
+
68
+ class AutorunUpdateRequest(BaseModel):
69
+ """Request to update an autorun."""
70
+ enabled: Optional[bool] = None
71
+ interval_seconds: Optional[int] = None
72
+ name: Optional[str] = None
73
+ condition_function: Optional[str] = None # Function/tool name to check before executing
74
+
75
+
76
+ class UIConfigUpdate(BaseModel):
77
+ """Request to update UI configuration flags."""
78
+ ui_debug: Optional[bool] = None
79
+
80
+
81
+ def initialize(config_storage_dir: Optional[str] = None, debug_ui: bool = False):
82
+ """Initialize the web server components."""
83
+ global executor, session_manager, UI_DEBUG_MODE
84
+
85
+ try:
86
+ if config_storage_dir:
87
+ session_manager = SessionManager(storage_dir=config_storage_dir)
88
+ else:
89
+ session_manager = SessionManager()
90
+
91
+ # Load config for executor
92
+ from ...core.config_storage import load_config_from_file
93
+ config = load_config_from_file()
94
+ executor = AgentExecutor(config)
95
+
96
+ UI_DEBUG_MODE = debug_ui
97
+ logger.info(
98
+ "AI Controller web server initialized (ui_debug_mode=%s, storage_dir=%s)",
99
+ UI_DEBUG_MODE,
100
+ config_storage_dir or "default",
101
+ )
102
+ except Exception as e:
103
+ logger.exception("Error initializing web server components")
104
+ raise
105
+
106
+
107
+ async def _run_autorun(autorun: AutorunConfig):
108
+ """
109
+ Execute a single autorun:
110
+ - Checks condition function if configured (skips if condition returns empty)
111
+ - Creates an AUTORUN session
112
+ - Executes the configured starting prompt
113
+ - Records output in the session entries
114
+ - Updates last_run / next_run on the autorun config
115
+ """
116
+ if not executor or not session_manager:
117
+ logger.warning("Executor or session manager not initialized; skipping autorun %s", autorun.id)
118
+ return
119
+
120
+ try:
121
+ logger.info("Executing autorun %s (%s)", autorun.id, autorun.name)
122
+
123
+ # Use a persistent AUTORUN session for this autorun
124
+ # Reload latest config to get any updated session_id
125
+ fresh_autorun = session_manager.get_autorun(autorun.id) or autorun
126
+
127
+ # Get or create session first (needed for condition logging)
128
+ session_id = fresh_autorun.session_id
129
+ session = session_manager.get_session(session_id) if session_id else None
130
+
131
+ # If the session was deleted or never created, create a new one and persist it
132
+ if not session:
133
+ session_name = f"Autorun: {fresh_autorun.name}"
134
+ logger.debug("Creating new AUTORUN session for autorun %s (%s)", fresh_autorun.id, session_name)
135
+ session = session_manager.create_session(session_name, SessionType.AUTORUN)
136
+ session_manager.update_autorun(fresh_autorun.id, session_id=session.id)
137
+ session_id = session.id
138
+
139
+ # Check condition function if configured
140
+ # Validate that condition_function is not None and not empty string
141
+ condition_function = fresh_autorun.condition_function
142
+ if condition_function and condition_function.strip():
143
+ logger.info("Checking condition function '%s' for autorun %s (%s)",
144
+ condition_function, fresh_autorun.id, fresh_autorun.name)
145
+ condition_result, condition_details = await _check_autorun_condition(condition_function, executor)
146
+
147
+ # Add condition check entry to session
148
+ condition_command_str = f"[CONDITION CHECK] {condition_function}"
149
+ condition_entry = session_manager.add_entry(session_id, condition_command_str)
150
+
151
+ # Create result for condition check.
152
+ #
153
+ # Debug mode controls how much JSON detail we keep. In non‑debug mode
154
+ # we only want the high‑level evaluation string to appear in the chat
155
+ # (e.g. "✗ CONDITION FAILED: No uninvestigated alerts found ...").
156
+ if UI_DEBUG_MODE:
157
+ # Verbose debug mode: include all details for troubleshooting.
158
+ condition_result_dict = {
159
+ "success": condition_result,
160
+ "condition_function": condition_function,
161
+ "should_proceed": condition_result,
162
+ "details": condition_details,
163
+ "output": condition_details.get("output"),
164
+ "evaluation": condition_details.get("evaluation", ""),
165
+ "command_executed": condition_details.get("command_executed"),
166
+ "execution_success": condition_details.get("execution_success"),
167
+ "output_type": condition_details.get("output_type"),
168
+ "uninvestigated_alerts": condition_details.get("uninvestigated_alerts"),
169
+ "total_alerts": condition_details.get("total_alerts"),
170
+ "groups_count": condition_details.get("groups_count"),
171
+ }
172
+ if "error" in condition_details:
173
+ condition_result_dict["error"] = condition_details.get("error")
174
+ else:
175
+ # Non-debug mode: slimmer payload. We deliberately set `output`
176
+ # to the evaluation string so the frontend shows only that text
177
+ # in the chat instead of raw JSON or nested tool output.
178
+ condition_result_dict = {
179
+ "success": condition_result,
180
+ "condition_function": condition_function,
181
+ "should_proceed": condition_result,
182
+ "evaluation": condition_details.get("evaluation", ""),
183
+ "output": condition_details.get("evaluation", ""),
184
+ }
185
+ # Only include key metrics if available
186
+ if condition_details.get("uninvestigated_alerts") is not None:
187
+ condition_result_dict["uninvestigated_alerts"] = condition_details.get("uninvestigated_alerts")
188
+
189
+ session_manager.update_entry(
190
+ session_id,
191
+ condition_entry.id,
192
+ result=condition_result_dict,
193
+ status=SessionStatus.COMPLETED,
194
+ )
195
+
196
+ # Broadcast condition check result
197
+ await broadcast_to_session(session_id, {
198
+ "type": "execution_completed",
199
+ "entry_id": condition_entry.id,
200
+ "result": condition_result_dict,
201
+ })
202
+
203
+ if not condition_result:
204
+ logger.info(
205
+ "Condition function '%s' returned empty result for autorun %s (%s). Skipping execution.",
206
+ condition_function,
207
+ fresh_autorun.id,
208
+ fresh_autorun.name
209
+ )
210
+ # Update schedule but don't execute
211
+ now = datetime.now()
212
+ next_run = now + timedelta(seconds=fresh_autorun.interval_seconds)
213
+ session_manager.update_autorun(
214
+ fresh_autorun.id,
215
+ last_run=now,
216
+ next_run=next_run,
217
+ )
218
+ return
219
+ else:
220
+ logger.info(
221
+ "Condition function '%s' returned content for autorun %s (%s). Proceeding with execution.",
222
+ condition_function,
223
+ fresh_autorun.id,
224
+ fresh_autorun.name
225
+ )
226
+ else:
227
+ logger.warning(
228
+ "Autorun %s (%s) has no condition function configured (value: %s) - proceeding without condition check. "
229
+ "This autorun will execute regardless of conditions.",
230
+ fresh_autorun.id,
231
+ fresh_autorun.name,
232
+ repr(condition_function)
233
+ )
234
+
235
+ # Parse and execute command
236
+ command_str = fresh_autorun.command
237
+ logger.debug("Parsed autorun command for %s: %s", fresh_autorun.id, command_str)
238
+ command = executor.parse_command(command_str)
239
+
240
+ # Add entry and mark session as running
241
+ entry = session_manager.add_entry(session_id, command_str)
242
+ session_manager.update_session_status(session_id, SessionStatus.RUNNING)
243
+
244
+ # Broadcast that autorun execution started for any connected clients
245
+ await broadcast_to_session(session_id, {
246
+ "type": "execution_started",
247
+ "entry_id": entry.id,
248
+ "command": command_str,
249
+ })
250
+
251
+ result: Optional[ExecutionResult] = await executor.execute_command(command)
252
+
253
+ # Update entry and session status
254
+ status = SessionStatus.COMPLETED if result and result.success else SessionStatus.FAILED
255
+ session_manager.update_entry(
256
+ session_id,
257
+ entry.id,
258
+ result=result.to_dict() if result else None,
259
+ status=status,
260
+ )
261
+ session_manager.update_session_status(session_id, status)
262
+
263
+ # Broadcast result so any open terminals update live
264
+ await broadcast_to_session(session_id, {
265
+ "type": "execution_completed",
266
+ "entry_id": entry.id,
267
+ "result": result.to_dict() if result else None,
268
+ })
269
+
270
+ # Update autorun schedule (last_run / next_run)
271
+ now = datetime.now()
272
+ next_run = now + timedelta(seconds=fresh_autorun.interval_seconds)
273
+ session_manager.update_autorun(
274
+ fresh_autorun.id,
275
+ last_run=now,
276
+ next_run=next_run,
277
+ )
278
+
279
+ logger.info(
280
+ "Completed autorun %s (%s) with status %s; next run at %s (session_id=%s)",
281
+ fresh_autorun.id,
282
+ fresh_autorun.name,
283
+ status.value,
284
+ next_run.isoformat(),
285
+ session_id,
286
+ )
287
+ except Exception as e:
288
+ logger.exception("Error executing autorun %s (%s): %s", autorun.id, autorun.name, e)
289
+ # Best-effort: still push next_run forward to avoid tight failure loops
290
+ try:
291
+ now = datetime.now()
292
+ next_run = now + timedelta(seconds=autorun.interval_seconds)
293
+ session_manager.update_autorun(
294
+ autorun.id,
295
+ last_run=now,
296
+ next_run=next_run,
297
+ )
298
+ except Exception:
299
+ logger.warning("Failed to update schedule for autorun %s after error", autorun.id)
300
+ finally:
301
+ running_autoruns.discard(autorun.id)
302
+
303
+
304
+ async def _check_autorun_condition(condition_function: str, executor: AgentExecutor) -> Tuple[bool, Dict[str, Any]]:
305
+ """
306
+ Check if an autorun condition function returns content.
307
+
308
+ Returns:
309
+ tuple: (should_proceed: bool, details: dict)
310
+ - should_proceed: True if the function returns non-empty content (should proceed),
311
+ False if it returns empty/None (should skip execution)
312
+ - details: Dictionary with verbose information about the condition check
313
+ """
314
+ details = {
315
+ "condition_function": condition_function,
316
+ "command_executed": None,
317
+ "execution_success": False,
318
+ "output": None,
319
+ "output_type": None,
320
+ "evaluation": "",
321
+ "uninvestigated_alerts": None,
322
+ "total_alerts": None,
323
+ "groups_count": None,
324
+ "cases_count": None,
325
+ }
326
+
327
+ try:
328
+ # SPECIAL-CASE: get_recent_alerts should be executed directly at the Python level,
329
+ # not via the AI agent / cursor-agent. This avoids consuming AI
330
+ # tokens just to check if there is work to do.
331
+ if condition_function == "get_recent_alerts":
332
+ try:
333
+ # Import here to avoid heavy imports at module load
334
+ from ...core.config_storage import load_config_from_file
335
+ from src.integrations.siem.elastic.elastic_client import ElasticSIEMClient
336
+ from src.orchestrator.tools_siem import get_recent_alerts
337
+ except Exception as e:
338
+ logger.exception("Failed to import dependencies for get_recent_alerts condition: %s", e)
339
+ details["evaluation"] = "✗ CONDITION ERROR: Failed to import get_recent_alerts dependencies"
340
+ details["error"] = str(e)
341
+ return False, details
342
+
343
+ try:
344
+ config = load_config_from_file()
345
+ if not getattr(config, "elastic", None):
346
+ msg = "Elastic SIEM is not configured; cannot evaluate get_recent_alerts condition"
347
+ logger.warning(msg)
348
+ details["evaluation"] = f"✗ CONDITION ERROR: {msg}"
349
+ details["error"] = msg
350
+ return False, details
351
+
352
+ # Build SIEM client directly from config
353
+ siem_client = ElasticSIEMClient.from_config(config)
354
+ details["command_executed"] = "python:get_recent_alerts(hours_back=1, max_alerts=100)"
355
+
356
+ logger.debug("Executing get_recent_alerts condition directly via SIEM client")
357
+ output = get_recent_alerts(hours_back=1, max_alerts=100, client=siem_client)
358
+ details["execution_success"] = True
359
+ details["output"] = output
360
+ details["output_type"] = type(output).__name__ if output is not None else "None"
361
+ except Exception as e:
362
+ logger.exception("Error executing get_recent_alerts condition directly: %s", e)
363
+ details["evaluation"] = "✗ CONDITION ERROR: Failed to execute get_recent_alerts condition"
364
+ details["error"] = str(e)
365
+ return False, details
366
+
367
+ elif condition_function == "list_cases":
368
+ try:
369
+ # Import here to avoid heavy imports at module load
370
+ from ...core.config_storage import load_config_from_file
371
+ from src.integrations.case_management.iris.iris_client import IRISCaseManagementClient
372
+ from src.integrations.case_management.thehive.thehive_client import TheHiveCaseManagementClient
373
+ from src.orchestrator.tools_case import list_cases
374
+ except Exception as e:
375
+ logger.exception("Failed to import dependencies for list_cases condition: %s", e)
376
+ details["evaluation"] = "✗ CONDITION ERROR: Failed to import list_cases dependencies"
377
+ details["error"] = str(e)
378
+ return False, details
379
+
380
+ try:
381
+ config = load_config_from_file()
382
+ # Prioritize IRIS if both are configured (same as mcp_server.py)
383
+ case_client = None
384
+ if getattr(config, "iris", None):
385
+ case_client = IRISCaseManagementClient.from_config(config)
386
+ details["command_executed"] = "python:list_cases(status='open', limit=50) [IRIS]"
387
+ elif getattr(config, "thehive", None):
388
+ case_client = TheHiveCaseManagementClient.from_config(config)
389
+ details["command_executed"] = "python:list_cases(status='open', limit=50) [TheHive]"
390
+ else:
391
+ msg = "Case management system (IRIS or TheHive) is not configured; cannot evaluate list_cases condition"
392
+ logger.warning(msg)
393
+ details["evaluation"] = f"✗ CONDITION ERROR: {msg}"
394
+ details["error"] = msg
395
+ return False, details
396
+
397
+ logger.debug("Executing list_cases condition directly via case management client")
398
+ output = list_cases(status="open", limit=50, client=case_client)
399
+ details["execution_success"] = True
400
+ details["output"] = output
401
+ details["output_type"] = type(output).__name__ if output is not None else "None"
402
+ except Exception as e:
403
+ logger.exception("Error executing list_cases condition directly: %s", e)
404
+ details["evaluation"] = "✗ CONDITION ERROR: Failed to execute list_cases condition"
405
+ details["error"] = str(e)
406
+ return False, details
407
+
408
+ else:
409
+ # Generic fallback: use the AgentExecutor to run the condition tool.
410
+ # NOTE: This path may involve the external AI agent for unknown commands,
411
+ # so prefer explicit Python-level implementations for conditions.
412
+ # Parse the condition function as a command
413
+ # Support formats like:
414
+ # - "get_recent_alerts"
415
+ # - "run get_recent_alerts"
416
+ # - "run get_recent_alerts with hours_back=1"
417
+ condition_command_str = condition_function
418
+ if not condition_command_str.startswith("run "):
419
+ condition_command_str = f"run {condition_function}"
420
+
421
+ details["command_executed"] = condition_command_str
422
+ logger.debug("Parsing condition command: %s", condition_command_str)
423
+ condition_command = executor.parse_command(condition_command_str)
424
+
425
+ # Execute the condition function
426
+ logger.debug("Executing condition function via AgentExecutor: %s", condition_function)
427
+ result = await executor.execute_command(condition_command)
428
+
429
+ details["execution_success"] = result is not None and result.success
430
+
431
+ if not result or not result.success:
432
+ logger.debug("Condition function '%s' failed or returned no result", condition_function)
433
+ details["evaluation"] = f"Condition function '{condition_function}' failed or returned no result"
434
+ if result:
435
+ details["error"] = result.error
436
+ return False, details
437
+
438
+ # Check if the output has content
439
+ output = result.output
440
+ details["output"] = output
441
+ details["output_type"] = type(output).__name__ if output is not None else "None"
442
+
443
+ if output is None:
444
+ logger.debug("Condition function '%s' returned None", condition_function)
445
+ details["evaluation"] = "Condition function returned None - no content to proceed"
446
+ return False, details
447
+
448
+ # Handle case where output might be wrapped (e.g., from ExecutionResult.to_dict())
449
+ if isinstance(output, dict) and "raw" in output and len(output) == 2 and "text" in output:
450
+ # Output was wrapped as string representation, extract if possible
451
+ # For now, treat wrapped output as having content (conservative approach)
452
+ logger.debug("Condition function '%s' returned wrapped output", condition_function)
453
+ details["evaluation"] = (
454
+ "✓ CONDITION PASSED: Wrapped output (raw/text) returned. "
455
+ "Proceeding with autorun execution."
456
+ )
457
+ return True, details
458
+
459
+ # Handle different output formats
460
+ # If it's a dict, check for common content indicators
461
+ if isinstance(output, dict):
462
+ # Check for get_recent_alerts specific format - focus on uninvestigated_alerts
463
+ if "uninvestigated_alerts" in output:
464
+ uninvestigated_alerts = output.get("uninvestigated_alerts", 0)
465
+ total_alerts = output.get("total_alerts", 0)
466
+ groups = output.get("groups", [])
467
+ groups_count = len(groups) if isinstance(groups, list) else 0
468
+
469
+ details["uninvestigated_alerts"] = uninvestigated_alerts
470
+ details["total_alerts"] = total_alerts
471
+ details["groups_count"] = groups_count
472
+
473
+ if uninvestigated_alerts > 0:
474
+ logger.debug("Condition function '%s' returned %d uninvestigated alerts - proceeding",
475
+ condition_function, uninvestigated_alerts)
476
+ details["evaluation"] = (
477
+ f"✓ CONDITION PASSED: Found {uninvestigated_alerts} uninvestigated alert(s) "
478
+ f"(total: {total_alerts}, groups: {groups_count}). Proceeding with autorun execution."
479
+ )
480
+ return True, details
481
+ else:
482
+ logger.debug("Condition function '%s' returned 0 uninvestigated alerts - skipping execution", condition_function)
483
+ details["evaluation"] = (
484
+ f"✗ CONDITION FAILED: No uninvestigated alerts found "
485
+ f"(total: {total_alerts}, groups: {groups_count}). Skipping autorun execution."
486
+ )
487
+ return False, details
488
+
489
+ # Check for groups (fallback for get_recent_alerts if uninvestigated_alerts not present)
490
+ if "groups" in output:
491
+ groups = output.get("groups", [])
492
+ groups_count = len(groups) if isinstance(groups, list) else 0
493
+ details["groups_count"] = groups_count
494
+
495
+ if groups_count > 0:
496
+ logger.debug("Condition function '%s' returned %d alert groups", condition_function, groups_count)
497
+ details["evaluation"] = f"✓ CONDITION PASSED: Found {groups_count} alert group(s). Proceeding with autorun execution."
498
+ return True, details
499
+ else:
500
+ logger.debug("Condition function '%s' returned no alert groups", condition_function)
501
+ details["evaluation"] = "✗ CONDITION FAILED: No alert groups found. Skipping autorun execution."
502
+ return False, details
503
+
504
+ # Check for list_cases specific format - focus on cases count
505
+ if "cases" in output and "count" in output:
506
+ cases = output.get("cases", [])
507
+ cases_count = output.get("count", 0)
508
+
509
+ details["cases_count"] = cases_count
510
+
511
+ if cases_count > 0:
512
+ logger.debug("Condition function '%s' returned %d case(s) - proceeding",
513
+ condition_function, cases_count)
514
+ details["evaluation"] = (
515
+ f"✓ CONDITION PASSED: Found {cases_count} open case(s). "
516
+ "Proceeding with autorun execution."
517
+ )
518
+ return True, details
519
+ else:
520
+ logger.debug("Condition function '%s' returned 0 cases - skipping execution", condition_function)
521
+ details["evaluation"] = (
522
+ f"✗ CONDITION FAILED: No open cases found. Skipping autorun execution."
523
+ )
524
+ return False, details
525
+
526
+ # Check for common keys that indicate content
527
+ if "alerts" in output:
528
+ alerts = output.get("alerts", [])
529
+ alerts_count = len(alerts) if isinstance(alerts, list) else 0
530
+ if alerts_count > 0:
531
+ logger.debug("Condition function '%s' returned %d alerts", condition_function, alerts_count)
532
+ details["evaluation"] = f"✓ CONDITION PASSED: Found {alerts_count} alert(s). Proceeding with autorun execution."
533
+ return True, details
534
+ else:
535
+ logger.debug("Condition function '%s' returned empty alerts list", condition_function)
536
+ details["evaluation"] = "✗ CONDITION FAILED: Empty alerts list. Skipping autorun execution."
537
+ return False, details
538
+
539
+ if "events" in output:
540
+ events = output.get("events", [])
541
+ events_count = len(events) if isinstance(events, list) else 0
542
+ if events_count > 0:
543
+ logger.debug("Condition function '%s' returned %d events", condition_function, events_count)
544
+ details["evaluation"] = f"✓ CONDITION PASSED: Found {events_count} event(s). Proceeding with autorun execution."
545
+ return True, details
546
+ else:
547
+ logger.debug("Condition function '%s' returned empty events list", condition_function)
548
+ details["evaluation"] = "✗ CONDITION FAILED: Empty events list. Skipping autorun execution."
549
+ return False, details
550
+
551
+ if "results" in output:
552
+ results = output.get("results", [])
553
+ results_count = len(results) if isinstance(results, list) else 0
554
+ if results_count > 0:
555
+ logger.debug("Condition function '%s' returned %d results", condition_function, results_count)
556
+ details["evaluation"] = f"✓ CONDITION PASSED: Found {results_count} result(s). Proceeding with autorun execution."
557
+ return True, details
558
+ else:
559
+ logger.debug("Condition function '%s' returned empty results list", condition_function)
560
+ details["evaluation"] = "✗ CONDITION FAILED: Empty results list. Skipping autorun execution."
561
+ return False, details
562
+
563
+ # Check if dict has any non-empty values
564
+ has_content = any(
565
+ v is not None and v != "" and v != [] and v != {}
566
+ for v in output.values()
567
+ )
568
+ if has_content:
569
+ logger.debug("Condition function '%s' returned dict with content", condition_function)
570
+ details["evaluation"] = "✓ CONDITION PASSED: Dictionary contains non-empty values. Proceeding with autorun execution."
571
+ return True, details
572
+ else:
573
+ logger.debug("Condition function '%s' returned empty dict", condition_function)
574
+ details["evaluation"] = "✗ CONDITION FAILED: Dictionary is empty or contains only empty values. Skipping autorun execution."
575
+ return False, details
576
+
577
+ # If it's a list, check if it has items
578
+ if isinstance(output, list):
579
+ list_count = len(output)
580
+ if list_count > 0:
581
+ logger.debug("Condition function '%s' returned list with %d items", condition_function, list_count)
582
+ details["evaluation"] = f"✓ CONDITION PASSED: List contains {list_count} item(s). Proceeding with autorun execution."
583
+ return True, details
584
+ else:
585
+ logger.debug("Condition function '%s' returned empty list", condition_function)
586
+ details["evaluation"] = "✗ CONDITION FAILED: List is empty. Skipping autorun execution."
587
+ return False, details
588
+
589
+ # If it's a string, check if it's non-empty
590
+ if isinstance(output, str):
591
+ if output.strip():
592
+ logger.debug("Condition function '%s' returned non-empty string", condition_function)
593
+ details["evaluation"] = f"✓ CONDITION PASSED: Non-empty string returned (length: {len(output)}). Proceeding with autorun execution."
594
+ return True, details
595
+ else:
596
+ logger.debug("Condition function '%s' returned empty string", condition_function)
597
+ details["evaluation"] = "✗ CONDITION FAILED: Empty string returned. Skipping autorun execution."
598
+ return False, details
599
+
600
+ # For other types, check if truthy
601
+ if output:
602
+ logger.debug("Condition function '%s' returned truthy value", condition_function)
603
+ details["evaluation"] = f"✓ CONDITION PASSED: Truthy value returned (type: {type(output).__name__}). Proceeding with autorun execution."
604
+ return True, details
605
+ else:
606
+ logger.debug("Condition function '%s' returned falsy value", condition_function)
607
+ details["evaluation"] = f"✗ CONDITION FAILED: Falsy value returned (type: {type(output).__name__}). Skipping autorun execution."
608
+ return False, details
609
+
610
+ except Exception as e:
611
+ logger.exception("Error checking condition function '%s': %s", condition_function, e)
612
+ details["error"] = str(e)
613
+ details["evaluation"] = f"✗ CONDITION ERROR: Exception occurred - {str(e)}. Skipping autorun execution for safety."
614
+ # On error, default to skipping execution to be safe
615
+ return False, details
616
+
617
+
618
+ async def autorun_scheduler_loop():
619
+ """
620
+ Background loop that periodically checks for enabled autoruns and runs them
621
+ when their next_run is due.
622
+ """
623
+ logger.info("Starting autorun scheduler loop")
624
+ while True:
625
+ try:
626
+ if not session_manager or not executor:
627
+ await asyncio.sleep(5)
628
+ continue
629
+
630
+ now = datetime.now()
631
+ autoruns = session_manager.list_autoruns(enabled_only=True)
632
+
633
+ for autorun in autoruns:
634
+ # If no schedule yet, initialize next_run to now
635
+ if not autorun.next_run:
636
+ session_manager.update_autorun(
637
+ autorun.id,
638
+ next_run=now + timedelta(seconds=autorun.interval_seconds),
639
+ )
640
+ continue
641
+
642
+ # Run when next_run is due or in the past, and not already running
643
+ if autorun.id in running_autoruns:
644
+ continue
645
+
646
+ if autorun.next_run <= now:
647
+ running_autoruns.add(autorun.id)
648
+ asyncio.create_task(_run_autorun(autorun))
649
+ except Exception as e:
650
+ logger.exception("Error in autorun scheduler loop: %s", e)
651
+
652
+ # Poll interval
653
+ await asyncio.sleep(5)
654
+
655
+
656
+ @app.on_event("startup")
657
+ async def on_startup():
658
+ """Start background tasks such as the autorun scheduler."""
659
+ global autorun_scheduler_task
660
+ if autorun_scheduler_task is None:
661
+ autorun_scheduler_task = asyncio.create_task(autorun_scheduler_loop())
662
+ logger.info("Autorun scheduler task started")
663
+
664
+
665
+ @app.on_event("shutdown")
666
+ async def on_shutdown():
667
+ """Cleanly stop background tasks."""
668
+ global autorun_scheduler_task
669
+ if autorun_scheduler_task:
670
+ autorun_scheduler_task.cancel()
671
+ autorun_scheduler_task = None
672
+
673
+
674
+ # Determine paths
675
+ WEB_DIR = Path(__file__).parent
676
+ STATIC_DIR = WEB_DIR / "static"
677
+ TEMPLATES_DIR = WEB_DIR / "templates"
678
+
679
+ # Create directories if they don't exist
680
+ STATIC_DIR.mkdir(parents=True, exist_ok=True)
681
+ TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
682
+
683
+ # Mount static files
684
+ if STATIC_DIR.exists():
685
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
686
+
687
+
688
+ async def broadcast_to_session(session_id: str, message: dict):
689
+ """Broadcast a message to all WebSocket connections for a session."""
690
+ if session_id in active_connections:
691
+ disconnected = []
692
+ for connection in active_connections[session_id]:
693
+ try:
694
+ await connection.send_json(message)
695
+ except Exception as e:
696
+ logger.error(f"Error sending message to WebSocket: {e}")
697
+ disconnected.append(connection)
698
+
699
+ # Remove disconnected connections
700
+ for conn in disconnected:
701
+ active_connections[session_id].remove(conn)
702
+
703
+
704
+ @app.get("/", response_class=HTMLResponse)
705
+ async def root():
706
+ """Serve the main controller interface."""
707
+ html_path = TEMPLATES_DIR / "index.html"
708
+ if html_path.exists():
709
+ with open(html_path, "r") as f:
710
+ return HTMLResponse(content=f.read())
711
+ return HTMLResponse(content="<h1>AI Controller</h1><p>index.html not found</p>")
712
+
713
+
714
+ @app.get("/api/config")
715
+ async def get_ui_config():
716
+ """Return UI-related configuration flags (e.g., debug mode)."""
717
+ return JSONResponse(
718
+ content={
719
+ "success": True,
720
+ "ui_debug": UI_DEBUG_MODE,
721
+ }
722
+ )
723
+
724
+
725
+ @app.post("/api/config")
726
+ async def update_ui_config(config: UIConfigUpdate):
727
+ """Update UI-related configuration flags (currently debug mode)."""
728
+ global UI_DEBUG_MODE
729
+
730
+ if config.ui_debug is not None:
731
+ UI_DEBUG_MODE = config.ui_debug
732
+ logger.info("UI debug mode updated via API: %s", UI_DEBUG_MODE)
733
+
734
+ return JSONResponse(
735
+ content={
736
+ "success": True,
737
+ "ui_debug": UI_DEBUG_MODE,
738
+ }
739
+ )
740
+
741
+
742
+ @app.get("/api/sessions")
743
+ async def list_sessions(session_type: Optional[str] = None):
744
+ """List all sessions."""
745
+ if not session_manager:
746
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
747
+
748
+ sessions = session_manager.list_sessions()
749
+ if session_type:
750
+ try:
751
+ type_enum = SessionType(session_type)
752
+ sessions = [s for s in sessions if s.session_type == type_enum]
753
+ except ValueError:
754
+ pass
755
+
756
+ logger.debug(
757
+ "Listing sessions (requested_type=%s, returned_count=%d)",
758
+ session_type,
759
+ len(sessions),
760
+ )
761
+ return JSONResponse(content={
762
+ "success": True,
763
+ "sessions": [s.to_dict() for s in sessions]
764
+ })
765
+
766
+
767
+ @app.post("/api/sessions")
768
+ async def create_session(request: Request):
769
+ """Create a new session."""
770
+ if not session_manager:
771
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
772
+
773
+ data = await request.json()
774
+ name = data.get("name", "New Session")
775
+ session_type = SessionType(data.get("session_type", "manual"))
776
+
777
+ session = session_manager.create_session(name, session_type)
778
+
779
+ return JSONResponse(content={
780
+ "success": True,
781
+ "session": session.to_dict()
782
+ })
783
+
784
+
785
+ @app.get("/api/sessions/{session_id}")
786
+ async def get_session(session_id: str):
787
+ """Get a session by ID."""
788
+ if not session_manager:
789
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
790
+
791
+ session = session_manager.get_session(session_id)
792
+ if not session:
793
+ raise HTTPException(status_code=404, detail="Session not found")
794
+
795
+ return JSONResponse(content={
796
+ "success": True,
797
+ "session": session.to_dict()
798
+ })
799
+
800
+
801
+ @app.delete("/api/sessions/{session_id}")
802
+ async def delete_session(session_id: str):
803
+ """Delete a session and clean up all associated resources."""
804
+ if not session_manager:
805
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
806
+
807
+ # Cancel any running tasks for this session
808
+ task = running_tasks.get(session_id)
809
+ if task and not task.done():
810
+ task.cancel()
811
+ logger.info(f"Deleting session {session_id}, cancelling running task")
812
+
813
+ # Best-effort: cancel any underlying external process (e.g., cursor-agent)
814
+ if executor:
815
+ try:
816
+ logger.info("Deleting session %s, attempting to cancel external execution process", session_id)
817
+ executor.cancel_current_execution()
818
+ except Exception as e:
819
+ logger.warning("Error cancelling external execution while deleting session %s: %s", session_id, e)
820
+
821
+ # Close all WebSocket connections for this session
822
+ if session_id in active_connections:
823
+ connections = active_connections[session_id].copy()
824
+ for connection in connections:
825
+ try:
826
+ await connection.close()
827
+ except Exception as e:
828
+ logger.warning(f"Error closing WebSocket for session {session_id}: {e}")
829
+ del active_connections[session_id]
830
+
831
+ # Clean up task tracking
832
+ running_tasks.pop(session_id, None)
833
+
834
+ # Delete the session (removes file and cache)
835
+ try:
836
+ logger.info(f"Attempting to delete session {session_id}")
837
+ session_manager.delete_session(session_id)
838
+
839
+ # Verify file was actually deleted
840
+ session_file = session_manager.sessions_dir / f"{session_id}.json"
841
+ if session_file.exists():
842
+ logger.warning(f"Session file still exists after delete: {session_file}")
843
+ # Try to delete again
844
+ try:
845
+ session_file.unlink()
846
+ logger.info(f"Force deleted session file: {session_file}")
847
+ except Exception as e:
848
+ logger.error(f"Failed to force delete session file: {e}")
849
+ else:
850
+ logger.info(f"Session file successfully deleted: {session_file}")
851
+
852
+ logger.info(f"Deleted session {session_id} and cleaned up all resources")
853
+ return JSONResponse(content={"success": True, "message": f"Session {session_id} deleted"})
854
+ except ValueError as e:
855
+ logger.error(f"Session {session_id} not found for deletion: {e}")
856
+ raise HTTPException(status_code=404, detail=str(e))
857
+ except Exception as e:
858
+ logger.exception(f"Unexpected error deleting session {session_id}: {e}")
859
+ raise HTTPException(status_code=500, detail=f"Failed to delete session: {str(e)}")
860
+
861
+
862
+ @app.post("/api/sessions/{session_id}/stop")
863
+ async def stop_session(session_id: str):
864
+ """Best-effort stop for a running session (similar to Ctrl+C)."""
865
+ if not session_manager:
866
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
867
+
868
+ session = session_manager.get_session(session_id)
869
+ if not session:
870
+ raise HTTPException(status_code=404, detail="Session not found")
871
+
872
+ # Cancel any running task for this session
873
+ task = running_tasks.get(session_id)
874
+ if task and not task.done():
875
+ task.cancel()
876
+ logger.info(f"Stop requested for session {session_id}, cancelling running task")
877
+ # Note: The task's finally block will clean up running_tasks entry
878
+ else:
879
+ logger.info(f"Stop requested for session {session_id}, but no active task found")
880
+
881
+ # Always attempt to cancel any underlying external process (e.g., cursor-agent),
882
+ # since there is at most one such process tracked globally in the executor.
883
+ if executor:
884
+ try:
885
+ executor.cancel_current_execution()
886
+ except Exception as e:
887
+ logger.warning(f"Error cancelling external execution for session {session_id}: {e}")
888
+
889
+ # Mark session as stopped to reflect user's intent
890
+ session_manager.update_session_status(session_id, SessionStatus.STOPPED)
891
+
892
+ # Broadcast stop event to any connected WebSocket clients
893
+ await broadcast_to_session(session_id, {
894
+ "type": "execution_stopped",
895
+ "message": "Session stopped by user"
896
+ })
897
+
898
+ return JSONResponse(content={"success": True, "status": "stopped"})
899
+
900
+
901
+ @app.post("/api/sessions/{session_id}/execute")
902
+ async def execute_command(session_id: str, command_request: CommandRequest):
903
+ """Execute a command in a session."""
904
+ if not executor or not session_manager:
905
+ raise HTTPException(status_code=500, detail="Server not initialized")
906
+
907
+ session = session_manager.get_session(session_id)
908
+ if not session:
909
+ raise HTTPException(status_code=404, detail="Session not found")
910
+
911
+ # Parse command
912
+ command = executor.parse_command(command_request.command)
913
+
914
+ # Add entry to session
915
+ entry = session_manager.add_entry(session_id, command_request.command)
916
+
917
+ # Broadcast that execution started
918
+ await broadcast_to_session(session_id, {
919
+ "type": "execution_started",
920
+ "entry_id": entry.id,
921
+ "command": command_request.command
922
+ })
923
+
924
+ # Execute command asynchronously
925
+ async def execute_and_update():
926
+ try:
927
+ session_manager.update_session_status(session_id, SessionStatus.RUNNING)
928
+
929
+ result = await executor.execute_command(command)
930
+
931
+ # Update entry
932
+ session_manager.update_entry(
933
+ session_id,
934
+ entry.id,
935
+ result=result.to_dict() if result else None,
936
+ status=SessionStatus.COMPLETED if result and result.success else SessionStatus.FAILED
937
+ )
938
+
939
+ # Update session status
940
+ final_status = SessionStatus.COMPLETED if result and result.success else SessionStatus.FAILED
941
+ session_manager.update_session_status(session_id, final_status)
942
+
943
+ # Broadcast result
944
+ await broadcast_to_session(session_id, {
945
+ "type": "execution_completed",
946
+ "entry_id": entry.id,
947
+ "result": result.to_dict() if result else None
948
+ })
949
+ except asyncio.CancelledError:
950
+ # Task was cancelled (user clicked Stop / closed tab)
951
+ logger.info(f"Execution task for session {session_id} was cancelled")
952
+
953
+ # Only update session if it still exists (might be deleted during cancellation)
954
+ try:
955
+ session = session_manager.get_session(session_id)
956
+ if session:
957
+ session_manager.update_entry(
958
+ session_id,
959
+ entry.id,
960
+ status=SessionStatus.STOPPED
961
+ )
962
+ session_manager.update_session_status(session_id, SessionStatus.STOPPED)
963
+
964
+ await broadcast_to_session(session_id, {
965
+ "type": "execution_failed",
966
+ "entry_id": entry.id,
967
+ "error": "Execution stopped by user"
968
+ })
969
+ except Exception as e:
970
+ logger.warning(f"Session {session_id} may have been deleted during cancellation: {e}")
971
+ except Exception as e:
972
+ logger.exception(f"Error executing command in session {session_id}")
973
+
974
+ # Only update session if it still exists (might be deleted)
975
+ try:
976
+ session = session_manager.get_session(session_id)
977
+ if session:
978
+ session_manager.update_entry(
979
+ session_id,
980
+ entry.id,
981
+ status=SessionStatus.FAILED
982
+ )
983
+ session_manager.update_session_status(session_id, SessionStatus.FAILED)
984
+
985
+ await broadcast_to_session(session_id, {
986
+ "type": "execution_failed",
987
+ "entry_id": entry.id,
988
+ "error": str(e)
989
+ })
990
+ except Exception as update_error:
991
+ logger.warning(f"Session {session_id} may have been deleted during error handling: {update_error}")
992
+ finally:
993
+ # Clear running task reference
994
+ if session_id in running_tasks:
995
+ running_tasks.pop(session_id, None)
996
+
997
+ # Run in background and track task for potential cancellation
998
+ task = asyncio.create_task(execute_and_update())
999
+ running_tasks[session_id] = task
1000
+
1001
+ return JSONResponse(content={
1002
+ "success": True,
1003
+ "entry_id": entry.id,
1004
+ "message": "Command execution started"
1005
+ })
1006
+
1007
+
1008
+ @app.websocket("/ws/sessions/{session_id}")
1009
+ async def websocket_session(websocket: WebSocket, session_id: str):
1010
+ """WebSocket endpoint for real-time session updates."""
1011
+ await websocket.accept()
1012
+
1013
+ # Add to active connections
1014
+ if session_id not in active_connections:
1015
+ active_connections[session_id] = []
1016
+ active_connections[session_id].append(websocket)
1017
+
1018
+ logger.info(f"WebSocket connected for session {session_id}")
1019
+
1020
+ try:
1021
+ while True:
1022
+ # Keep connection alive and handle incoming messages
1023
+ data = await websocket.receive_text()
1024
+ message = json.loads(data)
1025
+
1026
+ # Handle different message types
1027
+ if message.get("type") == "ping":
1028
+ await websocket.send_json({"type": "pong"})
1029
+ except WebSocketDisconnect:
1030
+ logger.info(f"WebSocket disconnected for session {session_id}")
1031
+ except Exception as e:
1032
+ logger.exception(f"Error in WebSocket for session {session_id}: {e}")
1033
+ finally:
1034
+ # Remove from active connections
1035
+ if session_id in active_connections:
1036
+ if websocket in active_connections[session_id]:
1037
+ active_connections[session_id].remove(websocket)
1038
+ if not active_connections[session_id]:
1039
+ del active_connections[session_id]
1040
+
1041
+
1042
+ # Autorun endpoints
1043
+ @app.get("/api/autoruns")
1044
+ async def list_autoruns(enabled_only: bool = False):
1045
+ """List all autoruns."""
1046
+ if not session_manager:
1047
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
1048
+
1049
+ autoruns = session_manager.list_autoruns(enabled_only=enabled_only)
1050
+
1051
+ return JSONResponse(content={
1052
+ "success": True,
1053
+ "autoruns": [a.to_dict() for a in autoruns]
1054
+ })
1055
+
1056
+
1057
+ @app.post("/api/autoruns")
1058
+ async def create_autorun(autorun_request: AutorunCreateRequest):
1059
+ """Create a new autorun."""
1060
+ if not session_manager:
1061
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
1062
+
1063
+ autorun = session_manager.create_autorun(
1064
+ name=autorun_request.name,
1065
+ command=autorun_request.command,
1066
+ interval_seconds=autorun_request.interval_seconds,
1067
+ condition_function=autorun_request.condition_function
1068
+ )
1069
+
1070
+ return JSONResponse(content={
1071
+ "success": True,
1072
+ "autorun": autorun.to_dict()
1073
+ })
1074
+
1075
+
1076
+ @app.get("/api/autoruns/{autorun_id}")
1077
+ async def get_autorun(autorun_id: str):
1078
+ """Get an autorun by ID."""
1079
+ if not session_manager:
1080
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
1081
+
1082
+ autorun = session_manager.get_autorun(autorun_id)
1083
+ if not autorun:
1084
+ raise HTTPException(status_code=404, detail="Autorun not found")
1085
+
1086
+ return JSONResponse(content={
1087
+ "success": True,
1088
+ "autorun": autorun.to_dict()
1089
+ })
1090
+
1091
+
1092
+ @app.put("/api/autoruns/{autorun_id}")
1093
+ async def update_autorun(autorun_id: str, autorun_update: AutorunUpdateRequest):
1094
+ """Update an autorun."""
1095
+ if not session_manager:
1096
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
1097
+
1098
+ update_data = autorun_update.dict(exclude_unset=True)
1099
+ try:
1100
+ session_manager.update_autorun(autorun_id, **update_data)
1101
+ autorun = session_manager.get_autorun(autorun_id)
1102
+ return JSONResponse(content={
1103
+ "success": True,
1104
+ "autorun": autorun.to_dict()
1105
+ })
1106
+ except ValueError as e:
1107
+ raise HTTPException(status_code=404, detail=str(e))
1108
+
1109
+
1110
+ @app.post("/api/autoruns/{autorun_id}/clear")
1111
+ async def clear_autorun_session(autorun_id: str):
1112
+ """Clear all entries from an autorun's backing session."""
1113
+ if not session_manager:
1114
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
1115
+
1116
+ try:
1117
+ autorun = session_manager.get_autorun(autorun_id)
1118
+ if not autorun:
1119
+ raise ValueError(f"Autorun {autorun_id} not found")
1120
+
1121
+ if not autorun.session_id:
1122
+ raise ValueError(f"Autorun {autorun_id} has no associated session")
1123
+
1124
+ logger.info("Clearing session entries for autorun %s (session_id=%s)", autorun_id, autorun.session_id)
1125
+
1126
+ # Clear all entries from the autorun's backing session
1127
+ session_manager.clear_session_entries(autorun.session_id)
1128
+
1129
+ return JSONResponse(content={"success": True})
1130
+ except ValueError as e:
1131
+ logger.error("Error clearing autorun session: %s", e)
1132
+ raise HTTPException(status_code=404, detail=str(e))
1133
+ except Exception as e:
1134
+ logger.exception("Unexpected error clearing autorun session %s: %s", autorun_id, e)
1135
+ raise HTTPException(status_code=500, detail=f"Failed to clear autorun session: {str(e)}")
1136
+
1137
+
1138
+ @app.delete("/api/autoruns/{autorun_id}")
1139
+ async def delete_autorun(autorun_id: str):
1140
+ """Delete an autorun and clean up its associated session/process if present."""
1141
+ if not session_manager:
1142
+ raise HTTPException(status_code=500, detail="Session manager not initialized")
1143
+
1144
+ try:
1145
+ autorun = session_manager.get_autorun(autorun_id)
1146
+ if not autorun:
1147
+ raise ValueError(f"Autorun {autorun_id} not found")
1148
+
1149
+ logger.info("Deleting autorun %s (session_id=%s)", autorun_id, autorun.session_id)
1150
+
1151
+ # Best-effort: if this autorun is currently scheduled as running, stop any underlying execution
1152
+ if autorun_id in running_autoruns:
1153
+ logger.info("Autorun %s is currently running; attempting to cancel execution", autorun_id)
1154
+ if executor:
1155
+ try:
1156
+ executor.cancel_current_execution()
1157
+ except Exception as e:
1158
+ logger.warning("Failed to cancel external execution for autorun %s: %s", autorun_id, e)
1159
+ running_autoruns.discard(autorun_id)
1160
+
1161
+ # Delete autorun configuration and its dedicated session (handled in SessionManager)
1162
+ session_manager.delete_autorun(autorun_id)
1163
+
1164
+ return JSONResponse(content={"success": True})
1165
+ except ValueError as e:
1166
+ logger.error("Autorun %s not found for deletion: %s", autorun_id, e)
1167
+ raise HTTPException(status_code=404, detail=str(e))
1168
+ except Exception as e:
1169
+ logger.exception("Unexpected error deleting autorun %s: %s", autorun_id, e)
1170
+ raise HTTPException(status_code=500, detail=f"Failed to delete autorun: {str(e)}")
1171
+
1172
+
1173
+ if __name__ == "__main__":
1174
+ import uvicorn
1175
+
1176
+ # Initialize
1177
+ initialize()
1178
+
1179
+ print("Starting SamiGPT AI Controller...")
1180
+ uvicorn.run(app, host="0.0.0.0", port=8081)
1181
+