fix: make mission workspaces backend-aware
This commit is contained in:
@@ -132,9 +132,9 @@ pub async fn get_backend_config(
|
||||
let api_key_configured = state
|
||||
.secrets
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
// Check async context
|
||||
false // TODO: implement proper secret check
|
||||
.map(|_s| {
|
||||
// TODO: implement proper secret check
|
||||
false
|
||||
})
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({
|
||||
|
||||
@@ -1059,7 +1059,7 @@ pub async fn create_mission(
|
||||
) -> Result<Json<Mission>, (StatusCode, String)> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
let (title, workspace_id, agent, model_override, backend) = body
|
||||
let (title, workspace_id, agent, model_override, mut backend) = body
|
||||
.map(|b| {
|
||||
(
|
||||
b.title.clone(),
|
||||
@@ -1071,6 +1071,12 @@ pub async fn create_mission(
|
||||
})
|
||||
.unwrap_or((None, None, None, None, None));
|
||||
|
||||
if let Some(value) = backend.as_ref() {
|
||||
if value.trim().is_empty() {
|
||||
backend = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate agent exists before creating mission (fail fast with clear error)
|
||||
if let Some(ref agent_name) = agent {
|
||||
super::library::validate_agent_exists(&state, agent_name)
|
||||
@@ -1078,6 +1084,16 @@ pub async fn create_mission(
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
}
|
||||
|
||||
if let Some(ref backend_id) = backend {
|
||||
let registry = state.backend_registry.read().await;
|
||||
if registry.get(backend_id).is_none() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Unknown backend: {}", backend_id),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let control = control_for_user(&state, &user).await;
|
||||
control
|
||||
.cmd_tx
|
||||
@@ -2318,6 +2334,7 @@ async fn control_actor_loop(
|
||||
tid,
|
||||
mission.workspace_id,
|
||||
mission.agent.clone(),
|
||||
Some(mission.backend.clone()),
|
||||
);
|
||||
// Load existing history
|
||||
for entry in &mission.history {
|
||||
@@ -2449,19 +2466,20 @@ async fn control_actor_loop(
|
||||
let progress_ref = Arc::clone(&progress);
|
||||
// Capture which mission this task is working on
|
||||
let mission_id = current_mission.read().await.clone();
|
||||
let (workspace_id, model_override, mission_agent) = if let Some(mid) = mission_id {
|
||||
let (workspace_id, model_override, mission_agent, backend_id) = if let Some(mid) = mission_id {
|
||||
match mission_store.get_mission(mid).await {
|
||||
Ok(Some(mission)) => (
|
||||
Some(mission.workspace_id),
|
||||
mission.model_override.clone(),
|
||||
mission.agent.clone(),
|
||||
Some(mission.backend.clone()),
|
||||
),
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"Mission {} not found while resolving workspace",
|
||||
mid
|
||||
);
|
||||
(None, None, None)
|
||||
(None, None, None, None)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -2469,11 +2487,11 @@ async fn control_actor_loop(
|
||||
mid,
|
||||
e
|
||||
);
|
||||
(None, None, None)
|
||||
(None, None, None, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, None, None)
|
||||
(None, None, None, None)
|
||||
};
|
||||
// Per-message agent overrides mission agent
|
||||
let agent_override = per_msg_agent.or(mission_agent);
|
||||
@@ -2499,6 +2517,7 @@ async fn control_actor_loop(
|
||||
progress_ref,
|
||||
mission_id,
|
||||
workspace_id,
|
||||
backend_id,
|
||||
model_override,
|
||||
agent_override,
|
||||
)
|
||||
@@ -2680,6 +2699,7 @@ async fn control_actor_loop(
|
||||
mission_id,
|
||||
mission.workspace_id,
|
||||
mission.agent.clone(),
|
||||
Some(mission.backend.clone()),
|
||||
);
|
||||
|
||||
// Load existing history into runner to preserve conversation context
|
||||
@@ -2847,6 +2867,7 @@ async fn control_actor_loop(
|
||||
let tree_ref = Arc::clone(¤t_tree);
|
||||
let progress_ref = Arc::clone(&progress);
|
||||
let workspace_id = Some(mission.workspace_id);
|
||||
let backend_id = Some(mission.backend.clone());
|
||||
let model_override = mission.model_override.clone();
|
||||
// Resume uses mission agent (no per-message override for resumes)
|
||||
let agent_override = mission.agent.clone();
|
||||
@@ -2871,6 +2892,7 @@ async fn control_actor_loop(
|
||||
progress_ref,
|
||||
Some(mission_id),
|
||||
workspace_id,
|
||||
backend_id,
|
||||
model_override,
|
||||
agent_override,
|
||||
)
|
||||
@@ -3303,19 +3325,20 @@ async fn control_actor_loop(
|
||||
running_cancel = Some(cancel.clone());
|
||||
// Capture which mission this task is working on
|
||||
let mission_id = current_mission.read().await.clone();
|
||||
let (workspace_id, model_override, mission_agent) = if let Some(mid) = mission_id {
|
||||
let (workspace_id, model_override, mission_agent, backend_id) = if let Some(mid) = mission_id {
|
||||
match mission_store.get_mission(mid).await {
|
||||
Ok(Some(mission)) => (
|
||||
Some(mission.workspace_id),
|
||||
mission.model_override.clone(),
|
||||
mission.agent.clone(),
|
||||
Some(mission.backend.clone()),
|
||||
),
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"Mission {} not found while resolving workspace",
|
||||
mid
|
||||
);
|
||||
(None, None, None)
|
||||
(None, None, None, None)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -3323,11 +3346,11 @@ async fn control_actor_loop(
|
||||
mid,
|
||||
e
|
||||
);
|
||||
(None, None, None)
|
||||
(None, None, None, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, None, None)
|
||||
(None, None, None, None)
|
||||
};
|
||||
// Per-message agent overrides mission agent
|
||||
let agent_override = per_msg_agent.or(mission_agent);
|
||||
@@ -3352,6 +3375,7 @@ async fn control_actor_loop(
|
||||
progress_ref,
|
||||
mission_id,
|
||||
workspace_id,
|
||||
backend_id,
|
||||
model_override,
|
||||
agent_override,
|
||||
)
|
||||
@@ -3572,6 +3596,7 @@ async fn run_single_control_turn(
|
||||
progress_snapshot: Arc<RwLock<ExecutionProgress>>,
|
||||
mission_id: Option<Uuid>,
|
||||
workspace_id: Option<Uuid>,
|
||||
backend_id: Option<String>,
|
||||
model_override: Option<String>,
|
||||
agent_override: Option<String>,
|
||||
) -> crate::agents::AgentResult {
|
||||
@@ -3587,13 +3612,20 @@ async fn run_single_control_turn(
|
||||
// Get library for skill syncing
|
||||
let lib_guard = library.read().await;
|
||||
let lib_ref = lib_guard.as_ref().map(|l| l.as_ref());
|
||||
let dir =
|
||||
match workspace::prepare_mission_workspace_with_skills(&ws, &mcp, lib_ref, mid).await {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to prepare mission workspace: {}", e);
|
||||
ws.path.clone()
|
||||
}
|
||||
let dir = match workspace::prepare_mission_workspace_with_skills_backend(
|
||||
&ws,
|
||||
&mcp,
|
||||
lib_ref,
|
||||
mid,
|
||||
backend_id.as_deref().unwrap_or("opencode"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to prepare mission workspace: {}", e);
|
||||
ws.path.clone()
|
||||
}
|
||||
};
|
||||
(dir, Some(ws))
|
||||
} else {
|
||||
|
||||
@@ -75,6 +75,9 @@ pub struct MissionRunner {
|
||||
/// Workspace ID where this mission should run
|
||||
pub workspace_id: Uuid,
|
||||
|
||||
/// Backend ID used for this mission
|
||||
pub backend_id: String,
|
||||
|
||||
/// Current state
|
||||
pub state: MissionRunState,
|
||||
|
||||
@@ -111,10 +114,16 @@ pub struct MissionRunner {
|
||||
|
||||
impl MissionRunner {
|
||||
/// Create a new mission runner.
|
||||
pub fn new(mission_id: Uuid, workspace_id: Uuid, agent_override: Option<String>) -> Self {
|
||||
pub fn new(
|
||||
mission_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
agent_override: Option<String>,
|
||||
backend_id: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
mission_id,
|
||||
workspace_id,
|
||||
backend_id: backend_id.unwrap_or_else(|| "opencode".to_string()),
|
||||
state: MissionRunState::Queued,
|
||||
agent_override,
|
||||
queue: VecDeque::new(),
|
||||
@@ -239,6 +248,7 @@ impl MissionRunner {
|
||||
let mission_id = self.mission_id;
|
||||
let workspace_id = self.workspace_id;
|
||||
let agent_override = self.agent_override.clone();
|
||||
let backend_id = self.backend_id.clone();
|
||||
let user_message = msg.content.clone();
|
||||
let msg_id = msg.id;
|
||||
tracing::info!(
|
||||
@@ -282,6 +292,7 @@ impl MissionRunner {
|
||||
progress_ref,
|
||||
mission_id,
|
||||
Some(workspace_id),
|
||||
backend_id,
|
||||
agent_override,
|
||||
)
|
||||
.await;
|
||||
@@ -384,6 +395,7 @@ async fn run_mission_turn(
|
||||
progress_snapshot: Arc<RwLock<ExecutionProgress>>,
|
||||
mission_id: Uuid,
|
||||
workspace_id: Option<Uuid>,
|
||||
backend_id: String,
|
||||
agent_override: Option<String>,
|
||||
) -> AgentResult {
|
||||
let mut config = config;
|
||||
@@ -465,8 +477,14 @@ async fn run_mission_turn(
|
||||
let mission_work_dir = match {
|
||||
let lib_guard = library.read().await;
|
||||
let lib_ref = lib_guard.as_ref().map(|l| l.as_ref());
|
||||
workspace::prepare_mission_workspace_with_skills(&workspace, &mcp, lib_ref, mission_id)
|
||||
.await
|
||||
workspace::prepare_mission_workspace_with_skills_backend(
|
||||
&workspace,
|
||||
&mcp,
|
||||
lib_ref,
|
||||
mission_id,
|
||||
&backend_id,
|
||||
)
|
||||
.await
|
||||
} {
|
||||
Ok(dir) => {
|
||||
tracing::info!(
|
||||
|
||||
211
src/workspace.rs
211
src/workspace.rs
@@ -692,6 +692,62 @@ fn opencode_entry_from_mcp(
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_entry_from_mcp(
|
||||
config: &McpServerConfig,
|
||||
workspace_dir: &Path,
|
||||
workspace_root: &Path,
|
||||
workspace_type: WorkspaceType,
|
||||
workspace_env: &HashMap<String, String>,
|
||||
shared_network: Option<bool>,
|
||||
) -> serde_json::Value {
|
||||
match &config.transport {
|
||||
McpTransport::Http { endpoint, headers } => {
|
||||
let mut entry = serde_json::Map::new();
|
||||
entry.insert("url".to_string(), json!(endpoint));
|
||||
if !headers.is_empty() {
|
||||
entry.insert("headers".to_string(), json!(headers));
|
||||
}
|
||||
serde_json::Value::Object(entry)
|
||||
}
|
||||
McpTransport::Stdio { .. } => {
|
||||
let opencode_entry = opencode_entry_from_mcp(
|
||||
config,
|
||||
workspace_dir,
|
||||
workspace_root,
|
||||
workspace_type,
|
||||
workspace_env,
|
||||
shared_network,
|
||||
);
|
||||
|
||||
let command_vec = opencode_entry
|
||||
.get("command")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let command = command_vec
|
||||
.first()
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let args: Vec<String> = command_vec
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
let mut entry = serde_json::Map::new();
|
||||
entry.insert("command".to_string(), json!(command));
|
||||
entry.insert("args".to_string(), json!(args));
|
||||
|
||||
if let Some(env) = opencode_entry.get("environment").and_then(|v| v.as_object()) {
|
||||
entry.insert("env".to_string(), serde_json::Value::Object(env.clone()));
|
||||
}
|
||||
|
||||
serde_json::Value::Object(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_opencode_config(
|
||||
workspace_dir: &Path,
|
||||
mcp_configs: Vec<McpServerConfig>,
|
||||
@@ -810,6 +866,7 @@ async fn write_claudecode_config(
|
||||
workspace_type: WorkspaceType,
|
||||
workspace_env: &HashMap<String, String>,
|
||||
skill_contents: Option<&[SkillContent]>,
|
||||
shared_network: Option<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Create .claude directory
|
||||
let claude_dir = workspace_dir.join(".claude");
|
||||
@@ -817,64 +874,24 @@ async fn write_claudecode_config(
|
||||
|
||||
// Build MCP servers config in Claude Code format
|
||||
let mut mcp_servers = serde_json::Map::new();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
|
||||
let filtered_configs = mcp_configs.into_iter().filter(|c| c.enabled);
|
||||
|
||||
for config in filtered_configs {
|
||||
let server_config = match &config.transport {
|
||||
McpTransport::Stdio(stdio) => {
|
||||
// For container workspaces, wrap commands with nspawn
|
||||
let (command, args) = match workspace_type {
|
||||
WorkspaceType::Chroot => {
|
||||
let container_name = workspace_root
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let mut nspawn_args = vec![
|
||||
"-q".to_string(),
|
||||
"-D".to_string(),
|
||||
workspace_root.to_string_lossy().to_string(),
|
||||
"-M".to_string(),
|
||||
container_name,
|
||||
"--".to_string(),
|
||||
];
|
||||
nspawn_args.extend(stdio.command.iter().cloned());
|
||||
nspawn_args.extend(stdio.args.iter().cloned());
|
||||
("systemd-nspawn".to_string(), nspawn_args)
|
||||
}
|
||||
WorkspaceType::Host => {
|
||||
let command = stdio.command.first().cloned().unwrap_or_default();
|
||||
let args: Vec<String> = stdio
|
||||
.command
|
||||
.iter()
|
||||
.skip(1)
|
||||
.chain(stdio.args.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
(command, args)
|
||||
}
|
||||
};
|
||||
|
||||
// Merge environment variables
|
||||
let mut env = workspace_env.clone();
|
||||
env.extend(stdio.env.clone());
|
||||
|
||||
json!({
|
||||
"command": command,
|
||||
"args": args,
|
||||
"env": env,
|
||||
})
|
||||
}
|
||||
McpTransport::Http(http) => {
|
||||
json!({
|
||||
"url": http.endpoint,
|
||||
"headers": http.headers,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let key = sanitize_key(&config.name);
|
||||
mcp_servers.insert(key, server_config);
|
||||
let base = sanitize_key(&config.name);
|
||||
let key = unique_key(&base, &mut used);
|
||||
mcp_servers.insert(
|
||||
key,
|
||||
claude_entry_from_mcp(
|
||||
&config,
|
||||
workspace_dir,
|
||||
workspace_root,
|
||||
workspace_type,
|
||||
workspace_env,
|
||||
shared_network,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Write settings.local.json
|
||||
@@ -887,10 +904,15 @@ async fn write_claudecode_config(
|
||||
|
||||
// Generate CLAUDE.md from skills
|
||||
if let Some(skills) = skill_contents {
|
||||
if !skills.is_empty() {
|
||||
let claude_md_path = workspace_dir.join("CLAUDE.md");
|
||||
if skills.is_empty() {
|
||||
let _ = tokio::fs::remove_file(&claude_md_path).await;
|
||||
} else {
|
||||
let mut claude_md = String::new();
|
||||
claude_md.push_str("# Project Context\n\n");
|
||||
claude_md.push_str("This file was generated by Open Agent. It contains context from configured skills.\n\n");
|
||||
claude_md.push_str(
|
||||
"This file was generated by Open Agent. It contains context from configured skills.\n\n",
|
||||
);
|
||||
|
||||
for skill in skills {
|
||||
claude_md.push_str(&format!("## {}\n\n", skill.name));
|
||||
@@ -908,7 +930,6 @@ async fn write_claudecode_config(
|
||||
claude_md.push_str("\n\n");
|
||||
}
|
||||
|
||||
let claude_md_path = workspace_dir.join("CLAUDE.md");
|
||||
tokio::fs::write(&claude_md_path, claude_md).await?;
|
||||
}
|
||||
}
|
||||
@@ -943,6 +964,17 @@ pub async fn write_backend_config(
|
||||
.await
|
||||
}
|
||||
"claudecode" => {
|
||||
// Keep OpenCode config in sync for compatibility with existing execution pipeline.
|
||||
write_opencode_config(
|
||||
workspace_dir,
|
||||
mcp_configs.clone(),
|
||||
workspace_root,
|
||||
workspace_type,
|
||||
workspace_env,
|
||||
skill_allowlist,
|
||||
shared_network,
|
||||
)
|
||||
.await?;
|
||||
write_claudecode_config(
|
||||
workspace_dir,
|
||||
mcp_configs,
|
||||
@@ -950,6 +982,7 @@ pub async fn write_backend_config(
|
||||
workspace_type,
|
||||
workspace_env,
|
||||
skill_contents,
|
||||
shared_network,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1173,6 +1206,25 @@ pub async fn sync_skills_to_dir(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let skills_to_write = collect_skill_contents(skill_names, context_name, library).await;
|
||||
|
||||
write_skills_to_workspace(target_dir, &skills_to_write).await?;
|
||||
|
||||
tracing::info!(
|
||||
context = %context_name,
|
||||
skills = ?skill_names,
|
||||
target = %target_dir.display(),
|
||||
"Synced skills to directory"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn collect_skill_contents(
|
||||
skill_names: &[String],
|
||||
context_name: &str,
|
||||
library: &LibraryStore,
|
||||
) -> Vec<SkillContent> {
|
||||
let mut skills_to_write: Vec<SkillContent> = Vec::new();
|
||||
|
||||
for skill_name in skill_names {
|
||||
@@ -1200,16 +1252,7 @@ pub async fn sync_skills_to_dir(
|
||||
}
|
||||
}
|
||||
|
||||
write_skills_to_workspace(target_dir, &skills_to_write).await?;
|
||||
|
||||
tracing::info!(
|
||||
context = %context_name,
|
||||
skills = ?skill_names,
|
||||
target = %target_dir.display(),
|
||||
"Synced skills to directory"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
skills_to_write
|
||||
}
|
||||
|
||||
/// Tool content to be written to the workspace.
|
||||
@@ -1500,6 +1543,24 @@ pub async fn prepare_mission_workspace_with_skills(
|
||||
mcp: &McpRegistry,
|
||||
library: Option<&LibraryStore>,
|
||||
mission_id: Uuid,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
prepare_mission_workspace_with_skills_backend(
|
||||
workspace,
|
||||
mcp,
|
||||
library,
|
||||
mission_id,
|
||||
"opencode",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Prepare a workspace directory for a mission with skill and tool syncing for a specific backend.
|
||||
pub async fn prepare_mission_workspace_with_skills_backend(
|
||||
workspace: &Workspace,
|
||||
mcp: &McpRegistry,
|
||||
library: Option<&LibraryStore>,
|
||||
mission_id: Uuid,
|
||||
backend_id: &str,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let dir = mission_workspace_dir_for_root(&workspace.path, mission_id);
|
||||
prepare_workspace_dir(&dir).await?;
|
||||
@@ -1509,13 +1570,27 @@ pub async fn prepare_mission_workspace_with_skills(
|
||||
} else {
|
||||
Some(workspace.skills.as_slice())
|
||||
};
|
||||
write_opencode_config(
|
||||
let mut skill_contents: Option<Vec<SkillContent>> = None;
|
||||
|
||||
if backend_id == "claudecode" {
|
||||
if let Some(lib) = library {
|
||||
let context = format!("mission-{}", mission_id);
|
||||
let skill_names =
|
||||
resolve_workspace_skill_names(workspace, lib).await.unwrap_or_default();
|
||||
let skills = collect_skill_contents(&skill_names, &context, lib).await;
|
||||
skill_contents = Some(skills);
|
||||
}
|
||||
}
|
||||
|
||||
write_backend_config(
|
||||
&dir,
|
||||
backend_id,
|
||||
mcp_configs,
|
||||
&workspace.path,
|
||||
workspace.workspace_type,
|
||||
&workspace.env_vars,
|
||||
skill_allowlist,
|
||||
skill_contents.as_deref(),
|
||||
workspace.shared_network,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Reference in New Issue
Block a user