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.
- package/README.md +38 -42
- package/docs/CHANGELOG.md +35 -0
- 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/SURFACE_PARITY.md +10 -4
- 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/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +28 -442
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat_agent_http.py +87 -46
- package/latticeai/api/chat_contracts.py +1 -1
- package/latticeai/core/agent.py +156 -18
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +2 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/telegram_bot.py +82 -23
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/tool_dispatch.py +56 -0
- package/package.json +2 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/run_sidecar_e2e.mjs +159 -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 +28 -28
- package/static/app/assets/{Act-BCmTU0E2.js → Act-DmxfbqBp.js} +1 -1
- package/static/app/assets/{Brain-CTnjox7w.js → Brain-DK_zKCda.js} +1 -1
- package/static/app/assets/{BrainHome-BJ3sFNX0.js → BrainHome-CCE_0hni.js} +1 -1
- package/static/app/assets/{BrainSignals-C52lwZVD.js → BrainSignals-r_J68_zh.js} +1 -1
- package/static/app/assets/{Capture-B6vBhFa3.js → Capture-CvxwzpJZ.js} +1 -1
- package/static/app/assets/{CommandPalette-90u9FWFH.js → CommandPalette-CVLiFIQC.js} +1 -1
- package/static/app/assets/{Library-pKCK0_tk.js → Library-4As9VUvy.js} +1 -1
- package/static/app/assets/{LivingBrain-Jlf2wFqI.js → LivingBrain-BVWk58MW.js} +1 -1
- package/static/app/assets/{ProductFlow-yg1fKP1P.js → ProductFlow-B-Tz0Um4.js} +1 -1
- package/static/app/assets/{ReviewCard-DWvD7n9h.js → ReviewCard-Va3fPQuj.js} +1 -1
- package/static/app/assets/{System-BAEuHqNY.js → System-DSR2zKWd.js} +1 -1
- package/static/app/assets/{bot-nB_buEZD.js → bot-VmP0kkeA.js} +1 -1
- package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause-T9POo4qy.js} +1 -1
- package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-Bz5iTD0p.js} +1 -1
- package/static/app/assets/{cpu-CEPBHaBl.js → cpu-CJhjRtNq.js} +1 -1
- package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-B2K_22VI.js} +1 -1
- package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-CjJtYqHf.js} +1 -1
- package/static/app/assets/{index-DrmOCySv.js → index-CEu0Wqjl.js} +3 -3
- package/static/app/assets/{input-gtVCg-ll.js → input-CSi6OBJ9.js} +1 -1
- package/static/app/assets/{navigation-Bot0hvuv.js → navigation-BPQqEQwr.js} +1 -1
- package/static/app/assets/{network-jE42eKfT.js → network-Ck1nqc-v.js} +1 -1
- package/static/app/assets/{primitives-CX2Komon.js → primitives-B2f9N7Dh.js} +1 -1
- package/static/app/assets/{shield-alert-BftATuAA.js → shield-alert-yA_Y8lOB.js} +1 -1
- package/static/app/assets/{textarea-CiMJfOSI.js → textarea-C4wM0D-Z.js} +1 -1
- package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-CsnIhsDJ.js} +1 -1
- package/static/app/assets/{utils-SJUNVOj5.js → utils-BqoznzGP.js} +1 -1
- package/static/app/index.html +3 -3
- 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
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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[
|
|
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:{
|
|
742
|
-
{"text": "❌ 취소", "callback_data": f"plan:cancel:{
|
|
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,
|
|
753
|
-
pending = _bot_pending_plans.get(
|
|
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(
|
|
792
|
+
pending = _bot_pending_plans.pop(pause_id, None)
|
|
762
793
|
|
|
763
|
-
if action == "cancel"
|
|
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(
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
#
|
|
798
|
-
if isinstance(data, dict) and data
|
|
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
|
|
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)
|
|
@@ -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.
|
|
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 = "
|
|
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();
|
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "9.9.
|
|
2
|
+
"version": "9.9.5",
|
|
3
3
|
"generated_at": "vite",
|
|
4
4
|
"entrypoints": {
|
|
5
|
-
"app": "/static/app/assets/index-
|
|
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-
|
|
10
|
-
"_LivingBrain-
|
|
11
|
-
"_ReviewCard-
|
|
12
|
-
"_bot-
|
|
13
|
-
"_circle-pause-
|
|
14
|
-
"_circle-play-
|
|
15
|
-
"_cpu-
|
|
16
|
-
"_folder-open-
|
|
17
|
-
"_hard-drive-
|
|
18
|
-
"_input-
|
|
19
|
-
"_navigation-
|
|
20
|
-
"_network-
|
|
21
|
-
"_primitives-
|
|
22
|
-
"_shield-alert-
|
|
23
|
-
"_textarea-
|
|
24
|
-
"_useFocusTrap-
|
|
25
|
-
"_utils-
|
|
26
|
-
"index.html": "/static/app/assets/index-
|
|
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-
|
|
29
|
-
"src/features/brain/BrainHome.tsx": "/static/app/assets/BrainHome-
|
|
30
|
-
"src/features/command/CommandPalette.tsx": "/static/app/assets/CommandPalette-
|
|
31
|
-
"src/pages/Act.tsx": "/static/app/assets/Act-
|
|
32
|
-
"src/pages/Brain.tsx": "/static/app/assets/Brain-
|
|
33
|
-
"src/pages/Capture.tsx": "/static/app/assets/Capture-
|
|
34
|
-
"src/pages/Library.tsx": "/static/app/assets/Library-
|
|
35
|
-
"src/pages/System.tsx": "/static/app/assets/System-
|
|
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
|
}
|