mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface AgentConfig {
|
||||
max_turns?: number;
|
||||
target_directory?: string;
|
||||
dashboard_id?: string;
|
||||
selected_app_output_ids?: string[];
|
||||
}
|
||||
|
||||
export interface HistorySession {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user