ltcai 1.7.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,400 @@
1
+ """Plugin SDK — manifest, registry, lifecycle, permissions, validation, and a
2
+ safe execution boundary.
3
+
4
+ The Plugin SDK is the v2.0.0 extension layer. It is intentionally additive:
5
+ a plugin is a directory under the configured ``plugins`` root that ships a
6
+ ``plugin.json`` manifest and *extends* the existing Skill / Tool / Workflow
7
+ surfaces rather than replacing them. Installed standalone skills keep working
8
+ untouched; a plugin can simply *bundle* skills (and declare tools / workflow
9
+ templates) under one versioned, permissioned unit.
10
+
11
+ Design rules (mirrors :mod:`latticeai.core.tool_registry` and the workspace
12
+ skill registry):
13
+
14
+ * **No import-time I/O.** The registry only touches the filesystem when asked
15
+ to ``discover``.
16
+ * **No FastAPI, no globals.** Lifecycle state lives in the Workspace OS store
17
+ (passed in), so personal/organization scoping and the local-first JSON store
18
+ are reused, not duplicated.
19
+ * **Permissions are an allow-list.** A manifest may only request permissions in
20
+ :data:`PLUGIN_PERMISSIONS`; the execution boundary refuses anything a plugin
21
+ did not declare *and* was not granted.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any, Callable, Dict, List, Optional, Tuple
31
+
32
+
33
+ PLUGIN_SDK_VERSION = "2.0.0"
34
+
35
+ # Capability-style permissions a plugin can request. Kept deliberately small so
36
+ # the Enterprise seam can layer finer-grained policy on top without changing the
37
+ # community contract. Every permission maps to a concrete thing the execution
38
+ # boundary will or will not allow.
39
+ PLUGIN_PERMISSIONS = (
40
+ "read_workspace",
41
+ "write_workspace",
42
+ "read_graph",
43
+ "write_graph",
44
+ "run_tools",
45
+ "run_skills",
46
+ "run_workflows",
47
+ "run_agents",
48
+ "network",
49
+ "manage_memory",
50
+ )
51
+
52
+ # What a plugin may contribute to the platform. These extend existing systems.
53
+ PLUGIN_PROVIDES = ("skills", "tools", "workflows", "actions")
54
+
55
+ _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+([.-][0-9A-Za-z.]+)?$")
56
+ _ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
57
+
58
+
59
+ class PluginError(Exception):
60
+ """Raised for plugin validation / lifecycle / execution failures."""
61
+
62
+
63
+ def _version_tuple(version: str) -> Tuple[int, int, int]:
64
+ parts = re.split(r"[.-]", str(version or "0"))
65
+ nums: List[int] = []
66
+ for part in parts[:3]:
67
+ try:
68
+ nums.append(int(part))
69
+ except ValueError:
70
+ nums.append(0)
71
+ while len(nums) < 3:
72
+ nums.append(0)
73
+ return nums[0], nums[1], nums[2]
74
+
75
+
76
+ def is_compatible(required: str, current: str = PLUGIN_SDK_VERSION) -> bool:
77
+ """True if a plugin requiring ``required`` runs on ``current`` host version.
78
+
79
+ ``required`` is a minimum (major must match, host must be >= required).
80
+ Empty / missing requirement is treated as "any host".
81
+ """
82
+ required = str(required or "").strip().lstrip(">=").strip()
83
+ if not required:
84
+ return True
85
+ req = _version_tuple(required)
86
+ cur = _version_tuple(current)
87
+ if req[0] != cur[0]:
88
+ return False
89
+ return cur >= req
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class PluginManifest:
94
+ """A parsed, validated ``plugin.json``."""
95
+
96
+ id: str
97
+ name: str
98
+ version: str
99
+ description: str = ""
100
+ author: str = ""
101
+ lattice_version: str = ""
102
+ permissions: Tuple[str, ...] = ()
103
+ provides: Dict[str, List[str]] = field(default_factory=dict)
104
+ entrypoint: str = ""
105
+ homepage: str = ""
106
+ path: str = ""
107
+
108
+ def public(self) -> Dict[str, Any]:
109
+ return {
110
+ "id": self.id,
111
+ "name": self.name,
112
+ "version": self.version,
113
+ "description": self.description,
114
+ "author": self.author,
115
+ "lattice_version": self.lattice_version,
116
+ "permissions": list(self.permissions),
117
+ "provides": {key: list(value) for key, value in self.provides.items()},
118
+ "entrypoint": self.entrypoint,
119
+ "homepage": self.homepage,
120
+ "path": self.path,
121
+ "compatible": is_compatible(self.lattice_version),
122
+ }
123
+
124
+
125
+ def validate_manifest(data: Dict[str, Any], *, path: str = "") -> Tuple[Optional[PluginManifest], List[str]]:
126
+ """Validate a manifest dict. Returns ``(manifest_or_None, errors)``.
127
+
128
+ A manifest with errors still returns ``None`` so callers never accidentally
129
+ treat an invalid plugin as runnable.
130
+ """
131
+ errors: List[str] = []
132
+ if not isinstance(data, dict):
133
+ return None, ["manifest is not a JSON object"]
134
+
135
+ plugin_id = str(data.get("id") or "").strip()
136
+ if not plugin_id:
137
+ errors.append("missing required field: id")
138
+ elif not _ID_RE.match(plugin_id):
139
+ errors.append("id must be lowercase alphanumeric with - or _ (2-64 chars)")
140
+
141
+ name = str(data.get("name") or plugin_id or "").strip()
142
+ if not name:
143
+ errors.append("missing required field: name")
144
+
145
+ version = str(data.get("version") or "").strip()
146
+ if not version:
147
+ errors.append("missing required field: version")
148
+ elif not _SEMVER_RE.match(version):
149
+ errors.append(f"version '{version}' is not a valid semantic version")
150
+
151
+ raw_perms = data.get("permissions") or []
152
+ if not isinstance(raw_perms, list):
153
+ errors.append("permissions must be a list")
154
+ raw_perms = []
155
+ perms: List[str] = []
156
+ for perm in raw_perms:
157
+ if perm not in PLUGIN_PERMISSIONS:
158
+ errors.append(f"unknown permission: {perm}")
159
+ else:
160
+ perms.append(perm)
161
+
162
+ raw_provides = data.get("provides") or {}
163
+ provides: Dict[str, List[str]] = {}
164
+ if not isinstance(raw_provides, dict):
165
+ errors.append("provides must be an object")
166
+ else:
167
+ for key, value in raw_provides.items():
168
+ if key not in PLUGIN_PROVIDES:
169
+ errors.append(f"unknown provides key: {key}")
170
+ continue
171
+ if not isinstance(value, list):
172
+ errors.append(f"provides.{key} must be a list")
173
+ continue
174
+ provides[key] = [str(item) for item in value]
175
+
176
+ lattice_version = str(data.get("lattice_version") or "").strip()
177
+ if lattice_version and not is_compatible(lattice_version):
178
+ errors.append(
179
+ f"requires Lattice {lattice_version} but host is {PLUGIN_SDK_VERSION}"
180
+ )
181
+
182
+ if errors:
183
+ return None, errors
184
+
185
+ manifest = PluginManifest(
186
+ id=plugin_id,
187
+ name=name,
188
+ version=version,
189
+ description=str(data.get("description") or ""),
190
+ author=str(data.get("author") or ""),
191
+ lattice_version=lattice_version,
192
+ permissions=tuple(perms),
193
+ provides=provides,
194
+ entrypoint=str(data.get("entrypoint") or ""),
195
+ homepage=str(data.get("homepage") or ""),
196
+ path=path,
197
+ )
198
+ return manifest, []
199
+
200
+
201
+ @dataclass
202
+ class PluginExecutionResult:
203
+ plugin_id: str
204
+ action: str
205
+ status: str # "ok" | "blocked" | "error" | "skipped"
206
+ output: Any = None
207
+ reason: str = ""
208
+
209
+ def as_dict(self) -> Dict[str, Any]:
210
+ return {
211
+ "plugin_id": self.plugin_id,
212
+ "action": self.action,
213
+ "status": self.status,
214
+ "output": self.output,
215
+ "reason": self.reason,
216
+ }
217
+
218
+
219
+ class PluginRegistry:
220
+ """Discovery + validation + a permissioned execution boundary for plugins.
221
+
222
+ Lifecycle *state* (installed / enabled / validation status) is delegated to
223
+ the Workspace OS store via the small ``store`` port so plugins reuse the same
224
+ local-first JSON persistence, workspace scoping, and timeline events as
225
+ skills. The registry itself owns only manifest parsing and the execution
226
+ boundary.
227
+ """
228
+
229
+ def __init__(self, plugins_dir: Path | str, *, store: Any = None):
230
+ self.plugins_dir = Path(plugins_dir).expanduser()
231
+ self.store = store
232
+
233
+ # ── discovery / validation ───────────────────────────────────────────
234
+
235
+ def discover(self) -> Dict[str, Any]:
236
+ """Scan ``plugins_dir`` for ``<id>/plugin.json`` manifests."""
237
+ valid: List[PluginManifest] = []
238
+ invalid: List[Dict[str, Any]] = []
239
+ if self.plugins_dir.exists():
240
+ for entry in sorted(self.plugins_dir.iterdir()):
241
+ if not entry.is_dir():
242
+ continue
243
+ manifest_path = entry / "plugin.json"
244
+ if not manifest_path.exists():
245
+ continue
246
+ try:
247
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
248
+ except Exception as exc:
249
+ invalid.append({"path": str(entry), "errors": [f"invalid JSON: {exc}"]})
250
+ continue
251
+ manifest, errors = validate_manifest(data, path=str(entry))
252
+ if manifest is None:
253
+ invalid.append({"path": str(entry), "id": data.get("id"), "errors": errors})
254
+ else:
255
+ valid.append(manifest)
256
+ return {"valid": valid, "invalid": invalid}
257
+
258
+ def get_manifest(self, plugin_id: str) -> Optional[PluginManifest]:
259
+ for manifest in self.discover()["valid"]:
260
+ if manifest.id == plugin_id:
261
+ return manifest
262
+ return None
263
+
264
+ def catalog(self) -> Dict[str, Any]:
265
+ """Merge discovered manifests with persisted lifecycle state for the UI."""
266
+ discovered = self.discover()
267
+ registry_state = self.store.list_plugin_registry() if self.store else {}
268
+ plugins = []
269
+ for manifest in discovered["valid"]:
270
+ state = registry_state.get(manifest.id, {})
271
+ public = manifest.public()
272
+ public.update({
273
+ "installed": bool(state.get("installed")),
274
+ "enabled": bool(state.get("enabled", state.get("installed"))),
275
+ "install_status": state.get("install_status") or ("ready" if state.get("installed") else "available"),
276
+ "validation_status": "valid",
277
+ "updated_at": state.get("updated_at"),
278
+ })
279
+ plugins.append(public)
280
+ return {
281
+ "sdk_version": PLUGIN_SDK_VERSION,
282
+ "permissions": list(PLUGIN_PERMISSIONS),
283
+ "provides": list(PLUGIN_PROVIDES),
284
+ "plugins": plugins,
285
+ "invalid": discovered["invalid"],
286
+ "plugins_dir": str(self.plugins_dir),
287
+ "total": len(plugins),
288
+ }
289
+
290
+ # ── lifecycle ─────────────────────────────────────────────────────────
291
+
292
+ def install(self, plugin_id: str, *, register_skill: Optional[Callable[[str, str], Any]] = None) -> Dict[str, Any]:
293
+ """Install (activate) a discovered plugin and register the skills it bundles.
294
+
295
+ ``register_skill(skill_name, plugin_id)`` is injected so plugins *extend*
296
+ the existing skill registry instead of owning a parallel one.
297
+ """
298
+ manifest = self.get_manifest(plugin_id)
299
+ if manifest is None:
300
+ raise PluginError(f"plugin not found or invalid: {plugin_id}")
301
+ if not is_compatible(manifest.lattice_version):
302
+ raise PluginError(
303
+ f"plugin '{plugin_id}' requires Lattice {manifest.lattice_version}, host is {PLUGIN_SDK_VERSION}"
304
+ )
305
+ registered_skills = []
306
+ if register_skill is not None:
307
+ for skill_name in manifest.provides.get("skills", []):
308
+ try:
309
+ register_skill(skill_name, plugin_id)
310
+ registered_skills.append(skill_name)
311
+ except Exception: # pragma: no cover - skill registration is best-effort
312
+ pass
313
+ entry = {}
314
+ if self.store is not None:
315
+ entry = self.store.mark_plugin_installed(
316
+ plugin_id,
317
+ version=manifest.version,
318
+ metadata={
319
+ "permissions": list(manifest.permissions),
320
+ "provides": {k: list(v) for k, v in manifest.provides.items()},
321
+ "registered_skills": registered_skills,
322
+ },
323
+ )
324
+ return {"plugin": manifest.public(), "registry": entry, "registered_skills": registered_skills}
325
+
326
+ def uninstall(self, plugin_id: str) -> Dict[str, Any]:
327
+ if self.store is None:
328
+ return {"status": "ok", "plugin_id": plugin_id}
329
+ return self.store.mark_plugin_uninstalled(plugin_id)
330
+
331
+ def set_enabled(self, plugin_id: str, enabled: bool) -> Dict[str, Any]:
332
+ if self.store is None:
333
+ return {"plugin_id": plugin_id, "enabled": enabled}
334
+ return self.store.set_plugin_enabled(plugin_id, enabled)
335
+
336
+ # ── execution boundary ────────────────────────────────────────────────
337
+
338
+ def _granted_permissions(self, plugin_id: str) -> List[str]:
339
+ if self.store is None:
340
+ return []
341
+ state = self.store.list_plugin_registry().get(plugin_id, {})
342
+ return list((state.get("metadata") or {}).get("permissions") or [])
343
+
344
+ def execute_action(
345
+ self,
346
+ plugin_id: str,
347
+ action: str,
348
+ args: Optional[Dict[str, Any]] = None,
349
+ *,
350
+ runners: Optional[Dict[str, Callable[..., Any]]] = None,
351
+ ) -> PluginExecutionResult:
352
+ """Run a plugin-provided action through the permission boundary.
353
+
354
+ ``runners`` maps a capability ("tools" / "skills" / "workflows" /
355
+ "agents") to a callable the host injects. The boundary refuses any
356
+ capability the plugin did not *declare in its manifest*; without a
357
+ matching runner the action is reported ``skipped`` (never crashes the
358
+ caller). This keeps v2.0.0 plugins safe-by-default.
359
+ """
360
+ args = args or {}
361
+ runners = runners or {}
362
+ manifest = self.get_manifest(plugin_id)
363
+ if manifest is None:
364
+ return PluginExecutionResult(plugin_id, action, "error", reason="plugin not found or invalid")
365
+
366
+ registry_state = self.store.list_plugin_registry().get(plugin_id, {}) if self.store else {}
367
+ if self.store is not None and not registry_state.get("enabled", registry_state.get("installed")):
368
+ return PluginExecutionResult(plugin_id, action, "blocked", reason="plugin is not enabled")
369
+
370
+ # Map an action to the capability + permission it needs.
371
+ capability_for: Dict[str, Tuple[str, str]] = {
372
+ "run_tool": ("tools", "run_tools"),
373
+ "run_skill": ("skills", "run_skills"),
374
+ "run_workflow": ("workflows", "run_workflows"),
375
+ "run_agent": ("agents", "run_agents"),
376
+ }
377
+ capability, permission = capability_for.get(action, ("actions", ""))
378
+
379
+ if permission and permission not in manifest.permissions:
380
+ return PluginExecutionResult(
381
+ plugin_id, action, "blocked",
382
+ reason=f"plugin did not declare required permission '{permission}'",
383
+ )
384
+ if permission and self.store is not None and permission not in self._granted_permissions(plugin_id):
385
+ return PluginExecutionResult(
386
+ plugin_id, action, "blocked",
387
+ reason=f"permission '{permission}' not granted at install time",
388
+ )
389
+
390
+ runner = runners.get(capability)
391
+ if runner is None:
392
+ return PluginExecutionResult(
393
+ plugin_id, action, "skipped",
394
+ reason=f"no host runner for capability '{capability}'",
395
+ )
396
+ try:
397
+ output = runner(plugin_id=plugin_id, action=action, args=args, manifest=manifest)
398
+ return PluginExecutionResult(plugin_id, action, "ok", output=output)
399
+ except Exception as exc:
400
+ return PluginExecutionResult(plugin_id, action, "error", reason=str(exc))
@@ -0,0 +1,190 @@
1
+ """Realtime Collaboration — an in-process pub/sub bus, presence registry, and
2
+ activity feed delivered over Server-Sent Events.
3
+
4
+ SSE is chosen deliberately: the codebase already streams model output over SSE
5
+ (``latticeai.services.model_runtime.sse_event``), it needs no extra dependency,
6
+ and it works through the existing single-port local-first deployment. The bus is
7
+ in-process (one server, local-first) and fans events out to subscriber queues.
8
+
9
+ Integration shape: the Workspace OS store exposes a single ``event_sink`` hook
10
+ that fires on every ``record_timeline_event``. Wiring ``RealtimeBus.publish`` as
11
+ that sink makes *all* workspace / graph / agent / workflow activity flow into
12
+ the realtime feed automatically — no per-call instrumentation, no duplicated
13
+ event system.
14
+
15
+ Guarantees:
16
+
17
+ * **Single-user local mode keeps working.** Publishing with zero subscribers is
18
+ a no-op; the feed ring buffer is still maintained so a late subscriber can
19
+ catch up.
20
+ * **Workspace isolation preserved.** Every event carries ``workspace_id``; a
21
+ subscriber only receives events whose workspace is in its allowed scope set
22
+ (``None`` scope = personal/local view sees unscoped + personal events).
23
+ * **Backpressure-safe.** Per-subscriber queues are bounded; on overflow the
24
+ oldest event is dropped rather than blocking the publisher.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import json
31
+ from datetime import datetime
32
+ from typing import Any, AsyncIterator, Dict, List, Optional, Set
33
+
34
+
35
+ REALTIME_VERSION = "2.0.0"
36
+ _FEED_LIMIT = 200
37
+ _QUEUE_MAX = 100
38
+
39
+
40
+ def _now() -> str:
41
+ return datetime.now().isoformat(timespec="seconds")
42
+
43
+
44
+ def sse_format(event: Dict[str, Any]) -> str:
45
+ """Encode an event as an SSE ``data:`` frame."""
46
+ return f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
47
+
48
+
49
+ class _Subscriber:
50
+ __slots__ = ("id", "queue", "workspace_scope", "user", "joined_at")
51
+
52
+ def __init__(self, sub_id: str, workspace_scope: Optional[Set[str]], user: Optional[str]):
53
+ self.id = sub_id
54
+ self.queue: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue(maxsize=_QUEUE_MAX)
55
+ self.workspace_scope = workspace_scope
56
+ self.user = user
57
+ self.joined_at = _now()
58
+
59
+ def accepts(self, workspace_id: Optional[str]) -> bool:
60
+ # ``None`` scope = see everything the local user can (personal/unscoped).
61
+ if self.workspace_scope is None:
62
+ return True
63
+ if workspace_id is None:
64
+ return True
65
+ return workspace_id in self.workspace_scope
66
+
67
+
68
+ class RealtimeBus:
69
+ """In-process event bus with presence and a recent-activity ring buffer."""
70
+
71
+ def __init__(self) -> None:
72
+ self._subscribers: Dict[str, _Subscriber] = {}
73
+ self._feed: List[Dict[str, Any]] = []
74
+ self._presence: Dict[str, Dict[str, Any]] = {}
75
+ self._seq = 0
76
+
77
+ # ── publishing ────────────────────────────────────────────────────────
78
+
79
+ def publish(self, event: Dict[str, Any]) -> Dict[str, Any]:
80
+ """Publish an event to matching subscribers + the activity feed.
81
+
82
+ Safe to call from sync code (e.g. the store's timeline hook). Never
83
+ raises and never blocks the caller.
84
+ """
85
+ self._seq += 1
86
+ enriched = {
87
+ "seq": self._seq,
88
+ "received_at": _now(),
89
+ "area": event.get("area", "workspace"),
90
+ "event_type": event.get("event_type", "event"),
91
+ "workspace_id": event.get("workspace_id"),
92
+ "payload": event.get("payload", {}),
93
+ **{k: v for k, v in event.items() if k not in {"area", "event_type", "workspace_id", "payload"}},
94
+ }
95
+ self._feed.append(enriched)
96
+ if len(self._feed) > _FEED_LIMIT:
97
+ self._feed = self._feed[-_FEED_LIMIT:]
98
+
99
+ workspace_id = enriched.get("workspace_id")
100
+ for sub in list(self._subscribers.values()):
101
+ if not sub.accepts(workspace_id):
102
+ continue
103
+ try:
104
+ sub.queue.put_nowait(enriched)
105
+ except asyncio.QueueFull:
106
+ try:
107
+ sub.queue.get_nowait() # drop oldest
108
+ sub.queue.put_nowait(enriched)
109
+ except Exception:
110
+ pass
111
+ return enriched
112
+
113
+ # The store calls ``event_sink(event)`` positionally; expose a stable alias.
114
+ def __call__(self, event: Dict[str, Any]) -> Dict[str, Any]:
115
+ return self.publish(event)
116
+
117
+ # ── subscription ──────────────────────────────────────────────────────
118
+
119
+ def add_subscriber(self, sub_id: str, *, workspace_scope: Optional[Set[str]] = None, user: Optional[str] = None) -> _Subscriber:
120
+ sub = _Subscriber(sub_id, workspace_scope, user)
121
+ self._subscribers[sub_id] = sub
122
+ return sub
123
+
124
+ def remove_subscriber(self, sub_id: str) -> None:
125
+ self._subscribers.pop(sub_id, None)
126
+
127
+ async def stream(self, sub: _Subscriber, *, heartbeat: float = 15.0) -> AsyncIterator[str]:
128
+ """Yield SSE frames for a subscriber until the client disconnects.
129
+
130
+ Emits a periodic heartbeat comment so proxies keep the connection open
131
+ and single-user local mode never looks "stuck" with no events.
132
+ """
133
+ # Replay a small tail so a fresh subscriber has immediate context.
134
+ for event in self.recent(limit=10, workspace_scope=sub.workspace_scope):
135
+ yield sse_format(event)
136
+ try:
137
+ while True:
138
+ try:
139
+ event = await asyncio.wait_for(sub.queue.get(), timeout=heartbeat)
140
+ yield sse_format(event)
141
+ except asyncio.TimeoutError:
142
+ yield ": heartbeat\n\n"
143
+ finally:
144
+ self.remove_subscriber(sub.id)
145
+
146
+ # ── feed + presence ─────────────────────────────────────────────────────
147
+
148
+ def recent(self, *, limit: int = 50, workspace_scope: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
149
+ events = self._feed
150
+ if workspace_scope is not None:
151
+ events = [e for e in events if e.get("workspace_id") is None or e.get("workspace_id") in workspace_scope]
152
+ return list(reversed(events[-max(1, min(limit, _FEED_LIMIT)):]))
153
+
154
+ def join(self, client_id: str, *, user: Optional[str], workspace_id: Optional[str]) -> Dict[str, Any]:
155
+ record = {
156
+ "client_id": client_id,
157
+ "user": user,
158
+ "workspace_id": workspace_id,
159
+ "joined_at": _now(),
160
+ "last_seen": _now(),
161
+ }
162
+ self._presence[client_id] = record
163
+ self.publish({"area": "presence", "event_type": "join", "workspace_id": workspace_id, "payload": {"user": user, "client_id": client_id}})
164
+ return record
165
+
166
+ def heartbeat(self, client_id: str) -> Optional[Dict[str, Any]]:
167
+ record = self._presence.get(client_id)
168
+ if record:
169
+ record["last_seen"] = _now()
170
+ return record
171
+
172
+ def leave(self, client_id: str) -> None:
173
+ record = self._presence.pop(client_id, None)
174
+ if record:
175
+ self.publish({"area": "presence", "event_type": "leave", "workspace_id": record.get("workspace_id"), "payload": {"client_id": client_id}})
176
+
177
+ def presence(self, *, workspace_scope: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
178
+ records = list(self._presence.values())
179
+ if workspace_scope is not None:
180
+ records = [r for r in records if r.get("workspace_id") is None or r.get("workspace_id") in workspace_scope]
181
+ return records
182
+
183
+ def stats(self) -> Dict[str, Any]:
184
+ return {
185
+ "version": REALTIME_VERSION,
186
+ "subscribers": len(self._subscribers),
187
+ "presence": len(self._presence),
188
+ "feed_size": len(self._feed),
189
+ "transport": "sse",
190
+ }