ltcai 10.6.2 → 10.7.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.
Files changed (85) hide show
  1. package/README.md +44 -59
  2. package/docs/CHANGELOG.md +56 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +29 -1
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/runtime/multi_agent.py +1 -1
  13. package/latticeai/__init__.py +1 -1
  14. package/latticeai/api/admin.py +72 -0
  15. package/latticeai/api/automation_intelligence.py +112 -0
  16. package/latticeai/api/knowledge_graph.py +133 -0
  17. package/latticeai/api/static_routes.py +35 -1
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/workspace_os.py +3 -0
  21. package/latticeai/core/workspace_os_constants.py +1 -1
  22. package/latticeai/core/workspace_runs.py +111 -0
  23. package/latticeai/services/architecture_readiness.py +1 -1
  24. package/latticeai/services/product_readiness.py +1 -1
  25. package/package.json +5 -3
  26. package/scripts/capture_release_evidence.mjs +335 -1
  27. package/scripts/check_current_release_docs.mjs +1 -1
  28. package/scripts/check_release_evidence_bound.mjs +116 -0
  29. package/scripts/check_screenshot_pixel_delta.py +435 -0
  30. package/scripts/measure_brain_home_fill.mjs +141 -0
  31. package/src-tauri/Cargo.lock +1 -1
  32. package/src-tauri/Cargo.toml +1 -1
  33. package/src-tauri/tauri.conf.json +1 -1
  34. package/static/app/asset-manifest.json +37 -39
  35. package/static/app/assets/{Act-aNud-lKL.js → Act-DIbkoJqs.js} +1 -1
  36. package/static/app/assets/AdminConsole-Cbi6Jre6.js +1 -0
  37. package/static/app/assets/{Brain-CMFh5q6k.js → Brain-BPfuXZ71.js} +2 -2
  38. package/static/app/assets/BrainHome-87XaAS4V.js +2 -0
  39. package/static/app/assets/{BrainSignals-RVMlmTGC.js → BrainSignals-ExpcdiUi.js} +1 -1
  40. package/static/app/assets/Capture-Bxe4WiVX.js +1 -0
  41. package/static/app/assets/{CommandPalette-bW5WxWWZ.js → CommandPalette-B1PV5Yd4.js} +1 -1
  42. package/static/app/assets/Library-BRlJJmwZ.js +1 -0
  43. package/static/app/assets/{LivingBrain-FlHFfu9i.js → LivingBrain-D61qG3WY.js} +1 -1
  44. package/static/app/assets/ProductFlow-BxjxI8Aa.js +1 -0
  45. package/static/app/assets/{ReviewCard-BHj86h2Z.js → ReviewCard-BoiWv0Y1.js} +2 -2
  46. package/static/app/assets/System-Ci0HEtTJ.js +1 -0
  47. package/static/app/assets/arrow-left-DArdBUxd.js +1 -0
  48. package/static/app/assets/{bot-D1gX4xks.js → bot-BaPt4PPn.js} +1 -1
  49. package/static/app/assets/brain-BSkWaj3i.js +1 -0
  50. package/static/app/assets/{button-DPFZ9lGw.js → button-CKwtO1O0.js} +1 -1
  51. package/static/app/assets/{circle-pause-B2P72YOO.js → circle-pause-CiUc5RRk.js} +1 -1
  52. package/static/app/assets/{circle-play-BiC8z2vg.js → circle-play-BaTDxzR9.js} +1 -1
  53. package/static/app/assets/{cpu-Cx-wRR_V.js → cpu-Dm1gHuP1.js} +1 -1
  54. package/static/app/assets/{download-CKxlzxsK.js → download-C32oGU-C.js} +1 -1
  55. package/static/app/assets/{folder-open-Bbjju0tt.js → folder-open-Ch2jLVdD.js} +1 -1
  56. package/static/app/assets/{hard-drive-Bon1VBvq.js → hard-drive-BBjA5st8.js} +1 -1
  57. package/static/app/assets/index-BV7Yf6Eq.css +2 -0
  58. package/static/app/assets/{index-mLP0-YNO.js → index-J6h01X78.js} +4 -4
  59. package/static/app/assets/{input-DrMc0Xns.js → input-y6ABdKWY.js} +1 -1
  60. package/static/app/assets/{permissionCopy-BGUsI7vw.js → permissionCopy-BEtc0Ihd.js} +1 -1
  61. package/static/app/assets/{primitives-CEMTjBz1.js → primitives-GSFXF2Pd.js} +1 -1
  62. package/static/app/assets/search-CaIZKyo_.js +1 -0
  63. package/static/app/assets/{share-2-D7THHq5K.js → share-2-BgGq2A63.js} +1 -1
  64. package/static/app/assets/{shield-alert-BoU4_8r9.js → shield-alert-BYA491cj.js} +1 -1
  65. package/static/app/assets/{textarea-rU1Lb7ia.js → textarea-BAcim3fs.js} +1 -1
  66. package/static/app/assets/{useFocusTrap-BgvK4Nkx.js → useFocusTrap-qLg24jiv.js} +1 -1
  67. package/static/app/assets/{useQuery-CT2ChyuU.js → useQuery-xkAu9eDX.js} +1 -1
  68. package/static/app/assets/utils-CogdofAA.js +4 -0
  69. package/static/app/assets/workspace-BMvfx9_R.js +1 -0
  70. package/static/app/index.html +4 -4
  71. package/static/sw.js +1 -1
  72. package/static/app/assets/AdminConsole-QiCTH68K.js +0 -1
  73. package/static/app/assets/BrainHome-B4Ar3atu.js +0 -2
  74. package/static/app/assets/Capture-DEBo-0vZ.js +0 -1
  75. package/static/app/assets/Library-5gFexm83.js +0 -1
  76. package/static/app/assets/ProductFlow-CFUNDOHu.js +0 -1
  77. package/static/app/assets/System-CeNHoZRu.js +0 -1
  78. package/static/app/assets/activity-7_ZmZqN0.js +0 -1
  79. package/static/app/assets/arrow-left-Dv2Tiwhe.js +0 -1
  80. package/static/app/assets/brain-Dn_bDfl4.js +0 -1
  81. package/static/app/assets/index-DYUs0cWy.css +0 -2
  82. package/static/app/assets/search-HsIji1wY.js +0 -1
  83. package/static/app/assets/users-DlbfHBQV.js +0 -1
  84. package/static/app/assets/utils-fEGWreKB.js +0 -7
  85. package/static/app/assets/workspace-ClDBz_0f.js +0 -1
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Onboarding
2
2
 
3
- Current release: **10.6.2 — Ask First**.
3
+ Current release: **10.7.0 — Plain Surface**.
4
4
 
5
5
  The first-run goal is a five-minute path from "I opened the app" to "my Brain
6
6
  has a source, a question, and proof." This page is the product contract behind
@@ -1,4 +1,4 @@
1
- # Lattice AI — Operations Guide (v10.6.2)
1
+ # Lattice AI — Operations Guide (v10.7.0)
2
2
 
3
3
  > **Status: canonical** — kept in sync with the current release. Storage layout
4
4
  > below reflects the SQLite live Brain store and workspace scoping, not the
@@ -162,3 +162,31 @@ Ctrl+C 또는 kill <pid>
162
162
  ```
163
163
 
164
164
  서버는 종료 시 진행 중인 HTTP 요청을 완료한 후 종료됩니다 (graceful shutdown — uvicorn 기본 동작).
165
+
166
+ ## 10. 릴리스 증거 캡처 순서 (레이아웃·UI 변경 후)
167
+
168
+ `scripts/capture_release_evidence.mjs` 의 `assertNotOverwritingTaggedRelease()` 는
169
+ `package.json` 버전과 동일한 git 태그(`vX.Y.Z`)가 이미 있으면 **즉시 exit 1** 한다.
170
+ 레이아웃/UI를 바꾼 뒤 스크린샷을 다시 찍을 때는 반드시 아래 순서를 지킨다.
171
+
172
+ 1. **버전 범프 먼저** — `node scripts/run_python.mjs scripts/bump_version.py`
173
+ (또는 동등한 package/pyproject/extension 버전 일괄 상향). 캡처 **전에** 한다.
174
+ 2. `npm run build:assets` — 캡처는 빌드된 `static/app` 을 본다.
175
+ 3. `npm run release:evidence` — `output/release/v<새버전>/` 에만 기록한다.
176
+ 캡처 → 오래된 버전 정리 → **픽셀 델타 검사**까지 한 번에 돈다.
177
+ 4. 결속 검사 — `SCREENSHOT_INDEX.md` 의 `asset-manifest.sha256` 과
178
+ `mock-server.sha256` 이 각각 `static/app/asset-manifest.json`,
179
+ `tests/visual/mock_server.cjs` 와 일치해야 `npm run lint` 가 통과한다.
180
+
181
+ 레이아웃을 바꿨다고 보고했는데 배치는 그대로인 경우를 잡기 위해 v10.7.0 부터
182
+ `scripts/check_screenshot_pixel_delta.py` 가 12개 캡처를 직전 버전과 대조한다
183
+ (`npm run check:screenshot-delta` 로 단독 실행). 정적 화면은 1.5%, 애니메이션이
184
+ 있는 화면은 8% 이상 픽셀이 달라져야 통과한다. 문구만 고친 화면은 여기서 실패로
185
+ 잡히므로, 실패했다면 캡처가 아니라 레이아웃을 다시 본다. 직전 버전 디렉터리가
186
+ 정리되어 없으면 기준선을 꺼낼 태그를 체크아웃한 뒤 다시 돌린다.
187
+
188
+ 이미 태그가 찍힌 버전의 `output/release/vX.Y.Z/` 를 WIP UI로 덮으면 그 릴리스
189
+ 증거가 영구히 거짓이 된다. 미리보기만 필요하면
190
+ `LTCAI_RELEASE_EVIDENCE_DIR=/tmp/lattice-preview npm run release:evidence` 로
191
+ 게시 경로 밖을 쓴다. 태그된 버전의 원본 증거가 오염됐다면 태그 시점 트리에서
192
+ 해당 디렉터리를 복원한 뒤, 새 버전 디렉터리에 다시 캡처한다.
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Trust Model
2
2
 
3
- Current release: **10.6.2 — Ask First**.
3
+ Current release: **10.7.0 — Plain Surface**.
4
4
 
5
5
  Lattice AI is local-first, explicit about external communication, and honest
6
6
  when a capability is unavailable.
@@ -1,6 +1,6 @@
1
1
  # Why Lattice AI Exists
2
2
 
3
- Current release: **10.6.2 — Ask First**.
3
+ Current release: **10.7.0 — Plain Surface**.
4
4
 
5
5
  **Lattice AI is a local-first Digital Brain that keeps your knowledge durable
6
6
  across any AI model.**
package/docs/kg-schema.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Knowledge Graph Schema
2
2
 
3
- Current release: **10.6.2 — Ask First**.
3
+ Current release: **10.7.0 — Plain Surface**.
4
4
 
5
5
  명세 출처: `lattice_ai_full_spec.pptx` 슬라이드 20·21·22
6
6
  구현: `lattice_brain/graph/schema.py`
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "10.6.2"
29
+ __version__ = "10.7.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -47,7 +47,7 @@ from typing import Any, Callable, Dict, List, Optional
47
47
  from ..utils import now_iso as _now
48
48
  from .contracts import multi_agent_contract
49
49
 
50
- MULTI_AGENT_VERSION = "10.6.2"
50
+ MULTI_AGENT_VERSION = "10.7.0"
51
51
 
52
52
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
53
53
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "10.6.2"
3
+ __version__ = "10.7.0"
@@ -159,6 +159,78 @@ def create_admin_router(
159
159
  "last_message_at": history[-1].get("timestamp") if history else None,
160
160
  }
161
161
 
162
+ @router.get("/admin/health-summary")
163
+ async def admin_health_summary(request: Request):
164
+ """One-line admin health for the calm console header (layout rebuild).
165
+
166
+ Aggregates existing admin surfaces — no new persistence. ``status`` is
167
+ ``attention`` when any issue is present, else ``ok``.
168
+ """
169
+ _, users = require_admin(request)
170
+ issues: List[Dict[str, Any]] = []
171
+
172
+ disabled = sum(1 for user in users.values() if user.get("disabled"))
173
+ if disabled:
174
+ issues.append({
175
+ "area": "users",
176
+ "severity": "warning",
177
+ "message": f"{disabled} disabled user(s)",
178
+ })
179
+
180
+ try:
181
+ report = build_sensitivity_report(_scoped_history(request)) or {}
182
+ severity = ((report.get("summary") or {}).get("severity_counts") or {})
183
+ high = int(severity.get("high") or 0)
184
+ if high:
185
+ issues.append({
186
+ "area": "security",
187
+ "severity": "high",
188
+ "message": f"{high} high-risk event(s)",
189
+ })
190
+ except Exception as exc:
191
+ logging.warning("admin health-summary sensitivity failed: %s", exc)
192
+
193
+ if enable_graph:
194
+ try:
195
+ stats = get_graph_stats() or {}
196
+ if isinstance(stats, dict) and stats.get("error"):
197
+ issues.append({
198
+ "area": "brain_ops",
199
+ "severity": "warning",
200
+ "message": "Knowledge graph unavailable",
201
+ })
202
+ except Exception as exc:
203
+ issues.append({
204
+ "area": "brain_ops",
205
+ "severity": "warning",
206
+ "message": str(exc)[:160],
207
+ })
208
+
209
+ if product_hardening_status is not None:
210
+ try:
211
+ res = product_hardening_status()
212
+ hardening = res if isinstance(res, dict) else {}
213
+ startup = hardening.get("startup") if isinstance(hardening, dict) else {} # type: ignore[union-attr]
214
+ if isinstance(startup, dict) and startup.get("network_exposed"):
215
+ issues.append({
216
+ "area": "runtime_trust",
217
+ "severity": "warning",
218
+ "message": "Server is network-exposed",
219
+ })
220
+ except Exception as exc:
221
+ logging.warning("admin health-summary hardening failed: %s", exc)
222
+
223
+ # issue_count is a first-class field (not client-derived from issues[]).
224
+ # AdminConsole reads healthData.issue_count for the header sentence;
225
+ # falling back to || 1 when the field is missing misreports multi-issue
226
+ # states as a single problem.
227
+ issue_count = len(issues)
228
+ return {
229
+ "status": "attention" if issue_count else "ok",
230
+ "issue_count": issue_count,
231
+ "issues": issues,
232
+ }
233
+
162
234
  @router.get("/admin/stats")
163
235
  async def admin_stats(request: Request):
164
236
  require_admin(request)
@@ -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
 
@@ -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(
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "10.6.2"
16
+ LEGACY_COMPATIBILITY_VERSION = "10.7.0"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -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.6.2"
13
+ MARKETPLACE_VERSION = "10.7.0"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -883,6 +883,9 @@ class WorkspaceOSStore:
883
883
  def list_workflow_runs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
884
884
  return self.runs.list_workflow_runs(*args, **kwargs)
885
885
 
886
+ def list_combined_runs(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
887
+ return self.runs.list_combined_runs(*args, **kwargs)
888
+
886
889
  def mark_workflow_run_resolved(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
887
890
  return self.runs.mark_workflow_run_resolved(*args, **kwargs)
888
891
 
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Dict
12
12
 
13
- WORKSPACE_OS_VERSION = "10.6.2"
13
+ WORKSPACE_OS_VERSION = "10.7.0"
14
14
 
15
15
  # Workspace types separate single-user Personal workspaces from shared
16
16
  # Organization workspaces. Both keep the same local-first JSON store; the type