feat: add official Codex skill and harden task execution
Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
This commit is contained in:
1 parent
2328a4df0c
commit
e1ea099592
128 files changed
+4944
-700
No files matched your search
+1
-1
@@ -120,7 +120,7 @@ limitations under the License.
|
||||
<div class="video-body">
|
||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'live') {
|
||||
@if (!liveStreamError()) {
|
||||
@if (!liveStreamError() && pageVisible()) {
|
||||
<div class="live-stream-container">
|
||||
<img
|
||||
class="live-screen-img"
|
||||
|
||||
+18
@@ -98,6 +98,24 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('removes the live image while hidden and reconnects it on return', () => {
|
||||
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
|
||||
agentService.recordingPlaybackStatus.set('live');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
|
||||
|
||||
hidden.and.returnValue(true);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('img.live-screen-img')).toBeNull();
|
||||
|
||||
hidden.and.returnValue(false);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
|
||||
expect(component.liveStreamError()).toBeFalse();
|
||||
});
|
||||
|
||||
function playButton(): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.play-btn');
|
||||
}
|
||||
|
||||
+15
@@ -70,6 +70,21 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
public sources = computed(() => this.agentService.recordingSources?.() || []);
|
||||
public liveStreamUrl = signal<string>('/api/stream/device-live');
|
||||
public liveStreamError = signal<boolean>(false);
|
||||
public pageVisible = signal(!document.hidden);
|
||||
|
||||
@HostListener('document:visibilitychange')
|
||||
@HostListener('window:pageshow')
|
||||
onPageVisibilityChange(): void {
|
||||
this.pageVisible.set(!document.hidden);
|
||||
if (!document.hidden && this.agentService.recordingPlaybackStatus() === 'live') {
|
||||
this.retryLiveStream();
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:pagehide')
|
||||
onPageHide(): void {
|
||||
this.pageVisible.set(false);
|
||||
}
|
||||
public activeSegmentIndex = signal<number>(0);
|
||||
public currentVideoUrl = computed(() => {
|
||||
const segments = this.agentService.activeVideoSegments();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, inject, OnInit, signal, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { finalize } from 'rxjs';
|
||||
import { finalize, timeout } from 'rxjs';
|
||||
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
|
||||
|
||||
interface Capabilities {
|
||||
recording?: { codec: string; max_long_edge: number; fps: number; h265_kbps: number; h264_kbps: number; computer_scope: string; minimum_disk_mb: number; hevc_playback: boolean };
|
||||
@@ -98,7 +99,7 @@ export class CapabilitySettingsComponent implements OnInit {
|
||||
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||
ngOnInit(): void {
|
||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
|
||||
next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
|
||||
});
|
||||
}
|
||||
|
||||
+14
-1
@@ -1,4 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ModelSettingsComponent } from './model-settings.component';
|
||||
@@ -17,6 +18,18 @@ describe('持久化模型与功能设置', () => {
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('配置请求排队超时后退出加载状态,不会发送保存请求', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
const request = http.expectOne('/api/system/model-settings');
|
||||
tick(UI_READ_TIMEOUT_MS);
|
||||
expect(request.cancelled).toBeTrue();
|
||||
expect(fixture.componentInstance.loading()).toBeFalse();
|
||||
expect(fixture.componentInstance.error()).toBeTruthy();
|
||||
expect(fixture.componentInstance.valid()).toBeFalse();
|
||||
http.expectNone('/api/system/model-settings');
|
||||
}));
|
||||
|
||||
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, OnInit, inject, signal, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { finalize } from 'rxjs';
|
||||
import { finalize, timeout } from 'rxjs';
|
||||
import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts';
|
||||
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
|
||||
|
||||
interface ModelSettings {
|
||||
@@ -128,7 +129,7 @@ export class ModelSettingsComponent implements OnInit {
|
||||
message = signal(''); error = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
this.http.get<ModelSettings>('/api/system/model-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||
this.http.get<ModelSettings>('/api/system/model-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
|
||||
next: config => this.apply(config),
|
||||
error: () => this.error.set('配置加载失败,请刷新页面重试。')
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Local metadata reads must also time out when the browser is waiting for a
|
||||
// connection. This is independent of model execution and long-running actions.
|
||||
export const UI_READ_TIMEOUT_MS = 15_000;
|
||||
@@ -0,0 +1,120 @@
|
||||
import { provideHttpClient, withXhr } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
|
||||
import { AgentService } from './agent.service';
|
||||
import { SharedLiveStream } from './shared-live-stream';
|
||||
|
||||
describe('AgentService browser connection lifecycle', () => {
|
||||
let service: AgentService;
|
||||
let http: HttpTestingController;
|
||||
let hidden: jasmine.Spy;
|
||||
let streams: (EventTarget & { close: jasmine.Spy })[];
|
||||
|
||||
beforeEach(() => {
|
||||
hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
|
||||
streams = [];
|
||||
spyOn(SharedLiveStream, 'open').and.callFake(() => {
|
||||
const stream = Object.assign(new EventTarget(), { close: jasmine.createSpy('close') });
|
||||
streams.push(stream);
|
||||
return stream as unknown as SharedLiveStream;
|
||||
});
|
||||
// Keep this test independent of the user's cached task selection.
|
||||
spyOn<any>(AgentService.prototype, 'restoreSessionsCache');
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(withXhr()), provideHttpClientTesting()] });
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service?.destroy();
|
||||
http.verify({ ignoreCancelled: true });
|
||||
});
|
||||
|
||||
function flushSnapshots(): void {
|
||||
http.expectOne('/api/sessions').flush([]);
|
||||
http.expectOne('/api/status').flush({ status: 'idle', queue: [], active_tasks: [] });
|
||||
// The initial offline -> idle transition may request a newer session list.
|
||||
for (const request of http.match('/api/sessions')) request.flush([]);
|
||||
}
|
||||
|
||||
it('does not open SSE in a hidden tab and releases it when the tab is hidden again', () => {
|
||||
hidden.and.returnValue(true);
|
||||
service = TestBed.inject(AgentService);
|
||||
flushSnapshots();
|
||||
expect(streams.length).toBe(0);
|
||||
|
||||
hidden.and.returnValue(false);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
service.ensureLiveStream();
|
||||
expect(streams.length).toBe(1);
|
||||
flushSnapshots();
|
||||
|
||||
hidden.and.returnValue(true);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(streams[0].close).toHaveBeenCalledTimes(1);
|
||||
service.ensureLiveStream();
|
||||
expect(streams.length).toBe(1);
|
||||
|
||||
hidden.and.returnValue(false);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expect(streams.length).toBe(2);
|
||||
flushSnapshots();
|
||||
service.destroy();
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
service.ensureLiveStream();
|
||||
expect(streams.length).toBe(2);
|
||||
});
|
||||
|
||||
it('releases SSE before entering the back-forward cache and reconnects on return', () => {
|
||||
service = TestBed.inject(AgentService);
|
||||
flushSnapshots();
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
expect(streams[0].close).toHaveBeenCalledTimes(1);
|
||||
service.ensureLiveStream();
|
||||
expect(streams.length).toBe(1);
|
||||
window.dispatchEvent(new Event('pageshow'));
|
||||
expect(streams.length).toBe(2);
|
||||
flushSnapshots();
|
||||
});
|
||||
|
||||
it('reconciles steps, verdicts and guidance after reconnecting without selecting another task', () => {
|
||||
service = TestBed.inject(AgentService);
|
||||
flushSnapshots();
|
||||
service.currentSessionId.set('selected-task');
|
||||
const steps = spyOn<any>(service, 'backfillSessionSteps');
|
||||
const checks = spyOn(service, 'fetchChecks');
|
||||
const notes = spyOn(service, 'fetchNotes');
|
||||
const guidance = spyOn(service, 'fetchGuidance');
|
||||
streams[0].dispatchEvent(new MessageEvent('info', { data: '{}' }));
|
||||
flushSnapshots();
|
||||
expect(steps).toHaveBeenCalledWith('selected-task');
|
||||
expect(checks).toHaveBeenCalledWith('selected-task');
|
||||
expect(notes).toHaveBeenCalledWith('selected-task');
|
||||
expect(guidance).toHaveBeenCalledWith('selected-task');
|
||||
expect(service.currentSessionId()).toBe('selected-task');
|
||||
});
|
||||
|
||||
it('does not accumulate polling requests while the browser connection pool is blocked', fakeAsync(() => {
|
||||
service = TestBed.inject(AgentService);
|
||||
service.currentSessionId.set('selected-task');
|
||||
for (let index = 0; index < 10; index++) {
|
||||
service.fetchStatus();
|
||||
service.fetchSessions();
|
||||
service.fetchGuidance('selected-task');
|
||||
}
|
||||
const status = http.expectOne('/api/status');
|
||||
const sessions = http.expectOne('/api/sessions');
|
||||
const guidance = http.expectOne('/api/tasks/selected-task/guidance');
|
||||
tick(UI_READ_TIMEOUT_MS);
|
||||
expect(status.cancelled).toBeTrue();
|
||||
expect(sessions.cancelled).toBeTrue();
|
||||
expect(guidance.cancelled).toBeTrue();
|
||||
// A timeout frees each guard so the next foreground refresh can recover.
|
||||
service.fetchStatus();
|
||||
service.fetchSessions();
|
||||
service.fetchGuidance('selected-task');
|
||||
http.expectOne('/api/tasks/selected-task/guidance').flush([]);
|
||||
flushSnapshots();
|
||||
service.destroy();
|
||||
}));
|
||||
});
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { Observable, finalize, tap, timeout } from 'rxjs';
|
||||
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
|
||||
import { SharedLiveStream } from './shared-live-stream';
|
||||
|
||||
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
|
||||
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
||||
@@ -452,7 +454,12 @@ export class AgentService {
|
||||
: status;
|
||||
});
|
||||
|
||||
private eventSource: EventSource | null = null;
|
||||
private eventSource: SharedLiveStream | null = null;
|
||||
private destroyed = false;
|
||||
private pageHidden = false;
|
||||
private statusRequestPending = false;
|
||||
private sessionsRequestPending = false;
|
||||
private guidanceRequestsPending = new Set<string>();
|
||||
private statusInterval: any = null;
|
||||
private sessionLoadGeneration = 0;
|
||||
private sessionSnapshotRequestId = 0;
|
||||
@@ -467,11 +474,22 @@ export class AgentService {
|
||||
private lastActiveTasksSignature: string | null = null;
|
||||
private lastPersistedSessionsJson: string | null = null;
|
||||
private onVisibilityChange = () => {
|
||||
if (typeof document !== 'undefined' && !document.hidden) {
|
||||
if (document.hidden || this.pageHidden) {
|
||||
this.closeLiveStream();
|
||||
} else {
|
||||
this.fetchStatus();
|
||||
this.fetchSessions();
|
||||
this.ensureLiveStream();
|
||||
}
|
||||
};
|
||||
private onPageHide = () => {
|
||||
this.pageHidden = true;
|
||||
this.closeLiveStream();
|
||||
};
|
||||
private onPageShow = () => {
|
||||
this.pageHidden = false;
|
||||
this.onVisibilityChange();
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.restoreSessionsCache();
|
||||
@@ -480,6 +498,8 @@ export class AgentService {
|
||||
this.ensureLiveStream();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
window.addEventListener('pagehide', this.onPageHide);
|
||||
window.addEventListener('pageshow', this.onPageShow);
|
||||
}
|
||||
inject(DestroyRef).onDestroy(() => this.destroy());
|
||||
}
|
||||
@@ -700,7 +720,12 @@ export class AgentService {
|
||||
* Fetch all past and active sessions from the backend
|
||||
*/
|
||||
public fetchSessions(): void {
|
||||
this.http.get<Session[]>('/api/sessions').subscribe({
|
||||
if (this.destroyed || this.sessionsRequestPending) return;
|
||||
this.sessionsRequestPending = true;
|
||||
this.http.get<Session[]>('/api/sessions').pipe(
|
||||
timeout(UI_READ_TIMEOUT_MS),
|
||||
finalize(() => this.sessionsRequestPending = false)
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
this.rawSessions.set(data);
|
||||
this.persistSessionsCache(data);
|
||||
@@ -850,26 +875,43 @@ export class AgentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure persistent single-channel live stream is established via /api/stream.
|
||||
* Eliminates reconnect tearing, race conditions, and disconnect dropouts.
|
||||
* Keep a live connection only while this page can display updates. Hidden
|
||||
* tabs otherwise exhaust HTTP/1.1's per-origin connection pool, blocking
|
||||
* configuration and history requests in every tab of the same browser.
|
||||
*/
|
||||
public ensureLiveStream(): void {
|
||||
if (this.eventSource) {
|
||||
if (this.destroyed || this.pageHidden || document.hidden || this.eventSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Establishing persistent unified live stream via /api/stream');
|
||||
const eventTypes = [
|
||||
'llm_stream', 'llm_stream_reset', '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', 'checker_event'
|
||||
];
|
||||
// The stream is registered outside the Angular zone: high-frequency SSE
|
||||
// callbacks must not schedule a change-detection pass each. Signal writes
|
||||
// still notify the render scheduler, so the UI stays live.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.eventSource = new EventSource('/api/stream');
|
||||
this.eventSource = SharedLiveStream.open('/api/stream', ['info', 'keep-alive', ...eventTypes]);
|
||||
const source = this.eventSource;
|
||||
|
||||
this.eventSource.addEventListener('info', () => {
|
||||
// Reconcile current session if active
|
||||
if (this.eventSource !== source) return;
|
||||
// Events may have been missed while hidden or during a server restart.
|
||||
this.fetchStatus();
|
||||
this.fetchSessions();
|
||||
const curId = this.currentSessionId();
|
||||
if (curId) {
|
||||
this.backfillSessionSteps(curId);
|
||||
this.fetchChecks(curId);
|
||||
this.fetchNotes(curId);
|
||||
this.fetchGuidance(curId);
|
||||
}
|
||||
if (this.isVideoWindowOpen() && ['live', 'processing'].includes(this.recordingPlaybackStatus())) {
|
||||
this.refreshActiveRecording(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -877,26 +919,9 @@ export class AgentService {
|
||||
// Keep-alive tick
|
||||
});
|
||||
|
||||
const eventTypes = [
|
||||
'llm_stream',
|
||||
'llm_stream_reset',
|
||||
'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',
|
||||
'checker_event'
|
||||
];
|
||||
|
||||
eventTypes.forEach((eventType) => {
|
||||
this.eventSource?.addEventListener(eventType, (event: MessageEvent) => {
|
||||
this.eventSource?.addEventListener(eventType, ((event: MessageEvent) => {
|
||||
if (this.eventSource !== source) return;
|
||||
try {
|
||||
const parsedData = JSON.parse(event.data);
|
||||
const evtSessionId = parsedData?.session_id;
|
||||
@@ -1197,7 +1222,7 @@ export class AgentService {
|
||||
}
|
||||
]);
|
||||
}
|
||||
});
|
||||
}) as EventListener);
|
||||
});
|
||||
|
||||
this.eventSource.onerror = (err) => {
|
||||
@@ -1206,6 +1231,12 @@ export class AgentService {
|
||||
});
|
||||
}
|
||||
|
||||
private closeLiveStream(): void {
|
||||
this.eventSource?.close();
|
||||
this.eventSource = null;
|
||||
this.flushStreamChunks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer one llm_stream chunk; the buffer is flushed into sessionLogs at most
|
||||
* once per short window (and immediately before any non-stream event so the
|
||||
@@ -1375,8 +1406,12 @@ export class AgentService {
|
||||
}
|
||||
|
||||
public fetchGuidance(sessionId: string): void {
|
||||
if (this.currentSessionId() !== sessionId) return;
|
||||
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).subscribe({
|
||||
if (this.destroyed || this.currentSessionId() !== sessionId || this.guidanceRequestsPending.has(sessionId)) return;
|
||||
this.guidanceRequestsPending.add(sessionId);
|
||||
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).pipe(
|
||||
timeout(UI_READ_TIMEOUT_MS),
|
||||
finalize(() => this.guidanceRequestsPending.delete(sessionId))
|
||||
).subscribe({
|
||||
next: (items) => {
|
||||
if (this.currentSessionId() !== sessionId) return;
|
||||
const signature = JSON.stringify(items);
|
||||
@@ -1618,7 +1653,12 @@ export class AgentService {
|
||||
* Fetch current agent runner process status
|
||||
*/
|
||||
public fetchStatus(): void {
|
||||
this.http.get<any>('/api/status').subscribe({
|
||||
if (this.destroyed || this.statusRequestPending) return;
|
||||
this.statusRequestPending = true;
|
||||
this.http.get<any>('/api/status').pipe(
|
||||
timeout(UI_READ_TIMEOUT_MS),
|
||||
finalize(() => this.statusRequestPending = false)
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
if (data && data.status) {
|
||||
const oldStatus = this.agentStatus();
|
||||
@@ -1718,6 +1758,7 @@ export class AgentService {
|
||||
* Clean up event source connection and timers on service destruction
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.destroyed = true;
|
||||
if (this.statusInterval) {
|
||||
clearInterval(this.statusInterval);
|
||||
this.statusInterval = null;
|
||||
@@ -1729,6 +1770,8 @@ export class AgentService {
|
||||
this.discardPendingStreamChunks();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
window.removeEventListener('pagehide', this.onPageHide);
|
||||
window.removeEventListener('pageshow', this.onPageShow);
|
||||
}
|
||||
this.cancelVideoRetry();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
|
||||
import { ExplorationService } from './exploration.service';
|
||||
|
||||
describe('ExplorationService settings loading', () => {
|
||||
it('cancels a stalled read and lets a subsequent read succeed', fakeAsync(() => {
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
const service = TestBed.inject(ExplorationService);
|
||||
let failed = false;
|
||||
service.settings().catch(() => failed = true);
|
||||
const stalled = http.expectOne('/api/explorations/settings');
|
||||
tick(UI_READ_TIMEOUT_MS);
|
||||
expect(stalled.cancelled).toBeTrue();
|
||||
expect(failed).toBeTrue();
|
||||
let model = '';
|
||||
service.settings().then(config => model = config.model);
|
||||
http.expectOne('/api/explorations/settings').flush({ model: 'test-model' });
|
||||
tick();
|
||||
expect(model).toBe('test-model');
|
||||
http.verify();
|
||||
}));
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { firstValueFrom, timeout } from 'rxjs';
|
||||
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
|
||||
import { CaseTestResult } from '../core/models/session.model';
|
||||
|
||||
export interface ExplorationConfig {
|
||||
@@ -28,7 +29,7 @@ export class ExplorationService {
|
||||
caseDrafts = new Map<string, {name: string; goal: string; preconditions: string; version: number}>();
|
||||
private base = '/api/explorations';
|
||||
list() { return firstValueFrom(this.http.get<ExplorationSession[]>(this.base)); }
|
||||
settings() { return firstValueFrom(this.http.get<ExplorationConfig>(this.base + '/settings')); }
|
||||
settings() { return firstValueFrom(this.http.get<ExplorationConfig>(this.base + '/settings').pipe(timeout(UI_READ_TIMEOUT_MS))); }
|
||||
saveSettings(value: ExplorationConfig) { return firstValueFrom(this.http.put<ExplorationConfig>(this.base + '/settings', value)); }
|
||||
get(id: string, after = 0) { return firstValueFrom(this.http.get<ExplorationSession>(`${this.base}/${encodeURIComponent(id)}?after=${after}`)); }
|
||||
create(goal: string, config: ExplorationConfig | undefined, task_config: Record<string, unknown>, request_id: string) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { SharedLiveStream } from './shared-live-stream';
|
||||
|
||||
describe('SharedLiveStream multi-window transport', () => {
|
||||
let connections: (EventTarget & { close: jasmine.Spy })[];
|
||||
let clients: SharedLiveStream[];
|
||||
let socketReady: (() => void) | null;
|
||||
|
||||
beforeEach(() => {
|
||||
connections = [];
|
||||
clients = [];
|
||||
socketReady = null;
|
||||
spyOn(window, 'EventSource').and.callFake(function () {
|
||||
const socket = Object.assign(new EventTarget(), { close: jasmine.createSpy('close') });
|
||||
connections.push(socket);
|
||||
socketReady?.();
|
||||
return socket as unknown as EventSource;
|
||||
});
|
||||
});
|
||||
afterEach(() => clients.forEach(client => client.close()));
|
||||
|
||||
function nextSocket(): Promise<void> {
|
||||
return new Promise(resolve => socketReady = resolve);
|
||||
}
|
||||
function event(client: SharedLiveStream, type: string): Promise<string> {
|
||||
return new Promise(resolve => client.addEventListener(type,
|
||||
value => resolve((value as MessageEvent).data), { once: true }));
|
||||
}
|
||||
function open(url: string): SharedLiveStream {
|
||||
const client = SharedLiveStream.open(url, ['info', 'step_recorded']);
|
||||
clients.push(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
it('shares a single socket across eight pages and recovers when the owner leaves', async () => {
|
||||
const url = `/api/stream?test=${crypto.randomUUID()}`;
|
||||
const connected = nextSocket();
|
||||
for (let index = 0; index < 8; index++) open(url);
|
||||
const infos = clients.map(client => event(client, 'info'));
|
||||
await connected;
|
||||
connections[0].dispatchEvent(new MessageEvent('info', { data: '{}' }));
|
||||
await Promise.all(infos);
|
||||
expect(connections.length).toBe(1);
|
||||
|
||||
const steps = clients.map(client => event(client, 'step_recorded'));
|
||||
connections[0].dispatchEvent(new MessageEvent('step_recorded', { data: '{"step_id":"one"}' }));
|
||||
expect(await Promise.all(steps)).toEqual(Array(8).fill('{"step_id":"one"}'));
|
||||
|
||||
// A page opened after the initial info event still receives a snapshot cue.
|
||||
const late = open(url);
|
||||
expect(await event(late, 'info')).toBe('{}');
|
||||
expect(connections.length).toBe(1);
|
||||
|
||||
// A closed follower must not acquire the socket when the owner leaves.
|
||||
clients[1].close();
|
||||
const successor = nextSocket();
|
||||
clients[0].close();
|
||||
await successor;
|
||||
expect(connections[0].close).toHaveBeenCalledTimes(1);
|
||||
expect(connections.length).toBe(2);
|
||||
const resumed = event(clients[2], 'step_recorded');
|
||||
connections[1].dispatchEvent(new MessageEvent('step_recorded', { data: '{"step_id":"two"}' }));
|
||||
expect(await resumed).toContain('two');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/** One SSE socket per origin, even with several visible ARTEMIS windows. */
|
||||
export class SharedLiveStream extends EventTarget {
|
||||
private source: EventSource | null = null;
|
||||
private channel: BroadcastChannel | null = null;
|
||||
private abort = new AbortController();
|
||||
private release: (() => void) | null = null;
|
||||
private closed = false;
|
||||
private ready = false;
|
||||
private readonly id = typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
|
||||
static open(url: string, eventTypes: readonly string[]): SharedLiveStream {
|
||||
return new SharedLiveStream(url, eventTypes);
|
||||
}
|
||||
|
||||
private constructor(private url: string, private eventTypes: readonly string[]) {
|
||||
super();
|
||||
if (!navigator.locks || typeof BroadcastChannel === 'undefined') {
|
||||
this.connect();
|
||||
return;
|
||||
}
|
||||
const name = `artemis.live:${url}`;
|
||||
this.channel = new BroadcastChannel(name);
|
||||
this.channel.onmessage = ({ data }) => {
|
||||
if (this.closed || !data || typeof data !== 'object') return;
|
||||
if (data.kind === 'join' && this.ready) {
|
||||
// A new follower needs a snapshot even if the current owner connected
|
||||
// long ago. Reuse the same info/reconciliation path as a reconnect.
|
||||
this.channel?.postMessage({ kind: 'event', type: 'info', data: '{}', recipient: data.id });
|
||||
} else if (data.kind === 'event' && (!data.recipient || data.recipient === this.id)
|
||||
&& this.eventTypes.includes(data.type) && typeof data.data === 'string') {
|
||||
this.dispatchEvent(new MessageEvent(data.type, { data: data.data }));
|
||||
}
|
||||
};
|
||||
this.channel.postMessage({ kind: 'join', id: this.id });
|
||||
// The browser releases this lock if its owning page is closed or crashes.
|
||||
// Waiting tabs acquire it in turn; hidden/destroyed tabs cancel their wait.
|
||||
void navigator.locks.request(name, { signal: this.abort.signal }, async () => {
|
||||
if (this.closed) return;
|
||||
const lifetime = new Promise<void>(resolve => this.release = resolve);
|
||||
this.connect();
|
||||
await lifetime;
|
||||
}).catch(error => {
|
||||
if (this.closed) return;
|
||||
// Older/restricted browser contexts may reject Web Locks. Visibility
|
||||
// gating still applies to the per-page fallback.
|
||||
console.warn('Shared live connection unavailable:', error);
|
||||
this.channel?.close();
|
||||
this.channel = null;
|
||||
this.connect();
|
||||
});
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (this.closed || this.source) return;
|
||||
this.source = new EventSource(this.url);
|
||||
for (const type of this.eventTypes) {
|
||||
this.source.addEventListener(type, (event: MessageEvent) => {
|
||||
if (this.closed) return;
|
||||
if (type === 'info') this.ready = true;
|
||||
this.channel?.postMessage({ kind: 'event', type, data: event.data });
|
||||
this.dispatchEvent(new MessageEvent(type, { data: event.data }));
|
||||
});
|
||||
}
|
||||
this.source.onerror = event => {
|
||||
this.ready = false;
|
||||
this.onerror?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.ready = false;
|
||||
this.source?.close();
|
||||
this.source = null;
|
||||
this.channel?.close();
|
||||
this.channel = null;
|
||||
this.abort.abort();
|
||||
this.release?.();
|
||||
this.release = null;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import { Injectable, signal, computed, inject, DestroyRef, NgZone } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, finalize, shareReplay, tap } from 'rxjs';
|
||||
import { Observable, finalize, shareReplay, tap, timeout } from 'rxjs';
|
||||
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
|
||||
import {
|
||||
AdbServerConnectionResponse,
|
||||
AdbServerStatus,
|
||||
@@ -239,6 +240,7 @@ export class SystemService {
|
||||
const request$ = this.http.get<SystemReadinessReport>('/api/system/readiness', {
|
||||
params: forceRefresh ? { force: true } : {}
|
||||
}).pipe(
|
||||
timeout(UI_READ_TIMEOUT_MS),
|
||||
tap({
|
||||
next: (report) => {
|
||||
this.applyReadinessReport(report);
|
||||
@@ -544,6 +546,7 @@ export class SystemService {
|
||||
*/
|
||||
public fetchModelConfigEnv(): Observable<ModelConfigEnvResponse> {
|
||||
return this.http.get<ModelConfigEnvResponse>('/api/system/model-config-env').pipe(
|
||||
timeout(UI_READ_TIMEOUT_MS),
|
||||
tap({
|
||||
next: (data) => {
|
||||
this.modelConfigEnv.set(data);
|
||||
|
||||
Reference in new issue
Block a user