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.
- package/README.md +31 -21
- package/docs/CHANGELOG.md +65 -0
- package/docs/EDITION_STRATEGY.md +10 -4
- package/docs/ENTERPRISE.md +3 -1
- package/docs/MULTI_AGENT_RUNTIME.md +410 -0
- package/docs/PLUGIN_SDK.md +651 -0
- package/docs/REALTIME_COLLABORATION.md +410 -0
- package/docs/V2_ARCHITECTURE.md +528 -0
- package/docs/WORKFLOW_DESIGNER.md +475 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +98 -0
- package/latticeai/api/plugins.py +115 -0
- package/latticeai/api/realtime.py +91 -0
- package/latticeai/api/workflow_designer.py +207 -0
- package/latticeai/core/multi_agent.py +270 -0
- package/latticeai/core/plugins.py +400 -0
- package/latticeai/core/realtime.py +190 -0
- package/latticeai/core/workflow_engine.py +329 -0
- package/latticeai/core/workspace_os.py +155 -2
- package/latticeai/server_app.py +76 -2
- package/latticeai/services/platform_runtime.py +200 -0
- package/package.json +8 -2
- package/plugins/README.md +35 -0
- package/plugins/git-insights/plugin.json +15 -0
- package/plugins/hello-world/plugin.json +16 -0
- package/plugins/hello-world/skills/hello_skill/SKILL.md +15 -0
- package/static/activity.html +70 -0
- package/static/agents.html +92 -0
- package/static/platform.css +75 -0
- package/static/plugins.html +82 -0
- package/static/scripts/platform.js +64 -0
- package/static/workflows.html +121 -0
- package/static/workspace.html +5 -1
|
@@ -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
|
+
}
|