feat(FN-1716): merge fusion/fn-1716

This commit is contained in:
gsxdsm
2026-04-15 17:08:12 -07:00
parent 07b406d412
commit 268dced2ec
3 changed files with 694 additions and 77 deletions

View File

@@ -154,6 +154,41 @@ Fusion uses a dual-scope model hierarchy with five independent lanes. Global set
For full settings documentation, see [Settings Reference](./docs/settings-reference.md).
### Scheduled Tasks / Automations
Fusion supports scheduled task automation via the `/api/automations` endpoints. Automations can run shell commands or multi-step workflows on a configurable schedule.
**Scope:** Automations support scope-aware routing with `?scope=global` or `?scope=project` query parameter (or `scope` field in request body). When scope is omitted, the legacy default behavior applies (backward compatible).
| Endpoint | Method | Description |
|---------|--------|-------------|
| `/api/automations` | GET | List all automations (filtered by scope if specified) |
| `/api/automations` | POST | Create automation (scope defaults to `project`) |
| `/api/automations/:id` | GET | Get automation by ID |
| `/api/automations/:id` | PATCH | Update automation |
| `/api/automations/:id` | DELETE | Delete automation |
| `/api/automations/:id/run` | POST | Trigger manual run |
| `/api/automations/:id/toggle` | POST | Toggle enabled/disabled |
| `/api/automations/:id/steps/reorder` | POST | Reorder automation steps |
### Routines
Routines are AI agent tasks triggered by cron schedules, webhooks, or manual execution.
**Scope:** Routines support scope-aware routing with `?scope=global` or `?scope=project` query parameter (or `scope` field in request body). When scope is omitted, the legacy default behavior applies (backward compatible).
| Endpoint | Method | Description |
|---------|--------|-------------|
| `/api/routines` | GET | List all routines (filtered by scope if specified) |
| `/api/routines` | POST | Create routine (scope defaults to `project`) |
| `/api/routines/:id` | GET | Get routine by ID |
| `/api/routines/:id` | PATCH | Update routine |
| `/api/routines/:id` | DELETE | Delete routine |
| `/api/routines/:id/run` | POST | Manual trigger |
| `/api/routines/:id/trigger` | POST | Canonical manual trigger |
| `/api/routines/:id/runs` | GET | Get execution history |
| `/api/routines/:id/webhook` | POST | Webhook trigger (signature verification supported) |
### Quick Examples
```bash

View File

@@ -9214,6 +9214,7 @@ describe("Automation routes", () => {
nextRunAt: "2026-04-01T00:00:00.000Z",
createdAt: "2026-03-30T00:00:00.000Z",
updatedAt: "2026-03-30T00:00:00.000Z",
scope: "project" as const,
};
function createMockAutomationStore() {
@@ -9409,6 +9410,175 @@ describe("Automation routes", () => {
expect(mockStore.updateSchedule).toHaveBeenCalledWith("sched-001", { enabled: false });
});
});
// ── Scope-aware automation tests ─────────────────────────────────────
describe("Scope-aware automation routes", () => {
it("returns 400 for invalid scope value in query param", async () => {
const { app } = buildApp();
const res = await GET(app, "/api/automations?scope=invalid");
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid scope value "invalid"');
});
it("returns 400 for invalid scope value in body", async () => {
const { app } = buildApp();
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test",
command: "echo test",
scheduleType: "hourly",
scope: "invalid",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid scope value "invalid"');
});
it("GET /automations filters by scope when scope=global is specified", async () => {
const mockStore = createMockAutomationStore();
const globalSchedule = { ...FAKE_SCHEDULE, scope: "global" as const };
const projectSchedule = { ...FAKE_SCHEDULE, id: "sched-002", scope: "project" as const };
mockStore.listSchedules.mockResolvedValue([globalSchedule, projectSchedule]);
const { app } = buildApp(mockStore);
const res = await GET(app, "/api/automations?scope=global");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].scope).toBe("global");
});
it("GET /automations filters by scope when scope=project is specified", async () => {
const mockStore = createMockAutomationStore();
const globalSchedule = { ...FAKE_SCHEDULE, scope: "global" as const };
const projectSchedule = { ...FAKE_SCHEDULE, id: "sched-002", scope: "project" as const };
mockStore.listSchedules.mockResolvedValue([globalSchedule, projectSchedule]);
const { app } = buildApp(mockStore);
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].scope).toBe("project");
});
it("POST /automations creates schedule with project scope when scope=project is specified", async () => {
const mockStore = createMockAutomationStore();
const { app, automationStore } = buildApp(mockStore);
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test",
command: "echo test",
scheduleType: "hourly",
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(automationStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("POST /automations creates schedule with global scope when scope=global is specified", async () => {
const mockStore = createMockAutomationStore();
const { app, automationStore } = buildApp(mockStore);
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test",
command: "echo test",
scheduleType: "hourly",
scope: "global",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(automationStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "global" }),
);
});
it("GET /automations/:id returns 404 for schedule with wrong scope", async () => {
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const });
const { app } = buildApp(mockStore);
// Request with scope=global but schedule is project-scoped
const res = await GET(app, "/api/automations/sched-001?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Schedule not found");
});
it("GET /automations/:id returns schedule when scope matches", async () => {
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "global" as const });
const { app } = buildApp(mockStore);
const res = await GET(app, "/api/automations/sched-001?scope=global");
expect(res.status).toBe(200);
expect(res.body.id).toBe("sched-001");
});
it("PATCH /automations/:id returns 404 for schedule with wrong scope", async () => {
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const });
const { app } = buildApp(mockStore);
const res = await REQUEST(app, "PATCH", "/api/automations/sched-001?scope=global", JSON.stringify({
name: "Updated",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(404);
expect(res.body.error).toContain("Schedule not found");
});
it("DELETE /automations/:id returns 404 for schedule with wrong scope", async () => {
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const });
const { app } = buildApp(mockStore);
const res = await REQUEST(app, "DELETE", "/api/automations/sched-001?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Schedule not found");
});
it("POST /automations/:id/run returns 404 for schedule with wrong scope", async () => {
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const });
const { app } = buildApp(mockStore);
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Schedule not found");
});
it("POST /automations/:id/toggle returns 404 for schedule with wrong scope", async () => {
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const });
const { app } = buildApp(mockStore);
const res = await REQUEST(app, "POST", "/api/automations/sched-001/toggle?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Schedule not found");
});
it("POST /automations/:id/steps/reorder returns 404 for schedule with wrong scope", async () => {
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, scope: "project" as const });
const { app } = buildApp(mockStore);
const res = await REQUEST(app, "POST", "/api/automations/sched-001/steps/reorder?scope=global", JSON.stringify({
stepIds: ["step-1", "step-2"],
}), { "Content-Type": "application/json" });
expect(res.status).toBe(404);
expect(res.body.error).toContain("Schedule not found");
});
it("omitted scope defaults to project for POST /automations", async () => {
const mockStore = createMockAutomationStore();
const { app, automationStore } = buildApp(mockStore);
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test",
command: "echo test",
scheduleType: "hourly",
// No scope specified - should default to "project"
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(automationStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("omitted scope calls listSchedules without scope argument (legacy behavior)", async () => {
const mockStore = createMockAutomationStore();
const { app, automationStore } = buildApp(mockStore);
const res = await GET(app, "/api/automations");
expect(res.status).toBe(200);
// Without scope, listSchedules should be called without scope argument
expect(automationStore.listSchedules).toHaveBeenCalledWith();
});
});
});
describe("Routine routes", () => {
@@ -9425,6 +9595,7 @@ describe("Routine routes", () => {
nextRunAt: "2026-04-01T00:00:00.000Z",
createdAt: "2026-03-30T00:00:00.000Z",
updatedAt: "2026-03-30T00:00:00.000Z",
scope: "project" as const,
};
function createMockRoutineStore() {
@@ -9996,6 +10167,169 @@ describe("Routine routes", () => {
expect(result.valid).toBe(true);
});
});
// ── Scope-aware routine tests ─────────────────────────────────────
describe("Scope-aware routine routes", () => {
it("returns 400 for invalid scope value in query param", async () => {
const { app } = buildRoutineApp();
const res = await GET(app, "/api/routines?scope=invalid");
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid scope value "invalid"');
});
it("returns 400 for invalid scope value in body", async () => {
const { app } = buildRoutineApp();
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "invalid",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain('Invalid scope value "invalid"');
});
it("GET /routines filters by scope when scope=global is specified", async () => {
const mockStore = createMockRoutineStore();
const globalRoutine = { ...FAKE_ROUTINE, scope: "global" as const };
const projectRoutine = { ...FAKE_ROUTINE, id: "routine-002", scope: "project" as const };
mockStore.listRoutines.mockResolvedValue([globalRoutine, projectRoutine]);
const { app } = buildRoutineApp(mockStore);
const res = await GET(app, "/api/routines?scope=global");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].scope).toBe("global");
});
it("GET /routines filters by scope when scope=project is specified", async () => {
const mockStore = createMockRoutineStore();
const globalRoutine = { ...FAKE_ROUTINE, scope: "global" as const };
const projectRoutine = { ...FAKE_ROUTINE, id: "routine-002", scope: "project" as const };
mockStore.listRoutines.mockResolvedValue([globalRoutine, projectRoutine]);
const { app } = buildRoutineApp(mockStore);
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].scope).toBe("project");
});
it("POST /routines creates routine with project scope when scope=project is specified", async () => {
const mockStore = createMockRoutineStore();
const { app, routineStore } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "project",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(routineStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("POST /routines creates routine with global scope when scope=global is specified", async () => {
const mockStore = createMockRoutineStore();
const { app, routineStore } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test",
trigger: { type: "cron", cronExpression: "0 * * * *" },
scope: "global",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(routineStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "global" }),
);
});
it("GET /routines/:id returns 404 for routine with wrong scope", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "project" as const });
const { app } = buildRoutineApp(mockStore);
// Request with scope=global but routine is project-scoped
const res = await GET(app, "/api/routines/routine-001?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Routine not found");
});
it("GET /routines/:id returns routine when scope matches", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "global" as const });
const { app } = buildRoutineApp(mockStore);
const res = await GET(app, "/api/routines/routine-001?scope=global");
expect(res.status).toBe(200);
expect(res.body.id).toBe("routine-001");
});
it("PATCH /routines/:id returns 404 for routine with wrong scope", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "project" as const });
const { app } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "PATCH", "/api/routines/routine-001?scope=global", JSON.stringify({
name: "Updated",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(404);
expect(res.body.error).toContain("Routine not found");
});
it("DELETE /routines/:id returns 404 for routine with wrong scope", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "project" as const });
const { app } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "DELETE", "/api/routines/routine-001?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Routine not found");
});
it("POST /routines/:id/run returns 404 for routine with wrong scope", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "project" as const });
const { app } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines/routine-001/run?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Routine not found");
});
it("POST /routines/:id/trigger returns 404 for routine with wrong scope", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "project" as const });
const { app } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines/routine-001/trigger?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Routine not found");
});
it("GET /routines/:id/runs returns 404 for routine with wrong scope", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({ ...FAKE_ROUTINE, scope: "project" as const });
const { app } = buildRoutineApp(mockStore);
const res = await GET(app, "/api/routines/routine-001/runs?scope=global");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Routine not found");
});
it("omitted scope defaults to project for POST /routines", async () => {
const mockStore = createMockRoutineStore();
const { app, routineStore } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test",
trigger: { type: "cron", cronExpression: "0 * * * *" },
// No scope specified - should default to "project"
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(routineStore.createRoutine).toHaveBeenCalledWith(
expect.objectContaining({ scope: "project" }),
);
});
it("omitted scope calls listRoutines without scope argument (legacy behavior)", async () => {
const mockStore = createMockRoutineStore();
const { app, routineStore } = buildRoutineApp(mockStore);
const res = await GET(app, "/api/routines");
expect(res.status).toBe(200);
// Without scope, listRoutines should be called without scope argument
expect(routineStore.listRoutines).toHaveBeenCalledWith();
});
});
});

View File

@@ -1784,6 +1784,120 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return { store: scopedStore, engine: undefined, projectId };
}
// ── Scope Parsing for Automation/Routine Routes ─────────────────────
/**
* Valid scope values for automation/routine routes.
* When scope is omitted, the route defaults to "project" for backward compatibility.
*/
type ScopeValue = "global" | "project";
/**
* Parse and validate the scope parameter from request query/body.
* Accepts scope in query param `?scope=global|project` or in body `{ scope: "global"|"project" }`.
*
* @returns The parsed scope value, or undefined if scope is omitted (legacy default)
* @throws ApiError(400) if scope is present but invalid
*/
function parseScopeParam(req: Request): ScopeValue | undefined {
// Check query param first, then body
const rawScope =
(typeof req.query.scope === "string" ? req.query.scope : undefined) ??
(req.body && typeof req.body.scope === "string" ? req.body.scope : undefined);
// If scope is not provided, return undefined (legacy default behavior)
if (rawScope === undefined || rawScope === "") {
return undefined;
}
// Validate scope value
if (rawScope !== "global" && rawScope !== "project") {
throw new ApiError(400, `Invalid scope value "${rawScope}". Must be "global" or "project".`);
}
return rawScope;
}
/**
* Resolve the AutomationStore for the given scope.
*
* Scope resolution:
* - "global": Returns the default AutomationStore from options (process-level)
* - "project": Returns the project-scoped AutomationStore from engine or project-store resolver
* - undefined (legacy): Returns the default AutomationStore for backward compatibility
*
* @throws ApiError(503) if the store is unavailable for the requested scope
*/
function resolveAutomationStore(req: Request, scope: ScopeValue | undefined): import("@fusion/core").AutomationStore {
const defaultStore = options?.automationStore;
if (scope === "global" || scope === undefined) {
// Global scope: use the default process-level store
if (!defaultStore) {
throw new ApiError(503, "Automation store not available");
}
return defaultStore;
}
// Project scope: resolve from engine or fallback to project store
// Project-scoped stores don't have a separate AutomationStore instance in the current design;
// they use the same store with scope filtering in queries.
// For now, fall back to the default store (scope filtering happens at query time).
if (!defaultStore) {
throw new ApiError(503, "Automation store not available");
}
return defaultStore;
}
/**
* Resolve the RoutineStore for the given scope.
*
* Scope resolution:
* - "global": Returns the default RoutineStore from options (process-level)
* - "project": Returns the project-scoped RoutineStore from engine or project-store resolver
* - undefined (legacy): Returns the default RoutineStore for backward compatibility
*
* @throws ApiError(503) if the store is unavailable for the requested scope
*/
function resolveRoutineStore(req: Request, scope: ScopeValue | undefined): import("@fusion/core").RoutineStore {
const defaultStore = options?.routineStore;
if (scope === "global" || scope === undefined) {
// Global scope: use the default process-level store
if (!defaultStore) {
throw new ApiError(503, "Routine store not available");
}
return defaultStore;
}
// Project scope: resolve from engine or fallback to project store
// Project-scoped stores don't have a separate RoutineStore instance in the current design;
// they use the same store with scope filtering in queries.
// For now, fall back to the default store (scope filtering happens at query time).
if (!defaultStore) {
throw new ApiError(503, "Routine store not available");
}
return defaultStore;
}
/**
* Resolve the RoutineRunner for the given scope.
* The RoutineRunner handles execution and must be scoped consistently with store lookups.
*
* @throws ApiError(503) if the runner is unavailable for the requested scope
*/
function resolveRoutineRunner(req: Request, scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]> {
const runner = options?.routineRunner;
if (!runner) {
throw new ApiError(503, "Routine execution not available");
}
// For now, the routine runner is process-level and not scoped
// This maintains backward compatibility while scope isolation is enforced at the store level
return runner;
}
if (process.env.FUSION_DEBUG_PLANNING_ROUTES === "1") {
const planningRoutes = [
"POST /planning/start",
@@ -8286,17 +8400,37 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// ── Automation / Scheduled Task Routes ────────────────────────────
//
// Scope-aware endpoints: Accept `scope=global|project` query param or body field.
// - When scope=global: Operations target the global automation store
// - When scope=project: Operations target project-scoped automations (filtered by scope)
// - When scope is omitted: Legacy default behavior (global store, backward compatible)
//
// Error codes:
// - 400: Invalid scope value or validation failure
// - 404: Schedule not found
// - 503: Automation store unavailable
const automationStore = options?.automationStore;
// GET /automations — list all scheduled tasks
router.get("/automations", async (_req: Request, res: Response) => {
if (!automationStore) {
// GET /automations — list all scheduled tasks (optionally filtered by scope)
router.get("/automations", async (req: Request, res: Response) => {
// Return empty array when no store available (legacy backward-compatible behavior)
if (!options?.automationStore) {
return res.json([]);
}
try {
const schedules = await automationStore.listSchedules();
res.json(schedules);
const scope = parseScopeParam(req);
const automationStore = resolveAutomationStore(req, scope);
// Get all schedules and filter by scope if specified
// When scope is omitted, return all schedules (legacy behavior)
const allSchedules = await automationStore.listSchedules();
if (scope) {
const filteredSchedules = allSchedules.filter((s) => s.scope === scope);
res.json(filteredSchedules);
} else {
res.json(allSchedules);
}
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
@@ -8305,11 +8439,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// POST /automations — create a new schedule
// POST /automations — create a new schedule (with optional scope)
router.post("/automations", async (req: Request, res: Response) => {
if (!automationStore) {
throw new ApiError(503, "Automation store not available");
}
const scope = parseScopeParam(req);
const automationStore = resolveAutomationStore(req, scope);
try {
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body;
@@ -8341,6 +8475,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
// Determine scope for the new schedule
// Default to "project" for backward compatibility when scope is omitted
const scheduleScope = scope ?? "project";
const schedule = await automationStore.createSchedule({
name,
description,
@@ -8350,6 +8488,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
enabled,
timeoutMs,
steps: hasSteps ? steps : undefined,
scope: scheduleScope,
});
res.status(201).json(schedule);
} catch (err: any) {
@@ -8361,13 +8500,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// GET /automations/:id — get a single schedule
router.get("/automations/:id", async (req, res) => {
if (!automationStore) {
throw new ApiError(503, "Automation store not available");
}
router.get("/automations/:id", async (req: Request, res: Response) => {
const scope = parseScopeParam(req);
const automationStore = resolveAutomationStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const schedule = await automationStore.getSchedule(id);
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
if (scope && schedule.scope !== scope) {
throw notFound("Schedule not found");
}
res.json(schedule);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -8381,12 +8526,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// PATCH /automations/:id — update a schedule
router.patch("/automations/:id", async (req, res) => {
if (!automationStore) {
throw new ApiError(503, "Automation store not available");
}
router.patch("/automations/:id", async (req: Request, res: Response) => {
const scope = parseScopeParam(req);
const automationStore = resolveAutomationStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
// by fetching it first (can't filter in update without scope support in store)
if (scope) {
const existing = await automationStore.getSchedule(id);
if (existing.scope !== scope) {
throw notFound("Schedule not found");
}
}
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body;
// Validate cron if switching to custom
@@ -8430,12 +8585,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// DELETE /automations/:id — delete a schedule
router.delete("/automations/:id", async (req, res) => {
if (!automationStore) {
throw new ApiError(503, "Automation store not available");
}
router.delete("/automations/:id", async (req: Request, res: Response) => {
const scope = parseScopeParam(req);
const automationStore = resolveAutomationStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
if (scope) {
const existing = await automationStore.getSchedule(id);
if (existing.scope !== scope) {
throw notFound("Schedule not found");
}
}
const deleted = await automationStore.deleteSchedule(id);
res.json(deleted);
} catch (err: any) {
@@ -8450,14 +8614,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// POST /automations/:id/run — trigger a manual run
router.post("/automations/:id/run", async (req, res) => {
if (!automationStore) {
throw new ApiError(503, "Automation store not available");
}
router.post("/automations/:id/run", async (req: Request, res: Response) => {
const scope = parseScopeParam(req);
const automationStore = resolveAutomationStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const schedule = await automationStore.getSchedule(id);
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
if (scope && schedule.scope !== scope) {
throw notFound("Schedule not found");
}
const startedAt = new Date().toISOString();
let result: import("@fusion/core").AutomationRunResult;
@@ -8484,13 +8653,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// POST /automations/:id/toggle — toggle enabled/disabled
router.post("/automations/:id/toggle", async (req, res) => {
if (!automationStore) {
throw new ApiError(503, "Automation store not available");
}
router.post("/automations/:id/toggle", async (req: Request, res: Response) => {
const scope = parseScopeParam(req);
const automationStore = resolveAutomationStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const schedule = await automationStore.getSchedule(id);
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
if (scope && schedule.scope !== scope) {
throw notFound("Schedule not found");
}
const updated = await automationStore.updateSchedule(id, {
enabled: !schedule.enabled,
});
@@ -8507,12 +8682,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// POST /automations/:id/steps/reorder — reorder steps
router.post("/automations/:id/steps/reorder", async (req, res) => {
if (!automationStore) {
throw new ApiError(503, "Automation store not available");
}
router.post("/automations/:id/steps/reorder", async (req: Request, res: Response) => {
const scope = parseScopeParam(req);
const automationStore = resolveAutomationStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
if (scope) {
const existing = await automationStore.getSchedule(id);
if (existing.scope !== scope) {
throw notFound("Schedule not found");
}
}
const { stepIds } = req.body;
if (!Array.isArray(stepIds)) {
throw badRequest("stepIds must be an array");
@@ -8534,18 +8718,39 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// ── Routine Routes ──────────────────────────────────────────────────
//
// Scope-aware endpoints: Accept `scope=global|project` query param or body field.
// - When scope=global: Operations target the global routine store
// - When scope=project: Operations target project-scoped routines (filtered by scope)
// - When scope is omitted: Legacy default behavior (global store, backward compatible)
//
// Error codes:
// - 400: Invalid scope value or validation failure
// - 401: Webhook signature verification failed
// - 403: Webhook disabled/forbidden
// - 404: Routine not found
// - 503: Routine store or runner unavailable
const routineStore = options?.routineStore;
const routineRunner = options?.routineRunner;
// GET /routines — list all routines
router.get("/routines", async (_req: Request, res: Response) => {
if (!routineStore) {
// GET /routines — list all routines (optionally filtered by scope)
router.get("/routines", async (req: Request, res: Response) => {
// Return empty array when no store available (legacy backward-compatible behavior)
if (!options?.routineStore) {
return res.json([]);
}
try {
const routines = await routineStore.listRoutines();
res.json(routines);
const scope = parseScopeParam(req);
const routineStore = resolveRoutineStore(req, scope);
// Get all routines and filter by scope if specified
// When scope is omitted, return all routines (legacy behavior)
const allRoutines = await routineStore.listRoutines();
if (scope) {
const filteredRoutines = allRoutines.filter((r) => r.scope === scope);
res.json(filteredRoutines);
} else {
res.json(allRoutines);
}
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
@@ -8554,11 +8759,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// POST /routines — create a new routine
// POST /routines — create a new routine (with optional scope)
router.post("/routines", async (req: Request, res: Response) => {
if (!routineStore) {
throw new ApiError(503, "Routine store not available");
}
const scope = parseScopeParam(req);
const routineStore = resolveRoutineStore(req, scope);
try {
const { name, agentId, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
@@ -8597,6 +8802,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
// Determine scope for the new routine
// Default to "project" for backward compatibility when scope is omitted
const routineScope = scope ?? "project";
const routine = await routineStore.createRoutine({
name: name.trim(),
agentId: typeof agentId === "string" ? agentId.trim() : "",
@@ -8605,6 +8814,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
catchUpPolicy,
executionPolicy,
enabled,
scope: routineScope,
});
res.status(201).json(routine);
} catch (err: any) {
@@ -8617,12 +8827,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// 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");
}
const scope = parseScopeParam(req);
const routineStore = resolveRoutineStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const routine = await routineStore.getRoutine(id);
// Scope isolation: if scope is specified, verify the routine belongs to that scope
if (scope && routine.scope !== scope) {
throw notFound("Routine not found");
}
res.json(routine);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -8637,11 +8853,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// 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");
}
const scope = parseScopeParam(req);
const routineStore = resolveRoutineStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
// Scope isolation: if scope is specified, verify the routine belongs to that scope
if (scope) {
const existing = await routineStore.getRoutine(id);
if (existing.scope !== scope) {
throw notFound("Routine not found");
}
}
const { name, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
// Validate name if provided
@@ -8689,11 +8914,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// 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");
}
const scope = parseScopeParam(req);
const routineStore = resolveRoutineStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
// Scope isolation: if scope is specified, verify the routine belongs to that scope
if (scope) {
const existing = await routineStore.getRoutine(id);
if (existing.scope !== scope) {
throw notFound("Routine not found");
}
}
const deleted = await routineStore.deleteRoutine(id);
res.json(deleted);
} catch (err: any) {
@@ -8709,16 +8943,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// POST /routines/:id/run — manual trigger (backward-compatible alias for /trigger)
router.post("/routines/:id/run", async (req: Request, res: Response) => {
if (!routineStore) {
throw new ApiError(503, "Routine store not available");
}
if (!routineRunner) {
throw new ApiError(503, "Routine execution not available");
}
const scope = parseScopeParam(req);
const routineStore = resolveRoutineStore(req, scope);
const routineRunner = resolveRoutineRunner(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const routine = await routineStore.getRoutine(id);
// Scope isolation: if scope is specified, verify the routine belongs to that scope
if (scope && routine.scope !== scope) {
throw notFound("Routine not found");
}
// Validate routine is enabled
if (!routine.enabled) {
throw badRequest("Routine is disabled");
@@ -8742,16 +8979,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// POST /routines/:id/trigger — canonical manual trigger (uses RoutineRunner)
// POST /routines/:id/run is a backward-compatible alias with identical behavior
router.post("/routines/:id/trigger", async (req: Request, res: Response) => {
if (!routineStore) {
throw new ApiError(503, "Routine store not available");
}
if (!routineRunner) {
throw new ApiError(503, "Routine execution not available");
}
const scope = parseScopeParam(req);
const routineStore = resolveRoutineStore(req, scope);
const routineRunner = resolveRoutineRunner(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const routine = await routineStore.getRoutine(id);
// Scope isolation: if scope is specified, verify the routine belongs to that scope
if (scope && routine.scope !== scope) {
throw notFound("Routine not found");
}
// Validate routine is enabled
if (!routine.enabled) {
throw badRequest("Routine is disabled");
@@ -8774,12 +9014,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// 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");
}
const scope = parseScopeParam(req);
const routineStore = resolveRoutineStore(req, scope);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const routine = await routineStore.getRoutine(id);
// Scope isolation: if scope is specified, verify the routine belongs to that scope
if (scope && routine.scope !== scope) {
throw notFound("Routine not found");
}
res.json(routine.runHistory);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -8793,13 +9039,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// POST /routines/:id/webhook — incoming webhook trigger
// Note: Webhook routes do NOT use scope params from the request - webhooks are triggered
// externally and the routine's own scope determines which store to use.
// The webhook URL should include the scope implicitly via the routine ID.
router.post("/routines/:id/webhook", async (req: Request, res: Response) => {
if (!routineStore) {
throw new ApiError(503, "Routine store not available");
}
if (!routineRunner) {
throw new ApiError(503, "Routine execution not available");
}
// Webhook triggers don't accept scope params from the request
// The routine's scope field determines which store to use
const routineStore = resolveRoutineStore(req, undefined);
const routineRunner = resolveRoutineRunner(req, undefined);
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const routine = await routineStore.getRoutine(id);