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:
|
if cursor.fetchone() is None:
|
||||||
return {}
|
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(
|
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]}
|
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]:
|
def get_llm_traces_for_profile(self, session_id: str, limit: int = 3) -> list[str]:
|
||||||
with db_session(self.db_path) as conn:
|
with db_session(self.db_path) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ from fastapi.responses import FileResponse, HTMLResponse
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT
|
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
|
from admin_console.services.media_service import media_service
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from apps.admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT
|
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
|
from apps.admin_console.services.media_service import media_service
|
||||||
|
|
||||||
router = APIRouter(tags=["media"])
|
router = APIRouter(tags=["media"])
|
||||||
@@ -82,22 +84,58 @@ async def get_video(video_path: str):
|
|||||||
|
|
||||||
@router.get("/api/sessions/{session_id}/video")
|
@router.get("/api/sessions/{session_id}/video")
|
||||||
async def get_session_video(session_id: str):
|
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_rec_map = session_repo.get_video_recordings_map()
|
||||||
video_idx = media_service.build_video_index()
|
video_idx = media_service.build_video_index()
|
||||||
row = session_repo.get_session_by_id(session_id)
|
row = session_repo.get_session_by_id(session_id)
|
||||||
row_dict = dict(row) if row else {"session_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)
|
v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
|
||||||
video_segments = media_service.resolve_video_segments(v_url)
|
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 {
|
return {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"has_video": bool(v_url),
|
"status": "unavailable",
|
||||||
"video_url": v_url,
|
"has_video": False,
|
||||||
"video_segments": video_segments,
|
"video_url": None,
|
||||||
|
"video_segments": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ async def list_sessions():
|
|||||||
|
|
||||||
for row_dict in rows:
|
for row_dict in rows:
|
||||||
s_id = str(row_dict.get("session_id"))
|
s_id = str(row_dict.get("session_id"))
|
||||||
|
recording = session_repo.get_video_recording_for_session(s_id)
|
||||||
if row_dict.get("status") == "running":
|
if row_dict.get("status") == "running":
|
||||||
is_active = s_id in state.active_connections or (
|
is_active = s_id in state.active_connections or (
|
||||||
state.is_running and s_id == str(state.active_session_id)
|
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()
|
row_dict["end_time"] = time.time()
|
||||||
orphaned_ids.append(s_id)
|
orphaned_ids.append(s_id)
|
||||||
|
|
||||||
row_dict["video_url"] = media_service.resolve_video_url(
|
recording_status = str((recording or {}).get("status") or "unavailable")
|
||||||
row_dict, video_rec_map, video_idx
|
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)
|
llm_traces = session_repo.get_llm_traces_for_profile(s_id)
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ class MediaService:
|
|||||||
resolved = p.resolve()
|
resolved = p.resolve()
|
||||||
try:
|
try:
|
||||||
rel = resolved.relative_to(WORKSPACE_ROOT)
|
rel = resolved.relative_to(WORKSPACE_ROOT)
|
||||||
return f"/videos/{rel}"
|
return f"/videos/{urllib.parse.quote(rel.as_posix(), safe='/')}"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return f"/videos/{str(resolved).lstrip('/')}"
|
return f"/videos/{urllib.parse.quote(resolved.as_posix().lstrip('/'), safe='/:')}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def build_video_index(cls) -> dict[str, str]:
|
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)
|
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
|
@staticmethod
|
||||||
def _subprocess_creation_kwargs() -> dict[str, Any]:
|
def _subprocess_creation_kwargs() -> dict[str, Any]:
|
||||||
"""Isolate task workers from the UI server's Windows console.
|
"""Isolate task workers from the UI server's Windows console.
|
||||||
@@ -281,15 +306,27 @@ class TaskQueueService:
|
|||||||
|
|
||||||
# 4. Perform fallback database status update and notification
|
# 4. Perform fallback database status update and notification
|
||||||
if sess_id:
|
if sess_id:
|
||||||
if state.was_stopped_manually:
|
current_status = session_repo.get_session_status(sess_id)
|
||||||
new_status = "cancelled"
|
new_status, should_persist = cls._resolve_terminal_status(
|
||||||
elif returncode == 0:
|
current_status,
|
||||||
new_status = "completed"
|
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:
|
else:
|
||||||
new_status = "failed"
|
print(
|
||||||
session_repo.update_session_status(sess_id, new_status, time.time())
|
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)
|
state.active_connections.pop(sess_id, None)
|
||||||
print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'")
|
|
||||||
cls._broadcast_event(
|
cls._broadcast_event(
|
||||||
"session_ended",
|
"session_ended",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -261,23 +261,28 @@
|
|||||||
class="btn-screen-recording"
|
class="btn-screen-recording"
|
||||||
[class.active]="agentService.isVideoWindowOpen()"
|
[class.active]="agentService.isVideoWindowOpen()"
|
||||||
[class.recording]="agentService.isCurrentSessionRunning()"
|
[class.recording]="agentService.isCurrentSessionRunning()"
|
||||||
|
[class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'"
|
||||||
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
|
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
|
||||||
(click)="agentService.toggleVideoPlayer()"
|
(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'))"
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined video-icon" *ngIf="agentService.isCurrentSessionRunning()">
|
<span class="material-symbols-outlined video-icon" *ngIf="agentService.isCurrentSessionRunning()">
|
||||||
videocam
|
videocam
|
||||||
</span>
|
</span>
|
||||||
<span class="material-symbols-outlined video-icon play-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionVideoUrl()">
|
<span class="material-symbols-outlined video-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'">
|
||||||
|
progress_activity
|
||||||
|
</span>
|
||||||
|
<span class="material-symbols-outlined video-icon play-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && agentService.currentSessionVideoUrl()">
|
||||||
smart_display
|
smart_display
|
||||||
</span>
|
</span>
|
||||||
<span class="material-symbols-outlined video-icon" *ngIf="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl()">
|
<span class="material-symbols-outlined video-icon" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl()">
|
||||||
videocam
|
videocam
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="btn-text" *ngIf="agentService.isCurrentSessionRunning()">Recording...</span>
|
<span class="btn-text" *ngIf="agentService.isCurrentSessionRunning()">Recording...</span>
|
||||||
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionVideoUrl()">Play Recording</span>
|
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'">Preparing...</span>
|
||||||
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl()">Screen Recording</span>
|
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && agentService.currentSessionVideoUrl()">Play Recording</span>
|
||||||
|
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl()">Screen Recording</span>
|
||||||
|
|
||||||
<span class="video-pulse-dot red" *ngIf="agentService.isCurrentSessionRunning()"></span>
|
<span class="video-pulse-dot red" *ngIf="agentService.isCurrentSessionRunning()"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -907,6 +907,13 @@ $uniform-gap: 8px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.processing {
|
||||||
|
.video-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover:not(.active) {
|
&:hover:not(.active) {
|
||||||
background: rgba(255, 255, 255, 0.88);
|
background: rgba(255, 255, 255, 0.88);
|
||||||
border-color: rgba(203, 213, 225, 0.95);
|
border-color: rgba(203, 213, 225, 0.95);
|
||||||
|
|||||||
+16
-11
@@ -33,16 +33,16 @@
|
|||||||
<header class="window-header" (mousedown)="startDrag($event)">
|
<header class="window-header" (mousedown)="startDrag($event)">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="material-symbols-outlined header-icon">
|
<span class="material-symbols-outlined header-icon">
|
||||||
{{ agentService.isCurrentSessionRunning() ? 'sensors' : 'smart_display' }}
|
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="header-title" [title]="agentService.activeVideoTitle()">
|
<span class="header-title" [title]="agentService.activeVideoTitle()">
|
||||||
{{ agentService.isCurrentSessionRunning() ? 'Live Device Screen' : (agentService.activeVideoTitle() || 'Screen Recording') }}
|
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording')) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||||
<!-- Playback Speed Pills (Shown only when playback video is active) -->
|
<!-- Playback Speed Pills (Shown only when playback video is active) -->
|
||||||
<div class="speed-pill-group" *ngIf="!agentService.isCurrentSessionRunning() && !agentService.isVideoMinimized() && !videoLoadError() && agentService.activeVideoUrl()">
|
<div class="speed-pill-group" *ngIf="agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoMinimized() && !videoLoadError() && agentService.activeVideoUrl()">
|
||||||
<button
|
<button
|
||||||
*ngFor="let speed of speedOptions"
|
*ngFor="let speed of speedOptions"
|
||||||
class="speed-btn"
|
class="speed-btn"
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<!-- Video Main Content Area -->
|
<!-- Video Main Content Area -->
|
||||||
<div class="video-body" *ngIf="!agentService.isVideoMinimized()">
|
<div class="video-body" *ngIf="!agentService.isVideoMinimized()">
|
||||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||||
<ng-container *ngIf="agentService.isCurrentSessionRunning()">
|
<ng-container *ngIf="agentService.recordingPlaybackStatus() === 'live'">
|
||||||
<div class="live-stream-container" *ngIf="!liveStreamError()">
|
<div class="live-stream-container" *ngIf="!liveStreamError()">
|
||||||
<img
|
<img
|
||||||
class="live-screen-img"
|
class="live-screen-img"
|
||||||
@@ -122,15 +122,15 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- 2. RECORDING PLAYBACK (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
|
<!-- 2. RECORDING PLAYBACK (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
|
||||||
<ng-container *ngIf="!agentService.isCurrentSessionRunning()">
|
<ng-container *ngIf="agentService.recordingPlaybackStatus() !== 'live'">
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div class="video-loading-state" *ngIf="agentService.isVideoLoading()">
|
<div class="video-loading-state" *ngIf="agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading()">
|
||||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||||
<span>Loading screen recording...</span>
|
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Video Playback View -->
|
<!-- Active Video Playback View -->
|
||||||
<ng-container *ngIf="!agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
|
<ng-container *ngIf="agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
|
||||||
<div class="video-container" *ngIf="!videoLoadError()">
|
<div class="video-container" *ngIf="!videoLoadError()">
|
||||||
<video
|
<video
|
||||||
#videoRef
|
#videoRef
|
||||||
@@ -207,14 +207,19 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Clean, High-Contrast Minimal Empty / Fallback State -->
|
<!-- Clean, High-Contrast Minimal Empty / Fallback State -->
|
||||||
<div class="empty-video-fallback" *ngIf="!agentService.isVideoLoading() && (!agentService.activeVideoUrl() || videoLoadError())">
|
<div class="empty-video-fallback" *ngIf="!agentService.isVideoLoading() && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError())">
|
||||||
<div class="fallback-icon-wrap">
|
<div class="fallback-icon-wrap">
|
||||||
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
|
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="fallback-title">No Video Recording Available</h4>
|
<h4 class="fallback-title">
|
||||||
|
{{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }}
|
||||||
|
</h4>
|
||||||
<p class="fallback-desc">
|
<p class="fallback-desc">
|
||||||
No screen recording was found for this task.
|
{{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }}
|
||||||
</p>
|
</p>
|
||||||
|
<button class="retry-btn" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
|
||||||
|
<span class="material-symbols-outlined">refresh</span> Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
@@ -202,6 +202,13 @@ export class FloatingVideoPlayerComponent {
|
|||||||
this.pendingAutoplay = false;
|
this.pendingAutoplay = false;
|
||||||
v.play().catch(() => {});
|
v.play().catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (this.agentService.consumeVideoAutoplay()) {
|
||||||
|
this.isMuted.set(true);
|
||||||
|
v.muted = true;
|
||||||
|
v.play().catch(() => {
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface Session {
|
|||||||
end_time?: number;
|
end_time?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
video_url?: string;
|
video_url?: string;
|
||||||
|
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||||
model_info?: ModelInfo;
|
model_info?: ModelInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,3 +108,78 @@ describe('AgentService live LLM retry timeline', () => {
|
|||||||
expect(selectSpy).toHaveBeenCalledWith('new-session', false);
|
expect(selectSpy).toHaveBeenCalledWith('new-session', false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('AgentService recording finalization lifecycle', () => {
|
||||||
|
function createVideoService(response: any): AgentService {
|
||||||
|
const service = Object.create(AgentService.prototype) as AgentService;
|
||||||
|
(service as any).http = { get: () => of(response) };
|
||||||
|
(service as any).rawSessions = signal<any[]>([
|
||||||
|
{ session_id: 'session-1', status: 'completed', recording_status: 'recording' }
|
||||||
|
]);
|
||||||
|
(service as any).activeVideoSessionId = 'session-1';
|
||||||
|
(service as any).videoRequestGeneration = 1;
|
||||||
|
(service as any).videoRetryTimer = null;
|
||||||
|
(service as any).videoWaitStartedAt = Date.now();
|
||||||
|
service.activeVideoUrl = signal<string | null>(null);
|
||||||
|
service.activeVideoSegments = signal<any[]>([]);
|
||||||
|
service.isVideoLoading = signal(false);
|
||||||
|
service.recordingPlaybackStatus = signal('idle');
|
||||||
|
service.recordingPlaybackMessage = signal('');
|
||||||
|
service.shouldAutoplayVideo = signal(true);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps unfinished media out of the video element and schedules another readiness check', () => {
|
||||||
|
const service = createVideoService({
|
||||||
|
session_id: 'session-1',
|
||||||
|
status: 'processing',
|
||||||
|
has_video: false,
|
||||||
|
video_url: null,
|
||||||
|
retry_after_ms: 750
|
||||||
|
});
|
||||||
|
const retrySpy = spyOn<any>(service, 'scheduleVideoRetry');
|
||||||
|
|
||||||
|
(service as any).requestSessionVideo('session-1', 1);
|
||||||
|
|
||||||
|
expect(service.recordingPlaybackStatus()).toBe('processing');
|
||||||
|
expect(service.isVideoLoading()).toBeTrue();
|
||||||
|
expect(service.activeVideoUrl()).toBeNull();
|
||||||
|
expect(retrySpy).toHaveBeenCalledWith('session-1', 1, 750);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes finalized media and preserves the automatic replay request', () => {
|
||||||
|
const service = createVideoService({
|
||||||
|
session_id: 'session-1',
|
||||||
|
status: 'ready',
|
||||||
|
has_video: true,
|
||||||
|
video_url: '/videos/recording.mp4?v=1',
|
||||||
|
video_segments: [
|
||||||
|
{ url: '/videos/recording.mp4?v=1', start: 0, duration: 5, width: 1080, height: 1920 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
(service as any).requestSessionVideo('session-1', 1);
|
||||||
|
|
||||||
|
expect(service.recordingPlaybackStatus()).toBe('ready');
|
||||||
|
expect(service.isVideoLoading()).toBeFalse();
|
||||||
|
expect(service.activeVideoUrl()).toBe('/videos/recording.mp4?v=1');
|
||||||
|
expect(service.activeVideoSegments().length).toBe(1);
|
||||||
|
expect(service.shouldAutoplayVideo()).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops polling and exposes a terminal recording failure', () => {
|
||||||
|
const service = createVideoService({
|
||||||
|
session_id: 'session-1',
|
||||||
|
status: 'failed',
|
||||||
|
has_video: false,
|
||||||
|
video_url: null,
|
||||||
|
message: 'ffmpeg failed'
|
||||||
|
});
|
||||||
|
|
||||||
|
(service as any).requestSessionVideo('session-1', 1);
|
||||||
|
|
||||||
|
expect(service.recordingPlaybackStatus()).toBe('failed');
|
||||||
|
expect(service.isVideoLoading()).toBeFalse();
|
||||||
|
expect(service.recordingPlaybackMessage()).toBe('ffmpeg failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,24 @@ export interface VideoSegment {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RecordingPlaybackStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'live'
|
||||||
|
| 'processing'
|
||||||
|
| 'ready'
|
||||||
|
| 'failed'
|
||||||
|
| 'unavailable';
|
||||||
|
|
||||||
|
interface SessionVideoResponse {
|
||||||
|
session_id: string;
|
||||||
|
status?: 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||||
|
has_video: boolean;
|
||||||
|
video_url: string | null;
|
||||||
|
video_segments?: VideoSegment[];
|
||||||
|
retry_after_ms?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -115,6 +133,13 @@ export class AgentService {
|
|||||||
public activeVideoSegments = signal<VideoSegment[]>([]);
|
public activeVideoSegments = signal<VideoSegment[]>([]);
|
||||||
public activeVideoTitle = signal<string>('');
|
public activeVideoTitle = signal<string>('');
|
||||||
public isVideoLoading = signal<boolean>(false);
|
public isVideoLoading = signal<boolean>(false);
|
||||||
|
public recordingPlaybackStatus = signal<RecordingPlaybackStatus>('idle');
|
||||||
|
public recordingPlaybackMessage = signal<string>('');
|
||||||
|
public shouldAutoplayVideo = signal<boolean>(false);
|
||||||
|
private activeVideoSessionId: string | null = null;
|
||||||
|
private videoRequestGeneration = 0;
|
||||||
|
private videoRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private videoWaitStartedAt = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear user-pinned selection so subsequent runs automatically follow active runner
|
* Clear user-pinned selection so subsequent runs automatically follow active runner
|
||||||
@@ -151,6 +176,14 @@ export class AgentService {
|
|||||||
return session?.video_url || null;
|
return session?.video_url || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public currentSessionRecordingStatus = computed(() => {
|
||||||
|
const session = this.currentSession();
|
||||||
|
const status = session?.recording_status;
|
||||||
|
return status === 'recording' || status === 'finalizing' || status === 'processing'
|
||||||
|
? 'processing'
|
||||||
|
: status;
|
||||||
|
});
|
||||||
|
|
||||||
private eventSource: EventSource | null = null;
|
private eventSource: EventSource | null = null;
|
||||||
private statusInterval: any = null;
|
private statusInterval: any = null;
|
||||||
|
|
||||||
@@ -441,7 +474,9 @@ export class AgentService {
|
|||||||
'task_resumed',
|
'task_resumed',
|
||||||
'llm_retrying',
|
'llm_retrying',
|
||||||
'session_started',
|
'session_started',
|
||||||
'session_ended'
|
'session_ended',
|
||||||
|
'recording_ready',
|
||||||
|
'recording_failed'
|
||||||
];
|
];
|
||||||
|
|
||||||
eventTypes.forEach((eventType) => {
|
eventTypes.forEach((eventType) => {
|
||||||
@@ -451,6 +486,29 @@ export class AgentService {
|
|||||||
|
|
||||||
// If the event carries a session_id for a different session, update global states if applicable and ignore
|
// If the event carries a session_id for a different session, update global states if applicable and ignore
|
||||||
const evtSessionId = parsedData?.session_id;
|
const evtSessionId = parsedData?.session_id;
|
||||||
|
|
||||||
|
if (eventType === 'recording_ready' || eventType === 'recording_failed') {
|
||||||
|
if (
|
||||||
|
this.isVideoWindowOpen()
|
||||||
|
&& evtSessionId
|
||||||
|
&& String(evtSessionId) === String(this.activeVideoSessionId)
|
||||||
|
) {
|
||||||
|
if (eventType === 'recording_ready') {
|
||||||
|
this.refreshActiveRecording(true);
|
||||||
|
} else {
|
||||||
|
this.cancelVideoRetry();
|
||||||
|
this.isVideoLoading.set(false);
|
||||||
|
this.activeVideoUrl.set(null);
|
||||||
|
this.activeVideoSegments.set([]);
|
||||||
|
this.recordingPlaybackStatus.set('failed');
|
||||||
|
this.recordingPlaybackMessage.set(
|
||||||
|
parsedData?.error || 'Recording finalization failed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (evtSessionId && String(evtSessionId) !== String(sessionId)) {
|
if (evtSessionId && String(evtSessionId) !== String(sessionId)) {
|
||||||
if (eventType === 'session_started') {
|
if (eventType === 'session_started') {
|
||||||
this.agentStatus.set('running');
|
this.agentStatus.set('running');
|
||||||
@@ -498,6 +556,13 @@ export class AgentService {
|
|||||||
this.fetchSessions();
|
this.fetchSessions();
|
||||||
this.fetchNotes(sessionId);
|
this.fetchNotes(sessionId);
|
||||||
this.updateModelForCurrentView();
|
this.updateModelForCurrentView();
|
||||||
|
if (
|
||||||
|
this.isVideoWindowOpen()
|
||||||
|
&& this.activeVideoSessionId === sessionId
|
||||||
|
&& this.recordingPlaybackStatus() === 'live'
|
||||||
|
) {
|
||||||
|
this.beginRecordingFinalization(sessionId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,6 +977,17 @@ export class AgentService {
|
|||||||
this.fetchSessions();
|
this.fetchSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isActive
|
||||||
|
&& (oldStatus === 'running' || oldStatus === 'paused')
|
||||||
|
&& oldRunningSessionId
|
||||||
|
&& this.isVideoWindowOpen()
|
||||||
|
&& this.activeVideoSessionId === oldRunningSessionId
|
||||||
|
&& this.recordingPlaybackStatus() === 'live'
|
||||||
|
) {
|
||||||
|
this.beginRecordingFinalization(oldRunningSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select the session if running and user hasn't explicitly chosen to inspect another task
|
// Auto-select the session if running and user hasn't explicitly chosen to inspect another task
|
||||||
if (isActive && data.session_id) {
|
if (isActive && data.session_id) {
|
||||||
const currentId = this.currentSessionId();
|
const currentId = this.currentSessionId();
|
||||||
@@ -961,6 +1037,7 @@ export class AgentService {
|
|||||||
if (this.eventSource) {
|
if (this.eventSource) {
|
||||||
this.eventSource.close();
|
this.eventSource.close();
|
||||||
}
|
}
|
||||||
|
this.cancelVideoRetry();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1008,35 +1085,147 @@ export class AgentService {
|
|||||||
this.activeVideoTitle.set(goalTitle);
|
this.activeVideoTitle.set(goalTitle);
|
||||||
this.isVideoWindowOpen.set(true);
|
this.isVideoWindowOpen.set(true);
|
||||||
this.isVideoMinimized.set(false);
|
this.isVideoMinimized.set(false);
|
||||||
|
this.activeVideoSessionId = targetSessionId;
|
||||||
|
this.cancelVideoRetry();
|
||||||
|
this.videoRequestGeneration++;
|
||||||
|
this.shouldAutoplayVideo.set(false);
|
||||||
|
this.recordingPlaybackMessage.set('');
|
||||||
|
this.activeVideoSegments.set([]);
|
||||||
|
if (!targetSessionId) {
|
||||||
|
this.activeVideoUrl.set(null);
|
||||||
|
this.isVideoLoading.set(false);
|
||||||
|
this.recordingPlaybackStatus.set('unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session?.status === 'running' || session?.status === 'paused') {
|
||||||
|
this.activeVideoUrl.set(null);
|
||||||
|
this.isVideoLoading.set(false);
|
||||||
|
this.recordingPlaybackStatus.set('live');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.activeVideoUrl.set(targetUrl);
|
this.activeVideoUrl.set(targetUrl);
|
||||||
|
this.shouldAutoplayVideo.set(true);
|
||||||
|
this.videoWaitStartedAt = Date.now();
|
||||||
|
this.isVideoLoading.set(true);
|
||||||
|
this.recordingPlaybackStatus.set('processing');
|
||||||
|
this.recordingPlaybackMessage.set('Loading screen recording...');
|
||||||
|
this.requestSessionVideo(targetSessionId, this.videoRequestGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginRecordingFinalization(sessionId: string): void {
|
||||||
|
if (this.activeVideoSessionId !== sessionId) return;
|
||||||
|
this.cancelVideoRetry();
|
||||||
|
this.videoRequestGeneration++;
|
||||||
|
this.videoWaitStartedAt = Date.now();
|
||||||
|
this.activeVideoUrl.set(null);
|
||||||
this.activeVideoSegments.set([]);
|
this.activeVideoSegments.set([]);
|
||||||
if (targetSessionId) {
|
this.shouldAutoplayVideo.set(true);
|
||||||
this.isVideoLoading.set(true);
|
this.isVideoLoading.set(true);
|
||||||
this.http.get<{ session_id: string; has_video: boolean; video_url: string | null; video_segments?: VideoSegment[] }>(`/api/sessions/${targetSessionId}/video`).subscribe({
|
this.recordingPlaybackStatus.set('processing');
|
||||||
next: (res) => {
|
this.recordingPlaybackMessage.set('Finalizing screen recording...');
|
||||||
this.isVideoLoading.set(false);
|
this.requestSessionVideo(sessionId, this.videoRequestGeneration);
|
||||||
if (res && res.has_video && res.video_url) {
|
}
|
||||||
this.activeVideoUrl.set(res.video_url);
|
|
||||||
this.activeVideoSegments.set(res.video_segments || []);
|
private requestSessionVideo(sessionId: string, generation: number): void {
|
||||||
this.rawSessions.update((list) =>
|
this.http.get<SessionVideoResponse>(`/api/sessions/${sessionId}/video`).subscribe({
|
||||||
list.map((s) => s.session_id === targetSessionId ? { ...s, video_url: res.video_url || undefined } : s)
|
next: (res) => {
|
||||||
);
|
if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
|
||||||
} else {
|
return;
|
||||||
this.activeVideoUrl.set(null);
|
}
|
||||||
this.activeVideoSegments.set([]);
|
const status = res.status || (res.has_video && res.video_url ? 'ready' : 'unavailable');
|
||||||
}
|
if (status === 'ready' && res.video_url) {
|
||||||
},
|
this.cancelVideoRetry();
|
||||||
error: () => {
|
|
||||||
this.isVideoLoading.set(false);
|
this.isVideoLoading.set(false);
|
||||||
|
this.recordingPlaybackStatus.set('ready');
|
||||||
|
this.recordingPlaybackMessage.set('');
|
||||||
|
this.activeVideoUrl.set(res.video_url);
|
||||||
|
this.activeVideoSegments.set(res.video_segments || []);
|
||||||
|
this.rawSessions.update((list) =>
|
||||||
|
list.map((s) => s.session_id === sessionId
|
||||||
|
? { ...s, video_url: res.video_url || undefined, recording_status: 'ready' }
|
||||||
|
: s)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === 'processing') {
|
||||||
this.activeVideoUrl.set(null);
|
this.activeVideoUrl.set(null);
|
||||||
this.activeVideoSegments.set([]);
|
this.activeVideoSegments.set([]);
|
||||||
|
this.isVideoLoading.set(true);
|
||||||
|
this.recordingPlaybackStatus.set('processing');
|
||||||
|
this.recordingPlaybackMessage.set('Finalizing screen recording...');
|
||||||
|
this.scheduleVideoRetry(sessionId, generation, res.retry_after_ms);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} else {
|
this.cancelVideoRetry();
|
||||||
this.activeVideoUrl.set(null);
|
this.isVideoLoading.set(false);
|
||||||
this.activeVideoSegments.set([]);
|
this.activeVideoUrl.set(null);
|
||||||
|
this.activeVideoSegments.set([]);
|
||||||
|
this.recordingPlaybackStatus.set(status === 'failed' ? 'failed' : 'unavailable');
|
||||||
|
this.recordingPlaybackMessage.set(
|
||||||
|
res.message || (status === 'failed'
|
||||||
|
? 'Recording finalization failed.'
|
||||||
|
: 'No screen recording is available for this task.')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.recordingPlaybackStatus() === 'processing') {
|
||||||
|
this.scheduleVideoRetry(sessionId, generation, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isVideoLoading.set(false);
|
||||||
|
this.recordingPlaybackStatus.set('failed');
|
||||||
|
this.recordingPlaybackMessage.set('Unable to load the screen recording.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleVideoRetry(sessionId: string, generation: number, retryAfterMs = 1000): void {
|
||||||
|
this.cancelVideoRetry();
|
||||||
|
if (Date.now() - this.videoWaitStartedAt > 120_000) {
|
||||||
|
this.isVideoLoading.set(false);
|
||||||
|
this.recordingPlaybackStatus.set('failed');
|
||||||
|
this.recordingPlaybackMessage.set('Recording finalization timed out. You can retry.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const delay = Math.max(500, Math.min(3000, retryAfterMs));
|
||||||
|
this.videoRetryTimer = setTimeout(() => {
|
||||||
|
this.videoRetryTimer = null;
|
||||||
|
this.requestSessionVideo(sessionId, generation);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelVideoRetry(): void {
|
||||||
|
if (this.videoRetryTimer) {
|
||||||
|
clearTimeout(this.videoRetryTimer);
|
||||||
|
this.videoRetryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshActiveRecording(autoplay: boolean): void {
|
||||||
|
const sessionId = this.activeVideoSessionId;
|
||||||
|
if (!sessionId) return;
|
||||||
|
this.cancelVideoRetry();
|
||||||
|
this.videoRequestGeneration++;
|
||||||
|
if (autoplay) this.shouldAutoplayVideo.set(true);
|
||||||
|
this.requestSessionVideo(sessionId, this.videoRequestGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public retryVideoRecording(): void {
|
||||||
|
const sessionId = this.activeVideoSessionId;
|
||||||
|
if (!sessionId) return;
|
||||||
|
this.beginRecordingFinalization(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public consumeVideoAutoplay(): boolean {
|
||||||
|
const shouldAutoplay = this.shouldAutoplayVideo();
|
||||||
|
this.shouldAutoplayVideo.set(false);
|
||||||
|
return shouldAutoplay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1055,5 +1244,8 @@ export class AgentService {
|
|||||||
*/
|
*/
|
||||||
public closeVideoPlayer(): void {
|
public closeVideoPlayer(): void {
|
||||||
this.isVideoWindowOpen.set(false);
|
this.isVideoWindowOpen.set(false);
|
||||||
|
this.cancelVideoRetry();
|
||||||
|
this.videoRequestGeneration++;
|
||||||
|
this.activeVideoSessionId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,6 +476,16 @@ class UnifiedMobileController:
|
|||||||
asyncio.create_task(self._remux_segment_record(session.android_segment_records[-1]))
|
asyncio.create_task(self._remux_segment_record(session.android_segment_records[-1]))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _record_recording_failure(self, session: RecordingSession, message: str) -> None:
|
||||||
|
if self.ctx and self.ctx.data_engine:
|
||||||
|
self.ctx.data_engine.record_video_failure(
|
||||||
|
video_id=session.video_id,
|
||||||
|
device_id=session.device_id,
|
||||||
|
local_video_path=session.local_video_path,
|
||||||
|
start_time=session.start_time,
|
||||||
|
error=message,
|
||||||
|
)
|
||||||
|
|
||||||
async def _start_next_recording_segment(
|
async def _start_next_recording_segment(
|
||||||
self, session: RecordingSession, display_state: tuple[int, int, int] | None
|
self, session: RecordingSession, display_state: tuple[int, int, int] | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -612,6 +622,7 @@ class UnifiedMobileController:
|
|||||||
stderr = await process.stderr.read()
|
stderr = await process.stderr.read()
|
||||||
err_msg = stderr.decode()
|
err_msg = stderr.decode()
|
||||||
logger.error(f"scrcpy failed to start on {device_id}: {err_msg}")
|
logger.error(f"scrcpy failed to start on {device_id}: {err_msg}")
|
||||||
|
self._record_recording_failure(session, f"scrcpy failed to start: {err_msg}")
|
||||||
remove_active_session(device_id)
|
remove_active_session(device_id)
|
||||||
return VideoRecordingResult(
|
return VideoRecordingResult(
|
||||||
success=False,
|
success=False,
|
||||||
@@ -675,10 +686,12 @@ class UnifiedMobileController:
|
|||||||
|
|
||||||
output_path = session.local_video_path
|
output_path = session.local_video_path
|
||||||
if not output_path or not output_path.exists():
|
if not output_path or not output_path.exists():
|
||||||
|
message = "Recording file not found on disk"
|
||||||
|
self._record_recording_failure(session, message)
|
||||||
remove_active_session(device_id)
|
remove_active_session(device_id)
|
||||||
return VideoRecordingResult(
|
return VideoRecordingResult(
|
||||||
success=False,
|
success=False,
|
||||||
message="Recording file not found on disk",
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._finalize_current_segment(session, time.time())
|
self._finalize_current_segment(session, time.time())
|
||||||
@@ -690,17 +703,31 @@ class UnifiedMobileController:
|
|||||||
self._remux_segment_record(record)
|
self._remux_segment_record(record)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
conversion_results = []
|
||||||
if session.android_conversion_tasks:
|
if session.android_conversion_tasks:
|
||||||
await asyncio.gather(*session.android_conversion_tasks, return_exceptions=True)
|
conversion_results = await asyncio.gather(
|
||||||
|
*session.android_conversion_tasks, return_exceptions=True
|
||||||
|
)
|
||||||
mp4_paths = [
|
mp4_paths = [
|
||||||
Path(record["output_path"])
|
Path(record["output_path"])
|
||||||
for record in session.android_segment_records
|
for record in session.android_segment_records
|
||||||
if Path(record["output_path"]).exists()
|
if Path(record["output_path"]).exists()
|
||||||
]
|
]
|
||||||
await write_recording_manifest(output_path.parent, mp4_paths)
|
manifest_path = await write_recording_manifest(output_path.parent, mp4_paths)
|
||||||
|
|
||||||
|
conversion_failed = (
|
||||||
|
len(mp4_paths) != len(session.android_segment_records)
|
||||||
|
or any(result is not True for result in conversion_results)
|
||||||
|
or manifest_path is None
|
||||||
|
)
|
||||||
|
if conversion_failed:
|
||||||
|
message = "Recording finalization failed; no complete browser-safe video was produced"
|
||||||
|
self._record_recording_failure(session, message)
|
||||||
|
remove_active_session(device_id)
|
||||||
|
return VideoRecordingResult(success=False, message=message)
|
||||||
|
|
||||||
remove_active_session(device_id)
|
remove_active_session(device_id)
|
||||||
final_video_path = mp4_paths[0] if mp4_paths else output_path
|
final_video_path = mp4_paths[0]
|
||||||
|
|
||||||
# Persist update to local database if Data Engine is active
|
# Persist update to local database if Data Engine is active
|
||||||
if self.ctx and self.ctx.data_engine:
|
if self.ctx and self.ctx.data_engine:
|
||||||
@@ -712,21 +739,15 @@ class UnifiedMobileController:
|
|||||||
end_time=time.time(),
|
end_time=time.time(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if mp4_paths:
|
return VideoRecordingResult(
|
||||||
return VideoRecordingResult(
|
success=True,
|
||||||
success=True,
|
message=f"Recording stopped, saved {len(mp4_paths)} orientation-safe segments",
|
||||||
message=f"Recording stopped, saved {len(mp4_paths)} orientation-safe segments",
|
video_path=mp4_paths[0],
|
||||||
video_path=mp4_paths[0],
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
return VideoRecordingResult(
|
|
||||||
success=True,
|
|
||||||
message=f"Recording stopped, saved as MKV to {output_path}",
|
|
||||||
video_path=output_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to stop scrcpy recording: {e}")
|
logger.error(f"Failed to stop scrcpy recording: {e}")
|
||||||
|
self._record_recording_failure(session, str(e))
|
||||||
remove_active_session(device_id)
|
remove_active_session(device_id)
|
||||||
return VideoRecordingResult(
|
return VideoRecordingResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|||||||
@@ -265,6 +265,9 @@ class DataEngine:
|
|||||||
session_id = self.current_session_id
|
session_id = self.current_session_id
|
||||||
end_time = time.time()
|
end_time = time.time()
|
||||||
session = self.storage.get_session(session_id)
|
session = self.storage.get_session(session_id)
|
||||||
|
if session and session.end_time is not None and session.status not in ("running", "paused"):
|
||||||
|
logger.debug(f"Session end already published for {session_id}; skipping duplicate")
|
||||||
|
return
|
||||||
if session:
|
if session:
|
||||||
session.end_time = end_time
|
session.end_time = end_time
|
||||||
session.status = status
|
session.status = status
|
||||||
@@ -328,11 +331,55 @@ class DataEngine:
|
|||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time or time.time(),
|
end_time=end_time or time.time(),
|
||||||
local_video_path=str(local_video_path),
|
local_video_path=str(local_video_path),
|
||||||
|
status="ready",
|
||||||
)
|
)
|
||||||
self.storage.update_video_recording(record)
|
self.storage.update_video_recording(record)
|
||||||
|
self._publish(
|
||||||
|
"recording_ready",
|
||||||
|
{
|
||||||
|
"session_id": str(self.current_session_id),
|
||||||
|
"video_id": str(video_id),
|
||||||
|
"local_video_path": str(local_video_path),
|
||||||
|
"end_time": record.end_time,
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to record video stop in DataEngine: {e}")
|
logger.error(f"Failed to record video stop in DataEngine: {e}")
|
||||||
|
|
||||||
|
def record_video_failure(
|
||||||
|
self,
|
||||||
|
video_id: UUID,
|
||||||
|
device_id: str,
|
||||||
|
local_video_path: str | Path | None,
|
||||||
|
start_time: float,
|
||||||
|
error: str,
|
||||||
|
):
|
||||||
|
"""Persist and publish a terminal recording failure."""
|
||||||
|
if not self.storage:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
record = VideoRecordingRecord(
|
||||||
|
video_id=video_id,
|
||||||
|
session_id=self.current_session_id,
|
||||||
|
device_id=device_id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=time.time(),
|
||||||
|
local_video_path=str(local_video_path) if local_video_path else None,
|
||||||
|
status="failed",
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
self.storage.update_video_recording(record)
|
||||||
|
self._publish(
|
||||||
|
"recording_failed",
|
||||||
|
{
|
||||||
|
"session_id": str(self.current_session_id),
|
||||||
|
"video_id": str(video_id),
|
||||||
|
"error": error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to record video failure in DataEngine: {e}")
|
||||||
|
|
||||||
def update_video_path(self, local_video_path: str | Path):
|
def update_video_path(self, local_video_path: str | Path):
|
||||||
"""Update the video path across tables when traces or videos are moved."""
|
"""Update the video path across tables when traces or videos are moved."""
|
||||||
if not self.storage or not self.current_session_id:
|
if not self.storage or not self.current_session_id:
|
||||||
|
|||||||
@@ -100,3 +100,5 @@ class VideoRecordingRecord(BaseModel):
|
|||||||
start_time: float
|
start_time: float
|
||||||
end_time: float | None = None
|
end_time: float | None = None
|
||||||
local_video_path: str | None = None
|
local_video_path: str | None = None
|
||||||
|
status: str = "recording"
|
||||||
|
error: str | None = None
|
||||||
@@ -213,9 +213,24 @@ class StorageManager:
|
|||||||
start_time REAL,
|
start_time REAL,
|
||||||
end_time REAL,
|
end_time REAL,
|
||||||
local_video_path TEXT,
|
local_video_path TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'recording',
|
||||||
|
error TEXT,
|
||||||
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
video_recording_columns = {
|
||||||
|
row[1] for row in conn.execute("PRAGMA table_info(video_recordings)").fetchall()
|
||||||
|
}
|
||||||
|
if "status" not in video_recording_columns:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE video_recordings ADD COLUMN status TEXT "
|
||||||
|
"NOT NULL DEFAULT 'recording'"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE video_recordings SET status = 'ready' WHERE end_time IS NOT NULL"
|
||||||
|
)
|
||||||
|
if "error" not in video_recording_columns:
|
||||||
|
conn.execute("ALTER TABLE video_recordings ADD COLUMN error TEXT")
|
||||||
try:
|
try:
|
||||||
conn.execute("ALTER TABLE background_tasks ADD COLUMN logs TEXT")
|
conn.execute("ALTER TABLE background_tasks ADD COLUMN logs TEXT")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
@@ -273,8 +288,11 @@ class StorageManager:
|
|||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path)
|
INSERT INTO video_recordings (
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
video_id, session_id, device_id, start_time, end_time,
|
||||||
|
local_video_path, status, error
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
str(record.video_id),
|
str(record.video_id),
|
||||||
@@ -283,6 +301,8 @@ class StorageManager:
|
|||||||
record.start_time,
|
record.start_time,
|
||||||
record.end_time,
|
record.end_time,
|
||||||
record.local_video_path,
|
record.local_video_path,
|
||||||
|
record.status,
|
||||||
|
record.error,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -293,12 +313,14 @@ class StorageManager:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE video_recordings
|
UPDATE video_recordings
|
||||||
SET end_time = ?, local_video_path = ?
|
SET end_time = ?, local_video_path = ?, status = ?, error = ?
|
||||||
WHERE video_id = ?
|
WHERE video_id = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
record.end_time,
|
record.end_time,
|
||||||
record.local_video_path,
|
record.local_video_path,
|
||||||
|
record.status,
|
||||||
|
record.error,
|
||||||
str(record.video_id),
|
str(record.video_id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -345,6 +367,10 @@ class StorageManager:
|
|||||||
start_time=row["start_time"],
|
start_time=row["start_time"],
|
||||||
end_time=row["end_time"],
|
end_time=row["end_time"],
|
||||||
local_video_path=row["local_video_path"],
|
local_video_path=row["local_video_path"],
|
||||||
|
status=row["status"] if "status" in row.keys() else (
|
||||||
|
"ready" if row["end_time"] is not None else "recording"
|
||||||
|
),
|
||||||
|
error=row["error"] if "error" in row.keys() else None,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -642,7 +642,7 @@ class Agent:
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
await self._finalize_tracing(task=task, context=context)
|
await self._finalize_tracing_safely(task=task, context=context)
|
||||||
finally:
|
finally:
|
||||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||||
await asyncio.to_thread(device_lock.release)
|
await asyncio.to_thread(device_lock.release)
|
||||||
@@ -833,6 +833,17 @@ class Agent:
|
|||||||
device_data["profile"] = task.request.profile
|
device_data["profile"] = task.request.profile
|
||||||
context.data_engine.start_session(goal=task.request.goal, device_info=device_data)
|
context.data_engine.start_session(goal=task.request.goal, device_info=device_data)
|
||||||
|
|
||||||
|
async def _finalize_tracing_safely(self, task: Task, context: ArtemisContext):
|
||||||
|
"""Finalize optional trace artifacts without changing task semantics."""
|
||||||
|
try:
|
||||||
|
await self._finalize_tracing(task=task, context=context)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
f"[{task.get_name()}] Trace artifact finalization failed after task status "
|
||||||
|
f"was resolved as '{task.status}': {exc}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def _finalize_tracing(self, task: Task, context: ArtemisContext):
|
async def _finalize_tracing(self, task: Task, context: ArtemisContext):
|
||||||
if context.data_engine:
|
if context.data_engine:
|
||||||
session_status = "completed"
|
session_status = "completed"
|
||||||
|
|||||||
@@ -186,9 +186,10 @@ def remove_images_from_trace_folder(trace_folder_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
def create_steps_json_from_trace_folder(trace_folder_path: Path):
|
def create_steps_json_from_trace_folder(trace_folder_path: Path):
|
||||||
|
"""Compile legacy timestamp-named step files without consuming manifests."""
|
||||||
steps = []
|
steps = []
|
||||||
for file in trace_folder_path.iterdir():
|
for file in trace_folder_path.iterdir():
|
||||||
if file.suffix == ".json":
|
if file.suffix == ".json" and file.stem.isdigit():
|
||||||
with open(file, encoding="utf-8", errors="ignore") as f:
|
with open(file, encoding="utf-8", errors="ignore") as f:
|
||||||
json_content = f.read()
|
json_content = f.read()
|
||||||
steps.append({"timestamp": int(file.stem), "data": json_content})
|
steps.append({"timestamp": int(file.stem), "data": json_content})
|
||||||
@@ -202,6 +203,7 @@ def create_steps_json_from_trace_folder(trace_folder_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
def remove_steps_json_from_trace_folder(trace_folder_path: Path):
|
def remove_steps_json_from_trace_folder(trace_folder_path: Path):
|
||||||
|
"""Remove only legacy timestamp-named step files, preserving manifests."""
|
||||||
for file in trace_folder_path.iterdir():
|
for file in trace_folder_path.iterdir():
|
||||||
if file.suffix == ".json" and file.name != "steps.json":
|
if file.suffix == ".json" and file.stem.isdigit():
|
||||||
file.unlink()
|
file.unlink()
|
||||||
+23
-8
@@ -256,9 +256,12 @@ async def normalize_recording_to_mp4(
|
|||||||
|
|
||||||
|
|
||||||
async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
|
async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
|
||||||
"""Move a fixed-dimension recording segment into MP4 without re-encoding."""
|
"""Atomically publish a fixed-dimension recording segment as MP4."""
|
||||||
if not source_path.exists() or source_path.stat().st_size == 0:
|
if not source_path.exists() or source_path.stat().st_size == 0:
|
||||||
return False
|
return False
|
||||||
|
temporary_path = output_path.with_name(f"{output_path.stem}.part{output_path.suffix}")
|
||||||
|
if temporary_path.exists():
|
||||||
|
temporary_path.unlink()
|
||||||
try:
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
get_ffmpeg_path(),
|
get_ffmpeg_path(),
|
||||||
@@ -275,13 +278,18 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
|
|||||||
"copy",
|
"copy",
|
||||||
"-movflags",
|
"-movflags",
|
||||||
"+faststart",
|
"+faststart",
|
||||||
str(output_path),
|
str(temporary_path),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
_stdout, stderr = await process.communicate()
|
_stdout, stderr = await process.communicate()
|
||||||
valid = process.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0
|
valid = (
|
||||||
|
process.returncode == 0
|
||||||
|
and temporary_path.exists()
|
||||||
|
and temporary_path.stat().st_size > 0
|
||||||
|
)
|
||||||
if valid:
|
if valid:
|
||||||
|
temporary_path.replace(output_path)
|
||||||
return True
|
return True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to remux recording segment (code {process.returncode}): "
|
f"Failed to remux recording segment (code {process.returncode}): "
|
||||||
@@ -289,8 +297,8 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to remux recording segment: {e}")
|
logger.error(f"Failed to remux recording segment: {e}")
|
||||||
if output_path.exists():
|
if temporary_path.exists():
|
||||||
output_path.unlink()
|
temporary_path.unlink()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -363,26 +371,33 @@ async def write_recording_manifest(output_dir: Path, mp4_paths: list[Path]) -> P
|
|||||||
continue
|
continue
|
||||||
metadata = await probe_video_segment(path)
|
metadata = await probe_video_segment(path)
|
||||||
duration = float(metadata.get("duration", 0))
|
duration = float(metadata.get("duration", 0))
|
||||||
|
width = int(metadata.get("width", 0))
|
||||||
|
height = int(metadata.get("height", 0))
|
||||||
|
if duration <= 0 or width <= 0 or height <= 0:
|
||||||
|
logger.error(f"Recording segment failed validation: {path}")
|
||||||
|
return None
|
||||||
segments.append(
|
segments.append(
|
||||||
{
|
{
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
"start": round(timeline, 3),
|
"start": round(timeline, 3),
|
||||||
"duration": round(duration, 3),
|
"duration": round(duration, 3),
|
||||||
"width": int(metadata.get("width", 0)),
|
"width": width,
|
||||||
"height": int(metadata.get("height", 0)),
|
"height": height,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
timeline += duration
|
timeline += duration
|
||||||
if not segments:
|
if not segments:
|
||||||
return None
|
return None
|
||||||
manifest_path = output_dir / "recording.json"
|
manifest_path = output_dir / "recording.json"
|
||||||
manifest_path.write_text(
|
temporary_manifest_path = output_dir / "recording.part.json"
|
||||||
|
temporary_manifest_path.write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{"version": 1, "duration": round(timeline, 3), "segments": segments},
|
{"version": 1, "duration": round(timeline, 3), "segments": segments},
|
||||||
indent=2,
|
indent=2,
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
temporary_manifest_path.replace(manifest_path)
|
||||||
return manifest_path
|
return manifest_path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from apps.admin_console.routers import media as media_router
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_video_does_not_expose_recording_in_progress(monkeypatch):
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.get_video_recordings_map.return_value = {}
|
||||||
|
repo.get_session_by_id.return_value = {"session_id": "session-1", "status": "completed"}
|
||||||
|
repo.get_video_recording_for_session.return_value = {
|
||||||
|
"session_id": "session-1",
|
||||||
|
"status": "recording",
|
||||||
|
"local_video_path": "/tmp/recording.mkv",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(media_router, "session_repo", repo, raising=False)
|
||||||
|
monkeypatch.setattr(media_router.media_service, "build_video_index", MagicMock(return_value={}))
|
||||||
|
resolve = MagicMock(return_value="/videos/recording.mkv")
|
||||||
|
monkeypatch.setattr(media_router.media_service, "resolve_video_url", resolve)
|
||||||
|
|
||||||
|
response = await media_router.get_session_video("session-1")
|
||||||
|
|
||||||
|
assert response["status"] == "processing"
|
||||||
|
assert response["has_video"] is False
|
||||||
|
assert response["video_url"] is None
|
||||||
|
resolve.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_video_publishes_only_finalized_versioned_media(monkeypatch):
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.get_video_recordings_map.return_value = {"session-1": "/tmp/recording.mp4"}
|
||||||
|
repo.get_session_by_id.return_value = {"session_id": "session-1", "status": "completed"}
|
||||||
|
repo.get_video_recording_for_session.return_value = {
|
||||||
|
"session_id": "session-1",
|
||||||
|
"status": "ready",
|
||||||
|
"end_time": 123.456,
|
||||||
|
"local_video_path": "/tmp/recording.mp4",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(media_router, "session_repo", repo, raising=False)
|
||||||
|
monkeypatch.setattr(media_router.media_service, "build_video_index", MagicMock(return_value={}))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
media_router.media_service,
|
||||||
|
"resolve_video_url",
|
||||||
|
MagicMock(return_value="/videos/recording.mp4"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
media_router.media_service,
|
||||||
|
"resolve_video_segments",
|
||||||
|
MagicMock(
|
||||||
|
return_value=[
|
||||||
|
{"url": "/videos/recording.mp4", "start": 0, "duration": 4, "width": 1080, "height": 1920}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await media_router.get_session_video("session-1")
|
||||||
|
|
||||||
|
assert response["status"] == "ready"
|
||||||
|
assert response["has_video"] is True
|
||||||
|
assert response["video_url"] == "/videos/recording.mp4?v=123456"
|
||||||
|
assert response["video_segments"][0]["url"] == "/videos/recording.mp4?v=123456"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_video_surfaces_terminal_recording_failure(monkeypatch):
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.get_video_recordings_map.return_value = {}
|
||||||
|
repo.get_session_by_id.return_value = {"session_id": "session-1", "status": "failed"}
|
||||||
|
repo.get_video_recording_for_session.return_value = {
|
||||||
|
"session_id": "session-1",
|
||||||
|
"status": "failed",
|
||||||
|
"error": "ffmpeg failed",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(media_router, "session_repo", repo, raising=False)
|
||||||
|
monkeypatch.setattr(media_router.media_service, "build_video_index", MagicMock(return_value={}))
|
||||||
|
|
||||||
|
response = await media_router.get_session_video("session-1")
|
||||||
|
|
||||||
|
assert response["status"] == "failed"
|
||||||
|
assert response["message"] == "ffmpeg failed"
|
||||||
|
assert response["video_url"] is None
|
||||||
@@ -71,6 +71,26 @@ async def test_get_next_pending_task():
|
|||||||
assert next_task["goal"] == "Goal A"
|
assert next_task["goal"] == "Goal A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("current_status", "returncode", "stopped", "expected"),
|
||||||
|
[
|
||||||
|
("completed", 1, False, ("completed", False)),
|
||||||
|
("failed", 0, False, ("failed", False)),
|
||||||
|
("cancelled", 0, False, ("cancelled", False)),
|
||||||
|
("success", 1, False, ("completed", True)),
|
||||||
|
("running", 0, False, ("completed", True)),
|
||||||
|
("running", 1, False, ("failed", True)),
|
||||||
|
("completed", 0, True, ("cancelled", True)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_terminal_status_preserves_authoritative_result(
|
||||||
|
current_status, returncode, stopped, expected
|
||||||
|
):
|
||||||
|
assert (
|
||||||
|
TaskQueueService._resolve_terminal_status(current_status, returncode, stopped) == expected
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_remove_task():
|
async def test_remove_task():
|
||||||
state.queue_items = [
|
state.queue_items = [
|
||||||
|
|||||||
@@ -191,16 +191,19 @@ def test_video_recording_persistence(tmp_path):
|
|||||||
assert persisted.start_time == 100.0
|
assert persisted.start_time == 100.0
|
||||||
assert persisted.end_time is None
|
assert persisted.end_time is None
|
||||||
assert persisted.local_video_path == "/tmp/recording.mp4"
|
assert persisted.local_video_path == "/tmp/recording.mp4"
|
||||||
|
assert persisted.status == "recording"
|
||||||
|
|
||||||
# Test update
|
# Test update
|
||||||
record.end_time = 150.0
|
record.end_time = 150.0
|
||||||
record.local_video_path = "/tmp/recording_final.mp4"
|
record.local_video_path = "/tmp/recording_final.mp4"
|
||||||
|
record.status = "ready"
|
||||||
storage.update_video_recording(record)
|
storage.update_video_recording(record)
|
||||||
|
|
||||||
updated = storage.get_video_recording(video_id)
|
updated = storage.get_video_recording(video_id)
|
||||||
assert updated is not None
|
assert updated is not None
|
||||||
assert updated.end_time == 150.0
|
assert updated.end_time == 150.0
|
||||||
assert updated.local_video_path == "/tmp/recording_final.mp4"
|
assert updated.local_video_path == "/tmp/recording_final.mp4"
|
||||||
|
assert updated.status == "ready"
|
||||||
|
|
||||||
|
|
||||||
def test_record_step_suppresses_identical_post_screenshot(tmp_path):
|
def test_record_step_suppresses_identical_post_screenshot(tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Copyright 2026 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from artemis.sdk.agent import Agent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trace_finalization_failure_does_not_escape():
|
||||||
|
agent = object.__new__(Agent)
|
||||||
|
agent._finalize_tracing = AsyncMock(side_effect=ValueError("bad recording manifest"))
|
||||||
|
task = MagicMock(status="completed")
|
||||||
|
task.get_name.return_value = "successful-task"
|
||||||
|
context = MagicMock()
|
||||||
|
|
||||||
|
await agent._finalize_tracing_safely(task=task, context=context)
|
||||||
|
|
||||||
|
agent._finalize_tracing.assert_awaited_once_with(task=task, context=context)
|
||||||
@@ -243,9 +243,10 @@ async def test_unified_controller_stop_recording(mock_ctx, tmp_path):
|
|||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"artemis.controllers.unified_controller.remux_recording_to_mp4",
|
"artemis.controllers.unified_controller.remux_recording_to_mp4",
|
||||||
AsyncMock(side_effect=lambda src, dst: dst.write_bytes(b"mp4 content") or True),
|
AsyncMock(side_effect=lambda src, dst: (dst.write_bytes(b"mp4 content"), True)[1]),
|
||||||
), patch(
|
), patch(
|
||||||
"artemis.controllers.unified_controller.write_recording_manifest", AsyncMock()
|
"artemis.controllers.unified_controller.write_recording_manifest",
|
||||||
|
AsyncMock(return_value=tmp_path / "recording.json"),
|
||||||
):
|
):
|
||||||
res = await controller.stop_video_recording()
|
res = await controller.stop_video_recording()
|
||||||
|
|
||||||
@@ -363,9 +364,12 @@ async def test_unified_controller_crash_recovery_and_multi_segment(mock_ctx, tmp
|
|||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"artemis.controllers.unified_controller.remux_recording_to_mp4",
|
"artemis.controllers.unified_controller.remux_recording_to_mp4",
|
||||||
AsyncMock(side_effect=lambda src, dst: dst.write_bytes(b"final mp4") or True),
|
AsyncMock(side_effect=lambda src, dst: (dst.write_bytes(b"final mp4"), True)[1]),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"artemis.controllers.unified_controller.write_recording_manifest",
|
||||||
|
AsyncMock(return_value=tmp_path / "recording.json"),
|
||||||
),
|
),
|
||||||
patch("artemis.controllers.unified_controller.write_recording_manifest", AsyncMock()),
|
|
||||||
):
|
):
|
||||||
res = await controller.stop_video_recording()
|
res = await controller.stop_video_recording()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Copyright 2026 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from artemis.utils.media import (
|
||||||
|
create_steps_json_from_trace_folder,
|
||||||
|
remove_steps_json_from_trace_folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_compilation_preserves_recording_manifest(tmp_path):
|
||||||
|
step_file = tmp_path / "123.json"
|
||||||
|
step_file.write_text('{"action": "click"}', encoding="utf-8")
|
||||||
|
recording_manifest = tmp_path / "recording.json"
|
||||||
|
recording_manifest.write_text(
|
||||||
|
'{"version": 1, "segments": [{"file": "recording.mp4"}]}',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
create_steps_json_from_trace_folder(tmp_path)
|
||||||
|
|
||||||
|
compiled = json.loads((tmp_path / "steps.json").read_text(encoding="utf-8"))
|
||||||
|
assert compiled == [{"timestamp": 123, "data": '{"action": "click"}'}]
|
||||||
|
assert recording_manifest.exists()
|
||||||
|
|
||||||
|
remove_steps_json_from_trace_folder(tmp_path)
|
||||||
|
|
||||||
|
assert not step_file.exists()
|
||||||
|
assert recording_manifest.exists()
|
||||||
|
assert (tmp_path / "steps.json").exists()
|
||||||
Reference in new issue
Block a user