[haik]: add pyright linter check for existence errors, wire analytics SDK wrappers (agent created, dashboard events, onboarding steps, link email), rename underscore-prefixed privates to p_ convention, promote cancel_event to a typed model field, fix ws_manager import casing and token-scrub args handling

This commit is contained in:
haikdc
2026-06-20 11:37:55 -07:00
parent d62e6a837a
commit f2ec278c91
19 changed files with 289 additions and 25 deletions
+14 -9
View File
@@ -251,10 +251,9 @@ class AgentManager:
# ever re-launched with the same id.
if config.mode == "view-builder" and not config.target_directory:
try:
from backend.apps.outputs.outputs import (
ensure_webapp_workspace_seeded_and_registered,
_load,
)
from backend.apps.outputs.outputs import ensure_webapp_workspace_seeded_and_registered
from backend.apps.outputs.workspace_io import load
output_id = ensure_webapp_workspace_seeded_and_registered(
workspace_id=session_id,
folder=effective_cwd,
@@ -268,7 +267,7 @@ class AgentManager:
# second upsert with the real name once the agent has
# written meta.json.
try:
new_output = _load(output_id)
new_output = load(output_id)
await WS_MANAGER.broadcast_global("agent:output_upserted", {
"output": new_output.model_dump(mode="json"),
})
@@ -320,6 +319,12 @@ class AgentManager:
"session": session.model_dump(mode="json"),
})
try:
from backend.apps.service.analytics import track_agent_created
track_agent_created(id=session.id, name=session.name, dashboard_id=session.dashboard_id)
except Exception:
pass
return session
def _build_dir_tree(self, root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
@@ -3540,8 +3545,8 @@ class AgentManager:
if session:
# Set cancel event BEFORE cancelling the task so in-flight
# browser agent loops see it immediately
if hasattr(session, '_cancel_event'):
session._cancel_event.set()
if session.cancel_event is not None:
session.cancel_event.set()
for req in list(session.pending_approvals):
WS_MANAGER.resolve_approval(req.id, {"behavior": "deny", "message": "Agent stopped"})
@@ -3993,8 +3998,8 @@ class AgentManager:
WS_MANAGER.resolve_approval(req.id, {"behavior": "deny", "message": "Session closed"})
session.pending_approvals = []
if hasattr(session, '_cancel_event'):
session._cancel_event.set()
if session.cancel_event is not None:
session.cancel_event.set()
self._sync_session_close(session)
+5 -5
View File
@@ -455,15 +455,15 @@ async def probe_model(body: dict):
from backend.apps.agents.providers.registry import (
resolve_model_id_for_sdk,
get_api_type,
_find_builtin_model,
_NINEROUTER_MODEL_PREFIXES,
find_builtin_model,
NINEROUTER_MODEL_PREFIXES,
)
from backend.apps.settings.store import load_settings
from backend.apps.nine_router.process import is_running
settings = load_settings()
api_type = get_api_type(short_name)
resolved = resolve_model_id_for_sdk(short_name, settings)
entry = _find_builtin_model(short_name) or {}
entry = find_builtin_model(short_name) or {}
route = entry.get("route")
connection_mode = getattr(settings, "connection_mode", "own_key")
@@ -473,7 +473,7 @@ async def probe_model(body: dict):
# Routing mirrors agent_manager: prefix takes precedence over Pro.
resolved_is_9router = (
isinstance(resolved, str)
and resolved.startswith(_NINEROUTER_MODEL_PREFIXES)
and resolved.startswith(NINEROUTER_MODEL_PREFIXES)
)
if resolved_is_9router:
@@ -707,7 +707,7 @@ async def list_models():
or_models = []
if or_models:
by_vendor: dict[str, list[dict]] = {}
from backend.apps.agents.providers.registry import (
from backend.apps.agents.providers.pricing import (
compute_tiers as _ct,
compute_billing_kind as _cbk,
)
+1 -1
View File
@@ -381,7 +381,7 @@ async def run_browser_agent(
system_prompt=SYSTEM_PROMPT,
parent_session_id=parent_session_id,
)
session._cancel_event = cancel_event
session.cancel_event = cancel_event
agent_manager.sessions[session_id] = session
# If parent was already stopped before we registered, bail immediately
+4 -1
View File
@@ -1,7 +1,8 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, InstanceOf
from typing import Optional, Literal, Any
from datetime import datetime
from uuid import uuid4
import asyncio
class AgentConfig(BaseModel):
name: str = Field(default_factory=lambda: f"Agent-{uuid4().hex[:6]}")
@@ -139,3 +140,5 @@ class AgentSession(BaseModel):
context_window: int = 200_000
# Provider-agnostic thinking level (off/low/medium/high/auto), translated per-API in agent_manager; only affects reasoning-flagged models.
thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
# Event to cancel the agent loop. Set before cancelling the task so in-flight browser agent loops see it immediately.
cancel_event: Optional[InstanceOf[asyncio.Event]] = None
+1 -1
View File
@@ -47,7 +47,7 @@ class SeqLogStore:
if log is not None:
return log
async with self.p_dict_lock:
log = self._per_session.get(session_id)
log = self.p_per_session.get(session_id)
if log is None:
log = P_SessionSeqLog()
self.p_per_session[session_id] = log
+2 -2
View File
@@ -137,8 +137,8 @@ class ConnectionManager:
# wipes pending_futures, which is correct because
# reconcile_on_startup also marks waiting_approval sessions as
# stopped so there's nothing to answer anyway.
events = self._filter_stale_approvals(events)
events = self._strip_replayed_closes(events)
events = self.p_filter_stale_approvals(events)
events = self.p_strip_replayed_closes(events)
for s in events:
try:
await websocket.send_text(s)
+6
View File
@@ -77,6 +77,12 @@ def p_sync_identity_to_service(settings_obj) -> None:
identify(props)
except Exception as e:
logger.debug("identify sync failed: %s", e)
if email:
try:
from backend.apps.service.analytics import track_link_email
track_link_email(email)
except Exception as e:
logger.debug("analytics link_email sync failed: %s", e)
# ---------------------------------------------------------------------------
+7
View File
@@ -133,6 +133,8 @@ async def list_dashboards():
async def create_dashboard(body: DashboardCreate):
dashboard = Dashboard(name=body.name)
save(dashboard)
from backend.apps.service.analytics import track_dashboard_event
track_dashboard_event(dashboard_id=dashboard.id, action="create")
return dashboard.model_dump(mode="json")
@@ -410,6 +412,8 @@ async def delete_dashboard(dashboard_id: str):
logger.warning(f"Failed to delete active session {sid} during dashboard deletion")
p_delete(dashboard_id)
from backend.apps.service.analytics import track_dashboard_event
track_dashboard_event(dashboard_id=dashboard_id, action="delete")
return {"ok": True}
@@ -512,4 +516,7 @@ async def duplicate_dashboard(dashboard_id: str):
}
atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard)
from backend.apps.service.analytics import track_dashboard_event
track_dashboard_event(dashboard_id=new_id, action="create")
return new_dashboard
+52
View File
@@ -74,3 +74,55 @@ def shutdown_analytics() -> None:
P_CLIENT.close()
finally:
P_CLIENT = None
# ---------------------------------------------------------------------------
# Typed fire-and-forget wrappers. Each one resolves the singleton, no-ops when
# the client is unavailable, and swallows every error (including the SDK's
# synchronous pydantic.ValidationError) so a bad/missing analytics call can
# never break a product code path. Call these from feature code, not the raw
# client.
# ---------------------------------------------------------------------------
def track_link_email(email: Optional[str]) -> None:
if not email:
return
c = get_analytics_client()
if c is None:
return
try:
c.identify.link_email(email=email)
except Exception as e:
logger.debug("analytics link_email failed: %s", e)
def track_agent_created(*, id: str, name: Optional[str] = None, dashboard_id: Optional[str] = None) -> None:
c = get_analytics_client()
if c is None:
return
try:
c.events.agent.create(id=id, name=name, dashboard_id=dashboard_id)
except Exception as e:
logger.debug("analytics agent.create failed: %s", e)
def track_dashboard_event(*, dashboard_id: str, action: str) -> None:
"""action is one of: open, close, create, delete (validated by the SDK)."""
c = get_analytics_client()
if c is None:
return
try:
c.events.dashboard.event(dashboard_id=dashboard_id, action=action)
except Exception as e:
logger.debug("analytics dashboard.event failed: %s", e)
def track_onboarding_step(*, step_id: str, status: str) -> None:
"""status is one of: started, completed, abandoned (validated by the SDK)."""
c = get_analytics_client()
if c is None:
return
try:
c.events.onboarding.step(step_id=step_id, status=status)
except Exception as e:
logger.debug("analytics onboarding.step failed: %s", e)
+36
View File
@@ -418,6 +418,40 @@ async def service_status():
# Frontend event endpoints
# ---------------------------------------------------------------------------
def p_bridge_to_analytics(item: dict) -> None:
"""Re-emit frontend `report()` events through the typed swarm-analytics SDK.
The frontend is browser-side and can't reach the analytics service
directly, so onboarding steps and dashboard open/close arrive here as
{s, a, p} envelopes. We translate the ones we care about into product
events. Best-effort: never raises (the track_* wrappers swallow errors).
Dashboard create/delete are NOT bridged here; those fire authoritatively
from the dashboards routes, bridging them too would double-count.
"""
s = item.get("s")
a = item.get("a")
p = item.get("p") or {}
if not isinstance(p, dict):
return
if s == "onboarding_v2":
status = {
"step_started": "started",
"step_completed": "completed",
"step_aborted": "abandoned",
"step_selector_timeout": "abandoned",
"step_error": "abandoned",
}.get(a)
step_id = p.get("step_id")
if status and step_id:
from backend.apps.service.analytics import track_onboarding_step
track_onboarding_step(step_id=str(step_id), status=status)
elif s == "dashboard" and a in ("open", "close"):
dashboard_id = p.get("dashboard_id")
if dashboard_id:
from backend.apps.service.analytics import track_dashboard_event
track_dashboard_event(dashboard_id=str(dashboard_id), action=a)
@service.router.post("/submit")
async def post_submit(body=Body(...)):
"""Accepts three body shapes for backward compatibility:
@@ -447,6 +481,7 @@ async def post_submit(body=Body(...)):
if isinstance(item, dict):
if any(k in item for k in ("s", "a", "p")):
sync(item)
p_bridge_to_analytics(item)
continue
kind = item.get("kind") or ""
payload = item.get("payload") or {}
@@ -459,6 +494,7 @@ async def post_submit(body=Body(...)):
# Shape 1: frontend `report()`; flat {s, a, p, ...}
if any(k in body for k in ("s", "a", "p")):
sync(body)
p_bridge_to_analytics(body)
return {"ok": True}
# Shape 2: legacy {kind, payload}
kind = body.get("kind") or ""
+3
View File
@@ -173,6 +173,9 @@ async def update_settings(body: AppSettings):
id_props["referral_source"] = body.user_referral_source
if id_props:
identify(id_props)
if body.user_email:
from backend.apps.service.analytics import track_link_email
track_link_email(body.user_email)
await save_settings_async(body)
+7 -2
View File
@@ -118,9 +118,14 @@ class P_TokenScrubFilter(logging.Filter):
try:
if isinstance(record.msg, str) and TOKEN in record.msg:
record.msg = record.msg.replace(TOKEN, self.P_PLACEHOLDER)
scrubbed = self._scrub_args(record.args)
scrubbed = self.p_scrub_args(record.args)
if scrubbed is not record.args:
record.args = scrubbed
if scrubbed is None or isinstance(scrubbed, (tuple, dict)):
record.args = scrubbed
else:
# A bare str can't be stored as record.args; fold it into msg and drop args.
record.msg = str(scrubbed)
record.args = None
try:
rendered = record.getMessage()
if TOKEN in rendered:
+3 -3
View File
@@ -680,7 +680,7 @@ async def mcp_meta(action: str, request: Request):
if session.sdk_session_id:
session.needs_fresh_session = True
try:
from backend.apps.agents.core.WS_MANAGER import WS_MANAGER
from backend.apps.agents.core.ws_manager import WS_MANAGER
await WS_MANAGER.send_to_session(parent_session_id, "agent:status", {
"session_id": parent_session_id,
"status": session.status,
@@ -748,7 +748,7 @@ async def session_compact(session_id: str):
only sets the marker; the button is the user opting into the cost).
"""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.WS_MANAGER import WS_MANAGER
from backend.apps.agents.core.ws_manager import WS_MANAGER
session = agent_manager.sessions.get(session_id)
if not session:
return JSONResponse({"error": "session not found"}, status_code=404)
@@ -767,7 +767,7 @@ async def session_compact(session_id: str):
async def session_clear(session_id: str):
"""Wipe the session's UI history AND its SDK convo state (/clear slash cmd, Reset history button)."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.WS_MANAGER import WS_MANAGER
from backend.apps.agents.core.ws_manager import WS_MANAGER
from backend.apps.agents.core.models import MessageBranch
session = agent_manager.sessions.get(session_id)
if not session:
+5
View File
@@ -20,4 +20,9 @@ pytest-mock==3.15.1
# imports/locals to ruff (see linter/checks/vulture.py).
vulture==2.16
ruff==0.15.17
# pyright: powers linter/checks/pyright.py. Run in a low-noise mode (most rules
# off) that promotes attribute/name/call existence errors to catch references to
# methods/attributes that don't exist (e.g. a missed rename). The pip wrapper
# downloads the pinned node-based pyright binary; node is required at lint time.
pyright==1.1.410
google-workspace-mcp==2.0.1
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { openSettingsModal } from '@/shared/state/settingsSlice';
import { report } from '@/shared/serviceClient';
import Box from '@mui/material/Box';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
@@ -327,6 +328,19 @@ const AppShell: React.FC = () => {
? location.pathname.split('/dashboard/')[1]
: null;
// Emit dashboard open/close as the active dashboard changes. The backend
// bridges these (POST /service/submit) into the analytics SDK; there's no
// backend route for viewing a dashboard, so the route transition is the
// only accurate open/close signal. close-then-open on switches.
const prevDashboardIdRef = useRef<string | null>(null);
useEffect(() => {
const prev = prevDashboardIdRef.current;
if (prev === activeDashboardId) return;
if (prev) report('dashboard', 'close', { dashboard_id: prev });
if (activeDashboardId) report('dashboard', 'open', { dashboard_id: activeDashboardId });
prevDashboardIdRef.current = activeDashboardId;
}, [activeDashboardId]);
const [lastDashboardId, setLastDashboardId] = useLastDashboardId();
const activeAppId = location.pathname.startsWith('/apps/')
? location.pathname.split('/apps/')[1]
+95
View File
@@ -0,0 +1,95 @@
"""Pyright runner: catches references to methods/attributes/names that don't exist.
The other checks can't do this. ruff is per-file and never builds a cross-symbol
type graph; vulture finds dead (unused) definitions, not invalid references; the
naming checks only inspect where names are *defined*, not where they're *read*.
So a missed rename like ``self._per_session`` (when the attribute is actually
``p_per_session``) sails through everything and only blows up at runtime.
Pyright resolves types/inheritance/imports, so its ``reportAttributeAccessIssue``
flags exactly that. We run it in the lowest-noise mode possible
(config/pyright_check.json sets typeCheckingMode "off" and re-enables only the
existence-checking rules as errors), so this section stays high-signal without a
full strict-mode cleanup.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
from . import CheckError, is_excepted, is_lintignored
# Cold first run downloads the pinned node binary (pip wrapper) and warms the
# import graph; keep this generous so a slow first pass doesn't time out and
# silently report zero.
_TIMEOUT = 240
_CONFIG_REL = Path("linter") / "config" / "pyright_check.json"
def run_pyright(
root: Path,
exceptions: dict[str, list[str]],
ignores: dict[Path, set[str]] | None = None,
) -> list[str]:
"""Run pyright on the Python backend and return existence errors."""
pyright_bin = root / "backend" / ".venv" / "bin" / "pyright"
if not pyright_bin.exists():
found = shutil.which("pyright")
if not found:
raise CheckError("pyright executable not found in backend/.venv/bin or PATH")
pyright_bin = Path(found)
config = root / _CONFIG_REL
if not config.exists():
raise CheckError(f"pyright config not found at {_CONFIG_REL}")
cmd = [str(pyright_bin), "--project", str(config), "--outputjson"]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, cwd=str(root), timeout=_TIMEOUT,
)
except subprocess.TimeoutExpired as e:
raise CheckError(f"timed out after {_TIMEOUT}s (machine under load or cold cache)") from e
except OSError as e:
raise CheckError(f"failed to launch pyright ({e})") from e
# pyright exits 0 (no errors) or 1 (errors found) on a successful run. Any
# other code with no parseable JSON means pyright itself failed (e.g. node
# missing, bad config) — surface it rather than treating empty as clean.
out = result.stdout.strip()
if not out:
detail = (result.stderr or "no output").strip()[:300]
raise CheckError(f"pyright produced no JSON (exit {result.returncode}): {detail}")
try:
data = json.loads(out)
except json.JSONDecodeError as e:
raise CheckError(f"pyright JSON parse failed (exit {result.returncode}): {e}") from e
errors: list[str] = []
for diag in data.get("generalDiagnostics", []):
if diag.get("severity") != "error":
continue
file_abs = diag.get("file", "")
try:
relpath = str(Path(file_abs).resolve().relative_to(root))
except ValueError:
# Diagnostic outside the repo root (stub/site-packages); ignore.
continue
if is_excepted(relpath, "pyright", exceptions):
continue
if ignores and is_lintignored(root / relpath, root, "pyright", ignores):
continue
# pyright ranges are 0-based; the linter/IDE want 1-based line+col.
start = diag.get("range", {}).get("start", {})
line = int(start.get("line", 0)) + 1
col = int(start.get("character", 0)) + 1
rule = diag.get("rule", "")
msg = " ".join((diag.get("message", "") or "").splitlines()).strip()
rule_part = f"{rule} " if rule else ""
errors.append(f"{relpath}:{line}:{col}: error: [pyright] {rule_part}{msg}")
return errors
+9
View File
@@ -6,6 +6,7 @@
"no-nested-imports": false,
"vulture": true,
"ruff": true,
"pyright": true,
"no-underscore-names": true,
"p-private": true,
"eslint": false,
@@ -18,6 +19,7 @@
"eslint-knip": "Node tooling deferred to a later pass.",
"classes": "Placeholder check, not wired up. endpoints: orphaned-endpoint triage deferred.",
"ruff-vulture-split": "ruff owns per-file/per-scope checks (F401 unused imports, F811 redefinitions, F841 unused locals, ARG001/ARG002 unused args), which it does AST-accurately and which vulture either gets wrong (global name-set hides per-file dead imports) or rates as noisy 60% findings. vulture is narrowed in checks/vulture.py to whole-program reachability only (dead functions/methods/classes/attributes), the one thing ruff structurally cannot do since it never builds a cross-module symbol graph. F401 honors __all__ and the redundant-alias form (import x as x) for intentional re-exports; add one of those if F401 flags a deliberate re-export.",
"pyright": "Catches references to methods/attributes/names that DON'T EXIST (e.g. self._per_session when the attribute is p_per_session after a missed rename). ruff/vulture/naming checks structurally can't: none resolve types/inheritance/imports, and the naming checks only inspect definition sites, not reads. Runs pyright (config/pyright_check.json) in the lowest-noise mode: typeCheckingMode 'off' silences the strict firehose, then re-enables only reportAttributeAccessIssue, reportUndefinedVariable, and reportMissingImports as errors. Needs node on PATH (pip pyright wrapper downloads the pinned binary). Add legitimate dynamic-access false positives to the 'pyright' exceptions list or a .lintignore-pyright sentinel.",
"no-underscore-names": "Bans leading-underscore names in backend/ Python (functions, methods, args, classes, variables, instance/class attribute writes, import aliases). The prefix is a blind spot: Pylance reportUnusedVariable, ruff dummy-variable-rgx (F841/ARG0xx), and vulture all treat it as intentionally-private/unused and stop reporting, so dead _name code hides. Exempt: dunders (__x__, required by Python) and the bare _ throwaway. Name-mangled __x IS flagged. Intentionally strict with no exceptions seeded -- offenders are renamed by hand.",
"p-private": "Enforces the p_ private convention (case-insensitive leading p, so P_ UPPER_SNAKE constants count too) with Java-private semantics over backend/ Python. Module-level p_ symbols (top-level def/class/assignment) are file-private; class members (def p_m, self.p_x=, class-body fields p_x: T) are class-private. A reference is legal only inside the owning file (module-level) or lexically inside the owning class / a class nested in it (members). Strict: cross-file subclass access to a base's p_ member is flagged (private, not protected). Nested classes may reach the enclosing class's members (whole enclosing-class stack is checked). Detection is access-form based (no type inference): attribute access governs class members, bare-name/import governs module-level. No exemptions -- tests and __init__.py re-exports are enforced. Greenfield today (zero p_ in tree), so it ships green and only fires once the convention is used.",
"max-file-lines-exceptions": "Grandfather list of pre-existing >300-line files (existing debt, not new). Paths updated after the folder-tree restructure moved several of them.",
@@ -68,6 +70,13 @@
"import-cycles": [],
"vulture": [],
"ruff": [],
"pyright": [
"backend/apps/subscription/free_trial.py",
"backend/apps/agents/browser_agent_mcp_server.py",
"backend/apps/health/health.py",
"backend/apps/web/web.py",
"backend/config/Apps.py"
],
"no-underscore-names": [
"backend/apps/agents/agent_manager.py",
"backend/apps/agents/agents.py",
+20
View File
@@ -0,0 +1,20 @@
{
"include": ["../../backend"],
"exclude": [
"../../backend/.venv",
"../../backend/uv-bin",
"../../backend/data",
"../../backend/tests",
"../../backend/apps/outputs/webapp_template",
"**/__pycache__"
],
"venvPath": "../../backend",
"venv": ".venv",
"pythonVersion": "3.11",
"extraPaths": ["../.."],
"reportMissingTypeStubs": false,
"typeCheckingMode": "off",
"reportAttributeAccessIssue": "error",
"reportUndefinedVariable": "error",
"reportMissingImports": "error"
}
+5 -1
View File
@@ -22,6 +22,7 @@ from checks.classes import run_class_check
from checks.cycles import run_cycle_check
from checks.no_underscore_names import run_underscore_check
from checks.p_private import run_p_private_check
from checks.pyright import run_pyright
from watchfiles import watch, DefaultFilter
SCRIPT_DIR = Path(__file__).resolve().parent
@@ -29,7 +30,7 @@ CONFIG_FILE = SCRIPT_DIR / "config" / "config.json"
# Print order for the sections; also the order they run in.
SECTION_ORDER = [
"structural", "vulture", "ruff", "no-underscore-names", "p-private", "eslint",
"structural", "vulture", "ruff", "pyright", "no-underscore-names", "p-private", "eslint",
"knip", "endpoints", "classes", "import-cycles",
]
@@ -154,6 +155,9 @@ def run_checks(root: Path) -> LintResult:
run_section("ruff", lambda: run_ruff(
root, rules.get("ruff-select", "F401,F811,F841,ARG001,ARG002"), exceptions, ignores,
) if enabled.get("ruff", True) else [])
run_section("pyright", lambda: run_pyright(
root, exceptions, ignores,
) if enabled.get("pyright", True) else [])
run_section("no-underscore-names", lambda: run_underscore_check(
root, exceptions, excludes, ignores,
) if enabled.get("no-underscore-names", True) else [])