Accept story 30: Worktree-based agent orchestration
Add git worktree isolation for concurrent story agents. Each agent now runs in its own worktree with setup/teardown commands driven by .story_kit/project.toml config. Agents stream output via SSE and support start/stop lifecycle with Pending/Running/Completed/Failed statuses. Backend: config.rs (TOML parsing), worktree.rs (git worktree lifecycle), refactored agents.rs (broadcast streaming), agents_sse.rs (SSE endpoint). Frontend: AgentPanel.tsx with Run/Stop buttons and streaming output log. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
145
server/src/config.rs
Normal file
145
server/src/config.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProjectConfig {
|
||||
#[serde(default)]
|
||||
pub component: Vec<ComponentConfig>,
|
||||
pub agent: Option<AgentConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ComponentConfig {
|
||||
pub name: String,
|
||||
#[serde(default = "default_path")]
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub setup: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub teardown: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentConfig {
|
||||
#[serde(default = "default_agent_command")]
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default = "default_agent_prompt")]
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
fn default_path() -> String {
|
||||
".".to_string()
|
||||
}
|
||||
|
||||
fn default_agent_command() -> String {
|
||||
"claude".to_string()
|
||||
}
|
||||
|
||||
fn default_agent_prompt() -> String {
|
||||
"Read .story_kit/README.md, then pick up story {{story_id}}".to_string()
|
||||
}
|
||||
|
||||
impl Default for ProjectConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
component: Vec::new(),
|
||||
agent: Some(AgentConfig {
|
||||
command: default_agent_command(),
|
||||
args: vec![],
|
||||
prompt: default_agent_prompt(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
/// Load from `.story_kit/project.toml` relative to the given root.
|
||||
/// Falls back to sensible defaults if the file doesn't exist.
|
||||
pub fn load(project_root: &Path) -> Result<Self, String> {
|
||||
let config_path = project_root.join(".story_kit/project.toml");
|
||||
if !config_path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
|
||||
toml::from_str(&content).map_err(|e| format!("Parse config: {e}"))
|
||||
}
|
||||
|
||||
/// Render template variables in agent args and prompt.
|
||||
pub fn render_agent_args(
|
||||
&self,
|
||||
worktree_path: &str,
|
||||
story_id: &str,
|
||||
) -> Option<(String, Vec<String>, String)> {
|
||||
let agent = self.agent.as_ref()?;
|
||||
let render = |s: &str| {
|
||||
s.replace("{{worktree_path}}", worktree_path)
|
||||
.replace("{{story_id}}", story_id)
|
||||
};
|
||||
let command = render(&agent.command);
|
||||
let args: Vec<String> = agent.args.iter().map(|a| render(a)).collect();
|
||||
let prompt = render(&agent.prompt);
|
||||
Some((command, args, prompt))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn default_config_when_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||
assert!(config.agent.is_some());
|
||||
assert!(config.component.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_project_toml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".story_kit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("project.toml"),
|
||||
r#"
|
||||
[[component]]
|
||||
name = "server"
|
||||
path = "."
|
||||
setup = ["cargo check"]
|
||||
teardown = []
|
||||
|
||||
[[component]]
|
||||
name = "frontend"
|
||||
path = "frontend"
|
||||
setup = ["pnpm install"]
|
||||
|
||||
[agent]
|
||||
command = "claude"
|
||||
args = ["--print", "--directory", "{{worktree_path}}"]
|
||||
prompt = "Pick up story {{story_id}}"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.component.len(), 2);
|
||||
assert_eq!(config.component[0].name, "server");
|
||||
assert_eq!(config.component[1].setup, vec!["pnpm install"]);
|
||||
|
||||
let agent = config.agent.unwrap();
|
||||
assert_eq!(agent.command, "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_template_vars() {
|
||||
let config = ProjectConfig::default();
|
||||
let (cmd, args, prompt) = config.render_agent_args("/tmp/wt", "42_foo").unwrap();
|
||||
assert_eq!(cmd, "claude");
|
||||
assert!(args.is_empty());
|
||||
assert!(prompt.contains("42_foo"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user