ltcai 4.0.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 +37 -33
- package/docs/CHANGELOG.md +64 -0
- package/docs/REALTIME_COLLABORATION.md +3 -3
- package/docs/V3_FRONTEND.md +9 -8
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +86 -43
- package/docs/kg-schema.md +6 -2
- package/docs/spec-vs-impl.md +10 -10
- package/kg_schema.py +2 -603
- package/knowledge_graph.py +37 -4958
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +15 -16
- package/latticeai/api/agents.py +13 -6
- package/latticeai/api/auth.py +19 -11
- package/latticeai/api/invitations.py +100 -0
- package/latticeai/api/knowledge_graph.py +4 -11
- package/latticeai/api/plugins.py +3 -6
- package/latticeai/api/realtime.py +4 -7
- package/latticeai/api/static_routes.py +9 -12
- package/latticeai/api/ui_redirects.py +26 -0
- package/latticeai/api/workflow_designer.py +39 -6
- package/latticeai/api/workspace.py +24 -10
- package/latticeai/app_factory.py +88 -17
- package/latticeai/brain/_kg_common.py +1123 -0
- package/latticeai/brain/discovery.py +1455 -0
- package/latticeai/brain/documents.py +218 -0
- package/latticeai/brain/ingest.py +644 -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/invitations.py +131 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/policy.py +54 -0
- package/latticeai/core/realtime.py +65 -44
- package/latticeai/core/sessions.py +31 -5
- package/latticeai/core/users.py +147 -0
- package/latticeai/core/workspace_os.py +420 -20
- package/latticeai/services/agent_runtime.py +242 -4
- package/latticeai/services/run_executor.py +328 -0
- package/latticeai/services/workspace_service.py +27 -19
- package/package.json +2 -14
- package/scripts/lint_v3.mjs +23 -0
- package/static/v3/asset-manifest.json +21 -14
- package/static/v3/js/{app.356e6452.js → app.c5c80c46.js} +1 -1
- package/static/v3/js/core/{api.7a308b89.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.a1657f20.js → shell.e3f6bbfa.js} +67 -38
- package/static/v3/js/core/shell.js +65 -36
- package/static/v3/js/core/{store.204a08b2.js → store.7b2aa044.js} +10 -0
- package/static/v3/js/core/store.js +10 -0
- 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/{knowledge-graph.5e40cbeb.js → knowledge-graph.4d09c537.js} +27 -7
- package/static/v3/js/views/knowledge-graph.js +27 -7
- 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/account.html +0 -113
- package/static/activity.html +0 -73
- package/static/admin.html +0 -486
- package/static/agents.html +0 -139
- package/static/chat.html +0 -841
- 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 -122
- 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.7222343d.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 = "4.0.
|
|
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:
|
|
@@ -903,7 +1046,6 @@ class WorkspaceOSStore:
|
|
|
903
1046
|
**trace,
|
|
904
1047
|
}
|
|
905
1048
|
state.setdefault("traces", []).append(record)
|
|
906
|
-
state["traces"] = state["traces"][-200:]
|
|
907
1049
|
self.save_state(state)
|
|
908
1050
|
self.record_timeline_event("graph", "answer_trace", {"trace_id": trace_id, "conversation_id": conversation_id})
|
|
909
1051
|
return record
|
|
@@ -1045,7 +1187,6 @@ class WorkspaceOSStore:
|
|
|
1045
1187
|
"indexed_folder_count": len(local_sources.get("sources") or []),
|
|
1046
1188
|
}
|
|
1047
1189
|
state.setdefault("snapshots", []).append(meta)
|
|
1048
|
-
state["snapshots"] = state["snapshots"][-200:]
|
|
1049
1190
|
self.save_state(state)
|
|
1050
1191
|
self.record_timeline_event("snapshot", "snapshot_saved", {"snapshot_id": snapshot_id, "name": name})
|
|
1051
1192
|
return {"snapshot": meta}
|
|
@@ -1089,6 +1230,52 @@ class WorkspaceOSStore:
|
|
|
1089
1230
|
self.record_timeline_event("snapshot", "snapshot_exported", {"snapshot_id": snapshot_id, "path": str(export_path)})
|
|
1090
1231
|
return {"snapshot_id": snapshot_id, "export_path": str(export_path), "bytes": export_path.stat().st_size}
|
|
1091
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
|
+
|
|
1092
1279
|
def compare_snapshots(self, before_id: str, after_id: str) -> Dict[str, Any]:
|
|
1093
1280
|
before = self.get_snapshot(before_id)
|
|
1094
1281
|
after = self.get_snapshot(after_id)
|
|
@@ -1207,7 +1394,7 @@ class WorkspaceOSStore:
|
|
|
1207
1394
|
record["graph_error"] = str(exc)
|
|
1208
1395
|
if existing is None:
|
|
1209
1396
|
memories.append(record)
|
|
1210
|
-
state["memories"] = memories
|
|
1397
|
+
state["memories"] = memories
|
|
1211
1398
|
self.save_state(state)
|
|
1212
1399
|
self.record_timeline_event("memory", "memory_upserted", {"memory_id": memory_id, "kind": kind}, workspace_id=record.get("workspace_id"))
|
|
1213
1400
|
return record
|
|
@@ -1283,7 +1470,6 @@ class WorkspaceOSStore:
|
|
|
1283
1470
|
"created_at": _now(),
|
|
1284
1471
|
}
|
|
1285
1472
|
state.setdefault("memory_snapshots", []).append(snapshot)
|
|
1286
|
-
state["memory_snapshots"] = state["memory_snapshots"][-200:]
|
|
1287
1473
|
self.save_state(state)
|
|
1288
1474
|
self.record_timeline_event("memory", "memory_snapshot", {"snapshot_id": snapshot["id"], "memory_count": len(memories)}, workspace_id=scope)
|
|
1289
1475
|
return snapshot
|
|
@@ -1371,9 +1557,8 @@ class WorkspaceOSStore:
|
|
|
1371
1557
|
"workspace_id": resolved_workspace,
|
|
1372
1558
|
}
|
|
1373
1559
|
stored_handoffs.append(stored)
|
|
1374
|
-
state["handoffs"] = stored_handoffs
|
|
1560
|
+
state["handoffs"] = stored_handoffs
|
|
1375
1561
|
state.setdefault("agent_runs", []).append(run)
|
|
1376
|
-
state["agent_runs"] = state["agent_runs"][-300:]
|
|
1377
1562
|
self.save_state(state)
|
|
1378
1563
|
self._emit_replayable_timeline_events(area="agent", run_id=run["id"], timeline=run["timeline"], workspace_id=resolved_workspace)
|
|
1379
1564
|
if status == "failed":
|
|
@@ -1381,6 +1566,93 @@ class WorkspaceOSStore:
|
|
|
1381
1566
|
self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
|
|
1382
1567
|
return run
|
|
1383
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
|
+
|
|
1384
1656
|
def get_agent_run(self, run_id: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1385
1657
|
run = next((item for item in _listify(self.load_state().get("agent_runs")) if item.get("id") == run_id), None)
|
|
1386
1658
|
if not run or (workspace_id and self._record_workspace(run) != str(workspace_id)):
|
|
@@ -1433,7 +1705,6 @@ class WorkspaceOSStore:
|
|
|
1433
1705
|
except Exception as exc:
|
|
1434
1706
|
workflow["graph_error"] = str(exc)
|
|
1435
1707
|
state.setdefault("workflows", []).append(workflow)
|
|
1436
|
-
state["workflows"] = state["workflows"][-300:]
|
|
1437
1708
|
self.save_state(state)
|
|
1438
1709
|
self.record_timeline_event("workflow", "workflow_created", {"workflow_id": workflow["id"], "name": workflow["name"]})
|
|
1439
1710
|
return workflow
|
|
@@ -1488,7 +1759,6 @@ class WorkspaceOSStore:
|
|
|
1488
1759
|
except Exception as exc:
|
|
1489
1760
|
run["graph_error"] = str(exc)
|
|
1490
1761
|
state.setdefault("workflow_runs", []).append(run)
|
|
1491
|
-
state["workflow_runs"] = state["workflow_runs"][-300:]
|
|
1492
1762
|
# Attach the run id to the workflow's event log for cross-linking.
|
|
1493
1763
|
for wf in _listify(state.get("workflows")):
|
|
1494
1764
|
if wf.get("id") == workflow_id:
|
|
@@ -1505,6 +1775,85 @@ class WorkspaceOSStore:
|
|
|
1505
1775
|
self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
1506
1776
|
return run
|
|
1507
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
|
+
|
|
1508
1857
|
def list_workflow_runs(self, workflow_id: Optional[str] = None, limit: int = 50, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1509
1858
|
runs = self._scoped(_listify(self.load_state().get("workflow_runs")), workspace_id)
|
|
1510
1859
|
if workflow_id:
|
|
@@ -1532,6 +1881,58 @@ class WorkspaceOSStore:
|
|
|
1532
1881
|
raise FileNotFoundError(run_id)
|
|
1533
1882
|
return run
|
|
1534
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
|
+
|
|
1535
1936
|
@staticmethod
|
|
1536
1937
|
def _replay_frames(run: Dict[str, Any], *, kind: str) -> List[Dict[str, Any]]:
|
|
1537
1938
|
frames = []
|
|
@@ -1747,7 +2148,6 @@ class WorkspaceOSStore:
|
|
|
1747
2148
|
**activity,
|
|
1748
2149
|
}
|
|
1749
2150
|
config.setdefault("activities", []).append(record)
|
|
1750
|
-
config["activities"] = config["activities"][-500:]
|
|
1751
2151
|
if graph is not None:
|
|
1752
2152
|
try:
|
|
1753
2153
|
graph.ingest_event(
|