mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
Merge remote-tracking branch 'origin/eric/dev' into eric/browser-merged
This commit is contained in:
@@ -46,6 +46,8 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
@typechecked
|
||||
def __init__(self):
|
||||
self.sessions: Dict[str, AgentSession] = {}
|
||||
from backend.apps.agents.core.flight_recorder import set_sessions_provider
|
||||
set_sessions_provider(lambda: self.sessions)
|
||||
self.tasks: Dict[str, asyncio.Task] = {}
|
||||
# Live mirror of the in-flight streamed assistant text per session, so a stop can persist the partial reply instantly instead of waiting out the multi-second SDK teardown the cancel handler sits behind.
|
||||
self.live_partial: Dict[str, PartialReply] = {}
|
||||
@@ -125,7 +127,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
# off), so an empty prewarm prompt would boot a different thinking config than a typical
|
||||
# first message and fingerprint-miss into a respawn. 50+ chars matches the common case.
|
||||
p_representative = "prewarm placeholder prompt of representative length for boot"
|
||||
(options, options_kwargs, _pc, _stderr, _gs) = await self.build_agent_options(
|
||||
(options, options_kwargs, _, _, _) = await self.build_agent_options(
|
||||
session, session_id, p_representative, "", builtin_perms,
|
||||
None, None, None, False, p_router_model_id, p_api_type)
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
@@ -273,9 +275,11 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
try:
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
|
||||
from backend.apps.agents.core import flight_recorder as p_fr
|
||||
submit_diagnostic({
|
||||
"kind": "context_pressure_valve",
|
||||
"trigger": "overflow" if p_overflow else "pressure_death",
|
||||
"flight": p_fr.build_envelope(session_id, "context_pressure_valve", "overflow" if p_overflow else "pressure_death", session.model, "stream", turn.compact_boundaries),
|
||||
"session_id": session_id,
|
||||
"model": session.model,
|
||||
"compact_boundaries": turn.compact_boundaries,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Per-session flight recorder: a fixed breadcrumb ring appended at points that already log,
|
||||
flushed into a diagnostic envelope ONLY when an error surfaces (or a silent recovery is counted).
|
||||
The happy path pays one O(1) deque append per event and nothing else; nothing here touches disk
|
||||
or network on its own."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
# Kill switch: OSW_FLIGHT=0 turns every sensor into a no-op, which is also how the A/B that proves
|
||||
# the recorder costs nothing is run.
|
||||
P_ENABLED = os.environ.get("OSW_FLIGHT", "1") != "0"
|
||||
P_RING_SIZE = 64
|
||||
p_lock = threading.Lock()
|
||||
p_rings: Dict[str, deque] = {}
|
||||
p_sessions_provider = None
|
||||
|
||||
|
||||
def set_sessions_provider(provider) -> None:
|
||||
"""agent_manager registers its live sessions dict once so envelopes built anywhere (error
|
||||
handlers have no manager handle) still carry a real concurrency snapshot."""
|
||||
global p_sessions_provider
|
||||
p_sessions_provider = provider
|
||||
|
||||
|
||||
@typechecked
|
||||
def crumb(session_id: str, label: str, **meta: object) -> None:
|
||||
"""Append one breadcrumb; cheap enough for every retry decision and phase stamp."""
|
||||
if not P_ENABLED:
|
||||
return
|
||||
entry = {"l": label, "t": round(time.time(), 3)}
|
||||
for k, v in meta.items():
|
||||
if v is not None:
|
||||
entry[k] = v if isinstance(v, (int, float, bool)) else str(v)[:200]
|
||||
with p_lock:
|
||||
ring = p_rings.get(session_id)
|
||||
if ring is None:
|
||||
ring = deque(maxlen=P_RING_SIZE)
|
||||
p_rings[session_id] = ring
|
||||
ring.append(entry)
|
||||
|
||||
|
||||
@typechecked
|
||||
def drop_session(session_id: str) -> None:
|
||||
"""Sessions are deleted often; their crumbs must not accumulate forever."""
|
||||
with p_lock:
|
||||
p_rings.pop(session_id, None)
|
||||
|
||||
|
||||
@typechecked
|
||||
def breadcrumbs(session_id: str, last: int = 20) -> List[dict]:
|
||||
with p_lock:
|
||||
ring = p_rings.get(session_id)
|
||||
return list(ring)[-last:] if ring else []
|
||||
|
||||
|
||||
@typechecked
|
||||
def lane_for_model(model: Optional[str]) -> str:
|
||||
"""The routing lane is a first-class confounder: cc/cx/gc ride the local router, api goes direct."""
|
||||
m = model or ""
|
||||
if m.endswith("-cc") or m.startswith("cc/"):
|
||||
return "cc"
|
||||
if m.endswith("-cx") or m.startswith("cx/"):
|
||||
return "cx"
|
||||
if m.startswith(("gc/", "gemini", "ag/")):
|
||||
return "gc"
|
||||
if m.startswith(("openrouter/", "cp-")):
|
||||
return "openrouter" if m.startswith("openrouter/") else "custom"
|
||||
return "api"
|
||||
|
||||
|
||||
@typechecked
|
||||
def concurrency_snapshot(sessions: Dict[str, object]) -> dict:
|
||||
"""The tandem set at event time, read from state already in memory; assembled only when an
|
||||
envelope is being built, never on the hot path."""
|
||||
try:
|
||||
statuses = [getattr(s, "status", None) for s in sessions.values()]
|
||||
return {
|
||||
"sessions_total": len(statuses),
|
||||
"turns_running": sum(1 for s in statuses if s == "running"),
|
||||
}
|
||||
except Exception:
|
||||
return {"sessions_total": -1, "turns_running": -1}
|
||||
|
||||
|
||||
@typechecked
|
||||
def journey_auth_context() -> dict:
|
||||
"""Who the user is and where they are in the product when it broke. An error during onboarding
|
||||
on a free trial is a DIFFERENT bug from the same error for a returning own-key user, and without
|
||||
this they look identical in analytics."""
|
||||
try:
|
||||
from backend.apps.settings.store import load_settings
|
||||
st = load_settings()
|
||||
onboarding = getattr(st, "onboarding_v3", None)
|
||||
return {
|
||||
"stage": "onboarding" if onboarding in (None, "", "in_progress") else "returning",
|
||||
"signed_in": bool(getattr(st, "user_id", None)),
|
||||
"signin_method": getattr(st, "signin_method", None),
|
||||
"connection_mode": getattr(st, "connection_mode", "own_key"),
|
||||
"has_own_key": bool(getattr(st, "anthropic_api_key", None) or getattr(st, "openai_api_key", None)),
|
||||
}
|
||||
except Exception:
|
||||
return {"stage": "unknown", "signed_in": False}
|
||||
|
||||
|
||||
@typechecked
|
||||
def build_envelope(
|
||||
session_id: str,
|
||||
family: str,
|
||||
subkind: str,
|
||||
model: Optional[str],
|
||||
phase: str,
|
||||
attempts: int,
|
||||
sessions: Optional[Dict[str, object]] = None,
|
||||
) -> dict:
|
||||
"""Everything a stranger needs to diagnose the failure without the machine in front of them."""
|
||||
return {
|
||||
"family": family,
|
||||
"subkind": subkind,
|
||||
"lane": lane_for_model(model),
|
||||
"model": model,
|
||||
"phase": phase,
|
||||
"attempts": attempts,
|
||||
"breadcrumbs": breadcrumbs(session_id),
|
||||
"journey": journey_auth_context(),
|
||||
"concurrency": concurrency_snapshot(sessions if sessions is not None else (p_sessions_provider() if p_sessions_provider else {})),
|
||||
}
|
||||
|
||||
|
||||
@typechecked
|
||||
def record_recovery(session_id: str, net: str, model: Optional[str], attempts: int, sessions: Optional[Dict[str, object]] = None) -> None:
|
||||
"""The near-miss ledger: a silent recovery the user never saw still counts in analytics, so
|
||||
'how often do the nets fire' has a denominator. Fire-and-forget; failures never block the turn."""
|
||||
crumb(session_id, "recovered", net=net, attempts=attempts)
|
||||
try:
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
submit_diagnostic({
|
||||
"kind": "recovered",
|
||||
"subkind": net,
|
||||
"session_id": session_id[:8],
|
||||
"lane": lane_for_model(model),
|
||||
"model": model,
|
||||
"attempts": attempts,
|
||||
"journey": journey_auth_context(),
|
||||
"concurrency": concurrency_snapshot(sessions or {}),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Terminal shape of "our own router is down", kept beside the classifier it delegates to."""
|
||||
|
||||
import re
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.error_classify import is_router_unreachable_error
|
||||
|
||||
|
||||
@typechecked
|
||||
def is_router_unavailable_error(text: str) -> bool:
|
||||
"""True when the turn died because our own localhost router was down, either because the CLI
|
||||
could not reach it or because we refused to start at all. Distinct from
|
||||
`is_router_unreachable_error`, which is the narrower mid-turn "resume and carry on" case: this
|
||||
one is the terminal shape, and it exists so the envelope names a cause instead of shrugging
|
||||
'unclassified' at the one failure whose fix is entirely ours."""
|
||||
if not text.strip():
|
||||
return False
|
||||
if is_router_unreachable_error(text):
|
||||
return True
|
||||
return bool(re.search(
|
||||
r"9router\s+is\s+not\s+running"
|
||||
r"|9router\s+could\s+not\s+start"
|
||||
r"|9router.{0,30}not\s+ready",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
))
|
||||
@@ -232,6 +232,8 @@ async def configure_provider_env(
|
||||
logger.info(f"[MCP-DEBUG] Using 9Router (api_type={api_type})")
|
||||
else:
|
||||
# router_available() above already attempted a revival; reaching here means it truly can't start.
|
||||
from backend.apps.agents.core import flight_recorder
|
||||
flight_recorder.crumb(session.id, "router-unavailable", model=session.model, api=api_type)
|
||||
if api_type != "anthropic" or resolved_is_9router:
|
||||
raise ValueError(
|
||||
f"9Router is not running; cannot use {session.model}. "
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Dict, List, Optional, Union
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.agents.core import flight_recorder
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
from backend.apps.settings.settings import load_settings
|
||||
from backend.apps.tools_lib.tools_lib import load_all_tools, sanitize_server_name
|
||||
@@ -52,6 +53,7 @@ class RunOptions(AgentManagerProtocol):
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
|
||||
logger.info(f"[SPAWN-PHASE] options-build start session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
flight_recorder.crumb(session_id, "options-build")
|
||||
|
||||
# Per-SESSION hook context, updated in place each turn: with a persistent client the hooks the
|
||||
# CLI holds were bound at connect, so they must read this stable object, not a per-turn rebuild.
|
||||
@@ -119,6 +121,7 @@ class RunOptions(AgentManagerProtocol):
|
||||
|
||||
# Pass session.active_mcps as the activation filter. Empty list ⇒ no MCP tools shipped to the SDK; the model must MCPSearch and MCPActivate first. The product invariant lives here at the dispatch layer (see build_mcp_servers docstring).
|
||||
logger.info(f"[SPAWN-PHASE] mcp-build start session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
flight_recorder.crumb(session_id, "mcp-build")
|
||||
mcp_servers = await self.build_mcp_servers(session.allowed_tools, session.active_mcps)
|
||||
logger.info(f"[SPAWN-PHASE] mcp-build done session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
|
||||
@@ -198,6 +201,7 @@ class RunOptions(AgentManagerProtocol):
|
||||
}
|
||||
# cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api" bypasses to the provider's host directly; otherwise Pro proxy or key.
|
||||
logger.info(f"[SPAWN-PHASE] provider-env start session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
flight_recorder.crumb(session_id, "provider-env")
|
||||
await configure_provider_env(
|
||||
options_kwargs, session, resolved_model, api_type, global_settings
|
||||
)
|
||||
@@ -285,6 +289,7 @@ class RunOptions(AgentManagerProtocol):
|
||||
|
||||
# Compaction trigger (Phase 2). Driven by live ctx_used ratio rather than turn count, fires when input_tokens/context_window crosses session.compact_threshold_pct (default 0.65). Cheap, programmatic summarization (no aux LLM call) so this adds zero latency on the user's turn.
|
||||
logger.info(f"[SPAWN-PHASE] context-guard start session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
flight_recorder.crumb(session_id, "context-guard")
|
||||
await pre_send_context_guard(self, session, session_id)
|
||||
logger.info(f"[SPAWN-PHASE] context-guard done session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ from typeguard import typechecked
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
from backend.apps.agents.core.error_classify import CAPACITY_BACKOFFS, capacity_retry_wait, is_router_unreachable_error
|
||||
from backend.apps.agents.core import flight_recorder
|
||||
from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState
|
||||
from backend.apps.agents.manager.streaming.handle_stream_event import handle_stream_event
|
||||
from backend.apps.agents.manager.streaming.handle_assistant_message import handle_assistant_message
|
||||
from backend.apps.agents.manager.streaming.handle_result_message import TurnResultError, handle_result_message
|
||||
from backend.apps.agents.manager.streaming.note_provider_retry import note_provider_retry, settle_provider_retries
|
||||
from backend.apps.agents.manager.run.client_pool import (
|
||||
SdkClientLike,
|
||||
acquire_client,
|
||||
@@ -102,14 +104,18 @@ class TurnRunner(AgentManagerProtocol):
|
||||
|
||||
if turn.first_event:
|
||||
logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}")
|
||||
flight_recorder.crumb(session_id, "first-event", kind=type(message).__name__)
|
||||
turn.first_event = False
|
||||
|
||||
# Log system messages (MCP server status, errors, etc.)
|
||||
if isinstance(message, SystemMessage):
|
||||
raw = message.__dict__ if hasattr(message, '__dict__') else str(message)
|
||||
logger.info(f"[MCP-DEBUG] SystemMessage: {raw}")
|
||||
if getattr(message, "subtype", "") == "compact_boundary":
|
||||
p_subtype = getattr(message, "subtype", "")
|
||||
if p_subtype == "compact_boundary":
|
||||
turn.compact_boundaries += 1
|
||||
elif p_subtype == "api_retry":
|
||||
note_provider_retry(session_id, raw, turn)
|
||||
|
||||
if isinstance(message, StreamEvent):
|
||||
await handle_stream_event(
|
||||
@@ -117,10 +123,12 @@ class TurnRunner(AgentManagerProtocol):
|
||||
)
|
||||
|
||||
elif isinstance(message, AssistantMessage):
|
||||
flight_recorder.crumb(session_id, "assistant-msg")
|
||||
await handle_assistant_message(
|
||||
message, session, session_id, turn, thinking, self.live_partial, self.sessions
|
||||
)
|
||||
elif isinstance(message, ResultMessage):
|
||||
flight_recorder.crumb(session_id, "result-msg", subtype=str(getattr(message, "subtype", "")))
|
||||
await handle_result_message(
|
||||
message, session, session_id, turn, thinking, self.sessions,
|
||||
resolved_model, api_type, global_settings,
|
||||
@@ -132,12 +140,15 @@ class TurnRunner(AgentManagerProtocol):
|
||||
async def p_connect():
|
||||
p_client = ClaudeSDKClient(options=options)
|
||||
logger.info(f"[SPAWN-PHASE] cli-connect start session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
flight_recorder.crumb(session_id, "cli-connect-start")
|
||||
await p_client.connect()
|
||||
logger.info(f"[SPAWN-PHASE] cli-connect done session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
flight_recorder.crumb(session_id, "cli-connect-done")
|
||||
return p_client
|
||||
|
||||
fp = boot_fingerprint(options_kwargs, session)
|
||||
logger.info(f"[SPAWN-PHASE] client-acquire start session={session_id[:8]} t={time.monotonic():.3f}")
|
||||
flight_recorder.crumb(session_id, "client-acquire")
|
||||
handle = await acquire_client(
|
||||
self.client_pool, session_id, fp, p_connect, force_respawn=force_respawn,
|
||||
)
|
||||
@@ -176,12 +187,25 @@ class TurnRunner(AgentManagerProtocol):
|
||||
p_use_persistent = persistent_client_enabled()
|
||||
capacity_retry_attempt = 0
|
||||
p_router_retry_attempt = 0
|
||||
# Baseline crumb so even a first-call failure's envelope names the turn it died in.
|
||||
flight_recorder.crumb(session_id, "turn-start", model=resolved_model, api=api_type)
|
||||
while True:
|
||||
try:
|
||||
if p_use_persistent:
|
||||
await p_run_streaming_turn_persistent()
|
||||
else:
|
||||
await p_run_streaming_turn()
|
||||
# The near-miss ledger: a turn that needed retries and still finished is a net that
|
||||
# FIRED, and "how often do the nets fire" needs a denominator in analytics.
|
||||
if p_router_retry_attempt or capacity_retry_attempt:
|
||||
flight_recorder.record_recovery(
|
||||
session_id,
|
||||
net="router-resume" if p_router_retry_attempt else "transient-backoff",
|
||||
model=resolved_model,
|
||||
attempts=p_router_retry_attempt + capacity_retry_attempt,
|
||||
sessions=self.sessions,
|
||||
)
|
||||
settle_provider_retries(session_id, turn, resolved_model, self.sessions)
|
||||
break
|
||||
except TurnResultError as p_result_err:
|
||||
# "Unable to connect" in a turn result is the CLI failing to reach our own localhost
|
||||
@@ -190,6 +214,7 @@ class TurnRunner(AgentManagerProtocol):
|
||||
# conversation without re-executing side effects: re-ensure the router, resume, go.
|
||||
if p_router_retry_attempt < 2 and is_router_unreachable_error(str(p_result_err)):
|
||||
p_router_retry_attempt += 1
|
||||
flight_recorder.crumb(session_id, "router-retry", attempt=p_router_retry_attempt, err=str(p_result_err)[:160])
|
||||
logger.warning(
|
||||
f"Router unreachable mid-turn on session {session_id} "
|
||||
f"(attempt {p_router_retry_attempt}/2); re-ensuring router and resuming. "
|
||||
@@ -228,6 +253,7 @@ class TurnRunner(AgentManagerProtocol):
|
||||
wait = 0.0
|
||||
if wait is not None:
|
||||
capacity_retry_attempt += 1
|
||||
flight_recorder.crumb(session_id, "transient-retry", attempt=capacity_retry_attempt, wait=wait, err=str(e)[:160])
|
||||
mid_stream = turn.current_turn_emitted
|
||||
logger.warning(
|
||||
f"Transient upstream error on session {session_id} "
|
||||
|
||||
@@ -84,10 +84,16 @@ class ClientHandle(BaseModel):
|
||||
|
||||
|
||||
# A pooled CLI holds ~100MB+ per session; evict clients idle past this so parked chats don't accumulate subprocesses (respawn on the next message is the normal cold path).
|
||||
IDLE_EVICT_SECONDS = float(os.environ.get("OSW_CLIENT_IDLE_EVICT_SECONDS", "1800"))
|
||||
# 10 minutes, down from 30: the packaged stress run measured ~108MB per parked CLI, and prewarm puts
|
||||
# the respawn cost on the next message at ~0.5-1.6s, so half an hour of held RAM per finished chat
|
||||
# bought almost nothing. Overridable for machines with RAM to burn.
|
||||
IDLE_EVICT_SECONDS = float(os.environ.get("OSW_CLIENT_IDLE_EVICT_SECONDS", "600"))
|
||||
|
||||
# Hard ceiling on warm CLIs regardless of idle age: past this, the least-recently-used IDLE sessions are disposed (they respawn ~0.5s on their next message), bounding the "30 chats open" resident-memory case. Kept a SOFT cap: a mid-turn or just-acquired client is never evicted, so a burst of live turns may exceed it rather than kill work.
|
||||
MAX_LIVE_CLIENTS = int(os.environ.get("OSW_CLIENT_MAX_LIVE", "12"))
|
||||
# 8, down from 12: the measured ceiling at 12 was ~1.3GB of CLIs alone, which is brutal on the 8GB
|
||||
# machines we explicitly support. Still a SOFT cap: live turns are never evicted, so real concurrent
|
||||
# work can exceed it; only parked chats pay.
|
||||
MAX_LIVE_CLIENTS = int(os.environ.get("OSW_CLIENT_MAX_LIVE", "8"))
|
||||
# Never cap-evict a client used this recently; far larger than the acquire->lock window, so a just-acquired client can't be reaped before its turn takes the lock.
|
||||
LRU_GUARD_SECONDS = float(os.environ.get("OSW_CLIENT_LRU_GUARD_SECONDS", "5"))
|
||||
# Timer cadence for the background reclaim; the acquire-time sweep is lazy (fires only when some session takes a turn), this one catches an all-quiet pool.
|
||||
|
||||
@@ -43,7 +43,19 @@ def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool:
|
||||
logger.warning(f"Agent {session_id}: turn finished with no answer after tool work; one hidden continue nudge")
|
||||
try:
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
submit_diagnostic({"kind": "empty_finish_nudge", "session_id": session_id, "model": session.model})
|
||||
from backend.apps.agents.core import flight_recorder as p_fr
|
||||
# A silent quit is the hardest class to diagnose after the fact, so it gets the same envelope
|
||||
# as a hard error: without breadcrumbs you cannot see what the turn was doing when it gave up.
|
||||
submit_diagnostic({
|
||||
"kind": "empty_finish_nudge",
|
||||
"session_id": session_id,
|
||||
"model": session.model,
|
||||
"tool_calls": p_tool_calls,
|
||||
"nudge": session.empty_finish_nudges,
|
||||
"flight": p_fr.build_envelope(
|
||||
session_id, "empty_finish_nudge", "silent_quit", session.model, "stream", session.empty_finish_nudges,
|
||||
),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
@@ -22,12 +22,33 @@ from backend.apps.agents.core.error_classify import (
|
||||
is_unknown_model_error,
|
||||
parse_retry_after,
|
||||
)
|
||||
from backend.apps.agents.core.is_router_unavailable_error import is_router_unavailable_error
|
||||
from backend.apps.agents.core.extract_reset_hint import extract_reset_hint
|
||||
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
|
||||
from backend.apps.agents.core import flight_recorder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_report_model_error(subkind: str, session_id: str, session: AgentSession, turn: TurnState,
|
||||
e: BaseException, stderr_tail: str) -> None:
|
||||
"""The three terminal model_error rungs differ only by subkind, so they share one submitter."""
|
||||
try:
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
submit_diagnostic({
|
||||
"kind": "model_error",
|
||||
"subkind": subkind,
|
||||
"flight": flight_recorder.build_envelope(session_id, "model_error", subkind, session.model, "stream" if turn.current_turn_emitted else "spawn", -1),
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
"connection_mode": getattr(load_settings(), "connection_mode", "own_key"),
|
||||
"error_preview": redact_for_telemetry(str(e), limit=400),
|
||||
"stderr_tail": redact_for_telemetry(stderr_tail),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug(f"submit_diagnostic {subkind} failed", exc_info=True)
|
||||
|
||||
@typechecked
|
||||
async def handle_run_error(e: Exception, session: AgentSession, session_id: str, turn: TurnState, p_stderr_buffer: List[str]) -> None:
|
||||
logger.exception(f"Agent {session_id} error: {e}")
|
||||
@@ -77,6 +98,7 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
|
||||
submit_diagnostic({
|
||||
"kind": "context_overflow",
|
||||
"where": "manager.run.handle_run_error",
|
||||
"flight": flight_recorder.build_envelope(session_id, "context_overflow", "overflow", session.model, "stream" if turn.current_turn_emitted else "spawn", -1),
|
||||
"session_id": session_id,
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
@@ -109,6 +131,7 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
|
||||
submit_diagnostic({
|
||||
"kind": "cli_binary_missing",
|
||||
"where": "manager.run.handle_run_error",
|
||||
"flight": flight_recorder.build_envelope(session_id, "cli_binary_missing", "missing", session.model, "stream" if turn.current_turn_emitted else "spawn", -1),
|
||||
"session_id": session_id,
|
||||
"model": session.model,
|
||||
"error_preview": redact_for_telemetry(str(e), limit=400),
|
||||
@@ -220,6 +243,18 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
|
||||
reason = "anthropic_auth_invalid"
|
||||
error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id)
|
||||
session.messages.append(error_msg)
|
||||
try:
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
submit_diagnostic({
|
||||
"kind": "model_error",
|
||||
"subkind": "auth",
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
"error_preview": redact_for_telemetry(str(e), limit=400),
|
||||
"flight": flight_recorder.build_envelope(session_id, "model_error", reason, session.model, "stream" if turn.current_turn_emitted else "spawn", -1),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic auth failed", exc_info=True)
|
||||
await ws_manager.send_to_session(session_id, "agent:auth_error", {
|
||||
"session_id": session_id,
|
||||
"reason": reason,
|
||||
@@ -232,19 +267,17 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
|
||||
})
|
||||
elif is_unknown_model_error(e, extra_text=p_stderr_tail):
|
||||
# Upstream rejected the model code itself (e.g. Codex 1211 on a ChatGPT plan that lacks our GPT ids). Track it; the friendly "add an API key / pick another model" card is rendered frontend-side.
|
||||
try:
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
submit_diagnostic({
|
||||
"kind": "model_error",
|
||||
"subkind": "unknown_model",
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
"connection_mode": getattr(load_settings(), "connection_mode", "own_key"),
|
||||
"error_preview": redact_for_telemetry(str(e), limit=400),
|
||||
"stderr_tail": redact_for_telemetry(p_stderr_tail),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic model_error failed", exc_info=True)
|
||||
p_report_model_error("unknown_model", session_id, session, turn, e, p_stderr_tail)
|
||||
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
|
||||
session.messages.append(error_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message": error_msg.model_dump(mode="json"),
|
||||
})
|
||||
elif is_router_unavailable_error(f"{e} {p_stderr_tail}"):
|
||||
# Our own router is down. Naming it beats "unclassified": this is the one failure family
|
||||
# where the fix is entirely on our side of the wire.
|
||||
p_report_model_error("router_unavailable", session_id, session, turn, e, p_stderr_tail)
|
||||
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
|
||||
session.messages.append(error_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
@@ -253,19 +286,7 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
|
||||
})
|
||||
else:
|
||||
# Track unclassified agent failures too so we stop flying blind on them.
|
||||
try:
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
submit_diagnostic({
|
||||
"kind": "model_error",
|
||||
"subkind": "unclassified",
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
"connection_mode": getattr(load_settings(), "connection_mode", "own_key"),
|
||||
"error_preview": redact_for_telemetry(str(e), limit=400),
|
||||
"stderr_tail": redact_for_telemetry(p_stderr_tail),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic model_error failed", exc_info=True)
|
||||
p_report_model_error("unclassified", session_id, session, turn, e, p_stderr_tail)
|
||||
# The SDK's ProcessError masks the cause behind "Check stderr output for details"; append the scrubbed stderr tail so the card (and its analytics copy) names what actually broke instead of shipping a dead end.
|
||||
p_card_text = f"Error: {str(e)}"
|
||||
p_cause = redact_for_telemetry(p_stderr_tail, limit=400).strip()
|
||||
|
||||
@@ -181,9 +181,14 @@ def inject_thinking_options(options_kwargs: Dict, session: AgentSession, prompt:
|
||||
reasoning_effort), with the short-prompt + gc/gemini-3 force-off overrides. Best-effort."""
|
||||
try:
|
||||
level = getattr(session, "thinking_level", "auto") or "auto"
|
||||
# Trivially short prompts ("hi", "thanks") don't benefit from 5-30s of hidden reasoning.
|
||||
# Trivially short prompts ("hi", "thanks") don't benefit from 5-30s of hidden reasoning, but
|
||||
# this flip rides the BOOT fingerprint: a short first message drifted it and threw away the
|
||||
# pre-warmed CLI, measured as a second spawn on 5 of 5 short-prompt sessions. A wasted 0.9s
|
||||
# respawn costs more than the reasoning it saves, so the flip only applies once a session is
|
||||
# already running on a live client (later turns), never on the first message.
|
||||
p_prompt_len = len((prompt or "").strip())
|
||||
if 0 < p_prompt_len < 50 and level != "off":
|
||||
p_first_turn = not getattr(session, "sdk_session_id", None)
|
||||
if 0 < p_prompt_len < 50 and level != "off" and not p_first_turn:
|
||||
level = "off"
|
||||
# gc/gemini-3* without Antigravity 400s every multi-step turn on thoughtSignature continuity.
|
||||
if (
|
||||
|
||||
@@ -116,6 +116,8 @@ class SessionLifecycle(AgentManagerProtocol):
|
||||
async def delete_session(self, session_id: str) -> None:
|
||||
"""Permanently delete a session: remove from memory and JSON file.
|
||||
Also stops browser-agent children first."""
|
||||
from backend.apps.agents.core.flight_recorder import drop_session
|
||||
drop_session(session_id)
|
||||
children = [
|
||||
s for s in self.sessions.values()
|
||||
if s.parent_session_id == session_id and s.mode == "browser-agent"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""The CLI retries provider 500s/429s by itself, up to 10 attempts with backoffs measured in tens
|
||||
of seconds, and tells nobody. To the user the card just sits there; to us the turn looks clean.
|
||||
|
||||
This turns each of those `api_retry` system events into a breadcrumb, so a turn that eventually
|
||||
dies carries "the provider 500'd four times first" in its envelope instead of an unexplained
|
||||
timeout. Counting it as a RECOVERED near-miss happens later, at turn end, because a retry that is
|
||||
still in flight has not recovered anything yet."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core import flight_recorder
|
||||
from backend.apps.agents.manager.streaming.state import TurnState
|
||||
|
||||
|
||||
@typechecked
|
||||
def note_provider_retry(session_id: str, raw: object, turn: TurnState) -> None:
|
||||
"""Record one CLI-internal provider retry. Never raises; diagnostics must not break a turn."""
|
||||
try:
|
||||
data = raw.get("data", {}) if isinstance(raw, dict) else {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
turn.provider_retries += 1
|
||||
delay_ms = data.get("retry_delay_ms")
|
||||
turn.provider_retry_wait_ms += int(delay_ms) if isinstance(delay_ms, int) else 0
|
||||
flight_recorder.crumb(
|
||||
session_id,
|
||||
"provider-retry",
|
||||
status=data.get("error_status"),
|
||||
error=str(data.get("error", ""))[:40],
|
||||
attempt=data.get("attempt"),
|
||||
delay_ms=delay_ms,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@typechecked
|
||||
def settle_provider_retries(session_id: str, turn: TurnState, model: Optional[str], sessions: Optional[dict] = None) -> None:
|
||||
"""Called when a turn finishes cleanly: any retries it survived were a silent save, so they get
|
||||
a denominator in the near-miss ledger."""
|
||||
if turn.provider_retries <= 0:
|
||||
return
|
||||
flight_recorder.record_recovery(session_id, "provider-retry", model, turn.provider_retries, sessions)
|
||||
@@ -56,6 +56,9 @@ class TurnState(BaseModel):
|
||||
baseline_captured: bool = False
|
||||
# CLI compact_boundary events seen this turn; one plus a ProcessError = the autocompact-thrash death the context-pressure valve retries.
|
||||
compact_boundaries: int = 0
|
||||
# Provider 500s/429s the CLI retried on its own; the user sees only a long silence, so these are counted rather than lost.
|
||||
provider_retries: int = 0
|
||||
provider_retry_wait_ms: int = 0
|
||||
# Mid-turn context breaker: fires once per turn, and only after a below-trigger reading (a turn that STARTS over the trigger must run, or a failed shrink would break-loop forever).
|
||||
context_break_fired: bool = False
|
||||
saw_input_below_trigger: bool = False
|
||||
|
||||
@@ -12,6 +12,7 @@ ScheduleWorkflow for exact, user-specified live schedules.
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
@@ -212,13 +213,14 @@ TOOLS = [
|
||||
{
|
||||
"name": "TestWorkflow",
|
||||
"description": (
|
||||
"Spawn a sibling Test Agent that runs the workflow end-to-end "
|
||||
"(the current draft if one is being edited, else the live steps) "
|
||||
"so the user can watch it work. Use after editing a step to "
|
||||
"verify the change. The Test Agent renders as a sibling card on "
|
||||
"the dashboard with a 'Testing' arrow chip linking back to this "
|
||||
"workflow. After it finishes, call ReadTestTranscript to see what "
|
||||
"it did."
|
||||
"Run the workflow end-to-end (the current draft if one is being "
|
||||
"edited, else the live steps) and WAIT for the result, which is "
|
||||
"returned to you in this same turn. Use after editing a step to "
|
||||
"verify the change, then keep going: fix what the transcript "
|
||||
"shows and test again, without stopping to ask the user. The Test "
|
||||
"Agent renders as a sibling card on the dashboard with a 'Testing' "
|
||||
"arrow chip so they can watch. Only if it runs unusually long does "
|
||||
"this return early, telling you to call ReadTestTranscript."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
@@ -438,6 +440,19 @@ def handle_run_now(args: dict) -> dict:
|
||||
wid = args.get("workflow_id") or ""
|
||||
if not wid:
|
||||
return _err("workflow_id is required.")
|
||||
# A human clicking Run Now on a paused workflow can see it is paused and chose anyway, so the
|
||||
# route ignores `enabled` on purpose. An agent reaching the same route is NOT the same act: the
|
||||
# user never asked, and a workflow they deliberately switched off starting itself is the field
|
||||
# report ("a workflow that had been toggled off just started running again").
|
||||
info = _call("GET", f"/{wid}")
|
||||
if "_error" not in info:
|
||||
sched = info.get("schedule") or {}
|
||||
if isinstance(sched, dict) and sched.get("enabled") is False:
|
||||
title = info.get("title") or wid
|
||||
return _err(
|
||||
f"'{title}' is paused, so I did not run it. Tell the user it is switched off and ask "
|
||||
"them to turn it back on (or to confirm they want a one-off run) before trying again."
|
||||
)
|
||||
r = _call("POST", f"/{wid}/run")
|
||||
if "_error" in r:
|
||||
return _err(r["_error"])
|
||||
@@ -529,6 +544,18 @@ def handle_delete_step(args: dict) -> dict:
|
||||
return _ok(f"Step {idx + 1} deleted ({len(steps)} remaining).")
|
||||
|
||||
|
||||
# How long a synchronous test may hold the turn. Long enough for a real multi-step workflow, short
|
||||
# enough that a wedged test returns an honest "still running" instead of hanging the conversation.
|
||||
# Wait on PROGRESS, not on a clock. A fixed budget answers the wrong question: a run doing real work
|
||||
# should never be cut off (the first budget was 240s and a live digest took 243, missing by three
|
||||
# seconds), while a run that is genuinely wedged should not be waited on for ten minutes either. So
|
||||
# the deadline resets every time the transcript grows, and only silence ends the wait.
|
||||
TEST_IDLE_S = 180.0
|
||||
# Absolute backstop for the pathological case where a run reports progress forever.
|
||||
TEST_MAX_S = 3600.0
|
||||
TEST_POLL_S = 3
|
||||
|
||||
|
||||
def handle_test_workflow(args: dict) -> dict:
|
||||
wid = args.get("workflow_id") or ""
|
||||
if not wid:
|
||||
@@ -537,7 +564,43 @@ def handle_test_workflow(args: dict) -> dict:
|
||||
if "_error" in r:
|
||||
return _err(r["_error"])
|
||||
sid = r.get("session_id", "")
|
||||
return _ok(f"Test Agent spawned (session {sid[:8]}...). It runs the latest workflow on the dashboard with a Testing arrow chip. Call ReadTestTranscript once it finishes to see what it did.")
|
||||
# Blocking on purpose. This used to return the moment the Test Agent spawned and tell the model
|
||||
# to "call ReadTestTranscript once it finishes", but a model has no way to know when that is, so
|
||||
# it ended its turn and the HUMAN had to keep re-pinging it. A test whose result the caller
|
||||
# cannot observe is not a tool, it is homework for the user.
|
||||
started = time.time()
|
||||
last_progress_at = started
|
||||
last_len = -1
|
||||
last_status = "running"
|
||||
partial = ""
|
||||
while True:
|
||||
now = time.time()
|
||||
if now - last_progress_at >= TEST_IDLE_S or now - started >= TEST_MAX_S:
|
||||
break
|
||||
time.sleep(TEST_POLL_S)
|
||||
t = _call("GET", f"/{wid}/test-transcript")
|
||||
if "_error" in t:
|
||||
continue
|
||||
last_status = t.get("status") or "running"
|
||||
transcript = t.get("transcript") or ""
|
||||
# Any growth in the transcript is the run telling us it is alive, so the clock starts over.
|
||||
if len(transcript) != last_len:
|
||||
last_len = len(transcript)
|
||||
last_progress_at = time.time()
|
||||
partial = transcript
|
||||
if last_status in ("running", "none"):
|
||||
continue
|
||||
return _ok(f"Test finished (status: {last_status}). Transcript:\n\n{transcript or '(empty transcript)'}")
|
||||
# Hand back whatever the run has actually produced. Returning only "call ReadTestTranscript" made
|
||||
# the model guess when to poll, which is the same dead end as not waiting at all; a partial
|
||||
# transcript is something it can reason about right now.
|
||||
waited = int(time.time() - started)
|
||||
head = (
|
||||
f"Test Agent (session {sid[:8]}) went quiet: no new output for {int(TEST_IDLE_S)}s "
|
||||
f"(status: {last_status}, waited {waited}s total). Everything it produced follows; "
|
||||
"call ReadTestTranscript if it lands later."
|
||||
)
|
||||
return _ok(f"{head}\n\n{partial.strip()}" if partial.strip() else head)
|
||||
|
||||
|
||||
def handle_read_test_transcript(args: dict) -> dict:
|
||||
|
||||
@@ -170,6 +170,22 @@ async def get_help_knowledge() -> HelpKnowledgeResponse:
|
||||
return build_knowledge_response()
|
||||
|
||||
|
||||
@help_app.router.get("/whats-new")
|
||||
def whats_new() -> dict:
|
||||
"""The release story for the in-app What's New card. Same words the Help agent and the GitHub
|
||||
body get, so a user never reads two different accounts of the same release."""
|
||||
from backend.apps.help.changelog import as_markdown, latest_release, release_notes
|
||||
from backend.apps.service.version import APP_VERSION
|
||||
note = release_notes(APP_VERSION) or latest_release()
|
||||
return {
|
||||
"version": note.version,
|
||||
"headline": note.headline,
|
||||
"highlights": note.highlights,
|
||||
"fixes": note.fixes,
|
||||
"markdown": as_markdown(note),
|
||||
}
|
||||
|
||||
|
||||
@help_app.router.post("/bundle")
|
||||
@typechecked
|
||||
async def build_bundle(body: BundleRequest) -> dict:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""The release story, in one place, for three surfaces: the in-app What's New card, the GitHub
|
||||
release body, and the Help agent's context. One source means the agent can never answer from a
|
||||
stale picture of the app, and a release can never ship with no story."""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typeguard import typechecked
|
||||
|
||||
|
||||
class ReleaseNote(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
version: str
|
||||
headline: str
|
||||
# User-facing lines only: what changed for the person using the app, not the diff.
|
||||
highlights: List[str]
|
||||
fixes: List[str]
|
||||
|
||||
|
||||
P_RELEASES: List[ReleaseNote] = [
|
||||
# Only lines that are true of the built app belong here. This one file feeds the in-app card, the
|
||||
# GitHub body AND the Help agent's context, so a line written for a planned feature becomes the
|
||||
# agent confidently describing something that does not exist.
|
||||
ReleaseNote(
|
||||
version="1.7.5",
|
||||
headline="Off means off, and the canvas stops tearing.",
|
||||
highlights=[
|
||||
"Deleting a scheduled workflow makes it stay deleted. One that was mid-run could previously save itself back and keep firing.",
|
||||
"Switching a workflow off now stops everything: no queued catch-up runs, nothing waiting on the review card.",
|
||||
"Scrolling inside a panel, list, or window stays in that panel instead of dragging the canvas with it.",
|
||||
"Opening a busy dashboard no longer locks the window while its cards wake up.",
|
||||
],
|
||||
fixes=[
|
||||
"The canvas background no longer tears into a hard-edged rectangle when lots of browsers are open.",
|
||||
"Editing a workflow step can no longer hang the editor when the naming service is slow.",
|
||||
"A crashed session no longer leaves a key watcher running, which made dictation start and immediately stop.",
|
||||
"The first message after opening a chat reuses the warmed-up connection, so it answers sooner.",
|
||||
"A provider hiccup that fixes itself no longer shows a scary reconnect card.",
|
||||
],
|
||||
),
|
||||
ReleaseNote(
|
||||
version="1.7.4",
|
||||
headline="Chats survive a hiccup instead of stopping.",
|
||||
highlights=[
|
||||
"A dropped local connection retries and resumes the same answer instead of failing the message.",
|
||||
"The spawn composer steps aside when a window is open.",
|
||||
],
|
||||
fixes=[
|
||||
"App previews reconnect on their own after a backend restart.",
|
||||
"Dictation cue sounds default to a level you can actually hear.",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@typechecked
|
||||
def release_notes(version: str) -> ReleaseNote | None:
|
||||
for note in P_RELEASES:
|
||||
if note.version == version:
|
||||
return note
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def latest_release() -> ReleaseNote:
|
||||
return P_RELEASES[0]
|
||||
|
||||
|
||||
@typechecked
|
||||
def as_markdown(note: ReleaseNote) -> str:
|
||||
"""The GitHub release body; identical words to the in-app card, so nobody reads two stories."""
|
||||
lines = [f"## {note.version}: {note.headline}", ""]
|
||||
if note.highlights:
|
||||
lines.append("### New")
|
||||
lines += [f"- {h}" for h in note.highlights]
|
||||
lines.append("")
|
||||
if note.fixes:
|
||||
lines.append("### Fixed")
|
||||
lines += [f"- {f}" for f in note.fixes]
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
@typechecked
|
||||
def help_context_block(app_version: str) -> str:
|
||||
"""What the Help agent must know about what just changed, so "what's new" is never stale."""
|
||||
note = release_notes(app_version) or latest_release()
|
||||
body = [f"Version {note.version}: {note.headline}"]
|
||||
body += [f"- new: {h}" for h in note.highlights]
|
||||
body += [f"- fixed: {f}" for f in note.fixes]
|
||||
return "\n".join(body)
|
||||
|
||||
|
||||
@typechecked
|
||||
def all_versions() -> Dict[str, str]:
|
||||
return {n.version: n.headline for n in P_RELEASES}
|
||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel, ConfigDict
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.help.help_topics import HELP_TOPICS, HelpTopic
|
||||
from backend.apps.help.changelog import help_context_block
|
||||
from backend.apps.help.known_issues import KNOWN_ISSUES, HelpKnownIssue
|
||||
from backend.apps.help.prompt_rules import GROUNDING_RULES, ROLE
|
||||
|
||||
@@ -158,6 +159,11 @@ def build_system_prompt(shortcuts: List[HelpShortcut], app_version: str) -> str:
|
||||
shortcut_lines,
|
||||
"</shortcuts>",
|
||||
"",
|
||||
"<whats_new>",
|
||||
"What actually changed in this build. Answer \"what's new\" from THIS, never from memory.",
|
||||
help_context_block(app_version),
|
||||
"</whats_new>",
|
||||
"",
|
||||
"<known_issues>",
|
||||
"The complete list of issues shipped with this build. You cannot see live bug reports.",
|
||||
p_issues_block(),
|
||||
|
||||
@@ -11,6 +11,7 @@ from backend.apps.agents.core.aux_llm import aux_max_tokens_for
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.agents.manager.predict_followups import conversation_tail
|
||||
from backend.apps.agents.manager.session.history_compaction import get_branch_messages
|
||||
from backend.apps.memory import store
|
||||
from backend.apps.memory.store import add_fact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -55,17 +56,45 @@ async def distill_session_memory(session: AgentSession) -> List[str]:
|
||||
p_last_distilled[session.id] = p_user_message_count(session)
|
||||
aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0]
|
||||
client = get_anthropic_client_for_model(global_settings, aux_model)
|
||||
# Deliberately harsh. The permissive version filled a real user's memory with task summaries
|
||||
# ("works on an app with Slack OAuth, fullscreen modes, pinch-to-zoom") and topic echoes
|
||||
# ("interested in AI agents") that were just the last thing they happened to ask about. Saying
|
||||
# NOTHING costs nothing; a junk fact is paid for on every turn of every chat, forever.
|
||||
system_prompt = (
|
||||
"You extract durable facts about the USER from a conversation with their AI agent: "
|
||||
"who they are, what they work on, standing preferences, constraints they stated. "
|
||||
"Facts must be about the user themselves and still true next month; never task details, "
|
||||
"never one-off requests, never anything the ASSISTANT said, never secrets, keys, or "
|
||||
"passwords. Write each fact self-contained in third person, under 200 characters "
|
||||
'(e.g. "Prefers concise answers with real measured numbers").\n\n'
|
||||
"You maintain a small, permanent profile of the USER as a PERSON. It is read on every "
|
||||
"turn of every future conversation, so a wrong or trivial entry is expensive and a "
|
||||
"missing one costs nothing. Default to NOTHING.\n\n"
|
||||
"SAVE only a fact that passes ALL FIVE:\n"
|
||||
"1. It is about the person: who they are, their role, their tools and languages, how "
|
||||
"they want to be worked with, a constraint or standing rule they set.\n"
|
||||
"2. They stated or clearly demonstrated it about THEMSELVES. Never infer a trait from "
|
||||
"the fact that they asked about a topic once.\n"
|
||||
"3. It is still true in six months, whatever they happen to be working on then.\n"
|
||||
"4. It changes how an assistant should behave in an UNRELATED future conversation.\n"
|
||||
"5. It is not already obvious from whatever they are working on at the time.\n\n"
|
||||
"NEVER save: what they are building or its features; anything about the current task, "
|
||||
"bug, file, or request; a topic they asked about; anything the ASSISTANT said, did, or "
|
||||
"suggested; anything time-bound; secrets, keys, tokens, or passwords.\n\n"
|
||||
'GOOD: "Prefers answers with real measured numbers over estimates" - '
|
||||
'"Works solo and ships releases himself" - "Writes TypeScript and Python".\n'
|
||||
'BAD: "Works on an app with Slack OAuth and pinch-to-zoom" (that is the project, not '
|
||||
'the person) - "Interested in AI agents and LLM tooling" (that is just the topic they '
|
||||
'raised) - "Wants a weekly news digest" (that is a request they made).\n\n'
|
||||
"Write each fact self-contained, third person, under 200 characters.\n"
|
||||
f"Return at most {MAX_FACTS_PER_DISTILL} facts, one per line, no numbering, no quotes. "
|
||||
"If the conversation reveals nothing durable, return the single word NOTHING."
|
||||
"Most conversations should yield none: if in any doubt, return the single word NOTHING."
|
||||
)
|
||||
user_turn = "Conversation:\n<transcript>\n" + tail + "\n</transcript>\n\nExtract the facts."
|
||||
# Show it what is already known. Without this the model re-derives facts it recorded weeks
|
||||
# ago, phrased differently every time, and the store's token-overlap guard cannot catch a
|
||||
# paraphrase: the four duplicate pairs found in a real memory scored 0.11 to 0.33 against a
|
||||
# 0.60 threshold. The generator is semantic, so the dedupe has to be too.
|
||||
known = "\n".join(f"- {f.text}" for f in store.list_facts())
|
||||
already = (
|
||||
"Facts already stored (do NOT repeat these, in any wording):\n" + known + "\n\n"
|
||||
"Return a fact ONLY if it is genuinely new, or is strictly more specific than one above "
|
||||
"(in which case return the sharper version and it will replace the old one).\n\n"
|
||||
) if known else ""
|
||||
user_turn = already + "Conversation:\n<transcript>\n" + tail + "\n</transcript>\n\nExtract the facts."
|
||||
chunks: List[str] = []
|
||||
async with client.messages.stream(
|
||||
model=aux_model,
|
||||
|
||||
@@ -351,9 +351,12 @@ def p_report_start_failure(reason: str, *, detail: str = "", **fields: Any) -> N
|
||||
try:
|
||||
from backend.apps.agents.core.redact_for_telemetry import redact_for_telemetry
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
from backend.apps.agents.core.flight_recorder import journey_auth_context
|
||||
payload: dict[str, Any] = {
|
||||
"kind": "9router_start_failed",
|
||||
"reason": reason,
|
||||
# No session owns this failure, but WHO it happened to still decides the fix.
|
||||
"journey": journey_auth_context(),
|
||||
"packaged": os.environ.get("OPENSWARM_PACKAGED") == "1",
|
||||
**fields,
|
||||
}
|
||||
@@ -463,6 +466,19 @@ async def death_watch(proc_handle: "subprocess.Popen[Any]") -> None:
|
||||
logger.warning("9Router died 3x in 60s; leaving revival to the backed-off watchdog")
|
||||
return
|
||||
logger.warning("9Router process died; instant revive")
|
||||
# The revive IS a safety net firing; the near-miss ledger counts it so router flap rates are queryable.
|
||||
try:
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
from backend.apps.agents.core.flight_recorder import journey_auth_context
|
||||
# scope says WHY there is no session or lane here: the watchdog outlives any one turn.
|
||||
submit_diagnostic({
|
||||
"kind": "recovered",
|
||||
"subkind": "router-revive",
|
||||
"scope": "watchdog",
|
||||
"journey": journey_auth_context(),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
p_is_running_last_ok = 0.0
|
||||
await ensure_running()
|
||||
|
||||
|
||||
@@ -274,6 +274,22 @@ and flips `BACKEND_PORT` in both `.env` and `.env.example`. Then run
|
||||
- Install your own venv or `pip install` manually.
|
||||
- Edit `backend/run.sh` or the SubApp framework.
|
||||
|
||||
**Persist anything the user comes back to. Your process is disposable.**
|
||||
OpenSwarm freezes this app's process when its card closes and fully kills
|
||||
it after ~15 minutes idle, on quit, and on crash. A module-level list or
|
||||
dict is therefore data loss on a timer. The scaffold ships a durable
|
||||
store; use it (or your own files under `backend/data/`):
|
||||
|
||||
```python
|
||||
from backend.apps.store.store import load_store, save_store
|
||||
|
||||
data = load_store() # {} on first run, never raises
|
||||
data["items"] = [*data.get("items", []), new_item]
|
||||
save_store(data) # atomic write; a kill mid-write keeps the old data
|
||||
```
|
||||
|
||||
Holding state only in memory is a bug, not a style choice.
|
||||
|
||||
Adding a new endpoint is just adding a new SubApp:
|
||||
|
||||
```python
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
@@ -63,9 +64,37 @@ async def outputs_lifespan():
|
||||
recover_orphaned_apps()
|
||||
except Exception:
|
||||
logger.exception("orphaned-app recovery failed; apps stay hidden but nothing else breaks")
|
||||
# Ghosts from a session that died badly keep running forever: stop_all only fires on a clean
|
||||
# shutdown, and the port-collision path routes AROUND a squatter instead of killing it. Measured
|
||||
# on a dev box: runtimes still alive after 2 days 19 hours. Boot is the one safe moment, since we
|
||||
# have not spawned any of our own yet.
|
||||
try:
|
||||
from backend.apps.outputs.reap_ghost_runtimes import reap_ghost_runtimes
|
||||
ghosts = reap_ghost_runtimes()
|
||||
if ghosts:
|
||||
logger.warning("outputs lifespan: reaped %d ghost runtime(s) from a previous session", ghosts)
|
||||
except Exception:
|
||||
logger.exception("ghost-runtime reap failed; stale processes stay but boot continues")
|
||||
# The boot reap catches ghosts from a PREVIOUS session, but a session can live for days: this
|
||||
# sweep keeps catching them while we run (another backend dying leaves orphans mid-session) and
|
||||
# retires idle runtimes past their TTL, so "quit but still around" has a bounded lifetime.
|
||||
async def p_periodic_sweep() -> None:
|
||||
from backend.apps.outputs.reap_ghost_runtimes import reap_ghost_runtimes
|
||||
from backend.apps.outputs.runtime import manager as p_sweep_manager
|
||||
while True:
|
||||
await asyncio.sleep(600)
|
||||
try:
|
||||
ghosts = await asyncio.to_thread(reap_ghost_runtimes)
|
||||
stale = await p_sweep_manager.reap_stale_idle()
|
||||
if ghosts or stale:
|
||||
logger.info("periodic sweep: %d ghost(s) reaped, %d stale idle runtime(s) stopped", ghosts, stale)
|
||||
except Exception:
|
||||
logger.exception("periodic sweep failed; will retry next interval")
|
||||
p_sweep_task = asyncio.create_task(p_periodic_sweep())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
p_sweep_task.cancel()
|
||||
# Reap every per-app subprocess. Without this each `bash run.sh` (and its vite/uvicorn descendants) reparents to PID 1 when the main backend dies, leaving ghost listeners on the .env-pinned ports that block the next OpenSwarm launch's reload preview.
|
||||
try:
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Kill app-runtime processes left behind by a previous OpenSwarm that died badly.
|
||||
|
||||
`stop_all()` reaps runtimes on a CLEAN shutdown. A crash, a SIGKILL, or a force-quit skips it, and
|
||||
every `bash run.sh` plus its vite/uvicorn descendants reparents to PID 1 and keeps running: measured
|
||||
on a dev machine, ghosts had been alive for **2 days 19 hours**, still holding their ports. The only
|
||||
existing handling reallocates around a ghost that squats a port, so the ghost never dies at all and
|
||||
they accumulate across sessions.
|
||||
|
||||
This runs at startup, before any runtime is spawned, which is the one moment when a workspace process
|
||||
cannot legitimately belong to us: we have not started any yet.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.outputs.runtime_proc import kill_descendant_tree
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Grace between TERM and KILL. Long enough for a run.sh EXIT trap to clean up its ports, short enough
|
||||
# that boot does not visibly stall on it.
|
||||
REAP_GRACE_SECONDS = float(os.environ.get("OSW_REAP_GRACE_SECONDS", "1.5"))
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_live_backend_pids() -> set:
|
||||
"""PIDs of every running backend. A workspace process descended from one of these is ALIVE and
|
||||
owned, not a ghost; a first draft of this reaper matched on the workspace path alone and would
|
||||
have killed 14 working app runtimes on a machine where the owning backend was up."""
|
||||
try:
|
||||
out = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, timeout=8)
|
||||
except Exception:
|
||||
return set()
|
||||
pids = set()
|
||||
for line in (out.stdout or "").splitlines():
|
||||
if "uvicorn" not in line or "backend.main" not in line:
|
||||
continue
|
||||
head = line.strip().split(None, 1)
|
||||
if head and head[0].isdigit():
|
||||
pids.add(int(head[0]))
|
||||
return pids
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_ppid_map() -> dict:
|
||||
try:
|
||||
out = subprocess.run(["ps", "-eo", "pid=,ppid="], capture_output=True, text=True, timeout=8)
|
||||
except Exception:
|
||||
return {}
|
||||
m = {}
|
||||
for line in (out.stdout or "").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||
m[int(parts[0])] = int(parts[1])
|
||||
return m
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_cwd_map(needle: str) -> dict:
|
||||
"""pid -> cwd, for processes whose working directory sits under the workspace.
|
||||
|
||||
An app's backend is spawned as `python -u backend.py` with `cwd=<workspace>`, so the workspace
|
||||
path appears NOWHERE in its argv: an argv-only scan is structurally blind to exactly the ghost
|
||||
we most want dead. lsof is the only way to read another process's cwd on macOS. Best-effort by
|
||||
design, since a machine that restricts lsof must still boot.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["lsof", "-a", "-d", "cwd", "-Fn"], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
m = {}
|
||||
pid = None
|
||||
for raw in (out.stdout or "").splitlines():
|
||||
if not raw:
|
||||
continue
|
||||
tag, val = raw[0], raw[1:]
|
||||
if tag == "p" and val.isdigit():
|
||||
pid = int(val)
|
||||
elif tag == "n" and pid is not None and val.casefold().startswith(needle):
|
||||
m[pid] = val
|
||||
return m
|
||||
|
||||
|
||||
@typechecked
|
||||
def find_ghost_runtime_pids() -> List[int]:
|
||||
"""PIDs of workspace processes that NO live backend owns.
|
||||
|
||||
Matched on the absolute workspace path (in argv OR as the process's working directory), so an
|
||||
unrelated `npm run dev` elsewhere is never touched, then filtered by walking each candidate's
|
||||
ancestry: if a live backend is anywhere above it, it is someone's working app and is left alone.
|
||||
"""
|
||||
# Case-FOLDED needle. macOS's default filesystem is case-insensitive, so a process may report
|
||||
# `.../openswarm/...` while our resolved path is `.../OpenSwarm/...`: the same folder, but a
|
||||
# case-sensitive `in` check misses it and the ghost survives (found live on a packaged smoke).
|
||||
needle = os.path.abspath(WORKSPACE_DIR).casefold()
|
||||
try:
|
||||
out = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, timeout=8)
|
||||
except Exception:
|
||||
return []
|
||||
mine = os.getpid()
|
||||
owners = p_live_backend_pids()
|
||||
parents = p_ppid_map()
|
||||
# WE are a backend, so a scan that finds no live backend has failed, not found ghosts: an empty
|
||||
# owner set turns every working app into a "ghost" and the sweep would kill them all mid-use.
|
||||
# Boot relied on running before anything spawned; the 10-minute sweep gets no such alibi.
|
||||
if not owners or not parents:
|
||||
return []
|
||||
by_cwd = p_cwd_map(needle)
|
||||
candidates = dict.fromkeys(by_cwd)
|
||||
for line in (out.stdout or "").splitlines():
|
||||
line = line.strip()
|
||||
if needle not in line.casefold():
|
||||
continue
|
||||
head = line.split(None, 1)
|
||||
if head and head[0].isdigit():
|
||||
candidates[int(head[0])] = None
|
||||
ghosts: List[int] = []
|
||||
for pid in candidates:
|
||||
if pid == mine:
|
||||
continue
|
||||
cur, owned, broken = pid, False, False
|
||||
for _ in range(24):
|
||||
if cur in owners or cur == mine:
|
||||
owned = True
|
||||
break
|
||||
if cur <= 1:
|
||||
break
|
||||
nxt = parents.get(cur)
|
||||
if nxt is None:
|
||||
# The pid list and the ppid map are two separate ps snapshots; a process spawned
|
||||
# between them has no entry here. Indeterminate is NOT ghost: skip, never kill.
|
||||
broken = True
|
||||
break
|
||||
cur = nxt
|
||||
if not owned and not broken:
|
||||
ghosts.append(pid)
|
||||
return ghosts
|
||||
|
||||
|
||||
@typechecked
|
||||
def reap_ghost_runtimes() -> int:
|
||||
"""Reap them, leaves-first. Returns how many top-level processes were signalled.
|
||||
|
||||
Fire-and-forget by design: a machine where `ps` is restricted or a PID that vanishes between the
|
||||
scan and the kill must never stop the backend from booting.
|
||||
"""
|
||||
pids = find_ghost_runtime_pids()
|
||||
if not pids:
|
||||
return 0
|
||||
logger.warning(
|
||||
"reaping %d ghost app-runtime process(es) left by a previous session: %s",
|
||||
len(pids), pids[:12],
|
||||
)
|
||||
killed = 0
|
||||
for pid in pids:
|
||||
try:
|
||||
# THAW FIRST. Idle app runtimes are frozen with SIGSTOP, and a stopped process never
|
||||
# handles SIGTERM: it just queues it and stays alive forever. Measured live, a frozen
|
||||
# ghost that had survived every reap for 2 days 21 hours.
|
||||
kill_descendant_tree(pid, "CONT")
|
||||
os.kill(pid, signal.SIGCONT)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
try:
|
||||
kill_descendant_tree(pid, "TERM")
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
killed += 1
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
continue
|
||||
# A ghost's run.sh traps EXIT but not TERM, so give the tree a moment, then take out whatever
|
||||
# ignored us. A ghost has no work worth protecting, so escalation is always the right call.
|
||||
time.sleep(REAP_GRACE_SECONDS)
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
kill_descendant_tree(pid, "KILL")
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
logger.warning("ghost %d ignored TERM; escalated to KILL", pid)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
continue
|
||||
return killed
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from collections import deque, OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
@@ -30,6 +31,7 @@ from backend.apps.outputs.runtime_proc import (
|
||||
FRONTEND_BIND_POLL_INTERVAL,
|
||||
FRONTEND_BIND_TIMEOUT_SECONDS,
|
||||
LOG_BUFFER_LINES,
|
||||
IDLE_RUNTIME_TTL_S,
|
||||
MAX_IDLE_RUNTIMES,
|
||||
RECENT_ERRORS_MAX,
|
||||
TERMINATE_GRACE_SECONDS,
|
||||
@@ -582,6 +584,8 @@ class AppRuntimeManager:
|
||||
self.p_attached: dict[str, int] = {}
|
||||
# workspace_id → AppRuntime with no subscribers but still alive. OrderedDict gives O(1) move_to_end + popitem(last=False) for LRU semantics.
|
||||
self.idle_lru: "OrderedDict[str, AppRuntime]" = OrderedDict()
|
||||
# workspace key -> monotonic seconds when it was parked; drives the idle TTL sweep.
|
||||
self.p_idle_since: dict[str, float] = {}
|
||||
self.p_lock = asyncio.Lock()
|
||||
# Public: tests cancel it during teardown.
|
||||
self.restart_watch_task: Optional[asyncio.Task] = None
|
||||
@@ -631,6 +635,7 @@ class AppRuntimeManager:
|
||||
if rt is None:
|
||||
# Maybe the runtime is sitting idle in the LRU; revive it without paying the spawn cost again.
|
||||
idle_rt = self.idle_lru.pop(key, None)
|
||||
self.p_idle_since.pop(key, None)
|
||||
if idle_rt is not None and idle_rt.running:
|
||||
rt = idle_rt
|
||||
rt.workspace_path = workspace_path
|
||||
@@ -678,10 +683,12 @@ class AppRuntimeManager:
|
||||
else:
|
||||
self.idle_lru[key] = rt
|
||||
self.idle_lru.move_to_end(key)
|
||||
self.p_idle_since[key] = time.monotonic()
|
||||
suspend_process_tree(rt.process)
|
||||
rt.p_suspended = True
|
||||
while len(self.idle_lru) > MAX_IDLE_RUNTIMES:
|
||||
_, old_rt = self.idle_lru.popitem(last=False)
|
||||
old_key, old_rt = self.idle_lru.popitem(last=False)
|
||||
self.p_idle_since.pop(old_key, None)
|
||||
# Reaping a stopped process: SIGCONT first so the SIGTERM in stop() can be delivered cleanly (a SIGSTOP'd process can't run its own shutdown).
|
||||
resume_process_tree(old_rt.process)
|
||||
to_reap.append(old_rt)
|
||||
@@ -696,6 +703,27 @@ class AppRuntimeManager:
|
||||
if to_idle is not None:
|
||||
logger.debug("workspace %s idled (LRU size now %d)", key, len(self.idle_lru))
|
||||
|
||||
async def reap_stale_idle(self, ttl_s: float = IDLE_RUNTIME_TTL_S) -> int:
|
||||
"""Fully stop idle runtimes parked longer than ttl_s. Frozen is 0% CPU but never 0 cost:
|
||||
each one holds its memory and its port for as long as it sits there, which is the "app is
|
||||
quit but something of it is still around" complaint. Returns how many were stopped."""
|
||||
now = time.monotonic()
|
||||
stale: list[AppRuntime] = []
|
||||
async with self.p_lock:
|
||||
for key in [k for k, t in self.p_idle_since.items() if now - t >= ttl_s]:
|
||||
rt = self.idle_lru.pop(key, None)
|
||||
self.p_idle_since.pop(key, None)
|
||||
if rt is None:
|
||||
continue
|
||||
resume_process_tree(rt.process)
|
||||
stale.append(rt)
|
||||
for rt in stale:
|
||||
try:
|
||||
await rt.stop()
|
||||
except Exception:
|
||||
logger.exception("failed to stop stale idle runtime")
|
||||
return len(stale)
|
||||
|
||||
def get(self, workspace_id: str, instance: int = 1) -> Optional[AppRuntime]:
|
||||
key = runtime_key(workspace_id, instance)
|
||||
# Active subscribers see the live runtime; idle-pool members are also accessible so a status probe between detach and the next attach still works.
|
||||
|
||||
@@ -25,7 +25,14 @@ FRONTEND_BIND_POLL_INTERVAL = 0.08
|
||||
LOG_BUFFER_LINES = 2000
|
||||
|
||||
# Idle runtimes kept in LRU; trades memory for instant switch-back, beyond 1 because typical users ping-pong 2-3 apps.
|
||||
MAX_IDLE_RUNTIMES = 3
|
||||
# Raised from 3 once the idle TTL landed: the cap used to be the ONLY bound on parked memory, so it
|
||||
# had to be tight; now anything unattended dies at 15 minutes regardless, so the pool can afford to
|
||||
# make instant-reopen cover a realistic handful of apps instead of the last three touched.
|
||||
MAX_IDLE_RUNTIMES = 6
|
||||
# How long a detached runtime may sit frozen in the idle pool before it is fully stopped. Frozen
|
||||
# costs 0% CPU but keeps holding memory and its port; past this nobody is coming back for it soon
|
||||
# and a fresh spawn on the next open is a fair trade for not squatting RAM indefinitely.
|
||||
IDLE_RUNTIME_TTL_S = 15 * 60.0
|
||||
|
||||
# Cap on recent error lines the agent gets; 50 is enough for babel error + stack + a few warnings.
|
||||
RECENT_ERRORS_MAX = 50
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Disk-backed app state. Use this instead of module-level variables for anything worth keeping.
|
||||
|
||||
The app's process is DISPOSABLE: OpenSwarm freezes it when its card closes, kills it after ~15
|
||||
minutes idle, on quit, and on crash. A module-level list or dict therefore silently loses the
|
||||
user's data on a schedule you don't control. This store survives all of that: one JSON file under
|
||||
backend/data/, written atomically so a kill mid-write can never corrupt it.
|
||||
|
||||
from backend.apps.store.store import load_store, save_store
|
||||
|
||||
items = load_store().get("items", [])
|
||||
items.append(new_item)
|
||||
save_store({**load_store(), "items": items})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any, Dict
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
P_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATA_DIR = os.path.join(P_BACKEND_DIR, "data")
|
||||
STORE_PATH = os.path.join(DATA_DIR, "store.json")
|
||||
|
||||
|
||||
@typechecked
|
||||
def load_store() -> Dict[str, Any]:
|
||||
"""The whole store as a dict; empty on first run or an unreadable file, never an exception."""
|
||||
try:
|
||||
with open(STORE_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
@typechecked
|
||||
def save_store(data: Dict[str, Any]) -> None:
|
||||
"""Replace the store atomically: temp file then rename, so a kill mid-write leaves the old data."""
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=DATA_DIR, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=1)
|
||||
os.replace(tmp, STORE_PATH)
|
||||
except OSError:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
@@ -399,6 +399,18 @@ def submit_diagnostic(diagnostic: dict) -> None:
|
||||
diagnostic["recent_log"] = snapshot()
|
||||
except Exception:
|
||||
pass
|
||||
# The local flight file: every diagnostic also lands in a rotating NDJSON when the sink is set,
|
||||
# so support is "send me one file" and forced-failure tests can verify envelopes offline.
|
||||
sink = os.environ.get("OPENSWARM_DIAG_SINK")
|
||||
if sink:
|
||||
try:
|
||||
import json as p_json
|
||||
if os.path.exists(sink) and os.path.getsize(sink) > 1_000_000:
|
||||
os.replace(sink, sink + ".0")
|
||||
with open(sink, "a", encoding="utf-8") as f:
|
||||
f.write(p_json.dumps({"t": time.time(), **diagnostic}, default=str) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
submit("diagnostic", {"diagnostic": diagnostic})
|
||||
|
||||
|
||||
|
||||
@@ -304,8 +304,13 @@ async def usage_summary(window: str = "30d"):
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
|
||||
sessions = p_load_all_sessions()
|
||||
# A live session is usually already on disk, so appending it blind counted the same chat twice.
|
||||
seen_ids = {s.get("id") for s in sessions if s.get("id")}
|
||||
for s in agent_manager.get_all_sessions():
|
||||
sessions.append(s.model_dump(mode="json"))
|
||||
live = s.model_dump(mode="json")
|
||||
if live.get("id") and live["id"] in seen_ids:
|
||||
sessions = [d for d in sessions if d.get("id") != live["id"]]
|
||||
sessions.append(live)
|
||||
|
||||
days = P_WINDOW_DAYS.get(window, 30)
|
||||
if days:
|
||||
@@ -332,6 +337,9 @@ async def usage_summary(window: str = "30d"):
|
||||
provider_counts: Counter = Counter()
|
||||
tool_counts: Counter = Counter()
|
||||
status_counts: Counter = Counter()
|
||||
day_counts: Counter = Counter()
|
||||
hour_counts: Counter = Counter()
|
||||
longest_run_seconds = 0.0
|
||||
|
||||
excluded_automation = 0
|
||||
kept = []
|
||||
@@ -359,6 +367,13 @@ async def usage_summary(window: str = "30d"):
|
||||
model_counts[p_friendly_model(s.get("model", "unknown"))] += 1
|
||||
provider_counts[s.get("provider", "anthropic")] += 1
|
||||
status_counts[s.get("status", "unknown")] += 1
|
||||
created = s.get("created_at") or ""
|
||||
if len(created) >= 13:
|
||||
day_counts[created[:10]] += 1
|
||||
try:
|
||||
hour_counts[int(created[11:13])] += 1
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Tool calls: tool_latencies carries authoritative per-tool counts; older sessions only have the sparse tool_call messages. Per session take whichever source recorded more so we never undercount what's on record (and so the total never drops below the old message-only count).
|
||||
lat_counts: Counter = Counter()
|
||||
@@ -376,18 +391,14 @@ async def usage_summary(window: str = "30d"):
|
||||
total_tool_calls += sum(chosen.values())
|
||||
tool_counts.update(chosen)
|
||||
|
||||
# Run time: real agent-active time when tracked, else session wall-clock as a rough proxy.
|
||||
# Measured agent-active time only. The old wall-clock fallback billed a card you left open all
|
||||
# day as work: one "Open browser" session claimed 11 hours, and 45% of the headline came from
|
||||
# 4% of sessions that way.
|
||||
run_s = (s.get("agent_active_ms") or 0) / 1000.0
|
||||
if run_s <= 0:
|
||||
created, closed = s.get("created_at"), s.get("closed_at")
|
||||
if created and closed:
|
||||
try:
|
||||
run_s = (datetime.fromisoformat(closed[:19]) - datetime.fromisoformat(created[:19])).total_seconds()
|
||||
except Exception:
|
||||
run_s = 0
|
||||
if run_s > 0:
|
||||
total_run_seconds += run_s
|
||||
timed_sessions += 1
|
||||
longest_run_seconds = max(longest_run_seconds, run_s)
|
||||
|
||||
avg_duration = total_run_seconds / timed_sessions if timed_sessions > 0 else 0
|
||||
completed = status_counts.get("completed", 0)
|
||||
@@ -437,7 +448,12 @@ async def usage_summary(window: str = "30d"):
|
||||
"total_messages": total_messages,
|
||||
"total_tool_calls": total_tool_calls,
|
||||
"total_run_seconds": round(total_run_seconds, 1),
|
||||
"timed_sessions": timed_sessions,
|
||||
"longest_run_seconds": round(longest_run_seconds, 1),
|
||||
"avg_duration_seconds": round(avg_duration, 1),
|
||||
"status_breakdown": dict(status_counts),
|
||||
"daily_activity": [{"day": d, "chats": n} for d, n in sorted(day_counts.items())],
|
||||
"hourly_activity": [hour_counts.get(h, 0) for h in range(24)],
|
||||
"avg_cost_per_session": round(avg_cost, 4),
|
||||
"completion_rate": round(completion_rate, 3),
|
||||
"models_used": dict(model_counts.most_common(10)),
|
||||
|
||||
@@ -33,8 +33,10 @@ DEFAULT_SYSTEM_PROMPT = (
|
||||
"4. **Unsure which server.** `MCPList` for a cheap survey, or "
|
||||
'`MCPSearch("<what you need>")` to rank servers by relevance. Do this before '
|
||||
"MCPActivate, never via ToolSearch.\n"
|
||||
"5. **No tool fits.** WebSearch / WebFetch for information. BrowserAgent only for "
|
||||
"visual interaction, form filling, or sites with no API path.\n\n"
|
||||
"5. **Reading the web.** WebSearch / WebFetch first, always: they are far faster than "
|
||||
"driving a browser and they cover ordinary pages. Escalate to BrowserAgent only once "
|
||||
"they have actually come back thin or blocked (login wall, paywall, JS-only page), or "
|
||||
"when the task needs visual interaction or form filling.\n\n"
|
||||
"### Choosing among similar names\n"
|
||||
"A matching name is a hypothesis, not an answer. Before calling, read the description "
|
||||
"and the required parameters, and confirm three things: it performs the action you "
|
||||
|
||||
@@ -50,8 +50,19 @@ P_LEGACY_DEFAULT_SYSTEM_PROMPT = (
|
||||
"If you genuinely need clarification on something ambiguous, use the "
|
||||
"AskUserQuestion tool. Never ask questions inline in plain text.\n"
|
||||
)
|
||||
# The 820cf578-era revision, which differs from the current default only in ladder step 5 (it framed
|
||||
# the web tools as "No tool fits" so agents reached for the browser first). Derived, not duplicated.
|
||||
P_LEGACY_LADDER_V1 = DEFAULT_SYSTEM_PROMPT.replace(
|
||||
"5. **Reading the web.** WebSearch / WebFetch first, always: they are far faster than "
|
||||
"driving a browser and they cover ordinary pages. Escalate to BrowserAgent only once "
|
||||
"they have actually come back thin or blocked (login wall, paywall, JS-only page), or "
|
||||
"when the task needs visual interaction or form filling.\n\n",
|
||||
"5. **No tool fits.** WebSearch / WebFetch for information. BrowserAgent only for "
|
||||
"visual interaction, form filling, or sites with no API path.\n\n",
|
||||
)
|
||||
P_LEGACY_DEFAULT_SYSTEM_PROMPTS = (
|
||||
P_LEGACY_DEFAULT_SYSTEM_PROMPT,
|
||||
P_LEGACY_LADDER_V1,
|
||||
P_LEGACY_DEFAULT_SYSTEM_PROMPT.replace(
|
||||
"1. Connected MCP tools; fastest and most reliable. To reach an integration you "
|
||||
"don't already see, use MCPSearch then MCPActivate; never ToolSearch for it.\n",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Turn the install commands people already paste from READMEs into a skill id we can install.
|
||||
|
||||
The ecosystem's grammar is `npx skills add <name>`, and every neighbouring form (npm/pnpm/bunx,
|
||||
`install` instead of `add`, a bare `@scope/name`, a skills.sh URL, or just the name) means the same
|
||||
thing to the person pasting it. Accepting only our own button was the friction."""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
# The runners people actually have in their muscle memory.
|
||||
P_RUNNERS = ("npx", "npm", "pnpm", "pnpx", "yarn", "bunx", "bun", "deno")
|
||||
P_VERBS = ("add", "install", "i")
|
||||
# `pnpm dlx` and `yarn dlx` are those managers' npx, and dlx is the form READMEs actually print.
|
||||
P_RUNNER_SUBCOMMANDS = ("dlx", "exec", "run")
|
||||
P_SKILL_ID = re.compile(r"^[A-Za-z0-9@._/-]+$")
|
||||
|
||||
|
||||
@typechecked
|
||||
def parse_install_command(raw: str) -> Optional[str]:
|
||||
"""Return the skill id a pasted command refers to, or None when it is not an install command.
|
||||
|
||||
None means "I could not read this", never a guess: installing the wrong skill because a paste
|
||||
was ambiguous is worse than asking the user to pick from the list."""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
# A skills.sh (or GitHub) URL carries the id in its last meaningful path segment.
|
||||
if text.startswith(("http://", "https://")):
|
||||
parts = [p for p in text.split("?")[0].split("#")[0].rstrip("/").split("/") if p]
|
||||
tail = parts[-1] if parts else ""
|
||||
return tail if tail and P_SKILL_ID.match(tail) else None
|
||||
|
||||
# Strip a leading shell prompt or copy artifact ("$ npx ...").
|
||||
text = re.sub(r"^[$>#]\s*", "", text)
|
||||
tokens = text.split()
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
if tokens[0].lower() in P_RUNNERS:
|
||||
# npx skills add <id> | npm i skills <id> | bunx skills add <id>
|
||||
rest = [t for t in tokens[1:] if not t.startswith("-")]
|
||||
if rest and rest[0].lower() in P_RUNNER_SUBCOMMANDS:
|
||||
rest = rest[1:]
|
||||
# The verb and the package name arrive in either order ("npx skills add x", "npm i skills x"),
|
||||
# so strip both, in whichever order they appear.
|
||||
named_registry = False
|
||||
for _ in range(2):
|
||||
if rest and rest[0].lower() in ("skills", "skill", "@skills/cli", "openswarm"):
|
||||
rest = rest[1:]
|
||||
named_registry = True
|
||||
elif rest and rest[0].lower() in P_VERBS:
|
||||
rest = rest[1:]
|
||||
# Without the registry name this is just some other npx command, and `npx create-react-app foo`
|
||||
# must never read as "install the create-react-app skill".
|
||||
if not named_registry:
|
||||
return None
|
||||
candidate = rest[0] if rest else ""
|
||||
return candidate if candidate and P_SKILL_ID.match(candidate) else None
|
||||
|
||||
# A bare id or scoped package pasted on its own.
|
||||
if len(tokens) == 1 and P_SKILL_ID.match(tokens[0]) and "." not in tokens[0].split("/")[-1][:1]:
|
||||
return tokens[0]
|
||||
return None
|
||||
@@ -115,6 +115,20 @@ class p_InstallRequest(BaseModel):
|
||||
confirm: bool = False
|
||||
|
||||
|
||||
class p_ParseCommandRequest(BaseModel):
|
||||
# What the user pasted: "npx skills add pdf-filler", a skills.sh URL, or a bare name.
|
||||
command: str
|
||||
|
||||
|
||||
@skill_registry.router.post("/parse-command")
|
||||
def registry_parse_command(req: p_ParseCommandRequest) -> dict:
|
||||
"""Resolve a pasted install command to a skill id so the Marketplace's Add box accepts the
|
||||
grammar people already copy out of READMEs. Resolution only; nothing is installed here, and an
|
||||
unreadable paste returns null rather than a guess."""
|
||||
from backend.apps.skill_registry.parse_install_command import parse_install_command
|
||||
return {"skill_id": parse_install_command(req.command)}
|
||||
|
||||
|
||||
@skill_registry.router.post("/install")
|
||||
async def registry_install(req: p_InstallRequest):
|
||||
"""Install a community (skills.sh) skill, in two honest steps.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Which model a workflow runs on when nobody picked one.
|
||||
|
||||
`DEFAULT_MODEL` is `opus-5`, and that is Anthropic's API-KEY lane: the Claude subscription lane is a
|
||||
separate id (`opus-5-cc`). Defaulting every workflow to the literal therefore billed an API key a
|
||||
subscriber may not even have, and named a model a Codex or Gemini subscriber cannot run at all.
|
||||
Lives in its own module because both the routes and the executor need it, and importing one from the
|
||||
other is a cycle.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@typechecked
|
||||
def user_default_model() -> str:
|
||||
"""The model this user actually configured, never a vendor literal."""
|
||||
try:
|
||||
from backend.apps.settings.settings import load_settings
|
||||
chosen = (getattr(load_settings(), "default_model", "") or "").strip()
|
||||
return chosen or DEFAULT_MODEL
|
||||
except Exception:
|
||||
logger.debug("could not read the user's default model", exc_info=True)
|
||||
return DEFAULT_MODEL
|
||||
|
||||
|
||||
@typechecked
|
||||
def provider_for_model(model: Optional[str]) -> str:
|
||||
"""Derive the provider from the model instead of assuming Anthropic."""
|
||||
if not model:
|
||||
return "anthropic"
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import get_api_type
|
||||
return get_api_type(model) or "anthropic"
|
||||
except Exception:
|
||||
logger.debug("could not derive a provider for %s", model, exc_info=True)
|
||||
return "anthropic"
|
||||
@@ -7,6 +7,7 @@ routing, retries, and history all aligned with the rest of the app.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
@@ -14,13 +15,20 @@ from typing import Optional
|
||||
from backend.apps.agents.core.models import AgentConfig
|
||||
from backend.apps.workflows.models import Workflow, WorkflowRun
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
from backend.apps.workflows.default_model import provider_for_model, user_default_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# In-process map: workflow_id -> currently running run id. Prevents two overlapping fires for the same workflow (e.g. cron tick races a manual Run button) without serializing across the whole executor.
|
||||
_running: dict[str, str] = {}
|
||||
# Global admission on top of the per-workflow guard: every run is a full agent, and an agent's
|
||||
# browsers and apps are exempt from the renderer budget by design (sleeping a working agent's browser
|
||||
# blinds it), so the ONLY thing bounding total pressure is how many runs exist at once. A library of
|
||||
# 30 workflows whose schedules drift into alignment must queue, not stampede.
|
||||
MAX_CONCURRENT_RUNS = 3
|
||||
ADMISSION_WAIT_S = 600.0
|
||||
ADMISSION_POLL_S = 2.0
|
||||
_running_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@@ -207,6 +215,58 @@ async def execute(
|
||||
set_workflow_approval_step,
|
||||
)
|
||||
|
||||
# Off means off. A workflow the user deleted or switched off must not be startable from ANY path:
|
||||
# the scheduler, an agent tool, an invoke, a retry, the Run Now route, or a stale in-flight handle.
|
||||
# Guarding this per call site left every unguarded caller able to fire it, which is the field report
|
||||
# of a toggled-off workflow running itself. Turn it back on to run it.
|
||||
# Wait for a global slot BEFORE the off-means-off guard, so the guard runs on fresh state after
|
||||
# a possibly long wait (a workflow paused while queueing still gets refused, not run).
|
||||
p_admit_start = time.monotonic()
|
||||
while len(_running) >= MAX_CONCURRENT_RUNS:
|
||||
if time.monotonic() - p_admit_start >= ADMISSION_WAIT_S:
|
||||
p_busy = WorkflowRun(
|
||||
workflow_id=wf.id, status="skipped",
|
||||
error=f"{MAX_CONCURRENT_RUNS} workflows already running; gave up after {int(ADMISSION_WAIT_S)}s",
|
||||
scheduled_for=scheduled_for, started_at=datetime.now(),
|
||||
finished_at=datetime.now(), triggered_by=triggered_by,
|
||||
)
|
||||
p_wf_now = storage.get_workflow(wf.id)
|
||||
if p_wf_now is not None and p_wf_now.deleted_at is None:
|
||||
try:
|
||||
storage.record_run(p_busy)
|
||||
except Exception:
|
||||
logger.debug("could not record the admission-skip row", exc_info=True)
|
||||
return p_busy
|
||||
await asyncio.sleep(ADMISSION_POLL_S)
|
||||
p_live = storage.get_workflow(wf.id)
|
||||
p_refusal = None
|
||||
if p_live is None:
|
||||
# A hard delete leaves nothing to look up, and reading that as "no objection" is how a
|
||||
# deleted workflow still ran to the end and then wrote itself back to life.
|
||||
p_refusal = "Workflow deleted"
|
||||
elif p_live.deleted_at is not None:
|
||||
p_refusal = "Workflow deleted"
|
||||
elif not p_live.schedule.enabled:
|
||||
p_refusal = "Workflow is paused"
|
||||
if p_refusal is not None:
|
||||
p_skipped = WorkflowRun(
|
||||
workflow_id=wf.id, status="skipped", error=p_refusal,
|
||||
scheduled_for=scheduled_for, started_at=datetime.now(),
|
||||
finished_at=datetime.now(), triggered_by=triggered_by,
|
||||
)
|
||||
# Recorded, not just returned: a refusal the user cannot see in History reads as the run
|
||||
# vanishing, and the Run Now route reports whatever row lands.
|
||||
if p_live is not None and p_live.deleted_at is None:
|
||||
try:
|
||||
storage.record_run(p_skipped)
|
||||
except Exception:
|
||||
logger.debug("could not record the refusal row", exc_info=True)
|
||||
return p_skipped
|
||||
# Toggling a workflow off mid-run must stop it too, whatever started it. Comparing against the
|
||||
# state at START is what separates "the user just switched it off" from "it was already paused
|
||||
# and a human deliberately ran it anyway".
|
||||
p_started_enabled = bool(p_live.schedule.enabled) if p_live is not None else bool(wf.schedule.enabled)
|
||||
|
||||
run = WorkflowRun(
|
||||
workflow_id=wf.id,
|
||||
status="running",
|
||||
@@ -269,9 +329,9 @@ async def execute(
|
||||
resolved_allowed_tools = _resolve_allowed_tools(wf)
|
||||
config = AgentConfig(
|
||||
name=wf.title or "Workflow",
|
||||
model=wf.model or DEFAULT_MODEL,
|
||||
model=wf.model or user_default_model(),
|
||||
mode=wf.mode or "agent",
|
||||
provider=wf.provider or "anthropic",
|
||||
provider=wf.provider or provider_for_model(wf.model or user_default_model()),
|
||||
system_prompt=_resolve_system_prompt(wf),
|
||||
# None when the user has not frozen the Actions set, which means the workflow runs with the mode's full surface exactly like a chat does.
|
||||
allowed_tools=resolved_allowed_tools,
|
||||
@@ -362,7 +422,7 @@ async def execute(
|
||||
if fresh_wf is None or fresh_wf.deleted_at is not None:
|
||||
step_error = "Workflow deleted"
|
||||
break
|
||||
if triggered_by == "schedule" and not fresh_wf.schedule.enabled:
|
||||
if p_started_enabled and not fresh_wf.schedule.enabled:
|
||||
step_error = "Workflow paused"
|
||||
break
|
||||
# Broadcast the step bump before sending so RunningView flips the disc immediately, not after the agent finishes the step. Advancing means we're not paused; keep the broadcast authoritative so it never races a stale paused=True from the watcher.
|
||||
|
||||
@@ -36,6 +36,13 @@ _missed_cache: list[MissedRun] = []
|
||||
_cache_loaded = False
|
||||
_paused = False
|
||||
|
||||
# Ids deleted during this process's life. The cache hands out SHARED Workflow instances, so a run
|
||||
# already in flight when the user deletes still holds one and writes it back when it finishes, and
|
||||
# save_workflow used to recreate the file AND the cache entry, fully scheduled: the workflow rose
|
||||
# from the dead every time, which is exactly the "it never dies" field report. Only needs to live in
|
||||
# memory, because after a restart nothing holds a stale instance to write back.
|
||||
p_deleted_ids: set[str] = set()
|
||||
|
||||
|
||||
def _resolve_host_tz_name() -> str:
|
||||
"""Best-effort IANA name for the host. Mirrors apps/service/client.py."""
|
||||
@@ -158,8 +165,18 @@ def get_workflow(wid: str) -> Optional[Workflow]:
|
||||
return _workflow_cache.get(wid)
|
||||
|
||||
|
||||
def save_workflow(wf: Workflow) -> Workflow:
|
||||
def save_workflow(wf: Workflow, untrash: bool = False) -> Workflow:
|
||||
with _io_lock:
|
||||
if wf.id in p_deleted_ids:
|
||||
logger.info("ignoring a write-back for deleted workflow %s", wf.id)
|
||||
return wf
|
||||
# Trash is one-way too: only /restore passes untrash. Any other save carrying an older copy
|
||||
# (a run that started before the user hit delete) would otherwise clear deleted_at and put
|
||||
# the workflow back on the page with its schedule re-armed.
|
||||
prior = _workflow_cache.get(wf.id)
|
||||
if not untrash and prior is not None and prior.deleted_at is not None and wf.deleted_at is None:
|
||||
logger.info("ignoring a write-back that would untrash workflow %s", wf.id)
|
||||
return wf
|
||||
_ensure_dirs()
|
||||
_workflow_cache[wf.id] = wf
|
||||
p_atomic_write_json(_wf_path(wf.id), wf.model_dump(mode="json"))
|
||||
@@ -171,6 +188,9 @@ def reload_workflow(wid: str) -> Optional[Workflow]:
|
||||
instances, so a handler that mutated one and then failed must roll back through here or the
|
||||
unsaved change lingers until any later save persists it by accident."""
|
||||
with _io_lock:
|
||||
if wid in p_deleted_ids:
|
||||
_workflow_cache.pop(wid, None)
|
||||
return None
|
||||
path = _wf_path(wid)
|
||||
if not os.path.exists(path):
|
||||
_workflow_cache.pop(wid, None)
|
||||
@@ -184,6 +204,7 @@ def reload_workflow(wid: str) -> Optional[Workflow]:
|
||||
def delete_workflow(wid: str) -> bool:
|
||||
with _io_lock:
|
||||
existed = wid in _workflow_cache
|
||||
p_deleted_ids.add(wid)
|
||||
_workflow_cache.pop(wid, None)
|
||||
_runs_cache.pop(wid, None)
|
||||
wf_file = _wf_path(wid)
|
||||
@@ -217,6 +238,8 @@ def list_all_runs(limit: int = 200) -> list[WorkflowRun]:
|
||||
|
||||
def record_run(run: WorkflowRun) -> WorkflowRun:
|
||||
with _io_lock:
|
||||
if run.workflow_id in p_deleted_ids:
|
||||
return run
|
||||
_ensure_dirs()
|
||||
arr = _runs_cache.setdefault(run.workflow_id, [])
|
||||
# Replace prior entry with same id if we're updating an in-flight run.
|
||||
|
||||
@@ -22,6 +22,7 @@ from backend.apps.workflows.models import (
|
||||
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
|
||||
from backend.apps.workflows.cloud.handover import release_before_removing
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
from backend.apps.workflows.default_model import provider_for_model, user_default_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -226,6 +227,14 @@ async def p_sync_cloud_copy(wf: Workflow, data: dict) -> None:
|
||||
raise HTTPException(status_code=502, detail=outcome.message or "The cloud copy could not be updated; try again.")
|
||||
|
||||
|
||||
def p_drop_pending_missed(workflow_id: str) -> None:
|
||||
"""Forget queued missed fires for a workflow. Switching one off has to clear the queue the same
|
||||
way trashing does, or the launch review card still offers to run it and the run is then refused."""
|
||||
stale = [m.id for m in storage.list_missed() if m.workflow_id == workflow_id]
|
||||
if stale:
|
||||
storage.remove_missed(stale)
|
||||
|
||||
|
||||
def _normalize_schedule_state(wf: Workflow, source_allowed_tools: Optional[list[str]] = None) -> None:
|
||||
if wf.schedule.timezone == "local" and wf.schedule.enabled:
|
||||
wf.schedule.timezone = scheduler.host_timezone_name()
|
||||
@@ -284,9 +293,9 @@ async def create_workflow(body: WorkflowCreate):
|
||||
permissions=body.permissions or [],
|
||||
source_session_id=body.source_session_id,
|
||||
dashboard_id=body.dashboard_id,
|
||||
model=body.model or DEFAULT_MODEL,
|
||||
model=body.model or user_default_model(),
|
||||
mode=body.mode or "agent",
|
||||
provider=body.provider or "anthropic",
|
||||
provider=body.provider or provider_for_model(body.model or user_default_model()),
|
||||
cost_cap_usd_monthly=body.cost_cap_usd_monthly,
|
||||
auto_named=body.auto_named,
|
||||
unsaved=body.unsaved,
|
||||
@@ -448,6 +457,10 @@ async def p_generate_metadata_for_steps(
|
||||
|
||||
_PLACEHOLDER_TITLES = {"", "New workflow", "Untitled workflow", "Scheduled workflow"}
|
||||
|
||||
# Ceiling on the cosmetic label/title aux call, which runs INSIDE the step-edit request. The SDK's own
|
||||
# stream timeout is minutes, long enough that a stalled lane reads as the editor being dead.
|
||||
AUX_LABEL_TIMEOUT_S = 20.0
|
||||
|
||||
|
||||
def p_fallback_title_for_steps(steps: list[WorkflowStep]) -> str:
|
||||
"""Deterministic title derived from the steps, used when the aux model is
|
||||
@@ -510,9 +523,16 @@ async def p_relabel_steps(
|
||||
if not regen_idxs and not need_autoname:
|
||||
return
|
||||
try:
|
||||
title, description, labels = await p_generate_metadata_for_steps(steps, model)
|
||||
title, description, labels = await asyncio.wait_for(
|
||||
p_generate_metadata_for_steps(steps, model), timeout=AUX_LABEL_TIMEOUT_S,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
# Every caller awaits this INSIDE the PATCH request, so an aux lane that stalls used to hold
|
||||
# the whole edit open and the editor just span: an agent editing a step bricked the app. This
|
||||
# is decoration with deterministic fallbacks right below, so failing here must cost a nicer
|
||||
# label, never the edit itself.
|
||||
logger.info("workflow meta gen unavailable; using deterministic labels", exc_info=True)
|
||||
title, description, labels = "", "", []
|
||||
# One aux call covers labels AND auto-naming. A manual rename sets auto_named=False, so the title/description below are left untouched then.
|
||||
if need_autoname:
|
||||
if title:
|
||||
@@ -674,7 +694,9 @@ async def list_missed_runs(limit: int = 50):
|
||||
out: list[dict] = []
|
||||
for m in missed[:limit]:
|
||||
wf = storage.get_workflow(m.workflow_id)
|
||||
if not wf:
|
||||
# Paused counts as gone here, same as trashed: offering to run a switched-off workflow is an
|
||||
# action we would then refuse, and the card is the one place a stale entry is visible.
|
||||
if not wf or wf.deleted_at is not None or not wf.schedule.enabled:
|
||||
continue
|
||||
out.append({
|
||||
"id": m.id,
|
||||
@@ -702,7 +724,7 @@ async def run_missed_runs(body: MissedRunAction):
|
||||
started = 0
|
||||
for wid, fors in by_wf.items():
|
||||
wf = storage.get_workflow(wid)
|
||||
if not wf:
|
||||
if not wf or wf.deleted_at is not None or not wf.schedule.enabled:
|
||||
continue
|
||||
started += len(fors)
|
||||
asyncio.create_task(scheduler.run_missed_sequence(wf, fors))
|
||||
@@ -837,6 +859,8 @@ async def update_workflow(
|
||||
if not wf.icon:
|
||||
wf.icon = _derive_icon(wf)
|
||||
_normalize_schedule_state(wf)
|
||||
if not wf.schedule.enabled:
|
||||
p_drop_pending_missed(wf.id)
|
||||
await p_sync_cloud_copy(wf, data)
|
||||
storage.save_workflow(wf)
|
||||
audit.log_change(wf.id, "user", before, wf.model_dump(mode="json"))
|
||||
@@ -886,9 +910,7 @@ async def delete_workflow(workflow_id: str):
|
||||
# A trashed workflow's in-flight run was previously un-stoppable even by hand (the manual Stop path filters deleted); halt it now, with the executor's per-step deleted-recheck as backstop.
|
||||
await _stop_in_flight_run(workflow_id)
|
||||
# Drop any pending missed fires so a trashed workflow can't haunt the card.
|
||||
stale = [m.id for m in storage.list_missed() if m.workflow_id == workflow_id]
|
||||
if stale:
|
||||
storage.remove_missed(stale)
|
||||
p_drop_pending_missed(workflow_id)
|
||||
scheduler.kick()
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
@@ -906,7 +928,7 @@ async def restore_workflow(workflow_id: str):
|
||||
if not wf or wf.deleted_at is None:
|
||||
raise HTTPException(status_code=404, detail="Workflow not in trash")
|
||||
wf.deleted_at = None
|
||||
storage.save_workflow(wf)
|
||||
storage.save_workflow(wf, untrash=True)
|
||||
enriched = _enriched(wf)
|
||||
try:
|
||||
from backend.apps.agents.core.ws_manager import ws_manager
|
||||
@@ -1453,6 +1475,10 @@ async def run_workflow_now(workflow_id: str, body: Optional[dict] = None):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
# The executor refuses a trashed workflow but writes no history for it, so without this the caller
|
||||
# got run_id "" with a null status and no idea why nothing happened.
|
||||
if wf.deleted_at is not None:
|
||||
raise HTTPException(status_code=409, detail="This workflow is in Trash. Restore it to run it.")
|
||||
# executor.execute() owns the run record. Don't pre-create a stub here or we end up with two rows per manual fire (one orphan "running" row from this handler plus the real one from the executor).
|
||||
pre_ids = {r.id for r in storage.list_runs(wf.id, limit=10)}
|
||||
tested_signature = body.get("signature") if isinstance(body, dict) else None
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""An agent must not start a workflow the user switched off.
|
||||
|
||||
Field report (Haik, 1.7.4): "a workflow that had been toggled off just started running again".
|
||||
The route ignores `schedule.enabled` for manual runs on purpose, because a human clicking Run Now
|
||||
can see the paused state. An agent reaching that same route is a different act.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import backend.apps.agents.schedule_mcp_server as mod
|
||||
|
||||
|
||||
def p_calls(get_result):
|
||||
seen = {"ran": False}
|
||||
def fake(method, path, body=None, timeout=None):
|
||||
if method == "GET":
|
||||
return get_result
|
||||
seen["ran"] = True
|
||||
return {"run_id": "r1"}
|
||||
return fake, seen
|
||||
|
||||
|
||||
def test_agent_refuses_to_run_a_paused_workflow():
|
||||
fake, seen = p_calls({"title": "Nightly report", "schedule": {"enabled": False}})
|
||||
with patch.object(mod, "_call", side_effect=fake):
|
||||
out = mod.handle_run_now({"workflow_id": "w1"})
|
||||
assert seen["ran"] is False, "the run must never be dispatched"
|
||||
assert out.get("isError") is True
|
||||
text = out["content"][0]["text"]
|
||||
assert "paused" in text and "Nightly report" in text
|
||||
|
||||
|
||||
def test_agent_runs_an_enabled_workflow_normally():
|
||||
fake, seen = p_calls({"title": "Nightly report", "schedule": {"enabled": True}})
|
||||
with patch.object(mod, "_call", side_effect=fake):
|
||||
out = mod.handle_run_now({"workflow_id": "w1"})
|
||||
assert seen["ran"] is True
|
||||
assert not out.get("isError")
|
||||
|
||||
|
||||
def test_an_unreadable_workflow_does_not_block_the_run():
|
||||
"""Fail open: if we cannot read the workflow, behave as before rather than refusing everything."""
|
||||
fake, seen = p_calls({"_error": "boom"})
|
||||
with patch.object(mod, "_call", side_effect=fake):
|
||||
out = mod.handle_run_now({"workflow_id": "w1"})
|
||||
assert seen["ran"] is True
|
||||
assert not out.get("isError")
|
||||
@@ -124,3 +124,21 @@ def test_reset_window_401_is_transient_not_auth():
|
||||
def test_genuine_auth_death_still_cards():
|
||||
assert is_auth_error(Exception("401 unauthorized: invalid authentication credentials"))
|
||||
assert capacity_retry_wait(Exception("401 unauthorized: invalid api key"), 0) is None
|
||||
|
||||
|
||||
# --- the first-turn thinking flip must not drift the boot fingerprint (measured: it threw away the
|
||||
# pre-warmed CLI on 5 of 5 short-prompt sessions, costing ~0.9s of respawn per first message) ------
|
||||
def test_short_first_message_keeps_the_prewarmed_thinking_setting():
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.agents.manager.run.run_options_helpers import inject_thinking_options
|
||||
|
||||
first = AgentSession(id="s1", name="n", model="sonnet-cc", thinking_level="auto")
|
||||
k_first: dict = {}
|
||||
inject_thinking_options(k_first, first, "hi", "cc/claude-sonnet-4-6", "anthropic")
|
||||
|
||||
later = AgentSession(id="s2", name="n", model="sonnet-cc", thinking_level="auto")
|
||||
later.sdk_session_id = "already-running"
|
||||
k_later: dict = {}
|
||||
inject_thinking_options(k_later, later, "hi", "cc/claude-sonnet-4-6", "anthropic")
|
||||
|
||||
assert k_first != k_later, "a short FIRST message must keep the prewarmed boot options"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""One release story, three surfaces. A release with no story, or a Help agent answering from a
|
||||
stale picture of the app, are both bugs this pins shut."""
|
||||
|
||||
from backend.apps.help.changelog import (
|
||||
all_versions, as_markdown, help_context_block, latest_release, release_notes,
|
||||
)
|
||||
from backend.apps.service.version import APP_VERSION
|
||||
|
||||
|
||||
def test_the_shipping_version_has_a_story():
|
||||
note = release_notes(APP_VERSION)
|
||||
assert note is not None, f"{APP_VERSION} ships with no release notes; write them before tagging"
|
||||
assert note.headline and (note.highlights or note.fixes)
|
||||
|
||||
|
||||
def test_notes_are_written_for_users_not_committers():
|
||||
for note in (latest_release(),):
|
||||
for line in note.highlights + note.fixes:
|
||||
assert not line.startswith("["), "no commit-style prefixes"
|
||||
assert "commit" not in line.lower() and "refactor" not in line.lower()
|
||||
assert "—" not in line and "–" not in line, "house style: no em/en dashes"
|
||||
|
||||
|
||||
def test_no_em_dashes_anywhere_in_a_release_body():
|
||||
# House rule, and the header was the one place the earlier test did not look.
|
||||
md = as_markdown(latest_release())
|
||||
assert "\u2014" not in md and "\u2013" not in md, "release bodies use plain punctuation"
|
||||
|
||||
|
||||
def test_markdown_body_carries_the_same_words_as_the_app():
|
||||
note = latest_release()
|
||||
md = as_markdown(note)
|
||||
assert note.headline in md
|
||||
for line in note.highlights + note.fixes:
|
||||
assert line in md, "the GitHub body must not drift from the in-app card"
|
||||
|
||||
|
||||
def test_help_context_names_the_version_and_its_changes():
|
||||
block = help_context_block(APP_VERSION)
|
||||
assert APP_VERSION in block
|
||||
note = release_notes(APP_VERSION)
|
||||
assert note is not None
|
||||
assert note.highlights[0] in block
|
||||
|
||||
|
||||
def test_unknown_version_falls_back_to_the_latest_story_not_silence():
|
||||
assert release_notes("0.0.0") is None
|
||||
assert latest_release().version in help_context_block("0.0.0")
|
||||
assert len(all_versions()) >= 2
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Shipping a new default system prompt has to carry the old one with it.
|
||||
|
||||
The default persists into settings.json, so bumping the constant alone leaves every existing install
|
||||
on the old text forever: they never see the change, and "Reset to default" compares against something
|
||||
that no longer exists. Each legacy revision is derived from the current default with a `replace`, and
|
||||
a `replace` whose anchor has drifted silently returns the string unchanged, which turns the whole
|
||||
migration into a no-op that nothing would notice. These pin both halves.
|
||||
"""
|
||||
|
||||
from backend.apps.settings.models import DEFAULT_SYSTEM_PROMPT
|
||||
from backend.apps.settings.store import (
|
||||
P_LEGACY_DEFAULT_SYSTEM_PROMPT,
|
||||
P_LEGACY_DEFAULT_SYSTEM_PROMPTS,
|
||||
P_LEGACY_LADDER_V1,
|
||||
)
|
||||
|
||||
|
||||
def test_every_legacy_revision_actually_differs_from_the_current_default():
|
||||
"""A derived revision equal to the default means its anchor text drifted and the replace did
|
||||
nothing, so users on that revision would never be migrated off it."""
|
||||
for i, legacy in enumerate(P_LEGACY_DEFAULT_SYSTEM_PROMPTS):
|
||||
assert legacy != DEFAULT_SYSTEM_PROMPT, (
|
||||
f"legacy revision {i} is byte-identical to the current default, so its derivation "
|
||||
"silently no-opped, most likely because the anchor string it replaces was edited"
|
||||
)
|
||||
|
||||
|
||||
def test_the_shipped_revisions_are_all_tracked():
|
||||
assert P_LEGACY_DEFAULT_SYSTEM_PROMPT in P_LEGACY_DEFAULT_SYSTEM_PROMPTS
|
||||
assert P_LEGACY_LADDER_V1 in P_LEGACY_DEFAULT_SYSTEM_PROMPTS
|
||||
assert len(set(P_LEGACY_DEFAULT_SYSTEM_PROMPTS)) == len(P_LEGACY_DEFAULT_SYSTEM_PROMPTS), (
|
||||
"duplicate legacy revisions mean one of the derivations collapsed onto another"
|
||||
)
|
||||
|
||||
|
||||
def test_the_web_ladder_tells_the_agent_to_try_the_cheap_tools_first():
|
||||
"""Eric's ask, and the reason the ladder step was rewritten: an agent given a plain reading task
|
||||
was driving a browser instead of searching, which is far slower. The old wording filed the web
|
||||
tools under 'No tool fits', which reads as a last resort."""
|
||||
assert "WebSearch / WebFetch first" in DEFAULT_SYSTEM_PROMPT
|
||||
assert "No tool fits." not in DEFAULT_SYSTEM_PROMPT, "the last-resort framing is back"
|
||||
ladder = DEFAULT_SYSTEM_PROMPT[DEFAULT_SYSTEM_PROMPT.index("5. **Reading the web.**"):]
|
||||
web_at = ladder.index("WebSearch")
|
||||
browser_at = ladder.index("BrowserAgent")
|
||||
assert web_at < browser_at, "the browser must not be named before the cheap tools"
|
||||
assert "Escalate to BrowserAgent only" in DEFAULT_SYSTEM_PROMPT, "the fallback must stay conditional"
|
||||
|
||||
|
||||
def test_a_user_customized_prompt_is_never_mistaken_for_a_default():
|
||||
"""The migration is a verbatim match, so an edited prompt must never collide with a shipped one."""
|
||||
customized = DEFAULT_SYSTEM_PROMPT + "\nAlways answer in French.\n"
|
||||
assert customized not in P_LEGACY_DEFAULT_SYSTEM_PROMPTS
|
||||
@@ -0,0 +1,156 @@
|
||||
"""A deleted workflow must never come back, by any route.
|
||||
|
||||
Field report (Haik, 1.7.4): a scheduled workflow survived deleting it, wiping OpenSwarm data,
|
||||
deleting the app, and reinstalling. It kept firing every 45 minutes. `delete_workflow` removes the
|
||||
file and the cache entry, but `save_workflow` was an unconditional upsert that recreates BOTH, so any
|
||||
write-back from a run that was already in flight resurrected it, fully scheduled. Runs that stall for
|
||||
1200s make that window enormous, and each resurrection re-armed the timer, so it never died.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.workflows.models import Workflow, WorkflowRun, WorkflowStep
|
||||
|
||||
|
||||
def p_make(title: str = "ghost") -> Workflow:
|
||||
return Workflow(
|
||||
title=title,
|
||||
steps=[WorkflowStep(prompt="do a thing")],
|
||||
schedule={"enabled": True, "kind": "interval", "every_minutes": 45},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def p_isolated_store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage, "DATA_DIR", str(tmp_path / "workflows"))
|
||||
monkeypatch.setattr(storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
|
||||
monkeypatch.setattr(storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
|
||||
monkeypatch.setattr(storage, "MISSED_FILE", str(tmp_path / "workflows" / "missed.json"))
|
||||
monkeypatch.setattr(storage, "_workflow_cache", {})
|
||||
monkeypatch.setattr(storage, "_runs_cache", {})
|
||||
monkeypatch.setattr(storage, "_missed_cache", [])
|
||||
monkeypatch.setattr(storage, "p_deleted_ids", set(), raising=False)
|
||||
monkeypatch.setattr(storage, "_cache_loaded", True)
|
||||
yield
|
||||
|
||||
|
||||
def test_stale_save_after_delete_does_not_resurrect():
|
||||
"""The exact Haik bug: an in-flight run holds the object, the user deletes, the run writes back."""
|
||||
wf = storage.save_workflow(p_make())
|
||||
in_flight = storage.get_workflow(wf.id)
|
||||
assert in_flight is not None
|
||||
|
||||
assert storage.delete_workflow(wf.id) is True
|
||||
|
||||
in_flight.last_run_at = datetime.now(timezone.utc)
|
||||
storage.save_workflow(in_flight)
|
||||
|
||||
assert storage.get_workflow(wf.id) is None
|
||||
assert [w.id for w in storage.list_workflows()] == []
|
||||
import os
|
||||
assert not os.path.exists(storage._wf_path(wf.id))
|
||||
|
||||
|
||||
def test_repeated_stale_saves_never_resurrect():
|
||||
"""It never dies: every later write-back must also bounce, not just the first."""
|
||||
wf = storage.save_workflow(p_make())
|
||||
stale = storage.get_workflow(wf.id)
|
||||
storage.delete_workflow(wf.id)
|
||||
for i in range(5):
|
||||
stale.next_run_at = datetime.now(timezone.utc)
|
||||
storage.save_workflow(stale)
|
||||
assert storage.get_workflow(wf.id) is None, f"resurrected on write-back {i + 1}"
|
||||
|
||||
|
||||
def test_record_run_after_delete_does_not_recreate_history():
|
||||
"""Runs are deleted with the workflow; a late run row must not rebuild an orphan history file."""
|
||||
wf = storage.save_workflow(p_make())
|
||||
storage.delete_workflow(wf.id)
|
||||
storage.record_run(WorkflowRun(workflow_id=wf.id, status="success"))
|
||||
assert storage.list_runs(wf.id) == []
|
||||
import os
|
||||
assert not os.path.exists(storage._runs_path(wf.id))
|
||||
|
||||
|
||||
def test_delete_does_not_block_a_different_workflow():
|
||||
"""The tombstone is per id: deleting one must not stop anything else being saved."""
|
||||
dead = storage.save_workflow(p_make("dead"))
|
||||
storage.delete_workflow(dead.id)
|
||||
alive = storage.save_workflow(p_make("alive"))
|
||||
assert storage.get_workflow(alive.id) is not None
|
||||
assert [w.id for w in storage.list_workflows()] == [alive.id]
|
||||
|
||||
|
||||
def test_executor_refuses_a_workflow_that_no_longer_exists():
|
||||
"""A hard delete makes get_workflow return None, which the pause guard did not treat as refusal,
|
||||
so a deleted workflow still ran to completion and then resurrected itself on write-back."""
|
||||
from backend.apps.workflows import executor
|
||||
|
||||
wf = storage.save_workflow(p_make())
|
||||
storage.delete_workflow(wf.id)
|
||||
|
||||
run = asyncio.run(executor.execute(wf, triggered_by="schedule"))
|
||||
assert run.status == "skipped"
|
||||
assert "delete" in (run.error or "").lower()
|
||||
assert storage.get_workflow(wf.id) is None
|
||||
|
||||
|
||||
def test_disable_schedule_on_a_deleted_workflow_stays_dead():
|
||||
"""The scheduler disables end-of-life workflows by saving them; on a deleted one that is a
|
||||
resurrection with enabled=False, which still shows up on the Workflows page."""
|
||||
from backend.apps.workflows import scheduler
|
||||
|
||||
wf = storage.save_workflow(p_make())
|
||||
stale = storage.get_workflow(wf.id)
|
||||
storage.delete_workflow(wf.id)
|
||||
scheduler._disable_schedule(stale)
|
||||
assert storage.get_workflow(wf.id) is None
|
||||
assert [w.id for w in storage.list_workflows()] == []
|
||||
|
||||
|
||||
def test_a_stale_copy_cannot_untrash_a_workflow():
|
||||
"""Trash is one-way. reload_workflow hands out a NEW instance, so a run that started before the
|
||||
user hit delete can end up holding a copy whose deleted_at is still None; saving that copy put
|
||||
the workflow back on the page with its schedule re-armed."""
|
||||
wf = storage.save_workflow(p_make())
|
||||
stale = storage.get_workflow(wf.id)
|
||||
storage.reload_workflow(wf.id) # the cache now holds a DIFFERENT instance; the run kept `stale`
|
||||
trashed = storage.get_workflow(wf.id)
|
||||
assert trashed is not stale, "test models nothing unless the two copies really diverged"
|
||||
assert stale.deleted_at is None
|
||||
|
||||
trashed.deleted_at = datetime.now()
|
||||
trashed.schedule.enabled = False
|
||||
storage.save_workflow(trashed)
|
||||
|
||||
stale.last_run_at = datetime.now(timezone.utc)
|
||||
storage.save_workflow(stale)
|
||||
|
||||
live = storage.get_workflow(wf.id)
|
||||
assert live is not None and live.deleted_at is not None, "a stale copy untrashed it"
|
||||
assert live.schedule.enabled is False, "the schedule was re-armed by a stale write-back"
|
||||
|
||||
|
||||
def test_restore_can_still_untrash():
|
||||
"""The one-way rule must not brick the Trash > Restore button."""
|
||||
wf = storage.save_workflow(p_make())
|
||||
trashed = storage.get_workflow(wf.id)
|
||||
trashed.deleted_at = datetime.now()
|
||||
storage.save_workflow(trashed)
|
||||
|
||||
back = storage.reload_workflow(wf.id)
|
||||
back.deleted_at = None
|
||||
storage.save_workflow(back, untrash=True)
|
||||
assert storage.get_workflow(wf.id).deleted_at is None
|
||||
|
||||
|
||||
def test_delete_survives_a_reload_from_disk():
|
||||
"""reload_workflow re-reads from disk; on a deleted id it must not repopulate the cache."""
|
||||
wf = storage.save_workflow(p_make())
|
||||
storage.delete_workflow(wf.id)
|
||||
assert storage.reload_workflow(wf.id) is None
|
||||
assert storage.get_workflow(wf.id) is None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""A workflow the user deleted or switched off must not run from ANY path.
|
||||
|
||||
Eric: "if the workflow is toggled off or deleted, it shouldn't be able to run ever, even as a
|
||||
detached head". Guarding individual call sites left every unguarded caller able to fire it, so the
|
||||
invariant lives in the executor where all of them converge.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.workflows import executor
|
||||
from backend.apps.workflows.models import ScheduleConfig, Workflow, WorkflowStep
|
||||
|
||||
|
||||
def p_wf(enabled: bool, deleted: bool = False) -> Workflow:
|
||||
wf = Workflow(
|
||||
title="t",
|
||||
steps=[WorkflowStep(text="say hi", enabled=True)],
|
||||
schedule=ScheduleConfig(enabled=enabled),
|
||||
)
|
||||
if deleted:
|
||||
wf.deleted_at = datetime.now()
|
||||
return wf
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trigger", ["schedule", "retry", "manual"])
|
||||
def test_a_deleted_workflow_never_runs_from_any_trigger(trigger):
|
||||
wf = p_wf(enabled=True, deleted=True)
|
||||
with patch.object(executor.storage, "get_workflow", return_value=wf):
|
||||
run = asyncio.run(executor.execute(wf, triggered_by=trigger))
|
||||
assert run.status == "skipped"
|
||||
assert run.error == "Workflow deleted"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trigger", ["schedule", "retry", "manual"])
|
||||
def test_a_paused_workflow_never_runs_from_any_trigger(trigger):
|
||||
"""Off means off: even the Run Now route cannot start a workflow the user switched off."""
|
||||
wf = p_wf(enabled=False)
|
||||
with patch.object(executor.storage, "get_workflow", return_value=wf):
|
||||
run = asyncio.run(executor.execute(wf, triggered_by=trigger))
|
||||
assert run.status == "skipped"
|
||||
assert run.error == "Workflow is paused"
|
||||
|
||||
|
||||
def test_turning_it_back_on_lets_it_run_again():
|
||||
"""The guard must be about state, not a permanent block."""
|
||||
wf = p_wf(enabled=True)
|
||||
with patch.object(executor.storage, "get_workflow", return_value=wf):
|
||||
with patch.object(executor.storage, "record_run"):
|
||||
with patch.object(executor, "_monthly_spend_so_far", return_value=0.0):
|
||||
assert executor.storage.get_workflow(wf.id).schedule.enabled is True
|
||||
@@ -0,0 +1,52 @@
|
||||
import inspect
|
||||
"""Clause: every surfaced error carries the full flight envelope.
|
||||
|
||||
`cli_binary_missing` shipped without one and nobody noticed, because the only way to see it is to
|
||||
force a failure class that cannot be forced on a dev box (the SDK falls back to the system `claude`,
|
||||
then to ~/.claude/local/claude). An audit of the source is the sensor that does not need the repro."""
|
||||
|
||||
import re
|
||||
|
||||
from backend.apps.agents.manager.run import handle_run_error as p_handler_mod
|
||||
from backend.apps.agents import agent_manager as p_manager_mod
|
||||
|
||||
P_SOURCES = {
|
||||
"handle_run_error": p_handler_mod,
|
||||
"agent_manager": p_manager_mod,
|
||||
}
|
||||
|
||||
|
||||
def p_diagnostic_blocks(src: str):
|
||||
return re.findall(r'submit_diagnostic\(\{(.*?)\}\)', src, re.S)
|
||||
|
||||
|
||||
def test_every_error_diagnostic_carries_a_flight_envelope():
|
||||
import inspect
|
||||
missing = []
|
||||
for name, mod in P_SOURCES.items():
|
||||
for block in p_diagnostic_blocks(inspect.getsource(mod)):
|
||||
kind = re.search(r'"kind":\s*"([a-z_]+)"', block)
|
||||
kind = kind.group(1) if kind else "?"
|
||||
# `recovered` rows are near-miss ledger entries, built by record_recovery, not error cards.
|
||||
if kind == "recovered":
|
||||
continue
|
||||
if '"flight"' not in block:
|
||||
missing.append(f"{name}:{kind}")
|
||||
assert not missing, f"error diagnostics with no envelope: {missing}"
|
||||
|
||||
|
||||
def test_the_cli_missing_class_specifically_is_covered():
|
||||
import inspect
|
||||
src = inspect.getsource(p_handler_mod)
|
||||
block = [b for b in p_diagnostic_blocks(src) if '"cli_binary_missing"' in b]
|
||||
assert block, "the cli_binary_missing diagnostic disappeared"
|
||||
assert '"flight"' in block[0]
|
||||
|
||||
|
||||
def test_silent_quit_diagnostic_carries_a_full_envelope():
|
||||
"""A silent quit is the hardest class to diagnose later, so it must not ship envelope-less.
|
||||
Found live: empty_finish_nudge was the ONE family writing only kind/model/session_id."""
|
||||
import backend.apps.agents.manager.run.empty_finish as ef
|
||||
src = inspect.getsource(ef.maybe_nudge_empty_finish) if hasattr(ef, "maybe_nudge_empty_finish") else inspect.getsource(ef)
|
||||
assert "build_envelope" in src, "empty_finish_nudge must attach a flight envelope"
|
||||
assert '"flight"' in src, "the envelope must ride under the standard 'flight' key"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""The flight envelope has to answer what/who/when/during-what from analytics alone.
|
||||
|
||||
The recorder was already built and wired into six error paths, but nothing asserted that what it
|
||||
emits is COMPLETE. An envelope missing its lane or its breadcrumbs looks fine in a log and is useless
|
||||
in an investigation, which is the exact failure it exists to prevent: every bug found by hand tonight
|
||||
(a workflow that resurrected itself, a keyboard tap alive for 2h35m, an editor hung on a stalled aux
|
||||
call) was silent in analytics.
|
||||
|
||||
These force each wired family and assert the envelope a stranger would receive.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.agents.core import flight_recorder as fr
|
||||
|
||||
# Straight from the clause: what a stranger needs, with no machine in front of them.
|
||||
P_REQUIRED_FIELDS = {
|
||||
"family", "subkind", "lane", "model", "phase", "attempts",
|
||||
"breadcrumbs", "journey", "concurrency",
|
||||
}
|
||||
|
||||
# Every family that actually attaches an envelope today (grep: build_envelope call sites).
|
||||
P_WIRED_FAMILIES: List[tuple] = [
|
||||
("model_error", "provider_500", "stream"),
|
||||
("model_error", "auth_401", "spawn"),
|
||||
("context_overflow", "overflow", "stream"),
|
||||
("cli_binary_missing", "missing", "spawn"),
|
||||
("context_pressure_valve", "pressure_death", "stream"),
|
||||
("empty_finish", "no_answer_text", "stream"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def p_clean_rings(monkeypatch):
|
||||
monkeypatch.setattr(fr, "p_rings", {})
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family,subkind,phase", P_WIRED_FAMILIES)
|
||||
def test_every_wired_family_emits_a_complete_envelope(family, subkind, phase):
|
||||
sid = f"sess-{family}"
|
||||
for i in range(12):
|
||||
fr.crumb(sid, "phase", step=i)
|
||||
env = fr.build_envelope(sid, family, subkind, "claude-sonnet-5", phase, 3, sessions={})
|
||||
|
||||
missing = P_REQUIRED_FIELDS - set(env)
|
||||
assert not missing, f"{family}: envelope is missing {sorted(missing)}"
|
||||
assert env["family"] == family
|
||||
assert env["subkind"] == subkind
|
||||
assert env["phase"] == phase
|
||||
assert env["attempts"] == 3
|
||||
assert env["lane"], "lane must name WHICH provider the failure happened on"
|
||||
|
||||
|
||||
def test_breadcrumbs_reach_the_ten_the_clause_asks_for():
|
||||
sid = "sess-crumbs"
|
||||
for i in range(15):
|
||||
fr.crumb(sid, "tool_call", n=i)
|
||||
env = fr.build_envelope(sid, "model_error", "x", "claude-sonnet-5", "stream", 1, sessions={})
|
||||
assert len(env["breadcrumbs"]) >= 10, f"only {len(env['breadcrumbs'])} breadcrumbs"
|
||||
|
||||
|
||||
def test_a_turn_with_no_history_still_emits_a_usable_envelope():
|
||||
"""A failure at spawn has no breadcrumbs yet; the envelope must still name cause and context
|
||||
rather than blowing up, or the earliest failures are exactly the ones we cannot see."""
|
||||
env = fr.build_envelope("sess-empty", "cli_binary_missing", "missing", None, "spawn", 0, sessions={})
|
||||
assert not (P_REQUIRED_FIELDS - set(env))
|
||||
assert env["breadcrumbs"] == []
|
||||
assert env["family"] == "cli_binary_missing"
|
||||
|
||||
|
||||
def test_the_envelope_never_raises_on_a_bad_session_id():
|
||||
"""It is built on the error path. If it can throw, it converts a diagnosable failure into a
|
||||
second, undiagnosable one."""
|
||||
for sid in ("", "does-not-exist", "\x00weird"):
|
||||
env = fr.build_envelope(sid, "model_error", "x", "gpt-5", "stream", 1, sessions={})
|
||||
assert env["family"] == "model_error"
|
||||
|
||||
|
||||
def test_the_near_miss_ledger_records_a_silent_recovery():
|
||||
"""The clause's last line: a recovery the user never saw still needs a denominator."""
|
||||
sid = "sess-recover"
|
||||
fr.crumb(sid, "api_retry", attempt=1)
|
||||
fr.record_recovery(sid, net="router_respawn", model="claude-sonnet-5", attempts=2, sessions={})
|
||||
labels = [c.get("l") for c in fr.breadcrumbs(sid)]
|
||||
assert "recovered" in labels, f"no recovery breadcrumb, got {labels}"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""The flight recorder's contract: cheap crumbs, bounded rings, envelopes that name everything,
|
||||
and a near-miss ledger that counts silent recoveries."""
|
||||
|
||||
|
||||
from backend.apps.agents.core import flight_recorder as fr
|
||||
|
||||
|
||||
def test_ring_is_bounded_and_ordered():
|
||||
sid = "t-ring"
|
||||
fr.drop_session(sid)
|
||||
for i in range(100):
|
||||
fr.crumb(sid, "step", n=i)
|
||||
crumbs = fr.breadcrumbs(sid, last=100)
|
||||
assert len(crumbs) == 64, "ring must cap at 64"
|
||||
assert crumbs[-1]["n"] == 99 and crumbs[0]["n"] == 36, "oldest drop first"
|
||||
fr.drop_session(sid)
|
||||
assert fr.breadcrumbs(sid) == []
|
||||
|
||||
|
||||
def test_meta_values_are_truncated_and_typed():
|
||||
sid = "t-meta"
|
||||
fr.drop_session(sid)
|
||||
fr.crumb(sid, "err", msg="x" * 999, count=3, flag=True, skipped=None)
|
||||
c = fr.breadcrumbs(sid)[0]
|
||||
assert len(c["msg"]) == 200 and c["count"] == 3 and c["flag"] is True and "skipped" not in c
|
||||
fr.drop_session(sid)
|
||||
|
||||
|
||||
def test_lane_classification_matches_the_routing_reality():
|
||||
assert fr.lane_for_model("sonnet-cc") == "cc"
|
||||
assert fr.lane_for_model("cx/gpt-5.2") == "cx"
|
||||
assert fr.lane_for_model("gemini-2.5-pro") == "gc"
|
||||
assert fr.lane_for_model("openrouter/meta/llama") == "openrouter"
|
||||
assert fr.lane_for_model("cp-openai/local") == "custom"
|
||||
assert fr.lane_for_model("sonnet") == "api"
|
||||
assert fr.lane_for_model(None) == "api"
|
||||
|
||||
|
||||
def test_envelope_carries_cause_context_and_crumbs():
|
||||
sid = "t-env"
|
||||
fr.drop_session(sid)
|
||||
fr.crumb(sid, "router-retry", attempt=1)
|
||||
|
||||
class FakeSession:
|
||||
status = "running"
|
||||
|
||||
env = fr.build_envelope(sid, "model_error", "unclassified", "sonnet-cc", "stream", 2, {"a": FakeSession(), "b": FakeSession()})
|
||||
assert env["family"] == "model_error" and env["lane"] == "cc" and env["phase"] == "stream"
|
||||
assert env["attempts"] == 2 and env["breadcrumbs"][0]["l"] == "router-retry"
|
||||
assert env["concurrency"] == {"sessions_total": 2, "turns_running": 2}
|
||||
fr.drop_session(sid)
|
||||
|
||||
|
||||
def test_recovery_ledger_emits_a_countable_diagnostic(monkeypatch):
|
||||
sent = []
|
||||
import backend.apps.service.client as svc
|
||||
monkeypatch.setattr(svc, "submit_diagnostic", lambda d: sent.append(d))
|
||||
fr.record_recovery("t-rec-12345678", "router-resume", "sonnet-cc", 1, None)
|
||||
assert len(sent) == 1
|
||||
d = sent[0]
|
||||
assert d["kind"] == "recovered" and d["subkind"] == "router-resume" and d["lane"] == "cc" and d["attempts"] == 1
|
||||
fr.drop_session("t-rec-12345678")
|
||||
|
||||
|
||||
def test_envelope_carries_journey_and_auth_context():
|
||||
sid = "t-journey"
|
||||
fr.drop_session(sid)
|
||||
env = fr.build_envelope(sid, "model_error", "auth", "sonnet-cc", "spawn", 1, {})
|
||||
j = env["journey"]
|
||||
assert set(j) >= {"stage", "signed_in"}, "stage + signed_in are the minimum honest context"
|
||||
assert j["stage"] in ("onboarding", "returning", "unknown")
|
||||
fr.drop_session(sid)
|
||||
|
||||
|
||||
def test_breadcrumb_trail_reaches_ten_across_a_normal_spawn():
|
||||
# The spawn pipeline now crumbs every phase; ten is the bar for reconstructing a turn.
|
||||
sid = "t-trail"
|
||||
fr.drop_session(sid)
|
||||
for label in ["turn-start", "options-build", "mcp-build", "provider-env", "context-guard",
|
||||
"client-acquire", "cli-connect-start", "cli-connect-done", "first-event",
|
||||
"assistant-msg", "result-msg"]:
|
||||
fr.crumb(sid, label)
|
||||
assert len(fr.breadcrumbs(sid, last=20)) >= 10
|
||||
fr.drop_session(sid)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Pasting the command from a README must install the skill. Guessing wrong is worse than asking,
|
||||
so anything unreadable returns None rather than a best effort."""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.skill_registry.parse_install_command import parse_install_command
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"npx skills add pdf-filler",
|
||||
"npx skills install pdf-filler",
|
||||
"npm i skills pdf-filler",
|
||||
"pnpm skills add pdf-filler",
|
||||
"bunx skills add pdf-filler",
|
||||
"$ npx skills add pdf-filler",
|
||||
"npx --yes skills add pdf-filler",
|
||||
"npx skills add pdf-filler ",
|
||||
])
|
||||
def test_every_common_runner_and_verb_resolves_the_same_skill(cmd):
|
||||
assert parse_install_command(cmd) == "pdf-filler"
|
||||
|
||||
|
||||
def test_scoped_names_survive():
|
||||
assert parse_install_command("npx skills add @anthropic/docx") == "@anthropic/docx"
|
||||
assert parse_install_command("@anthropic/docx") == "@anthropic/docx"
|
||||
|
||||
|
||||
def test_a_bare_name_is_accepted():
|
||||
assert parse_install_command("pdf-filler") == "pdf-filler"
|
||||
|
||||
|
||||
def test_urls_carry_their_id_in_the_last_segment():
|
||||
assert parse_install_command("https://skills.sh/s/pdf-filler") == "pdf-filler"
|
||||
assert parse_install_command("https://skills.sh/s/pdf-filler/") == "pdf-filler"
|
||||
assert parse_install_command("https://skills.sh/s/pdf-filler?ref=x") == "pdf-filler"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("junk", [
|
||||
"", " ", "npx skills add", "npm install", "how do i install a skill",
|
||||
"rm -rf /", "npx skills add ; rm -rf /",
|
||||
])
|
||||
def test_unreadable_input_returns_none_rather_than_a_guess(junk):
|
||||
assert parse_install_command(junk) is None
|
||||
@@ -0,0 +1,52 @@
|
||||
"""A paste that is not a skills install must return None, not a guess.
|
||||
|
||||
`npx create-react-app foo` parsed as the skill id "create-react-app" until 2026-08-07: any unrelated
|
||||
npx command a user pasted would have installed a skill by that name. The module's own docstring says
|
||||
None means "I could not read this", never a guess, so the registry name is now required."""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.skill_registry.parse_install_command import parse_install_command
|
||||
|
||||
REAL_INSTALLS = [
|
||||
("npx skills add pdf-filler", "pdf-filler"),
|
||||
("$ npx skills add pdf-filler", "pdf-filler"),
|
||||
("npm i skills pdf-filler", "pdf-filler"),
|
||||
("bunx skills install pdf-filler", "pdf-filler"),
|
||||
("pnpm skills add @acme/pdf-filler", "@acme/pdf-filler"),
|
||||
("npx skills add pdf-filler --force", "pdf-filler"),
|
||||
("https://skills.sh/s/pdf-filler", "pdf-filler"),
|
||||
("pdf-filler", "pdf-filler"),
|
||||
]
|
||||
|
||||
NOT_INSTALLS = [
|
||||
"npx create-react-app foo",
|
||||
"npm i lodash",
|
||||
"npx vite build",
|
||||
"yarn add react",
|
||||
"rm -rf /",
|
||||
"",
|
||||
" ",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", REAL_INSTALLS)
|
||||
def test_the_forms_people_actually_paste_still_parse(raw, expected):
|
||||
assert parse_install_command(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", NOT_INSTALLS)
|
||||
def test_anything_that_is_not_a_skills_install_is_refused(raw):
|
||||
assert parse_install_command(raw) is None, f"{raw!r} must not be read as a skill id"
|
||||
|
||||
def test_dlx_is_the_form_readmes_actually_print():
|
||||
"""`pnpm dlx` / `yarn dlx` are those managers' npx; missing them meant the most common paste failed."""
|
||||
assert parse_install_command("pnpm dlx skills add note-taker") == "note-taker"
|
||||
assert parse_install_command("yarn dlx skills add note-taker") == "note-taker"
|
||||
assert parse_install_command("pnpm exec skills add note-taker") == "note-taker"
|
||||
|
||||
|
||||
def test_dlx_does_not_widen_the_refusals():
|
||||
"""Skipping the runner subcommand must not turn every dlx invocation into an install."""
|
||||
assert parse_install_command("pnpm dlx create-vite my-app") is None
|
||||
assert parse_install_command("yarn dlx prettier --write .") is None
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Switching a workflow off must leave NOTHING queued anywhere.
|
||||
|
||||
Eric: "when you pause it it better not be putting ANYTHING anywhere". Trashing already dropped a
|
||||
workflow's pending missed fires; pausing did not, and neither the review-card list nor its run
|
||||
endpoint looked at `schedule.enabled` (both only skipped `if not wf`). So a paused workflow kept
|
||||
appearing on the launch review card, and running it from there reported started=N while the executor
|
||||
refused every one of them.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.workflows.models import MissedRun, Workflow, WorkflowStep
|
||||
|
||||
|
||||
def p_make(enabled: bool = True) -> Workflow:
|
||||
return Workflow(
|
||||
title="nightly",
|
||||
steps=[WorkflowStep(text="do it")],
|
||||
schedule={"enabled": enabled, "kind": "interval", "every_minutes": 45},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def p_isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage, "DATA_DIR", str(tmp_path / "workflows"))
|
||||
monkeypatch.setattr(storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
|
||||
monkeypatch.setattr(storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
|
||||
monkeypatch.setattr(storage, "MISSED_FILE", str(tmp_path / "workflows" / "missed.json"))
|
||||
monkeypatch.setattr(storage, "_workflow_cache", {})
|
||||
monkeypatch.setattr(storage, "_runs_cache", {})
|
||||
monkeypatch.setattr(storage, "_missed_cache", [])
|
||||
monkeypatch.setattr(storage, "p_deleted_ids", set(), raising=False)
|
||||
monkeypatch.setattr(storage, "_cache_loaded", True)
|
||||
yield
|
||||
|
||||
|
||||
def p_queue_a_miss(wf: Workflow) -> MissedRun:
|
||||
m = MissedRun(
|
||||
workflow_id=wf.id,
|
||||
scheduled_for=datetime.now(timezone.utc) - timedelta(hours=2),
|
||||
)
|
||||
storage.add_missed(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_pausing_clears_the_pending_missed_queue():
|
||||
from backend.apps.workflows.workflows import p_drop_pending_missed
|
||||
|
||||
wf = storage.save_workflow(p_make())
|
||||
p_queue_a_miss(wf)
|
||||
assert [m.workflow_id for m in storage.list_missed()] == [wf.id]
|
||||
|
||||
wf.schedule.enabled = False
|
||||
storage.save_workflow(wf)
|
||||
p_drop_pending_missed(wf.id)
|
||||
|
||||
assert storage.list_missed() == [], "a switched-off workflow left fires queued"
|
||||
|
||||
|
||||
def test_pausing_one_workflow_leaves_another_alone():
|
||||
from backend.apps.workflows.workflows import p_drop_pending_missed
|
||||
|
||||
off = storage.save_workflow(p_make())
|
||||
on = storage.save_workflow(p_make())
|
||||
p_queue_a_miss(off)
|
||||
p_queue_a_miss(on)
|
||||
|
||||
p_drop_pending_missed(off.id)
|
||||
assert [m.workflow_id for m in storage.list_missed()] == [on.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_card_hides_a_paused_workflows_misses():
|
||||
"""Belt over the clearing above: entries queued BEFORE this fix shipped must vanish too."""
|
||||
from backend.apps.workflows.workflows import list_missed_runs
|
||||
|
||||
wf = storage.save_workflow(p_make())
|
||||
p_queue_a_miss(wf)
|
||||
assert len((await list_missed_runs())["missed"]) == 1
|
||||
|
||||
wf.schedule.enabled = False
|
||||
storage.save_workflow(wf)
|
||||
assert (await list_missed_runs())["missed"] == [], "paused workflow still on the review card"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_card_hides_a_trashed_workflows_misses():
|
||||
from backend.apps.workflows.workflows import list_missed_runs
|
||||
|
||||
wf = storage.save_workflow(p_make())
|
||||
p_queue_a_miss(wf)
|
||||
wf.deleted_at = datetime.now()
|
||||
storage.save_workflow(wf)
|
||||
assert (await list_missed_runs())["missed"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_a_paused_workflows_miss_starts_nothing_and_says_so():
|
||||
"""It used to report started=N for runs the executor then refused, which is a lie the UI shows."""
|
||||
from backend.apps.workflows.workflows import run_missed_runs
|
||||
from backend.apps.workflows.models import MissedRunAction
|
||||
|
||||
wf = storage.save_workflow(p_make(enabled=False))
|
||||
m = p_queue_a_miss(wf)
|
||||
|
||||
result = await run_missed_runs(MissedRunAction(ids=[m.id]))
|
||||
assert result["started"] == 0, "dispatched a run for a switched-off workflow"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""The silent provider stall (Eric's "the ones that never get picked up").
|
||||
|
||||
The CLI retries provider 500s itself, up to 10 attempts, backing off in tens of seconds. Nothing
|
||||
reached our telemetry and nothing reached the user: the card just sat there. Both payloads below
|
||||
are verbatim from live traffic on 2026-08-07 05:20-05:21, where two turns took 15.7s and >50s for
|
||||
exactly this reason and the near-miss ledger recorded zero.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from backend.apps.agents.core import flight_recorder
|
||||
from backend.apps.agents.manager.run import TurnRunner
|
||||
from backend.apps.agents.manager.streaming.note_provider_retry import note_provider_retry, settle_provider_retries
|
||||
from backend.apps.agents.manager.streaming.state import TurnState
|
||||
|
||||
# Verbatim SystemMessage.__dict__ from the live 500s.
|
||||
LIVE_RETRY: Dict[str, Any] = {
|
||||
"subtype": "api_retry",
|
||||
"data": {
|
||||
"type": "system",
|
||||
"subtype": "api_retry",
|
||||
"attempt": 1,
|
||||
"max_retries": 10,
|
||||
"retry_delay_ms": 30000,
|
||||
"error_status": 500,
|
||||
"error": "server_error",
|
||||
"session_id": "2b0f3e16-8a38-4ae4-b1e9-70da2353b5d4",
|
||||
"uuid": "0cc6ec4a-6ead-4f3a-a29b-a5ceac2a4d2e",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_the_live_500_becomes_a_breadcrumb_with_its_status_and_backoff():
|
||||
sid = "provretry01"
|
||||
flight_recorder.drop_session(sid)
|
||||
turn = TurnState()
|
||||
note_provider_retry(sid, LIVE_RETRY, turn)
|
||||
crumbs = [c for c in flight_recorder.breadcrumbs(sid) if c.get("l") == "provider-retry"]
|
||||
assert len(crumbs) == 1, "the retry must leave a trace a stranger can read"
|
||||
assert crumbs[0]["status"] == 500
|
||||
assert crumbs[0]["delay_ms"] == 30000, "the backoff is the whole reason the user saw a long silence"
|
||||
assert turn.provider_retries == 1
|
||||
assert turn.provider_retry_wait_ms == 30000
|
||||
flight_recorder.drop_session(sid)
|
||||
|
||||
|
||||
def test_a_turn_that_survives_retries_lands_in_the_near_miss_ledger():
|
||||
sid = "provretry02"
|
||||
flight_recorder.drop_session(sid)
|
||||
sent: List[Dict[str, Any]] = []
|
||||
import backend.apps.service.client as service_client
|
||||
original = service_client.submit_diagnostic
|
||||
service_client.submit_diagnostic = lambda payload: sent.append(payload)
|
||||
try:
|
||||
turn = TurnState()
|
||||
note_provider_retry(sid, LIVE_RETRY, turn)
|
||||
note_provider_retry(sid, LIVE_RETRY, turn)
|
||||
settle_provider_retries(sid, turn, "sonnet-cc", {})
|
||||
finally:
|
||||
service_client.submit_diagnostic = original
|
||||
assert len(sent) == 1, "one settle per turn, not one per retry"
|
||||
assert sent[0]["kind"] == "recovered"
|
||||
assert sent[0]["subkind"] == "provider-retry"
|
||||
assert sent[0]["attempts"] == 2, "the denominator has to count every retry the turn rode out"
|
||||
flight_recorder.drop_session(sid)
|
||||
|
||||
|
||||
def test_a_turn_with_no_retries_stays_out_of_the_ledger():
|
||||
sid = "provretry03"
|
||||
sent: List[Dict[str, Any]] = []
|
||||
import backend.apps.service.client as service_client
|
||||
original = service_client.submit_diagnostic
|
||||
service_client.submit_diagnostic = lambda payload: sent.append(payload)
|
||||
try:
|
||||
settle_provider_retries(sid, TurnState(), "sonnet-cc", {})
|
||||
finally:
|
||||
service_client.submit_diagnostic = original
|
||||
assert sent == [], "a clean turn must not inflate the near-miss count"
|
||||
|
||||
|
||||
def test_a_malformed_retry_event_never_breaks_the_turn():
|
||||
sid = "provretry04"
|
||||
flight_recorder.drop_session(sid)
|
||||
turn = TurnState()
|
||||
for junk in ("not a dict", {"subtype": "api_retry"}, {"subtype": "api_retry", "data": None}):
|
||||
note_provider_retry(sid, junk, turn)
|
||||
flight_recorder.drop_session(sid)
|
||||
|
||||
|
||||
def test_the_turn_loop_actually_dispatches_api_retry():
|
||||
src = inspect.getsource(TurnRunner)
|
||||
assert 'p_subtype == "api_retry"' in src, "the SystemMessage branch must recognise the retry subtype"
|
||||
assert "note_provider_retry(session_id, raw, turn)" in src
|
||||
assert "settle_provider_retries(session_id, turn, resolved_model, self.sessions)" in src
|
||||
@@ -0,0 +1,187 @@
|
||||
"""A reaper that misjudges ownership kills working apps, so ownership is the thing under test.
|
||||
|
||||
The first draft matched on the workspace path alone; a dry run on a live machine showed it would
|
||||
have killed 14 running app runtimes whose backend was up. These pin the discriminator.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.apps.outputs import reap_ghost_runtimes as mod
|
||||
|
||||
|
||||
def p_ps(pid_args: str, pid_ppid: str):
|
||||
"""Fake `ps` with two different outputs depending on the requested format."""
|
||||
class R:
|
||||
def __init__(self, out): self.stdout = out
|
||||
def run(cmd, **kw):
|
||||
return R(pid_args if "args=" in cmd[-1] or "pid=,args=" in " ".join(cmd) else pid_ppid)
|
||||
return run
|
||||
|
||||
|
||||
def test_runtime_owned_by_a_live_backend_is_never_reaped():
|
||||
ws = os.path.abspath(mod.WORKSPACE_DIR)
|
||||
args = f"100 python -m uvicorn backend.main:app\n200 node {ws}/app/vite\n"
|
||||
ppid = "100 1\n200 100\n"
|
||||
with patch.object(mod.subprocess, "run", side_effect=p_ps(args, ppid)):
|
||||
assert mod.find_ghost_runtime_pids() == []
|
||||
|
||||
|
||||
def test_runtime_whose_backend_died_is_reaped():
|
||||
ws = os.path.abspath(mod.WORKSPACE_DIR)
|
||||
# The CALLER is always a live backend (this code runs inside one), so the scan must show at
|
||||
# least ourselves; a fixture with "no uvicorn anywhere" models a world that cannot exist, and
|
||||
# the fail-closed guard rightly refuses to reap in it.
|
||||
args = f"50 python -m uvicorn backend.main:app\n200 node {ws}/app/vite\n"
|
||||
ppid = "50 1\n200 1\n" # ghost reparented to init, NOT under the backend
|
||||
with patch.object(mod.subprocess, "run", side_effect=p_ps(args, ppid)):
|
||||
assert mod.find_ghost_runtime_pids() == [200]
|
||||
|
||||
|
||||
def test_ownership_is_inherited_through_the_bash_wrapper():
|
||||
"""run.sh sits between the backend and vite; the walk must climb past it."""
|
||||
ws = os.path.abspath(mod.WORKSPACE_DIR)
|
||||
args = f"100 python -m uvicorn backend.main:app\n150 bash run.sh\n200 node {ws}/app/vite\n"
|
||||
ppid = "100 1\n150 100\n200 150\n"
|
||||
with patch.object(mod.subprocess, "run", side_effect=p_ps(args, ppid)):
|
||||
assert mod.find_ghost_runtime_pids() == []
|
||||
|
||||
|
||||
def test_unrelated_processes_are_never_matched():
|
||||
"""A user's own npm dev server elsewhere on the machine must be invisible to this."""
|
||||
args = "300 node /Users/someone/other-project/vite\n"
|
||||
ppid = "300 1\n"
|
||||
with patch.object(mod.subprocess, "run", side_effect=p_ps(args, ppid)):
|
||||
assert mod.find_ghost_runtime_pids() == []
|
||||
|
||||
|
||||
def test_a_broken_ps_reaps_nothing_rather_than_guessing():
|
||||
def boom(*a, **k):
|
||||
raise OSError("ps unavailable")
|
||||
with patch.object(mod.subprocess, "run", side_effect=boom):
|
||||
assert mod.find_ghost_runtime_pids() == []
|
||||
assert mod.reap_ghost_runtimes() == 0
|
||||
|
||||
|
||||
def test_stale_idle_runtimes_are_stopped_after_the_ttl(monkeypatch):
|
||||
"""Frozen-idle is 0% CPU but holds memory and a port forever; past the TTL it must actually die."""
|
||||
import asyncio
|
||||
from backend.apps.outputs import runtime as rt_mod
|
||||
|
||||
class P_FakeRuntime:
|
||||
def __init__(self) -> None:
|
||||
self.process = None
|
||||
self.running = True
|
||||
self.stopped = False
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
monkeypatch.setattr(rt_mod, "resume_process_tree", lambda proc: None)
|
||||
m = rt_mod.AppRuntimeManager()
|
||||
old, fresh = P_FakeRuntime(), P_FakeRuntime()
|
||||
m.idle_lru["ws-old:1"] = old
|
||||
m.idle_lru["ws-new:1"] = fresh
|
||||
import time as p_time
|
||||
m.p_idle_since["ws-old:1"] = p_time.monotonic() - 3600
|
||||
m.p_idle_since["ws-new:1"] = p_time.monotonic()
|
||||
|
||||
reaped = asyncio.run(m.reap_stale_idle(ttl_s=900))
|
||||
assert reaped == 1
|
||||
assert old.stopped and not fresh.stopped
|
||||
assert "ws-old:1" not in m.idle_lru and "ws-new:1" in m.idle_lru
|
||||
assert "ws-old:1" not in m.p_idle_since
|
||||
|
||||
|
||||
def test_a_failed_backend_scan_reaps_nothing(monkeypatch):
|
||||
"""We ARE a backend, so 'no live backends found' means the scan failed, not that everything is a
|
||||
ghost; without this, one slow `ps` under load turned the 10-minute sweep into a kill-all."""
|
||||
from backend.apps.outputs import reap_ghost_runtimes as rg
|
||||
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: set())
|
||||
monkeypatch.setattr(rg, "p_ppid_map", lambda: {200: 1})
|
||||
class P_Out:
|
||||
stdout = f"200 node {rg.os.path.abspath(rg.WORKSPACE_DIR)}/ws-x/run\n"
|
||||
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
|
||||
assert rg.find_ghost_runtime_pids() == []
|
||||
|
||||
|
||||
def test_indeterminate_ancestry_is_never_a_ghost(monkeypatch):
|
||||
"""A process missing from the ppid snapshot (spawned between the two ps calls) must be skipped,
|
||||
not killed: mid-session, that is a runtime that just started."""
|
||||
from backend.apps.outputs import reap_ghost_runtimes as rg
|
||||
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50})
|
||||
monkeypatch.setattr(rg, "p_ppid_map", lambda: {300: 1})
|
||||
ws = rg.os.path.abspath(rg.WORKSPACE_DIR)
|
||||
class P_Out:
|
||||
stdout = f"300 node {ws}/ws-a/run\n999 node {ws}/ws-b/run\n"
|
||||
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
|
||||
ghosts = rg.find_ghost_runtime_pids()
|
||||
assert 999 not in ghosts, "pid absent from the ppid map was treated as a ghost"
|
||||
assert ghosts == [300], "a genuinely orphaned pid (walks to init, no backend) still reaps"
|
||||
|
||||
|
||||
def test_ghost_matched_despite_path_case_difference(monkeypatch):
|
||||
"""macOS is case-insensitive, so a process can report .../openswarm/... while our resolved path
|
||||
is .../OpenSwarm/... (same folder). A case-sensitive match missed the ghost entirely; found live
|
||||
on a packaged smoke where 8 orphans survived a reap that logged 0."""
|
||||
from backend.apps.outputs import reap_ghost_runtimes as rg
|
||||
ws = rg.os.path.abspath(rg.WORKSPACE_DIR)
|
||||
lower_ws = ws.replace("OpenSwarm", "openswarm").replace("Openswarm", "openswarm")
|
||||
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50})
|
||||
monkeypatch.setattr(rg, "p_ppid_map", lambda: {700: 1}) # orphan reparented to init
|
||||
class P_Out:
|
||||
stdout = f"50 python -m uvicorn backend.main:app\n700 bash {lower_ws}/ws-x/backend/run.sh\n"
|
||||
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
|
||||
assert rg.find_ghost_runtime_pids() == [700], "a case-different path must still match the ghost"
|
||||
|
||||
|
||||
def test_an_orphan_is_found_by_its_CWD_when_argv_hides_the_path(monkeypatch):
|
||||
"""An app's backend runs as `python -u backend.py` with cwd=<workspace>, so the workspace path is
|
||||
nowhere in its argv. An argv-only scan was structurally blind to exactly the ghost we most want
|
||||
dead; found live on a packaged build where orphaned app backends survived every reap."""
|
||||
from backend.apps.outputs import reap_ghost_runtimes as rg
|
||||
ws = rg.os.path.abspath(rg.WORKSPACE_DIR)
|
||||
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50})
|
||||
monkeypatch.setattr(rg, "p_ppid_map", lambda: {900: 1})
|
||||
monkeypatch.setattr(rg, "p_cwd_map", lambda needle: {900: ws + "/app-7"})
|
||||
class P_Out:
|
||||
stdout = "50 python -m uvicorn backend.main:app\n900 python3 -u backend.py\n"
|
||||
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
|
||||
assert rg.find_ghost_runtime_pids() == [900], "a cwd-only orphan must still be reaped"
|
||||
|
||||
|
||||
def test_a_cwd_orphan_owned_by_a_live_backend_is_spared(monkeypatch):
|
||||
"""The cwd path must obey the same ancestry rule: a working app is not a ghost."""
|
||||
from backend.apps.outputs import reap_ghost_runtimes as rg
|
||||
ws = rg.os.path.abspath(rg.WORKSPACE_DIR)
|
||||
monkeypatch.setattr(rg, "p_live_backend_pids", lambda: {50})
|
||||
monkeypatch.setattr(rg, "p_ppid_map", lambda: {900: 50, 50: 1})
|
||||
monkeypatch.setattr(rg, "p_cwd_map", lambda needle: {900: ws + "/app-7"})
|
||||
class P_Out:
|
||||
stdout = "50 python -m uvicorn backend.main:app\n900 python3 -u backend.py\n"
|
||||
monkeypatch.setattr(rg.subprocess, "run", lambda *a, **k: P_Out())
|
||||
assert rg.find_ghost_runtime_pids() == [], "a live backend's own app runtime must never be killed"
|
||||
|
||||
|
||||
def test_a_frozen_ghost_is_thawed_before_being_signalled(monkeypatch):
|
||||
"""Idle app runtimes are parked with SIGSTOP, and a STOPPED process never handles SIGTERM: it
|
||||
queues it and lives forever. Found live as a frozen `bash run.sh` that had survived every reap
|
||||
for 2 days 21 hours. CONT must precede TERM, and anything still breathing gets KILL."""
|
||||
import signal as sg
|
||||
from backend.apps.outputs import reap_ghost_runtimes as rg
|
||||
monkeypatch.setattr(rg, "find_ghost_runtime_pids", lambda: [4242])
|
||||
monkeypatch.setattr(rg, "REAP_GRACE_SECONDS", 0.0)
|
||||
monkeypatch.setattr(rg, "kill_descendant_tree", lambda pid, sig: None)
|
||||
sent = []
|
||||
alive = {4242: True}
|
||||
def p_kill(pid, sig):
|
||||
if sig == 0:
|
||||
if not alive.get(pid): raise ProcessLookupError()
|
||||
return
|
||||
sent.append(sig)
|
||||
if sig == sg.SIGKILL: alive[pid] = False
|
||||
monkeypatch.setattr(rg.os, "kill", p_kill)
|
||||
rg.reap_ghost_runtimes()
|
||||
assert sg.SIGCONT in sent, "a stopped ghost never receives TERM unless it is thawed first"
|
||||
assert sent.index(sg.SIGCONT) < sent.index(sg.SIGTERM), "CONT must come before TERM"
|
||||
assert sg.SIGKILL in sent, "a ghost that ignored TERM must be escalated, not left running"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""The router-down envelope must NAME the cause.
|
||||
|
||||
Found by the forced-failure battery on 2026-08-07: holding port 20128 with a dead socket so 9Router
|
||||
could not rebind produced a real terminal failure whose envelope read `subkind=unclassified`, with a
|
||||
breadcrumb trail that simply stopped after the prep phases. The cause was sitting in plain text in
|
||||
`error_preview` ("9Router is not running; cannot use sonnet-cc") but nothing could be queried on it.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
from backend.apps.agents.core.error_classify import is_router_unreachable_error
|
||||
from backend.apps.agents.core.is_router_unavailable_error import is_router_unavailable_error
|
||||
from backend.apps.agents.manager.run import handle_run_error
|
||||
|
||||
|
||||
def test_the_verbatim_live_refusal_is_classified():
|
||||
# Exact string raised by configure_provider_env and captured in the battery envelope.
|
||||
assert is_router_unavailable_error(
|
||||
"9Router is not running; cannot use sonnet-cc. Install Node.js and restart the app, "
|
||||
"or switch to a model with a direct API key."
|
||||
)
|
||||
|
||||
|
||||
def test_the_mid_turn_unreachable_shapes_still_qualify():
|
||||
for text in ("API Error: Unable to connect. Is the computer able to access the url?",
|
||||
"fetch failed", "connect ECONNREFUSED 127.0.0.1:20128"):
|
||||
assert is_router_unavailable_error(text), text
|
||||
assert is_router_unreachable_error(text), "the narrower resume-path check must keep matching too"
|
||||
|
||||
|
||||
def test_unrelated_failures_are_not_swallowed():
|
||||
for text in ("", " ", "Prompt is too long", "Invalid API key",
|
||||
"The router of the story is that nothing broke"):
|
||||
assert not is_router_unavailable_error(text), text
|
||||
|
||||
|
||||
def test_the_rung_sits_above_unclassified():
|
||||
src = inspect.getsource(handle_run_error)
|
||||
assert "is_router_unavailable_error" in src
|
||||
assert src.index("is_router_unavailable_error") < src.index('p_report_model_error("unclassified"'), \
|
||||
"a router death must be named before the catch-all claims it"
|
||||
assert 'p_report_model_error("router_unavailable"' in src
|
||||
@@ -93,8 +93,13 @@ def test_paused_scheduled_run_halts_at_next_step(make_wf, fake_agent_manager, mo
|
||||
assert fake_agent_manager.sent_messages == ["step1"]
|
||||
|
||||
|
||||
def test_pause_does_not_halt_a_manual_run(make_wf, fake_agent_manager, monkeypatch):
|
||||
"""Pausing the SCHEDULE must not kill a manual Run Now that's in flight."""
|
||||
def test_pause_halts_even_a_manual_run(make_wf, fake_agent_manager, monkeypatch):
|
||||
"""Off means off on every path, including a manual Run Now already in flight.
|
||||
|
||||
This used to assert the opposite, that pausing the schedule left a manual run alone. Eric's call
|
||||
is that a workflow switched off must not keep running by any route, so the switch now stops the
|
||||
run at the next step boundary whatever started it.
|
||||
"""
|
||||
from backend.apps.workflows import storage, executor
|
||||
wf = p_three_step_wf(make_wf)
|
||||
storage.save_workflow(wf)
|
||||
@@ -106,8 +111,8 @@ def test_pause_does_not_halt_a_manual_run(make_wf, fake_agent_manager, monkeypat
|
||||
|
||||
p_mutate_after_first_step(monkeypatch, fake_agent_manager, pause)
|
||||
run = p_run(executor.execute(wf, triggered_by="manual"))
|
||||
assert run.status == "success"
|
||||
assert fake_agent_manager.sent_messages == ["step1", "step2", "step3"] # all ran
|
||||
assert run.status != "success"
|
||||
assert fake_agent_manager.sent_messages == ["step1"] # stopped at the boundary after the switch
|
||||
|
||||
|
||||
def test_stop_active_run_signals_and_returns_session(make_wf, fake_agent_manager, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Editing a workflow step must finish even when the aux label lane is dead.
|
||||
|
||||
`p_relabel_steps` is awaited INSIDE the PATCH request, and its aux call had no timeout at all (the
|
||||
Anthropic SDK's own stream ceiling is minutes). A stalled lane therefore held the whole edit open:
|
||||
the agent's EditWorkflowStep tool never returned and the editor just span, which is the
|
||||
"agent edits a workflow and the app bricks" report. The labels are decoration with deterministic
|
||||
fallbacks, so a dead lane must cost a nicer label, never the edit.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.workflows import workflows as wf_mod
|
||||
from backend.apps.workflows.models import Workflow, WorkflowStep
|
||||
|
||||
|
||||
def p_wf() -> Workflow:
|
||||
return Workflow(title="Untitled workflow", auto_named=True, steps=[WorkflowStep(text="do the first thing")])
|
||||
|
||||
|
||||
def test_a_hung_aux_lane_cannot_hold_the_edit_open(monkeypatch):
|
||||
async def never_returns(*_a, **_k):
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
monkeypatch.setattr(wf_mod, "p_generate_metadata_for_steps", never_returns)
|
||||
monkeypatch.setattr(wf_mod, "AUX_LABEL_TIMEOUT_S", 0.05)
|
||||
|
||||
wf = p_wf()
|
||||
steps = [WorkflowStep(text="a brand new instruction")]
|
||||
|
||||
async def run():
|
||||
await asyncio.wait_for(
|
||||
wf_mod.p_relabel_steps(wf, [], steps, None),
|
||||
timeout=5.0, # the assertion IS that we return well inside this
|
||||
)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_a_hung_lane_still_leaves_a_usable_label_and_title(monkeypatch):
|
||||
"""Falling through to the deterministic path matters: the old code returned early on any aux
|
||||
failure, which left the step showing its raw prompt as its own title."""
|
||||
async def never_returns(*_a, **_k):
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
monkeypatch.setattr(wf_mod, "p_generate_metadata_for_steps", never_returns)
|
||||
monkeypatch.setattr(wf_mod, "AUX_LABEL_TIMEOUT_S", 0.05)
|
||||
|
||||
wf = p_wf()
|
||||
steps = [WorkflowStep(text="summarize my unread email and text me the digest")]
|
||||
asyncio.run(wf_mod.p_relabel_steps(wf, [], steps, None))
|
||||
|
||||
assert steps[0].label, "a dead aux lane must still leave a label"
|
||||
assert steps[0].label != steps[0].text, "the label must not be the raw prompt"
|
||||
assert wf.title not in wf_mod._PLACEHOLDER_TITLES, "auto-name must fall back, not stay Untitled"
|
||||
|
||||
|
||||
def test_an_erroring_aux_lane_behaves_the_same_as_a_hung_one(monkeypatch):
|
||||
async def blows_up(*_a, **_k):
|
||||
raise RuntimeError("provider 500")
|
||||
|
||||
monkeypatch.setattr(wf_mod, "p_generate_metadata_for_steps", blows_up)
|
||||
wf = p_wf()
|
||||
steps = [WorkflowStep(text="pull the calendar and write a brief")]
|
||||
asyncio.run(wf_mod.p_relabel_steps(wf, [], steps, None))
|
||||
assert steps[0].label
|
||||
assert wf.title not in wf_mod._PLACEHOLDER_TITLES
|
||||
|
||||
|
||||
def test_a_healthy_lane_still_wins(monkeypatch):
|
||||
"""The timeout must not quietly replace good aux output with the fallback."""
|
||||
async def good(*_a, **_k):
|
||||
return "Summarize Daily Email", "Reads unread mail and texts a digest.", ["Summarize unread email"]
|
||||
|
||||
monkeypatch.setattr(wf_mod, "p_generate_metadata_for_steps", good)
|
||||
wf = p_wf()
|
||||
steps = [WorkflowStep(text="summarize my unread email")]
|
||||
asyncio.run(wf_mod.p_relabel_steps(wf, [], steps, None))
|
||||
assert wf.title == "Summarize Daily Email"
|
||||
assert steps[0].label == "Summarize unread email"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("budget", [20.0])
|
||||
def test_the_ceiling_is_bounded_and_sane(budget):
|
||||
assert 0 < wf_mod.AUX_LABEL_TIMEOUT_S <= budget
|
||||
@@ -0,0 +1,29 @@
|
||||
"""TestWorkflow must return the RESULT, not a promise. The old handler returned as soon as the Test
|
||||
Agent spawned, so the model ended its turn and a human had to re-ping it to continue: the exact
|
||||
"it just stops" annoyance."""
|
||||
|
||||
import inspect
|
||||
|
||||
from backend.apps.agents import schedule_mcp_server as srv
|
||||
|
||||
|
||||
def test_the_handler_waits_for_the_result_instead_of_returning_a_promise():
|
||||
src = inspect.getsource(srv.handle_test_workflow)
|
||||
assert "while True:" in src and "last_progress_at" in src, (
|
||||
"the tool must block, waiting on PROGRESS (transcript growth resets the clock)")
|
||||
assert "test-transcript" in src, "and read the transcript itself, not delegate that to the model"
|
||||
assert "Test finished" in src
|
||||
|
||||
|
||||
def test_a_long_test_returns_honestly_instead_of_hanging_the_turn():
|
||||
src = inspect.getsource(srv.handle_test_workflow)
|
||||
assert "went quiet" in src, "the give-up message must say the run stalled, with the partial attached"
|
||||
assert srv.TEST_IDLE_S <= 300, "silence must end the wait in bounded time"
|
||||
assert srv.TEST_MAX_S <= 3600, "even a chatty run has an absolute ceiling"
|
||||
assert srv.TEST_POLL_S >= 1
|
||||
|
||||
|
||||
def test_the_description_tells_the_model_to_keep_going():
|
||||
desc = next(t["description"] for t in srv.TOOLS if t["name"] == "TestWorkflow")
|
||||
assert "WAIT for the result" in desc and "same turn" in desc
|
||||
assert "without stopping to ask the user" in desc
|
||||
@@ -0,0 +1,102 @@
|
||||
"""No stampede: a due fire beyond the global cap queues, and gives up honestly, never silently.
|
||||
|
||||
Every workflow run is a full agent, and a working agent's browsers and apps are exempt from the
|
||||
renderer budget on purpose (sleeping them blinds the agent). So the ONLY bound on total pressure is
|
||||
how many runs exist at once, and before this the scheduler would happily start one agent per due
|
||||
workflow: thirty accumulated workflows drifting into schedule alignment meant thirty agents.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.workflows import executor, storage
|
||||
from backend.apps.workflows.models import Workflow, WorkflowStep
|
||||
|
||||
|
||||
def p_make() -> Workflow:
|
||||
return Workflow(
|
||||
title="queued",
|
||||
steps=[WorkflowStep(text="do it")],
|
||||
schedule={"enabled": True, "kind": "interval", "every_minutes": 45},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def p_isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage, "DATA_DIR", str(tmp_path / "workflows"))
|
||||
monkeypatch.setattr(storage, "RUNS_DIR", str(tmp_path / "workflows" / "runs"))
|
||||
monkeypatch.setattr(storage, "PAUSED_FILE", str(tmp_path / "workflows" / "paused.json"))
|
||||
monkeypatch.setattr(storage, "MISSED_FILE", str(tmp_path / "workflows" / "missed.json"))
|
||||
monkeypatch.setattr(storage, "_workflow_cache", {})
|
||||
monkeypatch.setattr(storage, "_runs_cache", {})
|
||||
monkeypatch.setattr(storage, "_missed_cache", [])
|
||||
monkeypatch.setattr(storage, "p_deleted_ids", set(), raising=False)
|
||||
monkeypatch.setattr(storage, "_cache_loaded", True)
|
||||
monkeypatch.setattr(executor, "_running", {})
|
||||
yield
|
||||
|
||||
|
||||
def test_at_the_cap_a_fire_waits_then_skips_with_an_honest_row(monkeypatch):
|
||||
monkeypatch.setattr(executor, "MAX_CONCURRENT_RUNS", 2)
|
||||
monkeypatch.setattr(executor, "ADMISSION_WAIT_S", 0.05)
|
||||
monkeypatch.setattr(executor, "ADMISSION_POLL_S", 0.01)
|
||||
monkeypatch.setattr(executor, "_running", {"other-a": "r1", "other-b": "r2"})
|
||||
|
||||
wf = storage.save_workflow(p_make())
|
||||
run = asyncio.run(executor.execute(wf, triggered_by="schedule"))
|
||||
|
||||
assert run.status == "skipped"
|
||||
assert "already running" in (run.error or "")
|
||||
rows = storage.list_runs(wf.id)
|
||||
assert len(rows) == 1 and rows[0].status == "skipped", "the give-up must be visible in History"
|
||||
|
||||
|
||||
def test_a_freed_slot_lets_the_queued_fire_proceed(monkeypatch):
|
||||
"""The wait is a queue, not a rejection: the moment a slot frees, the run goes ahead and reaches
|
||||
the normal guard path (here: refused as paused, which proves it got past admission)."""
|
||||
monkeypatch.setattr(executor, "MAX_CONCURRENT_RUNS", 1)
|
||||
monkeypatch.setattr(executor, "ADMISSION_WAIT_S", 5.0)
|
||||
monkeypatch.setattr(executor, "ADMISSION_POLL_S", 0.01)
|
||||
monkeypatch.setattr(executor, "_running", {"other-a": "r1"})
|
||||
|
||||
wf = storage.save_workflow(p_make())
|
||||
live = storage.get_workflow(wf.id)
|
||||
live.schedule.enabled = False
|
||||
storage.save_workflow(live)
|
||||
|
||||
async def scenario():
|
||||
async def free_slot_soon():
|
||||
await asyncio.sleep(0.05)
|
||||
executor._running.clear()
|
||||
asyncio.ensure_future(free_slot_soon())
|
||||
return await executor.execute(wf, triggered_by="schedule")
|
||||
|
||||
run = asyncio.run(scenario())
|
||||
assert run.status == "skipped"
|
||||
assert "paused" in (run.error or "").lower(), (
|
||||
f"expected the post-wait guard to refuse the paused workflow, got {run.error!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_guard_runs_on_state_AFTER_the_wait(monkeypatch):
|
||||
"""A workflow deleted while queueing must be refused, not run: admission sits before the
|
||||
off-means-off guard precisely so the guard sees post-wait truth."""
|
||||
monkeypatch.setattr(executor, "MAX_CONCURRENT_RUNS", 1)
|
||||
monkeypatch.setattr(executor, "ADMISSION_WAIT_S", 5.0)
|
||||
monkeypatch.setattr(executor, "ADMISSION_POLL_S", 0.01)
|
||||
monkeypatch.setattr(executor, "_running", {"other-a": "r1"})
|
||||
|
||||
wf = storage.save_workflow(p_make())
|
||||
|
||||
async def scenario():
|
||||
async def delete_then_free():
|
||||
await asyncio.sleep(0.05)
|
||||
storage.delete_workflow(wf.id)
|
||||
executor._running.clear()
|
||||
asyncio.ensure_future(delete_then_free())
|
||||
return await executor.execute(wf, triggered_by="schedule")
|
||||
|
||||
run = asyncio.run(scenario())
|
||||
assert run.status == "skipped"
|
||||
assert "delete" in (run.error or "").lower()
|
||||
@@ -0,0 +1,131 @@
|
||||
'use strict';
|
||||
// Reads the CAUSE out of Crashpad minidumps so a native crash stops being an invisible file.
|
||||
//
|
||||
// Before this, a main-process SIGSEGV ran none of our JS (uncaughtException is JS-only,
|
||||
// child-process-gone is children-only), so the app vanished and left a .dmp nobody read. The boot
|
||||
// beacon shipped a lifetime cumulative COUNT, which cannot answer what crashed, when, or during
|
||||
// what. This parses the minidump header itself, which is a documented binary format, and reports
|
||||
// one record per NEW dump since the last boot.
|
||||
//
|
||||
// Deliberately parses only the header + stream directory + exception/misc streams. That is enough
|
||||
// for cause and timing, costs a few KB of reads, and cannot be confused by a truncated tail.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MINIDUMP_MAGIC = 0x504d444d; // 'MDMP'
|
||||
const STREAM_EXCEPTION = 6;
|
||||
const STREAM_SYSTEM_INFO = 7;
|
||||
const STREAM_MISC_INFO = 15;
|
||||
|
||||
// Mach exception codes; the signal is what a user-facing report should say.
|
||||
const MAC_EXC = {
|
||||
1: 'EXC_BAD_ACCESS (SIGSEGV/SIGBUS)',
|
||||
2: 'EXC_BAD_INSTRUCTION (SIGILL)',
|
||||
3: 'EXC_ARITHMETIC',
|
||||
5: 'EXC_BREAKPOINT (SIGTRAP)',
|
||||
6: 'EXC_SOFTWARE',
|
||||
10: 'EXC_CRASH (SIGABRT)',
|
||||
};
|
||||
|
||||
function p_readStreams(fd, size) {
|
||||
const head = Buffer.alloc(32);
|
||||
fs.readSync(fd, head, 0, 32, 0);
|
||||
if (head.readUInt32LE(0) !== MINIDUMP_MAGIC) return null;
|
||||
const streamCount = head.readUInt32LE(8);
|
||||
const streamRva = head.readUInt32LE(12);
|
||||
const timeDateStamp = head.readUInt32LE(20);
|
||||
if (streamCount > 4096 || streamRva + streamCount * 12 > size) return null;
|
||||
const dir = Buffer.alloc(streamCount * 12);
|
||||
fs.readSync(fd, dir, 0, dir.length, streamRva);
|
||||
const streams = new Map();
|
||||
for (let i = 0; i < streamCount; i++) {
|
||||
const off = i * 12;
|
||||
streams.set(dir.readUInt32LE(off), {
|
||||
size: dir.readUInt32LE(off + 4),
|
||||
rva: dir.readUInt32LE(off + 8),
|
||||
});
|
||||
}
|
||||
return { streams, timeDateStamp };
|
||||
}
|
||||
|
||||
function p_readExceptionStream(fd, s, fileSize) {
|
||||
if (!s || s.rva + 24 > fileSize) return null;
|
||||
const buf = Buffer.alloc(Math.min(s.size, 168));
|
||||
fs.readSync(fd, buf, 0, buf.length, s.rva);
|
||||
// MINIDUMP_EXCEPTION_STREAM: ThreadId(4) __align(4) then MINIDUMP_EXCEPTION
|
||||
const threadId = buf.readUInt32LE(0);
|
||||
const code = buf.readUInt32LE(8);
|
||||
const flags = buf.readUInt32LE(12);
|
||||
// ExceptionAddress is 8 bytes at offset 24 within the exception record
|
||||
let address = 0n;
|
||||
try { address = buf.readBigUInt64LE(24); } catch (_) { address = 0n; }
|
||||
return { threadId, code, flags, address: '0x' + address.toString(16) };
|
||||
}
|
||||
|
||||
/** Parse one minidump for cause + timing. Returns null if the file is not a readable minidump. */
|
||||
function readDump(file) {
|
||||
let fd = null;
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
fd = fs.openSync(file, 'r');
|
||||
const parsed = p_readStreams(fd, st.size);
|
||||
if (!parsed) return null;
|
||||
const exc = p_readExceptionStream(fd, parsed.streams.get(STREAM_EXCEPTION), st.size);
|
||||
const crashedAt = parsed.timeDateStamp ? new Date(parsed.timeDateStamp * 1000).toISOString() : null;
|
||||
return {
|
||||
file: path.basename(file),
|
||||
bytes: st.size,
|
||||
crashed_at: crashedAt || new Date(st.mtimeMs).toISOString(),
|
||||
mtime_ms: st.mtimeMs,
|
||||
has_exception_stream: !!exc,
|
||||
exception_code: exc ? exc.code : null,
|
||||
exception_name: exc ? (MAC_EXC[exc.code] || `code ${exc.code}`) : null,
|
||||
exception_address: exc ? exc.address : null,
|
||||
faulting_thread_id: exc ? exc.threadId : null,
|
||||
has_system_info: parsed.streams.has(STREAM_SYSTEM_INFO),
|
||||
has_misc_info: parsed.streams.has(STREAM_MISC_INFO),
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every .dmp under a Crashpad dir, newest first. */
|
||||
function listDumps(crashpadDir) {
|
||||
const out = [];
|
||||
const walk = (d) => {
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
|
||||
for (const e of entries) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (/\.dmp$/i.test(e.name)) out.push(p);
|
||||
}
|
||||
};
|
||||
walk(crashpadDir);
|
||||
return out.sort((a, b) => {
|
||||
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch (_) { return 0; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps written since `sinceMs`, parsed. `sinceMs` is the previous boot's watermark, so a relaunch
|
||||
* reports only what actually happened while the user was away, not the lifetime pile.
|
||||
*/
|
||||
function newDumpsSince(crashpadDir, sinceMs, limit = 10) {
|
||||
const rows = [];
|
||||
for (const f of listDumps(crashpadDir)) {
|
||||
let mt = 0;
|
||||
try { mt = fs.statSync(f).mtimeMs; } catch (_) { continue; }
|
||||
if (mt <= sinceMs) break;
|
||||
const parsed = readDump(f);
|
||||
if (parsed) rows.push(parsed);
|
||||
if (rows.length >= limit) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
module.exports = { readDump, listDumps, newDumpsSince, MINIDUMP_MAGIC };
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { readDump, listDumps, newDumpsSince, MINIDUMP_MAGIC } = require('./crashDumpScan');
|
||||
|
||||
// Builds a minimal but REAL minidump: header + stream directory + exception stream. Synthetic
|
||||
// fixtures are the only way to assert the unhappy paths (truncated, wrong magic) deterministically.
|
||||
function makeDump(file, { code = 1, address = 0x10n, threadId = 7, ts = 1754400000 } = {}) {
|
||||
const excRva = 32 + 12; // header + one directory entry
|
||||
const exc = Buffer.alloc(168);
|
||||
exc.writeUInt32LE(threadId, 0);
|
||||
exc.writeUInt32LE(code, 8);
|
||||
exc.writeUInt32LE(0, 12);
|
||||
exc.writeBigUInt64LE(address, 24);
|
||||
|
||||
const header = Buffer.alloc(32);
|
||||
header.writeUInt32LE(MINIDUMP_MAGIC, 0);
|
||||
header.writeUInt32LE(0xa793, 4);
|
||||
header.writeUInt32LE(1, 8); // stream count
|
||||
header.writeUInt32LE(32, 12); // stream directory rva
|
||||
header.writeUInt32LE(ts, 20);
|
||||
|
||||
const dir = Buffer.alloc(12);
|
||||
dir.writeUInt32LE(6, 0); // ExceptionStream
|
||||
dir.writeUInt32LE(exc.length, 4);
|
||||
dir.writeUInt32LE(excRva, 8);
|
||||
|
||||
fs.writeFileSync(file, Buffer.concat([header, dir, exc]));
|
||||
}
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'crashscan-'));
|
||||
let passed = 0;
|
||||
function t(name, fn) {
|
||||
try { fn(); passed++; console.log(' ok ' + name); }
|
||||
catch (e) { console.log(' FAIL ' + name + ': ' + e.message); process.exitCode = 1; }
|
||||
}
|
||||
|
||||
t('reads exception code, address and thread from a real header', () => {
|
||||
const f = path.join(tmp, 'a.dmp');
|
||||
makeDump(f, { code: 1, address: 0x10n, threadId: 42 });
|
||||
const r = readDump(f);
|
||||
assert.strictEqual(r.exception_code, 1);
|
||||
assert.strictEqual(r.exception_address, '0x10');
|
||||
assert.strictEqual(r.faulting_thread_id, 42);
|
||||
assert.match(r.exception_name, /EXC_BAD_ACCESS/);
|
||||
});
|
||||
|
||||
t('a null-pointer crash reports address 0x0, not a missing field', () => {
|
||||
const f = path.join(tmp, 'null.dmp');
|
||||
makeDump(f, { code: 1, address: 0x0n });
|
||||
assert.strictEqual(readDump(f).exception_address, '0x0');
|
||||
});
|
||||
|
||||
t('crash time comes from the dump header, not the file mtime', () => {
|
||||
const f = path.join(tmp, 'ts.dmp');
|
||||
makeDump(f, { ts: 1700000000 });
|
||||
assert.strictEqual(readDump(f).crashed_at, new Date(1700000000 * 1000).toISOString());
|
||||
});
|
||||
|
||||
t('a non-minidump file is refused rather than half-parsed', () => {
|
||||
const f = path.join(tmp, 'junk.dmp');
|
||||
fs.writeFileSync(f, Buffer.from('not a minidump at all, really'));
|
||||
assert.strictEqual(readDump(f), null);
|
||||
});
|
||||
|
||||
t('a truncated dump does not throw', () => {
|
||||
const f = path.join(tmp, 'trunc.dmp');
|
||||
makeDump(f);
|
||||
const buf = fs.readFileSync(f).subarray(0, 20);
|
||||
fs.writeFileSync(f, buf);
|
||||
assert.strictEqual(readDump(f), null);
|
||||
});
|
||||
|
||||
t('a missing file is refused', () => {
|
||||
assert.strictEqual(readDump(path.join(tmp, 'nope.dmp')), null);
|
||||
});
|
||||
|
||||
t('newDumpsSince reports only dumps newer than the watermark', () => {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-'));
|
||||
const older = path.join(d, 'old.dmp');
|
||||
const newer = path.join(d, 'new.dmp');
|
||||
makeDump(older); makeDump(newer);
|
||||
const t0 = Date.now() - 60000;
|
||||
fs.utimesSync(older, new Date(t0 - 60000), new Date(t0 - 60000));
|
||||
fs.utimesSync(newer, new Date(), new Date());
|
||||
const rows = newDumpsSince(d, t0);
|
||||
assert.strictEqual(rows.length, 1, 'only the dump after the watermark counts');
|
||||
assert.strictEqual(rows[0].file, 'new.dmp');
|
||||
});
|
||||
|
||||
t('a watermark in the future reports nothing (no false crash storm)', () => {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp2-'));
|
||||
makeDump(path.join(d, 'x.dmp'));
|
||||
assert.strictEqual(newDumpsSince(d, Date.now() + 600000).length, 0);
|
||||
});
|
||||
|
||||
t('listDumps walks nested Crashpad layout (completed/, pending/)', () => {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cp3-'));
|
||||
fs.mkdirSync(path.join(d, 'completed'), { recursive: true });
|
||||
makeDump(path.join(d, 'completed', 'deep.dmp'));
|
||||
assert.strictEqual(listDumps(d).length, 1);
|
||||
});
|
||||
|
||||
t('a missing Crashpad dir is empty, not a throw', () => {
|
||||
assert.deepStrictEqual(listDumps(path.join(tmp, 'does-not-exist')), []);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
+65
-1
@@ -312,6 +312,22 @@ function countCrashDumps() {
|
||||
} catch (_) { return -1; }
|
||||
}
|
||||
|
||||
// A native main-process crash runs none of our JS, so the app just vanishes and leaves a .dmp
|
||||
// nobody reads. On the next boot we read the CAUSE out of any dump written since last time.
|
||||
function newCrashDumps() {
|
||||
try {
|
||||
const scan = require('./crashDumpScan');
|
||||
const base = path.join(app.getPath('userData'), 'Crashpad');
|
||||
const markFile = path.join(app.getPath('userData'), 'crash-scan.json');
|
||||
let since = 0;
|
||||
try { since = JSON.parse(fs.readFileSync(markFile, 'utf8')).last_scan_ms || 0; } catch (_) { since = 0; }
|
||||
// First run has no watermark; reporting the whole historical pile would look like a crash storm.
|
||||
const rows = since ? scan.newDumpsSince(base, since, 5) : [];
|
||||
try { fs.writeFileSync(markFile, JSON.stringify({ last_scan_ms: Date.now() })); } catch (_) {}
|
||||
return rows;
|
||||
} catch (_) { return []; }
|
||||
}
|
||||
|
||||
// Fleet self-report: POST a compact boot outcome to the LOCAL backend, which forwards it via the existing service client (opt-out honored). No PII. Fire-and-forget, guarded.
|
||||
function sendBootBeacon() {
|
||||
try {
|
||||
@@ -323,7 +339,7 @@ function sendBootBeacon() {
|
||||
props: {
|
||||
sha: bi.shortSha, channel: bi.channel, version: app.getVersion(),
|
||||
os: process.platform, arch: process.arch,
|
||||
perf: _perfValues, preflight: _preflightInfo, preflight2: _preflightVerdict ? { verdict: _preflightVerdict.verdict, totalMs: _preflightVerdict.totalMs, names: (_preflightVerdict.results || []).map((r) => `${r.name}:${r.status}`) } : null, crash_dumps: countCrashDumps(),
|
||||
perf: _perfValues, preflight: _preflightInfo, preflight2: _preflightVerdict ? { verdict: _preflightVerdict.verdict, totalMs: _preflightVerdict.totalMs, names: (_preflightVerdict.results || []).map((r) => `${r.name}:${r.status}`) } : null, crash_dumps: countCrashDumps(), new_crashes: newCrashDumps(),
|
||||
},
|
||||
});
|
||||
const req = http.request({
|
||||
@@ -485,6 +501,8 @@ app.commandLine.appendSwitch('disable-gpu-process-crash-limit');
|
||||
|
||||
let mainWindow = null;
|
||||
let backendProcess = null;
|
||||
let backendRespawns = 0;
|
||||
const MAX_BACKEND_RESPAWNS = 5;
|
||||
let backendPort = null;
|
||||
let cachedUpdateStatus = { status: 'idle', info: null, error: null };
|
||||
let isInstallingUpdate = false;
|
||||
@@ -1183,10 +1201,25 @@ async function startBackend() {
|
||||
`document.title = "OpenSwarm (backend crashed)";`
|
||||
);
|
||||
}
|
||||
// Respawn a backend that died UNEXPECTEDLY. Without this a crash or SIGKILL left the app a dead
|
||||
// shell whose only recovery was a full relaunch, and every app-runtime it had spawned became a
|
||||
// permanent orphan (the boot reaper only runs at launch, which never came). Skip during an
|
||||
// orderly quit, and back off so a backend that instantly dies can't spin a respawn loop.
|
||||
backendProcess = null;
|
||||
if (quitInitiated || isInstallingUpdate) return;
|
||||
backendRespawns = (backendRespawns || 0) + 1;
|
||||
if (backendRespawns > MAX_BACKEND_RESPAWNS) {
|
||||
console.error(`[electron] backend died ${backendRespawns} times; giving up respawn`);
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(1000 * backendRespawns, 8000);
|
||||
console.warn(`[electron] backend died unexpectedly; respawning in ${delay}ms (attempt ${backendRespawns})`);
|
||||
setTimeout(() => { startBackend().catch((e) => console.error('[electron] backend respawn failed', e)); }, delay);
|
||||
});
|
||||
|
||||
emitSplashStatus('Starting backend…');
|
||||
await waitForBackend(backendPort, { process: backendProcess });
|
||||
backendRespawns = 0; // healthy boot resets the budget
|
||||
perfMark('backend-http-ready');
|
||||
console.log(`Backend ready on port ${backendPort}`);
|
||||
maybeCommitPreflightCache();
|
||||
@@ -1475,6 +1508,24 @@ function createWindow() {
|
||||
mainWindow.webContents.on('preload-error', (_event, preloadPath, err) => {
|
||||
console.error('[diag][main:preload-error]', preloadPath, err && err.stack || err);
|
||||
});
|
||||
// Frozen-but-not-crashed is the silent class no crash log sees; Chromium's own unresponsive
|
||||
// signal costs nothing and the report fires from the renderer AFTER it recovers.
|
||||
let wedgeStartedAt = 0;
|
||||
try {
|
||||
const { startMemorySensor } = require('./memorySensor');
|
||||
startMemorySensor(app, () => mainWindow);
|
||||
} catch (e) { console.warn('[diag] memory sensor unavailable:', e && e.message); }
|
||||
mainWindow.webContents.on('unresponsive', () => {
|
||||
wedgeStartedAt = Date.now();
|
||||
console.error('[diag][main] renderer unresponsive');
|
||||
});
|
||||
mainWindow.webContents.on('responsive', () => {
|
||||
if (!wedgeStartedAt) return;
|
||||
const ms = Date.now() - wedgeStartedAt;
|
||||
wedgeStartedAt = 0;
|
||||
console.error('[diag][main] renderer responsive again after', ms, 'ms');
|
||||
try { mainWindow.webContents.send('diag:wedge', { ms }); } catch (_) { /* window mid-teardown */ }
|
||||
});
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
const reason = details && details.reason;
|
||||
if (reason === 'clean-exit') return;
|
||||
@@ -1713,6 +1764,13 @@ async function clearStaleFrontendCache() {
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!autoUpdater) return;
|
||||
// Escape hatch for locally-built packaged smokes: an unpublished build otherwise downloads the
|
||||
// published release and silently DOWNGRADES on quit (the draft self-revert footgun, seen live on
|
||||
// 1.7.0), which both ruins the test and pollutes its memory numbers with ShipIt churn.
|
||||
if (process.env.OPENSWARM_NO_UPDATE === '1') {
|
||||
console.log('[updater] disabled via OPENSWARM_NO_UPDATE=1 (local packaged smoke)');
|
||||
return;
|
||||
}
|
||||
// Proactive, not post-mortem: an app running off the DMG or a Gatekeeper-translocated copy can NEVER self-update (Squirrel.Mac refuses read-only volumes, proven in the packaged smoke). Tell that cohort what to do at boot instead of after a failed check they may never click.
|
||||
if (process.platform === 'darwin' && isPackaged) {
|
||||
const exe = process.execPath || '';
|
||||
@@ -2536,6 +2594,12 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
// popups are 'window' contents created with the flag OFF, so they keep the OS default.
|
||||
if (isCreatingMainWindow) {
|
||||
contents.on('context-menu', (_e, params) => buildAppContextMenu(contents, params));
|
||||
// The dashboard IS a Figma-style canvas listening for ctrl+wheel, so it needs the very fix the
|
||||
// webview branch below spells out: at the default (1,1) limits Electron drops macOS pinch instead
|
||||
// of delivering it, which is the "pinch-to-zoom just stopped working" report. Widening them here
|
||||
// is safe because the canvas wheel handler is passive:false and preventDefaults the zoom path, so
|
||||
// Chromium never also magnifies the UI.
|
||||
try { contents.setVisualZoomLevelLimits(1, 3); } catch (_) { /* older Electron */ }
|
||||
}
|
||||
if (contents.getType() === 'webview') {
|
||||
const wcId = contents.id;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Memory/compute overload sensor: the quiet death nobody reports, where RSS climbs until macOS
|
||||
// kills a renderer or the fans spin up. Idle-scheduled, unref'd, and it EMITS ONLY on threshold
|
||||
// crossings, so a healthy session ships nothing at all.
|
||||
'use strict';
|
||||
|
||||
// Overridable so support can ask a user to run with a tighter cap, and so the wire is testable
|
||||
// without allocating gigabytes on someone's machine.
|
||||
const SAMPLE_MS = Number(process.env.OSW_MEM_SAMPLE_MS || 60_000);
|
||||
// Crossed once, reported once: a leak is a trend, not a per-minute alarm.
|
||||
const TOTAL_MB_CAP = Number(process.env.OSW_MEM_CAP_MB || 3000);
|
||||
const GROWTH_MB_PER_MIN = Number(process.env.OSW_MEM_GROWTH_MB || 40);
|
||||
const GROWTH_WINDOW = 10;
|
||||
|
||||
let p_timer = null;
|
||||
let p_history = [];
|
||||
let p_capReported = false;
|
||||
let p_growthReported = false;
|
||||
|
||||
function totalMb(metrics) {
|
||||
let kb = 0;
|
||||
for (const m of metrics) kb += (m.memory && m.memory.workingSetSize) || 0;
|
||||
return Math.round(kb / 1024);
|
||||
}
|
||||
|
||||
/** Least-squares slope in MB/min over the sample window; a straight climb is the leak signature. */
|
||||
function slopeMbPerMin(history) {
|
||||
const n = history.length;
|
||||
if (n < 4) return 0;
|
||||
const meanX = (n - 1) / 2;
|
||||
const meanY = history.reduce((a, b) => a + b, 0) / n;
|
||||
let num = 0;
|
||||
let den = 0;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
num += (i - meanX) * (history[i] - meanY);
|
||||
den += (i - meanX) * (i - meanX);
|
||||
}
|
||||
return den === 0 ? 0 : num / den;
|
||||
}
|
||||
|
||||
function startMemorySensor(app, getMainWindow) {
|
||||
if (p_timer) return;
|
||||
p_timer = setInterval(() => {
|
||||
let metrics;
|
||||
try { metrics = app.getAppMetrics(); } catch (_) { return; }
|
||||
const mb = totalMb(metrics);
|
||||
p_history.push(mb);
|
||||
if (p_history.length > GROWTH_WINDOW) p_history.shift();
|
||||
const slope = slopeMbPerMin(p_history);
|
||||
const send = (reason, extra) => {
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
try { win.webContents.send('diag:memory', { reason, total_mb: mb, procs: metrics.length, slope_mb_min: Math.round(slope), ...extra }); } catch (_) {}
|
||||
}
|
||||
console.error('[diag][memory]', reason, 'total_mb=' + mb, 'procs=' + metrics.length, 'slope=' + Math.round(slope));
|
||||
};
|
||||
if (!p_capReported && mb >= TOTAL_MB_CAP) { p_capReported = true; send('cap_crossed', {}); }
|
||||
if (p_capReported && mb < TOTAL_MB_CAP * 0.8) p_capReported = false;
|
||||
if (!p_growthReported && p_history.length >= GROWTH_WINDOW && slope >= GROWTH_MB_PER_MIN) {
|
||||
p_growthReported = true;
|
||||
send('growth_suspect', { window_min: GROWTH_WINDOW });
|
||||
}
|
||||
}, SAMPLE_MS);
|
||||
p_timer.unref?.();
|
||||
}
|
||||
|
||||
function stopMemorySensor() {
|
||||
if (p_timer) { clearInterval(p_timer); p_timer = null; }
|
||||
p_history = [];
|
||||
}
|
||||
|
||||
module.exports = { startMemorySensor, stopMemorySensor, slopeMbPerMin, totalMb, TOTAL_MB_CAP, GROWTH_MB_PER_MIN };
|
||||
@@ -0,0 +1,27 @@
|
||||
// The leak detector's math, pinned: a flat session reports nothing, a straight climb is caught.
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const { slopeMbPerMin, totalMb, GROWTH_MB_PER_MIN } = require('./memorySensor');
|
||||
|
||||
test('a flat memory profile has no slope, so nothing is ever reported', () => {
|
||||
assert.ok(Math.abs(slopeMbPerMin([900, 905, 898, 902, 900, 903, 899, 901, 900, 902])) < 1, 'jitter is not a trend');
|
||||
});
|
||||
|
||||
test('a steady climb is caught above the growth threshold', () => {
|
||||
const climbing = Array.from({ length: 10 }, (_, i) => 800 + i * 60);
|
||||
assert.ok(slopeMbPerMin(climbing) >= GROWTH_MB_PER_MIN, 'a 60MB/min climb must exceed the threshold');
|
||||
});
|
||||
|
||||
test('a single spike is not a leak', () => {
|
||||
const spike = [900, 900, 900, 900, 2000, 900, 900, 900, 900, 900];
|
||||
assert.ok(slopeMbPerMin(spike) < GROWTH_MB_PER_MIN, 'one spike must not read as a trend');
|
||||
});
|
||||
|
||||
test('too few samples never guesses', () => {
|
||||
assert.equal(slopeMbPerMin([900, 2000, 3000]), 0);
|
||||
});
|
||||
|
||||
test('totals sum every process in MB', () => {
|
||||
assert.equal(totalMb([{ memory: { workingSetSize: 1024 * 500 } }, { memory: { workingSetSize: 1024 * 300 } }]), 800);
|
||||
assert.equal(totalMb([{}, { memory: {} }]), 0);
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openswarm",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.5",
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"author": "openswarm-ai",
|
||||
@@ -49,7 +49,8 @@
|
||||
"!python-env",
|
||||
"!python-env/**",
|
||||
"!build-staging",
|
||||
"!build-staging/**"
|
||||
"!build-staging/**",
|
||||
"!**/*.test.js"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"mac": {
|
||||
|
||||
@@ -152,6 +152,18 @@ contextBridge.exposeInMainWorld('openswarm', {
|
||||
openApplication: (name) => ipcRenderer.invoke('open-application', name),
|
||||
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
|
||||
getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'),
|
||||
// Threshold-crossing memory alerts (cap or leak-shaped growth); silent on a healthy session.
|
||||
onMemoryAlert: (cb) => {
|
||||
const listener = (_e, info) => cb(info);
|
||||
ipcRenderer.on('diag:memory', listener);
|
||||
return () => ipcRenderer.removeListener('diag:memory', listener);
|
||||
},
|
||||
// Fires after the renderer RECOVERS from a Chromium-detected freeze, with how long it was wedged.
|
||||
onWedge: (cb) => {
|
||||
const listener = (_e, info) => cb(info);
|
||||
ipcRenderer.on('diag:wedge', listener);
|
||||
return () => ipcRenderer.removeListener('diag:wedge', listener);
|
||||
},
|
||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('download-update'),
|
||||
installUpdate: () => ipcRenderer.invoke('install-update'),
|
||||
|
||||
+38
-2
@@ -133,6 +133,7 @@ function installVoiceHotkey(getMainWindow) {
|
||||
|
||||
// ---- fn/Globe primary tier (macOS): the native watcher, since no JS tap can see keycode 63 ----
|
||||
let fnProc = null;
|
||||
let quitReaperWired = false;
|
||||
const startFnWatcher = () => {
|
||||
if (process.platform !== 'darwin' || combo.special !== 'fn' || fnProc) return;
|
||||
resolveFnWatcherBinary((bin) => {
|
||||
@@ -141,7 +142,21 @@ function installVoiceHotkey(getMainWindow) {
|
||||
startFnWatcherWith(bin);
|
||||
});
|
||||
};
|
||||
// Kill fn-watchers left by a previous OpenSwarm that died badly. will-quit is the ONLY thing that
|
||||
// reaps ours, and it never runs on a crash or a force-quit, so each bad exit strands a process
|
||||
// holding a GLOBAL keyboard tap forever (one was found alive after 2h35m). They accumulate, and
|
||||
// every extra one re-sends fn, so dictation double-toggles. We are a single-instance app about to
|
||||
// spawn our own, which makes this the one moment any other fn-watcher is provably not ours.
|
||||
const sweepStrayFnWatchers = (bin) => {
|
||||
try {
|
||||
const out = spawnSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf8', timeout: 4000 });
|
||||
for (const pid of strayFnWatcherPids(String((out && out.stdout) || ''), bin, process.pid)) {
|
||||
try { process.kill(pid, 'SIGKILL'); console.log('[voice] reaped stray fn watcher', pid); } catch (_) {}
|
||||
}
|
||||
} catch (_) { /* a machine where ps is restricted must still arm the watcher */ }
|
||||
};
|
||||
const startFnWatcherWith = (bin) => {
|
||||
sweepStrayFnWatchers(bin);
|
||||
try {
|
||||
fnProc = spawn(bin, [], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
} catch (e) {
|
||||
@@ -174,7 +189,10 @@ function installVoiceHotkey(getMainWindow) {
|
||||
fnProven = false;
|
||||
registerVoiceShortcut();
|
||||
});
|
||||
app.on('will-quit', () => { try { fnProc && fnProc.kill('SIGKILL'); } catch (_) {} });
|
||||
if (!quitReaperWired) {
|
||||
quitReaperWired = true;
|
||||
app.on('will-quit', () => { try { fnProc && fnProc.kill('SIGKILL'); } catch (_) {} });
|
||||
}
|
||||
console.log('[voice] fn watcher armed (awaiting first event to prove Input Monitoring)');
|
||||
// macOS's own Globe-key action (emoji picker by default) fires on a quick fn tap alongside us;
|
||||
// tell the renderer once so it can point the user at "Press Globe key to: Do Nothing".
|
||||
@@ -330,4 +348,22 @@ function installVoiceHotkey(getMainWindow) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { installVoiceHotkey };
|
||||
/**
|
||||
* PIDs of fn-watcher processes that are NOT this app's, given `ps -eo pid=,args=` output.
|
||||
*
|
||||
* Matched on the absolute binary path so an unrelated program never matches, and our own pid is
|
||||
* excluded. Pure so the selection can be tested; the killing stays at the call site.
|
||||
*/
|
||||
function strayFnWatcherPids(psOutput, binPath, selfPid) {
|
||||
const pids = [];
|
||||
if (!binPath) return pids;
|
||||
for (const line of String(psOutput || '').split('\n')) {
|
||||
if (line.indexOf(binPath) < 0) continue;
|
||||
const pid = parseInt(line.trim().split(/\s+/)[0], 10);
|
||||
if (!Number.isInteger(pid) || pid <= 1 || pid === selfPid) continue;
|
||||
pids.push(pid);
|
||||
}
|
||||
return pids;
|
||||
}
|
||||
|
||||
module.exports = { installVoiceHotkey, strayFnWatcherPids };
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const { strayFnWatcherPids } = require('./voiceHotkey');
|
||||
|
||||
// The fn watcher holds a GLOBAL keyboard tap. Its only reaper is app.on('will-quit'), which never
|
||||
// runs on a crash or a force-quit, so bad exits strand one forever (found alive after 2h35m on a dev
|
||||
// box, with a second live one, which means fn fires twice and dictation double-toggles). We sweep at
|
||||
// spawn, the one moment any other fn-watcher is provably not ours. Every case here is about NOT
|
||||
// killing something that isn't a stray, because this sends SIGKILL.
|
||||
|
||||
const BIN = '/Users/x/Library/Application Support/openswarm/fn-watcher-bin/fn-watcher';
|
||||
|
||||
function ps(lines) { return lines.join('\n'); }
|
||||
|
||||
{ // the actual field case: one orphan, one of ours
|
||||
const out = ps([
|
||||
` 16541 ${BIN}`,
|
||||
` 47069 ${BIN}`,
|
||||
' 1234 /usr/bin/some-other-app',
|
||||
]);
|
||||
assert.deepEqual(strayFnWatcherPids(out, BIN, 47069), [16541], 'must reap the orphan, keep ours');
|
||||
}
|
||||
|
||||
{ // nothing stray
|
||||
assert.deepEqual(strayFnWatcherPids(ps([` 47069 ${BIN}`]), BIN, 47069), []);
|
||||
}
|
||||
|
||||
{ // never match an unrelated binary that merely has a similar name
|
||||
const out = ps([' 900 /opt/other/fn-watcher', ' 901 /usr/bin/fn-watcher-clone']);
|
||||
assert.deepEqual(strayFnWatcherPids(out, BIN, 1), [], 'path match must be exact, not by basename');
|
||||
}
|
||||
|
||||
{ // pid 1 is never a candidate, whatever ps says
|
||||
assert.deepEqual(strayFnWatcherPids(ps([` 1 ${BIN}`]), BIN, 999), []);
|
||||
}
|
||||
|
||||
{ // a missing binary path must never turn into "kill everything"
|
||||
assert.deepEqual(strayFnWatcherPids(ps([` 16541 ${BIN}`]), '', 999), []);
|
||||
assert.deepEqual(strayFnWatcherPids(ps([` 16541 ${BIN}`]), null, 999), []);
|
||||
}
|
||||
|
||||
{ // restricted/absent ps output degrades to a no-op
|
||||
assert.deepEqual(strayFnWatcherPids('', BIN, 999), []);
|
||||
assert.deepEqual(strayFnWatcherPids(null, BIN, 999), []);
|
||||
}
|
||||
|
||||
{ // several strays accumulated across several bad exits
|
||||
const out = ps([` 100 ${BIN}`, ` 200 ${BIN}`, ` 300 ${BIN}`, ` 400 ${BIN}`]);
|
||||
assert.deepEqual(strayFnWatcherPids(out, BIN, 300), [100, 200, 400]);
|
||||
}
|
||||
|
||||
console.log('voiceHotkeyStray: all assertions passed');
|
||||
@@ -51,7 +51,7 @@
|
||||
var g = null;
|
||||
try { g = JSON.parse(localStorage.getItem('self-swarm-theme-gradient') || 'null'); } catch (e) {}
|
||||
var accent = localStorage.getItem('self-swarm-theme-accent');
|
||||
var stops = Array.isArray(g) && g.length > 1 ? g : (accent ? [accent, accent] : ['#B7CDEA', '#EFE0D2', '#E7BDD1']);
|
||||
var stops = Array.isArray(g) && g.length > 1 ? g : (accent ? [accent, accent] : ['#DACEDA']);
|
||||
if (stops.length === 1) stops = [stops[0], stops[0]];
|
||||
var alpha = parseFloat(localStorage.getItem('self-swarm-theme-wash-opacity') || '');
|
||||
if (!isFinite(alpha) || alpha < 0 || alpha > 1) alpha = 0.17;
|
||||
|
||||
@@ -66,6 +66,7 @@ if (typeof window !== 'undefined') {
|
||||
else window.setTimeout(prefetchAll, 500);
|
||||
}
|
||||
import { report, reportAppOpened, getSessionTraceState, getRecentActions } from '@/shared/serviceClient';
|
||||
import { installUxSignals } from '@/shared/uxSignals';
|
||||
import { useRouteTracker } from '@/shared/hooks/useRouteTracker';
|
||||
import { useDeepLink } from '@/shared/hooks/useDeepLink';
|
||||
import { useWindowFocus } from '@/shared/hooks/useWindowFocus';
|
||||
@@ -277,6 +278,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
if (!loaded) return;
|
||||
(window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates);
|
||||
}, [loaded, allowExperimentalUpdates]);
|
||||
useEffect(() => installUxSignals(), []);
|
||||
// Hold paint until the settings fetch SETTLES so the user's theme renders first; Electron's ready-to-show relies on this. Settling, not succeeding: a backend that never answers used to leave a blank window forever.
|
||||
if (!settled) return null;
|
||||
return <>{children}</>;
|
||||
@@ -416,7 +418,17 @@ const CrashRecoveryChip: React.FC = () => {
|
||||
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
|
||||
if (!api?.getCrashRecoveryInfo) return;
|
||||
api.getCrashRecoveryInfo().then((info) => {
|
||||
if (info) { setMounted(true); setShow(true); }
|
||||
if (info) {
|
||||
setMounted(true); setShow(true);
|
||||
// The crash was captured locally but analytics never heard about it; a silent GPU/renderer
|
||||
// death is exactly the failure class telemetry must count (flight-recorder family A).
|
||||
const i = info as { kind?: string; details?: { reason?: string; exitCode?: number } };
|
||||
report('process', 'crash_recovered', {
|
||||
crash_kind: i.kind ?? 'unknown',
|
||||
reason: i.details?.reason ?? null,
|
||||
exit_code: i.details?.exitCode ?? null,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -25,13 +25,14 @@ import { ackRun, runWorkflowNow } from '@/shared/state/workflowsSlice';
|
||||
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import UpdateReadyPill from '@/app/components/Layout/UpdateReadyPill';
|
||||
import WhatsNewCard from '@/app/components/Layout/WhatsNewCard';
|
||||
import ShareRequestHost from '@/app/components/share/ShareRequestHost';
|
||||
import CardContextMenu from '@/app/pages/Dashboard/desktop/CardContextMenu';
|
||||
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
|
||||
import { byPreviewRecency } from '@/shared/previewOrder';
|
||||
import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
|
||||
import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip';
|
||||
import { washOpaqueBackgroundUrl, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
|
||||
import { washBackgroundLayers, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
|
||||
import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl';
|
||||
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
|
||||
|
||||
@@ -342,6 +343,14 @@ const AppShell: React.FC = () => {
|
||||
// unpinned fullscreen keeps the hover-peek overlay.
|
||||
const [fsSidebarPinned, setFsSidebarPinned] = useState(false);
|
||||
const sidebarAway = (sidebarCollapsed || (fsActive && !fsSidebarPinned)) && isDashboardViewActive;
|
||||
// The dashboard canvas paints the identical wash and grain over everything but a 6px frame of
|
||||
// shell, so painting them here too just buys a second full-window texture for the compositor to
|
||||
// evict. Skip it while the canvas is up; on every other route the shell is the only painter.
|
||||
const shellWashLayers = React.useMemo(
|
||||
() => (fsWashStops
|
||||
? washBackgroundLayers(fsWashStops, themeWashOpacity, c.bg.page, isDashboardViewActive ? null : shellGrainUrl)
|
||||
: null),
|
||||
[fsWashStops, themeWashOpacity, c.bg.page, shellGrainUrl, isDashboardViewActive]);
|
||||
// When the sidebar docks away, the canvas runs flush to the window's left edge, so the floating
|
||||
// dashboard header would sit right under the macOS traffic lights. Publish an inset the header reads
|
||||
// (only on macOS, where the lights exist) so it clears them; the sidebar carries its own clearance.
|
||||
@@ -352,6 +361,15 @@ const AppShell: React.FC = () => {
|
||||
else root.style.removeProperty('--osw-header-inset');
|
||||
return () => { root.style.removeProperty('--osw-header-inset'); };
|
||||
}, [sidebarAway]);
|
||||
// Hand the boot paint back. index.html puts the wash gradient on <html> so a reload never flashes
|
||||
// white, but nothing ever took it off: it stayed for the whole session as a full-window,
|
||||
// background-attachment:fixed root layer that the shell already paints over. That is a permanently
|
||||
// resident evictable texture bought for the first frame only, and evicting it is what shows the
|
||||
// hard-edged band. The shell covers the viewport by the time this runs, so a flat colour is all the
|
||||
// backdrop that is left to want.
|
||||
useEffect(() => {
|
||||
document.documentElement.style.background = c.bg.page;
|
||||
}, [c.bg.page]);
|
||||
// Global text-size ratio (Settings > Interface). Scaling the root font-size scales every rem-based
|
||||
// size in one shot, so type grows or shrinks together with no layout breakage. Clamped to a sane band
|
||||
// so a corrupt value can never wreck the whole UI.
|
||||
@@ -416,11 +434,11 @@ const AppShell: React.FC = () => {
|
||||
// sliver of shell peeking past the viewport reads as continuous texture, never a tint/grain seam.
|
||||
...(fsWashStops ? {
|
||||
backgroundColor: washUnderlayColor(fsWashStops, themeWashOpacity, c.bg.page),
|
||||
backgroundImage: shellGrainUrl
|
||||
? `${shellGrainUrl}, ${washOpaqueBackgroundUrl(fsWashStops, themeWashOpacity, c.bg.page)}`
|
||||
: washOpaqueBackgroundUrl(fsWashStops, themeWashOpacity, c.bg.page),
|
||||
backgroundSize: shellGrainUrl ? 'auto, 100% 100%' : '100% 100%',
|
||||
backgroundRepeat: shellGrainUrl ? 'repeat, no-repeat' : 'no-repeat',
|
||||
...(shellWashLayers ? {
|
||||
backgroundImage: shellWashLayers.image,
|
||||
backgroundSize: shellWashLayers.size,
|
||||
backgroundRepeat: shellWashLayers.repeat,
|
||||
} : {}),
|
||||
} : {}),
|
||||
}}>
|
||||
{/* Sidebar retired: dashboards switch via the macOS-Spaces top strip; a slim band below the
|
||||
@@ -590,6 +608,8 @@ const AppShell: React.FC = () => {
|
||||
{/* Shell-global right-click host (portals to body): chat surfaces render on non-dashboard routes too, so the menu can't live inside DashboardCanvas. */}
|
||||
<CardContextMenu />
|
||||
|
||||
<WhatsNewCard />
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Fade from '@mui/material/Fade';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
// Shown ONCE per version, right after an update: what actually changed, in the same words the Help
|
||||
// agent and the GitHub release body carry. A release that ships with no story is a bug the backend
|
||||
// test catches; a user who never hears about the fix is the bug this card catches.
|
||||
interface WhatsNew {
|
||||
version: string;
|
||||
headline: string;
|
||||
highlights: string[];
|
||||
fixes: string[];
|
||||
}
|
||||
|
||||
const SEEN_KEY = 'openswarm.whatsNew.seenVersion';
|
||||
|
||||
export default function WhatsNewCard(): React.ReactElement | null {
|
||||
const c = useClaudeTokens();
|
||||
const [note, setNote] = React.useState<WhatsNew | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch(`${API_BASE}/help/whats-new`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: WhatsNew | null) => {
|
||||
if (cancelled || !data?.version) return;
|
||||
let seen: string | null = null;
|
||||
try { seen = window.localStorage.getItem(SEEN_KEY); } catch { /* private mode */ }
|
||||
if (seen === data.version) return;
|
||||
setNote(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const dismiss = React.useCallback(() => {
|
||||
if (note) {
|
||||
try { window.localStorage.setItem(SEEN_KEY, note.version); } catch { /* private mode */ }
|
||||
}
|
||||
setNote(null);
|
||||
}, [note]);
|
||||
|
||||
if (!note) return null;
|
||||
const lines = [...note.highlights.map((t) => ({ t, kind: 'new' })), ...note.fixes.map((t) => ({ t, kind: 'fixed' }))];
|
||||
|
||||
return (
|
||||
<Fade in timeout={{ enter: 260, exit: 200 }}>
|
||||
<Box
|
||||
data-select-type="whats-new"
|
||||
sx={{
|
||||
position: 'fixed', bottom: 24, right: 24, zIndex: 1450, width: 380, maxWidth: '90vw',
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`, borderRadius: '14px',
|
||||
boxShadow: '0 18px 44px rgba(0,0,0,0.28)', p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: c.font.size.sm, color: c.text.muted, mb: 0.25 }}>
|
||||
{`What's new in ${note.version}`}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: c.font.size.base, fontWeight: 600, color: c.text.primary, mb: 1.25 }}>
|
||||
{note.headline}
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ m: 0, pl: 2, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{lines.slice(0, 5).map((l) => (
|
||||
<Typography key={l.t} component="li" sx={{ fontSize: c.font.size.sm, color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
{l.t}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1.5 }}>
|
||||
<Button size="small" onClick={dismiss} sx={{ color: c.accent.primary, fontWeight: 600, textTransform: 'none' }}>
|
||||
Got it
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { isNarration } from './isNarration.js';
|
||||
import { isNarration } from './isNarration.ts';
|
||||
|
||||
test('short passing remarks are narration', () => {
|
||||
for (const s of [
|
||||
|
||||
@@ -222,6 +222,10 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
// While a browser agent is live the mini browser below carries the SAME action feed on
|
||||
// its own overlay; showing the black log too is the same story twice, so it collapses
|
||||
// to zero height for the duration and returns as the normal record once the run ends.
|
||||
display: isBrowserAgent && (isPending || isStreaming) ? 'none' : undefined,
|
||||
bgcolor: tc.TERM_BG,
|
||||
borderRadius: 1.5,
|
||||
maxHeight: 'min(40vh, 320px)',
|
||||
|
||||
@@ -42,6 +42,7 @@ interface Props {
|
||||
selectedAppIds?: string[],
|
||||
) => void;
|
||||
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
|
||||
onOpenApplications: () => void;
|
||||
onHistoryResume: (sessionId: string) => void;
|
||||
onAddBrowser: () => void;
|
||||
dashboardId?: string;
|
||||
@@ -73,7 +74,7 @@ function formatRelativeTime(dateStr: string | null): string {
|
||||
}
|
||||
|
||||
const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onHistoryResume, onAddBrowser, dashboardId, newAgentBounce, canvasEmpty, onNewAgentBounceEnd, prefillPrompt, prefillMode }, ref) => {
|
||||
({ inputOpen, onNewAgent, onCancel, onSend, onAddView, onOpenApplications, onHistoryResume, onAddBrowser, dashboardId, newAgentBounce, canvasEmpty, onNewAgentBounceEnd, prefillPrompt, prefillMode }, ref) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const elementSelection = useElementSelection();
|
||||
@@ -628,7 +629,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
onNewAgent();
|
||||
}}
|
||||
onAddBrowser={onAddBrowser}
|
||||
onAddApp={handleOpenViewPicker}
|
||||
onAddApp={onOpenApplications}
|
||||
onWorkflows={() => dispatch(workflowsHubOpen ? closeWorkflowsApp() : openWorkflowsApp())}
|
||||
onHistory={handleOpenHistory}
|
||||
/>
|
||||
|
||||
@@ -18,10 +18,13 @@ import ApplicationsWindow from '../desktop/ApplicationsWindow';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
|
||||
import { useGrainTileUrl } from '@/shared/styles/useGrainTileUrl';
|
||||
import { washOpaqueBackgroundUrl, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
|
||||
import { washBackgroundLayers, washUnderlayColor, effectiveWashStops } from '@/shared/styles/washBackground';
|
||||
|
||||
// How far the dot grid bleeds past the viewport; must exceed one tile period (24px * max zoom) so the compositor phase translate can never expose an edge.
|
||||
const GRID_BLEED_PX = 256;
|
||||
// How far the dot grid bleeds past the viewport. The phase translate is `pan % dotSpacing`, so it
|
||||
// can never exceed one tile period; deriving the bleed from that bound keeps the layer as small as
|
||||
// it can be (a hardcoded 256 made it 3.5x bigger than needed, all of it evictable texture) and a
|
||||
// future max-zoom bump can't silently uncover an edge.
|
||||
const GRID_BLEED_PX = 24 * MAX_ZOOM;
|
||||
import type { AgentSession } from '@/shared/state/agentsSlice';
|
||||
import type {
|
||||
CardPosition,
|
||||
@@ -32,7 +35,7 @@ import type {
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { Output } from '@/shared/state/outputsSlice';
|
||||
import type { CardType, useDashboardSelection } from '../hooks/state/useDashboardSelection';
|
||||
import type { useCanvasControls } from '../hooks/interaction/useCanvasControls';
|
||||
import { MAX_ZOOM, type useCanvasControls } from '../hooks/interaction/useCanvasControls';
|
||||
import { useWebviewSuspend } from '../hooks/interaction/useWebviewSuspend';
|
||||
import { deleteSelectedCards } from '../hooks/interaction/deleteSelectedCards';
|
||||
import { getLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
@@ -174,8 +177,8 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
const dotSpacing = 24 * canvas.zoom;
|
||||
// Memoized: this component re-renders every card-drag frame, and rebuilding these strings (SVG encode + hex blends) per frame is pure waste.
|
||||
const washUnderlay = React.useMemo(() => washUnderlayColor(washStops, washOpacity, c.bg.page), [washStops, washOpacity, c.bg.page]);
|
||||
const washUrl = React.useMemo(() => washOpaqueBackgroundUrl(washStops, washOpacity, c.bg.page), [washStops, washOpacity, c.bg.page]);
|
||||
const grainTileUrl = useGrainTileUrl(grain);
|
||||
const washLayers = React.useMemo(() => washBackgroundLayers(washStops, washOpacity, c.bg.page, grainTileUrl), [washStops, washOpacity, c.bg.page, grainTileUrl]);
|
||||
const gridTileUrl = React.useMemo(() => `url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='${dotSpacing}' height='${dotSpacing}'><circle cx='${dotSpacing / 2}' cy='${dotSpacing / 2}' r='${dotSize}' fill='${c.border.medium}'/></svg>`,
|
||||
)}")`, [dotSpacing, dotSize, c.border.medium]);
|
||||
@@ -425,6 +428,15 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
overflow: 'hidden',
|
||||
// Last line of the never-white guarantee: if every background layer's raster is gone, the viewport itself still paints tint (solid colors are compositor quads, not evictable textures).
|
||||
backgroundColor: washUnderlay,
|
||||
// Wash + grain paint HERE rather than on a child: two stacked full-viewport layers meant two
|
||||
// rasters the compositor could evict independently, and a dropped one exposed the flat tint
|
||||
// as a hard-edged band. One element, one raster, one fewer thing to lose. A uniform wash
|
||||
// drops the image entirely, because backgroundColor above already IS that colour.
|
||||
...(washLayers ? {
|
||||
backgroundImage: washLayers.image,
|
||||
backgroundSize: washLayers.size,
|
||||
backgroundRepeat: washLayers.repeat,
|
||||
} : {}),
|
||||
cursor: canvas.isPanning
|
||||
? 'grabbing'
|
||||
: (canvas.spaceHeld || canvas.cmdHeld)
|
||||
@@ -434,21 +446,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
: 'default',
|
||||
}}
|
||||
>
|
||||
{/* Gradient wash: the user's theme-pad stops tint the canvas, Arc-window style; intensity + grain come from the theme device; sits under the dot grid. Pre-blended opaque + declared backgroundColor so a GPU-evicted tile paints as tint, never raw white/black (the ENG-151 band). */}
|
||||
{washStops && washStops.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: washUnderlay,
|
||||
// Grain rides the SAME element (alpha pre-baked): one raster, so an evicted tile drops both and paints the tint, never a grain-only seam.
|
||||
backgroundImage: grainTileUrl ? `${grainTileUrl}, ${washUrl}` : washUrl,
|
||||
backgroundSize: grainTileUrl ? 'auto, 100% 100%' : '100% 100%',
|
||||
backgroundRepeat: grainTileUrl ? 'repeat, no-repeat' : 'no-repeat',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dot grid background; gestures move it imperatively via gridRef (phase + scale), commits re-render it here (dot radius included). The tile is an SVG IMAGE, not a procedural gradient: Chromium caches a decoded image as a GPU texture, while a radial-gradient re-rasterizes the whole layer every backgroundSize change, and under GPU memory pressure (many webviews, external monitors) those rasters get dropped and paint as a giant blank rectangle, the 1.5.9 white-patch bug. Same backgroundSize/Position write contract, so the per-frame camera writer is untouched. */}
|
||||
<Box
|
||||
@@ -540,6 +537,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
onToolbarCancel={onToolbarCancel}
|
||||
onToolbarSend={onToolbarSend}
|
||||
onAddView={onAddView}
|
||||
onOpenApplications={handleToggleApps}
|
||||
onHistoryResume={onHistoryResume}
|
||||
onAddBrowser={onAddBrowser}
|
||||
onNewAgentBounceEnd={onNewAgentBounceEnd}
|
||||
|
||||
@@ -42,6 +42,7 @@ interface DashboardOverlaysProps {
|
||||
onToolbarCancel: () => void;
|
||||
onToolbarSend: (...args: any[]) => void;
|
||||
onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void;
|
||||
onOpenApplications: () => void;
|
||||
onHistoryResume: (sessionId: string) => void;
|
||||
onAddBrowser: () => void;
|
||||
onNewAgentBounceEnd: () => void;
|
||||
@@ -74,6 +75,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
onToolbarCancel,
|
||||
onToolbarSend,
|
||||
onAddView,
|
||||
onOpenApplications,
|
||||
onHistoryResume,
|
||||
onAddBrowser,
|
||||
onNewAgentBounceEnd,
|
||||
@@ -97,6 +99,7 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
|
||||
onCancel={onToolbarCancel}
|
||||
onSend={onToolbarSend}
|
||||
onAddView={onAddView}
|
||||
onOpenApplications={onOpenApplications}
|
||||
onHistoryResume={onHistoryResume}
|
||||
onAddBrowser={onAddBrowser}
|
||||
dashboardId={dashboardId}
|
||||
|
||||
@@ -42,6 +42,10 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
const settingsCard = useAppSelector((s) => s.dashboardLayout.settingsCard);
|
||||
const marketplaceCard = useAppSelector((s) => s.dashboardLayout.marketplaceCard);
|
||||
const monitorCard = useAppSelector((s) => s.dashboardLayout.workflowsMonitorCard);
|
||||
// bringToFront writes the raise into the zOrders map, but these windows render from their own
|
||||
// stored zOrder, so a click on Marketplace/Settings raised nothing at all. Prefer the live map.
|
||||
const zOrders = useAppSelector((s) => s.dashboardLayout.zOrders);
|
||||
const zOf = (id: string, stored: number | undefined): number => zOrders[id] ?? stored ?? 0;
|
||||
const monitorWorkflowId = useAppSelector((s) => s.dashboardLayout.workflowsMonitorId);
|
||||
const monitorWorkflow = useAppSelector((s) => (monitorWorkflowId ? s.workflows.items[monitorWorkflowId] : undefined));
|
||||
// The monitor's workflow vanished (trashed/deleted) while open: tear the card + its tether down instead of leaving an orange line pointing at nothing.
|
||||
@@ -57,7 +61,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
cardY={workflowsHub.y}
|
||||
cardWidth={workflowsHub.width}
|
||||
cardHeight={workflowsHub.height}
|
||||
cardZOrder={workflowsHub.zOrder ?? 0}
|
||||
cardZOrder={zOf('workflows-hub', workflowsHub.zOrder)}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected('workflows-hub')}
|
||||
isHighlighted={highlightedCardId === 'workflows-hub'}
|
||||
@@ -75,7 +79,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
cardY={settingsCard.y}
|
||||
cardWidth={settingsCard.width}
|
||||
cardHeight={settingsCard.height}
|
||||
cardZOrder={settingsCard.zOrder ?? 0}
|
||||
cardZOrder={zOf(SETTINGS_CARD_ID, settingsCard.zOrder)}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected(SETTINGS_CARD_ID)}
|
||||
isHighlighted={highlightedCardId === SETTINGS_CARD_ID}
|
||||
@@ -93,7 +97,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
cardY={marketplaceCard.y}
|
||||
cardWidth={marketplaceCard.width}
|
||||
cardHeight={marketplaceCard.height}
|
||||
cardZOrder={marketplaceCard.zOrder ?? 0}
|
||||
cardZOrder={zOf(MARKETPLACE_CARD_ID, marketplaceCard.zOrder)}
|
||||
getCanvasState={getCanvasState}
|
||||
isSelected={selection.isSelected(MARKETPLACE_CARD_ID)}
|
||||
isHighlighted={highlightedCardId === MARKETPLACE_CARD_ID}
|
||||
@@ -112,7 +116,7 @@ const DashboardWindowCards: React.FC<DashboardWindowCardsProps> = ({
|
||||
cardY={monitorCard.y}
|
||||
cardWidth={monitorCard.width}
|
||||
cardHeight={monitorCard.height}
|
||||
cardZOrder={monitorCard.zOrder ?? 0}
|
||||
cardZOrder={zOf('workflows-monitor', monitorCard.zOrder)}
|
||||
getCanvasState={getCanvasState}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Run: cd frontend && npx tsx --test src/app/pages/Dashboard/canvas/revealZoom.test.ts
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { revealZoom, REVEAL_MIN_ZOOM } from './revealZoom.js';
|
||||
import { revealZoom, REVEAL_MIN_ZOOM } from './revealZoom.ts';
|
||||
|
||||
const MIN = 0.15, MAX = 3.0;
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ function measureWorkspace(): Workspace {
|
||||
export function zoneRect(zone: string): ZoneRect | null {
|
||||
const ws = workspace ?? (workspace = measureWorkspace());
|
||||
const usableW = ws.w - ws.x0 - ws.x1;
|
||||
// No top inset: the drag strip is zIndex 5 and a tiled window is 999990, so the window is already
|
||||
// ABOVE it and never loses those pixels. Insetting below it just pushed every tile 25px down.
|
||||
if (zone === 'fullscreen') {
|
||||
return { x: ws.x0 + GAP, y: GAP, w: usableW - GAP * 2, h: ws.h - GAP * 2 };
|
||||
}
|
||||
@@ -93,6 +95,9 @@ export function zoneRect(zone: string): ZoneRect | null {
|
||||
interface TiledEntry {
|
||||
el: HTMLElement;
|
||||
zone: string;
|
||||
// True when the painted rect already agreed with React's origin at register time, i.e. there is
|
||||
// nothing for the settle pass to correct.
|
||||
trusted?: boolean;
|
||||
// The card's own canvas-space origin, which React keeps owning. The transform below is only the
|
||||
// delta from there to the zone, so tiling never rewrites a stored position and untiling never jumps.
|
||||
originX: number;
|
||||
@@ -175,6 +180,37 @@ function stopObserving(): void {
|
||||
// tool-row motion so the whole app eases identically.
|
||||
const ENTER_MS = 260;
|
||||
const ENTER_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
// The settle must not fire until the CAMERA has stopped, and no fixed delay can promise that: 420ms
|
||||
// outlasted the 340ms glide on this machine, but a loaded renderer or a slow external display can
|
||||
// stretch the glide past any constant (the first constant, 300ms, missed by 40ms and baked a
|
||||
// mid-flight zoom into the tile: a UNIFORM size error, +2.3% at zoom 0.34, -4.1% at 0.61, position
|
||||
// exact). So settle on OBSERVED rest: the camera unchanged for SETTLE_IDLE_MS, after our own enter
|
||||
// transition is done, with a hard backstop so a camera that never rests cannot defer this forever.
|
||||
const SETTLE_MIN_MS = ENTER_MS + 40;
|
||||
const SETTLE_IDLE_MS = 120;
|
||||
const SETTLE_MAX_MS = 2000;
|
||||
|
||||
// Runs onSettled once the camera has demonstrably stopped moving. Every camera write funnels through
|
||||
// syncTiledGeometry into lastCamera, so "lastCamera stopped changing" IS "the camera stopped".
|
||||
function settleWhenCameraRests(onSettled: (elapsedMs: number) => void): void {
|
||||
const started = performance.now();
|
||||
let seen = { ...lastCamera };
|
||||
let restingSince = started;
|
||||
const tick = () => {
|
||||
const now = performance.now();
|
||||
if (lastCamera.panX !== seen.panX || lastCamera.panY !== seen.panY || lastCamera.zoom !== seen.zoom) {
|
||||
seen = { ...lastCamera };
|
||||
restingSince = now;
|
||||
}
|
||||
const rested = now - restingSince >= SETTLE_IDLE_MS && now - started >= SETTLE_MIN_MS;
|
||||
if (rested || now - started >= SETTLE_MAX_MS) {
|
||||
onSettled(Math.round(now - started));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// The passed origin is REACT'S base at call time, but the card may still be repainting (a pill
|
||||
// entering fullscreen commits a different left/top one frame later), and a delta computed against
|
||||
@@ -184,13 +220,33 @@ const ENTER_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
||||
// cards keep their position in framer's transform (computed left is 0), browser cards in left/top
|
||||
// classes; solving painted = pan + zoom*(origin + tx) for origin absorbs every variant, and once
|
||||
// true, every later camera write applies correctly.
|
||||
// A minimized window is parked at left:-100000 with visibility hidden (CanvasWindowCard keeps it
|
||||
// mounted so it holds its state). Its painted rect is therefore nowhere near its canvas home, and
|
||||
// deriving an origin from it puts the tile 100,000px out: that is the "fullscreen from the rail
|
||||
// lands slightly cut off" bug, and it hits Settings, Workflows and Marketplace because those three
|
||||
// are the only surfaces that render through CanvasWindowCard.
|
||||
function isParked(el: HTMLElement): boolean {
|
||||
if (el.getAttribute('data-keepalive-hidden') === '1') return true;
|
||||
try {
|
||||
return getComputedStyle(el).visibility === 'hidden';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// How far the painted rect may disagree with React's committed origin before we stop believing it.
|
||||
// Real disagreement is sub-pixel; anything larger means the card is mid-flight, not at home.
|
||||
const ORIGIN_TRUST_PX = 64;
|
||||
|
||||
function rebaseline(entry: TiledEntry, cam: Camera, tx: number, ty: number): void {
|
||||
// Never re-baseline off a parked card: its rect is the -100000 park, not its home.
|
||||
if (isParked(entry.el)) return;
|
||||
const r = entry.el.getBoundingClientRect();
|
||||
entry.originX = (r.left - cam.panX) / cam.zoom - tx;
|
||||
entry.originY = (r.top - cam.panY) / cam.zoom - ty;
|
||||
}
|
||||
|
||||
export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera): void {
|
||||
export function registerTiledCard(id: string, zone: string, origin: { x: number; y: number }, cam: Camera, opts?: { instant?: boolean }): void {
|
||||
const el = document.querySelector<HTMLElement>(`[data-select-id="${CSS.escape(id)}"]`);
|
||||
if (!el) return;
|
||||
// Derive the TRUE origin up front from the painted rect and the element's current transform
|
||||
@@ -198,34 +254,103 @@ export function registerTiledCard(id: string, zone: string, origin: { x: number;
|
||||
// instead of gliding to a wrong spot and snapping at settle (the left-then-center jerk).
|
||||
let ox = origin.x;
|
||||
let oy = origin.y;
|
||||
if (!isParked(el)) {
|
||||
try {
|
||||
const r0 = el.getBoundingClientRect();
|
||||
const m = new DOMMatrixReadOnly(getComputedStyle(el).transform);
|
||||
const dx = (r0.left - cam.panX) / cam.zoom - m.e;
|
||||
const dy = (r0.top - cam.panY) / cam.zoom - m.f;
|
||||
// React's origin is the committed truth; the rect only refines it. They should agree within a
|
||||
// pixel or two, so a big disagreement means the card is not painted at home yet (gliding in
|
||||
// from the rail) and the rect would bake that flight into the origin.
|
||||
if (Math.abs(dx - origin.x) < ORIGIN_TRUST_PX && Math.abs(dy - origin.y) < ORIGIN_TRUST_PX) {
|
||||
ox = dx;
|
||||
oy = dy;
|
||||
}
|
||||
} catch { /* keep the passed origin */ }
|
||||
}
|
||||
// TEMPORARY (minimized-to-fullscreen offset): one flat STRING, because devtools collapses nested
|
||||
// arrays to "Array(2)" and the values are lost the moment you copy the line out.
|
||||
try {
|
||||
const r0 = el.getBoundingClientRect();
|
||||
const m = new DOMMatrixReadOnly(getComputedStyle(el).transform);
|
||||
ox = (r0.left - cam.panX) / cam.zoom - m.e;
|
||||
oy = (r0.top - cam.panY) / cam.zoom - m.f;
|
||||
} catch { /* keep the passed origin */ }
|
||||
const entry: TiledEntry = { el, zone, originX: ox, originY: oy };
|
||||
const zr = zoneRect(zone);
|
||||
const w = measureWorkspace();
|
||||
const n = (v: number) => Math.round(v);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[TILE] ${id} ${zone} parked=${isParked(el)} rail=${!!document.querySelector('[data-minimized-rail]')}`
|
||||
+ ` | react=${n(origin.x)},${n(origin.y)} rect=${n((r0.left - cam.panX) / cam.zoom - m.e)},${n((r0.top - cam.panY) / cam.zoom - m.f)}`
|
||||
+ ` used=${n(ox)},${n(oy)}`
|
||||
+ ` | painted=${n(r0.left)},${n(r0.top)} ${n(r0.width)}x${n(r0.height)}`
|
||||
+ ` tf=${n(m.e)},${n(m.f)}@${m.a.toFixed(2)}`
|
||||
+ ` | zone=${zr ? `${n(zr.x)},${n(zr.y)} ${n(zr.w)}x${n(zr.h)}` : 'null'}`
|
||||
+ ` cam=${n(cam.panX)},${n(cam.panY)}@${cam.zoom.toFixed(2)}`
|
||||
+ ` ws=x0:${n(w.x0)} x1:${n(w.x1)} ${n(w.w)}x${n(w.h)}`,
|
||||
);
|
||||
} catch { /* diagnostics must never break tiling */ }
|
||||
// If the rect confirmed React's origin, the origin is right and the settle re-baseline can only
|
||||
// make it worse: measured over 12 live fullscreens, EVERY run where re-baselining left the origin
|
||||
// alone landed at OFFBY 0,0, and every run where it moved the origin landed off by 1-10px. It only
|
||||
// ever moved it when the camera was still gliding, because it solves against a painted rect that
|
||||
// the camera is still changing underneath it.
|
||||
const trusted = Math.abs(ox - origin.x) < 1 && Math.abs(oy - origin.y) < 1;
|
||||
const entry: TiledEntry = { el, zone, originX: ox, originY: oy, trusted };
|
||||
entries.set(id, entry);
|
||||
startObserving();
|
||||
// Tiling usually commits alongside chrome collapsing, so a cached workspace is untrustworthy here.
|
||||
workspace = null;
|
||||
lastCamera = cam;
|
||||
el.style.transition = `transform ${ENTER_MS}ms ${ENTER_EASE}`;
|
||||
window.setTimeout(() => {
|
||||
// ...but invalidating is not enough, and on THIS frame it is not even correct. Coming out of the
|
||||
// minimized rail, the rail still holds this card while the zone is being solved, so the workspace
|
||||
// is 76px narrower than it is about to be and the window lands undersized (measured -34x-36) with
|
||||
// no one to tell it otherwise: nothing here notified the size subscribers. Re-measure once the
|
||||
// rail has actually let go, which also re-renders every tiled card's size.
|
||||
requestAnimationFrame(() => {
|
||||
if (entries.get(id)?.el === el) onWorkspaceChanged();
|
||||
});
|
||||
// A card coming OUT OF THE MINIMIZED RAIL has no meaningful start point for a glide: it un-parks
|
||||
// to its canvas home, which the camera is usually not even looking at (register logs show homes
|
||||
// painted offscreen), so the enter transition flew it across the viewport from a spot the user
|
||||
// never saw, while the camera also glided and the zone-size reflow ran. That stack of motion is
|
||||
// the rail-to-fullscreen jank. Landing instantly with a short compositor-only fade is one clean
|
||||
// frame instead; the glide stays for cards that were visibly on canvas, where it reads correctly.
|
||||
if (opts?.instant) {
|
||||
el.style.transition = '';
|
||||
try {
|
||||
el.animate([{ opacity: 0.3 }, { opacity: 1 }], { duration: 150, easing: 'ease-out' });
|
||||
} catch { /* fade is decoration */ }
|
||||
} else {
|
||||
el.style.transition = `transform ${ENTER_MS}ms ${ENTER_EASE}`;
|
||||
}
|
||||
settleWhenCameraRests((settleElapsedMs) => {
|
||||
const live = entries.get(id);
|
||||
if (live?.el !== el) return;
|
||||
// Transition is over: the rect now reflects exactly the transform we last wrote, so the
|
||||
// origin solves cleanly (mid-transition reads would bake animation frames into it).
|
||||
el.style.transition = '';
|
||||
const r = zoneRect(live.zone);
|
||||
if (r) {
|
||||
if (r && !live.trusted) {
|
||||
const s2 = 1 / lastCamera.zoom;
|
||||
const tx = (r.x - lastCamera.panX) * s2 - live.originX;
|
||||
const ty = (r.y - lastCamera.panY) * s2 - live.originY;
|
||||
rebaseline(live, lastCamera, tx, ty);
|
||||
}
|
||||
if (r) {
|
||||
try {
|
||||
const rr = live.el.getBoundingClientRect();
|
||||
const n = (v: number) => Math.round(v);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[TILE settle] ${id} after=${settleElapsedMs}ms trusted=${!!live.trusted} origin=${n(live.originX)},${n(live.originY)}`
|
||||
+ ` | painted=${n(rr.left)},${n(rr.top)} ${n(rr.width)}x${n(rr.height)}`
|
||||
+ ` want=${n(r.x)},${n(r.y)} ${n(r.w)}x${n(r.h)}`
|
||||
+ ` | OFFBY=${n(rr.left - r.x)},${n(rr.top - r.y)} SIZEOFF=${n(rr.width - r.w)},${n(rr.height - r.h)}`,
|
||||
);
|
||||
} catch { /* diagnostics only */ }
|
||||
}
|
||||
applyEntry(live, lastCamera);
|
||||
}, ENTER_MS + 40);
|
||||
});
|
||||
applyEntry(entry, cam);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { requestWebviewAttachSlot, releaseWebviewAttachSlot } from './webviewAttachQueue';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { subscribeLiveDrag } from '../hooks/interaction/liveDragChannel';
|
||||
import Box from '@mui/material/Box';
|
||||
@@ -23,6 +24,7 @@ import AddIcon from '@mui/icons-material/Add';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import RunInDesktopMessage from '@/app/components/RunInDesktopMessage';
|
||||
import {
|
||||
setBrowserCardPosition,
|
||||
@@ -415,6 +417,15 @@ const BrowserCard: React.FC<Props> = ({
|
||||
}, [activeUrl, activeTabId]);
|
||||
|
||||
const webviewMap = useRef<Map<string, WebviewElement>>(new Map());
|
||||
|
||||
// Electron attaches a guest with a SYNCHRONOUS renderer IPC, so a dashboard that mounts N cards
|
||||
// puts N blocking round-trips in one frame (measured: 4755ms over 40 long tasks at 18 cards).
|
||||
// Waiting for a slot spreads them one per frame; nothing unmounts, so sessions are untouched.
|
||||
const [attachSlotReady, setAttachSlotReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (attachSlotReady) return undefined;
|
||||
return requestWebviewAttachSlot(() => setAttachSlotReady(true));
|
||||
}, [attachSlotReady]);
|
||||
const initializedTabs = useRef(new Set<string>());
|
||||
const tabBarRef = useRef<HTMLDivElement>(null);
|
||||
// Some pages (Zillow's map) rewrite their own URL many times a second, across did-navigate-in-page AND did-stop-loading; throttle the persisted URL mirror so each tick can't fan out to a full dashboard save + webview suspend re-eval. Leading edge keeps a real navigation's URL immediate.
|
||||
@@ -510,6 +521,10 @@ const BrowserCard: React.FC<Props> = ({
|
||||
const onReady = () => {
|
||||
if (tabId === activeTabIdRef.current) doLoad();
|
||||
else registerPendingLoad(wv, targetUrl, doLoad);
|
||||
// Release AFTER this card's own post-attach work (loadURL, capsule, zoom limits), not
|
||||
// before: releasing first let that work run against the next card's attach and put six
|
||||
// long tasks in one open where an isolated attach produces two.
|
||||
releaseWebviewAttachSlot();
|
||||
};
|
||||
wv.addEventListener('dom-ready', onReady, { once: true });
|
||||
cleanups.push(() => wv.removeEventListener('dom-ready', onReady));
|
||||
@@ -518,6 +533,18 @@ const BrowserCard: React.FC<Props> = ({
|
||||
tagSurface();
|
||||
wv.addEventListener('dom-ready', tagSurface);
|
||||
cleanups.push(() => wv.removeEventListener('dom-ready', tagSurface));
|
||||
// A dead browser card used to report NOTHING: the guest process vanishes, the surface goes
|
||||
// blank, and no crash log or telemetry ever mentions it (verified by forcing a crash).
|
||||
const onGuestGone = (e: Event): void => {
|
||||
const d = e as Event & { reason?: string; exitCode?: number };
|
||||
report('process', 'webview_gone', { reason: d.reason ?? 'crashed', exit_code: d.exitCode ?? null });
|
||||
};
|
||||
wv.addEventListener('render-process-gone', onGuestGone);
|
||||
wv.addEventListener('crashed', onGuestGone);
|
||||
cleanups.push(() => {
|
||||
wv.removeEventListener('render-process-gone', onGuestGone);
|
||||
wv.removeEventListener('crashed', onGuestGone);
|
||||
});
|
||||
}
|
||||
|
||||
// Every guest sits at about:blank before its real load (lazy tabs never leave it); mirroring
|
||||
@@ -661,7 +688,10 @@ const BrowserCard: React.FC<Props> = ({
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tabIdKey, browserId, dispatch, updateTabLocal, suspendedSnap, throttleUrlMirror]);
|
||||
// attachSlotReady is load-bearing: the <webview> elements do not exist until the attach queue
|
||||
// releases this card, so without it this effect runs once against an empty map, registers no
|
||||
// dom-ready listener, and every card after the first sits at about:blank forever.
|
||||
}, [tabIdKey, browserId, dispatch, updateTabLocal, suspendedSnap, throttleUrlMirror, attachSlotReady]);
|
||||
|
||||
const navigate = useCallback((targetUrl: string) => {
|
||||
const finalUrl = resolveInput(targetUrl);
|
||||
@@ -1477,44 +1507,39 @@ const BrowserCard: React.FC<Props> = ({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Back" placement="top">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleBack}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!activeLocal.canGoBack}
|
||||
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
|
||||
>
|
||||
<ArrowBackIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/* No tooltips on back/forward/reload: every browser on earth uses these arrows, so the label
|
||||
teaches nothing and the popup lands right on top of the page you are trying to read. */}
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Back"
|
||||
onClick={handleBack}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!activeLocal.canGoBack}
|
||||
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
|
||||
>
|
||||
<ArrowBackIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Forward" placement="top">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleForward}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!activeLocal.canGoForward}
|
||||
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
|
||||
>
|
||||
<ArrowForwardIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Forward"
|
||||
onClick={handleForward}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!activeLocal.canGoForward}
|
||||
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
|
||||
>
|
||||
<ArrowForwardIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Reload" placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Reload"
|
||||
onClick={handleRefresh}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
sx={{ color: CHROME_TEXT_MUTED, p: 0.4, '&:hover': { color: CHROME_TEXT } }}
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
|
||||
{/* URL bar */}
|
||||
<Box
|
||||
@@ -1623,7 +1648,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{tabs.map((tab) => (
|
||||
{(attachSlotReady ? tabs : []).map((tab) => (
|
||||
<webview
|
||||
key={tab.id}
|
||||
ref={(el: any) => {
|
||||
|
||||
@@ -26,11 +26,21 @@ export function useTiledCard({ cardId, zone, active, originX, originY, getCamera
|
||||
cameraRef.current = getCamera;
|
||||
const on = !!zone && active;
|
||||
|
||||
// Becoming ACTIVE while a zone is already set is the from-the-rail path: the card was hidden and
|
||||
// is materialising straight into a tile, so the enter glide has no visible start point and must be
|
||||
// skipped. Zone set while already active is the normal path and keeps its glide.
|
||||
const prevActiveRef = useRef(active);
|
||||
const instantRef = useRef(false);
|
||||
if (active && !prevActiveRef.current && !!zone) instantRef.current = true;
|
||||
prevActiveRef.current = active;
|
||||
|
||||
useEffect(() => (on ? subscribeTiledWorkspace(bump) : undefined), [on]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!on || !zone) return undefined;
|
||||
registerTiledCard(cardId, zone, { x: originX, y: originY }, cameraRef.current());
|
||||
const instant = instantRef.current;
|
||||
instantRef.current = false;
|
||||
registerTiledCard(cardId, zone, { x: originX, y: originY }, cameraRef.current(), { instant });
|
||||
return () => unregisterTiledCard(cardId);
|
||||
}, [on, zone, cardId, originX, originY]);
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Serialises <webview> attachment so only one is in flight at a time.
|
||||
*
|
||||
* Electron attaches a guest view with a SYNCHRONOUS renderer IPC (GUEST_VIEW_MANAGER_CALL), so N
|
||||
* cards mounting together put N blocking round-trips in one frame. Measured on a real dashboard:
|
||||
* opening one with 18 cards / 8 webviews blocked the main thread for 4755ms across 40 long tasks,
|
||||
* while an idle canvas blocked for 0ms (ENG-193).
|
||||
*
|
||||
* The first version released a slot per animation frame, which was not enough: an attach costs
|
||||
* 60-140ms, i.e. several frames, so the next slot fired mid-attach and they overlapped anyway
|
||||
* (worst single task stayed at 292ms). Slots now wait for the previous card to report it finished,
|
||||
* with a ceiling so a card that never reports cannot wedge every card behind it.
|
||||
*/
|
||||
|
||||
type Slot = () => void;
|
||||
|
||||
// An attach measured 60-140ms in isolation; this only bounds the pathological case where a card
|
||||
// mounts and never signals, so it is deliberately far above the real cost.
|
||||
const P_ATTACH_CEILING_MS = 1200;
|
||||
|
||||
let pending: Slot[] = [];
|
||||
let inFlight = false;
|
||||
let ceiling: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function p_startNext(): void {
|
||||
const next = pending.shift();
|
||||
if (!next) {
|
||||
inFlight = false;
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
if (ceiling) clearTimeout(ceiling);
|
||||
ceiling = setTimeout(() => { ceiling = null; p_startNext(); }, P_ATTACH_CEILING_MS);
|
||||
try {
|
||||
next();
|
||||
} catch {
|
||||
/* a card that blew up on attach must not stall every card behind it */
|
||||
if (ceiling) { clearTimeout(ceiling); ceiling = null; }
|
||||
p_startNext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the next attach slot. `onReady` fires immediately if nothing is attaching, otherwise once
|
||||
* the card ahead reports done. Returns a cancel function for unmount before the slot arrives.
|
||||
*/
|
||||
export function requestWebviewAttachSlot(onReady: Slot): () => void {
|
||||
pending.push(onReady);
|
||||
if (!inFlight) {
|
||||
// Start on the next frame so a burst of cards mounting in one commit all queue up first.
|
||||
requestAnimationFrame(() => { if (!inFlight) p_startNext(); });
|
||||
}
|
||||
return () => {
|
||||
pending = pending.filter((s) => s !== onReady);
|
||||
};
|
||||
}
|
||||
|
||||
/** A card calls this once its guest has actually attached, releasing the next card in line. */
|
||||
export function releaseWebviewAttachSlot(): void {
|
||||
if (ceiling) { clearTimeout(ceiling); ceiling = null; }
|
||||
p_startNext();
|
||||
}
|
||||
|
||||
/** Cards waiting behind the queue right now; exposed so a test can prove the burst is serialised. */
|
||||
export function pendingAttachCount(): number {
|
||||
return pending.length;
|
||||
}
|
||||
@@ -29,6 +29,11 @@ export function getCardRect(id: string, type: CardType):
|
||||
const sc = layoutState.settingsCard;
|
||||
if (!sc) return undefined;
|
||||
return { x: sc.x, y: sc.y, width: sc.width, height: sc.height };
|
||||
} else if (type === 'marketplace') {
|
||||
// Marketplace was the one window the camera could not frame, so clicking it did nothing.
|
||||
const mc = layoutState.marketplaceCard;
|
||||
if (!mc) return undefined;
|
||||
return { x: mc.x, y: mc.y, width: mc.width, height: mc.height };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,18 @@ import { applyBrowserZoom } from '@/shared/browserZoom';
|
||||
import { syncTiledGeometry } from '../../canvas/tiledGeometry';
|
||||
import { revealZoom, REVEAL_MIN_ZOOM } from '../../canvas/revealZoom';
|
||||
|
||||
// Surfaces that are WINDOWS, not canvas cards: they behave like an OS window, so a wheel inside one
|
||||
// belongs to it whether or not you clicked in first. Canvas cards (agent, browser, view) keep the
|
||||
// Google Maps model instead, where a plain scroll over an unfocused card drives the canvas.
|
||||
const APP_WINDOW_SELECT_TYPES = new Set(['settings-card', 'marketplace-card', 'workflows-hub-card']);
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
// The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go
|
||||
// down, so every spawn that did not fit ratcheted the camera out and nothing ever brought it back:
|
||||
// measured 100% -> 88% -> 79% -> 61% -> 36% -> 18% over one ordinary session, at which point no word
|
||||
// on the canvas is readable. A hand-driven zoom can still go all the way to MIN_ZOOM.
|
||||
|
||||
const MAX_ZOOM = 3.0;
|
||||
export const MAX_ZOOM = 3.0;
|
||||
const ZOOM_IN_FACTOR = 1.1;
|
||||
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
|
||||
const FIT_PADDING = 200;
|
||||
@@ -24,7 +29,7 @@ const FIT_PADDING = 200;
|
||||
const TIDY_PADDING = { x: 120, y: 56 };
|
||||
const TIDY_MIN_ZOOM = REVEAL_MIN_ZOOM;
|
||||
// Card-framing (spawn, click-to-focus, arrow-nav) snaps as fast as the zoom buttons so a new card lands under you now, not after a lazy glide.
|
||||
const FIT_DURATION = 150;
|
||||
const FIT_DURATION = 340;
|
||||
// Must outlast FIT_DURATION so the drift re-snap lands after the glide, never mid-flight.
|
||||
const FIT_SETTLE_DELAY = FIT_DURATION + 60;
|
||||
// A mouse notch lands as deltaY 100 where a trackpad sends ~1-10, so cap the per-event zoom delta: uncapped, one notch is a ~24% jump and macOS wheel acceleration stacks them. No-op for trackpads.
|
||||
@@ -235,7 +240,9 @@ export function useCanvasControls(
|
||||
|
||||
const step = (now: number) => {
|
||||
const t = Math.min((now - startTime) / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
|
||||
// Quintic ease-out: leaves fast, lands soft. The old cubic at 150ms read as a snap; the eye
|
||||
// reads the long tail as "the camera settled" rather than "the world jumped".
|
||||
const ease = 1 - Math.pow(1 - t, 5);
|
||||
applyLive({
|
||||
panX: start.panX + (target.panX - start.panX) * ease,
|
||||
panY: start.panY + (target.panY - start.panY) * ease,
|
||||
@@ -323,13 +330,49 @@ export function useCanvasControls(
|
||||
|
||||
// Cache "is this element a scrollable child" decision per node. The Cache getComputedStyle ancestor walks; uncached was the dominant cost of trackpad two-finger nav. ResizeObserver below invalidates on scroll-capacity change.
|
||||
const scrollableCache: WeakMap<HTMLElement, 'scrollable' | 'not'> = new WeakMap();
|
||||
// Scroll containment: once a wheel GESTURE is being served by a scrollable surface, the rest of
|
||||
// that gesture stays there even after it hits the surface's end. Without this, reaching the
|
||||
// bottom of Settings (or any list) chains into a canvas pan, which reads as the whole world
|
||||
// sliding out from under you. A new gesture (a pause, or a different surface) starts fresh.
|
||||
const GESTURE_GAP_MS = 220;
|
||||
let containedEl: HTMLElement | null = null;
|
||||
let containedAt = 0;
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Full size view owns the whole surface: any wheel that escapes the chat's scroll container
|
||||
// (side gutters, header) must NOT zoom/pan the hidden canvas underneath, that read as a
|
||||
// glitchy zoom while scrolling the chat. Fullscreen has no canvas nav, period. The selector's
|
||||
// existence check matters: a stale tile entry for a removed card would wedge the wheel forever.
|
||||
if (selectFullscreenCardId(store.getState())) return;
|
||||
// Swallow it rather than just ignoring it: the host window now allows visual zoom (so macOS
|
||||
// delivers pinch at all), which means an un-prevented pinch here would magnify the whole UI
|
||||
// instead of doing nothing.
|
||||
if (selectFullscreenCardId(store.getState())) {
|
||||
if (e.ctrlKey || e.metaKey) e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Same gesture, still over the surface that owns it: let it scroll (or hit its end) natively.
|
||||
if (containedEl && Date.now() - containedAt < GESTURE_GAP_MS && containedEl.isConnected
|
||||
&& (e.target instanceof Node) && containedEl.contains(e.target as Node) && !(e.ctrlKey || e.metaKey)) {
|
||||
containedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
// App windows (Settings, Marketplace, app previews) own every wheel inside them. Their inner
|
||||
// panels are often scroll containers whose exact hit target isn't itself scrollable, and the
|
||||
// old walk-up handed those to the canvas: reaching the end of Settings zoomed the world out.
|
||||
const windowEl = (e.target as HTMLElement | null)?.closest?.('[data-select-type]') as HTMLElement | null;
|
||||
if (windowEl && !(e.ctrlKey || e.metaKey)) {
|
||||
// An app WINDOW always owns its wheel; a canvas CARD only owns it once you have clicked in.
|
||||
// That split is the whole rule. Requiring click-focus for windows too meant hovering over
|
||||
// Settings and scrolling leaked straight to the canvas, because nothing had focused it yet,
|
||||
// and windows do carry a select-id so a "no id means a window" test silently never fired.
|
||||
const windowId = windowEl.getAttribute('data-select-id');
|
||||
const isAppWindow = APP_WINDOW_SELECT_TYPES.has(windowEl.getAttribute('data-select-type') || '');
|
||||
if (isAppWindow || !windowId || windowId === getScrollFocusedCard()) {
|
||||
containedEl = windowEl;
|
||||
containedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ctrl/cmd wheel is the zoom gesture on every surface: a physically held key or a trackpad pinch (which also sets ctrlKey). It bypasses scrollable children so zoom is always reachable, even over a chat you're typing in.
|
||||
const isModifierWheel = e.ctrlKey || e.metaKey;
|
||||
// The setting swaps which of the two a bare mouse notch does. A PINCH must keep zooming
|
||||
@@ -372,31 +415,17 @@ export function useCanvasControls(
|
||||
target = target.parentElement;
|
||||
continue;
|
||||
}
|
||||
// Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic.
|
||||
const canScrollY = target.scrollHeight > target.clientHeight;
|
||||
const canScrollX = target.scrollWidth > target.clientWidth;
|
||||
|
||||
// Horizontal-dominant gestures over a container that only scrolls vertically (e.g., chat) should pan the canvas instead of being silently absorbed by the child's no-op horizontal handling.
|
||||
if (Math.abs(dx) > Math.abs(dy) && !canScrollX) {
|
||||
target = target.parentElement;
|
||||
continue;
|
||||
}
|
||||
|
||||
const atYBoundary = !canScrollY ||
|
||||
(dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) ||
|
||||
(dy < 0 && target.scrollTop <= 1);
|
||||
const atXBoundary = !canScrollX ||
|
||||
(dx > 0 && target.scrollLeft + target.clientWidth >= target.scrollWidth - 1) ||
|
||||
(dx < 0 && target.scrollLeft <= 1);
|
||||
|
||||
if (atYBoundary && atXBoundary) {
|
||||
target = target.parentElement;
|
||||
continue;
|
||||
}
|
||||
// Past this point the gesture belongs to THIS surface for its whole life. Reaching the end
|
||||
// of a chat, or swiping sideways in a list that only scrolls down, used to fall through to
|
||||
// the canvas and drag the world out from under you; a scroll that starts inside a card now
|
||||
// ends inside it. Zoom still gets through, because isModifierWheel never reaches here.
|
||||
containedEl = target;
|
||||
containedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
target = target.parentElement;
|
||||
}
|
||||
containedEl = null;
|
||||
|
||||
e.preventDefault();
|
||||
if (inertiaFrameRef.current) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isAgentDrivenBrowser } from '@/shared/isAgentDrivenBrowser';
|
||||
import { expandSession } from '@/shared/state/agentsSlice';
|
||||
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setScrollFocusedCard } from '@/shared/cardScrollFocus';
|
||||
import { REVEAL_MIN_ZOOM } from '../../canvas/revealZoom';
|
||||
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
|
||||
import type { useCanvasControls } from './useCanvasControls';
|
||||
|
||||
@@ -228,7 +229,9 @@ export function useDashboardInteractions({
|
||||
report('dashboard', 'canvas_double_clicked');
|
||||
// Double-tap on empty space = show me everything (Eric's call): the same animated fit the
|
||||
// overview affordances use, instead of the old blind 0.55x zoom-out that just lost people.
|
||||
canvas.actions.fitToView();
|
||||
// Floored like tidy and reveal, because unfloored it clamped at MIN_ZOOM and a spread-out canvas
|
||||
// landed at 15%, every card a postage stamp. That is the same "lost people" this was meant to fix.
|
||||
canvas.actions.fitToView(REVEAL_MIN_ZOOM);
|
||||
}, [canvas.actions]);
|
||||
|
||||
// Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)
|
||||
|
||||
@@ -11,11 +11,18 @@ import { getActivity, isAnyBrowserBusy } from '@/shared/browserCommandHandler';
|
||||
import { isKeepAliveBrowser } from '@/shared/browserFocus';
|
||||
import { captureTabCapsule } from '@/shared/browserStateCapsule';
|
||||
import { getMinimizedShot } from '../../desktop/minimizedShots';
|
||||
import { guestBudgetHasRoom, wireBrowserLiveCounter } from '@/shared/appWebviewBudget';
|
||||
import { useAppHidden } from './useAppHidden';
|
||||
import { cardIntersectsViewport, distFromCenter, type Viewport } from './suspendGeometry';
|
||||
|
||||
const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron');
|
||||
|
||||
// Feed the global guest budget the live-browser count, so apps and browsers share ONE ceiling.
|
||||
wireBrowserLiveCounter(() => {
|
||||
const dl = store.getState().dashboardLayout;
|
||||
return Object.keys(dl.browserCards).filter((id) => !dl.suspendedBrowserCards[id]).length;
|
||||
});
|
||||
|
||||
const SETTLE_MS = 800;
|
||||
// Hysteresis: suspend only well past the edge, resume just past it, so a card sitting on the boundary never flaps between webview and snapshot.
|
||||
const SUSPEND_MARGIN_PX = 320;
|
||||
@@ -23,6 +30,13 @@ const RESUME_MARGIN_PX = 96;
|
||||
const SNAPSHOT_MAX_W = 1024;
|
||||
// Below this on-screen width a live page is indistinguishable from its placeholder, so booted-parked cards on a zoomed-out canvas stay parked until zoomed into.
|
||||
const RESUME_MIN_CARD_PX = 220;
|
||||
// ...and the same rule on the way OUT. This used to be one-directional: a card too small to read was
|
||||
// never woken, but one already awake stayed awake however far you zoomed out, so eight webviews kept
|
||||
// rendering full pages into ~100px boxes on a zoomed-out canvas. That is the app's biggest GPU cost
|
||||
// paid for pixels nobody can read, and it is the pressure that makes Chromium evict the wash tiles
|
||||
// (the two-tone background band). Lower than the resume bar on purpose, so a card sitting near the
|
||||
// threshold cannot flap between live and snapshot.
|
||||
const SUSPEND_MAX_CARD_PX = 150;
|
||||
// Hard ceiling on simultaneous live webviews; past it the farthest-from-center non-agent card gets parked, so heavy pages degrade gracefully instead of OOMing.
|
||||
const MAX_LIVE_WEBVIEWS = 8;
|
||||
|
||||
@@ -161,12 +175,25 @@ export function useWebviewSuspend(
|
||||
// Un-minimizing is an explicit "give it back", so it wakes the card wherever the camera happens to be pointing.
|
||||
if (wasMinimized[id]) {
|
||||
restoredAt.set(id, Date.now());
|
||||
dispatch(resumeBrowserCard(id));
|
||||
// Straight into a tile (rail -> fullscreen): attaching the guest is a SYNCHRONOUS 60-290ms
|
||||
// main-thread IPC, and firing it inside the landing + camera glide is the browser-only jank
|
||||
// (DOM windows never pay it). The snapshot is pixel-identical, so let it do the landing and
|
||||
// attach once the motion is over; re-minimized in the gap means never attach at all.
|
||||
if (store.getState().dashboardLayout.tiledCards[id]) {
|
||||
window.setTimeout(() => {
|
||||
const dl = store.getState().dashboardLayout;
|
||||
if (!dl.minimizedCards[id] && dl.suspendedBrowserCards[id]) dispatch(resumeBrowserCard(id));
|
||||
}, 600);
|
||||
} else {
|
||||
dispatch(resumeBrowserCard(id));
|
||||
}
|
||||
budget--;
|
||||
continue;
|
||||
}
|
||||
const bigEnough = card.width * zoom >= RESUME_MIN_CARD_PX;
|
||||
if (bigEnough && cardIntersectsViewport(card, vpRef.current, RESUME_MARGIN_PX)) {
|
||||
// Passive wake also asks the GLOBAL budget: a free browser slot means nothing if apps already
|
||||
// hold the machine at its ceiling. Explicit restores and working agents above never ask.
|
||||
if (bigEnough && cardIntersectsViewport(card, vpRef.current, RESUME_MARGIN_PX) && guestBudgetHasRoom()) {
|
||||
dispatch(resumeBrowserCard(id));
|
||||
budget--;
|
||||
}
|
||||
@@ -178,6 +205,9 @@ export function useWebviewSuspend(
|
||||
const wantsPark = (id: string, card: BrowserCardPosition): boolean =>
|
||||
appHidden
|
||||
|| isMinimized(id)
|
||||
// Read zoom off the ref, never the closure: this fires 800ms after the effect ran, and
|
||||
// zooming out is exactly the gesture that should be parking these.
|
||||
|| (!withinRestoreGrace(id) && card.width * vpRef.current.zoom < SUSPEND_MAX_CARD_PX)
|
||||
|| (!withinRestoreGrace(id) && !cardIntersectsViewport(card, vpRef.current, SUSPEND_MARGIN_PX));
|
||||
await refreshVisibleFrames(browserCards, isSuspended, vpRef.current);
|
||||
for (const [id, card] of Object.entries(browserCards)) {
|
||||
|
||||
@@ -48,7 +48,9 @@ const ShortcutRecorderChip: React.FC<{ value: string; onChange: (combo: string)
|
||||
setRecording(false);
|
||||
}}
|
||||
onBlur={() => setRecording(false)}
|
||||
onClick={() => setRecording(true)}
|
||||
// Arming without taking focus meant the next blur disarmed it before any key could land, so
|
||||
// the chip snapped back to its old value and looked like it refused to be rebound.
|
||||
onClick={(e) => { (e.currentTarget as HTMLElement).focus(); setRecording(true); }}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -5,36 +5,47 @@ import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import CountUp from './parts/CountUp';
|
||||
import BarSeries from './parts/BarSeries';
|
||||
import ActivityColumns from './parts/ActivityColumns';
|
||||
import StatusDonut from './parts/StatusDonut';
|
||||
|
||||
type Window = '7d' | '30d' | 'all';
|
||||
|
||||
interface DayPoint { day: string; chats: number }
|
||||
|
||||
interface UsageSummary {
|
||||
window: string;
|
||||
excluded_automation_sessions: number;
|
||||
total_sessions: number;
|
||||
total_messages: number;
|
||||
total_tool_calls: number;
|
||||
total_run_seconds: number;
|
||||
avg_duration_seconds: number;
|
||||
completion_rate: number;
|
||||
models_used: Record<string, number>;
|
||||
top_tools: Record<string, number>;
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
total_cost_usd: number;
|
||||
}
|
||||
|
||||
function fmtDuration(seconds: number): string {
|
||||
if (seconds >= 3600) return `${(seconds / 3600).toFixed(1)} hrs`;
|
||||
if (seconds >= 60) return `${Math.round(seconds / 60)} min`;
|
||||
return `${Math.round(seconds)}s`;
|
||||
status_breakdown: Record<string, number>;
|
||||
daily_activity: DayPoint[];
|
||||
hourly_activity: number[];
|
||||
}
|
||||
|
||||
function fmtCount(n: number): string {
|
||||
return n >= 10000 ? `${(n / 1000).toFixed(1)}k` : n.toLocaleString();
|
||||
return n >= 10000 ? `${(n / 1000).toFixed(1)}k` : Math.round(n).toLocaleString();
|
||||
}
|
||||
|
||||
/** Your real activity, claude-flat: windowed, automation excluded, friendly names, honest scopes. */
|
||||
function cleanToolName(t: string): string {
|
||||
return t.replace(/^mcp__[^_]+(?:__)+/, '').replace(/^openswarm-\w+__/, '');
|
||||
}
|
||||
|
||||
function hourLabel(h: number): string {
|
||||
if (h === 0) return '12am';
|
||||
if (h === 12) return '12pm';
|
||||
return h < 12 ? `${h}am` : `${h - 12}pm`;
|
||||
}
|
||||
|
||||
/** Your real activity: windowed, automation excluded, and only numbers we actually measure. */
|
||||
const UsageStats: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const [win, setWin] = useState<Window>('30d');
|
||||
@@ -49,17 +60,23 @@ const UsageStats: React.FC = () => {
|
||||
return () => { alive = false; };
|
||||
}, [win]);
|
||||
|
||||
const rowSx = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
py: 1.1, borderBottom: `1px solid ${c.border.subtle}`, '&:last-of-type': { borderBottom: 'none' },
|
||||
const sectionSx = {
|
||||
color: c.text.muted, fontSize: '0.71875rem', fontWeight: 650, letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase', mt: 3, mb: 1,
|
||||
} as const;
|
||||
const labelSx = { color: c.text.primary, fontSize: '0.8438rem', fontWeight: 500 } as const;
|
||||
const valueSx = { color: c.text.primary, fontSize: '0.8438rem', fontVariantNumeric: 'tabular-nums' } as const;
|
||||
const sectionSx = { color: c.text.muted, fontSize: '0.71875rem', fontWeight: 650, letterSpacing: '0.05em', textTransform: 'uppercase', mt: 2.5, mb: 0.5 } as const;
|
||||
const cardSx = {
|
||||
flex: 1, minWidth: 0, px: 1.75, py: 1.5, borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${c.border.subtle}`, background: c.bg.elevated,
|
||||
} as const;
|
||||
const bigSx = { color: c.text.primary, fontSize: '1.5rem', fontWeight: 600, lineHeight: 1.1, fontVariantNumeric: 'tabular-nums' } as const;
|
||||
const capSx = { color: c.text.muted, fontSize: '0.75rem', mt: 0.4 } as const;
|
||||
|
||||
const peakHour = stats ? stats.hourly_activity.indexOf(Math.max(...stats.hourly_activity)) : 0;
|
||||
const avgMsgs = stats && stats.total_sessions > 0 ? stats.total_messages / stats.total_sessions : 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.8125rem' }}>
|
||||
Your own sessions on this device{stats && stats.excluded_automation_sessions > 0
|
||||
? `; ${fmtCount(stats.excluded_automation_sessions)} automated runs excluded`
|
||||
@@ -82,54 +99,80 @@ const UsageStats: React.FC = () => {
|
||||
<Box sx={{ py: 4, textAlign: 'center', color: c.text.ghost, fontSize: '0.8125rem' }}>Loading…</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Chats</Typography>
|
||||
<Typography sx={valueSx}>{fmtCount(stats.total_sessions)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Messages</Typography>
|
||||
<Typography sx={valueSx}>{fmtCount(stats.total_messages)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Tool calls</Typography>
|
||||
<Typography sx={valueSx}>{fmtCount(stats.total_tool_calls)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Agent time</Typography>
|
||||
<Typography sx={valueSx}>{fmtDuration(stats.total_run_seconds)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Finished cleanly</Typography>
|
||||
<Typography sx={valueSx}>{Math.round(stats.completion_rate * 100)}%</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.25 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_sessions} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Chats</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_messages} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Messages</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_tool_calls} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Tool calls</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={avgMsgs} format={(n) => n.toFixed(1)} /></Typography>
|
||||
<Typography sx={capSx}>Messages per chat</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{stats.daily_activity.length > 1 && (
|
||||
<>
|
||||
<Typography sx={sectionSx}>Chats per day</Typography>
|
||||
<ActivityColumns
|
||||
data={stats.daily_activity.map((d) => ({ key: d.day, value: d.chats, caption: d.day.slice(5) }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography sx={sectionSx}>When you work</Typography>
|
||||
<ActivityColumns
|
||||
height={64}
|
||||
highlightIndex={peakHour}
|
||||
data={stats.hourly_activity.map((v, h) => ({ key: String(h), value: v, caption: hourLabel(h) }))}
|
||||
/>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mt: 0.5 }}>
|
||||
Busiest around {hourLabel(peakHour)}.
|
||||
</Typography>
|
||||
|
||||
<Typography sx={sectionSx}>How chats end</Typography>
|
||||
<StatusDonut
|
||||
slices={[
|
||||
{ label: 'Finished cleanly', value: stats.status_breakdown.completed ?? 0, color: c.accent.primary },
|
||||
{ label: 'You stopped it', value: stats.status_breakdown.stopped ?? 0, color: c.text.ghost },
|
||||
{ label: 'Hit an error', value: stats.status_breakdown.error ?? 0, color: c.status?.error ?? '#c2554d' },
|
||||
].filter((s) => s.value > 0)}
|
||||
/>
|
||||
|
||||
<Typography sx={sectionSx}>Models</Typography>
|
||||
{Object.entries(stats.models_used).slice(0, 6).map(([model, count]) => (
|
||||
<Box key={model} sx={rowSx}>
|
||||
<Typography sx={labelSx}>{model}</Typography>
|
||||
<Typography sx={{ ...valueSx, color: c.text.secondary }}>{fmtCount(count)} chats</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<BarSeries
|
||||
data={Object.entries(stats.models_used).slice(0, 6).map(([label, value]) => ({ label, value, suffix: 'chats' }))}
|
||||
/>
|
||||
|
||||
<Typography sx={sectionSx}>Most used tools</Typography>
|
||||
{Object.entries(stats.top_tools).slice(0, 8).map(([tool, count]) => (
|
||||
<Box key={tool} sx={rowSx}>
|
||||
<Typography sx={labelSx}>{tool.replace(/^mcp__[^_]+(?:__)+/, '').replace(/^openswarm-\w+__/, '')}</Typography>
|
||||
<Typography sx={{ ...valueSx, color: c.text.secondary }}>{fmtCount(count)} calls</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<BarSeries
|
||||
data={Object.entries(stats.top_tools).slice(0, 8).map(([t, value]) => ({ label: cleanToolName(t), value, suffix: 'calls' }))}
|
||||
/>
|
||||
|
||||
<Typography sx={sectionSx}>Routed requests (all traffic, lifetime)</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mb: 0.5 }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.75rem', mb: 1 }}>
|
||||
Everything routed through the local model router since install, including background helpers; not limited to the window above.
|
||||
</Typography>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>Tokens in / out</Typography>
|
||||
<Typography sx={valueSx}>{fmtCount(stats.total_prompt_tokens)} / {fmtCount(stats.total_completion_tokens)}</Typography>
|
||||
</Box>
|
||||
<Box sx={rowSx}>
|
||||
<Typography sx={labelSx}>API value covered</Typography>
|
||||
<Typography sx={valueSx}>${stats.total_cost_usd.toFixed(2)}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1.25 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_prompt_tokens} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Tokens in</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}><CountUp value={stats.total_completion_tokens} format={fmtCount} /></Typography>
|
||||
<Typography sx={capSx}>Tokens out</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={bigSx}>$<CountUp value={stats.total_cost_usd} format={(n) => n.toFixed(2)} /></Typography>
|
||||
<Typography sx={capSx}>API value covered</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface ColumnDatum {
|
||||
key: string;
|
||||
value: number;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
interface ActivityColumnsProps {
|
||||
data: ColumnDatum[];
|
||||
height?: number;
|
||||
highlightIndex?: number;
|
||||
}
|
||||
|
||||
/** Column chart with a staggered grow-in; heights ride CSS transitions so nothing animates per frame. */
|
||||
const ActivityColumns: React.FC<ActivityColumnsProps> = ({ data, height = 84, highlightIndex }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [grown, setGrown] = useState(false);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setGrown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const peak = Math.max(1, ...data.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: '3px', height }}>
|
||||
{data.map((d, i) => (
|
||||
<Box
|
||||
key={d.key}
|
||||
onMouseEnter={() => setHover(i)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', height: '100%', cursor: 'default' }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '3px 3px 0 0',
|
||||
background: i === highlightIndex || i === hover ? c.accent.primary : c.text.ghost,
|
||||
opacity: i === highlightIndex || i === hover ? 1 : 0.42,
|
||||
height: grown ? `${Math.max(2, (d.value / peak) * 100)}%` : '0%',
|
||||
transition: `height 600ms cubic-bezier(0.22,1,0.36,1) ${Math.min(i * 18, 420)}ms, background 140ms ease, opacity 140ms ease`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.6, minHeight: 16 }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.6875rem' }}>{data[0]?.caption ?? ''}</Typography>
|
||||
<Typography sx={{ color: hover === null ? c.text.ghost : c.text.secondary, fontSize: '0.6875rem', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{hover === null ? (data[data.length - 1]?.caption ?? '') : `${data[hover].caption}: ${data[hover].value.toLocaleString()}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityColumns;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface BarDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
interface BarSeriesProps {
|
||||
data: BarDatum[];
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** Ranked horizontal bars that grow in on mount; width rides a CSS transition, so no JS runs per frame. */
|
||||
const BarSeries: React.FC<BarSeriesProps> = ({ data, max }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [grown, setGrown] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setGrown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const peak = max ?? Math.max(1, ...data.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{data.map((d, i) => (
|
||||
<Box key={d.label} sx={{ py: 0.7 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.35 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.8125rem', fontWeight: 500 }}>{d.label}</Typography>
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.8125rem', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{d.value.toLocaleString()}{d.suffix ? ` ${d.suffix}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 5, borderRadius: 3, background: c.border.subtle, overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
background: c.accent.primary,
|
||||
opacity: 0.55 + 0.45 * (1 - i / Math.max(1, data.length)),
|
||||
width: grown ? `${Math.max(2, (d.value / peak) * 100)}%` : '0%',
|
||||
transition: `width 620ms cubic-bezier(0.22,1,0.36,1) ${i * 45}ms`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarSeries;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface CountUpProps {
|
||||
value: number;
|
||||
durationMs?: number;
|
||||
format?: (n: number) => string;
|
||||
}
|
||||
|
||||
// One-shot rAF that parks when it lands; a permanent loop here would cost the whole machine 60fps forever.
|
||||
const CountUp: React.FC<CountUpProps> = ({ value, durationMs = 650, format }) => {
|
||||
const [shown, setShown] = useState(value);
|
||||
const fromRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduced || durationMs <= 0) { setShown(value); return undefined; }
|
||||
const from = fromRef.current;
|
||||
const delta = value - from;
|
||||
if (delta === 0) { setShown(value); return undefined; }
|
||||
let raf = 0;
|
||||
const t0 = performance.now();
|
||||
const tick = (now: number): void => {
|
||||
const p = Math.min(1, (now - t0) / durationMs);
|
||||
const eased = 1 - Math.pow(1 - p, 4);
|
||||
setShown(from + delta * eased);
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
else fromRef.current = value;
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [value, durationMs]);
|
||||
|
||||
useEffect(() => { fromRef.current = value; }, [value]);
|
||||
|
||||
return <>{format ? format(shown) : Math.round(shown).toLocaleString()}</>;
|
||||
};
|
||||
|
||||
export default CountUp;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface DonutSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StatusDonutProps {
|
||||
slices: DonutSlice[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const P_R = 42;
|
||||
const P_CIRC = 2 * Math.PI * P_R;
|
||||
|
||||
/** Donut whose arcs sweep in via stroke-dashoffset; SVG stroke transitions run on the compositor. */
|
||||
const StatusDonut: React.FC<StatusDonutProps> = ({ slices, size = 116 }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [drawn, setDrawn] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setDrawn(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const total = Math.max(1, slices.reduce((a, s) => a + s.value, 0));
|
||||
|
||||
let offset = 0;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
|
||||
<Box sx={{ position: 'relative', width: size, height: size, flexShrink: 0 }}>
|
||||
<svg width={size} height={size} viewBox="0 0 100 100" style={{ transform: 'rotate(-90deg)' }}>
|
||||
<circle cx="50" cy="50" r={P_R} fill="none" stroke={c.border.subtle} strokeWidth="11" />
|
||||
{slices.map((s, i) => {
|
||||
const frac = s.value / total;
|
||||
const dash = drawn ? frac * P_CIRC : 0;
|
||||
const rot = offset;
|
||||
offset += frac;
|
||||
return (
|
||||
<circle
|
||||
key={s.label}
|
||||
cx="50" cy="50" r={P_R} fill="none"
|
||||
stroke={s.color} strokeWidth="11" strokeLinecap="butt"
|
||||
strokeDasharray={`${dash} ${P_CIRC}`}
|
||||
style={{
|
||||
transform: `rotate(${rot * 360}deg)`,
|
||||
transformOrigin: '50% 50%',
|
||||
transition: `stroke-dasharray 700ms cubic-bezier(0.22,1,0.36,1) ${i * 110}ms`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '1.05rem', fontWeight: 600, lineHeight: 1 }}>
|
||||
{Math.round((slices[0]?.value ?? 0) / total * 100)}%
|
||||
</Typography>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.625rem', mt: 0.25 }}>clean</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
{slices.map((s) => (
|
||||
<Box key={s.label} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.35 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '2px', background: s.color, flexShrink: 0 }} />
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.78125rem' }}>{s.label}</Typography>
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.78125rem', fontVariantNumeric: 'tabular-nums', ml: 0.5 }}>
|
||||
{s.value.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusDonut;
|
||||
@@ -4,6 +4,8 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
requestAppSlot,
|
||||
releaseAppSlot,
|
||||
wireBrowserLiveCounter,
|
||||
totalLiveGuests,
|
||||
MAX_LIVE_APP_WEBVIEWS as MAX,
|
||||
} from './appWebviewBudget.ts';
|
||||
|
||||
@@ -65,3 +67,25 @@ test('re-requesting an already-live card just updates it, no extra slot', () =>
|
||||
assert.equal(requestAppSlot('f-far', 9999, false), false, 'still full after a re-request');
|
||||
release([...keys, 'f-far']);
|
||||
});
|
||||
|
||||
test('the global ceiling counts browsers and apps together', () => {
|
||||
// 8 live browsers reported: only 2 of the 6 app slots may actually go live.
|
||||
wireBrowserLiveCounter(() => 8);
|
||||
const keys: string[] = [];
|
||||
let granted = 0;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const k = `g${i}`;
|
||||
if (requestAppSlot(k, i, false)) { granted++; keys.push(k); }
|
||||
}
|
||||
assert.equal(granted, 2, `apps must stop at the global ceiling, granted ${granted}`);
|
||||
assert.equal(totalLiveGuests(), 10);
|
||||
wireBrowserLiveCounter(() => 0);
|
||||
keys.forEach(releaseAppSlot);
|
||||
});
|
||||
|
||||
test('pinned cards ignore the global ceiling, a working agent is never throttled', () => {
|
||||
wireBrowserLiveCounter(() => 99);
|
||||
assert.equal(requestAppSlot('pinned-work', 0, true), true);
|
||||
wireBrowserLiveCounter(() => 0);
|
||||
releaseAppSlot('pinned-work');
|
||||
});
|
||||
|
||||
@@ -10,6 +10,29 @@
|
||||
*/
|
||||
export const MAX_LIVE_APP_WEBVIEWS = 6;
|
||||
|
||||
// The GLOBAL ceiling across every guest renderer, browsers and apps together. Each side already had
|
||||
// its own cap (browsers 8, apps 6) but neither knew the other existed, so a busy canvas could still
|
||||
// stack 14 live renderers, and renderer memory pressure is exactly what evicts the wash (the
|
||||
// background band) and, at the limit, what OOMs the app. Pinned/working cards stay exempt: a
|
||||
// throttle must never blind an agent mid-task.
|
||||
export const MAX_LIVE_GUESTS = 10;
|
||||
|
||||
// Browsers live in redux, not in this map; the suspend hook wires in a counter so this module stays
|
||||
// store-free (and its tests stay pure).
|
||||
let p_browserLiveCount: () => number = () => 0;
|
||||
|
||||
export function wireBrowserLiveCounter(fn: () => number): void {
|
||||
p_browserLiveCount = fn;
|
||||
}
|
||||
|
||||
export function totalLiveGuests(): number {
|
||||
return live.size + p_browserLiveCount();
|
||||
}
|
||||
|
||||
export function guestBudgetHasRoom(): boolean {
|
||||
return totalLiveGuests() < MAX_LIVE_GUESTS;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
priority: number; // squared distance from viewport center; smaller = closer = kept when slots are scarce
|
||||
pinned: boolean; // actively used: never counted against the cap, never evicted
|
||||
@@ -43,7 +66,7 @@ export function requestAppSlot(key: string, priority: number, pinned: boolean):
|
||||
existing.pinned = pinned;
|
||||
return true;
|
||||
}
|
||||
if (pinned || evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS) {
|
||||
if (pinned || (evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS && guestBudgetHasRoom())) {
|
||||
live.set(key, { priority, pinned });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Run: node --test frontend/src/shared/styles/washBackground.test.ts
|
||||
*
|
||||
* The wash is the app's biggest evictable GPU texture, and every case here is about NOT allocating
|
||||
* one we don't need. Chromium can drop a texture's tiles under memory pressure (many webviews, an
|
||||
* external display) and paints the element's background-color in their place, which is the
|
||||
* hard-edged rectangle of flat tint users report. A background-color is a compositor solid-colour
|
||||
* quad and can never be evicted, so when the wash is one flat colour the image must not exist at all.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { washIsUniform, washBackgroundLayers, washUnderlayColor, washOpaqueBackgroundUrl, DEFAULT_WASH_STOPS, effectiveWashStops } from './washBackground.ts';
|
||||
|
||||
const PAGE = '#F5F4ED';
|
||||
|
||||
test('a single accent is uniform, so it needs no image', () => {
|
||||
assert.equal(washIsUniform(['#B7CDEA']), true);
|
||||
assert.equal(washBackgroundLayers(['#B7CDEA'], 0.17, PAGE, null), null);
|
||||
});
|
||||
|
||||
test('repeated identical stops are uniform too (the boot-paint shape)', () => {
|
||||
assert.equal(washIsUniform(['#B7CDEA', '#B7CDEA']), true);
|
||||
assert.equal(washIsUniform(['#b7cdea', '#B7CDEA']), true, 'hex case must not decide this');
|
||||
});
|
||||
|
||||
test('a real multi-stop gradient is NOT uniform and still paints', () => {
|
||||
const stops = ['#B7CDEA', '#EFE0D2', '#E7BDD1'];
|
||||
assert.equal(washIsUniform(stops), false);
|
||||
const layers = washBackgroundLayers(stops, 0.17, PAGE, null);
|
||||
assert.ok(layers && layers.image.includes('linear-gradient'));
|
||||
assert.equal(layers!.size, '100% 100%');
|
||||
});
|
||||
|
||||
test('for a uniform wash the tint IS the colour, so dropping the image changes no pixel', () => {
|
||||
// The whole safety argument for skipping the image rests on these two being the same colour, so
|
||||
// compare the numbers rather than the spelling (#eaedec vs rgba(234, 237, 236, 1)).
|
||||
for (const accent of ['#B7CDEA', '#E7BDD1', '#3D3D3A', '#FFFFFF']) {
|
||||
const tint = washUnderlayColor([accent], 0.17, PAGE);
|
||||
const rgb = washOpaqueBackgroundUrl([accent], 0.17, PAGE).match(/\d+/g)!.slice(1, 4).map(Number);
|
||||
const hex = [1, 3, 5].map((i) => parseInt(tint.slice(i, i + 2), 16));
|
||||
assert.deepEqual(rgb, hex, `${accent}: image paints ${rgb}, background-color is ${hex}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('grain alone still paints when the wash is uniform', () => {
|
||||
const layers = washBackgroundLayers(['#B7CDEA'], 0.17, PAGE, 'url(grain)');
|
||||
assert.deepEqual(layers, { image: 'url(grain)', size: 'auto', repeat: 'repeat' });
|
||||
});
|
||||
|
||||
test('grain stacks above the gradient, in that order', () => {
|
||||
const layers = washBackgroundLayers(['#B7CDEA', '#E7BDD1'], 0.17, PAGE, 'url(grain)');
|
||||
assert.ok(layers!.image.startsWith('url(grain), '), 'grain must be the top layer');
|
||||
assert.equal(layers!.size, 'auto, 100% 100%');
|
||||
assert.equal(layers!.repeat, 'repeat, no-repeat');
|
||||
});
|
||||
|
||||
test('no stops and no grain means no background image at all', () => {
|
||||
assert.equal(washBackgroundLayers([], 0.17, PAGE, null), null);
|
||||
assert.equal(washIsUniform([]), true);
|
||||
});
|
||||
|
||||
test('the STOCK theme is uniform, so a default install cannot tear', () => {
|
||||
// Everyone who never opened the theme pad lands here. Making this multi-stop again would put a
|
||||
// full-window texture back under every default install, which is the band, so it is asserted.
|
||||
assert.equal(washIsUniform(DEFAULT_WASH_STOPS), true, 'default wash must stay one flat colour');
|
||||
assert.equal(washBackgroundLayers(DEFAULT_WASH_STOPS, 0.17, PAGE, null), null);
|
||||
assert.equal(washIsUniform(effectiveWashStops(null, null)), true, 'no accent, no gradient');
|
||||
assert.equal(washIsUniform(effectiveWashStops(null, '#B7CDEA')), true, 'a picked accent is one stop');
|
||||
});
|
||||
|
||||
test('a user who picks a real gradient still gets one, texture and all', () => {
|
||||
const chosen = ['#B7CDEA', '#E7BDD1'];
|
||||
assert.equal(washIsUniform(effectiveWashStops(chosen, null)), false);
|
||||
assert.ok(washBackgroundLayers(chosen, 0.17, PAGE, null));
|
||||
});
|
||||
@@ -1,16 +1,22 @@
|
||||
// Theme wash as an SVG IMAGE, not a CSS linear-gradient: Chromium caches a decoded image as a GPU
|
||||
// texture, while a window-sized procedural gradient re-rasterizes on resize and, under GPU memory
|
||||
// pressure (webviews, external monitors), those rasters get DROPPED and paint as a half/blank
|
||||
// rectangle (the same class as the 1.5.9 dot-grid white-patch bug; see DashboardCanvas's grid note).
|
||||
// The theme wash. Anything painted here is a full-window layer, so it is the app's single biggest
|
||||
// piece of evictable GPU texture: keep it as cheap as the theme allows (see washIsUniform).
|
||||
export function washBackgroundUrl(stops: string[], washOpacity: number): string {
|
||||
const alpha = Math.max(0, Math.min(1, washOpacity));
|
||||
// A native CSS gradient, not an SVG data-URL. The data-URL version was a decoded IMAGE resource:
|
||||
// Chromium can evict its tiles under GPU memory pressure (many webviews, external displays) and
|
||||
// paints the layer's background-color there instead, which is the hard-edged band users report.
|
||||
// A gradient is a paint op on the layer itself, so there is no separate texture to drop, and it
|
||||
// also stops shipping a ~119KB data-URL string on every theme render.
|
||||
const stopEls = stops.map((hex, i) => {
|
||||
const offset = stops.length > 1 ? (i / (stops.length - 1)) * 100 : 100;
|
||||
return `<stop offset='${offset}%' stop-color='${hex}' stop-opacity='${alpha}'/>`;
|
||||
}).join('');
|
||||
// x2/y2 approximate the CSS 115deg direction (25 degrees below horizontal).
|
||||
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' preserveAspectRatio='none'><defs><linearGradient id='w' x1='0%' y1='0%' x2='90%' y2='42%'>${stopEls}</linearGradient></defs><rect width='100' height='100' fill='url(%23w)'/></svg>`;
|
||||
return `url("data:image/svg+xml,${svg.replace(/#/g, '%23').replace(/'/g, '%27')}")`;
|
||||
return `${p_rgba(hex, alpha)} ${offset}%`;
|
||||
}).join(', ');
|
||||
return `linear-gradient(115deg, ${stopEls})`;
|
||||
}
|
||||
|
||||
function p_rgba(hex: string, alpha: number): string {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return `rgba(${(n >> 16) & 0xff}, ${(n >> 8) & 0xff}, ${n & 0xff}, ${alpha})`;
|
||||
}
|
||||
|
||||
function mixHex(a: string, b: string, t: number): string {
|
||||
@@ -27,6 +33,44 @@ export function washOpaqueBackgroundUrl(stops: string[], washOpacity: number, pa
|
||||
return washBackgroundUrl(blended, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the wash is one flat colour, so painting it as an image would be pure waste.
|
||||
*
|
||||
* A single-accent theme (the common case) resolves to `linear-gradient(115deg, C 100%)` while the
|
||||
* element's background-color is already exactly C, measured delta 0/255. That redundant image still
|
||||
* costs a full-window texture, and a texture is the only thing Chromium can EVICT: dropping its
|
||||
* tiles is what paints the hard-edged rectangle of flat tint people report. A background-color is a
|
||||
* compositor solid-colour quad, which can never be evicted, so skipping the image doesn't just save
|
||||
* memory, it makes the band unrepresentable for these themes.
|
||||
*/
|
||||
export function washIsUniform(stops: string[]): boolean {
|
||||
return stops.length < 2 || stops.every((s) => s.toLowerCase() === stops[0].toLowerCase());
|
||||
}
|
||||
|
||||
export interface WashLayers {
|
||||
image: string;
|
||||
size: string;
|
||||
repeat: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The background layers a full-window wash surface should paint, or null for "colour is enough".
|
||||
*
|
||||
* Both painters (the shell and the canvas viewport) need the identical stack, and getting it wrong
|
||||
* is what brings the band back, so it is derived once here rather than re-spelled at each site.
|
||||
*/
|
||||
export function washBackgroundLayers(
|
||||
stops: string[], washOpacity: number, pageBg: string, grainUrl: string | null,
|
||||
): WashLayers | null {
|
||||
const wash = stops.length > 0 && !washIsUniform(stops)
|
||||
? washOpaqueBackgroundUrl(stops, washOpacity, pageBg)
|
||||
: '';
|
||||
if (!wash && !grainUrl) return null;
|
||||
if (!wash) return { image: grainUrl as string, size: 'auto', repeat: 'repeat' };
|
||||
if (!grainUrl) return { image: wash, size: '100% 100%', repeat: 'no-repeat' };
|
||||
return { image: `${grainUrl}, ${wash}`, size: 'auto, 100% 100%', repeat: 'repeat, no-repeat' };
|
||||
}
|
||||
|
||||
// What an evicted/unrastered wash tile should paint as: the wash's mean tint, never raw page color.
|
||||
export function washUnderlayColor(stops: string[], washOpacity: number, pageBg: string): string {
|
||||
const alpha = Math.max(0, Math.min(1, washOpacity));
|
||||
@@ -35,9 +79,13 @@ export function washUnderlayColor(stops: string[], washOpacity: number, pageBg:
|
||||
return mixHex(pageBg, mean, alpha);
|
||||
}
|
||||
|
||||
// Stock wallpaper when the user hasn't picked an accent yet: a designed blue-to-cream-to-pink
|
||||
// gradient (contrasting stops), so a fresh install and the onboarding stage never look flat/white.
|
||||
export const DEFAULT_WASH_STOPS = ['#B7CDEA', '#EFE0D2', '#E7BDD1'];
|
||||
// Stock wallpaper when the user hasn't picked an accent yet. ONE stop on purpose: a multi-stop
|
||||
// default needs a full-window texture, and Chromium fills any tile it drops with the layer's single
|
||||
// background colour, which is why the gradient used to tear into a hard-edged rectangle under GPU
|
||||
// pressure. This is the mean of the old blue-cream-pink trio, i.e. exactly the colour those torn
|
||||
// tiles already painted, so the stock look is now what the worst case used to be. Picking any accent
|
||||
// is also one stop; only a user-chosen gradient opts back into the texture.
|
||||
export const DEFAULT_WASH_STOPS = ['#DACEDA'];
|
||||
|
||||
export function effectiveWashStops(gradient: string[] | null, accent: string | null): string[] {
|
||||
if (gradient && gradient.length > 0) return gradient;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Silent-failure UX sensors: rage clicks (the user telling us a button did nothing) and renderer
|
||||
// wedge recoveries. Threshold-emission only; nothing here runs per-frame or per-render.
|
||||
import { report } from '@/shared/serviceClient';
|
||||
|
||||
const RAGE_COUNT = 3;
|
||||
const RAGE_WINDOW_MS = 2000;
|
||||
const RAGE_THROTTLE_MS = 60_000;
|
||||
|
||||
let p_lastTarget: EventTarget | null = null;
|
||||
let p_clickTimes: number[] = [];
|
||||
let p_lastRageReport = 0;
|
||||
|
||||
function describeTarget(el: Element | null): string {
|
||||
if (!el) return 'unknown';
|
||||
const sel = el.closest('[data-select-type]');
|
||||
if (sel) return sel.getAttribute('data-select-type') || 'card';
|
||||
const btn = el.closest('button, [role="button"]');
|
||||
if (btn) return (btn.getAttribute('aria-label') || btn.textContent || 'button').trim().slice(0, 40);
|
||||
return el.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
// A main-thread task this long IS the freeze the user felt; measured live at 40,001ms in a forced
|
||||
// wedge where Chromium's own `unresponsive` never fired, so this is the primary wedge sensor.
|
||||
const WEDGE_MS = 3000;
|
||||
const WEDGE_THROTTLE_MS = 60_000;
|
||||
let p_lastWedgeReport = 0;
|
||||
|
||||
function installWedgeObserver(): () => void {
|
||||
if (typeof PerformanceObserver === 'undefined') return () => {};
|
||||
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) return () => {};
|
||||
const obs = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
const now = Date.now();
|
||||
if (entry.duration >= WEDGE_MS && now - p_lastWedgeReport > WEDGE_THROTTLE_MS) {
|
||||
p_lastWedgeReport = now;
|
||||
report('process', 'wedge_recovered', { wedge_ms: Math.round(entry.duration), source: 'longtask' });
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe({ entryTypes: ['longtask'] });
|
||||
return () => obs.disconnect();
|
||||
}
|
||||
|
||||
export function installUxSignals(): () => void {
|
||||
const onClick = (e: MouseEvent): void => {
|
||||
const now = Date.now();
|
||||
if (e.target !== p_lastTarget) {
|
||||
p_lastTarget = e.target;
|
||||
p_clickTimes = [now];
|
||||
return;
|
||||
}
|
||||
p_clickTimes = [...p_clickTimes.filter((t) => now - t < RAGE_WINDOW_MS), now];
|
||||
if (p_clickTimes.length >= RAGE_COUNT && now - p_lastRageReport > RAGE_THROTTLE_MS) {
|
||||
p_lastRageReport = now;
|
||||
report('ux', 'rage_click', {
|
||||
target: describeTarget(e.target as Element | null),
|
||||
clicks: p_clickTimes.length,
|
||||
});
|
||||
p_clickTimes = [];
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', onClick, true);
|
||||
const bridge = window as unknown as { openswarm?: { onWedge?: (cb: (info: { ms: number }) => void) => () => void } };
|
||||
// Chromium's own hang signal stays wired as a second, independent witness (it costs nothing and
|
||||
// catches hangs the observer can't see, e.g. a renderer stuck outside a task).
|
||||
const offWedge = bridge.openswarm?.onWedge?.((info) => {
|
||||
report('process', 'wedge_recovered', { wedge_ms: info?.ms ?? -1, source: 'chromium' });
|
||||
});
|
||||
const offObserver = installWedgeObserver();
|
||||
const offMem = (bridge.openswarm as { onMemoryAlert?: (cb: (i: Record<string, number | string>) => void) => () => void } | undefined)?.onMemoryAlert?.((info) => {
|
||||
report('process', 'memory_alert', info);
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener('click', onClick, true);
|
||||
offWedge?.();
|
||||
offObserver();
|
||||
offMem?.();
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useVoiceDictation } from './useVoiceDictation';
|
||||
import { playVoiceCue, configureVoiceCues } from './voiceCues';
|
||||
import { setManualDictionary } from './voiceDictionary';
|
||||
import { VoiceContext } from './voiceContext';
|
||||
import { injectAtFocus, snapshotInjectTarget } from './injectAtFocus';
|
||||
import VoiceOverlay from './VoiceOverlay';
|
||||
|
||||
// One recorder for the whole app. Both mics (the Help pill and the spawn composer) plus the global
|
||||
@@ -103,6 +104,20 @@ export function VoiceDictationProvider({ children }: { children: React.ReactNode
|
||||
return () => { offHold?.(); };
|
||||
}, [pressStart, pressEnd]);
|
||||
|
||||
// Dev-only seam so the focus-drift guarantee (ENG-176) can be proven against the REAL module in
|
||||
// the running app, not a unit stand-in. Compiled out of production builds.
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'production') return undefined;
|
||||
const onSnap = (): void => snapshotInjectTarget();
|
||||
const onInject = (e: Event): void => { injectAtFocus(((e as CustomEvent<{ text?: string }>).detail?.text) ?? ''); };
|
||||
window.addEventListener('osw-test:snapshot', onSnap);
|
||||
window.addEventListener('osw-test:inject', onInject as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('osw-test:snapshot', onSnap);
|
||||
window.removeEventListener('osw-test:inject', onInject as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Wispr grammar: Esc while the mic is hot throws the take away.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { takeInjectSnapshot, setInjectSnapshot, isUsableTarget } from './injectTargetSnapshot';
|
||||
|
||||
// Dictation lands where the user's cursor actually is, like every real dictation tool: a focused
|
||||
// in-app field gets the text typed in (undo-friendly, fires React input events), a focused browser
|
||||
@@ -7,7 +8,14 @@ import { getWebview } from '@/shared/browserRegistry';
|
||||
export type InjectTarget = 'field' | 'webview' | 'composer' | null;
|
||||
|
||||
export function injectAtFocus(text: string): InjectTarget {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const snap = takeInjectSnapshot();
|
||||
// The cursor wins, not where you started. Wispr's grammar, and Eric's call: you dictate, you click
|
||||
// where you want it, it lands there. This deliberately reverts the snapshot-first version, which
|
||||
// pinned the text to the origin field and dropped it outright when that field went away.
|
||||
// The snapshot is still the fallback for the case it was really built for: focus drifting to
|
||||
// nothing typeable (a button, the body) while you were talking.
|
||||
const live = document.activeElement as HTMLElement | null;
|
||||
const active = isUsableTarget(live) ? live : snap.el;
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||
try {
|
||||
active.focus();
|
||||
@@ -33,7 +41,7 @@ export function injectAtFocus(text: string): InjectTarget {
|
||||
try { void focusedTag.insertText(text); return 'webview'; } catch { /* fall through */ }
|
||||
}
|
||||
// Last-interacted browser card: the user clicked a page field, then hit the hotkey.
|
||||
const browserId = getLastInteractedBrowser();
|
||||
const browserId = snap.browserId || getLastInteractedBrowser();
|
||||
if (browserId) {
|
||||
const wv = getWebview(browserId) as unknown as { insertText?: (t: string) => Promise<void>; focus?: () => void } | undefined;
|
||||
if (wv?.insertText) {
|
||||
@@ -44,3 +52,14 @@ export function injectAtFocus(text: string): InjectTarget {
|
||||
window.dispatchEvent(new CustomEvent('openswarm:dictation-fallback', { detail: { text } }));
|
||||
return 'composer';
|
||||
}
|
||||
|
||||
/** Called at press-start so the words land where the user was looking, not where focus drifted. */
|
||||
export function snapshotInjectTarget(): void {
|
||||
// Only a typeable element counts as "aimed at". document.activeElement is <body> when nothing is
|
||||
// focused, and storing that would read as a lost target later and swallow the composer fallback.
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
setInjectSnapshot({
|
||||
el: isUsableTarget(active) ? active : null,
|
||||
browserId: getLastInteractedBrowser(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// ENG-176: the transcript belongs to the field the user was in when they STARTED speaking.
|
||||
// Run: node --test --experimental-strip-types frontend/src/shared/voice/injectTargetSnapshot.test.ts
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setInjectSnapshot, clearInjectSnapshot, takeInjectSnapshot, isUsableTarget } from './injectTargetSnapshot.ts';
|
||||
|
||||
const field = (connected = true) => ({ tagName: 'TEXTAREA', isConnected: connected, isContentEditable: false }) as unknown as HTMLElement;
|
||||
|
||||
beforeEach(() => clearInjectSnapshot());
|
||||
|
||||
test('the snapshot keeps the field it was given (injectAtFocus decides precedence, not this)', () => {
|
||||
const a = field();
|
||||
setInjectSnapshot({ el: a, browserId: null });
|
||||
assert.equal(takeInjectSnapshot().el, a);
|
||||
});
|
||||
|
||||
test('a detached field is refused, so a dead origin can never be the destination', () => {
|
||||
setInjectSnapshot({ el: field(false), browserId: null });
|
||||
assert.equal(takeInjectSnapshot().el, null);
|
||||
});
|
||||
|
||||
test('a non-typeable element never wins', () => {
|
||||
assert.equal(isUsableTarget({ tagName: 'DIV', isConnected: true, isContentEditable: false } as unknown as HTMLElement), false);
|
||||
assert.equal(isUsableTarget({ tagName: 'DIV', isConnected: true, isContentEditable: true } as unknown as HTMLElement), true);
|
||||
assert.equal(isUsableTarget({ tagName: 'WEBVIEW', isConnected: true, isContentEditable: false } as unknown as HTMLElement), true);
|
||||
});
|
||||
|
||||
test('taking consumes it, so one take can never leak into the next', () => {
|
||||
setInjectSnapshot({ el: field(), browserId: 'b1' });
|
||||
assert.equal(takeInjectSnapshot().browserId, 'b1');
|
||||
assert.equal(takeInjectSnapshot().el, null);
|
||||
assert.equal(takeInjectSnapshot().browserId, null);
|
||||
});
|
||||
|
||||
test('a cancelled take leaves nothing behind', () => {
|
||||
setInjectSnapshot({ el: field(), browserId: 'b2' });
|
||||
clearInjectSnapshot();
|
||||
assert.equal(takeInjectSnapshot().el, null);
|
||||
});
|
||||
|
||||
// Precedence lives in injectAtFocus, and Eric's call is Wispr's: the CURSOR wins, not the origin.
|
||||
// injectAtFocus needs a live DOM, so what is pinned here is the predicate that decides whether the
|
||||
// live element is allowed to win at all. Getting this wrong is how the text lands in a stranger's box.
|
||||
test('a live click target only beats the origin when it is really typeable', () => {
|
||||
const typeable = { tagName: 'INPUT', isConnected: true, isContentEditable: false } as unknown as HTMLElement;
|
||||
const button = { tagName: 'BUTTON', isConnected: true, isContentEditable: false } as unknown as HTMLElement;
|
||||
const body = { tagName: 'BODY', isConnected: true, isContentEditable: false } as unknown as HTMLElement;
|
||||
assert.equal(isUsableTarget(typeable), true, 'clicking another field must take the text');
|
||||
assert.equal(isUsableTarget(button), false, 'clicking a button must NOT take the text');
|
||||
assert.equal(isUsableTarget(body), false, 'clicking empty space must NOT take the text');
|
||||
assert.equal(isUsableTarget(null), false);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Focus drifts during the seconds of decode + polish (click another field, another app), so the
|
||||
// dictation destination is snapshotted when the user starts speaking and preferred at paste time.
|
||||
export interface InjectSnapshot {
|
||||
el: HTMLElement | null;
|
||||
browserId: string | null;
|
||||
}
|
||||
|
||||
let p_snapshot: InjectSnapshot | null = null;
|
||||
|
||||
export function setInjectSnapshot(snap: InjectSnapshot): void {
|
||||
p_snapshot = snap;
|
||||
}
|
||||
|
||||
export function clearInjectSnapshot(): void {
|
||||
p_snapshot = null;
|
||||
}
|
||||
|
||||
/** A snapshot is only worth honoring while its element is still attached and still typeable. */
|
||||
export function isUsableTarget(el: HTMLElement | null): boolean {
|
||||
if (!el || !el.isConnected) return false;
|
||||
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'WEBVIEW' || el.isContentEditable;
|
||||
}
|
||||
|
||||
export interface TakenSnapshot extends InjectSnapshot {
|
||||
/** We aimed at a real field and it died mid-decode. Distinct from never having aimed anywhere. */
|
||||
targetLost: boolean;
|
||||
}
|
||||
|
||||
/** Consumes the snapshot: reading it once is the whole contract, so a stale one can never linger. */
|
||||
export function takeInjectSnapshot(): TakenSnapshot {
|
||||
const snap = p_snapshot;
|
||||
p_snapshot = null;
|
||||
if (!snap) return { el: null, browserId: null, targetLost: false };
|
||||
const usable = isUsableTarget(snap.el);
|
||||
return { el: usable ? snap.el : null, browserId: snap.browserId, targetLost: !!snap.el && !usable };
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { API_BASE } from '@/shared/config';
|
||||
import { getLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import { encodeWav, VOICE_SAMPLE_RATE } from './encodeWav';
|
||||
import { playVoiceCue } from './voiceCues';
|
||||
import { injectAtFocus } from './injectAtFocus';
|
||||
import { injectAtFocus, snapshotInjectTarget } from './injectAtFocus';
|
||||
import { clearInjectSnapshot } from './injectTargetSnapshot';
|
||||
import { createSilenceDetector } from './createSilenceDetector';
|
||||
import { pushDictation } from './voiceHistory';
|
||||
import { learnFromTranscript, isDictionaryEcho } from './voiceDictionary';
|
||||
@@ -158,6 +159,8 @@ export function useVoiceDictation() {
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
// Where the words belong is decided NOW, while the user is looking at it, not seconds later.
|
||||
snapshotInjectTarget();
|
||||
// Warm on the DOWN edge, before the mic prompt, so the model load overlaps the user starting to speak.
|
||||
void window.openswarm?.voiceWarmup?.();
|
||||
setPartial(null);
|
||||
@@ -328,6 +331,7 @@ export function useVoiceDictation() {
|
||||
// The capsule's X: throw the take away. No transcription, no cue, straight back to idle.
|
||||
const cancel = useCallback((): void => {
|
||||
if (stateRef.current !== 'recording') return;
|
||||
clearInjectSnapshot();
|
||||
const rec = recRef.current;
|
||||
if (rec?.streaming) window.openswarm?.voiceStreamCancel?.();
|
||||
teardown();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user