mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[eric] swarm: dashboard export/import (closure of agents+apps+modes, two-class deps)
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user