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,329 @@
1
+ """Workflow engine — typed-node workflow definitions, validation, and a
2
+ deterministic execution interpreter with full run observability.
3
+
4
+ A workflow is a small directed graph of *nodes* starting from a ``trigger``.
5
+ Each node has a ``type`` (:data:`NODE_TYPES`), a ``config`` blob, and a ``next``
6
+ pointer (or a list of branches for ``condition`` nodes). The engine walks the
7
+ graph from the trigger, dispatching each node to an injected *runner* and
8
+ recording a step-by-step timeline so a run can be inspected, replayed, and
9
+ linked into the Workspace timeline / Knowledge Graph.
10
+
11
+ The engine is pure logic with injected runners, mirroring
12
+ :class:`latticeai.core.agent.AgentRuntime`:
13
+
14
+ * production wires runners that call the real tool registry, skill registry,
15
+ plugin registry, and multi-agent orchestrator;
16
+ * tests pass fakes and drive a full trigger→...→output run with no server,
17
+ no LLM, and no network.
18
+
19
+ Backward compatibility: legacy workflows persisted as a flat ``steps`` list
20
+ (pre-2.0) still validate and run — :func:`normalize_definition` lifts them into
21
+ a linear node chain so existing workflow history keeps working.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from typing import Any, Callable, Dict, List, Optional
29
+
30
+
31
+ WORKFLOW_ENGINE_VERSION = "2.0.0"
32
+
33
+ # The node vocabulary a workflow can be built from. ``trigger`` and ``output``
34
+ # are structural; the rest dispatch to an injected runner of the same family.
35
+ NODE_TYPES = (
36
+ "trigger",
37
+ "tool",
38
+ "skill",
39
+ "plugin",
40
+ "agent",
41
+ "condition",
42
+ "output",
43
+ )
44
+
45
+ # Which runner family handles each executable node type.
46
+ _RUNNER_FOR = {
47
+ "tool": "tool",
48
+ "skill": "skill",
49
+ "plugin": "plugin",
50
+ "agent": "agent",
51
+ }
52
+
53
+ _MAX_STEPS = 100 # hard cap so a mis-wired ``next`` cycle can never hang a run.
54
+
55
+
56
+ class WorkflowError(Exception):
57
+ """Raised for invalid workflow definitions."""
58
+
59
+
60
+ def _now() -> str:
61
+ return datetime.now().isoformat(timespec="seconds")
62
+
63
+
64
+ def normalize_definition(workflow: Dict[str, Any]) -> Dict[str, Any]:
65
+ """Return a node-based definition, lifting legacy ``steps`` lists if needed.
66
+
67
+ Never mutates the input. A legacy ``{"steps": [...]}`` workflow becomes a
68
+ linear ``trigger -> tool... -> output`` node chain so it validates and runs
69
+ under the v2.0 engine without rewriting stored history.
70
+ """
71
+ nodes = workflow.get("nodes")
72
+ if isinstance(nodes, list) and nodes:
73
+ return {
74
+ "id": workflow.get("id"),
75
+ "name": workflow.get("name") or "Untitled workflow",
76
+ "nodes": nodes,
77
+ "metadata": workflow.get("metadata") or {},
78
+ }
79
+
80
+ steps = workflow.get("steps") or []
81
+ lifted: List[Dict[str, Any]] = [{
82
+ "id": "trigger",
83
+ "type": "trigger",
84
+ "name": "Start",
85
+ "config": {"trigger": "manual"},
86
+ "next": "step-0" if steps else "output",
87
+ }]
88
+ for index, step in enumerate(steps):
89
+ action = str(step.get("action") or "tool") if isinstance(step, dict) else "tool"
90
+ nxt = f"step-{index + 1}" if index + 1 < len(steps) else "output"
91
+ lifted.append({
92
+ "id": f"step-{index}",
93
+ "type": "tool",
94
+ "name": action,
95
+ "config": {"tool": action, "args": step if isinstance(step, dict) else {"value": step}},
96
+ "next": nxt,
97
+ })
98
+ lifted.append({"id": "output", "type": "output", "name": "Output", "config": {}, "next": None})
99
+ return {
100
+ "id": workflow.get("id"),
101
+ "name": workflow.get("name") or "Untitled workflow",
102
+ "nodes": lifted,
103
+ "metadata": {**(workflow.get("metadata") or {}), "lifted_from_steps": True},
104
+ }
105
+
106
+
107
+ def validate_definition(workflow: Dict[str, Any]) -> List[str]:
108
+ """Return a list of validation errors ([] means valid)."""
109
+ errors: List[str] = []
110
+ definition = normalize_definition(workflow)
111
+ nodes = definition["nodes"]
112
+ if not isinstance(nodes, list) or not nodes:
113
+ return ["workflow has no nodes"]
114
+
115
+ ids = [node.get("id") for node in nodes]
116
+ if len(set(ids)) != len(ids):
117
+ errors.append("duplicate node ids")
118
+ id_set = {nid for nid in ids if nid}
119
+
120
+ triggers = [node for node in nodes if node.get("type") == "trigger"]
121
+ if not triggers:
122
+ errors.append("workflow must have a trigger node")
123
+ elif len(triggers) > 1:
124
+ errors.append("workflow must have exactly one trigger node")
125
+
126
+ for node in nodes:
127
+ nid = node.get("id")
128
+ ntype = node.get("type")
129
+ if not nid:
130
+ errors.append("node missing id")
131
+ if ntype not in NODE_TYPES:
132
+ errors.append(f"node '{nid}': unknown type '{ntype}'")
133
+ # Validate edges point at real nodes (None terminates a branch).
134
+ targets: List[Any] = []
135
+ if ntype == "condition":
136
+ branches = node.get("branches") or {}
137
+ if not isinstance(branches, dict) or not branches:
138
+ errors.append(f"condition node '{nid}' must define branches (e.g. true/false)")
139
+ else:
140
+ targets.extend(branches.values())
141
+ else:
142
+ targets.append(node.get("next"))
143
+ for target in targets:
144
+ if target is not None and target not in id_set:
145
+ errors.append(f"node '{nid}' points at unknown node '{target}'")
146
+ return errors
147
+
148
+
149
+ def _entry_node(nodes: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
150
+ for node in nodes:
151
+ if node.get("type") == "trigger":
152
+ return node
153
+ return nodes[0] if nodes else None
154
+
155
+
156
+ def _evaluate_condition(config: Dict[str, Any], context: Dict[str, Any]) -> bool:
157
+ """Safe condition evaluation — NO eval. Compares a context value to a literal.
158
+
159
+ config: ``{"left": "<context key>", "op": "==|!=|>|<|>=|<=|contains|truthy",
160
+ "right": <literal>}``. Unknown keys / ops resolve to ``False`` so a
161
+ mis-configured condition fails closed onto the ``false`` branch.
162
+ """
163
+ left_key = config.get("left")
164
+ op = str(config.get("op") or "truthy")
165
+ right = config.get("right")
166
+ left = context.get(left_key) if left_key in context else config.get("left_value")
167
+ try:
168
+ if op == "truthy":
169
+ return bool(left)
170
+ if op == "==":
171
+ return left == right
172
+ if op == "!=":
173
+ return left != right
174
+ if op == "contains":
175
+ return right in left # type: ignore[operator]
176
+ if op in (">", "<", ">=", "<="):
177
+ lf, rf = float(left), float(right) # type: ignore[arg-type]
178
+ return {">": lf > rf, "<": lf < rf, ">=": lf >= rf, "<=": lf <= rf}[op]
179
+ except Exception:
180
+ return False
181
+ return False
182
+
183
+
184
+ @dataclass
185
+ class WorkflowRun:
186
+ workflow_id: Optional[str]
187
+ name: str
188
+ status: str = "ok" # ok | failed | partial
189
+ timeline: List[Dict[str, Any]] = field(default_factory=list)
190
+ outputs: Dict[str, Any] = field(default_factory=dict)
191
+ started_at: str = field(default_factory=_now)
192
+ finished_at: Optional[str] = None
193
+
194
+ def as_dict(self) -> Dict[str, Any]:
195
+ return {
196
+ "workflow_id": self.workflow_id,
197
+ "name": self.name,
198
+ "status": self.status,
199
+ "timeline": self.timeline,
200
+ "outputs": self.outputs,
201
+ "started_at": self.started_at,
202
+ "finished_at": self.finished_at,
203
+ "step_count": len(self.timeline),
204
+ }
205
+
206
+
207
+ class WorkflowEngine:
208
+ """Interprets a validated workflow definition over injected runners.
209
+
210
+ ``runners`` maps a family ("tool" / "skill" / "plugin" / "agent") to a
211
+ callable ``runner(node, context) -> Any``. A missing runner records the
212
+ node as ``skipped`` with a reason rather than failing the whole run, so a
213
+ workflow that references a capability the host has not wired degrades
214
+ gracefully (and the gap is visible in the timeline).
215
+ """
216
+
217
+ def __init__(self, runners: Optional[Dict[str, Callable[..., Any]]] = None):
218
+ self.runners = runners or {}
219
+
220
+ def run(self, workflow: Dict[str, Any], *, inputs: Optional[Dict[str, Any]] = None) -> WorkflowRun:
221
+ definition = normalize_definition(workflow)
222
+ errors = validate_definition(definition)
223
+ run = WorkflowRun(workflow_id=definition.get("id"), name=definition.get("name") or "workflow")
224
+ if errors:
225
+ run.status = "failed"
226
+ run.timeline.append({"node": None, "type": "validation", "status": "failed", "errors": errors, "timestamp": _now()})
227
+ run.finished_at = _now()
228
+ return run
229
+
230
+ nodes = {node["id"]: node for node in definition["nodes"]}
231
+ context: Dict[str, Any] = {"inputs": inputs or {}, **(inputs or {})}
232
+
233
+ current = _entry_node(definition["nodes"])
234
+ steps = 0
235
+ had_error = False
236
+ had_skip = False
237
+ while current is not None and steps < _MAX_STEPS:
238
+ steps += 1
239
+ ntype = current.get("type")
240
+ nid = current.get("id")
241
+ entry: Dict[str, Any] = {
242
+ "node": nid,
243
+ "type": ntype,
244
+ "name": current.get("name") or nid,
245
+ "timestamp": _now(),
246
+ }
247
+
248
+ if ntype == "trigger":
249
+ entry["status"] = "ok"
250
+ entry["trigger"] = (current.get("config") or {}).get("trigger", "manual")
251
+ run.timeline.append(entry)
252
+ current = nodes.get(current.get("next")) if current.get("next") else None
253
+ continue
254
+
255
+ if ntype == "output":
256
+ entry["status"] = "ok"
257
+ payload = (current.get("config") or {}).get("value")
258
+ entry["output"] = payload if payload is not None else context.get("last_output")
259
+ run.outputs[nid] = entry["output"]
260
+ run.timeline.append(entry)
261
+ current = nodes.get(current.get("next")) if current.get("next") else None
262
+ continue
263
+
264
+ if ntype == "condition":
265
+ result = _evaluate_condition(current.get("config") or {}, context)
266
+ entry["status"] = "ok"
267
+ entry["result"] = result
268
+ run.timeline.append(entry)
269
+ branches = current.get("branches") or {}
270
+ target = branches.get("true" if result else "false")
271
+ current = nodes.get(target) if target else None
272
+ continue
273
+
274
+ # Executable node → dispatch to its runner family.
275
+ family = _RUNNER_FOR.get(ntype)
276
+ runner = self.runners.get(family) if family else None
277
+ if runner is None:
278
+ entry["status"] = "skipped"
279
+ entry["reason"] = f"no '{family}' runner configured"
280
+ had_skip = True
281
+ run.timeline.append(entry)
282
+ current = nodes.get(current.get("next")) if current.get("next") else None
283
+ continue
284
+ try:
285
+ result = runner(node=current, context=context)
286
+ entry["status"] = "ok"
287
+ entry["result"] = result
288
+ context["last_output"] = result
289
+ context[nid] = result
290
+ except Exception as exc:
291
+ entry["status"] = "error"
292
+ entry["reason"] = str(exc)
293
+ had_error = True
294
+ run.timeline.append(entry)
295
+ current = nodes.get(current.get("next")) if current.get("next") else None
296
+
297
+ if steps >= _MAX_STEPS:
298
+ run.timeline.append({"node": None, "type": "guard", "status": "error", "reason": f"exceeded {_MAX_STEPS} steps (cycle?)", "timestamp": _now()})
299
+ had_error = True
300
+
301
+ run.status = "failed" if had_error else ("partial" if had_skip else "ok")
302
+ run.finished_at = _now()
303
+ return run
304
+
305
+
306
+ def export_workflow(workflow: Dict[str, Any]) -> Dict[str, Any]:
307
+ """Portable JSON representation (definition only — no run history / scope)."""
308
+ definition = normalize_definition(workflow)
309
+ return {
310
+ "lattice_workflow_export": WORKFLOW_ENGINE_VERSION,
311
+ "name": definition.get("name"),
312
+ "nodes": definition.get("nodes"),
313
+ "metadata": {k: v for k, v in (definition.get("metadata") or {}).items() if k != "lifted_from_steps"},
314
+ }
315
+
316
+
317
+ def import_workflow(data: Dict[str, Any]) -> Dict[str, Any]:
318
+ """Validate an exported workflow and return a definition ready to persist."""
319
+ if not isinstance(data, dict):
320
+ raise WorkflowError("import payload must be a JSON object")
321
+ definition = {
322
+ "name": data.get("name") or "Imported workflow",
323
+ "nodes": data.get("nodes") or [],
324
+ "metadata": {**(data.get("metadata") or {}), "imported": True},
325
+ }
326
+ errors = validate_definition(definition)
327
+ if errors:
328
+ raise WorkflowError("; ".join(errors))
329
+ return definition
@@ -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.6.0"
21
+ WORKSPACE_OS_VERSION = "2.0.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,6 +49,7 @@ WORKSPACE_AREAS = [
49
49
  "memory",
50
50
  "agent",
51
51
  "workflow",
52
+ "plugins",
52
53
  "skills",
53
54
  "timeline",
54
55
  ]
@@ -164,7 +165,7 @@ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
164
165
  class WorkspaceOSStore:
165
166
  """Local-first state store for Workspace OS APIs."""
166
167
 
167
- def __init__(self, data_dir: Path | str):
168
+ def __init__(self, data_dir: Path | str, *, event_sink: Optional[Callable[[Dict[str, Any]], Any]] = None):
168
169
  self.data_dir = Path(data_dir).expanduser()
169
170
  self.state_path = self.data_dir / "workspace_os.json"
170
171
  self.snapshots_dir = self.data_dir / "workspace_snapshots"
@@ -172,6 +173,10 @@ class WorkspaceOSStore:
172
173
  self.data_dir.mkdir(parents=True, exist_ok=True)
173
174
  self.snapshots_dir.mkdir(parents=True, exist_ok=True)
174
175
  self.exports_dir.mkdir(parents=True, exist_ok=True)
176
+ # Optional realtime hook: fired on every timeline event so the Realtime
177
+ # bus (v2.0) receives all workspace activity without per-call wiring.
178
+ # Defaults to None → zero behavior change for existing callers/tests.
179
+ self.event_sink = event_sink
175
180
 
176
181
  @staticmethod
177
182
  def _new_workspace_record(
@@ -273,6 +278,10 @@ class WorkspaceOSStore:
273
278
  "local_computer_memory": False,
274
279
  "organization_workspaces": True,
275
280
  "enterprise_seam": True,
281
+ "plugin_sdk": True,
282
+ "workflow_designer": True,
283
+ "multi_agent_runtime": True,
284
+ "realtime_collaboration": True,
276
285
  },
277
286
  "onboarding": {
278
287
  "completed": False,
@@ -294,7 +303,9 @@ class WorkspaceOSStore:
294
303
  "agents": list(DEFAULT_AGENTS),
295
304
  "agent_runs": [],
296
305
  "workflows": [],
306
+ "workflow_runs": [],
297
307
  "skill_registry": {},
308
+ "plugin_registry": {},
298
309
  "computer_memory": {
299
310
  "enabled": False,
300
311
  "approved": False,
@@ -342,6 +353,12 @@ class WorkspaceOSStore:
342
353
  state.setdefault("timeline", []).append(event)
343
354
  state["timeline"] = state["timeline"][-500:]
344
355
  self.save_state(state)
356
+ if self.event_sink is not None:
357
+ try:
358
+ self.event_sink(event)
359
+ except Exception:
360
+ # Realtime delivery is best-effort and must never break a write.
361
+ pass
345
362
  return event
346
363
 
347
364
  def summary(self) -> Dict[str, Any]:
@@ -353,13 +370,16 @@ class WorkspaceOSStore:
353
370
  "workspaces": state.get("workspaces"),
354
371
  "navigation": list(WORKSPACE_AREAS),
355
372
  "feature_flags": state.get("feature_flags"),
373
+ "updated_at": state.get("updated_at"),
356
374
  "counts": {
357
375
  "snapshots": len(_listify(state.get("snapshots"))),
358
376
  "traces": len(_listify(state.get("traces"))),
359
377
  "memories": len(_listify(state.get("memories"))),
360
378
  "agent_runs": len(_listify(state.get("agent_runs"))),
361
379
  "workflows": len(_listify(state.get("workflows"))),
380
+ "workflow_runs": len(_listify(state.get("workflow_runs"))),
362
381
  "skills": len(state.get("skill_registry") or {}),
382
+ "plugins": len(state.get("plugin_registry") or {}),
363
383
  "timeline": len(_listify(state.get("timeline"))),
364
384
  },
365
385
  "onboarding": state.get("onboarding"),
@@ -628,6 +648,7 @@ class WorkspaceOSStore:
628
648
  "memories": len(self._scoped(_listify(state.get("memories")), workspace_id)),
629
649
  "agent_runs": len(self._scoped(_listify(state.get("agent_runs")), workspace_id)),
630
650
  "workflows": len(self._scoped(_listify(state.get("workflows")), workspace_id)),
651
+ "workflow_runs": len(self._scoped(_listify(state.get("workflow_runs")), workspace_id)),
631
652
  "traces": len(self._scoped(_listify(state.get("traces")), workspace_id)),
632
653
  "timeline": len(self._scoped(_listify(state.get("timeline")), workspace_id)),
633
654
  }
@@ -1213,6 +1234,7 @@ class WorkspaceOSStore:
1213
1234
  metadata: Optional[Dict[str, Any]] = None,
1214
1235
  graph: Any = None,
1215
1236
  workspace_id: Optional[str] = None,
1237
+ nodes: Optional[List[Dict[str, Any]]] = None,
1216
1238
  ) -> Dict[str, Any]:
1217
1239
  state = self.load_state()
1218
1240
  workflow = {
@@ -1226,6 +1248,10 @@ class WorkspaceOSStore:
1226
1248
  "created_at": _now(),
1227
1249
  "updated_at": _now(),
1228
1250
  }
1251
+ # v2.0 Workflow Designer stores a typed-node graph alongside the legacy
1252
+ # ``steps`` list so older history keeps working and new editors get nodes.
1253
+ if nodes is not None:
1254
+ workflow["nodes"] = nodes
1229
1255
  if graph is not None:
1230
1256
  try:
1231
1257
  ingested = graph.ingest_event(
@@ -1244,6 +1270,93 @@ class WorkspaceOSStore:
1244
1270
  self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
1245
1271
  return workflow
1246
1272
 
1273
+ def record_workflow_run(
1274
+ self,
1275
+ *,
1276
+ workflow_id: Optional[str],
1277
+ name: str,
1278
+ status: str,
1279
+ timeline: List[Dict[str, Any]],
1280
+ outputs: Optional[Dict[str, Any]] = None,
1281
+ user_email: Optional[str] = None,
1282
+ graph: Any = None,
1283
+ workspace_id: Optional[str] = None,
1284
+ ) -> Dict[str, Any]:
1285
+ """Persist a Workflow Designer execution into local-first run history."""
1286
+ state = self.load_state()
1287
+ run = {
1288
+ "id": f"workflow-run-{_json_hash([workflow_id, name, status, _now()])[:16]}",
1289
+ "workflow_id": workflow_id,
1290
+ "name": name or "workflow",
1291
+ "status": status,
1292
+ "timeline": timeline or [],
1293
+ "outputs": outputs or {},
1294
+ "user_email": user_email,
1295
+ "workspace_id": self._resolve_scope(workspace_id, state),
1296
+ "created_at": _now(),
1297
+ }
1298
+ if graph is not None:
1299
+ try:
1300
+ ingested = graph.ingest_event(
1301
+ "WorkflowRun",
1302
+ f"{run['name']} {status}",
1303
+ user_email=user_email,
1304
+ source="workspace_os",
1305
+ metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status},
1306
+ )
1307
+ run["graph_node_id"] = ingested.get("node_id")
1308
+ except Exception as exc:
1309
+ run["graph_error"] = str(exc)
1310
+ state.setdefault("workflow_runs", []).append(run)
1311
+ state["workflow_runs"] = state["workflow_runs"][-300:]
1312
+ # Attach the run id to the workflow's event log for cross-linking.
1313
+ for wf in _listify(state.get("workflows")):
1314
+ if wf.get("id") == workflow_id:
1315
+ wf.setdefault("events", []).append({"type": "run", "timestamp": _now(), "payload": {"run_id": run["id"], "status": status}})
1316
+ wf["updated_at"] = _now()
1317
+ break
1318
+ self.save_state(state)
1319
+ self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status})
1320
+ return run
1321
+
1322
+ def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1323
+ runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
1324
+ if workflow_id:
1325
+ runs = [run for run in runs if run.get("workflow_id") == workflow_id]
1326
+ return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
1327
+
1328
+ def get_workflow(self, workflow_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
1329
+ workflow = next((wf for wf in _listify(self.load_state().get("workflows")) if wf.get("id") == workflow_id), None)
1330
+ if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
1331
+ raise FileNotFoundError(workflow_id)
1332
+ return workflow
1333
+
1334
+ def update_workflow_definition(
1335
+ self,
1336
+ workflow_id: str,
1337
+ *,
1338
+ name: Optional[str] = None,
1339
+ nodes: Optional[List[Dict[str, Any]]] = None,
1340
+ metadata: Optional[Dict[str, Any]] = None,
1341
+ workspace_id: Optional[str] = None,
1342
+ ) -> Dict[str, Any]:
1343
+ """Edit a stored workflow's node graph / name without losing its history."""
1344
+ state = self.load_state()
1345
+ workflow = next((wf for wf in _listify(state.get("workflows")) if wf.get("id") == workflow_id), None)
1346
+ if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
1347
+ raise FileNotFoundError(workflow_id)
1348
+ if name is not None and str(name).strip():
1349
+ workflow["name"] = str(name).strip()
1350
+ if nodes is not None:
1351
+ workflow["nodes"] = nodes
1352
+ if metadata is not None:
1353
+ workflow["metadata"] = {**(workflow.get("metadata") or {}), **metadata}
1354
+ workflow.setdefault("events", []).append({"type": "edited", "timestamp": _now()})
1355
+ workflow["updated_at"] = _now()
1356
+ self.save_state(state)
1357
+ self.record_timeline_event("workflow", "workflow_edited", {"workflow_id": workflow_id})
1358
+ return workflow
1359
+
1247
1360
  def list_workflows(self, query: str = "", workspace_id: Optional[str] = None) -> Dict[str, Any]:
1248
1361
  workflows = list(reversed(self._scoped(_listify(self.load_state().get("workflows")), workspace_id)))
1249
1362
  q = str(query or "").lower().strip()
@@ -1424,6 +1537,9 @@ class WorkspaceOSStore:
1424
1537
  "description": desc,
1425
1538
  "version": version,
1426
1539
  "installed": True,
1540
+ "install_status": entry.get("install_status") or "ready",
1541
+ "validation_status": "ready" if skill_md.exists() else "missing_manifest",
1542
+ "source": entry.get("source") or "local",
1427
1543
  "path": str(skill_dir),
1428
1544
  "updated_at": entry.get("updated_at") or _now(),
1429
1545
  })
@@ -1438,6 +1554,9 @@ class WorkspaceOSStore:
1438
1554
  **item,
1439
1555
  "enabled": bool(state_entry.get("enabled", True)),
1440
1556
  "installed": bool(state_entry.get("installed")),
1557
+ "install_status": state_entry.get("install_status") or ("ready" if state_entry.get("installed") else "available"),
1558
+ "validation_status": state_entry.get("validation_status") or item.get("validation_status") or ("ready" if state_entry.get("installed") else "not_installed"),
1559
+ "source": state_entry.get("source") or item.get("source") or item.get("plugin") or "marketplace",
1441
1560
  "version": state_entry.get("version") or item.get("version") or "remote",
1442
1561
  })
1443
1562
  self.save_state(state)
@@ -1465,6 +1584,9 @@ class WorkspaceOSStore:
1465
1584
  "installed": True,
1466
1585
  "enabled": entry.get("enabled", True),
1467
1586
  "version": version,
1587
+ "install_status": "ready",
1588
+ "validation_status": "ready",
1589
+ "source": (metadata or {}).get("source") or entry.get("source") or "marketplace",
1468
1590
  "metadata": metadata or entry.get("metadata") or {},
1469
1591
  "updated_at": _now(),
1470
1592
  })
@@ -1480,6 +1602,47 @@ class WorkspaceOSStore:
1480
1602
  self.record_timeline_event("skills", "skill_uninstalled", {"skill": skill})
1481
1603
  return entry
1482
1604
 
1605
+ # ------------------------------------------------------------------
1606
+ # Plugin SDK registry (v2.0) — mirrors the skill registry contract.
1607
+ # ------------------------------------------------------------------
1608
+
1609
+ def list_plugin_registry(self) -> Dict[str, Any]:
1610
+ return dict(self.load_state().get("plugin_registry") or {})
1611
+
1612
+ def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> Dict[str, Any]:
1613
+ state = self.load_state()
1614
+ entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
1615
+ entry["enabled"] = bool(enabled)
1616
+ entry["updated_at"] = _now()
1617
+ self.save_state(state)
1618
+ self.record_timeline_event("plugins", "plugin_enabled" if enabled else "plugin_disabled", {"plugin": plugin_id})
1619
+ return entry
1620
+
1621
+ def mark_plugin_installed(self, plugin_id: str, *, version: str = "0.0.0", metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1622
+ state = self.load_state()
1623
+ entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
1624
+ entry.update({
1625
+ "id": plugin_id,
1626
+ "installed": True,
1627
+ "enabled": entry.get("enabled", True),
1628
+ "version": version,
1629
+ "install_status": "ready",
1630
+ "validation_status": "valid",
1631
+ "metadata": metadata or entry.get("metadata") or {},
1632
+ "updated_at": _now(),
1633
+ })
1634
+ self.save_state(state)
1635
+ self.record_timeline_event("plugins", "plugin_installed", {"plugin": plugin_id, "version": version})
1636
+ return entry
1637
+
1638
+ def mark_plugin_uninstalled(self, plugin_id: str) -> Dict[str, Any]:
1639
+ state = self.load_state()
1640
+ entry = state.setdefault("plugin_registry", {}).setdefault(plugin_id, {"id": plugin_id})
1641
+ entry.update({"installed": False, "enabled": False, "updated_at": _now()})
1642
+ self.save_state(state)
1643
+ self.record_timeline_event("plugins", "plugin_uninstalled", {"plugin": plugin_id})
1644
+ return {"status": "ok", "plugin_id": plugin_id, "registry": entry}
1645
+
1483
1646
  # ------------------------------------------------------------------
1484
1647
  # Audit timeline
1485
1648
  # ------------------------------------------------------------------