feat(FN-1716): merge fusion/fn-1716
This commit is contained in:
35
README.md
35
README.md
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user