runtime: separate workspace pod and profile identity
This commit is contained in:
parent
9df7f4eeb7
commit
b6af761da0
|
|
@ -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<String>,
|
||||
/// 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 <id>` を付与し、当該セッションから
|
||||
/// resume させる。
|
||||
pub resume_from: Option<Uuid>,
|
||||
/// true のとき `--pod <pod_name>` を付与し、pod 側で name-keyed state
|
||||
/// があれば resume、なければ同名の新規 Pod として起動させる。
|
||||
pub resume_by_pod_name: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -107,6 +105,27 @@ impl From<io::Error> for SpawnError {
|
|||
}
|
||||
}
|
||||
|
||||
fn runtime_args(config: &SpawnConfig) -> Vec<String> {
|
||||
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::<Vec<_>>().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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<u64> {
|
|||
}
|
||||
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<String> {
|
||||
match source {
|
||||
ProfileSource::Path { path } => path
|
||||
|
|
@ -1242,23 +1229,6 @@ fn source_name(source: &ProfileSource) -> Option<String> {
|
|||
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<PathBuf, ProfileError> {
|
||||
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"));
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
/// Runtime workspace root for profile discovery, default Pod naming, and process context.
|
||||
/// Defaults to the current directory.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
workspace: Option<PathBuf>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Require `--pod` to restore existing Pod state instead of creating a
|
||||
|
|
@ -90,6 +89,51 @@ struct Cli {
|
|||
session: Option<SegmentId>,
|
||||
}
|
||||
|
||||
fn runtime_workspace_root(cli: &Cli) -> Result<PathBuf, String> {
|
||||
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<F>(
|
|||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(|_| ())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(()),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,6 +108,7 @@ pub async fn run(
|
|||
let mut terminal = make_inline_terminal()?;
|
||||
|
||||
// Phase 1: confirm / cancel.
|
||||
if !immediate {
|
||||
loop {
|
||||
terminal.draw(|f| draw_form(f, &form))?;
|
||||
match poll_event()? {
|
||||
|
|
@ -135,6 +138,12 @@ pub async fn run(
|
|||
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
|
||||
// out of the name field — subsequent frames are passive status
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ enum Mode {
|
|||
Ticket(ticket_cli::TicketCli),
|
||||
PodRuntime(Vec<String>),
|
||||
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<Mode, ParseError> {
|
||||
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<Mode, ParseError> {
|
|||
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<Mode, ParseError> {
|
|||
return Ok(Mode::MemoryLint(options));
|
||||
}
|
||||
"memory" => {
|
||||
return Ok(Mode::Tui(LaunchMode::PodName {
|
||||
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<Mode, ParseError> {
|
|||
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<Mode, ParseError> {
|
|||
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<Mode, ParseError> {
|
|||
}
|
||||
|
||||
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<Mode, ParseError> {
|
|||
}
|
||||
|
||||
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 {
|
||||
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<PathBuf, ParseError> {
|
||||
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 <PATH>".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
||||
|
|
@ -314,7 +394,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
|||
|
||||
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 <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> 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 <PATH>]\n yoi keys\n yoi pod [POD_OPTIONS]\n yoi ticket <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> 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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user