ltcai 8.0.0 → 8.2.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 +33 -72
- package/docs/CHANGELOG.md +52 -874
- package/docs/DEVELOPMENT.md +26 -3
- package/docs/LEGACY_COMPATIBILITY.md +3 -1
- package/docs/TRUST_MODEL.md +26 -26
- package/docs/WHY_LATTICE.md +28 -25
- package/docs/kg-schema.md +6 -4
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/embeddings.py +9 -1
- package/lattice_brain/graph/schema.py +6 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/computer_use.py +2 -0
- package/latticeai/api/memory.py +13 -0
- package/latticeai/app_factory.py +43 -162
- package/latticeai/brain/__init__.py +1 -1
- package/latticeai/core/config.py +10 -0
- package/latticeai/core/local_embeddings.py +8 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +140 -1
- package/latticeai/core/tool_registry.py +10 -0
- package/latticeai/core/workspace_graph_trace.py +132 -0
- package/latticeai/core/workspace_memory.py +75 -0
- package/latticeai/core/workspace_os.py +135 -1283
- package/latticeai/core/workspace_os_utils.py +132 -0
- package/latticeai/core/workspace_permissions.py +99 -0
- package/latticeai/core/workspace_plugins.py +97 -0
- package/latticeai/core/workspace_runs.py +612 -0
- package/latticeai/core/workspace_skills.py +114 -0
- package/latticeai/core/workspace_snapshots.py +195 -0
- package/latticeai/core/workspace_timeline.py +107 -0
- package/latticeai/runtime/audit_runtime.py +64 -0
- package/latticeai/runtime/config_runtime.py +4 -0
- package/latticeai/runtime/sso_runtime.py +52 -0
- package/latticeai/services/architecture_readiness.py +6 -5
- package/latticeai/services/memory_service.py +215 -7
- package/latticeai/services/model_capability_registry.py +68 -0
- package/latticeai/services/model_engines.py +603 -0
- package/latticeai/services/model_loading.py +482 -0
- package/latticeai/services/model_recommendation.py +5 -0
- package/latticeai/services/model_runtime.py +146 -837
- package/latticeai/services/product_readiness.py +19 -18
- package/package.json +2 -2
- package/scripts/build_frontend_assets.mjs +11 -2
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -102
- package/static/app/assets/Act-D9jIknFd.js +1 -0
- package/static/app/assets/Brain-CFOtWbPN.js +321 -0
- package/static/app/assets/{Capture-B9Tlhzqr.js → Capture-Q4WYzwr5.js} +1 -2
- package/static/app/assets/Library-C5Q2yWee.js +1 -0
- package/static/app/assets/System-BLbjdr1_.js +1 -0
- package/static/app/assets/core-CwxXejkd.js +1 -2
- package/static/app/assets/index-BqammyNu.js +16 -0
- package/static/app/assets/index-ty1iGgZu.css +2 -0
- package/static/app/assets/primitives-Br8uSfZ4.js +1 -0
- package/static/app/assets/{textarea-BZk6ybp5.js → textarea-BnhNs1_X.js} +1 -2
- package/static/app/index.html +3 -10
- package/static/app/theme-boot.js +8 -0
- package/static/sw.js +1 -1
- package/tools/__init__.py +2 -1
- package/tools/computer.py +21 -0
- package/docs/CARRYOVER_AUDIT_v3.6.0.md +0 -61
- package/docs/HANDOVER_v3.6.0.md +0 -46
- package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +0 -56
- package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +0 -49
- package/docs/V2_ARCHITECTURE.md +0 -561
- package/docs/V3_2_AUDIT.md +0 -82
- package/docs/V3_BACKEND_ARCHITECTURE.md +0 -138
- package/docs/V3_FRONTEND.md +0 -140
- package/docs/V4_1_FRONTEND_ARCHITECTURE_REVIEW.md +0 -65
- package/docs/V4_1_FRONTEND_MIGRATION_REPORT.md +0 -70
- package/docs/V4_1_VALIDATION_REPORT.md +0 -47
- package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +0 -97
- package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +0 -91
- package/docs/V4_2_VALIDATION_REPORT.md +0 -89
- package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +0 -174
- package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +0 -81
- package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +0 -75
- package/docs/V4_3_2_GRAPH_UX_REPORT.md +0 -48
- package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +0 -209
- package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +0 -57
- package/docs/V4_3_2_SELF_AUDIT_REPORT.md +0 -63
- package/docs/V4_3_2_VALIDATION_REPORT.md +0 -97
- package/docs/V4_3_3_VALIDATION_REPORT.md +0 -46
- package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +0 -69
- package/docs/V4_3_PRIVACY_AUDIT.md +0 -60
- package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +0 -53
- package/docs/V4_3_VALIDATION_REPORT.md +0 -58
- package/docs/V4_4_0_EXTRACTION_REPORT.md +0 -239
- package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +0 -49
- package/docs/V4_5_0_GRAPH_UX_REPORT.md +0 -34
- package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +0 -40
- package/docs/V4_5_0_ONBOARDING_REPORT.md +0 -31
- package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +0 -49
- package/docs/V4_5_0_VALIDATION_REPORT.md +0 -60
- package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +0 -33
- package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +0 -37
- package/docs/V4_5_1_NAVIGATION_REPORT.md +0 -37
- package/docs/V4_5_1_ONBOARDING_REPORT.md +0 -29
- package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +0 -61
- package/docs/V4_5_1_RC_ARTIFACTS.md +0 -44
- package/docs/V4_5_1_UX_REPORT.md +0 -45
- package/docs/V4_5_1_VALIDATION_REPORT.md +0 -55
- package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +0 -30
- package/docs/V4_6_0_LIVING_BRAIN_EXPERIENCE_REPORT.md +0 -72
- package/docs/V4_6_1_RELEASE_REFRESH_REPORT.md +0 -42
- package/docs/V4_7_0_ADMIN_SEPARATION_REPORT.md +0 -42
- package/docs/V4_7_1_ADMIN_OPERATIONS_REPORT.md +0 -49
- package/docs/V4_7_2_INTUITIVE_BRAIN_UX_REPORT.md +0 -62
- package/docs/V4_BRAIN_ARCHITECTURE.md +0 -322
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +0 -555
- package/docs/V4_IMPLEMENTATION_PLAN.md +0 -470
- package/frontend/index.html +0 -24
- package/frontend/openapi.json +0 -15425
- package/frontend/src/App.tsx +0 -243
- package/frontend/src/api/client.ts +0 -580
- package/frontend/src/api/openapi.ts +0 -17892
- package/frontend/src/components/AdminAccessGate.tsx +0 -70
- package/frontend/src/components/FeedbackState.tsx +0 -45
- package/frontend/src/components/LanguageSwitcher.tsx +0 -23
- package/frontend/src/components/LivingBrain.tsx +0 -220
- package/frontend/src/components/ProductFlow.tsx +0 -171
- package/frontend/src/components/WorkspaceProfileSwitcher.tsx +0 -176
- package/frontend/src/components/onboarding/AnalysisScreen.tsx +0 -135
- package/frontend/src/components/onboarding/DownloadConsentPanel.tsx +0 -29
- package/frontend/src/components/onboarding/InstallScreen.tsx +0 -263
- package/frontend/src/components/onboarding/LanguageChooser.tsx +0 -23
- package/frontend/src/components/onboarding/LoginScreen.tsx +0 -131
- package/frontend/src/components/onboarding/ProductFlowScreens.tsx +0 -13
- package/frontend/src/components/onboarding/RecommendationScreen.tsx +0 -116
- package/frontend/src/components/onboarding/recommendationModel.ts +0 -189
- package/frontend/src/components/primitives.tsx +0 -392
- package/frontend/src/components/ui/badge.tsx +0 -27
- package/frontend/src/components/ui/button.tsx +0 -37
- package/frontend/src/components/ui/card.tsx +0 -22
- package/frontend/src/components/ui/input.tsx +0 -16
- package/frontend/src/components/ui/textarea.tsx +0 -16
- package/frontend/src/features/admin/AdminConsole.tsx +0 -334
- package/frontend/src/features/brain/BrainCarePanel.tsx +0 -254
- package/frontend/src/features/brain/BrainComposer.tsx +0 -85
- package/frontend/src/features/brain/BrainConversation.tsx +0 -688
- package/frontend/src/features/brain/BrainGraphLayer.tsx +0 -365
- package/frontend/src/features/brain/BrainHome.tsx +0 -624
- package/frontend/src/features/brain/BrainMemoryLayer.tsx +0 -45
- package/frontend/src/features/brain/BrainOverviewPanel.tsx +0 -149
- package/frontend/src/features/brain/BrainRelationshipLayer.tsx +0 -43
- package/frontend/src/features/brain/DepthEmergence.tsx +0 -53
- package/frontend/src/features/brain/brainData.ts +0 -246
- package/frontend/src/features/brain/graphLayout.ts +0 -37
- package/frontend/src/features/brain/types.ts +0 -150
- package/frontend/src/features/review/ReviewCard.tsx +0 -100
- package/frontend/src/features/review/ReviewInbox.tsx +0 -127
- package/frontend/src/features/review/reviewHelpers.ts +0 -69
- package/frontend/src/i18n.ts +0 -1303
- package/frontend/src/lib/utils.ts +0 -33
- package/frontend/src/main.tsx +0 -23
- package/frontend/src/pages/Act.tsx +0 -458
- package/frontend/src/pages/Ask.tsx +0 -14
- package/frontend/src/pages/Brain.tsx +0 -914
- package/frontend/src/pages/Capture.tsx +0 -258
- package/frontend/src/pages/Library.tsx +0 -486
- package/frontend/src/pages/System.tsx +0 -621
- package/frontend/src/routes.ts +0 -92
- package/frontend/src/store/appStore.ts +0 -94
- package/frontend/src/styles.css +0 -6579
- package/static/app/assets/Act-DOvf59ru.js +0 -2
- package/static/app/assets/Act-DOvf59ru.js.map +0 -1
- package/static/app/assets/Brain-C7_0mEiI.js +0 -322
- package/static/app/assets/Brain-C7_0mEiI.js.map +0 -1
- package/static/app/assets/Capture-B9Tlhzqr.js.map +0 -1
- package/static/app/assets/Library-BJPEEm5O.js +0 -2
- package/static/app/assets/Library-BJPEEm5O.js.map +0 -1
- package/static/app/assets/System-D6t9jo9V.js +0 -2
- package/static/app/assets/System-D6t9jo9V.js.map +0 -1
- package/static/app/assets/core-CwxXejkd.js.map +0 -1
- package/static/app/assets/index-C7g26IF6.css +0 -2
- package/static/app/assets/index-DbcEYJQ2.js +0 -17
- package/static/app/assets/index-DbcEYJQ2.js.map +0 -1
- package/static/app/assets/primitives-CD38lt4n.js +0 -2
- package/static/app/assets/primitives-CD38lt4n.js.map +0 -1
- package/static/app/assets/textarea-BZk6ybp5.js.map +0 -1
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""Agent and workflow run persistence extracted from WorkspaceOSStore."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from lattice_brain.runtime.contracts import run_record_contract, workflow_run_contract
|
|
8
|
+
|
|
9
|
+
from .workspace_os_utils import _json_hash, _listify, _now
|
|
10
|
+
|
|
11
|
+
RUN_ACTIVE_STATUSES = {"queued", "running", "in_progress", "retrying", "cancelling"}
|
|
12
|
+
RUN_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorkspaceRuns:
|
|
16
|
+
def __init__(self, store: Any):
|
|
17
|
+
self._store = store
|
|
18
|
+
|
|
19
|
+
def __getattr__(self, name: str) -> Any:
|
|
20
|
+
return getattr(self._store, name)
|
|
21
|
+
|
|
22
|
+
def list_agents(self, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
23
|
+
state = self.load_state()
|
|
24
|
+
runs = self._scoped(_listify(state.get("agent_runs")), workspace_id)
|
|
25
|
+
return {"agents": _listify(state.get("agents")), "runs": list(reversed(runs[-100:]))}
|
|
26
|
+
|
|
27
|
+
def record_agent_run(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
agent_id: str,
|
|
31
|
+
status: str,
|
|
32
|
+
input_text: str,
|
|
33
|
+
output_text: str,
|
|
34
|
+
user_email: Optional[str],
|
|
35
|
+
timeline: Optional[List[Dict[str, Any]]] = None,
|
|
36
|
+
relationships: Optional[List[str]] = None,
|
|
37
|
+
handoffs: Optional[List[Dict[str, Any]]] = None,
|
|
38
|
+
context_packets: Optional[List[Dict[str, Any]]] = None,
|
|
39
|
+
plan: Optional[List[Dict[str, Any]]] = None,
|
|
40
|
+
plan_review: Optional[Dict[str, Any]] = None,
|
|
41
|
+
review_history: Optional[List[Dict[str, Any]]] = None,
|
|
42
|
+
retry_history: Optional[List[Dict[str, Any]]] = None,
|
|
43
|
+
memory_snapshots: Optional[List[Dict[str, Any]]] = None,
|
|
44
|
+
graph: Any = None,
|
|
45
|
+
workspace_id: Optional[str] = None,
|
|
46
|
+
mode: str = "simulation",
|
|
47
|
+
) -> Dict[str, Any]:
|
|
48
|
+
state = self.load_state()
|
|
49
|
+
resolved_workspace = self._resolve_scope(workspace_id, state)
|
|
50
|
+
run = {
|
|
51
|
+
"id": f"agent-run-{_json_hash([agent_id, input_text, output_text, _now()])[:16]}",
|
|
52
|
+
"record_schema_version": 2,
|
|
53
|
+
"agent_id": agent_id,
|
|
54
|
+
"mode": mode,
|
|
55
|
+
"status": status,
|
|
56
|
+
"input": input_text,
|
|
57
|
+
"output_preview": output_text[:1000],
|
|
58
|
+
"user_email": user_email,
|
|
59
|
+
"workspace_id": resolved_workspace,
|
|
60
|
+
"relationships": relationships or [],
|
|
61
|
+
"timeline": timeline or [],
|
|
62
|
+
"handoffs": handoffs or [],
|
|
63
|
+
"context_packets": context_packets or [],
|
|
64
|
+
"plan": plan or [],
|
|
65
|
+
"plan_review": plan_review or {},
|
|
66
|
+
"review_history": review_history or [],
|
|
67
|
+
"retry_history": retry_history or [],
|
|
68
|
+
"memory_snapshots": memory_snapshots or [],
|
|
69
|
+
"created_at": _now(),
|
|
70
|
+
}
|
|
71
|
+
if mode == "simulation":
|
|
72
|
+
# Simulated runs are replay scaffolding, not experiences — they must
|
|
73
|
+
# never enter the knowledge graph as real provenance.
|
|
74
|
+
run["graph_node_id"] = None
|
|
75
|
+
run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
|
|
76
|
+
elif graph is not None:
|
|
77
|
+
try:
|
|
78
|
+
ingested = graph.ingest_event(
|
|
79
|
+
"AgentRun",
|
|
80
|
+
f"{agent_id} {status}",
|
|
81
|
+
user_email=user_email,
|
|
82
|
+
source="workspace_os",
|
|
83
|
+
metadata={"run_id": run["id"], "agent_id": agent_id, "status": status, "mode": mode},
|
|
84
|
+
)
|
|
85
|
+
run["graph_node_id"] = ingested.get("node_id")
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
run["graph_error"] = str(exc)
|
|
88
|
+
if handoffs:
|
|
89
|
+
stored_handoffs = state.setdefault("handoffs", [])
|
|
90
|
+
for handoff in handoffs:
|
|
91
|
+
stored = {
|
|
92
|
+
**handoff,
|
|
93
|
+
"run_id": run["id"],
|
|
94
|
+
"workspace_id": resolved_workspace,
|
|
95
|
+
}
|
|
96
|
+
stored_handoffs.append(stored)
|
|
97
|
+
state["handoffs"] = stored_handoffs
|
|
98
|
+
state.setdefault("agent_runs", []).append(run)
|
|
99
|
+
self.save_state(state)
|
|
100
|
+
self._emit_replayable_timeline_events(area="agent", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
|
|
101
|
+
if status == "failed":
|
|
102
|
+
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)
|
|
103
|
+
self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
|
|
104
|
+
run["contract"] = run_record_contract(run)
|
|
105
|
+
state = self.load_state()
|
|
106
|
+
for item in _listify(state.get("agent_runs")):
|
|
107
|
+
if item.get("id") == run["id"]:
|
|
108
|
+
item["contract"] = run["contract"]
|
|
109
|
+
break
|
|
110
|
+
self.save_state(state)
|
|
111
|
+
return run
|
|
112
|
+
|
|
113
|
+
def update_agent_run(
|
|
114
|
+
self,
|
|
115
|
+
run_id: str,
|
|
116
|
+
*,
|
|
117
|
+
workspace_id: Optional[str] = None,
|
|
118
|
+
graph: Any = None,
|
|
119
|
+
patch: Optional[Dict[str, Any]] = None,
|
|
120
|
+
**fields: Any,
|
|
121
|
+
) -> Dict[str, Any]:
|
|
122
|
+
"""Patch a persisted agent run without changing its id.
|
|
123
|
+
|
|
124
|
+
Async execution creates a durable queued/running row before work starts,
|
|
125
|
+
then updates that same row as progress, cancellation, or a terminal
|
|
126
|
+
result arrives. This keeps old run lists/read APIs compatible while
|
|
127
|
+
avoiding duplicate "placeholder + final" records.
|
|
128
|
+
"""
|
|
129
|
+
updates = {**(patch or {}), **fields}
|
|
130
|
+
state = self.load_state()
|
|
131
|
+
run = next((item for item in _listify(state.get("agent_runs")) if item.get("id") == run_id), None)
|
|
132
|
+
if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
133
|
+
raise FileNotFoundError(run_id)
|
|
134
|
+
resolved_workspace = self._record_workspace(run)
|
|
135
|
+
old_timeline_len = len(run.get("timeline") or [])
|
|
136
|
+
|
|
137
|
+
output_text = updates.pop("output_text", None)
|
|
138
|
+
if output_text is not None:
|
|
139
|
+
run["output_preview"] = str(output_text)[:1000]
|
|
140
|
+
for key, value in updates.items():
|
|
141
|
+
run[key] = value
|
|
142
|
+
status = str(run.get("status") or "")
|
|
143
|
+
run["updated_at"] = _now()
|
|
144
|
+
if status in RUN_TERMINAL_STATUSES:
|
|
145
|
+
run.setdefault("completed_at", _now())
|
|
146
|
+
|
|
147
|
+
handoffs = updates.get("handoffs")
|
|
148
|
+
if isinstance(handoffs, list):
|
|
149
|
+
stored_handoffs = [
|
|
150
|
+
item for item in _listify(state.get("handoffs"))
|
|
151
|
+
if item.get("run_id") != run_id
|
|
152
|
+
]
|
|
153
|
+
for handoff in handoffs:
|
|
154
|
+
if isinstance(handoff, dict):
|
|
155
|
+
stored_handoffs.append({**handoff, "run_id": run_id, "workspace_id": resolved_workspace})
|
|
156
|
+
state["handoffs"] = stored_handoffs
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
status in RUN_TERMINAL_STATUSES
|
|
160
|
+
and run.get("mode") != "simulation"
|
|
161
|
+
and graph is not None
|
|
162
|
+
and not run.get("graph_node_id")
|
|
163
|
+
):
|
|
164
|
+
try:
|
|
165
|
+
ingested = graph.ingest_event(
|
|
166
|
+
"AgentRun",
|
|
167
|
+
f"{run.get('agent_id')} {status}",
|
|
168
|
+
user_email=run.get("user_email"),
|
|
169
|
+
source="workspace_os",
|
|
170
|
+
metadata={
|
|
171
|
+
"run_id": run_id,
|
|
172
|
+
"agent_id": run.get("agent_id"),
|
|
173
|
+
"status": status,
|
|
174
|
+
"mode": run.get("mode"),
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
run["graph_node_id"] = ingested.get("node_id")
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
run["graph_error"] = str(exc)
|
|
180
|
+
|
|
181
|
+
self.save_state(state)
|
|
182
|
+
run["contract"] = run_record_contract(run)
|
|
183
|
+
state = self.load_state()
|
|
184
|
+
for item in _listify(state.get("agent_runs")):
|
|
185
|
+
if item.get("id") == run_id:
|
|
186
|
+
item["contract"] = run["contract"]
|
|
187
|
+
break
|
|
188
|
+
self.save_state(state)
|
|
189
|
+
|
|
190
|
+
timeline = run.get("timeline") or []
|
|
191
|
+
if len(timeline) > old_timeline_len:
|
|
192
|
+
self._emit_replayable_timeline_events(
|
|
193
|
+
area="agent",
|
|
194
|
+
run_id=run_id,
|
|
195
|
+
timeline=timeline[old_timeline_len:],
|
|
196
|
+
workspace_id=resolved_workspace,
|
|
197
|
+
)
|
|
198
|
+
if status == "failed":
|
|
199
|
+
self._emit_execution_event(area="agent", event_type="execution_failed", payload={"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
|
|
200
|
+
elif status == "cancelled":
|
|
201
|
+
self._emit_execution_event(area="agent", event_type="execution_cancelled", payload={"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
|
|
202
|
+
elif status == "interrupted":
|
|
203
|
+
self._emit_execution_event(area="agent", event_type="execution_interrupted", payload={"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
|
|
204
|
+
self.record_timeline_event("agent", "agent_run_update", {"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
|
|
205
|
+
return run
|
|
206
|
+
|
|
207
|
+
def get_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
208
|
+
run = next((item for item in _listify(self.load_state().get("agent_runs")) if item.get("id") == run_id), None)
|
|
209
|
+
if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
210
|
+
raise FileNotFoundError(run_id)
|
|
211
|
+
return run
|
|
212
|
+
|
|
213
|
+
def list_handoffs(self, workspace_id: Optional[str] = None, run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
214
|
+
handoffs = self._scoped(_listify(self.load_state().get("handoffs")), workspace_id)
|
|
215
|
+
if run_id:
|
|
216
|
+
handoffs = [item for item in handoffs if item.get("run_id") == run_id]
|
|
217
|
+
return {"handoffs": list(reversed(handoffs[-200:]))}
|
|
218
|
+
|
|
219
|
+
def create_workflow(
|
|
220
|
+
self,
|
|
221
|
+
*,
|
|
222
|
+
name: str,
|
|
223
|
+
steps: List[Dict[str, Any]],
|
|
224
|
+
user_email: Optional[str],
|
|
225
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
226
|
+
graph: Any = None,
|
|
227
|
+
workspace_id: Optional[str] = None,
|
|
228
|
+
nodes: Optional[List[Dict[str, Any]]] = None,
|
|
229
|
+
) -> Dict[str, Any]:
|
|
230
|
+
state = self.load_state()
|
|
231
|
+
workflow = {
|
|
232
|
+
"id": f"workflow-{_json_hash([name, steps, user_email, _now()])[:16]}",
|
|
233
|
+
"name": name or "Untitled workflow",
|
|
234
|
+
"steps": steps,
|
|
235
|
+
"user_email": user_email,
|
|
236
|
+
"workspace_id": self._resolve_scope(workspace_id, state),
|
|
237
|
+
"metadata": metadata or {},
|
|
238
|
+
"events": [{"type": "created", "timestamp": _now()}],
|
|
239
|
+
"created_at": _now(),
|
|
240
|
+
"updated_at": _now(),
|
|
241
|
+
}
|
|
242
|
+
# Workflow Designer stores a typed-node graph alongside the legacy
|
|
243
|
+
# ``steps`` list so older history keeps working and new editors get nodes.
|
|
244
|
+
if nodes is not None:
|
|
245
|
+
workflow["nodes"] = nodes
|
|
246
|
+
if graph is not None:
|
|
247
|
+
try:
|
|
248
|
+
ingested = graph.ingest_event(
|
|
249
|
+
"Workflow",
|
|
250
|
+
workflow["name"],
|
|
251
|
+
user_email=user_email,
|
|
252
|
+
source="workspace_os",
|
|
253
|
+
metadata={"workflow_id": workflow["id"], "steps": steps},
|
|
254
|
+
)
|
|
255
|
+
workflow["graph_node_id"] = ingested.get("node_id")
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
workflow["graph_error"] = str(exc)
|
|
258
|
+
state.setdefault("workflows", []).append(workflow)
|
|
259
|
+
self.save_state(state)
|
|
260
|
+
self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
|
|
261
|
+
return workflow
|
|
262
|
+
|
|
263
|
+
def record_workflow_run(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
workflow_id: Optional[str],
|
|
267
|
+
name: str,
|
|
268
|
+
status: str,
|
|
269
|
+
timeline: List[Dict[str, Any]],
|
|
270
|
+
outputs: Optional[Dict[str, Any]] = None,
|
|
271
|
+
user_email: Optional[str] = None,
|
|
272
|
+
graph: Any = None,
|
|
273
|
+
workspace_id: Optional[str] = None,
|
|
274
|
+
mode: str = "simulation",
|
|
275
|
+
pause: Optional[Dict[str, Any]] = None,
|
|
276
|
+
) -> Dict[str, Any]:
|
|
277
|
+
"""Persist a Workflow Designer execution into local-first run history."""
|
|
278
|
+
state = self.load_state()
|
|
279
|
+
resolved_workspace = self._resolve_scope(workspace_id, state)
|
|
280
|
+
run = {
|
|
281
|
+
"id": f"workflow-run-{_json_hash([workflow_id, name, status, _now()])[:16]}",
|
|
282
|
+
"record_schema_version": 2,
|
|
283
|
+
"workflow_id": workflow_id,
|
|
284
|
+
"name": name or "workflow",
|
|
285
|
+
"mode": mode,
|
|
286
|
+
"status": status,
|
|
287
|
+
"timeline": timeline or [],
|
|
288
|
+
"outputs": outputs or {},
|
|
289
|
+
"user_email": user_email,
|
|
290
|
+
"workspace_id": resolved_workspace,
|
|
291
|
+
"created_at": _now(),
|
|
292
|
+
}
|
|
293
|
+
if pause:
|
|
294
|
+
run["pause"] = pause
|
|
295
|
+
if mode == "simulation":
|
|
296
|
+
# Record-only node runners do no real work; their runs must not be
|
|
297
|
+
# written into the knowledge graph as if they were real executions.
|
|
298
|
+
run["graph_node_id"] = None
|
|
299
|
+
run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
|
|
300
|
+
elif graph is not None:
|
|
301
|
+
try:
|
|
302
|
+
ingested = graph.ingest_event(
|
|
303
|
+
"WorkflowRun",
|
|
304
|
+
f"{run['name']} {status}",
|
|
305
|
+
user_email=user_email,
|
|
306
|
+
source="workspace_os",
|
|
307
|
+
metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status, "mode": mode},
|
|
308
|
+
)
|
|
309
|
+
run["graph_node_id"] = ingested.get("node_id")
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
run["graph_error"] = str(exc)
|
|
312
|
+
state.setdefault("workflow_runs", []).append(run)
|
|
313
|
+
# Attach the run id to the workflow's event log for cross-linking.
|
|
314
|
+
for wf in _listify(state.get("workflows")):
|
|
315
|
+
if wf.get("id") == workflow_id:
|
|
316
|
+
wf.setdefault("events", []).append({"type": "run", "timestamp": _now(), "payload": {"run_id": run["id"], "status": status}})
|
|
317
|
+
wf["updated_at"] = _now()
|
|
318
|
+
break
|
|
319
|
+
self.save_state(state)
|
|
320
|
+
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)
|
|
321
|
+
self._emit_replayable_timeline_events(area="workflow", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
|
|
322
|
+
if status == "failed":
|
|
323
|
+
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)
|
|
324
|
+
elif status in {"ok", "partial"}:
|
|
325
|
+
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)
|
|
326
|
+
self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
327
|
+
run["contract"] = workflow_run_contract(run)
|
|
328
|
+
state = self.load_state()
|
|
329
|
+
for item in _listify(state.get("workflow_runs")):
|
|
330
|
+
if item.get("id") == run["id"]:
|
|
331
|
+
item["contract"] = run["contract"]
|
|
332
|
+
break
|
|
333
|
+
self.save_state(state)
|
|
334
|
+
return run
|
|
335
|
+
|
|
336
|
+
def update_workflow_run(
|
|
337
|
+
self,
|
|
338
|
+
run_id: str,
|
|
339
|
+
*,
|
|
340
|
+
workspace_id: Optional[str] = None,
|
|
341
|
+
graph: Any = None,
|
|
342
|
+
patch: Optional[Dict[str, Any]] = None,
|
|
343
|
+
**fields: Any,
|
|
344
|
+
) -> Dict[str, Any]:
|
|
345
|
+
"""Patch a persisted workflow run in place for async execution."""
|
|
346
|
+
updates = {**(patch or {}), **fields}
|
|
347
|
+
state = self.load_state()
|
|
348
|
+
run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
|
|
349
|
+
if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
350
|
+
raise FileNotFoundError(run_id)
|
|
351
|
+
resolved_workspace = self._record_workspace(run)
|
|
352
|
+
old_timeline_len = len(run.get("timeline") or [])
|
|
353
|
+
|
|
354
|
+
for key, value in updates.items():
|
|
355
|
+
if value is None and key == "pause":
|
|
356
|
+
run.pop("pause", None)
|
|
357
|
+
else:
|
|
358
|
+
run[key] = value
|
|
359
|
+
status = str(run.get("status") or "")
|
|
360
|
+
run["updated_at"] = _now()
|
|
361
|
+
if status in RUN_TERMINAL_STATUSES:
|
|
362
|
+
run.setdefault("completed_at", _now())
|
|
363
|
+
|
|
364
|
+
workflow_id = run.get("workflow_id")
|
|
365
|
+
for wf in _listify(state.get("workflows")):
|
|
366
|
+
if wf.get("id") == workflow_id:
|
|
367
|
+
wf.setdefault("events", []).append({"type": "run_update", "timestamp": _now(), "payload": {"run_id": run_id, "status": status}})
|
|
368
|
+
wf["updated_at"] = _now()
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
if (
|
|
372
|
+
status in RUN_TERMINAL_STATUSES
|
|
373
|
+
and run.get("mode") != "simulation"
|
|
374
|
+
and graph is not None
|
|
375
|
+
and not run.get("graph_node_id")
|
|
376
|
+
):
|
|
377
|
+
try:
|
|
378
|
+
ingested = graph.ingest_event(
|
|
379
|
+
"WorkflowRun",
|
|
380
|
+
f"{run.get('name')} {status}",
|
|
381
|
+
user_email=run.get("user_email"),
|
|
382
|
+
source="workspace_os",
|
|
383
|
+
metadata={
|
|
384
|
+
"run_id": run_id,
|
|
385
|
+
"workflow_id": workflow_id,
|
|
386
|
+
"status": status,
|
|
387
|
+
"mode": run.get("mode"),
|
|
388
|
+
},
|
|
389
|
+
)
|
|
390
|
+
run["graph_node_id"] = ingested.get("node_id")
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
run["graph_error"] = str(exc)
|
|
393
|
+
|
|
394
|
+
self.save_state(state)
|
|
395
|
+
run["contract"] = workflow_run_contract(run)
|
|
396
|
+
state = self.load_state()
|
|
397
|
+
for item in _listify(state.get("workflow_runs")):
|
|
398
|
+
if item.get("id") == run_id:
|
|
399
|
+
item["contract"] = run["contract"]
|
|
400
|
+
break
|
|
401
|
+
self.save_state(state)
|
|
402
|
+
|
|
403
|
+
timeline = run.get("timeline") or []
|
|
404
|
+
if len(timeline) > old_timeline_len:
|
|
405
|
+
self._emit_replayable_timeline_events(
|
|
406
|
+
area="workflow",
|
|
407
|
+
run_id=run_id,
|
|
408
|
+
timeline=timeline[old_timeline_len:],
|
|
409
|
+
workspace_id=resolved_workspace,
|
|
410
|
+
)
|
|
411
|
+
if status == "failed":
|
|
412
|
+
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)
|
|
413
|
+
elif status in {"ok", "partial"}:
|
|
414
|
+
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)
|
|
415
|
+
elif status == "cancelled":
|
|
416
|
+
self._emit_execution_event(area="workflow", event_type="execution_cancelled", payload={"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
417
|
+
elif status == "interrupted":
|
|
418
|
+
self._emit_execution_event(area="workflow", event_type="execution_interrupted", payload={"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
419
|
+
self.record_timeline_event("workflow", "workflow_run_update", {"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
420
|
+
return run
|
|
421
|
+
|
|
422
|
+
def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
423
|
+
runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
|
|
424
|
+
if workflow_id:
|
|
425
|
+
runs = [run for run in runs if run.get("workflow_id") == workflow_id]
|
|
426
|
+
return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
|
|
427
|
+
|
|
428
|
+
def mark_workflow_run_resolved(
|
|
429
|
+
self, run_id: str, *, resumed_run_id: str, approved: bool,
|
|
430
|
+
workspace_id: Optional[str] = None,
|
|
431
|
+
) -> Dict[str, Any]:
|
|
432
|
+
"""Close out a paused run after its approval decision (one decision only)."""
|
|
433
|
+
state = self.load_state()
|
|
434
|
+
run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
|
|
435
|
+
if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
436
|
+
raise FileNotFoundError(run_id)
|
|
437
|
+
run["status"] = "resumed" if approved else "denied"
|
|
438
|
+
run["resolved_at"] = _now()
|
|
439
|
+
run["resumed_run_id"] = resumed_run_id
|
|
440
|
+
self.save_state(state)
|
|
441
|
+
return run
|
|
442
|
+
|
|
443
|
+
def get_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
444
|
+
run = next((item for item in _listify(self.load_state().get("workflow_runs")) if item.get("id") == run_id), None)
|
|
445
|
+
if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
446
|
+
raise FileNotFoundError(run_id)
|
|
447
|
+
return run
|
|
448
|
+
|
|
449
|
+
def reconcile_interrupted_runs(self, *, reason: str = "server_startup") -> Dict[str, Any]:
|
|
450
|
+
"""Mark durable active runs as interrupted after a process restart.
|
|
451
|
+
|
|
452
|
+
Queued/running/cancelling rows cannot have an owning asyncio task after
|
|
453
|
+
startup. Paused approval runs are intentionally left untouched so their
|
|
454
|
+
durable human decision cursor remains resumable.
|
|
455
|
+
"""
|
|
456
|
+
state = self.load_state()
|
|
457
|
+
interrupted: List[Dict[str, Any]] = []
|
|
458
|
+
now = _now()
|
|
459
|
+
collections = (("agent_runs", "agent"), ("workflow_runs", "workflow"))
|
|
460
|
+
for key, area in collections:
|
|
461
|
+
for run in _listify(state.get(key)):
|
|
462
|
+
status = str(run.get("status") or "")
|
|
463
|
+
if status not in RUN_ACTIVE_STATUSES:
|
|
464
|
+
continue
|
|
465
|
+
run["status"] = "interrupted"
|
|
466
|
+
run["interrupted_at"] = now
|
|
467
|
+
run["interrupt_reason"] = reason
|
|
468
|
+
run["updated_at"] = now
|
|
469
|
+
run.setdefault("timeline", []).append({
|
|
470
|
+
"event": "execution_interrupted",
|
|
471
|
+
"status": "interrupted",
|
|
472
|
+
"reason": reason,
|
|
473
|
+
"timestamp": now,
|
|
474
|
+
})
|
|
475
|
+
interrupted.append({
|
|
476
|
+
"kind": area,
|
|
477
|
+
"run_id": run.get("id"),
|
|
478
|
+
"workspace_id": self._record_workspace(run),
|
|
479
|
+
"previous_status": status,
|
|
480
|
+
})
|
|
481
|
+
if not interrupted:
|
|
482
|
+
return {"count": 0, "interrupted": []}
|
|
483
|
+
self.save_state(state)
|
|
484
|
+
for item in interrupted:
|
|
485
|
+
area = item["kind"]
|
|
486
|
+
run_id = item["run_id"]
|
|
487
|
+
workspace = item.get("workspace_id")
|
|
488
|
+
self._emit_execution_event(
|
|
489
|
+
area=area,
|
|
490
|
+
event_type="execution_interrupted",
|
|
491
|
+
payload={"run_id": run_id, "reason": reason, "previous_status": item.get("previous_status")},
|
|
492
|
+
workspace_id=workspace,
|
|
493
|
+
)
|
|
494
|
+
self.record_timeline_event(
|
|
495
|
+
"system",
|
|
496
|
+
"startup_reconciliation",
|
|
497
|
+
{"interrupted_runs": len(interrupted), "reason": reason},
|
|
498
|
+
)
|
|
499
|
+
return {"count": len(interrupted), "interrupted": interrupted}
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
|
|
503
|
+
frames = []
|
|
504
|
+
for index, item in enumerate(run.get("timeline") or []):
|
|
505
|
+
event = item.get("event") or item.get("event_type") or item.get("type") or "event"
|
|
506
|
+
actor = (
|
|
507
|
+
item.get("agent_id")
|
|
508
|
+
or item.get("role")
|
|
509
|
+
or item.get("source_agent")
|
|
510
|
+
or item.get("target_agent")
|
|
511
|
+
or item.get("node")
|
|
512
|
+
or kind
|
|
513
|
+
)
|
|
514
|
+
result = item.get("result") if "result" in item else item.get("output")
|
|
515
|
+
decision = item.get("outcome") or item.get("verdict") or item.get("status")
|
|
516
|
+
frames.append({
|
|
517
|
+
"index": index,
|
|
518
|
+
"event": event,
|
|
519
|
+
"actor": actor,
|
|
520
|
+
"when": item.get("timestamp") or item.get("started_at") or run.get("created_at"),
|
|
521
|
+
"why": item.get("reason") or item.get("note") or item.get("name") or "",
|
|
522
|
+
"input": item.get("context_packet") or item.get("trigger") or run.get("input"),
|
|
523
|
+
"output": result,
|
|
524
|
+
"decision": decision,
|
|
525
|
+
"raw": item,
|
|
526
|
+
})
|
|
527
|
+
return frames
|
|
528
|
+
|
|
529
|
+
def replay_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
530
|
+
run = self.get_agent_run(run_id, workspace_id=workspace_id)
|
|
531
|
+
return {
|
|
532
|
+
"kind": "agent",
|
|
533
|
+
"run_id": run_id,
|
|
534
|
+
"status": run.get("status"),
|
|
535
|
+
"workspace_id": self._record_workspace(run),
|
|
536
|
+
"contract": run.get("contract") or run_record_contract(run),
|
|
537
|
+
"replayable": True,
|
|
538
|
+
"frames": self._replay_frames(run, kind="agent"),
|
|
539
|
+
"handoffs": run.get("handoffs") or [],
|
|
540
|
+
"context_packets": run.get("context_packets") or [],
|
|
541
|
+
"review_history": run.get("review_history") or [],
|
|
542
|
+
"retry_history": run.get("retry_history") or [],
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
def replay_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
546
|
+
run = self.get_workflow_run(run_id, workspace_id=workspace_id)
|
|
547
|
+
return {
|
|
548
|
+
"kind": "workflow",
|
|
549
|
+
"run_id": run_id,
|
|
550
|
+
"status": run.get("status"),
|
|
551
|
+
"workspace_id": self._record_workspace(run),
|
|
552
|
+
"contract": run.get("contract") or workflow_run_contract(run),
|
|
553
|
+
"replayable": True,
|
|
554
|
+
"frames": self._replay_frames(run, kind="workflow"),
|
|
555
|
+
"outputs": run.get("outputs") or {},
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
def get_workflow(self, workflow_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
559
|
+
workflow = next((wf for wf in _listify(self.load_state().get("workflows")) if wf.get("id") == workflow_id), None)
|
|
560
|
+
if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
|
|
561
|
+
raise FileNotFoundError(workflow_id)
|
|
562
|
+
return workflow
|
|
563
|
+
|
|
564
|
+
def update_workflow_definition(
|
|
565
|
+
self,
|
|
566
|
+
workflow_id: str,
|
|
567
|
+
*,
|
|
568
|
+
name: Optional[str] = None,
|
|
569
|
+
nodes: Optional[List[Dict[str, Any]]] = None,
|
|
570
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
571
|
+
workspace_id: Optional[str] = None,
|
|
572
|
+
) -> Dict[str, Any]:
|
|
573
|
+
"""Edit a stored workflow's node graph / name without losing its history."""
|
|
574
|
+
state = self.load_state()
|
|
575
|
+
workflow = next((wf for wf in _listify(state.get("workflows")) if wf.get("id") == workflow_id), None)
|
|
576
|
+
if not workflow or (workspace_id and self._record_workspace(workflow) != str(workspace_id)):
|
|
577
|
+
raise FileNotFoundError(workflow_id)
|
|
578
|
+
if name is not None and str(name).strip():
|
|
579
|
+
workflow["name"] = str(name).strip()
|
|
580
|
+
if nodes is not None:
|
|
581
|
+
workflow["nodes"] = nodes
|
|
582
|
+
if metadata is not None:
|
|
583
|
+
workflow["metadata"] = {**(workflow.get("metadata") or {}), **metadata}
|
|
584
|
+
workflow.setdefault("events", []).append({"type": "edited", "timestamp": _now()})
|
|
585
|
+
workflow["updated_at"] = _now()
|
|
586
|
+
self.save_state(state)
|
|
587
|
+
self.record_timeline_event("workflow", "workflow_edited", {"workflow_id": workflow_id})
|
|
588
|
+
return workflow
|
|
589
|
+
|
|
590
|
+
def list_workflows(self, query: str = "", workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
591
|
+
workflows = list(reversed(self._scoped(_listify(self.load_state().get("workflows")), workspace_id)))
|
|
592
|
+
q = str(query or "").lower().strip()
|
|
593
|
+
if q:
|
|
594
|
+
workflows = [
|
|
595
|
+
wf for wf in workflows
|
|
596
|
+
if q in str(wf.get("name") or "").lower()
|
|
597
|
+
or q in json.dumps(wf.get("steps") or [], ensure_ascii=False).lower()
|
|
598
|
+
]
|
|
599
|
+
return {"workflows": workflows}
|
|
600
|
+
|
|
601
|
+
def record_workflow_event(self, workflow_id: str, event_type: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
602
|
+
state = self.load_state()
|
|
603
|
+
workflows = _listify(state.get("workflows"))
|
|
604
|
+
workflow = next((item for item in workflows if item.get("id") == workflow_id), None)
|
|
605
|
+
if not workflow:
|
|
606
|
+
raise FileNotFoundError(workflow_id)
|
|
607
|
+
event = {"type": event_type, "timestamp": _now(), "payload": payload or {}}
|
|
608
|
+
workflow.setdefault("events", []).append(event)
|
|
609
|
+
workflow["updated_at"] = _now()
|
|
610
|
+
self.save_state(state)
|
|
611
|
+
self.record_timeline_event("workflow", "workflow_event", {"workflow_id": workflow_id, "event_type": event_type})
|
|
612
|
+
return workflow
|