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.
Files changed (238) hide show
  1. package/README.md +39 -31
  2. package/docs/CHANGELOG.md +64 -0
  3. package/docs/REALTIME_COLLABORATION.md +3 -3
  4. package/docs/V3_FRONTEND.md +9 -8
  5. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  6. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +552 -0
  7. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  8. package/docs/kg-schema.md +51 -53
  9. package/docs/spec-vs-impl.md +10 -10
  10. package/kg_schema.py +2 -520
  11. package/knowledge_graph.py +37 -4629
  12. package/knowledge_graph_api.py +11 -127
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +16 -17
  15. package/latticeai/api/agents.py +20 -7
  16. package/latticeai/api/auth.py +46 -15
  17. package/latticeai/api/chat.py +112 -76
  18. package/latticeai/api/health.py +1 -1
  19. package/latticeai/api/hooks.py +1 -1
  20. package/latticeai/api/invitations.py +100 -0
  21. package/latticeai/api/knowledge_graph.py +139 -0
  22. package/latticeai/api/local_files.py +1 -1
  23. package/latticeai/api/mcp.py +23 -11
  24. package/latticeai/api/memory.py +1 -1
  25. package/latticeai/api/models.py +1 -1
  26. package/latticeai/api/network.py +81 -0
  27. package/latticeai/api/plugins.py +3 -6
  28. package/latticeai/api/realtime.py +5 -8
  29. package/latticeai/api/search.py +26 -2
  30. package/latticeai/api/security_dashboard.py +2 -3
  31. package/latticeai/api/setup.py +2 -2
  32. package/latticeai/api/static_routes.py +11 -16
  33. package/latticeai/api/tools.py +3 -0
  34. package/latticeai/api/ui_redirects.py +26 -0
  35. package/latticeai/api/workflow_designer.py +85 -6
  36. package/latticeai/api/workspace.py +93 -57
  37. package/latticeai/app_factory.py +1781 -0
  38. package/latticeai/brain/__init__.py +18 -0
  39. package/latticeai/brain/_kg_common.py +1123 -0
  40. package/latticeai/brain/context.py +213 -0
  41. package/latticeai/brain/conversations.py +236 -0
  42. package/latticeai/brain/discovery.py +1455 -0
  43. package/latticeai/brain/documents.py +218 -0
  44. package/latticeai/brain/identity.py +175 -0
  45. package/latticeai/brain/ingest.py +644 -0
  46. package/latticeai/brain/memory.py +102 -0
  47. package/latticeai/brain/network.py +205 -0
  48. package/latticeai/brain/projection.py +561 -0
  49. package/latticeai/brain/provenance.py +401 -0
  50. package/latticeai/brain/retrieval.py +1316 -0
  51. package/latticeai/brain/schema.py +640 -0
  52. package/latticeai/brain/store.py +216 -0
  53. package/latticeai/brain/write_master.py +225 -0
  54. package/latticeai/core/agent.py +31 -7
  55. package/latticeai/core/audit.py +0 -7
  56. package/latticeai/core/config.py +1 -1
  57. package/latticeai/core/context_builder.py +1 -2
  58. package/latticeai/core/enterprise.py +1 -1
  59. package/latticeai/core/graph_curator.py +2 -2
  60. package/latticeai/core/invitations.py +131 -0
  61. package/latticeai/core/marketplace.py +1 -1
  62. package/latticeai/core/mcp_registry.py +791 -0
  63. package/latticeai/core/model_compat.py +1 -1
  64. package/latticeai/core/model_resolution.py +0 -1
  65. package/latticeai/core/multi_agent.py +238 -4
  66. package/latticeai/core/policy.py +54 -0
  67. package/latticeai/core/realtime.py +65 -44
  68. package/latticeai/core/security.py +1 -1
  69. package/latticeai/core/sessions.py +66 -10
  70. package/latticeai/core/users.py +147 -0
  71. package/latticeai/core/workflow_engine.py +114 -2
  72. package/latticeai/core/workspace_os.py +477 -29
  73. package/latticeai/models/__init__.py +7 -0
  74. package/latticeai/models/router.py +779 -0
  75. package/latticeai/server_app.py +29 -1536
  76. package/latticeai/services/agent_runtime.py +243 -4
  77. package/latticeai/services/app_context.py +75 -14
  78. package/latticeai/services/ingestion.py +47 -0
  79. package/latticeai/services/kg_portability.py +33 -3
  80. package/latticeai/services/memory_service.py +39 -11
  81. package/latticeai/services/model_runtime.py +2 -5
  82. package/latticeai/services/platform_runtime.py +100 -23
  83. package/latticeai/services/run_executor.py +328 -0
  84. package/latticeai/services/search_service.py +17 -8
  85. package/latticeai/services/tool_dispatch.py +12 -2
  86. package/latticeai/services/triggers.py +241 -0
  87. package/latticeai/services/upload_service.py +37 -12
  88. package/latticeai/services/workspace_service.py +55 -16
  89. package/llm_router.py +29 -772
  90. package/ltcai_cli.py +1 -2
  91. package/mcp_registry.py +25 -788
  92. package/p_reinforce.py +124 -14
  93. package/package.json +10 -20
  94. package/scripts/bump_version.py +99 -0
  95. package/scripts/generate_diagrams.py +0 -1
  96. package/scripts/lint_v3.mjs +105 -18
  97. package/scripts/validate_release_artifacts.py +0 -1
  98. package/scripts/wheel_smoke.py +142 -0
  99. package/server.py +11 -7
  100. package/setup_wizard.py +1142 -0
  101. package/static/sw.js +81 -52
  102. package/static/v3/asset-manifest.json +33 -25
  103. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  104. package/static/v3/css/lattice.base.css +1 -1
  105. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  106. package/static/v3/css/lattice.components.css +1 -1
  107. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  108. package/static/v3/css/lattice.shell.css +1 -1
  109. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  110. package/static/v3/css/lattice.tokens.css +3 -0
  111. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  112. package/static/v3/css/lattice.views.css +2 -2
  113. package/static/v3/index.html +3 -4
  114. package/static/v3/js/{app.c541f955.js → app.c5c80c46.js} +1 -1
  115. package/static/v3/js/core/{api.33d6320e.js → api.ba0fbf14.js} +58 -1
  116. package/static/v3/js/core/api.js +57 -0
  117. package/static/v3/js/core/i18n.880e1fec.js +575 -0
  118. package/static/v3/js/core/i18n.js +575 -0
  119. package/static/v3/js/core/routes.37522821.js +101 -0
  120. package/static/v3/js/core/routes.js +71 -63
  121. package/static/v3/js/core/{shell.8c163e0e.js → shell.e3f6bbfa.js} +68 -39
  122. package/static/v3/js/core/shell.js +66 -37
  123. package/static/v3/js/core/{store.34ebd5e6.js → store.7b2aa044.js} +11 -1
  124. package/static/v3/js/core/store.js +11 -1
  125. package/static/v3/js/views/account.eff40715.js +143 -0
  126. package/static/v3/js/views/account.js +143 -0
  127. package/static/v3/js/views/activity.0d271ef9.js +67 -0
  128. package/static/v3/js/views/activity.js +67 -0
  129. package/static/v3/js/views/{admin-users.03bac88c.js → admin-users.f7ac7b43.js} +4 -6
  130. package/static/v3/js/views/admin-users.js +4 -6
  131. package/static/v3/js/views/{agents.014d0b74.js → agents.17c5288d.js} +35 -12
  132. package/static/v3/js/views/agents.js +35 -12
  133. package/static/v3/js/views/{chat.e6dd7dd0.js → chat.e250e2cc.js} +23 -0
  134. package/static/v3/js/views/chat.js +23 -0
  135. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  136. package/static/v3/js/views/graph-canvas.js +509 -0
  137. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  138. package/static/v3/js/views/hybrid-search.js +1 -2
  139. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.4d09c537.js} +60 -44
  140. package/static/v3/js/views/knowledge-graph.js +60 -44
  141. package/static/v3/js/views/network.52a4f181.js +97 -0
  142. package/static/v3/js/views/network.js +97 -0
  143. package/static/v3/js/views/{planning.9ac3e313.js → planning.4876fd77.js} +26 -5
  144. package/static/v3/js/views/planning.js +26 -5
  145. package/static/v3/js/views/runs.b63b2afa.js +144 -0
  146. package/static/v3/js/views/runs.js +144 -0
  147. package/static/v3/js/views/{settings.8631fa5e.js → settings.b7140634.js} +7 -8
  148. package/static/v3/js/views/settings.js +7 -8
  149. package/static/v3/js/views/snapshots.6f5db095.js +135 -0
  150. package/static/v3/js/views/snapshots.js +135 -0
  151. package/static/v3/js/views/{workflows.26c57290.js → workflows.7752225a.js} +87 -2
  152. package/static/v3/js/views/workflows.js +87 -2
  153. package/static/v3/js/views/workspace-admin.c466029b.js +156 -0
  154. package/static/v3/js/views/workspace-admin.js +156 -0
  155. package/static/vendor/chart.umd.min.js +20 -0
  156. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  157. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  158. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  159. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  160. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  161. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  162. package/static/vendor/fonts/inter.css +44 -0
  163. package/static/vendor/icons/tabler-icons.min.css +4 -0
  164. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  165. package/static/vendor/marked.min.js +69 -0
  166. package/telegram_bot.py +1 -2
  167. package/tools/commands.py +4 -2
  168. package/tools/computer.py +1 -1
  169. package/tools/documents.py +1 -3
  170. package/tools/filesystem.py +0 -4
  171. package/tools/knowledge.py +1 -3
  172. package/tools/network.py +1 -3
  173. package/codex_telegram_bot.py +0 -195
  174. package/docs/assets/v3.4.0/agent-run.png +0 -0
  175. package/docs/assets/v3.4.0/agents.png +0 -0
  176. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  177. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  178. package/docs/assets/v3.4.0/chat.png +0 -0
  179. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  180. package/docs/assets/v3.4.0/files.png +0 -0
  181. package/docs/assets/v3.4.0/home.png +0 -0
  182. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  183. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  184. package/docs/assets/v3.4.0/local-agent.png +0 -0
  185. package/docs/assets/v3.4.0/memory.png +0 -0
  186. package/docs/assets/v3.4.0/settings.png +0 -0
  187. package/docs/assets/v3.4.0/vision-input.png +0 -0
  188. package/docs/assets/v3.4.0/workflows.png +0 -0
  189. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  190. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  191. package/docs/assets/v3.4.1/local-agent.png +0 -0
  192. package/docs/images/admin-dashboard.png +0 -0
  193. package/docs/images/architecture.png +0 -0
  194. package/docs/images/enterprise.png +0 -0
  195. package/docs/images/graph.png +0 -0
  196. package/docs/images/hero.gif +0 -0
  197. package/docs/images/knowledge-graph.png +0 -0
  198. package/docs/images/lattice-ai-demo.gif +0 -0
  199. package/docs/images/lattice-ai-hero.png +0 -0
  200. package/docs/images/logo.svg +0 -33
  201. package/docs/images/mobile-responsive.png +0 -0
  202. package/docs/images/model-recommendation.png +0 -0
  203. package/docs/images/onboarding.png +0 -0
  204. package/docs/images/organization.png +0 -0
  205. package/docs/images/pipeline.png +0 -0
  206. package/docs/images/screenshot-admin.png +0 -0
  207. package/docs/images/screenshot-chat.png +0 -0
  208. package/docs/images/screenshot-graph.png +0 -0
  209. package/docs/images/skills.png +0 -0
  210. package/docs/images/workspace-dark.png +0 -0
  211. package/docs/images/workspace-light.png +0 -0
  212. package/docs/images/workspace.png +0 -0
  213. package/requirements.txt +0 -16
  214. package/static/account.html +0 -115
  215. package/static/activity.html +0 -73
  216. package/static/admin.html +0 -488
  217. package/static/agents.html +0 -139
  218. package/static/chat.html +0 -844
  219. package/static/css/reference/account.css +0 -439
  220. package/static/css/reference/admin.css +0 -610
  221. package/static/css/reference/base.css +0 -1661
  222. package/static/css/reference/chat.css +0 -4623
  223. package/static/css/reference/graph.css +0 -1016
  224. package/static/css/responsive.css +0 -861
  225. package/static/graph.html +0 -124
  226. package/static/platform.css +0 -104
  227. package/static/plugins.html +0 -136
  228. package/static/scripts/account.js +0 -238
  229. package/static/scripts/admin.js +0 -1614
  230. package/static/scripts/chat.js +0 -5081
  231. package/static/scripts/graph.js +0 -1804
  232. package/static/scripts/platform.js +0 -64
  233. package/static/scripts/ux.js +0 -167
  234. package/static/scripts/workspace.js +0 -948
  235. package/static/v3/js/core/routes.2ce3815a.js +0 -93
  236. package/static/workflows.html +0 -146
  237. package/static/workspace.css +0 -1121
  238. package/static/workspace.html +0 -357
@@ -0,0 +1,147 @@
1
+ """User identity store and v4 UUID migration helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sqlite3
8
+ import uuid
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+
14
+ USER_NAMESPACE = uuid.UUID("5d6d4480-cf79-49c3-a6d0-4c6eec3224d6")
15
+
16
+
17
+ def _now() -> str:
18
+ return datetime.now().isoformat(timespec="seconds")
19
+
20
+
21
+ def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+ tmp = path.with_suffix(path.suffix + ".tmp")
24
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
25
+ tmp.replace(path)
26
+
27
+
28
+ def normalize_email(email: str) -> str:
29
+ return str(email or "").strip().lower()
30
+
31
+
32
+ def stable_user_id(email: str) -> str:
33
+ return f"user:{uuid.uuid5(USER_NAMESPACE, normalize_email(email))}"
34
+
35
+
36
+ def ensure_user_identity(email: str, user: Dict[str, Any]) -> bool:
37
+ changed = False
38
+ normalized = normalize_email(email or user.get("email") or "")
39
+ if not user.get("id"):
40
+ user["id"] = stable_user_id(normalized)
41
+ changed = True
42
+ if user.get("email") != normalized:
43
+ user["email"] = normalized
44
+ changed = True
45
+ return changed
46
+
47
+
48
+ def migrate_users(users: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, str], bool]:
49
+ migrated: Dict[str, Any] = {}
50
+ email_to_id: Dict[str, str] = {}
51
+ changed = False
52
+ for raw_email, raw_user in (users or {}).items():
53
+ if not isinstance(raw_user, dict):
54
+ continue
55
+ email = normalize_email(raw_user.get("email") or raw_email)
56
+ user = dict(raw_user)
57
+ changed = ensure_user_identity(email, user) or changed
58
+ if raw_email != email:
59
+ changed = True
60
+ if email in migrated:
61
+ existing = migrated[email]
62
+ merged = {**existing, **user}
63
+ merged["id"] = existing.get("id") or user.get("id") or stable_user_id(email)
64
+ if isinstance(existing.get("api_keys"), dict) or isinstance(user.get("api_keys"), dict):
65
+ merged["api_keys"] = {**(existing.get("api_keys") or {}), **(user.get("api_keys") or {})}
66
+ user = merged
67
+ changed = True
68
+ migrated[email] = user
69
+ email_to_id[email] = user["id"]
70
+ return migrated, email_to_id, changed
71
+
72
+
73
+ def load_users_file(path: Path) -> Dict[str, Any]:
74
+ if not path.exists():
75
+ return {}
76
+ try:
77
+ loaded = json.loads(path.read_text(encoding="utf-8"))
78
+ if not isinstance(loaded, dict):
79
+ loaded = {}
80
+ except Exception:
81
+ loaded = {}
82
+ migrated, _, changed = migrate_users(loaded)
83
+ if changed:
84
+ backup = path.with_name(f"{path.name}.pre-user-uuid.{_now().replace(':', '-')}.json")
85
+ try:
86
+ shutil.copy2(path, backup)
87
+ except Exception:
88
+ pass
89
+ _atomic_write_json(path, migrated)
90
+ return migrated
91
+
92
+
93
+ def save_users_file(path: Path, users: Dict[str, Any]) -> None:
94
+ migrated, _, _ = migrate_users(users)
95
+ _atomic_write_json(path, migrated)
96
+
97
+
98
+ def user_id_for_email(users: Dict[str, Any], email: Optional[str]) -> Optional[str]:
99
+ if not email:
100
+ return None
101
+ if str(email).startswith("user:"):
102
+ return str(email)
103
+ normalized = normalize_email(email)
104
+ user = (users or {}).get(normalized)
105
+ if isinstance(user, dict):
106
+ return user.get("id") or stable_user_id(normalized)
107
+ return stable_user_id(normalized)
108
+
109
+
110
+ def email_for_user_id(users: Dict[str, Any], user_id: Optional[str]) -> Optional[str]:
111
+ if not user_id:
112
+ return None
113
+ for email, user in (users or {}).items():
114
+ if isinstance(user, dict) and user.get("id") == user_id:
115
+ return email
116
+ return None
117
+
118
+
119
+ def migrate_knowledge_graph_identity(db_path: Path, email_to_id: Dict[str, str]) -> int:
120
+ """Rewrite KG owner/creator identity columns from email to stable UUIDs."""
121
+ if not db_path.exists() or not email_to_id:
122
+ return 0
123
+ changed = 0
124
+ with sqlite3.connect(db_path) as conn:
125
+ tables = {
126
+ row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
127
+ }
128
+ for email, user_id in email_to_id.items():
129
+ normalized = normalize_email(email)
130
+ if "nodes_v2" in tables:
131
+ cur = conn.execute("UPDATE nodes_v2 SET owner_id=? WHERE LOWER(owner_id)=?", (user_id, normalized))
132
+ changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
133
+ if "edges_v2" in tables:
134
+ cur = conn.execute("UPDATE edges_v2 SET created_by=? WHERE LOWER(created_by)=?", (user_id, normalized))
135
+ changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
136
+ if "ingestion_provenance" in tables:
137
+ cur = conn.execute("UPDATE ingestion_provenance SET owner=? WHERE LOWER(owner)=?", (user_id, normalized))
138
+ changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
139
+ if changed:
140
+ conn.execute(
141
+ "CREATE TABLE IF NOT EXISTS kg_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
142
+ )
143
+ conn.execute(
144
+ "INSERT OR REPLACE INTO kg_meta(key, value) VALUES('identity_uuid_migrated_at', ?)",
145
+ (_now(),),
146
+ )
147
+ return changed
@@ -185,11 +185,17 @@ def _evaluate_condition(config: Dict[str, Any], context: Dict[str, Any]) -> bool
185
185
  class WorkflowRun:
186
186
  workflow_id: Optional[str]
187
187
  name: str
188
- status: str = "ok" # ok | failed | partial
188
+ status: str = "ok" # ok | failed | partial | awaiting_approval
189
189
  timeline: List[Dict[str, Any]] = field(default_factory=list)
190
190
  outputs: Dict[str, Any] = field(default_factory=dict)
191
191
  started_at: str = field(default_factory=_now)
192
192
  finished_at: Optional[str] = None
193
+ # Suspension cursor (status == awaiting_approval): the paused node, what
194
+ # it is waiting for, and a JSON-serializable context snapshot resume()
195
+ # re-enters with — completed nodes are never re-executed.
196
+ paused_node: Optional[str] = None
197
+ pending_approval: Optional[Dict[str, Any]] = None
198
+ paused_context: Optional[Dict[str, Any]] = None
193
199
 
194
200
  def as_dict(self) -> Dict[str, Any]:
195
201
  return {
@@ -201,9 +207,36 @@ class WorkflowRun:
201
207
  "started_at": self.started_at,
202
208
  "finished_at": self.finished_at,
203
209
  "step_count": len(self.timeline),
210
+ "paused_node": self.paused_node,
211
+ "pending_approval": self.pending_approval,
212
+ "paused_context": self.paused_context,
204
213
  }
205
214
 
206
215
 
216
+ class ApprovalRequired(Exception):
217
+ """A node needs an explicit human decision before it may execute.
218
+
219
+ Raised by governed runners (e.g. a non-auto-approve tool). The engine
220
+ pauses the run into ``awaiting_approval`` with a serializable cursor —
221
+ it never records a fake success and never silently skips the node.
222
+ """
223
+
224
+ def __init__(self, message: str, *, tool: Optional[str] = None,
225
+ args: Optional[Dict[str, Any]] = None,
226
+ permission: Optional[Dict[str, Any]] = None):
227
+ super().__init__(message)
228
+ self.tool = tool
229
+ self.args = args or {}
230
+ self.permission = permission or {}
231
+
232
+
233
+ def _json_safe(value: Any) -> Any:
234
+ """Round-trip through JSON so paused context is durably serializable."""
235
+ import json as _json
236
+
237
+ return _json.loads(_json.dumps(value, ensure_ascii=False, default=str))
238
+
239
+
207
240
  class WorkflowEngine:
208
241
  """Interprets a validated workflow definition over injected runners.
209
242
 
@@ -212,6 +245,11 @@ class WorkflowEngine:
212
245
  node as ``skipped`` with a reason rather than failing the whole run, so a
213
246
  workflow that references a capability the host has not wired degrades
214
247
  gracefully (and the gap is visible in the timeline).
248
+
249
+ Suspension model (v4): a runner raising :class:`ApprovalRequired` pauses
250
+ the run (status ``awaiting_approval``) with the node cursor and a
251
+ JSON-serializable context snapshot. :meth:`resume` re-enters at the
252
+ paused node — completed nodes are NEVER re-executed.
215
253
  """
216
254
 
217
255
  def __init__(self, runners: Optional[Dict[str, Callable[..., Any]]] = None, *, hooks: Any = None):
@@ -243,8 +281,60 @@ class WorkflowEngine:
243
281
 
244
282
  nodes = {node["id"]: node for node in definition["nodes"]}
245
283
  context: Dict[str, Any] = {"inputs": inputs or {}, **(inputs or {})}
246
-
247
284
  current = _entry_node(definition["nodes"])
285
+ return self._execute(definition, run, nodes, context, current)
286
+
287
+ def resume(
288
+ self,
289
+ workflow: Dict[str, Any],
290
+ *,
291
+ paused_node: str,
292
+ paused_context: Dict[str, Any],
293
+ approved: bool,
294
+ prior_timeline: Optional[List[Dict[str, Any]]] = None,
295
+ ) -> WorkflowRun:
296
+ """Re-enter a paused run at its cursor; completed nodes never re-run.
297
+
298
+ ``approved=True`` marks the paused node as human-approved (its runner
299
+ sees the node id in ``context['__approved_nodes__']``); ``False``
300
+ records an explicit denial and fails the run honestly.
301
+ """
302
+ definition = normalize_definition(workflow)
303
+ nodes = {node["id"]: node for node in definition["nodes"]}
304
+ node = nodes.get(paused_node)
305
+ run = WorkflowRun(workflow_id=definition.get("id"), name=definition.get("name") or "workflow")
306
+ if prior_timeline:
307
+ run.timeline.extend(prior_timeline)
308
+ if node is None:
309
+ run.status = "failed"
310
+ run.timeline.append({"node": paused_node, "type": "resume", "status": "failed",
311
+ "reason": "paused node no longer exists in the definition",
312
+ "timestamp": _now()})
313
+ run.finished_at = _now()
314
+ return run
315
+ context: Dict[str, Any] = dict(paused_context or {})
316
+ if not approved:
317
+ run.status = "failed"
318
+ run.timeline.append({"node": paused_node, "type": node.get("type"),
319
+ "name": node.get("name") or paused_node,
320
+ "status": "denied",
321
+ "reason": "approval denied by the user",
322
+ "timestamp": _now()})
323
+ run.finished_at = _now()
324
+ return run
325
+ approvals = set(context.get("__approved_nodes__") or [])
326
+ approvals.add(paused_node)
327
+ context["__approved_nodes__"] = sorted(approvals)
328
+ return self._execute(definition, run, nodes, context, node)
329
+
330
+ def _execute(
331
+ self,
332
+ definition: Dict[str, Any],
333
+ run: WorkflowRun,
334
+ nodes: Dict[str, Dict[str, Any]],
335
+ context: Dict[str, Any],
336
+ current: Optional[Dict[str, Any]],
337
+ ) -> WorkflowRun:
248
338
  steps = 0
249
339
  had_error = False
250
340
  had_skip = False
@@ -301,6 +391,28 @@ class WorkflowEngine:
301
391
  entry["result"] = result
302
392
  context["last_output"] = result
303
393
  context[nid] = result
394
+ except ApprovalRequired as pause:
395
+ # Suspend — never a fake success, never a silent skip.
396
+ entry["status"] = "awaiting_approval"
397
+ entry["pending"] = {
398
+ "tool": pause.tool, "args": pause.args,
399
+ "permission": pause.permission, "reason": str(pause),
400
+ }
401
+ run.timeline.append(entry)
402
+ run.status = "awaiting_approval"
403
+ run.paused_node = nid
404
+ run.pending_approval = entry["pending"]
405
+ try:
406
+ run.paused_context = _json_safe(context)
407
+ except Exception:
408
+ run.paused_context = {"inputs": context.get("inputs") or {}}
409
+ if self.hooks is not None:
410
+ self.hooks.fire_hook(
411
+ "post_workflow", "workflow.paused",
412
+ payload={"workflow_id": definition.get("id"),
413
+ "status": run.status, "node": nid},
414
+ )
415
+ return run
304
416
  except Exception as exc:
305
417
  entry["status"] = "error"
306
418
  entry["reason"] = str(exc)