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

This commit is contained in:
gsxdsm
2026-04-15 14:27:06 -07:00
parent d147a8091f
commit 835562a588
4 changed files with 865 additions and 450 deletions

View File

@@ -127,10 +127,11 @@ Concrete references:
- SQLite (`node:sqlite`) with WAL mode + foreign keys
- JSON helpers: `toJson`, `toJsonNullable`, `fromJson`
- Tables: `tasks`, `config`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, `agentRatings`, `ai_sessions`, `workflow_steps`, `messages`, `plugins`, `routines`, `runAuditEvents`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), `__meta`
- **Standalone roadmap contracts**: `packages/core/src/roadmap-types.ts`, `roadmap-ordering.ts`, `roadmap-store.ts`
- **Standalone roadmap model**: `packages/core/src/roadmap-types.ts`, `roadmap-ordering.ts`, `roadmap-store.ts`
- Roadmap-first entity types (`Roadmap`, `RoadmapMilestone`, `RoadmapFeature`)
- Pure ordering helpers for contiguous 0-based milestone/feature order and deterministic cross-milestone feature moves
- `RoadmapStore` for CRUD operations, deterministic ordering, and atomic reorder/move operations
- Dashboard API routes in `packages/dashboard/src/roadmap-routes.ts`
- Exported from `@fusion/core` for downstream persistence/API/UI work
- **CentralCore**: `packages/core/src/central-core.ts`
- Global project registry, health, central activity feed, global concurrency

View File

@@ -0,0 +1,279 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import { get as performGet, request as performRequest } from "./test-request.js";
import { createRoadmapRouter } from "./roadmap-routes.js";
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion/core";
vi.mock("./roadmap-suggestions.js", () => ({
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateSuggestionInput: vi.fn(),
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateFeatureSuggestionInput: vi.fn(),
ValidationError: class extends Error { name = "ValidationError"; constructor(m: string) { super(m); } },
ParseError: class extends Error { name = "ParseError"; constructor(m: string) { super(m); } },
ServiceUnavailableError: class extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } },
}));
const mockGetOrCreateProjectStore = vi.fn();
vi.mock("./project-store-resolver.js", () => ({
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args),
}));
function createMockRoadmapStore(): RoadmapStore {
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: [] })) };
}),
} 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));
});
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");
});
});
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);
});
});
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");
});
});
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("uses projectId from query param", async () => {
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
expect(response.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("test-project");
});
});
});

View File

@@ -0,0 +1,579 @@
/**
* Roadmap REST API Routes
*
* Provides CRUD endpoints for standalone roadmaps, milestones, and features.
* Also includes AI-powered suggestion endpoints for milestone and feature creation.
*
* Endpoints:
* - Roadmaps: GET /, POST /, GET /:id, PATCH /:id, DELETE /:id
* - Milestones: GET /:roadmapId/milestones, POST /:roadmapId/milestones,
* PATCH /milestones/:id, DELETE /milestones/:id,
* POST /:roadmapId/milestones/reorder
* - Features: GET /milestones/:milestoneId/features,
* POST /milestones/:milestoneId/features,
* PATCH /features/:id, DELETE /features/:id,
* POST /milestones/:milestoneId/features/reorder,
* POST /features/:id/move
* - Suggestions: POST /:roadmapId/suggestions/milestones,
* POST /milestones/:milestoneId/suggestions/features
*/
import { Router, type Request, type Response } from "express";
import { AsyncLocalStorage } from "node:async_hooks";
import { TaskStore } from "@fusion/core";
import {
ApiError,
badRequest,
notFound,
internalError,
} from "./api-error.js";
/**
* Re-throws an error as an ApiError, converting unknown errors to internal errors.
* This is used in route handlers to ensure all errors are properly typed.
*/
function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
if (error instanceof ApiError) throw error;
if (error instanceof Error) throw new ApiError(500, error.message);
throw new ApiError(500, fallbackMessage);
}
import {
generateMilestoneSuggestions,
validateSuggestionInput,
generateFeatureSuggestions,
validateFeatureSuggestionInput,
ValidationError as SuggestionValidationError,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
} from "./roadmap-suggestions.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
// ── Validation Utilities ──────────────────────────────────────────────────────
function validateTitle(title: unknown): string {
if (!title || typeof title !== "string" || !title.trim()) {
throw badRequest("title is required");
}
if (title.length > 200) {
throw badRequest("title must not exceed 200 characters");
}
return title.trim();
}
function validateDescription(desc: unknown): string | undefined {
if (desc === undefined || desc === null) return undefined;
if (typeof desc !== "string") {
throw badRequest("description must be a string");
}
if (desc.length > 5000) {
throw badRequest("description must not exceed 5000 characters");
}
return desc.trim() || undefined;
}
function validateStringArray(arr: unknown, fieldName: string): string[] {
if (!Array.isArray(arr)) {
throw badRequest(`${fieldName} must be an array`);
}
if (!arr.every((item) => typeof item === "string")) {
throw badRequest(`${fieldName} must be an array of strings`);
}
return arr;
}
// ── Router Factory ────────────────────────────────────────────────────────────
export function createRoadmapRouter(store: TaskStore): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<TaskStore>();
function getProjectIdFromRequest(req: Request): string | undefined {
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
return req.query.projectId;
}
if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) {
return req.body.projectId;
}
return undefined;
}
function getScopedStore(): TaskStore {
const scoped = requestContext.getStore();
return scoped ?? store;
}
router.use(async (req: Request, _res: Response, next) => {
try {
const projectId = getProjectIdFromRequest(req);
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
requestContext.run(scopedStore, next);
} catch (error) {
next(error);
}
});
// ── Roadmap Endpoints ─────────────────────────────────────────────────────
/**
* GET /api/roadmaps
* List all roadmaps.
*/
router.get("/", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const roadmaps = roadmapStore.listRoadmaps();
res.json(roadmaps);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to list roadmaps");
}
});
/**
* POST /api/roadmaps
* Create a new roadmap.
*/
router.post("/", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { title, description } = req.body as { title: string; description?: string };
const validatedTitle = validateTitle(title);
const validatedDesc = validateDescription(description);
const roadmap = roadmapStore.createRoadmap({
title: validatedTitle,
description: validatedDesc,
});
res.status(201).json(roadmap);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to create roadmap");
}
});
/**
* GET /api/roadmaps/:roadmapId
* Get a roadmap with full hierarchy (milestones and features).
*/
router.get("/:roadmapId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const roadmap = roadmapStore.getRoadmapWithHierarchy(roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${roadmapId} not found`);
}
res.json(roadmap);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to get roadmap");
}
});
/**
* PATCH /api/roadmaps/:roadmapId
* Update roadmap metadata.
*/
router.patch("/:roadmapId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const roadmap = roadmapStore.updateRoadmap(roadmapId, {
title: title !== undefined ? validateTitle(title) : undefined,
description: description !== undefined ? validateDescription(description) : undefined,
});
res.json(roadmap);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to update roadmap");
}
});
/**
* DELETE /api/roadmaps/:roadmapId
* Delete a roadmap and all its milestones/features.
*/
router.delete("/:roadmapId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
roadmapStore.deleteRoadmap(roadmapId);
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to delete roadmap");
}
});
// ── Milestone Endpoints ───────────────────────────────────────────────────
/**
* POST /api/roadmaps/:roadmapId/milestones
* Create a new milestone in a roadmap.
*/
router.post("/:roadmapId/milestones", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const { title, description } = req.body as { title: string; description?: string };
const validatedTitle = validateTitle(title);
const validatedDesc = validateDescription(description);
const milestone = roadmapStore.createMilestone(roadmapId, {
title: validatedTitle,
description: validatedDesc,
});
res.status(201).json(milestone);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to create milestone");
}
});
/**
* POST /api/roadmaps/:roadmapId/milestones/reorder
* Reorder milestones within a roadmap.
*/
router.post("/:roadmapId/milestones/reorder", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const { orderedMilestoneIds } = req.body as { orderedMilestoneIds: string[] };
validateStringArray(orderedMilestoneIds, "orderedMilestoneIds");
roadmapStore.reorderMilestones({ roadmapId, orderedMilestoneIds });
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to reorder milestones");
}
});
/**
* PATCH /api/roadmaps/milestones/:milestoneId
* Update a milestone.
*/
router.patch("/milestones/:milestoneId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { milestoneId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const milestone = roadmapStore.updateMilestone(milestoneId, {
title: title !== undefined ? validateTitle(title) : undefined,
description: description !== undefined ? validateDescription(description) : undefined,
});
res.json(milestone);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to update milestone");
}
});
/**
* DELETE /api/roadmaps/milestones/:milestoneId
* Delete a milestone and all its features.
*/
router.delete("/milestones/:milestoneId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { milestoneId } = req.params;
roadmapStore.deleteMilestone(milestoneId);
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to delete milestone");
}
});
// ── Feature Endpoints ─────────────────────────────────────────────────────
/**
* POST /api/roadmaps/milestones/:milestoneId/features
* Create a new feature in a milestone.
*/
router.post("/milestones/:milestoneId/features", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { milestoneId } = req.params;
const { title, description } = req.body as { title: string; description?: string };
const validatedTitle = validateTitle(title);
const validatedDesc = validateDescription(description);
const feature = roadmapStore.createFeature(milestoneId, {
title: validatedTitle,
description: validatedDesc,
});
res.status(201).json(feature);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to create feature");
}
});
/**
* POST /api/roadmaps/milestones/:milestoneId/features/reorder
* Reorder features within a milestone.
*/
router.post("/milestones/:milestoneId/features/reorder", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { milestoneId } = req.params;
const { orderedFeatureIds } = req.body as { orderedFeatureIds: string[] };
validateStringArray(orderedFeatureIds, "orderedFeatureIds");
// Get the milestone to find the roadmapId
const milestone = roadmapStore.getMilestone(milestoneId);
if (!milestone) {
throw notFound(`Milestone ${milestoneId} not found`);
}
roadmapStore.reorderFeatures({
roadmapId: milestone.roadmapId,
milestoneId,
orderedFeatureIds,
});
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to reorder features");
}
});
/**
* PATCH /api/roadmaps/features/:featureId
* Update a feature.
*/
router.patch("/features/:featureId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { featureId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const feature = roadmapStore.updateFeature(featureId, {
title: title !== undefined ? validateTitle(title) : undefined,
description: description !== undefined ? validateDescription(description) : undefined,
});
res.json(feature);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to update feature");
}
});
/**
* DELETE /api/roadmaps/features/:featureId
* Delete a feature.
*/
router.delete("/features/:featureId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { featureId } = req.params;
roadmapStore.deleteFeature(featureId);
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to delete feature");
}
});
/**
* POST /api/roadmaps/features/:featureId/move
* Move a feature to a different milestone or position.
*/
router.post("/features/:featureId/move", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { featureId } = req.params;
const { targetMilestoneId, targetIndex } = req.body as {
targetMilestoneId: string;
targetIndex: number;
};
if (!targetMilestoneId) {
throw badRequest("targetMilestoneId is required");
}
if (typeof targetIndex !== "number") {
throw badRequest("targetIndex must be a number");
}
// Get the feature and source milestone
const feature = roadmapStore.getFeature(featureId);
if (!feature) {
throw notFound(`Feature ${featureId} not found`);
}
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
if (!fromMilestone) {
throw notFound(`Source milestone ${feature.milestoneId} not found`);
}
const toMilestone = roadmapStore.getMilestone(targetMilestoneId);
if (!toMilestone) {
throw notFound(`Target milestone ${targetMilestoneId} not found`);
}
roadmapStore.moveFeature({
roadmapId: fromMilestone.roadmapId,
featureId,
fromMilestoneId: feature.milestoneId,
toMilestoneId: targetMilestoneId,
targetOrderIndex: targetIndex,
});
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to move feature");
}
});
// ── Suggestion Endpoints ───────────────────────────────────────────────────
/**
* POST /api/roadmaps/:roadmapId/suggestions/milestones
* Generate milestone suggestions using AI.
*/
router.post("/:roadmapId/suggestions/milestones", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const scopedStore = getScopedStore();
const { roadmapId } = req.params;
// Check if roadmap exists
const roadmap = roadmapStore.getRoadmap(roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${roadmapId} not found`);
}
// Validate input
let input: { goalPrompt: string; count?: number };
try {
validateSuggestionInput(req.body);
input = req.body as { goalPrompt: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateMilestoneSuggestions(
input.goalPrompt,
input.count,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate milestone suggestions");
}
});
/**
* POST /api/roadmaps/milestones/:milestoneId/suggestions/features
* Generate feature suggestions using AI.
*/
router.post("/milestones/:milestoneId/suggestions/features", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const scopedStore = getScopedStore();
const { milestoneId } = req.params;
// Get the milestone to find the roadmap
const milestone = roadmapStore.getMilestone(milestoneId);
if (!milestone) {
throw notFound(`Milestone ${milestoneId} not found`);
}
// Get the roadmap for context
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${milestone.roadmapId} not found`);
}
// Get existing features for this milestone
const existingFeatures = roadmapStore.listFeatures(milestoneId);
const existingFeatureTitles = existingFeatures.map((f) => f.title);
// Validate input
let input: { prompt?: string; count?: number };
try {
validateFeatureSuggestionInput(req.body);
input = req.body as { prompt?: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Build the context for feature suggestion
const context = {
roadmapTitle: roadmap.title,
roadmapDescription: roadmap.description,
milestoneTitle: milestone.title,
milestoneDescription: milestone.description,
existingFeatureTitles,
};
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateFeatureSuggestions(
context,
input.count,
input.prompt,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate feature suggestions");
}
});
return router;
}

View File

@@ -27,6 +27,7 @@ import {
hasIssueBadgeFieldsChanged,
} from "./github-webhooks.js";
import { createMissionRouter } from "./mission-routes.js";
import { createRoadmapRouter } from "./roadmap-routes.js";
import { getOrCreateProjectStore, invalidateAllGlobalSettingsCaches } from "./project-store-resolver.js";
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
@@ -42,15 +43,6 @@ import {
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
import { writeSSEEvent } from "./sse-buffer.js";
import {
generateMilestoneSuggestions,
validateSuggestionInput,
generateFeatureSuggestions,
validateFeatureSuggestionInput,
ValidationError as SuggestionValidationError,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
} from "./roadmap-suggestions.js";
import {
ApiError,
badRequest,
@@ -2418,446 +2410,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Models
registerModelsRoute(router, options?.modelRegistry, store);
// ── Roadmap Routes ─────────────────────────────────────────────────
// List all roadmaps
router.get("/roadmaps", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const roadmaps = roadmapStore.listRoadmaps();
res.json(roadmaps);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to list roadmaps");
}
});
// Create a roadmap
router.post("/roadmaps", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { title, description } = req.body as { title: string; description?: string };
if (!title || typeof title !== "string" || !title.trim()) {
throw badRequest("title is required");
}
const roadmap = roadmapStore.createRoadmap({ title: title.trim(), description });
res.status(201).json(roadmap);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to create roadmap");
}
});
// Get a roadmap with full hierarchy
router.get("/roadmaps/:roadmapId", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { roadmapId } = req.params;
const roadmap = roadmapStore.getRoadmapWithHierarchy(roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${roadmapId} not found`);
}
res.json(roadmap);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to get roadmap");
}
});
// Update a roadmap
router.patch("/roadmaps/:roadmapId", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { roadmapId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const roadmap = roadmapStore.updateRoadmap(roadmapId, { title, description });
res.json(roadmap);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to update roadmap");
}
});
// Delete a roadmap
router.delete("/roadmaps/:roadmapId", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { roadmapId } = req.params;
roadmapStore.deleteRoadmap(roadmapId);
res.status(204).send();
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to delete roadmap");
}
});
// Create a milestone
router.post("/roadmaps/:roadmapId/milestones", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { roadmapId } = req.params;
const { title, description } = req.body as { title: string; description?: string };
if (!title || typeof title !== "string" || !title.trim()) {
throw badRequest("title is required");
}
const milestone = roadmapStore.createMilestone(roadmapId, { title: title.trim(), description });
res.status(201).json(milestone);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to create milestone");
}
});
// Reorder milestones
router.post("/roadmaps/:roadmapId/milestones/reorder", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { roadmapId } = req.params;
const { orderedMilestoneIds } = req.body as { orderedMilestoneIds: string[] };
if (!Array.isArray(orderedMilestoneIds)) {
throw badRequest("orderedMilestoneIds must be an array");
}
roadmapStore.reorderMilestones({ roadmapId, orderedMilestoneIds });
res.status(204).send();
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to reorder milestones");
}
});
// Update a milestone
router.patch("/roadmaps/milestones/:milestoneId", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { milestoneId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const milestone = roadmapStore.updateMilestone(milestoneId, { title, description });
res.json(milestone);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to update milestone");
}
});
// Delete a milestone
router.delete("/roadmaps/milestones/:milestoneId", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { milestoneId } = req.params;
roadmapStore.deleteMilestone(milestoneId);
res.status(204).send();
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to delete milestone");
}
});
// Create a feature
router.post("/roadmaps/milestones/:milestoneId/features", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { milestoneId } = req.params;
const { title, description } = req.body as { title: string; description?: string };
if (!title || typeof title !== "string" || !title.trim()) {
throw badRequest("title is required");
}
const feature = roadmapStore.createFeature(milestoneId, { title: title.trim(), description });
res.status(201).json(feature);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to create feature");
}
});
// Reorder features within a milestone
router.post("/roadmaps/milestones/:milestoneId/features/reorder", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { milestoneId } = req.params;
const { orderedFeatureIds } = req.body as { orderedFeatureIds: string[] };
if (!Array.isArray(orderedFeatureIds)) {
throw badRequest("orderedFeatureIds must be an array");
}
// Get the milestone to find the roadmapId
const milestone = roadmapStore.getMilestone(milestoneId);
if (!milestone) {
throw notFound(`Milestone ${milestoneId} not found`);
}
roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId, orderedFeatureIds });
res.status(204).send();
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to reorder features");
}
});
// Update a feature
router.patch("/roadmaps/features/:featureId", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { featureId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const feature = roadmapStore.updateFeature(featureId, { title, description });
res.json(feature);
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to update feature");
}
});
// Delete a feature
router.delete("/roadmaps/features/:featureId", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { featureId } = req.params;
roadmapStore.deleteFeature(featureId);
res.status(204).send();
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to delete feature");
}
});
// Move a feature
router.post("/roadmaps/features/:featureId/move", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { featureId } = req.params;
const { targetMilestoneId, targetIndex } = req.body as { targetMilestoneId: string; targetIndex: number };
if (!targetMilestoneId) {
throw badRequest("targetMilestoneId is required");
}
if (typeof targetIndex !== "number") {
throw badRequest("targetIndex must be a number");
}
// Get the feature and source milestone
const feature = roadmapStore.getFeature(featureId);
if (!feature) {
throw notFound(`Feature ${featureId} not found`);
}
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
if (!fromMilestone) {
throw notFound(`Source milestone ${feature.milestoneId} not found`);
}
const toMilestone = roadmapStore.getMilestone(targetMilestoneId);
if (!toMilestone) {
throw notFound(`Target milestone ${targetMilestoneId} not found`);
}
roadmapStore.moveFeature({
roadmapId: fromMilestone.roadmapId,
featureId,
fromMilestoneId: feature.milestoneId,
toMilestoneId: targetMilestoneId,
targetOrderIndex: targetIndex,
});
res.status(204).send();
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to move feature");
}
});
// ── Roadmap Milestone Suggestions ───────────────────────────────────────
// Generate milestone suggestions from a goal prompt
router.post("/roadmaps/:roadmapId/suggestions/milestones", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { roadmapId } = req.params;
// Check if roadmap exists
const roadmap = roadmapStore.getRoadmap(roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${roadmapId} not found`);
}
// Validate input
let input: { goalPrompt: string; count?: number };
try {
validateSuggestionInput(req.body);
input = req.body as { goalPrompt: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateMilestoneSuggestions(
input.goalPrompt,
input.count,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to generate milestone suggestions");
}
});
// ── Roadmap Feature Suggestions ──────────────────────────────────────────
// Generate feature suggestions for a milestone
router.post("/roadmaps/milestones/:milestoneId/suggestions/features", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { milestoneId } = req.params;
// Get the milestone to find the roadmap
const milestone = roadmapStore.getMilestone(milestoneId);
if (!milestone) {
throw notFound(`Milestone ${milestoneId} not found`);
}
// Get the roadmap for context
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${milestone.roadmapId} not found`);
}
// Get existing features for this milestone
const existingFeatures = roadmapStore.listFeatures(milestoneId);
const existingFeatureTitles = existingFeatures.map((f) => f.title);
// Validate input
let input: { prompt?: string; count?: number };
try {
validateFeatureSuggestionInput(req.body);
input = req.body as { prompt?: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Build the context for feature suggestion
const context = {
roadmapTitle: roadmap.title,
roadmapDescription: roadmap.description,
milestoneTitle: milestone.title,
milestoneDescription: milestone.description,
existingFeatureTitles,
};
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateFeatureSuggestions(
context,
input.count,
input.prompt,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to generate feature suggestions");
}
});
// List all tasks
router.get("/tasks", async (req, res) => {
try {
@@ -12591,6 +12143,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Mount mission routes at /api/missions
router.use("/missions", createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager));
// ── Roadmap Routes ─────────────────────────────────────────────────────────
// Mount roadmap routes at /api/roadmaps
router.use("/roadmaps", createRoadmapRouter(store));
// ── Plugin Routes ─────────────────────────────────────────────────────────
// Plugin management endpoints with projectId scoping support.
// Uses getScopedStore(req) pattern for multi-project support.