fix: finalize recordings before automatic replay

This commit is contained in:
somew1nd committed 2026-08-23 13:06:19 -07:00
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()
+46 -8
View File
@@ -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": [],
}
+7 -2
View File
@@ -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)
+2 -2
View File
@@ -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",
{
@@ -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'))"
>
<span class="material-symbols-outlined video-icon" *ngIf="agentService.isCurrentSessionRunning()">
videocam
</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
</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
</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.currentSessionVideoUrl()">Screen Recording</span>
<span class="btn-text" *ngIf="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'">Preparing...</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>
</button>
@@ -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);
@@ -33,16 +33,16 @@
<header class="window-header" (mousedown)="startDrag($event)">
<div class="header-left">
<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 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>
</div>
<div class="header-actions" (mousedown)="$event.stopPropagation()">
<!-- 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
*ngFor="let speed of speedOptions"
class="speed-btn"
@@ -90,7 +90,7 @@
<!-- Video Main Content Area -->
<div class="video-body" *ngIf="!agentService.isVideoMinimized()">
<!-- 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()">
<img
class="live-screen-img"
@@ -122,15 +122,15 @@
</ng-container>
<!-- 2. RECORDING PLAYBACK (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
<ng-container *ngIf="!agentService.isCurrentSessionRunning()">
<ng-container *ngIf="agentService.recordingPlaybackStatus() !== 'live'">
<!-- 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>Loading screen recording...</span>
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
</div>
<!-- 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()">
<video
#videoRef
@@ -207,14 +207,19 @@
</ng-container>
<!-- 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">
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
</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">
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>
<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>
</ng-container>
</div>
@@ -202,6 +202,13 @@ export class FloatingVideoPlayerComponent {
this.pendingAutoplay = false;
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;
status?: string;
video_url?: string;
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
model_info?: ModelInfo;
}
@@ -108,3 +108,78 @@ describe('AgentService live LLM retry timeline', () => {
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;
}
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({
providedIn: 'root'
})
@@ -115,6 +133,13 @@ export class AgentService {
public activeVideoSegments = signal<VideoSegment[]>([]);
public activeVideoTitle = signal<string>('');
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
@@ -151,6 +176,14 @@ export class AgentService {
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 statusInterval: any = null;
@@ -441,7 +474,9 @@ export class AgentService {
'task_resumed',
'llm_retrying',
'session_started',
'session_ended'
'session_ended',
'recording_ready',
'recording_failed'
];
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
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 (eventType === 'session_started') {
this.agentStatus.set('running');
@@ -498,6 +556,13 @@ export class AgentService {
this.fetchSessions();
this.fetchNotes(sessionId);
this.updateModelForCurrentView();
if (
this.isVideoWindowOpen()
&& this.activeVideoSessionId === sessionId
&& this.recordingPlaybackStatus() === 'live'
) {
this.beginRecordingFinalization(sessionId);
}
return;
}
@@ -912,6 +977,17 @@ export class AgentService {
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
if (isActive && data.session_id) {
const currentId = this.currentSessionId();
@@ -961,6 +1037,7 @@ export class AgentService {
if (this.eventSource) {
this.eventSource.close();
}
this.cancelVideoRetry();
}
/**
@@ -1008,35 +1085,147 @@ export class AgentService {
this.activeVideoTitle.set(goalTitle);
this.isVideoWindowOpen.set(true);
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.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([]);
if (targetSessionId) {
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({
next: (res) => {
this.isVideoLoading.set(false);
if (res && res.has_video && res.video_url) {
this.activeVideoUrl.set(res.video_url);
this.activeVideoSegments.set(res.video_segments || []);
this.rawSessions.update((list) =>
list.map((s) => s.session_id === targetSessionId ? { ...s, video_url: res.video_url || undefined } : s)
);
} else {
this.activeVideoUrl.set(null);
this.activeVideoSegments.set([]);
}
},
error: () => {
this.shouldAutoplayVideo.set(true);
this.isVideoLoading.set(true);
this.recordingPlaybackStatus.set('processing');
this.recordingPlaybackMessage.set('Finalizing screen recording...');
this.requestSessionVideo(sessionId, this.videoRequestGeneration);
}
private requestSessionVideo(sessionId: string, generation: number): void {
this.http.get<SessionVideoResponse>(`/api/sessions/${sessionId}/video`).subscribe({
next: (res) => {
if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
return;
}
const status = res.status || (res.has_video && res.video_url ? 'ready' : 'unavailable');
if (status === 'ready' && res.video_url) {
this.cancelVideoRetry();
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.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.activeVideoUrl.set(null);
this.activeVideoSegments.set([]);
this.cancelVideoRetry();
this.isVideoLoading.set(false);
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 {
this.isVideoWindowOpen.set(false);
this.cancelVideoRetry();
this.videoRequestGeneration++;
this.activeVideoSessionId = null;
}
}