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:
gsxdsm
2026-04-04 18:31:48 -07:00
parent e105da2a6e
commit 0c969e2789
2 changed files with 141 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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.