diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py index 9821ea8..0293e6d 100644 --- a/apps/admin_console/database/repositories/session_repository.py +++ b/apps/admin_console/database/repositories/session_repository.py @@ -41,11 +41,58 @@ class SessionRepository: ) if cursor.fetchone() is None: return {} + columns = { + str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)") + } + ready_clause = "status = 'ready'" if "status" in columns else "end_time IS NOT NULL" cursor.execute( - "SELECT session_id, local_video_path FROM video_recordings WHERE session_id IS NOT NULL AND local_video_path IS NOT NULL ORDER BY start_time ASC" + "SELECT session_id, local_video_path FROM video_recordings " + "WHERE session_id IS NOT NULL AND local_video_path IS NOT NULL " + f"AND {ready_clause} ORDER BY start_time ASC" ) return {str(r[0]): str(r[1]) for r in cursor.fetchall() if r[0] and r[1]} + def get_video_recording_for_session(self, session_id: str) -> dict[str, Any] | None: + try: + with db_session(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='video_recordings'" + ) + if cursor.fetchone() is None: + return None + cursor.execute( + "SELECT * FROM video_recordings WHERE session_id = ? " + "ORDER BY start_time DESC LIMIT 1", + (str(session_id),), + ) + row = cursor.fetchone() + if not row: + return None + result = dict(row) + if "status" not in result: + result["status"] = "ready" if result.get("end_time") is not None else "recording" + result.setdefault("error", None) + return result + except Exception: + return None + + def mark_recording_failed_if_pending(self, session_id: str, error: str) -> bool: + """Close a recording lifecycle when its worker exits before finalization.""" + try: + with db_session(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE video_recordings SET status = 'failed', error = ?, " + "end_time = COALESCE(end_time, ?) " + "WHERE session_id = ? AND status IN ('recording', 'finalizing')", + (error, time.time(), str(session_id)), + ) + conn.commit() + return cursor.rowcount > 0 + except Exception: + return False + def get_llm_traces_for_profile(self, session_id: str, limit: int = 3) -> list[str]: with db_session(self.db_path) as conn: cursor = conn.cursor() diff --git a/apps/admin_console/routers/media.py b/apps/admin_console/routers/media.py index 4603ed6..fb8dc33 100644 --- a/apps/admin_console/routers/media.py +++ b/apps/admin_console/routers/media.py @@ -18,9 +18,11 @@ from fastapi.responses import FileResponse, HTMLResponse try: from admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT + from admin_console.database.repositories.session_repository import session_repo from admin_console.services.media_service import media_service except ImportError: from apps.admin_console.core.config import IMAGES_DIR, WORKSPACE_ROOT + from apps.admin_console.database.repositories.session_repository import session_repo from apps.admin_console.services.media_service import media_service router = APIRouter(tags=["media"]) @@ -82,22 +84,58 @@ async def get_video(video_path: str): @router.get("/api/sessions/{session_id}/video") async def get_session_video(session_id: str): - try: - from admin_console.database.repositories.session_repository import session_repo - except ImportError: - from apps.admin_console.database.repositories.session_repository import session_repo - video_rec_map = session_repo.get_video_recordings_map() video_idx = media_service.build_video_index() row = session_repo.get_session_by_id(session_id) row_dict = dict(row) if row else {"session_id": session_id} + recording = session_repo.get_video_recording_for_session(session_id) + recording_status = str((recording or {}).get("status") or "") + + if recording_status in ("recording", "finalizing"): + return { + "session_id": session_id, + "status": "processing", + "has_video": False, + "video_url": None, + "video_segments": [], + "retry_after_ms": 750, + } + + if recording_status == "failed": + return { + "session_id": session_id, + "status": "failed", + "has_video": False, + "video_url": None, + "video_segments": [], + "message": recording.get("error") or "Recording finalization failed", + } + v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx) video_segments = media_service.resolve_video_segments(v_url) + if v_url: + version = int(float((recording or {}).get("end_time") or row_dict.get("end_time") or 0) * 1000) + separator = "&" if "?" in v_url else "?" + versioned_url = f"{v_url}{separator}v={version}" if version else v_url + for segment in video_segments: + segment_separator = "&" if "?" in segment["url"] else "?" + segment["url"] = ( + f"{segment['url']}{segment_separator}v={version}" if version else segment["url"] + ) + return { + "session_id": session_id, + "status": "ready", + "has_video": True, + "video_url": versioned_url, + "video_segments": video_segments, + } + return { "session_id": session_id, - "has_video": bool(v_url), - "video_url": v_url, - "video_segments": video_segments, + "status": "unavailable", + "has_video": False, + "video_url": None, + "video_segments": [], } diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py index 69258e9..64a8ee9 100644 --- a/apps/admin_console/routers/sessions.py +++ b/apps/admin_console/routers/sessions.py @@ -47,6 +47,7 @@ async def list_sessions(): for row_dict in rows: s_id = str(row_dict.get("session_id")) + recording = session_repo.get_video_recording_for_session(s_id) if row_dict.get("status") == "running": is_active = s_id in state.active_connections or ( state.is_running and s_id == str(state.active_session_id) @@ -56,8 +57,12 @@ async def list_sessions(): row_dict["end_time"] = time.time() orphaned_ids.append(s_id) - row_dict["video_url"] = media_service.resolve_video_url( - row_dict, video_rec_map, video_idx + recording_status = str((recording or {}).get("status") or "unavailable") + row_dict["recording_status"] = recording_status + row_dict["video_url"] = ( + media_service.resolve_video_url(row_dict, video_rec_map, video_idx) + if recording_status in ("ready", "unavailable") + else None ) llm_traces = session_repo.get_llm_traces_for_profile(s_id) diff --git a/apps/admin_console/services/media_service.py b/apps/admin_console/services/media_service.py index fda93d5..807c91e 100644 --- a/apps/admin_console/services/media_service.py +++ b/apps/admin_console/services/media_service.py @@ -35,9 +35,9 @@ class MediaService: resolved = p.resolve() try: rel = resolved.relative_to(WORKSPACE_ROOT) - return f"/videos/{rel}" + return f"/videos/{urllib.parse.quote(rel.as_posix(), safe='/')}" except ValueError: - return f"/videos/{str(resolved).lstrip('/')}" + return f"/videos/{urllib.parse.quote(resolved.as_posix().lstrip('/'), safe='/:')}" @classmethod def build_video_index(cls) -> dict[str, str]: diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py index 73ec96b..e576aec 100644 --- a/apps/admin_console/services/task_queue_service.py +++ b/apps/admin_console/services/task_queue_service.py @@ -78,6 +78,31 @@ class TaskQueueService: if not (isinstance(t, dict) and t.get("session_id") == session_id) ] + @staticmethod + def _resolve_terminal_status( + current_status: str | None, + returncode: int, + was_stopped_manually: bool, + ) -> tuple[str, bool]: + """Resolve final status while preserving an authoritative task result. + + The worker exit code is only a fallback for sessions that have not + reached a terminal state. ``success`` is the DataEngine alias for the + UI-facing ``completed`` status and is normalized here. + + Returns: + A tuple of ``(resolved_status, should_persist)``. + """ + if was_stopped_manually: + return "cancelled", True + + normalized = current_status.lower().strip() if isinstance(current_status, str) else None + if normalized == "success": + return "completed", True + if normalized in {"completed", "failed", "cancelled"}: + return normalized, False + return ("completed" if returncode == 0 else "failed"), True + @staticmethod def _subprocess_creation_kwargs() -> dict[str, Any]: """Isolate task workers from the UI server's Windows console. @@ -281,15 +306,27 @@ class TaskQueueService: # 4. Perform fallback database status update and notification if sess_id: - if state.was_stopped_manually: - new_status = "cancelled" - elif returncode == 0: - new_status = "completed" + current_status = session_repo.get_session_status(sess_id) + new_status, should_persist = cls._resolve_terminal_status( + current_status, + returncode, + state.was_stopped_manually, + ) + if should_persist: + session_repo.update_session_status(sess_id, new_status, time.time()) + print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'") else: - new_status = "failed" - session_repo.update_session_status(sess_id, new_status, time.time()) + print( + f"[QueueWorker] Preserved authoritative session {sess_id} " + f"status '{new_status}'" + ) + recording_error = "Task worker exited before recording finalization completed" + if session_repo.mark_recording_failed_if_pending(sess_id, recording_error): + cls._broadcast_event( + "recording_failed", + {"session_id": sess_id, "error": recording_error}, + ) state.active_connections.pop(sess_id, None) - print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'") cls._broadcast_event( "session_ended", { diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index f27b331..c5354a9 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -261,23 +261,28 @@ class="btn-screen-recording" [class.active]="agentService.isVideoWindowOpen()" [class.recording]="agentService.isCurrentSessionRunning()" + [class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'" [class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()" (click)="agentService.toggleVideoPlayer()" - [title]="agentService.isCurrentSessionRunning() ? 'Task is currently running (Recording screen)' : (agentService.currentSessionVideoUrl() ? 'Play Screen Recording' : 'Check Device Screen Recording')" + [title]="agentService.isCurrentSessionRunning() ? 'Task is currently running (Recording screen)' : (agentService.currentSessionRecordingStatus() === 'processing' ? 'Preparing screen recording' : (agentService.currentSessionVideoUrl() ? 'Play Screen Recording' : 'Check Device Screen Recording'))" > videocam - + + progress_activity + + smart_display - + videocam Recording... - Play Recording - Screen Recording + Preparing... + Play Recording + Screen Recording diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss index 6fe71b7..ce11e13 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss @@ -907,6 +907,13 @@ $uniform-gap: 8px; } } + &.processing { + .video-icon { + color: #2563eb; + animation: spin 1s linear infinite; + } + } + &:hover:not(.active) { background: rgba(255, 255, 255, 0.88); border-color: rgba(203, 213, 225, 0.95); diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html index 4cadb08..cc240fd 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html @@ -33,16 +33,16 @@
- {{ agentService.isCurrentSessionRunning() ? 'sensors' : 'smart_display' }} + {{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display') }} - {{ agentService.isCurrentSessionRunning() ? 'Live Device Screen' : (agentService.activeVideoTitle() || 'Screen Recording') }} + {{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording')) }}
-
+
diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts index 536e66d..6c421e0 100644 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +++ b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts @@ -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); + }); + } } } diff --git a/apps/showcase_ui/src/app/core/models/session.model.ts b/apps/showcase_ui/src/app/core/models/session.model.ts index ed8cb5e..b60e926 100644 --- a/apps/showcase_ui/src/app/core/models/session.model.ts +++ b/apps/showcase_ui/src/app/core/models/session.model.ts @@ -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; } diff --git a/apps/showcase_ui/src/app/services/agent.service.spec.ts b/apps/showcase_ui/src/app/services/agent.service.spec.ts index 342b65e..a8dbc2d 100644 --- a/apps/showcase_ui/src/app/services/agent.service.spec.ts +++ b/apps/showcase_ui/src/app/services/agent.service.spec.ts @@ -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([ + { 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(null); + service.activeVideoSegments = signal([]); + 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(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'); + }); +}); diff --git a/apps/showcase_ui/src/app/services/agent.service.ts b/apps/showcase_ui/src/app/services/agent.service.ts index 7432f27..e09cc8e 100644 --- a/apps/showcase_ui/src/app/services/agent.service.ts +++ b/apps/showcase_ui/src/app/services/agent.service.ts @@ -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([]); public activeVideoTitle = signal(''); public isVideoLoading = signal(false); + public recordingPlaybackStatus = signal('idle'); + public recordingPlaybackMessage = signal(''); + public shouldAutoplayVideo = signal(false); + private activeVideoSessionId: string | null = null; + private videoRequestGeneration = 0; + private videoRetryTimer: ReturnType | 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(`/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; } } diff --git a/artemis/controllers/unified_controller.py b/artemis/controllers/unified_controller.py index ebf2e8e..2abf4e9 100644 --- a/artemis/controllers/unified_controller.py +++ b/artemis/controllers/unified_controller.py @@ -476,6 +476,16 @@ class UnifiedMobileController: 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( self, session: RecordingSession, display_state: tuple[int, int, int] | None ) -> bool: @@ -612,6 +622,7 @@ class UnifiedMobileController: stderr = await process.stderr.read() err_msg = stderr.decode() 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) return VideoRecordingResult( success=False, @@ -675,10 +686,12 @@ class UnifiedMobileController: output_path = session.local_video_path 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) return VideoRecordingResult( success=False, - message="Recording file not found on disk", + message=message, ) self._finalize_current_segment(session, time.time()) @@ -690,17 +703,31 @@ class UnifiedMobileController: self._remux_segment_record(record) ) ) + conversion_results = [] 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 = [ Path(record["output_path"]) for record in session.android_segment_records 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) - 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 if self.ctx and self.ctx.data_engine: @@ -712,21 +739,15 @@ class UnifiedMobileController: end_time=time.time(), ) - if mp4_paths: - return VideoRecordingResult( - success=True, - message=f"Recording stopped, saved {len(mp4_paths)} orientation-safe segments", - video_path=mp4_paths[0], - ) - else: - return VideoRecordingResult( - success=True, - message=f"Recording stopped, saved as MKV to {output_path}", - video_path=output_path, - ) + return VideoRecordingResult( + success=True, + message=f"Recording stopped, saved {len(mp4_paths)} orientation-safe segments", + video_path=mp4_paths[0], + ) except Exception as e: logger.error(f"Failed to stop scrcpy recording: {e}") + self._record_recording_failure(session, str(e)) remove_active_session(device_id) return VideoRecordingResult( success=False, diff --git a/artemis/data_engine/engine.py b/artemis/data_engine/engine.py index b28ad68..194edd2 100644 --- a/artemis/data_engine/engine.py +++ b/artemis/data_engine/engine.py @@ -265,6 +265,9 @@ class DataEngine: session_id = self.current_session_id end_time = time.time() 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: session.end_time = end_time session.status = status @@ -328,11 +331,55 @@ class DataEngine: start_time=start_time, end_time=end_time or time.time(), local_video_path=str(local_video_path), + status="ready", ) 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: 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): """Update the video path across tables when traces or videos are moved.""" if not self.storage or not self.current_session_id: diff --git a/artemis/data_engine/models.py b/artemis/data_engine/models.py index 8e3989a..8725319 100644 --- a/artemis/data_engine/models.py +++ b/artemis/data_engine/models.py @@ -100,3 +100,5 @@ class VideoRecordingRecord(BaseModel): start_time: float end_time: float | None = None local_video_path: str | None = None + status: str = "recording" + error: str | None = None diff --git a/artemis/data_engine/storage.py b/artemis/data_engine/storage.py index 119135b..56ac3fc 100644 --- a/artemis/data_engine/storage.py +++ b/artemis/data_engine/storage.py @@ -213,9 +213,24 @@ class StorageManager: start_time REAL, end_time REAL, local_video_path TEXT, + status TEXT NOT NULL DEFAULT 'recording', + error TEXT, 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: conn.execute("ALTER TABLE background_tasks ADD COLUMN logs TEXT") except sqlite3.OperationalError: @@ -273,8 +288,11 @@ class StorageManager: with self._get_connection() as conn: conn.execute( """ - INSERT INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO video_recordings ( + video_id, session_id, device_id, start_time, end_time, + local_video_path, status, error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( str(record.video_id), @@ -283,6 +301,8 @@ class StorageManager: record.start_time, record.end_time, record.local_video_path, + record.status, + record.error, ), ) conn.commit() @@ -293,12 +313,14 @@ class StorageManager: conn.execute( """ UPDATE video_recordings - SET end_time = ?, local_video_path = ? + SET end_time = ?, local_video_path = ?, status = ?, error = ? WHERE video_id = ? """, ( record.end_time, record.local_video_path, + record.status, + record.error, str(record.video_id), ), ) @@ -345,6 +367,10 @@ class StorageManager: start_time=row["start_time"], end_time=row["end_time"], 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 diff --git a/artemis/sdk/agent.py b/artemis/sdk/agent.py index 4e1dbdc..2ac771b 100644 --- a/artemis/sdk/agent.py +++ b/artemis/sdk/agent.py @@ -642,7 +642,7 @@ class Agent: raise finally: try: - await self._finalize_tracing(task=task, context=context) + await self._finalize_tracing_safely(task=task, context=context) finally: if os.environ.get("ARTEMIS_CLOUD_MODE") != "1": await asyncio.to_thread(device_lock.release) @@ -833,6 +833,17 @@ class Agent: device_data["profile"] = task.request.profile 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): if context.data_engine: session_status = "completed" diff --git a/artemis/utils/media.py b/artemis/utils/media.py index ea32012..4b5680a 100644 --- a/artemis/utils/media.py +++ b/artemis/utils/media.py @@ -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): + """Compile legacy timestamp-named step files without consuming manifests.""" steps = [] 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: json_content = f.read() 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): + """Remove only legacy timestamp-named step files, preserving manifests.""" 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() diff --git a/artemis/utils/video.py b/artemis/utils/video.py index c31673f..0f7be57 100644 --- a/artemis/utils/video.py +++ b/artemis/utils/video.py @@ -256,9 +256,12 @@ async def normalize_recording_to_mp4( 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: return False + temporary_path = output_path.with_name(f"{output_path.stem}.part{output_path.suffix}") + if temporary_path.exists(): + temporary_path.unlink() try: process = await asyncio.create_subprocess_exec( get_ffmpeg_path(), @@ -275,13 +278,18 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool: "copy", "-movflags", "+faststart", - str(output_path), + str(temporary_path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) _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: + temporary_path.replace(output_path) return True logger.error( 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: logger.error(f"Failed to remux recording segment: {e}") - if output_path.exists(): - output_path.unlink() + if temporary_path.exists(): + temporary_path.unlink() return False @@ -363,26 +371,33 @@ async def write_recording_manifest(output_dir: Path, mp4_paths: list[Path]) -> P continue metadata = await probe_video_segment(path) 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( { "file": path.name, "start": round(timeline, 3), "duration": round(duration, 3), - "width": int(metadata.get("width", 0)), - "height": int(metadata.get("height", 0)), + "width": width, + "height": height, } ) timeline += duration if not segments: return None 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( {"version": 1, "duration": round(timeline, 3), "segments": segments}, indent=2, ), encoding="utf-8", ) + temporary_manifest_path.replace(manifest_path) return manifest_path diff --git a/tests/unit/admin_console/test_media_recording_lifecycle.py b/tests/unit/admin_console/test_media_recording_lifecycle.py new file mode 100644 index 0000000..31d8dc7 --- /dev/null +++ b/tests/unit/admin_console/test_media_recording_lifecycle.py @@ -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 diff --git a/tests/unit/admin_console/test_task_queue_service.py b/tests/unit/admin_console/test_task_queue_service.py index 1df7542..ad974d3 100644 --- a/tests/unit/admin_console/test_task_queue_service.py +++ b/tests/unit/admin_console/test_task_queue_service.py @@ -71,6 +71,26 @@ async def test_get_next_pending_task(): 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 async def test_remove_task(): state.queue_items = [ diff --git a/tests/unit/data_engine/test_engine.py b/tests/unit/data_engine/test_engine.py index ae33934..5b5fb89 100644 --- a/tests/unit/data_engine/test_engine.py +++ b/tests/unit/data_engine/test_engine.py @@ -191,16 +191,19 @@ def test_video_recording_persistence(tmp_path): assert persisted.start_time == 100.0 assert persisted.end_time is None assert persisted.local_video_path == "/tmp/recording.mp4" + assert persisted.status == "recording" # Test update record.end_time = 150.0 record.local_video_path = "/tmp/recording_final.mp4" + record.status = "ready" storage.update_video_recording(record) updated = storage.get_video_recording(video_id) assert updated is not None assert updated.end_time == 150.0 assert updated.local_video_path == "/tmp/recording_final.mp4" + assert updated.status == "ready" def test_record_step_suppresses_identical_post_screenshot(tmp_path): diff --git a/tests/unit/sdk/test_agent_trace_finalization.py b/tests/unit/sdk/test_agent_trace_finalization.py new file mode 100644 index 0000000..9a051d5 --- /dev/null +++ b/tests/unit/sdk/test_agent_trace_finalization.py @@ -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) diff --git a/tests/unit/test_unified_controller_video.py b/tests/unit/test_unified_controller_video.py index 9e8d0d4..d2ab347 100644 --- a/tests/unit/test_unified_controller_video.py +++ b/tests/unit/test_unified_controller_video.py @@ -243,9 +243,10 @@ async def test_unified_controller_stop_recording(mock_ctx, tmp_path): with patch( "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( - "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() @@ -363,9 +364,12 @@ async def test_unified_controller_crash_recovery_and_multi_segment(mock_ctx, tmp with ( patch( "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() diff --git a/tests/unit/utils/test_media.py b/tests/unit/utils/test_media.py new file mode 100644 index 0000000..bad64ab --- /dev/null +++ b/tests/unit/utils/test_media.py @@ -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()