ltcai 11.4.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 (80) hide show
  1. package/README.md +48 -44
  2. package/docs/CHANGELOG.md +26 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +11 -6
  11. package/docs/v11.5.0_RUST_COMPLETE_PLAN.md +145 -0
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/runtime/multi_agent.py +1 -1
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/index_jobs.py +145 -0
  16. package/latticeai/core/legacy_compatibility.py +1 -1
  17. package/latticeai/core/marketplace.py +1 -1
  18. package/latticeai/core/messages.py +5 -0
  19. package/latticeai/core/workspace_os_constants.py +1 -1
  20. package/latticeai/runtime/build_phases/features.py +14 -0
  21. package/latticeai/services/architecture_readiness.py +1 -1
  22. package/latticeai/services/product_readiness.py +1 -1
  23. package/package.json +1 -1
  24. package/scripts/check_current_release_docs.mjs +1 -1
  25. package/scripts/check_server_i18n.mjs +1 -0
  26. package/scripts/chunking_parity_corpus.py +449 -0
  27. package/scripts/generate_agent_parity_fixtures.py +752 -0
  28. package/scripts/generate_chunking_parity_fixtures.py +259 -0
  29. package/scripts/generate_rust_parity_fixtures.py +525 -90
  30. package/scripts/release_screen_claims.json +11 -0
  31. package/src-tauri/Cargo.lock +47 -4
  32. package/src-tauri/Cargo.toml +11 -4
  33. package/src-tauri/src/backend.rs +251 -140
  34. package/src-tauri/src/main.rs +16 -4
  35. package/src-tauri/src/topology.rs +356 -0
  36. package/src-tauri/tauri.conf.json +1 -1
  37. package/static/app/asset-manifest.json +40 -40
  38. package/static/app/assets/{Act-yYpYnn0v.js → Act-CWnxSCgN.js} +1 -1
  39. package/static/app/assets/{AdminConsole-DL3Cr5pL.js → AdminConsole-BEQYU6kF.js} +1 -1
  40. package/static/app/assets/{Brain-C1HBN0Wf.js → Brain-DWu1BhFg.js} +1 -1
  41. package/static/app/assets/{BrainHome-DoXRhUUC.js → BrainHome-95Hilr9R.js} +1 -1
  42. package/static/app/assets/{BrainSignals-6yR6ir5t.js → BrainSignals-QdeqCpAF.js} +1 -1
  43. package/static/app/assets/{Capture-CFIRsFNE.js → Capture-BHpCxnzb.js} +1 -1
  44. package/static/app/assets/{Chronicle-BZbEgiwN.js → Chronicle-B4xYKoed.js} +1 -1
  45. package/static/app/assets/{CommandPalette-D2pMxC2I.js → CommandPalette-BVXnttSz.js} +1 -1
  46. package/static/app/assets/{Library-DwO3yZST.js → Library-DgYcHome.js} +1 -1
  47. package/static/app/assets/{LivingBrain-Jn1GK0-S.js → LivingBrain-CrJLDbf7.js} +1 -1
  48. package/static/app/assets/{ProductFlow-B-w1R4Oo.js → ProductFlow-DFlScKoJ.js} +1 -1
  49. package/static/app/assets/{ReviewCard-6B27X8Vg.js → ReviewCard-Cy5f48Pj.js} +1 -1
  50. package/static/app/assets/{System-DW8F-2xL.js → System-NF8IfhTa.js} +1 -1
  51. package/static/app/assets/arrow-left-DwkSYrjR.js +1 -0
  52. package/static/app/assets/{bot-IM_E_Y12.js → bot-CucuhLhm.js} +1 -1
  53. package/static/app/assets/{brain-Ci1CkWjM.js → brain-BBnSryW_.js} +1 -1
  54. package/static/app/assets/{button-COwyqfHM.js → button-C2GUj2Ai.js} +1 -1
  55. package/static/app/assets/circle-check-CxOVPwYq.js +1 -0
  56. package/static/app/assets/{circle-pause-DEM4A1Y5.js → circle-pause-CbkWzBmG.js} +1 -1
  57. package/static/app/assets/{circle-play-C9djDuLd.js → circle-play-7lEaqHdJ.js} +1 -1
  58. package/static/app/assets/{cpu-DFdo1gw-.js → cpu-DAlCXlIy.js} +1 -1
  59. package/static/app/assets/{download-SnJL6oqk.js → download-RNhuuJwh.js} +1 -1
  60. package/static/app/assets/{folder-open-CqZeDkjE.js → folder-open-CLW4odzM.js} +1 -1
  61. package/static/app/assets/{hard-drive-j1jJXYYf.js → hard-drive-NKEiDIAJ.js} +1 -1
  62. package/static/app/assets/{index-_u5iUHDr.js → index-DMurvUuR.js} +3 -3
  63. package/static/app/assets/{input-B0lPdRQZ.js → input-D2UhPC1X.js} +1 -1
  64. package/static/app/assets/{link-2-CoFbooHS.js → link-2-6amKbP_P.js} +1 -1
  65. package/static/app/assets/{permissionCopy-BsyLxtao.js → permissionCopy-Cu9TZtdR.js} +1 -1
  66. package/static/app/assets/{primitives-DEbN-d6p.js → primitives-gPsccucr.js} +1 -1
  67. package/static/app/assets/search-Cj_TKk_2.js +1 -0
  68. package/static/app/assets/{share-2-CVtZ_ewX.js → share-2-Bau7KkPq.js} +1 -1
  69. package/static/app/assets/{shield-alert-CBi2GNWM.js → shield-alert-BufNYypi.js} +1 -1
  70. package/static/app/assets/{textarea-DNMpB5ih.js → textarea-BQnVWhYs.js} +1 -1
  71. package/static/app/assets/{useFocusTrap-C83t3GXF.js → useFocusTrap-B3_w60si.js} +1 -1
  72. package/static/app/assets/{useMutation-DtbJDoyz.js → useMutation-BHhCflT6.js} +1 -1
  73. package/static/app/assets/{useQuery-Dcp1OChy.js → useQuery-rBWfI-5t.js} +1 -1
  74. package/static/app/assets/{utils-BlZr7Pd4.js → utils-V_5-wxr5.js} +1 -1
  75. package/static/app/assets/{workspace-jJY4RuAV.js → workspace-K1zjYUHj.js} +1 -1
  76. package/static/app/index.html +3 -3
  77. package/static/sw.js +1 -1
  78. package/static/app/assets/arrow-left-DXvKg9U6.js +0 -1
  79. package/static/app/assets/circle-check-DfInj-qD.js +0 -1
  80. package/static/app/assets/search-BybIWPNd.js +0 -1
@@ -2,32 +2,33 @@
2
2
  //!
3
3
  //! Through 11.3.0 this file's job was done inline in `main.rs`: four hand-rolled
4
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 now live in
6
- //! the `lattice-host` supervisor, which the `lattice-host` binary uses too — so
7
- //! the desktop and the CLI front door cannot drift apart, and the desktop
8
- //! inherits an HTTP `/health` gate, crash restart with backoff, graceful
9
- //! SIGTERM shutdown, and a port that is scanned rather than assumed.
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.
10
8
  //!
11
- //! What this module adds on top is the two things only the desktop has: the
12
- //! `LATTICEAI_DESKTOP_BACKEND_ORIGIN` override (front an existing worker,
13
- //! spawn nothing) and the `LATTICEAI_DESKTOP_NO_BACKEND` kill switch.
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).
14
20
 
15
- use std::sync::Arc;
21
+ use std::sync::{Arc, Mutex};
16
22
  use std::time::Duration;
17
23
 
24
+ use lattice_host::gateway::{bind_loopback, mounts, serve_gateway, GatewayState};
18
25
  use lattice_host::supervisor::{
19
- find_free_port, wait_for_health, HostProbe, Supervisor, SupervisorConfig, SupervisorError,
20
- SystemProbe, WorkerStatus, DEFAULT_PORT, DEFAULT_SCAN_ATTEMPTS,
26
+ wait_for_health, HostProbe, Supervisor, SupervisorConfig, SupervisorError, SystemProbe,
27
+ WorkerStatus,
21
28
  };
22
29
  use serde::Serialize;
23
30
 
24
- /// Point the shell at an already-running worker. Nothing is spawned, and this
25
- /// exact string is what `backend_origin` returns and the webview navigates to.
26
- pub const ORIGIN_ENV: &str = "LATTICEAI_DESKTOP_BACKEND_ORIGIN";
27
- /// Kill switch: open the window with no worker at all.
28
- pub const NO_BACKEND_ENV: &str = "LATTICEAI_DESKTOP_NO_BACKEND";
29
- /// An explicit worker port, honoured verbatim.
30
- pub const PORT_ENV: &str = "LATTICEAI_PORT";
31
+ use crate::topology::{self, Plan, Topology};
31
32
 
32
33
  /// The desktop shell's view of the worker.
33
34
  ///
@@ -59,49 +60,35 @@ pub struct BackendStatus {
59
60
  pub restarts: u32,
60
61
  /// Seconds since the current worker process started.
61
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,
62
75
  }
63
76
 
64
- /// The worker port: an explicit `LATTICEAI_PORT` verbatim, else 4825 scanning
65
- /// upward.
66
- ///
67
- /// An explicit port is an instruction, not a preference — if it is busy the
68
- /// shell must fail loudly on it rather than quietly answer somewhere else,
69
- /// because the whole point of setting it is that something else knows the
70
- /// number.
71
- pub fn resolve_port(probe: &dyn HostProbe) -> u16 {
72
- let pinned = probe
73
- .env_var(PORT_ENV)
74
- .and_then(|raw| raw.trim().parse::<u16>().ok())
75
- .filter(|port| *port != 0);
76
- match pinned {
77
- Some(port) => port,
78
- None => find_free_port(DEFAULT_PORT, DEFAULT_SCAN_ATTEMPTS, &[]).unwrap_or(DEFAULT_PORT),
79
- }
80
- }
81
-
82
- /// The port named by an origin, when it names one.
83
- pub fn origin_port(origin: &str) -> Option<u16> {
84
- let authority = origin
85
- .split_once("://")
86
- .map(|(_, rest)| rest)
87
- .unwrap_or(origin)
88
- .split('/')
89
- .next()
90
- .unwrap_or_default();
91
- authority
92
- .rsplit_once(':')
93
- .and_then(|(_, port)| port.trim().parse::<u16>().ok())
94
- .filter(|port| *port != 0)
77
+ /// The gateway this shell is serving, and how to stop it.
78
+ struct RunningGateway {
79
+ shutdown: Option<tokio::sync::oneshot::Sender<()>>,
95
80
  }
96
81
 
97
- /// The supervisor plus the origin every surface is told about.
82
+ /// The supervisor, the front door, and the origin every surface is told about.
98
83
  pub struct DesktopBackend {
99
- origin: String,
84
+ plan: Plan,
100
85
  supervisor: Supervisor,
101
- supervised: bool,
102
- /// The kill switch was set: there is deliberately no worker to wait for.
103
- disabled: bool,
104
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>>,
105
92
  }
106
93
 
107
94
  impl DesktopBackend {
@@ -112,53 +99,53 @@ impl DesktopBackend {
112
99
 
113
100
  /// Resolve against an injected probe.
114
101
  pub fn with_probe(probe: Arc<dyn HostProbe>) -> Result<Self, SupervisorError> {
115
- let override_origin = probe
116
- .env_var(ORIGIN_ENV)
117
- .map(|value| value.trim().to_string())
118
- .filter(|value| !value.is_empty());
119
- let disabled = probe.env_var(NO_BACKEND_ENV).is_some();
120
- let port = override_origin
121
- .as_deref()
122
- .and_then(origin_port)
123
- .unwrap_or_else(|| resolve_port(&*probe));
124
-
125
- // Either override means "do not start anything": one because a worker
126
- // already exists, the other because the operator asked for none. Only
127
- // the second means there is nothing to wait for, so an explicit origin
128
- // wins over the kill switch when both are set.
129
- let override_origin_absent = override_origin.is_none();
130
- let supervised = override_origin_absent && !disabled;
131
- let mut config = SupervisorConfig::new(port).with_system_dirs(&*probe);
132
- config.supervise = supervised;
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;
133
109
  let health_deadline = config.health_deadline;
134
110
  let supervisor = Supervisor::with_probe(config, Arc::clone(&probe))?;
135
- let origin = override_origin.unwrap_or_else(|| supervisor.worker_origin());
111
+ let jobs = (plan.topology == Topology::Gateway)
112
+ .then(|| mounts::scheduler(&plan.worker_origin, supervisor.client()));
136
113
  Ok(Self {
137
- origin,
114
+ plan,
138
115
  supervisor,
139
- supervised,
140
- disabled: disabled && override_origin_absent,
141
116
  health_deadline,
117
+ gateway: Mutex::new(None),
118
+ jobs,
142
119
  })
143
120
  }
144
121
 
145
122
  /// Where the frontend and the webview should go.
146
123
  pub fn origin(&self) -> &str {
147
- &self.origin
124
+ &self.plan.origin
148
125
  }
149
126
 
150
- /// `{origin}/app` — the URL the window navigates to once the worker answers.
127
+ /// `{origin}/app` — the URL the window navigates to once health answers.
151
128
  pub fn app_url(&self) -> String {
152
- format!("{}/app", self.origin.trim_end_matches('/'))
129
+ self.plan.app_url()
153
130
  }
154
131
 
155
132
  /// Whether this shell owns the worker process.
156
133
  pub fn supervised(&self) -> bool {
157
- self.supervised
134
+ self.plan.supervised()
135
+ }
136
+
137
+ /// Which arrangement this shell resolved.
138
+ pub fn topology(&self) -> Topology {
139
+ self.plan.topology
158
140
  }
159
141
 
160
- /// Start the worker (or, in override mode, adopt the one already running).
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.
161
147
  pub async fn start(&self) -> BackendStatus {
148
+ self.start_gateway().await;
162
149
  let outcome = self.supervisor.start().await;
163
150
  if let Err(err) = &outcome {
164
151
  // Not fatal: the supervisor keeps retrying in the background, and
@@ -168,19 +155,97 @@ impl DesktopBackend {
168
155
  self.status().await
169
156
  }
170
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
+
171
234
  /// Wait for `GET {origin}/health`, so the webview never lands on a page the
172
235
  /// worker has not started serving yet.
173
236
  ///
174
237
  /// Replaces the old TCP-connect probe, which proved only that *something*
175
- /// had bound the port. With the kill switch on there is nothing to wait for
176
- /// and this returns immediately.
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.
177
242
  pub async fn wait_until_serving(&self) -> bool {
178
- if self.disabled {
243
+ if self.plan.disabled {
179
244
  return false;
180
245
  }
181
246
  wait_for_health(
182
247
  &self.supervisor.client(),
183
- &self.origin,
248
+ &self.plan.origin,
184
249
  Duration::from_millis(250),
185
250
  self.health_deadline,
186
251
  )
@@ -196,7 +261,8 @@ impl DesktopBackend {
196
261
  status
197
262
  }
198
263
 
199
- /// Stop the worker and suppress restarts.
264
+ /// Stop the worker and suppress restarts. The gateway keeps serving, so
265
+ /// `/host/status` still answers and the window can say what happened.
200
266
  pub async fn stop(&self) -> BackendStatus {
201
267
  let status = self.supervisor.stop().await;
202
268
  self.render(&status)
@@ -210,13 +276,22 @@ impl DesktopBackend {
210
276
  self.status().await
211
277
  }
212
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
+
213
288
  fn snapshot(&self) -> BackendStatus {
214
289
  self.render(&self.supervisor.status())
215
290
  }
216
291
 
217
292
  fn render(&self, status: &WorkerStatus) -> BackendStatus {
218
293
  BackendStatus {
219
- origin: self.origin.clone(),
294
+ origin: self.plan.origin.clone(),
220
295
  command: self.command_text(status),
221
296
  cwd: status.cwd.clone(),
222
297
  running: status.running,
@@ -227,6 +302,14 @@ impl DesktopBackend {
227
302
  supervised: status.supervised,
228
303
  restarts: status.restarts,
229
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),
230
313
  }
231
314
  }
232
315
 
@@ -236,8 +319,11 @@ impl DesktopBackend {
236
319
  if let Some(command) = &status.command {
237
320
  return command.clone();
238
321
  }
239
- if !self.supervised {
240
- return format!("external worker at {} (not started here)", self.origin);
322
+ if !self.supervised() {
323
+ return format!(
324
+ "external worker at {} (not started here)",
325
+ self.plan.worker_origin
326
+ );
241
327
  }
242
328
  match &status.last_error {
243
329
  Some(error) => format!("unavailable: {error}"),
@@ -249,101 +335,126 @@ impl DesktopBackend {
249
335
  #[cfg(test)]
250
336
  mod tests {
251
337
  use super::*;
338
+ use crate::topology::{DIRECT_ENV, NO_BACKEND_ENV, ORIGIN_ENV, PORT_ENV};
252
339
  use lattice_host::supervisor::StaticProbe;
253
340
 
254
- #[test]
255
- fn an_origin_names_its_port_or_admits_it_does_not() {
256
- assert_eq!(origin_port("http://127.0.0.1:8765"), Some(8765));
257
- assert_eq!(origin_port("http://127.0.0.1:8765/app"), Some(8765));
258
- assert_eq!(origin_port("https://[::1]:4825"), Some(4825));
259
- assert_eq!(origin_port("127.0.0.1:4899"), Some(4899));
260
- assert_eq!(origin_port("http://localhost"), None);
261
- assert_eq!(origin_port("http://[::1]"), None);
262
- assert_eq!(origin_port("http://127.0.0.1:0"), None);
263
- assert_eq!(origin_port(""), None);
341
+ fn backend(probe: StaticProbe) -> DesktopBackend {
342
+ DesktopBackend::with_probe(Arc::new(probe)).expect("backend")
264
343
  }
265
344
 
266
345
  #[test]
267
- fn an_explicit_port_is_honoured_verbatim() {
268
- let probe = StaticProbe::new().with_env(PORT_ENV, " 8765 ");
269
- assert_eq!(resolve_port(&probe), 8765);
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
+ );
270
356
  }
271
357
 
272
358
  #[test]
273
- fn without_a_pinned_port_the_scan_starts_at_the_unified_default() {
274
- let port = resolve_port(&StaticProbe::new());
275
- assert!(
276
- (DEFAULT_PORT..DEFAULT_PORT + DEFAULT_SCAN_ATTEMPTS).contains(&port),
277
- "expected a port scanned upward from {DEFAULT_PORT}, got {port}"
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"),
278
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");
279
369
  }
280
370
 
281
371
  #[test]
282
372
  fn the_origin_override_pins_the_origin_and_spawns_nothing() {
283
- let probe = StaticProbe::new().with_env(ORIGIN_ENV, "http://127.0.0.1:9100/");
284
- let backend = DesktopBackend::with_probe(Arc::new(probe)).expect("backend");
285
- assert_eq!(backend.origin(), "http://127.0.0.1:9100/");
286
- assert_eq!(backend.app_url(), "http://127.0.0.1:9100/app");
287
- assert!(!backend.supervised());
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);
288
378
  }
289
379
 
290
380
  #[test]
291
381
  fn the_kill_switch_spawns_nothing_but_still_names_an_origin() {
292
- let probe = StaticProbe::new().with_env(NO_BACKEND_ENV, "1");
293
- let backend = DesktopBackend::with_probe(Arc::new(probe)).expect("backend");
294
- assert!(!backend.supervised());
295
- assert!(
296
- backend.disabled,
297
- "nothing will ever answer, so nothing waits"
298
- );
299
- assert!(backend.origin().starts_with("http://127.0.0.1:"));
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:"));
300
386
  }
301
387
 
302
388
  #[test]
303
389
  fn an_explicit_origin_outranks_the_kill_switch() {
304
- let probe = StaticProbe::new()
305
- .with_env(NO_BACKEND_ENV, "1")
306
- .with_env(ORIGIN_ENV, "http://127.0.0.1:9100");
307
- let backend = DesktopBackend::with_probe(Arc::new(probe)).expect("backend");
308
- assert!(!backend.supervised(), "still nothing is spawned");
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");
309
396
  assert!(
310
- !backend.disabled,
311
- "a worker was named, so the shell waits for it"
397
+ !shell.plan.disabled,
398
+ "a worker was named, so the shell waits"
312
399
  );
313
400
  }
314
401
 
315
- #[test]
316
- fn the_default_shell_supervises_its_own_worker() {
317
- let backend = DesktopBackend::with_probe(Arc::new(StaticProbe::new())).expect("backend");
318
- assert!(backend.supervised());
319
- assert!(backend.app_url().ends_with("/app"));
320
- }
321
-
322
402
  #[test]
323
403
  fn an_unresolved_command_says_why_instead_of_going_blank() {
324
- let backend = DesktopBackend::with_probe(Arc::new(StaticProbe::new())).expect("backend");
404
+ let shell = backend(StaticProbe::new());
325
405
  let mut status = WorkerStatus::idle(4825, true);
326
- assert_eq!(backend.command_text(&status), "not resolved yet");
406
+ assert_eq!(shell.command_text(&status), "not resolved yet");
327
407
  status.last_error = Some("worker unavailable".into());
328
408
  assert_eq!(
329
- backend.command_text(&status),
409
+ shell.command_text(&status),
330
410
  "unavailable: worker unavailable"
331
411
  );
332
412
  status.command = Some("/usr/bin/python3 -m latticeai.cli.entrypoint".into());
333
413
  assert_eq!(
334
- backend.command_text(&status),
414
+ shell.command_text(&status),
335
415
  "/usr/bin/python3 -m latticeai.cli.entrypoint"
336
416
  );
337
417
  }
338
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
+
339
428
  #[test]
340
429
  fn the_status_keeps_every_field_the_frontend_has_ever_read() {
341
- let backend = DesktopBackend::with_probe(Arc::new(StaticProbe::new())).expect("backend");
342
- let rendered = backend.render(&WorkerStatus::idle(4825, true));
430
+ let shell = backend(StaticProbe::new().with_env(PORT_ENV, "41842"));
431
+ let rendered = shell.render(&WorkerStatus::idle(41843, true));
343
432
  let value = serde_json::to_value(&rendered).expect("serialise");
344
433
  for key in ["origin", "command", "cwd", "running", "pid", "last_error"] {
345
434
  assert!(value.get(key).is_some(), "missing legacy field {key}");
346
435
  }
347
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();
348
459
  }
349
460
  }
@@ -8,12 +8,18 @@
8
8
  //! Tauri's: five commands, one window, and the promise that closing the window
9
9
  //! stops the worker.
10
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
+ //!
11
15
  //! The five command names and their response shapes are unchanged — the
12
16
  //! frontend (`frontend/src/api/base.ts`, `frontend/src/api/client.ts`) invokes
13
- //! them by name and reads `backend_status` as a plain record.
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.
14
19
 
15
20
  mod backend;
16
21
  mod folder;
22
+ mod topology;
17
23
 
18
24
  use std::sync::Arc;
19
25
 
@@ -85,7 +91,8 @@ fn boot(app: &tauri::App) {
85
91
  });
86
92
  }
87
93
 
88
- /// Stop the worker from a synchronous Tauri callback (window close, app exit).
94
+ /// Stop the worker and the front door from a synchronous Tauri callback
95
+ /// (window close, app exit).
89
96
  ///
90
97
  /// `block_on` is safe here because these callbacks run on the event loop
91
98
  /// thread, outside any async runtime — and the wait is what makes the shutdown
@@ -94,9 +101,13 @@ fn boot(app: &tauri::App) {
94
101
  fn stop_worker<R: tauri::Runtime, M: Manager<R>>(manager: &M) {
95
102
  if let Some(shell) = manager.try_state::<Arc<DesktopBackend>>() {
96
103
  let shell = Arc::clone(&shell);
104
+ let stopping = Arc::clone(&shell);
97
105
  tauri::async_runtime::block_on(async move {
98
- shell.stop().await;
106
+ stopping.stop().await;
99
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();
100
111
  }
101
112
  }
102
113
 
@@ -109,8 +120,9 @@ fn main() {
109
120
  }
110
121
  };
111
122
  eprintln!(
112
- "lattice-ai-desktop: backend {} ({})",
123
+ "lattice-ai-desktop: backend {} [{}] ({})",
113
124
  shell.origin(),
125
+ shell.topology().as_str(),
114
126
  if shell.supervised() {
115
127
  "supervised here"
116
128
  } else {