Merge remote-tracking branch 'origin/eric/dev' into aidan/bug/app-export-max-size

# Conflicts:
#	backend/apps/outputs/outputs.py
This commit is contained in:
abccodes
2026-06-24 03:57:21 -07:00
29 changed files with 1060 additions and 19 deletions
+26
View File
@@ -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(
+3
View File
@@ -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)
+8
View File
@@ -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:
+5
View File
@@ -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:
+6
View File
@@ -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)
# ---------------------------------------------------------------------------
+16
View File
@@ -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
+18 -12
View File
@@ -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"}
+13 -1
View File
@@ -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
@@ -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).
@@ -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),
)
+237
View File
@@ -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)
@@ -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)
+41
View File
@@ -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
+33 -2
View File
@@ -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}
+5
View File
@@ -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
+7 -1
View File
@@ -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)
+2
View File
@@ -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.
+58
View File
@@ -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
+77
View File
@@ -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("<h1>v1</h1>")
snapshot = {"frontend/src/App.tsx": "<h1>v1</h1>"}
# 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("<h1>v2 agent</h1>")
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") == "<h1>v2 agent</h1>" # 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": "<html>saved</html>", "style.css": "body{}"},
meta={"name": "Flat"})
assert _read(folder, "index.html") == "<html>saved</html>"
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"
+49
View File
@@ -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
+2
View File
@@ -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
+5 -1
View File
@@ -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
@@ -407,6 +407,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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
@@ -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<HTMLDivElement, Props>(
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],
);
@@ -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) => {
+26 -1
View File
@@ -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;
+1
View File
@@ -125,6 +125,7 @@ export interface AgentConfig {
max_turns?: number;
target_directory?: string;
dashboard_id?: string;
selected_app_output_ids?: string[];
}
export interface HistorySession {
+1
View File
@@ -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",