feat(FN-941): add POST /api/agents/:id/runs endpoint for manual heartbeat runs
- Add POST /api/agents/:id/runs route to trigger manual agent heartbeat runs
- Accept optional { reason } in request body, logged to agent run
- Validate agent exists and return 404 for unknown agent IDs
- Return run result (success/failure) with heartbeat metadata
- Add comprehensive tests covering success, not-found, and error scenarios
This commit is contained in:
@@ -6864,3 +6864,107 @@ describe("POST /workflow-step-templates/:id/create", () => {
|
||||
expect(res.body.error).toContain("already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-runs-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
// Create a real agent in the temp directory so AgentStore can find it
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
});
|
||||
agentId = agent.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
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("returns 201 with created run for valid agent", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
id: expect.stringMatching(/^run-/),
|
||||
agentId,
|
||||
status: "active",
|
||||
endedAt: null,
|
||||
invocationSource: "on_demand",
|
||||
});
|
||||
expect(res.body.startedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists the run via saveRun", async () => {
|
||||
await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||||
|
||||
// Verify run was persisted to filesystem
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const runs = await agentStore.getRecentRuns(agentId);
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].invocationSource).toBe("on_demand");
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent agent", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/agents/agent-nonexistent/runs");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("uses default invocationSource when no body provided", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/runs`);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.invocationSource).toBe("on_demand");
|
||||
});
|
||||
|
||||
it("uses custom source and triggerDetail from body", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/runs`,
|
||||
JSON.stringify({ source: "timer", triggerDetail: "cron schedule" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.invocationSource).toBe("timer");
|
||||
expect(res.body.triggerDetail).toBe("cron schedule");
|
||||
});
|
||||
|
||||
it("returns 500 on store error", async () => {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue("/nonexistent/path/that/does/not/exist"),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// This should hit an error because the agent doesn't exist in that path
|
||||
const res = await REQUEST(app, "POST", `/api/agents/${agentId}/runs`);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6673,6 +6673,43 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/runs
|
||||
* Manually start a heartbeat run for an agent.
|
||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string }
|
||||
*/
|
||||
router.post("/agents/:id/runs", async (req, res) => {
|
||||
try {
|
||||
const { source, triggerDetail } = req.body || {};
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const run = await agentStore.startHeartbeatRun(req.params.id);
|
||||
|
||||
// Enrich with invocation source and trigger detail
|
||||
if (source) {
|
||||
(run as any).invocationSource = source;
|
||||
} else {
|
||||
(run as any).invocationSource = "on_demand";
|
||||
}
|
||||
if (triggerDetail) {
|
||||
(run as any).triggerDetail = triggerDetail;
|
||||
}
|
||||
|
||||
await agentStore.saveRun(run);
|
||||
res.status(201).json(run);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/runs/:runId
|
||||
* Get detail for a specific agent run.
|
||||
|
||||
Reference in New Issue
Block a user