Files
openswarm/backend/apps/swarm/entities/dashboards.py
T

169 lines
6.9 KiB
Python

"""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 backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable
from backend.apps.swarm.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.p_data = data
@classmethod
def load(cls, local_id: str) -> "DashboardExportable | None":
data = p_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.p_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 key, card in (layout.get("view_cards") or {}).items():
# Keys are output_id for the primary card, `output_id#N` for extra instances of the same app; resolve the bundle id off the bare output id and rebuild the suffix.
oid = str(card.get("output_id") or key).split("#")[0]
bid = ctx.bundle_id_for(EntityType.app, oid)
if bid:
inst = int(card.get("instance") or 1)
# parent_session_id tethers the app card to the agent that built it; it's a session id, so it remaps like spawned_by on browser cards.
parent = card.get("parent_session_id")
view_cards[bid if inst <= 1 else f"{bid}#{inst}"] = {
**card, "output_id": bid,
"parent_session_id": ctx.bundle_id_for(EntityType.session, parent) if parent else None,
}
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.p_data.get("name") or "Dashboard", "layout": {
**layout, "cards": cards, "view_cards": view_cards,
"browser_cards": browser_cards,
"expanded_session_ids": expanded,
}}
def files(self) -> dict[str, bytes]:
return {}
def dependencies(self) -> list[DepRef]:
layout = self.p_data.get("layout") or {}
deps = [DepRef(EntityType.session, sid, "has_agent") for sid in (layout.get("cards") or {})]
p_view_oids = {str(card.get("output_id") or key).split("#")[0] for key, card in (layout.get("view_cards") or {}).items()}
deps += [DepRef(EntityType.app, oid, "has_app") for oid in sorted(p_view_oids)]
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 key, card in (layout.get("view_cards") or {}).items():
bid = str(card.get("output_id") or key).split("#")[0]
noid = remap.local(bid)
if noid:
inst = int(card.get("instance") or 1)
parent = card.get("parent_session_id")
view_cards[noid if inst <= 1 else f"{noid}#{inst}"] = {
**card, "output_id": noid,
"parent_session_id": remap.local(parent) if parent else None,
}
browser_cards = {}
for p_bkey, card in (layout.get("browser_cards") or {}).items():
nbid = "browser-" + uuid4().hex[:10]
c = dict(card)
c["browser_id"] = nbid
# Re-stamp the home dashboard, else the card keeps the source's id and the anti-bleed render guard (DashboardCardLayer keepAliveHidden) hides it on the imported dashboard.
c["dashboard_id"] = new_did
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,
"expanded_session_ids": expanded,
},
}
p_write(new_did, doc)
p_retag_sessions(cards.keys(), new_did)
return new_did
@classmethod
def rollback(cls, local_id: str) -> None:
import os
d = p_dash_dir()
if d:
p = os.path.join(d, f"{local_id}.json")
if os.path.exists(p):
os.remove(p)
def p_dash_dir() -> str | None:
try:
from backend.config.paths import DASHBOARDS_DIR
return DASHBOARDS_DIR
except Exception:
return None
def p_read(did: str) -> dict | None:
import os
from backend.config.json_store import read_json_or_none
d = p_dash_dir()
return read_json_or_none(os.path.join(d, f"{did}.json")) if d else None
def p_write(did: str, doc: dict) -> None:
import os
from backend.config.json_store import atomic_write_json
d = p_dash_dir()
if d:
atomic_write_json(os.path.join(d, f"{did}.json"), doc)
def p_retag_sessions(session_ids, dashboard_id: str) -> None:
# Best-effort: a hiccup here must not orphan the just-written dashboard.
from backend.apps.agents.manager.session.session_store import load_session_data, save_session
for sid in session_ids:
try:
d = load_session_data(sid)
if d is not None:
d["dashboard_id"] = dashboard_id
save_session(sid, d)
except Exception:
pass