From 0e562dd4d92377f4492cc7e067137b698ca7ad88 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 22 May 2026 22:57:16 +0900 Subject: [PATCH] feat: resume pods by name --- crates/client/src/spawn.rs | 6 ++ crates/pod/src/main.rs | 133 ++++++++++++++++++++++++++++++-- crates/tui/src/main.rs | 113 ++++++++++++++++++++++++++- crates/tui/src/spawn.rs | 152 ++++++++++++++++++++++++++----------- 4 files changed, 347 insertions(+), 57 deletions(-) diff --git a/crates/client/src/spawn.rs b/crates/client/src/spawn.rs index a7e18d9b..d19f7ed4 100644 --- a/crates/client/src/spawn.rs +++ b/crates/client/src/spawn.rs @@ -34,6 +34,9 @@ pub struct SpawnConfig { /// `Some(id)` のとき `--session ` を付与し、当該セッションから /// resume させる。 pub resume_from: Option, + /// true のとき `--pod ` を付与し、pod 側で name-keyed state + /// があれば resume、なければ同名の新規 Pod として起動させる。 + pub resume_by_pod_name: bool, } pub struct SpawnReady { @@ -111,6 +114,9 @@ where .stdout(Stdio::null()) .stderr(Stdio::from(stderr_file)) .process_group(0); + if config.resume_by_pod_name { + command.arg("--pod").arg(&config.pod_name); + } if let Some(id) = config.resume_from { command.arg("--session").arg(id.to_string()); } diff --git a/crates/pod/src/main.rs b/crates/pod/src/main.rs index 01cb2055..a43716cd 100644 --- a/crates/pod/src/main.rs +++ b/crates/pod/src/main.rs @@ -3,9 +3,9 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use clap::Parser; -use manifest::{PodManifest, paths}; +use manifest::{PodManifest, PodManifestConfig, paths}; use pod::{Pod, PodController, PodFactory, PromptLoader}; -use session_store::{FsStore, SegmentId, Store}; +use session_store::{FsStore, PodMetadataStore, SegmentId, Store}; const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST"; @@ -47,12 +47,18 @@ struct Cli { #[arg(long, value_name = "PATH", requires = "adopt")] callback: Option, + /// Resume or create a Pod by name. If name-keyed Pod state exists, + /// the active session/segment recorded there is restored; otherwise a + /// fresh top-level Pod is created with this name. + #[arg(long, value_name = "NAME", conflicts_with_all = ["session", "adopt"])] + pod: Option, + /// Restore a Pod from an existing session. The Pod re-uses the /// given session id and appends new turns to the same jsonl; /// concurrent writers are prevented by the pod-registry. /// Mutually exclusive with `--adopt` (spawned children always start /// fresh). - #[arg(long, value_name = "UUID", conflicts_with = "adopt")] + #[arg(long, value_name = "UUID", conflicts_with_all = ["adopt", "pod"])] session: Option, } @@ -72,7 +78,7 @@ fn resolve_manifest_with_user_manifest_env( "--manifest cannot be used when {USER_MANIFEST_ENV} is set" )); } - return load_single_manifest(path); + return load_single_manifest(path, cli.pod.as_deref()); } let factory = build_factory_with_user_manifest_path(cli, user_manifest)?; @@ -91,14 +97,45 @@ fn user_manifest_path_from_env(value: Option) -> Option { }) } -fn load_single_manifest(path: &Path) -> Result<(PodManifest, PromptLoader), String> { +fn load_single_manifest( + path: &Path, + pod_name_override: Option<&str>, +) -> Result<(PodManifest, PromptLoader), String> { let toml = std::fs::read_to_string(path) .map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?; - let manifest = PodManifest::from_toml(&toml) - .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?; + let manifest = match pod_name_override { + Some(pod_name) => match PodManifest::from_toml(&toml) { + Ok(mut manifest) => { + manifest.pod.name = pod_name.to_string(); + manifest + } + Err(_) => { + let base = PodManifestConfig::from_toml(&toml) + .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?; + let overlay = PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name)) + .expect("pod name overlay TOML is generated"); + PodManifest::try_from(base.merge(overlay)).map_err(|e| { + format!( + "failed to resolve manifest {} with --pod: {e}", + path.display() + ) + })? + } + }, + None => PodManifest::from_toml(&toml) + .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?, + }; Ok((manifest, PromptLoader::builtins_only())) } +fn pod_name_overlay_toml(pod_name: &str) -> String { + let mut pod = toml::value::Table::new(); + pod.insert("name".into(), toml::Value::String(pod_name.to_string())); + let mut root = toml::value::Table::new(); + root.insert("pod".into(), toml::Value::Table(pod)); + toml::to_string(&toml::Value::Table(root)).expect("pod name overlay serialisation cannot fail") +} + fn build_factory_with_user_manifest_path( cli: &Cli, user_manifest: Option, @@ -129,6 +166,12 @@ fn build_factory_with_user_manifest_path( .map_err(|e| format!("failed to parse overlay TOML: {e}"))?; } + if let Some(pod_name) = cli.pod.as_deref() { + factory = factory + .with_overlay_toml(&pod_name_overlay_toml(pod_name)) + .map_err(|e| format!("failed to apply --pod overlay: {e}"))?; + } + Ok(factory) } @@ -136,7 +179,7 @@ fn build_factory_with_user_manifest_path( async fn main() -> ExitCode { let cli = Cli::parse(); - let (manifest, loader) = match resolve_manifest(&cli) { + let (mut manifest, loader) = match resolve_manifest(&cli) { Ok(pair) => pair, Err(e) => { eprintln!("error: {e}"); @@ -214,6 +257,30 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } } + } else if let Some(pod_name) = cli.pod.as_deref() { + manifest.pod.name = pod_name.to_string(); + match store.read_by_name(pod_name) { + Ok(Some(_)) => { + match Pod::restore_from_pod_metadata(pod_name, manifest, store, loader).await { + Ok(p) => p, + Err(e) => { + eprintln!("error: failed to restore pod {pod_name}: {e}"); + return ExitCode::FAILURE; + } + } + } + Ok(None) => match Pod::from_manifest(manifest, store, loader).await { + Ok(p) => p, + Err(e) => { + eprintln!("error: failed to create pod {pod_name}: {e}"); + return ExitCode::FAILURE; + } + }, + Err(e) => { + eprintln!("error: failed to read pod state for {pod_name}: {e}"); + return ExitCode::FAILURE; + } + } } else { match Pod::from_manifest(manifest, store, loader).await { Ok(p) => p, @@ -369,6 +436,56 @@ permission = "write" assert_eq!(manifest.pod.name, "from-env"); } + #[test] + fn pod_flag_conflicts_with_session() { + let segment_id = session_store::new_segment_id(); + let segment_id = segment_id.to_string(); + let err = + Cli::try_parse_from(["pod", "--pod", "agent", "--session", &segment_id]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn pod_flag_sets_requested_name_after_manifest_resolution() { + let tmp = TempDir::new().unwrap(); + let manifest = tmp.path().join("manifest.toml"); + write(&manifest, &manifest_toml("from-file", tmp.path())); + let cli = Cli::try_parse_from([ + "pod", + "--manifest", + manifest.to_str().unwrap(), + "--pod", + "from-flag", + ]) + .unwrap(); + + let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); + + assert_eq!(manifest.pod.name, "from-flag"); + } + + #[test] + fn pod_flag_supplies_missing_name_for_single_manifest() { + let tmp = TempDir::new().unwrap(); + let manifest = tmp.path().join("manifest.toml"); + write( + &manifest, + &manifest_toml("unused", tmp.path()).replace("name = \"unused\"\n", ""), + ); + let cli = Cli::try_parse_from([ + "pod", + "--manifest", + manifest.to_str().unwrap(), + "--pod", + "from-flag", + ]) + .unwrap(); + + let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap(); + + assert_eq!(manifest.pod.name, "from-flag"); + } + #[test] fn manifest_mode_loads_single_file_with_minimal_prompt_loader() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 6b3cbe37..4bc61da9 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -45,12 +45,20 @@ fn resolve_socket(pod_name: &str, override_path: Option) -> PathBuf { }) } +#[derive(Debug)] enum Mode { Spawn, Attach { pod_name: String, socket_override: Option, }, + /// `tui --pod `: attach to a live Pod by name if possible; + /// otherwise launch `pod --pod ` so the pod process resumes from + /// name-keyed state or creates a fresh same-name Pod. + PodName { + pod_name: String, + socket_override: Option, + }, /// `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 { - let args: Vec = std::env::args().skip(1).collect(); + parse_args_from(std::env::args().skip(1)) +} + +fn parse_args_from(args: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let args: Vec = args.into_iter().map(Into::into).collect(); let mut resume = false; let mut session: Option = None; + let mut pod: Option = None; let mut socket_override: Option = None; let mut positional: Option = None; @@ -99,6 +117,11 @@ fn parse_args() -> Result { ); 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 { } 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, +) -> Result<(), Box> { + 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> { // 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 { 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" + ); + } +} diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 5a0ffdb8..79eac9fa 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -90,56 +90,18 @@ type InlineTerminal = Terminal>; /// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and /// passes `--session ` to the spawned `pod` child. pub async fn run(resume_from: Option) -> Result { - 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) -> Result` 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 { + 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 { + 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 { 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 ` so it restores /// from `id` and appends to the same session log. resume_from: Option, + /// When true, launch the child with `--pod ` 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, } }