[eric] swarm: dashboard export/import (closure of agents+apps+modes, two-class deps)

This commit is contained in:
ciregenz
2026-06-14 06:44:16 -07:00
parent 9b80d0417a
commit 6899ea5bd1
5 changed files with 380 additions and 0 deletions
+138
View File
@@ -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)
+79
View File
@@ -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
+95
View File
@@ -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
+6
View File
@@ -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.
+62
View File
@@ -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: