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:
Dave
2026-02-19 17:58:53 +00:00
parent 7e56648954
commit 5e5cdd9b2f
15 changed files with 1440 additions and 281 deletions

16
.story_kit/project.toml Normal file
View File

@@ -0,0 +1,16 @@
[[component]]
name = "frontend"
path = "frontend"
setup = ["pnpm install", "pnpm run build"]
teardown = []
[[component]]
name = "server"
path = "."
setup = ["cargo check"]
teardown = []
[agent]
command = "claude"
args = []
prompt = "Read .story_kit/README.md, then pick up story {{story_id}}"

79
Cargo.lock generated
View File

@@ -26,6 +26,28 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -1401,7 +1423,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [ dependencies = [
"toml_edit", "toml_edit 0.23.10+spec-1.0.0",
] ]
[[package]] [[package]]
@@ -1872,6 +1894,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -2021,7 +2052,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "story-kit-server" name = "story-kit-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-stream",
"async-trait", "async-trait",
"bytes",
"chrono", "chrono",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
@@ -2039,6 +2072,7 @@ dependencies = [
"strip-ansi-escapes", "strip-ansi-escapes",
"tempfile", "tempfile",
"tokio", "tokio",
"toml",
"uuid", "uuid",
"walkdir", "walkdir",
] ]
@@ -2275,6 +2309,27 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.7.5+spec-1.1.0" version = "0.7.5+spec-1.1.0"
@@ -2284,6 +2339,20 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.23.10+spec-1.0.0" version = "0.23.10+spec-1.0.0"
@@ -2291,7 +2360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime 0.7.5+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow",
] ]
@@ -2305,6 +2374,12 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"

115
frontend/src/api/agents.ts Normal file
View File

@@ -0,0 +1,115 @@
export type AgentStatusValue = "pending" | "running" | "completed" | "failed";
export interface AgentInfo {
story_id: string;
status: AgentStatusValue;
session_id: string | null;
worktree_path: string | null;
}
export interface AgentEvent {
type: "status" | "output" | "agent_json" | "done" | "error" | "warning";
story_id?: string;
status?: string;
text?: string;
data?: unknown;
session_id?: string | null;
message?: string;
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const agentsApi = {
startAgent(storyId: string, baseUrl?: string) {
return requestJson<AgentInfo>(
"/agents/start",
{
method: "POST",
body: JSON.stringify({ story_id: storyId }),
},
baseUrl,
);
},
stopAgent(storyId: string, baseUrl?: string) {
return requestJson<boolean>(
"/agents/stop",
{
method: "POST",
body: JSON.stringify({ story_id: storyId }),
},
baseUrl,
);
},
listAgents(baseUrl?: string) {
return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
},
};
/**
* Subscribe to SSE events for a running agent.
* Returns a cleanup function to close the connection.
*/
export function subscribeAgentStream(
storyId: string,
onEvent: (event: AgentEvent) => void,
onError?: (error: Event) => void,
): () => void {
const host = import.meta.env.DEV ? "http://127.0.0.1:3001" : "";
const url = `${host}/agents/${encodeURIComponent(storyId)}/stream`;
const eventSource = new EventSource(url);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as AgentEvent;
onEvent(data);
// Close on terminal events
if (
data.type === "done" ||
data.type === "error" ||
(data.type === "status" && data.status === "stopped")
) {
eventSource.close();
}
} catch (err) {
console.error("Failed to parse agent event:", err);
}
};
eventSource.onerror = (e) => {
onError?.(e);
eventSource.close();
};
return () => {
eventSource.close();
};
}

View File

@@ -0,0 +1,470 @@
import * as React from "react";
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import type { UpcomingStory } from "../api/workflow";
const { useCallback, useEffect, useRef, useState } = React;
interface AgentPanelProps {
stories: UpcomingStory[];
}
interface AgentState {
status: AgentStatusValue;
log: string[];
sessionId: string | null;
worktreePath: string | null;
}
const STATUS_COLORS: Record<AgentStatusValue, string> = {
pending: "#e3b341",
running: "#58a6ff",
completed: "#7ee787",
failed: "#ff7b72",
};
const STATUS_LABELS: Record<AgentStatusValue, string> = {
pending: "Pending",
running: "Running",
completed: "Completed",
failed: "Failed",
};
const formatTimestamp = (value: Date | null): string => {
if (!value) return "";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
function StatusBadge({ status }: { status: AgentStatusValue }) {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.75em",
fontWeight: 600,
background: `${STATUS_COLORS[status]}22`,
color: STATUS_COLORS[status],
border: `1px solid ${STATUS_COLORS[status]}44`,
}}
>
{status === "running" && (
<span
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: STATUS_COLORS[status],
animation: "pulse 1.5s infinite",
}}
/>
)}
{STATUS_LABELS[status]}
</span>
);
}
export function AgentPanel({ stories }: AgentPanelProps) {
const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [expandedStory, setExpandedStory] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const cleanupRefs = useRef<Record<string, () => void>>({});
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Load existing agents on mount
useEffect(() => {
agentsApi
.listAgents()
.then((agentList) => {
const agentMap: Record<string, AgentState> = {};
for (const a of agentList) {
agentMap[a.story_id] = {
status: a.status,
log: [],
sessionId: a.session_id,
worktreePath: a.worktree_path,
};
// Re-subscribe to running agents
if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id);
}
}
setAgents(agentMap);
setLastRefresh(new Date());
})
.catch((err) => console.error("Failed to load agents:", err));
return () => {
for (const cleanup of Object.values(cleanupRefs.current)) {
cleanup();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const subscribeToAgent = useCallback((storyId: string) => {
// Clean up existing subscription
cleanupRefs.current[storyId]?.();
const cleanup = subscribeAgentStream(
storyId,
(event: AgentEvent) => {
setAgents((prev) => {
const current = prev[storyId] ?? {
status: "pending" as AgentStatusValue,
log: [],
sessionId: null,
worktreePath: null,
};
switch (event.type) {
case "status":
return {
...prev,
[storyId]: {
...current,
status: (event.status as AgentStatusValue) ?? current.status,
},
};
case "output":
return {
...prev,
[storyId]: {
...current,
log: [...current.log, event.text ?? ""],
},
};
case "done":
return {
...prev,
[storyId]: {
...current,
status: "completed",
sessionId: event.session_id ?? current.sessionId,
},
};
case "error":
return {
...prev,
[storyId]: {
...current,
status: "failed",
log: [
...current.log,
`[ERROR] ${event.message ?? "Unknown error"}`,
],
},
};
default:
return prev;
}
});
},
() => {
// SSE error — agent may not be streaming yet
},
);
cleanupRefs.current[storyId] = cleanup;
}, []);
// Auto-scroll log when expanded
useEffect(() => {
if (expandedStory) {
const el = logEndRefs.current[expandedStory];
el?.scrollIntoView({ behavior: "smooth" });
}
}, [expandedStory, agents]);
const handleStart = async (storyId: string) => {
setActionError(null);
try {
const info: AgentInfo = await agentsApi.startAgent(storyId);
setAgents((prev) => ({
...prev,
[storyId]: {
status: info.status,
log: [],
sessionId: info.session_id,
worktreePath: info.worktree_path,
},
}));
setExpandedStory(storyId);
subscribeToAgent(storyId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to start agent for ${storyId}: ${message}`);
}
};
const handleStop = async (storyId: string) => {
setActionError(null);
try {
await agentsApi.stopAgent(storyId);
cleanupRefs.current[storyId]?.();
delete cleanupRefs.current[storyId];
setAgents((prev) => {
const next = { ...prev };
delete next[storyId];
return next;
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to stop agent for ${storyId}: ${message}`);
}
};
const isAgentActive = (storyId: string): boolean => {
const agent = agents[storyId];
return agent?.status === "running" || agent?.status === "pending";
};
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Agents</div>
<div
style={{
fontSize: "0.75em",
color: "#777",
fontFamily: "monospace",
}}
>
{Object.values(agents).filter((a) => a.status === "running").length}{" "}
running
</div>
</div>
{lastRefresh && (
<div style={{ fontSize: "0.7em", color: "#555" }}>
Loaded {formatTimestamp(lastRefresh)}
</div>
)}
</div>
{actionError && (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
padding: "4px 8px",
background: "#ff7b7211",
borderRadius: "6px",
}}
>
{actionError}
</div>
)}
{stories.length === 0 ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
No stories available. Add stories to .story_kit/stories/upcoming/.
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
{stories.map((story) => {
const agent = agents[story.story_id];
const isExpanded = expandedStory === story.story_id;
return (
<div
key={`agent-${story.story_id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
background: "#191919",
overflow: "hidden",
}}
>
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<button
type="button"
onClick={() =>
setExpandedStory(isExpanded ? null : story.story_id)
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setExpandedStory(isExpanded ? null : story.story_id);
}
}}
style={{
background: "none",
border: "none",
color: "#aaa",
cursor: "pointer",
fontSize: "0.8em",
padding: "0 4px",
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 0.15s",
}}
>
&#9654;
</button>
<div
style={{
flex: 1,
fontWeight: 600,
fontSize: "0.9em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{story.name ?? story.story_id}
</div>
{agent && <StatusBadge status={agent.status} />}
{isAgentActive(story.story_id) ? (
<button
type="button"
onClick={() => handleStop(story.story_id)}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #ff7b7244",
background: "#ff7b7211",
color: "#ff7b72",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Stop
</button>
) : (
<button
type="button"
onClick={() => handleStart(story.story_id)}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #7ee78744",
background: "#7ee78711",
color: "#7ee787",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Run
</button>
)}
</div>
{isExpanded && agent && (
<div
style={{
borderTop: "1px solid #2a2a2a",
padding: "8px 12px",
}}
>
{agent.worktreePath && (
<div
style={{
fontSize: "0.75em",
color: "#666",
fontFamily: "monospace",
marginBottom: "6px",
}}
>
Worktree: {agent.worktreePath}
</div>
)}
<div
style={{
maxHeight: "300px",
overflowY: "auto",
background: "#111",
borderRadius: "6px",
padding: "8px",
fontFamily: "monospace",
fontSize: "0.8em",
lineHeight: "1.5",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{agent.log.length === 0 ? (
<span style={{ color: "#555" }}>
{agent.status === "pending" ||
agent.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
agent.log.map((line, i) => (
<div
key={`log-${story.story_id}-${i}`}
style={{
color: line.startsWith("[ERROR]")
? "#ff7b72"
: "#ccc",
}}
>
{line}
</div>
))
)}
<div
ref={(el) => {
logEndRefs.current[story.story_id] = el;
}}
/>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { api, ChatWebSocket } from "../api/client";
import type { ReviewStory, UpcomingStory } from "../api/workflow"; import type { ReviewStory, UpcomingStory } from "../api/workflow";
import { workflowApi } from "../api/workflow"; import { workflowApi } from "../api/workflow";
import type { Message, ProviderConfig, ToolCall } from "../types"; import type { Message, ProviderConfig, ToolCall } from "../types";
import { AgentPanel } from "./AgentPanel";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
import { GatePanel } from "./GatePanel"; import { GatePanel } from "./GatePanel";
import { ReviewPanel } from "./ReviewPanel"; import { ReviewPanel } from "./ReviewPanel";
@@ -743,6 +744,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
lastRefresh={lastUpcomingRefresh} lastRefresh={lastUpcomingRefresh}
onRefresh={refreshUpcomingStories} onRefresh={refreshUpcomingStories}
/> />
<AgentPanel stories={upcomingStories} />
</div> </div>
</div> </div>

View File

@@ -22,6 +22,9 @@ rust-embed = { workspace = true }
mime_guess = { workspace = true } mime_guess = { workspace = true }
homedir = { workspace = true } homedir = { workspace = true }
serde_yaml = "0.9" serde_yaml = "0.9"
toml = "0.8"
async-stream = "0.3"
bytes = "1"
portable-pty = { workspace = true } portable-pty = { workspace = true }
strip-ansi-escapes = { workspace = true } strip-ansi-escapes = { workspace = true }

View File

@@ -1,167 +1,305 @@
use crate::config::ProjectConfig;
use crate::worktree::{self, WorktreeInfo};
use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use serde::{Deserialize, Serialize}; use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::sync::Mutex; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
/// Manages multiple concurrent Claude Code agent sessions. /// Events streamed from a running agent to SSE clients.
/// #[derive(Debug, Clone, Serialize)]
/// Each agent is identified by a string name (e.g., "coder-1", "coder-2"). #[serde(tag = "type", rename_all = "snake_case")]
/// Agents run `claude -p` in a PTY for Max subscription billing. pub enum AgentEvent {
/// Sessions can be resumed for multi-turn conversations. /// Agent status changed.
pub struct AgentPool { Status { story_id: String, status: String },
agents: Mutex<HashMap<String, AgentState>>, /// Raw text output from the agent process.
Output { story_id: String, text: String },
/// Agent produced a JSON event from `--output-format stream-json`.
AgentJson { story_id: String, data: serde_json::Value },
/// Agent finished.
Done {
story_id: String,
session_id: Option<String>,
},
/// Agent errored.
Error { story_id: String, message: String },
} }
#[derive(Clone, Serialize)] #[derive(Debug, Clone, Serialize, PartialEq)]
pub struct AgentInfo {
pub name: String,
pub role: String,
pub cwd: String,
pub session_id: Option<String>,
pub status: AgentStatus,
pub message_count: usize,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AgentStatus { pub enum AgentStatus {
Idle, Pending,
Running, Running,
Completed,
Failed,
} }
struct AgentState { impl std::fmt::Display for AgentStatus {
role: String, fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
cwd: String, match self {
session_id: Option<String>, Self::Pending => write!(f, "pending"),
message_count: usize, Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
}
}
} }
#[derive(Deserialize)] #[derive(Serialize, Clone)]
pub struct CreateAgentRequest { pub struct AgentInfo {
pub name: String, pub story_id: String,
pub role: String, pub status: AgentStatus,
pub cwd: String,
}
#[derive(Deserialize)]
pub struct SendMessageRequest {
pub message: String,
}
#[derive(Serialize)]
pub struct AgentResponse {
pub agent: String,
pub text: String,
pub session_id: Option<String>, pub session_id: Option<String>,
pub model: Option<String>, pub worktree_path: Option<String>,
pub api_key_source: Option<String>, }
pub rate_limit_type: Option<String>,
pub cost_usd: Option<f64>, struct StoryAgent {
pub input_tokens: Option<u64>, status: AgentStatus,
pub output_tokens: Option<u64>, worktree_info: Option<WorktreeInfo>,
pub duration_ms: Option<u64>, config: ProjectConfig,
session_id: Option<String>,
tx: broadcast::Sender<AgentEvent>,
task_handle: Option<tokio::task::JoinHandle<()>>,
}
/// Manages concurrent story agents, each in its own worktree.
pub struct AgentPool {
agents: Arc<Mutex<HashMap<String, StoryAgent>>>,
} }
impl AgentPool { impl AgentPool {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
agents: Mutex::new(HashMap::new()), agents: Arc::new(Mutex::new(HashMap::new())),
} }
} }
pub fn create_agent(&self, req: CreateAgentRequest) -> Result<AgentInfo, String> { /// Start an agent for a story: load config, create worktree, spawn agent.
pub async fn start_agent(
&self,
project_root: &Path,
story_id: &str,
) -> Result<AgentInfo, String> {
// Check not already running
{
let agents = self.agents.lock().map_err(|e| e.to_string())?;
if let Some(agent) = agents.get(story_id)
&& (agent.status == AgentStatus::Running || agent.status == AgentStatus::Pending) {
return Err(format!(
"Agent for story '{story_id}' is already {}",
agent.status
));
}
}
let config = ProjectConfig::load(project_root)?;
let (tx, _) = broadcast::channel::<AgentEvent>(256);
// Register as pending
{
let mut agents = self.agents.lock().map_err(|e| e.to_string())?; let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
agents.insert(
if agents.contains_key(&req.name) { story_id.to_string(),
return Err(format!("Agent '{}' already exists", req.name)); StoryAgent {
status: AgentStatus::Pending,
worktree_info: None,
config: config.clone(),
session_id: None,
tx: tx.clone(),
task_handle: None,
},
);
} }
let state = AgentState { let _ = tx.send(AgentEvent::Status {
role: req.role.clone(), story_id: story_id.to_string(),
cwd: req.cwd.clone(), status: "pending".to_string(),
session_id: None, });
message_count: 0,
};
let info = AgentInfo { // Create worktree
name: req.name.clone(), let wt_info = worktree::create_worktree(project_root, story_id, &config).await?;
role: req.role,
cwd: req.cwd,
session_id: None,
status: AgentStatus::Idle,
message_count: 0,
};
agents.insert(req.name, state); // Update with worktree info
Ok(info) {
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
if let Some(agent) = agents.get_mut(story_id) {
agent.worktree_info = Some(wt_info.clone());
}
} }
// Spawn the agent process
let wt_path_str = wt_info.path.to_string_lossy().to_string();
let rendered = config.render_agent_args(&wt_path_str, story_id);
let (command, args, prompt) = rendered.ok_or_else(|| {
"No [agent] section in config — cannot spawn agent".to_string()
})?;
let sid = story_id.to_string();
let tx_clone = tx.clone();
let agents_ref = self.agents.clone();
let cwd = wt_path_str.clone();
let handle = tokio::spawn(async move {
let _ = tx_clone.send(AgentEvent::Status {
story_id: sid.clone(),
status: "running".to_string(),
});
match run_agent_pty_streaming(&sid, &command, &args, &prompt, &cwd, &tx_clone).await {
Ok(session_id) => {
// Mark completed in the pool
if let Ok(mut agents) = agents_ref.lock()
&& let Some(agent) = agents.get_mut(&sid) {
agent.status = AgentStatus::Completed;
agent.session_id = session_id.clone();
}
let _ = tx_clone.send(AgentEvent::Done {
story_id: sid.clone(),
session_id,
});
}
Err(e) => {
// Mark failed in the pool
if let Ok(mut agents) = agents_ref.lock()
&& let Some(agent) = agents.get_mut(&sid) {
agent.status = AgentStatus::Failed;
}
let _ = tx_clone.send(AgentEvent::Error {
story_id: sid.clone(),
message: e,
});
}
}
});
// Update status to running with task handle
{
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
if let Some(agent) = agents.get_mut(story_id) {
agent.status = AgentStatus::Running;
agent.task_handle = Some(handle);
}
}
Ok(AgentInfo {
story_id: story_id.to_string(),
status: AgentStatus::Running,
session_id: None,
worktree_path: Some(wt_path_str),
})
}
/// Stop a running agent and clean up its worktree.
pub async fn stop_agent(&self, project_root: &Path, story_id: &str) -> Result<(), String> {
let (worktree_info, config, task_handle, tx) = {
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
let agent = agents
.get_mut(story_id)
.ok_or_else(|| format!("No agent for story '{story_id}'"))?;
let wt = agent.worktree_info.clone();
let cfg = agent.config.clone();
let handle = agent.task_handle.take();
let tx = agent.tx.clone();
agent.status = AgentStatus::Failed;
(wt, cfg, handle, tx)
};
// Abort the task
if let Some(handle) = task_handle {
handle.abort();
let _ = handle.await;
}
// Remove worktree
if let Some(ref wt) = worktree_info
&& let Err(e) = worktree::remove_worktree(project_root, wt, &config).await {
eprintln!("[agents] Worktree cleanup warning for {story_id}: {e}");
}
let _ = tx.send(AgentEvent::Status {
story_id: story_id.to_string(),
status: "stopped".to_string(),
});
// Remove from map
{
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
agents.remove(story_id);
}
Ok(())
}
/// List all agents with their status.
pub fn list_agents(&self) -> Result<Vec<AgentInfo>, String> { pub fn list_agents(&self) -> Result<Vec<AgentInfo>, String> {
let agents = self.agents.lock().map_err(|e| e.to_string())?; let agents = self.agents.lock().map_err(|e| e.to_string())?;
Ok(agents Ok(agents
.iter() .iter()
.map(|(name, state)| AgentInfo { .map(|(story_id, agent)| AgentInfo {
name: name.clone(), story_id: story_id.clone(),
role: state.role.clone(), status: agent.status.clone(),
cwd: state.cwd.clone(), session_id: agent.session_id.clone(),
session_id: state.session_id.clone(), worktree_path: agent
status: AgentStatus::Idle, .worktree_info
message_count: state.message_count, .as_ref()
.map(|wt| wt.path.to_string_lossy().to_string()),
}) })
.collect()) .collect())
} }
/// Send a message to an agent and wait for the complete response. /// Subscribe to events for a story agent.
/// This spawns a `claude -p` process in a PTY, optionally resuming pub fn subscribe(&self, story_id: &str) -> Result<broadcast::Receiver<AgentEvent>, String> {
/// a previous session for multi-turn conversations.
pub async fn send_message(
&self,
agent_name: &str,
message: &str,
) -> Result<AgentResponse, String> {
let (cwd, role, session_id) = {
let agents = self.agents.lock().map_err(|e| e.to_string())?; let agents = self.agents.lock().map_err(|e| e.to_string())?;
let state = agents let agent = agents
.get(agent_name) .get(story_id)
.ok_or_else(|| format!("Agent '{}' not found", agent_name))?; .ok_or_else(|| format!("No agent for story '{story_id}'"))?;
( Ok(agent.tx.subscribe())
state.cwd.clone(),
state.role.clone(),
state.session_id.clone(),
)
};
let agent = agent_name.to_string();
let msg = message.to_string();
let role_clone = role.clone();
let result = tokio::task::spawn_blocking(move || {
run_agent_pty(&agent, &msg, &cwd, &role_clone, session_id.as_deref())
})
.await
.map_err(|e| format!("Agent task panicked: {e}"))??;
// Update session_id for next message
if let Some(ref sid) = result.session_id {
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
if let Some(state) = agents.get_mut(agent_name) {
state.session_id = Some(sid.clone());
state.message_count += 1;
}
} }
Ok(result) /// Get project root helper.
pub fn get_project_root(
&self,
state: &crate::state::SessionState,
) -> Result<PathBuf, String> {
state.get_project_root()
} }
} }
fn run_agent_pty( /// Spawn claude agent in a PTY and stream events through the broadcast channel.
agent_name: &str, async fn run_agent_pty_streaming(
message: &str, story_id: &str,
command: &str,
args: &[String],
prompt: &str,
cwd: &str, cwd: &str,
role: &str, tx: &broadcast::Sender<AgentEvent>,
resume_session: Option<&str>, ) -> Result<Option<String>, String> {
) -> Result<AgentResponse, String> { let sid = story_id.to_string();
let cmd = command.to_string();
let args = args.to_vec();
let prompt = prompt.to_string();
let cwd = cwd.to_string();
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
run_agent_pty_blocking(&sid, &cmd, &args, &prompt, &cwd, &tx)
})
.await
.map_err(|e| format!("Agent task panicked: {e}"))?
}
fn run_agent_pty_blocking(
story_id: &str,
command: &str,
args: &[String],
prompt: &str,
cwd: &str,
tx: &broadcast::Sender<AgentEvent>,
) -> Result<Option<String>, String> {
let pty_system = native_pty_system(); let pty_system = native_pty_system();
let pair = pty_system let pair = pty_system
@@ -173,9 +311,17 @@ fn run_agent_pty(
}) })
.map_err(|e| format!("Failed to open PTY: {e}"))?; .map_err(|e| format!("Failed to open PTY: {e}"))?;
let mut cmd = CommandBuilder::new("claude"); let mut cmd = CommandBuilder::new(command);
// -p <prompt> must come first
cmd.arg("-p"); cmd.arg("-p");
cmd.arg(message); cmd.arg(prompt);
// Add configured args (e.g., --directory /path/to/worktree)
for arg in args {
cmd.arg(arg);
}
cmd.arg("--output-format"); cmd.arg("--output-format");
cmd.arg("stream-json"); cmd.arg("stream-json");
cmd.arg("--verbose"); cmd.arg("--verbose");
@@ -184,32 +330,15 @@ fn run_agent_pty(
cmd.arg("--permission-mode"); cmd.arg("--permission-mode");
cmd.arg("bypassPermissions"); cmd.arg("bypassPermissions");
// Append role as system prompt context
cmd.arg("--append-system-prompt");
cmd.arg(format!(
"You are agent '{}' with role: {}. Work autonomously on the task given.",
agent_name, role
));
// Resume previous session if available
if let Some(session_id) = resume_session {
cmd.arg("--resume");
cmd.arg(session_id);
}
cmd.cwd(cwd); cmd.cwd(cwd);
cmd.env("NO_COLOR", "1"); cmd.env("NO_COLOR", "1");
eprintln!( eprintln!("[agent:{story_id}] Spawning {command} in {cwd} with args: {args:?}");
"[agent:{}] Spawning claude -p (session: {:?})",
agent_name,
resume_session.unwrap_or("new")
);
let mut child = pair let mut child = pair
.slave .slave
.spawn_command(cmd) .spawn_command(cmd)
.map_err(|e| format!("Failed to spawn claude for agent {agent_name}: {e}"))?; .map_err(|e| format!("Failed to spawn agent for {story_id}: {e}"))?;
drop(pair.slave); drop(pair.slave);
@@ -221,18 +350,7 @@ fn run_agent_pty(
drop(pair.master); drop(pair.master);
let buf_reader = BufReader::new(reader); let buf_reader = BufReader::new(reader);
let mut response = AgentResponse { let mut session_id: Option<String> = None;
agent: agent_name.to_string(),
text: String::new(),
session_id: None,
model: None,
api_key_source: None,
rate_limit_type: None,
cost_usd: None,
input_tokens: None,
output_tokens: None,
duration_ms: None,
};
for line in buf_reader.lines() { for line in buf_reader.lines() {
let line = match line { let line = match line {
@@ -245,67 +363,57 @@ fn run_agent_pty(
continue; continue;
} }
// Try to parse as JSON
let json: serde_json::Value = match serde_json::from_str(trimmed) { let json: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(j) => j, Ok(j) => j,
Err(_) => continue, // skip non-JSON (terminal escapes) Err(_) => {
// Non-JSON output (terminal escapes etc.) — send as raw output
let _ = tx.send(AgentEvent::Output {
story_id: story_id.to_string(),
text: trimmed.to_string(),
});
continue;
}
}; };
let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or(""); let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or("");
match event_type { match event_type {
"system" => { "system" => {
response.session_id = json session_id = json
.get("session_id") .get("session_id")
.and_then(|s| s.as_str()) .and_then(|s| s.as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
response.model = json
.get("model")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
response.api_key_source = json
.get("apiKeySource")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
"rate_limit_event" => {
if let Some(info) = json.get("rate_limit_info") {
response.rate_limit_type = info
.get("rateLimitType")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
} }
"assistant" => { "assistant" => {
if let Some(message) = json.get("message") { if let Some(message) = json.get("message")
if let Some(content) = message.get("content").and_then(|c| c.as_array()) { && let Some(content) = message.get("content").and_then(|c| c.as_array()) {
for block in content { for block in content {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) { if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
response.text.push_str(text); let _ = tx.send(AgentEvent::Output {
story_id: story_id.to_string(),
text: text.to_string(),
});
} }
} }
} }
} }
}
"result" => {
response.cost_usd = json.get("total_cost_usd").and_then(|c| c.as_f64());
response.duration_ms = json.get("duration_ms").and_then(|d| d.as_u64());
if let Some(usage) = json.get("usage") {
response.input_tokens =
usage.get("input_tokens").and_then(|t| t.as_u64());
response.output_tokens =
usage.get("output_tokens").and_then(|t| t.as_u64());
}
}
_ => {} _ => {}
} }
// Forward all JSON events
let _ = tx.send(AgentEvent::AgentJson {
story_id: story_id.to_string(),
data: json,
});
} }
let _ = child.kill(); let _ = child.kill();
eprintln!( eprintln!(
"[agent:{}] Done. Session: {:?}, tokens: {:?}/{:?}", "[agent:{story_id}] Done. Session: {:?}",
agent_name, response.session_id, response.input_tokens, response.output_tokens session_id
); );
Ok(response) Ok(session_id)
} }

145
server/src/config.rs Normal file
View 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"));
}
}

View File

@@ -9,39 +9,16 @@ enum AgentsTags {
} }
#[derive(Object)] #[derive(Object)]
struct CreateAgentPayload { struct StoryIdPayload {
name: String, story_id: String,
role: String,
cwd: String,
}
#[derive(Object)]
struct SendMessagePayload {
message: String,
} }
#[derive(Object, Serialize)] #[derive(Object, Serialize)]
struct AgentInfoResponse { struct AgentInfoResponse {
name: String, story_id: String,
role: String,
cwd: String,
session_id: Option<String>,
status: String, status: String,
message_count: usize,
}
#[derive(Object, Serialize)]
struct AgentMessageResponse {
agent: String,
text: String,
session_id: Option<String>, session_id: Option<String>,
model: Option<String>, worktree_path: Option<String>,
api_key_source: Option<String>,
rate_limit_type: Option<String>,
cost_usd: Option<f64>,
input_tokens: Option<u64>,
output_tokens: Option<u64>,
duration_ms: Option<u64>,
} }
pub struct AgentsApi { pub struct AgentsApi {
@@ -50,31 +27,52 @@ pub struct AgentsApi {
#[OpenApi(tag = "AgentsTags::Agents")] #[OpenApi(tag = "AgentsTags::Agents")]
impl AgentsApi { impl AgentsApi {
/// Create a new agent with a name, role, and working directory. /// Start an agent for a given story (creates worktree, runs setup, spawns agent).
#[oai(path = "/agents", method = "post")] #[oai(path = "/agents/start", method = "post")]
async fn create_agent( async fn start_agent(
&self, &self,
payload: Json<CreateAgentPayload>, payload: Json<StoryIdPayload>,
) -> OpenApiResult<Json<AgentInfoResponse>> { ) -> OpenApiResult<Json<AgentInfoResponse>> {
let req = crate::agents::CreateAgentRequest { let project_root = self
name: payload.0.name, .ctx
role: payload.0.role, .agents
cwd: payload.0.cwd, .get_project_root(&self.ctx.state)
}; .map_err(bad_request)?;
let info = self.ctx.agents.create_agent(req).map_err(bad_request)?; let info = self
.ctx
.agents
.start_agent(&project_root, &payload.0.story_id)
.await
.map_err(bad_request)?;
Ok(Json(AgentInfoResponse { Ok(Json(AgentInfoResponse {
name: info.name, story_id: info.story_id,
role: info.role, status: info.status.to_string(),
cwd: info.cwd,
session_id: info.session_id, session_id: info.session_id,
status: "idle".to_string(), worktree_path: info.worktree_path,
message_count: info.message_count,
})) }))
} }
/// List all registered agents. /// Stop a running agent and clean up its worktree.
#[oai(path = "/agents/stop", method = "post")]
async fn stop_agent(&self, payload: Json<StoryIdPayload>) -> OpenApiResult<Json<bool>> {
let project_root = self
.ctx
.agents
.get_project_root(&self.ctx.state)
.map_err(bad_request)?;
self.ctx
.agents
.stop_agent(&project_root, &payload.0.story_id)
.await
.map_err(bad_request)?;
Ok(Json(true))
}
/// List all agents with their status.
#[oai(path = "/agents", method = "get")] #[oai(path = "/agents", method = "get")]
async fn list_agents(&self) -> OpenApiResult<Json<Vec<AgentInfoResponse>>> { async fn list_agents(&self) -> OpenApiResult<Json<Vec<AgentInfoResponse>>> {
let agents = self.ctx.agents.list_agents().map_err(bad_request)?; let agents = self.ctx.agents.list_agents().map_err(bad_request)?;
@@ -83,45 +81,12 @@ impl AgentsApi {
agents agents
.into_iter() .into_iter()
.map(|info| AgentInfoResponse { .map(|info| AgentInfoResponse {
name: info.name, story_id: info.story_id,
role: info.role, status: info.status.to_string(),
cwd: info.cwd,
session_id: info.session_id, session_id: info.session_id,
status: match info.status { worktree_path: info.worktree_path,
crate::agents::AgentStatus::Idle => "idle".to_string(),
crate::agents::AgentStatus::Running => "running".to_string(),
},
message_count: info.message_count,
}) })
.collect(), .collect(),
)) ))
} }
/// Send a message to an agent and wait for its response.
#[oai(path = "/agents/:name/message", method = "post")]
async fn send_message(
&self,
name: poem_openapi::param::Path<String>,
payload: Json<SendMessagePayload>,
) -> OpenApiResult<Json<AgentMessageResponse>> {
let result = self
.ctx
.agents
.send_message(&name.0, &payload.0.message)
.await
.map_err(bad_request)?;
Ok(Json(AgentMessageResponse {
agent: result.agent,
text: result.text,
session_id: result.session_id,
model: result.model,
api_key_source: result.api_key_source,
rate_limit_type: result.rate_limit_type,
cost_usd: result.cost_usd,
input_tokens: result.input_tokens,
output_tokens: result.output_tokens,
duration_ms: result.duration_ms,
}))
}
} }

View File

@@ -0,0 +1,58 @@
use crate::http::context::AppContext;
use poem::handler;
use poem::http::StatusCode;
use poem::web::{Data, Path};
use poem::{Body, IntoResponse, Response};
use std::sync::Arc;
/// SSE endpoint: `GET /agents/:story_id/stream`
///
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
/// with `data:` prefix and double newline terminator per the SSE spec.
#[handler]
pub async fn agent_stream(
Path(story_id): Path<String>,
ctx: Data<&Arc<AppContext>>,
) -> impl IntoResponse {
let mut rx = match ctx.agents.subscribe(&story_id) {
Ok(rx) => rx,
Err(e) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from_string(e));
}
};
let stream = async_stream::stream! {
loop {
match rx.recv().await {
Ok(event) => {
if let Ok(json) = serde_json::to_string(&event) {
yield Ok::<_, std::io::Error>(format!("data: {json}\n\n"));
}
// Check for terminal events
match &event {
crate::agents::AgentEvent::Done { .. }
| crate::agents::AgentEvent::Error { .. } => break,
crate::agents::AgentEvent::Status { status, .. }
if status == "stopped" => break,
_ => {}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
let msg = format!("{{\"type\":\"warning\",\"message\":\"Skipped {n} events\"}}");
yield Ok::<_, std::io::Error>(format!("data: {msg}\n\n"));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
};
Response::builder()
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(Body::from_bytes_stream(
futures::StreamExt::map(stream, |r| r.map(bytes::Bytes::from)),
))
}

View File

@@ -1,4 +1,5 @@
pub mod agents; pub mod agents;
pub mod agents_sse;
pub mod anthropic; pub mod anthropic;
pub mod assets; pub mod assets;
pub mod chat; pub mod chat;
@@ -33,6 +34,10 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
.nest("/api", api_service) .nest("/api", api_service)
.nest("/docs", docs_service.swagger_ui()) .nest("/docs", docs_service.swagger_ui())
.at("/ws", get(ws::ws_handler)) .at("/ws", get(ws::ws_handler))
.at(
"/agents/:story_id/stream",
get(agents_sse::agent_stream),
)
.at("/health", get(health::health)) .at("/health", get(health::health))
.at("/assets/*path", get(assets::embedded_asset)) .at("/assets/*path", get(assets::embedded_asset))
.at("/", get(assets::embedded_index)) .at("/", get(assets::embedded_index))

View File

@@ -158,8 +158,8 @@ fn run_pty_session(
eprintln!("[pty-debug] processing: {}...", &trimmed[..trimmed.len().min(120)]); eprintln!("[pty-debug] processing: {}...", &trimmed[..trimmed.len().min(120)]);
// Try to parse as JSON // Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) { if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
if let Some(event_type) = json.get("type").and_then(|t| t.as_str()) { && let Some(event_type) = json.get("type").and_then(|t| t.as_str()) {
match event_type { match event_type {
// Streaming deltas (when --include-partial-messages is used) // Streaming deltas (when --include-partial-messages is used)
"stream_event" => { "stream_event" => {
@@ -169,8 +169,8 @@ fn run_pty_session(
} }
// Complete assistant message // Complete assistant message
"assistant" => { "assistant" => {
if let Some(message) = json.get("message") { if let Some(message) = json.get("message")
if let Some(content) = message.get("content").and_then(|c| c.as_array()) { && let Some(content) = message.get("content").and_then(|c| c.as_array()) {
for block in content { for block in content {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) { if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
let _ = token_tx.send(text.to_string()); let _ = token_tx.send(text.to_string());
@@ -178,7 +178,6 @@ fn run_pty_session(
} }
} }
} }
}
// Final result with usage stats // Final result with usage stats
"result" => { "result" => {
if let Some(cost) = json.get("total_cost_usd").and_then(|c| c.as_f64()) { if let Some(cost) = json.get("total_cost_usd").and_then(|c| c.as_f64()) {
@@ -209,7 +208,6 @@ fn run_pty_session(
_ => {} _ => {}
} }
} }
}
// Ignore non-JSON lines (terminal escape sequences) // Ignore non-JSON lines (terminal escape sequences)
if got_result { if got_result {
@@ -223,8 +221,8 @@ fn run_pty_session(
// Drain remaining lines // Drain remaining lines
while let Ok(Some(line)) = line_rx.try_recv() { while let Ok(Some(line)) = line_rx.try_recv() {
let trimmed = line.trim(); let trimmed = line.trim();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) { if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
if let Some(event) = json && let Some(event) = json
.get("type") .get("type")
.filter(|t| t.as_str() == Some("stream_event")) .filter(|t| t.as_str() == Some("stream_event"))
.and_then(|_| json.get("event")) .and_then(|_| json.get("event"))
@@ -232,7 +230,6 @@ fn run_pty_session(
handle_stream_event(event, &token_tx); handle_stream_event(event, &token_tx);
} }
} }
}
break; break;
} }
} }

View File

@@ -1,10 +1,12 @@
mod agents; mod agents;
mod config;
mod http; mod http;
mod io; mod io;
mod llm; mod llm;
mod state; mod state;
mod store; mod store;
mod workflow; mod workflow;
mod worktree;
use crate::agents::AgentPool; use crate::agents::AgentPool;
use crate::http::build_routes; use crate::http::build_routes;

197
server/src/worktree.rs Normal file
View File

@@ -0,0 +1,197 @@
use crate::config::ProjectConfig;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: String,
}
/// Worktree path as a sibling of the project root: `{project_root}-story-{id}`.
/// E.g. `/path/to/story-kit-app` → `/path/to/story-kit-app-story-42_foo`.
fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf {
let parent = project_root.parent().unwrap_or(project_root);
let dir_name = project_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string());
parent.join(format!("{dir_name}-story-{story_id}"))
}
fn branch_name(story_id: &str) -> String {
format!("feature/story-{story_id}")
}
/// Create a git worktree for the given story.
///
/// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory)
/// on branch `feature/story-{story_id}`.
/// - Runs setup commands from the config for each component.
/// - If the worktree/branch already exists, reuses rather than errors.
pub async fn create_worktree(
project_root: &Path,
story_id: &str,
config: &ProjectConfig,
) -> Result<WorktreeInfo, String> {
let wt_path = worktree_path(project_root, story_id);
let branch = branch_name(story_id);
let root = project_root.to_path_buf();
// Already exists — reuse
if wt_path.exists() {
run_setup_commands(&wt_path, config).await?;
return Ok(WorktreeInfo {
path: wt_path,
branch,
});
}
let wt = wt_path.clone();
let br = branch.clone();
tokio::task::spawn_blocking(move || create_worktree_sync(&root, &wt, &br))
.await
.map_err(|e| format!("spawn_blocking: {e}"))??;
run_setup_commands(&wt_path, config).await?;
Ok(WorktreeInfo {
path: wt_path,
branch,
})
}
fn create_worktree_sync(
project_root: &Path,
wt_path: &Path,
branch: &str,
) -> Result<(), String> {
// Ensure the parent directory exists
if let Some(parent) = wt_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Create worktree dir: {e}"))?;
}
// Try to create branch. If it already exists that's fine.
let _ = Command::new("git")
.args(["branch", branch])
.current_dir(project_root)
.output();
// Create worktree
let output = Command::new("git")
.args([
"worktree",
"add",
&wt_path.to_string_lossy(),
branch,
])
.current_dir(project_root)
.output()
.map_err(|e| format!("git worktree add: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// If it says already checked out, that's fine
if stderr.contains("already checked out") || stderr.contains("already exists") {
return Ok(());
}
return Err(format!("git worktree add failed: {stderr}"));
}
Ok(())
}
/// Remove a git worktree and its branch.
pub async fn remove_worktree(
project_root: &Path,
info: &WorktreeInfo,
config: &ProjectConfig,
) -> Result<(), String> {
run_teardown_commands(&info.path, config).await?;
let root = project_root.to_path_buf();
let wt_path = info.path.clone();
let branch = info.branch.clone();
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
.await
.map_err(|e| format!("spawn_blocking: {e}"))?
}
fn remove_worktree_sync(
project_root: &Path,
wt_path: &Path,
branch: &str,
) -> Result<(), String> {
// Remove worktree
let output = Command::new("git")
.args([
"worktree",
"remove",
"--force",
&wt_path.to_string_lossy(),
])
.current_dir(project_root)
.output()
.map_err(|e| format!("git worktree remove: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("[worktree] remove warning: {stderr}");
}
// Delete branch (best effort)
let _ = Command::new("git")
.args(["branch", "-d", branch])
.current_dir(project_root)
.output();
Ok(())
}
async fn run_setup_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> {
for component in &config.component {
let cmd_dir = wt_path.join(&component.path);
for cmd in &component.setup {
run_shell_command(cmd, &cmd_dir).await?;
}
}
Ok(())
}
async fn run_teardown_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> {
for component in &config.component {
let cmd_dir = wt_path.join(&component.path);
for cmd in &component.teardown {
// Best effort — don't fail teardown
if let Err(e) = run_shell_command(cmd, &cmd_dir).await {
eprintln!("[worktree] teardown warning for {}: {e}", component.name);
}
}
}
Ok(())
}
async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), String> {
let cmd = cmd.to_string();
let cwd = cwd.to_path_buf();
tokio::task::spawn_blocking(move || {
eprintln!("[worktree] Running: {cmd} in {}", cwd.display());
let output = Command::new("sh")
.args(["-c", &cmd])
.current_dir(&cwd)
.output()
.map_err(|e| format!("Run '{cmd}': {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Command '{cmd}' failed: {stderr}"));
}
Ok(())
})
.await
.map_err(|e| format!("spawn_blocking: {e}"))?
}