FN-9217: Fix project-scoped routine and automation listing

Project-scoped routine and automation reads now resolve the engine-backed project stores.

- Preserve legacy empty-list behavior for omitted and global scopes.
- Return project records through the same stores used for creation and surface unavailable stores as 503 errors.
- Add route regressions for populated, duplicate, empty, and unavailable project-store cases.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-9217-project-scoped-routine-list.md  |   7 ++
 .../src/__tests__/routes-automation.test.ts        | 121 +++++++++++++++++++--
 .../src/routes/register-plugins-automation.ts      |  26 +++--
 3 files changed, 134 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-9217

Fusion-Task-Lineage: bb2896d6-3e30-4b84-b86f-4a7c9394603a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-27 07:55:08 -07:00
parent 8fa9acbd6d
commit 6fd4fdd437
3 changed files with 134 additions and 20 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show project-scoped Automations and Routines instead of an empty list.
category: fix
dev: GET /routines and GET /automations now let scope=project bypass legacy global-store guards and resolve the project store.

View File

@@ -747,6 +747,26 @@ describe("Automation routes", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("reads project-scoped schedules from an engine-backed store without a global store", async () => {
const automationStore = createMockAutomationStore();
const projectSchedule = { ...FAKE_SCHEDULE, id: "sched-project-1", scope: "project" as const };
const globalSchedule = { ...FAKE_SCHEDULE, id: "sched-global-1", scope: "global" as const };
automationStore.listSchedules.mockResolvedValue([projectSchedule, globalSchedule]);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore(), {
engineManager: {
getEngine: vi.fn().mockReturnValue({ getAutomationStore: () => automationStore }),
},
} as any));
const res = await GET(app, "/api/automations?scope=project&projectId=proj-1");
expect(res.status).toBe(200);
expect(res.body).toEqual([projectSchedule]);
expect(automationStore.listSchedules).toHaveBeenCalledTimes(1);
});
});
describe("POST /automations", () => {
@@ -1900,16 +1920,33 @@ describe("Automation routes", () => {
expect(res.body.some((s: any) => s.scope === "global")).toBe(false);
});
it("returns empty array when automation store unavailable (scope=project) - legacy fallback", async () => {
// Build app WITHOUT automationStore option - routes return empty array for backward compatibility
const store = createMockStore();
it("returns 503 when no automation store is resolvable for scope=project", async () => {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
app.use("/api", createApiRoutes(createMockStore()));
const res = await GET(app, "/api/automations?scope=project&projectId=proj-1");
expect(res.status).toBe(503);
expect(res.body.error).toContain("Automation store not available");
});
it("returns an empty project list from the engine-backed automation store", async () => {
const automationStore = createMockAutomationStore();
automationStore.listSchedules.mockResolvedValue([]);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore(), {
engineManager: {
getEngine: vi.fn().mockReturnValue({ getAutomationStore: () => automationStore }),
},
} as any));
const res = await GET(app, "/api/automations?scope=project&projectId=proj-1");
const res = await GET(app, "/api/automations?scope=project");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(automationStore.listSchedules).toHaveBeenCalledTimes(1);
});
it("returns empty array when automation store unavailable (scope=global) - legacy fallback", async () => {
@@ -2030,6 +2067,53 @@ describe("Routine routes", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("reads duplicate project-scoped routines from an engine-backed store without a global store", async () => {
const routineStore = createMockRoutineStore();
const projectRoutineOne = { ...FAKE_ROUTINE, id: "routine-project-1", scope: "project" as const };
const projectRoutineTwo = { ...FAKE_ROUTINE, id: "routine-project-2", scope: "project" as const };
const globalRoutine = { ...FAKE_ROUTINE, id: "routine-global-1", scope: "global" as const };
routineStore.listRoutines.mockResolvedValue([projectRoutineOne, projectRoutineTwo, globalRoutine]);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore(), {
engineManager: {
getEngine: vi.fn().mockReturnValue({ getRoutineStore: () => routineStore }),
},
} as any));
const res = await GET(app, "/api/routines?scope=project&projectId=proj-1");
expect(res.status).toBe(200);
expect(res.body).toEqual([projectRoutineOne, projectRoutineTwo]);
expect(routineStore.listRoutines).toHaveBeenCalledTimes(1);
});
it("uses the same engine-backed routine store for project-scoped creation and listing", async () => {
const routineStore = createMockRoutineStore();
const createdRoutine = { ...FAKE_ROUTINE, id: "routine-created-1", name: "Created Project Routine" };
routineStore.createRoutine.mockResolvedValue(createdRoutine);
routineStore.listRoutines.mockResolvedValue([createdRoutine]);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore(), {
engineManager: {
getEngine: vi.fn().mockReturnValue({ getRoutineStore: () => routineStore }),
},
} as any));
const create = await REQUEST(app, "POST", "/api/routines?scope=project&projectId=proj-1", JSON.stringify({
name: "Created Project Routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
}), { "Content-Type": "application/json" });
const list = await GET(app, "/api/routines?scope=project&projectId=proj-1");
expect(create.status).toBe(201);
expect(routineStore.createRoutine).toHaveBeenCalledTimes(1);
expect(list.status).toBe(200);
expect(list.body).toEqual([createdRoutine]);
expect(routineStore.listRoutines).toHaveBeenCalledTimes(1);
});
});
describe("POST /routines", () => {
@@ -3022,16 +3106,33 @@ describe("Routine routes", () => {
expect(res.body.some((r: any) => r.scope === "global")).toBe(false);
});
it("returns empty array when routine store unavailable (scope=project) - legacy fallback", async () => {
// Build app WITHOUT routineStore option - routes return empty array for backward compatibility
const store = createMockStore();
it("returns 503 when no routine store is resolvable for scope=project", async () => {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
app.use("/api", createApiRoutes(createMockStore()));
const res = await GET(app, "/api/routines?scope=project&projectId=proj-1");
expect(res.status).toBe(503);
expect(res.body.error).toContain("Routine store not available");
});
it("returns an empty project list from the engine-backed routine store", async () => {
const routineStore = createMockRoutineStore();
routineStore.listRoutines.mockResolvedValue([]);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore(), {
engineManager: {
getEngine: vi.fn().mockReturnValue({ getRoutineStore: () => routineStore }),
},
} as any));
const res = await GET(app, "/api/routines?scope=project&projectId=proj-1");
const res = await GET(app, "/api/routines?scope=project");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(routineStore.listRoutines).toHaveBeenCalledTimes(1);
});
it("returns empty array when routine store unavailable (scope=global) - legacy fallback", async () => {

View File

@@ -46,13 +46,16 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
// 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 scope = parseScopeParam(req);
/*
FNXC:PluginsAutomationRoutes 2026-08-27-14:40:
The legacy empty-list fallback applies only to omitted/global reads. Project reads must
resolve the engine-backed store used by creates, surfacing 503 when none is available.
*/
if (!options?.automationStore && scope !== "project") {
return res.json([]);
}
const automationStore = resolveAutomationStore(req, scope);
// Get all schedules and filter by scope if specified
@@ -403,13 +406,16 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
// 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 scope = parseScopeParam(req);
/*
FNXC:PluginsAutomationRoutes 2026-08-27-14:40:
The legacy empty-list fallback applies only to omitted/global reads. Project reads must
resolve the engine-backed store used by creates, surfacing 503 when none is available.
*/
if (!options?.routineStore && scope !== "project") {
return res.json([]);
}
const routineStore = resolveRoutineStore(req, scope);
// Get all routines and filter by scope if specified