ltcai 10.8.0 → 10.10.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 +44 -63
- package/docs/CHANGELOG.md +69 -237
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +9 -6
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_history.py +4 -2
- package/latticeai/api/chat_intents.py +14 -14
- package/latticeai/api/knowledge_graph.py +5 -4
- package/latticeai/api/local_files.py +25 -15
- package/latticeai/api/mcp.py +15 -11
- package/latticeai/api/memory.py +2 -1
- package/latticeai/api/models.py +39 -24
- package/latticeai/api/network_boundary.py +9 -4
- package/latticeai/api/portability.py +27 -19
- package/latticeai/api/project_sessions.py +7 -5
- package/latticeai/api/review_queue.py +12 -7
- package/latticeai/api/setup.py +10 -11
- package/latticeai/api/static_routes.py +62 -46
- package/latticeai/api/tools.py +7 -6
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +20 -4
- package/latticeai/core/messages.py +222 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/integrations/telegram_bot.py +20 -18
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/model_loading.py +63 -27
- package/latticeai/services/product_readiness.py +5 -3
- package/package.json +3 -2
- package/scripts/check_current_release_docs.mjs +4 -2
- package/scripts/check_server_i18n.mjs +96 -0
- package/scripts/release_screen_claims.json +31 -8
- 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 +37 -37
- package/static/app/assets/{Act-CbQNsFgc.js → Act-CC8SpNRm.js} +1 -1
- package/static/app/assets/{AdminConsole-Bs8SR8fE.js → AdminConsole-DGIY_Tb_.js} +1 -1
- package/static/app/assets/{Brain-BZIPsZlH.js → Brain-BDSC2Mol.js} +2 -2
- package/static/app/assets/BrainHome-BYgYp3ON.js +2 -0
- package/static/app/assets/BrainSignals-DYwVFmcO.js +1 -0
- package/static/app/assets/Capture-ClcKLsmI.js +1 -0
- package/static/app/assets/{CommandPalette-Dq_MmPBl.js → CommandPalette-DpwbyF1S.js} +1 -1
- package/static/app/assets/Library-D8VNtffM.js +1 -0
- package/static/app/assets/LivingBrain-CeWwc0NN.js +1 -0
- package/static/app/assets/ProductFlow-CS5CCM4t.js +1 -0
- package/static/app/assets/{ReviewCard-Be7E7X18.js → ReviewCard-Bap4yPfI.js} +1 -1
- package/static/app/assets/System-odPxU1YL.js +1 -0
- package/static/app/assets/arrow-left-J47Yde1U.js +1 -0
- package/static/app/assets/{bot-BR0eoeXY.js → bot-CmvhqTeF.js} +1 -1
- package/static/app/assets/brain-DLT9uuGq.js +1 -0
- package/static/app/assets/{button-U1nU7xX4.js → button-DyppYhFH.js} +1 -1
- package/static/app/assets/{circle-pause-DG5KHjDL.js → circle-pause-Bm5z7EBI.js} +1 -1
- package/static/app/assets/{circle-play-Chhcuq90.js → circle-play-GjKdH0Fg.js} +1 -1
- package/static/app/assets/{cpu-pkOSlT9h.js → cpu-CuVMv-F5.js} +1 -1
- package/static/app/assets/{download-CSRwz923.js → download-C9_946tf.js} +1 -1
- package/static/app/assets/{folder-open-DiMJ7W_7.js → folder-open-B3QKdxib.js} +1 -1
- package/static/app/assets/{hard-drive-iyLPAPdp.js → hard-drive-BwW9Hx4r.js} +1 -1
- package/static/app/assets/{index-_ldE9TAX.js → index-BrUQ-bF8.js} +3 -3
- package/static/app/assets/index-CYb18LtP.css +2 -0
- package/static/app/assets/{input-e_r2hFAY.js → input-Db2Hw2VH.js} +1 -1
- package/static/app/assets/{permissionCopy-9uOHktz3.js → permissionCopy-BpbIHRio.js} +1 -1
- package/static/app/assets/primitives-4B_Ynf7r.js +1 -0
- package/static/app/assets/search-YH-7-x5Y.js +1 -0
- package/static/app/assets/{share-2-DLDJJKmI.js → share-2-BgvRTDPJ.js} +1 -1
- package/static/app/assets/{shield-alert-BTtE-2EZ.js → shield-alert-DWFLyykG.js} +1 -1
- package/static/app/assets/{textarea-Bbzkcgqe.js → textarea-Bk4vEqzT.js} +1 -1
- package/static/app/assets/{useFocusTrap-DJzQd-Za.js → useFocusTrap-CGRXEg_p.js} +1 -1
- package/static/app/assets/{useQuery-6R_tqHRe.js → useQuery-CdPzp3_U.js} +1 -1
- package/static/app/assets/{utils-XDsjcrhS.js → utils-orU8igel.js} +3 -3
- package/static/app/assets/{workspace-CwrG8LHq.js → workspace-Brj2Ylb2.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-BaH5MDce.js +0 -2
- package/static/app/assets/BrainSignals-aDt-nSsW.js +0 -1
- package/static/app/assets/Capture-DDv940Bv.js +0 -1
- package/static/app/assets/Library-BkShbqmN.js +0 -1
- package/static/app/assets/LivingBrain-CXb8094H.js +0 -1
- package/static/app/assets/ProductFlow-BaiqFWLO.js +0 -1
- package/static/app/assets/System-DCstbTxC.js +0 -1
- package/static/app/assets/arrow-left-DC9Oquhs.js +0 -1
- package/static/app/assets/brain-BmWa1wea.js +0 -1
- package/static/app/assets/index-BZRvGOM2.css +0 -2
- package/static/app/assets/primitives-CnEAOmg2.js +0 -1
- package/static/app/assets/search-veep4n6f.js +0 -1
|
@@ -12,6 +12,8 @@ from typing import Any, Callable, Optional
|
|
|
12
12
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
+
from latticeai.core.messages import http_error, resolve_language, translate
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class ImportRequest(BaseModel):
|
|
17
19
|
artifact: dict
|
|
@@ -72,52 +74,52 @@ def create_portability_router(
|
|
|
72
74
|
) -> APIRouter:
|
|
73
75
|
router = APIRouter()
|
|
74
76
|
|
|
75
|
-
def _require_service():
|
|
77
|
+
def _require_service(request: Request):
|
|
76
78
|
if service is None or not service.available():
|
|
77
|
-
raise
|
|
79
|
+
raise http_error(503, "common.graph_disabled", resolve_language(request))
|
|
78
80
|
|
|
79
81
|
@router.get("/api/knowledge-graph/portability")
|
|
80
82
|
async def portability_status(request: Request):
|
|
81
83
|
require_user(request)
|
|
82
|
-
_require_service()
|
|
84
|
+
_require_service(request)
|
|
83
85
|
return service.snapshot_metadata()
|
|
84
86
|
|
|
85
87
|
@router.get("/api/brain/storage")
|
|
86
88
|
async def brain_storage_status(request: Request):
|
|
87
89
|
require_user(request)
|
|
88
|
-
_require_service()
|
|
90
|
+
_require_service(request)
|
|
89
91
|
return service.storage_status()
|
|
90
92
|
|
|
91
93
|
@router.get("/api/knowledge-graph/backup-health")
|
|
92
94
|
async def backup_health(request: Request):
|
|
93
95
|
require_user(request)
|
|
94
|
-
_require_service()
|
|
96
|
+
_require_service(request)
|
|
95
97
|
return service.backup_health()
|
|
96
98
|
|
|
97
99
|
@router.get("/api/knowledge-graph/provenance")
|
|
98
100
|
async def recent_provenance(request: Request, limit: int = 50, source_type: Optional[str] = None):
|
|
99
101
|
"""Recent ingestions (provenance trail) for the ingestion-sources UI."""
|
|
100
102
|
require_admin(request)
|
|
101
|
-
_require_service()
|
|
103
|
+
_require_service(request)
|
|
102
104
|
return service.recent_ingestions(limit=limit, source_type=source_type)
|
|
103
105
|
|
|
104
106
|
@router.post("/api/knowledge-graph/export")
|
|
105
107
|
async def export_graph(request: Request):
|
|
106
108
|
"""Logical JSON export of the whole graph (read-only)."""
|
|
107
109
|
require_admin(request)
|
|
108
|
-
_require_service()
|
|
110
|
+
_require_service(request)
|
|
109
111
|
return service.export()
|
|
110
112
|
|
|
111
113
|
@router.post("/api/knowledge-graph/export-file")
|
|
112
114
|
async def export_graph_file(request: Request):
|
|
113
115
|
require_admin(request)
|
|
114
|
-
_require_service()
|
|
116
|
+
_require_service(request)
|
|
115
117
|
return service.export_to_file()
|
|
116
118
|
|
|
117
119
|
@router.post("/api/knowledge-graph/import")
|
|
118
120
|
async def import_graph(req: ImportRequest, request: Request):
|
|
119
121
|
require_admin(request)
|
|
120
|
-
_require_service()
|
|
122
|
+
_require_service(request)
|
|
121
123
|
try:
|
|
122
124
|
return service.import_data(req.artifact, mode=req.mode, dry_run=req.dry_run)
|
|
123
125
|
except ValueError as exc:
|
|
@@ -126,13 +128,13 @@ def create_portability_router(
|
|
|
126
128
|
@router.post("/api/knowledge-graph/backup")
|
|
127
129
|
async def backup_graph(req: BackupRequest, request: Request):
|
|
128
130
|
require_admin(request)
|
|
129
|
-
_require_service()
|
|
131
|
+
_require_service(request)
|
|
130
132
|
return service.backup(req.path)
|
|
131
133
|
|
|
132
134
|
@router.post("/api/knowledge-graph/restore")
|
|
133
135
|
async def restore_graph(req: RestoreRequest, request: Request):
|
|
134
136
|
require_admin(request)
|
|
135
|
-
_require_service()
|
|
137
|
+
_require_service(request)
|
|
136
138
|
try:
|
|
137
139
|
return service.restore(req.path, verify=req.verify, dry_run=req.dry_run, confirm=req.confirm)
|
|
138
140
|
except (ValueError, FileNotFoundError) as exc:
|
|
@@ -141,7 +143,7 @@ def create_portability_router(
|
|
|
141
143
|
@router.post("/api/knowledge-graph/archive")
|
|
142
144
|
async def encrypted_archive(req: EncryptedArchiveRequest, request: Request):
|
|
143
145
|
require_admin(request)
|
|
144
|
-
_require_service()
|
|
146
|
+
_require_service(request)
|
|
145
147
|
try:
|
|
146
148
|
return service.encrypted_archive(req.path, passphrase=req.passphrase)
|
|
147
149
|
except (ValueError, FileNotFoundError) as exc:
|
|
@@ -150,7 +152,7 @@ def create_portability_router(
|
|
|
150
152
|
@router.post("/api/knowledge-graph/archive/inspect")
|
|
151
153
|
async def inspect_encrypted_archive(req: EncryptedInspectRequest, request: Request):
|
|
152
154
|
require_admin(request)
|
|
153
|
-
_require_service()
|
|
155
|
+
_require_service(request)
|
|
154
156
|
try:
|
|
155
157
|
return service.inspect_encrypted_archive(req.path, passphrase=req.passphrase)
|
|
156
158
|
except (ValueError, FileNotFoundError) as exc:
|
|
@@ -159,16 +161,22 @@ def create_portability_router(
|
|
|
159
161
|
@router.post("/api/knowledge-graph/archive/verify")
|
|
160
162
|
async def verify_encrypted_archive(req: EncryptedVerifyRequest, request: Request):
|
|
161
163
|
require_admin(request)
|
|
162
|
-
_require_service()
|
|
164
|
+
_require_service(request)
|
|
163
165
|
result = service.verify_encrypted_archive(req.path, passphrase=req.passphrase)
|
|
164
166
|
if not result.get("ok"):
|
|
165
|
-
raise HTTPException(
|
|
167
|
+
raise HTTPException(
|
|
168
|
+
status_code=400,
|
|
169
|
+
detail="; ".join(
|
|
170
|
+
result.get("errors")
|
|
171
|
+
or [translate("portability.verification_failed", resolve_language(request))]
|
|
172
|
+
),
|
|
173
|
+
)
|
|
166
174
|
return result
|
|
167
175
|
|
|
168
176
|
@router.post("/api/knowledge-graph/archive/import")
|
|
169
177
|
async def import_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
|
|
170
178
|
require_admin(request)
|
|
171
|
-
_require_service()
|
|
179
|
+
_require_service(request)
|
|
172
180
|
try:
|
|
173
181
|
return service.import_encrypted_archive(
|
|
174
182
|
req.path,
|
|
@@ -182,7 +190,7 @@ def create_portability_router(
|
|
|
182
190
|
@router.post("/api/knowledge-graph/archive/restore")
|
|
183
191
|
async def restore_encrypted_archive(req: EncryptedRestoreRequest, request: Request):
|
|
184
192
|
require_admin(request)
|
|
185
|
-
_require_service()
|
|
193
|
+
_require_service(request)
|
|
186
194
|
try:
|
|
187
195
|
return service.restore_encrypted_archive(
|
|
188
196
|
req.path,
|
|
@@ -196,7 +204,7 @@ def create_portability_router(
|
|
|
196
204
|
@router.post("/api/brain/storage/postgres/docker")
|
|
197
205
|
async def setup_postgres_docker(req: DockerPostgresRequest, request: Request):
|
|
198
206
|
require_admin(request)
|
|
199
|
-
_require_service()
|
|
207
|
+
_require_service(request)
|
|
200
208
|
return service.postgres_docker_setup(
|
|
201
209
|
consent=req.consent,
|
|
202
210
|
dry_run=req.dry_run,
|
|
@@ -206,7 +214,7 @@ def create_portability_router(
|
|
|
206
214
|
@router.post("/api/brain/storage/migrate-postgres")
|
|
207
215
|
async def migrate_sqlite_to_postgres(req: SQLiteToPostgresRequest, request: Request):
|
|
208
216
|
require_admin(request)
|
|
209
|
-
_require_service()
|
|
217
|
+
_require_service(request)
|
|
210
218
|
try:
|
|
211
219
|
return service.migrate_sqlite_to_postgres(
|
|
212
220
|
dsn=req.dsn,
|
|
@@ -11,9 +11,11 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from typing import Any, Callable, List, Optional
|
|
13
13
|
|
|
14
|
-
from fastapi import APIRouter,
|
|
14
|
+
from fastapi import APIRouter, Request
|
|
15
15
|
from pydantic import BaseModel, Field
|
|
16
16
|
|
|
17
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
class CreateProjectRequest(BaseModel):
|
|
19
21
|
title: str = Field(..., min_length=1, max_length=200)
|
|
@@ -65,7 +67,7 @@ def create_project_sessions_router(
|
|
|
65
67
|
user = require_user(request)
|
|
66
68
|
record = store.get(session_id, user_email=user, workspace_id=_scope(request))
|
|
67
69
|
if record is None:
|
|
68
|
-
raise
|
|
70
|
+
raise http_error(404, "project.not_found", resolve_language(request))
|
|
69
71
|
return record
|
|
70
72
|
|
|
71
73
|
@router.patch("/api/projects/{session_id}")
|
|
@@ -80,7 +82,7 @@ def create_project_sessions_router(
|
|
|
80
82
|
workspace_id=_scope(request, write=True),
|
|
81
83
|
)
|
|
82
84
|
if record is None:
|
|
83
|
-
raise
|
|
85
|
+
raise http_error(404, "project.not_found", resolve_language(request))
|
|
84
86
|
return record
|
|
85
87
|
|
|
86
88
|
@router.put("/api/projects/{session_id}/todos")
|
|
@@ -93,7 +95,7 @@ def create_project_sessions_router(
|
|
|
93
95
|
workspace_id=_scope(request, write=True),
|
|
94
96
|
)
|
|
95
97
|
if record is None:
|
|
96
|
-
raise
|
|
98
|
+
raise http_error(404, "project.not_found", resolve_language(request))
|
|
97
99
|
return record
|
|
98
100
|
|
|
99
101
|
@router.delete("/api/projects/{session_id}")
|
|
@@ -103,7 +105,7 @@ def create_project_sessions_router(
|
|
|
103
105
|
session_id, user_email=user, workspace_id=_scope(request, write=True)
|
|
104
106
|
)
|
|
105
107
|
if not removed:
|
|
106
|
-
raise
|
|
108
|
+
raise http_error(404, "project.not_found", resolve_language(request))
|
|
107
109
|
return {"status": "deleted", "id": session_id}
|
|
108
110
|
|
|
109
111
|
return router
|
|
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
19
19
|
from fastapi import APIRouter, HTTPException, Request
|
|
20
20
|
from pydantic import BaseModel, Field
|
|
21
21
|
|
|
22
|
+
from latticeai.core.messages import http_error, resolve_language, translate
|
|
22
23
|
from latticeai.services.change_proposals import ProposalConflictError
|
|
23
24
|
from latticeai.services.review_queue import InvalidReviewTransition, ReviewQueueService
|
|
24
25
|
|
|
@@ -129,7 +130,7 @@ def create_review_queue_router(
|
|
|
129
130
|
try:
|
|
130
131
|
return service.get(item_id, workspace_id=scope)
|
|
131
132
|
except FileNotFoundError as exc:
|
|
132
|
-
raise
|
|
133
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
133
134
|
|
|
134
135
|
@router.post("/automation/reviews/{item_id}/approve", response_model=ReviewItem)
|
|
135
136
|
async def approve_item(item_id: str, request: Request):
|
|
@@ -141,12 +142,16 @@ def create_review_queue_router(
|
|
|
141
142
|
try:
|
|
142
143
|
stored = service.get(item_id, workspace_id=scope)
|
|
143
144
|
except FileNotFoundError as exc:
|
|
144
|
-
raise
|
|
145
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
145
146
|
if stored.get("source") == "change_proposal":
|
|
146
147
|
if stored.get("effective_status") not in ("pending", "snoozed"):
|
|
147
148
|
raise HTTPException(
|
|
148
149
|
status_code=409,
|
|
149
|
-
detail=
|
|
150
|
+
detail=translate(
|
|
151
|
+
"review.cannot_approve_in_status",
|
|
152
|
+
resolve_language(request),
|
|
153
|
+
status=stored.get("status"),
|
|
154
|
+
),
|
|
150
155
|
)
|
|
151
156
|
try:
|
|
152
157
|
applied = change_proposals.approve_and_apply(
|
|
@@ -177,7 +182,7 @@ def create_review_queue_router(
|
|
|
177
182
|
item_id, workspace_id=scope, reason=(req.reason if req else None)
|
|
178
183
|
)
|
|
179
184
|
except FileNotFoundError as exc:
|
|
180
|
-
raise
|
|
185
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
181
186
|
except InvalidReviewTransition as exc:
|
|
182
187
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
183
188
|
append_audit_event("review_item_dismiss", user_email=user, item_id=item_id)
|
|
@@ -190,7 +195,7 @@ def create_review_queue_router(
|
|
|
190
195
|
try:
|
|
191
196
|
item = service.snooze(item_id, until=req.until, workspace_id=scope)
|
|
192
197
|
except FileNotFoundError as exc:
|
|
193
|
-
raise
|
|
198
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
194
199
|
except InvalidReviewTransition as exc:
|
|
195
200
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
196
201
|
append_audit_event("review_item_snooze", user_email=user, item_id=item_id)
|
|
@@ -211,7 +216,7 @@ def create_review_queue_router(
|
|
|
211
216
|
workspace_id=scope,
|
|
212
217
|
)
|
|
213
218
|
except FileNotFoundError as exc:
|
|
214
|
-
raise
|
|
219
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
215
220
|
except InvalidReviewTransition as exc:
|
|
216
221
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
217
222
|
append_audit_event("review_item_run_now", user_email=user, item_id=item_id)
|
|
@@ -224,7 +229,7 @@ def create_review_queue_router(
|
|
|
224
229
|
try:
|
|
225
230
|
item = fn(item_id, workspace_id=scope)
|
|
226
231
|
except FileNotFoundError as exc:
|
|
227
|
-
raise
|
|
232
|
+
raise http_error(404, "review.item_not_found", resolve_language(request)) from exc
|
|
228
233
|
except InvalidReviewTransition as exc:
|
|
229
234
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
230
235
|
append_audit_event(f"review_item_{action}", user_email=user, item_id=item_id)
|
package/latticeai/api/setup.py
CHANGED
|
@@ -9,6 +9,7 @@ from fastapi.responses import StreamingResponse
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
11
|
from lattice_brain.ingestion import IngestionItem
|
|
12
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
12
13
|
from latticeai.models.router import parse_model_ref
|
|
13
14
|
from latticeai.services.process_audit import command_plan
|
|
14
15
|
from latticeai.setup.auto_setup import (
|
|
@@ -184,27 +185,25 @@ def create_setup_router(
|
|
|
184
185
|
}
|
|
185
186
|
url = auth_urls.get(mcp_id)
|
|
186
187
|
if not url:
|
|
187
|
-
raise
|
|
188
|
+
raise http_error(404, "mcp.unknown_id", resolve_language(request), mcp_id=mcp_id)
|
|
188
189
|
open_url(url)
|
|
189
190
|
return {"status": "ok", "opened": url, "mcp_id": mcp_id}
|
|
190
191
|
|
|
191
192
|
|
|
192
193
|
# ── First Value Loop demo corpus (backlog #3, review §3.3 P0) ────────────
|
|
193
194
|
|
|
194
|
-
def _require_demo_pipeline():
|
|
195
|
+
def _require_demo_pipeline(request: Request):
|
|
195
196
|
if ingestion_pipeline is None or not ingestion_pipeline.available():
|
|
196
|
-
raise
|
|
197
|
-
status_code=503, detail="Knowledge Graph ingestion is disabled.",
|
|
198
|
-
)
|
|
197
|
+
raise http_error(503, "capture.ingestion_disabled", resolve_language(request))
|
|
199
198
|
if knowledge_graph is None:
|
|
200
|
-
raise
|
|
199
|
+
raise http_error(503, "common.graph_disabled", resolve_language(request))
|
|
201
200
|
|
|
202
201
|
def _demo_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
|
|
203
202
|
header = request.headers.get("X-Workspace-Id")
|
|
204
203
|
header = header.strip() if header and header.strip() else None
|
|
205
204
|
supplied = [value for value in (body_workspace, header) if value]
|
|
206
205
|
if len(set(supplied)) > 1:
|
|
207
|
-
raise
|
|
206
|
+
raise http_error(403, "common.workspace_mismatch", resolve_language(request))
|
|
208
207
|
requested = supplied[0] if supplied else None
|
|
209
208
|
if workspace_service is None:
|
|
210
209
|
return requested
|
|
@@ -217,7 +216,7 @@ def create_setup_router(
|
|
|
217
216
|
async def demo_corpus_status(request: Request):
|
|
218
217
|
"""Whether the demo corpus is installed + the suggestion chips."""
|
|
219
218
|
require_user(request)
|
|
220
|
-
_require_demo_pipeline()
|
|
219
|
+
_require_demo_pipeline(request)
|
|
221
220
|
installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
|
|
222
221
|
return {
|
|
223
222
|
"installed": bool(installed),
|
|
@@ -236,7 +235,7 @@ def create_setup_router(
|
|
|
236
235
|
instead of duplicating.
|
|
237
236
|
"""
|
|
238
237
|
user = require_user(request)
|
|
239
|
-
_require_demo_pipeline()
|
|
238
|
+
_require_demo_pipeline(request)
|
|
240
239
|
workspace_id = _demo_workspace(request, req.workspace_id if req else None, user)
|
|
241
240
|
results = []
|
|
242
241
|
ingested = 0
|
|
@@ -285,7 +284,7 @@ def create_setup_router(
|
|
|
285
284
|
async def demo_corpus_remove(request: Request):
|
|
286
285
|
"""Remove every demo document (node + chunks + edges + orphan source)."""
|
|
287
286
|
require_user(request)
|
|
288
|
-
_require_demo_pipeline()
|
|
287
|
+
_require_demo_pipeline(request)
|
|
289
288
|
installed = knowledge_graph.find_documents_by_uri_prefix(DEMO_URI_PREFIX)
|
|
290
289
|
removed = []
|
|
291
290
|
for doc in installed:
|
|
@@ -314,7 +313,7 @@ def create_setup_router(
|
|
|
314
313
|
}
|
|
315
314
|
url = urls.get(permission_id)
|
|
316
315
|
if not url:
|
|
317
|
-
raise
|
|
316
|
+
raise http_error(404, "setup.unknown_permission", resolve_language(request))
|
|
318
317
|
open_url(url)
|
|
319
318
|
return {"status": "ok", "opened": url, "permission": permission_id}
|
|
320
319
|
return api_router
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import hashlib
|
|
6
7
|
import hmac
|
|
8
|
+
import re
|
|
7
9
|
import secrets
|
|
8
10
|
import subprocess
|
|
9
11
|
import time
|
|
@@ -40,6 +42,59 @@ SYSINFO_READINESS_ROOMY_MAX = 55.0
|
|
|
40
42
|
SYSINFO_READINESS_TIGHT_MAX = 80.0
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
def _probe_host_capacity() -> Dict[str, Any]:
|
|
46
|
+
"""Sample CPU / RAM / GPU on this machine. Blocking — call off the loop.
|
|
47
|
+
|
|
48
|
+
Extracted from the route so the subprocess sampling can be handed to a
|
|
49
|
+
worker thread, and so the parsing is testable without an HTTP request.
|
|
50
|
+
"""
|
|
51
|
+
result: Dict[str, Any] = {
|
|
52
|
+
"cpu_pct": 0.0,
|
|
53
|
+
"ram_pct": 0.0,
|
|
54
|
+
"gpu_mem_pct": 0.0,
|
|
55
|
+
"gpu_mem_gb": 0.0,
|
|
56
|
+
"readiness": "roomy",
|
|
57
|
+
}
|
|
58
|
+
try:
|
|
59
|
+
# CPU
|
|
60
|
+
top_out = subprocess.run(["top", "-l", "1", "-n", "0"], capture_output=True, text=True, timeout=4).stdout
|
|
61
|
+
for line in top_out.splitlines():
|
|
62
|
+
if "CPU usage" in line:
|
|
63
|
+
m = re.search(r"([\d.]+)% user.*?([\d.]+)% sys", line)
|
|
64
|
+
if m:
|
|
65
|
+
result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
|
|
66
|
+
# RAM
|
|
67
|
+
vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
|
|
68
|
+
pages: dict = {}
|
|
69
|
+
for line in vm_out.splitlines():
|
|
70
|
+
for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
|
|
71
|
+
if line.startswith(key):
|
|
72
|
+
m = re.search(r"(\d+)", line)
|
|
73
|
+
if m:
|
|
74
|
+
pages[key] = int(m.group(1))
|
|
75
|
+
total = sum(pages.values())
|
|
76
|
+
used = total - pages.get("Pages free", 0)
|
|
77
|
+
result["ram_pct"] = round(used / total * 100, 1) if total else 0.0
|
|
78
|
+
# GPU (MLX / Apple Silicon unified memory)
|
|
79
|
+
try:
|
|
80
|
+
import mlx.core as _mx
|
|
81
|
+
hw_out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2).stdout
|
|
82
|
+
total_bytes = int(hw_out.strip())
|
|
83
|
+
gpu_bytes = _mx.get_active_memory() + _mx.get_cache_memory()
|
|
84
|
+
result["gpu_mem_gb"] = round(gpu_bytes / (1024 ** 3), 2)
|
|
85
|
+
result["gpu_mem_pct"] = round(gpu_bytes / total_bytes * 100, 1) if total_bytes else 0.0
|
|
86
|
+
except Exception:
|
|
87
|
+
quiet()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
result["error"] = str(e)
|
|
90
|
+
result["readiness"] = host_capacity_readiness(
|
|
91
|
+
cpu_pct=float(result.get("cpu_pct") or 0.0),
|
|
92
|
+
ram_pct=float(result.get("ram_pct") or 0.0),
|
|
93
|
+
gpu_mem_pct=float(result.get("gpu_mem_pct") or 0.0),
|
|
94
|
+
)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
43
98
|
def host_capacity_readiness(
|
|
44
99
|
*,
|
|
45
100
|
cpu_pct: float = 0.0,
|
|
@@ -278,54 +333,15 @@ def create_static_routes_router(
|
|
|
278
333
|
|
|
279
334
|
``readiness`` is ``roomy`` | ``tight`` | ``low`` so basic System copy
|
|
280
335
|
does not re-interpret raw percents on the client.
|
|
336
|
+
|
|
337
|
+
The probe itself is three subprocesses — ``top -l 1`` alone samples for
|
|
338
|
+
about a second — so it runs on a worker thread. Run inline it held the
|
|
339
|
+
event loop for the whole sample, and this route is hit on the System
|
|
340
|
+
screen *and* during first-run analysis, i.e. exactly while an answer is
|
|
341
|
+
streaming: one status poll used to stall every open stream.
|
|
281
342
|
"""
|
|
282
343
|
require_user(request)
|
|
283
|
-
|
|
284
|
-
result: Dict[str, Any] = {
|
|
285
|
-
"cpu_pct": 0.0,
|
|
286
|
-
"ram_pct": 0.0,
|
|
287
|
-
"gpu_mem_pct": 0.0,
|
|
288
|
-
"gpu_mem_gb": 0.0,
|
|
289
|
-
"readiness": "roomy",
|
|
290
|
-
}
|
|
291
|
-
try:
|
|
292
|
-
# CPU
|
|
293
|
-
top_out = subprocess.run(["top", "-l", "1", "-n", "0"], capture_output=True, text=True, timeout=4).stdout
|
|
294
|
-
for line in top_out.splitlines():
|
|
295
|
-
if "CPU usage" in line:
|
|
296
|
-
m = _re.search(r"([\d.]+)% user.*?([\d.]+)% sys", line)
|
|
297
|
-
if m:
|
|
298
|
-
result["cpu_pct"] = round(float(m.group(1)) + float(m.group(2)), 1)
|
|
299
|
-
# RAM
|
|
300
|
-
vm_out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=4).stdout
|
|
301
|
-
pages: dict = {}
|
|
302
|
-
for line in vm_out.splitlines():
|
|
303
|
-
for key in ["Pages free", "Pages active", "Pages inactive", "Pages wired down", "Pages occupied by compressor"]:
|
|
304
|
-
if line.startswith(key):
|
|
305
|
-
m = _re.search(r"(\d+)", line)
|
|
306
|
-
if m:
|
|
307
|
-
pages[key] = int(m.group(1))
|
|
308
|
-
total = sum(pages.values())
|
|
309
|
-
used = total - pages.get("Pages free", 0)
|
|
310
|
-
result["ram_pct"] = round(used / total * 100, 1) if total else 0.0
|
|
311
|
-
# GPU (MLX / Apple Silicon unified memory)
|
|
312
|
-
try:
|
|
313
|
-
import mlx.core as _mx
|
|
314
|
-
hw_out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2).stdout
|
|
315
|
-
total_bytes = int(hw_out.strip())
|
|
316
|
-
gpu_bytes = _mx.get_active_memory() + _mx.get_cache_memory()
|
|
317
|
-
result["gpu_mem_gb"] = round(gpu_bytes / (1024 ** 3), 2)
|
|
318
|
-
result["gpu_mem_pct"] = round(gpu_bytes / total_bytes * 100, 1) if total_bytes else 0.0
|
|
319
|
-
except Exception:
|
|
320
|
-
quiet()
|
|
321
|
-
except Exception as e:
|
|
322
|
-
result["error"] = str(e)
|
|
323
|
-
result["readiness"] = host_capacity_readiness(
|
|
324
|
-
cpu_pct=float(result.get("cpu_pct") or 0.0),
|
|
325
|
-
ram_pct=float(result.get("ram_pct") or 0.0),
|
|
326
|
-
gpu_mem_pct=float(result.get("gpu_mem_pct") or 0.0),
|
|
327
|
-
)
|
|
328
|
-
return result
|
|
344
|
+
return await asyncio.to_thread(_probe_host_capacity)
|
|
329
345
|
|
|
330
346
|
return StaticRoutesBundle(
|
|
331
347
|
api_router,
|
package/latticeai/api/tools.py
CHANGED
|
@@ -19,6 +19,7 @@ from latticeai.api.computer_use import create_computer_use_router
|
|
|
19
19
|
from latticeai.api.local_files import create_local_files_router
|
|
20
20
|
from latticeai.api.mcp import create_mcp_router
|
|
21
21
|
from latticeai.api.permissions import create_permissions_router
|
|
22
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
22
23
|
from latticeai.services.router_context import ToolRouterContext
|
|
23
24
|
from latticeai.services.tool_dispatch import (
|
|
24
25
|
TOOL_GOVERNANCE,
|
|
@@ -510,7 +511,7 @@ def create_tools_router(
|
|
|
510
511
|
)
|
|
511
512
|
target = Path(path).expanduser().resolve()
|
|
512
513
|
if not target.exists() or not target.is_file():
|
|
513
|
-
raise
|
|
514
|
+
raise http_error(404, "common.file_not_found", resolve_language(request))
|
|
514
515
|
import pypdfium2 as pdfium
|
|
515
516
|
doc = None
|
|
516
517
|
try:
|
|
@@ -527,7 +528,7 @@ def create_tools_router(
|
|
|
527
528
|
pages.append({"page": i + 1, "b64": b64})
|
|
528
529
|
return {"total": total, "pages": pages}
|
|
529
530
|
except Exception as e:
|
|
530
|
-
raise
|
|
531
|
+
raise http_error(500, "tools.pdf_render_failed", resolve_language(request), reason=e)
|
|
531
532
|
finally:
|
|
532
533
|
if doc is not None:
|
|
533
534
|
try:
|
|
@@ -544,9 +545,9 @@ def create_tools_router(
|
|
|
544
545
|
rel = unquote(path).lstrip("/")
|
|
545
546
|
target = (AGENT_ROOT / rel).resolve()
|
|
546
547
|
if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
|
|
547
|
-
raise
|
|
548
|
+
raise http_error(403, "tools.path_outside_workspace", resolve_language(request))
|
|
548
549
|
if not target.exists() or not target.is_file():
|
|
549
|
-
raise
|
|
550
|
+
raise http_error(404, "common.file_not_found", resolve_language(request))
|
|
550
551
|
return FileResponse(
|
|
551
552
|
path=target,
|
|
552
553
|
filename=target.name,
|
|
@@ -570,9 +571,9 @@ def create_tools_router(
|
|
|
570
571
|
rel = unquote(path).lstrip("/")
|
|
571
572
|
target = (AGENT_ROOT / rel).resolve()
|
|
572
573
|
if AGENT_ROOT not in target.parents and target != AGENT_ROOT:
|
|
573
|
-
raise
|
|
574
|
+
raise http_error(403, "tools.path_outside_workspace", resolve_language(request))
|
|
574
575
|
if not target.exists() or not target.is_dir():
|
|
575
|
-
raise
|
|
576
|
+
raise http_error(404, "tools.directory_not_found", resolve_language(request))
|
|
576
577
|
try:
|
|
577
578
|
payload, filename = zip_workspace_dir(rel)
|
|
578
579
|
except ToolError as exc:
|
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from copy import deepcopy
|
|
11
11
|
from typing import Any, Dict, List, Optional
|
|
12
12
|
|
|
13
|
-
MARKETPLACE_VERSION = "10.
|
|
13
|
+
MARKETPLACE_VERSION = "10.10.0"
|
|
14
14
|
TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
|
|
15
15
|
|
|
16
16
|
|
|
@@ -4,6 +4,7 @@ Lattice AI — MCP Registry data & pure helper functions.
|
|
|
4
4
|
Extracted from server.py to reduce module size.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import os
|
|
@@ -354,6 +355,13 @@ async def install_skill(plugin: str, skill: str) -> Dict:
|
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
|
|
358
|
+
def _run_installer(command: List[str], timeout: int) -> "subprocess.CompletedProcess[str]":
|
|
359
|
+
"""Run one package-installer command. Blocking — call off the event loop."""
|
|
360
|
+
return subprocess.run(
|
|
361
|
+
command, capture_output=True, text=True, timeout=timeout, check=False
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
357
365
|
def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
|
|
358
366
|
"""Return bound MCP state helpers for given data_dir. No global side effects."""
|
|
359
367
|
MCP_FILE = Path(data_dir) / "mcp_installs.json"
|
|
@@ -445,11 +453,15 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
|
|
|
445
453
|
status = "needs_auth"
|
|
446
454
|
message = "커넥터 인증이 필요합니다. Codex 앱의 connector 설정에서 계정을 연결하면 바로 사용할 수 있습니다."
|
|
447
455
|
elif item.get("install_mode") == "pip":
|
|
456
|
+
# Package installers are minutes of network I/O. On the event loop
|
|
457
|
+
# they froze every other request for the whole install; each one now
|
|
458
|
+
# runs on a worker thread instead.
|
|
448
459
|
packages = item.get("pip_packages") or []
|
|
449
460
|
for pkg in packages:
|
|
450
|
-
completed =
|
|
461
|
+
completed = await asyncio.to_thread(
|
|
462
|
+
_run_installer,
|
|
451
463
|
[sys.executable, "-m", "pip", "install", "--upgrade", pkg],
|
|
452
|
-
|
|
464
|
+
900,
|
|
453
465
|
)
|
|
454
466
|
if completed.returncode != 0:
|
|
455
467
|
raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
|
|
@@ -458,7 +470,9 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
|
|
|
458
470
|
pkg = item.get("package", "")
|
|
459
471
|
version = item.get("package_version")
|
|
460
472
|
pkg_str = f"{pkg}=={version}" if version else pkg
|
|
461
|
-
completed =
|
|
473
|
+
completed = await asyncio.to_thread(
|
|
474
|
+
_run_installer, [sys.executable, "-m", "pip", "install", pkg_str], 300
|
|
475
|
+
)
|
|
462
476
|
if completed.returncode != 0:
|
|
463
477
|
raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
|
|
464
478
|
message = f"pip 패키지 설치 완료: {pkg_str}"
|
|
@@ -466,7 +480,9 @@ def create_mcp_install_state(data_dir: Path) -> Dict[str, Callable]:
|
|
|
466
480
|
pkg = item.get("package", "")
|
|
467
481
|
version = item.get("package_version")
|
|
468
482
|
pkg_str = f"{pkg}@{version}" if version else pkg
|
|
469
|
-
completed =
|
|
483
|
+
completed = await asyncio.to_thread(
|
|
484
|
+
_run_installer, ["npm", "install", "-g", pkg_str], 300
|
|
485
|
+
)
|
|
470
486
|
if completed.returncode != 0:
|
|
471
487
|
raise HTTPException(status_code=500, detail=(completed.stderr or "")[-2000:] or f"{pkg} 설치 실패")
|
|
472
488
|
message = f"npm 패키지 설치 완료: {pkg_str}"
|