ltcai 1.7.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.
- package/README.md +31 -21
- package/docs/CHANGELOG.md +65 -0
- package/docs/EDITION_STRATEGY.md +10 -4
- package/docs/ENTERPRISE.md +3 -1
- package/docs/MULTI_AGENT_RUNTIME.md +410 -0
- package/docs/PLUGIN_SDK.md +651 -0
- package/docs/REALTIME_COLLABORATION.md +410 -0
- package/docs/V2_ARCHITECTURE.md +528 -0
- package/docs/WORKFLOW_DESIGNER.md +475 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +98 -0
- package/latticeai/api/plugins.py +115 -0
- package/latticeai/api/realtime.py +91 -0
- package/latticeai/api/workflow_designer.py +207 -0
- package/latticeai/core/multi_agent.py +270 -0
- package/latticeai/core/plugins.py +400 -0
- package/latticeai/core/realtime.py +190 -0
- package/latticeai/core/workflow_engine.py +329 -0
- package/latticeai/core/workspace_os.py +155 -2
- package/latticeai/server_app.py +76 -2
- package/latticeai/services/platform_runtime.py +200 -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 +92 -0
- package/static/platform.css +75 -0
- package/static/plugins.html +82 -0
- package/static/scripts/platform.js +64 -0
- package/static/workflows.html +121 -0
- package/static/workspace.html +5 -1
|
@@ -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 = "
|
|
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]:
|
|
@@ -360,7 +377,9 @@ class WorkspaceOSStore:
|
|
|
360
377
|
"memories": len(_listify(state.get("memories"))),
|
|
361
378
|
"agent_runs": len(_listify(state.get("agent_runs"))),
|
|
362
379
|
"workflows": len(_listify(state.get("workflows"))),
|
|
380
|
+
"workflow_runs": len(_listify(state.get("workflow_runs"))),
|
|
363
381
|
"skills": len(state.get("skill_registry") or {}),
|
|
382
|
+
"plugins": len(state.get("plugin_registry") or {}),
|
|
364
383
|
"timeline": len(_listify(state.get("timeline"))),
|
|
365
384
|
},
|
|
366
385
|
"onboarding": state.get("onboarding"),
|
|
@@ -629,6 +648,7 @@ class WorkspaceOSStore:
|
|
|
629
648
|
"memories": len(self._scoped(_listify(state.get("memories")), workspace_id)),
|
|
630
649
|
"agent_runs": len(self._scoped(_listify(state.get("agent_runs")), workspace_id)),
|
|
631
650
|
"workflows": len(self._scoped(_listify(state.get("workflows")), workspace_id)),
|
|
651
|
+
"workflow_runs": len(self._scoped(_listify(state.get("workflow_runs")), workspace_id)),
|
|
632
652
|
"traces": len(self._scoped(_listify(state.get("traces")), workspace_id)),
|
|
633
653
|
"timeline": len(self._scoped(_listify(state.get("timeline")), workspace_id)),
|
|
634
654
|
}
|
|
@@ -1214,6 +1234,7 @@ class WorkspaceOSStore:
|
|
|
1214
1234
|
metadata: Optional[Dict[str, Any]] = None,
|
|
1215
1235
|
graph: Any = None,
|
|
1216
1236
|
workspace_id: Optional[str] = None,
|
|
1237
|
+
nodes: Optional[List[Dict[str, Any]]] = None,
|
|
1217
1238
|
) -> Dict[str, Any]:
|
|
1218
1239
|
state = self.load_state()
|
|
1219
1240
|
workflow = {
|
|
@@ -1227,6 +1248,10 @@ class WorkspaceOSStore:
|
|
|
1227
1248
|
"created_at": _now(),
|
|
1228
1249
|
"updated_at": _now(),
|
|
1229
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
|
|
1230
1255
|
if graph is not None:
|
|
1231
1256
|
try:
|
|
1232
1257
|
ingested = graph.ingest_event(
|
|
@@ -1245,6 +1270,93 @@ class WorkspaceOSStore:
|
|
|
1245
1270
|
self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
|
|
1246
1271
|
return workflow
|
|
1247
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
|
+
|
|
1248
1360
|
def list_workflows(self, query: str = "", workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1249
1361
|
workflows = list(reversed(self._scoped(_listify(self.load_state().get("workflows")), workspace_id)))
|
|
1250
1362
|
q = str(query or "").lower().strip()
|
|
@@ -1490,6 +1602,47 @@ class WorkspaceOSStore:
|
|
|
1490
1602
|
self.record_timeline_event("skills", "skill_uninstalled", {"skill": skill})
|
|
1491
1603
|
return entry
|
|
1492
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
|
+
|
|
1493
1646
|
# ------------------------------------------------------------------
|
|
1494
1647
|
# Audit timeline
|
|
1495
1648
|
# ------------------------------------------------------------------
|
package/latticeai/server_app.py
CHANGED
|
@@ -91,8 +91,16 @@ from latticeai.services.model_runtime import (
|
|
|
91
91
|
verify_cloud_models,
|
|
92
92
|
ensure_ollama_server,
|
|
93
93
|
)
|
|
94
|
-
from latticeai.api.workspace import create_workspace_router
|
|
94
|
+
from latticeai.api.workspace import create_workspace_router, _workspace_scope_from_request
|
|
95
95
|
from latticeai.api.health import create_health_router
|
|
96
|
+
# ── v2.0 Agentic Workspace Platform layers ───────────────────────────────────
|
|
97
|
+
from latticeai.core.plugins import PluginRegistry
|
|
98
|
+
from latticeai.core.realtime import RealtimeBus
|
|
99
|
+
from latticeai.services.platform_runtime import PlatformRuntime
|
|
100
|
+
from latticeai.api.plugins import create_plugins_router
|
|
101
|
+
from latticeai.api.workflow_designer import create_workflow_designer_router
|
|
102
|
+
from latticeai.api.agents import create_agents_router
|
|
103
|
+
from latticeai.api.realtime import create_realtime_router
|
|
96
104
|
from latticeai.api.models import create_models_router
|
|
97
105
|
from latticeai.api.chat import create_chat_router
|
|
98
106
|
from latticeai.api.tools import create_tools_router
|
|
@@ -236,10 +244,16 @@ AUDIT_FILE = DATA_DIR / "audit_log.json"
|
|
|
236
244
|
SSO_FILE = DATA_DIR / "sso_config.json"
|
|
237
245
|
KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
|
|
238
246
|
LOCAL_KG_WATCHER = LocalKnowledgeWatcher(lambda: KNOWLEDGE_GRAPH) if ENABLE_GRAPH else None
|
|
239
|
-
|
|
247
|
+
# ── v2.0 Realtime bus: constructed first so the store can fan every timeline
|
|
248
|
+
# event into the realtime feed via a single additive sink (no per-call wiring).
|
|
249
|
+
REALTIME_BUS = RealtimeBus()
|
|
250
|
+
WORKSPACE_OS = WorkspaceOSStore(DATA_DIR, event_sink=REALTIME_BUS)
|
|
240
251
|
# Service layer (latticeai.services) wraps the store with scope/permission
|
|
241
252
|
# guardrails; routers and the app assembly share this single instance.
|
|
242
253
|
WORKSPACE_SERVICE = WorkspaceService(WORKSPACE_OS)
|
|
254
|
+
# ── v2.0 Plugin SDK registry (extends skills; discovers plugins/<id>/plugin.json)
|
|
255
|
+
PLUGINS_DIR = Path(os.getenv("LATTICEAI_PLUGINS_DIR") or (BASE_DIR / "plugins"))
|
|
256
|
+
PLUGIN_REGISTRY = PluginRegistry(PLUGINS_DIR, store=WORKSPACE_OS)
|
|
243
257
|
|
|
244
258
|
def _require_graph():
|
|
245
259
|
if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
|
|
@@ -1187,6 +1201,66 @@ app.include_router(create_workspace_router(
|
|
|
1187
1201
|
))
|
|
1188
1202
|
|
|
1189
1203
|
|
|
1204
|
+
# ── v2.0 Agentic Workspace Platform: cross-system wiring ─────────────────────
|
|
1205
|
+
# All cross-subsystem closures live in latticeai.services.platform_runtime to
|
|
1206
|
+
# keep this assembly file lean; server_app only constructs it and mounts routers.
|
|
1207
|
+
PLATFORM = PlatformRuntime(
|
|
1208
|
+
store=WORKSPACE_OS,
|
|
1209
|
+
workspace_service=WORKSPACE_SERVICE,
|
|
1210
|
+
plugin_registry=PLUGIN_REGISTRY,
|
|
1211
|
+
get_current_user=get_current_user,
|
|
1212
|
+
workspace_graph=_workspace_graph,
|
|
1213
|
+
workspace_scope_from_request=_workspace_scope_from_request,
|
|
1214
|
+
get_tool_permission=get_tool_permission,
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
app.include_router(create_plugins_router(
|
|
1218
|
+
registry=PLUGIN_REGISTRY,
|
|
1219
|
+
require_user=require_user,
|
|
1220
|
+
require_admin=require_admin,
|
|
1221
|
+
append_audit_event=append_audit_event,
|
|
1222
|
+
register_skill=PLATFORM.register_plugin_skill,
|
|
1223
|
+
plugin_runners_factory=lambda: PLATFORM.plugin_capability_runners(None, None),
|
|
1224
|
+
ui_file_response=ui_file_response,
|
|
1225
|
+
static_dir=STATIC_DIR,
|
|
1226
|
+
))
|
|
1227
|
+
|
|
1228
|
+
app.include_router(create_workflow_designer_router(
|
|
1229
|
+
store=WORKSPACE_OS,
|
|
1230
|
+
require_user=require_user,
|
|
1231
|
+
get_current_user=get_current_user,
|
|
1232
|
+
gate_read=PLATFORM.gate_read,
|
|
1233
|
+
gate_write=PLATFORM.gate_write,
|
|
1234
|
+
workspace_graph=_workspace_graph,
|
|
1235
|
+
build_runners=PLATFORM.build_workflow_runners,
|
|
1236
|
+
append_audit_event=append_audit_event,
|
|
1237
|
+
ui_file_response=ui_file_response,
|
|
1238
|
+
static_dir=STATIC_DIR,
|
|
1239
|
+
))
|
|
1240
|
+
|
|
1241
|
+
app.include_router(create_agents_router(
|
|
1242
|
+
store=WORKSPACE_OS,
|
|
1243
|
+
orchestrator_factory=PLATFORM.build_orchestrator,
|
|
1244
|
+
require_user=require_user,
|
|
1245
|
+
get_current_user=get_current_user,
|
|
1246
|
+
gate_read=PLATFORM.gate_read,
|
|
1247
|
+
gate_write=PLATFORM.gate_write,
|
|
1248
|
+
workspace_graph=_workspace_graph,
|
|
1249
|
+
append_audit_event=append_audit_event,
|
|
1250
|
+
ui_file_response=ui_file_response,
|
|
1251
|
+
static_dir=STATIC_DIR,
|
|
1252
|
+
))
|
|
1253
|
+
|
|
1254
|
+
app.include_router(create_realtime_router(
|
|
1255
|
+
bus=REALTIME_BUS,
|
|
1256
|
+
require_user=require_user,
|
|
1257
|
+
get_current_user=get_current_user,
|
|
1258
|
+
allowed_scopes=PLATFORM.allowed_scopes,
|
|
1259
|
+
ui_file_response=ui_file_response,
|
|
1260
|
+
static_dir=STATIC_DIR,
|
|
1261
|
+
))
|
|
1262
|
+
|
|
1263
|
+
|
|
1190
1264
|
# ── Health & Info ──────────────────────────────────────────────────────────────
|
|
1191
1265
|
|
|
1192
1266
|
# ── Model runtime/provider helpers moved to latticeai.services.model_runtime ──
|