Accept story 34: Per-Project Agent Configuration and Role Definitions
Replace single [agent] config with multi-agent [[agent]] roster system. Each agent has name, role, model, allowed_tools, max_turns, max_budget_usd, and system_prompt fields that map to Claude CLI flags at spawn time. - AgentConfig expanded with structured fields, validated at startup (panics on duplicate names, empty names, non-positive budgets/turns) - Backwards-compatible: legacy [agent] format auto-wraps with deprecation warning - AgentPool uses composite "story_id:agent_name" keys for concurrent agents - agent_name added to AgentEvent variants, AgentInfo, start/stop/subscribe APIs - GET /agents/config returns roster, POST /agents/config/reload hot-reloads - POST /agents/start accepts optional agent_name, /agents/stop requires it - SSE route updated to /agents/:story_id/:agent_name/stream - Frontend: roster badges, agent selector dropdown, composite-key state - Project root initialized to cwd at startup so config endpoints work immediately Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,31 @@ path = "."
|
|||||||
setup = ["cargo check"]
|
setup = ["cargo check"]
|
||||||
teardown = []
|
teardown = []
|
||||||
|
|
||||||
[agent]
|
[[agent]]
|
||||||
command = "claude"
|
name = "supervisor"
|
||||||
args = []
|
role = "Coordinates work, reviews PRs, decomposes stories."
|
||||||
prompt = "Read .story_kit/README.md, then pick up story {{story_id}}"
|
model = "opus"
|
||||||
|
max_turns = 50
|
||||||
|
max_budget_usd = 10.00
|
||||||
|
system_prompt = "You are a senior engineering lead. Coordinate the work, review code, and ensure quality."
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-1"
|
||||||
|
role = "Full-stack engineer. Implements features across all components."
|
||||||
|
model = "sonnet"
|
||||||
|
max_turns = 30
|
||||||
|
max_budget_usd = 5.00
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-2"
|
||||||
|
role = "Full-stack engineer. Implements features across all components."
|
||||||
|
model = "sonnet"
|
||||||
|
max_turns = 30
|
||||||
|
max_budget_usd = 5.00
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "reviewer"
|
||||||
|
role = "Reviews code changes, runs tests, checks quality gates."
|
||||||
|
model = "sonnet"
|
||||||
|
max_turns = 20
|
||||||
|
max_budget_usd = 3.00
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AgentStatusValue = "pending" | "running" | "completed" | "failed";
|
|||||||
|
|
||||||
export interface AgentInfo {
|
export interface AgentInfo {
|
||||||
story_id: string;
|
story_id: string;
|
||||||
|
agent_name: string;
|
||||||
status: AgentStatusValue;
|
status: AgentStatusValue;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
worktree_path: string | null;
|
worktree_path: string | null;
|
||||||
@@ -10,6 +11,7 @@ export interface AgentInfo {
|
|||||||
export interface AgentEvent {
|
export interface AgentEvent {
|
||||||
type: "status" | "output" | "agent_json" | "done" | "error" | "warning";
|
type: "status" | "output" | "agent_json" | "done" | "error" | "warning";
|
||||||
story_id?: string;
|
story_id?: string;
|
||||||
|
agent_name?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
@@ -17,6 +19,15 @@ export interface AgentEvent {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentConfigInfo {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
model: string | null;
|
||||||
|
allowed_tools: string[] | null;
|
||||||
|
max_turns: number | null;
|
||||||
|
max_budget_usd: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
const DEFAULT_API_BASE = "/api";
|
||||||
|
|
||||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
@@ -45,23 +56,29 @@ async function requestJson<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const agentsApi = {
|
export const agentsApi = {
|
||||||
startAgent(storyId: string, baseUrl?: string) {
|
startAgent(storyId: string, agentName?: string, baseUrl?: string) {
|
||||||
return requestJson<AgentInfo>(
|
return requestJson<AgentInfo>(
|
||||||
"/agents/start",
|
"/agents/start",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ story_id: storyId }),
|
body: JSON.stringify({
|
||||||
|
story_id: storyId,
|
||||||
|
agent_name: agentName,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
stopAgent(storyId: string, baseUrl?: string) {
|
stopAgent(storyId: string, agentName: string, baseUrl?: string) {
|
||||||
return requestJson<boolean>(
|
return requestJson<boolean>(
|
||||||
"/agents/stop",
|
"/agents/stop",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ story_id: storyId }),
|
body: JSON.stringify({
|
||||||
|
story_id: storyId,
|
||||||
|
agent_name: agentName,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
@@ -70,6 +87,18 @@ export const agentsApi = {
|
|||||||
listAgents(baseUrl?: string) {
|
listAgents(baseUrl?: string) {
|
||||||
return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
|
return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getAgentConfig(baseUrl?: string) {
|
||||||
|
return requestJson<AgentConfigInfo[]>("/agents/config", {}, baseUrl);
|
||||||
|
},
|
||||||
|
|
||||||
|
reloadConfig(baseUrl?: string) {
|
||||||
|
return requestJson<AgentConfigInfo[]>(
|
||||||
|
"/agents/config/reload",
|
||||||
|
{ method: "POST" },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,11 +107,12 @@ export const agentsApi = {
|
|||||||
*/
|
*/
|
||||||
export function subscribeAgentStream(
|
export function subscribeAgentStream(
|
||||||
storyId: string,
|
storyId: string,
|
||||||
|
agentName: string,
|
||||||
onEvent: (event: AgentEvent) => void,
|
onEvent: (event: AgentEvent) => void,
|
||||||
onError?: (error: Event) => void,
|
onError?: (error: Event) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const host = import.meta.env.DEV ? "http://127.0.0.1:3001" : "";
|
const host = import.meta.env.DEV ? "http://127.0.0.1:3001" : "";
|
||||||
const url = `${host}/agents/${encodeURIComponent(storyId)}/stream`;
|
const url = `${host}/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/stream`;
|
||||||
|
|
||||||
const eventSource = new EventSource(url);
|
const eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
|
import type {
|
||||||
|
AgentConfigInfo,
|
||||||
|
AgentEvent,
|
||||||
|
AgentInfo,
|
||||||
|
AgentStatusValue,
|
||||||
|
} from "../api/agents";
|
||||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||||
import type { UpcomingStory } from "../api/workflow";
|
import type { UpcomingStory } from "../api/workflow";
|
||||||
|
|
||||||
@@ -10,6 +15,7 @@ interface AgentPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AgentState {
|
interface AgentState {
|
||||||
|
agentName: string;
|
||||||
status: AgentStatusValue;
|
status: AgentStatusValue;
|
||||||
log: string[];
|
log: string[];
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -71,30 +77,65 @@ function StatusBadge({ status }: { status: AgentStatusValue }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RosterBadge({ agent }: { agent: AgentConfigInfo }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "0.7em",
|
||||||
|
background: "#ffffff08",
|
||||||
|
color: "#888",
|
||||||
|
border: "1px solid #333",
|
||||||
|
}}
|
||||||
|
title={agent.role || agent.name}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, color: "#aaa" }}>{agent.name}</span>
|
||||||
|
{agent.model && <span style={{ color: "#666" }}>{agent.model}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a composite key for tracking agent state. */
|
||||||
|
function agentKey(storyId: string, agentName: string): string {
|
||||||
|
return `${storyId}:${agentName}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentPanel({ stories }: AgentPanelProps) {
|
export function AgentPanel({ stories }: AgentPanelProps) {
|
||||||
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
||||||
const [expandedStory, setExpandedStory] = useState<string | null>(null);
|
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
||||||
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
|
const [selectorStory, setSelectorStory] = useState<string | null>(null);
|
||||||
const cleanupRefs = useRef<Record<string, () => void>>({});
|
const cleanupRefs = useRef<Record<string, () => void>>({});
|
||||||
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
// Load existing agents on mount
|
// Load roster and existing agents on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
agentsApi
|
||||||
|
.getAgentConfig()
|
||||||
|
.then(setRoster)
|
||||||
|
.catch((err) => console.error("Failed to load agent config:", err));
|
||||||
|
|
||||||
agentsApi
|
agentsApi
|
||||||
.listAgents()
|
.listAgents()
|
||||||
.then((agentList) => {
|
.then((agentList) => {
|
||||||
const agentMap: Record<string, AgentState> = {};
|
const agentMap: Record<string, AgentState> = {};
|
||||||
for (const a of agentList) {
|
for (const a of agentList) {
|
||||||
agentMap[a.story_id] = {
|
const key = agentKey(a.story_id, a.agent_name);
|
||||||
|
agentMap[key] = {
|
||||||
|
agentName: a.agent_name,
|
||||||
status: a.status,
|
status: a.status,
|
||||||
log: [],
|
log: [],
|
||||||
sessionId: a.session_id,
|
sessionId: a.session_id,
|
||||||
worktreePath: a.worktree_path,
|
worktreePath: a.worktree_path,
|
||||||
};
|
};
|
||||||
// Re-subscribe to running agents
|
|
||||||
if (a.status === "running" || a.status === "pending") {
|
if (a.status === "running" || a.status === "pending") {
|
||||||
subscribeToAgent(a.story_id);
|
subscribeToAgent(a.story_id, a.agent_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAgents(agentMap);
|
setAgents(agentMap);
|
||||||
@@ -110,15 +151,17 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const subscribeToAgent = useCallback((storyId: string) => {
|
const subscribeToAgent = useCallback((storyId: string, agentName: string) => {
|
||||||
// Clean up existing subscription
|
const key = agentKey(storyId, agentName);
|
||||||
cleanupRefs.current[storyId]?.();
|
cleanupRefs.current[key]?.();
|
||||||
|
|
||||||
const cleanup = subscribeAgentStream(
|
const cleanup = subscribeAgentStream(
|
||||||
storyId,
|
storyId,
|
||||||
|
agentName,
|
||||||
(event: AgentEvent) => {
|
(event: AgentEvent) => {
|
||||||
setAgents((prev) => {
|
setAgents((prev) => {
|
||||||
const current = prev[storyId] ?? {
|
const current = prev[key] ?? {
|
||||||
|
agentName,
|
||||||
status: "pending" as AgentStatusValue,
|
status: "pending" as AgentStatusValue,
|
||||||
log: [],
|
log: [],
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
@@ -129,7 +172,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
case "status":
|
case "status":
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[storyId]: {
|
[key]: {
|
||||||
...current,
|
...current,
|
||||||
status: (event.status as AgentStatusValue) ?? current.status,
|
status: (event.status as AgentStatusValue) ?? current.status,
|
||||||
},
|
},
|
||||||
@@ -137,7 +180,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
case "output":
|
case "output":
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[storyId]: {
|
[key]: {
|
||||||
...current,
|
...current,
|
||||||
log: [...current.log, event.text ?? ""],
|
log: [...current.log, event.text ?? ""],
|
||||||
},
|
},
|
||||||
@@ -145,7 +188,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
case "done":
|
case "done":
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[storyId]: {
|
[key]: {
|
||||||
...current,
|
...current,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
sessionId: event.session_id ?? current.sessionId,
|
sessionId: event.session_id ?? current.sessionId,
|
||||||
@@ -154,7 +197,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
case "error":
|
case "error":
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[storyId]: {
|
[key]: {
|
||||||
...current,
|
...current,
|
||||||
status: "failed",
|
status: "failed",
|
||||||
log: [
|
log: [
|
||||||
@@ -173,47 +216,51 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
cleanupRefs.current[storyId] = cleanup;
|
cleanupRefs.current[key] = cleanup;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-scroll log when expanded
|
// Auto-scroll log when expanded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expandedStory) {
|
if (expandedKey) {
|
||||||
const el = logEndRefs.current[expandedStory];
|
const el = logEndRefs.current[expandedKey];
|
||||||
el?.scrollIntoView({ behavior: "smooth" });
|
el?.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [expandedStory, agents]);
|
}, [expandedKey, agents]);
|
||||||
|
|
||||||
const handleStart = async (storyId: string) => {
|
const handleStart = async (storyId: string, agentName?: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
|
setSelectorStory(null);
|
||||||
try {
|
try {
|
||||||
const info: AgentInfo = await agentsApi.startAgent(storyId);
|
const info: AgentInfo = await agentsApi.startAgent(storyId, agentName);
|
||||||
|
const key = agentKey(info.story_id, info.agent_name);
|
||||||
setAgents((prev) => ({
|
setAgents((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[storyId]: {
|
[key]: {
|
||||||
|
agentName: info.agent_name,
|
||||||
status: info.status,
|
status: info.status,
|
||||||
log: [],
|
log: [],
|
||||||
sessionId: info.session_id,
|
sessionId: info.session_id,
|
||||||
worktreePath: info.worktree_path,
|
worktreePath: info.worktree_path,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
setExpandedStory(storyId);
|
setExpandedKey(key);
|
||||||
subscribeToAgent(storyId);
|
subscribeToAgent(info.story_id, info.agent_name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setActionError(`Failed to start agent for ${storyId}: ${message}`);
|
setActionError(`Failed to start agent for ${storyId}: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStop = async (storyId: string) => {
|
const handleStop = async (storyId: string, agentName: string) => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
|
const key = agentKey(storyId, agentName);
|
||||||
try {
|
try {
|
||||||
await agentsApi.stopAgent(storyId);
|
await agentsApi.stopAgent(storyId, agentName);
|
||||||
cleanupRefs.current[storyId]?.();
|
cleanupRefs.current[key]?.();
|
||||||
delete cleanupRefs.current[storyId];
|
delete cleanupRefs.current[key];
|
||||||
setAgents((prev) => {
|
setAgents((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[storyId];
|
delete next[key];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -222,9 +269,23 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAgentActive = (storyId: string): boolean => {
|
const handleRunClick = (storyId: string) => {
|
||||||
const agent = agents[storyId];
|
if (roster.length <= 1) {
|
||||||
return agent?.status === "running" || agent?.status === "pending";
|
handleStart(storyId);
|
||||||
|
} else {
|
||||||
|
setSelectorStory(selectorStory === storyId ? null : storyId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get all active agent keys for a story. */
|
||||||
|
const getActiveKeysForStory = (storyId: string): string[] => {
|
||||||
|
return Object.keys(agents).filter((key) => {
|
||||||
|
const a = agents[key];
|
||||||
|
return (
|
||||||
|
key.startsWith(`${storyId}:`) &&
|
||||||
|
(a.status === "running" || a.status === "pending")
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +334,21 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Roster badges */}
|
||||||
|
{roster.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roster.map((a) => (
|
||||||
|
<RosterBadge key={`roster-${a.name}`} agent={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{actionError && (
|
{actionError && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -300,8 +376,13 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{stories.map((story) => {
|
{stories.map((story) => {
|
||||||
const agent = agents[story.story_id];
|
const activeKeys = getActiveKeysForStory(story.story_id);
|
||||||
const isExpanded = expandedStory === story.story_id;
|
const hasActive = activeKeys.length > 0;
|
||||||
|
|
||||||
|
// Gather all agent states for this story
|
||||||
|
const storyAgentEntries = Object.entries(agents).filter(([key]) =>
|
||||||
|
key.startsWith(`${story.story_id}:`),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -324,11 +405,19 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setExpandedStory(isExpanded ? null : story.story_id)
|
setExpandedKey(
|
||||||
|
expandedKey?.startsWith(`${story.story_id}:`)
|
||||||
|
? null
|
||||||
|
: (storyAgentEntries[0]?.[0] ?? story.story_id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
setExpandedStory(isExpanded ? null : story.story_id);
|
setExpandedKey(
|
||||||
|
expandedKey?.startsWith(`${story.story_id}:`)
|
||||||
|
? null
|
||||||
|
: (storyAgentEntries[0]?.[0] ?? story.story_id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
@@ -338,7 +427,9 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8em",
|
fontSize: "0.8em",
|
||||||
padding: "0 4px",
|
padding: "0 4px",
|
||||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
transform: expandedKey?.startsWith(`${story.story_id}:`)
|
||||||
|
? "rotate(90deg)"
|
||||||
|
: "rotate(0deg)",
|
||||||
transition: "transform 0.15s",
|
transition: "transform 0.15s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -358,12 +449,38 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
{story.name ?? story.story_id}
|
{story.name ?? story.story_id}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{agent && <StatusBadge status={agent.status} />}
|
{storyAgentEntries.map(([key, a]) => (
|
||||||
|
<span
|
||||||
|
key={`badge-${key}`}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7em",
|
||||||
|
color: "#666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.agentName}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={a.status} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
{isAgentActive(story.story_id) ? (
|
{hasActive ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleStop(story.story_id)}
|
onClick={() => {
|
||||||
|
for (const key of activeKeys) {
|
||||||
|
const a = agents[key];
|
||||||
|
if (a) {
|
||||||
|
handleStop(story.story_id, a.agentName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: "4px 10px",
|
padding: "4px 10px",
|
||||||
borderRadius: "999px",
|
borderRadius: "999px",
|
||||||
@@ -378,88 +495,164 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div style={{ position: "relative" }}>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleStart(story.story_id)}
|
type="button"
|
||||||
style={{
|
onClick={() => handleRunClick(story.story_id)}
|
||||||
padding: "4px 10px",
|
style={{
|
||||||
borderRadius: "999px",
|
padding: "4px 10px",
|
||||||
border: "1px solid #7ee78744",
|
borderRadius: "999px",
|
||||||
background: "#7ee78711",
|
border: "1px solid #7ee78744",
|
||||||
color: "#7ee787",
|
background: "#7ee78711",
|
||||||
cursor: "pointer",
|
color: "#7ee787",
|
||||||
fontSize: "0.75em",
|
cursor: "pointer",
|
||||||
fontWeight: 600,
|
fontSize: "0.75em",
|
||||||
}}
|
fontWeight: 600,
|
||||||
>
|
}}
|
||||||
Run
|
>
|
||||||
</button>
|
Run
|
||||||
|
</button>
|
||||||
|
{selectorStory === story.story_id &&
|
||||||
|
roster.length > 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
right: 0,
|
||||||
|
marginTop: "4px",
|
||||||
|
background: "#222",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "4px 0",
|
||||||
|
zIndex: 10,
|
||||||
|
minWidth: "160px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roster.map((r) => (
|
||||||
|
<button
|
||||||
|
key={`sel-${r.name}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleStart(story.story_id, r.name)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
padding: "6px 12px",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "#ccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(
|
||||||
|
e.target as HTMLButtonElement
|
||||||
|
).style.background = "#333";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(
|
||||||
|
e.target as HTMLButtonElement
|
||||||
|
).style.background = "none";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
||||||
|
{r.role && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85em",
|
||||||
|
color: "#888",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.role}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && agent && (
|
{/* Expanded detail per agent */}
|
||||||
<div
|
{storyAgentEntries.map(([key, a]) => {
|
||||||
style={{
|
if (expandedKey !== key) return null;
|
||||||
borderTop: "1px solid #2a2a2a",
|
return (
|
||||||
padding: "8px 12px",
|
<div
|
||||||
}}
|
key={`detail-${key}`}
|
||||||
>
|
style={{
|
||||||
{agent.worktreePath && (
|
borderTop: "1px solid #2a2a2a",
|
||||||
|
padding: "8px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.75em",
|
fontSize: "0.75em",
|
||||||
color: "#666",
|
color: "#888",
|
||||||
fontFamily: "monospace",
|
marginBottom: "4px",
|
||||||
marginBottom: "6px",
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Worktree: {agent.worktreePath}
|
{a.agentName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{a.worktreePath && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "300px",
|
fontSize: "0.75em",
|
||||||
overflowY: "auto",
|
color: "#666",
|
||||||
background: "#111",
|
fontFamily: "monospace",
|
||||||
borderRadius: "6px",
|
marginBottom: "6px",
|
||||||
padding: "8px",
|
}}
|
||||||
fontFamily: "monospace",
|
>
|
||||||
fontSize: "0.8em",
|
Worktree: {a.worktreePath}
|
||||||
lineHeight: "1.5",
|
</div>
|
||||||
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
|
<div
|
||||||
ref={(el) => {
|
style={{
|
||||||
logEndRefs.current[story.story_id] = el;
|
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",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{a.log.length === 0 ? (
|
||||||
|
<span style={{ color: "#555" }}>
|
||||||
|
{a.status === "pending" || a.status === "running"
|
||||||
|
? "Waiting for output..."
|
||||||
|
: "No output captured."}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
a.log.map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={`log-${key}-${i.toString()}`}
|
||||||
|
style={{
|
||||||
|
color: line.startsWith("[ERROR]")
|
||||||
|
? "#ff7b72"
|
||||||
|
: "#ccc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
logEndRefs.current[key] = el;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -475,7 +475,11 @@ describe("Chat review panel", () => {
|
|||||||
it("fetches upcoming stories on mount and renders panel", async () => {
|
it("fetches upcoming stories on mount and renders panel", async () => {
|
||||||
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
|
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
|
||||||
stories: [
|
stories: [
|
||||||
{ story_id: "31_view_upcoming", name: "View Upcoming Stories", error: null },
|
{
|
||||||
|
story_id: "31_view_upcoming",
|
||||||
|
name: "View Upcoming Stories",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
{ story_id: "32_worktree", name: null, error: null },
|
{ story_id: "32_worktree", name: null, error: null },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ describe("UpcomingPanel", () => {
|
|||||||
|
|
||||||
it("renders story list with names", () => {
|
it("renders story list with names", () => {
|
||||||
const stories: UpcomingStory[] = [
|
const stories: UpcomingStory[] = [
|
||||||
{ story_id: "31_view_upcoming", name: "View Upcoming Stories", error: null },
|
{
|
||||||
|
story_id: "31_view_upcoming",
|
||||||
|
name: "View Upcoming Stories",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
{ story_id: "32_worktree", name: "Worktree Orchestration", error: null },
|
{ story_id: "32_worktree", name: "Worktree Orchestration", error: null },
|
||||||
];
|
];
|
||||||
render(<UpcomingPanel {...baseProps} stories={stories} />);
|
render(<UpcomingPanel {...baseProps} stories={stories} />);
|
||||||
|
|||||||
@@ -8,23 +8,45 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
/// Build the composite key used to track agents in the pool.
|
||||||
|
fn composite_key(story_id: &str, agent_name: &str) -> String {
|
||||||
|
format!("{story_id}:{agent_name}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Events streamed from a running agent to SSE clients.
|
/// Events streamed from a running agent to SSE clients.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum AgentEvent {
|
pub enum AgentEvent {
|
||||||
/// Agent status changed.
|
/// Agent status changed.
|
||||||
Status { story_id: String, status: String },
|
Status {
|
||||||
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
status: String,
|
||||||
|
},
|
||||||
/// Raw text output from the agent process.
|
/// Raw text output from the agent process.
|
||||||
Output { story_id: String, text: String },
|
Output {
|
||||||
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
/// Agent produced a JSON event from `--output-format stream-json`.
|
/// Agent produced a JSON event from `--output-format stream-json`.
|
||||||
AgentJson { story_id: String, data: serde_json::Value },
|
AgentJson {
|
||||||
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
data: serde_json::Value,
|
||||||
|
},
|
||||||
/// Agent finished.
|
/// Agent finished.
|
||||||
Done {
|
Done {
|
||||||
story_id: String,
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
},
|
},
|
||||||
/// Agent errored.
|
/// Agent errored.
|
||||||
Error { story_id: String, message: String },
|
Error {
|
||||||
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
@@ -50,12 +72,14 @@ impl std::fmt::Display for AgentStatus {
|
|||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct AgentInfo {
|
pub struct AgentInfo {
|
||||||
pub story_id: String,
|
pub story_id: String,
|
||||||
|
pub agent_name: String,
|
||||||
pub status: AgentStatus,
|
pub status: AgentStatus,
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
pub worktree_path: Option<String>,
|
pub worktree_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StoryAgent {
|
struct StoryAgent {
|
||||||
|
agent_name: String,
|
||||||
status: AgentStatus,
|
status: AgentStatus,
|
||||||
worktree_info: Option<WorktreeInfo>,
|
worktree_info: Option<WorktreeInfo>,
|
||||||
config: ProjectConfig,
|
config: ProjectConfig,
|
||||||
@@ -77,32 +101,54 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start an agent for a story: load config, create worktree, spawn agent.
|
/// Start an agent for a story: load config, create worktree, spawn agent.
|
||||||
|
/// If `agent_name` is None, defaults to the first configured agent.
|
||||||
pub async fn start_agent(
|
pub async fn start_agent(
|
||||||
&self,
|
&self,
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
|
agent_name: Option<&str>,
|
||||||
) -> Result<AgentInfo, String> {
|
) -> Result<AgentInfo, String> {
|
||||||
|
let config = ProjectConfig::load(project_root)?;
|
||||||
|
|
||||||
|
// Resolve agent name from config
|
||||||
|
let resolved_name = match agent_name {
|
||||||
|
Some(name) => {
|
||||||
|
config
|
||||||
|
.find_agent(name)
|
||||||
|
.ok_or_else(|| format!("No agent named '{name}' in config"))?;
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
None => config
|
||||||
|
.default_agent()
|
||||||
|
.ok_or_else(|| "No agents configured".to_string())?
|
||||||
|
.name
|
||||||
|
.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = composite_key(story_id, &resolved_name);
|
||||||
|
|
||||||
// Check not already running
|
// Check not already running
|
||||||
{
|
{
|
||||||
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(agent) = agents.get(story_id)
|
if let Some(agent) = agents.get(&key)
|
||||||
&& (agent.status == AgentStatus::Running || agent.status == AgentStatus::Pending) {
|
&& (agent.status == AgentStatus::Running || agent.status == AgentStatus::Pending)
|
||||||
return Err(format!(
|
{
|
||||||
"Agent for story '{story_id}' is already {}",
|
return Err(format!(
|
||||||
agent.status
|
"Agent '{resolved_name}' for story '{story_id}' is already {}",
|
||||||
));
|
agent.status
|
||||||
}
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = ProjectConfig::load(project_root)?;
|
|
||||||
let (tx, _) = broadcast::channel::<AgentEvent>(256);
|
let (tx, _) = broadcast::channel::<AgentEvent>(256);
|
||||||
|
|
||||||
// Register as pending
|
// 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(
|
agents.insert(
|
||||||
story_id.to_string(),
|
key.clone(),
|
||||||
StoryAgent {
|
StoryAgent {
|
||||||
|
agent_name: resolved_name.clone(),
|
||||||
status: AgentStatus::Pending,
|
status: AgentStatus::Pending,
|
||||||
worktree_info: None,
|
worktree_info: None,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
@@ -115,6 +161,7 @@ impl AgentPool {
|
|||||||
|
|
||||||
let _ = tx.send(AgentEvent::Status {
|
let _ = tx.send(AgentEvent::Status {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: resolved_name.clone(),
|
||||||
status: "pending".to_string(),
|
status: "pending".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,51 +171,55 @@ impl AgentPool {
|
|||||||
// Update with worktree info
|
// Update with worktree info
|
||||||
{
|
{
|
||||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(agent) = agents.get_mut(story_id) {
|
if let Some(agent) = agents.get_mut(&key) {
|
||||||
agent.worktree_info = Some(wt_info.clone());
|
agent.worktree_info = Some(wt_info.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the agent process
|
// Spawn the agent process
|
||||||
let wt_path_str = wt_info.path.to_string_lossy().to_string();
|
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) =
|
||||||
|
config.render_agent_args(&wt_path_str, story_id, Some(&resolved_name))?;
|
||||||
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 sid = story_id.to_string();
|
||||||
|
let aname = resolved_name.clone();
|
||||||
let tx_clone = tx.clone();
|
let tx_clone = tx.clone();
|
||||||
let agents_ref = self.agents.clone();
|
let agents_ref = self.agents.clone();
|
||||||
let cwd = wt_path_str.clone();
|
let cwd = wt_path_str.clone();
|
||||||
|
let key_clone = key.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let _ = tx_clone.send(AgentEvent::Status {
|
let _ = tx_clone.send(AgentEvent::Status {
|
||||||
story_id: sid.clone(),
|
story_id: sid.clone(),
|
||||||
|
agent_name: aname.clone(),
|
||||||
status: "running".to_string(),
|
status: "running".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
match run_agent_pty_streaming(&sid, &command, &args, &prompt, &cwd, &tx_clone).await {
|
match run_agent_pty_streaming(&sid, &aname, &command, &args, &prompt, &cwd, &tx_clone)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(session_id) => {
|
Ok(session_id) => {
|
||||||
// Mark completed in the pool
|
|
||||||
if let Ok(mut agents) = agents_ref.lock()
|
if let Ok(mut agents) = agents_ref.lock()
|
||||||
&& let Some(agent) = agents.get_mut(&sid) {
|
&& let Some(agent) = agents.get_mut(&key_clone)
|
||||||
agent.status = AgentStatus::Completed;
|
{
|
||||||
agent.session_id = session_id.clone();
|
agent.status = AgentStatus::Completed;
|
||||||
}
|
agent.session_id = session_id.clone();
|
||||||
|
}
|
||||||
let _ = tx_clone.send(AgentEvent::Done {
|
let _ = tx_clone.send(AgentEvent::Done {
|
||||||
story_id: sid.clone(),
|
story_id: sid.clone(),
|
||||||
|
agent_name: aname.clone(),
|
||||||
session_id,
|
session_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Mark failed in the pool
|
|
||||||
if let Ok(mut agents) = agents_ref.lock()
|
if let Ok(mut agents) = agents_ref.lock()
|
||||||
&& let Some(agent) = agents.get_mut(&sid) {
|
&& let Some(agent) = agents.get_mut(&key_clone)
|
||||||
agent.status = AgentStatus::Failed;
|
{
|
||||||
}
|
agent.status = AgentStatus::Failed;
|
||||||
|
}
|
||||||
let _ = tx_clone.send(AgentEvent::Error {
|
let _ = tx_clone.send(AgentEvent::Error {
|
||||||
story_id: sid.clone(),
|
story_id: sid.clone(),
|
||||||
|
agent_name: aname.clone(),
|
||||||
message: e,
|
message: e,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -178,7 +229,7 @@ impl AgentPool {
|
|||||||
// Update status to running with task handle
|
// Update status to running with task handle
|
||||||
{
|
{
|
||||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(agent) = agents.get_mut(story_id) {
|
if let Some(agent) = agents.get_mut(&key) {
|
||||||
agent.status = AgentStatus::Running;
|
agent.status = AgentStatus::Running;
|
||||||
agent.task_handle = Some(handle);
|
agent.task_handle = Some(handle);
|
||||||
}
|
}
|
||||||
@@ -186,6 +237,7 @@ impl AgentPool {
|
|||||||
|
|
||||||
Ok(AgentInfo {
|
Ok(AgentInfo {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: resolved_name,
|
||||||
status: AgentStatus::Running,
|
status: AgentStatus::Running,
|
||||||
session_id: None,
|
session_id: None,
|
||||||
worktree_path: Some(wt_path_str),
|
worktree_path: Some(wt_path_str),
|
||||||
@@ -193,12 +245,19 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stop a running agent and clean up its worktree.
|
/// Stop a running agent and clean up its worktree.
|
||||||
pub async fn stop_agent(&self, project_root: &Path, story_id: &str) -> Result<(), String> {
|
pub async fn stop_agent(
|
||||||
|
&self,
|
||||||
|
project_root: &Path,
|
||||||
|
story_id: &str,
|
||||||
|
agent_name: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let key = composite_key(story_id, agent_name);
|
||||||
|
|
||||||
let (worktree_info, config, task_handle, tx) = {
|
let (worktree_info, config, task_handle, tx) = {
|
||||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||||
let agent = agents
|
let agent = agents
|
||||||
.get_mut(story_id)
|
.get_mut(&key)
|
||||||
.ok_or_else(|| format!("No agent for story '{story_id}'"))?;
|
.ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?;
|
||||||
|
|
||||||
let wt = agent.worktree_info.clone();
|
let wt = agent.worktree_info.clone();
|
||||||
let cfg = agent.config.clone();
|
let cfg = agent.config.clone();
|
||||||
@@ -216,19 +275,21 @@ impl AgentPool {
|
|||||||
|
|
||||||
// Remove worktree
|
// Remove worktree
|
||||||
if let Some(ref wt) = worktree_info
|
if let Some(ref wt) = worktree_info
|
||||||
&& let Err(e) = worktree::remove_worktree(project_root, wt, &config).await {
|
&& let Err(e) = worktree::remove_worktree(project_root, wt, &config).await
|
||||||
eprintln!("[agents] Worktree cleanup warning for {story_id}: {e}");
|
{
|
||||||
}
|
eprintln!("[agents] Worktree cleanup warning for {story_id}:{agent_name}: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
let _ = tx.send(AgentEvent::Status {
|
let _ = tx.send(AgentEvent::Status {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: agent_name.to_string(),
|
||||||
status: "stopped".to_string(),
|
status: "stopped".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove from map
|
// Remove from map
|
||||||
{
|
{
|
||||||
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.remove(story_id);
|
agents.remove(&key);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -239,24 +300,37 @@ impl AgentPool {
|
|||||||
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(|(story_id, agent)| AgentInfo {
|
.map(|(key, agent)| {
|
||||||
story_id: story_id.clone(),
|
// Extract story_id from composite key "story_id:agent_name"
|
||||||
status: agent.status.clone(),
|
let story_id = key
|
||||||
session_id: agent.session_id.clone(),
|
.rsplit_once(':')
|
||||||
worktree_path: agent
|
.map(|(sid, _)| sid.to_string())
|
||||||
.worktree_info
|
.unwrap_or_else(|| key.clone());
|
||||||
.as_ref()
|
AgentInfo {
|
||||||
.map(|wt| wt.path.to_string_lossy().to_string()),
|
story_id,
|
||||||
|
agent_name: agent.agent_name.clone(),
|
||||||
|
status: agent.status.clone(),
|
||||||
|
session_id: agent.session_id.clone(),
|
||||||
|
worktree_path: agent
|
||||||
|
.worktree_info
|
||||||
|
.as_ref()
|
||||||
|
.map(|wt| wt.path.to_string_lossy().to_string()),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to events for a story agent.
|
/// Subscribe to events for a story agent.
|
||||||
pub fn subscribe(&self, story_id: &str) -> Result<broadcast::Receiver<AgentEvent>, String> {
|
pub fn subscribe(
|
||||||
|
&self,
|
||||||
|
story_id: &str,
|
||||||
|
agent_name: &str,
|
||||||
|
) -> Result<broadcast::Receiver<AgentEvent>, String> {
|
||||||
|
let key = composite_key(story_id, agent_name);
|
||||||
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||||
let agent = agents
|
let agent = agents
|
||||||
.get(story_id)
|
.get(&key)
|
||||||
.ok_or_else(|| format!("No agent for story '{story_id}'"))?;
|
.ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?;
|
||||||
Ok(agent.tx.subscribe())
|
Ok(agent.tx.subscribe())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +346,7 @@ impl AgentPool {
|
|||||||
/// Spawn claude agent in a PTY and stream events through the broadcast channel.
|
/// Spawn claude agent in a PTY and stream events through the broadcast channel.
|
||||||
async fn run_agent_pty_streaming(
|
async fn run_agent_pty_streaming(
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
|
agent_name: &str,
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
@@ -279,6 +354,7 @@ async fn run_agent_pty_streaming(
|
|||||||
tx: &broadcast::Sender<AgentEvent>,
|
tx: &broadcast::Sender<AgentEvent>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
let sid = story_id.to_string();
|
let sid = story_id.to_string();
|
||||||
|
let aname = agent_name.to_string();
|
||||||
let cmd = command.to_string();
|
let cmd = command.to_string();
|
||||||
let args = args.to_vec();
|
let args = args.to_vec();
|
||||||
let prompt = prompt.to_string();
|
let prompt = prompt.to_string();
|
||||||
@@ -286,7 +362,7 @@ async fn run_agent_pty_streaming(
|
|||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
run_agent_pty_blocking(&sid, &cmd, &args, &prompt, &cwd, &tx)
|
run_agent_pty_blocking(&sid, &aname, &cmd, &args, &prompt, &cwd, &tx)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Agent task panicked: {e}"))?
|
.map_err(|e| format!("Agent task panicked: {e}"))?
|
||||||
@@ -294,6 +370,7 @@ async fn run_agent_pty_streaming(
|
|||||||
|
|
||||||
fn run_agent_pty_blocking(
|
fn run_agent_pty_blocking(
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
|
agent_name: &str,
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
@@ -317,7 +394,7 @@ fn run_agent_pty_blocking(
|
|||||||
cmd.arg("-p");
|
cmd.arg("-p");
|
||||||
cmd.arg(prompt);
|
cmd.arg(prompt);
|
||||||
|
|
||||||
// Add configured args (e.g., --directory /path/to/worktree)
|
// Add configured args (e.g., --directory /path/to/worktree, --model, etc.)
|
||||||
for arg in args {
|
for arg in args {
|
||||||
cmd.arg(arg);
|
cmd.arg(arg);
|
||||||
}
|
}
|
||||||
@@ -333,12 +410,12 @@ fn run_agent_pty_blocking(
|
|||||||
cmd.cwd(cwd);
|
cmd.cwd(cwd);
|
||||||
cmd.env("NO_COLOR", "1");
|
cmd.env("NO_COLOR", "1");
|
||||||
|
|
||||||
eprintln!("[agent:{story_id}] Spawning {command} in {cwd} with args: {args:?}");
|
eprintln!("[agent:{story_id}:{agent_name}] Spawning {command} in {cwd} with args: {args:?}");
|
||||||
|
|
||||||
let mut child = pair
|
let mut child = pair
|
||||||
.slave
|
.slave
|
||||||
.spawn_command(cmd)
|
.spawn_command(cmd)
|
||||||
.map_err(|e| format!("Failed to spawn agent for {story_id}: {e}"))?;
|
.map_err(|e| format!("Failed to spawn agent for {story_id}:{agent_name}: {e}"))?;
|
||||||
|
|
||||||
drop(pair.slave);
|
drop(pair.slave);
|
||||||
|
|
||||||
@@ -370,6 +447,7 @@ fn run_agent_pty_blocking(
|
|||||||
// Non-JSON output (terminal escapes etc.) — send as raw output
|
// Non-JSON output (terminal escapes etc.) — send as raw output
|
||||||
let _ = tx.send(AgentEvent::Output {
|
let _ = tx.send(AgentEvent::Output {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: agent_name.to_string(),
|
||||||
text: trimmed.to_string(),
|
text: trimmed.to_string(),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -387,16 +465,18 @@ fn run_agent_pty_blocking(
|
|||||||
}
|
}
|
||||||
"assistant" => {
|
"assistant" => {
|
||||||
if let Some(message) = json.get("message")
|
if let Some(message) = json.get("message")
|
||||||
&& 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 {
|
{
|
||||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
for block in content {
|
||||||
let _ = tx.send(AgentEvent::Output {
|
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||||
story_id: story_id.to_string(),
|
let _ = tx.send(AgentEvent::Output {
|
||||||
text: text.to_string(),
|
story_id: story_id.to_string(),
|
||||||
});
|
agent_name: agent_name.to_string(),
|
||||||
}
|
text: text.to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -404,6 +484,7 @@ fn run_agent_pty_blocking(
|
|||||||
// Forward all JSON events
|
// Forward all JSON events
|
||||||
let _ = tx.send(AgentEvent::AgentJson {
|
let _ = tx.send(AgentEvent::AgentJson {
|
||||||
story_id: story_id.to_string(),
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: agent_name.to_string(),
|
||||||
data: json,
|
data: json,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -411,7 +492,7 @@ fn run_agent_pty_blocking(
|
|||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[agent:{story_id}] Done. Session: {:?}",
|
"[agent:{story_id}:{agent_name}] Done. Session: {:?}",
|
||||||
session_id
|
session_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct ProjectConfig {
|
pub struct ProjectConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub component: Vec<ComponentConfig>,
|
pub component: Vec<ComponentConfig>,
|
||||||
pub agent: Option<AgentConfig>,
|
#[serde(default)]
|
||||||
|
pub agent: Vec<AgentConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -21,18 +23,36 @@ pub struct ComponentConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct AgentConfig {
|
pub struct AgentConfig {
|
||||||
|
#[serde(default = "default_agent_name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub role: String,
|
||||||
#[serde(default = "default_agent_command")]
|
#[serde(default = "default_agent_command")]
|
||||||
pub command: String,
|
pub command: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
#[serde(default = "default_agent_prompt")]
|
#[serde(default = "default_agent_prompt")]
|
||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowed_tools: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_turns: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_budget_usd: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub system_prompt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_path() -> String {
|
fn default_path() -> String {
|
||||||
".".to_string()
|
".".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_agent_name() -> String {
|
||||||
|
"default".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_agent_command() -> String {
|
fn default_agent_command() -> String {
|
||||||
"claude".to_string()
|
"claude".to_string()
|
||||||
}
|
}
|
||||||
@@ -41,15 +61,30 @@ fn default_agent_prompt() -> String {
|
|||||||
"Read .story_kit/README.md, then pick up story {{story_id}}".to_string()
|
"Read .story_kit/README.md, then pick up story {{story_id}}".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legacy config format with `agent` as an optional single table (`[agent]`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LegacyProjectConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
component: Vec<ComponentConfig>,
|
||||||
|
agent: Option<AgentConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ProjectConfig {
|
impl Default for ProjectConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
component: Vec::new(),
|
component: Vec::new(),
|
||||||
agent: Some(AgentConfig {
|
agent: vec![AgentConfig {
|
||||||
|
name: default_agent_name(),
|
||||||
|
role: String::new(),
|
||||||
command: default_agent_command(),
|
command: default_agent_command(),
|
||||||
args: vec![],
|
args: vec![],
|
||||||
prompt: default_agent_prompt(),
|
prompt: default_agent_prompt(),
|
||||||
}),
|
model: None,
|
||||||
|
allowed_tools: None,
|
||||||
|
max_turns: None,
|
||||||
|
max_budget_usd: None,
|
||||||
|
system_prompt: None,
|
||||||
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +92,9 @@ impl Default for ProjectConfig {
|
|||||||
impl ProjectConfig {
|
impl ProjectConfig {
|
||||||
/// Load from `.story_kit/project.toml` relative to the given root.
|
/// Load from `.story_kit/project.toml` relative to the given root.
|
||||||
/// Falls back to sensible defaults if the file doesn't exist.
|
/// Falls back to sensible defaults if the file doesn't exist.
|
||||||
|
///
|
||||||
|
/// Supports both the new `[[agent]]` array format and the legacy
|
||||||
|
/// `[agent]` single-table format (with a deprecation warning).
|
||||||
pub fn load(project_root: &Path) -> Result<Self, String> {
|
pub fn load(project_root: &Path) -> Result<Self, String> {
|
||||||
let config_path = project_root.join(".story_kit/project.toml");
|
let config_path = project_root.join(".story_kit/project.toml");
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
@@ -64,27 +102,152 @@ impl ProjectConfig {
|
|||||||
}
|
}
|
||||||
let content =
|
let content =
|
||||||
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
|
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}"))
|
Self::parse(&content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render template variables in agent args and prompt.
|
/// Parse config from a TOML string, supporting both new and legacy formats.
|
||||||
|
pub fn parse(content: &str) -> Result<Self, String> {
|
||||||
|
// Try new format first (agent as array of tables)
|
||||||
|
match toml::from_str::<ProjectConfig>(content) {
|
||||||
|
Ok(config) if !config.agent.is_empty() => {
|
||||||
|
validate_agents(&config.agent)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
Ok(config) => {
|
||||||
|
// Parsed successfully but no agents — could be legacy or no agent section.
|
||||||
|
// Try legacy format.
|
||||||
|
if let Ok(legacy) = toml::from_str::<LegacyProjectConfig>(content)
|
||||||
|
&& let Some(agent) = legacy.agent {
|
||||||
|
eprintln!(
|
||||||
|
"[config] Warning: [agent] table is deprecated. \
|
||||||
|
Use [[agent]] array format instead."
|
||||||
|
);
|
||||||
|
let config = ProjectConfig {
|
||||||
|
component: legacy.component,
|
||||||
|
agent: vec![agent],
|
||||||
|
};
|
||||||
|
validate_agents(&config.agent)?;
|
||||||
|
return Ok(config);
|
||||||
|
}
|
||||||
|
// No agent section at all
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// New format failed — try legacy
|
||||||
|
let legacy: LegacyProjectConfig =
|
||||||
|
toml::from_str(content).map_err(|e| format!("Parse config: {e}"))?;
|
||||||
|
if let Some(agent) = legacy.agent {
|
||||||
|
eprintln!(
|
||||||
|
"[config] Warning: [agent] table is deprecated. \
|
||||||
|
Use [[agent]] array format instead."
|
||||||
|
);
|
||||||
|
let config = ProjectConfig {
|
||||||
|
component: legacy.component,
|
||||||
|
agent: vec![agent],
|
||||||
|
};
|
||||||
|
validate_agents(&config.agent)?;
|
||||||
|
Ok(config)
|
||||||
|
} else {
|
||||||
|
Ok(ProjectConfig {
|
||||||
|
component: legacy.component,
|
||||||
|
agent: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up an agent config by name.
|
||||||
|
pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> {
|
||||||
|
self.agent.iter().find(|a| a.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the default (first) agent config.
|
||||||
|
pub fn default_agent(&self) -> Option<&AgentConfig> {
|
||||||
|
self.agent.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render template variables in agent args and prompt for the given agent.
|
||||||
|
/// If `agent_name` is None, uses the first (default) agent.
|
||||||
pub fn render_agent_args(
|
pub fn render_agent_args(
|
||||||
&self,
|
&self,
|
||||||
worktree_path: &str,
|
worktree_path: &str,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
) -> Option<(String, Vec<String>, String)> {
|
agent_name: Option<&str>,
|
||||||
let agent = self.agent.as_ref()?;
|
) -> Result<(String, Vec<String>, String), String> {
|
||||||
|
let agent = match agent_name {
|
||||||
|
Some(name) => self
|
||||||
|
.find_agent(name)
|
||||||
|
.ok_or_else(|| format!("No agent named '{name}' in config"))?,
|
||||||
|
None => self
|
||||||
|
.default_agent()
|
||||||
|
.ok_or_else(|| "No agents configured".to_string())?,
|
||||||
|
};
|
||||||
|
|
||||||
let render = |s: &str| {
|
let render = |s: &str| {
|
||||||
s.replace("{{worktree_path}}", worktree_path)
|
s.replace("{{worktree_path}}", worktree_path)
|
||||||
.replace("{{story_id}}", story_id)
|
.replace("{{story_id}}", story_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
let command = render(&agent.command);
|
let command = render(&agent.command);
|
||||||
let args: Vec<String> = agent.args.iter().map(|a| render(a)).collect();
|
let mut args: Vec<String> = agent.args.iter().map(|a| render(a)).collect();
|
||||||
let prompt = render(&agent.prompt);
|
let prompt = render(&agent.prompt);
|
||||||
Some((command, args, prompt))
|
|
||||||
|
// Append structured CLI flags
|
||||||
|
if let Some(ref model) = agent.model {
|
||||||
|
args.push("--model".to_string());
|
||||||
|
args.push(model.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref tools) = agent.allowed_tools
|
||||||
|
&& !tools.is_empty() {
|
||||||
|
args.push("--allowedTools".to_string());
|
||||||
|
args.push(tools.join(","));
|
||||||
|
}
|
||||||
|
if let Some(turns) = agent.max_turns {
|
||||||
|
args.push("--max-turns".to_string());
|
||||||
|
args.push(turns.to_string());
|
||||||
|
}
|
||||||
|
if let Some(budget) = agent.max_budget_usd {
|
||||||
|
args.push("--max-budget-usd".to_string());
|
||||||
|
args.push(budget.to_string());
|
||||||
|
}
|
||||||
|
if let Some(ref sp) = agent.system_prompt {
|
||||||
|
args.push("--append-system-prompt".to_string());
|
||||||
|
args.push(render(sp));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((command, args, prompt))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate agent configs: no duplicate names, no empty names, positive budgets/turns.
|
||||||
|
fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> {
|
||||||
|
let mut names = HashSet::new();
|
||||||
|
for agent in agents {
|
||||||
|
if agent.name.trim().is_empty() {
|
||||||
|
return Err("Agent name must not be empty".to_string());
|
||||||
|
}
|
||||||
|
if !names.insert(&agent.name) {
|
||||||
|
return Err(format!("Duplicate agent name: '{}'", agent.name));
|
||||||
|
}
|
||||||
|
if let Some(budget) = agent.max_budget_usd
|
||||||
|
&& budget <= 0.0 {
|
||||||
|
return Err(format!(
|
||||||
|
"Agent '{}': max_budget_usd must be positive, got {budget}",
|
||||||
|
agent.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(turns) = agent.max_turns
|
||||||
|
&& turns == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"Agent '{}': max_turns must be positive, got 0",
|
||||||
|
agent.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -94,12 +257,204 @@ mod tests {
|
|||||||
fn default_config_when_missing() {
|
fn default_config_when_missing() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let config = ProjectConfig::load(tmp.path()).unwrap();
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
||||||
assert!(config.agent.is_some());
|
assert_eq!(config.agent.len(), 1);
|
||||||
|
assert_eq!(config.agent[0].name, "default");
|
||||||
assert!(config.component.is_empty());
|
assert!(config.component.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_project_toml() {
|
fn parse_multi_agent_toml() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[component]]
|
||||||
|
name = "server"
|
||||||
|
path = "."
|
||||||
|
setup = ["cargo check"]
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "supervisor"
|
||||||
|
role = "Coordinates work"
|
||||||
|
model = "opus"
|
||||||
|
max_turns = 50
|
||||||
|
max_budget_usd = 10.00
|
||||||
|
system_prompt = "You are a senior engineer"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-1"
|
||||||
|
role = "Full-stack engineer"
|
||||||
|
model = "sonnet"
|
||||||
|
max_turns = 30
|
||||||
|
max_budget_usd = 5.00
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.agent.len(), 2);
|
||||||
|
assert_eq!(config.agent[0].name, "supervisor");
|
||||||
|
assert_eq!(config.agent[0].role, "Coordinates work");
|
||||||
|
assert_eq!(config.agent[0].model, Some("opus".to_string()));
|
||||||
|
assert_eq!(config.agent[0].max_turns, Some(50));
|
||||||
|
assert_eq!(config.agent[0].max_budget_usd, Some(10.0));
|
||||||
|
assert_eq!(
|
||||||
|
config.agent[0].system_prompt,
|
||||||
|
Some("You are a senior engineer".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(config.agent[1].name, "coder-1");
|
||||||
|
assert_eq!(config.agent[1].model, Some("sonnet".to_string()));
|
||||||
|
assert_eq!(config.component.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_legacy_single_agent() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[component]]
|
||||||
|
name = "server"
|
||||||
|
path = "."
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
command = "claude"
|
||||||
|
args = ["--print", "--directory", "{{worktree_path}}"]
|
||||||
|
prompt = "Pick up story {{story_id}}"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.agent.len(), 1);
|
||||||
|
assert_eq!(config.agent[0].name, "default");
|
||||||
|
assert_eq!(config.agent[0].command, "claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_duplicate_names() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
role = "Engineer"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
role = "Another engineer"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("Duplicate agent name: 'coder'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_empty_name() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = ""
|
||||||
|
role = "Engineer"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("Agent name must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_non_positive_budget() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
max_budget_usd = -1.0
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("must be positive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_zero_max_turns() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
max_turns = 0
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let err = ProjectConfig::parse(toml_str).unwrap_err();
|
||||||
|
assert!(err.contains("max_turns must be positive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_agent_args_default() {
|
||||||
|
let config = ProjectConfig::default();
|
||||||
|
let (cmd, args, prompt) = config
|
||||||
|
.render_agent_args("/tmp/wt", "42_foo", None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd, "claude");
|
||||||
|
assert!(args.is_empty());
|
||||||
|
assert!(prompt.contains("42_foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_agent_args_by_name() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "supervisor"
|
||||||
|
model = "opus"
|
||||||
|
max_turns = 50
|
||||||
|
max_budget_usd = 10.00
|
||||||
|
system_prompt = "You lead story {{story_id}}"
|
||||||
|
allowed_tools = ["Read", "Write", "Bash"]
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
model = "sonnet"
|
||||||
|
max_turns = 30
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
let (cmd, args, prompt) = config
|
||||||
|
.render_agent_args("/tmp/wt", "42_foo", Some("supervisor"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd, "claude");
|
||||||
|
assert!(args.contains(&"--model".to_string()));
|
||||||
|
assert!(args.contains(&"opus".to_string()));
|
||||||
|
assert!(args.contains(&"--max-turns".to_string()));
|
||||||
|
assert!(args.contains(&"50".to_string()));
|
||||||
|
assert!(args.contains(&"--max-budget-usd".to_string()));
|
||||||
|
assert!(args.contains(&"10".to_string()));
|
||||||
|
assert!(args.contains(&"--allowedTools".to_string()));
|
||||||
|
assert!(args.contains(&"Read,Write,Bash".to_string()));
|
||||||
|
assert!(args.contains(&"--append-system-prompt".to_string()));
|
||||||
|
// System prompt should have template rendered
|
||||||
|
assert!(args.contains(&"You lead story 42_foo".to_string()));
|
||||||
|
assert!(prompt.contains("42_foo"));
|
||||||
|
|
||||||
|
// Render for coder
|
||||||
|
let (_, coder_args, _) = config
|
||||||
|
.render_agent_args("/tmp/wt", "42_foo", Some("coder"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(coder_args.contains(&"sonnet".to_string()));
|
||||||
|
assert!(coder_args.contains(&"30".to_string()));
|
||||||
|
assert!(!coder_args.contains(&"--max-budget-usd".to_string()));
|
||||||
|
assert!(!coder_args.contains(&"--append-system-prompt".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_agent_args_not_found() {
|
||||||
|
let config = ProjectConfig::default();
|
||||||
|
let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("No agent named 'nonexistent'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_agent_and_default() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "first"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "second"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
|
assert_eq!(config.default_agent().unwrap().name, "first");
|
||||||
|
assert_eq!(config.find_agent("second").unwrap().name, "second");
|
||||||
|
assert!(config.find_agent("missing").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_project_toml_from_file() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let sk = tmp.path().join(".story_kit");
|
let sk = tmp.path().join(".story_kit");
|
||||||
fs::create_dir_all(&sk).unwrap();
|
fs::create_dir_all(&sk).unwrap();
|
||||||
@@ -117,10 +472,12 @@ name = "frontend"
|
|||||||
path = "frontend"
|
path = "frontend"
|
||||||
setup = ["pnpm install"]
|
setup = ["pnpm install"]
|
||||||
|
|
||||||
[agent]
|
[[agent]]
|
||||||
|
name = "main"
|
||||||
command = "claude"
|
command = "claude"
|
||||||
args = ["--print", "--directory", "{{worktree_path}}"]
|
args = ["--print", "--directory", "{{worktree_path}}"]
|
||||||
prompt = "Pick up story {{story_id}}"
|
prompt = "Pick up story {{story_id}}"
|
||||||
|
model = "sonnet"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -129,17 +486,8 @@ prompt = "Pick up story {{story_id}}"
|
|||||||
assert_eq!(config.component.len(), 2);
|
assert_eq!(config.component.len(), 2);
|
||||||
assert_eq!(config.component[0].name, "server");
|
assert_eq!(config.component[0].name, "server");
|
||||||
assert_eq!(config.component[1].setup, vec!["pnpm install"]);
|
assert_eq!(config.component[1].setup, vec!["pnpm install"]);
|
||||||
|
assert_eq!(config.agent.len(), 1);
|
||||||
let agent = config.agent.unwrap();
|
assert_eq!(config.agent[0].name, "main");
|
||||||
assert_eq!(agent.command, "claude");
|
assert_eq!(config.agent[0].model, Some("sonnet".to_string()));
|
||||||
}
|
|
||||||
|
|
||||||
#[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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::config::ProjectConfig;
|
||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -9,18 +10,36 @@ enum AgentsTags {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Object)]
|
#[derive(Object)]
|
||||||
struct StoryIdPayload {
|
struct StartAgentPayload {
|
||||||
story_id: String,
|
story_id: String,
|
||||||
|
agent_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct StopAgentPayload {
|
||||||
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Object, Serialize)]
|
#[derive(Object, Serialize)]
|
||||||
struct AgentInfoResponse {
|
struct AgentInfoResponse {
|
||||||
story_id: String,
|
story_id: String,
|
||||||
|
agent_name: String,
|
||||||
status: String,
|
status: String,
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
worktree_path: Option<String>,
|
worktree_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Object, Serialize)]
|
||||||
|
struct AgentConfigInfoResponse {
|
||||||
|
name: String,
|
||||||
|
role: String,
|
||||||
|
model: Option<String>,
|
||||||
|
allowed_tools: Option<Vec<String>>,
|
||||||
|
max_turns: Option<u32>,
|
||||||
|
max_budget_usd: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AgentsApi {
|
pub struct AgentsApi {
|
||||||
pub ctx: Arc<AppContext>,
|
pub ctx: Arc<AppContext>,
|
||||||
}
|
}
|
||||||
@@ -28,10 +47,11 @@ pub struct AgentsApi {
|
|||||||
#[OpenApi(tag = "AgentsTags::Agents")]
|
#[OpenApi(tag = "AgentsTags::Agents")]
|
||||||
impl AgentsApi {
|
impl AgentsApi {
|
||||||
/// Start an agent for a given story (creates worktree, runs setup, spawns agent).
|
/// Start an agent for a given story (creates worktree, runs setup, spawns agent).
|
||||||
|
/// If agent_name is omitted, the first configured agent is used.
|
||||||
#[oai(path = "/agents/start", method = "post")]
|
#[oai(path = "/agents/start", method = "post")]
|
||||||
async fn start_agent(
|
async fn start_agent(
|
||||||
&self,
|
&self,
|
||||||
payload: Json<StoryIdPayload>,
|
payload: Json<StartAgentPayload>,
|
||||||
) -> OpenApiResult<Json<AgentInfoResponse>> {
|
) -> OpenApiResult<Json<AgentInfoResponse>> {
|
||||||
let project_root = self
|
let project_root = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -42,12 +62,17 @@ impl AgentsApi {
|
|||||||
let info = self
|
let info = self
|
||||||
.ctx
|
.ctx
|
||||||
.agents
|
.agents
|
||||||
.start_agent(&project_root, &payload.0.story_id)
|
.start_agent(
|
||||||
|
&project_root,
|
||||||
|
&payload.0.story_id,
|
||||||
|
payload.0.agent_name.as_deref(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
Ok(Json(AgentInfoResponse {
|
Ok(Json(AgentInfoResponse {
|
||||||
story_id: info.story_id,
|
story_id: info.story_id,
|
||||||
|
agent_name: info.agent_name,
|
||||||
status: info.status.to_string(),
|
status: info.status.to_string(),
|
||||||
session_id: info.session_id,
|
session_id: info.session_id,
|
||||||
worktree_path: info.worktree_path,
|
worktree_path: info.worktree_path,
|
||||||
@@ -56,7 +81,7 @@ impl AgentsApi {
|
|||||||
|
|
||||||
/// Stop a running agent and clean up its worktree.
|
/// Stop a running agent and clean up its worktree.
|
||||||
#[oai(path = "/agents/stop", method = "post")]
|
#[oai(path = "/agents/stop", method = "post")]
|
||||||
async fn stop_agent(&self, payload: Json<StoryIdPayload>) -> OpenApiResult<Json<bool>> {
|
async fn stop_agent(&self, payload: Json<StopAgentPayload>) -> OpenApiResult<Json<bool>> {
|
||||||
let project_root = self
|
let project_root = self
|
||||||
.ctx
|
.ctx
|
||||||
.agents
|
.agents
|
||||||
@@ -65,7 +90,11 @@ impl AgentsApi {
|
|||||||
|
|
||||||
self.ctx
|
self.ctx
|
||||||
.agents
|
.agents
|
||||||
.stop_agent(&project_root, &payload.0.story_id)
|
.stop_agent(
|
||||||
|
&project_root,
|
||||||
|
&payload.0.story_id,
|
||||||
|
&payload.0.agent_name,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
@@ -82,6 +111,7 @@ impl AgentsApi {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|info| AgentInfoResponse {
|
.map(|info| AgentInfoResponse {
|
||||||
story_id: info.story_id,
|
story_id: info.story_id,
|
||||||
|
agent_name: info.agent_name,
|
||||||
status: info.status.to_string(),
|
status: info.status.to_string(),
|
||||||
session_id: info.session_id,
|
session_id: info.session_id,
|
||||||
worktree_path: info.worktree_path,
|
worktree_path: info.worktree_path,
|
||||||
@@ -89,4 +119,62 @@ impl AgentsApi {
|
|||||||
.collect(),
|
.collect(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the configured agent roster from project.toml.
|
||||||
|
#[oai(path = "/agents/config", method = "get")]
|
||||||
|
async fn get_agent_config(
|
||||||
|
&self,
|
||||||
|
) -> OpenApiResult<Json<Vec<AgentConfigInfoResponse>>> {
|
||||||
|
let project_root = self
|
||||||
|
.ctx
|
||||||
|
.agents
|
||||||
|
.get_project_root(&self.ctx.state)
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
|
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
config
|
||||||
|
.agent
|
||||||
|
.iter()
|
||||||
|
.map(|a| AgentConfigInfoResponse {
|
||||||
|
name: a.name.clone(),
|
||||||
|
role: a.role.clone(),
|
||||||
|
model: a.model.clone(),
|
||||||
|
allowed_tools: a.allowed_tools.clone(),
|
||||||
|
max_turns: a.max_turns,
|
||||||
|
max_budget_usd: a.max_budget_usd,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload project config and return the updated agent roster.
|
||||||
|
#[oai(path = "/agents/config/reload", method = "post")]
|
||||||
|
async fn reload_config(
|
||||||
|
&self,
|
||||||
|
) -> OpenApiResult<Json<Vec<AgentConfigInfoResponse>>> {
|
||||||
|
let project_root = self
|
||||||
|
.ctx
|
||||||
|
.agents
|
||||||
|
.get_project_root(&self.ctx.state)
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
|
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
config
|
||||||
|
.agent
|
||||||
|
.iter()
|
||||||
|
.map(|a| AgentConfigInfoResponse {
|
||||||
|
name: a.name.clone(),
|
||||||
|
role: a.role.clone(),
|
||||||
|
model: a.model.clone(),
|
||||||
|
allowed_tools: a.allowed_tools.clone(),
|
||||||
|
max_turns: a.max_turns,
|
||||||
|
max_budget_usd: a.max_budget_usd,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ use poem::web::{Data, Path};
|
|||||||
use poem::{Body, IntoResponse, Response};
|
use poem::{Body, IntoResponse, Response};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// SSE endpoint: `GET /agents/:story_id/stream`
|
/// SSE endpoint: `GET /agents/:story_id/:agent_name/stream`
|
||||||
///
|
///
|
||||||
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
|
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
|
||||||
/// with `data:` prefix and double newline terminator per the SSE spec.
|
/// with `data:` prefix and double newline terminator per the SSE spec.
|
||||||
#[handler]
|
#[handler]
|
||||||
pub async fn agent_stream(
|
pub async fn agent_stream(
|
||||||
Path(story_id): Path<String>,
|
Path((story_id, agent_name)): Path<(String, String)>,
|
||||||
ctx: Data<&Arc<AppContext>>,
|
ctx: Data<&Arc<AppContext>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut rx = match ctx.agents.subscribe(&story_id) {
|
let mut rx = match ctx.agents.subscribe(&story_id, &agent_name) {
|
||||||
Ok(rx) => rx,
|
Ok(rx) => rx,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
|||||||
.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(
|
.at(
|
||||||
"/agents/:story_id/stream",
|
"/agents/:story_id/:agent_name/stream",
|
||||||
get(agents_sse::agent_stream),
|
get(agents_sse::agent_stream),
|
||||||
)
|
)
|
||||||
.at("/health", get(health::health))
|
.at("/health", get(health::health))
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ fn remove_port_file(path: &Path) {
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
let app_state = Arc::new(SessionState::default());
|
let app_state = Arc::new(SessionState::default());
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
*app_state.project_root.lock().unwrap() = Some(cwd.clone());
|
||||||
let store = Arc::new(
|
let store = Arc::new(
|
||||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
||||||
);
|
);
|
||||||
@@ -69,7 +71,10 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m");
|
println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m");
|
||||||
println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m");
|
println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m");
|
||||||
|
|
||||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
// Validate agent config at startup — panic on invalid project.toml.
|
||||||
|
config::ProjectConfig::load(&cwd)
|
||||||
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||||
|
|
||||||
let port_file = write_port_file(&cwd, port);
|
let port_file = write_port_file(&cwd, port);
|
||||||
|
|
||||||
let result = Server::new(TcpListener::bind(&addr)).run(app).await;
|
let result = Server::new(TcpListener::bind(&addr)).run(app).await;
|
||||||
@@ -100,6 +105,28 @@ mod tests {
|
|||||||
assert_eq!(parse_port(Some("not_a_number".to_string())), 3001);
|
assert_eq!(parse_port(Some("not_a_number".to_string())), 3001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "Invalid project.toml: Duplicate agent name")]
|
||||||
|
fn panics_on_duplicate_agent_names() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
std::fs::create_dir_all(&sk).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
config::ProjectConfig::load(tmp.path())
|
||||||
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_and_remove_port_file() {
|
fn write_and_remove_port_file() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user