feat(FN-1298): add stop-agent-run flow across engine and dashboard
- Add heartbeat stopRun support in the engine and wire it into the dashboard server lifecycle - Add an agent run stop API route and client helper with route-level test coverage - Add stop controls in AgentDetailView and AgentRunHistory with updated UI tests - Add a changeset for @gsxdsm/fusion documenting the new stop run feature
This commit is contained in:
@@ -8,10 +8,12 @@ import * as path from "path";
|
||||
|
||||
const agentsViewPath = path.join(__dirname, "../components/AgentsView.tsx");
|
||||
const agentDetailViewPath = path.join(__dirname, "../components/AgentDetailView.tsx");
|
||||
const agentRunHistoryPath = path.join(__dirname, "../components/AgentRunHistory.tsx");
|
||||
const apiPath = path.join(__dirname, "../api.ts");
|
||||
|
||||
const agentsViewContent = fs.readFileSync(agentsViewPath, "utf-8");
|
||||
const agentDetailViewContent = fs.readFileSync(agentDetailViewPath, "utf-8");
|
||||
const agentRunHistoryContent = fs.readFileSync(agentRunHistoryPath, "utf-8");
|
||||
const apiContent = fs.readFileSync(apiPath, "utf-8");
|
||||
|
||||
describe("Agent runs UI — static analysis", () => {
|
||||
@@ -22,6 +24,11 @@ describe("Agent runs UI — static analysis", () => {
|
||||
expect(apiContent).toMatch(/options\?\.\s*triggerDetail/);
|
||||
});
|
||||
|
||||
it("exports stopAgentRun function", () => {
|
||||
expect(apiContent).toMatch(/export function stopAgentRun\s*\(/);
|
||||
expect(apiContent).toMatch(/\/runs\/stop/);
|
||||
});
|
||||
|
||||
it("exports HeartbeatInvocationSource type", () => {
|
||||
expect(apiContent).toMatch(/export type.*HeartbeatInvocationSource/);
|
||||
});
|
||||
@@ -121,5 +128,26 @@ describe("Agent runs UI — static analysis", () => {
|
||||
it("has no-runs empty state", () => {
|
||||
expect(agentDetailViewContent).toContain("No runs yet");
|
||||
});
|
||||
|
||||
it("wires a stop run handler", () => {
|
||||
expect(agentDetailViewContent).toMatch(/handleStopRun|handleStop/);
|
||||
expect(agentDetailViewContent).toMatch(/confirm\("Stop the active run\?/);
|
||||
});
|
||||
|
||||
it("references stopAgentRun and stop button copy", () => {
|
||||
expect(agentDetailViewContent).toContain("stopAgentRun");
|
||||
expect(agentDetailViewContent).toMatch(/Stop Run|Stop active run/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentRunHistory", () => {
|
||||
it("imports stopAgentRun", () => {
|
||||
expect(agentRunHistoryContent).toMatch(/import\s*\{[^}]*stopAgentRun[^}]*\}\s*from\s*"\.\.\/api"/);
|
||||
});
|
||||
|
||||
it("renders stop control for active runs", () => {
|
||||
expect(agentRunHistoryContent).toMatch(/run\.status === "active"/);
|
||||
expect(agentRunHistoryContent).toMatch(/Stop this run\?/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2110,6 +2110,19 @@ export function startAgentRun(
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop an active heartbeat run for an agent */
|
||||
export function stopAgentRun(
|
||||
agentId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ ok: boolean; runId?: string; message?: string }> {
|
||||
return api<{ ok: boolean; runId?: string; message?: string }>(
|
||||
withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/stop`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch aggregate agent stats */
|
||||
export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
||||
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ChevronDown, ChevronRight
|
||||
} from "lucide-react";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -877,6 +877,21 @@ function RunsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopRun = async () => {
|
||||
if (!confirm("Stop the active run? The agent's work will be interrupted.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stopAgentRun(agentId, projectId);
|
||||
addToast("Run stopped", "success");
|
||||
setIsLoadingRuns(true);
|
||||
void loadRuns();
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to stop run: ${err.message}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const canRunHeartbeat = agentState === "active" || agentState === "idle";
|
||||
|
||||
if (isLoadingRuns && runs.length === 0) {
|
||||
@@ -974,6 +989,19 @@ function RunsTab({
|
||||
{run.invocationSource}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleStopRun();
|
||||
}}
|
||||
aria-label="Stop active run"
|
||||
>
|
||||
<Square size={12} /> Stop
|
||||
</button>
|
||||
)}
|
||||
<span className={cn("run-status", run.status)}>
|
||||
<StatusIcon size={14} className={statusInfo.color} style={run.status === "active" ? { color: statusInfo.color } : undefined} />
|
||||
{run.status}
|
||||
@@ -1147,13 +1175,24 @@ function RunsTab({
|
||||
{runs.length} run{runs.length !== 1 ? "s" : ""}
|
||||
{hasActiveRun && <span className="run-live-indicator" style={{ marginLeft: "8px" }}><span className="live-dot" />Live</span>}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn--sm btn--primary"
|
||||
onClick={() => void handleRunHeartbeat()}
|
||||
aria-label={`Run heartbeat for ${agentName ?? agentId}`}
|
||||
>
|
||||
<Activity size={14} /> Run Heartbeat
|
||||
</button>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
{hasActiveRun && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStopRun()}
|
||||
aria-label={`Stop active run for ${agentName ?? agentId}`}
|
||||
>
|
||||
<Square size={14} /> Stop Run
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn--sm btn--primary"
|
||||
onClick={() => void handleRunHeartbeat()}
|
||||
aria-label={`Run heartbeat for ${agentName ?? agentId}`}
|
||||
>
|
||||
<Activity size={14} /> Run Heartbeat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeRuns.map((run, i) => renderRunCard(run, i, true))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { CheckCircle, XCircle, Loader2, Square, Clock } from "lucide-react";
|
||||
import type { AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgentRuns } from "../api";
|
||||
import { fetchAgentRuns, stopAgentRun } from "../api";
|
||||
|
||||
interface AgentRunHistoryProps {
|
||||
agentId: string;
|
||||
@@ -21,14 +21,35 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
|
||||
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRuns = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
fetchAgentRuns(agentId, 50, projectId)
|
||||
.then(setRuns)
|
||||
.catch(() => setRuns([]))
|
||||
.finally(() => setIsLoading(false));
|
||||
try {
|
||||
const data = await fetchAgentRuns(agentId, 50, projectId);
|
||||
setRuns(data);
|
||||
} catch {
|
||||
setRuns([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [agentId, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRuns();
|
||||
}, [loadRuns]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!confirm("Stop this run?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stopAgentRun(agentId, projectId);
|
||||
await loadRuns();
|
||||
} catch {
|
||||
// No-op: keep history view usable even if stop fails.
|
||||
}
|
||||
}, [agentId, projectId, loadRuns]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="agent-run-loading"><Loader2 className="animate-spin" size={20} /> Loading runs...</div>;
|
||||
}
|
||||
@@ -80,6 +101,20 @@ export function AgentRunHistory({ agentId, projectId, onRunClick }: AgentRunHist
|
||||
<span className="badge text-secondary">{run.triggerDetail}</span>
|
||||
)}
|
||||
</div>
|
||||
{run.status === "active" && (
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleStop();
|
||||
}}
|
||||
aria-label="Stop run"
|
||||
style={{ marginLeft: "8px" }}
|
||||
>
|
||||
<Square size={12} /> Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user