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
|
@@ -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)
|