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:
chenmingxuan committed 2026-10-18 14:18:37 +08:00
1 parent 2328a4df0c
commit e1ea099592
128 files changed
+4944 -700

No files matched your search

@@ -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"
@@ -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');
}
@@ -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('功能配置加载失败,请刷新页面重试。')
});
}
@@ -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);