Files
Artemis-Eapil/apps/showcase_ui/src/app/services/agent.service.ts
T

1371 lines
47 KiB
TypeScript

/**
* 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 { Injectable, signal, inject, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse } from '../core/models/session.model';
import { StepItemData } from '../core/models/stream.model';
export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData };
export interface VideoSegment {
url: string;
start: number;
duration: number;
width: number;
height: number;
}
export type RecordingPlaybackStatus =
| 'idle'
| 'live'
| 'processing'
| 'ready'
| 'failed'
| 'unavailable';
export interface StartupProgressEvent {
session_id?: string;
stage: string;
message: string;
timestamp: number;
}
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'
})
export class AgentService {
private http = inject(HttpClient);
private activePauseCardKey: string | null = null;
// Signals to expose state to components
private rawSessions = signal<Session[]>([]);
private pendingQueue = signal<Session[]>([]);
public sessions = computed(() => {
const raw = this.rawSessions();
const pending = this.pendingQueue();
const status = this.agentStatus();
const goal = this.runningGoal();
const runId = this.runningSessionId();
const sessionMap = new Map<string, Session>();
// 1. First add raw sessions from DB
raw.forEach((s) => {
const isCurrentActive = (status === 'running' || status === 'paused') && runId === s.session_id;
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
let sStatus = isCurrentActive ? status : (isPending ? 'pending' : s.status);
if ((sStatus === 'running' || sStatus === 'paused') && !isCurrentActive) {
const activeRun = raw.find(r => r.session_id === runId);
sStatus = (activeRun && (s.start_time || 0) > (activeRun.start_time || 0)) ? 'pending' : 'completed';
}
sessionMap.set(s.session_id, {
...s,
status: sStatus,
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
});
});
// 2. Add pending queue sessions if not yet in raw sessions
pending.forEach((p) => {
if (!sessionMap.has(p.session_id)) {
const isCurrentRunning = (status === 'running' || status === 'paused') && runId === p.session_id;
sessionMap.set(p.session_id, {
...p,
status: isCurrentRunning ? status : (p.status || 'pending')
});
}
});
// 3. Ensure currently active running session is present
if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
sessionMap.set(runId, {
session_id: runId,
initial_goal: goal || '',
start_time: Date.now() / 1000,
status,
model_info: this.activeModel() || undefined
});
}
return Array.from(sessionMap.values()).sort((a, b) => b.start_time - a.start_time);
});
public currentSessionId = signal<string | null>(null);
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
public agentStatus = signal<string>('idle'); // Status of the agent runner process
public runningSessionId = signal<string | null>(null);
public runningGoal = signal<string | null>(null);
public isPaused = signal<boolean>(false);
public pausedError = signal<string | null>(null);
public isRetrying = signal<boolean>(false);
public retryMessage = signal<string | null>(null);
public activeModel = signal<{ name: string; id: string; provider: string } | null>(null);
public userPinnedSessionId = signal<string | null>(null); // Pinned session if user explicitly selected a non-running task
public startupProgressBySession = signal<Record<string, StartupProgressEvent[]>>({});
private pendingStartupProgress = signal<StartupProgressEvent[]>([]);
public currentStartupProgress = computed(() => {
const sessionId = this.currentSessionId();
if (sessionId) {
const sessionEvents = this.startupProgressBySession()[sessionId];
if (sessionEvents?.length) return sessionEvents;
}
return this.pendingStartupProgress();
});
// Notes and Tab States
public currentNotes = signal<Record<string, string>>({});
public selectedNoteKey = signal<string>('task_plan.md');
public activeTab = signal<'tasks' | 'notes'>('tasks');
// Video Replay Floating Window States
public isVideoWindowOpen = signal<boolean>(false);
public isVideoMinimized = signal<boolean>(false);
public activeVideoUrl = signal<string | null>(null);
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
*/
public clearUserPinnedSession(): void {
this.userPinnedSessionId.set(null);
}
/**
* Computed active/viewed session
*/
public currentSession = computed(() => {
const curId = this.currentSessionId();
if (!curId) return null;
return this.sessions().find(s => s.session_id === curId) || null;
});
/**
* Computed whether the currently viewed session is running
*/
public isCurrentSessionRunning = computed(() => {
const session = this.currentSession();
if (session) return session.status === 'running' || session.status === 'paused';
return this.agentStatus() === 'running' || this.agentStatus() === 'paused';
});
/**
* Computed video URL for the currently viewed session
*/
public currentSessionVideoUrl = computed(() => {
const curId = this.currentSessionId();
if (!curId) return null;
const session = this.sessions().find(s => s.session_id === curId);
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;
constructor() {
this.fetchSessions();
this.startStatusPolling();
}
/**
* Check if any task is currently running
*/
public isRunningTask = computed(() => {
if (this.agentStatus() === 'running' || this.agentStatus() === 'paused') return true;
return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
});
/**
* Run a new task by submitting to backend queue
*/
public runTask(
goal: string,
profile: string = 'flash',
expectedOutput?: string,
enableOutputter?: boolean
): Observable<any> {
return new Observable((obs) => {
const submittedEvent: StartupProgressEvent = {
stage: 'submitting',
message: 'Submitting the task',
timestamp: Date.now() / 1000
};
this.pendingStartupProgress.set([submittedEvent]);
const payload: any = { goal, profile };
if (expectedOutput && expectedOutput.trim()) {
payload.expected_output = expectedOutput.trim();
}
if (enableOutputter !== undefined) {
payload.enable_outputter = enableOutputter;
}
this.clearUserPinnedSession();
this.http.post<any>('/api/run', payload).subscribe({
next: (res) => {
if (res && res.tasks && res.tasks.length > 0) {
const newSessionId = res.tasks[0].session_id;
if (newSessionId) {
this.appendStartupProgress(
{ ...submittedEvent, session_id: newSessionId },
newSessionId
);
this.pendingStartupProgress.set([]);
const isCurrentlyRunning =
this.agentStatus() === 'running' ||
this.sessions().some((s) => s.status === 'running');
const activeSessionId = this.runningSessionId()
|| this.sessions().find((s) => s.status === 'running' || s.status === 'paused')?.session_id;
// The status poll can observe the new runner before /api/run
// returns. In that case it is still the task we just submitted,
// not an older task that should remain selected.
if (!isCurrentlyRunning || activeSessionId === newSessionId) {
this.selectSession(newSessionId, false);
}
}
}
this.pendingStartupProgress.set([]);
obs.next(res);
obs.complete();
},
error: (err) => {
this.pendingStartupProgress.set([]);
obs.error(err);
}
});
});
}
/**
* Stop the currently running task (and optionally clear queue)
*/
public stopTask(stopAll: boolean = false): void {
const stoppingSessionId = this.runningSessionId()
|| this.sessions().find((session) => session.status === 'running' || session.status === 'paused')?.session_id
|| null;
// Keep the task's terminal state stable while the stop request and the
// persisted session update cross the network. Clearing the global runner
// state first used to make the session merger infer "completed" from the
// still-stale DB row, causing a brief completed -> cancelled flicker.
this.setSessionStatus(stoppingSessionId, 'cancelled');
// Apply optimistic updates: only set idle if stopAll is true or pending queue is empty
if (stopAll || this.pendingQueue().length === 0) {
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
}
this.isPaused.set(false);
this.pausedError.set(null);
this.isRetrying.set(false);
if (stopAll) {
this.pendingQueue.set([]);
}
// Mark active streaming / pending logs in current session as finished
this.sessionLogs.update((logs) =>
logs.map((l) => {
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
})
);
this.http.post<any>(`/api/stop?all=${stopAll}`, {}).subscribe({
next: () => {
this.fetchStatus();
this.fetchSessions();
},
error: (err) => {
console.error('Failed to stop task:', err);
this.fetchSessions();
this.fetchStatus();
}
});
}
/**
* Resume paused task
*/
public resumeTask(): void {
this.http.post<{ status?: string }>('/api/resume', {}).subscribe({
next: (response) => {
if (response?.status !== 'resumed') {
// A stale recovery control must not optimistically change the task
// back to running when the backend says there is nothing to resume.
this.fetchStatus();
return;
}
const resumedSessionId = this.runningSessionId();
this.isPaused.set(false);
this.pausedError.set(null);
this.agentStatus.set('running');
this.setSessionStatus(resumedSessionId, 'running');
this.fetchStatus();
},
error: (err) => {
console.error('Failed to resume task:', err);
}
});
}
/**
* Fetch all past and active sessions from the backend
*/
public fetchSessions(): void {
this.http.get<Session[]>('/api/sessions').subscribe({
next: (data) => {
this.rawSessions.set(data);
this.updateModelForCurrentView(data);
// On initial load, if nothing is selected, not pinned, not running, and sessions exist, select latest
if (!this.currentSessionId() && !this.userPinnedSessionId() && this.agentStatus() !== 'running' && data.length > 0) {
this.selectSession(data[0].session_id, false);
}
},
error: (err) => {
console.error('Failed to fetch sessions from backend:', err);
}
});
}
/**
* Delete an individual session / task
*/
public deleteSession(sessionId: string): Observable<any> {
// 1. Optimistically update local session state immediately
this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId));
this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId));
if (this.userPinnedSessionId() === sessionId) {
this.userPinnedSessionId.set(null);
}
if (this.currentSessionId() === sessionId) {
this.selectSession('', false);
if (this.isVideoWindowOpen()) {
this.closeVideoPlayer();
}
const runningId = this.runningSessionId();
if (runningId && this.agentStatus() === 'running') {
this.selectSession(runningId, false);
}
}
// 2. Send API request
return new Observable((obs) => {
this.http.post<any>(`/api/sessions/${sessionId}/delete`, {}).subscribe({
next: (res) => {
this.fetchSessions();
this.fetchStatus();
obs.next(res);
obs.complete();
},
error: (err) => {
console.error(`Failed to delete session ${sessionId}:`, err);
this.fetchSessions();
this.fetchStatus();
obs.error(err);
}
});
});
}
/**
* Clear all sessions, tasks, and history
*/
public clearAllHistory(): Observable<any> {
// 1. Optimistically clear all local session state immediately
this.userPinnedSessionId.set(null);
this.rawSessions.set([]);
this.pendingQueue.set([]);
this.selectSession('', false);
if (this.isVideoWindowOpen()) {
this.closeVideoPlayer();
}
// 2. Send API request
return new Observable((obs) => {
this.http.post<any>('/api/cleanup', {}).subscribe({
next: (res) => {
this.fetchSessions();
this.fetchStatus();
obs.next(res);
obs.complete();
},
error: (err) => {
console.error('Failed to cleanup history:', err);
this.fetchSessions();
this.fetchStatus();
obs.error(err);
}
});
});
}
/**
* Connect to the Server-Sent Events stream for a specific session
* @param sessionId The ID of the session to select
* @param isUserAction Whether this selection was explicitly triggered by user interaction
*/
public selectSession(sessionId: string, isUserAction: boolean = false): void {
if (isUserAction) {
if (sessionId && sessionId === this.runningSessionId() && this.agentStatus() === 'running') {
// User explicitly clicked the currently running task -> follow live runner
this.userPinnedSessionId.set(null);
} else if (sessionId) {
// User explicitly clicked a non-running or historical task -> pin to it
this.userPinnedSessionId.set(sessionId);
} else {
this.userPinnedSessionId.set(null);
}
}
if (!sessionId) {
this.currentSessionId.set(null);
this.sessionLogs.set([]);
this.currentNotes.set({});
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
return;
}
if (this.currentSessionId() === sessionId) {
return; // Already selected
}
this.currentSessionId.set(sessionId);
this.sessionLogs.set([]); // Reset logs for new session selection
this.activePauseCardKey = null;
this.isPaused.set(false);
this.pausedError.set(null);
this.fetchNotes(sessionId);
this.updateModelForCurrentView();
// If video window is currently open, dynamically sync/refresh video for new session
if (this.isVideoWindowOpen()) {
this.openVideoPlayer(sessionId);
}
// Clean up previous event stream connection if any
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
console.log(`Connecting to session: ${sessionId}`);
this.eventSource = new EventSource(`/api/stream/${sessionId}`);
// The server emits info only after this client has been registered as an
// SSE subscriber. Fetching the persisted snapshot after that acknowledgement
// closes the snapshot-to-stream gap: earlier events are in history and later
// events are already queued for this connection.
this.eventSource.addEventListener('info', () => {
this.backfillSessionSteps(sessionId);
});
// Listen to standard SSE keep-alive
this.eventSource.addEventListener('keep-alive', () => {
// No-op keep-alive tick
});
// Listen to raw events and append them directly to the logs array
const eventTypes = [
'llm_stream',
'trace_recorded',
'step_recorded',
'step_updated',
'background_tasks_updated',
'task_paused',
'task_resumed',
'llm_retrying',
'startup_progress',
'session_started',
'session_ended',
'recording_ready',
'recording_failed'
];
eventTypes.forEach((eventType) => {
this.eventSource?.addEventListener(eventType, (event: MessageEvent) => {
try {
const parsedData = JSON.parse(event.data);
// 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');
this.runningSessionId.set(parsedData.session_id);
if (parsedData?.initial_goal) {
this.runningGoal.set(parsedData.initial_goal);
}
this.fetchSessions();
// Only auto-select if user hasn't explicitly chosen to inspect another task
const pinnedId = this.userPinnedSessionId();
if (!pinnedId && parsedData?.session_id) {
this.selectSession(parsedData.session_id, false);
}
} else if (eventType === 'session_ended') {
this.applySessionEndedStatus(evtSessionId, parsedData);
this.fetchStatus();
this.fetchSessions();
this.updateModelForCurrentView();
}
return;
}
// Guard against obsolete subscriptions
if (this.currentSessionId() !== sessionId) {
return;
}
if (eventType === 'session_started') {
this.agentStatus.set('running');
if (parsedData?.session_id) {
this.runningSessionId.set(parsedData.session_id);
}
if (parsedData?.initial_goal) {
this.runningGoal.set(parsedData.initial_goal);
}
this.fetchSessions();
return;
}
if (eventType === 'session_ended') {
this.applySessionEndedStatus(sessionId, parsedData);
this.agentStatus.set('idle');
this.runningSessionId.set(null);
this.runningGoal.set(null);
this.isRetrying.set(false);
this.isPaused.set(false);
this.fetchSessions();
this.fetchNotes(sessionId);
this.updateModelForCurrentView();
if (
this.isVideoWindowOpen()
&& this.activeVideoSessionId === sessionId
&& this.recordingPlaybackStatus() === 'live'
) {
this.beginRecordingFinalization(sessionId);
}
return;
}
if (eventType === 'startup_progress') {
this.appendStartupProgress(parsedData, sessionId);
return;
}
if (eventType === 'llm_retrying') {
this.isRetrying.set(true);
const attempt = Number(parsedData.attempt || 0);
const max = Number(parsedData.max_retries || 0);
const delay = Number(parsedData.delay || 0);
const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : '';
const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`);
this.appendLiveLLMRetryTrace(parsedData, sessionId);
return;
}
if (eventType === 'task_paused') {
this.isPaused.set(true);
this.isRetrying.set(false);
this.agentStatus.set('paused');
this.runningSessionId.set(sessionId);
this.setSessionStatus(sessionId, 'paused');
const pauseError = parsedData.error || 'AI call failed';
this.pausedError.set(pauseError);
this.appendPausedErrorCard(
pauseError,
sessionId,
parsedData.timestamp,
parsedData.step_id,
parsedData
);
return;
}
if (eventType === 'task_resumed') {
this.isPaused.set(false);
this.isRetrying.set(false);
this.pausedError.set(null);
if (this.runningSessionId() === sessionId) {
this.agentStatus.set('running');
this.setSessionStatus(sessionId, 'running');
}
this.activePauseCardKey = null;
return;
}
if (eventType === 'step_updated' || eventType === 'step_recorded' || eventType === 'llm_stream') {
this.isRetrying.set(false);
}
if (eventType === 'llm_stream') {
const execId = parsedData.execution_id;
const stepId = parsedData.step_id;
const chunk = parsedData.chunk;
const streamType = parsedData.stream_type || 'text';
this.sessionLogs.update((logs) => {
// Find if there is an active stream log with same execution_id and stream_type
const existingIndex = logs.findIndex(
(l) => l.type === 'llm_stream' &&
l.data.execution_id === execId &&
(l.data.stream_type || 'text') === streamType
);
if (existingIndex > -1) {
// Append text in place
const updatedLogs = [...logs];
const existingLog = updatedLogs[existingIndex];
updatedLogs[existingIndex] = {
...existingLog,
data: {
...existingLog.data,
text: existingLog.data.text + chunk,
step_id: stepId || existingLog.data.step_id
}
};
return updatedLogs;
} else {
// First mark any other active streams of same stream_type as completed
const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' &&
(l.data.stream_type || 'text') === streamType &&
!l.data.isCompleted) {
return {
...l,
data: { ...l.data, isCompleted: true }
};
}
return l;
});
// Add new stream log
return [
...updatedLogs,
{
type: 'llm_stream',
timestamp: new Date().toISOString(),
data: {
execution_id: execId,
step_id: stepId,
text: chunk,
stream_type: streamType,
isCompleted: false
}
}
];
}
});
} else {
// For other event types, mark any active stream as completed
this.sessionLogs.update((logs) => {
const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' && !l.data.isCompleted) {
return {
...l,
data: { ...l.data, isCompleted: true }
};
}
return l;
});
const evtTime = typeof parsedData?.timestamp === 'number'
? new Date(parsedData.timestamp * 1000).toISOString()
: new Date().toISOString();
const nextLog = {
type: eventType,
timestamp: evtTime,
data: parsedData
};
// A retry is published both as trace_recorded and as the
// purpose-built llm_retrying event. Replace the optimistic live
// copy when the persisted trace arrives instead of duplicating it.
if (eventType === 'trace_recorded' && parsedData?.trace_id) {
const existingTraceIndex = updatedLogs.findIndex((log) =>
log.type === 'trace_recorded'
&& log.data?.trace_id === parsedData.trace_id
);
if (existingTraceIndex > -1) {
const deduplicatedLogs = [...updatedLogs];
deduplicatedLogs[existingTraceIndex] = nextLog;
return deduplicatedLogs;
}
}
return [...updatedLogs, nextLog];
});
}
if (eventType === 'trace_recorded' && parsedData) {
const trName = parsedData.name || '';
if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) {
const curSessionId = this.currentSessionId();
if (curSessionId === sessionId) {
this.fetchNotes(curSessionId);
}
}
}
} catch (e) {
console.error(`Failed to parse ${eventType} event data:`, e);
// Fallback to plain event data if parsing fails
this.sessionLogs.update((logs) => [
...logs,
{
type: eventType,
timestamp: new Date().toISOString(),
data: event.data
}
]);
}
});
});
this.eventSource.onerror = (err) => {
console.error('EventSource connection error:', err);
};
}
/**
* Load a fresh persisted snapshot without replacing events already received
* from SSE. Snapshot logs are replaceable, while live logs remain append-only;
* the stream aggregator merges matching steps and trace IDs within each step.
*/
private backfillSessionSteps(sessionId: string): void {
this.http.get<StepItemData[]>(`/api/sessions/${sessionId}/steps`).subscribe({
next: (steps) => {
if (this.currentSessionId() !== sessionId) return;
const historicalLogs = steps.map((step) => ({
type: 'step_updated',
session_id: sessionId,
timestamp: new Date((step.timestamp || Date.now() / 1000) * 1000).toISOString(),
data: step,
history_snapshot: true
}));
this.sessionLogs.update((logs) => [
...historicalLogs,
...logs.filter((log) => !log.history_snapshot)
]);
},
error: (err) => {
console.error('Failed to backfill session steps:', err);
}
});
}
/**
* Convert the dedicated live retry event into the same trace shape used by
* history APIs. This keeps live streaming and post-refresh rendering on one
* data contract instead of maintaining a separate retry UI path.
*/
private appendLiveLLMRetryTrace(data: any, sessionId: string): void {
const timestampSeconds = typeof data?.timestamp === 'number'
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
: Date.now() / 1000;
const traceId = data?.trace_id
|| `llm-retry-live-${data?.request_id || 'unknown'}-${timestampSeconds}`;
const payload = {
error: data?.error,
delay: data?.delay,
attempt: data?.attempt,
max_retries: data?.max_retries,
provider: data?.provider,
source: data?.source,
recoverable: data?.recoverable,
request_id: data?.request_id,
scheduled_at: data?.scheduled_at ?? timestampSeconds
};
const retryTrace = {
trace_id: traceId,
session_id: sessionId,
step_id: data?.step_id || null,
type: 'llm_call',
name: 'llm_retry',
timestamp: timestampSeconds,
status: 'retrying',
payload
};
this.sessionLogs.update((logs) => {
const existingIndex = logs.findIndex((log) =>
log.type === 'trace_recorded'
&& (
log.data?.trace_id === traceId
|| (
data?.request_id
&& log.data?.payload?.request_id === data.request_id
&& Number(log.data?.payload?.scheduled_at) === Number(payload.scheduled_at)
)
)
);
const retryLog = {
type: 'trace_recorded',
session_id: sessionId,
timestamp: new Date(timestampSeconds * 1000).toISOString(),
data: retryTrace
};
if (existingIndex < 0) return [...logs, retryLog];
const updatedLogs = [...logs];
updatedLogs[existingIndex] = retryLog;
return updatedLogs;
});
}
/**
* Preserve pause failures in the normal failed-LLM card stream. New runners
* emit a persisted failed trace first; this fallback also supports older
* runners whose task_paused event only carried an error string.
*/
private appendPausedErrorCard(
error: unknown,
sessionId: string,
timestamp?: number | string,
stepId?: string | null,
details?: any
): void {
const errorText = typeof error === 'string' ? error : JSON.stringify(error);
const pauseKey = `${sessionId}:${errorText}`;
if (this.activePauseCardKey === pauseKey) return;
this.sessionLogs.update((logs) => {
const alreadyRecorded = logs.some((log) => {
const traces = log?.type === 'trace_recorded'
? [log.data]
: (Array.isArray(log?.data?.generic_tools) ? log.data.generic_tools : []);
return traces.some((trace: any) =>
trace?.type === 'llm_call'
&& trace?.status === 'failed'
&& String(trace?.payload?.error ?? trace?.error ?? '') === errorText
);
});
if (alreadyRecorded) return logs;
const latestStepId = stepId || [...logs].reverse().find((log) =>
(log.type === 'step_updated' || log.type === 'step_recorded') && log.data?.step_id
)?.data?.step_id || null;
const timestampMs = typeof timestamp === 'number'
? (timestamp < 1e11 ? timestamp * 1000 : timestamp)
: (timestamp ? new Date(timestamp).getTime() : Date.now());
return [
...logs,
{
type: 'trace_recorded',
session_id: sessionId,
timestamp: new Date(timestampMs).toISOString(),
data: {
trace_id: `task-paused-${sessionId}-${timestampMs}`,
session_id: sessionId,
step_id: latestStepId,
type: 'llm_call',
name: 'llm_pause',
status: 'failed',
timestamp: timestampMs / 1000,
payload: {
error: errorText,
pause: true,
request_id: details?.request_id,
provider: details?.provider,
waited_seconds: details?.waited_seconds,
retries: Array.isArray(details?.retries) ? details.retries : []
}
}
}
];
});
this.activePauseCardKey = pauseKey;
}
private pollCounter: number = 0;
private setSessionStatus(sessionId: string | null, status: Session['status']): void {
if (!sessionId) return;
this.rawSessions.update((sessions) => sessions.map((session) =>
session.session_id === sessionId ? { ...session, status } : session
));
this.pendingQueue.update((sessions) => sessions.map((session) =>
session.session_id === sessionId ? { ...session, status } : session
));
}
private applySessionEndedStatus(sessionId: unknown, data: any): void {
if (!sessionId) return;
const reportedStatus = String(data?.status || '').toLowerCase();
const status: Session['status'] | null = data?.was_stopped_manually || reportedStatus === 'cancelled'
? 'cancelled'
: reportedStatus === 'failed'
? 'failed'
: reportedStatus === 'completed' || reportedStatus === 'success'
? 'completed'
: null;
if (status) {
this.setSessionStatus(String(sessionId), status);
}
}
/**
* Start periodic status polling from the backend
*/
private startStatusPolling(): void {
this.fetchStatus();
this.statusInterval = setInterval(() => {
this.fetchStatus();
this.pollCounter++;
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
if (this.pollCounter % 3 === 0) {
this.fetchSessions();
}
}, 2000);
}
/**
* Fetch current agent runner process status
*/
public fetchStatus(): void {
this.http.get<any>('/api/status').subscribe({
next: (data) => {
if (data && data.status) {
const oldStatus = this.agentStatus();
const oldRunningSessionId = this.runningSessionId();
this.agentStatus.set(data.status);
const isActive = data.status === 'running' || data.status === 'paused';
if (isActive) {
this.runningSessionId.set(data.session_id || null);
this.runningGoal.set(data.goal || null);
if (data.model_info) {
this.activeModel.set(data.model_info);
}
} else {
this.runningSessionId.set(null);
this.runningGoal.set(null);
// If viewing a selected history session, use its model_info
const curId = this.currentSessionId();
if (curId) {
const selected = this.sessions().find(s => s.session_id === curId);
if (selected && selected.model_info) {
this.activeModel.set(selected.model_info);
} else if (data.model_info) {
this.activeModel.set(data.model_info);
}
} else if (data.model_info) {
this.activeModel.set(data.model_info);
}
}
this.isPaused.set(data.status === 'paused');
if (data.status === 'paused') {
this.isRetrying.set(false);
const pauseError = data.paused_error
|| this.pausedError()
|| 'AI model request failed. The task is paused.';
this.pausedError.set(pauseError);
if (data.session_id && this.currentSessionId() === data.session_id) {
this.appendPausedErrorCard(pauseError, data.session_id);
}
} else {
this.pausedError.set(null);
this.activePauseCardKey = null;
}
// Map backend pending queue to real Session / TaskQueueItem objects
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
if (typeof item === 'object' && item !== null) {
return {
session_id: item.session_id || `pending-task-${index}`,
initial_goal: item.goal || '',
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
status: item.status || 'pending'
};
}
return {
session_id: `task-queued-${index}`,
initial_goal: String(item),
start_time: (Date.now() / 1000) + index,
status: 'pending'
};
});
this.pendingQueue.set(pending);
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
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();
const pinnedId = this.userPinnedSessionId();
if (!currentId) {
this.selectSession(data.session_id, false);
} else if (currentId !== data.session_id) {
if (!pinnedId) {
this.selectSession(data.session_id, false);
}
}
}
}
},
error: (err) => {
console.error('Failed to fetch status from backend:', err);
this.agentStatus.set('offline');
this.runningSessionId.set(null);
this.runningGoal.set(null);
}
});
}
/**
* Update active model badge according to currently viewed session
*/
private updateModelForCurrentView(sessionsList?: Session[]): void {
const curId = this.currentSessionId();
const allSessions = sessionsList || this.sessions();
if (curId) {
const selected = allSessions.find(s => s.session_id === curId);
if (selected && selected.model_info) {
this.activeModel.set(selected.model_info);
return;
}
}
}
/**
* Clean up event source connection and timers on service destruction
*/
public destroy(): void {
if (this.statusInterval) {
clearInterval(this.statusInterval);
}
if (this.eventSource) {
this.eventSource.close();
}
this.cancelVideoRetry();
}
/**
* Fetch notes for a specific session
*/
public fetchNotes(sessionId: string): void {
if (!sessionId) {
this.currentNotes.set({});
return;
}
this.http.get<any>(`/api/sessions/${sessionId}/notes`).subscribe({
next: (res) => {
if (res && res.notes) {
this.currentNotes.set(res.notes);
// Default to task_plan.md if available, otherwise first note
const keys = Object.keys(res.notes);
if (keys.length > 0) {
const currentSelected = this.selectedNoteKey();
if (!keys.includes(currentSelected)) {
if (keys.includes('task_plan.md')) {
this.selectedNoteKey.set('task_plan.md');
} else {
this.selectedNoteKey.set(keys[0]);
}
}
}
}
},
error: (err) => {
console.error(`Failed to fetch notes for session ${sessionId}:`, err);
}
});
}
/**
* Open the video player for a given session or the current session
*/
public openVideoPlayer(sessionId?: string, videoUrl?: string, title?: string): void {
const targetSessionId = sessionId || this.currentSessionId();
const session = this.sessions().find(s => s.session_id === targetSessionId);
const targetUrl = videoUrl || session?.video_url || null;
const goalTitle = title || session?.initial_goal || (targetSessionId ? `Task: ${targetSessionId.slice(0, 8)}...` : 'Screen Recording');
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 appendStartupProgress(data: any, sessionId: string): void {
if (!data?.stage || !data?.message) return;
const timestamp = typeof data.timestamp === 'number'
? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
: Date.now() / 1000;
const event: StartupProgressEvent = {
session_id: String(data.session_id || sessionId),
stage: String(data.stage),
message: String(data.message),
timestamp
};
this.startupProgressBySession.update((allEvents) => {
const current = [...(allEvents[sessionId] || [])];
const existingIndex = current.findIndex((item) => item.stage === event.stage);
if (existingIndex >= 0) {
current[existingIndex] = event;
} else {
current.push(event);
}
current.sort((a, b) => a.timestamp - b.timestamp);
return { ...allEvents, [sessionId]: current };
});
}
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.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;
}
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;
}
/**
* Toggle the video player for the current session
*/
public toggleVideoPlayer(): void {
if (this.isVideoWindowOpen()) {
this.isVideoWindowOpen.set(false);
} else {
this.openVideoPlayer();
}
}
/**
* Close the video player
*/
public closeVideoPlayer(): void {
this.isVideoWindowOpen.set(false);
this.cancelVideoRetry();
this.videoRequestGeneration++;
this.activeVideoSessionId = null;
}
}