feat(FN-3159): move roadmap ownership from core to fusion-plugin-roadmap pl

Moves roadmap ownership from `@fusion/core` to `fusion-plugin-roadmap`, deleting ~1,800 lines of roadmap store, types, ordering, and handoff logic from core and replacing it with ~760 lines of new route handlers in the plugin. The dashboard's roadmap routes and suggestions modules are substantially

Fusion-Task-Id: FN-3159
This commit is contained in:
Fusion
2026-05-08 09:35:45 -07:00
committed by gsxdsm
parent 0746260544
commit 6fffa830bf
45 changed files with 3285 additions and 3505 deletions

View File

@@ -106,10 +106,7 @@ describe("Database", () => {
expect(tableNames).toContain("agentRatings");
expect(tableNames).toContain("task_documents");
expect(tableNames).toContain("task_document_revisions");
// Roadmap tables
expect(tableNames).toContain("roadmaps");
expect(tableNames).toContain("roadmap_milestones");
expect(tableNames).toContain("roadmap_features");
// Roadmap tables are plugin-owned (FN-3159) and initialized via plugin schema hooks.
// Verification cache (migration 61)
expect(tableNames).toContain("verification_cache");
expect(tableNames).toContain("distributed_task_id_state");
@@ -156,9 +153,7 @@ describe("Database", () => {
expect(indexNames).toContain("idxAgentApiKeysAgentId");
expect(indexNames).toContain("idxAgentConfigRevisionsAgentIdCreatedAt");
expect(indexNames).toContain("idxTasksCreatedAt");
// Roadmap indexes
expect(indexNames).toContain("idxRoadmapMilestonesRoadmapOrder");
expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder");
// Roadmap indexes are plugin-owned (FN-3159) and initialized via plugin schema hooks.
// Verification cache index (migration 61)
expect(indexNames).toContain("idxVerificationCacheRecordedAt");
});

View File

@@ -685,51 +685,6 @@ CREATE TABLE IF NOT EXISTS routines (
updatedAt TEXT NOT NULL
);
-- Roadmap persistence tables (FN-1690)
-- Standalone roadmap: Roadmap → RoadmapMilestone → RoadmapFeature
-- with deterministic ordering indexes and FK cascade integrity
-- Roadmaps table
CREATE TABLE IF NOT EXISTS roadmaps (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
-- Roadmap milestones table
CREATE TABLE IF NOT EXISTS roadmap_milestones (
id TEXT PRIMARY KEY,
roadmapId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
orderIndex INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (roadmapId) REFERENCES roadmaps(id) ON DELETE CASCADE
);
-- Roadmap features table
CREATE TABLE IF NOT EXISTS roadmap_features (
id TEXT PRIMARY KEY,
milestoneId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
orderIndex INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (milestoneId) REFERENCES roadmap_milestones(id) ON DELETE CASCADE
);
-- Covering index for deterministic milestone ordering within a roadmap
CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder
ON roadmap_milestones(roadmapId, orderIndex, createdAt, id);
-- Covering index for deterministic feature ordering within a milestone
CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder
ON roadmap_features(milestoneId, orderIndex, createdAt, id);
-- Insight persistence tables (FN-1877)
-- Normalized insight entities and insight-generation run records
@@ -1910,66 +1865,6 @@ export class Database {
});
}
// Roadmap persistence tables (FN-1690)
// Standalone roadmap: Roadmap → RoadmapMilestone → RoadmapFeature
// with deterministic ordering indexes and FK cascade integrity
if (version < 32) {
this.applyMigration(32, () => {
// Roadmaps table
this.db.exec(`
CREATE TABLE IF NOT EXISTS roadmaps (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
// Roadmap milestones table
this.db.exec(`
CREATE TABLE IF NOT EXISTS roadmap_milestones (
id TEXT PRIMARY KEY,
roadmapId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
orderIndex INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (roadmapId) REFERENCES roadmaps(id) ON DELETE CASCADE
)
`);
// Roadmap features table
this.db.exec(`
CREATE TABLE IF NOT EXISTS roadmap_features (
id TEXT PRIMARY KEY,
milestoneId TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
orderIndex INTEGER NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (milestoneId) REFERENCES roadmap_milestones(id) ON DELETE CASCADE
)
`);
// Covering index for deterministic milestone ordering within a roadmap
// Covers: WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder
ON roadmap_milestones(roadmapId, orderIndex, createdAt, id)
`);
// Covering index for deterministic feature ordering within a milestone
// Covers: WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC
this.db.exec(`
CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder
ON roadmap_features(milestoneId, orderIndex, createdAt, id)
`);
});
}
// Insight persistence tables (FN-1877)
// Normalized insight entities and insight-generation run records
if (version < 33) {

View File

@@ -310,37 +310,6 @@ export {
} from "./memory-compaction.js";
// Note: AiServiceError is shared with ai-summarize.ts and re-exported from there
// ── Standalone Roadmap Model ───────────────────────────────────────────
export type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMilestoneReorderInput,
RoadmapFeatureReorderInput,
RoadmapFeatureMoveInput,
RoadmapFeatureMoveResult,
RoadmapMilestoneWithFeatures,
RoadmapWithHierarchy,
RoadmapExportBundle,
RoadmapFeatureSourceRef,
RoadmapFeatureTaskPlanningHandoff,
RoadmapMissionPlanningMilestoneHandoff,
RoadmapMissionPlanningHandoff,
} from "./roadmap-types.js";
export {
normalizeRoadmapMilestoneOrder,
applyRoadmapMilestoneReorder,
normalizeRoadmapFeatureOrder,
applyRoadmapFeatureReorder,
moveRoadmapFeature,
} from "./roadmap-ordering.js";
export {
isTaskPriority,
normalizeTaskPriority,
@@ -352,12 +321,6 @@ export {
sortTasksForDisplayColumn,
} from "./task-priority.js";
export type { TaskPrioritySortable, TaskColumnSortable } from "./task-priority.js";
export {
mapFeatureToTaskHandoff,
mapRoadmapToMissionHandoff,
mapRoadmapWithHierarchyToMissionHandoff,
mapAllFeaturesToTaskHandoffs,
} from "./roadmap-handoff.js";
// ── Mission Hierarchy Types ────────────────────────────────────────────
@@ -433,8 +396,6 @@ export type {
} from "./mission-types.js";
export { MissionStore } from "./mission-store.js";
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
export { RoadmapStore } from "./roadmap-store.js";
export type { RoadmapStoreEvents } from "./roadmap-store.js";
// ── Central Infrastructure (Multi-Project Support) ───────────────────────────

View File

@@ -191,7 +191,7 @@ export interface PluginToolResult {
// ── Plugin Routes ────────────────────────────────────────────────────
export type PluginRouteMethod = "GET" | "POST" | "PUT" | "DELETE";
export type PluginRouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
/**
* Custom dashboard API route definition.

View File

@@ -1,163 +0,0 @@
/**
* Pure mapping helpers for converting roadmap hierarchy data into mission/task planning handoffs.
*
* These helpers are read-only transformations that preserve source lineage and deterministic
* ordering without coupling to MissionStore or task persistence.
*
* @module roadmap-handoff
*/
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapWithHierarchy,
RoadmapFeatureTaskPlanningHandoff,
RoadmapMissionPlanningHandoff,
RoadmapFeatureSourceRef,
RoadmapMissionPlanningMilestoneHandoff,
} from "./roadmap-types.js";
import {
normalizeRoadmapMilestoneOrder,
normalizeRoadmapFeatureOrder,
} from "./roadmap-ordering.js";
/**
* Build a source reference for a roadmap feature.
*
* Includes roadmap and milestone context for downstream planning prompts.
*/
function buildFeatureSourceRef(
roadmap: Roadmap,
milestone: RoadmapMilestone,
feature: RoadmapFeature,
): RoadmapFeatureSourceRef {
return {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
};
}
/**
* Convert a single roadmap feature into a task planning handoff payload.
*
* The handoff preserves source lineage for traceability and deterministic ordering
* for consistent downstream processing.
*/
export function mapFeatureToTaskHandoff(
roadmap: Roadmap,
milestone: RoadmapMilestone,
feature: RoadmapFeature,
): RoadmapFeatureTaskPlanningHandoff {
return {
source: buildFeatureSourceRef(roadmap, milestone, feature),
title: feature.title,
description: feature.description,
};
}
/**
* Convert a full roadmap hierarchy into a mission planning handoff payload.
*
* The handoff preserves deterministic ordering by normalizing milestone and feature
* order indices before building the payload.
*
* @param roadmap - The roadmap to convert
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
* @param featuresByMilestoneId - Features grouped by milestone ID
* @returns Mission planning handoff payload
*/
export function mapRoadmapToMissionHandoff(
roadmap: Roadmap,
milestones: readonly RoadmapMilestone[],
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
): RoadmapMissionPlanningHandoff {
// Normalize milestone ordering deterministically
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
const milestoneHandoffs: RoadmapMissionPlanningMilestoneHandoff[] = normalizedMilestones.map((milestone) => {
// Get features for this milestone and normalize their order
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
return {
sourceMilestoneId: milestone.id,
title: milestone.title,
description: milestone.description,
orderIndex: milestone.orderIndex,
features: normalizedFeatures.map((feature) => ({
sourceFeatureId: feature.id,
title: feature.title,
description: feature.description,
orderIndex: feature.orderIndex,
})),
};
});
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: milestoneHandoffs,
};
}
/**
* Convert a roadmap with full hierarchy into a mission planning handoff payload.
*
* Convenience overload that accepts the composite RoadmapWithHierarchy type.
*/
export function mapRoadmapWithHierarchyToMissionHandoff(
roadmapWithHierarchy: RoadmapWithHierarchy,
): RoadmapMissionPlanningHandoff {
// Build features by milestone ID map
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
for (const milestone of roadmapWithHierarchy.milestones) {
featuresByMilestoneId.set(milestone.id, milestone.features);
}
return mapRoadmapToMissionHandoff(
roadmapWithHierarchy,
roadmapWithHierarchy.milestones,
featuresByMilestoneId,
);
}
/**
* Convert all features from a roadmap into task planning handoff payloads.
*
* Flattens the roadmap hierarchy into individual feature handoffs, each preserving
* source lineage and deterministic ordering.
*
* @param roadmap - The parent roadmap
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
* @param featuresByMilestoneId - Features grouped by milestone ID
* @returns Array of feature task planning handoffs, ordered by milestone order then feature order
*/
export function mapAllFeaturesToTaskHandoffs(
roadmap: Roadmap,
milestones: readonly RoadmapMilestone[],
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
): RoadmapFeatureTaskPlanningHandoff[] {
// Normalize milestone ordering deterministically
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
for (const milestone of normalizedMilestones) {
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
for (const feature of normalizedFeatures) {
handoffs.push(mapFeatureToTaskHandoff(roadmap, milestone, feature));
}
}
return handoffs;
}

View File

@@ -1,311 +0,0 @@
import type {
RoadmapFeature,
RoadmapFeatureMoveInput,
RoadmapFeatureMoveResult,
RoadmapFeatureReorderInput,
RoadmapMilestone,
RoadmapMilestoneReorderInput,
} from "./roadmap-types.js";
/**
* Pure ordering helpers for the standalone roadmap model.
*
* Ordering invariants enforced by this module:
* - helpers operate on scoped arrays (single roadmap for milestones, single
* milestone for feature reorders, source+target milestones for feature moves)
* - normalized `orderIndex` values are always contiguous + 0-based
* - when stored order data is conflicting, helpers repair deterministically via
* `orderIndex ASC`, `createdAt ASC`, `id ASC`
* - explicit reorder helpers reject partial or duplicate ID lists instead of
* guessing user intent
*/
interface OrderedEntity {
id: string;
orderIndex: number;
createdAt: string;
}
function compareOrderedEntities<T extends OrderedEntity>(a: T, b: T): number {
if (a.orderIndex !== b.orderIndex) {
return a.orderIndex - b.orderIndex;
}
if (a.createdAt !== b.createdAt) {
return a.createdAt.localeCompare(b.createdAt);
}
return a.id.localeCompare(b.id);
}
function clampInsertionIndex(targetIndex: number, length: number): number {
if (!Number.isFinite(targetIndex)) {
return length;
}
const normalized = Math.trunc(targetIndex);
if (normalized < 0) {
return 0;
}
if (normalized > length) {
return length;
}
return normalized;
}
function assertScopedRoadmapMilestones(
milestones: readonly RoadmapMilestone[],
roadmapId: string,
): void {
for (const milestone of milestones) {
if (milestone.roadmapId !== roadmapId) {
throw new Error(
`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`,
);
}
}
}
function assertScopedMilestoneFeatures(
features: readonly RoadmapFeature[],
milestoneId: string,
): void {
for (const feature of features) {
if (feature.milestoneId !== milestoneId) {
throw new Error(
`Feature ${feature.id} does not belong to milestone ${milestoneId}`,
);
}
}
}
function assertScopedMoveFeatures(
features: readonly RoadmapFeature[],
fromMilestoneId: string,
toMilestoneId: string,
): void {
const validMilestoneIds = new Set([fromMilestoneId, toMilestoneId]);
for (const feature of features) {
if (!validMilestoneIds.has(feature.milestoneId)) {
throw new Error(
`Feature ${feature.id} is outside the affected milestone scope (${fromMilestoneId}${toMilestoneId})`,
);
}
}
}
function assertExactIdSet(
entityLabel: string,
actualIds: readonly string[],
orderedIds: readonly string[],
): void {
const requestedIds = new Set<string>();
for (const id of orderedIds) {
if (requestedIds.has(id)) {
throw new Error(`Duplicate ${entityLabel} id in requested order: ${id}`);
}
requestedIds.add(id);
}
if (actualIds.length !== orderedIds.length) {
throw new Error(
`Expected ${actualIds.length} ${entityLabel} ids but received ${orderedIds.length}`,
);
}
const actualIdSet = new Set(actualIds);
for (const id of orderedIds) {
if (!actualIdSet.has(id)) {
throw new Error(`${capitalize(entityLabel)} ${id} not found in scoped list`);
}
}
for (const id of actualIds) {
if (!requestedIds.has(id)) {
throw new Error(`Missing ${entityLabel} id in requested order: ${id}`);
}
}
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function assignContiguousOrder<T extends OrderedEntity>(items: readonly T[]): T[] {
return items.map((item, orderIndex) => {
if (item.orderIndex === orderIndex) {
return { ...item };
}
return {
...item,
orderIndex,
};
});
}
/**
* Repairs milestone ordering for a single roadmap scope.
*
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
*/
export function normalizeRoadmapMilestoneOrder(
milestones: readonly RoadmapMilestone[],
): RoadmapMilestone[] {
if (milestones.length === 0) {
return [];
}
assertScopedRoadmapMilestones(milestones, milestones[0].roadmapId);
return assignContiguousOrder(
[...milestones].sort(compareOrderedEntities),
);
}
/**
* Applies an explicit milestone reorder for a single roadmap scope.
*
* The caller must provide the complete milestone ID set exactly once. Partial
* or duplicate lists are rejected to keep reorders deterministic.
*/
export function applyRoadmapMilestoneReorder(
milestones: readonly RoadmapMilestone[],
input: RoadmapMilestoneReorderInput,
): RoadmapMilestone[] {
assertScopedRoadmapMilestones(milestones, input.roadmapId);
const normalized = normalizeRoadmapMilestoneOrder(milestones);
const ids = normalized.map((milestone) => milestone.id);
assertExactIdSet("milestone", ids, input.orderedMilestoneIds);
const byId = new Map(normalized.map((milestone) => [milestone.id, milestone]));
return assignContiguousOrder(
input.orderedMilestoneIds.map((id) => byId.get(id)!),
);
}
/**
* Repairs feature ordering for a single milestone scope.
*
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
*/
export function normalizeRoadmapFeatureOrder(
features: readonly RoadmapFeature[],
): RoadmapFeature[] {
if (features.length === 0) {
return [];
}
assertScopedMilestoneFeatures(features, features[0].milestoneId);
return assignContiguousOrder(
[...features].sort(compareOrderedEntities),
);
}
/**
* Applies an explicit feature reorder for a single milestone scope.
*
* The caller must provide the complete feature ID set exactly once. Partial or
* duplicate lists are rejected to keep reorder behavior deterministic.
*/
export function applyRoadmapFeatureReorder(
features: readonly RoadmapFeature[],
input: RoadmapFeatureReorderInput,
): RoadmapFeature[] {
assertScopedMilestoneFeatures(features, input.milestoneId);
const normalized = normalizeRoadmapFeatureOrder(features);
const ids = normalized.map((feature) => feature.id);
assertExactIdSet("feature", ids, input.orderedFeatureIds);
const byId = new Map(normalized.map((feature) => [feature.id, feature]));
return assignContiguousOrder(
input.orderedFeatureIds.map((id) => byId.get(id)!),
);
}
/**
* Moves a feature within the affected milestone scope and deterministically
* normalizes both source and destination order.
*
* For cross-milestone moves, pass the combined feature list from the source and
* target milestones. For within-milestone moves, pass the current milestone's
* feature list. `targetOrderIndex` is clamped into the destination range.
*/
export function moveRoadmapFeature(
features: readonly RoadmapFeature[],
input: RoadmapFeatureMoveInput,
): RoadmapFeatureMoveResult {
assertScopedMoveFeatures(features, input.fromMilestoneId, input.toMilestoneId);
const existingFeature = features.find((feature) => feature.id === input.featureId);
if (!existingFeature) {
throw new Error(`Feature ${input.featureId} not found in affected milestone scope`);
}
if (existingFeature.milestoneId !== input.fromMilestoneId) {
throw new Error(
`Feature ${input.featureId} does not belong to milestone ${input.fromMilestoneId}`,
);
}
const sourceFeatures = normalizeRoadmapFeatureOrder(
features.filter((feature) => feature.milestoneId === input.fromMilestoneId),
);
const sourceWithoutFeature = sourceFeatures.filter(
(feature) => feature.id !== input.featureId,
);
if (input.fromMilestoneId === input.toMilestoneId) {
const insertionIndex = clampInsertionIndex(
input.targetOrderIndex,
sourceWithoutFeature.length,
);
const reordered = [...sourceWithoutFeature];
reordered.splice(insertionIndex, 0, {
...existingFeature,
milestoneId: input.toMilestoneId,
orderIndex: insertionIndex,
});
const normalized = assignContiguousOrder(reordered);
const movedFeature = normalized.find((feature) => feature.id === input.featureId)!;
return {
movedFeature,
affectedFeatures: normalized,
sourceMilestoneFeatures: normalized,
targetMilestoneFeatures: normalized,
};
}
const targetFeatures = normalizeRoadmapFeatureOrder(
features.filter((feature) => feature.milestoneId === input.toMilestoneId),
);
const insertionIndex = clampInsertionIndex(
input.targetOrderIndex,
targetFeatures.length,
);
const targetWithInsertedFeature = [...targetFeatures];
targetWithInsertedFeature.splice(insertionIndex, 0, {
...existingFeature,
milestoneId: input.toMilestoneId,
orderIndex: insertionIndex,
});
const normalizedSource = assignContiguousOrder(sourceWithoutFeature);
const normalizedTarget = assignContiguousOrder(targetWithInsertedFeature);
const movedFeature = normalizedTarget.find((feature) => feature.id === input.featureId)!;
return {
movedFeature,
affectedFeatures: [...normalizedSource, ...normalizedTarget],
sourceMilestoneFeatures: normalizedSource,
targetMilestoneFeatures: normalizedTarget,
};
}

View File

@@ -1,960 +0,0 @@
/**
* RoadmapStore - Data layer for standalone roadmap persistence.
*
* Manages CRUD operations for roadmaps, milestones, and features.
* Provides deterministic ordering via covering indexes and atomic reorder/move operations.
*
* Ordering invariants:
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
* - all list/read queries use deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
* - cross-milestone feature moves atomically renumber both affected milestone scopes
*/
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMilestoneReorderInput,
RoadmapFeatureReorderInput,
RoadmapFeatureMoveInput,
RoadmapMilestoneWithFeatures,
RoadmapWithHierarchy,
RoadmapExportBundle,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
RoadmapFeatureSourceRef,
} from "./roadmap-types.js";
import {
applyRoadmapMilestoneReorder,
applyRoadmapFeatureReorder,
moveRoadmapFeature,
} from "./roadmap-ordering.js";
// ── Event Types ─────────────────────────────────────────────────────
export interface RoadmapStoreEvents {
/** Emitted when a roadmap is created */
"roadmap:created": [Roadmap];
/** Emitted when a roadmap is updated */
"roadmap:updated": [Roadmap];
/** Emitted when a roadmap is deleted */
"roadmap:deleted": [string];
/** Emitted when a milestone is created */
"milestone:created": [RoadmapMilestone];
/** Emitted when a milestone is updated */
"milestone:updated": [RoadmapMilestone];
/** Emitted when a milestone is deleted */
"milestone:deleted": [string];
/** Emitted when a milestone is reordered */
"milestone:reordered": [{ roadmapId: string; milestones: RoadmapMilestone[] }];
/** Emitted when a feature is created */
"feature:created": [RoadmapFeature];
/** Emitted when a feature is updated */
"feature:updated": [RoadmapFeature];
/** Emitted when a feature is deleted */
"feature:deleted": [RoadmapFeature];
/** Emitted when features are reordered within a milestone */
"feature:reordered": [{ milestoneId: string; features: RoadmapFeature[] }];
/** Emitted when a feature is moved (including cross-milestone moves) */
"feature:moved": [{ feature: RoadmapFeature; fromMilestoneId: string; toMilestoneId: string }];
}
// ── Row Interfaces ──────────────────────────────────────────────────
/** Database row shape for roadmaps. */
interface RoadmapRow {
id: string;
title: string;
description: string | null;
createdAt: string;
updatedAt: string;
}
/** Database row shape for roadmap_milestones. */
interface RoadmapMilestoneRow {
id: string;
roadmapId: string;
title: string;
description: string | null;
orderIndex: number;
createdAt: string;
updatedAt: string;
}
/** Database row shape for roadmap_features. */
interface RoadmapFeatureRow {
id: string;
milestoneId: string;
title: string;
description: string | null;
orderIndex: number;
createdAt: string;
updatedAt: string;
}
// ── RoadmapStore Class ──────────────────────────────────────────────
export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
/**
* Creates a new RoadmapStore instance.
*
* @param db - Shared Database instance (same instance used by TaskStore)
*/
constructor(private db: Database) {
super();
this.setMaxListeners(50);
}
// ── ID Generators ───────────────────────────────────────────────────
private generateRoadmapId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `RM-${timestamp.toString(36).toUpperCase()}-${random}`;
}
private generateMilestoneId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`;
}
private generateFeatureId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `RF-${timestamp.toString(36).toUpperCase()}-${random}`;
}
// ── Row-to-Object Converters ───────────────────────────────────────
private rowToRoadmap(row: RoadmapRow): Roadmap {
return {
id: row.id,
title: row.title,
description: row.description || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToMilestone(row: RoadmapMilestoneRow): RoadmapMilestone {
return {
id: row.id,
roadmapId: row.roadmapId,
title: row.title,
description: row.description || undefined,
orderIndex: row.orderIndex,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToFeature(row: RoadmapFeatureRow): RoadmapFeature {
return {
id: row.id,
milestoneId: row.milestoneId,
title: row.title,
description: row.description || undefined,
orderIndex: row.orderIndex,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ── Roadmap CRUD ─────────────────────────────────────────────────
/**
* Create a new roadmap.
*
* @param input - Roadmap creation input
* @returns The created roadmap
*/
createRoadmap(input: RoadmapCreateInput): Roadmap {
const now = new Date().toISOString();
const id = this.generateRoadmapId();
const roadmap: Roadmap = {
id,
title: input.title,
description: input.description,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO roadmaps (id, title, description, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?)
`).run(
roadmap.id,
roadmap.title,
roadmap.description ?? null,
roadmap.createdAt,
roadmap.updatedAt,
);
this.db.bumpLastModified();
this.emit("roadmap:created", roadmap);
return roadmap;
}
/**
* Get a roadmap by ID.
*
* @param id - Roadmap ID
* @returns The roadmap, or undefined if not found
*/
getRoadmap(id: string): Roadmap | undefined {
const row = this.db.prepare("SELECT * FROM roadmaps WHERE id = ?").get(id) as unknown as RoadmapRow | undefined;
if (!row) return undefined;
return this.rowToRoadmap(row);
}
/**
* List all roadmaps, ordered by creation date (newest first).
*
* @returns Array of roadmaps
*/
listRoadmaps(): Roadmap[] {
const rows = this.db.prepare(
"SELECT * FROM roadmaps ORDER BY createdAt DESC"
).all();
return (rows as unknown as RoadmapRow[]).map((row) => this.rowToRoadmap(row));
}
/**
* Update a roadmap.
*
* @param id - Roadmap ID
* @param updates - Partial roadmap updates
* @returns The updated roadmap
* @throws Error if roadmap not found
*/
updateRoadmap(id: string, updates: RoadmapUpdateInput): Roadmap {
const roadmap = this.getRoadmap(id);
if (!roadmap) {
throw new Error(`Roadmap ${id} not found`);
}
const updated: Roadmap = {
...roadmap,
...updates,
id, // Prevent changing ID
createdAt: roadmap.createdAt, // Prevent changing creation time
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE roadmaps SET
title = ?,
description = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("roadmap:updated", updated);
return updated;
}
/**
* Delete a roadmap and all its milestones/features (cascading).
*
* @param id - Roadmap ID
* @throws Error if roadmap not found
*/
deleteRoadmap(id: string): void {
const roadmap = this.getRoadmap(id);
if (!roadmap) {
throw new Error(`Roadmap ${id} not found`);
}
// SQLite FK cascade will handle milestones and features
this.db.prepare("DELETE FROM roadmaps WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("roadmap:deleted", id);
}
// ── Milestone CRUD ────────────────────────────────────────────────
/**
* Add a milestone to a roadmap.
* Automatically computes the orderIndex (max + 1).
*
* @param roadmapId - Parent roadmap ID
* @param input - Milestone creation input
* @returns The created milestone
* @throws Error if roadmap not found
*/
createMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput): RoadmapMilestone {
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
const now = new Date().toISOString();
const id = this.generateMilestoneId();
// Compute next orderIndex
const existingMilestones = this.listMilestones(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,
};
this.db.prepare(`
INSERT INTO roadmap_milestones (id, roadmapId, title, description, orderIndex, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
milestone.id,
milestone.roadmapId,
milestone.title,
milestone.description ?? null,
milestone.orderIndex,
milestone.createdAt,
milestone.updatedAt,
);
this.db.bumpLastModified();
this.emit("milestone:created", milestone);
return milestone;
}
/**
* Get a milestone by ID.
*
* @param id - Milestone ID
* @returns The milestone, or undefined if not found
*/
getMilestone(id: string): RoadmapMilestone | undefined {
const row = this.db.prepare("SELECT * FROM roadmap_milestones WHERE id = ?").get(id) as unknown as RoadmapMilestoneRow | undefined;
if (!row) return undefined;
return this.rowToMilestone(row);
}
/**
* List milestones for a roadmap, ordered deterministically.
*
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
* to ensure consistent results when stored order data is incomplete or conflicting.
*
* @param roadmapId - Roadmap ID
* @returns Array of milestones in deterministic order
*/
listMilestones(roadmapId: string): RoadmapMilestone[] {
const rows = this.db.prepare(
"SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
).all(roadmapId);
return (rows as unknown as RoadmapMilestoneRow[]).map((row) => this.rowToMilestone(row));
}
/**
* Update a milestone.
*
* @param id - Milestone ID
* @param updates - Partial milestone updates
* @returns The updated milestone
* @throws Error if milestone not found
*/
updateMilestone(id: string, updates: RoadmapMilestoneUpdateInput): RoadmapMilestone {
const milestone = this.getMilestone(id);
if (!milestone) {
throw new Error(`Milestone ${id} not found`);
}
const updated: RoadmapMilestone = {
...milestone,
...updates,
id, // Prevent changing ID
roadmapId: milestone.roadmapId, // Prevent moving to different roadmap
createdAt: milestone.createdAt, // Prevent changing creation time
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE roadmap_milestones SET
title = ?,
description = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("milestone:updated", updated);
return updated;
}
/**
* Delete a milestone and all its features (cascading).
*
* @param id - Milestone ID
* @throws Error if milestone not found
*/
deleteMilestone(id: string): void {
const milestone = this.getMilestone(id);
if (!milestone) {
throw new Error(`Milestone ${id} not found`);
}
// SQLite FK cascade will handle features
this.db.prepare("DELETE FROM roadmap_milestones WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("milestone:deleted", id);
}
// ── Feature CRUD ─────────────────────────────────────────────────
/**
* Add a feature to a milestone.
* Automatically computes the orderIndex (max + 1).
*
* @param milestoneId - Parent milestone ID
* @param input - Feature creation input
* @returns The created feature
* @throws Error if milestone not found
*/
createFeature(milestoneId: string, input: RoadmapFeatureCreateInput): RoadmapFeature {
const milestone = this.getMilestone(milestoneId);
if (!milestone) {
throw new Error(`Milestone ${milestoneId} not found`);
}
const now = new Date().toISOString();
const id = this.generateFeatureId();
// Compute next orderIndex
const existingFeatures = this.listFeatures(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,
};
this.db.prepare(`
INSERT INTO roadmap_features (id, milestoneId, title, description, orderIndex, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
feature.id,
feature.milestoneId,
feature.title,
feature.description ?? null,
feature.orderIndex,
feature.createdAt,
feature.updatedAt,
);
this.db.bumpLastModified();
this.emit("feature:created", feature);
return feature;
}
/**
* Get a feature by ID.
*
* @param id - Feature ID
* @returns The feature, or undefined if not found
*/
getFeature(id: string): RoadmapFeature | undefined {
const row = this.db.prepare("SELECT * FROM roadmap_features WHERE id = ?").get(id) as unknown as RoadmapFeatureRow | undefined;
if (!row) return undefined;
return this.rowToFeature(row);
}
/**
* List features for a milestone, ordered deterministically.
*
* Uses deterministic ordering: ORDER BY orderIndex ASC, createdAt ASC, id ASC
* to ensure consistent results when stored order data is incomplete or conflicting.
*
* @param milestoneId - Milestone ID
* @returns Array of features in deterministic order
*/
listFeatures(milestoneId: string): RoadmapFeature[] {
const rows = this.db.prepare(
"SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
).all(milestoneId);
return (rows as unknown as RoadmapFeatureRow[]).map((row) => this.rowToFeature(row));
}
/**
* Update a feature.
*
* @param id - Feature ID
* @param updates - Partial feature updates
* @returns The updated feature
* @throws Error if feature not found
*/
updateFeature(id: string, updates: RoadmapFeatureUpdateInput): RoadmapFeature {
const feature = this.getFeature(id);
if (!feature) {
throw new Error(`Feature ${id} not found`);
}
const updated: RoadmapFeature = {
...feature,
...updates,
id, // Prevent changing ID
milestoneId: feature.milestoneId, // Prevent moving via update (use moveFeature instead)
createdAt: feature.createdAt, // Prevent changing creation time
updatedAt: new Date().toISOString(),
};
this.db.prepare(`
UPDATE roadmap_features SET
title = ?,
description = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.description ?? null,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("feature:updated", updated);
return updated;
}
/**
* Delete a feature.
*
* @param id - Feature ID
* @throws Error if feature not found
*/
deleteFeature(id: string): void {
const feature = this.getFeature(id);
if (!feature) {
throw new Error(`Feature ${id} not found`);
}
this.db.prepare("DELETE FROM roadmap_features WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("feature:deleted", feature);
}
// ── Reorder Operations ────────────────────────────────────────────
/**
* Reorder milestones within a roadmap.
*
* Applies an explicit reorder input and persists the full normalized order.
* The input must contain all milestone IDs exactly once.
*
* @param input - Reorder input with complete milestone ID list
* @returns The reordered milestones in their new order
* @throws Error if milestone set is incomplete, duplicate, or not found
*/
reorderMilestones(input: RoadmapMilestoneReorderInput): RoadmapMilestone[] {
// Validate roadmap exists
const roadmap = this.getRoadmap(input.roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${input.roadmapId} not found`);
}
// Load current milestones with deterministic ordering
const milestones = this.listMilestones(input.roadmapId);
// Apply the reorder using the pure ordering helper
const reordered = applyRoadmapMilestoneReorder(milestones, input);
// Persist in a transaction
this.db.transaction(() => {
for (const milestone of reordered) {
this.db.prepare(`
UPDATE roadmap_milestones SET orderIndex = ?, updatedAt = ? WHERE id = ?
`).run(milestone.orderIndex, new Date().toISOString(), milestone.id);
}
});
this.db.bumpLastModified();
this.emit("milestone:reordered", { roadmapId: input.roadmapId, milestones: reordered });
return reordered;
}
/**
* Reorder features within a milestone.
*
* Applies an explicit reorder input and persists the full normalized order.
* The input must contain all feature IDs for the milestone exactly once.
*
* @param input - Reorder input with complete feature ID list
* @returns The reordered features in their new order
* @throws Error if feature set is incomplete, duplicate, or not found
*/
reorderFeatures(input: RoadmapFeatureReorderInput): RoadmapFeature[] {
// Validate milestone exists and belongs to the roadmap
const milestone = this.getMilestone(input.milestoneId);
if (!milestone) {
throw new Error(`Milestone ${input.milestoneId} not found`);
}
if (milestone.roadmapId !== input.roadmapId) {
throw new Error(`Milestone ${input.milestoneId} does not belong to roadmap ${input.roadmapId}`);
}
// Load current features with deterministic ordering
const features = this.listFeatures(input.milestoneId);
// Apply the reorder using the pure ordering helper
const reordered = applyRoadmapFeatureReorder(features, input);
// Persist in a transaction
this.db.transaction(() => {
for (const feature of reordered) {
this.db.prepare(`
UPDATE roadmap_features SET orderIndex = ?, updatedAt = ? WHERE id = ?
`).run(feature.orderIndex, new Date().toISOString(), feature.id);
}
});
this.db.bumpLastModified();
this.emit("feature:reordered", { milestoneId: input.milestoneId, features: reordered });
return reordered;
}
/**
* Move a feature, including cross-milestone moves.
*
* Atomically renumbers both the source and destination milestone scopes.
*
* @param input - Move input with source/destination milestone info
* @returns The moved feature and both affected milestone feature lists
* @throws Error if feature or milestone not found, or scope validation fails
*/
moveFeature(input: RoadmapFeatureMoveInput): {
movedFeature: RoadmapFeature;
sourceMilestoneFeatures: RoadmapFeature[];
targetMilestoneFeatures: RoadmapFeature[];
} {
// Validate roadmap exists
const roadmap = this.getRoadmap(input.roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${input.roadmapId} not found`);
}
// Validate both milestones exist and belong to the roadmap
const fromMilestone = this.getMilestone(input.fromMilestoneId);
const toMilestone = this.getMilestone(input.toMilestoneId);
if (!fromMilestone) {
throw new Error(`Source milestone ${input.fromMilestoneId} not found`);
}
if (!toMilestone) {
throw new Error(`Destination milestone ${input.toMilestoneId} not found`);
}
if (fromMilestone.roadmapId !== input.roadmapId) {
throw new Error(`Source milestone ${input.fromMilestoneId} does not belong to roadmap ${input.roadmapId}`);
}
if (toMilestone.roadmapId !== input.roadmapId) {
throw new Error(`Destination milestone ${input.toMilestoneId} does not belong to roadmap ${input.roadmapId}`);
}
// Load features from both milestones with deterministic ordering
const sourceFeatures = this.listFeatures(input.fromMilestoneId);
const targetFeatures = this.listFeatures(input.toMilestoneId);
// For same-milestone moves, pass only one list to avoid duplication
// For cross-milestone moves, pass the combined list
const allFeatures = input.fromMilestoneId === input.toMilestoneId
? sourceFeatures
: [...sourceFeatures, ...targetFeatures];
// Apply the move using the pure ordering helper
const result = moveRoadmapFeature(allFeatures, input);
// Persist in a transaction
this.db.transaction(() => {
// Update all affected features
for (const feature of result.affectedFeatures) {
this.db.prepare(`
UPDATE roadmap_features SET milestoneId = ?, orderIndex = ?, updatedAt = ? WHERE id = ?
`).run(feature.milestoneId, feature.orderIndex, new Date().toISOString(), feature.id);
}
});
this.db.bumpLastModified();
this.emit("feature:moved", {
feature: result.movedFeature,
fromMilestoneId: input.fromMilestoneId,
toMilestoneId: input.toMilestoneId,
});
return {
movedFeature: result.movedFeature,
sourceMilestoneFeatures: result.sourceMilestoneFeatures,
targetMilestoneFeatures: result.targetMilestoneFeatures,
};
}
// ── Hierarchy Operations ───────────────────────────────────────────
/**
* Get a milestone with all of its features in deterministic order.
*
* @param id - Milestone ID
* @returns The milestone with features, or undefined if not found
*/
getMilestoneWithFeatures(id: string): RoadmapMilestoneWithFeatures | undefined {
const milestone = this.getMilestone(id);
if (!milestone) return undefined;
return {
...milestone,
features: this.listFeatures(id),
};
}
/**
* Get a roadmap with its full hierarchy (milestones → features).
*
* @param id - Roadmap ID
* @returns The roadmap with hierarchy, or undefined if not found
*/
getRoadmapWithHierarchy(id: string): RoadmapWithHierarchy | undefined {
const roadmap = this.getRoadmap(id);
if (!roadmap) return undefined;
return {
...roadmap,
milestones: this.listMilestones(id).map((milestone) => ({
...milestone,
features: this.listFeatures(milestone.id),
})),
};
}
// ── Export / Handoff Operations ────────────────────────────────────
/**
* Get a flat export bundle for a roadmap.
*
* Returns all roadmap data in a flat structure suitable for persistence,
* APIs, import/export, and sync jobs. Entities are separated so downstream
* persistence layers can upsert by table/collection.
*
* @param roadmapId - Roadmap ID
* @returns The export bundle with ordered entities
* @throws Error if roadmap not found
*/
getRoadmapExport(roadmapId: string): RoadmapExportBundle {
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
const milestones = this.listMilestones(roadmapId);
const allFeatures: RoadmapFeature[] = [];
for (const milestone of milestones) {
const features = this.listFeatures(milestone.id);
allFeatures.push(...features);
}
return {
roadmap,
milestones,
features: allFeatures,
};
}
/**
* Get a mission planning handoff payload for a roadmap.
*
* Converts the roadmap into a mission planning structure while preserving
* source IDs and deterministic order. Does not couple to MissionStore internals.
*
* @param roadmapId - Roadmap ID
* @returns The mission planning handoff payload
* @throws Error if roadmap not found
*/
getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
const milestones = this.listMilestones(roadmapId);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: milestones.map((milestone) => {
const features = this.listFeatures(milestone.id);
return {
sourceMilestoneId: milestone.id,
title: milestone.title,
description: milestone.description,
orderIndex: milestone.orderIndex,
features: features.map((feature) => ({
sourceFeatureId: feature.id,
title: feature.title,
description: feature.description,
orderIndex: feature.orderIndex,
})),
};
}),
};
}
/**
* Get a task planning handoff payload for a single roadmap feature.
*
* Returns a self-contained handoff payload for converting a roadmap feature
* into task planning flows without coupling to MissionStore internals.
*
* @param roadmapId - Parent roadmap ID (for validation)
* @param milestoneId - Parent milestone ID (for validation)
* @param featureId - Feature ID to generate handoff for
* @returns The task planning handoff payload
* @throws Error if any entity is not found or if ownership validation fails
*/
getRoadmapFeatureHandoff(
roadmapId: string,
milestoneId: string,
featureId: string,
): RoadmapFeatureTaskPlanningHandoff {
// Validate roadmap exists
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
// Validate milestone exists and belongs to roadmap
const milestone = this.getMilestone(milestoneId);
if (!milestone) {
throw new Error(`Milestone ${milestoneId} not found`);
}
if (milestone.roadmapId !== roadmapId) {
throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`);
}
// Validate feature exists and belongs to milestone
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
if (feature.milestoneId !== milestoneId) {
throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`);
}
// Build the source reference with ordering context
const source: RoadmapFeatureSourceRef = {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
};
return {
source,
title: feature.title,
description: feature.description,
};
}
/**
* Get a mission planning handoff payload for a roadmap.
*
* Alias for getRoadmapMissionHandoff() for API consistency.
* Converts the roadmap into a mission planning structure while preserving
* source IDs and deterministic order.
*
* @param roadmapId - Roadmap ID
* @returns The mission planning handoff payload
* @throws Error if roadmap not found
*/
getMissionPlanningHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
return this.getRoadmapMissionHandoff(roadmapId);
}
/**
* List all task planning handoff payloads for a roadmap.
*
* Returns a flat list of all feature handoffs in deterministic order
* (milestone order index, then feature order index).
*
* @param roadmapId - Roadmap ID
* @returns Array of task planning handoff payloads for all features
* @throws Error if roadmap not found
*/
listFeatureTaskPlanningHandoffs(roadmapId: string): RoadmapFeatureTaskPlanningHandoff[] {
// Validate roadmap exists
const roadmap = this.getRoadmap(roadmapId);
if (!roadmap) {
throw new Error(`Roadmap ${roadmapId} not found`);
}
const milestones = this.listMilestones(roadmapId);
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
for (const milestone of milestones) {
const features = this.listFeatures(milestone.id);
for (const feature of features) {
const source: RoadmapFeatureSourceRef = {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
};
handoffs.push({
source,
title: feature.title,
description: feature.description,
});
}
}
return handoffs;
}
}

View File

@@ -1,310 +0,0 @@
/**
* Standalone roadmap planning types.
*
* This model is intentionally separate from the mission hierarchy so roadmap
* work can evolve independently of `MissionStore`/`MissionManager`.
*
* Core ordering invariants:
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
* - cross-milestone feature moves must renumber both the source and target
* milestone deterministically after the move
* - whenever stored order data is incomplete or conflicting, consumers should
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
*
* These contracts are persistence-agnostic and UI-agnostic. They define the
* canonical domain surface that downstream storage, API, and dashboard work use.
*
* @module roadmap-types
*/
/**
* A standalone roadmap container.
*
* Roadmaps do not reuse mission lifecycle or mission status concepts. They are
* lightweight planning artifacts that own ordered milestones.
*/
export interface Roadmap {
/** Unique identifier (for example `RM-01HXYZ...`) */
id: string;
/** Display title shown in roadmap lists and detail views */
title: string;
/** Optional long-form planning context for the roadmap */
description?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A milestone within a roadmap.
*
* `orderIndex` is the canonical persisted ordering field. It is always scoped to
* the parent roadmap and must remain contiguous + 0-based after reorder flows.
*/
export interface RoadmapMilestone {
/** Unique identifier (for example `RMS-01HXYZ...`) */
id: string;
/** Parent roadmap ID */
roadmapId: string;
/** Display title for the milestone */
title: string;
/** Optional description of the milestone's goals */
description?: string;
/** 0-based contiguous ordering within the roadmap */
orderIndex: number;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A feature within a roadmap milestone.
*
* `orderIndex` is scoped to the parent milestone. Cross-milestone moves must
* update `milestoneId` and then normalize both affected milestone lists back to
* contiguous 0-based order.
*/
export interface RoadmapFeature {
/** Unique identifier (for example `RF-01HXYZ...`) */
id: string;
/** Parent milestone ID */
milestoneId: string;
/** Display title for the feature */
title: string;
/** Optional description of the feature's intent */
description?: string;
/** 0-based contiguous ordering within the parent milestone */
orderIndex: number;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
// ── CRUD Input Types ────────────────────────────────────────────────
/** Input for creating a roadmap. */
export interface RoadmapCreateInput {
/** Display title of the roadmap (required) */
title: string;
/** Optional roadmap description */
description?: string;
}
/** Input for updating roadmap metadata. Ordering is handled by dedicated move/reorder DTOs. */
export interface RoadmapUpdateInput {
/** Updated display title */
title?: string;
/** Updated roadmap description */
description?: string;
}
/** Input for creating a milestone inside a roadmap. */
export interface RoadmapMilestoneCreateInput {
/** Display title of the milestone (required) */
title: string;
/** Optional milestone description */
description?: string;
}
/** Input for updating milestone metadata. Ordering is handled separately. */
export interface RoadmapMilestoneUpdateInput {
/** Updated milestone title */
title?: string;
/** Updated milestone description */
description?: string;
}
/** Input for creating a feature inside a milestone. */
export interface RoadmapFeatureCreateInput {
/** Display title of the feature (required) */
title: string;
/** Optional feature description */
description?: string;
}
/** Input for updating feature metadata. Ordering is handled separately. */
export interface RoadmapFeatureUpdateInput {
/** Updated feature title */
title?: string;
/** Updated feature description */
description?: string;
}
// ── Ordering / Move Payload Types ───────────────────────────────────
/**
* Explicit reorder payload for milestones within a roadmap.
*
* `orderedMilestoneIds` must contain the full set of milestone IDs for the
* roadmap exactly once. Consumers should reject partial or duplicate lists.
*/
export interface RoadmapMilestoneReorderInput {
/** Roadmap whose milestone sequence is being rewritten */
roadmapId: string;
/** Complete milestone ID sequence in final order */
orderedMilestoneIds: string[];
}
/**
* Explicit reorder payload for features within a single milestone.
*
* `orderedFeatureIds` must contain the full set of feature IDs for the milestone
* exactly once. The resulting `orderIndex` values must be normalized to 0-based
* contiguous order.
*/
export interface RoadmapFeatureReorderInput {
/** Parent roadmap for integrity validation */
roadmapId: string;
/** Milestone whose internal feature ordering is being rewritten */
milestoneId: string;
/** Complete feature ID sequence in final order */
orderedFeatureIds: string[];
}
/**
* Explicit move payload for relocating a feature, including cross-milestone moves.
*
* `targetOrderIndex` is the desired insertion position in the destination
* milestone before final normalization. Consumers should clamp out-of-range
* values and must deterministically renumber both source and destination
* milestones after the move.
*/
export interface RoadmapFeatureMoveInput {
/** Parent roadmap for integrity validation */
roadmapId: string;
/** Feature being moved */
featureId: string;
/** Current milestone that owns the feature */
fromMilestoneId: string;
/** Destination milestone after the move */
toMilestoneId: string;
/** Requested insertion index in the destination milestone */
targetOrderIndex: number;
}
/**
* Result of a feature move operation after deterministic renumbering.
*
* `affectedFeatures` contains the canonical post-move feature records for the
* source and target milestones. When a feature is moved within the same
* milestone, `sourceMilestoneFeatures` and `targetMilestoneFeatures` will be
* the same normalized list.
*/
export interface RoadmapFeatureMoveResult {
/** The moved feature after `milestoneId` and `orderIndex` updates */
movedFeature: RoadmapFeature;
/** Canonical post-move features for the affected milestone scope */
affectedFeatures: RoadmapFeature[];
/** Canonical feature list for the source milestone after the move */
sourceMilestoneFeatures: RoadmapFeature[];
/** Canonical feature list for the destination milestone after the move */
targetMilestoneFeatures: RoadmapFeature[];
}
// ── Composite Read Models ───────────────────────────────────────────
/** Milestone with all of its ordered features loaded. */
export interface RoadmapMilestoneWithFeatures extends RoadmapMilestone {
/** Features belonging to this milestone */
features: RoadmapFeature[];
}
/** Full roadmap hierarchy loaded in roadmap → milestone → feature order. */
export interface RoadmapWithHierarchy extends Roadmap {
/** Ordered milestones with ordered features */
milestones: RoadmapMilestoneWithFeatures[];
}
// ── Export / Handoff Contracts ──────────────────────────────────────
/**
* Flat export payload for persistence, APIs, import/export, and sync jobs.
*
* This shape intentionally keeps entities separate so downstream persistence
* layers can upsert by table/collection without first denormalizing a nested
* hierarchy.
*/
export interface RoadmapExportBundle {
/** Roadmap being exported */
roadmap: Roadmap;
/** Ordered milestones for the roadmap */
milestones: RoadmapMilestone[];
/** Ordered features for the roadmap's milestones */
features: RoadmapFeature[];
}
/**
* Source metadata carried forward when a roadmap feature is converted into a
* task-planning input or other downstream artifact.
*/
export interface RoadmapFeatureSourceRef {
/** Source roadmap ID */
roadmapId: string;
/** Source milestone ID */
milestoneId: string;
/** Source feature ID */
featureId: string;
/** Human-readable roadmap title for prompt context */
roadmapTitle: string;
/** Human-readable milestone title for prompt context */
milestoneTitle: string;
/** Canonical milestone order at handoff time */
milestoneOrderIndex: number;
/** Canonical feature order at handoff time */
featureOrderIndex: number;
}
/**
* Handoff payload for converting a single roadmap feature into task planning
* flows without coupling the task system to roadmap persistence details.
*/
export interface RoadmapFeatureTaskPlanningHandoff {
/** Source lineage and ordering context */
source: RoadmapFeatureSourceRef;
/** Title to seed the downstream task or planning prompt */
title: string;
/** Optional description to seed the downstream task or planning prompt */
description?: string;
}
/** Source-preserving milestone payload used for mission conversion handoffs. */
export interface RoadmapMissionPlanningMilestoneHandoff {
/** Source roadmap milestone ID */
sourceMilestoneId: string;
/** Canonical milestone title */
title: string;
/** Optional milestone description */
description?: string;
/** Canonical milestone ordering within the roadmap */
orderIndex: number;
/** Ordered roadmap features that belong to this milestone */
features: Array<{
/** Source roadmap feature ID */
sourceFeatureId: string;
/** Canonical feature title */
title: string;
/** Optional feature description */
description?: string;
/** Canonical feature ordering within the milestone */
orderIndex: number;
}>;
}
/**
* Handoff payload for converting a standalone roadmap into mission planning
* structures while preserving source IDs and deterministic order.
*/
export interface RoadmapMissionPlanningHandoff {
/** Source roadmap ID */
sourceRoadmapId: string;
/** Canonical roadmap title */
title: string;
/** Optional roadmap description */
description?: string;
/** Ordered milestone breakdown captured at handoff time */
milestones: RoadmapMissionPlanningMilestoneHandoff[];
}

View File

@@ -14,7 +14,6 @@ import { ArchiveDatabase } from "./archive-db.js";
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
import { MissionStore } from "./mission-store.js";
import { PluginStore } from "./plugin-store.js";
import { RoadmapStore } from "./roadmap-store.js";
import { InsightStore } from "./insight-store.js";
import { ResearchStore } from "./research-store.js";
import { TodoStore } from "./todo-store.js";
@@ -513,8 +512,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private missionStore: MissionStore | null = null;
/** Cached PluginStore instance */
private pluginStore: PluginStore | null = null;
/** Cached RoadmapStore instance */
private roadmapStore: RoadmapStore | null = null;
/** Cached InsightStore instance */
private insightStore: InsightStore | null = null;
/** Cached ResearchStore instance */
@@ -6813,17 +6810,6 @@ ${notificationsSection}`;
return this.pluginStore;
}
/**
* Get the RoadmapStore instance for standalone roadmap operations.
* Lazily initializes the RoadmapStore on first access.
*/
getRoadmapStore(): RoadmapStore {
if (!this.roadmapStore) {
this.roadmapStore = new RoadmapStore(this.db);
}
return this.roadmapStore;
}
/**
* Get the InsightStore instance for project insights operations.
* Lazily initializes the InsightStore on first access.

View File

@@ -74,6 +74,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.36.4",
"@fusion-plugin-examples/dependency-graph": "workspace:*",
"@fusion-plugin-examples/roadmap": "workspace:*",
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
"@fusion-plugin-examples/droid-runtime": "workspace:*",

View File

@@ -9,7 +9,7 @@ import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@f
// vi.mock is hoisted
vi.mock("../roadmap-suggestions.js", () => {
vi.mock("../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js", () => {
// Define error classes inside the factory - these will be used by the mocked module
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
@@ -453,11 +453,11 @@ describe("Roadmap Routes", () => {
});
describe("projectId scoping", () => {
it("uses projectId from query param", async () => {
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).toHaveBeenCalledWith("test-project");
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
});
});
@@ -576,16 +576,7 @@ describe("Roadmap Routes", () => {
});
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
it("returns 503 when generation times out", async () => {
// Import the mocked module
const mod = await import("../roadmap-suggestions.js");
// Create an instance of the mocked ServiceUnavailableError
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
// Mock to throw ServiceUnavailableError with timeout message
(mod.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockRejectedValue(error);
it("returns 503 when AI is unavailable", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const response = await performRequest(
@@ -597,21 +588,12 @@ describe("Roadmap Routes", () => {
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("timed out");
expect(response.body.error).toContain("AI service is not available");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
it("returns 503 when generation times out", async () => {
// Import the mocked module - vi.mocked helps with type inference
const mod = vi.mocked(await import("../roadmap-suggestions.js"));
// Create an instance of the mocked ServiceUnavailableError
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
// Mock to throw ServiceUnavailableError with timeout message
mod.generateFeatureSuggestions.mockRejectedValue(error);
it("returns 503 when AI is unavailable", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
@@ -624,7 +606,7 @@ describe("Roadmap Routes", () => {
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("timed out");
expect(response.body.error).toContain("AI service is not available");
});
});
});

View File

@@ -1,701 +1,91 @@
/**
* Roadmap REST API Routes
*
* Provides CRUD endpoints for standalone roadmaps, milestones, and features.
* Also includes AI-powered suggestion endpoints for milestone and feature creation,
* and read-only handoff endpoints for exporting roadmap data to mission/task planning.
*
* 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
* - Export/Handoff: GET /:roadmapId/export, GET /:roadmapId/handoff,
* GET /:roadmapId/handoff/mission,
* GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
*/
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";
import { getCreateAiSessionFactory, type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
import { createRoadmapPluginRoutes } from "../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.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);
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"
);
}
import {
generateMilestoneSuggestions,
validateSuggestionInput,
generateFeatureSuggestions,
validateFeatureSuggestionInput,
ValidationError as SuggestionValidationError,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
SUGGESTION_TIMEOUT_MS,
} from "./roadmap-suggestions.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
async function buildContext(store: TaskStore): Promise<PluginContext> {
const createAiSession = await getCreateAiSessionFactory();
// ── 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();
return {
pluginId: "fusion-plugin-roadmap",
taskStore: store,
settings: {},
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
emitEvent: () => {},
createAiSession,
};
}
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>();
const routes = createRoadmapPluginRoutes();
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) => {
// Route-level timeout as safety net (slightly longer than internal timeout)
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
const routeTimeoutId = setTimeout(() => {
if (!res.headersSent) {
res.status(503).json({ error: "Request timed out" });
}
}, ROUTE_TIMEOUT_MS);
// Clean up timeout on connection close
res.on("close", () => {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
});
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 });
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;
}
throw err;
}
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate milestone suggestions");
} finally {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
}
});
/**
* POST /api/roadmaps/milestones/:milestoneId/suggestions/features
* Generate feature suggestions using AI.
*/
router.post("/milestones/:milestoneId/suggestions/features", async (req, res) => {
// Route-level timeout as safety net (slightly longer than internal timeout)
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
const routeTimeoutId = setTimeout(() => {
if (!res.headersSent) {
res.status(503).json({ error: "Request timed out" });
}
}, ROUTE_TIMEOUT_MS);
// Clean up timeout on connection close
res.on("close", () => {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
});
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 });
if (result.body === undefined) {
res.status(result.status).send();
return;
}
throw err;
res.status(result.status).json(result.body);
return;
}
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate feature suggestions");
} finally {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
}
});
res.status(200).json(result);
};
// ── Export / Handoff Endpoints ──────────────────────────────────────────
/**
* GET /api/roadmaps/:roadmapId/export
* Get a flat export bundle for the roadmap.
*/
router.get("/:roadmapId/export", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const export_ = roadmapStore.getRoadmapExport(roadmapId);
res.json(export_);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to export roadmap");
}
});
/**
* GET /api/roadmaps/:roadmapId/handoff
* Get both mission-oriented and task-oriented handoff payloads for the roadmap.
*
* This is a convenience endpoint that combines both handoff types in a single response.
* Returns 404 if the roadmap is not found.
*/
router.get("/:roadmapId/handoff", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
// Get both handoff types
const missionHandoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
const featureHandoffs = roadmapStore.listFeatureTaskPlanningHandoffs(roadmapId);
res.json({
mission: missionHandoff,
features: featureHandoffs,
});
} catch (err) {
if (err instanceof ApiError) throw err;
// Handle not-found case from store methods
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err, "Failed to generate handoff");
}
});
/**
* GET /api/roadmaps/:roadmapId/handoff/mission
* Get a mission planning handoff payload for the roadmap.
*/
router.get("/:roadmapId/handoff/mission", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const handoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
res.json(handoff);
} catch (err) {
if (err instanceof ApiError) throw err;
// Handle not-found case from store methods
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err, "Failed to generate mission handoff");
}
});
/**
* GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
* Get a task planning handoff payload for a single feature.
*/
router.get("/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId, milestoneId, featureId } = req.params;
const handoff = roadmapStore.getRoadmapFeatureHandoff(roadmapId, milestoneId, featureId);
res.json(handoff);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate task handoff");
}
});
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,879 +1,15 @@
/**
* Roadmap Milestone Suggestion Generation Service
*
* Provides AI-powered milestone suggestion generation for roadmaps.
* Users can generate milestone ideas from a goal prompt and accept them
* into their roadmap.
*
* Features:
* - AI agent integration via dynamic import of @fusion/engine
* - Planning-style JSON extraction with repair
* - Input validation (goal prompt max length, count bounds)
* - Read-only endpoint (no persistence of suggestions)
* - Error mapping (validation 400, not found 404, AI/parser 500/503)
*/
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createFnAgent: any = engineCreateFnAgent;
async function initEngine(): Promise<void> {
// Engine is statically imported; nothing to do.
}
// ── Types ───────────────────────────────────────────────────────────────────
/** Input for generating milestone suggestions */
export interface GenerateMilestoneSuggestionsInput {
/** The goal prompt/description for the roadmap */
goalPrompt: string;
/** Number of milestones to generate (default 5, max 10) */
count?: number;
}
/** A suggested milestone with title and optional description */
export interface MilestoneSuggestion {
title: string;
description?: string;
}
/** System prompt for milestone suggestion generation */
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
## Guidelines
1. **Think about phases**: Break the goal into logical phases (e.g., "Foundation", "Core Features", "Polish", "Launch")
2. **Use clear titles**: Milestone titles should be concise and descriptive (e.g., "Authentication System", "User Dashboard MVP")
3. **Add context**: Include a brief description explaining what this milestone encompasses
4. **Order matters**: List milestones in the order they should be completed
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
## Output Format
Respond with ONLY a valid JSON array of milestone suggestions:
[
{
"title": "Milestone Title",
"description": "Brief description of what this milestone covers (1-2 sentences)"
},
...
]
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
// ── Constants ─────────────────────────────────────────────────────────────
/** Maximum length for goal prompt */
const MAX_GOAL_PROMPT_LENGTH = 4000;
/** Timeout for AI suggestion generation (2 minutes) */
export const SUGGESTION_TIMEOUT_MS = 120_000;
/** Default number of suggestions to generate */
const DEFAULT_SUGGESTION_COUNT = 5;
/** Maximum number of suggestions to generate */
const MAX_SUGGESTION_COUNT = 10;
/** Minimum number of suggestions to generate */
const MIN_SUGGESTION_COUNT = 1;
/** Max number of retry attempts when AI returns unparseable output */
const MAX_PARSE_RETRIES = 1;
// ── Validation ─────────────────────────────────────────────────────────────
/**
* Validate the input for generating milestone suggestions.
* Throws with a descriptive error message on validation failure.
*/
export function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput {
if (!input || typeof input !== "object") {
throw new ValidationError("Request body must be an object");
}
const { goalPrompt, count } = input as Record<string, unknown>;
// Validate goalPrompt
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
throw new ValidationError("goalPrompt is required and must be a non-empty string");
}
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
throw new ValidationError(
`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`
);
}
// Validate count (optional)
if (count !== undefined) {
if (typeof count !== "number" || !Number.isInteger(count)) {
throw new ValidationError("count must be an integer");
}
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
throw new ValidationError(
`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`
);
}
}
}
// ── JSON Extraction ────────────────────────────────────────────────────────
/**
* Extract the best JSON candidate from AI response text.
* Handles markdown-wrapped JSON, embedded JSON, and balanced brace extraction.
*/
function extractJsonCandidate(text: string): string | null {
if (!text || !text.trim()) return null;
// 1. Try markdown code blocks first (most reliable)
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (codeBlockMatch?.[1]) {
const candidate = codeBlockMatch[1].trim();
if (candidate.startsWith("[")) return candidate;
}
// 2. Find all top-level bracket-delimited arrays using balanced counting
const candidates: Array<{ start: number; end: number; text: string }> = [];
for (let i = 0; i < text.length; i++) {
if (text[i] === "[") {
let depth = 0;
let inString = false;
let escape = false;
for (let j = i; j < text.length; j++) {
const ch = text[j];
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "[") depth++;
if (ch === "]") depth--;
if (depth === 0) {
const candidate = text.slice(i, j + 1).trim();
// Only accept candidates that parse as valid JSON
try {
JSON.parse(candidate);
candidates.push({ start: i, end: j, text: candidate });
} catch {
// Not valid JSON, skip
}
break;
}
}
}
}
// Pick the largest valid candidate (most likely the full response)
if (candidates.length > 0) {
candidates.sort((a, b) => b.text.length - a.text.length);
return candidates[0].text;
}
// 3. Last resort: try the full trimmed text
const trimmed = text.trim();
if (trimmed.startsWith("[")) return trimmed;
return null;
}
/**
* Attempt to repair common JSON issues:
* - Truncated JSON (missing closing brackets/braces)
* - Trailing commas before closing brackets/braces
* - Missing closing quotes
*/
function repairJson(text: string): string {
let repaired = text;
// Fix trailing commas before } or ]
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
// Count open/close braces and brackets
let openBraces = 0;
let openBrackets = 0;
let inString = false;
let escape = false;
for (const ch of repaired) {
if (escape) { escape = false; continue; }
if (ch === "\\") { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === "{") openBraces++;
if (ch === "}") openBraces--;
if (ch === "[") openBrackets++;
if (ch === "]") openBrackets--;
}
// If we're in an unclosed string, close it
if (inString) {
repaired += '"';
}
// Re-count after potential string fix
openBraces = 0;
openBrackets = 0;
inString = false;
escape = false;
for (const ch of repaired) {
if (escape) { escape = false; continue; }
if (ch === "\\") { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === "{") openBraces++;
if (ch === "}") openBraces--;
if (ch === "[") openBrackets++;
if (ch === "]") openBrackets--;
}
// Close unclosed brackets and braces
repaired += "]".repeat(Math.max(0, openBrackets));
repaired += "}".repeat(Math.max(0, openBraces));
return repaired;
}
/**
* Parse AI response JSON with robust extraction and recovery.
*/
function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
const candidate = extractJsonCandidate(text);
if (!candidate) {
throw new ParseError("AI returned no valid JSON. Please try again.");
}
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
// Attempt repair for truncated/malformed JSON
try {
const repaired = repairJson(candidate);
parsed = JSON.parse(repaired);
} catch (repairErr) {
throw new ParseError(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
);
}
}
// Validate structure: must be an array
if (!Array.isArray(parsed)) {
throw new ParseError("AI response must be a JSON array of milestone suggestions");
}
// Validate and normalize each item - filter invalid entries per spec
const suggestions: MilestoneSuggestion[] = [];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
// Skip items that are not objects
if (!item || typeof item !== "object") {
continue;
}
const { title, description } = item as Record<string, unknown>;
// Skip entries with empty/whitespace-only titles per spec
if (typeof title !== "string" || !title.trim()) {
continue;
}
suggestions.push({
title: title.trim(),
description: typeof description === "string" && description.trim()
? description.trim()
: undefined,
});
}
// If zero valid rows remain after filtering, return 500 error per spec
if (suggestions.length === 0) {
throw new ParseError("AI returned no valid milestone suggestions");
}
return suggestions;
}
// ── Generation ─────────────────────────────────────────────────────────────
/**
* Generate milestone suggestions from a goal prompt.
*
* @param goalPrompt - The goal/description for the roadmap
* @param count - Number of suggestions to generate (default 5, max 10)
* @param rootDir - Project root directory for AI context
* @param modelProvider - Optional AI model provider override
* @param modelId - Optional AI model ID override
* @returns Array of milestone suggestions
*/
export async function generateMilestoneSuggestions(
goalPrompt: string,
count: number = DEFAULT_SUGGESTION_COUNT,
rootDir?: string,
modelProvider?: string,
modelId?: string,
): Promise<MilestoneSuggestion[]> {
// Ensure engine is loaded before using createFnAgent
await initEngine();
if (!createFnAgent) {
throw new ServiceUnavailableError("AI service is not available");
}
if (!rootDir) {
throw new Error("rootDir is required for AI-powered suggestion generation");
}
// Race AI generation against a timeout to prevent hanging requests
const result = await Promise.race([
(async () => {
let agent: ReturnType<typeof createFnAgent> | undefined;
try {
// Create AI agent with milestone suggestion system prompt
agent = await createFnAgent({
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for milestone suggestions
},
onText: () => {
// Ignore incremental text
},
});
// Send the goal prompt with count instruction
const userMessage = `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`;
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: MilestoneSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseMilestoneSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of milestone suggestions in this format: " +
'[{"title": "Milestone Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch {
// Retry prompt itself failed — give up
break;
}
}
}
}
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session (inside the raced promise so cleanup happens when this settles)
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
SUGGESTION_TIMEOUT_MS
)
),
]);
return result;
}
// ═══════════════════════════════════════════════════════════════════════════════
// FEATURE SUGGESTION GENERATION
// ═══════════════════════════════════════════════════════════════════════════════
/** Input for generating feature suggestions within a milestone */
export interface GenerateFeatureSuggestionsInput {
/** Optional prompt to guide feature generation */
prompt?: string;
/** Number of features to generate (default 5, max 10) */
count?: number;
}
/** A suggested feature with title and optional description */
export interface FeatureSuggestion {
title: string;
description?: string;
}
/** Context about the milestone for feature generation */
export interface FeatureSuggestionContext {
/** Roadmap title */
roadmapTitle: string;
/** Roadmap description (optional) */
roadmapDescription?: string;
/** Milestone title */
milestoneTitle: string;
/** Milestone description (optional) */
milestoneDescription?: string;
/** Existing feature titles in this milestone */
existingFeatureTitles: string[];
}
/** System prompt for feature suggestion generation */
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.
Your job is to suggest concrete, actionable features that belong within a specific milestone.
## Guidelines
1. **Be specific**: Feature titles should clearly describe what will be built (e.g., "User profile avatar upload", "API rate limiting")
2. **Actionable scope**: Each feature should be achievable in 1-2 weeks of focused work
3. **Add context**: Include a brief description explaining the feature's purpose and key aspects
4. **Avoid duplication**: Do NOT suggest features that are similar to existing ones already planned
5. **Order matters**: List features in the order they should be implemented within this milestone
## Context
The features should fit within the following milestone:
{MILESTONE_CONTEXT}
## Output Format
Respond with ONLY a valid JSON array of feature suggestions:
[
{
"title": "Feature Title",
"description": "Brief description of the feature (1-2 sentences)"
},
...
]
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
/** Maximum length for feature generation prompt */
const MAX_FEATURE_PROMPT_LENGTH = 2000;
/**
* Validate the input for generating feature suggestions.
* Throws with a descriptive error message on validation failure.
*/
export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput {
if (!input || typeof input !== "object") {
throw new ValidationError("Request body must be an object");
}
// Arrays are objects in JS, but not valid input
if (Array.isArray(input)) {
throw new ValidationError("Request body must be an object, not an array");
}
const { prompt, count } = input as Record<string, unknown>;
// Validate prompt (optional)
if (prompt !== undefined) {
if (typeof prompt !== "string") {
throw new ValidationError("prompt must be a string");
}
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH) {
throw new ValidationError(
`prompt exceeds maximum length of ${MAX_FEATURE_PROMPT_LENGTH} characters`
);
}
}
// Validate count (optional)
if (count !== undefined) {
if (typeof count !== "number" || !Number.isInteger(count)) {
throw new ValidationError("count must be an integer");
}
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
throw new ValidationError(
`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`
);
}
}
}
/**
* Build the milestone context string for the system prompt.
*/
function buildMilestoneContextString(context: FeatureSuggestionContext): string {
const lines: string[] = [];
lines.push(`Roadmap: ${context.roadmapTitle}`);
if (context.roadmapDescription) {
lines.push(`Description: ${context.roadmapDescription}`);
}
lines.push("");
lines.push(`Milestone: ${context.milestoneTitle}`);
if (context.milestoneDescription) {
lines.push(`Description: ${context.milestoneDescription}`);
}
if (context.existingFeatureTitles.length > 0) {
lines.push("");
lines.push("Existing features in this milestone:");
for (const title of context.existingFeatureTitles) {
lines.push(` - ${title}`);
}
}
return lines.join("\n");
}
/**
* Parse AI response for feature suggestions with robust extraction and recovery.
*/
function parseFeatureSuggestions(text: string): FeatureSuggestion[] {
const candidate = extractJsonCandidate(text);
if (!candidate) {
throw new ParseError("AI returned no valid JSON. Please try again.");
}
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
// Attempt repair for truncated/malformed JSON
try {
const repaired = repairJson(candidate);
parsed = JSON.parse(repaired);
} catch (repairErr) {
throw new ParseError(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
);
}
}
// Validate structure: must be an array
if (!Array.isArray(parsed)) {
throw new ParseError("AI response must be a JSON array of feature suggestions");
}
// Validate and normalize each item - filter invalid entries per spec
const suggestions: FeatureSuggestion[] = [];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
// Skip items that are not objects
if (!item || typeof item !== "object") {
continue;
}
const { title, description } = item as Record<string, unknown>;
// Skip entries with empty/whitespace-only titles per spec
if (typeof title !== "string" || !title.trim()) {
continue;
}
suggestions.push({
title: title.trim(),
description: typeof description === "string" && description.trim()
? description.trim()
: undefined,
});
}
// If zero valid rows remain after filtering, return 500 error per spec
if (suggestions.length === 0) {
throw new ParseError("AI returned no valid feature suggestions");
}
return suggestions;
}
/**
* Generate feature suggestions for a specific milestone.
*
* @param context - Context about the milestone (roadmap info, milestone info, existing features)
* @param count - Number of suggestions to generate (default 5, max 10)
* @param prompt - Optional additional prompt to guide generation
* @param rootDir - Project root directory for AI context
* @param modelProvider - Optional AI model provider override
* @param modelId - Optional AI model ID override
* @returns Array of feature suggestions
*/
export async function generateFeatureSuggestions(
context: FeatureSuggestionContext,
count: number = DEFAULT_SUGGESTION_COUNT,
prompt?: string,
rootDir?: string,
modelProvider?: string,
modelId?: string,
): Promise<FeatureSuggestion[]> {
// Ensure engine is loaded before using createFnAgent
await initEngine();
if (!createFnAgent) {
throw new ServiceUnavailableError("AI service is not available");
}
if (!rootDir) {
throw new Error("rootDir is required for AI-powered suggestion generation");
}
// Build the milestone context string
const milestoneContextStr = buildMilestoneContextString(context);
// Build the system prompt with dynamic context
const systemPrompt = FEATURE_SUGGESTION_SYSTEM_PROMPT.replace(
"{MILESTONE_CONTEXT}",
milestoneContextStr
);
// Race AI generation against a timeout to prevent hanging requests
const result = await Promise.race([
(async () => {
let agent: ReturnType<typeof createFnAgent> | undefined;
try {
// Create AI agent with feature suggestion system prompt
agent = await createFnAgent({
cwd: rootDir,
systemPrompt,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for feature suggestions
},
onText: () => {
// Ignore incremental text
},
});
// Build the user message
let userMessage = `Please suggest ${count} features for the milestone described above.`;
if (prompt && prompt.trim()) {
userMessage += `\n\nAdditional guidance:\n${prompt.trim()}`;
}
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: FeatureSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseFeatureSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of feature suggestions in this format: " +
'[{"title": "Feature Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch {
// Retry prompt itself failed — give up
break;
}
}
}
}
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session (inside the raced promise so cleanup happens when this settles)
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
SUGGESTION_TIMEOUT_MS
)
),
]);
return result;
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
export class ParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ParseError";
}
}
export class ServiceUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "ServiceUnavailableError";
}
}
// ── Test Helpers ───────────────────────────────────────────────────────────
/**
* Reset module state. Used for testing only.
*/
export function __resetSuggestionState(): void {
createFnAgent = engineCreateFnAgent;
}
/**
* Inject a mock createFnAgent function. Used for testing only.
*/
export function __setCreateFnAgent(mock: typeof createFnAgent): void {
createFnAgent = mock;
}
export {
FEATURE_SUGGESTION_SYSTEM_PROMPT,
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
ParseError,
ServiceUnavailableError,
SUGGESTION_TIMEOUT_MS,
ValidationError,
__resetSuggestionState,
__setCreateAiSessionFactory,
__setCreateFnAgent,
generateFeatureSuggestions,
generateMilestoneSuggestions,
validateFeatureSuggestionInput,
validateSuggestionInput,
} from "../../../plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js";

View File

@@ -2,11 +2,11 @@ import type { Router } from "express";
import type { TaskStore } from "@fusion/core";
import type { ServerOptions } from "../server.js";
import { createMissionRouter } from "../mission-routes.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { createInsightsRouter } from "../insights-routes.js";
import { createEvalsRouter } from "../evals-routes.js";
import { createResearchRouter } from "../research-routes.js";
import { createTodoRouter } from "../todo-routes.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { createDevServerRouter } from "../dev-server-routes.js";
import type { AiSessionStore } from "../ai-session-store.js";
@@ -33,11 +33,11 @@ export function registerIntegratedRouters({
createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager),
);
router.use("/roadmaps", createRoadmapRouter(store));
router.use("/insights", createInsightsRouter(store));
router.use("/evals", createEvalsRouter(store));
router.use("/research", createResearchRouter(store));
router.use("/todos", createTodoRouter(store));
router.use("/roadmaps", createRoadmapRouter(store));
}
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {