runtime: separate workspace pod and profile identity

This commit is contained in:
2026-06-08 10:34:50 +09:00
parent 9df7f4eeb7
commit b6af761da0
9 changed files with 482 additions and 219 deletions
+14 -3
View File
@@ -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<String>,
profile: Option<String>,
},
/// `yoi <name>` / `yoi --pod <name>`: 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,
};
+3 -6
View File
@@ -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(|_| ())
}
+2 -1
View File
@@ -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<SegmentId>,
pod_name: Option<String>,
profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
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(()),
};
+38 -36
View File
@@ -75,6 +75,7 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// passes `--session <id>` to the spawned Pod runtime child.
pub async fn run(
resume_from: Option<SegmentId>,
pod_name: Option<String>,
profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
@@ -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 <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,
/// 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,