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
@@ -320,6 +320,7 @@ class SessionRepository:
|
||||
return 0
|
||||
|
||||
llm_calls = prompt = completion = total = cached = 0
|
||||
by_node_model: dict[tuple[str, str], dict] = {}
|
||||
operator_context: int | None = None
|
||||
operator_context_at: float | None = None
|
||||
with db_session(self.db_path) as conn:
|
||||
@@ -348,6 +349,27 @@ class SessionRepository:
|
||||
total += t
|
||||
cached += _int(payload.get("cached_tokens"))
|
||||
node = str(payload.get("node") or "").strip().lower()
|
||||
model_name = str(payload.get("model") or "unknown")
|
||||
group = by_node_model.setdefault(
|
||||
(node, model_name),
|
||||
{
|
||||
"node": node,
|
||||
"model": model_name,
|
||||
"llm_calls": 0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cached_tokens": 0,
|
||||
},
|
||||
)
|
||||
group["llm_calls"] += 1
|
||||
group["prompt_tokens"] += p
|
||||
group["completion_tokens"] += c
|
||||
group["cached_tokens"] += _int(payload.get("cached_tokens"))
|
||||
group["cache_hit_rate"] = (
|
||||
group["cached_tokens"] / group["prompt_tokens"]
|
||||
if group["prompt_tokens"]
|
||||
else None
|
||||
)
|
||||
if node in OPERATOR_NODE_NAMES:
|
||||
operator_context = _int(payload.get("context_base_tokens")) or p
|
||||
operator_context_at = row["timestamp"]
|
||||
@@ -375,6 +397,7 @@ class SessionRepository:
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": total,
|
||||
"cached_tokens": cached,
|
||||
"by_node_model": list(by_node_model.values()),
|
||||
"operator_context_tokens": operator_context,
|
||||
"operator_context_window_tokens": OPERATOR_CONTEXT_WINDOW_TOKENS,
|
||||
"operator_context_updated_at": operator_context_at,
|
||||
|
||||
@@ -23,6 +23,23 @@ from apps.admin_console.services.exploration_service import exploration_service
|
||||
router = APIRouter(prefix="/api/explorations", tags=["exploration"])
|
||||
|
||||
|
||||
@router.get("/{exploration_id}/deletion-preview")
|
||||
def preview_deletion(exploration_id: str):
|
||||
from apps.admin_console.services.task_deletion import exploration_deletion_preview
|
||||
|
||||
with domain_errors():
|
||||
return exploration_deletion_preview(exploration_id)
|
||||
|
||||
|
||||
@router.delete("/{exploration_id}")
|
||||
def delete_conversation(exploration_id: str):
|
||||
from apps.admin_console.services.exploration_deletion import delete_exploration
|
||||
|
||||
with domain_errors():
|
||||
deleted = delete_exploration(service.repository, exploration_id)
|
||||
return {"status": "success", "deleted_session_ids": deleted}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def domain_errors():
|
||||
try:
|
||||
|
||||
@@ -16,7 +16,7 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from artemis.config import DB_PATH, TRACES_PATH
|
||||
@@ -36,6 +36,23 @@ from apps.admin_console.services.exploration_listing import exploration_metadata
|
||||
router = APIRouter(tags=["sessions"])
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}/inspection")
|
||||
def inspect_session(
|
||||
session_id: str,
|
||||
view: str = "report",
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
):
|
||||
from apps.admin_console.services.task_inspection import inspect_task
|
||||
|
||||
try:
|
||||
return inspect_task(session_id, view, offset=offset, limit=limit)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}/request-diagnostics")
|
||||
def get_request_diagnostics(session_id: str, download: bool = False):
|
||||
from apps.admin_console.services.request_diagnostics import request_diagnostics
|
||||
@@ -281,6 +298,18 @@ async def cleanup_history_endpoint():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}/deletion-preview")
|
||||
def preview_session_deletion(session_id: str):
|
||||
from apps.admin_console.services.task_deletion import deletion_preview
|
||||
|
||||
try:
|
||||
return deletion_preview(session_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/api/sessions/{session_id}/delete")
|
||||
async def delete_session_endpoint(session_id: str):
|
||||
from artemis.exploration.store import ExplorationConflict, explorations
|
||||
@@ -295,6 +324,7 @@ async def delete_session_endpoint(session_id: str):
|
||||
|
||||
deleted = delete_exploration(explorations, run["exploration_id"])
|
||||
return {"status": "success", "deleted_session_ids": deleted}
|
||||
preview_session_deletion(session_id)
|
||||
storage = StorageManager(DB_PATH, TRACES_PATH)
|
||||
storage.delete_session(UUID(session_id))
|
||||
from apps.admin_console.services.automation_store import store
|
||||
@@ -307,6 +337,8 @@ async def delete_session_endpoint(session_id: str):
|
||||
}
|
||||
except ExplorationConflict as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ValueError, KeyError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
|
||||
@@ -105,6 +105,10 @@ async def _lifespan(_app: "FastAPI"):
|
||||
app.state.service_registration = registration
|
||||
configure_local_daemon(state.port)
|
||||
try:
|
||||
from artemis.integrations.codex_skill import sync_on_startup
|
||||
from artemis.config.paths import ROOT_DIR
|
||||
|
||||
await asyncio.to_thread(sync_on_startup, ROOT_DIR)
|
||||
shared_tool_executor.start()
|
||||
async with shared_mcp.session_manager.run():
|
||||
await on_startup()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from artemis.config.agent import load_agent_config
|
||||
from artemis.config.exploration import ExplorationConfig
|
||||
from artemis.exploration.case_prompt import require_resolved_case
|
||||
from artemis.exploration.models import CreateExploration, SaveCaseRequest, ValidateCaseRequest
|
||||
from artemis.exploration.demonstrations import Demonstrations
|
||||
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
|
||||
@@ -10,8 +11,7 @@ from apps.admin_console.services.task_import import TaskOptions
|
||||
|
||||
|
||||
def exploration_case_prompt(case: dict) -> str:
|
||||
prefix = f"前置条件:\n{case['preconditions']}\n\n" if case.get("preconditions") else ""
|
||||
return prefix + case["goal"]
|
||||
return require_resolved_case(case)
|
||||
|
||||
|
||||
class ExplorationService:
|
||||
|
||||
@@ -7,7 +7,9 @@ import time
|
||||
from apps.admin_console.database.connection import db_session
|
||||
|
||||
|
||||
def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = None) -> dict:
|
||||
def request_diagnostics(
|
||||
session_id: str, *, db_path=None, limit: int | None = None, offset: int = 0
|
||||
) -> dict:
|
||||
with db_session(db_path) as conn:
|
||||
session = conn.execute(
|
||||
"SELECT status FROM sessions WHERE session_id = ?", (session_id,)
|
||||
@@ -17,9 +19,13 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
|
||||
"ORDER BY timestamp DESC"
|
||||
)
|
||||
params: tuple = (session_id,)
|
||||
total = conn.execute(
|
||||
"SELECT count(*) FROM traces WHERE session_id=? AND type='llm_diagnostic'",
|
||||
params,
|
||||
).fetchone()[0]
|
||||
if limit is not None:
|
||||
sql += " LIMIT ?"
|
||||
params += (limit,)
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params += (limit, offset)
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
requests = []
|
||||
seen = set()
|
||||
@@ -34,6 +40,7 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
|
||||
request_id = value.get("request_id")
|
||||
value["active"] = (
|
||||
running
|
||||
and offset == 0
|
||||
and request_id not in seen
|
||||
and value.get("phase")
|
||||
in {
|
||||
@@ -64,6 +71,9 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
|
||||
"session_status": session["status"] if session else "unknown",
|
||||
"collected_at": time.time(),
|
||||
"requests": requests,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"next_offset": offset + limit if limit is not None and offset + limit < total else None,
|
||||
"note": "时间戳为 Unix 秒;timestamps_utc 为 UTC。HTTP 200 不代表完整输出。usage=null 表示服务未报告用量。历史任务没有记录的字段无法补推。",
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Read the impact of deleting a task without deleting or stopping anything."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from artemis.exploration.store import ExplorationConflict, explorations
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.exploration_deletion import _check_runs
|
||||
|
||||
|
||||
def deletion_preview(session_id: str) -> dict:
|
||||
session_id = str(UUID(session_id))
|
||||
run = explorations.run(session_id)
|
||||
if run and run["kind"] == "exploration":
|
||||
return exploration_deletion_preview(run["exploration_id"])
|
||||
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
|
||||
raise KeyError("任务不存在")
|
||||
_check_runs([{"id": session_id}])
|
||||
job = store.job(session_id) or {}
|
||||
if job.get("status") in {"pending", "queued", "running", "task_paused", "resuming"}:
|
||||
raise ExplorationConflict("任务仍在执行或排队,请先停止并等待结束")
|
||||
return {
|
||||
"session_ids": [session_id],
|
||||
"preserved_session_ids": [],
|
||||
"removes": ["任务历史", "报告", "截图、录像及附件"],
|
||||
}
|
||||
|
||||
|
||||
def exploration_deletion_preview(exploration_id: str) -> dict:
|
||||
session = explorations.get(exploration_id)
|
||||
runs = explorations.runs(exploration_id)
|
||||
_check_runs(runs)
|
||||
if session.get("active_run_id") or session.get("active_demonstration_id"):
|
||||
raise ExplorationConflict("探索尚有活动任务或示范,请先停止并等待结束")
|
||||
return {
|
||||
"exploration_id": exploration_id,
|
||||
"session_ids": [run["id"] for run in runs if run["kind"] == "exploration"],
|
||||
"preserved_session_ids": [run["id"] for run in runs if run["kind"] != "exploration"],
|
||||
"removes": ["探索对话", "用例版本", "探索执行历史及证据"],
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Bounded, read-only task diagnostics shared by MCP and HTTP clients."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services import evidence_service, report_export
|
||||
from apps.admin_console.services.automation_store import identifier, store
|
||||
from apps.admin_console.services.request_diagnostics import request_diagnostics
|
||||
|
||||
|
||||
_SECRET = re.compile(
|
||||
r"(?i)^(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|client_secret|token_key)$"
|
||||
)
|
||||
|
||||
|
||||
def public_value(value, *, max_chars: int = 60000):
|
||||
"""Remove common credential forms and bound text without inventing missing evidence."""
|
||||
remaining = max_chars
|
||||
remaining_nodes = 2000
|
||||
truncated = False
|
||||
secrets = [
|
||||
v
|
||||
for k, v in os.environ.items()
|
||||
if len(v) >= 8 and re.search(r"(?i)(api_?key|password|secret|token)$", k)
|
||||
]
|
||||
|
||||
def clean(item, depth=0):
|
||||
nonlocal remaining, remaining_nodes, truncated
|
||||
remaining_nodes -= 1
|
||||
if depth > 12:
|
||||
truncated = True
|
||||
return "[嵌套内容已截断]"
|
||||
if isinstance(item, dict):
|
||||
result = {}
|
||||
for k, v in item.items():
|
||||
if remaining_nodes <= 0 or len(result) >= 100:
|
||||
truncated = True
|
||||
break
|
||||
key = clean(str(k), depth + 1)
|
||||
result[key] = "[REDACTED]" if _SECRET.fullmatch(str(k)) else clean(v, depth + 1)
|
||||
return result
|
||||
if isinstance(item, list):
|
||||
result = []
|
||||
for v in item:
|
||||
if remaining_nodes <= 0 or len(result) >= 100:
|
||||
truncated = True
|
||||
break
|
||||
result.append(clean(v, depth + 1))
|
||||
return result
|
||||
if not isinstance(item, str):
|
||||
return item
|
||||
for secret in secrets:
|
||||
item = item.replace(secret, "[REDACTED]")
|
||||
item = re.sub(r"(?i)\bBearer\s+[^\s\"',;]+", "Bearer [REDACTED]", item)
|
||||
item = re.sub(r"\bsk-[A-Za-z0-9_-]+", "[REDACTED]", item)
|
||||
item = re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", item)
|
||||
item = re.sub(
|
||||
r"""(?ix)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|token_key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s&,;]+)""",
|
||||
r"\1[REDACTED]",
|
||||
item,
|
||||
)
|
||||
allowance = min(8000, remaining)
|
||||
if len(item) > allowance:
|
||||
truncated = True
|
||||
item = item[:allowance] + "…[已截断;按步骤或关键词缩小范围]"
|
||||
remaining = max(0, remaining - min(len(item), allowance))
|
||||
return item
|
||||
|
||||
result = clean(value)
|
||||
return {**result, "text_truncated": truncated, "content_truncated": truncated}
|
||||
|
||||
|
||||
def page(items: list, offset: int, limit: int) -> dict:
|
||||
return {
|
||||
"items": items[offset : offset + limit],
|
||||
"total": len(items),
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"next_offset": offset + limit if offset + limit < len(items) else None,
|
||||
}
|
||||
|
||||
|
||||
def inspect_task(session_id: str, view: str, *, offset: int = 0, limit: int = 20) -> dict:
|
||||
identifier(session_id)
|
||||
if offset < 0 or not 1 <= limit <= 100:
|
||||
raise ValueError("offset 必须非负,limit 必须在 1–100 之间")
|
||||
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
|
||||
raise LookupError("任务记录不存在")
|
||||
if view == "usage":
|
||||
result = session_repo.get_session_usage(session_id)
|
||||
result["cache_hit_rate"] = (
|
||||
result["cached_tokens"] / result["prompt_tokens"] if result["prompt_tokens"] else None
|
||||
)
|
||||
result["note"] = (
|
||||
"缓存率=已记录 cached_tokens / prompt_tokens;无用量不等于零消耗,未上报的请求无法补推。"
|
||||
)
|
||||
elif view == "requests":
|
||||
result = request_diagnostics(session_id, limit=limit, offset=offset)
|
||||
elif view == "logs":
|
||||
root = evidence_service.task_root(session_id)
|
||||
result = {"logs": []}
|
||||
for name in ("stdout.log", "stderr.log"):
|
||||
path = evidence_service.evidence_file(root, name)
|
||||
if path is None:
|
||||
result["logs"].append({"name": name, "available": False})
|
||||
continue
|
||||
with path.open("rb") as stream:
|
||||
size = path.stat().st_size
|
||||
start = max(0, size - 128 * 1024)
|
||||
stream.seek(start)
|
||||
raw = stream.read(128 * 1024)
|
||||
lines = raw.decode("utf-8", errors="replace").splitlines()
|
||||
if start and lines:
|
||||
lines = lines[1:]
|
||||
# Offset counts backward from the newest line within the bounded tail.
|
||||
end = max(0, len(lines) - offset)
|
||||
result["logs"].append(
|
||||
{
|
||||
"name": name,
|
||||
"available": True,
|
||||
"bytes": size,
|
||||
"lines": lines[max(0, end - limit) : end],
|
||||
"offset": offset,
|
||||
"next_offset": offset + limit if end > limit else None,
|
||||
"older_bytes_omitted": start,
|
||||
"order": "oldest_to_newest",
|
||||
}
|
||||
)
|
||||
elif view in {"report", "evidence"}:
|
||||
report = report_export.session_report(session_id)
|
||||
if view == "report":
|
||||
result = {**report, "steps": page(report.get("steps", []), offset, limit)}
|
||||
else:
|
||||
index = evidence_service.export_index(
|
||||
session_id, report, report_export.session_videos(report)
|
||||
)
|
||||
result = {
|
||||
**index,
|
||||
**{
|
||||
key: page(index.get(key, []), offset, limit)
|
||||
for key in ("steps", "calls", "recordings", "android_videos", "files")
|
||||
},
|
||||
}
|
||||
else:
|
||||
raise ValueError("view 需为 report / usage / requests / logs / evidence")
|
||||
return public_value({"session_id": session_id, **result})
|
||||
+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