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