From edef4055a1e01e132ff5f7352bccbace24d4a56d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Thu, 16 Jul 2026 21:53:50 +0800 Subject: [PATCH 01/25] feat(dashboard): general monitoring web dashboard (Watch->Finding + SDUI) Runtime: domain-neutral Watch->Finding contract, FindingStore, MonitorManager; leapd-hosted scheduler with NotificationBus push; client-coupled watches excluded from idle keep-alive. RPC: watch.* and session.* on protocol/service/client; session transcript + LLM analysis facade. Dashboard: SDUI (ViewSpec catalog + validation + YAML templates + bidirectional action protocol + Custom escape hatch), aiohttp view server (optional dep, graceful degrade), ViewHub WS fan-out, launcher + 'leap dashboard', /dashboard slash namespace, TUI finding cards + status-bar counts, webbrowser auto-open. Session-analysis dashboard (domain=session) with batch/model-salience/manual refresh (salience opt-in), session-scoped finding dedup. Domain templates (finance/sentiment/research) + overview; config dashboard.* and monitor.session_* via ConfigService. Tests: 7 hermetic suites; full suite 811 passed, 1 skipped. --- pyproject.toml | 3 + src/leapflow/cli/cli.py | 16 +- src/leapflow/cli/commands/dashboard.py | 74 ++++ src/leapflow/cli/commands/interactive.py | 46 +++ src/leapflow/cli/commands/registry.py | 14 + src/leapflow/cli/commands/slash_handlers.py | 253 +++++++++++++ src/leapflow/cli/tui_app/console.py | 37 ++ src/leapflow/cli/tui_app/status.py | 28 +- src/leapflow/config.py | 39 ++ src/leapflow/config_service.py | 9 +- src/leapflow/daemon/client.py | 50 +++ src/leapflow/daemon/protocol.py | 59 +++ src/leapflow/daemon/server.py | 17 +- src/leapflow/daemon/service.py | 245 ++++++++++++ src/leapflow/dashboard/__init__.py | 43 +++ src/leapflow/dashboard/hub.py | 64 ++++ src/leapflow/dashboard/intent.py | 115 ++++++ src/leapflow/dashboard/launcher.py | 178 +++++++++ src/leapflow/dashboard/server.py | 252 +++++++++++++ src/leapflow/dashboard/service.py | 118 ++++++ src/leapflow/dashboard/static/app.js | 193 ++++++++++ src/leapflow/dashboard/static/index.html | 22 ++ src/leapflow/dashboard/static/styles.css | 55 +++ src/leapflow/dashboard/templates.py | 203 ++++++++++ .../dashboard/templates/finance.market.yaml | 44 +++ src/leapflow/dashboard/templates/generic.yaml | 37 ++ .../dashboard/templates/overview.yaml | 44 +++ .../dashboard/templates/research.paper.yaml | 26 ++ .../dashboard/templates/sentiment.topic.yaml | 36 ++ .../dashboard/templates/session.analysis.yaml | 65 ++++ src/leapflow/dashboard/viewspec.py | 162 ++++++++ src/leapflow/monitor/__init__.py | 57 +++ src/leapflow/monitor/finding_store.py | 200 ++++++++++ src/leapflow/monitor/manager.py | 356 ++++++++++++++++++ src/leapflow/monitor/producers.py | 45 +++ src/leapflow/monitor/session_producer.py | 194 ++++++++++ src/leapflow/monitor/types.py | 335 ++++++++++++++++ tests/test_dashboard_domains.py | 83 ++++ tests/test_dashboard_launcher.py | 132 +++++++ tests/test_dashboard_sdui.py | 163 ++++++++ tests/test_dashboard_view.py | 148 ++++++++ tests/test_dashboard_watch_rpc.py | 102 +++++ tests/test_monitor_subsystem.py | 217 +++++++++++ tests/test_session_analysis.py | 176 +++++++++ 44 files changed, 4751 insertions(+), 4 deletions(-) create mode 100644 src/leapflow/cli/commands/dashboard.py create mode 100644 src/leapflow/dashboard/__init__.py create mode 100644 src/leapflow/dashboard/hub.py create mode 100644 src/leapflow/dashboard/intent.py create mode 100644 src/leapflow/dashboard/launcher.py create mode 100644 src/leapflow/dashboard/server.py create mode 100644 src/leapflow/dashboard/service.py create mode 100644 src/leapflow/dashboard/static/app.js create mode 100644 src/leapflow/dashboard/static/index.html create mode 100644 src/leapflow/dashboard/static/styles.css create mode 100644 src/leapflow/dashboard/templates.py create mode 100644 src/leapflow/dashboard/templates/finance.market.yaml create mode 100644 src/leapflow/dashboard/templates/generic.yaml create mode 100644 src/leapflow/dashboard/templates/overview.yaml create mode 100644 src/leapflow/dashboard/templates/research.paper.yaml create mode 100644 src/leapflow/dashboard/templates/sentiment.topic.yaml create mode 100644 src/leapflow/dashboard/templates/session.analysis.yaml create mode 100644 src/leapflow/dashboard/viewspec.py create mode 100644 src/leapflow/monitor/__init__.py create mode 100644 src/leapflow/monitor/finding_store.py create mode 100644 src/leapflow/monitor/manager.py create mode 100644 src/leapflow/monitor/producers.py create mode 100644 src/leapflow/monitor/session_producer.py create mode 100644 src/leapflow/monitor/types.py create mode 100644 tests/test_dashboard_domains.py create mode 100644 tests/test_dashboard_launcher.py create mode 100644 tests/test_dashboard_sdui.py create mode 100644 tests/test_dashboard_view.py create mode 100644 tests/test_dashboard_watch_rpc.py create mode 100644 tests/test_monitor_subsystem.py create mode 100644 tests/test_session_analysis.py diff --git a/pyproject.toml b/pyproject.toml index 1d7bc71..8268ba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dev = [ "ruff>=0.4.0", ] hub = ["modelscope-hub>=0.1.0"] +dashboard = ["aiohttp>=3.9"] [project.scripts] leap = "leapflow.__main__:main" @@ -56,6 +57,8 @@ include = ["leapflow*"] [tool.setuptools.package-data] "leapflow.gateway.action_packs" = ["*.yaml"] +"leapflow.dashboard.templates" = ["*.yaml"] +"leapflow.dashboard.static" = ["*"] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 28b11d4..3943fb6 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -255,6 +255,15 @@ def main(argv: list[str] | None = None) -> int: serve_parser = daemon_sub.add_parser("serve", help=argparse.SUPPRESS) serve_parser.add_argument("--internal", action="store_true", help=argparse.SUPPRESS) + # leap dashboard + dashboard_parser = subparsers.add_parser("dashboard", help="Open or serve the monitoring web dashboard") + dashboard_parser.add_argument("action", nargs="?", default="home", help="home | session | open") + dashboard_parser.add_argument("--serve", action="store_true", help=argparse.SUPPRESS) + dashboard_parser.add_argument("--token", default="", help=argparse.SUPPRESS) + dashboard_parser.add_argument("--port", type=int, default=0, help="Override the dashboard port") + dashboard_parser.add_argument("--bind", default="", help="Override the dashboard bind address") + dashboard_parser.add_argument("--no-open", action="store_true", help="Print the URL instead of opening a browser") + # leap config config_parser = subparsers.add_parser("config", help="View and update LeapFlow configuration") config_sub = config_parser.add_subparsers(dest="config_action") @@ -306,7 +315,7 @@ def main(argv: list[str] | None = None) -> int: # ── Pre-parse: detect if first non-flag arg is a known subcommand ── # If not, treat everything non-flag as a chat prompt. - known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config"} + known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config", "dashboard"} effective_argv = list(argv) if argv is not None else sys.argv[1:] # Find first non-flag argument, skipping values owned by global options. @@ -382,6 +391,11 @@ def main(argv: list[str] | None = None) -> int: from leapflow.cli.commands.daemon import cmd_daemon return cmd_daemon(args) + # Dashboard is a view client (connects to leapd); no Context initialization + if args.command == "dashboard": + from leapflow.cli.commands.dashboard import cmd_dashboard + return cmd_dashboard(args) + try: if args.command in {"interactive", "chat"} and _daemon_enabled(args): return asyncio.run(_async_daemon_main(args)) diff --git a/src/leapflow/cli/commands/dashboard.py b/src/leapflow/cli/commands/dashboard.py new file mode 100644 index 0000000..a77bfba --- /dev/null +++ b/src/leapflow/cli/commands/dashboard.py @@ -0,0 +1,74 @@ +"""`leap dashboard` — open or serve the monitoring web dashboard. + +Two modes: +- default (client): ensure a dashboard server is running, then open the default + browser at the requested view. Returns immediately; the server runs detached. +- ``--serve`` (server): run the aiohttp view server in the foreground. Used by + the launcher spawn and for a dedicated dashboard terminal. + +The dashboard is a view client: it connects to leapd, so it does not initialize +its own Context. +""" + +from __future__ import annotations + +import argparse +import asyncio + +from leapflow.config import load_config +from leapflow.dashboard import launcher + +_DEP_HINT = ( + "The dashboard web server requires the optional 'aiohttp' dependency.\n" + "Install it with: pip install 'leapflow[dashboard]'" +) + + +def cmd_dashboard(args: argparse.Namespace) -> int: + """Entry point for the ``leap dashboard`` subcommand.""" + settings = load_config() + + if not launcher.aiohttp_available(): + print(_DEP_HINT) + return 1 + + if getattr(args, "serve", False): + return _serve(args, settings) + return _open(args, settings) + + +def _serve(args: argparse.Namespace, settings: object) -> int: + from leapflow.dashboard.server import run_server + + token = getattr(args, "token", "") or launcher.generate_token() + bind = getattr(args, "bind", "") or settings.dashboard_bind + port = getattr(args, "port", 0) or settings.dashboard_port + try: + return asyncio.run(run_server(settings, token=token, bind=bind, port=port)) + except KeyboardInterrupt: + return 130 + + +def _open(args: argparse.Namespace, settings: object) -> int: + try: + state = launcher.ensure_server(settings) + except RuntimeError as exc: + print(f"dashboard: {exc}") + return 1 + + action = getattr(args, "action", None) or "home" + url = launcher.build_url(state["bind"], state["port"], state["token"]) + if action and action != "home": + url += f"&action={action}" + if action == "session": + url += "&target=session" + + auto_open = bool(getattr(settings, "dashboard_auto_open", True)) and not getattr(args, "no_open", False) + if auto_open and launcher.open_in_browser(url): + print(f"Opened dashboard in your browser: {url}") + else: + print(f"Dashboard ready: {url}") + return 0 + + +__all__ = ["cmd_dashboard"] diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 289dac8..915a765 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -1121,6 +1121,28 @@ async def _stream_response( # Session mode tracking for teaching — daemon-side state mirrored here daemon_session_mode = "idle" + async def _open_dashboard(payload: dict) -> None: + """Open the web dashboard on the user's machine (client-side).""" + from leapflow.dashboard import launcher + + action = str(payload.get("action") or "home") + url = payload.get("url") + if not url: + try: + state = await asyncio.to_thread(launcher.ensure_server, settings) + except Exception as exc: + console.warning(f"Could not start the dashboard server: {exc}") + return + url = launcher.build_url(state["bind"], state["port"], state["token"]) + if action != "home": + url += f"&action={action}" + if action == "session": + url += "&target=session" + if launcher.open_in_browser(url): + console.system(f"Opened dashboard in your browser: {url}") + else: + console.system(f"Dashboard ready (open manually): {url}") + async def handle_input(text: str) -> None: nonlocal daemon_session_mode @@ -1184,6 +1206,10 @@ async def handle_input(text: str) -> None: _apply_daemon_runtime_metadata({"host_backend": payload["result"]}) _update_status() + if str(payload.get("view")) == "dashboard" and payload.get("mode") == "open": + await _open_dashboard(payload) + return + # Render: app-specific views use app renderer, others use generic view = str(payload.get("view") or "") if view in ("list", "guide", "connect", "disconnect", "remove", "events", "actions") and "result" in payload: @@ -1342,11 +1368,22 @@ async def _notification_listener() -> None: """Subscribe to daemon notifications and render them in TUI.""" nonlocal daemon_session_mode from leapflow.daemon.client import DaemonUnavailableError + + async def _refresh_watch_count() -> None: + try: + watches = await bridge.client.watch_list() + except Exception: + return + active = sum(1 for w in watches if w.get("state") in ("armed", "watching")) + status.update_monitor_counts(watches=active) + app.invalidate() + while True: try: # Reset stale state on each (re)connection attempt status.distill_phase = "" status.distill_progress = 0.0 + await _refresh_watch_count() async for event in bridge.client.subscribe_notifications(): event_type = event.get("event_type", "") payload = event.get("payload") or {} @@ -1381,6 +1418,15 @@ async def _notification_listener() -> None: else: console.system("Distillation complete — no skills produced (insufficient signal)") app.invalidate() + elif event_type == "monitor.finding": + console.monitor_card(payload) + if str(payload.get("severity")) == "alert": + status.update_monitor_counts(alerts=status.alert_count + 1) + app.invalidate() + elif event_type == "watch.state": + await _refresh_watch_count() + elif event_type == "monitor.error": + logger.debug("monitor error notification: %s", payload) except (DaemonUnavailableError, OSError, asyncio.IncompleteReadError): await asyncio.sleep(3.0) except asyncio.CancelledError: diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index 130117d..cbf5a6f 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -132,6 +132,20 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: # Scheduler CommandDef("arm", "Schedule a skill for timed execution", "Scheduler", args_hint=" "), CommandDef("task", "List scheduled tasks", "Scheduler"), + + # Dashboard & Monitors + CommandDef("dashboard", "Manage monitoring dashboards and watches", "Dashboard", args_hint="[open|session|list|status|new|pause|resume|stop|mute|refresh|findings] ...", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("dashboard open", "Open the monitoring web dashboard", "Dashboard", args_hint="[watch-id]", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("dashboard session", "Open the current-session analysis dashboard", "Dashboard", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("dashboard list", "List active watches", "Dashboard"), + CommandDef("dashboard status", "Show monitoring runtime status", "Dashboard"), + CommandDef("dashboard new", "Create a watch for a domain", "Dashboard", args_hint=" [--name --trigger --sensitivity]", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("dashboard pause", "Pause a watch", "Dashboard", args_hint="", effect=CommandEffect.SESSION), + CommandDef("dashboard resume", "Resume a watch", "Dashboard", args_hint="", effect=CommandEffect.SESSION), + CommandDef("dashboard stop", "Stop a watch", "Dashboard", args_hint="", effect=CommandEffect.SESSION), + CommandDef("dashboard mute", "Mute or unmute a watch", "Dashboard", args_hint=" [on|off]", effect=CommandEffect.SESSION), + CommandDef("dashboard refresh", "Run a watch once now", "Dashboard", args_hint="", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("dashboard findings", "Show recent findings", "Dashboard", args_hint="[id] [--limit N]"), ) # ── Derived structures ─────────────────────────────────────────────── diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 6569fd1..e09a230 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -1112,9 +1112,175 @@ async def command_execute(ctx: "Context", name: str, args: str = "") -> dict[str return await _execute_scheduler_arm(ctx, args) if name == "task": return _execute_scheduler_task(ctx) + if name == "dashboard" or name.startswith("dashboard "): + return await _execute_dashboard(ctx, name, args) return {"ok": False, "message": f"Unknown command: /{name}"} +def _parse_watch_spec(tokens: list[str]) -> dict[str, Any]: + """Parse `` [--name N --trigger EXPR --sensitivity S]`` into a spec dict.""" + spec: dict[str, Any] = {"domain": tokens[0]} + flag_map = {"--name": "name", "--trigger": "trigger_expr", "--sensitivity": "sensitivity"} + i = 1 + while i < len(tokens): + key = flag_map.get(tokens[i]) + if key and i + 1 < len(tokens): + spec[key] = tokens[i + 1] + i += 2 + else: + i += 1 + spec.setdefault("name", spec["domain"]) + return spec + + +def _resolve_watch_id(monitors: Any, token: str) -> str: + """Accept a full watch id or a unique short prefix from the list view.""" + try: + views = monitors.list_watches() + except Exception: + return token + for view in views: + if view.watch_id == token: + return token + matches = [view.watch_id for view in views if view.watch_id.startswith(token)] + return matches[0] if len(matches) == 1 else token + + +async def _ensure_session_watch_refresh(ctx: "Context", monitors: Any) -> None: + """Ensure an active session watch exists and run one analysis cycle now.""" + from leapflow.monitor.session_producer import ensure_session_watch, session_watch_params + + watch_id = await ensure_session_watch( + monitors, params=session_watch_params(getattr(ctx, "settings", None)) + ) + await monitors.run_watch_once(watch_id, force=True) + + +async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[str, Any]: + """Manage monitor watches from the TUI (data operations; web open is separate).""" + monitors = getattr(ctx, "monitors", None) + sub = name[len("dashboard"):].strip() + rest = (sub + ((" " + args) if args else "")).strip() if sub else args.strip() + try: + tokens = shlex.split(rest) if rest else [] + except ValueError: + tokens = rest.split() + action = tokens[0].lower() if tokens else "open" + rest_tokens = tokens[1:] + + if monitors is None: + return {"ok": False, "message": "Monitor runtime is unavailable (scheduler disabled)."} + + if action in ("list", "ls"): + return {"ok": True, "view": "dashboard", "mode": "list", + "watches": [v.to_dict() for v in monitors.list_watches()]} + + if action == "status": + watches = monitors.list_watches() + by_state: dict[str, int] = {} + findings_total = 0 + for v in watches: + by_state[v.state] = by_state.get(v.state, 0) + 1 + findings_total += v.finding_count + return {"ok": True, "view": "dashboard", "mode": "status", + "count": len(watches), "by_state": by_state, "findings_total": findings_total, + "domains": sorted({v.domain for v in watches})} + + if action in ("new", "add", "arm"): + if not rest_tokens: + return {"ok": False, "message": "Usage: /dashboard new [--name N --trigger EXPR --sensitivity S]"} + from leapflow.monitor import WatchSpec + + view = await monitors.arm_watch(WatchSpec.from_dict(_parse_watch_spec(rest_tokens))) + return {"ok": True, "view": "dashboard", "mode": "armed", "watch": view.to_dict()} + + if action in ("pause", "resume", "stop"): + if not rest_tokens: + return {"ok": False, "message": f"Usage: /dashboard {action} "} + watch_id = _resolve_watch_id(monitors, rest_tokens[0]) + method = {"pause": monitors.pause_watch, "resume": monitors.resume_watch, + "stop": monitors.stop_watch}[action] + view = method(watch_id) + if view is None: + return {"ok": False, "message": f"Watch not found: {rest_tokens[0]}"} + return {"ok": True, "view": "dashboard", "mode": "control", "action": action, + "watch": view.to_dict()} + + if action == "mute": + if not rest_tokens: + return {"ok": False, "message": "Usage: /dashboard mute [on|off]"} + watch_id = _resolve_watch_id(monitors, rest_tokens[0]) + muted = not (len(rest_tokens) > 1 and rest_tokens[1].lower() in ("off", "false", "0", "no")) + view = monitors.set_muted(watch_id, muted) + if view is None: + return {"ok": False, "message": f"Watch not found: {rest_tokens[0]}"} + return {"ok": True, "view": "dashboard", "mode": "control", "action": "mute", + "watch": view.to_dict()} + + if action == "refresh": + if not rest_tokens: + return {"ok": False, "message": "Usage: /dashboard refresh "} + watch_id = _resolve_watch_id(monitors, rest_tokens[0]) + result = await monitors.run_watch_once(watch_id) + return {"ok": bool(result.get("ok", False)), "view": "dashboard", "mode": "refresh", + "watch_id": watch_id, "result": result, + "message": None if result.get("ok") else str(result.get("error", "refresh failed"))} + + if action == "findings": + positional = [t for t in rest_tokens if not t.startswith("--")] + watch_id = _resolve_watch_id(monitors, positional[0]) if positional else "" + limit = 20 + if "--limit" in rest_tokens: + try: + limit = int(rest_tokens[rest_tokens.index("--limit") + 1]) + except (ValueError, IndexError): + pass + findings = monitors.list_findings(watch_id=watch_id or None, limit=limit) + return {"ok": True, "view": "dashboard", "mode": "findings", + "findings": [f.to_dict() for f in findings]} + + if action in ("open", "home", "session"): + from leapflow.dashboard import launcher + from leapflow.dashboard.intent import DashboardIntent + + intent = DashboardIntent.from_args(rest) + # Bare `/dashboard` (open, no target): prefer the session view when there + # is an active conversation, otherwise the overview. + if intent.action == "open" and not intent.target: + engine = getattr(ctx, "engine", None) + if engine is not None and int(getattr(engine, "turn_count", 0) or 0) > 0: + intent = DashboardIntent(action="session", target="session") + if intent.action == "session" and monitors is not None: + try: + await _ensure_session_watch_refresh(ctx, monitors) + except Exception: + pass + settings = getattr(ctx, "settings", None) + state = None + if settings is not None: + try: + state = launcher.server_running(settings) + except Exception: + state = None + payload: dict[str, Any] = { + "ok": True, "view": "dashboard", "mode": "open", + "action": intent.action, "intent": intent.to_dict(), "running": bool(state), + } + if state: + url = launcher.build_url(state["bind"], state["port"], state["token"]) + if intent.action != "home": + url += f"&action={intent.action}" + if intent.action == "session": + url += "&target=session" + payload["url"] = url + else: + payload["hint"] = "Run `leap dashboard` to open the web view." + return payload + + return {"ok": False, "message": f"Unknown dashboard subcommand: {action}. " + "Try list|status|new|pause|resume|stop|mute|refresh|findings|open|session."} + + def _is_app_command_name(name: str) -> bool: return name == "app" or name.startswith("app ") @@ -1509,12 +1675,99 @@ def render_command_payload(console: "LeapConsole", payload: dict[str, Any]) -> N if view == "task": _render_task_view(console, payload) return + if view == "dashboard": + _render_dashboard_view(console, payload) + return msg = payload.get("message") if msg: console.success(str(msg)) +def _render_dashboard_view(console: "LeapConsole", payload: dict[str, Any]) -> None: + from rich.table import Table + + mode = str(payload.get("mode") or "") + if mode == "list": + watches = payload.get("watches") or [] + if not watches: + console.system("No watches. Create one with /dashboard new .") + return + table = Table(title="Watches", title_style="bold cyan", border_style="bright_black") + for col in ("id", "name", "domain", "trigger", "state", "muted", "runs", "findings"): + table.add_column(col) + for w in watches: + table.add_row( + str(w.get("watch_id", ""))[:8], + str(w.get("name", "")), + str(w.get("domain", "")), + str(w.get("trigger", "")), + str(w.get("state", "")), + "yes" if w.get("muted") else "", + str(w.get("run_count", 0)), + str(w.get("finding_count", 0)), + ) + console.print(table) + return + if mode == "status": + by_state = payload.get("by_state") or {} + parts = ", ".join(f"{k}:{v}" for k, v in sorted(by_state.items())) or "none" + console.system( + f"Watches: {payload.get('count', 0)} ({parts}) · " + f"findings: {payload.get('findings_total', 0)} · " + f"domains: {', '.join(payload.get('domains') or []) or '-'}" + ) + return + if mode == "armed": + w = payload.get("watch") or {} + console.success( + f"Armed watch {str(w.get('watch_id', ''))[:8]} · {w.get('domain')} · {w.get('trigger')}" + ) + return + if mode == "control": + w = payload.get("watch") or {} + suffix = " (muted)" if w.get("muted") else "" + console.success( + f"{payload.get('action', 'update')}: {w.get('name')} " + f"[{str(w.get('watch_id', ''))[:8]}] -> {w.get('state')}{suffix}" + ) + return + if mode == "refresh": + r = payload.get("result") or {} + console.success( + f"Refreshed {str(payload.get('watch_id', ''))[:8]} · " + f"{r.get('findings', 0)} finding(s), {r.get('emitted', 0)} pushed" + ) + return + if mode == "open": + url = payload.get("url") + if url: + console.system(f"Dashboard: {url}") + elif payload.get("running"): + console.system("Dashboard is running.") + else: + console.system(str(payload.get("hint") or "Run `leap dashboard` to open the web view.")) + return + if mode == "findings": + _render_findings_lines(console, payload.get("findings") or []) + return + + +def _render_findings_lines(console: "LeapConsole", findings: list) -> None: + if not findings: + console.system("No findings yet.") + return + markers = {"alert": "!!", "notable": " *", "info": " -"} + for f in findings[:50]: + sev = str(f.get("severity", "info")) + title = str(f.get("title", "")) + summary = str(f.get("summary", "")) + line = f"{markers.get(sev, ' -')} [{sev}] {title}" + if summary: + line += f" — {summary}" + console.system(line) + + def _render_status_view(console: "LeapConsole", payload: dict[str, Any]) -> None: from rich.panel import Panel from rich.text import Text diff --git a/src/leapflow/cli/tui_app/console.py b/src/leapflow/cli/tui_app/console.py index c8e9c07..7f580a7 100644 --- a/src/leapflow/cli/tui_app/console.py +++ b/src/leapflow/cli/tui_app/console.py @@ -183,6 +183,43 @@ def command_card(self, command: TuiCommand) -> None: padding=(0, 1), )) + def monitor_card(self, finding: Mapping[str, Any]) -> None: + """Render a compact, non-blocking card for a pushed monitor finding.""" + severity = str(finding.get("severity", "info")) + border = { + "alert": "leap.error", + "notable": "leap.warning", + "info": "leap.border", + }.get(severity, "leap.border") + domain = str(finding.get("domain", "")) + watch_id = str(finding.get("watch_id", ""))[:8] + title_text = str(finding.get("title", "")) or "(finding)" + summary = str(finding.get("summary", "")) + + body = Text(title_text, style="bold") + if summary: + body.append("\n") + body.append(summary, style="leap.muted") + actions = finding.get("suggested_actions") or [] + if actions: + labels = ", ".join(str(a.get("label") or a.get("name")) for a in actions[:3]) + body.append("\n") + body.append(f"actions: {labels}", style="leap.dim") + + head = Text() + head.append("◉ ", style=border) + head.append(domain or "monitor", style="bold") + if watch_id: + head.append(f" [{watch_id}]", style="leap.dim") + head.append(f" {severity}", style=border) + self._console.print(Panel( + body, + title=head, + title_align="left", + border_style=border, + padding=(0, 1), + )) + def markdown( self, text: str, diff --git a/src/leapflow/cli/tui_app/status.py b/src/leapflow/cli/tui_app/status.py index 49f3329..bd26e69 100644 --- a/src/leapflow/cli/tui_app/status.py +++ b/src/leapflow/cli/tui_app/status.py @@ -98,6 +98,8 @@ def __init__(self, theme: Optional[Theme | ResolvedTheme] = None) -> None: self.context_state: str = "baseline" self.running_tasks: int = 0 self.queued_tasks: int = 0 + self.watch_count: int = 0 + self.alert_count: int = 0 self.last_turn_elapsed: float = 0.0 self._turn_start: float = 0.0 self._session_start: float = time.monotonic() @@ -139,6 +141,21 @@ def __call__(self) -> list[tuple[str, str]]: parts.append(("class:status-bar.strong", task_text)) parts.append(("class:status-bar.dim", "│ ")) + if self.watch_count or self.alert_count: + if narrow: + mon_text = f"◉{self.watch_count}" + if self.alert_count: + mon_text += f"!{self.alert_count}" + mon_text += " " + else: + mon_text = f"◉ {self.watch_count} watch" + if self.alert_count: + mon_text += f" · {self.alert_count} alert" + mon_text += " " + mon_cls = "class:status-bar.warn" if self.alert_count else "class:status-bar.strong" + parts.append((mon_cls, mon_text)) + parts.append(("class:status-bar.dim", "│ ")) + if self.model_name and not narrow: model_limit = 12 if compact else 20 display = _truncate(self.model_name, model_limit) @@ -246,4 +263,13 @@ def update( def update_task_counts(self, *, running: int, queued: int) -> None: """Update task counters shown in the status bar.""" self.running_tasks = max(0, running) - self.queued_tasks = max(0, queued) \ No newline at end of file + self.queued_tasks = max(0, queued) + + def update_monitor_counts( + self, *, watches: Optional[int] = None, alerts: Optional[int] = None + ) -> None: + """Update the monitor watch/alert counters shown in the status bar.""" + if watches is not None: + self.watch_count = max(0, watches) + if alerts is not None: + self.alert_count = max(0, alerts) \ No newline at end of file diff --git a/src/leapflow/config.py b/src/leapflow/config.py index b3f5efa..b85ce25 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -406,6 +406,20 @@ class Settings: scheduler_grace_seconds: float = 120.0 scheduler_default_tier: str = "auto" # auto | local | cloud + # ── Dashboard (monitoring web view) ── + dashboard_enabled: bool = True + dashboard_bind: str = "127.0.0.1" + dashboard_port: int = 8765 + dashboard_auto_open: bool = True + dashboard_token_ref: str = "" # secret ref for the local dashboard access token + + # ── Session analysis dashboard (domain=session watch) ── + monitor_session_batch_turns: int = 6 + monitor_session_batch_tokens: int = 4000 + monitor_session_use_model_salience: bool = False + monitor_session_debounce_s: float = 15.0 + monitor_session_max_refresh_per_min: int = 4 + @property def profile_dir(self) -> Path: """Root directory for the active profile.""" @@ -829,6 +843,20 @@ def _build_settings_from_env( scheduler_grace_seconds = float(os.getenv("LEAPFLOW_SCHEDULER_GRACE_SECONDS", "120.0")) scheduler_default_tier = os.getenv("LEAPFLOW_SCHEDULER_DEFAULT_TIER", "auto") + # Dashboard + dashboard_enabled = _bool("LEAPFLOW_DASHBOARD_ENABLED", "true") + dashboard_bind = os.getenv("LEAPFLOW_DASHBOARD_BIND", "127.0.0.1").strip() or "127.0.0.1" + dashboard_port = int(os.getenv("LEAPFLOW_DASHBOARD_PORT", "8765")) + dashboard_auto_open = _bool("LEAPFLOW_DASHBOARD_AUTO_OPEN", "true") + dashboard_token_ref = os.getenv("LEAPFLOW_DASHBOARD_TOKEN_REF", "").strip() + + # Session analysis dashboard + monitor_session_batch_turns = int(os.getenv("LEAPFLOW_MONITOR_SESSION_BATCH_TURNS", "6")) + monitor_session_batch_tokens = int(os.getenv("LEAPFLOW_MONITOR_SESSION_BATCH_TOKENS", "4000")) + monitor_session_use_model_salience = _bool("LEAPFLOW_MONITOR_SESSION_USE_MODEL_SALIENCE", "false") + monitor_session_debounce_s = float(os.getenv("LEAPFLOW_MONITOR_SESSION_DEBOUNCE_S", "15.0")) + monitor_session_max_refresh_per_min = int(os.getenv("LEAPFLOW_MONITOR_SESSION_MAX_REFRESH_PER_MIN", "4")) + settings = Settings( llm_api_key=api_key, llm_base_url=base_url.rstrip("/"), @@ -1060,6 +1088,17 @@ def _build_settings_from_env( scheduler_tick_seconds=scheduler_tick_seconds, scheduler_grace_seconds=scheduler_grace_seconds, scheduler_default_tier=scheduler_default_tier, + # Dashboard + dashboard_enabled=dashboard_enabled, + dashboard_bind=dashboard_bind, + dashboard_port=dashboard_port, + dashboard_auto_open=dashboard_auto_open, + dashboard_token_ref=dashboard_token_ref, + monitor_session_batch_turns=monitor_session_batch_turns, + monitor_session_batch_tokens=monitor_session_batch_tokens, + monitor_session_use_model_salience=monitor_session_use_model_salience, + monitor_session_debounce_s=monitor_session_debounce_s, + monitor_session_max_refresh_per_min=monitor_session_max_refresh_per_min, ) if not settings.llm_api_key: diff --git a/src/leapflow/config_service.py b/src/leapflow/config_service.py index 0bca21f..c84f3e8 100644 --- a/src/leapflow/config_service.py +++ b/src/leapflow/config_service.py @@ -119,6 +119,11 @@ class ConfigSnapshot: "visual.track_enabled": "Enable screenshot-based visual perception for the active profile.", "recording.mode": "Default recording pipeline used during teaching and observation.", "scheduler.tick_seconds": "Scheduler polling interval in seconds.", + "dashboard.enabled": "Enable the local monitoring web dashboard.", + "dashboard.bind": "Address the dashboard web server binds to (keep loopback).", + "dashboard.port": "TCP port for the local dashboard web server.", + "dashboard.auto_open": "Open the default browser automatically when launching the dashboard.", + "dashboard.token_ref": "Secret ref for the local dashboard access token.", "stream.output": "Stream assistant tokens and progress in interactive sessions.", "verbose.progress": "Show inline execution progress for tools and runtime steps.", } @@ -146,6 +151,8 @@ class ConfigSnapshot: "mock": "Runtime", "log": "Runtime", "scheduler": "Scheduler", + "dashboard": "Dashboard", + "monitor": "Dashboard", "copilot": "Copilot", "react": "Execution Loop", "tool": "Execution Loop", @@ -166,7 +173,7 @@ class ConfigSnapshot: "signal.channels": "all or comma-separated channel names", } -_PARTIAL_RELOAD_SECTIONS = frozenset({"runtime", "mock", "gateway", "hub", "scheduler", "observer", "cua", "use"}) +_PARTIAL_RELOAD_SECTIONS = frozenset({"runtime", "mock", "gateway", "hub", "scheduler", "observer", "cua", "use", "dashboard"}) _PROFILE_FILE_BY_SECTION = { "llm": "llm.yaml", diff --git a/src/leapflow/daemon/client.py b/src/leapflow/daemon/client.py index c367b4f..7d17f99 100644 --- a/src/leapflow/daemon/client.py +++ b/src/leapflow/daemon/client.py @@ -195,6 +195,56 @@ async def approval_cancel(self, pending_id: str, *, reason: str = "cancelled") - ) return dict(result or {}) + # ── Watch runtime (monitor subsystem) ── + + async def watch_arm(self, spec: dict[str, Any]) -> dict[str, Any]: + """Arm a monitor watch on the daemon. Returns its runtime view.""" + return dict(await self.request("watch.arm", {"spec": spec}) or {}) + + async def watch_list(self) -> list[dict[str, Any]]: + """List daemon-hosted watches.""" + return list(await self.request("watch.list") or []) + + async def watch_get(self, watch_id: str) -> dict[str, Any]: + """Return a single watch view.""" + return dict(await self.request("watch.get", {"watch_id": watch_id}) or {}) + + async def watch_pause(self, watch_id: str) -> dict[str, Any]: + """Suspend a watch until resumed.""" + return dict(await self.request("watch.pause", {"watch_id": watch_id}) or {}) + + async def watch_resume(self, watch_id: str) -> dict[str, Any]: + """Re-arm a suspended watch.""" + return dict(await self.request("watch.resume", {"watch_id": watch_id}) or {}) + + async def watch_stop(self, watch_id: str) -> dict[str, Any]: + """Terminally stop a watch.""" + return dict(await self.request("watch.stop", {"watch_id": watch_id}) or {}) + + async def watch_mute(self, watch_id: str, *, muted: bool = True) -> dict[str, Any]: + """Toggle whether a watch's findings are pushed.""" + return dict(await self.request("watch.mute", {"watch_id": watch_id, "muted": muted}) or {}) + + async def watch_refresh(self, watch_id: str) -> dict[str, Any]: + """Run one observation cycle immediately.""" + return dict(await self.request("watch.refresh", {"watch_id": watch_id}) or {}) + + async def watch_findings( + self, *, watch_id: str = "", limit: int = 50, offset: int = 0 + ) -> list[dict[str, Any]]: + """Return persisted findings, newest first.""" + return list(await self.request( + "watch.findings", {"watch_id": watch_id, "limit": limit, "offset": offset} + ) or []) + + async def session_history(self, *, limit: int = 200) -> dict[str, Any]: + """Return the current conversation transcript and counts.""" + return dict(await self.request("session.history", {"limit": limit}) or {}) + + async def session_analyze(self) -> dict[str, Any]: + """Ensure a session-analysis watch and run one analysis cycle now.""" + return dict(await self.request("session.analyze") or {}) + async def shutdown(self) -> None: """Request graceful daemon shutdown.""" await self.request("daemon.shutdown") diff --git a/src/leapflow/daemon/protocol.py b/src/leapflow/daemon/protocol.py index d43aa1d..15ee5a1 100644 --- a/src/leapflow/daemon/protocol.py +++ b/src/leapflow/daemon/protocol.py @@ -183,6 +183,54 @@ async def scheduler_arm(self, task_config: Dict[str, Any]) -> str: """Arm a scheduled task. Returns task_id.""" ... + # ── Watch runtime (monitor subsystem) ── + + async def watch_arm(self, spec: Dict[str, Any]) -> Dict[str, Any]: + """Arm a monitor watch. Returns its runtime view.""" + ... + + async def watch_list(self) -> List[Dict[str, Any]]: + """List all watches.""" + ... + + async def watch_get(self, watch_id: str) -> Dict[str, Any]: + """Return a single watch view.""" + ... + + async def watch_pause(self, watch_id: str) -> Dict[str, Any]: + """Suspend a watch until resumed.""" + ... + + async def watch_resume(self, watch_id: str) -> Dict[str, Any]: + """Re-arm a suspended watch.""" + ... + + async def watch_stop(self, watch_id: str) -> Dict[str, Any]: + """Terminally stop a watch (kept for history).""" + ... + + async def watch_mute(self, watch_id: str, muted: bool = True) -> Dict[str, Any]: + """Toggle whether a watch's findings are pushed to view clients.""" + ... + + async def watch_refresh(self, watch_id: str) -> Dict[str, Any]: + """Run one observation cycle immediately (manual refresh).""" + ... + + async def watch_findings( + self, watch_id: str = "", limit: int = 50, offset: int = 0 + ) -> List[Dict[str, Any]]: + """Return persisted findings, newest first.""" + ... + + async def session_history(self, limit: int = 200) -> Dict[str, Any]: + """Return the current conversation transcript and turn/token counts.""" + ... + + async def session_analyze(self) -> Dict[str, Any]: + """Ensure a session-analysis watch and run one analysis cycle now.""" + ... + async def status(self) -> Dict[str, Any]: """Return daemon status (uptime, connections, db path, etc.).""" ... @@ -284,6 +332,17 @@ async def gateway_send( "engine.cancel": "engine_cancel", "skill.execute": "skill_execute", "scheduler.arm": "scheduler_arm", + "watch.arm": "watch_arm", + "watch.list": "watch_list", + "watch.get": "watch_get", + "watch.pause": "watch_pause", + "watch.resume": "watch_resume", + "watch.stop": "watch_stop", + "watch.mute": "watch_mute", + "watch.refresh": "watch_refresh", + "watch.findings": "watch_findings", + "session.history": "session_history", + "session.analyze": "session_analyze", "daemon.status": "status", "daemon.shutdown": "shutdown", "host.status": "host_status", diff --git a/src/leapflow/daemon/server.py b/src/leapflow/daemon/server.py index 648b7a7..0c2973e 100644 --- a/src/leapflow/daemon/server.py +++ b/src/leapflow/daemon/server.py @@ -70,6 +70,20 @@ def active_connections(self) -> int: """Return the current number of connected clients.""" return self._active_connections + def has_keepalive_work(self) -> bool: + """Return True when the service has work that must keep the daemon alive. + + Persistent monitor watches must survive client disconnects, so an armed + watch counts as activity for the idle-shutdown watchdog. + """ + checker = getattr(self._service, "has_active_watches", None) + if not callable(checker): + return False + try: + return bool(checker()) + except Exception: + return False + async def serve_forever(self) -> None: """Start listening and serve until cancelled.""" self._runtime_dir.mkdir(parents=True, exist_ok=True) @@ -311,7 +325,8 @@ async def _watch_idle_shutdown( server.runtime_dir, ttl_s=max_lease_age, ) - if server.active_connections > 0 or has_lease: + keepalive = getattr(server, "has_keepalive_work", None) + if server.active_connections > 0 or has_lease or (callable(keepalive) and keepalive()): last_active = asyncio.get_running_loop().time() elif asyncio.get_running_loop().time() - last_active >= idle_timeout_s: logger.info("daemon: idle timeout reached; shutting down") diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index a7c6521..1b89e17 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -27,6 +27,7 @@ def __init__(self, settings: Any, *, mock_host: bool = False) -> None: self._settings = settings self._mock_host = mock_host self._ctx: Any | None = None + self._monitors: Any | None = None self._engine_lock = asyncio.Lock() self._started_at = time.time() self._client_count: Callable[[], int] = lambda: 0 @@ -60,6 +61,42 @@ async def start(self) -> None: self._install_daemon_approval(ctx) self._install_learn_notifications(ctx) self._ctx = ctx + await self._start_monitors(ctx) + + async def _start_monitors(self, ctx: Any) -> None: + """Build and start the daemon-hosted monitor runtime (watches).""" + settings = getattr(ctx, "settings", self._settings) + if not getattr(settings, "scheduler_enabled", True): + return + try: + from leapflow.monitor import MonitorManager, SessionAnalysisProducer + + bus = self.notification_bus + self._monitors = MonitorManager( + holder=ctx._db_holder, + emit=lambda event_type, payload: bus.emit_event(event_type, **payload), + services=_ProducerServices(self), + tick_seconds=int(getattr(settings, "scheduler_tick_seconds", 60)), + grace_seconds=float(getattr(settings, "scheduler_grace_seconds", 120.0)), + ) + self._monitors.producers.register(SessionAnalysisProducer()) + setattr(ctx, "monitors", self._monitors) + await self._monitors.start() + logger.debug("daemon: monitor runtime started") + except Exception: + logger.debug("daemon: monitor runtime start skipped", exc_info=True) + self._monitors = None + setattr(ctx, "monitors", None) + + def has_active_watches(self) -> bool: + """Return True when any hosted watch is armed/watching (idle keep-alive).""" + monitors = self._monitors + if monitors is None: + return False + try: + return bool(monitors.has_active_watches()) + except Exception: + return False @property def context(self) -> Any: @@ -204,6 +241,154 @@ async def skill_execute(self, skill_name: str, params: dict[str, Any]) -> dict[s async def scheduler_arm(self, task_config: dict[str, Any]) -> str: raise NotImplementedError("scheduler.arm is not available in this daemon phase") + # ── Watch runtime (monitor subsystem) ─────────────────────────────── + + def _require_monitors(self) -> Any: + if self._monitors is None: + raise RuntimeError("monitor runtime is not available (scheduler disabled)") + return self._monitors + + async def watch_arm(self, spec: dict[str, Any]) -> dict[str, Any]: + from leapflow.monitor import WatchSpec + + view = await self._require_monitors().arm_watch(WatchSpec.from_dict(spec or {})) + return view.to_dict() + + async def watch_list(self) -> list[dict[str, Any]]: + if self._monitors is None: + return [] + return [view.to_dict() for view in self._monitors.list_watches()] + + async def watch_get(self, watch_id: str) -> dict[str, Any]: + view = self._require_monitors().get_watch(watch_id) + return view.to_dict() if view else {} + + async def watch_pause(self, watch_id: str) -> dict[str, Any]: + view = self._require_monitors().pause_watch(watch_id) + return view.to_dict() if view else {} + + async def watch_resume(self, watch_id: str) -> dict[str, Any]: + view = self._require_monitors().resume_watch(watch_id) + return view.to_dict() if view else {} + + async def watch_stop(self, watch_id: str) -> dict[str, Any]: + view = self._require_monitors().stop_watch(watch_id) + return view.to_dict() if view else {} + + async def watch_mute(self, watch_id: str, muted: bool = True) -> dict[str, Any]: + view = self._require_monitors().set_muted(watch_id, bool(muted)) + return view.to_dict() if view else {} + + async def watch_refresh(self, watch_id: str) -> dict[str, Any]: + return await self._require_monitors().run_watch_once(watch_id) + + async def watch_findings( + self, watch_id: str = "", limit: int = 50, offset: int = 0 + ) -> list[dict[str, Any]]: + if self._monitors is None: + return [] + findings = self._monitors.list_findings( + watch_id=watch_id or None, limit=int(limit), offset=int(offset) + ) + return [finding.to_dict() for finding in findings] + + # ── Session analysis (domain=session watch) ─────────────────────── + + async def session_history(self, limit: int = 200) -> dict[str, Any]: + ctx = self._ctx + if ctx is None: + return {"session_id": "", "turn_count": 0, "token_count": 0, "messages": []} + engine = getattr(ctx, "engine", None) + messages: list[dict[str, Any]] = [] + if engine is not None: + wm = getattr(engine, "_wm", None) + if wm is not None and hasattr(wm, "as_chat_messages"): + try: + messages = [dict(m) for m in wm.as_chat_messages() if isinstance(m, dict)] + except Exception: + messages = [] + if not messages: + store = getattr(ctx, "_conversation_store", None) + session_id = getattr(engine, "_current_session_id", "") if engine else "" + if store is not None and session_id: + try: + messages = [ + dict(m) for m in store.get_messages(session_id, limit=int(limit)) + if isinstance(m, dict) + ] + except Exception: + messages = [] + normalized = [ + {"role": str(m.get("role", "")), "content": str(m.get("content", ""))} + for m in messages + ][-int(limit):] + return { + "session_id": getattr(engine, "_current_session_id", "") if engine else "", + "turn_count": int(getattr(engine, "turn_count", 0)) if engine else 0, + "token_count": int(getattr(engine, "context_token_count", 0)) if engine else 0, + "messages": normalized, + } + + async def session_analyze(self) -> dict[str, Any]: + if self._monitors is None: + return {"ok": False, "error": "monitor runtime unavailable"} + watch_id = await self._ensure_session_watch() + result = await self._monitors.run_watch_once(watch_id, force=True) + return {"ok": bool(result.get("ok", True)), "watch_id": watch_id, "result": result} + + async def _ensure_session_watch(self) -> str: + from leapflow.monitor.session_producer import ensure_session_watch, session_watch_params + + monitors = self._require_monitors() + settings = getattr(self._ctx, "settings", self._settings) + return await ensure_session_watch(monitors, params=session_watch_params(settings)) + + async def _analyze_session_llm(self, messages: list[dict[str, Any]]) -> dict[str, Any]: + base: dict[str, Any] = { + "story": "", "insights": [], "decisions": [], "action_items": [], + "open_questions": [], "entities": [], "next_prompts": [], "usage": {}, + } + ctx = self._ctx + llm = getattr(ctx, "llm", None) if ctx is not None else None + if llm is None or not messages: + return base + transcript = "\n".join( + f"{m.get('role', '')}: {str(m.get('content', ''))[:500]}" for m in messages[-40:] + )[:12000] + prompt = [ + {"role": "system", "content": _SESSION_ANALYSIS_SYSTEM}, + {"role": "user", "content": transcript}, + ] + try: + response = await llm.achat(prompt, stream=False) + data = _parse_session_json(getattr(response, "content", "")) + except Exception: + logger.debug("daemon: session analysis LLM call failed", exc_info=True) + return base + if isinstance(data, dict): + for key in base: + if key != "usage" and key in data: + base[key] = data[key] + return base + + async def _session_should_refresh(self, messages: list[dict[str, Any]]) -> bool: + ctx = self._ctx + llm = getattr(ctx, "llm", None) if ctx is not None else None + if llm is None or not messages: + return False + tail = "\n".join( + f"{m.get('role', '')}: {str(m.get('content', ''))[:200]}" for m in messages[-6:] + )[:2000] + prompt = [ + {"role": "system", "content": _SESSION_SALIENCE_SYSTEM}, + {"role": "user", "content": tail}, + ] + try: + response = await llm.achat(prompt, stream=False) + return str(getattr(response, "content", "")).strip().upper().startswith("Y") + except Exception: + return False + async def status(self) -> dict[str, Any]: ctx = self._ctx settings = getattr(ctx, "settings", self._settings) if ctx is not None else self._settings @@ -415,6 +600,12 @@ async def shutdown(self) -> None: return ctx = self._ctx self._ctx = None + if self._monitors is not None: + try: + await self._monitors.stop() + except Exception: + logger.debug("daemon: monitor stop failed", exc_info=True) + self._monitors = None self._checkpoint_open_connection(ctx) await ctx.cleanup() @@ -730,6 +921,60 @@ def _chunk_from_event(self, event: StreamEvent, *, request_id: str = "") -> Stre ) +_SESSION_ANALYSIS_SYSTEM = ( + "You are a session analyst. Read the conversation transcript and return STRICT " + "JSON only, with keys: story (string narrative of the session arc), insights " + "(array of {title, summary, severity in [info,notable,alert]}), decisions " + "(array of strings), action_items (array of strings), open_questions (array of " + "strings), entities (array of strings), next_prompts (array of strings). Do not " + "wrap the JSON in prose or code fences." +) + +_SESSION_SALIENCE_SYSTEM = ( + "Answer with only YES or NO: does the latest conversation contain a new decision, " + "a topic shift, or a new action item that would justify refreshing an analysis " + "dashboard?" +) + + +def _parse_session_json(content: str) -> Any: + """Best-effort extraction of a JSON object from an LLM response.""" + import json as _json + + text = str(content or "").strip() + if text.startswith("```"): + text = text.strip("`") + if "\n" in text: + first, rest = text.split("\n", 1) + if first.strip().lower().startswith("json"): + text = rest + start, end = text.find("{"), text.rfind("}") + if start != -1 and end != -1 and end > start: + text = text[start:end + 1] + try: + return _json.loads(text) + except Exception: + return None + + +class _ProducerServices: + """Facade exposing daemon capabilities to monitor producers (session, etc.).""" + + def __init__(self, service: "RuntimeLeapService") -> None: + self._service = service + + async def session_history(self) -> dict[str, Any]: + return await self._service.session_history() + + async def analyze_session( + self, messages: list[dict[str, Any]], *, prior: dict[str, Any] | None = None + ) -> dict[str, Any]: + return await self._service._analyze_session_llm(messages) + + async def should_refresh(self, messages: list[dict[str, Any]]) -> bool: + return await self._service._session_should_refresh(messages) + + class _DaemonApprovalGate: """Approval gate that bridges daemon-side actions to thin clients.""" diff --git a/src/leapflow/dashboard/__init__.py b/src/leapflow/dashboard/__init__.py new file mode 100644 index 0000000..49930fa --- /dev/null +++ b/src/leapflow/dashboard/__init__.py @@ -0,0 +1,43 @@ +"""Dashboard subsystem: declarative Server-Driven UI (SDUI) for monitoring. + +This package owns the domain-neutral view layer: +- ``viewspec``: the validated component catalog and ViewSpec contract +- ``templates``: YAML template rendering into a ViewSpec (with safe binding) +- ``intent``: the ``DashboardIntent`` shared by slash and natural-language entry + +Transport (the local web server) is added as a separate, optional module so the +core view logic stays importable without web dependencies. +""" + +from leapflow.dashboard.intent import DashboardIntent +from leapflow.dashboard.hub import ViewHub +from leapflow.dashboard.service import ( + DaemonDataProvider, + DashboardDataProvider, + DashboardViewBuilder, + select_template, +) +from leapflow.dashboard.templates import TemplateLibrary, render_template +from leapflow.dashboard.viewspec import ( + COMPONENT_CATALOG, + COMPONENT_TYPES, + SCHEMA_VERSION, + normalize_viewspec, + validate_viewspec, +) + +__all__ = [ + "DashboardIntent", + "ViewHub", + "DashboardDataProvider", + "DaemonDataProvider", + "DashboardViewBuilder", + "select_template", + "TemplateLibrary", + "render_template", + "COMPONENT_CATALOG", + "COMPONENT_TYPES", + "SCHEMA_VERSION", + "normalize_viewspec", + "validate_viewspec", +] diff --git a/src/leapflow/dashboard/hub.py b/src/leapflow/dashboard/hub.py new file mode 100644 index 0000000..8ebda02 --- /dev/null +++ b/src/leapflow/dashboard/hub.py @@ -0,0 +1,64 @@ +"""ViewHub: fan out daemon monitor events to browser WebSocket subscribers. + +The dashboard server holds a single subscription to the daemon NotificationBus +and re-broadcasts qualifying messages to every connected browser. This mirrors +the daemon's own NotificationBus fan-out philosophy, keeping slow browsers from +blocking the shared upstream via bounded per-subscriber queues. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +class ViewHub: + """Broadcast view/monitor messages to all subscribed browsers.""" + + def __init__(self, maxsize: int = 128) -> None: + self._subscribers: dict[str, asyncio.Queue[Optional[dict[str, Any]]]] = {} + self._maxsize = maxsize + + def subscribe(self, subscriber_id: str) -> asyncio.Queue[Optional[dict[str, Any]]]: + """Register a browser subscriber and return its message queue.""" + queue: asyncio.Queue[Optional[dict[str, Any]]] = asyncio.Queue(maxsize=self._maxsize) + self._subscribers[subscriber_id] = queue + return queue + + def unsubscribe(self, subscriber_id: str) -> None: + """Remove a browser subscriber.""" + self._subscribers.pop(subscriber_id, None) + + def broadcast(self, message: dict[str, Any]) -> int: + """Deliver a message to all subscribers (non-blocking); return count. + + Full queues are skipped (back-pressure) rather than blocking the shared + upstream subscription. + """ + delivered = 0 + for sid, queue in list(self._subscribers.items()): + try: + queue.put_nowait(message) + delivered += 1 + except asyncio.QueueFull: + logger.debug("view_hub: dropped message for slow subscriber %s", sid) + return delivered + + @property + def subscriber_count(self) -> int: + return len(self._subscribers) + + async def shutdown(self) -> None: + """Signal all subscribers to disconnect.""" + for queue in self._subscribers.values(): + try: + queue.put_nowait(None) + except asyncio.QueueFull: + pass + self._subscribers.clear() + + +__all__ = ["ViewHub"] diff --git a/src/leapflow/dashboard/intent.py b/src/leapflow/dashboard/intent.py new file mode 100644 index 0000000..582b8db --- /dev/null +++ b/src/leapflow/dashboard/intent.py @@ -0,0 +1,115 @@ +"""DashboardIntent: the single normalized request behind both entry doors. + +Slash commands (``/dashboard ...``) and the engine ``dashboard`` tool both +produce a ``DashboardIntent``, so there is one implementation with two front +doors -- no duplicated routing and no keyword-matching taxonomy. +""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass, field +from typing import Any, Mapping + +KNOWN_ACTIONS = frozenset({ + "open", "home", "session", "watch", "new", "list", "refresh", + "template", "close", "status", "pause", "resume", "stop", "mute", "findings", +}) +_DEFAULT_ACTION = "open" +_TARGET_ACTIONS = frozenset({ + "watch", "refresh", "open", "pause", "resume", "stop", "mute", "findings", +}) +_FLAG_KEYS = { + "--template": "template", + "--domain": "domain", + "--name": "name", + "--trigger": "trigger", + "--sensitivity": "sensitivity", +} + + +@dataclass(frozen=True) +class DashboardIntent: + """A normalized dashboard request from slash or natural-language entry.""" + + action: str = _DEFAULT_ACTION + domain: str = "" + template: str = "" + target: str = "" + params: Mapping[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "action": self.action, + "domain": self.domain, + "template": self.template, + "target": self.target, + "params": dict(self.params), + } + + @classmethod + def from_params(cls, data: Mapping[str, Any]) -> "DashboardIntent": + """Build an intent from structured tool/RPC params.""" + data = data if isinstance(data, Mapping) else {} + action = str(data.get("action", "") or "").strip().lower() + if action not in KNOWN_ACTIONS: + action = _DEFAULT_ACTION + return cls( + action=action, + domain=str(data.get("domain", "")).strip(), + template=str(data.get("template", "")).strip(), + target=str(data.get("target", "")).strip(), + params=dict(data.get("params") or {}), + ) + + @classmethod + def from_args(cls, args: str) -> "DashboardIntent": + """Parse a slash argument string into an intent. + + The first token is the action when recognized; otherwise it is treated + as a domain to create (``/dashboard finance`` -> new finance watch). + """ + try: + tokens = shlex.split(args or "") + except ValueError: + tokens = (args or "").split() + if not tokens: + return cls(action="open") + + first = tokens[0].lower() + if first in KNOWN_ACTIONS: + action, rest = first, tokens[1:] + else: + action, rest = "new", tokens + + domain = template = target = "" + params: dict[str, Any] = {} + positional: list[str] = [] + i = 0 + while i < len(rest): + key = _FLAG_KEYS.get(rest[i]) + if key and i + 1 < len(rest): + value = rest[i + 1] + if key == "template": + template = value + elif key == "domain": + domain = value + else: + params[key] = value + i += 2 + else: + positional.append(rest[i]) + i += 1 + + if action == "new" and positional and not domain: + domain = positional[0] + elif action in _TARGET_ACTIONS and positional: + target = positional[0] + elif action == "template" and positional: + template = positional[0] + elif action == "session": + target = "session" + return cls(action=action, domain=domain, template=template, target=target, params=params) + + +__all__ = ["DashboardIntent", "KNOWN_ACTIONS"] diff --git a/src/leapflow/dashboard/launcher.py b/src/leapflow/dashboard/launcher.py new file mode 100644 index 0000000..4136db7 --- /dev/null +++ b/src/leapflow/dashboard/launcher.py @@ -0,0 +1,178 @@ +"""Dashboard launcher: token/URL/state helpers, browser open, and server spawn. + +The dashboard runs as a separate view-client process (like the TUI). This module +owns the client-side concerns: a per-session access token, the localhost URL, +a small discovery state file under the profile runtime dir, opening the default +browser, and spawning the server when it is not already running. It has no web +dependency itself so it stays importable everywhere. +""" + +from __future__ import annotations + +import json +import logging +import os +import secrets +import socket +import subprocess +import sys +import time +import webbrowser +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +_STATE_FILE = "dashboard.json" + + +def aiohttp_available() -> bool: + """Return True when the optional ``aiohttp`` dependency is importable.""" + import importlib.util + + return importlib.util.find_spec("aiohttp") is not None + + +def generate_token() -> str: + """Return a fresh URL-safe access token for the local dashboard.""" + return secrets.token_urlsafe(32) + + +def state_path(settings: Any) -> Path: + return Path(settings.runtime_dir) / _STATE_FILE + + +def load_state(settings: Any) -> Optional[dict[str, Any]]: + """Load the dashboard discovery state, or None when absent/invalid.""" + path = state_path(settings) + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, json.JSONDecodeError): + return None + + +def write_state(settings: Any, state: dict[str, Any]) -> None: + """Persist the dashboard discovery state under the profile runtime dir.""" + path = state_path(settings) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state), encoding="utf-8") + + +def clear_state(settings: Any) -> None: + state_path(settings).unlink(missing_ok=True) + + +def _host_for_bind(bind: str) -> str: + return "127.0.0.1" if bind in ("", "0.0.0.0") else bind + + +def build_url(bind: str, port: int, token: str, path: str = "/") -> str: + """Build a token-scoped localhost dashboard URL.""" + if not path.startswith("/"): + path = "/" + path + suffix = f"?token={token}" if token else "" + return f"http://{_host_for_bind(bind)}:{port}{path}{suffix}" + + +def is_port_open(host: str, port: int, timeout: float = 0.3) -> bool: + """Return True when a TCP connect to (host, port) succeeds quickly.""" + try: + with socket.create_connection((_host_for_bind(host), int(port)), timeout=timeout): + return True + except OSError: + return False + + +def server_running(settings: Any) -> Optional[dict[str, Any]]: + """Return live dashboard state when a server appears reachable, else None.""" + state = load_state(settings) + if not state: + return None + port = int(state.get("port") or 0) + bind = str(state.get("bind") or settings.dashboard_bind) + if port and is_port_open(bind, port): + return state + return None + + +def open_in_browser(url: str) -> bool: + """Open ``url`` in the default browser; return False on headless failure.""" + try: + return bool(webbrowser.open(url, new=2)) + except Exception: # noqa: BLE001 - headless/no-DISPLAY environments + logger.debug("dashboard: webbrowser.open failed", exc_info=True) + return False + + +def ensure_server(settings: Any, *, wait_s: float = 8.0) -> dict[str, Any]: + """Return running dashboard state, spawning a detached server if needed. + + Raises RuntimeError when the optional web dependency is missing so callers + can surface an actionable install hint instead of spawning a doomed process. + """ + existing = server_running(settings) + if existing: + return existing + if not aiohttp_available(): + raise RuntimeError( + "The dashboard web server requires the optional 'aiohttp' dependency. " + "Install it with: pip install 'leapflow[dashboard]'" + ) + + token = generate_token() + port = int(getattr(settings, "dashboard_port", 8765)) + bind = str(getattr(settings, "dashboard_bind", "127.0.0.1")) + cmd = [ + sys.executable, "-m", "leapflow", "dashboard", "--serve", + "--token", token, "--port", str(port), "--bind", bind, + ] + creationflags = 0 + start_new_session = True + if os.name == "nt": # pragma: no cover - platform specific + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + start_new_session = False + proc = subprocess.Popen( # noqa: S603 - trusted, fixed argv + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=start_new_session, + creationflags=creationflags, + ) + + deadline = time.monotonic() + wait_s + while time.monotonic() < deadline: + if is_port_open(bind, port): + break + if proc.poll() is not None: + raise RuntimeError("dashboard server exited before becoming ready") + time.sleep(0.15) + else: + raise RuntimeError("dashboard server did not become ready in time") + + state = { + "port": port, + "bind": bind, + "token": token, + "pid": proc.pid, + "url": build_url(bind, port, token), + } + write_state(settings, state) + return state + + +__all__ = [ + "aiohttp_available", + "generate_token", + "state_path", + "load_state", + "write_state", + "clear_state", + "build_url", + "is_port_open", + "server_running", + "open_in_browser", + "ensure_server", +] diff --git a/src/leapflow/dashboard/server.py b/src/leapflow/dashboard/server.py new file mode 100644 index 0000000..2a96c66 --- /dev/null +++ b/src/leapflow/dashboard/server.py @@ -0,0 +1,252 @@ +"""Local dashboard web server (optional aiohttp transport, view-client process). + +Holds one upstream subscription to the daemon (via DaemonClient) and fans out +monitor events to browser WebSockets through a ``ViewHub``. Serves the SDUI +frontend, a ``/api/view`` endpoint (ViewSpec for an intent), and a guarded +``/api/action`` endpoint that dispatches the bidirectional action protocol. + +``aiohttp`` is imported lazily inside methods so this module (and the package) +stays importable without the optional dependency; only ``build_app``/``serve`` +require it. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import uuid +from pathlib import Path +from typing import Any, Optional + +from leapflow.dashboard.hub import ViewHub +from leapflow.dashboard.intent import DashboardIntent +from leapflow.dashboard.service import DaemonDataProvider, DashboardViewBuilder +from leapflow.dashboard.templates import TemplateLibrary +from leapflow.monitor.types import EVENT_ERROR, EVENT_FINDING, EVENT_HEARTBEAT, EVENT_WATCH_STATE + +logger = logging.getLogger(__name__) + +STATIC_DIR = Path(__file__).parent / "static" +_MONITOR_EVENTS = frozenset({EVENT_FINDING, EVENT_WATCH_STATE, EVENT_ERROR, EVENT_HEARTBEAT}) +# Only these RPCs may be triggered by browser actions (least privilege). +_ALLOWED_RPC = frozenset({"watch.pause", "watch.resume", "watch.stop", "watch.refresh", "watch.mute"}) + + +class DashboardServer: + """aiohttp view server bridging the daemon to browsers over WebSocket.""" + + def __init__( + self, + *, + client: Any, + token: str, + bind: str = "127.0.0.1", + port: int = 8765, + templates: Optional[TemplateLibrary] = None, + ) -> None: + self._client = client + self._token = token + self._bind = bind + self._port = port + self._hub = ViewHub() + self._builder = DashboardViewBuilder(templates or TemplateLibrary()) + self._provider = DaemonDataProvider(client) + self._upstream_task: Optional[asyncio.Task[None]] = None + + # ── App wiring ───────────────────────────────────────────────────────── + + def build_app(self) -> Any: + """Build the aiohttp Application (requires the optional aiohttp dep).""" + from aiohttp import web + + app = web.Application() + app.router.add_get("/", self._handle_index) + app.router.add_get("/api/view", self._handle_view) + app.router.add_post("/api/action", self._handle_action) + app.router.add_get("/ws", self._handle_ws) + if STATIC_DIR.exists(): + app.router.add_static("/static/", str(STATIC_DIR)) + app.on_startup.append(self._on_startup) + app.on_cleanup.append(self._on_cleanup) + return app + + async def serve(self) -> None: + """Run the server until cancelled.""" + from aiohttp import web + + app = self.build_app() + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, self._bind, self._port) + await site.start() + logger.info("dashboard serving on http://%s:%d", self._bind, self._port) + try: + while True: + await asyncio.sleep(3600) + finally: + await runner.cleanup() + + # ── Auth ─────────────────────────────────────────────────────────────── + + def _check_token(self, request: Any) -> bool: + token = request.query.get("token") or request.headers.get("X-Dashboard-Token", "") + return bool(self._token) and token == self._token + + @staticmethod + def _check_origin(request: Any) -> bool: + origin = request.headers.get("Origin") + if not origin: + return True # non-browser or same-origin requests carry no Origin + return "127.0.0.1" in origin or "localhost" in origin + + # ── Handlers ───────────────────────────────────────────────────────────── + + async def _handle_index(self, request: Any) -> Any: + from aiohttp import web + + if not self._check_token(request): + return web.Response(status=401, text="missing or invalid token") + index = STATIC_DIR / "index.html" + if index.exists(): + return web.FileResponse(index) + return web.Response(text="

LeapFlow dashboard

", content_type="text/html") + + async def _handle_view(self, request: Any) -> Any: + from aiohttp import web + + if not self._check_token(request): + return web.json_response({"error": "unauthorized"}, status=401) + intent = DashboardIntent.from_params({ + "action": request.query.get("action", "home"), + "domain": request.query.get("domain", ""), + "template": request.query.get("template", ""), + "target": request.query.get("target", ""), + }) + spec = await self._builder.build(intent, self._provider) + return web.json_response(spec) + + async def _handle_action(self, request: Any) -> Any: + from aiohttp import web + + if not self._check_token(request) or not self._check_origin(request): + return web.json_response({"error": "unauthorized"}, status=401) + try: + body = await request.json() + except Exception: # noqa: BLE001 - malformed client payload + body = {} + result = await self.dispatch_action(body if isinstance(body, dict) else {}) + return web.json_response(result) + + async def _handle_ws(self, request: Any) -> Any: + from aiohttp import web + + if not self._check_token(request): + return web.Response(status=401, text="unauthorized") + ws = web.WebSocketResponse(heartbeat=30.0) + await ws.prepare(request) + subscriber_id = uuid.uuid4().hex + queue = self._hub.subscribe(subscriber_id) + try: + while True: + message = await queue.get() + if message is None: + break + await ws.send_json(message) + except (asyncio.CancelledError, ConnectionError): + pass + finally: + self._hub.unsubscribe(subscriber_id) + return ws + + # ── Action dispatch (transport-independent, allow-listed) ──────────────── + + async def dispatch_action(self, action: dict[str, Any]) -> dict[str, Any]: + """Dispatch one action protocol message; returns a JSON-safe result.""" + kind = str(action.get("kind", "")) + name = str(action.get("name", "")) + params = dict(action.get("params") or {}) + if kind == "nav": + return {"ok": True, "nav": name, "params": params} + if kind == "rpc": + if name not in _ALLOWED_RPC: + return {"ok": False, "error": f"action not allowed: {name}"} + watch_id = str(params.get("watch_id") or params.get("target") or "") + result = await self._invoke_rpc(name, watch_id, params) + return {"ok": True, "result": result} + if kind == "approval": + pending_id = str(params.get("pending_id", "")) + decision = str(params.get("decision", "deny")) + return {"ok": True, "result": await self._client.approval_resolve(pending_id, decision)} + if kind == "intent": + # Engine intents (deep-dive, storytelling) are wired in a later phase; + # accept and acknowledge so the UI can reflect a queued request. + return {"ok": True, "queued": True, "name": name, "params": params} + return {"ok": False, "error": f"unknown action kind: {kind or '(missing)'}"} + + async def _invoke_rpc(self, name: str, watch_id: str, params: dict[str, Any]) -> Any: + if name == "watch.pause": + return await self._client.watch_pause(watch_id) + if name == "watch.resume": + return await self._client.watch_resume(watch_id) + if name == "watch.stop": + return await self._client.watch_stop(watch_id) + if name == "watch.refresh": + return await self._client.watch_refresh(watch_id) + if name == "watch.mute": + return await self._client.watch_mute(watch_id, muted=bool(params.get("muted", True))) + return {"ok": False, "error": f"unhandled rpc: {name}"} + + # ── Lifecycle ──────────────────────────────────────────────────────────── + + async def _on_startup(self, _app: Any) -> None: + self._upstream_task = asyncio.create_task(self._pump_upstream()) + + async def _on_cleanup(self, _app: Any) -> None: + if self._upstream_task is not None: + self._upstream_task.cancel() + try: + await self._upstream_task + except asyncio.CancelledError: + pass + await self._hub.shutdown() + + async def _pump_upstream(self) -> None: + """Forward daemon monitor events to all browser subscribers.""" + while True: + try: + async for event in self._client.subscribe_notifications(): + event_type = event.get("event_type", "") + if event_type in _MONITOR_EVENTS: + self._hub.broadcast({"type": event_type, "payload": event.get("payload") or {}}) + except asyncio.CancelledError: + return + except Exception: # noqa: BLE001 - reconnect on transient upstream loss + logger.debug("dashboard: upstream subscription lost; retrying", exc_info=True) + await asyncio.sleep(3.0) + + +async def run_server(settings: Any, *, token: str, bind: str, port: int) -> int: + """Connect to leapd and serve the dashboard until interrupted.""" + from leapflow.dashboard import launcher + from leapflow.daemon.client import ensure_daemon_client + + client = await ensure_daemon_client(settings) + server = DashboardServer(client=client, token=token, bind=bind, port=port) + launcher.write_state(settings, { + "port": port, + "bind": bind, + "token": token, + "pid": os.getpid(), + "url": launcher.build_url(bind, port, token), + }) + try: + await server.serve() + except (KeyboardInterrupt, asyncio.CancelledError): + pass + finally: + launcher.clear_state(settings) + return 0 + + +__all__ = ["DashboardServer", "run_server", "STATIC_DIR"] diff --git a/src/leapflow/dashboard/service.py b/src/leapflow/dashboard/service.py new file mode 100644 index 0000000..0e9c61e --- /dev/null +++ b/src/leapflow/dashboard/service.py @@ -0,0 +1,118 @@ +"""DashboardViewBuilder: turn a DashboardIntent + live data into a ViewSpec. + +The builder is transport-agnostic: it reads data through a small +``DashboardDataProvider`` protocol (satisfied by a DaemonClient adapter in the +server, or a fake in tests) and renders via the template library. Template +selection is convention-based (intent template, else a template named for the +domain), never a hardcoded domain->file map. +""" + +from __future__ import annotations + +import logging +from typing import Any, Protocol, runtime_checkable + +from leapflow.dashboard.intent import DashboardIntent +from leapflow.dashboard.templates import TemplateLibrary + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class DashboardDataProvider(Protocol): + """Read-only data access the builder needs (watches and findings).""" + + async def watches(self) -> list[dict[str, Any]]: + """Return all watch views.""" + ... + + async def findings(self, *, watch_id: str = "", limit: int = 50) -> list[dict[str, Any]]: + """Return findings, optionally scoped to a watch.""" + ... + + +class DaemonDataProvider: + """Adapt a DaemonClient's ``watch_*`` RPCs to the provider protocol.""" + + def __init__(self, client: Any) -> None: + self._client = client + + async def watches(self) -> list[dict[str, Any]]: + return list(await self._client.watch_list()) + + async def findings(self, *, watch_id: str = "", limit: int = 50) -> list[dict[str, Any]]: + return list(await self._client.watch_findings(watch_id=watch_id, limit=limit)) + + +def select_template(domain: str, intent_template: str, names: list[str]) -> str: + """Pick a template by convention: explicit -> exact domain -> domain prefix -> generic.""" + if intent_template and intent_template in names: + return intent_template + if domain: + if domain in names: + return domain + prefixed = sorted(n for n in names if n.split(".", 1)[0] == domain) + if prefixed: + return prefixed[0] + return "generic" + + +class DashboardViewBuilder: + """Assemble ViewSpecs for dashboard intents.""" + + def __init__(self, templates: TemplateLibrary | None = None) -> None: + self._templates = templates or TemplateLibrary() + + async def build(self, intent: DashboardIntent, provider: DashboardDataProvider) -> dict[str, Any]: + """Return a normalized ViewSpec for the given intent.""" + if intent.action == "session" or intent.target == "session": + return await self._build_session(intent, provider) + if intent.target and intent.action in ("watch", "open", "refresh"): + return await self._build_watch(intent, provider) + return await self._build_overview(intent, provider) + + async def _build_overview(self, intent: DashboardIntent, provider: DashboardDataProvider) -> dict[str, Any]: + watches = await provider.watches() + findings = await provider.findings(watch_id="", limit=50) + data = {"title": "LeapFlow Monitors", "watches": watches, "findings": findings} + name = "overview" if "overview" in self._templates.names() else "generic" + return self._templates.render(name, data) + + async def _build_watch(self, intent: DashboardIntent, provider: DashboardDataProvider) -> dict[str, Any]: + watches = await provider.watches() + watch = next( + (w for w in watches if str(w.get("watch_id", "")).startswith(intent.target)), + {}, + ) + domain = str(watch.get("domain", "")) + findings = await provider.findings(watch_id=str(watch.get("watch_id") or intent.target), limit=100) + data = { + "title": watch.get("name") or "Watch", + "watch": watch, + "domain": domain, + "findings": findings, + } + name = select_template(domain, intent.template, self._templates.names()) + return self._templates.render(name, data) + + async def _build_session(self, intent: DashboardIntent, provider: DashboardDataProvider) -> dict[str, Any]: + # The session watch (P3) emits an insight finding whose payload carries + # the structured analysis. Until it exists this renders gracefully empty. + findings = await provider.findings(watch_id="", limit=50) + session_findings = [f for f in findings if str(f.get("domain")) == "session"] + analysis = session_findings[0].get("payload") if session_findings else {} + data = { + "title": "Session Analysis", + "analysis": analysis or {}, + "findings": session_findings, + } + name = select_template("session", intent.template, self._templates.names()) + return self._templates.render(name, data) + + +__all__ = [ + "DashboardDataProvider", + "DaemonDataProvider", + "DashboardViewBuilder", + "select_template", +] diff --git a/src/leapflow/dashboard/static/app.js b/src/leapflow/dashboard/static/app.js new file mode 100644 index 0000000..5be9807 --- /dev/null +++ b/src/leapflow/dashboard/static/app.js @@ -0,0 +1,193 @@ +// LeapFlow dashboard: a minimal Server-Driven UI renderer. +// Fetches a ViewSpec from /api/view, renders the fixed component catalog into +// the DOM, connects a WebSocket for live monitor events, and posts interactive +// actions back to /api/action. +(function () { + "use strict"; + + const params = new URLSearchParams(location.search); + const TOKEN = params.get("token") || ""; + const rootEl = document.getElementById("root"); + const statusEl = document.getElementById("status"); + const toastsEl = document.getElementById("toasts"); + let current = { action: params.get("action") || "home", target: params.get("target") || "" }; + + function api(path) { + const url = new URL(path, location.origin); + url.searchParams.set("token", TOKEN); + return url.toString(); + } + + async function fetchView(intent) { + current = Object.assign({}, current, intent || {}); + const url = new URL("/api/view", location.origin); + url.searchParams.set("token", TOKEN); + Object.entries(current).forEach(([k, v]) => v && url.searchParams.set(k, v)); + try { + const resp = await fetch(url.toString()); + if (!resp.ok) throw new Error("HTTP " + resp.status); + render(await resp.json()); + } catch (err) { + rootEl.innerHTML = '
Failed to load view: ' + esc(String(err)) + "
"; + } + } + + async function postAction(action) { + if (action && action.kind === "nav") { handleNav(action); return; } + try { + const resp = await fetch(api("/api/action"), { + method: "POST", + headers: { "Content-Type": "application/json", "X-Dashboard-Token": TOKEN }, + body: JSON.stringify(action), + }); + const result = await resp.json(); + if (action.kind === "rpc") fetchView(); // reflect control changes + return result; + } catch (err) { + toast({ title: "Action failed", summary: String(err), severity: "alert" }); + } + } + + // nav actions are purely client-side (no server round-trip). + function handleNav(action) { + const p = action.params || {}; + if (action.name === "openWatch" && p.target) fetchView({ action: "open", target: p.target }); + else if (action.name === "openLink" && p.url) window.open(p.url, "_blank", "noopener"); + } + + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"]/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); + } + + // ── Renderers keyed by catalog type; unknown types fall back to text ── + function el(tag, cls, html) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; + } + + function renderChildren(node, parent) { + (node.children || []).forEach((c) => parent.appendChild(renderNode(c))); + return parent; + } + + function bindAction(dom, node) { + if (node.action) { + dom.style.cursor = "pointer"; + dom.addEventListener("click", (ev) => { ev.stopPropagation(); postAction(node.action); }); + } + return dom; + } + + // Escape-hatch renderers for the `Custom` component, keyed by props.render. + const CUSTOM_RENDERERS = { + candlestick: (p) => { const data = Array.isArray(p.data) ? p.data : []; + const d = el("div", "card"); d.appendChild(el("div", "card-title", "Candlestick")); + d.appendChild(el("div", "md", esc(data.length + " series"))); return d; }, + gauge: (p) => { const d = el("div", "stat"); d.appendChild(el("div", "label", "Gauge")); + d.appendChild(el("div", "value", esc(p.data != null ? p.data : "\u2014"))); return d; }, + }; + + const RENDERERS = { + Page: (n) => { const d = el("div", "page"); + const t = (n.props && n.props.title); if (t) d.appendChild(el("div", "page-title", esc(t))); + return renderChildren(n, d); }, + Section: (n) => renderChildren(n, el("div", "section")), + Grid: (n) => renderChildren(n, el("div", "grid")), + Row: (n) => renderChildren(n, el("div", "row")), + Col: (n) => renderChildren(n, el("div", "col")), + Card: (n) => { const d = el("div", "card"); + const t = n.props && n.props.title; if (t) d.appendChild(el("div", "card-title", esc(t))); + return renderChildren(n, d); }, + Board: (n) => { const d = el("div", "board"); + const t = n.props && n.props.title; if (t) d.appendChild(el("div", "board-title", esc(t))); + return renderChildren(n, d); }, + Toolbar: (n) => renderChildren(n, el("div", "toolbar")), + Stat: (n) => { const p = n.props || {}; const d = el("div", "stat"); + d.appendChild(el("div", "label", esc(p.label))); d.appendChild(el("div", "value", esc(p.value))); + return d; }, + Markdown: (n) => el("div", "md", esc((n.props || {}).text)), + StoryPanel: (n) => { const p = n.props || {}; const d = el("div", "card"); + d.appendChild(el("div", "card-title", esc(p.title || "Story"))); + d.appendChild(el("div", "md", esc(p.text))); return d; }, + List: (n) => { const items = ((n.props || {}).data) || []; const ul = el("ul"); + (Array.isArray(items) ? items : []).forEach((it) => + ul.appendChild(el("li", null, esc(typeof it === "object" ? JSON.stringify(it) : it)))); + return ul; }, + SuggestionChips: (n) => { const items = ((n.props || {}).data) || []; const d = el("div", "row"); + (Array.isArray(items) ? items : []).forEach((it) => d.appendChild(el("button", null, esc(it)))); + return d; }, + Gauge: (n) => { const p = n.props || {}; const d = el("div", "stat"); + d.appendChild(el("div", "label", esc(p.label || "Gauge"))); + d.appendChild(el("div", "value", esc(p.data != null ? p.data : (p.value != null ? p.value : "\u2014")))); return d; }, + Custom: (n) => { const p = n.props || {}; const fn = CUSTOM_RENDERERS[p.render]; + return fn ? fn(p) : el("div", "card md", esc("Custom: " + (p.render || "?"))); }, + FindingCard: renderFinding, + InsightCard: renderFinding, + Button: (n) => el("button", null, esc((n.props || {}).label || (n.props || {}).text || "Action")), + FilterBar: (n) => el("div", "toolbar", ""), + }; + + function renderFinding(node) { + const p = node.props || {}; + const sev = (p.severity || "info").toLowerCase(); + const d = el("div", "finding sev-" + sev); + d.appendChild(el("div", "sev", esc(sev))); + d.appendChild(el("div", "card-title", esc(p.title))); + if (p.summary) d.appendChild(el("div", "summary", esc(p.summary))); + return d; + } + + function renderNode(node) { + if (!node || typeof node !== "object") return el("div", "md", esc(node)); + const fn = RENDERERS[node.type]; + let dom; + if (fn) { + dom = fn(node); + } else { + dom = el("div", "card"); // safe fallback for unknown catalog types + dom.appendChild(el("div", "sev", esc(node.type || "unknown"))); + dom.appendChild(el("div", "md", esc((node.props && node.props.text) || JSON.stringify(node.props || {})))); + renderChildren(node, dom); + } + return bindAction(dom, node); + } + + function render(spec) { + rootEl.innerHTML = ""; + (spec.root || []).forEach((n) => rootEl.appendChild(renderNode(n))); + if (!(spec.root || []).length) rootEl.appendChild(el("div", "empty", "No content yet.")); + document.title = spec.title || "LeapFlow Monitors"; + } + + function toast(finding) { + const sev = (finding.severity || "info").toLowerCase(); + const t = el("div", "toast sev-" + sev); + t.appendChild(el("div", "card-title", esc(finding.title))); + if (finding.summary) t.appendChild(el("div", "summary", esc(finding.summary))); + toastsEl.appendChild(t); + setTimeout(() => t.remove(), 8000); + } + + // ── Live updates over WebSocket ── + function connectWS() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(proto + "://" + location.host + "/ws?token=" + encodeURIComponent(TOKEN)); + ws.onopen = () => { statusEl.textContent = "live"; }; + ws.onclose = () => { statusEl.textContent = "reconnecting…"; setTimeout(connectWS, 3000); }; + ws.onmessage = (ev) => { + let msg; try { msg = JSON.parse(ev.data); } catch (_) { return; } + if (msg.type === "monitor.finding") { toast(msg.payload || {}); if (current.action === "home") fetchView(); } + else if (msg.type === "watch.state") { if (current.action === "home") fetchView(); } + else if (msg.type === "view.replace" && msg.spec) { render(msg.spec); } + }; + } + + document.querySelectorAll("[data-action]").forEach((a) => + a.addEventListener("click", (ev) => { ev.preventDefault(); fetchView({ action: a.dataset.action, target: a.dataset.action === "session" ? "session" : "" }); })); + + fetchView(); + connectWS(); +})(); diff --git a/src/leapflow/dashboard/static/index.html b/src/leapflow/dashboard/static/index.html new file mode 100644 index 0000000..4d0e6a1 --- /dev/null +++ b/src/leapflow/dashboard/static/index.html @@ -0,0 +1,22 @@ + + + + + + LeapFlow Monitors + + + +
+ LeapFlow + + connecting… +
+
Loading…
+
+ + + diff --git a/src/leapflow/dashboard/static/styles.css b/src/leapflow/dashboard/static/styles.css new file mode 100644 index 0000000..061f717 --- /dev/null +++ b/src/leapflow/dashboard/static/styles.css @@ -0,0 +1,55 @@ +:root { + --bg: #0f1115; --panel: #171a21; --border: #262b36; --text: #e6e9ef; + --muted: #9aa4b2; --accent: #6ea8fe; --alert: #ff6b6b; --notable: #ffd166; --info: #8bd3a0; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--bg); color: var(--text); + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} +.topbar { + display: flex; align-items: center; gap: 16px; padding: 10px 16px; + background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; +} +.brand { font-weight: 700; color: var(--accent); } +.nav a { color: var(--muted); text-decoration: none; margin-right: 12px; } +.nav a:hover { color: var(--text); } +.status { margin-left: auto; color: var(--muted); font-size: 12px; } +.root { padding: 16px; } +.empty { color: var(--muted); padding: 40px; text-align: center; } +.page-title { font-size: 18px; font-weight: 600; margin: 4px 0 14px; } +.section { margin-bottom: 18px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } +.row { display: flex; gap: 12px; flex-wrap: wrap; } +.board { display: flex; flex-direction: column; gap: 10px; } +.board-title, .card-title { font-weight: 600; margin-bottom: 6px; } +.card, .finding { + background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + padding: 12px 14px; +} +.finding { border-left: 4px solid var(--border); cursor: pointer; } +.finding.sev-alert { border-left-color: var(--alert); } +.finding.sev-notable { border-left-color: var(--notable); } +.finding.sev-info { border-left-color: var(--info); } +.finding .sev { font-size: 11px; text-transform: uppercase; color: var(--muted); } +.finding .summary { color: var(--muted); margin-top: 4px; } +.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; } +.stat .label { color: var(--muted); font-size: 12px; } +.stat .value { font-size: 22px; font-weight: 700; } +.toolbar { display: flex; gap: 8px; margin-bottom: 12px; } +button, .btn { + background: #222736; color: var(--text); border: 1px solid var(--border); + border-radius: 8px; padding: 6px 12px; cursor: pointer; +} +button:hover { border-color: var(--accent); } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 600; } +.md { white-space: pre-wrap; } +.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; } +.toast { + background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent); + border-radius: 8px; padding: 10px 14px; max-width: 360px; box-shadow: 0 6px 24px rgba(0,0,0,.4); +} +.toast.sev-alert { border-left-color: var(--alert); } +.toast.sev-notable { border-left-color: var(--notable); } diff --git a/src/leapflow/dashboard/templates.py b/src/leapflow/dashboard/templates.py new file mode 100644 index 0000000..11cf2cc --- /dev/null +++ b/src/leapflow/dashboard/templates.py @@ -0,0 +1,203 @@ +"""YAML template rendering into a validated ViewSpec (the SDUI authoring layer). + +Templates are authored in YAML per scenario and compiled at runtime into a +ViewSpec by binding live data into a component tree. Binding is intentionally +minimal and safe -- whitelisted dotted paths and ``{{ path }}`` interpolation, +never arbitrary evaluation. A ``repeat`` directive expands one node per item in a +bound list (e.g. one FindingCard per finding). +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Mapping, Optional + +from leapflow.dashboard.viewspec import SCHEMA_VERSION, normalize_viewspec + +_INDEX_RE = re.compile(r"^(.*?)\[(\d+)\]$") +_FULL_RE = re.compile(r"^\{\{\s*(.+?)\s*\}\}$") +_PART_RE = re.compile(r"\{\{\s*(.+?)\s*\}\}") + +# Inline fallback so rendering works even if no template files are present. +_DEFAULT_GENERIC: dict[str, Any] = { + "template": "generic", + "title": "{{ title }}", + "layout": [ + { + "type": "Board", + "props": {"title": "Findings"}, + "children": [ + { + "type": "FindingCard", + "repeat": "findings", + "as": "f", + "props": { + "title": "{{ f.title }}", + "summary": "{{ f.summary }}", + "severity": "{{ f.severity }}", + "bind": "f", + }, + } + ], + } + ], +} + + +def resolve_path(data: Any, path: str) -> Any: + """Resolve a dotted path with optional ``[i]`` indices; None when missing.""" + current = data + for raw_part in str(path).split("."): + if not raw_part: + continue + part, index = raw_part, None + match = _INDEX_RE.match(raw_part) + if match: + part, index = match.group(1), int(match.group(2)) + if part: + if isinstance(current, Mapping) and part in current: + current = current[part] + else: + return None + if index is not None: + if isinstance(current, (list, tuple)) and 0 <= index < len(current): + current = current[index] + else: + return None + return current + + +def _coerce_str(value: Any) -> str: + return "" if value is None else str(value) + + +def bind_value(value: Any, data: Mapping[str, Any]) -> Any: + """Bind ``{{ path }}`` templates inside strings/dicts/lists against ``data``.""" + if isinstance(value, str): + full = _FULL_RE.match(value.strip()) + if full: + return resolve_path(data, full.group(1)) + return _PART_RE.sub(lambda m: _coerce_str(resolve_path(data, m.group(1))), value) + if isinstance(value, Mapping): + return {key: bind_value(val, data) for key, val in value.items()} + if isinstance(value, list): + return [bind_value(item, data) for item in value] + return value + + +def _bind_props(props: Mapping[str, Any], data: Mapping[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in props.items(): + if key == "bind" and isinstance(value, str): + out["data"] = resolve_path(data, value) + else: + out[key] = bind_value(value, data) + return out + + +def render_node(node: Any, data: Mapping[str, Any]) -> list[dict[str, Any]]: + """Render one template node into zero or more concrete nodes.""" + if not isinstance(node, Mapping): + return [{"type": "Markdown", "props": {"text": str(node)}}] + + repeat = node.get("repeat") + if isinstance(repeat, str) and repeat: + items = resolve_path(data, repeat) + if not isinstance(items, (list, tuple)): + return [] + as_name = str(node.get("as") or "item") + base = {key: value for key, value in node.items() if key not in ("repeat", "as")} + expanded: list[dict[str, Any]] = [] + for item in items: + scope = dict(data) + scope[as_name] = item + expanded.extend(render_node(base, scope)) + return expanded + + rendered: dict[str, Any] = {"type": str(node.get("type", ""))} + rendered["props"] = _bind_props(dict(node.get("props") or {}), data) + action = node.get("action") + if isinstance(action, Mapping): + rendered["action"] = bind_value(dict(action), data) + children = node.get("children") + if isinstance(children, list): + kids: list[dict[str, Any]] = [] + for child in children: + kids.extend(render_node(child, data)) + rendered["children"] = kids + return [rendered] + + +def render_template(template: Any, data: Optional[Mapping[str, Any]] = None) -> dict[str, Any]: + """Compile a template dict + data into a normalized, render-safe ViewSpec.""" + template = template if isinstance(template, Mapping) else {} + data = data or {} + layout = template.get("layout", template.get("root", [])) + if not isinstance(layout, list): + layout = [layout] + root: list[dict[str, Any]] = [] + for node in layout: + root.extend(render_node(node, data)) + return normalize_viewspec({ + "schema_version": template.get("schema_version", SCHEMA_VERSION), + "title": bind_value(template.get("title", ""), data), + "domain": template.get("domain", ""), + "root": root, + "meta": { + "template": template.get("template", ""), + "refresh": dict(template.get("refresh") or {}), + }, + }) + + +class TemplateLibrary: + """Loads YAML templates from a builtin dir plus an optional override dir. + + Profile-level override templates take precedence over builtin ones. + """ + + def __init__( + self, + builtin_dir: Optional[Path] = None, + override_dir: Optional[Path] = None, + ) -> None: + self._builtin = builtin_dir or (Path(__file__).parent / "templates") + self._override = override_dir + + def _dirs(self) -> list[Path]: + return [d for d in (self._override, self._builtin) if d is not None] + + def names(self) -> list[str]: + """Return available template names (override + builtin).""" + found: set[str] = set() + for directory in self._dirs(): + if directory.exists(): + for path in directory.glob("*.yaml"): + found.add(path.stem) + return sorted(found) + + def load(self, name: str) -> Optional[dict[str, Any]]: + """Load a raw template dict by name, or None when not found.""" + import yaml + + for directory in self._dirs(): + path = directory / f"{name}.yaml" + if path.exists(): + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) + return loaded if isinstance(loaded, dict) else None + return None + + def render(self, name: str, data: Optional[Mapping[str, Any]] = None) -> dict[str, Any]: + """Render a template by name, falling back to a builtin generic view.""" + template = self.load(name) or self.load("generic") or _DEFAULT_GENERIC + return render_template(template, data or {}) + + +__all__ = [ + "resolve_path", + "bind_value", + "render_node", + "render_template", + "TemplateLibrary", +] diff --git a/src/leapflow/dashboard/templates/finance.market.yaml b/src/leapflow/dashboard/templates/finance.market.yaml new file mode 100644 index 0000000..6acc3ca --- /dev/null +++ b/src/leapflow/dashboard/templates/finance.market.yaml @@ -0,0 +1,44 @@ +template: finance.market +version: 1 +title: "{{ title }}" +domain: finance +layout: + - type: Page + props: + title: "{{ title }}" + children: + - type: Row + children: + - type: Stat + props: + label: "Watch" + value: "{{ watch.name }}" + - type: Stat + props: + label: "Signals" + value: "{{ watch.finding_count }}" + - type: Card + props: + title: "Price action" + children: + - type: Custom + props: + render: candlestick + bind: findings + - type: Board + props: + title: "Signals" + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: intent + name: drilldown + params: + finding_id: "{{ f.finding_id }}" diff --git a/src/leapflow/dashboard/templates/generic.yaml b/src/leapflow/dashboard/templates/generic.yaml new file mode 100644 index 0000000..72830fa --- /dev/null +++ b/src/leapflow/dashboard/templates/generic.yaml @@ -0,0 +1,37 @@ +template: generic +version: 1 +title: "{{ title }}" +domain: generic +# Refresh policy is consumed by session/monitor producers; harmless elsewhere. +refresh: + batch_turns: 6 +layout: + - type: Page + props: + title: "{{ title }}" + children: + - type: Toolbar + children: + - type: FilterBar + props: + fields: ["severity", "domain", "watch"] + action: + kind: nav + name: filter + - type: Board + props: + title: Findings + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: intent + name: drilldown + params: + finding_id: "{{ f.finding_id }}" diff --git a/src/leapflow/dashboard/templates/overview.yaml b/src/leapflow/dashboard/templates/overview.yaml new file mode 100644 index 0000000..43bdf49 --- /dev/null +++ b/src/leapflow/dashboard/templates/overview.yaml @@ -0,0 +1,44 @@ +template: overview +version: 1 +title: "{{ title }}" +domain: "" +layout: + - type: Page + props: + title: "{{ title }}" + children: + - type: Board + props: + title: "Watches" + children: + - type: Card + repeat: watches + as: w + props: + title: "{{ w.name }}" + action: + kind: nav + name: openWatch + params: + target: "{{ w.watch_id }}" + children: + - type: Markdown + props: + text: "{{ w.domain }} · {{ w.trigger }} · {{ w.state }} · {{ w.finding_count }} findings" + - type: Board + props: + title: "Recent findings" + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: intent + name: drilldown + params: + finding_id: "{{ f.finding_id }}" diff --git a/src/leapflow/dashboard/templates/research.paper.yaml b/src/leapflow/dashboard/templates/research.paper.yaml new file mode 100644 index 0000000..49a8293 --- /dev/null +++ b/src/leapflow/dashboard/templates/research.paper.yaml @@ -0,0 +1,26 @@ +template: research.paper +version: 1 +title: "{{ title }}" +domain: research +layout: + - type: Page + props: + title: "{{ title }}" + children: + - type: Board + props: + title: "New papers" + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: nav + name: openLink + params: + url: "{{ f.payload.url }}" diff --git a/src/leapflow/dashboard/templates/sentiment.topic.yaml b/src/leapflow/dashboard/templates/sentiment.topic.yaml new file mode 100644 index 0000000..e322054 --- /dev/null +++ b/src/leapflow/dashboard/templates/sentiment.topic.yaml @@ -0,0 +1,36 @@ +template: sentiment.topic +version: 1 +title: "{{ title }}" +domain: sentiment +layout: + - type: Page + props: + title: "{{ title }}" + children: + - type: Row + children: + - type: Gauge + props: + label: "Latest sentiment" + bind: "findings[0].payload.sentiment" + - type: Stat + props: + label: "Mentions" + value: "{{ watch.finding_count }}" + - type: Board + props: + title: "Mentions" + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: intent + name: drilldown + params: + finding_id: "{{ f.finding_id }}" diff --git a/src/leapflow/dashboard/templates/session.analysis.yaml b/src/leapflow/dashboard/templates/session.analysis.yaml new file mode 100644 index 0000000..cf30db3 --- /dev/null +++ b/src/leapflow/dashboard/templates/session.analysis.yaml @@ -0,0 +1,65 @@ +template: session.analysis +version: 1 +title: "Session Analysis" +domain: session +refresh: + batch_turns: 6 + use_model_salience: true +layout: + - type: Page + props: + title: "Session Analysis" + children: + - type: StoryPanel + props: + title: "Story" + text: "{{ analysis.story }}" + - type: Grid + children: + - type: Card + props: + title: "Insights" + children: + - type: InsightCard + repeat: analysis.insights + as: i + props: + title: "{{ i.title }}" + summary: "{{ i.summary }}" + severity: "{{ i.severity }}" + bind: i + - type: Card + props: + title: "Action items" + children: + - type: List + props: + bind: analysis.action_items + - type: Card + props: + title: "Decisions" + children: + - type: List + props: + bind: analysis.decisions + - type: Card + props: + title: "Open questions" + children: + - type: List + props: + bind: analysis.open_questions + - type: Card + props: + title: "Entities" + children: + - type: List + props: + bind: analysis.entities + - type: Card + props: + title: "Suggested next prompts" + children: + - type: SuggestionChips + props: + bind: analysis.next_prompts diff --git a/src/leapflow/dashboard/viewspec.py b/src/leapflow/dashboard/viewspec.py new file mode 100644 index 0000000..f885b6d --- /dev/null +++ b/src/leapflow/dashboard/viewspec.py @@ -0,0 +1,162 @@ +"""ViewSpec: the declarative, validated UI contract for the dashboard (SDUI). + +A ViewSpec is a JSON-serializable tree of components drawn from a fixed, +versioned catalog. Templates and the engine may author ViewSpecs, but they can +only reference catalog component types and a whitelisted action protocol -- never +arbitrary HTML/JS. Unknown component types degrade to a Markdown node so a view +never fails to render. + +Node shape:: + + {"type": "Card", "props": {...}, "children": [...], "action": {...}?} + +Action shape (bidirectional protocol):: + + {"kind": "nav|rpc|intent|approval", "name": "...", "params": {...}} +""" + +from __future__ import annotations + +from typing import Any, Mapping + +SCHEMA_VERSION = 1 +FALLBACK_TYPE = "Markdown" +ACTION_KINDS = frozenset({"nav", "rpc", "intent", "approval"}) + +# Fixed, versioned component vocabulary grouped by purpose. The frontend renderer +# implements exactly these types; adding a scenario reuses them (or registers a +# ``Custom`` escape-hatch renderer), never new core code. +COMPONENT_CATALOG: dict[str, tuple[str, ...]] = { + "layout": ("Page", "Section", "Grid", "Row", "Col", "Tabs", "Tab", "Card", "Drawer", "Toolbar"), + "display": ( + "Stat", "Table", "List", "Timeline", "Board", "Markdown", "EntityGraph", + "Gauge", "ProgressBar", "Badge", + ), + "chart": ("LineChart", "AreaChart", "BarChart", "CandlestickChart", "Sparkline", "Heatmap", "PieChart"), + "evidence": ("LinkCard", "Quote", "CitationList"), + "interactive": ("Button", "FilterBar", "Select", "DateRange", "Search", "Form", "Slider", "TagInput"), + "agent": ("FindingCard", "InsightCard", "StoryPanel", "ApprovalPrompt", "SuggestionChips", "AskBox"), + "escape": ("Custom", "Raw"), +} + +COMPONENT_TYPES: frozenset[str] = frozenset( + name for group in COMPONENT_CATALOG.values() for name in group +) | {FALLBACK_TYPE} + + +class ViewSpecError(ValueError): + """Raised only by strict validation helpers, never by normalization.""" + + +def make_markdown(text: str, **props: Any) -> dict[str, Any]: + """Build a Markdown node (also the safe fallback for unknown types).""" + return {"type": "Markdown", "props": {"text": str(text), **props}} + + +def _normalize_action(action: Any) -> dict[str, Any] | None: + if not isinstance(action, Mapping): + return None + kind = str(action.get("kind", "")) + if kind not in ACTION_KINDS: + return None + result: dict[str, Any] = { + "kind": kind, + "name": str(action.get("name", "")), + "params": dict(action.get("params") or {}), + } + if action.get("confirm"): + result["confirm"] = True + return result + + +def normalize_node(node: Any) -> dict[str, Any]: + """Return a safe, catalog-valid node, degrading unknown types to Markdown.""" + if not isinstance(node, Mapping): + return make_markdown(str(node)) + ntype = str(node.get("type", "")) + if ntype not in COMPONENT_TYPES: + return make_markdown( + f"Unsupported component: {ntype or '(missing type)'}", + _unsupported=ntype, + ) + out: dict[str, Any] = {"type": ntype, "props": dict(node.get("props") or {})} + action = _normalize_action(node.get("action")) + if action is not None: + out["action"] = action + children = node.get("children") + if isinstance(children, list): + out["children"] = [normalize_node(child) for child in children] + return out + + +def normalize_viewspec(spec: Any) -> dict[str, Any]: + """Return a fully normalized, render-safe ViewSpec (never raises). + + Accepts ``root`` or legacy ``layout`` for the node list. + """ + data = spec if isinstance(spec, Mapping) else {} + root = data.get("root", data.get("layout", [])) + if not isinstance(root, list): + root = [root] + return { + "schema_version": int(data.get("schema_version", SCHEMA_VERSION) or SCHEMA_VERSION), + "title": str(data.get("title", "")), + "domain": str(data.get("domain", "")), + "root": [normalize_node(node) for node in root], + "meta": dict(data.get("meta") or {}), + } + + +def validate_viewspec(spec: Any) -> list[str]: + """Return a list of strict-validation error strings (empty when valid). + + Used by templates/tests to catch authoring mistakes; runtime rendering uses + ``normalize_viewspec`` which degrades instead of failing. + """ + errors: list[str] = [] + if not isinstance(spec, Mapping): + return ["ViewSpec must be a mapping"] + version = spec.get("schema_version", SCHEMA_VERSION) + if int(version or 0) != SCHEMA_VERSION: + errors.append(f"unsupported schema_version: {version!r} (expected {SCHEMA_VERSION})") + root = spec.get("root", spec.get("layout")) + if not isinstance(root, list): + errors.append("ViewSpec.root must be a list of components") + root = [] + + def _walk(node: Any, path: str) -> None: + if not isinstance(node, Mapping): + errors.append(f"{path}: node must be a mapping") + return + ntype = str(node.get("type", "")) + if ntype not in COMPONENT_TYPES: + errors.append(f"{path}: unknown component type {ntype or '(missing)'!r}") + action = node.get("action") + if action is not None: + if not isinstance(action, Mapping) or str(action.get("kind")) not in ACTION_KINDS: + errors.append(f"{path}: invalid action (kind must be one of {sorted(ACTION_KINDS)})") + children = node.get("children") + if children is not None: + if not isinstance(children, list): + errors.append(f"{path}: children must be a list") + else: + for i, child in enumerate(children): + _walk(child, f"{path}.children[{i}]") + + for i, node in enumerate(root): + _walk(node, f"root[{i}]") + return errors + + +__all__ = [ + "SCHEMA_VERSION", + "FALLBACK_TYPE", + "ACTION_KINDS", + "COMPONENT_CATALOG", + "COMPONENT_TYPES", + "ViewSpecError", + "make_markdown", + "normalize_node", + "normalize_viewspec", + "validate_viewspec", +] diff --git a/src/leapflow/monitor/__init__.py b/src/leapflow/monitor/__init__.py new file mode 100644 index 0000000..211cf35 --- /dev/null +++ b/src/leapflow/monitor/__init__.py @@ -0,0 +1,57 @@ +"""Domain-neutral monitoring subsystem: Watch -> Finding contract and runtime. + +Public surface: +- Contract types (``WatchSpec``, ``Finding``, ``Severity``, ``MonitorProducer``) +- ``FindingStore`` for persistence +- ``ProducerRegistry`` for per-domain observation logic +- ``MonitorManager`` orchestrating watch lifecycle, persistence, and push +""" + +from leapflow.monitor.finding_store import FindingStore +from leapflow.monitor.manager import EmitFn, MonitorManager +from leapflow.monitor.producers import ProducerRegistry +from leapflow.monitor.session_producer import ( + SessionAnalysisProducer, + SessionAnalysisServices, + ensure_session_watch, + session_watch_params, +) +from leapflow.monitor.types import ( + EVENT_ERROR, + EVENT_FINDING, + EVENT_HEARTBEAT, + EVENT_WATCH_STATE, + WATCH_KIND, + Evidence, + Finding, + MonitorProducer, + ProducerContext, + Severity, + SuggestedAction, + WatchSpec, + WatchView, +) + +__all__ = [ + "EVENT_FINDING", + "EVENT_WATCH_STATE", + "EVENT_ERROR", + "EVENT_HEARTBEAT", + "WATCH_KIND", + "Severity", + "Evidence", + "SuggestedAction", + "Finding", + "WatchSpec", + "WatchView", + "ProducerContext", + "MonitorProducer", + "FindingStore", + "ProducerRegistry", + "MonitorManager", + "EmitFn", + "SessionAnalysisProducer", + "SessionAnalysisServices", + "ensure_session_watch", + "session_watch_params", +] diff --git a/src/leapflow/monitor/finding_store.py b/src/leapflow/monitor/finding_store.py new file mode 100644 index 0000000..83f4742 --- /dev/null +++ b/src/leapflow/monitor/finding_store.py @@ -0,0 +1,200 @@ +"""DuckDB-backed persistence for monitor findings. + +Shares the daemon's single ``leap.duckdb`` connection via ``ConnectionHolder`` +(same pattern as ``TaskStore``). Findings are append-mostly with a dedup guard +keyed by ``(watch_id, dedup_key)`` so producers can re-observe without spamming. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import List, Optional, Union + +from leapflow.monitor.types import Finding, Severity +from leapflow.storage.connection import ConnectionHolder, LocalConnectionHolder +from leapflow.storage.write_buffer import execute_with_retry + +logger = logging.getLogger(__name__) + + +class FindingStore: + """Atomic CRUD and filtered queries for findings. + + Accepts a shared ``ConnectionHolder`` (daemon-owned) or a legacy ``Path`` + for standalone use in tests. + """ + + def __init__(self, source: Union[ConnectionHolder, Path, str]) -> None: + self._owns_holder = isinstance(source, (str, Path)) + if self._owns_holder: + source = LocalConnectionHolder(Path(source)) + self._holder = source + self._con = self._holder.connection + self._ensure_table() + + def close(self) -> None: + """Close the DuckDB connection if owned by this store.""" + if self._owns_holder: + self._holder.close() + + # ── Schema ─────────────────────────────────────────────────────────── + + def _ensure_table(self) -> None: + self._con.execute(""" + CREATE TABLE IF NOT EXISTS monitor_findings ( + finding_id TEXT PRIMARY KEY, + watch_id TEXT NOT NULL, + domain TEXT NOT NULL, + ts DOUBLE NOT NULL, + severity TEXT NOT NULL DEFAULT 'info', + score DOUBLE DEFAULT 0.0, + title TEXT NOT NULL, + summary TEXT DEFAULT '', + evidence TEXT DEFAULT '[]', + tags TEXT DEFAULT '[]', + suggested_actions TEXT DEFAULT '[]', + payload TEXT DEFAULT '{}', + dedup_key TEXT DEFAULT '' + ) + """) + + # ── Write ──────────────────────────────────────────────────────────── + + def save(self, finding: Finding) -> None: + """Insert or replace a finding by ``finding_id``.""" + execute_with_retry( + self._con, + """ + INSERT OR REPLACE INTO monitor_findings ( + finding_id, watch_id, domain, ts, severity, score, + title, summary, evidence, tags, suggested_actions, payload, dedup_key + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + finding.finding_id, + finding.watch_id, + finding.domain, + finding.ts, + finding.severity.value, + finding.score, + finding.title, + finding.summary, + json.dumps([item.to_dict() for item in finding.evidence], ensure_ascii=False), + json.dumps(list(finding.tags), ensure_ascii=False), + json.dumps([a.to_dict() for a in finding.suggested_actions], ensure_ascii=False), + json.dumps(dict(finding.payload), ensure_ascii=False), + finding.dedup_key, + ], + ) + + def exists_dedup(self, watch_id: str, dedup_key: str) -> bool: + """Return True if a finding with this dedup key already exists.""" + if not dedup_key: + return False + row = self._con.execute( + "SELECT 1 FROM monitor_findings WHERE watch_id = ? AND dedup_key = ? LIMIT 1", + [watch_id, dedup_key], + ).fetchone() + return row is not None + + def delete_for_watch(self, watch_id: str) -> None: + """Remove all findings belonging to a watch.""" + execute_with_retry( + self._con, + "DELETE FROM monitor_findings WHERE watch_id = ?", + [watch_id], + ) + + # ── Read ───────────────────────────────────────────────────────────── + + def list( + self, + *, + watch_id: Optional[str] = None, + min_severity: Optional[Severity] = None, + since: Optional[float] = None, + limit: int = 50, + offset: int = 0, + ) -> List[Finding]: + """Return findings newest-first with optional filters.""" + clauses: list[str] = [] + params: list[object] = [] + if watch_id: + clauses.append("watch_id = ?") + params.append(watch_id) + if since is not None: + clauses.append("ts >= ?") + params.append(since) + if min_severity is not None: + allowed = [s.value for s in Severity if s.rank >= min_severity.rank] + placeholders = ", ".join("?" for _ in allowed) + clauses.append(f"severity IN ({placeholders})") + params.extend(allowed) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.append(max(1, int(limit))) + params.append(max(0, int(offset))) + rows = self._con.execute( + f""" + SELECT finding_id, watch_id, domain, ts, severity, score, + title, summary, evidence, tags, suggested_actions, payload, dedup_key + FROM monitor_findings + {where} + ORDER BY ts DESC + LIMIT ? OFFSET ? + """, + params, + ).fetchall() + return [self._row_to_finding(row) for row in rows] + + def count(self, *, watch_id: Optional[str] = None, min_severity: Optional[Severity] = None) -> int: + """Return the number of findings matching the filters.""" + clauses: list[str] = [] + params: list[object] = [] + if watch_id: + clauses.append("watch_id = ?") + params.append(watch_id) + if min_severity is not None: + allowed = [s.value for s in Severity if s.rank >= min_severity.rank] + placeholders = ", ".join("?" for _ in allowed) + clauses.append(f"severity IN ({placeholders})") + params.extend(allowed) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + row = self._con.execute( + f"SELECT COUNT(*) FROM monitor_findings {where}", params + ).fetchone() + return int(row[0]) if row else 0 + + # ── Internal ───────────────────────────────────────────────────────── + + def _row_to_finding(self, row: tuple) -> Finding: + return Finding.from_dict({ + "finding_id": row[0], + "watch_id": row[1], + "domain": row[2], + "ts": row[3], + "severity": row[4], + "score": row[5], + "title": row[6], + "summary": row[7], + "evidence": self._safe_json(row[8], []), + "tags": self._safe_json(row[9], []), + "suggested_actions": self._safe_json(row[10], []), + "payload": self._safe_json(row[11], {}), + "dedup_key": row[12], + }) + + @staticmethod + def _safe_json(value: object, default: object) -> object: + if value is None: + return default + if isinstance(value, (list, dict)): + return value + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return default + + +__all__ = ["FindingStore"] diff --git a/src/leapflow/monitor/manager.py b/src/leapflow/monitor/manager.py new file mode 100644 index 0000000..5d4ca0a --- /dev/null +++ b/src/leapflow/monitor/manager.py @@ -0,0 +1,356 @@ +"""Watch lifecycle orchestration for the monitoring subsystem. + +``MonitorManager`` wires the domain-neutral contract to the existing scheduler: +watches are ``ArmedTask`` rows (``kind=watch``); each due tick runs the matching +producer, persists findings, and pushes qualifying ones to view clients through +an injected ``emit`` callback (the daemon NotificationBus). It owns no domain +logic and no transport -- both are injected. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import replace +from typing import Any, Callable, List, Optional + +from leapflow.monitor.finding_store import FindingStore +from leapflow.monitor.producers import ProducerRegistry +from leapflow.monitor.types import ( + EVENT_ERROR, + EVENT_FINDING, + EVENT_WATCH_STATE, + METADATA_CLIENT_COUPLED_KEY, + METADATA_KIND_KEY, + METADATA_MUTED_KEY, + WATCH_KIND, + Finding, + ProducerContext, + WatchSpec, + WatchView, +) +from leapflow.scheduler.coordinator import TaskCoordinator +from leapflow.scheduler.local_scheduler import LocalScheduler +from leapflow.scheduler.store import TaskStore +from leapflow.scheduler.types import ArmedTask, TaskState + +logger = logging.getLogger(__name__) + +# Emit signature: (event_type, payload_dict) -> None. Injected by the host. +EmitFn = Callable[[str, dict], None] + +_ACTIVE_STATES = frozenset({TaskState.ARMED.value, TaskState.WATCHING.value}) + + +def _format_trigger(task: ArmedTask) -> str: + """Return a compact human-readable trigger label for a task.""" + cfg = task.trigger_config if isinstance(task.trigger_config, dict) else {} + if task.trigger_type == "interval": + sec = float(cfg.get("interval_seconds", 0) or 0) + if sec < 60: + return f"every {int(sec)}s" + if sec < 3600: + return f"every {int(sec / 60)}m" + if sec < 86400: + return f"every {int(sec / 3600)}h" + return f"every {int(sec / 86400)}d" + if task.trigger_type == "cron": + return str(cfg.get("expression", "cron")) + if task.trigger_type == "event": + return f"event:{cfg.get('event_pattern', '?')}" + if task.trigger_type == "condition": + return f"cond:{str(cfg.get('expression', '?'))[:24]}" + return task.trigger_type + + +def _is_watch(task: ArmedTask) -> bool: + """Return True when an ArmedTask row represents a monitor watch.""" + meta = task.metadata if isinstance(task.metadata, dict) else {} + return meta.get(METADATA_KIND_KEY) == WATCH_KIND + + +class _MonitorExecutor: + """SkillExecutor adapter: run a producer, persist + push its findings.""" + + def __init__( + self, + *, + task_store: TaskStore, + finding_store: FindingStore, + producers: ProducerRegistry, + emit: Optional[EmitFn], + services: Any, + ) -> None: + self._tasks = task_store + self._findings = finding_store + self._producers = producers + self._emit = emit + self._services = services + + async def execute(self, skill_name: str, parameters: dict) -> dict: + """Run one observation cycle for a watch (skill_name == domain).""" + force = bool(parameters.get("_force", False)) + spec = WatchSpec.from_params(parameters) + task = self._tasks.load(spec.watch_id) if spec.watch_id else None + muted = bool((task.metadata or {}).get(METADATA_MUTED_KEY)) if task else False + run_count = task.run_count if task else 0 + last_run_at = task.last_run_at if task else 0.0 + + producer = self._producers.resolve(spec.domain) + if producer is None: + logger.debug( + "monitor: no producer for domain=%s (watch=%s)", spec.domain, spec.watch_id + ) + return {"ok": False, "error": f"no producer registered for domain {spec.domain!r}"} + + ctx = ProducerContext( + spec=replace(spec, muted=muted), + now=time.time(), + run_count=run_count, + last_run_at=last_run_at, + services=self._services, + force=force, + ) + try: + findings = list(await producer.observe(ctx)) + except Exception as exc: # noqa: BLE001 - producer errors are surfaced, not fatal + logger.warning("monitor: producer %s failed: %s", spec.domain, exc) + self._push(EVENT_ERROR, {"watch_id": spec.watch_id, "domain": spec.domain, + "error": str(exc)}) + return {"ok": False, "error": str(exc)} + + threshold = spec.push_threshold() + persisted = 0 + emitted = 0 + for finding in findings: + if not finding.watch_id: + finding = replace(finding, watch_id=spec.watch_id) + if finding.dedup_key and self._findings.exists_dedup(finding.watch_id, finding.dedup_key): + continue + self._findings.save(finding) + persisted += 1 + if not muted and finding.severity.rank >= threshold.rank: + self._push(EVENT_FINDING, finding.to_dict()) + emitted += 1 + return {"ok": True, "findings": persisted, "emitted": emitted} + + def _push(self, event_type: str, payload: dict) -> None: + if self._emit is None: + return + try: + self._emit(event_type, payload) + except Exception: # noqa: BLE001 - a failing sink must not break the tick + logger.debug("monitor: emit failed for %s", event_type, exc_info=True) + + +class MonitorManager: + """Owns watch lifecycle: arm, list, control, and finding retrieval. + + Local execution only in this phase; cloud dispatch remains available through + the scheduler for a later phase. Transport (``emit``) and domain producers + are injected, keeping this class domain- and platform-neutral. + """ + + def __init__( + self, + *, + holder: Any, + producers: Optional[ProducerRegistry] = None, + emit: Optional[EmitFn] = None, + services: Any = None, + tick_seconds: int = 60, + grace_seconds: float = 120.0, + ) -> None: + self._task_store = TaskStore(holder) + self._finding_store = FindingStore(holder) + self.producers = producers or ProducerRegistry() + self._emit = emit + self._executor = _MonitorExecutor( + task_store=self._task_store, + finding_store=self._finding_store, + producers=self.producers, + emit=emit, + services=services, + ) + self._scheduler = LocalScheduler( + store=self._task_store, + executor=self._executor, + tick_seconds=tick_seconds, + grace_seconds=grace_seconds, + ) + self._coordinator = TaskCoordinator( + store=self._task_store, + local_scheduler=self._scheduler, + cloud_dispatcher=None, + default_tier="local", + ) + self._started = False + + # ── Lifecycle ──────────────────────────────────────────────────────── + + async def start(self) -> None: + """Start the background tick loop (idempotent).""" + if self._started: + return + await self._scheduler.start() + self._started = True + + async def stop(self) -> None: + """Stop the background tick loop (idempotent).""" + if not self._started: + return + await self._scheduler.stop() + self._started = False + + @property + def finding_store(self) -> FindingStore: + """Expose the finding store for read-only queries by hosts.""" + return self._finding_store + + # ── Watch management ─────────────────────────────────────────────────── + + async def arm_watch(self, spec: WatchSpec) -> WatchView: + """Create and register a watch, returning its runtime view.""" + task = await self._coordinator.arm( + skill_name=spec.domain, + trigger_expr=spec.trigger_expr, + execution_tier="local", + max_runs=spec.max_runs, + parameters=spec.to_task_parameters(), + ) + # Backfill watch_id into parameters and stamp watch metadata so ticks + # and listings can identify the row without re-deriving it. + params = dict(task.parameters) if isinstance(task.parameters, dict) else {} + params["watch_id"] = task.task_id + task.parameters = params + task.metadata = { + METADATA_KIND_KEY: WATCH_KIND, + METADATA_MUTED_KEY: bool(spec.muted), + METADATA_CLIENT_COUPLED_KEY: bool(spec.client_coupled), + } + self._task_store.save(task) + self._emit_state(task) + return self._to_view(task) + + def list_watches(self) -> List[WatchView]: + """Return runtime views for all watches (newest first).""" + watches = [t for t in self._task_store.load_all() if _is_watch(t)] + watches.sort(key=lambda t: t.created_at, reverse=True) + return [self._to_view(t) for t in watches] + + def get_watch(self, watch_id: str) -> Optional[WatchView]: + """Return a single watch view, or None when absent/not a watch.""" + task = self._task_store.load(watch_id) + if task is None or not _is_watch(task): + return None + return self._to_view(task) + + def has_active_watches(self) -> bool: + """Return True when a standalone watch is armed/watching (keep-alive signal). + + Client-coupled watches (e.g. session analysis) are excluded: they only + matter while an interactive client is present and must not, by themselves, + keep the daemon alive across idle periods. + """ + for task in self._task_store.load_all(): + if not _is_watch(task) or task.state not in _ACTIVE_STATES: + continue + meta = task.metadata if isinstance(task.metadata, dict) else {} + if meta.get(METADATA_CLIENT_COUPLED_KEY): + continue + return True + return False + + def pause_watch(self, watch_id: str) -> Optional[WatchView]: + """Suspend a watch so it stops firing until resumed.""" + return self._transition(watch_id, TaskState.SUSPENDED.value) + + def resume_watch(self, watch_id: str) -> Optional[WatchView]: + """Re-arm a suspended watch.""" + return self._transition(watch_id, TaskState.ARMED.value) + + def stop_watch(self, watch_id: str) -> Optional[WatchView]: + """Terminally stop a watch (kept for history).""" + return self._transition(watch_id, TaskState.DONE.value) + + def set_muted(self, watch_id: str, muted: bool) -> Optional[WatchView]: + """Toggle whether a watch's findings are pushed to view clients.""" + task = self._task_store.load(watch_id) + if task is None or not _is_watch(task): + return None + meta = dict(task.metadata) if isinstance(task.metadata, dict) else {} + meta[METADATA_MUTED_KEY] = bool(muted) + meta.setdefault(METADATA_KIND_KEY, WATCH_KIND) + task.metadata = meta + self._task_store.save(task) + self._emit_state(task) + return self._to_view(task) + + # ── Findings ─────────────────────────────────────────────────────────── + + def list_findings( + self, + *, + watch_id: Optional[str] = None, + limit: int = 50, + offset: int = 0, + since: Optional[float] = None, + ) -> List[Finding]: + """Return persisted findings newest-first with optional filters.""" + return self._finding_store.list( + watch_id=watch_id, limit=limit, offset=offset, since=since + ) + + async def run_watch_once(self, watch_id: str, *, force: bool = False) -> dict: + """Run one observation cycle immediately (manual refresh/trigger). + + Bypasses the tick timer while reusing the same producer -> persist -> + push path, so a user-triggered refresh is identical to a scheduled one. + ``force=True`` signals producers to re-analyze even without new input. + """ + task = self._task_store.load(watch_id) + if task is None or not _is_watch(task): + return {"ok": False, "error": f"watch not found: {watch_id}"} + params = dict(task.parameters) if isinstance(task.parameters, dict) else {} + params.setdefault("watch_id", task.task_id) + if force: + params["_force"] = True + return await self._executor.execute(task.skill_name, params) + + # ── Internal ─────────────────────────────────────────────────────────── + + def _transition(self, watch_id: str, state: str) -> Optional[WatchView]: + task = self._task_store.load(watch_id) + if task is None or not _is_watch(task): + return None + self._task_store.update_state(watch_id, state) + task.state = state + self._emit_state(task) + return self._to_view(task) + + def _to_view(self, task: ArmedTask) -> WatchView: + meta = task.metadata if isinstance(task.metadata, dict) else {} + params = task.parameters if isinstance(task.parameters, dict) else {} + return WatchView( + watch_id=task.task_id, + name=str(params.get("name") or task.task_id[:8]), + domain=str(params.get("domain") or task.skill_name), + trigger=_format_trigger(task), + state=task.state, + muted=bool(meta.get(METADATA_MUTED_KEY, False)), + run_count=task.run_count, + next_due_at=task.next_due_at, + last_run_at=task.last_run_at, + finding_count=self._finding_store.count(watch_id=task.task_id), + ) + + def _emit_state(self, task: ArmedTask) -> None: + if self._emit is None: + return + try: + self._emit(EVENT_WATCH_STATE, self._to_view(task).to_dict()) + except Exception: # noqa: BLE001 - state notification is best-effort + logger.debug("monitor: watch.state emit failed", exc_info=True) + + +__all__ = ["MonitorManager", "EmitFn"] diff --git a/src/leapflow/monitor/producers.py b/src/leapflow/monitor/producers.py new file mode 100644 index 0000000..f41791b --- /dev/null +++ b/src/leapflow/monitor/producers.py @@ -0,0 +1,45 @@ +"""Producer registry: resolve per-domain observation logic by ``domain`` key. + +The registry keeps the runtime domain-agnostic. A new scenario registers a +``MonitorProducer`` for its ``domain``; core scheduling, persistence, and push +code never branch on the domain. Unknown domains resolve to ``None`` so the +manager can skip them gracefully instead of failing. +""" + +from __future__ import annotations + +import logging +from typing import Dict, Optional + +from leapflow.monitor.types import MonitorProducer + +logger = logging.getLogger(__name__) + + +class ProducerRegistry: + """Mutable registry mapping ``domain`` -> ``MonitorProducer``.""" + + def __init__(self) -> None: + self._producers: Dict[str, MonitorProducer] = {} + + def register(self, producer: MonitorProducer) -> None: + """Register (or replace) a producer for its declared domain.""" + domain = producer.domain + if not domain: + raise ValueError("MonitorProducer.domain must be a non-empty string") + self._producers[domain] = producer + logger.debug("monitor: registered producer for domain=%s", domain) + + def resolve(self, domain: str) -> Optional[MonitorProducer]: + """Return the producer for a domain, or None when unregistered.""" + return self._producers.get(domain) + + def domains(self) -> list[str]: + """Return all registered domain keys.""" + return sorted(self._producers.keys()) + + def __contains__(self, domain: object) -> bool: + return domain in self._producers + + +__all__ = ["ProducerRegistry"] diff --git a/src/leapflow/monitor/session_producer.py b/src/leapflow/monitor/session_producer.py new file mode 100644 index 0000000..c0de41c --- /dev/null +++ b/src/leapflow/monitor/session_producer.py @@ -0,0 +1,194 @@ +"""Session-analysis producer: model the current conversation as a Watch. + +``SessionAnalysisProducer`` reuses the generic Watch -> Finding machinery: on each +cycle it reads the conversation transcript (through an injected services facade), +decides whether a refresh is warranted (batch threshold, model salience, or a +forced manual refresh), and emits one insight ``Finding`` carrying a structured +analysis payload (story / insights / decisions / action items / entities / ...). + +The producer holds only in-memory checkpoints keyed by watch id; on daemon +restart it re-analyzes once, which is acceptable and self-correcting. +""" + +from __future__ import annotations + +import logging +from typing import Any, Protocol, Sequence, runtime_checkable + +from leapflow.monitor.types import Finding, ProducerContext, Severity + +logger = logging.getLogger(__name__) + +DOMAIN = "session" + +_DEFAULT_BATCH_TURNS = 6 +_DEFAULT_BATCH_TOKENS = 4000 +_DEFAULT_DEBOUNCE_S = 15.0 +_DEFAULT_MAX_PER_MIN = 4 + + +@runtime_checkable +class SessionAnalysisServices(Protocol): + """Daemon-provided capabilities the session producer needs. + + Kept as a protocol so the producer is testable with a fake and never imports + engine/LLM internals directly. + """ + + async def session_history(self) -> dict[str, Any]: + """Return {session_id, turn_count, token_count, messages:[{role,content}]}.""" + ... + + async def analyze_session( + self, messages: list[dict[str, Any]], *, prior: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Return a structured analysis payload for the transcript.""" + ... + + async def should_refresh(self, messages: list[dict[str, Any]]) -> bool: + """Return True when the model judges a refresh worthwhile (salience).""" + ... + + +def _first_line(text: str, limit: int = 180) -> str: + line = " ".join(str(text).split()) + return line if len(line) <= limit else line[: limit - 1] + "\u2026" + + +class SessionAnalysisProducer: + """Produce session-analysis findings from the live conversation transcript.""" + + def __init__(self) -> None: + self._last_turn: dict[str, int] = {} + self._last_tokens: dict[str, int] = {} + self._last_run_at: dict[str, float] = {} + self._recent_runs: dict[str, list[float]] = {} + + @property + def domain(self) -> str: + return DOMAIN + + async def observe(self, ctx: ProducerContext) -> Sequence[Finding]: + services = ctx.services + if services is None: + return [] + watch_id = ctx.spec.watch_id + params = ctx.spec.params or {} + batch_turns = int(params.get("batch_turns", _DEFAULT_BATCH_TURNS)) + batch_tokens = int(params.get("batch_tokens", _DEFAULT_BATCH_TOKENS)) + use_salience = bool(params.get("use_model_salience", False)) + debounce_s = float(params.get("debounce_s", _DEFAULT_DEBOUNCE_S)) + max_per_min = int(params.get("max_refresh_per_min", _DEFAULT_MAX_PER_MIN)) + + try: + history = await services.session_history() + except Exception as exc: # noqa: BLE001 - degrade if history unavailable + logger.debug("session producer: history unavailable: %s", exc) + return [] + turn_count = int(history.get("turn_count", 0) or 0) + token_count = int(history.get("token_count", 0) or 0) + session_id = str(history.get("session_id", "") or "") + messages = list(history.get("messages") or []) + + last_turn = self._last_turn.get(watch_id, -1) + first = last_turn < 0 + + if not ctx.force and not first and turn_count <= last_turn: + return [] # no new turns since last analysis + + if not ctx.force and not first: + if (ctx.now - self._last_run_at.get(watch_id, 0.0)) < debounce_s: + return [] + recent = [t for t in self._recent_runs.get(watch_id, []) if ctx.now - t < 60.0] + if len(recent) >= max_per_min: + return [] + + should = ( + ctx.force + or first + or (turn_count - last_turn >= batch_turns) + or (token_count - self._last_tokens.get(watch_id, 0) >= batch_tokens) + ) + if not should and use_salience and turn_count > last_turn: + try: + should = bool(await services.should_refresh(messages)) + except Exception: # noqa: BLE001 - salience is best-effort + should = False + if not should: + return [] + + try: + analysis = dict(await services.analyze_session(messages)) + except Exception as exc: # noqa: BLE001 - surface as no-op, not crash + logger.debug("session producer: analysis failed: %s", exc) + return [] + + self._last_turn[watch_id] = turn_count + self._last_tokens[watch_id] = token_count + self._last_run_at[watch_id] = ctx.now + self._recent_runs.setdefault(watch_id, []).append(ctx.now) + + analysis.setdefault("usage", {}) + analysis["usage"].update({"turns": turn_count, "tokens": token_count}) + story = str(analysis.get("story") or "") + summary = _first_line(story) if story else f"{turn_count} turns analyzed" + return [ + Finding( + watch_id=watch_id, + domain=DOMAIN, + title=f"Session analysis \u00b7 {turn_count} turns", + summary=summary, + severity=Severity.NOTABLE, + score=0.5, + tags=("session",), + payload=analysis, + dedup_key=f"{watch_id}:{session_id}:{turn_count}", + ) + ] + + +_SESSION_TRIGGER = "2m" + + +def session_watch_params(settings: Any) -> dict[str, Any]: + """Build session-watch params from settings (falls back to producer defaults).""" + if settings is None: + return {} + return { + "batch_turns": int(getattr(settings, "monitor_session_batch_turns", _DEFAULT_BATCH_TURNS)), + "batch_tokens": int(getattr(settings, "monitor_session_batch_tokens", _DEFAULT_BATCH_TOKENS)), + "use_model_salience": bool(getattr(settings, "monitor_session_use_model_salience", False)), + "debounce_s": float(getattr(settings, "monitor_session_debounce_s", _DEFAULT_DEBOUNCE_S)), + "max_refresh_per_min": int(getattr(settings, "monitor_session_max_refresh_per_min", _DEFAULT_MAX_PER_MIN)), + } + + +async def ensure_session_watch(monitors: Any, *, params: dict[str, Any] | None = None) -> str: + """Find the active session watch, or arm a new client-coupled one; return its id. + + Single source of truth for the session watch shape (trigger, coupling) so the + daemon RPC and the TUI slash handler cannot drift apart. + """ + for view in monitors.list_watches(): + if view.domain == DOMAIN and view.state in ("armed", "watching"): + return view.watch_id + from leapflow.monitor.types import WatchSpec + + view = await monitors.arm_watch(WatchSpec( + name="Session", + domain=DOMAIN, + trigger_expr=_SESSION_TRIGGER, + sensitivity="notable", + params=dict(params or {}), + client_coupled=True, + )) + return view.watch_id + + +__all__ = [ + "SessionAnalysisProducer", + "SessionAnalysisServices", + "ensure_session_watch", + "session_watch_params", + "DOMAIN", +] diff --git a/src/leapflow/monitor/types.py b/src/leapflow/monitor/types.py new file mode 100644 index 0000000..cb1c213 --- /dev/null +++ b/src/leapflow/monitor/types.py @@ -0,0 +1,335 @@ +"""Domain-neutral contract for the monitoring subsystem. + +A ``Watch`` is a persistent, proactive monitor that periodically observes a +source and emits ``Finding`` objects. The contract is domain-agnostic: finance, +sentiment, research, and session analysis differ only by ``domain`` and the +shape of ``Finding.payload`` -- never by branching in core logic. + +Watches are persisted as scheduler ``ArmedTask`` rows (``kind=watch``); this +module owns only the domain vocabulary and the serialization helpers that map a +``WatchSpec`` to/from an ``ArmedTask``. +""" + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Mapping, Protocol, Sequence, runtime_checkable + +# ── Event names broadcast on the daemon NotificationBus ────────────────── +EVENT_FINDING = "monitor.finding" +EVENT_WATCH_STATE = "watch.state" +EVENT_ERROR = "monitor.error" +EVENT_HEARTBEAT = "monitor.heartbeat" + +# ── ArmedTask.metadata markers ─────────────────────────────────────────── +WATCH_KIND = "watch" +METADATA_KIND_KEY = "kind" +METADATA_MUTED_KEY = "muted" +# Client-coupled watches (e.g. session analysis) only make sense while an +# interactive client is present; they must NOT keep the daemon alive on their own. +METADATA_CLIENT_COUPLED_KEY = "client_coupled" + +_SEVERITY_RANK = {"info": 0, "notable": 1, "alert": 2} + + +class Severity(str, Enum): + """Finding importance, driving disclosure and push gating.""" + + INFO = "info" # persist only (memory-level) + NOTABLE = "notable" # persist + passive push + ALERT = "alert" # persist + push + eligible for escalation + + @property + def rank(self) -> int: + """Return an ordinal for threshold comparisons.""" + return _SEVERITY_RANK[self.value] + + @classmethod + def coerce(cls, value: Any, default: "Severity | None" = None) -> "Severity": + """Return a Severity from a loose string, falling back to ``default``. + + ``default`` resolves to ``Severity.INFO`` when not supplied (it cannot be + referenced as a class-body default before the enum members exist). + """ + if isinstance(value, cls): + return value + try: + return cls(str(value).strip().lower()) + except ValueError: + return default if default is not None else cls.INFO + + +@dataclass(frozen=True) +class Evidence: + """A single citation/link/snippet backing a finding.""" + + kind: str = "text" # text | link | quote | metric + label: str = "" + value: str = "" + url: str = "" + + def to_dict(self) -> dict[str, Any]: + return {"kind": self.kind, "label": self.label, "value": self.value, "url": self.url} + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Evidence": + return cls( + kind=str(data.get("kind", "text")), + label=str(data.get("label", "")), + value=str(data.get("value", "")), + url=str(data.get("url", "")), + ) + + +@dataclass(frozen=True) +class SuggestedAction: + """A proposed next action a user can take on a finding. + + ``kind`` mirrors the dashboard action protocol so a suggested action can be + dispatched directly by a view client (nav | rpc | intent | approval). + """ + + name: str + label: str = "" + kind: str = "intent" # nav | rpc | intent | approval + params: Mapping[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "label": self.label or self.name, + "kind": self.kind, + "params": dict(self.params), + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "SuggestedAction": + return cls( + name=str(data.get("name", "")), + label=str(data.get("label", "")), + kind=str(data.get("kind", "intent")), + params=dict(data.get("params") or {}), + ) + + +@dataclass(frozen=True) +class Finding: + """An immutable unit of observation produced by a watch. + + ``payload`` is a domain-private escape hatch (e.g. OHLC series for finance, + author/abstract for papers). Core code never inspects it; only the matching + dashboard renderer does. + """ + + watch_id: str + domain: str + title: str + summary: str = "" + severity: Severity = Severity.INFO + score: float = 0.0 + ts: float = field(default_factory=time.time) + finding_id: str = field(default_factory=lambda: uuid.uuid4().hex) + evidence: tuple[Evidence, ...] = () + tags: tuple[str, ...] = () + suggested_actions: tuple[SuggestedAction, ...] = () + payload: Mapping[str, Any] = field(default_factory=dict) + dedup_key: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "finding_id": self.finding_id, + "watch_id": self.watch_id, + "domain": self.domain, + "title": self.title, + "summary": self.summary, + "severity": self.severity.value, + "score": self.score, + "ts": self.ts, + "evidence": [item.to_dict() for item in self.evidence], + "tags": list(self.tags), + "suggested_actions": [action.to_dict() for action in self.suggested_actions], + "payload": dict(self.payload), + "dedup_key": self.dedup_key, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Finding": + return cls( + finding_id=str(data.get("finding_id") or uuid.uuid4().hex), + watch_id=str(data.get("watch_id", "")), + domain=str(data.get("domain", "")), + title=str(data.get("title", "")), + summary=str(data.get("summary", "")), + severity=Severity.coerce(data.get("severity")), + score=float(data.get("score", 0.0) or 0.0), + ts=float(data.get("ts", 0.0) or time.time()), + evidence=tuple(Evidence.from_dict(item) for item in (data.get("evidence") or [])), + tags=tuple(str(tag) for tag in (data.get("tags") or [])), + suggested_actions=tuple( + SuggestedAction.from_dict(item) for item in (data.get("suggested_actions") or []) + ), + payload=dict(data.get("payload") or {}), + dedup_key=str(data.get("dedup_key", "")), + ) + + +@dataclass(frozen=True) +class WatchSpec: + """Declarative definition of a monitor, persisted inside an ArmedTask. + + ``sensitivity`` is the minimum severity that will be pushed to view clients; + lower-severity findings are still persisted (memory-level). + """ + + name: str + domain: str + trigger_expr: str = "10m" + source: Mapping[str, Any] = field(default_factory=dict) + lens: Mapping[str, Any] = field(default_factory=dict) + sensitivity: str = "notable" # info | notable | alert + params: Mapping[str, Any] = field(default_factory=dict) + max_runs: int = -1 + execution_tier: str = "local" + watch_id: str = "" + muted: bool = False + client_coupled: bool = False + + def push_threshold(self) -> Severity: + """Return the minimum severity that should be pushed to clients.""" + return Severity.coerce(self.sensitivity, default=Severity.NOTABLE) + + def to_task_parameters(self) -> dict[str, Any]: + """Serialize spec content into ``ArmedTask.parameters``.""" + return { + "watch_id": self.watch_id, + "name": self.name, + "domain": self.domain, + "trigger_expr": self.trigger_expr, + "source": dict(self.source), + "lens": dict(self.lens), + "sensitivity": self.sensitivity, + "params": dict(self.params), + } + + @classmethod + def from_params(cls, parameters: Mapping[str, Any], *, muted: bool = False) -> "WatchSpec": + """Reconstruct a WatchSpec from ``ArmedTask.parameters``.""" + params = parameters if isinstance(parameters, Mapping) else {} + return cls( + watch_id=str(params.get("watch_id", "")), + name=str(params.get("name", "")), + domain=str(params.get("domain", "")), + trigger_expr=str(params.get("trigger_expr", "10m")), + source=dict(params.get("source") or {}), + lens=dict(params.get("lens") or {}), + sensitivity=str(params.get("sensitivity", "notable")), + params=dict(params.get("params") or {}), + muted=bool(muted), + ) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "WatchSpec": + """Build a spec from a client request dict (RPC/CLI/natural language).""" + data = data if isinstance(data, Mapping) else {} + return cls( + name=str(data.get("name", "")).strip() or "watch", + domain=str(data.get("domain", "")).strip(), + trigger_expr=str(data.get("trigger_expr") or data.get("trigger") or "10m").strip(), + source=dict(data.get("source") or {}), + lens=dict(data.get("lens") or {}), + sensitivity=str(data.get("sensitivity", "notable")).strip() or "notable", + params=dict(data.get("params") or {}), + max_runs=int(data.get("max_runs", -1) or -1), + execution_tier=str(data.get("execution_tier", "local")).strip() or "local", + watch_id=str(data.get("watch_id", "")), + muted=bool(data.get("muted", False)), + client_coupled=bool(data.get("client_coupled", False)), + ) + + +@dataclass(frozen=True) +class WatchView: + """Runtime snapshot of a watch for listing and status reporting.""" + + watch_id: str + name: str + domain: str + trigger: str + state: str + muted: bool + run_count: int + next_due_at: float + last_run_at: float + finding_count: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "watch_id": self.watch_id, + "name": self.name, + "domain": self.domain, + "trigger": self.trigger, + "state": self.state, + "muted": self.muted, + "run_count": self.run_count, + "next_due_at": self.next_due_at, + "last_run_at": self.last_run_at, + "finding_count": self.finding_count, + } + + +@dataclass(frozen=True) +class ProducerContext: + """Inputs handed to a producer for one observation cycle. + + ``services`` is an opaque, daemon-provided facade exposing capabilities a + producer may need (skill execution, session history, LLM). It is optional so + producers and tests can run without a live runtime. + """ + + spec: WatchSpec + now: float + run_count: int = 0 + last_run_at: float = 0.0 + services: Any = None + force: bool = False + + +@runtime_checkable +class MonitorProducer(Protocol): + """Per-domain observation logic: observe a source, emit findings. + + Producers own the domain-specific observe -> normalize -> score -> dedup + steps; the ``Finding`` schema and the emit/persist path stay universal. + """ + + @property + def domain(self) -> str: + """Domain key this producer serves (matches ``WatchSpec.domain``).""" + ... + + async def observe(self, ctx: ProducerContext) -> Sequence[Finding]: + """Return findings for this cycle (possibly empty).""" + ... + + +__all__ = [ + "EVENT_FINDING", + "EVENT_WATCH_STATE", + "EVENT_ERROR", + "EVENT_HEARTBEAT", + "WATCH_KIND", + "METADATA_KIND_KEY", + "METADATA_MUTED_KEY", + "METADATA_CLIENT_COUPLED_KEY", + "Severity", + "Evidence", + "SuggestedAction", + "Finding", + "WatchSpec", + "WatchView", + "ProducerContext", + "MonitorProducer", +] diff --git a/tests/test_dashboard_domains.py b/tests/test_dashboard_domains.py new file mode 100644 index 0000000..732318c --- /dev/null +++ b/tests/test_dashboard_domains.py @@ -0,0 +1,83 @@ +"""Hermetic tests for P4 domain templates and the custom-component escape hatch.""" + +from __future__ import annotations + +from leapflow.dashboard import TemplateLibrary, normalize_viewspec, select_template +from leapflow.dashboard.viewspec import COMPONENT_TYPES + + +def _flatten(spec: dict) -> list[dict]: + flat: list[dict] = [] + + def _walk(nodes: list) -> None: + for node in nodes: + flat.append(node) + _walk(node.get("children") or []) + + _walk(spec["root"]) + return flat + + +def _types(spec: dict) -> set[str]: + return {n["type"] for n in _flatten(spec)} + + +def test_domain_templates_are_available() -> None: + names = TemplateLibrary().names() + for name in ("finance.market", "sentiment.topic", "research.paper", "session.analysis", "generic"): + assert name in names + + +def test_select_template_maps_domains_to_templates() -> None: + names = TemplateLibrary().names() + assert select_template("finance", "", names) == "finance.market" + assert select_template("sentiment", "", names) == "sentiment.topic" + assert select_template("research", "", names) == "research.paper" + + +def test_finance_template_uses_custom_candlestick() -> None: + lib = TemplateLibrary() + spec = lib.render("finance.market", { + "title": "AAPL", "watch": {"name": "AAPL", "finding_count": 2}, + "findings": [ + {"finding_id": "f1", "title": "volume spike", "summary": "3x", "severity": "alert", + "payload": {"ohlc": [[1, 2, 3, 4]]}}, + ], + }) + types = _types(spec) + assert "Custom" in types # escape-hatch candlestick component + assert "Stat" in types + assert len([n for n in _flatten(spec) if n["type"] == "FindingCard"]) == 1 + custom = next(n for n in _flatten(spec) if n["type"] == "Custom") + assert custom["props"]["render"] == "candlestick" + assert custom["props"]["data"] # findings bound in + + +def test_sentiment_template_binds_gauge_value() -> None: + lib = TemplateLibrary() + spec = lib.render("sentiment.topic", { + "title": "BrandX", "watch": {"finding_count": 1}, + "findings": [{"finding_id": "s1", "title": "spike", "summary": "", "severity": "notable", + "payload": {"sentiment": 0.82}}], + }) + gauge = next(n for n in _flatten(spec) if n["type"] == "Gauge") + assert gauge["props"]["data"] == 0.82 # bound from findings[0].payload.sentiment + + +def test_research_template_binds_paper_link_action() -> None: + lib = TemplateLibrary() + spec = lib.render("research.paper", { + "title": "arXiv cs.CL", + "findings": [{"finding_id": "p1", "title": "A paper", "summary": "abstract", "severity": "info", + "payload": {"url": "http://arxiv.org/abs/x"}}], + }) + cards = [n for n in _flatten(spec) if n["type"] == "FindingCard"] + assert len(cards) == 1 + assert cards[0]["action"]["kind"] == "nav" + assert cards[0]["action"]["params"]["url"] == "http://arxiv.org/abs/x" + + +def test_custom_component_is_in_catalog_and_survives_normalize() -> None: + assert "Custom" in COMPONENT_TYPES + spec = normalize_viewspec({"root": [{"type": "Custom", "props": {"render": "candlestick"}}]}) + assert spec["root"][0]["type"] == "Custom" diff --git a/tests/test_dashboard_launcher.py b/tests/test_dashboard_launcher.py new file mode 100644 index 0000000..b628c6f --- /dev/null +++ b/tests/test_dashboard_launcher.py @@ -0,0 +1,132 @@ +"""Hermetic tests for the dashboard launcher and server action dispatch. + +No aiohttp required: the launcher is dependency-free and DashboardServer's +action dispatch only touches the injected client. The aiohttp app wiring is +covered by an importorskip guard. +""" + +from __future__ import annotations + +import socket +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from leapflow.dashboard import launcher +from leapflow.dashboard.server import DashboardServer + + +def _settings(tmp_path: Path) -> SimpleNamespace: + return SimpleNamespace( + runtime_dir=tmp_path, + dashboard_bind="127.0.0.1", + dashboard_port=8765, + dashboard_auto_open=True, + ) + + +# ── launcher ──────────────────────────────────────────────────────────────── + + +def test_build_url_includes_token_and_host() -> None: + url = launcher.build_url("0.0.0.0", 9000, "abc") + assert url == "http://127.0.0.1:9000/?token=abc" + + +def test_generate_token_is_unique() -> None: + assert launcher.generate_token() != launcher.generate_token() + + +def test_state_roundtrip(tmp_path: Path) -> None: + settings = _settings(tmp_path) + assert launcher.load_state(settings) is None + launcher.write_state(settings, {"port": 1, "bind": "127.0.0.1", "token": "t"}) + assert launcher.load_state(settings)["token"] == "t" + launcher.clear_state(settings) + assert launcher.load_state(settings) is None + + +def test_server_running_requires_open_port(tmp_path: Path) -> None: + settings = _settings(tmp_path) + launcher.write_state(settings, {"port": 65534, "bind": "127.0.0.1", "token": "t"}) + assert launcher.server_running(settings) is None # nothing listening + + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + try: + port = srv.getsockname()[1] + launcher.write_state(settings, {"port": port, "bind": "127.0.0.1", "token": "t"}) + state = launcher.server_running(settings) + assert state is not None and state["port"] == port + finally: + srv.close() + + +def test_open_in_browser_handles_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(launcher.webbrowser, "open", lambda url, new=0: True) + assert launcher.open_in_browser("http://x") is True + + def _boom(url, new=0): + raise RuntimeError("no display") + + monkeypatch.setattr(launcher.webbrowser, "open", _boom) + assert launcher.open_in_browser("http://x") is False + + +def test_ensure_server_requires_aiohttp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings = _settings(tmp_path) + monkeypatch.setattr(launcher, "aiohttp_available", lambda: False) + with pytest.raises(RuntimeError, match="aiohttp"): + launcher.ensure_server(settings) + + +# ── DashboardServer.dispatch_action (allow-listed, transport-free) ─────────── + + +class _FakeClient: + def __init__(self) -> None: + self.calls: list[tuple] = [] + + async def watch_pause(self, watch_id: str) -> dict: + self.calls.append(("pause", watch_id)) + return {"state": "suspended"} + + async def watch_mute(self, watch_id: str, *, muted: bool = True) -> dict: + self.calls.append(("mute", watch_id, muted)) + return {"muted": muted} + + async def approval_resolve(self, pending_id: str, decision: str) -> dict: + self.calls.append(("approval", pending_id, decision)) + return {"ok": True} + + +async def test_dispatch_action_rpc_allowlist_and_kinds() -> None: + client = _FakeClient() + server = DashboardServer(client=client, token="t") + + ok = await server.dispatch_action({"kind": "rpc", "name": "watch.pause", "params": {"watch_id": "w1"}}) + assert ok["ok"] is True and ("pause", "w1") in client.calls + + denied = await server.dispatch_action({"kind": "rpc", "name": "daemon.shutdown"}) + assert denied["ok"] is False + + nav = await server.dispatch_action({"kind": "nav", "name": "filter"}) + assert nav["ok"] is True + + intent = await server.dispatch_action({"kind": "intent", "name": "drilldown"}) + assert intent["queued"] is True + + await server.dispatch_action({"kind": "approval", "params": {"pending_id": "p", "decision": "allow"}}) + assert ("approval", "p", "allow") in client.calls + + unknown = await server.dispatch_action({"kind": "weird"}) + assert unknown["ok"] is False + + +def test_server_build_app_requires_aiohttp() -> None: + pytest.importorskip("aiohttp") + server = DashboardServer(client=_FakeClient(), token="t") + app = server.build_app() + assert app is not None diff --git a/tests/test_dashboard_sdui.py b/tests/test_dashboard_sdui.py new file mode 100644 index 0000000..f2b3141 --- /dev/null +++ b/tests/test_dashboard_sdui.py @@ -0,0 +1,163 @@ +"""Hermetic tests for the dashboard SDUI core: ViewSpec, templates, intent.""" + +from __future__ import annotations + +from leapflow.dashboard import ( + DashboardIntent, + TemplateLibrary, + normalize_viewspec, + render_template, + validate_viewspec, +) +from leapflow.dashboard.templates import bind_value, resolve_path + + +# ── ViewSpec catalog + validation + fallback ─────────────────────────────── + + +def test_normalize_degrades_unknown_component_to_markdown() -> None: + spec = normalize_viewspec({ + "title": "T", + "root": [ + {"type": "Card", "children": [{"type": "Nonexistent", "props": {"x": 1}}]}, + ], + }) + assert spec["schema_version"] == 1 + card = spec["root"][0] + assert card["type"] == "Card" + child = card["children"][0] + assert child["type"] == "Markdown" + assert "Unsupported component" in child["props"]["text"] + + +def test_normalize_keeps_valid_action_and_drops_invalid() -> None: + spec = normalize_viewspec({ + "root": [ + {"type": "Button", "action": {"kind": "rpc", "name": "watch.pause", "params": {"id": "x"}}}, + {"type": "Button", "action": {"kind": "evil", "name": "hack"}}, + ], + }) + assert spec["root"][0]["action"]["kind"] == "rpc" + assert "action" not in spec["root"][1] + + +def test_validate_reports_unknown_type_and_bad_action() -> None: + errors = validate_viewspec({ + "schema_version": 1, + "root": [ + {"type": "Bogus"}, + {"type": "Button", "action": {"kind": "nope"}}, + ], + }) + assert any("unknown component type" in e for e in errors) + assert any("invalid action" in e for e in errors) + + +def test_validate_accepts_clean_spec() -> None: + assert validate_viewspec({ + "schema_version": 1, + "root": [{"type": "Card", "children": [{"type": "Markdown", "props": {"text": "hi"}}]}], + }) == [] + + +# ── Template binding ──────────────────────────────────────────────────────── + + +def test_resolve_path_supports_dots_and_indices() -> None: + data = {"a": {"b": [{"c": 7}]}} + assert resolve_path(data, "a.b[0].c") == 7 + assert resolve_path(data, "a.missing") is None + assert resolve_path(data, "a.b[5]") is None + + +def test_bind_value_full_and_interpolated() -> None: + data = {"finding": {"title": "Spike", "score": 0.9}} + assert bind_value("{{ finding.score }}", data) == 0.9 # full match preserves type + assert bind_value("T: {{ finding.title }}", data) == "T: Spike" # interpolation -> str + + +def test_render_template_repeat_and_bind() -> None: + template = { + "template": "demo", + "title": "Watch {{ name }}", + "layout": [ + { + "type": "Board", + "children": [ + { + "type": "FindingCard", + "repeat": "findings", + "as": "f", + "props": {"title": "{{ f.title }}", "bind": "f"}, + } + ], + } + ], + } + data = {"name": "AAPL", "findings": [{"title": "a"}, {"title": "b"}]} + spec = render_template(template, data) + assert spec["title"] == "Watch AAPL" + cards = spec["root"][0]["children"] + assert [c["props"]["title"] for c in cards] == ["a", "b"] + assert cards[0]["props"]["data"] == {"title": "a"} # bind -> data + + +def test_render_template_repeat_missing_list_yields_no_children() -> None: + template = {"layout": [{"type": "Board", "children": [ + {"type": "FindingCard", "repeat": "nope", "props": {}}]}]} + spec = render_template(template, {}) + assert spec["root"][0]["children"] == [] + + +def test_template_library_generic_renders_findings() -> None: + lib = TemplateLibrary() + assert "generic" in lib.names() + spec = lib.render("generic", {"title": "Overview", "findings": [ + {"finding_id": "1", "title": "x", "summary": "s", "severity": "alert"}, + ]}) + assert spec["title"] == "Overview" + # A FindingCard should have been produced somewhere in the tree. + flat: list[dict] = [] + + def _walk(nodes: list) -> None: + for n in nodes: + flat.append(n) + _walk(n.get("children") or []) + + _walk(spec["root"]) + finding_cards = [n for n in flat if n["type"] == "FindingCard"] + assert len(finding_cards) == 1 + assert finding_cards[0]["action"]["kind"] == "intent" + + +def test_template_library_unknown_falls_back_to_generic() -> None: + lib = TemplateLibrary() + spec = lib.render("does-not-exist", {"title": "F", "findings": []}) + assert spec["meta"]["template"] == "generic" + + +# ── DashboardIntent (dual entry) ──────────────────────────────────────────── + + +def test_intent_from_args_action_first() -> None: + intent = DashboardIntent.from_args("session") + assert intent.action == "session" and intent.target == "session" + + +def test_intent_from_args_bare_domain_becomes_new() -> None: + intent = DashboardIntent.from_args("finance --name Market --trigger 5m") + assert intent.action == "new" + assert intent.domain == "finance" + assert intent.params == {"name": "Market", "trigger": "5m"} + + +def test_intent_from_args_target_and_template() -> None: + assert DashboardIntent.from_args("open abc123").target == "abc123" + assert DashboardIntent.from_args("new research --template research.paper").template == "research.paper" + + +def test_intent_from_params_normalizes_unknown_action() -> None: + intent = DashboardIntent.from_params({"action": "bogus", "domain": "x"}) + assert intent.action == "open" + assert intent.domain == "x" + assert DashboardIntent.from_params({}).to_dict()["action"] == "open" diff --git a/tests/test_dashboard_view.py b/tests/test_dashboard_view.py new file mode 100644 index 0000000..8752f15 --- /dev/null +++ b/tests/test_dashboard_view.py @@ -0,0 +1,148 @@ +"""Hermetic tests for the dashboard view builder and WebSocket fan-out hub.""" + +from __future__ import annotations + +from typing import Any + +from leapflow.dashboard import ( + DashboardIntent, + DashboardViewBuilder, + TemplateLibrary, + ViewHub, + select_template, +) + + +class _FakeProvider: + def __init__(self, watches: list[dict], findings: list[dict]) -> None: + self._watches = watches + self._findings = findings + + async def watches(self) -> list[dict[str, Any]]: + return list(self._watches) + + async def findings(self, *, watch_id: str = "", limit: int = 50) -> list[dict[str, Any]]: + items = [f for f in self._findings if f.get("watch_id") == watch_id] if watch_id else list(self._findings) + return items[:limit] + + +def _finding_cards(spec: dict) -> list[dict]: + flat: list[dict] = [] + + def _walk(nodes: list) -> None: + for node in nodes: + flat.append(node) + _walk(node.get("children") or []) + + _walk(spec["root"]) + return [n for n in flat if n["type"] == "FindingCard"] + + +# ── select_template convention ───────────────────────────────────────────── + + +def test_select_template_prefers_explicit_then_domain_then_generic() -> None: + names = ["generic", "finance.market", "session.analysis"] + assert select_template("finance", "", names) == "finance.market" + assert select_template("", "session.analysis", names) == "session.analysis" + assert select_template("unknown", "", names) == "generic" + assert select_template("finance", "missing", names) == "finance.market" + + +# ── DashboardViewBuilder ──────────────────────────────────────────────────── + + +async def test_builder_overview_renders_findings() -> None: + provider = _FakeProvider( + watches=[{"watch_id": "w1", "name": "M", "domain": "finance", "state": "armed"}], + findings=[{"finding_id": "f1", "watch_id": "w1", "domain": "finance", "title": "spike", "severity": "alert"}], + ) + builder = DashboardViewBuilder(TemplateLibrary()) + spec = await builder.build(DashboardIntent(action="home"), provider) + assert spec["title"] == "LeapFlow Monitors" + assert len(_finding_cards(spec)) == 1 + + +async def test_builder_overview_lists_watch_lanes() -> None: + provider = _FakeProvider( + watches=[{"watch_id": "w1", "name": "Market", "domain": "finance", + "trigger": "every 5m", "state": "armed", "finding_count": 3}], + findings=[], + ) + builder = DashboardViewBuilder(TemplateLibrary()) + spec = await builder.build(DashboardIntent(action="home"), provider) + + flat: list[dict] = [] + + def _walk(nodes: list) -> None: + for node in nodes: + flat.append(node) + _walk(node.get("children") or []) + + _walk(spec["root"]) + watch_cards = [n for n in flat if n["type"] == "Card" and n.get("action", {}).get("name") == "openWatch"] + assert len(watch_cards) == 1 + assert watch_cards[0]["action"]["params"]["target"] == "w1" + + +async def test_builder_watch_scopes_to_target() -> None: + provider = _FakeProvider( + watches=[{"watch_id": "abc123", "name": "Market", "domain": "finance", "state": "armed"}], + findings=[{"finding_id": "f1", "watch_id": "abc123", "domain": "finance", "title": "t", "severity": "notable"}], + ) + builder = DashboardViewBuilder(TemplateLibrary()) + spec = await builder.build(DashboardIntent(action="open", target="abc1"), provider) + assert spec["title"] == "Market" + assert len(_finding_cards(spec)) == 1 + + +async def test_builder_session_uses_analysis_payload() -> None: + provider = _FakeProvider( + watches=[], + findings=[ + {"finding_id": "s1", "watch_id": "s", "domain": "session", "title": "analysis", + "severity": "notable", "payload": { + "story": "the arc", + "insights": [{"title": "i", "summary": "s", "severity": "notable"}], + "next_prompts": ["p"], + }}, + {"finding_id": "x1", "watch_id": "w", "domain": "finance", "title": "noise", "severity": "info"}, + ], + ) + builder = DashboardViewBuilder(TemplateLibrary()) + spec = await builder.build(DashboardIntent(action="session"), provider) + assert spec["title"] == "Session Analysis" + + flat: list[dict] = [] + + def _walk(nodes: list) -> None: + for node in nodes: + flat.append(node) + _walk(node.get("children") or []) + + _walk(spec["root"]) + types = {n["type"] for n in flat} + assert "StoryPanel" in types + assert len([n for n in flat if n["type"] == "InsightCard"]) == 1 # from analysis payload + + +# ── ViewHub fan-out ───────────────────────────────────────────────────────── + + +async def test_view_hub_broadcast_and_unsubscribe() -> None: + hub = ViewHub() + queue = hub.subscribe("a") + assert hub.broadcast({"type": "monitor.finding", "payload": {"x": 1}}) == 1 + assert (await queue.get())["type"] == "monitor.finding" + hub.unsubscribe("a") + assert hub.broadcast({"type": "x"}) == 0 + assert hub.subscriber_count == 0 + + +async def test_view_hub_backpressure_drops_when_full() -> None: + hub = ViewHub(maxsize=1) + hub.subscribe("slow") + assert hub.broadcast({"n": 1}) == 1 + assert hub.broadcast({"n": 2}) == 0 # queue full -> dropped, not blocked + await hub.shutdown() + assert hub.subscriber_count == 0 diff --git a/tests/test_dashboard_watch_rpc.py b/tests/test_dashboard_watch_rpc.py new file mode 100644 index 0000000..6861c01 --- /dev/null +++ b/tests/test_dashboard_watch_rpc.py @@ -0,0 +1,102 @@ +"""Hermetic tests for the watch RPC surface and the /dashboard command handler. + +No network, no LLM, no full Context: the daemon service and slash handler are +exercised with an in-memory MonitorManager and a fake producer. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from leapflow.cli.commands.slash_handlers import command_execute +from leapflow.daemon.service import RuntimeLeapService +from leapflow.monitor import Finding, MonitorManager, ProducerRegistry, Severity +from leapflow.monitor.types import ProducerContext +from leapflow.storage.connection import LocalConnectionHolder + + +class _DemoProducer: + @property + def domain(self) -> str: + return "demo" + + async def observe(self, ctx: ProducerContext) -> list[Finding]: + return [Finding(watch_id="", domain="demo", title="hit", severity=Severity.ALERT, dedup_key="d1")] + + +def _manager(tmp_path: Path, emit=None) -> MonitorManager: + producers = ProducerRegistry() + producers.register(_DemoProducer()) + return MonitorManager( + holder=LocalConnectionHolder(tmp_path / "leap.duckdb"), + producers=producers, + emit=emit, + ) + + +async def test_service_watch_rpc_roundtrip(tmp_path: Path) -> None: + emitted: list[tuple[str, dict]] = [] + service = RuntimeLeapService(SimpleNamespace()) + service._monitors = _manager(tmp_path, emit=lambda et, p: emitted.append((et, p))) + + view = await service.watch_arm({"name": "D", "domain": "demo", "sensitivity": "notable"}) + watch_id = view["watch_id"] + assert service.has_active_watches() is True + + assert len(await service.watch_list()) == 1 + assert (await service.watch_get(watch_id))["domain"] == "demo" + + result = await service.watch_refresh(watch_id) + assert result["ok"] is True and result["findings"] == 1 + assert any(et == "monitor.finding" for et, _ in emitted) + assert len(await service.watch_findings(watch_id)) == 1 + + assert (await service.watch_pause(watch_id))["state"] == "suspended" + assert service.has_active_watches() is False + assert (await service.watch_resume(watch_id))["state"] == "armed" + assert (await service.watch_mute(watch_id, muted=True))["muted"] is True + assert (await service.watch_stop(watch_id))["state"] == "done" + + +async def test_service_watch_unavailable_is_graceful() -> None: + service = RuntimeLeapService(SimpleNamespace()) + # No monitor runtime attached (scheduler disabled). + assert service.has_active_watches() is False + assert await service.watch_list() == [] + assert await service.watch_findings() == [] + + +async def test_dashboard_command_execute_flow(tmp_path: Path) -> None: + ctx = SimpleNamespace(monitors=_manager(tmp_path)) + + armed = await command_execute(ctx, "dashboard new", "demo --name Market --trigger 5m") + assert armed["ok"] is True and armed["mode"] == "armed" + watch_id = armed["watch"]["watch_id"] + assert armed["watch"]["trigger"] == "every 5m" + + listed = await command_execute(ctx, "dashboard list", "") + assert listed["mode"] == "list" and len(listed["watches"]) == 1 + + status_payload = await command_execute(ctx, "dashboard status", "") + assert status_payload["mode"] == "status" and status_payload["count"] == 1 + + # Short-prefix id resolution + manual refresh. + refreshed = await command_execute(ctx, "dashboard refresh", watch_id[:8]) + assert refreshed["mode"] == "refresh" and refreshed["ok"] is True + + finds = await command_execute(ctx, "dashboard findings", "") + assert finds["mode"] == "findings" and len(finds["findings"]) >= 1 + + paused = await command_execute(ctx, "dashboard pause", watch_id[:8]) + assert paused["watch"]["state"] == "suspended" + + unknown = await command_execute(ctx, "dashboard", "bogus") + assert unknown["ok"] is False + + +async def test_dashboard_command_scheduler_disabled(tmp_path: Path) -> None: + ctx = SimpleNamespace(monitors=None) + disabled = await command_execute(ctx, "dashboard list", "") + assert disabled["ok"] is False + assert "unavailable" in disabled["message"].lower() diff --git a/tests/test_monitor_subsystem.py b/tests/test_monitor_subsystem.py new file mode 100644 index 0000000..1837446 --- /dev/null +++ b/tests/test_monitor_subsystem.py @@ -0,0 +1,217 @@ +"""Hermetic tests for the domain-neutral monitor subsystem (Watch -> Finding). + +No network, no LLM: uses a temporary DuckDB and a fake in-process producer. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from leapflow.monitor import ( + EVENT_FINDING, + Evidence, + Finding, + MonitorManager, + ProducerRegistry, + Severity, + SuggestedAction, + WatchSpec, +) +from leapflow.monitor.finding_store import FindingStore +from leapflow.monitor.types import ProducerContext +from leapflow.storage.connection import LocalConnectionHolder + + +class _FakeProducer: + """Deterministic producer returning a fixed list of findings per cycle.""" + + def __init__(self, domain: str, findings: list[Finding]) -> None: + self._domain = domain + self._findings = findings + self.calls = 0 + + @property + def domain(self) -> str: + return self._domain + + async def observe(self, ctx: ProducerContext) -> list[Finding]: + self.calls += 1 + return list(self._findings) + + +def _holder(tmp_path: Path) -> LocalConnectionHolder: + return LocalConnectionHolder(tmp_path / "leap.duckdb") + + +# ── Contract serialization ──────────────────────────────────────────────── + + +def test_finding_roundtrip_preserves_fields() -> None: + finding = Finding( + watch_id="w1", + domain="finance", + title="AAPL spike", + summary="Unusual volume", + severity=Severity.ALERT, + score=0.87, + evidence=(Evidence(kind="link", label="chart", url="http://x/y"),), + tags=("volume", "equity"), + suggested_actions=(SuggestedAction(name="drilldown", label="Open", kind="nav"),), + payload={"ohlc": [[1, 2, 3, 4]]}, + dedup_key="aapl-2026-07-16", + ) + restored = Finding.from_dict(finding.to_dict()) + assert restored.watch_id == "w1" + assert restored.domain == "finance" + assert restored.severity is Severity.ALERT + assert restored.tags == ("volume", "equity") + assert restored.evidence[0].url == "http://x/y" + assert restored.suggested_actions[0].kind == "nav" + assert restored.payload == {"ohlc": [[1, 2, 3, 4]]} + assert restored.dedup_key == "aapl-2026-07-16" + + +def test_watchspec_params_roundtrip() -> None: + spec = WatchSpec( + name="ArXiv NLP", + domain="research", + trigger_expr="30m", + source={"feed": "arxiv:cs.CL"}, + lens={"keywords": ["agent"]}, + sensitivity="alert", + watch_id="wid", + ) + restored = WatchSpec.from_params(spec.to_task_parameters()) + assert restored.name == "ArXiv NLP" + assert restored.domain == "research" + assert restored.trigger_expr == "30m" + assert restored.source == {"feed": "arxiv:cs.CL"} + assert restored.push_threshold() is Severity.ALERT + + +def test_severity_coerce_and_rank() -> None: + assert Severity.coerce("alert") is Severity.ALERT + assert Severity.coerce("bogus") is Severity.INFO + assert Severity.ALERT.rank > Severity.NOTABLE.rank > Severity.INFO.rank + + +# ── FindingStore ──────────────────────────────────────────────────────────── + + +def test_finding_store_crud_dedup_and_filters(tmp_path: Path) -> None: + store = FindingStore(_holder(tmp_path)) + store.save(Finding(watch_id="w1", domain="d", title="a", severity=Severity.INFO, ts=100.0, dedup_key="k1")) + store.save(Finding(watch_id="w1", domain="d", title="b", severity=Severity.ALERT, ts=200.0, dedup_key="k2")) + store.save(Finding(watch_id="w2", domain="d", title="c", severity=Severity.NOTABLE, ts=150.0)) + + assert store.exists_dedup("w1", "k1") is True + assert store.exists_dedup("w1", "missing") is False + + all_w1 = store.list(watch_id="w1") + assert [f.title for f in all_w1] == ["b", "a"] # newest-first by ts + + alerts = store.list(min_severity=Severity.ALERT) + assert [f.title for f in alerts] == ["b"] + + since = store.list(since=150.0) + assert {f.title for f in since} == {"b", "c"} + + assert store.count() == 3 + assert store.count(watch_id="w1") == 2 + assert store.count(min_severity=Severity.NOTABLE) == 2 + + store.delete_for_watch("w1") + assert store.count(watch_id="w1") == 0 + + +# ── MonitorManager lifecycle ──────────────────────────────────────────────── + + +async def test_manager_arm_list_and_state_transitions(tmp_path: Path) -> None: + manager = MonitorManager(holder=_holder(tmp_path)) + view = await manager.arm_watch(WatchSpec(name="Market", domain="finance", trigger_expr="5m")) + assert view.domain == "finance" + assert view.state == "armed" + + assert [v.watch_id for v in manager.list_watches()] == [view.watch_id] + assert manager.has_active_watches() is True + + assert manager.pause_watch(view.watch_id).state == "suspended" + assert manager.has_active_watches() is False + assert manager.resume_watch(view.watch_id).state == "armed" + + muted = manager.set_muted(view.watch_id, True) + assert muted.muted is True + + stopped = manager.stop_watch(view.watch_id) + assert stopped.state == "done" + assert manager.has_active_watches() is False + assert manager.get_watch("nonexistent") is None + + +async def test_manager_run_once_persists_and_gates_push(tmp_path: Path) -> None: + emitted: list[tuple[str, dict]] = [] + producers = ProducerRegistry() + producers.register(_FakeProducer("finance", [ + Finding(watch_id="", domain="finance", title="quiet", severity=Severity.INFO, dedup_key="i"), + Finding(watch_id="", domain="finance", title="move", severity=Severity.NOTABLE, dedup_key="n"), + Finding(watch_id="", domain="finance", title="spike", severity=Severity.ALERT, dedup_key="a"), + ])) + manager = MonitorManager( + holder=_holder(tmp_path), + producers=producers, + emit=lambda et, payload: emitted.append((et, payload)), + ) + view = await manager.arm_watch(WatchSpec(name="M", domain="finance", sensitivity="notable")) + + result = await manager.run_watch_once(view.watch_id) + assert result["ok"] is True + assert result["findings"] == 3 # all persisted + finding_events = [p for et, p in emitted if et == EVENT_FINDING] + assert {p["title"] for p in finding_events} == {"move", "spike"} # info gated out + + # Second cycle: dedup keys already present -> nothing new persisted/pushed. + before = len(emitted) + result2 = await manager.run_watch_once(view.watch_id) + assert result2["findings"] == 0 + assert len(emitted) == before + + assert manager.finding_store.count(watch_id=view.watch_id) == 3 + + +async def test_manager_muted_watch_persists_without_push(tmp_path: Path) -> None: + emitted: list[tuple[str, dict]] = [] + producers = ProducerRegistry() + producers.register(_FakeProducer("sentiment", [ + Finding(watch_id="", domain="sentiment", title="surge", severity=Severity.ALERT, dedup_key="s"), + ])) + manager = MonitorManager( + holder=_holder(tmp_path), + producers=producers, + emit=lambda et, payload: emitted.append((et, payload)), + ) + view = await manager.arm_watch(WatchSpec(name="S", domain="sentiment")) + manager.set_muted(view.watch_id, True) + emitted.clear() + + result = await manager.run_watch_once(view.watch_id) + assert result["findings"] == 1 + assert [et for et, _ in emitted if et == EVENT_FINDING] == [] # muted -> no push + + +async def test_manager_unknown_domain_is_graceful(tmp_path: Path) -> None: + manager = MonitorManager(holder=_holder(tmp_path)) + view = await manager.arm_watch(WatchSpec(name="X", domain="unregistered")) + result = await manager.run_watch_once(view.watch_id) + assert result["ok"] is False + assert "no producer" in result["error"] + + +async def test_has_active_watches_excludes_client_coupled(tmp_path: Path) -> None: + manager = MonitorManager(holder=_holder(tmp_path)) + await manager.arm_watch(WatchSpec(name="S", domain="session", client_coupled=True)) + assert manager.has_active_watches() is False # client-coupled must not keep leapd alive + await manager.arm_watch(WatchSpec(name="F", domain="finance")) + assert manager.has_active_watches() is True diff --git a/tests/test_session_analysis.py b/tests/test_session_analysis.py new file mode 100644 index 0000000..0cbd187 --- /dev/null +++ b/tests/test_session_analysis.py @@ -0,0 +1,176 @@ +"""Hermetic tests for the session-analysis dashboard (domain=session watch). + +Fakes the analysis services facade (no LLM); exercises producer gating, +the manager path, the session.history RPC, and the session template render. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from leapflow.dashboard import TemplateLibrary +from leapflow.daemon.service import RuntimeLeapService +from leapflow.monitor import MonitorManager, ProducerRegistry, SessionAnalysisProducer, WatchSpec +from leapflow.monitor.types import ProducerContext +from leapflow.storage.connection import LocalConnectionHolder + + +class _FakeServices: + def __init__(self, history: dict, analysis: dict, salient: bool = False) -> None: + self._history = history + self._analysis = analysis + self._salient = salient + self.analyze_calls = 0 + + async def session_history(self) -> dict: + return dict(self._history) + + async def analyze_session(self, messages, *, prior=None) -> dict: + self.analyze_calls += 1 + return dict(self._analysis) + + async def should_refresh(self, messages) -> bool: + return self._salient + + +def _ctx(spec: WatchSpec, services, *, now: float = 1000.0, force: bool = False) -> ProducerContext: + return ProducerContext(spec=spec, now=now, services=services, force=force) + + +# ── Producer gating ───────────────────────────────────────────────────────── + + +async def test_session_producer_first_run_analyzes() -> None: + prod = SessionAnalysisProducer() + svc = _FakeServices( + {"turn_count": 2, "token_count": 100, "messages": [{"role": "user", "content": "hi"}]}, + {"story": "The arc", "insights": [{"title": "a"}]}, + ) + spec = WatchSpec(name="Session", watch_id="w", domain="session", params={"use_model_salience": False, "debounce_s": 0}) + out = list(await prod.observe(_ctx(spec, svc))) + assert len(out) == 1 + assert out[0].payload["story"] == "The arc" + assert out[0].payload["usage"]["turns"] == 2 + assert svc.analyze_calls == 1 + + # No new turns since last analysis -> skip. + again = list(await prod.observe(_ctx(spec, svc, now=1001.0))) + assert again == [] + assert svc.analyze_calls == 1 + + +async def test_session_producer_batch_threshold() -> None: + prod = SessionAnalysisProducer() + spec = WatchSpec(name="Session", watch_id="w", domain="session", params={"batch_turns": 6, "use_model_salience": False, "debounce_s": 0}) + await prod.observe(_ctx(spec, _FakeServices({"turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]}, {"story": "s"}))) + svc = _FakeServices({"turn_count": 8, "token_count": 20, "messages": [{"role": "u", "content": "x"}]}, {"story": "s2"}) + out = list(await prod.observe(_ctx(spec, svc, now=2000.0))) + assert len(out) == 1 and svc.analyze_calls == 1 + + +async def test_session_producer_salience_below_batch() -> None: + prod = SessionAnalysisProducer() + spec = WatchSpec(name="Session", watch_id="w2", domain="session", params={"batch_turns": 100, "batch_tokens": 10**9, "use_model_salience": True, "debounce_s": 0}) + await prod.observe(_ctx(spec, _FakeServices({"turn_count": 1, "token_count": 5, "messages": [{"role": "u", "content": "x"}]}, {"story": "s"}, salient=True), now=1.0)) + svc = _FakeServices({"turn_count": 2, "token_count": 8, "messages": [{"role": "u", "content": "y"}]}, {"story": "s"}, salient=True) + out = list(await prod.observe(_ctx(spec, svc, now=100.0))) + assert len(out) == 1 # salience forced a refresh below the batch threshold + + +async def test_session_producer_force_without_new_turns() -> None: + prod = SessionAnalysisProducer() + spec = WatchSpec(name="Session", watch_id="w3", domain="session", params={"use_model_salience": False, "debounce_s": 999}) + await prod.observe(_ctx(spec, _FakeServices({"turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]}, {"story": "s"}))) + svc = _FakeServices({"turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]}, {"story": "forced"}) + out = list(await prod.observe(_ctx(spec, svc, now=5.0, force=True))) + assert len(out) == 1 and out[0].payload["story"] == "forced" + + +async def test_session_dedup_key_is_session_scoped() -> None: + prod = SessionAnalysisProducer() + spec = WatchSpec(name="Session", watch_id="w", domain="session", params={"use_model_salience": False, "debounce_s": 0}) + a = list(await prod.observe(_ctx(spec, _FakeServices( + {"session_id": "A", "turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]}, + {"story": "a"}), now=1.0))) + # A different session with the SAME turn_count must not be deduped away. + b = list(await prod.observe(_ctx(spec, _FakeServices( + {"session_id": "B", "turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "y"}]}, + {"story": "b"}), now=2.0, force=True))) + assert a[0].dedup_key == "w:A:2" + assert b[0].dedup_key == "w:B:2" + assert a[0].dedup_key != b[0].dedup_key + + +# ── Full manager path ──────────────────────────────────────────────────────── + + +async def test_session_watch_via_manager(tmp_path: Path) -> None: + producers = ProducerRegistry() + producers.register(SessionAnalysisProducer()) + svc = _FakeServices( + {"turn_count": 3, "token_count": 50, "messages": [{"role": "user", "content": "hi"}]}, + {"story": "Story", "insights": [{"title": "i", "summary": "s", "severity": "notable"}]}, + ) + mgr = MonitorManager( + holder=LocalConnectionHolder(tmp_path / "leap.duckdb"), + producers=producers, + services=svc, + ) + view = await mgr.arm_watch(WatchSpec(name="Session", domain="session", params={"use_model_salience": False, "debounce_s": 0})) + result = await mgr.run_watch_once(view.watch_id, force=True) + assert result["ok"] is True and result["findings"] == 1 + findings = mgr.list_findings(watch_id=view.watch_id) + assert findings[0].payload["story"] == "Story" + + +# ── session.history RPC ────────────────────────────────────────────────────── + + +async def test_session_history_reads_engine_transcript() -> None: + class _WM: + def as_chat_messages(self): + return [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}] + + engine = SimpleNamespace(_wm=_WM(), _current_session_id="sid", turn_count=2, context_token_count=42) + service = RuntimeLeapService(SimpleNamespace()) + service._ctx = SimpleNamespace(engine=engine, _conversation_store=None) + history = await service.session_history() + assert history["turn_count"] == 2 + assert history["token_count"] == 42 + assert history["session_id"] == "sid" + assert [m["role"] for m in history["messages"]] == ["user", "assistant"] + + +async def test_session_history_empty_without_context() -> None: + service = RuntimeLeapService(SimpleNamespace()) + history = await service.session_history() + assert history["turn_count"] == 0 and history["messages"] == [] + + +# ── session template render ────────────────────────────────────────────────── + + +def test_session_template_renders_analysis() -> None: + lib = TemplateLibrary() + assert "session.analysis" in lib.names() + spec = lib.render("session.analysis", {"analysis": { + "story": "the arc", + "insights": [{"title": "t", "summary": "s", "severity": "notable"}], + "action_items": ["do x"], + "decisions": ["chose y"], + "open_questions": [], + "entities": ["Alice"], + "next_prompts": ["ask z"], + }}) + flat: list[dict] = [] + + def _walk(nodes: list) -> None: + for n in nodes: + flat.append(n) + _walk(n.get("children") or []) + + _walk(spec["root"]) + types = {n["type"] for n in flat} + assert "StoryPanel" in types + assert len([n for n in flat if n["type"] == "InsightCard"]) == 1 From fe6e8f2f3e763c4bb087479c20b82a5ceca46ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Thu, 16 Jul 2026 22:17:59 +0800 Subject: [PATCH 02/25] feat(board): rename /dashboard -> /board and add LeapBoard branding + README Task 1: rename the slash command /dashboard* -> /board* (12 subcommands) and CLI 'leap dashboard' -> 'leap board'; launcher spawn argv updated to match; robust split-based subcommand parsing; all usage/hint strings updated; no back-compat alias. Task 2: LeapBoard brand in the shared web shell -- Fraunces serif wordmark (Leap+Board), muted italic slogan 'Signals into insight.', hairline divider; consulting-grade, monochrome, non-intrusive; tab title branded. Config keys dashboard.* kept (server settings, not the command). Docs: README LeapBoard section (intro, how-it-works, install, config, /board + NL + CLI usage, showcase) + Key Modules rows for monitor/ and dashboard/. Full suite: 811 passed, 1 skipped. --- README.md | 76 +++++++++++++++++++++ src/leapflow/cli/cli.py | 8 +-- src/leapflow/cli/commands/registry.py | 26 +++---- src/leapflow/cli/commands/slash_handlers.py | 20 +++--- src/leapflow/dashboard/launcher.py | 2 +- src/leapflow/dashboard/static/app.js | 2 +- src/leapflow/dashboard/static/index.html | 11 ++- src/leapflow/dashboard/static/styles.css | 13 +++- tests/test_dashboard_watch_rpc.py | 18 ++--- 9 files changed, 135 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 397c5e5..ae5c606 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ### News +- **2026-07-16**: **LeapBoard** — general-purpose monitoring web dashboard (Watch→Finding + Server-Driven UI); `/board` entry, live session analysis, and finance/sentiment/research templates. - **2026-07-16**: v0.0.4 released — protocol-driven recovery coordinator with budget-constrained strategies, unified error classification, checkpoint-based cross-turn resumption, structured audit trail, and tool execution idempotency ledger. - **2026-07-15**: v0.0.3 released — layered YAML config, Path/Profile/Cache layouts, encrypted secret refs, DuckDB cache indexing, `leap config`, and robustness hardening. - **2026-07-15**: v0.0.2 released — TUI, Gateway/App Connector, Hub sync, Scheduler, Workflow Copilot, and runtime hardening. @@ -511,6 +512,79 @@ For deployment environments, provision platform credentials through the same gat --- +## LeapBoard — Monitoring Dashboard + +> **Signals into insight.** + +**LeapBoard** extends LeapFlow's Observe/Orient boundary into a general-purpose, always-on monitoring surface. Launch independent, persistent **watches** — for financial-market anomalies, public-opinion (sentiment) shifts, new research papers, or your own live conversation — and each one continuously observes its source, filters signal from noise, and surfaces **findings** (typed, scored, with evidence) onto a real-time web board. + +It is domain-neutral by design: finance, sentiment, research, and session analysis differ only by a small YAML template and their finding payload — never by branching in core code. + +### How it works + +``` +Watch (leapd-hosted) ── observe → SNR filter → score → Finding ──▶ NotificationBus + │ push + YAML template ─▶ ViewSpec (validated) ─▶ Web board (SDUI) ◀── WebSocket +``` + +- **Runtime** — watches run inside the shared `leapd` daemon, so they survive TUI restarts and are shared across clients. Findings are persisted (DuckDB) and pushed live; session-analysis watches are client-coupled and never keep the daemon alive on their own. +- **Server-Driven UI** — each scenario is authored as a **YAML template** compiled into a validated **ViewSpec** over a fixed component catalog (cards, tables, charts, gauges, story panels…). Interactive components talk back through a bidirectional action protocol; unknown component types degrade gracefully, and bespoke visuals use a `Custom` escape hatch. The board never renders arbitrary HTML/JS. +- **View client** — the board connects to `leapd` like the TUI does, with no privileged coupling. The web server is optional (`aiohttp`) and degrades with a clear install hint when absent. + +### Install & enable + +```bash +pip install 'leapflow[dashboard]' # adds the optional aiohttp web server +``` + +The board binds to `127.0.0.1` with a per-session access token. Tune it through `leap config`: + +| Key | Default | Description | +|-----|---------|-------------| +| `dashboard.enabled` | `true` | Enable the local monitoring web board | +| `dashboard.bind` | `127.0.0.1` | Bind address (keep loopback) | +| `dashboard.port` | `8765` | Web server port | +| `dashboard.auto_open` | `true` | Open the default browser automatically | +| `monitor.session_batch_turns` | `6` | Re-analyze the session every N new turns | + +### Usage + +**Slash command (`/board`)** — inside the TUI: + +```text +/board open the board (session analysis if a conversation is active, else overview) +/board session open the current-session analysis board +/board new finance --name "AAPL watch" --trigger 5m +/board list list active watches (in-TUI) +/board findings show recent findings +/board pause|resume|stop|mute +/board refresh run one observation cycle now +``` + +**Natural language** — just ask ("open a finance board", "analyze this conversation"); the agent opens or creates boards for you. + +**CLI** — from any shell: + +```bash +leap board # ensure the server is running and open the browser +leap board session # open the session-analysis board +leap board --no-open # print the URL instead of opening a browser +``` + +Executing any entry auto-launches your default browser at the token-scoped local URL. + +### Showcase + +- **Financial market watch** — monitor a symbol or source on an interval/condition; anomalies and opportunities arrive as alert-severity finding cards alongside a candlestick panel. +- **Public-opinion (sentiment) watch** — track a brand or topic; a sentiment gauge plus a live stream of representative mentions. +- **Paper hunting** — watch arXiv / venues / authors / keywords; new relevant papers appear as cards linking straight to the source. +- **Session analysis (built-in)** — after a few conversation turns, `/board session` opens a board that reads the transcript and renders a story arc, insights, decisions, action items, open questions, an entity view, and suggested next prompts — refreshing automatically as the conversation grows (batch threshold, optional model salience, or manual `/board refresh`). + +> Adding a new scenario = one YAML template (plus an optional renderer) and a producer. The board core stays untouched — no domain branching, no hardcoded layouts. + +--- + ## Safety & Approval LeapFlow enforces a **layered safety architecture** that balances autonomy with human oversight. The goal is minimal interruption — the agent asks for permission only when an action carries real consequences. @@ -768,6 +842,8 @@ uv run pytest -k "test_world_model" -q # By keyword | `learning/` | Skill distillation, parameterization, and maturity lifecycle | | `skills/` | Skill library, runtime execution, and self-evolution (Loop γ) | | `copilot/` | Workflow-level next-step prediction and proactive suggestion | +| `monitor/` | Domain-neutral Watch→Finding monitoring runtime (leapd-hosted scheduler, findings store) | +| `dashboard/` | LeapBoard — Server-Driven UI web board (ViewSpec catalog, YAML templates, WebSocket fan-out) | | `analysis/` | Six-layer denoising pipeline for trajectory refinement | | `engine/` | Session orchestration and ReAct execution loop | | `memory/` | Three-tier event-driven memory (working → episodic → long-term) | diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 3943fb6..365fd16 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -255,8 +255,8 @@ def main(argv: list[str] | None = None) -> int: serve_parser = daemon_sub.add_parser("serve", help=argparse.SUPPRESS) serve_parser.add_argument("--internal", action="store_true", help=argparse.SUPPRESS) - # leap dashboard - dashboard_parser = subparsers.add_parser("dashboard", help="Open or serve the monitoring web dashboard") + # leap board (LeapBoard monitoring dashboard) + dashboard_parser = subparsers.add_parser("board", help="Open or serve the LeapBoard monitoring dashboard") dashboard_parser.add_argument("action", nargs="?", default="home", help="home | session | open") dashboard_parser.add_argument("--serve", action="store_true", help=argparse.SUPPRESS) dashboard_parser.add_argument("--token", default="", help=argparse.SUPPRESS) @@ -315,7 +315,7 @@ def main(argv: list[str] | None = None) -> int: # ── Pre-parse: detect if first non-flag arg is a known subcommand ── # If not, treat everything non-flag as a chat prompt. - known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config", "dashboard"} + known_commands = {"teach", "run", "skills", "relearn", "host", "daemon", "config", "board"} effective_argv = list(argv) if argv is not None else sys.argv[1:] # Find first non-flag argument, skipping values owned by global options. @@ -392,7 +392,7 @@ def main(argv: list[str] | None = None) -> int: return cmd_daemon(args) # Dashboard is a view client (connects to leapd); no Context initialization - if args.command == "dashboard": + if args.command == "board": from leapflow.cli.commands.dashboard import cmd_dashboard return cmd_dashboard(args) diff --git a/src/leapflow/cli/commands/registry.py b/src/leapflow/cli/commands/registry.py index cbf5a6f..ead4ace 100644 --- a/src/leapflow/cli/commands/registry.py +++ b/src/leapflow/cli/commands/registry.py @@ -133,19 +133,19 @@ def supports_runtime(self, runtime: CommandRuntime) -> bool: CommandDef("arm", "Schedule a skill for timed execution", "Scheduler", args_hint=" "), CommandDef("task", "List scheduled tasks", "Scheduler"), - # Dashboard & Monitors - CommandDef("dashboard", "Manage monitoring dashboards and watches", "Dashboard", args_hint="[open|session|list|status|new|pause|resume|stop|mute|refresh|findings] ...", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), - CommandDef("dashboard open", "Open the monitoring web dashboard", "Dashboard", args_hint="[watch-id]", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), - CommandDef("dashboard session", "Open the current-session analysis dashboard", "Dashboard", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), - CommandDef("dashboard list", "List active watches", "Dashboard"), - CommandDef("dashboard status", "Show monitoring runtime status", "Dashboard"), - CommandDef("dashboard new", "Create a watch for a domain", "Dashboard", args_hint=" [--name --trigger --sensitivity]", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), - CommandDef("dashboard pause", "Pause a watch", "Dashboard", args_hint="", effect=CommandEffect.SESSION), - CommandDef("dashboard resume", "Resume a watch", "Dashboard", args_hint="", effect=CommandEffect.SESSION), - CommandDef("dashboard stop", "Stop a watch", "Dashboard", args_hint="", effect=CommandEffect.SESSION), - CommandDef("dashboard mute", "Mute or unmute a watch", "Dashboard", args_hint=" [on|off]", effect=CommandEffect.SESSION), - CommandDef("dashboard refresh", "Run a watch once now", "Dashboard", args_hint="", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), - CommandDef("dashboard findings", "Show recent findings", "Dashboard", args_hint="[id] [--limit N]"), + # Board & Monitors (LeapBoard) + CommandDef("board", "Open or manage monitoring boards and watches", "Board", args_hint="[open|session|list|status|new|pause|resume|stop|mute|refresh|findings] ...", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("board open", "Open the monitoring web board", "Board", args_hint="[watch-id]", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("board session", "Open the current-session analysis board", "Board", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("board list", "List active watches", "Board"), + CommandDef("board status", "Show monitoring runtime status", "Board"), + CommandDef("board new", "Create a watch for a domain", "Board", args_hint=" [--name --trigger --sensitivity]", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("board pause", "Pause a watch", "Board", args_hint="", effect=CommandEffect.SESSION), + CommandDef("board resume", "Resume a watch", "Board", args_hint="", effect=CommandEffect.SESSION), + CommandDef("board stop", "Stop a watch", "Board", args_hint="", effect=CommandEffect.SESSION), + CommandDef("board mute", "Mute or unmute a watch", "Board", args_hint=" [on|off]", effect=CommandEffect.SESSION), + CommandDef("board refresh", "Run a watch once now", "Board", args_hint="", effect=CommandEffect.SESSION, execution=CommandExecution.SHORT_OPERATION), + CommandDef("board findings", "Show recent findings", "Board", args_hint="[id] [--limit N]"), ) # ── Derived structures ─────────────────────────────────────────────── diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index e09a230..89b6942 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -1112,7 +1112,7 @@ async def command_execute(ctx: "Context", name: str, args: str = "") -> dict[str return await _execute_scheduler_arm(ctx, args) if name == "task": return _execute_scheduler_task(ctx) - if name == "dashboard" or name.startswith("dashboard "): + if name == "board" or name.startswith("board "): return await _execute_dashboard(ctx, name, args) return {"ok": False, "message": f"Unknown command: /{name}"} @@ -1159,7 +1159,7 @@ async def _ensure_session_watch_refresh(ctx: "Context", monitors: Any) -> None: async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[str, Any]: """Manage monitor watches from the TUI (data operations; web open is separate).""" monitors = getattr(ctx, "monitors", None) - sub = name[len("dashboard"):].strip() + sub = name.split(" ", 1)[1].strip() if " " in name else "" rest = (sub + ((" " + args) if args else "")).strip() if sub else args.strip() try: tokens = shlex.split(rest) if rest else [] @@ -1188,7 +1188,7 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ if action in ("new", "add", "arm"): if not rest_tokens: - return {"ok": False, "message": "Usage: /dashboard new [--name N --trigger EXPR --sensitivity S]"} + return {"ok": False, "message": "Usage: /board new [--name N --trigger EXPR --sensitivity S]"} from leapflow.monitor import WatchSpec view = await monitors.arm_watch(WatchSpec.from_dict(_parse_watch_spec(rest_tokens))) @@ -1196,7 +1196,7 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ if action in ("pause", "resume", "stop"): if not rest_tokens: - return {"ok": False, "message": f"Usage: /dashboard {action} "} + return {"ok": False, "message": f"Usage: /board {action} "} watch_id = _resolve_watch_id(monitors, rest_tokens[0]) method = {"pause": monitors.pause_watch, "resume": monitors.resume_watch, "stop": monitors.stop_watch}[action] @@ -1208,7 +1208,7 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ if action == "mute": if not rest_tokens: - return {"ok": False, "message": "Usage: /dashboard mute [on|off]"} + return {"ok": False, "message": "Usage: /board mute [on|off]"} watch_id = _resolve_watch_id(monitors, rest_tokens[0]) muted = not (len(rest_tokens) > 1 and rest_tokens[1].lower() in ("off", "false", "0", "no")) view = monitors.set_muted(watch_id, muted) @@ -1219,7 +1219,7 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ if action == "refresh": if not rest_tokens: - return {"ok": False, "message": "Usage: /dashboard refresh "} + return {"ok": False, "message": "Usage: /board refresh "} watch_id = _resolve_watch_id(monitors, rest_tokens[0]) result = await monitors.run_watch_once(watch_id) return {"ok": bool(result.get("ok", False)), "view": "dashboard", "mode": "refresh", @@ -1274,10 +1274,10 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ url += "&target=session" payload["url"] = url else: - payload["hint"] = "Run `leap dashboard` to open the web view." + payload["hint"] = "Run `leap board` to open the web view." return payload - return {"ok": False, "message": f"Unknown dashboard subcommand: {action}. " + return {"ok": False, "message": f"Unknown board subcommand: {action}. " "Try list|status|new|pause|resume|stop|mute|refresh|findings|open|session."} @@ -1691,7 +1691,7 @@ def _render_dashboard_view(console: "LeapConsole", payload: dict[str, Any]) -> N if mode == "list": watches = payload.get("watches") or [] if not watches: - console.system("No watches. Create one with /dashboard new .") + console.system("No watches. Create one with /board new .") return table = Table(title="Watches", title_style="bold cyan", border_style="bright_black") for col in ("id", "name", "domain", "trigger", "state", "muted", "runs", "findings"): @@ -1746,7 +1746,7 @@ def _render_dashboard_view(console: "LeapConsole", payload: dict[str, Any]) -> N elif payload.get("running"): console.system("Dashboard is running.") else: - console.system(str(payload.get("hint") or "Run `leap dashboard` to open the web view.")) + console.system(str(payload.get("hint") or "Run `leap board` to open the web view.")) return if mode == "findings": _render_findings_lines(console, payload.get("findings") or []) diff --git a/src/leapflow/dashboard/launcher.py b/src/leapflow/dashboard/launcher.py index 4136db7..a786409 100644 --- a/src/leapflow/dashboard/launcher.py +++ b/src/leapflow/dashboard/launcher.py @@ -126,7 +126,7 @@ def ensure_server(settings: Any, *, wait_s: float = 8.0) -> dict[str, Any]: port = int(getattr(settings, "dashboard_port", 8765)) bind = str(getattr(settings, "dashboard_bind", "127.0.0.1")) cmd = [ - sys.executable, "-m", "leapflow", "dashboard", "--serve", + sys.executable, "-m", "leapflow", "board", "--serve", "--token", token, "--port", str(port), "--bind", bind, ] creationflags = 0 diff --git a/src/leapflow/dashboard/static/app.js b/src/leapflow/dashboard/static/app.js index 5be9807..f8d829d 100644 --- a/src/leapflow/dashboard/static/app.js +++ b/src/leapflow/dashboard/static/app.js @@ -159,7 +159,7 @@ rootEl.innerHTML = ""; (spec.root || []).forEach((n) => rootEl.appendChild(renderNode(n))); if (!(spec.root || []).length) rootEl.appendChild(el("div", "empty", "No content yet.")); - document.title = spec.title || "LeapFlow Monitors"; + document.title = spec.title ? spec.title + " \u00b7 LeapBoard" : "LeapBoard"; } function toast(finding) { diff --git a/src/leapflow/dashboard/static/index.html b/src/leapflow/dashboard/static/index.html index 4d0e6a1..aeca192 100644 --- a/src/leapflow/dashboard/static/index.html +++ b/src/leapflow/dashboard/static/index.html @@ -3,12 +3,19 @@ - LeapFlow Monitors + LeapBoard + + +
- LeapFlow +
+ LeapBoard + Signals into insight. +
+
-
Loading…
+
Loading…
diff --git a/src/leapflow/dashboard/static/styles.css b/src/leapflow/dashboard/static/styles.css index cf9dc1d..70d5b07 100644 --- a/src/leapflow/dashboard/static/styles.css +++ b/src/leapflow/dashboard/static/styles.css @@ -1,6 +1,6 @@ :root { - --bg: #0f1115; --panel: #171a21; --border: #262b36; --text: #e6e9ef; - --muted: #9aa4b2; --accent: #6ea8fe; --alert: #ff6b6b; --notable: #ffd166; --info: #8bd3a0; + --bg: #0f1115; --panel: #171a21; --panel-2: #1c2029; --border: #262b36; --text: #e6e9ef; + --muted: #9aa4b2; --accent: #8fa4c7; --alert: #d37a73; --notable: #c7a86d; --info: #8aa896; } * { box-sizing: border-box; } body { @@ -25,28 +25,77 @@ body { .brand-divider { width: 1px; height: 26px; background: var(--border); } .nav a { color: var(--muted); text-decoration: none; margin-right: 12px; } .nav a:hover { color: var(--text); } +.locale-label { color: var(--muted); font-size: 11px; margin-left: 4px; } +.locale-switch { + background: var(--panel-2); color: var(--text); border: 1px solid var(--border); + border-radius: 8px; padding: 4px 8px; font-size: 12px; +} .status { margin-left: auto; color: var(--muted); font-size: 12px; } -.root { padding: 16px; } +.root { padding: 20px; max-width: 1440px; margin: 0 auto; } .empty { color: var(--muted); padding: 40px; text-align: center; } -.page-title { font-size: 18px; font-weight: 600; margin: 4px 0 14px; } -.section { margin-bottom: 18px; } -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } +.page-title { + font-family: "Fraunces", "Iowan Old Style", Palatino, Georgia, serif; + font-size: 25px; font-weight: 600; letter-spacing: .01em; margin: 4px 0 18px; +} +.section { margin-bottom: 22px; } +.section-title { + color: var(--text); font-size: 12px; letter-spacing: .13em; text-transform: uppercase; + margin: 18px 0 4px; font-weight: 700; +} +.section-subtitle { color: var(--muted); margin: 0 0 12px; max-width: 760px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; } .row { display: flex; gap: 12px; flex-wrap: wrap; } +.col { display: flex; flex-direction: column; gap: 12px; } .board { display: flex; flex-direction: column; gap: 10px; } -.board-title, .card-title { font-weight: 600; margin-bottom: 6px; } +.board-title, .card-title { font-weight: 650; margin-bottom: 8px; } +.kicker { color: var(--muted); font-size: 11px; letter-spacing: .11em; text-transform: uppercase; margin-bottom: 6px; } .card, .finding { - background: var(--panel); border: 1px solid var(--border); border-radius: 10px; - padding: 12px 14px; + background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%); + border: 1px solid var(--border); border-radius: 12px; + padding: 14px 16px; } .finding { border-left: 4px solid var(--border); cursor: pointer; } .finding.sev-alert { border-left-color: var(--alert); } .finding.sev-notable { border-left-color: var(--notable); } .finding.sev-info { border-left-color: var(--info); } .finding .sev { font-size: 11px; text-transform: uppercase; color: var(--muted); } -.finding .summary { color: var(--muted); margin-top: 4px; } -.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; } +.finding .summary { color: var(--muted); margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } +.story-panel { border-top: 3px solid var(--accent); } +.story-text { max-width: 960px; } +.insight-list { margin: 0; padding-left: 18px; color: var(--text); } +.insight-list li { margin: 6px 0; } +.chips { display: flex; gap: 8px; flex-wrap: wrap; } +.stat { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 12px 16px; min-width: 150px; } .stat .label { color: var(--muted); font-size: 12px; } -.stat .value { font-size: 22px; font-weight: 700; } +.stat .value { font-family: "Fraunces", Georgia, serif; font-size: 24px; font-weight: 600; } +.chart { min-height: 140px; } +.chart-placeholder { color: var(--muted); border: 1px dashed var(--border); border-radius: 10px; padding: 34px 16px; text-align: center; } +.bar-row { display: grid; grid-template-columns: 82px 1fr 34px; align-items: center; gap: 8px; margin: 9px 0; } +.bar-label, .bar-value { color: var(--muted); font-size: 12px; } +.bar-track { height: 10px; background: #222735; border-radius: 999px; overflow: hidden; } +.bar-fill { display: block; height: 100%; border-radius: 999px; background: var(--info); } +.bar-fill.sev-alert { background: var(--alert); } +.bar-fill.sev-notable { background: var(--notable); } +.bar-fill.sev-info { background: var(--info); } +.sparkline { width: 100%; height: 92px; } +.sparkline polyline { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; } +.pie-card { align-items: center; } +.pie { width: 112px; height: 112px; border-radius: 50%; margin: 8px auto 14px; box-shadow: inset 0 0 0 18px rgba(15,17,21,.55); } +.legend { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } +.legend-item, .badge { border: 1px solid var(--border); border-radius: 999px; padding: 3px 8px; color: var(--muted); font-size: 11px; } +.badge.sev-alert, .legend-item.sev-alert { border-color: color-mix(in srgb, var(--alert) 60%, var(--border)); color: var(--alert); } +.badge.sev-notable, .legend-item.sev-notable { border-color: color-mix(in srgb, var(--notable) 60%, var(--border)); color: var(--notable); } +.badge.sev-info, .legend-item.sev-info { border-color: color-mix(in srgb, var(--info) 60%, var(--border)); color: var(--info); } +.timeline { display: flex; flex-direction: column; gap: 10px; } +.timeline-item { position: relative; padding: 8px 0 8px 18px; border-left: 1px solid var(--border); } +.timeline-item::before { content: ""; position: absolute; left: -5px; top: 14px; width: 9px; height: 9px; border-radius: 50%; background: var(--info); } +.timeline-item.sev-alert::before { background: var(--alert); } +.timeline-item.sev-notable::before { background: var(--notable); } +.timeline-title { font-weight: 650; } +.data-table { font-size: 13px; } +.quote { margin: 0; padding: 10px 14px; border-left: 3px solid var(--accent); color: var(--text); background: rgba(143,164,199,.08); } +.quote cite { display: block; color: var(--muted); margin-top: 6px; font-size: 12px; } +.entity-cloud { display: flex; flex-wrap: wrap; gap: 8px; } .toolbar { display: flex; gap: 8px; margin-bottom: 12px; } button, .btn { background: #222736; color: var(--text); border: 1px solid var(--border); @@ -57,6 +106,8 @@ table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); } th { color: var(--muted); font-weight: 600; } .md { white-space: pre-wrap; } +.prose { line-height: 1.65; color: var(--muted); } +.prose:not(:empty)::first-line { color: var(--text); } .toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; } .toast { background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent); diff --git a/src/leapflow/dashboard/templates/finance.market.yaml b/src/leapflow/dashboard/templates/finance.market.yaml index 6acc3ca..03cd715 100644 --- a/src/leapflow/dashboard/templates/finance.market.yaml +++ b/src/leapflow/dashboard/templates/finance.market.yaml @@ -7,38 +7,59 @@ layout: props: title: "{{ title }}" children: - - type: Row + - type: Section + props: + title: "Market brief" + subtitle: "Price action, signal count, and severity structure in one executive view." children: - - type: Stat - props: - label: "Watch" - value: "{{ watch.name }}" - - type: Stat - props: - label: "Signals" - value: "{{ watch.finding_count }}" - - type: Card + - type: Row + children: + - type: Stat + props: + label: "Watch" + value: "{{ watch.name }}" + - type: Stat + props: + label: "Signals" + value: "{{ watch.finding_count }}" + - type: PieChart + props: + title: "Signal mix" + bind: findings + - type: Section props: title: "Price action" + subtitle: "Chart-first reading before drilling into narrative evidence." children: - - type: Custom - props: - render: candlestick - bind: findings - - type: Board + - type: Grid + children: + - type: Custom + props: + render: candlestick + bind: findings + - type: Sparkline + props: + title: "Signal momentum" + bind: findings + - type: Section props: - title: "Signals" + title: "Evidence stream" + subtitle: "Each card is capped for scanability; open it for drill-down context." children: - - type: FindingCard - repeat: findings - as: f + - type: Board props: - title: "{{ f.title }}" - summary: "{{ f.summary }}" - severity: "{{ f.severity }}" - bind: f - action: - kind: intent - name: drilldown - params: - finding_id: "{{ f.finding_id }}" + title: "Signals" + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: intent + name: drilldown + params: + finding_id: "{{ f.finding_id }}" diff --git a/src/leapflow/dashboard/templates/generic.yaml b/src/leapflow/dashboard/templates/generic.yaml index 72830fa..d0e8f40 100644 --- a/src/leapflow/dashboard/templates/generic.yaml +++ b/src/leapflow/dashboard/templates/generic.yaml @@ -10,28 +10,40 @@ layout: props: title: "{{ title }}" children: - - type: Toolbar + - type: Section + props: + title: "Executive brief" + subtitle: "A concise operating view of the active signal stream." children: - - type: FilterBar - props: - fields: ["severity", "domain", "watch"] - action: - kind: nav - name: filter - - type: Board + - type: Row + children: + - type: Stat + props: + label: "Findings" + value: "{{ watch.finding_count }}" + - type: PieChart + props: + title: "Severity mix" + bind: findings + - type: Section props: - title: Findings + title: "Signal flow" + subtitle: "Latest observations, formatted for quick triage rather than long-form reading." children: - - type: FindingCard - repeat: findings - as: f + - type: Board props: - title: "{{ f.title }}" - summary: "{{ f.summary }}" - severity: "{{ f.severity }}" - bind: f - action: - kind: intent - name: drilldown - params: - finding_id: "{{ f.finding_id }}" + title: Findings + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: intent + name: drilldown + params: + finding_id: "{{ f.finding_id }}" diff --git a/src/leapflow/dashboard/templates/overview.yaml b/src/leapflow/dashboard/templates/overview.yaml index 43bdf49..3753b39 100644 --- a/src/leapflow/dashboard/templates/overview.yaml +++ b/src/leapflow/dashboard/templates/overview.yaml @@ -5,40 +5,68 @@ domain: "" layout: - type: Page props: - title: "{{ title }}" + title: "LeapBoard" children: - - type: Board + - type: Section props: - title: "Watches" + title: "Watch portfolio" + subtitle: "Active observation lanes, grouped as a board rather than a text log." children: - - type: Card - repeat: watches - as: w - props: - title: "{{ w.name }}" - action: - kind: nav - name: openWatch - params: - target: "{{ w.watch_id }}" + - type: Grid children: - - type: Markdown + - type: Card + repeat: watches + as: w props: - text: "{{ w.domain }} · {{ w.trigger }} · {{ w.state }} · {{ w.finding_count }} findings" - - type: Board + title: "{{ w.name }}" + kicker: "{{ w.domain }}" + action: + kind: nav + name: openWatch + params: + target: "{{ w.watch_id }}" + children: + - type: Row + children: + - type: Badge + props: + label: "{{ w.state }}" + tone: info + - type: Stat + props: + label: "Findings" + value: "{{ w.finding_count }}" + - type: Markdown + props: + text: "{{ w.trigger }}" + - type: Section props: title: "Recent findings" + subtitle: "A refreshed evidence stream with severity mix and the latest observations." children: - - type: FindingCard - repeat: findings - as: f - props: - title: "{{ f.title }}" - summary: "{{ f.summary }}" - severity: "{{ f.severity }}" - bind: f - action: - kind: intent - name: drilldown - params: - finding_id: "{{ f.finding_id }}" + - type: Grid + children: + - type: BarChart + props: + title: "Severity mix" + bind: findings + - type: Timeline + props: + bind: findings + - type: Board + props: + title: Findings + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: intent + name: drilldown + params: + finding_id: "{{ f.finding_id }}" diff --git a/src/leapflow/dashboard/templates/research.paper.yaml b/src/leapflow/dashboard/templates/research.paper.yaml index 49a8293..e6d8cca 100644 --- a/src/leapflow/dashboard/templates/research.paper.yaml +++ b/src/leapflow/dashboard/templates/research.paper.yaml @@ -7,20 +7,39 @@ layout: props: title: "{{ title }}" children: - - type: Board + - type: Section + props: + title: "Research pipeline" + subtitle: "New papers are organized as a decision funnel: signal mix, timeline, then source links." + children: + - type: Grid + children: + - type: PieChart + props: + title: "Severity mix" + bind: findings + - type: Timeline + props: + bind: findings + - type: Section props: title: "New papers" + subtitle: "Abstracts are clipped for readability; open source links for the full paper." children: - - type: FindingCard - repeat: findings - as: f + - type: Board props: - title: "{{ f.title }}" - summary: "{{ f.summary }}" - severity: "{{ f.severity }}" - bind: f - action: - kind: nav - name: openLink - params: - url: "{{ f.payload.url }}" + title: "New papers" + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: nav + name: openLink + params: + url: "{{ f.payload.url }}" diff --git a/src/leapflow/dashboard/templates/sentiment.topic.yaml b/src/leapflow/dashboard/templates/sentiment.topic.yaml index e322054..c3a782c 100644 --- a/src/leapflow/dashboard/templates/sentiment.topic.yaml +++ b/src/leapflow/dashboard/templates/sentiment.topic.yaml @@ -7,30 +7,56 @@ layout: props: title: "{{ title }}" children: - - type: Row + - type: Section + props: + title: "Narrative pulse" + subtitle: "Sentiment is presented as a direction, distribution, and evidence stream." children: - - type: Gauge - props: - label: "Latest sentiment" - bind: "findings[0].payload.sentiment" - - type: Stat + - type: Row + children: + - type: Gauge + props: + label: "Latest sentiment" + bind: "findings[0].payload.sentiment" + - type: Stat + props: + label: "Mentions" + value: "{{ watch.finding_count }}" + - type: PieChart + props: + title: "Sentiment structure" + bind: findings + - type: Section + props: + title: "Signal momentum" + subtitle: "A compact directional view of how the topic is evolving." + children: + - type: LineChart props: - label: "Mentions" - value: "{{ watch.finding_count }}" - - type: Board + title: "Mentions" + bind: findings + - type: Section props: - title: "Mentions" + title: "Evidence stream" + subtitle: "Representative mentions are formatted for scanning and drill-down." children: - - type: FindingCard - repeat: findings - as: f + - type: Timeline + props: + bind: findings + - type: Board props: - title: "{{ f.title }}" - summary: "{{ f.summary }}" - severity: "{{ f.severity }}" - bind: f - action: - kind: intent - name: drilldown - params: - finding_id: "{{ f.finding_id }}" + title: "Mentions" + children: + - type: FindingCard + repeat: findings + as: f + props: + title: "{{ f.title }}" + summary: "{{ f.summary }}" + severity: "{{ f.severity }}" + bind: f + action: + kind: intent + name: drilldown + params: + finding_id: "{{ f.finding_id }}" diff --git a/src/leapflow/dashboard/templates/session.analysis.yaml b/src/leapflow/dashboard/templates/session.analysis.yaml index cf30db3..d8e2955 100644 --- a/src/leapflow/dashboard/templates/session.analysis.yaml +++ b/src/leapflow/dashboard/templates/session.analysis.yaml @@ -10,15 +10,27 @@ layout: props: title: "Session Analysis" children: - - type: StoryPanel + - type: Section props: - title: "Story" - text: "{{ analysis.story }}" - - type: Grid + title: "Executive brief" + subtitle: "Conversation intelligence rendered like an analyst memo: storyline first, then implications." children: - - type: Card - props: - title: "Insights" + - type: Grid + children: + - type: StoryPanel + props: + title: "Storyline" + text: "{{ analysis.story }}" + - type: BarChart + props: + title: "Severity mix" + bind: analysis.insights + - type: Section + props: + title: "Insights" + subtitle: "Key observations are carded and capped for fast reading." + children: + - type: Grid children: - type: InsightCard repeat: analysis.insights @@ -28,38 +40,52 @@ layout: summary: "{{ i.summary }}" severity: "{{ i.severity }}" bind: i - - type: Card - props: - title: "Action items" - children: - - type: List - props: - bind: analysis.action_items - - type: Card - props: - title: "Decisions" + - type: Section + props: + title: "Operating agenda" + subtitle: "Decisions, actions, and unresolved questions are separated so the next step is obvious." + children: + - type: Grid children: - - type: List + - type: Card props: - bind: analysis.decisions - - type: Card - props: - title: "Open questions" - children: - - type: List + title: "Action items" + children: + - type: List + props: + bind: analysis.action_items + - type: Card props: - bind: analysis.open_questions - - type: Card - props: - title: "Entities" - children: - - type: List + title: "Decisions" + children: + - type: List + props: + bind: analysis.decisions + - type: Card props: - bind: analysis.entities - - type: Card + title: "Open questions" + children: + - type: List + props: + bind: analysis.open_questions + - type: Section props: - title: "Suggested next prompts" + title: "Context map" + subtitle: "Entities and recommended follow-ups keep the analysis actionable." children: - - type: SuggestionChips - props: - bind: analysis.next_prompts + - type: Grid + children: + - type: Card + props: + title: "Entities" + children: + - type: EntityGraph + props: + bind: analysis.entities + - type: Card + props: + title: "Suggested next prompts" + children: + - type: SuggestionChips + props: + bind: analysis.next_prompts diff --git a/tests/test_dashboard_domains.py b/tests/test_dashboard_domains.py index 732318c..b094412 100644 --- a/tests/test_dashboard_domains.py +++ b/tests/test_dashboard_domains.py @@ -46,6 +46,8 @@ def test_finance_template_uses_custom_candlestick() -> None: }) types = _types(spec) assert "Custom" in types # escape-hatch candlestick component + assert "PieChart" in types + assert "Sparkline" in types assert "Stat" in types assert len([n for n in _flatten(spec) if n["type"] == "FindingCard"]) == 1 custom = next(n for n in _flatten(spec) if n["type"] == "Custom") @@ -62,6 +64,8 @@ def test_sentiment_template_binds_gauge_value() -> None: }) gauge = next(n for n in _flatten(spec) if n["type"] == "Gauge") assert gauge["props"]["data"] == 0.82 # bound from findings[0].payload.sentiment + assert "PieChart" in _types(spec) + assert "LineChart" in _types(spec) def test_research_template_binds_paper_link_action() -> None: @@ -72,6 +76,9 @@ def test_research_template_binds_paper_link_action() -> None: "payload": {"url": "http://arxiv.org/abs/x"}}], }) cards = [n for n in _flatten(spec) if n["type"] == "FindingCard"] + types = _types(spec) + assert "PieChart" in types + assert "Timeline" in types assert len(cards) == 1 assert cards[0]["action"]["kind"] == "nav" assert cards[0]["action"]["params"]["url"] == "http://arxiv.org/abs/x" @@ -79,5 +86,7 @@ def test_research_template_binds_paper_link_action() -> None: def test_custom_component_is_in_catalog_and_survives_normalize() -> None: assert "Custom" in COMPONENT_TYPES + for component in ("BarChart", "PieChart", "LineChart", "Sparkline", "Timeline", "EntityGraph"): + assert component in COMPONENT_TYPES spec = normalize_viewspec({"root": [{"type": "Custom", "props": {"render": "candlestick"}}]}) assert spec["root"][0]["type"] == "Custom" diff --git a/tests/test_dashboard_view.py b/tests/test_dashboard_view.py index 8752f15..8b267c7 100644 --- a/tests/test_dashboard_view.py +++ b/tests/test_dashboard_view.py @@ -59,7 +59,7 @@ async def test_builder_overview_renders_findings() -> None: ) builder = DashboardViewBuilder(TemplateLibrary()) spec = await builder.build(DashboardIntent(action="home"), provider) - assert spec["title"] == "LeapFlow Monitors" + assert spec["title"] == "LeapBoard" assert len(_finding_cards(spec)) == 1 @@ -80,6 +80,8 @@ def _walk(nodes: list) -> None: _walk(node.get("children") or []) _walk(spec["root"]) + types = {n["type"] for n in flat} + assert {"BarChart", "Timeline"}.issubset(types) watch_cards = [n for n in flat if n["type"] == "Card" and n.get("action", {}).get("name") == "openWatch"] assert len(watch_cards) == 1 assert watch_cards[0]["action"]["params"]["target"] == "w1" @@ -123,6 +125,8 @@ def _walk(nodes: list) -> None: _walk(spec["root"]) types = {n["type"] for n in flat} assert "StoryPanel" in types + assert "BarChart" in types + assert "EntityGraph" in types assert len([n for n in flat if n["type"] == "InsightCard"]) == 1 # from analysis payload From 7c2146a2cfd442a77e9d0620524c6bf04ed302c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Thu, 16 Jul 2026 23:07:50 +0800 Subject: [PATCH 05/25] Enhance LeapBoard session context monitoring --- README.md | 6 +- src/leapflow/daemon/service.py | 219 ++++++++++++++++-- src/leapflow/dashboard/service.py | 18 +- src/leapflow/dashboard/static/app.js | 10 +- src/leapflow/dashboard/static/styles.css | 2 + .../dashboard/templates/session.analysis.yaml | 52 +++++ src/leapflow/engine/engine.py | 15 +- src/leapflow/monitor/session_producer.py | 125 +++++++++- tests/test_dashboard_view.py | 5 +- tests/test_session_analysis.py | 63 ++++- 10 files changed, 465 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 428c6f7..d6fc006 100644 --- a/README.md +++ b/README.md @@ -530,7 +530,7 @@ Watch (leapd-hosted) ── observe → SNR filter → score → Finding ── ``` - **Continuous observation** — a board starts as a `Watch` in `leapd`. Scheduler triggers, manual `/board refresh`, and session-analysis batch thresholds all run the same observe→finding cycle; new findings are persisted, severity-gated, and pushed to browsers over WebSocket. -- **Refresh model** — overview boards refresh when watch state or new findings arrive; session boards refresh as the conversation accumulates turns (and can optionally use model salience). Muted watches still persist findings but stop pushing notifications; paused/stopped watches stop observing. +- **Refresh model** — overview boards refresh when watch state or new findings arrive; session boards refresh as the conversation accumulates turns, when written workspace artifacts change, or when manual/model-salience triggers fire. Muted watches still persist findings but stop pushing notifications; paused/stopped watches stop observing. - **Server-Driven UI** — each scenario is authored as a **YAML template** compiled into a validated **ViewSpec** over a fixed component catalog (cards, tables, charts, timelines, gauges, story panels…). Interactive components talk back through a bidirectional action protocol; unknown component types degrade gracefully, and bespoke visuals use a `Custom` escape hatch. The board never renders arbitrary HTML/JS. - **View client** — the board connects to `leapd` like the TUI does, with no privileged coupling. The web server is optional (`aiohttp`) and degrades with a clear install hint when absent. @@ -580,6 +580,8 @@ Executing any entry auto-launches your default browser at the token-scoped local /board stop end the watch and remove it from active refresh ``` +**Session context coverage** — `/board session` watches more than chat text. It also scans current-session `file_write` tool results and, when the path is inside the workspace and safe to read without approval, includes a capped/redacted file excerpt in the analysis. The board's **Observation status** panel shows refresh reason, coverage, observed targets, included/skipped file artifacts, and missing context notes. Skipped artifacts usually mean the file is outside the workspace, no longer exists, or is sensitive enough that background reads are intentionally blocked. + **Natural language** — just ask ("open a finance board", "analyze this conversation"); the agent opens or creates boards for you when the runtime can safely map the request to a board intent. ### Showcase @@ -587,7 +589,7 @@ Executing any entry auto-launches your default browser at the token-scoped local - **Financial market watch** — monitor a symbol or source on an interval/condition; the board emphasizes price/action charts, severity mix, signal momentum, and capped evidence cards. - **Public-opinion (sentiment) watch** — track a brand or topic; sentiment direction, distribution, timeline, and representative mentions are shown as an analyst-style narrative pulse. - **Paper hunting** — watch arXiv / venues / authors / keywords; new papers flow through a research-pipeline layout with source links and clipped abstracts. -- **Session analysis (built-in)** — after a few conversation turns, `/board session` opens a board that reads the transcript and renders a storyline, insights, decisions, action items, open questions, an entity map, and suggested next prompts — refreshing automatically as the conversation grows (batch threshold, optional model salience, or manual `/board refresh`). +- **Session analysis (built-in)** — after a few conversation turns, `/board session` opens a board that reads the transcript plus safe workspace file artifacts written during the session, then renders a storyline, insights, decisions, action items, open questions, an entity map, artifact coverage, and suggested next prompts — refreshing automatically as the conversation grows or artifacts change (batch threshold, optional model salience, or manual `/board refresh`). > Adding a new scenario = one YAML template (plus an optional renderer) and a producer. The board core stays untouched — no domain branching, no hardcoded layouts. diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 1b89e17..5210e2e 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -3,8 +3,10 @@ import asyncio import inspect +import json import logging import os +import re import sys import time import uuid @@ -19,6 +21,11 @@ logger = logging.getLogger(__name__) +_MAX_SESSION_ARTIFACTS = 5 +_MAX_SESSION_ARTIFACT_CHARS = 6000 +_MAX_SESSION_ARTIFACT_TOTAL_CHARS = 16000 +_PATH_RE = re.compile(r'(?Ppath|file_path)["\'=:\s]+(?P[^"\'\n|]+)') + class RuntimeLeapService: """LeapService implementation backed by a single initialized Context.""" @@ -297,8 +304,9 @@ async def watch_findings( async def session_history(self, limit: int = 200) -> dict[str, Any]: ctx = self._ctx if ctx is None: - return {"session_id": "", "turn_count": 0, "token_count": 0, "messages": []} + return {"session_id": "", "turn_count": 0, "token_count": 0, "messages": [], "artifacts": []} engine = getattr(ctx, "engine", None) + session_id = getattr(engine, "_current_session_id", "") if engine else "" messages: list[dict[str, Any]] = [] if engine is not None: wm = getattr(engine, "_wm", None) @@ -307,26 +315,170 @@ async def session_history(self, limit: int = 200) -> dict[str, Any]: messages = [dict(m) for m in wm.as_chat_messages() if isinstance(m, dict)] except Exception: messages = [] - if not messages: - store = getattr(ctx, "_conversation_store", None) - session_id = getattr(engine, "_current_session_id", "") if engine else "" - if store is not None and session_id: - try: - messages = [ - dict(m) for m in store.get_messages(session_id, limit=int(limit)) - if isinstance(m, dict) - ] - except Exception: - messages = [] + store_messages = self._session_store_messages(session_id, limit=int(limit)) + if store_messages: + if not messages: + messages = store_messages + else: + messages.extend(m for m in store_messages if m.get("role") == "tool") normalized = [ - {"role": str(m.get("role", "")), "content": str(m.get("content", ""))} + { + "role": str(m.get("role", "")), + "content": str(m.get("content", "")), + "tool_name": str(m.get("tool_name", "") or ""), + "created_at": float(m.get("created_at", 0.0) or 0.0), + } for m in messages ][-int(limit):] + artifacts = self._collect_session_artifacts(session_id, store_messages or normalized) return { - "session_id": getattr(engine, "_current_session_id", "") if engine else "", + "session_id": session_id, "turn_count": int(getattr(engine, "turn_count", 0)) if engine else 0, "token_count": int(getattr(engine, "context_token_count", 0)) if engine else 0, "messages": normalized, + "artifacts": artifacts, + } + + def _session_store_messages(self, session_id: str, *, limit: int = 200) -> list[dict[str, Any]]: + ctx = self._ctx + store = getattr(ctx, "_conversation_store", None) if ctx is not None else None + if store is None or not session_id: + return [] + try: + rows = store.get_messages(session_id, limit=int(limit)) + except Exception: + logger.debug("daemon: session store messages unavailable", exc_info=True) + return [] + return [self._conversation_message_to_dict(row) for row in rows] + + @staticmethod + def _conversation_message_to_dict(message: Any) -> dict[str, Any]: + if isinstance(message, dict): + return dict(message) + return { + "role": str(getattr(message, "role", "")), + "content": str(getattr(message, "content", "")), + "tool_name": str(getattr(message, "tool_name", "") or ""), + "tool_call_id": str(getattr(message, "tool_call_id", "") or ""), + "created_at": float(getattr(message, "created_at", 0.0) or 0.0), + "metadata": dict(getattr(message, "metadata", {}) or {}), + } + + def _collect_session_artifacts(self, session_id: str, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not session_id: + return [] + workspace = self._workspace_root() + candidates: list[tuple[str, dict[str, Any]]] = [] + for message in messages: + if str(message.get("role", "")) != "tool": + continue + tool_name = str(message.get("tool_name", "") or "") + if tool_name and tool_name not in {"file_write", "write_file"}: + continue + for path in self._extract_artifact_paths(message): + candidates.append((path, message)) + seen: set[str] = set() + artifacts: list[dict[str, Any]] = [] + total_chars = 0 + for raw_path, message in reversed(candidates): + if len(artifacts) >= _MAX_SESSION_ARTIFACTS: + break + artifact = self._read_session_artifact(raw_path, workspace, message) + key = str(artifact.get("path") or raw_path) + if key in seen: + continue + seen.add(key) + if artifact.get("status") == "included": + content = str(artifact.get("content_excerpt", "")) + remaining = max(0, _MAX_SESSION_ARTIFACT_TOTAL_CHARS - total_chars) + if len(content) > remaining: + artifact["content_excerpt"] = content[:remaining] + artifact["truncated"] = True + artifact["reason"] = "artifact context budget reached" + total_chars += len(str(artifact.get("content_excerpt", ""))) + artifacts.append(artifact) + artifacts.reverse() + return artifacts + + @staticmethod + def _extract_artifact_paths(message: dict[str, Any]) -> list[str]: + paths: list[str] = [] + payloads = [message.get("content", ""), message.get("metadata", {})] + for payload in payloads: + if isinstance(payload, dict): + for key in ("path", "file_path"): + if payload.get(key): + paths.append(str(payload[key])) + continue + text = str(payload or "") + try: + data = json.loads(text) + if isinstance(data, dict): + for key in ("path", "file_path"): + if data.get(key): + paths.append(str(data[key])) + except Exception: + pass + for match in _PATH_RE.finditer(text): + value = match.group("value").strip().strip(",}") + if value: + paths.append(value) + return paths + + def _workspace_root(self) -> Path: + ctx = self._ctx + settings = getattr(ctx, "settings", self._settings) if ctx is not None else self._settings + return Path(str(getattr(settings, "workspace_root", os.getcwd()))).expanduser().resolve() + + def _read_session_artifact(self, raw_path: str, workspace: Path, message: dict[str, Any]) -> dict[str, Any]: + target = Path(raw_path).expanduser() + if not target.is_absolute(): + target = workspace / target + try: + target = target.resolve() + except OSError: + target = target.absolute() + base = { + "path": str(target), + "name": target.name, + "source": "file_write", + "tool_call_id": str(message.get("tool_call_id", "") or ""), + "status": "skipped", + } + try: + target.relative_to(workspace) + except ValueError: + return {**base, "reason": "outside workspace boundary"} + try: + from leapflow.security.path_sensitivity import classify_path_sensitivity + sensitivity = classify_path_sensitivity(target) + except Exception: + sensitivity = None + if sensitivity is not None: + base.update({"sensitivity": sensitivity.category, "sensitivity_level": sensitivity.level}) + if not sensitivity.readable or sensitivity.requires_approval or sensitivity.redact_on_read: + return {**base, "reason": f"sensitive path ({sensitivity.category}) not read in background"} + if not target.exists() or not target.is_file(): + return {**base, "reason": "file no longer exists"} + try: + stat = target.stat() + content = target.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return {**base, "reason": f"read failed: {exc}"} + truncated = len(content) > _MAX_SESSION_ARTIFACT_CHARS + excerpt = content[:_MAX_SESSION_ARTIFACT_CHARS] + try: + from leapflow.security.redact import redact_sensitive_text + excerpt = redact_sensitive_text(excerpt, file_read=bool(getattr(sensitivity, "redact_on_read", False))) + except Exception: + pass + return { + **base, + "status": "included", + "size": int(stat.st_size), + "mtime": float(stat.st_mtime), + "content_excerpt": excerpt, + "truncated": truncated, } async def session_analyze(self) -> dict[str, Any]: @@ -343,7 +495,12 @@ async def _ensure_session_watch(self) -> str: settings = getattr(self._ctx, "settings", self._settings) return await ensure_session_watch(monitors, params=session_watch_params(settings)) - async def _analyze_session_llm(self, messages: list[dict[str, Any]]) -> dict[str, Any]: + async def _analyze_session_llm( + self, + messages: list[dict[str, Any]], + *, + artifacts: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: base: dict[str, Any] = { "story": "", "insights": [], "decisions": [], "action_items": [], "open_questions": [], "entities": [], "next_prompts": [], "usage": {}, @@ -355,9 +512,11 @@ async def _analyze_session_llm(self, messages: list[dict[str, Any]]) -> dict[str transcript = "\n".join( f"{m.get('role', '')}: {str(m.get('content', ''))[:500]}" for m in messages[-40:] )[:12000] + artifact_block = self._format_artifact_context(artifacts or []) + user_content = transcript if not artifact_block else f"{transcript}\n\n## Session file artifacts\n{artifact_block}" prompt = [ {"role": "system", "content": _SESSION_ANALYSIS_SYSTEM}, - {"role": "user", "content": transcript}, + {"role": "user", "content": user_content[:18000]}, ] try: response = await llm.achat(prompt, stream=False) @@ -371,6 +530,20 @@ async def _analyze_session_llm(self, messages: list[dict[str, Any]]) -> dict[str base[key] = data[key] return base + @staticmethod + def _format_artifact_context(artifacts: list[dict[str, Any]]) -> str: + lines: list[str] = [] + for artifact in artifacts: + status = str(artifact.get("status", "")) + path = str(artifact.get("path", "")) + if status != "included": + lines.append(f"- SKIPPED {path}: {artifact.get('reason', 'not included')}") + continue + excerpt = str(artifact.get("content_excerpt", ""))[:_MAX_SESSION_ARTIFACT_CHARS] + truncated = " (truncated)" if artifact.get("truncated") else "" + lines.append(f"- FILE {path}{truncated}\n```text\n{excerpt}\n```") + return "\n".join(lines) + async def _session_should_refresh(self, messages: list[dict[str, Any]]) -> bool: ctx = self._ctx llm = getattr(ctx, "llm", None) if ctx is not None else None @@ -926,8 +1099,10 @@ def _chunk_from_event(self, event: StreamEvent, *, request_id: str = "") -> Stre "JSON only, with keys: story (string narrative of the session arc), insights " "(array of {title, summary, severity in [info,notable,alert]}), decisions " "(array of strings), action_items (array of strings), open_questions (array of " - "strings), entities (array of strings), next_prompts (array of strings). Do not " - "wrap the JSON in prose or code fences." + "strings), entities (array of strings), next_prompts (array of strings). If a " + "Session file artifacts section is present, incorporate the artifact contents " + "as first-class evidence and mention generated files when they materially " + "change the analysis. Do not wrap the JSON in prose or code fences." ) _SESSION_SALIENCE_SYSTEM = ( @@ -967,9 +1142,13 @@ async def session_history(self) -> dict[str, Any]: return await self._service.session_history() async def analyze_session( - self, messages: list[dict[str, Any]], *, prior: dict[str, Any] | None = None + self, + messages: list[dict[str, Any]], + *, + prior: dict[str, Any] | None = None, + artifacts: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: - return await self._service._analyze_session_llm(messages) + return await self._service._analyze_session_llm(messages, artifacts=artifacts) async def should_refresh(self, messages: list[dict[str, Any]]) -> bool: return await self._service._session_should_refresh(messages) diff --git a/src/leapflow/dashboard/service.py b/src/leapflow/dashboard/service.py index 94d5709..e4644de 100644 --- a/src/leapflow/dashboard/service.py +++ b/src/leapflow/dashboard/service.py @@ -96,15 +96,29 @@ async def _build_watch(self, intent: DashboardIntent, provider: DashboardDataPro return self._templates.render(name, data) async def _build_session(self, intent: DashboardIntent, provider: DashboardDataProvider) -> dict[str, Any]: - # The session watch (P3) emits an insight finding whose payload carries - # the structured analysis. Until it exists this renders gracefully empty. + # The session watch emits an insight finding whose payload carries the + # structured analysis plus observation transparency metadata. + watches = await provider.watches() + session_watch = next((w for w in watches if str(w.get("domain")) == "session"), {}) findings = await provider.findings(watch_id="", limit=50) session_findings = [f for f in findings if str(f.get("domain")) == "session"] analysis = session_findings[0].get("payload") if session_findings else {} + observation = dict((analysis or {}).get("observation_status") or {}) + if session_watch: + observation.update({ + "watch_state": session_watch.get("state", ""), + "watch_muted": session_watch.get("muted", False), + "last_run_at": session_watch.get("last_run_at", 0), + "next_due_at": session_watch.get("next_due_at", 0), + "run_count": session_watch.get("run_count", 0), + }) data = { "title": "Session Analysis", "analysis": analysis or {}, + "observation": observation, + "artifact_context": (analysis or {}).get("artifact_context") or [], "findings": session_findings, + "watch": session_watch, } name = select_template("session", intent.template, self._templates.names()) return self._templates.render(name, data) diff --git a/src/leapflow/dashboard/static/app.js b/src/leapflow/dashboard/static/app.js index 439605e..55ac28b 100644 --- a/src/leapflow/dashboard/static/app.js +++ b/src/leapflow/dashboard/static/app.js @@ -17,11 +17,11 @@ let current = { action: params.get("action") || "home", target: params.get("target") || "" }; const I18N = { - zh: { "Overview": "概览", "Session": "会话", "Language": "语言", "connecting…": "连接中…", "live": "实时", "reconnecting…": "重连中…", "Loading…": "加载中…", "No content yet.": "暂无内容。", "Failed to load view": "视图加载失败", "Action failed": "操作失败", "Candlestick": "K线", "Series": "序列", "Gauge": "仪表", "Custom": "自定义", "unknown": "未知", "Watch portfolio": "观察组合", "Refresh cadence": "刷新节奏", "Active watches": "活跃观察", "Recent findings": "最新发现", "Watches": "观察任务", "Findings": "发现", "Signals": "信号", "Watch": "观察", "Price action": "价格行为", "Signal mix": "信号结构", "Market brief": "市场简报", "Latest sentiment": "最新情绪", "Mentions": "提及", "Sentiment structure": "情绪结构", "Narrative pulse": "叙事脉搏", "New papers": "新论文", "Research pipeline": "研究管线", "Evidence stream": "证据流", "Executive brief": "执行摘要", "Storyline": "叙事线", "Insights": "洞察", "Action items": "行动项", "Decisions": "决策", "Open questions": "待回答问题", "Entities": "实体", "Suggested next prompts": "建议追问", "Timeline": "时间线", "Severity mix": "严重度结构", "alert": "警报", "notable": "重要", "info": "信息" }, - fr: { "Overview": "Vue d’ensemble", "Session": "Session", "Language": "Langue", "connecting…": "connexion…", "live": "direct", "reconnecting…": "reconnexion…", "Loading…": "chargement…", "No content yet.": "Aucun contenu.", "Failed to load view": "Échec du chargement", "Action failed": "Action échouée", "Watch": "Veille", "Watches": "Veilles", "Findings": "Constats", "Recent findings": "Constats récents", "Signals": "Signaux", "Insights": "Analyses", "Action items": "Actions", "Decisions": "Décisions", "Open questions": "Questions ouvertes", "Entities": "Entités", "Suggested next prompts": "Prochaines invites", "Executive brief": "Synthèse exécutive", "Storyline": "Narratif", "Timeline": "Chronologie", "Severity mix": "Mix de sévérité", "alert": "alerte", "notable": "notable", "info": "info" }, - es: { "Overview": "Resumen", "Session": "Sesión", "Language": "Idioma", "connecting…": "conectando…", "live": "en vivo", "reconnecting…": "reconectando…", "Loading…": "cargando…", "No content yet.": "Sin contenido.", "Failed to load view": "Error al cargar", "Action failed": "Acción fallida", "Watch": "Vigilancia", "Watches": "Vigilancias", "Findings": "Hallazgos", "Recent findings": "Hallazgos recientes", "Signals": "Señales", "Insights": "Ideas", "Action items": "Acciones", "Decisions": "Decisiones", "Open questions": "Preguntas abiertas", "Entities": "Entidades", "Suggested next prompts": "Siguientes preguntas", "Executive brief": "Resumen ejecutivo", "Storyline": "Narrativa", "Timeline": "Cronología", "Severity mix": "Mezcla de severidad", "alert": "alerta", "notable": "relevante", "info": "info" }, - ar: { "Overview": "نظرة عامة", "Session": "الجلسة", "Language": "اللغة", "connecting…": "جارٍ الاتصال…", "live": "مباشر", "reconnecting…": "إعادة الاتصال…", "Loading…": "جارٍ التحميل…", "No content yet.": "لا يوجد محتوى بعد.", "Failed to load view": "فشل تحميل العرض", "Action failed": "فشل الإجراء", "Watch": "مراقبة", "Watches": "المراقبات", "Findings": "النتائج", "Recent findings": "أحدث النتائج", "Signals": "الإشارات", "Insights": "الرؤى", "Action items": "إجراءات", "Decisions": "قرارات", "Open questions": "أسئلة مفتوحة", "Entities": "كيانات", "Suggested next prompts": "أسئلة مقترحة", "Executive brief": "ملخص تنفيذي", "Storyline": "السرد", "Timeline": "الخط الزمني", "Severity mix": "توزيع الشدة", "alert": "تنبيه", "notable": "مهم", "info": "معلومة" }, - ru: { "Overview": "Обзор", "Session": "Сессия", "Language": "Язык", "connecting…": "подключение…", "live": "онлайн", "reconnecting…": "переподключение…", "Loading…": "загрузка…", "No content yet.": "Пока нет данных.", "Failed to load view": "Не удалось загрузить", "Action failed": "Действие не выполнено", "Watch": "Наблюдение", "Watches": "Наблюдения", "Findings": "Находки", "Recent findings": "Последние находки", "Signals": "Сигналы", "Insights": "Инсайты", "Action items": "Действия", "Decisions": "Решения", "Open questions": "Открытые вопросы", "Entities": "Сущности", "Suggested next prompts": "Следующие запросы", "Executive brief": "Краткий обзор", "Storyline": "Сюжет", "Timeline": "Хронология", "Severity mix": "Структура важности", "alert": "тревога", "notable": "важно", "info": "инфо" } + zh: { "Overview": "概览", "Session": "会话", "Language": "语言", "connecting…": "连接中…", "live": "实时", "reconnecting…": "重连中…", "Loading…": "加载中…", "No content yet.": "暂无内容。", "Failed to load view": "视图加载失败", "Action failed": "操作失败", "Candlestick": "K线", "Series": "序列", "Gauge": "仪表", "Custom": "自定义", "unknown": "未知", "Watch portfolio": "观察组合", "Refresh cadence": "刷新节奏", "Active watches": "活跃观察", "Recent findings": "最新发现", "Watches": "观察任务", "Findings": "发现", "Signals": "信号", "Watch": "观察", "Price action": "价格行为", "Signal mix": "信号结构", "Market brief": "市场简报", "Latest sentiment": "最新情绪", "Mentions": "提及", "Sentiment structure": "情绪结构", "Narrative pulse": "叙事脉搏", "New papers": "新论文", "Research pipeline": "研究管线", "Evidence stream": "证据流", "Executive brief": "执行摘要", "Storyline": "叙事线", "Insights": "洞察", "Action items": "行动项", "Decisions": "决策", "Open questions": "待回答问题", "Entities": "实体", "Suggested next prompts": "建议追问", "Timeline": "时间线", "Severity mix": "严重度结构", "alert": "警报", "notable": "重要", "info": "信息", "Observation status": "观察状态", "Refresh state": "刷新状态", "Refresh reason": "刷新原因", "Coverage": "覆盖率", "Artifacts": "副产物", "Observed context": "已观察上下文", "File artifacts": "文件副产物", "File": "文件", "Status": "状态", "Note": "说明", "manual_refresh": "手动刷新", "first_observation": "首次观察", "artifact_changed": "文件副产物变化", "batch_turns": "轮次阈值", "batch_tokens": "上下文阈值", "model_salience": "模型显著性" }, + fr: { "Overview": "Vue d’ensemble", "Session": "Session", "Language": "Langue", "connecting…": "connexion…", "live": "direct", "reconnecting…": "reconnexion…", "Loading…": "chargement…", "No content yet.": "Aucun contenu.", "Failed to load view": "Échec du chargement", "Action failed": "Action échouée", "Watch": "Veille", "Watches": "Veilles", "Findings": "Constats", "Recent findings": "Constats récents", "Signals": "Signaux", "Insights": "Analyses", "Action items": "Actions", "Decisions": "Décisions", "Open questions": "Questions ouvertes", "Entities": "Entités", "Suggested next prompts": "Prochaines invites", "Executive brief": "Synthèse exécutive", "Storyline": "Narratif", "Timeline": "Chronologie", "Severity mix": "Mix de sévérité", "alert": "alerte", "notable": "notable", "info": "info", "Observation status": "Statut d’observation", "Refresh state": "État", "Refresh reason": "Raison", "Coverage": "Couverture", "Artifacts": "Artefacts", "Observed context": "Contexte observé", "File artifacts": "Fichiers", "File": "Fichier", "Status": "Statut", "Note": "Note", "manual_refresh": "actualisation manuelle", "first_observation": "première observation", "artifact_changed": "artefact modifié", "batch_turns": "seuil de tours", "batch_tokens": "seuil de jetons", "model_salience": "saillance modèle" }, + es: { "Overview": "Resumen", "Session": "Sesión", "Language": "Idioma", "connecting…": "conectando…", "live": "en vivo", "reconnecting…": "reconectando…", "Loading…": "cargando…", "No content yet.": "Sin contenido.", "Failed to load view": "Error al cargar", "Action failed": "Acción fallida", "Watch": "Vigilancia", "Watches": "Vigilancias", "Findings": "Hallazgos", "Recent findings": "Hallazgos recientes", "Signals": "Señales", "Insights": "Ideas", "Action items": "Acciones", "Decisions": "Decisiones", "Open questions": "Preguntas abiertas", "Entities": "Entidades", "Suggested next prompts": "Siguientes preguntas", "Executive brief": "Resumen ejecutivo", "Storyline": "Narrativa", "Timeline": "Cronología", "Severity mix": "Mezcla de severidad", "alert": "alerta", "notable": "relevante", "info": "info", "Observation status": "Estado de observación", "Refresh state": "Estado", "Refresh reason": "Motivo", "Coverage": "Cobertura", "Artifacts": "Artefactos", "Observed context": "Contexto observado", "File artifacts": "Archivos", "File": "Archivo", "Status": "Estado", "Note": "Nota", "manual_refresh": "actualización manual", "first_observation": "primera observación", "artifact_changed": "artefacto cambiado", "batch_turns": "umbral de turnos", "batch_tokens": "umbral de tokens", "model_salience": "relevancia del modelo" }, + ar: { "Overview": "نظرة عامة", "Session": "الجلسة", "Language": "اللغة", "connecting…": "جارٍ الاتصال…", "live": "مباشر", "reconnecting…": "إعادة الاتصال…", "Loading…": "جارٍ التحميل…", "No content yet.": "لا يوجد محتوى بعد.", "Failed to load view": "فشل تحميل العرض", "Action failed": "فشل الإجراء", "Watch": "مراقبة", "Watches": "المراقبات", "Findings": "النتائج", "Recent findings": "أحدث النتائج", "Signals": "الإشارات", "Insights": "الرؤى", "Action items": "إجراءات", "Decisions": "قرارات", "Open questions": "أسئلة مفتوحة", "Entities": "كيانات", "Suggested next prompts": "أسئلة مقترحة", "Executive brief": "ملخص تنفيذي", "Storyline": "السرد", "Timeline": "الخط الزمني", "Severity mix": "توزيع الشدة", "alert": "تنبيه", "notable": "مهم", "info": "معلومة", "Observation status": "حالة المراقبة", "Refresh state": "حالة التحديث", "Refresh reason": "سبب التحديث", "Coverage": "التغطية", "Artifacts": "المخرجات", "Observed context": "السياق المرصود", "File artifacts": "ملفات", "File": "ملف", "Status": "الحالة", "Note": "ملاحظة", "manual_refresh": "تحديث يدوي", "first_observation": "أول مراقبة", "artifact_changed": "تغير ملف", "batch_turns": "حد الجولات", "batch_tokens": "حد الرموز", "model_salience": "أهمية النموذج" }, + ru: { "Overview": "Обзор", "Session": "Сессия", "Language": "Язык", "connecting…": "подключение…", "live": "онлайн", "reconnecting…": "переподключение…", "Loading…": "загрузка…", "No content yet.": "Пока нет данных.", "Failed to load view": "Не удалось загрузить", "Action failed": "Действие не выполнено", "Watch": "Наблюдение", "Watches": "Наблюдения", "Findings": "Находки", "Recent findings": "Последние находки", "Signals": "Сигналы", "Insights": "Инсайты", "Action items": "Действия", "Decisions": "Решения", "Open questions": "Открытые вопросы", "Entities": "Сущности", "Suggested next prompts": "Следующие запросы", "Executive brief": "Краткий обзор", "Storyline": "Сюжет", "Timeline": "Хронология", "Severity mix": "Структура важности", "alert": "тревога", "notable": "важно", "info": "инфо", "Observation status": "Статус наблюдения", "Refresh state": "Состояние", "Refresh reason": "Причина", "Coverage": "Покрытие", "Artifacts": "Артефакты", "Observed context": "Наблюдаемый контекст", "File artifacts": "Файлы", "File": "Файл", "Status": "Статус", "Note": "Заметка", "manual_refresh": "ручное обновление", "first_observation": "первое наблюдение", "artifact_changed": "файл изменён", "batch_turns": "порог ходов", "batch_tokens": "порог токенов", "model_salience": "значимость модели" } }; function t(key) { return (I18N[locale] && I18N[locale][key]) || key; } diff --git a/src/leapflow/dashboard/static/styles.css b/src/leapflow/dashboard/static/styles.css index 70d5b07..6144ff8 100644 --- a/src/leapflow/dashboard/static/styles.css +++ b/src/leapflow/dashboard/static/styles.css @@ -92,6 +92,8 @@ body { .timeline-item.sev-alert::before { background: var(--alert); } .timeline-item.sev-notable::before { background: var(--notable); } .timeline-title { font-weight: 650; } +.progress { height: 10px; background: #222735; border-radius: 999px; overflow: hidden; margin-top: 10px; } +.progress-fill { display: block; height: 100%; background: var(--accent); border-radius: 999px; } .data-table { font-size: 13px; } .quote { margin: 0; padding: 10px 14px; border-left: 3px solid var(--accent); color: var(--text); background: rgba(143,164,199,.08); } .quote cite { display: block; color: var(--muted); margin-top: 6px; font-size: 12px; } diff --git a/src/leapflow/dashboard/templates/session.analysis.yaml b/src/leapflow/dashboard/templates/session.analysis.yaml index d8e2955..e3228fb 100644 --- a/src/leapflow/dashboard/templates/session.analysis.yaml +++ b/src/leapflow/dashboard/templates/session.analysis.yaml @@ -10,6 +10,58 @@ layout: props: title: "Session Analysis" children: + - type: Section + props: + title: "Observation status" + subtitle: "LeapBoard continuously watches the transcript, tool results, and workspace file artifacts. This panel explains what triggered the latest refresh and what context was included." + children: + - type: Grid + children: + - type: Card + props: + title: "Refresh state" + children: + - type: Row + children: + - type: Stat + props: + label: "Refresh reason" + value: "{{ observation.refresh_reason }}" + - type: Stat + props: + label: "Coverage" + value: "{{ observation.context_coverage_pct }}%" + - type: Stat + props: + label: "Artifacts" + value: "{{ observation.artifacts_included }}/{{ observation.artifact_count }}" + - type: ProgressBar + props: + value: "{{ observation.context_coverage_pct }}" + - type: Card + props: + title: "Observed context" + children: + - type: List + props: + bind: observation.observed_targets + - type: List + props: + bind: observation.missing_items + - type: Card + props: + title: "File artifacts" + children: + - type: Table + props: + bind: artifact_context + columns: + - key: name + label: File + - key: status + label: Status + - key: reason + label: Note - type: Section props: title: "Executive brief" diff --git a/src/leapflow/engine/engine.py b/src/leapflow/engine/engine.py index 7c22f9c..487c406 100644 --- a/src/leapflow/engine/engine.py +++ b/src/leapflow/engine/engine.py @@ -46,11 +46,10 @@ from leapflow.engine.stale_stream import StaleStreamError, stale_guarded_stream, build_continuation_prompt from leapflow.engine.turn_recovery import TurnRecoveryState from leapflow.engine.turn_usage import TurnUsageTracker -from leapflow.engine.recovery_coordinator import RecoveryCoordinator, RecoveryState +from leapflow.engine.recovery_coordinator import RecoveryCoordinator from leapflow.engine.recovery_budget import RecoveryBudget from leapflow.engine.unified_classifier import UnifiedErrorClassifier -from leapflow.engine.failure_envelope import FailureEnvelope, FailureSource, Recoverability -from leapflow.engine.recovery_decision import RecoveryAction +from leapflow.engine.recovery_decision import RecoveryAction, RecoveryDecision from leapflow.engine.recovery_strategies import default_strategies from leapflow.engine.recovery_audit import JsonlAuditSink, create_audit_entry from leapflow.engine.recovery_checkpoint import RecoveryCheckpoint, InMemoryCheckpointStore @@ -2323,7 +2322,6 @@ async def _unified_tool_loop( except Exception as exc: _clear_indicator() classified = self._error_classifier.classify(exc) - rec = self._error_classifier.get_recovery(classified) category_str = classified.value if hasattr(classified, 'value') else str(classified) recovery.record_api_error(category_str) @@ -2835,8 +2833,6 @@ async def _unified_tool_loop_stream( self._record_provider_usage(resp.model or '', usage) except Exception as exc: _clear_indicator() - classified = self._error_classifier.classify(exc) - rec = self._error_classifier.get_recovery(classified) turn_recovery.record_api_error() # Classify through unified coordinator @@ -3028,8 +3024,6 @@ async def _unified_tool_loop_stream( break except Exception as exc: _clear_indicator() - classified = self._error_classifier.classify(exc) - rec = self._error_classifier.get_recovery(classified) turn_recovery.record_api_error() # Classify through unified coordinator envelope = self._unified_classifier.classify_llm_error( @@ -3089,8 +3083,6 @@ async def _unified_tool_loop_stream( self._last_context_tokens = provider_prompt except Exception as exc: _clear_indicator() - classified = self._error_classifier.classify(exc) - rec = self._error_classifier.get_recovery(classified) turn_recovery.record_api_error() # Classify through unified coordinator envelope = self._unified_classifier.classify_llm_error( @@ -3846,7 +3838,8 @@ def _tool_execution_metadata(result: Any) -> Dict[str, Any]: "execution_id", "idempotency_key", "execution_status", "execution_policy", "already_executed", "duplicate_suppressed", "execution_reused", "execution_skipped", "counts_as_failure", "counts_as_tool_attempt", "ui_hidden", "skipped_reason", - "blocked_by_tool", "blocked_by_error", "tool_call_id", + "blocked_by_tool", "blocked_by_error", "tool_call_id", "path", "file_path", + "bytes_written", ): if key in result: metadata[key] = result[key] diff --git a/src/leapflow/monitor/session_producer.py b/src/leapflow/monitor/session_producer.py index c0de41c..0d89bde 100644 --- a/src/leapflow/monitor/session_producer.py +++ b/src/leapflow/monitor/session_producer.py @@ -40,9 +40,13 @@ async def session_history(self) -> dict[str, Any]: ... async def analyze_session( - self, messages: list[dict[str, Any]], *, prior: dict[str, Any] | None = None + self, + messages: list[dict[str, Any]], + *, + prior: dict[str, Any] | None = None, + artifacts: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: - """Return a structured analysis payload for the transcript.""" + """Return a structured analysis payload for the transcript and artifacts.""" ... async def should_refresh(self, messages: list[dict[str, Any]]) -> bool: @@ -55,12 +59,98 @@ def _first_line(text: str, limit: int = 180) -> str: return line if len(line) <= limit else line[: limit - 1] + "\u2026" +def _artifact_fingerprint(artifacts: list[dict[str, Any]]) -> str: + """Return a stable fingerprint for artifact state changes.""" + parts: list[str] = [] + for artifact in artifacts: + path = str(artifact.get("path", "")) + mtime = str(artifact.get("mtime", "")) + size = str(artifact.get("size", "")) + status = str(artifact.get("status", "")) + parts.append(f"{path}:{mtime}:{size}:{status}") + return "|".join(sorted(parts)) + + +def _refresh_reason( + *, + force: bool, + first: bool, + artifact_changed: bool, + turn_delta: int, + token_delta: int, + batch_turns: int, + batch_tokens: int, +) -> str: + if force: + return "manual_refresh" + if first: + return "first_observation" + if artifact_changed: + return "artifact_changed" + if turn_delta >= batch_turns: + return "batch_turns" + if token_delta >= batch_tokens: + return "batch_tokens" + return "model_salience" + + +def _observation_status( + *, + artifacts: list[dict[str, Any]], + reason: str, + now: float, + turn_count: int, + token_count: int, + batch_turns: int, + batch_tokens: int, + last_turn: int, +) -> dict[str, Any]: + included = [a for a in artifacts if a.get("status") == "included"] + skipped = [a for a in artifacts if a.get("status") == "skipped"] + observed_targets = ["conversation transcript", "tool results"] + if artifacts: + observed_targets.append("file artifacts") + missing_items: list[str] = [] + if not artifacts: + missing_items.append("No file-write artifacts detected in the current session yet.") + for artifact in skipped[:5]: + label = artifact.get("path") or artifact.get("name") or "artifact" + missing_items.append(f"{label}: {artifact.get('reason', 'not included')}") + coverage = 0.7 + if included: + coverage += 0.2 + if artifacts and not skipped: + coverage += 0.1 + if skipped: + coverage -= min(0.3, len(skipped) * 0.08) + coverage = max(0.1, min(1.0, coverage)) + return { + "state": "watching", + "refresh_reason": reason, + "last_refresh_at": now, + "observed_targets": observed_targets, + "context_coverage_pct": round(coverage * 100), + "missing_items": missing_items, + "artifact_count": len(artifacts), + "artifacts_included": len(included), + "artifacts_skipped": len(skipped), + "next_threshold": { + "turns": batch_turns, + "tokens": batch_tokens, + "turns_since_last": max(0, turn_count - max(last_turn, 0)), + "current_turns": turn_count, + "current_tokens": token_count, + }, + } + + class SessionAnalysisProducer: """Produce session-analysis findings from the live conversation transcript.""" def __init__(self) -> None: self._last_turn: dict[str, int] = {} self._last_tokens: dict[str, int] = {} + self._last_artifact_fingerprint: dict[str, str] = {} self._last_run_at: dict[str, float] = {} self._recent_runs: dict[str, list[float]] = {} @@ -89,12 +179,15 @@ async def observe(self, ctx: ProducerContext) -> Sequence[Finding]: token_count = int(history.get("token_count", 0) or 0) session_id = str(history.get("session_id", "") or "") messages = list(history.get("messages") or []) + artifacts = list(history.get("artifacts") or []) + artifact_fingerprint = _artifact_fingerprint(artifacts) last_turn = self._last_turn.get(watch_id, -1) first = last_turn < 0 + artifact_changed = bool(artifact_fingerprint and artifact_fingerprint != self._last_artifact_fingerprint.get(watch_id, "")) - if not ctx.force and not first and turn_count <= last_turn: - return [] # no new turns since last analysis + if not ctx.force and not first and turn_count <= last_turn and not artifact_changed: + return [] # no new turns or artifacts since last analysis if not ctx.force and not first: if (ctx.now - self._last_run_at.get(watch_id, 0.0)) < debounce_s: @@ -106,6 +199,7 @@ async def observe(self, ctx: ProducerContext) -> Sequence[Finding]: should = ( ctx.force or first + or artifact_changed or (turn_count - last_turn >= batch_turns) or (token_count - self._last_tokens.get(watch_id, 0) >= batch_tokens) ) @@ -117,7 +211,18 @@ async def observe(self, ctx: ProducerContext) -> Sequence[Finding]: if not should: return [] + reason = _refresh_reason( + force=ctx.force, + first=first, + artifact_changed=artifact_changed, + turn_delta=turn_count - last_turn, + token_delta=token_count - self._last_tokens.get(watch_id, 0), + batch_turns=batch_turns, + batch_tokens=batch_tokens, + ) try: + analysis = dict(await services.analyze_session(messages, artifacts=artifacts)) + except TypeError: analysis = dict(await services.analyze_session(messages)) except Exception as exc: # noqa: BLE001 - surface as no-op, not crash logger.debug("session producer: analysis failed: %s", exc) @@ -125,11 +230,23 @@ async def observe(self, ctx: ProducerContext) -> Sequence[Finding]: self._last_turn[watch_id] = turn_count self._last_tokens[watch_id] = token_count + self._last_artifact_fingerprint[watch_id] = artifact_fingerprint self._last_run_at[watch_id] = ctx.now self._recent_runs.setdefault(watch_id, []).append(ctx.now) analysis.setdefault("usage", {}) analysis["usage"].update({"turns": turn_count, "tokens": token_count}) + analysis["artifact_context"] = artifacts + analysis["observation_status"] = _observation_status( + artifacts=artifacts, + reason=reason, + now=ctx.now, + turn_count=turn_count, + token_count=token_count, + batch_turns=batch_turns, + batch_tokens=batch_tokens, + last_turn=last_turn, + ) story = str(analysis.get("story") or "") summary = _first_line(story) if story else f"{turn_count} turns analyzed" return [ diff --git a/tests/test_dashboard_view.py b/tests/test_dashboard_view.py index 8b267c7..9124ccb 100644 --- a/tests/test_dashboard_view.py +++ b/tests/test_dashboard_view.py @@ -100,13 +100,15 @@ async def test_builder_watch_scopes_to_target() -> None: async def test_builder_session_uses_analysis_payload() -> None: provider = _FakeProvider( - watches=[], + watches=[{"watch_id": "s", "domain": "session", "state": "armed", "last_run_at": 10.0, "next_due_at": 20.0, "run_count": 2}], findings=[ {"finding_id": "s1", "watch_id": "s", "domain": "session", "title": "analysis", "severity": "notable", "payload": { "story": "the arc", "insights": [{"title": "i", "summary": "s", "severity": "notable"}], "next_prompts": ["p"], + "observation_status": {"refresh_reason": "artifact_changed", "context_coverage_pct": 90}, + "artifact_context": [{"name": "report.md", "status": "included"}], }}, {"finding_id": "x1", "watch_id": "w", "domain": "finance", "title": "noise", "severity": "info"}, ], @@ -127,6 +129,7 @@ def _walk(nodes: list) -> None: assert "StoryPanel" in types assert "BarChart" in types assert "EntityGraph" in types + assert "Table" in types assert len([n for n in flat if n["type"] == "InsightCard"]) == 1 # from analysis payload diff --git a/tests/test_session_analysis.py b/tests/test_session_analysis.py index 0cbd187..664f342 100644 --- a/tests/test_session_analysis.py +++ b/tests/test_session_analysis.py @@ -14,6 +14,7 @@ from leapflow.monitor import MonitorManager, ProducerRegistry, SessionAnalysisProducer, WatchSpec from leapflow.monitor.types import ProducerContext from leapflow.storage.connection import LocalConnectionHolder +from leapflow.storage.conversation_store import ConversationMessage class _FakeServices: @@ -26,9 +27,12 @@ def __init__(self, history: dict, analysis: dict, salient: bool = False) -> None async def session_history(self) -> dict: return dict(self._history) - async def analyze_session(self, messages, *, prior=None) -> dict: + async def analyze_session(self, messages, *, prior=None, artifacts=None) -> dict: self.analyze_calls += 1 - return dict(self._analysis) + data = dict(self._analysis) + if artifacts is not None: + data["seen_artifacts"] = list(artifacts) + return data async def should_refresh(self, messages) -> bool: return self._salient @@ -87,6 +91,20 @@ async def test_session_producer_force_without_new_turns() -> None: assert len(out) == 1 and out[0].payload["story"] == "forced" +async def test_session_producer_artifact_change_refreshes_without_new_turns() -> None: + prod = SessionAnalysisProducer() + spec = WatchSpec(name="Session", watch_id="w-art", domain="session", params={"use_model_salience": False, "debounce_s": 0}) + base_history = {"session_id": "s", "turn_count": 2, "token_count": 10, "messages": [{"role": "u", "content": "x"}]} + await prod.observe(_ctx(spec, _FakeServices({**base_history, "artifacts": []}, {"story": "initial"}), now=1.0)) + artifact = {"path": "/workspace/report.md", "status": "included", "mtime": 2.0, "size": 12, "content_excerpt": "new report"} + svc = _FakeServices({**base_history, "artifacts": [artifact]}, {"story": "with artifact"}) + out = list(await prod.observe(_ctx(spec, svc, now=2.0))) + assert len(out) == 1 + assert out[0].payload["artifact_context"][0]["content_excerpt"] == "new report" + assert out[0].payload["observation_status"]["refresh_reason"] == "artifact_changed" + assert out[0].payload["observation_status"]["artifacts_included"] == 1 + + async def test_session_dedup_key_is_session_scoped() -> None: prod = SessionAnalysisProducer() spec = WatchSpec(name="Session", watch_id="w", domain="session", params={"use_model_salience": False, "debounce_s": 0}) @@ -133,8 +151,8 @@ def as_chat_messages(self): return [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}] engine = SimpleNamespace(_wm=_WM(), _current_session_id="sid", turn_count=2, context_token_count=42) - service = RuntimeLeapService(SimpleNamespace()) - service._ctx = SimpleNamespace(engine=engine, _conversation_store=None) + service = RuntimeLeapService(SimpleNamespace(workspace_root=".")) + service._ctx = SimpleNamespace(engine=engine, _conversation_store=None, settings=SimpleNamespace(workspace_root=".")) history = await service.session_history() assert history["turn_count"] == 2 assert history["token_count"] == 42 @@ -142,6 +160,32 @@ def as_chat_messages(self): assert [m["role"] for m in history["messages"]] == ["user", "assistant"] +async def test_session_history_collects_file_write_artifact(tmp_path: Path) -> None: + artifact = tmp_path / "china_ecommerce_overseas_analysis.md" + artifact.write_text("# China ecommerce\n\nKey findings from the research.", encoding="utf-8") + + class _Store: + def get_messages(self, session_id, *, limit=200): + assert session_id == "sid" + return [ConversationMessage( + message_id="m1", + session_id="sid", + role="tool", + content='{"ok": true, "path": "china_ecommerce_overseas_analysis.md", "bytes_written": 45}', + tool_name="file_write", + tool_call_id="t1", + )] + + engine = SimpleNamespace(_wm=None, _current_session_id="sid", turn_count=1, context_token_count=10) + settings = SimpleNamespace(workspace_root=str(tmp_path)) + service = RuntimeLeapService(settings) + service._ctx = SimpleNamespace(engine=engine, _conversation_store=_Store(), settings=settings) + history = await service.session_history() + assert history["artifacts"][0]["status"] == "included" + assert history["artifacts"][0]["name"] == "china_ecommerce_overseas_analysis.md" + assert "Key findings" in history["artifacts"][0]["content_excerpt"] + + async def test_session_history_empty_without_context() -> None: service = RuntimeLeapService(SimpleNamespace()) history = await service.session_history() @@ -162,7 +206,14 @@ def test_session_template_renders_analysis() -> None: "open_questions": [], "entities": ["Alice"], "next_prompts": ["ask z"], - }}) + }, "observation": { + "refresh_reason": "artifact_changed", + "context_coverage_pct": 90, + "artifacts_included": 1, + "artifact_count": 1, + "observed_targets": ["conversation transcript", "file artifacts"], + "missing_items": [], + }, "artifact_context": [{"name": "report.md", "status": "included", "reason": ""}]}) flat: list[dict] = [] def _walk(nodes: list) -> None: @@ -173,4 +224,6 @@ def _walk(nodes: list) -> None: _walk(spec["root"]) types = {n["type"] for n in flat} assert "StoryPanel" in types + assert "ProgressBar" in types + assert "Table" in types assert len([n for n in flat if n["type"] == "InsightCard"]) == 1 From 4b8cf86b53fa7b248139f6042871602079b9f28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Thu, 16 Jul 2026 23:14:22 +0800 Subject: [PATCH 06/25] Harden LeapBoard watch shutdown lifecycle --- src/leapflow/cli/commands/interactive.py | 67 +++++++++++++++++++++--- src/leapflow/daemon/service.py | 38 ++++++++++++++ src/leapflow/monitor/manager.py | 9 +++- src/leapflow/monitor/types.py | 2 + tests/test_cli_entrypoint.py | 67 ++++++++++++++++++++++++ tests/test_dashboard_watch_rpc.py | 15 ++++++ tests/test_monitor_subsystem.py | 11 ++-- 7 files changed, 198 insertions(+), 11 deletions(-) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 915a765..90fb29a 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -30,6 +30,8 @@ _last_hint: Optional["PredictionCandidate"] = None +_WATCH_EXIT_ACTIVE_STATES = frozenset({"armed", "watching", "due", "confirming", "executing"}) + def _is_app_command(canonical: str) -> bool: """Return true only for `/app` or `/app ...`, not `/apple`.""" @@ -51,6 +53,13 @@ async def _prompt_stop_daemon_on_exit( "leapd runs in the background; stop/restart it after reinstalling LeapFlow " "to load new code." ) + watch_snapshot = await _cleanup_client_coupled_watches_on_exit(client, console) + standalone_active = _standalone_active_watches(watch_snapshot) + if standalone_active: + console.system( + "Active standalone LeapBoard watch(es) will keep leapd alive if you leave it running: " + f"{_format_watch_samples(standalone_active)}" + ) connected_clients = daemon_status.get("connected_clients") other_clients = 0 try: @@ -82,17 +91,66 @@ async def _prompt_stop_daemon_on_exit( stop_daemon, runtime_dir, timeout_s=5.0, + force=True, grace_timeout_s=1.0 if graceful_requested else 0.0, + force_timeout_s=2.0, ) if result.stopped: - console.system("leapd stopped.") + suffix = " with force" if result.forced else "" + console.system(f"leapd stopped{suffix}.") else: console.warning( f"leapd did not stop within the exit window (pid={result.pid}). " - "Run `leap daemon stop --force` if it remains unhealthy." + "Inspect the process manually if it remains unhealthy." ) +async def _cleanup_client_coupled_watches_on_exit(client: "DaemonClient", console: Any) -> list[dict[str, Any]]: + """Stop TUI-scoped watches and return the latest watch snapshot best-effort.""" + try: + watches = await asyncio.wait_for(client.watch_list(), timeout=1.5) + except Exception: + return [] + snapshot = [dict(watch) for watch in watches if isinstance(watch, dict)] + coupled = [watch for watch in snapshot if _is_active_watch(watch) and _is_client_coupled_watch(watch)] + stopped = 0 + for watch in coupled: + watch_id = str(watch.get("watch_id", "")) + if not watch_id: + continue + try: + await asyncio.wait_for(client.watch_stop(watch_id), timeout=1.0) + watch["state"] = "done" + stopped += 1 + except Exception: + logger.debug("interactive: failed to stop client-coupled watch %s", watch_id, exc_info=True) + if stopped: + console.system(f"Released {stopped} TUI-scoped LeapBoard watch(es).") + return snapshot + + +def _is_active_watch(watch: dict[str, Any]) -> bool: + return str(watch.get("state", "")) in _WATCH_EXIT_ACTIVE_STATES + + +def _is_client_coupled_watch(watch: dict[str, Any]) -> bool: + return bool(watch.get("client_coupled", False)) + + +def _standalone_active_watches(watches: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [watch for watch in watches if _is_active_watch(watch) and not _is_client_coupled_watch(watch)] + + +def _format_watch_samples(watches: list[dict[str, Any]]) -> str: + labels: list[str] = [] + for watch in watches[:3]: + label = str(watch.get("name") or watch.get("domain") or watch.get("watch_id") or "watch") + state = str(watch.get("state") or "active") + labels.append(f"{label}({state})") + suffix = f", +{len(watches) - 3} more" if len(watches) > 3 else "" + return ", ".join(labels) + suffix + + async def _ask_yes_no_default_yes(prompt: str) -> bool: """Return True by default, including non-interactive or interrupted prompts.""" if not sys.stdin.isatty(): @@ -847,13 +905,10 @@ async def cmd_interactive_daemon( from leapflow.cli.banner import display_rich_banner from leapflow.cli.commands.registry import completion_entries from leapflow.config_service import ConfigService - from leapflow.cli.commands.router import CommandRouter, render_command_result + from leapflow.cli.commands.router import CommandRouter from leapflow.cli.commands.slash_handlers import ( render_app_payload, render_command_payload, - render_model_payload, - render_tool_payload, - render_usage_payload, ) from leapflow.cli.tui_app import ( LeapApp, diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 5210e2e..071fb7b 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -105,6 +105,42 @@ def has_active_watches(self) -> bool: except Exception: return False + def _watch_runtime_summary(self) -> dict[str, Any]: + monitors = self._monitors + if monitors is None: + return { + "total": 0, + "active": 0, + "standalone_active": 0, + "client_coupled_active": 0, + "active_samples": [], + } + try: + watches = [view.to_dict() for view in monitors.list_watches()] + except Exception: + logger.debug("daemon: watch summary unavailable", exc_info=True) + watches = [] + active_states = {"armed", "watching", "due", "confirming", "executing"} + active = [watch for watch in watches if str(watch.get("state", "")) in active_states] + standalone = [watch for watch in active if not bool(watch.get("client_coupled", False))] + coupled = [watch for watch in active if bool(watch.get("client_coupled", False))] + return { + "total": len(watches), + "active": len(active), + "standalone_active": len(standalone), + "client_coupled_active": len(coupled), + "active_samples": [ + { + "watch_id": str(watch.get("watch_id", "")), + "name": str(watch.get("name", "")), + "domain": str(watch.get("domain", "")), + "state": str(watch.get("state", "")), + "client_coupled": bool(watch.get("client_coupled", False)), + } + for watch in active[:5] + ], + } + @property def context(self) -> Any: """Return the initialized Context or raise a clear lifecycle error.""" @@ -573,6 +609,7 @@ async def status(self) -> dict[str, Any]: workspace_config_path = layout.workspace_config_path(workspace_root) workspace_manifest_path = layout.workspace_manifest_path(workspace_root) context_metadata = self._engine_context_metadata(engine, settings) + watch_summary = self._watch_runtime_summary() return { "pid": os.getpid(), "profile": getattr(settings, "profile", "default"), @@ -610,6 +647,7 @@ async def status(self) -> dict[str, Any]: "runtime_executable": sys.executable, "runtime_version": self._runtime_version(), "pending_approvals": len(self._approval_pending), + "watch_summary": watch_summary, "host_backend": self._host_backend_status(ctx), } diff --git a/src/leapflow/monitor/manager.py b/src/leapflow/monitor/manager.py index 5d4ca0a..fe4d6ae 100644 --- a/src/leapflow/monitor/manager.py +++ b/src/leapflow/monitor/manager.py @@ -39,7 +39,13 @@ # Emit signature: (event_type, payload_dict) -> None. Injected by the host. EmitFn = Callable[[str, dict], None] -_ACTIVE_STATES = frozenset({TaskState.ARMED.value, TaskState.WATCHING.value}) +_ACTIVE_STATES = frozenset({ + TaskState.ARMED.value, + TaskState.WATCHING.value, + TaskState.DUE.value, + TaskState.CONFIRMING.value, + TaskState.EXECUTING.value, +}) def _format_trigger(task: ArmedTask) -> str: @@ -342,6 +348,7 @@ def _to_view(self, task: ArmedTask) -> WatchView: next_due_at=task.next_due_at, last_run_at=task.last_run_at, finding_count=self._finding_store.count(watch_id=task.task_id), + client_coupled=bool(meta.get(METADATA_CLIENT_COUPLED_KEY, False)), ) def _emit_state(self, task: ArmedTask) -> None: diff --git a/src/leapflow/monitor/types.py b/src/leapflow/monitor/types.py index cb1c213..9219763 100644 --- a/src/leapflow/monitor/types.py +++ b/src/leapflow/monitor/types.py @@ -264,6 +264,7 @@ class WatchView: next_due_at: float last_run_at: float finding_count: int = 0 + client_coupled: bool = False def to_dict(self) -> dict[str, Any]: return { @@ -277,6 +278,7 @@ def to_dict(self) -> dict[str, Any]: "next_due_at": self.next_due_at, "last_run_at": self.last_run_at, "finding_count": self.finding_count, + "client_coupled": self.client_coupled, } diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 9f1926e..82d722c 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -1079,6 +1079,73 @@ def fail_stop(*args, **kwargs): assert any("kept running" in message for message in console.systems) +@pytest.mark.asyncio +async def test_daemon_tui_exit_releases_session_watches_and_reports_keepalive( + monkeypatch, + tmp_path, +) -> None: + from leapflow.cli.commands import interactive as interactive_module + import leapflow.daemon.lifecycle as lifecycle_module + + class Client: + async def status(self): + return {"pid": 1234, "connected_clients": 0} + + async def watch_list(self): + return [ + {"watch_id": "session-watch", "name": "Session", "domain": "session", "state": "armed", "client_coupled": True}, + {"watch_id": "market-watch", "name": "Market", "domain": "finance", "state": "executing", "client_coupled": False}, + ] + + async def watch_stop(self, watch_id: str): + calls.append(("watch_stop", watch_id)) + return {"watch_id": watch_id, "state": "done"} + + async def shutdown(self): + calls.append(("shutdown",)) + + class Console: + def __init__(self) -> None: + self.systems: list[str] = [] + self.warnings: list[str] = [] + + def system(self, message: str) -> None: + self.systems.append(message) + + def warning(self, message: str) -> None: + self.warnings.append(message) + + class Settings: + profile_dir = tmp_path + runtime_dir = tmp_path / "runtime" + + async def yes(prompt: str) -> bool: + prompts.append(prompt) + return True + + def record_stop(runtime_dir, **kwargs): + calls.append(("stop_daemon", runtime_dir, kwargs)) + return lifecycle_module.StopDaemonResult(pid=1234, stopped=True, forced=True) + + calls = [] + prompts: list[str] = [] + monkeypatch.setattr(interactive_module, "_ask_yes_no_default_yes", yes) + monkeypatch.setattr(lifecycle_module, "stop_daemon", record_stop) + console = Console() + + await interactive_module._prompt_stop_daemon_on_exit(Client(), Settings(), console) + + assert ("watch_stop", "session-watch") in calls + assert ("watch_stop", "market-watch") not in calls + assert any("Released 1 TUI-scoped LeapBoard watch" in message for message in console.systems) + assert any("Active standalone LeapBoard watch" in message for message in console.systems) + stop_call = next(call for call in calls if call[0] == "stop_daemon") + assert stop_call[2]["force"] is True + assert prompts == ["Stop leapd now (pid=1234)? [Y/n]: "] + assert console.systems[-1] == "leapd stopped with force." + + + def test_leap_prompt_uses_daemon_chat_route(monkeypatch) -> None: from leapflow.cli import cli diff --git a/tests/test_dashboard_watch_rpc.py b/tests/test_dashboard_watch_rpc.py index 47d8042..a434f5e 100644 --- a/tests/test_dashboard_watch_rpc.py +++ b/tests/test_dashboard_watch_rpc.py @@ -46,6 +46,7 @@ async def test_service_watch_rpc_roundtrip(tmp_path: Path) -> None: assert len(await service.watch_list()) == 1 assert (await service.watch_get(watch_id))["domain"] == "demo" + assert (await service.watch_get(watch_id))["client_coupled"] is False result = await service.watch_refresh(watch_id) assert result["ok"] is True and result["findings"] == 1 @@ -59,6 +60,20 @@ async def test_service_watch_rpc_roundtrip(tmp_path: Path) -> None: assert (await service.watch_stop(watch_id))["state"] == "done" +async def test_service_watch_summary_separates_keepalive_watches(tmp_path: Path) -> None: + service = RuntimeLeapService(SimpleNamespace()) + service._monitors = _manager(tmp_path) + + await service.watch_arm({"name": "Session", "domain": "demo", "client_coupled": True}) + await service.watch_arm({"name": "Market", "domain": "demo"}) + summary = service._watch_runtime_summary() + + assert summary["active"] == 2 + assert summary["client_coupled_active"] == 1 + assert summary["standalone_active"] == 1 + assert summary["active_samples"][0]["state"] == "armed" + + async def test_service_watch_unavailable_is_graceful() -> None: service = RuntimeLeapService(SimpleNamespace()) # No monitor runtime attached (scheduler disabled). diff --git a/tests/test_monitor_subsystem.py b/tests/test_monitor_subsystem.py index 1837446..573b3eb 100644 --- a/tests/test_monitor_subsystem.py +++ b/tests/test_monitor_subsystem.py @@ -7,8 +7,6 @@ from pathlib import Path -import pytest - from leapflow.monitor import ( EVENT_FINDING, Evidence, @@ -134,6 +132,8 @@ async def test_manager_arm_list_and_state_transitions(tmp_path: Path) -> None: view = await manager.arm_watch(WatchSpec(name="Market", domain="finance", trigger_expr="5m")) assert view.domain == "finance" assert view.state == "armed" + assert view.client_coupled is False + assert view.to_dict()["client_coupled"] is False assert [v.watch_id for v in manager.list_watches()] == [view.watch_id] assert manager.has_active_watches() is True @@ -211,7 +211,10 @@ async def test_manager_unknown_domain_is_graceful(tmp_path: Path) -> None: async def test_has_active_watches_excludes_client_coupled(tmp_path: Path) -> None: manager = MonitorManager(holder=_holder(tmp_path)) - await manager.arm_watch(WatchSpec(name="S", domain="session", client_coupled=True)) + coupled = await manager.arm_watch(WatchSpec(name="S", domain="session", client_coupled=True)) + assert coupled.client_coupled is True assert manager.has_active_watches() is False # client-coupled must not keep leapd alive - await manager.arm_watch(WatchSpec(name="F", domain="finance")) + standalone = await manager.arm_watch(WatchSpec(name="F", domain="finance")) + assert manager.has_active_watches() is True + manager._task_store.update_state(standalone.watch_id, "executing") assert manager.has_active_watches() is True From c7743f54de5340eba1b62498705cc02f82d27042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Thu, 16 Jul 2026 23:29:43 +0800 Subject: [PATCH 07/25] Fix /board leaking to LLM in in-process REPL In-process REPL hardcoded per-command dispatch and never wired /board, so /board session fell through to the chat stream (LLM called skills_list) instead of opening the web board. Route any registered non-client-local command through the shared command_execute contract so recognized slash commands never leak to the LLM, and let web-view actions (open/home/session) open without a local monitor runtime. Extract a shared _open_dashboard_view helper for both REPLs. --- src/leapflow/cli/commands/interactive.py | 65 +++++++++++++++------ src/leapflow/cli/commands/slash_handlers.py | 6 +- tests/test_dashboard_watch_rpc.py | 13 +++++ tests/test_slash_command_router.py | 16 +++++ 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 90fb29a..3a25a28 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -151,6 +151,33 @@ def _format_watch_samples(watches: list[dict[str, Any]]) -> str: return ", ".join(labels) + suffix +async def _open_dashboard_view(settings: Any, console: Any, payload: dict[str, Any]) -> None: + """Open the web dashboard on the user's machine (client-side). + + Shared by both the in-process and daemon-backed REPLs so a single owner + handles server discovery, URL assembly, and the browser-open UX. + """ + from leapflow.dashboard import launcher + + action = str(payload.get("action") or "home") + url = payload.get("url") + if not url: + try: + state = await asyncio.to_thread(launcher.ensure_server, settings) + except Exception as exc: + console.warning(f"Could not start the dashboard server: {exc}") + return + url = launcher.build_url(state["bind"], state["port"], state["token"]) + if action != "home": + url += f"&action={action}" + if action == "session": + url += "&target=session" + if launcher.open_in_browser(url): + console.system(f"Opened dashboard in your browser: {url}") + else: + console.system(f"Dashboard ready (open manually): {url}") + + async def _ask_yes_no_default_yes(prompt: str) -> bool: """Return True by default, including non-interactive or interrupted prompts.""" if not sys.stdin.isatty(): @@ -325,6 +352,7 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> from leapflow.config_service import ConfigService from leapflow.cli.commands.router import CommandRouter, render_command_result from leapflow.cli.commands.slash_handlers import ( + command_execute, handle_status, handle_tool, handle_usage, @@ -333,6 +361,7 @@ async def cmd_interactive(ctx: "Context", *, resume_id: Optional[str] = None) -> handle_clear, handle_gateway, handle_app, + render_command_payload, ) from leapflow.utils.terminal_io import TerminalIOProvider from leapflow.engine.session import SessionMode @@ -732,6 +761,22 @@ async def handle_input(text: str) -> None: ) return + # Registered engine command with no in-process fast path (e.g. + # /board): route through the shared command_execute contract so a + # recognized slash command never leaks into the LLM chat stream. + # Client-local commands are handled above or via task-control. + if not cmd_def.client_local: + try: + payload = await command_execute(ctx, canonical, cmd_args) + except Exception as exc: + console.warning(f"/{canonical} failed: {exc}") + return + if str(payload.get("view")) == "dashboard" and payload.get("mode") == "open": + await _open_dashboard_view(ctx.settings, console, payload) + else: + render_command_payload(console, payload) + return + if _learning: ctx.imitation.end_control_input() @@ -1178,25 +1223,7 @@ async def _stream_response( async def _open_dashboard(payload: dict) -> None: """Open the web dashboard on the user's machine (client-side).""" - from leapflow.dashboard import launcher - - action = str(payload.get("action") or "home") - url = payload.get("url") - if not url: - try: - state = await asyncio.to_thread(launcher.ensure_server, settings) - except Exception as exc: - console.warning(f"Could not start the dashboard server: {exc}") - return - url = launcher.build_url(state["bind"], state["port"], state["token"]) - if action != "home": - url += f"&action={action}" - if action == "session": - url += "&target=session" - if launcher.open_in_browser(url): - console.system(f"Opened dashboard in your browser: {url}") - else: - console.system(f"Dashboard ready (open manually): {url}") + await _open_dashboard_view(settings, console, payload) async def handle_input(text: str) -> None: nonlocal daemon_session_mode diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 2eba13b..d05c4d4 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -1168,7 +1168,11 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ action = tokens[0].lower() if tokens else "open" rest_tokens = tokens[1:] - if monitors is None: + # Web-view actions (open/home/session) only need a client-side browser and + # the dashboard server; they must work even when this process hosts no + # monitor runtime (e.g. the in-process fallback REPL). Data operations below + # still require the runtime. + if monitors is None and action not in ("open", "home", "session"): return {"ok": False, "message": "Monitor runtime is unavailable (scheduler disabled)."} if action in ("list", "ls"): diff --git a/tests/test_dashboard_watch_rpc.py b/tests/test_dashboard_watch_rpc.py index a434f5e..25be8cc 100644 --- a/tests/test_dashboard_watch_rpc.py +++ b/tests/test_dashboard_watch_rpc.py @@ -115,3 +115,16 @@ async def test_dashboard_command_scheduler_disabled(tmp_path: Path) -> None: disabled = await command_execute(ctx, "board list", "") assert disabled["ok"] is False assert "unavailable" in disabled["message"].lower() + + +async def test_dashboard_web_view_actions_work_without_monitor_runtime() -> None: + """open/home/session must open the web board even with no local monitor + runtime (the in-process fallback REPL), never leak back to chat.""" + ctx = SimpleNamespace(monitors=None, settings=None, engine=None) + for command in ("board session", "board open", "board home"): + payload = await command_execute(ctx, command, "") + assert payload["ok"] is True, command + assert payload["view"] == "dashboard" + assert payload["mode"] == "open" + session_payload = await command_execute(ctx, "board session", "") + assert session_payload["action"] == "session" diff --git a/tests/test_slash_command_router.py b/tests/test_slash_command_router.py index 94dab0c..28d4434 100644 --- a/tests/test_slash_command_router.py +++ b/tests/test_slash_command_router.py @@ -53,6 +53,22 @@ def test_command_router_client_local_commands() -> None: assert inv is not None, f"parse failed for {cmd_text}" assert inv.command.client_local is False, f"{cmd_text} should NOT be client_local" +def test_board_commands_resolve_as_engine_routed() -> None: + """Board commands must resolve as non-client-local so the in-process REPL + routes them through command_execute instead of leaking to the LLM chat.""" + router = CommandRouter("daemon") + + for cmd_text in ("/board", "/board session", "/board open", "/board list"): + inv = router.parse(cmd_text) + assert inv is not None, f"parse failed for {cmd_text}" + assert inv.command.name.startswith("board"), cmd_text + assert inv.command.client_local is False, f"{cmd_text} must be engine-routed" + + session = router.parse("/board session") + assert session is not None + assert session.command.name == "board session" + + def test_plural_skill_tool_task_commands_are_not_registered() -> None: router = CommandRouter("daemon") From 8776a26cd2dd9e3ada50f3eb75663efb1bd40726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Thu, 16 Jul 2026 23:40:51 +0800 Subject: [PATCH 08/25] Sweep stale client-coupled watches on daemon startup A session-analysis (client-coupled) watch persisted in DuckDB from a prior run surfaced as '1 watch' in the status bar on a fresh leap start, before any /board. Client-coupled watches only make sense while a live interactive client owns them, so a fresh daemon lifetime must own none: drop persisted client-coupled watches (and their findings) in _start_monitors. Standalone watches stay durable. --- src/leapflow/daemon/service.py | 10 ++++++++++ src/leapflow/monitor/manager.py | 21 +++++++++++++++++++++ tests/test_monitor_subsystem.py | 24 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/src/leapflow/daemon/service.py b/src/leapflow/daemon/service.py index 071fb7b..daf6367 100644 --- a/src/leapflow/daemon/service.py +++ b/src/leapflow/daemon/service.py @@ -89,6 +89,16 @@ async def _start_monitors(self, ctx: Any) -> None: self._monitors.producers.register(SessionAnalysisProducer()) setattr(ctx, "monitors", self._monitors) await self._monitors.start() + # A fresh daemon lifetime owns no interactive clients yet, so any + # persisted client-coupled watch (e.g. a session-analysis watch left + # over from a prior run or an unclean client exit) is stale. Drop it + # so the status bar and keep-alive only reflect real active monitors. + try: + swept = self._monitors.sweep_client_coupled_watches() + if swept: + logger.info("daemon: swept %d stale client-coupled watch(es) on startup", swept) + except Exception: + logger.debug("daemon: client-coupled watch sweep failed", exc_info=True) logger.debug("daemon: monitor runtime started") except Exception: logger.debug("daemon: monitor runtime start skipped", exc_info=True) diff --git a/src/leapflow/monitor/manager.py b/src/leapflow/monitor/manager.py index fe4d6ae..f6f14e1 100644 --- a/src/leapflow/monitor/manager.py +++ b/src/leapflow/monitor/manager.py @@ -267,6 +267,27 @@ def has_active_watches(self) -> bool: return True return False + def sweep_client_coupled_watches(self) -> int: + """Delete leftover client-coupled watches (e.g. session analysis). + + Client-coupled watches only make sense while an interactive client owns + them. On a fresh daemon lifetime none can, so any persisted ones are + stale and must not surface as active monitors (status bar, keep-alive, + ``/board list``). Standalone watches are intentionally durable and are + left untouched. Returns the number of watches removed. + """ + removed = 0 + for task in self._task_store.load_all(): + if not _is_watch(task): + continue + meta = task.metadata if isinstance(task.metadata, dict) else {} + if not meta.get(METADATA_CLIENT_COUPLED_KEY): + continue + self._task_store.delete(task.task_id) + self._finding_store.delete_for_watch(task.task_id) + removed += 1 + return removed + def pause_watch(self, watch_id: str) -> Optional[WatchView]: """Suspend a watch so it stops firing until resumed.""" return self._transition(watch_id, TaskState.SUSPENDED.value) diff --git a/tests/test_monitor_subsystem.py b/tests/test_monitor_subsystem.py index 573b3eb..532b3a7 100644 --- a/tests/test_monitor_subsystem.py +++ b/tests/test_monitor_subsystem.py @@ -218,3 +218,27 @@ async def test_has_active_watches_excludes_client_coupled(tmp_path: Path) -> Non assert manager.has_active_watches() is True manager._task_store.update_state(standalone.watch_id, "executing") assert manager.has_active_watches() is True + + +async def test_sweep_client_coupled_watches_removes_only_session_watches(tmp_path: Path) -> None: + manager = MonitorManager(holder=_holder(tmp_path)) + session = await manager.arm_watch(WatchSpec(name="S", domain="session", client_coupled=True)) + standalone = await manager.arm_watch(WatchSpec(name="F", domain="finance")) + manager.finding_store.save( + Finding(watch_id=session.watch_id, domain="session", title="stale", severity=Severity.INFO) + ) + + removed = manager.sweep_client_coupled_watches() + + assert removed == 1 + remaining = [v.watch_id for v in manager.list_watches()] + assert remaining == [standalone.watch_id] # standalone watch is durable + assert manager.get_watch(session.watch_id) is None + assert manager.finding_store.count(watch_id=session.watch_id) == 0 + + +async def test_sweep_client_coupled_watches_is_noop_without_session_watches(tmp_path: Path) -> None: + manager = MonitorManager(holder=_holder(tmp_path)) + await manager.arm_watch(WatchSpec(name="F", domain="finance")) + assert manager.sweep_client_coupled_watches() == 0 + assert len(manager.list_watches()) == 1 From a2b36b8d2846a4f88a61219f26d1254327348057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Thu, 16 Jul 2026 23:49:54 +0800 Subject: [PATCH 09/25] Suggest command instead of chatting on typo'd subcommand A misspelled subcommand like 'leap deamon status' fell through the pre-parse 'unknown first token -> chat prompt' path, silently spawning a daemon and asking the LLM. Add a conservative did-you-mean guard: a short, command-like invocation whose first word is a near-miss of a known command now prints a suggestion and exits 2 instead of routing to chat. Genuine free-text prompts (longer sentences, non-command words) still chat. --- src/leapflow/cli/cli.py | 32 ++++++++++++++++++++++++++++++ tests/test_cli_entrypoint.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 365fd16..12edbd3 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -175,6 +175,23 @@ def _daemon_enabled(args: argparse.Namespace) -> bool: return raw not in {"0", "false", "no", "off"} +def _suggest_known_command(token: str, known_commands: set[str]) -> str | None: + """Return the closest known command when a token looks like a typo, else None. + + Uses a conservative similarity cutoff so genuine free-text prompts (e.g. + ``leap what is a daemon``) still route to chat, while near-miss command + typos (e.g. ``deamon`` -> ``daemon``) are caught and surfaced as a + suggestion instead of silently becoming an LLM chat turn that also spawns a + daemon. + """ + import difflib + + matches = difflib.get_close_matches( + token.lower(), sorted(known_commands), n=1, cutoff=0.82 + ) + return matches[0] if matches else None + + def main(argv: list[str] | None = None) -> int: common = argparse.ArgumentParser(add_help=False) common.add_argument( @@ -336,6 +353,21 @@ def main(argv: list[str] | None = None) -> int: break if first_pos is not None and effective_argv[first_pos] not in known_commands: + first_token = effective_argv[first_pos] + non_flag_tokens = [tok for tok in effective_argv if not tok.startswith("-")] + # A short, command-like invocation whose first word is a near-miss of a + # known command is almost certainly a typo (e.g. `leap deamon status`). + # Surface a suggestion instead of silently spawning a daemon + LLM chat. + if len(non_flag_tokens) <= 3: + suggestion = _suggest_known_command(first_token, known_commands) + if suggestion is not None: + corrected = " ".join(["leap", suggestion, *effective_argv[first_pos + 1:]]) + sys.stderr.write( + f"leap: '{first_token}' is not a leap command. " + f"Did you mean '{suggestion}'?\n" + f"Try: {corrected}\n" + ) + return 2 # Collect all non-option prompt tokens while preserving global option values. flags: list[str] = [] prompt_words = [] diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 82d722c..1c0ef42 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -1162,6 +1162,44 @@ async def fake_daemon_main(args): assert captured == {"command": "chat", "prompt": "hello world"} +def test_leap_typo_command_suggests_instead_of_chatting(monkeypatch, capsys) -> None: + from leapflow.cli import cli + + called = {"chat": False} + + async def fake_daemon_main(args): # pragma: no cover - must not run + called["chat"] = True + return 0 + + monkeypatch.setattr(cli, "_async_daemon_main", fake_daemon_main) + + code = cli.main(["deamon", "status"]) + + assert code == 2 # usage error, not a chat turn + assert called["chat"] is False # never spawned a daemon or asked the LLM + err = capsys.readouterr().err + assert "Did you mean 'daemon'" in err + assert "leap daemon status" in err + + +def test_leap_long_freetext_near_miss_still_chats(monkeypatch) -> None: + from leapflow.cli import cli + + captured = {} + + async def fake_daemon_main(args): + captured["command"] = args.command + captured["prompt"] = args.prompt + return 0 + + monkeypatch.setattr(cli, "_async_daemon_main", fake_daemon_main) + + # First word is a near-miss of `daemon`, but a full sentence is genuine chat + # and must not be hijacked by the did-you-mean guard. + assert cli.main(["deamon", "is", "a", "background", "process"]) == 0 + assert captured == {"command": "chat", "prompt": "deamon is a background process"} + + @pytest.mark.asyncio async def test_teach_start_without_session_returns_structured_error() -> None: from types import SimpleNamespace From 1e6e7ec33f77ef55f83023ad7c559cf0341f2816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 17 Jul 2026 00:03:52 +0800 Subject: [PATCH 10/25] Make /board session non-blocking and report watch id /board session awaited an LLM-backed run_watch_once inside the command RPC, so the analysis (tens of seconds) blocked the response past the 30s client timeout; the bridge then reconnected and retried, producing the ~60s 'Timed out waiting for leapd response' failure. Decouple: arm the session watch and schedule the analysis as a tracked background task (MonitorManager.schedule_watch_once), returning the open payload with the watch id immediately. The board receives the finding over WebSocket when the cycle completes. Surface the watch id in the TUI, which also unblocks the browser-open flow (previously the timeout prevented the open payload from ever returning). --- src/leapflow/cli/commands/interactive.py | 6 ++++ src/leapflow/cli/commands/slash_handlers.py | 25 ++++++++++++---- src/leapflow/monitor/manager.py | 20 +++++++++++++ tests/test_dashboard_watch_rpc.py | 32 ++++++++++++++++++++- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 3a25a28..fc009c0 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -176,6 +176,12 @@ async def _open_dashboard_view(settings: Any, console: Any, payload: dict[str, A console.system(f"Opened dashboard in your browser: {url}") else: console.system(f"Dashboard ready (open manually): {url}") + watch_id = str(payload.get("watch_id") or "") + if watch_id: + console.system( + f"Session board watch {watch_id[:8]} is observing; " + "analysis streams to the board as it completes." + ) async def _ask_yes_no_default_yes(prompt: str) -> bool: diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index d05c4d4..7125f6a 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -6,6 +6,7 @@ from __future__ import annotations +import logging import os import shlex from typing import TYPE_CHECKING, Any @@ -15,6 +16,9 @@ from leapflow.cli.tui_app.console import LeapConsole +logger = logging.getLogger(__name__) + + def build_tool_payload(ctx: "Context") -> dict[str, Any]: """Build a serializable tool summary for local or daemon rendering.""" from leapflow.cli.banner import _categorize_tools @@ -1146,14 +1150,22 @@ def _resolve_watch_id(monitors: Any, token: str) -> str: return matches[0] if len(matches) == 1 else token -async def _ensure_session_watch_refresh(ctx: "Context", monitors: Any) -> None: - """Ensure an active session watch exists and run one analysis cycle now.""" +async def _ensure_session_watch_refresh(ctx: "Context", monitors: Any) -> str: + """Ensure an active session watch exists and trigger one analysis cycle. + + The analysis producer is LLM-backed and can take tens of seconds, so it is + scheduled in the background: arming the watch and opening the board must be + instant and must never block the command RPC past its timeout. The board + receives the resulting finding over WebSocket when the cycle completes. + Returns the session watch id. + """ from leapflow.monitor.session_producer import ensure_session_watch, session_watch_params watch_id = await ensure_session_watch( monitors, params=session_watch_params(getattr(ctx, "settings", None)) ) - await monitors.run_watch_once(watch_id, force=True) + monitors.schedule_watch_once(watch_id, force=True) + return watch_id async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[str, Any]: @@ -1254,11 +1266,12 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ engine = getattr(ctx, "engine", None) if engine is not None and int(getattr(engine, "turn_count", 0) or 0) > 0: intent = DashboardIntent(action="session", target="session") + session_watch_id = "" if intent.action == "session" and monitors is not None: try: - await _ensure_session_watch_refresh(ctx, monitors) + session_watch_id = await _ensure_session_watch_refresh(ctx, monitors) except Exception: - pass + logger.debug("dashboard: session watch refresh failed", exc_info=True) settings = getattr(ctx, "settings", None) state = None if settings is not None: @@ -1270,6 +1283,8 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ "ok": True, "view": "dashboard", "mode": "open", "action": intent.action, "intent": intent.to_dict(), "running": bool(state), } + if session_watch_id: + payload["watch_id"] = session_watch_id if state: url = launcher.build_url(state["bind"], state["port"], state["token"]) if intent.action != "home": diff --git a/src/leapflow/monitor/manager.py b/src/leapflow/monitor/manager.py index f6f14e1..4d9fb08 100644 --- a/src/leapflow/monitor/manager.py +++ b/src/leapflow/monitor/manager.py @@ -9,6 +9,7 @@ from __future__ import annotations +import asyncio import logging import time from dataclasses import replace @@ -191,6 +192,7 @@ def __init__( default_tier="local", ) self._started = False + self._background_tasks: set[asyncio.Task[Any]] = set() # ── Lifecycle ──────────────────────────────────────────────────────── @@ -344,6 +346,24 @@ async def run_watch_once(self, watch_id: str, *, force: bool = False) -> dict: params["_force"] = True return await self._executor.execute(task.skill_name, params) + def schedule_watch_once(self, watch_id: str, *, force: bool = False) -> None: + """Fire one observation cycle in the background (non-blocking). + + Producers may be slow (e.g. an LLM-backed session analysis). Callers + that only need to *trigger* a refresh -- such as opening a live board -- + must not block on that latency, so the cycle runs as a tracked task and + its finding reaches clients over the normal push path when it completes. + """ + task = asyncio.create_task(self._safe_run_once(watch_id, force=force)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + async def _safe_run_once(self, watch_id: str, *, force: bool) -> None: + try: + await self.run_watch_once(watch_id, force=force) + except Exception: # noqa: BLE001 - background refresh must never crash the loop + logger.debug("monitor: background run_watch_once failed for %s", watch_id, exc_info=True) + # ── Internal ─────────────────────────────────────────────────────────── def _transition(self, watch_id: str, state: str) -> Optional[WatchView]: diff --git a/tests/test_dashboard_watch_rpc.py b/tests/test_dashboard_watch_rpc.py index 25be8cc..f9f2f3f 100644 --- a/tests/test_dashboard_watch_rpc.py +++ b/tests/test_dashboard_watch_rpc.py @@ -6,12 +6,13 @@ from __future__ import annotations +import asyncio from pathlib import Path from types import SimpleNamespace from leapflow.cli.commands.slash_handlers import command_execute from leapflow.daemon.service import RuntimeLeapService -from leapflow.monitor import Finding, MonitorManager, ProducerRegistry, Severity +from leapflow.monitor import Finding, MonitorManager, ProducerRegistry, Severity, WatchSpec from leapflow.monitor.types import ProducerContext from leapflow.storage.connection import LocalConnectionHolder @@ -110,6 +111,35 @@ async def test_dashboard_command_execute_flow(tmp_path: Path) -> None: assert unknown["ok"] is False +async def test_schedule_watch_once_runs_in_background(tmp_path: Path) -> None: + """schedule_watch_once must not block the caller yet still produce findings.""" + manager = _manager(tmp_path) + view = await manager.arm_watch(WatchSpec(name="D", domain="demo", sensitivity="notable")) + + manager.schedule_watch_once(view.watch_id, force=True) + + tasks = list(manager._background_tasks) + assert len(tasks) == 1 # scheduled, not awaited inline + assert manager.finding_store.count(watch_id=view.watch_id) == 0 # not run yet + await asyncio.gather(*tasks) + assert manager.finding_store.count(watch_id=view.watch_id) >= 1 + + +async def test_board_session_returns_watch_id_without_blocking(tmp_path: Path) -> None: + """/board session arms the session watch, returns its id + open payload, and + schedules the (LLM-backed) analysis in the background instead of awaiting it.""" + manager = _manager(tmp_path) + ctx = SimpleNamespace(monitors=manager, settings=None, engine=None) + + payload = await command_execute(ctx, "board session", "") + + assert payload["view"] == "dashboard" and payload["mode"] == "open" + assert payload["action"] == "session" + assert payload.get("watch_id") # id surfaced to the user + assert len(manager._background_tasks) == 1 # analysis deferred, RPC returns fast + await asyncio.gather(*list(manager._background_tasks)) + + async def test_dashboard_command_scheduler_disabled(tmp_path: Path) -> None: ctx = SimpleNamespace(monitors=None) disabled = await command_execute(ctx, "board list", "") From 2d981698956c39c329283380c2b13c6e19a7771d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 17 Jul 2026 00:15:04 +0800 Subject: [PATCH 11/25] Reliably force-stop leapd on exit with transparent progress TUI exit reported 'leapd did not stop within the exit window' even though SIGKILL was sent. Root cause: a daemon spawned by the exiting process lingers as an unreaped zombie, and os.kill(pid, 0) still succeeds for zombies, so a successful kill looked like a failure; short timeouts made a slow graceful shutdown worse. Fix _process_alive to reap our exited children (WNOHANG) so a terminated daemon is correctly seen as gone, and distinguish ProcessLookupError/PermissionError. Per the user's directive, the confirmed exit stop now escalates to SIGKILL with generous windows and narrates every step (graceful wait -> SIGTERM -> SIGKILL) via a stop_daemon on_progress callback, with an honest final message and exact remediation if the OS still blocks it. --- src/leapflow/cli/commands/interactive.py | 21 +++++--- src/leapflow/daemon/lifecycle.py | 42 +++++++++++++-- tests/test_cli_entrypoint.py | 2 +- tests/test_daemon_rpc.py | 66 ++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 13 deletions(-) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index fc009c0..48f0eaa 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -80,28 +80,33 @@ async def _prompt_stop_daemon_on_exit( from leapflow.daemon.lifecycle import stop_daemon runtime_dir = settings.runtime_dir - console.system(f"Stopping leapd (pid={pid})...") + console.system(f"Stopping leapd (pid={pid}); requesting graceful shutdown...") graceful_requested = False try: - await asyncio.wait_for(client.shutdown(), timeout=2.0) + await asyncio.wait_for(client.shutdown(), timeout=3.0) graceful_requested = True except Exception: graceful_requested = False + # The user confirmed the stop, so escalate all the way to SIGKILL if needed. + # Patience over surprise: use generous windows and narrate every step so the + # wait is transparent rather than a silent stall. result = await asyncio.to_thread( stop_daemon, runtime_dir, - timeout_s=5.0, + timeout_s=8.0, force=True, - grace_timeout_s=1.0 if graceful_requested else 0.0, - force_timeout_s=2.0, + grace_timeout_s=3.0 if graceful_requested else 0.0, + force_timeout_s=6.0, + on_progress=console.system, ) if result.stopped: - suffix = " with force" if result.forced else "" + suffix = " (forced with SIGKILL)" if result.forced else "" console.system(f"leapd stopped{suffix}.") else: console.warning( - f"leapd did not stop within the exit window (pid={result.pid}). " - "Inspect the process manually if it remains unhealthy." + f"leapd could not be stopped even after SIGKILL (pid={result.pid}). " + "It may be blocked on the OS (e.g. disk I/O); run " + f"`kill -9 {result.pid}` if it stays unhealthy." ) diff --git a/src/leapflow/daemon/lifecycle.py b/src/leapflow/daemon/lifecycle.py index 82ef060..e80218c 100644 --- a/src/leapflow/daemon/lifecycle.py +++ b/src/leapflow/daemon/lifecycle.py @@ -29,7 +29,7 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Callable, Optional logger = logging.getLogger(__name__) @@ -200,8 +200,21 @@ def stop_daemon( grace_timeout_s: float = 0.0, poll_interval_s: float = 0.1, force_timeout_s: float = 2.0, + on_progress: Callable[[str], None] | None = None, ) -> StopDaemonResult: - """Stop leapd as a bounded transaction and verify final state.""" + """Stop leapd as a bounded transaction and verify final state. + + ``on_progress`` receives short, human-readable step messages so callers can + keep the user informed while a slow shutdown escalates from graceful wait to + SIGTERM to SIGKILL. + """ + def _notify(message: str) -> None: + if on_progress is not None: + try: + on_progress(message) + except Exception: # noqa: BLE001 - progress reporting must never break stop + pass + info = DaemonInfo.discover(runtime_dir) pid = info.pid if not info.is_running: @@ -211,11 +224,13 @@ def stop_daemon( deadline = time.time() + max(0.1, timeout_s) interval = max(0.01, poll_interval_s) if grace_timeout_s > 0: + _notify(f"Waiting up to {grace_timeout_s:.0f}s for graceful shutdown...") grace_deadline = min(deadline, time.time() + grace_timeout_s) if _wait_until_stopped(runtime_dir, deadline=grace_deadline, interval_s=interval): stale_cleaned = cleanup_stale(runtime_dir) return StopDaemonResult(pid=pid, stopped=True, stale_cleaned=stale_cleaned) + _notify(f"Sending SIGTERM to pid {pid}...") signal_sent = send_signal(runtime_dir, signal.SIGTERM) if not signal_sent and not DaemonInfo.discover(runtime_dir).is_running: stale_cleaned = cleanup_stale(runtime_dir) @@ -229,6 +244,7 @@ def stop_daemon( forced = False if force: + _notify(f"Still running; escalating to SIGKILL (force) and waiting up to {force_timeout_s:.0f}s...") forced = send_signal(runtime_dir, signal.SIGKILL) kill_deadline = time.time() + max(0.1, force_timeout_s) if forced and _wait_until_stopped(runtime_dir, deadline=kill_deadline, interval_s=interval): @@ -315,12 +331,30 @@ def _read_meta(path: Path) -> Optional[dict]: def _process_alive(pid: int) -> bool: - """Check if a process with the given PID exists.""" + """Return True if the process with the given PID is still running. + + A daemon spawned by the current process lingers as an unreaped zombie after + it exits (including after ``SIGKILL``); ``os.kill(pid, 0)`` still succeeds + for zombies, which would make a successful stop look like a failure. Reap + our own exited children first so a terminated daemon is correctly reported + as gone. For processes that are not our children, reaping is a no-op and we + fall back to the ``os.kill`` liveness probe. + """ + try: + reaped, _ = os.waitpid(pid, os.WNOHANG) + if reaped == pid: + return False # our child has exited and was just reaped + except (ChildProcessError, OSError): + pass # not our child, or already reaped by subprocess/init try: os.kill(pid, 0) - return True + except ProcessLookupError: + return False + except PermissionError: + return True # exists but owned by another user except OSError: return False + return True def _sock_healthy(sock_path: Path) -> bool: diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 1c0ef42..d6917ff 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -1142,7 +1142,7 @@ def record_stop(runtime_dir, **kwargs): stop_call = next(call for call in calls if call[0] == "stop_daemon") assert stop_call[2]["force"] is True assert prompts == ["Stop leapd now (pid=1234)? [Y/n]: "] - assert console.systems[-1] == "leapd stopped with force." + assert console.systems[-1] == "leapd stopped (forced with SIGKILL)." diff --git a/tests/test_daemon_rpc.py b/tests/test_daemon_rpc.py index 1528b2c..8200e6d 100644 --- a/tests/test_daemon_rpc.py +++ b/tests/test_daemon_rpc.py @@ -1028,6 +1028,72 @@ def test_stop_daemon_force_escalates_after_timeout(monkeypatch, tmp_path) -> Non assert signals == [lifecycle_module.signal.SIGTERM, lifecycle_module.signal.SIGKILL] +def test_stop_daemon_narrates_progress(monkeypatch, tmp_path) -> None: + import leapflow.daemon.lifecycle as lifecycle_module + + running = lifecycle_module.DaemonInfo( + pid=4885, + sock_path=tmp_path / "runtime" / "leapd.sock", + start_time=None, + is_running=True, + is_healthy=False, + ) + monkeypatch.setattr(lifecycle_module.DaemonInfo, "discover", staticmethod(lambda runtime_dir: running)) + monkeypatch.setattr(lifecycle_module, "send_signal", lambda runtime_dir, sig: True) + + messages: list[str] = [] + result = lifecycle_module.stop_daemon( + tmp_path / "runtime", + timeout_s=0.05, + force=True, + grace_timeout_s=0.05, + force_timeout_s=0.05, + poll_interval_s=0.01, + on_progress=messages.append, + ) + + assert result.timed_out is True + # Every escalation step is narrated so the wait is transparent, not silent. + assert any("graceful" in m.lower() for m in messages) + assert any("SIGTERM" in m for m in messages) + assert any("SIGKILL" in m for m in messages) + + +def test_process_alive_reaps_exited_child_as_dead() -> None: + import subprocess + import sys + import time + + import leapflow.daemon.lifecycle as lifecycle_module + + # A child spawned by this process becomes an unreaped zombie once it exits; + # os.kill(pid, 0) still succeeds for zombies. _process_alive must reap it and + # report it as dead so a SIGKILL'd daemon is not seen as "still running". + proc = subprocess.Popen([sys.executable, "-c", "pass"]) # noqa: S603 - trusted argv + deadline = time.time() + 5.0 + alive = True + while time.time() < deadline: + alive = lifecycle_module._process_alive(proc.pid) + if not alive: + break + time.sleep(0.05) + proc.returncode = 0 # already reaped by _process_alive; silence Popen.__del__ + + assert alive is False + + +def test_process_alive_false_for_reaped_pid() -> None: + import subprocess + import sys + + import leapflow.daemon.lifecycle as lifecycle_module + + proc = subprocess.Popen([sys.executable, "-c", "pass"]) # noqa: S603 - trusted argv + proc.wait() # fully reaped; pid no longer exists + + assert lifecycle_module._process_alive(proc.pid) is False + + def test_daemon_restart_stops_and_starts(monkeypatch, tmp_path) -> None: from leapflow.cli.commands import daemon as daemon_module From 5c06256feb72314f0f78d324b80b02e73c5203e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 17 Jul 2026 00:16:36 +0800 Subject: [PATCH 12/25] Require human confirmation for slash-command path changes Add a Review Requirements rule: any change to slash-command paths (registry, router, in-process/daemon REPL, command_execute, completion, rendering), especially UX-facing behavior, must get a second human confirmation before it is considered ready. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 6521d20..e3248f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ This document is the LeapFlow engineering collaboration contract. It is not only - **Deep review for large changes**: When a change substantially affects architecture, runtime behavior, user flows, persistence, safety, or multiple modules, perform an additional deep review before considering the work complete. - **Human confirmation for TUI changes**: Any TUI layout or interaction-logic change requires a second human confirmation before it is considered ready. +- **Human confirmation for slash-command paths**: Any change that adds, removes, renames, reroutes, or alters the behavior of a slash command (`/...`) — across the registry, router, in-process REPL, daemon REPL, `command_execute`, completion, and rendering — requires a second human confirmation before it is considered ready. This applies especially to user-experience-facing behavior (dispatch, prompts, confirmations, output, browser/dashboard launches, and error/recovery messaging), which must never be shipped on a single pass. - **Design goal check**: Verify that the implementation actually achieves the intended design goal and is not just a local patch. - **Optimality check**: Evaluate whether the solution is the simplest robust design, avoids unnecessary abstractions, and fits the existing architecture. - **Regression impact check**: Inspect affected modules and user journeys for logic bugs, degraded UX, broken compatibility, slower feedback, weaker diagnostics, or worse failure recovery. From 4f4eccaa7089485937e8c4cb804ebd83c0bd7b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 17 Jul 2026 11:19:06 +0800 Subject: [PATCH 13/25] Open the web board on /board new and fix view targeting /board new only returned an 'armed' text payload, so it never opened the web board. Add an open_web directive (decoupled from mode) so the TUI both renders the armed confirmation and launches the browser, focused on the freshly created watch. Also fix a latent bug where URL assembly dropped the target for non-session views (so /board open / watch landed on the overview instead of the watch detail): a single launcher.build_view_url now owns action+target query assembly, shared by the daemon-running and not-yet-running paths and both REPLs. Web-opening commands: open/home/session/new; terminal-feedback commands: list/status/findings/pause/resume/stop/mute/refresh. Confirmed by the user per the AGENTS.md slash-command human-confirmation rule. --- src/leapflow/cli/commands/interactive.py | 23 ++++--- src/leapflow/cli/commands/slash_handlers.py | 69 +++++++++++++++------ src/leapflow/dashboard/launcher.py | 23 +++++++ tests/test_dashboard_watch_rpc.py | 43 +++++++++++++ 4 files changed, 130 insertions(+), 28 deletions(-) diff --git a/src/leapflow/cli/commands/interactive.py b/src/leapflow/cli/commands/interactive.py index 48f0eaa..c8fbcd2 100644 --- a/src/leapflow/cli/commands/interactive.py +++ b/src/leapflow/cli/commands/interactive.py @@ -165,6 +165,7 @@ async def _open_dashboard_view(settings: Any, console: Any, payload: dict[str, A from leapflow.dashboard import launcher action = str(payload.get("action") or "home") + target = str(payload.get("target") or "") url = payload.get("url") if not url: try: @@ -172,11 +173,9 @@ async def _open_dashboard_view(settings: Any, console: Any, payload: dict[str, A except Exception as exc: console.warning(f"Could not start the dashboard server: {exc}") return - url = launcher.build_url(state["bind"], state["port"], state["token"]) - if action != "home": - url += f"&action={action}" - if action == "session": - url += "&target=session" + url = launcher.build_view_url( + state["bind"], state["port"], state["token"], action=action, target=target, + ) if launcher.open_in_browser(url): console.system(f"Opened dashboard in your browser: {url}") else: @@ -782,8 +781,11 @@ async def handle_input(text: str) -> None: except Exception as exc: console.warning(f"/{canonical} failed: {exc}") return - if str(payload.get("view")) == "dashboard" and payload.get("mode") == "open": - await _open_dashboard_view(ctx.settings, console, payload) + if str(payload.get("view")) == "dashboard": + if payload.get("mode") == "open" or payload.get("open_web"): + await _open_dashboard_view(ctx.settings, console, payload) + if payload.get("mode") != "open": + render_command_payload(console, payload) else: render_command_payload(console, payload) return @@ -1299,8 +1301,11 @@ async def handle_input(text: str) -> None: _apply_daemon_runtime_metadata({"host_backend": payload["result"]}) _update_status() - if str(payload.get("view")) == "dashboard" and payload.get("mode") == "open": - await _open_dashboard(payload) + if str(payload.get("view")) == "dashboard": + if payload.get("mode") == "open" or payload.get("open_web"): + await _open_dashboard(payload) + if payload.get("mode") != "open": + render_command_payload(console, payload) return # Render: app-specific views use app renderer, others use generic diff --git a/src/leapflow/cli/commands/slash_handlers.py b/src/leapflow/cli/commands/slash_handlers.py index 7125f6a..7e78724 100644 --- a/src/leapflow/cli/commands/slash_handlers.py +++ b/src/leapflow/cli/commands/slash_handlers.py @@ -1208,7 +1208,23 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ from leapflow.monitor import WatchSpec view = await monitors.arm_watch(WatchSpec.from_dict(_parse_watch_spec(rest_tokens))) - return {"ok": True, "view": "dashboard", "mode": "armed", "watch": view.to_dict()} + watch = view.to_dict() + # Creating a board is a "take me to it" action: report the armed watch as + # text and open its web view (the domain board that will fill in as it + # ticks). open_web keeps this decoupled from mode so the TUI can both + # render the confirmation and launch the browser. + payload: dict[str, Any] = { + "ok": True, "view": "dashboard", "mode": "armed", "watch": watch, + "open_web": True, + } + payload.update( + _board_web_directive( + getattr(ctx, "settings", None), + action="watch", + target=str(watch.get("watch_id", "")), + ) + ) + return payload if action in ("pause", "resume", "stop"): if not rest_tokens: @@ -1256,7 +1272,6 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ "findings": [f.to_dict() for f in findings]} if action in ("open", "home", "session"): - from leapflow.dashboard import launcher from leapflow.dashboard.intent import DashboardIntent intent = DashboardIntent.from_args(rest) @@ -1272,34 +1287,50 @@ async def _execute_dashboard(ctx: "Context", name: str, args: str = "") -> dict[ session_watch_id = await _ensure_session_watch_refresh(ctx, monitors) except Exception: logger.debug("dashboard: session watch refresh failed", exc_info=True) - settings = getattr(ctx, "settings", None) - state = None - if settings is not None: - try: - state = launcher.server_running(settings) - except Exception: - state = None payload: dict[str, Any] = { "ok": True, "view": "dashboard", "mode": "open", - "action": intent.action, "intent": intent.to_dict(), "running": bool(state), + "intent": intent.to_dict(), } + payload.update( + _board_web_directive( + getattr(ctx, "settings", None), + action=intent.action, + target=intent.target, + ) + ) if session_watch_id: payload["watch_id"] = session_watch_id - if state: - url = launcher.build_url(state["bind"], state["port"], state["token"]) - if intent.action != "home": - url += f"&action={intent.action}" - if intent.action == "session": - url += "&target=session" - payload["url"] = url - else: - payload["hint"] = "Run `leap board` to open the web view." return payload return {"ok": False, "message": f"Unknown board subcommand: {action}. " "Try list|status|new|pause|resume|stop|mute|refresh|findings|open|session."} +def _board_web_directive(settings: Any, *, action: str, target: str) -> dict[str, Any]: + """Build the web-open directive (running state + URL or install hint). + + Shared by every board view so URL assembly (action + target) lives in one + place and both the daemon-running and not-yet-running cases are handled. + """ + from leapflow.dashboard import launcher + + directive: dict[str, Any] = {"action": action, "target": target} + state = None + if settings is not None: + try: + state = launcher.server_running(settings) + except Exception: + state = None + directive["running"] = bool(state) + if state: + directive["url"] = launcher.build_view_url( + state["bind"], state["port"], state["token"], action=action, target=target, + ) + else: + directive["hint"] = "Run `leap board` to open the web view." + return directive + + def _is_app_command_name(name: str) -> bool: return name == "app" or name.startswith("app ") diff --git a/src/leapflow/dashboard/launcher.py b/src/leapflow/dashboard/launcher.py index a786409..a914854 100644 --- a/src/leapflow/dashboard/launcher.py +++ b/src/leapflow/dashboard/launcher.py @@ -77,6 +77,28 @@ def build_url(bind: str, port: int, token: str, path: str = "/") -> str: return f"http://{_host_for_bind(bind)}:{port}{path}{suffix}" +def build_view_url( + bind: str, + port: int, + token: str, + *, + action: str = "home", + target: str = "", +) -> str: + """Build a token-scoped URL that also selects a specific board view. + + Single owner of the ``action``/``target`` query contract so every entry + (session open, watch detail, freshly-created board) lands on the intended + page instead of silently falling back to the overview. + """ + url = build_url(bind, port, token) + if action and action != "home": + url += f"&action={action}" + if target: + url += f"&target={target}" + return url + + def is_port_open(host: str, port: int, timeout: float = 0.3) -> bool: """Return True when a TCP connect to (host, port) succeeds quickly.""" try: @@ -171,6 +193,7 @@ def ensure_server(settings: Any, *, wait_s: float = 8.0) -> dict[str, Any]: "write_state", "clear_state", "build_url", + "build_view_url", "is_port_open", "server_running", "open_in_browser", diff --git a/tests/test_dashboard_watch_rpc.py b/tests/test_dashboard_watch_rpc.py index f9f2f3f..58c7234 100644 --- a/tests/test_dashboard_watch_rpc.py +++ b/tests/test_dashboard_watch_rpc.py @@ -140,6 +140,49 @@ async def test_board_session_returns_watch_id_without_blocking(tmp_path: Path) - await asyncio.gather(*list(manager._background_tasks)) +async def test_board_new_opens_web_view_for_created_watch(tmp_path: Path) -> None: + """/board new must both confirm the armed watch (text) and open its web view + (open_web), focused on the freshly created watch.""" + ctx = SimpleNamespace(monitors=_manager(tmp_path), settings=None, engine=None) + + payload = await command_execute(ctx, "board new", "demo --name Market --trigger 5m") + + assert payload["mode"] == "armed" # still renders the confirmation text + assert payload["watch"]["name"] == "Market" + assert payload["open_web"] is True # and launches the browser + assert payload["action"] == "watch" + assert payload["target"] == payload["watch"]["watch_id"] + + +async def test_board_open_targets_the_requested_watch(tmp_path: Path) -> None: + """/board open must carry the target so the web lands on the watch + detail, not the overview.""" + manager = _manager(tmp_path) + view = await manager.arm_watch(WatchSpec(name="D", domain="demo")) + ctx = SimpleNamespace(monitors=manager, settings=None, engine=None) + + payload = await command_execute(ctx, "board open", view.watch_id) + + assert payload["mode"] == "open" + assert payload["action"] == "open" + assert payload["target"] == view.watch_id + + +def test_build_view_url_propagates_action_and_target() -> None: + from leapflow.dashboard import launcher + + assert ( + launcher.build_view_url("127.0.0.1", 8765, "tok", action="watch", target="abc123") + == "http://127.0.0.1:8765/?token=tok&action=watch&target=abc123" + ) + # Home is the default view and needs no action/target query params. + assert launcher.build_view_url("127.0.0.1", 8765, "tok") == "http://127.0.0.1:8765/?token=tok" + assert ( + launcher.build_view_url("127.0.0.1", 8765, "tok", action="session", target="session") + == "http://127.0.0.1:8765/?token=tok&action=session&target=session" + ) + + async def test_dashboard_command_scheduler_disabled(tmp_path: Path) -> None: ctx = SimpleNamespace(monitors=None) disabled = await command_execute(ctx, "board list", "") From 188d48acdb2fb9a0cb8c63b4ce0eaf0d66e3b165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Fri, 17 Jul 2026 14:12:04 +0800 Subject: [PATCH 14/25] Refactor LeapBoard to session-only, template-as-hub model One analysis target (the current session); a template is the sole view lens. /board opens the session board with the default generic template; /board