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
@@ -0,0 +1,460 @@
1
+ //! What the desktop shell asks of `lattice-host`.
2
+ //!
3
+ //! Through 11.3.0 this file's job was done inline in `main.rs`: four hand-rolled
4
+ //! command-resolution rules, a `Command::spawn`, a TCP-connect probe, and a
5
+ //! restart that killed the child and spawned another one. All four moved into
6
+ //! the `lattice-host` supervisor in 11.4.0, which the `lattice-host` binary uses
7
+ //! too — so the desktop and the CLI front door cannot drift apart.
8
+ //!
9
+ //! 11.5.0 finishes the job: the shell no longer only *supervises* a worker, it
10
+ //! **is** the front door. In the default topology it serves `lattice-host`'s
11
+ //! gateway on the public port — the `/host/*` status routes, the native
12
+ //! `/rust/*` retrieval, ingest and agent-kernel surfaces, the `/host/jobs`
13
+ //! scheduler — and the worker runs on an internal port behind it. The webview
14
+ //! navigates to the gateway, and the worker is told to trust that origin so
15
+ //! cookie-authenticated writes still work through the proxy hop.
16
+ //!
17
+ //! Which arrangement is chosen lives in [`crate::topology`]; what this module
18
+ //! adds is the running of it, and the five IPC contracts on top (unchanged in
19
+ //! name, type and meaning — the new status fields are additive).
20
+
21
+ use std::sync::{Arc, Mutex};
22
+ use std::time::Duration;
23
+
24
+ use lattice_host::gateway::{bind_loopback, mounts, serve_gateway, GatewayState};
25
+ use lattice_host::supervisor::{
26
+ wait_for_health, HostProbe, Supervisor, SupervisorConfig, SupervisorError, SystemProbe,
27
+ WorkerStatus,
28
+ };
29
+ use serde::Serialize;
30
+
31
+ use crate::topology::{self, Plan, Topology};
32
+
33
+ /// The desktop shell's view of the worker.
34
+ ///
35
+ /// The first six fields are the contract `latticeApi.desktopBackendStatus`
36
+ /// has consumed since the first shell and are unchanged in name, type and
37
+ /// meaning. The rest are additive: facts the ad-hoc shell could not know and
38
+ /// the supervisor does.
39
+ #[derive(Debug, Clone, Serialize)]
40
+ pub struct BackendStatus {
41
+ /// Where the frontend should send its requests.
42
+ pub origin: String,
43
+ /// The resolved command line, or why there is not one.
44
+ pub command: String,
45
+ /// Working directory of the worker, when the resolution rule set one.
46
+ pub cwd: Option<String>,
47
+ /// Whether a worker process is alive right now.
48
+ pub running: bool,
49
+ /// Its OS process id, when running.
50
+ pub pid: Option<u32>,
51
+ /// The last failure observed, if any.
52
+ pub last_error: Option<String>,
53
+ /// Whether `GET /health` answered 2xx on the most recent probe.
54
+ pub healthy: bool,
55
+ /// The chosen port — authoritative, not the configured preference.
56
+ pub port: u16,
57
+ /// `false` when the shell only fronts a worker it did not start.
58
+ pub supervised: bool,
59
+ /// How many times the supervisor restarted the worker after a crash.
60
+ pub restarts: u32,
61
+ /// Seconds since the current worker process started.
62
+ pub uptime_seconds: Option<u64>,
63
+ /// Which arrangement this shell is running: `gateway`, `direct`,
64
+ /// `external` or `disabled` (11.5.0, additive).
65
+ pub topology: String,
66
+ /// Where the worker itself answers. Equal to `origin` unless a gateway is
67
+ /// in front of it (11.5.0, additive).
68
+ pub worker_origin: String,
69
+ /// The gateway's origin when one is served here, else `null`
70
+ /// (11.5.0, additive).
71
+ pub gateway_origin: Option<String>,
72
+ /// Whether the background jobs timer is running in this process
73
+ /// (11.5.0, additive).
74
+ pub jobs_running: bool,
75
+ }
76
+
77
+ /// The gateway this shell is serving, and how to stop it.
78
+ struct RunningGateway {
79
+ shutdown: Option<tokio::sync::oneshot::Sender<()>>,
80
+ }
81
+
82
+ /// The supervisor, the front door, and the origin every surface is told about.
83
+ pub struct DesktopBackend {
84
+ plan: Plan,
85
+ supervisor: Supervisor,
86
+ health_deadline: Duration,
87
+ /// Present once the gateway is serving. `Mutex` because the Tauri commands
88
+ /// touch it from whichever thread the runtime hands them.
89
+ gateway: Mutex<Option<RunningGateway>>,
90
+ /// The jobs scheduler, mounted on `/host/jobs` in the gateway topology.
91
+ jobs: Option<Arc<lattice_jobs::Scheduler>>,
92
+ }
93
+
94
+ impl DesktopBackend {
95
+ /// Resolve against the real machine.
96
+ pub fn resolve() -> Result<Self, SupervisorError> {
97
+ Self::with_probe(Arc::new(SystemProbe))
98
+ }
99
+
100
+ /// Resolve against an injected probe.
101
+ pub fn with_probe(probe: Arc<dyn HostProbe>) -> Result<Self, SupervisorError> {
102
+ let plan = topology::resolve(&*probe);
103
+ let mut config = SupervisorConfig::new(plan.worker_port).with_system_dirs(&*probe);
104
+ config.supervise = plan.supervised();
105
+ // Behind a gateway the worker's own CSRF default knows only its
106
+ // internal port; naming the front door here is what makes a
107
+ // cookie-authenticated POST through the proxy work at all.
108
+ config.gateway_port = plan.gateway_port;
109
+ let health_deadline = config.health_deadline;
110
+ let supervisor = Supervisor::with_probe(config, Arc::clone(&probe))?;
111
+ let jobs = (plan.topology == Topology::Gateway)
112
+ .then(|| mounts::scheduler(&plan.worker_origin, supervisor.client()));
113
+ Ok(Self {
114
+ plan,
115
+ supervisor,
116
+ health_deadline,
117
+ gateway: Mutex::new(None),
118
+ jobs,
119
+ })
120
+ }
121
+
122
+ /// Where the frontend and the webview should go.
123
+ pub fn origin(&self) -> &str {
124
+ &self.plan.origin
125
+ }
126
+
127
+ /// `{origin}/app` — the URL the window navigates to once health answers.
128
+ pub fn app_url(&self) -> String {
129
+ self.plan.app_url()
130
+ }
131
+
132
+ /// Whether this shell owns the worker process.
133
+ pub fn supervised(&self) -> bool {
134
+ self.plan.supervised()
135
+ }
136
+
137
+ /// Which arrangement this shell resolved.
138
+ pub fn topology(&self) -> Topology {
139
+ self.plan.topology
140
+ }
141
+
142
+ /// Start the front door, then the worker.
143
+ ///
144
+ /// The gateway comes up first on purpose: it is what the webview polls, and
145
+ /// a front door that answers "worker not up yet" is more use than a
146
+ /// connection refused.
147
+ pub async fn start(&self) -> BackendStatus {
148
+ self.start_gateway().await;
149
+ let outcome = self.supervisor.start().await;
150
+ if let Err(err) = &outcome {
151
+ // Not fatal: the supervisor keeps retrying in the background, and
152
+ // the window must open either way so the person can read why.
153
+ eprintln!("lattice-ai-desktop: worker did not come up: {err}");
154
+ }
155
+ self.status().await
156
+ }
157
+
158
+ /// Serve the gateway on the public port, once.
159
+ ///
160
+ /// A failure here is logged rather than fatal — the window still opens, and
161
+ /// the browser's own "cannot connect" plus this line is a better failure
162
+ /// than a shell that exits. `LATTICEAI_DESKTOP_DIRECT=1` is the way out.
163
+ async fn start_gateway(&self) {
164
+ let Some(port) = self.plan.gateway_port else {
165
+ return;
166
+ };
167
+ if self.gateway.lock().expect("gateway lock").is_some() {
168
+ return;
169
+ }
170
+ let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
171
+ let listener = match bind_loopback(addr).await {
172
+ Ok(listener) => listener,
173
+ Err(err) => {
174
+ eprintln!(
175
+ "lattice-ai-desktop: the gateway could not bind {addr}: {err} — \
176
+ set LATTICEAI_DESKTOP_DIRECT=1 to run the worker as the front door"
177
+ );
178
+ return;
179
+ }
180
+ };
181
+
182
+ let mut state =
183
+ GatewayState::with_client(Arc::new(self.supervisor.clone()), self.supervisor.client());
184
+ if let Some(root) = self.agent_root() {
185
+ state = state.with_agent_root(root);
186
+ }
187
+ if let Some(scheduler) = &self.jobs {
188
+ state = state.with_jobs(Arc::clone(scheduler));
189
+ }
190
+
191
+ let (tx, rx) = tokio::sync::oneshot::channel::<()>();
192
+ tauri::async_runtime::spawn(async move {
193
+ if let Err(err) = serve_gateway(listener, Arc::new(state), async {
194
+ let _ = rx.await;
195
+ })
196
+ .await
197
+ {
198
+ eprintln!("lattice-ai-desktop: the gateway stopped: {err}");
199
+ }
200
+ });
201
+ *self.gateway.lock().expect("gateway lock") = Some(RunningGateway { shutdown: Some(tx) });
202
+ eprintln!("lattice-ai-desktop: gateway listening on {}", self.origin());
203
+
204
+ self.start_jobs();
205
+ }
206
+
207
+ /// Start the background jobs timer, unless it is switched off.
208
+ ///
209
+ /// It keeps running across `shutdown_backend`/`restart_backend`: a tick
210
+ /// against a stopped worker fails, backs off, and is picked up again when
211
+ /// the worker returns — which is exactly what a scheduler should do, and
212
+ /// cheaper than a second lifecycle to keep in step.
213
+ fn start_jobs(&self) {
214
+ let Some(scheduler) = &self.jobs else {
215
+ return;
216
+ };
217
+ if !mounts::jobs_enabled_from_env() {
218
+ eprintln!("lattice-ai-desktop: jobs scheduler off (LATTICEAI_JOBS); /host/jobs is manual only");
219
+ return;
220
+ }
221
+ let interval = scheduler.config().interval.as_secs();
222
+ Arc::clone(scheduler).spawn(std::future::pending::<()>());
223
+ eprintln!("lattice-ai-desktop: jobs scheduler every {interval}s");
224
+ }
225
+
226
+ /// The agent workspace the worker will be given, so the host's native
227
+ /// kernel judges the same directory.
228
+ fn agent_root(&self) -> Option<std::path::PathBuf> {
229
+ SupervisorConfig::new(self.plan.worker_port)
230
+ .with_system_dirs(&SystemProbe)
231
+ .agent_root()
232
+ }
233
+
234
+ /// Wait for `GET {origin}/health`, so the webview never lands on a page the
235
+ /// worker has not started serving yet.
236
+ ///
237
+ /// Replaces the old TCP-connect probe, which proved only that *something*
238
+ /// had bound the port. In the gateway topology this probes through the
239
+ /// proxy, which is the honest question: can the page the window is about to
240
+ /// open reach a healthy worker? With the kill switch on there is nothing to
241
+ /// wait for and this returns immediately.
242
+ pub async fn wait_until_serving(&self) -> bool {
243
+ if self.plan.disabled {
244
+ return false;
245
+ }
246
+ wait_for_health(
247
+ &self.supervisor.client(),
248
+ &self.plan.origin,
249
+ Duration::from_millis(250),
250
+ self.health_deadline,
251
+ )
252
+ .await
253
+ .is_ok()
254
+ }
255
+
256
+ /// A live snapshot, including a fresh health probe.
257
+ pub async fn status(&self) -> BackendStatus {
258
+ let healthy = self.supervisor.probe_health().await;
259
+ let mut status = self.snapshot();
260
+ status.healthy = healthy;
261
+ status
262
+ }
263
+
264
+ /// Stop the worker and suppress restarts. The gateway keeps serving, so
265
+ /// `/host/status` still answers and the window can say what happened.
266
+ pub async fn stop(&self) -> BackendStatus {
267
+ let status = self.supervisor.stop().await;
268
+ self.render(&status)
269
+ }
270
+
271
+ /// Stop then start again, preserving the restart counter.
272
+ pub async fn restart(&self) -> BackendStatus {
273
+ if let Err(err) = self.supervisor.restart().await {
274
+ eprintln!("lattice-ai-desktop: restart failed: {err}");
275
+ }
276
+ self.status().await
277
+ }
278
+
279
+ /// Stop the gateway too. Called on the way out of the process.
280
+ pub fn stop_gateway(&self) {
281
+ if let Some(running) = self.gateway.lock().expect("gateway lock").as_mut() {
282
+ if let Some(tx) = running.shutdown.take() {
283
+ let _ = tx.send(());
284
+ }
285
+ }
286
+ }
287
+
288
+ fn snapshot(&self) -> BackendStatus {
289
+ self.render(&self.supervisor.status())
290
+ }
291
+
292
+ fn render(&self, status: &WorkerStatus) -> BackendStatus {
293
+ BackendStatus {
294
+ origin: self.plan.origin.clone(),
295
+ command: self.command_text(status),
296
+ cwd: status.cwd.clone(),
297
+ running: status.running,
298
+ pid: status.pid,
299
+ last_error: status.last_error.clone(),
300
+ healthy: status.healthy,
301
+ port: status.port,
302
+ supervised: status.supervised,
303
+ restarts: status.restarts,
304
+ uptime_seconds: status.uptime_seconds,
305
+ topology: self.plan.topology.as_str().to_string(),
306
+ worker_origin: self.plan.worker_origin.clone(),
307
+ gateway_origin: self.plan.gateway_port.map(|_| self.plan.origin.clone()),
308
+ jobs_running: self
309
+ .jobs
310
+ .as_ref()
311
+ .map(|scheduler| scheduler.is_running())
312
+ .unwrap_or(false),
313
+ }
314
+ }
315
+
316
+ /// `command` has always been a plain string, never null, so an unresolved
317
+ /// worker says *why* rather than disappearing from the panel.
318
+ fn command_text(&self, status: &WorkerStatus) -> String {
319
+ if let Some(command) = &status.command {
320
+ return command.clone();
321
+ }
322
+ if !self.supervised() {
323
+ return format!(
324
+ "external worker at {} (not started here)",
325
+ self.plan.worker_origin
326
+ );
327
+ }
328
+ match &status.last_error {
329
+ Some(error) => format!("unavailable: {error}"),
330
+ None => "not resolved yet".to_string(),
331
+ }
332
+ }
333
+ }
334
+
335
+ #[cfg(test)]
336
+ mod tests {
337
+ use super::*;
338
+ use crate::topology::{DIRECT_ENV, NO_BACKEND_ENV, ORIGIN_ENV, PORT_ENV};
339
+ use lattice_host::supervisor::StaticProbe;
340
+
341
+ fn backend(probe: StaticProbe) -> DesktopBackend {
342
+ DesktopBackend::with_probe(Arc::new(probe)).expect("backend")
343
+ }
344
+
345
+ #[test]
346
+ fn the_default_shell_is_the_gateway_front_door() {
347
+ let shell = backend(StaticProbe::new().with_env(PORT_ENV, "41840"));
348
+ assert_eq!(shell.topology(), Topology::Gateway);
349
+ assert_eq!(shell.origin(), "http://127.0.0.1:41840");
350
+ assert_eq!(shell.app_url(), "http://127.0.0.1:41840/app");
351
+ assert!(shell.supervised());
352
+ assert_ne!(
353
+ shell.plan.worker_origin, shell.plan.origin,
354
+ "the worker lives behind the front door"
355
+ );
356
+ }
357
+
358
+ #[test]
359
+ fn the_direct_hatch_restores_the_previous_topology() {
360
+ let shell = backend(
361
+ StaticProbe::new()
362
+ .with_env(DIRECT_ENV, "1")
363
+ .with_env(PORT_ENV, "41841"),
364
+ );
365
+ assert_eq!(shell.topology(), Topology::Direct);
366
+ assert_eq!(shell.origin(), "http://127.0.0.1:41841");
367
+ assert_eq!(shell.plan.worker_origin, shell.plan.origin);
368
+ assert!(shell.jobs.is_none(), "no gateway, no /host/jobs to drive");
369
+ }
370
+
371
+ #[test]
372
+ fn the_origin_override_pins_the_origin_and_spawns_nothing() {
373
+ let shell = backend(StaticProbe::new().with_env(ORIGIN_ENV, "http://127.0.0.1:9100/"));
374
+ assert_eq!(shell.origin(), "http://127.0.0.1:9100/");
375
+ assert_eq!(shell.app_url(), "http://127.0.0.1:9100/app");
376
+ assert!(!shell.supervised());
377
+ assert_eq!(shell.topology(), Topology::External);
378
+ }
379
+
380
+ #[test]
381
+ fn the_kill_switch_spawns_nothing_but_still_names_an_origin() {
382
+ let shell = backend(StaticProbe::new().with_env(NO_BACKEND_ENV, "1"));
383
+ assert!(!shell.supervised());
384
+ assert!(shell.plan.disabled, "nothing will answer, so nothing waits");
385
+ assert!(shell.origin().starts_with("http://127.0.0.1:"));
386
+ }
387
+
388
+ #[test]
389
+ fn an_explicit_origin_outranks_the_kill_switch() {
390
+ let shell = backend(
391
+ StaticProbe::new()
392
+ .with_env(NO_BACKEND_ENV, "1")
393
+ .with_env(ORIGIN_ENV, "http://127.0.0.1:9100"),
394
+ );
395
+ assert!(!shell.supervised(), "still nothing is spawned");
396
+ assert!(
397
+ !shell.plan.disabled,
398
+ "a worker was named, so the shell waits"
399
+ );
400
+ }
401
+
402
+ #[test]
403
+ fn an_unresolved_command_says_why_instead_of_going_blank() {
404
+ let shell = backend(StaticProbe::new());
405
+ let mut status = WorkerStatus::idle(4825, true);
406
+ assert_eq!(shell.command_text(&status), "not resolved yet");
407
+ status.last_error = Some("worker unavailable".into());
408
+ assert_eq!(
409
+ shell.command_text(&status),
410
+ "unavailable: worker unavailable"
411
+ );
412
+ status.command = Some("/usr/bin/python3 -m latticeai.cli.entrypoint".into());
413
+ assert_eq!(
414
+ shell.command_text(&status),
415
+ "/usr/bin/python3 -m latticeai.cli.entrypoint"
416
+ );
417
+ }
418
+
419
+ #[test]
420
+ fn an_external_worker_is_named_by_its_own_origin() {
421
+ let shell = backend(StaticProbe::new().with_env(ORIGIN_ENV, "http://127.0.0.1:9100"));
422
+ assert_eq!(
423
+ shell.command_text(&WorkerStatus::idle(9100, false)),
424
+ "external worker at http://127.0.0.1:9100 (not started here)"
425
+ );
426
+ }
427
+
428
+ #[test]
429
+ fn the_status_keeps_every_field_the_frontend_has_ever_read() {
430
+ let shell = backend(StaticProbe::new().with_env(PORT_ENV, "41842"));
431
+ let rendered = shell.render(&WorkerStatus::idle(41843, true));
432
+ let value = serde_json::to_value(&rendered).expect("serialise");
433
+ for key in ["origin", "command", "cwd", "running", "pid", "last_error"] {
434
+ assert!(value.get(key).is_some(), "missing legacy field {key}");
435
+ }
436
+ assert!(value["command"].is_string(), "command is never null");
437
+ // …and the additive ones describe the new arrangement honestly.
438
+ assert_eq!(value["topology"], "gateway");
439
+ assert_eq!(value["gateway_origin"], "http://127.0.0.1:41842");
440
+ assert_eq!(value["worker_origin"], shell.plan.worker_origin);
441
+ assert_eq!(value["jobs_running"], false, "mounted is not started");
442
+ }
443
+
444
+ #[test]
445
+ fn a_direct_shell_reports_no_gateway_origin() {
446
+ let shell = backend(StaticProbe::new().with_env(DIRECT_ENV, "1"));
447
+ let value =
448
+ serde_json::to_value(shell.render(&WorkerStatus::idle(4825, true))).expect("serialise");
449
+ assert_eq!(value["topology"], "direct");
450
+ assert!(value["gateway_origin"].is_null());
451
+ assert_eq!(value["worker_origin"], value["origin"]);
452
+ }
453
+
454
+ #[test]
455
+ fn stopping_a_gateway_that_never_started_is_a_no_op() {
456
+ let shell = backend(StaticProbe::new().with_env(DIRECT_ENV, "1"));
457
+ shell.stop_gateway();
458
+ shell.stop_gateway();
459
+ }
460
+ }
@@ -0,0 +1,33 @@
1
+ //! The native folder picker behind the `select_folder` command.
2
+ //!
3
+ //! Unchanged from the pre-11.4.0 shell: macOS gets an AppleScript `choose
4
+ //! folder` dialog, every other platform answers `None` and the frontend falls
5
+ //! back to its Electron bridge or to typing a path.
6
+
7
+ /// Ask the user for a directory. `None` when they cancel, or when the platform
8
+ /// has no picker here.
9
+ #[cfg(target_os = "macos")]
10
+ pub fn select_folder() -> Option<String> {
11
+ let output = std::process::Command::new("osascript")
12
+ .args([
13
+ "-e",
14
+ r#"POSIX path of (choose folder with prompt "Choose a folder for Lattice AI")"#,
15
+ ])
16
+ .output()
17
+ .ok()?;
18
+ if !output.status.success() {
19
+ return None;
20
+ }
21
+ let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
22
+ if path.is_empty() {
23
+ None
24
+ } else {
25
+ Some(path)
26
+ }
27
+ }
28
+
29
+ /// No native picker outside macOS; the caller falls back.
30
+ #[cfg(not(target_os = "macos"))]
31
+ pub fn select_folder() -> Option<String> {
32
+ None
33
+ }