feat: resume pods by name

This commit is contained in:
2026-05-22 22:57:16 +09:00
parent ca2e2352f4
commit 0e562dd4d9
4 changed files with 347 additions and 57 deletions
+109 -4
View File
@@ -45,12 +45,20 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
})
}
#[derive(Debug)]
enum Mode {
Spawn,
Attach {
pod_name: String,
socket_override: Option<PathBuf>,
},
/// `tui --pod <name>`: attach to a live Pod by name if possible;
/// otherwise launch `pod --pod <name>` so the pod process resumes from
/// name-keyed state or creates a fresh same-name Pod.
PodName {
pod_name: String,
socket_override: Option<PathBuf>,
},
/// `tui -r` / `tui --resume`: open the session picker first, then
/// run the same name dialog as Spawn but in resume mode.
Resume,
@@ -59,8 +67,9 @@ enum Mode {
ResumeWithSession(SegmentId),
}
#[derive(Debug)]
enum ParseError {
Conflict,
Conflict(&'static str),
InvalidSession(String),
MissingValue(&'static str),
}
@@ -68,7 +77,7 @@ enum ParseError {
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Conflict => write!(f, "--resume and --session are mutually exclusive"),
Self::Conflict(message) => write!(f, "{message}"),
Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"),
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
}
@@ -76,9 +85,18 @@ impl std::fmt::Display for ParseError {
}
fn parse_args() -> Result<Mode, ParseError> {
let args: Vec<String> = std::env::args().skip(1).collect();
parse_args_from(std::env::args().skip(1))
}
fn parse_args_from<I, S>(args: I) -> Result<Mode, ParseError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args: Vec<String> = args.into_iter().map(Into::into).collect();
let mut resume = false;
let mut session: Option<SegmentId> = None;
let mut pod: Option<String> = None;
let mut socket_override: Option<PathBuf> = None;
let mut positional: Option<String> = None;
@@ -99,6 +117,11 @@ fn parse_args() -> Result<Mode, ParseError> {
);
i += 2;
}
"--pod" => {
let raw = args.get(i + 1).ok_or(ParseError::MissingValue("--pod"))?;
pod = Some(raw.clone());
i += 2;
}
"--socket" => {
let raw = args
.get(i + 1)
@@ -119,9 +142,27 @@ fn parse_args() -> Result<Mode, ParseError> {
}
if resume && session.is_some() {
return Err(ParseError::Conflict);
return Err(ParseError::Conflict(
"--resume and --session are mutually exclusive",
));
}
if pod.is_some() && session.is_some() {
return Err(ParseError::Conflict(
"--pod and --session are mutually exclusive",
));
}
if pod.is_some() && resume {
return Err(ParseError::Conflict(
"--pod and --resume are mutually exclusive",
));
}
if let Some(pod_name) = pod {
return Ok(Mode::PodName {
pod_name,
socket_override,
});
}
if let Some(id) = session {
return Ok(Mode::ResumeWithSession(id));
}
@@ -163,6 +204,10 @@ async fn main() -> ExitCode {
pod_name,
socket_override,
} => run_attach(pod_name, socket_override).await,
Mode::PodName {
pod_name,
socket_override,
} => run_pod_name(pod_name, socket_override).await,
Mode::Resume => run_resume().await,
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
};
@@ -205,6 +250,37 @@ async fn run_attach(
run(&mut terminal, pod_name, &socket_path).await
}
async fn run_pod_name(
pod_name: String,
socket_override: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let socket_path = resolve_socket(&pod_name, socket_override);
if let Ok(client) = PodClient::connect(&socket_path).await {
let mut terminal = enter_fullscreen()?;
let mut app = App::new(pod_name);
app.connected = true;
return run_loop(&mut terminal, &mut app, client).await;
}
let ready = match spawn::run_pod_name(pod_name).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
let SpawnReady {
pod_name,
socket_path,
} = ready;
let mut terminal = enter_fullscreen()?;
let result = run(&mut terminal, pod_name, &socket_path).await;
let _ = execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
);
result
}
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
// Phase 1: pick a session in its own inline viewport, dropping the
// viewport before the name dialog opens so each phase gets fresh
@@ -612,3 +688,32 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
app.push_error("Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).");
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pod_name_mode() {
match parse_args_from(["--pod", "agent", "--socket", "/tmp/agent.sock"]).unwrap() {
Mode::PodName {
pod_name,
socket_override,
} => {
assert_eq!(pod_name, "agent");
assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock")));
}
_ => panic!("expected PodName mode"),
}
}
#[test]
fn parse_rejects_pod_and_session() {
let segment_id = session_store::new_segment_id().to_string();
let err = parse_args_from(["--pod", "agent", "--session", &segment_id]).unwrap_err();
assert_eq!(
err.to_string(),
"--pod and --session are mutually exclusive"
);
}
}
+107 -45
View File
@@ -90,56 +90,18 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
/// passes `--session <id>` to the spawned `pod` child.
pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnError> {
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
// Run the same merge pod itself uses, then read what's missing
// off the result. We only look at `scope.allow` here — `pod.name`
// is intentionally an instance-level identifier and is always
// taken from the dialog regardless of what (if anything) a layer
// declared.
let user_layer = user_manifest_path()
.filter(|p| p.is_file())
.and_then(|p| load_layer(&p).ok());
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
let mut cascade = PodManifestConfig::builtin_defaults();
for layer in [user_layer.as_ref(), project_layer.as_ref()]
.into_iter()
.flatten()
{
cascade = cascade.merge(layer.clone());
}
let cascade_has_scope = !cascade.scope.allow.is_empty();
let scope_origin = match (
project_layer
.as_ref()
.is_some_and(|l| !l.scope.allow.is_empty()),
user_layer
.as_ref()
.is_some_and(|l| !l.scope.allow.is_empty()),
) {
(true, _) => ScopeOrigin::FromProject,
(false, true) => ScopeOrigin::FromUser,
(false, false) => ScopeOrigin::CwdDefault,
};
let default_name = cwd
.file_name()
.and_then(|s| s.to_str())
.map(sanitise_default_name)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "pod".to_string());
let defaults = load_spawn_defaults()?;
let mut form = Form {
cwd: cwd.clone(),
cascade_has_scope,
scope_origin,
name_cursor: default_name.chars().count(),
name: default_name,
cwd: defaults.cwd.clone(),
cascade_has_scope: defaults.cascade_has_scope,
scope_origin: defaults.scope_origin,
name_cursor: defaults.default_name.chars().count(),
name: defaults.default_name,
message: None,
editing: true,
resume_from,
resume_by_pod_name: false,
resume_scope: None,
};
@@ -206,6 +168,101 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
}
}
/// Launch `pod --pod <name>` without opening the name dialog. The child Pod
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
/// with the usual TUI cwd-scope fallback.
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut form = Form {
cwd: defaults.cwd,
cascade_has_scope: defaults.cascade_has_scope,
scope_origin: defaults.scope_origin,
name_cursor: pod_name.chars().count(),
name: pod_name,
message: Some(("resuming pod...".to_string(), MessageKind::Progress)),
editing: false,
resume_from: None,
resume_by_pod_name: true,
resume_scope: None,
};
let overlay_toml = build_overlay_toml(&form);
let mut terminal = make_inline_terminal()?;
terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form, &overlay_toml).await {
Ok(ready) => {
form.message = Some((
format!("ready: {} attaching...", ready.pod_name),
MessageKind::Ok,
));
terminal.draw(|f| draw_form(f, &form))?;
drop(terminal);
Ok(SpawnOutcome::Ready(ready))
}
Err(e) => {
form.message = Some((e.to_string(), MessageKind::Error));
let _ = terminal.draw(|f| draw_form(f, &form));
drop(terminal);
Err(e)
}
}
}
struct SpawnDefaults {
cwd: PathBuf,
cascade_has_scope: bool,
scope_origin: ScopeOrigin,
default_name: String,
}
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
// Run the same merge pod itself uses, then read what's missing off the
// result. We only look at `scope.allow` here — `pod.name` is an
// instance-level identifier and is supplied by the dialog or `--pod`.
let user_layer = user_manifest_path()
.filter(|p| p.is_file())
.and_then(|p| load_layer(&p).ok());
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
let mut cascade = PodManifestConfig::builtin_defaults();
for layer in [user_layer.as_ref(), project_layer.as_ref()]
.into_iter()
.flatten()
{
cascade = cascade.merge(layer.clone());
}
let cascade_has_scope = !cascade.scope.allow.is_empty();
let scope_origin = match (
project_layer
.as_ref()
.is_some_and(|l| !l.scope.allow.is_empty()),
user_layer
.as_ref()
.is_some_and(|l| !l.scope.allow.is_empty()),
) {
(true, _) => ScopeOrigin::FromProject,
(false, true) => ScopeOrigin::FromUser,
(false, false) => ScopeOrigin::CwdDefault,
};
let default_name = cwd
.file_name()
.and_then(|s| s.to_str())
.map(sanitise_default_name)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "pod".to_string());
Ok(SpawnDefaults {
cwd,
cascade_has_scope,
scope_origin,
default_name,
})
}
fn make_inline_terminal() -> io::Result<InlineTerminal> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
@@ -279,6 +336,7 @@ async fn wait_for_ready(
overlay_toml: overlay_toml.to_string(),
cwd,
resume_from: form.resume_from,
resume_by_pod_name: form.resume_by_pod_name,
};
let ready = spawn_pod(config, |line| {
form.message = Some((line.to_string(), MessageKind::Progress));
@@ -377,6 +435,9 @@ struct Form {
/// child pod is launched with `--session <id>` so it restores
/// from `id` and appends to the same session log.
resume_from: Option<SegmentId>,
/// When true, launch the child with `--pod <name>` so the pod process
/// resolves name-keyed state before falling back to fresh creation.
resume_by_pod_name: bool,
/// Scope snapshot recovered from the source session log. Set only for
/// resume runs, and serialized into the overlay instead of cwd-default
/// scope so resume does not silently broaden access.
@@ -556,6 +617,7 @@ mod tests {
message: None,
editing: true,
resume_from: None,
resume_by_pod_name: false,
resume_scope: None,
}
}