ltcai 1.6.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.
Files changed (40) hide show
  1. package/README.md +40 -19
  2. package/docs/CHANGELOG.md +107 -0
  3. package/docs/EDITION_STRATEGY.md +14 -4
  4. package/docs/ENTERPRISE.md +11 -3
  5. package/docs/MULTI_AGENT_RUNTIME.md +410 -0
  6. package/docs/PLUGIN_SDK.md +651 -0
  7. package/docs/REALTIME_COLLABORATION.md +410 -0
  8. package/docs/V2_ARCHITECTURE.md +528 -0
  9. package/docs/WORKFLOW_DESIGNER.md +475 -0
  10. package/latticeai/__init__.py +1 -1
  11. package/latticeai/api/agents.py +98 -0
  12. package/latticeai/api/plugins.py +115 -0
  13. package/latticeai/api/realtime.py +91 -0
  14. package/latticeai/api/workflow_designer.py +207 -0
  15. package/latticeai/core/multi_agent.py +270 -0
  16. package/latticeai/core/plugins.py +400 -0
  17. package/latticeai/core/realtime.py +190 -0
  18. package/latticeai/core/workflow_engine.py +329 -0
  19. package/latticeai/core/workspace_os.py +165 -2
  20. package/latticeai/server_app.py +76 -2
  21. package/latticeai/services/platform_runtime.py +200 -0
  22. package/package.json +17 -2
  23. package/plugins/README.md +35 -0
  24. package/plugins/git-insights/plugin.json +15 -0
  25. package/plugins/hello-world/plugin.json +16 -0
  26. package/plugins/hello-world/skills/hello_skill/SKILL.md +15 -0
  27. package/static/activity.html +70 -0
  28. package/static/admin.html +62 -0
  29. package/static/agents.html +92 -0
  30. package/static/graph.html +7 -1
  31. package/static/lattice-reference.css +184 -0
  32. package/static/platform.css +75 -0
  33. package/static/plugins.html +82 -0
  34. package/static/scripts/admin.js +121 -1
  35. package/static/scripts/graph.js +296 -14
  36. package/static/scripts/platform.js +64 -0
  37. package/static/scripts/workspace.js +107 -10
  38. package/static/workflows.html +121 -0
  39. package/static/workspace.css +73 -0
  40. package/static/workspace.html +18 -2
@@ -0,0 +1,410 @@
1
+ # Lattice AI Realtime Collaboration
2
+
3
+ Realtime Collaboration is the v2.0.0 subsystem that gives a Lattice AI workspace
4
+ a live **presence** registry and an **activity feed**. It is delivered over
5
+ Server-Sent Events (SSE) by an in-process pub/sub bus, the
6
+ [`RealtimeBus`](../latticeai/core/realtime.py).
7
+
8
+ The design goal is to surface "what is happening in the workspace right now"
9
+ (workspaces created, graphs indexed, agents and workflows run, plugins enabled,
10
+ who is online) without adding a new transport, a new dependency, or a second
11
+ event system.
12
+
13
+ ---
14
+
15
+ ## Why SSE
16
+
17
+ SSE was chosen deliberately rather than WebSockets:
18
+
19
+ - The codebase **already streams model output over SSE**
20
+ (`latticeai.services.model_runtime.sse_event`), so the wire format and the
21
+ client patterns are familiar.
22
+ - SSE needs **no extra dependency** — it is plain `text/event-stream` over the
23
+ existing single HTTP port used by the local-first deployment.
24
+ - It works through the existing single-port local-first server with no extra
25
+ ports or upgrade handshakes.
26
+
27
+ The bus is **in-process** (one server, local-first) and fans events out to
28
+ in-memory subscriber queues.
29
+
30
+ > **Compatibility.** This subsystem is purely additive. It introduces new
31
+ > `/realtime/*` endpoints and an `/activity` page, and it attaches to the
32
+ > existing `WorkspaceOSStore` through an optional `event_sink` hook. No v1.x
33
+ > data shape, API, or behavior changes. With zero subscribers the bus is a
34
+ > no-op, so single-user local mode behaves exactly as before.
35
+
36
+ ---
37
+
38
+ ## Architecture at a glance
39
+
40
+ ```
41
+ record_timeline_event(...) (any workspace/graph/agent/workflow write)
42
+
43
+
44
+ WorkspaceOSStore.event_sink ──► RealtimeBus.publish(event)
45
+
46
+ ┌───────────────────┼───────────────────────┐
47
+ ▼ ▼ ▼
48
+ ring-buffer feed matching subscriber queues presence registry
49
+ (capped, 200) (bounded, drop-oldest)
50
+
51
+
52
+ GET /realtime/stream (SSE frames)
53
+ ```
54
+
55
+ The bus is created once and wired into the store in `server_app.py`:
56
+
57
+ ```python
58
+ REALTIME_BUS = RealtimeBus()
59
+ WORKSPACE_OS = WorkspaceOSStore(DATA_DIR, event_sink=REALTIME_BUS)
60
+ ```
61
+
62
+ ### The key integration: a single `event_sink`
63
+
64
+ `WorkspaceOSStore` exposes exactly one realtime hook. Its
65
+ `record_timeline_event` method already runs on **every** meaningful workspace
66
+ write, and it ends by firing the sink:
67
+
68
+ ```python
69
+ def record_timeline_event(self, area, event_type, payload, workspace_id=None):
70
+ state = self.load_state()
71
+ event = {
72
+ "id": f"timeline-{...}",
73
+ "area": area,
74
+ "event_type": event_type,
75
+ "timestamp": _now(),
76
+ "workspace_id": self._resolve_scope(workspace_id, state),
77
+ "payload": payload,
78
+ }
79
+ # ... persist to the timeline ...
80
+ if self.event_sink is not None:
81
+ try:
82
+ self.event_sink(event)
83
+ except Exception:
84
+ # Realtime delivery is best-effort and must never break a write.
85
+ pass
86
+ return event
87
+ ```
88
+
89
+ Because the store calls `event_sink(event)` positionally and `RealtimeBus`
90
+ implements `__call__` as an alias for `publish`, wiring the bus as the sink
91
+ makes **all** workspace / graph / agent / workflow / memory / skill / plugin
92
+ activity flow into the realtime feed automatically. There is **no per-call
93
+ instrumentation** to maintain and no duplicated event system — anything that
94
+ already records a timeline event is realtime by construction.
95
+
96
+ Representative `area` / `event_type` pairs already emitted by the store
97
+ include:
98
+
99
+ | `area` | example `event_type` |
100
+ |-------------|---------------------------------------------------|
101
+ | `workspace` | `workspace_created`, `member_added`, `workspace_archived` |
102
+ | `graph` | `answer_trace`, `indexing_paused`, `indexing_resumed` |
103
+ | `agent` | `agent_run` |
104
+ | `workflow` | `workflow_created`, `workflow_run`, `workflow_edited` |
105
+ | `memory` | `memory_upserted`, `memory_deleted` |
106
+ | `skills` | `skill_installed`, `skill_enabled` |
107
+ | `plugins` | `plugin_installed`, `plugin_enabled` |
108
+ | `presence` | `join`, `leave` (emitted by the bus itself) |
109
+
110
+ ---
111
+
112
+ ## `publish()` — the core contract
113
+
114
+ ```python
115
+ def publish(self, event: Dict[str, Any]) -> Dict[str, Any]: ...
116
+ ```
117
+
118
+ `publish` is the heart of the bus and is built so it is always safe to call
119
+ from the store's synchronous write path:
120
+
121
+ - **Sync-callable.** No `await`, callable from any synchronous code.
122
+ - **Never raises.** Queue overflow and other failures are swallowed; the worst
123
+ case is a dropped frame, never a broken write. (The store also wraps the call
124
+ in its own `try/except` as a second layer.)
125
+ - **Never blocks.** Subscriber queues are bounded; the publisher never waits on
126
+ a slow or disconnected consumer.
127
+
128
+ On each call it:
129
+
130
+ 1. Assigns a monotonically increasing `seq` and a `received_at` timestamp, and
131
+ normalizes the event into a stable enriched shape.
132
+ 2. Appends the enriched event to a **capped ring-buffer feed** (`_FEED_LIMIT =
133
+ 200`); older events fall off the front.
134
+ 3. Fans the event out to every subscriber whose scope **accepts** the event's
135
+ `workspace_id` (see [Workspace isolation](#workspace-isolation)).
136
+
137
+ ### Enriched event shape
138
+
139
+ ```json
140
+ {
141
+ "seq": 42,
142
+ "received_at": "2026-06-01T10:15:30",
143
+ "area": "workflow",
144
+ "event_type": "workflow_run",
145
+ "workspace_id": "ws_marketing",
146
+ "payload": { "run_id": "wf-run-7", "workflow_id": "wf_3", "status": "ok" },
147
+ "id": "timeline-9f1c...",
148
+ "timestamp": "2026-06-01T10:15:30"
149
+ }
150
+ ```
151
+
152
+ `area`, `event_type`, `workspace_id`, and `payload` are always present
153
+ (defaulting to `"workspace"`, `"event"`, `None`, and `{}` respectively). Any
154
+ extra keys on the source event (such as the store's `id` and `timestamp`) are
155
+ preserved alongside them.
156
+
157
+ ### Backpressure: bounded queues drop the oldest
158
+
159
+ Each subscriber has an `asyncio.Queue(maxsize=100)`. On overflow the publisher
160
+ does **not** block — it discards the oldest queued event to make room for the
161
+ newest:
162
+
163
+ ```python
164
+ try:
165
+ sub.queue.put_nowait(enriched)
166
+ except asyncio.QueueFull:
167
+ try:
168
+ sub.queue.get_nowait() # drop oldest
169
+ sub.queue.put_nowait(enriched)
170
+ except Exception:
171
+ pass
172
+ ```
173
+
174
+ A slow client therefore sees gaps rather than stalling the whole server.
175
+
176
+ ---
177
+
178
+ ## Workspace isolation
179
+
180
+ Every event carries a `workspace_id`. A subscriber is created with an allowed
181
+ **workspace scope** — a `Set[str]` of workspace IDs the caller may see — and
182
+ only receives events that its scope accepts:
183
+
184
+ ```python
185
+ def accepts(self, workspace_id: Optional[str]) -> bool:
186
+ # ``None`` scope = see everything the local user can (personal/unscoped).
187
+ if self.workspace_scope is None:
188
+ return True
189
+ if workspace_id is None:
190
+ return True
191
+ return workspace_id in self.workspace_scope
192
+ ```
193
+
194
+ Two rules fall out of this:
195
+
196
+ - **Unscoped events are always delivered.** An event with `workspace_id` of
197
+ `None` reaches every subscriber. So do events for a subscriber whose scope is
198
+ `None`. This is what makes **single-user local mode** work with no scope
199
+ restriction — there is nothing to filter and everything is visible.
200
+ - **Scoped events are filtered.** When a subscriber has a concrete scope set,
201
+ it only receives events whose `workspace_id` is in that set (plus the always-
202
+ delivered unscoped events).
203
+
204
+ The scope is resolved per request, not hard-coded. The API layer calls
205
+ `PlatformRuntime.allowed_scopes`, which derives the set from the workspaces the
206
+ user can actually list:
207
+
208
+ ```python
209
+ def allowed_scopes(self, user: Optional[str]) -> Optional[Set[str]]:
210
+ try:
211
+ workspaces = self.svc.list_workspaces(user or None).get("workspaces", [])
212
+ return {ws.get("workspace_id") for ws in workspaces if ws.get("workspace_id")}
213
+ except Exception:
214
+ return None
215
+ ```
216
+
217
+ If scope resolution fails for any reason it returns `None` — the permissive,
218
+ local-friendly default — rather than erroring the stream. The feed
219
+ (`recent`) and presence (`presence`) reads apply the same scope filter, so a
220
+ caller can never read across workspaces it is not entitled to.
221
+
222
+ ---
223
+
224
+ ## `stream()` — replay tail, live frames, heartbeats
225
+
226
+ ```python
227
+ async def stream(self, sub: _Subscriber, *, heartbeat: float = 15.0) -> AsyncIterator[str]: ...
228
+ ```
229
+
230
+ When a client connects, `stream` first **replays a short tail** (up to the 10
231
+ most recent in-scope events) so a fresh subscriber immediately has context,
232
+ then yields **live frames** as they arrive. If no event arrives within the
233
+ `heartbeat` interval (default 15 seconds) it emits an SSE comment:
234
+
235
+ ```
236
+ : heartbeat
237
+ ```
238
+
239
+ The heartbeat keeps proxies from closing an idle connection and stops
240
+ single-user local mode from looking "stuck" when nothing is happening. When the
241
+ async generator is closed (client disconnect), the subscriber is removed in a
242
+ `finally` block, so queues do not leak.
243
+
244
+ Each event is encoded with `sse_format`:
245
+
246
+ ```python
247
+ def sse_format(event: Dict[str, Any]) -> str:
248
+ """Encode an event as an SSE ``data:`` frame."""
249
+ return f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
250
+ ```
251
+
252
+ ---
253
+
254
+ ## API reference
255
+
256
+ All endpoints require an authenticated user via `require_user`. In local mode
257
+ with auth disabled, `require_user` returns an empty string and the request
258
+ proceeds; the resolved scope is then `None` (see everything). The realtime
259
+ router is mounted in `server_app.py` with the live bus, the auth helpers, and
260
+ `PlatformRuntime.allowed_scopes` as the scope resolver.
261
+
262
+ ### `GET /activity`
263
+
264
+ Serves the Activity UI page (`static/activity.html`). Returns `404` if the UI
265
+ file or static directory is not available.
266
+
267
+ ### `GET /realtime/stream`
268
+
269
+ The SSE stream. Resolves the caller's allowed scope, registers a new subscriber
270
+ with a random ID, and returns a `StreamingResponse` of
271
+ `media_type="text/event-stream"`. The response sets streaming-friendly headers:
272
+
273
+ ```
274
+ Cache-Control: no-cache
275
+ X-Accel-Buffering: no
276
+ Connection: keep-alive
277
+ ```
278
+
279
+ The server stops generating frames as soon as `request.is_disconnected()` is
280
+ true.
281
+
282
+ ### `GET /realtime/feed`
283
+
284
+ Reads the recent activity feed (scope-filtered). Query parameter `limit`
285
+ defaults to `50` and is clamped to the `200`-entry buffer. Returns newest-first:
286
+
287
+ ```json
288
+ {
289
+ "events": [ /* enriched events, newest first */ ],
290
+ "stats": {
291
+ "version": "2.0.0",
292
+ "subscribers": 1,
293
+ "presence": 2,
294
+ "feed_size": 17,
295
+ "transport": "sse"
296
+ }
297
+ }
298
+ ```
299
+
300
+ ### `GET /realtime/presence`
301
+
302
+ Returns the scope-filtered presence registry plus the same `stats` block:
303
+
304
+ ```json
305
+ {
306
+ "presence": [
307
+ {
308
+ "client_id": "Hk3p...",
309
+ "user": "rnlgnquvk@gmail.com",
310
+ "workspace_id": "ws_marketing",
311
+ "joined_at": "2026-06-01T10:14:00",
312
+ "last_seen": "2026-06-01T10:15:30"
313
+ }
314
+ ],
315
+ "stats": { "version": "2.0.0", "subscribers": 1, "presence": 1, "feed_size": 17, "transport": "sse" }
316
+ }
317
+ ```
318
+
319
+ ### `POST /realtime/presence/join`
320
+
321
+ Registers a client as present. Request body:
322
+
323
+ ```json
324
+ {
325
+ "client_id": "optional-client-id",
326
+ "workspace_id": "ws_marketing"
327
+ }
328
+ ```
329
+
330
+ Both fields are optional. If `client_id` is omitted, the server generates one.
331
+ A `presence`/`join` event is published to subscribers in scope. Response:
332
+
333
+ ```json
334
+ {
335
+ "presence": {
336
+ "client_id": "Hk3p...",
337
+ "user": "rnlgnquvk@gmail.com",
338
+ "workspace_id": "ws_marketing",
339
+ "joined_at": "2026-06-01T10:14:00",
340
+ "last_seen": "2026-06-01T10:14:00"
341
+ }
342
+ }
343
+ ```
344
+
345
+ ### `POST /realtime/presence/leave`
346
+
347
+ Removes a client from the presence registry (publishing a `presence`/`leave`
348
+ event) when a `client_id` is supplied. Request body uses the same
349
+ `PresenceRequest` shape; only `client_id` is read.
350
+
351
+ ```json
352
+ { "status": "ok" }
353
+ ```
354
+
355
+ ---
356
+
357
+ ## Client example (`EventSource`)
358
+
359
+ The stream is standard SSE, so a browser can consume it with the built-in
360
+ `EventSource`. Join presence first, then subscribe to the feed:
361
+
362
+ ```javascript
363
+ // 1. Announce presence (optional but enables the presence registry).
364
+ const clientId = crypto.randomUUID();
365
+ await fetch("/realtime/presence/join", {
366
+ method: "POST",
367
+ headers: { "Content-Type": "application/json" },
368
+ body: JSON.stringify({ client_id: clientId, workspace_id: "ws_marketing" }),
369
+ });
370
+
371
+ // 2. Subscribe to the live activity stream.
372
+ const source = new EventSource("/realtime/stream");
373
+
374
+ source.onmessage = (e) => {
375
+ const event = JSON.parse(e.data);
376
+ console.log(`[${event.seq}] ${event.area}/${event.event_type}`, event.payload);
377
+ // e.g. render into an activity panel...
378
+ };
379
+
380
+ source.onerror = () => {
381
+ // EventSource auto-reconnects; on the next connect the server replays
382
+ // a short tail so missed-while-offline context is restored.
383
+ };
384
+
385
+ // 3. On unload, leave presence.
386
+ window.addEventListener("beforeunload", () => {
387
+ navigator.sendBeacon(
388
+ "/realtime/presence/leave",
389
+ new Blob([JSON.stringify({ client_id: clientId })], { type: "application/json" }),
390
+ );
391
+ });
392
+ ```
393
+
394
+ Heartbeat lines (`: heartbeat`) are SSE comments and never fire `onmessage`, so
395
+ no client-side filtering is needed.
396
+
397
+ ---
398
+
399
+ ## Operational notes
400
+
401
+ - **Limits.** Feed ring buffer: `200` events (`_FEED_LIMIT`). Per-subscriber
402
+ queue: `100` events (`_QUEUE_MAX`). Heartbeat interval: `15` seconds.
403
+ - **No persistence.** The feed, presence registry, and subscriber set live in
404
+ memory and reset on server restart. The durable record of activity remains
405
+ the store's own `timeline` (capped at 500 events) — realtime is a live view
406
+ on top of it, not a replacement.
407
+ - **Single process.** The bus is in-process by design for the local-first
408
+ deployment; it does not coordinate across multiple server processes.
409
+ - **`stats()`** reports `version` (`2.0.0`), live `subscribers`, `presence`
410
+ count, `feed_size`, and the `transport` (`"sse"`) for health/observability.