feat: improve task execution, verification and packaging
Add execution incidents, action bursts and shared Flash history. Expose verification and Explorer settings with streamed check results. Support graceful cancellation, recording recovery and bundled UI resources. Add CI quality checks and clean up comments and documentation.
This commit is contained in:
1 parent
5ccf06d677
commit
67248c887e
289 files changed
+15883
-6919
No files matched your search
@@ -236,6 +236,7 @@ class ServerState:
|
||||
state = ServerState()
|
||||
|
||||
import sys
|
||||
|
||||
if __name__ == "admin_console.core.state":
|
||||
sys.modules["apps.admin_console.core.state"] = sys.modules[__name__]
|
||||
elif __name__ == "apps.admin_console.core.state":
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -74,10 +76,12 @@ class SessionRepository:
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
return {}
|
||||
columns = {
|
||||
str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)")
|
||||
}
|
||||
ready_clause = "status = 'ready'" if "status" in columns else "end_time IS NOT NULL"
|
||||
columns = {str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)")}
|
||||
# Failed recordings may still be recoverable from disk.
|
||||
# Callers exclude in-progress sessions before resolving paths.
|
||||
ready_clause = (
|
||||
"status IN ('ready', 'failed')" if "status" in columns else "end_time IS NOT NULL"
|
||||
)
|
||||
cursor.execute(
|
||||
"SELECT session_id, local_video_path FROM video_recordings "
|
||||
"WHERE session_id IS NOT NULL AND local_video_path IS NOT NULL "
|
||||
@@ -104,7 +108,9 @@ class SessionRepository:
|
||||
return None
|
||||
result = dict(row)
|
||||
if "status" not in result:
|
||||
result["status"] = "ready" if result.get("end_time") is not None else "recording"
|
||||
result["status"] = (
|
||||
"ready" if result.get("end_time") is not None else "recording"
|
||||
)
|
||||
result.setdefault("error", None)
|
||||
return result
|
||||
except Exception:
|
||||
@@ -147,6 +153,40 @@ class SessionRepository:
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_unfinalized_video_recordings(self) -> list[dict[str, Any]]:
|
||||
"""Recordings whose worker never finalized them, oldest first.
|
||||
|
||||
Rows of sessions that are still ``running`` are skipped: their worker
|
||||
(possibly owned by another daemon) may still be writing the file.
|
||||
"""
|
||||
try:
|
||||
with db_session(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='video_recordings'"
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
return []
|
||||
columns = {
|
||||
str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)")
|
||||
}
|
||||
pending_clause = (
|
||||
"v.status IN ('recording', 'finalizing', 'failed')"
|
||||
if "status" in columns
|
||||
else "v.end_time IS NULL"
|
||||
)
|
||||
cursor.execute(
|
||||
"SELECT v.session_id, v.local_video_path, v.start_time, "
|
||||
"s.status AS session_status, s.start_time AS session_start_time "
|
||||
"FROM video_recordings v LEFT JOIN sessions s ON s.session_id = v.session_id "
|
||||
f"WHERE v.session_id IS NOT NULL AND v.local_video_path IS NOT NULL "
|
||||
f"AND {pending_clause} ORDER BY v.start_time ASC"
|
||||
)
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
except (OSError, sqlite3.Error):
|
||||
return []
|
||||
return [row for row in rows if str(row.get("session_status") or "").lower() != "running"]
|
||||
|
||||
def mark_recording_failed_if_pending(self, session_id: str, error: str) -> bool:
|
||||
"""Close a recording lifecycle when its worker exits before finalization."""
|
||||
try:
|
||||
@@ -174,8 +214,16 @@ class SessionRepository:
|
||||
"WHERE session_id = ?",
|
||||
(str(local_video_path), time.time(), str(session_id)),
|
||||
)
|
||||
updated = cursor.rowcount > 0
|
||||
if os.path.exists(str(local_video_path)):
|
||||
# Mirror the worker's own finalization so the session row
|
||||
# and trace tooling see the recovered file too.
|
||||
cursor.execute(
|
||||
"UPDATE sessions SET video_filepath = ? WHERE session_id = ?",
|
||||
(str(local_video_path), str(session_id)),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
return updated
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -324,8 +372,7 @@ class SessionRepository:
|
||||
# read_status itself never raises; this guards the
|
||||
# lock/write side of update_trace_status.
|
||||
logger.warning(
|
||||
"Could not mark trace %s failed during orphan "
|
||||
"reconciliation: %s",
|
||||
"Could not mark trace %s failed during orphan reconciliation: %s",
|
||||
row["session_id"],
|
||||
exc,
|
||||
)
|
||||
|
||||
@@ -149,7 +149,9 @@ def _get_session_video_sync(session_id: str):
|
||||
v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
|
||||
video_segments = media_service.resolve_video_segments(v_url)
|
||||
if v_url:
|
||||
version = int(float((recording or {}).get("end_time") or row_dict.get("end_time") or 0) * 1000)
|
||||
version = int(
|
||||
float((recording or {}).get("end_time") or row_dict.get("end_time") or 0) * 1000
|
||||
)
|
||||
separator = "&" if "?" in v_url else "?"
|
||||
versioned_url = f"{v_url}{separator}v={version}" if version else v_url
|
||||
for segment in video_segments:
|
||||
@@ -188,3 +190,9 @@ async def get_task_plan(session_id: str):
|
||||
@router.get("/api/sessions/{session_id}/notes")
|
||||
async def get_all_notes(session_id: str):
|
||||
return {"notes": media_service.get_session_notes_content(session_id)}
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}/checks")
|
||||
async def get_session_checks(session_id: str):
|
||||
"""Checker verdict ledger + run outcome (backfill for the Checker panel)."""
|
||||
return media_service.get_session_checks(session_id)
|
||||
@@ -64,11 +64,10 @@ def _list_sessions_sync():
|
||||
|
||||
try:
|
||||
from artemis.runtime import DeviceExecutionLock
|
||||
|
||||
active_owners = DeviceExecutionLock.get_active_owners()
|
||||
active_owner_sids = {
|
||||
str(owner.session_id)
|
||||
for owner in active_owners.values()
|
||||
if owner.session_id
|
||||
str(owner.session_id) for owner in active_owners.values() if owner.session_id
|
||||
}
|
||||
except Exception:
|
||||
active_owner_sids = set()
|
||||
|
||||
@@ -297,7 +297,11 @@ async def get_credentials():
|
||||
providers = ("google", "openai", "anthropic", "openrouter", "ocr")
|
||||
status = {name: bool(settings.get_api_key(name)) for name in providers}
|
||||
status["gemini"] = status["google"]
|
||||
return {"providers": [{"name": name, "configured": configured} for name, configured in status.items()]}
|
||||
return {
|
||||
"providers": [
|
||||
{"name": name, "configured": configured} for name, configured in status.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/credentials/test")
|
||||
@@ -370,7 +374,7 @@ async def update_credentials(request: UpdateCredentialsRequest):
|
||||
async def get_model_config_and_env():
|
||||
"""Retrieve the current active artemis.jsonc configuration and .env status for custom setup."""
|
||||
import os
|
||||
from artemis.config.paths import ROOT_DIR, get_config_path
|
||||
from artemis.config.paths import get_config_path, get_env_file
|
||||
from artemis.config import settings
|
||||
from artemis.utils.file import load_jsonc
|
||||
|
||||
@@ -390,7 +394,7 @@ async def get_model_config_and_env():
|
||||
config_content = f"// Error reading config: {e}"
|
||||
|
||||
# 2. Env file resolution
|
||||
env_path = ROOT_DIR / ".env"
|
||||
env_path = get_env_file()
|
||||
|
||||
# 3. Relevant Env Variables status
|
||||
def get_real_env_value(key_name: str, provider: str) -> str | None:
|
||||
|
||||
@@ -90,8 +90,7 @@ async def run_task(request: RunRequest):
|
||||
(
|
||||
item
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict)
|
||||
and str(item.get("session_id")) == requested_sid
|
||||
if isinstance(item, dict) and str(item.get("session_id")) == requested_sid
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -122,9 +121,7 @@ async def run_task(request: RunRequest):
|
||||
# fail downstream with a clear error instead.
|
||||
if request.device_serial:
|
||||
try:
|
||||
rejection = await device_pool.validate_explicit_serial_async(
|
||||
request.device_serial
|
||||
)
|
||||
rejection = await device_pool.validate_explicit_serial_async(request.device_serial)
|
||||
except Exception:
|
||||
rejection = None
|
||||
if rejection:
|
||||
@@ -144,19 +141,19 @@ async def run_task(request: RunRequest):
|
||||
# prefers the diagnostics target preference, then any unlocked ready
|
||||
# device); the verified serial is bound below.
|
||||
target_serial = request.device_serial
|
||||
device_probe = await readiness_engine.run_device_submission_probe(
|
||||
target_serial=target_serial
|
||||
)
|
||||
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
||||
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
|
||||
locked_serial = (
|
||||
device_probe.metadata.get("active_device", {}).get("serial")
|
||||
or target_serial
|
||||
or ""
|
||||
device_probe.metadata.get("active_device", {}).get("serial") or target_serial or ""
|
||||
)
|
||||
detail = (
|
||||
f"Android device {locked_serial} is locked. Unlock it and enter the home screen before running a task.".replace(" ", " ").strip()
|
||||
f"Android device {locked_serial} is locked. Unlock it and enter the home screen before running a task.".replace(
|
||||
" ", " "
|
||||
).strip()
|
||||
if device_probe.summary == "Device Locked"
|
||||
else f"Android device {locked_serial} lock state could not be verified. Keep it unlocked on the home screen and try again.".replace(" ", " ").strip()
|
||||
else f"Android device {locked_serial} lock state could not be verified. Keep it unlocked on the home screen and try again.".replace(
|
||||
" ", " "
|
||||
).strip()
|
||||
)
|
||||
raise HTTPException(status_code=409, detail=detail)
|
||||
|
||||
@@ -173,6 +170,8 @@ async def run_task(request: RunRequest):
|
||||
profile=request.profile or "flash",
|
||||
expected_output=request.expected_output,
|
||||
enable_outputter=request.enable_outputter,
|
||||
verification_level=request.verification_level,
|
||||
explorer_mode=request.explorer_mode,
|
||||
locked_app_package=request.locked_app_package,
|
||||
app_path=request.app_path,
|
||||
device_serial=target_serial,
|
||||
@@ -182,6 +181,22 @@ async def run_task(request: RunRequest):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/run/defaults")
|
||||
async def get_run_defaults():
|
||||
"""Effective Pro-profile tuning defaults from the agent config.
|
||||
|
||||
The launcher's sliders start here so they reflect ``artemis.jsonc`` (and the
|
||||
``ARTEMIS_EXPLORER_VERSION`` override) instead of a hard-coded guess.
|
||||
"""
|
||||
from artemis.config import load_agent_config, verification_level_for_checker
|
||||
|
||||
agent_cfg = load_agent_config()
|
||||
return {
|
||||
"verification_level": verification_level_for_checker(agent_cfg.checker),
|
||||
"explorer_mode": agent_cfg.explorer.resolve(profile="pro"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/devices")
|
||||
async def list_devices():
|
||||
"""List all connected Android devices with their busy / idle status."""
|
||||
@@ -223,7 +238,6 @@ async def stop_task(
|
||||
return {"status": "no_running_task"}
|
||||
|
||||
|
||||
|
||||
@router.post("/api/resume")
|
||||
async def resume_task():
|
||||
resumed = task_queue_service.resume_task()
|
||||
@@ -269,12 +283,14 @@ async def get_status():
|
||||
(global_owner.session_id if global_owner else None)
|
||||
or state.active_session_id
|
||||
or (running_task.get("session_id") if running_task else None)
|
||||
or (active_tasks[0]["session_id"] if active_tasks and active_tasks[0].get("session_id") else None)
|
||||
or (
|
||||
active_tasks[0]["session_id"]
|
||||
if active_tasks and active_tasks[0].get("session_id")
|
||||
else None
|
||||
)
|
||||
or (session_repo.get_running_session_id() if is_running else None)
|
||||
)
|
||||
owner_connection = (
|
||||
state.active_connections.get(str(running_sid), {}) if running_sid else {}
|
||||
)
|
||||
owner_connection = state.active_connections.get(str(running_sid), {}) if running_sid else {}
|
||||
running_goal = (
|
||||
owner_connection.get("goal")
|
||||
or (global_owner.description if global_owner else None)
|
||||
@@ -283,8 +299,10 @@ async def get_status():
|
||||
or (active_tasks[0]["goal"] if active_tasks else None)
|
||||
)
|
||||
|
||||
active_profile = owner_connection.get("profile") or state.current_profile or (
|
||||
running_task.get("profile") if running_task and not global_owner else None
|
||||
active_profile = (
|
||||
owner_connection.get("profile")
|
||||
or state.current_profile
|
||||
or (running_task.get("profile") if running_task and not global_owner else None)
|
||||
)
|
||||
if not active_profile and (running_sid or latest_session_id):
|
||||
check_sid = running_sid or latest_session_id
|
||||
@@ -407,7 +425,11 @@ async def stream_events(session_id: str = "active", client: str | None = None):
|
||||
active_sid = state.active_session_id
|
||||
if not active_sid:
|
||||
running_item = next(
|
||||
(t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "running"),
|
||||
(
|
||||
t
|
||||
for t in state.queue_items
|
||||
if isinstance(t, dict) and t.get("status") == "running"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if running_item:
|
||||
@@ -435,16 +457,14 @@ async def stream_events(session_id: str = "active", client: str | None = None):
|
||||
recorded_steps = step_repo.get_session_steps(str(active_sid))
|
||||
for step_dict in recorded_steps:
|
||||
yield (
|
||||
"event: step_recorded\n"
|
||||
f"data: {json.dumps(step_dict, default=str)}\n\n"
|
||||
f"event: step_recorded\ndata: {json.dumps(step_dict, default=str)}\n\n"
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[Stream] Could not replay active steps: {exc}")
|
||||
else:
|
||||
for progress_event in state.get_startup_progress(session_id):
|
||||
yield (
|
||||
"event: startup_progress\n"
|
||||
f"data: {json.dumps(progress_event, default=str)}\n\n"
|
||||
f"event: startup_progress\ndata: {json.dumps(progress_event, default=str)}\n\n"
|
||||
)
|
||||
|
||||
shutdown_waiter = asyncio.create_task(state.shutdown_event.wait())
|
||||
|
||||
@@ -21,6 +21,11 @@ class RunRequest(BaseModel):
|
||||
profile: str | None = "flash"
|
||||
expected_output: str | None = None
|
||||
enable_outputter: bool | None = None
|
||||
# Pro-profile tuning (ignored by the Flash profile): a coarse Checker preset
|
||||
# ('off' | 'final' | 'checkpoints' | 'strict') and the Explorer perception
|
||||
# version used by the Operator ('flash' | 'pro' | 'ultra').
|
||||
verification_level: str | None = None
|
||||
explorer_mode: str | None = None
|
||||
locked_app_package: str | None = None
|
||||
app_path: str | None = None
|
||||
device_serial: str | None = None
|
||||
@@ -29,7 +34,6 @@ class RunRequest(BaseModel):
|
||||
conversation_id: str | None = None
|
||||
|
||||
|
||||
|
||||
class ReplayRequest(BaseModel):
|
||||
device_id: str
|
||||
user_submits: dict
|
||||
@@ -41,4 +45,3 @@ class StopRequest(BaseModel):
|
||||
session_id: str | None = None
|
||||
device_id: str | None = None
|
||||
all: bool = False
|
||||
|
||||
@@ -67,6 +67,7 @@ from artemis.config import (
|
||||
WORKSPACE_ROOT,
|
||||
init_ls_address,
|
||||
)
|
||||
from artemis.resources import get_bundled_showcase_dist
|
||||
|
||||
try:
|
||||
from admin_console.core.security import SameOriginBoundaryMiddleware
|
||||
@@ -141,6 +142,9 @@ async def on_startup():
|
||||
cleaned = session_repo.cleanup_orphans_on_startup()
|
||||
if cleaned > 0:
|
||||
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as failed.")
|
||||
# Workers killed together with a previous daemon never remuxed their
|
||||
# recordings; publish whatever raw files they left behind.
|
||||
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
|
||||
|
||||
await ipc_service.start_server()
|
||||
state.worker_task = asyncio.create_task(task_queue_service.queue_worker())
|
||||
@@ -174,9 +178,7 @@ async def on_shutdown():
|
||||
run_task.cancel()
|
||||
if run_tasks:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*run_tasks, return_exceptions=True), timeout=5.0
|
||||
)
|
||||
await asyncio.wait_for(asyncio.gather(*run_tasks, return_exceptions=True), timeout=5.0)
|
||||
except (TimeoutError, asyncio.CancelledError):
|
||||
pass
|
||||
for run in list(state.active_runs.values()):
|
||||
@@ -231,7 +233,7 @@ except ImportError:
|
||||
# Showcase UI (Angular 19) Unified Single-Port SPA Static Hosting
|
||||
# ------------------------------------------------------------------------------
|
||||
def _get_showcase_dist() -> Path:
|
||||
"""Return the Showcase UI Angular build directory."""
|
||||
"""Return the source build, or the immutable build bundled in a wheel."""
|
||||
base_dist = _workspace_root / "apps" / "showcase_ui" / "dist"
|
||||
candidates = [
|
||||
base_dist / "frontend" / "browser",
|
||||
@@ -242,6 +244,9 @@ def _get_showcase_dist() -> Path:
|
||||
for candidate in candidates:
|
||||
if candidate.is_dir() and (candidate / "index.html").exists():
|
||||
return candidate
|
||||
bundled_dist = get_bundled_showcase_dist()
|
||||
if bundled_dist is not None:
|
||||
return bundled_dist
|
||||
return base_dist / "frontend" / "browser"
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -20,6 +21,7 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.parse
|
||||
from fastapi import HTTPException
|
||||
@@ -51,13 +53,23 @@ class MediaService:
|
||||
if suffix == ".mp4":
|
||||
return p
|
||||
if suffix in (".mkv", ".webm"):
|
||||
if cls.is_live_recording(p):
|
||||
# scrcpy is still appending to this file: converting now
|
||||
# would freeze a few-seconds partial as recording.mp4.
|
||||
return p
|
||||
src_stat = p.stat()
|
||||
cache_key = str(p)
|
||||
cached = cls._playable_cache.get(cache_key)
|
||||
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
|
||||
return Path(cached[2])
|
||||
mp4_cand = p.with_suffix(".mp4")
|
||||
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
|
||||
if (
|
||||
mp4_cand.exists()
|
||||
and mp4_cand.stat().st_size > 0
|
||||
and mp4_cand.stat().st_mtime >= src_stat.st_mtime
|
||||
):
|
||||
# An MP4 older than its source is a stale partial left by a
|
||||
# mid-run scan; fall through and convert again.
|
||||
cls._playable_cache[cache_key] = (
|
||||
src_stat.st_mtime,
|
||||
src_stat.st_size,
|
||||
@@ -88,6 +100,7 @@ class MediaService:
|
||||
@staticmethod
|
||||
def _convert_to_mp4(p: Path, mp4_cand: Path) -> Path:
|
||||
from artemis.utils.video import get_ffmpeg_path
|
||||
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
# 1. Attempt fast copy remux
|
||||
subprocess.run(
|
||||
@@ -135,6 +148,115 @@ class MediaService:
|
||||
return mp4_cand
|
||||
return p
|
||||
|
||||
LIVE_RECORDING_GRACE_SECONDS = 10.0
|
||||
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_")
|
||||
|
||||
@classmethod
|
||||
def is_live_recording(cls, p: Path) -> bool:
|
||||
"""Whether ``p`` is a raw .mkv that scrcpy is still appending to.
|
||||
|
||||
scrcpy touches the file continuously while recording; a raw file that
|
||||
has not changed for a few seconds belongs to a finished (or killed)
|
||||
recording and is safe to convert.
|
||||
"""
|
||||
if p.suffix.lower() != ".mkv":
|
||||
return False
|
||||
try:
|
||||
return (time.time() - p.stat().st_mtime) < cls.LIVE_RECORDING_GRACE_SECONDS
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_browser_playable(p: Path) -> bool:
|
||||
return p.suffix.lower() in (".mp4", ".webm")
|
||||
|
||||
@classmethod
|
||||
def strip_trace_status_suffix(cls, folder_name: str) -> str:
|
||||
for marker in cls.TRACE_STATUS_SUFFIXES:
|
||||
folder_name = folder_name.split(marker)[0]
|
||||
return folder_name
|
||||
|
||||
@classmethod
|
||||
def _finalize_trace_folder_name(cls, folder: Path, session_start_time: float | None) -> Path:
|
||||
"""Give an unfinished temp trace folder the terminal name the worker uses."""
|
||||
try:
|
||||
if folder.resolve().parent != Path(TRACES_PATH).resolve():
|
||||
return folder
|
||||
except OSError:
|
||||
return folder
|
||||
if any(marker in folder.name for marker in cls.TRACE_STATUS_SUFFIXES):
|
||||
return folder
|
||||
try:
|
||||
stamp = datetime.fromtimestamp(float(session_start_time or time.time())).strftime(
|
||||
"%Y-%m-%dT%H-%M-%S"
|
||||
)
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
stamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
||||
target = folder.with_name(f"{folder.name}_FAIL_{stamp}")
|
||||
if target.exists():
|
||||
return folder
|
||||
try:
|
||||
folder.rename(target)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not rename orphaned trace folder %s: %s", folder, exc)
|
||||
return folder
|
||||
return target
|
||||
|
||||
@classmethod
|
||||
def recover_orphaned_recording(
|
||||
cls,
|
||||
local_video_path: str | Path | None,
|
||||
session_start_time: float | None = None,
|
||||
) -> Path | None:
|
||||
"""Finalize the recording of a worker that died before its own stop path ran.
|
||||
|
||||
A hard-killed worker leaves the raw scrcpy ``recording.mkv`` behind
|
||||
(playable, but never remuxed) plus, possibly, a few-seconds partial
|
||||
``recording.mp4`` that a session-list scan converted mid-run. The raw
|
||||
file is remuxed over that partial and removed on success, and the temp
|
||||
trace folder is renamed the way the worker would have done it.
|
||||
|
||||
Returns the final browser-playable path, or None when nothing usable
|
||||
exists (the caller then records a terminal recording failure).
|
||||
"""
|
||||
if not local_video_path:
|
||||
return None
|
||||
source = Path(str(local_video_path))
|
||||
folder = source.parent
|
||||
if not folder.is_dir():
|
||||
return None
|
||||
raw = source if source.suffix.lower() == ".mkv" else folder / "recording.mkv"
|
||||
mp4 = folder / "recording.mp4"
|
||||
|
||||
def _non_empty(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_file() and path.stat().st_size > 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if _non_empty(raw):
|
||||
if cls.is_live_recording(raw):
|
||||
return None
|
||||
with cls._playable_cache_lock:
|
||||
converted = cls._convert_to_mp4(raw, mp4)
|
||||
cls._playable_cache.pop(str(raw), None)
|
||||
if converted != mp4 or not _non_empty(mp4):
|
||||
return None
|
||||
try:
|
||||
raw.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
final = mp4
|
||||
elif _non_empty(mp4):
|
||||
final = mp4
|
||||
elif _non_empty(source) and cls.is_browser_playable(source):
|
||||
final = source
|
||||
else:
|
||||
return None
|
||||
|
||||
folder = cls._finalize_trace_folder_name(folder, session_start_time)
|
||||
return folder / final.name
|
||||
|
||||
@staticmethod
|
||||
def path_to_video_url(p: Path) -> str:
|
||||
resolved = p.resolve()
|
||||
@@ -168,13 +290,18 @@ class MediaService:
|
||||
vfile = item / f"recording{ext}"
|
||||
if vfile.exists():
|
||||
vfile = cls.ensure_browser_playable_video(vfile)
|
||||
if not cls.is_browser_playable(vfile):
|
||||
# Live (still recording) or unconvertible raw file.
|
||||
break
|
||||
url = cls.path_to_video_url(vfile)
|
||||
idx[item.name] = url
|
||||
prefix = item.name.split("_PASS_")[0].split("_FAIL_")[0]
|
||||
prefix = cls.strip_trace_status_suffix(item.name)
|
||||
idx[prefix] = url
|
||||
break
|
||||
elif item.suffix in [".mp4", ".mkv", ".webm"]:
|
||||
item = cls.ensure_browser_playable_video(item)
|
||||
if not cls.is_browser_playable(item):
|
||||
continue
|
||||
url = cls.path_to_video_url(item)
|
||||
idx[item.stem] = url
|
||||
idx[item.name] = url
|
||||
@@ -199,15 +326,17 @@ class MediaService:
|
||||
v_fp = row_dict.get("video_filepath")
|
||||
if v_fp and os.path.exists(v_fp):
|
||||
p = cls.ensure_browser_playable_video(Path(v_fp))
|
||||
v_url = cls.path_to_video_url(p)
|
||||
if cls.is_browser_playable(p):
|
||||
v_url = cls.path_to_video_url(p)
|
||||
|
||||
orig_rec = video_rec_map.get(s_id)
|
||||
if not v_url and orig_rec:
|
||||
p = Path(orig_rec)
|
||||
if p.exists():
|
||||
p = cls.ensure_browser_playable_video(p)
|
||||
v_url = cls.path_to_video_url(p)
|
||||
else:
|
||||
if cls.is_browser_playable(p):
|
||||
v_url = cls.path_to_video_url(p)
|
||||
if not v_url:
|
||||
folder_name = p.parent.name
|
||||
v_url = video_idx.get(folder_name) or video_idx.get(folder_name.split("_")[0])
|
||||
|
||||
@@ -221,13 +350,16 @@ class MediaService:
|
||||
vfile = sess_dir / f"recording{ext}"
|
||||
if vfile.exists():
|
||||
vfile = cls.ensure_browser_playable_video(vfile)
|
||||
v_url = cls.path_to_video_url(vfile)
|
||||
if cls.is_browser_playable(vfile):
|
||||
v_url = cls.path_to_video_url(vfile)
|
||||
break
|
||||
if not v_url:
|
||||
try:
|
||||
for item in sess_dir.iterdir():
|
||||
if item.is_file() and item.suffix.lower() in [".mp4", ".webm", ".mkv"]:
|
||||
item = cls.ensure_browser_playable_video(item)
|
||||
if not cls.is_browser_playable(item):
|
||||
continue
|
||||
v_url = cls.path_to_video_url(item)
|
||||
break
|
||||
except OSError:
|
||||
@@ -260,7 +392,10 @@ class MediaService:
|
||||
segments = []
|
||||
for item in payload.get("segments", []):
|
||||
segment_path = (manifest_path.parent / str(item["file"])).resolve()
|
||||
if segment_path.parent != manifest_path.parent.resolve() or not segment_path.is_file():
|
||||
if (
|
||||
segment_path.parent != manifest_path.parent.resolve()
|
||||
or not segment_path.is_file()
|
||||
):
|
||||
continue
|
||||
segments.append(
|
||||
{
|
||||
@@ -400,6 +535,43 @@ class MediaService:
|
||||
return f"Error reading task plan: {e}"
|
||||
return "No task plan created yet."
|
||||
|
||||
@staticmethod
|
||||
def get_session_checks(session_id: str) -> dict[str, Any]:
|
||||
"""Checker material of one session for UI backfill: the append-only
|
||||
verdict ledger (``check_ledger.jsonl``) and the machine-readable run
|
||||
outcome (``run_outcome.json``), both written by
|
||||
``artemis.graph.checkpoints``. Missing files mean "no checks ran".
|
||||
"""
|
||||
session_dir = TRACES_PATH / session_id
|
||||
records: list[dict[str, Any]] = []
|
||||
ledger_path = session_dir / "check_ledger.jsonl"
|
||||
if ledger_path.is_file():
|
||||
try:
|
||||
for line in ledger_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(rec, dict):
|
||||
records.append(rec)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning(f"Failed to read check ledger for {session_id}: {e}")
|
||||
|
||||
run_outcome: dict[str, Any] | None = None
|
||||
outcome_path = session_dir / "run_outcome.json"
|
||||
if outcome_path.is_file():
|
||||
try:
|
||||
loaded = json.loads(outcome_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
run_outcome = loaded
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning(f"Failed to read run outcome for {session_id}: {e}")
|
||||
|
||||
return {"records": records, "run_outcome": run_outcome}
|
||||
|
||||
@staticmethod
|
||||
def get_session_notes_content(session_id: str) -> dict[str, str]:
|
||||
notes_dir = TRACES_PATH / session_id / "notes"
|
||||
|
||||
@@ -16,8 +16,10 @@ import asyncio
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
import uuid
|
||||
@@ -43,8 +45,11 @@ from artemis.runtime import (
|
||||
AdbEndpoint,
|
||||
AdbTarget,
|
||||
DeviceExecutionLock,
|
||||
clear_cancel_request,
|
||||
current_adb_endpoint,
|
||||
pid_is_alive,
|
||||
process_supervisor,
|
||||
request_cancel,
|
||||
trace_store,
|
||||
)
|
||||
|
||||
@@ -59,6 +64,156 @@ class TaskQueueService:
|
||||
# Strong references to in-flight _execute_task_item tasks (asyncio itself only
|
||||
# keeps weak references to running tasks).
|
||||
_run_tasks: set[asyncio.Task] = set()
|
||||
# Deadline enforcers for graceful stops (see _stop_worker_gracefully).
|
||||
_forced_stop_tasks: set[asyncio.Task] = set()
|
||||
|
||||
DEFAULT_CANCEL_GRACE_SECONDS = 45.0
|
||||
|
||||
@classmethod
|
||||
def _cancel_grace_seconds(cls) -> float:
|
||||
"""How long a worker may finalize itself before it is killed.
|
||||
|
||||
``ARTEMIS_CANCEL_GRACE_SECONDS=0`` restores the legacy immediate kill.
|
||||
"""
|
||||
raw = os.getenv("ARTEMIS_CANCEL_GRACE_SECONDS")
|
||||
if raw is None or not raw.strip():
|
||||
return cls.DEFAULT_CANCEL_GRACE_SECONDS
|
||||
try:
|
||||
return max(0.0, float(raw))
|
||||
except ValueError:
|
||||
return cls.DEFAULT_CANCEL_GRACE_SECONDS
|
||||
|
||||
@staticmethod
|
||||
def _hard_kill(pid: int, process_created_at: float = 0.0) -> bool:
|
||||
if not pid:
|
||||
return False
|
||||
if process_created_at and process_created_at > 0:
|
||||
return process_supervisor.terminate_tree_verified(pid, process_created_at)
|
||||
try:
|
||||
return process_supervisor.terminate_tree(pid)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _stop_worker_gracefully(
|
||||
cls,
|
||||
pid: Any,
|
||||
process_created_at: float = 0.0,
|
||||
session_id: str | None = None,
|
||||
reason: str = "Task stopped from the Artemis frontend.",
|
||||
) -> tuple[bool, bool]:
|
||||
"""Ask a worker to cancel itself; hard-kill it once the grace period lapses.
|
||||
|
||||
Workers are isolated from the daemon's console (and may belong to
|
||||
another ingress process), so instead of a signal the daemon drops a
|
||||
cancel marker the worker polls for. Honouring it runs the worker's
|
||||
normal cancellation path: the screen recording is stopped and remuxed,
|
||||
the trace folder is compiled, and the device lease is released. A
|
||||
worker that never picks the marker up is killed after the grace period.
|
||||
|
||||
Returns ``(stopped, deferred)``: ``stopped`` mirrors the legacy kill
|
||||
result, ``deferred`` is True when the kill was handed to the deadline
|
||||
enforcer instead of happening now.
|
||||
"""
|
||||
try:
|
||||
pid_int = int(pid) if pid else 0
|
||||
except (TypeError, ValueError):
|
||||
pid_int = 0
|
||||
grace = cls._cancel_grace_seconds()
|
||||
if not pid_int or grace <= 0:
|
||||
return cls._hard_kill(pid_int, process_created_at), False
|
||||
|
||||
created_at = float(process_created_at or 0.0)
|
||||
if created_at <= 0:
|
||||
# PID markers carry the creation time so a leftover marker can never
|
||||
# cancel a future process that reuses this PID.
|
||||
try:
|
||||
import psutil
|
||||
|
||||
created_at = float(psutil.Process(pid_int).create_time())
|
||||
except Exception:
|
||||
created_at = 0.0
|
||||
if not pid_is_alive(pid_int, created_at or None):
|
||||
return True, False
|
||||
|
||||
written = request_cancel(
|
||||
session_id=str(session_id) if session_id else None,
|
||||
pid=pid_int,
|
||||
process_created_at=created_at,
|
||||
reason=reason,
|
||||
)
|
||||
if not written:
|
||||
return cls._hard_kill(pid_int, created_at), False
|
||||
|
||||
print(
|
||||
f"[stop_tasks] Cancel requested for worker {pid_int}"
|
||||
f" (session {session_id or 'n/a'}); forcing termination after {grace:.0f}s"
|
||||
)
|
||||
cls._schedule_forced_stop(pid_int, created_at, grace, session_id)
|
||||
return True, True
|
||||
|
||||
@classmethod
|
||||
def _stop_proc_gracefully(cls, proc: Any, session_id: Any) -> bool:
|
||||
"""Graceful variant for a locally spawned process; True when deferred."""
|
||||
pid = getattr(proc, "pid", None)
|
||||
if not pid or cls._cancel_grace_seconds() <= 0:
|
||||
return False
|
||||
try:
|
||||
_stopped, deferred = cls._stop_worker_gracefully(
|
||||
pid, session_id=str(session_id) if session_id else None
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return deferred
|
||||
|
||||
@classmethod
|
||||
def _schedule_forced_stop(
|
||||
cls, pid: int, process_created_at: float, grace: float, session_id: Any
|
||||
) -> None:
|
||||
"""Kill ``pid`` if it is still alive once ``grace`` seconds have passed."""
|
||||
|
||||
def _still_alive() -> bool:
|
||||
return pid_is_alive(pid, process_created_at or None)
|
||||
|
||||
def _force() -> None:
|
||||
print(
|
||||
f"[stop_tasks] Worker {pid} (session {session_id or 'n/a'}) did not exit"
|
||||
f" within {grace:.0f}s of the cancel request; forcing termination."
|
||||
)
|
||||
cls._hard_kill(pid, process_created_at)
|
||||
|
||||
async def _enforce_async() -> None:
|
||||
deadline = time.monotonic() + grace
|
||||
while time.monotonic() < deadline:
|
||||
if not _still_alive():
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
_force()
|
||||
clear_cancel_request(session_id=str(session_id) if session_id else None, pid=pid)
|
||||
|
||||
def _enforce_sync() -> None:
|
||||
deadline = time.monotonic() + grace
|
||||
while time.monotonic() < deadline:
|
||||
if not _still_alive():
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
_force()
|
||||
clear_cancel_request(session_id=str(session_id) if session_id else None, pid=pid)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
if loop is not None:
|
||||
task = loop.create_task(_enforce_async())
|
||||
cls._forced_stop_tasks.add(task)
|
||||
task.add_done_callback(cls._forced_stop_tasks.discard)
|
||||
return
|
||||
threading.Thread(
|
||||
target=_enforce_sync, name=f"artemis-forced-stop-{pid}", daemon=True
|
||||
).start()
|
||||
|
||||
@staticmethod
|
||||
def _task_target(task_item: dict[str, Any]) -> AdbTarget:
|
||||
@@ -88,9 +243,7 @@ class TaskQueueService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _broadcast_startup_progress(
|
||||
cls, session_id: str | None, stage: str, message: str
|
||||
) -> None:
|
||||
def _broadcast_startup_progress(cls, session_id: str | None, stage: str, message: str) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
data = {
|
||||
@@ -223,8 +376,7 @@ class TaskQueueService:
|
||||
inside each worker process.
|
||||
"""
|
||||
print(
|
||||
"[QueueWorker] Dispatcher initialized "
|
||||
f"(concurrency mode: {cls._concurrency_mode()})."
|
||||
f"[QueueWorker] Dispatcher initialized (concurrency mode: {cls._concurrency_mode()})."
|
||||
)
|
||||
try:
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
@@ -262,9 +414,7 @@ class TaskQueueService:
|
||||
# "running" state, so admission must count those too -- active_runs alone
|
||||
# lags behind by the subprocess startup latency.
|
||||
in_flight = [
|
||||
i
|
||||
for i in state.queue_items
|
||||
if isinstance(i, dict) and i.get("status") == "running"
|
||||
i for i in state.queue_items if isinstance(i, dict) and i.get("status") == "running"
|
||||
]
|
||||
busy_devices = state.busy_device_ids | {
|
||||
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
|
||||
@@ -325,9 +475,7 @@ class TaskQueueService:
|
||||
state.current_profile = profile
|
||||
state.active_session_id = sess_id
|
||||
|
||||
cls._broadcast_startup_progress(
|
||||
sess_id, "launching", "Starting the execution process"
|
||||
)
|
||||
cls._broadcast_startup_progress(sess_id, "launching", "Starting the execution process")
|
||||
|
||||
# Broadcast session_started so all connected clients know the task has started
|
||||
cls._broadcast_event(
|
||||
@@ -353,6 +501,8 @@ class TaskQueueService:
|
||||
"""Assemble the worker subprocess command line and environment."""
|
||||
expected_output = task_item.get("expected_output")
|
||||
enable_outputter = task_item.get("enable_outputter")
|
||||
verification_level = task_item.get("verification_level")
|
||||
explorer_mode = task_item.get("explorer_mode")
|
||||
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
|
||||
app_path = task_item.get("app_path")
|
||||
|
||||
@@ -395,6 +545,10 @@ class TaskQueueService:
|
||||
cmd.extend(["--output-description", str(expected_output)])
|
||||
if enable_outputter is not None:
|
||||
cmd.append("--enable-outputter" if enable_outputter else "--disable-outputter")
|
||||
if verification_level:
|
||||
cmd.extend(["--verification-level", str(verification_level)])
|
||||
if explorer_mode:
|
||||
cmd.extend(["--explorer-pro-mode", str(explorer_mode)])
|
||||
if locked_app:
|
||||
cmd.extend(["--locked-app", str(locked_app)])
|
||||
if app_path:
|
||||
@@ -439,9 +593,7 @@ class TaskQueueService:
|
||||
print(
|
||||
f"[QueueWorker] Could not record worker pid in status.json for {sess_id}: {exc}"
|
||||
)
|
||||
cls._broadcast_startup_progress(
|
||||
sess_id, "process_ready", "Execution process started"
|
||||
)
|
||||
cls._broadcast_startup_progress(sess_id, "process_ready", "Execution process started")
|
||||
ingress_type = str(task_item.get("ingress", "frontend"))
|
||||
DeviceExecutionLock.transfer_reservation(
|
||||
str(task_item.get("queue_ticket")),
|
||||
@@ -467,9 +619,7 @@ class TaskQueueService:
|
||||
except Exception:
|
||||
log_path = None
|
||||
if isinstance(proc.stdout, asyncio.StreamReader):
|
||||
return asyncio.create_task(
|
||||
cls._forward_worker_output(proc.stdout, log_path)
|
||||
)
|
||||
return asyncio.create_task(cls._forward_worker_output(proc.stdout, log_path))
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -481,9 +631,7 @@ class TaskQueueService:
|
||||
str(sess_id) in getattr(state, "cancelled_session_ids", set())
|
||||
or run_key in state.manually_stopped_run_ids
|
||||
):
|
||||
print(
|
||||
f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating."
|
||||
)
|
||||
print(f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating.")
|
||||
await cls._terminate_worker_process(proc)
|
||||
|
||||
@classmethod
|
||||
@@ -505,23 +653,21 @@ class TaskQueueService:
|
||||
# against when status.json is stale, so a failed DB
|
||||
# write must not pass silently.
|
||||
logger.error(
|
||||
"[QueueWorker] Could not persist terminal DB status "
|
||||
"'%s' for session %s",
|
||||
"[QueueWorker] Could not persist terminal DB status '%s' for session %s",
|
||||
new_status,
|
||||
sess_id,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[QueueWorker] Preserved authoritative session {sess_id} "
|
||||
f"status '{new_status}'"
|
||||
)
|
||||
print(f"[QueueWorker] Preserved authoritative session {sess_id} status '{new_status}'")
|
||||
# A stale status.json would leave MCP pollers seeing "running"
|
||||
# until their next DB reconcile, so retry transient write
|
||||
# failures before giving up.
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if trace_store.read_status(str(sess_id)):
|
||||
canonical_mcp_status = "completed" if new_status in ("completed", "success") else new_status
|
||||
canonical_mcp_status = (
|
||||
"completed" if new_status in ("completed", "success") else new_status
|
||||
)
|
||||
trace_store.update_trace_status(
|
||||
str(sess_id),
|
||||
canonical_mcp_status,
|
||||
@@ -542,37 +688,68 @@ class TaskQueueService:
|
||||
|
||||
@classmethod
|
||||
async def _recover_or_fail_recording(cls, sess_id: Any) -> None:
|
||||
"""Attempt recovery of a not-yet-ready recording, else mark it failed."""
|
||||
"""Finalize a recording whose worker died before it could, else mark it failed.
|
||||
|
||||
A worker that stops gracefully finalizes its own recording and this is
|
||||
a no-op. A hard-killed (or crashed) worker leaves the raw scrcpy file it
|
||||
registered at recording start; remuxing that file is all that is
|
||||
needed to publish the full video.
|
||||
"""
|
||||
rec_info = session_repo.get_video_recording_for_session(sess_id)
|
||||
rec_status = (rec_info or {}).get("status")
|
||||
recovered_video_path = None
|
||||
if rec_status != "ready":
|
||||
if rec_status == "ready":
|
||||
return
|
||||
|
||||
recovered_url = None
|
||||
# 1. Direct recovery from the recording row written at recording start.
|
||||
try:
|
||||
local_video_path = (rec_info or {}).get("local_video_path")
|
||||
if local_video_path:
|
||||
start_time = (rec_info or {}).get("start_time")
|
||||
if not start_time:
|
||||
session_row = session_repo.get_session_by_id(sess_id)
|
||||
start_time = dict(session_row).get("start_time") if session_row else None
|
||||
final_path = await asyncio.to_thread(
|
||||
media_service.recover_orphaned_recording,
|
||||
local_video_path,
|
||||
start_time,
|
||||
)
|
||||
if final_path:
|
||||
session_repo.mark_recording_ready(sess_id, str(final_path))
|
||||
recovered_url = media_service.path_to_video_url(Path(final_path))
|
||||
except Exception as rec_err:
|
||||
print(f"[QueueWorker] Error finalizing orphaned recording: {rec_err}")
|
||||
|
||||
# 2. Fallback: locate an already finalized file by folder / session naming.
|
||||
if not recovered_url:
|
||||
try:
|
||||
video_rec_map = session_repo.get_video_recordings_map()
|
||||
video_idx = await asyncio.to_thread(media_service.build_video_index)
|
||||
recovered_url = await asyncio.to_thread(
|
||||
fallback_url = await asyncio.to_thread(
|
||||
media_service.resolve_video_url,
|
||||
{"session_id": sess_id},
|
||||
video_rec_map,
|
||||
video_idx,
|
||||
)
|
||||
if recovered_url:
|
||||
session_repo.mark_recording_ready(sess_id, recovered_url)
|
||||
cls._broadcast_event(
|
||||
"recording_ready",
|
||||
{"session_id": sess_id, "video_url": recovered_url},
|
||||
)
|
||||
recovered_video_path = recovered_url
|
||||
if fallback_url:
|
||||
session_repo.mark_recording_ready(sess_id, fallback_url)
|
||||
recovered_url = fallback_url
|
||||
except Exception as rec_err:
|
||||
print(f"[QueueWorker] Error attempting recording recovery: {rec_err}")
|
||||
|
||||
if not recovered_video_path and rec_status != "ready":
|
||||
recording_error = "Task worker exited before recording finalization completed"
|
||||
if session_repo.mark_recording_failed_if_pending(sess_id, recording_error):
|
||||
cls._broadcast_event(
|
||||
"recording_failed",
|
||||
{"session_id": sess_id, "error": recording_error},
|
||||
)
|
||||
if recovered_url:
|
||||
cls._broadcast_event(
|
||||
"recording_ready",
|
||||
{"session_id": sess_id, "video_url": recovered_url},
|
||||
)
|
||||
return
|
||||
|
||||
recording_error = "Task worker exited before recording finalization completed"
|
||||
if session_repo.mark_recording_failed_if_pending(sess_id, recording_error):
|
||||
cls._broadcast_event(
|
||||
"recording_failed",
|
||||
{"session_id": sess_id, "error": recording_error},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _announce_session_end(
|
||||
@@ -624,6 +801,15 @@ class TaskQueueService:
|
||||
state.cancelled_session_ids.discard(str(sess_id))
|
||||
state.cancelled_session_ids.discard(run_key)
|
||||
state.manually_stopped_run_ids.discard(run_key)
|
||||
try:
|
||||
clear_cancel_request(
|
||||
session_id=str(sess_id) if sess_id else None,
|
||||
pid=getattr(proc, "pid", None),
|
||||
)
|
||||
except (OSError, TypeError, ValueError):
|
||||
# Marker cleanup is best-effort: an unwritable temp dir or an odd
|
||||
# pid value must not block releasing the run slot.
|
||||
pass
|
||||
state.active_runs.pop(run_key, None)
|
||||
if proc is not None and state.current_process is proc:
|
||||
state.current_process = None
|
||||
@@ -665,9 +851,7 @@ class TaskQueueService:
|
||||
env=env,
|
||||
**cls._subprocess_creation_kwargs(),
|
||||
)
|
||||
cls._register_worker_run(
|
||||
task_item, run_key, sess_id, goal, profile, target, proc
|
||||
)
|
||||
cls._register_worker_run(task_item, run_key, sess_id, goal, profile, target, proc)
|
||||
output_task = cls._start_output_forwarder(sess_id, proc)
|
||||
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
|
||||
|
||||
@@ -685,9 +869,7 @@ class TaskQueueService:
|
||||
sess_id, returncode, manual_stop
|
||||
)
|
||||
await cls._recover_or_fail_recording(sess_id)
|
||||
cls._announce_session_end(
|
||||
task_item, sess_id, goal, new_status, manual_stop
|
||||
)
|
||||
cls._announce_session_end(task_item, sess_id, goal, new_status, manual_stop)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(f"[QueueWorker] Task [{sess_id}] received cancellation signal.")
|
||||
@@ -763,9 +945,7 @@ class TaskQueueService:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def _reject_unavailable_device(
|
||||
cls, device_serial: str | None
|
||||
) -> dict[str, Any] | None:
|
||||
async def _reject_unavailable_device(cls, device_serial: str | None) -> dict[str, Any] | None:
|
||||
"""Return the rejection response for an unattached explicit serial, if any."""
|
||||
# Strict device binding: reject an explicitly requested serial that is not
|
||||
# attached and authorized, instead of silently running on another device.
|
||||
@@ -804,6 +984,8 @@ class TaskQueueService:
|
||||
device_serial: str | None,
|
||||
ingress: str,
|
||||
conversation_id: str | None,
|
||||
verification_level: str | None = None,
|
||||
explorer_mode: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reserve a device slot and build one pending queue item for a goal."""
|
||||
sess_id = single_session_id if single_session_id else str(uuid.uuid4())
|
||||
@@ -829,6 +1011,8 @@ class TaskQueueService:
|
||||
"profile": profile or "flash",
|
||||
"expected_output": expected_output,
|
||||
"enable_outputter": enable_outputter,
|
||||
"verification_level": verification_level,
|
||||
"explorer_mode": explorer_mode,
|
||||
"locked_app_package": locked_app_package,
|
||||
"app_path": app_path,
|
||||
"device_serial": assigned_serial,
|
||||
@@ -854,8 +1038,19 @@ class TaskQueueService:
|
||||
ingress: str = "frontend",
|
||||
session_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
verification_level: str | None = None,
|
||||
explorer_mode: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueues one or more goals and wakes up the background worker."""
|
||||
"""Enqueues one or more goals and wakes up the background worker.
|
||||
|
||||
``verification_level`` and ``explorer_mode`` are Pro-profile tuning knobs
|
||||
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
|
||||
they are normalised here so the queue item and the CLI see one spelling.
|
||||
"""
|
||||
verification_level = (
|
||||
str(verification_level).strip().lower() or None if verification_level else None
|
||||
)
|
||||
explorer_mode = str(explorer_mode).strip().lower() or None if explorer_mode else None
|
||||
cls.ensure_worker_running()
|
||||
|
||||
enqueued_tasks = []
|
||||
@@ -888,6 +1083,8 @@ class TaskQueueService:
|
||||
device_serial,
|
||||
ingress,
|
||||
conversation_id,
|
||||
verification_level=verification_level,
|
||||
explorer_mode=explorer_mode,
|
||||
)
|
||||
state.queue_items.append(task_item)
|
||||
enqueued_tasks.append(task_item)
|
||||
@@ -931,9 +1128,10 @@ class TaskQueueService:
|
||||
|
||||
for dev_owner in list(active_owners.values()):
|
||||
if dev_owner and DeviceExecutionLock.is_active_owner(dev_owner):
|
||||
process_supervisor.terminate_tree_verified(
|
||||
cls._stop_worker_gracefully(
|
||||
dev_owner.pid,
|
||||
dev_owner.process_created_at,
|
||||
session_id=dev_owner.session_id,
|
||||
)
|
||||
DeviceExecutionLock.cleanup_stale_locks(dev_owner.device_id)
|
||||
sid = dev_owner.session_id
|
||||
@@ -973,20 +1171,22 @@ class TaskQueueService:
|
||||
state.cancelled_session_ids.add(str(run_key))
|
||||
run_proc = run.get("process")
|
||||
if run_proc is not None and run_proc.returncode is None:
|
||||
try:
|
||||
run_proc.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited between the check and the kill.
|
||||
pass
|
||||
if not cls._stop_proc_gracefully(run_proc, run.get("session_id") or run_key):
|
||||
try:
|
||||
run_proc.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited between the check and the kill.
|
||||
pass
|
||||
# active_runs entries are popped by each run's finalizer once the
|
||||
# process exit is observed; clearing them here would free the device
|
||||
# slots before the processes are actually gone.
|
||||
if state.current_process:
|
||||
try:
|
||||
state.current_process.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited between the check and the kill.
|
||||
pass
|
||||
if not cls._stop_proc_gracefully(state.current_process, state.active_session_id):
|
||||
try:
|
||||
state.current_process.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited between the check and the kill.
|
||||
pass
|
||||
state.current_process = None
|
||||
|
||||
@classmethod
|
||||
@@ -1167,19 +1367,24 @@ class TaskQueueService:
|
||||
stopped = False
|
||||
reservation_cancelled = False
|
||||
if owner and DeviceExecutionLock.is_active_owner(owner):
|
||||
stopped = process_supervisor.terminate_tree_verified(
|
||||
stopped, _deferred = cls._stop_worker_gracefully(
|
||||
owner.pid,
|
||||
owner.process_created_at,
|
||||
session_id=owner.session_id or target_sid,
|
||||
)
|
||||
DeviceExecutionLock.cleanup_stale_locks(owner.device_id)
|
||||
elif owner is None and owner_record_exists and not target_sid:
|
||||
# Never fall back to a frontend PID while another process has an
|
||||
# owner record that is still being published or cannot be parsed.
|
||||
return None
|
||||
elif owner is None and local_proc is not None and (
|
||||
local_run is not None
|
||||
or not target_sid
|
||||
or str(state.active_session_id) == target_sid
|
||||
elif (
|
||||
owner is None
|
||||
and local_proc is not None
|
||||
and (
|
||||
local_run is not None
|
||||
or not target_sid
|
||||
or str(state.active_session_id) == target_sid
|
||||
)
|
||||
):
|
||||
# The locally-managed worker can be stopped during its short
|
||||
# initialization window before Agent acquires the device lease.
|
||||
@@ -1187,21 +1392,28 @@ class TaskQueueService:
|
||||
state.cancelled_session_ids.add(target_sid)
|
||||
elif local_run_key is not None:
|
||||
state.manually_stopped_run_ids.add(str(local_run_key))
|
||||
deferred = False
|
||||
if local_pid:
|
||||
try:
|
||||
stopped = process_supervisor.terminate_tree(local_pid)
|
||||
stopped, deferred = cls._stop_worker_gracefully(
|
||||
local_pid,
|
||||
session_id=target_sid or (local_run or {}).get("session_id"),
|
||||
)
|
||||
except Exception:
|
||||
stopped = False
|
||||
try:
|
||||
local_proc.kill()
|
||||
stopped = True
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited; terminate_tree above may have got it.
|
||||
pass
|
||||
if not deferred:
|
||||
try:
|
||||
local_proc.kill()
|
||||
stopped = True
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited; the stop above may have got it.
|
||||
pass
|
||||
stopped = True
|
||||
is_local_owner = True
|
||||
elif owner is None and local_item and (
|
||||
not target_sid or str(local_item.get("session_id")) == target_sid
|
||||
elif (
|
||||
owner is None
|
||||
and local_item
|
||||
and (not target_sid or str(local_item.get("session_id")) == target_sid)
|
||||
):
|
||||
# Cancel a frontend submission before its worker has started. This
|
||||
# does not touch pending reservations created by other ingresses.
|
||||
@@ -1226,12 +1438,10 @@ class TaskQueueService:
|
||||
row_pid = row.get("pid")
|
||||
if row_pid and session_repo.process_is_alive(row_pid):
|
||||
try:
|
||||
process_supervisor.terminate_tree(int(row_pid))
|
||||
cls._stop_worker_gracefully(int(row_pid), session_id=target_sid)
|
||||
except Exception as exc:
|
||||
# Report it: a surviving worker keeps the device busy.
|
||||
print(
|
||||
f"[stop_tasks] Could not terminate worker pid {row_pid}: {exc}"
|
||||
)
|
||||
print(f"[stop_tasks] Could not terminate worker pid {row_pid}: {exc}")
|
||||
DeviceExecutionLock.cleanup_stale_locks()
|
||||
stopped = True
|
||||
return stopped, is_local_owner, reservation_cancelled
|
||||
@@ -1264,9 +1474,7 @@ class TaskQueueService:
|
||||
state.active_connections.pop(sid, None)
|
||||
|
||||
if stopped_session_id:
|
||||
session_repo.update_session_status(
|
||||
str(stopped_session_id), "cancelled", time.time()
|
||||
)
|
||||
session_repo.update_session_status(str(stopped_session_id), "cancelled", time.time())
|
||||
try:
|
||||
is_mcp = bool(owner and owner.ingress == "mcp")
|
||||
if is_mcp or trace_store.read_status(str(stopped_session_id)):
|
||||
@@ -1287,8 +1495,7 @@ class TaskQueueService:
|
||||
)
|
||||
|
||||
if state.active_session_id and (
|
||||
not stopped_session_id
|
||||
or str(state.active_session_id) == str(stopped_session_id)
|
||||
not stopped_session_id or str(state.active_session_id) == str(stopped_session_id)
|
||||
):
|
||||
state.active_session_id = None
|
||||
state.current_goal = None
|
||||
@@ -1305,8 +1512,7 @@ class TaskQueueService:
|
||||
(
|
||||
item
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict)
|
||||
and str(item.get("session_id")) == str(stopped_session_id)
|
||||
if isinstance(item, dict) and str(item.get("session_id")) == str(stopped_session_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -1316,15 +1522,12 @@ class TaskQueueService:
|
||||
item
|
||||
for item in state.queue_items
|
||||
if not (
|
||||
isinstance(item, dict)
|
||||
and str(item.get("session_id")) == str(stopped_session_id)
|
||||
isinstance(item, dict) and str(item.get("session_id")) == str(stopped_session_id)
|
||||
)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _stop_targeted_task(
|
||||
cls, target_sid: str | None, target_device: str | None
|
||||
) -> bool:
|
||||
def _stop_targeted_task(cls, target_sid: str | None, target_device: str | None) -> bool:
|
||||
"""Stop a specific task (or default single-device active task)."""
|
||||
active_owners = {}
|
||||
try:
|
||||
@@ -1338,9 +1541,7 @@ class TaskQueueService:
|
||||
owner_record_exists = DeviceExecutionLock.has_owner_record(target_device)
|
||||
owner_pid = owner.pid if owner else None
|
||||
|
||||
local_run_key, local_run, local_proc = cls._resolve_local_run(
|
||||
target_sid, target_device
|
||||
)
|
||||
local_run_key, local_run, local_proc = cls._resolve_local_run(target_sid, target_device)
|
||||
local_pid = getattr(local_proc, "pid", None)
|
||||
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
|
||||
|
||||
@@ -1409,7 +1610,6 @@ class TaskQueueService:
|
||||
|
||||
return cls._stop_targeted_task(target_sid, target_device)
|
||||
|
||||
|
||||
@classmethod
|
||||
def resume_task(cls) -> bool:
|
||||
if PAUSE_FILE.exists():
|
||||
@@ -1417,6 +1617,40 @@ class TaskQueueService:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def recover_orphaned_recordings_on_launch(cls) -> int:
|
||||
"""Finalize recordings left behind by workers that died with the daemon.
|
||||
|
||||
The per-run finalizer handles workers that exit while the daemon is up;
|
||||
this sweep covers everything else (daemon crash, machine reboot, tasks
|
||||
cancelled before this recovery path existed). Returns the number of
|
||||
recordings published.
|
||||
"""
|
||||
recovered = 0
|
||||
try:
|
||||
pending = session_repo.get_unfinalized_video_recordings()
|
||||
except Exception as exc:
|
||||
print(f"[ServerStartup] Could not enumerate unfinalized recordings: {exc}")
|
||||
return 0
|
||||
for row in pending:
|
||||
sess_id = row.get("session_id")
|
||||
try:
|
||||
final_path = media_service.recover_orphaned_recording(
|
||||
row.get("local_video_path"),
|
||||
row.get("start_time") or row.get("session_start_time"),
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[ServerStartup] Recording recovery failed for {sess_id}: {exc}")
|
||||
continue
|
||||
if not final_path:
|
||||
continue
|
||||
if session_repo.mark_recording_ready(str(sess_id), str(final_path)):
|
||||
recovered += 1
|
||||
print(f"[ServerStartup] Recovered recording for session {sess_id}: {final_path}")
|
||||
if recovered:
|
||||
print(f"[ServerStartup] Recovered {recovered} orphaned recording(s).")
|
||||
return recovered
|
||||
|
||||
@staticmethod
|
||||
def archive_older_replays_on_launch():
|
||||
"""Archives all existing step replay output folders on server launch."""
|
||||
|
||||
@@ -47,9 +47,7 @@ def subprocess_creation_kwargs() -> dict[str, Any]:
|
||||
"stderr": asyncio.subprocess.STDOUT,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = (
|
||||
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
||||
)
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
||||
return kwargs
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<!-- Robot Silhouette Mask -->
|
||||
<mask id="robotMeshMask">
|
||||
<rect width="240" height="240" fill="black" />
|
||||
|
||||
|
||||
<!-- Antennae -->
|
||||
<path d="M 90 60 Q 75 35 65 28" stroke="white" stroke-width="10" stroke-linecap="round" />
|
||||
<circle cx="62" cy="24" r="10" fill="white" />
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -277,7 +277,7 @@ limitations under the License.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Verification Box (Pulled out into a minimalist framed card) -->
|
||||
<!-- Verification result -->
|
||||
@if (milestone.checks.length > 0) {
|
||||
<div class="milestone-checks-container">
|
||||
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
|
||||
@@ -505,7 +505,7 @@ limitations under the License.
|
||||
class="phase-header"
|
||||
[class.first-phase]="first && startupWorkItems().length === 0"
|
||||
>
|
||||
<span class="phase-worked-time">Worked for {{ phase.durationSeconds }}s</span>
|
||||
<span class="phase-worked-time">{{ isCheckerPhase(phase) ? 'Checked' : 'Worked' }} for {{ phase.durationSeconds }}s</span>
|
||||
@if (phase.tokens) {
|
||||
<span class="phase-token-usage" [title]="getTokenTooltip(phase)">
|
||||
<span class="token-dot">·</span>
|
||||
@@ -524,12 +524,68 @@ limitations under the License.
|
||||
>
|
||||
<!-- Unified Step / Stream Body -->
|
||||
<div class="step-body">
|
||||
<!-- Check block (verifier lane): header, then the declared check items
|
||||
and how each is judged; the chronological stream follows, and the
|
||||
result rows close the block (see check-result below). -->
|
||||
@if (isCheckerBlock(block)) {
|
||||
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
|
||||
<div class="check-header">
|
||||
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
|
||||
<span class="check-title">{{ getCheckerPhaseLabel(block) }}</span>
|
||||
@if (block.data.phase !== 'outcome' && block.data.subgoal_text) {
|
||||
<span class="check-subject" [title]="block.data.subgoal_text">{{ block.data.subgoal_text }}</span>
|
||||
}
|
||||
<span class="check-status">{{ getCheckerStatusLabel(block) }}</span>
|
||||
</div>
|
||||
@if (!isCheckerMuted(block) && getCheckerItems(block).length > 0) {
|
||||
<ol class="check-items">
|
||||
@for (item of getCheckerItems(block); track $index) {
|
||||
<li class="check-item">
|
||||
<span class="check-index">{{ $index + 1 }}</span>
|
||||
<span class="check-text">{{ item.text }}</span>
|
||||
<span class="check-method">{{ getCheckMethodLabel(item, block) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Text, tool calls, and actions share one chronological timeline. -->
|
||||
@if (getSortedStepEvents(block.data).length > 0) {
|
||||
<div class="step-actions-container">
|
||||
@for (item of getSortedStepEvents(block.data); track trackStepEvent(itemIdx, item); let itemIdx = $index) {
|
||||
<!-- Check block: one Thought / Work entry per streamed turn, in time order with its tool calls -->
|
||||
@if (isCheckerBlock(block) && item.type === 'thinking') {
|
||||
<div
|
||||
class="thinking-stream-details native-thinking-details"
|
||||
[class.is-running]="isCheckerSegmentRunning(block, itemIdx)"
|
||||
[class.collapsed]="isCheckerSegmentCollapsed(block, itemIdx)">
|
||||
<div class="details-summary" (click)="toggleCheckerSegment(block, itemIdx)">
|
||||
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
|
||||
Thought
|
||||
</div>
|
||||
<div class="details-content-wrapper">
|
||||
<div class="details-content stream-box">
|
||||
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (isCheckerBlock(block) && item.type === 'text' && isCheckerTextVisible(item)) {
|
||||
<div
|
||||
class="step-raw-thinking-message"
|
||||
[class.is-running]="isCheckerSegmentRunning(block, itemIdx)">
|
||||
<div class="report-header">
|
||||
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
|
||||
Work
|
||||
</div>
|
||||
<div class="report-content stream-box">
|
||||
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Native Thinking (Thought) -->
|
||||
@if (item.type === 'thinking') {
|
||||
@if (!isCheckerBlock(block) && item.type === 'thinking') {
|
||||
<div
|
||||
class="thinking-stream-details native-thinking-details"
|
||||
[class.is-running]="isBlockRunning(block)"
|
||||
@@ -546,7 +602,7 @@ limitations under the License.
|
||||
</div>
|
||||
}
|
||||
<!-- Raw Thinking / Plan / Message (Work) -->
|
||||
@if (item.type === 'text' && getRawThinking(block)) {
|
||||
@if (!isCheckerBlock(block) && item.type === 'text' && getRawThinking(block)) {
|
||||
<div
|
||||
class="step-raw-thinking-message"
|
||||
[class.is-running]="isBlockRunning(block)">
|
||||
@@ -1093,6 +1149,26 @@ limitations under the License.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Check block result: one row per check item, keyed by its number above -->
|
||||
@if (hasCheckerResult(block)) {
|
||||
<div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)">
|
||||
@for (verdict of getCheckerVerdicts(block); track $index) {
|
||||
<div class="check-verdict" [ngClass]="verdict.status">
|
||||
<span class="check-index">{{ getVerdictItemNumber(block, verdict, $index) }}</span>
|
||||
<span class="material-symbols-outlined verdict-icon">{{ getVerdictIcon(verdict.status) }}</span>
|
||||
<span class="verdict-body">
|
||||
<span class="verdict-evidence">{{ verdict.evidence || getVerdictStatusText(verdict.status) }}</span>
|
||||
@if (verdict.suggestion) {
|
||||
<span class="verdict-suggestion">{{ verdict.suggestion }}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@for (note of getCheckerNotes(block); track note) {
|
||||
<div class="check-note">{{ note }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3558,5 +3558,162 @@ $stream-row-height: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Checker blocks share the step timeline, marked by a violet left border. */
|
||||
.agent-stream-container .stream-logs-content .phase-body .block-item.type-checker {
|
||||
border-left: 2px solid #ddd6fe;
|
||||
padding-left: 12px;
|
||||
margin-left: -14px;
|
||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif;
|
||||
|
||||
.check-index {
|
||||
flex: 0 0 14px;
|
||||
text-align: right;
|
||||
color: #a1a1aa;
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.check-lane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $uniform-gap;
|
||||
margin-bottom: $uniform-gap;
|
||||
|
||||
.check-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.2px;
|
||||
color: #71717a;
|
||||
user-select: none;
|
||||
|
||||
.stream-status-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
background: #d4d4d8;
|
||||
|
||||
&.streaming {
|
||||
background: #7c3aed;
|
||||
box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.45);
|
||||
animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.check-title {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.check-subject {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.check-status {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
color: #52525b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&.tone-passed .check-status { color: #15803d; }
|
||||
&.tone-failed .check-status { color: #b91c1c; }
|
||||
&.tone-inconclusive .check-status { color: #b45309; }
|
||||
&.tone-running .check-status { color: #6d28d9; }
|
||||
&.tone-muted { opacity: 0.6; }
|
||||
|
||||
.check-items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #3f3f46;
|
||||
|
||||
.check-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.check-method {
|
||||
flex: 0 0 auto;
|
||||
color: #a1a1aa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.check-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: $uniform-gap;
|
||||
padding-left: 16px;
|
||||
|
||||
.check-verdict {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #52525b;
|
||||
|
||||
.check-index {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.verdict-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
&.passed .verdict-icon { color: #16a34a; }
|
||||
&.failed .verdict-icon { color: #dc2626; }
|
||||
&.inconclusive .verdict-icon { color: #d97706; }
|
||||
&.superseded, &.unchecked { color: #a1a1aa; }
|
||||
|
||||
.verdict-body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.verdict-suggestion {
|
||||
display: block;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.check-note {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #71717a;
|
||||
padding-left: 22px;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,8 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
effect(() => {
|
||||
const blocks = this.consolidatedBlocks();
|
||||
blocks.forEach(block => {
|
||||
// Check blocks render their per-turn segments directly (no typewriter).
|
||||
if (block.type === 'checker') return;
|
||||
const blockId = block.id;
|
||||
const rawText = this.getRawThinking(block) || '';
|
||||
const nativeText = this.getNativeThinking(block) || '';
|
||||
@@ -1162,8 +1164,187 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return this.getThinkingTexts(block.data).raw;
|
||||
}
|
||||
|
||||
// --- Check blocks (verifier lane) ----------------------------------------------
|
||||
public isCheckerBlock(block: any): boolean {
|
||||
return block?.type === 'checker';
|
||||
}
|
||||
|
||||
public isCheckerPhase(phase: any): boolean {
|
||||
const blocks: any[] = Array.isArray(phase?.blocks) ? phase.blocks : [];
|
||||
return blocks.length > 0 && blocks.every((b) => this.isCheckerBlock(b));
|
||||
}
|
||||
|
||||
public getCheckerPhaseLabel(block: any): string {
|
||||
switch (block?.data?.phase) {
|
||||
case 'final':
|
||||
return 'Final check';
|
||||
case 'outcome':
|
||||
return 'Result';
|
||||
default:
|
||||
return 'Check';
|
||||
}
|
||||
}
|
||||
|
||||
public getCheckerVerdicts(block: any): any[] {
|
||||
return Array.isArray(block?.data?.verdicts) ? block.data.verdicts : [];
|
||||
}
|
||||
|
||||
/** Declared check items; persisted attempts only carry verdicts, so derive them. */
|
||||
public getCheckerItems(block: any): any[] {
|
||||
const d = block?.data || {};
|
||||
if (Array.isArray(d.items) && d.items.length > 0) return d.items;
|
||||
return this.getCheckerVerdicts(block).map((v) => ({ kind: v.kind, text: v.item_text, when: v.when }));
|
||||
}
|
||||
|
||||
/** How the item is judged, in plain words (verify = must pass, assert = recorded test result). */
|
||||
public getCheckMethodLabel(item: any, block: any): string {
|
||||
const parts: string[] = [item?.kind === 'assert' ? 'Test assertion' : 'Must pass'];
|
||||
if (item?.when === 'at_end' && block?.data?.phase !== 'final') parts.push('at the end');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Superseded / unchecked attempts collapse to their header line. */
|
||||
public isCheckerMuted(block: any): boolean {
|
||||
const status = block?.data?.status;
|
||||
return status === 'superseded' || status === 'unchecked';
|
||||
}
|
||||
|
||||
/** Number of the check item a verdict belongs to (1-based, matches the list above the stream). */
|
||||
public getVerdictItemNumber(block: any, verdict: any, fallbackIndex: number): number {
|
||||
const idx = this.getCheckerItems(block).findIndex((i) => i.text === verdict?.item_text);
|
||||
return (idx > -1 ? idx : fallbackIndex) + 1;
|
||||
}
|
||||
|
||||
public getVerdictStatusText(status: string): string {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'Passed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'inconclusive':
|
||||
return 'Could not be confirmed';
|
||||
case 'superseded':
|
||||
return 'Superseded by a newer check';
|
||||
case 'unchecked':
|
||||
return 'Not checked';
|
||||
default:
|
||||
return status || '';
|
||||
}
|
||||
}
|
||||
|
||||
public hasCheckerResult(block: any): boolean {
|
||||
if (!this.isCheckerBlock(block) || this.isBlockRunning(block) || this.isCheckerMuted(block)) return false;
|
||||
if (block.data?.phase === 'outcome') return this.getCheckerNotes(block).length > 0;
|
||||
return this.getCheckerVerdicts(block).length > 0 || this.getCheckerNotes(block).length > 0;
|
||||
}
|
||||
|
||||
/** Follow-ups not already visible in the verdict rows (the findings text repeats them). */
|
||||
public getCheckerNotes(block: any): string[] {
|
||||
const d = block?.data || {};
|
||||
if (d.phase === 'outcome') return Array.isArray(d.last_findings) ? d.last_findings : [];
|
||||
const notes: string[] = [];
|
||||
if (Array.isArray(d.unmet_subgoals)) {
|
||||
for (const text of d.unmet_subgoals) notes.push(`Not finished yet: ${text}`);
|
||||
}
|
||||
if (d.reverted) notes.push('The step was sent back to be redone.');
|
||||
if (d.error) notes.push(String(d.error));
|
||||
return notes;
|
||||
}
|
||||
|
||||
// Streamed turns of a check: the last one is live while the block runs;
|
||||
// finished Thought turns fold unless the reader opens them.
|
||||
public openCheckSegments = signal<Set<string>>(new Set<string>());
|
||||
|
||||
public isCheckerSegmentRunning(block: any, itemIdx: number): boolean {
|
||||
if (!this.isBlockRunning(block)) return false;
|
||||
return itemIdx === this.getSortedStepEvents(block.data).length - 1;
|
||||
}
|
||||
|
||||
public isCheckerSegmentCollapsed(block: any, itemIdx: number): boolean {
|
||||
if (this.isCheckerSegmentRunning(block, itemIdx)) return false;
|
||||
return !this.openCheckSegments().has(`${block.id}-${itemIdx}`);
|
||||
}
|
||||
|
||||
public toggleCheckerSegment(block: any, itemIdx: number): void {
|
||||
const key = `${block.id}-${itemIdx}`;
|
||||
this.openCheckSegments.update((set) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
public isCheckerTextVisible(item: any): boolean {
|
||||
const text = item?.data?.text;
|
||||
return typeof text === 'string' && text.trim().length > 0 && this.isHumanThinking(text);
|
||||
}
|
||||
|
||||
public getCheckerTone(block: any): string {
|
||||
const d = block?.data || {};
|
||||
if (d.phase === 'outcome') {
|
||||
return d.task_status === 'completed' ? 'passed' : (d.task_status === 'blocked' ? 'failed' : 'inconclusive');
|
||||
}
|
||||
if (d.isCompleted === false) return 'running';
|
||||
if (d.status === 'superseded' || d.status === 'unchecked') return 'muted';
|
||||
if (d.status === 'error') return 'inconclusive';
|
||||
const verdicts = this.getCheckerVerdicts(block);
|
||||
if (verdicts.some((v) => v.status === 'failed')) return 'failed';
|
||||
if (verdicts.some((v) => v.status === 'inconclusive')) return 'inconclusive';
|
||||
return 'passed';
|
||||
}
|
||||
|
||||
public getCheckerStatusLabel(block: any): string {
|
||||
const d = block?.data || {};
|
||||
if (d.phase === 'outcome') {
|
||||
const t = d.tests || {};
|
||||
const label = d.task_status === 'completed' ? 'Goal completed'
|
||||
: (d.task_status === 'blocked' ? 'Blocked' : 'Partially completed');
|
||||
const counts = [
|
||||
[t.passed, 'passed'],
|
||||
[t.failed, 'failed'],
|
||||
[t.inconclusive, 'inconclusive'],
|
||||
[t.unchecked, 'not checked']
|
||||
]
|
||||
.filter(([n]) => Number(n) > 0)
|
||||
.map(([n, word]) => `${n} ${word}`);
|
||||
return counts.length > 0 ? `${label} · ${counts.join(' · ')}` : label;
|
||||
}
|
||||
if (d.isCompleted === false) return 'Checking…';
|
||||
switch (d.status) {
|
||||
case 'superseded':
|
||||
return 'Superseded';
|
||||
case 'unchecked':
|
||||
return 'Not checked';
|
||||
case 'error':
|
||||
return 'No verdict';
|
||||
}
|
||||
const verdicts = this.getCheckerVerdicts(block);
|
||||
const failed = verdicts.filter((v) => v.status === 'failed').length;
|
||||
if (failed > 0) return `${failed} failed`;
|
||||
if (verdicts.some((v) => v.status === 'inconclusive')) return 'Inconclusive';
|
||||
return verdicts.length > 0 ? 'Passed' : 'Done';
|
||||
}
|
||||
|
||||
public getVerdictIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'check_circle';
|
||||
case 'failed':
|
||||
return 'cancel';
|
||||
case 'inconclusive':
|
||||
return 'help';
|
||||
case 'superseded':
|
||||
return 'history';
|
||||
case 'unchecked':
|
||||
return 'radio_button_unchecked';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
public hasVisibleContent(block: any): boolean {
|
||||
if (!block) return false;
|
||||
if (block.type === 'checker') return true;
|
||||
const hasNative = Boolean(this.getNativeThinking(block));
|
||||
const hasRaw = Boolean(this.getRawThinking(block));
|
||||
const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) =>
|
||||
@@ -1569,6 +1750,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public isBlockRunning(block: any): boolean {
|
||||
if (!block || !block.data) return false;
|
||||
if (!this.isCurrentSessionActive()) return false;
|
||||
if (block.type === 'checker') return block.data.isCompleted === false;
|
||||
if (this.isReportStatusAction(block.data.action_taken) || block.data?.status === 'completed' || block.data?.status === 'failed' || block.data?.status === 'cancelled') return false;
|
||||
if (block.data.isCompleted === false) return true;
|
||||
const blocks = this.consolidatedBlocks();
|
||||
|
||||
@@ -290,7 +290,7 @@ limitations under the License.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Verification Box (Pulled out into a minimalist framed card) -->
|
||||
<!-- Verification result -->
|
||||
@if (milestone.checks.length > 0) {
|
||||
<div class="milestone-checks-container">
|
||||
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
DEFAULT_EXPLORER_MODE,
|
||||
DEFAULT_VERIFICATION_LEVEL,
|
||||
EXPLORER_MODES,
|
||||
VERIFICATION_LEVELS,
|
||||
levelIndex,
|
||||
notchPercent
|
||||
} from './pro-tuning.model';
|
||||
|
||||
describe('pro tuning ladders', () => {
|
||||
it('keeps every notch id and label unique within its ladder', () => {
|
||||
for (const ladder of [VERIFICATION_LEVELS, EXPLORER_MODES]) {
|
||||
expect(new Set(ladder.map((l) => l.id)).size).toBe(ladder.length);
|
||||
expect(new Set(ladder.map((l) => l.label)).size).toBe(ladder.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('mirrors the backend wire values', () => {
|
||||
expect(VERIFICATION_LEVELS.map((l) => l.id)).toEqual(['off', 'final', 'checkpoints', 'strict']);
|
||||
expect(EXPLORER_MODES.map((l) => l.id)).toEqual(['flash', 'pro', 'ultra']);
|
||||
expect(VERIFICATION_LEVELS.map((l) => l.id)).toContain(DEFAULT_VERIFICATION_LEVEL);
|
||||
expect(EXPLORER_MODES.map((l) => l.id)).toContain(DEFAULT_EXPLORER_MODE);
|
||||
});
|
||||
|
||||
it('every level explains itself', () => {
|
||||
for (const level of [...VERIFICATION_LEVELS, ...EXPLORER_MODES]) {
|
||||
expect(level.tagline.length).toBeGreaterThan(10);
|
||||
expect(level.latency.length).toBeGreaterThan(0);
|
||||
expect(level.runs.length).toBeGreaterThan(0);
|
||||
expect(level.bestFor.length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves ids case-insensitively and falls back to the default', () => {
|
||||
expect(levelIndex(VERIFICATION_LEVELS, ' STRICT ', DEFAULT_VERIFICATION_LEVEL)).toBe(3);
|
||||
expect(levelIndex(VERIFICATION_LEVELS, 'nope', DEFAULT_VERIFICATION_LEVEL)).toBe(1);
|
||||
expect(levelIndex(EXPLORER_MODES, null, DEFAULT_EXPLORER_MODE)).toBe(0);
|
||||
});
|
||||
|
||||
it('maps notches onto the track', () => {
|
||||
expect(notchPercent(0, 4)).toBe(0);
|
||||
expect(notchPercent(3, 4)).toBe(100);
|
||||
expect(notchPercent(1, 3)).toBe(50);
|
||||
expect(notchPercent(9, 3)).toBe(100);
|
||||
expect(notchPercent(0, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Launcher options for `/api/run` (`verification_level`, `explorer_mode`).
|
||||
* Keep ids in sync with `VERIFICATION_LEVEL_PRESETS` in `artemis/config/agent.py`
|
||||
* and `EXPLORER_TIERS` in `artemis/agents/explorer/tiers.py`.
|
||||
*/
|
||||
|
||||
export type VerificationLevelId = 'off' | 'final' | 'checkpoints' | 'strict';
|
||||
export type ExplorerModeId = 'flash' | 'pro' | 'ultra';
|
||||
|
||||
/** One notch on a tuning slider. */
|
||||
export interface TuningLevel<TId extends string = string> {
|
||||
/** Wire value sent to the backend. */
|
||||
id: TId;
|
||||
/** Short name shown next to the slider title and as the hover card heading. */
|
||||
label: string;
|
||||
/** One-sentence summary shown in the hover card. */
|
||||
tagline: string;
|
||||
/** Plain-language time cost, e.g. "no extra time". */
|
||||
latency: string;
|
||||
/** Checks or searches performed at this level. */
|
||||
runs: string[];
|
||||
/** Checks or searches omitted at this level. */
|
||||
skips?: string[];
|
||||
/** When to pick this level. */
|
||||
bestFor: string;
|
||||
}
|
||||
|
||||
export const VERIFICATION_LEVELS: readonly TuningLevel<VerificationLevelId>[] = [
|
||||
{
|
||||
id: 'off',
|
||||
label: 'Off',
|
||||
tagline: 'No checking. The run ends as soon as the task looks done.',
|
||||
latency: 'no extra time',
|
||||
runs: [
|
||||
'Each step is treated as finished the moment it is carried out.',
|
||||
'You get the full action trace, but no pass / fail verdict.'
|
||||
],
|
||||
skips: ['Nothing is double-checked and nothing is retried.'],
|
||||
bestFor: 'Quick tries and demos, when you only want to watch what happens.'
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
label: 'At the end',
|
||||
tagline: 'One check of the finished result against your goal. This is the default.',
|
||||
latency: 'adds about 20–60 s at the end',
|
||||
runs: [
|
||||
'When the task finishes, the final screen, the step history and the device state are compared with what you asked for.',
|
||||
'If the result does not match, the task goes back and tries to fix it, up to 3 times.'
|
||||
],
|
||||
skips: ['Nothing is checked while the task is still running.'],
|
||||
bestFor: 'Everyday tasks: an honest pass / fail without slowing the run down.'
|
||||
},
|
||||
{
|
||||
id: 'checkpoints',
|
||||
label: 'Every step',
|
||||
tagline: 'Each step is checked as soon as it is done, plus the final check.',
|
||||
latency: 'a short check after each step, done in the background',
|
||||
runs: [
|
||||
'Every step is checked right after it completes, using the screenshots from that moment.',
|
||||
'If a step went wrong, it gets fixed before moving on (up to 2 tries per step).',
|
||||
'A failed test condition is written down and the task keeps going.',
|
||||
'The final check still runs at the end.'
|
||||
],
|
||||
bestFor: 'Long tasks where one early mistake would spoil everything after it.'
|
||||
},
|
||||
{
|
||||
id: 'strict',
|
||||
label: 'Strict',
|
||||
tagline: 'Every step is checked, with more retries. The first failed test stops the run.',
|
||||
latency: 'slowest: more checks and more retries',
|
||||
runs: [
|
||||
'Each check takes longer and gets more attempts: 4 fixes per step and 5 at the end.',
|
||||
'The first failed test condition stops the run immediately, with the evidence attached.'
|
||||
],
|
||||
bestFor: 'Release checks and regression runs, where a wrong pass is never acceptable.'
|
||||
}
|
||||
];
|
||||
|
||||
export const EXPLORER_MODES: readonly TuningLevel<ExplorerModeId>[] = [
|
||||
{
|
||||
id: 'flash',
|
||||
label: 'Quick glance',
|
||||
tagline: 'Finds buttons and text on the screen in a single look.',
|
||||
latency: '1 look per search',
|
||||
runs: [
|
||||
'Something on screen is asked for by name, icon or colour and its position comes back straight away.',
|
||||
'Several things can be looked up at once.'
|
||||
],
|
||||
skips: ['No zooming in and no second try.'],
|
||||
bestFor: 'Ordinary apps with clearly labelled buttons, icons and text.'
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
label: 'Second look',
|
||||
tagline: 'Takes up to 3 looks, thinking in between, before answering.',
|
||||
latency: 'up to 3 looks per search',
|
||||
runs: [
|
||||
'The screen layout is read first, then the picture is searched.',
|
||||
'If the first try misses, a different approach is tried within the 3 looks.'
|
||||
],
|
||||
skips: ['Still no zooming into small areas, to keep searches short.'],
|
||||
bestFor: 'Things described by where they are ("the switch next to Wi-Fi") or with unclear labels.'
|
||||
},
|
||||
{
|
||||
id: 'ultra',
|
||||
label: 'Close-up',
|
||||
tagline: 'Zooms into parts of the screen and takes up to 8 looks.',
|
||||
latency: 'up to 8 looks per search (slowest)',
|
||||
runs: [
|
||||
'Parts of the screen can be cropped and magnified to read tiny text and crowded layouts piece by piece.',
|
||||
'Later looks reuse the earlier ones, so they cost less time than they sound.'
|
||||
],
|
||||
bestFor: 'Crowded screens, tiny targets, charts and drawings, and checks where exact placement matters.'
|
||||
}
|
||||
];
|
||||
|
||||
/** Per-run tuning sent with `/api/run` for the Pro profile. */
|
||||
export interface ProTuningOptions {
|
||||
verificationLevel?: VerificationLevelId | string;
|
||||
explorerMode?: ExplorerModeId | string;
|
||||
}
|
||||
|
||||
/** Effective defaults reported by `GET /api/run/defaults`. */
|
||||
export interface ProTuningDefaults {
|
||||
verification_level?: string | null;
|
||||
explorer_mode?: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_VERIFICATION_LEVEL: VerificationLevelId = 'final';
|
||||
export const DEFAULT_EXPLORER_MODE: ExplorerModeId = 'flash';
|
||||
|
||||
/** Index of a level id within its ladder; falls back to the default when unknown. */
|
||||
export function levelIndex<TId extends string>(
|
||||
ladder: readonly TuningLevel<TId>[],
|
||||
id: string | null | undefined,
|
||||
fallback: TId
|
||||
): number {
|
||||
const wanted = String(id ?? '').trim().toLowerCase();
|
||||
const idx = ladder.findIndex((l) => l.id === wanted);
|
||||
if (idx >= 0) return idx;
|
||||
return Math.max(0, ladder.findIndex((l) => l.id === fallback));
|
||||
}
|
||||
|
||||
/** Slider fill percentage for a notch index on a ladder of `count` notches. */
|
||||
export function notchPercent(index: number, count: number): number {
|
||||
if (count <= 1) return 0;
|
||||
const clamped = Math.min(Math.max(index, 0), count - 1);
|
||||
return (clamped / (count - 1)) * 100;
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export interface StepItemData {
|
||||
|
||||
export interface StepBlock {
|
||||
id: string;
|
||||
type: 'llm_stream' | 'step';
|
||||
type: 'llm_stream' | 'step' | 'checker';
|
||||
timestamp: string;
|
||||
data: any;
|
||||
}
|
||||
@@ -93,6 +93,75 @@ export interface PhaseBlock {
|
||||
blocks: StepBlock[];
|
||||
}
|
||||
|
||||
/** One check item's verdict as booked by the Checker (ledger record shape). */
|
||||
export interface CheckerVerdict {
|
||||
item_text: string;
|
||||
kind: 'verify' | 'assert' | string;
|
||||
status: 'passed' | 'failed' | 'inconclusive' | 'superseded' | 'unchecked' | string;
|
||||
evidence: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface CheckerCheckItem {
|
||||
kind: string;
|
||||
text: string;
|
||||
when?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One streamed LLM turn of a multi-turn agent (the Checker's tool loop):
|
||||
* the text of one execution id with the time its first chunk arrived, so the
|
||||
* timeline can interleave it with the tool calls that followed.
|
||||
*/
|
||||
export interface StreamSegment {
|
||||
execution_id: string;
|
||||
stream_type: 'thinking' | 'text';
|
||||
text: string;
|
||||
timestamp: string;
|
||||
isCompleted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data of a `checker` timeline block: one Checker attempt (a midway checkpoint
|
||||
* of a completed subgoal or the exit final review), or the run outcome
|
||||
* (`phase: 'outcome'`). Streamed reasoning and tool traces land in the same
|
||||
* `operator_*_thinking` / `generic_tools` fields as an Operator step so the
|
||||
* timeline renders them with one code path.
|
||||
*/
|
||||
export interface CheckerBlockData {
|
||||
event?: string;
|
||||
attempt_id?: string;
|
||||
checkpoint_id?: string;
|
||||
phase: 'checkpoint' | 'final' | 'outcome' | string;
|
||||
subgoal_text?: string;
|
||||
status?: 'running' | 'done' | 'superseded' | 'unchecked' | 'error' | string;
|
||||
trace_id?: string | null;
|
||||
anchor_step_id?: string | null;
|
||||
items?: CheckerCheckItem[];
|
||||
verdicts?: CheckerVerdict[];
|
||||
findings?: string[];
|
||||
unmet_subgoals?: string[];
|
||||
reverted?: boolean;
|
||||
applicable?: boolean;
|
||||
repairs_used?: number;
|
||||
route?: string;
|
||||
error?: string;
|
||||
task_status?: 'completed' | 'partial' | 'blocked' | string;
|
||||
tests?: { passed: number; failed: number; inconclusive: number; unchecked: number };
|
||||
last_findings?: string[];
|
||||
started_at?: number;
|
||||
finished_at?: number;
|
||||
duration?: number;
|
||||
isCompleted?: boolean;
|
||||
generic_tools?: any[];
|
||||
/** Per-turn stream segments (live sessions only); the flat fields below hold the joined text. */
|
||||
stream_segments?: StreamSegment[];
|
||||
operator_native_thinking?: string;
|
||||
operator_raw_thinking?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** @deprecated legacy JSON-in-stream checker verdict (pre-ledger); kept for the parser. */
|
||||
export interface CheckerResult {
|
||||
success: boolean;
|
||||
reason: string;
|
||||
|
||||
@@ -580,7 +580,7 @@
|
||||
<span class="provider-dot"></span>
|
||||
<span class="provider-name">{{ modelConfigEnv()?.default_model?.provider || 'google' }}</span>
|
||||
<span class="model-divider">/</span>
|
||||
<span class="model-name">{{ modelConfigEnv()?.default_model?.model || 'gemini-3.7-flash' }}</span>
|
||||
<span class="model-name">{{ modelConfigEnv()?.default_model?.model || 'gemini-3.8-flash' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1761,14 +1761,14 @@
|
||||
<p class="desc-line">
|
||||
<span class="mode-name">Flash</span>
|
||||
<span class="bullet">·</span>
|
||||
<span>Token-efficient, rapid <strong>3–5s/step</strong> reactive decisions. Best for routine daily tasks within <strong>25 steps</strong>.</span>
|
||||
<span class="limitation-tag">(No long-term monitoring or video analysis)</span>
|
||||
<span>Token-efficient, rapid <strong>3–5s/step</strong> decisions straight from the live screen. Best for everyday tasks with a clear path, however long they run.</span>
|
||||
<span class="limitation-tag">(No plan, no result check, no ADB commands)</span>
|
||||
</p>
|
||||
} @else {
|
||||
<p class="desc-line">
|
||||
<span class="mode-name">Pro</span>
|
||||
<span class="bullet">·</span>
|
||||
<span>Our most powerful agent architecture. Handles <strong>100+ step</strong> workflows and direct phone state monitoring (<strong>20–40s</strong> / step).</span>
|
||||
<span>Our most powerful mode. Plans first, checks its own results and recovers when a tap is blocked. Handles <strong>100+ step</strong> workflows and live phone monitoring (<strong>15–40s</strong> / step).</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -1792,7 +1792,7 @@
|
||||
class="model-toggle-btn btn-flash"
|
||||
[class.active]="selectedProfile() === 'flash'"
|
||||
(click)="setProfile('flash')"
|
||||
title="Flash: Reactive fast-loop architecture for standard app workflows (3–5s/step)"
|
||||
title="Flash: fast single-look loop for everyday app tasks (3–5s/step)"
|
||||
>
|
||||
<span class="material-symbols-outlined btn-icon">bolt</span>
|
||||
<span class="btn-text">Flash</span>
|
||||
@@ -1802,7 +1802,7 @@
|
||||
class="model-toggle-btn btn-pro"
|
||||
[class.active]="selectedProfile() === 'pro'"
|
||||
(click)="setProfile('pro')"
|
||||
title="Pro: Multi-agent graph architecture with planner & closed-loop verification for complex tasks"
|
||||
title="Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15–40s/step)"
|
||||
>
|
||||
<span class="material-symbols-outlined btn-icon">psychology</span>
|
||||
<span class="btn-text">Pro</span>
|
||||
@@ -1863,6 +1863,120 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pro Mode Tuning Card: verification depth & explorer perception sliders -->
|
||||
<div class="pro-tuning-card" [class.tip-open]="activeTuningTip() !== null">
|
||||
<div class="tuning-card-header">
|
||||
<span class="tuning-title">Pro settings</span>
|
||||
@if (!isTuningDefault()) {
|
||||
<button type="button" class="btn-tuning-reset" (click)="resetTuning()" title="Back to the defaults">Reset</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tuning-sliders">
|
||||
@for (slider of tuningSliders(); track slider.kind) {
|
||||
<div
|
||||
class="tuning-slider"
|
||||
[class.verify]="slider.kind === 'verify'"
|
||||
[class.explore]="slider.kind === 'explore'"
|
||||
[class.dragging]="activeTuningTip() === slider.kind"
|
||||
[style.--pct]="slider.fraction"
|
||||
>
|
||||
<div class="slider-head">
|
||||
<span class="slider-name">{{ slider.name }}</span>
|
||||
<span class="slider-value">{{ slider.level.label }}</span>
|
||||
</div>
|
||||
<div class="slider-ends" aria-hidden="true">
|
||||
<span>{{ slider.ends[0] }}</span>
|
||||
<span>{{ slider.ends[1] }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="slider-track-wrap"
|
||||
(pointerenter)="showTuningTip(slider.kind)"
|
||||
(pointerleave)="hideTuningTip(slider.kind)"
|
||||
>
|
||||
<div class="slider-rail" aria-hidden="true">
|
||||
<div class="slider-fill"></div>
|
||||
@for (lvl of slider.ladder; track lvl.id; let i = $index) {
|
||||
<span
|
||||
class="slider-notch"
|
||||
[class.reached]="i <= slider.index"
|
||||
[style.--n]="notchFraction(i, slider.ladder.length)"
|
||||
></span>
|
||||
}
|
||||
@if (slider.index === slider.ladder.length - 1) {
|
||||
<!-- Re-created each time the thumb reaches the end, so the spread replays. -->
|
||||
<div class="slider-solid"></div>
|
||||
<svg class="slider-dither" width="100%" height="6" aria-hidden="true">
|
||||
@for (px of ditherPixels; track $index) {
|
||||
<rect
|
||||
[attr.x]="px.x + '%'"
|
||||
[attr.y]="px.y"
|
||||
width="2"
|
||||
height="2"
|
||||
[style.opacity]="px.opacity"
|
||||
[style.animation-delay.ms]="px.delay"
|
||||
/>
|
||||
}
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
<div class="slider-thumb" aria-hidden="true"></div>
|
||||
@if (slider.index === slider.ladder.length - 1) {
|
||||
<span class="thumb-ripple" aria-hidden="true"></span>
|
||||
}
|
||||
<input
|
||||
type="range"
|
||||
class="slider-input"
|
||||
min="0"
|
||||
[max]="slider.ladder.length - 1"
|
||||
step="1"
|
||||
[value]="slider.index"
|
||||
(input)="setTuningIndex(slider.kind, $any($event.target).value)"
|
||||
(focus)="showTuningTip(slider.kind)"
|
||||
(blur)="hideTuningTip(slider.kind)"
|
||||
[attr.aria-label]="slider.name"
|
||||
[attr.aria-valuetext]="slider.level.label + ': ' + slider.level.tagline"
|
||||
/>
|
||||
|
||||
@if (activeTuningTip() === slider.kind) {
|
||||
<div class="tuning-tip-arrow" aria-hidden="true"></div>
|
||||
<div class="tuning-tip" role="tooltip">
|
||||
<div class="tip-heading">
|
||||
<span class="tip-title">{{ slider.level.label }}</span>
|
||||
<span class="tip-latency">{{ slider.level.latency }}</span>
|
||||
</div>
|
||||
<p class="tip-tagline">{{ slider.level.tagline }}</p>
|
||||
<div class="tip-section">
|
||||
<span class="tip-section-title">What happens</span>
|
||||
<ul>
|
||||
@for (line of slider.level.runs; track $index) {
|
||||
<li>{{ line }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@if (slider.level.skips?.length) {
|
||||
<div class="tip-section skips">
|
||||
<span class="tip-section-title">Not included</span>
|
||||
<ul>
|
||||
@for (line of slider.level.skips; track $index) {
|
||||
<li>{{ line }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
<div class="tip-best">
|
||||
<span class="tip-section-title">Use it for</span>
|
||||
<span>{{ slider.level.bestFor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Recommended Tasks Hub (Clean & Direct) -->
|
||||
|
||||
@@ -4272,6 +4272,381 @@ $text-light: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Pro tuning sliders and hover cards */
|
||||
.pro-tuning-card {
|
||||
--tip-w: 320px;
|
||||
--thumb-w: 22px;
|
||||
--thumb-h: 14px;
|
||||
--col-gap: 28px;
|
||||
--accent: #6366f1;
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 16px;
|
||||
padding: 12px 18px 14px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 0 8px 24px -6px rgba(15, 23, 42, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
animation: slideUpFade 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.tip-open {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.tuning-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 16px;
|
||||
|
||||
.tuning-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.btn-tuning-reset {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tuning-sliders {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px var(--col-gap);
|
||||
}
|
||||
|
||||
.tuning-slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
/* Hover card clamp range, relative to this slider's track. The left
|
||||
column may spill right across the gap; the right column may spill
|
||||
left. Overridden below when the columns stack. */
|
||||
--tip-min: 0px;
|
||||
--tip-max: calc(100% - var(--tip-w));
|
||||
|
||||
&.verify {
|
||||
--tip-max: calc(200% + var(--col-gap) - var(--tip-w));
|
||||
}
|
||||
|
||||
&.explore {
|
||||
--accent: #0ea5e9;
|
||||
--tip-min: calc(-100% - var(--col-gap));
|
||||
}
|
||||
|
||||
.slider-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: #0f172a;
|
||||
|
||||
.slider-name {
|
||||
font-weight: 400;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-ends {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11.5px;
|
||||
line-height: 14px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.slider-track-wrap {
|
||||
position: relative;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.slider-rail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--accent) 24%, #e2e8f0);
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
|
||||
.slider-notch {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(var(--n) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: background 0.15s ease;
|
||||
z-index: 1;
|
||||
|
||||
&.reached {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
|
||||
width: var(--thumb-w);
|
||||
height: var(--thumb-h);
|
||||
border-radius: 5px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.08);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
transition: left 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* End of track: a pixel front sweeps from the thumb to the left end,
|
||||
a solid fill follows it until the whole rail has changed colour,
|
||||
and the thumb emits one ripple. */
|
||||
.slider-solid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
transform-origin: right center;
|
||||
transform: scaleX(1);
|
||||
animation: solidSweep 1s cubic-bezier(0.4, 0, 0.2, 1) 0.2s both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-dither {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
|
||||
rect {
|
||||
fill: var(--accent);
|
||||
animation: ditherIn 0.3s ease-out both;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb-ripple {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
|
||||
width: var(--thumb-w);
|
||||
height: var(--thumb-w);
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--accent);
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: thumbRipple 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
&.dragging .slider-thumb {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.12),
|
||||
0 2px 8px rgba(15, 23, 42, 0.1),
|
||||
0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* Native range input does drag, keyboard, and a11y; it is invisible
|
||||
but its thumb geometry matches the drawn thumb so hits line up. */
|
||||
.slider-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: var(--thumb-w);
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: var(--thumb-w);
|
||||
height: 26px;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-input:focus-visible ~ .slider-thumb,
|
||||
&:has(.slider-input:focus-visible) .slider-thumb {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.12),
|
||||
0 0 0 2px #ffffff,
|
||||
0 0 0 4px var(--accent);
|
||||
}
|
||||
|
||||
.tuning-tip-arrow {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 2px);
|
||||
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid rgba(226, 232, 240, 1);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 1);
|
||||
transform: translate(-50%, 50%) rotate(45deg);
|
||||
z-index: 41;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tuning-tip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 7px);
|
||||
left: clamp(
|
||||
var(--tip-min),
|
||||
calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2 - var(--tip-w) / 2),
|
||||
var(--tip-max)
|
||||
);
|
||||
width: var(--tip-w);
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 12px 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(226, 232, 240, 1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.06);
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
animation: tipPop 0.16s ease-out;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.tip-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
|
||||
.tip-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tip-latency {
|
||||
font-size: 11.5px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.tip-tagline {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tip-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
&.skips li {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.tip-section-title {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tip-best {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.tuning-sliders {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tuning-slider {
|
||||
&.verify,
|
||||
&.explore {
|
||||
--tip-min: 0px;
|
||||
--tip-max: calc(100% - var(--tip-w));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Smart Suggestions Hub (Device-Aware, Flash vs Pro Tailored) */
|
||||
.smart-suggestions-container {
|
||||
display: flex;
|
||||
@@ -4733,7 +5108,47 @@ $text-light: #94a3b8;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-container .aurora-background .glow,
|
||||
.sparkle-icon,
|
||||
.status-dot {
|
||||
.status-dot,
|
||||
.slider-dither rect,
|
||||
.slider-solid,
|
||||
.thumb-ripple {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ditherIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes solidSweep {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thumbRipple {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(2.6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tipPop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
@@ -32,6 +31,69 @@ import {
|
||||
SuggestionCategory
|
||||
} from '../../core/data/smart-tasks.data';
|
||||
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
|
||||
import {
|
||||
DEFAULT_EXPLORER_MODE,
|
||||
DEFAULT_VERIFICATION_LEVEL,
|
||||
EXPLORER_MODES,
|
||||
ExplorerModeId,
|
||||
TuningLevel,
|
||||
VERIFICATION_LEVELS,
|
||||
VerificationLevelId,
|
||||
levelIndex,
|
||||
notchPercent
|
||||
} from '../../core/models/pro-tuning.model';
|
||||
|
||||
export type TuningKind = 'verify' | 'explore';
|
||||
|
||||
/** One 2x2 px square of the "maxed out" dither texture drawn over a slider rail. */
|
||||
export interface DitherPixel {
|
||||
/** Horizontal position as a percentage of the rail width. */
|
||||
x: number;
|
||||
/** Row offset in px (rail is 6 px tall, three 2 px rows). */
|
||||
y: number;
|
||||
/** Resting opacity; squares near the thumb are stronger. */
|
||||
opacity: number;
|
||||
/** Animation delay in ms so the texture spreads leftwards from the thumb. */
|
||||
delay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded pixels keep the slider texture stable across renders.
|
||||
* Delays increase with distance from the thumb to animate from right to left.
|
||||
*/
|
||||
function buildDitherPixels(count = 260, seed = 7): DitherPixel[] {
|
||||
let state = seed >>> 0;
|
||||
const rand = (): number => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
const pixels: DitherPixel[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = rand() * 100;
|
||||
const y = Math.floor(rand() * 3) * 2;
|
||||
pixels.push({
|
||||
x: Math.round(x * 10) / 10,
|
||||
y,
|
||||
opacity: Math.round((0.35 + 0.4 * rand()) * 100) / 100,
|
||||
// 8 ms per percent: the front takes ~0.8 s to reach the left end.
|
||||
delay: Math.round((100 - x) * 8)
|
||||
});
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
/** Shared view model for the verification and screen-reading sliders. */
|
||||
export interface TuningSliderVm {
|
||||
kind: TuningKind;
|
||||
name: string;
|
||||
/** One-word meaning of each end of the track, e.g. ["Off", "Strict"]. */
|
||||
ends: readonly [string, string];
|
||||
ladder: readonly TuningLevel[];
|
||||
index: number;
|
||||
level: TuningLevel;
|
||||
/** 0..1 position of the thumb along the track. */
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
export type { AppReference, SmartSuggestion, SuggestionCategory };
|
||||
|
||||
@@ -139,6 +201,118 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
public enableOutputter = signal<boolean>(true);
|
||||
public showOutputterDrawer = signal<boolean>(false);
|
||||
|
||||
// Slider indexes map to the API ids in VERIFICATION_LEVELS / EXPLORER_MODES.
|
||||
public readonly verificationLevels = VERIFICATION_LEVELS;
|
||||
public readonly explorerModes = EXPLORER_MODES;
|
||||
public verificationIndex = signal<number>(
|
||||
levelIndex(VERIFICATION_LEVELS, DEFAULT_VERIFICATION_LEVEL, DEFAULT_VERIFICATION_LEVEL)
|
||||
);
|
||||
public explorerIndex = signal<number>(
|
||||
levelIndex(EXPLORER_MODES, DEFAULT_EXPLORER_MODE, DEFAULT_EXPLORER_MODE)
|
||||
);
|
||||
/** Effective defaults from the backend config (`GET /api/run/defaults`). */
|
||||
private tuningDefaults = signal<{ verification: VerificationLevelId; explorer: ExplorerModeId }>({
|
||||
verification: DEFAULT_VERIFICATION_LEVEL,
|
||||
explorer: DEFAULT_EXPLORER_MODE
|
||||
});
|
||||
/** True once the user moved a slider; backend defaults then stop overriding it. */
|
||||
private tuningTouched = signal<boolean>(false);
|
||||
/** Which slider's hover card is open (while hovering, dragging, or focused). */
|
||||
public activeTuningTip = signal<TuningKind | null>(null);
|
||||
/** Pixel cloud drawn over a rail once its slider reaches the last notch. */
|
||||
public readonly ditherPixels: readonly DitherPixel[] = buildDitherPixels();
|
||||
|
||||
public verificationLevel = computed<TuningLevel<VerificationLevelId>>(
|
||||
() => VERIFICATION_LEVELS[this.verificationIndex()]
|
||||
);
|
||||
public explorerMode = computed<TuningLevel<ExplorerModeId>>(
|
||||
() => EXPLORER_MODES[this.explorerIndex()]
|
||||
);
|
||||
public isTuningDefault = computed<boolean>(() => {
|
||||
const d = this.tuningDefaults();
|
||||
return this.verificationLevel().id === d.verification && this.explorerMode().id === d.explorer;
|
||||
});
|
||||
public tuningSliders = computed<TuningSliderVm[]>(() => {
|
||||
const vi = this.verificationIndex();
|
||||
const ei = this.explorerIndex();
|
||||
return [
|
||||
{
|
||||
kind: 'verify',
|
||||
name: 'Result check',
|
||||
ends: ['Off', 'Strict'],
|
||||
ladder: VERIFICATION_LEVELS,
|
||||
index: vi,
|
||||
level: VERIFICATION_LEVELS[vi],
|
||||
fraction: notchPercent(vi, VERIFICATION_LEVELS.length) / 100
|
||||
},
|
||||
{
|
||||
kind: 'explore',
|
||||
name: 'Screen reading',
|
||||
ends: ['Faster', 'Sharper'],
|
||||
ladder: EXPLORER_MODES,
|
||||
index: ei,
|
||||
level: EXPLORER_MODES[ei],
|
||||
fraction: notchPercent(ei, EXPLORER_MODES.length) / 100
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
public notchFraction(index: number, count: number): number {
|
||||
return notchPercent(index, count) / 100;
|
||||
}
|
||||
|
||||
public setTuningIndex(kind: TuningKind, raw: number | string): void {
|
||||
const idx = Math.round(Number(raw));
|
||||
if (!Number.isFinite(idx)) return;
|
||||
const ladder = kind === 'verify' ? VERIFICATION_LEVELS : EXPLORER_MODES;
|
||||
const clamped = Math.min(Math.max(idx, 0), ladder.length - 1);
|
||||
this.tuningTouched.set(true);
|
||||
if (kind === 'verify') {
|
||||
this.verificationIndex.set(clamped);
|
||||
} else {
|
||||
this.explorerIndex.set(clamped);
|
||||
}
|
||||
// Keyboard nudges and drags should keep the explanation visible.
|
||||
this.activeTuningTip.set(kind);
|
||||
}
|
||||
|
||||
public showTuningTip(kind: TuningKind): void {
|
||||
this.activeTuningTip.set(kind);
|
||||
}
|
||||
|
||||
public hideTuningTip(kind: TuningKind): void {
|
||||
if (this.activeTuningTip() === kind) {
|
||||
this.activeTuningTip.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
public resetTuning(): void {
|
||||
const d = this.tuningDefaults();
|
||||
this.verificationIndex.set(levelIndex(VERIFICATION_LEVELS, d.verification, DEFAULT_VERIFICATION_LEVEL));
|
||||
this.explorerIndex.set(levelIndex(EXPLORER_MODES, d.explorer, DEFAULT_EXPLORER_MODE));
|
||||
this.tuningTouched.set(false);
|
||||
}
|
||||
|
||||
/** Pull the effective config defaults so the sliders start where artemis.jsonc is. */
|
||||
private loadProTuningDefaults(): void {
|
||||
this.agentService.getProTuningDefaults().subscribe({
|
||||
next: (res) => {
|
||||
const vIdx = levelIndex(VERIFICATION_LEVELS, res?.verification_level, DEFAULT_VERIFICATION_LEVEL);
|
||||
const eIdx = levelIndex(EXPLORER_MODES, res?.explorer_mode, DEFAULT_EXPLORER_MODE);
|
||||
this.tuningDefaults.set({
|
||||
verification: VERIFICATION_LEVELS[vIdx].id,
|
||||
explorer: EXPLORER_MODES[eIdx].id
|
||||
});
|
||||
if (!this.tuningTouched()) {
|
||||
this.verificationIndex.set(vIdx);
|
||||
this.explorerIndex.set(eIdx);
|
||||
}
|
||||
},
|
||||
// Defaults are a convenience; the built-in ladder defaults already apply.
|
||||
error: () => undefined
|
||||
});
|
||||
}
|
||||
|
||||
public toggleOutputterDrawer(): void {
|
||||
this.showOutputterDrawer.update((v) => !v);
|
||||
}
|
||||
@@ -362,6 +536,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.loadProTuningDefaults();
|
||||
// Initial fetch of system readiness & model configuration
|
||||
this.systemService.fetchReadiness().subscribe();
|
||||
this.systemService.fetchModelConfigEnv().subscribe();
|
||||
@@ -913,7 +1089,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
|
||||
? this.expectedOutput().trim()
|
||||
: undefined,
|
||||
this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined
|
||||
this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined,
|
||||
this.selectedProfile() === 'pro'
|
||||
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
|
||||
: undefined
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
|
||||
@@ -105,8 +105,8 @@ limitations under the License.
|
||||
rows="1"
|
||||
[disabled]="isSubmitting()"
|
||||
[placeholder]="selectedProfile() === 'pro'
|
||||
? 'Pro: Multi-agent cognitive graph architecture for complex multi-step tasks'
|
||||
: 'Flash: Reactive fast loop for standard app workflows within 25 steps'"
|
||||
? 'Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks'
|
||||
: 'Flash: fast single-look loop for everyday app tasks'"
|
||||
class="dock-textarea"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
@@ -155,13 +155,13 @@ limitations under the License.
|
||||
<div class="model-profile-toggle" role="group" aria-label="Select Agent Architecture">
|
||||
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'flash'"
|
||||
(mousedown)="$event.preventDefault()" (click)="setProfile('flash', $event)"
|
||||
title="Flash: Reactive fast-loop architecture for standard app workflows (3–5s/step)">
|
||||
title="Flash: fast single-look loop for everyday app tasks (3–5s/step)">
|
||||
<span class="material-symbols-outlined pill-icon icon-flash">bolt</span>
|
||||
<span class="pill-label">Flash</span>
|
||||
</button>
|
||||
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'pro'"
|
||||
(mousedown)="$event.preventDefault()" (click)="setProfile('pro', $event)"
|
||||
title="Pro: Multi-agent cognitive graph architecture with planner & closed-loop verification for complex tasks">
|
||||
title="Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15–40s/step)">
|
||||
<span class="material-symbols-outlined pill-icon icon-pro">psychology</span>
|
||||
<span class="pill-label">Pro</span>
|
||||
</button>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse } from '../core/models/session.model';
|
||||
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
||||
import { StepItemData, StepReplayFrame } from '../core/models/stream.model';
|
||||
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
||||
export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame };
|
||||
@@ -347,7 +348,7 @@ export class AgentService {
|
||||
|
||||
// Streaming chunks are coalesced into one signal update per short window so a
|
||||
// chatty LLM stream cannot force a full recompute pass per SSE chunk.
|
||||
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; streamType: string; chunk: string }>();
|
||||
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; parentTraceId?: any; streamType: string; chunk: string }>();
|
||||
private streamFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastQueueSignature: string | null = null;
|
||||
private lastActiveTasksSignature: string | null = null;
|
||||
@@ -378,6 +379,14 @@ export class AgentService {
|
||||
return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
|
||||
});
|
||||
|
||||
/**
|
||||
* Effective Pro tuning defaults (verification level, explorer mode) from the
|
||||
* backend agent config, so the launcher sliders start where the config is.
|
||||
*/
|
||||
public getProTuningDefaults(): Observable<ProTuningDefaults> {
|
||||
return this.http.get<ProTuningDefaults>('/api/run/defaults');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a new task by submitting to backend queue
|
||||
*/
|
||||
@@ -385,7 +394,8 @@ export class AgentService {
|
||||
goal: string,
|
||||
profile: string = 'flash',
|
||||
expectedOutput?: string,
|
||||
enableOutputter?: boolean
|
||||
enableOutputter?: boolean,
|
||||
proTuning?: ProTuningOptions
|
||||
): Observable<any> {
|
||||
return new Observable((obs) => {
|
||||
const submittedEvent: StartupProgressEvent = {
|
||||
@@ -401,6 +411,12 @@ export class AgentService {
|
||||
if (enableOutputter !== undefined) {
|
||||
payload.enable_outputter = enableOutputter;
|
||||
}
|
||||
if (proTuning?.verificationLevel) {
|
||||
payload.verification_level = proTuning.verificationLevel;
|
||||
}
|
||||
if (proTuning?.explorerMode) {
|
||||
payload.explorer_mode = proTuning.explorerMode;
|
||||
}
|
||||
this.clearUserPinnedSession();
|
||||
this.http.post<any>('/api/run', payload).subscribe({
|
||||
next: (res) => {
|
||||
@@ -702,6 +718,7 @@ export class AgentService {
|
||||
this.isPaused.set(false);
|
||||
this.pausedError.set(null);
|
||||
this.fetchNotes(sessionId);
|
||||
this.fetchChecks(sessionId);
|
||||
this.updateModelForCurrentView();
|
||||
|
||||
// If video window is currently open, dynamically sync/refresh video for new session
|
||||
@@ -709,7 +726,7 @@ export class AgentService {
|
||||
this.openVideoPlayer(sessionId);
|
||||
}
|
||||
|
||||
console.log(`Selecting session: ${sessionId}`);
|
||||
console.debug(`Selecting session: ${sessionId}`);
|
||||
// Start reading the persisted snapshot immediately
|
||||
this.backfillSessionSteps(sessionId, loadGeneration);
|
||||
this.ensureLiveStream();
|
||||
@@ -724,7 +741,7 @@ export class AgentService {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Establishing persistent unified live stream via /api/stream');
|
||||
console.debug('Establishing persistent unified live stream via /api/stream');
|
||||
// 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.
|
||||
@@ -756,7 +773,8 @@ export class AgentService {
|
||||
'session_started',
|
||||
'session_ended',
|
||||
'recording_ready',
|
||||
'recording_failed'
|
||||
'recording_failed',
|
||||
'checker_event'
|
||||
];
|
||||
|
||||
eventTypes.forEach((eventType) => {
|
||||
@@ -843,6 +861,11 @@ export class AgentService {
|
||||
this.fetchStatus();
|
||||
if (endedId) {
|
||||
this.fetchNotes(endedId);
|
||||
if (String(endedId) === String(this.currentSessionId() || '')) {
|
||||
// The exit final review / run outcome may have landed while the
|
||||
// stream was reconnecting: reconcile from the persisted ledger.
|
||||
this.fetchChecks(endedId);
|
||||
}
|
||||
if (
|
||||
this.isVideoWindowOpen()
|
||||
&& this.activeVideoSessionId === endedId
|
||||
@@ -1010,14 +1033,19 @@ export class AgentService {
|
||||
private queueStreamChunk(parsedData: any): void {
|
||||
const execId = parsedData.execution_id;
|
||||
const stepId = parsedData.step_id;
|
||||
// The emitting agent's trace: lets the aggregator route a concurrent
|
||||
// agent's stream (e.g. the Checker) to its own block instead of the
|
||||
// Operator step that happens to be current.
|
||||
const parentTraceId = parsedData.parent_trace_id || null;
|
||||
const streamType = parsedData.stream_type || 'text';
|
||||
const key = `${execId}|${streamType}`;
|
||||
const pending = this.pendingStreamChunks.get(key);
|
||||
if (pending) {
|
||||
pending.chunk += parsedData.chunk;
|
||||
if (stepId) pending.stepId = stepId;
|
||||
if (parentTraceId) pending.parentTraceId = parentTraceId;
|
||||
} else {
|
||||
this.pendingStreamChunks.set(key, { execId, stepId, streamType, chunk: parsedData.chunk });
|
||||
this.pendingStreamChunks.set(key, { execId, stepId, parentTraceId, streamType, chunk: parsedData.chunk });
|
||||
}
|
||||
if (!this.streamFlushTimer) {
|
||||
const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80;
|
||||
@@ -1058,14 +1086,18 @@ export class AgentService {
|
||||
data: {
|
||||
...existingLog.data,
|
||||
text: existingLog.data.text + batch.chunk,
|
||||
step_id: batch.stepId || existingLog.data.step_id
|
||||
step_id: batch.stepId || existingLog.data.step_id,
|
||||
parent_trace_id: batch.parentTraceId || existingLog.data.parent_trace_id || null
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// A new execution closes the previous open stream of the same agent
|
||||
// lane only; concurrent agents (Operator vs. Checker) keep theirs.
|
||||
next = next.map((l) => {
|
||||
if (l.type === 'llm_stream' &&
|
||||
(l.data.stream_type || 'text') === batch.streamType &&
|
||||
!l.data.isCompleted) {
|
||||
!l.data.isCompleted &&
|
||||
(l.data.parent_trace_id || null) === (batch.parentTraceId || null)) {
|
||||
return { ...l, data: { ...l.data, isCompleted: true } };
|
||||
}
|
||||
return l;
|
||||
@@ -1076,6 +1108,7 @@ export class AgentService {
|
||||
data: {
|
||||
execution_id: batch.execId,
|
||||
step_id: batch.stepId,
|
||||
parent_trace_id: batch.parentTraceId || null,
|
||||
text: batch.chunk,
|
||||
stream_type: batch.streamType,
|
||||
isCompleted: false
|
||||
@@ -1511,6 +1544,105 @@ export class AgentService {
|
||||
/**
|
||||
* Fetch notes for a specific session
|
||||
*/
|
||||
/**
|
||||
* Backfill the Checker's attempts and run outcome from the persisted verdict
|
||||
* ledger (`/api/sessions/{id}/checks`) as synthetic `checker_event` logs, so
|
||||
* historical sessions show the same timeline blocks as live ones. Live
|
||||
* events for attempts already known are merged by attempt id in the
|
||||
* aggregator, so re-fetching is idempotent.
|
||||
*/
|
||||
public fetchChecks(sessionId: string): void {
|
||||
if (!sessionId) return;
|
||||
this.http.get<{ records?: any[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({
|
||||
next: (res) => {
|
||||
if (this.currentSessionId() !== sessionId) return;
|
||||
const snapshot = this.buildCheckerSnapshotLogs(sessionId, res?.records || [], res?.run_outcome || null);
|
||||
this.sessionLogs.update((logs) => [
|
||||
...logs.filter((log) => !log.checks_snapshot),
|
||||
...snapshot
|
||||
]);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Failed to fetch checks for session ${sessionId}:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildCheckerSnapshotLogs(sessionId: string, records: any[], runOutcome: any): any[] {
|
||||
const byAttempt = new Map<string, any>();
|
||||
for (const rec of records) {
|
||||
if (!rec || !rec.attempt_id) continue;
|
||||
const attemptId = String(rec.attempt_id);
|
||||
const checkpointId = String(rec.checkpoint_id || '');
|
||||
let attempt = byAttempt.get(attemptId);
|
||||
if (!attempt) {
|
||||
attempt = {
|
||||
event: 'attempt_finished',
|
||||
phase: checkpointId === 'final' ? 'final' : 'checkpoint',
|
||||
attempt_id: attemptId,
|
||||
checkpoint_id: checkpointId,
|
||||
subgoal_text: String(rec.subgoal_text || (checkpointId === 'final'
|
||||
? "Final review against the user's original goal"
|
||||
: `Subgoal ${checkpointId.slice(0, 8)}`)),
|
||||
anchor_step_id: rec.anchor_step_id ?? null,
|
||||
trace_id: rec.trace_id ?? null,
|
||||
status: 'done',
|
||||
verdicts: [],
|
||||
findings: [],
|
||||
ts: typeof rec.ts === 'number' ? rec.ts : undefined,
|
||||
session_id: sessionId
|
||||
};
|
||||
byAttempt.set(attemptId, attempt);
|
||||
}
|
||||
if (typeof rec.ts === 'number') {
|
||||
attempt.ts = attempt.ts === undefined ? rec.ts : Math.min(attempt.ts, rec.ts);
|
||||
}
|
||||
if (!attempt.trace_id && rec.trace_id) {
|
||||
attempt.trace_id = rec.trace_id;
|
||||
}
|
||||
attempt.verdicts.push({
|
||||
item_text: String(rec.item_text || ''),
|
||||
kind: String(rec.kind || ''),
|
||||
status: String(rec.status || ''),
|
||||
evidence: String(rec.evidence || ''),
|
||||
suggestion: rec.suggestion ? String(rec.suggestion) : '',
|
||||
when: rec.when ? String(rec.when) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
const logs: any[] = [];
|
||||
for (const attempt of byAttempt.values()) {
|
||||
const statuses = new Set(attempt.verdicts.map((v: any) => v.status));
|
||||
if (statuses.size === 1) {
|
||||
const only = [...statuses][0];
|
||||
if (only === 'superseded' || only === 'unchecked') attempt.status = only;
|
||||
}
|
||||
const ts = attempt.ts ?? Date.now() / 1000;
|
||||
attempt.timestamp = ts;
|
||||
logs.push({
|
||||
type: 'checker_event',
|
||||
session_id: sessionId,
|
||||
timestamp: new Date(ts * 1000).toISOString(),
|
||||
checks_snapshot: true,
|
||||
data: attempt
|
||||
});
|
||||
}
|
||||
if (runOutcome && typeof runOutcome === 'object') {
|
||||
const lastTs = logs.length > 0
|
||||
? Math.max(...logs.map((l) => new Date(l.timestamp).getTime() / 1000))
|
||||
: Date.now() / 1000;
|
||||
const ts = lastTs + 0.001;
|
||||
logs.push({
|
||||
type: 'checker_event',
|
||||
session_id: sessionId,
|
||||
timestamp: new Date(ts * 1000).toISOString(),
|
||||
checks_snapshot: true,
|
||||
data: { event: 'run_outcome', phase: 'outcome', ...runOutcome, ts, timestamp: ts, session_id: sessionId }
|
||||
});
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
public fetchNotes(sessionId: string): void {
|
||||
if (!sessionId) {
|
||||
this.currentNotes.set({});
|
||||
|
||||
@@ -91,3 +91,261 @@ describe('stream aggregator timeline ordering', () => {
|
||||
.toEqual(['text', 'tool']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream aggregator checker lane', () => {
|
||||
const started = {
|
||||
type: 'checker_event',
|
||||
timestamp: '2026-08-25T20:00:05.000Z',
|
||||
data: {
|
||||
event: 'attempt_started',
|
||||
phase: 'checkpoint',
|
||||
attempt_id: 'abc#1',
|
||||
checkpoint_id: 'abc',
|
||||
subgoal_text: 'Create the alarm',
|
||||
trace_id: 'trace-checker-1',
|
||||
items: [{ kind: 'verify', text: 'alarm exists' }],
|
||||
ts: 1756152005
|
||||
}
|
||||
};
|
||||
|
||||
it('creates a running checker block from attempt_started and finishes it by attempt id', () => {
|
||||
const blocks = consolidateLogsToBlocks([
|
||||
started,
|
||||
{
|
||||
type: 'checker_event',
|
||||
timestamp: '2026-08-25T20:00:09.000Z',
|
||||
data: {
|
||||
event: 'attempt_finished',
|
||||
attempt_id: 'abc#1',
|
||||
status: 'done',
|
||||
verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }],
|
||||
findings: [],
|
||||
ts: 1756152009
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
expect(blocks.length).toBe(1);
|
||||
expect(blocks[0].type).toBe('checker');
|
||||
expect(blocks[0].id).toBe('checker-abc#1');
|
||||
expect(blocks[0].data.isCompleted).toBeTrue();
|
||||
expect(blocks[0].data.status).toBe('done');
|
||||
expect(blocks[0].data.trace_id).toBe('trace-checker-1');
|
||||
expect(blocks[0].data.verdicts.length).toBe(1);
|
||||
expect(blocks[0].data.duration).toBe(4);
|
||||
});
|
||||
|
||||
it("routes the checker's stream and tool traces by parent trace id, not by the current step", () => {
|
||||
const blocks = consolidateLogsToBlocks([
|
||||
{
|
||||
type: 'step_recorded',
|
||||
timestamp: '2026-08-25T20:00:01.000Z',
|
||||
data: { step_id: 'step-1', step_number: 1, timestamp: 1756152001 }
|
||||
},
|
||||
started,
|
||||
{
|
||||
type: 'llm_stream',
|
||||
timestamp: '2026-08-25T20:00:06.000Z',
|
||||
data: {
|
||||
execution_id: 'exec-checker',
|
||||
step_id: 'step-1',
|
||||
parent_trace_id: 'trace-checker-1',
|
||||
stream_type: 'thinking',
|
||||
text: 'Inspecting the alarm list',
|
||||
isCompleted: false
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'trace_recorded',
|
||||
timestamp: '2026-08-25T20:00:07.000Z',
|
||||
data: {
|
||||
trace_id: 'tool-probe',
|
||||
parent_trace_id: 'trace-checker-1',
|
||||
agent_name: 'checker',
|
||||
type: 'tool',
|
||||
name: 'probe_device',
|
||||
step_id: 'step-1',
|
||||
status: 'success'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const step = blocks.find((b) => b.id === 'step-step-1')!;
|
||||
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
|
||||
expect(step.data.operator_native_thinking).toBeUndefined();
|
||||
expect(step.data.generic_tools || []).toEqual([]);
|
||||
expect(checker.data.operator_native_thinking).toBe('Inspecting the alarm list');
|
||||
expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']);
|
||||
expect(checker.data.step_id).toBeUndefined();
|
||||
expect(blocks.map((b) => b.id)).toEqual(['step-step-1', 'checker-abc#1']);
|
||||
});
|
||||
|
||||
it('keeps one stream segment per checker turn so text and tool calls interleave by time', () => {
|
||||
const turn = (execId: string, timestamp: string, text: string) => ({
|
||||
type: 'llm_stream',
|
||||
timestamp,
|
||||
data: {
|
||||
execution_id: execId,
|
||||
parent_trace_id: 'trace-checker-1',
|
||||
stream_type: 'text',
|
||||
text,
|
||||
isCompleted: false
|
||||
}
|
||||
});
|
||||
const blocks = consolidateLogsToBlocks([
|
||||
started,
|
||||
turn('exec-1', '2026-08-25T20:00:06.000Z', 'Let me look at step 2.'),
|
||||
{
|
||||
type: 'trace_recorded',
|
||||
timestamp: '2026-08-25T20:00:07.000Z',
|
||||
data: {
|
||||
trace_id: 'tool-detail',
|
||||
parent_trace_id: 'trace-checker-1',
|
||||
agent_name: 'checker',
|
||||
type: 'tool',
|
||||
name: 'get_step_detail',
|
||||
status: 'success',
|
||||
timestamp: '2026-08-25T20:00:07.000Z'
|
||||
}
|
||||
},
|
||||
turn('exec-2', '2026-08-25T20:00:08.000Z', 'The alarm is there.'),
|
||||
turn('exec-1', '2026-08-25T20:00:08.500Z', 'Let me look at step 2. Done.')
|
||||
]);
|
||||
|
||||
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
|
||||
expect(checker.data.stream_segments.map((s: any) => s.execution_id)).toEqual(['exec-1', 'exec-2']);
|
||||
// A later chunk of an earlier turn keeps that turn's first-seen time.
|
||||
expect(checker.data.stream_segments[0].text).toBe('Let me look at step 2. Done.');
|
||||
expect(checker.data.stream_segments[0].timestamp).toBe('2026-08-25T20:00:06.000Z');
|
||||
const events = getSortedStepEvents(checker.data);
|
||||
expect(events.map((e) => e.type)).toEqual(['text', 'tool', 'text']);
|
||||
expect(events[0].data.text).toBe('Let me look at step 2. Done.');
|
||||
expect(events[2].data.text).toBe('The alarm is there.');
|
||||
});
|
||||
|
||||
it('builds the run outcome block once and keeps it idempotent', () => {
|
||||
const outcome = {
|
||||
type: 'checker_event',
|
||||
timestamp: '2026-08-25T20:00:20.000Z',
|
||||
data: { event: 'run_outcome', task_status: 'completed', tests: { passed: 1, failed: 0, inconclusive: 0, unchecked: 0 } }
|
||||
};
|
||||
const blocks = consolidateLogsToBlocks([outcome, outcome]);
|
||||
expect(blocks.length).toBe(1);
|
||||
expect(blocks[0].id).toBe('checker-outcome');
|
||||
expect(blocks[0].data.phase).toBe('outcome');
|
||||
expect(blocks[0].data.task_status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream aggregator step ownership', () => {
|
||||
const plannerStream = {
|
||||
type: 'llm_stream',
|
||||
timestamp: '2026-09-02T03:57:25.000Z',
|
||||
data: { execution_id: 'exec-planner', stream_type: 'text', text: 'I have formulated the task plan.', isCompleted: true }
|
||||
};
|
||||
const tap = { trace_id: 'trace-tap-3', type: 'action', name: 'tap', timestamp: 1788374289.37, payload: { args: { action: 'tap' } } };
|
||||
|
||||
it('does not fold a step without streamed text into an earlier untagged stream block', () => {
|
||||
const blocks = consolidateLogsToBlocks([
|
||||
plannerStream,
|
||||
{
|
||||
type: 'step_recorded',
|
||||
timestamp: '2026-09-02T03:57:47.000Z',
|
||||
data: { step_id: 's1', step_number: 1, operator_raw_thinking: 'Launching Maps', generic_tools: [] }
|
||||
},
|
||||
{
|
||||
type: 'step_recorded',
|
||||
timestamp: '2026-09-02T03:58:09.000Z',
|
||||
data: { step_id: 's3', step_number: 3, generic_tools: [tap] }
|
||||
}
|
||||
]);
|
||||
|
||||
expect(blocks.map((b) => b.id)).toEqual(['stream-exec-planner', 'step-s1', 'step-s3']);
|
||||
expect(blocks[0].type).toBe('llm_stream');
|
||||
expect(blocks[0].data.generic_tools).toEqual([]);
|
||||
expect(blocks[0].data.operator_raw_thinking).toBe('I have formulated the task plan.');
|
||||
expect(blocks[2].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-tap-3']);
|
||||
});
|
||||
|
||||
it('creates the step block for a tagged trace that arrives before its step is recorded', () => {
|
||||
const blocks = consolidateLogsToBlocks([
|
||||
{
|
||||
type: 'step_recorded',
|
||||
timestamp: '2026-09-02T03:58:03.000Z',
|
||||
data: { step_id: 's2', step_number: 2, operator_raw_thinking: 'Dismissing the dialog', generic_tools: [] }
|
||||
},
|
||||
{
|
||||
type: 'trace_recorded',
|
||||
timestamp: '2026-09-02T03:58:09.300Z',
|
||||
data: { ...tap, step_id: 's3', status: 'running' }
|
||||
},
|
||||
{
|
||||
type: 'step_recorded',
|
||||
timestamp: '2026-09-02T03:58:09.400Z',
|
||||
data: { step_id: 's3', step_number: 3, generic_tools: [{ ...tap, status: 'success' }] }
|
||||
}
|
||||
]);
|
||||
|
||||
expect(blocks.map((b) => b.id)).toEqual(['step-s2', 'step-s3']);
|
||||
expect(blocks[0].data.generic_tools).toEqual([]);
|
||||
expect(blocks[1].data.generic_tools.length).toBe(1);
|
||||
expect(blocks[1].data.generic_tools[0].status).toBe('success');
|
||||
});
|
||||
|
||||
it('still attaches untagged traces to the latest preceding block', () => {
|
||||
const blocks = consolidateLogsToBlocks([
|
||||
plannerStream,
|
||||
{
|
||||
type: 'trace_recorded',
|
||||
timestamp: '2026-09-02T03:57:26.000Z',
|
||||
data: { trace_id: 'trace-note', type: 'tool', name: 'save_note', payload: { args: {} } }
|
||||
}
|
||||
]);
|
||||
|
||||
expect(blocks.length).toBe(1);
|
||||
expect(blocks[0].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-note']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream aggregator checker history relocation', () => {
|
||||
it('moves persisted checker tool traces from the operator step to the attempt block', () => {
|
||||
const blocks = consolidateLogsToBlocks([
|
||||
{
|
||||
type: 'step_updated',
|
||||
timestamp: '2026-08-25T20:00:01.000Z',
|
||||
history_snapshot: true,
|
||||
data: {
|
||||
step_id: 'step-1',
|
||||
step_number: 1,
|
||||
timestamp: 1756152001,
|
||||
generic_tools: [
|
||||
{ trace_id: 'trace-checker-1', type: 'agent', name: 'checker', status: 'success' },
|
||||
{ trace_id: 'tool-probe', parent_trace_id: 'trace-checker-1', type: 'tool', name: 'probe_device', status: 'success' },
|
||||
{ trace_id: 'tool-note', parent_trace_id: 'operator-trace', type: 'tool', name: 'save_note', status: 'success' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'checker_event',
|
||||
timestamp: '2026-08-25T20:00:09.000Z',
|
||||
checks_snapshot: true,
|
||||
data: {
|
||||
event: 'attempt_finished',
|
||||
phase: 'checkpoint',
|
||||
attempt_id: 'abc#1',
|
||||
checkpoint_id: 'abc',
|
||||
subgoal_text: 'Create the alarm',
|
||||
trace_id: 'trace-checker-1',
|
||||
status: 'done',
|
||||
verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }],
|
||||
ts: 1756152009
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const step = blocks.find((b) => b.id === 'step-step-1')!;
|
||||
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
|
||||
expect(step.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-note']);
|
||||
expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { StepBlock, PhaseBlock, StepEvent } from '../core/models/stream.model';
|
||||
import { StepBlock, PhaseBlock, StepEvent, StreamSegment } from '../core/models/stream.model';
|
||||
import { isAndroidAction, isReportStatusAction, getReportStatusExplanation, getReportStatusValue } from './action-formatter.util';
|
||||
import { getUniqueGenericTools } from './tool-formatter.util';
|
||||
|
||||
@@ -32,6 +32,145 @@ export function getItemTimestamp(ts: any, fallback: number = 0): number {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the `checker` block that owns a trace id (the Checker's own agent
|
||||
* trace). The Checker runs concurrently with Operator steps, so its streamed
|
||||
* reasoning and tool traces are routed by `parent_trace_id`, never by the
|
||||
* step that happens to be current.
|
||||
*/
|
||||
function findCheckerBlockByTrace(blocks: StepBlock[], parentTraceId: any): number {
|
||||
if (!parentTraceId) return -1;
|
||||
const key = String(parentTraceId);
|
||||
return blocks.findIndex(b => b.type === 'checker' && b.data?.trace_id && String(b.data.trace_id) === key);
|
||||
}
|
||||
|
||||
/** Latest still-running checker block (fallback when only the agent name is known). */
|
||||
function findRunningCheckerBlock(blocks: StepBlock[]): number {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
if (blocks[i].type === 'checker' && blocks[i].data?.isCompleted === false) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one `checker_event` (attempt_started / attempt_finished / run_outcome)
|
||||
* to the block list. Attempts are keyed by attempt id so live events and the
|
||||
* ledger backfill merge instead of duplicating.
|
||||
*/
|
||||
function applyCheckerLog(blocks: StepBlock[], log: any): void {
|
||||
const d = log.data || {};
|
||||
if (!d.event) return;
|
||||
|
||||
if (d.event === 'run_outcome') {
|
||||
const id = 'checker-outcome';
|
||||
const idx = blocks.findIndex(b => b.id === id);
|
||||
const data = {
|
||||
...(idx > -1 ? blocks[idx].data : {}),
|
||||
...d,
|
||||
phase: 'outcome',
|
||||
status: 'done',
|
||||
isCompleted: true,
|
||||
generic_tools: []
|
||||
};
|
||||
const block: StepBlock = { id, type: 'checker', timestamp: log.timestamp, data };
|
||||
if (idx > -1) blocks[idx] = block; else blocks.push(block);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!d.attempt_id) return;
|
||||
const id = `checker-${d.attempt_id}`;
|
||||
const idx = blocks.findIndex(b => b.id === id);
|
||||
const existing = idx > -1 ? blocks[idx] : null;
|
||||
const ts = typeof d.ts === 'number' ? d.ts : (log.timestamp ? new Date(log.timestamp).getTime() / 1000 : undefined);
|
||||
|
||||
if (d.event === 'attempt_started') {
|
||||
const data = {
|
||||
...(existing?.data || {}),
|
||||
...d,
|
||||
// A late "started" must not resurrect a finished attempt.
|
||||
status: existing?.data?.isCompleted ? existing.data.status : 'running',
|
||||
isCompleted: existing?.data?.isCompleted ?? false,
|
||||
started_at: existing?.data?.started_at ?? ts,
|
||||
verdicts: existing?.data?.verdicts || [],
|
||||
findings: existing?.data?.findings || [],
|
||||
generic_tools: existing?.data?.generic_tools || []
|
||||
};
|
||||
const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data };
|
||||
if (existing) blocks[idx] = block; else blocks.push(block);
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.event === 'attempt_finished') {
|
||||
const startedAt = existing?.data?.started_at ?? ts;
|
||||
const verdicts = Array.isArray(d.verdicts) ? d.verdicts : (existing?.data?.verdicts || []);
|
||||
const items = existing?.data?.items?.length
|
||||
? existing.data.items
|
||||
: verdicts.map((v: any) => ({ kind: v.kind, text: v.item_text, when: v.when }));
|
||||
const data = {
|
||||
...(existing?.data || {}),
|
||||
...d,
|
||||
status: d.status || 'done',
|
||||
verdicts,
|
||||
items,
|
||||
findings: Array.isArray(d.findings) ? d.findings : (existing?.data?.findings || []),
|
||||
generic_tools: existing?.data?.generic_tools || [],
|
||||
started_at: startedAt,
|
||||
finished_at: ts,
|
||||
duration: startedAt !== undefined && ts !== undefined ? Math.max(0, ts - startedAt) : undefined,
|
||||
isCompleted: true
|
||||
};
|
||||
const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data };
|
||||
if (existing) blocks[idx] = block; else blocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted steps list the Checker's tool traces (and its agent trace) under
|
||||
* the Operator step that was current while it ran, because traces are stored
|
||||
* by step id. Once the checker blocks exist (ledger backfill carries the
|
||||
* attempt's trace id), move those traces to the attempt they belong to and
|
||||
* drop the agent trace itself.
|
||||
*/
|
||||
function relocateCheckerTools(blocks: StepBlock[]): void {
|
||||
const checkerByTrace = new Map<string, number>();
|
||||
blocks.forEach((b, i) => {
|
||||
if (b.type === 'checker' && b.data?.trace_id) {
|
||||
checkerByTrace.set(String(b.data.trace_id), i);
|
||||
}
|
||||
});
|
||||
if (checkerByTrace.size === 0) return;
|
||||
|
||||
blocks.forEach((block, i) => {
|
||||
if (block.type === 'checker') return;
|
||||
const tools: any[] = block.data?.generic_tools || [];
|
||||
if (tools.length === 0) return;
|
||||
const keep: any[] = [];
|
||||
let moved = false;
|
||||
for (const tool of tools) {
|
||||
const ownTrace = tool?.trace_id ? String(tool.trace_id) : '';
|
||||
const parent = tool?.parent_trace_id ? String(tool.parent_trace_id) : '';
|
||||
if (ownTrace && checkerByTrace.has(ownTrace) && tool?.type === 'agent') {
|
||||
moved = true; // the Checker's own agent trace: not a tool of the step
|
||||
continue;
|
||||
}
|
||||
const target = parent ? checkerByTrace.get(parent) : undefined;
|
||||
if (target === undefined) {
|
||||
keep.push(tool);
|
||||
continue;
|
||||
}
|
||||
const checker = blocks[target];
|
||||
const existing: any[] = checker.data.generic_tools || [];
|
||||
if (!existing.some((t) => t.trace_id && t.trace_id === tool.trace_id)) {
|
||||
blocks[target] = { ...checker, data: { ...checker.data, generic_tools: [...existing, tool] } };
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
if (moved) {
|
||||
blocks[i] = { ...block, data: { ...block.data, generic_tools: keep } };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidate raw SSE logs into deduplicated and ordered StepBlocks
|
||||
*/
|
||||
@@ -43,6 +182,11 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
|
||||
rawLogs.forEach(log => {
|
||||
if (!log) return;
|
||||
|
||||
if (log.type === 'checker_event') {
|
||||
applyCheckerLog(blocks, log);
|
||||
return;
|
||||
}
|
||||
|
||||
if (log.type === 'llm_stream') {
|
||||
const execId = log.data?.execution_id || log.id;
|
||||
const stepId = log.data?.step_id;
|
||||
@@ -50,6 +194,52 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
|
||||
const text = log.data?.text || '';
|
||||
const isCompleted = log.data?.isCompleted ?? false;
|
||||
|
||||
// The Checker's own reasoning streams under its agent trace: route it to
|
||||
// the attempt block, never to the Operator step that is current.
|
||||
const checkerIndex = findCheckerBlockByTrace(blocks, log.data?.parent_trace_id);
|
||||
if (checkerIndex > -1) {
|
||||
const checkerBlock = blocks[checkerIndex];
|
||||
const checkerData: any = { ...checkerBlock.data };
|
||||
// The Checker is a multi-turn tool loop: every LLM turn streams under
|
||||
// its own execution id. Keep one segment per turn (with its first-seen
|
||||
// time) so the timeline can interleave the text with the tool calls it
|
||||
// triggered, instead of collapsing all turns into one early block.
|
||||
const segments: StreamSegment[] = Array.isArray(checkerData.stream_segments)
|
||||
? [...checkerData.stream_segments]
|
||||
: [];
|
||||
const segmentIndex = segments.findIndex(
|
||||
(s) => s.execution_id === execId && s.stream_type === streamType
|
||||
);
|
||||
const segment: StreamSegment = {
|
||||
execution_id: execId,
|
||||
stream_type: streamType === 'thinking' ? 'thinking' : 'text',
|
||||
text,
|
||||
timestamp: segmentIndex > -1 ? segments[segmentIndex].timestamp : log.timestamp,
|
||||
isCompleted
|
||||
};
|
||||
if (segmentIndex > -1) segments[segmentIndex] = segment; else segments.push(segment);
|
||||
checkerData.stream_segments = segments;
|
||||
// Flat fields stay as the joined text for consumers that only know steps.
|
||||
const joined = (type: string) => segments
|
||||
.filter((s) => s.stream_type === type && s.text.trim())
|
||||
.map((s) => s.text)
|
||||
.join('\n\n');
|
||||
checkerData.operator_native_thinking = joined('thinking');
|
||||
checkerData.operator_raw_thinking = joined('text');
|
||||
if (streamType === 'thinking') {
|
||||
checkerData.operator_native_thinking_timestamp =
|
||||
checkerData.operator_native_thinking_timestamp || log.timestamp;
|
||||
} else {
|
||||
checkerData.operator_raw_thinking_timestamp =
|
||||
checkerData.operator_raw_thinking_timestamp || log.timestamp;
|
||||
}
|
||||
if (execId && !checkerData.execution_id) {
|
||||
checkerData.execution_id = execId;
|
||||
}
|
||||
blocks[checkerIndex] = { ...checkerBlock, data: checkerData };
|
||||
return;
|
||||
}
|
||||
|
||||
// Find existing block: match by stepId if available, or by execId, or active incomplete block
|
||||
let existingIndex = -1;
|
||||
if (stepId) {
|
||||
@@ -111,15 +301,12 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
|
||||
// Find if there is an existing block for this step or an unattached stream block from this turn
|
||||
let existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId);
|
||||
|
||||
// If not found by step_id, check if there is an open stream block from the same execution turn
|
||||
if (existingIndex === -1) {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
if (blocks[i].type === 'llm_stream' && (!blocks[i].data?.step_id || blocks[i].data?.step_id === stepId)) {
|
||||
existingIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No adoption of untagged stream blocks here: the Operator's streams
|
||||
// always carry the step id (Perception / the Flash turn pre-allocate it
|
||||
// before the LLM call), so a stream without one belongs to another agent
|
||||
// (Planner, Checker...). A step whose Operator emitted only tool calls
|
||||
// and no text used to steal the latest such block -- typically the
|
||||
// Planner's -- and render its action inside it at the top of the timeline.
|
||||
|
||||
if (existingIndex > -1) {
|
||||
const existingBlock = blocks[existingIndex];
|
||||
@@ -185,8 +372,18 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
|
||||
let stepId = log.data.step_id;
|
||||
let existingIndex = -1;
|
||||
|
||||
// 0. Tools invoked by the Checker belong to its attempt block (routed by
|
||||
// the parent trace, falling back to the running attempt by agent name).
|
||||
let checkerIndex = findCheckerBlockByTrace(blocks, log.data.parent_trace_id);
|
||||
if (checkerIndex === -1 && String(log.data.agent_name || '').toLowerCase() === 'checker') {
|
||||
checkerIndex = findRunningCheckerBlock(blocks);
|
||||
}
|
||||
if (checkerIndex > -1) {
|
||||
existingIndex = checkerIndex;
|
||||
}
|
||||
|
||||
// 1. If this trace_id already exists in some block, update that block directly
|
||||
if (log.data.trace_id) {
|
||||
if (existingIndex === -1 && log.data.trace_id) {
|
||||
existingIndex = blocks.findIndex(b =>
|
||||
b.data?.generic_tools?.some((t: any) => t.trace_id === log.data.trace_id)
|
||||
);
|
||||
@@ -202,8 +399,13 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
|
||||
existingIndex = blocks.findIndex(b => b.id === 'step-pre-planning' || b.data?.step_id === 'pre-planning');
|
||||
}
|
||||
|
||||
// 4. Otherwise find by timestamp among existing step blocks
|
||||
if (existingIndex === -1) {
|
||||
// 4. Untagged traces only: attach to the latest block that precedes
|
||||
// them. A trace that names a step whose block does not exist yet
|
||||
// (tool call before any streamed text, or an action recorded before
|
||||
// the step in Flash) creates that step's block below instead of being
|
||||
// glued to an earlier step, where the later `step_recorded` (which
|
||||
// carries the same trace) would leave a duplicate card.
|
||||
if (existingIndex === -1 && !stepId) {
|
||||
const logTime = log.timestamp ? new Date(log.timestamp).getTime() : 0;
|
||||
let maxStepTime = -1;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
@@ -255,6 +457,8 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
|
||||
}
|
||||
});
|
||||
|
||||
relocateCheckerTools(blocks);
|
||||
|
||||
blocks.sort((a, b) => {
|
||||
const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||||
const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||||
@@ -470,13 +674,17 @@ export function getSortedStepEvents(
|
||||
const rawText = typeof stepData.operator_raw_thinking === 'string'
|
||||
? stepData.operator_raw_thinking.trim()
|
||||
: '';
|
||||
const segments: StreamSegment[] | null = Array.isArray(stepData.stream_segments) && stepData.stream_segments.length > 0
|
||||
? stepData.stream_segments
|
||||
: null;
|
||||
const signature = [
|
||||
toolsLen,
|
||||
actionTs ?? '',
|
||||
stepData.operator_native_thinking_timestamp ?? '',
|
||||
stepData.operator_raw_thinking_timestamp ?? '',
|
||||
nativeText.length,
|
||||
rawText.length
|
||||
rawText.length,
|
||||
segments ? segments.map((s) => `${s.execution_id}:${s.stream_type}:${s.text.length}`).join(',') : ''
|
||||
].join('|');
|
||||
|
||||
if (cache) {
|
||||
@@ -492,22 +700,36 @@ export function getSortedStepEvents(
|
||||
|
||||
// Text and tools share one timeline. Live streams carry their first-seen
|
||||
// timestamps; persisted steps use the step timestamp as a stable fallback.
|
||||
if (nativeText) {
|
||||
events.push({
|
||||
type: 'thinking',
|
||||
data: { text: stepData.operator_native_thinking },
|
||||
timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
if (segments) {
|
||||
// Multi-turn agent (Checker): one event per streamed turn, each at the
|
||||
// time its first chunk arrived, so turns sort between the tool calls.
|
||||
segments.forEach((segment) => {
|
||||
if (!segment.text || !segment.text.trim()) return;
|
||||
events.push({
|
||||
type: segment.stream_type === 'thinking' ? 'thinking' : 'text',
|
||||
data: { text: segment.text, execution_id: segment.execution_id, segment: true },
|
||||
timestamp: getItemTimestamp(segment.timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (nativeText) {
|
||||
events.push({
|
||||
type: 'thinking',
|
||||
data: { text: stepData.operator_native_thinking },
|
||||
timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
});
|
||||
}
|
||||
|
||||
if (rawText) {
|
||||
events.push({
|
||||
type: 'text',
|
||||
data: { text: stepData.operator_raw_thinking },
|
||||
timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
});
|
||||
if (rawText) {
|
||||
events.push({
|
||||
type: 'text',
|
||||
data: { text: stepData.operator_raw_thinking },
|
||||
timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add action_taken if present and valid.
|
||||
|
||||
@@ -675,10 +675,20 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
|
||||
const q = args.specific_query || args.query || '';
|
||||
return q ? `Analyzing audio track: "${q}"` : 'Analyzing recording audio track';
|
||||
}
|
||||
case 'get_step_details':
|
||||
return 'Retrieving step execution details';
|
||||
case 'get_step_screenshot':
|
||||
return 'Inspecting screenshot evidence';
|
||||
case 'get_step_detail':
|
||||
case 'get_step_details': {
|
||||
const n = args.step_no ?? args.step_number ?? args.step;
|
||||
return n !== undefined && n !== '' ? `Reviewing step ${n}` : 'Reviewing step details';
|
||||
}
|
||||
case 'get_step_screenshot': {
|
||||
const n = args.step_no ?? args.step_number ?? args.step;
|
||||
const which = String(args.which || '').toLowerCase() === 'post' ? 'after' : 'before';
|
||||
return n !== undefined && n !== '' ? `Looking at the screen ${which} step ${n}` : 'Looking at a step screenshot';
|
||||
}
|
||||
case 'probe_device': {
|
||||
const kind = args.kind ? String(args.kind).replace(/_/g, ' ') : '';
|
||||
return kind ? `Reading ${kind} from the device` : 'Reading device state';
|
||||
}
|
||||
case 'search_history_for_text': {
|
||||
const q = args.text || args.query || '';
|
||||
return q ? `Searching execution trace for "${q}"` : 'Searching execution trace';
|
||||
@@ -754,10 +764,13 @@ export function getToolIcon(tool: any): string {
|
||||
return 'smart_toy';
|
||||
case 'analyze_audio_only':
|
||||
return 'graphic_eq';
|
||||
case 'get_step_detail':
|
||||
case 'get_step_details':
|
||||
return 'manage_search';
|
||||
case 'get_step_screenshot':
|
||||
return 'image_search';
|
||||
case 'probe_device':
|
||||
return 'sensors';
|
||||
case 'search_history_for_text':
|
||||
return 'find_in_page';
|
||||
case 'outputter':
|
||||
|
||||
Reference in new issue
Block a user