merge: runtime workspace identity bundle

This commit is contained in:
Keisuke Hirata 2026-06-08 10:57:47 +09:00
commit b7a533f6cb
No known key found for this signature in database
9 changed files with 553 additions and 246 deletions

View File

@ -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",
]
);
}
}

View File

@ -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]

View File

@ -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"));

View File

@ -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
@ -40,10 +39,6 @@ struct Cli {
#[arg(long, value_name = "PATH")]
project: Option<PathBuf>,
/// Internal typed pod-name override for session restore launched by the TUI.
#[arg(long, value_name = "NAME", requires = "session", hide = true)]
session_pod_name: Option<String>,
/// Internal resolved manifest config for delegated child Pod spawning.
#[arg(
long,
@ -73,7 +68,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
@ -86,10 +81,54 @@ struct Cli {
/// concurrent writers are prevented by the pod-registry.
/// Mutually exclusive with `--adopt` (spawned children always start
/// fresh).
#[arg(long, value_name = "UUID", conflicts_with_all = ["adopt", "pod"])]
#[arg(long, value_name = "UUID", conflicts_with_all = ["adopt"])]
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.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 +138,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 +158,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 +166,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.pod.as_deref() {
manifest.pod.name = pod_name.to_string();
}
Ok(())
@ -141,14 +182,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 +198,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 +221,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 +278,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 +293,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 +594,20 @@ permission = "write"
"yoi pod",
"--profile",
profile.to_str().unwrap(),
"--profile-pod-name",
"--pod",
"from-profile-name",
])
.unwrap();
let mut called = false;
let (manifest, loader) =
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, pod_name| {
called = true;
assert_eq!(selector, &ProfileSelector::path(profile.clone()));
assert_eq!(pod_name, Some("from-profile-name"));
assert_eq!(pod_name, "from-profile-name");
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
if let Some(pod_name) = pod_name {
manifest.pod.name = pod_name.to_string();
}
manifest.pod.name = pod_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
@ -571,14 +625,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 +643,7 @@ permission = "write"
);
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
if let Some(pod_name) = pod_name {
manifest.pod.name = pod_name.to_string();
}
manifest.pod.name = pod_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
@ -601,31 +653,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();
@ -633,12 +728,28 @@ permission = "write"
}
#[test]
fn pod_flag_conflicts_with_session() {
let segment_id = session_store::new_segment_id();
let segment_id = segment_id.to_string();
let err = Cli::try_parse_from(["yoi pod", "--pod", "agent", "--session", &segment_id])
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
fn pod_flag_is_runtime_identity_for_session_restore() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("explicit-workspace");
let store = tmp.path().join("sessions");
std::fs::create_dir(&workspace).unwrap();
let segment_id = session_store::new_segment_id().to_string();
let cli = Cli::try_parse_from([
"yoi pod",
"--workspace",
workspace.to_str().unwrap(),
"--session",
&segment_id,
"--pod",
"explicit-name",
"--store",
store.to_str().unwrap(),
])
.unwrap();
assert_eq!(cli.session.unwrap().to_string(), segment_id);
assert_eq!(cli.pod.as_deref(), Some("explicit-name"));
assert_eq!(runtime_pod_name(&cli, &workspace), "explicit-name");
}
#[test]
@ -700,16 +811,14 @@ permission = "write"
let mut called = false;
let (manifest, _loader) =
resolve_manifest_with_profile_loader(&cli, |selector, pod_name| {
resolve_manifest_with_profile_loader(&cli, |selector, _workspace_root, pod_name| {
called = true;
assert_eq!(selector, &ProfileSelector::Default);
assert_eq!(pod_name, Some("agent"));
assert_eq!(pod_name, "agent");
let mut manifest =
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
.unwrap();
if let Some(pod_name) = pod_name {
manifest.pod.name = pod_name.to_string();
}
manifest.pod.name = pod_name.to_string();
Ok((manifest, PromptLoader::builtins_only()))
})
.unwrap();
@ -723,7 +832,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 +840,30 @@ 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([
fn old_session_pod_name_identity_alias_is_rejected() {
let segment_id = session_store::new_segment_id().to_string();
let err = Cli::try_parse_from([
"yoi pod",
"--profile",
"p.lua",
"--profile-pod-name",
"--session",
&segment_id,
"--session-pod-name",
"agent",
])
.unwrap();
assert_eq!(cli.profile_pod_name.as_deref(), Some("agent"));
assert!(cli.pod.is_none());
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
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]

View File

@ -33,11 +33,13 @@ use client::PodRuntimeCommand;
pub struct LaunchOptions {
pub mode: LaunchMode,
pub runtime_command: PodRuntimeCommand,
pub workspace_root: PathBuf,
}
#[derive(Debug, Clone)]
pub enum LaunchMode {
Spawn {
pod_name: Option<String>,
profile: Option<String>,
},
/// `yoi <name>` / `yoi --pod <name>`: attach to a live Pod by name if
@ -52,7 +54,10 @@ pub enum LaunchMode {
Resume,
/// `yoi --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in.
ResumeWithSession(SegmentId),
ResumeWithSession {
id: SegmentId,
pod_name: Option<String>,
},
/// `yoi panel`: open the workspace panel from the current workspace.
Panel,
}
@ -61,8 +66,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,16 +88,16 @@ 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,
socket_override,
} => 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
LaunchMode::ResumeWithSession { id, pod_name } => {
single_pod::run_spawn(Some(id), pod_name, None, runtime_command).await
}
LaunchMode::Panel => single_pod::run_panel(runtime_command).await,
};

View File

@ -1622,9 +1622,8 @@ async fn restore_workspace_companion_pod(
runtime_command,
pod_name: pod_name.to_string(),
profile: None,
cwd: workspace_root.to_path_buf(),
workspace_root: workspace_root.to_path_buf(),
resume_from: None,
resume_by_pod_name: true,
};
spawn_pod(config, |_| {}).await.map(|_| ())
}
@ -1638,9 +1637,8 @@ async fn spawn_workspace_companion_pod(
runtime_command,
pod_name: pod_name.to_string(),
profile: None,
cwd: workspace_root.to_path_buf(),
workspace_root: workspace_root.to_path_buf(),
resume_from: None,
resume_by_pod_name: false,
};
spawn_pod(config, |_| {}).await.map(|_| ())
}
@ -1654,9 +1652,8 @@ async fn restore_orchestrator_pod(
runtime_command,
pod_name: pod_name.to_string(),
profile: None,
cwd: workspace_root.to_path_buf(),
workspace_root: workspace_root.to_path_buf(),
resume_from: None,
resume_by_pod_name: true,
};
spawn_pod(config, |_| {}).await.map(|_| ())
}

View File

@ -215,10 +215,11 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
pub(crate) async fn run_spawn(
resume_from: Option<SegmentId>,
pod_name: Option<String>,
profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from, profile, runtime_command.clone()).await? {
let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};

View File

@ -75,6 +75,7 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// passes `--session <id>` to the spawned Pod runtime child.
pub async fn run(
resume_from: Option<SegmentId>,
pod_name: Option<String>,
profile: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
@ -90,15 +91,16 @@ pub async fn run(
defaults.default_profile_index,
);
let selected_name = pod_name.unwrap_or(defaults.default_name);
let immediate = resume_from.is_some() || profile.is_some() && !selected_name.is_empty();
let mut form = Form {
cwd: defaults.cwd.clone(),
scope_origin: defaults.scope_origin,
name_cursor: defaults.default_name.chars().count(),
name: defaults.default_name,
name_cursor: selected_name.chars().count(),
name: selected_name,
message: None,
editing: true,
resume_from,
resume_by_pod_name: false,
profile_choices,
profile_index,
};
@ -106,34 +108,41 @@ pub async fn run(
let mut terminal = make_inline_terminal()?;
// Phase 1: confirm / cancel.
loop {
terminal.draw(|f| draw_form(f, &form))?;
match poll_event()? {
None => continue,
Some(Action::Submit) => {
if form.name.trim().is_empty() {
form.message = Some(("name is required".to_string(), MessageKind::Error));
continue;
if !immediate {
loop {
terminal.draw(|f| draw_form(f, &form))?;
match poll_event()? {
None => continue,
Some(Action::Submit) => {
if form.name.trim().is_empty() {
form.message = Some(("name is required".to_string(), MessageKind::Error));
continue;
}
break;
}
break;
Some(Action::Cancel) => {
form.editing = false;
form.message = Some(("cancelled".to_string(), MessageKind::Info));
terminal.draw(|f| draw_form(f, &form))?;
drop(terminal);
return Ok(SpawnOutcome::Cancelled);
}
Some(Action::Char(c)) => form.insert_char(c),
Some(Action::Backspace) => form.backspace(),
Some(Action::Delete) => form.delete_forward(),
Some(Action::Left) => form.move_left(),
Some(Action::Right) => form.move_right(),
Some(Action::Home) => form.name_cursor = 0,
Some(Action::End) => form.name_cursor = form.name.chars().count(),
Some(Action::ProfileNext) => form.cycle_profile_next(),
Some(Action::ProfilePrev) => form.cycle_profile_prev(),
}
Some(Action::Cancel) => {
form.editing = false;
form.message = Some(("cancelled".to_string(), MessageKind::Info));
terminal.draw(|f| draw_form(f, &form))?;
drop(terminal);
return Ok(SpawnOutcome::Cancelled);
}
Some(Action::Char(c)) => form.insert_char(c),
Some(Action::Backspace) => form.backspace(),
Some(Action::Delete) => form.delete_forward(),
Some(Action::Left) => form.move_left(),
Some(Action::Right) => form.move_right(),
Some(Action::Home) => form.name_cursor = 0,
Some(Action::End) => form.name_cursor = form.name.chars().count(),
Some(Action::ProfileNext) => form.cycle_profile_next(),
Some(Action::ProfilePrev) => form.cycle_profile_prev(),
}
} else if form.name.trim().is_empty() {
return Err(SpawnError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"name is required",
)));
}
// Phase 2: launch pod and wait for ready line. Drop the cursor
@ -290,7 +299,6 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
message: Some(("resuming pod...".to_string(), MessageKind::Progress)),
editing: false,
resume_from: None,
resume_by_pod_name: true,
profile_choices: Vec::new(),
profile_index: 0,
}
@ -370,9 +378,8 @@ async fn wait_for_ready(
runtime_command: runtime_command.clone(),
pod_name: form.name.clone(),
profile: form.selected_profile_selector(),
cwd: form.cwd.clone(),
workspace_root: form.cwd.clone(),
resume_from: form.resume_from,
resume_by_pod_name: form.resume_by_pod_name,
};
let ready = spawn_pod(config, |line| {
form.message = Some((line.to_string(), MessageKind::Progress));
@ -418,9 +425,6 @@ struct Form {
/// child pod is launched with `--session <id>` so it restores
/// from `id` and appends to the same session log.
resume_from: Option<SegmentId>,
/// When true, launch the child with `--pod <name>` so the pod process
/// resolves name-keyed state before falling back to fresh creation.
resume_by_pod_name: bool,
/// Optional profile choices passed with `--profile` for
/// fresh spawns. This is not used for resume/attach flows because those must
/// restore Pod state rather than re-evaluate a profile source.
@ -622,7 +626,6 @@ mod tests {
message: None,
editing: true,
resume_from: None,
resume_by_pod_name: false,
profile_choices: Vec::new(),
profile_index: 0,
}
@ -642,7 +645,6 @@ mod tests {
assert_eq!(f.name, "agent");
assert_eq!(f.name_cursor, "agent".chars().count());
assert_eq!(f.resume_from, None);
assert!(f.resume_by_pod_name);
assert!(!f.editing);
assert_eq!(
f.message,

View File

@ -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 {
pod_name: "memory".to_string(),
socket_override: None,
}));
return Ok(Mode::Tui {
mode: LaunchMode::PodName {
pod_name: "memory".to_string(),
socket_override: None,
},
workspace_root: std::env::current_dir()
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))?,
});
}
_ => {}
}
let mut workspace_root = std::env::current_dir()
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))?;
let mut resume = false;
let mut session = None;
let mut pod_name = None;
@ -190,6 +210,16 @@ fn parse_args_slice(args: &[String]) -> Result<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,21 +291,12 @@ 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(),
));
}
if pod_name.is_some() && session.is_some() {
return Err(ParseError(
"--pod and --session are mutually exclusive".to_string(),
));
}
if pod_name.is_some() && resume {
return Err(ParseError(
"--pod and --resume are mutually exclusive".to_string(),
@ -290,20 +319,71 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
}
let pod_name = pod_name.or(positional);
if let Some(pod_name) = pod_name {
return Ok(Mode::Tui(LaunchMode::PodName {
pod_name,
socket_override,
}));
}
if resume {
return Ok(Mode::Tui(LaunchMode::Resume));
}
if let Some(id) = session {
return Ok(Mode::Tui(LaunchMode::ResumeWithSession(id)));
if socket_override.is_some() && session.is_some() {
return Err(ParseError(
"--socket can only be used with --pod attach mode".to_string(),
));
}
Ok(Mode::Tui(LaunchMode::Spawn { profile }))
if let Some(profile) = profile {
return Ok(Mode::Tui {
mode: LaunchMode::Spawn {
pod_name,
profile: Some(profile),
},
workspace_root,
});
}
if let Some(id) = session {
return Ok(Mode::Tui {
mode: LaunchMode::ResumeWithSession { id, pod_name },
workspace_root,
});
}
if let Some(pod_name) = pod_name {
return Ok(Mode::Tui {
mode: LaunchMode::PodName {
pod_name,
socket_override,
},
workspace_root,
});
}
if resume {
return Ok(Mode::Tui {
mode: LaunchMode::Resume,
workspace_root,
});
}
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 {
pod_name,
socket_override,
}) => {
Mode::Tui {
mode:
LaunchMode::PodName {
pod_name,
socket_override,
},
..
} => {
assert_eq!(pod_name, "agent");
assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock")));
}
@ -345,10 +429,14 @@ mod tests {
#[test]
fn parse_positional_name_uses_pod_name_mode() {
match parse_args_from(["agent"]).unwrap() {
Mode::Tui(LaunchMode::PodName {
pod_name,
socket_override,
}) => {
Mode::Tui {
mode:
LaunchMode::PodName {
pod_name,
socket_override,
},
..
} => {
assert_eq!(pod_name, "agent");
assert_eq!(socket_override, None);
}
@ -359,10 +447,14 @@ mod tests {
#[test]
fn parse_memory_alone_remains_positional_pod_name() {
match parse_args_from(["memory"]).unwrap() {
Mode::Tui(LaunchMode::PodName {
pod_name,
socket_override,
}) => {
Mode::Tui {
mode:
LaunchMode::PodName {
pod_name,
socket_override,
},
..
} => {
assert_eq!(pod_name, "memory");
assert_eq!(socket_override, None);
}
@ -405,10 +497,14 @@ mod tests {
#[test]
fn parse_literal_pod_name_still_available_with_flag() {
match parse_args_from(["--pod", "pod"]).unwrap() {
Mode::Tui(LaunchMode::PodName {
pod_name,
socket_override,
}) => {
Mode::Tui {
mode:
LaunchMode::PodName {
pod_name,
socket_override,
},
..
} => {
assert_eq!(pod_name, "pod");
assert_eq!(socket_override, None);
}
@ -458,19 +554,38 @@ 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"),
}
}
#[test]
fn parse_rejects_pod_and_session() {
let segment_id = session_store::new_segment_id().to_string();
let err = parse_args_from(["--pod", "agent", "--session", &segment_id]).unwrap_err();
assert_eq!(
err.to_string(),
"--pod and --session are mutually exclusive"
);
fn parse_session_accepts_explicit_runtime_pod_identity() {
let segment_id = session_store::new_segment_id();
match parse_args_from([
"--session",
&segment_id.to_string(),
"--pod",
"explicit-name",
])
.unwrap()
{
Mode::Tui {
mode:
LaunchMode::ResumeWithSession {
id,
pod_name: Some(pod_name),
},
..
} => {
assert_eq!(id, segment_id);
assert_eq!(pod_name, "explicit-name");
}
_ => panic!("expected ResumeWithSession mode with explicit pod name"),
}
}
#[test]
@ -498,9 +613,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 +683,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"),
}
}