ltcai 9.9.4 → 9.9.5

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 (64) hide show
  1. package/README.md +38 -42
  2. package/docs/CHANGELOG.md +35 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +10 -4
  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/graph/rerank.py +160 -0
  13. package/lattice_brain/graph/retrieval.py +28 -442
  14. package/lattice_brain/graph/retrieval_reads.py +459 -0
  15. package/lattice_brain/graph/store.py +2 -0
  16. package/lattice_brain/quality.py +26 -6
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/chat_agent_http.py +87 -46
  20. package/latticeai/api/chat_contracts.py +1 -1
  21. package/latticeai/core/agent.py +156 -18
  22. package/latticeai/core/legacy_compatibility.py +1 -1
  23. package/latticeai/core/marketplace.py +1 -1
  24. package/latticeai/core/run_store.py +2 -0
  25. package/latticeai/core/workspace_os.py +1 -1
  26. package/latticeai/integrations/telegram_bot.py +82 -23
  27. package/latticeai/services/architecture_readiness.py +1 -1
  28. package/latticeai/services/product_readiness.py +1 -1
  29. package/latticeai/services/tool_dispatch.py +56 -0
  30. package/package.json +2 -1
  31. package/scripts/check_current_release_docs.mjs +1 -1
  32. package/scripts/run_sidecar_e2e.mjs +159 -0
  33. package/src-tauri/Cargo.lock +1 -1
  34. package/src-tauri/Cargo.toml +1 -1
  35. package/src-tauri/tauri.conf.json +1 -1
  36. package/static/app/asset-manifest.json +28 -28
  37. package/static/app/assets/{Act-BCmTU0E2.js → Act-DmxfbqBp.js} +1 -1
  38. package/static/app/assets/{Brain-CTnjox7w.js → Brain-DK_zKCda.js} +1 -1
  39. package/static/app/assets/{BrainHome-BJ3sFNX0.js → BrainHome-CCE_0hni.js} +1 -1
  40. package/static/app/assets/{BrainSignals-C52lwZVD.js → BrainSignals-r_J68_zh.js} +1 -1
  41. package/static/app/assets/{Capture-B6vBhFa3.js → Capture-CvxwzpJZ.js} +1 -1
  42. package/static/app/assets/{CommandPalette-90u9FWFH.js → CommandPalette-CVLiFIQC.js} +1 -1
  43. package/static/app/assets/{Library-pKCK0_tk.js → Library-4As9VUvy.js} +1 -1
  44. package/static/app/assets/{LivingBrain-Jlf2wFqI.js → LivingBrain-BVWk58MW.js} +1 -1
  45. package/static/app/assets/{ProductFlow-yg1fKP1P.js → ProductFlow-B-Tz0Um4.js} +1 -1
  46. package/static/app/assets/{ReviewCard-DWvD7n9h.js → ReviewCard-Va3fPQuj.js} +1 -1
  47. package/static/app/assets/{System-BAEuHqNY.js → System-DSR2zKWd.js} +1 -1
  48. package/static/app/assets/{bot-nB_buEZD.js → bot-VmP0kkeA.js} +1 -1
  49. package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause-T9POo4qy.js} +1 -1
  50. package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-Bz5iTD0p.js} +1 -1
  51. package/static/app/assets/{cpu-CEPBHaBl.js → cpu-CJhjRtNq.js} +1 -1
  52. package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-B2K_22VI.js} +1 -1
  53. package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-CjJtYqHf.js} +1 -1
  54. package/static/app/assets/{index-DrmOCySv.js → index-CEu0Wqjl.js} +3 -3
  55. package/static/app/assets/{input-gtVCg-ll.js → input-CSi6OBJ9.js} +1 -1
  56. package/static/app/assets/{navigation-Bot0hvuv.js → navigation-BPQqEQwr.js} +1 -1
  57. package/static/app/assets/{network-jE42eKfT.js → network-Ck1nqc-v.js} +1 -1
  58. package/static/app/assets/{primitives-CX2Komon.js → primitives-B2f9N7Dh.js} +1 -1
  59. package/static/app/assets/{shield-alert-BftATuAA.js → shield-alert-yA_Y8lOB.js} +1 -1
  60. package/static/app/assets/{textarea-CiMJfOSI.js → textarea-C4wM0D-Z.js} +1 -1
  61. package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  62. package/static/app/assets/{utils-SJUNVOj5.js → utils-BqoznzGP.js} +1 -1
  63. package/static/app/index.html +3 -3
  64. package/static/sw.js +1 -1
@@ -40,7 +40,8 @@ UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
40
40
  AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
41
41
  AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
42
42
 
43
- # Pending plan approvals: context_id (chat_id, executing_model, reviewing_model)
43
+ # Pending plan approvals keyed by run_id (or legacy context_id).
44
+ # Values: chat_id, models, and the resume credentials (token and/or legacy).
44
45
  _bot_pending_plans: dict[str, dict] = {}
45
46
  MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
46
47
  INVITE_CODE = env_value("LATTICEAI_INVITE_CODE")
@@ -711,35 +712,65 @@ async def send_generated_files(client, chat_id, generated_files):
711
712
 
712
713
  # ── Plan approval (Human-in-the-loop) ────────────────────────────────────────
713
714
 
715
+ def _approval_pause_id(data: dict) -> str:
716
+ """Stable key for a paused plan: run_id preferred, legacy context_id fallback."""
717
+ return str(data.get("run_id") or data.get("context_id") or "")
718
+
719
+
720
+ def _is_approval_pause(data: dict) -> bool:
721
+ status = str(data.get("status") or "")
722
+ return status in {"waiting_approval", "awaiting_approval"}
723
+
724
+
714
725
  async def send_plan_for_approval(client, chat_id, data: dict) -> None:
715
- """Show the agent plan to the user and present Done/Cancel buttons."""
716
- context_id = data.get("context_id", "")
717
- plan = data.get("plan", {})
718
- goal = plan.get("goal", "")
719
- steps = plan.get("steps", [])
726
+ """Show the agent plan to the user and present Done/Cancel buttons.
727
+
728
+ Handles both the modern ``awaiting_approval`` (run_id + token) and the
729
+ legacy ``waiting_approval`` / ``context_id`` wire contracts (v9.9.5
730
+ SURFACE_PARITY Telegram approval flow).
731
+ """
732
+ pause_id = _approval_pause_id(data)
733
+ if not pause_id:
734
+ await send_message(client, chat_id, "❌ 승인할 계획을 식별할 수 없습니다.")
735
+ return
736
+ plan = data.get("plan", {}) or {}
737
+ goal = plan.get("goal") or (data.get("approval") or {}).get("plan_summary") or ""
738
+ steps = plan.get("steps", []) or data.get("non_auto_steps") or []
720
739
  p_model = data.get("planning_model", "current")
721
740
  e_model = data.get("executing_model", "current")
722
741
  r_model = data.get("reviewing_model", "current")
742
+ approval = data.get("approval") or {}
743
+ expires_at = approval.get("expires_at")
723
744
 
724
745
  lines = ["📋 *플래닝 완료* — 실행 전 확인해주세요\n"]
725
746
  if goal:
726
747
  lines.append(f"*목표:* {goal}\n")
727
748
  for i, step in enumerate(steps, 1):
728
- desc = step.get("description") or step.get("action") or str(step)
749
+ if isinstance(step, dict):
750
+ desc = step.get("description") or step.get("action") or str(step)
751
+ else:
752
+ desc = str(step)
729
753
  lines.append(f"{i}. {desc}")
730
754
  lines.append(f"\n🧠 플래닝: `{p_model}`")
731
755
  lines.append(f"⚙️ 실행: `{e_model}`")
732
756
  lines.append(f"🔍 검토: `{r_model}`")
757
+ if expires_at:
758
+ lines.append(f"⏳ 승인 만료: `{expires_at}`")
733
759
 
734
- _bot_pending_plans[context_id] = {
760
+ _bot_pending_plans[pause_id] = {
735
761
  "chat_id": chat_id,
762
+ "run_id": data.get("run_id") or pause_id,
763
+ "context_id": data.get("context_id"),
764
+ "approval_token": approval.get("token"),
765
+ "legacy": data.get("status") == "waiting_approval" or bool(data.get("context_id")),
736
766
  "executing_model": data.get("executing_model"),
737
767
  "reviewing_model": data.get("reviewing_model"),
738
768
  }
739
769
 
770
+ # callback_data max 64 bytes — pause ids are token_urlsafe(16) (~22 chars).
740
771
  keyboard = {"inline_keyboard": [[
741
- {"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{context_id}"},
742
- {"text": "❌ 취소", "callback_data": f"plan:cancel:{context_id}"},
772
+ {"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{pause_id}"},
773
+ {"text": "❌ 취소", "callback_data": f"plan:cancel:{pause_id}"},
743
774
  ]]}
744
775
  await send_message(client, chat_id, "\n".join(lines), reply_markup=keyboard)
745
776
 
@@ -749,8 +780,8 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
749
780
  parts = data.split(":", 2)
750
781
  if len(parts) != 3:
751
782
  return
752
- _, action, context_id = parts
753
- pending = _bot_pending_plans.get(context_id)
783
+ _, action, pause_id = parts
784
+ pending = _bot_pending_plans.get(pause_id)
754
785
 
755
786
  # A callback is bound to the chat that received the plan. Even two allowed
756
787
  # chats cannot approve or cancel each other's plan by replaying callback
@@ -758,9 +789,19 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
758
789
  if pending and int(pending.get("chat_id")) != int(chat_id):
759
790
  await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
760
791
  return
761
- pending = _bot_pending_plans.pop(context_id, None)
792
+ pending = _bot_pending_plans.pop(pause_id, None)
762
793
 
763
- if action == "cancel" or not pending:
794
+ if action == "cancel":
795
+ if pending:
796
+ try:
797
+ resume_body = _resume_payload(pending, approved=False)
798
+ async with _server_client() as sc:
799
+ await sc.post(AGENT_RESUME_URL, json=resume_body, timeout=30.0)
800
+ except Exception as exc: # noqa: BLE001 — cancel is best-effort
801
+ logger.warning("telegram cancel resume failed: %s", safe_log_text(exc))
802
+ await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
803
+ return
804
+ if not pending:
764
805
  await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
765
806
  return
766
807
 
@@ -769,12 +810,11 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
769
810
 
770
811
  try:
771
812
  async with _server_client() as sc:
772
- res = await sc.post(AGENT_RESUME_URL, json={
773
- "context_id": context_id,
774
- "approved": True,
775
- "executing_model": pending.get("executing_model"),
776
- "reviewing_model": pending.get("reviewing_model"),
777
- }, timeout=300.0)
813
+ res = await sc.post(
814
+ AGENT_RESUME_URL,
815
+ json=_resume_payload(pending, approved=True),
816
+ timeout=300.0,
817
+ )
778
818
  data_r = res.json() if res.status_code == 200 else {}
779
819
  ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
780
820
  await send_message(client, chat_id, str(ans))
@@ -785,6 +825,25 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
785
825
  await send_message(client, chat_id, f"❌ 실행 중 오류: {e}")
786
826
 
787
827
 
828
+ def _resume_payload(pending: dict, *, approved: bool) -> dict:
829
+ """Build /agent/resume body: token path preferred, else legacy context_id."""
830
+ body: dict = {
831
+ "approved": approved,
832
+ "executing_model": pending.get("executing_model"),
833
+ "reviewing_model": pending.get("reviewing_model"),
834
+ }
835
+ token = pending.get("approval_token")
836
+ run_id = pending.get("run_id")
837
+ # Unified durable store (9.9.5): token-gated resume works for both the
838
+ # modern awaiting_approval and the legacy human_in_loop pause.
839
+ if token and run_id:
840
+ body["run_id"] = run_id
841
+ body["approval_token"] = token
842
+ return body
843
+ body["context_id"] = pending.get("context_id") or run_id
844
+ return body
845
+
846
+
788
847
  # ── AI request task ───────────────────────────────────────────────────────────
789
848
 
790
849
  async def process_ai_request(client, chat_id, user_text, image_data=None):
@@ -794,8 +853,8 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
794
853
  data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
795
854
  logger.info("ask_ai 완료: chat_id=%s result_keys=%s", chat_id, list(data.keys()) if isinstance(data, dict) else type(data))
796
855
 
797
- # Human-in-the-loop: show plan and wait for approval
798
- if isinstance(data, dict) and data.get("status") == "waiting_approval":
856
+ # Approval pause (legacy waiting_approval or modern awaiting_approval)
857
+ if isinstance(data, dict) and _is_approval_pause(data):
799
858
  await send_plan_for_approval(client, chat_id, data)
800
859
  return
801
860
 
@@ -887,7 +946,7 @@ async def handle_command(client, chat_id, command: str, args: str):
887
946
  await send_chat_action(client, chat_id, "typing")
888
947
  data = await ask_ai(client, task_text, agent_mode=True,
889
948
  executing_model=exec_model, reviewing_model=reviewing_model)
890
- if isinstance(data, dict) and data.get("status") == "waiting_approval":
949
+ if isinstance(data, dict) and _is_approval_pause(data):
891
950
  await send_plan_for_approval(client, chat_id, data)
892
951
  else:
893
952
  ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.9.4"
20
+ ARCHITECTURE_VERSION_TARGET = "9.9.5"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.9.4"
21
+ PRODUCT_VERSION_TARGET = "9.9.5"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -220,6 +220,60 @@ class ToolDispatchService:
220
220
  )
221
221
  return {"path": path, "ok": r.returncode == 0, "stderr": r.stderr[:200]}
222
222
 
223
+ # ── snapshot rollback ports (review L7) ──────────────────────────────
224
+ # Pre-write capture + restore for workspaces where git rollback does not
225
+ # apply (untracked new files, no git). Strictly confined to AGENT_ROOT.
226
+
227
+ _SNAPSHOT_MAX_BYTES = 512 * 1024
228
+
229
+ def _workspace_path(self, path: str) -> Optional[Path]:
230
+ raw = Path(path)
231
+ candidate = raw if raw.is_absolute() else AGENT_ROOT / raw
232
+ try:
233
+ resolved = candidate.resolve()
234
+ root = AGENT_ROOT.resolve()
235
+ except OSError:
236
+ return None
237
+ if resolved != root and root not in resolved.parents:
238
+ return None
239
+ return resolved
240
+
241
+ def snapshot_file(self, path: str) -> Dict[str, Any]:
242
+ """Pre-write state of a workspace file: existence + bounded content."""
243
+ target = self._workspace_path(path)
244
+ if target is None:
245
+ return {"existed": False, "content": None, "too_large": False,
246
+ "error": "path escapes the agent workspace"}
247
+ if not target.exists() or not target.is_file():
248
+ return {"existed": False, "content": None, "too_large": False}
249
+ try:
250
+ if target.stat().st_size > self._SNAPSHOT_MAX_BYTES:
251
+ return {"existed": True, "content": None, "too_large": True}
252
+ return {
253
+ "existed": True,
254
+ "content": target.read_text(encoding="utf-8", errors="replace"),
255
+ "too_large": False,
256
+ }
257
+ except OSError as exc:
258
+ return {"existed": True, "content": None, "too_large": True,
259
+ "error": str(exc)}
260
+
261
+ def restore_snapshot(self, path: str, content: Optional[str]) -> Dict[str, Any]:
262
+ """Restore a pre-write snapshot: rewrite prior content, or delete a
263
+ file the run created (``content=None``)."""
264
+ target = self._workspace_path(path)
265
+ if target is None:
266
+ return {"path": path, "ok": False, "error": "path escapes the agent workspace"}
267
+ try:
268
+ if content is None:
269
+ target.unlink(missing_ok=True)
270
+ return {"path": path, "ok": True, "action": "deleted"}
271
+ target.parent.mkdir(parents=True, exist_ok=True)
272
+ target.write_text(content, encoding="utf-8")
273
+ return {"path": path, "ok": True, "action": "restored"}
274
+ except OSError as exc:
275
+ return {"path": path, "ok": False, "error": str(exc)}
276
+
223
277
 
224
278
  DEFAULT_TOOL_DISPATCH_SERVICE = ToolDispatchService()
225
279
 
@@ -375,6 +429,8 @@ def build_agent_runtime(
375
429
  memory_updater_prompt=MEMORY_UPDATER_PROMPT,
376
430
  agent_root=AGENT_ROOT,
377
431
  rollback_file=dispatch_service.rollback_file,
432
+ snapshot_file=dispatch_service.snapshot_file,
433
+ restore_snapshot=dispatch_service.restore_snapshot,
378
434
  hooks=hooks,
379
435
  brain_memory=brain_memory,
380
436
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "9.9.4",
3
+ "version": "9.9.5",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -41,6 +41,7 @@
41
41
  "test:integration": "node scripts/run_integration_tests.mjs",
42
42
  "test:browser-extension": "node --check browser-extension/popup.js && node --check browser-extension/capture-page.js && node --test tests/browser-extension.test.cjs",
43
43
  "test:visual": "playwright test",
44
+ "test:e2e:sidecar": "node scripts/run_sidecar_e2e.mjs",
44
45
  "vercel:build": "node scripts/build_vercel_static.mjs",
45
46
  "desktop:tauri": "tauri dev",
46
47
  "desktop:tauri:build": "tauri build",
@@ -6,7 +6,7 @@ const root = process.cwd();
6
6
  const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
7
7
  const version = pkg.version;
8
8
  const releaseDir = `output/release/v${version}`;
9
- const releaseTheme = "Durable Loops";
9
+ const releaseTheme = "Closed Gaps";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Start an isolated Lattice sidecar and run Playwright first-value E2E
4
+ * against it (Wave 3.2 residual / v9.9.5).
5
+ *
6
+ * Mirrors scripts/run_integration_tests.mjs isolation rules: never touch
7
+ * the developer's real HOME / ~/.ltcai / Brain vault.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const host = process.env.LTCAI_E2E_HOST || "127.0.0.1";
15
+ if (!new Set(["127.0.0.1", "localhost"]).has(host)) {
16
+ throw new Error(`LTCAI_E2E_HOST must be loopback, received: ${host}`);
17
+ }
18
+ const port = process.env.LTCAI_E2E_PORT || "4899";
19
+ if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
20
+ throw new Error(`LTCAI_E2E_PORT must be 1–65535, received: ${port}`);
21
+ }
22
+ const baseUrl = `http://${host}:${port}`;
23
+ const venvPython = join(process.cwd(), ".venv", "bin", "python");
24
+ const python = process.env.PYTHON || (existsSync(venvPython) ? venvPython : "python3");
25
+
26
+ const sandboxRoot = mkdtempSync(join(tmpdir(), "ltcai-e2e-"));
27
+ const sandbox = {
28
+ home: join(sandboxRoot, "home"),
29
+ data: join(sandboxRoot, "data"),
30
+ brain: join(sandboxRoot, "brain"),
31
+ agent: join(sandboxRoot, "agent-workspace"),
32
+ vault: join(sandboxRoot, "vault"),
33
+ cache: join(sandboxRoot, "cache"),
34
+ config: join(sandboxRoot, "config"),
35
+ tmp: join(sandboxRoot, "tmp"),
36
+ };
37
+ for (const path of Object.values(sandbox)) mkdirSync(path, { recursive: true });
38
+
39
+ const isolatedEnv = {
40
+ ...process.env,
41
+ HOME: sandbox.home,
42
+ USERPROFILE: sandbox.home,
43
+ XDG_CACHE_HOME: sandbox.cache,
44
+ XDG_CONFIG_HOME: sandbox.config,
45
+ XDG_DATA_HOME: join(sandboxRoot, "share"),
46
+ TMPDIR: sandbox.tmp,
47
+ TEMP: sandbox.tmp,
48
+ TMP: sandbox.tmp,
49
+ LATTICEAI_MODE: "local",
50
+ LATTICEAI_HOST: host,
51
+ LATTICEAI_PORT: port,
52
+ LATTICEAI_DATA_DIR: sandbox.data,
53
+ LATTICEAI_BRAIN_DIR: sandbox.brain,
54
+ LATTICEAI_AGENT_ROOT: sandbox.agent,
55
+ LATTICEAI_OBSIDIAN_VAULT_DIR: sandbox.vault,
56
+ LATTICEAI_STORAGE_ENGINE: "sqlite",
57
+ LATTICEAI_POSTGRES_DSN: "",
58
+ LATTICEAI_REQUIRE_AUTH: "false",
59
+ LATTICEAI_ENABLE_TELEGRAM: "false",
60
+ LATTICEAI_AUTOLOAD_MODELS: "false",
61
+ LATTICEAI_ALLOW_MODEL_DOWNLOADS: "false",
62
+ LATTICEAI_AUTO_READ_CHAT_PATHS: "false",
63
+ LATTICEAI_TUNNEL: "false",
64
+ LATTICEAI_DISCORD_PERMISSION_WEBHOOK: "",
65
+ LATTICEAI_DISCORD_BOT_TOKEN: "",
66
+ OIDC_DISCOVERY_URL: "",
67
+ PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring",
68
+ LTCAI_E2E_BASE_URL: baseUrl,
69
+ };
70
+
71
+ let sandboxCleaned = false;
72
+ function cleanupSandbox() {
73
+ if (sandboxCleaned) return;
74
+ sandboxCleaned = true;
75
+ rmSync(sandboxRoot, { recursive: true, force: true });
76
+ }
77
+ process.once("exit", cleanupSandbox);
78
+
79
+ function delay(ms) {
80
+ return new Promise((resolve) => setTimeout(resolve, ms));
81
+ }
82
+
83
+ async function waitForHealth(url, timeoutMs = 60000) {
84
+ const deadline = Date.now() + timeoutMs;
85
+ let lastError = "";
86
+ while (Date.now() < deadline) {
87
+ try {
88
+ const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) });
89
+ if (response.ok) return;
90
+ lastError = `HTTP ${response.status}`;
91
+ } catch (error) {
92
+ lastError = String(error?.message || error);
93
+ }
94
+ await delay(500);
95
+ }
96
+ throw new Error(`Timed out waiting for ${url}/health: ${lastError}`);
97
+ }
98
+
99
+ function stop(child) {
100
+ return new Promise((resolve) => {
101
+ if (child.exitCode !== null || child.signalCode !== null) {
102
+ resolve();
103
+ return;
104
+ }
105
+ const timer = setTimeout(() => {
106
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
107
+ }, 5000);
108
+ child.once("close", () => {
109
+ clearTimeout(timer);
110
+ resolve();
111
+ });
112
+ child.kill("SIGTERM");
113
+ });
114
+ }
115
+
116
+ async function main() {
117
+ console.log(`[e2e] sandbox ${sandboxRoot}`);
118
+ console.log(`[e2e] starting sidecar at ${baseUrl}`);
119
+ // Same process model as scripts/run_integration_tests.mjs: uvicorn on
120
+ // server:app with an isolated env. Do not double-append /health — the
121
+ // wait helper already probes `${url}/health`.
122
+ const server = spawn(
123
+ python,
124
+ ["-m", "uvicorn", "server:app", "--host", host, "--port", port],
125
+ { stdio: "inherit", env: isolatedEnv, cwd: process.cwd() },
126
+ );
127
+
128
+ let exitCode = 1;
129
+ try {
130
+ await waitForHealth(baseUrl);
131
+ console.log("[e2e] sidecar healthy — running Playwright");
132
+ // Playwright must use the host browser install (npx playwright install).
133
+ // The sidecar isolation rewrites HOME/XDG_* into a disposable sandbox —
134
+ // reusing that env here makes Chromium look under /tmp/.../ms-playwright
135
+ // and fail with "Executable doesn't exist".
136
+ const playwrightEnv = {
137
+ ...process.env,
138
+ LTCAI_E2E_BASE_URL: baseUrl,
139
+ };
140
+ const playwright = spawn(
141
+ "npx",
142
+ ["playwright", "test", "-c", "playwright.e2e.config.js"],
143
+ { stdio: "inherit", env: playwrightEnv, cwd: process.cwd() },
144
+ );
145
+ exitCode = await new Promise((resolve, reject) => {
146
+ playwright.on("error", reject);
147
+ playwright.on("close", (code) => resolve(code ?? 1));
148
+ });
149
+ } catch (error) {
150
+ console.error("[e2e] failed:", error);
151
+ exitCode = 1;
152
+ } finally {
153
+ await stop(server);
154
+ cleanupSandbox();
155
+ }
156
+ process.exit(exitCode);
157
+ }
158
+
159
+ main();
@@ -1584,7 +1584,7 @@ dependencies = [
1584
1584
 
1585
1585
  [[package]]
1586
1586
  name = "lattice-ai-desktop"
1587
- version = "9.9.4"
1587
+ version = "9.9.5"
1588
1588
  dependencies = [
1589
1589
  "plist",
1590
1590
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "9.9.4"
3
+ version = "9.9.5"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "9.9.4",
4
+ "version": "9.9.5",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",
@@ -1,37 +1,37 @@
1
1
  {
2
- "version": "9.9.4",
2
+ "version": "9.9.5",
3
3
  "generated_at": "vite",
4
4
  "entrypoints": {
5
- "app": "/static/app/assets/index-DrmOCySv.js"
5
+ "app": "/static/app/assets/index-CEu0Wqjl.js"
6
6
  },
7
7
  "assets": {
8
8
  "../node_modules/@tauri-apps/api/core.js": "/static/app/assets/core-CwxXejkd.js",
9
- "_BrainSignals-C52lwZVD.js": "/static/app/assets/BrainSignals-C52lwZVD.js",
10
- "_LivingBrain-Jlf2wFqI.js": "/static/app/assets/LivingBrain-Jlf2wFqI.js",
11
- "_ReviewCard-DWvD7n9h.js": "/static/app/assets/ReviewCard-DWvD7n9h.js",
12
- "_bot-nB_buEZD.js": "/static/app/assets/bot-nB_buEZD.js",
13
- "_circle-pause-DLNw6Ucp.js": "/static/app/assets/circle-pause-DLNw6Ucp.js",
14
- "_circle-play-B-IsFL1y.js": "/static/app/assets/circle-play-B-IsFL1y.js",
15
- "_cpu-CEPBHaBl.js": "/static/app/assets/cpu-CEPBHaBl.js",
16
- "_folder-open-hmN0N9cX.js": "/static/app/assets/folder-open-hmN0N9cX.js",
17
- "_hard-drive-CBV_B_Yd.js": "/static/app/assets/hard-drive-CBV_B_Yd.js",
18
- "_input-gtVCg-ll.js": "/static/app/assets/input-gtVCg-ll.js",
19
- "_navigation-Bot0hvuv.js": "/static/app/assets/navigation-Bot0hvuv.js",
20
- "_network-jE42eKfT.js": "/static/app/assets/network-jE42eKfT.js",
21
- "_primitives-CX2Komon.js": "/static/app/assets/primitives-CX2Komon.js",
22
- "_shield-alert-BftATuAA.js": "/static/app/assets/shield-alert-BftATuAA.js",
23
- "_textarea-CiMJfOSI.js": "/static/app/assets/textarea-CiMJfOSI.js",
24
- "_useFocusTrap-B7RPGfFy.js": "/static/app/assets/useFocusTrap-B7RPGfFy.js",
25
- "_utils-SJUNVOj5.js": "/static/app/assets/utils-SJUNVOj5.js",
26
- "index.html": "/static/app/assets/index-DrmOCySv.js",
9
+ "_BrainSignals-r_J68_zh.js": "/static/app/assets/BrainSignals-r_J68_zh.js",
10
+ "_LivingBrain-BVWk58MW.js": "/static/app/assets/LivingBrain-BVWk58MW.js",
11
+ "_ReviewCard-Va3fPQuj.js": "/static/app/assets/ReviewCard-Va3fPQuj.js",
12
+ "_bot-VmP0kkeA.js": "/static/app/assets/bot-VmP0kkeA.js",
13
+ "_circle-pause-T9POo4qy.js": "/static/app/assets/circle-pause-T9POo4qy.js",
14
+ "_circle-play-Bz5iTD0p.js": "/static/app/assets/circle-play-Bz5iTD0p.js",
15
+ "_cpu-CJhjRtNq.js": "/static/app/assets/cpu-CJhjRtNq.js",
16
+ "_folder-open-B2K_22VI.js": "/static/app/assets/folder-open-B2K_22VI.js",
17
+ "_hard-drive-CjJtYqHf.js": "/static/app/assets/hard-drive-CjJtYqHf.js",
18
+ "_input-CSi6OBJ9.js": "/static/app/assets/input-CSi6OBJ9.js",
19
+ "_navigation-BPQqEQwr.js": "/static/app/assets/navigation-BPQqEQwr.js",
20
+ "_network-Ck1nqc-v.js": "/static/app/assets/network-Ck1nqc-v.js",
21
+ "_primitives-B2f9N7Dh.js": "/static/app/assets/primitives-B2f9N7Dh.js",
22
+ "_shield-alert-yA_Y8lOB.js": "/static/app/assets/shield-alert-yA_Y8lOB.js",
23
+ "_textarea-C4wM0D-Z.js": "/static/app/assets/textarea-C4wM0D-Z.js",
24
+ "_useFocusTrap-CsnIhsDJ.js": "/static/app/assets/useFocusTrap-CsnIhsDJ.js",
25
+ "_utils-BqoznzGP.js": "/static/app/assets/utils-BqoznzGP.js",
26
+ "index.html": "/static/app/assets/index-CEu0Wqjl.js",
27
27
  "assets/index-7FAfYm4v.css": "/static/app/assets/index-7FAfYm4v.css",
28
- "src/components/ProductFlow.tsx": "/static/app/assets/ProductFlow-yg1fKP1P.js",
29
- "src/features/brain/BrainHome.tsx": "/static/app/assets/BrainHome-BJ3sFNX0.js",
30
- "src/features/command/CommandPalette.tsx": "/static/app/assets/CommandPalette-90u9FWFH.js",
31
- "src/pages/Act.tsx": "/static/app/assets/Act-BCmTU0E2.js",
32
- "src/pages/Brain.tsx": "/static/app/assets/Brain-CTnjox7w.js",
33
- "src/pages/Capture.tsx": "/static/app/assets/Capture-B6vBhFa3.js",
34
- "src/pages/Library.tsx": "/static/app/assets/Library-pKCK0_tk.js",
35
- "src/pages/System.tsx": "/static/app/assets/System-BAEuHqNY.js"
28
+ "src/components/ProductFlow.tsx": "/static/app/assets/ProductFlow-B-Tz0Um4.js",
29
+ "src/features/brain/BrainHome.tsx": "/static/app/assets/BrainHome-CCE_0hni.js",
30
+ "src/features/command/CommandPalette.tsx": "/static/app/assets/CommandPalette-CVLiFIQC.js",
31
+ "src/pages/Act.tsx": "/static/app/assets/Act-DmxfbqBp.js",
32
+ "src/pages/Brain.tsx": "/static/app/assets/Brain-DK_zKCda.js",
33
+ "src/pages/Capture.tsx": "/static/app/assets/Capture-CvxwzpJZ.js",
34
+ "src/pages/Library.tsx": "/static/app/assets/Library-4As9VUvy.js",
35
+ "src/pages/System.tsx": "/static/app/assets/System-DSR2zKWd.js"
36
36
  }
37
37
  }