ltcai 1.0.0 → 1.1.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 +15 -0
- package/docs/CHANGELOG.md +71 -0
- package/docs/EDITION_STRATEGY.md +56 -0
- package/docs/ENTERPRISE.md +78 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/core/enterprise.py +152 -0
- package/latticeai/core/workspace_os.py +409 -38
- package/latticeai/server_app.py +188 -8
- package/package.json +1 -1
- package/static/scripts/workspace.js +149 -0
- package/static/sw.js +1 -1
- package/static/workspace.css +31 -0
- package/static/workspace.html +41 -2
|
@@ -18,7 +18,30 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
WORKSPACE_OS_VERSION = "1.
|
|
21
|
+
WORKSPACE_OS_VERSION = "1.1.0"
|
|
22
|
+
|
|
23
|
+
# Workspace types separate single-user Personal workspaces from shared
|
|
24
|
+
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
25
|
+
# only changes how membership and permissions are evaluated.
|
|
26
|
+
WORKSPACE_TYPES = ("personal", "organization")
|
|
27
|
+
|
|
28
|
+
DEFAULT_WORKSPACE_ID = "personal"
|
|
29
|
+
|
|
30
|
+
# Role hierarchy for Organization workspaces. Personal workspaces always grant
|
|
31
|
+
# their single local user the owner role.
|
|
32
|
+
WORKSPACE_ROLES = ("owner", "admin", "member", "viewer")
|
|
33
|
+
|
|
34
|
+
# Capability-style permissions. Kept intentionally small so Enterprise editions
|
|
35
|
+
# can layer advanced RBAC/ABAC on top via the enterprise seam without changing
|
|
36
|
+
# these community defaults.
|
|
37
|
+
WORKSPACE_PERMISSIONS = ("read", "write", "manage_members", "manage_workspace")
|
|
38
|
+
|
|
39
|
+
ROLE_PERMISSIONS: Dict[str, set] = {
|
|
40
|
+
"owner": {"read", "write", "manage_members", "manage_workspace"},
|
|
41
|
+
"admin": {"read", "write", "manage_members", "manage_workspace"},
|
|
42
|
+
"member": {"read", "write"},
|
|
43
|
+
"viewer": {"read"},
|
|
44
|
+
}
|
|
22
45
|
|
|
23
46
|
WORKSPACE_AREAS = [
|
|
24
47
|
"graph",
|
|
@@ -150,26 +173,94 @@ class WorkspaceOSStore:
|
|
|
150
173
|
self.snapshots_dir.mkdir(parents=True, exist_ok=True)
|
|
151
174
|
self.exports_dir.mkdir(parents=True, exist_ok=True)
|
|
152
175
|
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _new_workspace_record(
|
|
178
|
+
*,
|
|
179
|
+
workspace_id: str,
|
|
180
|
+
name: str,
|
|
181
|
+
workspace_type: str,
|
|
182
|
+
owner_user_id: Optional[str],
|
|
183
|
+
settings: Optional[Dict[str, Any]] = None,
|
|
184
|
+
members: Optional[List[Dict[str, Any]]] = None,
|
|
185
|
+
) -> Dict[str, Any]:
|
|
186
|
+
if workspace_type not in WORKSPACE_TYPES:
|
|
187
|
+
raise ValueError(f"unknown workspace type: {workspace_type}")
|
|
188
|
+
now = _now()
|
|
189
|
+
member_list = list(members or [])
|
|
190
|
+
if owner_user_id and not any(m.get("user_id") == owner_user_id for m in member_list):
|
|
191
|
+
member_list.insert(0, {"user_id": owner_user_id, "role": "owner", "added_at": now})
|
|
192
|
+
return {
|
|
193
|
+
"workspace_id": workspace_id,
|
|
194
|
+
"id": workspace_id,
|
|
195
|
+
"name": name,
|
|
196
|
+
"type": workspace_type,
|
|
197
|
+
"owner_user_id": owner_user_id,
|
|
198
|
+
"members": member_list,
|
|
199
|
+
"roles": {role: sorted(perms) for role, perms in ROLE_PERMISSIONS.items()},
|
|
200
|
+
"status": "active",
|
|
201
|
+
"areas": list(WORKSPACE_AREAS),
|
|
202
|
+
"settings": settings or {},
|
|
203
|
+
"created_at": now,
|
|
204
|
+
"updated_at": now,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
def _migrate_workspaces(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
208
|
+
"""Non-destructive upgrade of legacy workspace entries to the v1.1 model.
|
|
209
|
+
|
|
210
|
+
Existing 1.0.x state files stored minimal ``{id,name,type,areas}`` dicts.
|
|
211
|
+
This backfills membership/role/timestamp fields without dropping data and
|
|
212
|
+
guarantees the default Personal workspace always exists.
|
|
213
|
+
"""
|
|
214
|
+
workspaces = state.get("workspaces")
|
|
215
|
+
if not isinstance(workspaces, dict):
|
|
216
|
+
workspaces = {}
|
|
217
|
+
migrated: Dict[str, Any] = {}
|
|
218
|
+
for ws_id, ws in workspaces.items():
|
|
219
|
+
if not isinstance(ws, dict):
|
|
220
|
+
continue
|
|
221
|
+
ws_type = ws.get("type") if ws.get("type") in WORKSPACE_TYPES else "organization"
|
|
222
|
+
if ws_id == DEFAULT_WORKSPACE_ID:
|
|
223
|
+
ws_type = "personal"
|
|
224
|
+
base = self._new_workspace_record(
|
|
225
|
+
workspace_id=ws_id,
|
|
226
|
+
name=ws.get("name") or ws_id,
|
|
227
|
+
workspace_type=ws_type,
|
|
228
|
+
owner_user_id=ws.get("owner_user_id"),
|
|
229
|
+
settings=ws.get("settings") or {},
|
|
230
|
+
members=ws.get("members") if isinstance(ws.get("members"), list) else None,
|
|
231
|
+
)
|
|
232
|
+
# Preserve any pre-existing timestamps / status from the loaded record.
|
|
233
|
+
base["created_at"] = ws.get("created_at") or base["created_at"]
|
|
234
|
+
base["updated_at"] = ws.get("updated_at") or base["updated_at"]
|
|
235
|
+
base["status"] = ws.get("status") or base["status"]
|
|
236
|
+
migrated[ws_id] = base
|
|
237
|
+
if DEFAULT_WORKSPACE_ID not in migrated:
|
|
238
|
+
migrated[DEFAULT_WORKSPACE_ID] = self._new_workspace_record(
|
|
239
|
+
workspace_id=DEFAULT_WORKSPACE_ID,
|
|
240
|
+
name="Personal Workspace",
|
|
241
|
+
workspace_type="personal",
|
|
242
|
+
owner_user_id=None,
|
|
243
|
+
)
|
|
244
|
+
state["workspaces"] = migrated
|
|
245
|
+
active = state.get("active_workspace")
|
|
246
|
+
if active not in migrated:
|
|
247
|
+
state["active_workspace"] = DEFAULT_WORKSPACE_ID
|
|
248
|
+
return state
|
|
249
|
+
|
|
153
250
|
def _default_state(self) -> Dict[str, Any]:
|
|
154
251
|
return {
|
|
155
252
|
"version": WORKSPACE_OS_VERSION,
|
|
156
253
|
"identity": "AI Workspace OS",
|
|
157
254
|
"created_at": _now(),
|
|
158
255
|
"updated_at": _now(),
|
|
159
|
-
"active_workspace":
|
|
256
|
+
"active_workspace": DEFAULT_WORKSPACE_ID,
|
|
160
257
|
"workspaces": {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"organization": {
|
|
168
|
-
"id": "organization",
|
|
169
|
-
"name": "Organization Workspace",
|
|
170
|
-
"type": "organization",
|
|
171
|
-
"areas": list(WORKSPACE_AREAS),
|
|
172
|
-
},
|
|
258
|
+
DEFAULT_WORKSPACE_ID: self._new_workspace_record(
|
|
259
|
+
workspace_id=DEFAULT_WORKSPACE_ID,
|
|
260
|
+
name="Personal Workspace",
|
|
261
|
+
workspace_type="personal",
|
|
262
|
+
owner_user_id=None,
|
|
263
|
+
),
|
|
173
264
|
},
|
|
174
265
|
"feature_flags": {
|
|
175
266
|
"workspace_os": True,
|
|
@@ -180,6 +271,8 @@ class WorkspaceOSStore:
|
|
|
180
271
|
"workflow_graph": True,
|
|
181
272
|
"skill_marketplace": True,
|
|
182
273
|
"local_computer_memory": False,
|
|
274
|
+
"organization_workspaces": True,
|
|
275
|
+
"enterprise_seam": True,
|
|
183
276
|
},
|
|
184
277
|
"onboarding": {
|
|
185
278
|
"completed": False,
|
|
@@ -227,6 +320,7 @@ class WorkspaceOSStore:
|
|
|
227
320
|
loaded = {}
|
|
228
321
|
state = _deep_merge(default, loaded)
|
|
229
322
|
state["version"] = WORKSPACE_OS_VERSION
|
|
323
|
+
self._migrate_workspaces(state)
|
|
230
324
|
return state
|
|
231
325
|
|
|
232
326
|
def save_state(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -235,13 +329,14 @@ class WorkspaceOSStore:
|
|
|
235
329
|
_atomic_write_json(self.state_path, state)
|
|
236
330
|
return state
|
|
237
331
|
|
|
238
|
-
def record_timeline_event(self, area: str, event_type: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
332
|
+
def record_timeline_event(self, area: str, event_type: str, payload: Dict[str, Any], workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
239
333
|
state = self.load_state()
|
|
240
334
|
event = {
|
|
241
335
|
"id": f"timeline-{_json_hash([area, event_type, payload, _now()])[:16]}",
|
|
242
336
|
"area": area,
|
|
243
337
|
"event_type": event_type,
|
|
244
338
|
"timestamp": _now(),
|
|
339
|
+
"workspace_id": self._resolve_scope(workspace_id, state),
|
|
245
340
|
"payload": payload,
|
|
246
341
|
}
|
|
247
342
|
state.setdefault("timeline", []).append(event)
|
|
@@ -275,6 +370,269 @@ class WorkspaceOSStore:
|
|
|
275
370
|
},
|
|
276
371
|
}
|
|
277
372
|
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
# Organization workspaces, membership, and roles
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def _active_workspace_id(self, state: Optional[Dict[str, Any]] = None) -> str:
|
|
378
|
+
state = state or self.load_state()
|
|
379
|
+
active = state.get("active_workspace") or DEFAULT_WORKSPACE_ID
|
|
380
|
+
if active not in (state.get("workspaces") or {}):
|
|
381
|
+
return DEFAULT_WORKSPACE_ID
|
|
382
|
+
return active
|
|
383
|
+
|
|
384
|
+
def _resolve_scope(self, workspace_id: Optional[str], state: Optional[Dict[str, Any]] = None) -> str:
|
|
385
|
+
"""Resolve the workspace a write should be tagged with.
|
|
386
|
+
|
|
387
|
+
``None`` falls back to the active workspace (Personal by default), so
|
|
388
|
+
legacy callers keep writing to the Personal workspace unchanged.
|
|
389
|
+
"""
|
|
390
|
+
if workspace_id:
|
|
391
|
+
return str(workspace_id)
|
|
392
|
+
return self._active_workspace_id(state)
|
|
393
|
+
|
|
394
|
+
@staticmethod
|
|
395
|
+
def _record_workspace(record: Dict[str, Any]) -> str:
|
|
396
|
+
"""Workspace a stored record belongs to (legacy records map to Personal)."""
|
|
397
|
+
return str(record.get("workspace_id") or DEFAULT_WORKSPACE_ID)
|
|
398
|
+
|
|
399
|
+
def _scoped(self, records: List[Dict[str, Any]], workspace_id: Optional[str]) -> List[Dict[str, Any]]:
|
|
400
|
+
if not workspace_id:
|
|
401
|
+
return records
|
|
402
|
+
target = str(workspace_id)
|
|
403
|
+
return [item for item in records if self._record_workspace(item) == target]
|
|
404
|
+
|
|
405
|
+
def list_workspaces(self, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
406
|
+
state = self.load_state()
|
|
407
|
+
workspaces = state.get("workspaces") or {}
|
|
408
|
+
items = []
|
|
409
|
+
for ws in workspaces.values():
|
|
410
|
+
if user_id and ws.get("type") == "organization":
|
|
411
|
+
role = self._member_role(ws, user_id)
|
|
412
|
+
if role is None:
|
|
413
|
+
continue
|
|
414
|
+
items.append(self._workspace_public(ws, user_id))
|
|
415
|
+
items.sort(key=lambda w: (w.get("type") != "personal", w.get("created_at") or ""))
|
|
416
|
+
return {
|
|
417
|
+
"active_workspace": self._active_workspace_id(state),
|
|
418
|
+
"workspaces": items,
|
|
419
|
+
"roles": list(WORKSPACE_ROLES),
|
|
420
|
+
"permissions": {role: sorted(perms) for role, perms in ROLE_PERMISSIONS.items()},
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
def _workspace_public(self, ws: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
424
|
+
return {
|
|
425
|
+
"workspace_id": ws.get("workspace_id") or ws.get("id"),
|
|
426
|
+
"id": ws.get("workspace_id") or ws.get("id"),
|
|
427
|
+
"name": ws.get("name"),
|
|
428
|
+
"type": ws.get("type"),
|
|
429
|
+
"owner_user_id": ws.get("owner_user_id"),
|
|
430
|
+
"status": ws.get("status", "active"),
|
|
431
|
+
"member_count": len(_listify(ws.get("members"))),
|
|
432
|
+
"members": _listify(ws.get("members")),
|
|
433
|
+
"settings": ws.get("settings") or {},
|
|
434
|
+
"created_at": ws.get("created_at"),
|
|
435
|
+
"updated_at": ws.get("updated_at"),
|
|
436
|
+
"your_role": self._member_role(ws, user_id) if user_id else ("owner" if ws.get("type") == "personal" else None),
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
def get_workspace(self, workspace_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
440
|
+
state = self.load_state()
|
|
441
|
+
ws = (state.get("workspaces") or {}).get(workspace_id)
|
|
442
|
+
if not ws:
|
|
443
|
+
raise FileNotFoundError(workspace_id)
|
|
444
|
+
return self._workspace_public(ws, user_id)
|
|
445
|
+
|
|
446
|
+
def create_organization_workspace(
|
|
447
|
+
self,
|
|
448
|
+
*,
|
|
449
|
+
name: str,
|
|
450
|
+
owner_user_id: Optional[str],
|
|
451
|
+
settings: Optional[Dict[str, Any]] = None,
|
|
452
|
+
) -> Dict[str, Any]:
|
|
453
|
+
if not str(name or "").strip():
|
|
454
|
+
raise ValueError("workspace name is required")
|
|
455
|
+
state = self.load_state()
|
|
456
|
+
workspaces = state.setdefault("workspaces", {})
|
|
457
|
+
base = _safe_slug(f"org-{name}")
|
|
458
|
+
workspace_id = base
|
|
459
|
+
suffix = 2
|
|
460
|
+
while workspace_id in workspaces:
|
|
461
|
+
workspace_id = f"{base}-{suffix}"
|
|
462
|
+
suffix += 1
|
|
463
|
+
record = self._new_workspace_record(
|
|
464
|
+
workspace_id=workspace_id,
|
|
465
|
+
name=name.strip(),
|
|
466
|
+
workspace_type="organization",
|
|
467
|
+
owner_user_id=owner_user_id,
|
|
468
|
+
settings=settings or {},
|
|
469
|
+
)
|
|
470
|
+
workspaces[workspace_id] = record
|
|
471
|
+
self.save_state(state)
|
|
472
|
+
self.record_timeline_event("workspace", "workspace_created", {"workspace_id": workspace_id, "type": "organization"})
|
|
473
|
+
return self._workspace_public(record, owner_user_id)
|
|
474
|
+
|
|
475
|
+
@staticmethod
|
|
476
|
+
def _member_role(ws: Dict[str, Any], user_id: Optional[str]) -> Optional[str]:
|
|
477
|
+
if ws.get("type") == "personal":
|
|
478
|
+
return "owner"
|
|
479
|
+
owner = ws.get("owner_user_id")
|
|
480
|
+
# Local single-user / no-auth mode: an ownerless org is owned by the
|
|
481
|
+
# local user (who has no identity), so they can manage what they create.
|
|
482
|
+
if not owner and not user_id:
|
|
483
|
+
return "owner"
|
|
484
|
+
if user_id and user_id == owner:
|
|
485
|
+
return "owner"
|
|
486
|
+
for member in _listify(ws.get("members")):
|
|
487
|
+
if member.get("user_id") == user_id:
|
|
488
|
+
return member.get("role")
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
def get_member_role(self, workspace_id: str, user_id: Optional[str]) -> Optional[str]:
|
|
492
|
+
ws = (self.load_state().get("workspaces") or {}).get(workspace_id)
|
|
493
|
+
if not ws:
|
|
494
|
+
raise FileNotFoundError(workspace_id)
|
|
495
|
+
return self._member_role(ws, user_id)
|
|
496
|
+
|
|
497
|
+
def has_permission(self, workspace_id: str, user_id: Optional[str], permission: str) -> bool:
|
|
498
|
+
try:
|
|
499
|
+
role = self.get_member_role(workspace_id, user_id)
|
|
500
|
+
except FileNotFoundError:
|
|
501
|
+
return False
|
|
502
|
+
if role is None:
|
|
503
|
+
return False
|
|
504
|
+
return permission in ROLE_PERMISSIONS.get(role, set())
|
|
505
|
+
|
|
506
|
+
def _require_permission(self, ws: Dict[str, Any], actor: Optional[str], permission: str) -> None:
|
|
507
|
+
role = self._member_role(ws, actor)
|
|
508
|
+
if role is None or permission not in ROLE_PERMISSIONS.get(role, set()):
|
|
509
|
+
raise PermissionError(
|
|
510
|
+
f"'{actor or 'anonymous'}' lacks '{permission}' on workspace '{ws.get('workspace_id')}'"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def _load_org(self, state: Dict[str, Any], workspace_id: str) -> Dict[str, Any]:
|
|
514
|
+
ws = (state.get("workspaces") or {}).get(workspace_id)
|
|
515
|
+
if not ws:
|
|
516
|
+
raise FileNotFoundError(workspace_id)
|
|
517
|
+
if ws.get("type") != "organization":
|
|
518
|
+
raise ValueError("operation only valid for organization workspaces")
|
|
519
|
+
return ws
|
|
520
|
+
|
|
521
|
+
def update_workspace(
|
|
522
|
+
self,
|
|
523
|
+
workspace_id: str,
|
|
524
|
+
*,
|
|
525
|
+
name: Optional[str] = None,
|
|
526
|
+
settings: Optional[Dict[str, Any]] = None,
|
|
527
|
+
actor: Optional[str] = None,
|
|
528
|
+
) -> Dict[str, Any]:
|
|
529
|
+
state = self.load_state()
|
|
530
|
+
ws = self._load_org(state, workspace_id)
|
|
531
|
+
self._require_permission(ws, actor, "manage_workspace")
|
|
532
|
+
if name is not None and str(name).strip():
|
|
533
|
+
ws["name"] = str(name).strip()
|
|
534
|
+
if settings is not None:
|
|
535
|
+
ws["settings"] = {**(ws.get("settings") or {}), **settings}
|
|
536
|
+
ws["updated_at"] = _now()
|
|
537
|
+
self.save_state(state)
|
|
538
|
+
self.record_timeline_event("workspace", "workspace_updated", {"workspace_id": workspace_id})
|
|
539
|
+
return self._workspace_public(ws, actor)
|
|
540
|
+
|
|
541
|
+
def archive_workspace(self, workspace_id: str, *, actor: Optional[str] = None) -> Dict[str, Any]:
|
|
542
|
+
"""Soft-archive an organization workspace. Data is never deleted."""
|
|
543
|
+
state = self.load_state()
|
|
544
|
+
ws = self._load_org(state, workspace_id)
|
|
545
|
+
self._require_permission(ws, actor, "manage_workspace")
|
|
546
|
+
ws["status"] = "archived"
|
|
547
|
+
ws["updated_at"] = _now()
|
|
548
|
+
if state.get("active_workspace") == workspace_id:
|
|
549
|
+
state["active_workspace"] = DEFAULT_WORKSPACE_ID
|
|
550
|
+
self.save_state(state)
|
|
551
|
+
self.record_timeline_event("workspace", "workspace_archived", {"workspace_id": workspace_id})
|
|
552
|
+
return self._workspace_public(ws, actor)
|
|
553
|
+
|
|
554
|
+
def add_member(self, workspace_id: str, *, user_id: str, role: str = "member", actor: Optional[str] = None) -> Dict[str, Any]:
|
|
555
|
+
if role not in WORKSPACE_ROLES:
|
|
556
|
+
raise ValueError(f"unknown role: {role}")
|
|
557
|
+
if not str(user_id or "").strip():
|
|
558
|
+
raise ValueError("user_id is required")
|
|
559
|
+
state = self.load_state()
|
|
560
|
+
ws = self._load_org(state, workspace_id)
|
|
561
|
+
self._require_permission(ws, actor, "manage_members")
|
|
562
|
+
members = ws.setdefault("members", [])
|
|
563
|
+
existing = next((m for m in members if m.get("user_id") == user_id), None)
|
|
564
|
+
if existing:
|
|
565
|
+
existing["role"] = role
|
|
566
|
+
existing["updated_at"] = _now()
|
|
567
|
+
else:
|
|
568
|
+
members.append({"user_id": user_id, "role": role, "added_at": _now()})
|
|
569
|
+
ws["updated_at"] = _now()
|
|
570
|
+
self.save_state(state)
|
|
571
|
+
self.record_timeline_event("workspace", "member_added", {"workspace_id": workspace_id, "user_id": user_id, "role": role})
|
|
572
|
+
return self._workspace_public(ws, actor)
|
|
573
|
+
|
|
574
|
+
def update_member_role(self, workspace_id: str, *, user_id: str, role: str, actor: Optional[str] = None) -> Dict[str, Any]:
|
|
575
|
+
if role not in WORKSPACE_ROLES:
|
|
576
|
+
raise ValueError(f"unknown role: {role}")
|
|
577
|
+
state = self.load_state()
|
|
578
|
+
ws = self._load_org(state, workspace_id)
|
|
579
|
+
self._require_permission(ws, actor, "manage_members")
|
|
580
|
+
if user_id == ws.get("owner_user_id") and role != "owner":
|
|
581
|
+
raise ValueError("cannot demote the workspace owner")
|
|
582
|
+
member = next((m for m in _listify(ws.get("members")) if m.get("user_id") == user_id), None)
|
|
583
|
+
if not member:
|
|
584
|
+
raise FileNotFoundError(user_id)
|
|
585
|
+
member["role"] = role
|
|
586
|
+
member["updated_at"] = _now()
|
|
587
|
+
ws["updated_at"] = _now()
|
|
588
|
+
self.save_state(state)
|
|
589
|
+
self.record_timeline_event("workspace", "member_role_updated", {"workspace_id": workspace_id, "user_id": user_id, "role": role})
|
|
590
|
+
return self._workspace_public(ws, actor)
|
|
591
|
+
|
|
592
|
+
def remove_member(self, workspace_id: str, *, user_id: str, actor: Optional[str] = None) -> Dict[str, Any]:
|
|
593
|
+
state = self.load_state()
|
|
594
|
+
ws = self._load_org(state, workspace_id)
|
|
595
|
+
self._require_permission(ws, actor, "manage_members")
|
|
596
|
+
if user_id == ws.get("owner_user_id"):
|
|
597
|
+
raise ValueError("cannot remove the workspace owner")
|
|
598
|
+
members = _listify(ws.get("members"))
|
|
599
|
+
kept = [m for m in members if m.get("user_id") != user_id]
|
|
600
|
+
if len(kept) == len(members):
|
|
601
|
+
raise FileNotFoundError(user_id)
|
|
602
|
+
ws["members"] = kept
|
|
603
|
+
ws["updated_at"] = _now()
|
|
604
|
+
self.save_state(state)
|
|
605
|
+
self.record_timeline_event("workspace", "member_removed", {"workspace_id": workspace_id, "user_id": user_id})
|
|
606
|
+
return self._workspace_public(ws, actor)
|
|
607
|
+
|
|
608
|
+
def set_active_workspace(self, workspace_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
609
|
+
state = self.load_state()
|
|
610
|
+
ws = (state.get("workspaces") or {}).get(workspace_id)
|
|
611
|
+
if not ws:
|
|
612
|
+
raise FileNotFoundError(workspace_id)
|
|
613
|
+
if ws.get("type") == "organization" and self._member_role(ws, user_id) is None:
|
|
614
|
+
raise PermissionError(f"'{user_id or 'anonymous'}' is not a member of '{workspace_id}'")
|
|
615
|
+
state["active_workspace"] = workspace_id
|
|
616
|
+
self.save_state(state)
|
|
617
|
+
self.record_timeline_event("workspace", "workspace_activated", {"workspace_id": workspace_id})
|
|
618
|
+
return self._workspace_public(ws, user_id)
|
|
619
|
+
|
|
620
|
+
def workspace_summary(self, workspace_id: str, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
621
|
+
state = self.load_state()
|
|
622
|
+
ws = (state.get("workspaces") or {}).get(workspace_id)
|
|
623
|
+
if not ws:
|
|
624
|
+
raise FileNotFoundError(workspace_id)
|
|
625
|
+
public = self._workspace_public(ws, user_id)
|
|
626
|
+
public["counts"] = {
|
|
627
|
+
"snapshots": len(self._scoped(_listify(state.get("snapshots")), workspace_id)),
|
|
628
|
+
"memories": len(self._scoped(_listify(state.get("memories")), workspace_id)),
|
|
629
|
+
"agent_runs": len(self._scoped(_listify(state.get("agent_runs")), workspace_id)),
|
|
630
|
+
"workflows": len(self._scoped(_listify(state.get("workflows")), workspace_id)),
|
|
631
|
+
"traces": len(self._scoped(_listify(state.get("traces")), workspace_id)),
|
|
632
|
+
"timeline": len(self._scoped(_listify(state.get("timeline")), workspace_id)),
|
|
633
|
+
}
|
|
634
|
+
return public
|
|
635
|
+
|
|
278
636
|
# ------------------------------------------------------------------
|
|
279
637
|
# Onboarding
|
|
280
638
|
# ------------------------------------------------------------------
|
|
@@ -438,6 +796,7 @@ class WorkspaceOSStore:
|
|
|
438
796
|
conversation_id: Optional[str],
|
|
439
797
|
user_email: Optional[str],
|
|
440
798
|
trace: Dict[str, Any],
|
|
799
|
+
workspace_id: Optional[str] = None,
|
|
441
800
|
) -> Dict[str, Any]:
|
|
442
801
|
state = self.load_state()
|
|
443
802
|
trace_id = f"trace-{_json_hash([question, response, conversation_id, _now()])[:16]}"
|
|
@@ -447,6 +806,7 @@ class WorkspaceOSStore:
|
|
|
447
806
|
"response_preview": str(response or "")[:700],
|
|
448
807
|
"conversation_id": conversation_id,
|
|
449
808
|
"user_email": user_email,
|
|
809
|
+
"workspace_id": self._resolve_scope(workspace_id, state),
|
|
450
810
|
"created_at": _now(),
|
|
451
811
|
**trace,
|
|
452
812
|
}
|
|
@@ -456,8 +816,8 @@ class WorkspaceOSStore:
|
|
|
456
816
|
self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
|
|
457
817
|
return record
|
|
458
818
|
|
|
459
|
-
def list_traces(self, conversation_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
460
|
-
traces = _listify(self.load_state().get("traces"))
|
|
819
|
+
def list_traces(self, conversation_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
820
|
+
traces = self._scoped(_listify(self.load_state().get("traces")), workspace_id)
|
|
461
821
|
if conversation_id:
|
|
462
822
|
traces = [trace for trace in traces if trace.get("conversation_id") == conversation_id]
|
|
463
823
|
return {"traces": list(reversed(traces[-max(1, min(limit, 200)):]))}
|
|
@@ -550,7 +910,9 @@ class WorkspaceOSStore:
|
|
|
550
910
|
history: Iterable[Dict[str, Any]],
|
|
551
911
|
settings: Dict[str, Any],
|
|
552
912
|
models: Dict[str, Any],
|
|
913
|
+
workspace_id: Optional[str] = None,
|
|
553
914
|
) -> Dict[str, Any]:
|
|
915
|
+
scope = self._resolve_scope(workspace_id)
|
|
554
916
|
graph_payload = {"nodes": [], "edges": []}
|
|
555
917
|
graph_stats = {}
|
|
556
918
|
local_sources = {"sources": []}
|
|
@@ -563,7 +925,8 @@ class WorkspaceOSStore:
|
|
|
563
925
|
"version": WORKSPACE_OS_VERSION,
|
|
564
926
|
"name": name or "Workspace snapshot",
|
|
565
927
|
"created_at": _now(),
|
|
566
|
-
"workspace":
|
|
928
|
+
"workspace": scope,
|
|
929
|
+
"workspace_id": scope,
|
|
567
930
|
"graph": graph_payload,
|
|
568
931
|
"graph_stats": graph_stats,
|
|
569
932
|
"chat": chat,
|
|
@@ -581,6 +944,7 @@ class WorkspaceOSStore:
|
|
|
581
944
|
"id": snapshot_id,
|
|
582
945
|
"name": snapshot_body["name"],
|
|
583
946
|
"created_at": snapshot_body["created_at"],
|
|
947
|
+
"workspace_id": scope,
|
|
584
948
|
"path": str(path),
|
|
585
949
|
"node_count": len(graph_payload.get("nodes") or []),
|
|
586
950
|
"edge_count": len(graph_payload.get("edges") or []),
|
|
@@ -594,8 +958,8 @@ class WorkspaceOSStore:
|
|
|
594
958
|
self.record_timeline_event("snapshot", "snapshot_saved", {"snapshot_id": snapshot_id, "name": name})
|
|
595
959
|
return {"snapshot": meta}
|
|
596
960
|
|
|
597
|
-
def list_snapshots(self) -> Dict[str, Any]:
|
|
598
|
-
snapshots = _listify(self.load_state().get("snapshots"))
|
|
961
|
+
def list_snapshots(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
962
|
+
snapshots = self._scoped(_listify(self.load_state().get("snapshots")), workspace_id)
|
|
599
963
|
return {"snapshots": list(reversed(snapshots))}
|
|
600
964
|
|
|
601
965
|
def get_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
|
|
@@ -682,18 +1046,18 @@ class WorkspaceOSStore:
|
|
|
682
1046
|
},
|
|
683
1047
|
}
|
|
684
1048
|
|
|
685
|
-
def timeline(self, audit_events: Optional[Iterable[Dict[str, Any]]] = None, limit: int = 100) -> Dict[str, Any]:
|
|
1049
|
+
def timeline(self, audit_events: Optional[Iterable[Dict[str, Any]]] = None, limit: int = 100, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
686
1050
|
state = self.load_state()
|
|
687
1051
|
events: List[Dict[str, Any]] = []
|
|
688
|
-
events.extend(_listify(state.get("timeline")))
|
|
689
|
-
for snapshot in _listify(state.get("snapshots")):
|
|
690
|
-
events.append({"area": "snapshot", "event_type": "snapshot", "timestamp": snapshot.get("created_at"), "payload": snapshot})
|
|
691
|
-
for trace in _listify(state.get("traces")):
|
|
692
|
-
events.append({"area": "graph", "event_type": "answer_trace", "timestamp": trace.get("created_at"), "payload": trace})
|
|
693
|
-
for run in _listify(state.get("agent_runs")):
|
|
694
|
-
events.append({"area": "agent", "event_type": "agent_run", "timestamp": run.get("created_at"), "payload": run})
|
|
695
|
-
for workflow in _listify(state.get("workflows")):
|
|
696
|
-
events.append({"area": "workflow", "event_type": "workflow", "timestamp": workflow.get("created_at"), "payload": workflow})
|
|
1052
|
+
events.extend(self._scoped(_listify(state.get("timeline")), workspace_id))
|
|
1053
|
+
for snapshot in self._scoped(_listify(state.get("snapshots")), workspace_id):
|
|
1054
|
+
events.append({"area": "snapshot", "event_type": "snapshot", "timestamp": snapshot.get("created_at"), "workspace_id": self._record_workspace(snapshot), "payload": snapshot})
|
|
1055
|
+
for trace in self._scoped(_listify(state.get("traces")), workspace_id):
|
|
1056
|
+
events.append({"area": "graph", "event_type": "answer_trace", "timestamp": trace.get("created_at"), "workspace_id": self._record_workspace(trace), "payload": trace})
|
|
1057
|
+
for run in self._scoped(_listify(state.get("agent_runs")), workspace_id):
|
|
1058
|
+
events.append({"area": "agent", "event_type": "agent_run", "timestamp": run.get("created_at"), "workspace_id": self._record_workspace(run), "payload": run})
|
|
1059
|
+
for workflow in self._scoped(_listify(state.get("workflows")), workspace_id):
|
|
1060
|
+
events.append({"area": "workflow", "event_type": "workflow", "timestamp": workflow.get("created_at"), "workspace_id": self._record_workspace(workflow), "payload": workflow})
|
|
697
1061
|
for audit in audit_events or []:
|
|
698
1062
|
events.append({"area": "audit", "event_type": audit.get("event_type") or "audit", "timestamp": audit.get("timestamp"), "payload": audit})
|
|
699
1063
|
events.sort(key=lambda item: item.get("timestamp") or "", reverse=True)
|
|
@@ -713,6 +1077,7 @@ class WorkspaceOSStore:
|
|
|
713
1077
|
memory_id: Optional[str] = None,
|
|
714
1078
|
metadata: Optional[Dict[str, Any]] = None,
|
|
715
1079
|
graph: Any = None,
|
|
1080
|
+
workspace_id: Optional[str] = None,
|
|
716
1081
|
) -> Dict[str, Any]:
|
|
717
1082
|
if kind not in MEMORY_KINDS:
|
|
718
1083
|
raise ValueError(f"unknown memory kind: {kind}")
|
|
@@ -733,6 +1098,7 @@ class WorkspaceOSStore:
|
|
|
733
1098
|
"user_email": user_email,
|
|
734
1099
|
"tags": tags or [],
|
|
735
1100
|
"metadata": metadata or {},
|
|
1101
|
+
"workspace_id": self._resolve_scope(workspace_id, state) if existing is None else self._record_workspace(record),
|
|
736
1102
|
"updated_at": now,
|
|
737
1103
|
})
|
|
738
1104
|
if graph is not None:
|
|
@@ -754,17 +1120,17 @@ class WorkspaceOSStore:
|
|
|
754
1120
|
self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind})
|
|
755
1121
|
return record
|
|
756
1122
|
|
|
757
|
-
def list_memories(self, user_email: Optional[str] = None, kind: Optional[str] = None) -> Dict[str, Any]:
|
|
758
|
-
memories = _listify(self.load_state().get("memories"))
|
|
1123
|
+
def list_memories(self, user_email: Optional[str] = None, kind: Optional[str] = None, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1124
|
+
memories = self._scoped(_listify(self.load_state().get("memories")), workspace_id)
|
|
759
1125
|
if user_email:
|
|
760
1126
|
memories = [item for item in memories if item.get("user_email") in {None, user_email}]
|
|
761
1127
|
if kind:
|
|
762
1128
|
memories = [item for item in memories if item.get("kind") == kind]
|
|
763
1129
|
return {"memories": list(reversed(memories))}
|
|
764
1130
|
|
|
765
|
-
def search_memories(self, query: str, user_email: Optional[str] = None, limit: int = 20) -> Dict[str, Any]:
|
|
1131
|
+
def search_memories(self, query: str, user_email: Optional[str] = None, limit: int = 20, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
766
1132
|
q = str(query or "").lower().strip()
|
|
767
|
-
memories = self.list_memories(user_email=user_email).get("memories", [])
|
|
1133
|
+
memories = self.list_memories(user_email=user_email, workspace_id=workspace_id).get("memories", [])
|
|
768
1134
|
if q:
|
|
769
1135
|
memories = [
|
|
770
1136
|
item for item in memories
|
|
@@ -789,9 +1155,10 @@ class WorkspaceOSStore:
|
|
|
789
1155
|
# Agent and workflow graph
|
|
790
1156
|
# ------------------------------------------------------------------
|
|
791
1157
|
|
|
792
|
-
def list_agents(self) -> Dict[str, Any]:
|
|
1158
|
+
def list_agents(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
793
1159
|
state = self.load_state()
|
|
794
|
-
|
|
1160
|
+
runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
|
|
1161
|
+
return {"agents": _listify(state.get("agents")), "runs": list(reversed(runs[-100:]))}
|
|
795
1162
|
|
|
796
1163
|
def record_agent_run(
|
|
797
1164
|
self,
|
|
@@ -804,6 +1171,7 @@ class WorkspaceOSStore:
|
|
|
804
1171
|
timeline: Optional[List[Dict[str, Any]]] = None,
|
|
805
1172
|
relationships: Optional[List[str]] = None,
|
|
806
1173
|
graph: Any = None,
|
|
1174
|
+
workspace_id: Optional[str] = None,
|
|
807
1175
|
) -> Dict[str, Any]:
|
|
808
1176
|
state = self.load_state()
|
|
809
1177
|
run = {
|
|
@@ -813,6 +1181,7 @@ class WorkspaceOSStore:
|
|
|
813
1181
|
"input": input_text,
|
|
814
1182
|
"output_preview": output_text[:1000],
|
|
815
1183
|
"user_email": user_email,
|
|
1184
|
+
"workspace_id": self._resolve_scope(workspace_id, state),
|
|
816
1185
|
"relationships": relationships or [],
|
|
817
1186
|
"timeline": timeline or [],
|
|
818
1187
|
"created_at": _now(),
|
|
@@ -843,6 +1212,7 @@ class WorkspaceOSStore:
|
|
|
843
1212
|
user_email: Optional[str],
|
|
844
1213
|
metadata: Optional[Dict[str, Any]] = None,
|
|
845
1214
|
graph: Any = None,
|
|
1215
|
+
workspace_id: Optional[str] = None,
|
|
846
1216
|
) -> Dict[str, Any]:
|
|
847
1217
|
state = self.load_state()
|
|
848
1218
|
workflow = {
|
|
@@ -850,6 +1220,7 @@ class WorkspaceOSStore:
|
|
|
850
1220
|
"name": name or "Untitled workflow",
|
|
851
1221
|
"steps": steps,
|
|
852
1222
|
"user_email": user_email,
|
|
1223
|
+
"workspace_id": self._resolve_scope(workspace_id, state),
|
|
853
1224
|
"metadata": metadata or {},
|
|
854
1225
|
"events": [{"type": "created", "timestamp": _now()}],
|
|
855
1226
|
"created_at": _now(),
|
|
@@ -873,8 +1244,8 @@ class WorkspaceOSStore:
|
|
|
873
1244
|
self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
|
|
874
1245
|
return workflow
|
|
875
1246
|
|
|
876
|
-
def list_workflows(self, query: str = "") -> Dict[str, Any]:
|
|
877
|
-
workflows = list(reversed(_listify(self.load_state().get("workflows"))))
|
|
1247
|
+
def list_workflows(self, query: str = "", workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1248
|
+
workflows = list(reversed(self._scoped(_listify(self.load_state().get("workflows")), workspace_id)))
|
|
878
1249
|
q = str(query or "").lower().strip()
|
|
879
1250
|
if q:
|
|
880
1251
|
workflows = [
|