ltcai 4.3.1 → 4.4.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 +191 -278
- package/docs/CHANGELOG.md +128 -0
- package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +174 -0
- package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +81 -0
- package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +75 -0
- package/docs/V4_3_2_GRAPH_UX_REPORT.md +48 -0
- package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +209 -0
- package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +57 -0
- package/docs/V4_3_2_SELF_AUDIT_REPORT.md +63 -0
- package/docs/V4_3_2_VALIDATION_REPORT.md +97 -0
- package/docs/V4_3_3_VALIDATION_REPORT.md +46 -0
- package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +18 -19
- package/frontend/openapi.json +1 -1
- package/frontend/src/components/primitives.tsx +92 -10
- package/frontend/src/pages/Act.tsx +11 -9
- package/frontend/src/pages/Ask.tsx +2 -2
- package/frontend/src/pages/Brain.tsx +607 -65
- package/frontend/src/pages/Capture.tsx +11 -7
- package/frontend/src/pages/Library.tsx +3 -3
- package/frontend/src/pages/System.tsx +186 -23
- package/lattice_brain/__init__.py +38 -23
- package/lattice_brain/_kg_common.py +11 -1
- package/lattice_brain/context.py +212 -2
- package/lattice_brain/conversations.py +234 -1
- package/lattice_brain/discovery.py +11 -1
- package/lattice_brain/documents.py +11 -1
- package/lattice_brain/graph/__init__.py +28 -0
- package/lattice_brain/graph/_kg_common.py +1123 -0
- package/lattice_brain/graph/curator.py +473 -0
- package/lattice_brain/graph/discovery.py +1455 -0
- package/lattice_brain/graph/documents.py +218 -0
- package/lattice_brain/graph/identity.py +175 -0
- package/lattice_brain/graph/ingest.py +644 -0
- package/lattice_brain/graph/network.py +205 -0
- package/lattice_brain/graph/projection.py +571 -0
- package/lattice_brain/graph/provenance.py +401 -0
- package/lattice_brain/graph/retrieval.py +1341 -0
- package/lattice_brain/graph/schema.py +640 -0
- package/lattice_brain/graph/store.py +237 -0
- package/lattice_brain/graph/write_master.py +225 -0
- package/lattice_brain/identity.py +11 -13
- package/lattice_brain/ingest.py +11 -1
- package/lattice_brain/ingestion.py +318 -0
- package/lattice_brain/memory.py +100 -1
- package/lattice_brain/network.py +11 -1
- package/lattice_brain/portability.py +431 -0
- package/lattice_brain/projection.py +11 -1
- package/lattice_brain/provenance.py +11 -1
- package/lattice_brain/retrieval.py +11 -1
- package/lattice_brain/runtime/__init__.py +32 -0
- package/lattice_brain/runtime/agent_runtime.py +569 -0
- package/lattice_brain/runtime/hooks.py +754 -0
- package/lattice_brain/runtime/multi_agent.py +795 -0
- package/lattice_brain/schema.py +11 -1
- package/lattice_brain/store.py +10 -2
- package/lattice_brain/workflow.py +461 -0
- package/lattice_brain/write_master.py +11 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +2 -2
- package/latticeai/api/browser.py +1 -1
- package/latticeai/api/chat.py +1 -1
- package/latticeai/api/computer_use.py +1 -1
- package/latticeai/api/hooks.py +2 -2
- package/latticeai/api/mcp.py +1 -1
- package/latticeai/api/tools.py +1 -1
- package/latticeai/api/workflow_designer.py +2 -2
- package/latticeai/app_factory.py +4 -4
- package/latticeai/brain/__init__.py +24 -6
- package/latticeai/brain/_kg_common.py +11 -1117
- package/latticeai/brain/context.py +12 -208
- package/latticeai/brain/conversations.py +12 -231
- package/latticeai/brain/discovery.py +13 -1451
- package/latticeai/brain/documents.py +13 -214
- package/latticeai/brain/identity.py +11 -169
- package/latticeai/brain/ingest.py +13 -640
- package/latticeai/brain/memory.py +12 -97
- package/latticeai/brain/network.py +12 -200
- package/latticeai/brain/projection.py +13 -567
- package/latticeai/brain/provenance.py +13 -397
- package/latticeai/brain/retrieval.py +13 -1337
- package/latticeai/brain/schema.py +12 -635
- package/latticeai/brain/store.py +13 -233
- package/latticeai/brain/write_master.py +13 -221
- package/latticeai/core/agent.py +1 -1
- package/latticeai/core/agent_registry.py +2 -2
- package/latticeai/core/builtin_hooks.py +2 -2
- package/latticeai/core/graph_curator.py +6 -468
- package/latticeai/core/hooks.py +6 -749
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +6 -790
- package/latticeai/core/workflow_engine.py +6 -456
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/agent_runtime.py +6 -564
- package/latticeai/services/ingestion.py +6 -313
- package/latticeai/services/kg_portability.py +6 -426
- package/latticeai/services/platform_runtime.py +3 -3
- package/latticeai/services/run_executor.py +1 -1
- package/latticeai/services/upload_service.py +1 -1
- package/p_reinforce.py +1 -1
- package/package.json +3 -6
- package/scripts/build_vercel_static.mjs +77 -0
- package/scripts/bump_version.py +1 -1
- package/scripts/check_markdown_links.mjs +75 -0
- package/scripts/wheel_smoke.py +7 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +12 -2
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-CHHal8Zl.css +2 -0
- package/static/app/assets/index-pdzil9ac.js +333 -0
- package/static/app/assets/index-pdzil9ac.js.map +1 -0
- package/static/app/index.html +2 -2
- package/latticeai/api/deps.py +0 -15
- package/scripts/capture/README.md +0 -28
- package/scripts/capture/capture_enterprise.js +0 -8
- package/scripts/capture/capture_graph.js +0 -8
- package/scripts/capture/capture_onboarding.js +0 -8
- package/scripts/capture/capture_page.js +0 -43
- package/scripts/capture/capture_release_media.js +0 -125
- package/scripts/capture/capture_skills.js +0 -8
- package/scripts/capture/capture_v340.js +0 -88
- package/scripts/capture/capture_workspace.js +0 -8
- package/scripts/generate_diagrams.py +0 -512
- package/scripts/release-0.3.1.sh +0 -105
- package/scripts/take_screenshots.js +0 -69
- package/static/app/assets/index-BhPuj8rT.js +0 -333
- package/static/app/assets/index-BhPuj8rT.js.map +0 -1
- package/static/app/assets/index-yZswHE3d.css +0 -2
- package/static/css/tokens.3ba22e37.css +0 -260
|
@@ -1,461 +1,11 @@
|
|
|
1
|
-
"""
|
|
2
|
-
deterministic execution interpreter with full run observability.
|
|
1
|
+
"""Compatibility shim: physically moved to ``lattice_brain.workflow``.
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
3
|
+
Aliases itself to the physical module so identity, module-level state, and
|
|
4
|
+
monkeypatching keep working through the old import path.
|
|
22
5
|
"""
|
|
23
6
|
|
|
24
|
-
|
|
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.2.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 | awaiting_approval
|
|
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
|
-
# Suspension cursor (status == awaiting_approval): the paused node, what
|
|
194
|
-
# it is waiting for, and a JSON-serializable context snapshot resume()
|
|
195
|
-
# re-enters with — completed nodes are never re-executed.
|
|
196
|
-
paused_node: Optional[str] = None
|
|
197
|
-
pending_approval: Optional[Dict[str, Any]] = None
|
|
198
|
-
paused_context: Optional[Dict[str, Any]] = None
|
|
199
|
-
|
|
200
|
-
def as_dict(self) -> Dict[str, Any]:
|
|
201
|
-
return {
|
|
202
|
-
"workflow_id": self.workflow_id,
|
|
203
|
-
"name": self.name,
|
|
204
|
-
"status": self.status,
|
|
205
|
-
"timeline": self.timeline,
|
|
206
|
-
"outputs": self.outputs,
|
|
207
|
-
"started_at": self.started_at,
|
|
208
|
-
"finished_at": self.finished_at,
|
|
209
|
-
"step_count": len(self.timeline),
|
|
210
|
-
"paused_node": self.paused_node,
|
|
211
|
-
"pending_approval": self.pending_approval,
|
|
212
|
-
"paused_context": self.paused_context,
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
class ApprovalRequired(Exception):
|
|
217
|
-
"""A node needs an explicit human decision before it may execute.
|
|
218
|
-
|
|
219
|
-
Raised by governed runners (e.g. a non-auto-approve tool). The engine
|
|
220
|
-
pauses the run into ``awaiting_approval`` with a serializable cursor —
|
|
221
|
-
it never records a fake success and never silently skips the node.
|
|
222
|
-
"""
|
|
223
|
-
|
|
224
|
-
def __init__(self, message: str, *, tool: Optional[str] = None,
|
|
225
|
-
args: Optional[Dict[str, Any]] = None,
|
|
226
|
-
permission: Optional[Dict[str, Any]] = None):
|
|
227
|
-
super().__init__(message)
|
|
228
|
-
self.tool = tool
|
|
229
|
-
self.args = args or {}
|
|
230
|
-
self.permission = permission or {}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def _json_safe(value: Any) -> Any:
|
|
234
|
-
"""Round-trip through JSON so paused context is durably serializable."""
|
|
235
|
-
import json as _json
|
|
236
|
-
|
|
237
|
-
return _json.loads(_json.dumps(value, ensure_ascii=False, default=str))
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
class WorkflowEngine:
|
|
241
|
-
"""Interprets a validated workflow definition over injected runners.
|
|
242
|
-
|
|
243
|
-
``runners`` maps a family ("tool" / "skill" / "plugin" / "agent") to a
|
|
244
|
-
callable ``runner(node, context) -> Any``. A missing runner records the
|
|
245
|
-
node as ``skipped`` with a reason rather than failing the whole run, so a
|
|
246
|
-
workflow that references a capability the host has not wired degrades
|
|
247
|
-
gracefully (and the gap is visible in the timeline).
|
|
248
|
-
|
|
249
|
-
Suspension model (v4): a runner raising :class:`ApprovalRequired` pauses
|
|
250
|
-
the run (status ``awaiting_approval``) with the node cursor and a
|
|
251
|
-
JSON-serializable context snapshot. :meth:`resume` re-enters at the
|
|
252
|
-
paused node — completed nodes are NEVER re-executed.
|
|
253
|
-
"""
|
|
254
|
-
|
|
255
|
-
def __init__(self, runners: Optional[Dict[str, Callable[..., Any]]] = None, *, hooks: Any = None):
|
|
256
|
-
self.runners = runners or {}
|
|
257
|
-
# Optional lifecycle hooks registry. When present, ``run`` fires the
|
|
258
|
-
# ``workflow`` hooks at workflow start and end so automation registered
|
|
259
|
-
# against the workflow lifecycle actually executes.
|
|
260
|
-
self.hooks = hooks
|
|
261
|
-
|
|
262
|
-
def run(self, workflow: Dict[str, Any], *, inputs: Optional[Dict[str, Any]] = None) -> WorkflowRun:
|
|
263
|
-
definition = normalize_definition(workflow)
|
|
264
|
-
errors = validate_definition(definition)
|
|
265
|
-
run = WorkflowRun(workflow_id=definition.get("id"), name=definition.get("name") or "workflow")
|
|
266
|
-
if self.hooks is not None:
|
|
267
|
-
self.hooks.fire_hook(
|
|
268
|
-
"pre_workflow", "workflow.start",
|
|
269
|
-
payload={"workflow_id": definition.get("id"), "name": definition.get("name"), "valid": not errors},
|
|
270
|
-
)
|
|
271
|
-
if errors:
|
|
272
|
-
run.status = "failed"
|
|
273
|
-
run.timeline.append({"node": None, "type": "validation", "status": "failed", "errors": errors, "timestamp": _now()})
|
|
274
|
-
run.finished_at = _now()
|
|
275
|
-
if self.hooks is not None:
|
|
276
|
-
self.hooks.fire_hook(
|
|
277
|
-
"post_workflow", "workflow.end",
|
|
278
|
-
payload={"workflow_id": definition.get("id"), "status": run.status},
|
|
279
|
-
)
|
|
280
|
-
return run
|
|
281
|
-
|
|
282
|
-
nodes = {node["id"]: node for node in definition["nodes"]}
|
|
283
|
-
context: Dict[str, Any] = {"inputs": inputs or {}, **(inputs or {})}
|
|
284
|
-
current = _entry_node(definition["nodes"])
|
|
285
|
-
return self._execute(definition, run, nodes, context, current)
|
|
286
|
-
|
|
287
|
-
def resume(
|
|
288
|
-
self,
|
|
289
|
-
workflow: Dict[str, Any],
|
|
290
|
-
*,
|
|
291
|
-
paused_node: str,
|
|
292
|
-
paused_context: Dict[str, Any],
|
|
293
|
-
approved: bool,
|
|
294
|
-
prior_timeline: Optional[List[Dict[str, Any]]] = None,
|
|
295
|
-
) -> WorkflowRun:
|
|
296
|
-
"""Re-enter a paused run at its cursor; completed nodes never re-run.
|
|
297
|
-
|
|
298
|
-
``approved=True`` marks the paused node as human-approved (its runner
|
|
299
|
-
sees the node id in ``context['__approved_nodes__']``); ``False``
|
|
300
|
-
records an explicit denial and fails the run honestly.
|
|
301
|
-
"""
|
|
302
|
-
definition = normalize_definition(workflow)
|
|
303
|
-
nodes = {node["id"]: node for node in definition["nodes"]}
|
|
304
|
-
node = nodes.get(paused_node)
|
|
305
|
-
run = WorkflowRun(workflow_id=definition.get("id"), name=definition.get("name") or "workflow")
|
|
306
|
-
if prior_timeline:
|
|
307
|
-
run.timeline.extend(prior_timeline)
|
|
308
|
-
if node is None:
|
|
309
|
-
run.status = "failed"
|
|
310
|
-
run.timeline.append({"node": paused_node, "type": "resume", "status": "failed",
|
|
311
|
-
"reason": "paused node no longer exists in the definition",
|
|
312
|
-
"timestamp": _now()})
|
|
313
|
-
run.finished_at = _now()
|
|
314
|
-
return run
|
|
315
|
-
context: Dict[str, Any] = dict(paused_context or {})
|
|
316
|
-
if not approved:
|
|
317
|
-
run.status = "failed"
|
|
318
|
-
run.timeline.append({"node": paused_node, "type": node.get("type"),
|
|
319
|
-
"name": node.get("name") or paused_node,
|
|
320
|
-
"status": "denied",
|
|
321
|
-
"reason": "approval denied by the user",
|
|
322
|
-
"timestamp": _now()})
|
|
323
|
-
run.finished_at = _now()
|
|
324
|
-
return run
|
|
325
|
-
approvals = set(context.get("__approved_nodes__") or [])
|
|
326
|
-
approvals.add(paused_node)
|
|
327
|
-
context["__approved_nodes__"] = sorted(approvals)
|
|
328
|
-
return self._execute(definition, run, nodes, context, node)
|
|
329
|
-
|
|
330
|
-
def _execute(
|
|
331
|
-
self,
|
|
332
|
-
definition: Dict[str, Any],
|
|
333
|
-
run: WorkflowRun,
|
|
334
|
-
nodes: Dict[str, Dict[str, Any]],
|
|
335
|
-
context: Dict[str, Any],
|
|
336
|
-
current: Optional[Dict[str, Any]],
|
|
337
|
-
) -> WorkflowRun:
|
|
338
|
-
steps = 0
|
|
339
|
-
had_error = False
|
|
340
|
-
had_skip = False
|
|
341
|
-
while current is not None and steps < _MAX_STEPS:
|
|
342
|
-
steps += 1
|
|
343
|
-
ntype = current.get("type")
|
|
344
|
-
nid = current.get("id")
|
|
345
|
-
entry: Dict[str, Any] = {
|
|
346
|
-
"node": nid,
|
|
347
|
-
"type": ntype,
|
|
348
|
-
"name": current.get("name") or nid,
|
|
349
|
-
"timestamp": _now(),
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if ntype == "trigger":
|
|
353
|
-
entry["status"] = "ok"
|
|
354
|
-
entry["trigger"] = (current.get("config") or {}).get("trigger", "manual")
|
|
355
|
-
run.timeline.append(entry)
|
|
356
|
-
current = nodes.get(current.get("next")) if current.get("next") else None
|
|
357
|
-
continue
|
|
358
|
-
|
|
359
|
-
if ntype == "output":
|
|
360
|
-
entry["status"] = "ok"
|
|
361
|
-
payload = (current.get("config") or {}).get("value")
|
|
362
|
-
entry["output"] = payload if payload is not None else context.get("last_output")
|
|
363
|
-
run.outputs[nid] = entry["output"]
|
|
364
|
-
run.timeline.append(entry)
|
|
365
|
-
current = nodes.get(current.get("next")) if current.get("next") else None
|
|
366
|
-
continue
|
|
367
|
-
|
|
368
|
-
if ntype == "condition":
|
|
369
|
-
result = _evaluate_condition(current.get("config") or {}, context)
|
|
370
|
-
entry["status"] = "ok"
|
|
371
|
-
entry["result"] = result
|
|
372
|
-
run.timeline.append(entry)
|
|
373
|
-
branches = current.get("branches") or {}
|
|
374
|
-
target = branches.get("true" if result else "false")
|
|
375
|
-
current = nodes.get(target) if target else None
|
|
376
|
-
continue
|
|
377
|
-
|
|
378
|
-
# Executable node → dispatch to its runner family.
|
|
379
|
-
family = _RUNNER_FOR.get(ntype)
|
|
380
|
-
runner = self.runners.get(family) if family else None
|
|
381
|
-
if runner is None:
|
|
382
|
-
entry["status"] = "skipped"
|
|
383
|
-
entry["reason"] = f"no '{family}' runner configured"
|
|
384
|
-
had_skip = True
|
|
385
|
-
run.timeline.append(entry)
|
|
386
|
-
current = nodes.get(current.get("next")) if current.get("next") else None
|
|
387
|
-
continue
|
|
388
|
-
try:
|
|
389
|
-
result = runner(node=current, context=context)
|
|
390
|
-
entry["status"] = "ok"
|
|
391
|
-
entry["result"] = result
|
|
392
|
-
context["last_output"] = result
|
|
393
|
-
context[nid] = result
|
|
394
|
-
except ApprovalRequired as pause:
|
|
395
|
-
# Suspend — never a fake success, never a silent skip.
|
|
396
|
-
entry["status"] = "awaiting_approval"
|
|
397
|
-
entry["pending"] = {
|
|
398
|
-
"tool": pause.tool, "args": pause.args,
|
|
399
|
-
"permission": pause.permission, "reason": str(pause),
|
|
400
|
-
}
|
|
401
|
-
run.timeline.append(entry)
|
|
402
|
-
run.status = "awaiting_approval"
|
|
403
|
-
run.paused_node = nid
|
|
404
|
-
run.pending_approval = entry["pending"]
|
|
405
|
-
try:
|
|
406
|
-
run.paused_context = _json_safe(context)
|
|
407
|
-
except Exception:
|
|
408
|
-
run.paused_context = {"inputs": context.get("inputs") or {}}
|
|
409
|
-
if self.hooks is not None:
|
|
410
|
-
self.hooks.fire_hook(
|
|
411
|
-
"post_workflow", "workflow.paused",
|
|
412
|
-
payload={"workflow_id": definition.get("id"),
|
|
413
|
-
"status": run.status, "node": nid},
|
|
414
|
-
)
|
|
415
|
-
return run
|
|
416
|
-
except Exception as exc:
|
|
417
|
-
entry["status"] = "error"
|
|
418
|
-
entry["reason"] = str(exc)
|
|
419
|
-
had_error = True
|
|
420
|
-
run.timeline.append(entry)
|
|
421
|
-
current = nodes.get(current.get("next")) if current.get("next") else None
|
|
422
|
-
|
|
423
|
-
if steps >= _MAX_STEPS:
|
|
424
|
-
run.timeline.append({"node": None, "type": "guard", "status": "error", "reason": f"exceeded {_MAX_STEPS} steps (cycle?)", "timestamp": _now()})
|
|
425
|
-
had_error = True
|
|
426
|
-
|
|
427
|
-
run.status = "failed" if had_error else ("partial" if had_skip else "ok")
|
|
428
|
-
run.finished_at = _now()
|
|
429
|
-
if self.hooks is not None:
|
|
430
|
-
self.hooks.fire_hook(
|
|
431
|
-
"post_workflow", "workflow.end",
|
|
432
|
-
payload={"workflow_id": definition.get("id"), "name": definition.get("name"),
|
|
433
|
-
"status": run.status, "steps": steps},
|
|
434
|
-
)
|
|
435
|
-
return run
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def export_workflow(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
|
439
|
-
"""Portable JSON representation (definition only — no run history / scope)."""
|
|
440
|
-
definition = normalize_definition(workflow)
|
|
441
|
-
return {
|
|
442
|
-
"lattice_workflow_export": WORKFLOW_ENGINE_VERSION,
|
|
443
|
-
"name": definition.get("name"),
|
|
444
|
-
"nodes": definition.get("nodes"),
|
|
445
|
-
"metadata": {k: v for k, v in (definition.get("metadata") or {}).items() if k != "lifted_from_steps"},
|
|
446
|
-
}
|
|
7
|
+
import sys
|
|
447
8
|
|
|
9
|
+
import lattice_brain.workflow as _impl
|
|
448
10
|
|
|
449
|
-
|
|
450
|
-
"""Validate an exported workflow and return a definition ready to persist."""
|
|
451
|
-
if not isinstance(data, dict):
|
|
452
|
-
raise WorkflowError("import payload must be a JSON object")
|
|
453
|
-
definition = {
|
|
454
|
-
"name": data.get("name") or "Imported workflow",
|
|
455
|
-
"nodes": data.get("nodes") or [],
|
|
456
|
-
"metadata": {**(data.get("metadata") or {}), "imported": True},
|
|
457
|
-
}
|
|
458
|
-
errors = validate_definition(definition)
|
|
459
|
-
if errors:
|
|
460
|
-
raise WorkflowError("; ".join(errors))
|
|
461
|
-
return definition
|
|
11
|
+
sys.modules[__name__] = _impl
|
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
WORKSPACE_OS_VERSION = "4.
|
|
22
|
+
WORKSPACE_OS_VERSION = "4.4.0"
|
|
23
23
|
|
|
24
24
|
# Workspace types separate single-user Personal workspaces from shared
|
|
25
25
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|