From b6af761da04396f34670d2809123ffd11bd7dc80 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 8 Jun 2026 10:34:50 +0900 Subject: [PATCH] runtime: separate workspace pod and profile identity --- crates/client/src/spawn.rs | 103 +++++++++++---- crates/client/src/ticket_role.rs | 5 +- crates/manifest/src/profile.rs | 70 +++++----- crates/pod/src/entrypoint.rs | 212 ++++++++++++++++++++++--------- crates/tui/src/lib.rs | 17 ++- crates/tui/src/multi_pod.rs | 9 +- crates/tui/src/single_pod.rs | 3 +- crates/tui/src/spawn.rs | 74 +++++------ crates/yoi/src/main.rs | 208 +++++++++++++++++++++++------- 9 files changed, 482 insertions(+), 219 deletions(-) diff --git a/crates/client/src/spawn.rs b/crates/client/src/spawn.rs index 98251bf2..7ac87249 100644 --- a/crates/client/src/spawn.rs +++ b/crates/client/src/spawn.rs @@ -29,18 +29,16 @@ pub struct SpawnConfig { /// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る /// 名前との突き合わせに使う。 pub pod_name: String, - /// Optional profile selector. When present the child is launched with - /// `--profile`; the Pod name is supplied through `--profile-pod-name` so - /// profile evaluation stays separate from `--pod` restore semantics. + /// Optional reusable Profile selector. Pod identity is always supplied + /// separately with `--pod`; profile selection must not imply a name. pub profile: Option, - /// pod の current_dir。 - pub cwd: PathBuf, + /// Explicit runtime workspace root. The child uses it as process cwd and + /// receives it via `--workspace` so startup does not infer workspace + /// identity from the parent process cwd. + pub workspace_root: PathBuf, /// `Some(id)` のとき `--session ` を付与し、当該セッションから /// resume させる。 pub resume_from: Option, - /// true のとき `--pod ` を付与し、pod 側で name-keyed state - /// があれば resume、なければ同名の新規 Pod として起動させる。 - pub resume_by_pod_name: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -107,6 +105,27 @@ impl From for SpawnError { } } +fn runtime_args(config: &SpawnConfig) -> Vec { + let mut args = vec![ + "--workspace".to_string(), + config.workspace_root.display().to_string(), + ]; + if let Some(id) = config.resume_from { + args.extend([ + "--session".to_string(), + id.to_string(), + "--pod".to_string(), + config.pod_name.clone(), + ]); + } else { + args.extend(["--pod".to_string(), config.pod_name.clone()]); + if let Some(profile) = &config.profile { + args.extend(["--profile".to_string(), profile.clone()]); + } + } + args +} + /// pod を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。 /// /// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる @@ -124,27 +143,13 @@ where let mut command = Command::new(config.runtime_command.program()); command .args(config.runtime_command.prefix_args()) - .current_dir(&config.cwd) + .current_dir(&config.workspace_root) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::from(stderr_file)) .process_group(0); - if let Some(profile) = &config.profile { - command - .arg("--profile") - .arg(profile) - .arg("--profile-pod-name") - .arg(&config.pod_name); - } - if config.resume_by_pod_name && config.profile.is_none() { - command.arg("--pod").arg(&config.pod_name); - } - if let Some(id) = config.resume_from { - command - .arg("--session") - .arg(id.to_string()) - .arg("--session-pod-name") - .arg(&config.pod_name); + for arg in runtime_args(&config) { + command.arg(arg); } let mut child = command .spawn() @@ -311,3 +316,51 @@ impl StderrTail { self.lines.into_iter().collect::>().join(" | ") } } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + fn base_config() -> SpawnConfig { + SpawnConfig { + runtime_command: PodRuntimeCommand::new("/bin/yoi", vec![OsString::from("pod")]), + pod_name: "explicit-pod".to_string(), + profile: Some("project:companion".to_string()), + workspace_root: PathBuf::from("/work/other-project"), + resume_from: None, + } + } + + #[test] + fn runtime_args_keep_workspace_pod_and_profile_separate() { + assert_eq!( + runtime_args(&base_config()), + vec![ + "--workspace", + "/work/other-project", + "--pod", + "explicit-pod", + "--profile", + "project:companion", + ] + ); + } + + #[test] + fn runtime_args_use_session_mode_without_profile_identity_alias() { + let mut config = base_config(); + config.resume_from = Some(Uuid::nil()); + assert_eq!( + runtime_args(&config), + vec![ + "--workspace", + "/work/other-project", + "--session", + "00000000-0000-0000-0000-000000000000", + "--pod", + "explicit-pod", + ] + ); + } +} diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index aefc9d9d..2b2f0e75 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -162,9 +162,8 @@ impl TicketRoleLaunchPlan { runtime_command, pod_name: self.pod_name.clone(), profile: Some(self.profile.clone()), - cwd: self.workspace_root.clone(), + workspace_root: self.workspace_root.clone(), resume_from: None, - resume_by_pod_name: false, }) } } @@ -1025,7 +1024,7 @@ workflow = "ticket-review-workflow" .unwrap(); assert_eq!(spawn.pod_name, "reviewer-fixed"); assert_eq!(spawn.profile.as_deref(), Some("builtin:default")); - assert_eq!(spawn.cwd, temp.path()); + assert_eq!(spawn.workspace_root, temp.path()); } #[test] diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 00f30b19..d0d6334a 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -24,7 +24,6 @@ const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1"; const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default"; const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua"); const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); -const DEFAULT_POD_NAME: &str = "yoi"; const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml"; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -551,7 +550,7 @@ fn resolve_lua_profile_value( validate_profile_paths(&profile)?; let pod_name = options .pod_name - .unwrap_or_else(|| derive_pod_name(&source, profile.slug.as_deref())); + .ok_or(ProfileError::MissingRuntimePodName)?; let profile_meta = Some(ProfileMetadata { name: profile.slug.clone().or_else(|| source_name(&source)), description: profile.description.clone(), @@ -1221,18 +1220,6 @@ fn builtin_model_context_window(reference: &str) -> Option { } None } -fn derive_pod_name(source: &ProfileSource, slug: Option<&str>) -> String { - if matches!(source, ProfileSource::Registry { source: ProfileRegistrySource::Builtin, name, .. } if name == BUILTIN_DEFAULT_PROFILE_NAME) - || slug == Some(BUILTIN_DEFAULT_PROFILE_NAME) - { - return DEFAULT_POD_NAME.to_string(); - } - let raw = slug - .map(str::to_string) - .or_else(|| source_name(source)) - .unwrap_or_else(|| DEFAULT_POD_NAME.to_string()); - sanitise_pod_name(&raw) -} fn source_name(source: &ProfileSource) -> Option { match source { ProfileSource::Path { path } => path @@ -1242,23 +1229,6 @@ fn source_name(source: &ProfileSource) -> Option { ProfileSource::Registry { name, .. } => Some(name.clone()), } } -fn sanitise_pod_name(raw: &str) -> String { - let name: String = raw - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '-' - } - }) - .collect(); - if name.is_empty() { - DEFAULT_POD_NAME.to_string() - } else { - name - } -} fn canonicalize_existing_dir(path: &Path) -> Result { path.canonicalize() .map_err(|source| ProfileError::CommandIo { @@ -1295,7 +1265,7 @@ pub fn resolve_profile_artifact( source, base_dir, base_dir, - ProfileResolveOptions::default(), + ProfileResolveOptions::with_pod_name("artifact-pod"), raw_artifact.clone(), raw_artifact, None, @@ -1342,6 +1312,8 @@ pub enum ProfileError { InvalidWorkspaceOverride { path: PathBuf, message: String }, #[error("no default profile is configured")] NoDefaultProfile, + #[error("profile resolution requires an explicit runtime Pod name")] + MissingRuntimePodName, #[error("profile not found: {selector}")] ProfileNotFound { selector: String }, #[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")] @@ -1404,6 +1376,16 @@ mod tests { assert_eq!(default.path, None); assert_eq!(default.provenance, "builtin:default"); } + #[test] + fn profile_resolution_requires_runtime_pod_name() { + let tmp = TempDir::new().unwrap(); + let err = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .unwrap_err(); + assert!(matches!(err, ProfileError::MissingRuntimePodName)); + } + #[test] fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() { let tmp = TempDir::new().unwrap(); @@ -1570,9 +1552,12 @@ return profile { let tmp = TempDir::new().unwrap(); let resolved = ProfileResolver::new() .with_workspace_base(tmp.path()) - .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"), + ProfileResolveOptions::with_pod_name("runtime-workspace"), + ) .unwrap(); - assert_eq!(resolved.manifest.pod.name, "yoi"); + assert_eq!(resolved.manifest.pod.name, "runtime-workspace"); assert_eq!( resolved.manifest.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5") @@ -1621,10 +1606,13 @@ record_event_trace = false let resolved = ProfileResolver::new() .with_workspace_base(&nested) - .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .resolve( + &ProfileSelector::Default, + ProfileResolveOptions::with_pod_name("runtime-pod"), + ) .unwrap(); - assert_eq!(resolved.manifest.pod.name, "yoi"); + assert_eq!(resolved.manifest.pod.name, "runtime-pod"); assert_eq!(resolved.manifest.worker.language, "ja"); assert!(!resolved.manifest.session.record_event_trace); assert_eq!( @@ -1678,7 +1666,10 @@ language = "nested" let resolved = ProfileResolver::new() .with_workspace_base(&child) - .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .resolve( + &ProfileSelector::Default, + ProfileResolveOptions::with_pod_name("runtime-pod"), + ) .unwrap(); assert_eq!(resolved.manifest.worker.language, "nested"); @@ -1710,7 +1701,10 @@ language = "nested" let err = ProfileResolver::new() .with_workspace_base(tmp.path()) - .resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) + .resolve( + &ProfileSelector::Default, + ProfileResolveOptions::with_pod_name("runtime-pod"), + ) .unwrap_err(); assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. })); assert!(err.to_string().contains("pod.name")); diff --git a/crates/pod/src/entrypoint.rs b/crates/pod/src/entrypoint.rs index 3d2da442..054a1acf 100644 --- a/crates/pod/src/entrypoint.rs +++ b/crates/pod/src/entrypoint.rs @@ -19,15 +19,14 @@ struct Cli { #[arg( long, value_name = "PROFILE", - conflicts_with_all = ["manifest", "project", "pod", "session", "adopt"] + conflicts_with_all = ["manifest", "project", "session", "adopt"] )] profile: Option, - /// Pod name override for a freshly-created profile Pod. This does not use - /// `--pod` restore semantics, so it must not attach/restore existing Pod - /// state by re-evaluating the profile source. - #[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])] - profile_pod_name: Option, + /// Runtime workspace root for profile discovery, default Pod naming, and process context. + /// Defaults to the current directory. + #[arg(long, value_name = "PATH")] + workspace: Option, /// Manifest TOML to use directly as a one-file compatibility/debug input. /// This bypasses profile discovery but still applies builtin defaults and @@ -73,7 +72,7 @@ struct Cli { /// 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"])] + #[arg(long, value_name = "NAME", conflicts_with_all = ["adopt"])] pod: Option, /// Require `--pod` to restore existing Pod state instead of creating a @@ -90,6 +89,51 @@ struct Cli { session: Option, } +fn runtime_workspace_root(cli: &Cli) -> Result { + let raw = cli.workspace.as_deref().unwrap_or_else(|| Path::new(".")); + if raw.is_absolute() { + Ok(raw.to_path_buf()) + } else { + std::env::current_dir() + .map_err(|e| format!("failed to resolve current directory for workspace: {e}")) + .map(|cwd| cwd.join(raw)) + } +} + +fn runtime_pod_name(cli: &Cli, workspace_root: &Path) -> String { + cli.session_pod_name + .as_deref() + .or(cli.pod.as_deref()) + .map(str::to_string) + .unwrap_or_else(|| default_pod_name_for_workspace(workspace_root)) +} + +fn default_pod_name_for_workspace(workspace_root: &Path) -> String { + let raw = workspace_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace"); + sanitise_pod_name(raw) +} + +fn sanitise_pod_name(raw: &str) -> String { + let name: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '-' + } + }) + .collect(); + if name.chars().any(|c| c.is_ascii_alphanumeric()) { + name + } else { + "workspace".to_string() + } +} + fn resolve_manifest(cli: &Cli) -> Result<(PodManifest, PromptLoader), String> { resolve_manifest_with_profile_loader(cli, load_profile) } @@ -99,15 +143,17 @@ fn resolve_manifest_with_profile_loader( load_profile_fn: F, ) -> Result<(PodManifest, PromptLoader), String> where - F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>, + F: FnOnce(&ProfileSelector, &Path, &str) -> Result<(PodManifest, PromptLoader), String>, { + let workspace_root = runtime_workspace_root(cli)?; + let runtime_pod_name = runtime_pod_name(cli, &workspace_root); let mut manifest_and_loader = if let Some(config_json) = cli.spawn_config_json.as_deref() { load_spawn_config_json(config_json)? } else if let Some(profile) = &cli.profile { let selector = ProfileSelector::parse_cli(profile); - load_profile_fn(&selector, cli.profile_pod_name.as_deref())? + load_profile_fn(&selector, &workspace_root, &runtime_pod_name)? } else if let Some(path) = &cli.manifest { - load_single_manifest(path, cli.pod.as_deref())? + load_single_manifest(path, cli.pod.as_deref(), &runtime_pod_name)? } else { if cli.project.is_some() { return Err( @@ -117,7 +163,7 @@ where ); } let selector = ProfileSelector::Default; - load_profile_fn(&selector, cli.pod.as_deref())? + load_profile_fn(&selector, &workspace_root, &runtime_pod_name)? }; apply_session_restore_overrides(&mut manifest_and_loader.0, cli)?; @@ -125,7 +171,7 @@ where } fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> { - if let Some(pod_name) = cli.session_pod_name.as_deref() { + if let Some(pod_name) = cli.session_pod_name.as_deref().or(cli.pod.as_deref()) { manifest.pod.name = pod_name.to_string(); } Ok(()) @@ -141,14 +187,11 @@ fn load_spawn_config_json(config_json: &str) -> Result<(PodManifest, PromptLoade fn load_profile( selector: &ProfileSelector, - pod_name_override: Option<&str>, + workspace_root: &Path, + pod_name: &str, ) -> Result<(PodManifest, PromptLoader), String> { - let cwd = std::env::current_dir() - .map_err(|e| format!("failed to resolve current directory for profile: {e}"))?; - let resolver = ProfileResolver::new().with_workspace_base(cwd); - let options = pod_name_override - .map(ProfileResolveOptions::with_pod_name) - .unwrap_or_default(); + let resolver = ProfileResolver::new().with_workspace_base(workspace_root); + let options = ProfileResolveOptions::with_pod_name(pod_name); let resolved = resolver.resolve(selector, options).map_err(|e| { format!( "failed to resolve profile {}: {e}", @@ -160,7 +203,8 @@ fn load_profile( fn load_single_manifest( path: &Path, - pod_name_override: Option<&str>, + explicit_pod_name: Option<&str>, + default_pod_name: &str, ) -> Result<(PodManifest, PromptLoader), String> { let toml = std::fs::read_to_string(path) .map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?; @@ -182,8 +226,10 @@ fn load_single_manifest( .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))? .resolve_paths(base_dir), ); - if let Some(pod_name) = pod_name_override { + if let Some(pod_name) = explicit_pod_name { config.pod.name = Some(pod_name.to_string()); + } else if config.pod.name.is_none() { + config.pod.name = Some(default_pod_name.to_string()); } let manifest = PodManifest::try_from(config) .map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?; @@ -237,6 +283,13 @@ fn exit_code_from_i32(code: i32) -> ExitCode { } async fn run_cli_inner(cli: Cli) -> ExitCode { + let workspace_root = match runtime_workspace_root(&cli) { + Ok(root) => root, + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + }; let (mut manifest, loader) = match resolve_manifest(&cli) { Ok(pair) => pair, Err(e) => { @@ -245,6 +298,14 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { } }; + if let Err(e) = std::env::set_current_dir(&workspace_root) { + eprintln!( + "error: failed to enter runtime workspace {}: {e}", + workspace_root.display() + ); + return ExitCode::FAILURE; + } + // Initialize persistent store. `paths::sessions_dir()` only // returns None when none of YOI_HOME / YOI_DATA_DIR / // HOME is set — surface that as a hard error to match the @@ -538,22 +599,20 @@ permission = "write" "yoi pod", "--profile", profile.to_str().unwrap(), - "--profile-pod-name", + "--pod", "from-profile-name", ]) .unwrap(); let mut called = false; let (manifest, loader) = - resolve_manifest_with_profile_loader(&cli, |selector, pod_name| { + resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, pod_name| { called = true; assert_eq!(selector, &ProfileSelector::path(profile.clone())); - assert_eq!(pod_name, Some("from-profile-name")); + assert_eq!(pod_name, "from-profile-name"); let mut manifest = PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap(); - if let Some(pod_name) = pod_name { - manifest.pod.name = pod_name.to_string(); - } + manifest.pod.name = pod_name.to_string(); Ok((manifest, PromptLoader::builtins_only())) }) .unwrap(); @@ -571,14 +630,14 @@ permission = "write" "yoi pod", "--profile", "project:coder", - "--profile-pod-name", + "--pod", "from-profile-name", ]) .unwrap(); let mut called = false; let (manifest, _loader) = - resolve_manifest_with_profile_loader(&cli, |selector, pod_name| { + resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, pod_name| { called = true; assert_eq!( selector, @@ -589,9 +648,7 @@ permission = "write" ); let mut manifest = PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap(); - if let Some(pod_name) = pod_name { - manifest.pod.name = pod_name.to_string(); - } + manifest.pod.name = pod_name.to_string(); Ok((manifest, PromptLoader::builtins_only())) }) .unwrap(); @@ -601,31 +658,74 @@ permission = "write" } #[test] - fn normal_startup_uses_default_profile() { + fn profile_without_explicit_pod_uses_workspace_basename_not_selector() { let tmp = TempDir::new().unwrap(); - let cli = Cli::try_parse_from(["yoi pod"]).unwrap(); + let workspace = tmp.path().join("other-workspace"); + std::fs::create_dir(&workspace).unwrap(); + let cli = Cli::try_parse_from([ + "yoi pod", + "--workspace", + workspace.to_str().unwrap(), + "--profile", + "project:companion", + ]) + .unwrap(); let mut called = false; let (manifest, _loader) = - resolve_manifest_with_profile_loader(&cli, |selector, pod_name| { + resolve_manifest_with_profile_loader(&cli, |selector, workspace_root, pod_name| { called = true; - assert_eq!(selector, &ProfileSelector::Default); - assert_eq!(pod_name, None); - let manifest = - PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path())) + assert_eq!( + selector, + &ProfileSelector::source_named( + manifest::ProfileRegistrySource::Project, + "companion" + ) + ); + assert_eq!(workspace_root, workspace.as_path()); + assert_eq!(pod_name, "other-workspace"); + let mut manifest = + PodManifest::from_toml(&manifest_toml("profile-selector-name", tmp.path())) .unwrap(); + manifest.pod.name = pod_name.to_string(); Ok((manifest, PromptLoader::builtins_only())) }) .unwrap(); assert!(called); - assert_eq!(manifest.pod.name, "from-default-profile"); + assert_eq!(manifest.pod.name, "other-workspace"); + } + + #[test] + fn normal_startup_uses_default_profile() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("runtime-workspace"); + std::fs::create_dir(&workspace).unwrap(); + let cli = + Cli::try_parse_from(["yoi pod", "--workspace", workspace.to_str().unwrap()]).unwrap(); + let mut called = false; + + let (manifest, _loader) = + resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, pod_name| { + called = true; + assert_eq!(selector, &ProfileSelector::Default); + assert_eq!(pod_name, "runtime-workspace"); + let mut manifest = + PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path())) + .unwrap(); + manifest.pod.name = pod_name.to_string(); + Ok((manifest, PromptLoader::builtins_only())) + }) + .unwrap(); + + assert!(called); + assert_eq!(manifest.pod.name, "runtime-workspace"); } #[test] fn project_flag_no_longer_enables_ambient_manifest_cascade() { let cli = Cli::try_parse_from(["yoi pod", "--project", "."]).unwrap(); - let err = resolve_manifest_with_profile_loader(&cli, |_, _| { + let err = resolve_manifest_with_profile_loader(&cli, |_, _, _| { panic!("default profile loader must not run when deprecated --project is present") }) .unwrap_err(); @@ -700,16 +800,14 @@ permission = "write" let mut called = false; let (manifest, _loader) = - resolve_manifest_with_profile_loader(&cli, |selector, pod_name| { + resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, pod_name| { called = true; assert_eq!(selector, &ProfileSelector::Default); - assert_eq!(pod_name, Some("agent")); + assert_eq!(pod_name, "agent"); let mut manifest = PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path())) .unwrap(); - if let Some(pod_name) = pod_name { - manifest.pod.name = pod_name.to_string(); - } + manifest.pod.name = pod_name.to_string(); Ok((manifest, PromptLoader::builtins_only())) }) .unwrap(); @@ -723,7 +821,6 @@ permission = "write" let segment_id = session_store::new_segment_id().to_string(); for args in [ vec!["yoi pod", "--profile", "p.lua", "--manifest", "m.toml"], - vec!["yoi pod", "--profile", "p.lua", "--pod", "agent"], vec!["yoi pod", "--profile", "p.lua", "--session", &segment_id], ] { let err = Cli::try_parse_from(args).unwrap_err(); @@ -732,23 +829,16 @@ permission = "write" } #[test] - fn profile_pod_name_requires_profile() { - let err = Cli::try_parse_from(["yoi pod", "--profile-pod-name", "agent"]).unwrap_err(); - assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + fn profile_and_pod_are_independent_startup_inputs() { + let cli = Cli::try_parse_from(["yoi pod", "--profile", "p.lua", "--pod", "agent"]).unwrap(); + assert_eq!(cli.profile.as_deref(), Some("p.lua")); + assert_eq!(cli.pod.as_deref(), Some("agent")); } #[test] - fn profile_pod_name_is_not_restore_pod_flag() { - let cli = Cli::try_parse_from([ - "yoi pod", - "--profile", - "p.lua", - "--profile-pod-name", - "agent", - ]) - .unwrap(); - assert_eq!(cli.profile_pod_name.as_deref(), Some("agent")); - assert!(cli.pod.is_none()); + fn removed_profile_pod_name_alias_is_rejected() { + let err = Cli::try_parse_from(["yoi pod", "--profile-pod-name", "agent"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } #[test] diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index feb19fb8..b9bede35 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -33,11 +33,13 @@ use client::PodRuntimeCommand; pub struct LaunchOptions { pub mode: LaunchMode, pub runtime_command: PodRuntimeCommand, + pub workspace_root: PathBuf, } #[derive(Debug, Clone)] pub enum LaunchMode { Spawn { + pod_name: Option, profile: Option, }, /// `yoi ` / `yoi --pod `: attach to a live Pod by name if @@ -61,8 +63,17 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { let LaunchOptions { mode, runtime_command, + workspace_root, } = options; + if let Err(e) = std::env::set_current_dir(&workspace_root) { + eprintln!( + "yoi: failed to enter workspace {}: {e}", + workspace_root.display() + ); + return ExitCode::FAILURE; + } + if let Err(e) = enable_raw_mode() { eprintln!("yoi: failed to enter raw mode: {e}"); return ExitCode::FAILURE; @@ -74,8 +85,8 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } let result = match mode { - LaunchMode::Spawn { profile } => { - single_pod::run_spawn(None, profile, runtime_command).await + LaunchMode::Spawn { pod_name, profile } => { + single_pod::run_spawn(None, pod_name, profile, runtime_command).await } LaunchMode::PodName { pod_name, @@ -83,7 +94,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } => single_pod::run_pod_name(pod_name, socket_override, runtime_command).await, LaunchMode::Resume => single_pod::run_resume(runtime_command).await, LaunchMode::ResumeWithSession(id) => { - single_pod::run_spawn(Some(id), None, runtime_command).await + single_pod::run_spawn(Some(id), None, None, runtime_command).await } LaunchMode::Panel => single_pod::run_panel(runtime_command).await, }; diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 24c7f6b2..2b37a2ae 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -1622,9 +1622,8 @@ async fn restore_workspace_companion_pod( runtime_command, pod_name: pod_name.to_string(), profile: None, - cwd: workspace_root.to_path_buf(), + workspace_root: workspace_root.to_path_buf(), resume_from: None, - resume_by_pod_name: true, }; spawn_pod(config, |_| {}).await.map(|_| ()) } @@ -1638,9 +1637,8 @@ async fn spawn_workspace_companion_pod( runtime_command, pod_name: pod_name.to_string(), profile: None, - cwd: workspace_root.to_path_buf(), + workspace_root: workspace_root.to_path_buf(), resume_from: None, - resume_by_pod_name: false, }; spawn_pod(config, |_| {}).await.map(|_| ()) } @@ -1654,9 +1652,8 @@ async fn restore_orchestrator_pod( runtime_command, pod_name: pod_name.to_string(), profile: None, - cwd: workspace_root.to_path_buf(), + workspace_root: workspace_root.to_path_buf(), resume_from: None, - resume_by_pod_name: true, }; spawn_pod(config, |_| {}).await.map(|_| ()) } diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 9495dbb7..1576e45c 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -215,10 +215,11 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> pub(crate) async fn run_spawn( resume_from: Option, + pod_name: Option, profile: Option, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { - let ready = match spawn::run(resume_from, profile, runtime_command.clone()).await? { + let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), }; diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 9a08242c..7d3ac2a1 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -75,6 +75,7 @@ type InlineTerminal = Terminal>; /// passes `--session ` to the spawned Pod runtime child. pub async fn run( resume_from: Option, + pod_name: Option, profile: Option, runtime_command: PodRuntimeCommand, ) -> Result { @@ -90,15 +91,16 @@ pub async fn run( defaults.default_profile_index, ); + let selected_name = pod_name.unwrap_or(defaults.default_name); + let immediate = resume_from.is_some() || profile.is_some() && !selected_name.is_empty(); let mut form = Form { cwd: defaults.cwd.clone(), scope_origin: defaults.scope_origin, - name_cursor: defaults.default_name.chars().count(), - name: defaults.default_name, + name_cursor: selected_name.chars().count(), + name: selected_name, message: None, editing: true, resume_from, - resume_by_pod_name: false, profile_choices, profile_index, }; @@ -106,34 +108,41 @@ pub async fn run( let mut terminal = make_inline_terminal()?; // Phase 1: confirm / cancel. - loop { - terminal.draw(|f| draw_form(f, &form))?; - match poll_event()? { - None => continue, - Some(Action::Submit) => { - if form.name.trim().is_empty() { - form.message = Some(("name is required".to_string(), MessageKind::Error)); - continue; + if !immediate { + loop { + terminal.draw(|f| draw_form(f, &form))?; + match poll_event()? { + None => continue, + Some(Action::Submit) => { + if form.name.trim().is_empty() { + form.message = Some(("name is required".to_string(), MessageKind::Error)); + continue; + } + break; } - break; + Some(Action::Cancel) => { + form.editing = false; + form.message = Some(("cancelled".to_string(), MessageKind::Info)); + terminal.draw(|f| draw_form(f, &form))?; + drop(terminal); + return Ok(SpawnOutcome::Cancelled); + } + Some(Action::Char(c)) => form.insert_char(c), + Some(Action::Backspace) => form.backspace(), + Some(Action::Delete) => form.delete_forward(), + Some(Action::Left) => form.move_left(), + Some(Action::Right) => form.move_right(), + Some(Action::Home) => form.name_cursor = 0, + Some(Action::End) => form.name_cursor = form.name.chars().count(), + Some(Action::ProfileNext) => form.cycle_profile_next(), + Some(Action::ProfilePrev) => form.cycle_profile_prev(), } - Some(Action::Cancel) => { - form.editing = false; - form.message = Some(("cancelled".to_string(), MessageKind::Info)); - terminal.draw(|f| draw_form(f, &form))?; - drop(terminal); - return Ok(SpawnOutcome::Cancelled); - } - Some(Action::Char(c)) => form.insert_char(c), - Some(Action::Backspace) => form.backspace(), - Some(Action::Delete) => form.delete_forward(), - Some(Action::Left) => form.move_left(), - Some(Action::Right) => form.move_right(), - Some(Action::Home) => form.name_cursor = 0, - Some(Action::End) => form.name_cursor = form.name.chars().count(), - Some(Action::ProfileNext) => form.cycle_profile_next(), - Some(Action::ProfilePrev) => form.cycle_profile_prev(), } + } else if form.name.trim().is_empty() { + return Err(SpawnError::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "name is required", + ))); } // Phase 2: launch pod and wait for ready line. Drop the cursor @@ -290,7 +299,6 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { message: Some(("resuming pod...".to_string(), MessageKind::Progress)), editing: false, resume_from: None, - resume_by_pod_name: true, profile_choices: Vec::new(), profile_index: 0, } @@ -370,9 +378,8 @@ async fn wait_for_ready( runtime_command: runtime_command.clone(), pod_name: form.name.clone(), profile: form.selected_profile_selector(), - cwd: form.cwd.clone(), + workspace_root: form.cwd.clone(), 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)); @@ -418,9 +425,6 @@ 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, /// Optional profile choices passed with `--profile` for /// fresh spawns. This is not used for resume/attach flows because those must /// restore Pod state rather than re-evaluate a profile source. @@ -622,7 +626,6 @@ mod tests { message: None, editing: true, resume_from: None, - resume_by_pod_name: false, profile_choices: Vec::new(), profile_index: 0, } @@ -642,7 +645,6 @@ mod tests { assert_eq!(f.name, "agent"); assert_eq!(f.name_cursor, "agent".chars().count()); assert_eq!(f.resume_from, None); - assert!(f.resume_by_pod_name); assert!(!f.editing); assert_eq!( f.message, diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 812073c1..edbbb7cf 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -18,7 +18,10 @@ enum Mode { Ticket(ticket_cli::TicketCli), PodRuntime(Vec), Keys, - Tui(LaunchMode), + Tui { + mode: LaunchMode, + workspace_root: PathBuf, + }, } #[derive(Debug)] @@ -75,7 +78,10 @@ async fn main() -> ExitCode { }, Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("yoi pod", args).await, Mode::Keys => tui::keys::launch().await, - Mode::Tui(mode) => { + Mode::Tui { + mode, + workspace_root, + } => { let runtime_command = match PodRuntimeCommand::resolve() { Ok(command) => command, Err(e) => { @@ -86,6 +92,7 @@ async fn main() -> ExitCode { tui::launch(LaunchOptions { mode, runtime_command, + workspace_root, }) .await } @@ -107,7 +114,14 @@ where fn parse_args_slice(args: &[String]) -> Result { if args.is_empty() { - return Ok(Mode::Tui(LaunchMode::Spawn { profile: None })); + return Ok(Mode::Tui { + mode: LaunchMode::Spawn { + pod_name: None, + profile: None, + }, + workspace_root: std::env::current_dir() + .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))?, + }); } match args[0].as_str() { @@ -119,10 +133,10 @@ fn parse_args_slice(args: &[String]) -> Result { return Ok(Mode::Ticket(ticket_cli)); } "panel" => { - if args.len() != 1 { - return Err(ParseError("yoi panel does not accept arguments".into())); - } - return Ok(Mode::Tui(LaunchMode::Panel)); + return Ok(Mode::Tui { + mode: LaunchMode::Panel, + workspace_root: parse_panel_workspace(&args[1..])?, + }); } "keys" => { if args.len() != 1 { @@ -140,14 +154,20 @@ fn parse_args_slice(args: &[String]) -> Result { return Ok(Mode::MemoryLint(options)); } "memory" => { - return Ok(Mode::Tui(LaunchMode::PodName { - pod_name: "memory".to_string(), - socket_override: None, - })); + return Ok(Mode::Tui { + mode: LaunchMode::PodName { + pod_name: "memory".to_string(), + socket_override: None, + }, + workspace_root: std::env::current_dir() + .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))?, + }); } _ => {} } + let mut workspace_root = std::env::current_dir() + .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))?; let mut resume = false; let mut session = None; let mut pod_name = None; @@ -190,6 +210,16 @@ fn parse_args_slice(args: &[String]) -> Result { socket_override = Some(PathBuf::from(value)); i += 2; } + "--workspace" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--workspace requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--workspace requires a value".to_string())); + } + workspace_root = PathBuf::from(value); + i += 2; + } "--profile" => { let value = args .get(i + 1) @@ -224,6 +254,14 @@ fn parse_args_slice(args: &[String]) -> Result { socket_override = Some(PathBuf::from(value)); i += 1; } + arg if arg.starts_with("--workspace=") => { + let value = arg.trim_start_matches("--workspace="); + if value.is_empty() { + return Err(ParseError("--workspace requires a value".to_string())); + } + workspace_root = PathBuf::from(value); + i += 1; + } arg if arg.starts_with("--profile=") => { let value = arg.trim_start_matches("--profile="); if value.is_empty() { @@ -253,11 +291,7 @@ fn parse_args_slice(args: &[String]) -> Result { } if profile.is_some() - && (resume - || session.is_some() - || pod_name.is_some() - || positional.is_some() - || socket_override.is_some()) + && (resume || session.is_some() || positional.is_some() || socket_override.is_some()) { return Err(ParseError( "--profile can only be used for fresh spawn".to_string(), @@ -290,20 +324,66 @@ fn parse_args_slice(args: &[String]) -> Result { } let pod_name = pod_name.or(positional); + if let Some(profile) = profile { + return Ok(Mode::Tui { + mode: LaunchMode::Spawn { + pod_name, + profile: Some(profile), + }, + workspace_root, + }); + } if let Some(pod_name) = pod_name { - return Ok(Mode::Tui(LaunchMode::PodName { - pod_name, - socket_override, - })); + return Ok(Mode::Tui { + mode: LaunchMode::PodName { + pod_name, + socket_override, + }, + workspace_root, + }); } if resume { - return Ok(Mode::Tui(LaunchMode::Resume)); + return Ok(Mode::Tui { + mode: LaunchMode::Resume, + workspace_root, + }); } if let Some(id) = session { - return Ok(Mode::Tui(LaunchMode::ResumeWithSession(id))); + return Ok(Mode::Tui { + mode: LaunchMode::ResumeWithSession(id), + workspace_root, + }); } - Ok(Mode::Tui(LaunchMode::Spawn { profile })) + Ok(Mode::Tui { + mode: LaunchMode::Spawn { + pod_name: None, + profile: None, + }, + workspace_root, + }) +} + +fn parse_panel_workspace(args: &[String]) -> Result { + match args { + [] => std::env::current_dir() + .map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))), + [flag, value] if flag == "--workspace" => Ok(PathBuf::from(value)), + [flag] if flag.starts_with("--workspace=") => { + let value = flag.trim_start_matches("--workspace="); + if value.is_empty() { + Err(ParseError("--workspace requires a value".to_string())) + } else { + Ok(PathBuf::from(value)) + } + } + [flag] if flag == "--workspace" => { + Err(ParseError("--workspace requires a value".to_string())) + } + _ => Err(ParseError( + "yoi panel accepts only --workspace ".to_string(), + )), + } } fn parse_session_id(value: &str) -> Result { @@ -314,7 +394,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi ticket [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Start a fresh Pod from a profile\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi ticket [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } @@ -331,10 +411,14 @@ mod tests { #[test] fn parse_pod_name_mode() { match parse_args_from(["--pod", "agent", "--socket", "/tmp/agent.sock"]).unwrap() { - Mode::Tui(LaunchMode::PodName { - pod_name, - socket_override, - }) => { + Mode::Tui { + mode: + LaunchMode::PodName { + pod_name, + socket_override, + }, + .. + } => { assert_eq!(pod_name, "agent"); assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock"))); } @@ -345,10 +429,14 @@ mod tests { #[test] fn parse_positional_name_uses_pod_name_mode() { match parse_args_from(["agent"]).unwrap() { - Mode::Tui(LaunchMode::PodName { - pod_name, - socket_override, - }) => { + Mode::Tui { + mode: + LaunchMode::PodName { + pod_name, + socket_override, + }, + .. + } => { assert_eq!(pod_name, "agent"); assert_eq!(socket_override, None); } @@ -359,10 +447,14 @@ mod tests { #[test] fn parse_memory_alone_remains_positional_pod_name() { match parse_args_from(["memory"]).unwrap() { - Mode::Tui(LaunchMode::PodName { - pod_name, - socket_override, - }) => { + Mode::Tui { + mode: + LaunchMode::PodName { + pod_name, + socket_override, + }, + .. + } => { assert_eq!(pod_name, "memory"); assert_eq!(socket_override, None); } @@ -405,10 +497,14 @@ mod tests { #[test] fn parse_literal_pod_name_still_available_with_flag() { match parse_args_from(["--pod", "pod"]).unwrap() { - Mode::Tui(LaunchMode::PodName { - pod_name, - socket_override, - }) => { + Mode::Tui { + mode: + LaunchMode::PodName { + pod_name, + socket_override, + }, + .. + } => { assert_eq!(pod_name, "pod"); assert_eq!(socket_override, None); } @@ -458,7 +554,10 @@ mod tests { #[test] fn memory_lint_with_other_second_word_remains_positional_pod_name() { match parse_args_from(["memory", "other"]).unwrap() { - Mode::Tui(LaunchMode::PodName { pod_name, .. }) => assert_eq!(pod_name, "memory"), + Mode::Tui { + mode: LaunchMode::PodName { pod_name, .. }, + .. + } => assert_eq!(pod_name, "memory"), _ => panic!("expected PodName mode"), } } @@ -498,9 +597,23 @@ mod tests { #[test] fn parse_profile_spawn_mode() { - match parse_args_from(["--profile", "/profiles/coder.lua"]).unwrap() { - Mode::Tui(LaunchMode::Spawn { profile }) => { - assert_eq!(profile, Some("/profiles/coder.lua".to_string())); + match parse_args_from([ + "--workspace", + "/tmp/other-workspace", + "--profile", + "project:companion", + "--pod", + "agent", + ]) + .unwrap() + { + Mode::Tui { + mode: LaunchMode::Spawn { pod_name, profile }, + workspace_root, + } => { + assert_eq!(pod_name, Some("agent".to_string())); + assert_eq!(profile, Some("project:companion".to_string())); + assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")); } _ => panic!("expected Spawn mode"), } @@ -554,8 +667,11 @@ mod tests { #[test] fn parse_panel_mode() { - match parse_args_from(["panel"]).unwrap() { - Mode::Tui(LaunchMode::Panel) => {} + match parse_args_from(["panel", "--workspace", "/tmp/other-workspace"]).unwrap() { + Mode::Tui { + mode: LaunchMode::Panel, + workspace_root, + } => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")), _ => panic!("expected Panel mode"), } }