fix: finalize recordings before automatic replay
This commit is contained in:
1 parent
474e56ef8e
commit
a62ac4aef1
25 files changed
+820
-92
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user