Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -417,6 +465,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