diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py new file mode 100644 index 00000000..eebb2507 --- /dev/null +++ b/backend/apps/swarm/entities/dashboards.py @@ -0,0 +1,138 @@ +"""DashboardExportable: the bundling showcase. A dashboard's agent cards and app +cards are pulled into the closure as sessions + apps (each session pulls its +custom mode); the layout's entity-keyed dicts are rewritten local->bundle on +export and bundle->fresh-local on import via the RemapTable. Mirrors the in-app +duplicate_dashboard remap. Browser cards keep their url/tabs but get fresh ids; +after writing the dashboard we re-point each imported session at it.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement + + +class DashboardExportable: + type = EntityType.dashboard + + def __init__(self, did: str, name: str, data: dict): + self.local_id = did + self.name = name + self._data = data + + @classmethod + def load(cls, local_id: str) -> "DashboardExportable | None": + data = _read(local_id) + if data is None: + return None + return cls(local_id, data.get("name") or "Dashboard", data) + + def serialize(self, ctx: ExportContext) -> dict: + layout = dict(self._data.get("layout") or {}) + cards = {} + for sid, card in (layout.get("cards") or {}).items(): + bid = ctx.bundle_id_for(EntityType.session, sid) + if bid: + cards[bid] = {**card, "session_id": bid} + view_cards = {} + for oid, card in (layout.get("view_cards") or {}).items(): + bid = ctx.bundle_id_for(EntityType.app, oid) + if bid: + view_cards[bid] = {**card, "output_id": bid} + browser_cards = {} + for bkey, card in (layout.get("browser_cards") or {}).items(): + c = dict(card) + spawn = c.get("spawned_by") + c["spawned_by"] = ctx.bundle_id_for(EntityType.session, spawn) if spawn else None + browser_cards[bkey] = c + expanded = [b for b in (ctx.bundle_id_for(EntityType.session, s) for s in (layout.get("expanded_session_ids") or [])) if b] + return {"name": self._data.get("name") or "Dashboard", "layout": { + **layout, "cards": cards, "view_cards": view_cards, + "browser_cards": browser_cards, "notes": layout.get("notes") or {}, + "expanded_session_ids": expanded, + }} + + def files(self) -> dict[str, bytes]: + return {} + + def dependencies(self) -> list[DepRef]: + layout = self._data.get("layout") or {} + deps = [DepRef(EntityType.session, sid, "has_agent") for sid in (layout.get("cards") or {})] + deps += [DepRef(EntityType.app, oid, "has_app") for oid in (layout.get("view_cards") or {})] + return deps + + def requirements(self) -> list[Requirement]: + return [] + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + new_did = uuid4().hex + layout = dict(payload.get("layout") or {}) + cards = {} + for bid, card in (layout.get("cards") or {}).items(): + nsid = remap.local(bid) + if nsid: + cards[nsid] = {**card, "session_id": nsid} + view_cards = {} + for bid, card in (layout.get("view_cards") or {}).items(): + noid = remap.local(bid) + if noid: + view_cards[noid] = {**card, "output_id": noid} + browser_cards = {} + for _bkey, card in (layout.get("browser_cards") or {}).items(): + nbid = "browser-" + uuid4().hex[:10] + c = dict(card) + c["browser_id"] = nbid + spawn = c.get("spawned_by") + c["spawned_by"] = remap.local(spawn) if spawn else None + browser_cards[nbid] = c + expanded = [e for e in (remap.local(b) for b in (layout.get("expanded_session_ids") or [])) if e] + now = datetime.now(timezone.utc).isoformat() + doc = { + "id": new_did, + "name": payload.get("name") or "Imported Dashboard", + "auto_named": False, + "created_at": now, + "updated_at": now, + "layout": { + **layout, "cards": cards, "view_cards": view_cards, + "browser_cards": browser_cards, "notes": layout.get("notes") or {}, + "expanded_session_ids": expanded, + }, + } + _write(new_did, doc) + _retag_sessions(cards.keys(), new_did) + return new_did + + +def _dash_dir() -> str | None: + try: + from backend.config.paths import DASHBOARDS_DIR + return DASHBOARDS_DIR + except Exception: + return None + + +def _read(did: str) -> dict | None: + import os + from backend.config.json_store import read_json_or_none + d = _dash_dir() + return read_json_or_none(os.path.join(d, f"{did}.json")) if d else None + + +def _write(did: str, doc: dict) -> None: + import os + from backend.config.json_store import atomic_write_json + d = _dash_dir() + if d: + atomic_write_json(os.path.join(d, f"{did}.json"), doc) + + +def _retag_sessions(session_ids, dashboard_id: str) -> None: + from backend.apps.agents.manager.session.session_store import _load_session_data, _save_session + for sid in session_ids: + d = _load_session_data(sid) + if d is not None: + d["dashboard_id"] = dashboard_id + _save_session(sid, d) diff --git a/backend/apps/swarm/entities/modes.py b/backend/apps/swarm/entities/modes.py new file mode 100644 index 00000000..015d3eb9 --- /dev/null +++ b/backend/apps/swarm/entities/modes.py @@ -0,0 +1,79 @@ +"""ModeExportable: a user-created mode (system prompt + allowed tools). Pulled in +as a dependency when a shared dashboard's agent runs in a custom mode. Built-in +modes (agent/ask/plan/...) ship with every install, so they're never bundled, +they surface as requirements instead. Modes are referenced by slug, so import +reuses an existing same-slug mode rather than clobbering it (keeps the session's +`mode` pointer valid without rewriting it).""" +from __future__ import annotations + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement + +# Machine-relative or install-owned fields that must not ride along. +_DROP = {"is_builtin", "default_folder"} + + +class ModeExportable: + type = EntityType.mode + + def __init__(self, mode_id: str, name: str, data: dict): + self.local_id = mode_id + self.name = name + self._data = data + + @classmethod + def load(cls, local_id: str) -> "ModeExportable | None": + store = _store() + if store is None: + return None + m = store.load_mode(local_id) + if m is None: + return None + d = m.model_dump() + return cls(local_id, d.get("name") or local_id, d) + + def serialize(self, ctx: ExportContext) -> dict: + return {k: v for k, v in self._data.items() if k not in _DROP} + + def files(self) -> dict[str, bytes]: + return {} + + def dependencies(self) -> list[DepRef]: + return [] + + def requirements(self) -> list[Requirement]: + return [] + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + store = _store() + model = _model() + if store is None or model is None: + from ..ziputil import BundleError + raise BundleError("can't import this mode on this build") + mid = payload.get("id") or (payload.get("name") or "mode").lower().replace(" ", "-") + # Reuse a same-slug mode (incl. built-ins) instead of overwriting it; + # sessions point at modes by this slug. + if store.load_mode(mid) is not None: + return mid + data = {k: v for k, v in payload.items() if k != "is_builtin"} + data["id"] = mid + data["is_builtin"] = False + store._save(model(**data)) + return mid + + +def _store(): + try: + from backend.apps.modes import modes + return modes + except Exception: + return None + + +def _model(): + try: + from backend.apps.modes.models import Mode + return Mode + except Exception: + return None diff --git a/backend/apps/swarm/entities/sessions.py b/backend/apps/swarm/entities/sessions.py new file mode 100644 index 00000000..a028b77d --- /dev/null +++ b/backend/apps/swarm/entities/sessions.py @@ -0,0 +1,95 @@ +"""SessionExportable: an agent card on a shared dashboard. We carry only the +recipe (name, model, mode, system prompt, allowed tools) and deliberately DROP +the chat transcript (privacy + size), runtime state, costs, the worktree path, +and active_mcps (importing must never silently grant tool access, per the gate). +Its MCP/actions, provider, and built-in mode become import requirements so the +importer is walked through enabling them. The dashboard re-points dashboard_id +after import.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement, RequirementKind + +_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} +_KEEP = ("name", "provider", "model", "mode", "system_prompt", "allowed_tools", "max_turns", "thinking_level") + + +class SessionExportable: + type = EntityType.session + + def __init__(self, sid: str, name: str, data: dict): + self.local_id = sid + self.name = name + self._data = data + + @classmethod + def load(cls, local_id: str) -> "SessionExportable | None": + from backend.apps.agents.manager.session.session_store import _load_session_data + d = _load_session_data(local_id) + if d is None: + return None + return cls(local_id, d.get("name") or "Agent", d) + + def serialize(self, ctx: ExportContext) -> dict: + return {k: self._data.get(k) for k in _KEEP if k in self._data} + + def files(self) -> dict[str, bytes]: + return {} + + def dependencies(self) -> list[DepRef]: + mode = self._data.get("mode") + if mode and mode not in _BUILTIN_MODES: + return [DepRef(EntityType.mode, mode, "uses_mode")] + return [] + + def requirements(self) -> list[Requirement]: + reqs: list[Requirement] = [] + for mcp in self._data.get("active_mcps") or []: + reqs.append(Requirement( + kind=RequirementKind.mcp_action, key=mcp, label=mcp, + detail="An agent here uses this action.", + )) + mode = self._data.get("mode") or "agent" + if mode in _BUILTIN_MODES and mode != "agent": + reqs.append(Requirement( + kind=RequirementKind.builtin_mode, key=mode, label=f"{mode} mode", + detail="A built-in mode an agent runs in.", + )) + provider = self._data.get("provider") or "anthropic" + reqs.append(Requirement( + kind=RequirementKind.api_key, key=provider, label=f"A {provider} model", + detail="Set up this provider so the agents can run.", + )) + return reqs + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + from backend.apps.agents.manager.session.session_store import _save_session + sid = uuid4().hex + now = datetime.now(timezone.utc).isoformat() + doc = { + "id": sid, + "name": payload.get("name") or "Agent", + "status": "completed", + "provider": payload.get("provider") or "anthropic", + "model": payload.get("model") or "sonnet", + "mode": payload.get("mode") or "agent", + "system_prompt": payload.get("system_prompt"), + "allowed_tools": payload.get("allowed_tools") or [], + "max_turns": payload.get("max_turns"), + "thinking_level": payload.get("thinking_level") or "auto", + "messages": [], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now}}, + "active_branch_id": "main", + "active_mcps": [], + "dashboard_id": None, # the dashboard import re-points this + "browser_id": None, + "parent_session_id": None, + "created_at": now, + "closed_at": now, + } + _save_session(sid, doc) + return sid diff --git a/backend/apps/swarm/registry.py b/backend/apps/swarm/registry.py index e771850f..73554543 100644 --- a/backend/apps/swarm/registry.py +++ b/backend/apps/swarm/registry.py @@ -1,6 +1,9 @@ """Maps an EntityType to the Exportable that handles it, and the leaves-first order import walks. Adding a shareable type is one entry here plus its module.""" from .entities.apps import AppExportable +from .entities.dashboards import DashboardExportable +from .entities.modes import ModeExportable +from .entities.sessions import SessionExportable from .entities.skills import SkillExportable from .entities.workflows import WorkflowExportable from .models import EntityType @@ -9,6 +12,9 @@ REGISTRY: dict[EntityType, type] = { EntityType.skill: SkillExportable, EntityType.app: AppExportable, EntityType.workflow: WorkflowExportable, + EntityType.mode: ModeExportable, + EntityType.session: SessionExportable, + EntityType.dashboard: DashboardExportable, } # Leaves first: a dependency must import before whatever references it. diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index d0a423ed..fc6bb7a7 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -155,6 +155,68 @@ def test_workflow_unavailable_on_this_branch(): WorkflowExportable.import_({"title": "x"}, {}, RemapTable()) +def test_session_export_strips_transcript_and_secrets(): + from backend.apps.swarm.entities.sessions import SessionExportable + data = { + "name": "A", "provider": "anthropic", "model": "sonnet", "mode": "agent", + "system_prompt": "hi", "allowed_tools": ["Read"], + "messages": [{"role": "user", "content": "private chat"}], + "active_mcps": ["Gmail"], "cwd": "/Users/me/repo", "cost_usd": 9.9, "sdk_session_id": "x", + } + ex = SessionExportable("s1", "A", data) + out = ex.serialize(None) + for gone in ("messages", "cwd", "active_mcps", "cost_usd", "sdk_session_id"): + assert gone not in out + assert out["model"] == "sonnet" and out["mode"] == "agent" + reqs = ex.requirements() + assert any(r.kind.value == "mcp_action" and r.key == "Gmail" for r in reqs) + + +def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): + from backend.apps.swarm.entities.dashboards import DashboardExportable + from backend.apps.swarm.models import EntityType + + class Ctx: + def bundle_id_for(self, t: EntityType, lid: str): + return {("session", "S"): "SBID", ("app", "A"): "ABID"}.get((t.value, lid)) + + data = {"name": "D", "layout": { + "cards": {"S": {"session_id": "S", "x": 1}}, + "view_cards": {"A": {"output_id": "A", "x": 2}}, + "browser_cards": {"b1": {"browser_id": "b1", "url": "u", "spawned_by": "S"}}, + "expanded_session_ids": ["S"], + }} + L = DashboardExportable("d1", "D", data).serialize(Ctx())["layout"] + assert L["cards"]["SBID"]["session_id"] == "SBID" + assert L["view_cards"]["ABID"]["output_id"] == "ABID" + assert L["browser_cards"]["b1"]["spawned_by"] == "SBID" + assert L["expanded_session_ids"] == ["SBID"] + + +def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch): + from backend.apps.swarm.entities import dashboards as dmod + from backend.apps.swarm.exportable import RemapTable + + written: dict = {} + monkeypatch.setattr(dmod, "_write", lambda did, doc: written.update({did: doc})) + monkeypatch.setattr(dmod, "_retag_sessions", lambda ids, did: None) + remap = RemapTable() + remap.assign("SBID", "newsess") + remap.assign("ABID", "newapp") + payload = {"name": "D", "layout": { + "cards": {"SBID": {"session_id": "SBID"}}, + "view_cards": {"ABID": {"output_id": "ABID"}}, + "browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID"}}, + "expanded_session_ids": ["SBID", "ORPHAN"], + }} + did = dmod.DashboardExportable.import_(payload, {}, remap) + L = written[did]["layout"] + assert L["cards"]["newsess"]["session_id"] == "newsess" + assert "newapp" in L["view_cards"] + assert list(L["browser_cards"].values())[0]["spawned_by"] == "newsess" + assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped + + def _zip_with(name, data=b"x"): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: