ltcai 1.7.0 → 2.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 +32 -21
- package/docs/CHANGELOG.md +119 -0
- package/docs/EDITION_STRATEGY.md +10 -4
- package/docs/ENTERPRISE.md +3 -1
- package/docs/MULTI_AGENT_RUNTIME.md +428 -0
- package/docs/PLUGIN_SDK.md +664 -0
- package/docs/REALTIME_COLLABORATION.md +423 -0
- package/docs/V2_ARCHITECTURE.md +540 -0
- package/docs/WORKFLOW_DESIGNER.md +485 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +154 -0
- package/latticeai/api/marketplace.py +81 -0
- package/latticeai/api/plugins.py +115 -0
- package/latticeai/api/realtime.py +91 -0
- package/latticeai/api/workflow_designer.py +216 -0
- package/latticeai/core/marketplace.py +178 -0
- package/latticeai/core/multi_agent.py +561 -0
- package/latticeai/core/plugins.py +416 -0
- package/latticeai/core/realtime.py +190 -0
- package/latticeai/core/workflow_engine.py +329 -0
- package/latticeai/core/workspace_os.py +406 -6
- package/latticeai/server_app.py +88 -2
- package/latticeai/services/platform_runtime.py +204 -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 +136 -0
- package/static/platform.css +75 -0
- package/static/plugins.html +133 -0
- package/static/scripts/platform.js +64 -0
- package/static/workflows.html +143 -0
- package/static/workspace.html +5 -1
|
@@ -18,7 +18,7 @@ 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 = "2.1.0"
|
|
22
22
|
|
|
23
23
|
# Workspace types separate single-user Personal workspaces from shared
|
|
24
24
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -49,7 +49,9 @@ WORKSPACE_AREAS = [
|
|
|
49
49
|
"memory",
|
|
50
50
|
"agent",
|
|
51
51
|
"workflow",
|
|
52
|
+
"plugins",
|
|
52
53
|
"skills",
|
|
54
|
+
"marketplace",
|
|
53
55
|
"timeline",
|
|
54
56
|
]
|
|
55
57
|
|
|
@@ -66,6 +68,8 @@ ONBOARDING_STEPS = [
|
|
|
66
68
|
]
|
|
67
69
|
|
|
68
70
|
MEMORY_KINDS = {
|
|
71
|
+
"short_term",
|
|
72
|
+
"workspace",
|
|
69
73
|
"preferences",
|
|
70
74
|
"decisions",
|
|
71
75
|
"working_style",
|
|
@@ -73,6 +77,23 @@ MEMORY_KINDS = {
|
|
|
73
77
|
"long_term",
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
EXECUTION_EVENT_TYPES = {
|
|
81
|
+
"agent_started",
|
|
82
|
+
"handoff_created",
|
|
83
|
+
"handoff_accepted",
|
|
84
|
+
"handoff_completed",
|
|
85
|
+
"review_requested",
|
|
86
|
+
"review_approved",
|
|
87
|
+
"review_rejected",
|
|
88
|
+
"retry_requested",
|
|
89
|
+
"workflow_started",
|
|
90
|
+
"workflow_completed",
|
|
91
|
+
"plugin_started",
|
|
92
|
+
"plugin_completed",
|
|
93
|
+
"execution_failed",
|
|
94
|
+
"execution_cancelled",
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
DEFAULT_AGENTS = [
|
|
77
98
|
{
|
|
78
99
|
"id": "agent:planner",
|
|
@@ -164,7 +185,7 @@ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
|
164
185
|
class WorkspaceOSStore:
|
|
165
186
|
"""Local-first state store for Workspace OS APIs."""
|
|
166
187
|
|
|
167
|
-
def __init__(self, data_dir: Path | str):
|
|
188
|
+
def __init__(self, data_dir: Path | str, *, event_sink: Optional[Callable[[Dict[str, Any]], Any]] = None):
|
|
168
189
|
self.data_dir = Path(data_dir).expanduser()
|
|
169
190
|
self.state_path = self.data_dir / "workspace_os.json"
|
|
170
191
|
self.snapshots_dir = self.data_dir / "workspace_snapshots"
|
|
@@ -172,6 +193,10 @@ class WorkspaceOSStore:
|
|
|
172
193
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
173
194
|
self.snapshots_dir.mkdir(parents=True, exist_ok=True)
|
|
174
195
|
self.exports_dir.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
# Optional realtime hook: fired on every timeline event so the Realtime
|
|
197
|
+
# bus receives all workspace activity without per-call wiring.
|
|
198
|
+
# Defaults to None → zero behavior change for existing callers/tests.
|
|
199
|
+
self.event_sink = event_sink
|
|
175
200
|
|
|
176
201
|
@staticmethod
|
|
177
202
|
def _new_workspace_record(
|
|
@@ -273,6 +298,18 @@ class WorkspaceOSStore:
|
|
|
273
298
|
"local_computer_memory": False,
|
|
274
299
|
"organization_workspaces": True,
|
|
275
300
|
"enterprise_seam": True,
|
|
301
|
+
"plugin_sdk": True,
|
|
302
|
+
"workflow_designer": True,
|
|
303
|
+
"multi_agent_runtime": True,
|
|
304
|
+
"realtime_collaboration": True,
|
|
305
|
+
"agent_handoff": True,
|
|
306
|
+
"agent_context_packets": True,
|
|
307
|
+
"review_retry_loops": True,
|
|
308
|
+
"timeline_replay": True,
|
|
309
|
+
"agent_memory": True,
|
|
310
|
+
"agent_planning": True,
|
|
311
|
+
"marketplace_foundation": True,
|
|
312
|
+
"realtime_execution_observability": True,
|
|
276
313
|
},
|
|
277
314
|
"onboarding": {
|
|
278
315
|
"completed": False,
|
|
@@ -291,10 +328,15 @@ class WorkspaceOSStore:
|
|
|
291
328
|
"snapshots": [],
|
|
292
329
|
"traces": [],
|
|
293
330
|
"memories": [],
|
|
331
|
+
"memory_snapshots": [],
|
|
294
332
|
"agents": list(DEFAULT_AGENTS),
|
|
295
333
|
"agent_runs": [],
|
|
334
|
+
"handoffs": [],
|
|
296
335
|
"workflows": [],
|
|
336
|
+
"workflow_runs": [],
|
|
297
337
|
"skill_registry": {},
|
|
338
|
+
"plugin_registry": {},
|
|
339
|
+
"template_registry": {},
|
|
298
340
|
"computer_memory": {
|
|
299
341
|
"enabled": False,
|
|
300
342
|
"approved": False,
|
|
@@ -342,8 +384,46 @@ class WorkspaceOSStore:
|
|
|
342
384
|
state.setdefault("timeline", []).append(event)
|
|
343
385
|
state["timeline"] = state["timeline"][-500:]
|
|
344
386
|
self.save_state(state)
|
|
387
|
+
if self.event_sink is not None:
|
|
388
|
+
try:
|
|
389
|
+
self.event_sink(event)
|
|
390
|
+
except Exception:
|
|
391
|
+
# Realtime delivery is best-effort and must never break a write.
|
|
392
|
+
pass
|
|
345
393
|
return event
|
|
346
394
|
|
|
395
|
+
def _emit_execution_event(
|
|
396
|
+
self,
|
|
397
|
+
*,
|
|
398
|
+
area: str,
|
|
399
|
+
event_type: str,
|
|
400
|
+
payload: Dict[str, Any],
|
|
401
|
+
workspace_id: Optional[str],
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Best-effort execution observability event for the realtime feed."""
|
|
404
|
+
if event_type not in EXECUTION_EVENT_TYPES:
|
|
405
|
+
return
|
|
406
|
+
try:
|
|
407
|
+
self.record_timeline_event(area, event_type, payload, workspace_id=workspace_id)
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
def _emit_replayable_timeline_events(
|
|
412
|
+
self,
|
|
413
|
+
*,
|
|
414
|
+
area: str,
|
|
415
|
+
run_id: str,
|
|
416
|
+
timeline: List[Dict[str, Any]],
|
|
417
|
+
workspace_id: Optional[str],
|
|
418
|
+
) -> None:
|
|
419
|
+
for index, item in enumerate(timeline or []):
|
|
420
|
+
event_type = item.get("event") or item.get("event_type")
|
|
421
|
+
if event_type in EXECUTION_EVENT_TYPES:
|
|
422
|
+
payload = {k: v for k, v in item.items() if k not in {"context_packet"}}
|
|
423
|
+
payload["run_id"] = run_id
|
|
424
|
+
payload["timeline_index"] = index
|
|
425
|
+
self._emit_execution_event(area=area, event_type=event_type, payload=payload, workspace_id=workspace_id)
|
|
426
|
+
|
|
347
427
|
def summary(self) -> Dict[str, Any]:
|
|
348
428
|
state = self.load_state()
|
|
349
429
|
return {
|
|
@@ -358,9 +438,14 @@ class WorkspaceOSStore:
|
|
|
358
438
|
"snapshots": len(_listify(state.get("snapshots"))),
|
|
359
439
|
"traces": len(_listify(state.get("traces"))),
|
|
360
440
|
"memories": len(_listify(state.get("memories"))),
|
|
441
|
+
"memory_snapshots": len(_listify(state.get("memory_snapshots"))),
|
|
361
442
|
"agent_runs": len(_listify(state.get("agent_runs"))),
|
|
443
|
+
"handoffs": len(_listify(state.get("handoffs"))),
|
|
362
444
|
"workflows": len(_listify(state.get("workflows"))),
|
|
445
|
+
"workflow_runs": len(_listify(state.get("workflow_runs"))),
|
|
363
446
|
"skills": len(state.get("skill_registry") or {}),
|
|
447
|
+
"plugins": len(state.get("plugin_registry") or {}),
|
|
448
|
+
"templates": len(state.get("template_registry") or {}),
|
|
364
449
|
"timeline": len(_listify(state.get("timeline"))),
|
|
365
450
|
},
|
|
366
451
|
"onboarding": state.get("onboarding"),
|
|
@@ -627,8 +712,11 @@ class WorkspaceOSStore:
|
|
|
627
712
|
public["counts"] = {
|
|
628
713
|
"snapshots": len(self._scoped(_listify(state.get("snapshots")), workspace_id)),
|
|
629
714
|
"memories": len(self._scoped(_listify(state.get("memories")), workspace_id)),
|
|
715
|
+
"memory_snapshots": len(self._scoped(_listify(state.get("memory_snapshots")), workspace_id)),
|
|
630
716
|
"agent_runs": len(self._scoped(_listify(state.get("agent_runs")), workspace_id)),
|
|
717
|
+
"handoffs": len(self._scoped(_listify(state.get("handoffs")), workspace_id)),
|
|
631
718
|
"workflows": len(self._scoped(_listify(state.get("workflows")), workspace_id)),
|
|
719
|
+
"workflow_runs": len(self._scoped(_listify(state.get("workflow_runs")), workspace_id)),
|
|
632
720
|
"traces": len(self._scoped(_listify(state.get("traces")), workspace_id)),
|
|
633
721
|
"timeline": len(self._scoped(_listify(state.get("timeline")), workspace_id)),
|
|
634
722
|
}
|
|
@@ -1098,7 +1186,7 @@ class WorkspaceOSStore:
|
|
|
1098
1186
|
"content": content,
|
|
1099
1187
|
"user_email": user_email,
|
|
1100
1188
|
"tags": tags or [],
|
|
1101
|
-
"metadata": metadata or {},
|
|
1189
|
+
"metadata": {**(metadata or {}), "memory_scope": kind},
|
|
1102
1190
|
"workspace_id": self._resolve_scope(workspace_id, state) if existing is None else self._record_workspace(record),
|
|
1103
1191
|
"updated_at": now,
|
|
1104
1192
|
})
|
|
@@ -1118,7 +1206,7 @@ class WorkspaceOSStore:
|
|
|
1118
1206
|
memories.append(record)
|
|
1119
1207
|
state["memories"] = memories[-500:]
|
|
1120
1208
|
self.save_state(state)
|
|
1121
|
-
self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind})
|
|
1209
|
+
self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind}, workspace_id=record.get("workspace_id"))
|
|
1122
1210
|
return record
|
|
1123
1211
|
|
|
1124
1212
|
def list_memories(self, user_email: Optional[str] = None, kind: Optional[str] = None, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
@@ -1152,6 +1240,44 @@ class WorkspaceOSStore:
|
|
|
1152
1240
|
self.record_timeline_event("memory", "memory_deleted", {"memory_id": memory_id})
|
|
1153
1241
|
return {"status": "ok", "memory_id": memory_id}
|
|
1154
1242
|
|
|
1243
|
+
def create_memory_snapshot(
|
|
1244
|
+
self,
|
|
1245
|
+
*,
|
|
1246
|
+
label: str = "memory snapshot",
|
|
1247
|
+
user_email: Optional[str] = None,
|
|
1248
|
+
workspace_id: Optional[str] = None,
|
|
1249
|
+
memory_ids: Optional[List[str]] = None,
|
|
1250
|
+
reason: str = "",
|
|
1251
|
+
) -> Dict[str, Any]:
|
|
1252
|
+
"""Persist a replayable point-in-time memory view without mutation."""
|
|
1253
|
+
state = self.load_state()
|
|
1254
|
+
scope = self._resolve_scope(workspace_id, state)
|
|
1255
|
+
memories = self._scoped(_listify(state.get("memories")), scope)
|
|
1256
|
+
if user_email:
|
|
1257
|
+
memories = [item for item in memories if item.get("user_email") in {None, user_email}]
|
|
1258
|
+
if memory_ids:
|
|
1259
|
+
allowed = set(memory_ids)
|
|
1260
|
+
memories = [item for item in memories if item.get("id") in allowed]
|
|
1261
|
+
snapshot = {
|
|
1262
|
+
"id": f"memory-snapshot-{_json_hash([label, scope, memories, _now()])[:16]}",
|
|
1263
|
+
"label": label,
|
|
1264
|
+
"reason": reason,
|
|
1265
|
+
"workspace_id": scope,
|
|
1266
|
+
"user_email": user_email,
|
|
1267
|
+
"memory_count": len(memories),
|
|
1268
|
+
"memories": memories,
|
|
1269
|
+
"created_at": _now(),
|
|
1270
|
+
}
|
|
1271
|
+
state.setdefault("memory_snapshots", []).append(snapshot)
|
|
1272
|
+
state["memory_snapshots"] = state["memory_snapshots"][-200:]
|
|
1273
|
+
self.save_state(state)
|
|
1274
|
+
self.record_timeline_event("memory", "memory_snapshot", {"snapshot_id": snapshot["id"], "memory_count": len(memories)}, workspace_id=scope)
|
|
1275
|
+
return snapshot
|
|
1276
|
+
|
|
1277
|
+
def list_memory_snapshots(self, workspace_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
1278
|
+
snapshots = self._scoped(_listify(self.load_state().get("memory_snapshots")), workspace_id)
|
|
1279
|
+
return {"snapshots": list(reversed(snapshots[-max(1, min(limit, 200)):]))}
|
|
1280
|
+
|
|
1155
1281
|
# ------------------------------------------------------------------
|
|
1156
1282
|
# Agent and workflow graph
|
|
1157
1283
|
# ------------------------------------------------------------------
|
|
@@ -1171,10 +1297,18 @@ class WorkspaceOSStore:
|
|
|
1171
1297
|
user_email: Optional[str],
|
|
1172
1298
|
timeline: Optional[List[Dict[str, Any]]] = None,
|
|
1173
1299
|
relationships: Optional[List[str]] = None,
|
|
1300
|
+
handoffs: Optional[List[Dict[str, Any]]] = None,
|
|
1301
|
+
context_packets: Optional[List[Dict[str, Any]]] = None,
|
|
1302
|
+
plan: Optional[List[Dict[str, Any]]] = None,
|
|
1303
|
+
plan_review: Optional[Dict[str, Any]] = None,
|
|
1304
|
+
review_history: Optional[List[Dict[str, Any]]] = None,
|
|
1305
|
+
retry_history: Optional[List[Dict[str, Any]]] = None,
|
|
1306
|
+
memory_snapshots: Optional[List[Dict[str, Any]]] = None,
|
|
1174
1307
|
graph: Any = None,
|
|
1175
1308
|
workspace_id: Optional[str] = None,
|
|
1176
1309
|
) -> Dict[str, Any]:
|
|
1177
1310
|
state = self.load_state()
|
|
1311
|
+
resolved_workspace = self._resolve_scope(workspace_id, state)
|
|
1178
1312
|
run = {
|
|
1179
1313
|
"id": f"agent-run-{_json_hash([agent_id, input_text, output_text, _now()])[:16]}",
|
|
1180
1314
|
"agent_id": agent_id,
|
|
@@ -1182,9 +1316,16 @@ class WorkspaceOSStore:
|
|
|
1182
1316
|
"input": input_text,
|
|
1183
1317
|
"output_preview": output_text[:1000],
|
|
1184
1318
|
"user_email": user_email,
|
|
1185
|
-
"workspace_id":
|
|
1319
|
+
"workspace_id": resolved_workspace,
|
|
1186
1320
|
"relationships": relationships or [],
|
|
1187
1321
|
"timeline": timeline or [],
|
|
1322
|
+
"handoffs": handoffs or [],
|
|
1323
|
+
"context_packets": context_packets or [],
|
|
1324
|
+
"plan": plan or [],
|
|
1325
|
+
"plan_review": plan_review or {},
|
|
1326
|
+
"review_history": review_history or [],
|
|
1327
|
+
"retry_history": retry_history or [],
|
|
1328
|
+
"memory_snapshots": memory_snapshots or [],
|
|
1188
1329
|
"created_at": _now(),
|
|
1189
1330
|
}
|
|
1190
1331
|
if graph is not None:
|
|
@@ -1199,12 +1340,37 @@ class WorkspaceOSStore:
|
|
|
1199
1340
|
run["graph_node_id"] = ingested.get("node_id")
|
|
1200
1341
|
except Exception as exc:
|
|
1201
1342
|
run["graph_error"] = str(exc)
|
|
1343
|
+
if handoffs:
|
|
1344
|
+
stored_handoffs = state.setdefault("handoffs", [])
|
|
1345
|
+
for handoff in handoffs:
|
|
1346
|
+
stored = {
|
|
1347
|
+
**handoff,
|
|
1348
|
+
"run_id": run["id"],
|
|
1349
|
+
"workspace_id": resolved_workspace,
|
|
1350
|
+
}
|
|
1351
|
+
stored_handoffs.append(stored)
|
|
1352
|
+
state["handoffs"] = stored_handoffs[-500:]
|
|
1202
1353
|
state.setdefault("agent_runs", []).append(run)
|
|
1203
1354
|
state["agent_runs"] = state["agent_runs"][-300:]
|
|
1204
1355
|
self.save_state(state)
|
|
1205
|
-
self.
|
|
1356
|
+
self._emit_replayable_timeline_events(area="agent", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
|
|
1357
|
+
if status == "failed":
|
|
1358
|
+
self._emit_execution_event(area="agent", event_type="execution_failed", payload={"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
|
|
1359
|
+
self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
|
|
1360
|
+
return run
|
|
1361
|
+
|
|
1362
|
+
def get_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1363
|
+
run = next((item for item in _listify(self.load_state().get("agent_runs")) if item.get("id") == run_id), None)
|
|
1364
|
+
if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
1365
|
+
raise FileNotFoundError(run_id)
|
|
1206
1366
|
return run
|
|
1207
1367
|
|
|
1368
|
+
def list_handoffs(self, workspace_id: Optional[str] = None, run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1369
|
+
handoffs = self._scoped(_listify(self.load_state().get("handoffs")), workspace_id)
|
|
1370
|
+
if run_id:
|
|
1371
|
+
handoffs = [item for item in handoffs if item.get("run_id") == run_id]
|
|
1372
|
+
return {"handoffs": list(reversed(handoffs[-200:]))}
|
|
1373
|
+
|
|
1208
1374
|
def create_workflow(
|
|
1209
1375
|
self,
|
|
1210
1376
|
*,
|
|
@@ -1214,6 +1380,7 @@ class WorkspaceOSStore:
|
|
|
1214
1380
|
metadata: Optional[Dict[str, Any]] = None,
|
|
1215
1381
|
graph: Any = None,
|
|
1216
1382
|
workspace_id: Optional[str] = None,
|
|
1383
|
+
nodes: Optional[List[Dict[str, Any]]] = None,
|
|
1217
1384
|
) -> Dict[str, Any]:
|
|
1218
1385
|
state = self.load_state()
|
|
1219
1386
|
workflow = {
|
|
@@ -1227,6 +1394,10 @@ class WorkspaceOSStore:
|
|
|
1227
1394
|
"created_at": _now(),
|
|
1228
1395
|
"updated_at": _now(),
|
|
1229
1396
|
}
|
|
1397
|
+
# Workflow Designer stores a typed-node graph alongside the legacy
|
|
1398
|
+
# ``steps`` list so older history keeps working and new editors get nodes.
|
|
1399
|
+
if nodes is not None:
|
|
1400
|
+
workflow["nodes"] = nodes
|
|
1230
1401
|
if graph is not None:
|
|
1231
1402
|
try:
|
|
1232
1403
|
ingested = graph.ingest_event(
|
|
@@ -1245,6 +1416,161 @@ class WorkspaceOSStore:
|
|
|
1245
1416
|
self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
|
|
1246
1417
|
return workflow
|
|
1247
1418
|
|
|
1419
|
+
def record_workflow_run(
|
|
1420
|
+
self,
|
|
1421
|
+
*,
|
|
1422
|
+
workflow_id: Optional[str],
|
|
1423
|
+
name: str,
|
|
1424
|
+
status: str,
|
|
1425
|
+
timeline: List[Dict[str, Any]],
|
|
1426
|
+
outputs: Optional[Dict[str, Any]] = None,
|
|
1427
|
+
user_email: Optional[str] = None,
|
|
1428
|
+
graph: Any = None,
|
|
1429
|
+
workspace_id: Optional[str] = None,
|
|
1430
|
+
) -> Dict[str, Any]:
|
|
1431
|
+
"""Persist a Workflow Designer execution into local-first run history."""
|
|
1432
|
+
state = self.load_state()
|
|
1433
|
+
resolved_workspace = self._resolve_scope(workspace_id, state)
|
|
1434
|
+
run = {
|
|
1435
|
+
"id": f"workflow-run-{_json_hash([workflow_id, name, status, _now()])[:16]}",
|
|
1436
|
+
"workflow_id": workflow_id,
|
|
1437
|
+
"name": name or "workflow",
|
|
1438
|
+
"status": status,
|
|
1439
|
+
"timeline": timeline or [],
|
|
1440
|
+
"outputs": outputs or {},
|
|
1441
|
+
"user_email": user_email,
|
|
1442
|
+
"workspace_id": resolved_workspace,
|
|
1443
|
+
"created_at": _now(),
|
|
1444
|
+
}
|
|
1445
|
+
if graph is not None:
|
|
1446
|
+
try:
|
|
1447
|
+
ingested = graph.ingest_event(
|
|
1448
|
+
"WorkflowRun",
|
|
1449
|
+
f"{run['name']} {status}",
|
|
1450
|
+
user_email=user_email,
|
|
1451
|
+
source="workspace_os",
|
|
1452
|
+
metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status},
|
|
1453
|
+
)
|
|
1454
|
+
run["graph_node_id"] = ingested.get("node_id")
|
|
1455
|
+
except Exception as exc:
|
|
1456
|
+
run["graph_error"] = str(exc)
|
|
1457
|
+
state.setdefault("workflow_runs", []).append(run)
|
|
1458
|
+
state["workflow_runs"] = state["workflow_runs"][-300:]
|
|
1459
|
+
# Attach the run id to the workflow's event log for cross-linking.
|
|
1460
|
+
for wf in _listify(state.get("workflows")):
|
|
1461
|
+
if wf.get("id") == workflow_id:
|
|
1462
|
+
wf.setdefault("events", []).append({"type": "run", "timestamp": _now(), "payload": {"run_id": run["id"], "status": status}})
|
|
1463
|
+
wf["updated_at"] = _now()
|
|
1464
|
+
break
|
|
1465
|
+
self.save_state(state)
|
|
1466
|
+
self._emit_execution_event(area="workflow", event_type="workflow_started", payload={"run_id": run["id"], "workflow_id": workflow_id, "name": name}, workspace_id=resolved_workspace)
|
|
1467
|
+
self._emit_replayable_timeline_events(area="workflow", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
|
|
1468
|
+
if status == "failed":
|
|
1469
|
+
self._emit_execution_event(area="workflow", event_type="execution_failed", payload={"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
1470
|
+
elif status in {"ok", "partial"}:
|
|
1471
|
+
self._emit_execution_event(area="workflow", event_type="workflow_completed", payload={"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
1472
|
+
self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
1473
|
+
return run
|
|
1474
|
+
|
|
1475
|
+
def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1476
|
+
runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
|
|
1477
|
+
if workflow_id:
|
|
1478
|
+
runs = [run for run in runs if run.get("workflow_id") == workflow_id]
|
|
1479
|
+
return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
|
|
1480
|
+
|
|
1481
|
+
def get_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1482
|
+
run = next((item for item in _listify(self.load_state().get("workflow_runs")) if item.get("id") == run_id), None)
|
|
1483
|
+
if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
1484
|
+
raise FileNotFoundError(run_id)
|
|
1485
|
+
return run
|
|
1486
|
+
|
|
1487
|
+
@staticmethod
|
|
1488
|
+
def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
|
|
1489
|
+
frames = []
|
|
1490
|
+
for index, item in enumerate(run.get("timeline") or []):
|
|
1491
|
+
event = item.get("event") or item.get("event_type") or item.get("type") or "event"
|
|
1492
|
+
actor = (
|
|
1493
|
+
item.get("agent_id")
|
|
1494
|
+
or item.get("role")
|
|
1495
|
+
or item.get("source_agent")
|
|
1496
|
+
or item.get("target_agent")
|
|
1497
|
+
or item.get("node")
|
|
1498
|
+
or kind
|
|
1499
|
+
)
|
|
1500
|
+
result = item.get("result") if "result" in item else item.get("output")
|
|
1501
|
+
decision = item.get("outcome") or item.get("verdict") or item.get("status")
|
|
1502
|
+
frames.append({
|
|
1503
|
+
"index": index,
|
|
1504
|
+
"event": event,
|
|
1505
|
+
"actor": actor,
|
|
1506
|
+
"when": item.get("timestamp") or item.get("started_at") or run.get("created_at"),
|
|
1507
|
+
"why": item.get("reason") or item.get("note") or item.get("name") or "",
|
|
1508
|
+
"input": item.get("context_packet") or item.get("trigger") or run.get("input"),
|
|
1509
|
+
"output": result,
|
|
1510
|
+
"decision": decision,
|
|
1511
|
+
"raw": item,
|
|
1512
|
+
})
|
|
1513
|
+
return frames
|
|
1514
|
+
|
|
1515
|
+
def replay_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1516
|
+
run = self.get_agent_run(run_id, workspace_id=workspace_id)
|
|
1517
|
+
return {
|
|
1518
|
+
"kind": "agent",
|
|
1519
|
+
"run_id": run_id,
|
|
1520
|
+
"status": run.get("status"),
|
|
1521
|
+
"workspace_id": self._record_workspace(run),
|
|
1522
|
+
"replayable": True,
|
|
1523
|
+
"frames": self._replay_frames(run, kind="agent"),
|
|
1524
|
+
"handoffs": run.get("handoffs") or [],
|
|
1525
|
+
"context_packets": run.get("context_packets") or [],
|
|
1526
|
+
"review_history": run.get("review_history") or [],
|
|
1527
|
+
"retry_history": run.get("retry_history") or [],
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
def replay_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1531
|
+
run = self.get_workflow_run(run_id, workspace_id=workspace_id)
|
|
1532
|
+
return {
|
|
1533
|
+
"kind": "workflow",
|
|
1534
|
+
"run_id": run_id,
|
|
1535
|
+
"status": run.get("status"),
|
|
1536
|
+
"workspace_id": self._record_workspace(run),
|
|
1537
|
+
"replayable": True,
|
|
1538
|
+
"frames": self._replay_frames(run, kind="workflow"),
|
|
1539
|
+
"outputs": run.get("outputs") or {},
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
def get_workflow(self, workflow_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1543
|
+
workflow = next((wf for wf in _listify(self.load_state().get("workflows")) if wf.get("id") == workflow_id), None)
|
|
1544
|
+
if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
|
|
1545
|
+
raise FileNotFoundError(workflow_id)
|
|
1546
|
+
return workflow
|
|
1547
|
+
|
|
1548
|
+
def update_workflow_definition(
|
|
1549
|
+
self,
|
|
1550
|
+
workflow_id: str,
|
|
1551
|
+
*,
|
|
1552
|
+
name: Optional[str] = None,
|
|
1553
|
+
nodes: Optional[List[Dict[str, Any]]] = None,
|
|
1554
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1555
|
+
workspace_id: Optional[str] = None,
|
|
1556
|
+
) -> Dict[str, Any]:
|
|
1557
|
+
"""Edit a stored workflow's node graph / name without losing its history."""
|
|
1558
|
+
state = self.load_state()
|
|
1559
|
+
workflow = next((wf for wf in _listify(state.get("workflows")) if wf.get("id") == workflow_id), None)
|
|
1560
|
+
if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
|
|
1561
|
+
raise FileNotFoundError(workflow_id)
|
|
1562
|
+
if name is not None and str(name).strip():
|
|
1563
|
+
workflow["name"] = str(name).strip()
|
|
1564
|
+
if nodes is not None:
|
|
1565
|
+
workflow["nodes"] = nodes
|
|
1566
|
+
if metadata is not None:
|
|
1567
|
+
workflow["metadata"] = {**(workflow.get("metadata") or {}), **metadata}
|
|
1568
|
+
workflow.setdefault("events", []).append({"type": "edited", "timestamp": _now()})
|
|
1569
|
+
workflow["updated_at"] = _now()
|
|
1570
|
+
self.save_state(state)
|
|
1571
|
+
self.record_timeline_event("workflow", "workflow_edited", {"workflow_id": workflow_id})
|
|
1572
|
+
return workflow
|
|
1573
|
+
|
|
1248
1574
|
def list_workflows(self, query: str = "", workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1249
1575
|
workflows = list(reversed(self._scoped(_listify(self.load_state().get("workflows")), workspace_id)))
|
|
1250
1576
|
q = str(query or "").lower().strip()
|
|
@@ -1490,6 +1816,80 @@ class WorkspaceOSStore:
|
|
|
1490
1816
|
self.record_timeline_event("skills", "skill_uninstalled", {"skill": skill})
|
|
1491
1817
|
return entry
|
|
1492
1818
|
|
|
1819
|
+
# ------------------------------------------------------------------
|
|
1820
|
+
# Plugin SDK registry — mirrors the skill registry contract.
|
|
1821
|
+
# ------------------------------------------------------------------
|
|
1822
|
+
|
|
1823
|
+
def list_plugin_registry(self) -> Dict[str, Any]:
|
|
1824
|
+
return dict(self.load_state().get("plugin_registry") or {})
|
|
1825
|
+
|
|
1826
|
+
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> Dict[str, Any]:
|
|
1827
|
+
state = self.load_state()
|
|
1828
|
+
entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
|
|
1829
|
+
entry["enabled"] = bool(enabled)
|
|
1830
|
+
entry["updated_at"] = _now()
|
|
1831
|
+
self.save_state(state)
|
|
1832
|
+
self.record_timeline_event("plugins", "plugin_enabled" if enabled else "plugin_disabled", {"plugin": plugin_id})
|
|
1833
|
+
return entry
|
|
1834
|
+
|
|
1835
|
+
def mark_plugin_installed(self, plugin_id: str, *, version: str = "0.0.0", metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1836
|
+
state = self.load_state()
|
|
1837
|
+
entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
|
|
1838
|
+
entry.update({
|
|
1839
|
+
"id": plugin_id,
|
|
1840
|
+
"installed": True,
|
|
1841
|
+
"enabled": entry.get("enabled", True),
|
|
1842
|
+
"version": version,
|
|
1843
|
+
"install_status": "ready",
|
|
1844
|
+
"validation_status": "valid",
|
|
1845
|
+
"metadata": metadata or entry.get("metadata") or {},
|
|
1846
|
+
"updated_at": _now(),
|
|
1847
|
+
})
|
|
1848
|
+
self.save_state(state)
|
|
1849
|
+
self.record_timeline_event("plugins", "plugin_installed", {"plugin": plugin_id, "version": version})
|
|
1850
|
+
return entry
|
|
1851
|
+
|
|
1852
|
+
def mark_plugin_uninstalled(self, plugin_id: str) -> Dict[str, Any]:
|
|
1853
|
+
state = self.load_state()
|
|
1854
|
+
entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
|
|
1855
|
+
entry.update({"installed": False, "enabled": False, "updated_at": _now()})
|
|
1856
|
+
self.save_state(state)
|
|
1857
|
+
self.record_timeline_event("plugins", "plugin_uninstalled", {"plugin": plugin_id})
|
|
1858
|
+
return {"status": "ok", "plugin_id": plugin_id, "registry": entry}
|
|
1859
|
+
|
|
1860
|
+
# ------------------------------------------------------------------
|
|
1861
|
+
# Marketplace template registry (v2.1 foundation)
|
|
1862
|
+
# ------------------------------------------------------------------
|
|
1863
|
+
|
|
1864
|
+
def list_template_registry(self) -> Dict[str, Any]:
|
|
1865
|
+
return dict(self.load_state().get("template_registry") or {})
|
|
1866
|
+
|
|
1867
|
+
def mark_template_installed(
|
|
1868
|
+
self,
|
|
1869
|
+
*,
|
|
1870
|
+
kind: str,
|
|
1871
|
+
template_id: str,
|
|
1872
|
+
version: str = "1.0.0",
|
|
1873
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1874
|
+
workspace_id: Optional[str] = None,
|
|
1875
|
+
) -> Dict[str, Any]:
|
|
1876
|
+
state = self.load_state()
|
|
1877
|
+
scope = self._resolve_scope(workspace_id, state)
|
|
1878
|
+
key = f"{kind}:{template_id}"
|
|
1879
|
+
entry = state.setdefault("template_registry", {}).setdefault(key, {"id": template_id, "kind": kind})
|
|
1880
|
+
entry.update({
|
|
1881
|
+
"id": template_id,
|
|
1882
|
+
"kind": kind,
|
|
1883
|
+
"version": version,
|
|
1884
|
+
"installed": True,
|
|
1885
|
+
"workspace_id": scope,
|
|
1886
|
+
"metadata": metadata or entry.get("metadata") or {},
|
|
1887
|
+
"updated_at": _now(),
|
|
1888
|
+
})
|
|
1889
|
+
self.save_state(state)
|
|
1890
|
+
self.record_timeline_event("marketplace", "template_installed", {"kind": kind, "template_id": template_id}, workspace_id=scope)
|
|
1891
|
+
return entry
|
|
1892
|
+
|
|
1493
1893
|
# ------------------------------------------------------------------
|
|
1494
1894
|
# Audit timeline
|
|
1495
1895
|
# ------------------------------------------------------------------
|