Skip to content
Closed
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
2 changes: 1 addition & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,4 @@ def read_root():
app.include_router(ws_guidance.router)

# Alert suppression endpoints
app.include_router(suppression.router, prefix="/api/v1")
app.include_router(suppression.router, prefix="/api/v1")
70 changes: 56 additions & 14 deletions api/routes/actions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
from typing import Optional

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

from core.hybrid.action_logger import ActionRecord, action_logger
from fastapi import APIRouter, HTTPException
from core.hybrid.action_logger import action_logger, ActionRecord

router = APIRouter()


class ActionCreate(BaseModel):
type: str
description: str
domain: str = "digital"
session_id: str = "default"
was_guided: bool = False
guidance_confidence: float = 0.0
is_undoable: bool = False
undo_instruction: Optional[str] = None


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):
actions = await action_logger.get_history(limit=limit, offset=offset)
def get_actions(limit: int = Query(20, ge=1), offset: int = Query(0, ge=0)):
actions = action_logger.list_actions(limit=limit, offset=offset)
return {
"total": len(actions),
"actions": actions
"total": action_logger.total_actions(),
"actions": [action.to_dict() for action in actions],
}

@router.post("/actions")
Expand All @@ -23,17 +46,36 @@ async def create_action(action: ActionRecord):
async def undo_last_action():
undone = action_logger.undo_last()

if undone is None:
raise HTTPException(
status_code=409,
detail="Nothing to undo. Action log is empty."
)
@router.post("/actions")
def create_action(payload: ActionCreate):
action = ActionRecord(**payload.dict())
action_logger.record_action(action)
return {"action": action.to_dict()}


@router.post("/actions/undo")
def undo_last_action():
action = action_logger.undo_last()
if action is None:
raise HTTPException(status_code=409, detail="Nothing in the undo stack")

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}
118 changes: 43 additions & 75 deletions core/hybrid/action_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,23 @@

logger = logging.getLogger(__name__)

class ActionRecord(BaseModel):
id: str
session_id: str # session_id was missing in the data model, added it here
timestamp: datetime
type: str
description: str
domain: Literal["digital", "physical"]
was_guided: bool
guidance_confidence: float | None

class ActionLogger:
"""Records user actions to SQLite and maintains an in-memory undo stack."""

def __init__(self, db_path: str = "data/execra.db"):
"""Initialize logger with database path and empty undo stack (max 50)."""
if db_path != ":memory:":
os.makedirs(os.path.dirname(db_path), exist_ok=True)
@dataclass
class ActionRecord:
id: str = field(default_factory=lambda: str(uuid4()))
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
type: str = ""
description: str = ""
domain: str = "digital"
session_id: str = "default"
was_guided: bool = False
guidance_confidence: float = 0.0
is_undoable: bool = False
undo_instruction: Optional[str] = None
undone: bool = False

def to_dict(self) -> dict:
return asdict(self)


self.db_path = db_path
self._stack = deque(maxlen=50)
Expand All @@ -42,29 +42,16 @@ def unregister_callback(self, cb) -> None:
if cb in self.on_log_callbacks:
self.on_log_callbacks.remove(cb)

async def _init_db(self):
"""Create the action_log table if it doesn't exist."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS action_log (
id TEXT PRIMARY KEY,
session_id TEXT,
timestamp TEXT,
type TEXT,
description TEXT,
domain TEXT,
was_guided INTEGER,
guidance_confidence REAL
)
""")
await db.commit()
def record_action(self, action: ActionRecord) -> ActionRecord:
self._actions.append(action)
return action

async def log_action(self, action: ActionRecord) -> None:
"""Save action to SQLite, append to stack, and trigger callbacks."""
await self._init_db() # ensure table exists

# Add to in-memory deque
self._stack.append(action)
def total_actions(self) -> int:
return len(self._actions)

# Save to SQLite
async with aiosqlite.connect(self.db_path) as db:
Expand Down Expand Up @@ -94,48 +81,29 @@ async def log_action(self, action: ActionRecord) -> None:
logger.error(f"Error in action log callback: {e}")

def undo_last(self) -> Optional[ActionRecord]:
"""Pop and return the last action from the undo stack. Returns None if empty."""
if not self._stack:
return None
return self._stack.pop()

async def get_history(self, limit: int = 20, offset: int = 0) -> list[ActionRecord]:
"""Fetch paginated action history from SQLite, newest first."""
await self._init_db() # ensure table exists
for action in reversed(self._actions):
if action.is_undoable and not action.undone:
action.undone = True
return action
return None

async def replay_session(
self, session_id: Optional[str] = None, speed: float = 1.0
) -> AsyncIterator[ActionRecord]:
if speed <= 0:
raise ValueError("Replay speed must be greater than 0")

for action in self._actions:
if session_id is None or action.session_id == session_id:
await asyncio.sleep(0)
yield action

def clear(self) -> None:
self._actions.clear()

async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute("""
SELECT * FROM action_log
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
""", (limit, offset))
rows = await cursor.fetchall()

return [
ActionRecord(
id=row[0],
session_id=row[1],
timestamp=datetime.fromisoformat(row[2]),
type=row[3],
description=row[4],
domain=row[5],
was_guided=bool(row[6]),
guidance_confidence=row[7]
)
for row in rows
]
async def clear_session(self, session_id: str) -> None:
"""Delete all actions for the session from SQLite and clear the in-memory stack."""
await self._init_db() # ensure table exists

async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"DELETE FROM action_log WHERE session_id = ?",
(session_id,)
)
await db.commit()
self._actions = [a for a in self._actions if a.session_id != session_id]

self._stack.clear()

async def log_error(self, session_id: str, step: int, error: str) -> None:
"""Encrypt and save an error to the error_history table."""
Expand Down Expand Up @@ -183,4 +151,4 @@ async def get_errors(self, session_id: str) -> list[Dict[str, Any]]:
})
return errors

action_logger = ActionLogger()
action_logger = ActionLogger()
17 changes: 8 additions & 9 deletions tests/integration/test_actions_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,20 @@ def test_create_action():
def test_undo_returns_409_when_empty():
response = client.post("/api/v1/actions/undo")
assert response.status_code == 409
assert "Nothing to undo" in response.json()["detail"]
assert "Nothing in the undo stack" in response.json()["detail"]

def test_undo_returns_undone_action():
action = ActionRecord(
id="act_001",
session_id="sess_001",
timestamp=datetime.now(),
type="code_edit",
description="Modified line 42",
domain="digital",
was_guided=True,
guidance_confidence=0.9
guidance_confidence=0.9,
is_undoable=True,
)
action_logger._stack.append(action)
action_logger.record_action(action)

response = client.post("/api/v1/actions/undo")
assert response.status_code == 200
Expand Down Expand Up @@ -113,7 +113,7 @@ def test_delete_context_returns_success():
assert response.status_code == 200
assert response.json()["message"] == "Session context cleared."

def test_delete_context_clears_deque():
def test_delete_context_clears_session_actions():
from api.routes.context import SessionContext

context_module._current_context = SessionContext(
Expand All @@ -127,19 +127,18 @@ def test_delete_context_clears_deque():
started_at=datetime.now()
)

action_logger._stack.append(
action_logger.record_action(
ActionRecord(
id="act_001",
session_id="sess_001",
timestamp=datetime.now(),
type="code_edit",
description="Test",
domain="digital",
was_guided=True,
guidance_confidence=0.9
guidance_confidence=0.9,
)
)

client.delete("/api/v1/context")

assert len(action_logger._stack) == 0
assert action_logger.total_actions() == 0
Loading
Loading