ltcai 11.2.0 → 11.5.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 (253) hide show
  1. package/README.md +50 -53
  2. package/docs/CHANGELOG.md +87 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/OPERATIONS.md +6 -2
  8. package/docs/PERMISSION_MODE.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +2 -2
  12. package/docs/v11.3.0_PLAN.md +202 -0
  13. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +181 -0
  14. package/docs/v11.5.0_RUST_COMPLETE_PLAN.md +145 -0
  15. package/lattice_brain/__init__.py +1 -1
  16. package/lattice_brain/graph/_kg_common/__init__.py +287 -0
  17. package/lattice_brain/graph/_kg_common/extraction.py +516 -0
  18. package/lattice_brain/graph/_kg_common/relations.py +161 -0
  19. package/lattice_brain/graph/_kg_common/text.py +479 -0
  20. package/lattice_brain/graph/discovery_index/__init__.py +35 -0
  21. package/lattice_brain/graph/discovery_index/cleanup.py +182 -0
  22. package/lattice_brain/graph/discovery_index/extract.py +137 -0
  23. package/lattice_brain/graph/discovery_index/scan.py +411 -0
  24. package/lattice_brain/graph/discovery_index/upsert.py +495 -0
  25. package/lattice_brain/graph/projection/__init__.py +42 -0
  26. package/lattice_brain/graph/projection/curation.py +500 -0
  27. package/lattice_brain/graph/{projection.py → projection/v2_schema.py} +15 -477
  28. package/lattice_brain/graph/retrieval/__init__.py +54 -0
  29. package/lattice_brain/graph/retrieval/context.py +197 -0
  30. package/lattice_brain/graph/retrieval/graph_view.py +319 -0
  31. package/lattice_brain/graph/retrieval/hybrid.py +488 -0
  32. package/lattice_brain/graph/retrieval/maintenance.py +121 -0
  33. package/lattice_brain/graph/retrieval/signals.py +95 -0
  34. package/lattice_brain/graph/retrieval_vector/__init__.py +42 -0
  35. package/lattice_brain/graph/retrieval_vector/fingerprint.py +97 -0
  36. package/lattice_brain/graph/retrieval_vector/indexing.py +347 -0
  37. package/lattice_brain/graph/retrieval_vector/search.py +560 -0
  38. package/lattice_brain/graph/retrieval_vector/status.py +374 -0
  39. package/lattice_brain/ingestion/__init__.py +130 -0
  40. package/lattice_brain/ingestion/_contract.py +90 -0
  41. package/lattice_brain/ingestion/constants.py +127 -0
  42. package/lattice_brain/ingestion/folder_scan.py +57 -0
  43. package/lattice_brain/ingestion/folders.py +258 -0
  44. package/lattice_brain/ingestion/hashing.py +26 -0
  45. package/lattice_brain/ingestion/jobs_api.py +107 -0
  46. package/lattice_brain/ingestion/models.py +80 -0
  47. package/lattice_brain/ingestion/pipeline.py +486 -0
  48. package/lattice_brain/ingestion/quality.py +209 -0
  49. package/lattice_brain/ingestion/routing.py +295 -0
  50. package/lattice_brain/multimodal/__init__.py +164 -0
  51. package/lattice_brain/multimodal/audio.py +77 -0
  52. package/lattice_brain/multimodal/common.py +118 -0
  53. package/lattice_brain/multimodal/images.py +498 -0
  54. package/lattice_brain/multimodal/ports.py +169 -0
  55. package/lattice_brain/multimodal/video.py +410 -0
  56. package/lattice_brain/portability/__init__.py +90 -0
  57. package/lattice_brain/portability/_contract.py +42 -0
  58. package/lattice_brain/portability/backups.py +338 -0
  59. package/lattice_brain/portability/bundles.py +136 -0
  60. package/lattice_brain/portability/constants.py +93 -0
  61. package/lattice_brain/portability/fsops.py +138 -0
  62. package/lattice_brain/portability/service.py +41 -0
  63. package/lattice_brain/{portability.py → portability/sharing.py} +44 -677
  64. package/lattice_brain/runtime/__init__.py +1 -1
  65. package/lattice_brain/runtime/multi_agent.py +1 -1
  66. package/latticeai/__init__.py +1 -1
  67. package/latticeai/api/chronicle.py +63 -0
  68. package/latticeai/api/index_jobs.py +145 -0
  69. package/latticeai/core/agent/__init__.py +93 -0
  70. package/latticeai/core/agent/_contract.py +79 -0
  71. package/latticeai/core/agent/context.py +57 -0
  72. package/latticeai/core/agent/deps.py +125 -0
  73. package/latticeai/core/agent/execution.py +622 -0
  74. package/latticeai/core/agent/planning.py +145 -0
  75. package/latticeai/core/agent/recovery.py +157 -0
  76. package/latticeai/core/agent/runtime.py +210 -0
  77. package/latticeai/core/agent/verification.py +231 -0
  78. package/latticeai/core/embedding_providers/__init__.py +151 -0
  79. package/latticeai/core/embedding_providers/base.py +199 -0
  80. package/latticeai/core/embedding_providers/captions.py +162 -0
  81. package/latticeai/core/embedding_providers/profiles.py +126 -0
  82. package/latticeai/core/embedding_providers/text.py +350 -0
  83. package/latticeai/core/embedding_providers/vision.py +352 -0
  84. package/latticeai/core/file_generation/__init__.py +115 -0
  85. package/latticeai/core/file_generation/bundles.py +76 -0
  86. package/latticeai/core/file_generation/extraction.py +154 -0
  87. package/latticeai/core/file_generation/inference.py +235 -0
  88. package/latticeai/core/file_generation/orchestration.py +152 -0
  89. package/latticeai/core/file_generation/prompting.py +117 -0
  90. package/latticeai/core/file_generation/repair.py +114 -0
  91. package/latticeai/core/file_generation/sanitize.py +61 -0
  92. package/latticeai/core/file_generation/validation.py +201 -0
  93. package/latticeai/core/legacy_compatibility.py +1 -1
  94. package/latticeai/core/marketplace.py +1 -1
  95. package/latticeai/core/messages.py +14 -0
  96. package/latticeai/core/workspace_os_constants.py +1 -1
  97. package/latticeai/integrations/telegram_bot/__init__.py +123 -0
  98. package/latticeai/integrations/telegram_bot/__main__.py +17 -0
  99. package/latticeai/integrations/telegram_bot/config.py +86 -0
  100. package/latticeai/integrations/telegram_bot/dispatch.py +311 -0
  101. package/latticeai/integrations/telegram_bot/flows.py +478 -0
  102. package/latticeai/integrations/telegram_bot/helpers.py +322 -0
  103. package/latticeai/integrations/telegram_bot/screens.py +394 -0
  104. package/latticeai/models/router/__init__.py +88 -0
  105. package/latticeai/models/router/_contract.py +66 -0
  106. package/latticeai/models/router/branding.py +56 -0
  107. package/latticeai/models/router/catalog.py +69 -0
  108. package/latticeai/models/router/documents.py +199 -0
  109. package/latticeai/models/router/errors.py +37 -0
  110. package/latticeai/models/router/generation.py +258 -0
  111. package/latticeai/models/router/loading.py +291 -0
  112. package/latticeai/models/router/local_models.py +85 -0
  113. package/latticeai/models/router/registry.py +147 -0
  114. package/latticeai/runtime/build_phases/__init__.py +82 -0
  115. package/latticeai/runtime/build_phases/features.py +421 -0
  116. package/latticeai/runtime/build_phases/foundation.py +555 -0
  117. package/latticeai/runtime/build_phases/web.py +492 -0
  118. package/latticeai/runtime/runtime_context.py +1 -0
  119. package/latticeai/services/architecture_readiness.py +48 -19
  120. package/latticeai/services/brain_intelligence/__init__.py +58 -0
  121. package/latticeai/services/brain_intelligence/_contract.py +71 -0
  122. package/latticeai/services/brain_intelligence/consistency.py +193 -0
  123. package/latticeai/services/brain_intelligence/constants.py +47 -0
  124. package/latticeai/services/brain_intelligence/digest.py +258 -0
  125. package/latticeai/services/brain_intelligence/health.py +331 -0
  126. package/latticeai/services/brain_intelligence/proposals.py +264 -0
  127. package/latticeai/services/brain_intelligence/sampling.py +84 -0
  128. package/latticeai/services/brain_intelligence/service.py +48 -0
  129. package/latticeai/services/chronicle.py +557 -0
  130. package/latticeai/services/memory_service/__init__.py +52 -0
  131. package/latticeai/services/memory_service/_contract.py +100 -0
  132. package/latticeai/services/memory_service/brief.py +431 -0
  133. package/latticeai/services/memory_service/constants.py +57 -0
  134. package/latticeai/services/memory_service/maintenance.py +138 -0
  135. package/latticeai/services/memory_service/manager.py +186 -0
  136. package/latticeai/services/memory_service/proof.py +136 -0
  137. package/latticeai/services/memory_service/recall.py +225 -0
  138. package/latticeai/services/memory_service/service.py +48 -0
  139. package/latticeai/services/memory_service/stores.py +110 -0
  140. package/latticeai/services/model_runtime/__init__.py +322 -0
  141. package/latticeai/services/model_runtime/cloud.py +87 -0
  142. package/latticeai/services/model_runtime/download.py +282 -0
  143. package/latticeai/services/model_runtime/engines.py +341 -0
  144. package/latticeai/services/model_runtime/loading.py +178 -0
  145. package/latticeai/services/model_runtime/service.py +129 -0
  146. package/latticeai/services/model_runtime/state.py +131 -0
  147. package/latticeai/services/model_runtime/status.py +255 -0
  148. package/latticeai/services/product_readiness.py +15 -7
  149. package/latticeai/setup/wizard/__init__.py +126 -0
  150. package/latticeai/setup/wizard/catalog.py +172 -0
  151. package/latticeai/setup/wizard/detect.py +323 -0
  152. package/latticeai/setup/wizard/install.py +348 -0
  153. package/latticeai/setup/wizard/paths.py +168 -0
  154. package/latticeai/setup/wizard/plans.py +74 -0
  155. package/latticeai/setup/wizard/recommend.py +320 -0
  156. package/package.json +6 -2
  157. package/scripts/bump_version.py +14 -0
  158. package/scripts/capture_release_evidence.mjs +33 -21
  159. package/scripts/check_current_release_docs.mjs +1 -1
  160. package/scripts/check_i18n_namespace_coverage.mjs +41 -4
  161. package/scripts/check_max_file_lines.mjs +102 -0
  162. package/scripts/check_release_evidence_bound.mjs +30 -15
  163. package/scripts/check_screenshot_pixel_delta.py +34 -4
  164. package/scripts/check_server_i18n.mjs +2 -0
  165. package/scripts/chunking_parity_corpus.py +449 -0
  166. package/scripts/generate_agent_parity_fixtures.py +752 -0
  167. package/scripts/generate_chunking_parity_fixtures.py +259 -0
  168. package/scripts/generate_rust_parity_fixtures.py +997 -0
  169. package/scripts/lib/mock_server_fingerprint.mjs +94 -0
  170. package/scripts/release_screen_claims.json +42 -2
  171. package/src-tauri/Cargo.lock +404 -3
  172. package/src-tauri/Cargo.toml +13 -1
  173. package/src-tauri/src/backend.rs +460 -0
  174. package/src-tauri/src/folder.rs +33 -0
  175. package/src-tauri/src/main.rs +109 -399
  176. package/src-tauri/src/topology.rs +356 -0
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +41 -37
  179. package/static/app/assets/Act-CWnxSCgN.js +1 -0
  180. package/static/app/assets/AdminConsole-BEQYU6kF.js +1 -0
  181. package/static/app/assets/{Brain-tuhI4sOC.js → Brain-DWu1BhFg.js} +2 -2
  182. package/static/app/assets/BrainHome-95Hilr9R.js +2 -0
  183. package/static/app/assets/BrainSignals-QdeqCpAF.js +1 -0
  184. package/static/app/assets/Capture-BHpCxnzb.js +1 -0
  185. package/static/app/assets/Chronicle-B4xYKoed.js +1 -0
  186. package/static/app/assets/CommandPalette-BVXnttSz.js +1 -0
  187. package/static/app/assets/Library-DgYcHome.js +1 -0
  188. package/static/app/assets/{LivingBrain-DBwhto14.js → LivingBrain-CrJLDbf7.js} +1 -1
  189. package/static/app/assets/ProductFlow-DFlScKoJ.js +1 -0
  190. package/static/app/assets/ReviewCard-Cy5f48Pj.js +3 -0
  191. package/static/app/assets/System-NF8IfhTa.js +1 -0
  192. package/static/app/assets/arrow-left-DwkSYrjR.js +1 -0
  193. package/static/app/assets/{bot-Cia42c2h.js → bot-CucuhLhm.js} +1 -1
  194. package/static/app/assets/brain-BBnSryW_.js +1 -0
  195. package/static/app/assets/{button-2j2Ijzgq.js → button-C2GUj2Ai.js} +1 -1
  196. package/static/app/assets/circle-check-CxOVPwYq.js +1 -0
  197. package/static/app/assets/{circle-pause-BEFeWpVW.js → circle-pause-CbkWzBmG.js} +1 -1
  198. package/static/app/assets/{circle-play-ujXMcHxl.js → circle-play-7lEaqHdJ.js} +1 -1
  199. package/static/app/assets/{cpu-k4awryFq.js → cpu-DAlCXlIy.js} +1 -1
  200. package/static/app/assets/{download-DFbLJ_ig.js → download-RNhuuJwh.js} +1 -1
  201. package/static/app/assets/{folder-open-7y_b6xkM.js → folder-open-CLW4odzM.js} +1 -1
  202. package/static/app/assets/{hard-drive-Bidh02Kr.js → hard-drive-NKEiDIAJ.js} +1 -1
  203. package/static/app/assets/{index-DwDl9-8Y.css → index-BLPb5lmE.css} +1 -1
  204. package/static/app/assets/index-DMurvUuR.js +10 -0
  205. package/static/app/assets/input-D2UhPC1X.js +1 -0
  206. package/static/app/assets/link-2-6amKbP_P.js +1 -0
  207. package/static/app/assets/{permissionCopy-Bpb83Hx9.js → permissionCopy-Cu9TZtdR.js} +1 -1
  208. package/static/app/assets/primitives-gPsccucr.js +1 -0
  209. package/static/app/assets/search-Cj_TKk_2.js +1 -0
  210. package/static/app/assets/{share-2-BH1M-WNi.js → share-2-Bau7KkPq.js} +1 -1
  211. package/static/app/assets/{shield-alert-BlKdBXcG.js → shield-alert-BufNYypi.js} +1 -1
  212. package/static/app/assets/{textarea-CCWbUfFB.js → textarea-BQnVWhYs.js} +1 -1
  213. package/static/app/assets/{useFocusTrap-YdHQ7pJ1.js → useFocusTrap-B3_w60si.js} +1 -1
  214. package/static/app/assets/useMutation-BHhCflT6.js +1 -0
  215. package/static/app/assets/{useQuery-CXQiwbVT.js → useQuery-rBWfI-5t.js} +1 -1
  216. package/static/app/assets/utils-V_5-wxr5.js +4 -0
  217. package/static/app/assets/workspace-K1zjYUHj.js +1 -0
  218. package/static/app/index.html +4 -4
  219. package/static/sw.js +1 -1
  220. package/lattice_brain/graph/_kg_common.py +0 -1331
  221. package/lattice_brain/graph/discovery_index.py +0 -1141
  222. package/lattice_brain/graph/retrieval.py +0 -1120
  223. package/lattice_brain/graph/retrieval_vector.py +0 -1293
  224. package/lattice_brain/ingestion.py +0 -1525
  225. package/lattice_brain/multimodal.py +0 -1258
  226. package/latticeai/core/agent.py +0 -1465
  227. package/latticeai/core/embedding_providers.py +0 -1196
  228. package/latticeai/core/file_generation.py +0 -1047
  229. package/latticeai/integrations/telegram_bot.py +0 -1390
  230. package/latticeai/models/router.py +0 -1007
  231. package/latticeai/runtime/build_phases.py +0 -1450
  232. package/latticeai/services/brain_intelligence.py +0 -1083
  233. package/latticeai/services/memory_service.py +0 -1177
  234. package/latticeai/services/model_runtime.py +0 -1281
  235. package/latticeai/setup/wizard.py +0 -1310
  236. package/static/app/assets/Act-AWf0SAKp.js +0 -1
  237. package/static/app/assets/AdminConsole-D0u8Tiyj.js +0 -1
  238. package/static/app/assets/BrainHome-Ts7G_Ila.js +0 -2
  239. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +0 -1
  240. package/static/app/assets/Capture-CqOSzyPr.js +0 -1
  241. package/static/app/assets/CommandPalette-DC0Bzh-I.js +0 -1
  242. package/static/app/assets/Library-CX-bbhmK.js +0 -1
  243. package/static/app/assets/ProductFlow-BHA2cfKI.js +0 -1
  244. package/static/app/assets/ReviewCard-BUhCKRNM.js +0 -3
  245. package/static/app/assets/System-Bu2t5hn1.js +0 -1
  246. package/static/app/assets/arrow-left-Dzwa5zRb.js +0 -1
  247. package/static/app/assets/brain-DJMoqrwx.js +0 -1
  248. package/static/app/assets/index-BpYkzcVm.js +0 -10
  249. package/static/app/assets/input-DSlJJxRs.js +0 -1
  250. package/static/app/assets/primitives-BCx6TvfG.js +0 -1
  251. package/static/app/assets/search-Cgy8cCFJ.js +0 -1
  252. package/static/app/assets/utils-zqPZJxdx.js +0 -4
  253. package/static/app/assets/workspace-DXTihhfU.js +0 -1
@@ -1,410 +1,137 @@
1
- use std::{
2
- env,
3
- fs::OpenOptions,
4
- path::PathBuf,
5
- process::{Child, Command, Stdio},
6
- sync::Mutex,
7
- };
8
-
9
- use serde::Serialize;
1
+ //! Lattice AI desktop shell.
2
+ //!
3
+ //! Everything this file used to do itself — resolving the worker command,
4
+ //! spawning it, probing a TCP socket to guess whether it was up, killing and
5
+ //! respawning on restart — moved into the `lattice-host` crate in 11.4.0 and is
6
+ //! now shared with the `lattice-host` binary, so the desktop and the CLI front
7
+ //! door cannot drift apart. What is left here is the part that is genuinely
8
+ //! Tauri's: five commands, one window, and the promise that closing the window
9
+ //! stops the worker.
10
+ //!
11
+ //! Since 11.5.0 the shell also *serves* that front door: the gateway runs
12
+ //! in-process on the public port and the worker sits behind it (see
13
+ //! [`topology`] for the arrangement and its escape hatches).
14
+ //!
15
+ //! The five command names and their response shapes are unchanged — the
16
+ //! frontend (`frontend/src/api/base.ts`, `frontend/src/api/client.ts`) invokes
17
+ //! them by name and reads `backend_status` as a plain record. The fields added
18
+ //! in 11.5.0 are additive; nothing was renamed or removed.
19
+
20
+ mod backend;
21
+ mod folder;
22
+ mod topology;
23
+
24
+ use std::sync::Arc;
25
+
26
+ use backend::{BackendStatus, DesktopBackend};
10
27
  use tauri::{Manager, State};
11
28
 
12
- struct BackendState {
13
- origin: String,
14
- command: String,
15
- cwd: Option<String>,
16
- child: Mutex<Option<Child>>,
17
- last_error: Mutex<Option<String>>,
18
- }
19
-
20
- #[derive(Serialize)]
21
- struct BackendStatus {
22
- origin: String,
23
- command: String,
24
- cwd: Option<String>,
25
- running: bool,
26
- pid: Option<u32>,
27
- last_error: Option<String>,
28
- }
29
-
30
- struct BackendLaunch {
31
- command: String,
32
- program: String,
33
- args: Vec<String>,
34
- cwd: Option<PathBuf>,
35
- }
36
-
29
+ /// `backend_origin` → the origin every frontend request is sent to.
37
30
  #[tauri::command]
38
- fn backend_origin(state: State<'_, BackendState>) -> String {
39
- state.origin.clone()
31
+ fn backend_origin(state: State<'_, Arc<DesktopBackend>>) -> String {
32
+ state.origin().to_string()
40
33
  }
41
34
 
35
+ /// `backend_status` → a live snapshot, health probe included.
36
+ ///
37
+ /// Async, and therefore `Result`, because Tauri requires it of a command that
38
+ /// borrows state across an await. It never returns `Err`: the frontend keeps
39
+ /// receiving a resolved record exactly as before.
42
40
  #[tauri::command]
43
- fn backend_status(state: State<'_, BackendState>) -> BackendStatus {
44
- status_from_state(&state)
41
+ async fn backend_status(state: State<'_, Arc<DesktopBackend>>) -> Result<BackendStatus, String> {
42
+ Ok(state.status().await)
45
43
  }
46
44
 
45
+ /// `restart_backend` → stop, start, and report what happened.
47
46
  #[tauri::command]
48
- fn restart_backend(state: State<'_, BackendState>) -> BackendStatus {
49
- kill_backend(&state);
50
- let launch = backend_launch(&state.origin);
51
- match spawn_backend(&state.origin, &launch) {
52
- Ok(child) => {
53
- if let Ok(mut slot) = state.child.lock() {
54
- *slot = child;
55
- }
56
- set_error(&state, None);
57
- }
58
- Err(err) => set_error(&state, Some(err)),
59
- }
60
- status_from_state(&state)
47
+ async fn restart_backend(state: State<'_, Arc<DesktopBackend>>) -> Result<BackendStatus, String> {
48
+ Ok(state.restart().await)
61
49
  }
62
50
 
51
+ /// `shutdown_backend` → stop the worker and suppress restarts.
63
52
  #[tauri::command]
64
- fn shutdown_backend(state: State<'_, BackendState>) -> BackendStatus {
65
- kill_backend(&state);
66
- status_from_state(&state)
53
+ async fn shutdown_backend(state: State<'_, Arc<DesktopBackend>>) -> Result<BackendStatus, String> {
54
+ Ok(state.stop().await)
67
55
  }
68
56
 
57
+ /// `select_folder` → the native directory picker.
69
58
  #[tauri::command]
70
59
  fn select_folder() -> Option<String> {
71
- native_select_folder()
72
- }
73
-
74
- #[cfg(target_os = "macos")]
75
- fn native_select_folder() -> Option<String> {
76
- let output = Command::new("osascript")
77
- .args([
78
- "-e",
79
- r#"POSIX path of (choose folder with prompt "Choose a folder for Lattice AI")"#,
80
- ])
81
- .output()
82
- .ok()?;
83
- if !output.status.success() {
84
- return None;
85
- }
86
- let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
87
- if path.is_empty() {
88
- None
89
- } else {
90
- Some(path)
91
- }
92
- }
93
-
94
- #[cfg(not(target_os = "macos"))]
95
- fn native_select_folder() -> Option<String> {
96
- None
97
- }
98
-
99
- fn split_command(command: &str) -> Vec<String> {
100
- command.split_whitespace().map(|part| part.to_string()).collect()
101
- }
102
-
103
- fn command_in_path(name: &str) -> Option<String> {
104
- let mut dirs: Vec<PathBuf> = env::var_os("PATH")
105
- .map(|value| env::split_paths(&value).collect())
106
- .unwrap_or_default();
107
- dirs.extend([
108
- PathBuf::from("/opt/homebrew/bin"),
109
- PathBuf::from("/usr/local/bin"),
110
- PathBuf::from("/usr/bin"),
111
- PathBuf::from("/bin"),
112
- ]);
113
- for dir in dirs {
114
- let candidate = dir.join(name);
115
- if candidate.is_file() {
116
- return Some(candidate.to_string_lossy().to_string());
117
- }
118
- }
119
- None
60
+ folder::select_folder()
120
61
  }
121
62
 
122
- fn python_candidates() -> Vec<String> {
123
- let mut out = Vec::new();
124
- if let Ok(value) = env::var("LTCAI_PYTHON") {
125
- out.push(value);
126
- }
127
- for name in ["python3", "python"] {
128
- if let Some(path) = command_in_path(name) {
129
- out.push(path);
130
- }
131
- }
132
- out.extend([
133
- "/opt/homebrew/bin/python3".to_string(),
134
- "/usr/local/bin/python3".to_string(),
135
- "/usr/bin/python3".to_string(),
136
- ]);
137
- out.sort();
138
- out.dedup();
139
- out
140
- }
141
-
142
- fn module_importable(python: &str, module: &str) -> bool {
143
- Command::new(python)
144
- .args(["-c", &format!("import {module}")])
145
- .stdout(Stdio::null())
146
- .stderr(Stdio::null())
147
- .status()
148
- .map(|status| status.success())
149
- .unwrap_or(false)
150
- }
151
-
152
- fn resource_dir() -> Option<PathBuf> {
153
- let exe = env::current_exe().ok()?;
154
- let macos_dir = exe.parent()?;
155
- let contents_dir = macos_dir.parent()?;
156
- let resources = contents_dir.join("Resources");
157
- if resources.exists() {
158
- Some(resources)
159
- } else {
160
- None
161
- }
162
- }
163
-
164
- fn bundled_python_root() -> Option<PathBuf> {
165
- let resources = resource_dir()?;
166
- let up = resources.join("_up_");
167
- if up.join("latticeai").join("cli").join("entrypoint.py").is_file() {
168
- Some(up)
169
- } else if resources.join("latticeai").join("cli").join("entrypoint.py").is_file() {
170
- Some(resources)
171
- } else {
172
- None
173
- }
174
- }
175
-
176
- fn desktop_runtime_dir() -> Option<PathBuf> {
177
- let home = env::var("HOME").ok()?;
178
- let dir = PathBuf::from(home).join(".ltcai").join("desktop-runtime");
179
- let _ = std::fs::create_dir_all(&dir);
180
- Some(dir)
181
- }
182
-
183
- fn python_path_env(launch: &BackendLaunch) -> Option<String> {
184
- let mut paths: Vec<PathBuf> = Vec::new();
185
- if let Some(resources) = bundled_python_root() {
186
- paths.push(resources);
187
- }
188
- if let Some(cwd) = &launch.cwd {
189
- if !paths.iter().any(|path| path == cwd) {
190
- paths.push(cwd.clone());
191
- }
192
- }
193
- if let Some(existing) = env::var_os("PYTHONPATH") {
194
- paths.extend(env::split_paths(&existing));
195
- }
196
- env::join_paths(paths).ok().map(|value| value.to_string_lossy().to_string())
197
- }
198
-
199
- fn backend_launch(origin: &str) -> BackendLaunch {
200
- let port = origin.rsplit(':').next().unwrap_or("8765").to_string();
201
- if let Ok(command) = env::var("LATTICEAI_DESKTOP_BACKEND_CMD") {
202
- let parts = split_command(&command);
203
- if let Some(program) = parts.first() {
204
- return BackendLaunch {
205
- command,
206
- program: program.clone(),
207
- args: parts[1..].to_vec(),
208
- cwd: env::var("LATTICEAI_DESKTOP_BACKEND_CWD").ok().map(PathBuf::from),
209
- };
210
- }
211
- }
212
-
213
- for name in ["LTCAI", "ltcai"] {
214
- if let Some(program) = command_in_path(name) {
215
- return BackendLaunch {
216
- command: format!("{program} --host 127.0.0.1 --port {port}"),
217
- program,
218
- args: vec!["--host".into(), "127.0.0.1".into(), "--port".into(), port],
219
- cwd: None,
220
- };
221
- }
222
- }
223
-
224
- for python in python_candidates() {
225
- if module_importable(&python, "latticeai.cli.entrypoint") {
226
- return BackendLaunch {
227
- command: format!("{python} -m latticeai.cli.entrypoint --host 127.0.0.1 --port {port}"),
228
- program: python,
229
- args: vec![
230
- "-m".into(),
231
- "latticeai.cli.entrypoint".into(),
232
- "--host".into(),
233
- "127.0.0.1".into(),
234
- "--port".into(),
235
- port,
236
- ],
237
- cwd: None,
238
- };
63
+ /// Bring the worker up, then point the webview at `{origin}/app`.
64
+ ///
65
+ /// The navigation waits on the worker's own `GET /health` rather than on a TCP
66
+ /// connect: a bound socket only proves something is listening, and landing on
67
+ /// `/app` before the routes exist is what used to show a blank window.
68
+ fn boot(app: &tauri::App) {
69
+ let Some(window) = app.get_webview_window("main") else {
70
+ return;
71
+ };
72
+ let _ = window.set_title("Lattice AI");
73
+ let _ = window.show();
74
+ let _ = window.set_focus();
75
+
76
+ let shell = Arc::clone(&app.state::<Arc<DesktopBackend>>());
77
+ tauri::async_runtime::spawn(async move {
78
+ let status = shell.start().await;
79
+ if !status.healthy && !shell.supervised() {
80
+ // A supervised worker already had its health gate inside `start`;
81
+ // an adopted one (LATTICEAI_DESKTOP_BACKEND_ORIGIN) still needs it.
82
+ // Waiting in both places would double the worst case.
83
+ shell.wait_until_serving().await;
239
84
  }
240
- }
241
-
242
- if let Some(resources) = bundled_python_root() {
243
- if resources.join("latticeai").join("cli").join("entrypoint.py").is_file() {
244
- if let Some(python) = python_candidates().into_iter().next() {
245
- return BackendLaunch {
246
- command: format!("{python} -m latticeai.cli.entrypoint --host 127.0.0.1 --port {port}"),
247
- program: python,
248
- args: vec![
249
- "-m".into(),
250
- "latticeai.cli.entrypoint".into(),
251
- "--host".into(),
252
- "127.0.0.1".into(),
253
- "--port".into(),
254
- port,
255
- ],
256
- cwd: Some(resources),
257
- };
258
- }
85
+ // Navigate either way, as the shell always has: a window showing the
86
+ // browser's own "cannot connect" is more use than a window showing
87
+ // nothing while a worker that will never arrive is waited for.
88
+ if let Ok(url) = tauri::Url::parse(&shell.app_url()) {
89
+ let _ = window.navigate(url);
259
90
  }
260
- }
261
-
262
- BackendLaunch {
263
- command: "unavailable: LTCAI executable or importable latticeai.cli.entrypoint module not found".to_string(),
264
- program: String::new(),
265
- args: Vec::new(),
266
- cwd: None,
267
- }
268
- }
269
-
270
- fn set_error(state: &BackendState, err: Option<String>) {
271
- if let Ok(mut last) = state.last_error.lock() {
272
- *last = err;
91
+ });
92
+ }
93
+
94
+ /// Stop the worker and the front door from a synchronous Tauri callback
95
+ /// (window close, app exit).
96
+ ///
97
+ /// `block_on` is safe here because these callbacks run on the event loop
98
+ /// thread, outside any async runtime — and the wait is what makes the shutdown
99
+ /// graceful: SIGTERM, then SIGKILL only if the worker ignores it, so the
100
+ /// worker gets to close its SQLite handles instead of being shot mid-write.
101
+ fn stop_worker<R: tauri::Runtime, M: Manager<R>>(manager: &M) {
102
+ if let Some(shell) = manager.try_state::<Arc<DesktopBackend>>() {
103
+ let shell = Arc::clone(&shell);
104
+ let stopping = Arc::clone(&shell);
105
+ tauri::async_runtime::block_on(async move {
106
+ stopping.stop().await;
107
+ });
108
+ // The gateway outlives `shutdown_backend` on purpose (so the window can
109
+ // still read `/host/status`), but not the process.
110
+ shell.stop_gateway();
273
111
  }
274
112
  }
275
113
 
276
- fn spawn_backend(origin: &str, launch: &BackendLaunch) -> Result<Option<Child>, String> {
277
- if env::var("LATTICEAI_DESKTOP_NO_BACKEND").is_ok() {
278
- return Ok(None);
279
- }
280
- if launch.program.is_empty() {
281
- return Err("Desktop backend unavailable: LTCAI executable or importable latticeai.cli.entrypoint module not found.".to_string());
282
- }
283
-
284
- let mut cmd = Command::new(&launch.program);
285
- cmd.args(&launch.args)
286
- .env("LATTICEAI_HOST", "127.0.0.1")
287
- .env("LATTICEAI_PORT", origin.rsplit(':').next().unwrap_or("8765"))
288
- .env("LATTICEAI_ENABLE_TELEGRAM", "false")
289
- .env("LATTICEAI_AUTOLOAD_MODELS", "false")
290
- .env("LATTICEAI_ALLOW_MODEL_DOWNLOADS", "false")
291
- .env("LATTICEAI_CORS_ALLOW_NETWORK", "false")
292
- .env("LATTICEAI_ENABLE_EXTERNAL_CONNECTORS", "false")
293
- .env("LATTICEAI_TUNNEL", "false")
294
- .env(
295
- "PATH",
296
- format!(
297
- "{}:{}",
298
- "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
299
- env::var("PATH").unwrap_or_default()
300
- ),
301
- );
302
- if let Some(runtime_dir) = desktop_runtime_dir() {
303
- cmd.env("LATTICEAI_AGENT_ROOT", runtime_dir.join("agent_workspace"));
304
- if launch.cwd.is_none() {
305
- cmd.current_dir(&runtime_dir);
306
- }
307
- }
308
- if let Some(python_path) = python_path_env(launch) {
309
- cmd.env("PYTHONPATH", python_path);
310
- }
311
- if let Some(cwd) = &launch.cwd {
312
- cmd.current_dir(cwd);
313
- }
314
- if let Ok(home) = env::var("HOME") {
315
- let log_dir = PathBuf::from(home).join(".ltcai");
316
- let _ = std::fs::create_dir_all(&log_dir);
317
- if let Ok(file) = OpenOptions::new().create(true).append(true).open(log_dir.join("desktop-sidecar.log")) {
318
- cmd.stdout(Stdio::from(file));
319
- } else {
320
- cmd.stdout(Stdio::null());
114
+ fn main() {
115
+ let shell = match DesktopBackend::resolve() {
116
+ Ok(shell) => Arc::new(shell),
117
+ Err(err) => {
118
+ eprintln!("lattice-ai-desktop: cannot prepare the backend: {err}");
119
+ std::process::exit(1);
321
120
  }
322
- if let Ok(file) = OpenOptions::new().create(true).append(true).open(log_dir.join("desktop-sidecar.err.log")) {
323
- cmd.stderr(Stdio::from(file));
121
+ };
122
+ eprintln!(
123
+ "lattice-ai-desktop: backend {} [{}] ({})",
124
+ shell.origin(),
125
+ shell.topology().as_str(),
126
+ if shell.supervised() {
127
+ "supervised here"
324
128
  } else {
325
- cmd.stderr(Stdio::null());
326
- }
327
- } else {
328
- cmd.stdout(Stdio::null()).stderr(Stdio::null());
329
- }
330
-
331
- cmd.spawn()
332
- .map(Some)
333
- .map_err(|err| format!("Failed to start desktop backend '{}': {}", launch.command, err))
334
- }
335
-
336
- fn kill_backend(state: &BackendState) {
337
- if let Ok(mut child) = state.child.lock() {
338
- if let Some(mut process) = child.take() {
339
- let _ = process.kill();
340
- let _ = process.wait();
341
- }
342
- }
343
- }
344
-
345
- fn status_from_state(state: &BackendState) -> BackendStatus {
346
- let mut running = false;
347
- let mut pid = None;
348
- if let Ok(mut child_slot) = state.child.lock() {
349
- if let Some(child) = child_slot.as_mut() {
350
- match child.try_wait() {
351
- Ok(Some(status)) => {
352
- set_error(state, Some(format!("Desktop backend exited with status {}", status)));
353
- *child_slot = None;
354
- }
355
- Ok(None) => {
356
- running = true;
357
- pid = Some(child.id());
358
- }
359
- Err(err) => set_error(state, Some(format!("Unable to inspect desktop backend: {}", err))),
360
- }
129
+ "external — nothing will be spawned"
361
130
  }
362
- }
363
- let last_error = state.last_error.lock().ok().and_then(|guard| guard.clone());
364
- BackendStatus {
365
- origin: state.origin.clone(),
366
- command: state.command.clone(),
367
- cwd: state.cwd.clone(),
368
- running,
369
- pid,
370
- last_error,
371
- }
372
- }
131
+ );
373
132
 
374
- fn wait_for_backend(origin: &str) {
375
- let host_port = origin
376
- .trim_start_matches("http://")
377
- .trim_start_matches("https://")
378
- .split('/')
379
- .next()
380
- .unwrap_or("127.0.0.1:8765")
381
- .to_string();
382
- for _ in 0..45 {
383
- if std::net::TcpStream::connect(&host_port).is_ok() {
384
- return;
385
- }
386
- std::thread::sleep(std::time::Duration::from_millis(500));
387
- }
388
- }
389
-
390
- fn main() {
391
- let origin = env::var("LATTICEAI_DESKTOP_BACKEND_ORIGIN")
392
- .unwrap_or_else(|_| "http://127.0.0.1:8765".to_string());
393
- let launch = backend_launch(&origin);
394
- let command = launch.command.clone();
395
- let cwd = launch.cwd.as_ref().map(|path| path.to_string_lossy().to_string());
396
- let (child, last_error) = match spawn_backend(&origin, &launch) {
397
- Ok(child) => (child, None),
398
- Err(err) => (None, Some(err)),
399
- };
400
133
  tauri::Builder::default()
401
- .manage(BackendState {
402
- origin,
403
- command,
404
- cwd,
405
- child: Mutex::new(child),
406
- last_error: Mutex::new(last_error),
407
- })
134
+ .manage(shell)
408
135
  .invoke_handler(tauri::generate_handler![
409
136
  backend_origin,
410
137
  backend_status,
@@ -413,27 +140,12 @@ fn main() {
413
140
  select_folder
414
141
  ])
415
142
  .setup(|app| {
416
- if let Some(window) = app.get_webview_window("main") {
417
- let _ = window.set_title("Lattice AI");
418
- let _ = window.show();
419
- let _ = window.set_focus();
420
- let origin = app.state::<BackendState>().origin.clone();
421
- let target = format!("{}/app", origin.trim_end_matches('/'));
422
- let window_for_nav = window.clone();
423
- std::thread::spawn(move || {
424
- wait_for_backend(&origin);
425
- if let Ok(url) = tauri::Url::parse(&target) {
426
- let _ = window_for_nav.navigate(url);
427
- }
428
- });
429
- }
143
+ boot(app);
430
144
  Ok(())
431
145
  })
432
146
  .on_window_event(|window, event| {
433
147
  if matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
434
- if let Some(state) = window.try_state::<BackendState>() {
435
- kill_backend(&state);
436
- }
148
+ stop_worker(window);
437
149
  }
438
150
  })
439
151
  .build(tauri::generate_context!())
@@ -443,9 +155,7 @@ fn main() {
443
155
  event,
444
156
  tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
445
157
  ) {
446
- if let Some(state) = app_handle.try_state::<BackendState>() {
447
- kill_backend(&state);
448
- }
158
+ stop_worker(app_handle);
449
159
  }
450
160
  });
451
161
  }