From e89afc2ccc6acda7c66c09177450a097bb4b27d8 Mon Sep 17 00:00:00 2001 From: Diksha Dabhole Date: Sat, 20 Jun 2026 16:22:13 +0530 Subject: [PATCH] feat: persist phase timestamps in tasks table and expose via get_task_status --- backend/secuscan/database.py | 4 +++- backend/secuscan/executor.py | 27 +++++++++++++++++++----- testing/backend/unit/test_scan_phases.py | 15 +++++++++---- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 3ab7bcdd6..95b6f5b10 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -64,6 +64,7 @@ async def _create_schema(self): preset TEXT, status TEXT NOT NULL DEFAULT 'queued', scan_phase TEXT, + phase_timestamps_json TEXT NOT NULL DEFAULT '{}', consent_granted BOOLEAN NOT NULL DEFAULT 0, safe_mode BOOLEAN NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), @@ -410,7 +411,8 @@ async def _create_schema(self): "inputs_json": "TEXT NOT NULL DEFAULT '{}'", "execution_context_json": "TEXT NOT NULL DEFAULT '{}'", "preset": "TEXT", - "safe_mode": "BOOLEAN NOT NULL DEFAULT 1" + "safe_mode": "BOOLEAN NOT NULL DEFAULT 1", + "phase_timestamps_json": "TEXT NOT NULL DEFAULT '{}'" } for col_name, col_type in needed_cols.items(): diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index db078a8eb..7014ca228 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -231,9 +231,19 @@ async def _broadcast_phase(self, task_id: str, phase: str): """Broadcast a scan phase transition and persist it to the database.""" await self._broadcast(task_id, "phase", phase) db = await get_db() + now = datetime.now(timezone.utc).isoformat() await db.execute( - "UPDATE tasks SET scan_phase = ? WHERE id = ?", - (phase, task_id) + """ + UPDATE tasks + SET scan_phase = ?, + phase_timestamps_json = json_set( + phase_timestamps_json, + '$.' || COALESCE(scan_phase, 'unknown') || '.completed_at', ?, + '$.' || ? || '.started_at', ? + ) + WHERE id = ? + """, + (phase, now, phase, now, task_id) ) async def create_task( @@ -281,8 +291,8 @@ async def create_task( """ INSERT INTO tasks ( id, owner_id, plugin_id, tool_name, target, inputs_json, preset, - execution_context_json, status, scan_phase, consent_granted, safe_mode - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + execution_context_json, status, scan_phase, phase_timestamps_json, consent_granted, safe_mode + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, @@ -295,6 +305,7 @@ async def create_task( serialize_execution_context(execution_context), TaskStatus.QUEUED.value, ScanPhase.QUEUED.value, + json.dumps({ScanPhase.QUEUED.value: {"started_at": datetime.now(timezone.utc).isoformat()}}), consent_granted, bool(safe_mode) ) @@ -968,7 +979,7 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]: db = await get_db() task_row = await db.fetchone( """ - SELECT id, plugin_id, tool_name, target, status, scan_phase, created_at, started_at, completed_at, + SELECT id, plugin_id, tool_name, target, status, scan_phase, phase_timestamps_json, created_at, started_at, completed_at, duration_seconds, exit_code, error_message, preset, inputs_json, execution_context_json FROM tasks WHERE id = ? """, @@ -989,6 +1000,11 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]: pending_count = len(ids) queue_position = (ids.index(task_id) + 1) if task_id in ids else None + try: + phase_timestamps = json.loads(_row_value(task_row, "phase_timestamps_json", "{}")) + except json.JSONDecodeError: + phase_timestamps = {} + return { "task_id": task_row["id"], "plugin_id": task_row["plugin_id"], @@ -996,6 +1012,7 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]: "target": task_row["target"], "status": task_row["status"], "scan_phase": task_row.get("scan_phase"), + "phase_timestamps": phase_timestamps, "created_at": task_row["created_at"], "started_at": task_row["started_at"], "completed_at": task_row["completed_at"], diff --git a/testing/backend/unit/test_scan_phases.py b/testing/backend/unit/test_scan_phases.py index 8b7850660..9ea39f0c4 100644 --- a/testing/backend/unit/test_scan_phases.py +++ b/testing/backend/unit/test_scan_phases.py @@ -16,6 +16,7 @@ def make_task_row(task_id: str, status: str, scan_phase: str = None): "target": "127.0.0.1", "status": status, "scan_phase": scan_phase, + "phase_timestamps_json": "{}", "created_at": "2026-01-01T00:00:00", "started_at": None, "completed_at": None, @@ -136,10 +137,14 @@ async def test_broadcast_phase_persists_to_db(): await executor._broadcast_phase("task-1", ScanPhase.PARSING.value) # Verify the DB was updated - mock_db.execute.assert_called_once_with( - "UPDATE tasks SET scan_phase = ? WHERE id = ?", - (ScanPhase.PARSING.value, "task-1") - ) + assert mock_db.execute.call_count == 1 + args = mock_db.execute.call_args[0] + assert "UPDATE tasks" in args[0] + assert "SET scan_phase = ?" in args[0] + assert "phase_timestamps_json = json_set(" in args[0] + assert args[1][0] == ScanPhase.PARSING.value + assert args[1][2] == ScanPhase.PARSING.value + assert args[1][4] == "task-1" @pytest.mark.asyncio @@ -205,6 +210,7 @@ async def test_scan_phase_included_in_status_response(): "target": "127.0.0.1", "status": TaskStatus.RUNNING.value, "scan_phase": ScanPhase.RUNNING_COMMAND.value, + "phase_timestamps_json": '{"queued": {"started_at": "2026-01-01T00:00:00"}}', "created_at": "2026-01-01T00:00:00", "started_at": None, "completed_at": None, @@ -219,3 +225,4 @@ async def test_scan_phase_included_in_status_response(): result = await _call_with_mock_db(executor, "task-sse-1", mock_db) assert result["scan_phase"] == ScanPhase.RUNNING_COMMAND.value + assert "queued" in result["phase_timestamps"]