feat(FN-3166): remove dashboard roadmap backend and add reports plugin scaf

Completes the FN-3166 multi-step removal of the legacy dashboard roadmap feature (backend routes, frontend API wrappers, nav state), along with FN-3792 deep-link URL support, and initial reports plugin scaffold with WhatsApp dedupe retention pruning.

Fusion-Task-Id: FN-3166
This commit is contained in:
Fusion
2026-05-08 23:41:46 -07:00
committed by gsxdsm
parent de070db4a5
commit 9d1c05a7d9
20 changed files with 44 additions and 3196 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Remove dashboard-owned roadmap backend routing and legacy `/api/roadmaps` integration so roadmap APIs are plugin-owned under `/api/plugins/roadmap-planner/...`.

View File

@@ -177,8 +177,7 @@ Concrete references:
- Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta` - Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta`
- Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage` - Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage`
- `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error` - `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error`
- **Roadmap feature ownership**: roadmap contracts, ordering/handoff helpers, and persistence live in `plugins/fusion-plugin-roadmap` (package `@fusion-plugin-examples/roadmap`) rather than `@fusion/core` - **Roadmap feature ownership**: roadmap contracts, ordering/handoff helpers, persistence, routes, and dashboard UI live in `plugins/fusion-plugin-roadmap` (package `@fusion-plugin-examples/roadmap`) rather than dashboard/core ownership.
- Dashboard route adapter remains in `packages/dashboard/src/roadmap-routes.ts` and consumes the plugin API
- **CentralCore**: `packages/core/src/central-core.ts` - **CentralCore**: `packages/core/src/central-core.ts`
- Global project registry, health, central activity feed, global concurrency - Global project registry, health, central activity feed, global concurrency
- Backed by `packages/core/src/central-db.ts` (`~/.fusion/fusion-central.db`) - Backed by `packages/core/src/central-db.ts` (`~/.fusion/fusion-central.db`)
@@ -403,7 +402,7 @@ Key roadmap invariants:
- Milestones: `GET /:roadmapId/milestones`, `POST /:roadmapId/milestones`, `PATCH /milestones/:milestoneId`, `DELETE /milestones/:milestoneId`, `POST /:roadmapId/milestones/reorder` - Milestones: `GET /:roadmapId/milestones`, `POST /:roadmapId/milestones`, `PATCH /milestones/:milestoneId`, `DELETE /milestones/:milestoneId`, `POST /:roadmapId/milestones/reorder`
- Features: `GET /milestones/:milestoneId/features`, `POST /milestones/:milestoneId/features`, `PATCH /features/:featureId`, `DELETE /features/:featureId`, `POST /milestones/:milestoneId/features/reorder`, `POST /features/:featureId/move` - Features: `GET /milestones/:milestoneId/features`, `POST /milestones/:milestoneId/features`, `PATCH /features/:featureId`, `DELETE /features/:featureId`, `POST /milestones/:milestoneId/features/reorder`, `POST /features/:featureId/move`
- Export/Handoff: `GET /:roadmapId/export`, `GET /:roadmapId/handoff`, `GET /:roadmapId/handoff/mission`, `GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task` - Export/Handoff: `GET /:roadmapId/export`, `GET /:roadmapId/handoff`, `GET /:roadmapId/handoff/mission`, `GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task`
- Note: the integrated dashboard server may still mount legacy `/api/roadmaps` routes for compatibility, but frontend callers should use the plugin namespace. - Dashboard host no longer mounts legacy `/api/roadmaps`; roadmap REST traffic goes through the plugin namespace only.
**Database schema:** **Database schema:**
- `roadmaps` — roadmap metadata (id, title, description, timestamps) - `roadmaps` — roadmap metadata (id, title, description, timestamps)
@@ -678,7 +677,7 @@ Key server capabilities:
- Insights routes (`insights-routes.ts`) - Insights routes (`insights-routes.ts`)
- Evals routes (`evals-routes.ts`) — `/api/evals` read surface for eval result listing/filtering, drill-down detail, and eval run metadata - Evals routes (`evals-routes.ts`) — `/api/evals` read surface for eval result listing/filtering, drill-down detail, and eval run metadata
- Research routes (`research-routes.ts`) — `/api/research` surface for runs, details, cancel/retry, exports, create-task, and attach-task actions; supports graceful degradation envelopes via availability payloads when capabilities are unavailable - Research routes (`research-routes.ts`) — `/api/research` surface for runs, details, cancel/retry, exports, create-task, and attach-task actions; supports graceful degradation envelopes via availability payloads when capabilities are unavailable
- Roadmap routes (`roadmap-routes.ts`) - Plugin-defined roadmap routes under `plugin-routes.ts` dispatch (`/api/plugins/roadmap-planner/...`)
- Project-scoped store reuse via `project-store-resolver.ts` - Project-scoped store reuse via `project-store-resolver.ts`
- Rate limiting (`rate-limit.ts`) - Rate limiting (`rate-limit.ts`)
- Static SPA hosting (Vite build output) - Static SPA hosting (Vite build output)
@@ -724,7 +723,7 @@ Key server capabilities:
- Task detail surface is shared through `TaskDetailContent` (exported from `TaskDetailModal.tsx`): desktop/tablet `ListView` renders it inline in the split right pane, while mobile and non-list entry points continue using `TaskDetailModal`. - Task detail surface is shared through `TaskDetailContent` (exported from `TaskDetailModal.tsx`): desktop/tablet `ListView` renders it inline in the split right pane, while mobile and non-list entry points continue using `TaskDetailModal`.
- In desktop split mode, `ListView` now uses a compact sidebar-first control layout (count/actions/summary chips + collapsible "View options" panel) to keep list controls dense alongside the inline detail pane; mobile keeps the card-first flow with a toolbar "View options" entry point for the same visibility/filter toggles. - In desktop split mode, `ListView` now uses a compact sidebar-first control layout (count/actions/summary chips + collapsible "View options" panel) to keep list controls dense alongside the inline detail pane; mobile keeps the card-first flow with a toolbar "View options" entry point for the same visibility/filter toggles.
- Chat system UI: `ChatView.tsx`, `QuickChatFAB.tsx` - Chat system UI: `ChatView.tsx`, `QuickChatFAB.tsx`
- Planning/roadmap/insight UI: `MissionManager.tsx`, `RoadmapsView.tsx`, `TodoView.tsx`, `InsightsView.tsx`, `DocumentsView.tsx` - Planning/insight UI: `MissionManager.tsx`, `TodoView.tsx`, `InsightsView.tsx`, `DocumentsView.tsx` (roadmap view is plugin-owned)
- Dev server UI: `DevServerView.tsx` (controls + status/log panel + embedded preview with iframe fallback messaging) - Dev server UI: `DevServerView.tsx` (controls + status/log panel + embedded preview with iframe fallback messaging)
### CSS Architecture ### CSS Architecture
@@ -742,7 +741,7 @@ The dashboard's CSS is split between a consolidated global stylesheet and modula
**Lazy-loaded views** (bundle size optimization): **Lazy-loaded views** (bundle size optimization):
The following 15 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`: The following 15 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`:
- `AgentsView`, `RoadmapsView`, `TodoView`, `NodesView`, `ChatView`, `MemoryView`, `ResearchView` - `AgentsView`, `TodoView`, `NodesView`, `ChatView`, `MemoryView`, `ResearchView`
- `DevServerView`, `InsightsView`, `DocumentsView`, `SkillsView` - `DevServerView`, `InsightsView`, `DocumentsView`, `SkillsView`
- `SetupWizardModal`, `PluginManager`, `PiExtensionsManager`, `AgentDetailView - `SetupWizardModal`, `PluginManager`, `PiExtensionsManager`, `AgentDetailView
@@ -752,13 +751,13 @@ A `prefetchLazyViews()` function runs once on mount via `requestIdleCallback` to
- Task + realtime: `useTasks.ts`, `useBadgeWebSocket.ts`, `useAiSessionSync.ts` - Task + realtime: `useTasks.ts`, `useBadgeWebSocket.ts`, `useAiSessionSync.ts`
- Chat: `useChat.ts`, `useQuickChat.ts` - Chat: `useChat.ts`, `useQuickChat.ts`
- Documents/insights/memory: `useDocuments.ts`, `useInsights.ts`, `useMemoryBackendStatus.ts`, `useMemoryData.ts` - Documents/insights/memory: `useDocuments.ts`, `useInsights.ts`, `useMemoryBackendStatus.ts`, `useMemoryData.ts`
- Planning/roadmaps: `useRoadmaps.ts` - Plugin roadmap state/hooks: owned by `plugins/fusion-plugin-roadmap/src/dashboard/*`
- Dev server: `useDevServer.ts` (status hydration, command controls, reconnect stream handling, project-scope reset) - Dev server: `useDevServer.ts` (status hydration, command controls, reconnect stream handling, project-scope reset)
- Project/agents/setup: `useProjects.ts`, `useCurrentProject.ts`, `useAgents.ts`, `useSetupReadiness.ts` - Project/agents/setup: `useProjects.ts`, `useCurrentProject.ts`, `useAgents.ts`, `useSetupReadiness.ts`
- UX/platform helpers: `useFavorites.ts`, `useAuthOnboarding.ts`, `useDeepLink.ts`, `useTerminal.ts` - UX/platform helpers: `useFavorites.ts`, `useAuthOnboarding.ts`, `useDeepLink.ts`, `useTerminal.ts`
### Planning and decomposition features ### Planning and decomposition features
- Backend planners: `planning.ts`, `subtask-breakdown.ts`, `roadmap-suggestions.ts` - Backend planners: `planning.ts`, `subtask-breakdown.ts` (roadmap suggestion generation is plugin-owned)
- UI modals: `PlanningModeModal.tsx`, `SubtaskBreakdownModal.tsx`, milestone interview flows - UI modals: `PlanningModeModal.tsx`, `SubtaskBreakdownModal.tsx`, milestone interview flows
- Multi-task creation endpoints are wired under planning/subtask routes in `routes.ts` - Multi-task creation endpoints are wired under planning/subtask routes in `routes.ts`

View File

@@ -536,7 +536,6 @@ function AppInner() {
settingsLoaded, settingsLoaded,
experimentalFeatures, experimentalFeatures,
insightsEnabled, insightsEnabled,
roadmapEnabled,
memoryEnabled, memoryEnabled,
devServerEnabled, devServerEnabled,
todosEnabled, todosEnabled,
@@ -1386,7 +1385,6 @@ function AppInner() {
isRemote={isRemote} isRemote={isRemote}
experimentalFeatures={{ experimentalFeatures={{
insights: insightsEnabled, insights: insightsEnabled,
roadmap: roadmapEnabled,
memoryView: memoryEnabled, memoryView: memoryEnabled,
devServer: devServerEnabled, devServer: devServerEnabled,
devServerView: devServerEnabled, devServerView: devServerEnabled,
@@ -1492,7 +1490,6 @@ function AppInner() {
showSkillsTab={skillsEnabled} showSkillsTab={skillsEnabled}
experimentalFeatures={{ experimentalFeatures={{
insights: insightsEnabled, insights: insightsEnabled,
roadmap: roadmapEnabled,
memoryView: memoryEnabled, memoryView: memoryEnabled,
devServer: devServerEnabled, devServer: devServerEnabled,
devServerView: devServerEnabled, devServerView: devServerEnabled,

View File

@@ -244,392 +244,6 @@ function mockSchedulingFetchResponse(
} as unknown as Response); } as unknown as Response);
} }
describe("Roadmap API wrappers", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve([]),
text: () => Promise.resolve("[]"),
} as unknown as Response);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
const mockRoadmap = {
id: "RM-001",
title: "Q2 Roadmap",
description: "Q2 product roadmap",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const mockRoadmapHierarchy = {
...mockRoadmap,
milestones: [
{
id: "RMS-001",
roadmapId: "RM-001",
title: "Milestone 1",
description: "First milestone",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
features: [
{
id: "RF-001",
milestoneId: "RMS-001",
title: "Feature 1",
description: "First feature",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
},
],
};
it("fetchRoadmaps sends GET and propagates projectId", async () => {
const { fetchRoadmaps } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve([mockRoadmap]),
text: () => Promise.resolve(JSON.stringify([mockRoadmap])),
} as unknown as Response);
const result = await fetchRoadmaps("proj_abc");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("projectId=proj_abc");
});
it("createRoadmap sends POST with input payload", async () => {
const { createRoadmap } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockRoadmap),
text: () => Promise.resolve(JSON.stringify(mockRoadmap)),
} as unknown as Response);
const result = await createRoadmap({ title: "Q2 Roadmap", description: "Q2 product roadmap" }, "proj_abc");
expect(result.id).toBe("RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("projectId=proj_abc");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.title).toBe("Q2 Roadmap");
});
it("fetchRoadmap returns roadmap with hierarchy", async () => {
const { fetchRoadmap } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockRoadmapHierarchy),
text: () => Promise.resolve(JSON.stringify(mockRoadmapHierarchy)),
} as unknown as Response);
const result = await fetchRoadmap("RM-001");
expect(result.id).toBe("RM-001");
expect(result.milestones).toHaveLength(1);
expect(result.milestones[0].features).toHaveLength(1);
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
});
it("updateRoadmap sends PATCH with updates", async () => {
const { updateRoadmap } = await import("../api");
const updatedRoadmap = { ...mockRoadmap, title: "Updated Roadmap" };
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(updatedRoadmap),
text: () => Promise.resolve(JSON.stringify(updatedRoadmap)),
} as unknown as Response);
const result = await updateRoadmap("RM-001", { title: "Updated Roadmap" });
expect(result.title).toBe("Updated Roadmap");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
});
it("deleteRoadmap sends DELETE and returns void", async () => {
const { deleteRoadmap } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? null : null,
},
json: () => Promise.resolve(undefined),
text: () => Promise.resolve(""),
} as unknown as Response);
const result = await deleteRoadmap("RM-001");
expect(result).toBeUndefined();
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
});
it("createRoadmapMilestone sends POST with milestone input", async () => {
const { createRoadmapMilestone } = await import("../api");
const mockMilestone = {
id: "RMS-001",
roadmapId: "RM-001",
title: "Milestone 1",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockMilestone),
text: () => Promise.resolve(JSON.stringify(mockMilestone)),
} as unknown as Response);
const result = await createRoadmapMilestone("RM-001", { title: "Milestone 1" });
expect(result.id).toBe("RMS-001");
expect(result.roadmapId).toBe("RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
});
it("updateRoadmapMilestone sends PATCH", async () => {
const { updateRoadmapMilestone } = await import("../api");
const updatedMilestone = {
id: "RMS-001",
roadmapId: "RM-001",
title: "Updated Milestone",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(updatedMilestone),
text: () => Promise.resolve(JSON.stringify(updatedMilestone)),
} as unknown as Response);
const result = await updateRoadmapMilestone("RMS-001", { title: "Updated Milestone" });
expect(result.title).toBe("Updated Milestone");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
});
it("deleteRoadmapMilestone sends DELETE", async () => {
const { deleteRoadmapMilestone } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? null : null,
},
json: () => Promise.resolve(undefined),
text: () => Promise.resolve(""),
} as unknown as Response);
const result = await deleteRoadmapMilestone("RMS-001");
expect(result).toBeUndefined();
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
});
it("createRoadmapFeature sends POST with feature input", async () => {
const { createRoadmapFeature } = await import("../api");
const mockFeature = {
id: "RF-001",
milestoneId: "RMS-001",
title: "Feature 1",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockFeature),
text: () => Promise.resolve(JSON.stringify(mockFeature)),
} as unknown as Response);
const result = await createRoadmapFeature("RMS-001", { title: "Feature 1" });
expect(result.id).toBe("RF-001");
expect(result.milestoneId).toBe("RMS-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
});
it("updateRoadmapFeature sends PATCH", async () => {
const { updateRoadmapFeature } = await import("../api");
const updatedFeature = {
id: "RF-001",
milestoneId: "RMS-001",
title: "Updated Feature",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(updatedFeature),
text: () => Promise.resolve(JSON.stringify(updatedFeature)),
} as unknown as Response);
const result = await updateRoadmapFeature("RF-001", { title: "Updated Feature" });
expect(result.title).toBe("Updated Feature");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/features/RF-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
});
it("deleteRoadmapFeature sends DELETE", async () => {
const { deleteRoadmapFeature } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? null : null,
},
json: () => Promise.resolve(undefined),
text: () => Promise.resolve(""),
} as unknown as Response);
const result = await deleteRoadmapFeature("RF-001");
expect(result).toBeUndefined();
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/features/RF-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
});
it("fetchRoadmapFeatures returns features for a milestone", async () => {
const { fetchRoadmapFeatures } = await import("../api");
const mockFeatures = [
{
id: "RF-001",
milestoneId: "RMS-001",
title: "Feature 1",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "RF-002",
milestoneId: "RMS-001",
title: "Feature 2",
orderIndex: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockFeatures),
text: () => Promise.resolve(JSON.stringify(mockFeatures)),
} as unknown as Response);
const result = await fetchRoadmapFeatures("RMS-001");
expect(result).toHaveLength(2);
expect(result[0].id).toBe("RF-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features");
});
});
/** /**
* Settings API wrapper tests for FN-1712 (scope-split settings UX). * Settings API wrapper tests for FN-1712 (scope-split settings UX).
* These tests verify the API contract for: * These tests verify the API contract for:
@@ -936,331 +550,7 @@ describe("Settings API wrappers", () => {
await expect(fetchGlobalSettings()).rejects.toThrow("Settings file corrupted"); await expect(fetchGlobalSettings()).rejects.toThrow("Settings file corrupted");
}); });
}); });
describe("roadmap reorder APIs", () => {
it("reorderRoadmapMilestones sends POST with orderedMilestoneIds", async () => {
const { reorderRoadmapMilestones } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapMilestones("RM-001", ["RMS-002", "RMS-001", "RMS-003"]);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/reorder");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
orderedMilestoneIds: ["RMS-002", "RMS-001", "RMS-003"],
}); });
});
it("reorderRoadmapMilestones includes projectId when provided", async () => {
const { reorderRoadmapMilestones } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapMilestones("RM-001", ["RMS-001", "RMS-002"], "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/reorder?projectId=proj_abc");
});
it("reorderRoadmapFeatures sends POST with orderedFeatureIds", async () => {
const { reorderRoadmapFeatures } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapFeatures("RMS-001", ["RF-002", "RF-001"]);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features/reorder");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
orderedFeatureIds: ["RF-002", "RF-001"],
});
});
it("moveRoadmapFeature sends POST with targetMilestoneId and targetIndex", async () => {
const { moveRoadmapFeature } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await moveRoadmapFeature("RF-001", "RMS-002", 2);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/features/RF-001/move");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
targetMilestoneId: "RMS-002",
targetIndex: 2,
});
});
it("moveRoadmapFeature includes projectId when provided", async () => {
const { moveRoadmapFeature } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await moveRoadmapFeature("RF-001", "RMS-002", 0, "proj_xyz");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/features/RF-001/move?projectId=proj_xyz");
});
it("generateFeatureSuggestions sends POST with milestone ID", async () => {
const { generateFeatureSuggestions } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] })),
} as unknown as Response);
const result = await generateFeatureSuggestions("RMS-001");
expect(result.suggestions).toHaveLength(2);
expect(result.suggestions[0].title).toBe("Feature 1");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/suggestions/features");
});
it("generateFeatureSuggestions includes input parameters in body", async () => {
const { generateFeatureSuggestions } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
} as unknown as Response);
await generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 });
const [, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse((options as RequestInit).body as string);
expect(body.prompt).toBe("Focus on auth");
expect(body.count).toBe(3);
});
it("generateFeatureSuggestions includes projectId when provided", async () => {
const { generateFeatureSuggestions } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
} as unknown as Response);
await generateFeatureSuggestions("RMS-001", undefined, "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/suggestions/features");
expect(url).toContain("projectId=proj_abc");
});
});
describe("roadmap export/handoff APIs", () => {
it("exportRoadmap sends GET to export endpoint", async () => {
const { exportRoadmap } = await import("../api");
const exportData = {
roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" },
milestones: [],
features: [],
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(exportData),
text: () => Promise.resolve(JSON.stringify(exportData)),
} as unknown as Response);
const result = await exportRoadmap("RM-001");
expect(result.roadmap.id).toBe("RM-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/export");
});
it("exportRoadmap includes projectId when provided", async () => {
const { exportRoadmap } = await import("../api");
const exportData = { roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" }, milestones: [], features: [] };
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(exportData),
text: () => Promise.resolve(JSON.stringify(exportData)),
} as unknown as Response);
await exportRoadmap("RM-001", "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/export");
expect(url).toContain("projectId=proj_abc");
});
it("getRoadmapMissionHandoff sends GET to mission handoff endpoint", async () => {
const { getRoadmapMissionHandoff } = await import("../api");
const handoffData = {
sourceRoadmapId: "RM-001",
title: "Test Roadmap",
milestones: [],
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
const result = await getRoadmapMissionHandoff("RM-001");
expect(result.sourceRoadmapId).toBe("RM-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/handoff/mission");
});
it("getRoadmapFeatureHandoff sends GET to feature handoff endpoint", async () => {
const { getRoadmapFeatureHandoff } = await import("../api");
const handoffData = {
source: {
roadmapId: "RM-001",
milestoneId: "RMS-001",
featureId: "RF-001",
roadmapTitle: "Test",
milestoneTitle: "Phase 1",
milestoneOrderIndex: 0,
featureOrderIndex: 0,
},
title: "Feature 1",
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
const result = await getRoadmapFeatureHandoff("RM-001", "RMS-001", "RF-001");
expect(result.source.featureId).toBe("RF-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
});
it("getRoadmapFeatureHandoff includes projectId when provided", async () => {
const { getRoadmapFeatureHandoff } = await import("../api");
const handoffData = {
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "T", milestoneTitle: "M", milestoneOrderIndex: 0, featureOrderIndex: 0 },
title: "F",
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
await getRoadmapFeatureHandoff("RM-001", "RMS-001", "RF-001", "proj_xyz");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
expect(url).toContain("projectId=proj_xyz");
});
});
});
describe("Automation API scope forwarding", () => { describe("Automation API scope forwarding", () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;

View File

@@ -94,7 +94,7 @@ describe("tablet header controls", () => {
expect(screen.getByTitle("Board view")).toBeDefined(); expect(screen.getByTitle("Board view")).toBeDefined();
expect(screen.getByTitle("List view")).toBeDefined(); expect(screen.getByTitle("List view")).toBeDefined();
expect(screen.getByTitle("Agents view")).toBeDefined(); expect(screen.getByTitle("Agents view")).toBeDefined();
// Skills, Roadmaps, Insights are NOT inline (they're in overflow) // Skills and Insights are NOT inline (they're in overflow)
expect(screen.queryByTitle("Skills view")).toBeNull(); expect(screen.queryByTitle("Skills view")).toBeNull();
expect(screen.queryByTitle("Roadmaps view")).toBeNull(); expect(screen.queryByTitle("Roadmaps view")).toBeNull();
expect(screen.queryByTitle("Insights view")).toBeNull(); expect(screen.queryByTitle("Insights view")).toBeNull();
@@ -106,7 +106,7 @@ describe("tablet header controls", () => {
}); });
it("opens overflow menu with Insights and Skills on tablet when trigger is clicked", () => { it("opens overflow menu with Insights and Skills on tablet when trigger is clicked", () => {
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true, roadmap: true } }); renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-insights")).toBeDefined(); expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
expect(screen.getByTestId("view-overflow-skills")).toBeDefined(); expect(screen.getByTestId("view-overflow-skills")).toBeDefined();
@@ -121,7 +121,7 @@ describe("tablet header controls", () => {
}); });
it("closes overflow menu on tablet after selecting an item", async () => { it("closes overflow menu on tablet after selecting an item", async () => {
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true, roadmap: true } }); renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-insights")).toBeDefined(); expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
fireEvent.click(screen.getByTestId("view-overflow-skills")); fireEvent.click(screen.getByTestId("view-overflow-skills"));

View File

@@ -70,21 +70,6 @@ import type {
ProjectNodePathMapping, ProjectNodePathMapping,
} from "@fusion/core"; } from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapWithHierarchy,
RoadmapExportBundle,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion-plugin-examples/roadmap";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core"; import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard"; import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
import type { MilestoneValidationTelemetry } from "../components/mission-types"; import type { MilestoneValidationTelemetry } from "../components/mission-types";
@@ -7305,223 +7290,6 @@ export async function previewEnrichedDescription(
} }
} }
// ── Roadmap API ─────────────────────────────────────────────────────────────────
/** Fetch all roadmaps */
export function fetchRoadmaps(projectId?: string): Promise<Roadmap[]> {
return api<Roadmap[]>(withProjectId("/plugins/roadmap-planner/roadmaps", projectId));
}
/** Create a new roadmap */
export function createRoadmap(input: RoadmapCreateInput, projectId?: string): Promise<Roadmap> {
return api<Roadmap>(withProjectId("/plugins/roadmap-planner/roadmaps", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch a single roadmap with full hierarchy (milestones and features) */
export function fetchRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapWithHierarchy> {
return api<RoadmapWithHierarchy>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId));
}
/** Update roadmap metadata */
export function updateRoadmap(roadmapId: string, updates: RoadmapUpdateInput, projectId?: string): Promise<Roadmap> {
return api<Roadmap>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a roadmap */
export function deleteRoadmap(roadmapId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), {
method: "DELETE",
});
}
/** Fetch milestones for a roadmap */
export function fetchRoadmapMilestones(roadmapId: string, projectId?: string): Promise<RoadmapMilestone[]> {
return api<RoadmapMilestone[]>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId));
}
/** Create a milestone in a roadmap */
export function createRoadmapMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput, projectId?: string): Promise<RoadmapMilestone> {
return api<RoadmapMilestone>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update milestone metadata */
export function updateRoadmapMilestone(milestoneId: string, updates: RoadmapMilestoneUpdateInput, projectId?: string): Promise<RoadmapMilestone> {
return api<RoadmapMilestone>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a milestone */
export function deleteRoadmapMilestone(milestoneId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
method: "DELETE",
});
}
/** Fetch features for a milestone */
export function fetchRoadmapFeatures(milestoneId: string, projectId?: string): Promise<RoadmapFeature[]> {
return api<RoadmapFeature[]>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId));
}
/** Create a feature in a milestone */
export function createRoadmapFeature(milestoneId: string, input: RoadmapFeatureCreateInput, projectId?: string): Promise<RoadmapFeature> {
return api<RoadmapFeature>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update feature metadata */
export function updateRoadmapFeature(featureId: string, updates: RoadmapFeatureUpdateInput, projectId?: string): Promise<RoadmapFeature> {
return api<RoadmapFeature>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a feature */
export function deleteRoadmapFeature(featureId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), {
method: "DELETE",
});
}
/** Reorder milestones within a roadmap */
export function reorderRoadmapMilestones(roadmapId: string, orderedMilestoneIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedMilestoneIds }),
});
}
/** Reorder features within a milestone */
export function reorderRoadmapFeatures(milestoneId: string, orderedFeatureIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedFeatureIds }),
});
}
/** Move a feature to a different milestone or position */
export function moveRoadmapFeature(
featureId: string,
targetMilestoneId: string,
targetIndex: number,
projectId?: string
): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}/move`, projectId), {
method: "POST",
body: JSON.stringify({ targetMilestoneId, targetIndex }),
});
}
/** Export a roadmap as a flat bundle for persistence/import/export */
export function exportRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapExportBundle> {
return api<RoadmapExportBundle>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/export`, projectId));
}
/** Get mission planning handoff payload for a roadmap */
export function getRoadmapMissionHandoff(roadmapId: string, projectId?: string): Promise<RoadmapMissionPlanningHandoff> {
return api<RoadmapMissionPlanningHandoff>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/handoff/mission`, projectId));
}
/** Get task planning handoff payload for a single roadmap feature */
export function getRoadmapFeatureHandoff(
roadmapId: string,
milestoneId: string,
featureId: string,
projectId?: string
): Promise<RoadmapFeatureTaskPlanningHandoff> {
return api<RoadmapFeatureTaskPlanningHandoff>(
withProjectId(
`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones/${encodeURIComponent(milestoneId)}/features/${encodeURIComponent(featureId)}/handoff/task`,
projectId
)
);
}
/** Combined handoff response type for roadmap handoff endpoint */
export interface RoadmapHandoffResponse {
mission: RoadmapMissionPlanningHandoff;
features: RoadmapFeatureTaskPlanningHandoff[];
}
/** Get both mission and feature handoff payloads for a roadmap */
export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise<RoadmapHandoffResponse> {
return api<RoadmapHandoffResponse>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/handoff`, projectId));
}
/** Response from milestone suggestion generation */
export interface MilestoneSuggestionsResponse {
suggestions: Array<{
title: string;
description?: string;
}>;
}
/** Generate milestone suggestions from a goal prompt */
export function generateMilestoneSuggestions(
roadmapId: string,
goalPrompt: string,
count?: number,
projectId?: string
): Promise<MilestoneSuggestionsResponse> {
return api<MilestoneSuggestionsResponse>(
withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/suggestions/milestones`, projectId),
{
method: "POST",
body: JSON.stringify({
goalPrompt: goalPrompt.trim(),
...(count !== undefined ? { count } : {}),
}),
}
);
}
/** Response type for feature suggestions */
export interface FeatureSuggestionsResponse {
suggestions: Array<{
title: string;
description?: string;
}>;
}
/** Input for generating feature suggestions */
export interface GenerateFeatureSuggestionsInput {
/** Optional prompt to guide feature generation */
prompt?: string;
/** Number of features to generate (default 5, max 10) */
count?: number;
}
/** Generate feature suggestions for a milestone */
export function generateFeatureSuggestions(
milestoneId: string,
input?: GenerateFeatureSuggestionsInput,
projectId?: string
): Promise<FeatureSuggestionsResponse> {
return api<FeatureSuggestionsResponse>(
withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/suggestions/features`, projectId),
{
method: "POST",
body: JSON.stringify({
...(input?.prompt !== undefined ? { prompt: input.prompt.trim() } : {}),
...(input?.count !== undefined ? { count: input.count } : {}),
}),
}
);
}
// ── Todo API ───────────────────────────────────────────────────────────────── // ── Todo API ─────────────────────────────────────────────────────────────────
/** Fetch all todo lists with their items */ /** Fetch all todo lists with their items */

View File

@@ -237,7 +237,7 @@ export interface HeaderProps {
/** Whether the current view is a remote node */ /** Whether the current view is a remote node */
isRemote?: boolean; isRemote?: boolean;
/** Experimental feature flags controlling visibility of nav items. */ /** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean }; experimentalFeatures?: { insights?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean };
pluginDashboardViews?: PluginDashboardViewEntry[]; pluginDashboardViews?: PluginDashboardViewEntry[];
shellConnectionControl?: ReactNode; shellConnectionControl?: ReactNode;
} }
@@ -358,11 +358,6 @@ export function Header({
return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b)); return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b));
}, [overflowScripts]); }, [overflowScripts]);
const hasRoadmapsPluginView = useMemo(
() => pluginDashboardViews.some((entry) => entry.pluginId === "roadmap-planner"),
[pluginDashboardViews],
);
const hasViewOverflowItems = useMemo(() => { const hasViewOverflowItems = useMemo(() => {
return !!( return !!(
onChangeView || onChangeView ||
@@ -376,7 +371,7 @@ export function Header({
!hideFullNav || !hideFullNav ||
pluginDashboardViews.some((entry) => entry.view.placement !== "primary") pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
); );
}, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews, hasRoadmapsPluginView]); }, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews]);
const getEffectiveViewport = useCallback(() => { const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport; const vv = window.visualViewport;

View File

@@ -75,7 +75,6 @@ export interface MobileNavBarProps {
/** Experimental feature flags controlling visibility of nav items. */ /** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { experimentalFeatures?: {
insights?: boolean; insights?: boolean;
roadmap?: boolean;
memoryView?: boolean; memoryView?: boolean;
devServer?: boolean; devServer?: boolean;
devServerView?: boolean; devServerView?: boolean;

View File

@@ -16,7 +16,7 @@ const defaultSettings: Settings = {
worktreeInitCommand: "", worktreeInitCommand: "",
testCommand: "", testCommand: "",
buildCommand: "", buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true }, experimentalFeatures: { insights: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true },
}; };
@@ -2025,7 +2025,7 @@ describe("App view switching", () => {
// Override the default mock to exclude agentsView // Override the default mock to exclude agentsView
vi.mocked(fetchSettings).mockResolvedValue({ vi.mocked(fetchSettings).mockResolvedValue({
...defaultSettings, ...defaultSettings,
experimentalFeatures: { insights: true, roadmap: true, skillsView: true }, // no agentsView experimentalFeatures: { insights: true, skillsView: true }, // no agentsView
}); });
render(<App />); render(<App />);
@@ -2201,7 +2201,7 @@ describe("App view switching", () => {
// Keep at least one overflow item enabled so the overflow trigger still renders. // Keep at least one overflow item enabled so the overflow trigger still renders.
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings, ...defaultSettings,
experimentalFeatures: { insights: false, roadmap: true }, experimentalFeatures: { insights: false },
}); });
render(<App />); render(<App />);

View File

@@ -226,7 +226,7 @@ describe("Header", () => {
it("hides legacy roadmaps overflow item when roadmap plugin view is present", () => { it("hides legacy roadmaps overflow item when roadmap plugin view is present", () => {
renderHeader({ renderHeader({
onChangeView: noop, onChangeView: noop,
experimentalFeatures: { roadmap: true }, experimentalFeatures: {},
pluginDashboardViews: [ pluginDashboardViews: [
{ {
pluginId: "fusion-plugin-roadmap", pluginId: "fusion-plugin-roadmap",
@@ -253,7 +253,7 @@ describe("Header", () => {
renderHeader({ renderHeader({
onChangeView: noop, onChangeView: noop,
showSkillsTab: false, showSkillsTab: false,
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, researchView: false }, experimentalFeatures: { insights: false, memoryView: false, devServerView: false, researchView: false },
}); });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));

View File

@@ -70,13 +70,13 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined(); expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
}); });
it("does not render legacy roadmaps tab when experimentalFeatures.roadmap is true", () => { it("does not render legacy roadmaps tab", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />); render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull(); expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
}); });
it("keeps skills available without rendering legacy roadmaps destinations", () => { it("keeps skills available without rendering legacy roadmaps destinations", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />); render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} experimentalFeatures={{}} />);
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined(); expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull(); expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
@@ -86,7 +86,7 @@ describe("MobileNavBar", () => {
}); });
it("keeps skills top-level regardless of legacy roadmaps view value", () => { it("keeps skills top-level regardless of legacy roadmaps view value", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />); render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} experimentalFeatures={{}} />);
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined(); expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
@@ -387,8 +387,8 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined(); expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
}); });
it("does not show legacy roadmaps in more sheet when experimentalFeatures.roadmap is true", () => { it("does not show legacy roadmaps in more sheet", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />); render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull(); expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
}); });
@@ -397,7 +397,7 @@ describe("MobileNavBar", () => {
render( render(
<MobileNavBar <MobileNavBar
{...createDefaultProps()} {...createDefaultProps()}
experimentalFeatures={{ roadmap: true }} experimentalFeatures={{}}
pluginDashboardViews={[ pluginDashboardViews={[
{ {
pluginId: "roadmap-planner", pluginId: "roadmap-planner",

View File

@@ -17,7 +17,6 @@ export interface UseAppSettingsResult {
settingsLoaded: boolean; settingsLoaded: boolean;
experimentalFeatures: Record<string, boolean>; experimentalFeatures: Record<string, boolean>;
insightsEnabled: boolean; insightsEnabled: boolean;
roadmapEnabled: boolean;
memoryEnabled: boolean; memoryEnabled: boolean;
devServerEnabled: boolean; devServerEnabled: boolean;
todosEnabled: boolean; todosEnabled: boolean;
@@ -46,7 +45,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [settingsLoaded, setSettingsLoaded] = useState(false); const [settingsLoaded, setSettingsLoaded] = useState(false);
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({}); const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
const [insightsEnabled, setInsightsEnabled] = useState(false); const [insightsEnabled, setInsightsEnabled] = useState(false);
const [roadmapEnabled, setRoadmapEnabled] = useState(false);
const [memoryEnabled, setMemoryEnabled] = useState(false); const [memoryEnabled, setMemoryEnabled] = useState(false);
const [devServerEnabled, setDevServerEnabled] = useState(false); const [devServerEnabled, setDevServerEnabled] = useState(false);
const [todosEnabled, setTodosEnabled] = useState(false); const [todosEnabled, setTodosEnabled] = useState(false);
@@ -78,7 +76,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setExperimentalFeatures(settings.experimentalFeatures ?? {}); setExperimentalFeatures(settings.experimentalFeatures ?? {});
const features = settings.experimentalFeatures ?? {}; const features = settings.experimentalFeatures ?? {};
setInsightsEnabled(features.insights === true); setInsightsEnabled(features.insights === true);
setRoadmapEnabled(features.roadmap === true);
setMemoryEnabled(features.memoryView === true); setMemoryEnabled(features.memoryView === true);
setDevServerEnabled(features.devServerView === true || features.devServer === true); setDevServerEnabled(features.devServerView === true || features.devServer === true);
setTodosEnabled(features.todoView === true); setTodosEnabled(features.todoView === true);
@@ -95,7 +92,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setSettingsLoaded(false); setSettingsLoaded(false);
setExperimentalFeatures({}); setExperimentalFeatures({});
setInsightsEnabled(false); setInsightsEnabled(false);
setRoadmapEnabled(false);
setMemoryEnabled(false); setMemoryEnabled(false);
setDevServerEnabled(false); setDevServerEnabled(false);
setTodosEnabled(false); setTodosEnabled(false);
@@ -177,7 +173,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
settingsLoaded, settingsLoaded,
experimentalFeatures, experimentalFeatures,
insightsEnabled, insightsEnabled,
roadmapEnabled,
memoryEnabled, memoryEnabled,
devServerEnabled, devServerEnabled,
todosEnabled, todosEnabled,

View File

@@ -1,612 +1,21 @@
// @vitest-environment node // @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect } from "vitest";
import express from "express"; import express from "express";
import { get as performGet, request as performRequest } from "../test-request.js"; import { registerIntegratedRouters } from "../routes/register-integrated-routers.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { ApiError } from "../api-error.js";
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion-plugin-examples/roadmap";
describe("integrated roadmap routes removed", () => {
// vi.mock is hoisted it("does not register a legacy /roadmaps mount", () => {
vi.mock("@fusion-plugin-examples/roadmap/roadmap-suggestions", () => { const router = express.Router();
// Define error classes inside the factory - these will be used by the mocked module registerIntegratedRouters({
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } } router,
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } } store: {} as never,
class MockServiceUnavailableError extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } }
return {
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateSuggestionInput: vi.fn(),
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateFeatureSuggestionInput: vi.fn(),
ValidationError: MockValidationError,
ParseError: MockParseError,
ServiceUnavailableError: MockServiceUnavailableError,
SUGGESTION_TIMEOUT_MS: 120_000,
};
}); });
const mockGetOrCreateProjectStore = vi.fn(); const mountedPaths = (router as unknown as { stack?: Array<{ regexp?: { source?: string } }> }).stack
vi.mock("../project-store-resolver.js", () => ({ ?.map((layer) => layer.regexp?.source ?? "")
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args), ?? [];
}));
function createMockRoadmapStore(): RoadmapStore { expect(mountedPaths.some((path) => path.includes("roadmaps"))).toBe(false);
const roadmaps = new Map<string, Roadmap>();
const milestones = new Map<string, RoadmapMilestone>();
const features = new Map<string, RoadmapFeature>();
return {
createRoadmap: vi.fn((input: { title: string; description?: string }) => {
const id = "RM-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const roadmap: Roadmap = { id, title: input.title, description: input.description, createdAt: now, updatedAt: now };
roadmaps.set(id, roadmap);
return roadmap;
}),
getRoadmap: vi.fn((id: string) => roadmaps.get(id)),
listRoadmaps: vi.fn(() => Array.from(roadmaps.values())),
updateRoadmap: vi.fn((id: string, updates: Partial<Roadmap>) => {
const roadmap = roadmaps.get(id);
if (!roadmap) throw new Error("Roadmap " + id + " not found");
const updated = { ...roadmap, ...updates, updatedAt: new Date().toISOString() };
roadmaps.set(id, updated);
return updated;
}),
deleteRoadmap: vi.fn((id: string) => { roadmaps.delete(id); }),
createMilestone: vi.fn((roadmapId: string, input: { title: string; description?: string }) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const id = "RMS-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const existingMilestones = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId);
const orderIndex = existingMilestones.length > 0 ? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1 : 0;
const milestone: RoadmapMilestone = { id, roadmapId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
milestones.set(id, milestone);
return milestone;
}),
getMilestone: vi.fn((id: string) => milestones.get(id)),
listMilestones: vi.fn((roadmapId: string) => Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex)),
updateMilestone: vi.fn((id: string, updates: Partial<RoadmapMilestone>) => {
const milestone = milestones.get(id);
if (!milestone) throw new Error("Milestone " + id + " not found");
const updated = { ...milestone, ...updates, updatedAt: new Date().toISOString() };
milestones.set(id, updated);
return updated;
}),
deleteMilestone: vi.fn((id: string) => { milestones.delete(id); }),
createFeature: vi.fn((milestoneId: string, input: { title: string; description?: string }) => {
const milestone = milestones.get(milestoneId);
if (!milestone) throw new Error("Milestone " + milestoneId + " not found");
const id = "RF-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const existingFeatures = Array.from(features.values()).filter((f) => f.milestoneId === milestoneId);
const orderIndex = existingFeatures.length > 0 ? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1 : 0;
const feature: RoadmapFeature = { id, milestoneId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
features.set(id, feature);
return feature;
}),
getFeature: vi.fn((id: string) => features.get(id)),
listFeatures: vi.fn((milestoneId: string) => Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex)),
updateFeature: vi.fn((id: string, updates: Partial<RoadmapFeature>) => {
const feature = features.get(id);
if (!feature) throw new Error("Feature " + id + " not found");
const updated = { ...feature, ...updates, updatedAt: new Date().toISOString() };
features.set(id, updated);
return updated;
}),
deleteFeature: vi.fn((id: string) => { features.delete(id); }),
reorderMilestones: vi.fn((input: { roadmapId: string; orderedMilestoneIds: string[] }) => {
const { roadmapId, orderedMilestoneIds } = input;
orderedMilestoneIds.forEach((id, index) => {
const milestone = milestones.get(id);
if (milestone) milestones.set(id, { ...milestone, orderIndex: index, updatedAt: new Date().toISOString() });
});
return Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
}),
reorderFeatures: vi.fn((input: { roadmapId: string; milestoneId: string; orderedFeatureIds: string[] }) => {
const { milestoneId, orderedFeatureIds } = input;
orderedFeatureIds.forEach((id, index) => {
const feature = features.get(id);
if (feature) features.set(id, { ...feature, orderIndex: index, updatedAt: new Date().toISOString() });
});
return Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex);
}),
moveFeature: vi.fn((input: { roadmapId: string; featureId: string; fromMilestoneId: string; toMilestoneId: string; targetOrderIndex: number }) => {
const { featureId, toMilestoneId, targetOrderIndex } = input;
const feature = features.get(featureId);
if (!feature) throw new Error("Feature " + featureId + " not found");
const updated: RoadmapFeature = { ...feature, milestoneId: toMilestoneId, orderIndex: targetOrderIndex, updatedAt: new Date().toISOString() };
features.set(featureId, updated);
return { movedFeature: updated, sourceMilestoneFeatures: [], targetMilestoneFeatures: [] };
}),
getMilestoneWithFeatures: vi.fn((id: string) => {
const milestone = milestones.get(id);
if (!milestone) return undefined;
return { ...milestone, features: [] };
}),
getRoadmapWithHierarchy: vi.fn((id: string) => {
const roadmap = roadmaps.get(id);
if (!roadmap) return undefined;
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === id).sort((a, b) => a.orderIndex - b.orderIndex);
return { ...roadmap, milestones: ms.map((m) => ({ ...m, features: [] })) };
}),
getRoadmapExport: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const allFeatures = ms.flatMap((m) => Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex));
return { roadmap, milestones: ms, features: allFeatures };
}),
getRoadmapMissionHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: ms.map((m) => {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceMilestoneId: m.id,
title: m.title,
description: m.description,
orderIndex: m.orderIndex,
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
};
}),
};
}),
getRoadmapFeatureHandoff: vi.fn((roadmapId: string, milestoneId: string, featureId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const milestone = milestones.get(milestoneId);
if (!milestone) throw new ApiError(500, "Milestone " + milestoneId + " not found");
if (milestone.roadmapId !== roadmapId) throw new ApiError(500, "Milestone " + milestoneId + " does not belong to roadmap " + roadmapId);
const feature = features.get(featureId);
if (!feature) throw new ApiError(500, "Feature " + featureId + " not found");
if (feature.milestoneId !== milestoneId) throw new ApiError(500, "Feature " + featureId + " does not belong to milestone " + milestoneId);
return {
source: {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
},
title: feature.title,
description: feature.description,
};
}),
getMissionPlanningHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: ms.map((m) => {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceMilestoneId: m.id,
title: m.title,
description: m.description,
orderIndex: m.orderIndex,
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
};
}),
};
}),
listFeatureTaskPlanningHandoffs: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const handoffs = [];
for (const m of ms) {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
for (const f of fs) {
handoffs.push({
source: {
roadmapId: roadmap.id,
milestoneId: m.id,
featureId: f.id,
roadmapTitle: roadmap.title,
milestoneTitle: m.title,
milestoneOrderIndex: m.orderIndex,
featureOrderIndex: f.orderIndex,
},
title: f.title,
description: f.description,
});
}
}
return handoffs;
}),
} as unknown as RoadmapStore;
}
describe("Roadmap Routes", () => {
let app: express.Express;
let mockStore: { getRoadmapStore: ReturnType<typeof vi.fn>; getRootDir: ReturnType<typeof vi.fn> };
let mockRoadmapStore: ReturnType<typeof createMockRoadmapStore>;
beforeEach(() => {
mockRoadmapStore = createMockRoadmapStore();
mockStore = {
getRoadmapStore: vi.fn(() => mockRoadmapStore),
getRootDir: vi.fn(() => "/test/root"),
};
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
app = express();
app.use(express.json());
app.use("/api/roadmaps", createRoadmapRouter(mockStore));
// Add error handler for tests that check 404 responses
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
if (err instanceof Error) {
res.status(500).json({ error: err.message });
return;
}
res.status(500).json({ error: "Internal server error" });
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("GET /api/roadmaps", () => {
it("returns empty list when no roadmaps exist", async () => {
const response = await performGet(app, "/api/roadmaps");
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
it("returns all roadmaps", async () => {
mockRoadmapStore.createRoadmap({ title: "Roadmap 1" });
mockRoadmapStore.createRoadmap({ title: "Roadmap 2" });
const response = await performGet(app, "/api/roadmaps");
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
});
});
describe("POST /api/roadmaps", () => {
it("creates a new roadmap", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "New Roadmap" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.title).toBe("New Roadmap");
});
it("returns 400 when title is missing", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({}), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title is empty", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title is whitespace-only", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: " " }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title exceeds 200 characters", async () => {
const longTitle = "A".repeat(201);
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: longTitle }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("200 characters");
});
});
describe("GET /api/roadmaps/:roadmapId", () => {
it("returns roadmap with hierarchy", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Milestone 1" });
mockRoadmapStore.createFeature(milestone.id, { title: "Feature 1" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id);
expect(response.status).toBe(200);
expect(response.body.title).toBe("Test Roadmap");
expect(response.body.milestones).toHaveLength(1);
});
});
describe("PATCH /api/roadmaps/:roadmapId", () => {
it("updates roadmap title", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Original Title" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/" + roadmap.id, JSON.stringify({ title: "Updated Title" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated Title");
});
});
describe("DELETE /api/roadmaps/:roadmapId", () => {
it("deletes a roadmap", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/" + roadmap.id);
expect(response.status).toBe(204);
});
});
describe("POST /api/roadmaps/:roadmapId/milestones", () => {
it("creates a milestone with auto orderIndex", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones", JSON.stringify({ title: "New Milestone" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.roadmapId).toBe(roadmap.id);
expect(response.body.orderIndex).toBe(0);
});
});
describe("POST /api/roadmaps/:roadmapId/milestones/reorder", () => {
it("reorders milestones", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const m1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "First" });
const m2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Second" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: [m2.id, m1.id] }), { "Content-Type": "application/json" });
expect(response.status).toBe(204);
});
it("returns 400 when orderedMilestoneIds is not an array", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: "not-an-array" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array");
});
it("returns 400 when orderedMilestoneIds contains non-strings", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: ["id1", 123, "id3"] }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array of strings");
});
});
describe("PATCH /api/roadmaps/milestones/:milestoneId", () => {
it("updates a milestone", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Original" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/milestones/" + milestone.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated");
});
});
describe("DELETE /api/roadmaps/milestones/:milestoneId", () => {
it("deletes a milestone", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/milestones/" + milestone.id);
expect(response.status).toBe(204);
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/features", () => {
it("creates a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({ title: "New Feature" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.title).toBe("New Feature");
});
it("returns 400 when title is missing", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({}), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/features/reorder", () => {
it("returns 400 when orderedFeatureIds is not an array", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: "not-an-array" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array");
});
it("returns 400 when orderedFeatureIds contains non-strings", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: [123, "id2"] }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array of strings");
});
});
describe("PATCH /api/roadmaps/features/:featureId", () => {
it("updates a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Original" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/features/" + feature.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated");
});
});
describe("DELETE /api/roadmaps/features/:featureId", () => {
it("deletes a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/features/" + feature.id);
expect(response.status).toBe(204);
});
});
describe("projectId scoping", () => {
it("ignores projectId query param in legacy adapter", async () => {
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
expect(response.status).toBe(200);
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
});
});
describe("GET /api/roadmaps/:roadmapId/export", () => {
it("returns export bundle with all entities", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Export Test", description: "Test desc" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "F1" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/export");
expect(response.status).toBe(200);
expect(response.body.roadmap.id).toBe(roadmap.id);
expect(response.body.roadmap.title).toBe("Export Test");
expect(response.body.milestones.length).toBe(1);
expect(response.body.features.length).toBe(1);
expect(response.body.features[0].id).toBe(feature.id);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff", () => {
it("returns both mission and feature handoffs", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Combined Handoff" });
const milestone1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const milestone2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 2" });
const feature1 = mockRoadmapStore.createFeature(milestone1.id, { title: "Feature A" });
const feature2 = mockRoadmapStore.createFeature(milestone2.id, { title: "Feature B" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
// Verify mission handoff structure
expect(response.body.mission).toBeDefined();
expect(response.body.mission.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.mission.title).toBe("Combined Handoff");
expect(response.body.mission.milestones).toHaveLength(2);
// Verify feature handoffs structure
expect(response.body.features).toBeDefined();
expect(response.body.features).toHaveLength(2);
expect(response.body.features[0].title).toBe("Feature A");
expect(response.body.features[0].source.milestoneId).toBe(milestone1.id);
expect(response.body.features[1].title).toBe("Feature B");
expect(response.body.features[1].source.milestoneId).toBe(milestone2.id);
});
it("returns empty features array when roadmap has no features", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Empty Handoff" });
mockRoadmapStore.createMilestone(roadmap.id, { title: "Empty Phase" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
expect(response.body.features).toHaveLength(0);
});
it("returns 404 when roadmap not found", async () => {
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff");
expect(response.status).toBe(404);
});
it("returns 404 for cross-project isolation", async () => {
// Create roadmap in default store
const roadmap = mockRoadmapStore.createRoadmap({ title: "Isolated Roadmap" });
// Mock a different project store that returns no roadmap
mockGetOrCreateProjectStore.mockResolvedValueOnce({
getRoadmapStore: vi.fn(() => ({
getMissionPlanningHandoff: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
listFeatureTaskPlanningHandoffs: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
})),
getRootDir: vi.fn(() => "/test/root"),
});
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff?projectId=other-project");
expect(response.status).toBe(404);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff/mission", () => {
it("returns mission handoff payload", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff/mission");
expect(response.status).toBe(200);
expect(response.body.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.title).toBe("Mission Handoff");
expect(response.body.description).toBe("Mission desc");
expect(response.body.milestones.length).toBe(1);
expect(response.body.milestones[0].sourceMilestoneId).toBe(milestone.id);
expect(response.body.milestones[0].features.length).toBe(1);
expect(response.body.milestones[0].features[0].sourceFeatureId).toBe(feature.id);
});
});
describe("GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", () => {
it("returns task handoff payload for feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Feature Handoff" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A", description: "Feature desc" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/milestones/" + milestone.id + "/features/" + feature.id + "/handoff/task");
expect(response.status).toBe(200);
expect(response.body.source.roadmapId).toBe(roadmap.id);
expect(response.body.source.milestoneId).toBe(milestone.id);
expect(response.body.source.featureId).toBe(feature.id);
expect(response.body.source.roadmapTitle).toBe("Feature Handoff");
expect(response.body.source.milestoneTitle).toBe("Phase 1");
expect(response.body.title).toBe("Feature A");
expect(response.body.description).toBe("Feature desc");
});
});
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
it("returns 503 when AI is unavailable", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const response = await performRequest(
app,
"POST",
"/api/roadmaps/" + roadmap.id + "/suggestions/milestones",
JSON.stringify({ goalPrompt: "Build a platform", count: 5 }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("AI service is not available");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
it("returns 503 when AI is unavailable", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const response = await performRequest(
app,
"POST",
"/api/roadmaps/milestones/" + milestone.id + "/suggestions/features",
JSON.stringify({ count: 5 }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("AI service is not available");
});
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,6 @@ import {
} from "./api-error.js"; } from "./api-error.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js"; import { getOrCreateProjectStore } from "./project-store-resolver.js";
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
const ROADMAP_PLUGIN_ROUTE_NAMESPACE = "roadmap-planner";
// PluginRunner interface for optional plugin runner // PluginRunner interface for optional plugin runner
function isPluginRouteResponse(result: unknown): result is import("@fusion/core").PluginRouteResponse { function isPluginRouteResponse(result: unknown): result is import("@fusion/core").PluginRouteResponse {
@@ -535,8 +533,7 @@ export function createPluginRouter(
const pluginRoutes = pluginRunner.getPluginRoutes(); const pluginRoutes = pluginRunner.getPluginRoutes();
for (const { pluginId, route } of pluginRoutes) { for (const { pluginId, route } of pluginRoutes) {
const routePluginId = pluginId === ROADMAP_PLUGIN_ID ? ROADMAP_PLUGIN_ROUTE_NAMESPACE : pluginId; const fullPath = `/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
const fullPath = `/${routePluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
const handler = catchHandler(async (req: Request, res: Response) => { const handler = catchHandler(async (req: Request, res: Response) => {
// Get the plugin context // Get the plugin context

View File

@@ -1,89 +0,0 @@
import { Router, type Request, type Response } from "express";
import { type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
import { createRoadmapPluginRoutes } from "@fusion-plugin-examples/roadmap/server";
function isRouteResponse(value: unknown): value is { status: number; body?: unknown } {
return (
typeof value === "object"
&& value !== null
&& "status" in value
&& typeof (value as { status?: unknown }).status === "number"
);
}
async function buildContext(store: TaskStore): Promise<PluginContext> {
return {
pluginId: "roadmap-planner",
taskStore: store,
settings: {},
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
emitEvent: () => {},
createAiSession: undefined,
};
}
export function createRoadmapRouter(store: TaskStore): Router {
const router = Router();
const routes = createRoadmapPluginRoutes();
for (const route of routes) {
const handler = async (req: Request, res: Response) => {
const result = await route.handler(req, await buildContext(store));
if (isRouteResponse(result)) {
if (result.status === 204) {
res.status(204).send();
return;
}
if (result.body === undefined) {
res.status(result.status).send();
return;
}
res.status(result.status).json(result.body);
return;
}
res.status(200).json(result);
};
registerRoute(router, route, handler, normalizeLegacyRoadmapPath(route.path));
}
return router;
}
function normalizeLegacyRoadmapPath(path: string): string {
if (path === "/roadmaps") return "/";
if (path.startsWith("/roadmaps/")) return path.slice("/roadmaps".length);
return path;
}
function registerRoute(
router: Router,
route: PluginRouteDefinition,
handler: (req: Request, res: Response) => Promise<void>,
normalizedPath: string,
): void {
switch (route.method) {
case "GET":
router.get(normalizedPath, handler);
break;
case "POST":
router.post(normalizedPath, handler);
break;
case "PUT":
router.put(normalizedPath, handler);
break;
case "PATCH":
router.patch(normalizedPath, handler);
break;
case "DELETE":
router.delete(normalizedPath, handler);
break;
}
}
export { createRoadmapPluginRoutes };

View File

@@ -1,15 +0,0 @@
export {
FEATURE_SUGGESTION_SYSTEM_PROMPT,
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
ParseError,
ServiceUnavailableError,
SUGGESTION_TIMEOUT_MS,
ValidationError,
__resetSuggestionState,
__setCreateAiSessionFactory,
__setCreateFnAgent,
generateFeatureSuggestions,
generateMilestoneSuggestions,
validateFeatureSuggestionInput,
validateSuggestionInput,
} from "@fusion-plugin-examples/roadmap/roadmap-suggestions";

View File

@@ -3131,7 +3131,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Keep this call at the current position to preserve precedence with // Keep this call at the current position to preserve precedence with
// surrounding route handlers. registerIntegratedRouters() mounts: // surrounding route handlers. registerIntegratedRouters() mounts:
// - /missions // - /missions
// - /roadmaps
// - /insights // - /insights
// - /todos // - /todos
registerIntegratedRouters({ registerIntegratedRouters({

View File

@@ -135,8 +135,9 @@ Integrated routers are mounted through `register-integrated-routers.ts` and inte
- `registerIntegratedRouters(...)` mounts: - `registerIntegratedRouters(...)` mounts:
- `createMissionRouter``/api/missions` - `createMissionRouter``/api/missions`
- `createRoadmapRouter``/api/roadmaps`
- `createInsightsRouter``/api/insights` - `createInsightsRouter``/api/insights`
- `createTodoRouter``/api/todos`
- Roadmap endpoints are plugin-owned and exposed under `/api/plugins/roadmap-planner/...`.
- `registerIntegratedDevServerRouter(...)` mounts: - `registerIntegratedDevServerRouter(...)` mounts:
- `createDevServerRouter``/api/dev-server` - `createDevServerRouter``/api/dev-server`

View File

@@ -6,7 +6,6 @@ import { createInsightsRouter } from "../insights-routes.js";
import { createEvalsRouter } from "../evals-routes.js"; import { createEvalsRouter } from "../evals-routes.js";
import { createResearchRouter } from "../research-routes.js"; import { createResearchRouter } from "../research-routes.js";
import { createTodoRouter } from "../todo-routes.js"; import { createTodoRouter } from "../todo-routes.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { createDevServerRouter } from "../dev-server-routes.js"; import { createDevServerRouter } from "../dev-server-routes.js";
import type { AiSessionStore } from "../ai-session-store.js"; import type { AiSessionStore } from "../ai-session-store.js";
@@ -37,7 +36,6 @@ export function registerIntegratedRouters({
router.use("/evals", createEvalsRouter(store)); router.use("/evals", createEvalsRouter(store));
router.use("/research", createResearchRouter(store)); router.use("/research", createResearchRouter(store));
router.use("/todos", createTodoRouter(store)); router.use("/todos", createTodoRouter(store));
router.use("/roadmaps", createRoadmapRouter(store));
} }
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void { export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {