feat(FN-1274): add routine CRUD API routes and frontend functions
- Add routineStore to ServerOptions for dependency injection - Implement CRUD API routes for routines (create, list, get, update, delete) - Add frontend API functions for routine management - Include comprehensive route tests for all routine endpoints - Update memory documentation with webhook HMAC testing pattern
This commit is contained in:
@@ -105,6 +105,7 @@ The `PluginRunner` bridges the plugin core system with the Fusion engine runtime
|
||||
## Pitfalls
|
||||
|
||||
- When adding props to a React component interface that were previously declared but not destructured in the function body, remember to add them to the destructuring list too. TypeScript won't warn about unused interface fields, so `onOpenScripts` in `MobileNavBarProps` compiled fine but caused `ReferenceError: onOpenScripts is not defined` at runtime.
|
||||
- **Webhook HMAC testing**: The `REQUEST` test utility in `test-request.ts` doesn't handle stream-based middleware like `express.raw()` well. For webhook routes requiring HMAC verification (e.g., GitHub webhooks, routine webhooks), test the `verifyWebhookSignature` function directly using `await import()` rather than trying to set up raw body middleware through Express. See the routine webhook tests in `routes.test.ts` for the pattern.
|
||||
|
||||
- `vi.fn<Parameters<SomeType>, ReturnType<SomeType>>()` works in Vitest runtime but causes TypeScript build errors (`TS2558: Expected 0-1 type arguments, but got 2`). Always use the cast pattern instead.
|
||||
- When adding new exports to `@fusion/engine`, update the mock in `packages/cli/src/commands/__tests__/dashboard.test.ts` AND `packages/cli/src/commands/__tests__/serve.test.ts` to include the new export, otherwise the test may fail with mysterious errors. Both test files need to be kept in sync.
|
||||
|
||||
@@ -39,7 +39,7 @@ import type {
|
||||
ChatMessage,
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
|
||||
function looksLikeHtml(body: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
@@ -1636,6 +1636,58 @@ export function reorderAutomationSteps(id: string, stepIds: string[]): Promise<S
|
||||
});
|
||||
}
|
||||
|
||||
// ── Routines API ────────────────────────────────────────────────
|
||||
|
||||
export interface RoutineRunResponse {
|
||||
routine: Routine;
|
||||
result: RoutineExecutionResult;
|
||||
}
|
||||
|
||||
export function fetchRoutines(): Promise<Routine[]> {
|
||||
return api<Routine[]>("/routines");
|
||||
}
|
||||
|
||||
export function fetchRoutine(id: string): Promise<Routine> {
|
||||
return api<Routine>(`/routines/${id}`);
|
||||
}
|
||||
|
||||
export function createRoutine(input: RoutineCreateInput): Promise<Routine> {
|
||||
return api<Routine>("/routines", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRoutine(id: string, updates: RoutineUpdateInput): Promise<Routine> {
|
||||
return api<Routine>(`/routines/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRoutine(id: string): Promise<void> {
|
||||
await api(`/routines/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function runRoutine(id: string): Promise<RoutineRunResponse> {
|
||||
return api<RoutineRunResponse>(`/routines/${id}/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchRoutineRuns(id: string): Promise<RoutineExecutionResult[]> {
|
||||
return api<RoutineExecutionResult[]>(`/routines/${id}/runs`);
|
||||
}
|
||||
|
||||
export function triggerRoutineWebhook(id: string, payload?: Record<string, unknown>): Promise<RoutineRunResponse> {
|
||||
return api<RoutineRunResponse>(`/routines/${id}/webhook`, {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Activity Log API ────────────────────────────────────────────
|
||||
|
||||
/** Re-export ActivityLogEntry type from core for convenience */
|
||||
|
||||
@@ -8,10 +8,11 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { GitHubClient } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
import type { TaskStore, TaskAttachment } from "@fusion/core";
|
||||
import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { __resetBatchImportRateLimiter, __setCreateKbAgentForRefine } from "./routes.js";
|
||||
@@ -8114,6 +8115,463 @@ describe("Automation routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routine routes", () => {
|
||||
const FAKE_ROUTINE = {
|
||||
id: "routine-001",
|
||||
name: "Test Routine",
|
||||
description: "A test routine",
|
||||
trigger: { type: "cron" as const, cronExpression: "0 * * * *" },
|
||||
catchUpPolicy: "skip" as const,
|
||||
executionPolicy: "queue" as const,
|
||||
enabled: true,
|
||||
runCount: 0,
|
||||
runHistory: [] as RoutineExecutionResult[],
|
||||
nextRunAt: "2026-04-01T00:00:00.000Z",
|
||||
createdAt: "2026-03-30T00:00:00.000Z",
|
||||
updatedAt: "2026-03-30T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function createMockRoutineStore() {
|
||||
return {
|
||||
listRoutines: vi.fn().mockResolvedValue([FAKE_ROUTINE]),
|
||||
createRoutine: vi.fn().mockResolvedValue(FAKE_ROUTINE),
|
||||
getRoutine: vi.fn().mockResolvedValue(FAKE_ROUTINE),
|
||||
updateRoutine: vi.fn().mockResolvedValue(FAKE_ROUTINE),
|
||||
deleteRoutine: vi.fn().mockResolvedValue(FAKE_ROUTINE),
|
||||
recordRun: vi.fn().mockResolvedValue(FAKE_ROUTINE),
|
||||
isValidCron: (expr: string) => expr === "0 * * * *",
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutineApp(routineStoreOverride?: ReturnType<typeof createMockRoutineStore>) {
|
||||
const store = createMockStore();
|
||||
const routineStore = routineStoreOverride ?? createMockRoutineStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { routineStore: routineStore as any }));
|
||||
return { app, routineStore };
|
||||
}
|
||||
|
||||
describe("GET /routines", () => {
|
||||
it("returns all routines", async () => {
|
||||
const { app, routineStore } = buildRoutineApp();
|
||||
const res = await GET(app, "/api/routines");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(routineStore.listRoutines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns empty array when no routineStore provided", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await GET(app, "/api/routines");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /routines", () => {
|
||||
it("creates a routine with cron trigger", async () => {
|
||||
const { app, routineStore } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Test",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(routineStore.createRoutine).toHaveBeenCalledTimes(1);
|
||||
expect(routineStore.createRoutine).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "Test",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("creates a routine with webhook trigger", async () => {
|
||||
const { app, routineStore } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Webhook Routine",
|
||||
trigger: { type: "webhook", webhookPath: "/trigger/test" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(routineStore.createRoutine).toHaveBeenCalledWith(expect.objectContaining({
|
||||
trigger: { type: "webhook", webhookPath: "/trigger/test" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("creates a routine with api trigger", async () => {
|
||||
const { app, routineStore } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "API Routine",
|
||||
trigger: { type: "api", endpoint: "/api/my-routine" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(routineStore.createRoutine).toHaveBeenCalledWith(expect.objectContaining({
|
||||
trigger: { type: "api", endpoint: "/api/my-routine" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns 400 for missing name", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Name is required");
|
||||
});
|
||||
|
||||
it("returns 400 for missing trigger", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Test",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Trigger is required");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid trigger type", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Test",
|
||||
trigger: { type: "invalid" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid trigger type");
|
||||
});
|
||||
|
||||
it("returns 400 for cron trigger without cronExpression", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Test",
|
||||
trigger: { type: "cron" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Cron expression is required");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid cron expression", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Test",
|
||||
trigger: { type: "cron", cronExpression: "not-a-cron" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid cron expression");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid catchUpPolicy", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Test",
|
||||
trigger: { type: "manual" },
|
||||
catchUpPolicy: "bad",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid catchUpPolicy");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid executionPolicy", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Test",
|
||||
trigger: { type: "manual" },
|
||||
executionPolicy: "bad",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid executionPolicy");
|
||||
});
|
||||
|
||||
it("returns 503 when routineStore not available", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
|
||||
name: "Test",
|
||||
trigger: { type: "manual" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /routines/:id", () => {
|
||||
it("returns a routine by id", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await GET(app, "/api/routines/routine-001");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe("routine-001");
|
||||
});
|
||||
|
||||
it("returns 404 for missing routine", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await GET(app, "/api/routines/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 503 when routineStore not available", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await GET(app, "/api/routines/routine-001");
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /routines/:id", () => {
|
||||
it("updates a routine", async () => {
|
||||
const { app, routineStore } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "PATCH", "/api/routines/routine-001", JSON.stringify({
|
||||
name: "Updated",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(routineStore.updateRoutine).toHaveBeenCalledWith("routine-001", expect.objectContaining({ name: "Updated" }));
|
||||
});
|
||||
|
||||
it("returns 404 for missing routine", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.updateRoutine.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "PATCH", "/api/routines/missing", JSON.stringify({
|
||||
name: "Updated",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid trigger type in update", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "PATCH", "/api/routines/routine-001", JSON.stringify({
|
||||
trigger: { type: "bad" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid trigger type");
|
||||
});
|
||||
|
||||
it("returns 400 for empty name in update", async () => {
|
||||
const { app } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "PATCH", "/api/routines/routine-001", JSON.stringify({
|
||||
name: "",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Name cannot be empty");
|
||||
});
|
||||
|
||||
it("returns 503 when routineStore not available", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await REQUEST(app, "PATCH", "/api/routines/routine-001", JSON.stringify({
|
||||
name: "Updated",
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /routines/:id", () => {
|
||||
it("deletes a routine", async () => {
|
||||
const { app, routineStore } = buildRoutineApp();
|
||||
const res = await REQUEST(app, "DELETE", "/api/routines/routine-001");
|
||||
expect(res.status).toBe(200);
|
||||
expect(routineStore.deleteRoutine).toHaveBeenCalledWith("routine-001");
|
||||
});
|
||||
|
||||
it("returns 404 for missing routine", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.deleteRoutine.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "DELETE", "/api/routines/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 503 when routineStore not available", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await REQUEST(app, "DELETE", "/api/routines/routine-001");
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /routines/:id/run", () => {
|
||||
it("runs a routine and records the result", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/run");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toBeDefined();
|
||||
expect(res.body.result.triggerType).toBe("cron");
|
||||
expect(mockStore.recordRun).toHaveBeenCalledWith(
|
||||
"routine-001",
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
startedAt: expect.any(String),
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 for missing routine", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/routines/missing/run");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 503 when routineStore not available", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/run");
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /routines/:id/runs", () => {
|
||||
it("returns run history", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({
|
||||
...FAKE_ROUTINE,
|
||||
runHistory: [
|
||||
{ routineId: "routine-001", startedAt: "2026-03-30T00:00:00.000Z", completedAt: "2026-03-30T00:01:00.000Z", success: true, output: "Test" },
|
||||
],
|
||||
});
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await GET(app, "/api/routines/routine-001/runs");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns 404 for missing routine", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await GET(app, "/api/routines/missing/runs");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 503 when routineStore not available", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await GET(app, "/api/routines/routine-001/runs");
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /routines/:id/webhook", () => {
|
||||
function buildRoutineApp(routineStoreOverride?: ReturnType<typeof createMockRoutineStore>) {
|
||||
const store = createMockStore();
|
||||
const routineStore = routineStoreOverride ?? createMockRoutineStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { routineStore: routineStore as any }));
|
||||
return { app, routineStore };
|
||||
}
|
||||
|
||||
it("triggers a webhook routine without secret", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({
|
||||
...FAKE_ROUTINE,
|
||||
trigger: { type: "webhook" as const, webhookPath: "/trigger/test" },
|
||||
});
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toBeDefined();
|
||||
expect(res.body.result.triggerType).toBe("webhook");
|
||||
expect(mockStore.recordRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when routine is not a webhook type", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({
|
||||
...FAKE_ROUTINE,
|
||||
trigger: { type: "cron" as const, cronExpression: "0 * * * *" },
|
||||
});
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("webhook triggers");
|
||||
});
|
||||
|
||||
it("returns 400 when routine is disabled", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({
|
||||
...FAKE_ROUTINE,
|
||||
enabled: false,
|
||||
trigger: { type: "webhook" as const, webhookPath: "/trigger/test" },
|
||||
});
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("disabled");
|
||||
});
|
||||
|
||||
it("returns 404 for missing routine", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" }));
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/routines/missing/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 503 when routineStore not available", async () => {
|
||||
const store = createMockStore();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("accepts webhook when no secret is configured", async () => {
|
||||
const mockStore = createMockRoutineStore();
|
||||
mockStore.getRoutine.mockResolvedValue({
|
||||
...FAKE_ROUTINE,
|
||||
trigger: { type: "webhook" as const, webhookPath: "/trigger/test" },
|
||||
});
|
||||
const { app } = buildRoutineApp(mockStore);
|
||||
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook HMAC verification", () => {
|
||||
// These tests verify the verifyWebhookSignature function directly
|
||||
// since testing through HTTP requires complex middleware setup
|
||||
it("verifyWebhookSignature rejects missing signature header", async () => {
|
||||
const { verifyWebhookSignature } = await import("./github-webhooks.js");
|
||||
const result = verifyWebhookSignature(Buffer.from("{}"), undefined, "secret");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Missing signature header");
|
||||
});
|
||||
|
||||
it("verifyWebhookSignature rejects wrong signature", async () => {
|
||||
const { verifyWebhookSignature } = await import("./github-webhooks.js");
|
||||
const body = Buffer.from('{"test":true}');
|
||||
const result = verifyWebhookSignature(body, "sha256=deadbeef", "secret");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Signature mismatch");
|
||||
});
|
||||
|
||||
it("verifyWebhookSignature accepts valid HMAC", async () => {
|
||||
const { verifyWebhookSignature } = await import("./github-webhooks.js");
|
||||
const secret = "test-secret";
|
||||
const body = Buffer.from('{"test":true}');
|
||||
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
||||
const result = verifyWebhookSignature(body, sig, secret);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// --- Settings API Tests ---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||
import multer from "multer";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { execSync } from "node:child_process";
|
||||
@@ -7,8 +8,8 @@ import { resolve, sep, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as nodeFs from "node:fs";
|
||||
import * as nodeChildProcess from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep, MessageType, ParticipantType, MessageCreateInput } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, MEMORY_FILE_PATH } from "@fusion/core";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep, MessageType, ParticipantType, MessageCreateInput, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult, RoutineTriggerType } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, MEMORY_FILE_PATH, RoutineStore, isWebhookTrigger } from "@fusion/core";
|
||||
import type { ChatStore, ChatSessionCreateInput, ChatSessionUpdateInput } from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, parseBadgeUrl } from "./github.js";
|
||||
@@ -7709,6 +7710,292 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Routine Routes ──────────────────────────────────────────────────
|
||||
|
||||
const routineStore = options?.routineStore;
|
||||
|
||||
// GET /routines — list all routines
|
||||
router.get("/routines", async (_req: Request, res: Response) => {
|
||||
if (!routineStore) {
|
||||
return res.json([]);
|
||||
}
|
||||
try {
|
||||
const routines = await routineStore.listRoutines();
|
||||
res.json(routines);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /routines — create a new routine
|
||||
router.post("/routines", async (req: Request, res: Response) => {
|
||||
if (!routineStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
try {
|
||||
const { name, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name?.trim()) {
|
||||
throw badRequest("Name is required");
|
||||
}
|
||||
if (!trigger) {
|
||||
throw badRequest("Trigger is required");
|
||||
}
|
||||
if (!trigger.type) {
|
||||
throw badRequest("Trigger must have a type field");
|
||||
}
|
||||
const validTriggerTypes: RoutineTriggerType[] = ["cron", "webhook", "api", "manual"];
|
||||
if (!validTriggerTypes.includes(trigger.type)) {
|
||||
throw badRequest(`Invalid trigger type. Must be one of: ${validTriggerTypes.join(", ")}`);
|
||||
}
|
||||
if (trigger.type === "cron") {
|
||||
if (!trigger.cronExpression?.trim()) {
|
||||
throw badRequest("Cron expression is required for cron trigger");
|
||||
}
|
||||
if (!RoutineStore.isValidCron(trigger.cronExpression)) {
|
||||
throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`);
|
||||
}
|
||||
}
|
||||
if (catchUpPolicy !== undefined) {
|
||||
const validCatchUpPolicies: Array<"run" | "skip" | "run_one"> = ["run", "skip", "run_one"];
|
||||
if (!validCatchUpPolicies.includes(catchUpPolicy)) {
|
||||
throw badRequest(`Invalid catchUpPolicy. Must be one of: ${validCatchUpPolicies.join(", ")}`);
|
||||
}
|
||||
}
|
||||
if (executionPolicy !== undefined) {
|
||||
const validExecutionPolicies: Array<"parallel" | "queue" | "reject"> = ["parallel", "queue", "reject"];
|
||||
if (!validExecutionPolicies.includes(executionPolicy)) {
|
||||
throw badRequest(`Invalid executionPolicy. Must be one of: ${validExecutionPolicies.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const routine = await routineStore.createRoutine({
|
||||
name: name.trim(),
|
||||
description,
|
||||
trigger,
|
||||
catchUpPolicy,
|
||||
executionPolicy,
|
||||
enabled,
|
||||
});
|
||||
res.status(201).json(routine);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /routines/:id — get a single routine
|
||||
router.get("/routines/:id", async (req: Request, res: Response) => {
|
||||
if (!routineStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const routine = await routineStore.getRoutine(id);
|
||||
res.json(routine);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw notFound("Routine not found");
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /routines/:id — update a routine
|
||||
router.patch("/routines/:id", async (req: Request, res: Response) => {
|
||||
if (!routineStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const { name, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
|
||||
|
||||
// Validate name if provided
|
||||
if (name !== undefined && !name.trim()) {
|
||||
throw badRequest("Name cannot be empty");
|
||||
}
|
||||
|
||||
// Validate trigger if provided
|
||||
if (trigger !== undefined) {
|
||||
if (trigger.type) {
|
||||
const validTriggerTypes: RoutineTriggerType[] = ["cron", "webhook", "api", "manual"];
|
||||
if (!validTriggerTypes.includes(trigger.type)) {
|
||||
throw badRequest(`Invalid trigger type. Must be one of: ${validTriggerTypes.join(", ")}`);
|
||||
}
|
||||
if (trigger.type === "cron" && trigger.cronExpression) {
|
||||
if (!RoutineStore.isValidCron(trigger.cronExpression)) {
|
||||
throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routine = await routineStore.updateRoutine(id, {
|
||||
name: name !== undefined ? name.trim() : undefined,
|
||||
description,
|
||||
trigger,
|
||||
catchUpPolicy,
|
||||
executionPolicy,
|
||||
enabled,
|
||||
});
|
||||
res.json(routine);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw notFound("Routine not found");
|
||||
}
|
||||
if (err.message?.includes("cannot be empty") || err.message?.includes("Invalid cron")) {
|
||||
throw badRequest(err.message);
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /routines/:id — delete a routine
|
||||
router.delete("/routines/:id", async (req: Request, res: Response) => {
|
||||
if (!routineStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const deleted = await routineStore.deleteRoutine(id);
|
||||
res.json(deleted);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw notFound("Routine not found");
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /routines/:id/run — manual trigger (record a manual run)
|
||||
router.post("/routines/:id/run", async (req: Request, res: Response) => {
|
||||
if (!routineStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const routine = await routineStore.getRoutine(id);
|
||||
|
||||
// Create a placeholder result for now (actual execution in future task)
|
||||
const startedAt = new Date().toISOString();
|
||||
const result: RoutineExecutionResult = {
|
||||
routineId: id,
|
||||
startedAt,
|
||||
triggerType: routine.trigger.type,
|
||||
success: true,
|
||||
output: "Manual run triggered",
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updated = await routineStore.recordRun(id, result);
|
||||
res.json({ routine: updated, result });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw notFound("Routine not found");
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /routines/:id/runs — get execution history
|
||||
router.get("/routines/:id/runs", async (req: Request, res: Response) => {
|
||||
if (!routineStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const routine = await routineStore.getRoutine(id);
|
||||
res.json(routine.runHistory);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw notFound("Routine not found");
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /routines/:id/webhook — incoming webhook trigger
|
||||
router.post("/routines/:id/webhook", async (req: Request, res: Response) => {
|
||||
if (!routineStore) {
|
||||
throw new ApiError(503, "Routine store not available");
|
||||
}
|
||||
try {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const routine = await routineStore.getRoutine(id);
|
||||
|
||||
// Validate this is a webhook-type routine
|
||||
if (!isWebhookTrigger(routine.trigger)) {
|
||||
throw badRequest("Routine is not configured for webhook triggers");
|
||||
}
|
||||
|
||||
// Validate routine is enabled
|
||||
if (!routine.enabled) {
|
||||
throw badRequest("Routine is disabled");
|
||||
}
|
||||
|
||||
// Get raw body for HMAC verification
|
||||
const rawBody = (req as any).rawBody as Buffer | undefined;
|
||||
const signatureHeader = req.headers["x-hub-signature-256"] as string | undefined;
|
||||
|
||||
// If webhook secret is configured, verify the signature
|
||||
if (routine.trigger.secret) {
|
||||
if (!rawBody) {
|
||||
throw badRequest("Raw body not available for signature verification");
|
||||
}
|
||||
if (!signatureHeader) {
|
||||
throw new ApiError(403, "Missing signature header");
|
||||
}
|
||||
const verification = verifyWebhookSignature(rawBody, signatureHeader, routine.trigger.secret);
|
||||
if (!verification.valid) {
|
||||
throw new ApiError(403, verification.error ?? "Invalid signature");
|
||||
}
|
||||
}
|
||||
|
||||
// Create a placeholder result for now (actual execution in future task)
|
||||
const startedAt = new Date().toISOString();
|
||||
const result: RoutineExecutionResult = {
|
||||
routineId: id,
|
||||
startedAt,
|
||||
triggerType: "webhook",
|
||||
success: true,
|
||||
output: "Webhook trigger received",
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updated = await routineStore.recordRun(id, result);
|
||||
res.json({ routine: updated, result });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw notFound("Routine not found");
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Activity Log Routes ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { join, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Task, TaskStore, MergeResult, AutomationStore } from "@fusion/core";
|
||||
import type { Task, TaskStore, MergeResult, AutomationStore, RoutineStore } from "@fusion/core";
|
||||
import { ChatStore } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
@@ -82,6 +82,8 @@ export interface ServerOptions {
|
||||
badgePubSub?: BadgePubSub;
|
||||
/** Optional AutomationStore for scheduled task management */
|
||||
automationStore?: AutomationStore;
|
||||
/** Optional RoutineStore for recurring task automation */
|
||||
routineStore?: RoutineStore;
|
||||
/** Optional AiSessionStore — if not provided, one is created from the default store's database */
|
||||
aiSessionStore?: AiSessionStore;
|
||||
/** Optional MissionAutopilot for autonomous mission progression */
|
||||
|
||||
Reference in New Issue
Block a user