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 行に乗る
|
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
|
||||||
/// 名前との突き合わせに使う。
|
/// 名前との突き合わせに使う。
|
||||||
pub pod_name: String,
|
pub pod_name: String,
|
||||||
/// Optional profile selector. When present the child is launched with
|
/// Optional reusable Profile selector. Pod identity is always supplied
|
||||||
/// `--profile`; the Pod name is supplied through `--profile-pod-name` so
|
/// separately with `--pod`; profile selection must not imply a name.
|
||||||
/// profile evaluation stays separate from `--pod` restore semantics.
|
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
/// pod の current_dir。
|
/// Explicit runtime workspace root. The child uses it as process cwd and
|
||||||
pub cwd: PathBuf,
|
/// receives it via `--workspace` so startup does not infer workspace
|
||||||
|
/// identity from the parent process cwd.
|
||||||
|
pub workspace_root: PathBuf,
|
||||||
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
|
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
|
||||||
/// resume させる。
|
/// resume させる。
|
||||||
pub resume_from: Option<Uuid>,
|
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)]
|
#[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` ハンドシェイクが終わるまで待つ。
|
/// pod を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
|
||||||
///
|
///
|
||||||
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
|
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
|
||||||
|
|
@ -124,27 +143,13 @@ where
|
||||||
let mut command = Command::new(config.runtime_command.program());
|
let mut command = Command::new(config.runtime_command.program());
|
||||||
command
|
command
|
||||||
.args(config.runtime_command.prefix_args())
|
.args(config.runtime_command.prefix_args())
|
||||||
.current_dir(&config.cwd)
|
.current_dir(&config.workspace_root)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::from(stderr_file))
|
.stderr(Stdio::from(stderr_file))
|
||||||
.process_group(0);
|
.process_group(0);
|
||||||
if let Some(profile) = &config.profile {
|
for arg in runtime_args(&config) {
|
||||||
command
|
command.arg(arg);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
let mut child = command
|
let mut child = command
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|
@ -311,3 +316,51 @@ impl StderrTail {
|
||||||
self.lines.into_iter().collect::<Vec<_>>().join(" | ")
|
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,
|
runtime_command,
|
||||||
pod_name: self.pod_name.clone(),
|
pod_name: self.pod_name.clone(),
|
||||||
profile: Some(self.profile.clone()),
|
profile: Some(self.profile.clone()),
|
||||||
cwd: self.workspace_root.clone(),
|
workspace_root: self.workspace_root.clone(),
|
||||||
resume_from: None,
|
resume_from: None,
|
||||||
resume_by_pod_name: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1025,7 +1024,7 @@ workflow = "ticket-review-workflow"
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(spawn.pod_name, "reviewer-fixed");
|
assert_eq!(spawn.pod_name, "reviewer-fixed");
|
||||||
assert_eq!(spawn.profile.as_deref(), Some("builtin:default"));
|
assert_eq!(spawn.profile.as_deref(), Some("builtin:default"));
|
||||||
assert_eq!(spawn.cwd, temp.path());
|
assert_eq!(spawn.workspace_root, temp.path());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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_NAME: &str = "default";
|
||||||
const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua");
|
const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua");
|
||||||
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
|
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";
|
const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
|
@ -551,7 +550,7 @@ fn resolve_lua_profile_value(
|
||||||
validate_profile_paths(&profile)?;
|
validate_profile_paths(&profile)?;
|
||||||
let pod_name = options
|
let pod_name = options
|
||||||
.pod_name
|
.pod_name
|
||||||
.unwrap_or_else(|| derive_pod_name(&source, profile.slug.as_deref()));
|
.ok_or(ProfileError::MissingRuntimePodName)?;
|
||||||
let profile_meta = Some(ProfileMetadata {
|
let profile_meta = Some(ProfileMetadata {
|
||||||
name: profile.slug.clone().or_else(|| source_name(&source)),
|
name: profile.slug.clone().or_else(|| source_name(&source)),
|
||||||
description: profile.description.clone(),
|
description: profile.description.clone(),
|
||||||
|
|
@ -1221,18 +1220,6 @@ fn builtin_model_context_window(reference: &str) -> Option<u64> {
|
||||||
}
|
}
|
||||||
None
|
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> {
|
fn source_name(source: &ProfileSource) -> Option<String> {
|
||||||
match source {
|
match source {
|
||||||
ProfileSource::Path { path } => path
|
ProfileSource::Path { path } => path
|
||||||
|
|
@ -1242,23 +1229,6 @@ fn source_name(source: &ProfileSource) -> Option<String> {
|
||||||
ProfileSource::Registry { name, .. } => Some(name.clone()),
|
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> {
|
fn canonicalize_existing_dir(path: &Path) -> Result<PathBuf, ProfileError> {
|
||||||
path.canonicalize()
|
path.canonicalize()
|
||||||
.map_err(|source| ProfileError::CommandIo {
|
.map_err(|source| ProfileError::CommandIo {
|
||||||
|
|
@ -1295,7 +1265,7 @@ pub fn resolve_profile_artifact(
|
||||||
source,
|
source,
|
||||||
base_dir,
|
base_dir,
|
||||||
base_dir,
|
base_dir,
|
||||||
ProfileResolveOptions::default(),
|
ProfileResolveOptions::with_pod_name("artifact-pod"),
|
||||||
raw_artifact.clone(),
|
raw_artifact.clone(),
|
||||||
raw_artifact,
|
raw_artifact,
|
||||||
None,
|
None,
|
||||||
|
|
@ -1342,6 +1312,8 @@ pub enum ProfileError {
|
||||||
InvalidWorkspaceOverride { path: PathBuf, message: String },
|
InvalidWorkspaceOverride { path: PathBuf, message: String },
|
||||||
#[error("no default profile is configured")]
|
#[error("no default profile is configured")]
|
||||||
NoDefaultProfile,
|
NoDefaultProfile,
|
||||||
|
#[error("profile resolution requires an explicit runtime Pod name")]
|
||||||
|
MissingRuntimePodName,
|
||||||
#[error("profile not found: {selector}")]
|
#[error("profile not found: {selector}")]
|
||||||
ProfileNotFound { selector: String },
|
ProfileNotFound { selector: String },
|
||||||
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
|
#[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.path, None);
|
||||||
assert_eq!(default.provenance, "builtin:default");
|
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]
|
#[test]
|
||||||
fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() {
|
fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|
@ -1570,9 +1552,12 @@ return profile {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
.resolve(
|
||||||
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
|
||||||
|
ProfileResolveOptions::with_pod_name("runtime-workspace"),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resolved.manifest.pod.name, "yoi");
|
assert_eq!(resolved.manifest.pod.name, "runtime-workspace");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.manifest.model.ref_.as_deref(),
|
resolved.manifest.model.ref_.as_deref(),
|
||||||
Some("codex-oauth/gpt-5.5")
|
Some("codex-oauth/gpt-5.5")
|
||||||
|
|
@ -1621,10 +1606,13 @@ record_event_trace = false
|
||||||
|
|
||||||
let resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(&nested)
|
.with_workspace_base(&nested)
|
||||||
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
.resolve(
|
||||||
|
&ProfileSelector::Default,
|
||||||
|
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(resolved.manifest.pod.name, "yoi");
|
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
|
||||||
assert_eq!(resolved.manifest.worker.language, "ja");
|
assert_eq!(resolved.manifest.worker.language, "ja");
|
||||||
assert!(!resolved.manifest.session.record_event_trace);
|
assert!(!resolved.manifest.session.record_event_trace);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1678,7 +1666,10 @@ language = "nested"
|
||||||
|
|
||||||
let resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(&child)
|
.with_workspace_base(&child)
|
||||||
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
.resolve(
|
||||||
|
&ProfileSelector::Default,
|
||||||
|
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(resolved.manifest.worker.language, "nested");
|
assert_eq!(resolved.manifest.worker.language, "nested");
|
||||||
|
|
@ -1710,7 +1701,10 @@ language = "nested"
|
||||||
|
|
||||||
let err = ProfileResolver::new()
|
let err = ProfileResolver::new()
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
|
.resolve(
|
||||||
|
&ProfileSelector::Default,
|
||||||
|
ProfileResolveOptions::with_pod_name("runtime-pod"),
|
||||||
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
|
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
|
||||||
assert!(err.to_string().contains("pod.name"));
|
assert!(err.to_string().contains("pod.name"));
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,14 @@ struct Cli {
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "PROFILE",
|
value_name = "PROFILE",
|
||||||
conflicts_with_all = ["manifest", "project", "pod", "session", "adopt"]
|
conflicts_with_all = ["manifest", "project", "session", "adopt"]
|
||||||
)]
|
)]
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
|
|
||||||
/// Pod name override for a freshly-created profile Pod. This does not use
|
/// Runtime workspace root for profile discovery, default Pod naming, and process context.
|
||||||
/// `--pod` restore semantics, so it must not attach/restore existing Pod
|
/// Defaults to the current directory.
|
||||||
/// state by re-evaluating the profile source.
|
#[arg(long, value_name = "PATH")]
|
||||||
#[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])]
|
workspace: Option<PathBuf>,
|
||||||
profile_pod_name: Option<String>,
|
|
||||||
|
|
||||||
/// Manifest TOML to use directly as a one-file compatibility/debug input.
|
/// Manifest TOML to use directly as a one-file compatibility/debug input.
|
||||||
/// This bypasses profile discovery but still applies builtin defaults and
|
/// 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,
|
/// Resume or create a Pod by name. If name-keyed Pod state exists,
|
||||||
/// the active session/segment recorded there is restored; otherwise a
|
/// the active session/segment recorded there is restored; otherwise a
|
||||||
/// fresh top-level Pod is created with this name.
|
/// 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>,
|
pod: Option<String>,
|
||||||
|
|
||||||
/// Require `--pod` to restore existing Pod state instead of creating a
|
/// Require `--pod` to restore existing Pod state instead of creating a
|
||||||
|
|
@ -90,6 +89,51 @@ struct Cli {
|
||||||
session: Option<SegmentId>,
|
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> {
|
fn resolve_manifest(cli: &Cli) -> Result<(PodManifest, PromptLoader), String> {
|
||||||
resolve_manifest_with_profile_loader(cli, load_profile)
|
resolve_manifest_with_profile_loader(cli, load_profile)
|
||||||
}
|
}
|
||||||
|
|
@ -99,15 +143,17 @@ fn resolve_manifest_with_profile_loader<F>(
|
||||||
load_profile_fn: F,
|
load_profile_fn: F,
|
||||||
) -> Result<(PodManifest, PromptLoader), String>
|
) -> Result<(PodManifest, PromptLoader), String>
|
||||||
where
|
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() {
|
let mut manifest_and_loader = if let Some(config_json) = cli.spawn_config_json.as_deref() {
|
||||||
load_spawn_config_json(config_json)?
|
load_spawn_config_json(config_json)?
|
||||||
} else if let Some(profile) = &cli.profile {
|
} else if let Some(profile) = &cli.profile {
|
||||||
let selector = ProfileSelector::parse_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 {
|
} 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 {
|
} else {
|
||||||
if cli.project.is_some() {
|
if cli.project.is_some() {
|
||||||
return Err(
|
return Err(
|
||||||
|
|
@ -117,7 +163,7 @@ where
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let selector = ProfileSelector::Default;
|
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)?;
|
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> {
|
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();
|
manifest.pod.name = pod_name.to_string();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -141,14 +187,11 @@ fn load_spawn_config_json(config_json: &str) -> Result<(PodManifest, PromptLoade
|
||||||
|
|
||||||
fn load_profile(
|
fn load_profile(
|
||||||
selector: &ProfileSelector,
|
selector: &ProfileSelector,
|
||||||
pod_name_override: Option<&str>,
|
workspace_root: &Path,
|
||||||
|
pod_name: &str,
|
||||||
) -> Result<(PodManifest, PromptLoader), String> {
|
) -> Result<(PodManifest, PromptLoader), String> {
|
||||||
let cwd = std::env::current_dir()
|
let resolver = ProfileResolver::new().with_workspace_base(workspace_root);
|
||||||
.map_err(|e| format!("failed to resolve current directory for profile: {e}"))?;
|
let options = ProfileResolveOptions::with_pod_name(pod_name);
|
||||||
let resolver = ProfileResolver::new().with_workspace_base(cwd);
|
|
||||||
let options = pod_name_override
|
|
||||||
.map(ProfileResolveOptions::with_pod_name)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let resolved = resolver.resolve(selector, options).map_err(|e| {
|
let resolved = resolver.resolve(selector, options).map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"failed to resolve profile {}: {e}",
|
"failed to resolve profile {}: {e}",
|
||||||
|
|
@ -160,7 +203,8 @@ fn load_profile(
|
||||||
|
|
||||||
fn load_single_manifest(
|
fn load_single_manifest(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
pod_name_override: Option<&str>,
|
explicit_pod_name: Option<&str>,
|
||||||
|
default_pod_name: &str,
|
||||||
) -> Result<(PodManifest, PromptLoader), String> {
|
) -> Result<(PodManifest, PromptLoader), String> {
|
||||||
let toml = std::fs::read_to_string(path)
|
let toml = std::fs::read_to_string(path)
|
||||||
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
|
.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()))?
|
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?
|
||||||
.resolve_paths(base_dir),
|
.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());
|
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)
|
let manifest = PodManifest::try_from(config)
|
||||||
.map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?;
|
.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 {
|
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) {
|
let (mut manifest, loader) = match resolve_manifest(&cli) {
|
||||||
Ok(pair) => pair,
|
Ok(pair) => pair,
|
||||||
Err(e) => {
|
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
|
// Initialize persistent store. `paths::sessions_dir()` only
|
||||||
// returns None when none of YOI_HOME / YOI_DATA_DIR /
|
// returns None when none of YOI_HOME / YOI_DATA_DIR /
|
||||||
// HOME is set — surface that as a hard error to match the
|
// HOME is set — surface that as a hard error to match the
|
||||||
|
|
@ -538,22 +599,20 @@ permission = "write"
|
||||||
"yoi pod",
|
"yoi pod",
|
||||||
"--profile",
|
"--profile",
|
||||||
profile.to_str().unwrap(),
|
profile.to_str().unwrap(),
|
||||||
"--profile-pod-name",
|
"--pod",
|
||||||
"from-profile-name",
|
"from-profile-name",
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut called = false;
|
let mut called = false;
|
||||||
|
|
||||||
let (manifest, loader) =
|
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;
|
called = true;
|
||||||
assert_eq!(selector, &ProfileSelector::path(profile.clone()));
|
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 =
|
let mut manifest =
|
||||||
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
|
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()))
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -571,14 +630,14 @@ permission = "write"
|
||||||
"yoi pod",
|
"yoi pod",
|
||||||
"--profile",
|
"--profile",
|
||||||
"project:coder",
|
"project:coder",
|
||||||
"--profile-pod-name",
|
"--pod",
|
||||||
"from-profile-name",
|
"from-profile-name",
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut called = false;
|
let mut called = false;
|
||||||
|
|
||||||
let (manifest, _loader) =
|
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;
|
called = true;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
selector,
|
selector,
|
||||||
|
|
@ -589,9 +648,7 @@ permission = "write"
|
||||||
);
|
);
|
||||||
let mut manifest =
|
let mut manifest =
|
||||||
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
|
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()))
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -601,31 +658,74 @@ permission = "write"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normal_startup_uses_default_profile() {
|
fn profile_without_explicit_pod_uses_workspace_basename_not_selector() {
|
||||||
let tmp = TempDir::new().unwrap();
|
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 mut called = false;
|
||||||
|
|
||||||
let (manifest, _loader) =
|
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;
|
called = true;
|
||||||
assert_eq!(selector, &ProfileSelector::Default);
|
assert_eq!(
|
||||||
assert_eq!(pod_name, None);
|
selector,
|
||||||
let manifest =
|
&ProfileSelector::source_named(
|
||||||
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
|
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();
|
.unwrap();
|
||||||
|
manifest.pod.name = pod_name.to_string();
|
||||||
Ok((manifest, PromptLoader::builtins_only()))
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(called);
|
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]
|
#[test]
|
||||||
fn project_flag_no_longer_enables_ambient_manifest_cascade() {
|
fn project_flag_no_longer_enables_ambient_manifest_cascade() {
|
||||||
let cli = Cli::try_parse_from(["yoi pod", "--project", "."]).unwrap();
|
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")
|
panic!("default profile loader must not run when deprecated --project is present")
|
||||||
})
|
})
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
@ -700,16 +800,14 @@ permission = "write"
|
||||||
let mut called = false;
|
let mut called = false;
|
||||||
|
|
||||||
let (manifest, _loader) =
|
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;
|
called = true;
|
||||||
assert_eq!(selector, &ProfileSelector::Default);
|
assert_eq!(selector, &ProfileSelector::Default);
|
||||||
assert_eq!(pod_name, Some("agent"));
|
assert_eq!(pod_name, "agent");
|
||||||
let mut manifest =
|
let mut manifest =
|
||||||
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
|
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
|
||||||
.unwrap();
|
.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()))
|
Ok((manifest, PromptLoader::builtins_only()))
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -723,7 +821,6 @@ permission = "write"
|
||||||
let segment_id = session_store::new_segment_id().to_string();
|
let segment_id = session_store::new_segment_id().to_string();
|
||||||
for args in [
|
for args in [
|
||||||
vec!["yoi pod", "--profile", "p.lua", "--manifest", "m.toml"],
|
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],
|
vec!["yoi pod", "--profile", "p.lua", "--session", &segment_id],
|
||||||
] {
|
] {
|
||||||
let err = Cli::try_parse_from(args).unwrap_err();
|
let err = Cli::try_parse_from(args).unwrap_err();
|
||||||
|
|
@ -732,23 +829,16 @@ permission = "write"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_pod_name_requires_profile() {
|
fn profile_and_pod_are_independent_startup_inputs() {
|
||||||
let err = Cli::try_parse_from(["yoi pod", "--profile-pod-name", "agent"]).unwrap_err();
|
let cli = Cli::try_parse_from(["yoi pod", "--profile", "p.lua", "--pod", "agent"]).unwrap();
|
||||||
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
|
assert_eq!(cli.profile.as_deref(), Some("p.lua"));
|
||||||
|
assert_eq!(cli.pod.as_deref(), Some("agent"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_pod_name_is_not_restore_pod_flag() {
|
fn removed_profile_pod_name_alias_is_rejected() {
|
||||||
let cli = Cli::try_parse_from([
|
let err = Cli::try_parse_from(["yoi pod", "--profile-pod-name", "agent"]).unwrap_err();
|
||||||
"yoi pod",
|
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
|
||||||
"--profile",
|
|
||||||
"p.lua",
|
|
||||||
"--profile-pod-name",
|
|
||||||
"agent",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(cli.profile_pod_name.as_deref(), Some("agent"));
|
|
||||||
assert!(cli.pod.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,13 @@ use client::PodRuntimeCommand;
|
||||||
pub struct LaunchOptions {
|
pub struct LaunchOptions {
|
||||||
pub mode: LaunchMode,
|
pub mode: LaunchMode,
|
||||||
pub runtime_command: PodRuntimeCommand,
|
pub runtime_command: PodRuntimeCommand,
|
||||||
|
pub workspace_root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum LaunchMode {
|
pub enum LaunchMode {
|
||||||
Spawn {
|
Spawn {
|
||||||
|
pod_name: Option<String>,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
},
|
},
|
||||||
/// `yoi <name>` / `yoi --pod <name>`: attach to a live Pod by name if
|
/// `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 {
|
let LaunchOptions {
|
||||||
mode,
|
mode,
|
||||||
runtime_command,
|
runtime_command,
|
||||||
|
workspace_root,
|
||||||
} = options;
|
} = 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() {
|
if let Err(e) = enable_raw_mode() {
|
||||||
eprintln!("yoi: failed to enter raw mode: {e}");
|
eprintln!("yoi: failed to enter raw mode: {e}");
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
|
|
@ -74,8 +85,8 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match mode {
|
let result = match mode {
|
||||||
LaunchMode::Spawn { profile } => {
|
LaunchMode::Spawn { pod_name, profile } => {
|
||||||
single_pod::run_spawn(None, profile, runtime_command).await
|
single_pod::run_spawn(None, pod_name, profile, runtime_command).await
|
||||||
}
|
}
|
||||||
LaunchMode::PodName {
|
LaunchMode::PodName {
|
||||||
pod_name,
|
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,
|
} => single_pod::run_pod_name(pod_name, socket_override, runtime_command).await,
|
||||||
LaunchMode::Resume => single_pod::run_resume(runtime_command).await,
|
LaunchMode::Resume => single_pod::run_resume(runtime_command).await,
|
||||||
LaunchMode::ResumeWithSession(id) => {
|
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,
|
LaunchMode::Panel => single_pod::run_panel(runtime_command).await,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1622,9 +1622,8 @@ async fn restore_workspace_companion_pod(
|
||||||
runtime_command,
|
runtime_command,
|
||||||
pod_name: pod_name.to_string(),
|
pod_name: pod_name.to_string(),
|
||||||
profile: None,
|
profile: None,
|
||||||
cwd: workspace_root.to_path_buf(),
|
workspace_root: workspace_root.to_path_buf(),
|
||||||
resume_from: None,
|
resume_from: None,
|
||||||
resume_by_pod_name: true,
|
|
||||||
};
|
};
|
||||||
spawn_pod(config, |_| {}).await.map(|_| ())
|
spawn_pod(config, |_| {}).await.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
@ -1638,9 +1637,8 @@ async fn spawn_workspace_companion_pod(
|
||||||
runtime_command,
|
runtime_command,
|
||||||
pod_name: pod_name.to_string(),
|
pod_name: pod_name.to_string(),
|
||||||
profile: None,
|
profile: None,
|
||||||
cwd: workspace_root.to_path_buf(),
|
workspace_root: workspace_root.to_path_buf(),
|
||||||
resume_from: None,
|
resume_from: None,
|
||||||
resume_by_pod_name: false,
|
|
||||||
};
|
};
|
||||||
spawn_pod(config, |_| {}).await.map(|_| ())
|
spawn_pod(config, |_| {}).await.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
@ -1654,9 +1652,8 @@ async fn restore_orchestrator_pod(
|
||||||
runtime_command,
|
runtime_command,
|
||||||
pod_name: pod_name.to_string(),
|
pod_name: pod_name.to_string(),
|
||||||
profile: None,
|
profile: None,
|
||||||
cwd: workspace_root.to_path_buf(),
|
workspace_root: workspace_root.to_path_buf(),
|
||||||
resume_from: None,
|
resume_from: None,
|
||||||
resume_by_pod_name: true,
|
|
||||||
};
|
};
|
||||||
spawn_pod(config, |_| {}).await.map(|_| ())
|
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(
|
pub(crate) async fn run_spawn(
|
||||||
resume_from: Option<SegmentId>,
|
resume_from: Option<SegmentId>,
|
||||||
|
pod_name: Option<String>,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> 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::Ready(r) => r,
|
||||||
SpawnOutcome::Cancelled => return Ok(()),
|
SpawnOutcome::Cancelled => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||||
/// passes `--session <id>` to the spawned Pod runtime child.
|
/// passes `--session <id>` to the spawned Pod runtime child.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
resume_from: Option<SegmentId>,
|
resume_from: Option<SegmentId>,
|
||||||
|
pod_name: Option<String>,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<SpawnOutcome, SpawnError> {
|
) -> Result<SpawnOutcome, SpawnError> {
|
||||||
|
|
@ -90,15 +91,16 @@ pub async fn run(
|
||||||
defaults.default_profile_index,
|
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 {
|
let mut form = Form {
|
||||||
cwd: defaults.cwd.clone(),
|
cwd: defaults.cwd.clone(),
|
||||||
scope_origin: defaults.scope_origin,
|
scope_origin: defaults.scope_origin,
|
||||||
name_cursor: defaults.default_name.chars().count(),
|
name_cursor: selected_name.chars().count(),
|
||||||
name: defaults.default_name,
|
name: selected_name,
|
||||||
message: None,
|
message: None,
|
||||||
editing: true,
|
editing: true,
|
||||||
resume_from,
|
resume_from,
|
||||||
resume_by_pod_name: false,
|
|
||||||
profile_choices,
|
profile_choices,
|
||||||
profile_index,
|
profile_index,
|
||||||
};
|
};
|
||||||
|
|
@ -106,34 +108,41 @@ pub async fn run(
|
||||||
let mut terminal = make_inline_terminal()?;
|
let mut terminal = make_inline_terminal()?;
|
||||||
|
|
||||||
// Phase 1: confirm / cancel.
|
// Phase 1: confirm / cancel.
|
||||||
loop {
|
if !immediate {
|
||||||
terminal.draw(|f| draw_form(f, &form))?;
|
loop {
|
||||||
match poll_event()? {
|
terminal.draw(|f| draw_form(f, &form))?;
|
||||||
None => continue,
|
match poll_event()? {
|
||||||
Some(Action::Submit) => {
|
None => continue,
|
||||||
if form.name.trim().is_empty() {
|
Some(Action::Submit) => {
|
||||||
form.message = Some(("name is required".to_string(), MessageKind::Error));
|
if form.name.trim().is_empty() {
|
||||||
continue;
|
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
|
// 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)),
|
message: Some(("resuming pod...".to_string(), MessageKind::Progress)),
|
||||||
editing: false,
|
editing: false,
|
||||||
resume_from: None,
|
resume_from: None,
|
||||||
resume_by_pod_name: true,
|
|
||||||
profile_choices: Vec::new(),
|
profile_choices: Vec::new(),
|
||||||
profile_index: 0,
|
profile_index: 0,
|
||||||
}
|
}
|
||||||
|
|
@ -370,9 +378,8 @@ async fn wait_for_ready(
|
||||||
runtime_command: runtime_command.clone(),
|
runtime_command: runtime_command.clone(),
|
||||||
pod_name: form.name.clone(),
|
pod_name: form.name.clone(),
|
||||||
profile: form.selected_profile_selector(),
|
profile: form.selected_profile_selector(),
|
||||||
cwd: form.cwd.clone(),
|
workspace_root: form.cwd.clone(),
|
||||||
resume_from: form.resume_from,
|
resume_from: form.resume_from,
|
||||||
resume_by_pod_name: form.resume_by_pod_name,
|
|
||||||
};
|
};
|
||||||
let ready = spawn_pod(config, |line| {
|
let ready = spawn_pod(config, |line| {
|
||||||
form.message = Some((line.to_string(), MessageKind::Progress));
|
form.message = Some((line.to_string(), MessageKind::Progress));
|
||||||
|
|
@ -418,9 +425,6 @@ struct Form {
|
||||||
/// child pod is launched with `--session <id>` so it restores
|
/// child pod is launched with `--session <id>` so it restores
|
||||||
/// from `id` and appends to the same session log.
|
/// from `id` and appends to the same session log.
|
||||||
resume_from: Option<SegmentId>,
|
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
|
/// Optional profile choices passed with `--profile` for
|
||||||
/// fresh spawns. This is not used for resume/attach flows because those must
|
/// fresh spawns. This is not used for resume/attach flows because those must
|
||||||
/// restore Pod state rather than re-evaluate a profile source.
|
/// restore Pod state rather than re-evaluate a profile source.
|
||||||
|
|
@ -622,7 +626,6 @@ mod tests {
|
||||||
message: None,
|
message: None,
|
||||||
editing: true,
|
editing: true,
|
||||||
resume_from: None,
|
resume_from: None,
|
||||||
resume_by_pod_name: false,
|
|
||||||
profile_choices: Vec::new(),
|
profile_choices: Vec::new(),
|
||||||
profile_index: 0,
|
profile_index: 0,
|
||||||
}
|
}
|
||||||
|
|
@ -642,7 +645,6 @@ mod tests {
|
||||||
assert_eq!(f.name, "agent");
|
assert_eq!(f.name, "agent");
|
||||||
assert_eq!(f.name_cursor, "agent".chars().count());
|
assert_eq!(f.name_cursor, "agent".chars().count());
|
||||||
assert_eq!(f.resume_from, None);
|
assert_eq!(f.resume_from, None);
|
||||||
assert!(f.resume_by_pod_name);
|
|
||||||
assert!(!f.editing);
|
assert!(!f.editing);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
f.message,
|
f.message,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ enum Mode {
|
||||||
Ticket(ticket_cli::TicketCli),
|
Ticket(ticket_cli::TicketCli),
|
||||||
PodRuntime(Vec<String>),
|
PodRuntime(Vec<String>),
|
||||||
Keys,
|
Keys,
|
||||||
Tui(LaunchMode),
|
Tui {
|
||||||
|
mode: LaunchMode,
|
||||||
|
workspace_root: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -75,7 +78,10 @@ async fn main() -> ExitCode {
|
||||||
},
|
},
|
||||||
Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("yoi pod", args).await,
|
Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("yoi pod", args).await,
|
||||||
Mode::Keys => tui::keys::launch().await,
|
Mode::Keys => tui::keys::launch().await,
|
||||||
Mode::Tui(mode) => {
|
Mode::Tui {
|
||||||
|
mode,
|
||||||
|
workspace_root,
|
||||||
|
} => {
|
||||||
let runtime_command = match PodRuntimeCommand::resolve() {
|
let runtime_command = match PodRuntimeCommand::resolve() {
|
||||||
Ok(command) => command,
|
Ok(command) => command,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -86,6 +92,7 @@ async fn main() -> ExitCode {
|
||||||
tui::launch(LaunchOptions {
|
tui::launch(LaunchOptions {
|
||||||
mode,
|
mode,
|
||||||
runtime_command,
|
runtime_command,
|
||||||
|
workspace_root,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +114,14 @@ where
|
||||||
|
|
||||||
fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
if args.is_empty() {
|
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() {
|
match args[0].as_str() {
|
||||||
|
|
@ -119,10 +133,10 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
return Ok(Mode::Ticket(ticket_cli));
|
return Ok(Mode::Ticket(ticket_cli));
|
||||||
}
|
}
|
||||||
"panel" => {
|
"panel" => {
|
||||||
if args.len() != 1 {
|
return Ok(Mode::Tui {
|
||||||
return Err(ParseError("yoi panel does not accept arguments".into()));
|
mode: LaunchMode::Panel,
|
||||||
}
|
workspace_root: parse_panel_workspace(&args[1..])?,
|
||||||
return Ok(Mode::Tui(LaunchMode::Panel));
|
});
|
||||||
}
|
}
|
||||||
"keys" => {
|
"keys" => {
|
||||||
if args.len() != 1 {
|
if args.len() != 1 {
|
||||||
|
|
@ -140,14 +154,20 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
return Ok(Mode::MemoryLint(options));
|
return Ok(Mode::MemoryLint(options));
|
||||||
}
|
}
|
||||||
"memory" => {
|
"memory" => {
|
||||||
return Ok(Mode::Tui(LaunchMode::PodName {
|
return Ok(Mode::Tui {
|
||||||
pod_name: "memory".to_string(),
|
mode: LaunchMode::PodName {
|
||||||
socket_override: None,
|
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 resume = false;
|
||||||
let mut session = None;
|
let mut session = None;
|
||||||
let mut pod_name = 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));
|
socket_override = Some(PathBuf::from(value));
|
||||||
i += 2;
|
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" => {
|
"--profile" => {
|
||||||
let value = args
|
let value = args
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
|
|
@ -224,6 +254,14 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
socket_override = Some(PathBuf::from(value));
|
socket_override = Some(PathBuf::from(value));
|
||||||
i += 1;
|
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=") => {
|
arg if arg.starts_with("--profile=") => {
|
||||||
let value = arg.trim_start_matches("--profile=");
|
let value = arg.trim_start_matches("--profile=");
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
|
|
@ -253,11 +291,7 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if profile.is_some()
|
if profile.is_some()
|
||||||
&& (resume
|
&& (resume || session.is_some() || positional.is_some() || socket_override.is_some())
|
||||||
|| session.is_some()
|
|
||||||
|| pod_name.is_some()
|
|
||||||
|| positional.is_some()
|
|
||||||
|| socket_override.is_some())
|
|
||||||
{
|
{
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
"--profile can only be used for fresh spawn".to_string(),
|
"--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);
|
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 {
|
if let Some(pod_name) = pod_name {
|
||||||
return Ok(Mode::Tui(LaunchMode::PodName {
|
return Ok(Mode::Tui {
|
||||||
pod_name,
|
mode: LaunchMode::PodName {
|
||||||
socket_override,
|
pod_name,
|
||||||
}));
|
socket_override,
|
||||||
|
},
|
||||||
|
workspace_root,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if resume {
|
if resume {
|
||||||
return Ok(Mode::Tui(LaunchMode::Resume));
|
return Ok(Mode::Tui {
|
||||||
|
mode: LaunchMode::Resume,
|
||||||
|
workspace_root,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if let Some(id) = session {
|
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> {
|
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() {
|
fn print_help() {
|
||||||
println!(
|
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]
|
#[test]
|
||||||
fn parse_pod_name_mode() {
|
fn parse_pod_name_mode() {
|
||||||
match parse_args_from(["--pod", "agent", "--socket", "/tmp/agent.sock"]).unwrap() {
|
match parse_args_from(["--pod", "agent", "--socket", "/tmp/agent.sock"]).unwrap() {
|
||||||
Mode::Tui(LaunchMode::PodName {
|
Mode::Tui {
|
||||||
pod_name,
|
mode:
|
||||||
socket_override,
|
LaunchMode::PodName {
|
||||||
}) => {
|
pod_name,
|
||||||
|
socket_override,
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} => {
|
||||||
assert_eq!(pod_name, "agent");
|
assert_eq!(pod_name, "agent");
|
||||||
assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock")));
|
assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock")));
|
||||||
}
|
}
|
||||||
|
|
@ -345,10 +429,14 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_positional_name_uses_pod_name_mode() {
|
fn parse_positional_name_uses_pod_name_mode() {
|
||||||
match parse_args_from(["agent"]).unwrap() {
|
match parse_args_from(["agent"]).unwrap() {
|
||||||
Mode::Tui(LaunchMode::PodName {
|
Mode::Tui {
|
||||||
pod_name,
|
mode:
|
||||||
socket_override,
|
LaunchMode::PodName {
|
||||||
}) => {
|
pod_name,
|
||||||
|
socket_override,
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} => {
|
||||||
assert_eq!(pod_name, "agent");
|
assert_eq!(pod_name, "agent");
|
||||||
assert_eq!(socket_override, None);
|
assert_eq!(socket_override, None);
|
||||||
}
|
}
|
||||||
|
|
@ -359,10 +447,14 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_memory_alone_remains_positional_pod_name() {
|
fn parse_memory_alone_remains_positional_pod_name() {
|
||||||
match parse_args_from(["memory"]).unwrap() {
|
match parse_args_from(["memory"]).unwrap() {
|
||||||
Mode::Tui(LaunchMode::PodName {
|
Mode::Tui {
|
||||||
pod_name,
|
mode:
|
||||||
socket_override,
|
LaunchMode::PodName {
|
||||||
}) => {
|
pod_name,
|
||||||
|
socket_override,
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} => {
|
||||||
assert_eq!(pod_name, "memory");
|
assert_eq!(pod_name, "memory");
|
||||||
assert_eq!(socket_override, None);
|
assert_eq!(socket_override, None);
|
||||||
}
|
}
|
||||||
|
|
@ -405,10 +497,14 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_literal_pod_name_still_available_with_flag() {
|
fn parse_literal_pod_name_still_available_with_flag() {
|
||||||
match parse_args_from(["--pod", "pod"]).unwrap() {
|
match parse_args_from(["--pod", "pod"]).unwrap() {
|
||||||
Mode::Tui(LaunchMode::PodName {
|
Mode::Tui {
|
||||||
pod_name,
|
mode:
|
||||||
socket_override,
|
LaunchMode::PodName {
|
||||||
}) => {
|
pod_name,
|
||||||
|
socket_override,
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} => {
|
||||||
assert_eq!(pod_name, "pod");
|
assert_eq!(pod_name, "pod");
|
||||||
assert_eq!(socket_override, None);
|
assert_eq!(socket_override, None);
|
||||||
}
|
}
|
||||||
|
|
@ -458,7 +554,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_lint_with_other_second_word_remains_positional_pod_name() {
|
fn memory_lint_with_other_second_word_remains_positional_pod_name() {
|
||||||
match parse_args_from(["memory", "other"]).unwrap() {
|
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"),
|
_ => panic!("expected PodName mode"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -498,9 +597,23 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_profile_spawn_mode() {
|
fn parse_profile_spawn_mode() {
|
||||||
match parse_args_from(["--profile", "/profiles/coder.lua"]).unwrap() {
|
match parse_args_from([
|
||||||
Mode::Tui(LaunchMode::Spawn { profile }) => {
|
"--workspace",
|
||||||
assert_eq!(profile, Some("/profiles/coder.lua".to_string()));
|
"/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"),
|
_ => panic!("expected Spawn mode"),
|
||||||
}
|
}
|
||||||
|
|
@ -554,8 +667,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_panel_mode() {
|
fn parse_panel_mode() {
|
||||||
match parse_args_from(["panel"]).unwrap() {
|
match parse_args_from(["panel", "--workspace", "/tmp/other-workspace"]).unwrap() {
|
||||||
Mode::Tui(LaunchMode::Panel) => {}
|
Mode::Tui {
|
||||||
|
mode: LaunchMode::Panel,
|
||||||
|
workspace_root,
|
||||||
|
} => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")),
|
||||||
_ => panic!("expected Panel mode"),
|
_ => panic!("expected Panel mode"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user