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

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