ltcai 10.6.3 → 10.8.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 +56 -59
- package/docs/CHANGELOG.md +68 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +29 -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/graph/retrieval_vector.py +69 -33
- package/lattice_brain/runtime/multi_agent.py +96 -15
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +81 -6
- package/latticeai/api/auth.py +37 -38
- package/latticeai/api/automation_intelligence.py +112 -0
- package/latticeai/api/browser.py +7 -6
- package/latticeai/api/knowledge_graph.py +133 -0
- package/latticeai/api/static_routes.py +35 -1
- package/latticeai/core/file_generation.py +141 -8
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +230 -0
- package/latticeai/core/workspace_computer_memory.py +84 -0
- package/latticeai/core/workspace_indexing.py +102 -0
- package/latticeai/core/workspace_onboarding.py +104 -0
- package/latticeai/core/workspace_os.py +27 -207
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_relationships.py +99 -0
- package/latticeai/core/workspace_runs.py +111 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +5 -3
- package/scripts/capture_release_evidence.mjs +335 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_release_evidence_bound.mjs +116 -0
- package/scripts/check_screenshot_pixel_delta.py +517 -0
- package/scripts/measure_brain_home_fill.mjs +141 -0
- package/scripts/release_screen_claims.json +37 -0
- 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 -39
- package/static/app/assets/{Act-05uZSgyw.js → Act-CbQNsFgc.js} +1 -1
- package/static/app/assets/AdminConsole-Bs8SR8fE.js +1 -0
- package/static/app/assets/{Brain-jjMYTdnU.js → Brain-BZIPsZlH.js} +2 -2
- package/static/app/assets/BrainHome-BaH5MDce.js +2 -0
- package/static/app/assets/{BrainSignals-BYxDqBH3.js → BrainSignals-aDt-nSsW.js} +1 -1
- package/static/app/assets/Capture-DDv940Bv.js +1 -0
- package/static/app/assets/{CommandPalette-Cj7z-qUE.js → CommandPalette-Dq_MmPBl.js} +1 -1
- package/static/app/assets/Library-BkShbqmN.js +1 -0
- package/static/app/assets/{LivingBrain-CTokkZn1.js → LivingBrain-CXb8094H.js} +1 -1
- package/static/app/assets/ProductFlow-BaiqFWLO.js +1 -0
- package/static/app/assets/{ReviewCard-uAII_l9v.js → ReviewCard-Be7E7X18.js} +2 -2
- package/static/app/assets/System-DCstbTxC.js +1 -0
- package/static/app/assets/arrow-left-DC9Oquhs.js +1 -0
- package/static/app/assets/{bot-CkrHFdUl.js → bot-BR0eoeXY.js} +1 -1
- package/static/app/assets/brain-BmWa1wea.js +1 -0
- package/static/app/assets/{button-Ic6omIbs.js → button-U1nU7xX4.js} +1 -1
- package/static/app/assets/{circle-pause-DBaaUR08.js → circle-pause-DG5KHjDL.js} +1 -1
- package/static/app/assets/{circle-play-Dz0OLYN9.js → circle-play-Chhcuq90.js} +1 -1
- package/static/app/assets/{cpu-N8uE9ono.js → cpu-pkOSlT9h.js} +1 -1
- package/static/app/assets/{download-CCjhOjms.js → download-CSRwz923.js} +1 -1
- package/static/app/assets/{folder-open-Bld9TtHp.js → folder-open-DiMJ7W_7.js} +1 -1
- package/static/app/assets/{hard-drive-BMIqDSyY.js → hard-drive-iyLPAPdp.js} +1 -1
- package/static/app/assets/index-BZRvGOM2.css +2 -0
- package/static/app/assets/{index-yNtiRyw3.js → index-_ldE9TAX.js} +4 -4
- package/static/app/assets/{input-DW1tXtc0.js → input-e_r2hFAY.js} +1 -1
- package/static/app/assets/{permissionCopy-Djpwn54H.js → permissionCopy-9uOHktz3.js} +1 -1
- package/static/app/assets/{primitives-B6WtkLgB.js → primitives-CnEAOmg2.js} +1 -1
- package/static/app/assets/search-veep4n6f.js +1 -0
- package/static/app/assets/{share-2-DR5L1qRc.js → share-2-DLDJJKmI.js} +1 -1
- package/static/app/assets/{shield-alert-CVE5eXsl.js → shield-alert-BTtE-2EZ.js} +1 -1
- package/static/app/assets/{textarea-DdaVFgvk.js → textarea-Bbzkcgqe.js} +1 -1
- package/static/app/assets/{useFocusTrap-B5TyGaiB.js → useFocusTrap-DJzQd-Za.js} +1 -1
- package/static/app/assets/{useQuery-CiRdTQlD.js → useQuery-6R_tqHRe.js} +1 -1
- package/static/app/assets/utils-XDsjcrhS.js +4 -0
- package/static/app/assets/workspace-CwrG8LHq.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/AdminConsole-C8yz1Cs_.js +0 -1
- package/static/app/assets/BrainHome-D3t45_D4.js +0 -2
- package/static/app/assets/Capture-DKgu7fB0.js +0 -1
- package/static/app/assets/Library-CO76FWeO.js +0 -1
- package/static/app/assets/ProductFlow-Bmted2ys.js +0 -1
- package/static/app/assets/System-PUMzDmof.js +0 -1
- package/static/app/assets/activity-C_7182GG.js +0 -1
- package/static/app/assets/arrow-left-BQCpWR37.js +0 -1
- package/static/app/assets/brain-CdPX7svt.js +0 -1
- package/static/app/assets/index-DYUs0cWy.css +0 -2
- package/static/app/assets/search-CkHYuKpY.js +0 -1
- package/static/app/assets/users-pZqCRxdD.js +0 -1
- package/static/app/assets/utils-C3rXoa-U.js +0 -4
- package/static/app/assets/workspace-DfxKEttN.js +0 -1
package/latticeai/api/auth.py
CHANGED
|
@@ -9,10 +9,11 @@ from dataclasses import dataclass
|
|
|
9
9
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
10
10
|
from urllib.parse import urlencode
|
|
11
11
|
|
|
12
|
-
from fastapi import APIRouter,
|
|
12
|
+
from fastapi import APIRouter, Request
|
|
13
13
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
14
14
|
from pydantic import BaseModel
|
|
15
15
|
|
|
16
|
+
from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
|
|
16
17
|
from latticeai.core.oidc import (
|
|
17
18
|
OIDCValidationError,
|
|
18
19
|
)
|
|
@@ -92,18 +93,16 @@ def create_auth_router(
|
|
|
92
93
|
) -> APIRouter:
|
|
93
94
|
router = APIRouter()
|
|
94
95
|
|
|
95
|
-
def _enforce_password_policy(password: str) -> None:
|
|
96
|
+
def _enforce_password_policy(password: str, language: str = DEFAULT_LANGUAGE) -> None:
|
|
96
97
|
# Real policy (v4): length >= 8 with letters AND digits. A 4-char
|
|
97
98
|
# minimum was not a policy.
|
|
98
99
|
pw = str(password or "")
|
|
99
100
|
if len(pw) < 8 or not any(c.isalpha() for c in pw) or not any(c.isdigit() for c in pw):
|
|
100
|
-
raise
|
|
101
|
-
status_code=400,
|
|
102
|
-
detail="비밀번호는 8자 이상이며 영문자와 숫자를 모두 포함해야 합니다.",
|
|
103
|
-
)
|
|
101
|
+
raise http_error(400, "auth.password_too_weak", language)
|
|
104
102
|
|
|
105
103
|
@router.post("/register")
|
|
106
104
|
async def register(req: UserRegister, request: Request):
|
|
105
|
+
lang = resolve_language(request)
|
|
107
106
|
check_ip_rate_limit(client_ip(request), "register", max_calls=5, window_secs=3600)
|
|
108
107
|
invite_claim = bool(
|
|
109
108
|
invite_gate_enabled
|
|
@@ -111,19 +110,16 @@ def create_auth_router(
|
|
|
111
110
|
and invite_authorized(request)
|
|
112
111
|
)
|
|
113
112
|
if invite_gate_enabled and not invite_claim:
|
|
114
|
-
raise
|
|
115
|
-
status_code=403,
|
|
116
|
-
detail="유효한 서명 초대 권한이 필요합니다.",
|
|
117
|
-
)
|
|
113
|
+
raise http_error(403, "auth.invitation_required", lang)
|
|
118
114
|
# Closed public registration stays closed unless the operator enabled
|
|
119
115
|
# the invite gate and this exact request carries a valid signed claim.
|
|
120
116
|
if not open_registration and not invite_claim:
|
|
121
|
-
raise
|
|
122
|
-
_enforce_password_policy(req.password)
|
|
117
|
+
raise http_error(403, "auth.registration_disabled", lang)
|
|
118
|
+
_enforce_password_policy(req.password, lang)
|
|
123
119
|
email = normalize_email(req.email)
|
|
124
120
|
users = load_users()
|
|
125
121
|
if email in users:
|
|
126
|
-
raise
|
|
122
|
+
raise http_error(400, "auth.email_taken", lang)
|
|
127
123
|
role = "admin" if not users else "user"
|
|
128
124
|
users[email] = {
|
|
129
125
|
"password": hash_password(req.password),
|
|
@@ -140,14 +136,15 @@ def create_auth_router(
|
|
|
140
136
|
|
|
141
137
|
@router.post("/login")
|
|
142
138
|
async def login(req: UserLogin, request: Request):
|
|
139
|
+
lang = resolve_language(request)
|
|
143
140
|
check_ip_rate_limit(client_ip(request), "login", max_calls=10, window_secs=300)
|
|
144
141
|
email = normalize_email(req.email)
|
|
145
142
|
users = load_users()
|
|
146
143
|
user = users.get(email)
|
|
147
144
|
if not user or not verify_and_migrate(email, req.password, user.get("password", ""), users):
|
|
148
|
-
raise
|
|
145
|
+
raise http_error(401, "auth.bad_credentials", lang)
|
|
149
146
|
if user.get("disabled"):
|
|
150
|
-
raise
|
|
147
|
+
raise http_error(403, "auth.account_disabled", lang)
|
|
151
148
|
role = get_user_role(email, users)
|
|
152
149
|
token = create_session(email)
|
|
153
150
|
response = JSONResponse(content={
|
|
@@ -174,10 +171,11 @@ def create_auth_router(
|
|
|
174
171
|
|
|
175
172
|
@router.get("/auth/sso/login")
|
|
176
173
|
async def sso_login(request: Request):
|
|
174
|
+
lang = resolve_language(request)
|
|
177
175
|
settings = get_sso_settings()
|
|
178
176
|
discovery = await get_sso_discovery()
|
|
179
177
|
if not settings.get("enabled") or not discovery:
|
|
180
|
-
raise
|
|
178
|
+
raise http_error(503, "sso.not_configured", lang)
|
|
181
179
|
state = secrets.token_urlsafe(16)
|
|
182
180
|
nonce = secrets.token_urlsafe(16)
|
|
183
181
|
# PKCE (S256): bind the token exchange to this login, so an
|
|
@@ -214,16 +212,17 @@ def create_auth_router(
|
|
|
214
212
|
return RedirectResponse(f"{discovery['authorization_endpoint']}?{params}")
|
|
215
213
|
|
|
216
214
|
@router.get("/auth/sso/callback")
|
|
217
|
-
async def sso_callback(code: str = "", state: str = "", error: str = ""):
|
|
215
|
+
async def sso_callback(request: Request, code: str = "", state: str = "", error: str = ""):
|
|
216
|
+
lang = resolve_language(request)
|
|
218
217
|
entry = _sso_states.pop(state, None)
|
|
219
218
|
if entry is None or time.time() - entry.issued_at > 300:
|
|
220
|
-
raise
|
|
219
|
+
raise http_error(400, "sso.invalid_state", lang)
|
|
221
220
|
if error:
|
|
222
221
|
return RedirectResponse(f"/?{urlencode({'sso_error': error})}")
|
|
223
222
|
settings = get_sso_settings()
|
|
224
223
|
discovery = await get_sso_discovery()
|
|
225
224
|
if not settings.get("enabled") or not discovery:
|
|
226
|
-
raise
|
|
225
|
+
raise http_error(503, "sso.config_error", lang)
|
|
227
226
|
import httpx as _httpx
|
|
228
227
|
async with _httpx.AsyncClient() as c:
|
|
229
228
|
r = await c.post(discovery["token_endpoint"], data={
|
|
@@ -237,7 +236,7 @@ def create_auth_router(
|
|
|
237
236
|
tokens = r.json()
|
|
238
237
|
id_token = tokens.get("id_token")
|
|
239
238
|
if not id_token:
|
|
240
|
-
raise
|
|
239
|
+
raise http_error(400, "sso.no_id_token", lang)
|
|
241
240
|
# Never trust a decoded JWT payload: verify signature (against the
|
|
242
241
|
# provider JWKS), issuer, audience, expiry and the login nonce before
|
|
243
242
|
# using any claim. Any failure is fail-closed (401).
|
|
@@ -253,20 +252,17 @@ def create_auth_router(
|
|
|
253
252
|
)
|
|
254
253
|
except OIDCValidationError as exc:
|
|
255
254
|
logging.warning("SSO ID token rejected: %s", exc)
|
|
256
|
-
raise
|
|
255
|
+
raise http_error(401, "sso.token_verification_failed", lang)
|
|
257
256
|
except Exception as exc: # discovery/JWKS fetch failure → fail closed
|
|
258
257
|
logging.warning("SSO token validation error: %s", exc)
|
|
259
|
-
raise
|
|
258
|
+
raise http_error(502, "sso.provider_verification_failed", lang)
|
|
260
259
|
email = normalize_email(payload.get("email") or payload.get("preferred_username") or payload.get("upn") or "")
|
|
261
260
|
if not email:
|
|
262
|
-
raise
|
|
261
|
+
raise http_error(400, "sso.email_unavailable", lang)
|
|
263
262
|
users = load_users()
|
|
264
263
|
if email not in users:
|
|
265
264
|
if invite_gate_enabled and not entry.invite_authorized:
|
|
266
|
-
raise
|
|
267
|
-
status_code=403,
|
|
268
|
-
detail="신규 SSO 계정에는 유효한 서명 초대 권한이 필요합니다.",
|
|
269
|
-
)
|
|
265
|
+
raise http_error(403, "sso.invitation_required", lang)
|
|
270
266
|
is_first = len(users) == 0
|
|
271
267
|
users[email] = {
|
|
272
268
|
"password": "",
|
|
@@ -280,7 +276,7 @@ def create_auth_router(
|
|
|
280
276
|
ensure_identity(email, users[email])
|
|
281
277
|
save_users(users)
|
|
282
278
|
if users[email].get("disabled"):
|
|
283
|
-
raise
|
|
279
|
+
raise http_error(403, "auth.account_disabled", lang)
|
|
284
280
|
token = create_session(email)
|
|
285
281
|
resp = RedirectResponse("/app", status_code=302)
|
|
286
282
|
resp.set_cookie(
|
|
@@ -309,33 +305,35 @@ def create_auth_router(
|
|
|
309
305
|
|
|
310
306
|
@router.post("/account/change-password")
|
|
311
307
|
async def change_password(req: ChangePasswordRequest, request: Request):
|
|
308
|
+
lang = resolve_language(request)
|
|
312
309
|
email = normalize_email(require_user(request))
|
|
313
310
|
if not email:
|
|
314
|
-
raise
|
|
315
|
-
_enforce_password_policy(req.new_password)
|
|
311
|
+
raise http_error(401, "auth.login_required", lang)
|
|
312
|
+
_enforce_password_policy(req.new_password, lang)
|
|
316
313
|
users = load_users()
|
|
317
314
|
user = users.get(email)
|
|
318
315
|
if not user:
|
|
319
|
-
raise
|
|
316
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
320
317
|
if not verify_and_migrate(email, req.current_password, user.get("password", ""), users):
|
|
321
|
-
raise
|
|
318
|
+
raise http_error(401, "auth.current_password_wrong", lang)
|
|
322
319
|
users[email]["password"] = hash_password(req.new_password)
|
|
323
320
|
save_users(users)
|
|
324
321
|
return {"status": "ok", "message": "비밀번호가 변경되었습니다."}
|
|
325
322
|
|
|
326
323
|
@router.patch("/account/profile")
|
|
327
324
|
async def update_profile(req: UpdateProfileRequest, request: Request):
|
|
325
|
+
lang = resolve_language(request)
|
|
328
326
|
email = normalize_email(require_user(request))
|
|
329
327
|
if not email:
|
|
330
|
-
raise
|
|
328
|
+
raise http_error(401, "auth.login_required", lang)
|
|
331
329
|
if req.name is not None and not req.name.strip():
|
|
332
|
-
raise
|
|
330
|
+
raise http_error(400, "auth.name_required", lang)
|
|
333
331
|
if req.nickname is not None and not req.nickname.strip():
|
|
334
|
-
raise
|
|
332
|
+
raise http_error(400, "auth.nickname_required", lang)
|
|
335
333
|
users = load_users()
|
|
336
334
|
user = users.get(email)
|
|
337
335
|
if not user:
|
|
338
|
-
raise
|
|
336
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
339
337
|
if req.name is not None:
|
|
340
338
|
users[email]["name"] = req.name.strip()
|
|
341
339
|
if req.nickname is not None:
|
|
@@ -345,15 +343,16 @@ def create_auth_router(
|
|
|
345
343
|
|
|
346
344
|
@router.get("/account/profile")
|
|
347
345
|
async def get_profile(request: Request):
|
|
346
|
+
lang = resolve_language(request)
|
|
348
347
|
email = normalize_email(require_user(request))
|
|
349
348
|
if not email:
|
|
350
349
|
if require_auth:
|
|
351
|
-
raise
|
|
350
|
+
raise http_error(401, "auth.login_required", lang)
|
|
352
351
|
return {"email": "", "name": "Local User", "nickname": "You", "role": "admin", "is_admin": True}
|
|
353
352
|
users = load_users()
|
|
354
353
|
user = users.get(email)
|
|
355
354
|
if not user:
|
|
356
|
-
raise
|
|
355
|
+
raise http_error(404, "auth.user_not_found", lang)
|
|
357
356
|
role = get_user_role(email, users)
|
|
358
357
|
return {"email": email, "name": user.get("name", ""), "nickname": user.get("nickname", ""),
|
|
359
358
|
"role": role, "is_admin": role == "admin"}
|
|
@@ -44,6 +44,65 @@ class AutomationRunNowRequest(BaseModel):
|
|
|
44
44
|
dry_run: bool = True
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _ensure_activity_run_source(run: Any) -> dict[str, Any]:
|
|
48
|
+
"""Guarantee the Act feed ``source`` field for 레시피/목표 badge split.
|
|
49
|
+
|
|
50
|
+
Capture 09 (``frontend/src/pages/Act.tsx``) distinguishes workflow vs agent
|
|
51
|
+
with ``run.source === "workflow" || Boolean(run.workflow_id)``. A store that
|
|
52
|
+
omits both collapses every row to "목표" and silently undoes the merged
|
|
53
|
+
timeline redesign. Prefer an explicit ``source``; otherwise infer from
|
|
54
|
+
``workflow_id`` / ``agent_id``.
|
|
55
|
+
"""
|
|
56
|
+
if not isinstance(run, dict):
|
|
57
|
+
return {"source": "agent", "id": None, "title": "", "status": ""}
|
|
58
|
+
row = dict(run)
|
|
59
|
+
source = str(row.get("source") or "").strip().lower()
|
|
60
|
+
if source in {"workflow", "agent"}:
|
|
61
|
+
row["source"] = source
|
|
62
|
+
return row
|
|
63
|
+
if row.get("workflow_id"):
|
|
64
|
+
row["source"] = "workflow"
|
|
65
|
+
return row
|
|
66
|
+
if row.get("agent_id"):
|
|
67
|
+
row["source"] = "agent"
|
|
68
|
+
return row
|
|
69
|
+
# Last resort: keep any non-empty legacy value, else agent.
|
|
70
|
+
row["source"] = source if source else "agent"
|
|
71
|
+
return row
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _with_run_truncation_meta(payload: Any, *, capped: int) -> dict[str, Any]:
|
|
75
|
+
"""Guarantee ``total`` / ``truncated`` on combined-run payloads.
|
|
76
|
+
|
|
77
|
+
Stores that already implement :meth:`list_combined_runs` may return only
|
|
78
|
+
``runs``. The Act feed needs to say how many rows are hidden when the
|
|
79
|
+
server slices agent+workflow history (default cap 20).
|
|
80
|
+
|
|
81
|
+
Every run is also passed through :func:`_ensure_activity_run_source` so the
|
|
82
|
+
레시피/목표 badge never loses its discriminator.
|
|
83
|
+
"""
|
|
84
|
+
if not isinstance(payload, dict):
|
|
85
|
+
return {"runs": [], "total": 0, "truncated": False}
|
|
86
|
+
runs = [_ensure_activity_run_source(run) for run in (payload.get("runs") or [])]
|
|
87
|
+
if "total" in payload:
|
|
88
|
+
try:
|
|
89
|
+
total = max(0, int(payload.get("total") or 0))
|
|
90
|
+
except (TypeError, ValueError):
|
|
91
|
+
total = len(runs)
|
|
92
|
+
else:
|
|
93
|
+
total = len(runs)
|
|
94
|
+
if "truncated" in payload:
|
|
95
|
+
truncated = bool(payload.get("truncated"))
|
|
96
|
+
else:
|
|
97
|
+
truncated = total > len(runs) or total > capped
|
|
98
|
+
# Prefer the larger of declared total and visible rows so a buggy store
|
|
99
|
+
# never claims fewer total than the list it just returned.
|
|
100
|
+
total = max(total, len(runs))
|
|
101
|
+
if total > len(runs):
|
|
102
|
+
truncated = True
|
|
103
|
+
return {**payload, "runs": runs, "total": total, "truncated": truncated}
|
|
104
|
+
|
|
105
|
+
|
|
47
106
|
def _run_now_wait_seconds() -> float:
|
|
48
107
|
raw = os.environ.get("LATTICEAI_AUTOMATION_RUN_NOW_WAIT", "30")
|
|
49
108
|
try:
|
|
@@ -283,6 +342,59 @@ def create_automation_intelligence_router(
|
|
|
283
342
|
payload["review_item_id"] = review_item.get("id")
|
|
284
343
|
return payload
|
|
285
344
|
|
|
345
|
+
def _combined_runs(request: Request, limit: int = 20) -> dict[str, Any]:
|
|
346
|
+
require_user(request)
|
|
347
|
+
scope = gate_read(request)
|
|
348
|
+
capped = max(1, min(int(limit or 20), 100))
|
|
349
|
+
if hasattr(store, "list_combined_runs"):
|
|
350
|
+
payload = store.list_combined_runs(limit=capped, workspace_id=scope)
|
|
351
|
+
return _with_run_truncation_meta(payload, capped=capped)
|
|
352
|
+
# Store without the helper: merge the two existing listings in place.
|
|
353
|
+
agent_listing = (
|
|
354
|
+
store.list_agents(workspace_id=scope)
|
|
355
|
+
if hasattr(store, "list_agents")
|
|
356
|
+
else {"runs": []}
|
|
357
|
+
)
|
|
358
|
+
# Pull a wide window so agent+workflow merge can still fill ``capped``
|
|
359
|
+
# after sort; true total is the unscoped merge size before slice.
|
|
360
|
+
workflow_listing = (
|
|
361
|
+
store.list_workflow_runs(limit=max(capped, 100), workspace_id=scope)
|
|
362
|
+
if hasattr(store, "list_workflow_runs")
|
|
363
|
+
else {"runs": []}
|
|
364
|
+
)
|
|
365
|
+
from latticeai.core.workspace_runs import WorkspaceRuns
|
|
366
|
+
|
|
367
|
+
rows = []
|
|
368
|
+
for run in agent_listing.get("runs") or []:
|
|
369
|
+
rows.append(
|
|
370
|
+
_ensure_activity_run_source(
|
|
371
|
+
WorkspaceRuns.activity_run_row(run, source="agent")
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
for run in workflow_listing.get("runs") or []:
|
|
375
|
+
rows.append(
|
|
376
|
+
_ensure_activity_run_source(
|
|
377
|
+
WorkspaceRuns.activity_run_row(run, source="workflow")
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
rows.sort(key=lambda row: str(row.get("started_at") or ""), reverse=True)
|
|
381
|
+
total = len(rows)
|
|
382
|
+
return {
|
|
383
|
+
"runs": rows[:capped],
|
|
384
|
+
"total": total,
|
|
385
|
+
"truncated": total > capped,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@router.get("/api/activity/runs")
|
|
389
|
+
async def activity_runs(request: Request, limit: int = 20):
|
|
390
|
+
"""Unified agent + workflow run timeline (layout rebuild screen 09)."""
|
|
391
|
+
return _combined_runs(request, limit=limit)
|
|
392
|
+
|
|
393
|
+
@router.get("/automations/runs/combined")
|
|
394
|
+
async def automations_runs_combined(request: Request, limit: int = 20):
|
|
395
|
+
"""Alias used by the frontend handoff for the same combined timeline."""
|
|
396
|
+
return _combined_runs(request, limit=limit)
|
|
397
|
+
|
|
286
398
|
return router
|
|
287
399
|
|
|
288
400
|
|
package/latticeai/api/browser.py
CHANGED
|
@@ -29,6 +29,7 @@ from pydantic import BaseModel
|
|
|
29
29
|
from lattice_brain.ingestion import IngestionItem, capture_quality_verdict
|
|
30
30
|
from latticeai import __version__
|
|
31
31
|
from latticeai.api.workspace_scope import resolve_workspace_scope
|
|
32
|
+
from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
|
|
32
33
|
from latticeai.core.quiet import quiet
|
|
33
34
|
|
|
34
35
|
MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
|
|
@@ -395,9 +396,9 @@ def create_browser_router(
|
|
|
395
396
|
router = APIRouter()
|
|
396
397
|
_fetch = fetch_url or _default_fetch_url
|
|
397
398
|
|
|
398
|
-
def _require_pipeline():
|
|
399
|
+
def _require_pipeline(language: str = DEFAULT_LANGUAGE):
|
|
399
400
|
if pipeline is None or not pipeline.available():
|
|
400
|
-
raise
|
|
401
|
+
raise http_error(503, "capture.ingestion_disabled", language)
|
|
401
402
|
|
|
402
403
|
def _write_workspace(request: Request, body_workspace: Optional[str], user: str) -> Optional[str]:
|
|
403
404
|
return resolve_workspace_scope(
|
|
@@ -412,7 +413,7 @@ def create_browser_router(
|
|
|
412
413
|
async def read_url(req: ReadUrlRequest, request: Request):
|
|
413
414
|
"""Fetch a public URL locally and ingest it as a web_url source."""
|
|
414
415
|
user = require_user(request)
|
|
415
|
-
_require_pipeline()
|
|
416
|
+
_require_pipeline(resolve_language(request))
|
|
416
417
|
workspace_id = _write_workspace(request, req.workspace_id, user)
|
|
417
418
|
url = _validate_http_url(req.url)
|
|
418
419
|
try:
|
|
@@ -443,7 +444,7 @@ def create_browser_router(
|
|
|
443
444
|
async def ingest_current_tab(req: IngestTabRequest, request: Request):
|
|
444
445
|
"""Ingest a payload captured from the local browser extension."""
|
|
445
446
|
user = require_user(request)
|
|
446
|
-
_require_pipeline()
|
|
447
|
+
_require_pipeline(resolve_language(request))
|
|
447
448
|
workspace_id = _write_workspace(request, req.workspace_id, user)
|
|
448
449
|
url = _validate_http_url(req.url)
|
|
449
450
|
# Bound the entire capture, not merely each field independently. The
|
|
@@ -462,14 +463,14 @@ def create_browser_router(
|
|
|
462
463
|
if value
|
|
463
464
|
)
|
|
464
465
|
if captured_bytes > max_tab_bytes:
|
|
465
|
-
raise
|
|
466
|
+
raise http_error(413, "capture.payload_too_large", resolve_language(request))
|
|
466
467
|
text = (req.text or "").strip()
|
|
467
468
|
if not text and req.html:
|
|
468
469
|
_title, text = extract_readable_text(req.html)
|
|
469
470
|
if not text:
|
|
470
471
|
text = (req.selected_text or "").strip()
|
|
471
472
|
if not text:
|
|
472
|
-
raise
|
|
473
|
+
raise http_error(400, "capture.nothing_to_capture", resolve_language(request))
|
|
473
474
|
res = pipeline.ingest(
|
|
474
475
|
IngestionItem(
|
|
475
476
|
source_type="browser_tab",
|
|
@@ -6,6 +6,7 @@ remains as a deprecation shim. Route paths and response shapes are frozen by
|
|
|
6
6
|
data endpoints back the v3 SPA (Files, Hybrid Search, graph explorer).
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
from datetime import datetime
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any, Callable, Dict, List, Optional
|
|
11
12
|
|
|
@@ -18,6 +19,7 @@ from latticeai.api.workspace_scope import (
|
|
|
18
19
|
resolve_workspace_scope,
|
|
19
20
|
workspace_scope_from_request,
|
|
20
21
|
)
|
|
22
|
+
from latticeai.core.quiet import quiet
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
class KnowledgeGraphIngestRequest(BaseModel):
|
|
@@ -71,6 +73,25 @@ def _format_context(matches: list, limit: int) -> str:
|
|
|
71
73
|
return "\n".join(lines)
|
|
72
74
|
|
|
73
75
|
|
|
76
|
+
def _pipeline_stage_view(*, count: int, pending: int) -> Dict[str, Any]:
|
|
77
|
+
"""Derive a coherent journey-ribbon stage from count + pending.
|
|
78
|
+
|
|
79
|
+
Invariants (enforced for layout-rebuild Capture screen 11):
|
|
80
|
+
- ``pending > 0`` → ``working``
|
|
81
|
+
- ``pending == 0`` and ``count > 0`` → ``done`` (never ``waiting``)
|
|
82
|
+
- ``pending == 0`` and ``count == 0`` → ``waiting`` (nothing arrived yet)
|
|
83
|
+
"""
|
|
84
|
+
safe_count = max(0, int(count))
|
|
85
|
+
safe_pending = max(0, int(pending))
|
|
86
|
+
if safe_pending > 0:
|
|
87
|
+
status = "working"
|
|
88
|
+
elif safe_count > 0:
|
|
89
|
+
status = "done"
|
|
90
|
+
else:
|
|
91
|
+
status = "waiting"
|
|
92
|
+
return {"count": safe_count, "pending": safe_pending, "status": status}
|
|
93
|
+
|
|
94
|
+
|
|
74
95
|
def create_knowledge_graph_router(
|
|
75
96
|
*,
|
|
76
97
|
get_graph: Callable[[], Any],
|
|
@@ -248,6 +269,118 @@ def create_knowledge_graph_router(
|
|
|
248
269
|
async def knowledge_graph_stats(request: Request):
|
|
249
270
|
return _scoped_stats(request)
|
|
250
271
|
|
|
272
|
+
@router.get("/knowledge-graph/pipeline/status")
|
|
273
|
+
async def knowledge_graph_pipeline_status(request: Request):
|
|
274
|
+
"""Per-stage counts + status for the Capture 3-step journey ribbon.
|
|
275
|
+
|
|
276
|
+
Aggregates existing store data only — no schema change. Top-level
|
|
277
|
+
``received`` / ``extracted`` / ``connected`` stay for simple clients;
|
|
278
|
+
``stages`` is the single source of truth for count, pending, and
|
|
279
|
+
status (``done`` / ``working`` / ``waiting``). Keys are omitted when
|
|
280
|
+
a value cannot be computed (never faked as 0 when the underlying
|
|
281
|
+
source is unavailable).
|
|
282
|
+
"""
|
|
283
|
+
kg, allowed = _scoped(request)
|
|
284
|
+
received: Optional[int] = None
|
|
285
|
+
extracted: Optional[int] = None
|
|
286
|
+
connected: Optional[int] = None
|
|
287
|
+
index_pending: Optional[int] = None
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
docs_payload = kg.list_documents(10_000)
|
|
291
|
+
documents = list(docs_payload.get("documents") or [])
|
|
292
|
+
if allowed is not None:
|
|
293
|
+
documents = _filter_scoped(kg, documents, allowed)
|
|
294
|
+
received = len(documents)
|
|
295
|
+
extracted = sum(
|
|
296
|
+
1
|
|
297
|
+
for doc in documents
|
|
298
|
+
if doc.get("indexed") or int(doc.get("chunks") or 0) > 0
|
|
299
|
+
)
|
|
300
|
+
except Exception:
|
|
301
|
+
quiet()
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
stats = _scoped_stats(request)
|
|
305
|
+
edges = stats.get("edges")
|
|
306
|
+
if isinstance(edges, dict):
|
|
307
|
+
connected = sum(int(value or 0) for value in edges.values())
|
|
308
|
+
elif isinstance(edges, (int, float)) and not isinstance(edges, bool):
|
|
309
|
+
connected = int(edges)
|
|
310
|
+
elif isinstance(stats.get("v2"), dict):
|
|
311
|
+
v2_edges = stats["v2"].get("edges")
|
|
312
|
+
if isinstance(v2_edges, (int, float)) and not isinstance(v2_edges, bool):
|
|
313
|
+
connected = int(v2_edges)
|
|
314
|
+
if received is None:
|
|
315
|
+
nodes = stats.get("nodes")
|
|
316
|
+
if isinstance(nodes, dict):
|
|
317
|
+
received = sum(
|
|
318
|
+
int(value or 0)
|
|
319
|
+
for key, value in nodes.items()
|
|
320
|
+
if str(key).lower() != "chunk"
|
|
321
|
+
)
|
|
322
|
+
elif isinstance(nodes, (int, float)) and not isinstance(nodes, bool):
|
|
323
|
+
received = int(nodes)
|
|
324
|
+
except Exception:
|
|
325
|
+
quiet()
|
|
326
|
+
|
|
327
|
+
index_fn = getattr(kg, "index_status", None)
|
|
328
|
+
if callable(index_fn):
|
|
329
|
+
try:
|
|
330
|
+
index = index_fn() or {}
|
|
331
|
+
if received is None and index.get("source_items") is not None:
|
|
332
|
+
received = int(index.get("source_items") or 0)
|
|
333
|
+
if extracted is None and index.get("ready_items") is not None:
|
|
334
|
+
extracted = int(index.get("ready_items") or 0)
|
|
335
|
+
if index.get("pending_items") is not None:
|
|
336
|
+
index_pending = max(0, int(index.get("pending_items") or 0))
|
|
337
|
+
elif index.get("pending") is not None:
|
|
338
|
+
index_pending = max(0, int(index.get("pending") or 0))
|
|
339
|
+
except Exception:
|
|
340
|
+
quiet()
|
|
341
|
+
|
|
342
|
+
payload: Dict[str, Any] = {}
|
|
343
|
+
if received is not None:
|
|
344
|
+
payload["received"] = max(0, int(received))
|
|
345
|
+
if extracted is not None:
|
|
346
|
+
payload["extracted"] = max(0, int(extracted))
|
|
347
|
+
if connected is not None:
|
|
348
|
+
payload["connected"] = max(0, int(connected))
|
|
349
|
+
|
|
350
|
+
if payload:
|
|
351
|
+
stages: Dict[str, Dict[str, Any]] = {}
|
|
352
|
+
if "received" in payload:
|
|
353
|
+
# Once listed as a document it has been received — no backlog.
|
|
354
|
+
stages["received"] = _pipeline_stage_view(
|
|
355
|
+
count=payload["received"], pending=0
|
|
356
|
+
)
|
|
357
|
+
if "extracted" in payload:
|
|
358
|
+
# Prefer vector-index backlog; fall back to docs not yet chunked.
|
|
359
|
+
if index_pending is not None:
|
|
360
|
+
extract_pending = index_pending
|
|
361
|
+
elif "received" in payload:
|
|
362
|
+
extract_pending = max(
|
|
363
|
+
0, int(payload["received"]) - int(payload["extracted"])
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
366
|
+
extract_pending = 0
|
|
367
|
+
stages["extracted"] = _pipeline_stage_view(
|
|
368
|
+
count=payload["extracted"], pending=extract_pending
|
|
369
|
+
)
|
|
370
|
+
if "connected" in payload:
|
|
371
|
+
# Connections are graph edges; backlog only when index reports
|
|
372
|
+
# pending items that still need linking (reuse extract backlog
|
|
373
|
+
# only when we have no better signal — 0 when graph is ready).
|
|
374
|
+
connect_pending = 0
|
|
375
|
+
if index_pending is not None and payload["connected"] == 0:
|
|
376
|
+
connect_pending = index_pending
|
|
377
|
+
stages["connected"] = _pipeline_stage_view(
|
|
378
|
+
count=payload["connected"], pending=connect_pending
|
|
379
|
+
)
|
|
380
|
+
payload["stages"] = stages
|
|
381
|
+
payload["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
|
382
|
+
return payload
|
|
383
|
+
|
|
251
384
|
@router.get("/knowledge-graph/schema")
|
|
252
385
|
async def knowledge_graph_schema(request: Request):
|
|
253
386
|
stats = _scoped_stats(request)
|
|
@@ -34,6 +34,30 @@ PRODUCTION_CSP = (
|
|
|
34
34
|
INVITE_COOKIE_NAME = "lattice_invite"
|
|
35
35
|
INVITE_COOKIE_TTL_SECONDS = 60 * 60 * 24 * 7
|
|
36
36
|
|
|
37
|
+
# Host-capacity plain-language judgment for System basic mode (layout rebuild).
|
|
38
|
+
# Thresholds live here so the frontend never re-derives copy from raw percents.
|
|
39
|
+
SYSINFO_READINESS_ROOMY_MAX = 55.0
|
|
40
|
+
SYSINFO_READINESS_TIGHT_MAX = 80.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def host_capacity_readiness(
|
|
44
|
+
*,
|
|
45
|
+
cpu_pct: float = 0.0,
|
|
46
|
+
ram_pct: float = 0.0,
|
|
47
|
+
gpu_mem_pct: float = 0.0,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Map host telemetry to a single plain-language readiness bucket.
|
|
50
|
+
|
|
51
|
+
Returns one of ``roomy`` / ``tight`` / ``low``. The product uses this so
|
|
52
|
+
basic-mode System copy and advanced-mode numbers share one judgment.
|
|
53
|
+
"""
|
|
54
|
+
load = max(float(cpu_pct or 0.0), float(ram_pct or 0.0), float(gpu_mem_pct or 0.0))
|
|
55
|
+
if load <= SYSINFO_READINESS_ROOMY_MAX:
|
|
56
|
+
return "roomy"
|
|
57
|
+
if load <= SYSINFO_READINESS_TIGHT_MAX:
|
|
58
|
+
return "tight"
|
|
59
|
+
return "low"
|
|
60
|
+
|
|
37
61
|
|
|
38
62
|
def _sign_invite_cookie(secret: str, *, now: Optional[int] = None) -> str:
|
|
39
63
|
"""Create an expiring, nonce-bearing invite-gate capability cookie."""
|
|
@@ -250,7 +274,11 @@ def create_static_routes_router(
|
|
|
250
274
|
|
|
251
275
|
@api_router.get("/local/sysinfo")
|
|
252
276
|
async def local_sysinfo(request: Request):
|
|
253
|
-
"""CPU / RAM / GPU(MLX)
|
|
277
|
+
"""CPU / RAM / GPU(MLX) usage plus a plain-language readiness bucket.
|
|
278
|
+
|
|
279
|
+
``readiness`` is ``roomy`` | ``tight`` | ``low`` so basic System copy
|
|
280
|
+
does not re-interpret raw percents on the client.
|
|
281
|
+
"""
|
|
254
282
|
require_user(request)
|
|
255
283
|
import re as _re
|
|
256
284
|
result: Dict[str, Any] = {
|
|
@@ -258,6 +286,7 @@ def create_static_routes_router(
|
|
|
258
286
|
"ram_pct": 0.0,
|
|
259
287
|
"gpu_mem_pct": 0.0,
|
|
260
288
|
"gpu_mem_gb": 0.0,
|
|
289
|
+
"readiness": "roomy",
|
|
261
290
|
}
|
|
262
291
|
try:
|
|
263
292
|
# CPU
|
|
@@ -291,6 +320,11 @@ def create_static_routes_router(
|
|
|
291
320
|
quiet()
|
|
292
321
|
except Exception as e:
|
|
293
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
|
+
)
|
|
294
328
|
return result
|
|
295
329
|
|
|
296
330
|
return StaticRoutesBundle(
|