Skip to content
Merged
93 changes: 92 additions & 1 deletion desktop/src-tauri/src/deployment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,59 @@ pub fn needs_fetch(root: &Path, wanted: &str) -> bool {
}
match installed(root) {
// A deployment laid out by an app that copied less is missing something this one runs.
Some(found) => found.version != wanted || found.layout != layout(),
Some(found) => {
found.version != wanted || found.layout != layout() || predates_containers(root)
}
None => true,
}
}

/// Whether the deployment on disk is a release from before OpenBot's server and worker were
/// Compose services.
///
/// Such a release ran both as Bun processes on the host, and this app no longer starts those. Its
/// Compose file has no `server` or `worker` to raise, and its server image has no worker in it, so
/// keeping its pin would leave an installation that used to start unable to. Answered from the
/// Compose file because that is what is missing; an unreadable or absent file is a damaged
/// deployment, not an old one, and the ordinary repair of the pinned version covers it.
pub fn predates_containers(root: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(root.join("docker-compose.yml")) else {
return false;
};
let services = compose_services(&text);
!(services.contains(&"server") && services.contains(&"worker"))
}

/// The service names in a Compose file, read line by line.
///
/// Not a YAML parser, and not meant to be one: it reads the shape `docker-compose.yml` in this
/// repository has always had, a top-level `services:` with each service two spaces in. That is the
/// only file this is ever pointed at, and it is enough to tell whether a service is declared.
fn compose_services(text: &str) -> Vec<&str> {
let mut in_services = false;
let mut services = Vec::new();
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if !line.starts_with(' ') {
in_services = line.starts_with("services:");
continue;
}
let Some(entry) = line.strip_prefix(" ").filter(|_| in_services) else {
continue;
};
if entry.starts_with(' ') {
continue;
}
if let Some((name, _)) = entry.split_once(':') {
services.push(name.trim().trim_matches(|c| c == '"' || c == '\''));
}
}
services
}

/// What this app lays out, in a fixed order, as recorded in the stamp.
fn layout() -> Vec<String> {
let mut entries: Vec<String> = REQUIRED
Expand Down Expand Up @@ -428,6 +476,49 @@ mod tests {
served.join().unwrap();
}

#[test]
fn this_repositorys_compose_file_runs_the_server_and_worker_as_services() {
let text = include_str!("../../../docker-compose.yml");
let services = compose_services(text);
for name in ["postgres", "supervisor", "server", "worker"] {
assert!(services.contains(&name), "{name} not in {services:?}");
}
}

#[test]
fn a_release_whose_server_ran_on_the_host_is_fetched_again_at_its_own_version() {
let root = crate::test_support::temp_root("deployment-host-server");
std::fs::create_dir_all(&root).unwrap();
// The shape of every release before the server moved into Compose: services, none of them
// OpenBot's own, and one commented-out line that must not count.
std::fs::write(
root.join("docker-compose.yml"),
"services:\n postgres:\n image: postgres\n # server: on the host\n supervisor:\n \
environment:\n server: x\nvolumes:\n worker:\n",
)
.unwrap();
std::fs::write(images_path(&root), "{}").unwrap();
record(&root, "v0.0.15").unwrap();
assert!(predates_containers(&root));
assert!(needs_fetch(&root, "v0.0.15"));

std::fs::write(
root.join("docker-compose.yml"),
"services:\n postgres: {}\n server:\n profiles: [\"openbot\"]\n worker:\n \
profiles: [\"openbot\"]\n",
)
.unwrap();
assert!(!predates_containers(&root));
assert!(!needs_fetch(&root, "v0.0.15"));

std::fs::remove_file(root.join("docker-compose.yml")).unwrap();
assert!(
!predates_containers(&root),
"a missing file is damage, not age"
);
std::fs::remove_dir_all(root).unwrap();
}

fn manifest(names: &[&str]) -> Images {
Images {
version: "v0.0.7".into(),
Expand Down
24 changes: 22 additions & 2 deletions desktop/src-tauri/src/deployment_release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const LATEST_RELEASE: &str = "https://api.github.com/repos/CopilotKit/OpenBot/re

/// Only new deployments consult GitHub. The fetcher records the exact tag after the source and
/// image manifest have both downloaded successfully; restarts and repairs retain that pin.
///
/// The one exception is a pin this app cannot run: a release from before the server and worker
/// were Compose services. Repairing it at its own version lays out the same Compose file again, so
/// that installation is moved to the latest release instead. See `deployment::predates_containers`.
/// Uses blocking HTTP, so callers in an async runtime must use a blocking task.
pub fn resolve_version(root: &Path) -> Result<String, String> {
resolve_version_with(root, || {
Expand All @@ -24,8 +28,8 @@ fn resolve_version_with(
latest: impl FnOnce() -> Result<String, String>,
) -> Result<String, String> {
match deployment::installed(root) {
Some(installed) => Ok(installed.version),
None => latest(),
Some(installed) if !deployment::predates_containers(root) => Ok(installed.version),
_ => latest(),
}
}

Expand Down Expand Up @@ -75,6 +79,22 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn a_pinned_release_whose_server_ran_on_the_host_is_upgraded_to_latest() {
let root = scratch("release-host-server");
std::fs::write(
root.join("docker-compose.yml"),
"services:\n postgres: {}\n supervisor: {}\n",
)
.unwrap();
std::fs::write(deployment::images_path(&root), "{}").unwrap();
deployment::record(&root, "v0.0.15").unwrap();
let version = resolve_version_with(&root, || Ok("v0.0.16".into())).unwrap();
assert_eq!(version, "v0.0.16");
assert!(deployment::needs_fetch(&root, &version));
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn a_missing_manifest_repairs_the_pinned_version() {
let root = scratch("release-repair");
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/desktop_page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ async fn status(
}

#[tauri::command]
fn restart(app: tauri::AppHandle) -> Result<(), String> {
crate::restart_openbot(app)
async fn restart(app: tauri::AppHandle) -> Result<(), String> {
crate::restart_openbot(app).await
}

/// Stops everything, as the menu's Stop did. The page asking goes with it; the window shows setup.
Expand Down
24 changes: 2 additions & 22 deletions desktop/src-tauri/src/desktop_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
//!
//! Containers are told apart by the names their owners give them: Compose names OpenBot's
//! `openbot-<service>-<n>` (its project is `openbot`), and k3d names an Intelligence stack's
//! `k3d-<stack id>-<role>`. Memory is Docker's own figure for each container, and the resident size
//! of OpenBot's host processes, so the total is what the machine is actually spending.
//! `k3d-<stack id>-<role>`. Memory is Docker's own figure for each container, OpenBot's own server
//! and worker among them, so the total is what the machine is actually spending.

use serde::Serialize;

Expand Down Expand Up @@ -62,18 +62,6 @@ pub fn containers(stats: &str, stack: Option<&str>) -> (Vec<Part>, Vec<Part>) {
(openbot, intelligence)
}

/// `ps -o pid=,rss= -p …`: each process's resident size, in bytes (ps reports kibibytes).
pub fn resident(ps: &str) -> Vec<(u32, u64)> {
ps.lines()
.filter_map(|line| {
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse().ok()?;
let kib: u64 = fields.next()?.parse().ok()?;
Some((pid, kib * 1024))
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -133,12 +121,4 @@ mod tests {
);
assert_eq!(intelligence[0].memory_bytes, None);
}

#[test]
fn resident_sizes_are_kibibytes_per_pid() {
assert_eq!(
resident(" 123 2048\n 456 10\nnonsense\n"),
vec![(123, 2_097_152), (456, 10_240)]
);
}
}
Loading
Loading