Skip to content

Commit 25aaf86

Browse files
njbrakeclaude
andauthored
feature: ability to stop container (#310)
* stop container feature * pr update * fix: stop session bugs and status reporting - Fix Stopped sessions being miscounted as idle in `aoe status` - Add `stopped` field to StatusCounts/StatusJson and include in all output - Fix TUI StopSession action setting Stopped status even when stop() fails - Persist Stopped status to disk in both CLI and TUI so it survives restarts - Add HomeView::save() for TUI-side persistence - Revert STATUS_REFRESH_INTERVAL to 500ms (no need to double poll frequency) - Clean up std::collections::HashMap usage with proper imports - Document projects/ sentinel heuristic in container_config sync logic Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: filter batch_running_states by exact prefix match Docker's --filter name= does substring matching, which could match unrelated containers whose names contain "aoe-sandbox-". Add a starts_with post-filter to ensure only exact prefix matches are included. Co-Authored-By: Claude Opus 4.6 <[email protected]> * stopp --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 684397e commit 25aaf86

18 files changed

Lines changed: 352 additions & 177 deletions

File tree

.claude/skills/review-pr/SKILL.md

Lines changed: 0 additions & 124 deletions
This file was deleted.

src/cli/session.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,36 @@ async fn start_session(profile: &str, args: SessionIdArgs) -> Result<()> {
122122

123123
async fn stop_session(profile: &str, args: SessionIdArgs) -> Result<()> {
124124
let storage = Storage::new(profile)?;
125-
let (instances, _) = storage.load_with_groups()?;
125+
let (mut instances, groups) = storage.load_with_groups()?;
126126

127127
let inst = super::resolve_session(&args.identifier, &instances)?;
128+
let session_id = inst.id.clone();
129+
let title = inst.title.clone();
128130
let tmux_session = crate::tmux::Session::new(&inst.id, &inst.title)?;
131+
let was_running = tmux_session.exists();
132+
let had_container = inst.is_sandboxed()
133+
&& crate::containers::DockerContainer::from_session_id(&inst.id)
134+
.is_running()
135+
.unwrap_or(false);
136+
137+
if !was_running && !had_container {
138+
println!("Session is not running: {}", title);
139+
return Ok(());
140+
}
141+
142+
inst.stop()?;
143+
144+
// Persist Stopped status to disk so it survives TUI restarts
145+
if let Some(stored) = instances.iter_mut().find(|i| i.id == session_id) {
146+
stored.status = crate::session::Status::Stopped;
147+
}
148+
let group_tree = crate::session::GroupTree::new_with_groups(&instances, &groups);
149+
storage.save_with_groups(&instances, &group_tree)?;
129150

130-
if tmux_session.exists() {
131-
tmux_session.kill()?;
132-
println!("✓ Stopped session: {}", inst.title);
151+
if had_container {
152+
println!("✓ Stopped session and container: {}", title);
133153
} else {
134-
println!("Session is not running: {}", inst.title);
154+
println!("✓ Stopped session: {}", title);
135155
}
136156

137157
Ok(())

src/cli/status.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct StatusCounts {
2626
running: usize,
2727
waiting: usize,
2828
idle: usize,
29+
stopped: usize,
2930
error: usize,
3031
total: usize,
3132
}
@@ -35,6 +36,7 @@ struct StatusJson {
3536
waiting: usize,
3637
running: usize,
3738
idle: usize,
39+
stopped: usize,
3840
error: usize,
3941
total: usize,
4042
}
@@ -45,7 +47,9 @@ pub async fn run(profile: &str, args: StatusArgs) -> Result<()> {
4547

4648
if instances.is_empty() {
4749
if args.json {
48-
println!(r#"{{"waiting": 0, "running": 0, "idle": 0, "error": 0, "total": 0}}"#);
50+
println!(
51+
r#"{{"waiting": 0, "running": 0, "idle": 0, "stopped": 0, "error": 0, "total": 0}}"#
52+
);
4953
} else if args.quiet {
5054
println!("0");
5155
} else {
@@ -69,6 +73,7 @@ pub async fn run(profile: &str, args: StatusArgs) -> Result<()> {
6973
waiting: counts.waiting,
7074
running: counts.running,
7175
idle: counts.idle,
76+
stopped: counts.stopped,
7277
error: counts.error,
7378
total: counts.total,
7479
};
@@ -79,12 +84,18 @@ pub async fn run(profile: &str, args: StatusArgs) -> Result<()> {
7984
print_status_group("WAITING", "◐", Status::Waiting, &instances);
8085
print_status_group("RUNNING", "●", Status::Running, &instances);
8186
print_status_group("IDLE", "○", Status::Idle, &instances);
87+
print_status_group("STOPPED", "■", Status::Stopped, &instances);
8288
print_status_group("ERROR", "✕", Status::Error, &instances);
8389
println!(
8490
"Total: {} sessions in profile '{}'",
8591
counts.total,
8692
storage.profile()
8793
);
94+
} else if counts.stopped > 0 {
95+
println!(
96+
"{} waiting • {} running • {} idle • {} stopped",
97+
counts.waiting, counts.running, counts.idle, counts.stopped
98+
);
8899
} else {
89100
println!(
90101
"{} waiting • {} running • {} idle",
@@ -107,6 +118,7 @@ fn count_by_status(instances: &[crate::session::Instance]) -> StatusCounts {
107118
Status::Running => counts.running += 1,
108119
Status::Waiting => counts.waiting += 1,
109120
Status::Idle => counts.idle += 1,
121+
Status::Stopped => counts.stopped += 1,
110122
Status::Error => counts.error += 1,
111123
Status::Starting => counts.idle += 1,
112124
Status::Deleting => {}

src/containers/apple_container.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashMap;
2+
13
use super::container_interface::{ContainerConfig, ContainerRuntimeInterface};
24
use super::error::{DockerError, Result};
35
use super::runtime_base::RuntimeBase;
@@ -107,6 +109,10 @@ impl ContainerRuntimeInterface for AppleContainer {
107109
fn exec(&self, name: &str, cmd: &[&str]) -> Result<std::process::Output> {
108110
self.base.exec(name, cmd)
109111
}
112+
113+
fn batch_running_states(&self, _prefix: &str) -> HashMap<String, bool> {
114+
HashMap::new()
115+
}
110116
}
111117

112118
#[cfg(test)]

src/containers/container_interface.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashMap;
2+
13
use super::error::Result;
24
use enum_dispatch::enum_dispatch;
35

@@ -58,4 +60,8 @@ pub trait ContainerRuntimeInterface {
5860
fn exec_command(&self, name: &str, options: Option<&str>) -> String;
5961

6062
fn exec(&self, name: &str, cmd: &[&str]) -> Result<std::process::Output>;
63+
64+
/// Check running state of all containers matching a name prefix in a single call.
65+
/// Returns a map of container name -> is_running.
66+
fn batch_running_states(&self, prefix: &str) -> HashMap<String, bool>;
6167
}

src/containers/docker.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashMap;
2+
13
use super::container_interface::{ContainerConfig, ContainerRuntimeInterface};
24
use super::error::{DockerError, Result};
35
use super::runtime_base::RuntimeBase;
@@ -106,6 +108,42 @@ impl ContainerRuntimeInterface for Docker {
106108
fn exec(&self, name: &str, cmd: &[&str]) -> Result<std::process::Output> {
107109
self.base.exec(name, cmd)
108110
}
111+
112+
fn batch_running_states(&self, prefix: &str) -> HashMap<String, bool> {
113+
let output = self
114+
.base
115+
.command()
116+
.args([
117+
"ps",
118+
"-a",
119+
"--filter",
120+
&format!("name={}", prefix),
121+
"--format",
122+
"{{.Names}}\t{{.State}}",
123+
])
124+
.output();
125+
126+
let output = match output {
127+
Ok(o) if o.status.success() => o,
128+
_ => return HashMap::new(),
129+
};
130+
131+
let stdout = String::from_utf8_lossy(&output.stdout);
132+
stdout
133+
.lines()
134+
.filter_map(|line| {
135+
let mut parts = line.splitn(2, '\t');
136+
let name = parts.next()?.trim();
137+
let state = parts.next()?.trim();
138+
// Docker's --filter name= does substring matching, so
139+
// post-filter to ensure we only include exact prefix matches.
140+
if name.is_empty() || !name.starts_with(prefix) {
141+
return None;
142+
}
143+
Some((name.to_string(), state == "running"))
144+
})
145+
.collect()
146+
}
109147
}
110148

111149
#[cfg(test)]

src/containers/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ mod docker;
44
pub mod error;
55
pub(crate) mod runtime_base;
66

7+
use std::collections::HashMap;
8+
79
use crate::cli::truncate_id;
810
use crate::session::{Config, ContainerRuntimeName};
911
use apple_container::AppleContainer;
@@ -47,6 +49,12 @@ pub fn get_container_runtime() -> ContainerRuntime {
4749
}
4850
}
4951

52+
/// Check running state of all aoe sandbox containers in a single subprocess call.
53+
/// Returns a map of container name -> is_running.
54+
pub fn batch_container_health() -> HashMap<String, bool> {
55+
get_container_runtime().batch_running_states("aoe-sandbox-")
56+
}
57+
5058
pub struct DockerContainer {
5159
pub name: String,
5260
pub image: String,

0 commit comments

Comments
 (0)