ltcai 3.6.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +47 -53
- package/kg_schema.py +93 -10
- package/knowledge_graph.py +362 -33
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +7 -1
- package/latticeai/api/auth.py +27 -4
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/knowledge_graph.py +146 -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/realtime.py +1 -1
- 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 +2 -4
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/workflow_designer.py +46 -0
- package/latticeai/api/workspace.py +71 -49
- package/latticeai/app_factory.py +1710 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -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/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/security.py +1 -1
- package/latticeai/core/sessions.py +37 -7
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +58 -10
- 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 +1 -0
- 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/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 +31 -0
- 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 +9 -7
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +82 -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/account.html +2 -4
- package/static/admin.html +3 -5
- package/static/chat.html +3 -6
- package/static/graph.html +2 -4
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +20 -19
- 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.356e6452.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
- package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
- package/static/v3/js/core/routes.js +22 -22
- package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
- package/static/v3/js/core/shell.js +1 -1
- package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
- package/static/v3/js/core/store.js +1 -1
- 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.5e40cbeb.js} +33 -37
- package/static/v3/js/views/knowledge-graph.js +33 -37
- 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/static/workspace.html +2 -2
- 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
|
@@ -7,7 +7,7 @@ keyword search into UI-ready contracts without tying routers to store internals.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
-
from typing import Any, Dict,
|
|
10
|
+
from typing import Any, Dict, Mapping, Optional
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
DEFAULT_HYBRID_WEIGHTS = {
|
|
@@ -34,7 +34,15 @@ class SearchService:
|
|
|
34
34
|
raise ValueError("knowledge graph is disabled")
|
|
35
35
|
return self.graph_store
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def _scope(self, matches, allowed_workspaces):
|
|
38
|
+
"""Drop matches scoped to workspaces the caller is not a member of
|
|
39
|
+
(None = no scoping; legacy-global rows stay visible — documented)."""
|
|
40
|
+
if allowed_workspaces is None:
|
|
41
|
+
return matches
|
|
42
|
+
graph = self._require_graph()
|
|
43
|
+
return graph.filter_scoped_nodes(matches, allowed_workspaces)
|
|
44
|
+
|
|
45
|
+
def keyword_search(self, query: str, *, limit: int = 30, allowed_workspaces=None) -> Dict[str, Any]:
|
|
38
46
|
graph = self._require_graph()
|
|
39
47
|
payload = graph.search(query, limit)
|
|
40
48
|
matches = []
|
|
@@ -53,9 +61,9 @@ class SearchService:
|
|
|
53
61
|
"metadata": match.get("metadata") or {},
|
|
54
62
|
"updated_at": match.get("updated_at"),
|
|
55
63
|
})
|
|
56
|
-
return {"query": query, "mode": "keyword", "matches": matches}
|
|
64
|
+
return {"query": query, "mode": "keyword", "matches": self._scope(matches, allowed_workspaces)}
|
|
57
65
|
|
|
58
|
-
def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0) -> Dict[str, Any]:
|
|
66
|
+
def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0, allowed_workspaces=None) -> Dict[str, Any]:
|
|
59
67
|
graph = self._require_graph()
|
|
60
68
|
payload = graph.vector_search(query, limit=limit, min_score=min_score)
|
|
61
69
|
matches = []
|
|
@@ -80,10 +88,10 @@ class SearchService:
|
|
|
80
88
|
"mode": "vector",
|
|
81
89
|
"embedding_model": payload.get("embedding_model"),
|
|
82
90
|
"embedding_dim": payload.get("embedding_dim"),
|
|
83
|
-
"matches": matches,
|
|
91
|
+
"matches": self._scope(matches, allowed_workspaces),
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1) -> Dict[str, Any]:
|
|
94
|
+
def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1, allowed_workspaces=None) -> Dict[str, Any]:
|
|
87
95
|
graph = self._require_graph()
|
|
88
96
|
limit = max(1, min(int(limit or 30), 100))
|
|
89
97
|
expand_depth = max(0, min(int(expand_depth or 1), 3))
|
|
@@ -157,7 +165,7 @@ class SearchService:
|
|
|
157
165
|
match["rank"] = rank
|
|
158
166
|
match["score"] = round(float(match["score"]), 6)
|
|
159
167
|
match["source_scores"]["graph"] = round(float(match["source_scores"]["graph"]), 6)
|
|
160
|
-
return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": matches}
|
|
168
|
+
return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": self._scope(matches, allowed_workspaces)}
|
|
161
169
|
|
|
162
170
|
def hybrid_search(
|
|
163
171
|
self,
|
|
@@ -168,6 +176,7 @@ class SearchService:
|
|
|
168
176
|
vector_limit: int = 30,
|
|
169
177
|
graph_limit: int = 30,
|
|
170
178
|
weights: Optional[Mapping[str, float]] = None,
|
|
179
|
+
allowed_workspaces=None,
|
|
171
180
|
) -> Dict[str, Any]:
|
|
172
181
|
weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
|
|
173
182
|
channels = {
|
|
@@ -202,7 +211,7 @@ class SearchService:
|
|
|
202
211
|
current.setdefault("graph_context", [])
|
|
203
212
|
current["graph_context"].extend(result.get("graph_context") or [])
|
|
204
213
|
|
|
205
|
-
matches = sorted(fused.values(), key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 100))]
|
|
214
|
+
matches = self._scope(sorted(fused.values(), key=lambda item: item["score"], reverse=True), allowed_workspaces)[: max(1, min(limit, 100))]
|
|
206
215
|
for rank, match in enumerate(matches, start=1):
|
|
207
216
|
match["rank"] = rank
|
|
208
217
|
match["score"] = round(float(match["score"]), 6)
|
|
@@ -22,8 +22,16 @@ from latticeai.core.tool_registry import ToolPermission, ToolPolicy
|
|
|
22
22
|
from tools import AGENT_ROOT, DEFAULT_TOOL_REGISTRY, ToolError, ensure_agent_root
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
def _default_load_users() -> Dict[str, Any]:
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_get_user_role(_email, _users=None) -> str:
|
|
30
|
+
return "user"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_load_users: Callable[[], Dict[str, Any]] = _default_load_users
|
|
34
|
+
_get_user_role: Callable[..., str] = _default_get_user_role
|
|
27
35
|
|
|
28
36
|
FILE_CREATE_ACTIONS = set(DEFAULT_TOOL_REGISTRY.file_create_actions)
|
|
29
37
|
TOOL_GOVERNANCE: Dict[str, ToolPolicy] = dict(DEFAULT_TOOL_REGISTRY.governance)
|
|
@@ -104,6 +112,7 @@ def build_agent_runtime(
|
|
|
104
112
|
knowledge_save: Callable[..., Dict[str, Any]],
|
|
105
113
|
audit: Callable[..., None],
|
|
106
114
|
hooks: Any = None,
|
|
115
|
+
brain_memory: Any = None,
|
|
107
116
|
) -> AgentRuntime:
|
|
108
117
|
ensure_agent_root()
|
|
109
118
|
deps = AgentDeps(
|
|
@@ -125,6 +134,7 @@ def build_agent_runtime(
|
|
|
125
134
|
memory_updater_prompt=MEMORY_UPDATER_PROMPT,
|
|
126
135
|
agent_root=AGENT_ROOT,
|
|
127
136
|
hooks=hooks,
|
|
137
|
+
brain_memory=brain_memory,
|
|
128
138
|
)
|
|
129
139
|
return AgentRuntime(deps)
|
|
130
140
|
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Trigger system (T7d) — workflows fire beyond 'manual'.
|
|
2
|
+
|
|
3
|
+
Two real trigger types:
|
|
4
|
+
|
|
5
|
+
* **interval** — a supervised scheduler loop fires the workflow every
|
|
6
|
+
``interval_seconds``. Firings missed while the server was down are
|
|
7
|
+
SKIPPED with a recorded skip event (design-review amendment: no silent
|
|
8
|
+
gaps, no thundering catch-up).
|
|
9
|
+
* **brain_event** — the killer Digital Brain feature: "when new knowledge
|
|
10
|
+
enters the brain, run this workflow". Wired through the existing hooks
|
|
11
|
+
bus (the ingestion pipeline fires ``post_tool`` on ``kg_ingest.<source>``);
|
|
12
|
+
an optional ``source_type`` filter narrows it.
|
|
13
|
+
|
|
14
|
+
Trigger-fired runs carry provenance: their inputs include ``__trigger__``
|
|
15
|
+
describing what fired them, persisted in the run record like any other
|
|
16
|
+
input.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
27
|
+
|
|
28
|
+
DEFAULT_TICK_SECONDS = 5.0
|
|
29
|
+
MIN_INTERVAL_SECONDS = 60
|
|
30
|
+
|
|
31
|
+
TRIGGER_HOOK_NAME = "brain-event-triggers"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TriggerService:
|
|
35
|
+
"""Scans workflow definitions for non-manual triggers and fires them."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
store: Any,
|
|
41
|
+
run_workflow: Callable[[str, Dict[str, Any]], Dict[str, Any]],
|
|
42
|
+
data_dir: Path,
|
|
43
|
+
clock: Callable[[], float] = time.time,
|
|
44
|
+
tick_seconds: float = DEFAULT_TICK_SECONDS,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._store = store
|
|
47
|
+
self._run_workflow = run_workflow
|
|
48
|
+
self._state_file = Path(data_dir) / "triggers_state.json"
|
|
49
|
+
self._clock = clock
|
|
50
|
+
self._tick = float(tick_seconds)
|
|
51
|
+
self._stop_event = threading.Event()
|
|
52
|
+
self._thread: Optional[threading.Thread] = None
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
# ── durable state ──────────────────────────────────────────────────────
|
|
56
|
+
def _load_state(self) -> Dict[str, Any]:
|
|
57
|
+
if not self._state_file.exists():
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(self._state_file.read_text(encoding="utf-8"))
|
|
61
|
+
except Exception:
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
def _save_state(self, state: Dict[str, Any]) -> None:
|
|
65
|
+
self._state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
tmp = self._state_file.with_suffix(".tmp")
|
|
67
|
+
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
68
|
+
tmp.replace(self._state_file)
|
|
69
|
+
|
|
70
|
+
def _record_event(self, state: Dict[str, Any], workflow_id: str, event: Dict[str, Any]) -> None:
|
|
71
|
+
entry = state.setdefault(workflow_id, {})
|
|
72
|
+
events = entry.setdefault("events", [])
|
|
73
|
+
events.append({**event, "at": self._clock()})
|
|
74
|
+
entry["events"] = events[-50:]
|
|
75
|
+
|
|
76
|
+
# ── definition scanning ────────────────────────────────────────────────
|
|
77
|
+
def _triggered_workflows(self) -> List[Dict[str, Any]]:
|
|
78
|
+
found = []
|
|
79
|
+
try:
|
|
80
|
+
workflows = list(self._store.load_state().get("workflows") or [])
|
|
81
|
+
except Exception:
|
|
82
|
+
return []
|
|
83
|
+
for wf in workflows:
|
|
84
|
+
for node in wf.get("nodes") or []:
|
|
85
|
+
if node.get("type") != "trigger":
|
|
86
|
+
continue
|
|
87
|
+
cfg = node.get("config") or {}
|
|
88
|
+
kind = str(cfg.get("trigger") or "manual")
|
|
89
|
+
if kind in ("interval", "brain_event"):
|
|
90
|
+
found.append({"workflow": wf, "node": node, "kind": kind, "config": cfg})
|
|
91
|
+
return found
|
|
92
|
+
|
|
93
|
+
def describe(self) -> Dict[str, Any]:
|
|
94
|
+
"""Honest status surface: what is armed, when it last fired/skipped."""
|
|
95
|
+
state = self._load_state()
|
|
96
|
+
armed = []
|
|
97
|
+
for item in self._triggered_workflows():
|
|
98
|
+
wf_id = item["workflow"].get("id")
|
|
99
|
+
armed.append({
|
|
100
|
+
"workflow_id": wf_id,
|
|
101
|
+
"name": item["workflow"].get("name"),
|
|
102
|
+
"kind": item["kind"],
|
|
103
|
+
"config": {k: v for k, v in item["config"].items() if k != "trigger"},
|
|
104
|
+
"last_fired_at": (state.get(wf_id) or {}).get("last_fired_at"),
|
|
105
|
+
"recent_events": (state.get(wf_id) or {}).get("events", [])[-5:],
|
|
106
|
+
})
|
|
107
|
+
return {
|
|
108
|
+
"running": bool(self._thread and self._thread.is_alive()),
|
|
109
|
+
"tick_seconds": self._tick,
|
|
110
|
+
"armed": armed,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# ── interval scheduling ────────────────────────────────────────────────
|
|
114
|
+
def reconcile_missed(self) -> int:
|
|
115
|
+
"""Startup pass: record (never replay) firings missed while down."""
|
|
116
|
+
now = self._clock()
|
|
117
|
+
skipped = 0
|
|
118
|
+
with self._lock:
|
|
119
|
+
state = self._load_state()
|
|
120
|
+
for item in self._triggered_workflows():
|
|
121
|
+
if item["kind"] != "interval":
|
|
122
|
+
continue
|
|
123
|
+
wf_id = item["workflow"].get("id")
|
|
124
|
+
interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
|
|
125
|
+
entry = state.setdefault(wf_id, {})
|
|
126
|
+
last = entry.get("last_fired_at")
|
|
127
|
+
if last is not None and now - float(last) > interval:
|
|
128
|
+
missed = int((now - float(last)) // interval)
|
|
129
|
+
self._record_event(state, wf_id, {
|
|
130
|
+
"type": "skipped",
|
|
131
|
+
"reason": f"{missed} interval firing(s) missed while the server was down",
|
|
132
|
+
})
|
|
133
|
+
skipped += missed
|
|
134
|
+
# Reset the cadence from now — no catch-up storm.
|
|
135
|
+
entry["last_fired_at"] = now if last is not None else entry.get("last_fired_at")
|
|
136
|
+
self._save_state(state)
|
|
137
|
+
return skipped
|
|
138
|
+
|
|
139
|
+
def tick_intervals(self) -> int:
|
|
140
|
+
"""One scheduler pass; returns how many workflows fired."""
|
|
141
|
+
now = self._clock()
|
|
142
|
+
fired = 0
|
|
143
|
+
with self._lock:
|
|
144
|
+
state = self._load_state()
|
|
145
|
+
for item in self._triggered_workflows():
|
|
146
|
+
if item["kind"] != "interval":
|
|
147
|
+
continue
|
|
148
|
+
wf_id = item["workflow"].get("id")
|
|
149
|
+
interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
|
|
150
|
+
entry = state.setdefault(wf_id, {})
|
|
151
|
+
last = entry.get("last_fired_at")
|
|
152
|
+
if last is None:
|
|
153
|
+
# First sighting arms the schedule; it fires one interval later.
|
|
154
|
+
entry["last_fired_at"] = now
|
|
155
|
+
continue
|
|
156
|
+
if now - float(last) < interval:
|
|
157
|
+
continue
|
|
158
|
+
entry["last_fired_at"] = now
|
|
159
|
+
self._record_event(state, wf_id, {"type": "fired", "trigger": "interval"})
|
|
160
|
+
fired += 1
|
|
161
|
+
self._fire(wf_id, {
|
|
162
|
+
"type": "interval",
|
|
163
|
+
"interval_seconds": interval,
|
|
164
|
+
"fired_at": now,
|
|
165
|
+
})
|
|
166
|
+
self._save_state(state)
|
|
167
|
+
return fired
|
|
168
|
+
|
|
169
|
+
# ── brain events ───────────────────────────────────────────────────────
|
|
170
|
+
def on_brain_event(self, event: str, payload: Optional[Dict[str, Any]] = None) -> int:
|
|
171
|
+
"""Fire workflows whose brain_event trigger matches this ingestion."""
|
|
172
|
+
payload = payload or {}
|
|
173
|
+
source_type = str(payload.get("source_type") or event.split(".", 1)[-1] or "")
|
|
174
|
+
fired = 0
|
|
175
|
+
with self._lock:
|
|
176
|
+
state = self._load_state()
|
|
177
|
+
for item in self._triggered_workflows():
|
|
178
|
+
if item["kind"] != "brain_event":
|
|
179
|
+
continue
|
|
180
|
+
wanted = str(item["config"].get("source_type") or "").strip()
|
|
181
|
+
if wanted and wanted != source_type:
|
|
182
|
+
continue
|
|
183
|
+
wf_id = item["workflow"].get("id")
|
|
184
|
+
state.setdefault(wf_id, {})["last_fired_at"] = self._clock()
|
|
185
|
+
self._record_event(state, wf_id, {
|
|
186
|
+
"type": "fired", "trigger": "brain_event", "source_type": source_type,
|
|
187
|
+
})
|
|
188
|
+
fired += 1
|
|
189
|
+
self._fire(wf_id, {
|
|
190
|
+
"type": "brain_event",
|
|
191
|
+
"event": event,
|
|
192
|
+
"source_type": source_type,
|
|
193
|
+
"node_id": payload.get("node_id"),
|
|
194
|
+
})
|
|
195
|
+
self._save_state(state)
|
|
196
|
+
return fired
|
|
197
|
+
|
|
198
|
+
def hook_runner(self):
|
|
199
|
+
"""A post_tool hook runner: ingestion events fan into triggers."""
|
|
200
|
+
def runner(context):
|
|
201
|
+
event = str(getattr(context, "event", "") or "")
|
|
202
|
+
if not event.startswith("kg_ingest."):
|
|
203
|
+
return {"status": "ok", "output": "not an ingestion event"}
|
|
204
|
+
payload = context.payload if isinstance(context.payload, dict) else {}
|
|
205
|
+
fired = self.on_brain_event(event, payload)
|
|
206
|
+
return {"status": "ok", "output": f"fired {fired} workflow trigger(s)"}
|
|
207
|
+
return runner
|
|
208
|
+
|
|
209
|
+
# ── execution + lifecycle ──────────────────────────────────────────────
|
|
210
|
+
def _fire(self, workflow_id: str, trigger_info: Dict[str, Any]) -> None:
|
|
211
|
+
def _run():
|
|
212
|
+
try:
|
|
213
|
+
self._run_workflow(workflow_id, {"__trigger__": trigger_info})
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
logging.warning("trigger run failed for %s: %s", workflow_id, exc)
|
|
216
|
+
|
|
217
|
+
threading.Thread(target=_run, name=f"trigger-{workflow_id}", daemon=True).start()
|
|
218
|
+
|
|
219
|
+
def start(self) -> None:
|
|
220
|
+
if self._thread and self._thread.is_alive():
|
|
221
|
+
return
|
|
222
|
+
self.reconcile_missed()
|
|
223
|
+
self._stop_event.clear()
|
|
224
|
+
|
|
225
|
+
def _loop():
|
|
226
|
+
while not self._stop_event.wait(self._tick):
|
|
227
|
+
try:
|
|
228
|
+
self.tick_intervals()
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
logging.warning("trigger scheduler tick failed: %s", exc)
|
|
231
|
+
|
|
232
|
+
self._thread = threading.Thread(target=_loop, name="trigger-scheduler", daemon=True)
|
|
233
|
+
self._thread.start()
|
|
234
|
+
|
|
235
|
+
def stop(self) -> None:
|
|
236
|
+
self._stop_event.set()
|
|
237
|
+
if self._thread:
|
|
238
|
+
self._thread.join(timeout=2)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
__all__ = ["TriggerService", "TRIGGER_HOOK_NAME", "MIN_INTERVAL_SECONDS"]
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from fastapi import HTTPException, Request, UploadFile
|
|
11
11
|
|
|
12
|
+
from latticeai.services.ingestion import IngestionItem
|
|
12
13
|
from tools import ToolError, read_document
|
|
13
14
|
|
|
14
15
|
|
|
@@ -19,6 +20,7 @@ async def process_uploaded_document(
|
|
|
19
20
|
current_user: str,
|
|
20
21
|
enable_graph: bool,
|
|
21
22
|
knowledge_graph,
|
|
23
|
+
ingestion_pipeline=None,
|
|
22
24
|
bytes_match_extension,
|
|
23
25
|
classify_sensitive_message,
|
|
24
26
|
append_audit_event,
|
|
@@ -71,18 +73,41 @@ async def process_uploaded_document(
|
|
|
71
73
|
try:
|
|
72
74
|
if not (enable_graph and knowledge_graph):
|
|
73
75
|
raise RuntimeError("graph disabled")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
if ingestion_pipeline is not None:
|
|
77
|
+
# v4: uploads enter the brain through the unified ingestion
|
|
78
|
+
# pipeline (provenance + kg_ingest hook lifecycle).
|
|
79
|
+
ingest = ingestion_pipeline.ingest(
|
|
80
|
+
IngestionItem(
|
|
81
|
+
source_type="upload",
|
|
82
|
+
title=file.filename,
|
|
83
|
+
path=tmp_path,
|
|
84
|
+
mime_type=file.content_type,
|
|
85
|
+
owner=current_user,
|
|
86
|
+
conversation_id=request.query_params.get("conversation_id"),
|
|
87
|
+
metadata={"extracted": result},
|
|
88
|
+
),
|
|
89
|
+
user_email=current_user,
|
|
90
|
+
)
|
|
91
|
+
if ingest.status != "ok":
|
|
92
|
+
raise RuntimeError(ingest.detail or f"ingestion {ingest.status}")
|
|
93
|
+
result["knowledge_graph"] = {
|
|
94
|
+
"node_id": ingest.node_id,
|
|
95
|
+
"sha256": ingest.content_hash,
|
|
96
|
+
"provenance_id": ingest.provenance_id,
|
|
97
|
+
}
|
|
98
|
+
else:
|
|
99
|
+
graph_result = knowledge_graph.ingest_document(
|
|
100
|
+
Path(tmp_path),
|
|
101
|
+
original_filename=file.filename,
|
|
102
|
+
mime_type=file.content_type,
|
|
103
|
+
uploader=current_user,
|
|
104
|
+
conversation_id=request.query_params.get("conversation_id"),
|
|
105
|
+
extracted=result,
|
|
106
|
+
)
|
|
107
|
+
result["knowledge_graph"] = {
|
|
108
|
+
"node_id": graph_result["node_id"],
|
|
109
|
+
"sha256": graph_result["sha256"],
|
|
110
|
+
}
|
|
86
111
|
except Exception as graph_error:
|
|
87
112
|
logging.warning("knowledge graph document ingest failed: %s", graph_error)
|
|
88
113
|
result["knowledge_graph"] = {"error": str(graph_error)}
|
|
@@ -72,6 +72,37 @@ class WorkspaceService:
|
|
|
72
72
|
def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
|
|
73
73
|
return self.store.has_permission(workspace_id, user_id, "write")
|
|
74
74
|
|
|
75
|
+
# ── record-level authorization (by-id access must not bypass gating) ──
|
|
76
|
+
|
|
77
|
+
def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
|
|
78
|
+
"""Authorize reading a record against ITS OWN workspace.
|
|
79
|
+
|
|
80
|
+
Records predating workspace scoping carry no workspace_id and remain
|
|
81
|
+
readable (legacy-global compatibility); a scoped record requires read
|
|
82
|
+
permission on its workspace regardless of any caller-supplied header.
|
|
83
|
+
"""
|
|
84
|
+
workspace_id = (record or {}).get("workspace_id")
|
|
85
|
+
if workspace_id:
|
|
86
|
+
self._ensure_permission(workspace_id, user_id, "read")
|
|
87
|
+
|
|
88
|
+
def authorize_memory_delete(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
|
|
89
|
+
"""Delete requires owning the memory or write access to its workspace.
|
|
90
|
+
|
|
91
|
+
Ownerless records with no workspace keep their pre-v4 behaviour
|
|
92
|
+
(deletable by any authenticated local user).
|
|
93
|
+
"""
|
|
94
|
+
owner = (record or {}).get("user_email")
|
|
95
|
+
workspace_id = (record or {}).get("workspace_id")
|
|
96
|
+
if owner and owner == user_id:
|
|
97
|
+
return
|
|
98
|
+
if workspace_id:
|
|
99
|
+
self._ensure_permission(workspace_id, user_id, "write")
|
|
100
|
+
return
|
|
101
|
+
if owner and owner != user_id:
|
|
102
|
+
raise PermissionError(
|
|
103
|
+
f"'{user_id or 'anonymous'}' is not the owner of memory '{record.get('id')}'"
|
|
104
|
+
)
|
|
105
|
+
|
|
75
106
|
# ── workspace registry / summary ─────────────────────────────────────
|
|
76
107
|
|
|
77
108
|
def summary(self, user_id: Optional[str]) -> Dict[str, Any]:
|