Files
storkit/server/src/config.rs
Dave 5e5cdd9b2f 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>
2026-02-19 17:58:53 +00:00

146 lines
3.9 KiB
Rust

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