Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt', 'requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

Expand Down Expand Up @@ -59,7 +59,7 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt', 'requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

Expand Down Expand Up @@ -88,7 +88,7 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt', 'requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

# Python cache
__pycache__/
*.py[cod]
.coverage
46 changes: 14 additions & 32 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from api.routes import status, mode
from api.routes import actions, context
from api.routes import actions, context, mode, plugins, status, suppression
from api.websockets import guidance as ws_guidance

from api.websockets import router as ws_router
from core.config import settings
from core.errors import handle_exception # ✅ NEW

from api.routes import suppression
from core.errors import handle_exception
from core.hybrid.action_logger import action_logger

logger = logging.getLogger(__name__)

Expand All @@ -26,57 +24,41 @@
)


# Startup event
@app.on_event("startup")
async def startup_event():
logger.info("Execra API starting...")
# Restore persisted action history and undo state from SQLite.
await action_logger.load()
from api.websockets.router import broadcast_action_log
from core.hybrid.action_logger import action_logger

action_logger.register_callback(broadcast_action_log)
logger.info("Execra API starting...")


# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Execra API shutting down...")
from api.websockets.router import broadcast_action_log
from core.hybrid.action_logger import action_logger

action_logger.unregister_callback(broadcast_action_log)
logger.info("Execra API shutting down...")


# Root endpoint
@app.get("/")
def read_root():
try:
return {
"status": "success",
"data": {
"message": "Execra is running",
"version": "0.1.0"
}
}
return {"status": "success", "data": {"message": "Execra is running", "version": "0.1.0"}}
except Exception as e:
return handle_exception(e)


# Routes (wrapped safely)

try:
app.include_router(status.router, prefix="/api/v1")
app.include_router(mode.router, prefix="/api/v1")
app.include_router(actions.router, prefix="/api/v1")
app.include_router(context.router, prefix="/api/v1")

except Exception as e:
handle_exception(e)


# Action log and session context endpoints
app.include_router(actions.router, prefix="/api/v1")
app.include_router(context.router, prefix="/api/v1")

# WebSocket endpoints (no prefix — WS routes use the path as-is)
app.include_router(ws_guidance.router)

# Alert suppression endpoints
app.include_router(suppression.router, prefix="/api/v1")
app.include_router(ws_router.router)
app.include_router(suppression.router, prefix="/api/v1")
app.include_router(plugins.router, prefix="/api/v1")
51 changes: 37 additions & 14 deletions api/routes/actions.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,62 @@
from fastapi import APIRouter, HTTPException
from core.hybrid.action_logger import action_logger, ActionRecord
from typing import Optional

from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel

from core.hybrid.action_logger import ActionRecord, action_logger

router = APIRouter()


class ReplayRequest(BaseModel):
session_id: Optional[str] = None
speed: float = 1.0


@router.get("/actions")
async def get_actions(limit: int = 20, offset: int = 0):
async def get_actions(limit: int = Query(20, ge=1), offset: int = Query(0, ge=0)):
actions = await action_logger.get_history(limit=limit, offset=offset)
return {
"total": len(actions),
"actions": actions
"actions": [a.to_dict() for a in actions],
}


@router.post("/actions")
async def create_action(action: ActionRecord):
await action_logger.log_action(action)
return {
"message": "Action logged successfully.",
"action": action
"action": action.to_dict(),
}


@router.post("/actions/undo")
async def undo_last_action():
undone = action_logger.undo_last()

if undone is None:
action = await action_logger.undo_last()
if action is None:
raise HTTPException(
status_code=409,
detail="Nothing to undo. Action log is empty."
detail="Nothing to undo. Action log is empty.",
)

return {
"message": "Last action undone successfully.",
"action_undone": {
"id": undone.id,
"description": undone.description
}
"action_undone": action.to_dict(),
}


@router.post("/actions/replay")
async def replay_actions(payload: ReplayRequest):
try:
actions = [
action.to_dict()
async for action in action_logger.replay_session(
session_id=payload.session_id,
speed=payload.speed,
)
]
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

}
return {"total": len(actions), "actions": actions}
9 changes: 6 additions & 3 deletions api/routes/context.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from datetime import datetime
from typing import Literal

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from core.hybrid.action_logger import action_logger

from core.hybrid.action_logger import action_logger

router = APIRouter()

Expand All @@ -24,18 +25,20 @@ class SessionContext(BaseModel):
domain: Literal["digital", "physical", "hybrid"]
started_at: datetime


# In memory placeholder until SessionContext is wired to SQLite
_current_context: SessionContext | None = None


@router.get("/context")
async def get_context():
if _current_context is None:
raise HTTPException(
status_code=404,
detail="No active session context found. Start Execra first."
status_code=404, detail="No active session context found. Start Execra first."
)
return _current_context


@router.delete("/context")
async def clear_context():
global _current_context
Expand Down
12 changes: 6 additions & 6 deletions api/routes/mode.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from core.hybrid.mode_manager import mode_manager

from core.hybrid.mode_manager import mode_manager

router = APIRouter()


class ModeRequest(BaseModel):
mode: str


# Returns current mode with description
@router.get("/mode")
async def get_mode():
return mode_manager.get_current_mode()


# Switches mode based on user input
@router.put("/mode")
async def switch_mode(request: ModeRequest):
try:
mode_manager.switch_mode(request.mode)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid mode value")

result = mode_manager.get_current_mode()
return {
"mode": result["mode"],
"message": result["description"]
}
return {"mode": result["mode"], "message": result["description"]}
3 changes: 2 additions & 1 deletion api/routes/plugins.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi import APIRouter

from core.plugins.rule_loader import PluginLoader

router = APIRouter()
Expand All @@ -19,4 +20,4 @@ def get_plugins():
"trigger_objects": p.trigger_objects,
}
for p in plugins
]
]
12 changes: 7 additions & 5 deletions api/routes/status.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from fastapi import APIRouter
import time

from fastapi import APIRouter

from core.config import settings

router = APIRouter()

start_time = time.time()

@router.get('/status')

@router.get("/status")
async def get_status():
uptime_seconds = int(time.time() - start_time)

Expand All @@ -17,6 +20,5 @@ async def get_status():
"active_domain": "digital",
"active_mode": "passive",
"perception_fps": settings.SCREEN_CAPTURE_FPS,
"llm_backend": settings.LLM_BACKEND
}

"llm_backend": settings.LLM_BACKEND,
}
9 changes: 6 additions & 3 deletions api/routes/suppression.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from fastapi import APIRouter
from core.hybrid.alert_suppressor import alert_suppressor

from core.hybrid.alert_suppressor import alert_suppressor

router = APIRouter()


@router.get("/suppression/stats")
def get_suppression_stats()-> dict:
return alert_suppressor.get_suppression_stats()
def get_suppression_stats() -> dict:
return alert_suppressor.get_suppression_stats()
4 changes: 3 additions & 1 deletion api/websockets/connection_manager.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import logging

from fastapi import WebSocket
from starlette.websockets import WebSocketDisconnect

logger = logging.getLogger(__name__)


class ConnectionManager:
"""Manages active WebSocket connections, handles connection/disconnection, and safe broadcasts."""
"""Manages active WebSocket connections, handles connect/disconnect, and safe broadcasts."""

def __init__(self):
self.active_connections: set[WebSocket] = set()
Expand Down
Loading
Loading