use std::{
env,
net::SocketAddr,
path::{Path, PathBuf},
};
use crate::opensymphony_memory::DEFAULT_PRIVATE_MEMORY_CONFIG_FILE;
use crate::opensymphony_openhands::OpenHandsConversationStorePaths;
use crate::opensymphony_workflow::{ResolvedWorkflow, WorkflowDefinition};
use serde::Deserialize;
use tokio::fs;
use super::{RunArgs, RunCommandError};
const DEFAULT_CONFIG_FILE: &str = "config.yaml";
const DEFAULT_CONTROL_PLANE_BIND: &str = "127.0.0.1:2468";
#[derive(Debug, Default, Deserialize)]
struct RunConfigFile {
#[serde(default)]
target_repo: Option<String>,
#[serde(default)]
control_plane: ControlPlaneConfigFile,
#[serde(default)]
openhands: RunOpenHandsConfigFile,
#[serde(default)]
memory: RunMemoryConfigFile,
}
#[derive(Debug, Default, Deserialize)]
struct ControlPlaneConfigFile {
#[serde(default)]
bind: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RunOpenHandsConfigFile {
#[serde(default)]
tool_dir: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RunMemoryConfigFile {
#[serde(default)]
auto_capture: Option<bool>,
#[serde(default)]
auto_archive: Option<bool>,
}
pub(super) struct RunMemoryConfig {
pub(super) auto_capture: bool,
pub(super) auto_archive: bool,
}
pub(super) struct RunRuntimeConfig {
pub(super) config_path: Option<PathBuf>,
pub(super) target_repo: PathBuf,
pub(super) workflow_path: PathBuf,
pub(super) workflow: ResolvedWorkflow,
pub(super) bind: SocketAddr,
pub(super) tool_dir: Option<PathBuf>,
pub(super) openhands_conversation_store: Option<OpenHandsConversationStorePaths>,
pub(super) memory: RunMemoryConfig,
}
pub(super) async fn resolve_runtime_config(
args: &RunArgs,
) -> Result<RunRuntimeConfig, RunCommandError> {
let cwd = env::current_dir().map_err(RunCommandError::CurrentDir)?;
let config_path = match &args.config {
Some(path) => Some(resolve_relative_to(&cwd, path)),
None => {
let candidate = cwd.join(DEFAULT_CONFIG_FILE);
candidate.exists().then_some(candidate)
}
};
let config = match &config_path {
Some(path) => load_run_config(path).await?,
None => RunConfigFile::default(),
};
let config_root = config_path
.as_deref()
.and_then(Path::parent)
.unwrap_or(cwd.as_path());
let target_repo = config
.target_repo
.as_deref()
.map(|path| super::super::resolve_path(config_root, path))
.unwrap_or_else(|| cwd.clone());
let workflow_path = target_repo.join("WORKFLOW.md");
let workflow = WorkflowDefinition::load_from_path(&workflow_path).map_err(|source| {
RunCommandError::LoadWorkflow {
path: workflow_path.clone(),
source,
}
})?;
let workflow = workflow
.resolve_with_process_env(&target_repo)
.map_err(|source| RunCommandError::ResolveWorkflow {
path: workflow_path.clone(),
source,
})?;
let bind_value = config
.control_plane
.bind
.as_deref()
.unwrap_or(DEFAULT_CONTROL_PLANE_BIND);
let bind = bind_value
.parse()
.map_err(|source| RunCommandError::InvalidBind {
value: bind_value.to_string(),
source,
})?;
let tool_dir = config
.openhands
.tool_dir
.as_deref()
.map(|path| super::super::resolve_path(config_root, path));
let openhands_conversation_store = tool_dir
.as_ref()
.map(|tool_dir| OpenHandsConversationStorePaths::for_tool_dir(tool_dir, &target_repo))
.transpose()?;
let memory = RunMemoryConfig {
auto_capture: config.memory.auto_capture.unwrap_or(true),
auto_archive: config.memory.auto_archive.unwrap_or(false),
};
validate_memory_bootstrap(&target_repo, &memory)?;
Ok(RunRuntimeConfig {
config_path,
target_repo,
workflow_path,
workflow,
bind,
tool_dir,
openhands_conversation_store,
memory,
})
}
fn validate_memory_bootstrap(
target_repo: &Path,
memory: &RunMemoryConfig,
) -> Result<(), RunCommandError> {
if !memory.auto_capture {
return Ok(());
}
let path = target_repo.join(DEFAULT_PRIVATE_MEMORY_CONFIG_FILE);
if path.is_file() {
return Ok(());
}
Err(RunCommandError::MissingMemoryConfig { path })
}
async fn load_run_config(path: &Path) -> Result<RunConfigFile, RunCommandError> {
let raw = fs::read_to_string(path)
.await
.map_err(|source| RunCommandError::ReadConfig {
path: path.to_path_buf(),
source,
})?;
let config = serde_yaml::from_str::<RunConfigFile>(&raw).map_err(|source| {
RunCommandError::ParseConfig {
path: path.to_path_buf(),
source,
}
})?;
resolve_run_config(path, config)
}
fn resolve_run_config(
path: &Path,
mut config: RunConfigFile,
) -> Result<RunConfigFile, RunCommandError> {
config.target_repo = config
.target_repo
.take()
.map(|value| expand_run_value(path, value))
.transpose()?;
config.control_plane.bind = config
.control_plane
.bind
.take()
.map(|value| expand_run_value(path, value))
.transpose()?;
config.openhands.tool_dir = config
.openhands
.tool_dir
.take()
.map(|value| expand_run_value(path, value))
.transpose()?;
Ok(config)
}
fn expand_run_value(path: &Path, value: String) -> Result<String, RunCommandError> {
super::super::expand_env_tokens(&value).map_err(|error| RunCommandError::ResolveConfig {
path: path.to_path_buf(),
detail: error.to_string(),
})
}
fn resolve_relative_to(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn memory_bootstrap_is_required_when_auto_capture_is_enabled() {
let repo = tempfile::tempdir().expect("temp repo should exist");
let memory = RunMemoryConfig {
auto_capture: true,
auto_archive: false,
};
let result = validate_memory_bootstrap(repo.path(), &memory);
assert!(matches!(
result,
Err(RunCommandError::MissingMemoryConfig { .. })
));
}
#[test]
fn memory_bootstrap_is_not_required_when_auto_capture_is_disabled() {
let repo = tempfile::tempdir().expect("temp repo should exist");
let memory = RunMemoryConfig {
auto_capture: false,
auto_archive: false,
};
validate_memory_bootstrap(repo.path(), &memory).expect("disabled auto-capture should pass");
}
#[test]
fn memory_bootstrap_accepts_initialized_repo() {
let repo = tempfile::tempdir().expect("temp repo should exist");
let path = repo.path().join(DEFAULT_PRIVATE_MEMORY_CONFIG_FILE);
std::fs::create_dir_all(path.parent().expect("memory config should have parent"))
.expect("memory config parent should be created");
std::fs::write(&path, "memory_root: .opensymphony/memory\n")
.expect("memory config should be written");
let memory = RunMemoryConfig {
auto_capture: true,
auto_archive: false,
};
validate_memory_bootstrap(repo.path(), &memory).expect("memory config should satisfy run");
}
}