6ede33ccdb
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled
Obsidian Plugin Lint / lint (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled
164 lines
5.6 KiB
Python
164 lines
5.6 KiB
Python
"""Live progress plumbing shared by every capability run.
|
|
|
|
A single module-level :func:`emit_progress` lets the proprietary scraper code
|
|
report what it is doing without knowing *who* is listening. The active listener
|
|
is held in a :class:`contextvars.ContextVar`, so:
|
|
|
|
* the REST async door sets a reporter bound to the run's bus channel — every
|
|
event is streamed live over SSE and coarse ones are buffered for persistence;
|
|
* the REST sync door and the agent door set a buffer-only reporter — coarse
|
|
events still land in ``runs.progress`` and (in a chat/graph context) surface
|
|
as ``scraper_progress`` custom events on the active thinking step;
|
|
* outside any run (unit tests calling a scraper directly) the var is unset and
|
|
:func:`emit_progress` is a **no-op**, so scraper code can call it freely.
|
|
|
|
The event shape is one flexible dict, not a class hierarchy::
|
|
|
|
{"type": "run.progress", "ts": <epoch ms>, "phase": str,
|
|
"message"?: str, "current"?: int, "total"?: int, "unit"?: str,
|
|
"detail"?: {...}}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import time
|
|
from contextvars import ContextVar
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
from app.capabilities.core.events import RunEventBus
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Coarse counters are persisted/chat-surfaced at most once per this window per
|
|
# (phase, unit) key; the raw fine-grained stream is unthrottled on the bus.
|
|
_COUNTER_THROTTLE_MS = 1000
|
|
# ponytail: bound the persisted coarse log so a runaway scrape can't write an
|
|
# unbounded JSONB blob. Upgrade path: page the log into its own table.
|
|
_MAX_COARSE_EVENTS = 1000
|
|
|
|
|
|
class ProgressReporter:
|
|
"""Sink for one run's progress events.
|
|
|
|
Publishes every event to the bus (live SSE tail) and keeps a throttled,
|
|
bounded list of *coarse* events for persistence + chat surfacing.
|
|
"""
|
|
|
|
def __init__(
|
|
self, *, run_id: str | None = None, bus: RunEventBus | None = None
|
|
) -> None:
|
|
self.run_id = run_id
|
|
self.bus = bus
|
|
self.coarse: list[dict[str, Any]] = []
|
|
self._last_phase: str | None = None
|
|
self._last_counter_ts: dict[tuple[str, str | None], int] = {}
|
|
|
|
def handle(self, event: dict[str, Any]) -> None:
|
|
# Live tail: every event, unthrottled (this is the "stream everything").
|
|
if self.bus is not None and self.run_id is not None:
|
|
self.bus.publish(self.run_id, event)
|
|
|
|
if not self._is_coarse(event):
|
|
return
|
|
|
|
if len(self.coarse) < _MAX_COARSE_EVENTS:
|
|
self.coarse.append(event)
|
|
# Chat surface: coarse events become thinking-step items when a LangGraph
|
|
# run context is active (agent door). No-op elsewhere.
|
|
_dispatch_chat_event(event)
|
|
|
|
def _is_coarse(self, event: dict[str, Any]) -> bool:
|
|
phase = event.get("phase", "")
|
|
if phase != self._last_phase:
|
|
self._last_phase = phase
|
|
self._last_counter_ts.clear()
|
|
return True
|
|
if "current" not in event:
|
|
return True
|
|
key = (phase, event.get("unit"))
|
|
now = int(event.get("ts", 0))
|
|
last = self._last_counter_ts.get(key, 0)
|
|
if now - last >= _COUNTER_THROTTLE_MS:
|
|
self._last_counter_ts[key] = now
|
|
return True
|
|
return False
|
|
|
|
|
|
_active_reporter: ContextVar[ProgressReporter | None] = ContextVar(
|
|
"active_progress_reporter", default=None
|
|
)
|
|
|
|
|
|
def emit_progress(
|
|
phase: str,
|
|
message: str | None = None,
|
|
*,
|
|
current: int | None = None,
|
|
total: int | None = None,
|
|
unit: str | None = None,
|
|
**detail: Any,
|
|
) -> None:
|
|
"""Report a progress event for the active run; a no-op when none is active.
|
|
|
|
Safe to call from anywhere in the scraper code path — never raises into the
|
|
caller.
|
|
"""
|
|
reporter = _active_reporter.get()
|
|
if reporter is None:
|
|
return
|
|
try:
|
|
event: dict[str, Any] = {
|
|
"type": "run.progress",
|
|
"ts": int(time.time() * 1000),
|
|
"phase": phase,
|
|
}
|
|
if message is not None:
|
|
event["message"] = message
|
|
if current is not None:
|
|
event["current"] = current
|
|
if total is not None:
|
|
event["total"] = total
|
|
if unit is not None:
|
|
event["unit"] = unit
|
|
if detail:
|
|
event["detail"] = detail
|
|
reporter.handle(event)
|
|
except Exception: # pragma: no cover - progress must never break a scrape
|
|
logger.debug("emit_progress failed; suppressed", exc_info=True)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def progress_scope(
|
|
*, run_id: str | None = None, bus: RunEventBus | None = None
|
|
) -> Iterator[ProgressReporter]:
|
|
"""Bind a :class:`ProgressReporter` for the duration of a run.
|
|
|
|
The contextvar set here propagates to every coroutine awaited inside the
|
|
``with`` block (same task), so deep scraper code sees it without wiring.
|
|
"""
|
|
reporter = ProgressReporter(run_id=run_id, bus=bus)
|
|
token = _active_reporter.set(reporter)
|
|
try:
|
|
yield reporter
|
|
finally:
|
|
_active_reporter.reset(token)
|
|
|
|
|
|
def _dispatch_chat_event(event: dict[str, Any]) -> None:
|
|
"""Best-effort forward to the chat SSE relay via a LangGraph custom event.
|
|
|
|
Only meaningful inside an agent tool call (there is a run context); raises
|
|
otherwise, which we swallow — mirrors ``retry_after``'s guarded dispatch.
|
|
"""
|
|
try:
|
|
from langchain_core.callbacks import dispatch_custom_event
|
|
|
|
dispatch_custom_event("scraper_progress", event)
|
|
except Exception:
|
|
logger.debug("scraper_progress dispatch skipped (no run context)")
|