Skip to content

Commit 3dbadc7

Browse files
loopx-agenthuangruiteng
authored andcommitted
fix(desktop): qualify bundled Core version and reuse managed Python
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent b0dba8b commit 3dbadc7

6 files changed

Lines changed: 177 additions & 21 deletions

File tree

‎apps/desktop/loopx-control-plane/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ unknown; this is maintenance of the App's own installation, not proof that one
3737
source revision is newer. A provably newer runtime remains selected. Both local HTTP services must expose the selected
3838
artifact's identity before the workspace opens.
3939

40+
The bundled runtime carries its Core package version from the archived wheel
41+
metadata. Main App build numbers (`0.0.0-main.…`) identify the native shell;
42+
they are not compared with CLI package versions. Older bundle metadata retains
43+
its previous shell-version fallback; this correction requires an App update.
44+
4045
If the bundle is newer, or no selected CLI qualifies, the App prepares its
4146
bundled snapshot in its own data directory through the existing installer.
4247
This updates the CLI used by the App without replacing uv/pip/pipx's command or
@@ -69,6 +74,9 @@ The release workflow packages both layers from one Git revision.
6974
The installer and App-owned services use the same bounded tool search,
7075
including standard Homebrew locations on macOS, without loading interactive
7176
shell profiles. Finder launches therefore do not depend on terminal PATH setup.
77+
When preparing the first App-owned snapshot, an already selected managed CLI's
78+
recorded Python is reused if the App has no interpreter record of its own.
79+
This leaves that CLI's launcher and installation owner unchanged.
7280
Installation uses `loopx doctor --deep --installation-only`: package ownership,
7381
representative imports/commands/files and the TypeScript runtime semantic probe
7482
remain required, but existing Goal projects are not traversed. Native runtime

‎apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs‎

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,10 @@ pub(crate) fn private_executable(app: &AppHandle) -> Result<std::path::PathBuf,
129129
.join("loopx"))
130130
}
131131

132-
pub(crate) fn install_private(app: &AppHandle) -> Result<String, String> {
132+
pub(crate) fn install_private(
133+
app: &AppHandle,
134+
selected_executable: &str,
135+
) -> Result<String, String> {
133136
let metadata = identity(app)?;
134137
let archive = app
135138
.path()
@@ -142,11 +145,16 @@ pub(crate) fn install_private(app: &AppHandle) -> Result<String, String> {
142145
.parent()
143146
.and_then(Path::parent)
144147
.ok_or("runtime_selection_unavailable")?;
145-
install_snapshot(&bytes, &metadata, root)?;
148+
install_snapshot(&bytes, &metadata, root, selected_executable)?;
146149
Ok(executable.to_string_lossy().into_owned())
147150
}
148151

149-
fn install_snapshot(bytes: &[u8], metadata: &Value, private_root: &Path) -> Result<(), String> {
152+
fn install_snapshot(
153+
bytes: &[u8],
154+
metadata: &Value,
155+
private_root: &Path,
156+
selected_executable: &str,
157+
) -> Result<(), String> {
150158
let digest: String = Sha256::digest(bytes)
151159
.iter()
152160
.map(|byte| format!("{byte:02x}"))
@@ -178,14 +186,19 @@ fn install_snapshot(bytes: &[u8], metadata: &Value, private_root: &Path) -> Resu
178186
.env("LOOPX_SHELL_PROFILE", root.join("shell-profile"))
179187
.env("LOOPX_MAN_ROOT", root.join("man"))
180188
.env("LOOPX_MAN_DIR", root.join("man/man1"));
181-
let selected_executable = root.join("bin/loopx").to_string_lossy().into_owned();
182-
// Preserve the working interpreter of an existing managed snapshot.
183-
if let Ok(executable) = fs::canonicalize(&selected_executable) {
184-
if let Some(release) = executable.parent().and_then(Path::parent) {
185-
if let Ok(python) = fs::read_to_string(release.join(".loopx-python")) {
186-
command.env("LOOPX_PYTHON", python.trim());
187-
}
188-
}
189+
let private_executable = root.join("bin/loopx").to_string_lossy().into_owned();
190+
// Finder may see only the system Python even when the selected managed CLI
191+
// already has a working interpreter. Reuse its installer's record for a
192+
// first private install; an existing App-owned snapshot keeps precedence.
193+
let managed_python = |executable: &str| {
194+
let executable = fs::canonicalize(executable).ok()?;
195+
let release = executable.parent()?.parent()?;
196+
fs::read_to_string(release.join(".loopx-python")).ok()
197+
};
198+
if let Some(python) =
199+
managed_python(&private_executable).or_else(|| managed_python(selected_executable))
200+
{
201+
command.env("LOOPX_PYTHON", python.trim());
189202
}
190203
command
191204
.current_dir(source.path())
@@ -231,9 +244,12 @@ fn install_snapshot(bytes: &[u8], metadata: &Value, private_root: &Path) -> Resu
231244
Ok(Some(status)) => {
232245
return if status.success() {
233246
let installed =
234-
crate::services::runtime_identity_for_executable(&selected_executable);
247+
crate::services::runtime_identity_for_executable(&private_executable);
235248
if installed.as_ref().map(|v| &v["source_revision"])
236249
== Some(&metadata["source_revision"])
250+
&& metadata.get("package_version").is_none_or(|version| {
251+
installed.as_ref().map(|v| &v["package_version"]) == Some(version)
252+
})
237253
{
238254
Ok(())
239255
} else {
@@ -304,13 +320,17 @@ mod tests {
304320
&fs::read(bundle.join("runtime-source.tar.gz")).unwrap(),
305321
&metadata,
306322
&root,
323+
&default,
307324
)
308325
.unwrap();
309326
let observed = crate::services::runtime_identity_for_executable(
310327
root.join("bin/loopx").to_str().unwrap(),
311328
)
312329
.unwrap();
313330
assert_eq!(observed["source_revision"], metadata["source_revision"]);
331+
if let Some(version) = metadata.get("package_version") {
332+
assert_eq!(&observed["package_version"], version);
333+
}
314334
let manifest: Value = serde_json::from_slice(
315335
&fs::read(
316336
fs::canonicalize(root.join("bin/loopx"))
@@ -328,6 +348,18 @@ mod tests {
328348
manifest["source"]["git_ref"].is_null() && manifest["source"]["git_dirty"].is_null(),
329349
"a bundled archive has no Git checkout; host repository facts are not its provenance"
330350
);
351+
let mut wrong_version = metadata.clone();
352+
wrong_version["package_version"] = json!("0.0.0-mismatch");
353+
assert_eq!(
354+
install_snapshot(
355+
&fs::read(bundle.join("runtime-source.tar.gz")).unwrap(),
356+
&wrong_version,
357+
&root,
358+
&default,
359+
)
360+
.unwrap_err(),
361+
"runtime_identity_mismatch"
362+
);
331363
assert_eq!(
332364
fs::read(default).unwrap(),
333365
original,

‎apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -420,10 +420,11 @@ async fn reinstall_bundled_runtime(app: &AppHandle) -> Result<Value, String> {
420420
}
421421
state.publish("installing_runtime", json!({}));
422422
let handle = app.clone();
423-
let executable =
424-
tauri::async_runtime::spawn_blocking(move || bundled_runtime::install_private(&handle))
425-
.await
426-
.map_err(|_| "runtime_install_failed".to_string())??;
423+
let executable = tauri::async_runtime::spawn_blocking(move || {
424+
bundled_runtime::install_private(&handle, &selection.executable)
425+
})
426+
.await
427+
.map_err(|_| "runtime_install_failed".to_string())??;
427428
crate::runtime_selection::remember(
428429
app,
429430
&crate::runtime_selection::Selection {
@@ -685,7 +686,8 @@ fn resume_runtime(app: &AppHandle) -> Result<crate::services::SelectedRuntime, S
685686
let state = app.state::<Maintenance>();
686687
let _guard = state.acquire()?;
687688
let bundled = bundled_runtime::identity(app)?;
688-
let candidate = json!({"package_version":app.package_info().version.to_string(), "source_revision":bundled["source_revision"]});
689+
let candidate =
690+
crate::runtime_selection::bundled_runtime_candidate(app.package_info(), &bundled)?;
689691
let private_executable = bundled_runtime::private_executable(app)?;
690692
// Only a launch-time developer override pins a runtime. Ordinary launches
691693
// reconcile discovery, a previous choice, and the App-owned installation.
@@ -755,7 +757,10 @@ fn resume_runtime(app: &AppHandle) -> Result<crate::services::SelectedRuntime, S
755757
if newer_bundle && !selection.environment_override {
756758
let mut prepared = None;
757759
let result = state.prepare_runtime(Instant::now(), || {
758-
prepared = Some(bundled_runtime::install_private(app)?);
760+
prepared = Some(bundled_runtime::install_private(
761+
app,
762+
&selection.executable,
763+
)?);
759764
Ok(())
760765
});
761766
match result {

‎apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,33 @@ pub(crate) fn forget(app: &AppHandle) -> Result<(), String> {
107107
}
108108
}
109109

110+
fn parse_like<T: std::str::FromStr>(_: &T, text: &str) -> Option<T> {
111+
text.parse().ok()
112+
}
113+
114+
// The updater's Main build number is a shell version, not a Core version.
115+
// New bundles carry the wheel version from their exact archived source. Old
116+
// bundles retain their existing shell-version fallback for compatibility.
117+
pub(crate) fn bundled_runtime_candidate(
118+
package: &tauri::utils::PackageInfo,
119+
bundled: &Value,
120+
) -> Result<Value, String> {
121+
let version = match bundled.get("package_version") {
122+
Some(value) => value
123+
.as_str()
124+
.filter(|text| parse_like(&package.version, text).is_some())
125+
.ok_or("runtime_bundle_invalid")?
126+
.to_owned(),
127+
None => package.version.to_string(),
128+
};
129+
Ok(json!({"package_version": version, "source_revision": bundled["source_revision"]}))
130+
}
131+
110132
fn compare_package_versions(
111133
package: &tauri::utils::PackageInfo,
112134
installed: &Value,
113135
candidate: &Value,
114136
) -> Option<std::cmp::Ordering> {
115-
fn parse_like<T: std::str::FromStr>(_: &T, text: &str) -> Option<T> {
116-
text.parse().ok()
117-
}
118137
let mut left = parse_like(&package.version, installed["package_version"].as_str()?)?;
119138
let mut right = parse_like(&package.version, candidate["package_version"].as_str()?)?;
120139
// Core wheels carry the release base; main-channel shell prereleases do
@@ -279,6 +298,59 @@ pub(crate) fn compare_official_commits(left: &str, right: &str) -> Option<std::c
279298
#[cfg(test)]
280299
mod tests {
281300
use super::*;
301+
#[test]
302+
fn main_shell_build_number_does_not_rank_the_bundled_core() {
303+
use std::cmp::Ordering;
304+
let package = tauri::utils::PackageInfo {
305+
name: "LoopX".into(),
306+
version: "0.0.0-main.42.1".parse().unwrap(),
307+
authors: "contributors",
308+
description: "desktop",
309+
crate_name: "desktop",
310+
};
311+
let installed = json!({"package_version":"1.4.0", "source_revision":"installed"});
312+
let bundled = json!({"package_version":"1.4.0", "source_revision":"bundle"});
313+
let candidate = bundled_runtime_candidate(&package, &bundled).unwrap();
314+
for (relation, expected) in [
315+
(Some(Ordering::Less), true),
316+
(Some(Ordering::Greater), false),
317+
(Some(Ordering::Equal), false),
318+
(None, false),
319+
] {
320+
assert_eq!(
321+
prefer_current_bundle(&package, &installed, &candidate, false, |left, right| {
322+
assert_eq!((left, right), ("installed", "bundle"));
323+
relation
324+
}),
325+
expected,
326+
"source ancestry owns equal Core versions, even with a 0.0.0 shell"
327+
);
328+
}
329+
for (version, expected) in [("1.3.0", false), ("1.5.0", true)] {
330+
let candidate = bundled_runtime_candidate(
331+
&package,
332+
&json!({"package_version":version, "source_revision":"bundle"}),
333+
)
334+
.unwrap();
335+
assert_eq!(
336+
prefer_current_bundle(&package, &installed, &candidate, false, |_, _| panic!(
337+
"unequal Core versions need no ancestry lookup"
338+
)),
339+
expected
340+
);
341+
}
342+
for invalid in [json!(null), json!(42), json!("invalid"), json!("")] {
343+
assert_eq!(
344+
bundled_runtime_candidate(&package, &json!({"package_version":invalid}))
345+
.unwrap_err(),
346+
"runtime_bundle_invalid"
347+
);
348+
}
349+
let legacy =
350+
bundled_runtime_candidate(&package, &json!({"source_revision":"legacy"})).unwrap();
351+
assert_eq!(legacy["package_version"], "0.0.0-main.42.1");
352+
}
353+
282354
#[test]
283355
fn newer_runtime_uses_versions_then_ancestry_and_never_install_time() {
284356
use std::cmp::Ordering;

‎scripts/desktop_runtime_bundle.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import tarfile
1616
import io
1717
import importlib.util
18+
import tomllib
1819

1920

2021
def build(root: Path) -> None:
@@ -109,6 +110,7 @@ def build(root: Path) -> None:
109110
identity = {
110111
"schema_version": "desktop_runtime_bundle_v1",
111112
"source_revision": revision,
113+
"package_version": archive_package_version(archive),
112114
"sha256": hashlib.sha256(archive.read_bytes()).hexdigest(),
113115
}
114116
(destination / "identity.json").write_text(json.dumps(identity, indent=2) + "\n")
@@ -117,6 +119,18 @@ def build(root: Path) -> None:
117119
)
118120

119121

122+
def archive_package_version(archive: Path) -> str:
123+
"""Use the archived wheel metadata, not the shell tag or working tree."""
124+
with tarfile.open(archive, "r:gz") as source:
125+
metadata = source.extractfile("pyproject.toml")
126+
if metadata is None:
127+
raise RuntimeError("runtime archive has no package metadata")
128+
version = tomllib.loads(metadata.read().decode("utf-8"))["project"]["version"]
129+
if not isinstance(version, str) or not version.strip():
130+
raise RuntimeError("runtime archive has no package version")
131+
return version
132+
133+
120134
def qualify_archive(archive: Path) -> int:
121135
"""Fail the release build when the App-side extractor could never install it.
122136

‎tests/desktop/test_runtime_bundle.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Reject runtime snapshots the App-side installer could never unpack."""
22
import gzip
3+
import io
34
import importlib.util
45
from pathlib import Path
56
import tempfile
67
import unittest
8+
import tarfile
79

810
spec = importlib.util.spec_from_file_location(
911
"desktop_bundle", Path(__file__).resolve().parents[2] / "scripts/desktop_runtime_bundle.py"
@@ -37,6 +39,29 @@ def write_archive(self, *blocks: bytes) -> Path:
3739
compressed.write(block)
3840
return archive
3941

42+
def test_core_version_comes_from_archived_wheel_metadata(self):
43+
archive = self.root / "versioned.tar.gz"
44+
content = b'[project]\nversion = "1.5.2"\n'
45+
with tarfile.open(archive, "w:gz") as target:
46+
member = tarfile.TarInfo("pyproject.toml")
47+
member.size = len(content)
48+
target.addfile(member, io.BytesIO(content))
49+
# Neither a newer working-tree version nor a Main shell tag owns this
50+
# archive's package identity.
51+
(self.root / "pyproject.toml").write_text('[project]\nversion = "1.6.0"\n')
52+
self.assertEqual(bundle.archive_package_version(archive), "1.5.2")
53+
54+
def test_invalid_archived_package_version_is_rejected(self):
55+
for version in ["42", '""', '" "']:
56+
archive = self.root / "invalid-version.tar.gz"
57+
content = f"[project]\nversion = {version}\n".encode()
58+
with tarfile.open(archive, "w:gz") as target:
59+
member = tarfile.TarInfo("pyproject.toml")
60+
member.size = len(content)
61+
target.addfile(member, io.BytesIO(content))
62+
with self.assertRaises(RuntimeError):
63+
bundle.archive_package_version(archive)
64+
4065
def test_files_and_directories_qualify(self):
4166
archive = self.write_archive(
4267
tar_header(b"loopx/", b"5"),

0 commit comments

Comments
 (0)