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.
@@ -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.7.0"
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": self._resolve_scope(workspace_id, state),
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.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status})
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
  # ------------------------------------------------------------------