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:
16
.story_kit/project.toml
Normal file
16
.story_kit/project.toml
Normal 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
79
Cargo.lock
generated
@@ -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
115
frontend/src/api/agents.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
470
frontend/src/components/AgentPanel.tsx
Normal file
470
frontend/src/components/AgentPanel.tsx
Normal 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
58
server/src/http/agents_sse.rs
Normal file
58
server/src/http/agents_sse.rs
Normal 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)),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
197
server/src/worktree.rs
Normal 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}"))?
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user