diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py
index 9821ea8..0293e6d 100644
--- a/apps/admin_console/database/repositories/session_repository.py
+++ b/apps/admin_console/database/repositories/session_repository.py
@@ -41,11 +41,58 @@ class SessionRepository:
)
if cursor.fetchone() is None:
return {}
+ columns = {
+ str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)")
+ }
+ ready_clause = "status = 'ready'" if "status" in columns else "end_time IS NOT NULL"
cursor.execute(
- "SELECT session_id, local_video_path FROM video_recordings WHERE session_id IS NOT NULL AND local_video_path IS NOT NULL ORDER BY start_time ASC"
+ "SELECT session_id, local_video_path FROM video_recordings "
+ "WHERE session_id IS NOT NULL AND local_video_path IS NOT NULL "
+ f"AND {ready_clause} ORDER BY start_time ASC"
)
return {str(r[0]): str(r[1]) for r in cursor.fetchall() if r[0] and r[1]}
+ def get_video_recording_for_session(self, session_id: str) -> dict[str, Any] | None:
+ try:
+ with db_session(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='video_recordings'"
+ )
+ if cursor.fetchone() is None:
+ return None
+ cursor.execute(
+ "SELECT * FROM video_recordings WHERE session_id = ? "
+ "ORDER BY start_time DESC LIMIT 1",
+ (str(session_id),),
+ )
+ row = cursor.fetchone()
+ if not row:
+ return None
+ result = dict(row)
+ if "status" not in result:
+ result["status"] = "ready" if result.get("end_time") is not None else "recording"
+ result.setdefault("error", None)
+ return result
+ except Exception:
+ return None
+
+ def mark_recording_failed_if_pending(self, session_id: str, error: str) -> bool:
+ """Close a recording lifecycle when its worker exits before finalization."""
+ try:
+ with db_session(self.db_path) as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "UPDATE video_recordings SET status = 'failed', error = ?, "
+ "end_time = COALESCE(end_time, ?) "
+ "WHERE session_id = ? AND status IN ('recording', 'finalizing')",
+ (error, time.time(), str(session_id)),
+ )
+ conn.commit()
+ return cursor.rowcount > 0
+ except Exception:
+ return False
+
def get_llm_traces_for_profile(self, session_id: str, limit: int = 3) -> list[str]:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
diff --git a/apps/admin_console/routers/media.py b/apps/admin_console/routers/media.py
index 4603ed6..fb8dc33 100644
--- a/apps/admin_console/routers/media.py
+++ b/apps/admin_console/routers/media.py
@@ -18,9 +18,11 @@ from fastapi.responses import FileResponse, HTMLResponse
try:
from admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT
+ from admin_console.database.repositories.session_repository import session_repo
from admin_console.services.media_service import media_service
except ImportError:
from apps.admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT
+ from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services.media_service import media_service
router = APIRouter(tags=["media"])
@@ -82,22 +84,58 @@ async def get_video(video_path: str):
@router.get("/api/sessions/{session_id}/video")
async def get_session_video(session_id: str):
- try:
- from admin_console.database.repositories.session_repository import session_repo
- except ImportError:
- from apps.admin_console.database.repositories.session_repository import session_repo
-
video_rec_map = session_repo.get_video_recordings_map()
video_idx = media_service.build_video_index()
row = session_repo.get_session_by_id(session_id)
row_dict = dict(row) if row else {"session_id": session_id}
+ recording = session_repo.get_video_recording_for_session(session_id)
+ recording_status = str((recording or {}).get("status") or "")
+
+ if recording_status in ("recording", "finalizing"):
+ return {
+ "session_id": session_id,
+ "status": "processing",
+ "has_video": False,
+ "video_url": None,
+ "video_segments": [],
+ "retry_after_ms": 750,
+ }
+
+ if recording_status == "failed":
+ return {
+ "session_id": session_id,
+ "status": "failed",
+ "has_video": False,
+ "video_url": None,
+ "video_segments": [],
+ "message": recording.get("error") or "Recording finalization failed",
+ }
+
v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
video_segments = media_service.resolve_video_segments(v_url)
+ if v_url:
+ version = int(float((recording or {}).get("end_time") or row_dict.get("end_time") or 0) * 1000)
+ separator = "&" if "?" in v_url else "?"
+ versioned_url = f"{v_url}{separator}v={version}" if version else v_url
+ for segment in video_segments:
+ segment_separator = "&" if "?" in segment["url"] else "?"
+ segment["url"] = (
+ f"{segment['url']}{segment_separator}v={version}" if version else segment["url"]
+ )
+ return {
+ "session_id": session_id,
+ "status": "ready",
+ "has_video": True,
+ "video_url": versioned_url,
+ "video_segments": video_segments,
+ }
+
return {
"session_id": session_id,
- "has_video": bool(v_url),
- "video_url": v_url,
- "video_segments": video_segments,
+ "status": "unavailable",
+ "has_video": False,
+ "video_url": None,
+ "video_segments": [],
}
diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py
index 69258e9..64a8ee9 100644
--- a/apps/admin_console/routers/sessions.py
+++ b/apps/admin_console/routers/sessions.py
@@ -47,6 +47,7 @@ async def list_sessions():
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
+ recording = session_repo.get_video_recording_for_session(s_id)
if row_dict.get("status") == "running":
is_active = s_id in state.active_connections or (
state.is_running and s_id == str(state.active_session_id)
@@ -56,8 +57,12 @@ async def list_sessions():
row_dict["end_time"] = time.time()
orphaned_ids.append(s_id)
- row_dict["video_url"] = media_service.resolve_video_url(
- row_dict, video_rec_map, video_idx
+ recording_status = str((recording or {}).get("status") or "unavailable")
+ row_dict["recording_status"] = recording_status
+ row_dict["video_url"] = (
+ media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
+ if recording_status in ("ready", "unavailable")
+ else None
)
llm_traces = session_repo.get_llm_traces_for_profile(s_id)
diff --git a/apps/admin_console/services/media_service.py b/apps/admin_console/services/media_service.py
index fda93d5..807c91e 100644
--- a/apps/admin_console/services/media_service.py
+++ b/apps/admin_console/services/media_service.py
@@ -35,9 +35,9 @@ class MediaService:
resolved = p.resolve()
try:
rel = resolved.relative_to(WORKSPACE_ROOT)
- return f"/videos/{rel}"
+ return f"/videos/{urllib.parse.quote(rel.as_posix(), safe='/')}"
except ValueError:
- return f"/videos/{str(resolved).lstrip('/')}"
+ return f"/videos/{urllib.parse.quote(resolved.as_posix().lstrip('/'), safe='/:')}"
@classmethod
def build_video_index(cls) -> dict[str, str]:
diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py
index 73ec96b..e576aec 100644
--- a/apps/admin_console/services/task_queue_service.py
+++ b/apps/admin_console/services/task_queue_service.py
@@ -78,6 +78,31 @@ class TaskQueueService:
if not (isinstance(t, dict) and t.get("session_id") == session_id)
]
+ @staticmethod
+ def _resolve_terminal_status(
+ current_status: str | None,
+ returncode: int,
+ was_stopped_manually: bool,
+ ) -> tuple[str, bool]:
+ """Resolve final status while preserving an authoritative task result.
+
+ The worker exit code is only a fallback for sessions that have not
+ reached a terminal state. ``success`` is the DataEngine alias for the
+ UI-facing ``completed`` status and is normalized here.
+
+ Returns:
+ A tuple of ``(resolved_status, should_persist)``.
+ """
+ if was_stopped_manually:
+ return "cancelled", True
+
+ normalized = current_status.lower().strip() if isinstance(current_status, str) else None
+ if normalized == "success":
+ return "completed", True
+ if normalized in {"completed", "failed", "cancelled"}:
+ return normalized, False
+ return ("completed" if returncode == 0 else "failed"), True
+
@staticmethod
def _subprocess_creation_kwargs() -> dict[str, Any]:
"""Isolate task workers from the UI server's Windows console.
@@ -281,15 +306,27 @@ class TaskQueueService:
# 4. Perform fallback database status update and notification
if sess_id:
- if state.was_stopped_manually:
- new_status = "cancelled"
- elif returncode == 0:
- new_status = "completed"
+ current_status = session_repo.get_session_status(sess_id)
+ new_status, should_persist = cls._resolve_terminal_status(
+ current_status,
+ returncode,
+ state.was_stopped_manually,
+ )
+ if should_persist:
+ session_repo.update_session_status(sess_id, new_status, time.time())
+ print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
else:
- new_status = "failed"
- session_repo.update_session_status(sess_id, new_status, time.time())
+ print(
+ f"[QueueWorker] Preserved authoritative session {sess_id} "
+ f"status '{new_status}'"
+ )
+ recording_error = "Task worker exited before recording finalization completed"
+ if session_repo.mark_recording_failed_if_pending(sess_id, recording_error):
+ cls._broadcast_event(
+ "recording_failed",
+ {"session_id": sess_id, "error": recording_error},
+ )
state.active_connections.pop(sess_id, None)
- print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
cls._broadcast_event(
"session_ended",
{
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
index f27b331..c5354a9 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html
@@ -261,23 +261,28 @@
class="btn-screen-recording"
[class.active]="agentService.isVideoWindowOpen()"
[class.recording]="agentService.isCurrentSessionRunning()"
+ [class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'"
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
(click)="agentService.toggleVideoPlayer()"
- [title]="agentService.isCurrentSessionRunning() ? 'Task is currently running (Recording screen)' : (agentService.currentSessionVideoUrl() ? 'Play Screen Recording' : 'Check Device Screen Recording')"
+ [title]="agentService.isCurrentSessionRunning() ? 'Task is currently running (Recording screen)' : (agentService.currentSessionRecordingStatus() === 'processing' ? 'Preparing screen recording' : (agentService.currentSessionVideoUrl() ? 'Play Screen Recording' : 'Check Device Screen Recording'))"
>
videocam
-
+
+ progress_activity
+
+
smart_display
-
+
videocam
Recording...
- Play Recording
- Screen Recording
+ Preparing...
+ Play Recording
+ Screen Recording
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
index 6fe71b7..ce11e13 100644
--- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
+++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss
@@ -907,6 +907,13 @@ $uniform-gap: 8px;
}
}
+ &.processing {
+ .video-icon {
+ color: #2563eb;
+ animation: spin 1s linear infinite;
+ }
+ }
+
&:hover:not(.active) {
background: rgba(255, 255, 255, 0.88);
border-color: rgba(203, 213, 225, 0.95);
diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html
index 4cadb08..cc240fd 100644
--- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html
+++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html
@@ -33,16 +33,16 @@