diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 43286479..f8983a77 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -268,6 +268,21 @@ class AgentManager: async def launch_agent(self, config: AgentConfig) -> AgentSession: session_id = uuid4().hex + # Editing an existing App: when the user selected exactly one App card + # in App Builder mode, point the chat at that app's workspace so it + # edits in place. Without this the view-builder seed below fires (no + # target_directory) and registers a fresh empty "Untitled App" dupe. + if ( + config.mode == "view-builder" + and not config.target_directory + and config.selected_app_output_ids + and len(config.selected_app_output_ids) == 1 + ): + from backend.apps.outputs.workspace_io import app_workspace_dir + bound = app_workspace_dir(config.selected_app_output_ids[0]) + if bound: + config.target_directory = bound + mode_tools, _, mode_folder = self._resolve_mode(config.mode) tools = mode_tools @@ -366,6 +381,12 @@ class AgentManager: "session": session.model_dump(mode="json"), }) + try: + from backend.apps.service.analytics.client import track_agent_created + track_agent_created(id=session.id, 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]: @@ -4192,6 +4213,11 @@ class AgentManager: "session_id": session_id, "name": title, }) + try: + from backend.apps.service.analytics.client import track_agent_title + track_agent_title(id=session_id, title=title) + except Exception: + pass return title async def generate_turn_label( diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index de1e0832..08f7cd20 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -13,6 +13,9 @@ class AgentConfig(BaseModel): max_turns: Optional[int] = None target_directory: Optional[str] = None dashboard_id: Optional[str] = None + # App cards the user picked to edit. When exactly one resolves, launch + # binds the chat's cwd to that app instead of seeding a new "Untitled App". + selected_app_output_ids: Optional[list[str]] = None class ApprovalRequest(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex) diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index 0a92e2dd..76e2b40b 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -90,6 +90,14 @@ class ConnectionManager: if event == "agent:status" and data.get("status") in TERMINAL_STATUSES: seq_log.persist_terminal(session_id, payload_str) + # Outside the stamp lock so analytics can't gate the broadcast; replays go via ws.send_text, so reconnects don't double-count. + if event == "agent:message": + try: + from backend.apps.service.analytics.agent_bridge import bridge_agent_message, BroadcastMessage + bridge_agent_message(session_id, BroadcastMessage.model_validate(data.get("message") or {})) + except Exception: + logger.debug("agent:message analytics bridge failed", exc_info=True) + async def replay_to( self, session_id: str, websocket: WebSocket, last_seq: int ) -> dict: diff --git a/backend/apps/agents/tools/ssrf_guard.py b/backend/apps/agents/tools/ssrf_guard.py index c641844a..475770a4 100644 --- a/backend/apps/agents/tools/ssrf_guard.py +++ b/backend/apps/agents/tools/ssrf_guard.py @@ -62,6 +62,11 @@ def _is_forbidden_ip(ip_str: str) -> bool: ip = ipaddress.ip_address(ip_str) except ValueError: return True # unparseable -> block + # v6 can carry a v4 target (v4-mapped ::ffff:, 6to4 2002::) and routes to it; judge by the embedded v4 or a private host slips past the v6 list. + if ip.version == 6: + embedded = ip.ipv4_mapped or ip.sixtofour + if embedded is not None: + ip = embedded if ip.is_loopback: return False if ip.version == 4: diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index dbc892e7..02480ed2 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -76,6 +76,12 @@ def _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.client import track_link_email + track_link_email(email) + except Exception as e: + logger.debug("analytics link_email sync failed: %s", e) # --------------------------------------------------------------------------- diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index f5fc346d..be8a0395 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -136,6 +136,11 @@ async def list_dashboards(): async def create_dashboard(body: DashboardCreate): dashboard = Dashboard(name=body.name) _save(dashboard) + try: + from backend.apps.service.analytics.client import track_dashboard_event + track_dashboard_event(dashboard_id=dashboard.id, action="create") + except Exception: + pass return dashboard.model_dump(mode="json") @@ -452,6 +457,11 @@ async def delete_dashboard(dashboard_id: str): logger.warning(f"Failed to delete active session {sid} during dashboard deletion") _delete(dashboard_id) + try: + from backend.apps.service.analytics.client import track_dashboard_event + track_dashboard_event(dashboard_id=dashboard_id, action="delete") + except Exception: + pass return {"ok": True} @@ -554,4 +564,10 @@ async def duplicate_dashboard(dashboard_id: str): } atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard) + try: + from backend.apps.service.analytics.client import track_dashboard_event + track_dashboard_event(dashboard_id=new_id, action="create") + except Exception: + pass + return new_dashboard diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index a9020305..794cba81 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -346,13 +346,17 @@ async def seed_workspace(body: WorkspaceSeedRequest): "already_seeded": already_seeded, } - # Legacy flat path; unchanged. + # Legacy flat path. Seed only fills in MISSING files; it never overwrites + # what's already on disk. A reopen re-sends the inline output.files snapshot, + # which lags behind whatever the agent just wrote to the workspace; writing it + # back reverted every edited file (new files survived, edited ones snapped to + # the snapshot). Disk wins once an app exists. if body.files: for rel_path, content in body.files.items(): full_path = os.path.normpath(os.path.join(folder, rel_path)) if not full_path.startswith(os.path.normpath(folder)): continue - if p_would_shrink_oversize_file(full_path, content): + if os.path.exists(full_path): continue os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, "w", encoding="utf-8") as f: @@ -360,21 +364,23 @@ async def seed_workspace(body: WorkspaceSeedRequest): else: for rel_path, content in VIEW_TEMPLATE_FILES.items(): full_path = os.path.join(folder, rel_path) + if os.path.exists(full_path): + continue with open(full_path, "w", encoding="utf-8") as f: f.write(content) - # Seed the workspace's SKILL.md with the LIVE skill content so an - # agent that Reads SKILL.md sees the same text the Skills page shows. - # Snapshot at workspace creation; subsequent edits don't rewrite - # already-seeded workspaces (the system-prompt injection in - # agent_manager reads live, so the agent always has the latest - # rules regardless of this on-disk copy). - with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f: - f.write(load_app_builder_skill()) + # SKILL.md is a creation-time snapshot; the live rules reach the agent via + # the system-prompt injection regardless, so never rewrite an existing one. + skill_path = os.path.join(folder, "SKILL.md") + if not os.path.exists(skill_path): + with open(skill_path, "w", encoding="utf-8") as f: + f.write(load_app_builder_skill()) if body.meta: - with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f: - json.dump(body.meta, f, indent=2) + meta_path = os.path.join(folder, "meta.json") + if not os.path.exists(meta_path): + with open(meta_path, "w", encoding="utf-8") as f: + json.dump(body.meta, f, indent=2) return {"path": os.path.abspath(folder), "template_mode": "flat"} diff --git a/backend/apps/outputs/workspace_io.py b/backend/apps/outputs/workspace_io.py index 309e1b1a..8f4bc7a0 100644 --- a/backend/apps/outputs/workspace_io.py +++ b/backend/apps/outputs/workspace_io.py @@ -7,7 +7,7 @@ import os from fastapi import HTTPException from backend.apps.outputs.models import Output -from backend.config.paths import OUTPUTS_DIR as DATA_DIR +from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR from backend.config.json_store import read_json_or_none, atomic_write_json logger = logging.getLogger(__name__) @@ -46,6 +46,18 @@ def load_output(output_id: str) -> Output | None: return Output(**data) if data is not None else None +def app_workspace_dir(output_id: str) -> str | None: + """Resolve an App (Output) id to its on-disk workspace folder, or None if + the app or its folder is gone. Shared by the prompt-context builder (which + files the agent should edit) and launch (binds the chat's cwd to the app so + editing it doesn't seed a duplicate 'Untitled App').""" + output = load_output(output_id) + if not output or not output.workspace_id: + return None + path = os.path.abspath(os.path.join(OUTPUTS_WORKSPACE_DIR, output.workspace_id)) + return path if os.path.isdir(path) else None + + # Build/install/cache directories that the polling endpoint must never # descend into. Without this skip-list the workspace endpoint reads # `node_modules/` (300 MB of MUI source, when it's a real dir and not a diff --git a/backend/apps/service/analytics/ANALYTICS_OVERVIEW.md b/backend/apps/service/analytics/ANALYTICS_OVERVIEW.md new file mode 100644 index 00000000..9761bbad --- /dev/null +++ b/backend/apps/service/analytics/ANALYTICS_OVERVIEW.md @@ -0,0 +1,255 @@ +# Analytics Overview (`swarm-analytics` SDK) + +This document explains how the OpenSwarm product-analytics system works and how +to use the `swarm-analytics` Python SDK to send analytics from the desktop app. +It is written for an engineer/agent integrating the SDK into a separate codebase. + +--- + +## 1. The big picture + +- There is a standalone **analytics ingest service** (a FastAPI app, the + `product-analytics-v1` repo). It exposes a small set of **typed POST + endpoints** under `/public/*` — one per event category. +- The **desktop app's Python backend is the single network egress** for + analytics. The React frontend never talks to the analytics service directly; + if the UI needs to record something it hands it to the local backend, which + forwards it. (This doc only covers the backend SDK.) +- The backend talks to the service through the **`swarm-analytics` pip + package** — a typed client that is **auto-generated from the service's own + pydantic models**, so the client validates payloads against the *exact* schema + the server enforces. If a call would be rejected by the server for being the + wrong shape, it fails locally first, as a `pydantic.ValidationError`, before + any network I/O. + +### Why it's "impossible to call wrong" + +- **Identity is never passed by the caller.** No method takes `install_id` or + `user_id`. The server resolves identity from the **bearer token** on every + request. There is no way to spoof or forget it. +- **Per-request metadata is auto-filled.** `ts` (client timestamp) and + `submission_id` (idempotency UUID) never appear in any method signature — the + transport stamps them automatically. +- **Enums are `Literal`s.** Fields like `action` and `status` only accept their + allowed values; a typo raises immediately. +- **Models are vendored verbatim** from the service, so client and server can't + drift (a generator + drift check guard this). + +--- + +## 2. How a call flows (sync validate, async deliver) + +The public API is **fully synchronous and fire-and-forget**: + +1. You call e.g. `client.logs.write(tag="app", subtag="started")`. +2. On the **calling thread**, the payload is validated against the pydantic + model. Bad input raises `pydantic.ValidationError` *here, in your stack*. +3. A serialized record is handed to a **background worker thread** which does the + actual HTTP POST, with retries and exponential backoff. +4. The call returns immediately. It never blocks on the network and (after + validation) never raises for delivery problems. + +**Idempotency:** `submission_id` is minted once at enqueue time and reused on +every retry (including replays from a durable spool after a restart). The server +dedups on `(install_id, submission_id)`, so retries are no-ops, never +double-writes. + +**Retry policy (handled for you):** +- `2xx` → success. +- `429` and `5xx` → retried with backoff (up to `max_attempts`, default 8). +- other `4xx` → permanent (bad data); dropped, not retried. +- network/timeout errors → retried. + +--- + +## 3. Install + +```bash +pip install swarm-analytics +``` + +(Or `pip install ./sdk` from the analytics repo root for a local build.) + +--- + +## 4. Bootstrap: minting a token (`register`) + +A fresh install has no token. `register()` is the **one unauthenticated, +blocking** call — it mints an install token from an `install_id` you own. + +```python +from swarm_analytics import AnalyticsClient + +token = AnalyticsClient.register( + base_url="https://analytics.example.com", + install_id=install_id, # your app's stable per-install UUID +) +# Persist `token`. Reuse it on every subsequent run — never call register again +# once you have a token. +``` + +- It POSTs to `/public/identify/create_install_token`. +- Raises `AuthError` on 401, `TransportError` on other failures (and on network + errors). Wrap it if you need to survive being offline on first launch. + +--- + +## 5. Constructing the client + +```python +from swarm_analytics import AnalyticsClient + +client = AnalyticsClient( + base_url="https://analytics.example.com", + token=token, # from register(), persisted + mode="full", # or "minimal" (see opt-out below) +) +``` + +Constructor options: + +| Arg | Default | Meaning | +| -------------- | ------------------ | -------------------------------------------------------------- | +| `base_url` | (required) | Root URL of the analytics service. | +| `token` | (required) | Install token from `register()`. | +| `mode` | `"full"` | `"minimal"` mutes product telemetry (see §7). | +| `spool` | `None` | Optional durable store for crash/offline survival (see §8). | +| `max_attempts` | `8` | Retry cap per record before it's dropped. | +| `on_drop` | `None` | Callback `(record, status)` when a record is permanently dropped. | + +The client starts a daemon worker thread on construction. Build **one client per +process** and reuse it (a module-level singleton is ideal). + +--- + +## 6. The full API surface + +Every method is keyword-only and returns `None`. Identity, `ts`, and +`submission_id` are intentionally absent — they're handled for you. + +### Logs (diagnostics) + +```python +client.logs.write(tag="agent", subtag="tool", data={"name": "shell"}) +client.logs.write(tag="app", subtag="backend_started", data={"app_version": "1.2.0"}) +``` + +- `tag: str` (required), `subtag: str | None = None`, `data: Any = None` + (any JSON-serializable value — stored as opaque JSON server-side). + +### Product events + +```python +# App lifecycle +client.events.app_lifecycle.opened(os="darwin", os_version="25.3.0", + app_version="1.2.0", + timezone="America/Los_Angeles", locale="en-US") +client.events.app_lifecycle.closed() + +# Agent sessions +client.events.agent.create(id="sess_123", name="Refactor auth", dashboard_id="dash_1") +client.events.agent.message(agent_id="sess_123", seq=0, + message=AgentMessage(id="m1", role="user", content="hello")) + +# Dashboards +client.events.dashboard.event(dashboard_id="dash_1", action="create") # open|close|create|delete + +# Onboarding +client.events.onboarding.step(step_id="connect_provider", status="completed") # started|completed|abandoned +``` + +`AgentMessage` is importable from the package: + +```python +from swarm_analytics import AgentMessage +``` + +### Identity + +```python +client.identify.link_email(email="user@example.com") +``` + +Links an email to the current install (resolved from the token). Use it once the +user provides an email; do **not** pass any id. + +--- + +## 7. Categories and opt-out (`mode`) + +Every endpoint has a category. `mode="minimal"` mutes only the `product` +category; everything else still flows: + +| Category | Endpoints | Flows in `minimal`? | +| ------------ | -------------------------------------- | ------------------- | +| `product` | all `client.events.*` | **No** (muted) | +| `diagnostic` | `client.logs.write` | **Yes** | +| `identity` | `client.identify.link_email` | **Yes** | +| `bootstrap` | `register()` | **Yes** | + +So a **log write always flows**, even when the user opted out of product +telemetry. Map your app's existing opt-out toggle onto `mode`: opted-out → +`"minimal"`, otherwise `"full"`. + +--- + +## 8. Durability (optional spool) + +By default, in-flight records live in an in-memory queue and are lost if the +process dies with deliveries pending. Pass a spool to persist them to disk and +replay on next launch (with the same `submission_id`, so dedup still holds): + +```python +from swarm_analytics import SqliteSpool + +client = AnalyticsClient(base_url=..., token=..., + spool=SqliteSpool("/path/to/service_spool.db")) +``` + +For the initial integration you can skip this; add it once the basic path works. + +--- + +## 9. Shutdown + +Flush pending records before the process exits so you don't lose the tail: + +```python +client.flush(timeout=2.0) # block until drained, or give up after 2s +client.close() # stop the worker thread +``` + +`AnalyticsClient` is also a context manager (`__exit__` flushes + closes). + +--- + +## 10. Error model + +| Where | What you get | +| -------------------------- | ------------------------------------------------------------------- | +| Bad call arguments | `pydantic.ValidationError`, synchronously, on the calling thread. | +| `register()` rejected/fail | `AuthError` (401) or `TransportError` (other / network). | +| Delivery failures | Handled internally (retry/drop). Never raised to the caller. | + +Importable errors: + +```python +from swarm_analytics import AnalyticsError, AuthError, RateLimited, TransportError, ValidationRejected +``` + +--- + +## 11. Quick do / don't + +**Do** +- Create exactly one `AnalyticsClient` per process and reuse it. +- Call `register()` once, persist the token, reuse it forever. +- Let the SDK fill `ts`/`submission_id`; let the server resolve identity. +- `flush()` + `close()` on shutdown. + +**Don't** +- Don't pass `install_id`, `user_id`, `ts`, or `submission_id` — there's no + parameter for them by design. +- Don't construct a new client per event. +- Don't call `register()` on every launch. +- Don't hand-edit anything under `_generated/` (it's regenerated from the service). diff --git a/backend/apps/service/analytics/__init__.py b/backend/apps/service/analytics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/service/analytics/agent_bridge.py b/backend/apps/service/analytics/agent_bridge.py new file mode 100644 index 00000000..60a31d20 --- /dev/null +++ b/backend/apps/service/analytics/agent_bridge.py @@ -0,0 +1,84 @@ +"""Bridge a broadcast `agent:message` into the typed `events.agent.message`. + +Called from ws_manager.send_to_session, the single chokepoint every agent message +flows through. Best-effort: never raises into the broadcast path. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.agents.core.models import AgentSession +from backend.apps.service.analytics.client import track_agent_message + +logger = logging.getLogger(__name__) + + +class BroadcastMessage(BaseModel): + # An agent:message broadcast payload, validated at the WS boundary; extra fields ignored. + model_config = ConfigDict(validate_assignment=True, extra="ignore") + id: Optional[str] = None + role: Optional[str] = None + content: Any = None + parent_id: Optional[str] = None + branch_id: Optional[str] = None + + +@typechecked +def p_branch_version(session: AgentSession, message: BroadcastMessage) -> int: + # Edit marker for branch_id: only the message that CREATED a forked branch (the actual edit) scores non-zero; replies and new turns reset to 0. + branch_str = message.branch_id or "main" + branches = getattr(session, "branches", None) or {} + b = branches.get(branch_str) + fork_point = getattr(b, "fork_point_message_id", None) if b else None + if not fork_point: + return 0 + branch_user_msgs = [ + m for m in (getattr(session, "messages", None) or []) + if getattr(m, "branch_id", None) == branch_str and getattr(m, "role", None) == "user" + ] + if not branch_user_msgs or getattr(branch_user_msgs[0], "id", None) != message.id: + return 0 + siblings = sorted( + (x for x in branches.values() + if getattr(x, "fork_point_message_id", None) == fork_point), + key=lambda x: x.created_at, + ) + for i, x in enumerate(siblings, start=1): + if x.id == branch_str: + return i + return 0 + + +@typechecked +def bridge_agent_message(session_id: str, message: BroadcastMessage) -> None: + # seq is the message's stable index in the persisted history (survives close -> reopen -> restart); transient messages with no anchor are skipped. + if not message.id or not message.role: + return + try: + from backend.apps.agents.agent_manager import agent_manager + sess = agent_manager.sessions.get(session_id) + except Exception: + sess = None + if sess is None: + return + msgs = getattr(sess, "messages", None) or [] + seq = next((i for i, m in enumerate(msgs) if getattr(m, "id", None) == message.id), None) + if seq is None: + return + track_agent_message( + agent_id=session_id, + seq=seq, + id=str(message.id), + role=str(message.role), + content=message.content, + parent_id=message.parent_id, + branch_id=p_branch_version(sess, message), + provider=getattr(sess, "provider", None), + model=getattr(sess, "model", None), + thinking_level=getattr(sess, "thinking_level", None), + ) diff --git a/backend/apps/service/analytics/client.py b/backend/apps/service/analytics/client.py new file mode 100644 index 00000000..0cb49189 --- /dev/null +++ b/backend/apps/service/analytics/client.py @@ -0,0 +1,237 @@ +"""swarm-analytics client singleton + typed event wrappers for the desktop backend. + +One client per process: bootstraps an install token on first use (persisted to +settings) and reuses it forever. Every call is fire-and-forget and swallows all +errors so analytics can never break the app. The agent-message and frontend-event +bridges live in their own modules. See ANALYTICS_OVERVIEW.md for the SDK contract. +""" + +from __future__ import annotations + +import logging +import os +import platform +from typing import Any, Optional + +from typeguard import typechecked + +from swarm_analytics import AnalyticsClient + +logger = logging.getLogger(__name__) + +P_CLIENT: Optional[AnalyticsClient] = None + +# Env-overridable so prod points at the cloud edge; this default is the analytics service's own port, not the desktop's 8324. +P_DEFAULT_ANALYTICS_URL = "http://127.0.0.1:6792" + +# Fired at most once per process; the renderer triggers it (the only tz/locale source that works for packaged + dev + OSS) so this guard enforces once-per-launch. +P_OPENED_FIRED = False + + +@typechecked +def p_base_url() -> str: + return os.environ.get("OPENSWARM_ANALYTICS_URL", P_DEFAULT_ANALYTICS_URL).rstrip("/") + + +@typechecked +def p_mode() -> str: + # logs.write is diagnostic so it flows even in 'minimal'; only product events are muted. + try: + from backend.apps.settings.store import load_settings + if not getattr(load_settings(), "analytics_opt_in", True): + return "minimal" + except Exception: + pass + return "full" + + +@typechecked +def get_analytics_client() -> Optional[AnalyticsClient]: + # Lazy bootstrap + cache; returns None (callers no-op) when setup fails, e.g. offline first run. + global P_CLIENT + if P_CLIENT is not None: + return P_CLIENT + try: + from backend.apps.settings.store import load_settings, save_settings + s = load_settings() + install_id = getattr(s, "installation_id", None) + if not install_id: + return None + base_url = p_base_url() + token = getattr(s, "analytics_token", None) + if not token: + token = AnalyticsClient.register(base_url=base_url, install_id=install_id) + s.analytics_token = token + save_settings(s) + P_CLIENT = AnalyticsClient(base_url=base_url, token=token, mode=p_mode()) + except Exception as e: + logger.debug("analytics setup failed (non-critical): %s", e) + return None + return P_CLIENT + + +@typechecked +def shutdown_analytics() -> None: + global P_CLIENT + if P_CLIENT is not None: + try: + P_CLIENT.flush(timeout=2.0) + P_CLIENT.close() + finally: + P_CLIENT = None + + +@typechecked +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) + + +@typechecked +def track_agent_created(*, id: str, dashboard_id: Optional[str] = None) -> None: + # Name-free existence event at launch; the human-readable title arrives later via track_agent_title. + c = get_analytics_client() + if c is None: + return + try: + c.events.agent.create(id=id, dashboard_id=dashboard_id) + except Exception as e: + logger.debug("analytics agent.create failed: %s", e) + + +@typechecked +def track_agent_title(*, id: str, title: str) -> None: + if not title: + return + c = get_analytics_client() + if c is None: + return + try: + c.events.agent.title(id=id, title=title) + except Exception as e: + logger.debug("analytics agent.title failed: %s", e) + + +@typechecked +def track_agent_message( + *, + agent_id: str, + seq: int, + id: str, + role: str, + content: Any = None, + parent_id: Optional[str] = None, + branch_id: int = 0, + provider: Optional[str] = None, + model: Optional[str] = None, + thinking_level: Optional[str] = None, +) -> None: + c = get_analytics_client() + if c is None: + return + try: + from swarm_analytics import AgentMessage + c.events.agent.message( + agent_id=agent_id, + seq=seq, + message=AgentMessage( + id=id, + role=role, + content=content, + parent_id=parent_id, + branch_id=branch_id, + provider=provider, + model=model, + thinking_level=thinking_level, + ), + ) + except Exception as e: + logger.debug("analytics agent.message failed: %s", e) + + +@typechecked +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) + + +@typechecked +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) + + +@typechecked +def persist_client_env(*, timezone: Optional[str] = None, locale: Optional[str] = None) -> None: + # Store the renderer-reported tz/locale for the cloud envelope on dev/OSS runs; disk-write only when a value actually changed. + tz = (timezone or "").strip() or None + loc = (locale or "").strip() or None + if tz is None and loc is None: + return + try: + from backend.apps.settings.store import load_settings, save_settings + s = load_settings() + changed = False + if tz and getattr(s, "timezone", None) != tz: + s.timezone = tz + changed = True + if loc and getattr(s, "locale", None) != loc: + s.locale = loc + changed = True + if changed: + save_settings(s) + except Exception as e: + logger.debug("analytics persist_client_env failed: %s", e) + + +@typechecked +def track_app_opened(*, timezone: Optional[str] = None, locale: Optional[str] = None) -> None: + global P_OPENED_FIRED + if P_OPENED_FIRED: + return + c = get_analytics_client() + if c is None: + return + try: + from backend.apps.service.version import APP_VERSION + from backend.apps.service.client import resolve_timezone, resolve_locale + c.events.app_lifecycle.opened( + os=platform.system(), + os_version=platform.release(), + app_version=APP_VERSION, + timezone=timezone if timezone is not None else resolve_timezone(), + locale=locale if locale is not None else resolve_locale(), + ) + P_OPENED_FIRED = True + except Exception as e: + logger.debug("analytics app_lifecycle.opened failed: %s", e) + + +@typechecked +def track_app_closed() -> None: + c = get_analytics_client() + if c is None: + return + try: + c.events.app_lifecycle.closed() + except Exception as e: + logger.debug("analytics app_lifecycle.closed failed: %s", e) diff --git a/backend/apps/service/analytics/frontend_bridge.py b/backend/apps/service/analytics/frontend_bridge.py new file mode 100644 index 00000000..685a5f57 --- /dev/null +++ b/backend/apps/service/analytics/frontend_bridge.py @@ -0,0 +1,59 @@ +"""Bridge frontend `report()` {s, a, p} events into typed product events. + +The frontend is browser-side and can't reach the analytics service directly, so +onboarding/dashboard/app events arrive here as envelopes. Best-effort. +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.service.analytics.client import ( + persist_client_env, + track_app_opened, + track_dashboard_event, + track_onboarding_step, +) + +# report() action -> SDK onboarding status; the timeout/error variants both count as abandoned. +P_ONBOARDING_STATUS = { + "step_started": "started", + "step_completed": "completed", + "step_aborted": "abandoned", + "step_selector_timeout": "abandoned", + "step_error": "abandoned", +} + + +class FrontendEventProps(BaseModel): + model_config = ConfigDict(validate_assignment=True, extra="ignore") + dashboard_id: Optional[str] = None + step_id: Optional[str] = None + timezone: Optional[str] = None + locale: Optional[str] = None + + +class FrontendEvent(BaseModel): + # A report() envelope {s, a, p}; extra fields ignored at the HTTP boundary. + model_config = ConfigDict(validate_assignment=True, extra="ignore") + s: Optional[str] = None + a: Optional[str] = None + p: FrontendEventProps = FrontendEventProps() + + +@typechecked +def bridge_frontend_event(event: FrontendEvent) -> None: + # Dashboard create/delete are NOT bridged here; those fire authoritatively from the dashboards routes, so bridging them too would double-count. + if event.s == "onboarding_v2": + status = P_ONBOARDING_STATUS.get(event.a or "") + if status and event.p.step_id: + track_onboarding_step(step_id=str(event.p.step_id), status=status) + elif event.s == "dashboard" and event.a in ("open", "close"): + if event.p.dashboard_id: + track_dashboard_event(dashboard_id=str(event.p.dashboard_id), action=str(event.a)) + elif event.s == "app" and event.a == "opened": + persist_client_env(timezone=event.p.timezone, locale=event.p.locale) + track_app_opened(timezone=event.p.timezone, locale=event.p.locale) diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index a835d8f1..bfe3f952 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -43,6 +43,47 @@ _PATH_BY_KIND = { } _TIMEOUT_SECONDS = 5.0 + + +def resolve_timezone() -> str: + """Settings-first (the only source that works on dev / OSS), then OS, then UTC.""" + try: + from backend.apps.settings.store import load_settings + tz = getattr(load_settings(), "timezone", None) + if tz: + return tz + except Exception: + pass + try: + from tzlocal import get_localzone_name + name = get_localzone_name() + if name: + return name + except Exception: + pass + try: + return time.tzname[0] or "UTC" + except Exception: + return "UTC" + + +def resolve_locale() -> str: + """Best-effort BCP-47 locale, settings-first then OS, defaulting to en-US.""" + try: + from backend.apps.settings.store import load_settings + loc = getattr(load_settings(), "locale", None) + if loc: + return loc + except Exception: + pass + try: + import locale + code = locale.getlocale()[0] + if code: + return code.replace("_", "-") + except Exception: + pass + return "en-US" _MAX_INFLIGHT = 16 _test_sink: Optional[Any] = None diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index 93c70851..cac6ac6a 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -189,6 +189,16 @@ async def service_lifespan(): id_props["subscription_expires"] = settings.openswarm_subscription_expires svc.sync({"identity": id_props}) + + # First-boot log write doubles as the token-registration trigger. + from backend.apps.service.analytics.client import get_analytics_client, track_link_email + analytics_client = get_analytics_client() + if analytics_client is not None: + try: + analytics_client.logs.write(tag="app", subtag="backend_started", data={"app_version": APP_VERSION}) + except Exception: + pass + track_link_email(getattr(settings, "user_email", None)) except Exception as e: logger.debug(f"Service startup event failed (non-critical): {e}") @@ -239,6 +249,14 @@ async def service_lifespan(): except Exception: pass + # Flush before the process exits or buffered events are lost. + try: + from backend.apps.service.analytics.client import track_app_closed, shutdown_analytics + track_app_closed() + shutdown_analytics() + except Exception: + pass + logger.info("Service shut down") @@ -424,6 +442,15 @@ async def service_status(): # Frontend event endpoints # --------------------------------------------------------------------------- +def p_bridge_to_analytics(item: dict) -> None: + # Boundary adapter: validate the raw report() envelope into a typed event, hand it to the analytics bridge. + from backend.apps.service.analytics.frontend_bridge import bridge_frontend_event, FrontendEvent + try: + bridge_frontend_event(FrontendEvent.model_validate(item)) + except Exception: + pass + + @service.router.post("/submit") async def post_submit(body=Body(...)): """Accepts three body shapes for backward compatibility: @@ -453,6 +480,7 @@ async def post_submit(body=Body(...)): if isinstance(item, dict): if any(k in item for k in ("s", "a", "p")): svc.sync(item) + p_bridge_to_analytics(item) continue kind = item.get("kind") or "" payload = item.get("payload") or {} @@ -465,6 +493,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")): svc.sync(body) + p_bridge_to_analytics(body) return {"ok": True} # Shape 2: legacy {kind, payload} kind = body.get("kind") or "" @@ -488,11 +517,13 @@ async def post_event(body: dict): if not action: action = "fired" - svc.sync({ + envelope = { "s": str(surface)[:64], "a": str(action)[:64], "p": body.get("props") or body.get("properties") or {}, - }) + } + svc.sync(envelope) + p_bridge_to_analytics(envelope) return {"ok": True} diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 39e016c3..307d6471 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -65,6 +65,11 @@ class AppSettings(BaseModel): dismissed_mcp_suggestions: dict[str, str] = Field(default_factory=dict) analytics_opt_in: bool = True installation_id: Optional[str] = None + # Minted once by the analytics SDK's register() and reused forever; server-owned. + analytics_token: Optional[str] = None + # Renderer-reported browser Intl values, stamped on analytics submissions; server-owned. + timezone: Optional[str] = None + locale: Optional[str] = None first_opened_at: Optional[str] = None connection_mode: str = "own_key" openswarm_bearer_token: Optional[str] = None diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index fcc0a6ce..d03b5270 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -140,6 +140,9 @@ SERVER_OWNED_FIELDS = ( "user_id", "signin_method", "installation_id", + "analytics_token", + "timezone", + "locale", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", @@ -245,7 +248,7 @@ async def apply_settings_update(body: AppSettings, protect_fields: set[str] | No secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", - "openswarm_bearer_token", "free_trial_token", "installation_id"} + "openswarm_bearer_token", "free_trial_token", "installation_id", "analytics_token"} safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys} _sync(safe) @@ -263,6 +266,9 @@ async def apply_settings_update(body: AppSettings, protect_fields: set[str] | No id_props["referral_source"] = body.user_referral_source if id_props: _identify(id_props) + if body.user_email: + from backend.apps.service.analytics.client import track_link_email + track_link_email(body.user_email) await save_settings_async(body) diff --git a/backend/requirements.txt b/backend/requirements.txt index 61b0595c..a9b7fea0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,6 +16,8 @@ python-dotenv==1.1.1 Pillow==12.2.0 httpx==0.28.1 trafilatura==2.0.0 +# swarm-analytics: typed client for the product-analytics ingest; fire-and-forget so it never breaks the app. +swarm-analytics==0.1.1 # tzlocal: dev-mode fallback for resolving the user's IANA timezone when # Electron's OPENSWARM_TIMEZONE env var isn't set (i.e. `bash run.sh`). # Packaged builds get the env var directly so this is a safety net. diff --git a/backend/tests/test_app_edit_bind.py b/backend/tests/test_app_edit_bind.py new file mode 100644 index 00000000..be6a2420 --- /dev/null +++ b/backend/tests/test_app_edit_bind.py @@ -0,0 +1,58 @@ +"""Editing an existing App must bind to its workspace, never seed a dupe. + +`app_workspace_dir` is the resolver launch_agent uses to turn a selected App +(Output) id into the cwd it should edit in place. If it returns a real path, +launch sets target_directory and the view-builder seed is skipped; if it +returns None the seed fires and a duplicate "Untitled App" is born (the bug +this locks out). Path constants are module-level, so (like test_seed_no_clobber) +we monkeypatch a temp tree. +""" +import json +import os + +import pytest + +from backend.apps.outputs import workspace_io as wio +from backend.apps.outputs.models import Output + + +@pytest.fixture +def out_root(tmp_path, monkeypatch): + data = tmp_path / "outputs" + ws = tmp_path / "outputs_workspace" + data.mkdir() + ws.mkdir() + monkeypatch.setattr(wio, "DATA_DIR", str(data)) + monkeypatch.setattr(wio, "OUTPUTS_WORKSPACE_DIR", str(ws)) + return data, ws + + +def _write_output(data_dir, **kw): + o = Output(**kw) + with open(os.path.join(str(data_dir), f"{o.id}.json"), "w") as f: + json.dump(o.model_dump(), f) + return o + + +def test_resolves_existing_app_workspace(out_root): + data, ws = out_root + os.makedirs(os.path.join(str(ws), "ws-app")) + o = _write_output(data, name="Voxelcraft", workspace_id="ws-app") + assert wio.app_workspace_dir(o.id) == os.path.abspath(os.path.join(str(ws), "ws-app")) + + +def test_missing_output_returns_none(out_root): + # Deleted/bogus selection -> no bind -> launch falls through to a normal new build. + assert wio.app_workspace_dir("doesnotexist") is None + + +def test_output_without_workspace_returns_none(out_root): + data, _ = out_root + o = _write_output(data, name="NoWorkspace", workspace_id=None) + assert wio.app_workspace_dir(o.id) is None + + +def test_output_with_vanished_folder_returns_none(out_root): + data, _ = out_root + o = _write_output(data, name="Gone", workspace_id="ws-vanished") # folder never created + assert wio.app_workspace_dir(o.id) is None diff --git a/backend/tests/test_seed_no_clobber.py b/backend/tests/test_seed_no_clobber.py new file mode 100644 index 00000000..3b62946a --- /dev/null +++ b/backend/tests/test_seed_no_clobber.py @@ -0,0 +1,77 @@ +"""Seed must CREATE, never overwrite. Reopening an app re-POSTs the inline +output.files snapshot, which lags behind whatever the agent last wrote to the +workspace on disk; seeding it back used to revert every edited file while the +agent's new files survived (edits looked half-reverted on the next export). + +Path constants are module-level, so (like test_versions) we monkeypatch them +into a temp tree. seed_workspace is async; we drive it with asyncio.run from a +sync test so the suite's bare-async-skip doesn't quietly no-op these.""" +import asyncio +import os + +import pytest + +from backend.apps.outputs import outputs as outputs_mod +from backend.apps.outputs.models import WorkspaceSeedRequest + + +@pytest.fixture +def ws_root(tmp_path, monkeypatch): + root = tmp_path / "ws" + root.mkdir() + monkeypatch.setattr(outputs_mod, "WORKSPACE_DIR", str(root)) + return root + + +def _seed(**kw): + return asyncio.run(outputs_mod.seed_workspace(WorkspaceSeedRequest(**kw))) + + +def _read(folder, rel): + with open(os.path.join(folder, rel), encoding="utf-8") as f: + return f.read() + + +def test_reopen_seed_preserves_agent_edits(ws_root): + wsid = "ws-reopen" + folder = os.path.join(str(ws_root), wsid) + os.makedirs(os.path.join(folder, "frontend", "src")) + # v1 on disk, captured into the inline snapshot the editor later autosaves. + with open(os.path.join(folder, "frontend", "src", "App.tsx"), "w") as f: + f.write("

v1

") + snapshot = {"frontend/src/App.tsx": "

v1

"} + + # Agent advances the workspace to v2 on disk: edits a file, adds a new one. + with open(os.path.join(folder, "frontend", "src", "App.tsx"), "w") as f: + f.write("

v2 agent

") + with open(os.path.join(folder, "frontend", "src", "New.tsx"), "w") as f: + f.write("// new v2 file") + + # Reopen replays the stale snapshot through seed. + _seed(workspace_id=wsid, files=snapshot, meta={"name": "App"}) + + assert _read(folder, "frontend/src/App.tsx") == "

v2 agent

" # not reverted + assert os.path.exists(os.path.join(folder, "frontend", "src", "New.tsx")) # survived + + +def test_fresh_seed_materializes_saved_files(ws_root): + wsid = "ws-fresh" + folder = os.path.join(str(ws_root), wsid) + _seed(workspace_id=wsid, + files={"index.html": "saved", "style.css": "body{}"}, + meta={"name": "Flat"}) + assert _read(folder, "index.html") == "saved" + assert _read(folder, "style.css") == "body{}" + + +def test_seed_fills_only_missing_files(ws_root): + wsid = "ws-partial" + folder = os.path.join(str(ws_root), wsid) + os.makedirs(folder) + with open(os.path.join(folder, "keep.txt"), "w") as f: + f.write("on-disk wins") + # snapshot wants to change keep.txt AND add gone.txt; only the missing one lands. + _seed(workspace_id=wsid, + files={"keep.txt": "snapshot loses", "gone.txt": "recreated"}) + assert _read(folder, "keep.txt") == "on-disk wins" + assert _read(folder, "gone.txt") == "recreated" diff --git a/backend/tests/test_ssrf_guard.py b/backend/tests/test_ssrf_guard.py new file mode 100644 index 00000000..b8490afd --- /dev/null +++ b/backend/tests/test_ssrf_guard.py @@ -0,0 +1,49 @@ +"""Regression tests for the SSRF guard. + +Covers the plain ranges and the v4-in-v6 smuggling bypass: a private/metadata +v4 target hidden inside a v6 address (v4-mapped ::ffff:, 6to4 2002::) used to +slip past the v6-only blocklist. Surfaced while running the OPENSAGE +comprehension-gap probe against this module. +""" + +import pytest + +from apps.agents.tools.ssrf_guard import SSRFBlocked, _is_forbidden_ip, assert_safe_url + + +@pytest.mark.parametrize( + "ip_str, forbidden", + [ + # plain ranges still behave + ("10.0.0.1", True), + ("169.254.169.254", True), # cloud metadata + ("8.8.8.8", False), # public + ("127.0.0.1", False), # loopback intentionally allowed + ("::1", False), # v6 loopback allowed + ("2606:4700::1", False), # public v6 + ("fe80::1", True), # v6 link-local + ("not-an-ip", True), # unparseable -> block + # the bypass: a v4 target smuggled inside a v6 address + ("::ffff:10.0.0.1", True), # v4-mapped private + ("::ffff:169.254.169.254", True), # v4-mapped cloud metadata + ("2002:0a00:0001::1", True), # 6to4 of 10.0.0.1 + ("::ffff:127.0.0.1", False), # v4-mapped loopback stays allowed + ("::ffff:8.8.8.8", False), # v4-mapped public stays allowed + ], +) +def test_is_forbidden_ip(ip_str, forbidden): + assert _is_forbidden_ip(ip_str) is forbidden + + +@pytest.mark.asyncio +async def test_assert_safe_url_blocks_v4_mapped_metadata(): + # IP-literal host short-circuits before DNS, so this needs no network. + with pytest.raises(SSRFBlocked): + await assert_safe_url("http://[::ffff:169.254.169.254]/latest/meta-data/") + + +@pytest.mark.asyncio +async def test_assert_safe_url_allows_v4_mapped_loopback(): + # App Builder previews on loopback must keep working, even v4-mapped. + url = "http://[::ffff:127.0.0.1]:8731/" + assert await assert_safe_url(url) == url diff --git a/electron/main.js b/electron/main.js index 9b8405e2..c078640d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -926,6 +926,8 @@ async function startBackend() { // app_version="unknown". The path-based fallback stays in place so this // change is purely additive. OPENSWARM_APP_VERSION: app.getVersion(), + // Packaged builds route analytics through the cloud edge; dev leaves it unset so the backend hits the local ingest. + ...(isPackaged ? { OPENSWARM_ANALYTICS_URL: 'https://api.openswarm.com' } : {}), // Inject the user's BCP 47 locale + IANA timezone. The Python backend // doesn't have reliable APIs for either: locale.getdefaultlocale() is // deprecated and inconsistent across OSes, and Python's local-tz string diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 7e4cb477..64963801 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -78,7 +78,7 @@ if (typeof window !== 'undefined') { if (ric) ric(prefetchAll, { timeout: 1500 }); else window.setTimeout(prefetchAll, 500); } -import { report, getSessionTraceState, getRecentActions } from '@/shared/serviceClient'; +import { report, reportAppOpened, getSessionTraceState, getRecentActions } from '@/shared/serviceClient'; import { useRouteTracker } from '@/shared/hooks/useRouteTracker'; import { useDeepLink } from '@/shared/hooks/useDeepLink'; import { useWindowFocus } from '@/shared/hooks/useWindowFocus'; @@ -223,6 +223,10 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = useEffect(() => { dispatch(fetchSettings()); dispatch(fetchModels()); + // Report the app launch with the browser's canonical tz/locale so the backend + // can emit analytics app_lifecycle.opened with values that work in packaged, + // dev, and open-source builds. Guarded once per page load; backend dedupes per process. + reportAppOpened(); // Connected subscriptions live in their own slice; without this the dashboard // (and the onboarding gate) think no model is connected until the user opens // Settings > Models, so a fresh launch shows a false "connect a model" empty diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 9c1768c8..035a85a2 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -407,6 +407,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // session lands dashboard_id=null, drops out of the reconcile filter, and its card vanishes // the instant you send (looked like "the chat quit when I clicked an option"). if (session?.dashboard_id) config.dashboard_id = session.dashboard_id; + // Editing an existing app: bind the launch to it so the backend edits in + // place instead of seeding a duplicate empty app (App Builder mode only). + if (msg.selectedAppIds?.length) config.selected_app_output_ids = msg.selectedAppIds; dispatch( launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds, selectedSettingIds: msg.selectedSettingIds }) ).then((action) => { @@ -478,6 +481,18 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]); + // A reload remounts past the live running->stopped transition that first shows + // the resume button, so re-derive it once from the persisted 'stopped' status + // (transcript-gated so a cleared chat can't resurrect it). + const resumeHydratedRef = useRef(false); + useEffect(() => { + if (resumeHydratedRef.current) return; + if (session?.status === 'stopped' && (session?.messages?.length ?? 0) > 0) { + resumeHydratedRef.current = true; + setShowResumeBubble(true); + } + }, [session?.status, session?.messages?.length]); + // Idle reconcile: if the session has been 'running' for 5s with no // WebSocket activity (no new messages, no streaming updates), do a // single GET to fetch the real status from the backend. Catches the diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index bedf49b5..ecf1cfc8 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -49,6 +49,7 @@ interface Props { forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => void; onAddView: (outputId: string) => void; onHistoryResume: (sessionId: string) => void; @@ -213,8 +214,9 @@ const DashboardToolbar = React.forwardRef( forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => { - onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds); + onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds); }, [onSend, mode, model], ); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 918ccc09..1cba1dc2 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -127,6 +127,7 @@ export function useAgentSpawn({ forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => { setToolbarOpen(false); report('dashboard', 'agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length }); @@ -148,6 +149,9 @@ export function useAgentSpawn({ } const config: AgentConfig = { name: 'New chat', model, mode, dashboard_id: dashboardId }; + // Editing an existing app: bind the launch to it so the backend edits in + // place instead of seeding a duplicate empty app (App Builder mode only). + if (selectedAppIds?.length) config.selected_app_output_ids = selectedAppIds; dispatch( launchAndSendFirstMessage({ @@ -161,6 +165,7 @@ export function useAgentSpawn({ forcedTools, attachedSkills, selectedBrowserIds, + selectedAppIds, expand: expandNewChats, }), ).then((action) => { diff --git a/frontend/src/shared/serviceClient.ts b/frontend/src/shared/serviceClient.ts index fee2b254..780e3830 100644 --- a/frontend/src/shared/serviceClient.ts +++ b/frontend/src/shared/serviceClient.ts @@ -87,6 +87,31 @@ export function report( sync({ s: surface, a: action, p: props || {} }, opts); } +let _openedSent = false; + +/** + * Report the app launch with the browser's canonical timezone + locale (the + * Intl API gives the same values Electron does, but works in dev and the + * open-source build too, where Electron's env injection never runs). The backend + * persists these and emits analytics `app_lifecycle.opened` from them. + * + * Guarded so a remount won't re-send within one page load; the backend also + * dedupes per process, so a hard reload can't double-count an app launch. + */ +export function reportAppOpened(): void { + if (_openedSent) return; + _openedSent = true; + let timezone = ''; + let locale = ''; + try { + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch { /* leave empty; backend resolver/fallback handles it */ } + try { + locale = (typeof navigator !== 'undefined' && navigator.language) || ''; + } catch { /* leave empty */ } + report('app', 'opened', { timezone, locale }, { immediate: true }); +} + export function getSessionTraceState(): { appStartTs: number; lastTs: number; @@ -99,5 +124,5 @@ export function getSessionTraceState(): { }; } -const serviceClient = { sync, report, getSessionTraceState, getRecentActions }; +const serviceClient = { sync, report, reportAppOpened, getSessionTraceState, getRecentActions }; export default serviceClient; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 0592abe5..25631095 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -125,6 +125,7 @@ export interface AgentConfig { max_turns?: number; target_directory?: string; dashboard_id?: string; + selected_app_output_ids?: string[]; } export interface HistorySession { diff --git a/linter/config/config.json b/linter/config/config.json index 2f4d0f21..187a76a7 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -137,6 +137,7 @@ "backend/apps/agents/core", "backend/apps/outputs", "backend/apps/tools_lib", + "backend/apps/service", "backend/tests", "frontend/src/shared", "frontend/src/shared/state",