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.
- package/README.md +48 -44
- package/docs/CHANGELOG.md +26 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +11 -6
- package/docs/v11.5.0_RUST_COMPLETE_PLAN.md +145 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/index_jobs.py +145 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +5 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/runtime/build_phases/features.py +14 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/chunking_parity_corpus.py +449 -0
- package/scripts/generate_agent_parity_fixtures.py +752 -0
- package/scripts/generate_chunking_parity_fixtures.py +259 -0
- package/scripts/generate_rust_parity_fixtures.py +525 -90
- package/scripts/release_screen_claims.json +11 -0
- package/src-tauri/Cargo.lock +47 -4
- package/src-tauri/Cargo.toml +11 -4
- package/src-tauri/src/backend.rs +251 -140
- package/src-tauri/src/main.rs +16 -4
- package/src-tauri/src/topology.rs +356 -0
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +40 -40
- package/static/app/assets/{Act-yYpYnn0v.js → Act-CWnxSCgN.js} +1 -1
- package/static/app/assets/{AdminConsole-DL3Cr5pL.js → AdminConsole-BEQYU6kF.js} +1 -1
- package/static/app/assets/{Brain-C1HBN0Wf.js → Brain-DWu1BhFg.js} +1 -1
- package/static/app/assets/{BrainHome-DoXRhUUC.js → BrainHome-95Hilr9R.js} +1 -1
- package/static/app/assets/{BrainSignals-6yR6ir5t.js → BrainSignals-QdeqCpAF.js} +1 -1
- package/static/app/assets/{Capture-CFIRsFNE.js → Capture-BHpCxnzb.js} +1 -1
- package/static/app/assets/{Chronicle-BZbEgiwN.js → Chronicle-B4xYKoed.js} +1 -1
- package/static/app/assets/{CommandPalette-D2pMxC2I.js → CommandPalette-BVXnttSz.js} +1 -1
- package/static/app/assets/{Library-DwO3yZST.js → Library-DgYcHome.js} +1 -1
- package/static/app/assets/{LivingBrain-Jn1GK0-S.js → LivingBrain-CrJLDbf7.js} +1 -1
- package/static/app/assets/{ProductFlow-B-w1R4Oo.js → ProductFlow-DFlScKoJ.js} +1 -1
- package/static/app/assets/{ReviewCard-6B27X8Vg.js → ReviewCard-Cy5f48Pj.js} +1 -1
- package/static/app/assets/{System-DW8F-2xL.js → System-NF8IfhTa.js} +1 -1
- package/static/app/assets/arrow-left-DwkSYrjR.js +1 -0
- package/static/app/assets/{bot-IM_E_Y12.js → bot-CucuhLhm.js} +1 -1
- package/static/app/assets/{brain-Ci1CkWjM.js → brain-BBnSryW_.js} +1 -1
- package/static/app/assets/{button-COwyqfHM.js → button-C2GUj2Ai.js} +1 -1
- package/static/app/assets/circle-check-CxOVPwYq.js +1 -0
- package/static/app/assets/{circle-pause-DEM4A1Y5.js → circle-pause-CbkWzBmG.js} +1 -1
- package/static/app/assets/{circle-play-C9djDuLd.js → circle-play-7lEaqHdJ.js} +1 -1
- package/static/app/assets/{cpu-DFdo1gw-.js → cpu-DAlCXlIy.js} +1 -1
- package/static/app/assets/{download-SnJL6oqk.js → download-RNhuuJwh.js} +1 -1
- package/static/app/assets/{folder-open-CqZeDkjE.js → folder-open-CLW4odzM.js} +1 -1
- package/static/app/assets/{hard-drive-j1jJXYYf.js → hard-drive-NKEiDIAJ.js} +1 -1
- package/static/app/assets/{index-_u5iUHDr.js → index-DMurvUuR.js} +3 -3
- package/static/app/assets/{input-B0lPdRQZ.js → input-D2UhPC1X.js} +1 -1
- package/static/app/assets/{link-2-CoFbooHS.js → link-2-6amKbP_P.js} +1 -1
- package/static/app/assets/{permissionCopy-BsyLxtao.js → permissionCopy-Cu9TZtdR.js} +1 -1
- package/static/app/assets/{primitives-DEbN-d6p.js → primitives-gPsccucr.js} +1 -1
- package/static/app/assets/search-Cj_TKk_2.js +1 -0
- package/static/app/assets/{share-2-CVtZ_ewX.js → share-2-Bau7KkPq.js} +1 -1
- package/static/app/assets/{shield-alert-CBi2GNWM.js → shield-alert-BufNYypi.js} +1 -1
- package/static/app/assets/{textarea-DNMpB5ih.js → textarea-BQnVWhYs.js} +1 -1
- package/static/app/assets/{useFocusTrap-C83t3GXF.js → useFocusTrap-B3_w60si.js} +1 -1
- package/static/app/assets/{useMutation-DtbJDoyz.js → useMutation-BHhCflT6.js} +1 -1
- package/static/app/assets/{useQuery-Dcp1OChy.js → useQuery-rBWfI-5t.js} +1 -1
- package/static/app/assets/{utils-BlZr7Pd4.js → utils-V_5-wxr5.js} +1 -1
- package/static/app/assets/{workspace-jJY4RuAV.js → workspace-K1zjYUHj.js} +1 -1
- package/static/app/index.html +3 -3
- package/static/sw.js +1 -1
- package/static/app/assets/arrow-left-DXvKg9U6.js +0 -1
- package/static/app/assets/circle-check-DfInj-qD.js +0 -1
- package/static/app/assets/search-BybIWPNd.js +0 -1
package/src-tauri/src/backend.rs
CHANGED
|
@@ -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
|
|
6
|
-
//! the `lattice-host` supervisor, which the `lattice-host` binary uses
|
|
7
|
-
//! the desktop and the CLI front door cannot drift apart
|
|
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
|
-
//!
|
|
12
|
-
//!
|
|
13
|
-
//!
|
|
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
|
-
|
|
20
|
-
|
|
26
|
+
wait_for_health, HostProbe, Supervisor, SupervisorConfig, SupervisorError, SystemProbe,
|
|
27
|
+
WorkerStatus,
|
|
21
28
|
};
|
|
22
29
|
use serde::Serialize;
|
|
23
30
|
|
|
24
|
-
|
|
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
|
|
65
|
-
|
|
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
|
|
82
|
+
/// The supervisor, the front door, and the origin every surface is told about.
|
|
98
83
|
pub struct DesktopBackend {
|
|
99
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
111
|
+
let jobs = (plan.topology == Topology::Gateway)
|
|
112
|
+
.then(|| mounts::scheduler(&plan.worker_origin, supervisor.client()));
|
|
136
113
|
Ok(Self {
|
|
137
|
-
|
|
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
|
|
127
|
+
/// `{origin}/app` — the URL the window navigates to once health answers.
|
|
151
128
|
pub fn app_url(&self) -> String {
|
|
152
|
-
|
|
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
|
|
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.
|
|
176
|
-
///
|
|
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!(
|
|
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
|
-
|
|
255
|
-
|
|
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
|
|
268
|
-
let
|
|
269
|
-
assert_eq!(
|
|
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
|
|
274
|
-
let
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
assert_eq!(
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
293
|
-
|
|
294
|
-
assert!(
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
!
|
|
311
|
-
"a worker was named, so the shell waits
|
|
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
|
|
404
|
+
let shell = backend(StaticProbe::new());
|
|
325
405
|
let mut status = WorkerStatus::idle(4825, true);
|
|
326
|
-
assert_eq!(
|
|
406
|
+
assert_eq!(shell.command_text(&status), "not resolved yet");
|
|
327
407
|
status.last_error = Some("worker unavailable".into());
|
|
328
408
|
assert_eq!(
|
|
329
|
-
|
|
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
|
-
|
|
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
|
|
342
|
-
let rendered =
|
|
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
|
}
|
package/src-tauri/src/main.rs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|