ltcai 3.6.0 → 4.0.1
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 +39 -31
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +51 -53
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -520
- package/knowledge_graph.py +37 -4629
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +16 -17
- package/latticeai/api/agents.py +20 -7
- package/latticeai/api/auth.py +46 -15
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +139 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +5 -8
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +11 -16
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +85 -6
- package/latticeai/api/workspace.py +93 -57
- package/latticeai/app_factory.py +1781 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/ingest.py +644 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/brain/projection.py +561 -0
- package/latticeai/brain/provenance.py +401 -0
- package/latticeai/brain/retrieval.py +1316 -0
- package/latticeai/brain/schema.py +640 -0
- package/latticeai/brain/store.py +216 -0
- package/latticeai/brain/write_master.py +225 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +66 -10
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +477 -29
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +243 -4
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +55 -16
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +10 -20
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +105 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +33 -25
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
- package/static/v3/js/core/api.js +57 -0
- package/static/v3/js/core/i18n.880e1fec.js +575 -0
- package/static/v3/js/core/i18n.js +575 -0
- package/static/v3/js/core/routes.37522821.js +101 -0
- package/static/v3/js/core/routes.js +71 -63
- package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
- package/static/v3/js/core/shell.js +66 -37
- package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
- package/static/v3/js/core/store.js +11 -1
- package/static/v3/js/views/account.eff40715.js +143 -0
- package/static/v3/js/views/account.js +143 -0
- package/static/v3/js/views/activity.0d271ef9.js +67 -0
- package/static/v3/js/views/activity.js +67 -0
- package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
- package/static/v3/js/views/admin-users.js +4 -6
- package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
- package/static/v3/js/views/agents.js +35 -12
- package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
- package/static/v3/js/views/chat.js +23 -0
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
- package/static/v3/js/views/knowledge-graph.js +60 -44
- package/static/v3/js/views/network.52a4f181.js +97 -0
- package/static/v3/js/views/network.js +97 -0
- package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
- package/static/v3/js/views/planning.js +26 -5
- package/static/v3/js/views/runs.b63b2afa.js +144 -0
- package/static/v3/js/views/runs.js +144 -0
- package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
- package/static/v3/js/views/settings.js +7 -8
- package/static/v3/js/views/snapshots.6f5db095.js +135 -0
- package/static/v3/js/views/snapshots.js +135 -0
- package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
- package/static/v3/js/views/workflows.js +87 -2
- package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
- package/static/v3/js/views/workspace-admin.js +156 -0
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
- package/static/account.html +0 -115
- package/static/activity.html +0 -73
- package/static/admin.html +0 -488
- package/static/agents.html +0 -139
- package/static/chat.html +0 -844
- package/static/css/reference/account.css +0 -439
- package/static/css/reference/admin.css +0 -610
- package/static/css/reference/base.css +0 -1661
- package/static/css/reference/chat.css +0 -4623
- package/static/css/reference/graph.css +0 -1016
- package/static/css/responsive.css +0 -861
- package/static/graph.html +0 -124
- package/static/platform.css +0 -104
- package/static/plugins.html +0 -136
- package/static/scripts/account.js +0 -238
- package/static/scripts/admin.js +0 -1614
- package/static/scripts/chat.js +0 -5081
- package/static/scripts/graph.js +0 -1804
- package/static/scripts/platform.js +0 -64
- package/static/scripts/ux.js +0 -167
- package/static/scripts/workspace.js +0 -948
- package/static/v3/js/core/routes.2ce3815a.js +0 -93
- package/static/workflows.html +0 -146
- package/static/workspace.css +0 -1121
- package/static/workspace.html +0 -357
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
13
|
import shutil
|
|
14
|
+
import sqlite3
|
|
14
15
|
import zipfile
|
|
15
16
|
from collections import deque
|
|
16
17
|
from datetime import datetime
|
|
@@ -18,7 +19,7 @@ from pathlib import Path
|
|
|
18
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
WORKSPACE_OS_VERSION = "
|
|
22
|
+
WORKSPACE_OS_VERSION = "4.0.1"
|
|
22
23
|
|
|
23
24
|
# Workspace types separate single-user Personal workspaces from shared
|
|
24
25
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -92,8 +93,12 @@ EXECUTION_EVENT_TYPES = {
|
|
|
92
93
|
"plugin_completed",
|
|
93
94
|
"execution_failed",
|
|
94
95
|
"execution_cancelled",
|
|
96
|
+
"execution_interrupted",
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
RUN_ACTIVE_STATUSES = {"queued", "running", "in_progress", "retrying", "cancelling"}
|
|
100
|
+
RUN_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
|
|
101
|
+
|
|
97
102
|
DEFAULT_AGENTS = [
|
|
98
103
|
{
|
|
99
104
|
"id": "agent:planner",
|
|
@@ -173,6 +178,52 @@ def _listify(value: Any) -> List[Any]:
|
|
|
173
178
|
return value if isinstance(value, list) else []
|
|
174
179
|
|
|
175
180
|
|
|
181
|
+
def _snapshot_graph_import_payload(graph_payload: Dict[str, Any], *, workspace_id: Optional[str]) -> Dict[str, Any]:
|
|
182
|
+
"""Convert the UI graph snapshot shape into the logical import artifact."""
|
|
183
|
+
|
|
184
|
+
nodes = []
|
|
185
|
+
for node in _listify((graph_payload or {}).get("nodes")):
|
|
186
|
+
node_id = node.get("id")
|
|
187
|
+
if not node_id:
|
|
188
|
+
continue
|
|
189
|
+
metadata = node.get("metadata") if isinstance(node.get("metadata"), dict) else {}
|
|
190
|
+
raw = node.get("raw") if isinstance(node.get("raw"), dict) else {}
|
|
191
|
+
if workspace_id and not metadata.get("workspace_id"):
|
|
192
|
+
metadata = {**metadata, "workspace_id": workspace_id}
|
|
193
|
+
nodes.append({
|
|
194
|
+
"id": node_id,
|
|
195
|
+
"type": node.get("type") or "Concept",
|
|
196
|
+
"title": node.get("title") or node.get("label") or node_id,
|
|
197
|
+
"summary": node.get("summary") or "",
|
|
198
|
+
"metadata_json": json.dumps(metadata, ensure_ascii=False),
|
|
199
|
+
"raw_json": json.dumps(raw, ensure_ascii=False),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
edges = []
|
|
203
|
+
for edge in _listify((graph_payload or {}).get("edges")):
|
|
204
|
+
source = edge.get("from_node") or edge.get("from") or edge.get("source")
|
|
205
|
+
target = edge.get("to_node") or edge.get("to") or edge.get("target")
|
|
206
|
+
if not source or not target:
|
|
207
|
+
continue
|
|
208
|
+
edges.append({
|
|
209
|
+
"from_node": source,
|
|
210
|
+
"to_node": target,
|
|
211
|
+
"type": edge.get("type") or "related_to",
|
|
212
|
+
"weight": edge.get("weight") or 1.0,
|
|
213
|
+
"metadata_json": json.dumps(edge.get("metadata") or {}, ensure_ascii=False),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"header": {"graph_schema_version": 1, "workspace_id": workspace_id, "source": "workspace_snapshot"},
|
|
218
|
+
"nodes": nodes,
|
|
219
|
+
"edges": edges,
|
|
220
|
+
"chunks": [],
|
|
221
|
+
"knowledge_sources": [],
|
|
222
|
+
"provenance": [],
|
|
223
|
+
"counts": {"nodes": len(nodes), "edges": len(edges)},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
176
227
|
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
177
228
|
if not value:
|
|
178
229
|
return None
|
|
@@ -188,6 +239,7 @@ class WorkspaceOSStore:
|
|
|
188
239
|
def __init__(self, data_dir: Path | str, *, event_sink: Optional[Callable[[Dict[str, Any]], Any]] = None):
|
|
189
240
|
self.data_dir = Path(data_dir).expanduser()
|
|
190
241
|
self.state_path = self.data_dir / "workspace_os.json"
|
|
242
|
+
self.sqlite_path = self.data_dir / "knowledge_graph.sqlite"
|
|
191
243
|
self.snapshots_dir = self.data_dir / "workspace_snapshots"
|
|
192
244
|
self.exports_dir = self.data_dir / "workspace_exports"
|
|
193
245
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -198,6 +250,59 @@ class WorkspaceOSStore:
|
|
|
198
250
|
# Defaults to None → zero behavior change for existing callers/tests.
|
|
199
251
|
self.event_sink = event_sink
|
|
200
252
|
|
|
253
|
+
def _connect_state_db(self) -> sqlite3.Connection:
|
|
254
|
+
conn = sqlite3.connect(self.sqlite_path)
|
|
255
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
256
|
+
conn.execute(
|
|
257
|
+
"CREATE TABLE IF NOT EXISTS workspace_os_state ("
|
|
258
|
+
"id TEXT PRIMARY KEY, state_json TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
|
259
|
+
)
|
|
260
|
+
conn.execute(
|
|
261
|
+
"CREATE TABLE IF NOT EXISTS workspace_os_meta ("
|
|
262
|
+
"key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
263
|
+
)
|
|
264
|
+
return conn
|
|
265
|
+
|
|
266
|
+
def _load_sqlite_state(self) -> Optional[Dict[str, Any]]:
|
|
267
|
+
try:
|
|
268
|
+
with self._connect_state_db() as conn:
|
|
269
|
+
row = conn.execute(
|
|
270
|
+
"SELECT state_json FROM workspace_os_state WHERE id='current'"
|
|
271
|
+
).fetchone()
|
|
272
|
+
if not row:
|
|
273
|
+
return None
|
|
274
|
+
data = json.loads(row[0])
|
|
275
|
+
return data if isinstance(data, dict) else None
|
|
276
|
+
except Exception:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def _save_sqlite_state(self, state: Dict[str, Any]) -> None:
|
|
280
|
+
payload = json.dumps(state, ensure_ascii=False)
|
|
281
|
+
with self._connect_state_db() as conn:
|
|
282
|
+
conn.execute(
|
|
283
|
+
"INSERT OR REPLACE INTO workspace_os_state(id, state_json, updated_at) VALUES('current', ?, ?)",
|
|
284
|
+
(payload, state.get("updated_at") or _now()),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _import_json_state_once(self, default: Dict[str, Any]) -> Dict[str, Any]:
|
|
288
|
+
if not self.state_path.exists():
|
|
289
|
+
return default
|
|
290
|
+
try:
|
|
291
|
+
loaded = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
292
|
+
if not isinstance(loaded, dict):
|
|
293
|
+
return default
|
|
294
|
+
except Exception:
|
|
295
|
+
return default
|
|
296
|
+
try:
|
|
297
|
+
backup = self.state_path.with_name(
|
|
298
|
+
f"{self.state_path.name}.pre-sqlite.{_now().replace(':', '-')}.json"
|
|
299
|
+
)
|
|
300
|
+
if not any(self.state_path.parent.glob(f"{self.state_path.name}.pre-sqlite.*.json")):
|
|
301
|
+
shutil.copy2(self.state_path, backup)
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
return _deep_merge(default, loaded)
|
|
305
|
+
|
|
201
306
|
@staticmethod
|
|
202
307
|
def _new_workspace_record(
|
|
203
308
|
*,
|
|
@@ -272,6 +377,47 @@ class WorkspaceOSStore:
|
|
|
272
377
|
state["active_workspace"] = DEFAULT_WORKSPACE_ID
|
|
273
378
|
return state
|
|
274
379
|
|
|
380
|
+
def migrate_workspace_identities(self, email_to_id: Dict[str, str]) -> int:
|
|
381
|
+
"""Rewrite workspace membership identities from legacy emails to UUIDs.
|
|
382
|
+
|
|
383
|
+
The migration is additive and in-place: workspace records, memberships,
|
|
384
|
+
and owner fields keep their shape, only identity string values change.
|
|
385
|
+
"""
|
|
386
|
+
if not email_to_id:
|
|
387
|
+
return 0
|
|
388
|
+
normalized = {str(email).strip().lower(): user_id for email, user_id in email_to_id.items() if user_id}
|
|
389
|
+
state = self.load_state()
|
|
390
|
+
changed = 0
|
|
391
|
+
for ws in (state.get("workspaces") or {}).values():
|
|
392
|
+
owner = str(ws.get("owner_user_id") or "").strip().lower()
|
|
393
|
+
if owner in normalized and ws.get("owner_user_id") != normalized[owner]:
|
|
394
|
+
ws["owner_user_id"] = normalized[owner]
|
|
395
|
+
changed += 1
|
|
396
|
+
for member in _listify(ws.get("members")):
|
|
397
|
+
member_id = str(member.get("user_id") or "").strip().lower()
|
|
398
|
+
if member_id in normalized and member.get("user_id") != normalized[member_id]:
|
|
399
|
+
member["user_id"] = normalized[member_id]
|
|
400
|
+
member["updated_at"] = _now()
|
|
401
|
+
changed += 1
|
|
402
|
+
members = _listify(ws.get("members"))
|
|
403
|
+
deduped = []
|
|
404
|
+
seen_members = set()
|
|
405
|
+
for member in members:
|
|
406
|
+
member_id = member.get("user_id")
|
|
407
|
+
if member_id and member_id in seen_members:
|
|
408
|
+
changed += 1
|
|
409
|
+
continue
|
|
410
|
+
if member_id:
|
|
411
|
+
seen_members.add(member_id)
|
|
412
|
+
deduped.append(member)
|
|
413
|
+
if len(deduped) != len(members):
|
|
414
|
+
ws["members"] = deduped
|
|
415
|
+
if changed:
|
|
416
|
+
state["updated_at"] = _now()
|
|
417
|
+
self.save_state(state)
|
|
418
|
+
self.record_timeline_event("workspace", "identity_uuid_migrated", {"records": changed})
|
|
419
|
+
return changed
|
|
420
|
+
|
|
275
421
|
def _default_state(self) -> Dict[str, Any]:
|
|
276
422
|
return {
|
|
277
423
|
"version": WORKSPACE_OS_VERSION,
|
|
@@ -351,23 +497,21 @@ class WorkspaceOSStore:
|
|
|
351
497
|
|
|
352
498
|
def load_state(self) -> Dict[str, Any]:
|
|
353
499
|
default = self._default_state()
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
loaded = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
359
|
-
if not isinstance(loaded, dict):
|
|
360
|
-
loaded = {}
|
|
361
|
-
except Exception:
|
|
362
|
-
loaded = {}
|
|
500
|
+
loaded = self._load_sqlite_state()
|
|
501
|
+
imported = loaded is None
|
|
502
|
+
if loaded is None:
|
|
503
|
+
loaded = self._import_json_state_once(default)
|
|
363
504
|
state = _deep_merge(default, loaded)
|
|
364
505
|
state["version"] = WORKSPACE_OS_VERSION
|
|
365
506
|
self._migrate_workspaces(state)
|
|
507
|
+
if imported:
|
|
508
|
+
self.save_state(state)
|
|
366
509
|
return state
|
|
367
510
|
|
|
368
511
|
def save_state(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
369
512
|
state["version"] = WORKSPACE_OS_VERSION
|
|
370
513
|
state["updated_at"] = _now()
|
|
514
|
+
self._save_sqlite_state(state)
|
|
371
515
|
_atomic_write_json(self.state_path, state)
|
|
372
516
|
return state
|
|
373
517
|
|
|
@@ -382,7 +526,6 @@ class WorkspaceOSStore:
|
|
|
382
526
|
"payload": payload,
|
|
383
527
|
}
|
|
384
528
|
state.setdefault("timeline", []).append(event)
|
|
385
|
-
state["timeline"] = state["timeline"][-500:]
|
|
386
529
|
self.save_state(state)
|
|
387
530
|
if self.event_sink is not None:
|
|
388
531
|
try:
|
|
@@ -430,7 +573,10 @@ class WorkspaceOSStore:
|
|
|
430
573
|
"version": WORKSPACE_OS_VERSION,
|
|
431
574
|
"identity": state.get("identity"),
|
|
432
575
|
"active_workspace": state.get("active_workspace"),
|
|
433
|
-
|
|
576
|
+
# The raw workspace registry (with member lists) must not leak to
|
|
577
|
+
# non-members; WorkspaceService.summary() adds a membership-filtered
|
|
578
|
+
# "workspace_registry" instead.
|
|
579
|
+
"workspace_count": len(state.get("workspaces") or {}),
|
|
434
580
|
"navigation": list(WORKSPACE_AREAS),
|
|
435
581
|
"feature_flags": state.get("feature_flags"),
|
|
436
582
|
"updated_at": state.get("updated_at"),
|
|
@@ -900,7 +1046,6 @@ class WorkspaceOSStore:
|
|
|
900
1046
|
**trace,
|
|
901
1047
|
}
|
|
902
1048
|
state.setdefault("traces", []).append(record)
|
|
903
|
-
state["traces"] = state["traces"][-200:]
|
|
904
1049
|
self.save_state(state)
|
|
905
1050
|
self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
|
|
906
1051
|
return record
|
|
@@ -1042,7 +1187,6 @@ class WorkspaceOSStore:
|
|
|
1042
1187
|
"indexed_folder_count": len(local_sources.get("sources") or []),
|
|
1043
1188
|
}
|
|
1044
1189
|
state.setdefault("snapshots", []).append(meta)
|
|
1045
|
-
state["snapshots"] = state["snapshots"][-200:]
|
|
1046
1190
|
self.save_state(state)
|
|
1047
1191
|
self.record_timeline_event("snapshot", "snapshot_saved", {"snapshot_id": snapshot_id, "name": name})
|
|
1048
1192
|
return {"snapshot": meta}
|
|
@@ -1086,6 +1230,52 @@ class WorkspaceOSStore:
|
|
|
1086
1230
|
self.record_timeline_event("snapshot", "snapshot_exported", {"snapshot_id": snapshot_id, "path": str(export_path)})
|
|
1087
1231
|
return {"snapshot_id": snapshot_id, "export_path": str(export_path), "bytes": export_path.stat().st_size}
|
|
1088
1232
|
|
|
1233
|
+
def restore_snapshot(
|
|
1234
|
+
self,
|
|
1235
|
+
snapshot_id: str,
|
|
1236
|
+
*,
|
|
1237
|
+
graph: Any,
|
|
1238
|
+
workspace_id: Optional[str] = None,
|
|
1239
|
+
user_email: Optional[str] = None,
|
|
1240
|
+
) -> Dict[str, Any]:
|
|
1241
|
+
"""Restore a snapshot additively, preserving all current user data.
|
|
1242
|
+
|
|
1243
|
+
v4 snapshots are immutable checkpoints. Restoring one must not delete
|
|
1244
|
+
newer graph nodes, chat history, memories, workspaces, or settings, so
|
|
1245
|
+
this operation imports the snapshot graph in ``merge`` mode and records a
|
|
1246
|
+
durable restore event. It is a real restore path for lost/missing graph
|
|
1247
|
+
data, with rollback safety because current state remains intact.
|
|
1248
|
+
"""
|
|
1249
|
+
|
|
1250
|
+
snapshot = self.get_snapshot(snapshot_id)
|
|
1251
|
+
scope = self._resolve_scope(workspace_id or snapshot.get("workspace_id"))
|
|
1252
|
+
if graph is None or not hasattr(graph, "import_graph_data"):
|
|
1253
|
+
raise ValueError("knowledge graph import is required for snapshot restore")
|
|
1254
|
+
artifact = _snapshot_graph_import_payload(snapshot.get("graph") or {}, workspace_id=scope)
|
|
1255
|
+
import_result = graph.import_graph_data(artifact, mode="merge", dry_run=False)
|
|
1256
|
+
restore_id = f"restore-{datetime.now().strftime('%Y%m%d%H%M%S')}-{_json_hash([snapshot_id, scope, user_email, _now()])[:10]}"
|
|
1257
|
+
record = {
|
|
1258
|
+
"id": restore_id,
|
|
1259
|
+
"snapshot_id": snapshot_id,
|
|
1260
|
+
"workspace_id": scope,
|
|
1261
|
+
"restored_at": _now(),
|
|
1262
|
+
"restored_by": user_email,
|
|
1263
|
+
"mode": "merge",
|
|
1264
|
+
"graph": import_result,
|
|
1265
|
+
"settings_preserved": True,
|
|
1266
|
+
"chat_preserved": True,
|
|
1267
|
+
}
|
|
1268
|
+
state = self.load_state()
|
|
1269
|
+
state.setdefault("snapshot_restores", []).append(record)
|
|
1270
|
+
self.save_state(state)
|
|
1271
|
+
self.record_timeline_event(
|
|
1272
|
+
"snapshot",
|
|
1273
|
+
"snapshot_restored",
|
|
1274
|
+
{"snapshot_id": snapshot_id, "restore_id": restore_id, "mode": "merge", "graph": import_result},
|
|
1275
|
+
workspace_id=scope,
|
|
1276
|
+
)
|
|
1277
|
+
return {"restored": True, "restore": record}
|
|
1278
|
+
|
|
1089
1279
|
def compare_snapshots(self, before_id: str, after_id: str) -> Dict[str, Any]:
|
|
1090
1280
|
before = self.get_snapshot(before_id)
|
|
1091
1281
|
after = self.get_snapshot(after_id)
|
|
@@ -1204,7 +1394,7 @@ class WorkspaceOSStore:
|
|
|
1204
1394
|
record["graph_error"] = str(exc)
|
|
1205
1395
|
if existing is None:
|
|
1206
1396
|
memories.append(record)
|
|
1207
|
-
state["memories"] = memories
|
|
1397
|
+
state["memories"] = memories
|
|
1208
1398
|
self.save_state(state)
|
|
1209
1399
|
self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind}, workspace_id=record.get("workspace_id"))
|
|
1210
1400
|
return record
|
|
@@ -1229,15 +1419,26 @@ class WorkspaceOSStore:
|
|
|
1229
1419
|
]
|
|
1230
1420
|
return {"query": query, "memories": memories[: max(1, min(limit, 100))]}
|
|
1231
1421
|
|
|
1422
|
+
def get_memory(self, memory_id: str) -> Dict[str, Any]:
|
|
1423
|
+
record = next(
|
|
1424
|
+
(item for item in _listify(self.load_state().get("memories")) if item.get("id") == memory_id),
|
|
1425
|
+
None,
|
|
1426
|
+
)
|
|
1427
|
+
if record is None:
|
|
1428
|
+
raise FileNotFoundError(memory_id)
|
|
1429
|
+
return record
|
|
1430
|
+
|
|
1232
1431
|
def delete_memory(self, memory_id: str) -> Dict[str, Any]:
|
|
1233
1432
|
state = self.load_state()
|
|
1234
1433
|
memories = _listify(state.get("memories"))
|
|
1235
|
-
|
|
1236
|
-
if
|
|
1434
|
+
target = next((item for item in memories if item.get("id") == memory_id), None)
|
|
1435
|
+
if target is None:
|
|
1237
1436
|
raise FileNotFoundError(memory_id)
|
|
1238
|
-
state["memories"] =
|
|
1437
|
+
state["memories"] = [item for item in memories if item.get("id") != memory_id]
|
|
1239
1438
|
self.save_state(state)
|
|
1240
|
-
self.record_timeline_event(
|
|
1439
|
+
self.record_timeline_event(
|
|
1440
|
+
"memory", "memory_deleted", {"memory_id": memory_id}, workspace_id=target.get("workspace_id")
|
|
1441
|
+
)
|
|
1241
1442
|
return {"status": "ok", "memory_id": memory_id}
|
|
1242
1443
|
|
|
1243
1444
|
def create_memory_snapshot(
|
|
@@ -1269,7 +1470,6 @@ class WorkspaceOSStore:
|
|
|
1269
1470
|
"created_at": _now(),
|
|
1270
1471
|
}
|
|
1271
1472
|
state.setdefault("memory_snapshots", []).append(snapshot)
|
|
1272
|
-
state["memory_snapshots"] = state["memory_snapshots"][-200:]
|
|
1273
1473
|
self.save_state(state)
|
|
1274
1474
|
self.record_timeline_event("memory", "memory_snapshot", {"snapshot_id": snapshot["id"], "memory_count": len(memories)}, workspace_id=scope)
|
|
1275
1475
|
return snapshot
|
|
@@ -1306,12 +1506,15 @@ class WorkspaceOSStore:
|
|
|
1306
1506
|
memory_snapshots: Optional[List[Dict[str, Any]]] = None,
|
|
1307
1507
|
graph: Any = None,
|
|
1308
1508
|
workspace_id: Optional[str] = None,
|
|
1509
|
+
mode: str = "simulation",
|
|
1309
1510
|
) -> Dict[str, Any]:
|
|
1310
1511
|
state = self.load_state()
|
|
1311
1512
|
resolved_workspace = self._resolve_scope(workspace_id, state)
|
|
1312
1513
|
run = {
|
|
1313
1514
|
"id": f"agent-run-{_json_hash([agent_id, input_text, output_text, _now()])[:16]}",
|
|
1515
|
+
"record_schema_version": 2,
|
|
1314
1516
|
"agent_id": agent_id,
|
|
1517
|
+
"mode": mode,
|
|
1315
1518
|
"status": status,
|
|
1316
1519
|
"input": input_text,
|
|
1317
1520
|
"output_preview": output_text[:1000],
|
|
@@ -1328,14 +1531,19 @@ class WorkspaceOSStore:
|
|
|
1328
1531
|
"memory_snapshots": memory_snapshots or [],
|
|
1329
1532
|
"created_at": _now(),
|
|
1330
1533
|
}
|
|
1331
|
-
if
|
|
1534
|
+
if mode == "simulation":
|
|
1535
|
+
# Simulated runs are replay scaffolding, not experiences — they must
|
|
1536
|
+
# never enter the knowledge graph as real provenance.
|
|
1537
|
+
run["graph_node_id"] = None
|
|
1538
|
+
run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
|
|
1539
|
+
elif graph is not None:
|
|
1332
1540
|
try:
|
|
1333
1541
|
ingested = graph.ingest_event(
|
|
1334
1542
|
"AgentRun",
|
|
1335
1543
|
f"{agent_id} {status}",
|
|
1336
1544
|
user_email=user_email,
|
|
1337
1545
|
source="workspace_os",
|
|
1338
|
-
metadata={"run_id": run["id"], "agent_id": agent_id, "status": status},
|
|
1546
|
+
metadata={"run_id": run["id"], "agent_id": agent_id, "status": status, "mode": mode},
|
|
1339
1547
|
)
|
|
1340
1548
|
run["graph_node_id"] = ingested.get("node_id")
|
|
1341
1549
|
except Exception as exc:
|
|
@@ -1349,9 +1557,8 @@ class WorkspaceOSStore:
|
|
|
1349
1557
|
"workspace_id": resolved_workspace,
|
|
1350
1558
|
}
|
|
1351
1559
|
stored_handoffs.append(stored)
|
|
1352
|
-
state["handoffs"] = stored_handoffs
|
|
1560
|
+
state["handoffs"] = stored_handoffs
|
|
1353
1561
|
state.setdefault("agent_runs", []).append(run)
|
|
1354
|
-
state["agent_runs"] = state["agent_runs"][-300:]
|
|
1355
1562
|
self.save_state(state)
|
|
1356
1563
|
self._emit_replayable_timeline_events(area="agent", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
|
|
1357
1564
|
if status == "failed":
|
|
@@ -1359,6 +1566,93 @@ class WorkspaceOSStore:
|
|
|
1359
1566
|
self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
|
|
1360
1567
|
return run
|
|
1361
1568
|
|
|
1569
|
+
def update_agent_run(
|
|
1570
|
+
self,
|
|
1571
|
+
run_id: str,
|
|
1572
|
+
*,
|
|
1573
|
+
workspace_id: Optional[str] = None,
|
|
1574
|
+
graph: Any = None,
|
|
1575
|
+
patch: Optional[Dict[str, Any]] = None,
|
|
1576
|
+
**fields: Any,
|
|
1577
|
+
) -> Dict[str, Any]:
|
|
1578
|
+
"""Patch a persisted agent run without changing its id.
|
|
1579
|
+
|
|
1580
|
+
Async execution creates a durable queued/running row before work starts,
|
|
1581
|
+
then updates that same row as progress, cancellation, or a terminal
|
|
1582
|
+
result arrives. This keeps old run lists/read APIs compatible while
|
|
1583
|
+
avoiding duplicate "placeholder + final" records.
|
|
1584
|
+
"""
|
|
1585
|
+
updates = {**(patch or {}), **fields}
|
|
1586
|
+
state = self.load_state()
|
|
1587
|
+
run = next((item for item in _listify(state.get("agent_runs")) if item.get("id") == run_id), None)
|
|
1588
|
+
if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
1589
|
+
raise FileNotFoundError(run_id)
|
|
1590
|
+
resolved_workspace = self._record_workspace(run)
|
|
1591
|
+
old_timeline_len = len(run.get("timeline") or [])
|
|
1592
|
+
|
|
1593
|
+
output_text = updates.pop("output_text", None)
|
|
1594
|
+
if output_text is not None:
|
|
1595
|
+
run["output_preview"] = str(output_text)[:1000]
|
|
1596
|
+
for key, value in updates.items():
|
|
1597
|
+
run[key] = value
|
|
1598
|
+
status = str(run.get("status") or "")
|
|
1599
|
+
run["updated_at"] = _now()
|
|
1600
|
+
if status in RUN_TERMINAL_STATUSES:
|
|
1601
|
+
run.setdefault("completed_at", _now())
|
|
1602
|
+
|
|
1603
|
+
handoffs = updates.get("handoffs")
|
|
1604
|
+
if isinstance(handoffs, list):
|
|
1605
|
+
stored_handoffs = [
|
|
1606
|
+
item for item in _listify(state.get("handoffs"))
|
|
1607
|
+
if item.get("run_id") != run_id
|
|
1608
|
+
]
|
|
1609
|
+
for handoff in handoffs:
|
|
1610
|
+
if isinstance(handoff, dict):
|
|
1611
|
+
stored_handoffs.append({**handoff, "run_id": run_id, "workspace_id": resolved_workspace})
|
|
1612
|
+
state["handoffs"] = stored_handoffs
|
|
1613
|
+
|
|
1614
|
+
if (
|
|
1615
|
+
status in RUN_TERMINAL_STATUSES
|
|
1616
|
+
and run.get("mode") != "simulation"
|
|
1617
|
+
and graph is not None
|
|
1618
|
+
and not run.get("graph_node_id")
|
|
1619
|
+
):
|
|
1620
|
+
try:
|
|
1621
|
+
ingested = graph.ingest_event(
|
|
1622
|
+
"AgentRun",
|
|
1623
|
+
f"{run.get('agent_id')} {status}",
|
|
1624
|
+
user_email=run.get("user_email"),
|
|
1625
|
+
source="workspace_os",
|
|
1626
|
+
metadata={
|
|
1627
|
+
"run_id": run_id,
|
|
1628
|
+
"agent_id": run.get("agent_id"),
|
|
1629
|
+
"status": status,
|
|
1630
|
+
"mode": run.get("mode"),
|
|
1631
|
+
},
|
|
1632
|
+
)
|
|
1633
|
+
run["graph_node_id"] = ingested.get("node_id")
|
|
1634
|
+
except Exception as exc:
|
|
1635
|
+
run["graph_error"] = str(exc)
|
|
1636
|
+
|
|
1637
|
+
self.save_state(state)
|
|
1638
|
+
|
|
1639
|
+
timeline = run.get("timeline") or []
|
|
1640
|
+
if len(timeline) > old_timeline_len:
|
|
1641
|
+
self._emit_replayable_timeline_events(
|
|
1642
|
+
area="agent",
|
|
1643
|
+
run_id=run_id,
|
|
1644
|
+
timeline=timeline[old_timeline_len:],
|
|
1645
|
+
workspace_id=resolved_workspace,
|
|
1646
|
+
)
|
|
1647
|
+
if status == "failed":
|
|
1648
|
+
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)
|
|
1649
|
+
elif status == "cancelled":
|
|
1650
|
+
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)
|
|
1651
|
+
elif status == "interrupted":
|
|
1652
|
+
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)
|
|
1653
|
+
self.record_timeline_event("agent", "agent_run_update", {"run_id": run_id, "agent_id": run.get("agent_id"), "status": status}, workspace_id=resolved_workspace)
|
|
1654
|
+
return run
|
|
1655
|
+
|
|
1362
1656
|
def get_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1363
1657
|
run = next((item for item in _listify(self.load_state().get("agent_runs")) if item.get("id") == run_id), None)
|
|
1364
1658
|
if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
@@ -1411,7 +1705,6 @@ class WorkspaceOSStore:
|
|
|
1411
1705
|
except Exception as exc:
|
|
1412
1706
|
workflow["graph_error"] = str(exc)
|
|
1413
1707
|
state.setdefault("workflows", []).append(workflow)
|
|
1414
|
-
state["workflows"] = state["workflows"][-300:]
|
|
1415
1708
|
self.save_state(state)
|
|
1416
1709
|
self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
|
|
1417
1710
|
return workflow
|
|
@@ -1427,14 +1720,18 @@ class WorkspaceOSStore:
|
|
|
1427
1720
|
user_email: Optional[str] = None,
|
|
1428
1721
|
graph: Any = None,
|
|
1429
1722
|
workspace_id: Optional[str] = None,
|
|
1723
|
+
mode: str = "simulation",
|
|
1724
|
+
pause: Optional[Dict[str, Any]] = None,
|
|
1430
1725
|
) -> Dict[str, Any]:
|
|
1431
1726
|
"""Persist a Workflow Designer execution into local-first run history."""
|
|
1432
1727
|
state = self.load_state()
|
|
1433
1728
|
resolved_workspace = self._resolve_scope(workspace_id, state)
|
|
1434
1729
|
run = {
|
|
1435
1730
|
"id": f"workflow-run-{_json_hash([workflow_id, name, status, _now()])[:16]}",
|
|
1731
|
+
"record_schema_version": 2,
|
|
1436
1732
|
"workflow_id": workflow_id,
|
|
1437
1733
|
"name": name or "workflow",
|
|
1734
|
+
"mode": mode,
|
|
1438
1735
|
"status": status,
|
|
1439
1736
|
"timeline": timeline or [],
|
|
1440
1737
|
"outputs": outputs or {},
|
|
@@ -1442,20 +1739,26 @@ class WorkspaceOSStore:
|
|
|
1442
1739
|
"workspace_id": resolved_workspace,
|
|
1443
1740
|
"created_at": _now(),
|
|
1444
1741
|
}
|
|
1445
|
-
if
|
|
1742
|
+
if pause:
|
|
1743
|
+
run["pause"] = pause
|
|
1744
|
+
if mode == "simulation":
|
|
1745
|
+
# Record-only node runners do no real work; their runs must not be
|
|
1746
|
+
# written into the knowledge graph as if they were real executions.
|
|
1747
|
+
run["graph_node_id"] = None
|
|
1748
|
+
run["graph_skipped"] = "simulation runs are not recorded in the knowledge graph"
|
|
1749
|
+
elif graph is not None:
|
|
1446
1750
|
try:
|
|
1447
1751
|
ingested = graph.ingest_event(
|
|
1448
1752
|
"WorkflowRun",
|
|
1449
1753
|
f"{run['name']} {status}",
|
|
1450
1754
|
user_email=user_email,
|
|
1451
1755
|
source="workspace_os",
|
|
1452
|
-
metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status},
|
|
1756
|
+
metadata={"run_id": run["id"], "workflow_id": workflow_id, "status": status, "mode": mode},
|
|
1453
1757
|
)
|
|
1454
1758
|
run["graph_node_id"] = ingested.get("node_id")
|
|
1455
1759
|
except Exception as exc:
|
|
1456
1760
|
run["graph_error"] = str(exc)
|
|
1457
1761
|
state.setdefault("workflow_runs", []).append(run)
|
|
1458
|
-
state["workflow_runs"] = state["workflow_runs"][-300:]
|
|
1459
1762
|
# Attach the run id to the workflow's event log for cross-linking.
|
|
1460
1763
|
for wf in _listify(state.get("workflows")):
|
|
1461
1764
|
if wf.get("id") == workflow_id:
|
|
@@ -1472,18 +1775,164 @@ class WorkspaceOSStore:
|
|
|
1472
1775
|
self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
1473
1776
|
return run
|
|
1474
1777
|
|
|
1778
|
+
def update_workflow_run(
|
|
1779
|
+
self,
|
|
1780
|
+
run_id: str,
|
|
1781
|
+
*,
|
|
1782
|
+
workspace_id: Optional[str] = None,
|
|
1783
|
+
graph: Any = None,
|
|
1784
|
+
patch: Optional[Dict[str, Any]] = None,
|
|
1785
|
+
**fields: Any,
|
|
1786
|
+
) -> Dict[str, Any]:
|
|
1787
|
+
"""Patch a persisted workflow run in place for async execution."""
|
|
1788
|
+
updates = {**(patch or {}), **fields}
|
|
1789
|
+
state = self.load_state()
|
|
1790
|
+
run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
|
|
1791
|
+
if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
1792
|
+
raise FileNotFoundError(run_id)
|
|
1793
|
+
resolved_workspace = self._record_workspace(run)
|
|
1794
|
+
old_timeline_len = len(run.get("timeline") or [])
|
|
1795
|
+
|
|
1796
|
+
for key, value in updates.items():
|
|
1797
|
+
if value is None and key == "pause":
|
|
1798
|
+
run.pop("pause", None)
|
|
1799
|
+
else:
|
|
1800
|
+
run[key] = value
|
|
1801
|
+
status = str(run.get("status") or "")
|
|
1802
|
+
run["updated_at"] = _now()
|
|
1803
|
+
if status in RUN_TERMINAL_STATUSES:
|
|
1804
|
+
run.setdefault("completed_at", _now())
|
|
1805
|
+
|
|
1806
|
+
workflow_id = run.get("workflow_id")
|
|
1807
|
+
for wf in _listify(state.get("workflows")):
|
|
1808
|
+
if wf.get("id") == workflow_id:
|
|
1809
|
+
wf.setdefault("events", []).append({"type": "run_update", "timestamp": _now(), "payload": {"run_id": run_id, "status": status}})
|
|
1810
|
+
wf["updated_at"] = _now()
|
|
1811
|
+
break
|
|
1812
|
+
|
|
1813
|
+
if (
|
|
1814
|
+
status in RUN_TERMINAL_STATUSES
|
|
1815
|
+
and run.get("mode") != "simulation"
|
|
1816
|
+
and graph is not None
|
|
1817
|
+
and not run.get("graph_node_id")
|
|
1818
|
+
):
|
|
1819
|
+
try:
|
|
1820
|
+
ingested = graph.ingest_event(
|
|
1821
|
+
"WorkflowRun",
|
|
1822
|
+
f"{run.get('name')} {status}",
|
|
1823
|
+
user_email=run.get("user_email"),
|
|
1824
|
+
source="workspace_os",
|
|
1825
|
+
metadata={
|
|
1826
|
+
"run_id": run_id,
|
|
1827
|
+
"workflow_id": workflow_id,
|
|
1828
|
+
"status": status,
|
|
1829
|
+
"mode": run.get("mode"),
|
|
1830
|
+
},
|
|
1831
|
+
)
|
|
1832
|
+
run["graph_node_id"] = ingested.get("node_id")
|
|
1833
|
+
except Exception as exc:
|
|
1834
|
+
run["graph_error"] = str(exc)
|
|
1835
|
+
|
|
1836
|
+
self.save_state(state)
|
|
1837
|
+
|
|
1838
|
+
timeline = run.get("timeline") or []
|
|
1839
|
+
if len(timeline) > old_timeline_len:
|
|
1840
|
+
self._emit_replayable_timeline_events(
|
|
1841
|
+
area="workflow",
|
|
1842
|
+
run_id=run_id,
|
|
1843
|
+
timeline=timeline[old_timeline_len:],
|
|
1844
|
+
workspace_id=resolved_workspace,
|
|
1845
|
+
)
|
|
1846
|
+
if status == "failed":
|
|
1847
|
+
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)
|
|
1848
|
+
elif status in {"ok", "partial"}:
|
|
1849
|
+
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)
|
|
1850
|
+
elif status == "cancelled":
|
|
1851
|
+
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)
|
|
1852
|
+
elif status == "interrupted":
|
|
1853
|
+
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)
|
|
1854
|
+
self.record_timeline_event("workflow", "workflow_run_update", {"run_id": run_id, "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
1855
|
+
return run
|
|
1856
|
+
|
|
1475
1857
|
def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1476
1858
|
runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
|
|
1477
1859
|
if workflow_id:
|
|
1478
1860
|
runs = [run for run in runs if run.get("workflow_id") == workflow_id]
|
|
1479
1861
|
return {"runs": list(reversed(runs[-max(1, min(limit, 300)):]))}
|
|
1480
1862
|
|
|
1863
|
+
def mark_workflow_run_resolved(
|
|
1864
|
+
self, run_id: str, *, resumed_run_id: str, approved: bool,
|
|
1865
|
+
workspace_id: Optional[str] = None,
|
|
1866
|
+
) -> Dict[str, Any]:
|
|
1867
|
+
"""Close out a paused run after its approval decision (one decision only)."""
|
|
1868
|
+
state = self.load_state()
|
|
1869
|
+
run = next((item for item in _listify(state.get("workflow_runs")) if item.get("id") == run_id), None)
|
|
1870
|
+
if run is None or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
1871
|
+
raise FileNotFoundError(run_id)
|
|
1872
|
+
run["status"] = "resumed" if approved else "denied"
|
|
1873
|
+
run["resolved_at"] = _now()
|
|
1874
|
+
run["resumed_run_id"] = resumed_run_id
|
|
1875
|
+
self.save_state(state)
|
|
1876
|
+
return run
|
|
1877
|
+
|
|
1481
1878
|
def get_workflow_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1482
1879
|
run = next((item for item in _listify(self.load_state().get("workflow_runs")) if item.get("id") == run_id), None)
|
|
1483
1880
|
if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
1484
1881
|
raise FileNotFoundError(run_id)
|
|
1485
1882
|
return run
|
|
1486
1883
|
|
|
1884
|
+
def reconcile_interrupted_runs(self, *, reason: str = "server_startup") -> Dict[str, Any]:
|
|
1885
|
+
"""Mark durable active runs as interrupted after a process restart.
|
|
1886
|
+
|
|
1887
|
+
Queued/running/cancelling rows cannot have an owning asyncio task after
|
|
1888
|
+
startup. Paused approval runs are intentionally left untouched so their
|
|
1889
|
+
durable human decision cursor remains resumable.
|
|
1890
|
+
"""
|
|
1891
|
+
state = self.load_state()
|
|
1892
|
+
interrupted: List[Dict[str, Any]] = []
|
|
1893
|
+
now = _now()
|
|
1894
|
+
collections = (("agent_runs", "agent"), ("workflow_runs", "workflow"))
|
|
1895
|
+
for key, area in collections:
|
|
1896
|
+
for run in _listify(state.get(key)):
|
|
1897
|
+
status = str(run.get("status") or "")
|
|
1898
|
+
if status not in RUN_ACTIVE_STATUSES:
|
|
1899
|
+
continue
|
|
1900
|
+
run["status"] = "interrupted"
|
|
1901
|
+
run["interrupted_at"] = now
|
|
1902
|
+
run["interrupt_reason"] = reason
|
|
1903
|
+
run["updated_at"] = now
|
|
1904
|
+
run.setdefault("timeline", []).append({
|
|
1905
|
+
"event": "execution_interrupted",
|
|
1906
|
+
"status": "interrupted",
|
|
1907
|
+
"reason": reason,
|
|
1908
|
+
"timestamp": now,
|
|
1909
|
+
})
|
|
1910
|
+
interrupted.append({
|
|
1911
|
+
"kind": area,
|
|
1912
|
+
"run_id": run.get("id"),
|
|
1913
|
+
"workspace_id": self._record_workspace(run),
|
|
1914
|
+
"previous_status": status,
|
|
1915
|
+
})
|
|
1916
|
+
if not interrupted:
|
|
1917
|
+
return {"count": 0, "interrupted": []}
|
|
1918
|
+
self.save_state(state)
|
|
1919
|
+
for item in interrupted:
|
|
1920
|
+
area = item["kind"]
|
|
1921
|
+
run_id = item["run_id"]
|
|
1922
|
+
workspace = item.get("workspace_id")
|
|
1923
|
+
self._emit_execution_event(
|
|
1924
|
+
area=area,
|
|
1925
|
+
event_type="execution_interrupted",
|
|
1926
|
+
payload={"run_id": run_id, "reason": reason, "previous_status": item.get("previous_status")},
|
|
1927
|
+
workspace_id=workspace,
|
|
1928
|
+
)
|
|
1929
|
+
self.record_timeline_event(
|
|
1930
|
+
"system",
|
|
1931
|
+
"startup_reconciliation",
|
|
1932
|
+
{"interrupted_runs": len(interrupted), "reason": reason},
|
|
1933
|
+
)
|
|
1934
|
+
return {"count": len(interrupted), "interrupted": interrupted}
|
|
1935
|
+
|
|
1487
1936
|
@staticmethod
|
|
1488
1937
|
def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
|
|
1489
1938
|
frames = []
|
|
@@ -1699,7 +2148,6 @@ class WorkspaceOSStore:
|
|
|
1699
2148
|
**activity,
|
|
1700
2149
|
}
|
|
1701
2150
|
config.setdefault("activities", []).append(record)
|
|
1702
|
-
config["activities"] = config["activities"][-500:]
|
|
1703
2151
|
if graph is not None:
|
|
1704
2152
|
try:
|
|
1705
2153
|
graph.ingest_event(
|