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:
gsxdsm
2026-04-10 02:42:48 -07:00
parent 517b1bb46c
commit e664d133ea
5 changed files with 805 additions and 5 deletions

View File

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

View File

@@ -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 */

View File

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

View File

@@ -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 ─────────────────────────────────────────────
/**

View File

@@ -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 */