feat(FN-1085): align agent routing and runtime contracts
- Harden core AgentStore lifecycle behavior and heartbeat runtime integration paths - Align dashboard agent APIs, server routes, and agent UI flows with the updated contract - Tighten CLI agent/message command routing and validate payload handling semantics - Expand test coverage across core, dashboard, engine, and CLI for route, heartbeat, and instruction regressions
This commit is contained in:
@@ -262,7 +262,6 @@ describe("Agent runs routes (without HeartbeatMonitor)", () => {
|
||||
describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
let mockStartRun: ReturnType<typeof vi.fn>;
|
||||
let mockExecuteHeartbeat: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -271,14 +270,12 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
mockGetActiveHeartbeatRun.mockResolvedValue(null);
|
||||
|
||||
mockStartRun = vi.fn();
|
||||
mockExecuteHeartbeat = vi.fn();
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any, {
|
||||
heartbeatMonitor: {
|
||||
startRun: mockStartRun,
|
||||
executeHeartbeat: mockExecuteHeartbeat,
|
||||
},
|
||||
});
|
||||
@@ -289,9 +286,8 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
it("delegates to heartbeatMonitor.startRun when available", async () => {
|
||||
it("delegates to heartbeatMonitor.executeHeartbeat when available", async () => {
|
||||
const mockRun = createMockRun({ invocationSource: "on_demand", triggerDetail: "Triggered from dashboard" });
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue({ ...mockRun, status: "completed" });
|
||||
|
||||
const response = await request(
|
||||
@@ -303,26 +299,20 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
},
|
||||
});
|
||||
// executeHeartbeat should be called fire-and-forget
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
taskId: undefined,
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes custom source and triggerDetail to heartbeatMonitor", async () => {
|
||||
const mockRun = createMockRun();
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue(mockRun);
|
||||
|
||||
await request(
|
||||
@@ -333,9 +323,11 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
taskId: undefined,
|
||||
contextSnapshot: {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
@@ -349,7 +341,6 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
|
||||
mockRecordHeartbeat.mockResolvedValue(mockEvent);
|
||||
const mockRun = createMockRun({ invocationSource: "on_demand" });
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue(mockRun);
|
||||
|
||||
const response = await request(
|
||||
@@ -361,8 +352,15 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartRun).toHaveBeenCalled();
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalled();
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
},
|
||||
});
|
||||
// Response should include both event and run
|
||||
expect((response.body as any).event).toBeDefined();
|
||||
expect((response.body as any).run).toBeDefined();
|
||||
|
||||
@@ -7876,6 +7876,131 @@ describe("POST /workflow-step-templates/:id/create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent create/update routes", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agents-fields-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "Initial Agent",
|
||||
role: "executor",
|
||||
});
|
||||
agentId = agent.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildAgentApp() {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("POST /api/agents accepts all AgentCreateInput fields", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
"/api/agents",
|
||||
JSON.stringify({
|
||||
name: "Full Agent",
|
||||
role: "reviewer",
|
||||
metadata: { team: "qa" },
|
||||
title: "QA Reviewer",
|
||||
icon: "🧪",
|
||||
reportsTo: agentId,
|
||||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||||
permissions: { read: true },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
name: "Full Agent",
|
||||
role: "reviewer",
|
||||
metadata: { team: "qa" },
|
||||
title: "QA Reviewer",
|
||||
icon: "🧪",
|
||||
reportsTo: agentId,
|
||||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||||
permissions: { read: true },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCH /api/agents/:id accepts all AgentUpdateInput fields", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"PATCH",
|
||||
`/api/agents/${agentId}`,
|
||||
JSON.stringify({
|
||||
name: "Updated Agent",
|
||||
role: "engineer",
|
||||
metadata: { area: "infra" },
|
||||
title: "Infra Engineer",
|
||||
icon: "⚙️",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||||
pauseReason: "manual",
|
||||
permissions: { deploy: true },
|
||||
totalInputTokens: 42,
|
||||
totalOutputTokens: 21,
|
||||
instructionsPath: "agents/infra.md",
|
||||
instructionsText: "Focus on reliability.",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: agentId,
|
||||
name: "Updated Agent",
|
||||
role: "engineer",
|
||||
metadata: { area: "infra" },
|
||||
title: "Infra Engineer",
|
||||
icon: "⚙️",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||||
pauseReason: "manual",
|
||||
permissions: { deploy: true },
|
||||
totalInputTokens: 42,
|
||||
totalOutputTokens: 21,
|
||||
instructionsPath: "agents/infra.md",
|
||||
instructionsText: "Focus on reliability.",
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/state`,
|
||||
JSON.stringify({ state: "terminated" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid state transition");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
|
||||
@@ -6893,14 +6893,69 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
function validateAgentInstructionsPayload(
|
||||
res: Response,
|
||||
instructionsPath: unknown,
|
||||
instructionsText: unknown,
|
||||
): boolean {
|
||||
if (instructionsPath !== undefined && instructionsPath !== null && instructionsPath !== "") {
|
||||
if (typeof instructionsPath !== "string") {
|
||||
res.status(400).json({ error: "instructionsPath must be a string" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsPath.length > 500) {
|
||||
res.status(400).json({ error: "instructionsPath must be at most 500 characters" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsPath.includes("..")) {
|
||||
res.status(400).json({ error: "instructionsPath must not contain parent directory traversal (..)" });
|
||||
return false;
|
||||
}
|
||||
const isAbsoluteUnix = instructionsPath.startsWith("/");
|
||||
const isAbsoluteWindows = /^[A-Za-z]:[\\/]/.test(instructionsPath);
|
||||
if (isAbsoluteUnix || isAbsoluteWindows) {
|
||||
res.status(400).json({ error: "instructionsPath must be a project-relative path" });
|
||||
return false;
|
||||
}
|
||||
if (!instructionsPath.endsWith(".md")) {
|
||||
res.status(400).json({ error: "instructionsPath must end in .md" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (instructionsText !== undefined && instructionsText !== null && instructionsText !== "") {
|
||||
if (typeof instructionsText !== "string") {
|
||||
res.status(400).json({ error: "instructionsText must be a string" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsText.length > 50000) {
|
||||
res.status(400).json({ error: "instructionsText must be at most 50,000 characters" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/agents
|
||||
* Create a new agent.
|
||||
* Body: { name: string, role: string, metadata?: object }
|
||||
*/
|
||||
router.post("/agents", async (req, res) => {
|
||||
try {
|
||||
const { name, role, metadata } = req.body;
|
||||
const {
|
||||
name,
|
||||
role,
|
||||
metadata,
|
||||
title,
|
||||
icon,
|
||||
reportsTo,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
instructionsPath,
|
||||
instructionsText,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
@@ -6909,16 +6964,58 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
res.status(400).json({ error: "role is required" });
|
||||
return;
|
||||
}
|
||||
if (metadata !== undefined && (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))) {
|
||||
res.status(400).json({ error: "metadata must be an object" });
|
||||
return;
|
||||
}
|
||||
if (title !== undefined && title !== null && typeof title !== "string") {
|
||||
res.status(400).json({ error: "title must be a string" });
|
||||
return;
|
||||
}
|
||||
if (icon !== undefined && icon !== null && typeof icon !== "string") {
|
||||
res.status(400).json({ error: "icon must be a string" });
|
||||
return;
|
||||
}
|
||||
if (reportsTo !== undefined && reportsTo !== null && typeof reportsTo !== "string") {
|
||||
res.status(400).json({ error: "reportsTo must be a string" });
|
||||
return;
|
||||
}
|
||||
if (runtimeConfig !== undefined && (typeof runtimeConfig !== "object" || runtimeConfig === null || Array.isArray(runtimeConfig))) {
|
||||
res.status(400).json({ error: "runtimeConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
if (permissions !== undefined && (typeof permissions !== "object" || permissions === null || Array.isArray(permissions))) {
|
||||
res.status(400).json({ error: "permissions must be an object" });
|
||||
return;
|
||||
}
|
||||
if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.createAgent({ name, role: role as import("@fusion/core").AgentCapability, metadata });
|
||||
const agent = await agentStore.createAgent({
|
||||
name,
|
||||
role: role as import("@fusion/core").AgentCapability,
|
||||
metadata,
|
||||
title: title ?? undefined,
|
||||
icon: icon ?? undefined,
|
||||
reportsTo: reportsTo ?? undefined,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
});
|
||||
res.status(201).json(agent);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
if (err.message?.includes("required") || err.message?.includes("cannot be empty")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7079,18 +7176,119 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
*/
|
||||
router.patch("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const { name, role, metadata, runtimeConfig } = req.body;
|
||||
const body = req.body ?? {};
|
||||
const updates: import("@fusion/core").AgentUpdateInput = {};
|
||||
|
||||
if ("name" in body) {
|
||||
if (body.name !== null && typeof body.name !== "string") {
|
||||
res.status(400).json({ error: "name must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.name = body.name ?? undefined;
|
||||
}
|
||||
|
||||
if ("role" in body) {
|
||||
if (body.role !== null && typeof body.role !== "string") {
|
||||
res.status(400).json({ error: "role must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.role = body.role ?? undefined;
|
||||
}
|
||||
|
||||
if ("metadata" in body) {
|
||||
if (body.metadata !== null && (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
|
||||
res.status(400).json({ error: "metadata must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.metadata = body.metadata ?? undefined;
|
||||
}
|
||||
|
||||
if ("title" in body) {
|
||||
if (body.title !== null && typeof body.title !== "string") {
|
||||
res.status(400).json({ error: "title must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.title = body.title ?? undefined;
|
||||
}
|
||||
|
||||
if ("icon" in body) {
|
||||
if (body.icon !== null && typeof body.icon !== "string") {
|
||||
res.status(400).json({ error: "icon must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.icon = body.icon ?? undefined;
|
||||
}
|
||||
|
||||
if ("reportsTo" in body) {
|
||||
if (body.reportsTo !== null && typeof body.reportsTo !== "string") {
|
||||
res.status(400).json({ error: "reportsTo must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.reportsTo = body.reportsTo ?? undefined;
|
||||
}
|
||||
|
||||
if ("pauseReason" in body) {
|
||||
if (body.pauseReason !== null && typeof body.pauseReason !== "string") {
|
||||
res.status(400).json({ error: "pauseReason must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.pauseReason = body.pauseReason ?? undefined;
|
||||
}
|
||||
|
||||
if ("runtimeConfig" in body) {
|
||||
if (body.runtimeConfig !== null && (typeof body.runtimeConfig !== "object" || Array.isArray(body.runtimeConfig))) {
|
||||
res.status(400).json({ error: "runtimeConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.runtimeConfig = body.runtimeConfig ?? undefined;
|
||||
}
|
||||
|
||||
if ("permissions" in body) {
|
||||
if (body.permissions !== null && (typeof body.permissions !== "object" || Array.isArray(body.permissions))) {
|
||||
res.status(400).json({ error: "permissions must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.permissions = body.permissions ?? undefined;
|
||||
}
|
||||
|
||||
if ("totalInputTokens" in body) {
|
||||
if (body.totalInputTokens !== null && typeof body.totalInputTokens !== "number") {
|
||||
res.status(400).json({ error: "totalInputTokens must be a number" });
|
||||
return;
|
||||
}
|
||||
updates.totalInputTokens = body.totalInputTokens ?? undefined;
|
||||
}
|
||||
|
||||
if ("totalOutputTokens" in body) {
|
||||
if (body.totalOutputTokens !== null && typeof body.totalOutputTokens !== "number") {
|
||||
res.status(400).json({ error: "totalOutputTokens must be a number" });
|
||||
return;
|
||||
}
|
||||
updates.totalOutputTokens = body.totalOutputTokens ?? undefined;
|
||||
}
|
||||
|
||||
if (!validateAgentInstructionsPayload(res, body.instructionsPath, body.instructionsText)) {
|
||||
return;
|
||||
}
|
||||
if ("instructionsPath" in body) {
|
||||
updates.instructionsPath = body.instructionsPath ?? undefined;
|
||||
}
|
||||
if ("instructionsText" in body) {
|
||||
updates.instructionsText = body.instructionsText ?? undefined;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.updateAgent(req.params.id, { name, role, metadata, runtimeConfig });
|
||||
const agent = await agentStore.updateAgent(req.params.id, updates);
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.message?.includes("cannot be empty")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -7104,38 +7302,9 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
*/
|
||||
router.patch("/agents/:id/instructions", async (req, res) => {
|
||||
try {
|
||||
const { instructionsPath, instructionsText } = req.body;
|
||||
|
||||
// Validate instructionsPath if provided
|
||||
if (instructionsPath !== undefined && instructionsPath !== "") {
|
||||
if (typeof instructionsPath !== "string") {
|
||||
res.status(400).json({ error: "instructionsPath must be a string" });
|
||||
return;
|
||||
}
|
||||
if (instructionsPath.length > 500) {
|
||||
res.status(400).json({ error: "instructionsPath must be at most 500 characters" });
|
||||
return;
|
||||
}
|
||||
if (instructionsPath.includes("..")) {
|
||||
res.status(400).json({ error: "instructionsPath must not contain parent directory traversal (..)" });
|
||||
return;
|
||||
}
|
||||
if (!instructionsPath.endsWith(".md")) {
|
||||
res.status(400).json({ error: "instructionsPath must end in .md" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate instructionsText if provided
|
||||
if (instructionsText !== undefined && instructionsText !== "") {
|
||||
if (typeof instructionsText !== "string") {
|
||||
res.status(400).json({ error: "instructionsText must be a string" });
|
||||
return;
|
||||
}
|
||||
if (instructionsText.length > 50000) {
|
||||
res.status(400).json({ error: "instructionsText must be at most 50,000 characters" });
|
||||
return;
|
||||
}
|
||||
const { instructionsPath, instructionsText } = req.body ?? {};
|
||||
if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
@@ -7143,7 +7312,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.updateAgent(req.params.id, { instructionsPath, instructionsText });
|
||||
const agent = await agentStore.updateAgent(req.params.id, {
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
});
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
@@ -7177,7 +7349,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.message?.includes("Invalid state transition") || err.message?.includes("Cannot transition from terminated")) {
|
||||
} else if (/invalid state transition/i.test(err.message ?? "")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -7316,7 +7488,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
* Body: { status?: "ok"|"missed"|"recovered", triggerExecution?: boolean }
|
||||
*
|
||||
* When triggerExecution is true AND HeartbeatMonitor is available,
|
||||
* also starts a heartbeat run after recording the heartbeat event.
|
||||
* also executes a heartbeat run after recording the heartbeat event.
|
||||
*/
|
||||
router.post("/agents/:id/heartbeat", async (req, res) => {
|
||||
try {
|
||||
@@ -7332,18 +7504,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
// Optionally trigger execution
|
||||
let run: import("@fusion/core").AgentHeartbeatRun | undefined;
|
||||
if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor) {
|
||||
run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
});
|
||||
|
||||
// Fire-and-forget execution
|
||||
void heartbeatMonitor.executeHeartbeat({
|
||||
run = await heartbeatMonitor.executeHeartbeat({
|
||||
agentId: req.params.id,
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7407,11 +7575,9 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
* Manually start a heartbeat run for an agent.
|
||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string, taskId?: string }
|
||||
*
|
||||
* When HeartbeatMonitor is available, delegates to startRun() which enriches
|
||||
* the run with execution context, transitions the agent to "running", and
|
||||
* fires the onRunStarted event. The route returns the run immediately with
|
||||
* "active" status while execution continues in the background via
|
||||
* executeHeartbeat() fire-and-forget.
|
||||
* When HeartbeatMonitor is available, delegates to executeHeartbeat() with
|
||||
* a structured wake context snapshot. This ensures a single authoritative run
|
||||
* record is created and fully completed without duplicate startRun calls.
|
||||
*
|
||||
* Returns 409 Conflict if the agent already has an active run.
|
||||
*/
|
||||
@@ -7443,21 +7609,13 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to HeartbeatMonitor for enriched run creation
|
||||
const run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
contextSnapshot,
|
||||
});
|
||||
|
||||
// Fire-and-forget execution in the background
|
||||
void heartbeatMonitor.executeHeartbeat({
|
||||
// Execute heartbeat end-to-end (single run record, no duplicate startRun call)
|
||||
const run = await heartbeatMonitor.executeHeartbeat({
|
||||
agentId: req.params.id,
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
taskId,
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
contextSnapshot,
|
||||
});
|
||||
|
||||
res.status(201).json(run);
|
||||
@@ -7479,7 +7637,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
// Enrich with invocation source, trigger detail, and context snapshot
|
||||
(run as any).invocationSource = invocationSource;
|
||||
(run as any).triggerDetail = triggerDetail;
|
||||
(run as any).triggerDetail = trigger;
|
||||
(run as any).contextSnapshot = contextSnapshot;
|
||||
|
||||
await agentStore.saveRun(run);
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface ServerOptions {
|
||||
/** Optional HeartbeatMonitor for triggering agent execution runs */
|
||||
heartbeatMonitor?: {
|
||||
startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user