ltcai 8.8.0 → 9.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 +33 -37
- package/auto_setup.py +84 -70
- package/docs/CHANGELOG.md +113 -237
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +39 -496
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -597
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +368 -418
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +72 -33
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +105 -62
- package/latticeai/app_factory.py +101 -296
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +196 -74
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-I1OSzxJu.js +0 -321
- package/static/app/assets/System-D1Lkei3I.js +0 -1
- package/static/app/assets/index--P0ksosz.js +0 -17
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/primitives-BLqaKk5g.js +0 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Auditable command plans for installer and local process execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Iterable, Mapping, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_SECRET_RE = re.compile(
|
|
15
|
+
r"(api[_-]?key|access[_-]?token|auth[_-]?token|bearer|client[_-]?secret|password|secret|token)",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
_SENSITIVE_FLAGS = {
|
|
19
|
+
"--api-key",
|
|
20
|
+
"--access-token",
|
|
21
|
+
"--auth-token",
|
|
22
|
+
"--client-secret",
|
|
23
|
+
"--password",
|
|
24
|
+
"--secret",
|
|
25
|
+
"--token",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CommandConfirmationError(ValueError):
|
|
30
|
+
"""Raised when a process command lacks the expected execution token."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_audit_file() -> Path:
|
|
34
|
+
base = Path(os.environ.get("LATTICEAI_DATA_DIR") or Path.home() / ".ltcai")
|
|
35
|
+
return base / "process_audit.jsonl"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_command(command: Sequence[Any]) -> list[str]:
|
|
39
|
+
return [str(part) for part in command]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def redact_command(command: Sequence[Any]) -> list[str]:
|
|
43
|
+
redacted: list[str] = []
|
|
44
|
+
redact_next = False
|
|
45
|
+
for raw in _normalize_command(command):
|
|
46
|
+
lower = raw.lower()
|
|
47
|
+
if redact_next:
|
|
48
|
+
redacted.append("[REDACTED]")
|
|
49
|
+
redact_next = False
|
|
50
|
+
continue
|
|
51
|
+
if lower in _SENSITIVE_FLAGS:
|
|
52
|
+
redacted.append(raw)
|
|
53
|
+
redact_next = True
|
|
54
|
+
continue
|
|
55
|
+
if "=" in raw:
|
|
56
|
+
key, _value = raw.split("=", 1)
|
|
57
|
+
if _SECRET_RE.search(key):
|
|
58
|
+
redacted.append(f"{key}=[REDACTED]")
|
|
59
|
+
continue
|
|
60
|
+
if _SECRET_RE.search(raw) and len(raw) > 32:
|
|
61
|
+
redacted.append("[REDACTED]")
|
|
62
|
+
continue
|
|
63
|
+
redacted.append(raw)
|
|
64
|
+
return redacted
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _digest_payload(payload: Mapping[str, Any]) -> str:
|
|
68
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
|
69
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def command_hash(command: Sequence[Any], *, cwd: Optional[str] = None) -> str:
|
|
73
|
+
return _digest_payload({"command": _normalize_command(command), "cwd": cwd or ""})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def confirmation_token(command: Sequence[Any], *, cwd: Optional[str] = None, purpose: str = "execute") -> str:
|
|
77
|
+
digest = _digest_payload(
|
|
78
|
+
{
|
|
79
|
+
"purpose": purpose,
|
|
80
|
+
"command": _normalize_command(command),
|
|
81
|
+
"cwd": cwd or "",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
return digest[:16]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def command_plan(
|
|
88
|
+
command: Sequence[Any],
|
|
89
|
+
*,
|
|
90
|
+
name: str,
|
|
91
|
+
purpose: str = "installer",
|
|
92
|
+
cwd: Optional[str] = None,
|
|
93
|
+
requires_admin: bool = False,
|
|
94
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"name": name,
|
|
98
|
+
"purpose": purpose,
|
|
99
|
+
"command_preview": redact_command(command),
|
|
100
|
+
"command_hash": command_hash(command, cwd=cwd),
|
|
101
|
+
"confirmation_token": confirmation_token(command, cwd=cwd, purpose=purpose),
|
|
102
|
+
"requires_admin": bool(requires_admin),
|
|
103
|
+
"cwd": cwd,
|
|
104
|
+
"metadata": dict(metadata or {}),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def command_plan_for_commands(
|
|
109
|
+
commands: Iterable[Sequence[Any]],
|
|
110
|
+
*,
|
|
111
|
+
name: str,
|
|
112
|
+
purpose: str = "installer",
|
|
113
|
+
cwd: Optional[str] = None,
|
|
114
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
normalized = [_normalize_command(command) for command in commands]
|
|
117
|
+
digest = _digest_payload({"purpose": purpose, "commands": normalized, "cwd": cwd or ""})
|
|
118
|
+
return {
|
|
119
|
+
"name": name,
|
|
120
|
+
"purpose": purpose,
|
|
121
|
+
"command_count": len(normalized),
|
|
122
|
+
"command_hash": digest,
|
|
123
|
+
"confirmation_token": digest[:16],
|
|
124
|
+
"cwd": cwd,
|
|
125
|
+
"metadata": dict(metadata or {}),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def verify_command_confirmation(
|
|
130
|
+
command: Sequence[Any],
|
|
131
|
+
provided_token: Optional[str],
|
|
132
|
+
*,
|
|
133
|
+
cwd: Optional[str] = None,
|
|
134
|
+
purpose: str = "installer",
|
|
135
|
+
) -> bool:
|
|
136
|
+
if not provided_token:
|
|
137
|
+
return False
|
|
138
|
+
return str(provided_token).strip() == confirmation_token(command, cwd=cwd, purpose=purpose)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def require_command_confirmation(
|
|
142
|
+
command: Sequence[Any],
|
|
143
|
+
provided_token: Optional[str],
|
|
144
|
+
*,
|
|
145
|
+
cwd: Optional[str] = None,
|
|
146
|
+
purpose: str = "installer",
|
|
147
|
+
) -> None:
|
|
148
|
+
if not verify_command_confirmation(command, provided_token, cwd=cwd, purpose=purpose):
|
|
149
|
+
raise CommandConfirmationError("installer command requires a matching confirmation token")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _preview_text(text: Optional[str], *, limit: int = 500) -> Optional[str]:
|
|
153
|
+
if text is None:
|
|
154
|
+
return None
|
|
155
|
+
value = str(text)[-limit:]
|
|
156
|
+
value = re.sub(r"(?i)(api[_-]?key|access[_-]?token|auth[_-]?token|password|secret|token)=\S+", r"\1=[REDACTED]", value)
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _text_hash(text: Optional[str]) -> Optional[str]:
|
|
161
|
+
if text is None:
|
|
162
|
+
return None
|
|
163
|
+
return hashlib.sha256(str(text).encode("utf-8", errors="replace")).hexdigest()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def append_process_audit_event(
|
|
167
|
+
event_type: str,
|
|
168
|
+
*,
|
|
169
|
+
plan: Mapping[str, Any],
|
|
170
|
+
status: str,
|
|
171
|
+
user_email: Optional[str] = None,
|
|
172
|
+
returncode: Optional[int] = None,
|
|
173
|
+
stdout: Optional[str] = None,
|
|
174
|
+
stderr: Optional[str] = None,
|
|
175
|
+
error: Optional[str] = None,
|
|
176
|
+
audit_file: Optional[Path] = None,
|
|
177
|
+
extra: Optional[Mapping[str, Any]] = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
path = audit_file or _default_audit_file()
|
|
180
|
+
entry = {
|
|
181
|
+
"ts": time.time(),
|
|
182
|
+
"event_type": event_type,
|
|
183
|
+
"status": status,
|
|
184
|
+
"user_email": user_email,
|
|
185
|
+
"plan": {
|
|
186
|
+
"name": plan.get("name"),
|
|
187
|
+
"purpose": plan.get("purpose"),
|
|
188
|
+
"command_preview": plan.get("command_preview"),
|
|
189
|
+
"command_hash": plan.get("command_hash"),
|
|
190
|
+
"requires_admin": plan.get("requires_admin"),
|
|
191
|
+
"cwd": plan.get("cwd"),
|
|
192
|
+
"metadata": plan.get("metadata") or {},
|
|
193
|
+
},
|
|
194
|
+
"returncode": returncode,
|
|
195
|
+
"stdout_hash": _text_hash(stdout),
|
|
196
|
+
"stderr_hash": _text_hash(stderr),
|
|
197
|
+
"stdout_preview": _preview_text(stdout),
|
|
198
|
+
"stderr_preview": _preview_text(stderr),
|
|
199
|
+
"error": _preview_text(error),
|
|
200
|
+
"extra": dict(extra or {}),
|
|
201
|
+
}
|
|
202
|
+
try:
|
|
203
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
205
|
+
fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
|
|
206
|
+
except Exception:
|
|
207
|
+
# Auditing must never make setup recovery paths unusable.
|
|
208
|
+
return
|
|
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
|
|
|
18
18
|
|
|
19
19
|
from latticeai.services.architecture_readiness import architecture_readiness
|
|
20
20
|
|
|
21
|
-
PRODUCT_VERSION_TARGET = "
|
|
21
|
+
PRODUCT_VERSION_TARGET = "9.0.0"
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
@dataclass(frozen=True)
|
|
@@ -76,10 +76,10 @@ PRODUCT_GATES: List[ProductGate] = [
|
|
|
76
76
|
evidence=[
|
|
77
77
|
"package.json::release:artifacts",
|
|
78
78
|
"package.json::release:validate",
|
|
79
|
-
"README.md::dist/ltcai-
|
|
80
|
-
"README.md::dist/ltcai-
|
|
81
|
-
"README.md::dist/ltcai-
|
|
82
|
-
"README.md::ltcai-
|
|
79
|
+
"README.md::dist/ltcai-9.0.0-py3-none-any.whl",
|
|
80
|
+
"README.md::dist/ltcai-9.0.0.tar.gz",
|
|
81
|
+
"README.md::dist/ltcai-9.0.0.vsix",
|
|
82
|
+
"README.md::ltcai-9.0.0.tgz",
|
|
83
83
|
"scripts/validate_release_artifacts.py",
|
|
84
84
|
"scripts/release_smoke.py",
|
|
85
85
|
"Dockerfile",
|
|
@@ -95,12 +95,12 @@ PRODUCT_GATES: List[ProductGate] = [
|
|
|
95
95
|
title="Release story is documented and honest",
|
|
96
96
|
evidence=[
|
|
97
97
|
"README.md",
|
|
98
|
-
"README.md::The current release is **
|
|
99
|
-
"SECURITY.md::
|
|
100
|
-
"vscode-extension/README.md::**
|
|
101
|
-
"docs/CHANGELOG.md::## [
|
|
98
|
+
"README.md::The current release is **9.0.0",
|
|
99
|
+
"SECURITY.md::9.0.x (latest)",
|
|
100
|
+
"vscode-extension/README.md::**9.0.0",
|
|
101
|
+
"docs/CHANGELOG.md::## [9.0.0]",
|
|
102
102
|
"FEATURE_STATUS.md",
|
|
103
|
-
"
|
|
103
|
+
"RELEASE_NOTES_v9.0.0.md",
|
|
104
104
|
"latticeai/core/agent.py::SingleAgentRuntime",
|
|
105
105
|
"latticeai/core/agent.py::AgentRuntime = SingleAgentRuntime",
|
|
106
106
|
"lattice_brain/runtime/contracts.py::runtime-boundary/v1",
|
|
@@ -115,7 +115,7 @@ PRODUCT_GATES: List[ProductGate] = [
|
|
|
115
115
|
id="ecosystem-path",
|
|
116
116
|
title="Community and plugin growth path is explicit",
|
|
117
117
|
evidence=[
|
|
118
|
-
"docs/COMMUNITY_AND_PLUGINS.md::
|
|
118
|
+
"docs/COMMUNITY_AND_PLUGINS.md::9.0.0",
|
|
119
119
|
"docs/PLUGIN_SDK.md",
|
|
120
120
|
"plugins/README.md",
|
|
121
121
|
"plugins/hello-world/plugin.json",
|
|
@@ -21,12 +21,14 @@ from __future__ import annotations
|
|
|
21
21
|
from datetime import datetime
|
|
22
22
|
from typing import Any, Callable, Dict, Optional
|
|
23
23
|
|
|
24
|
+
from latticeai.core.io_utils import parse_iso as _parse_iso
|
|
25
|
+
|
|
24
26
|
# status: terminal vs. open. Open items can still be acted on.
|
|
25
27
|
OPEN_STATUSES = {"pending", "snoozed"}
|
|
26
28
|
TERMINAL_STATUSES = {"approved", "dismissed"}
|
|
27
29
|
ALL_STATUSES = OPEN_STATUSES | TERMINAL_STATUSES
|
|
28
30
|
|
|
29
|
-
REVIEW_SOURCES = frozenset({"workflow_run", "trigger", "kg_change_digest"})
|
|
31
|
+
REVIEW_SOURCES = frozenset({"workflow_run", "trigger", "kg_change_digest", "chat_followup", "agent_followup"})
|
|
30
32
|
|
|
31
33
|
# Which source statuses each action is allowed from.
|
|
32
34
|
_ALLOWED_FROM: Dict[str, set] = {
|
|
@@ -59,15 +61,6 @@ class InvalidReviewTransition(Exception):
|
|
|
59
61
|
super().__init__(f"cannot {action!r} a review item in status {status!r}")
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
63
|
-
if not value:
|
|
64
|
-
return None
|
|
65
|
-
try:
|
|
66
|
-
return datetime.fromisoformat(str(value))
|
|
67
|
-
except (TypeError, ValueError):
|
|
68
|
-
return None
|
|
69
|
-
|
|
70
|
-
|
|
71
64
|
class ReviewQueueService:
|
|
72
65
|
"""Policy layer over the store's workspace-scoped ``review_items``."""
|
|
73
66
|
|
|
@@ -122,7 +115,20 @@ class ReviewQueueService:
|
|
|
122
115
|
|
|
123
116
|
# ── transitions ──────────────────────────────────────────────────────
|
|
124
117
|
def approve(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
125
|
-
|
|
118
|
+
item = self._store.get_review_item(item_id, workspace_id=workspace_id)
|
|
119
|
+
self._guard("approve", item)
|
|
120
|
+
payload = dict(item.get("payload") or {})
|
|
121
|
+
provenance = dict(item.get("provenance") or {})
|
|
122
|
+
promoted = self._promote_agent_followup(item, workspace_id=workspace_id)
|
|
123
|
+
if promoted:
|
|
124
|
+
payload["promoted_workflow_id"] = promoted.get("id")
|
|
125
|
+
provenance["workflow_id"] = promoted.get("id")
|
|
126
|
+
provenance["promotion"] = "workflow_draft"
|
|
127
|
+
patch: Dict[str, Any] = {"status": "approved", "payload": payload, "provenance": provenance}
|
|
128
|
+
if item.get("snoozed_until") is not None:
|
|
129
|
+
patch["snoozed_until"] = None
|
|
130
|
+
updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
|
|
131
|
+
return self._view(updated)
|
|
126
132
|
|
|
127
133
|
def dismiss(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
128
134
|
return self._transition(item_id, "dismiss", "dismissed", workspace_id=workspace_id)
|
|
@@ -210,6 +216,53 @@ class ReviewQueueService:
|
|
|
210
216
|
view["effective_status"] = self._effective_status(item)
|
|
211
217
|
return view
|
|
212
218
|
|
|
219
|
+
def _promote_agent_followup(self, item: Dict[str, Any], *, workspace_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
220
|
+
if item.get("source") != "agent_followup" or not hasattr(self._store, "create_workflow"):
|
|
221
|
+
return None
|
|
222
|
+
payload = dict(item.get("payload") or {})
|
|
223
|
+
followup = str(payload.get("followup") or item.get("title") or "").strip()
|
|
224
|
+
if not followup:
|
|
225
|
+
return None
|
|
226
|
+
goal = str(payload.get("goal") or item.get("summary") or followup).strip()
|
|
227
|
+
nodes = [
|
|
228
|
+
{
|
|
229
|
+
"id": "trigger",
|
|
230
|
+
"type": "trigger",
|
|
231
|
+
"config": {"trigger": "manual", "review_queue": False},
|
|
232
|
+
"next": "agent",
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"id": "agent",
|
|
236
|
+
"type": "agent",
|
|
237
|
+
"config": {
|
|
238
|
+
"goal": followup,
|
|
239
|
+
"roles": ["planner", "executor", "reviewer"],
|
|
240
|
+
"source": "agent_followup",
|
|
241
|
+
},
|
|
242
|
+
"next": "output",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"id": "output",
|
|
246
|
+
"type": "output",
|
|
247
|
+
"config": {"format": "review_followup"},
|
|
248
|
+
"next": None,
|
|
249
|
+
},
|
|
250
|
+
]
|
|
251
|
+
return self._store.create_workflow(
|
|
252
|
+
name=f"Follow-up: {str(item.get('title') or followup)[:96]}",
|
|
253
|
+
steps=[{"action": "agent", "goal": followup}],
|
|
254
|
+
nodes=nodes,
|
|
255
|
+
metadata={
|
|
256
|
+
"source": "review_center",
|
|
257
|
+
"review_item_id": item.get("id"),
|
|
258
|
+
"agent_followup": followup,
|
|
259
|
+
"goal": goal,
|
|
260
|
+
"draft": True,
|
|
261
|
+
},
|
|
262
|
+
user_email=item.get("user_email"),
|
|
263
|
+
workspace_id=workspace_id or item.get("workspace_id"),
|
|
264
|
+
)
|
|
265
|
+
|
|
213
266
|
|
|
214
267
|
def enqueue_from_automation(
|
|
215
268
|
sink: "ReviewQueueService",
|
|
@@ -129,6 +129,27 @@ class RunExecutor:
|
|
|
129
129
|
self._cancel_agent_record(run_id, handle.scope, "cancelled after the final result was persisted")
|
|
130
130
|
else:
|
|
131
131
|
self._results[run_id] = payload
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
try:
|
|
134
|
+
run = self.store.get_agent_run(run_id, workspace_id=handle.scope)
|
|
135
|
+
timeline = list(run.get("timeline") or [])
|
|
136
|
+
timeline.append({"event": "execution_failed", "status": "failed", "detail": str(exc), "timestamp": _now()})
|
|
137
|
+
failed = self.store.update_agent_run(
|
|
138
|
+
run_id,
|
|
139
|
+
workspace_id=handle.scope,
|
|
140
|
+
status="failed",
|
|
141
|
+
current_role=None,
|
|
142
|
+
output_text=str(exc),
|
|
143
|
+
timeline=timeline,
|
|
144
|
+
graph=self.workspace_graph(),
|
|
145
|
+
)
|
|
146
|
+
self._results[run_id] = {"run": failed, "result": {"status": "failed", "error": str(exc)}}
|
|
147
|
+
except Exception:
|
|
148
|
+
self._results[run_id] = {"run": {"id": run_id, "status": "failed"}, "result": {"status": "failed", "error": str(exc)}}
|
|
149
|
+
try:
|
|
150
|
+
self.append_audit_event("agent_run_failed", user_email=user_email, run_id=run_id, error=str(exc))
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
132
153
|
finally:
|
|
133
154
|
self._handles.pop(run_id, None)
|
|
134
155
|
|
|
@@ -42,9 +42,84 @@ class SearchService:
|
|
|
42
42
|
graph = self._require_graph()
|
|
43
43
|
return graph.filter_scoped_nodes(matches, allowed_workspaces)
|
|
44
44
|
|
|
45
|
+
def _graph_search(self, graph, query: str, *, limit: int, allowed_workspaces=None):
|
|
46
|
+
try:
|
|
47
|
+
return graph.search(query, limit, allowed_workspaces=allowed_workspaces)
|
|
48
|
+
except TypeError:
|
|
49
|
+
payload = graph.search(query, limit)
|
|
50
|
+
if allowed_workspaces is not None:
|
|
51
|
+
payload = {
|
|
52
|
+
**payload,
|
|
53
|
+
"matches": graph.filter_scoped_nodes(
|
|
54
|
+
payload.get("matches", []),
|
|
55
|
+
allowed_workspaces,
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
return payload
|
|
59
|
+
|
|
60
|
+
def _relationship_search(self, graph, *, allowed_workspaces=None, **kwargs):
|
|
61
|
+
try:
|
|
62
|
+
return graph.relationship_search(
|
|
63
|
+
**kwargs,
|
|
64
|
+
allowed_workspaces=allowed_workspaces,
|
|
65
|
+
)
|
|
66
|
+
except TypeError:
|
|
67
|
+
payload = graph.relationship_search(**kwargs)
|
|
68
|
+
if allowed_workspaces is not None:
|
|
69
|
+
kept = []
|
|
70
|
+
for rel in payload.get("relationships", []):
|
|
71
|
+
endpoints = [
|
|
72
|
+
{"id": (rel.get("source") or {}).get("id")},
|
|
73
|
+
{"id": (rel.get("target") or {}).get("id")},
|
|
74
|
+
]
|
|
75
|
+
if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
|
|
76
|
+
kept.append(rel)
|
|
77
|
+
payload = {**payload, "relationships": kept}
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
def _traverse(self, graph, node_id: str, *, depth: int, limit: int, allowed_workspaces=None):
|
|
81
|
+
try:
|
|
82
|
+
return graph.traverse(
|
|
83
|
+
node_id,
|
|
84
|
+
depth=depth,
|
|
85
|
+
limit=limit,
|
|
86
|
+
allowed_workspaces=allowed_workspaces,
|
|
87
|
+
)
|
|
88
|
+
except TypeError:
|
|
89
|
+
neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
|
|
90
|
+
if allowed_workspaces is not None:
|
|
91
|
+
nodes = graph.filter_scoped_nodes(
|
|
92
|
+
neighborhood.get("nodes", []),
|
|
93
|
+
allowed_workspaces,
|
|
94
|
+
)
|
|
95
|
+
kept = {item.get("id") for item in nodes}
|
|
96
|
+
edges = [
|
|
97
|
+
edge for edge in neighborhood.get("edges", [])
|
|
98
|
+
if edge.get("from") in kept and edge.get("to") in kept
|
|
99
|
+
]
|
|
100
|
+
neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
|
|
101
|
+
return neighborhood
|
|
102
|
+
|
|
103
|
+
def _get_node(self, graph, node_id: str, *, allowed_workspaces=None):
|
|
104
|
+
try:
|
|
105
|
+
return graph.get_node(node_id, allowed_workspaces=allowed_workspaces)
|
|
106
|
+
except TypeError:
|
|
107
|
+
node = graph.get_node(node_id)
|
|
108
|
+
if allowed_workspaces is not None:
|
|
109
|
+
visible = graph.filter_scoped_nodes([node], allowed_workspaces)
|
|
110
|
+
if not visible:
|
|
111
|
+
raise ValueError(f"graph node not found: {node_id}")
|
|
112
|
+
return visible[0]
|
|
113
|
+
return node
|
|
114
|
+
|
|
45
115
|
def keyword_search(self, query: str, *, limit: int = 30, allowed_workspaces=None) -> Dict[str, Any]:
|
|
46
116
|
graph = self._require_graph()
|
|
47
|
-
payload =
|
|
117
|
+
payload = self._graph_search(
|
|
118
|
+
graph,
|
|
119
|
+
query,
|
|
120
|
+
limit=limit,
|
|
121
|
+
allowed_workspaces=allowed_workspaces,
|
|
122
|
+
)
|
|
48
123
|
matches = []
|
|
49
124
|
for rank, match in enumerate(payload.get("matches", []), start=1):
|
|
50
125
|
matches.append({
|
|
@@ -95,8 +170,18 @@ class SearchService:
|
|
|
95
170
|
graph = self._require_graph()
|
|
96
171
|
limit = max(1, min(int(limit or 30), 100))
|
|
97
172
|
expand_depth = max(0, min(int(expand_depth or 1), 3))
|
|
98
|
-
direct =
|
|
99
|
-
|
|
173
|
+
direct = self._graph_search(
|
|
174
|
+
graph,
|
|
175
|
+
query,
|
|
176
|
+
limit=max(limit, 10),
|
|
177
|
+
allowed_workspaces=allowed_workspaces,
|
|
178
|
+
).get("matches", [])
|
|
179
|
+
relationships = self._relationship_search(
|
|
180
|
+
graph,
|
|
181
|
+
query=query,
|
|
182
|
+
limit=limit,
|
|
183
|
+
allowed_workspaces=allowed_workspaces,
|
|
184
|
+
).get("relationships", [])
|
|
100
185
|
by_id: Dict[str, Dict[str, Any]] = {}
|
|
101
186
|
|
|
102
187
|
def add_node(node: Mapping[str, Any], score: float, reason: str, edge: Optional[Mapping[str, Any]] = None) -> None:
|
|
@@ -138,7 +223,13 @@ class SearchService:
|
|
|
138
223
|
if expand_depth <= 0:
|
|
139
224
|
continue
|
|
140
225
|
try:
|
|
141
|
-
neighborhood =
|
|
226
|
+
neighborhood = self._traverse(
|
|
227
|
+
graph,
|
|
228
|
+
match["id"],
|
|
229
|
+
depth=expand_depth,
|
|
230
|
+
limit=limit * 3,
|
|
231
|
+
allowed_workspaces=allowed_workspaces,
|
|
232
|
+
)
|
|
142
233
|
except Exception:
|
|
143
234
|
neighborhood = {"nodes": [], "edges": []}
|
|
144
235
|
edge_by_pair = {
|
|
@@ -262,23 +353,16 @@ class SearchService:
|
|
|
262
353
|
allowed_workspaces=None,
|
|
263
354
|
) -> Dict[str, Any]:
|
|
264
355
|
graph = self._require_graph()
|
|
265
|
-
node =
|
|
266
|
-
if allowed_workspaces is not None:
|
|
267
|
-
visible = graph.filter_scoped_nodes([node], allowed_workspaces)
|
|
268
|
-
if not visible:
|
|
269
|
-
raise ValueError(f"graph node not found: {node_id}")
|
|
270
|
-
node = visible[0]
|
|
356
|
+
node = self._get_node(graph, node_id, allowed_workspaces=allowed_workspaces)
|
|
271
357
|
payload = {"node": node}
|
|
272
358
|
if include_neighbors:
|
|
273
|
-
neighborhood =
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
]
|
|
281
|
-
neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
|
|
359
|
+
neighborhood = self._traverse(
|
|
360
|
+
graph,
|
|
361
|
+
node_id,
|
|
362
|
+
depth=depth,
|
|
363
|
+
limit=limit,
|
|
364
|
+
allowed_workspaces=allowed_workspaces,
|
|
365
|
+
)
|
|
282
366
|
payload["neighborhood"] = neighborhood
|
|
283
367
|
return payload
|
|
284
368
|
|
|
@@ -292,22 +376,14 @@ class SearchService:
|
|
|
292
376
|
allowed_workspaces=None,
|
|
293
377
|
) -> Dict[str, Any]:
|
|
294
378
|
graph = self._require_graph()
|
|
295
|
-
payload =
|
|
379
|
+
payload = self._relationship_search(
|
|
380
|
+
graph,
|
|
296
381
|
query=query,
|
|
297
382
|
node_id=node_id,
|
|
298
383
|
relationship_type=relationship_type,
|
|
299
384
|
limit=limit,
|
|
385
|
+
allowed_workspaces=allowed_workspaces,
|
|
300
386
|
)
|
|
301
|
-
if allowed_workspaces is not None:
|
|
302
|
-
kept = []
|
|
303
|
-
for rel in payload.get("relationships", []):
|
|
304
|
-
endpoints = [
|
|
305
|
-
{"id": (rel.get("source") or {}).get("id")},
|
|
306
|
-
{"id": (rel.get("target") or {}).get("id")},
|
|
307
|
-
]
|
|
308
|
-
if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
|
|
309
|
-
kept.append(rel)
|
|
310
|
-
payload = {**payload, "relationships": kept}
|
|
311
387
|
return payload
|
|
312
388
|
|
|
313
389
|
def index_status(self) -> Dict[str, Any]:
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared setup/environment detection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
WhichFn = Callable[[str], Optional[str]]
|
|
11
|
+
RunFn = Callable[[List[str]], str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_windows_video_controllers(raw: str) -> List[Dict[str, Any]]:
|
|
15
|
+
controllers: List[Dict[str, Any]] = []
|
|
16
|
+
if not raw:
|
|
17
|
+
return controllers
|
|
18
|
+
try:
|
|
19
|
+
data = json.loads(raw)
|
|
20
|
+
if isinstance(data, dict):
|
|
21
|
+
data = [data]
|
|
22
|
+
if isinstance(data, list):
|
|
23
|
+
for item in data:
|
|
24
|
+
name = str(item.get("Name") or "").strip()
|
|
25
|
+
if not name:
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
ram_mb = int(item.get("AdapterRAM") or 0) // (1024 * 1024)
|
|
29
|
+
except Exception:
|
|
30
|
+
ram_mb = 0
|
|
31
|
+
controllers.append({"name": name, "vram_mb": ram_mb})
|
|
32
|
+
if controllers:
|
|
33
|
+
return controllers
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
current: Dict[str, Any] = {}
|
|
37
|
+
for line in raw.splitlines():
|
|
38
|
+
if line.startswith("Name="):
|
|
39
|
+
if current:
|
|
40
|
+
controllers.append(current)
|
|
41
|
+
current = {"name": line.split("=", 1)[-1].strip(), "vram_mb": 0}
|
|
42
|
+
elif line.startswith("AdapterRAM=") and current:
|
|
43
|
+
try:
|
|
44
|
+
current["vram_mb"] = int(line.split("=", 1)[-1].strip()) // (1024 * 1024)
|
|
45
|
+
except ValueError:
|
|
46
|
+
current["vram_mb"] = 0
|
|
47
|
+
if current:
|
|
48
|
+
controllers.append(current)
|
|
49
|
+
return controllers
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_cuda(which: WhichFn, run: RunFn) -> Tuple[bool, str, Optional[str], Optional[str]]:
|
|
53
|
+
nvidia_smi = which("nvidia-smi")
|
|
54
|
+
nvcc = which("nvcc")
|
|
55
|
+
version = ""
|
|
56
|
+
if nvidia_smi:
|
|
57
|
+
raw = run([nvidia_smi, "--query-gpu=driver_version", "--format=csv,noheader"])
|
|
58
|
+
version = raw.splitlines()[0].strip() if raw.splitlines() else ""
|
|
59
|
+
if nvcc:
|
|
60
|
+
raw = run([nvcc, "--version"])
|
|
61
|
+
match = re.search(r"release\s+([\d.]+)", raw)
|
|
62
|
+
if match:
|
|
63
|
+
version = match.group(1)
|
|
64
|
+
return bool(nvidia_smi or nvcc), version, nvidia_smi, nvcc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_wsl_from_text(prof_os: str, raw: str) -> Tuple[bool, str]:
|
|
68
|
+
if prof_os.lower() != "linux":
|
|
69
|
+
return False, ""
|
|
70
|
+
lowered = (raw or "").lower()
|
|
71
|
+
is_wsl = "microsoft" in lowered or "wsl" in lowered
|
|
72
|
+
version = "2" if "microsoft-standard" in lowered or "wsl2" in lowered else ("1" if is_wsl else "")
|
|
73
|
+
return is_wsl, version
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def detect_tools(which: WhichFn, binaries: Iterable[str]) -> Dict[str, Optional[str]]:
|
|
77
|
+
return {binary: which(binary) for binary in binaries}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["detect_cuda", "detect_tools", "detect_wsl_from_text", "parse_windows_video_controllers"]
|