diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 35d34a2a4..f50c5acb2 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -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"); }); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 38d6185b9..b90f2c3d0 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -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) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ce6814a19..0eda2d1f1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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) ─────────────────────────── diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index f2ce71f99..cf3813ba5 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -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. diff --git a/packages/core/src/roadmap-handoff.ts b/packages/core/src/roadmap-handoff.ts deleted file mode 100644 index bdf53756d..000000000 --- a/packages/core/src/roadmap-handoff.ts +++ /dev/null @@ -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, -): 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(); - 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, -): 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; -} diff --git a/packages/core/src/roadmap-ordering.ts b/packages/core/src/roadmap-ordering.ts deleted file mode 100644 index be74ea42a..000000000 --- a/packages/core/src/roadmap-ordering.ts +++ /dev/null @@ -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(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(); - - 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(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, - }; -} diff --git a/packages/core/src/roadmap-store.ts b/packages/core/src/roadmap-store.ts deleted file mode 100644 index 6597d2312..000000000 --- a/packages/core/src/roadmap-store.ts +++ /dev/null @@ -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 { - /** - * 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; - } -} diff --git a/packages/core/src/roadmap-types.ts b/packages/core/src/roadmap-types.ts deleted file mode 100644 index 6a90936a9..000000000 --- a/packages/core/src/roadmap-types.ts +++ /dev/null @@ -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[]; -} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 1535b025a..8e076c2e1 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 { 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. diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 432e58fcf..53adf436f 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -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:*", diff --git a/packages/dashboard/src/__tests__/roadmap-routes.routes.test.ts b/packages/dashboard/src/__tests__/roadmap-routes.routes.test.ts index 31201ff06..37e16ac15 100644 --- a/packages/dashboard/src/__tests__/roadmap-routes.routes.test.ts +++ b/packages/dashboard/src/__tests__/roadmap-routes.routes.test.ts @@ -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).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"); }); }); }); diff --git a/packages/dashboard/src/roadmap-routes.ts b/packages/dashboard/src/roadmap-routes.ts index d15216bcd..dcea0dcb7 100644 --- a/packages/dashboard/src/roadmap-routes.ts +++ b/packages/dashboard/src/roadmap-routes.ts @@ -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 { + 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(); + 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, + 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 }; diff --git a/packages/dashboard/src/roadmap-suggestions.ts b/packages/dashboard/src/roadmap-suggestions.ts index 5c071579d..295140c1f 100644 --- a/packages/dashboard/src/roadmap-suggestions.ts +++ b/packages/dashboard/src/roadmap-suggestions.ts @@ -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 { - // 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; - - // 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; - - // 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 { - // 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 | 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((_, 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; - - // 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; - - // 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 { - // 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 | 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((_, 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"; diff --git a/packages/dashboard/src/routes/register-integrated-routers.ts b/packages/dashboard/src/routes/register-integrated-routers.ts index 0ba1ae80b..45425c0a3 100644 --- a/packages/dashboard/src/routes/register-integrated-routers.ts +++ b/packages/dashboard/src/routes/register-integrated-routers.ts @@ -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 { diff --git a/plugins/fusion-plugin-roadmap/package.json b/plugins/fusion-plugin-roadmap/package.json index a40db20be..47b39ba9a 100644 --- a/plugins/fusion-plugin-roadmap/package.json +++ b/plugins/fusion-plugin-roadmap/package.json @@ -10,12 +10,16 @@ "import": "./src/index.ts" }, "./server": { - "types": "./src/server/index.ts", + "types": "./src/server/index.d.ts", "import": "./src/server/index.ts" }, "./dashboard-view": { "types": "./src/dashboard-view.ts", "import": "./src/dashboard-view.ts" + }, + "./roadmap-suggestions": { + "types": "./src/roadmap-suggestions.d.ts", + "import": "./src/roadmap-suggestions.ts" } }, "scripts": { @@ -29,6 +33,7 @@ "express": "^5.1.0" }, "devDependencies": { + "@types/express": "^5.0.5", "@types/node": "^25.5.2", "typescript": "^5.7.0", "vitest": "^3.2.4" diff --git a/plugins/fusion-plugin-roadmap/src/index.ts b/plugins/fusion-plugin-roadmap/src/index.ts index 5567fd5b5..f79e90740 100644 --- a/plugins/fusion-plugin-roadmap/src/index.ts +++ b/plugins/fusion-plugin-roadmap/src/index.ts @@ -1,6 +1,6 @@ import type { Database } from "@fusion/core"; import { definePlugin } from "@fusion/plugin-sdk"; -import { createRoadmapPluginRoutes } from "./roadmap-routes.js"; +import { createRoadmapPluginRoutes } from "./routes/roadmap-routes.js"; export function ensureRoadmapSchema(db: Database): void { db.exec(` diff --git a/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts b/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts index 81732b403..6caa4c028 100644 --- a/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts +++ b/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts @@ -1,3 +1 @@ -export function createRoadmapPluginRoutes(): [] { - return []; -} +export { createRoadmapPluginRoutes, SUGGESTION_TIMEOUT_MS } from "./routes/roadmap-routes.js"; diff --git a/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts b/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts new file mode 100644 index 000000000..0f9157a2b --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts @@ -0,0 +1,15 @@ +export { + FEATURE_SUGGESTION_SYSTEM_PROMPT, + MILESTONE_SUGGESTION_SYSTEM_PROMPT, + ParseError, + ServiceUnavailableError, + SUGGESTION_TIMEOUT_MS, + ValidationError, + __resetSuggestionState, + __setCreateAiSessionFactory, + __setCreateFnAgent, + generateFeatureSuggestions, + generateMilestoneSuggestions, + validateFeatureSuggestionInput, + validateSuggestionInput, +} from "./routes/roadmap-suggestions.js"; diff --git a/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts b/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts index cb0ff5c3b..0f9157a2b 100644 --- a/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts +++ b/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts @@ -1 +1,15 @@ -export {}; +export { + FEATURE_SUGGESTION_SYSTEM_PROMPT, + MILESTONE_SUGGESTION_SYSTEM_PROMPT, + ParseError, + ServiceUnavailableError, + SUGGESTION_TIMEOUT_MS, + ValidationError, + __resetSuggestionState, + __setCreateAiSessionFactory, + __setCreateFnAgent, + generateFeatureSuggestions, + generateMilestoneSuggestions, + validateFeatureSuggestionInput, + validateSuggestionInput, +} from "./routes/roadmap-suggestions.js"; diff --git a/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts b/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts new file mode 100644 index 000000000..d0d72ca4b --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts @@ -0,0 +1,283 @@ +/** + * 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; +} +/** 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; +} +/** + * 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[]; +} +/** 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[]; +} +/** + * 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[]; +} +//# sourceMappingURL=roadmap-types.d.ts.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map b/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map new file mode 100644 index 000000000..a0ea21325 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-types.d.ts","sourceRoot":"","sources":["roadmap-types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/roadmap-types.js b/plugins/fusion-plugin-roadmap/src/roadmap-types.js new file mode 100644 index 000000000..31286c30d --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/roadmap-types.js @@ -0,0 +1,21 @@ +/** + * 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 + */ +export {}; +//# sourceMappingURL=roadmap-types.js.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map b/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map new file mode 100644 index 000000000..2cd2ede6f --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-types.js","sourceRoot":"","sources":["roadmap-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts new file mode 100644 index 000000000..63c2e273b --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts @@ -0,0 +1,4 @@ +import type { PluginRouteDefinition } from "@fusion/core"; +export declare function createRoadmapPluginRoutes(): PluginRouteDefinition[]; +export { SUGGESTION_TIMEOUT_MS }; +//# sourceMappingURL=roadmap-routes.d.ts.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map new file mode 100644 index 000000000..5811d1922 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-routes.d.ts","sourceRoot":"","sources":["roadmap-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,qBAAqB,EAAuB,MAAM,cAAc,CAAC;AAiG9F,wBAAgB,yBAAyB,IAAI,qBAAqB,EAAE,CAyRnE;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAC"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js new file mode 100644 index 000000000..c3d9cc9aa --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js @@ -0,0 +1,361 @@ +import { RoadmapStore } from "../store/roadmap-store.js"; +import { generateFeatureSuggestions, generateMilestoneSuggestions, ParseError as SuggestionParseError, ServiceUnavailableError as SuggestionServiceUnavailableError, validateFeatureSuggestionInput, validateSuggestionInput, ValidationError as SuggestionValidationError, } from "./roadmap-suggestions.js"; +const roadmapStoreCache = new WeakMap(); +function getRoadmapStore(ctx) { + const taskStoreWithRoadmaps = ctx.taskStore; + if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") { + return taskStoreWithRoadmaps.getRoadmapStore(); + } + const key = ctx.taskStore; + const cached = roadmapStoreCache.get(key); + if (cached) + return cached; + const store = new RoadmapStore(ctx.taskStore.getDatabase()); + roadmapStoreCache.set(key, store); + return store; +} +function asRequest(req) { + return req; +} +function badRequest(message) { + return { status: 400, body: { error: message } }; +} +function notFound(message) { + return { status: 404, body: { error: message } }; +} +function serverError(message) { + return { status: 500, body: { error: message } }; +} +function noContent() { + return { status: 204 }; +} +function routeHandler(handler) { + return async (req, ctx) => { + const roadmapStore = getRoadmapStore(ctx); + try { + return await handler(asRequest(req), ctx, roadmapStore); + } + catch (error) { + if (error instanceof Error && error.message.toLowerCase().includes("not found")) { + return notFound(error.message); + } + return serverError(error instanceof Error ? error.message : "Internal server error"); + } + }; +} +function validateTitle(title) { + if (!title || typeof title !== "string" || !title.trim()) { + throw new Error("title is required"); + } + if (title.length > 200) { + throw new Error("title must not exceed 200 characters"); + } + return title.trim(); +} +function validateDescription(desc) { + if (desc === undefined || desc === null) + return undefined; + if (typeof desc !== "string") { + throw new Error("description must be a string"); + } + if (desc.length > 5000) { + throw new Error("description must not exceed 5000 characters"); + } + return desc.trim() || undefined; +} +function validateStringArray(arr, fieldName) { + if (!Array.isArray(arr)) { + throw new Error(`${fieldName} must be an array`); + } + if (!arr.every((item) => typeof item === "string")) { + throw new Error(`${fieldName} must be an array of strings`); + } + return arr; +} +export function createRoadmapPluginRoutes() { + return [ + { + method: "GET", + path: "/roadmaps", + handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()), + }, + { + method: "POST", + path: "/roadmaps", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body; + try { + return { + status: 201, + body: roadmapStore.createRoadmap({ + title: validateTitle(body?.title), + description: validateDescription(body?.description), + }), + }; + } + catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId", + handler: routeHandler((req, _ctx, roadmapStore) => { + const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId); + return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`); + }), + }, + { + method: "PATCH", + path: "/roadmaps/:roadmapId", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body; + try { + return roadmapStore.updateRoadmap(req.params.roadmapId, { + title: body.title !== undefined ? validateTitle(body.title) : undefined, + description: body.description !== undefined ? validateDescription(body.description) : undefined, + }); + } + catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => { + roadmapStore.deleteRoadmap(req.params.roadmapId); + return noContent(); + }) }, + { + method: "POST", + path: "/roadmaps/:roadmapId/milestones", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body; + try { + return { + status: 201, + body: roadmapStore.createMilestone(req.params.roadmapId, { + title: validateTitle(body?.title), + description: validateDescription(body?.description), + }), + }; + } + catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "POST", + path: "/roadmaps/:roadmapId/milestones/reorder", + handler: routeHandler((req, _ctx, roadmapStore) => { + try { + const body = req.body; + roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") }); + return noContent(); + } + catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "PATCH", + path: "/roadmaps/milestones/:milestoneId", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body; + try { + return roadmapStore.updateMilestone(req.params.milestoneId, { + title: body.title !== undefined ? validateTitle(body.title) : undefined, + description: body.description !== undefined ? validateDescription(body.description) : undefined, + }); + } + catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => { + roadmapStore.deleteMilestone(req.params.milestoneId); + return noContent(); + }) }, + { + method: "POST", + path: "/roadmaps/milestones/:milestoneId/features", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body; + try { + return { + status: 201, + body: roadmapStore.createFeature(req.params.milestoneId, { + title: validateTitle(body?.title), + description: validateDescription(body?.description), + }), + }; + } + catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "POST", + path: "/roadmaps/milestones/:milestoneId/features/reorder", + handler: routeHandler((req, _ctx, roadmapStore) => { + try { + const body = req.body; + const milestone = roadmapStore.getMilestone(req.params.milestoneId); + if (!milestone) + return notFound(`Milestone ${req.params.milestoneId} not found`); + roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") }); + return noContent(); + } + catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "PATCH", + path: "/roadmaps/features/:featureId", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body; + try { + return roadmapStore.updateFeature(req.params.featureId, { + title: body.title !== undefined ? validateTitle(body.title) : undefined, + description: body.description !== undefined ? validateDescription(body.description) : undefined, + }); + } + catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => { + roadmapStore.deleteFeature(req.params.featureId); + return noContent(); + }) }, + { + method: "POST", + path: "/roadmaps/features/:featureId/move", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body; + if (!body?.targetMilestoneId) + return badRequest("targetMilestoneId is required"); + if (typeof body.targetIndex !== "number") + return badRequest("targetIndex must be a number"); + const feature = roadmapStore.getFeature(req.params.featureId); + if (!feature) + return notFound(`Feature ${req.params.featureId} not found`); + const fromMilestone = roadmapStore.getMilestone(feature.milestoneId); + if (!fromMilestone) + return notFound(`Source milestone ${feature.milestoneId} not found`); + const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId); + if (!toMilestone) + return notFound(`Target milestone ${body.targetMilestoneId} not found`); + roadmapStore.moveFeature({ + roadmapId: fromMilestone.roadmapId, + featureId: req.params.featureId, + fromMilestoneId: feature.milestoneId, + toMilestoneId: body.targetMilestoneId, + targetOrderIndex: body.targetIndex, + }); + return noContent(); + }), + }, + { + method: "POST", + path: "/roadmaps/:roadmapId/suggestions/milestones", + handler: routeHandler(async (req, ctx, roadmapStore) => { + const roadmap = roadmapStore.getRoadmap(req.params.roadmapId); + if (!roadmap) + return notFound(`Roadmap ${req.params.roadmapId} not found`); + try { + validateSuggestionInput(req.body); + } + catch (error) { + if (error instanceof SuggestionValidationError) + return badRequest(error.message); + throw error; + } + try { + const body = req.body; + const suggestions = await generateMilestoneSuggestions(body.goalPrompt, body.count, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession); + return { suggestions }; + } + catch (error) { + if (error instanceof SuggestionParseError) + return serverError(error.message); + if (error instanceof SuggestionServiceUnavailableError) { + return { status: 503, body: { error: error.message } }; + } + throw error; + } + }), + }, + { + method: "POST", + path: "/roadmaps/milestones/:milestoneId/suggestions/features", + handler: routeHandler(async (req, ctx, roadmapStore) => { + const milestone = roadmapStore.getMilestone(req.params.milestoneId); + if (!milestone) + return notFound(`Milestone ${req.params.milestoneId} not found`); + const roadmap = roadmapStore.getRoadmap(milestone.roadmapId); + if (!roadmap) + return notFound(`Roadmap ${milestone.roadmapId} not found`); + try { + validateFeatureSuggestionInput(req.body); + } + catch (error) { + if (error instanceof SuggestionValidationError) + return badRequest(error.message); + throw error; + } + try { + const body = req.body; + const suggestions = await generateFeatureSuggestions({ + roadmapTitle: roadmap.title, + roadmapDescription: roadmap.description, + milestoneTitle: milestone.title, + milestoneDescription: milestone.description, + existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title), + }, body.count, body.prompt, ctx.taskStore.getRootDir(), undefined, undefined, ctx.createAiSession); + return { suggestions }; + } + catch (error) { + if (error instanceof SuggestionParseError) + return serverError(error.message); + if (error instanceof SuggestionServiceUnavailableError) { + return { status: 503, body: { error: error.message } }; + } + throw error; + } + }), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId/export", + handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId/handoff", + handler: routeHandler((req, _ctx, roadmapStore) => ({ + mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId), + features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId), + })), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId/handoff/mission", + handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", + handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)), + }, + ]; +} +export { SUGGESTION_TIMEOUT_MS } from "./roadmap-suggestions.js"; +//# sourceMappingURL=roadmap-routes.js.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map new file mode 100644 index 000000000..9a5c0346c --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-routes.js","sourceRoot":"","sources":["roadmap-routes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts new file mode 100644 index 000000000..08a136537 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts @@ -0,0 +1,381 @@ +import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core"; +import type { Request } from "express"; +import { RoadmapStore } from "../store/roadmap-store.js"; +import { + generateFeatureSuggestions, + generateMilestoneSuggestions, + ParseError as SuggestionParseError, + ServiceUnavailableError as SuggestionServiceUnavailableError, + validateFeatureSuggestionInput, + validateSuggestionInput, + ValidationError as SuggestionValidationError, +} from "./roadmap-suggestions.js"; + +const roadmapStoreCache = new WeakMap(); + +function getRoadmapStore(ctx: PluginContext): RoadmapStore { + const taskStoreWithRoadmaps = ctx.taskStore as PluginContext["taskStore"] & { + getRoadmapStore?: () => RoadmapStore; + }; + + if (typeof taskStoreWithRoadmaps.getRoadmapStore === "function") { + return taskStoreWithRoadmaps.getRoadmapStore(); + } + + const key = ctx.taskStore as object; + const cached = roadmapStoreCache.get(key); + if (cached) return cached; + const store = new RoadmapStore(ctx.taskStore.getDatabase()); + roadmapStoreCache.set(key, store); + return store; +} + +function asRequest(req: unknown): Request { + return req as Request; +} + +function badRequest(message: string): PluginRouteResponse { + return { status: 400, body: { error: message } }; +} + +function notFound(message: string): PluginRouteResponse { + return { status: 404, body: { error: message } }; +} + +function serverError(message: string): PluginRouteResponse { + return { status: 500, body: { error: message } }; +} + +function noContent(): PluginRouteResponse { + return { status: 204 }; +} + +function routeHandler(handler: (req: Request, ctx: PluginContext, roadmapStore: RoadmapStore) => Promise | T | PluginRouteResponse) { + return async (req: unknown, ctx: PluginContext): Promise => { + const roadmapStore = getRoadmapStore(ctx); + try { + return await handler(asRequest(req), ctx, roadmapStore); + } catch (error) { + if (error instanceof Error && error.message.toLowerCase().includes("not found")) { + return notFound(error.message); + } + return serverError(error instanceof Error ? error.message : "Internal server error"); + } + }; +} + +function validateTitle(title: unknown): string { + if (!title || typeof title !== "string" || !title.trim()) { + throw new Error("title is required"); + } + if (title.length > 200) { + throw new Error("title must not exceed 200 characters"); + } + return title.trim(); +} + +function validateDescription(desc: unknown): string | undefined { + if (desc === undefined || desc === null) return undefined; + if (typeof desc !== "string") { + throw new Error("description must be a string"); + } + if (desc.length > 5000) { + throw new Error("description must not exceed 5000 characters"); + } + return desc.trim() || undefined; +} + +function validateStringArray(arr: unknown, fieldName: string): string[] { + if (!Array.isArray(arr)) { + throw new Error(`${fieldName} must be an array`); + } + if (!arr.every((item) => typeof item === "string")) { + throw new Error(`${fieldName} must be an array of strings`); + } + return arr; +} + +export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { + return [ + { + method: "GET", + path: "/roadmaps", + handler: routeHandler((_req, _ctx, roadmapStore) => roadmapStore.listRoadmaps()), + }, + { + method: "POST", + path: "/roadmaps", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body as { title: string; description?: string }; + try { + return { + status: 201, + body: roadmapStore.createRoadmap({ + title: validateTitle(body?.title), + description: validateDescription(body?.description), + }), + }; + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId", + handler: routeHandler((req, _ctx, roadmapStore) => { + const roadmap = roadmapStore.getRoadmapWithHierarchy(req.params.roadmapId); + return roadmap ? roadmap : notFound(`Roadmap ${req.params.roadmapId} not found`); + }), + }, + { + method: "PATCH", + path: "/roadmaps/:roadmapId", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body as { title?: string; description?: string }; + try { + return roadmapStore.updateRoadmap(req.params.roadmapId, { + title: body.title !== undefined ? validateTitle(body.title) : undefined, + description: body.description !== undefined ? validateDescription(body.description) : undefined, + }); + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => { + roadmapStore.deleteRoadmap(req.params.roadmapId); + return noContent(); + }) }, + { + method: "POST", + path: "/roadmaps/:roadmapId/milestones", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body as { title: string; description?: string }; + try { + return { + status: 201, + body: roadmapStore.createMilestone(req.params.roadmapId, { + title: validateTitle(body?.title), + description: validateDescription(body?.description), + }), + }; + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "POST", + path: "/roadmaps/:roadmapId/milestones/reorder", + handler: routeHandler((req, _ctx, roadmapStore) => { + try { + const body = req.body as { orderedMilestoneIds: string[] }; + roadmapStore.reorderMilestones({ roadmapId: req.params.roadmapId, orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") }); + return noContent(); + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "PATCH", + path: "/roadmaps/milestones/:milestoneId", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body as { title?: string; description?: string }; + try { + return roadmapStore.updateMilestone(req.params.milestoneId, { + title: body.title !== undefined ? validateTitle(body.title) : undefined, + description: body.description !== undefined ? validateDescription(body.description) : undefined, + }); + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => { + roadmapStore.deleteMilestone(req.params.milestoneId); + return noContent(); + }) }, + { + method: "POST", + path: "/roadmaps/milestones/:milestoneId/features", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body as { title: string; description?: string }; + try { + return { + status: 201, + body: roadmapStore.createFeature(req.params.milestoneId, { + title: validateTitle(body?.title), + description: validateDescription(body?.description), + }), + }; + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "POST", + path: "/roadmaps/milestones/:milestoneId/features/reorder", + handler: routeHandler((req, _ctx, roadmapStore) => { + try { + const body = req.body as { orderedFeatureIds: string[] }; + const milestone = roadmapStore.getMilestone(req.params.milestoneId); + if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`); + roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: req.params.milestoneId, orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") }); + return noContent(); + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { + method: "PATCH", + path: "/roadmaps/features/:featureId", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body as { title?: string; description?: string }; + try { + return roadmapStore.updateFeature(req.params.featureId, { + title: body.title !== undefined ? validateTitle(body.title) : undefined, + description: body.description !== undefined ? validateDescription(body.description) : undefined, + }); + } catch (error) { + return badRequest(error instanceof Error ? error.message : "Invalid input"); + } + }), + }, + { method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => { + roadmapStore.deleteFeature(req.params.featureId); + return noContent(); + }) }, + { + method: "POST", + path: "/roadmaps/features/:featureId/move", + handler: routeHandler((req, _ctx, roadmapStore) => { + const body = req.body as { targetMilestoneId: string; targetIndex: number }; + if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required"); + if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number"); + + const feature = roadmapStore.getFeature(req.params.featureId); + if (!feature) return notFound(`Feature ${req.params.featureId} not found`); + const fromMilestone = roadmapStore.getMilestone(feature.milestoneId); + if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`); + const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId); + if (!toMilestone) return notFound(`Target milestone ${body.targetMilestoneId} not found`); + + roadmapStore.moveFeature({ + roadmapId: fromMilestone.roadmapId, + featureId: req.params.featureId, + fromMilestoneId: feature.milestoneId, + toMilestoneId: body.targetMilestoneId, + targetOrderIndex: body.targetIndex, + }); + + return noContent(); + }), + }, + { + method: "POST", + path: "/roadmaps/:roadmapId/suggestions/milestones", + handler: routeHandler(async (req, ctx, roadmapStore) => { + const roadmap = roadmapStore.getRoadmap(req.params.roadmapId); + if (!roadmap) return notFound(`Roadmap ${req.params.roadmapId} not found`); + + try { + validateSuggestionInput(req.body); + } catch (error) { + if (error instanceof SuggestionValidationError) return badRequest(error.message); + throw error; + } + + try { + const body = req.body as { goalPrompt: string; count?: number }; + const suggestions = await generateMilestoneSuggestions( + body.goalPrompt, + body.count, + ctx.taskStore.getRootDir(), + undefined, + undefined, + ctx.createAiSession, + ); + return { suggestions }; + } catch (error) { + if (error instanceof SuggestionParseError) return serverError(error.message); + if (error instanceof SuggestionServiceUnavailableError) { + return { status: 503, body: { error: error.message } }; + } + throw error; + } + }), + }, + { + method: "POST", + path: "/roadmaps/milestones/:milestoneId/suggestions/features", + handler: routeHandler(async (req, ctx, roadmapStore) => { + const milestone = roadmapStore.getMilestone(req.params.milestoneId); + if (!milestone) return notFound(`Milestone ${req.params.milestoneId} not found`); + const roadmap = roadmapStore.getRoadmap(milestone.roadmapId); + if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`); + + try { + validateFeatureSuggestionInput(req.body); + } catch (error) { + if (error instanceof SuggestionValidationError) return badRequest(error.message); + throw error; + } + + try { + const body = req.body as { prompt?: string; count?: number }; + const suggestions = await generateFeatureSuggestions( + { + roadmapTitle: roadmap.title, + roadmapDescription: roadmap.description, + milestoneTitle: milestone.title, + milestoneDescription: milestone.description, + existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title), + }, + body.count, + body.prompt, + ctx.taskStore.getRootDir(), + undefined, + undefined, + ctx.createAiSession, + ); + return { suggestions }; + } catch (error) { + if (error instanceof SuggestionParseError) return serverError(error.message); + if (error instanceof SuggestionServiceUnavailableError) { + return { status: 503, body: { error: error.message } }; + } + throw error; + } + }), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId/export", + handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapExport(req.params.roadmapId)), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId/handoff", + handler: routeHandler((req, _ctx, roadmapStore) => ({ + mission: roadmapStore.getMissionPlanningHandoff(req.params.roadmapId), + features: roadmapStore.listFeatureTaskPlanningHandoffs(req.params.roadmapId), + })), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId/handoff/mission", + handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getMissionPlanningHandoff(req.params.roadmapId)), + }, + { + method: "GET", + path: "/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", + handler: routeHandler((req, _ctx, roadmapStore) => roadmapStore.getRoadmapFeatureHandoff(req.params.roadmapId, req.params.milestoneId, req.params.featureId)), + }, + ]; +} + +export { SUGGESTION_TIMEOUT_MS }; diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts new file mode 100644 index 000000000..3470d3b14 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts @@ -0,0 +1,68 @@ +import type { CreateAiSessionFactory } from "@fusion/core"; +type LegacyCreateFnAgent = (options: { + cwd: string; + systemPrompt: string; + tools?: "coding" | "readonly"; + defaultProvider?: string; + defaultModelId?: string; + onThinking?: () => void; + onText?: () => void; +}) => Promise<{ + session: { + prompt(text: string): Promise; + state: { + messages: Array<{ + role: string; + content?: string | Array<{ + type: string; + text: string; + }>; + }>; + }; + dispose?: () => void; + }; +}>; +export interface GenerateMilestoneSuggestionsInput { + goalPrompt: string; + count?: number; +} +export interface MilestoneSuggestion { + title: string; + description?: string; +} +export declare const MILESTONE_SUGGESTION_SYSTEM_PROMPT = "You are a milestone planning assistant for a product roadmap system.\n\nYour job is to suggest logical milestones that would help achieve a user's roadmap goal.\n\n## Guidelines\n\n1. **Think about phases**: Break the goal into logical phases\n2. **Use clear titles**: Milestone titles should be concise and descriptive\n3. **Add context**: Include a brief description explaining what this milestone encompasses\n4. **Order matters**: List milestones in the order they should be completed\n5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks\n\n## Output Format\n\nRespond with ONLY a valid JSON array of milestone suggestions."; +export declare const SUGGESTION_TIMEOUT_MS = 120000; +export declare function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput; +export declare function generateMilestoneSuggestions(goalPrompt: string, count?: number, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise; +export interface GenerateFeatureSuggestionsInput { + prompt?: string; + count?: number; +} +export interface FeatureSuggestion { + title: string; + description?: string; +} +export interface FeatureSuggestionContext { + roadmapTitle: string; + roadmapDescription?: string; + milestoneTitle: string; + milestoneDescription?: string; + existingFeatureTitles: string[]; +} +export declare const FEATURE_SUGGESTION_SYSTEM_PROMPT = "You are a feature planning assistant for a product roadmap system."; +export declare function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput; +export declare function generateFeatureSuggestions(context: FeatureSuggestionContext, count?: number, prompt?: string, rootDir?: string, modelProvider?: string, modelId?: string, createAiSession?: CreateAiSessionFactory): Promise; +export declare class ValidationError extends Error { + constructor(message: string); +} +export declare class ParseError extends Error { + constructor(message: string); +} +export declare class ServiceUnavailableError extends Error { + constructor(message: string); +} +export declare function __resetSuggestionState(): void; +export declare function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void; +export declare function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void; +export {}; +//# sourceMappingURL=roadmap-suggestions.d.ts.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map new file mode 100644 index 000000000..7594f0dc5 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-suggestions.d.ts","sourceRoot":"","sources":["roadmap-suggestions.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js new file mode 100644 index 000000000..7f827937a --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js @@ -0,0 +1,300 @@ +let injectedCreateAiSession; +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 +2. **Use clear titles**: Milestone titles should be concise and descriptive +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.`; +const MAX_GOAL_PROMPT_LENGTH = 4000; +export const SUGGESTION_TIMEOUT_MS = 120_000; +const DEFAULT_SUGGESTION_COUNT = 5; +const MAX_SUGGESTION_COUNT = 10; +const MIN_SUGGESTION_COUNT = 1; +const MAX_PARSE_RETRIES = 1; +export function validateSuggestionInput(input) { + if (!input || typeof input !== "object") { + throw new ValidationError("Request body must be an object"); + } + const { goalPrompt, count } = input; + 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`); + } + 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}`); + } + } +} +function extractJsonCandidate(text) { + if (!text || !text.trim()) + return null; + const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + const source = codeBlockMatch?.[1]?.trim() || text.trim(); + const startIndex = source.indexOf("["); + if (startIndex < 0) + return null; + let depth = 0; + let inString = false; + let escaped = false; + for (let index = startIndex; index < source.length; index++) { + const char = source[index]; + if (inString) { + if (escaped) { + escaped = false; + } + else if (char === "\\") { + escaped = true; + } + else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === "[") + depth++; + if (char === "]") { + depth--; + if (depth === 0) { + return source.slice(startIndex, index + 1).trim(); + } + } + } + return source.slice(startIndex).trim(); +} +function repairJson(text) { + let repaired = text.replace(/,\s*([}\]])/g, "$1"); + let depthBraces = 0; + let depthBrackets = 0; + for (const ch of repaired) { + if (ch === "{") + depthBraces++; + if (ch === "}") + depthBraces--; + if (ch === "[") + depthBrackets++; + if (ch === "]") + depthBrackets--; + } + repaired += "]".repeat(Math.max(0, depthBrackets)); + repaired += "}".repeat(Math.max(0, depthBraces)); + return repaired; +} +function parseMilestoneSuggestions(text) { + const candidate = extractJsonCandidate(text); + if (!candidate) + throw new ParseError("AI returned no valid JSON. Please try again."); + let parsed; + try { + parsed = JSON.parse(candidate); + } + catch { + parsed = JSON.parse(repairJson(candidate)); + } + if (!Array.isArray(parsed)) { + throw new ParseError("AI response must be a JSON array of milestone suggestions"); + } + const suggestions = []; + for (const item of parsed) { + if (!item || typeof item !== "object") + continue; + const row = item; + if (typeof row.title !== "string" || !row.title.trim()) + continue; + suggestions.push({ + title: row.title.trim(), + description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined, + }); + } + if (suggestions.length === 0) + throw new ParseError("AI returned no valid milestone suggestions"); + return suggestions; +} +function pickFactory(explicit) { + return explicit ?? injectedCreateAiSession; +} +async function runPrompt(createAiSession, options, prompt) { + const agent = await createAiSession(options); + await agent.session.prompt(prompt); + const lastMessage = agent.session.state.messages + .filter((m) => m.role === "assistant") + .pop(); + let text = ""; + if (lastMessage?.content) { + if (typeof lastMessage.content === "string") + text = lastMessage.content; + else { + text = lastMessage.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join(""); + } + } + return { text, dispose: agent.session.dispose }; +} +export async function generateMilestoneSuggestions(goalPrompt, count = DEFAULT_SUGGESTION_COUNT, rootDir, modelProvider, modelId, createAiSession) { + const factory = pickFactory(createAiSession); + if (!factory) + throw new ServiceUnavailableError("AI service is not available"); + if (!rootDir) + throw new Error("rootDir is required for AI-powered suggestion generation"); + const result = await Promise.race([ + (async () => { + let dispose; + try { + let response = await runPrompt(factory, { + cwd: rootDir, + systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT, + tools: "readonly", + ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), + }, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`); + dispose = response.dispose; + let suggestions; + let lastError; + for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) { + try { + suggestions = parseMilestoneSuggestions(response.text); + break; + } + catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (attempt === MAX_PARSE_RETRIES) + break; + response = await runPrompt(factory, { + cwd: rootDir, + systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT, + tools: "readonly", + ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), + }, "Your previous response could not be parsed as JSON. Respond with only a JSON array."); + dispose = response.dispose; + } + } + if (!suggestions) { + throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`); + } + return suggestions.slice(0, count); + } + finally { + dispose?.(); + } + })(), + new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)), + ]); + return result; +} +export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`; +const MAX_FEATURE_PROMPT_LENGTH = 2000; +export function validateFeatureSuggestionInput(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) + throw new ValidationError("Request body must be an object"); + const { prompt, count } = input; + 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`); + } + 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}`); + } + } +} +function buildMilestoneContextString(context) { + const lines = []; + lines.push(`Roadmap: ${context.roadmapTitle}`); + if (context.roadmapDescription) + lines.push(`Description: ${context.roadmapDescription}`); + lines.push("", `Milestone: ${context.milestoneTitle}`); + if (context.milestoneDescription) + lines.push(`Description: ${context.milestoneDescription}`); + if (context.existingFeatureTitles.length > 0) { + lines.push("", "Existing features in this milestone:"); + for (const title of context.existingFeatureTitles) + lines.push(` - ${title}`); + } + return lines.join("\n"); +} +function parseFeatureSuggestions(text) { + return parseMilestoneSuggestions(text); +} +export async function generateFeatureSuggestions(context, count = DEFAULT_SUGGESTION_COUNT, prompt, rootDir, modelProvider, modelId, createAiSession) { + const factory = pickFactory(createAiSession); + if (!factory) + throw new ServiceUnavailableError("AI service is not available"); + if (!rootDir) + throw new Error("rootDir is required for AI-powered suggestion generation"); + const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`; + const userMessage = prompt?.trim() + ? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}` + : `Please suggest ${count} features for the milestone described above.`; + const result = await Promise.race([ + (async () => { + const { text, dispose } = await runPrompt(factory, { + cwd: rootDir, + systemPrompt, + tools: "readonly", + ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), + }, userMessage); + try { + return parseFeatureSuggestions(text).slice(0, count); + } + finally { + dispose?.(); + } + })(), + new Promise((_, reject) => setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS)), + ]); + return result; +} +export class ValidationError extends Error { + constructor(message) { + super(message); + this.name = "ValidationError"; + } +} +export class ParseError extends Error { + constructor(message) { + super(message); + this.name = "ParseError"; + } +} +export class ServiceUnavailableError extends Error { + constructor(message) { + super(message); + this.name = "ServiceUnavailableError"; + } +} +export function __resetSuggestionState() { + injectedCreateAiSession = undefined; +} +export function __setCreateFnAgent(mock) { + if (!mock) { + injectedCreateAiSession = undefined; + return; + } + injectedCreateAiSession = async (options) => mock(options); +} +export function __setCreateAiSessionFactory(mock) { + injectedCreateAiSession = mock; +} +//# sourceMappingURL=roadmap-suggestions.js.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map new file mode 100644 index 000000000..1277913ee --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-suggestions.js","sourceRoot":"","sources":["roadmap-suggestions.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts new file mode 100644 index 000000000..a731e92bc --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts @@ -0,0 +1,381 @@ +import type { CreateAiSessionFactory } from "@fusion/core"; + +type LegacyCreateFnAgent = (options: { + cwd: string; + systemPrompt: string; + tools?: "coding" | "readonly"; + defaultProvider?: string; + defaultModelId?: string; + onThinking?: () => void; + onText?: () => void; +}) => Promise<{ + session: { + prompt(text: string): Promise; + state: { messages: Array<{ role: string; content?: string | Array<{ type: string; text: string }> }> }; + dispose?: () => void; + }; +}>; + +let injectedCreateAiSession: CreateAiSessionFactory | undefined; + +export interface GenerateMilestoneSuggestionsInput { + goalPrompt: string; + count?: number; +} + +export interface MilestoneSuggestion { + title: string; + description?: string; +} + +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 +2. **Use clear titles**: Milestone titles should be concise and descriptive +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.`; + +const MAX_GOAL_PROMPT_LENGTH = 4000; +export const SUGGESTION_TIMEOUT_MS = 120_000; +const DEFAULT_SUGGESTION_COUNT = 5; +const MAX_SUGGESTION_COUNT = 10; +const MIN_SUGGESTION_COUNT = 1; +const MAX_PARSE_RETRIES = 1; + +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; + 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`); + } + 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}`); + } + } +} + +function extractJsonCandidate(text: string): string | null { + if (!text || !text.trim()) return null; + const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + const source = codeBlockMatch?.[1]?.trim() || text.trim(); + + const startIndex = source.indexOf("["); + if (startIndex < 0) return null; + + let depth = 0; + let inString = false; + let escaped = false; + for (let index = startIndex; index < source.length; index++) { + const char = source[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + if (char === "[") depth++; + if (char === "]") { + depth--; + if (depth === 0) { + return source.slice(startIndex, index + 1).trim(); + } + } + } + + return source.slice(startIndex).trim(); +} + +function repairJson(text: string): string { + let repaired = text.replace(/,\s*([}\]])/g, "$1"); + let depthBraces = 0; + let depthBrackets = 0; + for (const ch of repaired) { + if (ch === "{") depthBraces++; + if (ch === "}") depthBraces--; + if (ch === "[") depthBrackets++; + if (ch === "]") depthBrackets--; + } + repaired += "]".repeat(Math.max(0, depthBrackets)); + repaired += "}".repeat(Math.max(0, depthBraces)); + return repaired; +} + +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 { + parsed = JSON.parse(repairJson(candidate)); + } + + if (!Array.isArray(parsed)) { + throw new ParseError("AI response must be a JSON array of milestone suggestions"); + } + + const suggestions: MilestoneSuggestion[] = []; + for (const item of parsed) { + if (!item || typeof item !== "object") continue; + const row = item as Record; + if (typeof row.title !== "string" || !row.title.trim()) continue; + suggestions.push({ + title: row.title.trim(), + description: typeof row.description === "string" && row.description.trim() ? row.description.trim() : undefined, + }); + } + + if (suggestions.length === 0) throw new ParseError("AI returned no valid milestone suggestions"); + return suggestions; +} + +interface AgentMessage { + role: string; + content?: string | Array<{ type: string; text: string }>; +} + +function pickFactory(explicit?: CreateAiSessionFactory): CreateAiSessionFactory | undefined { + return explicit ?? injectedCreateAiSession; +} + +async function runPrompt( + createAiSession: CreateAiSessionFactory, + options: Parameters[0], + prompt: string, +): Promise<{ text: string; dispose?: () => void }> { + const agent = await createAiSession(options); + await agent.session.prompt(prompt); + const lastMessage = (agent.session.state.messages as AgentMessage[]) + .filter((m) => m.role === "assistant") + .pop(); + + let text = ""; + if (lastMessage?.content) { + if (typeof lastMessage.content === "string") text = lastMessage.content; + else { + text = lastMessage.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } + } + + return { text, dispose: (agent.session as { dispose?: () => void }).dispose }; +} + +export async function generateMilestoneSuggestions( + goalPrompt: string, + count: number = DEFAULT_SUGGESTION_COUNT, + rootDir?: string, + modelProvider?: string, + modelId?: string, + createAiSession?: CreateAiSessionFactory, +): Promise { + const factory = pickFactory(createAiSession); + if (!factory) throw new ServiceUnavailableError("AI service is not available"); + if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation"); + + const result = await Promise.race([ + (async () => { + let dispose: (() => void) | undefined; + try { + let response = await runPrompt(factory, { + cwd: rootDir, + systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT, + tools: "readonly", + ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), + }, `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`); + dispose = response.dispose; + + let suggestions: MilestoneSuggestion[] | undefined; + let lastError: Error | undefined; + for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) { + try { + suggestions = parseMilestoneSuggestions(response.text); + break; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (attempt === MAX_PARSE_RETRIES) break; + response = await runPrompt(factory, { + cwd: rootDir, + systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT, + tools: "readonly", + ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), + }, "Your previous response could not be parsed as JSON. Respond with only a JSON array."); + dispose = response.dispose; + } + } + + if (!suggestions) { + throw new ParseError(`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message ?? "Unknown error"}`); + } + + return suggestions.slice(0, count); + } finally { + dispose?.(); + } + })(), + new Promise((_, reject) => + setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS), + ), + ]); + + return result; +} + +export interface GenerateFeatureSuggestionsInput { + prompt?: string; + count?: number; +} + +export interface FeatureSuggestion { + title: string; + description?: string; +} + +export interface FeatureSuggestionContext { + roadmapTitle: string; + roadmapDescription?: string; + milestoneTitle: string; + milestoneDescription?: string; + existingFeatureTitles: string[]; +} + +export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.`; + +const MAX_FEATURE_PROMPT_LENGTH = 2000; + +export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput { + if (!input || typeof input !== "object" || Array.isArray(input)) throw new ValidationError("Request body must be an object"); + const { prompt, count } = input as Record; + 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`); + } + 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}`); + } + } +} + +function buildMilestoneContextString(context: FeatureSuggestionContext): string { + const lines: string[] = []; + lines.push(`Roadmap: ${context.roadmapTitle}`); + if (context.roadmapDescription) lines.push(`Description: ${context.roadmapDescription}`); + lines.push("", `Milestone: ${context.milestoneTitle}`); + if (context.milestoneDescription) lines.push(`Description: ${context.milestoneDescription}`); + if (context.existingFeatureTitles.length > 0) { + lines.push("", "Existing features in this milestone:"); + for (const title of context.existingFeatureTitles) lines.push(` - ${title}`); + } + return lines.join("\n"); +} + +function parseFeatureSuggestions(text: string): FeatureSuggestion[] { + return parseMilestoneSuggestions(text); +} + +export async function generateFeatureSuggestions( + context: FeatureSuggestionContext, + count: number = DEFAULT_SUGGESTION_COUNT, + prompt?: string, + rootDir?: string, + modelProvider?: string, + modelId?: string, + createAiSession?: CreateAiSessionFactory, +): Promise { + const factory = pickFactory(createAiSession); + if (!factory) throw new ServiceUnavailableError("AI service is not available"); + if (!rootDir) throw new Error("rootDir is required for AI-powered suggestion generation"); + + const systemPrompt = `${FEATURE_SUGGESTION_SYSTEM_PROMPT}\n\n${buildMilestoneContextString(context)}`; + const userMessage = prompt?.trim() + ? `Please suggest ${count} features for the milestone described above.\n\nAdditional guidance:\n${prompt.trim()}` + : `Please suggest ${count} features for the milestone described above.`; + + const result = await Promise.race([ + (async () => { + const { text, dispose } = await runPrompt(factory, { + cwd: rootDir, + systemPrompt, + tools: "readonly", + ...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}), + }, userMessage); + try { + return parseFeatureSuggestions(text).slice(0, count); + } finally { + dispose?.(); + } + })(), + new Promise((_, reject) => + setTimeout(() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")), SUGGESTION_TIMEOUT_MS), + ), + ]); + + return result; +} + +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"; + } +} + +export function __resetSuggestionState(): void { + injectedCreateAiSession = undefined; +} + +export function __setCreateFnAgent(mock: LegacyCreateFnAgent | undefined): void { + if (!mock) { + injectedCreateAiSession = undefined; + return; + } + injectedCreateAiSession = async (options) => mock(options); +} + +export function __setCreateAiSessionFactory(mock: CreateAiSessionFactory | undefined): void { + injectedCreateAiSession = mock; +} diff --git a/plugins/fusion-plugin-roadmap/src/server/index.d.ts b/plugins/fusion-plugin-roadmap/src/server/index.d.ts new file mode 100644 index 000000000..f14b5ec89 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/server/index.d.ts @@ -0,0 +1 @@ +export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js"; diff --git a/plugins/fusion-plugin-roadmap/src/server/index.ts b/plugins/fusion-plugin-roadmap/src/server/index.ts index cd3185194..f14b5ec89 100644 --- a/plugins/fusion-plugin-roadmap/src/server/index.ts +++ b/plugins/fusion-plugin-roadmap/src/server/index.ts @@ -1 +1 @@ -export { createRoadmapPluginRoutes } from "../roadmap-routes.js"; +export { createRoadmapPluginRoutes } from "../routes/roadmap-routes.js"; diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts b/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts new file mode 100644 index 000000000..0cac06242 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts @@ -0,0 +1,37 @@ +import type { RoadmapFeature, RoadmapFeatureMoveInput, RoadmapFeatureMoveResult, RoadmapFeatureReorderInput, RoadmapMilestone, RoadmapMilestoneReorderInput } from "../roadmap-types.js"; +/** + * Repairs milestone ordering for a single roadmap scope. + * + * Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`. + */ +export declare function normalizeRoadmapMilestoneOrder(milestones: readonly RoadmapMilestone[]): RoadmapMilestone[]; +/** + * 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 declare function applyRoadmapMilestoneReorder(milestones: readonly RoadmapMilestone[], input: RoadmapMilestoneReorderInput): RoadmapMilestone[]; +/** + * Repairs feature ordering for a single milestone scope. + * + * Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`. + */ +export declare function normalizeRoadmapFeatureOrder(features: readonly RoadmapFeature[]): RoadmapFeature[]; +/** + * 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 declare function applyRoadmapFeatureReorder(features: readonly RoadmapFeature[], input: RoadmapFeatureReorderInput): RoadmapFeature[]; +/** + * 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 declare function moveRoadmapFeature(features: readonly RoadmapFeature[], input: RoadmapFeatureMoveInput): RoadmapFeatureMoveResult; +//# sourceMappingURL=roadmap-ordering.d.ts.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map b/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map new file mode 100644 index 000000000..5023105ff --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-ordering.d.ts","sourceRoot":"","sources":["roadmap-ordering.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,uBAAuB,EACvB,wBAAwB,EACxB,0BAA0B,EAC1B,gBAAgB,EAChB,4BAA4B,EAC7B,MAAM,qBAAqB,CAAC;AA8I7B;;;;GAIG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,SAAS,gBAAgB,EAAE,GACtC,gBAAgB,EAAE,CAUpB;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,UAAU,EAAE,SAAS,gBAAgB,EAAE,EACvC,KAAK,EAAE,4BAA4B,GAClC,gBAAgB,EAAE,CAWpB;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,SAAS,cAAc,EAAE,GAClC,cAAc,EAAE,CAUlB;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,KAAK,EAAE,0BAA0B,GAChC,cAAc,EAAE,CAWlB;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,KAAK,EAAE,uBAAuB,GAC7B,wBAAwB,CAoE1B"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js b/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js new file mode 100644 index 000000000..6ec829e44 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js @@ -0,0 +1,188 @@ +function compareOrderedEntities(a, b) { + 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, length) { + 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, roadmapId) { + for (const milestone of milestones) { + if (milestone.roadmapId !== roadmapId) { + throw new Error(`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`); + } + } +} +function assertScopedMilestoneFeatures(features, milestoneId) { + for (const feature of features) { + if (feature.milestoneId !== milestoneId) { + throw new Error(`Feature ${feature.id} does not belong to milestone ${milestoneId}`); + } + } +} +function assertScopedMoveFeatures(features, fromMilestoneId, toMilestoneId) { + 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, actualIds, orderedIds) { + const requestedIds = new Set(); + 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) { + return value.charAt(0).toUpperCase() + value.slice(1); +} +function assignContiguousOrder(items) { + 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) { + 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, input) { + 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) { + 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, input) { + 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, input) { + 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, + }; +} +//# sourceMappingURL=roadmap-ordering.js.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map b/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map new file mode 100644 index 000000000..29fae9574 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-ordering.js","sourceRoot":"","sources":["roadmap-ordering.ts"],"names":[],"mappings":"AA4BA,SAAS,sBAAsB,CAA0B,CAAI,EAAE,CAAI;IACjE,IAAI,CAAC,CAAC,UAAU,KAAK,CAAC,CAAC,UAAU,EAAE,CAAC;QAClC,OAAO,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,CAAC;IACrC,CAAC;IAED,IAAI,CAAC,CAAC,SAAS,KAAK,CAAC,CAAC,SAAS,EAAE,CAAC;QAChC,OAAO,CAAC,CAAC,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAChD,CAAC;IAED,OAAO,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;AAClC,CAAC;AAED,SAAS,mBAAmB,CAAC,WAAmB,EAAE,MAAc;IAC9D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC;QAClC,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IAC3C,IAAI,UAAU,GAAG,CAAC,EAAE,CAAC;QACnB,OAAO,CAAC,CAAC;IACX,CAAC;IACD,IAAI,UAAU,GAAG,MAAM,EAAE,CAAC;QACxB,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,SAAS,6BAA6B,CACpC,UAAuC,EACvC,SAAiB;IAEjB,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,SAAS,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;YACtC,MAAM,IAAI,KAAK,CACb,aAAa,SAAS,CAAC,EAAE,+BAA+B,SAAS,EAAE,CACpE,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,6BAA6B,CACpC,QAAmC,EACnC,WAAmB;IAEnB,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,OAAO,CAAC,WAAW,KAAK,WAAW,EAAE,CAAC;YACxC,MAAM,IAAI,KAAK,CACb,WAAW,OAAO,CAAC,EAAE,iCAAiC,WAAW,EAAE,CACpE,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,wBAAwB,CAC/B,QAAmC,EACnC,eAAuB,EACvB,aAAqB;IAErB,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAC,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC,CAAC;IAEpE,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC;YAChD,MAAM,IAAI,KAAK,CACb,WAAW,OAAO,CAAC,EAAE,6CAA6C,eAAe,MAAM,aAAa,GAAG,CACxG,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CACvB,WAAmB,EACnB,SAA4B,EAC5B,UAA6B;IAE7B,MAAM,YAAY,GAAG,IAAI,GAAG,EAAU,CAAC;IAEvC,KAAK,MAAM,EAAE,IAAI,UAAU,EAAE,CAAC;QAC5B,IAAI,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CAAC,aAAa,WAAW,2BAA2B,EAAE,EAAE,CAAC,CAAC;QAC3E,CAAC;QACD,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACvB,CAAC;IAED,IAAI,SAAS,CAAC,MAAM,KAAK,UAAU,CAAC,MAAM,EAAE,CAAC;QAC3C,MAAM,IAAI,KAAK,CACb,YAAY,SAAS,CAAC,MAAM,IAAI,WAAW,qBAAqB,UAAU,CAAC,MAAM,EAAE,CACpF,CAAC;IACJ,CAAC;IAED,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,SAAS,CAAC,CAAC;IAEvC,KAAK,MAAM,EAAE,IAAI,UAAU,EAAE,CAAC;QAC5B,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CAAC,GAAG,UAAU,CAAC,WAAW,CAAC,IAAI,EAAE,2BAA2B,CAAC,CAAC;QAC/E,CAAC;IACH,CAAC;IAED,KAAK,MAAM,EAAE,IAAI,SAAS,EAAE,CAAC;QAC3B,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YAC1B,MAAM,IAAI,KAAK,CAAC,WAAW,WAAW,2BAA2B,EAAE,EAAE,CAAC,CAAC;QACzE,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AACxD,CAAC;AAED,SAAS,qBAAqB,CAA0B,KAAmB;IACzE,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,UAAU,EAAE,EAAE;QACpC,IAAI,IAAI,CAAC,UAAU,KAAK,UAAU,EAAE,CAAC;YACnC,OAAO,EAAE,GAAG,IAAI,EAAE,CAAC;QACrB,CAAC;QAED,OAAO;YACL,GAAG,IAAI;YACP,UAAU;SACX,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,8BAA8B,CAC5C,UAAuC;IAEvC,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,6BAA6B,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAEnE,OAAO,qBAAqB,CAC1B,CAAC,GAAG,UAAU,CAAC,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAC7C,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,4BAA4B,CAC1C,UAAuC,EACvC,KAAmC;IAEnC,6BAA6B,CAAC,UAAU,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAE3D,MAAM,UAAU,GAAG,8BAA8B,CAAC,UAAU,CAAC,CAAC;IAC9D,MAAM,GAAG,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;IACxD,gBAAgB,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,CAAC,mBAAmB,CAAC,CAAC;IAE9D,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;IAC/E,OAAO,qBAAqB,CAC1B,KAAK,CAAC,mBAAmB,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAE,CAAC,CACrD,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,4BAA4B,CAC1C,QAAmC;IAEnC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,6BAA6B,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;IAEjE,OAAO,qBAAqB,CAC1B,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAC3C,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,0BAA0B,CACxC,QAAmC,EACnC,KAAiC;IAEjC,6BAA6B,CAAC,QAAQ,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;IAE3D,MAAM,UAAU,GAAG,4BAA4B,CAAC,QAAQ,CAAC,CAAC;IAC1D,MAAM,GAAG,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IACpD,gBAAgB,CAAC,SAAS,EAAE,GAAG,EAAE,KAAK,CAAC,iBAAiB,CAAC,CAAC;IAE1D,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACzE,OAAO,qBAAqB,CAC1B,KAAK,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAE,CAAC,CACnD,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAChC,QAAmC,EACnC,KAA8B;IAE9B,wBAAwB,CAAC,QAAQ,EAAE,KAAK,CAAC,eAAe,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC;IAE/E,MAAM,eAAe,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IACnF,IAAI,CAAC,eAAe,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,CAAC,SAAS,wCAAwC,CAAC,CAAC;IACtF,CAAC;IAED,IAAI,eAAe,CAAC,WAAW,KAAK,KAAK,CAAC,eAAe,EAAE,CAAC;QAC1D,MAAM,IAAI,KAAK,CACb,WAAW,KAAK,CAAC,SAAS,iCAAiC,KAAK,CAAC,eAAe,EAAE,CACnF,CAAC;IACJ,CAAC;IAED,MAAM,cAAc,GAAG,4BAA4B,CACjD,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,WAAW,KAAK,KAAK,CAAC,eAAe,CAAC,CAC5E,CAAC;IACF,MAAM,oBAAoB,GAAG,cAAc,CAAC,MAAM,CAChD,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,KAAK,CAAC,SAAS,CAC5C,CAAC;IAEF,IAAI,KAAK,CAAC,eAAe,KAAK,KAAK,CAAC,aAAa,EAAE,CAAC;QAClD,MAAM,cAAc,GAAG,mBAAmB,CACxC,KAAK,CAAC,gBAAgB,EACtB,oBAAoB,CAAC,MAAM,CAC5B,CAAC;QACF,MAAM,SAAS,GAAG,CAAC,GAAG,oBAAoB,CAAC,CAAC;QAC5C,SAAS,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,EAAE;YAClC,GAAG,eAAe;YAClB,WAAW,EAAE,KAAK,CAAC,aAAa;YAChC,UAAU,EAAE,cAAc;SAC3B,CAAC,CAAC;QAEH,MAAM,UAAU,GAAG,qBAAqB,CAAC,SAAS,CAAC,CAAC;QACpD,MAAM,YAAY,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,KAAK,CAAC,SAAS,CAAE,CAAC;QAEnF,OAAO;YACL,YAAY;YACZ,gBAAgB,EAAE,UAAU;YAC5B,uBAAuB,EAAE,UAAU;YACnC,uBAAuB,EAAE,UAAU;SACpC,CAAC;IACJ,CAAC;IAED,MAAM,cAAc,GAAG,4BAA4B,CACjD,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,WAAW,KAAK,KAAK,CAAC,aAAa,CAAC,CAC1E,CAAC;IACF,MAAM,cAAc,GAAG,mBAAmB,CACxC,KAAK,CAAC,gBAAgB,EACtB,cAAc,CAAC,MAAM,CACtB,CAAC;IACF,MAAM,yBAAyB,GAAG,CAAC,GAAG,cAAc,CAAC,CAAC;IACtD,yBAAyB,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,EAAE;QAClD,GAAG,eAAe;QAClB,WAAW,EAAE,KAAK,CAAC,aAAa;QAChC,UAAU,EAAE,cAAc;KAC3B,CAAC,CAAC;IAEH,MAAM,gBAAgB,GAAG,qBAAqB,CAAC,oBAAoB,CAAC,CAAC;IACrE,MAAM,gBAAgB,GAAG,qBAAqB,CAAC,yBAAyB,CAAC,CAAC;IAC1E,MAAM,YAAY,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,KAAK,KAAK,CAAC,SAAS,CAAE,CAAC;IAEzF,OAAO;QACL,YAAY;QACZ,gBAAgB,EAAE,CAAC,GAAG,gBAAgB,EAAE,GAAG,gBAAgB,CAAC;QAC5D,uBAAuB,EAAE,gBAAgB;QACzC,uBAAuB,EAAE,gBAAgB;KAC1C,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts new file mode 100644 index 000000000..911e5ff90 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts @@ -0,0 +1,299 @@ +/** + * 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 "@fusion/core"; +import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapCreateInput, RoadmapUpdateInput, RoadmapMilestoneCreateInput, RoadmapMilestoneUpdateInput, RoadmapFeatureCreateInput, RoadmapFeatureUpdateInput, RoadmapMilestoneReorderInput, RoadmapFeatureReorderInput, RoadmapFeatureMoveInput, RoadmapMilestoneWithFeatures, RoadmapWithHierarchy, RoadmapExportBundle, RoadmapMissionPlanningHandoff, RoadmapFeatureTaskPlanningHandoff } from "../roadmap-types.js"; +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; + }]; +} +export declare class RoadmapStore extends EventEmitter { + private db; + /** + * Creates a new RoadmapStore instance. + * + * @param db - Shared Database instance (same instance used by TaskStore) + */ + constructor(db: Database); + private ensureSchema; + private generateRoadmapId; + private generateMilestoneId; + private generateFeatureId; + private rowToRoadmap; + private rowToMilestone; + private rowToFeature; + /** + * Create a new roadmap. + * + * @param input - Roadmap creation input + * @returns The created roadmap + */ + createRoadmap(input: RoadmapCreateInput): Roadmap; + /** + * Get a roadmap by ID. + * + * @param id - Roadmap ID + * @returns The roadmap, or undefined if not found + */ + getRoadmap(id: string): Roadmap | undefined; + /** + * List all roadmaps, ordered by creation date (newest first). + * + * @returns Array of roadmaps + */ + listRoadmaps(): Roadmap[]; + /** + * 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; + /** + * Delete a roadmap and all its milestones/features (cascading). + * + * @param id - Roadmap ID + * @throws Error if roadmap not found + */ + deleteRoadmap(id: string): void; + /** + * 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; + /** + * Get a milestone by ID. + * + * @param id - Milestone ID + * @returns The milestone, or undefined if not found + */ + getMilestone(id: string): RoadmapMilestone | undefined; + /** + * 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[]; + /** + * 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; + /** + * Delete a milestone and all its features (cascading). + * + * @param id - Milestone ID + * @throws Error if milestone not found + */ + deleteMilestone(id: string): void; + /** + * 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; + /** + * Get a feature by ID. + * + * @param id - Feature ID + * @returns The feature, or undefined if not found + */ + getFeature(id: string): RoadmapFeature | undefined; + /** + * 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[]; + /** + * 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; + /** + * Delete a feature. + * + * @param id - Feature ID + * @throws Error if feature not found + */ + deleteFeature(id: string): void; + /** + * 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[]; + /** + * 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[]; + /** + * 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[]; + }; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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[]; +} +//# sourceMappingURL=roadmap-store.d.ts.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map new file mode 100644 index 000000000..09fb9d29e --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-store.d.ts","sourceRoot":"","sources":["roadmap-store.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js new file mode 100644 index 000000000..454fc4cb9 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js @@ -0,0 +1,765 @@ +/** + * 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 { applyRoadmapMilestoneReorder, applyRoadmapFeatureReorder, moveRoadmapFeature, } from "./roadmap-ordering.js"; +// ── RoadmapStore Class ────────────────────────────────────────────── +export class RoadmapStore extends EventEmitter { + db; + /** + * Creates a new RoadmapStore instance. + * + * @param db - Shared Database instance (same instance used by TaskStore) + */ + constructor(db) { + super(); + this.db = db; + this.setMaxListeners(50); + this.ensureSchema(); + } + ensureSchema() { + 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 + ); + + 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 + ); + + 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 + ); + + CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder + ON roadmap_milestones(roadmapId, orderIndex, createdAt, id); + + CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder + ON roadmap_features(milestoneId, orderIndex, createdAt, id); + `); + } + // ── ID Generators ─────────────────────────────────────────────────── + generateRoadmapId() { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `RM-${timestamp.toString(36).toUpperCase()}-${random}`; + } + generateMilestoneId() { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `RMS-${timestamp.toString(36).toUpperCase()}-${random}`; + } + generateFeatureId() { + 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 ─────────────────────────────────────── + rowToRoadmap(row) { + return { + id: row.id, + title: row.title, + description: row.description || undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + rowToMilestone(row) { + return { + id: row.id, + roadmapId: row.roadmapId, + title: row.title, + description: row.description || undefined, + orderIndex: row.orderIndex, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + rowToFeature(row) { + 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) { + const now = new Date().toISOString(); + const id = this.generateRoadmapId(); + const 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) { + const row = this.db.prepare("SELECT * FROM roadmaps WHERE id = ?").get(id); + if (!row) + return undefined; + return this.rowToRoadmap(row); + } + /** + * List all roadmaps, ordered by creation date (newest first). + * + * @returns Array of roadmaps + */ + listRoadmaps() { + const rows = this.db.prepare("SELECT * FROM roadmaps ORDER BY createdAt DESC").all(); + return rows.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, updates) { + const roadmap = this.getRoadmap(id); + if (!roadmap) { + throw new Error(`Roadmap ${id} not found`); + } + const updated = { + ...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) { + 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, input) { + 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 = { + 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) { + const row = this.db.prepare("SELECT * FROM roadmap_milestones WHERE id = ?").get(id); + 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) { + const rows = this.db.prepare("SELECT * FROM roadmap_milestones WHERE roadmapId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC").all(roadmapId); + return rows.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, updates) { + const milestone = this.getMilestone(id); + if (!milestone) { + throw new Error(`Milestone ${id} not found`); + } + const updated = { + ...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) { + 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, input) { + 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 = { + 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) { + const row = this.db.prepare("SELECT * FROM roadmap_features WHERE id = ?").get(id); + 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) { + const rows = this.db.prepare("SELECT * FROM roadmap_features WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC").all(milestoneId); + return rows.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, updates) { + const feature = this.getFeature(id); + if (!feature) { + throw new Error(`Feature ${id} not found`); + } + const updated = { + ...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) { + 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) { + // 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) { + // 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) { + // 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) { + 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) { + 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) { + const roadmap = this.getRoadmap(roadmapId); + if (!roadmap) { + throw new Error(`Roadmap ${roadmapId} not found`); + } + const milestones = this.listMilestones(roadmapId); + const allFeatures = []; + 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) { + 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, milestoneId, featureId) { + // 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 = { + 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) { + 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) { + // Validate roadmap exists + const roadmap = this.getRoadmap(roadmapId); + if (!roadmap) { + throw new Error(`Roadmap ${roadmapId} not found`); + } + const milestones = this.listMilestones(roadmapId); + const handoffs = []; + for (const milestone of milestones) { + const features = this.listFeatures(milestone.id); + for (const feature of features) { + const source = { + 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; + } +} +//# sourceMappingURL=roadmap-store.js.map \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map new file mode 100644 index 000000000..3dcd5fde5 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map @@ -0,0 +1 @@ +{"version":3,"file":"roadmap-store.js","sourceRoot":"","sources":["roadmap-store.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts index 2678fa171..f4b05bd9c 100644 --- a/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts +++ b/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts @@ -112,6 +112,47 @@ export class RoadmapStore extends EventEmitter { constructor(private db: Database) { super(); this.setMaxListeners(50); + this.ensureSchema(); + } + + private ensureSchema(): void { + 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 + ); + + 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 + ); + + 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 + ); + + CREATE INDEX IF NOT EXISTS idxRoadmapMilestonesRoadmapOrder + ON roadmap_milestones(roadmapId, orderIndex, createdAt, id); + + CREATE INDEX IF NOT EXISTS idxRoadmapFeaturesMilestoneOrder + ON roadmap_features(milestoneId, orderIndex, createdAt, id); + `); } // ── ID Generators ─────────────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef1341bba..721a8ae26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,9 @@ importers: '@fusion-plugin-examples/paperclip-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-paperclip-runtime + '@fusion-plugin-examples/roadmap': + specifier: workspace:* + version: link:../../plugins/fusion-plugin-roadmap '@fusion/core': specifier: workspace:* version: link:../core @@ -768,6 +771,9 @@ importers: specifier: ^5.1.0 version: 5.2.1 devDependencies: + '@types/express': + specifier: ^5.0.5 + version: 5.0.6 '@types/node': specifier: ^25.5.2 version: 25.5.2